diff --git a/tools/new_project_layout/fetch_boxart.py b/tools/new_project_layout/fetch_boxart.py index edc4cd80..8eebffff 100755 --- a/tools/new_project_layout/fetch_boxart.py +++ b/tools/new_project_layout/fetch_boxart.py @@ -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/.png https://raw.githubusercontent.com/libretro-thumbnails/Sony_-_PlayStation/master/Named_Boxarts/.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 diff --git a/tools/new_project_layout/fill_tokens.py b/tools/new_project_layout/fill_tokens.py index 012ca948..43e6c048 100755 --- a/tools/new_project_layout/fill_tokens.py +++ b/tools/new_project_layout/fill_tokens.py @@ -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") diff --git a/tools/new_project_layout/project_studio/bulkops.py b/tools/new_project_layout/project_studio/bulkops.py index 79a08e0c..a91ee709 100644 --- a/tools/new_project_layout/project_studio/bulkops.py +++ b/tools/new_project_layout/project_studio/bulkops.py @@ -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 diff --git a/tools/new_project_layout/project_studio/cli.py b/tools/new_project_layout/project_studio/cli.py index 87aabf30..cc2a65ac 100644 --- a/tools/new_project_layout/project_studio/cli.py +++ b/tools/new_project_layout/project_studio/cli.py @@ -62,6 +62,8 @@ def _options_from_args(args: argparse.Namespace) -> MigrateOptions: boot_exe=args.boot_exe, players=args.players, zip_prefix=args.zip_prefix, + github_owner=getattr(args, "github_owner", None) or None, + github_repo=getattr(args, "github_repo", None) or None, window_title=args.window_title, enable_recomp_ui=not args.no_recomp_ui, enable_wizard=not args.no_wizard, @@ -152,10 +154,394 @@ def cmd_ops(_: argparse.Namespace) -> int: def cmd_gui(args: argparse.Namespace) -> int: - from project_studio.gui import run_gui + """Launch the native Dear ImGui Studio binary when available.""" + import os + import shutil + import subprocess root = Path(args.root).expanduser().resolve() if args.root else None - return run_gui(initial_root=root) + candidates: list[Path] = [] + env_bin = (os.environ.get("RETCOMM_STUDIO_BIN") or "").strip() + if env_bin: + candidates.append(Path(env_bin)) + here = Path(__file__).resolve() + # tools/new_project_layout/project_studio/cli.py → repo root + repo = here.parents[3] if len(here.parents) > 3 else here.parents[2] + for rel in ( + Path("build") / "RetComM-Studio", + Path("build") / "retcomm-studio", + Path("build-release") / "RetComM-Studio", + Path("out") / "bin" / "RetComM-Studio", + ): + candidates.append(repo / rel) + which = shutil.which("RetComM-Studio") or shutil.which("retcomm-studio") + if which: + candidates.append(Path(which)) + + for cand in candidates: + if cand.is_file() and os.access(cand, os.X_OK): + cmd = [str(cand)] + if root is not None: + os.environ["RETCOMM_STUDIO_INITIAL_ROOT"] = str(root) + return int(subprocess.call(cmd)) + + print( + "RetComM Studio GUI is Dear ImGui (native).\n" + "Build it first:\n" + " cmake -S . -B build && cmake --build build\n" + " ./build/RetComM-Studio\n" + "Or set RETCOMM_STUDIO_BIN to the executable path.", + file=sys.stderr, + ) + return 2 + + +def _index_to_json_dict(index) -> dict: + from project_studio.repo_index import labels_for_repos + + from project_studio.bulkops import filter_indexed_catalog + from project_studio.naming import configured_players + + data = index.to_dict() + labels = labels_for_repos(index.repos) + catalog_hits, _note = filter_indexed_catalog(index) + catalog_paths = {e.path for e in catalog_hits} + repos_out = [] + for entry, label in zip(index.repos, labels): + try: + players = configured_players(entry.resolved()) + except OSError: + players = 2 + repos_out.append( + { + "path": entry.path, + "name": entry.name, + "cue": entry.cue, + "label": label, + "in_catalog": entry.path in catalog_paths, + "players": players, + } + ) + data["repos"] = repos_out + return data + + +def _print_index_json(index) -> int: + print(json.dumps(_index_to_json_dict(index), indent=2)) + return 0 + + +def cmd_repos_list(_: argparse.Namespace) -> int: + from project_studio.repo_index import load_index + + return _print_index_json(load_index()) + + +def cmd_repos_add(args: argparse.Namespace) -> int: + from project_studio.repo_index import add_repo, load_index, looks_like_game_repo + + root = Path(args.path).expanduser().resolve() + if not root.is_dir(): + print(f"error: not a directory: {root}", file=sys.stderr) + return 2 + if not looks_like_game_repo(root) and not args.force: + print( + f"error: does not look like a game repo (pass --force): {root}", + file=sys.stderr, + ) + return 2 + idx = load_index() + add_repo(idx, root, name=args.name or "", cue=args.cue or "") + return _print_index_json(idx) + + +def cmd_repos_remove(args: argparse.Namespace) -> int: + from project_studio.repo_index import load_index, remove_repo + + idx = load_index() + if not remove_repo(idx, args.path): + print(f"error: not in index: {args.path}", file=sys.stderr) + return 2 + return _print_index_json(idx) + + +def cmd_repos_set_cue(args: argparse.Namespace) -> int: + from project_studio.repo_index import load_index, set_repo_cue + + idx = load_index() + entry = set_repo_cue(idx, args.path, args.cue) + if entry is None: + print(f"error: not in index: {args.path}", file=sys.stderr) + return 2 + return _print_index_json(idx) + + +def cmd_repos_set_last(args: argparse.Namespace) -> int: + from project_studio.repo_index import load_index, set_last + + idx = load_index() + set_last(idx, args.path) + return _print_index_json(idx) + + +def cmd_repos_set_flags(args: argparse.Namespace) -> int: + from project_studio.repo_index import load_index, save_index + + idx = load_index() + if args.catalog_only is not None: + idx.catalog_only = str(args.catalog_only).strip() in ("1", "true", "yes", "on") + if args.bulk_jobs is not None: + try: + jobs = int(args.bulk_jobs) + except (TypeError, ValueError): + jobs = idx.bulk_jobs + idx.bulk_jobs = max(1, min(4, jobs)) + if args.log_height is not None: + try: + h = int(args.log_height) + except (TypeError, ValueError): + h = idx.log_height + idx.log_height = max(100, min(800, h)) + save_index(idx) + return _print_index_json(idx) + + +def cmd_repos_filter_catalog(_: argparse.Namespace) -> int: + """JSON paths for indexed repos that map to retcomm-catalog titles.""" + from project_studio.bulkops import filter_indexed_catalog + from project_studio.repo_index import load_index + + hits, note = filter_indexed_catalog(load_index()) + print( + json.dumps( + { + "note": note, + "paths": [e.path for e in hits], + "labels": [e.label() for e in hits], + }, + indent=2, + ) + ) + return 0 + + +def cmd_repos_filter_catalog_contributors(_: argparse.Namespace) -> int: + """JSON paths for catalog repos where the viewer has WRITE+ (gh).""" + from project_studio.bulkops import filter_indexed_catalog_contributors + from project_studio.repo_index import load_index + + hits, note, logs = filter_indexed_catalog_contributors(load_index()) + for line in logs: + print(line, file=sys.stderr) + print( + json.dumps( + { + "note": note, + "paths": [e.path for e in hits], + "labels": [e.label() for e in hits], + "logs": logs, + }, + indent=2, + ) + ) + return 0 + + +def cmd_updates_check(args: argparse.Namespace) -> int: + from project_studio.updater import check_updates, check_updates_on_startup_enabled + + if getattr(args, "startup", False) and not check_updates_on_startup_enabled(): + if args.json: + print( + json.dumps( + { + "ok": True, + "skipped": True, + "message": "Startup update check disabled in config.", + "studio": {"available": False}, + "toolchain": {"available": False}, + }, + indent=2, + ) + ) + else: + print("Startup update check disabled in config.") + return 0 + + def prog(msg: str) -> None: + print(msg, file=sys.stderr) + + result = check_updates(on_progress=prog) + if args.json: + def comp(c) -> dict: + return { + "kind": c.kind, + "current": c.current, + "latest": c.latest, + "available": c.available, + "supported": c.supported, + "message": c.message, + "asset_name": c.asset_name, + } + + print( + json.dumps( + { + "ok": result.ok, + "message": result.message, + "studio": comp(result.studio), + "toolchain": comp(result.toolchain), + }, + indent=2, + ) + ) + else: + print(result.studio.message) + print(result.toolchain.message) + if result.message: + print(result.message) + return 0 if result.ok else 1 + + +def cmd_updates_apply(args: argparse.Namespace) -> int: + from project_studio.updater import apply_updates, check_updates + + def prog(msg: str) -> None: + print(msg, file=sys.stderr) + + result = check_updates(on_progress=prog) + summary, should_exit = apply_updates( + result, + update_studio=not args.toolchain_only, + update_toolchain=not args.studio_only, + on_progress=prog, + ) + print(summary) + if should_exit: + return 0 + return 0 + + +def cmd_updates_ensure_toolchain(args: argparse.Namespace) -> int: + from project_studio.updater import ensure_toolchain, resolve_toolchain_python + + def prog(msg: str) -> None: + print(msg, file=sys.stderr) + + ok, msg = ensure_toolchain(on_progress=prog) + print(f"[{'OK' if ok else 'FAIL'}] {msg}") + py = resolve_toolchain_python() + if py: + print(f"python: {py}") + return 0 if ok else 1 + + +def cmd_new_project(args: argparse.Namespace) -> int: + from project_studio.newproject import ( + NewProjectOptions, + index_new_project, + project_root_for, + run_new_project, + validate_options, + ) + + opts = NewProjectOptions( + name=(args.name or "").strip(), + disc=(args.disc or "").strip(), + parent_dir=(args.dir or ".").strip(), + bios=(getattr(args, "bios", None) or "").strip(), + boot_exe=(getattr(args, "boot_exe", None) or "").strip(), + players=int(getattr(args, "players", 2) or 2), + zip_prefix=(getattr(args, "zip_prefix", None) or "").strip(), + github_owner=(getattr(args, "github_owner", None) or "").strip(), + github_repo=(getattr(args, "github_repo", None) or "").strip(), + description=(getattr(args, "description", None) or "").strip(), + publisher=(getattr(args, "publisher", None) or "").strip(), + year=(getattr(args, "year", None) or "").strip(), + region=(getattr(args, "region", None) or "USA").strip(), + enable_recomp_ui=not bool(getattr(args, "no_recomp_ui", False)), + enable_wizard=not bool(getattr(args, "no_wizard", False)), + enable_netplay=bool(getattr(args, "enable_netplay", False)), + lobby_url=(getattr(args, "lobby_url", None) or "netplay.retcomm.net").strip(), + enable_ci=not bool(getattr(args, "no_ci", False)), + fetch_boxart=not bool(getattr(args, "no_fetch_boxart", False)), + stage_disc=bool(getattr(args, "stage_disc", False)), + do_generate=bool(getattr(args, "generate", False)), + do_build=bool(getattr(args, "enable_build", False)), + create_github=bool(getattr(args, "create_github", False)), + github_visibility=( + getattr(args, "github_visibility", None) or "private" + ).strip(), + psxrecomp_ref=(getattr(args, "psxrecomp_ref", None) or "master").strip(), + recomp_ui_ref=(getattr(args, "recomp_ui_ref", None) or "master").strip(), + recomp_net_ref=(getattr(args, "recomp_net_ref", None) or "").strip(), + rbengine_ref=(getattr(args, "rbengine_ref", None) or "").strip(), + dry_run=bool(getattr(args, "dry_run", False)), + ) + + if bool(getattr(args, "autofill_meta", False)): + from project_studio.discmeta import apply_hit_to_options, lookup_cue + + print("Looking up disc metadata (Redump / libretro / catalog)…", flush=True) + hit = lookup_cue(opts.disc) + for note in hit.notes: + print(f" meta: {note}", flush=True) + filled = apply_hit_to_options(opts, hit, only_empty=True) + if filled: + print(" filled: " + ", ".join(filled), flush=True) + else: + print(f" no empty fields filled (source={hit.source})", flush=True) + + errs = validate_options(opts) + if errs: + for e in errs: + print(f"error: {e}", file=sys.stderr) + return 2 + + def on_line(msg: str) -> None: + print(msg, flush=True) + + r = run_new_project(opts, on_line=on_line) + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + if not r.ok: + return 1 + if opts.dry_run: + return 0 + root = project_root_for(opts) + ir = index_new_project(root, name=opts.name, cue=opts.disc) + print(f"[{'OK' if ir.ok else 'FAIL'}] {ir.message}") + return 0 if ir.ok else 1 + + +def cmd_lookup_disc_meta(args: argparse.Namespace) -> int: + from project_studio.discmeta import lookup_cue, lookup_digests + + force = bool(getattr(args, "force_refresh", False)) + if getattr(args, "disc", None): + hit = lookup_cue(args.disc, force_refresh=force) + else: + hit = lookup_digests( + crc32=getattr(args, "crc32", "") or "", + md5=getattr(args, "md5", "") or "", + sha1=getattr(args, "sha1", "") or "", + serial=getattr(args, "serial", "") or "", + force_refresh=force, + ) + if args.json: + print(json.dumps(hit.to_dict(), indent=2)) + else: + print(f"source: {hit.source}") + print(f"name: {hit.name}") + print(f"serial: {hit.serial}") + print(f"region: {hit.region}") + print(f"players: {hit.players}") + print(f"publisher: {hit.publisher}") + print(f"year: {hit.year}") + print(f"description: {(hit.description or '')[:200]}") + print(f"crc32/md5/sha1: {hit.crc32} / {hit.md5} / {hit.sha1}") + for n in hit.notes: + print(f"note: {n}") + return 0 if hit.source != "none" else 1 def _root_or_die(args: argparse.Namespace) -> Path | None: @@ -166,6 +552,105 @@ def _root_or_die(args: argparse.Namespace) -> Path | None: return root +def cmd_git_branches(args: argparse.Namespace) -> int: + """List game + module branch names as JSON for Studio dropdowns.""" + from project_studio.gitops import ( + DEFAULT_PSXRECOMP_URL, + DEFAULT_RBENGINE_URL, + DEFAULT_RECOMP_NET_URL, + DEFAULT_RECOMP_UI_URL, + current_branch, + list_branches, + list_module_branches, + list_remote_head_branches, + resolve_module_dir, + ) + + def prefer_current(names: list[str], cur: str) -> list[str]: + cur = (cur or "").strip() + if not cur: + return names + rest = [n for n in names if n != cur] + return [cur, *rest] + + fetch = bool(getattr(args, "fetch", False)) + root_s = (getattr(args, "root", None) or "").strip() + out: dict = { + "game": [], + "psxrecomp": [], + "recomp-ui": [], + "recomp-net": [], + "rbengine": [], + "current": { + "game": "", + "psxrecomp": "", + "recomp-ui": "", + "recomp-net": "", + "rbengine": "", + }, + } + if root_s: + root = Path(root_s).expanduser().resolve() + if not root.is_dir(): + print(f"error: not a directory: {root}", file=sys.stderr) + return 2 + out["game"] = list_branches(root, remotes=True, fetch=fetch) + out["psxrecomp"] = list_module_branches( + root, "psxrecomp", remotes=True, fetch=fetch, url_fallback=DEFAULT_PSXRECOMP_URL + ) + out["recomp-ui"] = list_module_branches( + root, "recomp-ui", remotes=True, fetch=fetch, url_fallback=DEFAULT_RECOMP_UI_URL + ) + out["recomp-net"] = list_module_branches( + root, + "lib/recomp-net", + nested=True, + remotes=True, + fetch=fetch, + url_fallback=DEFAULT_RECOMP_NET_URL, + ) + out["rbengine"] = list_module_branches( + root, + "lib/retcomm-rbengine", + nested=True, + remotes=True, + fetch=fetch, + url_fallback=DEFAULT_RBENGINE_URL, + ) + cur = out["current"] + cur["game"] = current_branch(root) or "" + for key, path, nested in ( + ("psxrecomp", "psxrecomp", False), + ("recomp-ui", "recomp-ui", False), + ("recomp-net", "lib/recomp-net", True), + ("rbengine", "lib/retcomm-rbengine", True), + ): + sub = resolve_module_dir(root, path, nested=nested) + cur[key] = (current_branch(sub) or "") if sub is not None else "" + # Put live checkouts first so dropdowns open on the active branch. + out["game"] = prefer_current(out["game"], cur["game"]) + out["psxrecomp"] = prefer_current(out["psxrecomp"], cur["psxrecomp"]) + out["recomp-ui"] = prefer_current(out["recomp-ui"], cur["recomp-ui"]) + out["recomp-net"] = prefer_current(out["recomp-net"], cur["recomp-net"]) + out["rbengine"] = prefer_current(out["rbengine"], cur["rbengine"]) + else: + # New-project / no checkout: ls-remote default module URLs. + out["psxrecomp"] = list_remote_head_branches(DEFAULT_PSXRECOMP_URL) + out["recomp-ui"] = list_remote_head_branches(DEFAULT_RECOMP_UI_URL) + out["recomp-net"] = list_remote_head_branches(DEFAULT_RECOMP_NET_URL) + out["rbengine"] = list_remote_head_branches(DEFAULT_RBENGINE_URL) + # Sensible game-branch placeholders when scaffolding. + out["game"] = ["main", "master"] + + # Always offer (default) for nested/ref menus that support it. + for key in ("recomp-net", "rbengine"): + if "(default)" not in out[key]: + out[key] = ["(default)", *out[key]] + + print(json.dumps(out, indent=2)) + return 0 + + def cmd_git_status(args: argparse.Namespace) -> int: from project_studio.gitops import repo_status @@ -261,8 +746,8 @@ def cmd_git_ensure_nested(args: argparse.Namespace) -> int: def cmd_git_set_branch(args: argparse.Namespace) -> int: from project_studio.gitops import ( set_nested_branch, - set_repo_branch, set_submodule_branch, + switch_branch, ) root = _root_or_die(args) @@ -278,7 +763,7 @@ def cmd_git_set_branch(args: argparse.Namespace) -> int: root, args.submodule, args.branch, dry_run=args.dry_run ) else: - r = set_repo_branch( + r = switch_branch( root, args.branch, create=args.create, dry_run=args.dry_run ) print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") @@ -287,6 +772,158 @@ def cmd_git_set_branch(args: argparse.Namespace) -> int: return 0 if r.ok else 1 +def _git_target_flags(args: argparse.Namespace) -> dict[str, bool]: + """Resolve Game / Modules / Nested / psxrecomp targets for a single-root op. + + When no target flags are set, default to the game root (legacy ``git pull`` / + ``git switch --branch``). + """ + game = bool(getattr(args, "game", False)) + modules = bool(getattr(args, "modules", False)) + nested = bool(getattr(args, "nested", False)) + psx = bool(getattr(args, "psxrecomp", False)) + if not (game or modules or nested or psx): + game = True + return { + "game": game, + "modules": modules, + "nested": nested, + "psxrecomp": psx, + } + + +def cmd_git_switch(args: argparse.Namespace) -> int: + from project_studio.gitops import ( + CmdResult, + default_module_paths, + switch_branch, + switch_modules, + switch_psxrecomp, + ) + + root = _root_or_die(args) + if root is None: + return 2 + branch = (getattr(args, "branch", None) or "").strip() + create = bool(getattr(args, "create", False)) + set_tracking = not bool(getattr(args, "no_track", False)) + submodule = (getattr(args, "submodule", None) or "").strip() + psx_branch = (getattr(args, "psxrecomp_branch", None) or "").strip() + ui_branch = (getattr(args, "ui_branch", None) or "").strip() + net_branch = (getattr(args, "net_branch", None) or "").strip() + rb_branch = (getattr(args, "rb_branch", None) or "").strip() + per_module = bool(psx_branch or ui_branch or net_branch or rb_branch) + multi = bool(getattr(args, "game", False)) or ( + sum( + bool(x) + for x in ( + getattr(args, "modules", False), + getattr(args, "nested", False), + getattr(args, "psxrecomp", False), + ) + ) + > 1 + ) + + # Single-path legacy: --submodule PATH [--nested] [--branch] + if submodule and not multi and not per_module and not getattr(args, "game", False): + nested = bool(getattr(args, "nested", False)) + paths = [submodule] + branch_by_path = None + if branch: + branch_by_path = {submodule.strip().replace("\\", "/"): branch} + results = switch_modules( + root, + paths=paths, + nested=nested, + branch_by_path=branch_by_path, + create=create, + set_tracking=set_tracking, + dry_run=args.dry_run, + ) + return _print_module_results(results) + + t = _git_target_flags(args) + results: list[CmdResult] = [] + + if t["game"]: + if not branch: + print("error: --game / game root requires --branch NAME", file=sys.stderr) + return 2 + r = switch_branch(root, branch, create=create, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + + if t["modules"]: + branch_by_path: dict[str, str] | None = None + paths = _module_paths_from_args(args) + if psx_branch or ui_branch: + branch_by_path = {} + if psx_branch: + branch_by_path["psxrecomp"] = psx_branch + if ui_branch: + branch_by_path["recomp-ui"] = ui_branch + paths = list(branch_by_path.keys()) + elif branch and not t["game"]: + # Legacy: ``git switch --modules --branch X`` applies X to all modules. + want = paths or list(default_module_paths(nested=False)) + branch_by_path = {p.strip().replace("\\", "/"): branch for p in want} + results.extend( + switch_modules( + root, + paths=paths, + nested=False, + branch_by_path=branch_by_path, + create=create, + set_tracking=set_tracking, + dry_run=args.dry_run, + ) + ) + elif t["psxrecomp"]: + use = psx_branch or branch + if not use: + print( + "error: --psxrecomp requires --branch or --psxrecomp-branch", + file=sys.stderr, + ) + return 2 + r = switch_psxrecomp(root, use, create=create, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + + if t["nested"]: + branch_by_path = None + paths = _module_paths_from_args(args) + if net_branch or rb_branch: + branch_by_path = {} + if net_branch: + branch_by_path["lib/recomp-net"] = net_branch + if rb_branch: + branch_by_path["lib/retcomm-rbengine"] = rb_branch + paths = list(branch_by_path.keys()) + elif branch and not t["modules"] and not t["game"]: + # Legacy: ``git switch --nested --branch X`` applies X to all nested. + want = paths or list(default_module_paths(nested=True)) + branch_by_path = {p.strip().replace("\\", "/"): branch for p in want} + results.extend( + switch_modules( + root, + paths=paths, + nested=True, + branch_by_path=branch_by_path, + create=create, + set_tracking=set_tracking, + dry_run=args.dry_run, + ) + ) + + if not results: + print( + "error: nothing to switch (pass --game / --modules / --nested)", + file=sys.stderr, + ) + return 2 + return _print_module_results(results) + + def cmd_git_update_submodules(args: argparse.Namespace) -> int: from project_studio.gitops import update_submodules @@ -353,92 +990,319 @@ def _print_module_results(results: list) -> int: return 1 if failed else 0 +def _bulk_select_from_args(args: argparse.Namespace) -> list[str] | None: + raw = getattr(args, "select", None) or "" + parts = [p.strip() for p in raw.split(",") if p.strip()] + return parts or None + + +def _bulk_repos_or_die(args: argparse.Namespace): + from project_studio.bulkops import indexed_repos + + repos = indexed_repos(select=_bulk_select_from_args(args)) + if not repos: + print("No indexed repos matched (Add… in GUI, or --select filter).") + return None + return repos + + +def _bulk_targets_from_args(args: argparse.Namespace) -> dict: + game = bool(getattr(args, "game", False)) + modules = bool(getattr(args, "modules", False)) + psx = bool(getattr(args, "psxrecomp", False)) + nested = bool(getattr(args, "nested", False)) + # Default: game root only when nothing specified. + if not (game or modules or psx or nested): + game = True + return { + "game": game, + "modules": modules, + "psxrecomp": psx, + "nested": nested, + } + + +def cmd_git_bulk_status(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_status + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + return _print_module_results(bulk_status(repos)) + + +def cmd_git_bulk_pull(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_pull + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + return _print_module_results( + bulk_pull( + repos, + **_bulk_targets_from_args(args), + mode=getattr(args, "mode", None) or "ff-only", + dirty=getattr(args, "dirty", None) or "fail", + dry_run=args.dry_run, + ) + ) + + +def cmd_git_bulk_push(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_push + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + return _print_module_results( + bulk_push( + repos, + **_bulk_targets_from_args(args), + dry_run=args.dry_run, + ) + ) + + +def cmd_git_bulk_commit(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_commit + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + t = _bulk_targets_from_args(args) + return _print_module_results( + bulk_commit( + repos, + args.message, + game=t["game"], + modules=t["modules"], + nested=t["nested"], + dry_run=args.dry_run, + ) + ) + + +def cmd_git_bulk_switch(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_switch + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + t = _bulk_targets_from_args(args) + # For switch, defaulting to game-only when no flags is surprising if they + # only passed --psxrecomp-branch — require at least one explicit target. + if not any( + ( + getattr(args, "game", False), + getattr(args, "modules", False), + getattr(args, "psxrecomp", False), + getattr(args, "nested", False), + ) + ): + print( + "error: pass --game / --modules / --psxrecomp / --nested " + "(which checkouts to switch)", + file=sys.stderr, + ) + return 2 + return _print_module_results( + bulk_switch( + repos, + game=t["game"], + modules=t["modules"], + psxrecomp=t["psxrecomp"], + nested=t["nested"], + game_branch=getattr(args, "branch", None) or "", + psxrecomp_branch=getattr(args, "psxrecomp_branch", None) or "", + recomp_ui_branch=getattr(args, "ui_branch", None) or "", + recomp_net_branch=getattr(args, "net_branch", None) or "", + rbengine_branch=getattr(args, "rb_branch", None) or "", + create=bool(getattr(args, "create", False)), + set_tracking=not bool(getattr(args, "no_track", False)), + dry_run=args.dry_run, + ) + ) + + def cmd_git_pull(args: argparse.Namespace) -> int: - from project_studio.gitops import pull, pull_modules, pull_psxrecomp + from project_studio.gitops import CmdResult, pull, pull_modules, pull_psxrecomp root = _root_or_die(args) if root is None: return 2 - if getattr(args, "psxrecomp", False): - r = pull_psxrecomp(root, dry_run=args.dry_run) - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") - if r.detail: - print(r.detail) - return 0 if r.ok else 1 - if getattr(args, "modules", False) or getattr(args, "nested", False): - results = pull_modules( - root, - paths=_module_paths_from_args(args), - nested=bool(getattr(args, "nested", False)), - dry_run=args.dry_run, + mode = getattr(args, "mode", None) or "ff-only" + dirty = getattr(args, "dirty", None) or "fail" + t = _git_target_flags(args) + results: list[CmdResult] = [] + if t["game"]: + r = pull(root, mode=mode, dirty=dirty, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + if t["modules"]: + results.extend( + pull_modules( + root, + paths=_module_paths_from_args(args), + nested=False, + mode=mode, + dirty=dirty, + dry_run=args.dry_run, + ) ) - return _print_module_results(results) - r = pull(root, dry_run=args.dry_run) - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") - if r.detail: - print(r.detail) - return 0 if r.ok else 1 + elif t["psxrecomp"]: + r = pull_psxrecomp(root, mode=mode, dirty=dirty, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + if t["nested"]: + results.extend( + pull_modules( + root, + paths=_module_paths_from_args(args), + nested=True, + mode=mode, + dirty=dirty, + dry_run=args.dry_run, + ) + ) + if not results: + print("error: nothing to pull (pass --game / --modules / --nested)", file=sys.stderr) + return 2 + return _print_module_results(results) def cmd_git_commit(args: argparse.Namespace) -> int: - from project_studio.gitops import commit_all, commit_modules + from project_studio.gitops import CmdResult, commit_all, commit_modules root = _root_or_die(args) if root is None: return 2 - if getattr(args, "modules", False) or getattr(args, "nested", False): - results = commit_modules( - root, - args.message, - paths=_module_paths_from_args(args), - nested=bool(getattr(args, "nested", False)), - dry_run=args.dry_run, + t = _git_target_flags(args) + results: list[CmdResult] = [] + if t["game"]: + r = commit_all(root, args.message, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + if t["modules"]: + results.extend( + commit_modules( + root, + args.message, + paths=_module_paths_from_args(args), + nested=False, + dry_run=args.dry_run, + ) ) - return _print_module_results(results) - r = commit_all(root, args.message, dry_run=args.dry_run) - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") - if r.detail: - print(r.detail) - return 0 if r.ok else 1 + if t["nested"]: + results.extend( + commit_modules( + root, + args.message, + paths=_module_paths_from_args(args), + nested=True, + dry_run=args.dry_run, + ) + ) + if not results: + print( + "error: nothing to commit (pass --game / --modules / --nested)", + file=sys.stderr, + ) + return 2 + return _print_module_results(results) def cmd_git_push(args: argparse.Namespace) -> int: - from project_studio.gitops import push, push_modules, push_psxrecomp + from project_studio.gitops import ( + CmdResult, + default_module_paths, + push, + push_modules, + push_psxrecomp, + ) root = _root_or_die(args) if root is None: return 2 branch = (getattr(args, "branch", None) or "").strip() - if getattr(args, "psxrecomp", False): - r = push_psxrecomp(root, branch=branch, dry_run=args.dry_run) - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") - if r.detail: - print(r.detail) - return 0 if r.ok else 1 - if getattr(args, "modules", False) or getattr(args, "nested", False): + t = _git_target_flags(args) + results: list[CmdResult] = [] + if t["game"]: + r = push(root, branch=branch, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + if t["modules"]: paths = _module_paths_from_args(args) branch_by_path = None if branch and paths and len(paths) == 1: branch_by_path = {paths[0]: branch} elif branch and not paths: - # Apply same branch hint to all default paths (detached rescue). - nested = bool(getattr(args, "nested", False)) - from project_studio.gitops import default_module_paths - - branch_by_path = {p: branch for p in default_module_paths(nested=nested)} - results = push_modules( - root, - paths=paths, - nested=bool(getattr(args, "nested", False)), - branch_by_path=branch_by_path, - dry_run=args.dry_run, + branch_by_path = {p: branch for p in default_module_paths(nested=False)} + results.extend( + push_modules( + root, + paths=paths, + nested=False, + branch_by_path=branch_by_path, + dry_run=args.dry_run, + ) ) - return _print_module_results(results) - r = push(root, branch=branch, dry_run=args.dry_run) - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") - if r.detail: - print(r.detail) - return 0 if r.ok else 1 + elif t["psxrecomp"]: + r = push_psxrecomp(root, branch=branch, dry_run=args.dry_run) + results.append(CmdResult(r.ok, r.message, r.detail)) + if t["nested"]: + paths = _module_paths_from_args(args) + branch_by_path = None + if branch and paths and len(paths) == 1: + branch_by_path = {paths[0]: branch} + elif branch and not paths and not t["modules"] and not t["game"]: + branch_by_path = {p: branch for p in default_module_paths(nested=True)} + results.extend( + push_modules( + root, + paths=paths, + nested=True, + branch_by_path=branch_by_path, + dry_run=args.dry_run, + ) + ) + if not results: + print( + "error: nothing to push (pass --game / --modules / --nested)", + file=sys.stderr, + ) + return 2 + return _print_module_results(results) + + +def cmd_git_bulk_release(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_release + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + results = bulk_release( + repos, + version=getattr(args, "version", "") or "", + bump=getattr(args, "bump", "patch") or "patch", + publish=not bool(getattr(args, "no_publish", False)), + reuse_cached_emitters=not bool( + getattr(args, "no_reuse_cached_emitters", False) + ), + dry_run=bool(getattr(args, "dry_run", False)), + skip_missing_workflow=not bool(getattr(args, "strict", False)), + ) + return _print_module_results(results) + + +def cmd_git_bulk_install_ci(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_install_ci + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + results = bulk_install_ci( + repos, + force=bool(getattr(args, "force", False)), + push_remote=not bool(getattr(args, "no_push", False)), + dry_run=bool(getattr(args, "dry_run", False)), + ) + return _print_module_results(results) def cmd_git_release(args: argparse.Namespace) -> int: @@ -461,6 +1325,96 @@ def cmd_git_release(args: argparse.Namespace) -> int: return 0 if r.ok else 1 +def cmd_git_install_ci(args: argparse.Namespace) -> int: + from project_studio.gitops import install_and_push_release_ci + + root = _root_or_die(args) + if root is None: + return 2 + r = install_and_push_release_ci( + root, + zip_prefix=getattr(args, "zip_prefix", "") or "", + force=bool(getattr(args, "force", False)), + push_remote=not bool(getattr(args, "no_push", False)), + dry_run=args.dry_run, + ) + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + if r.detail: + print(r.detail) + return 0 if r.ok else 1 + + +def cmd_build_mingw(args: argparse.Namespace) -> int: + """Linux → Windows MinGW cross-build via scripts/build_windows_mingw.sh.""" + import json + import shutil + import subprocess + + script = _TOOLKIT / "scripts" / "build_windows_mingw.sh" + if not script.is_file(): + print(f"error: missing {script}", file=sys.stderr) + return 2 + + bash = shutil.which("bash") + if not bash: + print("error: bash not found (required for MinGW cross script)", file=sys.stderr) + return 2 + + cmd: list[str] = [bash, str(script)] + if getattr(args, "root", None): + cmd += ["--root", str(Path(args.root).expanduser().resolve())] + if getattr(args, "name", None): + cmd += ["--name", args.name] + if getattr(args, "last", False) or ( + not getattr(args, "root", None) and not getattr(args, "name", None) + ): + cmd.append("--last") + if args.build_dir and args.build_dir != "build-mingw": + cmd += ["--build-dir", args.build_dir] + if getattr(args, "package_only", False): + cmd.append("--package-only") + if getattr(args, "setup_host", False): + cmd.append("--setup-host") + if getattr(args, "package", False) and not getattr(args, "package_only", False): + cmd.append("--package") + if getattr(args, "dynamic", False): + cmd.append("--dynamic") + if getattr(args, "ensure", False) and not getattr(args, "package_only", False): + cmd.append("--ensure") + if getattr(args, "jobs", 0): + cmd += ["--jobs", str(args.jobs)] + if getattr(args, "dry_run", False): + cmd.append("--dry-run") + if getattr(args, "extra", ""): + cmd += ["--extra", args.extra] + + print("+", " ".join(cmd), flush=True) + r = subprocess.run(cmd, check=False) + # Studio Bundle+Export parses the JSON trailer / MINGW_ZIP line. + root = "" + if getattr(args, "root", None): + root = str(Path(args.root).expanduser().resolve()) + zip_path = "" + if root: + dist = Path(root) / "dist" + if dist.is_dir(): + zips = sorted( + dist.glob("*-windows-x64-mingw.zip"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not zips: + zips = sorted(dist.glob("*.zip"), key=lambda p: p.stat().st_mtime, reverse=True) + if zips: + zip_path = str(zips[0].resolve()) + if zip_path and r.returncode == 0: + print(f"MINGW_ZIP={zip_path}", flush=True) + print(json.dumps({"ok": True, "zip": zip_path}), flush=True) + elif getattr(args, "package_only", False) or getattr(args, "package", False): + print(json.dumps({"ok": False, "zip": "", "error": "no zip produced"}), flush=True) + return int(r.returncode) + + def cmd_build_configure(args: argparse.Namespace) -> int: import shlex @@ -478,11 +1432,67 @@ def cmd_build_configure(args: argparse.Namespace) -> int: extra_args=extra, dry_run=args.dry_run, log=print, + ensure_bios=not bool(getattr(args, "skip_bios", False)), ) print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") return 0 if r.ok else 1 +def cmd_build_ensure_bios(args: argparse.Namespace) -> int: + from project_studio.buildops import ensure_bios_backends + + root = _root_or_die(args) + if root is None: + return 2 + r = ensure_bios_backends( + root, + force=bool(getattr(args, "force", False)), + include_scph1001=not bool(getattr(args, "openbios_only", False)), + dry_run=args.dry_run, + log=print, + ) + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + return 0 if r.ok else 1 + + +def cmd_build_generate(args: argparse.Namespace) -> int: + from project_studio.buildops import generate_rom_and_bios + + root = _root_or_die(args) + if root is None: + return 2 + r = generate_rom_and_bios( + root, + disc=getattr(args, "disc", "") or "", + bios=getattr(args, "bios", "") or "", + force_bios=not bool(getattr(args, "no_force_bios", False)), + dry_run=args.dry_run, + log=print, + ) + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + if r.detail and not r.ok: + print(r.detail) + return 0 if r.ok else 1 + + +def cmd_build_ensure_emitters(args: argparse.Namespace) -> int: + from project_studio.buildops import ensure_emitters + + root = _root_or_die(args) + if root is None: + return 2 + r = ensure_emitters( + root, + force=bool(getattr(args, "force", False)), + dry_run=args.dry_run, + log=print, + ) + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + if r.detail and not r.ok: + print(r.detail) + return 0 if r.ok else 1 + + def cmd_build_compile(args: argparse.Namespace) -> int: from project_studio.buildops import build @@ -511,6 +1521,11 @@ def cmd_build_run(args: argparse.Namespace) -> int: if root is None: return 2 extra = shlex.split(args.args, posix=os.name != "nt") if args.args else [] + + def _log(line: str) -> None: + # Piped into Studio — must flush or activity log stalls until buffer fill. + print(line, flush=True) + r = launch( root, build_dir=args.build_dir, @@ -518,9 +1533,10 @@ def cmd_build_run(args: argparse.Namespace) -> int: env_text=args.env or "", extra_args=extra, dry_run=args.dry_run, - log=print, + log=_log, + wait=True, ) - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}", flush=True) return 0 if r.ok else 1 @@ -528,7 +1544,7 @@ def cmd_build_stop(args: argparse.Namespace) -> int: from project_studio.buildops import stop_launch r = stop_launch() - print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}", flush=True) return 0 if r.ok else 1 @@ -580,6 +1596,8 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--boot-exe", help="Boot EXE basename (e.g. SCUS_944.23)") p.add_argument("--players", type=int, default=2) p.add_argument("--zip-prefix", help="CI/zip prefix") + p.add_argument("--github-owner", help="GitHub owner/org for README download badges") + p.add_argument("--github-repo", help="GitHub repo name for README download badges") p.add_argument("--window-title", help="WINDOW_TITLE override") p.add_argument("--enable-netplay", action="store_true") p.add_argument("--lobby-url", default="ws://netplay.retcomm.net:8765") @@ -618,10 +1636,138 @@ def build_parser() -> argparse.ArgumentParser: p_ops = sub.add_parser("ops", help="List op ids") p_ops.set_defaults(func=cmd_ops) - p_gui = sub.add_parser("gui", help="Open Project Studio GUI") + p_gui = sub.add_parser("gui", help="Open Project Studio GUI (Dear ImGui)") p_gui.add_argument("--root", default=None, help="Optional initial game root") p_gui.set_defaults(func=cmd_gui) + p_repos = sub.add_parser("repos", help="Manage local game-repo index") + repos_sub = p_repos.add_subparsers(dest="repos_cmd", required=True) + p_rl = repos_sub.add_parser("list", help="Print index as JSON") + p_rl.add_argument("--json", action="store_true", default=True, + help="Always JSON (accepted for ImGui runner symmetry)") + p_rl.set_defaults(func=cmd_repos_list) + p_ra = repos_sub.add_parser("add", help="Add a game repo") + p_ra.add_argument("--path", required=True) + p_ra.add_argument("--name", default="") + p_ra.add_argument("--cue", default="") + p_ra.add_argument("--force", action="store_true") + p_ra.add_argument("--json", action="store_true", default=True) + p_ra.set_defaults(func=cmd_repos_add) + p_rr = repos_sub.add_parser("remove", help="Remove a game repo") + p_rr.add_argument("--path", required=True) + p_rr.add_argument("--json", action="store_true", default=True) + p_rr.set_defaults(func=cmd_repos_remove) + p_rsc = repos_sub.add_parser("set-cue", help="Set .cue for an indexed repo") + p_rsc.add_argument("--path", required=True) + p_rsc.add_argument("--cue", required=True) + p_rsc.add_argument("--json", action="store_true", default=True) + p_rsc.set_defaults(func=cmd_repos_set_cue) + p_rsl = repos_sub.add_parser("set-last", help="Remember last-selected repo") + p_rsl.add_argument("--path", required=True) + p_rsl.add_argument("--json", action="store_true", default=True) + p_rsl.set_defaults(func=cmd_repos_set_last) + p_rsf = repos_sub.add_parser("set-flags", help="Update catalog_only / bulk_jobs / log_height") + p_rsf.add_argument("--catalog-only", default=None) + p_rsf.add_argument("--bulk-jobs", default=None) + p_rsf.add_argument("--log-height", default=None) + p_rsf.add_argument("--json", action="store_true", default=True) + p_rsf.set_defaults(func=cmd_repos_set_flags) + + p_rfc = repos_sub.add_parser( + "filter-catalog", + help="List indexed repos that have a retcomm-catalog entry (JSON paths)", + ) + p_rfc.add_argument("--json", action="store_true", default=True) + p_rfc.set_defaults(func=cmd_repos_filter_catalog) + + p_rfcc = repos_sub.add_parser( + "filter-catalog-contributors", + help="Catalog-backed indexed repos with WRITE+ for the current gh user", + ) + p_rfcc.add_argument("--json", action="store_true", default=True) + p_rfcc.set_defaults(func=cmd_repos_filter_catalog_contributors) + + p_upd = sub.add_parser("updates", help="Studio + toolchain updates") + upd_sub = p_upd.add_subparsers(dest="updates_cmd", required=True) + p_uc = upd_sub.add_parser("check", help="Check for updates") + p_uc.add_argument("--json", action="store_true") + p_uc.add_argument( + "--startup", + action="store_true", + help="Honor check_updates_on_startup from studio/hub config (skip when false)", + ) + p_uc.set_defaults(func=cmd_updates_check) + p_ua = upd_sub.add_parser("apply", help="Apply available updates") + p_ua.add_argument("--studio-only", action="store_true") + p_ua.add_argument("--toolchain-only", action="store_true") + p_ua.set_defaults(func=cmd_updates_apply) + p_uet = upd_sub.add_parser( + "ensure-toolchain", + help="Install cmake-clang-v1 if missing (github.com downloads, no API listing)", + ) + p_uet.set_defaults(func=cmd_updates_ensure_toolchain) + + p_np = sub.add_parser( + "new-project", + help="Run setup_project.sh/.ps1 (OS-routed) then index the new repo", + ) + p_np.add_argument("--name", required=True, help="Project folder / display name") + p_np.add_argument("--disc", required=True, help="Redump .cue path") + p_np.add_argument( + "--dir", + default=".", + help="Parent directory for the new repo (default: .)", + ) + p_np.add_argument("--bios", default="", help="Optional SCPH1001.BIN") + p_np.add_argument("--boot-exe", default="") + p_np.add_argument("--players", type=int, default=2) + p_np.add_argument("--zip-prefix", default="") + p_np.add_argument("--github-owner", default="") + p_np.add_argument("--github-repo", default="") + p_np.add_argument("--description", default="") + p_np.add_argument("--publisher", default="") + p_np.add_argument("--year", default="") + p_np.add_argument("--region", default="USA") + p_np.add_argument("--lobby-url", default="netplay.retcomm.net") + p_np.add_argument("--no-recomp-ui", action="store_true") + p_np.add_argument("--no-wizard", action="store_true") + p_np.add_argument("--enable-netplay", action="store_true") + p_np.add_argument("--no-ci", action="store_true") + p_np.add_argument("--no-fetch-boxart", action="store_true") + p_np.add_argument("--stage-disc", action="store_true") + p_np.add_argument("--generate", action="store_true") + p_np.add_argument("--enable-build", action="store_true") + p_np.add_argument("--create-github", action="store_true") + p_np.add_argument( + "--github-visibility", + choices=("private", "public", "internal"), + default="private", + ) + p_np.add_argument("--psxrecomp-ref", default="master") + p_np.add_argument("--recomp-ui-ref", default="master") + p_np.add_argument("--recomp-net-ref", default="") + p_np.add_argument("--rbengine-ref", default="") + p_np.add_argument("--dry-run", action="store_true") + p_np.add_argument( + "--autofill-meta", + action="store_true", + help="Fill empty players/description/publisher/year/region from disc digests", + ) + p_np.set_defaults(func=cmd_new_project) + + p_meta = sub.add_parser( + "lookup-disc-meta", + help="Lookup players/description/publisher/year/region from disc digests", + ) + p_meta.add_argument("--disc", default="", help="Redump .cue to probe") + p_meta.add_argument("--crc32", default="") + p_meta.add_argument("--md5", default="") + p_meta.add_argument("--sha1", default="") + p_meta.add_argument("--serial", default="") + p_meta.add_argument("--force-refresh", action="store_true") + p_meta.add_argument("--json", action="store_true") + p_meta.set_defaults(func=cmd_lookup_disc_meta) + # --- git / GitHub --- p_git = sub.add_parser("git", help="Git / GitHub ops on a game repo") git_sub = p_git.add_subparsers(dest="git_cmd", required=True) @@ -635,6 +1781,23 @@ def build_parser() -> argparse.ArgumentParser: p_gs.add_argument("--json", action="store_true") p_gs.set_defaults(func=cmd_git_status) + p_gbrs = git_sub.add_parser( + "branches", + help="List game/module branch names (JSON) for Studio dropdowns", + ) + p_gbrs.add_argument( + "--root", + default="", + help="Game repo root (omit to ls-remote default module URLs)", + ) + p_gbrs.add_argument( + "--fetch", + action="store_true", + help="git fetch --prune before listing local checkouts", + ) + p_gbrs.add_argument("--json", action="store_true", default=True) + p_gbrs.set_defaults(func=cmd_git_branches) + p_ge = git_sub.add_parser("ensure-submodules", help="Add psxrecomp + recomp-ui") add_git_root(p_ge) p_ge.add_argument("--psxrecomp-branch", default="master") @@ -652,13 +1815,13 @@ def build_parser() -> argparse.ArgumentParser: p_gb = git_sub.add_parser( "set-branch", - help="Set game branch (checkout) or submodule tracking branch", + help="Switch game branch (git switch) or set submodule tracking in .gitmodules", ) add_git_root(p_gb) p_gb.add_argument("--branch", required=True) p_gb.add_argument( "--submodule", - help="Submodule path (e.g. psxrecomp). Omit to checkout game branch.", + help="Submodule path (e.g. psxrecomp). Omit to switch the game working tree.", ) p_gb.add_argument( "--nested", @@ -668,10 +1831,82 @@ def build_parser() -> argparse.ArgumentParser: p_gb.add_argument( "--create", action="store_true", - help="Create/reset game branch (-B) when not using --submodule", + help="Create the game branch (-c) if it does not exist", ) p_gb.set_defaults(func=cmd_git_set_branch) + p_gsw = git_sub.add_parser( + "switch", + help="git switch on game / --modules / --nested (combinable)", + ) + add_git_root(p_gsw) + p_gsw.add_argument( + "--branch", + default="", + help="Game-root branch (with --game / default). " + "With --modules/--nested alone, omit to use each .gitmodules tracking branch.", + ) + p_gsw.add_argument( + "--game", + action="store_true", + help="Switch the game repo root (default when no other target flags)", + ) + p_gsw.add_argument( + "--modules", + action="store_true", + help="Switch game submodules (psxrecomp, recomp-ui)", + ) + p_gsw.add_argument( + "--nested", + action="store_true", + help="Switch nested libs inside psxrecomp (recomp-net, rbengine)", + ) + p_gsw.add_argument( + "--psxrecomp", + action="store_true", + help="Switch the psxrecomp checkout itself", + ) + p_gsw.add_argument( + "--psxrecomp-branch", + default="", + help="psxrecomp branch (with --modules or --psxrecomp)", + ) + p_gsw.add_argument( + "--ui-branch", + default="", + help="recomp-ui branch (with --modules)", + ) + p_gsw.add_argument( + "--net-branch", + default="", + help="lib/recomp-net branch (with --nested)", + ) + p_gsw.add_argument( + "--rb-branch", + default="", + help="lib/retcomm-rbengine branch (with --nested)", + ) + p_gsw.add_argument( + "--submodule", + default="", + help="Single module path (with --nested if inside psxrecomp)", + ) + p_gsw.add_argument( + "--paths", + help="Comma-separated module paths (defaults depend on --modules/--nested)", + ) + p_gsw.add_argument( + "--create", + action="store_true", + help="Create the branch (-c) if it does not exist locally or remotely", + ) + p_gsw.add_argument( + "--no-track", + action="store_true", + help="Do not update .gitmodules branch= when switching modules", + ) + p_gsw.set_defaults(func=cmd_git_switch) + p_gu = git_sub.add_parser("update-submodules", help="git submodule update") add_git_root(p_gu) p_gu.add_argument( @@ -706,9 +1941,14 @@ def build_parser() -> argparse.ArgumentParser: p_gpull = git_sub.add_parser( "pull", - help="git pull --ff-only (game root, --modules, --nested, or --psxrecomp)", + help="git pull (game / --modules / --nested; combinable)", ) add_git_root(p_gpull) + p_gpull.add_argument( + "--game", + action="store_true", + help="Pull the game repo root (default when no other target flags)", + ) p_gpull.add_argument( "--modules", action="store_true", @@ -728,14 +1968,33 @@ def build_parser() -> argparse.ArgumentParser: "--paths", help="Comma-separated module paths (defaults depend on --modules/--nested)", ) + p_gpull.add_argument( + "--mode", + choices=("ff-only", "rebase", "merge", "reset"), + default="ff-only", + help="ff-only (default), rebase, merge (--no-rebase), or reset " + "(fetch + reset --hard upstream = match origin)", + ) + p_gpull.add_argument( + "--dirty", + choices=("fail", "stash", "discard"), + default="fail", + help="If working tree dirty: fail (default), stash, or discard " + "(reset --hard HEAD). Ignored for --mode reset.", + ) p_gpull.set_defaults(func=cmd_git_pull) p_gc = git_sub.add_parser( "commit", - help="git add -A && git commit (game root, --modules, or --nested)", + help="git add -A && git commit (game / --modules / --nested; combinable)", ) add_git_root(p_gc) p_gc.add_argument("-m", "--message", required=True) + p_gc.add_argument( + "--game", + action="store_true", + help="Commit the game repo root (default when no other target flags)", + ) p_gc.add_argument( "--modules", action="store_true", @@ -754,9 +2013,14 @@ def build_parser() -> argparse.ArgumentParser: p_gpush = git_sub.add_parser( "push", - help="git push -u origin HEAD (game root, --modules, --nested, or --psxrecomp)", + help="git push -u origin HEAD (game / --modules / --nested; combinable)", ) add_git_root(p_gpush) + p_gpush.add_argument( + "--game", + action="store_true", + help="Push the game repo root (default when no other target flags)", + ) p_gpush.add_argument( "--modules", action="store_true", @@ -783,6 +2047,157 @@ def build_parser() -> argparse.ArgumentParser: ) p_gpush.set_defaults(func=cmd_git_push) + def add_bulk_select(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--select", + default="", + help="Comma-separated name/path filters (default: all indexed repos)", + ) + p.add_argument("--dry-run", action="store_true") + + def add_bulk_targets(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--game", + action="store_true", + help="Operate on game repo root (default if no target flags)", + ) + p.add_argument( + "--modules", + action="store_true", + help="Operate on psxrecomp + recomp-ui", + ) + p.add_argument( + "--psxrecomp", + action="store_true", + help="Operate on the psxrecomp checkout", + ) + p.add_argument( + "--nested", + action="store_true", + help="Operate on nested libs (recomp-net, rbengine)", + ) + + p_gbs = git_sub.add_parser( + "bulk-status", + help="Status for indexed repos (project_studio_repos.json)", + ) + add_bulk_select(p_gbs) + p_gbs.set_defaults(func=cmd_git_bulk_status) + + p_gbp = git_sub.add_parser( + "bulk-pull", + help="Pull indexed repos (see --mode / --dirty / targets)", + ) + add_bulk_select(p_gbp) + add_bulk_targets(p_gbp) + p_gbp.add_argument( + "--mode", + choices=("ff-only", "rebase", "merge", "reset"), + default="ff-only", + ) + p_gbp.add_argument( + "--dirty", + choices=("fail", "stash", "discard"), + default="fail", + ) + p_gbp.set_defaults(func=cmd_git_bulk_pull) + + p_gbpush = git_sub.add_parser( + "bulk-push", + help="Push indexed repos (game / modules / psxrecomp / nested)", + ) + add_bulk_select(p_gbpush) + add_bulk_targets(p_gbpush) + p_gbpush.set_defaults(func=cmd_git_bulk_push) + + p_gbc = git_sub.add_parser( + "bulk-commit", + help="Commit indexed repos (game / modules / nested)", + ) + add_bulk_select(p_gbc) + add_bulk_targets(p_gbc) + p_gbc.add_argument("-m", "--message", required=True) + p_gbc.set_defaults(func=cmd_git_bulk_commit) + + p_gbsw = git_sub.add_parser( + "bulk-switch", + help="git switch submodule / nested / game branches on indexed repos", + ) + add_bulk_select(p_gbsw) + add_bulk_targets(p_gbsw) + p_gbsw.add_argument( + "--branch", + default="", + help="Game-root branch (with --game)", + ) + p_gbsw.add_argument( + "--psxrecomp-branch", + default="", + help="psxrecomp branch (with --modules or --psxrecomp)", + ) + p_gbsw.add_argument( + "--ui-branch", + default="", + help="recomp-ui branch (with --modules)", + ) + p_gbsw.add_argument( + "--net-branch", + default="", + help="lib/recomp-net branch (with --nested)", + ) + p_gbsw.add_argument( + "--rb-branch", + default="", + help="lib/retcomm-rbengine branch (with --nested)", + ) + p_gbsw.add_argument( + "--create", + action="store_true", + help="Create the branch (-c) if missing", + ) + p_gbsw.add_argument( + "--no-track", + action="store_true", + help="Do not update .gitmodules branch= tracking", + ) + p_gbsw.set_defaults(func=cmd_git_bulk_switch) + + + p_gbr = git_sub.add_parser( + "bulk-release", + help="Dispatch release.yml on selected indexed repos (gh workflow run)", + ) + add_bulk_select(p_gbr) + p_gbr.add_argument("--version", default="", help="Empty = auto-bump per repo") + p_gbr.add_argument( + "--bump", choices=("patch", "minor", "major"), default="patch" + ) + p_gbr.add_argument("--no-publish", action="store_true") + p_gbr.add_argument("--no-reuse-cached-emitters", action="store_true") + p_gbr.add_argument( + "--strict", + action="store_true", + help="Fail hard when release.yml is missing (default: skip)", + ) + p_gbr.set_defaults(func=cmd_git_bulk_release) + + p_gbci = git_sub.add_parser( + "bulk-install-ci", + help="Install/push setup-host release.yml on selected indexed repos", + ) + add_bulk_select(p_gbci) + p_gbci.add_argument( + "--force", + action="store_true", + help="Overwrite an already-filled release.yml", + ) + p_gbci.add_argument( + "--no-push", + action="store_true", + help="Commit locally only (do not push)", + ) + p_gbci.set_defaults(func=cmd_git_bulk_install_ci) + p_gr = git_sub.add_parser("release", help="gh workflow run release.yml") add_git_root(p_gr) p_gr.add_argument("--version", default="", help="Empty = auto-bump") @@ -793,6 +2208,24 @@ def build_parser() -> argparse.ArgumentParser: p_gr.add_argument("--no-reuse-cached-emitters", action="store_true") p_gr.set_defaults(func=cmd_git_release) + p_gci = git_sub.add_parser( + "install-ci", + help="Write psxrecomp setup-release.yml, commit, and push", + ) + add_git_root(p_gci) + p_gci.add_argument("--zip-prefix", default="", help="CI asset zip prefix") + p_gci.add_argument( + "--force", + action="store_true", + help="Overwrite an already-filled release.yml", + ) + p_gci.add_argument( + "--no-push", + action="store_true", + help="Commit locally only (do not push)", + ) + p_gci.set_defaults(func=cmd_git_install_ci) + # --- local cmake build / launch --- p_build = sub.add_parser("build", help="Local CMake configure / build / launch") build_sub = p_build.add_subparsers(dest="build_cmd", required=True) @@ -802,13 +2235,119 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--dry-run", action="store_true") p.add_argument("--build-dir", default="build-release") + p_bm = build_sub.add_parser( + "mingw", + help="Linux→Windows MinGW cross-build (local testing; no GitHub CI)", + ) + p_bm.add_argument( + "--root", + default="", + help="Game repository root (default: Studio index --last / --name)", + ) + p_bm.add_argument( + "--name", + default="", + help="Match a Studio-indexed title by name (substring)", + ) + p_bm.add_argument( + "--last", + action="store_true", + help="Use Studio index 'last' project (default when --root/--name omitted)", + ) + p_bm.add_argument("--build-dir", default="build-mingw") + p_bm.add_argument( + "--setup-host", + action="store_true", + help="CI-parity setup-host configure (FORCE_SETUP_HOST + wizard)", + ) + p_bm.add_argument( + "--package", + action="store_true", + help="Run scripts/package_setup_release.sh after build", + ) + p_bm.add_argument( + "--package-only", + action="store_true", + help="Skip configure/build; package existing MinGW build dir into dist/*.zip", + ) + p_bm.add_argument( + "--dynamic", + action="store_true", + help="PSX_STATIC_RUNTIME=OFF (ship SDL2/libgcc DLLs)", + ) + p_bm.add_argument( + "--ensure", + action="store_true", + help="Host ensure-emitters / ensure-bios / generate before cross-build", + ) + p_bm.add_argument("--jobs", type=int, default=0) + p_bm.add_argument("--extra", default="", help="Extra cmake -D args (one shell string)") + p_bm.add_argument("--dry-run", action="store_true") + p_bm.set_defaults(func=cmd_build_mingw) + p_bc = build_sub.add_parser("configure", help="cmake -S . -B ") add_build_root(p_bc) p_bc.add_argument("--build-type", default="Release") p_bc.add_argument("--generator", default=None, help="Empty = auto") p_bc.add_argument("--extra", default="", help="Extra cmake args (shell-quoted)") + p_bc.add_argument( + "--skip-bios", + action="store_true", + help="Do not auto-regen missing OpenBIOS before cmake", + ) p_bc.set_defaults(func=cmd_build_configure) + p_beb = build_sub.add_parser( + "ensure-bios", + help="Regen OpenBIOS (+ SCPH1001 if dump present) under psxrecomp/generated", + ) + add_build_root(p_beb) + p_beb.add_argument( + "--force", + action="store_true", + help="Regen even when generated backends already exist", + ) + p_beb.add_argument( + "--openbios-only", + action="store_true", + help="Skip SCPH1001 even if bios/SCPH1001.BIN exists", + ) + p_beb.set_defaults(func=cmd_build_ensure_bios) + + p_bg = build_sub.add_parser( + "generate", + help="psxrecomp_cli generate: BIOS backends + disc prepare + game C", + ) + add_build_root(p_bg) + p_bg.add_argument( + "--disc", + default="", + help="Source .cue (default: game.toml game.disc / indexed cue)", + ) + p_bg.add_argument( + "--bios", + default="", + help="Optional retail SCPH1001.BIN (omit for OpenBIOS only)", + ) + p_bg.add_argument( + "--no-force-bios", + action="store_true", + help="Skip BIOS regen when backends already exist", + ) + p_bg.set_defaults(func=cmd_build_generate) + + p_bee = build_sub.add_parser( + "ensure-emitters", + help="Build psxrecomp-game + psxrecomp-bios (psxrecomp_cli ensure-emitters)", + ) + add_build_root(p_bee) + p_bee.add_argument( + "--force", + action="store_true", + help="Rebuild even if emitter binaries already exist", + ) + p_bee.set_defaults(func=cmd_build_ensure_emitters) + p_bb = build_sub.add_parser("compile", help="cmake --build (alias: build)") add_build_root(p_bb) p_bb.add_argument("--target", default="psx-runtime") diff --git a/tools/new_project_layout/project_studio/detect.py b/tools/new_project_layout/project_studio/detect.py index 7db869ba..b60c0070 100644 --- a/tools/new_project_layout/project_studio/detect.py +++ b/tools/new_project_layout/project_studio/detect.py @@ -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 = [ diff --git a/tools/new_project_layout/project_studio/models.py b/tools/new_project_layout/project_studio/models.py index 640dbe44..94ad8eb1 100644 --- a/tools/new_project_layout/project_studio/models.py +++ b/tools/new_project_layout/project_studio/models.py @@ -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 diff --git a/tools/new_project_layout/project_studio/naming.py b/tools/new_project_layout/project_studio/naming.py index 0921d3d1..a5e2a0e5 100644 --- a/tools/new_project_layout/project_studio/naming.py +++ b/tools/new_project_layout/project_studio/naming.py @@ -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), diff --git a/tools/new_project_layout/project_studio/newproject.py b/tools/new_project_layout/project_studio/newproject.py new file mode 100644 index 00000000..15315d3b --- /dev/null +++ b/tools/new_project_layout/project_studio/newproject.py @@ -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)) diff --git a/tools/new_project_layout/project_studio/ops.py b/tools/new_project_layout/project_studio/ops.py index 2fa1e2a5..54d5a675 100644 --- a/tools/new_project_layout/project_studio/ops.py +++ b/tools/new_project_layout/project_studio/ops.py @@ -2,6 +2,7 @@ from __future__ import annotations +import 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, } diff --git a/tools/new_project_layout/project_studio/plan.py b/tools/new_project_layout/project_studio/plan.py index 8270012c..9204f39e 100644 --- a/tools/new_project_layout/project_studio/plan.py +++ b/tools/new_project_layout/project_studio/plan.py @@ -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", } diff --git a/tools/new_project_layout/project_studio/readme_metrics.py b/tools/new_project_layout/project_studio/readme_metrics.py new file mode 100644 index 00000000..bf771a50 --- /dev/null +++ b/tools/new_project_layout/project_studio/readme_metrics.py @@ -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 = "" +METRICS_END = "" +BOXART_BEGIN = "" +BOXART_END = "" +LAUNCHER_BEGIN = "" +LAUNCHER_END = "" +RAID_BEGIN = "" +RAID_END = "" + +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*

\s*\n\s*R\.A\.I\.D\..*\Z", + re.S, +) +_REMOTE_RE = re.compile( + r"github\.com[:/](?P[^/\s]+)/(?P[^/\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, + '

', + f' {alt} box art', + "

", + 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)", + "", + "

", + f' RetComM hub with a background build, next to a title’s recomp-ui launcher', + "

", + "", + "

", + f' Background cmake build with titles queued', + "

", + "", + "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, + "---", + "", + "

", + " R.A.I.D. — Retro AI Development · a Discord for AI-assisted retro reverse-engineering, decomp & recomp", + "

", + "", + "

", + ' Join the Retro AI Development (R.A.I.D.) Discord', + "

", + 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" diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index f37edaf5..061a43b0 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -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." + } + } } } diff --git a/tools/new_project_layout/setup_project.sh b/tools/new_project_layout/setup_project.sh index 8f9ea735..6f7a658f 100755 --- a/tools/new_project_layout/setup_project.sh +++ b/tools/new_project_layout/setup_project.sh @@ -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 README download-badge owner (default TechnicallyComputers) +# --github-repo 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 diff --git a/tools/new_project_layout/templates/README.md.in b/tools/new_project_layout/templates/README.md.in index f978342d..3f31de03 100644 --- a/tools/new_project_layout/templates/README.md.in +++ b/tools/new_project_layout/templates/README.md.in @@ -1,5 +1,11 @@ # @WINDOW_TITLE@ + +[![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) + + 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 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) + +

+ RetComM hub with a background build, next to a title’s recomp-ui launcher +

+ +

+ Background cmake build with titles queued +

+ +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. + + ## 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=` 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. + + +--- + +

+ R.A.I.D. — Retro AI Development · a Discord for AI-assisted retro reverse-engineering, decomp & recomp +

+ +

+ Join the Retro AI Development (R.A.I.D.) Discord +

+ diff --git a/tools/new_project_layout/templates/raid-discord.png b/tools/new_project_layout/templates/raid-discord.png new file mode 100644 index 00000000..f10e0193 Binary files /dev/null and b/tools/new_project_layout/templates/raid-discord.png differ