Files
Cesarus85--ringfall-mjolnir-vr/tools/linux_build.py
T
xshxdex98 8194c5a00b Online co-op: play the campaign together over LAN and the internet
Hosting a co-op level from the Map screen (CO-OP CAMPAIGN, a level and its
difficulty, then Server Setup) makes a network game on the campaign map
with no game engine, which is what co-op is. Only the host runs the
scripts and the AI; port/linux/game/network_coop.c sends clients what the
scripts do (cutscenes, camera, fades, HUD, sounds, devices, named objects,
animations), and network_actors.c drives the host's AI units on every
machine. Players spawn beside each other, respawn beside a safe teammate,
and spectate while dead or waiting. Skipping a cutscene is a vote.
Players can join a game in progress at any time.
2026-10-05 00:31:30 -05:00

515 lines
21 KiB
Python

"""Ninja rules for the native Linux build (``ninja linux``).
It compiles the game sources with clang for 32-bit x86 Linux, adds the
platform layer in ``port/linux/src``, and links an ELF executable at
``build/linux/halo``. See port/linux/README.md for the design.
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from .embed_assets import hud_assets_build, hud_configure_inputs
from .ninja_syntax import Writer
PORT_DIR = Path("port/linux")
PORT_CONFIG = PORT_DIR / "port.json"
# the Xbox SDK declarations the game and the platform layer use, in place of
# the SDK's headers (port/include/xdk/README.md)
XDK_INCLUDE = Path("port/include/xdk")
def xdk_headers() -> List[Path]:
return sorted(XDK_INCLUDE.glob("*.h"))
def game_sources(config: Dict[str, Any]) -> List[Path]:
"""the game's C sources (port.json "game"): every one under its root but
those excluded"""
game = config["game"]
excluded = set(game.get("exclude", []))
return sorted(
source for source in Path(game["root"]).rglob("*.c")
if source.as_posix() not in excluded
)
def game_defines_and_includes(config: Dict[str, Any]) -> str:
"""the game sources' defines and include directories (port.json "game")"""
game = config["game"]
return " ".join(
[f"-D{define}" for define in game.get("defines", [])]
+ [f"-I{_quote(Path(directory))}" for directory in game.get("include_dirs", [])]
)
def compile_launcher(sln: Any) -> str:
"""what the native ports' compile commands start with: the
--compiler-launcher (ccache, say) and a space, or nothing"""
launcher = getattr(sln, "compiler_launcher", None)
return f"{launcher} " if launcher else ""
# The optimisation level of every unit, and of link-time optimisation.
OPTIMISATION = "-O2"
# Flags shared by the game and the XDK-facing half of the platform layer.
# They reproduce the MSVC/Xbox ABI the source was written against:
# - 16-bit wchar_t (UTF-16 strings in tag data and saved games),
# - MSVC struct layout for 64-bit members (-malign-double),
# - __declspec, __int64 and calling conventions,
# - C89 with tentative definitions shared between units (-fcommon),
# - small structures and unions returned in EAX:EDX, as Win32 does
# (hs_runtime.c calls union-returning converters through pointers typed
# as returning long),
# - no optimisations that assume the absence of MSVC-tolerated UB.
LINUX_ABI_FLAGS = [
"--target=i686-linux-gnu",
"-m32",
"-fms-extensions",
"-fshort-wchar",
"-malign-double",
"-fcommon",
"-fno-pic",
"-fno-strict-aliasing",
"-fwrapv",
"-fno-delete-null-pointer-checks",
"-freg-struct-return",
# the game keeps EBP frames (MSVC /Oy-): get_return_eip and the stack
# walker follow the frame chain
"-fno-omit-frame-pointer",
# the same floating point results on every port (every machine in a
# system link game simulates it from the same inputs): no
# fused multiply-adds, which -march=native and ARM64 would otherwise
# emit (port/include/halo_math.h)
"-ffp-contract=off",
OPTIMISATION,
"-g",
# glibc's wide string functions assume a 32-bit wchar_t; stop clang from
# turning loops into calls to them (it rewrites a counting loop as
# wcslen even under -fshort-wchar)
*(f"-fno-builtin-{name}" for name in (
"wcslen", "wcsnlen", "wcschr", "wcsrchr", "wcscmp", "wcsncmp", "wcscpy",
"wcsncpy", "wcscat", "wcsncat", "wmemchr", "wmemcmp", "wmemcpy",
"wmemmove", "wmemset",
)),
]
# The game is compiled with glibc restricted to ISO C so that POSIX names
# (random, strnlen, ...) cannot collide with the game's own declarations.
GAME_FLAGS = [
"-std=gnu89",
"-D__STRICT_ANSI__",
"-w",
"-Wno-error=incompatible-pointer-types",
"-Wno-error=incompatible-function-pointer-types",
"-Wno-error=int-conversion",
"-Wno-error=implicit-function-declaration",
"-Wno-error=implicit-int",
"-Wno-error=return-type",
]
# the TOML parser the platform layer reads config.toml with (port_config.c)
TOML_DIR = Path("port/third_party/tomlc17")
EXPAT_DIR = Path("port/third_party/expat")
EXPAT_SOURCES = ("xmlparse.c", "xmlrole.c", "xmltok.c")
KCP_DIR = Path("port/third_party/kcp")
MONOCYPHER_DIR = Path("port/third_party/monocypher")
MUSL_MATH_DIR = Path("port/third_party/musl-math")
# the self-updater's TLS (port/linux/src/posix_update.c)
MBEDTLS_DIR = Path("port/third_party/mbedtls")
# internet play's UPnP (port/linux/src/posix_upnp.c)
MINIUPNPC_DIR = Path("port/third_party/miniupnpc")
# miniupnpc's own build's definitions (its Makefile), and a static library
MINIUPNPC_DEFINES = ["-DMINIUPNP_STATICLIB", "-DMINIUPNPC_SET_SOCKET_TIMEOUT", "-DMINIUPNPC_GET_SRC_ADDR",
"-D_BSD_SOURCE", "-D_DEFAULT_SOURCE"]
def miniupnpc_sources() -> List[Path]:
"""miniupnpc's library sources (port/third_party/miniupnpc/src)"""
return sorted((MINIUPNPC_DIR / "src").glob("*.c"))
def updater_defines(release: bool) -> str:
"""the self-updater's build (port/linux/src/updater.c): its number, from
HALO_BUILD_NUMBER (tools/ci_build.py gives it for builds of main; none
elsewhere, which never look for updates), and its configuration"""
number = os.environ.get("HALO_BUILD_NUMBER", "0")
if not number.isdigit():
number = "0"
flavor = "release" if release else "debug"
return f'-DHALO_BUILD_NUMBER={number} -DHALO_BUILD_FLAVOR=\\"{flavor}\\"'
PLATFORM_FLAGS = [
"-std=gnu11",
"-D_GNU_SOURCE",
"-DHALO_LINUX_PLATFORM_LAYER",
"-Wall",
"-Wno-unused-function",
"-Wno-unknown-pragmas",
"-Wno-microsoft-anon-tag",
"-Wno-pragma-pack",
"-Wno-ignored-attributes",
"-Wno-duplicate-decl-specifier",
"-Wno-missing-braces",
"-Wno-unused-variable",
"-Wno-ignored-pragmas",
]
# Platform files named posix_*.c talk to glibc only. They are built with the
# host's native ABI (no -malign-double, no 16-bit wchar_t, no XDK headers) so
# glibc structures such as struct stat have their real layout.
POSIX_FLAGS = [
"--target=i686-linux-gnu",
"-m32",
"-std=gnu11",
"-D_GNU_SOURCE",
"-D_FILE_OFFSET_BITS=64",
"-fno-pic",
OPTIMISATION,
"-g",
"-Wall",
]
# Units compiled with a profile (-fprofile-use) that does not quite match
# them: new or changed functions simply go without.
PROFILE_USE_FLAGS = [
"-Wno-profile-instr-unprofiled",
"-Wno-profile-instr-out-of-date",
"-Wno-profile-instr-missing",
"-Wno-backend-plugin",
]
def musl_math_sources() -> List[Path]:
"""musl's maths functions the game uses (port/third_party/musl-math)"""
return sorted((MUSL_MATH_DIR / "src").glob("*.c"))
def musl_math_cflags(abi: str) -> str:
"""their flags: the game's ABI, and the headers standing in for musl's"""
return " ".join([abi, "-std=gnu11", "-w", f"-I{MUSL_MATH_DIR}/include",
f"-include {MUSL_MATH_DIR}/include/libm.h"])
def march_flag(sln: Any) -> str:
"""The instruction set of the native x86 builds: this machine's
(-march=native, the default), or with configure.py --portable the
baseline every x86-64 processor has (SSE2), for builds that run on other
computers. The game stays 32-bit code either way."""
return "-march=x86-64" if getattr(sln, "port_portable", False) else "-march=native"
def lto_mode(sln: Any) -> str:
"""full, thin or off (configure.py --lto)"""
return getattr(sln, "port_lto", "full")
def lto_flags(sln: Any, cache_dir: Path) -> Tuple[List[str], List[str]]:
"""compiler and linker flags for link-time optimisation with lld"""
mode = lto_mode(sln)
if mode == "off":
return [], []
flag = "-flto=thin" if mode == "thin" else "-flto=full"
ldflags = [flag, "-fuse-ld=lld", OPTIMISATION]
if mode == "thin":
ldflags.append(f"-Wl,--thinlto-cache-dir={_quote(cache_dir)}")
return [flag], ldflags
# Profiles recorded by playing the game (tools/pgo_train.py), kept in the
# repository so that every build is optimised with them. They are in the
# format of this LLVM version, which older compilers cannot read.
PGO_DIR = Path("pgo")
LINUX_PROFILE = PGO_DIR / "halo_linux.profdata"
WINDOWS_PROFILE = PGO_DIR / "halo_windows.profdata"
PROFILE_LLVM_MAJOR = 22
_clang_majors: Dict[str, Optional[int]] = {}
def clang_major(cc: str) -> Optional[int]:
"""the major version of the clang named cc, or None if unknown"""
if cc not in _clang_majors:
try:
output = subprocess.run([cc, "--version"], capture_output=True, text=True, check=False).stdout
match = re.search(r"clang version (\d+)", output)
_clang_majors[cc] = int(match.group(1)) if match else None
except OSError:
_clang_majors[cc] = None
return _clang_majors[cc]
def pgo_mode(sln: Any) -> str:
"""use, train or off (configure.py --pgo)"""
return getattr(sln, "port_pgo", "use")
def pgo_profile(sln: Any, own: Optional[Path], others: Sequence[Path], cc: str) -> Optional[Path]:
"""The profile a native build is optimised with: --pgo-profile's; with
--pgo=train, the build's own profile (trained if it is missing);
otherwise the first committed profile there is, its own first. None with
--pgo=off, or when cc is too old to read the profiles."""
explicit = getattr(sln, "port_pgo_profile", None)
if explicit:
return explicit
mode = pgo_mode(sln)
if mode == "off":
return None
major = clang_major(cc)
if major is not None and major < PROFILE_LLVM_MAJOR:
print(f"{cc} is clang {major}; the profiles in {PGO_DIR} need clang {PROFILE_LLVM_MAJOR}: "
"building without profile-guided optimisation", file=sys.stderr)
return None
if mode == "train" and own is not None:
return own
for profile in ([own] if own else []) + list(others):
if profile.is_file():
return profile
return None
def profile_use_flags(profile: Any) -> List[str]:
return [f"-fprofile-use={_quote(profile)}", *PROFILE_USE_FLAGS] if profile else []
def _load_port_config() -> Dict[str, Any]:
with open(PORT_CONFIG, "r", encoding="utf-8") as f:
return json.load(f)
def linux_configure_inputs() -> List[Path]:
"""Files whose change must re-run configure.py."""
if not PORT_CONFIG.is_file():
return [Path(__file__)]
# (the folders of the game's sources, so that adding or removing one
# re-runs it)
game_folders = sorted({source.parent for source in game_sources(_load_port_config())})
return [PORT_CONFIG, Path(__file__), PORT_DIR / "src", PORT_DIR / "game", XDK_INCLUDE, *game_folders,
*hud_configure_inputs()]
def _quote(path: Any) -> str:
text = str(path).replace(os.sep, "/")
return f'"{text}"' if " " in text else text
def generate_linux_build(n: Writer, sln: Any) -> None:
if not PORT_CONFIG.is_file():
# a checkout without the port (or a test fixture): nothing to emit
return
config = _load_port_config()
build_dir: Path = sln.build_dir / "linux"
obj_dir = build_dir / "obj"
output = build_dir / "halo"
cc = sln.linux_cc or "clang"
prefix_header = PORT_DIR / "include" / "halo_linux_prefix.h"
semantics_header = build_dir / "halo_msvc_semantics.h"
platform_semantics_header = build_dir / "platform_msvc_semantics.h"
n.comment("Native Linux build (ninja linux)")
n.variable("linux_cc", cc)
n.rule(
name="linux_msvc_semantics",
command="$python tools/linux_msvc_semantics.py --output $out $scan",
description="LINUX MSVC SEMANTICS $out",
restat=True,
)
game_headers = sorted(
p for p in Path("source").rglob("*") if p.suffix in (".c", ".h")
)
# The game sees its own tags and inline functions and those of the SDK
# declarations (port/include/xdk); the platform layer only includes the
# SDK declarations and so only needs their inline functions.
n.build(
outputs=semantics_header,
rule="linux_msvc_semantics",
implicit=[Path("tools/linux_msvc_semantics.py"), *xdk_headers(), *game_headers],
variables={
"scan": f"--all-inlines --tags source --inlines source --inlines {XDK_INCLUDE}"
},
)
n.build(
outputs=platform_semantics_header,
rule="linux_msvc_semantics",
implicit=[Path("tools/linux_msvc_semantics.py"), *xdk_headers()],
variables={"scan": f"--inlines {XDK_INCLUDE}"},
)
n.rule(
name="linux_cc",
command=f"{compile_launcher(sln)}$linux_cc -MMD -MF $out.d $cflags -c $in -o $out",
description="LINUX CC $out",
depfile="$out.d",
deps="gcc",
)
n.rule(
name="linux_link",
command=(
"$linux_cc $ldflags -o $out @$out.rsp $libs"
" && $python tools/linux_link_check.py $out.rsp $out"
),
description="LINUX LINK $out",
rspfile="$out.rsp",
rspfile_content="$in_newline",
)
n.rule(
name="linux_pgo_train",
command="$python tools/pgo_train.py --binary $binary --work $work --output $out",
description="LINUX PGO TRAINING: playing levels in the instrumented build",
pool="console",
)
# the high-res HUD's textures (port/assets/hud; port/linux/src/hud_hires.c)
embedded_assets = hud_assets_build(n, "linux", build_dir / "generated" / "hud_hires_assets.c")
abi = " ".join(LINUX_ABI_FLAGS + [march_flag(sln)] + (["-DHALO_RELEASE"] if getattr(sln, "port_release", False) else []))
port_include = PORT_DIR / "include"
sdk_flags = f"-idirafter {XDK_INCLUDE}"
libs = " ".join(f"-l{lib}" for lib in config.get("libraries", []))
def emit(obj_dir: Path, output: Path, extra_cflags: List[str], extra_ldflags: List[str],
implicit_inputs: List[Path]) -> None:
"""the objects and the executable, with the given extra flags"""
extra = " ".join(extra_cflags)
# the posix_* units have glibc's 32-bit wchar_t, and LLVM will not
# optimise them together with code that has a 16-bit one: they stay
# native objects
posix_extra = " ".join(flag for flag in extra_cflags if not flag.startswith("-flto"))
objects: List[Path] = []
def add_object(source: Path, cflags: str, posix: bool = False) -> None:
obj = obj_dir / source.with_suffix(".o")
objects.append(obj)
n.build(
outputs=obj,
rule="linux_cc",
inputs=source,
# (the SDK declarations are system headers, which the depfile
# leaves out)
implicit=[*xdk_headers(), prefix_header, semantics_header, platform_semantics_header,
*implicit_inputs],
variables={"cflags": f"{cflags} {posix_extra if posix else extra}"},
)
game_cflags = " ".join([
abi,
" ".join(GAME_FLAGS),
f"-include {prefix_header}",
f"-include {semantics_header}",
f"-I{port_include}",
# the headers of the port's own game units (port/linux/game), for
# the game sources that call them
f"-iquote {Path(config['game_sources'])}",
game_defines_and_includes(config),
sdk_flags,
])
for source in game_sources(config):
add_object(source, game_cflags)
# Port-specific units that must see the game exactly as its own
# sources do (port/linux/game).
for source in sorted(Path(config["game_sources"]).glob("*.c")):
add_object(source, game_cflags)
platform_dir = Path(config["platform_sources"])
platform_cflags = " ".join([
abi,
" ".join(PLATFORM_FLAGS),
f"-include {prefix_header}",
f"-include {platform_semantics_header}",
f"-I{platform_dir}",
f"-I{port_include}",
f"-I{TOML_DIR}",
f"-I{EXPAT_DIR}",
f"-I{KCP_DIR}",
f"-I{MONOCYPHER_DIR}",
"-Isource -Isource/cseries",
sdk_flags,
])
posix_cflags = " ".join(POSIX_FLAGS + [march_flag(sln), f"-I{platform_dir}"])
mbedtls_include = f"-I{MBEDTLS_DIR / 'include'}"
for source in sorted(platform_dir.glob("*.c")):
if source.name == "posix_update.c":
add_object(source, f"{posix_cflags} {mbedtls_include}", posix=True)
elif source.name == "posix_upnp.c":
add_object(source, f"{posix_cflags} -I{MINIUPNPC_DIR / 'include'} -DMINIUPNP_STATICLIB", posix=True)
elif source.name.startswith("posix_"):
add_object(source, posix_cflags, posix=True)
elif source.name == "updater.c":
add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))}")
else:
add_object(source, platform_cflags)
for source in embedded_assets:
add_object(source, platform_cflags)
# the self-updater's TLS (port/third_party/mbedtls), with the host's
# ABI as the posix_*.c that use it (and no loop turned into glibc's
# wcslen, which linux_link_check.py rejects: the game's wchar_t is
# 16-bit)
for source in sorted((MBEDTLS_DIR / "library").glob("*.c")):
add_object(source, " ".join(POSIX_FLAGS + [march_flag(sln), mbedtls_include,
f"-I{MBEDTLS_DIR / 'library'}", "-fno-builtin-wcslen",
"-w"]), posix=True)
# internet play's UPnP (port/third_party/miniupnpc), with the host's
# ABI as posix_upnp.c, which uses it
for source in miniupnpc_sources():
add_object(source, " ".join(POSIX_FLAGS + [march_flag(sln), *MINIUPNPC_DEFINES,
f"-I{MINIUPNPC_DIR / 'include'}", f"-I{MINIUPNPC_DIR / 'src'}",
"-fno-builtin-wcslen", "-w"]), posix=True)
# the settings file's parser (port/third_party/tomlc17), with the
# platform layer's ABI (its structs hold doubles) and nothing else
add_object(TOML_DIR / "tomlc17.c", " ".join([abi, "-std=gnu11", "-w"]))
# the menus' XML parser (port/third_party/expat; menu_files.c)
for name in EXPAT_SOURCES:
add_object(EXPAT_DIR / name, " ".join([abi, "-std=gnu11", f"-I{EXPAT_DIR}", "-w"]))
# internet play's reliable streams (port/third_party/kcp; p2p.c)
add_object(KCP_DIR / "ikcp.c", " ".join([abi, "-std=gnu11", "-w"]))
# internet play's signatures, for public games' listings
# (port/third_party/monocypher; p2p_crypto.c)
for name in ("monocypher.c", "monocypher-ed25519.c"):
add_object(MONOCYPHER_DIR / name, " ".join([abi, "-std=gnu11", "-w"]))
# the game's sin, pow and the rest, the same on every port
# (port/include/halo_math.h)
for source in musl_math_sources():
add_object(source, musl_math_cflags(abi))
n.build(
outputs=output,
rule="linux_link",
inputs=objects,
variables={
"ldflags": " ".join(["--target=i686-linux-gnu", "-m32", "-no-pie", "-g", *extra_ldflags]),
"libs": libs,
},
implicit=[Path("tools/linux_link_check.py")],
)
# Profile-guided optimisation: with the committed profile, or with
# --pgo=train one that an instrumented build records while playing
# (tools/pgo_train.py). A profile is trained once: code changed since
# simply goes without, and deleting it trains a new one.
profile = pgo_profile(sln, LINUX_PROFILE, [], cc)
if pgo_mode(sln) == "train" and profile == LINUX_PROFILE:
instrumented = build_dir / "pgo-generate" / "halo"
emit(build_dir / "pgo-generate" / "obj", instrumented, ["-fprofile-generate"], ["-fprofile-generate"], [])
n.build(
outputs=profile,
rule="linux_pgo_train",
order_only=[instrumented],
variables={"binary": str(instrumented), "work": str(sln.build_dir / "pgo" / "linux")},
)
# Link-time optimisation: the objects are LLVM bitcode, optimised and
# compiled together when lld links them.
cflags, ldflags = lto_flags(sln, build_dir / "thinlto-cache")
cflags += profile_use_flags(profile)
emit(obj_dir, output, cflags, ldflags, [profile] if profile else [])
# internet play's MQTT brokers, a file beside the game (network.brokers_file)
brokers = build_dir / "brokers.txt"
n.rule(name="linux_copy", command="cp $in $out", description="LINUX COPY $out")
n.build(outputs=brokers, rule="linux_copy", inputs=Path("port/assets/network/brokers.txt"))
n.build(outputs="linux", rule="phony", inputs=[output, brokers])
n.newline()