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()