Merge pull request #434 from RetroPortingToolKit/fix/aot-discovery-project-root

AOT extract: resolve discovery's BIOS profile from the framework, fail closed
This commit is contained in:
Matt Stanley
2026-09-29 16:44:22 -07:00
committed by GitHub
4 changed files with 165 additions and 9 deletions
+9
View File
@@ -918,6 +918,15 @@ if(BUILD_TESTING)
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_aot_overlay_discovery.py
--recompiler $<TARGET_FILE:psxrecomp-game>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# Original-disc extraction's normal-mode discovery resolves its BIOS
# profile from --project-root, not the cwd, and a failed recompiler run
# aborts extraction instead of silently shipping fewer native pairs.
add_test(
NAME extract_discovery_project_root
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_extract_discovery_project_root.py
--recompiler $<TARGET_FILE:psxrecomp-game>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# ---- runtime source-invariant guards --------------------------------
# These live in runtime/tests/ but only READ runtime source, so they
+2 -1
View File
@@ -704,7 +704,8 @@ def extract(profile_path, game_toml, recompiler, output, cue=None, disc=None, pr
command = [sys.executable, str(FRAMEWORK / 'tools/aot_overlay_spike/extract_generic.py'),
'--game-toml', str(game_toml), '--recompiler', str(recompiler),
'--disc', str(cue),
'--out', str(output / 'generic.json'), '--tmp', str(output / 'extract-tmp')]
'--out', str(output / 'generic.json'), '--tmp', str(output / 'extract-tmp'),
'--project-root', str(FRAMEWORK)]
command += ['--require-bios-resident'] if profile.get('bios_resident') else ['--no-bios-resident']
with (output / 'extract.log').open('w') as log:
result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT)
+50 -8
View File
@@ -646,20 +646,52 @@ def parse_full_discovery_ranges(lines):
pending=None
return sorted(seeds),sorted(set(aliases))
def full_discovery_seeds(data, recompiler, tmp):
"""Run NORMAL mode; return discovered entries plus exact alias recipes."""
class DiscoveryError(RuntimeError):
"""Normal-mode discovery could not run; the extraction must not continue."""
# The framework this tool belongs to (tools/aot_overlay_spike/ -> root). Normal
# mode resolves its BIOS profile against --project-root; without it the
# recompiler probes the process cwd, so the same release run found every entry
# from a framework checkout and none from a directory without bios/ (it exits
# "no BIOS profile found"), shipping fewer native pairs with no error.
FRAMEWORK_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
_project_root = None # set by main() from --project-root
def discovery_project_root(explicit=None):
"""The --project-root every normal-mode run uses: explicit, else the
framework root. It must hold bios/SCPH1001.toml, or nothing can resolve."""
root = os.path.abspath(explicit or _project_root or FRAMEWORK_ROOT)
if not os.path.isfile(os.path.join(root, 'bios', 'SCPH1001.toml')):
raise DiscoveryError(f"no BIOS profile at {os.path.join(root, 'bios', 'SCPH1001.toml')}; "
"pass --project-root <framework or game-project root>")
return root
def full_discovery_seeds(data, recompiler, tmp, project_root=None):
"""Run NORMAL mode; return discovered entries plus exact alias recipes.
A run that does not complete raises DiscoveryError with the recompiler's
own message. Falling back here would silently drop every normal-mode entry
and alias the producer has. (None, []) means the run completed and found
no entry, which the callers' prologue fallback covers."""
exe_path = os.path.join(tmp, 'producer.exe')
open(exe_path,'wb').write(data)
out = os.path.join(tmp, f'disc_out_{binascii.crc32(data)&0xFFFFFFFF:08X}')
os.makedirs(out, exist_ok=True)
cmd = [recompiler, exe_path, '--out-dir', out,
'--project-root', discovery_project_root(project_root)]
try:
# errors='replace': the recompiler prints non-ASCII (✓) that would raise
# a decode error under text=True and lose the (already-written) ranges.
subprocess.run([recompiler, exe_path, '--out-dir', out],
capture_output=True, text=True, errors='replace', timeout=300)
except Exception as e:
print(f" full-discovery failed ({e}); falling back to prologue scan")
return None,[]
r = subprocess.run(cmd, capture_output=True, text=True, errors='replace', timeout=300)
except (OSError, subprocess.SubprocessError) as e:
raise DiscoveryError(f"normal-mode discovery did not run: {' '.join(cmd)}: {e}") from e
if r.returncode != 0:
tail = '\n'.join(((r.stdout or '') + (r.stderr or '')).strip().splitlines()[-12:])
raise DiscoveryError(f"normal-mode discovery failed (exit {r.returncode}): "
f"{' '.join(cmd)}\n{tail}")
lines=[]
for rf in [f for f in os.listdir(out) if f.endswith('.ranges')]:
lines.extend(open(os.path.join(out,rf), errors='ignore'))
@@ -908,6 +940,9 @@ def main():
help="cue to read (default: the game config's [game].disc)")
ap.add_argument('--out', required=True)
ap.add_argument('--tmp', default=None)
ap.add_argument('--project-root', default=None,
help='root holding bios/SCPH1001.toml for normal-mode discovery '
'(default: this framework)')
ap.add_argument('--bios', default=None,
help='BIOS ROM for exact-hash resident-code recipes (default: '
'PSXRECOMP_BIOS_ROM or framework bios/SCPH1001.BIN)')
@@ -920,6 +955,8 @@ def main():
ap.add_argument('--only-bios-resident', action='store_true',
help='emit only exact-hash BIOS-installed RAM code captures')
a=ap.parse_args()
global _project_root
_project_root=a.project_root
if a.require_bios_resident and a.no_bios_resident:
ap.error('--require-bios-resident conflicts with --no-bios-resident')
if a.only_bios_resident and a.no_bios_resident:
@@ -1227,4 +1264,9 @@ def main():
f"{nc} adjacent composites, {nb} BIOS resident; "
f"{len(records)} regions -> {a.out}")
if __name__=='__main__': main()
if __name__=='__main__':
try:
main()
except DiscoveryError as e:
print(f"extract_generic: FATAL: {e}", file=sys.stderr)
sys.exit(2)
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""extract_generic's normal-mode discovery must not depend on the process cwd,
and must fail closed when the recompiler does not complete.
Before this test, full_discovery_seeds ran psxrecomp-game with no
--project-root and ignored its exit code. From a cwd without bios/ the
recompiler exits "no BIOS profile found", the pipeline fell back to reduced
seeds, and aot_overlay_pipeline release shipped fewer native pairs with no
error (Tomba 2: 54 pairs instead of 81).
Build/run: ctest -R extract_discovery_project_root
"""
import argparse
import os
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, '..', 'aot_overlay_spike'))
sys.path.insert(0, os.path.join(HERE, '..'))
import extract_generic as eg # noqa: E402
RECOMPILER = None
BASE = 0x80010000
# f: jal g; nop; jr ra; nop g: jr ra; nop
BODY = b''.join(w.to_bytes(4, 'little') for w in (
0x0C000000 | ((BASE + 0x10) >> 2) & 0x03FFFFFF, 0, 0x03E00008, 0,
0x03E00008, 0))
def fake_recompiler(directory, exit_code, message):
"""An executable that prints `message` and exits `exit_code`."""
if os.name == 'nt':
path = os.path.join(directory, 'fake_recompiler.cmd')
with open(path, 'w') as f:
f.write(f'@echo off\r\necho {message} 1>&2\r\nexit /b {exit_code}\r\n')
else:
path = os.path.join(directory, 'fake_recompiler.sh')
with open(path, 'w') as f:
f.write(f'#!/bin/sh\necho "{message}" >&2\nexit {exit_code}\n')
os.chmod(path, 0o755)
return path
class DiscoveryProjectRoot(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.cwd = os.getcwd()
# A cwd with no bios/ and no framework below it: the case that
# silently degraded.
self.empty_cwd = os.path.join(self.tmp.name, 'elsewhere')
os.makedirs(self.empty_cwd)
os.chdir(self.empty_cwd)
def tearDown(self):
os.chdir(self.cwd)
self.tmp.cleanup()
def work(self, name):
path = os.path.join(self.tmp.name, name)
os.makedirs(path, exist_ok=True)
return path
def test_discovers_from_a_cwd_without_bios(self):
if not RECOMPILER:
self.skipTest('--recompiler not given')
data = eg.make_psx_exe(BODY, BASE, BASE)
seeds, _ = eg.full_discovery_seeds(data, RECOMPILER, self.work('real'))
self.assertIsNotNone(seeds, 'normal mode found no entry from a cwd without bios/')
self.assertIn(BASE, seeds)
self.assertIn(BASE + 0x10, seeds)
def test_failed_run_raises_with_its_message(self):
fake = fake_recompiler(self.tmp.name, 1, 'FATAL: no BIOS profile found')
with self.assertRaises(eg.DiscoveryError) as cm:
eg.full_discovery_seeds(eg.make_psx_exe(BODY, BASE, BASE), fake,
self.work('fail'))
self.assertIn('exit 1', str(cm.exception))
self.assertIn('no BIOS profile found', str(cm.exception))
def test_unrunnable_recompiler_raises(self):
missing = os.path.join(self.tmp.name, 'no_such_recompiler.exe')
with self.assertRaises(eg.DiscoveryError):
eg.full_discovery_seeds(eg.make_psx_exe(BODY, BASE, BASE), missing,
self.work('missing'))
def test_root_without_profile_is_refused(self):
with self.assertRaises(eg.DiscoveryError):
eg.discovery_project_root(self.empty_cwd)
def test_default_root_is_this_framework(self):
root = eg.discovery_project_root()
self.assertTrue(os.path.isfile(os.path.join(root, 'bios', 'SCPH1001.toml')))
self.assertEqual(os.path.normcase(root),
os.path.normcase(os.path.abspath(os.path.join(HERE, '..', '..'))))
if __name__ == '__main__':
ap = argparse.ArgumentParser()
ap.add_argument('--recompiler')
args, rest = ap.parse_known_args()
RECOMPILER = os.path.abspath(args.recompiler) if args.recompiler else None
unittest.main(argv=[sys.argv[0]] + rest)