rodecomp/scripts/exe/build_exe.py

463 lines
20 KiB
Python

"""Build every unit in data/compile.json and link them into Ragexe.exe (+ .pdb).
Optional and local: clank checks and merges never run this, and it does not
need td. It compiles with the exact compiler flags of data/compile.json using
the first MSVC v142 (14.29) + Windows SDK 10.0.19041 toolchain it finds:
1. --toolchain DIR, or the RODECOMP_TOOLCHAIN environment variable: a
directory laid out like td's toolchain (VC/Tools/MSVC/14.29.30133 and
Program Files/Windows Kits/10).
2. .td/config.json, when `td setup` / `td toolchain install` ran here.
3. A regular Visual Studio / Build Tools install found with vswhere, with
the "MSVC v142 - VS 2019 C++ x64/x86 build tools (v14.29-16.11)" and
"Windows 10 SDK (10.0.19041.0)" components:
vs_buildtools.exe --add Microsoft.VisualStudio.Component.VC.14.29.16.11.x86.x64
--add Microsoft.VisualStudio.Component.Windows10SDK.19041
Vendor DLL imports (Bink, Granny, Stove, ...) come from scripts/exe/imports/*.def,
the exact import table of the original executable, so a declaration that does
not match the real DLL export fails at link time.
Compiles are incremental: a unit is rebuilt only when its flags, its source or
one of the headers it included (recorded with /showIncludes) changed.
python scripts/exe/build_exe.py # compile + link, fail on any error
python scripts/exe/build_exe.py --force-multiple # accept duplicate definitions
python scripts/exe/build_exe.py --link-only # relink objects from a previous run
python scripts/exe/build_exe.py --toolchain DIR # use this toolchain directory
"""
import argparse
import concurrent.futures
import json
import os
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
EXE_DIR = Path(__file__).resolve().parent
INCLUDE_NOTE = "Note: including file:"
MSVC_VERSION = "14.29.30133"
SDK_VERSION = "10.0.19041.0"
SYSTEM_LIBS = [
"kernel32.lib", "user32.lib", "gdi32.lib", "advapi32.lib", "winmm.lib", "ws2_32.lib",
"ole32.lib", "oleaut32.lib", "uuid.lib", "shell32.lib", "imm32.lib", "version.lib",
"comctl32.lib", "comdlg32.lib", "shlwapi.lib", "wininet.lib", "gdiplus.lib", "bcrypt.lib",
"dxguid.lib", "ddraw.lib", "d3d9.lib", "dinput8.lib", "dbghelp.lib", "crypt32.lib",
"iphlpapi.lib", "psapi.lib", "wintrust.lib", "winhttp.lib", "netapi32.lib",
]
class ToolchainNotFound(Exception):
pass
class Toolchain:
"""An MSVC v142 + Windows SDK pair, run natively on Windows or through wine elsewhere.
vc is the directory holding Tools/MSVC/<version> (a Visual Studio "VC" folder),
kits the Windows Kits/10 directory; td's toolchain keeps both under one root.
"""
def __init__(self, vc, kits, kind, origin, env=None):
self.vc = Path(vc)
self.kits = Path(kits)
self.kind = kind
self.origin = origin
self.msvc = self.vc / "Tools" / "MSVC" / MSVC_VERSION
self.bin = self.msvc / "bin" / "Hostx64" / "x86"
self.env = dict(os.environ)
self.env.update(env or {})
def problems(self, link=True):
"""What this toolchain lacks to compile (and, with link, to link) the exe."""
missing = []
if not (self.bin / "cl.exe").exists():
missing.append(str(self.bin / "cl.exe"))
if not (self.kits / "Include" / SDK_VERSION / "um" / "windows.h").exists():
missing.append(str(self.kits / "Include" / SDK_VERSION / "um" / "windows.h"))
if link:
if not (self.msvc / "lib" / "x86" / "msvcrt.lib").exists():
missing.append(str(self.msvc / "lib" / "x86" / "msvcrt.lib"))
if not (self.kits / "Lib" / SDK_VERSION / "um" / "x86" / "kernel32.Lib").exists():
missing.append(str(self.kits / "Lib" / SDK_VERSION / "um" / "x86" / "kernel32.Lib"))
return missing
def expand(self, value, native_paths=False):
"""Substitute data/compile.json's ${TC}/${SRC} placeholders with this toolchain's paths.
With td's single-root layout ${TC} is that root; with a Visual Studio install the VC and
Windows Kits directories live apart, so ${TC}<sep>VC and ${TC}<sep>Program Files<sep>Windows Kits<sep>10
are replaced separately.
"""
path = (lambda p: str(p)) if native_paths else self.path
root = self.vc.parent
if self.kits != root / "Program Files" / "Windows Kits" / "10":
for sep in ("\\", "/"):
value = value.replace(f"${{TC}}{sep}Program Files{sep}Windows Kits{sep}10", path(self.kits))
value = value.replace(f"${{TC}}{sep}VC", path(self.vc))
value = value.replace("${TC}", path(root))
source = str(ROOT) if native_paths else self.path(ROOT)
return value.replace("${SRC}", source.replace("\\", "/"))
def path(self, p):
if self.kind == "native":
return str(p)
return subprocess.run(["winepath", "-w", str(p)], capture_output=True, text=True, check=True).stdout.strip()
def lib_env(self):
sdk_lib = self.kits / "Lib" / SDK_VERSION
return ";".join(self.path(p) for p in (
self.msvc / "lib" / "x86", self.msvc / "atlmfc" / "lib" / "x86", sdk_lib / "ucrt" / "x86", sdk_lib / "um" / "x86"))
def run(self, tool, args, env=None, cwd=None):
exe = str(self.bin / tool)
command = [exe, *args] if self.kind == "native" else ["wine", exe, *args]
return subprocess.run(command, env=env or self.env, cwd=cwd, capture_output=True, text=True, errors="replace")
def _layout(root, kind, origin, env=None):
"""td's single-root layout: <root>/VC and <root>/Program Files/Windows Kits/10."""
root = Path(root)
return Toolchain(root / "VC", root / "Program Files" / "Windows Kits" / "10", kind, origin, env)
def _from_td():
try:
config = json.loads((ROOT / ".td" / "config.json").read_text(encoding="utf-8"))
runner = config["runners"]["ragnarok"]
return _layout(runner["toolchainRoot"], runner["type"], ".td/config.json", runner.get("env", {}))
except (OSError, KeyError, ValueError, TypeError):
return None
def _windows_kits_roots():
roots = []
try:
import winreg
for view in (winreg.KEY_WOW64_32KEY, winreg.KEY_WOW64_64KEY):
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows Kits\Installed Roots",
0, winreg.KEY_READ | view) as key:
roots.append(Path(winreg.QueryValueEx(key, "KitsRoot10")[0]))
except OSError:
pass
except ImportError:
pass
for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles")):
if base:
roots.append(Path(base) / "Windows Kits" / "10")
return list(dict.fromkeys(roots))
def _from_visual_studio(link):
if os.name != "nt":
return None
kits = _windows_kits_roots()
for vc in (path / "VC" for path in _vs_installs()):
for kit in kits:
candidate = Toolchain(vc, kit, "native", f"Visual Studio ({vc.parent})")
if not candidate.problems(link):
return candidate
return None
VS_COMPONENTS = ["Microsoft.VisualStudio.Component.VC.14.29.16.11.x86.x64", "Microsoft.VisualStudio.Component.Windows10SDK.19041"]
BUILD_TOOLS_URL = "https://aka.ms/vs/17/release/vs_buildtools.exe"
def _vs_installs():
base = os.environ.get("ProgramFiles(x86)") or r"C:\Program Files (x86)"
vswhere = Path(base) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
if not vswhere.exists():
return []
result = subprocess.run([str(vswhere), "-all", "-products", "*", "-format", "json", "-utf8"],
capture_output=True, text=True, encoding="utf-8", errors="replace")
try:
return [Path(i["installationPath"]) for i in json.loads(result.stdout or "[]") if i.get("installationPath")]
except ValueError:
return []
def install_build_tools():
"""Add the MSVC v142 14.29 + Windows SDK 19041 components with the Visual Studio Installer.
Modifies an existing Visual Studio / Build Tools instance when there is one, otherwise downloads the
Build Tools bootstrapper and installs a new instance. The installer asks for elevation (UAC) itself.
"""
add = [arg for component in VS_COMPONENTS for arg in ("--add", component)]
installs = _vs_installs()
installer = Path(os.environ.get("ProgramFiles(x86)") or r"C:\Program Files (x86)") / "Microsoft Visual Studio" / "Installer" / "setup.exe"
if installs and installer.exists():
command = [str(installer), "modify", "--installPath", str(installs[0]), *add, "--passive", "--norestart"]
print(f"adding the components to {installs[0]} with the Visual Studio Installer...", flush=True)
else:
import tempfile
import urllib.request
bootstrapper = Path(tempfile.gettempdir()) / "vs_buildtools.exe"
print(f"downloading {BUILD_TOOLS_URL}...", flush=True)
urllib.request.urlretrieve(BUILD_TOOLS_URL, bootstrapper)
command = [str(bootstrapper), *add, "--passive", "--wait", "--norestart"]
print("installing Visual Studio Build Tools (several GB, this takes a while)...", flush=True)
code = subprocess.run(command).returncode
# 3010: success, restart required
if code not in (0, 3010):
raise ToolchainNotFound(f"the Visual Studio Installer failed with exit code {code}")
def _not_found_message(td, link):
hint = ""
if td is not None:
hint = f"\n.td/config.json points to {td.vc.parent}, which lacks:\n " + "\n ".join(td.problems(link))
return (
f"no MSVC {MSVC_VERSION} (v142) + Windows SDK {SDK_VERSION} toolchain found.{hint}\n"
"Either install them with the Visual Studio Installer (Build Tools 2019 or later):\n"
f" vs_buildtools.exe --add {VS_COMPONENTS[0]} --add {VS_COMPONENTS[1]}\n"
"or pass --toolchain DIR / set RODECOMP_TOOLCHAIN to a td-layout toolchain directory\n"
"(e.g. one installed by `td toolchain install`).")
def find_toolchain(explicit=None, link=True, offer_install=True):
"""The first usable toolchain: explicit path / RODECOMP_TOOLCHAIN, then .td/config.json, then Visual Studio.
Without one, on an interactive Windows console, offer to install the Build Tools components.
"""
explicit = explicit or os.environ.get("RODECOMP_TOOLCHAIN")
if explicit:
tc = _layout(explicit, "native" if os.name == "nt" else "wine", f"--toolchain/RODECOMP_TOOLCHAIN ({explicit})")
missing = tc.problems(link)
if missing:
raise ToolchainNotFound(f"{explicit} is not a usable toolchain, missing:\n " + "\n ".join(missing))
return tc
td = _from_td()
if td is not None:
if td.kind not in ("native", "wine"):
raise ToolchainNotFound(f"td runner '{td.kind}' is not supported; use td's native (Windows) or wine runner")
if not td.problems(link):
return td
vs = _from_visual_studio(link)
if vs is not None:
return vs
message = _not_found_message(td, link)
if not (offer_install and os.name == "nt" and sys.stdin.isatty()):
raise ToolchainNotFound(message)
print(message, file=sys.stderr)
try:
answer = input(f"\nInstall MSVC {MSVC_VERSION} + Windows SDK {SDK_VERSION} now with the Visual Studio Installer? [y/N] ")
except EOFError:
answer = ""
if answer.strip().lower() not in ("y", "yes"):
raise ToolchainNotFound("toolchain not installed")
install_build_tools()
vs = _from_visual_studio(link)
if vs is None:
raise ToolchainNotFound("the installer finished but the toolchain is still not found:\n" + message)
return vs
def build_import_libs(tc, out_dir):
lib_dir = out_dir / "importlibs"
lib_dir.mkdir(exist_ok=True)
libs = []
for definition in sorted((EXE_DIR / "imports").glob("*.def")):
lib = lib_dir / (definition.stem + ".lib")
result = tc.run("lib.exe", ["/nologo", "/MACHINE:X86", f"/DEF:{tc.path(definition)}", f"/OUT:{tc.path(lib)}"])
if result.returncode != 0:
sys.exit(f"import library {definition.name} failed:\n{result.stdout}{result.stderr}")
libs.append(lib)
return libs
# Units only the standalone exe needs: stand-ins for third-party code the
# decompilation does not reproduce. Each is compiled with the flags of the
# listed data/compile.json unit plus any extra include directories (relative to
# the repository). Clank does not know about these files.
EXE_ONLY_UNITS = {
"Recovered/nProtect/GameGuard.cpp": ("Source/Security/NProtectGameGuardMgr.cpp", []),
"Recovered/jsoncons/JsonCppLoaderGlue.cpp": ("Source/Resource/Json/JsonCppLoader.cpp", ["Recovered/jsoncons/include"]),
"Recovered/GPak/PakAuxiliary.cpp": ("Source/Archive/GPak.cpp", []),
"Recovered/GGHash/GGHash.cpp": ("Source/Utility/Hash/GGHash.cpp", []),
}
def exe_units(config):
"""unit -> (shape index, extra include directories)."""
units = {unit: (shape, []) for unit, shape in config["units"].items()}
for unit, (flags_of, includes) in EXE_ONLY_UNITS.items():
units[unit] = (config["units"][flags_of], includes)
return units
def compile_units(tc, config, out_dir, jobs):
env = dict(tc.env)
for key, value in config["env"].items():
env[key] = tc.expand(value)
def stamp(path):
try:
info = os.stat(path)
except OSError:
return None
return [info.st_mtime_ns, info.st_size]
def up_to_date(obj, record, key):
if not obj.exists() or record is None or record.get("key") != key:
return False
return all(stamp(path) == saved for path, saved in record["deps"].items())
def build(item):
unit, (shape, includes) = item
obj = out_dir / (unit.replace("/", "_") + ".obj")
deps_file = obj.with_suffix(".deps.json")
extra = [f"/I{tc.path(ROOT / directory)}" for directory in includes]
args = [*(tc.expand(a) for a in config["shapes"][shape]), *extra, "/Z7", f"/Fo{tc.path(obj)}", tc.path(ROOT / unit)]
key = "\x00".join(args)
try:
record = json.loads(deps_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
record = None
if up_to_date(obj, record, key):
return unit, obj, 0, "", True
deps_file.unlink(missing_ok=True)
result = tc.run("cl.exe", [*args, "/showIncludes"], env=env, cwd=ROOT)
output = []
headers = []
for line in (result.stdout + result.stderr).splitlines():
if line.startswith(INCLUDE_NOTE):
headers.append(line[len(INCLUDE_NOTE):].strip())
else:
output.append(line)
if result.returncode == 0:
deps = {path: stamp(path) for path in [str(ROOT / unit), *headers]}
if None not in deps.values():
deps_file.write_text(json.dumps({"key": key, "deps": deps}), encoding="utf-8")
return unit, obj, result.returncode, "\n".join(output), False
items = list(exe_units(config).items())
results = [None] * len(items)
progress = Progress(len(items))
with concurrent.futures.ThreadPoolExecutor(jobs) as pool:
pending = {pool.submit(build, item): index for index, item in enumerate(items)}
for future in concurrent.futures.as_completed(pending):
result = future.result()
results[pending[future]] = result
progress.update(result)
progress.finish()
failures = [r for r in results if r[2] != 0]
for unit, _, _, output, _ in failures:
print(f"== {unit}")
print("\n".join(line for line in output.splitlines() if " error " in line or "fatal error" in line))
cached = sum(1 for r in results if r[4])
print(f"compiled {len(results) - len(failures)}/{len(results)} units ({cached} up to date, {len(results) - cached} rebuilt)")
return [r[1] for r in results if r[2] == 0], len(failures)
class Progress:
"""Compile progress on stderr: one rewritten line on a terminal, a line per 10% otherwise."""
def __init__(self, total):
self.total = total
self.done = self.rebuilt = self.failed = 0
self.tty = sys.stderr.isatty()
self.next_step = 10
self.width = 0
def update(self, result):
unit, _, code, _, cached = result
self.done += 1
self.rebuilt += 0 if cached else 1
self.failed += 1 if code != 0 else 0
counts = f"[{self.done}/{self.total}] {self.rebuilt} rebuilt" + (f", {self.failed} failed" if self.failed else "")
if self.tty:
line = f"{counts} {unit}"
self.width = max(self.width, len(line))
sys.stderr.write("\r" + line.ljust(self.width))
sys.stderr.flush()
else:
if code != 0:
print(f"failed: {unit}", file=sys.stderr, flush=True)
if self.done * 100 >= self.next_step * self.total:
print(counts, file=sys.stderr, flush=True)
while self.next_step * self.total <= self.done * 100:
self.next_step += 10
def finish(self):
if self.tty and self.width:
sys.stderr.write("\r" + " " * self.width + "\r")
sys.stderr.flush()
def link(tc, objects, out_dir, extra_args):
rsp = out_dir / "link.rsp"
rsp.write_text("\n".join([*(f'"{tc.path(o)}"' for o in objects), *extra_args]), encoding="utf-8")
exe = out_dir / "Ragexe.exe"
outputs = (exe, exe.with_suffix(".pdb"))
for stale in outputs:
stale.unlink(missing_ok=True)
args = ["/nologo", "/MACHINE:X86", "/SUBSYSTEM:WINDOWS", "/LARGEADDRESSAWARE",
"/DEBUG:FULL", f"/PDB:{tc.path(exe.with_suffix('.pdb'))}", "/INCREMENTAL:NO", "/OPT:REF", "/OPT:ICF",
"/ERRORLIMIT:0", f"/OUT:{tc.path(exe)}", f"@{tc.path(rsp)}", *SYSTEM_LIBS]
result = tc.run("link.exe", args)
if result.returncode != 0:
for partial in outputs:
partial.unlink(missing_ok=True)
return result.returncode, result.stdout + result.stderr, exe
def summarize(log, out_dir):
(out_dir / "link.log").write_text(log, encoding="utf-8")
duplicates = sorted(set(re.findall(r'(?:error LNK2005|warning LNK4006): "([^"]+)"', log)))
unresolved = sorted(set(a or b for a, b in re.findall(r'unresolved external symbol (?:"[^"]+" \((\S+)\)|(\S+))', log)))
print(f"link log: {out_dir / 'link.log'}")
print(f"duplicate definitions: {len(duplicates)}")
for symbol in duplicates:
print(f" {symbol}")
print(f"unresolved symbols: {len(unresolved)}")
codes = Counter(re.findall(r"error (LNK\d+)", log))
if codes:
print("linker errors: " + ", ".join(f"{code} x{count}" for code, count in codes.most_common()))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--out", default=str(ROOT / "build" / "exe"), help="output directory")
parser.add_argument("--jobs", type=int, default=os.cpu_count(), help="parallel compiles")
parser.add_argument("--force-multiple", action="store_true",
help="link despite duplicate definitions (/FORCE:MULTIPLE)")
parser.add_argument("--link-only", action="store_true", help="reuse the objects from a previous run")
parser.add_argument("--toolchain", help="toolchain directory (td layout); default: RODECOMP_TOOLCHAIN, "
".td/config.json, then a Visual Studio install")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
config = json.loads((ROOT / "data" / "compile.json").read_text(encoding="utf-8"))
try:
tc = find_toolchain(args.toolchain)
except ToolchainNotFound as error:
sys.exit(str(error))
tc.env["LIB"] = tc.lib_env()
print(f"toolchain: {tc.msvc} + {tc.kits} ({tc.kind}, from {tc.origin})", flush=True)
if args.link_only:
objects = [out_dir / (unit.replace("/", "_") + ".obj") for unit in exe_units(config)]
compile_failures = 0
else:
objects, compile_failures = compile_units(tc, config, out_dir, args.jobs)
extra = ["/FORCE:MULTIPLE"] if args.force_multiple else []
print(f"linking {len(objects)} objects...", flush=True)
code, log, exe = link(tc, [*objects, *build_import_libs(tc, out_dir)], out_dir, extra)
summarize(log, out_dir)
if code == 0:
print(f"built {exe}")
sys.exit(1 if code != 0 or compile_failures else 0)
if __name__ == "__main__":
main()