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}")"