diff --git a/README.md b/README.md index e0ce57d7..662a6390 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,19 @@ sh tools/new_project_layout/setup_project.sh --disc /path/to/game.cue --dir ~/sr powershell -File tools\new_project_layout\setup_project.ps1 -Disc C:\dumps\game.cue ``` +**Migrate an older title** (e.g. `psxrecomp-v4` / prebuilt `packaging/`) onto +setup-host with Project Studio — audit → plan → apply (CLI or GUI). Releases +stay setup-host only (no prebuilt game C): + +```bash +python3 tools/new_project_layout/migrate_project.py audit --root ~/src/MyGameRecomp +python3 tools/new_project_layout/migrate_project.py apply --root ~/src/MyGameRecomp --dry-run +python3 tools/new_project_layout/migrate_project.py gui +``` + +Details: [`tools/new_project_layout/README.md`](tools/new_project_layout/README.md) +and [`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md). + Launcher features that are still in active development are **opt-in at configure time** (defaults OFF — other platforms sharing `recomp-ui` stay dark): diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index 8cffad02..666dd9e3 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -137,6 +137,36 @@ python3 tools/new_project_layout/probe_disc.py disc/game.cue \ Prefer a **full multi-track** Redump cue. A single-TRACK dump will warn and will fail online multi-track gates (see `[NETPLAY.md](NETPLAY.md)`). +### Project Studio — migrate / update existing titles + +Older titles (e.g. `psxrecomp-v4` submodule, `psxrecomp_add_runtime_target`, +`packaging/` prebuilt zips) can be audited and migrated onto this layout with +**Project Studio**. Releases remain **setup-host only** — the tool does not +create prebuilt generated-C packages. + +| Entry | Role | +| ----- | ---- | +| `tools/new_project_layout/migrate_project.py` | CLI: `audit` / `plan` / `apply` / `gui` / `ops` | +| `tools/new_project_layout/project_studio_gui.py` | Tkinter GUI | +| `tools/new_project_layout/project_studio/` | Shared detect / plan / ops library | +| `tools/new_project_layout/README.md` | Short usage | + +```bash +# From a psxrecomp checkout (or any title that vendors this toolkit path): +python3 tools/new_project_layout/migrate_project.py audit --root ~/src/ApeEscapeRecomp +python3 tools/new_project_layout/migrate_project.py apply --root ~/src/ApeEscapeRecomp --dry-run +python3 tools/new_project_layout/migrate_project.py gui +``` + +Typical apply order: rename `psxrecomp-v4` → emit `codegen_setup` → rewrite +CMake (`psxrecomp_add_game_runtime` + wizard) → setup-host packager + CI → +optional `probe_disc` / pins. CMake rewrites keep +`CMakeLists.txt.pre_migrate.bak` plus `CMakeLists.migrate_extras.txt` when the +old file had tests / `EXTRAS_SOURCES` / mod `POST_BUILD` hooks. + +`apply` always enables **recomp-ui + setup wizard** (required for +`PSXRECOMP_FORCE_SETUP_HOST`). Netplay stays opt-in (`--enable-netplay` / GUI). + ### After scaffold (still not automatic) If you answered Y to boxart + Generate (+ optional build), you get a local diff --git a/docs/ci/README.md b/docs/ci/README.md index 35d9e217..0fc234c0 100644 --- a/docs/ci/README.md +++ b/docs/ci/README.md @@ -15,6 +15,10 @@ writes `scripts/package_setup_release.sh`, `README.md`, and an optional `--enable-wizard` / `--enable-netplay`. Release CI pins via submodule gitlinks and only logs SHAs with `record_pins.sh` (no `verify_pins` gate). +**Migrating an older title** onto this CI shape: Project Studio +(`tools/new_project_layout/migrate_project.py apply` / `gui`) emits the same +packager + filled `release.yml`. Setup-host only — no prebuilt game-C zips. + Manual: ```bash diff --git a/tools/new_project_layout/README.md b/tools/new_project_layout/README.md new file mode 100644 index 00000000..9629fef6 --- /dev/null +++ b/tools/new_project_layout/README.md @@ -0,0 +1,58 @@ +# New Project Layout + Project Studio + +Scaffold **new** titles and **migrate** older ones onto the setup-host layout. + +**Policy:** public releases are **setup-host only** (no prebuilt generated game C). + +## New project + +```bash +sh tools/new_project_layout/setup_project.sh --disc /path/to/game.cue --dir ~/src +``` + +See [`docs/GAME_PROJECT_SETUP.md`](../../docs/GAME_PROJECT_SETUP.md). + +## Project Studio (migrate / update) + +Shared Python library under `project_studio/` with CLI + tkinter GUI. + +```bash +# Audit layout gaps +python3 tools/new_project_layout/migrate_project.py audit \ + --root /path/to/ApeEscapeRecomp + +# Show ordered plan +python3 tools/new_project_layout/migrate_project.py plan \ + --root /path/to/ApeEscapeRecomp + +# Dry-run apply (recommended first) +python3 tools/new_project_layout/migrate_project.py apply \ + --root /path/to/ApeEscapeRecomp --dry-run + +# Apply for real (rewrites CMake with .pre_migrate.bak) +python3 tools/new_project_layout/migrate_project.py apply \ + --root /path/to/ApeEscapeRecomp \ + --disc /path/to/game.cue + +# GUI +python3 tools/new_project_layout/migrate_project.py gui +# or +python3 tools/new_project_layout/project_studio_gui.py +``` + +### Ops (subset) + +| Op | Purpose | +|----|---------| +| `rename_psxrecomp_submodule` | `psxrecomp-v4` → `psxrecomp` | +| `ensure_recomp_ui_submodule` | Add `recomp-ui` | +| `emit_codegen_setup` | Thin `codegen_setup.c/.h` | +| `rewrite_cmake_setup_host` | `psxrecomp_add_game_runtime` + wizard | +| `emit_packager` | `scripts/package_setup_release.sh` | +| `emit_ci_workflow` | Setup-host `release.yml` | +| `probe_disc_refresh` | TOC / catalog / seeds (needs `--disc`) | +| `annotate_legacy_packaging` | Mark old prebuilt packagers obsolete | + +Wizard + `recomp-ui` are forced on for `apply` (setup-host requirement). + +Helpers reused: `probe_disc.py`, `fill_tokens.py`, `sync_symbols.py`, `templates/*`. diff --git a/tools/new_project_layout/migrate_project.py b/tools/new_project_layout/migrate_project.py new file mode 100755 index 00000000..58a1e7fa --- /dev/null +++ b/tools/new_project_layout/migrate_project.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Project Studio CLI entry (migrate / update title repos → setup-host layout). + +Examples: + python3 tools/new_project_layout/migrate_project.py audit --root ~/src/ApeEscapeRecomp + python3 tools/new_project_layout/migrate_project.py plan --root ~/src/ApeEscapeRecomp + python3 tools/new_project_layout/migrate_project.py apply --root ~/src/ApeEscapeRecomp --dry-run + python3 tools/new_project_layout/migrate_project.py gui +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from project_studio.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/new_project_layout/project_studio/__init__.py b/tools/new_project_layout/project_studio/__init__.py new file mode 100644 index 00000000..ea335ef4 --- /dev/null +++ b/tools/new_project_layout/project_studio/__init__.py @@ -0,0 +1,25 @@ +"""PSXRecomp Project Studio — audit / plan / apply for New Project Layout. + +Setup-host releases only (no prebuilt game-C packaging path). +""" + +from __future__ import annotations + +__version__ = "0.1.0" + +from .detect import audit_project +from .models import AuditReport, CheckResult, MigrateOptions, Plan, PlanStep +from .plan import build_plan +from .ops import apply_plan + +__all__ = [ + "AuditReport", + "CheckResult", + "MigrateOptions", + "Plan", + "PlanStep", + "audit_project", + "build_plan", + "apply_plan", + "__version__", +] diff --git a/tools/new_project_layout/project_studio/__main__.py b/tools/new_project_layout/project_studio/__main__.py new file mode 100644 index 00000000..eb53e2f3 --- /dev/null +++ b/tools/new_project_layout/project_studio/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/tools/new_project_layout/project_studio/cli.py b/tools/new_project_layout/project_studio/cli.py new file mode 100644 index 00000000..8216471b --- /dev/null +++ b/tools/new_project_layout/project_studio/cli.py @@ -0,0 +1,236 @@ +"""CLI: audit / plan / apply / gui for Project Studio.""" + +from __future__ import annotations + +import argparse +import json +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 (setup-host releases only)") + 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, + 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) + # Setup-host exclusive hard rules + 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: + from project_studio.gui import run_gui + + root = Path(args.root).expanduser().resolve() if args.root else None + return run_gui(initial_root=root) + + +def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser( + prog="migrate_project", + description=( + "PSXRecomp Project Studio — migrate / update title repos to the " + "New Project Layout (setup-host releases only)." + ), + ) + 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("--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 setup-host apply (forced on)") + p.add_argument("--no-wizard", action="store_true", + help="Ignored for setup-host 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") + p_gui.add_argument("--root", default=None, help="Optional initial game root") + p_gui.set_defaults(func=cmd_gui) + + return ap + + +def main(argv: list[str] | None = None) -> int: + ap = build_parser() + args = ap.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/new_project_layout/project_studio/detect.py b/tools/new_project_layout/project_studio/detect.py new file mode 100644 index 00000000..7c4ab6f4 --- /dev/null +++ b/tools/new_project_layout/project_studio/detect.py @@ -0,0 +1,648 @@ +"""Audit an existing title repo against the New Project Layout (setup-host).""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .models import ( + AuditReport, + CheckResult, + CheckStatus, + LayoutClass, + Severity, +) +from .naming import boot_exe_from_game_toml, infer_project_name + + +def _read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +def _gitmodules_paths(root: Path) -> dict[str, str]: + """path → url from .gitmodules.""" + gm = root / ".gitmodules" + if not gm.is_file(): + return {} + text = _read(gm) + out: dict[str, str] = {} + path = url = None + for line in text.splitlines(): + s = line.strip() + if s.startswith("[submodule"): + if path and url: + out[path] = url + path = url = None + continue + if s.startswith("path"): + _, _, v = s.partition("=") + path = v.strip() + elif s.startswith("url"): + _, _, v = s.partition("=") + url = v.strip() + if path and url: + out[path] = url + return out + + +def _is_real_psxrecomp_tree(path: Path) -> bool: + return (path / "runtime" / "runtime.cmake").is_file() + + +def _cmake_has(text: str, needle: str) -> bool: + return needle in text + + +def audit_project(root: Path) -> AuditReport: + root = root.resolve() + name = infer_project_name(root) + boot = boot_exe_from_game_toml(root / "game.toml") + checks: list[CheckResult] = [] + notes: list[str] = [] + + gm = _gitmodules_paths(root) + has_v4 = "psxrecomp-v4" in gm or (root / "psxrecomp-v4").is_dir() + has_psx = "psxrecomp" in gm or _is_real_psxrecomp_tree(root / "psxrecomp") + stub_psx = (root / "psxrecomp").is_dir() and not _is_real_psxrecomp_tree(root / "psxrecomp") + has_ui = "recomp-ui" in gm or (root / "recomp-ui").is_dir() + + cmake_path = root / "CMakeLists.txt" + cmake = _read(cmake_path) if cmake_path.is_file() else "" + + # --- Submodule path --- + if has_v4 and not has_psx: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Found psxrecomp-v4; rename to psxrecomp/ for New Project Layout.", + fix_op="rename_psxrecomp_submodule", + ) + ) + elif has_v4 and has_psx: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.WARN, + severity=Severity.REQUIRED, + detail="Both psxrecomp/ and psxrecomp-v4 present — consolidate to psxrecomp/.", + fix_op="rename_psxrecomp_submodule", + ) + ) + elif stub_psx and has_v4: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="psxrecomp/ is a stub (no runtime.cmake); real tree is psxrecomp-v4.", + fix_op="rename_psxrecomp_submodule", + ) + ) + elif has_psx: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail="psxrecomp/ present with runtime.cmake.", + ) + ) + else: + checks.append( + CheckResult( + id="submodule_psxrecomp", + title="Root psxrecomp/ submodule", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="No psxrecomp submodule found.", + fix_op="ensure_psxrecomp_submodule", + ) + ) + + if has_ui: + checks.append( + CheckResult( + id="submodule_recomp_ui", + title="recomp-ui submodule", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail="recomp-ui/ present (required for setup-host wizard).", + ) + ) + else: + checks.append( + CheckResult( + id="submodule_recomp_ui", + title="recomp-ui submodule", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Missing recomp-ui/ — setup-host wizard needs it.", + fix_op="ensure_recomp_ui_submodule", + ) + ) + + # --- CMake API --- + if not cmake_path.is_file(): + checks.append( + CheckResult( + id="cmake_game_runtime", + title="psxrecomp_add_game_runtime", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="No CMakeLists.txt.", + fix_op="rewrite_cmake_setup_host", + ) + ) + elif _cmake_has(cmake, "psxrecomp_add_game_runtime"): + detail = "Uses psxrecomp_add_game_runtime." + status = CheckStatus.PASS + fix = None + if "psxrecomp-v4" in cmake: + status = CheckStatus.WARN + detail += " Still references psxrecomp-v4 path." + fix = "rewrite_cmake_setup_host" + checks.append( + CheckResult( + id="cmake_game_runtime", + title="psxrecomp_add_game_runtime", + status=status, + severity=Severity.REQUIRED, + detail=detail, + fix_op=fix, + ) + ) + elif _cmake_has(cmake, "psxrecomp_add_runtime_target"): + checks.append( + CheckResult( + id="cmake_game_runtime", + title="psxrecomp_add_game_runtime", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Legacy psxrecomp_add_runtime_target — rewrite to setup-host helper.", + fix_op="rewrite_cmake_setup_host", + ) + ) + else: + checks.append( + CheckResult( + id="cmake_game_runtime", + title="psxrecomp_add_game_runtime", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="CMakeLists.txt does not call a known runtime helper.", + fix_op="rewrite_cmake_setup_host", + ) + ) + + # Split-gen / GEN_FULL_GLOB + if _cmake_has(cmake, "GEN_FULL_GLOB") or re.search( + r"full_\*\.c|full_\[0-9\]", cmake + ): + checks.append( + CheckResult( + id="split_gen", + title="Split-gen generated shards", + status=CheckStatus.PASS, + severity=Severity.RECOMMENDED, + detail="CMake already globs *_full_*.c / GEN_FULL_GLOB.", + ) + ) + elif cmake: + checks.append( + CheckResult( + id="split_gen", + title="Split-gen generated shards", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail="No GEN_FULL_GLOB / shard glob — regenerate will need shard support.", + fix_op="rewrite_cmake_setup_host", + ) + ) + + # Wizard + wizard_ok = ( + _cmake_has(cmake, "PSX_SETUP_WIZARD") + or _cmake_has(cmake, "ENABLE_SETUP_WIZARD") + ) + if wizard_ok: + checks.append( + CheckResult( + id="setup_wizard", + title="Setup wizard flags", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail="PSX_SETUP_WIZARD / ENABLE_SETUP_WIZARD present.", + ) + ) + else: + checks.append( + CheckResult( + id="setup_wizard", + title="Setup wizard flags", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Missing wizard flags — setup-host CI will not open first-run UI.", + fix_op="rewrite_cmake_setup_host", + ) + ) + + # codegen_setup + if (root / "codegen_setup.c").is_file() and (root / "codegen_setup.h").is_file(): + cg = _read(root / "codegen_setup.c") + if "psx_game_codegen_forward_if_built" in cg: + checks.append( + CheckResult( + id="codegen_setup", + title="codegen_setup.c / .h", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail="codegen_setup present with forward_if_built.", + ) + ) + else: + checks.append( + CheckResult( + id="codegen_setup", + title="codegen_setup.c / .h", + status=CheckStatus.WARN, + severity=Severity.REQUIRED, + detail="codegen_setup.c missing psx_game_codegen_forward_if_built.", + fix_op="emit_codegen_setup", + ) + ) + else: + checks.append( + CheckResult( + id="codegen_setup", + title="codegen_setup.c / .h", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Missing thin codegen_setup sources for setup-host.", + fix_op="emit_codegen_setup", + ) + ) + + # Packager + packager = root / "scripts" / "package_setup_release.sh" + if packager.is_file(): + checks.append( + CheckResult( + id="setup_host_packager", + title="scripts/package_setup_release.sh", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail="Setup-host packager wrapper present.", + ) + ) + else: + checks.append( + CheckResult( + id="setup_host_packager", + title="scripts/package_setup_release.sh", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Missing setup-host packager (required; prebuilt releases are not used).", + fix_op="emit_packager", + ) + ) + + # CI + wf = root / ".github" / "workflows" / "release.yml" + if wf.is_file(): + wtext = _read(wf) + if "YOUR_ZIP_PREFIX" in wtext or "YOUR_GAME_TITLE" in wtext: + checks.append( + CheckResult( + id="ci_release", + title="Setup-host release CI", + status=CheckStatus.WARN, + severity=Severity.REQUIRED, + detail="release.yml still has YOUR_* placeholders.", + fix_op="emit_ci_workflow", + ) + ) + else: + checks.append( + CheckResult( + id="ci_release", + title="Setup-host release CI", + status=CheckStatus.PASS, + severity=Severity.REQUIRED, + detail=".github/workflows/release.yml present.", + ) + ) + else: + checks.append( + CheckResult( + id="ci_release", + title="Setup-host release CI", + status=CheckStatus.FAIL, + severity=Severity.REQUIRED, + detail="Missing setup-host release.yml workflow.", + fix_op="emit_ci_workflow", + ) + ) + + # Legacy prebuilt packaging — warn only (setup-host exclusive policy) + legacy_pack = False + if (root / "packaging").is_dir() or (root / "tools" / "package_release.ps1").is_file(): + legacy_pack = True + checks.append( + CheckResult( + id="legacy_prebuilt", + title="Legacy prebuilt packaging", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail=( + "Found packaging/ or tools/package_release.ps1. " + "This studio only ships setup-host zips — leave prebuilt scripts unused " + "or remove them after CI is green." + ), + fix_op="annotate_legacy_packaging", + ) + ) + else: + checks.append( + CheckResult( + id="legacy_prebuilt", + title="Legacy prebuilt packaging", + status=CheckStatus.PASS, + severity=Severity.INFO, + detail="No legacy prebuilt packager detected.", + ) + ) + + # VERSION + if (root / "VERSION").is_file(): + checks.append( + CheckResult( + id="version_file", + title="VERSION", + status=CheckStatus.PASS, + severity=Severity.RECOMMENDED, + detail=_read(root / "VERSION").strip() or "(empty)", + ) + ) + else: + checks.append( + CheckResult( + id="version_file", + title="VERSION", + status=CheckStatus.FAIL, + severity=Severity.RECOMMENDED, + detail="Missing VERSION pin file.", + fix_op="emit_version", + ) + ) + + # symbols + if (root / "symbols.toml").is_file(): + checks.append( + CheckResult( + id="symbols_toml", + title="symbols.toml", + status=CheckStatus.PASS, + severity=Severity.RECOMMENDED, + detail="symbols.toml present.", + ) + ) + else: + checks.append( + CheckResult( + id="symbols_toml", + title="symbols.toml", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail="Missing symbols.toml progressive map.", + fix_op="emit_symbols_toml", + ) + ) + + sync = root / "tools" / "sync_symbols.py" + if sync.is_file(): + checks.append( + CheckResult( + id="sync_symbols", + title="tools/sync_symbols.py", + status=CheckStatus.PASS, + severity=Severity.OPTIONAL, + detail="sync_symbols.py present.", + ) + ) + else: + checks.append( + CheckResult( + id="sync_symbols", + title="tools/sync_symbols.py", + status=CheckStatus.WARN, + severity=Severity.OPTIONAL, + detail="Missing tools/sync_symbols.py.", + fix_op="emit_sync_symbols", + ) + ) + + # catalog / disc identity + game_toml = _read(root / "game.toml") + has_prepare = "[prepare_disc]" in game_toml + has_catalog = (root / "catalog_identity.json").is_file() + if has_catalog and has_prepare: + checks.append( + CheckResult( + id="disc_identity", + title="Disc / catalog identity", + status=CheckStatus.PASS, + severity=Severity.RECOMMENDED, + detail="catalog_identity.json + [prepare_disc] present.", + ) + ) + elif has_prepare or has_catalog: + checks.append( + CheckResult( + id="disc_identity", + title="Disc / catalog identity", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail="Partial disc identity — re-run probe_disc with a Redump .cue.", + fix_op="probe_disc_refresh", + ) + ) + else: + checks.append( + CheckResult( + id="disc_identity", + title="Disc / catalog identity", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail="No catalog_identity.json / [prepare_disc] — needed for RetComM gates.", + fix_op="probe_disc_refresh", + ) + ) + + # boxart + modern_box = root / "launcher_assets" / "img" / "boxart.tga" + legacy_box_candidates = [ + root / "recomp" / "launcher" / "boxart.tga", + root / "recomp" / "launcher" / "boxart.png", + ] + legacy_box = next((p for p in legacy_box_candidates if p.is_file()), None) + if modern_box.is_file(): + checks.append( + CheckResult( + id="boxart", + title="launcher_assets boxart", + status=CheckStatus.PASS, + severity=Severity.OPTIONAL, + detail="launcher_assets/img/boxart.tga present.", + ) + ) + elif legacy_box is not None: + checks.append( + CheckResult( + id="boxart", + title="launcher_assets boxart", + status=CheckStatus.WARN, + severity=Severity.OPTIONAL, + detail=f"Boxart at {legacy_box.relative_to(root)} — relocate to launcher_assets/.", + fix_op="relocate_boxart", + ) + ) + else: + checks.append( + CheckResult( + id="boxart", + title="launcher_assets boxart", + status=CheckStatus.WARN, + severity=Severity.OPTIONAL, + detail="No boxart found (optional).", + fix_op="emit_boxart_stub", + ) + ) + + # gitignore essentials + gi = _read(root / ".gitignore") + missing_gi = [ + pat + for pat in ("/generated/", "/disc/", "/bios/", "/dist/") + if pat not in gi and pat.rstrip("/") not in gi + ] + if not (root / ".gitignore").is_file(): + checks.append( + CheckResult( + id="gitignore", + title=".gitignore setup-host rules", + status=CheckStatus.FAIL, + severity=Severity.RECOMMENDED, + detail="Missing .gitignore.", + fix_op="merge_gitignore", + ) + ) + elif missing_gi: + checks.append( + CheckResult( + id="gitignore", + title=".gitignore setup-host rules", + status=CheckStatus.WARN, + severity=Severity.RECOMMENDED, + detail="Missing patterns: " + ", ".join(missing_gi), + fix_op="merge_gitignore", + ) + ) + else: + checks.append( + CheckResult( + id="gitignore", + title=".gitignore setup-host rules", + status=CheckStatus.PASS, + severity=Severity.RECOMMENDED, + detail="Essential setup-host ignore rules present.", + ) + ) + + # pins + if (root / "framework_pins.txt").is_file(): + checks.append( + CheckResult( + id="framework_pins", + title="framework_pins.txt", + status=CheckStatus.PASS, + severity=Severity.OPTIONAL, + detail="Pin snapshot present (gitlinks remain authoritative).", + ) + ) + else: + checks.append( + CheckResult( + id="framework_pins", + title="framework_pins.txt", + status=CheckStatus.WARN, + severity=Severity.OPTIONAL, + detail="No framework_pins.txt snapshot.", + fix_op="record_framework_pins", + ) + ) + + # mods preloaded stub + if (root / "mods" / "preloaded").is_dir(): + checks.append( + CheckResult( + id="mods_preloaded", + title="mods/preloaded", + status=CheckStatus.PASS, + severity=Severity.OPTIONAL, + detail="mods/preloaded present.", + ) + ) + else: + checks.append( + CheckResult( + id="mods_preloaded", + title="mods/preloaded", + status=CheckStatus.WARN, + severity=Severity.OPTIONAL, + detail="No mods/preloaded catalog stub.", + fix_op="emit_mods_preloaded", + ) + ) + + # Classify layout + fails = {c.id for c in checks if c.status == CheckStatus.FAIL} + rec_warns = [ + c + for c in checks + if c.status == CheckStatus.WARN and c.severity == Severity.RECOMMENDED + ] + if legacy_pack or "psxrecomp_add_runtime_target" in cmake or has_v4: + layout = LayoutClass.LEGACY_PACKAGING + elif fails: + if packager.is_file() or wizard_ok or (root / "codegen_setup.c").is_file(): + layout = LayoutClass.SETUP_HOST_PARTIAL + else: + layout = LayoutClass.UNKNOWN + elif rec_warns: + layout = LayoutClass.SETUP_HOST_PARTIAL + else: + layout = LayoutClass.SCAFFOLD_COMPLETE + + if boot is None: + notes.append("Could not parse [game].exe from game.toml — set --boot-exe when applying.") + notes.append( + "Policy: setup-host releases only (no prebuilt generated-C zips)." + ) + + return AuditReport( + root=str(root), + layout=layout, + project_name=name, + boot_exe=boot, + checks=checks, + notes=notes, + ) diff --git a/tools/new_project_layout/project_studio/gui.py b/tools/new_project_layout/project_studio/gui.py new file mode 100644 index 00000000..076d4196 --- /dev/null +++ b/tools/new_project_layout/project_studio/gui.py @@ -0,0 +1,283 @@ +"""Tkinter GUI for Project Studio (stdlib only).""" + +from __future__ import annotations + +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from .detect import audit_project +from .models import CheckStatus, MigrateOptions +from .ops import apply_plan +from .plan import build_plan + + +def run_gui(*, initial_root: Path | None = None) -> int: + app = ProjectStudioApp(initial_root=initial_root) + app.mainloop() + return 0 + + +class ProjectStudioApp(tk.Tk): + def __init__(self, *, initial_root: Path | None = None) -> None: + super().__init__() + self.title("PSXRecomp Project Studio") + self.geometry("980x720") + self.minsize(800, 560) + + self.root_var = tk.StringVar(value=str(initial_root) if initial_root else "") + self.disc_var = tk.StringVar() + self.players_var = tk.IntVar(value=2) + self.zip_var = tk.StringVar() + self.netplay_var = tk.BooleanVar(value=False) + self.ci_var = tk.BooleanVar(value=True) + self.probe_var = tk.BooleanVar(value=False) + self.dry_run_var = tk.BooleanVar(value=True) + self.force_var = tk.BooleanVar(value=False) + + self._report = None + self._plan = None + self._step_vars: dict[str, tk.BooleanVar] = {} + + self._build() + if initial_root: + self.refresh_audit() + + def _build(self) -> None: + pad = {"padx": 8, "pady": 4} + + top = ttk.Frame(self) + top.pack(fill=tk.X, **pad) + ttk.Label(top, text="Game repo:").pack(side=tk.LEFT) + ttk.Entry(top, textvariable=self.root_var).pack( + side=tk.LEFT, fill=tk.X, expand=True, padx=6 + ) + ttk.Button(top, text="Browse…", command=self._browse_root).pack(side=tk.LEFT) + ttk.Button(top, text="Audit", command=self.refresh_audit).pack(side=tk.LEFT, padx=4) + + opts = ttk.LabelFrame(self, text="Options (setup-host only)") + opts.pack(fill=tk.X, **pad) + + row1 = ttk.Frame(opts) + row1.pack(fill=tk.X, **pad) + ttk.Label(row1, text="Disc .cue (optional probe):").pack(side=tk.LEFT) + ttk.Entry(row1, textvariable=self.disc_var).pack( + side=tk.LEFT, fill=tk.X, expand=True, padx=6 + ) + ttk.Button(row1, text="Browse…", command=self._browse_disc).pack(side=tk.LEFT) + + row2 = ttk.Frame(opts) + row2.pack(fill=tk.X, **pad) + ttk.Label(row2, text="Players:").pack(side=tk.LEFT) + ttk.Spinbox(row2, from_=1, to=8, textvariable=self.players_var, width=4).pack( + side=tk.LEFT, padx=4 + ) + ttk.Label(row2, text="Zip prefix:").pack(side=tk.LEFT, padx=(12, 0)) + ttk.Entry(row2, textvariable=self.zip_var, width=16).pack(side=tk.LEFT, padx=4) + ttk.Checkbutton(row2, text="Netplay", variable=self.netplay_var).pack( + side=tk.LEFT, padx=8 + ) + ttk.Checkbutton(row2, text="CI workflow", variable=self.ci_var).pack(side=tk.LEFT) + ttk.Checkbutton(row2, text="Probe disc", variable=self.probe_var).pack( + side=tk.LEFT, padx=8 + ) + ttk.Checkbutton(row2, text="Dry-run", variable=self.dry_run_var).pack(side=tk.LEFT) + ttk.Checkbutton(row2, text="Force", variable=self.force_var).pack( + side=tk.LEFT, padx=8 + ) + + policy = ttk.Label( + opts, + text="Wizard + recomp-ui are always enabled. Releases are setup-host only (no prebuilt game C).", + foreground="#444", + ) + policy.pack(anchor=tk.W, padx=8, pady=(0, 6)) + + mid = ttk.Panedwindow(self, orient=tk.VERTICAL) + mid.pack(fill=tk.BOTH, expand=True, **pad) + + audit_frame = ttk.LabelFrame(mid, text="Audit") + plan_frame = ttk.LabelFrame(mid, text="Plan (uncheck to skip)") + mid.add(audit_frame, weight=2) + mid.add(plan_frame, weight=2) + + self.audit_tree = ttk.Treeview( + audit_frame, + columns=("status", "severity", "detail"), + show="tree headings", + height=12, + ) + self.audit_tree.heading("#0", text="Check") + self.audit_tree.heading("status", text="Status") + self.audit_tree.heading("severity", text="Severity") + self.audit_tree.heading("detail", text="Detail") + self.audit_tree.column("#0", width=220) + self.audit_tree.column("status", width=70, anchor=tk.CENTER) + self.audit_tree.column("severity", width=100) + self.audit_tree.column("detail", width=480) + yscroll = ttk.Scrollbar(audit_frame, orient=tk.VERTICAL, command=self.audit_tree.yview) + self.audit_tree.configure(yscrollcommand=yscroll.set) + self.audit_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + yscroll.pack(side=tk.RIGHT, fill=tk.Y) + + self.plan_frame_inner = ttk.Frame(plan_frame) + self.plan_frame_inner.pack(fill=tk.BOTH, expand=True) + self.plan_canvas = tk.Canvas(self.plan_frame_inner, highlightthickness=0) + self.plan_scroll = ttk.Scrollbar( + self.plan_frame_inner, orient=tk.VERTICAL, command=self.plan_canvas.yview + ) + self.plan_checks = ttk.Frame(self.plan_canvas) + self.plan_checks.bind( + "", + lambda e: self.plan_canvas.configure(scrollregion=self.plan_canvas.bbox("all")), + ) + self.plan_canvas.create_window((0, 0), window=self.plan_checks, anchor="nw") + self.plan_canvas.configure(yscrollcommand=self.plan_scroll.set) + self.plan_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.plan_scroll.pack(side=tk.RIGHT, fill=tk.Y) + + bottom = ttk.Frame(self) + bottom.pack(fill=tk.X, **pad) + ttk.Button(bottom, text="Build plan", command=self.refresh_plan).pack(side=tk.LEFT) + ttk.Button(bottom, text="Apply selected", command=self.apply_selected).pack( + side=tk.LEFT, padx=8 + ) + self.status_var = tk.StringVar(value="Open a game repo and Audit.") + ttk.Label(bottom, textvariable=self.status_var).pack(side=tk.LEFT, padx=12) + + log_frame = ttk.LabelFrame(self, text="Log") + log_frame.pack(fill=tk.BOTH, expand=False, **pad) + self.log = tk.Text(log_frame, height=8, wrap=tk.WORD) + self.log.pack(fill=tk.BOTH, expand=True) + + def _browse_root(self) -> None: + path = filedialog.askdirectory(title="Select game repository root") + if path: + self.root_var.set(path) + self.refresh_audit() + + def _browse_disc(self) -> None: + path = filedialog.askopenfilename( + title="Select Redump .cue", + filetypes=[("Cue sheet", "*.cue"), ("All", "*.*")], + ) + if path: + self.disc_var.set(path) + self.probe_var.set(True) + + def _options(self) -> MigrateOptions: + return MigrateOptions( + disc=self.disc_var.get().strip() or None, + players=int(self.players_var.get()), + zip_prefix=self.zip_var.get().strip() or None, + enable_recomp_ui=True, + enable_wizard=True, + enable_netplay=bool(self.netplay_var.get()), + enable_ci=bool(self.ci_var.get()), + probe_disc=bool(self.probe_var.get()) and bool(self.disc_var.get().strip()), + record_pins=True, + dry_run=bool(self.dry_run_var.get()), + force=bool(self.force_var.get()), + ) + + def _log(self, msg: str) -> None: + self.log.insert(tk.END, msg + "\n") + self.log.see(tk.END) + + def refresh_audit(self) -> None: + root_s = self.root_var.get().strip() + if not root_s: + messagebox.showerror("Project Studio", "Choose a game repo root.") + return + root = Path(root_s).expanduser().resolve() + if not root.is_dir(): + messagebox.showerror("Project Studio", f"Not a directory:\n{root}") + return + self._report = audit_project(root) + for item in self.audit_tree.get_children(): + self.audit_tree.delete(item) + for c in self._report.checks: + self.audit_tree.insert( + "", + tk.END, + text=c.title, + values=(c.status.value.upper(), c.severity.value, c.detail), + tags=(c.status.value,), + ) + self.audit_tree.tag_configure("fail", foreground="#b00020") + self.audit_tree.tag_configure("warn", foreground="#9a6700") + self.audit_tree.tag_configure("pass", foreground="#0b6b0b") + self.status_var.set( + f"Layout: {self._report.layout.value} · boot={self._report.boot_exe or '?'}" + ) + self._log(f"Audited {root} → {self._report.layout.value}") + self.refresh_plan() + + def refresh_plan(self) -> None: + root_s = self.root_var.get().strip() + if not root_s: + return + root = Path(root_s).expanduser().resolve() + opts = self._options() + self._plan = build_plan(root, opts, self._report) + for child in self.plan_checks.winfo_children(): + child.destroy() + self._step_vars.clear() + if not self._plan.steps: + ttk.Label(self.plan_checks, text="No migration steps needed.").pack(anchor=tk.W) + return + for step in self._plan.steps: + var = tk.BooleanVar(value=step.selected) + self._step_vars[step.op_id] = var + row = ttk.Frame(self.plan_checks) + row.pack(fill=tk.X, anchor=tk.W, pady=2) + ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT) + ttk.Label(row, text=f"{step.title} ({step.op_id})").pack(side=tk.LEFT) + if step.detail: + ttk.Label(row, text=step.detail, foreground="#555").pack( + side=tk.LEFT, padx=8 + ) + + def apply_selected(self) -> None: + if self._plan is None: + self.refresh_plan() + if self._plan is None or not self._plan.steps: + messagebox.showinfo("Project Studio", "Nothing to apply.") + return + opts = self._options() + opts.enable_wizard = True + opts.enable_recomp_ui = True + self._plan.options = opts + selected = [op for op, var in self._step_vars.items() if var.get()] + if not selected: + messagebox.showinfo("Project Studio", "No steps selected.") + return + # Sync selected flags onto plan steps + for step in self._plan.steps: + step.selected = step.op_id in selected + + mode = "DRY-RUN" if opts.dry_run else "APPLY" + if not opts.dry_run: + if not messagebox.askyesno( + "Project Studio", + f"Apply {len(selected)} step(s) to:\n{self._plan.root}\n\n" + "A backup CMakeLists.txt.pre_migrate.bak is written when rewriting CMake.", + ): + return + + self._log(f"--- {mode} ({len(selected)} ops) ---") + results = apply_plan(self._plan, selected=selected) + failed = 0 + for r in results: + self._log(f"[{'OK' if r.ok else 'FAIL'}] {r.op_id}: {r.message}") + for p in r.changed_paths: + self._log(f" · {p}") + if not r.ok: + failed += 1 + self.status_var.set(f"{mode} done — {failed} failed, {len(results) - failed} ok") + if not opts.dry_run: + self.refresh_audit() + if failed: + messagebox.showwarning("Project Studio", f"{failed} step(s) failed — see log.") + else: + messagebox.showinfo("Project Studio", f"{mode} completed successfully.") diff --git a/tools/new_project_layout/project_studio/models.py b/tools/new_project_layout/project_studio/models.py new file mode 100644 index 00000000..640dbe44 --- /dev/null +++ b/tools/new_project_layout/project_studio/models.py @@ -0,0 +1,141 @@ +"""Data models for audit / plan / apply.""" + +from __future__ import annotations + +from dataclasses import dataclass, field, asdict +from enum import Enum +from typing import Any + + +class Severity(str, Enum): + REQUIRED = "required" + RECOMMENDED = "recommended" + OPTIONAL = "optional" + INFO = "info" + + +class CheckStatus(str, Enum): + PASS = "pass" + FAIL = "fail" + WARN = "warn" + SKIP = "skip" + + +class LayoutClass(str, Enum): + SCAFFOLD_COMPLETE = "scaffold-complete" + SETUP_HOST_PARTIAL = "setup-host-partial" + LEGACY_PACKAGING = "legacy-packaging" + UNKNOWN = "unknown" + + +@dataclass +class CheckResult: + id: str + title: str + status: CheckStatus + severity: Severity + detail: str = "" + fix_op: str | None = None + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["status"] = self.status.value + d["severity"] = self.severity.value + return d + + +@dataclass +class AuditReport: + root: str + layout: LayoutClass + project_name: str + boot_exe: str | None + checks: list[CheckResult] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "root": self.root, + "layout": self.layout.value, + "project_name": self.project_name, + "boot_exe": self.boot_exe, + "checks": [c.to_dict() for c in self.checks], + "notes": list(self.notes), + "fail_count": sum(1 for c in self.checks if c.status == CheckStatus.FAIL), + "warn_count": sum(1 for c in self.checks if c.status == CheckStatus.WARN), + } + + def failing_ops(self) -> list[str]: + ops: list[str] = [] + for c in self.checks: + if c.status in (CheckStatus.FAIL, CheckStatus.WARN) and c.fix_op: + if c.fix_op not in ops: + ops.append(c.fix_op) + return ops + + +@dataclass +class MigrateOptions: + """User choices for apply (setup-host exclusively).""" + + disc: str | None = None + project_name: str | None = None + boot_exe: str | None = None + players: int = 2 + zip_prefix: str | None = None + window_title: str | None = None + enable_recomp_ui: bool = True + enable_wizard: bool = True + enable_netplay: bool = False + lobby_url: str = "ws://netplay.retcomm.net:8765" + enable_ci: bool = True + relocate_boxart: bool = True + rewrite_cmake: bool = True + merge_gitignore: bool = True + probe_disc: bool = False + record_pins: bool = True + force: bool = False + only: list[str] = field(default_factory=list) + skip: list[str] = field(default_factory=list) + dry_run: bool = False + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class PlanStep: + op_id: str + title: str + detail: str = "" + selected: bool = True + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class Plan: + root: str + layout: LayoutClass + steps: list[PlanStep] = field(default_factory=list) + options: MigrateOptions = field(default_factory=MigrateOptions) + + def to_dict(self) -> dict[str, Any]: + return { + "root": self.root, + "layout": self.layout.value, + "steps": [s.to_dict() for s in self.steps], + "options": self.options.to_dict(), + } + + +@dataclass +class ApplyResult: + op_id: str + ok: bool + message: str + changed_paths: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/tools/new_project_layout/project_studio/naming.py b/tools/new_project_layout/project_studio/naming.py new file mode 100644 index 00000000..f91bf747 --- /dev/null +++ b/tools/new_project_layout/project_studio/naming.py @@ -0,0 +1,214 @@ +"""Derive project naming tokens (mirrors setup_project.sh).""" + +from __future__ import annotations + +import re +from pathlib import Path + +from fill_tokens import derive_zip_prefix + + +DEFAULT_LOBBY_HOST = "netplay.retcomm.net" +DEFAULT_LOBBY_URL = f"ws://{DEFAULT_LOBBY_HOST}:8765" + + +def project_cmake_name(name: str) -> str: + return re.sub(r"[^A-Za-z0-9_]", "_", name) + + +def env_prefix(cmake_name: str) -> str: + return cmake_name.upper() + + +def window_title_from_name(name: str) -> str: + title = re.sub(r"Recomp$", " Recompiled", name) + title = title.replace("Recompiled Recompiled", "Recompiled") + return title + + +def exe_basename(window_title: str) -> str: + return re.sub(r"[^A-Za-z0-9_]", "_", window_title) + + +def game_name_from_project(name: str) -> str: + g = re.sub(r"(Recomp|Recompiled)$", "", name).rstrip() + return g or name + + +def normalize_lobby_url(value: str | None) -> str: + raw = (value or DEFAULT_LOBBY_HOST).strip() + if raw.startswith("ws://") or raw.startswith("wss://"): + return raw + if "://" in raw: + return raw + if ":" in raw: + return f"ws://{raw}" + return f"ws://{raw}:8765" + + +def boot_exe_from_game_toml(game_toml: Path) -> str | None: + """Best-effort parse of [game].exe basename (e.g. SCUS_944.23).""" + if not game_toml.is_file(): + return None + text = game_toml.read_text(encoding="utf-8", errors="replace") + in_game = False + for line in text.splitlines(): + s = line.strip() + if s.startswith("[") and s.endswith("]"): + in_game = s == "[game]" + continue + if not in_game or "=" not in s or s.startswith("#"): + continue + key, _, val = s.partition("=") + if key.strip() != "exe": + continue + val = val.strip().strip('"').strip("'") + # path/to/SCUS_944.23 → SCUS_944.23 + return Path(val).name + return None + + +def entry_pc_from_game_toml(game_toml: Path) -> str: + if not game_toml.is_file(): + return "0x80010000" + text = game_toml.read_text(encoding="utf-8", errors="replace") + in_game = False + for line in text.splitlines(): + s = line.strip() + if s.startswith("[") and s.endswith("]"): + in_game = s == "[game]" + continue + if not in_game or "=" not in s or s.startswith("#"): + continue + key, _, val = s.partition("=") + if key.strip() == "entry_pc": + return val.strip().strip('"').strip("'") + return "0x80010000" + + +def players_from_cmake(cmake: Path) -> int | None: + if not cmake.is_file(): + return None + text = cmake.read_text(encoding="utf-8", errors="replace") + m = re.search(r"MAX_PLAYERS\s+(\d+)", text) + if m: + return int(m.group(1)) + return None + + +def window_title_from_cmake(cmake: Path) -> str | None: + if not cmake.is_file(): + return None + text = cmake.read_text(encoding="utf-8", errors="replace") + m = re.search(r'WINDOW_TITLE\s+"([^"]+)"', text) + return m.group(1) if m else None + + +def infer_project_name(root: Path) -> str: + return root.name + + +def build_token_map( + *, + name: str, + boot_exe: str, + players: int, + zip_prefix: str | None = None, + window_title: str | None = None, + entry_pc: str = "0x80010000", + description: str = "", + publisher: str = "", + year: str = "", + region: str = "USA", + lobby_url: str = DEFAULT_LOBBY_URL, + enable_recomp_ui: bool = True, + enable_wizard: bool = True, + enable_netplay: bool = False, + has_boxart: bool = False, +) -> dict[str, str]: + cmake_name = project_cmake_name(name) + title = window_title or window_title_from_name(name) + game = game_name_from_project(name) + zp = zip_prefix or derive_zip_prefix(name) + desc = description or "_Add a short pitch in catalog_identity.json / README._" + publisher_disp = publisher or "—" + year_disp = year or "—" + + if enable_recomp_ui: + recomp_ui_block = "# recomp-ui submodule present (PSX_RECOMP_UI defaults ON)." + codegen_arg = ( + ' CODEGEN_SETUP_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/codegen_setup.c"' + ) + else: + recomp_ui_block = ( + 'set(PSX_RECOMP_UI OFF CACHE BOOL\n' + ' "No recomp-ui submodule in this scaffold" FORCE)' + ) + codegen_arg = " # CODEGEN_SETUP_SOURCES — add with recomp-ui / wizard later" + + if enable_netplay: + netplay_block = ( + 'if(EXISTS "${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt")\n' + " set(PSX_NETPLAY ON CACHE BOOL\n" + ' "Link recomp-net delay-sync (opt-in; needs recomp-net)" FORCE)\n' + "endif()" + ) + netplay_runtime = " ENABLE_NETPLAY_IF_PRESENT" + netplay_lobby = f' NETPLAY_LOBBY_URL "{lobby_url}"' + else: + netplay_block = ( + "# Full netplay UI — enable after adding recomp-ui + testing:\n" + '# if(EXISTS "${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt")\n' + '# set(PSX_NETPLAY ON CACHE BOOL "…" FORCE)\n' + "# endif()" + ) + netplay_runtime = " # ENABLE_NETPLAY_IF_PRESENT" + netplay_lobby = f' # NETPLAY_LOBBY_URL "ws://{DEFAULT_LOBBY_HOST}:8765"' + + if enable_wizard: + wizard_block = ( + "set(PSX_SETUP_WIZARD ON CACHE BOOL\n" + ' "Advertise first-run setup wizard + Generate & rebuild in recomp-ui" FORCE)' + ) + wizard_runtime = " ENABLE_SETUP_WIZARD" + else: + wizard_block = ( + "# First-run setup wizard — enable after testing:\n" + '# set(PSX_SETUP_WIZARD ON CACHE BOOL "…" FORCE)' + ) + wizard_runtime = " # ENABLE_SETUP_WIZARD" + + if has_boxart: + boxart = ( + ' LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"' + ) + else: + boxart = ( + ' # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga"' + ) + + return { + "PROJECT_CMAKE_NAME": cmake_name, + "WINDOW_TITLE": title, + "BOOT_EXE": boot_exe, + "GAME_NAME": game, + "ENV_PREFIX": env_prefix(cmake_name), + "EXE_BASENAME": exe_basename(title), + "ZIP_PREFIX": zp, + "DISC_HINT": f"your legally owned {game} disc", + "GAME_TITLE": title, + "PLAYERS": str(players), + "DESCRIPTION": desc, + "PUBLISHER": publisher_disp, + "YEAR": year_disp, + "REGION": region, + "ENTRY_PC": entry_pc, + "NETPLAY_RUNTIME_ARG": netplay_runtime, + "NETPLAY_LOBBY_URL_ARG": netplay_lobby, + "WIZARD_RUNTIME_ARG": wizard_runtime, + "BOXART_CMAKE_ARG": boxart, + "NETPLAY_CMAKE_BLOCK": netplay_block, + "WIZARD_CMAKE_BLOCK": wizard_block, + "RECOMP_UI_CMAKE_BLOCK": recomp_ui_block, + "CODEGEN_SETUP_ARG": codegen_arg, + } diff --git a/tools/new_project_layout/project_studio/ops.py b/tools/new_project_layout/project_studio/ops.py new file mode 100644 index 00000000..a9ee0289 --- /dev/null +++ b/tools/new_project_layout/project_studio/ops.py @@ -0,0 +1,725 @@ +"""Apply migration plan steps (setup-host exclusively).""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from fill_tokens import derive_zip_prefix + +from .models import ApplyResult, MigrateOptions, Plan +from .naming import ( + boot_exe_from_game_toml, + build_token_map, + entry_pc_from_game_toml, + infer_project_name, + normalize_lobby_url, + players_from_cmake, + window_title_from_cmake, + window_title_from_name, +) +from .paths import ci_setup_release_template, templates_dir, toolkit_dir + + +def _run(cmd: list[str], cwd: Path, dry_run: bool) -> tuple[bool, str]: + if dry_run: + return True, "dry-run: " + " ".join(cmd) + try: + proc = subprocess.run( + cmd, + cwd=str(cwd), + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + return False, str(exc) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip() + return False, err or f"exit {proc.returncode}" + return True, (proc.stdout or "").strip() + + +def _write(path: Path, text: str, dry_run: bool) -> None: + if dry_run: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _fill(src: Path, dst: Path, tokens: dict[str, str], dry_run: bool, *, ci: bool = False) -> None: + text = src.read_text(encoding="utf-8") + for k, v in tokens.items(): + text = text.replace(f"@{k}@", v) + if ci: + zp = tokens.get("ZIP_PREFIX", "game") + title = tokens.get("GAME_TITLE") or tokens.get("WINDOW_TITLE") or "Game" + title = " ".join(title.split()) + title_yaml = ( + title.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", "") + ) + text = text.replace("YOUR_ZIP_PREFIX", zp) + text = text.replace("YOUR_GAME_TITLE", title_yaml) + text = text.replace("yourgame-release", f"{zp}-release") + _write(dst, text, dry_run) + + +def _is_real_psxrecomp(path: Path) -> bool: + return (path / "runtime" / "runtime.cmake").is_file() + + +def _resolve_tokens(root: Path, options: MigrateOptions) -> dict[str, str]: + name = options.project_name or infer_project_name(root) + boot = ( + options.boot_exe + or boot_exe_from_game_toml(root / "game.toml") + or "SLUS_01234" + ) + players = options.players + if players == 2: + detected = players_from_cmake(root / "CMakeLists.txt") + if detected is not None: + players = detected + title = options.window_title or window_title_from_cmake(root / "CMakeLists.txt") + if not title: + # Prefer game.toml window_title + gt = (root / "game.toml").read_text(encoding="utf-8", errors="replace") if ( + root / "game.toml" + ).is_file() else "" + for line in gt.splitlines(): + if line.strip().startswith("window_title"): + _, _, v = line.partition("=") + title = v.strip().strip('"').strip("'") + break + if not title: + title = window_title_from_name(name) + + has_boxart = (root / "launcher_assets" / "img" / "boxart.tga").is_file() + # Also treat pending relocate as has_boxart for cmake wiring after relocate runs first + if not has_boxart: + for p in ( + root / "recomp" / "launcher" / "boxart.tga", + root / "recomp" / "launcher" / "boxart.png", + ): + if p.is_file(): + has_boxart = options.relocate_boxart + break + + enable_netplay = options.enable_netplay and players >= 2 + return build_token_map( + name=name, + boot_exe=boot, + players=players, + zip_prefix=options.zip_prefix or derive_zip_prefix(name), + window_title=title, + entry_pc=entry_pc_from_game_toml(root / "game.toml"), + lobby_url=normalize_lobby_url(options.lobby_url), + enable_recomp_ui=options.enable_recomp_ui, + enable_wizard=options.enable_wizard if options.enable_recomp_ui else False, + enable_netplay=enable_netplay if options.enable_recomp_ui else False, + has_boxart=has_boxart, + ) + + +def op_rename_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyResult: + v4 = root / "psxrecomp-v4" + dest = root / "psxrecomp" + changed: list[str] = [] + + if not v4.exists() and _is_real_psxrecomp(dest): + return ApplyResult("rename_psxrecomp_submodule", True, "Already using psxrecomp/", []) + + if not v4.exists(): + return ApplyResult( + "rename_psxrecomp_submodule", False, "psxrecomp-v4 not found", [] + ) + + if dest.exists() and not _is_real_psxrecomp(dest): + stub_bak = root / "psxrecomp.stub.bak" + if options.dry_run: + msg = f"dry-run: move stub {dest} → {stub_bak}, then {v4} → {dest}" + else: + if stub_bak.exists(): + shutil.rmtree(stub_bak) + dest.rename(stub_bak) + changed.append(str(stub_bak.relative_to(root))) + msg = f"Moved stub psxrecomp/ → {stub_bak.name}" + elif dest.exists() and _is_real_psxrecomp(dest): + # Real tree already — drop v4 submodule entry if possible + msg = "psxrecomp/ already real; will update .gitmodules and leave v4 for manual removal" + else: + msg = "" + + if not dest.exists() or not _is_real_psxrecomp(dest): + ok, out = _run(["git", "mv", "psxrecomp-v4", "psxrecomp"], root, options.dry_run) + if not ok: + # Fallback without git mv + if options.dry_run: + out = "dry-run: shutil.move psxrecomp-v4 → psxrecomp" + else: + shutil.move(str(v4), str(dest)) + out = "Moved psxrecomp-v4 → psxrecomp (without git mv)" + changed.append("psxrecomp") + msg = (msg + "; " if msg else "") + out + + # Rewrite .gitmodules + gm = root / ".gitmodules" + if gm.is_file(): + text = gm.read_text(encoding="utf-8") + new = text.replace('path = psxrecomp-v4', "path = psxrecomp") + new = new.replace('[submodule "psxrecomp-v4"]', '[submodule "psxrecomp"]') + if new != text: + _write(gm, new, options.dry_run) + changed.append(".gitmodules") + + # Patch CMakeLists PSXRECOMP_ROOT path if present + cmake = root / "CMakeLists.txt" + if cmake.is_file(): + text = cmake.read_text(encoding="utf-8") + new = text.replace("psxrecomp-v4", "psxrecomp") + if new != text: + _write(cmake, new, options.dry_run) + changed.append("CMakeLists.txt") + + return ApplyResult("rename_psxrecomp_submodule", True, msg or "Renamed submodule", changed) + + +def op_ensure_psxrecomp_submodule(root: Path, options: MigrateOptions) -> ApplyResult: + if _is_real_psxrecomp(root / "psxrecomp"): + return ApplyResult("ensure_psxrecomp_submodule", True, "Already present", []) + url = "https://github.com/mstan/psxrecomp.git" + ok, out = _run( + ["git", "submodule", "add", "-b", "master", url, "psxrecomp"], + root, + options.dry_run, + ) + if not ok: + return ApplyResult("ensure_psxrecomp_submodule", False, out, []) + ok2, out2 = _run( + ["git", "submodule", "update", "--init", "--recursive", "psxrecomp"], + root, + options.dry_run, + ) + return ApplyResult( + "ensure_psxrecomp_submodule", + ok2, + out2 or out or "Added psxrecomp submodule", + ["psxrecomp", ".gitmodules"], + ) + + +def op_ensure_recomp_ui_submodule(root: Path, options: MigrateOptions) -> ApplyResult: + if (root / "recomp-ui").is_dir() and ( + (root / "recomp-ui" / "CMakeLists.txt").is_file() + or (root / "recomp-ui" / ".git").exists() + ): + return ApplyResult("ensure_recomp_ui_submodule", True, "Already present", []) + url = "https://github.com/mstan/recomp-ui.git" + ok, out = _run( + ["git", "submodule", "add", "-b", "master", url, "recomp-ui"], + root, + options.dry_run, + ) + if not ok: + return ApplyResult("ensure_recomp_ui_submodule", False, out, []) + _run( + ["git", "submodule", "update", "--init", "--recursive", "recomp-ui"], + root, + options.dry_run, + ) + return ApplyResult( + "ensure_recomp_ui_submodule", + True, + out or "Added recomp-ui submodule", + ["recomp-ui", ".gitmodules"], + ) + + +def op_emit_codegen_setup(root: Path, options: MigrateOptions) -> ApplyResult: + tokens = _resolve_tokens(root, options) + tdir = templates_dir() + changed = [] + for name in ("codegen_setup.c", "codegen_setup.h"): + src = tdir / f"{name}.in" + dst = root / name + if dst.is_file() and not options.force: + # Refresh if missing forward helper + if name.endswith(".c") and "psx_game_codegen_forward_if_built" in dst.read_text( + encoding="utf-8", errors="replace" + ): + continue + _fill(src, dst, tokens, options.dry_run) + changed.append(name) + if not changed: + return ApplyResult("emit_codegen_setup", True, "Already up to date", []) + return ApplyResult("emit_codegen_setup", True, "Wrote codegen_setup", changed) + + +def op_emit_version(root: Path, options: MigrateOptions) -> ApplyResult: + dst = root / "VERSION" + if dst.is_file() and not options.force: + return ApplyResult("emit_version", True, "VERSION already exists", []) + src = templates_dir() / "VERSION.in" + text = "0.1.0\n" if not src.is_file() else src.read_text(encoding="utf-8") + # VERSION.in may have tokens — keep simple + text = text.replace("@VERSION@", "0.1.0") + if not text.endswith("\n"): + text += "\n" + _write(dst, text if "@" not in text else "0.1.0\n", options.dry_run) + return ApplyResult("emit_version", True, "Wrote VERSION", ["VERSION"]) + + +def op_emit_symbols_toml(root: Path, options: MigrateOptions) -> ApplyResult: + dst = root / "symbols.toml" + if dst.is_file() and not options.force: + return ApplyResult("emit_symbols_toml", True, "symbols.toml already exists", []) + tokens = _resolve_tokens(root, options) + _fill(templates_dir() / "symbols.toml.in", dst, tokens, options.dry_run) + return ApplyResult("emit_symbols_toml", True, "Wrote symbols.toml", ["symbols.toml"]) + + +def op_emit_sync_symbols(root: Path, options: MigrateOptions) -> ApplyResult: + src = toolkit_dir() / "sync_symbols.py" + dst = root / "tools" / "sync_symbols.py" + if dst.is_file() and not options.force: + return ApplyResult("emit_sync_symbols", True, "Already present", []) + if options.dry_run: + return ApplyResult("emit_sync_symbols", True, "dry-run: copy sync_symbols.py", ["tools/sync_symbols.py"]) + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + return ApplyResult("emit_sync_symbols", True, "Installed tools/sync_symbols.py", ["tools/sync_symbols.py"]) + + +def op_merge_gitignore(root: Path, options: MigrateOptions) -> ApplyResult: + src = templates_dir() / "gitignore.in" + template = src.read_text(encoding="utf-8") + tokens = _resolve_tokens(root, options) + for k, v in tokens.items(): + template = template.replace(f"@{k}@", v) + + dst = root / ".gitignore" + existing = dst.read_text(encoding="utf-8") if dst.is_file() else "" + required = [ + "/generated/", + "/disc/", + "/bios/", + "/dist/", + "/build/", + "/build-*/", + "/saves/", + "*.bin", + "*.cue", + ] + additions = [] + for pat in required: + if pat not in existing: + additions.append(pat) + + if not existing: + _write(dst, template if not template.startswith("@") else "\n".join(required) + "\n", options.dry_run) + return ApplyResult("merge_gitignore", True, "Wrote .gitignore from template", [".gitignore"]) + + if not additions: + return ApplyResult("merge_gitignore", True, "No gitignore changes needed", []) + + block = ( + "\n# --- PSXRecomp setup-host (project studio) ---\n" + + "\n".join(additions) + + "\n" + ) + _write(dst, existing.rstrip() + "\n" + block, options.dry_run) + return ApplyResult( + "merge_gitignore", + True, + f"Appended {len(additions)} ignore rules", + [".gitignore"], + ) + + +def op_emit_mods_preloaded(root: Path, options: MigrateOptions) -> ApplyResult: + base = root / "mods" / "preloaded" + readme = base / "README.md" + packages = base / "packages" / ".gitkeep" + if readme.is_file() and (base / "packages").is_dir() and not options.force: + return ApplyResult("emit_mods_preloaded", True, "Already present", []) + if options.dry_run: + return ApplyResult( + "emit_mods_preloaded", + True, + "dry-run: stub mods/preloaded", + ["mods/preloaded/README.md"], + ) + packages.parent.mkdir(parents=True, exist_ok=True) + packages.write_text("", encoding="utf-8") + readme.write_text( + "# Preloaded mods\n\n" + "Ship default-off `.psxmod` packages under `packages///`.\n" + "Setup-host releases copy this tree beside the executable when present.\n", + encoding="utf-8", + ) + return ApplyResult( + "emit_mods_preloaded", + True, + "Created mods/preloaded stub", + ["mods/preloaded/README.md", "mods/preloaded/packages/.gitkeep"], + ) + + +def op_relocate_boxart(root: Path, options: MigrateOptions) -> ApplyResult: + dest_dir = root / "launcher_assets" / "img" + dest = dest_dir / "boxart.tga" + if dest.is_file() and not options.force: + return ApplyResult("relocate_boxart", True, "Modern boxart already present", []) + + sources = [ + root / "recomp" / "launcher" / "boxart.tga", + root / "recomp" / "launcher" / "boxart.png", + ] + src = next((p for p in sources if p.is_file()), None) + if src is None: + return ApplyResult("relocate_boxart", False, "No legacy boxart found", []) + + if options.dry_run: + return ApplyResult( + "relocate_boxart", + True, + f"dry-run: copy {src.name} → launcher_assets/img/boxart.tga", + ["launcher_assets/img/boxart.tga"], + ) + + dest_dir.mkdir(parents=True, exist_ok=True) + if src.suffix.lower() == ".tga": + shutil.copy2(src, dest) + else: + # Keep png beside expected path with note — TGA preferred by launcher + shutil.copy2(src, dest_dir / src.name) + (dest_dir / "BOXART_SOURCE.txt").write_text( + f"Copied from {src.relative_to(root)}; convert to boxart.tga if needed.\n", + encoding="utf-8", + ) + return ApplyResult( + "relocate_boxart", + True, + f"Copied {src.name} (not TGA — convert for LAUNCHER_BOXART)", + [str((dest_dir / src.name).relative_to(root))], + ) + + (dest_dir / "BOXART_SOURCE.txt").write_text( + f"Relocated from {src.relative_to(root)} by project studio.\n", + encoding="utf-8", + ) + return ApplyResult( + "relocate_boxart", + True, + "Relocated boxart to launcher_assets/img/boxart.tga", + ["launcher_assets/img/boxart.tga", "launcher_assets/img/BOXART_SOURCE.txt"], + ) + + +def op_emit_boxart_stub(root: Path, options: MigrateOptions) -> ApplyResult: + dest_dir = root / "launcher_assets" / "img" + if dest_dir.is_dir() and not options.force: + return ApplyResult("emit_boxart_stub", True, "launcher_assets already exists", []) + if options.dry_run: + return ApplyResult("emit_boxart_stub", True, "dry-run: mkdir launcher_assets/img", []) + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / ".gitkeep").write_text("", encoding="utf-8") + return ApplyResult( + "emit_boxart_stub", + True, + "Created launcher_assets/img (add boxart.tga later)", + ["launcher_assets/img/.gitkeep"], + ) + + +def op_rewrite_cmake_setup_host(root: Path, options: MigrateOptions) -> ApplyResult: + tokens = _resolve_tokens(root, options) + # Force wizard for setup-host policy + if not options.enable_wizard and options.enable_recomp_ui: + # Still rewrite with wizard ON — exclusive setup-host + options = MigrateOptions(**{**options.to_dict(), "enable_wizard": True}) + tokens = _resolve_tokens(root, options) + + cmake = root / "CMakeLists.txt" + changed = ["CMakeLists.txt"] + extras_note = root / "CMakeLists.migrate_extras.txt" + + old = cmake.read_text(encoding="utf-8") if cmake.is_file() else "" + # Capture extras for the user + extras_bits = [] + if "EXTRAS_SOURCES" in old: + extras_bits.append("Old CMakeLists used EXTRAS_SOURCES — re-attach mod/plugin sources.") + if "add_test(" in old or "BUILD_TESTING" in old: + extras_bits.append("Old CMakeLists had tests — restore from CMakeLists.txt.pre_migrate.bak.") + if "POST_BUILD" in old and "mods/preloaded" in old: + extras_bits.append("Old CMakeLists copied mods/preloaded POST_BUILD — re-add if needed.") + + bak = root / "CMakeLists.txt.pre_migrate.bak" + if cmake.is_file() and not options.dry_run: + if not bak.is_file() or options.force: + bak.write_text(old, encoding="utf-8") + changed.append("CMakeLists.txt.pre_migrate.bak") + + _fill(templates_dir() / "CMakeLists.txt.in", cmake, tokens, options.dry_run) + + if extras_bits and not options.dry_run: + extras_note.write_text( + "Preserved notes after setup-host CMake rewrite:\n\n" + + "\n".join(f"- {b}" for b in extras_bits) + + "\n\nFull previous file: CMakeLists.txt.pre_migrate.bak\n", + encoding="utf-8", + ) + changed.append("CMakeLists.migrate_extras.txt") + + return ApplyResult( + "rewrite_cmake_setup_host", + True, + "Wrote setup-host CMakeLists.txt (backup .pre_migrate.bak)", + changed, + ) + + +def op_emit_packager(root: Path, options: MigrateOptions) -> ApplyResult: + tokens = _resolve_tokens(root, options) + dst = root / "scripts" / "package_setup_release.sh" + if dst.is_file() and not options.force: + return ApplyResult("emit_packager", True, "Packager already present", []) + _fill(templates_dir() / "package_setup_release.sh.in", dst, tokens, options.dry_run) + if not options.dry_run: + dst.chmod(dst.stat().st_mode | 0o111) + return ApplyResult( + "emit_packager", + True, + "Wrote scripts/package_setup_release.sh", + ["scripts/package_setup_release.sh"], + ) + + +def op_emit_ci_workflow(root: Path, options: MigrateOptions) -> ApplyResult: + tokens = _resolve_tokens(root, options) + src = ci_setup_release_template(root) + if src is None: + return ApplyResult( + "emit_ci_workflow", + False, + "Cannot find docs/ci/templates/setup-release.yml (need psxrecomp submodule)", + [], + ) + dst = root / ".github" / "workflows" / "release.yml" + if dst.is_file() and not options.force and "YOUR_ZIP_PREFIX" not in dst.read_text( + encoding="utf-8", errors="replace" + ): + return ApplyResult("emit_ci_workflow", True, "release.yml already filled", []) + _fill(src, dst, tokens, options.dry_run, ci=True) + return ApplyResult( + "emit_ci_workflow", + True, + "Wrote .github/workflows/release.yml (setup-host)", + [".github/workflows/release.yml"], + ) + + +def op_annotate_legacy_packaging(root: Path, options: MigrateOptions) -> ApplyResult: + note = root / "packaging" / "SETUP_HOST_MIGRATION.txt" + text = ( + "This title is migrating to setup-host releases only.\n" + "Do not ship prebuilt generated game C.\n" + "Use scripts/package_setup_release.sh + .github/workflows/release.yml\n" + "(see psxrecomp/docs/GAME_PROJECT_SETUP.md and docs/ci/HOST_ONLY_RELEASES.md).\n" + "Legacy package_release scripts in this folder are obsolete for public releases.\n" + ) + if (root / "packaging").is_dir(): + _write(note, text, options.dry_run) + return ApplyResult( + "annotate_legacy_packaging", + True, + "Wrote packaging/SETUP_HOST_MIGRATION.txt", + ["packaging/SETUP_HOST_MIGRATION.txt"], + ) + # tools-only legacy + tools_note = root / "tools" / "SETUP_HOST_MIGRATION.txt" + _write(tools_note, text, options.dry_run) + return ApplyResult( + "annotate_legacy_packaging", + True, + "Wrote tools/SETUP_HOST_MIGRATION.txt", + ["tools/SETUP_HOST_MIGRATION.txt"], + ) + + +def op_probe_disc_refresh(root: Path, options: MigrateOptions) -> ApplyResult: + if not options.disc: + return ApplyResult( + "probe_disc_refresh", + False, + "Pass --disc path/to/game.cue to refresh identity", + [], + ) + disc = Path(options.disc).expanduser().resolve() + if not disc.is_file(): + return ApplyResult("probe_disc_refresh", False, f"Disc not found: {disc}", []) + + probe = toolkit_dir() / "probe_disc.py" + if not probe.is_file(): + return ApplyResult("probe_disc_refresh", False, "probe_disc.py missing from toolkit", []) + + name = options.project_name or infer_project_name(root) + players = options.players + cmd = [ + sys.executable, + str(probe), + str(disc), + "--write-game-toml", + str(root / "game.toml"), + "--write-catalog", + str(root / "catalog_identity.json"), + "--write-seeds", + str(root / "seeds" / "ghidra_funcs.txt"), + "--out-dir", + "disc", + "--disc-rel", + "disc/" + disc.name, + "--players", + str(players), + "--display-name", + name, + ] + if options.dry_run: + return ApplyResult( + "probe_disc_refresh", + True, + "dry-run: " + " ".join(cmd), + ["game.toml", "catalog_identity.json", "seeds/ghidra_funcs.txt"], + ) + + (root / "seeds").mkdir(parents=True, exist_ok=True) + ok, out = _run(cmd, root, dry_run=False) + if not ok: + return ApplyResult("probe_disc_refresh", False, out, []) + return ApplyResult( + "probe_disc_refresh", + True, + out or "probe_disc completed", + ["game.toml", "catalog_identity.json", "seeds/ghidra_funcs.txt"], + ) + + +def op_record_framework_pins(root: Path, options: MigrateOptions) -> ApplyResult: + record = None + for base in (root / "psxrecomp", root / "psxrecomp-v4"): + cand = base / "tools" / "ci" / "record_pins.sh" + if cand.is_file(): + record = cand + break + dst = root / "framework_pins.txt" + if record is None: + # Best-effort: git rev-parse in submodules + lines = [] + for name in ("psxrecomp", "recomp-ui"): + sub = root / name + if not sub.is_dir(): + continue + ok, out = _run(["git", "rev-parse", "HEAD"], sub, options.dry_run) + if ok and out: + short = out[:7] if not options.dry_run else "dryrun" + lines.append(f"{name}={short} ({out})") + if not lines: + return ApplyResult( + "record_framework_pins", + False, + "No psxrecomp submodule to record pins from", + [], + ) + text = "\n".join(lines) + "\n" + _write(dst, text, options.dry_run) + return ApplyResult("record_framework_pins", True, "Wrote framework_pins.txt", ["framework_pins.txt"]) + + if options.dry_run: + return ApplyResult( + "record_framework_pins", + True, + f"dry-run: {record} --root {root}", + ["framework_pins.txt"], + ) + proc = subprocess.run( + ["bash", str(record), "--root", str(root)], + capture_output=True, + text=True, + check=False, + cwd=str(root), + ) + body = (proc.stdout or "").strip() + if proc.returncode != 0 and not body: + return ApplyResult( + "record_framework_pins", + False, + (proc.stderr or f"exit {proc.returncode}").strip(), + [], + ) + if body: + dst.write_text(body + "\n", encoding="utf-8") + return ApplyResult( + "record_framework_pins", + True, + "Wrote framework_pins.txt", + ["framework_pins.txt"], + ) + + +_OPS = { + "rename_psxrecomp_submodule": op_rename_psxrecomp_submodule, + "ensure_psxrecomp_submodule": op_ensure_psxrecomp_submodule, + "ensure_recomp_ui_submodule": op_ensure_recomp_ui_submodule, + "emit_codegen_setup": op_emit_codegen_setup, + "emit_version": op_emit_version, + "emit_symbols_toml": op_emit_symbols_toml, + "emit_sync_symbols": op_emit_sync_symbols, + "merge_gitignore": op_merge_gitignore, + "emit_mods_preloaded": op_emit_mods_preloaded, + "relocate_boxart": op_relocate_boxart, + "emit_boxart_stub": op_emit_boxart_stub, + "rewrite_cmake_setup_host": op_rewrite_cmake_setup_host, + "emit_packager": op_emit_packager, + "emit_ci_workflow": op_emit_ci_workflow, + "annotate_legacy_packaging": op_annotate_legacy_packaging, + "probe_disc_refresh": op_probe_disc_refresh, + "record_framework_pins": op_record_framework_pins, +} + + +def apply_plan(plan: Plan, *, selected: list[str] | None = None) -> list[ApplyResult]: + root = Path(plan.root) + options = plan.options + results: list[ApplyResult] = [] + + # Setup-host exclusive: never leave wizard off when CI/packager are applied + if options.enable_ci or any( + s.op_id in ("emit_ci_workflow", "emit_packager", "rewrite_cmake_setup_host") + and s.selected + for s in plan.steps + ): + options.enable_wizard = True + options.enable_recomp_ui = True + + for step in plan.steps: + if not step.selected: + continue + if selected is not None and step.op_id not in selected: + continue + fn = _OPS.get(step.op_id) + if fn is None: + results.append( + ApplyResult(step.op_id, False, f"Unknown op: {step.op_id}", []) + ) + continue + try: + results.append(fn(root, options)) + except Exception as exc: # noqa: BLE001 — surface to CLI/GUI + results.append(ApplyResult(step.op_id, False, f"{type(exc).__name__}: {exc}", [])) + return results + + +def list_ops() -> list[str]: + return list(_OPS.keys()) diff --git a/tools/new_project_layout/project_studio/paths.py b/tools/new_project_layout/project_studio/paths.py new file mode 100644 index 00000000..0838ead3 --- /dev/null +++ b/tools/new_project_layout/project_studio/paths.py @@ -0,0 +1,39 @@ +"""Locate toolkit roots (templates, CI helpers) relative to this package.""" + +from __future__ import annotations + +from pathlib import Path + + +def toolkit_dir() -> Path: + """tools/new_project_layout/""" + return Path(__file__).resolve().parent.parent + + +def templates_dir() -> Path: + return toolkit_dir() / "templates" + + +def psxrecomp_root_from_toolkit() -> Path | None: + """If this toolkit lives inside a psxrecomp checkout, return that root.""" + # …/psxrecomp/tools/new_project_layout/project_studio + candidate = toolkit_dir().parent.parent + if (candidate / "runtime" / "runtime.cmake").is_file(): + return candidate + return None + + +def ci_setup_release_template(game_root: Path | None = None) -> Path | None: + """Prefer game submodule copy; fall back to toolkit's parent psxrecomp.""" + if game_root is not None: + p = game_root / "psxrecomp" / "docs" / "ci" / "templates" / "setup-release.yml" + if p.is_file(): + return p + p = game_root / "psxrecomp-v4" / "docs" / "ci" / "templates" / "setup-release.yml" + if p.is_file(): + return p + root = psxrecomp_root_from_toolkit() + if root is None: + return None + p = root / "docs" / "ci" / "templates" / "setup-release.yml" + return p if p.is_file() else None diff --git a/tools/new_project_layout/project_studio/plan.py b/tools/new_project_layout/project_studio/plan.py new file mode 100644 index 00000000..cdb7783b --- /dev/null +++ b/tools/new_project_layout/project_studio/plan.py @@ -0,0 +1,123 @@ +"""Build an ordered migration plan from an audit + options.""" + +from __future__ import annotations + +from pathlib import Path + +from .detect import audit_project +from .models import AuditReport, LayoutClass, MigrateOptions, Plan, PlanStep + + +# Topological order for apply. Ops not listed are appended at the end. +_OP_ORDER = [ + "rename_psxrecomp_submodule", + "ensure_psxrecomp_submodule", + "ensure_recomp_ui_submodule", + "emit_codegen_setup", + "emit_version", + "emit_symbols_toml", + "emit_sync_symbols", + "merge_gitignore", + "emit_mods_preloaded", + "relocate_boxart", + "emit_boxart_stub", + "rewrite_cmake_setup_host", + "emit_packager", + "emit_ci_workflow", + "annotate_legacy_packaging", + "probe_disc_refresh", + "record_framework_pins", +] + +_OP_TITLES = { + "rename_psxrecomp_submodule": "Rename psxrecomp-v4 → psxrecomp", + "ensure_psxrecomp_submodule": "Add psxrecomp submodule", + "ensure_recomp_ui_submodule": "Add recomp-ui submodule", + "emit_codegen_setup": "Emit codegen_setup.c / .h", + "emit_version": "Emit VERSION", + "emit_symbols_toml": "Emit symbols.toml stub", + "emit_sync_symbols": "Install tools/sync_symbols.py", + "merge_gitignore": "Merge setup-host .gitignore rules", + "emit_mods_preloaded": "Stub mods/preloaded catalog", + "relocate_boxart": "Relocate boxart → launcher_assets/", + "emit_boxart_stub": "Create launcher_assets stub dir", + "rewrite_cmake_setup_host": "Rewrite CMakeLists.txt (setup-host)", + "emit_packager": "Emit scripts/package_setup_release.sh", + "emit_ci_workflow": "Emit setup-host release.yml", + "annotate_legacy_packaging": "Annotate legacy prebuilt packaging", + "probe_disc_refresh": "Refresh disc identity via probe_disc.py", + "record_framework_pins": "Write framework_pins.txt", +} + + +def build_plan( + root: Path, + options: MigrateOptions | None = None, + report: AuditReport | None = None, +) -> Plan: + root = root.resolve() + options = options or MigrateOptions() + report = report or audit_project(root) + + wanted = set(report.failing_ops()) + + # Option-gated extras + if options.probe_disc and options.disc: + wanted.add("probe_disc_refresh") + if options.record_pins: + wanted.add("record_framework_pins") + if options.merge_gitignore: + # only if audit asked for it — already in failing_ops when needed + pass + if options.relocate_boxart: + pass # already from audit + if not options.rewrite_cmake: + wanted.discard("rewrite_cmake_setup_host") + if not options.enable_ci: + wanted.discard("emit_ci_workflow") + if not options.probe_disc: + wanted.discard("probe_disc_refresh") + if not options.record_pins: + wanted.discard("record_framework_pins") + + if options.only: + wanted = {o for o in wanted if o in options.only} | set(options.only) + if options.skip: + wanted -= set(options.skip) + + # Force-include rewrite when legacy cmake and rewrite enabled + if options.rewrite_cmake and report.layout == LayoutClass.LEGACY_PACKAGING: + wanted.add("rewrite_cmake_setup_host") + wanted.add("emit_codegen_setup") + wanted.add("emit_packager") + if options.enable_ci: + wanted.add("emit_ci_workflow") + + # Always ensure wizard/codegen for setup-host policy when rewriting + if "rewrite_cmake_setup_host" in wanted: + wanted.add("emit_codegen_setup") + + ordered = [op for op in _OP_ORDER if op in wanted] + for op in sorted(wanted): + if op not in ordered: + ordered.append(op) + + steps = [ + PlanStep( + op_id=op, + title=_OP_TITLES.get(op, op), + detail=next( + (c.detail for c in report.checks if c.fix_op == op), + "", + ), + selected=True, + ) + for op in ordered + ] + + return Plan( + root=str(root), + layout=report.layout, + steps=steps, + options=options, + ) diff --git a/tools/new_project_layout/project_studio_gui.py b/tools/new_project_layout/project_studio_gui.py new file mode 100755 index 00000000..905be8b9 --- /dev/null +++ b/tools/new_project_layout/project_studio_gui.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Launch the Project Studio GUI.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from project_studio.cli import main + +if __name__ == "__main__": + raise SystemExit(main(["gui", *sys.argv[1:]]))