Integrate PR392 preserving current import and parser context

This commit is contained in:
Matt Stanley
2026-10-19 12:28:11 -07:00
parent 215e074fb3
commit 4ff7ed4d6e
10 changed files with 2197 additions and 148 deletions
+201
View File
@@ -0,0 +1,201 @@
# Overlay root enrichment: next steps after #386
#386 added `tools/enrich_overlay_captures.py`. It scans each captured overlay
image for likely function starts and writes them to
`static_discovery_entry_pcs`. Nothing runs it automatically yet. This page is
the plan for making it the default and getting more coverage from it, without
any risk of softlocks.
## Where we are
- **Measured on Ace Combat 3** (x6568tank's captures, compile tooling fixed):
rootless images went from 250 to 1, and capacity skips from 3,028 to 0.
There were 8 shard failures, all `fragment: no-func-ids`, and all of them
also appear in the comparison tool. So no new failures.
- **A richer tool finds 47% more starts** (15,008 vs 10,191). That gave +31%
entries served and +19% covered bytes. It also gave 19 failures instead of
8, a 38% larger cache and 39% more compile time.
- **Our titles' captures** have no `function_entry_pcs`, only
`dispatch_entry_pcs`. Enrichment would add:
| Title | Dispatch entries | Starts added |
|---|---|---|
| Tomba | 38 | 764 |
| Tomba 2 | 178 | 3,316 |
| Mega Man X6 | 325 | 901 |
| Ape Escape | 149 | 459 |
## The rule that matters
A shard **failure** is safe. The compiler refuses the piece and it runs
interpreted. The real risk is a wrong start point that **passes** every check.
A walk root is a hard cap, so a false root in the middle of a function splits
that function. The output is valid C that passes every audit, but it behaves
wrong. That is the mid-function-seed softlock class, and it never shows up in
the failure count.
So the failure count is not the safety metric. The goal is **zero
mis-splits**, then as much coverage as possible.
## Plan
1. **No-split guard** (in `compile_overlays.py`).
- Walk each candidate root without hard caps.
- A candidate that another root reaches by fallthrough or branch (not by
`jal`) is inside that function. Demote it to `DISPATCH_INTERIOR`, which
makes it an alias entry and never a cap.
- This extends the protection dispatch entries already get to every
static root.
- With this guard, a wrong guess can only cost speed, never correctness.
2. **Move enrichment into `compile_overlays.py`.**
- Call the same root derivation when building `pre_roots`, instead of
running a separate pipeline step.
- Add it to the overlay cache/recipe key, so shards built without it are
never reused.
- Keep the script for inspecting captures.
3. **Add more root categories, behind the guard.** These are the sources the
richer tool uses and #386 does not:
- `jal` edges from the main EXE and other images into the overlay. These
are real call evidence, and likely the safest win.
- Frameless starts right after `jr ra` + delay slot. These are riskiest;
`compile_overlays` itself says this spot can be data.
- Pointer tables with fewer than 3 entries, and `lui`/`addiu`-built
pointers.
Measure each category on its own: roots added, entries served, failures.
4. **Explain the `no-func-ids` failures.**
- Find which requested entries produce an empty ranges manifest, and why
they were requested.
- Fix the request side so nothing unprovable is asked for. Do not
reclassify these as skips just to reach zero.
5. **Validate on Tomba, Tomba 2, Mega Man X6 and Ape Escape.**
- Run interpreted-only and enriched-native to the same frame, and compare
`frame_fingerprint` rings. They must match.
- Then play and explore areas that load new overlays: levels, menus,
cutscenes, save and load.
## Status: steps 1 and 2 (beads-eio.3.177)
- **Guard** (`no_split_partition` in `compile_overlays.py`, same rule in
`FunctionAnalyzer::analyze_exact_entries` for overlay mode). For each root,
the following candidates its uncapped walk reaches are absorbed together
(a switch resolves only when all its case labels are inside the walk); the
run stops at the first unreached root, so a tail call over another function
keeps its target. Applies to every root source; promoted kernel orphans are
exempt. Delay-slot candidates are never roots or aliases.
- **Evidence.** A jal or pointer only proves a function start in the image
that is resident when it runs. STRONG = a prologue, or a jal/table target
that this image bounds (`jr $ra` before it plus the CFG probe). WEAK = CFG
probe only; kept only if no possible function start reaches it, and dropped
if a host's local branch jumps over it. Cross-producer calls are never weak
evidence.
- **Default on, one code path.** `classify_overlay_seeds` derives the roots;
`tools/enrich_overlay_captures.py` is an inspection tool built on the same
functions. `--no-root-enrichment` / `PSX_OVERLAY_ROOT_ENRICHMENT=0` is
diagnostic only.
- **Cache key.** `tools/compile_overlays.py` is in
`runtime/codegen_hash_sources.cmake`, so any root-policy change moves the
cache namespace.
- **Tomba (25 AOT images).** Emitted fallthrough splits: master 57, #386
enrichment 2,072, guard 0. Fingerprints match master on every value and
timing field over the 30.7k-frame route and the 32k attract run. Sound
enrichment adds almost nothing on Tomba: 12.3k of #386's extra roots were
jump-table case labels inside functions. The way to that coverage is
resolving those tables, not rooting their labels.
- **Not caused by splits:** master's own 3-4 cycle drift from the interpreter
at frame 2111. It is unchanged with the guard.
- **Tomba 2 (23 captures, autocompile path).** Two guard gaps found and
fixed. (1) Roots the region shard leaves out are built in strong-root
supplement fragments from `dispatch_root` seeds only, so aliases of those
hosts were served by no shard (127 captured dispatch entries lost). The
supplement now passes each host's aliases as `interior` seeds, and a single
root whose aliases break its fragment is rebuilt without them. (2) When
every host of an absorbed candidate stays interpreted (0x800BDF10 walks
into data and fails the audit), the candidate is compiled as the root it
was before the guard. Left: 14 captured dispatch entries at delay slots,
which the guard keeps interpreted by design (master aliased them).
- **Tomba 2 fingerprints do not match master exactly,** and the cause is
master's own splits. Frame 6797: master splits 0x8009B0C0 from its
fallthrough 0x8009B0CC; the split leaves the nested call unit, so master
takes a CD IRQ inside the loop, while the unsplit function runs as one
unit and the runtime's nested-unit rule defers the IRQ to its return
(with `PSX_OVERLAY_UNIT_DEFER=0` in both arms this fork goes away).
Frame 7169/7363: master splits switch 0x8006D654 at its case label
0x8006D690; the jump-table exit costs master 2 extra cycles. Values stay
identical, and every route and attract screenshot is identical.
- **Mega Man X6 and Ape Escape (guard at 5a094db6).** Same gate as below,
arms N (master) and G (guard), own sandboxes and caches, both proven to run
native. MMX6: interpreter fallback 14.04M vs 14.11M on the route, 0/0
shard failures, 74/74 route and 13/13 attract screenshots identical; the
only fingerprint fork is the store-PC hash at 8303, where overlay code
master interprets runs native in G (same address, value and cycle).
Ape: fallback 3.28M vs 3.31M, memcard LOAD GAME passes, 67/67 and 39/39
screenshots identical; fork at 3564 is the same interpreted-to-native class
plus a 3-cycle difference in how native and interpreter cost one
straight-line multiply block (framework behaviour, beads-eio.3.185).
Tomba re-checked on 5a094db6: 0 diverging frames vs master.
## Late prologues (beads-eio.3.191)
The STRONG prologue rule rooted every `addiu sp,sp,-N` that was not in a
delay slot. Compilers schedule loads in front of the stack adjust
(`lui v0; lw v1,..(v0); addiu sp,sp,-24`), so the root capped the function
two to five words in. Ace Combat 3 had 359 such roots under the guard, 175
with the real start executed. The guard could not absorb them because the
real start was not a root.
Now a prologue roots its function's true start
(`prologue_function_start`): walk back over the straight-line preamble to
the nearest boundary (image or producer start, after an unconditional
transfer's delay slot or a `break`, or after data) and root that word. After
data the preamble must be global-data setup (`lui`, `li`, loads from a
`lui` base). No provable start (a conditional branch or call in the
preamble, a local branch from below into it) makes the prologue weak
evidence, which the host above always reaches, so it is not rooted.
Captured entries at a stack adjust keep their own gate and are absorbed by
the true start.
Measured:
- **Ace Combat 3** (73 captures): late-prologue walk roots 359 to 1 (the one
is a jal target right after a pointer table, a real start). 357 roots
moved to their true start, 5 mid-function prologues (after a conditional
branch, or entered by a branch from above) are no longer roots. Rootless
0, capacity skips 0, shards 890/2/61 (the 2 are the guard-word class).
Served identities 6219 vs 6337: the stack adjusts are no longer emitted
as entries, since nothing enters there; captured dispatch entries served
3671 in both, covered words +278.
- **Four-title gate** (same method as below, master 19b5a65f, own
sandboxes and caches): interpreter fallback G <= N on every run, no shard
failures, every screenshot identical. Forks from N are the same frames
and fields as the previous guard gate. Against the previous guard runs,
only the store/MMIO attribution hashes move (code that changed between
native and interpreted); writes and cycles are identical.
## Gate used, and what is left
The gate the four titles passed is **no regression against master**:
interpreter fallback not higher, no new shard failures, no freeze or wedge,
all checkpoint screenshots identical. Small timing forks that come from
removing master's own splits are accepted. Matching the interpreted-only
arm exactly is not the gate: master itself drifts a few cycles from it.
Done:
- The no-split guard, with tests that mid-function, branch-reached and
delay-slot candidates are demoted, not rooted.
- Enrichment on by default in `compile_overlays.py`, and part of the cache
key.
- Four-title regression gate (above).
Not done:
- Step 3 as a widening. The evidence rules were made stricter (image-local
proof, cross-producer calls excluded), not extended with new categories.
- Step 4, the `no-func-ids` root cause.
- Re-measuring Ace Combat 3. #386's AC3 numbers (rootless images 250 to 1,
capacity skips 3,028 to 0) were taken without the guard; the stricter
evidence rule may root fewer of those images.
- Delay-slot dispatch PCs that master aliased stay interpreted under the
guard by design (Tomba 96, Tomba 2 14, Ape 10); no fallback cost measured.
+12
View File
@@ -920,6 +920,18 @@ if(BUILD_TESTING)
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_enrich_overlay_captures.py
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# A walk root is a hard cap: a root inside another function splits it
# and still passes every audit. The classifier's no-split guard, the
# image-local enrichment proof, the recompiler's own guard on explicit
# roots, and the root policy's place in the cache namespace
# (beads-eio.3.177).
add_test(
NAME overlay_no_split_guard
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_overlay_no_split_guard.py
--cmake ${CMAKE_COMMAND}
--recompiler $<TARGET_FILE:psxrecomp-game>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# The runtime exe name must come from CMake, not be re-derived.
add_test(
+13 -1
View File
@@ -172,10 +172,22 @@ public:
// reachable from them. Used by runtime-loaded overlays and opt-in main-EXE
// reachable discovery. Unresolved jalr/indirect targets do not mint
// functions; evidence-backed entries must be supplied explicitly.
//
// no_split_guard (overlay mode): an EXPLICIT entry that the entry below
// it reaches by fallthrough or branch -- walked with the entry's own cap
// removed -- is inside that function. It is absorbed like a derived
// call target and exported in absorbed_entries (an alias, never a cap).
// `trusted_entries` (classifier-promoted dispatch roots) are exempt.
// compile_overlays.py applies the same rule before writing seeds; this is
// the enforcement point for walk proofs only this analyzer can make
// (computed-stride and self-limited jump tables). Off by default so the
// main-EXE reachable-discovery partition is unchanged.
FunctionAnalysisResult analyze_exact_entries(
const std::vector<uint32_t>& entries,
const std::vector<std::pair<uint32_t, uint32_t>>& producer_ranges = {},
const std::set<uint32_t>& cross_call_allow = {});
const std::set<uint32_t>& cross_call_allow = {},
bool no_split_guard = false,
const std::set<uint32_t>& trusted_entries = {});
// Add a forced entry point address that is treated as a function start
// even if it has no standard ADDIU $sp prologue. The function will be
+90 -3
View File
@@ -1418,7 +1418,9 @@ bool resolve_self_limited_jump_table(
FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
const std::vector<uint32_t>& entries,
const std::vector<std::pair<uint32_t, uint32_t>>& producer_ranges,
const std::set<uint32_t>& cross_call_allow) {
const std::set<uint32_t>& cross_call_allow,
bool no_split_guard,
const std::set<uint32_t>& trusted_entries) {
FunctionAnalysisResult result;
result.total_instructions = 0;
result.jr_ra_count = 0;
@@ -1628,6 +1630,61 @@ FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
std::map<uint32_t, std::set<std::pair<uint32_t, bool>>> derived_evidence;
fmt::print("Explicit entries: {}\n", explicit_count);
// No-split guard: fixed point over EVERY non-trusted entry (explicit and
// derived); same rule as compile_overlays.no_split_partition. For each
// host, the run of following entries its UNCAPPED walk reaches (stopping
// at the first it does not reach, or a trusted entry) may be inside it;
// walk the host capped at the end of the run, shrink the run to the first
// entry that walk misses, and absorb the rest together. Absorbing the run
// at once lets a switch whose table targets several entries resolve
// (every target must lie inside the walk); stopping at the first
// unreached entry keeps a forward tail call over an unrelated function
// from orphaning its target. Absorbing only removes caps, so repeat.
auto absorb_split_entries = [&](std::set<uint32_t>& entries) {
const uint32_t analysis_end = exe_.analysis_end_address();
bool changed = true;
while (changed) {
changed = false;
std::vector<uint32_t> roots(entries.begin(), entries.end());
std::vector<uint32_t> kept;
size_t index = 0;
while (index < roots.size()) {
const uint32_t host = roots[index];
kept.push_back(host);
const ExactWalkResult reach = walk(host, analysis_end);
// A wider range can reject a jump table, so the walk capped
// at the successor is not always a subset: consult both.
const ExactWalkResult succ = index + 1 < roots.size()
? walk(host, roots[index + 1]) : ExactWalkResult{};
auto reached = [&](uint32_t a) {
return reach.visited.count(a) || succ.visited.count(a);
};
size_t end = index + 1;
while (end < roots.size() && reached(roots[end]) &&
!trusted_entries.count(roots[end]))
end++;
while (end > index + 1) {
const uint32_t cap =
end < roots.size() ? roots[end] : analysis_end;
const ExactWalkResult inner = walk(host, cap);
size_t blocked = end;
for (size_t k = index + 1; k < end; k++) {
if (!inner.visited.count(roots[k])) {
blocked = k;
break;
}
}
if (blocked == end) break;
end = blocked;
}
if (end > index + 1) changed = true;
index = end;
}
entries = std::set<uint32_t>(kept.begin(), kept.end());
}
};
if (no_split_guard) absorb_split_entries(known_entries);
// Discover callees in rounds against one stable entry partition. Processing
// roots sequentially let an early caller insert a JAL target that actually
// lay inside a later explicit root. That new entry hard-capped the later
@@ -1643,6 +1700,7 @@ FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
fmt::print("WARNING: exact-entry ownership did not converge; "
"falling back to explicit roots only\n");
known_entries = explicit_entries;
if (no_split_guard) absorb_split_entries(known_entries);
derived_entries.clear();
derived_evidence.clear();
break;
@@ -1680,7 +1738,9 @@ FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
// "absorbed forever" set therefore loses reachable code.
known_entries = explicit_entries;
known_entries.insert(derived_entries.begin(), derived_entries.end());
for (uint32_t target : derived_entries) {
if (no_split_guard) {
absorb_split_entries(known_entries);
} else for (uint32_t target : derived_entries) {
auto target_it = known_entries.find(target);
if (target_it == known_entries.end() ||
explicit_entries.count(target) ||
@@ -1701,8 +1761,15 @@ FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
derived_entries == previous_derived) break;
}
result.call_discovered_count = static_cast<int>(known_entries.size() - explicit_count);
size_t explicit_kept = 0;
for (uint32_t entry : known_entries)
explicit_kept += explicit_entries.count(entry);
result.call_discovered_count =
static_cast<int>(known_entries.size() - explicit_kept);
fmt::print("Direct-JAL entries: {}\n", result.call_discovered_count);
if (no_split_guard)
fmt::print("No-split guard absorbed explicit entries: {}\n",
explicit_count - explicit_kept);
fmt::print("Total exact entries: {}\n\n", known_entries.size());
std::vector<uint32_t> starts_vec(known_entries.begin(), known_entries.end());
@@ -1784,6 +1851,26 @@ FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
break;
}
}
// Explicit entries the no-split guard absorbed: the proof is the host's
// own walk (fallthrough/branch), so the host start is the source. A
// delay-slot target is not exported: an alias there would make the slot
// a block leader. It stays with the interpreter.
if (no_split_guard) {
for (uint32_t target : explicit_entries) {
if (final_starts.count(target)) continue;
if (target >= exe_.header.load_address + 4u) {
auto prev = exe_.read_word(target - 4u);
if (prev.has_value() && is_branch_or_jump(*prev)) continue;
}
for (const auto& [host, wr] : final_walks) {
if (!wr.visited.count(target)) continue;
result.absorbed_entries.push_back(
{target, host.start_addr, host.end_addr,
host.start_addr, false});
break;
}
}
}
return result;
}
+8 -1
View File
@@ -1161,8 +1161,15 @@ static int psxrecomp_game_main(int argc, char** argv) {
{
PSXRecomp::FunctionAnalyzer analyzer(*exe);
std::vector<uint32_t> roots_vec(roots.begin(), roots.end());
// Overlay mode enforces the no-split guard on explicit roots too
// (compile_overlays.py applies the same rule first; this catches
// walk proofs only this analyzer makes). Classifier-promoted
// dispatch roots are exempt. Main-EXE reachable discovery keeps
// its existing partition.
analysis_result = analyzer.analyze_exact_entries(
roots_vec, producer_ranges, cross_call_allow);
roots_vec, producer_ranges, cross_call_allow,
/*no_split_guard=*/overlay_mode,
overlay_mode ? trusted_root_seeds : std::set<uint32_t>{});
}
std::vector<AliasEntry> alias_entries;
@@ -760,6 +760,83 @@ int main() {
thunk_starts.count(kLoad + 0x610),
"packed BIOS thunk bounds the following frameless leaf");
// ---- No-split guard on EXPLICIT entries (overlay mode) ----------------
// A root is a hard cap, so an explicit entry that the entry below it
// reaches by fallthrough splits that function (beads-eio.3.177: Tomba
// X00 root 0x8011B1CC, a subu mid-way through host 0x8011AC10).
{
auto split_image = make_exe_buffer(0x1000);
put32(split_image, text + 0x100, 0x27BDFFE0u); // addiu sp,sp,-32
put32(split_image, text + 0x104, 0x24420001u);
put32(split_image, text + 0x108, 0x00431023u); // subu v0,v0,v1
put32(split_image, text + 0x10C, 0x03E00008u);
put32(split_image, text + 0x110, 0x27BD0020u);
auto split_exe = parse(split_image);
PSXRecomp::FunctionAnalyzer unguarded(split_exe);
CHECK(starts(unguarded.analyze_exact_entries(
{kLoad + 0x100, kLoad + 0x108})) ==
std::set<uint32_t>({kLoad + 0x100, kLoad + 0x108}),
"without the guard an explicit mid-function entry still caps");
PSXRecomp::FunctionAnalyzer guarded(split_exe);
const auto guarded_result = guarded.analyze_exact_entries(
{kLoad + 0x100, kLoad + 0x108}, {}, {}, true);
CHECK(starts(guarded_result) == std::set<uint32_t>({kLoad + 0x100}),
"no-split guard absorbs an explicit entry reached by fallthrough");
bool aliased = false;
for (const auto& absorbed : guarded_result.absorbed_entries)
aliased |= absorbed.addr == kLoad + 0x108 &&
absorbed.host_start == kLoad + 0x100 &&
absorbed.addr < absorbed.host_end;
CHECK(aliased, "absorbed explicit entry is exported as a hosted alias");
PSXRecomp::FunctionAnalyzer trusted(split_exe);
CHECK(starts(trusted.analyze_exact_entries(
{kLoad + 0x100, kLoad + 0x108}, {}, {}, true,
{kLoad + 0x108})) ==
std::set<uint32_t>({kLoad + 0x100, kLoad + 0x108}),
"trusted (promoted dispatch) entries are exempt from the guard");
}
{
// Every case label of a switch is an explicit entry. Each one caps
// the host below its siblings, so the table (which resolves only when
// all of its targets are inside the walk) is only visible when the
// whole run of case labels is absorbed together.
auto sw = make_ape_switch();
auto sw_exe = parse(sw.image);
std::vector<uint32_t> entries = {sw.entry};
entries.insert(entries.end(), sw.cases.begin(), sw.cases.end());
PSXRecomp::FunctionAnalyzer guarded(sw_exe);
const auto result = guarded.analyze_exact_entries(entries, {}, {}, true);
CHECK(starts(result) == std::set<uint32_t>({sw.entry}),
"no-split guard absorbs every case-label entry of a switch host");
size_t hosted = 0;
for (const auto& absorbed : result.absorbed_entries)
hosted += absorbed.host_start == sw.entry;
CHECK(hosted == sw.cases.size(),
"every absorbed case label is hosted by the switch function");
}
{
// A forward tail call over an unrelated function must not demote the
// tail-call target: the intervening root would leave it hostless.
auto tail_image = make_exe_buffer(0x1000);
put32(tail_image, text + 0x700, 0x08000000u |
(((kLoad + 0x740) >> 2) & 0x03FFFFFFu));
put32(tail_image, text + 0x704, 0x00000000u);
put32(tail_image, text + 0x720, 0x03E00008u);
put32(tail_image, text + 0x724, 0x00000000u);
put32(tail_image, text + 0x740, 0x24020001u);
put32(tail_image, text + 0x744, 0x03E00008u);
put32(tail_image, text + 0x748, 0x00000000u);
auto tail_exe = parse(tail_image);
PSXRecomp::FunctionAnalyzer guarded(tail_exe);
CHECK(starts(guarded.analyze_exact_entries(
{kLoad + 0x700, kLoad + 0x720, kLoad + 0x740},
{}, {}, true)) ==
std::set<uint32_t>(
{kLoad + 0x700, kLoad + 0x720, kLoad + 0x740}),
"tail call over an unrelated root keeps its target a root");
}
PSXRecomp::FunctionAnalyzer seeded_analyzer(exe);
const auto seeded = starts(
seeded_analyzer.analyze_exact_entries({kLoad, kLoad + 0x200}));
+10
View File
@@ -58,6 +58,16 @@ set(PSXRECOMP_CODEGEN_HASH_SRCS
${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/include/basic_block.h
${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/include/code_generator.h
# --- Root policy ------------------------------------------------------------
# compile_overlays.py decides which captured PCs become walk roots, alias
# entries or interpreter-only PCs (the seed classifier, the no-split guard
# and default-on root enrichment all live there). Roots are hard caps, so
# the same bytes and the same emitter produce DIFFERENT shards under a
# different root policy: a shard built before the no-split guard can carry
# a mis-split host (beads-eio.3.177). Hashing the classifier moves the cache
# namespace whenever the policy changes, so such shards are never reused.
${PSXRECOMP_CODEGEN_HASH_ROOT}/tools/compile_overlays.py
# --- Shard COMPILE surface -------------------------------------------------
# The cg tag must invalidate on anything a shard compiles/links against, not
# just what the emitter WRITES. A change to the overlay ABI header, the
+967 -72
View File
File diff suppressed because it is too large Load Diff
+52 -71
View File
@@ -1,18 +1,25 @@
#!/usr/bin/env python3
"""Add conservative static walk-root evidence to runtime overlay captures.
"""Inspect (and optionally materialize) static root enrichment for captures.
Runtime capture knows that a dirty-RAM PC was dispatched, but that is not the
same thing as knowing it is a function boundary: CPS continuations and jump
table cases are dispatched too. This tool therefore leaves the runtime-owned
``function_entry_pcs`` untouched and derives the optional, tool-owned
``static_discovery_entry_pcs`` from captured bytes. compile_overlays.py
re-validates every supplied root before compiling it.
compile_overlays.py derives these roots itself (enrichment is on by default
and part of the overlay cache key), so this tool is no longer a pipeline
step. It shows what the compiler's derivation finds in each captured image,
using exactly the same code (compile_overlays.derive_static_roots), and can
still write the roots into ``static_discovery_entry_pcs`` for offline A/B.
A jal or pointer elsewhere in the image is not proof on its own: overlay
regions are swapped, so shared code can call an address that is a function
start only in a sibling image. A candidate is STRONG when this image's bytes
bound it (a non-delay-slot stack-frame prologue, or a preceding ``jr $ra``
plus the bounded CFG probe) and WEAK when only the CFG probe accepts it; a
weak candidate is kept only if no possible function start below it reaches
it (compile_overlays.derive_enrichment_roots). compile_overlays.py
additionally applies the no-split guard to every root, derived or not.
"""
import argparse
import base64
import json
import struct
import sys
from pathlib import Path
@@ -22,66 +29,25 @@ sys.path.insert(0, str(ROOT / 'tools'))
import compile_overlays as CO # noqa: E402
def word_at(data, base, addr):
offset = addr - base
if offset < 0 or offset + 4 > len(data):
return None
return struct.unpack_from('<I', data, offset)[0]
def is_candidate(data, base, addr):
"""Use only the compiler's existing root proofs.
A classic stack-frame prologue is already accepted by overlay discovery.
Frameless entries additionally need its bounded CFG proof, which rejects
tables and sequential data that happen to decode as MIPS.
"""
if addr & 3 or not base <= addr < base + len(data):
return False
word = word_at(data, base, addr)
previous = word_at(data, base, addr - 4)
if CO._is_addiu_sp_neg(word) and not CO._is_control_flow(previous):
return True
return CO.plausible_callable_target(data, base, len(data), addr,
base + len(data))
def derived_roots(data, base):
"""Find generic, independently evidenced roots in one captured image."""
hi = base + len(data)
roots = set()
pointers = []
for addr in range(base, hi - 3, 4):
word = word_at(data, base, addr)
if CO._is_addiu_sp_neg(word) and not CO._is_control_flow(
word_at(data, base, addr - 4)):
roots.add(addr)
if word is not None and (word >> 26) == 0x03: # jal
target = 0x80000000 | ((word & 0x03ffffff) << 2)
if is_candidate(data, base, target):
roots.add(target)
pointers.append(word if word is not None and not (word & 3) and
base <= word < hi else None)
# A dense run is a table only when it has three or more in-image pointers.
# Each member must still pass the same root proof; no bare pointer becomes
# authority, and a pointer to the table itself fails the CFG proof.
start = 0
while start < len(pointers):
if pointers[start] is None:
start += 1
continue
end = start + 1
while end < len(pointers) and pointers[end] is not None:
end += 1
if end - start >= 3:
for target in pointers[start:end]:
if is_candidate(data, base, target):
roots.add(target)
start = end
def derived_roots(data, base, explicit=(), sources=None, stats=None):
"""The compiler's own enrichment roots for one captured image."""
roots, info = CO.derive_enrichment_roots(data, base, len(data),
explicit=explicit)
if sources is not None:
weak = set()
CO.derive_static_roots(data, base, len(data), sources=sources,
weak_out=weak)
if stats is not None:
stats.update(info)
return sorted(roots)
def explicit_roots(record):
return set().union(*(CO._parse_addr_list(record.get(key, [])) for key in (
'function_entry_pcs', 'dispatch_entry_pcs',
'static_discovery_entry_pcs', 'seeds')))
def enrich_record(record):
"""Return a copy of *record* with discovered roots unioned into it."""
out = dict(record)
@@ -94,7 +60,8 @@ def enrich_record(record):
if size != len(data) or base & 3 or len(data) & 3:
raise ValueError('capture bytes must be a word-aligned image matching size')
prior = CO._parse_addr_list(record.get('static_discovery_entry_pcs', []))
roots = sorted(prior | set(derived_roots(data, base)))
roots = sorted(prior | set(derived_roots(data, base,
explicit_roots(record))))
out['static_discovery_entry_pcs'] = [f'0x{addr:08X}' for addr in roots]
return out, len(roots) - len(prior)
@@ -102,7 +69,9 @@ def enrich_record(record):
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--captures', required=True, help='runtime capture JSON')
parser.add_argument('--out', required=True, help='enriched JSON output path')
parser.add_argument('--out', help='write an enriched JSON copy here')
parser.add_argument('--list', action='store_true',
help='print every derived root and its evidence sources')
args = parser.parse_args(argv)
with open(args.captures, encoding='utf-8') as src:
records = json.load(src)
@@ -116,12 +85,24 @@ def main(argv=None):
item, count = enrich_record(record)
except ValueError as exc:
raise SystemExit(f'capture {index}: {exc}') from exc
if args.list:
base = CO._parse_addr(record['load_addr'])
data = base64.b64decode(record['bytes_b64'])
sources, stats = {}, {}
roots = derived_roots(data, base, explicit_roots(record),
sources, stats)
print(f'capture {index} load=0x{base:08X}: {len(roots)} derived '
f'root(s) {stats}')
for addr in roots:
print(f' 0x{addr:08X} {"+".join(sorted(sources[addr]))}')
enriched.append(item)
added += count
with open(args.out, 'w', encoding='utf-8', newline='\n') as dst:
json.dump(enriched, dst, indent=2)
dst.write('\n')
print(f'enriched {len(enriched)} capture(s); added {added} static discovery root(s)')
if args.out:
with open(args.out, 'w', encoding='utf-8', newline='\n') as dst:
json.dump(enriched, dst, indent=2)
dst.write('\n')
print(f'{len(enriched)} capture(s); {added} static discovery root(s) not '
f'already declared')
if __name__ == '__main__':
+767
View File
@@ -0,0 +1,767 @@
#!/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)