Files
psxrecomp/recompiler/tests/test_computed_stride_jump_codegen.py
Kevin b0e923dcd0 code_generator: resolve self-limited pointer tables with an unchecked index
The bounded-jump-table resolver requires the guest's own bounds check
(`sltiu/beq`) and index scaling (`sll`), because those are what prove the
table's extent. A decoder that keeps its state as a pre-scaled byte offset
and dispatches through `lui/ori base; addu base,offset; lw; jr` has neither,
so the jr fell back to a bare CPS tail-transfer and every entry it reached
that was not already a dispatch key ran on the dirty-RAM interpreter. On
Breath of Fire III that is the file decoder's state dispatch at
`0x80164AB0` (five entries; the first entry is the word right after the
table), the last boot-EXE row a play session still interpreted after the
computed-stride resolver.

resolve_self_limited_jump_table recovers the extent from the table's own
layout instead: words are taken from the base while each is a 4-aligned
in-function code address outside every delay slot, and the table ends
where its lowest target begins, since a pointer table cannot overlap the
code it points at. As with the stride resolver the emitter switches on
the actual runtime target with the CPS tail-transfer as default, so the
recovered entries only add native cases; an index past the recovered
extent still takes the fallback. Wired as the third fallback at both
code-generator sites and in the exact-entry discovery walker.

On the title's boot EXE it matches exactly one site (five entries); every
other shard is byte-identical. The synthetic test gains a second function
with this shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:16:20 -05:00

161 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Codegen test: a computed-stride jump into an unrolled run gets a switch.
Duff's-device copy loops enter their unrolled body in the middle through
`jr R` with `R = base + index * stride`. There is no table in memory, so the
bounded-jump-table resolver cannot see it and the emitter used to fall back
to a bare CPS tail-transfer; the trampoline then found no dispatch key at the
interior PC and the dirty-RAM interpreter ran the rest of the copy on every
call (Breath of Fire III's decompressor: the whole non-kernel residual of a
play session). resolve_computed_stride_jump recognises the shape and the
emitter registers the interior targets and switches on them.
Synthesized function (load 0x80010000):
sll t7,a0,3
sll t5,a0,2
addu t7,t7,t5 ; t7 = a0 * 12
addu t7,a1,t7 ; t7 = base + a0 * 12
jr t7
nop
lbu t5,0(a2); nop; sb t5,0(a3) ; group 0 @ +0x18
lbu t5,1(a2); nop; sb t5,1(a3) ; group 1 @ +0x24
lbu t5,2(a2); nop; sb t5,2(a3) ; group 2 @ +0x30
lbu t5,3(a2); nop; sb t5,3(a3) ; group 3 @ +0x3C
jr ra ; nop ; tail @ +0x48
Expected: a `computed-stride jump` switch with cases for every group and the
tail, each a `goto block_...`, and the CPS tail-transfer kept as default.
A second function (see build_exe) covers the sibling shape from the same
decoder: an in-function pointer table indexed by a stored, unchecked byte
offset (`lui/ori base; addu; lw; jr`, no sltiu/beq guard, no sll), whose
extent resolve_self_limited_jump_table takes from the table's own layout.
Usage: python test_computed_stride_jump_codegen.py [--recompiler <psxrecomp-game.exe>]
"""
import argparse
import os
import re
import struct
import subprocess
import sys
import tempfile
LOAD = 0x80010000
def w(words):
return b"".join(struct.pack("<I", x) for x in words)
def make_psxexe(entry, data):
h = bytearray(2048)
h[0:8] = b"PS-X EXE"
struct.pack_into("<I", h, 0x10, entry)
struct.pack_into("<I", h, 0x18, LOAD)
struct.pack_into("<I", h, 0x1C, len(data))
return bytes(h) + data
def build_exe():
body = [
0x000478C0, # sll t7,a0,3
0x00046880, # sll t5,a0,2
0x01ED7821, # addu t7,t7,t5
0x00AF7821, # addu t7,a1,t7
0x01E00008, # jr t7
0x00000000, # nop
]
for k in range(4):
body += [0x90CD0000 | k, 0x00000000, 0xA0ED0000 | k] # lbu t5,k(a2); nop; sb t5,k(a3)
body += [0x03E00008, 0x00000000] # jr ra; nop
body += [0x00000000] * 4 # pad to 0x80010060
# Second function @ 0x80010060: a self-limited pointer table indexed by a
# stored, unchecked byte offset (no sltiu/beq guard, no sll):
# lui t5,0x8001 ; ori t5,t5,0x007C ; addu t5,a0,t5 ; lw t4,0(t5) ; nop ; jr t4 ; nop (0x60..0x78)
# table @ 0x8001007C: 0x80010084, 0x80010090 (ends where its lowest target begins)
# 0x80010084: addiu v0,zero,1 ; j exit ; nop
# 0x80010090: addiu v0,zero,2 ; j exit ; nop
# 0x8001009C exit: jr ra ; nop
exit_pc = 0x8001009C
j_exit = 0x08000000 | ((exit_pc >> 2) & 0x03FFFFFF)
body += [0x3C0D8001, 0x35AD007C, 0x008D6821, 0x8DAC0000, 0x00000000, 0x01800008, 0x00000000]
body += [0x80010084, 0x80010090]
body += [0x24020001, j_exit, 0x00000000]
body += [0x24020002, j_exit, 0x00000000]
body += [0x03E00008, 0x00000000]
return make_psxexe(LOAD, w(body))
def gen_c(recompiler, tmp):
psx = os.path.join(tmp, "t.psx")
seeds = os.path.join(tmp, "seeds.txt")
out = os.path.join(tmp, "out")
os.makedirs(out, exist_ok=True)
with open(psx, "wb") as f:
f.write(build_exe())
with open(seeds, "w") as f:
f.write("0x80010000\n0x80010060\n")
r = subprocess.run([recompiler, psx, "--seeds", seeds, "--out-dir", out],
capture_output=True, text=True)
if r.returncode != 0:
raise SystemExit(f"recompiler failed:\n{r.stderr or r.stdout}")
chunks = []
for name in sorted(os.listdir(out)):
if "_full" in name and name.endswith(".c") and "_dispatch" not in name:
with open(os.path.join(out, name), encoding="utf-8") as f:
chunks.append(f.read())
if not chunks:
raise SystemExit(f"no _full*.c emitted in {out}")
return "\n".join(chunks)
def main():
here = os.path.dirname(os.path.abspath(__file__))
default_recomp = os.path.normpath(os.path.join(here, "..", "build", "psxrecomp-game.exe"))
ap = argparse.ArgumentParser()
ap.add_argument("--recompiler", default=default_recomp)
args = ap.parse_args()
if not os.path.isfile(args.recompiler):
raise SystemExit(f"recompiler not found: {args.recompiler} (build it first)")
with tempfile.TemporaryDirectory() as tmp:
c = gen_c(args.recompiler, tmp)
fails = []
m = re.search(r"/\* computed-stride jump into unrolled run 0x80010018 \(rom 0x80010018\), "
r"stride 12, 5 entries \*/", c)
if not m:
fails.append("no computed-stride switch comment for run 0x80010018 / stride 12 / 5 entries")
for k in range(5):
t = 0x80010018 + 12 * k
if not re.search(r"case 0x%08Xu:\s*(?:\n.*?)*?goto block_%08X;" % (t, t), c):
fails.append("missing case 0x%08X -> goto block_%08X" % (t, t))
if not re.search(r"default:\s*\n(?:.*\n)*?\s*cpu->pc = .*; return; /\* CPS: jr table miss", c):
fails.append("the CPS tail-transfer default is missing from the switch")
# Interior labels must exist mid-block for the gotos to land on.
for k in range(1, 4):
t = 0x80010018 + 12 * k
if ("block_%08X:" % t) not in c:
fails.append("no interior label block_%08X" % t)
# Second function: the self-limited table.
if not re.search(r"/\* self-limited jump table 0x8001007C \(rom 0x8001007C\), 2 entries, unchecked index \*/", c):
fails.append("no self-limited table switch comment for table 0x8001007C / 2 entries")
for t in (0x80010084, 0x80010090):
if not re.search(r"case 0x%08Xu:\s*(?:\n.*?)*?goto block_%08X;" % (t, t), c):
fails.append("missing self-limited case 0x%08X -> goto block_%08X" % (t, t))
for f in fails:
print("FAIL:", f)
if fails:
return 1
print("PASS: computed-stride jump and self-limited table each emit a switch over every entry.")
return 0
if __name__ == "__main__":
sys.exit(main())