From d5b5a029fea070343cdee83ba11aa6ebb06510f5 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 12 Aug 2026 21:53:26 -0400 Subject: [PATCH] hotfix ci template --- assets/psxrecomp.ico | Bin 0 -> 370070 bytes assets/psxrecomp.png | Bin 0 -> 29937 bytes assets/psxrecomp.svg | 24 + docs/GAME_PROJECT_SETUP.md | 5 + docs/ci/HOST_ONLY_RELEASES.md | 3 + docs/ci/templates/setup-release.yml | 24 + packaging/make-icons.sh | 139 ++++++ runtime/runtime.cmake | 59 +++ tools/new_project_layout/README.md | 3 +- .../project_studio/buildops.py | 432 +++++++++++++++- .../project_studio/detect.py | 156 +++++- .../project_studio/naming.py | 5 + .../new_project_layout/project_studio/ops.py | 467 ++++++++++++++++-- .../new_project_layout/project_studio/plan.py | 6 +- tools/new_project_layout/setup_project.ps1 | 20 + tools/new_project_layout/setup_project.sh | 21 +- .../templates/CMakeLists.txt.in | 1 + .../new_project_layout/templates/README.md.in | 2 + tools/package_setup_host.sh | 79 ++- 19 files changed, 1383 insertions(+), 63 deletions(-) create mode 100644 assets/psxrecomp.ico create mode 100644 assets/psxrecomp.png create mode 100644 assets/psxrecomp.svg create mode 100755 packaging/make-icons.sh diff --git a/assets/psxrecomp.ico b/assets/psxrecomp.ico new file mode 100644 index 0000000000000000000000000000000000000000..34670f35b26f378073f6dc8f22627a0dab52e570 GIT binary patch literal 370070 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 ZoUUsy>mCitsXgX4LgwRw)Ir2={~z&SgO~sS literal 0 HcmV?d00001 diff --git a/assets/psxrecomp.png b/assets/psxrecomp.png new file mode 100644 index 0000000000000000000000000000000000000000..b43ea162c53fa13389aa2c00f11026242c0cc945 GIT binary patch literal 29937 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 UN#q>(4O57g`Zcv873-k?1Gf?Dx&QzG literal 0 HcmV?d00001 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}")"