diff --git a/psxrecomp_cli.py b/psxrecomp_cli.py index 2d7cd07f..4606b552 100755 --- a/psxrecomp_cli.py +++ b/psxrecomp_cli.py @@ -2,10 +2,12 @@ """Headless disc → generate / rebuild / optional PGO for psxrecomp games. Commands: - verify-disc Hash-check a dump against game.toml [prepare_disc] - generate Prepare disc (if needed) + run psxrecomp-game → generated/ - rebuild cmake --build; if [pgo] enabled, instrument → train → use - pgo-train Standalone PGO train (same as rebuild's PGO phase) + verify-disc Hash-check a dump against game.toml [prepare_disc] + generate Ensure emitters + prepare disc + run psxrecomp-game → generated/ + rebuild cmake --build; if [pgo] enabled, instrument → train → use + pgo-train Standalone PGO train (same as rebuild's PGO phase) + ensure-toolchain Resolve / download cmake-clang-v1 into the shared cache + ensure-emitters Build psxrecomp-game + psxrecomp-bios when missing Exit codes: 0 ok · 1 runtime · 2 usage · 3 disc verify fail """ @@ -337,6 +339,163 @@ def find_psxrecomp_bios(project_root: Path) -> Path: return _find_recompiler_tool(project_root, "psxrecomp-bios", "PSXRECOMP_BIOS") +def find_emitters(project_root: Path) -> tuple[Path, Path]: + """Return (psxrecomp-game, psxrecomp-bios); raises FileNotFoundError if either missing.""" + return find_psxrecomp_game(project_root), find_psxrecomp_bios(project_root) + + +def recompiler_source_dir(project_root: Path) -> Path: + fw = framework_root(project_root) + src = fw / "recompiler" + if not (src / "CMakeLists.txt").is_file(): + raise FileNotFoundError( + f"recompiler sources missing under {src} " + "(need a full psxrecomp checkout / submodule, not a pruned zipball)" + ) + return src + + +def ensure_emitters( + project_root: Path, + progress: ProgressReporter, + *, + download_toolchain: bool = True, + force: bool = False, +) -> tuple[Path, Path]: + """Return emitter binaries, building them with the portable toolchain if needed. + + Matches setup_project.sh / tools/ci/build_emitters.sh so a GitHub fork can + Generate & rebuild without a pre-staged setup-host SDK. + """ + if not force: + try: + game, bios = find_emitters(project_root) + progress.log(f"Emitters ready: {game.name}, {bios.name}") + return game, bios + except FileNotFoundError: + pass + + progress.phase( + "emitters", + pct=0.12, + message="Building psxrecomp-game + psxrecomp-bios…", + ) + if not activate_embedded_toolchain(project_root, progress): + if not download_toolchain or not ensure_toolchain_for_rebuild( + project_root, progress, download=True + ): + if not (_which_tool("cmake") and _which_tool("ninja")): + raise RuntimeError( + "Cannot build emitters: no portable toolchain and no " + "cmake+ninja on PATH. Finish the launcher toolchain step, " + "or run: python3 psxrecomp/psxrecomp_cli.py ensure-toolchain" + ) + progress.log("Using system cmake/ninja on PATH for emitters") + elif not activate_embedded_toolchain(project_root, progress): + raise RuntimeError( + "Toolchain ensure succeeded but bin/ is not usable for emitters" + ) + + src = recompiler_source_dir(project_root) + # Prefer recompiler/build (packaging / RetComM harvest layout); also keep + # project-root build-recompiler if that is where prior binaries lived. + build_dir = src / "build" + try: + existing_game = find_psxrecomp_game(project_root) + # Rebuild into the directory that already holds a partial install. + if existing_game.parent != build_dir: + alt = project_root / "build-recompiler" + if existing_game.parent.resolve() == alt.resolve(): + build_dir = alt + except FileNotFoundError: + pass + + build_dir.mkdir(parents=True, exist_ok=True) + ninja = _which_tool("ninja") + clang_c = _which_tool("clang") + clang_cxx = _which_tool("clang++") + cmake = _which_tool("cmake") + if cmake is None: + raise RuntimeError("cmake not found on PATH after toolchain activate") + + cache_file = build_dir / "CMakeCache.txt" + gen: list[str] = [] + if ninja is not None: + cached_gen = _read_cmake_cache_generator(cache_file) + if cached_gen and cached_gen != "Ninja": + progress.log(f'Replacing emitters cmake generator "{cached_gen}" with Ninja…') + shutil.rmtree(build_dir, ignore_errors=True) + build_dir.mkdir(parents=True, exist_ok=True) + gen = ["-G", "Ninja", f"-DCMAKE_MAKE_PROGRAM={ninja}"] + elif sys.platform == "win32": + progress.log( + "warning: ninja not on PATH — emitters cmake may pick NMake Makefiles" + ) + + cmake_args = [ + str(cmake), + "-S", + str(src), + "-B", + str(build_dir), + *gen, + "-DCMAKE_BUILD_TYPE=Release", + ] + if clang_c is not None: + cmake_args.append(f"-DCMAKE_C_COMPILER={clang_c}") + if clang_cxx is not None: + cmake_args.append(f"-DCMAKE_CXX_COMPILER={clang_cxx}") + # Portable llvm-mingw / cmake-clang-v1: static CRT so staged emitters need + # no MSYS2 GCC DLLs (same as tools/ci/build_emitters.sh). + if clang_c is not None or clang_cxx is not None: + cmake_args.append("-DPSXRECOMP_STATIC_CLI=ON") + + progress.log(" ".join(cmake_args)) + proc = subprocess.run( + cmake_args, cwd=str(project_root), capture_output=True, text=True + ) + for stream in (proc.stdout, proc.stderr): + if stream: + for line in stream.splitlines(): + if line.strip(): + progress.log(line) + if proc.returncode != 0: + raise RuntimeError( + f"emitters cmake configure failed (exit {proc.returncode})" + ) + + jobs = os.environ.get("CMAKE_BUILD_PARALLEL_LEVEL") or str(os.cpu_count() or 4) + build_cmd = [ + str(cmake), + "--build", + str(build_dir), + "--parallel", + jobs, + "--target", + "psxrecomp-game", + "--target", + "psxrecomp-bios", + ] + progress.log(" ".join(build_cmd)) + proc = subprocess.run(build_cmd, capture_output=True, text=True) + for stream in (proc.stdout, proc.stderr): + if stream: + for line in stream.splitlines(): + if line.strip(): + progress.log(line) + if proc.returncode != 0: + raise RuntimeError(f"emitters cmake build failed (exit {proc.returncode})") + + try: + game, bios = find_emitters(project_root) + except FileNotFoundError as exc: + raise RuntimeError( + f"emitters build finished but binaries not found under {build_dir}: {exc}" + ) from exc + progress.log(f"Emitters built: {game} , {bios}") + return game, bios + + def framework_root(project_root: Path) -> Path: """Directory containing bios/*.toml and recompiler/ (usually …/psxrecomp).""" cand = project_root / "psxrecomp" @@ -652,6 +811,17 @@ def cmd_generate(args: argparse.Namespace, progress: ProgressReporter) -> int: # ---- BIOS backends (local only; CI ships none) ---- fw = ensure_framework(project_root, progress=progress) + try: + ensure_emitters( + project_root, + progress, + download_toolchain=not bool(getattr(args, "no_toolchain_download", False)), + force=bool(getattr(args, "force_emitters", False)), + ) + except Exception as exc: # noqa: BLE001 + progress.error(str(exc), code=EXIT_ERROR) + return EXIT_ERROR + bios_arg = (getattr(args, "bios", None) or "").strip() staged_retail = False if bios_arg: @@ -715,7 +885,7 @@ def cmd_generate(args: argparse.Namespace, progress: ProgressReporter) -> int: return EXIT_ERROR try: - game_tool = find_psxrecomp_game(project_root) + game_tool, _bios_tool = find_emitters(project_root) except FileNotFoundError as exc: progress.error(str(exc), code=EXIT_ERROR) return EXIT_ERROR @@ -1259,6 +1429,29 @@ def cmd_ensure_toolchain(args: argparse.Namespace, progress: ProgressReporter) - return EXIT_OK +def cmd_ensure_emitters(args: argparse.Namespace, progress: ProgressReporter) -> int: + """Build psxrecomp-game + psxrecomp-bios when missing (or --force).""" + project_root = ( + Path(args.project_root).expanduser().resolve() + if args.project_root + else Path.cwd().resolve() + ) + try: + ensure_framework(project_root, progress=progress) + game, bios = ensure_emitters( + project_root, + progress, + download_toolchain=not bool(getattr(args, "no_download", False)), + force=bool(getattr(args, "force", False)), + ) + except Exception as exc: # noqa: BLE001 + progress.error(str(exc), code=EXIT_ERROR) + return EXIT_ERROR + progress.phase("done", pct=1.0, message="Emitters ready") + progress.result(ok=True, game=str(game), bios=str(bios)) + return EXIT_OK + + def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="psxrecomp_cli", description=__doc__) sub = ap.add_subparsers(dest="command", required=True) @@ -1292,6 +1485,16 @@ def build_parser() -> argparse.ArgumentParser: g.add_argument("--skip-hash-check", action="store_true") g.add_argument("--force-prepare", action="store_true") g.add_argument("--gen-marker", default="", help="expected marker under out_dir") + g.add_argument( + "--force-emitters", + action="store_true", + help="rebuild psxrecomp-game/bios even if binaries already exist", + ) + g.add_argument( + "--no-toolchain-download", + action="store_true", + help="when emitters are missing, do not download cmake-clang-v1", + ) g.set_defaults(handler=cmd_generate) r = sub.add_parser("rebuild", help="cmake build (+ optional local PGO)") @@ -1391,6 +1594,24 @@ def build_parser() -> argparse.ArgumentParser: ) e.set_defaults(handler=cmd_ensure_toolchain) + em = sub.add_parser( + "ensure-emitters", + help="build psxrecomp-game + psxrecomp-bios when missing", + ) + em.add_argument("--project-root", default="", help="game project root (optional)") + em.add_argument("--json-progress", action="store_true") + em.add_argument( + "--force", + action="store_true", + help="rebuild even if emitter binaries already exist", + ) + em.add_argument( + "--no-download", + action="store_true", + help="do not fetch cmake-clang-v1 when no local pack is found", + ) + em.set_defaults(handler=cmd_ensure_emitters) + p = sub.add_parser("pgo-train", help="force PGO rebuild+train+use") add_common(p) p.add_argument("--build-dir", required=True)