Merge origin/master into feat/rbengine

Conflict resolutions:
- recompiler/src/main_psx.cpp: keep both — canonicalize via
  psx_ram_canon_code_addr (rbengine 8MB mirror fold), then compare
  29-bit physical addresses (master KUSEG dispatch fix, PR #155).
- runtime/src/main.cpp: keep both — text-base pin for high-loading
  EXEs (rbengine) plus PSX_OVERLAY_REGION_FLOOR env override (master).
- runtime/src/text_xlate.cpp: keep rbengine's build-flavour capture
  default (PSX_NO_DEBUG_TOOLS) which subsumes master's default-off fix.

Verified: psxrecomp-game rebuilds; test_kuseg_dispatch_lookup.py passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdzpVVH912V9ktt4n4kntj
This commit is contained in:
Alex Vanderveen
2026-08-20 14:39:42 -04:00
5 changed files with 178 additions and 5 deletions
+1 -1
View File
@@ -6,4 +6,4 @@
[submodule "lib/retcomm-rbengine"]
path = lib/retcomm-rbengine
url = https://github.com/TechnicallyComputers/retcomm-rbengine.git
branch = main
branch = master
+6
View File
@@ -455,6 +455,12 @@ if(BUILD_TESTING)
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fmv_cycle_batch_codegen.py
--recompiler $<TARGET_FILE:psxrecomp-game>
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 $<TARGET_FILE:psxrecomp-game>
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
add_test(
NAME overlay_shim_compile_contract
COMMAND ${Python3_EXECUTABLE}
+17 -4
View File
@@ -1507,9 +1507,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
@@ -1591,15 +1595,24 @@ 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 << " extern uint32_t psx_ram_canon_code_addr(uint32_t);\n";
ds << " addr = psx_ram_canon_code_addr(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";
@@ -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 <psxrecomp-game>]
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("<I", x) for x in words)
def make_psxexe(entry, load, 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))
struct.pack_into("<I", h, 0x30, 0x801FFFF0) # stack_base, as real headers carry
return bytes(h) + data
def jal(target):
return 0x0C000000 | ((target >> 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()
+31
View File
@@ -6303,6 +6303,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. */
@@ -6344,6 +6346,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
@@ -13264,6 +13268,20 @@ int main(int argc, char** argv) {
uint32_t text_lo = gc.load_address & 0x1FFFFFFFu;
if (text_lo > 0x00010000u && text_lo < g_overlay_region_floor)
g_text_image_lo = text_lo;
/* 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), "
"text_image_lo = 0x%05X\n",
@@ -13953,6 +13971,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";