self-build: loop breaker, install-folder preflight, boot-EXE name lint

Follow-ups to the marker-skew hardening, closing the remaining legs of
the generate+build loop class:

- Loop breaker: host_prepare_generate stamps a successful generate into
  .psxrecomp_last_generate at the project root. When apply() is about to
  reopen the wizard because sources are missing and the stamp is recent,
  the wizard's prepare note becomes a diagnostic naming exactly which
  gate is unmet (the expected *_dispatch.c and/or the BIOS backends) and
  what generated/ actually contains, instead of silently asking for
  another round. The stamp is dropped once sources are visible so a much
  later manual wipe of generated/ reads as a fresh first run.

- Preflight: generate now refuses up front when the project root sits
  under the Windows temp directory (Explorer's run-from-inside-the-ZIP
  extraction, which evaporates) or is not writable, with instructions,
  instead of failing downstream in ways that present as the same loop.

- CI lint: tools/ci/check_boot_exe.sh cross-checks the three baked
  copies of the boot-EXE name (game.toml boot_exe/exe, CMakeLists
  GEN_MARKER, codegen_setup.c gen_marker_relpath) and the setup-release
  template gains a fail-fast "Lint boot-EXE name consistency" step, so a
  skew fails the maintainer's release instead of the player's install.
  op_emit_ci_workflow's step-name drift detection flags older titles
  automatically. Script is bash-3.2-clean for macOS runners; verified
  against fixture repos (consistent / skewed / boot_exe override /
  single-source) and real ports - where it immediately caught
  Tomba2Recomp's placeholder gen_marker_relpath ("SLUS_01234" vs
  SCUS_944.54), a live instance of the reported loop.

Both gitignore templates learn the new sidecar names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfPHUBEYiSCwaGiwRc8ea7
This commit is contained in:
Alex Vanderveen
2026-09-01 11:00:29 -04:00
parent 5b197d45df
commit 209970cfe5
5 changed files with 271 additions and 0 deletions
+2
View File
@@ -69,6 +69,8 @@ psx_crash.txt
psx_last_run_report.json
psx_screenshot.bmp
psx_freeze_heartbeat.json
.psxrecomp_last_generate
.psxrecomp_write_probe
starvation_dump.jsonl
# Diagnostic dumps (root-level) — keep catalog / probe trackable
+8
View File
@@ -195,6 +195,14 @@ jobs:
shell: bash
run: bash psxrecomp/tools/ci/record_pins.sh
# A skew between game.toml, CMakeLists GEN_MARKER and codegen_setup.c
# makes every player's self-build loop generate+build forever. The CLI
# also refuses the skew at generate time, but failing the release is
# cheaper than failing the player.
- name: Lint boot-EXE name consistency
shell: bash
run: bash psxrecomp/tools/ci/check_boot_exe.sh .
- name: Setup host mode (no game C / BIOS backends in CI)
shell: bash
run: bash psxrecomp/tools/ci/clear_generated.sh
+179
View File
@@ -6,6 +6,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#if defined(_WIN32)
# include <windows.h>
@@ -3582,6 +3583,165 @@ static void host_update_disc_set(RecompLauncherCPrepareProgressFn on_progress,
"Could not record every disc — continuing with the boot disc.");
}
/* ---- self-build loop breaker + preflight ------------------------------- */
#define HOST_LAST_GENERATE_SIDECAR ".psxrecomp_last_generate"
/* A wizard loop iterates in minutes; a recent stamp with sources still
* missing means "generate worked but nothing gates on its output" — a
* config skew — not a user who wiped generated/ last week. */
#define HOST_LOOP_BREAKER_WINDOW_SECS (6 * 60 * 60)
static char g_loop_breaker_note[1024];
/* Comma-join the *_dispatch.c basenames under <root>/generated. */
static void list_generated_dispatch(char* out, size_t cap) {
char dir[1100];
size_t used = 0;
out[0] = '\0';
if (!join_path(dir, sizeof(dir), g_project_root, "generated"))
return;
#if defined(_WIN32)
{
char pat[1200];
WIN32_FIND_DATAA fd;
HANDLE h;
if (!join_path(pat, sizeof(pat), dir, "*_dispatch.c"))
return;
h = FindFirstFileA(pat, &fd);
if (h == INVALID_HANDLE_VALUE)
return;
do {
int n = snprintf(out + used, cap - used, "%s%s",
used ? ", " : "", fd.cFileName);
if (n <= 0 || (size_t)n >= cap - used)
break;
used += (size_t)n;
} while (FindNextFileA(h, &fd));
FindClose(h);
}
#else
{
static const char suffix[] = "_dispatch.c";
DIR* d = opendir(dir);
struct dirent* e;
if (!d)
return;
while ((e = readdir(d)) != NULL) {
size_t len = strlen(e->d_name);
int n;
if (len <= sizeof(suffix) - 1 ||
strcmp(e->d_name + len - (sizeof(suffix) - 1), suffix) != 0)
continue;
n = snprintf(out + used, cap - used, "%s%s",
used ? ", " : "", e->d_name);
if (n <= 0 || (size_t)n >= cap - used)
break;
used += (size_t)n;
}
closedir(d);
}
#endif
}
static void host_record_generate_ok(void) {
char sidecar[1200], line[32];
if (!join_path(sidecar, sizeof(sidecar), g_project_root,
HOST_LAST_GENERATE_SIDECAR))
return;
snprintf(line, sizeof(line), "%lld", (long long)time(NULL));
write_line_file(sidecar, line);
}
static void host_clear_generate_stamp(void) {
char sidecar[1200];
if (join_path(sidecar, sizeof(sidecar), g_project_root,
HOST_LAST_GENERATE_SIDECAR))
remove(sidecar);
}
/* apply() consults this when the wizard is about to reopen because sources
* are missing. If a generate succeeded here recently, re-running it will
* loop — say exactly what is missing and what exists instead of silently
* re-prompting. Returns NULL when there is nothing to warn about. */
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;
int game_missing, bios_missing;
if (!join_path(sidecar, sizeof(sidecar), g_project_root,
HOST_LAST_GENERATE_SIDECAR))
return NULL;
if (!read_line_file(sidecar, line, sizeof(line)))
return NULL;
then = strtoll(line, NULL, 10);
now = (long long)time(NULL);
if (then <= 0 || now < then || now - then > HOST_LOOP_BREAKER_WINDOW_SECS)
return NULL;
marker_rel = cfg_or(g_cfg->gen_marker_relpath,
"generated/SLUS_011.89_dispatch.c");
game_missing =
!join_path(marker_abs, sizeof(marker_abs), g_project_root,
marker_rel) ||
!path_is_file(marker_abs);
bios_missing = bios_backends_missing();
if (!game_missing && !bios_missing)
return NULL;
list_generated_dispatch(found, sizeof(found));
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.",
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");
fprintf(stderr, "psxrecomp-codegen: %s\n", g_loop_breaker_note);
return g_loop_breaker_note;
}
/* Generating into Explorer's temporary zip extraction (Windows) or an
* unwritable folder always ends badly — the output evaporates or never
* lands, and the wizard reopens forever. Refuse with instructions. */
static int host_preflight_project_root(char* err_msg, size_t err_cap) {
#if defined(_WIN32)
{
char tmp[MAX_PATH];
DWORD n = GetTempPathA((DWORD)sizeof(tmp), tmp);
if (n > 0 && n < (DWORD)sizeof(tmp) &&
_strnicmp(g_project_root, tmp, strlen(tmp)) == 0) {
snprintf(err_msg, err_cap,
"This game is running from a temporary folder (%s), "
"usually because the downloaded ZIP was opened without "
"extracting it. Extract the whole ZIP to a normal "
"folder, then run the game from there.",
g_project_root);
return 0;
}
}
#endif
{
char probe[1200];
FILE* f;
if (!join_path(probe, sizeof(probe), g_project_root,
".psxrecomp_write_probe"))
return 1; /* path too long — let the CLI surface the real error */
f = fopen(probe, "wb");
if (!f) {
snprintf(err_msg, err_cap,
"The install folder is not writable: %s. Move the game "
"folder somewhere writable (e.g. your user folder) or "
"fix its permissions, then retry.",
g_project_root);
return 0;
}
fclose(f);
remove(probe);
}
return 1;
}
static int host_prepare_generate(const char* source_path, char* out_path,
size_t out_cap, char* err_msg, size_t err_cap,
RecompLauncherCPrepareProgressFn on_progress,
@@ -3594,6 +3754,8 @@ static int host_prepare_generate(const char* source_path, char* out_path,
snprintf(err_msg, err_cap, "No disc selected.");
return 0;
}
if (!host_preflight_project_root(err_msg, err_cap))
return 0;
activate_toolchain_path();
if (!find_python(g_python, sizeof(g_python))) {
/* First-run / pruned cache: page 0 normally installed the pack; heal. */
@@ -3699,6 +3861,10 @@ static int host_prepare_generate(const char* source_path, char* out_path,
return 0;
#endif
/* Stamp the success so apply() can tell "generate worked but the
* launcher still cannot see its output" apart from a plain first run. */
host_record_generate_ok();
snprintf(out_path, out_cap, "%s", source_path);
if (on_progress)
on_progress(progress_ctx, 1.0f, "Generate complete");
@@ -4365,6 +4531,19 @@ void psxrecomp_codegen_host_apply(RecompLauncherCGameInfo* gi,
if (psxrecomp_codegen_host_sources_missing(cfg) || force_setup) {
gi->needs_setup = 1;
gi->prepare_required_before_continue = 1;
if (!force_setup) {
/* Loop breaker: a recent successful generate with sources still
* missing is a config skew, not a first run — say so in the
* wizard instead of silently asking for another round. */
const char* note = host_loop_breaker_note();
if (note)
gi->prepare_disc_note = note;
}
} else {
/* Sources are visible again — the last generate resolved; drop the
* stamp so a much later manual wipe of generated/ reads as a fresh
* first run, not a loop. */
host_clear_generate_stamp();
}
#endif /* PSX_HAS_SETUP_WIZARD */
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Assert the three baked copies of the boot-EXE name agree.
#
# A game project carries the boot-EXE name in three places, all filled from
# the same @BOOT_EXE@ token at instantiation: game.toml ([prepare_disc]
# boot_exe, or the basename of [game] exe), CMakeLists.txt (GEN_MARKER),
# and codegen_setup.c (.gen_marker_relpath). generate names its output
# after game.toml while the build and the launcher gate on the other two,
# so a skew makes every player's self-build loop generate+build forever.
# The CLI also refuses the skew at generate time; failing the release here
# is cheaper than failing the player.
#
# Usage: check_boot_exe.sh [game-repo-root] (default: .)
# Runs on bash 3.2+ (macOS runners) — no associative arrays.
set -euo pipefail
ROOT="${1:-.}"
toml_get() { # <section> <key> — first quoted value of key inside [section]
awk -v sec="$1" -v key="$2" '
/^[[:space:]]*\[/ { in_sec = ($0 ~ ("^[[:space:]]*\\[" sec "\\]")) }
in_sec && $0 ~ ("^[[:space:]]*" key "[[:space:]]*=") {
if (match($0, /"[^"]*"/)) { print substr($0, RSTART + 1, RLENGTH - 2) }
exit
}' "${ROOT}/game.toml"
}
n_toml="" n_cmake="" n_setup=""
if [[ -f "${ROOT}/game.toml" ]]; then
n_toml="$(toml_get prepare_disc boot_exe || true)"
if [[ -z "${n_toml}" ]]; then
exe="$(toml_get game exe || true)"
n_toml="${exe##*/}"
fi
fi
if [[ -f "${ROOT}/CMakeLists.txt" ]]; then
n_cmake="$(sed -n \
's/.*GEN_MARKER[[:space:]]*"generated\/\(.*\)_dispatch\.c".*/\1/p' \
"${ROOT}/CMakeLists.txt" | head -n1)"
fi
if [[ -f "${ROOT}/codegen_setup.c" ]]; then
n_setup="$(sed -n \
's/.*\.gen_marker_relpath[[:space:]]*=[[:space:]]*"generated\/\(.*\)_dispatch\.c".*/\1/p' \
"${ROOT}/codegen_setup.c" | head -n1)"
fi
found=0
ref=""
mismatch=0
report() { # <label> <value>
[[ -z "$2" ]] && return 0
echo " $1 -> $2"
found=$((found + 1))
if [[ -z "${ref}" ]]; then
ref="$2"
elif [[ "$2" != "${ref}" ]]; then
mismatch=1
fi
}
echo "boot-EXE name sources:"
report "game.toml (boot_exe / game.exe)" "${n_toml}"
report "CMakeLists.txt GEN_MARKER" "${n_cmake}"
report "codegen_setup.c gen_marker_relpath" "${n_setup}"
if [[ "${found}" -lt 2 ]]; then
echo "warning: fewer than two boot-EXE sources found under ${ROOT} —" \
"nothing to cross-check (non-standard layout?)"
exit 0
fi
if [[ "${mismatch}" -ne 0 ]]; then
echo "error: the boot-EXE names above disagree. generate names its output" >&2
echo "after game.toml, while the build (GEN_MARKER) and the launcher" >&2
echo "(gen_marker_relpath) gate on the exact other names — players'" >&2
echo "self-builds would loop generate+build forever. Make all three name" >&2
echo "the same boot EXE." >&2
exit 1
fi
echo "boot-EXE names agree: ${ref}"
@@ -71,6 +71,8 @@ psx_crash.txt
psx_last_run_report.json
psx_screenshot.bmp
psx_freeze_heartbeat.json
.psxrecomp_last_generate
.psxrecomp_write_probe
starvation_dump.jsonl
# Diagnostic dumps (root-level) — keep catalog / probe trackable