hotfix ci template

This commit is contained in:
Alex Vanderveen
2026-08-12 21:53:26 -04:00
parent f3c6caf96c
commit d5b5a029fe
19 changed files with 1383 additions and 63 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="PSXRecomp">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a2332"/>
<stop offset="100%" stop-color="#0d121a"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="100%" stop-color="#2dd4bf"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<!-- Classic pad silhouette (outline only) -->
<path fill="none" stroke="url(#accent)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round"
d="M156 210 C156 178 178 156 210 156 H302 C334 156 356 178 356 210 V248 C356 268 348 286 334 298 L372 360 C380 374 372 392 356 392 H328 C312 392 300 382 294 368 L278 328 H234 L218 368 C212 382 200 392 184 392 H156 C140 392 132 374 140 360 L178 298 C164 286 156 268 156 248 Z"/>
<!-- D-pad -->
<path fill="none" stroke="url(#accent)" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"
d="M214 236 V268 M198 252 H230"/>
<!-- Face buttons -->
<circle cx="298" cy="236" r="10" fill="url(#accent)"/>
<circle cx="318" cy="252" r="10" fill="url(#accent)" opacity="0.85"/>
<circle cx="298" cy="268" r="10" fill="url(#accent)" opacity="0.7"/>
<circle cx="278" cy="252" r="10" fill="url(#accent)" opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+5
View File
@@ -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"
)
```
+3
View File
@@ -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).
+24
View File
@@ -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: |
+139
View File
@@ -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("<IIIHHIIIIII", 40, w, h*2, 1, 32, 0, len(xor), 0, 0, 0, 0) + xor + and_mask
# ICONDIR + ICONDIRENTRY
entry = struct.pack("<BBBBHHII", w if w < 256 else 0, h if h < 256 else 0, 0, 0, 1, 32, len(dib), 22)
ico = struct.pack("<HHH", 0, 1, 1) + entry + dib
Path(sys.argv[2]).write_bytes(ico)
print(f"wrote {sys.argv[2]} (python ico)")
PY
fi
+59
View File
@@ -733,6 +733,7 @@ function(psxrecomp_add_runtime_target target)
EXE_NAME
GAME_VERSION
MAX_PLAYERS
APP_ICON
)
# GAME_GENERATED_FULL_C is a list (not a single value): the split-TU build
# writes the recompiled game as N full_NN.c shards instead of one
@@ -874,6 +875,39 @@ function(psxrecomp_add_runtime_target target)
endif()
set_target_properties(${target} PROPERTIES OUTPUT_NAME "${_psxrt_exe_name}")
# ---- Windows / desktop app icon ---------------------------------------
# Prefer an explicit APP_ICON, then the game-repo copy under assets/, then
# the framework default shipped in psxrecomp/assets (RetComM-themed pad).
if(NOT PSXRT_APP_ICON)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico")
set(PSXRT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico")
elseif(EXISTS "${PSXRECOMP_ROOT}/assets/psxrecomp.ico")
set(PSXRT_APP_ICON "${PSXRECOMP_ROOT}/assets/psxrecomp.ico")
endif()
endif()
if(PSXRT_APP_ICON AND EXISTS "${PSXRT_APP_ICON}")
if(WIN32)
string(REPLACE "\\" "/" _psxrt_ico_fwd "${PSXRT_APP_ICON}")
set(_psxrt_rc "${CMAKE_CURRENT_BINARY_DIR}/${target}_app_icon.rc")
file(WRITE "${_psxrt_rc}" "IDI_ICON1 ICON \"${_psxrt_ico_fwd}\"\n")
target_sources(${target} PRIVATE "${_psxrt_rc}")
message(STATUS "psxrecomp ${target}: APP_ICON=${PSXRT_APP_ICON}")
endif()
# Stage PNG beside the exe when present (AppImage / desktop / docs).
get_filename_component(_psxrt_ico_dir "${PSXRT_APP_ICON}" DIRECTORY)
set(_psxrt_png "${_psxrt_ico_dir}/psxrecomp.png")
if(EXISTS "${_psxrt_png}")
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:${target}>/assets"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_psxrt_png}"
"$<TARGET_FILE_DIR:${target}>/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<N>_<hash>: any emitter change
@@ -1055,6 +1089,19 @@ function(psxrecomp_add_runtime_target target)
$<$<CXX_COMPILER_ID:MSVC>: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 "$<TARGET_FILE_DIR:${target}>/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")
+2 -1
View File
@@ -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 |
@@ -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/<boot>_dispatch.c exists."
)
return None
def bios_backend_present(fw: Path, stem: str) -> bool:
"""True when generated/<stem>_{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
+146 -10
View File
@@ -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 = [
@@ -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,
+438 -29
View File
@@ -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=<pin> (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,
@@ -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",
@@ -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
+19 -2
View File
@@ -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
@@ -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@
@@ -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.
+60 -19
View File
@@ -20,10 +20,14 @@
# [--embed-toolchain] # optional: copy PSXRECOMP_TOOLCHAIN_DIR into zip
#
# Env:
# RELEASE_VERSION / <version-env> / VERSION file
# RELEASE_VERSION / <version-env> / 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
# $<TARGET_FILE_DIR>/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}")"