From e002506c5d20384ba7b99b834328442340a985a9 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:06:40 +0300 Subject: [PATCH 01/10] runtime: check setup host before validating a chosen BIOS resolve_bios_for_runtime() validated an explicit --bios / bios.cfg pick before it noticed it was running on a first-run setup host, where zero BIOS backends are linked. With an empty registry bios_backend_for_file() matches nothing, so every image was rejected -- including the correct one -- and a title with openbios = false had no fallback and exited. The player could never supply the BIOS that Generate needs, so first-run setup deadlocked. 109 of 156 ports set openbios = false. The launcher-side check already had this guard in the right place; the Play path did not. Pure move, guard body unchanged. --- runtime/src/main.cpp | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index d465671f..508b03d1 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2510,11 +2510,32 @@ static std::filesystem::path resolve_bios_for_runtime(const char* requested, const bool bundled_only = openbios_allowed && bundled && !player_bios_selectable; + /* 0. Setup host (CI zip root): no BIOS backends are linked yet, so there + * is nothing an image could be validated against — bios_backend_for_file() + * rejects every file, including the correct one, and a title with + * openbios = false has no fallback to land on. This MUST precede the + * explicit-choice branch below: a remembered bios.cfg pick reached + * validate_bios_for_launch() first and deadlocked first-run setup — the + * player was told their good SCPH-1001 was "not an image this build was + * compiled from", and could never supply the BIOS that Generate needs in + * order to emit the backend. Play belongs to the product binary under + * build-release/ after Generate & rebuild. */ + if (psx_bios_registry_count == 0) { + launcher_warning("Setup host — finish Generate & rebuild", + "This executable is the first-run setup host (no game/BIOS code " + "linked).\n\n" + "Use Generate & rebuild in the launcher. After that succeeds, open " + "this same shortcut again — it starts the game from build-release/ " + "(where bios/, mods/, and settings live).\n\n" + "Or run build-release/.exe directly."); + return {}; + } + /* 1. An explicit choice: --bios, else a remembered pick. A product build * with only its bundled backend has no meaningful player choice: ignore * stale settings/bios.cfg paths instead of validating an image the hidden - * launcher row cannot clear. Setup hosts (registry_count == 0) retain their - * picker/generation flow. */ + * launcher row cannot clear. Setup hosts (registry_count == 0) already + * returned above. */ std::filesystem::path chosen; if (!bundled_only) { if (requested_is_explicit && requested && requested[0]) { @@ -2547,19 +2568,6 @@ static std::filesystem::path resolve_bios_for_runtime(const char* requested, return {}; } - /* Setup host (CI zip root): no BIOS backends linked yet. Play belongs to - * the product binary under build-release/ after Generate & rebuild. */ - if (psx_bios_registry_count == 0) { - launcher_warning("Setup host — finish Generate & rebuild", - "This executable is the first-run setup host (no game/BIOS code " - "linked).\n\n" - "Use Generate & rebuild in the launcher. After that succeeds, open " - "this same shortcut again — it starts the game from build-release/ " - "(where bios/, mods/, and settings live).\n\n" - "Or run build-release/.exe directly."); - return {}; - } - /* 3. This title requires a retail BIOS: ask for one. */ const std::string accepted = bios_accepted_images(); launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(), From d5090a5bfe09b99f2df3b6a26681c5181653a88a Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:06:40 +0300 Subject: [PATCH 02/10] host: detect any recompiled BIOS stem, not just OpenBIOS/SCPH1001 bios_backends_missing() probed two hardcoded filenames, psxrecomp/generated/OpenBIOS_dispatch.c and SCPH1001_dispatch.c. A port that pins a different image via PSXRECOMP_BIOS_STEMS / game.toml recompiler.bios_config emits its backend under that stem instead, so the probe could never be satisfied: every Generate succeeded, the wizard reopened, and first-run setup looped forever. Every wave-3 kit pins SCPH5552, so this affected all of them. Accept any stem that has both _dispatch.c and _full.c, the same pairing runtime.cmake requires before it will link a backend. --- host/psxrecomp_codegen_host.c | 73 +++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index 9a907f69..58940182 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -1140,15 +1140,74 @@ static int resolve_build_paths(void) { return join_path(g_exe_path, sizeof(g_exe_path), g_build_dir, exe_name); } +/* Pair a _dispatch.c with its _full.c, the same pairing + * runtime.cmake requires before it will link a BIOS backend. */ +static int dispatch_has_full(const char* dir, const char* name) { + static const char suffix[] = "_dispatch.c"; + char stem[512], full[600], path[1200]; + size_t len = strlen(name); + size_t slen = sizeof(suffix) - 1; + if (len <= slen || strcmp(name + len - slen, suffix) != 0) + return 0; + if (len - slen >= sizeof(stem)) + return 0; + memcpy(stem, name, len - slen); + stem[len - slen] = '\0'; + if ((size_t)snprintf(full, sizeof(full), "%s_full.c", stem) >= sizeof(full)) + return 0; + if (!join_path(path, sizeof(path), dir, full)) + return 0; + return path_is_file(path); +} + +/* Does psxrecomp/generated/ hold ANY recompiled BIOS backend? + * + * This used to probe two hardcoded names, OpenBIOS_dispatch.c and + * SCPH1001_dispatch.c. A port that pins a different image — via + * PSXRECOMP_BIOS_STEMS / game.toml recompiler.bios_config, as every wave-3 + * kit does with SCPH5552 — emits its backend under that other stem, so the + * probe was permanently unsatisfied: Generate kept succeeding, the wizard + * kept reopening, and first-run setup could never complete. Accept any stem + * that has both halves instead of naming images here. */ +static int generated_has_bios_backend(const char* dir) { +#if defined(_WIN32) + char pat[1200]; + WIN32_FIND_DATAA fd; + HANDLE h; + int found = 0; + if (!join_path(pat, sizeof(pat), dir, "*_dispatch.c")) + return 0; + h = FindFirstFileA(pat, &fd); + if (h == INVALID_HANDLE_VALUE) + return 0; + do { + if (dispatch_has_full(dir, fd.cFileName)) { + found = 1; + break; + } + } while (FindNextFileA(h, &fd)); + FindClose(h); + return found; +#else + DIR* d = opendir(dir); + struct dirent* e; + int found = 0; + if (!d) + return 0; + while (!found && (e = readdir(d)) != NULL) { + if (dispatch_has_full(dir, e->d_name)) + found = 1; + } + closedir(d); + return found; +#endif +} + static int bios_backends_missing(void) { - char openbios[1100], scph[1100]; - if (!join_path(openbios, sizeof(openbios), g_project_root, - "psxrecomp/generated/OpenBIOS_dispatch.c")) - return 1; - if (!join_path(scph, sizeof(scph), g_project_root, - "psxrecomp/generated/SCPH1001_dispatch.c")) + char gen[1100]; + if (!join_path(gen, sizeof(gen), g_project_root, "psxrecomp/generated")) return 1; - return !(path_is_file(openbios) || path_is_file(scph)); + return !generated_has_bios_backend(gen); } int psxrecomp_codegen_host_sources_missing( From e6cec93e84122c48a380172546c8f8870443b866 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:06:55 +0300 Subject: [PATCH 03/10] host: give the first-run loop breaker a cause-specific explanation host_loop_breaker_note() appended "the project's boot-EXE names disagree" whichever branch fired. That conclusion only holds when the game dispatch is missing; for missing BIOS backends it is wrong, and it sent two players (and the maintainer) after a naming problem that did not exist. Pick the explanation per branch, and do not name a specific BIOS image in it -- ports pin different ones, so point at the README instead. --- host/psxrecomp_codegen_host.c | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index 58940182..443befcb 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -3788,6 +3788,7 @@ static const char* host_loop_breaker_note(void) { char sidecar[1200], line[64], found[512], marker_abs[1200]; long long then, now; const char* marker_rel; + const char* cause; int game_missing, bios_missing; if (!join_path(sidecar, sizeof(sidecar), g_project_root, HOST_LAST_GENERATE_SIDECAR)) @@ -3808,16 +3809,30 @@ static const char* host_loop_breaker_note(void) { if (!game_missing && !bios_missing) return NULL; list_generated_dispatch(found, sizeof(found)); + /* Each branch has its own cause, so do not assert a single one. A missing + * game dispatch really does point at disagreeing boot-EXE names. Missing + * BIOS backends do not: they mean Generate never emitted them, normally + * because no retail BIOS was available to emit them from. Blaming + * boot-EXE names for that sent players after the wrong thing. */ + if (game_missing) + cause = "please report this to the port maintainer: the project's " + "boot-EXE names disagree"; + else + cause = "Generate produced the game code but no BIOS backend, which " + "normally means it had no retail BIOS to work from. Select " + "the PlayStation BIOS dump this port requires (named in the " + "README; it must be exactly 512 KB) in the launcher, then " + "run Generate again"; snprintf(g_loop_breaker_note, sizeof(g_loop_breaker_note), "A Generate completed here recently, yet the launcher still " "cannot find %s%s%s. generated/ contains: %s. Running Generate " - "again will very likely loop — please report this to the port " - "maintainer: the project's boot-EXE names disagree.", + "again will very likely loop — %s.", game_missing ? marker_rel : "", (game_missing && bios_missing) ? " and " : "", bios_missing ? "the BIOS backends under psxrecomp/generated/" : "", - found[0] ? found : "no *_dispatch.c at all"); + found[0] ? found : "no *_dispatch.c at all", + cause); fprintf(stderr, "psxrecomp-codegen: %s\n", g_loop_breaker_note); return g_loop_breaker_note; } From a1d458d230bd77441d403540eb57fe50578ae411 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:06:55 +0300 Subject: [PATCH 04/10] runtime: point the recomp-ui preflight at submodule init The missing-recomp-ui error told the reader to run "git submodule add -b master https://github.com/mstan/recomp-ui.git". For anyone building a released kit that already declares recomp-ui and pins a fork commit, that either fails outright or wires up the wrong upstream at the wrong revision. Lead with clone --recurse-submodules / submodule update --init, name the GitHub source-ZIP trap that produces this state, and keep submodule add only for a repo that does not declare recomp-ui yet. --- runtime/runtime.cmake | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 129ab695..0967fa8e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -1513,11 +1513,20 @@ function(psxrecomp_add_runtime_target target) if(PSX_RECOMP_UI AND NOT PSXRT_ORACLE) if(NOT RECOMP_UI_ROOT OR NOT EXISTS "${RECOMP_UI_ROOT}/recomp_ui.cmake") message(FATAL_ERROR - "PSX_RECOMP_UI=ON but recomp-ui is missing.\n" - "Add at the game repo root:\n" - " git submodule add -b master " - "https://github.com/mstan/recomp-ui.git recomp-ui\n" - "Or set -DRECOMP_UI_ROOT=/path/to/recomp-ui") + "PSX_RECOMP_UI=ON but recomp-ui is missing from the game " + "repo root.\n" + "A source ZIP downloaded from GitHub never contains " + "submodule contents and cannot build. Clone instead:\n" + " git clone --recurse-submodules \n" + "In a clone that already declares recomp-ui, fetch the " + "pinned commit:\n" + " git submodule update --init --recursive\n" + "Only if this repo does not declare recomp-ui yet, add it " + "(use the fork this project pins, not necessarily " + "upstream):\n" + " git submodule add recomp-ui\n" + "Or point at an existing checkout: " + "-DRECOMP_UI_ROOT=/path/to/recomp-ui") endif() # recomp-ui gates its Mods view behind RECOMP_UI_ENABLE_MODS, which # defaults OFF there -- correct for a cross-console launcher, since a From e0d146d8824c37fc3a2789acdd229e498fbc5c6e Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:19:35 +0300 Subject: [PATCH 05/10] tests: cover the codegen host's BIOS stem detection test_cli_retail_bios_profile.py already covers recompiler.bios_config on the psxrecomp_cli.py side. The C host half of the same feature had no coverage, which is why bios_backends_missing() kept probing hardcoded OpenBIOS/SCPH1001 filenames and shipped a first-run loop on all 26 wave-3 kits. Compiles host/psxrecomp_codegen_host.c against a temporary project tree and asserts sources_missing() over four cases: a pinned non-SCPH1001 stem, the bundled stem, a dispatch with no _full.c, and no backend at all. Verified to fail on the pre-fix host and pass after. Skips cleanly when recomp-ui or a C compiler is unavailable. --- runtime/tests/test_codegen_host_bios_stems.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 runtime/tests/test_codegen_host_bios_stems.py diff --git a/runtime/tests/test_codegen_host_bios_stems.py b/runtime/tests/test_codegen_host_bios_stems.py new file mode 100644 index 00000000..20094901 --- /dev/null +++ b/runtime/tests/test_codegen_host_bios_stems.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Regression: the codegen host must accept ANY recompiled BIOS stem. + +bios_backends_missing() in host/psxrecomp_codegen_host.c used to probe two +hardcoded filenames, psxrecomp/generated/OpenBIOS_dispatch.c and +SCPH1001_dispatch.c. Ports that pin another image via recompiler.bios_config / +PSXRECOMP_BIOS_STEMS emit _dispatch.c + _full.c instead, so the +probe could never be satisfied: Generate succeeded, the setup wizard reopened, +and first-run setup looped forever. Every wave-3 kit pins SCPH5552 and every +one of them shipped with that loop. + +test_cli_retail_bios_profile.py already covers the psxrecomp_cli.py half of +this feature. This is the C host half, which had no coverage. +""" + +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +HOST_C = ROOT / "host" / "psxrecomp_codegen_host.c" + +PROBE = """ +#include +#include +#include "psxrecomp_codegen_host.h" +/* recomp-ui symbol the host references but sources_missing() never reaches. */ +int recomp_launcher_relaunch_exe(char* o, size_t c) { (void)o; (void)c; return 0; } +int main(void) { + PsxrecompCodegenHostConfig cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.cmake_target = "psx-runtime"; + cfg.exe_basename = "Probe"; + cfg.gen_marker_relpath = "generated/SCUS_943.51_dispatch.c"; + printf("%d", psxrecomp_codegen_host_sources_missing(&cfg)); + return 0; +} +""" + + +def find_recomp_ui() -> Path | None: + env = os.environ.get("RECOMP_UI_ROOT") + candidates = ([Path(env)] if env else []) + [ + ROOT.parent / "recomp-ui", + ROOT / "recomp-ui", + ] + for c in candidates: + if (c / "src" / "recomp_launcher.h").is_file(): + return c + return None + + +def make_project(root: Path, bios_files: list[str]) -> None: + """A project tree the host recognises, with the given BIOS artefacts.""" + (root / "generated").mkdir(parents=True, exist_ok=True) + (root / "generated" / "SCUS_943.51_dispatch.c").write_text("", encoding="utf-8") + fw_gen = root / "psxrecomp" / "generated" + fw_gen.mkdir(parents=True, exist_ok=True) + (root / "game.toml").write_text("[game]\n", encoding="utf-8") + (root / "psxrecomp" / "psxrecomp_cli.py").write_text("", encoding="utf-8") + for name in bios_files: + (fw_gen / name).write_text("", encoding="utf-8") + + +def main() -> int: + ui = find_recomp_ui() + if ui is None: + print("SKIP: recomp-ui not found (set RECOMP_UI_ROOT)") + return 0 + cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang") + if cc is None: + print("SKIP: no C compiler on PATH") + return 0 + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + probe_c = tmp / "probe.c" + probe_c.write_text(PROBE, encoding="utf-8") + exe = tmp / ("probe.exe" if os.name == "nt" else "probe") + build = subprocess.run( + [cc, "-std=c11", "-o", str(exe), str(probe_c), str(HOST_C), + "-I", str(ROOT / "host"), + "-I", str(ui / "src"), "-I", str(ui / "src" / "common")], + capture_output=True, text=True) + if build.returncode != 0: + print("FAIL: could not build probe\n" + build.stderr[-2000:]) + return 1 + + cases = [ + # (BIOS artefacts present, expected sources_missing, why) + (["SCPH5552_dispatch.c", "SCPH5552_full.c"], 0, + "a pinned non-SCPH1001 stem must satisfy the host"), + (["OpenBIOS_dispatch.c", "OpenBIOS_full.c"], 0, + "the bundled stem must still satisfy the host"), + (["SCPH5552_dispatch.c"], 1, + "a dispatch with no _full.c is not a linkable backend"), + ([], 1, + "no BIOS backend at all means setup is genuinely incomplete"), + ] + failures = 0 + for artefacts, expected, why in cases: + project = tmp / ("case%d" % len(artefacts + [why])) + shutil.rmtree(project, ignore_errors=True) + make_project(project, artefacts) + env = dict(os.environ, PSXRECOMP_PROJECT_ROOT=str(project)) + run = subprocess.run([str(exe)], capture_output=True, text=True, env=env) + got = run.stdout.strip() + if got != str(expected): + print("FAIL: %s\n artefacts=%s expected=%s got=%r" + % (why, artefacts or "(none)", expected, got)) + failures += 1 + else: + print("ok: %s" % why) + if failures: + return 1 + + print("PASS: codegen host accepts any paired BIOS stem") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 8ed8d9c3f6a2c7a08c1fe46e29a7eba0d6300e2b Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:33:04 +0300 Subject: [PATCH 06/10] runtime: derive the expected retail BIOS from the pinned stem A built host asks its linked backends which image it accepts. A setup host has none -- that is what makes it a setup host -- so first-run discovery, bios.cfg seeding and every player-facing string hardcoded SCPH-1001. On a kit pinning anything else (all 26 wave-3 kits pin SCPH5552) that meant: a correct dump sitting beside the install was never auto-discovered, seeding refused it, and the launcher told the player their BIOS was wrong while quoting SCPH-1001's CRC at them. Add psx_bios_known_images.h as the one place those identities live, and have runtime.cmake pass the pinned stem in as PSX_EXPECTED_BIOS_STEM. Both SCPH-1001 (0x37157331) and SCPH-5552 (0xD786F0B9) are verified against the dumps and against the identity the recompiler emits into generated/_dispatch.c. Replaces the hardcodes in: - retail_bios_file_ok_c / discover_retail_bios_c (setup host) - retail_bios_file_ok / discover_retail_bios_near (runtime seeding) - the launcher BIOS row: size, CRC and "required" copy - the BIOS picker copy - PSXRT_DEFAULT_BIOS_PATH, which defaulted to bios/SCPH1001.BIN A stem absent from the table degrades safely: nothing is auto-adopted, the player is asked, and no message claims an identity it cannot back up. SCPH-1001 builds are unaffected. --- host/psxrecomp_codegen_host.c | 28 +++-- runtime/include/psx_bios_known_images.h | 106 ++++++++++++++++++ runtime/runtime.cmake | 13 ++- runtime/src/main.cpp | 64 +++++++---- runtime/tests/test_codegen_host_bios_stems.py | 1 + 5 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 runtime/include/psx_bios_known_images.h diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index 443befcb..71f6e268 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -2,6 +2,8 @@ #include "psxrecomp_codegen_host.h" +#include "psx_bios_known_images.h" + #include #include #include @@ -1273,7 +1275,7 @@ static void write_sidecar_near_exe(const char* near_exe, const char* name, write_line_file(path, value ? value : ""); } -/* IEEE CRC-32 (zlib / Ethernet) — SCPH-1001 identity for setup discovery. */ +/* IEEE CRC-32 (zlib / Ethernet) — retail BIOS identity for setup discovery. */ static uint32_t host_crc32(const unsigned char* data, size_t len) { uint32_t crc = 0xFFFFFFFFu; size_t i, j; @@ -1285,17 +1287,23 @@ static uint32_t host_crc32(const unsigned char* data, size_t len) { return ~crc; } +/* Does this file match the retail image THIS build pins? A setup host has no + * linked backend to ask, so it consults psx_bios_known_images.h rather than + * assuming SCPH-1001 — which made every non-SCPH1001 kit reject a perfectly + * good dump. An unknown pinned stem adopts nothing and the player is asked. */ static int retail_bios_file_ok_c(const char* path) { + const PsxKnownBiosImage* want = psx_expected_bios(); FILE* f; long size; unsigned char* buf; uint32_t crc; + if (!want) return 0; if (!path || !path[0] || !path_is_file(path)) return 0; f = fopen(path, "rb"); if (!f) return 0; if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; } size = ftell(f); - if (size != 512 * 1024) { fclose(f); return 0; } + if (size != (long)want->size) { fclose(f); return 0; } if (fseek(f, 0, SEEK_SET) != 0) { fclose(f); return 0; } buf = (unsigned char*)malloc((size_t)size); if (!buf) { fclose(f); return 0; } @@ -1307,22 +1315,22 @@ static int retail_bios_file_ok_c(const char* path) { fclose(f); crc = host_crc32(buf, (size_t)size); free(buf); - return crc == 0x37157331u; /* SCPH-1001 */ + return crc == want->crc32; } -/* Prefer a player-supplied SCPH1001 next to the project/exe for Generate. - * Missing → leave empty (OpenBIOS). Does not override an explicit OpenBIOS. */ +/* Prefer a player-supplied dump of the pinned retail image next to the + * project/exe for Generate. Missing → leave empty (OpenBIOS). Does not + * override an explicit OpenBIOS. */ static int discover_retail_bios_c(char* out, size_t cap) { - static const char* names[] = { - "SCPH1001.BIN", "scph1001.bin", "SCPH-1001.BIN", "scph-1001.bin", - "SCPH1001.bin", "scph1001.BIN", - }; + char names[8][32]; + int nnames = psx_known_bios_filenames(psx_expected_bios(), names, 8); static const char* subs[] = { "bios", "", "system", "firmware", "psxrecomp/bios", "psxrecomp-v4/bios", }; char roots[3][1100]; int nroots = 0; int r, s, n; + if (nnames <= 0) { out[0] = 0; return 0; } if (g_project_root[0]) { snprintf(roots[nroots], sizeof(roots[0]), "%s", g_project_root); ++nroots; @@ -1346,7 +1354,7 @@ static int discover_retail_bios_c(char* out, size_t cap) { } else { snprintf(dir, sizeof(dir), "%s", walk); } - for (n = 0; n < (int)(sizeof(names) / sizeof(names[0])); ++n) { + for (n = 0; n < nnames; ++n) { char cand[1300]; if (!join_path(cand, sizeof(cand), dir, names[n])) continue; if (!retail_bios_file_ok_c(cand)) continue; diff --git a/runtime/include/psx_bios_known_images.h b/runtime/include/psx_bios_known_images.h new file mode 100644 index 00000000..39664eef --- /dev/null +++ b/runtime/include/psx_bios_known_images.h @@ -0,0 +1,106 @@ +/* psx_bios_known_images.h — identities of the retail BIOS images this + * framework ships a build profile for. + * + * A *built* host asks its linked backends (psx_bios_registry) which image it + * accepts. A *setup* host has no linked backend — that is what makes it a + * setup host — so first-run discovery, bios.cfg seeding and picker copy had + * nowhere to look and hardcoded SCPH-1001. Any kit pinning another image via + * PSXRECOMP_BIOS_STEM / recompiler.bios_config (every wave-3 kit pins + * SCPH5552) then refused to auto-discover a perfectly good dump and told the + * player their BIOS was the wrong one. + * + * Keep in sync with bios/.toml. A stem absent from this table still + * builds and runs; it only loses first-run auto-discovery and falls back to + * asking the player, which is safe. + */ +#ifndef PSX_BIOS_KNOWN_IMAGES_H +#define PSX_BIOS_KNOWN_IMAGES_H + +#include +#include + +/* Set by runtime.cmake from PSXRECOMP_BIOS_STEM. */ +#ifndef PSX_EXPECTED_BIOS_STEM +#define PSX_EXPECTED_BIOS_STEM "SCPH1001" +#endif + +typedef struct PsxKnownBiosImage { + const char* stem; /* generated/_dispatch.c, bios/.toml */ + const char* id; /* profile id, e.g. "SCPH-5552" */ + uint32_t crc32; /* IEEE CRC-32 (zlib/Ethernet) over the whole image */ + uint32_t size; /* bytes */ +} PsxKnownBiosImage; + +/* Each entry verified two ways: against the dump itself, and against the + * identity the recompiler emits into generated/_dispatch.c. */ +static const PsxKnownBiosImage psx_known_bios_images[] = { + { "SCPH1001", "SCPH-1001", 0x37157331u, 524288u }, + { "SCPH5552", "SCPH-5552", 0xD786F0B9u, 524288u }, +}; + +static inline const PsxKnownBiosImage* psx_known_bios_by_stem(const char* stem) { + size_t i; + if (!stem || !stem[0]) + return 0; + for (i = 0; i < sizeof(psx_known_bios_images) / sizeof(psx_known_bios_images[0]); ++i) { + if (strcmp(psx_known_bios_images[i].stem, stem) == 0) + return &psx_known_bios_images[i]; + } + return 0; +} + +/* The retail image THIS build was configured for, or NULL when the pinned + * stem is not in the table. Callers must handle NULL by asking the player + * rather than assuming SCPH-1001. */ +static inline const PsxKnownBiosImage* psx_expected_bios(void) { + return psx_known_bios_by_stem(PSX_EXPECTED_BIOS_STEM); +} + +/* A short label for the pinned image, for player-facing copy: "SCPH5552.BIN". + * Falls back to the raw stem when the identity is not in the table. */ +static inline const char* psx_expected_bios_label(void) { + static char label[40]; + if (!label[0]) { + const PsxKnownBiosImage* e = psx_expected_bios(); + const char* stem = e ? e->stem : PSX_EXPECTED_BIOS_STEM; + size_t n = strlen(stem); + if (n > sizeof(label) - 5) + n = sizeof(label) - 5; + memcpy(label, stem, n); + memcpy(label + n, ".BIN", 5); + } + return label; +} + +/* Filename spellings worth probing for during first-run discovery, + * written into out[0..n). Covers the stem and the dashed profile id, each in + * upper and lower case: SCPH5552.BIN, scph5552.bin, SCPH-5552.BIN, ... */ +static inline int psx_known_bios_filenames(const PsxKnownBiosImage* img, + char out[][32], int cap) { + const char* bases[2]; + int n = 0, b, lower; + if (!img) + return 0; + bases[0] = img->stem; + bases[1] = img->id; + for (b = 0; b < 2; ++b) { + for (lower = 0; lower < 2; ++lower) { + const char* ext = lower ? "bin" : "BIN"; + size_t i, len = strlen(bases[b]); + char* dst; + if (n >= cap || len + 5 > 32) + continue; + dst = out[n++]; + for (i = 0; i < len; ++i) { + char c = bases[b][i]; + dst[i] = (char)(lower && c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c); + } + dst[len] = '.'; + memcpy(dst + len + 1, ext, 3); + dst[len + 4] = 0; + } + } + return n; +} + +#endif /* PSX_BIOS_KNOWN_IMAGES_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 0967fa8e..6f3a2b07 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -895,15 +895,18 @@ function(psxrecomp_add_runtime_target target) # where releases are validated. Dev checkouts still resolve the relative # default without prompting via the exe-dir upward search, which also tries # /psxrecomp-v4/ for game-project layouts. + # Follow the stem this build actually pins; assuming SCPH1001 here handed + # every non-SCPH1001 kit a default path that could never resolve. + set(_psxrt_stem_bios "bios/${PSXRECOMP_BIOS_STEM}.BIN") if(NOT PSXRT_DEFAULT_BIOS_PATH) - set(PSXRT_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN") + set(PSXRT_DEFAULT_BIOS_PATH "${_psxrt_stem_bios}") elseif(IS_ABSOLUTE "${PSXRT_DEFAULT_BIOS_PATH}") message(WARNING "DEFAULT_BIOS_PATH '${PSXRT_DEFAULT_BIOS_PATH}' is absolute; refusing to " "bake a build-machine path into the binary (release exes must prompt on " - "user machines). Using relative 'bios/SCPH1001.BIN' instead — drop the " + "user machines). Using relative '${_psxrt_stem_bios}' instead — drop the " "DEFAULT_BIOS_PATH argument from this game's CMakeLists.") - set(PSXRT_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN") + set(PSXRT_DEFAULT_BIOS_PATH "${_psxrt_stem_bios}") endif() if(NOT DEFINED PSXRT_DEFAULT_GAME_CONFIG_PATH) set(PSXRT_DEFAULT_GAME_CONFIG_PATH "") @@ -1301,6 +1304,10 @@ function(psxrecomp_add_runtime_target target) target_compile_definitions(${target} PRIVATE DEFAULT_DEBUG_PORT=${PSXRT_DEBUG_PORT} PSX_DEFAULT_BIOS_PATH="${PSXRT_DEFAULT_BIOS_PATH}" + # The retail stem this build pins. A setup host has no linked + # backend to ask, so this is how it knows which image to look for + # and name (psx_bios_known_images.h) instead of assuming SCPH-1001. + PSX_EXPECTED_BIOS_STEM="${PSXRECOMP_BIOS_STEM}" # Where the shipped redistributable image lives, relative to the exe. # This is what a player gets when they choose no BIOS. PSX_BUNDLED_BIOS_PATH="${PSXRECOMP_BUNDLED_BIOS_PATH}" diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 508b03d1..cd3a073a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16,6 +16,7 @@ #include "boot_state.h" #include "bios_hle.h" #include "bios_hle_plan.h" +#include "psx_bios_known_images.h" #include "psx_bios_backend.h" #include "psx_cycles.h" #include "starvation_ring.h" @@ -2575,7 +2576,8 @@ static std::filesystem::path resolve_bios_for_runtime(const char* requested, "Step 1 of 2 — PlayStation BIOS\n\n" "In the next window, select your PlayStation BIOS dump. This build " "requires the exact image it was compiled from: " + accepted + ". " - "Usually named SCPH1001.BIN and exactly 512 KB. Dump from your own " + "Usually named " + std::string(psx_expected_bios_label()) + + " and exactly 512 KB. Dump from your own " "console or otherwise legally obtain it.\n\n" "(This is NOT the game disc — that is asked for next.)"); std::string bios_title = @@ -2708,24 +2710,27 @@ static bool retail_bios_file_ok(const std::filesystem::path& path) { const PsxBiosBackend* b = bios_backend_for_file(path, nullptr, nullptr); return b && b->image && !b->image->image_bundled; } - /* Setup host (no backends linked yet): accept validated SCPH-1001 only. */ - constexpr uint64_t kSize = 512u * 1024u; - constexpr uint32_t kScph1001Crc = 0x37157331u; + /* Setup host (no backends linked yet): accept the retail image THIS build + * pins, from psx_bios_known_images.h. This used to hardcode SCPH-1001, so + * a kit pinning anything else refused to seed from a correct dump. An + * unknown pinned stem seeds nothing and the player is asked instead. */ + const PsxKnownBiosImage* want = psx_expected_bios(); + if (!want) return false; std::ifstream f(path, std::ios::binary | std::ios::ate); if (!f.is_open()) return false; const auto size = static_cast(f.tellg()); - if (size != kSize) return false; + if (size != static_cast(want->size)) return false; std::vector data(static_cast(size)); if (!read_at(f, 0, data.data(), data.size())) return false; - return crc32_compute(data.data(), data.size()) == kScph1001Crc; + return crc32_compute(data.data(), data.size()) == want->crc32; } static std::filesystem::path discover_retail_bios_near(const char* argv0) { namespace fs = std::filesystem; - static const char* kNames[] = { - "SCPH1001.BIN", "scph1001.bin", "SCPH-1001.BIN", "scph-1001.bin", - "SCPH1001.bin", "scph1001.BIN", - }; + char name_buf[8][32]; + const int name_count = + psx_known_bios_filenames(psx_expected_bios(), name_buf, 8); + if (name_count <= 0) return {}; static const char* kSubdirs[] = { "bios", "", "system", "firmware", "psxrecomp/bios", "psxrecomp-v4/bios", }; @@ -2734,8 +2739,8 @@ static std::filesystem::path discover_retail_bios_near(const char* argv0) { for (fs::path root = exe_dir; !root.empty(); root = root.parent_path()) { for (const char* sub : kSubdirs) { const fs::path dir = (sub && sub[0]) ? (root / sub) : root; - for (const char* name : kNames) { - const fs::path cand = dir / name; + for (int ni = 0; ni < name_count; ++ni) { + const fs::path cand = dir / name_buf[ni]; if (retail_bios_file_ok(cand)) { auto abs = fs::weakly_canonical(cand, ec); if (ec) abs = fs::absolute(cand, ec); @@ -8191,12 +8196,14 @@ namespace { out->ok = 1; std::snprintf(out->detail, sizeof(out->detail), "OpenBIOS will be emitted on Generate & rebuild " - "(optional: pick SCPH1001). Play uses " - "build-release/ after rebuild."); + "(optional: pick %s). Play uses " + "build-release/ after rebuild.", + psx_expected_bios_label()); return 1; } std::snprintf(out->detail, sizeof(out->detail), - "PlayStation BIOS required (SCPH1001.BIN)."); + "PlayStation BIOS required (%s).", + psx_expected_bios_label()); return 1; } /* Match runtime resolve: relative picks like bios/SCPH1001.BIN must not @@ -8214,12 +8221,16 @@ namespace { "BIOS file not found."); return 1; } + const PsxKnownBiosImage* want = psx_expected_bios(); + const std::streamoff want_size = + want ? (std::streamoff)want->size : (std::streamoff)(512 * 1024); const std::streamoff size = f.tellg(); - if (size != 512 * 1024) { + if (size != want_size) { std::snprintf(out->detail, sizeof(out->detail), - "BIOS must be exactly 512 KiB (got %lld). Use " - "SCPH1001.BIN.", - (long long)size); + "BIOS must be exactly %lld bytes (got %lld). Use " + "%s.", + (long long)want_size, (long long)size, + psx_expected_bios_label()); return 1; } std::vector data((size_t)size); @@ -8229,15 +8240,20 @@ namespace { return 1; } const uint32_t crc = crc32_compute(data.data(), data.size()); - if (crc != 0x37157331u) { + if (want && crc != want->crc32) { + out->warn = 1; + std::snprintf(out->detail, sizeof(out->detail), + "CRC32 %08X (this build expects %s, CRC32 %08X).", + crc, want->id, want->crc32); + } else if (!want) { out->warn = 1; std::snprintf(out->detail, sizeof(out->detail), - "CRC32 %08X (validated dump is SCPH1001 CRC32 " - "37157331).", - crc); + "CRC32 %08X (this build pins %s, whose identity " + "is not recorded here).", + crc, PSX_EXPECTED_BIOS_STEM); } else { std::snprintf(out->detail, sizeof(out->detail), - "SCPH1001.BIN (CRC OK)."); + "%s (CRC OK).", psx_expected_bios_label()); } /* Setup host (no backends yet): file is fine for first Generate. */ if (psx_bios_registry_count == 0) { diff --git a/runtime/tests/test_codegen_host_bios_stems.py b/runtime/tests/test_codegen_host_bios_stems.py index 20094901..5633273b 100644 --- a/runtime/tests/test_codegen_host_bios_stems.py +++ b/runtime/tests/test_codegen_host_bios_stems.py @@ -84,6 +84,7 @@ def main() -> int: build = subprocess.run( [cc, "-std=c11", "-o", str(exe), str(probe_c), str(HOST_C), "-I", str(ROOT / "host"), + "-I", str(ROOT / "runtime" / "include"), "-I", str(ui / "src"), "-I", str(ui / "src" / "common")], capture_output=True, text=True) if build.returncode != 0: From 8594fb2ef515c65fed5a06e2f9c26930a762e33d Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:42:06 +0300 Subject: [PATCH 07/10] tests: register test_codegen_host_bios_stems so it actually runs Added the file without an add_test() entry, which is exactly the failure mode runtime/check_test_registration.cmake exists to catch -- and it did, at configure time: Test file(s) present on disk but registered nowhere: runtime/tests/test_codegen_host_bios_stems.py An unregistered test cannot run and cannot fail. Register it alongside the other runtime source-invariant guards; it needs neither a generated BIOS nor a built runtime, and skips cleanly without recomp-ui or a C compiler. --- recompiler/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 1388ff65..676ac88b 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -627,6 +627,7 @@ if(BUILD_TESTING) bios_selection_guards capture_history cli_retail_bios_profile + codegen_host_bios_stems default_renderer_guards mod_owned_display_controls fmv_quiet_guards From a3df10f6881e55221583107566e04ac3234dee2c Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:48:50 +0300 Subject: [PATCH 08/10] cli: decode subprocess output as UTF-8, not the ANSI codepage subprocess.run(..., text=True) decodes child output with the machine's preferred encoding. On a Windows install whose ANSI codepage is not UTF-8 the emitter's output kills the reader thread: File "encodings/cp1253.py", line 23, in decode UnicodeDecodeError: 'charmap' codec can't decode byte 0x9c Hit on a Greek-locale machine during a real generate. The generate still completed, but the captured stdout/stderr for that step is lost, so any diagnostic the CLI meant to surface silently vanishes -- and the callers that parse proc.stdout see nothing. Pin all nine output-capturing calls to utf-8 with errors="replace". --- psxrecomp_cli.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/psxrecomp_cli.py b/psxrecomp_cli.py index ccb97c5e..2afb7b57 100755 --- a/psxrecomp_cli.py +++ b/psxrecomp_cli.py @@ -711,7 +711,7 @@ def _build_recompiler_targets( progress.log(" ".join(cmake_args)) proc = subprocess.run( - cmake_args, cwd=str(project_root), capture_output=True, text=True + cmake_args, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace" ) for stream in (proc.stdout, proc.stderr): if stream: @@ -728,7 +728,7 @@ def _build_recompiler_targets( for target in targets: build_cmd += ["--target", target] progress.log(" ".join(build_cmd)) - proc = subprocess.run(build_cmd, capture_output=True, text=True) + proc = subprocess.run(build_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") for stream in (proc.stdout, proc.stderr): if stream: for line in stream.splitlines(): @@ -841,7 +841,7 @@ def regen_bios_profile( [str(bios_tool), "--config", profile_rel], cwd=str(fw), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) for stream in (proc.stdout, proc.stderr): if not stream: @@ -1000,7 +1000,7 @@ def run_prepare_disc( str(project_root), str(source), ] - proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace") out = (proc.stdout or "") + (proc.stderr or "") for line in out.splitlines(): if line.strip(): @@ -1211,7 +1211,7 @@ def cmd_generate(args: argparse.Namespace, progress: ProgressReporter) -> int: cmd, cwd=str(project_root), capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", ) ri_warn = 0 for stream in (proc.stdout, proc.stderr): @@ -1408,7 +1408,7 @@ def _cmake_configure( *extra, ] progress.log(" ".join(cmd)) - proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace") for stream in (proc.stdout, proc.stderr): if stream: for line in stream.splitlines(): @@ -1491,7 +1491,7 @@ def _cmake_build( target, ] progress.log(" ".join(cmd)) - proc = subprocess.run(cmd, capture_output=True, text=True) + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") for stream in (proc.stdout, proc.stderr): if stream: for line in stream.splitlines(): @@ -1646,7 +1646,7 @@ def run_pgo_train( r = subprocess.run( ["xcrun", "--find", "llvm-profdata"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", check=False, ) if r.returncode == 0 and r.stdout.strip(): @@ -2027,7 +2027,7 @@ def cmd_analyze(args: argparse.Namespace, progress: ProgressReporter) -> int: progress.phase("analyze", pct=0.3, message=f"Analyzing {exe_path.name}…") progress.log(" ".join(cmd)) - proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace") for stream in (proc.stdout, proc.stderr): if stream: for line in stream.splitlines(): From 5ed1c948d26da8cfd0172b51fe557e085e9b78b4 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 00:48:50 +0300 Subject: [PATCH 09/10] host: add --setup-selfcheck for headless setup verification Disc selection, BIOS selection and generation were already scriptable through psxrecomp_cli.py (generate --disc --bios, rebuild, verify-disc). The one part with no headless entry point was the verdict: does the setup host consider the generated sources complete? That was reachable only by clicking through the wizard, so nothing in CI could assert it -- and a BIOS stem mismatch in exactly that check shipped a first-run loop on 26 titles. --setup-selfcheck prints that verdict as JSON and exits: 0 when setup is complete, 2 when the wizard would reopen, 1 when it cannot tell. Hooked into psxrecomp_codegen_host_forward_if_built(), which every title already calls with argc/argv at the top of main(), so existing titles gain the flag with no per-title change. Placed ahead of the PSX_HAS_GAME_DISPATCH early return so product builds answer too. --- host/psxrecomp_codegen_host.c | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index 71f6e268..95a43d51 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -4344,9 +4344,92 @@ static int host_paths_same_file(const char* a, const char* b) { #endif } +/* --setup-selfcheck: report what the setup host believes about this tree, as + * JSON on stdout, then exit. 0 = generated sources complete, 2 = the wizard + * would reopen, 1 = could not tell (no project root). + * + * This exists because the decision layer -- "are the generated sources + * present?" -- had no headless entry point. Disc selection, BIOS selection and + * generation were already scriptable via psxrecomp_cli.py; the verdict on + * whether setup is DONE was reachable only by clicking through the wizard, so + * nothing in CI could assert it. A stem mismatch there shipped a first-run + * loop on 26 titles before anyone noticed. + * + * Deliberately ahead of the PSX_HAS_GAME_DISPATCH early return below, so a + * product build answers too. Every title already calls this function with + * argc/argv, so no per-title change is needed to gain the flag. */ +static void host_json_str(const char* s) { + putchar('"'); + for (; s && *s; ++s) { + if (*s == '\\' || *s == '"') + putchar('\\'); + putchar(*s); + } + putchar('"'); +} + +static void host_selfcheck_or_return(const PsxrecompCodegenHostConfig* cfg, + int argc, char** argv) { + const PsxKnownBiosImage* want; + const char* marker_rel; + char marker_abs[1200]; + int i, missing, game_ok, bios_ok; + + for (i = 1; i < argc; ++i) + if (argv[i] && strcmp(argv[i], "--setup-selfcheck") == 0) + break; + if (i >= argc) + return; + + if (!cfg || !cfg->cmake_target || !cfg->exe_basename) { + printf("{\"error\": \"no codegen host config linked\"}\n"); + exit(1); + } + /* Sets g_cfg and g_project_root as a side effect. */ + missing = psxrecomp_codegen_host_sources_missing(cfg); + if (!g_project_root[0]) { + printf("{\"error\": \"project root not found\"}\n"); + exit(1); + } + + marker_rel = cfg_or(cfg->gen_marker_relpath, + "generated/SLUS_011.89_dispatch.c"); + game_ok = join_path(marker_abs, sizeof(marker_abs), g_project_root, + marker_rel) && path_is_file(marker_abs); + bios_ok = !bios_backends_missing(); + want = psx_expected_bios(); + + printf("{\n"); + printf(" \"display_name\": "); + host_json_str(cfg_or(cfg->display_name, "Game")); + printf(",\n \"project_root\": "); + host_json_str(g_project_root); + printf(",\n \"expected_bios_stem\": "); + host_json_str(PSX_EXPECTED_BIOS_STEM); + printf(",\n \"expected_bios_id\": "); + host_json_str(want ? want->id : ""); + printf(",\n \"expected_bios_crc32\": "); + if (want) { + char crcbuf[16]; + snprintf(crcbuf, sizeof(crcbuf), "0x%08X", want->crc32); + host_json_str(crcbuf); + } else { + printf("null"); + } + printf(",\n \"game_dispatch\": "); + host_json_str(marker_rel); + printf(",\n \"game_dispatch_present\": %s", game_ok ? "true" : "false"); + printf(",\n \"bios_backends_present\": %s", bios_ok ? "true" : "false"); + printf(",\n \"sources_missing\": %s", missing ? "true" : "false"); + printf("\n}\n"); + fflush(stdout); + exit(missing ? 2 : 0); +} + /* Setup-host zip-root exe → build-release product (bios/mods/assets/settings). */ void psxrecomp_codegen_host_forward_if_built( const PsxrecompCodegenHostConfig* cfg, int argc, char** argv) { + host_selfcheck_or_return(cfg, argc, argv); /* exits when requested */ #if defined(PSX_HAS_GAME_DISPATCH) /* Full game binary — already the product tree. */ (void)cfg; From 66f432e3c74a6b4ded78e286af431f710f95cabd Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Tue, 8 Sep 2026 10:20:50 +0300 Subject: [PATCH 10/10] runtime: make the rbengine preflight name the recursive init The message said: git submodule update --init lib/retcomm-rbengine That only works from inside psxrecomp/. Someone standing at the game repo root -- which is where they cloned, and where the error is read -- gets "no submodule mapping found", because from there the path is psxrecomp/lib/retcomm-rbengine. retcomm-rbengine is a submodule of psxrecomp, not of the game, so a non-recursive init at the game root populates psxrecomp/ and leaves lib/* empty, which is exactly the state that trips this check. Name --recursive, and name the GitHub source-ZIP trap that also produces it. --- runtime/runtime.cmake | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 6f3a2b07..d5e93172 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -529,7 +529,13 @@ if(PSX_REWIND) message(FATAL_ERROR "psxrecomp: PSX_REWIND=ON exposes the Rewind launcher controls " "but no retcomm-rbengine snap-ring backend was found.\n" - " git submodule update --init lib/retcomm-rbengine\n" + "A source ZIP downloaded from GitHub never contains submodule " + "contents and cannot build. Clone instead:\n" + " git clone --recurse-submodules \n" + "In an existing clone, run this from the GAME repo root. Note " + "--recursive: rbengine is a submodule of psxrecomp, not of the " + "game, so a non-recursive init leaves it empty.\n" + " git submodule update --init --recursive\n" " or -DRECOMP_RBENGINE_ROOT=/path/to/retcomm-rbengine\n" " or configure with -DPSX_REWIND=OFF to hide Rewind.") endif()