mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
tools: extend new project setup workflow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"[]({base}/releases)",
|
||||
f"[]({base}/releases/latest)",
|
||||
f"[]({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 & 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"
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
[](https://github.com/@GITHUB_OWNER@/@GITHUB_REPO@/releases)
|
||||
[](https://github.com/@GITHUB_OWNER@/@GITHUB_REPO@/releases/latest)
|
||||
[](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 & 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 |
Reference in New Issue
Block a user