diff --git a/assets/psxrecomp.ico b/assets/psxrecomp.ico new file mode 100644 index 00000000..34670f35 Binary files /dev/null and b/assets/psxrecomp.ico differ diff --git a/assets/psxrecomp.png b/assets/psxrecomp.png new file mode 100644 index 00000000..b43ea162 Binary files /dev/null and b/assets/psxrecomp.png differ diff --git a/assets/psxrecomp.svg b/assets/psxrecomp.svg new file mode 100644 index 00000000..24a2a4fa --- /dev/null +++ b/assets/psxrecomp.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index 666dd9e3..9e4a2888 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -257,6 +257,10 @@ YourGameRecomp/ # your git repo ├── framework_pins.txt # optional scaffold snapshot (gitlinks are authoritative) ├── README.md # scaffold stub (legal + quick start) ├── VERSION # release pin (e.g. 0.1.0) +├── assets/ +│ ├── psxrecomp.ico # default Windows app icon (RetComM-themed pad) +│ ├── psxrecomp.png # staged beside exe / packaging +│ └── psxrecomp.svg # source mark (see psxrecomp/assets/) ├── seeds/ghidra_funcs.txt # probe: boot-EXE JAL seeds (grow over time) ├── codegen_setup.c / .h # title PsxrecompCodegenHostConfig (+ apply hooks) ├── launcher_assets/img/ @@ -296,6 +300,7 @@ psxrecomp_add_game_runtime(psx-runtime CODEGEN_SETUP_SOURCES codegen_setup.c DEFAULT_GAME_CONFIG_PATH "game.toml" LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga" + APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico" ) ``` diff --git a/docs/ci/HOST_ONLY_RELEASES.md b/docs/ci/HOST_ONLY_RELEASES.md index 0bb9047e..9bea7178 100644 --- a/docs/ci/HOST_ONLY_RELEASES.md +++ b/docs/ci/HOST_ONLY_RELEASES.md @@ -11,6 +11,9 @@ framework pins. It never ships retail discs, BIOS dumps, or `generated/` game C. 3. Configure with `-DPSXRECOMP_FORCE_SETUP_HOST=ON` + wizard flags; build the host binary only. 4. `package_setup_host.sh` — lean zip (no embedded toolchain by default). + Requires `psx_game_version.txt` beside the host exe (stamped at compile + time). Refuses to ship if `RELEASE_VERSION` / `VERSION` disagree with that + stamp — a mismatched pin makes netplay lobby browsers filter each other out. Template: [`templates/setup-release.yml`](templates/setup-release.yml). diff --git a/docs/ci/templates/setup-release.yml b/docs/ci/templates/setup-release.yml index 6aa8bf2c..5bc876c6 100644 --- a/docs/ci/templates/setup-release.yml +++ b/docs/ci/templates/setup-release.yml @@ -504,6 +504,30 @@ jobs: exit 1 fi cmake --build build-ci --target psx-runtime -j"${JOBS}" + # Lobby list pin must match RELEASE_VERSION (package_setup_host enforces). + STAMP="" + for cand in \ + build-ci/psx_game_version.txt \ + build-ci/Release/psx_game_version.txt \ + build-ci/*/psx_game_version.txt + do + if [[ -f "${cand}" ]]; then STAMP="${cand}"; break; fi + done + if [[ -z "${STAMP}" ]]; then + # Fallback: stamp next to the host exe + STAMP="$(find build-ci -name psx_game_version.txt -type f 2>/dev/null | head -n1 || true)" + fi + if [[ -z "${STAMP}" || ! -f "${STAMP}" ]]; then + echo "::error::missing psx_game_version.txt after build (update psxrecomp submodule)" + exit 1 + fi + BUILT="$(tr -d '[:space:]' <"${STAMP}")" + BUILT="${BUILT#v}" + if [[ "${BUILT}" != "${RELEASE_VERSION}" ]]; then + echo "::error::lobby pin stamp ${BUILT} != RELEASE_VERSION ${RELEASE_VERSION} (${STAMP})" + exit 1 + fi + echo "lobby pin ok: ${BUILT} (${STAMP})" - name: Package setup zip run: | diff --git a/packaging/make-icons.sh b/packaging/make-icons.sh new file mode 100755 index 00000000..b5815a1b --- /dev/null +++ b/packaging/make-icons.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Rasterize assets/psxrecomp.svg → PNG / ICO. +# Master PNG is 512x512 — linuxdeploy / AppImage reject oversized icons. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SVG="${ROOT}/assets/psxrecomp.svg" +PNG="${ROOT}/assets/psxrecomp.png" +ICO="${ROOT}/assets/psxrecomp.ico" +SIZE=512 + +if [[ ! -f "${SVG}" ]]; then + echo "missing ${SVG}" >&2 + exit 1 +fi + +if command -v rsvg-convert >/dev/null 2>&1; then + rsvg-convert -w "${SIZE}" -h "${SIZE}" "${SVG}" -o "${PNG}" +elif command -v magick >/dev/null 2>&1; then + magick -background none "${SVG}" -resize "${SIZE}x${SIZE}" "${PNG}" +elif command -v convert >/dev/null 2>&1; then + convert -background none "${SVG}" -resize "${SIZE}x${SIZE}" "${PNG}" +elif command -v inkscape >/dev/null 2>&1; then + inkscape "${SVG}" -w "${SIZE}" -h "${SIZE}" -o "${PNG}" +else + python3 - "${PNG}" "${SIZE}" <<'PY' +import struct, zlib, sys +from pathlib import Path + +def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff) + +out = Path(sys.argv[1]) +w = h = int(sys.argv[2]) +rows = [] +for y in range(h): + row = bytearray([0]) + for x in range(w): + # Approximate dark rounded tile + teal pad blob (fallback only). + cx, cy = x - w / 2, y - h / 2 + rr = (w * 0.42) + inside = cx * cx + cy * cy < rr * rr + if inside: + t = (x + y) / (2 * w) + r = int(26 + t * 10) + g = int(35 + t * 10) + b = int(50 + t * 10) + a = 255 + # teal accent ring + if abs((cx * cx + cy * cy) ** 0.5 - rr * 0.55) < w * 0.03: + r, g, b, a = 45, 212, 191, 255 + else: + r = g = b = a = 0 + row.extend([r, g, b, a]) + rows.append(bytes(row)) +raw = b"".join(rows) +ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) +png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") +out.write_bytes(png) +print(f"wrote {out} (python fallback)") +PY +fi + +echo "PNG: ${PNG} (${SIZE}x${SIZE})" + +if command -v magick >/dev/null 2>&1; then + magick "${PNG}" -define icon:auto-resize=256,128,64,48,32,16 "${ICO}" + echo "ICO: ${ICO}" +elif command -v convert >/dev/null 2>&1; then + convert "${PNG}" -define icon:auto-resize=256,128,64,48,32,16 "${ICO}" + echo "ICO: ${ICO}" +else + python3 - "${PNG}" "${ICO}" <<'PY' +# Minimal single-size ICO from PNG (no Pillow required). +import struct, sys, zlib +from pathlib import Path + +def png_rgba(path): + data = Path(path).read_bytes() + assert data[:8] == b"\x89PNG\r\n\x1a\n" + i = 8 + w = h = None + idat = b"" + while i < len(data): + ln = struct.unpack(">I", data[i:i+4])[0] + tag = data[i+4:i+8] + chunk = data[i+8:i+8+ln] + i += 12 + ln + if tag == b"IHDR": + w, h = struct.unpack(">II", chunk[:8]) + elif tag == b"IDAT": + idat += chunk + elif tag == b"IEND": + break + raw = zlib.decompress(idat) + stride = 1 + w * 4 + pixels = bytearray() + for y in range(h): + row = raw[y*stride+1:(y+1)*stride] + pixels.extend(row) + return w, h, bytes(pixels) + +w, h, rgba = png_rgba(sys.argv[1]) +# ICO: BGRA + AND mask, bottom-up +stride = ((w * 32 + 31) // 32) * 4 +and_stride = ((w + 31) // 32) * 4 +xor = bytearray() +and_mask = bytearray() +for y in range(h-1, -1, -1): + row = bytearray() + for x in range(w): + i = (y * w + x) * 4 + r, g, b, a = rgba[i:i+4] + row += bytes([b, g, r, a]) + row += b"\x00" * (stride - w * 4) + xor.extend(row) +for y in range(h-1, -1, -1): + bits = 0 + byte = 0 + row = bytearray() + for x in range(w): + a = rgba[(y * w + x) * 4 + 3] + byte = (byte << 1) | (0 if a > 0 else 1) + bits += 1 + if bits == 8: + row.append(byte) + bits = byte = 0 + if bits: + row.append(byte << (8 - bits)) + row += b"\x00" * (and_stride - len(row)) + and_mask.extend(row) + +dib = struct.pack("/assets" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_psxrt_png}" + "$/assets/psxrecomp.png" + COMMENT "Staging psxrecomp.png app icon" + VERBATIM) + endif() + endif() + # ---- overlay codegen hash (auto cache key) ----------------------------- # Hash the recompiler's codegen sources into runtime/include/overlay_codegen_hash.h # (gitignored) so the overlay cache path carries cg_: any emitter change @@ -1055,6 +1089,19 @@ function(psxrecomp_add_runtime_target target) $<$:SDL_MAIN_HANDLED> ) + # Stamp the lobby pin next to the exe (and in the build tree). Packagers + # must ship VERSION == this stamp — rewriting VERSION after the build + # caused Twisted Metal 4 installs where VERSION said 0.3.8 but the binary + # still filtered lobbies as 0.3.7. + file(GENERATE + OUTPUT "$/psx_game_version.txt" + CONTENT "${PSXRT_GAME_VERSION}\n" + ) + file(GENERATE + OUTPUT "${CMAKE_BINARY_DIR}/psx_game_version.txt" + CONTENT "${PSXRT_GAME_VERSION}\n" + ) + # OpenBIOS is part of the native runtime product, not a developer-machine # prerequisite. Stage both the exact ROM consumed by the compiled backend # and its required MIT notice beside every native executable. Release @@ -1450,6 +1497,7 @@ endfunction() # CODEGEN_SETUP_SOURCES codegen_setup.c # DEFAULT_GAME_CONFIG_PATH "game.toml" # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga" +# APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico" # MAX_PLAYERS 2 # ENABLE_NETPLAY_IF_PRESENT # ENABLE_SETUP_WIZARD @@ -1520,6 +1568,17 @@ function(psxrecomp_add_game_runtime target) message(STATUS "psxrecomp game_version: ${PSX_GAME_VERSION} " "(from VERSION=${_psxg_release_version})") + if(NOT "${PSX_GAME_VERSION}" STREQUAL "" + AND NOT "${PSX_GAME_VERSION}" MATCHES "\\$<" + AND NOT "${_psxg_release_version}" STREQUAL "" + AND NOT "${_psxg_release_version}" STREQUAL "0.0.0" + AND NOT "${PSX_GAME_VERSION}" STREQUAL "${_psxg_release_version}") + message(WARNING + "PSX_GAME_VERSION=${PSX_GAME_VERSION} differs from VERSION file " + "(${_psxg_release_version}). Sticky CMakeCache after a VERSION bump " + "causes netplay lobby list mismatches. Reconfigure with " + "-DPSX_GAME_VERSION=${_psxg_release_version} or delete the build cache.") + endif() # Prefer game-root recomp-ui (runtime.cmake also auto-discovers this). if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/recomp-ui/recomp_ui.cmake") diff --git a/tools/new_project_layout/README.md b/tools/new_project_layout/README.md index 26b2715b..fd1023f8 100644 --- a/tools/new_project_layout/README.md +++ b/tools/new_project_layout/README.md @@ -80,7 +80,8 @@ For **bulk ops across many titles / platforms**, use the sibling tool | Op | Purpose | |----|---------| -| `rename_psxrecomp_submodule` | `psxrecomp-v4` → `psxrecomp` | +| `rename_psxrecomp_submodule` | Promote/keep `psxrecomp/`; delete leftover `psxrecomp-v4` | +| `repair_psxrecomp_submodule` | Re-clone when `psxrecomp/.git` gitdir is broken / absorbed | | `ensure_recomp_ui_submodule` | Add `recomp-ui` | | `emit_codegen_setup` | Thin `codegen_setup.c/.h` | | `rewrite_cmake_setup_host` | `psxrecomp_add_game_runtime` + wizard | diff --git a/tools/new_project_layout/project_studio/buildops.py b/tools/new_project_layout/project_studio/buildops.py index cb8ebe0a..6c7145bc 100644 --- a/tools/new_project_layout/project_studio/buildops.py +++ b/tools/new_project_layout/project_studio/buildops.py @@ -2,6 +2,10 @@ Cross-platform (Windows / macOS / Linux). No force flags; builds stay under the chosen build directory (default ``build-release``). + +Before configure, missing OpenBIOS generated C under ``psxrecomp/generated/`` +is regenerated (MIT OpenBIOS — no retail dump required) so runtime.cmake can +link a BIOS backend. """ from __future__ import annotations @@ -21,6 +25,10 @@ from .gitops import CmdResult DEFAULT_BUILD_DIR = "build-release" DEFAULT_TARGET = "psx-runtime" DEFAULT_BUILD_TYPE = "Release" +OPENBIOS_PROFILE = "bios/OpenBIOS.toml" +OPENBIOS_STEM = "OpenBIOS" +SCPH1001_PROFILE = "bios/SCPH1001.toml" +SCPH1001_STEM = "SCPH1001" LogFn = Callable[[str], None] @@ -155,6 +163,403 @@ def _run_stream( return CmdResult(True, "OK", detail) +def resolve_framework_root(root: Path) -> Path | None: + """Return the psxrecomp framework root (bios/ + recompiler/), or None.""" + root = root.expanduser().resolve() + for cand in (root / "psxrecomp", root): + if (cand / "bios" / "OpenBIOS.toml").is_file() and (cand / "recompiler").is_dir(): + return cand + return None + + +_MAX_PLAYERS_CMAKE_RE = re.compile( + r"^\s*MAX_PLAYERS\s+(\d+)\s*$", re.MULTILINE | re.IGNORECASE +) +_MAX_PLAYERS_RANGE_RE = re.compile( + r"MAX_PLAYERS must be in\s+(\d+)\.\.(\d+)", re.IGNORECASE +) + + +def project_max_players(root: Path) -> int | None: + """``MAX_PLAYERS N`` from the game ``CMakeLists.txt``, if present.""" + cmake = root / "CMakeLists.txt" + if not cmake.is_file(): + return None + try: + text = cmake.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + m = _MAX_PLAYERS_CMAKE_RE.search(text) + return int(m.group(1)) if m else None + + +def framework_max_players_range(fw: Path) -> tuple[int, int] | None: + """``(lo, hi)`` from runtime.cmake's FATAL_ERROR range check, if found.""" + cmake = fw / "runtime" / "runtime.cmake" + if not cmake.is_file(): + return None + try: + text = cmake.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + m = _MAX_PLAYERS_RANGE_RE.search(text) + if not m: + return None + return int(m.group(1)), int(m.group(2)) + + +def preflight_max_players(root: Path) -> CmdResult | None: + """Fail fast when the game asks for MAX_PLAYERS the nested framework rejects. + + Single-player titles (Ape Escape, Tomba, …) use ``MAX_PLAYERS 1``. Older + psxrecomp pins only allowed 2..5 and abort configure with a cryptic + FATAL_ERROR. Detect that before running cmake. + """ + players = project_max_players(root) + if players is None: + return None + fw = resolve_framework_root(root) + if fw is None: + return None + rng = framework_max_players_range(fw) + if rng is None: + return None + lo, hi = rng + if lo <= players <= hi: + return None + pins = root / "framework_pins.txt" + pin_hint = "" + if pins.is_file(): + pin_hint = f" Check {pins.name} vs the checked-out psxrecomp commit." + return CmdResult( + False, + f"MAX_PLAYERS {players} is outside nested psxrecomp range {lo}..{hi}. " + f"Update the psxrecomp submodule to a pin that allows 1..8 " + f"(runtime.cmake after single-player / rewind support).{pin_hint}", + ) + + +def diagnose_configure_failure(detail: str, root: Path) -> str | None: + """Extra hint appended to a failed cmake configure message.""" + blob = detail or "" + if "MAX_PLAYERS must be in" in blob and "got" in blob: + pre = preflight_max_players(root) + if pre is not None: + return pre.message + return ( + "MAX_PLAYERS rejected by nested psxrecomp. Single-player titles need " + "a framework pin whose runtime.cmake allows 1..8 — update the " + "psxrecomp submodule (and framework_pins.txt)." + ) + if "generated" in blob.lower() and ( + "GEN_MARKER" in blob or "dispatch.c" in blob or "missing" in blob.lower() + ): + return ( + "Game generated C may be missing — run Generate (disc→C) before " + "Configure, or ensure generated/_dispatch.c exists." + ) + return None + + +def bios_backend_present(fw: Path, stem: str) -> bool: + """True when generated/_{full,dispatch}.c look linkable.""" + dispatch = fw / "generated" / f"{stem}_dispatch.c" + full = fw / "generated" / f"{stem}_full.c" + if not dispatch.is_file() or not full.is_file(): + return False + try: + text = dispatch.read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + return f"{stem}_psx_bios_backend" in text + + +def _allow_no_bios(extra_args: list[str] | None) -> bool: + if not extra_args: + return False + blob = " ".join(extra_args) + return ( + "PSXRECOMP_ALLOW_NO_BIOS=ON" in blob + or "PSXRECOMP_ALLOW_NO_BIOS:BOOL=ON" in blob + or "PSXRECOMP_ALLOW_NO_BIOS=1" in blob + ) + + +def _find_psxrecomp_bios(fw: Path, game_root: Path | None = None) -> Path | None: + names = ("psxrecomp-bios.exe", "psxrecomp-bios") + dirs: list[Path] = [ + fw / "recompiler" / "build", + fw / "recompiler" / "build-t2", + ] + if game_root is not None: + dirs.append(game_root / "build-recompiler") + for d in dirs: + if not d.is_dir(): + continue + for name in names: + p = d / name + if p.is_file(): + return p + # Nested generator layouts (e.g. Debug/Release on MSVC) + for sub in d.iterdir(): + if not sub.is_dir(): + continue + for name in names: + p = sub / name + if p.is_file(): + return p + return None + + +def _recompiler_build_usable(build_dir: Path) -> bool: + cache = build_dir / "CMakeCache.txt" + if not cache.is_file(): + return False + try: + text = cache.read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + gen = "" + for line in text.splitlines(): + if line.startswith("CMAKE_GENERATOR:INTERNAL="): + gen = line.split("=", 1)[1].strip() + break + if gen.startswith("Ninja"): + return (build_dir / "build.ninja").is_file() + if "Makefiles" in gen: + return (build_dir / "Makefile").is_file() + if gen.startswith("Visual Studio"): + return any(build_dir.glob("*.sln")) + # Unknown generator — trust the cache and let cmake --build diagnose. + return True + + +def _iter_recompiler_build_dirs(fw: Path) -> list[Path]: + src = fw / "recompiler" + if not src.is_dir(): + return [] + dirs: list[Path] = [] + for name in ("build-t2", "build"): + dirs.append(src / name) + dirs.extend(sorted(src.glob("cmake-build*"))) + return dirs + + +def _any_usable_recompiler_build(fw: Path) -> bool: + return any(_recompiler_build_usable(d) for d in _iter_recompiler_build_dirs(fw)) + + +def ensure_bios_emitter( + fw: Path, + *, + game_root: Path | None = None, + build_type: str = DEFAULT_BUILD_TYPE, + dry_run: bool = False, + log: LogFn | None = None, +) -> CmdResult: + """Configure ``recompiler/build`` and build ``psxrecomp-bios`` if needed.""" + host = detect_host() + if not host.cmake: + return CmdResult(False, "cmake not found on PATH (needed to build psxrecomp-bios)") + + existing = _find_psxrecomp_bios(fw, game_root) + if existing is not None and not dry_run: + if log: + log(f"BIOS emitter ready: {existing}") + return CmdResult(True, f"BIOS emitter ready: {existing.name}") + + src = fw / "recompiler" + if not (src / "CMakeLists.txt").is_file(): + return CmdResult(False, f"recompiler sources missing under {src}") + + # Prefer an already-finished tree (matches regen_bios.sh discovery order). + build_dir = src / "build" + for cand in _iter_recompiler_build_dirs(fw): + if _recompiler_build_usable(cand): + build_dir = cand + break + + gen = default_generator(host) + cfg = [host.cmake, "-S", str(src), "-B", str(build_dir)] + if gen: + cfg.extend(["-G", gen]) + cfg.append(f"-DCMAKE_BUILD_TYPE={build_type}") + + if dry_run: + msg = "dry-run: " + " ".join(cfg) + if log: + log(msg) + log(f"dry-run: {host.cmake} --build {build_dir} --target psxrecomp-bios") + return CmdResult(True, msg) + + if not _recompiler_build_usable(build_dir): + if log: + log("Configuring recompiler for psxrecomp-bios…") + build_dir.mkdir(parents=True, exist_ok=True) + r = _run_stream(cfg, fw, log=log) + if not r.ok: + return CmdResult( + False, + "Failed to configure recompiler (needed for OpenBIOS regen)", + r.detail, + ) + + jobs = str(host.jobs) + build_cmd = [ + host.cmake, + "--build", + str(build_dir), + "--target", + "psxrecomp-bios", + "-j", + jobs, + ] + if log: + log("Building psxrecomp-bios…") + r = _run_stream(build_cmd, fw, log=log) + if not r.ok: + return CmdResult(False, "Failed to build psxrecomp-bios", r.detail) + + bios = _find_psxrecomp_bios(fw, game_root) + if bios is None: + return CmdResult( + False, + f"psxrecomp-bios not found after build under {build_dir}", + ) + return CmdResult(True, f"Built BIOS emitter: {bios.name}") + + +def _regen_bios_profile( + fw: Path, + profile_rel: str, + *, + game_root: Path | None = None, + dry_run: bool = False, + log: LogFn | None = None, +) -> CmdResult: + """Regen one BIOS profile into ``fw/generated/`` (canonical regen_bios.sh).""" + profile = fw / profile_rel + if not profile.is_file(): + return CmdResult(False, f"BIOS profile missing: {profile}") + + script = fw / "tools" / "regen_bios.sh" + bash = shutil.which("bash") or shutil.which("bash.exe") + + if script.is_file() and bash: + cmd = [bash, str(script), "--config", profile_rel] + if dry_run: + msg = "dry-run: " + " ".join(cmd) + f" (cwd={fw})" + if log: + log(msg) + return CmdResult(True, msg) + return _run_stream(cmd, fw, log=log) + + # Fallback without bash: invoke emitter + optional fingerprint helper. + r = ensure_bios_emitter(fw, game_root=game_root, dry_run=dry_run, log=log) + if not r.ok: + return r + if dry_run: + return CmdResult(True, f"dry-run: psxrecomp-bios --config {profile_rel}") + + bios = _find_psxrecomp_bios(fw, game_root) + if bios is None: + return CmdResult(False, "psxrecomp-bios missing after ensure") + (fw / "generated").mkdir(parents=True, exist_ok=True) + r = _run_stream([str(bios), "--config", profile_rel], fw, log=log) + if not r.ok: + return CmdResult(False, f"psxrecomp-bios failed for {profile_rel}", r.detail) + + # Best-effort fingerprint (staleness WARN in runtime.cmake). + fp = fw / "tools" / "bios_emitter_fingerprint.sh" + if fp.is_file() and bash: + stem = OPENBIOS_STEM if "OpenBIOS" in profile_rel else SCPH1001_STEM + try: + proc = subprocess.run( + [bash, str(fp), profile_rel], + cwd=str(fw), + capture_output=True, + text=True, + check=False, + ) + if proc.returncode == 0 and proc.stdout.strip(): + out = fw / "generated" / f"{stem}.emitter.sha" + out.write_text(proc.stdout, encoding="utf-8") + if log: + log(f"Wrote fingerprint {out.name}") + except OSError: + pass + return CmdResult(True, f"Regenerated BIOS profile {profile_rel}") + + +def ensure_bios_backends( + root: Path, + *, + force: bool = False, + include_scph1001: bool = True, + dry_run: bool = False, + log: LogFn | None = None, +) -> CmdResult: + """Ensure linkable OpenBIOS (and optional SCPH1001) under ``psxrecomp/generated``. + + OpenBIOS is bundled and MIT-licensed. SCPH1001 is regenerated only when the + retail dump ``bios/SCPH1001.BIN`` is already present beside the profile. + """ + root = root.expanduser().resolve() + fw = resolve_framework_root(root) + if fw is None: + return CmdResult(True, "No psxrecomp framework — skipping BIOS ensure") + + needed: list[tuple[str, str]] = [] + if force or not bios_backend_present(fw, OPENBIOS_STEM): + needed.append((OPENBIOS_STEM, OPENBIOS_PROFILE)) + elif log: + log(f"OpenBIOS backend already present under {fw / 'generated'}") + + scph_rom = fw / "bios" / "SCPH1001.BIN" + if include_scph1001 and scph_rom.is_file(): + if force or not bios_backend_present(fw, SCPH1001_STEM): + needed.append((SCPH1001_STEM, SCPH1001_PROFILE)) + elif log: + log("SCPH1001 backend already present") + + if not needed: + return CmdResult(True, "BIOS backends already generated") + + # Prefer regen_bios.sh (builds emitter + fingerprints). It does not configure + # the recompiler — ensure a usable tree (or a found binary) first. + script = fw / "tools" / "regen_bios.sh" + bash = shutil.which("bash") or shutil.which("bash.exe") + need_emitter_setup = not ( + _any_usable_recompiler_build(fw) or _find_psxrecomp_bios(fw, root) is not None + ) + if need_emitter_setup or not (script.is_file() and bash): + r = ensure_bios_emitter(fw, game_root=root, dry_run=dry_run, log=log) + if not r.ok: + return r + + done: list[str] = [] + for stem, profile in needed: + if log: + log(f"Regenerating {stem} via {profile}…") + r = _regen_bios_profile( + fw, profile, game_root=root, dry_run=dry_run, log=log + ) + if not r.ok: + return CmdResult( + False, + f"Failed to regenerate {stem}: {r.message}", + r.detail, + ) + if not dry_run and not bios_backend_present(fw, stem): + return CmdResult( + False, + f"Regen finished but {stem} backend still missing under {fw / 'generated'}", + ) + done.append(stem) + + return CmdResult(True, "BIOS ready: " + ", ".join(done)) + + def configure( root: Path, *, @@ -164,6 +569,7 @@ def configure( extra_args: list[str] | None = None, dry_run: bool = False, log: LogFn | None = None, + ensure_bios: bool = True, ) -> CmdResult: root = root.expanduser().resolve() host = detect_host() @@ -172,6 +578,19 @@ def configure( if not (root / "CMakeLists.txt").is_file(): return CmdResult(False, f"No CMakeLists.txt in {root}") + if ensure_bios and not _allow_no_bios(extra_args): + bios_r = ensure_bios_backends(root, dry_run=dry_run, log=log) + if not bios_r.ok: + return bios_r + if log and bios_r.message: + log(bios_r.message) + + pre = preflight_max_players(root) + if pre is not None: + if log: + log(pre.message) + return pre + bdir = Path(build_dir) if not bdir.is_absolute(): bdir = root / bdir @@ -191,7 +610,18 @@ def configure( r = _run_stream(cmd, root, log=log) if r.ok: - r = CmdResult(True, f"Configured {bdir.name} ({build_type}" + (f", {gen}" if gen else "") + ")", r.detail) + r = CmdResult( + True, + f"Configured {bdir.name} ({build_type}" + (f", {gen}" if gen else "") + ")", + r.detail, + ) + return r + hint = diagnose_configure_failure(r.detail or "", root) + if hint: + msg = f"{r.message}\n{hint}" + if log: + log(hint) + return CmdResult(False, msg, r.detail) return r diff --git a/tools/new_project_layout/project_studio/detect.py b/tools/new_project_layout/project_studio/detect.py index 7c4ab6f4..7db869ba 100644 --- a/tools/new_project_layout/project_studio/detect.py +++ b/tools/new_project_layout/project_studio/detect.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import subprocess from pathlib import Path from .models import ( @@ -52,6 +53,57 @@ def _is_real_psxrecomp_tree(path: Path) -> bool: return (path / "runtime" / "runtime.cmake").is_file() +def _git_checkout_ok(path: Path) -> bool: + """True when ``git -C path rev-parse`` sees a live work tree.""" + if not path.is_dir(): + return False + try: + proc = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return False + return proc.returncode == 0 and proc.stdout.strip() == "true" + + +def diagnose_psxrecomp_checkout(root: Path) -> str | None: + """Return a human reason when ``psxrecomp/`` is present but not a usable git checkout. + + Common after consolidating away ``psxrecomp-v4``: ``psxrecomp/.git`` still + points at ``../.git/modules/psxrecomp-v4`` (deleted), so Studio module ops + report ``checkout missing`` even though ``runtime.cmake`` is on disk. + """ + dest = root / "psxrecomp" + if not dest.is_dir(): + return None + if not _is_real_psxrecomp_tree(dest): + return None + if _git_checkout_ok(dest): + return None + + git_file = dest / ".git" + if git_file.is_file(): + text = _read(git_file).strip() + if text.lower().startswith("gitdir:"): + rel = text.split(":", 1)[1].strip() + target = (dest / rel).resolve() if rel else None + if target is not None and not target.exists(): + return ( + f"psxrecomp/.git points at missing gitdir ({rel}) — " + "broken submodule metadata (often leftover psxrecomp-v4)." + ) + return f"psxrecomp/.git gitdir is unusable ({rel or text})." + if git_file.is_dir(): + return "psxrecomp/.git exists but git rev-parse fails." + return ( + "psxrecomp/ has runtime.cmake but is not a git checkout " + "(absorbed into the parent tree or missing .git)." + ) + + def _cmake_has(text: str, needle: str) -> bool: return needle in text @@ -91,7 +143,10 @@ def audit_project(root: Path) -> AuditReport: title="Root psxrecomp/ submodule", status=CheckStatus.WARN, severity=Severity.REQUIRED, - detail="Both psxrecomp/ and psxrecomp-v4 present — consolidate to psxrecomp/.", + detail=( + "Both psxrecomp/ and psxrecomp-v4 present — keep psxrecomp/ " + "and delete the legacy psxrecomp-v4 tree." + ), fix_op="rename_psxrecomp_submodule", ) ) @@ -107,15 +162,28 @@ def audit_project(root: Path) -> AuditReport: ) ) elif has_psx: - checks.append( - CheckResult( - id="submodule_psxrecomp", - title="Root psxrecomp/ submodule", - status=CheckStatus.PASS, - severity=Severity.REQUIRED, - detail="psxrecomp/ present with runtime.cmake.", + broken = diagnose_psxrecomp_checkout(root) + if broken: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail=broken + " Repair re-clones as a real submodule.", + fix_op="repair_psxrecomp_submodule", + ) + ) + else: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail="psxrecomp/ present with runtime.cmake.", + ) ) - ) else: checks.append( CheckResult( @@ -382,14 +450,16 @@ def audit_project(root: Path) -> AuditReport: ) # VERSION + ver_text = "" if (root / "VERSION").is_file(): + ver_text = _read(root / "VERSION").strip() checks.append( CheckResult( id="version_file", title="VERSION", status=CheckStatus.PASS, severity=Severity.RECOMMENDED, - detail=_read(root / "VERSION").strip() or "(empty)", + detail=ver_text or "(empty)", ) ) else: @@ -404,6 +474,48 @@ def audit_project(root: Path) -> AuditReport: ) ) + # Lobby pin stamp vs VERSION (build tree) — drift breaks netplay lists. + stamp_hits: list[tuple[str, str]] = [] + for build in sorted(root.glob("build*")): + if not build.is_dir(): + continue + for stamp in ( + build / "psx_game_version.txt", + build / "Release" / "psx_game_version.txt", + ): + if stamp.is_file(): + stamp_hits.append( + (str(stamp.relative_to(root)).replace("\\", "/"), + _read(stamp).strip()) + ) + if ver_text and stamp_hits: + bad = [(p, s) for p, s in stamp_hits if s and s.lstrip("vV") != ver_text.lstrip("vV")] + if bad: + detail = "; ".join(f"{p}={s} (VERSION={ver_text})" for p, s in bad[:3]) + checks.append( + CheckResult( + id="version_stamp_match", + title="Lobby pin stamp", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail=( + "psx_game_version.txt disagrees with VERSION — " + "rebuild with -DPSX_GAME_VERSION matching VERSION before " + "packaging/releasing. " + detail + ), + ) + ) + else: + checks.append( + CheckResult( + id="version_stamp_match", + title="Lobby pin stamp", + status=CheckStatus.PASS, + severity=Severity.RECOMMENDED, + detail="Build stamp matches VERSION.", + ) + ) + # symbols if (root / "symbols.toml").is_file(): checks.append( @@ -527,6 +639,30 @@ def audit_project(root: Path) -> AuditReport: ) ) + # Default RetComM-themed app icon (Windows .ico + PNG) + app_ico = root / "assets" / "psxrecomp.ico" + if app_ico.is_file(): + checks.append( + CheckResult( + id="app_icon", + title="assets/psxrecomp app icon", + status=CheckStatus.PASS, + severity=Severity.OPTIONAL, + detail="assets/psxrecomp.ico present.", + ) + ) + else: + checks.append( + CheckResult( + id="app_icon", + title="assets/psxrecomp app icon", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail="Missing assets/psxrecomp.ico (RetComM-themed default pad icon).", + fix_op="ensure_app_icon", + ) + ) + # gitignore essentials gi = _read(root / ".gitignore") missing_gi = [ diff --git a/tools/new_project_layout/project_studio/naming.py b/tools/new_project_layout/project_studio/naming.py index ecefce51..0921d3d1 100644 --- a/tools/new_project_layout/project_studio/naming.py +++ b/tools/new_project_layout/project_studio/naming.py @@ -282,6 +282,10 @@ def build_token_map( ' # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"' ) + app_icon = ( + ' APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico"' + ) + return { "PROJECT_CMAKE_NAME": cmake_name, "WINDOW_TITLE": title, @@ -302,6 +306,7 @@ def build_token_map( "NETPLAY_LOBBY_URL_ARG": netplay_lobby, "WIZARD_RUNTIME_ARG": wizard_runtime, "BOXART_CMAKE_ARG": boxart, + "APP_ICON_CMAKE_ARG": app_icon, "NETPLAY_CMAKE_BLOCK": netplay_block, "WIZARD_CMAKE_BLOCK": wizard_block, "RECOMP_UI_CMAKE_BLOCK": recomp_ui_block, diff --git a/tools/new_project_layout/project_studio/ops.py b/tools/new_project_layout/project_studio/ops.py index e951ec03..2fa1e2a5 100644 --- a/tools/new_project_layout/project_studio/ops.py +++ b/tools/new_project_layout/project_studio/ops.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import shutil import subprocess import sys @@ -20,7 +21,7 @@ from .naming import ( window_title_from_cmake, window_title_from_name, ) -from .paths import ci_setup_release_template, templates_dir, toolkit_dir +from .paths import ci_setup_release_template, psxrecomp_root_from_toolkit, templates_dir, toolkit_dir def _run(cmd: list[str], cwd: Path, dry_run: bool) -> tuple[bool, str]: @@ -70,6 +71,100 @@ def _is_real_psxrecomp(path: Path) -> bool: return (path / "runtime" / "runtime.cmake").is_file() +def _strip_gitmodules_submodule(text: str, name: str) -> str: + """Remove a ``[submodule "name"]`` block (and its indented keys) from .gitmodules.""" + lines = text.splitlines(keepends=True) + out: list[str] = [] + skipping = False + header = f'[submodule "{name}"]' + for line in lines: + stripped = line.strip() + if stripped.startswith("[submodule ") and stripped.endswith("]"): + skipping = stripped == header + if skipping: + continue + if skipping: + # Indented key = value lines belong to the skipped block. + if line[:1] in (" ", "\t") or stripped == "": + continue + skipping = False + if not skipping: + out.append(line) + return "".join(out) + + +def _remove_psxrecomp_v4(root: Path, options: MigrateOptions) -> tuple[bool, str, list[str]]: + """Drop legacy ``psxrecomp-v4`` (submodule or plain tree). Prefer keeping ``psxrecomp/``.""" + v4 = root / "psxrecomp-v4" + changed: list[str] = [] + if not v4.exists(): + return True, "psxrecomp-v4 already absent", changed + + notes: list[str] = [] + if options.dry_run: + return True, "dry-run: remove psxrecomp-v4 (deinit/rm or rmtree)", ["psxrecomp-v4"] + + # Registered submodule: deinit + git rm cleans the gitlink and worktree. + gm = root / ".gitmodules" + gm_text = gm.read_text(encoding="utf-8") if gm.is_file() else "" + registered = 'path = psxrecomp-v4' in gm_text or '[submodule "psxrecomp-v4"]' in gm_text + + if registered or (v4 / ".git").exists(): + ok, out = _run( + ["git", "submodule", "deinit", "-f", "psxrecomp-v4"], root, False + ) + if out: + notes.append(out if ok else f"deinit: {out}") + ok_rm, out_rm = _run(["git", "rm", "-f", "psxrecomp-v4"], root, False) + if ok_rm: + notes.append(out_rm or "git rm psxrecomp-v4") + changed.append("psxrecomp-v4") + else: + # Fallback: force-remove the directory if git rm refused (untracked tree). + notes.append(f"git rm: {out_rm}") + try: + if v4.is_symlink() or v4.is_file(): + v4.unlink() + elif v4.is_dir(): + shutil.rmtree(v4) + changed.append("psxrecomp-v4") + notes.append("removed psxrecomp-v4 via rmtree") + except OSError as exc: + return False, f"could not remove psxrecomp-v4: {exc}", changed + else: + try: + if v4.is_symlink() or v4.is_file(): + v4.unlink() + elif v4.is_dir(): + shutil.rmtree(v4) + changed.append("psxrecomp-v4") + notes.append("removed untracked psxrecomp-v4/") + except OSError as exc: + return False, f"could not remove psxrecomp-v4: {exc}", changed + + # Ensure .gitmodules no longer lists the legacy path (git rm usually does this). + if gm.is_file(): + text = gm.read_text(encoding="utf-8") + new = _strip_gitmodules_submodule(text, "psxrecomp-v4") + new = new.replace("path = psxrecomp-v4\n", "") + if new != text: + _write(gm, new, False) + if ".gitmodules" not in changed: + changed.append(".gitmodules") + notes.append("stripped psxrecomp-v4 from .gitmodules") + + # Drop stale git modules metadata if present. + mod_meta = root / ".git" / "modules" / "psxrecomp-v4" + if mod_meta.is_dir(): + try: + shutil.rmtree(mod_meta) + notes.append("removed .git/modules/psxrecomp-v4") + except OSError: + pass + + return True, "; ".join(n for n in notes if n) or "removed psxrecomp-v4", changed + + def _resolve_tokens(root: Path, options: MigrateOptions) -> dict[str, str]: name = options.project_name or infer_project_name(root) boot = ( @@ -82,6 +177,12 @@ def _resolve_tokens(root: Path, options: MigrateOptions) -> dict[str, str]: detected = players_from_cmake(root / "CMakeLists.txt") if detected is not None: players = detected + else: + from .naming import players_from_game_toml + + detected = players_from_game_toml(root / "game.toml") + if detected is not None: + players = detected title = options.window_title or window_title_from_cmake(root / "CMakeLists.txt") if not title: # Prefer game.toml window_title @@ -124,11 +225,36 @@ def _resolve_tokens(root: Path, options: MigrateOptions) -> dict[str, str]: def op_rename_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyResult: + """Consolidate on ``psxrecomp/``: promote v4 if needed, always delete leftover v4.""" v4 = root / "psxrecomp-v4" dest = root / "psxrecomp" changed: list[str] = [] + parts: list[str] = [] if not v4.exists() and _is_real_psxrecomp(dest): + # Still scrub orphan .gitmodules / CMake references to the old name. + scrubbed: list[str] = [] + gm = root / ".gitmodules" + if gm.is_file(): + text = gm.read_text(encoding="utf-8") + new = _strip_gitmodules_submodule(text, "psxrecomp-v4") + if new != text: + _write(gm, new, options.dry_run) + scrubbed.append(".gitmodules") + cmake = root / "CMakeLists.txt" + if cmake.is_file(): + text = cmake.read_text(encoding="utf-8") + new = text.replace("psxrecomp-v4", "psxrecomp") + if new != text: + _write(cmake, new, options.dry_run) + scrubbed.append("CMakeLists.txt") + if scrubbed: + return ApplyResult( + "rename_psxrecomp_submodule", + True, + "Already using psxrecomp/; scrubbed leftover psxrecomp-v4 refs", + scrubbed, + ) return ApplyResult("rename_psxrecomp_submodule", True, "Already using psxrecomp/", []) if not v4.exists(): @@ -136,43 +262,71 @@ def op_rename_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyR "rename_psxrecomp_submodule", False, "psxrecomp-v4 not found", [] ) - if dest.exists() and not _is_real_psxrecomp(dest): - stub_bak = root / "psxrecomp.stub.bak" - if options.dry_run: - msg = f"dry-run: move stub {dest} → {stub_bak}, then {v4} → {dest}" - else: - if stub_bak.exists(): - shutil.rmtree(stub_bak) - dest.rename(stub_bak) - changed.append(str(stub_bak.relative_to(root))) - msg = f"Moved stub psxrecomp/ → {stub_bak.name}" - elif dest.exists() and _is_real_psxrecomp(dest): - # Real tree already — drop v4 submodule entry if possible - msg = "psxrecomp/ already real; will update .gitmodules and leave v4 for manual removal" - else: - msg = "" - - if not dest.exists() or not _is_real_psxrecomp(dest): - ok, out = _run(["git", "mv", "psxrecomp-v4", "psxrecomp"], root, options.dry_run) + # Case A: real psxrecomp/ already — keep it, remove legacy v4 entirely. + if dest.exists() and _is_real_psxrecomp(dest): + ok, out, ch = _remove_psxrecomp_v4(root, options) + changed.extend(ch) + parts.append(out) if not ok: - # Fallback without git mv + return ApplyResult("rename_psxrecomp_submodule", False, out, changed) + # Removing v4 often deletes .git/modules/psxrecomp-v4 while psxrecomp/.git + # still points there — repair into a real submodule checkout. + if not options.dry_run and not _psxrecomp_git_ok(dest): + fix = op_repair_psxrecomp_submodule(root, options) + changed.extend(fix.changed_paths) + parts.append(fix.message) + if not fix.ok: + return ApplyResult( + "rename_psxrecomp_submodule", False, "; ".join(parts), changed + ) + else: + # Case B: stub or missing psxrecomp/ — promote v4 into place. + if dest.exists() and not _is_real_psxrecomp(dest): + stub_bak = root / "psxrecomp.stub.bak" if options.dry_run: - out = "dry-run: shutil.move psxrecomp-v4 → psxrecomp" + parts.append(f"dry-run: move stub {dest} → {stub_bak}") else: - shutil.move(str(v4), str(dest)) - out = "Moved psxrecomp-v4 → psxrecomp (without git mv)" - changed.append("psxrecomp") - msg = (msg + "; " if msg else "") + out + if stub_bak.exists(): + shutil.rmtree(stub_bak) + dest.rename(stub_bak) + changed.append(str(stub_bak.relative_to(root))) + parts.append(f"Moved stub psxrecomp/ → {stub_bak.name}") - # Rewrite .gitmodules + if not dest.exists() or not _is_real_psxrecomp(dest): + ok, out = _run( + ["git", "mv", "psxrecomp-v4", "psxrecomp"], root, options.dry_run + ) + if not ok: + if options.dry_run: + out = "dry-run: shutil.move psxrecomp-v4 → psxrecomp" + else: + shutil.move(str(v4), str(dest)) + out = "Moved psxrecomp-v4 → psxrecomp (without git mv)" + changed.append("psxrecomp") + parts.append(out) + + # After promote, any leftover v4 path must still go (rare dual gitlink). + if v4.exists(): + ok, out, ch = _remove_psxrecomp_v4(root, options) + changed.extend(ch) + parts.append(out) + if not ok: + return ApplyResult("rename_psxrecomp_submodule", False, out, changed) + + # Rewrite .gitmodules: rename v4 → psxrecomp if still named v4, then strip orphans. gm = root / ".gitmodules" if gm.is_file(): text = gm.read_text(encoding="utf-8") new = text.replace('path = psxrecomp-v4', "path = psxrecomp") new = new.replace('[submodule "psxrecomp-v4"]', '[submodule "psxrecomp"]') + # If both names somehow remain, drop the legacy block. + if '[submodule "psxrecomp-v4"]' in new or "path = psxrecomp-v4" in new: + new = _strip_gitmodules_submodule(new, "psxrecomp-v4") if new != text: _write(gm, new, options.dry_run) - changed.append(".gitmodules") + if ".gitmodules" not in changed: + changed.append(".gitmodules") + parts.append("updated .gitmodules") # Patch CMakeLists PSXRECOMP_ROOT path if present cmake = root / "CMakeLists.txt" @@ -182,13 +336,51 @@ def op_rename_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyR if new != text: _write(cmake, new, options.dry_run) changed.append("CMakeLists.txt") + parts.append("rewrote CMakeLists psxrecomp-v4 → psxrecomp") - return ApplyResult("rename_psxrecomp_submodule", True, msg or "Renamed submodule", changed) + msg = "; ".join(p for p in parts if p) or "Consolidated on psxrecomp/" + return ApplyResult("rename_psxrecomp_submodule", True, msg, changed) + + +def _gitmodules_psxrecomp_url_branch(root: Path) -> tuple[str, str]: + """Return (url, branch) for the game's psxrecomp submodule entry.""" + url = "https://github.com/mstan/psxrecomp.git" + branch = "master" + gm = root / ".gitmodules" + if not gm.is_file(): + return url, branch + text = gm.read_text(encoding="utf-8") + for block in re.split(r"(?=\[submodule)", text): + if not re.search(r"^\s*path\s*=\s*psxrecomp\s*$", block, re.M): + continue + m = re.search(r"^\s*url\s*=\s*(\S+)", block, re.M) + if m: + url = m.group(1) + m = re.search(r"^\s*branch\s*=\s*(\S+)", block, re.M) + if m: + branch = m.group(1) + break + return url, branch + + +def _psxrecomp_git_ok(path: Path) -> bool: + if not path.is_dir(): + return False + ok, out = _run( + ["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"], + path.parent, + False, + ) + return ok and out.strip() == "true" def op_ensure_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyResult: - if _is_real_psxrecomp(root / "psxrecomp"): + dest = root / "psxrecomp" + if _is_real_psxrecomp(dest) and _psxrecomp_git_ok(dest): return ApplyResult("ensure_psxrecomp_submodule", True, "Already present", []) + if _is_real_psxrecomp(dest) and not _psxrecomp_git_ok(dest): + # Tree on disk but broken gitdir — repair, don't no-op. + return op_repair_psxrecomp_submodule(root, options) url = "https://github.com/mstan/psxrecomp.git" ok, out = _run( ["git", "submodule", "add", "-b", "master", url, "psxrecomp"], @@ -210,6 +402,162 @@ def op_ensure_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyR ) +def op_repair_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyResult: + """Re-establish ``psxrecomp/`` as a real git submodule after a broken gitdir. + + Moves the on-disk tree aside (``psxrecomp.broken.bak``), clears absorbed + index entries, then ``submodule update --init`` / ``submodule add``. + """ + dest = root / "psxrecomp" + changed: list[str] = [] + parts: list[str] = [] + + if not dest.is_dir(): + return ApplyResult( + "repair_psxrecomp_submodule", + False, + "psxrecomp/ missing — use ensure_psxrecomp_submodule instead", + [], + ) + + if _is_real_psxrecomp(dest) and _psxrecomp_git_ok(dest): + return ApplyResult( + "repair_psxrecomp_submodule", + True, + "psxrecomp/ checkout already healthy", + [], + ) + + url, branch = _gitmodules_psxrecomp_url_branch(root) + if options.dry_run: + return ApplyResult( + "repair_psxrecomp_submodule", + True, + f"dry-run: move psxrecomp/ aside, re-init submodule from {url}@{branch}", + ["psxrecomp", ".gitmodules"], + ) + + # Ensure .gitmodules lists the canonical submodule before re-init. + gm = root / ".gitmodules" + gm_text = gm.read_text(encoding="utf-8") if gm.is_file() else "" + if 'path = psxrecomp' not in gm_text and 'path=psxrecomp' not in gm_text: + block = ( + f'[submodule "psxrecomp"]\n' + f"\tpath = psxrecomp\n" + f"\turl = {url}\n" + f"\tbranch = {branch}\n" + ) + _write(gm, (gm_text.rstrip() + "\n\n" if gm_text.strip() else "") + block, False) + changed.append(".gitmodules") + parts.append("wrote .gitmodules psxrecomp entry") + else: + # Make sure url/branch are present on the existing block (best-effort). + if url and url not in gm_text: + parts.append(f"using url {url}") + + bak = root / "psxrecomp.broken.bak" + if bak.exists(): + n = 1 + while (root / f"psxrecomp.broken.bak.{n}").exists(): + n += 1 + bak = root / f"psxrecomp.broken.bak.{n}" + + # Drop absorbed tree from the index (mode 100644 flood) so a gitlink can land. + ok_rm, out_rm = _run(["git", "rm", "-rf", "--cached", "psxrecomp"], root, False) + if ok_rm: + parts.append(out_rm or "git rm --cached psxrecomp") + elif out_rm: + parts.append(f"git rm --cached: {out_rm}") + + try: + dest.rename(bak) + changed.append(bak.name) + parts.append(f"moved broken tree → {bak.name}") + except OSError as exc: + return ApplyResult( + "repair_psxrecomp_submodule", + False, + f"could not move broken psxrecomp/: {exc}", + changed, + ) + + # Prefer update --init when .gitmodules already declares the path. + _run(["git", "submodule", "sync", "--", "psxrecomp"], root, False) + ok_up, out_up = _run( + [ + "git", + "submodule", + "update", + "--init", + "--force", + "--recursive", + "psxrecomp", + ], + root, + False, + ) + if ok_up and _psxrecomp_git_ok(dest) and _is_real_psxrecomp(dest): + changed.append("psxrecomp") + parts.append(out_up or "submodule update --init psxrecomp") + return ApplyResult( + "repair_psxrecomp_submodule", + True, + "; ".join(parts), + changed, + ) + + # Fresh add (works when no gitlink was ever recorded). + if dest.exists(): + try: + shutil.rmtree(dest) + except OSError: + pass + ok_add, out_add = _run( + ["git", "submodule", "add", "-b", branch, "--force", url, "psxrecomp"], + root, + False, + ) + if not ok_add: + # Restore bak so the user keeps a buildable tree. + if bak.exists() and not dest.exists(): + try: + bak.rename(dest) + parts.append("restored broken tree after failed add") + except OSError: + pass + return ApplyResult( + "repair_psxrecomp_submodule", + False, + f"submodule add failed: {out_add}; update: {out_up}", + changed, + ) + + _run( + ["git", "submodule", "update", "--init", "--recursive", "psxrecomp"], + root, + False, + ) + if not (_psxrecomp_git_ok(dest) and _is_real_psxrecomp(dest)): + return ApplyResult( + "repair_psxrecomp_submodule", + False, + "re-init finished but psxrecomp/ still not a healthy checkout", + changed, + ) + + changed.append("psxrecomp") + if ".gitmodules" not in changed: + changed.append(".gitmodules") + parts.append(out_add or f"submodule add {url}") + parts.append(f"old tree kept at {bak.name} (delete when satisfied)") + return ApplyResult( + "repair_psxrecomp_submodule", + True, + "; ".join(parts), + changed, + ) + + def op_ensure_recomp_ui_submodule(root: Path, options: MigrateOptions) -> ApplyResult: if (root / "recomp-ui").is_dir() and ( (root / "recomp-ui" / "CMakeLists.txt").is_file() @@ -258,6 +606,13 @@ def op_emit_codegen_setup(root: Path, options: MigrateOptions) -> ApplyResult: def op_emit_version(root: Path, options: MigrateOptions) -> ApplyResult: + """Write starter VERSION only when missing. + + Never bump VERSION here — a newer VERSION without a rebuild leaves + PSX_GAME_VERSION sticky in CMakeCache / an old binary, which breaks + netplay lobby list filters. Releases must pin VERSION then rebuild with + -DPSX_GAME_VERSION= (CI / package_setup_host enforce the stamp). + """ dst = root / "VERSION" if dst.is_file() and not options.force: return ApplyResult("emit_version", True, "VERSION already exists", []) @@ -434,6 +789,58 @@ def op_emit_boxart_stub(root: Path, options: MigrateOptions) -> ApplyResult: ) +def _app_icon_source_dir(root: Path) -> Path | None: + """Prefer game submodule assets, then toolkit-adjacent psxrecomp checkout.""" + for cand in ( + root / "psxrecomp" / "assets", + root / "psxrecomp-v4" / "assets", + ): + if (cand / "psxrecomp.ico").is_file() or (cand / "psxrecomp.svg").is_file(): + return cand + pr = psxrecomp_root_from_toolkit() + if pr is not None: + cand = pr / "assets" + if (cand / "psxrecomp.ico").is_file() or (cand / "psxrecomp.svg").is_file(): + return cand + return None + + +def op_ensure_app_icon(root: Path, options: MigrateOptions) -> ApplyResult: + dest_dir = root / "assets" + dest_ico = dest_dir / "psxrecomp.ico" + if dest_ico.is_file() and not options.force: + return ApplyResult("ensure_app_icon", True, "assets/psxrecomp.ico already present", []) + src_dir = _app_icon_source_dir(root) + if src_dir is None: + return ApplyResult( + "ensure_app_icon", + False, + "No psxrecomp/assets icon source (add psxrecomp submodule or update toolkit)", + [], + ) + names = [n for n in ("psxrecomp.svg", "psxrecomp.png", "psxrecomp.ico") if (src_dir / n).is_file()] + if not names: + return ApplyResult("ensure_app_icon", False, f"Empty icon source: {src_dir}", []) + if options.dry_run: + return ApplyResult( + "ensure_app_icon", + True, + f"dry-run: copy {', '.join(names)} → assets/", + [f"assets/{n}" for n in names], + ) + dest_dir.mkdir(parents=True, exist_ok=True) + changed: list[str] = [] + for name in names: + shutil.copy2(src_dir / name, dest_dir / name) + changed.append(f"assets/{name}") + return ApplyResult( + "ensure_app_icon", + True, + f"Installed app icon from {src_dir.name}/ ({', '.join(names)})", + changed, + ) + + def op_rewrite_cmake_setup_host(root: Path, options: MigrateOptions) -> ApplyResult: tokens = _resolve_tokens(root, options) # Force wizard for setup-host policy @@ -676,6 +1083,7 @@ def op_record_framework_pins(root: Path, options: MigrateOptions) -> ApplyResult _OPS = { "rename_psxrecomp_submodule": op_rename_psxrecomp_submodule, "ensure_psxrecomp_submodule": op_ensure_psxrecomp_submodule, + "repair_psxrecomp_submodule": op_repair_psxrecomp_submodule, "ensure_recomp_ui_submodule": op_ensure_recomp_ui_submodule, "emit_codegen_setup": op_emit_codegen_setup, "emit_version": op_emit_version, @@ -685,6 +1093,7 @@ _OPS = { "emit_mods_preloaded": op_emit_mods_preloaded, "relocate_boxart": op_relocate_boxart, "emit_boxart_stub": op_emit_boxart_stub, + "ensure_app_icon": op_ensure_app_icon, "rewrite_cmake_setup_host": op_rewrite_cmake_setup_host, "emit_packager": op_emit_packager, "emit_ci_workflow": op_emit_ci_workflow, diff --git a/tools/new_project_layout/project_studio/plan.py b/tools/new_project_layout/project_studio/plan.py index cdb7783b..8270012c 100644 --- a/tools/new_project_layout/project_studio/plan.py +++ b/tools/new_project_layout/project_studio/plan.py @@ -11,6 +11,7 @@ from .models import AuditReport, LayoutClass, MigrateOptions, Plan, PlanStep # Topological order for apply. Ops not listed are appended at the end. _OP_ORDER = [ "rename_psxrecomp_submodule", + "repair_psxrecomp_submodule", "ensure_psxrecomp_submodule", "ensure_recomp_ui_submodule", "emit_codegen_setup", @@ -21,6 +22,7 @@ _OP_ORDER = [ "emit_mods_preloaded", "relocate_boxart", "emit_boxart_stub", + "ensure_app_icon", "rewrite_cmake_setup_host", "emit_packager", "emit_ci_workflow", @@ -30,7 +32,8 @@ _OP_ORDER = [ ] _OP_TITLES = { - "rename_psxrecomp_submodule": "Rename psxrecomp-v4 → psxrecomp", + "rename_psxrecomp_submodule": "Consolidate on psxrecomp/ (remove psxrecomp-v4)", + "repair_psxrecomp_submodule": "Repair broken psxrecomp/ git checkout", "ensure_psxrecomp_submodule": "Add psxrecomp submodule", "ensure_recomp_ui_submodule": "Add recomp-ui submodule", "emit_codegen_setup": "Emit codegen_setup.c / .h", @@ -41,6 +44,7 @@ _OP_TITLES = { "emit_mods_preloaded": "Stub mods/preloaded catalog", "relocate_boxart": "Relocate boxart → launcher_assets/", "emit_boxart_stub": "Create launcher_assets stub dir", + "ensure_app_icon": "Install assets/psxrecomp app icon", "rewrite_cmake_setup_host": "Rewrite CMakeLists.txt (setup-host)", "emit_packager": "Emit scripts/package_setup_release.sh", "emit_ci_workflow": "Emit setup-host release.yml", diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 3fc8a08a..2b3db0e4 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -304,6 +304,8 @@ set(PSX_SETUP_WIZARD ON CACHE BOOL Set-Content -Encoding UTF8 -Path $BoxartBlockFile -Value ' # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"' $HasBoxart = $false +$AppIconBlockFile = Join-Path $env:TEMP "psxrecomp_app_icon_block.cmake" +Set-Content -Encoding UTF8 -Path $AppIconBlockFile -Value ' APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico"' function Fill-Template([string]$src, [string]$dst) { python $FillTokens $src $dst ` @@ -326,6 +328,7 @@ function Fill-Template([string]$src, [string]$dst) { --set "NETPLAY_LOBBY_URL_ARG=$NetplayLobbyUrlArg" ` --set "WIZARD_RUNTIME_ARG=$WizardRuntimeArg" ` --set-file "BOXART_CMAKE_ARG=$BoxartBlockFile" ` + --set-file "APP_ICON_CMAKE_ARG=$AppIconBlockFile" ` --set-file "NETPLAY_CMAKE_BLOCK=$NetplayBlockFile" ` --set-file "WIZARD_CMAKE_BLOCK=$WizardBlockFile" ` --set-file "RECOMP_UI_CMAKE_BLOCK=$RecompUiBlockFile" ` @@ -366,6 +369,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $Root "launcher_assets\img" New-Item -ItemType Directory -Force -Path (Join-Path $Root "scripts") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "tools") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "mods\preloaded\packages") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $Root "assets") | Out-Null # Empty mod catalog tree (runtime copies mods/preloaded → beside the exe as mods/). @' # Preloaded mods @@ -392,8 +396,24 @@ if ($useRecompUi) { } git submodule update --init --recursive +# RetComM-themed default app icon (Windows .ico + PNG for packaging). +$iconSrc = Join-Path $Root "psxrecomp\assets" +$iconDst = Join-Path $Root "assets" +if (Test-Path $iconSrc) { + New-Item -ItemType Directory -Force -Path $iconDst | Out-Null + foreach ($name in @("psxrecomp.svg", "psxrecomp.png", "psxrecomp.ico")) { + $src = Join-Path $iconSrc $name + if (Test-Path $src) { + Copy-Item -Force $src (Join-Path $iconDst $name) + } + } +} + git -C psxrecomp checkout --detach -q HEAD git add psxrecomp +if (Test-Path $iconDst) { + git add assets 2>$null +} if ($useRecompUi) { git -C recomp-ui checkout --detach -q HEAD git add recomp-ui diff --git a/tools/new_project_layout/setup_project.sh b/tools/new_project_layout/setup_project.sh index 256c1c64..8f9ea735 100755 --- a/tools/new_project_layout/setup_project.sh +++ b/tools/new_project_layout/setup_project.sh @@ -458,9 +458,10 @@ fi NETPLAY_BLOCK_FILE=$(mktemp) WIZARD_BLOCK_FILE=$(mktemp) BOXART_BLOCK_FILE=$(mktemp) +APP_ICON_BLOCK_FILE=$(mktemp) RECOMP_UI_BLOCK_FILE=$(mktemp) CODEGEN_ARG_FILE=$(mktemp) -trap 'rm -f "$NETPLAY_BLOCK_FILE" "$WIZARD_BLOCK_FILE" "$BOXART_BLOCK_FILE" "$RECOMP_UI_BLOCK_FILE" "$CODEGEN_ARG_FILE"' EXIT +trap 'rm -f "$NETPLAY_BLOCK_FILE" "$WIZARD_BLOCK_FILE" "$BOXART_BLOCK_FILE" "$APP_ICON_BLOCK_FILE" "$RECOMP_UI_BLOCK_FILE" "$CODEGEN_ARG_FILE"' EXIT if [ "$ENABLE_RECOMP_UI" -eq 1 ]; then printf '%s\n' '# recomp-ui submodule present (PSX_RECOMP_UI defaults ON).' \ @@ -512,6 +513,8 @@ fi printf '%s\n' ' # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"' \ >"$BOXART_BLOCK_FILE" HAS_BOXART=0 +printf '%s\n' ' APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico"' \ + >"$APP_ICON_BLOCK_FILE" fill_template() { src=$1 @@ -536,6 +539,7 @@ fill_template() { --set "NETPLAY_LOBBY_URL_ARG=$NETPLAY_LOBBY_URL_ARG" \ --set "WIZARD_RUNTIME_ARG=$WIZARD_RUNTIME_ARG" \ --set-file "BOXART_CMAKE_ARG=$BOXART_BLOCK_FILE" \ + --set-file "APP_ICON_CMAKE_ARG=$APP_ICON_BLOCK_FILE" \ --set-file "NETPLAY_CMAKE_BLOCK=$NETPLAY_BLOCK_FILE" \ --set-file "WIZARD_CMAKE_BLOCK=$WIZARD_BLOCK_FILE" \ --set-file "RECOMP_UI_CMAKE_BLOCK=$RECOMP_UI_BLOCK_FILE" \ @@ -574,7 +578,7 @@ fill_template "$TEMPLATE_DIR/VERSION.in" "$ROOT/VERSION" fill_template "$TEMPLATE_DIR/README.md.in" "$ROOT/README.md" fill_template "$TEMPLATE_DIR/symbols.toml.in" "$ROOT/symbols.toml" mkdir -p "$ROOT/seeds" "$ROOT/launcher_assets/img" "$ROOT/scripts" "$ROOT/tools" \ - "$ROOT/mods/preloaded/packages" + "$ROOT/mods/preloaded/packages" "$ROOT/assets" cp "$SCRIPT_DIR/sync_symbols.py" "$ROOT/tools/sync_symbols.py" chmod +x "$ROOT/tools/sync_symbols.py" # Empty mod catalog tree (runtime copies mods/preloaded → beside the exe as mods/). @@ -602,8 +606,21 @@ if [ "$ENABLE_RECOMP_UI" -eq 1 ]; then fi git submodule update --init --recursive +# RetComM-themed default app icon (Windows .ico + PNG for packaging). +if [ -d psxrecomp/assets ]; then + mkdir -p "$ROOT/assets" + for _icon in psxrecomp.svg psxrecomp.png psxrecomp.ico; do + if [ -f "psxrecomp/assets/$_icon" ]; then + cp -f "psxrecomp/assets/$_icon" "$ROOT/assets/$_icon" + fi + done +fi + git -C psxrecomp checkout --detach -q HEAD git add psxrecomp +if [ -d "$ROOT/assets" ]; then + git add assets 2>/dev/null || true +fi if [ "$ENABLE_RECOMP_UI" -eq 1 ]; then git -C recomp-ui checkout --detach -q HEAD git add recomp-ui diff --git a/tools/new_project_layout/templates/CMakeLists.txt.in b/tools/new_project_layout/templates/CMakeLists.txt.in index 24db61d1..21406e0d 100644 --- a/tools/new_project_layout/templates/CMakeLists.txt.in +++ b/tools/new_project_layout/templates/CMakeLists.txt.in @@ -21,6 +21,7 @@ psxrecomp_add_game_runtime(psx-runtime DEFAULT_GAME_CONFIG_PATH "game.toml" MAX_PLAYERS @PLAYERS@ @BOXART_CMAKE_ARG@ +@APP_ICON_CMAKE_ARG@ GEN_MARKER "generated/@BOOT_EXE@_dispatch.c" GEN_FULL_GLOB "generated/@BOOT_EXE@_full_*.c" @CODEGEN_SETUP_ARG@ diff --git a/tools/new_project_layout/templates/README.md.in b/tools/new_project_layout/templates/README.md.in index 29b33f50..539116c3 100644 --- a/tools/new_project_layout/templates/README.md.in +++ b/tools/new_project_layout/templates/README.md.in @@ -22,6 +22,8 @@ You must own the original game. Disc images under `disc/` are gitignored and must never be committed. Retail BIOS dumps are not redistributed; OpenBIOS is used for Generate unless you supply your own SCPH locally. +Default app icon: `assets/psxrecomp.ico` (and `.png` / `.svg`) — RetComM-themed controller mark from `psxrecomp/assets/`. Windows builds embed it via `APP_ICON`. + Optional box art under `launcher_assets/img/` may come from [libretro-thumbnails](https://github.com/libretro-thumbnails/libretro-thumbnails) (`Named_Boxarts`); see `BOXART_SOURCE.txt` when present. diff --git a/tools/package_setup_host.sh b/tools/package_setup_host.sh index ca2ded63..c08d9d1c 100755 --- a/tools/package_setup_host.sh +++ b/tools/package_setup_host.sh @@ -20,10 +20,14 @@ # [--embed-toolchain] # optional: copy PSXRECOMP_TOOLCHAIN_DIR into zip # # Env: -# RELEASE_VERSION / / VERSION file +# RELEASE_VERSION / / VERSION file (must match binary stamp) # PSXRECOMP_TOOLCHAIN_DIR | TOOLCHAIN_DIR | BPE_TOOLCHAIN_DIR (only with --embed-toolchain) # PSXRECOMP_EMBED_TOOLCHAIN=1 same as --embed-toolchain # PSXRECOMP_RUNTIME_BIN_DIR | BPE_RUNTIME_BIN_DIR (Windows MinGW DLL search) +# +# Lobby pin: the host exe must have been built with current runtime.cmake so +# $/psx_game_version.txt exists. This script refuses to ship a +# VERSION file that disagrees with that stamp (netplay list filter bug). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -108,35 +112,28 @@ if [[ ${#PROJECT_FILES[@]} -eq 0 && ${#PROJECT_DIRS[@]} -eq 0 ]]; then done fi -# Resolve version. -VERSION="" +# Resolve *requested* version from env / VERSION file (may be empty until stamp). +REQUESTED="" if [[ -n "${VERSION_ENV}" ]]; then - VERSION="${!VERSION_ENV:-}" + REQUESTED="${!VERSION_ENV:-}" fi -if [[ -z "${VERSION}" && -n "${RELEASE_VERSION:-}" ]]; then - VERSION="${RELEASE_VERSION}" +if [[ -z "${REQUESTED}" && -n "${RELEASE_VERSION:-}" ]]; then + REQUESTED="${RELEASE_VERSION}" fi -if [[ -z "${VERSION}" && -n "${BPE_RELEASE_VERSION:-}" ]]; then - VERSION="${BPE_RELEASE_VERSION}" +if [[ -z "${REQUESTED}" && -n "${BPE_RELEASE_VERSION:-}" ]]; then + REQUESTED="${BPE_RELEASE_VERSION}" fi -if [[ -z "${VERSION}" && -f "${ROOT}/VERSION" ]]; then - VERSION="$(tr -d '[:space:]' <"${ROOT}/VERSION")" +if [[ -z "${REQUESTED}" && -f "${ROOT}/VERSION" ]]; then + REQUESTED="$(tr -d '[:space:]' <"${ROOT}/VERSION")" fi -VERSION="$(printf '%s' "${VERSION}" | tr -d '[:space:]')" -VERSION="${VERSION#v}" -if [[ -z "${VERSION}" ]]; then - echo "error: VERSION empty (set ${VERSION_ENV} / RELEASE_VERSION or write VERSION)" >&2 - exit 1 -fi -printf '%s\n' "${VERSION}" >"${ROOT}/VERSION" +REQUESTED="$(printf '%s' "${REQUESTED}" | tr -d '[:space:]')" +REQUESTED="${REQUESTED#v}" DIST="${ROOT}/dist" STAGE="${DIST}/stage-setup-${ARTIFACT}" -ZIP_NAME="${ZIP_PREFIX}-${VERSION}-${ARTIFACT}.zip" rm -rf "${STAGE}" mkdir -p "${STAGE}" "${DIST}" -rm -f "${DIST}/${ZIP_NAME}" EXE="" for cand in \ @@ -155,7 +152,51 @@ if [[ -z "${EXE}" ]]; then exit 1 fi +# Authoritative lobby pin = stamp written at compile time next to the exe +# (see runtime.cmake file(GENERATE) psx_game_version.txt). Never invent a +# newer VERSION for the zip than what was baked into PSX_GAME_VERSION. +normalize_ver() { + local v + v="$(printf '%s' "${1:-}" | tr -d '[:space:]')" + v="${v#v}" + printf '%s' "${v}" +} +BUILT="" +for stamp in \ + "$(dirname "${EXE}")/psx_game_version.txt" \ + "${BUILD_DIR}/psx_game_version.txt" \ + "${BUILD_DIR}/Release/psx_game_version.txt" +do + if [[ -f "${stamp}" ]]; then + BUILT="$(normalize_ver "$(cat "${stamp}")")" + echo "lobby pin stamp: ${stamp} -> ${BUILT}" + break + fi +done +if [[ -z "${BUILT}" ]]; then + echo "error: missing psx_game_version.txt next to ${EXE}" >&2 + echo " Rebuild with current psxrecomp runtime.cmake so the lobby pin is stamped." >&2 + echo " Refusing to package (VERSION file alone can drift from PSX_GAME_VERSION)." >&2 + exit 1 +fi +if [[ -n "${REQUESTED}" && "${REQUESTED}" != "${BUILT}" ]]; then + echo "error: RELEASE_VERSION/VERSION=${REQUESTED} but binary stamp=${BUILT}" >&2 + echo " Rebuild with -DPSX_GAME_VERSION=${REQUESTED} (or pin VERSION then reconfigure)," >&2 + echo " then re-run this packager. Shipping mismatched pins breaks netplay lobby lists." >&2 + exit 1 +fi +VERSION="${BUILT}" +printf '%s\n' "${VERSION}" >"${ROOT}/VERSION" +ZIP_NAME="${ZIP_PREFIX}-${VERSION}-${ARTIFACT}.zip" +rm -f "${DIST}/${ZIP_NAME}" + cp -a "${EXE}" "${STAGE}/" +# Ship the stamp beside the exe so installers / RetComM can prefer it over VERSION. +if [[ -f "$(dirname "${EXE}")/psx_game_version.txt" ]]; then + cp -a "$(dirname "${EXE}")/psx_game_version.txt" "${STAGE}/psx_game_version.txt" +else + printf '%s\n' "${VERSION}" >"${STAGE}/psx_game_version.txt" +fi EXE_BASENAME="$(basename "${EXE}")" EXE_DIR="$(dirname "${EXE}")"