project migration tools

This commit is contained in:
Alex Vanderveen
2026-08-10 20:44:55 -04:00
parent 61279a9d01
commit 2c1942b569
16 changed files with 2581 additions and 0 deletions
+13
View File
@@ -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):
+30
View File
@@ -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
+4
View File
@@ -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
+58
View File
@@ -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/*`.
+23
View File
@@ -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())
@@ -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__",
]
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
@@ -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())
@@ -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,
)
@@ -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(
"<Configure>",
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.")
@@ -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)
@@ -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,
}
@@ -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/<id>/<version>/`.\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())
@@ -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
@@ -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,
)
+16
View File
@@ -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:]]))