Files
2026-10-01 11:39:00 -07:00

1569 lines
59 KiB
Python

"""Apply migration plan steps (bundled releases: committed generated/ C, compiled game shipped)."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from fill_tokens import derive_zip_prefix
from .models import ApplyResult, MigrateOptions, Plan
from .naming import (
boot_exe_from_game_toml,
build_token_map,
entry_pc_from_game_toml,
game_name_from_project,
infer_project_name,
normalize_lobby_url,
players_from_cmake,
window_title_from_cmake,
window_title_from_name,
)
from .paths import ci_release_template, psxrecomp_root_from_toolkit, templates_dir, toolkit_dir
# The title's packager wrapper (bundled release) and the retired setup-host one.
PACKAGER_WRAPPER = "package_release.sh"
LEGACY_PACKAGER_WRAPPER = "package_setup_release.sh"
# A .gitignore line that hides the release's inputs: `/generated/`, `generated/`,
# `generated`, with optional surrounding whitespace.
_GENERATED_IGNORE_RE = re.compile(r"^\s*/?generated/?\s*$")
def _run(cmd: list[str], cwd: Path, dry_run: bool) -> tuple[bool, str]:
if dry_run:
return True, "dry-run: " + " ".join(cmd)
try:
proc = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
return False, str(exc)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, err or f"exit {proc.returncode}"
return True, (proc.stdout or "").strip()
def _write(path: Path, text: str, dry_run: bool) -> None:
if dry_run:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def _fill(src: Path, dst: Path, tokens: dict[str, str], dry_run: bool, *, ci: bool = False) -> None:
text = src.read_text(encoding="utf-8")
for k, v in tokens.items():
text = text.replace(f"@{k}@", v)
if ci:
zp = tokens.get("ZIP_PREFIX", "game")
title = tokens.get("GAME_TITLE") or tokens.get("WINDOW_TITLE") or "Game"
title = " ".join(title.split())
title_yaml = (
title.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", "")
)
text = text.replace("YOUR_ZIP_PREFIX", zp)
text = text.replace("YOUR_GAME_TITLE", title_yaml)
text = text.replace("yourgame-release", f"{zp}-release")
_write(dst, text, dry_run)
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 = (
options.boot_exe
or boot_exe_from_game_toml(root / "game.toml")
or "SLUS_01234"
)
players = options.players
if players == 2:
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
gt = (root / "game.toml").read_text(encoding="utf-8", errors="replace") if (
root / "game.toml"
).is_file() else ""
for line in gt.splitlines():
if line.strip().startswith("window_title"):
_, _, v = line.partition("=")
title = v.strip().strip('"').strip("'")
break
if not title:
title = window_title_from_name(name)
has_boxart = (root / "launcher_assets" / "img" / "boxart.tga").is_file()
# Also treat pending relocate as has_boxart for cmake wiring after relocate runs first
if not has_boxart:
for p in (
root / "recomp" / "launcher" / "boxart.tga",
root / "recomp" / "launcher" / "boxart.png",
):
if p.is_file():
has_boxart = options.relocate_boxart
break
enable_netplay = options.enable_netplay and players >= 2
return build_token_map(
name=name,
boot_exe=boot,
players=players,
zip_prefix=options.zip_prefix or derive_zip_prefix(name),
window_title=title,
entry_pc=entry_pc_from_game_toml(root / "game.toml"),
lobby_url=normalize_lobby_url(options.lobby_url),
enable_recomp_ui=options.enable_recomp_ui,
enable_wizard=options.enable_wizard if options.enable_recomp_ui else False,
enable_netplay=enable_netplay if options.enable_recomp_ui else False,
has_boxart=has_boxart,
github_owner=options.github_owner,
github_repo=options.github_repo,
)
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():
return ApplyResult(
"rename_psxrecomp_submodule", False, "psxrecomp-v4 not found", []
)
# 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:
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:
parts.append(f"dry-run: move stub {dest} → {stub_bak}")
else:
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}")
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)
if ".gitmodules" not in changed:
changed.append(".gitmodules")
parts.append("updated .gitmodules")
# Patch CMakeLists PSXRECOMP_ROOT path if present
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)
changed.append("CMakeLists.txt")
parts.append("rewrote CMakeLists psxrecomp-v4 → psxrecomp")
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:
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"],
root,
options.dry_run,
)
if not ok:
return ApplyResult("ensure_psxrecomp_submodule", False, out, [])
ok2, out2 = _run(
["git", "submodule", "update", "--init", "--recursive", "psxrecomp"],
root,
options.dry_run,
)
return ApplyResult(
"ensure_psxrecomp_submodule",
ok2,
out2 or out or "Added psxrecomp submodule",
["psxrecomp", ".gitmodules"],
)
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()
or (root / "recomp-ui" / ".git").exists()
):
return ApplyResult("ensure_recomp_ui_submodule", True, "Already present", [])
url = "https://github.com/RetroPortingToolKit/recomp-ui.git"
ok, out = _run(
["git", "submodule", "add", "-b", "master", url, "recomp-ui"],
root,
options.dry_run,
)
if not ok:
return ApplyResult("ensure_recomp_ui_submodule", False, out, [])
_run(
["git", "submodule", "update", "--init", "--recursive", "recomp-ui"],
root,
options.dry_run,
)
return ApplyResult(
"ensure_recomp_ui_submodule",
True,
out or "Added recomp-ui submodule",
["recomp-ui", ".gitmodules"],
)
def op_emit_codegen_setup(root: Path, options: MigrateOptions) -> ApplyResult:
tokens = _resolve_tokens(root, options)
tdir = templates_dir()
changed = []
for name in ("codegen_setup.c", "codegen_setup.h"):
src = tdir / f"{name}.in"
dst = root / name
if dst.is_file() and not options.force:
# Refresh if missing forward helper
if name.endswith(".c") and "psx_game_codegen_forward_if_built" in dst.read_text(
encoding="utf-8", errors="replace"
):
continue
_fill(src, dst, tokens, options.dry_run)
changed.append(name)
if not changed:
return ApplyResult("emit_codegen_setup", True, "Already up to date", [])
return ApplyResult("emit_codegen_setup", True, "Wrote codegen_setup", changed)
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", [])
src = templates_dir() / "VERSION.in"
text = "0.1.0\n" if not src.is_file() else src.read_text(encoding="utf-8")
# VERSION.in may have tokens — keep simple
text = text.replace("@VERSION@", "0.1.0")
if not text.endswith("\n"):
text += "\n"
_write(dst, text if "@" not in text else "0.1.0\n", options.dry_run)
return ApplyResult("emit_version", True, "Wrote VERSION", ["VERSION"])
def op_emit_symbols_toml(root: Path, options: MigrateOptions) -> ApplyResult:
dst = root / "symbols.toml"
if dst.is_file() and not options.force:
return ApplyResult("emit_symbols_toml", True, "symbols.toml already exists", [])
tokens = _resolve_tokens(root, options)
_fill(templates_dir() / "symbols.toml.in", dst, tokens, options.dry_run)
return ApplyResult("emit_symbols_toml", True, "Wrote symbols.toml", ["symbols.toml"])
def op_emit_sync_symbols(root: Path, options: MigrateOptions) -> ApplyResult:
src = toolkit_dir() / "sync_symbols.py"
dst = root / "tools" / "sync_symbols.py"
if dst.is_file() and not options.force:
return ApplyResult("emit_sync_symbols", True, "Already present", [])
if options.dry_run:
return ApplyResult("emit_sync_symbols", True, "dry-run: copy sync_symbols.py", ["tools/sync_symbols.py"])
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return ApplyResult("emit_sync_symbols", True, "Installed tools/sync_symbols.py", ["tools/sync_symbols.py"])
def op_merge_gitignore(root: Path, options: MigrateOptions) -> ApplyResult:
src = templates_dir() / "gitignore.in"
template = src.read_text(encoding="utf-8")
tokens = _resolve_tokens(root, options)
for k, v in tokens.items():
template = template.replace(f"@{k}@", v)
dst = root / ".gitignore"
existing = dst.read_text(encoding="utf-8") if dst.is_file() else ""
# generated/ is deliberately NOT here: releases build from the committed
# game C, so an ignore rule for it is removed below, never added.
required = [
"/disc/",
"/bios/",
"/dist/",
"/build/",
"/build-*/",
"/saves/",
# Local analysis scratch: GPU frame captures / parity dumps and disc
# asset rips. Routinely exceeds GitHub's 100 MB blob limit, and rips
# are disc content that must never be committed.
"/analysis/",
"*.bin",
"*.cue",
]
additions = []
for pat in required:
if pat not in existing:
additions.append(pat)
if not existing:
_write(dst, template if not template.startswith("@") else "\n".join(required) + "\n", options.dry_run)
return ApplyResult("merge_gitignore", True, "Wrote .gitignore from template", [".gitignore"])
# The setup-host layout ignored generated/; a bundled release is built
# from it, so that rule hides the release's inputs from CI. Drop it.
kept_lines = []
dropped = 0
for line in existing.splitlines():
if _GENERATED_IGNORE_RE.match(line):
dropped += 1
continue
kept_lines.append(line)
if dropped:
existing = "\n".join(kept_lines) + ("\n" if existing.endswith("\n") else "")
if not additions and not dropped:
return ApplyResult("merge_gitignore", True, "No gitignore changes needed", [])
text = existing.rstrip() + "\n"
if additions:
text += (
"\n# --- PSXRecomp (project studio) ---\n"
+ "\n".join(additions)
+ "\n"
)
_write(dst, text, options.dry_run)
parts = []
if additions:
parts.append(f"appended {len(additions)} ignore rule(s)")
if dropped:
parts.append("un-ignored generated/ (releases build from the committed game C)")
return ApplyResult("merge_gitignore", True, "; ".join(parts).capitalize(), [".gitignore"])
def op_emit_mods_preloaded(root: Path, options: MigrateOptions) -> ApplyResult:
base = root / "mods" / "preloaded"
readme = base / "README.md"
packages = base / "packages" / ".gitkeep"
if readme.is_file() and (base / "packages").is_dir() and not options.force:
return ApplyResult("emit_mods_preloaded", True, "Already present", [])
if options.dry_run:
return ApplyResult(
"emit_mods_preloaded",
True,
"dry-run: stub mods/preloaded",
["mods/preloaded/README.md"],
)
packages.parent.mkdir(parents=True, exist_ok=True)
packages.write_text("", encoding="utf-8")
readme.write_text(
"# Preloaded mods\n\n"
"Ship default-off `.psxmod` packages under `packages/<id>/<version>/`.\n"
"Setup-host releases copy this tree beside the executable when present.\n",
encoding="utf-8",
)
return ApplyResult(
"emit_mods_preloaded",
True,
"Created mods/preloaded stub",
["mods/preloaded/README.md", "mods/preloaded/packages/.gitkeep"],
)
def op_relocate_boxart(root: Path, options: MigrateOptions) -> ApplyResult:
dest_dir = root / "launcher_assets" / "img"
dest = dest_dir / "boxart.tga"
if dest.is_file() and not options.force:
return ApplyResult("relocate_boxart", True, "Modern boxart already present", [])
sources = [
root / "recomp" / "launcher" / "boxart.tga",
root / "recomp" / "launcher" / "boxart.png",
]
src = next((p for p in sources if p.is_file()), None)
if src is None:
return ApplyResult("relocate_boxart", False, "No legacy boxart found", [])
if options.dry_run:
return ApplyResult(
"relocate_boxart",
True,
f"dry-run: copy {src.name} → launcher_assets/img/boxart.tga",
["launcher_assets/img/boxart.tga"],
)
dest_dir.mkdir(parents=True, exist_ok=True)
if src.suffix.lower() == ".tga":
shutil.copy2(src, dest)
else:
# Keep png beside expected path with note — TGA preferred by launcher
shutil.copy2(src, dest_dir / src.name)
(dest_dir / "BOXART_SOURCE.txt").write_text(
f"Copied from {src.relative_to(root)}; convert to boxart.tga if needed.\n",
encoding="utf-8",
)
return ApplyResult(
"relocate_boxart",
True,
f"Copied {src.name} (not TGA — convert for LAUNCHER_BOXART)",
[str((dest_dir / src.name).relative_to(root))],
)
(dest_dir / "BOXART_SOURCE.txt").write_text(
f"Relocated from {src.relative_to(root)} by project studio.\n",
encoding="utf-8",
)
return ApplyResult(
"relocate_boxart",
True,
"Relocated boxart to launcher_assets/img/boxart.tga",
["launcher_assets/img/boxart.tga", "launcher_assets/img/BOXART_SOURCE.txt"],
)
def op_emit_boxart_stub(root: Path, options: MigrateOptions) -> ApplyResult:
dest_dir = root / "launcher_assets" / "img"
if dest_dir.is_dir() and not options.force:
return ApplyResult("emit_boxart_stub", True, "launcher_assets already exists", [])
if options.dry_run:
return ApplyResult("emit_boxart_stub", True, "dry-run: mkdir launcher_assets/img", [])
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / ".gitkeep").write_text("", encoding="utf-8")
return ApplyResult(
"emit_boxart_stub",
True,
"Created launcher_assets/img (add boxart.tga later)",
["launcher_assets/img/.gitkeep"],
)
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)
# The wizard is the shipped game's first-run disc picker; keep it on.
if not options.enable_wizard and options.enable_recomp_ui:
options = MigrateOptions(**{**options.to_dict(), "enable_wizard": True})
tokens = _resolve_tokens(root, options)
cmake = root / "CMakeLists.txt"
changed = ["CMakeLists.txt"]
extras_note = root / "CMakeLists.migrate_extras.txt"
old = cmake.read_text(encoding="utf-8") if cmake.is_file() else ""
# Capture extras for the user
extras_bits = []
if "EXTRAS_SOURCES" in old:
extras_bits.append("Old CMakeLists used EXTRAS_SOURCES — re-attach mod/plugin sources.")
if "add_test(" in old or "BUILD_TESTING" in old:
extras_bits.append("Old CMakeLists had tests — restore from CMakeLists.txt.pre_migrate.bak.")
if "POST_BUILD" in old and "mods/preloaded" in old:
extras_bits.append("Old CMakeLists copied mods/preloaded POST_BUILD — re-add if needed.")
bak = root / "CMakeLists.txt.pre_migrate.bak"
if cmake.is_file() and not options.dry_run:
if not bak.is_file() or options.force:
bak.write_text(old, encoding="utf-8")
changed.append("CMakeLists.txt.pre_migrate.bak")
_fill(templates_dir() / "CMakeLists.txt.in", cmake, tokens, options.dry_run)
go = root / "game_options.toml"
if not go.is_file():
_fill(templates_dir() / "game_options.toml.in", go, tokens, options.dry_run)
changed.append("game_options.toml")
if extras_bits and not options.dry_run:
extras_note.write_text(
"Preserved notes after the CMake rewrite:\n\n"
+ "\n".join(f"- {b}" for b in extras_bits)
+ "\n\nFull previous file: CMakeLists.txt.pre_migrate.bak\n",
encoding="utf-8",
)
changed.append("CMakeLists.migrate_extras.txt")
return ApplyResult(
"rewrite_cmake_setup_host",
True,
"Wrote CMakeLists.txt (psxrecomp_add_game_runtime; backup .pre_migrate.bak)",
changed,
)
def op_emit_packager(root: Path, options: MigrateOptions) -> ApplyResult:
tokens = _resolve_tokens(root, options)
dst = root / "scripts" / PACKAGER_WRAPPER
if dst.is_file() and not options.force:
return ApplyResult("emit_packager", True, "Packager already present", [])
_fill(templates_dir() / (PACKAGER_WRAPPER + ".in"), dst, tokens, options.dry_run)
if not options.dry_run:
dst.chmod(dst.stat().st_mode | 0o111)
msg = f"Wrote scripts/{PACKAGER_WRAPPER} (bundled release: compiled game)"
legacy = root / "scripts" / LEGACY_PACKAGER_WRAPPER
if legacy.is_file():
msg += (f"; scripts/{LEGACY_PACKAGER_WRAPPER} is the retired setup-host "
"wrapper and can be deleted")
return ApplyResult("emit_packager", True, msg, [f"scripts/{PACKAGER_WRAPPER}"])
_PACKAGER_BLURB = {
"src": (
"# Game-owned C that CMakeLists.txt compiles into the runtime — mod\n"
"# activation plugins live here (CODEGEN_SETUP_SOURCES \"src/*_mods.c\").\n"
),
"mods": (
"# Preloaded mod packages (mods/preloaded/packages/<id>). Plugins compiled\n"
"# in above select themselves through these; without them a mod is built\n"
"# and never enabled.\n"
),
}
def op_sync_packager_project_dirs(root: Path, options: MigrateOptions) -> ApplyResult:
"""Stage paths CMakeLists.txt needs but the packager allowlist omits.
Patches the existing wrapper rather than regenerating it: packagers are
routinely hand-extended with extra --project-file lines, and rewriting from
the template would silently drop them.
"""
from .detect import (
packager_missing_optional_paths,
packager_missing_paths,
packager_wrapper,
packager_is_bundled,
)
dst = packager_wrapper(root)
if dst is None:
return ApplyResult(
"sync_packager_project_dirs", False, f"No scripts/{PACKAGER_WRAPPER}", []
)
if packager_is_bundled(dst):
# A bundled release ships the compiled game and no sources, so there
# is no source allowlist to keep in step with CMakeLists.txt.
return ApplyResult(
"sync_packager_project_dirs", True,
"Bundled packager ships no sources; nothing to stage", [])
missing = sorted(set(packager_missing_paths(root)) |
set(packager_missing_optional_paths(root)))
if not missing:
return ApplyResult(
"sync_packager_project_dirs", True, "Packager already stages every needed path", []
)
text = dst.read_text(encoding="utf-8")
anchor = "\ncd \"${ROOT}\"\n"
if anchor not in text:
return ApplyResult(
"sync_packager_project_dirs",
False,
"Unrecognised packager layout (no cd \"${ROOT}\" anchor); fix by hand",
[],
)
added: list[str] = []
block = ""
for rel in missing:
target = root / rel
kind = "dir" if target.is_dir() else "file"
test = "-d" if kind == "dir" else "-f"
block += _PACKAGER_BLURB.get(rel, "")
block += (
f'if [[ {test} "${{ROOT}}/{rel}" ]]; then\n'
f" EXTRA_PROJECT+=(--project-{kind} {rel})\n"
"fi\n"
)
added.append(rel)
text = text.replace(anchor, "\n" + block + anchor, 1)
_write(dst, text, options.dry_run)
rel = dst.relative_to(root).as_posix()
return ApplyResult(
"sync_packager_project_dirs",
True,
"Staged " + ", ".join(added) + f" in {rel}",
[rel],
)
def _ci_step_names(text: str) -> list[str]:
"""`- name:` values under a workflow's steps, in order.
Deliberately a regex over the raw text rather than a YAML parse: this runs
against a template that still holds @TOKEN@ placeholders, which are valid
YAML here but need no interpretation, and the check must not depend on a
yaml module being importable inside Studio.
"""
names: list[str] = []
for line in text.splitlines():
m = re.match(r"^\s*-\s+name:\s*(.+?)\s*$", line)
if m:
names.append(m.group(1).strip().strip('"\''))
return names
# Steps earlier templates defined that the current one deliberately dropped. An
# installed workflow still running one is stale even though it lacks nothing
# the template has: the setup-host wipe and the CI-side OpenBIOS emission both
# call scripts that no longer exist in the pinned framework, so the job dies
# at that step. A step-name comparison that only looked for MISSING steps
# called such a file current (TM4 v0.3.34, first attempt).
RETIRED_CI_STEPS = (
"Setup host mode (no game C / BIOS backends in CI)",
"Generate OpenBIOS backend C",
)
def _ci_steps_missing_from(installed: Path, template: Path) -> list[str]:
"""Template step names absent from the installed workflow, plus retired
step names the installed workflow still carries (reported as
'retired: <name>')."""
try:
have_list = _ci_step_names(installed.read_text(encoding="utf-8", errors="replace"))
have = set(have_list)
want = _ci_step_names(template.read_text(encoding="utf-8", errors="replace"))
except OSError:
return []
# Names carrying a token cannot be compared literally -- the installed copy
# has them substituted -- so they are not evidence either way.
missing = [n for n in want if n not in have and "@" not in n]
retired = [f"retired: {n}" for n in RETIRED_CI_STEPS if n in have]
return missing + retired
def op_emit_ci_workflow(root: Path, options: MigrateOptions) -> ApplyResult:
tokens = _resolve_tokens(root, options)
src = ci_release_template(root)
if src is None:
return ApplyResult(
"emit_ci_workflow",
False,
"Cannot find docs/ci/templates/game-release.yml (need a psxrecomp "
"submodule pinned at or after bundled releases)",
[],
)
dst = root / ".github" / "workflows" / "release.yml"
if dst.is_file() and not options.force and "YOUR_ZIP_PREFIX" not in dst.read_text(
encoding="utf-8", errors="replace"
):
# "Filled" is not the same as "current". This used to stop here, so a
# project that installed the workflow once never learned the template
# had gained steps -- which is how a release gate can exist in the
# framework and be missing from every title that predates it.
#
# Compare STEP NAMES rather than text: a filled workflow legitimately
# differs from the template (tokens, and titles customise theirs), so a
# textual diff would cry stale on every project forever. A step the
# template defines and the installed file does not is unambiguous.
missing = _ci_steps_missing_from(dst, src)
if missing:
return ApplyResult(
"emit_ci_workflow",
False,
"release.yml is out of date with the CI template; missing step(s): "
+ ", ".join(missing)
+ " — re-emit with force to update (review the diff first: a "
"customised workflow is overwritten wholesale)",
[],
)
return ApplyResult(
"emit_ci_workflow", True, "release.yml already filled and current", []
)
_fill(src, dst, tokens, options.dry_run, ci=True)
return ApplyResult(
"emit_ci_workflow",
True,
"Wrote .github/workflows/release.yml (bundled release: builds the committed generated/ C)",
[".github/workflows/release.yml"],
)
def op_annotate_legacy_packaging(root: Path, options: MigrateOptions) -> ApplyResult:
note = root / "packaging" / "PACKAGING_MIGRATION.txt"
text = (
"This title releases through the shared bundled-release flow:\n"
f"scripts/{PACKAGER_WRAPPER} + .github/workflows/release.yml build the\n"
"COMMITTED generated/ game C and ship the compiled game\n"
"(see psxrecomp/docs/GAME_PROJECT_SETUP.md and docs/ci/BUNDLED_RELEASES.md).\n"
"Hand-forked packagers in this folder (package_release.ps1,\n"
"package_appimage.sh, ...) are superseded and can be deleted once CI is green.\n"
)
if (root / "packaging").is_dir():
_write(note, text, options.dry_run)
return ApplyResult(
"annotate_legacy_packaging",
True,
"Wrote packaging/PACKAGING_MIGRATION.txt",
["packaging/PACKAGING_MIGRATION.txt"],
)
# tools-only legacy
tools_note = root / "tools" / "PACKAGING_MIGRATION.txt"
_write(tools_note, text, options.dry_run)
return ApplyResult(
"annotate_legacy_packaging",
True,
"Wrote tools/PACKAGING_MIGRATION.txt",
["tools/PACKAGING_MIGRATION.txt"],
)
# probe_disc.py renders a whole scaffold game.toml and seed list. On a project
# that already has them (every migration), only the disc identity is the
# probe's to write; titles carry hand-tuned runtime, widescreen and overlay
# configuration and curated seeds. Overwriting them would have replaced
# THPS2's cull config and 1,891-seed list during its migration (2026-10-01).
_PROBE_OWNED_SECTIONS = ("prepare_disc", "netplay")
_PROBE_GAME_KEYS = ("name", "id", "players", "exe", "disc", "load_address",
"entry_pc", "text_size", "stack_base")
_TOML_HEADER = re.compile(r"^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$")
_TOML_KEY = re.compile(r"^\s*([A-Za-z0-9_-]+)\s*=")
_SEED = re.compile(r"^\s*(0x[0-9A-Fa-f]+)\b")
def _toml_chunks(text: str) -> list[tuple[str | None, list[str]]]:
"""Split TOML text at table headers. Comment/blank lines directly above a
header travel with that header's chunk. Chunk 0 (name None) is preamble."""
chunks: list[tuple[str | None, list[str]]] = [(None, [])]
for line in text.splitlines():
m = _TOML_HEADER.match(line)
if m:
prev = chunks[-1][1]
lead: list[str] = []
while prev and (not prev[-1].strip() or prev[-1].lstrip().startswith("#")):
lead.insert(0, prev.pop())
chunks.append((m.group(1).strip(), lead + [line]))
else:
chunks[-1][1].append(line)
return chunks
def _emit_section(out: list[str], lines: list[str]) -> None:
"""Append a probe section separated from what precedes it by one blank."""
body = list(lines)
while body and not body[0].strip():
body.pop(0)
while out and not out[-1].strip():
out.pop()
if out:
out.append("")
out.extend(body)
def merge_probe_game_toml(existing: str, probed: str) -> str:
"""Existing config wins. Probe-owned identity sections are replaced (or
inserted after [game] / appended), and [game] gains only the identity keys
it lacks. Merging the same probe output twice is a no-op."""
probed_by_name = {name: lines for name, lines in _toml_chunks(probed) if name}
chunks = _toml_chunks(existing)
present = {name for name, _ in chunks}
out: list[str] = []
placed: set[str] = set()
for name, lines in chunks:
if name in _PROBE_OWNED_SECTIONS and name in probed_by_name:
_emit_section(out, probed_by_name[name])
placed.add(name)
continue
if name == "game" and "game" in probed_by_name:
have = {m.group(1) for m in map(_TOML_KEY.match, lines) if m}
body = list(lines)
for line in probed_by_name["game"]:
m = _TOML_KEY.match(line)
if m and m.group(1) in _PROBE_GAME_KEYS and m.group(1) not in have:
body.append(line)
_emit_section(out, body)
if "prepare_disc" in probed_by_name and "prepare_disc" not in present:
_emit_section(out, probed_by_name["prepare_disc"])
placed.add("prepare_disc")
continue
if name is None:
out.extend(lines)
else:
_emit_section(out, lines)
for name in _PROBE_OWNED_SECTIONS:
if name in placed or name not in probed_by_name:
continue
_emit_section(out, probed_by_name[name])
return "\n".join(out).rstrip("\n") + "\n"
def merge_seed_files(existing: str, probed: str) -> tuple[str, int]:
"""Keep the existing (curated) seed file verbatim and append any probed
address it lacks. Returns (text, number added)."""
have = {int(m.group(1), 16) for m in map(_SEED.match, existing.splitlines()) if m}
new: list[str] = []
for line in probed.splitlines():
m = _SEED.match(line)
if m and int(m.group(1), 16) not in have:
have.add(int(m.group(1), 16))
new.append(f"0x{int(m.group(1), 16):08X}")
if not new:
return existing, 0
text = existing.rstrip("\n") + "\n"
text += f"# Added by probe_disc refresh: {len(new)} boot-EXE JAL target(s)\n"
text += "\n".join(new) + "\n"
return text, len(new)
def op_probe_disc_refresh(root: Path, options: MigrateOptions) -> ApplyResult:
if not options.disc:
return ApplyResult(
"probe_disc_refresh",
False,
"Pass --disc path/to/game.cue to refresh identity",
[],
)
disc = Path(options.disc).expanduser().resolve()
if not disc.is_file():
return ApplyResult("probe_disc_refresh", False, f"Disc not found: {disc}", [])
probe = toolkit_dir() / "probe_disc.py"
if not probe.is_file():
return ApplyResult("probe_disc_refresh", False, "probe_disc.py missing from toolkit", [])
name = options.project_name or infer_project_name(root)
players = options.players
game_toml = root / "game.toml"
seeds = root / "seeds" / "ghidra_funcs.txt"
files = ["game.toml", "catalog_identity.json", "seeds/ghidra_funcs.txt"]
with tempfile.TemporaryDirectory(prefix="probe_refresh_") as tmp:
tmp_toml = Path(tmp) / "game.toml"
tmp_seeds = Path(tmp) / "ghidra_funcs.txt"
cmd = [
sys.executable,
str(probe),
str(disc),
"--write-game-toml",
str(tmp_toml),
"--write-catalog",
str(root / "catalog_identity.json"),
"--write-seeds",
str(tmp_seeds),
"--out-dir",
"disc",
"--disc-rel",
"disc/" + disc.name,
"--players",
str(players),
"--display-name",
name,
]
if options.dry_run:
return ApplyResult(
"probe_disc_refresh",
True,
"dry-run: " + " ".join(cmd) + " (disc identity merged into any "
"existing game.toml / seeds; nothing else replaced)",
files,
)
ok, out = _run(cmd, root, dry_run=False)
if not ok:
return ApplyResult("probe_disc_refresh", False, out, [])
notes = [out or "probe_disc completed"]
probed_toml = tmp_toml.read_text(encoding="utf-8")
if game_toml.is_file():
_write(game_toml, merge_probe_game_toml(
game_toml.read_text(encoding="utf-8"), probed_toml), False)
notes.append("merged disc identity into existing game.toml")
else:
_write(game_toml, probed_toml, False)
probed_seeds = tmp_seeds.read_text(encoding="utf-8")
if seeds.is_file():
text, added = merge_seed_files(seeds.read_text(encoding="utf-8"), probed_seeds)
_write(seeds, text, False)
notes.append(f"kept existing seeds, added {added}")
else:
_write(seeds, probed_seeds, False)
return ApplyResult("probe_disc_refresh", True, "; ".join(notes), files)
def _record_pins_via_git(root: Path, dst: Path, options: MigrateOptions) -> ApplyResult:
"""Pure-Python pin snapshot: git rev-parse HEAD in each submodule."""
lines = []
for name in ("psxrecomp", "recomp-ui"):
sub = root / name
if not sub.is_dir():
continue
ok, out = _run(["git", "rev-parse", "HEAD"], sub, options.dry_run)
if ok and out:
short = out[:7] if not options.dry_run else "dryrun"
lines.append(f"{name}={short} ({out})")
if not lines:
return ApplyResult(
"record_framework_pins",
False,
"No psxrecomp submodule to record pins from",
[],
)
text = "\n".join(lines) + "\n"
_write(dst, text, options.dry_run)
return ApplyResult("record_framework_pins", True, "Wrote framework_pins.txt", ["framework_pins.txt"])
def op_record_framework_pins(root: Path, options: MigrateOptions) -> ApplyResult:
record = None
for base in (root / "psxrecomp", root / "psxrecomp-v4"):
cand = base / "tools" / "ci" / "record_pins.sh"
if cand.is_file():
record = cand
break
dst = root / "framework_pins.txt"
# Resolve bash through PATH. A bare "bash" given to CreateProcess on
# Windows searches System32 first and launches WSL's bash, which cannot
# see D:/ paths (and a CRLF checkout of the script breaks any bash).
bash = shutil.which("bash")
if record is None or bash is None:
return _record_pins_via_git(root, dst, options)
if options.dry_run:
return ApplyResult(
"record_framework_pins",
True,
f"dry-run: {record} --root {root}",
["framework_pins.txt"],
)
proc = subprocess.run(
# POSIX-style paths: Git Bash / MSYS2 read backslashes as escapes.
[bash, record.as_posix(), "--root", root.as_posix()],
capture_output=True,
text=True,
check=False,
cwd=str(root),
)
body = (proc.stdout or "").strip()
if proc.returncode != 0 and not body:
# The script could not run here; the git fallback records the same pins.
return _record_pins_via_git(root, dst, options)
if body:
dst.write_text(body + "\n", encoding="utf-8")
return ApplyResult(
"record_framework_pins",
True,
"Wrote framework_pins.txt",
["framework_pins.txt"],
)
def _boxart_hints(root: Path, options: MigrateOptions) -> tuple[str, str]:
cue = ""
display = ""
if options.disc:
cue = Path(options.disc).name
cat = root / "catalog_identity.json"
if cat.is_file():
try:
data = json.loads(cat.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
if isinstance(data, dict):
rom = data.get("rom_identity") if isinstance(data.get("rom_identity"), dict) else {}
cue = cue or str(rom.get("cue_name") or "")
game = data.get("game") if isinstance(data.get("game"), dict) else {}
display = str(game.get("name") or "")
name = options.project_name or infer_project_name(root)
display = options.window_title or display or game_name_from_project(name)
return cue, display
def _ensure_boxart_png(root: Path, options: MigrateOptions) -> list[str]:
"""Fetch libretro boxart PNG/TGA when the README PNG is missing. Never raises."""
png = root / "launcher_assets" / "img" / "boxart.png"
tga = root / "launcher_assets" / "img" / "boxart.tga"
if png.is_file():
return []
if options.dry_run:
return ["launcher_assets/img/boxart.png"]
cue, display = _boxart_hints(root, options)
if not cue and not display:
return []
try:
sys.path.insert(0, str(toolkit_dir()))
from fetch_boxart import fetch_to_paths # type: ignore
except ImportError:
return []
try:
fetch_to_paths(tga, cue_stem=cue, display_name=display)
except Exception:
return []
changed: list[str] = []
if png.is_file():
changed.append("launcher_assets/img/boxart.png")
if tga.is_file():
changed.append("launcher_assets/img/boxart.tga")
src = tga.parent / "BOXART_SOURCE.txt"
if src.is_file():
changed.append("launcher_assets/img/BOXART_SOURCE.txt")
return changed
def op_patch_readme_metrics(root: Path, options: MigrateOptions) -> ApplyResult:
"""Upsert download badges, libretro boxart, Retro Launcher, and R.A.I.D. footer."""
from .readme_metrics import (
apply_github_about,
boxart_png_present,
render_boxart_block,
render_launcher_block,
render_metrics_block,
render_raid_block,
resolve_github_slug,
upsert_readme_blocks,
)
name = options.project_name or infer_project_name(root)
owner, repo = resolve_github_slug(root, options.github_owner, options.github_repo)
zp = options.zip_prefix or derive_zip_prefix(name)
fetched = _ensure_boxart_png(root, options)
display = options.window_title or game_name_from_project(name)
boxart_block = None
if boxart_png_present(root) or (
options.dry_run and "launcher_assets/img/boxart.png" in fetched
):
boxart_block = render_boxart_block(display)
path = root / "README.md"
if path.is_file():
old = path.read_text(encoding="utf-8", errors="replace")
else:
title = options.window_title or window_title_from_name(name)
old = f"# {title}\n"
new = upsert_readme_blocks(
old,
render_metrics_block(owner, repo, zp),
render_launcher_block(),
render_raid_block(),
boxart=boxart_block,
)
changed: list[str] = list(fetched)
if new != old:
_write(path, new, options.dry_run)
if "README.md" not in changed:
changed.append("README.md")
src_img = templates_dir() / "raid-discord.png"
dst_img = root / ".github" / "raid-discord.png"
if src_img.is_file() and not dst_img.is_file():
if not options.dry_run:
dst_img.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_img, dst_img)
changed.append(".github/raid-discord.png")
about_ok, about_msg = apply_github_about(owner, repo, dry_run=options.dry_run)
parts: list[str] = []
if changed:
parts.append(
f"Patched README badges, boxart, Retro Launcher, and R.A.I.D. footer ({owner}/{repo})"
)
if fetched:
parts.append("fetched libretro boxart")
if about_ok:
parts.append(about_msg)
else:
parts.append("GitHub About skipped: " + about_msg)
if not changed and not about_ok:
return ApplyResult(
"patch_readme_metrics",
True,
f"README metrics already current ({owner}/{repo}); {about_msg}",
[],
)
return ApplyResult(
"patch_readme_metrics",
True,
"; ".join(parts),
changed,
)
_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,
"emit_symbols_toml": op_emit_symbols_toml,
"emit_sync_symbols": op_emit_sync_symbols,
"merge_gitignore": op_merge_gitignore,
"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,
"sync_packager_project_dirs": op_sync_packager_project_dirs,
"emit_ci_workflow": op_emit_ci_workflow,
"annotate_legacy_packaging": op_annotate_legacy_packaging,
"probe_disc_refresh": op_probe_disc_refresh,
"record_framework_pins": op_record_framework_pins,
"patch_readme_metrics": op_patch_readme_metrics,
}
def apply_plan(plan: Plan, *, selected: list[str] | None = None) -> list[ApplyResult]:
root = Path(plan.root)
options = plan.options
results: list[ApplyResult] = []
# Setup-host exclusive: never leave wizard off when CI/packager are applied
if options.enable_ci or any(
s.op_id in ("emit_ci_workflow", "emit_packager", "rewrite_cmake_setup_host")
and s.selected
for s in plan.steps
):
options.enable_wizard = True
options.enable_recomp_ui = True
for step in plan.steps:
if not step.selected:
continue
if selected is not None and step.op_id not in selected:
continue
fn = _OPS.get(step.op_id)
if fn is None:
results.append(
ApplyResult(step.op_id, False, f"Unknown op: {step.op_id}", [])
)
continue
try:
results.append(fn(root, options))
except Exception as exc: # noqa: BLE001 — surface to CLI/GUI
results.append(ApplyResult(step.op_id, False, f"{type(exc).__name__}: {exc}", []))
return results
def list_ops() -> list[str]:
return list(_OPS.keys())