mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
c10bad2ebb
No setup package ever staged one, so overlay_cache = true alone left every streamed overlay on the interpreter. rebuild now stages the pinned Python, tcc, this pin's recompiler, headers and the build's codegen hash beside the normal and diagnostic products, and writes compiler.txt naming the compiler the wizard already has; the runtime treats that as a gcc-tier compiler, so a player with nothing on PATH gets -O2 shards. Verified on the NCII kit: gcc tier reported and four shards built with no compiler on PATH. (cherry picked from commit 03865e10f6ee198d58c47c74526df5db46bb35a9)
2275 lines
84 KiB
Python
Executable File
2275 lines
84 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
|
||
progress.phase("overlays", pct=0.93, message="Staging overlay toolchain beside the product...")
|
||
stage_overlay_toolchain_for_product(project_root, exe.parent, progress)
|
||
|
||
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 stage_overlay_toolchain_for_product(project_root: Path, exe_dir: Path, progress) -> Optional[Path]:
|
||
"""Stage overlay_toolchain/ beside a built product so the runtime's autocompile gate is
|
||
true for every player build (wave-5 F1), and name the compiler the wizard already has so
|
||
shards get an optimising compiler instead of tcc (F2). Best effort: a failure leaves the
|
||
product playable with overlays interpreted, and says so."""
|
||
try:
|
||
fw = framework_root(project_root)
|
||
tools_dir = fw / "tools"
|
||
inc_dir = fw / "runtime" / "include"
|
||
game_emitter = find_psxrecomp_game(project_root)
|
||
if str(tools_dir) not in sys.path:
|
||
sys.path.insert(0, str(tools_dir))
|
||
import release_stage # noqa: E402
|
||
|
||
dl_cache = project_root / ".cache" / "overlay-toolchain"
|
||
# Runtime DLLs only from the emitter's own directory, never from PATH: Git's
|
||
# mingw64 on PATH carries the MSVCRT runtime, which is the 0xC0000139 trap.
|
||
mingw_bin = game_emitter.parent if (game_emitter.parent / "libgcc_s_seh-1.dll").is_file() else None
|
||
tk = Path(release_stage.stage_toolchain(
|
||
str(exe_dir), str(game_emitter.parent), str(tools_dir), str(inc_dir), str(dl_cache),
|
||
mingw_bin=str(mingw_bin) if mingw_bin else None, log=progress.log))
|
||
# The stale-recompiler check compares the emitter's codegen hash with the runtime's tag.
|
||
for cand in (exe_dir / "psxrecomp_codegen_include" / "overlay_codegen_hash.h",
|
||
inc_dir / "overlay_codegen_hash.h"):
|
||
if cand.is_file():
|
||
shutil.copy2(cand, tk / "include" / "overlay_codegen_hash.h")
|
||
break
|
||
compiler = None
|
||
if sys.platform == "win32":
|
||
bin_dir = resolve_toolchain_bin(project_root)
|
||
if bin_dir and (bin_dir / "clang.exe").is_file():
|
||
compiler = bin_dir / "clang.exe"
|
||
else:
|
||
for name in ("gcc", "cc", "clang"):
|
||
w = shutil.which(name)
|
||
if w:
|
||
compiler = Path(w)
|
||
break
|
||
if compiler:
|
||
(tk / "compiler.txt").write_text(str(compiler) + "\n", encoding="utf-8")
|
||
progress.log(f"overlay toolchain staged at {tk}; shard compiler: {compiler}")
|
||
else:
|
||
progress.log(f"overlay toolchain staged at {tk}; no optimising compiler found (tcc tier)")
|
||
return tk
|
||
except Exception as exc: # noqa: BLE001
|
||
progress.log(f"WARNING: overlay toolchain staging failed; overlays will run interpreted: {exc}")
|
||
return None
|
||
|
||
|
||
|
||
|
||
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())
|