mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
338 lines
15 KiB
Python
338 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""bios_module_build.py — build a loadable BIOS backend from the player's own
|
|
retail dump, on the player's machine.
|
|
|
|
WHY
|
|
===
|
|
A bundled release links only the redistributable OpenBIOS backend: the C a
|
|
retail image recompiles to is a translation of that image's code and never
|
|
ships (docs/BIOS_SELECTION.md). This script is what the runtime runs, from the
|
|
overlay toolchain beside the executable, when the player selects a retail dump:
|
|
it recompiles THAT dump with the bundled psxrecomp-bios and compiles the result
|
|
into a shared library the runtime loads as one more backend
|
|
(runtime/src/psx_bios_module.c, runtime/include/psx_bios_module.h). The same
|
|
interpreter and compiler already turn overlays streamed from the player's disc
|
|
into native code; this is that mechanism applied to the BIOS.
|
|
|
|
CONTRACT
|
|
========
|
|
The module is a self-contained shared library (no imports from the executable
|
|
on any platform), exporting psx_bios_module_abi / _codegen_hash / _init /
|
|
_backend (see psx_bios_module_glue.c.inc). It is compiled with the same flags
|
|
the executable's own BIOS backend uses (no debug tools, block cycles, game
|
|
dispatch present) plus the two module defines, from THREE translation units:
|
|
|
|
<STEM>_full.c generated function bodies (+ psx_bios_module.h prepended)
|
|
<STEM>_dispatch.c generated dispatcher/descriptor (+ psx_bios_module.h prepended)
|
|
glue.c overlay preamble + BIOS glue (the only exports)
|
|
|
|
The identity gate is the emitter's own: a profile pins the image's SHA-256 and
|
|
psxrecomp-bios refuses any other image, so a module can only ever be built from
|
|
the exact dump its code describes.
|
|
|
|
USAGE (as the runtime invokes it)
|
|
python3 bios_module_build.py --dump <SCPH1001.BIN> --toolchain <dir> \
|
|
--stem SCPH1001 --out <cache>/SCPH1001_<crc8>.so --flavor 0 \
|
|
--compiler gcc|tcc [--gcc <cc>] [--tcc <tcc>] [--arch-abi <os>-<arch>]
|
|
|
|
The toolchain dir is overlay_toolchain/ as tools/release_stage.py stages it
|
|
(python, psxrecomp-bios, include/, bios/*.toml, recompiler/seeds/). Each
|
|
piece can be overridden (--emitter, --include, --profiles, --seeds) so a
|
|
developer tree can run it without staging.
|
|
|
|
Prints `PSX_BIOS_MODULE_PUBLISHED <path>` on success; exit 0. Any failure
|
|
leaves no file at --out and exits non-zero with the reason on stdout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ctypes
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zlib
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
|
|
import compile_overlays as co # noqa: E402 (shared compiler driver helpers)
|
|
|
|
|
|
def die(msg: str) -> "NoReturn":
|
|
print(f"bios_module_build: ERROR: {msg}")
|
|
sys.exit(1)
|
|
|
|
|
|
def read_profile(path: str) -> dict:
|
|
"""The few keys this needs from a bios/<stem>.toml (simple `key = "value"`
|
|
lines inside [program] / [program.image] / [recompiler]). No TOML library:
|
|
the pinned interpreter is the stdlib-only embeddable Python."""
|
|
out = {"rom": "", "sha256": "", "seeds": "", "out_stem": "", "id": ""}
|
|
section = ""
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
s = line.split("#", 1)[0].strip()
|
|
if not s:
|
|
continue
|
|
m = re.match(r"^\[([^\]]+)\]$", s)
|
|
if m:
|
|
section = m.group(1).strip()
|
|
continue
|
|
m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"', s)
|
|
if not m:
|
|
continue
|
|
k, v = m.group(1), m.group(2)
|
|
if section == "program" and k == "rom":
|
|
out["rom"] = v
|
|
elif section == "program" and k == "id":
|
|
out["id"] = v
|
|
elif section == "program.image" and k == "sha256":
|
|
out["sha256"] = v.lower()
|
|
elif section == "recompiler" and k == "seeds":
|
|
out["seeds"] = v
|
|
elif section == "recompiler" and k == "out_stem":
|
|
out["out_stem"] = v
|
|
return out
|
|
|
|
|
|
def identity(path: str) -> tuple[int, int, str]:
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
return len(data), zlib.crc32(data) & 0xFFFFFFFF, hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
MODULE_DEFINES = [
|
|
"-DPSX_OVERLAY_DLL_BUILD",
|
|
"-DPSX_BIOS_MODULE_BUILD",
|
|
# Mirror the executable's own BIOS objects (runtime.cmake): no debug tools,
|
|
# faithful block cycles, and the game-dispatch-aware dispatcher body.
|
|
"-DPSX_NO_DEBUG_TOOLS",
|
|
"-DPSX_ENABLE_BLOCK_CYCLES=1",
|
|
"-DPSX_HAS_GAME_DISPATCH",
|
|
]
|
|
|
|
|
|
def compile_module(sources: list[str], out: str, include_dirs: list[str],
|
|
stem: str, flavor: int, compiler: str, gcc: str, tcc: str) -> bool:
|
|
defines = MODULE_DEFINES + [f"-DPSX_OVERLAY_FLAVOR={int(flavor)}",
|
|
f"-DPSX_BIOS_MODULE_STEM={stem}"]
|
|
if int(flavor) & 2:
|
|
defines.append("-DPSX_PGXP=1")
|
|
if compiler == "tcc":
|
|
# Same treatment compile_overlays gives a shard: strip any BOM, add the
|
|
# __builtin_ctz shim, and hand tcc BOM-free copies of the headers.
|
|
for src in sources:
|
|
with open(src, "rb") as f:
|
|
data = f.read()
|
|
if data[:3] == b"\xef\xbb\xbf":
|
|
data = data[3:]
|
|
if not data.startswith(co._TCC_COMPAT_PREFIX):
|
|
data = co._TCC_COMPAT_PREFIX + data
|
|
with open(src, "wb") as f:
|
|
f.write(data)
|
|
cmd = [tcc, "-shared", *defines, *[co.native_path(s) for s in sources],
|
|
"-o", co.native_path(out)]
|
|
for d in include_dirs:
|
|
cmd.append("-I" + co.native_path(co._bom_free_incdir(d)))
|
|
env = None
|
|
else:
|
|
env, tc_dir = co._toolchain_env(gcc)
|
|
pic = [] if co.is_windows() else ["-fPIC"]
|
|
cmd = [gcc, "-shared", *pic, *co.target_arch_flags("gcc"), "-O2", *defines,
|
|
*[co.native_path(s) for s in sources],
|
|
"-o", co.native_path(out),
|
|
*[f"-I{co.native_path(d)}" for d in include_dirs], "-lm"]
|
|
if tc_dir is None:
|
|
print(" WARNING: no gcc toolchain dir found; compile may fail to launch")
|
|
print(" compile: " + " ".join(cmd))
|
|
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if r.returncode != 0:
|
|
msg = (r.stderr or r.stdout or "").strip() or "(no compiler output)"
|
|
print(f" COMPILE ERROR (exit {r.returncode}):\n{msg}")
|
|
return False
|
|
return True
|
|
|
|
|
|
def self_check(path: str, flavor: int) -> bool:
|
|
"""Load the fresh module in this interpreter: proves it is self-contained
|
|
(RTLD_NOW resolves every symbol or fails) and that its ABI tag names the
|
|
flavor asked for. The host repeats the gate with its own tag."""
|
|
try:
|
|
lib = ctypes.CDLL(co.native_path(path))
|
|
except OSError as exc:
|
|
print(f" self-check: module does not load: {exc}")
|
|
return False
|
|
try:
|
|
abi = ctypes.c_int(lib.psx_bios_module_abi()).value
|
|
h = ctypes.c_uint32(lib.psx_bios_module_codegen_hash()).value
|
|
except AttributeError as exc:
|
|
print(f" self-check: export missing: {exc}")
|
|
return False
|
|
if (abi >> 16) & 0xFFFF != int(flavor) & 0xFFFF:
|
|
print(f" self-check: ABI flavor {(abi >> 16) & 0xFFFF} != requested {flavor}")
|
|
return False
|
|
print(f" self-check: abi=0x{abi:08x} codegen_hash=0x{h:08x} ok")
|
|
return True
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--dump", required=True, help="the player's BIOS image")
|
|
ap.add_argument("--toolchain", default=HERE, help="overlay_toolchain/ dir (default: this script's dir)")
|
|
ap.add_argument("--stem", required=True, help="profile stem, e.g. SCPH1001 (bios/<stem>.toml)")
|
|
ap.add_argument("--out", required=True, help="module path to publish (.so/.dll)")
|
|
ap.add_argument("--flavor", type=int, default=0, help="PSX_OVERLAY_FLAVOR of the host build")
|
|
ap.add_argument("--arch-abi", default=None,
|
|
help="\"<os>-<arch>\" of the host build (overlay_loader.h's PSX_OVERLAY_ARCH_ABI); "
|
|
"default: PSX_OVERLAY_ARCH_ABI from the environment, else this interpreter's")
|
|
ap.add_argument("--compiler", choices=("gcc", "tcc"), default="gcc")
|
|
ap.add_argument("--gcc", default="gcc")
|
|
ap.add_argument("--tcc", default="tcc")
|
|
ap.add_argument("--emitter", default=None, help="psxrecomp-bios (default: <toolchain>/psxrecomp-bios[.exe])")
|
|
ap.add_argument("--include", default=None, help="runtime include dir (default: <toolchain>/include)")
|
|
ap.add_argument("--profiles", default=None, help="bios/ profiles dir (default: <toolchain>/bios)")
|
|
ap.add_argument("--seeds", default=None, help="seeds dir (default: <toolchain>/recompiler/seeds)")
|
|
ap.add_argument("--keep-work", action="store_true", help="keep the temporary emit tree (debugging)")
|
|
args = ap.parse_args(argv)
|
|
|
|
# Build for the HOST's architecture, which on macOS need not be the
|
|
# compiler binary's default (an x86_64 runtime under Rosetta, arm64-only
|
|
# Xcode clang). The self-check loads the module into this interpreter, so
|
|
# the interpreter has to run as that architecture as well.
|
|
env_arch = os.environ.get("PSX_OVERLAY_ARCH_ABI")
|
|
if env_arch and args.arch_abi and env_arch != args.arch_abi:
|
|
print(f"PSX_OVERLAY_ARCH_ABI overrides --arch-abi: {env_arch}")
|
|
arch_err = co.apply_runtime_arch_abi(
|
|
env_arch or args.arch_abi,
|
|
"PSX_OVERLAY_ARCH_ABI" if env_arch else "--arch-abi")
|
|
if arch_err:
|
|
die(arch_err)
|
|
arch_mismatch = co.interpreter_arch_mismatch()
|
|
if arch_mismatch:
|
|
die(f"cannot build a BIOS module: {arch_mismatch}")
|
|
|
|
tk = os.path.abspath(args.toolchain)
|
|
# Everything absolute: the emitter runs with cwd inside the staged work
|
|
# tree, and the compiler is handed paths from more than one directory.
|
|
emitter = os.path.abspath(args.emitter or os.path.join(
|
|
tk, "psxrecomp-bios.exe" if co.is_windows() else "psxrecomp-bios"))
|
|
include = os.path.abspath(args.include or os.path.join(tk, "include"))
|
|
profiles = os.path.abspath(args.profiles or os.path.join(tk, "bios"))
|
|
seeds_dir = os.path.abspath(args.seeds or os.path.join(tk, "recompiler", "seeds"))
|
|
args.dump = os.path.abspath(args.dump)
|
|
args.out = os.path.abspath(args.out)
|
|
for label, p in (("emitter", emitter), ("include dir", include), ("profiles dir", profiles),
|
|
("seeds dir", seeds_dir), ("dump", args.dump)):
|
|
if not os.path.exists(p):
|
|
die(f"{label} not found: {p}")
|
|
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", args.stem):
|
|
die(f"bad stem {args.stem!r}")
|
|
profile_path = os.path.join(profiles, f"{args.stem}.toml")
|
|
if not os.path.isfile(profile_path):
|
|
die(f"no profile for stem {args.stem}: {profile_path}")
|
|
prof = read_profile(profile_path)
|
|
if not prof["rom"] or not prof["seeds"]:
|
|
die(f"profile {profile_path} lacks rom/seeds")
|
|
out_stem = prof["out_stem"] or args.stem
|
|
if out_stem != args.stem:
|
|
die(f"profile out_stem {out_stem!r} != --stem {args.stem!r}; the descriptor symbol must match")
|
|
|
|
size, crc, sha = identity(args.dump)
|
|
print(f"dump: {args.dump} ({size} bytes, CRC32 {crc:08X}, SHA-256 {sha[:16]}…)")
|
|
if size != 512 * 1024:
|
|
die(f"a PlayStation BIOS image is exactly 524288 bytes (got {size})")
|
|
if prof["sha256"] and prof["sha256"] != sha:
|
|
die(f"this image is not {prof['id'] or args.stem}: profile pins SHA-256 "
|
|
f"{prof['sha256'][:16]}…, dump is {sha[:16]}…")
|
|
|
|
seeds_src = os.path.join(seeds_dir, os.path.basename(prof["seeds"]))
|
|
if not os.path.isfile(seeds_src):
|
|
die(f"seeds file the profile names is not shipped: {seeds_src}")
|
|
|
|
# Stage an emitter project root: the profile's rom/seeds paths are relative
|
|
# to the directory holding a .gitignore/.git/CMakeLists.txt marker
|
|
# (config_loader find_project_root), so recreate exactly that shape.
|
|
work = tempfile.mkdtemp(prefix="psx_bios_module_")
|
|
try:
|
|
with open(os.path.join(work, ".gitignore"), "w") as f:
|
|
f.write("*\n")
|
|
rom_dst = os.path.join(work, prof["rom"])
|
|
os.makedirs(os.path.dirname(rom_dst), exist_ok=True)
|
|
shutil.copy2(args.dump, rom_dst)
|
|
seeds_dst = os.path.join(work, prof["seeds"])
|
|
os.makedirs(os.path.dirname(seeds_dst), exist_ok=True)
|
|
shutil.copy2(seeds_src, seeds_dst)
|
|
prof_dst = os.path.join(work, "bios", f"{args.stem}.toml")
|
|
os.makedirs(os.path.dirname(prof_dst), exist_ok=True)
|
|
shutil.copy2(profile_path, prof_dst)
|
|
gen = os.path.join(work, "out")
|
|
os.makedirs(gen, exist_ok=True)
|
|
|
|
env = os.environ.copy()
|
|
env.setdefault("PSX_CPS", "1")
|
|
cmd = [emitter, "--config", os.path.join("bios", f"{args.stem}.toml"), "--out-dir", gen]
|
|
print(" emit: " + " ".join(cmd))
|
|
r = subprocess.run(cmd, cwd=work, capture_output=True, text=True, env=env)
|
|
if r.returncode != 0:
|
|
tail = "\n".join((r.stderr or r.stdout or "").splitlines()[-25:])
|
|
die(f"psxrecomp-bios exited {r.returncode}:\n{tail}")
|
|
full_c = os.path.join(gen, f"{args.stem}_full.c")
|
|
disp_c = os.path.join(gen, f"{args.stem}_dispatch.c")
|
|
for p in (full_c, disp_c):
|
|
if not os.path.isfile(p):
|
|
die(f"emitter produced no {p}")
|
|
with open(disp_c, encoding="utf-8", errors="replace") as f:
|
|
if f"{args.stem}_psx_bios_backend" not in f.read():
|
|
die("emitted dispatch defines no backend descriptor (emitter too old)")
|
|
|
|
# Module translation units: the name mappings go before the first
|
|
# #include so psx_memory.h's g_psx_ram_mask is rewritten too.
|
|
prelude = '#include "psx_bios_module.h"\n'
|
|
for p in (full_c, disp_c):
|
|
with open(p, "rb") as f:
|
|
data = f.read()
|
|
if data[:3] == b"\xef\xbb\xbf":
|
|
data = data[3:]
|
|
with open(p, "wb") as f:
|
|
f.write(prelude.encode("utf-8") + data)
|
|
glue_c = os.path.join(gen, "glue.c")
|
|
with open(glue_c, "w", encoding="utf-8") as f:
|
|
f.write(prelude)
|
|
f.write('#include "overlay_dispatch_preamble.c.inc"\n')
|
|
f.write('#include "psx_bios_module_glue.c.inc"\n')
|
|
|
|
out_dir = os.path.dirname(os.path.abspath(args.out)) or "."
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
tmp_out = os.path.join(out_dir, "." + os.path.basename(args.out) + ".tmp")
|
|
if os.path.exists(tmp_out):
|
|
os.remove(tmp_out)
|
|
include_dirs = [include]
|
|
fmt_inc = os.path.join(tk, "lib", "fmt", "include") # dev-tree layout only
|
|
if os.path.isdir(fmt_inc):
|
|
include_dirs.append(fmt_inc)
|
|
if not compile_module([full_c, disp_c, glue_c], tmp_out, include_dirs,
|
|
args.stem, args.flavor, args.compiler, args.gcc, args.tcc):
|
|
if os.path.exists(tmp_out):
|
|
os.remove(tmp_out)
|
|
die("compile failed")
|
|
if not self_check(tmp_out, args.flavor):
|
|
os.remove(tmp_out)
|
|
die("self-check failed")
|
|
os.replace(tmp_out, args.out)
|
|
print(f"PSX_BIOS_MODULE_PUBLISHED {args.out}")
|
|
return 0
|
|
finally:
|
|
if args.keep_work:
|
|
print(f" work tree kept: {work}")
|
|
else:
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|