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
+21 -3
View File
@@ -2322,6 +2322,15 @@ std::string CodeGenerator::translate_basic_block(
exe_, cfg.function_start, cfg.function_end,
block.exit_instr.address, jr_rs, exact_table,
ram_to_rom, cfg.producer_lo, cfg.producer_hi);
// Not a table: a computed-stride entry into an unrolled
// run (Duff's device — the decompressor copy loops). Same
// switch shape; the CPS default keeps every other target.
if (!have_exact_table) {
have_exact_table = resolve_computed_stride_jump(
exe_, cfg.function_start, cfg.function_end,
block.exit_instr.address, jr_rs, exact_table,
ram_to_rom);
}
uint32_t table_base = have_exact_table
? exact_table.table_base : 0u;
uint32_t table_count = have_exact_table
@@ -2344,8 +2353,14 @@ std::string CodeGenerator::translate_basic_block(
}
}
if (!targets.empty()) {
ss << config_.indent << fmt::format("/* jump table 0x{:08X} (rom 0x{:08X}), {} entries */\n",
table_base, rom_table_base, table_count);
if (exact_table.stride != 0u) {
ss << config_.indent << fmt::format(
"/* computed-stride jump into unrolled run 0x{:08X} (rom 0x{:08X}), stride {}, {} entries */\n",
table_base, rom_table_base, exact_table.stride, table_count);
} else {
ss << config_.indent << fmt::format("/* jump table 0x{:08X} (rom 0x{:08X}), {} entries */\n",
table_base, rom_table_base, table_count);
}
ss << config_.indent << fmt::format("switch ({}) {{\n", delay_saved_target);
for (auto& [rt, rom] : targets) {
if (partial_block_cycle_count(rom, cfg) != 0) {
@@ -2898,7 +2913,10 @@ void CodeGenerator::scan_jr_tables(
if (!resolve_exact_bounded_jump_table(
exe_, cfg.function_start, cfg.function_end,
blk.exit_instr.address, jr_r, exact_table, ram_to_rom,
cfg.producer_lo, cfg.producer_hi)) {
cfg.producer_lo, cfg.producer_hi) &&
!resolve_computed_stride_jump(
exe_, cfg.function_start, cfg.function_end,
blk.exit_instr.address, jr_r, exact_table, ram_to_rom)) {
continue;
}
uint32_t tb = exact_table.table_base;