code_generator: resolve computed-stride jumps into unrolled runs (Duff's device)

A `jr R` with `R = base + index * stride` and no table in memory is how
unrolled copy loops enter their body in the middle (Duff's device). The
bounded-jump-table resolver only knows the `sltiu/beq; sll; addu; lw; jr`
chain, so the emitter fell back to a bare CPS tail-transfer; the trampoline
then found no dispatch key at the interior PC (straight-line code, no block
leader) and the dirty-RAM interpreter ran the rest of the copy on every
call. On Breath of Fire III that is the file decompressor's literal-copy
loop (`0x80164D1C`, 32 groups of `lbu/nop/sb`), which fires on every load
and was the whole non-kernel interpreted residual of a play session once
the kernel patch ranges (#346) and the call vectors (#381) ran native.

resolve_computed_stride_jump walks the chain back from the jr through
nearest definitions in straight-line code: `addu R, B, S` with S from
`sll S, I, k` or the two-sll sum `sll P,I,a; sll Q,I,b; addu S,P,Q`, and
then requires the code after the delay slot to be N >= 2 shape-identical
groups of `stride` bytes (same opcodes and register fields, immediates
free, no control flow inside a group). Targets are run_start + k * stride
for k = 0..N. The base register's value is never assumed: the emitter
switches on the actual runtime target with the CPS tail-transfer as the
default, so the recovered cases can only add native entries, never
redirect a jump that lands elsewhere. Wired into both code-generator
sites (interior labels + the switch) and the exact-entry discovery walker.

On the title's boot EXE it matches exactly one site, the decompressor loop
(33 entries); every other jr is untouched.

Adds recompiler/tests/test_computed_stride_jump_codegen.py (synthetic
PS-EXE with the idiom; asserts the switch, every case and the interior
labels), registered with CTest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin
2026-09-17 14:59:49 -04:00
committed by Matt Stanley
parent 2ba432ac0c
commit 6c98726a66
5 changed files with 346 additions and 4 deletions
+159 -1
View File
@@ -1133,6 +1133,162 @@ bool resolve_exact_bounded_jump_table(
return true;
}
bool resolve_computed_stride_jump(
const PS1Executable& exe,
uint32_t entry,
uint32_t hard_cap,
uint32_t jr_pc,
uint32_t jr_rs,
ExactJumpTable& table,
ExactAddressMapper runtime_to_image) {
table = {};
if (entry >= hard_cap || jr_pc < entry || jr_pc + 8u >= hard_cap ||
(entry & 3u) != 0u || (hard_cap & 3u) != 0u ||
(jr_pc & 3u) != 0u || jr_rs == 0u || jr_rs == 31u) {
return false;
}
auto read = [&](uint32_t pc) { return exe.read_word(pc); };
auto jr_word = read(jr_pc);
if (!jr_word.has_value() ||
*jr_word != ((jr_rs & 0x1Fu) << 21u | 0x08u)) {
return false;
}
// Nearest definition of `reg` strictly before `use_pc`, walking back at
// most `window` instructions through straight-line code only. A control
// transfer (or its delay slot) in the way ends the search: the chain
// must be one basic block so every path to the jr computes the same
// shape.
auto nearest_def = [&](uint32_t reg, uint32_t use_pc,
uint32_t window) -> std::optional<uint32_t> {
if (reg == 0u) return std::nullopt;
uint32_t pc = use_pc;
for (uint32_t n = 0; n < window && pc >= entry + 4u; ++n) {
pc -= 4u;
auto w = read(pc);
if (!w.has_value()) return std::nullopt;
if (exact_classify_cf(pc, *w).kind != ExactCfKind::Normal)
return std::nullopt;
if (exact_instruction_writes_gpr(*w, reg)) return pc;
}
return std::nullopt;
};
auto fields = [&](uint32_t w, uint32_t& op, uint32_t& rs, uint32_t& rt,
uint32_t& rd, uint32_t& sa, uint32_t& fn) {
op = (w >> 26) & 0x3Fu; rs = (w >> 21) & 0x1Fu; rt = (w >> 16) & 0x1Fu;
rd = (w >> 11) & 0x1Fu; sa = (w >> 6) & 0x1Fu; fn = w & 0x3Fu;
};
auto is_addu = [&](uint32_t w, uint32_t& rd, uint32_t& rs, uint32_t& rt) {
uint32_t op, sa, fn;
fields(w, op, rs, rt, rd, sa, fn);
return op == 0u && fn == 0x21u && sa == 0u && rd != 0u;
};
// sll rd, rt, sa -> (rd, index rt, shift)
auto is_sll = [&](uint32_t w, uint32_t& rd, uint32_t& idx, uint32_t& sh) {
uint32_t op, rs, fn;
fields(w, op, rs, idx, rd, sh, fn);
return op == 0u && fn == 0u && rs == 0u && rd != 0u && idx != 0u && w != 0u;
};
// 1. jr_rs = addu B, S (either order), nearest definition before the jr.
auto addu_pc = nearest_def(jr_rs, jr_pc, 8u);
if (!addu_pc.has_value()) return false;
uint32_t a_rd, a_rs, a_rt;
if (!is_addu(*read(*addu_pc), a_rd, a_rs, a_rt) || a_rs == a_rt ||
a_rs == 0u || a_rt == 0u) {
return false;
}
// 2. One operand is the scaled index: sll S,I,k or the two-sll sum.
// Try each operand as the scaled register; the other is the base.
auto scaled_stride = [&](uint32_t s_reg, uint32_t use_pc) -> uint32_t {
auto def = nearest_def(s_reg, use_pc, 8u);
if (!def.has_value()) return 0u;
uint32_t w = *read(*def);
uint32_t rd, idx, sh;
if (is_sll(w, rd, idx, sh)) {
return (sh >= 2u && sh <= 8u) ? (1u << sh) : 0u;
}
uint32_t s_rd, p, q;
if (!is_addu(w, s_rd, p, q) || p == q || p == 0u || q == 0u) return 0u;
auto p_def = nearest_def(p, *def, 8u);
auto q_def = nearest_def(q, *def, 8u);
if (!p_def.has_value() || !q_def.has_value()) return 0u;
uint32_t p_rd, p_idx, p_sh, q_rd, q_idx, q_sh;
if (!is_sll(*read(*p_def), p_rd, p_idx, p_sh) ||
!is_sll(*read(*q_def), q_rd, q_idx, q_sh) ||
p_idx != q_idx || p_sh == q_sh || p_sh > 8u || q_sh > 8u) {
return 0u;
}
// The index must reach both shifts unchanged: no write to it between
// the earlier sll and the later one.
uint32_t lo = std::min(*p_def, *q_def), hi = std::max(*p_def, *q_def);
for (uint32_t pc = lo + 4u; pc < hi; pc += 4u) {
if (exact_instruction_writes_gpr(*read(pc), p_idx)) return 0u;
}
return (1u << p_sh) + (1u << q_sh);
};
uint32_t stride = scaled_stride(a_rt, *addu_pc);
if (stride == 0u) stride = scaled_stride(a_rs, *addu_pc);
if (stride == 0u || (stride & 3u) != 0u || stride > 256u) return false;
// 3. The run: N >= 2 shape-identical groups right after the delay slot.
// Same opcode/register fields per position; immediates free; no
// control flow inside group 0 (so every group is straight-line).
const uint32_t run_start = jr_pc + 8u;
const uint32_t words_per_group = stride / 4u;
if (run_start + stride > hard_cap) return false;
auto shape = [](uint32_t w) -> uint32_t {
uint32_t op = (w >> 26) & 0x3Fu;
// I-type: keep opcode + rs + rt, drop the immediate. J-type and
// SPECIAL/REGIMM/COP forms compare whole.
if (op == 0u || op == 1u || op == 2u || op == 3u || op == 0x10u ||
op == 0x12u) {
return w;
}
return w & 0xFFFF0000u;
};
std::vector<uint32_t> group0;
bool all_nop = true;
for (uint32_t i = 0; i < words_per_group; ++i) {
auto w = read(run_start + i * 4u);
if (!w.has_value()) return false;
if (exact_classify_cf(run_start + i * 4u, *w).kind != ExactCfKind::Normal)
return false;
if (*w != 0u) all_nop = false;
group0.push_back(*w);
}
if (all_nop) return false;
uint32_t groups = 1;
for (;;) {
uint32_t g = run_start + groups * stride;
if (g + stride > hard_cap) break;
bool same = true;
for (uint32_t i = 0; i < words_per_group && same; ++i) {
auto w = read(g + i * 4u);
same = w.has_value() && shape(*w) == shape(group0[i]);
}
if (!same) break;
++groups;
}
if (groups < 2u) return false;
auto mapped = [&](uint32_t addr) {
return runtime_to_image ? runtime_to_image(addr, exe) : addr;
};
ExactJumpTable resolved;
resolved.table_base = run_start;
resolved.stride = stride;
for (uint32_t k = 0; k <= groups; ++k) {
uint32_t t = run_start + k * stride;
if (t >= hard_cap) break;
resolved.targets.emplace_back(t, mapped(t));
}
resolved.table_count = static_cast<uint32_t>(resolved.targets.size());
table = std::move(resolved);
return true;
}
FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
const std::vector<uint32_t>& entries,
const std::vector<std::pair<uint32_t, uint32_t>>& producer_ranges,
@@ -1304,7 +1460,9 @@ FunctionAnalysisResult FunctionAnalyzer::analyze_exact_entries(
}
if (resolve_exact_bounded_jump_table(
exe_, entry, hard_cap, pc, jr_rs, table,
nullptr, producer_lo, producer_hi)) {
nullptr, producer_lo, producer_hi) ||
resolve_computed_stride_jump(
exe_, entry, hard_cap, pc, jr_rs, table)) {
bool all_owned = std::all_of(
table.targets.begin(), table.targets.end(),
[&](const auto& target_pair) {