mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
project migration tools
This commit is contained in:
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*`.
|
||||
Executable
+23
@@ -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
@@ -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:]]))
|
||||
Reference in New Issue
Block a user