#!/usr/bin/env python3 """build.py: generate host sources from OpenRAC rac1-decomp and build the RAC1 Wrapper. usage: python build.py [--repo PATH_TO_DECOMP] [--platform linux|windows] [-j N] [--iso PATH_TO_DISC_IMAGE] [--levels 00,01,...|none] Needs Python 3 and zig (pip install ziglang, or zig on PATH). The decomp repo is only read. Output: build//rac1_wrapper[.exe] Level programs (docs/LEVELS.md): each level listed in --levels (default 00, Veldin 1) is compiled from src/overlays and its own bytes. Those bytes come from your disc image (--iso, else RAC1_ISO, else iso= in build//rac1_wrapper.cfg), or from an OpenRAC dump in the decomp repo (baserom/overlays/level_NN); they are extracted to build/levels/ and never leave your machine. """ import argparse, glob, json, os, shutil, subprocess, sys from concurrent.futures import ThreadPoolExecutor, as_completed HERE = os.path.dirname(os.path.abspath(__file__)) ap = argparse.ArgumentParser() ap.add_argument("--repo", default=os.environ.get("UYA_DECOMP", os.path.join(HERE, "..", "rac1-decomp-main"))) ap.add_argument("--platform", default="windows" if os.name == "nt" else "linux") ap.add_argument("-j", type=int, default=os.cpu_count() or 2) ap.add_argument("-O", default="2") ap.add_argument("--iso", default=None) ap.add_argument("--levels", default="00") ap.add_argument("--region", default="pal", choices=("pal", "us"), help="pal: SCES-50916 (the decomp's own); us: SCUS-97199, made from the same sources by tools/region.py") a = ap.parse_args() sys.path.insert(0, os.path.join(HERE, "tools")) import region as _region RC = _region.consts(a.region) EXE_NAME = RC["exe"] zig = [shutil.which("zig")] if shutil.which("zig") else [sys.executable, "-m", "ziglang"] # Linux: 32-bit glibc 2.17 or newer (any distribution since 2013), linked dynamically so it can load # the system's SDL2 and OpenGL driver (32-bit: i386 libraries) target = {"linux": "x86-linux-gnu.2.17", "windows": "x86-windows-gnu"}[a.platform] out = os.path.join(HERE, "build", a.platform if a.region == "pal" else "%s_%s" % (a.platform, a.region)) gen = os.path.join(out, "gen") stage = os.path.join(out, "gen.new") obj = os.path.join(out, "obj") os.makedirs(obj, exist_ok=True) levels = [] if a.levels in ("", "none") else sorted({int(x) for x in a.levels.split(",")}) def find_iso(): if a.iso: return a.iso if os.environ.get("RAC1_ISO"): return os.environ["RAC1_ISO"] cfg = os.path.join(out, "rac1_wrapper.cfg") if os.path.exists(cfg): for l in open(cfg, errors="replace"): if l.strip().startswith("iso="): return l.strip()[4:].strip() return None def sync(src, dst): """copy the freshly generated tree over gen/, touching only files that changed, so unchanged sources keep their objects""" os.makedirs(dst, exist_ok=True) keep = set() for root, _, fs in os.walk(src): for f in fs: s = os.path.join(root, f) rel = os.path.relpath(s, src) d = os.path.join(dst, rel) keep.add(rel) os.makedirs(os.path.dirname(d), exist_ok=True) if not os.path.exists(d) or open(s, "rb").read() != open(d, "rb").read(): shutil.copyfile(s, d) for root, _, fs in os.walk(dst): for f in fs: rel = os.path.relpath(os.path.join(root, f), dst) if rel not in keep: os.remove(os.path.join(root, f)) shutil.rmtree(src) def iso_file(iso, name): """a file in the disc image's root directory (ISO 9660)""" import struct with open(iso, "rb") as f: f.seek(16 * 2048); pvd = f.read(2048) if pvd[1:6] != b"CD001": return None dlsn, dlen = struct.unpack_from(" os.path.getmtime(src) and t > headers_time and (not hdr or t > os.path.getmtime(hdr)): return o, None r = subprocess.run(zig + ["cc"] + common + extra + ["-c", src, "-o", o], capture_output=True, text=True) return o, (r.stderr if r.returncode else None) try: # Linux: the link opens every object at once (~5,700); the default soft limit is often 1,024 import resource _s, _h = resource.getrlimit(resource.RLIMIT_NOFILE) if _s != resource.RLIM_INFINITY and (_h == resource.RLIM_INFINITY or _s < _h): resource.setrlimit(resource.RLIMIT_NOFILE, (_h if _h != resource.RLIM_INFINITY else 1 << 20, _h)) except (ImportError, ValueError, OSError): pass progress("compile", 0, len(jobs)) with ThreadPoolExecutor(a.j) as ex: futs = [ex.submit(cc, j) for j in jobs] done_n = 0 for _ in as_completed(futs): done_n += 1 if done_n % 8 == 0 or done_n == len(jobs): progress("compile", done_n, len(jobs)) res = [f.result() for f in futs] progress("link", 0, 1) def objs_rsp(name): """the object files as a response file (@file): Windows limits a command line to 32767 characters, and the ~5700 objects of a full build are far more than that""" p = os.path.join(obj, name + ".rsp") with open(p, "w") as f: for o, _ in res: f.write('"%s"\n' % o.replace("\\", "/")) return "@" + p.replace("\\", "/") errs = [e for _, e in res if e] for e in errs: print(e[-3000:]) if errs: sys.exit("%d files failed to compile" % len(errs)) if a.platform == "linux": exe = os.path.join(out, "rac1_wrapper") # at 0x40000000 like the Windows DLL (lower addresses are the PS2's); its symbols exported # (-rdynamic) so the crash handler can name functions link = zig + ["cc", "-target", target, "-o", exe, objs_rsp("link")] + ["-no-pie", "-rdynamic", "-Wl,--image-base=0x40000000", "-ldl", "-lpthread", "-lm"] r = subprocess.run(link, capture_output=True, text=True) if r.returncode: print(r.stderr[-6000:]); sys.exit("link failed") else: # the menu and runtime as a DLL at 0x40000000, plus the small launcher that owns PS2 RAM dll = os.path.join(out, "rac1_game.dll") link = zig + ["cc", "-shared", "-target", target, "-o", dll, objs_rsp("link")] + ["-Wl,--image-base=0x40000000", "-Wl,--no-dynamicbase", "-lshell32", "-lwinmm", "-lgdi32", "-luser32", "-ladvapi32"] r = subprocess.run(link, capture_output=True, text=True) if r.returncode: print(r.stderr[-6000:]); sys.exit("link failed (dll)") # the function names for crash reports (the .pdb is deleted after a launcher build) try: sys.path.insert(0, os.path.join(HERE, "tools")) import pdb_syms print("symbols:", pdb_syms.write_sym(dll, os.path.join(out, "rac1_game.pdb"), os.path.join(out, "rac1_game.sym"))) except Exception as e: print("no symbol map (%s): crash reports will show offsets only" % e) exe =os.path.join(out, "rac1_wrapper.exe") L = os.path.join(HERE, "launcher") link = zig + ["cc", "-target", target, "-O2", "-nostdlib", "-o", exe, os.path.join(L, "launcher_win32.c"), os.path.join(L, "ps2ram_win32.c"), "-lkernel32", "-luser32", "-Wl,--image-base=0x10000", "-Wl,--entry=launcher_start@0", "-Wl,--no-dynamicbase"] r = subprocess.run(link, capture_output=True, text=True) if r.returncode: print(r.stderr[-6000:]); sys.exit("link failed (launcher)") # the library app (frontend/solana.c): build/Project Solana.exe, which finds the game in build/windows F = os.path.join(HERE, "frontend") if not os.path.isdir(F): F = None # (the game kit players download has no launcher source) link = None if not F else zig + ["cc", "-target", target, "-O2", "-municode", "-Wl,--subsystem,windows", "-o", os.path.join(HERE, "build", "Project Solana.exe"), os.path.join(F, "solana.c"), os.path.join(F, "solana.rc"), "-lgdi32", "-luser32", "-lshell32", "-lcomdlg32", "-lole32", "-lmsimg32", "-lwinhttp", "-lbcrypt", "-lwindowscodecs", "-luuid"] r = subprocess.run(link, capture_output=True, text=True, cwd=F) if link else None if r and r.returncode: print(r.stderr[-6000:]); sys.exit("link failed (Project Solana.exe)") progress("done", 1, 1) print("built", exe)