mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
2222 lines
81 KiB
Python
Executable File
2222 lines
81 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Headless disc → generate / rebuild / optional PGO for psxrecomp games.
|
||
|
||
Commands:
|
||
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
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import os
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(ROOT / "tools"))
|
||
from sdk_progress import ProgressReporter # noqa: E402
|
||
from disc_companion import CompanionError, inspect_companion # noqa: E402
|
||
from toolchain_pack import ( # noqa: E402
|
||
ensure_toolchain as _ensure_toolchain_pack,
|
||
resolve_toolchain_bin,
|
||
toolchain_bin_runs,
|
||
)
|
||
|
||
EXIT_OK = 0
|
||
EXIT_ERROR = 1
|
||
EXIT_USAGE = 2
|
||
EXIT_VERIFY = 3
|
||
|
||
|
||
def resolve_embedded_toolchain_bin(project_root: Path) -> Optional[Path]:
|
||
"""Return a usable toolchain bin/ (project, env, or shared cache)."""
|
||
return resolve_toolchain_bin(project_root)
|
||
|
||
|
||
def activate_embedded_toolchain(
|
||
project_root: Path, progress: Optional[ProgressReporter] = None
|
||
) -> bool:
|
||
"""Prepend resolved toolchain bin/ to PATH for cmake/ninja/clang."""
|
||
log = progress.log if progress else None
|
||
bin_dir = resolve_toolchain_bin(project_root)
|
||
if not bin_dir or not toolchain_bin_runs(bin_dir, log=log):
|
||
return False
|
||
from toolchain_pack import activate_toolchain_bin
|
||
|
||
activate_toolchain_bin(bin_dir, log=log)
|
||
return True
|
||
|
||
|
||
def ensure_toolchain_for_rebuild(
|
||
project_root: Path,
|
||
progress: ProgressReporter,
|
||
*,
|
||
from_zip: str = "",
|
||
download: bool = True,
|
||
min_version: str = "",
|
||
) -> bool:
|
||
"""Ensure cmake is available via cache / download / offline zip."""
|
||
try:
|
||
_ensure_toolchain_pack(
|
||
project_root,
|
||
from_zip=Path(from_zip) if from_zip else None,
|
||
download=download and not from_zip,
|
||
min_version=min_version,
|
||
log=progress.log,
|
||
)
|
||
return True
|
||
except Exception as exc: # noqa: BLE001 — surface to progress UI
|
||
progress.log(f"Toolchain ensure: {exc}")
|
||
return False
|
||
|
||
|
||
def prune_after_rebuild(
|
||
project_root: Path,
|
||
build_dir: Path,
|
||
modes: set[str],
|
||
progress: ProgressReporter,
|
||
) -> None:
|
||
"""Free disk after a successful rebuild (wizard / one-shot setup)."""
|
||
if not modes:
|
||
return
|
||
if "toolchain" in modes or "all" in modes:
|
||
tc = project_root / "toolchain"
|
||
if tc.is_dir():
|
||
shutil.rmtree(tc, ignore_errors=True)
|
||
progress.log(f"Pruned {tc}")
|
||
if "build-intermediates" in modes or "all" in modes:
|
||
if build_dir.is_dir():
|
||
for name in ("CMakeFiles", ".ninja_deps", ".ninja_log", "CMakeCache.txt",
|
||
"cmake_install.cmake", "build.ninja", "compile_commands.json"):
|
||
p = build_dir / name
|
||
if p.is_dir():
|
||
shutil.rmtree(p, ignore_errors=True)
|
||
elif p.is_file():
|
||
try:
|
||
p.unlink()
|
||
except OSError:
|
||
pass
|
||
# Drop object/lib digests but keep the launch binary + assets/.
|
||
for p in build_dir.rglob("*"):
|
||
if not p.is_file():
|
||
continue
|
||
if p.suffix in {".o", ".obj", ".a", ".lib", ".pdb", ".ilk", ".exp"}:
|
||
try:
|
||
p.unlink()
|
||
except OSError:
|
||
pass
|
||
progress.log(f"Pruned build intermediates under {build_dir}")
|
||
if "build-tree" in modes:
|
||
# Keep only the executable + assets next to it, then wipe the rest of
|
||
# the build dir by moving keepers aside — used by aggressive cleanup.
|
||
if build_dir.is_dir():
|
||
keep_names = set()
|
||
for p in build_dir.iterdir():
|
||
if p.is_file() and os.access(p, os.X_OK):
|
||
keep_names.add(p.name)
|
||
if p.name.lower().endswith(".exe"):
|
||
keep_names.add(p.name)
|
||
assets = build_dir / "assets"
|
||
staging = build_dir.parent / f".prune-keep-{build_dir.name}"
|
||
if staging.exists():
|
||
shutil.rmtree(staging, ignore_errors=True)
|
||
staging.mkdir(parents=True, exist_ok=True)
|
||
for name in keep_names:
|
||
src = build_dir / name
|
||
if src.is_file():
|
||
shutil.move(str(src), str(staging / name))
|
||
if assets.is_dir():
|
||
shutil.move(str(assets), str(staging / "assets"))
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
staging.rename(build_dir)
|
||
progress.log(f"Pruned build tree to binary+assets under {build_dir}")
|
||
|
||
|
||
def clamp_future_mtimes(
|
||
root: Path,
|
||
*,
|
||
skip: Optional[Path] = None,
|
||
now: Optional[float] = None,
|
||
) -> int:
|
||
"""Clamp mtimes ahead of *now* so Ninja does not infinite-reconfigure.
|
||
|
||
Release zips often preserve CI clocks that are slightly ahead of a user's
|
||
clock (timezone / skew). Ninja then treats every source as newer than
|
||
``build.ninja``, re-runs CMake forever, and fails with
|
||
``manifest 'build.ninja' still dirty after 100 tries``.
|
||
"""
|
||
if not root.is_dir():
|
||
return 0
|
||
stamp = time.time() if now is None else now
|
||
skip_res: Optional[Path] = None
|
||
if skip is not None:
|
||
try:
|
||
skip_res = skip.resolve()
|
||
except OSError:
|
||
skip_res = skip
|
||
n = 0
|
||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||
dpath = Path(dirpath)
|
||
try:
|
||
d_res = dpath.resolve()
|
||
except OSError:
|
||
d_res = dpath
|
||
# Skip the active build tree entirely (outputs are rewritten anyway).
|
||
if skip_res is not None and (
|
||
d_res == skip_res or skip_res in d_res.parents
|
||
):
|
||
dirnames[:] = []
|
||
continue
|
||
# Never descend into VCS metadata; prune the build dir at the parent.
|
||
pruned: list[str] = []
|
||
for x in dirnames:
|
||
if x == ".git":
|
||
continue
|
||
if skip_res is not None:
|
||
try:
|
||
if (dpath / x).resolve() == skip_res:
|
||
continue
|
||
except OSError:
|
||
pass
|
||
pruned.append(x)
|
||
dirnames[:] = pruned
|
||
for name in filenames:
|
||
p = dpath / name
|
||
try:
|
||
mtime = p.stat().st_mtime
|
||
except OSError:
|
||
continue
|
||
if mtime > stamp:
|
||
try:
|
||
os.utime(p, (stamp, stamp), follow_symlinks=False)
|
||
n += 1
|
||
except OSError:
|
||
pass
|
||
return n
|
||
|
||
|
||
def _parse_array_items(inner: str) -> list[Any]:
|
||
items: list[Any] = []
|
||
if not inner.strip():
|
||
return items
|
||
for part in re.split(r",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", inner):
|
||
part = part.strip().rstrip(",").strip()
|
||
if not part:
|
||
continue
|
||
if part.startswith('"') and part.endswith('"'):
|
||
items.append(part[1:-1])
|
||
else:
|
||
try:
|
||
items.append(int(part, 0))
|
||
except ValueError:
|
||
items.append(part)
|
||
return items
|
||
|
||
|
||
def parse_toml_simple(text: str) -> dict[str, dict[str, Any]]:
|
||
sections: dict[str, dict[str, Any]] = {"": {}}
|
||
cur = ""
|
||
array_key: Optional[str] = None
|
||
array_buf: list[str] = []
|
||
for raw in text.splitlines():
|
||
line = raw.split("#", 1)[0].strip()
|
||
if array_key is not None:
|
||
array_buf.append(line)
|
||
joined = " ".join(array_buf)
|
||
if "]" in joined:
|
||
inner = joined.split("]", 1)[0]
|
||
if inner.startswith("["):
|
||
inner = inner[1:]
|
||
sections[cur][array_key] = _parse_array_items(inner)
|
||
array_key = None
|
||
array_buf = []
|
||
continue
|
||
if not line:
|
||
continue
|
||
if line.startswith("[") and line.endswith("]"):
|
||
cur = line[1:-1].strip()
|
||
sections.setdefault(cur, {})
|
||
continue
|
||
if "=" not in line:
|
||
continue
|
||
key, val = line.split("=", 1)
|
||
key, val = key.strip(), val.strip()
|
||
if val.startswith("["):
|
||
if val.endswith("]") and val.count("[") == val.count("]"):
|
||
sections[cur][key] = _parse_array_items(val[1:-1])
|
||
else:
|
||
array_key = key
|
||
array_buf = [val[1:]]
|
||
continue
|
||
if val.startswith('"') and val.endswith('"'):
|
||
sections[cur][key] = val[1:-1]
|
||
elif val.lower() in ("true", "false"):
|
||
sections[cur][key] = val.lower() == "true"
|
||
else:
|
||
try:
|
||
sections[cur][key] = int(val, 0)
|
||
except ValueError:
|
||
sections[cur][key] = val
|
||
return sections
|
||
|
||
|
||
def file_hashes(path: Path) -> tuple[str, str, int]:
|
||
h_md5, h_sha1 = hashlib.md5(), hashlib.sha1()
|
||
size = 0
|
||
with open(path, "rb") as f:
|
||
while True:
|
||
chunk = f.read(1024 * 1024)
|
||
if not chunk:
|
||
break
|
||
size += len(chunk)
|
||
h_md5.update(chunk)
|
||
h_sha1.update(chunk)
|
||
return h_md5.hexdigest(), h_sha1.hexdigest(), size
|
||
|
||
|
||
def resolve_cue_bin(cue_path: Path) -> Path:
|
||
text = cue_path.read_text(encoding="utf-8", errors="replace")
|
||
m = re.search(r'FILE\s+"([^"]+)"\s+BINARY', text, re.I)
|
||
if not m:
|
||
m = re.search(r"FILE\s+(\S+)\s+BINARY", text, re.I)
|
||
if not m:
|
||
raise ValueError(f"no BINARY FILE in cue: {cue_path}")
|
||
cand = Path(m.group(1))
|
||
if not cand.is_absolute():
|
||
cand = cue_path.parent / cand
|
||
if not cand.is_file():
|
||
raise ValueError(f"cue references missing bin: {cand}")
|
||
return cand
|
||
|
||
|
||
def _find_recompiler_tool(project_root: Path, basename: str, env_name: str) -> Path:
|
||
env = os.environ.get(env_name, "").strip()
|
||
if env:
|
||
p = Path(env).expanduser()
|
||
if p.is_file():
|
||
return p.resolve()
|
||
names = [basename, f"{basename}.exe"]
|
||
search_dirs = [
|
||
# The dedicated analyzer tree comes FIRST. ensure_analyzer() builds and
|
||
# source-stamps that one, and an older psxrecomp-analyze left behind in
|
||
# the shared emitters tree would otherwise shadow every rebuild — the
|
||
# tool would be rebuilt correctly and then never used.
|
||
project_root / "psxrecomp" / "recompiler" / "build-analyze",
|
||
ROOT / "recompiler" / "build-analyze",
|
||
project_root / "psxrecomp" / "recompiler" / "build",
|
||
project_root / "psxrecomp" / "recompiler" / "build" / "Release",
|
||
project_root / "build-recompiler",
|
||
ROOT / "recompiler" / "build",
|
||
ROOT / "recompiler" / "build" / "Release",
|
||
]
|
||
for d in search_dirs:
|
||
for name in names:
|
||
c = d / name
|
||
if c.is_file():
|
||
return c.resolve()
|
||
which = shutil.which(basename)
|
||
if which:
|
||
return Path(which).resolve()
|
||
raise FileNotFoundError(
|
||
f"{basename} not found. Build it:\n"
|
||
" cmake -S psxrecomp/recompiler -B psxrecomp/recompiler/build -G Ninja\n"
|
||
f" cmake --build psxrecomp/recompiler/build --target {basename}\n"
|
||
f"Or set {env_name}=/path/to/{basename}"
|
||
)
|
||
|
||
|
||
def find_psxrecomp_game(project_root: Path) -> Path:
|
||
return _find_recompiler_tool(project_root, "psxrecomp-game", "PSXRECOMP_GAME")
|
||
|
||
|
||
def find_psxrecomp_bios(project_root: Path) -> Path:
|
||
return _find_recompiler_tool(project_root, "psxrecomp-bios", "PSXRECOMP_BIOS")
|
||
|
||
|
||
def find_psxrecomp_analyze(project_root: Path) -> Path:
|
||
return _find_recompiler_tool(project_root, "psxrecomp-analyze", "PSXRECOMP_ANALYZE")
|
||
|
||
|
||
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…",
|
||
)
|
||
_build_recompiler_targets(
|
||
project_root,
|
||
progress,
|
||
# psxrecomp-analyze is deliberately NOT built here. It has its own
|
||
# ensure_analyzer()/build tree, and requesting it from the shared
|
||
# emitters build breaks `ensure-emitters` outright on any checkout whose
|
||
# recompiler/CMakeLists.txt predates the target ("ninja: error: unknown
|
||
# target"). The emitters path must keep working on old framework pins.
|
||
("psxrecomp-game", "psxrecomp-bios"),
|
||
download_toolchain=download_toolchain,
|
||
clean=force,
|
||
)
|
||
|
||
try:
|
||
game, bios = find_emitters(project_root)
|
||
except FileNotFoundError as exc:
|
||
raise RuntimeError(
|
||
f"emitters build finished but binaries not found: {exc}"
|
||
) from exc
|
||
progress.log(f"Emitters built: {game} , {bios}")
|
||
return game, bios
|
||
|
||
|
||
# Source files that make up psxrecomp-analyze. Kept in step with the target's
|
||
# source list in recompiler/CMakeLists.txt.
|
||
ANALYZER_SOURCES = (
|
||
"recompiler/src/ps1_exe_parser.cpp",
|
||
"recompiler/src/mips_decoder.cpp",
|
||
"recompiler/src/function_analysis.cpp",
|
||
"recompiler/src/analysis_db.cpp",
|
||
"recompiler/src/bios_call_names.cpp",
|
||
"recompiler/src/analysis_export.cpp",
|
||
"recompiler/src/widescreen_scan.cpp",
|
||
"recompiler/src/main_analyze.cpp",
|
||
"recompiler/include/analysis_db.h",
|
||
"recompiler/src/analysis_export.h",
|
||
"recompiler/include/widescreen_scan.h",
|
||
"runtime/include/ws_cull_detect.h",
|
||
)
|
||
|
||
|
||
def _analyzer_source_hash(project_root: Path) -> str:
|
||
"""Digest of the analyzer's sources, or "" if they cannot be read.
|
||
|
||
The toolchain stamp notices a changed COMPILER; nothing noticed changed
|
||
CODE, so a psxrecomp-analyze built before a feature landed kept being reused
|
||
and rejected the very options the caller had just learned to pass. This is
|
||
the same guard `psxrecomp-game --codegen-hash` provides for the emitters,
|
||
computed here rather than baked in so it needs no CMake support.
|
||
"""
|
||
import hashlib
|
||
|
||
fw = framework_root(project_root)
|
||
h = hashlib.sha256()
|
||
for rel in ANALYZER_SOURCES:
|
||
p = fw / rel
|
||
if not p.is_file():
|
||
return ""
|
||
h.update(rel.encode("utf-8"))
|
||
h.update(p.read_bytes())
|
||
return h.hexdigest()
|
||
|
||
|
||
def _analyzer_stamp_path(tool: Path) -> Path:
|
||
return tool.parent / (tool.name + ".srcstamp")
|
||
|
||
|
||
def ensure_analyzer(
|
||
project_root: Path,
|
||
progress: ProgressReporter,
|
||
*,
|
||
download_toolchain: bool = True,
|
||
) -> Path:
|
||
"""Return psxrecomp-analyze, building ONLY that target when it is missing.
|
||
|
||
Every build tree created before this target existed has game+bios but no
|
||
analyzer, so `analyze` has to be able to add one. It must not do that by
|
||
rebuilding the emitters: they already work, a relink is slow, and on a repo
|
||
whose tree was configured with the portable cmake-clang toolchain against a
|
||
newer system glibc the psxrecomp-game link fails outright on
|
||
``__isoc23_strtoul`` — a pre-existing toolchain mismatch that has nothing to
|
||
do with analysis. Building the one target that is actually missing avoids
|
||
dragging that failure into an unrelated command.
|
||
"""
|
||
src = recompiler_source_dir(project_root)
|
||
dedicated = src / "build-analyze"
|
||
|
||
# If this project already has the dedicated tree, always run the build step
|
||
# rather than trusting the binary's presence. Ninja is a no-op when nothing
|
||
# changed (tens of milliseconds), and it is the only thing that notices an
|
||
# analyzer SOURCE edit — the toolchain stamp catches a changed compiler, not
|
||
# changed code, so a stale binary would otherwise keep producing old-format
|
||
# reports indefinitely.
|
||
want = _analyzer_source_hash(project_root)
|
||
try:
|
||
existing = find_psxrecomp_analyze(project_root)
|
||
stamp = _analyzer_stamp_path(existing)
|
||
have = stamp.read_text(encoding="utf-8").strip() if stamp.is_file() else ""
|
||
if want and have == want:
|
||
return existing
|
||
progress.log(
|
||
"psxrecomp-analyze is stale for the current sources — rebuilding"
|
||
if have
|
||
else "psxrecomp-analyze has no source stamp — rebuilding once"
|
||
)
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
progress.phase("emitters", pct=0.12, message="Building psxrecomp-analyze…")
|
||
_build_recompiler_targets(
|
||
project_root,
|
||
progress,
|
||
("psxrecomp-analyze",),
|
||
download_toolchain=download_toolchain,
|
||
build_dir_override=dedicated,
|
||
# The analyzer needs fmt and nothing else. Configuring its own tree with
|
||
# libchdr and the test suite off keeps `analyze` independent of whatever
|
||
# state the emitters' build directory is in — a half-configured cache, a
|
||
# cold FetchContent needing the network, or a project path containing a
|
||
# colon, all of which fail the shared configure for reasons that have
|
||
# nothing to do with static analysis.
|
||
extra_cmake_args=("-DPSXRECOMP_ENABLE_CHD=OFF", "-DBUILD_TESTING=OFF"),
|
||
)
|
||
try:
|
||
tool = find_psxrecomp_analyze(project_root)
|
||
except FileNotFoundError as exc:
|
||
raise RuntimeError(
|
||
f"psxrecomp-analyze build finished but the binary was not found: {exc}"
|
||
) from exc
|
||
if want:
|
||
try:
|
||
_analyzer_stamp_path(tool).write_text(want, encoding="utf-8")
|
||
except OSError:
|
||
pass
|
||
progress.log(f"Analyzer built: {tool}")
|
||
return tool
|
||
|
||
|
||
def _toolchain_stamp(clang_cxx: Optional[str], cmake_tool: Optional[str]) -> str:
|
||
"""Identity of the toolchain that a build directory's objects were made with.
|
||
|
||
The portable toolchain is addressed through a stable ``latest`` symlink, so
|
||
an upgrade behind it leaves CMAKE_CXX_COMPILER unchanged and CMake's
|
||
compiler-change detection never fires. Ninja then relinks object files
|
||
built by the previous toolchain.
|
||
|
||
That is not theoretical: cmake-clang-v1 v1.0.10 shipped no sysroot and
|
||
compiled against the host's glibc, so `strtoul` became `__isoc23_strtoul`
|
||
(glibc >= 2.38). v1.0.14 added a bundled older sysroot that exports no such
|
||
symbol, and every build tree from before the upgrade failed to link with
|
||
"undefined symbol: __isoc23_strtoul" while nothing looked out of date.
|
||
|
||
Resolving the symlink puts the concrete version in the stamp, so the
|
||
upgrade becomes visible and the tree can be wiped.
|
||
"""
|
||
parts: list[str] = []
|
||
for tool in (clang_cxx, cmake_tool):
|
||
if not tool:
|
||
continue
|
||
real = Path(tool).resolve()
|
||
parts.append(str(real))
|
||
try:
|
||
parts.append(str(real.stat().st_size))
|
||
except OSError:
|
||
pass
|
||
manifest = real.parent.parent / "retcomm-toolchain.json"
|
||
if manifest.is_file():
|
||
try:
|
||
parts.append(manifest.read_text(encoding="utf-8", errors="replace"))
|
||
except OSError:
|
||
pass
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _build_recompiler_targets(
|
||
project_root: Path,
|
||
progress: ProgressReporter,
|
||
targets: tuple[str, ...],
|
||
*,
|
||
download_toolchain: bool = True,
|
||
build_dir_override: Path | None = None,
|
||
extra_cmake_args: tuple[str, ...] = (),
|
||
clean: bool = False,
|
||
) -> Path:
|
||
"""Configure recompiler/ and build `targets`. Returns the build directory."""
|
||
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 / Retro 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
|
||
|
||
if build_dir_override is not None:
|
||
build_dir = build_dir_override
|
||
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
toolchain_bin = resolve_embedded_toolchain_bin(project_root)
|
||
ninja = _tool_in_dir(toolchain_bin, "ninja") or _which_tool("ninja")
|
||
clang_c = _tool_in_dir(toolchain_bin, "clang") or _which_tool("clang")
|
||
clang_cxx = _tool_in_dir(toolchain_bin, "clang++") or _which_tool("clang++")
|
||
cmake = _tool_in_dir(toolchain_bin, "cmake") or _which_tool("cmake")
|
||
if cmake is None:
|
||
raise RuntimeError("cmake not found on PATH after toolchain activate")
|
||
|
||
# Wipe when the caller asked for a clean build, or when the toolchain that
|
||
# produced this tree's objects is not the one about to link them.
|
||
stamp_file = build_dir / ".retcomm-toolchain-stamp"
|
||
stamp = _toolchain_stamp(clang_cxx, cmake)
|
||
if clean:
|
||
progress.log(f"Clean rebuild requested — removing {build_dir}")
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
elif stamp and stamp_file.is_file():
|
||
try:
|
||
previous = stamp_file.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
previous = ""
|
||
if previous and previous != stamp:
|
||
progress.log(
|
||
"Toolchain changed since this build directory was configured — "
|
||
f"removing {build_dir} so objects are rebuilt against it"
|
||
)
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
elif stamp and not stamp_file.is_file() and (build_dir / "CMakeCache.txt").is_file():
|
||
# A tree configured before stamping existed. Its objects may predate the
|
||
# current toolchain with no way to tell, and a mismatch surfaces only as
|
||
# an undefined symbol at link time. Rebuild once, then stamp.
|
||
progress.log(
|
||
f"Unstamped build directory {build_dir} — rebuilding once so its "
|
||
"objects are known to match the active toolchain"
|
||
)
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
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")
|
||
cmake_args.extend(
|
||
_pack_sysroot_cmake_args(clang_c, (*cmake_args, *extra_cmake_args))
|
||
)
|
||
cmake_args.extend(extra_cmake_args)
|
||
|
||
progress.log(" ".join(cmake_args))
|
||
proc = subprocess.run(
|
||
cmake_args, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace"
|
||
)
|
||
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]
|
||
for target in targets:
|
||
build_cmd += ["--target", target]
|
||
progress.log(" ".join(build_cmd))
|
||
proc = subprocess.run(build_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
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"recompiler cmake build failed (exit {proc.returncode}) for "
|
||
+ ", ".join(targets)
|
||
)
|
||
if stamp:
|
||
try:
|
||
stamp_file.write_text(stamp, encoding="utf-8")
|
||
except OSError:
|
||
pass
|
||
return build_dir
|
||
|
||
|
||
def framework_root(project_root: Path) -> Path:
|
||
"""Directory containing bios/*.toml and recompiler/ (usually …/psxrecomp)."""
|
||
cand = project_root / "psxrecomp"
|
||
if (cand / "bios" / "OpenBIOS.toml").is_file() or (
|
||
(cand / "bios").is_dir() and (cand / "recompiler").is_dir()
|
||
):
|
||
if (cand / "bios").is_dir():
|
||
return cand
|
||
if (ROOT / "bios").is_dir() and (ROOT / "recompiler").is_dir():
|
||
return ROOT
|
||
return cand
|
||
|
||
|
||
def _copy_missing(src: Path, dest: Path) -> None:
|
||
if src.is_dir():
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
for child in src.iterdir():
|
||
_copy_missing(child, dest / child.name)
|
||
return
|
||
if not src.is_file():
|
||
return
|
||
if dest.is_file():
|
||
return
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dest)
|
||
|
||
|
||
def ensure_framework(
|
||
project_root: Path, *, progress: Optional[ProgressReporter] = None
|
||
) -> Path:
|
||
"""Ensure project_root/psxrecomp has BIOS profiles + seeds for local generate.
|
||
|
||
GitHub zipballs omit git submodules, so Retro source trees often lack
|
||
psxrecomp/bios. Seed from the SDK pack that ships this CLI (ROOT).
|
||
"""
|
||
fw = project_root / "psxrecomp"
|
||
need_seed = not (fw / "bios" / "OpenBIOS.toml").is_file()
|
||
if need_seed and (ROOT / "bios").is_dir():
|
||
if progress:
|
||
progress.log(
|
||
f"Seeding psxrecomp BIOS profiles from SDK -> {fw}"
|
||
)
|
||
fw.mkdir(parents=True, exist_ok=True)
|
||
_copy_missing(ROOT / "bios", fw / "bios")
|
||
seeds_src = ROOT / "recompiler" / "seeds"
|
||
if seeds_src.is_dir():
|
||
_copy_missing(seeds_src, fw / "recompiler" / "seeds")
|
||
(fw / "recompiler" / "build").mkdir(parents=True, exist_ok=True)
|
||
# psxrecomp-bios walks up from bios/*.toml looking for .gitignore/.git/
|
||
# CMakeLists.txt to find the framework root. Zipball trees lack the
|
||
# submodule .git — plant a marker so rom = "bios/openbios.bin" resolves.
|
||
if (fw / "bios").is_dir() and not any(
|
||
(fw / m).exists() for m in (".gitignore", ".git", "CMakeLists.txt")
|
||
):
|
||
marker = fw / ".gitignore"
|
||
if not marker.is_file():
|
||
marker.write_text(
|
||
"# Retro SDK seed marker (project-root for psxrecomp-bios)\n",
|
||
encoding="utf-8",
|
||
)
|
||
return framework_root(project_root)
|
||
|
||
|
||
def bios_backend_present(fw: Path, stem: str) -> bool:
|
||
dispatch = fw / "generated" / f"{stem}_dispatch.c"
|
||
full = fw / "generated" / f"{stem}_full.c"
|
||
if not dispatch.is_file() or not full.is_file():
|
||
return False
|
||
try:
|
||
text = dispatch.read_text(encoding="utf-8", errors="ignore")
|
||
except OSError:
|
||
return False
|
||
return f"{stem}_psx_bios_backend" in text
|
||
|
||
|
||
def regen_bios_profile(
|
||
project_root: Path,
|
||
profile_rel: str,
|
||
*,
|
||
progress: ProgressReporter,
|
||
) -> None:
|
||
fw = framework_root(project_root)
|
||
profile = fw / profile_rel
|
||
if not profile.is_file():
|
||
raise FileNotFoundError(f"BIOS profile not found: {profile}")
|
||
bios_tool = find_psxrecomp_bios(project_root)
|
||
progress.log(f"regen BIOS via {bios_tool.name} --config {profile_rel}")
|
||
(fw / "generated").mkdir(parents=True, exist_ok=True)
|
||
# Pass a framework-relative config path and cwd=fw so toml paths like
|
||
# rom = "bios/openbios.bin" resolve under the framework root (not bios/bios/).
|
||
proc = subprocess.run(
|
||
[str(bios_tool), "--config", profile_rel],
|
||
cwd=str(fw),
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
)
|
||
for stream in (proc.stdout, proc.stderr):
|
||
if not stream:
|
||
continue
|
||
for line in stream.splitlines():
|
||
if line.strip():
|
||
progress.log(line)
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(
|
||
f"psxrecomp-bios failed for {profile_rel} (exit {proc.returncode})"
|
||
)
|
||
|
||
|
||
def load_sections(config: Path) -> dict[str, dict[str, Any]]:
|
||
return parse_toml_simple(config.read_text(encoding="utf-8"))
|
||
|
||
|
||
def verify_disc_path(
|
||
disc: Path,
|
||
prep: dict[str, Any],
|
||
*,
|
||
skip_hash: bool,
|
||
progress: ProgressReporter,
|
||
) -> dict[str, Any]:
|
||
path = disc.resolve()
|
||
if path.suffix.lower() == ".cue":
|
||
path = resolve_cue_bin(path)
|
||
md5, sha1, size = file_hashes(path)
|
||
try:
|
||
subchannel, _ = inspect_companion(disc, size, sha1)
|
||
except CompanionError as exc:
|
||
raise DiscVerifyError(str(exc)) from exc
|
||
identity = {
|
||
"path": str(path),
|
||
"md5": md5,
|
||
"sha1": sha1,
|
||
"size": size,
|
||
"verified": False,
|
||
"subchannel": subchannel,
|
||
}
|
||
progress.event("disc", **identity)
|
||
sizes = [int(s) for s in (prep.get("known_sizes") or [])]
|
||
md5s = [str(x).lower() for x in (prep.get("known_md5") or [])]
|
||
sha1s = [str(x).lower() for x in (prep.get("known_sha1") or [])]
|
||
if not md5s and not sha1s and not sizes:
|
||
identity["verified"] = True
|
||
return identity
|
||
if skip_hash:
|
||
return identity
|
||
ok = (md5 in md5s) or (sha1 in sha1s)
|
||
if not ok and sizes and size in sizes and not md5s and not sha1s:
|
||
ok = True
|
||
if not ok:
|
||
raise DiscVerifyError(
|
||
f"disc digests not in prepare_disc.known_* "
|
||
f"(size={size} md5={md5} sha1={sha1})"
|
||
)
|
||
identity["verified"] = True
|
||
return identity
|
||
|
||
|
||
class DiscVerifyError(Exception):
|
||
pass
|
||
|
||
|
||
def cmd_verify_disc(args: argparse.Namespace, progress: ProgressReporter) -> int:
|
||
config = Path(args.config).expanduser().resolve()
|
||
if not config.is_file():
|
||
progress.error(f"config not found: {config}", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
project_root = (
|
||
Path(args.project_root).expanduser().resolve()
|
||
if args.project_root
|
||
else config.parent
|
||
)
|
||
activate_embedded_toolchain(project_root, progress)
|
||
disc = Path(args.disc).expanduser()
|
||
if not disc.is_absolute():
|
||
disc = (project_root / disc).resolve()
|
||
else:
|
||
disc = disc.resolve()
|
||
if not disc.is_file():
|
||
progress.error(f"disc not found: {disc}", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
secs = load_sections(config)
|
||
prep = secs.get("prepare_disc") or {}
|
||
progress.phase("verify", pct=0.1, message=f"Verifying {disc.name}")
|
||
try:
|
||
identity = verify_disc_path(
|
||
disc, prep, skip_hash=bool(args.skip_hash_check), progress=progress
|
||
)
|
||
except DiscVerifyError as exc:
|
||
progress.error(str(exc), code=EXIT_VERIFY, verify_failed=True)
|
||
return EXIT_VERIFY
|
||
progress.phase("done", pct=1.0,
|
||
message=f"Main track accepted; subchannel status: {identity['subchannel']['status']}")
|
||
progress.result(ok=True, **identity)
|
||
return EXIT_OK
|
||
|
||
|
||
def run_prepare_disc(
|
||
project_root: Path,
|
||
config: Path,
|
||
source: Path,
|
||
progress: ProgressReporter,
|
||
) -> Path:
|
||
script = ROOT / "tools" / "prepare_disc.py"
|
||
if not script.is_file():
|
||
raise RuntimeError(f"missing {script}")
|
||
progress.phase("prepare_disc", pct=0.15, message="Normalizing disc image...")
|
||
cmd = [
|
||
sys.executable,
|
||
str(script),
|
||
"--config",
|
||
str(config),
|
||
"--project-root",
|
||
str(project_root),
|
||
str(source),
|
||
]
|
||
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
out = (proc.stdout or "") + (proc.stderr or "")
|
||
for line in out.splitlines():
|
||
if line.strip():
|
||
progress.log(line)
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(f"prepare_disc failed (exit {proc.returncode})")
|
||
marker = "RESULT_CUE="
|
||
cue = None
|
||
for line in out.splitlines():
|
||
if line.startswith(marker):
|
||
cue = Path(line[len(marker) :].strip())
|
||
if not cue or not cue.is_file():
|
||
raise RuntimeError("prepare_disc did not print RESULT_CUE=")
|
||
return cue.resolve()
|
||
|
||
|
||
def cmd_generate(args: argparse.Namespace, progress: ProgressReporter) -> int:
|
||
config = Path(args.config).expanduser().resolve()
|
||
if not config.is_file():
|
||
progress.error(f"config not found: {config}", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
project_root = (
|
||
Path(args.project_root).expanduser().resolve()
|
||
if args.project_root
|
||
else config.parent
|
||
)
|
||
activate_embedded_toolchain(project_root, progress)
|
||
secs = load_sections(config)
|
||
game = secs.get("game") or {}
|
||
prep = secs.get("prepare_disc") or {}
|
||
recomp = secs.get("recompiler") or {}
|
||
runtime = secs.get("runtime") or {}
|
||
openbios_allowed = bool(runtime.get("openbios", True))
|
||
|
||
disc_arg = args.disc
|
||
if not disc_arg:
|
||
disc_arg = game.get("disc") or ""
|
||
if not disc_arg:
|
||
progress.error("no --disc and game.disc empty", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
disc = Path(str(disc_arg)).expanduser()
|
||
if not disc.is_absolute():
|
||
disc = (project_root / disc).resolve()
|
||
else:
|
||
disc = disc.resolve()
|
||
|
||
progress.log(f"generate --disc {disc}")
|
||
progress.phase("verify", pct=0.05, message=f"Checking disc {disc.name}")
|
||
try:
|
||
if disc.is_file():
|
||
verify_disc_path(
|
||
disc, prep, skip_hash=bool(args.skip_hash_check), progress=progress
|
||
)
|
||
except DiscVerifyError as exc:
|
||
progress.error(str(exc), code=EXIT_VERIFY, verify_failed=True)
|
||
return EXIT_VERIFY
|
||
|
||
boot = str(prep.get("boot_exe") or Path(str(game.get("exe") or "")).name)
|
||
out_rel = str(prep.get("out_dir") or "prepared_disc")
|
||
boot_path = project_root / out_rel / boot
|
||
working_disc = disc
|
||
# Normalize library dumps when boot EXE missing or source looks like ISO/raw.
|
||
need_prep = (not boot_path.is_file()) or disc.suffix.lower() in (
|
||
".iso",
|
||
".ISO",
|
||
)
|
||
if need_prep or args.force_prepare:
|
||
try:
|
||
working_disc = run_prepare_disc(project_root, config, disc, progress)
|
||
except Exception as exc: # noqa: BLE001
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
if not boot_path.is_file():
|
||
progress.error(f"boot EXE missing after prepare: {boot_path}", code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
# psxrecomp-game reads [game].exe from game.toml only — it does not take
|
||
# the wizard/CLI disc directory. If prepare_disc.out_dir and game.exe
|
||
# disagree (legacy default out_dir=prepared_disc), stage the boot EXE
|
||
# to the path the emitter will open.
|
||
game_exe_rel = str(game.get("exe") or "").strip()
|
||
if game_exe_rel:
|
||
game_exe_path = Path(game_exe_rel).expanduser()
|
||
if not game_exe_path.is_absolute():
|
||
game_exe_path = project_root / game_exe_path
|
||
game_exe_path = game_exe_path.resolve()
|
||
try:
|
||
if game_exe_path != boot_path.resolve():
|
||
game_exe_path.parent.mkdir(parents=True, exist_ok=True)
|
||
if (not game_exe_path.is_file()) or (
|
||
game_exe_path.stat().st_mtime_ns < boot_path.stat().st_mtime_ns
|
||
):
|
||
shutil.copy2(boot_path, game_exe_path)
|
||
progress.log(
|
||
f"Staged boot EXE for emitter: {game_exe_path}"
|
||
)
|
||
except OSError as exc:
|
||
progress.error(
|
||
f"boot EXE ready at {boot_path} but cannot stage to "
|
||
f"game.exe path {game_exe_path}: {exc}",
|
||
code=EXIT_ERROR,
|
||
)
|
||
return EXIT_ERROR
|
||
|
||
# ---- 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:
|
||
bios_path = Path(bios_arg).expanduser()
|
||
if not bios_path.is_absolute():
|
||
bios_path = (project_root / bios_path).resolve()
|
||
else:
|
||
bios_path = bios_path.resolve()
|
||
if not bios_path.is_file():
|
||
progress.error(f"BIOS not found: {bios_path}", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
dest = fw / "bios" / "SCPH1001.BIN"
|
||
progress.phase("bios", pct=0.15, message="Staging retail BIOS dump...")
|
||
progress.log(f"generate --bios {bios_path}")
|
||
try:
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
if dest.resolve() != bios_path.resolve():
|
||
shutil.copy2(bios_path, dest)
|
||
except OSError as exc:
|
||
progress.error(f"failed to stage BIOS: {exc}", code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
try:
|
||
if args.force_bios or not bios_backend_present(fw, "SCPH1001"):
|
||
progress.phase(
|
||
"bios", pct=0.2, message="Generating SCPH1001 BIOS C..."
|
||
)
|
||
regen_bios_profile(
|
||
project_root, "bios/SCPH1001.toml", progress=progress
|
||
)
|
||
else:
|
||
progress.log(
|
||
"SCPH1001 backend already present — skipping bios regen "
|
||
"(pass --force-bios to regenerate)"
|
||
)
|
||
staged_retail = True
|
||
except Exception as exc: # noqa: BLE001
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
elif not openbios_allowed and not bios_backend_present(fw, "SCPH1001"):
|
||
progress.error(
|
||
"This title requires a retail BIOS dump. Pass --bios SCPH1001.BIN "
|
||
"(or pick one in the setup wizard).",
|
||
code=EXIT_USAGE,
|
||
)
|
||
return EXIT_USAGE
|
||
|
||
if openbios_allowed and (
|
||
args.force_bios or not bios_backend_present(fw, "OpenBIOS")
|
||
):
|
||
try:
|
||
progress.phase(
|
||
"bios", pct=0.25, message="Generating OpenBIOS backend C..."
|
||
)
|
||
regen_bios_profile(project_root, "bios/OpenBIOS.toml", progress=progress)
|
||
except Exception as exc: # noqa: BLE001
|
||
# Prefer retail when --bios was given; OpenBIOS is then best-effort.
|
||
if staged_retail:
|
||
progress.log(f"OpenBIOS regen skipped (retail BIOS ok): {exc}")
|
||
else:
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
try:
|
||
game_tool, _bios_tool = find_emitters(project_root)
|
||
except FileNotFoundError as exc:
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
out_dir = project_root / str(recomp.get("out_dir") or "generated")
|
||
progress.phase(
|
||
"emit",
|
||
pct=0.35,
|
||
message=f"Running psxrecomp-game -> {out_dir}",
|
||
)
|
||
cmd = [str(game_tool), "--config", str(config)]
|
||
# Product Generate: collapse reserved-opcode WARNING spam in the wizard log.
|
||
# The emitter also dedupes to once-per-PC unless PSXRECOMP_VERBOSE_RI_WARNINGS=1.
|
||
verbose_ri = os.environ.get("PSXRECOMP_VERBOSE_RI_WARNINGS", "").strip() not in (
|
||
"",
|
||
"0",
|
||
)
|
||
proc = subprocess.run(
|
||
cmd,
|
||
cwd=str(project_root),
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
)
|
||
ri_warn = 0
|
||
for stream in (proc.stdout, proc.stderr):
|
||
if not stream:
|
||
continue
|
||
for line in stream.splitlines():
|
||
if not line.strip():
|
||
continue
|
||
if (
|
||
not verbose_ri
|
||
and "WARNING: reserved opcode" in line
|
||
and "emitting guest RI" in line
|
||
):
|
||
ri_warn += 1
|
||
continue
|
||
progress.log(line)
|
||
if ri_warn:
|
||
progress.log(
|
||
f"Note: suppressed {ri_warn} reserved-opcode WARNING line(s) "
|
||
"(data-as-code sites; RI raises still emitted). "
|
||
"Set PSXRECOMP_VERBOSE_RI_WARNINGS=1 for full detail."
|
||
)
|
||
if proc.returncode != 0:
|
||
progress.error(
|
||
f"psxrecomp-game failed (exit {proc.returncode})", code=EXIT_ERROR
|
||
)
|
||
return EXIT_ERROR
|
||
|
||
marker_name = args.gen_marker or f"{boot}_dispatch.c"
|
||
marker = out_dir / marker_name
|
||
if not marker.is_file():
|
||
# The build (CMakeLists GEN_MARKER) and the setup host
|
||
# (codegen_setup.c gen_marker_relpath) both gate on this exact
|
||
# filename. Accepting a differently named dispatch here used to
|
||
# report success while the next rebuild silently produced another
|
||
# setup host — an endless generate+build loop. Fail loudly instead.
|
||
hits = list(out_dir.glob("*_dispatch.c"))
|
||
if not hits:
|
||
progress.error(f"generate produced no dispatch under {out_dir}", code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
progress.error(
|
||
f"generate produced {', '.join(h.name for h in hits)} under "
|
||
f"{out_dir}, but the project expects {marker_name}. The boot-exe "
|
||
"name in game.toml disagrees with GEN_MARKER in CMakeLists.txt / "
|
||
"codegen_setup.c — the rebuild would link no game code. Fix the "
|
||
"project so all three name the same boot EXE.",
|
||
code=EXIT_ERROR,
|
||
)
|
||
return EXIT_ERROR
|
||
|
||
progress.phase("done", pct=1.0, message="Generate complete")
|
||
progress.result(
|
||
ok=True,
|
||
out_dir=str(out_dir),
|
||
marker=str(marker),
|
||
disc=str(working_disc),
|
||
boot_exe=str(boot_path),
|
||
)
|
||
return EXIT_OK
|
||
|
||
|
||
def _which_tool(name: str) -> Optional[Path]:
|
||
"""Resolve *name* or *name*.exe from PATH (Windows PATHEXT-friendly)."""
|
||
for cand in (name, f"{name}.exe"):
|
||
found = shutil.which(cand)
|
||
if found:
|
||
return Path(found)
|
||
return None
|
||
|
||
|
||
def _tool_in_dir(bin_dir: Optional[Path], name: str) -> Optional[Path]:
|
||
if bin_dir is None:
|
||
return None
|
||
for cand in (name, f"{name}.exe"):
|
||
p = bin_dir / cand
|
||
if p.is_file():
|
||
return p
|
||
return None
|
||
|
||
|
||
def _pack_sysroot_cmake_args(
|
||
clang_path: Optional[Path], existing: list[str] | tuple[str, ...] = ()
|
||
) -> list[str]:
|
||
"""CMake args that wire the portable pack's bundled sysroot (Linux only).
|
||
|
||
clang.cfg already gives the *compiler* ``--sysroot``, but CMake's find_*
|
||
still searches the host, so on a headerless host (stock SteamOS) recomp-ui's
|
||
``find_package(OpenGL REQUIRED)`` fails at configure with "Could NOT find
|
||
OpenGL (missing: OPENGL_INCLUDE_DIR)". CMAKE_SYSROOT re-roots find_* into
|
||
the pack. OpenGL_GL_PREFERENCE=LEGACY is required with it: the sysroot
|
||
ships legacy ``libGL.so`` + ``GL/gl.h`` but no GLVND dev files
|
||
(``glx.h`` / ``libOpenGL.so`` / ``libGLX.so``), so the GLVND path cannot
|
||
complete inside the sysroot and ``OpenGL::GL`` would never be created.
|
||
"""
|
||
if not sys.platform.startswith("linux") or clang_path is None:
|
||
return []
|
||
if any("CMAKE_SYSROOT" in str(a) for a in existing):
|
||
return []
|
||
sysroot = Path(clang_path).resolve().parent.parent / "sysroot"
|
||
if not (sysroot / "usr" / "include").is_dir():
|
||
return []
|
||
return [f"-DCMAKE_SYSROOT={sysroot}", "-DOpenGL_GL_PREFERENCE=LEGACY"]
|
||
|
||
|
||
def _read_cmake_cache_generator(cache_file: Path) -> str:
|
||
if not cache_file.is_file():
|
||
return ""
|
||
try:
|
||
text = cache_file.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
return ""
|
||
for line in text.splitlines():
|
||
if line.startswith("CMAKE_GENERATOR:INTERNAL="):
|
||
return line.split("=", 1)[1].strip()
|
||
return ""
|
||
|
||
|
||
def _cmake_configure(
|
||
project_root: Path,
|
||
build_dir: Path,
|
||
*,
|
||
pgo: str,
|
||
extra: list[str],
|
||
progress: ProgressReporter,
|
||
) -> None:
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
cache_file = build_dir / "CMakeCache.txt"
|
||
toolchain_bin = resolve_embedded_toolchain_bin(project_root)
|
||
ninja = _tool_in_dir(toolchain_bin, "ninja") or _which_tool("ninja")
|
||
clang_c = _tool_in_dir(toolchain_bin, "clang") or _which_tool("clang")
|
||
clang_cxx = _tool_in_dir(toolchain_bin, "clang++") or _which_tool("clang++")
|
||
cmake = _tool_in_dir(toolchain_bin, "cmake") or _which_tool("cmake")
|
||
if cmake is None:
|
||
raise RuntimeError("cmake not found on PATH after toolchain activate")
|
||
|
||
# Prefer Ninja + pack clang when the portable toolchain is on PATH.
|
||
# On Windows, a prior failed configure often leaves CMakeCache stuck on
|
||
# "NMake Makefiles" with no compiler — wipe that and force Ninja.
|
||
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 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 — cmake may pick NMake Makefiles "
|
||
"(ensure cmake-clang-v1 bin is active)"
|
||
)
|
||
|
||
compiler_args: list[str] = []
|
||
if clang_c is not None:
|
||
compiler_args.append(f"-DCMAKE_C_COMPILER={clang_c}")
|
||
if clang_cxx is not None:
|
||
compiler_args.append(f"-DCMAKE_CXX_COMPILER={clang_cxx}")
|
||
compiler_args.extend(_pack_sysroot_cmake_args(clang_c, extra))
|
||
|
||
cmd = [
|
||
str(cmake),
|
||
"-S",
|
||
str(project_root),
|
||
"-B",
|
||
str(build_dir),
|
||
*gen,
|
||
"-DCMAKE_BUILD_TYPE=Release",
|
||
f"-DPSX_PGO={pgo}",
|
||
*compiler_args,
|
||
*extra,
|
||
]
|
||
progress.log(" ".join(cmd))
|
||
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
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"cmake configure failed (exit {proc.returncode})")
|
||
|
||
|
||
|
||
|
||
def _runtime_exe_candidates(build_dir: Path, target: str, exe_basename: str):
|
||
"""Names to try for the built runtime, best evidence first.
|
||
|
||
runtime.cmake writes the OUTPUT_NAME it actually set to
|
||
psxrecomp_exe_name-<target>.txt at configure time. That file is the answer;
|
||
exe_basename is only a guess re-derived from a second copy of the window
|
||
title, and the two drift the moment a game is renamed.
|
||
"""
|
||
published = build_dir / f"psxrecomp_exe_name-{target}.txt"
|
||
names, seen = [], set()
|
||
try:
|
||
name = published.read_text(encoding="utf-8").strip()
|
||
except OSError:
|
||
name = ""
|
||
for candidate in (name, exe_basename):
|
||
if candidate and candidate not in seen:
|
||
seen.add(candidate)
|
||
names.append(candidate)
|
||
return names, name
|
||
|
||
|
||
def _resolve_runtime_exe(build_dir: Path, target: str, exe_basename: str):
|
||
"""(path, None) once found, or (None, message) explaining what went wrong."""
|
||
names, published = _runtime_exe_candidates(build_dir, target, exe_basename)
|
||
for name in names:
|
||
for path in (build_dir / name, build_dir / f"{name}.exe"):
|
||
if path.is_file():
|
||
return path, None
|
||
|
||
# Nothing matched. Distinguish the two very different reasons, because
|
||
# "binary missing" after a clean build reads as a build failure and is not
|
||
# one: a name disagreement means the executable is sitting right there.
|
||
built = sorted(
|
||
p.name
|
||
for p in build_dir.glob("*")
|
||
if p.is_file() and (p.suffix.lower() == ".exe" or os.access(p, os.X_OK))
|
||
)
|
||
if published and published != exe_basename:
|
||
return None, (
|
||
f"the runtime was built as '{published}' but this project expects "
|
||
f"'{exe_basename}'. The build itself succeeded — nothing is wrong "
|
||
f"with the code. CMake derives the name from WINDOW_TITLE in "
|
||
f"CMakeLists.txt; --exe-name / codegen_setup.exe_basename carries a "
|
||
f"second copy of that title which has drifted. Make them agree, or "
|
||
f"pin it by passing EXE_NAME to psxrecomp_add_game_runtime()."
|
||
)
|
||
hint = f" Executables present: {', '.join(built)}." if built else ""
|
||
return None, (
|
||
f"build reported success but no runtime binary was found in "
|
||
f"{build_dir} (looked for {', '.join(names)}).{hint}"
|
||
)
|
||
|
||
|
||
def _cmake_build(
|
||
build_dir: Path,
|
||
target: str,
|
||
progress: ProgressReporter,
|
||
) -> None:
|
||
jobs = os.environ.get("CMAKE_BUILD_PARALLEL_LEVEL") or str(
|
||
os.cpu_count() or 4
|
||
)
|
||
cmd = [
|
||
"cmake",
|
||
"--build",
|
||
str(build_dir),
|
||
"--parallel",
|
||
jobs,
|
||
"--target",
|
||
target,
|
||
]
|
||
progress.log(" ".join(cmd))
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
for stream in (proc.stdout, proc.stderr):
|
||
if stream:
|
||
for line in stream.splitlines():
|
||
if line.strip():
|
||
progress.log(line)
|
||
if proc.returncode != 0:
|
||
err = f"cmake --build failed (exit {proc.returncode})"
|
||
combined = "\n".join(
|
||
s for s in (proc.stdout or "", proc.stderr or "") if s
|
||
)
|
||
if "still dirty after" in combined:
|
||
err += (
|
||
" — Ninja reconfigure loop (often release-zip mtimes ahead of "
|
||
"the system clock). Re-run after the CLI clamps future mtimes, "
|
||
"or touch the project tree / fix the clock."
|
||
)
|
||
raise RuntimeError(err)
|
||
|
||
|
||
PGO_DEBUG_PORT = 45231
|
||
|
||
|
||
def _soft_stop(pid: int, timeout: int = 30) -> None:
|
||
try:
|
||
os.kill(pid, 15) # SIGTERM
|
||
except (ProcessLookupError, PermissionError):
|
||
return
|
||
for _ in range(timeout):
|
||
try:
|
||
os.kill(pid, 0)
|
||
except (ProcessLookupError, PermissionError):
|
||
return
|
||
time.sleep(1)
|
||
try:
|
||
os.kill(pid, 9)
|
||
except (ProcessLookupError, PermissionError):
|
||
pass
|
||
|
||
|
||
def _debug_quit(pid: int, port: int, *, exit_timeout: int = 15) -> bool:
|
||
"""Request a clean exit and confirm the training process has stopped."""
|
||
try:
|
||
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
||
sock.sendall(b'{"id":1,"cmd":"quit"}\n')
|
||
try:
|
||
sock.settimeout(2)
|
||
sock.recv(256)
|
||
except OSError:
|
||
pass
|
||
except OSError:
|
||
return False
|
||
for _ in range(exit_timeout):
|
||
try:
|
||
os.kill(pid, 0)
|
||
except (ProcessLookupError, PermissionError):
|
||
return True
|
||
time.sleep(1)
|
||
return False
|
||
|
||
|
||
def _pgo_train_warning(*, hide_video: bool) -> str:
|
||
if hide_video:
|
||
return (
|
||
"WARNING: PGO training is running (no video window). Do not cancel "
|
||
"or kill this process until training finishes — interrupting may "
|
||
"corrupt profiles and force a restart of this step."
|
||
)
|
||
return (
|
||
"WARNING: PGO training is running. Do not close the game window or "
|
||
"interact with the application until training finishes — closing or "
|
||
"clicking may corrupt profiles and force a restart of this step."
|
||
)
|
||
|
||
|
||
def run_pgo_train(
|
||
project_root: Path,
|
||
build_dir: Path,
|
||
*,
|
||
exe_basename: str,
|
||
target: str,
|
||
disc: Path,
|
||
config: Path,
|
||
train_secs: int,
|
||
train_runs: int,
|
||
mute_host_audio: bool,
|
||
hide_video: bool,
|
||
progress: ProgressReporter,
|
||
) -> None:
|
||
exe, exe_err = _resolve_runtime_exe(build_dir, target, exe_basename)
|
||
if exe is None:
|
||
raise RuntimeError(f"PGO training cannot start: {exe_err}")
|
||
|
||
pgo_dir = build_dir / "pgo"
|
||
if pgo_dir.exists():
|
||
shutil.rmtree(pgo_dir)
|
||
pgo_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
env = os.environ.copy()
|
||
env.setdefault("PSX_BIOS_HLE", "0")
|
||
env["LLVM_PROFILE_FILE"] = str(pgo_dir / "bpe-%p.profraw")
|
||
# Discard SDL output; SPU/CD still advance (profiles stay valid).
|
||
if mute_host_audio:
|
||
env["PSX_HOST_MUTE"] = "1"
|
||
else:
|
||
env.pop("PSX_HOST_MUTE", None)
|
||
# No on-screen FMV (photosensitive / abrasive content). Guest MDEC/GPU
|
||
# still run; host present is skipped. Matches prior CI train shape.
|
||
if hide_video:
|
||
env["PSX_HEADLESS"] = "1"
|
||
env.setdefault("SDL_VIDEODRIVER", "dummy")
|
||
else:
|
||
env.pop("PSX_HEADLESS", None)
|
||
|
||
warning = _pgo_train_warning(hide_video=hide_video)
|
||
progress.phase("pgo_train", pct=0.35, message=warning)
|
||
# Always surface on the console even when JSONL owns stdout.
|
||
print(f"\n*** {warning} ***\n", file=sys.stderr, flush=True)
|
||
if mute_host_audio:
|
||
progress.log("Host audio muted for train (PSX_HOST_MUTE=1; SPU still runs).")
|
||
else:
|
||
progress.log("Host audio unmuted for train (speakers will play).")
|
||
if hide_video:
|
||
progress.log(
|
||
"Video hidden for train (--headless / PSX_HEADLESS=1; "
|
||
"guest FMV decode still runs, nothing shown on screen)."
|
||
)
|
||
else:
|
||
progress.log("Video window visible for train (host present paths included).")
|
||
|
||
for run in range(1, train_runs + 1):
|
||
progress.phase(
|
||
"pgo_train",
|
||
pct=0.4 + 0.4 * (run - 1) / max(train_runs, 1),
|
||
message=(
|
||
f"WARNING: PGO train run {run}/{train_runs} ({train_secs}s) — "
|
||
+ (
|
||
"Do not cancel this process."
|
||
if hide_video
|
||
else "Do not close or interact with the application."
|
||
)
|
||
),
|
||
)
|
||
cmd = [
|
||
str(exe),
|
||
"--no-launcher",
|
||
"--game",
|
||
str(config),
|
||
"--disc",
|
||
str(disc),
|
||
]
|
||
if hide_video:
|
||
cmd.append("--headless")
|
||
cmd.extend(["--debug-port", str(PGO_DEBUG_PORT)])
|
||
proc = subprocess.Popen(
|
||
cmd,
|
||
cwd=str(project_root),
|
||
env=env,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
time.sleep(train_secs)
|
||
if proc.poll() is None:
|
||
if not _debug_quit(proc.pid, PGO_DEBUG_PORT):
|
||
progress.log("PGO train debug quit unavailable; using forced stop.")
|
||
_soft_stop(proc.pid)
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
_soft_stop(proc.pid)
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
|
||
n_gcda = len(list(build_dir.rglob("*.gcda")))
|
||
n_raw = len(list(pgo_dir.glob("*.profraw")))
|
||
progress.log(f"PGO profiles: {n_gcda} .gcda, {n_raw} .profraw")
|
||
if n_raw >= 1:
|
||
merge = shutil.which("llvm-profdata")
|
||
if not merge:
|
||
# macOS xcrun
|
||
try:
|
||
r = subprocess.run(
|
||
["xcrun", "--find", "llvm-profdata"],
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
check=False,
|
||
)
|
||
if r.returncode == 0 and r.stdout.strip():
|
||
merge = "xcrun"
|
||
except OSError:
|
||
pass
|
||
raws = list(pgo_dir.glob("*.profraw"))
|
||
out = pgo_dir / "default.profdata"
|
||
if merge == "xcrun":
|
||
cmd = ["xcrun", "llvm-profdata", "merge", "-sparse", *[str(p) for p in raws], "-o", str(out)]
|
||
elif merge:
|
||
cmd = [merge, "merge", "-sparse", *[str(p) for p in raws], "-o", str(out)]
|
||
else:
|
||
raise RuntimeError("llvm-profdata not found (Clang PGO merge)")
|
||
subprocess.run(cmd, check=True)
|
||
if n_gcda < 1 and not (pgo_dir / "default.profdata").is_file():
|
||
raise RuntimeError("no PGO profiles written — train did not flush")
|
||
|
||
|
||
def cmd_rebuild(args: argparse.Namespace, progress: ProgressReporter) -> int:
|
||
config = Path(args.config).expanduser().resolve()
|
||
if not config.is_file():
|
||
progress.error(f"config not found: {config}", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
project_root = (
|
||
Path(args.project_root).expanduser().resolve()
|
||
if args.project_root
|
||
else config.parent
|
||
)
|
||
build_dir = Path(args.build_dir).expanduser()
|
||
if not build_dir.is_absolute():
|
||
build_dir = (project_root / build_dir).resolve()
|
||
else:
|
||
build_dir = build_dir.resolve()
|
||
target = args.target or "psx-runtime"
|
||
exe_basename = args.exe_basename or "psx-runtime"
|
||
|
||
secs = load_sections(config)
|
||
pgo = secs.get("pgo") or {}
|
||
# Opt-in only: requires game.toml [pgo] enabled = true (or --force-pgo).
|
||
pgo_enabled = bool(pgo.get("enabled", False)) and not args.no_pgo
|
||
if args.force_pgo:
|
||
pgo_enabled = True
|
||
|
||
game = secs.get("game") or {}
|
||
prep = secs.get("prepare_disc") or {}
|
||
if args.disc:
|
||
disc = Path(args.disc).expanduser()
|
||
if not disc.is_absolute():
|
||
disc = (project_root / disc).resolve()
|
||
else:
|
||
disc = disc.resolve()
|
||
else:
|
||
cue_name = prep.get("cue_name")
|
||
if cue_name:
|
||
disc = (
|
||
project_root / str(prep.get("out_dir") or "bpe") / str(cue_name)
|
||
).resolve()
|
||
elif game.get("disc"):
|
||
disc = (project_root / str(game["disc"])).resolve()
|
||
if disc.suffix.lower() == ".bin":
|
||
cue = disc.with_suffix(".cue")
|
||
if cue.is_file():
|
||
disc = cue
|
||
else:
|
||
disc = None
|
||
|
||
zip_arg = getattr(args, "toolchain_zip", "") or ""
|
||
no_dl = bool(getattr(args, "no_toolchain_download", False))
|
||
if zip_arg:
|
||
if not ensure_toolchain_for_rebuild(
|
||
project_root, progress, from_zip=zip_arg, download=False
|
||
):
|
||
progress.error(
|
||
f"Failed to install toolchain from zip: {zip_arg}",
|
||
code=EXIT_ERROR,
|
||
)
|
||
return EXIT_ERROR
|
||
elif not activate_embedded_toolchain(project_root, progress):
|
||
if no_dl or not ensure_toolchain_for_rebuild(
|
||
project_root, progress, download=True
|
||
):
|
||
if not (shutil.which("cmake") or shutil.which("cmake.exe")):
|
||
progress.error(
|
||
"No portable toolchain and no cmake on PATH. "
|
||
"Pass --toolchain-zip PATH, allow download, "
|
||
"or set PSXRECOMP_TOOLCHAIN_DIR / RETCOMM_TOOLCHAIN_DIR.",
|
||
code=EXIT_ERROR,
|
||
)
|
||
return EXIT_ERROR
|
||
progress.log("Using system cmake on PATH")
|
||
|
||
# Full playable link after local generate (not the CI setup-host shape).
|
||
# The legacy BPE_ alias in runtime.cmake only maps ON, so clear the
|
||
# canonical name too; REQUIRE_GAME_C turns a marker-name skew into a
|
||
# loud configure failure instead of a silently rebuilt setup host that
|
||
# reopens the wizard forever. --cmake-extra comes last so a deliberate
|
||
# override (e.g. -DPSXRECOMP_REQUIRE_GAME_C=OFF) still wins.
|
||
cmake_extra = [
|
||
"-DBPE_FORCE_SETUP_HOST=OFF",
|
||
"-DPSXRECOMP_FORCE_SETUP_HOST=OFF",
|
||
"-DPSXRECOMP_ALLOW_NO_BIOS=OFF",
|
||
"-DPSXRECOMP_REQUIRE_GAME_C=ON",
|
||
]
|
||
if args.cmake_extra:
|
||
cmake_extra.extend(args.cmake_extra)
|
||
|
||
clamped = clamp_future_mtimes(project_root, skip=build_dir)
|
||
if clamped:
|
||
progress.log(
|
||
f"Clamped {clamped} future mtime(s) under {project_root} "
|
||
"(avoids Ninja dirty-manifest loop from release-zip clocks)."
|
||
)
|
||
|
||
# mute_host_audio / hide_video: default ON; game.toml / CLI can disable.
|
||
mute_host = True
|
||
if "mute_host_audio" in pgo:
|
||
mute_host = bool(pgo.get("mute_host_audio"))
|
||
if getattr(args, "pgo_audio", False):
|
||
mute_host = False
|
||
if getattr(args, "pgo_mute", False):
|
||
mute_host = True
|
||
|
||
hide_video = True
|
||
if "hide_video" in pgo:
|
||
hide_video = bool(pgo.get("hide_video"))
|
||
if getattr(args, "pgo_video", False):
|
||
hide_video = False
|
||
if getattr(args, "pgo_hide_video", False):
|
||
hide_video = True
|
||
|
||
try:
|
||
if not pgo_enabled:
|
||
progress.phase("build", pct=0.2, message="cmake Release build...")
|
||
_cmake_configure(
|
||
project_root, build_dir, pgo="", extra=cmake_extra + ["-DPSX_DEBUG_TOOLS=OFF"], progress=progress
|
||
)
|
||
_cmake_build(build_dir, target, progress)
|
||
else:
|
||
# Framework defaults (60×2); game.toml / CLI may lengthen for hard titles.
|
||
train_secs = int(getattr(args, "train_secs", 0) or 0)
|
||
if train_secs <= 0:
|
||
train_secs = int(pgo.get("train_secs") or 60)
|
||
train_runs = int(getattr(args, "train_runs", 0) or 0)
|
||
if train_runs <= 0:
|
||
train_runs = int(pgo.get("train_runs") or 2)
|
||
progress.phase(
|
||
"pgo_instrument",
|
||
pct=0.1,
|
||
message="PGO instrumented build...",
|
||
)
|
||
_cmake_configure(
|
||
project_root,
|
||
build_dir,
|
||
pgo="generate",
|
||
extra=cmake_extra + ["-DPSX_DEBUG_TOOLS=ON"],
|
||
progress=progress,
|
||
)
|
||
_cmake_build(build_dir, target, progress)
|
||
if not disc or not Path(disc).is_file():
|
||
raise RuntimeError(
|
||
f"PGO train needs a playable disc (got {disc!s}). "
|
||
"Pass --disc or set game.disc / prepare_disc.cue_name."
|
||
)
|
||
run_pgo_train(
|
||
project_root,
|
||
build_dir,
|
||
exe_basename=exe_basename,
|
||
target=target,
|
||
disc=Path(disc),
|
||
config=config,
|
||
train_secs=train_secs,
|
||
train_runs=train_runs,
|
||
mute_host_audio=mute_host,
|
||
hide_video=hide_video,
|
||
progress=progress,
|
||
)
|
||
progress.phase("pgo_use", pct=0.85, message="PGO optimized rebuild...")
|
||
_cmake_configure(
|
||
project_root,
|
||
build_dir,
|
||
pgo="use",
|
||
extra=cmake_extra + ["-DPSX_DEBUG_TOOLS=OFF"],
|
||
progress=progress,
|
||
)
|
||
_cmake_build(build_dir, target, progress)
|
||
except Exception as exc: # noqa: BLE001
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
exe, exe_err = _resolve_runtime_exe(build_dir, target, exe_basename)
|
||
if exe is None:
|
||
progress.error(exe_err, code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
prune_raw = (getattr(args, "prune_after", None) or "").strip()
|
||
if prune_raw:
|
||
modes = {m.strip() for m in prune_raw.split(",") if m.strip()}
|
||
progress.phase("prune", pct=0.97, message="Pruning toolchain / build bulk...")
|
||
prune_after_rebuild(project_root, build_dir, modes, progress)
|
||
|
||
progress.phase("done", pct=1.0, message="Rebuild complete")
|
||
progress.result(ok=True, exe=str(exe), pgo=pgo_enabled)
|
||
return EXIT_OK
|
||
|
||
|
||
def cmd_pgo_train(args: argparse.Namespace, progress: ProgressReporter) -> int:
|
||
args.force_pgo = True
|
||
args.no_pgo = False
|
||
return cmd_rebuild(args, progress)
|
||
|
||
|
||
def cmd_ensure_toolchain(args: argparse.Namespace, progress: ProgressReporter) -> int:
|
||
"""Resolve / download / unpack cmake-clang-v1 into the shared Retro cache."""
|
||
project_root = (
|
||
Path(args.project_root).expanduser().resolve()
|
||
if args.project_root
|
||
else Path.cwd().resolve()
|
||
)
|
||
zip_arg = (args.from_zip or "").strip()
|
||
# Default: reuse cache/env, else download unless --no-download.
|
||
want_download = (not zip_arg) and not bool(getattr(args, "no_download", False))
|
||
if getattr(args, "download", False):
|
||
want_download = not zip_arg
|
||
min_version = (getattr(args, "min_version", None) or "").strip()
|
||
try:
|
||
bin_dir = _ensure_toolchain_pack(
|
||
project_root,
|
||
from_zip=Path(zip_arg) if zip_arg else None,
|
||
download=want_download,
|
||
min_version=min_version,
|
||
log=progress.log,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
progress.phase("done", pct=1.0, message=f"Toolchain ready: {bin_dir}")
|
||
progress.result(ok=True, toolchain_bin=str(bin_dir))
|
||
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 cmd_analyze(args: argparse.Namespace, progress: ProgressReporter) -> int:
|
||
"""Static function discovery over the project's boot EXE.
|
||
|
||
Distinct from `generate`: this produces knowledge *about* the executable
|
||
(function boundaries, call graph, recovered jump tables, inferred
|
||
signatures) rather than C for the runtime, and it never consults the
|
||
runtime or any capture set to do it.
|
||
"""
|
||
project_root = (
|
||
Path(args.project_root).expanduser().resolve()
|
||
if args.project_root
|
||
else Path.cwd().resolve()
|
||
)
|
||
config = Path(args.config).expanduser()
|
||
if not config.is_absolute():
|
||
config = (project_root / config).resolve()
|
||
|
||
exe = str(getattr(args, "exe", "") or "").strip()
|
||
seeds = ""
|
||
if not exe:
|
||
if not config.is_file():
|
||
progress.error(
|
||
f"no --exe given and config not found: {config}", code=EXIT_USAGE
|
||
)
|
||
return EXIT_USAGE
|
||
secs = load_sections(config)
|
||
exe = str((secs.get("game") or {}).get("exe") or "").strip()
|
||
seeds = str((secs.get("recompiler") or {}).get("seeds") or "").strip()
|
||
if not exe:
|
||
progress.error(f"[game].exe not set in {config}", code=EXIT_USAGE)
|
||
return EXIT_USAGE
|
||
|
||
exe_path = Path(exe).expanduser()
|
||
if not exe_path.is_absolute():
|
||
exe_path = (project_root / exe_path).resolve()
|
||
if not exe_path.is_file():
|
||
progress.error(
|
||
f"boot EXE not found: {exe_path} (run `prepare-disc` first?)",
|
||
code=EXIT_USAGE,
|
||
)
|
||
return EXIT_USAGE
|
||
|
||
activate_embedded_toolchain(project_root, progress)
|
||
# Always go through ensure_analyzer: it owns both the "is it there" and the
|
||
# "is it current" questions. Calling find_psxrecomp_analyze() here first
|
||
# made the source-stamp check unreachable whenever a binary already existed,
|
||
# which is exactly the case it was written for.
|
||
try:
|
||
tool = ensure_analyzer(project_root, progress)
|
||
except Exception as exc: # noqa: BLE001
|
||
progress.error(str(exc), code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
out_dir = Path(args.out).expanduser() if args.out else (project_root / "analysis")
|
||
if not out_dir.is_absolute():
|
||
out_dir = (project_root / out_dir).resolve()
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
symbols = Path(args.symbols).expanduser() if args.symbols else (
|
||
project_root / "symbols.toml"
|
||
)
|
||
if not symbols.is_absolute():
|
||
symbols = (project_root / symbols).resolve()
|
||
|
||
cmd = [
|
||
str(tool),
|
||
str(exe_path),
|
||
"--out",
|
||
str(out_dir),
|
||
"--emit-tsv",
|
||
str(out_dir / "functions.tsv"),
|
||
"--top",
|
||
str(int(getattr(args, "top", 15) or 15)),
|
||
]
|
||
if symbols.is_file():
|
||
cmd += ["--symbols", str(symbols)]
|
||
if getattr(args, "no_refs", False):
|
||
cmd.append("--no-refs")
|
||
if getattr(args, "exact", False):
|
||
cmd.append("--exact")
|
||
seeds_arg = str(getattr(args, "seeds", "") or "").strip() or seeds
|
||
if seeds_arg:
|
||
seeds_path = Path(seeds_arg).expanduser()
|
||
if not seeds_path.is_absolute():
|
||
seeds_path = (project_root / seeds_path).resolve()
|
||
if seeds_path.is_file():
|
||
cmd += ["--seeds", str(seeds_path)]
|
||
if getattr(args, "emit_symbols", False):
|
||
cmd += ["--symbols", str(symbols)] if not symbols.is_file() else []
|
||
cmd += [
|
||
"--emit-symbols",
|
||
"--min-confidence",
|
||
str(getattr(args, "min_confidence", "high") or "high"),
|
||
]
|
||
if getattr(args, "emit_ghidra", False):
|
||
cmd += ["--emit-ghidra", str(out_dir / "psxrecomp_import.py")]
|
||
if getattr(args, "emit_symbol_addrs", False):
|
||
cmd += ["--emit-symbol-addrs", str(out_dir / "symbol_addrs.txt")]
|
||
if getattr(args, "scan_widescreen", False):
|
||
cmd += [
|
||
"--scan-widescreen",
|
||
"--emit-ws-sites",
|
||
str(out_dir / "widescreen_sites.toml"),
|
||
]
|
||
prev = out_dir / "functions.prev.tsv"
|
||
if getattr(args, "diff", False) and prev.is_file():
|
||
cmd += ["--diff", str(prev)]
|
||
|
||
# Keep the last run so the next one can diff against it without the caller
|
||
# having to manage snapshots.
|
||
cur = out_dir / "functions.tsv"
|
||
if cur.is_file():
|
||
shutil.copy2(cur, prev)
|
||
|
||
progress.phase("analyze", pct=0.3, message=f"Analyzing {exe_path.name}…")
|
||
progress.log(" ".join(cmd))
|
||
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
for stream in (proc.stdout, proc.stderr):
|
||
if stream:
|
||
for line in stream.splitlines():
|
||
if line.strip():
|
||
progress.log(line)
|
||
if proc.returncode != 0:
|
||
progress.error(
|
||
f"psxrecomp-analyze failed (exit {proc.returncode})", code=EXIT_ERROR
|
||
)
|
||
return EXIT_ERROR
|
||
|
||
progress.phase("done", pct=1.0, message=f"Analysis written to {out_dir}")
|
||
progress.result(ok=True, out_dir=str(out_dir), exe=str(exe_path))
|
||
return EXIT_OK
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
ap = argparse.ArgumentParser(prog="psxrecomp_cli", description=__doc__)
|
||
sub = ap.add_subparsers(dest="command", required=True)
|
||
|
||
def add_common(p: argparse.ArgumentParser) -> None:
|
||
p.add_argument("--config", default="game.toml", help="game.toml path")
|
||
p.add_argument("--project-root", default="", help="game project root")
|
||
p.add_argument("--json-progress", action="store_true")
|
||
|
||
v = sub.add_parser("verify-disc", help="verify disc digests")
|
||
add_common(v)
|
||
v.add_argument("--disc", required=True)
|
||
v.add_argument("--skip-hash-check", action="store_true")
|
||
v.set_defaults(handler=cmd_verify_disc)
|
||
|
||
g = sub.add_parser(
|
||
"generate", help="regen BIOS backends + prepare disc + generate game C"
|
||
)
|
||
add_common(g)
|
||
g.add_argument("--disc", default="", help="source dump or working cue/bin")
|
||
g.add_argument(
|
||
"--bios",
|
||
default="",
|
||
help="optional retail BIOS dump (staged as bios/SCPH1001.BIN + regen)",
|
||
)
|
||
g.add_argument(
|
||
"--force-bios",
|
||
action="store_true",
|
||
help="regenerate OpenBIOS even if generated/ backends already exist",
|
||
)
|
||
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)")
|
||
add_common(r)
|
||
r.add_argument("--build-dir", required=True)
|
||
r.add_argument("--target", default="psx-runtime")
|
||
r.add_argument("--exe-basename", default="")
|
||
r.add_argument("--disc", default="", help="disc for PGO train")
|
||
r.add_argument(
|
||
"--no-pgo",
|
||
action="store_true",
|
||
help="skip PGO even if game.toml [pgo] enabled=true",
|
||
)
|
||
r.add_argument(
|
||
"--force-pgo",
|
||
action="store_true",
|
||
help="run PGO even without [pgo] enabled=true",
|
||
)
|
||
r.add_argument(
|
||
"--train-secs",
|
||
type=int,
|
||
default=0,
|
||
help="override [pgo] train_secs (default framework 60)",
|
||
)
|
||
r.add_argument(
|
||
"--train-runs",
|
||
type=int,
|
||
default=0,
|
||
help="override [pgo] train_runs (default framework 2)",
|
||
)
|
||
r.add_argument(
|
||
"--pgo-mute",
|
||
action="store_true",
|
||
help="force mute host speakers during train (default)",
|
||
)
|
||
r.add_argument(
|
||
"--pgo-audio",
|
||
action="store_true",
|
||
help="allow host speakers during train (overrides mute default)",
|
||
)
|
||
r.add_argument(
|
||
"--pgo-hide-video",
|
||
action="store_true",
|
||
help="force headless train with no on-screen video (default)",
|
||
)
|
||
r.add_argument(
|
||
"--pgo-video",
|
||
action="store_true",
|
||
help="show a game window during train (includes host present paths)",
|
||
)
|
||
r.add_argument(
|
||
"--prune-after",
|
||
default="",
|
||
help=(
|
||
"comma list after success: toolchain, build-intermediates, "
|
||
"build-tree, all (frees disk for one-shot wizard installs)"
|
||
),
|
||
)
|
||
r.add_argument(
|
||
"--toolchain-zip",
|
||
default="",
|
||
help="offline cmake-clang-v1-*.zip to install into the shared cache",
|
||
)
|
||
r.add_argument(
|
||
"--no-toolchain-download",
|
||
action="store_true",
|
||
help="do not fetch cmake-clang-v1 when no local pack is found",
|
||
)
|
||
r.add_argument("--cmake-extra", action="append", default=[])
|
||
r.set_defaults(handler=cmd_rebuild)
|
||
|
||
e = sub.add_parser(
|
||
"ensure-toolchain",
|
||
help="download or unpack cmake-clang-v1 into the shared cache",
|
||
)
|
||
e.add_argument("--project-root", default="", help="game project root (optional)")
|
||
e.add_argument("--json-progress", action="store_true")
|
||
e.add_argument(
|
||
"--from-zip",
|
||
default="",
|
||
help="install from a local cmake-clang-v1-*.zip (offline)",
|
||
)
|
||
e.add_argument(
|
||
"--download",
|
||
action="store_true",
|
||
help="force download even if a pack is already cached",
|
||
)
|
||
e.add_argument(
|
||
"--no-download",
|
||
action="store_true",
|
||
help="only resolve env/cache/project toolchain/ (no network)",
|
||
)
|
||
e.add_argument(
|
||
"--min-version",
|
||
default="",
|
||
help="require retcomm-toolchain.json version >= this (semver)",
|
||
)
|
||
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)
|
||
|
||
an = sub.add_parser(
|
||
"analyze",
|
||
help="static function discovery over the boot EXE (no runtime involved)",
|
||
)
|
||
an.add_argument("--project-root", default="", help="game project root")
|
||
an.add_argument("--config", default="game.toml", help="game.toml path")
|
||
an.add_argument("--exe", default="", help="override the boot EXE path")
|
||
an.add_argument("--out", default="", help="output dir (default: <root>/analysis)")
|
||
an.add_argument("--symbols", default="", help="symbols.toml (default: <root>/symbols.toml)")
|
||
an.add_argument("--seeds", default="", help="seed address file (default: [recompiler].seeds)")
|
||
an.add_argument("--top", default="15", help="rows in the stdout top lists")
|
||
an.add_argument("--exact", action="store_true", help="reachability-only partition")
|
||
an.add_argument("--no-refs", action="store_true", help="skip refs.json")
|
||
an.add_argument("--diff", action="store_true", help="diff against the previous run")
|
||
an.add_argument(
|
||
"--emit-symbols",
|
||
action="store_true",
|
||
help="merge discovered functions into symbols.toml (existing names win)",
|
||
)
|
||
an.add_argument("--min-confidence", default="high", help="gate for --emit-symbols")
|
||
an.add_argument("--emit-ghidra", action="store_true", help="write a Ghidra import script")
|
||
an.add_argument(
|
||
"--emit-symbol-addrs", action="store_true", help="write a decomp symbol map"
|
||
)
|
||
an.add_argument(
|
||
"--scan-widescreen",
|
||
action="store_true",
|
||
help="also scan for [widescreen.cull] site candidates",
|
||
)
|
||
an.add_argument("--json-progress", action="store_true")
|
||
an.set_defaults(handler=cmd_analyze)
|
||
|
||
p = sub.add_parser("pgo-train", help="force PGO rebuild+train+use")
|
||
add_common(p)
|
||
p.add_argument("--build-dir", required=True)
|
||
p.add_argument("--target", default="psx-runtime")
|
||
p.add_argument("--exe-basename", default="")
|
||
p.add_argument("--disc", default="")
|
||
p.add_argument("--train-secs", type=int, default=0)
|
||
p.add_argument("--train-runs", type=int, default=0)
|
||
p.add_argument("--pgo-mute", action="store_true")
|
||
p.add_argument("--pgo-audio", action="store_true")
|
||
p.add_argument("--pgo-hide-video", action="store_true")
|
||
p.add_argument("--pgo-video", action="store_true")
|
||
p.add_argument("--cmake-extra", action="append", default=[])
|
||
p.add_argument("--no-pgo", action="store_false", default=False)
|
||
p.add_argument("--force-pgo", action="store_true", default=True)
|
||
p.set_defaults(handler=cmd_pgo_train)
|
||
|
||
return ap
|
||
|
||
|
||
def _configure_stdio() -> None:
|
||
"""Best-effort: don't crash on Windows cp1252 when logging Unicode."""
|
||
for stream in (sys.stdout, sys.stderr):
|
||
reconf = getattr(stream, "reconfigure", None)
|
||
if callable(reconf):
|
||
try:
|
||
reconf(errors="replace")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def main() -> int:
|
||
_configure_stdio()
|
||
args = build_parser().parse_args()
|
||
progress = ProgressReporter(json_progress=bool(getattr(args, "json_progress", False)))
|
||
try:
|
||
return int(args.handler(args, progress))
|
||
except BrokenPipeError:
|
||
return EXIT_ERROR
|
||
except KeyboardInterrupt:
|
||
progress.error("interrupted", code=EXIT_ERROR)
|
||
return EXIT_ERROR
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|