mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
24c5826072
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
206 lines
11 KiB
Python
206 lines
11 KiB
Python
"""Audit native cache pairs against independently established original inputs.
|
|
|
|
This verifies byte/ABI provenance, not the semantics of generated native code.
|
|
Input recipes themselves still require original-disc and loader verification.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
|
|
_RANGES = re.compile(r'static const uint32_t (psx_ov_static_ranges_\d+)\[\] = \{([^}]*)\};')
|
|
_VARIANT = re.compile(r'\{ (psx_ov_static_ranges_\d+), (\d+)u, 0x([0-9A-Fa-f]{8})u, (\w+) \},')
|
|
_ENTRY = re.compile(r'\{ 0x([0-9A-Fa-f]{8})u, (\d+)u, (\d+)u \},')
|
|
|
|
|
|
def static_dispatch_identities(path):
|
|
"""(entry, crc, ranges) of every variant in a generated static dispatcher."""
|
|
text = Path(path).read_text(encoding='utf-8')
|
|
ranges = {}
|
|
for symbol, body in _RANGES.findall(text):
|
|
words = [int(word.strip().rstrip('u'), 0) for word in body.split(',') if word.strip()]
|
|
assert len(words) % 2 == 0 and words, f'Malformed static range table: {symbol}'
|
|
ranges[symbol] = [(words[i], words[i + 1]) for i in range(0, len(words), 2)]
|
|
table = text.split('psx_ov_variants[', 1)
|
|
variants = [] if len(table) == 1 else _VARIANT.findall(table[1].split('};', 1)[0])
|
|
entries = [] if 'psx_ov_entries[' not in text else _ENTRY.findall(text.split('psx_ov_entries[', 1)[1].split('};', 1)[0])
|
|
ids = []
|
|
for address, first, count in entries:
|
|
for symbol, n, crc, _ in variants[int(first):int(first) + int(count)]:
|
|
assert len(ranges[symbol]) == int(n), f'Static range count mismatch: {symbol}'
|
|
ids.append((int(address, 16), int(crc, 16), ranges[symbol]))
|
|
assert sum(int(count) for _, _, count in entries) == len(variants), 'Static entry table mismatch'
|
|
return ids
|
|
|
|
|
|
def resident_metadata(dll, record):
|
|
"""The compiler's preload opt-in must agree with the verified recipe."""
|
|
marker = dll.with_suffix('.resident')
|
|
resident = record.get('producer') == 'bios_resident_manifest'
|
|
assert marker.exists() == resident, f'Resident preload marker/recipe mismatch: {dll.name}'
|
|
if not resident:
|
|
return {}
|
|
body = marker.read_bytes()
|
|
document = json.loads(body)
|
|
assert document.get('schema') == 'psxrecomp bios resident shard v1', f'Invalid resident marker: {dll.name}'
|
|
bios_hash = record.get('bios_sha256', '')
|
|
assert len(bios_hash) == 64 and all(c in '0123456789abcdef' for c in bios_hash), 'Invalid resident BIOS hash'
|
|
assert document.get('bios_sha256') == bios_hash, f'Resident BIOS provenance mismatch: {dll.name}'
|
|
assert document.get('producer_name') == str(record.get('producer_name', 'BIOS resident code')), \
|
|
f'Resident producer mismatch: {dll.name}'
|
|
return dict(resident_sha256=hashlib.sha256(body).hexdigest())
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--framework-root', type=Path,
|
|
default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument('--recompiler', type=Path, required=True)
|
|
parser.add_argument('--game-toml', type=Path, required=True)
|
|
outputs = parser.add_mutually_exclusive_group(required=True)
|
|
outputs.add_argument('--cache-root', type=Path)
|
|
outputs.add_argument('--static-dispatch', type=Path,
|
|
help='generated overlays_static.c; its units are audited beside it')
|
|
inputs = parser.add_mutually_exclusive_group(required=True)
|
|
inputs.add_argument('--records', type=Path)
|
|
inputs.add_argument('--inventory', type=Path)
|
|
parser.add_argument('--flavor', type=int, default=0)
|
|
parser.add_argument('--expected-pairs', type=int)
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
args = parser.parse_args()
|
|
framework = args.framework_root.resolve()
|
|
sys.path.insert(0, str(framework / 'tools'))
|
|
import compile_overlays as compiler
|
|
try:
|
|
import tomllib
|
|
except ImportError:
|
|
import tomli as tomllib
|
|
config = tomllib.loads(args.game_toml.read_text(encoding='utf-8-sig'))
|
|
game_id = config['game']['id']
|
|
include = framework / 'runtime/include'
|
|
tag = compiler.cache_tag(str(include), str(args.recompiler),
|
|
str(args.game_toml), args.flavor)
|
|
cache = None if args.static_dispatch else args.cache_root / game_id / 'gcc' / compiler.cache_arch_abi() / tag
|
|
abi = compiler.overlay_abi_tag(str(include), args.flavor)
|
|
source = args.inventory or args.records
|
|
source_bytes = source.read_bytes()
|
|
document = json.loads(source_bytes)
|
|
jobs = document['jobs'] if args.inventory else [
|
|
dict(name=f'record-{index:03d}', record=record)
|
|
for index, record in enumerate(document)]
|
|
recipes = []
|
|
records_by_name = {}
|
|
for job in jobs:
|
|
if args.inventory:
|
|
record = json.loads(Path(job['input']).read_text(encoding='utf-8-sig'))[0]
|
|
else:
|
|
record = job['record']
|
|
assert not record.get('executed_pcs'), 'Runtime observations are not AOT inputs'
|
|
data = base64.b64decode(record['bytes_b64'], validate=True)
|
|
assert len(data) == record['size']
|
|
if 'sha256' in job:
|
|
assert hashlib.sha256(data).hexdigest() == job['sha256']
|
|
load = int(record['load_addr'], 0)
|
|
known = job.get('known_ranges')
|
|
if known is None:
|
|
known = [(int(r['start'], 0), int(r['end'], 0))
|
|
for r in record.get('producer_ranges', [])]
|
|
assert known, f"No established producer bounds: {job['name']}"
|
|
bounds = [(lo & 0x1fffffff, hi & 0x1fffffff) for lo, hi in known]
|
|
assert all(lo < hi for lo, hi in bounds)
|
|
recipes.append((job['name'], load, data, bounds))
|
|
records_by_name[job['name']] = record
|
|
|
|
pairs = []
|
|
native_ids = []
|
|
def within_bounds(ranges, bounds):
|
|
return all(any(lo <= (start & 0x1fffffff) and (start & 0x1fffffff) + length <= hi
|
|
for lo, hi in bounds) for start, length in ranges)
|
|
variants = []
|
|
if args.static_dispatch:
|
|
# Linked-in variants carry no DLL stem, so each must be proven by the
|
|
# recipe whose original bytes its guard was computed from.
|
|
ids = static_dispatch_identities(args.static_dispatch)
|
|
assert ids, f'No static variants in {args.static_dispatch}'
|
|
for item in ids:
|
|
matched = next((name for name, load, data, bounds in recipes
|
|
if within_bounds(item[2], bounds) and
|
|
compiler.current_variant_func_ids([item], data, load, len(data))), None)
|
|
assert matched, f'No original-input guard proof for static entry 0x{item[0]:08X}'
|
|
variants.append(dict(entry=f'0x{item[0]:08X}', crc=f'0x{item[1]:08X}', matched_recipe=matched))
|
|
native_ids.append((None, [item]))
|
|
dlls = [] if args.static_dispatch else sorted(cache.glob('*' + compiler.overlay_ext()))
|
|
assert dlls or args.static_dispatch, f'No native pairs in expected cache namespace: {cache}'
|
|
for dll in dlls:
|
|
ids = compiler.load_shard_func_ids(str(dll), abi)
|
|
assert ids, f'Invalid ABI/exports/pair/manifest: {dll}'
|
|
physical = int(dll.stem.split('_')[0], 16)
|
|
native_ids.append((physical, ids))
|
|
matched = None
|
|
for name, load, data, bounds in recipes:
|
|
if (load & 0x1fffffff) != physical:
|
|
continue
|
|
known = all(any(lo <= (start & 0x1fffffff) and
|
|
(start & 0x1fffffff) + length <= hi
|
|
for lo, hi in bounds)
|
|
for _, _, ranges in ids for start, length in ranges)
|
|
if known and len(compiler.current_variant_func_ids(ids, data, load, len(data))) == len(ids):
|
|
matched = name
|
|
break
|
|
assert matched, f'No original-input guard proof for {dll.name}'
|
|
pairs.append(dict(dll=dll.name, functions=len(ids), matched_recipe=matched,
|
|
dll_sha256=hashlib.sha256(dll.read_bytes()).hexdigest(),
|
|
manifest_sha256=hashlib.sha256(dll.with_suffix('.ranges').read_bytes()).hexdigest(),
|
|
**resident_metadata(dll, records_by_name[matched])))
|
|
if args.expected_pairs is not None:
|
|
assert len(pairs) == args.expected_pairs, (len(pairs), args.expected_pairs)
|
|
image_coverage = []
|
|
required = {job['name']: {entry & 0x1fffffff for entry in job.get('required_entries', [])}
|
|
for job in jobs}
|
|
for name, load, data, bounds in recipes:
|
|
compatible = set()
|
|
for physical, ids in native_ids:
|
|
if physical is not None and physical != (load & 0x1fffffff):
|
|
continue
|
|
bounded = [item for item in ids if all(
|
|
any(lo <= (start & 0x1fffffff) and (start & 0x1fffffff) + length <= hi
|
|
for lo, hi in bounds) for start, length in item[2])]
|
|
compatible.update((entry, crc, tuple(tuple(r) for r in ranges))
|
|
for entry, crc, ranges in compiler.current_variant_func_ids(
|
|
bounded, data, load, len(data)))
|
|
assert compatible, f'No guarded native entries for input image: {name}'
|
|
served = {item[0] & 0x1fffffff for item in compatible}
|
|
assert required[name] <= served, f'Missing loader-established entries for {name}: {sorted(required[name] - served)}'
|
|
image_coverage.append(dict(name=name,
|
|
required_entries_verified=len(required[name]),
|
|
native_entries=len({item[0] for item in compatible}),
|
|
guarded_variants=len(compatible)))
|
|
files = {}
|
|
if args.static_dispatch:
|
|
stem = args.static_dispatch.stem
|
|
for path in [args.static_dispatch] + sorted(args.static_dispatch.parent.glob(stem + '_[0-9]*.c')):
|
|
files[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
receipt = dict(game_id=game_id, cache_tag=tag, flavor=args.flavor,
|
|
recipe_input_sha256=hashlib.sha256(source_bytes).hexdigest(),
|
|
recipe_count=len(recipes), published_pairs=len(pairs),
|
|
manifest_rows=sum(pair['functions'] for pair in pairs),
|
|
all_pairs_valid=True, all_guards_match_known_input_bytes=True,
|
|
full_static_coverage_proven=False,
|
|
image_coverage=image_coverage, pairs=pairs)
|
|
if args.static_dispatch:
|
|
# Linked into the runtime: no DLL cache namespace, flavor tag or pairs.
|
|
for key in ('cache_tag', 'flavor', 'published_pairs', 'manifest_rows', 'pairs'):
|
|
receipt.pop(key)
|
|
receipt.update(static=True, published_variants=len(variants), files=files, variants=variants)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(receipt, indent=2), encoding='utf-8', newline='\n')
|
|
print(json.dumps({k: v for k, v in receipt.items()
|
|
if k not in ('pairs', 'image_coverage', 'variants', 'files')}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|