From 9913b146cd13ae52d4e70b40a8b46774dd90fe22 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 18 Aug 2026 12:20:20 -0400 Subject: [PATCH 1/6] chore: sync --- .gitmodules | 2 +- lib/recomp-net | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 6adc28bc..bbef4ec1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,4 +6,4 @@ [submodule "lib/retcomm-rbengine"] path = lib/retcomm-rbengine url = https://github.com/TechnicallyComputers/retcomm-rbengine.git - branch = main + branch = master diff --git a/lib/recomp-net b/lib/recomp-net index 02c29e6a..77a9e26f 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 02c29e6ac7a5d2eb604cf497f8613f6a36e47cc1 +Subproject commit 77a9e26fa9197694fb8f3424005b3a0fd0a2a810 From 926a9e9e99b339e09a0761cfcf38d4d47491a11b Mon Sep 17 00:00:00 2001 From: Claudio Varandas Date: Wed, 19 Aug 2026 00:31:56 +0100 Subject: [PATCH 2/6] 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() From 91ea482b1726b5382519c68ec91c2c1c8b5e0936 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Wed, 19 Aug 2026 16:46:26 +0300 Subject: [PATCH 3/6] runtime: default string-inventory capture off, PSX_XLATE_CAPTURE=1 re-enables The always-on Shift-JIS string-inventory scan walks every guest store argument even when no translation table is loaded. Measured cost: 26-35% of whole-lane throughput on streaming-heavy titles (three GT2 race lanes; reproduced on Driver 2, where disabling it took one lane from 19 to 43 vblank/s). Capture now defaults off; PSX_XLATE_CAPTURE=1 turns the scan back on for translation authoring. Substitution (armed table + language) is unaffected either way. --- runtime/src/text_xlate.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/runtime/src/text_xlate.cpp b/runtime/src/text_xlate.cpp index 562630ee..94396cec 100644 --- a/runtime/src/text_xlate.cpp +++ b/runtime/src/text_xlate.cpp @@ -417,7 +417,12 @@ std::atomic g_msg_sep_pending{0}; // unpatched coun std::mutex g_mtx; std::atomic g_apply_armed{false}; // table non-empty AND language enabled -std::atomic g_capture_on{true}; // always-on inventory (default) +std::atomic g_capture_on{false}; // inventory capture DEFAULT OFF: + // the always-on Shift-JIS string-inventory scan costs 26-35% of + // whole-lane throughput on streaming-heavy titles (measured across + // three GT2 race lanes; reproduced fleet-wide). Substitution (table + + // language armed) is unaffected; PSX_XLATE_CAPTURE=1 re-enables the + // scan for translation authoring. std::atomic g_calls{0}; std::atomic g_hits{0}; std::string g_lang = "en"; @@ -914,6 +919,7 @@ extern "C" void text_xlate_init(const char* project_root, const char* language) g_dir = (fs::path(project_root) / "translations").string(); const char* capenv = std::getenv("PSX_XLATE_CAPTURE"); if (capenv && capenv[0] == '0') g_capture_on.store(false); + else if (capenv && capenv[0] == '1') g_capture_on.store(true); std::lock_guard lk(g_mtx); load_tables_locked(); } From af9202ff4e9caf44a1242dadaf620ca8e2ce844e Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Wed, 19 Aug 2026 16:46:49 +0300 Subject: [PATCH 4/6] runtime: seed launcher initial disc from CLI --disc override Passing --disc suppressed the remembered settings/disc.cfg disc for the launcher's initial state (the has_disc_path gate) without seeding the launcher itself, so the window opened with "No disc selected" and the player had to re-pick the disc on every launch even though a valid disc was named on the command line. Seed resolved_disc from the validated CLI path before the launcher window opens. resolve_disc_for_runtime still applies the override authoritatively for the runtime after the launcher returns, so precedence is unchanged. --- runtime/src/main.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 97c3ab5f..95df89e0 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -11532,6 +11532,19 @@ int main(int argc, char** argv) { std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); /* Same keybinds.ini / config.ini the runtime reads — never cwd. */ ae_rui_set_sidecar_paths(argv[0]); + /* A CLI --disc must seed the launcher's initial disc. Without this, + * the override suppresses the remembered settings/disc.cfg pick (the + * has_disc_path gate above) while contributing nothing itself, so the + * launcher opens with "No disc selected" and forces a manual pick on + * every launch. resolve_disc_for_runtime still applies the override + * authoritatively after the launcher returns. */ + if (disc_override_path && disc_override_path[0]) { + std::filesystem::path cli_disc = normalize_disc_path_for_launch( + std::filesystem::path(disc_override_path)); + std::error_code cli_ec; + if (std::filesystem::exists(cli_disc, cli_ec)) + resolved_disc = cli_disc; + } std::string rui_initial_disc = resolved_disc.string(); std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) + " - Launcher"; From 4a30c5cc0184dfea39d199f9c41c4358ebda1d4e Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Wed, 19 Aug 2026 16:47:15 +0300 Subject: [PATCH 5/6] runtime: add PSX_OVERLAY_REGION_FLOOR override for streamed-over text ranges Some titles stream level code over pages inside their own static text range (Driver 2 streams mission code over its boot text), leaving those pages permanently diverged: below the overlay floor they are excluded from overlay capture and fall into single-instruction dispatch thrash (hundreds of thousands of one-instruction dispatches per window). PSX_OVERLAY_REGION_FLOOR lowers the floor per title so diverged text pages become overlay-cache candidates and chain through local flow. Value is clamped above the kernel window; default behavior is unchanged when the variable is unset. On Driver 2 this took text-blocked interpreter work from 278M to 37M instructions per window. --- runtime/src/main.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 97c3ab5f..d8348e11 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10897,6 +10897,20 @@ int main(int argc, char** argv) { uint32_t text_end = (gc.load_address + gc.text_size) & 0x1FFFFFFFu; if (text_end > 0x00010000u /* DIRTY_RAM_KERNEL_WINDOW_END */) g_overlay_region_floor = text_end; + /* PSX_OVERLAY_REGION_FLOOR: per-title override for games whose TEXT + * range is itself partially overwritten by streamed level data + * (Driver 2 streams mission code over pages inside its static text + * range). Lowering the floor routes those regions through local-flow + * chaining and makes them overlay-cache candidates, so live-byte + * closures can own them instead of single-instruction dispatch + * thrash. Clamped to stay above the kernel window. */ + { + const char* fenv = std::getenv("PSX_OVERLAY_REGION_FLOOR"); + if (fenv && fenv[0]) { + uint32_t v = (uint32_t)strtoul(fenv, nullptr, 0) & 0x1FFFFFFFu; + if (v >= 0x00010000u) g_overlay_region_floor = v; + } + } std::fprintf(stdout, "psxrecomp: overlay_region_floor = 0x%05X (game text end)\n", g_overlay_region_floor); From 20b9ba6cc84168ea85a6d2a144f7efd0449e0ef4 Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Wed, 19 Aug 2026 17:36:45 -0300 Subject: [PATCH 6/6] runtime: swallow the overlay-closing press so it doesn't bleed into the game Closing the save-state menu (Square save / Circle back / toggle key) or the rewind filmstrip resumes the guest while the closing press is still held on the pad, so the game samples it as gameplay input - saving a state in a fighter throws a punch, and since the queued save executes after the guest resumes, that punch gets baked into the snapshot. Loads already solve exactly this with savestate_input_guard_arm(), which feeds neutral pads until the held input is released (min 90 ms, capped at 700 ms). Arm the same guard whenever either host pause loop hands control back to the guest, covering every close path: save, cancel/back, Esc, the toggle hotkey, and rewind accept/cancel. --- runtime/src/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 97c3ab5f..1354ea91 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -5811,6 +5811,8 @@ static void rewind_host_pause_loop(void) { rewind_pause_present(); SDL_Delay(8); } + /* Swallow the still-held close press so it doesn't bleed into the game. */ + savestate_input_guard_arm(); } /* Freeze guest in vblank present while the save-state slot menu is open. */ @@ -5852,6 +5854,8 @@ static void savestate_menu_host_pause_loop(void) { rewind_pause_present(); SDL_Delay(8); } + /* Swallow the close press; a just-queued save must not snapshot it. */ + savestate_input_guard_arm(); } /* Epilogue for netplay admit/pace AFTER all C++ RAII in the present body