mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user