Recognize bounded MIPS switches with scheduled table constants

This commit is contained in:
Matthew Stanley
2026-09-11 13:38:06 -07:00
parent 6f77dcc31f
commit fe2c0046ba
7 changed files with 281 additions and 27 deletions
+4 -1
View File
@@ -79,7 +79,10 @@ using ExactAddressMapper = uint32_t (*)(uint32_t, const PS1Executable&);
// Recognize only the canonical bounded-switch dependency chain:
// sltiu/beq guard -> sll index,2 -> addu table address -> lw target -> jr.
// The table constant may use either same-register or cross-register
// `lui source; addiu base,source,lo`. `producer_lo/producer_hi` bound both
// `lui source; addiu base,source,lo`, either before the guard or scheduled
// exactly as `sltiu; beq; lui (delay slot); addiu; sll` without clobbering
// the checked index or allowing direct edges to bypass the guard.
// `producer_lo/producer_hi` bound both
// table storage and case code for composite images; zero/zero means the full
// executable. Every dependency, table word, and target must pass the hard
// safety checks; otherwise the whole table is rejected.
+39 -12
View File
@@ -894,16 +894,36 @@ bool resolve_exact_bounded_jump_table(
return false;
}
// Exact canonical guard: sltiu; beq; nop; sll. This is the same accepted
// suffix as the Python capture verifier and excludes unrelated bounds.
// Exact guards: sltiu; beq; nop; sll, or sltiu; beq; lui; addiu; sll.
// The latter schedules the table's LUI in the bounds branch's delay slot.
// Keep both forms in parity with the Python capture verifier.
if (sll_pc < entry + 12u) return false;
uint32_t guard_pc = sll_pc - 8u;
uint32_t bound_pc = sll_pc - 12u;
auto before_scale = read(sll_pc - 4u);
if (!before_scale.has_value()) return false;
bool scheduled_base = *before_scale != 0u;
if (scheduled_base) {
// R3000 JR would see the old target register without this load delay.
if (lw_pc != jr_pc - 8u) return false;
if (sll_pc < entry + 16u) return false;
guard_pc -= 4u;
bound_pc -= 4u;
auto upper_word = read(sll_pc - 8u);
if (!upper_word.has_value()) return false;
uint32_t upper_reg = (*upper_word >> 16) & 0x1Fu;
if ((*upper_word >> 26) != 0x0Fu ||
((*upper_word >> 21) & 0x1Fu) != 0u || upper_reg == 0u ||
(*before_scale >> 26) != 0x09u ||
((*before_scale >> 21) & 0x1Fu) != upper_reg ||
((*before_scale >> 16) & 0x1Fu) != base_reg ||
upper_reg == index_reg || base_reg == index_reg) {
return false;
}
}
auto guard_word_opt = read(guard_pc);
auto bound_word = read(bound_pc);
auto guard_delay = read(sll_pc - 4u);
if (!guard_word_opt.has_value() || !bound_word.has_value() ||
!guard_delay.has_value() || *guard_delay != 0u) {
if (!guard_word_opt.has_value() || !bound_word.has_value()) {
return false;
}
uint32_t guard_word = *guard_word_opt;
@@ -920,20 +940,23 @@ bool resolve_exact_bounded_jump_table(
uint32_t bound_rt = (*bound_word >> 16) & 0x1Fu;
uint32_t count = *bound_word & 0xFFFFu;
if (bound_op != 0x0Bu || bound_rs != index_reg ||
bound_rt != bound_reg || count == 0u || count >= 512u ||
bound_rt != bound_reg || bound_reg == index_reg ||
count == 0u || count >= 512u ||
in_delay_slot(bound_pc)) {
return false;
}
uint32_t guard_target = exact_branch_target(guard_pc, guard_word);
if ((guard_target & 3u) != 0u || guard_target < entry ||
guard_target >= hard_cap ||
(guard_target >= sll_pc && guard_target < jr_pc + 8u)) {
(guard_target >= sll_pc && guard_target < jr_pc + 8u) ||
(scheduled_base && guard_target > bound_pc && guard_target < jr_pc + 8u)) {
return false;
}
// Resolve the table-base reaching definition. Cross-register constants
// (`lui rA; addiu rB,rA,lo`) are valid, but both definitions must be local,
// unskippable, outside delay slots, and unclobbered before use.
// unskippable and unclobbered before use. Only the validated scheduled
// form permits LUI in a delay slot, exactly that of the bounds BEQ.
uint32_t low_pc = 0, source_reg = base_reg;
int16_t low = 0;
uint32_t lui_pc = 0, upper = 0;
@@ -974,7 +997,9 @@ bool resolve_exact_bounded_jump_table(
break;
}
}
if (lui_pc == 0u || in_delay_slot(lui_pc) ||
if (scheduled_base && (lui_pc != guard_pc + 4u || low_pc != sll_pc - 4u))
return false;
if (lui_pc == 0u || (in_delay_slot(lui_pc) && !scheduled_base) ||
(low_pc != 0u && in_delay_slot(low_pc)) ||
inbound_skips(lui_pc, addu_pc) ||
(low_pc != 0u && inbound_skips(low_pc, addu_pc)) ||
@@ -1026,6 +1051,7 @@ bool resolve_exact_bounded_jump_table(
uint32_t runtime_target = *target_word;
uint32_t image_target = mapped(runtime_target);
if (image_target < entry || image_target >= hard_cap ||
(scheduled_base && image_target > bound_pc && image_target < jr_pc + 8u) ||
image_target < producer_lo || image_target >= producer_hi ||
(image_target & 3u) != 0u) {
return false;
@@ -1089,6 +1115,7 @@ bool resolve_exact_bounded_jump_table(
break;
}
}
uint32_t protected_pc = scheduled_base ? bound_pc : lui_pc;
for (uint32_t source = entry; source < hard_cap; source += 4u) {
auto word = read(source);
if (!word.has_value()) return false;
@@ -1096,9 +1123,9 @@ bool resolve_exact_bounded_jump_table(
bool direct = cf.kind == ExactCfKind::Branch ||
cf.kind == ExactCfKind::Jump ||
cf.kind == ExactCfKind::Jal;
if (direct && cf.target > lui_pc && cf.target <= jr_pc &&
(source < lui_pc || source > jr_pc) &&
!case_reachable.count(source)) {
if (direct && cf.target > protected_pc && cf.target <= jr_pc &&
(source < protected_pc || source > jr_pc) &&
(scheduled_base || !case_reachable.count(source))) {
return false;
}
}
@@ -102,9 +102,95 @@ bool resolves_ape_switch(const ApeSwitchFixture& f,
nullptr, producer_lo, producer_hi);
}
ApeSwitchFixture make_scheduled_switch() {
auto f = make_ape_switch();
const size_t text = 2048;
put32(f.image, text + 0x500, 0);
put32(f.image, text + 0x504, 0);
put32(f.image, text + 0x508, 0x2C620003u); // sltiu v0,v1,3
put32(f.image, text + 0x50C, 0x1040001Cu); // beq v0,zero,+0x580
put32(f.image, text + 0x510, 0x3C028001u); // lui v0 (guard delay)
put32(f.image, text + 0x514, 0x24420A00u); // addiu v0,v0,0xa00
put32(f.image, text + 0x518, 0x00031880u); // sll v1,v1,2
put32(f.image, text + 0x51C, 0x00621821u); // addu v1,v1,v0
put32(f.image, text + 0x520, 0x8C620000u); // lw v0,0(v1)
return f;
}
void check_scheduled_switch() {
const size_t text = 2048;
auto f = make_scheduled_switch();
auto exe = parse(f.image);
PSXRecomp::ExactJumpTable table;
CHECK(PSXRecomp::resolve_exact_bounded_jump_table(
exe, f.entry, kLoad + 0x600u, f.jr_pc, 2u, table) &&
table.table_base == f.table && table.table_count == 3u,
"scheduled table constant resolves exactly");
std::set<uint32_t> targets;
for (auto target : table.targets) targets.insert(target.second);
CHECK(targets == std::set<uint32_t>(f.cases.begin(), f.cases.end()),
"scheduled table has exactly the expected case targets");
PSXRecomp::FunctionAnalyzer analyzer(exe);
auto result = analyzer.analyze_exact_entries({f.entry});
for (auto target : f.cases)
CHECK(result.exact_reachable_pcs.count(target),
"scheduled switch cases are reached from the function root");
auto renamed = f;
put32(renamed.image, text + 0x510, 0x3C088001u);
put32(renamed.image, text + 0x514, 0x25020A00u);
CHECK(resolves_ape_switch(renamed, renamed.entry),
"scheduled constant can rename registers");
auto no_load_delay = f;
no_load_delay.jr_pc = kLoad + 0x524u;
put32(no_load_delay.image, text + 0x524, 0x00400008u);
CHECK(!resolves_ape_switch(no_load_delay, f.entry),
"scheduled switch must respect the R3000 load delay");
auto signed_low = f;
signed_low.image.resize(text + 0x9000u);
put32(signed_low.image, 0x1C, 0x9000u);
put32(signed_low.image, text + 0x510, 0x3C028002u);
put32(signed_low.image, text + 0x514, 0x24428A00u);
for (size_t i = 0; i < f.cases.size(); ++i)
put32(signed_low.image, text + 0x8A00u + 4u * i, f.cases[i]);
CHECK(resolves_ape_switch(signed_low, f.entry, kLoad, kLoad + 0x9000u),
"scheduled constant sign-extends ADDIU low half");
CHECK(!resolves_ape_switch(f, f.entry, kLoad, kLoad + 0x900u),
"scheduled table must belong to the producer");
auto jump = [](uint32_t offset) {
return 0x08000000u | (((kLoad + offset) >> 2) & 0x03FFFFFFu);
};
const std::vector<std::vector<std::pair<size_t, uint32_t>>> mutations = {
{{0x508, 0x2C630003u}, {0x50C, 0x1060001Cu}}, // bound writes index
{{0x510, 0x3C038001u}, {0x514, 0x24620A00u}}, // LUI writes index
{{0x514, 0x24430A00u}}, // ADDIU writes index
{{0x514, 0x25020A00u}}, // wrong constant source
{{0x510, 0x3C228001u}}, // reserved LUI fields
{{0x50C, jump(0x580) | 0x04000000u}}, // JAL delay
{{0x50C, 0x5040001Cu}}, // BEQL
{{0x50C, 0x1040FFFFu}}, // self-loop with clobbered condition
{{0x50C, 0x10400000u}}, // reject into LUI
{{0x500, jump(0x510)}}, // earlier edge skips bound
{{0x590, jump(0x510)}}, // later edge skips bound
{{0x540, jump(0x510)}, {0x544, 0}}, // case skips bound
{{0x504, jump(0x580)}}, // bound in delay slot
{{0xA00, kLoad + 0x510u}}, // case target skips bound
{{0xA04, kLoad + 0x2000u}}, // out of host
};
for (const auto& changes : mutations) {
auto broken = f;
for (auto change : changes)
put32(broken.image, text + change.first, change.second);
CHECK(!resolves_ape_switch(broken, broken.entry),
"unsafe scheduled-switch mutation fails closed");
}
}
} // namespace
int main() {
check_scheduled_switch();
auto image = make_exe_buffer(0x2000);
const size_t text = 2048;
@@ -1068,6 +1068,29 @@ void jump_table_producer_codegen_test() {
unbounded_alias_generated.front().full_code.find("/* jump table") !=
std::string::npos,
"alias regression fixture reaches the table when ownership is absent");
// The scheduled variant must feed code generation too, not merely the
// discovery report. Its table base overwrites the guard in the BEQ delay
// slot, so the existing branch emitter must preserve the tested condition.
write_word(exe, base + 0x500u, 0u);
write_word(exe, base + 0x504u, 0u);
write_word(exe, base + 0x508u, 0x2C620003u);
write_word(exe, base + 0x50Cu, 0x1040001Cu);
write_word(exe, base + 0x510u, 0x3C028001u);
write_word(exe, base + 0x514u, 0x24420A00u);
write_word(exe, base + 0x518u, 0x00031880u);
write_word(exe, base + 0x51Cu, 0x00621821u);
write_word(exe, base + 0x520u, 0x8C620000u);
PSXRecomp::ControlFlowAnalyzer scheduled_analyzer(exe);
const auto scheduled_cfg = scheduled_analyzer.analyze_function(function);
PSXRecomp::CodeGenerator scheduled_generator(exe);
const auto scheduled = scheduled_generator.generate_function(
function, scheduled_cfg).full_code;
check(scheduled.find("/* jump table") != std::string::npos,
"codegen emits a switch with its table LUI in the bounds delay slot");
for (uint32_t target : cases)
check(scheduled.find(fmt::format("goto block_{:08X}", target)) != std::string::npos,
"every scheduled case has an emitted native control-flow edge");
}
void cfg_codegen_load_delay_test() {
@@ -223,6 +223,81 @@ def check_bounded_jump_table_discovery():
LOAD + 0x60, 2, (LOAD, LOAD + 0x180)) == set()
def check_scheduled_jump_table_discovery():
# Independent synthetic function: a checked input in v1, the table base
# in v0, and three cases returning distinct values. No captured game bytes.
data = bytearray(0x1000)
for offset, word in (
(0x508, 0x2C620003), # sltiu v0,v1,3
(0x50C, 0x1040001C), # beq v0,zero,+0x580
(0x510, 0x3C028001), # lui v0,0x8001 (guard delay slot)
(0x514, 0x24420A00), # addiu v0,v0,0xa00
(0x518, 0x00031880), # sll v1,v1,2
(0x51C, 0x00621821), # addu v1,v1,v0
(0x520, 0x8C620000), # lw v0,0(v1)
(0x528, 0x00400008), # jr v0
(0x580, 0x03E00008),
):
put(data, offset, word)
cases = {LOAD + off for off in (0x540, 0x550, 0x560)}
for i, target in enumerate(sorted(cases)):
put(data, 0xA00 + i * 4, target)
put(data, target - LOAD, 0x24020001 + i)
put(data, target - LOAD + 4, 0x03E00008)
def resolve(image, producer=None):
return MOD._find_jump_table_targets(
image, LOAD, len(image), LOAD + 0x500, LOAD + 0x600,
LOAD + 0x528, 2, producer)
assert resolve(data) == cases
no_load_delay = bytearray(data)
put(no_load_delay, 0x524, 0x00400008)
assert MOD._find_jump_table_targets(
no_load_delay, LOAD, len(data), LOAD + 0x500, LOAD + 0x600,
LOAD + 0x524, 2) == set()
walk = MOD._walk_overlay_function(
data, LOAD, len(data), LOAD + 0x500, LOAD + 0x600)
assert walk['jump_table_targets'] == cases
assert cases <= walk['visited']
renamed = bytearray(data)
put(renamed, 0x510, 0x3C088001) # lui t0
put(renamed, 0x514, 0x25020A00) # addiu v0,t0
assert resolve(renamed) == cases
signed_low = bytearray(0x9000)
signed_low[:len(data)] = data
signed_low[0x8A00:0x8A0C] = data[0xA00:0xA0C]
put(signed_low, 0x510, 0x3C028002)
put(signed_low, 0x514, 0x24428A00)
assert resolve(signed_low) == cases
assert resolve(data, (LOAD, LOAD + 0x900)) == set()
def jump(offset):
return 0x08000000 | (((LOAD + offset) >> 2) & 0x03FFFFFF)
for name, mutations in (
('bound overwrites index', ((0x508, 0x2C630003), (0x50C, 0x1060001C))),
('LUI overwrites index', ((0x510, 0x3C038001), (0x514, 0x24620A00))),
('ADDIU overwrites index', ((0x514, 0x24430A00),)),
('wrong constant source', ((0x514, 0x25020A00),)),
('reserved LUI fields', ((0x510, 0x3C228001),)),
('call delay slot', ((0x50C, jal(LOAD + 0x580)),)),
('branch likely', ((0x50C, 0x5040001C),)),
('guard loop clobbers condition', ((0x50C, 0x1040FFFF),)),
('reject into constant', ((0x50C, 0x10400000),)),
('skip bound from before', ((0x500, jump(0x510)),)),
('skip bound from after', ((0x590, jump(0x510)),)),
('case skips bound', ((0x540, jump(0x510)), (0x544, 0))),
('bound in delay slot', ((0x504, jump(0x580)),)),
('table case skips bound', ((0xA00, LOAD + 0x510),)),
('out of host target', ((0xA04, LOAD + 0x700),)),
):
broken = bytearray(data)
for offset, word in mutations:
put(broken, offset, word)
assert resolve(broken) == set(), name
def check_composite_call_boundaries():
data = bytearray(0x100)
cross_target = LOAD + 0x50
@@ -3649,6 +3724,7 @@ def main():
check_composite_call_boundaries()
check_bounded_jump_table_discovery()
check_scheduled_jump_table_discovery()
check_static_discovery_provenance()
check_static_dispatch_provenance()
check_owned_direct_call_is_interior()
+15 -6
View File
@@ -5,7 +5,7 @@ disc image at build time, so a fresh install ships with overlay coverage instead
of relying entirely on runtime (tcc/gcc) autocompile. See `docs/AOT_OVERLAY_PLAN.md`
for the full design, findings, and honest coverage numbers.
**Status: ENHANCEMENT SPIKE. Provably-correct for clean producers, not exhaustive.**
**Status: ENHANCEMENT SPIKE. Partial static discovery; not a correctness proof.**
`extract_generic.py` is path-parameterized and suitable for the current build
pipeline. Disc-container discovery remains engine-specific; unsupported producers
fail closed instead of being guessed as code.
@@ -41,13 +41,22 @@ the named metric.
All 22 A*.BIN overlays converge on 0x80108F9C (region 0x80108000 +3996),
DEMO/GAME converge on 0x80106228, and OPN resolves to 0x8018A000.
## Correctness guarantee (why partial coverage is safe)
## What byte guards establish
Every shard is content-addressed by per-function `code_crc`; the runtime loader
only executes a shard when live RAM byte-matches (`overlay_loader.c` lazy_man_matches).
A mis-positioned / data-as-code shard either fails audit at compile time or never
fires at runtime → coverage loss, never incorrect execution. Whatever static
misses, production autocompile (tcc/gcc) self-heals on first visit.
checks live RAM against its recorded byte ranges (`overlay_loader.c`
`lazy_man_matches`). That protects against selecting a different overlay at the
same address. It does not prove that bytes were correctly classified as code,
that all dependencies were guarded, or that the emitted native code is correct.
Captured shards are investigative references, not correctness or completeness
oracles. Production fallback can cover additional executed paths; it cannot
establish that a static extraction is complete.
The shared switch recognizer accepts a bounded `sltiu; beq; lui; addiu; sll;
addu; lw; nop; jr` pattern, including LUI in the BEQ delay slot. Register
dependencies, producer bounds, table targets, and direct edges must pass the
proof checks. This recovers a compiler convention across titles without game
addresses in the recognizer. Unknown dispatch forms still require investigation.
## Durable runtime capture history
+38 -8
View File
@@ -844,6 +844,11 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
sll scaled,index,2; addu address,scaled,table_base
lw target,offset(address); [nop x 0..1]; jr target
Also accept ``sltiu; beq; lui; addiu; sll``: the LUI executes in the
guard's delay slot, then ADDIU completes the base on the in-range path.
That exact pair may not overwrite the checked index, and direct edges
may not bypass the bound (including edges from switch cases).
``table_base`` must be a nearest-definition LUI, optionally followed by a
nearest-definition ADDIU. The lower instruction may rename the
register (``lui v0; addiu s0,v0,lo``), as used by Ape Escape. Any other
@@ -853,7 +858,7 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
"""
lo = load_addr
hi = load_addr + size
if not (entry <= jr_pc < hard_cap and 0 < jr_rs < 32):
if not (entry <= jr_pc < hard_cap and 0 < jr_rs < 31):
return set()
jr_word = _word_at(data, load_addr, jr_pc)
if jr_word != ((jr_rs << 21) | 0x08):
@@ -904,7 +909,24 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
# The bound must guard this exact index on the sole fallthrough into SLL.
sltiu_pc = sll_pc - 12
branch_pc = sll_pc - 8
if _word_at(data, load_addr, sll_pc - 4) != 0:
scheduled_base = _word_at(data, load_addr, sll_pc - 4) != 0
if scheduled_base:
# R3000 load delay: JR must not consume the immediately preceding LW.
if lw_pc != jr_pc - 8:
return set()
sltiu_pc -= 4
branch_pc -= 4
upper = _word_at(data, load_addr, sll_pc - 8)
lower = _word_at(data, load_addr, sll_pc - 4)
if (upper is None or lower is None or
(upper >> 26) != 0x0F or ((upper >> 21) & 31) != 0 or
(lower >> 26) != 0x09 or ((lower >> 16) & 31) != table_reg or
((upper >> 16) & 31) == 0 or
((upper >> 16) & 31) != ((lower >> 21) & 31) or
index_reg in (table_reg, (upper >> 16) & 31)):
return set()
if sltiu_pc < entry or (sltiu_pc >= entry + 4 and
_is_control_flow(_word_at(data, load_addr, sltiu_pc - 4))):
return set()
sltiu_word = _word_at(data, load_addr, sltiu_pc)
branch_word = _word_at(data, load_addr, branch_pc)
@@ -913,7 +935,7 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
return set()
guard_reg = (sltiu_word >> 16) & 0x1F
table_count = sltiu_word & 0xFFFF
if not guard_reg or not (1 <= table_count < 512):
if not guard_reg or guard_reg == index_reg or not (1 <= table_count < 512):
return set()
if branch_word is None or ((branch_word >> 26) & 0x3F) != 0x04:
return set()
@@ -923,7 +945,8 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
return set()
reject_target = _branch_target(branch_pc, branch_word)
if (not (entry <= reject_target < hard_cap) or
sll_pc <= reject_target < jr_pc + 8):
sll_pc <= reject_target < jr_pc + 8 or
(scheduled_base and sltiu_pc < reject_target < jr_pc + 8)):
return set()
def nearest_writer(reg: int, before: int):
@@ -962,6 +985,10 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
else:
return set()
if scheduled_base and (constant_pc != branch_pc + 4 or
table_def_pc != sll_pc - 4):
return set()
# Calls could clobber the proven constant. Other branches could enter the
# suffix through an unproved path. The one exact bounds branch is allowed.
for pc in range(constant_pc + 4, sll_pc, 4):
@@ -971,7 +998,8 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
if _is_control_flow(word) and pc != branch_pc:
return set()
predecessor = _word_at(data, load_addr, constant_pc - 4)
if constant_pc >= entry + 4 and _is_control_flow(predecessor):
if (constant_pc >= entry + 4 and _is_control_flow(predecessor) and
not scheduled_base):
return set()
table_base = (table_base + lw_offset) & 0xFFFFFFFF
table_end = table_base + table_count * 4
@@ -984,6 +1012,7 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
target = _word_at(data, load_addr, table_base + index * 4)
if (target is None or (target & 3) or
not (entry <= target < hard_cap) or
(scheduled_base and sltiu_pc < target < jr_pc + 8) or
not (range_lo <= target < range_hi) or
not _is_valid_mips_word(_word_at(data, load_addr, target))):
return set()
@@ -1025,15 +1054,16 @@ def _find_jump_table_targets(data: bytes, load_addr: int, size: int,
elif kind in ('jr', 'jr_ra'):
case_reachable.add(delay)
protected_pc = sltiu_pc if scheduled_base else constant_pc
for source in range(entry, hard_cap, 4):
source_word = _word_at(data, load_addr, source)
if source_word is None:
return set()
kind, target = _classify_cf(source, source_word)
if (kind in ('branch', 'j', 'jal') and
constant_pc < target <= jr_pc and
not (constant_pc <= source <= jr_pc) and
source not in case_reachable):
protected_pc < target <= jr_pc and
not (protected_pc <= source <= jr_pc) and
(scheduled_base or source not in case_reachable)):
return set()
if proof_out is not None:
proof_out.append({