Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 185 additions & 20 deletions host/psxrecomp_codegen_host.c
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include "psxrecomp_codegen_host.h"

#include "psx_bios_known_images.h"

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
Expand Down Expand Up @@ -1140,15 +1142,74 @@ static int resolve_build_paths(void) {
return join_path(g_exe_path, sizeof(g_exe_path), g_build_dir, exe_name);
}

/* Pair a <stem>_dispatch.c with its <stem>_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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When generated/ contains a paired game output or stale non-configured BIOS, this scan treats it as a BIOS backend. The setup host can skip BIOS generation and rebuild with no linked backend; restrict the scan to configured, descriptor-bearing BIOS stems.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At host/psxrecomp_codegen_host.c, line 1198:

<comment>When `generated/` contains a paired game output or stale non-configured BIOS, this scan treats it as a BIOS backend. The setup host can skip BIOS generation and rebuild with no linked backend; restrict the scan to configured, descriptor-bearing BIOS stems.</comment>

<file context>
@@ -1140,15 +1140,74 @@ static int resolve_build_paths(void) {
+    if (!d)
+        return 0;
+    while (!found && (e = readdir(d)) != NULL) {
+        if (dispatch_has_full(dir, e->d_name))
+            found = 1;
+    }
</file context>

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(
Expand Down Expand Up @@ -1214,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;
Expand All @@ -1226,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; }
Expand All @@ -1248,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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On case-sensitive filesystems, this lookup skips common mixed-case spellings such as SCPH5552.bin because the filename helper emits only all-uppercase or all-lowercase forms. Generate all independent base and extension case combinations so valid dumps are still auto-discovered.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At host/psxrecomp_codegen_host.c, line 1326:

<comment>On case-sensitive filesystems, this lookup skips common mixed-case spellings such as `SCPH5552.bin` because the filename helper emits only all-uppercase or all-lowercase forms. Generate all independent base and extension case combinations so valid dumps are still auto-discovered.</comment>

<file context>
@@ -1307,22 +1315,22 @@ static int retail_bios_file_ok_c(const char* path) {
-        "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",
</file context>

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;
Expand All @@ -1287,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;
Expand Down Expand Up @@ -3729,6 +3796,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))
Expand All @@ -3749,16 +3817,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),
Comment on lines +3825 to 3834

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When both game_missing and bios_missing are true, this branch chooses only the boot-EXE mismatch message. Add a combined branch so the wizard also gives the required BIOS remediation instead of sending the player only to the port maintainer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At host/psxrecomp_codegen_host.c, line 3817:

<comment>When both `game_missing` and `bios_missing` are true, this branch chooses only the boot-EXE mismatch message. Add a combined branch so the wizard also gives the required BIOS remediation instead of sending the player only to the port maintainer.</comment>

<file context>
@@ -3749,16 +3809,30 @@ static const char* host_loop_breaker_note(void) {
+     * 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";
</file context>
Suggested change
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),
if (game_missing && bios_missing)
cause = "the generated game dispatch and BIOS backends are both "
"missing. Check the boot-EXE names, select the PlayStation "
"BIOS dump this port requires (named in the README; it must "
"be exactly 512 KB), then run Generate again";
else 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";

"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;
}
Expand Down Expand Up @@ -4262,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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a project path or display name contains a control character, --setup-selfcheck emits invalid JSON and headless CI parsers fail. Escape bytes below 0x20 (\n, \r, \t, \b, \f, or \u00XX) before writing them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At host/psxrecomp_codegen_host.c, line 4366:

<comment>When a project path or display name contains a control character, `--setup-selfcheck` emits invalid JSON and headless CI parsers fail. Escape bytes below `0x20` (`\n`, `\r`, `\t`, `\b`, `\f`, or `\u00XX`) before writing them.</comment>

<file context>
@@ -4344,9 +4344,92 @@ static int host_paths_same_file(const char* a, const char* b) {
+    for (; s && *s; ++s) {
+        if (*s == '\\' || *s == '"')
+            putchar('\\');
+        putchar(*s);
+    }
+    putchar('"');
</file context>
Suggested change
putchar(*s);
if ((unsigned char)*s < 0x20) {
switch (*s) {
case '\b': fputs("\\b", stdout); break;
case '\f': fputs("\\f", stdout); break;
case '\n': fputs("\\n", stdout); break;
case '\r': fputs("\\r", stdout); break;
case '\t': fputs("\\t", stdout); break;
default: fprintf(stdout, "\\u%04X", (unsigned char)*s); break;
}
} else {
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;
Expand Down
18 changes: 9 additions & 9 deletions psxrecomp_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions recompiler/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ if(BUILD_TESTING)
bios_selection_guards
capture_history
cli_retail_bios_profile
codegen_host_bios_stems

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test is registered in the "runtime source-invariant guards" loop, which the enclosing comment guarantees only reads runtime source and "runs from a plain recompiler build" with no generated BIOS and no built runtime. test_codegen_host_bios_stems.py does not meet that contract: it returns 0 (reports PASS) as a SKIP whenever recomp-ui is absent or no C compiler is on PATH, and it compiles and executes psxrecomp_codegen_host.c rather than reading source. In a framework-only recompiler build recomp-ui is never present, so this test always skips and ctest reports it green with zero coverage, giving false confidence that the loop-breaker regression is guarded in the very tree the comment says it runs from. The host half only gets real coverage in full kit builds. Make the skip visible (e.g. return 77 so ctest treats it as SKIPPED) or relocate the registration so the loop's documented guarantee stays accurate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At recompiler/CMakeLists.txt, line 630:

<comment>This test is registered in the "runtime source-invariant guards" loop, which the enclosing comment guarantees only reads runtime source and "runs from a plain recompiler build" with no generated BIOS and no built runtime. `test_codegen_host_bios_stems.py` does not meet that contract: it returns 0 (reports PASS) as a SKIP whenever recomp-ui is absent or no C compiler is on PATH, and it compiles and executes `psxrecomp_codegen_host.c` rather than reading source. In a framework-only recompiler build recomp-ui is never present, so this test always skips and ctest reports it green with zero coverage, giving false confidence that the loop-breaker regression is guarded in the very tree the comment says it runs from. The host half only gets real coverage in full kit builds. Make the skip visible (e.g. return 77 so ctest treats it as SKIPPED) or relocate the registration so the loop's documented guarantee stays accurate.</comment>

<file context>
@@ -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
</file context>

default_renderer_guards
mod_owned_display_controls
fmv_quiet_guards
Expand Down
Loading