Files
psxrecomp/tools/new_project_layout/project_studio/cli.py
T
Matthew Stanley d240180943 Revert #487: recomp-net header block and update_cmake tool
#479 guards the host_relay.h include, so offline title builds no longer need recomp-net's include dir; the tool had no other managed block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:41:25 -07:00

2528 lines
85 KiB
Python

"""CLI: audit / plan / apply / gui for Project Studio."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
# Allow `python migrate_project.py` and `python -m project_studio`
_TOOLKIT = Path(__file__).resolve().parent.parent
if str(_TOOLKIT) not in sys.path:
sys.path.insert(0, str(_TOOLKIT))
from project_studio import __version__ # noqa: E402
from project_studio.detect import audit_project # noqa: E402
from project_studio.models import MigrateOptions # noqa: E402
from project_studio.ops import apply_plan, list_ops # noqa: E402
from project_studio.plan import build_plan # noqa: E402
def _print_audit(report, *, as_json: bool) -> int:
if as_json:
print(json.dumps(report.to_dict(), indent=2))
return 0 if report.to_dict()["fail_count"] == 0 else 1
print(f"Project Studio audit v{__version__}")
print(f" root: {report.root}")
print(f" name: {report.project_name}")
print(f" layout: {report.layout.value}")
print(f" boot: {report.boot_exe or '(unknown)'}")
print()
width = max(len(c.title) for c in report.checks) if report.checks else 10
for c in report.checks:
mark = {
"pass": "OK ",
"fail": "FAIL",
"warn": "WARN",
"skip": "SKIP",
}.get(c.status.value, "????")
print(f" [{mark}] {c.title:<{width}} {c.detail}")
if c.fix_op and c.status.value in ("fail", "warn"):
print(f" → op: {c.fix_op}")
if report.notes:
print()
for n in report.notes:
print(f" note: {n}")
fails = sum(1 for c in report.checks if c.status.value == "fail")
warns = sum(1 for c in report.checks if c.status.value == "warn")
print()
print(f"Summary: {fails} fail, {warns} warn (bundled releases: committed generated/ C)")
return 1 if fails else 0
def _options_from_args(args: argparse.Namespace) -> MigrateOptions:
only = [x.strip() for x in (args.only or "").split(",") if x.strip()]
skip = [x.strip() for x in (args.skip or "").split(",") if x.strip()]
return MigrateOptions(
disc=args.disc,
project_name=args.name,
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,
enable_netplay=args.enable_netplay,
lobby_url=args.lobby_url,
enable_ci=not args.no_ci,
relocate_boxart=not args.no_boxart,
rewrite_cmake=not args.no_rewrite_cmake,
merge_gitignore=not args.no_gitignore,
probe_disc=bool(args.disc) and not args.no_probe,
record_pins=not args.no_pins,
force=args.force,
only=only,
skip=skip,
dry_run=args.dry_run,
)
def cmd_audit(args: argparse.Namespace) -> int:
root = Path(args.root).expanduser().resolve()
if not root.is_dir():
print(f"error: not a directory: {root}", file=sys.stderr)
return 2
return _print_audit(audit_project(root), as_json=args.json)
def cmd_plan(args: argparse.Namespace) -> int:
root = Path(args.root).expanduser().resolve()
opts = _options_from_args(args)
plan = build_plan(root, opts)
if args.json:
print(json.dumps(plan.to_dict(), indent=2))
return 0
print(f"Project Studio plan ({plan.layout.value})")
print(f" root: {plan.root}")
print(f" dry-run default for apply: use --dry-run")
print()
if not plan.steps:
print(" (no steps — audit looks clean for selected options)")
return 0
for i, s in enumerate(plan.steps, 1):
print(f" {i:2d}. [{s.op_id}] {s.title}")
if s.detail:
print(f" {s.detail}")
print()
print("Apply with: migrate_project.py apply --root … [--dry-run]")
return 0
def cmd_apply(args: argparse.Namespace) -> int:
root = Path(args.root).expanduser().resolve()
opts = _options_from_args(args)
# Hard rules: recomp-ui + wizard are the shipped game's first-run disc picker
opts.enable_wizard = True
opts.enable_recomp_ui = True
if opts.players < 2:
opts.enable_netplay = False
report = audit_project(root)
plan = build_plan(root, opts, report)
if args.json_plan:
print(json.dumps(plan.to_dict(), indent=2))
if not plan.steps:
print("Nothing to apply.")
return 0
print(f"Applying {len(plan.steps)} step(s) to {root}"
+ (" [DRY-RUN]" if opts.dry_run else ""))
results = apply_plan(plan)
failed = 0
for r in results:
mark = "OK" if r.ok else "FAIL"
print(f" [{mark}] {r.op_id}: {r.message}")
for p in r.changed_paths:
print(f" · {p}")
if not r.ok:
failed += 1
if args.json:
print(json.dumps([r.to_dict() for r in results], indent=2))
return 1 if failed else 0
def cmd_ops(_: argparse.Namespace) -> int:
for op in list_ops():
print(op)
return 0
def cmd_gui(args: argparse.Namespace) -> int:
"""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
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") / "Retro-Studio",
Path("build") / "retcomm-studio",
Path("build-release") / "Retro-Studio",
Path("out") / "bin" / "Retro-Studio",
):
candidates.append(repo / rel)
which = shutil.which("Retro-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(
"Retro Studio GUI is Dear ImGui (native).\n"
"Build it first:\n"
" cmake -S . -B build && cmake --build build\n"
" ./build/Retro-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:
root = Path(args.root).expanduser().resolve()
if not root.is_dir():
print(f"error: not a directory: {root}", file=sys.stderr)
return 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
root = _root_or_die(args)
if root is None:
return 2
st = repo_status(root)
if args.json:
print(json.dumps(st.to_dict(), indent=2))
return 0 if st.is_git else 1
print(f"Project Studio git v{__version__}")
print(f" root: {st.root}")
print(f" git: {st.is_git}")
if not st.is_git:
return 1
print(f" branch: {st.branch}" + (f" → {st.upstream}" if st.upstream else ""))
print(f" ahead/behind: {st.ahead}/{st.behind}")
print(f" dirty: {st.dirty} (staged={st.staged} unstaged={st.unstaged} untracked={st.untracked})")
print(f" origin: {st.remote_url or '(none)'}")
print(f" gh: {st.gh_repo or ('available' if st.gh_available else 'missing')}")
if st.psxrecomp_root:
print(f" psxrecomp: {st.psxrecomp_root}")
print()
print("Submodules:")
for s in st.submodules:
mark = "OK" if s.present else "MISSING"
print(
f" [{mark}] {s.path:<12} branch={s.branch or '-':<16} "
f"sha={s.sha or '-':<12} {s.url}"
)
if st.nested_submodules and st.psxrecomp_root != st.root:
print()
print("Nested (inside psxrecomp):")
for s in st.nested_submodules:
mark = "OK" if s.present else "MISSING"
print(
f" [{mark}] {s.path:<22} branch={s.branch or '-':<16} "
f"sha={s.sha or '-':<12} {s.url}"
)
if st.short_status:
print()
print("Status:")
print(st.short_status)
for n in st.notes:
print(f"note: {n}")
return 0
def cmd_git_ensure_submodules(args: argparse.Namespace) -> int:
from project_studio.gitops import ensure_known_submodules
root = _root_or_die(args)
if root is None:
return 2
results = ensure_known_submodules(
root,
psxrecomp_branch=args.psxrecomp_branch,
recomp_ui_branch=args.recomp_ui_branch,
dry_run=args.dry_run,
)
failed = 0
for r in results:
print(f" [{'OK' if r.ok else 'FAIL'}] {r.message}")
if r.detail:
print(f" {r.detail}")
if not r.ok:
failed += 1
return 1 if failed else 0
def cmd_git_ensure_nested(args: argparse.Namespace) -> int:
from project_studio.gitops import ensure_nested_modules
root = _root_or_die(args)
if root is None:
return 2
results = ensure_nested_modules(
root,
recomp_net_branch=args.recomp_net_branch,
rbengine_branch=args.rbengine_branch,
dry_run=args.dry_run,
)
failed = 0
for r in results:
print(f" [{'OK' if r.ok else 'FAIL'}] {r.message}")
if r.detail:
print(f" {r.detail}")
if not r.ok:
failed += 1
return 1 if failed else 0
def cmd_git_set_branch(args: argparse.Namespace) -> int:
from project_studio.gitops import (
set_nested_branch,
set_submodule_branch,
switch_branch,
)
root = _root_or_die(args)
if root is None:
return 2
if args.nested:
if not args.submodule:
print("error: --nested requires --submodule PATH", file=sys.stderr)
return 2
r = set_nested_branch(root, args.submodule, args.branch, dry_run=args.dry_run)
elif args.submodule:
r = set_submodule_branch(
root, args.submodule, args.branch, dry_run=args.dry_run
)
else:
r = switch_branch(
root, args.branch, create=args.create, 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 _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
root = _root_or_die(args)
if root is None:
return 2
paths = [p.strip() for p in (args.paths or "").split(",") if p.strip()] or None
r = update_submodules(
root, paths=paths, remote=args.remote, 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_git_update_nested(args: argparse.Namespace) -> int:
from project_studio.gitops import update_nested_modules
root = _root_or_die(args)
if root is None:
return 2
paths = [p.strip() for p in (args.paths or "").split(",") if p.strip()] or None
r = update_nested_modules(
root,
paths=paths,
remote=args.remote,
stage=not args.no_stage,
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_git_commit_nested(args: argparse.Namespace) -> int:
from project_studio.gitops import commit_nested
root = _root_or_die(args)
if root is None:
return 2
r = commit_nested(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
def _module_paths_from_args(args: argparse.Namespace) -> list[str] | None:
raw = getattr(args, "paths", None) or ""
paths = [p.strip() for p in raw.split(",") if p.strip()]
return paths or None
def _print_module_results(results: list) -> int:
failed = 0
for r in results:
print(f" [{'OK' if r.ok else 'FAIL'}] {r.message}")
if r.detail:
print(f" {r.detail}")
if not r.ok:
failed += 1
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 CmdResult, pull, pull_modules, pull_psxrecomp
root = _root_or_die(args)
if root is None:
return 2
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,
)
)
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 CmdResult, commit_all, commit_modules
root = _root_or_die(args)
if root is None:
return 2
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,
)
)
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 (
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()
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:
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,
)
)
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 _migrate_bundled_common(args: argparse.Namespace) -> dict:
return dict(
psxrecomp_ref=getattr(args, "psxrecomp_ref", "") or "origin/master",
recomp_ui_ref=getattr(args, "recomp_ui_ref", "") or "origin/master",
regenerate=not bool(getattr(args, "skip_generate", False)),
push_remote=bool(getattr(args, "push", False)),
dry_run=bool(getattr(args, "dry_run", False)),
)
def cmd_git_bulk_migrate_bundled(args: argparse.Namespace) -> int:
from project_studio.bulkops import bulk_migrate_bundled
from project_studio.repo_index import load_index
repos = _bulk_repos_or_die(args)
if repos is None:
return 2
jobs = int(getattr(args, "jobs", 0) or 0) or int(getattr(load_index(), "bulk_jobs", 1) or 1)
def on_repo(label: str, results: list) -> None:
for r in results:
print(f" [{'OK' if r.ok else 'FAIL'}] {r.message}", flush=True)
if r.detail:
for ln in r.detail.splitlines():
print(f" {ln}", flush=True)
results = bulk_migrate_bundled(repos, jobs=jobs, on_repo=on_repo, **_migrate_bundled_common(args))
failed = sum(1 for r in results if not r.ok)
print(f"bulk migrate-bundled: {len(results) - failed} ok, {failed} failed", flush=True)
return 1 if failed else 0
def cmd_git_migrate_bundled(args: argparse.Namespace) -> int:
from project_studio.migrate_bundled import BundledMigrateOptions, migrate_to_bundled_release
root = _root_or_die(args)
if root is None:
return 2
opts = BundledMigrateOptions(
disc=getattr(args, "disc", None) or None,
bios=getattr(args, "bios", None) or None,
zip_prefix=getattr(args, "zip_prefix", "") or None,
**_migrate_bundled_common(args),
)
r = migrate_to_bundled_release(root, opts)
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_git_release(args: argparse.Namespace) -> int:
from project_studio.gitops import run_release_workflow
root = _root_or_die(args)
if root is None:
return 2
r = run_release_workflow(
root,
version=args.version or "",
bump=args.bump,
publish=not args.no_publish,
reuse_cached_emitters=not args.no_reuse_cached_emitters,
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_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
from project_studio.buildops import configure
root = _root_or_die(args)
if root is None:
return 2
extra = shlex.split(args.extra, posix=os.name != "nt") if args.extra else []
r = configure(
root,
build_dir=args.build_dir,
build_type=args.build_type,
generator=args.generator if args.generator is not None else None,
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
root = _root_or_die(args)
if root is None:
return 2
r = build(
root,
build_dir=args.build_dir,
target=args.target,
jobs=args.jobs or None,
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_export(args: argparse.Namespace) -> int:
"""Bundle a distributable zip from an existing build, mods included."""
from project_studio.buildops import export_release
root = _root_or_die(args)
if root is None:
return 2
r = export_release(
root,
build_dir=args.build_dir,
artifact_tag=args.artifact or None,
recompiler_build=args.recompiler_build,
exclude_dev_mods=args.exclude_dev_mods,
log=print,
)
print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}")
if r.ok:
print(r.detail)
elif r.detail:
print(r.detail, file=sys.stderr)
return 0 if r.ok else 1
def cmd_build_run(args: argparse.Namespace) -> int:
import shlex
from pathlib import Path
from project_studio.buildops import launch
root = _root_or_die(args)
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,
exe=Path(args.exe) if args.exe else None,
env_text=args.env or "",
extra_args=extra,
dry_run=args.dry_run,
log=_log,
wait=True,
)
print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}", flush=True)
return 0 if r.ok else 1
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}", flush=True)
return 0 if r.ok else 1
def cmd_build_status(args: argparse.Namespace) -> int:
from project_studio.buildops import (
detect_host,
find_runtime_exe,
launch_status,
resolve_build_dir,
)
root = _root_or_die(args)
if root is None:
return 2
host = detect_host()
bdir = resolve_build_dir(root, args.build_dir)
exe = find_runtime_exe(bdir)
print(f"host: {host.label} ({host.system})")
print(f"cmake: {host.cmake or '(missing)'}")
print(f"ninja: {host.ninja or '(missing)'}")
print(f"jobs: {host.jobs}")
print(f"build_dir: {bdir} exists={bdir.is_dir()}")
print(f"exe: {exe or '(not found)'}")
print(f"launch: {launch_status()}")
return 0 if exe else 1
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
prog="migrate_project",
description=(
"PSXRecomp Project Studio — migrate / update title repos to the "
"New Project Layout (bundled releases: committed generated/ C, compiled game shipped)."
),
)
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
sub = ap.add_subparsers(dest="cmd", required=True)
def add_root(p: argparse.ArgumentParser, required: bool = True) -> None:
p.add_argument(
"--root",
required=required,
help="Game repository root",
)
def add_common(p: argparse.ArgumentParser) -> None:
p.add_argument("--disc", help="Redump .cue for probe_disc refresh")
p.add_argument("--name", help="Project name override")
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")
p.add_argument("--no-recomp-ui", action="store_true",
help="Ignored for apply (forced on)")
p.add_argument("--no-wizard", action="store_true",
help="Ignored for apply (forced on)")
p.add_argument("--no-ci", action="store_true")
p.add_argument("--no-boxart", action="store_true")
p.add_argument("--no-rewrite-cmake", action="store_true")
p.add_argument("--no-gitignore", action="store_true")
p.add_argument("--no-probe", action="store_true")
p.add_argument("--no-pins", action="store_true")
p.add_argument("--force", action="store_true", help="Overwrite existing stubs")
p.add_argument("--only", help="Comma-separated op ids")
p.add_argument("--skip", help="Comma-separated op ids to skip")
p.add_argument("--dry-run", action="store_true")
p.add_argument("--json", action="store_true")
p_audit = sub.add_parser("audit", help="Audit a title repo")
add_root(p_audit)
p_audit.add_argument("--json", action="store_true")
p_audit.set_defaults(func=cmd_audit)
p_plan = sub.add_parser("plan", help="Show migration plan")
add_root(p_plan)
add_common(p_plan)
p_plan.set_defaults(func=cmd_plan)
p_apply = sub.add_parser("apply", help="Apply migration plan")
add_root(p_apply)
add_common(p_apply)
p_apply.add_argument("--json-plan", action="store_true")
p_apply.set_defaults(func=cmd_apply)
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 (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)
def add_git_root(p: argparse.ArgumentParser) -> None:
p.add_argument("--root", required=True, help="Game repository root")
p.add_argument("--dry-run", action="store_true")
p_gs = git_sub.add_parser("status", help="Repo + submodule status")
add_git_root(p_gs)
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")
p_ge.add_argument("--recomp-ui-branch", default="master")
p_ge.set_defaults(func=cmd_git_ensure_submodules)
p_gen = git_sub.add_parser(
"ensure-nested",
help="Add lib/recomp-net + lib/retcomm-rbengine inside psxrecomp",
)
add_git_root(p_gen)
p_gen.add_argument("--recomp-net-branch", default="main")
p_gen.add_argument("--rbengine-branch", default="main")
p_gen.set_defaults(func=cmd_git_ensure_nested)
p_gb = git_sub.add_parser(
"set-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 switch the game working tree.",
)
p_gb.add_argument(
"--nested",
action="store_true",
help="Treat --submodule as a path inside psxrecomp (e.g. lib/recomp-net)",
)
p_gb.add_argument(
"--create",
action="store_true",
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(
"--remote",
action="store_true",
help="Update to remote tracking branch tip (then commit gitlinks)",
)
p_gu.add_argument("--paths", help="Comma-separated submodule paths")
p_gu.set_defaults(func=cmd_git_update_submodules)
p_gun = git_sub.add_parser(
"update-nested",
help="Update nested modules inside psxrecomp (recomp-net, rbengine)",
)
add_git_root(p_gun)
p_gun.add_argument("--remote", action="store_true")
p_gun.add_argument("--paths", help="Comma-separated nested paths")
p_gun.add_argument(
"--no-stage",
action="store_true",
help="Do not git-add nested gitlinks inside psxrecomp",
)
p_gun.set_defaults(func=cmd_git_update_nested)
p_gcn = git_sub.add_parser(
"commit-nested",
help="Commit inside psxrecomp (after update-nested)",
)
add_git_root(p_gcn)
p_gcn.add_argument("-m", "--message", required=True)
p_gcn.set_defaults(func=cmd_git_commit_nested)
p_gpull = git_sub.add_parser(
"pull",
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",
help="Pull game submodules (psxrecomp, recomp-ui)",
)
p_gpull.add_argument(
"--nested",
action="store_true",
help="Pull nested libs inside psxrecomp (recomp-net, rbengine)",
)
p_gpull.add_argument(
"--psxrecomp",
action="store_true",
help="Pull the psxrecomp checkout itself",
)
p_gpull.add_argument(
"--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 / --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",
help="Commit inside game submodules (psxrecomp, recomp-ui)",
)
p_gc.add_argument(
"--nested",
action="store_true",
help="Commit inside nested libs (recomp-net, rbengine)",
)
p_gc.add_argument(
"--paths",
help="Comma-separated module paths (defaults depend on --modules/--nested)",
)
p_gc.set_defaults(func=cmd_git_commit)
p_gpush = git_sub.add_parser(
"push",
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",
help="Push game submodules (psxrecomp, recomp-ui)",
)
p_gpush.add_argument(
"--nested",
action="store_true",
help="Push nested libs inside psxrecomp (recomp-net, rbengine)",
)
p_gpush.add_argument(
"--psxrecomp",
action="store_true",
help="Push the psxrecomp checkout itself",
)
p_gpush.add_argument(
"--paths",
help="Comma-separated module paths (defaults depend on --modules/--nested)",
)
p_gpush.add_argument(
"--branch",
default="",
help="Branch name for detached-HEAD pushes (HEAD:refs/heads/BRANCH)",
)
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 bundled 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)
def add_migrate_bundled_flags(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--psxrecomp-ref", default="origin/master",
help="psxrecomp ref to pin (must carry bundled releases; default origin/master)",
)
p.add_argument(
"--recomp-ui-ref", default="origin/master",
help="recomp-ui ref to pin (default origin/master)",
)
p.add_argument(
"--skip-generate", action="store_true",
help="Do not rebuild emitters / regenerate game C (you must before releasing)",
)
p.add_argument(
"--push", action="store_true",
help="Push the migration commit (default: commit locally only)",
)
p_gbmb = git_sub.add_parser(
"bulk-migrate-bundled",
help="Move selected indexed repos onto bundled releases (bump pins, "
"new release.yml + packager, regenerate, commit)",
)
add_bulk_select(p_gbmb)
add_migrate_bundled_flags(p_gbmb)
p_gbmb.add_argument("--jobs", type=int, default=0, help="Parallel repos (default: index setting)")
p_gbmb.set_defaults(func=cmd_git_bulk_migrate_bundled)
p_gmb = git_sub.add_parser(
"migrate-bundled",
help="Move one game repo onto bundled releases",
)
add_git_root(p_gmb)
add_migrate_bundled_flags(p_gmb)
p_gmb.add_argument("--disc", default="", help="Disc .cue/.bin (default: disc.cfg or the one disc/*.cue)")
p_gmb.add_argument("--bios", default="", help="BIOS dump for generate (default: framework bundled OpenBIOS)")
p_gmb.add_argument("--zip-prefix", default="", help="CI asset zip prefix (default: from existing packager)")
p_gmb.set_defaults(func=cmd_git_migrate_bundled)
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")
p_gr.add_argument(
"--bump", choices=("patch", "minor", "major"), default="patch"
)
p_gr.add_argument("--no-publish", action="store_true")
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 game-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)
def add_build_root(p: argparse.ArgumentParser) -> None:
p.add_argument("--root", required=True, help="Game repository root")
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="Retired: setup-host configure parity (releases build the committed game C)",
)
p_bm.add_argument(
"--package",
action="store_true",
help="Run scripts/package_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 <dir>")
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")
p_bb.add_argument("--jobs", type=int, default=0)
p_bb.set_defaults(func=cmd_build_compile)
p_bx = build_sub.add_parser(
"export",
help="Bundle a distributable zip from an existing build (mods included)",
)
add_build_root(p_bx)
p_bx.add_argument(
"--artifact",
default="",
help="Artifact tag (default: this host -- linux-x64 / windows-x64). "
"Matches the CI matrix so local and released zips share names.",
)
p_bx.add_argument("--recompiler-build", default="build-recompiler")
p_bx.add_argument(
"--exclude-dev-mods",
action="store_true",
help='Drop channel = "developer" packages, reproducing what CI '
"publishes. Local exports keep them by default.",
)
p_bx.set_defaults(func=cmd_build_export)
p_br = build_sub.add_parser("run", help="Launch product binary with env")
add_build_root(p_br)
p_br.add_argument("--exe", default="", help="Override executable path")
p_br.add_argument(
"--env",
default="",
help='Env pairs, e.g. \'RBE_CROSS_OS_PACING_DIAG=1 FOO="bar baz"\'',
)
p_br.add_argument("--args", default="", help="Extra CLI args for the game")
p_br.set_defaults(func=cmd_build_run)
p_bs = build_sub.add_parser("stop", help="Stop Studio-launched process")
p_bs.set_defaults(func=cmd_build_stop)
p_bst = build_sub.add_parser("status", help="Host + exe detection")
add_build_root(p_bst)
p_bst.set_defaults(func=cmd_build_status)
return ap
def main(argv: list[str] | None = None) -> int:
# Reports print non-ASCII marks (e.g. an arrow). On a Windows console whose
# code page cannot encode them (cp1250, cp437, ...) print() raised
# UnicodeEncodeError mid-audit; degrade those characters instead.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
try:
stream.reconfigure(errors="replace")
except (ValueError, OSError):
pass
ap = build_parser()
args = ap.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())