tools: extend new project setup workflow

This commit is contained in:
Matthew Stanley
2026-08-23 13:24:06 -07:00
parent 13a56626d3
commit 8ddcafb1c4
15 changed files with 3005 additions and 124 deletions
+64 -20
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python3
"""Fetch PS1 Named_Boxarts from libretro-thumbnails and write launcher TGA.
"""Fetch PS1 Named_Boxarts from libretro-thumbnails (PNG + launcher TGA).
Sources (tried in order):
https://thumbnails.libretro.com/Sony - PlayStation/Named_Boxarts/<name>.png
https://raw.githubusercontent.com/libretro-thumbnails/Sony_-_PlayStation/master/Named_Boxarts/<name>.png
Writes 32-bit uncompressed TGA (bottom-up BGRA) for recomp-ui LAUNCHER_BOXART.
Also writes launcher_assets/img/BOXART_SOURCE.txt with URL + attribution.
Writes the original PNG (README) and a 32-bit uncompressed TGA (bottom-up BGRA)
for recomp-ui LAUNCHER_BOXART. Also writes launcher_assets/img/BOXART_SOURCE.txt
with URL + attribution.
Requires network. Prefer Pillow when installed; otherwise decodes common
8-bit RGBA/RGB PNGs with the stdlib.
@@ -120,7 +121,7 @@ def try_fetch(names: list[str]) -> tuple[bytes, str, str]:
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, RuntimeError) as e:
last_err = e
continue
raise SystemExit(
raise FileNotFoundError(
f"boxart not found for candidates {names[:8]}… ({last_err})"
)
@@ -241,6 +242,40 @@ def write_tga_bgra(path: Path, width: int, height: int, rgba: bytes) -> None:
path.write_bytes(bytes(header) + bytes(bgra))
def fetch_to_paths(
tga_path: Path,
*,
cue_stem: str = "",
display_name: str = "",
extra_names: list[str] | None = None,
png_path: Path | None = None,
) -> tuple[Path, Path, str]:
"""Fetch libretro Named_Boxarts PNG, write PNG + TGA + BOXART_SOURCE.txt."""
names = candidate_names(cue_stem, display_name, *(extra_names or []))
if not names:
raise ValueError("pass cue_stem and/or display_name")
png, url, matched = try_fetch(names)
tga_path = Path(tga_path)
png_path = Path(png_path) if png_path else tga_path.with_suffix(".png")
png_path.parent.mkdir(parents=True, exist_ok=True)
png_path.write_bytes(png)
try:
w, h, rgba = png_to_rgba(png)
write_tga_bgra(tga_path, w, h, rgba)
except SystemExit as exc:
raise RuntimeError(str(exc) or "PNG decode failed") from exc
src_path = tga_path.parent / "BOXART_SOURCE.txt"
src_path.write_text(
"Box art sourced from libretro-thumbnails (Named_Boxarts).\n"
f"Matched name: {matched}\n"
f"URL: {url}\n"
"https://github.com/libretro-thumbnails/libretro-thumbnails\n"
"PNG is for README; TGA is for recomp-ui LAUNCHER_BOXART.\n",
encoding="utf-8",
)
return tga_path, png_path, url
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
@@ -256,6 +291,11 @@ def main() -> int:
default="",
help="attribution file (default: next to TGA as BOXART_SOURCE.txt)",
)
ap.add_argument(
"--png-out",
default="",
help="output PNG path (default: same stem as --out with .png)",
)
args = ap.parse_args()
names = candidate_names(args.cue_stem, args.display_name, *args.name)
@@ -264,24 +304,28 @@ def main() -> int:
return 2
print(f" searching libretro boxart ({len(names)} candidates)…", file=sys.stderr)
png, url, matched = try_fetch(names)
print(f" fetched {matched}", file=sys.stderr)
print(f" {url}", file=sys.stderr)
w, h, rgba = png_to_rgba(png)
out = Path(args.out)
write_tga_bgra(out, w, h, rgba)
print(f" wrote {out} ({w}x{h})", file=sys.stderr)
png_out = Path(args.png_out) if args.png_out else out.with_suffix(".png")
try:
tga_path, png_path, url = fetch_to_paths(
out,
cue_stem=args.cue_stem,
display_name=args.display_name,
extra_names=list(args.name),
png_path=png_out,
)
except (FileNotFoundError, ValueError, RuntimeError) as exc:
print(str(exc) or "boxart fetch failed", file=sys.stderr)
return 1
print(f" wrote {png_path}", file=sys.stderr)
print(f" wrote {tga_path}", file=sys.stderr)
print(f" {url}", file=sys.stderr)
src_path = Path(args.source_out) if args.source_out else out.parent / "BOXART_SOURCE.txt"
src_path.write_text(
"Box art sourced from libretro-thumbnails (Named_Boxarts).\n"
f"Matched name: {matched}\n"
f"URL: {url}\n"
"https://github.com/libretro-thumbnails/libretro-thumbnails\n"
"Convert: PNG → 32-bit TGA for recomp-ui LAUNCHER_BOXART.\n",
encoding="utf-8",
)
if args.source_out:
src_path.write_text(
(tga_path.parent / "BOXART_SOURCE.txt").read_text(encoding="utf-8"),
encoding="utf-8",
)
print(f" wrote {src_path}", file=sys.stderr)
return 0
+60
View File
@@ -8,6 +8,12 @@ import re
import sys
from pathlib import Path
GITHUB_ABOUT_DESCRIPTION = (
"Made with PSXrecomp, a Sony PlayStation game static recompiler ecosystem · "
"Part of the R.A.I.D. community"
)
GITHUB_ABOUT_HOMEPAGE = "https://discord.gg/Ad9BwSzctP"
def derive_zip_prefix(name: str) -> str:
base = re.sub(r"(?i)recomp(iled)?$", "", name).strip()
@@ -20,6 +26,60 @@ def derive_zip_prefix(name: str) -> str:
return (slug or "game")[:20]
def sanitize_github_name(name: str) -> str:
"""GitHub owner/repo slug: spaces → '-', drop illegal chars, keep [A-Za-z0-9._-]."""
s = (name or "").strip()
if not s:
return ""
s = re.sub(r"\s+", "-", s)
s = re.sub(r"[^A-Za-z0-9._-]+", "", s)
s = re.sub(r"-{2,}", "-", s)
s = s.strip(".-")
return s[:100] or "repo"
def install_dir_name(name: str) -> str:
"""Local checkout / launcher ``apps/`` folder — same slug as the GitHub repo name.
New-project wizard must create this folder (not the display name with spaces)
so Studio catalog matching and RetComM ``install_dir_name`` stay consistent.
"""
return sanitize_github_name(name)
def normalize_repo_key(name: str) -> str:
"""Casefold + collapse whitespace/underscores to hyphens for folder/catalog match.
Lets ``Wipeout 3 Special Edition Recomp`` match catalog
``Wipeout-3-Special-Edition-Recomp`` / github short name without renaming.
"""
s = (name or "").strip().lower()
if not s:
return ""
s = re.sub(r"[\s_]+", "-", s)
s = re.sub(r"[^a-z0-9._-]+", "", s)
s = re.sub(r"-{2,}", "-", s)
return s.strip(".-")
def repo_match_keys(name: str) -> set[str]:
"""Match keys for a folder / install_dir / github short name.
Includes the normalized form and a form with a trailing ``-recomp`` /
``-recompiled`` stripped so local ``… Recomp`` checkouts still match
catalog ``install_dir_name`` / github slugs that omit that suffix
(e.g. Klonoa).
"""
k = normalize_repo_key(name)
if not k:
return set()
out = {k}
stripped = re.sub(r"(-recomp(iled)?)+$", "", k).strip("-")
if stripped:
out.add(stripped)
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("src")
@@ -509,7 +509,13 @@ def resolve_catalog_root(studio_toml: Path | None = None) -> Path | None:
def _catalog_name_keys(catalog_root: Path) -> set[str]:
"""Folder / github short-name keys (lowercased) for every catalog title."""
"""Folder / github short-name keys for every catalog title.
Stores raw lowercased names plus ``repo_match_keys`` forms so spacey /
``… Recomp`` legacy checkouts still match hyphenated ``install_dir_name``.
"""
from fill_tokens import repo_match_keys
index_path = catalog_root / "index.json"
titles_dir = catalog_root / "titles"
if not index_path.is_file() or not titles_dir.is_dir():
@@ -519,8 +525,16 @@ def _catalog_name_keys(catalog_root: Path) -> set[str]:
except (OSError, json.JSONDecodeError):
return set()
keys: set[str] = set()
def _add(raw: str) -> None:
s = (raw or "").strip()
if not s:
return
keys.add(s.lower())
keys.update(repo_match_keys(s))
for tid in ids:
keys.add(str(tid).lower())
_add(str(tid))
path = titles_dir / f"{tid}.json"
if not path.is_file():
continue
@@ -530,7 +544,7 @@ def _catalog_name_keys(catalog_root: Path) -> set[str]:
continue
name = str(raw.get("install_dir_name") or "").strip()
if name:
keys.add(name.lower())
_add(name)
for nest in (
("release", "github"),
("build", "source", "github"),
@@ -543,15 +557,141 @@ def _catalog_name_keys(catalog_root: Path) -> set[str]:
break
cur = cur[k]
if ok and isinstance(cur, str) and "/" in cur:
keys.add(cur.rsplit("/", 1)[-1].lower())
_add(cur.rsplit("/", 1)[-1])
homepage = str(raw.get("homepage") or "")
if "github.com/" in homepage.lower():
tail = homepage.rstrip("/").split("github.com/")[-1]
if "/" in tail:
keys.add(tail.split("/")[-1].lower())
_add(tail.split("/")[-1])
return keys
def catalog_title_id_for_root(
root: Path,
*,
catalog_root: Path | None = None,
) -> str | None:
"""Best-effort catalog title id for a local checkout (install_dir / github slug)."""
from fill_tokens import repo_match_keys
cat = catalog_root or resolve_catalog_root()
if cat is None:
return None
index_path = cat / "index.json"
titles_dir = cat / "titles"
if not index_path.is_file() or not titles_dir.is_dir():
return None
try:
ids = list(json.loads(index_path.read_text(encoding="utf-8")).get("titles") or [])
except (OSError, json.JSONDecodeError):
return None
want = set(repo_match_keys(root.name))
try:
from .gitops import _git
code, url, _ = _git(root, "remote", "get-url", "origin")
if code == 0 and url:
u = url.strip().rstrip("/")
if u.endswith(".git"):
u = u[:-4]
if "github.com" in u.lower():
# git@github.com:Owner/Repo or https://github.com/Owner/Repo
tail = u.replace(":", "/").split("github.com/")[-1]
if "/" in tail:
want.update(repo_match_keys(tail.split("/")[-1]))
except Exception:
pass
want.discard("")
for tid in ids:
path = titles_dir / f"{tid}.json"
if not path.is_file():
continue
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
candidates = [str(tid), str(raw.get("install_dir_name") or "")]
gh = raw.get("release") if isinstance(raw.get("release"), dict) else {}
if isinstance(gh, dict) and isinstance(gh.get("github"), str) and "/" in gh["github"]:
candidates.append(gh["github"].rsplit("/", 1)[-1])
homepage = str(raw.get("homepage") or "")
if "github.com/" in homepage.lower():
tail = homepage.rstrip("/").split("github.com/")[-1]
if "/" in tail:
candidates.append(tail.split("/")[-1])
for c in candidates:
if repo_match_keys(c) & want:
return str(tid)
return None
def upsert_studio_toml_title(
title_id: str,
project_root: Path,
*,
studio_toml: Path | None = None,
) -> str | None:
"""Ensure ``[titles] "id" = "rel/path"`` in studio.toml. Returns note or None."""
cfg = studio_toml or find_studio_toml()
if cfg is None or not (title_id or "").strip():
return None
title_id = title_id.strip()
root = project_root.expanduser().resolve()
if not root.is_dir():
return None
try:
rel = os.path.relpath(root, cfg.parent.resolve()).replace("\\", "/")
except ValueError:
rel = str(root)
key_line = f'"{title_id}" = "{rel}"'
text = cfg.read_text(encoding="utf-8")
# Already present with any path → leave alone (user may have customized).
existing = re.search(
rf'^[ \t]*"{re.escape(title_id)}"\s*=\s*("[^"]*"|\'[^\']*\')',
text,
re.M,
)
if existing:
return f"studio.toml already maps {title_id}"
if re.search(r"^\[titles\]\s*$", text, re.M):
# Append after [titles] block's last assignment, or right after header.
lines = text.splitlines(keepends=True)
out: list[str] = []
in_titles = False
inserted = False
i = 0
while i < len(lines):
line = lines[i]
s = line.strip()
if s.startswith("[") and s.endswith("]"):
if in_titles and not inserted:
out.append(key_line + "\n")
inserted = True
in_titles = s == "[titles]"
out.append(line)
i += 1
continue
out.append(line)
i += 1
if in_titles and not inserted:
if out and not out[-1].endswith("\n"):
out[-1] = out[-1] + "\n"
out.append(key_line + "\n")
inserted = True
if not inserted:
return None
cfg.write_text("".join(out), encoding="utf-8")
else:
suffix = "" if text.endswith("\n") or not text else "\n"
cfg.write_text(
text + suffix + "\n[titles]\n" + key_line + "\n",
encoding="utf-8",
)
return f"studio.toml [titles] += {title_id}"
def _studio_title_paths(studio_toml: Path | None = None) -> set[Path]:
cfg = studio_toml or find_studio_toml()
if cfg is None:
@@ -576,6 +716,8 @@ def repo_has_catalog_entry(
studio_toml: Path | None = None,
) -> bool:
"""True if this indexed checkout maps to a retcomm-catalog title."""
from fill_tokens import repo_match_keys
try:
root = entry.resolved()
except OSError:
@@ -589,12 +731,34 @@ def repo_has_catalog_entry(
keys = _catalog_name_keys(cat)
if not keys:
return False
basename = root.name.lower()
if basename in keys:
def _hits(label: str) -> bool:
s = (label or "").strip()
if not s:
return False
if s.lower() in keys:
return True
return bool(repo_match_keys(s) & keys)
if _hits(root.name):
return True
name = (entry.name or "").strip().lower()
if name and name in keys:
if _hits(entry.name or ""):
return True
# GitHub origin short name (catalog often matches repo slug, not local folder).
try:
from .gitops import _git
code, url, _ = _git(root, "remote", "get-url", "origin")
if code == 0 and url:
u = url.strip().rstrip("/")
if u.endswith(".git"):
u = u[:-4]
if "github.com" in u.lower():
tail = u.replace(":", "/").split("github.com/")[-1]
if "/" in tail and _hits(tail.split("/")[-1]):
return True
except Exception:
pass
return False
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,13 @@ from .models import (
Severity,
)
from .naming import boot_exe_from_game_toml, infer_project_name
from .readme_metrics import (
boxart_png_present,
readme_has_boxart,
readme_has_launcher,
readme_has_metrics,
readme_has_raid,
)
def _read(path: Path) -> str:
@@ -749,6 +756,47 @@ def audit_project(root: Path) -> AuditReport:
)
)
# README download badges + boxart + RetComM Launcher (idempotent migrate op)
readme_path = root / "README.md"
readme_text = _read(readme_path) if readme_path.is_file() else ""
missing_readme: list[str] = []
if not readme_path.is_file():
missing_readme.append("README.md")
else:
if not readme_has_metrics(readme_text):
missing_readme.append("download badges")
if not readme_has_boxart(readme_text):
missing_readme.append("libretro boxart")
if not readme_has_launcher(readme_text):
missing_readme.append("RetComM Launcher section")
if not readme_has_raid(readme_text):
missing_readme.append("R.A.I.D. Discord footer")
if not (root / ".github" / "raid-discord.png").is_file():
missing_readme.append(".github/raid-discord.png")
if not boxart_png_present(root):
missing_readme.append("launcher_assets/img/boxart.png")
if missing_readme:
checks.append(
CheckResult(
id="readme_metrics",
title="README download metrics / launcher / RAID / boxart",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="Missing: " + ", ".join(missing_readme),
fix_op="patch_readme_metrics",
)
)
else:
checks.append(
CheckResult(
id="readme_metrics",
title="README download metrics / launcher / RAID / boxart",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="Download badges, boxart, RetComM Launcher, and R.A.I.D. footer present.",
)
)
# Classify layout
fails = {c.id for c in checks if c.status == CheckStatus.FAIL}
rec_warns = [
@@ -83,6 +83,8 @@ class MigrateOptions:
boot_exe: str | None = None
players: int = 2
zip_prefix: str | None = None
github_owner: str | None = None
github_repo: str | None = None
window_title: str | None = None
enable_recomp_ui: bool = True
enable_wizard: bool = True
@@ -5,7 +5,7 @@ from __future__ import annotations
import re
from pathlib import Path
from fill_tokens import derive_zip_prefix
from fill_tokens import derive_zip_prefix, sanitize_github_name
DEFAULT_LOBBY_HOST = "netplay.retcomm.net"
@@ -220,6 +220,8 @@ def build_token_map(
enable_wizard: bool = True,
enable_netplay: bool = False,
has_boxart: bool = False,
github_owner: str | None = None,
github_repo: str | None = None,
) -> dict[str, str]:
cmake_name = project_cmake_name(name)
title = window_title or window_title_from_name(name)
@@ -294,6 +296,10 @@ def build_token_map(
"ENV_PREFIX": env_prefix(cmake_name),
"EXE_BASENAME": exe_basename(title),
"ZIP_PREFIX": zp,
"GITHUB_OWNER": sanitize_github_name(
(github_owner or "").strip() or "TechnicallyComputers"
),
"GITHUB_REPO": sanitize_github_name((github_repo or "").strip() or name),
"DISC_HINT": f"your legally owned {game} disc",
"GAME_TITLE": title,
"PLAYERS": str(players),
@@ -0,0 +1,374 @@
"""New-project wizard: drive setup_project.sh / .ps1 non-interactively."""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from .gitops import CmdResult, switch_modules
from .paths import toolkit_dir
@dataclass
class NewProjectOptions:
"""Mirrors setup_project.sh / setup_project.ps1 CLI flags."""
name: str = ""
disc: str = ""
parent_dir: str = ""
bios: str = ""
boot_exe: str = ""
players: int = 2
zip_prefix: str = ""
github_owner: str = ""
github_repo: str = ""
description: str = ""
publisher: str = ""
year: str = ""
region: str = "USA"
enable_recomp_ui: bool = True
enable_wizard: bool = True
# GUI defaults: all on except GitHub; netplay follows players (≥2).
enable_netplay: bool = False
lobby_url: str = "netplay.retcomm.net"
enable_ci: bool = True
fetch_boxart: bool = True
stage_disc: bool = True
do_generate: bool = True
do_build: bool = True
create_github: bool = False
github_visibility: str = "private" # public | private | internal
psxrecomp_ref: str = "master"
recomp_ui_ref: str = "master"
recomp_net_ref: str = "" # empty = keep psxrecomp pin
rbengine_ref: str = "" # post-setup switch only (script has no flag)
dry_run: bool = False
def setup_script_paths() -> tuple[Path, Path]:
"""Return (setup_project.sh, setup_project.ps1) under the toolkit."""
base = toolkit_dir()
return base / "setup_project.sh", base / "setup_project.ps1"
def is_windows() -> bool:
return platform.system().lower().startswith("win") or os.name == "nt"
def project_folder_name(opts: NewProjectOptions) -> str:
"""Checkout folder = GitHub/catalog install_dir slug (not display name with spaces)."""
from fill_tokens import install_dir_name, sanitize_github_name
repo = (opts.github_repo or "").strip()
if repo:
return sanitize_github_name(repo)
return install_dir_name(opts.name or "")
def project_root_for(opts: NewProjectOptions) -> Path:
parent = Path(opts.parent_dir or ".").expanduser()
if not parent.is_absolute():
parent = parent.resolve()
else:
parent = parent.resolve()
folder = project_folder_name(opts)
if not folder:
folder = (opts.name or "").strip() or "repo"
return (parent / folder).resolve()
def validate_options(opts: NewProjectOptions) -> list[str]:
errs: list[str] = []
name = (opts.name or "").strip()
disc = (opts.disc or "").strip()
if not name:
errs.append("Project name is required")
if not disc:
errs.append("Disc .cue path is required")
else:
p = Path(disc).expanduser()
if not p.is_file():
errs.append(f"Disc not found: {disc}")
if opts.bios:
bp = Path(opts.bios).expanduser()
if not bp.is_file():
errs.append(f"BIOS not found: {opts.bios}")
if opts.players < 1 or opts.players > 8:
errs.append("Players must be 1–8")
if opts.do_build and not opts.do_generate:
errs.append("Build requires Generate")
# Wizard/netplay without UI (and 1P netplay) are auto-corrected at run time.
vis = (opts.github_visibility or "private").strip().lower()
if vis not in ("public", "private", "internal"):
errs.append("GitHub visibility must be public/private/internal")
dest = project_root_for(opts)
if dest.exists():
if dest.is_file() or (dest.is_dir() and any(dest.iterdir())):
errs.append(f"Destination already exists: {dest}")
return errs
def build_command(opts: NewProjectOptions) -> tuple[list[str], dict[str, str]]:
"""Build argv + env for the OS-appropriate setup script.
Always passes ``--yes`` / ``-Yes`` so the GUI/CLI supply every choice.
"""
sh, ps1 = setup_script_paths()
env = os.environ.copy()
env["PSXRECOMP_SETUP_YES"] = "1"
if (opts.recomp_net_ref or "").strip():
env["RECOMP_NET_REF"] = opts.recomp_net_ref.strip()
if is_windows():
if not ps1.is_file():
raise FileNotFoundError(f"Missing setup script: {ps1}")
powershell = (
shutil.which("pwsh")
or shutil.which("powershell")
or "powershell"
)
cmd: list[str] = [
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
"-Disc",
str(Path(opts.disc).expanduser().resolve()),
"-Name",
opts.name.strip(),
"-Yes",
"-Players",
str(int(opts.players)),
"-PsxrecompRef",
(opts.psxrecomp_ref or "master").strip(),
"-RecompUiRef",
(opts.recomp_ui_ref or "master").strip(),
"-GithubVisibility",
(opts.github_visibility or "private").strip().lower(),
]
parent = (opts.parent_dir or ".").strip() or "."
cmd.extend(["-Dir", str(Path(parent).expanduser().resolve())])
if opts.bios:
cmd.extend(["-Bios", str(Path(opts.bios).expanduser().resolve())])
if opts.boot_exe:
cmd.extend(["-BootExe", opts.boot_exe.strip()])
if opts.zip_prefix:
cmd.extend(["-ZipPrefix", opts.zip_prefix.strip()])
if opts.github_owner:
cmd.extend(["-GithubOwner", opts.github_owner.strip()])
if opts.github_repo:
cmd.extend(["-GithubRepo", opts.github_repo.strip()])
if opts.description:
cmd.extend(["-Description", opts.description.strip()])
if opts.publisher:
cmd.extend(["-Publisher", opts.publisher.strip()])
if opts.year:
cmd.extend(["-Year", opts.year.strip()])
if opts.region:
cmd.extend(["-Region", opts.region.strip()])
if opts.lobby_url:
cmd.extend(["-LobbyUrl", opts.lobby_url.strip()])
def sw(yes: bool, on: str, off: str) -> None:
cmd.append(on if yes else off)
sw(opts.enable_recomp_ui, "-EnableRecompUi", "-NoRecompUi")
sw(opts.enable_wizard, "-EnableWizard", "-NoWizard")
sw(opts.enable_netplay, "-EnableNetplay", "-NoNetplay")
sw(opts.enable_ci, "-EnableCi", "-NoCi")
sw(opts.fetch_boxart, "-FetchBoxart", "-NoFetchBoxart")
sw(opts.do_generate, "-Generate", "-NoGenerate")
sw(opts.do_build, "-EnableBuild", "-NoBuild")
sw(opts.create_github, "-CreateGithub", "-NoGithub")
if opts.stage_disc:
cmd.append("-StageDisc")
return cmd, env
if not sh.is_file():
raise FileNotFoundError(f"Missing setup script: {sh}")
cmd = [
"sh",
str(sh),
"--yes",
"--disc",
str(Path(opts.disc).expanduser().resolve()),
"--name",
opts.name.strip(),
"--players",
str(int(opts.players)),
"--psxrecomp-ref",
(opts.psxrecomp_ref or "master").strip(),
"--recomp-ui-ref",
(opts.recomp_ui_ref or "master").strip(),
"--github-visibility",
(opts.github_visibility or "private").strip().lower(),
]
parent = (opts.parent_dir or ".").strip() or "."
cmd.extend(["--dir", str(Path(parent).expanduser().resolve())])
if opts.bios:
cmd.extend(["--bios", str(Path(opts.bios).expanduser().resolve())])
if opts.boot_exe:
cmd.extend(["--boot-exe", opts.boot_exe.strip()])
if opts.zip_prefix:
cmd.extend(["--zip-prefix", opts.zip_prefix.strip()])
if opts.github_owner:
cmd.extend(["--github-owner", opts.github_owner.strip()])
if opts.github_repo:
cmd.extend(["--github-repo", opts.github_repo.strip()])
if opts.description:
cmd.extend(["--description", opts.description.strip()])
if opts.publisher:
cmd.extend(["--publisher", opts.publisher.strip()])
if opts.year:
cmd.extend(["--year", opts.year.strip()])
if opts.region:
cmd.extend(["--region", opts.region.strip()])
if opts.lobby_url:
cmd.extend(["--lobby-url", opts.lobby_url.strip()])
if (opts.recomp_net_ref or "").strip():
cmd.extend(["--recomp-net-ref", opts.recomp_net_ref.strip()])
def flag(yes: bool, on: str, off: str) -> None:
cmd.append(on if yes else off)
flag(opts.enable_recomp_ui, "--enable-recomp-ui", "--no-recomp-ui")
flag(opts.enable_wizard, "--enable-wizard", "--no-wizard")
flag(opts.enable_netplay, "--enable-netplay", "--no-netplay")
flag(opts.enable_ci, "--enable-ci", "--no-ci")
flag(opts.fetch_boxart, "--fetch-boxart", "--no-fetch-boxart")
flag(opts.do_generate, "--generate", "--no-generate")
flag(opts.do_build, "--enable-build", "--no-build")
flag(opts.create_github, "--create-github", "--no-github")
cmd.append("--stage-disc" if opts.stage_disc else "--no-stage-disc")
return cmd, env
def run_new_project(
opts: NewProjectOptions,
*,
on_line: Callable[[str], None] | None = None,
cancel_event: threading.Event | None = None,
) -> CmdResult:
"""Run setup_project end-to-end; stream lines via ``on_line``."""
errs = validate_options(opts)
if errs:
return CmdResult(False, "Invalid new-project options", "\n".join(errs))
# Match script policy: no UI ⇒ no wizard/netplay; 1P ⇒ no netplay.
if not opts.enable_recomp_ui:
opts.enable_wizard = False
opts.enable_netplay = False
if opts.players < 2:
opts.enable_netplay = False
try:
cmd, env = build_command(opts)
except FileNotFoundError as exc:
return CmdResult(False, str(exc))
if opts.dry_run:
preview = " ".join(cmd)
if on_line:
on_line(f"[dry-run] {preview}")
return CmdResult(True, "dry-run: would run setup_project", preview)
if on_line:
on_line(f"$ {' '.join(cmd)}")
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
env=env,
cwd=str(Path(opts.parent_dir or ".").expanduser().resolve()),
)
except OSError as exc:
return CmdResult(False, f"Failed to start setup: {exc}")
assert proc.stdout is not None
for line in proc.stdout:
if cancel_event is not None and cancel_event.is_set():
proc.terminate()
return CmdResult(False, "New project cancelled")
text = line.rstrip("\n")
if on_line:
on_line(text)
code = proc.wait()
root = project_root_for(opts)
if code != 0:
return CmdResult(
False,
f"setup_project failed (exit {code})",
str(root),
)
# Optional nested rbengine (and net on Windows, where script has no ref flag)
post_notes: list[str] = []
if root.is_dir():
nested_branches: dict[str, str] = {}
net = (opts.recomp_net_ref or "").strip()
rb = (opts.rbengine_ref or "").strip()
if net and is_windows():
nested_branches["lib/recomp-net"] = net
if rb:
nested_branches["lib/retcomm-rbengine"] = rb
if nested_branches:
if on_line:
on_line("== Post-setup nested lib branch switch ==")
for r in switch_modules(
root,
nested=True,
branch_by_path=nested_branches,
paths=list(nested_branches.keys()),
set_tracking=True,
dry_run=False,
):
post_notes.append(r.message)
if on_line:
on_line(f" [{'OK' if r.ok else 'FAIL'}] {r.message}")
detail = str(root)
if post_notes:
detail += "\n" + "\n".join(post_notes)
return CmdResult(True, f"Created project at {root}", detail)
def index_new_project(
root: Path,
*,
name: str = "",
cue: str = "",
) -> CmdResult:
"""Add/update the Studio repo index for a freshly created project."""
from .bulkops import (
catalog_title_id_for_root,
upsert_studio_toml_title,
)
from .repo_index import add_repo, load_index
if not root.is_dir():
return CmdResult(False, f"Project root missing: {root}")
idx = load_index()
entry = add_repo(idx, root, name=name or root.name, cue=cue)
notes: list[str] = [entry.path]
tid = catalog_title_id_for_root(root)
if tid:
note = upsert_studio_toml_title(tid, root)
if note:
notes.append(note)
return CmdResult(True, f"Indexed {entry.label()}", "\n".join(notes))
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
@@ -15,6 +16,7 @@ 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,
@@ -221,6 +223,8 @@ def _resolve_tokens(root: Path, options: MigrateOptions) -> dict[str, str]:
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,
)
@@ -1080,6 +1084,136 @@ def op_record_framework_pins(root: Path, options: MigrateOptions) -> ApplyResult
)
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, RetComM 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, RetComM 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,
@@ -1100,6 +1234,7 @@ _OPS = {
"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,
}
@@ -29,6 +29,7 @@ _OP_ORDER = [
"annotate_legacy_packaging",
"probe_disc_refresh",
"record_framework_pins",
"patch_readme_metrics",
]
_OP_TITLES = {
@@ -51,6 +52,7 @@ _OP_TITLES = {
"annotate_legacy_packaging": "Annotate legacy prebuilt packaging",
"probe_disc_refresh": "Refresh disc identity via probe_disc.py",
"record_framework_pins": "Write framework_pins.txt",
"patch_readme_metrics": "Patch README badges, RetComM Launcher, and R.A.I.D. footer",
}
@@ -0,0 +1,348 @@
"""README download-badge + RetComM Launcher blocks for scaffold and migrate."""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
from fill_tokens import (
GITHUB_ABOUT_DESCRIPTION,
GITHUB_ABOUT_HOMEPAGE,
sanitize_github_name,
)
METRICS_BEGIN = "<!-- retcomm-readme-metrics -->"
METRICS_END = "<!-- /retcomm-readme-metrics -->"
BOXART_BEGIN = "<!-- retcomm-readme-boxart -->"
BOXART_END = "<!-- /retcomm-readme-boxart -->"
LAUNCHER_BEGIN = "<!-- retcomm-readme-launcher -->"
LAUNCHER_END = "<!-- /retcomm-readme-launcher -->"
RAID_BEGIN = "<!-- retcomm-readme-raid -->"
RAID_END = "<!-- /retcomm-readme-raid -->"
BOXART_REL = "launcher_assets/img/boxart.png"
_METRICS_RE = re.compile(
re.escape(METRICS_BEGIN) + r".*?" + re.escape(METRICS_END) + r"\n?",
re.S,
)
_BOXART_RE = re.compile(
re.escape(BOXART_BEGIN) + r".*?" + re.escape(BOXART_END) + r"\n?",
re.S,
)
_LAUNCHER_RE = re.compile(
re.escape(LAUNCHER_BEGIN) + r".*?" + re.escape(LAUNCHER_END) + r"\n?",
re.S,
)
_RAID_RE = re.compile(
re.escape(RAID_BEGIN) + r".*?" + re.escape(RAID_END) + r"\n?",
re.S,
)
_LEGACY_LAUNCHER_RE = re.compile(
r"^## RetComM Launcher\n.*?(?=^## |\Z)",
re.M | re.S,
)
_LEGACY_RAID_RE = re.compile(
r"(?:\n---\s*)?\n*<p align=\"center\">\s*\n\s*<sub><b>R\.A\.I\.D\..*\Z",
re.S,
)
_REMOTE_RE = re.compile(
r"github\.com[:/](?P<owner>[^/\s]+)/(?P<repo>[^/\s]+)",
re.I,
)
DEFAULT_GITHUB_OWNER = "TechnicallyComputers"
LAUNCHER_REPO = "https://github.com/TechnicallyComputers/RetComM-Launcher"
def parse_github_remote(url: str) -> tuple[str, str] | None:
url = (url or "").strip().rstrip("/")
m = _REMOTE_RE.search(url)
if not m:
return None
owner = m.group("owner")
repo = m.group("repo")
if repo.endswith(".git"):
repo = repo[: -len(".git")]
repo = repo.rstrip("/")
if not owner or not repo:
return None
return owner, repo
def infer_github_slug(root: Path) -> tuple[str, str] | None:
"""Best-effort owner/repo from origin, then catalog homepage."""
try:
proc = subprocess.run(
["git", "-C", str(root), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
)
except OSError:
proc = None
if proc is not None and proc.returncode == 0:
parsed = parse_github_remote(proc.stdout.strip())
if parsed:
return parsed
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):
for key in ("homepage", "github", "repository", "url"):
val = data.get(key)
if isinstance(val, str):
parsed = parse_github_remote(val)
if parsed:
return parsed
return None
def resolve_github_slug(
root: Path,
owner: str | None = None,
repo: str | None = None,
) -> tuple[str, str]:
o = sanitize_github_name(owner or "")
r = sanitize_github_name(repo or "")
if o and r:
return o, r
inferred = infer_github_slug(root)
if inferred:
return (
o or sanitize_github_name(inferred[0]),
r or sanitize_github_name(inferred[1]),
)
return (o or DEFAULT_GITHUB_OWNER, r or sanitize_github_name(root.name))
def apply_github_about(
owner: str,
repo: str,
*,
dry_run: bool = False,
) -> tuple[bool, str]:
"""Set GitHub About description + Discord homepage via ``gh repo edit``."""
slug = f"{sanitize_github_name(owner) or DEFAULT_GITHUB_OWNER}/{sanitize_github_name(repo) or 'repo'}"
cmd = [
"gh",
"repo",
"edit",
slug,
"--description",
GITHUB_ABOUT_DESCRIPTION,
"--homepage",
GITHUB_ABOUT_HOMEPAGE,
]
if dry_run:
return True, "dry-run: " + " ".join(cmd)
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
except OSError as exc:
return False, f"gh not available ({exc})"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, err or f"gh repo edit exit {proc.returncode}"
return True, f"GitHub About set on {slug}"
def render_metrics_block(owner: str, repo: str, zip_prefix: str = "") -> str:
# Per-OS latest badges need an exact asset name. Title zips are
# `{prefix}-{version}-{platform}.zip`, so globs always show 0 on shields.io.
# Keep all-time / latest totals + version; zip_prefix is unused here.
del zip_prefix
owner = sanitize_github_name(owner) or DEFAULT_GITHUB_OWNER
repo = sanitize_github_name(repo) or "repo"
base = f"https://github.com/{owner}/{repo}"
img = "https://img.shields.io/github"
lines = [
METRICS_BEGIN,
f"[![GitHub downloads (all assets, all releases)]({img}/downloads/{owner}/{repo}/total)]({base}/releases)",
f"[![GitHub downloads (latest release)]({img}/downloads/{owner}/{repo}/latest/total)]({base}/releases/latest)",
f"[![GitHub release]({img}/v/release/{owner}/{repo})]({base}/releases/latest)",
METRICS_END,
]
return "\n".join(lines)
def render_boxart_block(game_name: str) -> str:
alt = (game_name or "Game").strip() or "Game"
alt = alt.replace('"', "'")
return "\n".join(
[
BOXART_BEGIN,
'<p align="center">',
f' <img src="{BOXART_REL}" alt="{alt} box art" width="280">',
"</p>",
BOXART_END,
]
)
def render_launcher_block() -> str:
shots = (
"https://raw.githubusercontent.com/TechnicallyComputers/"
"RetComM-Launcher/main/docs/screenshots"
)
return "\n".join(
[
LAUNCHER_BEGIN,
"## RetComM Launcher",
"",
"You can run this title **standalone** (release zip + the built-in recomp-ui",
"Generate & Build flow), or manage installs, updates, ROM/BIOS wiring, and queued",
"builds more intuitively with",
f"**[RetComM Launcher]({LAUNCHER_REPO})** —",
"the Retro Compilation Manager hub for self-compiling recomps.",
"",
f"[Downloads]({LAUNCHER_REPO}/releases) ·",
f"[Full README & features]({LAUNCHER_REPO}#readme)",
"",
"<p align=\"center\">",
f' <img src="{shots}/hub-and-game-launcher.png" alt="RetComM hub with a background build, next to a title’s recomp-ui launcher" width="720">',
"</p>",
"",
"<p align=\"center\">",
f' <img src="{shots}/queue-and-background-build.png" alt="Background cmake build with titles queued" width="720">',
"</p>",
"",
"RetComM checks for updates, rebuilds with existing build data when possible,",
"shares the portable toolchain used by per-title launchers, and automates",
"BIOS/ROM/save plumbing so you are not stuck repeating each game’s wizard by hand.",
LAUNCHER_END,
]
)
def render_raid_block() -> str:
return "\n".join(
[
RAID_BEGIN,
"---",
"",
"<p align=\"center\">",
" <sub><b>R.A.I.D. — Retro AI Development</b> · a Discord for AI-assisted retro reverse-engineering, decomp &amp; recomp</sub>",
"</p>",
"",
"<p align=\"center\">",
' <a href="https://discord.gg/Ad9BwSzctP"><img src=".github/raid-discord.png" alt="Join the Retro AI Development (R.A.I.D.) Discord" width="200"></a>',
"</p>",
RAID_END,
]
)
def readme_has_metrics(text: str) -> bool:
return "img.shields.io/github/downloads/" in text
def readme_has_boxart(text: str) -> bool:
return BOXART_BEGIN in text or BOXART_REL in text
def boxart_png_present(root: Path) -> bool:
return (root / BOXART_REL).is_file()
def readme_has_launcher(text: str) -> bool:
return "TechnicallyComputers/RetComM-Launcher" in text
def readme_has_raid(text: str) -> bool:
return "discord.gg/Ad9BwSzctP" in text or "R.A.I.D. — Retro AI Development" in text
def _apply_boxart(text: str, boxart_block: str) -> str:
boxart_block = boxart_block.strip() + "\n"
if _BOXART_RE.search(text):
return _BOXART_RE.sub(boxart_block, text, count=1)
m = _METRICS_RE.search(text)
if m:
rest = text[m.end() :].lstrip("\n")
return text[: m.end()] + "\n" + boxart_block + "\n" + rest
hm = re.match(r"(#[^\n]+\n)", text)
if hm:
rest = text[hm.end() :].lstrip("\n")
return text[: hm.end()] + "\n" + boxart_block + "\n" + rest
return boxart_block + "\n" + text
def upsert_boxart_block(text: str, game_name: str) -> str:
if not text.endswith("\n"):
text += "\n"
return _apply_boxart(text, render_boxart_block(game_name))
def inject_readme_boxart(readme: Path, game_name: str) -> bool:
"""Insert/replace the README boxart block if ``boxart.png`` exists."""
readme = Path(readme)
if not readme.is_file() or not boxart_png_present(readme.parent):
return False
old = readme.read_text(encoding="utf-8", errors="replace")
new = upsert_boxart_block(old, game_name)
if new == old:
return False
readme.write_text(new, encoding="utf-8")
return True
def upsert_readme_blocks(
text: str,
metrics: str,
launcher: str,
raid: str | None = None,
boxart: str | None = None,
) -> str:
"""Insert or replace marked metrics, boxart, launcher, and RAID footer; keep custom body."""
if not text.endswith("\n"):
text += "\n"
metrics_block = metrics.strip() + "\n"
launcher_block = launcher.strip() + "\n"
raid_block = (raid if raid is not None else render_raid_block()).strip() + "\n"
if _METRICS_RE.search(text):
text = _METRICS_RE.sub(metrics_block, text, count=1)
elif not readme_has_metrics(text):
m = re.match(r"(#[^\n]+\n)", text)
if m:
text = (
text[: m.end()]
+ "\n"
+ metrics_block
+ "\n"
+ text[m.end() :].lstrip("\n")
)
else:
text = metrics_block + "\n" + text
if boxart:
text = _apply_boxart(text, boxart)
if _LAUNCHER_RE.search(text):
text = _LAUNCHER_RE.sub(launcher_block, text, count=1)
elif _LEGACY_LAUNCHER_RE.search(text):
text = _LEGACY_LAUNCHER_RE.sub(launcher_block + "\n", text, count=1)
elif not readme_has_launcher(text):
insert_at = None
for heading in ("## Legal", "## Quick start", "## Layout", "## Symbols"):
idx = text.find("\n" + heading)
if idx >= 0:
insert_at = idx + 1
break
if insert_at is None:
text = text.rstrip() + "\n\n" + launcher_block
else:
text = text[:insert_at] + launcher_block + "\n" + text[insert_at:]
if _RAID_RE.search(text):
text = _RAID_RE.sub(raid_block, text, count=1)
elif _LEGACY_RAID_RE.search(text):
text = _LEGACY_RAID_RE.sub("\n" + raid_block, text, count=1)
elif not readme_has_raid(text):
text = text.rstrip() + "\n\n" + raid_block
return text if text.endswith("\n") else text + "\n"
+71 -6
View File
@@ -22,6 +22,8 @@ param(
[string]$BootExe = "SLUS_01234",
[int]$Players = 0,
[string]$ZipPrefix = "",
[string]$GithubOwner = "",
[string]$GithubRepo = "",
[switch]$EnableRecompUi,
[switch]$NoRecompUi,
[switch]$EnableNetplay,
@@ -142,6 +144,24 @@ if (-not $ZipPrefix) {
}
}
if (-not $GithubOwner) {
if ($interactive) {
$GithubOwner = Prompt-Line "GitHub owner / org (README download badges)" "TechnicallyComputers"
} else {
$GithubOwner = "TechnicallyComputers"
}
}
$derivedRepo = (python -c "from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))" $Name).Trim()
if (-not $GithubRepo) {
if ($interactive) {
$GithubRepo = Prompt-Line "GitHub repo name (README download badges)" $derivedRepo
} else {
$GithubRepo = $derivedRepo
}
}
$GithubOwner = (python -c "from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))" $GithubOwner).Trim()
$GithubRepo = (python -c "from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))" $GithubRepo).Trim()
if (-not $PSBoundParameters.ContainsKey("Description")) {
if ($interactive) { $Description = Prompt-Line "Short game description (optional)" "" }
}
@@ -246,8 +266,13 @@ $PublisherDisp = if ($Publisher) { $Publisher } else { "-" }
$YearDisp = if ($Year) { $Year } else { "-" }
$DescriptionMd = if ($Description) { $Description } else { "_Add a short pitch in catalog_identity.json / README._" }
$Root = Join-Path (Resolve-Path $Dir) $Name
# Folder = GitHub/catalog install_dir slug (hyphenated), not display $Name with spaces.
$InstallDirName = $GithubRepo
$Root = Join-Path (Resolve-Path $Dir) $InstallDirName
if (Test-Path $Root) { throw "Target already exists: $Root" }
if ($InstallDirName -ne $Name) {
Write-Host "note: project folder is '$InstallDirName' (catalog/GitHub slug; display name stays '$Name')"
}
$NetplayBlockFile = Join-Path $env:TEMP "psxrecomp_netplay_block.cmake"
$WizardBlockFile = Join-Path $env:TEMP "psxrecomp_wizard_block.cmake"
@@ -316,6 +341,8 @@ function Fill-Template([string]$src, [string]$dst) {
--set "ENV_PREFIX=$EnvPrefix" `
--set "EXE_BASENAME=$ExeBasename" `
--set "ZIP_PREFIX=$ZipPrefix" `
--set "GITHUB_OWNER=$GithubOwner" `
--set "GITHUB_REPO=$GithubRepo" `
--set "DISC_HINT=$DiscHint" `
--set "GAME_TITLE=$WindowTitle" `
--set "PLAYERS=$Players" `
@@ -339,6 +366,7 @@ Write-Host "== New Project Layout =="
Write-Host " repo: $Root"
Write-Host " disc: $Disc"
Write-Host " zip prefix: $ZipPrefix"
Write-Host " gh slug: $GithubOwner/$GithubRepo"
Write-Host " players: $Players"
Write-Host " recomp-ui: $useRecompUi"
Write-Host " wizard: $useWizard"
@@ -564,6 +592,32 @@ print(m.group(1) if m else '')
Write-Warning "-Disc is not a .cue; skipped probe autofill."
}
if ($doBoxart) {
Write-Host "== Fetching libretro boxart =="
$cueHint = if ($DiscBasename) { $DiscBasename } else { $GameName }
$prevPy = $env:PYTHONPATH
try {
python $FetchBoxartPy `
--out (Join-Path $Root "launcher_assets\img\boxart.tga") `
--cue-stem $cueHint `
--display-name $GameName
if ($LASTEXITCODE -eq 0) {
$HasBoxart = $true
Set-Content -Encoding UTF8 -Path $BoxartBlockFile -Value ' LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"'
Fill-Template (Join-Path $TemplateDir "CMakeLists.txt.in") (Join-Path $Root "CMakeLists.txt")
Write-Host " wired LAUNCHER_BOXART in CMakeLists.txt"
$env:PYTHONPATH = $ScriptDir
python -c "from pathlib import Path; import sys; from project_studio.readme_metrics import inject_readme_boxart; inject_readme_boxart(Path(sys.argv[1]), sys.argv[2])" (Join-Path $Root "README.md") $GameName
Write-Host " injected boxart into README.md"
} else {
Write-Warning "boxart fetch failed — leave LAUNCHER_BOXART commented."
}
} catch {
Write-Warning "boxart fetch failed — leave LAUNCHER_BOXART commented."
} finally {
if ($null -eq $prevPy) { Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue } else { $env:PYTHONPATH = $prevPy }
}
}
Write-Host "== Sync symbols header =="
Push-Location $Root
@@ -571,8 +625,8 @@ try { python tools/sync_symbols.py --game $GameName } catch { Write-Warning "syn
Pop-Location
git add CMakeLists.txt game.toml codegen_setup.c codegen_setup.h .gitignore VERSION README.md seeds scripts tools mods framework_pins.txt symbols.toml psx_symbols.h 2>$null
if (Test-Path (Join-Path $Root ".github\workflows")) {
git add .github/workflows 2>$null
if (Test-Path (Join-Path $Root ".github")) {
git add .github 2>$null
}
if (Test-Path (Join-Path $Root "catalog_identity.json")) {
git add catalog_identity.json 2>$null
@@ -581,7 +635,7 @@ if (Test-Path (Join-Path $Root "disc_probe.json")) {
git add disc_probe.json 2>$null
}
if ($HasBoxart) {
git add launcher_assets/img/boxart.tga launcher_assets/img/BOXART_SOURCE.txt 2>$null
git add launcher_assets/img/boxart.tga launcher_assets/img/boxart.png launcher_assets/img/BOXART_SOURCE.txt 2>$null
}
$committed = $false
try {
@@ -606,15 +660,18 @@ if ($createGithub) {
"internal" { "--internal" }
default { "--private" }
}
$ghSlug = "$GithubOwner/$GithubRepo"
$ghDesc = (python -c "from fill_tokens import GITHUB_ABOUT_DESCRIPTION; print(GITHUB_ABOUT_DESCRIPTION)").Trim()
$ghHome = (python -c "from fill_tokens import GITHUB_ABOUT_HOMEPAGE; print(GITHUB_ABOUT_HOMEPAGE)").Trim()
try {
gh repo create $Name $vis --source=$Root --remote=origin
gh repo create $ghSlug $vis --source=$Root --remote=origin --description "$ghDesc" --homepage "$ghHome"
Write-Host " created origin ($GithubVisibility); push deferred until end"
$githubCreated = $true
} catch {
Write-Warning "gh repo create failed -- if the repo already exists, add origin and push at the end."
if (-not (git remote get-url origin 2>$null)) {
try {
$ghUrl = (gh repo view $Name --json url -q .url 2>$null)
$ghUrl = (gh repo view $ghSlug --json url -q .url 2>$null)
if ($ghUrl) {
git remote add origin $ghUrl
Write-Host " attached existing origin -> $ghUrl"
@@ -625,6 +682,14 @@ if ($createGithub) {
$githubCreated = $true
}
}
if ($githubCreated) {
try {
gh repo edit $ghSlug --description "$ghDesc" --homepage "$ghHome"
Write-Host " GitHub About: R.A.I.D. description + Discord homepage"
} catch {
Write-Warning "gh repo edit (About) failed — set description/homepage by hand."
}
}
}
}
+67 -10
View File
@@ -9,11 +9,13 @@
# --stage-disc Copy full cue+bins into repo disc/ (large; optional)
# --no-stage-disc Default: probe in place, extract boot EXE only
# --psxrecomp-ref / --recomp-ui-ref / --recomp-net-ref / URLs
# --github-owner <org> README download-badge owner (default TechnicallyComputers)
# --github-repo <name> README download-badge repo (default project name)
#
# Everything else is prompted on a TTY (or passed via flags / --yes defaults):
# name, players (1–8, default 2), zip-prefix, marketing blurb, recomp-ui,
# wizard, netplay, lobby URL (if netplay), CI workflow, fetch-boxart,
# generate, build (if generate), GitHub repo (gh)
# name, players (1–8, default 2), zip-prefix, github owner/repo (README badges),
# marketing blurb, recomp-ui, wizard, netplay, lobby URL (if netplay), CI
# workflow, fetch-boxart, generate, build (if generate), GitHub repo (gh)
#
# Non-interactive: --yes (or PSXRECOMP_SETUP_YES=1) requires --name and --disc;
# boolean opts stay off unless explicitly flagged; lobby URL defaults to
@@ -55,6 +57,8 @@ DO_GENERATE=""
DO_BUILD=""
CREATE_GITHUB=""
GITHUB_VISIBILITY="private"
GITHUB_OWNER=""
GITHUB_REPO=""
BIOS_PATH=""
LOBBY_URL=""
DESCRIPTION=""
@@ -183,6 +187,8 @@ while [ $# -gt 0 ]; do
--create-github) CREATE_GITHUB=1; SET_GITHUB=1; shift ;;
--no-github) CREATE_GITHUB=0; SET_GITHUB=1; shift ;;
--github-visibility) GITHUB_VISIBILITY=$2; shift 2 ;;
--github-owner) GITHUB_OWNER=$2; shift 2 ;;
--github-repo) GITHUB_REPO=$2; shift 2 ;;
--description) DESCRIPTION=$2; SET_DESCRIPTION=1; shift 2 ;;
--publisher) PUBLISHER=$2; SET_PUBLISHER=1; shift 2 ;;
--year) YEAR=$2; SET_YEAR=1; shift 2 ;;
@@ -264,6 +270,28 @@ if [ -z "$ZIP_PREFIX" ]; then
fi
fi
if [ -z "$GITHUB_OWNER" ]; then
if [ "$YES_MODE" -eq 1 ] || ! is_tty; then
GITHUB_OWNER=TechnicallyComputers
else
prompt_line "GitHub owner / org (README download badges)" GITHUB_OWNER \
"TechnicallyComputers"
fi
fi
if [ -z "$GITHUB_REPO" ]; then
_derived_repo=$(PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
python3 -c 'from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))' "$NAME")
if [ "$YES_MODE" -eq 1 ] || ! is_tty; then
GITHUB_REPO=$_derived_repo
else
prompt_line "GitHub repo name (README download badges)" GITHUB_REPO "$_derived_repo"
fi
fi
GITHUB_OWNER=$(PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
python3 -c 'from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))' "$GITHUB_OWNER")
GITHUB_REPO=$(PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
python3 -c 'from fill_tokens import sanitize_github_name; import sys; print(sanitize_github_name(sys.argv[1]))' "$GITHUB_REPO")
# Optional marketing (catalog_identity.json + README)
if [ "$SET_DESCRIPTION" -eq 0 ]; then
if [ "$YES_MODE" -eq 1 ] || ! is_tty; then
@@ -448,11 +476,16 @@ if [ -z "$DESCRIPTION_MD" ]; then
DESCRIPTION_MD="_Add a short pitch in catalog_identity.json / README._"
fi
ROOT=$(CDPATH= cd -- "$PARENT" && pwd)/"$NAME"
# Folder = GitHub/catalog install_dir slug (hyphenated), not display $NAME with spaces.
INSTALL_DIR_NAME="$GITHUB_REPO"
ROOT=$(CDPATH= cd -- "$PARENT" && pwd)/"$INSTALL_DIR_NAME"
if [ -e "$ROOT" ]; then
echo "error: target already exists: $ROOT" >&2
exit 1
fi
if [ "$INSTALL_DIR_NAME" != "$NAME" ]; then
echo "note: project folder is '$INSTALL_DIR_NAME' (catalog/GitHub slug; display name stays '$NAME')" >&2
fi
# --- CMake block files -----------------------------------------------------
NETPLAY_BLOCK_FILE=$(mktemp)
@@ -527,6 +560,8 @@ fill_template() {
--set "ENV_PREFIX=$ENV_PREFIX" \
--set "EXE_BASENAME=$EXE_BASENAME" \
--set "ZIP_PREFIX=$ZIP_PREFIX" \
--set "GITHUB_OWNER=$GITHUB_OWNER" \
--set "GITHUB_REPO=$GITHUB_REPO" \
--set "DISC_HINT=$DISC_HINT" \
--set "GAME_TITLE=$WINDOW_TITLE" \
--set "PLAYERS=$PLAYERS" \
@@ -550,6 +585,7 @@ echo "== New Project Layout =="
echo " repo: $ROOT"
echo " disc: $DISC"
echo " zip prefix: $ZIP_PREFIX"
echo " gh slug: $GITHUB_OWNER/$GITHUB_REPO"
echo " players: $PLAYERS"
echo " recomp-ui: $ENABLE_RECOMP_UI"
echo " wizard: $ENABLE_WIZARD"
@@ -578,9 +614,12 @@ 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/assets"
"$ROOT/mods/preloaded/packages" "$ROOT/assets" "$ROOT/.github"
cp "$SCRIPT_DIR/sync_symbols.py" "$ROOT/tools/sync_symbols.py"
chmod +x "$ROOT/tools/sync_symbols.py"
if [ -f "$TEMPLATE_DIR/raid-discord.png" ]; then
cp "$TEMPLATE_DIR/raid-discord.png" "$ROOT/.github/raid-discord.png"
fi
# Empty mod catalog tree (runtime copies mods/preloaded → beside the exe as mods/).
cat > "$ROOT/mods/preloaded/README.md" <<'EOF'
# Preloaded mods
@@ -801,6 +840,11 @@ if [ "$FETCH_BOXART_FLAG" -eq 1 ]; then
>"$BOXART_BLOCK_FILE"
fill_template "$TEMPLATE_DIR/CMakeLists.txt.in" "$ROOT/CMakeLists.txt"
echo " wired LAUNCHER_BOXART in CMakeLists.txt"
PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" python3 -c \
'from pathlib import Path; import sys; from project_studio.readme_metrics import inject_readme_boxart; inject_readme_boxart(Path(sys.argv[1]), sys.argv[2])' \
"$ROOT/README.md" "$GAME_NAME" \
&& echo " injected boxart into README.md" \
|| echo "warning: README boxart inject failed" >&2
else
echo "warning: boxart fetch failed — leave LAUNCHER_BOXART commented." >&2
fi
@@ -813,8 +857,8 @@ echo "== Sync symbols header =="
)
git add CMakeLists.txt game.toml codegen_setup.c codegen_setup.h .gitignore VERSION README.md seeds scripts tools mods framework_pins.txt symbols.toml psx_symbols.h || true
if [ -d "$ROOT/.github/workflows" ]; then
git add .github/workflows || true
if [ -d "$ROOT/.github" ]; then
git add .github || true
fi
if [ -f "$ROOT/catalog_identity.json" ]; then
git add catalog_identity.json || true
@@ -823,7 +867,7 @@ if [ -f "$ROOT/disc_probe.json" ]; then
git add disc_probe.json || true
fi
if [ "$HAS_BOXART" -eq 1 ]; then
git add launcher_assets/img/boxart.tga launcher_assets/img/BOXART_SOURCE.txt || true
git add launcher_assets/img/boxart.tga launcher_assets/img/boxart.png launcher_assets/img/BOXART_SOURCE.txt || true
fi
COMMITTED=0
if git -c user.email=setup@localhost -c user.name=setup \
@@ -847,14 +891,20 @@ if [ "${CREATE_GITHUB:-0}" -eq 1 ]; then
VIS_FLAG="--private"
[ "$GITHUB_VISIBILITY" = "public" ] && VIS_FLAG="--public"
[ "$GITHUB_VISIBILITY" = "internal" ] && VIS_FLAG="--internal"
if gh repo create "$NAME" $VIS_FLAG --source="$ROOT" --remote=origin; then
GH_SLUG="$GITHUB_OWNER/$GITHUB_REPO"
GH_DESC=$(PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
python3 -c 'from fill_tokens import GITHUB_ABOUT_DESCRIPTION; print(GITHUB_ABOUT_DESCRIPTION)')
GH_HOME=$(PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
python3 -c 'from fill_tokens import GITHUB_ABOUT_HOMEPAGE; print(GITHUB_ABOUT_HOMEPAGE)')
if gh repo create "$GH_SLUG" $VIS_FLAG --source="$ROOT" --remote=origin \
--description "$GH_DESC" --homepage "$GH_HOME"; then
echo " created origin ($GITHUB_VISIBILITY); push deferred until end"
GITHUB_CREATED=1
else
echo "warning: gh repo create failed — if the repo already exists," >&2
echo " add origin and push manually at the end." >&2
if ! git remote get-url origin >/dev/null 2>&1; then
_gh_url=$(gh repo view "$NAME" --json url -q .url 2>/dev/null || true)
_gh_url=$(gh repo view "$GH_SLUG" --json url -q .url 2>/dev/null || true)
if [ -n "$_gh_url" ]; then
git remote add origin "$_gh_url"
echo " attached existing origin → $_gh_url"
@@ -864,6 +914,13 @@ if [ "${CREATE_GITHUB:-0}" -eq 1 ]; then
GITHUB_CREATED=1
fi
fi
if [ "$GITHUB_CREATED" -eq 1 ]; then
if gh repo edit "$GH_SLUG" --description "$GH_DESC" --homepage "$GH_HOME"; then
echo " GitHub About: R.A.I.D. description + Discord homepage"
else
echo "warning: gh repo edit (About) failed — set description/homepage by hand." >&2
fi
fi
fi
fi
@@ -1,5 +1,11 @@
# @WINDOW_TITLE@
<!-- retcomm-readme-metrics -->
[![GitHub downloads (all assets, all releases)](https://img.shields.io/github/downloads/@GITHUB_OWNER@/@GITHUB_REPO@/total)](https://github.com/@GITHUB_OWNER@/@GITHUB_REPO@/releases)
[![GitHub downloads (latest release)](https://img.shields.io/github/downloads/@GITHUB_OWNER@/@GITHUB_REPO@/latest/total)](https://github.com/@GITHUB_OWNER@/@GITHUB_REPO@/releases/latest)
[![GitHub release](https://img.shields.io/github/v/release/@GITHUB_OWNER@/@GITHUB_REPO@)](https://github.com/@GITHUB_OWNER@/@GITHUB_REPO@/releases/latest)
<!-- /retcomm-readme-metrics -->
Static recompilation of **@GAME_NAME@** built on
[psxrecomp](https://github.com/mstan/psxrecomp) and
[recomp-ui](https://github.com/mstan/recomp-ui).
@@ -16,6 +22,31 @@ Static recompilation of **@GAME_NAME@** built on
Scaffolded with the New Project Layout. See
`psxrecomp/docs/GAME_PROJECT_SETUP.md` for the full flow.
<!-- retcomm-readme-launcher -->
## RetComM Launcher
You can run this title **standalone** (release zip + the built-in recomp-ui
Generate & Build flow), or manage installs, updates, ROM/BIOS wiring, and queued
builds more intuitively with
**[RetComM Launcher](https://github.com/TechnicallyComputers/RetComM-Launcher)** —
the Retro Compilation Manager hub for self-compiling recomps.
[Downloads](https://github.com/TechnicallyComputers/RetComM-Launcher/releases) ·
[Full README & features](https://github.com/TechnicallyComputers/RetComM-Launcher#readme)
<p align="center">
<img src="https://raw.githubusercontent.com/TechnicallyComputers/RetComM-Launcher/main/docs/screenshots/hub-and-game-launcher.png" alt="RetComM hub with a background build, next to a title’s recomp-ui launcher" width="720">
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/TechnicallyComputers/RetComM-Launcher/main/docs/screenshots/queue-and-background-build.png" alt="Background cmake build with titles queued" width="720">
</p>
RetComM checks for updates, rebuilds with existing build data when possible,
shares the portable toolchain used by per-title launchers, and automates
BIOS/ROM/save plumbing so you are not stuck repeating each game’s wizard by hand.
<!-- /retcomm-readme-launcher -->
## Legal
You must own the original game. Disc images under `disc/` are gitignored and
@@ -39,12 +70,6 @@ cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release --target psx-runtime
```
`psxrecomp/runtime/runtime.cmake` must exist before configuring. If CMake says
the runtime is missing, the framework submodule is not checked out; run
`git submodule update --init --recursive`, or pass
`-DPSXRECOMP_ROOT=<path-to-psxrecomp>` when intentionally building against a
separate framework checkout.
Zip prefix for CI artifacts: `@ZIP_PREFIX@`.
## Symbols
@@ -59,3 +84,15 @@ are authoritative. `framework_pins.txt` is an optional scaffold snapshot;
release CI logs SHAs with `record_pins.sh` but builds whatever the gitlinks
resolve to. Bump submodules deliberately — do not float on `main`/`master`
in release CI.
<!-- retcomm-readme-raid -->
---
<p align="center">
<sub><b>R.A.I.D. — Retro AI Development</b> · a Discord for AI-assisted retro reverse-engineering, decomp &amp; recomp</sub>
</p>
<p align="center">
<a href="https://discord.gg/Ad9BwSzctP"><img src=".github/raid-discord.png" alt="Join the Retro AI Development (R.A.I.D.) Discord" width="200"></a>
</p>
<!-- /retcomm-readme-raid -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB