diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index b302fa24..5ab0a00e 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -918,6 +918,15 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_aot_overlay_discovery.py --recompiler $ 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 $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) # ---- runtime source-invariant guards -------------------------------- # These live in runtime/tests/ but only READ runtime source, so they diff --git a/tools/aot_overlay_pipeline.py b/tools/aot_overlay_pipeline.py index 02c6a8dc..5f95622b 100644 --- a/tools/aot_overlay_pipeline.py +++ b/tools/aot_overlay_pipeline.py @@ -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) diff --git a/tools/aot_overlay_spike/extract_generic.py b/tools/aot_overlay_spike/extract_generic.py index 667bbfb2..78c80233 100644 --- a/tools/aot_overlay_spike/extract_generic.py +++ b/tools/aot_overlay_spike/extract_generic.py @@ -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 ") + 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) diff --git a/tools/tests/test_extract_discovery_project_root.py b/tools/tests/test_extract_discovery_project_root.py new file mode 100644 index 00000000..7a1feca8 --- /dev/null +++ b/tools/tests/test_extract_discovery_project_root.py @@ -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)