Files
psxrecomp/recompiler/tests/test_bios_module_build.py
Alex Vanderveen 60919f5a87 bios: build a retail backend from the player's own dump as a loadable module
Bundled releases link OpenBIOS only; retail-BIOS-derived C never ships.
"Bring your own BIOS" now works without it: when a dump matching a shipped
profile (psx_bios_known_images.h) is selected, the runtime builds that
backend on the player's machine with the overlay toolchain the release
already carries, caches it, and loads it as one more registered backend.

- runtime/include/psx_bios_module.h: the module contract. An overlay-style
  self-contained shared library (no imports from the executable) exporting
  psx_bios_module_{abi,codegen_hash,init,backend}; reaches the runtime only
  through OverlayCallbacks plus a new PsxBiosModuleCallbacks table (traps,
  dirty-RAM/kernel-bless probes, fntrace, log_probe) and pointer aliases of
  the globals the generated dispatcher touches. Its #defines rewrite the
  emitter's own extern lines, so the generated C compiles unchanged.
- runtime/include/psx_bios_module_glue.c.inc: module-side forwarders and the
  four exports, compiled after overlay_dispatch_preamble.c.inc.
- runtime/src/psx_bios_module.c: host side. Cache path
  <exe>/cache/bios/<os-arch>/bm<abi>_<codegen hash>_f<flavor>/<STEM>_<crc>,
  ABI + codegen-hash gated load, callback fill, and a blocking build via
  overlay_toolchain/bios_module_build.py (gcc via compiler.txt or PATH,
  else tcc), then psx_bios_register().
- psx_bios_backend.c: the registry is now a mutable table seeded from the
  CMake-generated psx_bios_builtin_registry by a constructor, with
  psx_bios_register(); every existing walker keeps working.
- main.cpp: validate_bios_for_launch acquires a module (announcing a first
  build) before declaring a mismatch; discovery accepts a buildable dump;
  the launcher verify reports it usable ("ready" / "compiled on first
  launch") with no Generate & rebuild; the BIOS row shows whenever the
  toolchain is present; bundled_only respects module support.
- emitter: the CPS-marker constructor is not emitted in a module build.
- overlay_api.h: OverlayCallbacks gains a struct tag;
  overlay_loader_callbacks() exposes the host table before loader init.
- tools/bios_module_build.py (ships in the toolchain): identity + profile
  SHA-256 gate, staged emitter tree, emit, three-TU compile, ctypes
  self-check, atomic publish. release_stage.py stages psxrecomp-bios, every
  bios/*.toml, the seeds and the builder into overlay_toolchain/.
- Tests: runtime/tests/test_bios_module_host.c against three fake modules
  (good / wrong ABI / wrong hash) on real dlopen; recompiler/tests/
  test_bios_module_build.py builds the bundled OpenBIOS end to end and reads
  the descriptor back; guards extended.

Verified on Twisted Metal 4 (bundled build + staged toolchain, headless):
first launch with --bios SCPH1001.BIN builds and loads the module and boots
on image=SCPH-1001; the second launch loads it from cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 19:26:29 -04:00

153 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""tools/bios_module_build.py end to end against the real emitter.
Builds a loadable backend from the bundled, redistributable OpenBIOS image
(the only image this tree can legally test with), then loads the result in
this interpreter and reads the descriptor back through ctypes:
* the module is self-contained (RTLD_NOW resolves everything or fails),
* it exports exactly the psx_bios_module.h entry points,
* its ABI tag carries the requested flavor and its codegen hash is the one
runtime/include/overlay_codegen_hash.h (or overlay_api.h's default) names,
* the descriptor's image identity is OpenBIOS's real size and CRC-32,
* a dump that does not match the profile's SHA-256 pin is refused before
anything is compiled.
Needs the built psxrecomp-bios (--emitter, default recompiler/build) and a C
compiler on PATH; skips (exit 0 with a notice) when either is absent, since a
BIOS-free tree cannot prove anything here.
python3 recompiler/tests/test_bios_module_build.py [--emitter PATH]
"""
import argparse
import ctypes
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zlib
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
TOOLS = os.path.join(ROOT, 'tools')
def codegen_hash_expected():
p = os.path.join(ROOT, 'runtime', 'include', 'overlay_codegen_hash.h')
if os.path.isfile(p):
m = re.search(r'PSX_OVERLAY_CODEGEN_HASH\s+(0x[0-9a-fA-F]+)', open(p).read())
if m:
return int(m.group(1), 16)
return None # header absent: overlay_api.h defaults to 0u
class PsxBiosImageInfo(ctypes.Structure):
_fields_ = [('kbless_ram_lo', ctypes.c_uint32), ('kbless_ram_hi', ctypes.c_uint32),
('kbless_rom_off', ctypes.c_uint32), ('shell_entry_phys', ctypes.c_uint32),
('deliver_event_ret', ctypes.c_uint32), ('image_size', ctypes.c_uint32),
('image_crc32', ctypes.c_uint32), ('image_wordsum', ctypes.c_uint32),
('image_sha256', ctypes.c_char_p), ('image_id', ctypes.c_char_p),
('image_bundled', ctypes.c_int)]
class PsxBiosBackend(ctypes.Structure):
_fields_ = [('image', ctypes.POINTER(PsxBiosImageInfo)),
('dispatch', ctypes.c_void_p), ('dispatch_call', ctypes.c_void_p),
('kernel_bodies', ctypes.c_void_p), ('kernel_body_count', ctypes.c_uint32),
('kernel_patch_ranges', ctypes.c_void_p), ('kernel_patch_range_count', ctypes.c_uint32)]
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--emitter', default=None)
args = ap.parse_args()
emitter = args.emitter
if not emitter:
for c in ('recompiler/build/psxrecomp-bios', 'recompiler/build/psxrecomp-bios.exe',
'recompiler/build-t2/psxrecomp-bios'):
if os.path.isfile(os.path.join(ROOT, c)):
emitter = os.path.join(ROOT, c)
break
if not emitter or not os.path.isfile(emitter):
print('SKIP: psxrecomp-bios not built (pass --emitter)')
return 0
cc = shutil.which('gcc') or shutil.which('cc') or shutil.which('clang')
if not cc:
print('SKIP: no C compiler on PATH')
return 0
if sys.platform.startswith('win'):
print('SKIP: ctypes descriptor read is validated on POSIX; the host test covers Windows loading')
return 0
dump = os.path.join(ROOT, 'bios', 'openbios.bin')
with open(dump, 'rb') as f:
data = f.read()
want_crc = zlib.crc32(data) & 0xFFFFFFFF
work = tempfile.mkdtemp(prefix='bios_module_test_')
try:
out = os.path.join(work, 'OpenBIOS_test.so')
base = [sys.executable, os.path.join(TOOLS, 'bios_module_build.py'),
'--toolchain', work, # no staged layout: every piece overridden
'--emitter', emitter,
'--include', os.path.join(ROOT, 'runtime', 'include'),
'--profiles', os.path.join(ROOT, 'bios'),
'--seeds', os.path.join(ROOT, 'recompiler', 'seeds'),
'--stem', 'OpenBIOS', '--compiler', 'gcc', '--gcc', cc]
# A wrong image is refused by the profile's SHA-256 pin, before compiling.
wrong = os.path.join(work, 'wrong.bin')
with open(wrong, 'wb') as f:
f.write(bytes(len(data)))
r = subprocess.run(base + ['--dump', wrong, '--out', out, '--flavor', '0'],
capture_output=True, text=True)
assert r.returncode != 0, 'a zero-filled image must be refused'
assert 'not OPENBIOS' in r.stdout or 'is not' in r.stdout, r.stdout[-600:]
assert not os.path.exists(out), 'refusal must leave no module'
# The real image builds, with flavor 2 so the tag's flavor bits are exercised.
r = subprocess.run(base + ['--dump', dump, '--out', out, '--flavor', '2'],
capture_output=True, text=True)
assert r.returncode == 0, r.stdout[-3000:] + r.stderr[-1000:]
assert f'PSX_BIOS_MODULE_PUBLISHED {out}' in r.stdout, r.stdout[-600:]
assert os.path.isfile(out)
lib = ctypes.CDLL(out) # RTLD_NOW: an unresolved symbol fails here
lib.psx_bios_module_abi.restype = ctypes.c_int
lib.psx_bios_module_codegen_hash.restype = ctypes.c_uint32
lib.psx_bios_module_backend.restype = ctypes.POINTER(PsxBiosBackend)
abi = lib.psx_bios_module_abi()
assert (abi & 0xFFFF) == 1, f'ABI version {abi & 0xFFFF}'
assert ((abi >> 16) & 0xFFFF) == 2, f'flavor bits {(abi >> 16) & 0xFFFF}'
h = lib.psx_bios_module_codegen_hash()
want_h = codegen_hash_expected()
if want_h is not None:
assert h == want_h, f'codegen hash {h:#x} != header {want_h:#x}'
b = lib.psx_bios_module_backend().contents
img = b.image.contents
assert img.image_id == b'OPENBIOS', img.image_id
assert img.image_size == len(data), img.image_size
assert img.image_crc32 == want_crc, f'{img.image_crc32:#x} != {want_crc:#x}'
assert img.image_bundled == 1
assert b.dispatch and b.dispatch_call, 'dispatch entry points missing'
assert b.kernel_body_count > 0, 'no kernel bodies published'
# The CPS constructor must not be in a module (it would write a host global).
nm = shutil.which('nm')
if nm:
syms = subprocess.run([nm, '-D', '--undefined-only', out], capture_output=True, text=True).stdout
assert 'g_psx_cps_mode' not in syms, 'module still references g_psx_cps_mode'
names = [ln.split()[-1] for ln in syms.splitlines() if ln.strip()]
bad = [n for n in names if not n.startswith('_') and '@' not in n]
assert not bad, f'module imports non-libc symbols: {bad}'
print(f'OK: OpenBIOS module built and loaded (abi={abi:#x}, hash={h:#x}, crc={img.image_crc32:08X})')
return 0
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == '__main__':
sys.exit(main())