From 926a9e9e99b339e09a0761cfcf38d4d47491a11b Mon Sep 17 00:00:00 2001 From: Claudio Varandas Date: Wed, 19 Aug 2026 00:31:56 +0100 Subject: [PATCH] recompiler: match dispatch entries for KUSEG-addressed games PS1 segments alias the same physical RAM. Most PS-X EXE headers carry KSEG addresses, but a header may legally carry KUSEG ones instead, and the guest then executes with a KUSEG PC. Alien Resurrection (SLUS-00633) is such a title: its header holds load address 0x00010000 and entry PC 0x000155A4, and get_registers on a live run reports PC=0x00010170. The emitted dispatch table is keyed by the recompiler's KSEG-normalized addresses, and psx_game_find_entry() compared those keys against the raw incoming address. For a KUSEG PC the comparison can never succeed, because 0x0001xxxx is always below 0x8001xxxx: the binary search walks to the start of the table and returns no entry. Every dispatch then falls through to the interpreter. The failure is silent and easy to misread. psx_game_address_in_text() does mask, so it still reports the address as game text and the interpreter runs the real RAM bytes via clean_game_text_miss. The game boots and renders correctly, just entirely interpreted. Because no entry is ever found, the text-image guard is not reached either, so its diagnostics stay at zero (text_native_blocked=0, text_exact_mismatches=0) while dirty_ram_stats reports over a billion interpreted instructions and native_handoffs=0 -- which reads as "nothing was rejected" rather than "nothing was looked up". Compare 29-bit physical addresses on both sides of the search, and sort the table by that same masked key so the binary-search invariant holds by construction rather than by every entry happening to share a segment. Adds tests/test_kuseg_dispatch_lookup.py: synthesizes a KUSEG-addressed PS-EXE, runs the emitter, and asserts the lookup masks both the incoming address and the stored key, and that the emitted table is ordered by physical address. Co-Authored-By: Claude Opus 5 (1M context) --- recompiler/CMakeLists.txt | 6 + recompiler/src/main_psx.cpp | 21 ++- .../tests/test_kuseg_dispatch_lookup.py | 123 ++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 recompiler/tests/test_kuseg_dispatch_lookup.py diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 71c16cc0..0bb2d77d 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -399,6 +399,12 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fmv_cycle_batch_codegen.py --recompiler $ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_test( + NAME kuseg_dispatch_lookup + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_kuseg_dispatch_lookup.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) add_test( NAME overlay_shim_compile_contract COMMAND ${Python3_EXECUTABLE} diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 5df7cb7c..5d6e45df 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -1502,9 +1502,13 @@ int main(int argc, char** argv) { records.push_back({cont, cont, owner, 0, 0}); } } + // Sort by PHYSICAL address. psx_game_find_entry() compares masked + // addresses so a KUSEG-executing guest still matches KSEG-normalized + // keys; the search invariant must therefore be the masked order too. + // Within one segment this is identical to sorting by the raw address. std::sort(records.begin(), records.end(), [](const DispatchRecord& a, const DispatchRecord& b) { - return a.addr < b.addr; + return (a.addr & 0x1FFFFFFFu) < (b.addr & 0x1FFFFFFFu); }); // Attach the exact CFG instruction ranges from the manifest to every @@ -1586,13 +1590,22 @@ int main(int argc, char** argv) { } ds << "};\n"; ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size()); + ds << "/* PS1 segments alias the same physical RAM. A game whose PS-X EXE\n"; + ds << " * header carries KUSEG addresses (load address and entry PC without the\n"; + ds << " * KSEG bit) executes with a KUSEG PC, while this table is keyed by the\n"; + ds << " * recompiler's KSEG-normalized addresses. Comparing raw values made every\n"; + ds << " * lookup fail for such a title: 0x0001xxxx is always below 0x8001xxxx, so\n"; + ds << " * the search collapsed and returned no entry, silently routing all game\n"; + ds << " * code to the interpreter. Compare the 29-bit physical address instead;\n"; + ds << " * the table is sorted by the same masked key. */\n"; ds << "static const PsxGameDispatchEntry* psx_game_find_entry(uint32_t addr) {\n"; + ds << " const uint32_t want = addr & 0x1FFFFFFFu;\n"; ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n"; ds << " while (lo < hi) {\n"; ds << " uint32_t mid = lo + (hi - lo) / 2;\n"; - ds << " uint32_t key = k_psx_game_dispatch[mid].addr;\n"; - ds << " if (addr < key) hi = mid;\n"; - ds << " else if (addr > key) lo = mid + 1;\n"; + ds << " uint32_t key = k_psx_game_dispatch[mid].addr & 0x1FFFFFFFu;\n"; + ds << " if (want < key) hi = mid;\n"; + ds << " else if (want > key) lo = mid + 1;\n"; ds << " else return &k_psx_game_dispatch[mid];\n"; ds << " }\n"; ds << " return 0;\n"; diff --git a/recompiler/tests/test_kuseg_dispatch_lookup.py b/recompiler/tests/test_kuseg_dispatch_lookup.py new file mode 100644 index 00000000..cb14c4b7 --- /dev/null +++ b/recompiler/tests/test_kuseg_dispatch_lookup.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Recompiler codegen regression test: KUSEG-addressed games must dispatch. + +PS1 segments alias the same physical RAM. Most PS-X EXE headers carry KSEG +addresses (0x8001xxxx), but a header may legally carry KUSEG ones instead +(0x0001xxxx) -- Alien Resurrection (SLUS-00633) does, and it then executes with +a KUSEG PC. + +The recompiler normalizes the dispatch table to KSEG keys. When the lookup +compared raw values, a KUSEG PC could never match: 0x0001xxxx is always below +0x8001xxxx, so the binary search collapsed to the start and returned no entry. +Every dispatch fell through to the interpreter -- silently, because the +text-image guard was never even reached (it reported zero blocks and zero +mismatches while the whole game ran interpreted). + +This test synthesizes a KUSEG-addressed PS-EXE and asserts the emitted lookup +compares 29-bit physical addresses, and that the table is ordered by that same +key so the binary-search invariant holds. + +Usage: python test_kuseg_dispatch_lookup.py [--recompiler ] +Exit 0 = PASS. +""" +import argparse, os, re, struct, subprocess, sys, tempfile + +LOAD = 0x00010000 # KUSEG: no KSEG bit, the case under test +PHYS_MASK = 0x1FFFFFFF + + +def w(words): + return b"".join(struct.pack("> 2) & 0x03FFFFFF) + + +def build_exe(): + # func A @ LOAD: addiu sp,-8 ; jal B ; nop ; addiu sp,8 ; jr ra ; nop + # func B @ LOAD+0x20: jr ra ; nop + a = [0x27BDFFF8, jal(LOAD + 0x20), 0x00000000, 0x27BD0008, 0x03E00008, 0x00000000] + body = bytearray(w(a)) + body += b"\x00" * (0x20 - len(body)) + body += w([0x03E00008, 0x00000000]) + return make_psxexe(LOAD, LOAD, bytes(body)) + + +def gen_dispatch(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("0x%08X\n0x%08X\n" % (LOAD, LOAD + 0x20)) + r = subprocess.run([recompiler, psx, "--seeds", seeds, "--out-dir", out], + capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit("recompiler failed:\n" + (r.stderr or r.stdout)) + disp = [f for f in os.listdir(out) if f.endswith("_dispatch.c")] + if not disp: + raise SystemExit("no _dispatch.c emitted in " + out) + with open(os.path.join(out, disp[0])) as f: + return f.read() + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + ap = argparse.ArgumentParser() + ap.add_argument("--recompiler", + default=os.path.normpath(os.path.join(here, "..", "build", + "psxrecomp-game"))) + args = ap.parse_args() + if not os.path.isfile(args.recompiler): + raise SystemExit("recompiler not found: %s (build it first)" % args.recompiler) + + with tempfile.TemporaryDirectory() as tmp: + src = gen_dispatch(args.recompiler, tmp) + + m = re.search(r"static const PsxGameDispatchEntry\* psx_game_find_entry" + r"\(uint32_t addr\) \{(.*?)\n\}", src, re.DOTALL) + if not m: + raise SystemExit("psx_game_find_entry not found in emitted dispatch") + body = m.group(1) + + # The incoming PC must be reduced to a physical address before comparing. + if "0x1FFFFFFFu" not in body: + raise SystemExit( + "psx_game_find_entry does not mask to a physical address; a KUSEG " + "guest PC can never match KSEG-normalized table keys") + # ... and so must the stored key, otherwise the comparison is still mixed. + if not re.search(r"k_psx_game_dispatch\[mid\]\.addr\s*&\s*0x1FFFFFFFu", body): + raise SystemExit( + "psx_game_find_entry compares against an unmasked table key") + + # The binary search requires the table to be ordered by the same key it + # compares. Parse the emitted entries and assert monotonic physical order. + tbl = re.search(r"k_psx_game_dispatch\[\] = \{(.*?)\n\};", src, re.DOTALL) + if not tbl: + raise SystemExit("dispatch table not found in emitted dispatch") + keys = [int(x, 16) & PHYS_MASK + for x in re.findall(r"\{0x([0-9A-Fa-f]{8})u,", tbl.group(1))] + if len(keys) < 2: + raise SystemExit("expected at least two dispatch entries, got %d" % len(keys)) + if keys != sorted(keys): + raise SystemExit("dispatch table is not sorted by physical address; " + "the masked binary search would miss entries") + + print("KUSEG dispatch lookup test passed (%d entries, physical-keyed)" % len(keys)) + + +if __name__ == "__main__": + main()