Files
totk-pc-recomp/tools/build_game.py
T
Carlos DonatoandClaude Opus 5.5 fb34177cfd Make the Xbox prompts for every game resolution the setup lays the UI out for (#378)
The setup wrote romfs-patches-xbox and romfs-patches-xbox-label for 16:9 only, so prompts = xbox on a 21:9, 32:9,
16:10 or 3:2 screen (the Steam Deck's 1280x800 among them) kept the game resolution at 1920x1080 (cfg.c) or, with
output_res set to the screen's shape, fell back to the Switch prompts. #979 fixed this for prompts = pc; this extends
the same mechanism to both Xbox folders, the shared-files option of #378 by way of #979's --reuse.

- The setup builds romfs-patches-xbox-<w>x<h> and romfs-patches-xbox-label-<w>x<h> for each of ASPECT_SIZES, from
  their 16:9 folder with --reuse, as it does for romfs-patches-pc-<w>x<h>.
- romfs.c overlay_init: at a game resolution of another shape any prompts' folder loads from <folder>-<w>x<h> in place
  of romfs-patches-<w>x<h>, not only the PC one; without it the Switch prompts stay, and the log names the missing
  folder and the command that makes it. romfs_shaped_pc_ready becomes romfs_shaped_prompts_ready(folder, w, h).
- cfg.c: the display-shaped output_res default applies with prompts = xbox when the folder of the pad layout
  (romfs-patches-xbox with the Nintendo layout, romfs-patches-xbox-label with any other) is there for that size.
- The menu's note for a missing shaped folder reads "off at this game resolution (no layout made for it)" in every
  menu language, since 16:9 is no longer the only shape with prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:53:49 +01:00

1002 lines
54 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.
On Linux the same steps make build/<version>/totk with the clang, lld and ninja on PATH (or the clang and lld in --toolchain DIR/bin): the
runtime's *_sdl.* and platform/linux.c take the place of its *_win.*, platform/win32.c and totk.rc, the libraries are
the system's (Vulkan, SDL3, Opus, libvpx, zstd; libstdc++ static), and the executable finds a libSDL3.so.0 beside it
first (rpath $ORIGIN). docs/building.md lists the packages.
.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. --exefs and --romfs
default to extracted/<version> in the work folder: the [work] dir of config.local.toml, where tools/extract.py writes
them, else build/. 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). Without that index.json the build stops:
the exe would stop at start. --no-romfs builds it anyway.
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 copied next to
totk.exe: SDL3.dll, libopus-0.dll and, when SDL3.dll comes from MSYS2, the libiconv-2.dll it imports, without which
the exe stops at start with 0xc000007b or a missing-DLL error), 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 # the job-object structures below; importing it works on every OS
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
import check_isa
import romfs_index
from build_shaders import stale as stale_shaders
from check_content import GENERATED_MARKER
WIN = os.name == 'nt'
if not WIN:
import resource
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')
EXE = 'totk.exe' if WIN else 'totk'
def on_path(name):
"""name on PATH, else a path that does not exist (the tool check below names it)."""
found = shutil.which(name)
return Path(found) if found else Path('/usr/bin') / name
CLANG = UCRT / 'clang.exe' if WIN else on_path('clang')
NINJA = UCRT / 'ninja.exe' if WIN else on_path('ninja')
SDL3_DLLS = [UCRT / 'SDL3.dll', UCRT / 'libiconv-2.dll', UCRT / 'libopus-0.dll'] if WIN else []
# Flags for generated code as LAS Recomp builds it (Makefile GENFLAGS, Windows), plus -march for the FP/SIMD lowering.
# --cpu v2 replaces the level for CPUs without AVX2/FMA; runtime/fma.c then supplies the correctly rounded fma the
# lowering needs (runtime/simd.h).
GEN_FLAGS = ['-O1', '-fno-strict-aliasing', '-Wno-return-type', '-Wno-unused-label', '-fvisibility=hidden',
'-march=x86-64-v3']
RT_FLAGS = ['-O2', '-g', '-Wall', '-fno-strict-aliasing', '-march=x86-64-v3']
def set_cpu(cpu):
"""Target x86-64-v3 (the default) or x86-64-v2. RT_CPU_LEVEL lets runtime/main.c's self-test check the same
instruction set the build was compiled for."""
march = f'-march=x86-64-{cpu}'
GEN_FLAGS[GEN_FLAGS.index('-march=x86-64-v3')] = march
RT_FLAGS[RT_FLAGS.index('-march=x86-64-v3')] = march
RT_FLAGS.append(f'-DRT_CPU_LEVEL={3 if cpu == "v3" else 2}')
# Profile-guided optimisation (--pgo). Both modes compile the generated code at -O2, the level the profile's inlining and
# hot/cold layout work at. A static function's profile name carries its source path by default; the base name alone
# keeps the names equal between the training build and a build from another tree (a worktree, an install).
PGO_NAMES = ['-mllvm', '-static-func-full-module-prefix=false']
PGO_INPUT = None # the .profdata of --pgo FILE: every compile depends on it, so a new profile rebuilds them
def set_pgo(pgo, out):
"""--pgo gen: instrument the generated code and the runtime; totk.exe adds its counts to
out/pgo/default_<id>.profraw every minute and at exit (runtime/platform win32.c), across runs. --pgo FILE: compile
with that merged profile (tools/pgo.py merge). Functions the profile never saw run are optimised for size."""
global PGO_INPUT
GEN_FLAGS[GEN_FLAGS.index('-O1')] = '-O2'
if pgo == 'gen':
# absolute: the instrumented exe resolves the directory against its own working directory
flags = [f'-fprofile-generate={cmd_arg((out / "pgo").resolve().as_posix())}']
RT_FLAGS.append('-DTOTK_PGO_GEN')
LINK_FLAGS.append('-fprofile-generate') # links clang_rt.profile
else:
PGO_INPUT = Path(pgo).resolve()
if not PGO_INPUT.is_file():
sys.exit(f'--pgo {pgo}: no such profile')
# unprofiled: chunks the training never entered; out-of-date and backend-plugin: functions whose code changed
# since the training, which then compile without a profile
flags = [f'-fprofile-use={cmd_path(PGO_INPUT)}', '-Wno-profile-instr-unprofiled',
'-Wno-profile-instr-out-of-date', '-Wno-backend-plugin']
GEN_FLAGS.extend(flags + PGO_NAMES)
RT_FLAGS.extend(flags + PGO_NAMES)
if WIN:
# _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_win.c and dpi_probe_win.c call.
RT_FLAGS.append('-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', '-lole32', '-lshell32', '-luuid', '-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
else:
# -no-pie: the lifted modules' function tables (hundreds of thousands of pointers) need no load-time relocation.
# $ORIGIN: a libSDL3.so.0 next to totk comes before the system's (the release's portable folder).
LINK_FLAGS = ['-fuse-ld=lld', '-no-pie', '-Wl,-z,noexecstack', "'-Wl,-rpath,$$ORIGIN'",
'"-Wl,-Map=$builddir/totk.map"']
LINK_LIBS = ['-lvulkan', '-lSDL3', '-lopus', '-lvpx', '-lzstd', '-l:libstdc++.a', '-lm', '-lpthread', '-ldl']
VMA_INCLUDE = Path('/usr/include') # vk_mem_alloc.h; Arch (AUR) vulkan-memory-allocator, Debian libvulkan-memory-allocator-dev
# Without a system vk_mem_alloc.h, the header the Windows release pins (tools/make_release.py, VMA 3.4.0) is fetched once
# into toolchain/vma: one MIT header (THIRD_PARTY_NOTICES.md), checked against its hash.
VMA_FETCH = ('https://raw.githubusercontent.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator/v3.4.0/include/vk_mem_alloc.h',
'8487b7995ad3b263eb73bc5b9a77d71aa69b6bef5d58a715c02d2663afd81f1a')
VMA_LOCAL = ROOT / 'toolchain' / 'vma'
def vma_include():
"""The directory holding vk_mem_alloc.h: the system's, else toolchain/vma, fetched there on first use."""
if (VMA_INCLUDE / 'vk_mem_alloc.h').is_file():
return VMA_INCLUDE
dst = VMA_LOCAL / 'vk_mem_alloc.h'
if not dst.is_file():
url, sha = VMA_FETCH
print(f'build_game: no {VMA_INCLUDE / "vk_mem_alloc.h"}; fetching VMA 3.4.0 into {VMA_LOCAL}')
try:
data = urllib.request.urlopen(url, timeout=60).read()
except OSError as e:
sys.exit(f'build_game: fetching {url} failed ({e}); install VMA or put vk_mem_alloc.h in {VMA_LOCAL}')
if hashlib.sha256(data).hexdigest() != sha:
sys.exit(f'build_game: {url} does not match the pinned hash')
VMA_LOCAL.mkdir(parents=True, exist_ok=True)
dst.write_bytes(data)
return VMA_LOCAL
# --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', '-lole32', '-lshell32', '-luuid', '-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). mouse_direct.c also registers host callbacks relative to that version's patch page.
INSTALL_SIDE = {'romfs.c', 'patches.c', 'ov_zstd.c', 'mouse_direct.c'}
LIB_NAME = 'libtotkrt.a'
AR = UCRT / 'llvm-ar.exe' if WIN else on_path('llvm-ar')
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', 'v110': '1.1.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 every romfs-patches* folder of build/<version> that has a
manifest, the same way when it differs: romfs-patches, romfs-patches-xbox, romfs-patches-xbox-label and
romfs-patches-pc (the same with Xbox or keyboard and mouse button prompts, make_romfs_patches.py --prompts) and
the ones of another shape, romfs-patches-<w>x<h> and romfs-patches-<style>-<w>x<h> (make_romfs_patches.py
--aspect), which runtime/romfs.c overlay_init needs for a game resolution that is not 16:9."""
names = sorted(p.name for p in (ROOT / 'build' / version).glob('romfs-patches*') if p.is_dir())
for name in names:
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 work_dir():
"""The work folder: the [work] dir of config.local.toml (relative to the checkout), where tools/extract.py writes
extracted/; build/ without the file or the key."""
cfg = ROOT / 'config.local.toml'
if not cfg.is_file():
return ROOT / 'build'
import tomllib
return ROOT / tomllib.loads(cfg.read_text(encoding='utf-8')).get('work', {}).get('dir', 'build')
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 other_os(name):
"""A runtime file for another OS: Win32 ones (*_win.*, platform/win32.c, totk.rc) off Windows, the SDL window's
(*_sdl.*) and platform/linux.c on Windows."""
stem = name.rsplit('.', 1)[0]
if WIN:
return stem.endswith('_sdl') or name == 'linux.c'
return stem.endswith('_win') or name in ('win32.c', 'totk.rc')
def runtime_sources(skip=()):
"""Every runtime/*.c, *.cpp, platform/*.c, *.rc, the in-game menu (ui/*.cpp) and Dear ImGui of this OS 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 and not other_os(p.name))
def install_stamp(out, runtime_lib):
"""Write obj/runtime/inputs.sha256, one hash over the runtime library, the INSTALL_SIDE sources and every runtime
header, and return its path. The release zip dates every file 1980-01-01 (make_release.py write_zip), so a new
release extracted over an old Setup folder has sources and a library older than the objects and the totk.exe the
last install left there. Ninja compares mtimes only: it kept the old install-side objects and linked them with the
new library (undefined symbols), or skipped the link and kept the old totk.exe. The install-side objects and the
link depend on this file, which is rewritten only when the hash changes, so new contents rebuild them whatever the
file dates are."""
files = sorted((p for p in RUNTIME.rglob('*') if p.is_file() and
(p.suffix in ('.h', '.inc') or (p.parent == RUNTIME and p.name in INSTALL_SIDE))),
key=lambda p: p.relative_to(RUNTIME).as_posix())
h = hashlib.sha256()
for p in files:
h.update(f'{p.relative_to(RUNTIME).as_posix()} {file_sha256(p)}\n'.encode())
h.update(f'{LIB_NAME} {file_sha256(runtime_lib)}\n'.encode())
path = out / 'obj' / 'runtime' / 'inputs.sha256'
write_if_changed(path, h.hexdigest() + '\n')
return path
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, stamp=None):
"""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. stamp: the
install_stamp file, an extra input of the runtime objects and of the link."""
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 WIN else 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 = {}
pgo_dep = f' | {ninja_path(PGO_INPUT)}' if PGO_INPUT else ''
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)}{pgo_dep}')
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:
stamp_dep = (' | ' if not pgo_dep else ' ') + ninja_path(stamp) if stamp else ''
lines.append(f'build {ninja_path(o)}: cc_rt {ninja_path(src)}{pgo_dep}{stamp_dep}')
rt.append(o)
objs['runtime'] = rt
every = [o for v in objs.values() for o in v]
exe = out / 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 '') + (f' {ninja_path(stamp)}' if stamp 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', '')) if WIN else dict(os.environ)
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).
Off Windows: the largest resident set of one child process, and 0 for all of them (no job accounting)."""
if not WIN:
t0 = time.monotonic()
code = subprocess.call(cmd, cwd=ROOT, env=env)
peak = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss * 1024 # KiB on Linux
return code, time.monotonic() - t0, peak, 0
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():
if not WIN:
return os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES')
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 elf_sections(path):
"""{section name: size} of the allocated sections of an ELF64 file, and the extent they span in memory."""
with open(path, 'rb') as f:
head = f.read(64)
shoff = int.from_bytes(head[0x28:0x30], 'little')
shentsize, shnum, shstrndx = (int.from_bytes(head[o:o + 2], 'little') for o in (0x3A, 0x3C, 0x3E))
f.seek(shoff)
table = f.read(shentsize * shnum)
sh = [table[i * shentsize:(i + 1) * shentsize] for i in range(shnum)]
f.seek(int.from_bytes(sh[shstrndx][0x18:0x20], 'little'))
names = f.read(int.from_bytes(sh[shstrndx][0x20:0x28], 'little'))
out, lo, hi = {}, None, 0
for s in sh:
flags = int.from_bytes(s[0x08:0x10], 'little')
addr, size = int.from_bytes(s[0x10:0x18], 'little'), int.from_bytes(s[0x20:0x28], 'little')
if not flags & 2 or not size: # SHF_ALLOC
continue
name = names[int.from_bytes(s[0:4], 'little'):].split(b'\0', 1)[0].decode(errors='replace')
out[name] = out.get(name, 0) + size
lo = addr if lo is None else min(lo, addr)
hi = max(hi, addr + size)
return out, hi - (lo or 0)
def objdump_tool():
"""The disassembler for tools/check_isa.py: llvm-objdump of --toolchain, else MSYS2's or the one on PATH."""
suffix = '.exe' if WIN else ''
for d in (TOOLCHAIN_BIN, UCRT if WIN else None):
for name in ('llvm-objdump', 'objdump'):
if d and (d / (name + suffix)).is_file():
return str(d / (name + suffix))
return check_isa.find_objdump()
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('--cpu', choices=('v2', 'v3'), default='v3',
help='target CPU level: v3 (AVX2/FMA, default) or v2 (SSE4.2, for older CPUs; slower)')
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 <work dir>/extracted/<version>/exefs, the work "
"dir being the [work] dir of config.local.toml, else build)")
ap.add_argument('--romfs', type=Path, help="totk.exe's default RomFS directory with romfs.bin and index.json "
'(default <work dir>/extracted/<version>/romfs)')
ap.add_argument('--no-romfs', action='store_true',
help='build even when the RomFS directory has no index.json (the exe then stops at start)')
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; on Linux an LLVM directory (bin/clang, '
'bin/llvm-ar) instead of the clang on PATH')
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('--sysroot', type=Path, help='Linux: the glibc headers and libraries to compile and link against '
'instead of the host\'s (the release\'s llvm/sysroot)')
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('--pgo', metavar='gen|FILE',
help='profile-guided optimisation, generated code at -O2: gen builds an instrumented totk.exe '
'(needs --toolchain on Windows: MSYS2 clang ships no profile runtime); FILE builds with that '
'.profdata (tools/pgo.py merge)')
ap.add_argument('--expected', type=Path,
help='expected hashes (default tools/data/expected-<display version>.json)')
args = ap.parse_args()
set_cpu(args.cpu)
gen = (args.gen or ROOT / 'generated' / args.version).resolve()
out = (args.out or ROOT / 'build' / args.version).resolve()
if args.pgo:
if args.pgo == 'gen' and WIN and not args.toolchain:
sys.exit('--pgo gen: MSYS2 UCRT64 clang ships no profile runtime (clang_rt.profile); pass --toolchain with '
'an llvm-mingw directory')
set_pgo(args.pgo, out)
extracted = None if args.exefs and args.romfs else work_dir() / '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()}
has_romfs = (paths['romfs'] / 'index.json').is_file()
if not (has_romfs or args.no_romfs or args.make_runtime_lib or args.compile_only):
sys.exit(f'{paths["romfs"] / "index.json"} not found: without the RomFS the game stops at start. Run '
'tools/extract.py (it writes extracted/<version> into the [work] dir of config.local.toml), pass '
'--romfs DIR, or --no-romfs to build without it')
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' if WIN else ''))).is_file():
NINJA = ROOT / 'bin' / ('ninja' + ('.exe' if WIN else '')) # the release folder (tools/make_release.py)
dlls = SDL3_DLLS
suffix = '.exe' if WIN else ''
if args.toolchain:
TOOLCHAIN_BIN = (args.toolchain / 'bin').resolve()
CLANG = TOOLCHAIN_BIN / f'clang{suffix}'
AR = TOOLCHAIN_BIN / f'llvm-ar{suffix}'
WINDRES = TOOLCHAIN_BIN / 'llvm-windres.exe'
if args.toolchain and WIN or args.deps and (args.toolchain or not WIN): # Linux: --deps only, never deps-mingw
DEPS = (args.deps or DEFAULT_DEPS).resolve()
dlls = sorted((DEPS / 'bin').glob('*.dll')) if WIN else []
if DEPS and not WIN:
# A release (--deps): Opus, libvpx and zstd go into the executable, so it needs no more than libc, Vulkan and
# the libSDL3.so.0 beside it
for lib in ('opus', 'vpx', 'zstd'):
LINK_LIBS[LINK_LIBS.index(f'-l{lib}')] = f'-l:lib{lib}.a'
if args.sysroot and not WIN:
# Every compile and the link: the game then needs no newer glibc than the sysroot's (the Steam Runtime's)
sysroot = ['--target=x86_64-linux-gnu', f'--sysroot={cmd_path(args.sysroot.resolve())}']
GEN_FLAGS[:0] = sysroot
RT_FLAGS[:0] = sysroot
LINK_FLAGS[:0] = sysroot
for tool in (CLANG, NINJA):
if not tool.is_file():
sys.exit(f'{tool} not found: install ' + ('MSYS2 UCRT64 clang, lld and ninja' if WIN else '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]
# --make-runtime-lib builds runtime/ only, so it never reads the generated chunks
mods = {} if args.make_runtime_lib else {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 has_romfs:
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"}; {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')
# The library leaves INSTALL_SIDE out, so a release that ships without one of them links with undefined symbols.
missing = sorted(n for n in INSTALL_SIDE - skip if not (RUNTIME / n).is_file())
if runtime_lib and missing:
sys.exit(f'build_game: {", ".join(missing)} missing from {flag_path(RUNTIME)}: the release must ship every '
f'INSTALL_SIDE source beside {runtime_lib.name}')
stamp = install_stamp(out, runtime_lib) if runtime_lib else None
text, objs, exe = ninja_file(gen, out, mods, jobs, skip, pairs is not None, runtime_lib,
args.make_runtime_lib, stamp)
write_if_changed(out / 'build.ninja', text)
env = dict(os.environ)
if WIN or TOOLCHAIN_BIN:
env['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' + (f', {mib(peak_all)} all processes' if WIN else ''))
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.cpu == 'v2':
# Inline assembly, target attributes and intrinsics pass -march=x86-64-v2 by; one vmovdqa of a YMM register
# stops a CPU without AVX (tools/check_isa.py)
fail = check_isa.check_objects(objs['runtime'], objdump_tool(), out / 'obj' / 'runtime' / 'isa-v2.json')
if fail:
sys.exit(f'build_game: {fail}')
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) if WIN else elf_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()