Files
totk-pc-recomp/tools/make_release_linux.py
T
Valiantsin DzerakhandClaude Opus 5.5 101132a6c8 Build the Linux release again after build_sources gained its profile argument
282130a made make_release.build_sources take the release's profiles and left the call in make_release_linux.py with
one argument, so the Linux release stopped with a TypeError. The Linux setup compiles without a profile, so it passes
none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:16:41 +03:00

595 lines
32 KiB
Python

"""Assemble the Linux release folder of docs/installer.md ("Linux release") and pack it as an AppImage.
.venv/bin/python tools/make_release_linux.py --out build/release/TotK-Setup-linux \\
[--appimage build/release/TotK-Setup-x86_64.AppImage]
.venv/bin/python tools/make_release_linux.py --out build/release/TotK-Setup-linux --prove
The same model as the Windows release (tools/make_release.py, whose download cache, unpacking and checks this reuses): the
folder carries the setup and a toolchain, never game code; the player's machine builds the game. Everything that runs on
the player's machine is built against the Steam Runtime 3 "sniper" SDK sysroot (glibc 2.31), so the AppImage and the
game it builds run on SteamOS and on any distribution at least as new. Needs network access on the first run, make,
the .NET 10 SDK and Python 3.12+ with Pillow (.venv). The folder holds:
python/ python-build-standalone 3.14 (with tkinter) + the packages of tools/requirements-installer.txt; a .pth
file puts the folder, tools/ and recomp/ on sys.path
llvm/ LLVM 23 (clang, lld, llvm-ar, the clang resource headers) and llvm/sysroot: the files of the sniper SDK
sysroot the game's compile and link read, nothing else (recorded while building the runtime library)
runtime/ libtotkrt.a and libtotkrt-v2.a (build_game.py --make-runtime-lib --sysroot, x86-64-v3 and v2), the
runtime headers, romfs.c, patches.c and ov_zstd.c (compiled per install), totk.png (the window icon),
deps/ (Vulkan headers 1.4.363, VMA, zstd built static; SDL3, Opus and libvpx come from the sysroot)
bin/ totk_xlate (+ .id), hactool, ninja, libSDL3.so.0 (the sysroot's; the game finds it beside itself)
recomp/, tools/, data/, installer/, licenses/, THIRD_PARTY_NOTICES.md, totk.ini.example, VERSION
AppRun, totk-setup.desktop, totk-setup.png the AppImage entry: python/bin/python3 -m installer.gui
--appimage packs the folder with appimagetool and the static type-2 runtime (no libfuse2 on the host). --prove compiles
the generated C and links build/release-linux-check/totk with the folder's tools only, then runs the probes beside it.
Like tools/make_release.py, both need one game version extracted and lifted first (generated/v121, else v143).
The folder contains nothing derived from the game: check_content runs over it before the script finishes, then the
folder's own python runs a check-only installer pass and the window's --selftest.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import build_game # noqa: E402
import make_release as mr # noqa: E402
ROOT = mr.ROOT
MAIN_ROOT = mr.MAIN_ROOT
WORK = mr.WORK
CHECK = MAIN_ROOT / 'build' / 'release-linux-check'
LLVM_VERSION = '23.1.4'
LLVM_MAJOR = LLVM_VERSION.split('.')[0]
SNIPER = '3.0.20261021.266025'
SNIPER_URL = 'https://repo.steampowered.com/steamrt-images-sniper/snapshots'
PINS = {
'llvm-linux': (f'https://github.com/llvm/llvm-project/releases/download/llvmorg-{LLVM_VERSION}/'
f'LLVM-{LLVM_VERSION}-Linux-X64.tar.xz',
'25d814fda50d5fb0084b42be09605d8b1595d49d5e2e03a3345d7e112ff6f9f3'),
'llvm-license': (f'https://raw.githubusercontent.com/llvm/llvm-project/llvmorg-{LLVM_VERSION}/llvm/LICENSE.TXT',
'8d85c1057d742e597985c7d4e6320b015a9139385cff4cbae06ffc0ebe89afee'), # the binary release carries none
'sniper-sdk': (f'{SNIPER_URL}/{SNIPER}/com.valvesoftware.SteamRuntime.Sdk-amd64%2Ci386-sniper-sysroot.tar.gz',
'8f71947035f97925a311b2ed753f22efcf2f13d6f1631945b8e537f8903ce2cf'),
'python-linux': ('https://github.com/astral-sh/python-build-standalone/releases/download/20261026/'
'cpython-3.14.10%2B20261026-x86_64-unknown-linux-gnu-install_only.tar.gz',
'1b38024924eb7f4cf0cab45d7b871d1f1991354190fccd2625ac7529a43786e2'),
'tcl-linux-src': ('https://github.com/tcltk/tcl/archive/refs/tags/core-9-0-4.tar.gz',
'8c01bac92a9a8ce271b63d3aa28f09cde2c6762d6719ca671cdc8aee25391234'),
'tk-linux-src': ('https://github.com/tcltk/tk/archive/refs/tags/core-9-0-4.tar.gz',
'ab9bd89fcff2e1248b7a9c097035f817fa0725285028c95686fdb92db65bc148'),
'ninja-linux': ('https://github.com/ninja-build/ninja/releases/download/v1.13.2/ninja-linux.zip',
'5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6'),
'zstd-src': ('https://github.com/facebook/zstd/releases/download/v1.5.7/zstd-1.5.7.tar.gz',
'eb33e51f49a15e023950cd7825ca74a4a2b43db8354825ac24fc1b7ee09e6fa3'),
'vulkan-headers-src': ('https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/v1.4.363.tar.gz',
'cbaf687d3c59b9666fe080e7f8396b6b4f4d344a768ee6b337c59852f4526b68'),
'vma-header': build_game.VMA_FETCH,
# appimagetool and the type-2 runtime from release tags, not "continuous", whose uploads change under the same
# URL and would stop the build at the hash check
'appimagetool': ('https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage',
'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0'),
'appimage-runtime': ('https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64',
'2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d'),
}
mr.PINS.update(PINS) # one cache (toolchain/release-dl) and one fetch for both releases
# What clang, lld and the runtime build need from the LLVM release (the rest of its 8 GB stays packed).
LLVM_MEMBERS = [f'bin/clang-{LLVM_MAJOR}', 'bin/clang', 'bin/clang++', 'bin/lld', 'bin/ld.lld', 'bin/llvm-ar',
'bin/llvm-ranlib', 'bin/llvm-objcopy', 'bin/llvm-strip', f'lib/clang/{LLVM_MAJOR}/include']
# The parts of the sniper SDK a C/C++ compile and link for x86_64 can read.
SNIPER_MEMBERS = ['usr/include', 'usr/share/pkgconfig', 'usr/lib/x86_64-linux-gnu', 'usr/lib/gcc/x86_64-linux-gnu',
'lib/x86_64-linux-gnu', 'lib64', 'usr/share/doc/libsdl3-0', 'usr/share/doc/libopus0',
'usr/share/doc/libvpx6', 'usr/share/doc/libc6', 'usr/share/doc/gcc-10-base',
'usr/share/doc/libvulkan1',
'usr/share/doc/libxft2', 'usr/share/doc/libxrender1']
# glibc's link libraries and their scripts (lib<name>.so, .a, _nonshared.a), and the GCC runtime pieces clang links.
GLIBC_LIBS = ['c', 'm', 'mvec', 'dl', 'pthread', 'rt', 'util']
GCC_LIBS = {'libgcc.a', 'libgcc_eh.a', 'libgcc_s.so', 'libstdc++.a', 'libsupc++.a'}
# The libraries of a release link (build_game.py LINK_LIBS with --deps: Opus, libvpx and zstd static).
RELEASE_LIBS = ['-lvulkan', '-lSDL3', '-l:libopus.a', '-l:libvpx.a', '-l:libzstd.a', '-l:libstdc++.a', '-lm',
'-lpthread', '-ldl']
def run(cmd, **kw):
return mr.run(cmd, **kw)
def tar_extract(name, dest, members, strip=0):
"""Extract only members (path prefixes inside the archive) of a pinned tarball with the system tar, once."""
stamp = dest / '.unpacked'
if stamp.is_file():
return dest
shutil.rmtree(dest, ignore_errors=True)
dest.mkdir(parents=True)
path = mr.fetch(name)
t0 = time.time()
run(['tar', '-xf', path, '-C', dest, f'--strip-components={strip}', '--wildcards', '--no-wildcards-match-slash',
*members])
print(f'{name}: unpacked in {time.time() - t0:.0f} s')
stamp.write_text('')
return dest
# ---- llvm/ and the sysroot -------------------------------------------------------------------------------------------
def build_llvm(out):
top = f'LLVM-{LLVM_VERSION}-Linux-X64'
src = tar_extract('llvm-linux', WORK / 'llvm-linux', [f'{top}/{m}' for m in LLVM_MEMBERS], strip=1)
dst = out / 'llvm'
shutil.copytree(src, dst, symlinks=True, ignore=shutil.ignore_patterns('.unpacked'))
strip(dst / 'bin', dst / 'bin' / 'llvm-strip')
r = subprocess.run([str(dst / 'bin' / 'clang'), '--version'], capture_output=True, text=True)
if r.returncode:
sys.exit(f'{dst / "bin" / "clang"} does not start:\n{r.stderr}')
print(r.stdout.splitlines()[0])
return dst
def strip(where, tool):
"""Drop the debug symbols of the ELF files under where (the LLVM and Python releases ship them: clang 270 MB)."""
for p in sorted(where.rglob('*')):
if p.is_file() and not p.is_symlink() and p.read_bytes()[:4] == b'\x7fELF':
subprocess.run([str(tool), '--strip-unneeded', str(p)], check=True)
def full_sysroot():
"""The sniper SDK's x86_64 compile and link files in toolchain/release-work/sniper, absolute symlinks made relative
(the SDK points some at /lib and /usr/lib, which outside its container are the host's)."""
root = WORK / 'sniper'
if (root / '.relinked').is_file() and (root / 'usr/share/doc/libxft2/copyright').is_file():
return root
# Older cached SDK extractions omitted the font libraries' architecture-independent pkg-config files and the
# libXft and libXrender copyright files.
(root / '.unpacked').unlink(missing_ok=True)
tar_extract('sniper-sdk', root, SNIPER_MEMBERS)
for p in root.rglob('*'):
if p.is_symlink() and os.readlink(p).startswith('/'):
target = root / os.readlink(p).lstrip('/')
p.unlink()
p.symlink_to(os.path.relpath(target, p.parent))
(root / '.relinked').write_text('')
return root
def sysroot_cc(llvm, sysroot):
return f'{llvm / "bin" / "clang"} --target=x86_64-linux-gnu --sysroot={sysroot} -fuse-ld=lld'
# ---- runtime/deps, bin/ --------------------------------------------------------------------------------------------
def build_deps(out, llvm, sysroot):
d = out / 'runtime' / 'deps'
(d / 'include' / 'vma').mkdir(parents=True, exist_ok=True)
(d / 'lib').mkdir(parents=True, exist_ok=True)
vk = tar_extract('vulkan-headers-src', WORK / 'vulkan-headers', ['Vulkan-Headers-1.4.363/include',
'Vulkan-Headers-1.4.363/LICENSE.md'], strip=1)
for sub in ('vulkan', 'vk_video'):
shutil.copytree(vk / 'include' / sub, d / 'include' / sub)
shutil.copy2(mr.fetch('vma-header'), d / 'include' / 'vma' / 'vk_mem_alloc.h')
zs = tar_extract('zstd-src', WORK / 'zstd', ['zstd-1.5.7'], strip=1)
lib = zs / 'lib'
if not (lib / 'libzstd.a').is_file():
run(['make', '-C', lib, f'-j{os.cpu_count() or 4}', 'libzstd.a', f'CC={sysroot_cc(llvm, sysroot)}',
f'AR={llvm / "bin" / "llvm-ar"}', 'ZSTD_LEGACY_SUPPORT=0', 'MOREFLAGS=-O2'], stdout=subprocess.DEVNULL)
shutil.copy2(lib / 'libzstd.a', d / 'lib' / 'libzstd.a')
for name in ('zstd.h', 'zstd_errors.h'):
shutil.copy2(lib / name, d / 'include' / name)
def build_bin(out, llvm, sysroot):
b = out / 'bin'
b.mkdir(parents=True, exist_ok=True)
zip_dir = mr.unpack('ninja-linux')
shutil.copy2(zip_dir / 'ninja', b / 'ninja')
(b / 'ninja').chmod(0o755)
hac = WORK / 'hactool-linux'
run([sys.executable, ROOT / 'tools' / 'build_hactool.py', '--src', WORK / 'hactool-src', '--out', hac,
'--cc', sysroot_cc(llvm, sysroot)])
shutil.copy2(hac / 'hactool', b / 'hactool')
xl = WORK / 'xlate-linux'
run([sys.executable, ROOT / 'tools' / 'build_xlate.py', '--out', xl, '--llvm', llvm, '--sysroot', sysroot])
shutil.copy2(xl / 'totk_xlate', b / 'totk_xlate')
shutil.copy2(xl / 'totk_xlate.id', b / 'totk_xlate.id')
sdl = (sysroot / 'usr' / 'lib' / 'x86_64-linux-gnu' / 'libSDL3.so.0').resolve()
shutil.copy2(sdl, b / 'libSDL3.so.0')
# ---- runtime/ ----------------------------------------------------------------------------------------------------------
def runtime_lib(out, llvm, sysroot):
"""libtotkrt.a (x86-64-v3) and libtotkrt-v2.a (x86-64-v2), so a machine without AVX2 can install too."""
version = mr.lifted_version()
rt = out / 'runtime'
for cpu, name in (('v3', 'libtotkrt.a'), ('v2', 'libtotkrt-v2.a')):
build = CHECK / f'rtlib-{cpu}'
shutil.rmtree(build, ignore_errors=True)
run([sys.executable, ROOT / 'tools' / 'build_game.py', '--make-runtime-lib', '--toolchain', llvm,
'--sysroot', sysroot, '--cpu', cpu, '--deps', out / 'runtime' / 'deps', '--ninja', out / 'bin' / 'ninja',
'--version', version, '--gen', MAIN_ROOT / 'generated' / version, '--out', build])
shutil.copy2(build / 'libtotkrt.a', rt / name)
for p in sorted((ROOT / 'runtime').rglob('*')):
if p.suffix in mr.RUNTIME_SUFFIXES and p.is_file():
(rt / p.relative_to(ROOT / 'runtime')).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, rt / p.relative_to(ROOT / 'runtime'))
for name in build_game.INSTALL_SIDE:
shutil.copy2(ROOT / 'runtime' / name, rt / name)
from PIL import Image # Tk loads no .ico off Windows: the window and the AppImage take a PNG
with Image.open(ROOT / 'runtime' / 'totk.ico') as ico:
ico.size = max(ico.ico.sizes())
ico.convert('RGBA').save(rt / 'totk.png')
return build
def used_sysroot_files(out, llvm, sysroot, rtlib_build):
"""The sysroot files a game build reads: the headers of the runtime's compile (its .d files), of the install-side
sources and of hostcheck, and every library and object of a link (lld --trace) of the runtime library with the
flags and libraries build_game.py uses, its references to the generated code left unresolved."""
used = set()
for dep in rtlib_build.rglob('*.d'):
used.update(re.findall(r'\S+', dep.read_text(encoding='utf-8', errors='replace').replace('\\\n', ' ')))
probe = CHECK / 'sysroot-probe'
shutil.rmtree(probe, ignore_errors=True)
probe.mkdir(parents=True)
cc = [llvm / 'bin' / 'clang', '--target=x86_64-linux-gnu', f'--sysroot={sysroot}', '-I', out / 'runtime',
'-I', out / 'runtime' / 'deps' / 'include', '-I', CHECK / 'rtlib']
for i, src in enumerate([*(out / 'runtime' / n for n in sorted(build_game.INSTALL_SIDE)),
ROOT / 'installer' / 'hostcheck.c']):
# romfs.c needs the per-install romfs_config.h: whatever it reads up to there still lands in the .d file
subprocess.run([str(c) for c in [*cc, '-fsyntax-only', '-MD', '-MF', probe / f'{i}.d', src]],
capture_output=True)
if (probe / f'{i}.d').is_file():
used.update(re.findall(r'\S+', (probe / f'{i}.d').read_text().replace('\\\n', ' ')))
trace = subprocess.run([str(c) for c in [
llvm / 'bin' / 'clang', '--target=x86_64-linux-gnu', f'--sysroot={sysroot}', *build_game.LINK_FLAGS[:3],
'-Wl,--trace', '-Wl,--unresolved-symbols=ignore-all', '-Wl,--whole-archive', out / 'runtime' / 'libtotkrt.a',
'-Wl,--no-whole-archive', '-L', out / 'runtime' / 'deps' / 'lib',
*RELEASE_LIBS, '-o', probe / 'trace.out']],
capture_output=True, text=True)
if trace.returncode:
sys.exit(f'sysroot trace link failed:\n{trace.stderr[-3000:]}')
used.update(line.split('(')[0] for line in trace.stdout.splitlines())
root = str(sysroot.resolve())
files = set()
for p in used:
path = Path(p)
if not path.is_absolute():
continue
for q in (path, path.resolve()): # the name read and, through symlinks, the file
if str(q).startswith(root + '/') and q.exists():
files.add(q)
# The C library and kernel headers whole: which of them a compile reads depends on its flags (at -O1 glibc's inline
# variants pull in bits/stdio.h and others), and the setup compiles the generated code, hostcheck and the
# install-side sources with flags of its own
inc = sysroot / 'usr' / 'include'
arch = inc / 'x86_64-linux-gnu'
files.update(p for p in [*inc.glob('*.h'), *arch.glob('*.h')] if p.is_file())
for d in (arch / 'bits', arch / 'sys', arch / 'gnu', arch / 'asm', inc / 'linux', inc / 'asm-generic'):
files.update(p for p in d.rglob('*') if p.is_file() or p.is_symlink())
# The C runtime whole, for PIE links (hostcheck) as well as the game's: lld's trace names the libraries a link
# script loads but not the script (libc.so, libm.so, libgcc_s.so), and clang's GCC detection needs the crt objects
lib = sysroot / 'usr' / 'lib' / 'x86_64-linux-gnu'
for name in GLIBC_LIBS:
files.update(p for p in lib.glob(f'lib{name}[._]*') if p.suffix in ('.so', '.a') or '.so' in p.name)
files.update(p for p in (sysroot / 'lib' / 'x86_64-linux-gnu').glob(f'lib{name}.so*'))
files.update(lib.glob('*crt*.o'))
files.update((sysroot / 'lib64').glob('ld-linux*'))
files.update((sysroot / 'lib' / 'x86_64-linux-gnu').glob('ld-linux*'))
files.update((sysroot / 'lib' / 'x86_64-linux-gnu').glob('libgcc_s.so*'))
gcc = next((sysroot / 'usr' / 'lib' / 'gcc' / 'x86_64-linux-gnu').iterdir())
files.update(p for p in gcc.iterdir() if p.name.startswith('crt') or p.name in GCC_LIBS)
return files
def link_chain(path):
"""path and every link on the way to its file: libvulkan.so -> libvulkan.so.1 -> libvulkan.so.1.4.309."""
hops = [path]
while hops[-1].is_symlink() and len(hops) < 16:
hops.append(hops[-1].parent / os.readlink(hops[-1]))
return hops
def build_sysroot(out, sysroot, files):
dst = out / 'llvm' / 'sysroot'
files = {Path(os.path.normpath(h)) for f in files for h in link_chain(f)}
for f in sorted(files):
rel = f.relative_to(sysroot)
(dst / rel).parent.mkdir(parents=True, exist_ok=True)
if f.is_symlink():
if not (dst / rel).is_symlink():
(dst / rel).symlink_to(os.readlink(f))
elif not (dst / rel).exists():
shutil.copy2(f, dst / rel)
# a symlink copied above may name a file the build reads only through it
for link in [p for p in dst.rglob('*') if p.is_symlink()]:
target = (sysroot / link.relative_to(dst)).resolve()
mirrored = dst / target.relative_to(sysroot.resolve())
if not mirrored.exists():
mirrored.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(target, mirrored)
print(f'sysroot: {sum(1 for p in dst.rglob("*") if p.is_file())} files, {mr.mib(mr.dir_size(dst))}')
# ---- python/ ---------------------------------------------------------------------------------------------------------
def build_tk_xft(out, llvm, sysroot):
"""Replace standalone Python's no-Xft Tcl/Tk with the same version using system TrueType fonts (#227)."""
prefix = WORK / 'tcltk-xft'
env = dict(os.environ, CC=sysroot_cc(llvm, sysroot),
PKG_CONFIG_SYSROOT_DIR=str(sysroot),
PKG_CONFIG_LIBDIR=os.pathsep.join(str(sysroot / p) for p in
('usr/lib/x86_64-linux-gnu/pkgconfig', 'usr/share/pkgconfig')),
PKG_CONFIG_PATH='')
sources = {}
for name in ('tcl', 'tk'):
src = mr.unpack(f'{name}-linux-src') / f'{name}-core-9-0-4'
sources[name] = src
build = src / 'unix'
options = [f'--prefix={prefix}', '--enable-shared', '--disable-zipfs', '--disable-rpath']
if name == 'tk':
options += [f'--with-tcl={prefix / "lib"}', '--enable-xft', '--disable-libcups', '--disable-xss']
run([build / 'configure', *options], cwd=build, env=env)
run(['make', f'-j{os.cpu_count() or 4}'], cwd=build, env=env)
run(['make', 'install'], cwd=build, env=env)
lib = prefix / 'lib'
# A successful GUI selftest alone did not catch missing glyphs. Reject a no-Xft build before packaging it.
symbols = subprocess.run(['nm', '-D', str(lib / 'libtcl9tk9.0.so')],
capture_output=True, text=True, check=True).stdout
if not re.search(r'\bU Xft', symbols):
sys.exit('Tcl/Tk build has no Xft font rendering')
dst = out / 'python' / 'lib'
for name in ('libtcl9.0.so', 'libtcl9tk9.0.so'):
shutil.copy2(lib / name, dst / name)
# libXft is not on every desktop (the old Tk did not need it) and without it _tkinter fails to import, so the
# window would not open at all. python's RPATH ($ORIGIN/../lib) finds these beside the Tk library.
sys_lib = sysroot / 'usr' / 'lib' / 'x86_64-linux-gnu'
for pattern in ('libXft.so.2*', 'libXrender.so.1*'):
for f in sys_lib.glob(pattern):
shutil.copy2(f, dst / f.name, follow_symlinks=False)
for name in ('tcl9.0', 'tk9.0'):
shutil.rmtree(dst / name)
shutil.copytree(lib / name, dst / name, symlinks=True)
licenses = out / 'licenses'
licenses.mkdir(exist_ok=True)
for name, src in sources.items():
shutil.copy2(src / 'license.terms', licenses / f'{name}-license.terms')
def build_python(out, llvm, sysroot):
src = mr.unpack('python-linux') / 'python'
py = out / 'python'
shutil.copytree(src, py, symlinks=True,
ignore=shutil.ignore_patterns('test', 'idlelib', 'turtledemo', '__pycache__', '*.a'))
exe = py / 'bin' / 'python3'
env = dict(os.environ, PIP_DISABLE_PIP_VERSION_CHECK='1')
run([exe, '-m', 'pip', 'install', '--no-compile', '--only-binary', ':all:', '-r',
ROOT / 'tools' / 'requirements-installer.txt'], env=env, stdout=subprocess.DEVNULL)
run([exe, '-m', 'pip', 'uninstall', '-y', 'pip'], stdout=subprocess.DEVNULL) # the setup installs nothing
build_tk_xft(out, llvm, sysroot)
strip(py, out / 'llvm' / 'bin' / 'llvm-strip')
site = next(py.glob('lib/python3.*/site-packages'))
# The release folder, tools/ and recomp/ on sys.path, as the Windows python's ._pth does: `python -m installer` and
# the tools importing their siblings work from any directory
(site / 'totk-release.pth').write_text('../../../..\n../../../../tools\n../../../../recomp\n', encoding='utf-8')
check = subprocess.run([str(exe), '-c', 'import tkinter, numpy, lz4.block, cryptography, cffi, pycparser; '
'print("python ok: Tcl", tkinter.Tcl().eval("info patchlevel"), "numpy", numpy.__version__)'],
cwd='/', capture_output=True, text=True)
if check.returncode:
sys.exit('bundled python check failed:\n' + check.stderr)
print(check.stdout.strip())
# ---- licences, AppImage entry ------------------------------------------------------------------------------------------
def collect_licenses(out, sysroot):
lic = out / 'licenses'
lic.mkdir(parents=True, exist_ok=True)
doc = sysroot / 'usr' / 'share' / 'doc'
pairs = [
(mr.fetch('llvm-license'), 'llvm-LICENSE.txt'),
(doc / 'libsdl3-0' / 'copyright', 'SDL3-copyright.txt'),
(doc / 'libopus0' / 'copyright', 'opus-copyright.txt'),
(doc / 'libvpx6' / 'copyright', 'libvpx-copyright.txt'),
(doc / 'libc6' / 'copyright', 'glibc-copyright.txt'),
(doc / 'gcc-10-base' / 'copyright', 'libstdc++-copyright.txt'),
(doc / 'libvulkan1' / 'copyright', 'vulkan-loader-copyright.txt'),
(doc / 'libxft2' / 'copyright', 'libXft-copyright.txt'),
(doc / 'libxrender1' / 'copyright', 'libXrender-copyright.txt'),
(WORK / 'vulkan-headers' / 'LICENSE.md', 'vulkan-headers-LICENSE.txt'),
(WORK / 'zstd' / 'LICENSE', 'zstd-LICENSE.txt'),
(out / 'python' / 'lib' / f'python{sys_version(out)}' / 'LICENSE.txt', 'python-LICENSE.txt'),
(ROOT / 'runtime' / 'shaders' / 'ffx' / 'LICENSE.txt', 'FidelityFX-FSR-LICENSE.txt'),
(WORK / 'hactool-linux' / 'LICENSE', 'hactool-LICENSE.txt'),
(MAIN_ROOT / 'refs' / 'MacNativeGames' / 'LICENSE', 'MacNativeGames-LICENSE.txt'),
]
for src, name in pairs:
shutil.copy2(src, lic / name)
import tarfile # Debian's libvpx copyright carries no patent grant: the WebM PATENTS text from the pinned source tag
with tarfile.open(mr.fetch('libvpx-src')) as t:
(lic / 'libvpx-PATENTS.txt').write_bytes(t.extractfile('libvpx-1.17.0/PATENTS').read())
head = (out / 'runtime' / 'deps' / 'include' / 'vma' / 'vk_mem_alloc.h').read_text(encoding='utf-8').split('*/')[0]
(lic / 'vulkan-memory-allocator-LICENSE.txt').write_text(head.lstrip('/*') + '\n', encoding='utf-8')
def sys_version(out):
return next((out / 'python' / 'lib').glob('python3.*')).name.removeprefix('python')
APPRUN = '''#!/bin/sh
# The AppImage entry: the setup window, run by the bundled Python from the read-only mount (no bytecode written there).
HERE="$(dirname "$(readlink -f "$0")")"
export PYTHONDONTWRITEBYTECODE=1
export TCL_LIBRARY="$HERE/python/lib/tcl9.0"
export TK_LIBRARY="$HERE/python/lib/tk9.0"
exec "$HERE/python/bin/python3" -m installer.gui "$@"
'''
DESKTOP = '''[Desktop Entry]
Type=Application
Name=TotK Recomp Setup
Comment=Builds Tears of the Kingdom for this PC from your own game files
Exec=AppRun
Icon=totk-setup
Categories=Game;
Terminal=false
'''
def build_entry(out):
(out / 'AppRun').write_text(APPRUN, encoding='utf-8')
(out / 'AppRun').chmod(0o755)
(out / 'totk-setup.desktop').write_text(DESKTOP, encoding='utf-8')
shutil.copy2(out / 'runtime' / 'totk.png', out / 'totk-setup.png')
(out / '.DirIcon').symlink_to('totk-setup.png')
def write_appimage(out, path):
tool = mr.fetch('appimagetool')
tool.chmod(0o755)
runtime = mr.fetch('appimage-runtime')
path.parent.mkdir(parents=True, exist_ok=True)
env = dict(os.environ, ARCH='x86_64', APPIMAGE_EXTRACT_AND_RUN='1')
run([tool, '--no-appstream', '--runtime-file', runtime, out, path], env=env)
print(f'{path}: {mr.mib(path.stat().st_size)}')
# ---- checks -------------------------------------------------------------------------------------------------------------
# The newest glibc any program or library in the folder may need: the official LLVM binaries and the cryptography
# wheel are built against 2.34 (Ubuntu 22.04, Debian 12, SteamOS 3 have it); the game itself links the sysroot's 2.31.
GLIBC_FLOOR = (2, 34)
def check_glibc(out):
"""Every ELF file in the folder (llvm/sysroot aside: glibc itself, read by the link) needs glibc GLIBC_FLOOR or
older; prints which files set the floor."""
newest, by = (0, 0), []
for p in sorted(out.rglob('*')):
if not p.is_file() or p.is_symlink() or out / 'llvm' / 'sysroot' in p.parents:
continue
with open(p, 'rb') as f:
if f.read(4) != b'\x7fELF':
continue
text = subprocess.run(['readelf', '-V', str(p)], capture_output=True, text=True).stdout
need = max((tuple(map(int, v.split('.'))) for v in re.findall(r'GLIBC_(\d+\.\d+(?:\.\d+)?)', text)),
default=(0, 0))
if need > newest:
newest, by = need, []
if need == newest:
by.append(str(p.relative_to(out)))
floor = '.'.join(map(str, GLIBC_FLOOR))
if newest > GLIBC_FLOOR:
sys.exit(f'glibc check: {", ".join(by[:8])} need glibc {".".join(map(str, newest))}, above {floor}')
print(f'glibc check: the folder needs glibc {".".join(map(str, newest))} (floor {floor}), set by '
f'{", ".join(by[:6])}{" and others" if len(by) > 6 else ""}')
def check_bundle(out):
"""The folder's own python runs the installer without game files and the window's selftest."""
import json
chk = CHECK / 'bundle'
shutil.rmtree(chk, ignore_errors=True)
py = out / 'python' / 'bin' / 'python3'
env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1')
r = subprocess.run([str(py), '-m', 'installer', '--lang', 'en-US', '--dest', str(chk / 'install'), '--temp',
str(chk / 'temp'), '--root', str(out)], cwd='/', env=env, capture_output=True, text=True,
timeout=600)
events = [json.loads(line) for line in r.stdout.splitlines() if line.startswith('{')]
errors = [e['id'] for e in events if e['type'] == 'error']
started = any(e['type'] == 'start' and e.get('release') == (out / 'VERSION').read_text().strip() for e in events)
steps = [e['phase'] for e in events if e['type'] == 'progress']
if errors != ['check.inputs_needed'] or not started or 'inputs' not in steps:
sys.exit(f'bundle check: exit {r.returncode}, errors {errors}, release read {started}\n{r.stdout[-2000:]}\n'
f'{r.stderr[-2000:]}')
print(f'bundle check: the installer ran the host check from the folder and stopped at the game files '
f'(exit {r.returncode})')
run([py, '-c', 'import tkinter as tk; from tkinter import font; '
'root = tk.Tk(); root.withdraw(); '
'fonts = [font.nametofont("TkDefaultFont"), font.Font(family="DejaVu Sans"), '
'font.Font(family="DejaVu Sans Mono")]; '
'missing = [(f.actual("family"), c) for f in fonts for c in "УстановкаЁёЙй" if f.measure(c) <= 0]; '
'root.destroy(); assert not missing, f"Cyrillic glyphs missing: {missing}"'], cwd='/', env=env)
r = subprocess.run([str(py), '-m', 'installer.gui', '--selftest', '--root', str(out)], cwd='/', env=env,
capture_output=True, text=True, timeout=300)
if r.returncode:
sys.exit(f'bundle check: window selftest exit {r.returncode}\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}')
print('bundle check: the window built every screen in both languages and closed')
def prove(out, cpu='v3'):
"""Compile the generated C and link totk with the folder's tools only, then run the probes beside it."""
out = out.resolve()
version = mr.lifted_version()
gen = MAIN_ROOT / 'generated' / version
ext = MAIN_ROOT / 'build' / 'extracted' / version
chk = CHECK / 'game'
env = dict(os.environ, PATH=os.pathsep.join([str(out / 'llvm' / 'bin'), str(out / 'bin'), '/usr/bin', '/bin']))
t0 = time.time()
run([out / 'python' / 'bin' / 'python3', out / 'tools' / 'build_game.py', '--toolchain', out / 'llvm', '--cpu', cpu,
'--sysroot', out / 'llvm' / 'sysroot', '--deps', out / 'runtime' / 'deps', '--runtime', out / 'runtime',
'--runtime-lib', out / 'runtime' / ('libtotkrt-v2.a' if cpu == 'v2' else 'libtotkrt.a'),
'--ninja', out / 'bin' / 'ninja', '--version', version,
'--gen', gen, '--out', chk, '--exefs', ext / 'exefs', '--romfs', ext / 'romfs', '--profile', chk / 'profile'],
env=env)
print(f'prove: build {time.time() - t0:.0f} s')
shutil.copy2(out / 'bin' / 'libSDL3.so.0', chk / 'libSDL3.so.0')
needed = subprocess.run(['readelf', '-d', str(chk / 'totk')], capture_output=True, text=True).stdout
print('prove: needs', ', '.join(re.findall(r'\[(lib[^\]]+)\]', needed)))
glibc = sorted(set(re.findall(r'GLIBC_(\d+\.\d+)', subprocess.run(['readelf', '-V', str(chk / 'totk')],
capture_output=True, text=True).stdout)),
key=lambda v: tuple(map(int, v.split('.'))))
print(f'prove: newest glibc symbol version {glibc[-1] if glibc else "none"}')
failed = []
for probe in ['--selftest', *mr.PROBES]:
r = subprocess.run([str(chk / 'totk'), probe], cwd=chk, capture_output=True, text=True, timeout=900)
tail = (r.stdout.strip().splitlines() or [''])[-1]
print(f'{probe}: exit {r.returncode}: {tail}')
if r.returncode:
failed.append(probe)
if failed:
sys.exit(f'probes failed: {failed}')
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--out', type=Path, required=True, help='release folder (replaced)')
ap.add_argument('--appimage', type=Path, help='also pack the folder as this AppImage')
ap.add_argument('--prove', action='store_true', help='only run the compile-and-probe check on an existing folder')
ap.add_argument('--cpu', choices=('v2', 'v3'), default='v3',
help='--prove: build and run the check for this CPU level (default v3)')
args = ap.parse_args()
out = args.out.resolve()
if args.prove:
prove(out, args.cpu)
return
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
llvm = build_llvm(out)
sysroot = full_sysroot()
build_deps(out, llvm, sysroot)
build_bin(out, llvm, sysroot)
rtlib = runtime_lib(out, llvm, sysroot)
build_sysroot(out, sysroot, used_sysroot_files(out, llvm, sysroot, rtlib))
build_python(out, llvm, sysroot)
mr.build_sources(out, {}) # no profiles: the Linux setup compiles without one (installer/layout.py, pgo_dir)
collect_licenses(out, sysroot)
build_entry(out)
mr.check_folder(out)
check_glibc(out)
check_bundle(out)
mr.sizes(out)
if args.appimage:
write_appimage(out, args.appimage.resolve())
if __name__ == '__main__':
main()