Files
psxrecomp/tools/tests/test_overlay_no_split_guard.py

768 lines
38 KiB
Python

#!/usr/bin/env python3
"""The overlay no-split guard, image-local root enrichment, and the cache key.
A walk root is a hard cap: the walk of the root below it stops there, so a
root inside another function's body splits that function. The generated C
still passes every audit, which is why this class never shows up as a shard
failure. Measured on Tomba (bead beads-eio.3.177): shared engine code, byte
identical in 12 swapped area images, does `jal 0x8011B1CC`; that address is a
function start in one image and a `subu` mid-way through host 0x8011AC10 in
X00. Rooting it there split the host and moved guest timing by one cycle
against the interpreter.
Usage: test_overlay_no_split_guard.py --cmake <cmake> [--recompiler <exe>]
"""
import argparse
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'tools'))
import compile_overlays as CO # noqa: E402
LOAD = 0x80100000
NOP = 0x00000000
JR_RA = 0x03E00008
FRAME = 0x27BDFFE0 # addiu sp,sp,-32
UNFRAME = 0x27BD0020 # addiu sp,sp,32
ADDIU = 0x24420001 # addiu v0,v0,1
SUBU = 0x00431023 # subu v0,v0,v1
CMAKE = shutil.which('cmake') # --cmake overrides; ctest always passes it
RECOMPILER = None
def jal(target):
return 0x0C000000 | ((target >> 2) & 0x03FFFFFF)
def bne_v0(pc, target):
return 0x14400000 | (((target - (pc + 4)) >> 2) & 0xFFFF)
def image(words: dict, size: int) -> bytes:
data = bytearray(size)
for addr, word in words.items():
struct.pack_into('<I', data, addr - LOAD, word)
return bytes(data)
def classify(data, enrichment=True, **cap_fields):
cap = {'schema': 'psxrecomp overlay capture v2',
'function_entry_pcs': [], 'dispatch_entry_pcs': []}
for key, values in cap_fields.items():
cap[key] = [f'0x{addr:08X}' for addr in values]
return CO.classify_overlay_seeds(cap, data, LOAD, len(data), 0, {},
root_enrichment=enrichment)
def root_seeds(seeds):
return {int(seed.split()[-1], 16) for seed in seeds
if seed.split()[0] in ('call_root', 'dispatch_root') or
seed.startswith('0x')}
class NoSplitGuardTests(unittest.TestCase):
# (a) ------------------------------------------------------------------
def test_fallthrough_candidate_is_demoted_not_rooted(self):
host, mid = LOAD + 0x20, LOAD + 0x30
data = image({host: FRAME, host + 4: ADDIU, host + 8: ADDIU,
host + 12: ADDIU, mid: SUBU, mid + 4: ADDIU,
mid + 8: JR_RA, mid + 12: UNFRAME}, 0x100)
# A #386-style enriched capture declared the mid-body word as a
# static discovery root; it passes the old CFG-only probe.
self.assertTrue(CO.plausible_callable_target(
data, LOAD, len(data), mid, LOAD + len(data)))
for enrichment in (True, False):
seeds, audit = classify(data, enrichment,
function_entry_pcs=[host],
static_discovery_entry_pcs=[mid])
self.assertEqual(audit['included_reasons'][mid],
'DISPATCH_INTERIOR')
self.assertIn(f'interior 0x{mid:08X}', seeds)
self.assertNotIn(mid, root_seeds(seeds))
self.assertIn(host, root_seeds(seeds))
self.assertEqual(audit['guard_demoted'][mid],
('STATIC_DISCOVERY_ROOT', host))
# (b) ------------------------------------------------------------------
def test_branch_reached_candidate_is_demoted(self):
host = LOAD + 0x40
target = host + 0x14
caller = LOAD + 0x80
data = image({host: FRAME,
host + 4: bne_v0(host + 4, target), host + 8: NOP,
host + 12: JR_RA, host + 16: UNFRAME,
# The branch target sits right after `jr ra; delay`:
# it looks like a frameless function and passes the
# image-local proof, and something jals it, so
# enrichment derives it.
target: ADDIU, target + 4: JR_RA,
target + 8: UNFRAME,
caller: FRAME, caller + 4: jal(target),
caller + 8: NOP, caller + 12: JR_RA,
caller + 16: UNFRAME}, 0x100)
self.assertIn(target, CO.derive_static_roots(data, LOAD, len(data)))
seeds, audit = classify(data, True, function_entry_pcs=[host])
self.assertEqual(audit['included_reasons'][target],
'DISPATCH_INTERIOR')
self.assertNotIn(target, root_seeds(seeds))
self.assertEqual(audit['guard_demoted'][target][1], host)
def test_enrichment_root_in_unreached_body_word_is_dropped(self):
# The host branches (b) over a dead block whose first word looks like
# a prologue. As a root it would cap the host and turn the branch
# into a cross-function exit, although nothing reaches it. Enrichment
# evidence is weak: it is dropped, not rooted and not aliased.
host = LOAD + 0x20
dead, label = host + 0x10, host + 0x30
data = image({host: FRAME,
host + 4: 0x10000000 | (((label - (host + 8)) >> 2)
& 0xFFFF),
host + 8: NOP,
dead: FRAME, dead + 4: ADDIU, dead + 8: JR_RA,
dead + 12: UNFRAME,
label: ADDIU, label + 4: JR_RA,
label + 8: UNFRAME}, 0x100)
self.assertIn(dead, CO.derive_static_roots(data, LOAD, len(data)))
seeds, audit = classify(data, True, function_entry_pcs=[host])
self.assertNotIn(dead, root_seeds(seeds))
self.assertNotIn(dead, audit['included_reasons'])
self.assertEqual(audit['excluded_reasons'][dead],
'ENRICHMENT_SHADOWED')
self.assertEqual(root_seeds(seeds), {host})
# An absolute j over it is how tail calls are encoded, so that alone
# does not make the candidate part of the host.
tail = bytearray(data)
struct.pack_into('<I', tail, host + 4 - LOAD,
0x08000000 | ((label >> 2) & 0x03FFFFFF))
seeds, audit = classify(bytes(tail), True, function_entry_pcs=[host])
self.assertIn(dead, root_seeds(seeds))
def test_delay_slot_candidate_is_never_root_nor_alias(self):
host, callee = LOAD + 0x20, LOAD + 0x80
slot = host + 8
data = image({host: FRAME, host + 4: jal(callee), slot: ADDIU,
host + 12: JR_RA, host + 16: UNFRAME,
callee: JR_RA, callee + 4: NOP}, 0x100)
seeds, audit = classify(data, True, function_entry_pcs=[host],
static_discovery_entry_pcs=[slot],
dispatch_entry_pcs=[slot])
self.assertNotIn(slot, audit['included_reasons'])
self.assertEqual(audit['excluded_reasons'][slot], 'DELAY_SLOT')
self.assertFalse(any(seed.endswith(f'0x{slot:08X}') for seed in seeds))
self.assertIn(slot, audit['delay_slot_rejected'])
self.assertFalse(CO.image_local_entry_proven(
data, LOAD, len(data), slot, LOAD + len(data)))
def test_switch_case_roots_absorbed_together(self):
# Every case label passes the image-local proof (each case before it
# ends in `jr ra`), and the jump table is a >=3 pointer run, so
# enrichment derives them all. The table resolves only when every
# target is inside the host's walk, so they must be absorbed as a run.
host = LOAD
table = LOAD + 0x200
cases = [LOAD + 0x68, LOAD + 0x78, LOAD + 0x88]
words = {host: FRAME,
LOAD + 0x04: 0x3C088010, # lui t0,0x8010
LOAD + 0x08: 0x25100200, # addiu s0,t0,0x200
LOAD + 0x0C: 0x2C620003, # sltiu v0,v1,3
LOAD + 0x10: 0x10400000 | ((0x98 - 0x14) >> 2), # beq v0,zero
LOAD + 0x14: NOP,
LOAD + 0x18: 0x00031080, # sll v0,v1,2
LOAD + 0x1C: 0x00501021, # addu v0,v0,s0
LOAD + 0x20: 0x8C420000, # lw v0,0(v0)
LOAD + 0x24: NOP,
LOAD + 0x28: 0x00400008, # jr v0
LOAD + 0x2C: NOP,
LOAD + 0x98: JR_RA, LOAD + 0x9C: UNFRAME}
for index, case in enumerate(cases):
words[case] = ADDIU
words[case + 4] = JR_RA
words[case + 8] = UNFRAME
words[table + index * 4] = case
data = image(words, 0x300)
derived = CO.derive_static_roots(data, LOAD, len(data))
self.assertTrue(set(cases[1:]) <= derived)
# The guard alone (the case labels supplied as explicit roots, as a
# #386-enriched capture would) absorbs the whole run.
seeds, audit = classify(data, False, function_entry_pcs=[host],
static_discovery_entry_pcs=cases[1:])
for case in cases[1:]:
self.assertEqual(audit['included_reasons'][case],
'DISPATCH_INTERIOR', hex(case))
self.assertNotIn(case, root_seeds(seeds))
# Default enrichment: the guarded prepass resolves the table, so the
# case labels are never offered as roots at all.
seeds, audit = classify(data, True, function_entry_pcs=[host])
for case in cases:
self.assertIn(audit['included_reasons'].get(case),
(None, 'DISPATCH_INTERIOR'), hex(case))
self.assertNotIn(case, root_seeds(seeds))
self.assertEqual(root_seeds(seeds), {host})
# (c) ------------------------------------------------------------------
def test_jal_proven_function_start_stays_a_root(self):
caller, framed, frameless = LOAD, LOAD + 0x40, LOAD + 0x80
data = image({caller: FRAME, caller + 4: jal(framed),
caller + 8: NOP, caller + 12: jal(frameless),
caller + 16: NOP, caller + 20: JR_RA,
caller + 24: UNFRAME,
framed: FRAME, framed + 4: ADDIU, framed + 8: JR_RA,
framed + 12: UNFRAME,
frameless - 8: JR_RA, frameless - 4: NOP,
frameless: ADDIU, frameless + 4: JR_RA,
frameless + 8: NOP}, 0x100)
derived = CO.derive_static_roots(data, LOAD, len(data))
self.assertTrue({caller, framed, frameless} <= derived)
seeds, audit = classify(data, True)
roots = root_seeds(seeds)
self.assertTrue({caller, framed, frameless} <= roots)
self.assertEqual(audit['guard_demoted'], {})
# (d) ------------------------------------------------------------------
def _cross_image(self, target_is_function):
engine, host = LOAD, LOAD + 0x100
target = LOAD + 0x110
words = {engine: FRAME, engine + 4: jal(target), engine + 8: NOP,
engine + 12: JR_RA, engine + 16: UNFRAME}
if target_is_function:
# Sibling image: the called address is a real function here.
words.update({host: ADDIU, host + 4: JR_RA, host + 8: NOP,
target: FRAME, target + 4: ADDIU,
target + 8: JR_RA, target + 12: UNFRAME})
else:
# This image: the same address is mid-way through a host
# reached by pure fallthrough (the X00 shape).
words.update({host: FRAME, host + 4: ADDIU, host + 8: ADDIU,
host + 12: ADDIU, target: SUBU,
target + 4: ADDIU, target + 8: JR_RA,
target + 12: UNFRAME})
return image(words, 0x200), engine, host, target
def test_shared_jal_into_mid_body_is_not_rooted(self):
data, engine, host, target = self._cross_image(False)
weak = set()
strong = CO.derive_static_roots(data, LOAD, len(data), weak_out=weak)
self.assertNotIn(target, strong)
self.assertIn(target, weak) # the CFG probe alone accepts it
self.assertTrue({engine, host} <= strong)
derived, stats = CO.derive_enrichment_roots(data, LOAD, len(data))
self.assertNotIn(target, derived) # host's prologue reaches it
self.assertEqual(stats['weak_dropped'], 1)
# Enrichment on (default): the rooted engine walk still derives the
# jal target, and the guard absorbs it into its host.
seeds, audit = classify(data, True)
self.assertNotIn(target, root_seeds(seeds))
self.assertEqual(audit['included_reasons'][target],
'DISPATCH_INTERIOR')
self.assertIn(host, root_seeds(seeds))
# A #386-enriched capture that already names it is demoted too.
seeds, audit = classify(data, True,
static_discovery_entry_pcs=[target])
self.assertNotIn(target, root_seeds(seeds))
self.assertEqual(audit['guard_demoted'][target],
('STATIC_DISCOVERY_ROOT', host))
def test_weak_target_nothing_reaches_is_rooted(self):
# A frameless function right after another function's tail jump,
# called by jal: no `jr $ra` before it, so no boundary proof, but no
# possible function start reaches it either.
caller, tail, leaf, far = LOAD, LOAD + 0x40, LOAD + 0x4C, LOAD + 0x80
data = image({caller: FRAME, caller + 4: jal(leaf), caller + 8: NOP,
caller + 12: JR_RA, caller + 16: UNFRAME,
tail: ADDIU,
tail + 4: 0x08000000 | ((far >> 2) & 0x03FFFFFF),
tail + 8: NOP,
leaf: ADDIU, leaf + 4: JR_RA, leaf + 8: NOP,
far: JR_RA, far + 4: NOP}, 0x100)
weak = set()
CO.derive_static_roots(data, LOAD, len(data), weak_out=weak)
self.assertIn(leaf, weak)
derived, _stats = CO.derive_enrichment_roots(data, LOAD, len(data))
self.assertIn(leaf, derived)
seeds, _audit = classify(data, True)
self.assertIn(leaf, root_seeds(seeds))
def test_shared_jal_into_real_function_in_sibling_image_is_rooted(self):
data, _engine, _host, target = self._cross_image(True)
self.assertIn(target, CO.derive_static_roots(data, LOAD, len(data)))
seeds, _audit = classify(data, True)
self.assertIn(target, root_seeds(seeds))
def test_cross_producer_call_evidence(self):
# Producer A calls into producer B. A cross-producer call is only
# ever weaker evidence: never WEAK evidence, STRONG only when B's own
# bytes bound the target and the capture is not strict.
a_fn, bounded, frameless = LOAD, LOAD + 0x110, LOAD + 0x130
data = image({a_fn: FRAME, a_fn + 4: jal(bounded), a_fn + 8: NOP,
a_fn + 12: jal(frameless), a_fn + 16: NOP,
a_fn + 20: JR_RA, a_fn + 24: UNFRAME,
bounded - 8: JR_RA, bounded - 4: NOP,
bounded: ADDIU, bounded + 4: JR_RA, bounded + 8: NOP,
frameless - 4: ADDIU,
frameless: ADDIU, frameless + 4: JR_RA,
frameless + 8: NOP}, 0x200)
ranges = [(LOAD, LOAD + 0x100), (LOAD + 0x100, LOAD + 0x200)]
for strict in (True, False):
roots, _ = CO.derive_enrichment_roots(
data, LOAD, len(data), ranges, strict_producer_ranges=strict)
self.assertNotIn(frameless, roots)
self.assertEqual(bounded in roots, not strict)
# late prologues (beads-eio.3.191) -------------------------------------
# A stack adjust is a boundary only when it is at one. Compilers schedule
# loads in front of it, so rooting every `addiu sp,sp,-N` capped 359 Ace
# Combat 3 functions two to five words in, 175 of them with the real
# start executed. The prologue must root the function's true start.
AC3 = { # Ace Combat 3 USA image 0 (load 0x800C8000), 0x800D1250..
0x00: JR_RA, # jr ra
0x04: 0x27BD0018, # addiu sp,sp,24 (delay slot)
0x08: 0x3C02800F, # lui v0,0x800F <- real start
0x0C: 0x8C435D4C, # lw v1,0x5D4C(v0)
0x10: 0x27BDFFE8, # addiu sp,sp,-24 <- was rooted
0x14: 0xAFBF0014, # sw ra,20(sp)
0x18: 0xAFB00010, # sw s0,16(sp)
0x1C: 0x8C620000, # lw v0,0(v1)
0x20: NOP,
0x24: 0x8FBF0014, # lw ra,20(sp)
0x28: 0x8FB00010, # lw s0,16(sp)
0x2C: JR_RA,
0x30: 0x27BD0018, # addiu sp,sp,24
}
def ac3_image(self, base=LOAD + 0x40, extra=None):
words = {LOAD: FRAME, LOAD + 4: JR_RA, LOAD + 8: UNFRAME}
words.update({base + off: word for off, word in self.AC3.items()})
words.update(extra or {})
return image(words, 0x100), base + 0x08, base + 0x10
def test_late_prologue_roots_its_true_start(self):
data, start, prologue = self.ac3_image()
self.assertEqual(CO.prologue_function_start(data, LOAD, prologue),
start)
self.assertFalse(CO.image_local_entry_proven(
data, LOAD, len(data), prologue, LOAD + len(data)))
sources = {}
strong = CO.derive_static_roots(data, LOAD, len(data),
sources=sources)
self.assertIn(start, strong)
self.assertNotIn(prologue, strong)
self.assertIn('prologue_start', sources[start])
_roots, stats = CO.derive_enrichment_roots(data, LOAD, len(data))
self.assertEqual(stats['late_prologue_starts'], 1)
seeds, audit = classify(data, True)
self.assertIn(start, root_seeds(seeds))
self.assertNotIn(prologue, root_seeds(seeds))
# Captured entry evidence at the stack adjust is absorbed by the true
# start: an alias, never a cap.
seeds, audit = classify(data, True, dispatch_entry_pcs=[prologue])
self.assertIn(start, root_seeds(seeds))
self.assertEqual(audit['included_reasons'][prologue],
'DISPATCH_INTERIOR')
self.assertEqual(audit['guard_demoted'][prologue][1], start)
def test_prologue_at_a_boundary_is_its_own_start(self):
# image start, after jr ra + padding, after j, after a break trap
far = LOAD + 0xF0
data = image({LOAD: FRAME, LOAD + 4: JR_RA, LOAD + 8: UNFRAME,
LOAD + 0x20: FRAME, LOAD + 0x24: JR_RA,
LOAD + 0x28: UNFRAME,
LOAD + 0x40: 0x08000000 | ((far >> 2) & 0x03FFFFFF),
LOAD + 0x44: NOP, LOAD + 0x48: FRAME,
LOAD + 0x4C: JR_RA, LOAD + 0x50: UNFRAME,
LOAD + 0x60: 0x0000000D, # break
LOAD + 0x64: 0x3C02800F, # lui v0 <- start
LOAD + 0x68: FRAME, LOAD + 0x6C: JR_RA,
LOAD + 0x70: UNFRAME,
far: JR_RA, far + 4: NOP}, 0x100)
for addr in (LOAD, LOAD + 0x20, LOAD + 0x48):
self.assertEqual(CO.prologue_function_start(data, LOAD, addr),
addr, hex(addr))
self.assertTrue(CO.image_local_entry_proven(
data, LOAD, len(data), addr, LOAD + len(data)), hex(addr))
self.assertEqual(CO.prologue_function_start(data, LOAD, LOAD + 0x68),
LOAD + 0x64)
self.assertTrue({LOAD, LOAD + 0x20, LOAD + 0x48, LOAD + 0x64} <=
CO.derive_static_roots(data, LOAD, len(data)))
def test_preamble_with_branch_or_call_proves_no_start(self):
# `jr ra; nop; lw v0,0(a0); beqz v0,out; nop; addiu sp` -- the stack
# adjust is reached by fallthrough from code this walk does not
# bound. It is weak evidence, and the start after `jr ra` (a possible
# function start) reaches it, so it is dropped: never a root.
base = LOAD + 0x40
out = base + 0x30
for shape, word in (('branch', bne_v0(base + 0x0C, out)),
('call', jal(LOAD))):
data = image({LOAD: FRAME, LOAD + 4: JR_RA, LOAD + 8: UNFRAME,
base: JR_RA, base + 4: NOP,
base + 8: 0x8C820000, # lw v0,0(a0)
base + 0x0C: word, base + 0x10: NOP,
base + 0x14: FRAME, base + 0x18: ADDIU,
base + 0x1C: JR_RA, base + 0x20: UNFRAME,
out: JR_RA, out + 4: NOP}, 0x100)
prologue = base + 0x14
self.assertIsNone(CO.prologue_function_start(data, LOAD,
prologue), shape)
self.assertFalse(CO.image_local_entry_proven(
data, LOAD, len(data), prologue, LOAD + len(data)), shape)
weak = set()
strong = CO.derive_static_roots(data, LOAD, len(data),
weak_out=weak)
self.assertNotIn(prologue, strong, shape)
self.assertNotIn(base + 8, strong, shape)
roots, _stats = CO.derive_enrichment_roots(data, LOAD, len(data))
self.assertNotIn(prologue, roots, shape)
seeds, _audit = classify(data, True)
self.assertNotIn(prologue, root_seeds(seeds), shape)
def test_branch_from_below_into_preamble_proves_no_start(self):
# The function before returns early (`jr ra`) and branches past it to
# a label inside what looks like the next function's preamble: the
# words after that `jr ra` are still its body. Also the same with the
# label exactly at the would-be start.
host = LOAD + 0x20
for label_off in (0x0C, 0x08):
label = host + 0x10 + label_off
data = image({host: FRAME,
host + 4: bne_v0(host + 4, label),
host + 8: NOP,
host + 0x0C: JR_RA, host + 0x10: UNFRAME,
host + 0x18: 0x3C02800F, # lui v0,0x800F
host + 0x1C: 0x8C435D4C, # lw v1,..(v0)
host + 0x20: FRAME, host + 0x24: ADDIU,
host + 0x28: JR_RA, host + 0x2C: UNFRAME}, 0x100)
prologue = host + 0x20
self.assertIsNone(CO.prologue_function_start(data, LOAD,
prologue),
hex(label))
strong = CO.derive_static_roots(data, LOAD, len(data))
self.assertNotIn(host + 0x18, strong, hex(label))
self.assertNotIn(prologue, strong, hex(label))
seeds, _audit = classify(data, True, function_entry_pcs=[host])
self.assertEqual(root_seeds(seeds), {host}, hex(label))
def test_prologue_after_data(self):
# A pointer table (two pointer-shaped words) or an invalid word ends
# the walk: the prologue right after it is its own start; a preamble
# after data must be global-data setup, since data decodes as
# instructions too.
table = LOAD + 0x40
words = {LOAD: FRAME, LOAD + 4: JR_RA, LOAD + 8: UNFRAME,
table: LOAD + 0x80, table + 4: LOAD + 0x90,
table + 8: FRAME, table + 12: JR_RA, table + 16: UNFRAME}
data = image(words, 0x100)
self.assertEqual(CO.prologue_function_start(data, LOAD, table + 8),
table + 8)
setup = dict(words)
setup.update({table + 8: 0x3C02800F, table + 12: 0x8C435D4C,
table + 16: FRAME, table + 20: JR_RA,
table + 24: UNFRAME})
data = image(setup, 0x100)
self.assertEqual(CO.prologue_function_start(data, LOAD, table + 16),
table + 8)
other = dict(setup)
other[table + 12] = 0xA6020020 # sh v0,32(s0)
data = image(other, 0x100)
self.assertIsNone(CO.prologue_function_start(data, LOAD, table + 16))
invalid = {LOAD + 0x40: 0x0000003D, LOAD + 0x44: FRAME,
LOAD + 0x48: JR_RA, LOAD + 0x4C: UNFRAME,
# REGIMM with no such rt: data shaped like a branch
LOAD + 0x60: 0x06040200, LOAD + 0x64: FRAME,
LOAD + 0x68: JR_RA, LOAD + 0x6C: UNFRAME}
data = image(invalid, 0x100)
for addr in (LOAD + 0x44, LOAD + 0x64):
self.assertEqual(CO.prologue_function_start(data, LOAD, addr),
addr, hex(addr))
def test_single_pointer_shaped_word_is_code(self):
# `lui v0,0x8010; lb v0,0x5CEC(v0)` is 0x80425CEC: one aligned RAM
# address in a preamble is an instruction, not a table.
base = LOAD + 0x40
data = image({base: JR_RA, base + 4: UNFRAME,
base + 8: 0x3C028010, base + 12: 0x80425CEC,
base + 16: FRAME, base + 20: JR_RA,
base + 24: UNFRAME}, 0x100)
self.assertEqual(CO.prologue_function_start(data, LOAD, base + 16),
base + 8)
def test_late_prologue_start_respects_producer_start(self):
# The walk never leaves the prologue's producer.
data, start, prologue = self.ac3_image(base=LOAD + 0xC0)
ranges = [(LOAD, LOAD + 0xC8), (LOAD + 0xC8, LOAD + 0x100)]
self.assertEqual(CO.prologue_function_start(
data, LOAD, prologue, LOAD + 0xC8), start)
self.assertEqual(CO.prologue_function_start(
data, LOAD, prologue, LOAD + 0xCC), LOAD + 0xCC)
self.assertIn(start, CO.derive_static_roots(data, LOAD, len(data),
ranges))
# enrichment policy ----------------------------------------------------
def test_enrichment_is_default_and_opt_out_is_diagnostic(self):
data, engine, host, _target = self._cross_image(False)
old = os.environ.pop('PSX_OVERLAY_ROOT_ENRICHMENT', None)
try:
self.assertTrue(CO.root_enrichment_default())
_seeds, audit = classify(data, None)
self.assertTrue(audit['root_enrichment'])
self.assertTrue({engine, host} <= audit['derived_static_roots'])
os.environ['PSX_OVERLAY_ROOT_ENRICHMENT'] = '0'
self.assertFalse(CO.root_enrichment_default())
_seeds, audit = classify(data, None)
self.assertFalse(audit['root_enrichment'])
self.assertEqual(audit['derived_static_roots'], set())
finally:
os.environ.pop('PSX_OVERLAY_ROOT_ENRICHMENT', None)
if old is not None:
os.environ['PSX_OVERLAY_ROOT_ENRICHMENT'] = old
# A conservative retry recipe is never enriched again.
cap = {'schema': 'psxrecomp overlay capture v2',
CO.ROOT_ENRICHMENT_OFF_KEY: True}
_seeds, audit = CO.classify_overlay_seeds(
cap, data, LOAD, len(data), 0, {}, root_enrichment=True)
self.assertEqual(audit['derived_static_roots'], set())
retry = CO.conservative_retry_capture(
{'schema': 'x'}, {'derived_static_roots': {engine}})
self.assertTrue(retry[CO.ROOT_ENRICHMENT_OFF_KEY])
self.assertIsNone(CO.conservative_retry_capture(
retry, {'derived_static_roots': {engine}}))
# (e) ----------------------------------------------------------------------
class CacheKeyTests(unittest.TestCase):
def _cmake_list(self):
self.assertIsNotNone(CMAKE, 'cmake not found: pass --cmake or put '
'cmake on PATH')
with tempfile.TemporaryDirectory() as tmp:
script = Path(tmp) / 'list.cmake'
script.write_text(
f'set(PSXRECOMP_CODEGEN_HASH_ROOT "{ROOT.as_posix()}")\n'
f'include("{(ROOT / "runtime/codegen_hash_sources.cmake").as_posix()}")\n'
'message(STATUS "SRCS=${PSXRECOMP_CODEGEN_HASH_SRCS}")\n',
encoding='utf-8')
proc = subprocess.run([CMAKE, '-P', str(script)],
capture_output=True, text=True)
self.assertEqual(proc.returncode, 0, proc.stderr)
match = re.search(r'SRCS=(.*)', proc.stdout + proc.stderr)
self.assertIsNotNone(match)
return match.group(1).strip().split(';')
def _hash_header(self, sources, out_dir):
header = Path(out_dir) / 'overlay_codegen_hash.h'
proc = subprocess.run(
[CMAKE, f'-DOUT={header.as_posix()}',
'-DSRCS=' + ';'.join(sources),
'-P', str(ROOT / 'runtime' / 'hash_codegen.cmake')],
capture_output=True, text=True)
self.assertEqual(proc.returncode, 0, proc.stderr)
shutil.copy2(ROOT / 'runtime' / 'include' / 'overlay_api.h',
Path(out_dir) / 'overlay_api.h')
return CO.codegen_hash(str(out_dir))
def test_root_policy_is_part_of_the_cache_namespace(self):
sources = self._cmake_list()
classifier = (ROOT / 'tools' / 'compile_overlays.py').as_posix()
self.assertIn(classifier, sources)
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
changed = tmp / 'compile_overlays.py'
changed.write_text(
Path(classifier).read_text(encoding='utf-8') +
'\n# a root-policy change\n', encoding='utf-8')
(tmp / 'a').mkdir()
(tmp / 'b').mkdir()
before = self._hash_header(sources, tmp / 'a')
after = self._hash_header(
[changed.as_posix() if s == classifier else s
for s in sources], tmp / 'b')
self.assertNotEqual(before, after)
saved = CO.overlay_config_hash
CO.overlay_config_hash = lambda recompiler, toml: 0x1234ABCD
try:
tag_a = CO.cache_tag(str(tmp / 'a'), 'r', 'g', 0)
tag_b = CO.cache_tag(str(tmp / 'b'), 'r', 'g', 0)
finally:
CO.overlay_config_hash = saved
self.assertNotEqual(tag_a, tag_b)
# recompiler enforcement ---------------------------------------------------
class RecompilerGuardTests(unittest.TestCase):
def test_recompiler_absorbs_explicit_fallthrough_root(self):
if not RECOMPILER:
self.skipTest('pass --recompiler (ctest always does)')
host, mid = LOAD + 0x20, LOAD + 0x30
data = image({host: FRAME, host + 4: ADDIU, host + 8: ADDIU,
host + 12: ADDIU, mid: SUBU, mid + 4: ADDIU,
mid + 8: JR_RA, mid + 12: UNFRAME}, 0x1000)
with tempfile.TemporaryDirectory() as tmp:
psx = Path(tmp) / 'split.psx'
psx.write_bytes(CO.make_psxexe(LOAD, host, data, guard_bytes=0))
seeds = Path(tmp) / 'seeds.txt'
# Seeds the classifier would never write: both as call roots.
seeds.write_text(f'call_root 0x{host:08X}\ncall_root 0x{mid:08X}\n')
out = Path(tmp) / 'out'
proc = subprocess.run(
[RECOMPILER, str(psx), '--seeds', str(seeds),
'--out-dir', str(out), '--overlay'],
capture_output=True, text=True, cwd=str(ROOT))
self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout)
self.assertEqual(
CO.report_recompiler_guard_absorptions(proc.stdout), 1,
proc.stdout)
ranges = next(out.glob('*_full.ranges')).read_text()
# Both PCs stay callable; the mid-body one only as an alias whose
# identity covers the host range (it starts at the host).
self.assertIn(f'F {host:08X}', ranges)
self.assertIn(f'F {mid:08X}', ranges)
mid_rows = ranges.split(f'F {mid:08X}', 1)[1].splitlines()[1:]
self.assertTrue(any(row.startswith(f'R {host:08X}')
for row in mid_rows[:2]), ranges)
# supplemental root fragments carry the aliases of their hosts --------------
#
# Measured on Tomba 2 (bead beads-eio.3.177): the 0x80038000 capture has more
# roots than the region shard admits, so ~960 roots are compiled in strong-root
# supplement fragments. Those fragments were built from `dispatch_root` seeds
# only, so every candidate the guard absorbed into such a host (0x8003A678 in
# host 0x8003A614, captured dispatch entries) was served by neither shard: 127
# captured dispatch entries lost the native identity the unguarded build had.
class SupplementFragmentAliasTests(unittest.TestCase):
HOST, MID = LOAD + 0x20, LOAD + 0x30
def fallthrough_image(self, size=0x100):
host, mid = self.HOST, self.MID
return image({host: FRAME, host + 4: ADDIU, host + 8: ADDIU,
host + 12: ADDIU, mid: SUBU, mid + 4: ADDIU,
mid + 8: JR_RA, mid + 12: UNFRAME}, size)
def job(self, data):
cap = {'schema': 'psxrecomp overlay capture v2',
'function_entry_pcs': [f'0x{self.HOST:08X}'],
'dispatch_entry_pcs': [f'0x{self.HOST:08X}'],
'static_discovery_entry_pcs': [f'0x{self.MID:08X}']}
_seeds, audit = CO.classify_overlay_seeds(
cap, data, LOAD, len(data), 0, {}, root_enrichment=True)
return audit, CO.make_interior_fragment_job(
LOAD & 0x1FFFFFFF, LOAD, len(data), data, audit, set(), cap)
def test_classifier_records_final_host_of_every_alias(self):
audit, _job = self.job(self.fallthrough_image())
self.assertEqual(audit['included_reasons'][self.MID],
'DISPATCH_INTERIOR')
self.assertEqual(audit['interior_hosts'][self.MID], {self.HOST})
self.assertEqual(audit['guard_demoted'][self.MID],
('STATIC_DISCOVERY_ROOT', self.HOST))
def test_root_fragment_selects_its_hosts_aliases(self):
_audit, job = self.job(self.fallthrough_image())
self.assertIn(self.HOST, job['static_exact_demands'])
self.assertEqual(CO.fragment_interior_aliases(job, [self.HOST]),
[self.MID])
self.assertEqual(CO.fragment_interior_aliases(job, [LOAD + 0x80]), [])
# merged byte-variant recipes union the hosts
merged = CO.merge_fragment_jobs_by_recipe([job, job])
self.assertEqual(merged[0]['interior_hosts'][self.MID], {self.HOST})
def test_fragment_seed_file_carries_aliases_as_interior(self):
data = self.fallthrough_image()
seen = {}
real_run = CO.subprocess.run
def fake_run(cmd, **kw):
seen['seeds'] = Path(cmd[cmd.index('--seeds') + 1]).read_text()
return subprocess.CompletedProcess(cmd, 1, '', 'stop')
args = argparse.Namespace(recompiler='recomp.exe', game_toml=__file__,
project_root=None, runtime_include='.')
CO.subprocess.run = fake_run
try:
CO.compile_fragment_batch(
[self.HOST], data, LOAD, len(data), LOAD & 0x1FFFFFFF, '.',
args, {}, {}, guard_bytes=0, interior_aliases=[self.MID])
finally:
CO.subprocess.run = real_run
lines = seen['seeds'].splitlines()
self.assertIn(f'dispatch_root 0x{self.HOST:08X}', lines)
self.assertIn(f'interior 0x{self.MID:08X}', lines)
def test_alias_of_interpreted_host_gets_its_root_back(self):
# Tomba 2: host 0x800BDF10 (a walk through data, 48 unsupported
# instructions) fails its fragment; the 8-byte stubs the guard had
# absorbed into it were native roots in the unguarded build.
_audit, job = self.job(self.fallthrough_image())
self.assertEqual(job['guard_demoted_aliases'], {self.MID})
self.assertEqual(CO.orphaned_guard_aliases(job, set()), [self.MID])
self.assertEqual(CO.orphaned_guard_aliases(
job, {self.HOST & 0x1FFFFFFF}), [])
self.assertEqual(CO.orphaned_guard_aliases(
job, {self.MID & 0x1FFFFFFF}), [])
merged = CO.merge_fragment_jobs_by_recipe([job, dict(job)])
self.assertEqual(merged[0]['guard_demoted_aliases'], {self.MID})
def test_alias_failure_never_costs_the_host(self):
calls = []
def build(aliases):
calls.append(list(aliases))
return ((None, 'generated-c-audit: 1 unknown_bad') if aliases
else ([('ok',)], 'built'))
ids, status = CO.compile_root_fragment_with_aliases(
[self.HOST], [self.MID], build)
self.assertEqual((ids, status, calls), ([('ok',)], 'built',
[[self.MID], []]))
# a multi-root batch is left to the caller's bisection
calls.clear()
ids, _status = CO.compile_root_fragment_with_aliases(
[self.HOST, LOAD + 0x80], [self.MID], build)
self.assertIsNone(ids)
self.assertEqual(calls, [[self.MID]])
# capacity verdicts are never retried
calls.clear()
CO.compile_root_fragment_with_aliases(
[self.HOST], [self.MID],
lambda a: calls.append(a) or (None, 'candidate-capacity: full; x'))
self.assertEqual(len(calls), 1)
def test_recompiler_emits_alias_in_root_fragment(self):
if not RECOMPILER:
self.skipTest('pass --recompiler (ctest always does)')
data = self.fallthrough_image(0x1000)
with tempfile.TemporaryDirectory() as tmp:
psx = Path(tmp) / 'frag.psx'
psx.write_bytes(CO.make_psxexe(LOAD, self.HOST, data,
guard_bytes=0))
seeds = Path(tmp) / 'seeds.txt'
seeds.write_text(f'dispatch_root 0x{self.HOST:08X}\n'
f'interior 0x{self.MID:08X}\n')
out = Path(tmp) / 'out'
proc = subprocess.run(
[RECOMPILER, str(psx), '--seeds', str(seeds),
'--out-dir', str(out), '--overlay'],
capture_output=True, text=True, cwd=str(ROOT))
self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout)
ranges = next(out.glob('*_full.ranges')).read_text()
mid_rows = ranges.split(f'F {self.MID:08X}', 1)
self.assertEqual(len(mid_rows), 2, ranges)
self.assertTrue(mid_rows[1].splitlines()[1].startswith(
f'R {self.HOST:08X}'), ranges)
if __name__ == '__main__':
ap = argparse.ArgumentParser()
ap.add_argument('--cmake', default=shutil.which('cmake'))
ap.add_argument('--recompiler')
args, rest = ap.parse_known_args()
if not args.cmake:
sys.exit('cmake not found: pass --cmake')
CMAKE = args.cmake
RECOMPILER = args.recompiler
unittest.main(argv=[sys.argv[0]] + rest)