Files
psxrecomp/tools/new_project_layout/project_studio/buildops.py
T
Alex Vanderveen 874df9b5bb release: ship bundled, compiled games from committed generated/ C
Retire the setup-host release model (CI wiped generated/ and shipped
emitters + sources + a wizard so every player regenerated and rebuilt the
title). Titles now commit generated/ and CI ships the compiled game.

- docs/ci/templates/game-release.yml replaces setup-release.yml: gate on
  committed game C (tools/ci/check_generated.sh), emit the OpenBIOS backend
  in CI (tools/ci/generate_openbios.sh), configure with
  PSXRECOMP_REQUIRE_GAME_C=ON and assert the full path was taken, package
  with tools/package_game_release.sh, verify the zip is a game not a kit.
- tools/package_game_release.sh: exe, assets, bundled OpenBIOS, configs, mod
  catalog + overlay toolchain via release_stage.py, notices; refuses sources,
  emitters, generated C, BIOS dumps. clear_generated.sh removed;
  package_setup_host.sh marked retired.
- Scaffold: gitignore templates track generated/, package_release.sh.in
  wrapper, setup_project.{sh,ps1} commit generated/ after Generate.
- Project Studio: audit flags ignored/untracked generated/, retail BIOS C
  and setup-host workflows; merge_gitignore un-ignores generated/; ops emit
  the bundled packager + workflow; generate_ci uses the new template.
- Docs: docs/ci/BUNDLED_RELEASES.md replaces HOST_ONLY_RELEASES.md.
- Tests: tools/tests/test_bundled_release_layout.py; fix pre-existing
  false positives in the packager-surface and overlay-static tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 17:11:14 -04:00

935 lines
30 KiB
Python

"""Local CMake configure / build / launch for game repos.
Cross-platform (Windows / macOS / Linux). No force flags; builds stay under
the chosen build directory (default ``build-release``).
Before configure, missing OpenBIOS generated C under ``psxrecomp/generated/``
is regenerated (MIT OpenBIOS — no retail dump required) so runtime.cmake can
link a BIOS backend.
"""
from __future__ import annotations
import os
import platform
import re
import shutil
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .gitops import CmdResult
DEFAULT_BUILD_DIR = "build-release"
DEFAULT_TARGET = "psx-runtime"
DEFAULT_BUILD_TYPE = "Release"
OPENBIOS_PROFILE = "bios/OpenBIOS.toml"
OPENBIOS_STEM = "OpenBIOS"
SCPH1001_PROFILE = "bios/SCPH1001.toml"
SCPH1001_STEM = "SCPH1001"
LogFn = Callable[[str], None]
@dataclass
class BuildHost:
system: str # Windows | Darwin | Linux | …
label: str # windows | macos | linux | other
cmake: str | None
ninja: str | None
jobs: int
@dataclass
class LaunchHandle:
proc: subprocess.Popen
exe: Path
cwd: Path
env_overlay: dict[str, str] = field(default_factory=dict)
@property
def pid(self) -> int | None:
return self.proc.pid if self.proc else None
def poll(self) -> int | None:
return self.proc.poll() if self.proc else None
def terminate(self) -> None:
if self.proc and self.proc.poll() is None:
self.proc.terminate()
_active_launch: LaunchHandle | None = None
_launch_lock = threading.Lock()
def detect_host() -> BuildHost:
system = platform.system()
if system == "Windows":
label = "windows"
elif system == "Darwin":
label = "macos"
elif system == "Linux":
label = "linux"
else:
label = "other"
jobs = os.cpu_count() or 4
return BuildHost(
system=system,
label=label,
cmake=shutil.which("cmake"),
ninja=shutil.which("ninja") or shutil.which("ninja-build"),
jobs=jobs,
)
def default_generator(host: BuildHost | None = None) -> str:
host = host or detect_host()
if host.ninja:
return "Ninja"
if host.label == "windows":
# Leave empty → cmake picks VS / default generator.
return ""
return "Unix Makefiles"
def parse_env_text(text: str) -> dict[str, str]:
"""Parse ``KEY=VAL`` pairs from free text (space / newline / ``;`` separated).
Values may be quoted with single or double quotes. Lines starting with ``#``
are ignored.
"""
env: dict[str, str] = {}
if not text or not text.strip():
return env
# Normalize separators to newlines, but keep quoted spans intact via a
# simple token walk on KEY=VAL forms.
cleaned: list[str] = []
for raw_line in text.replace(";", "\n").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
cleaned.append(line)
blob = "\n".join(cleaned)
# Match KEY=VALUE where VALUE is "…", '…', or non-space / until next KEY=
pattern = re.compile(
r"""(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<val>"[^"]*"|'[^']*'|\S+)"""
)
for m in pattern.finditer(blob):
key = m.group("key")
val = m.group("val")
if (val.startswith('"') and val.endswith('"')) or (
val.startswith("'") and val.endswith("'")
):
val = val[1:-1]
env[key] = val
return env
def _run_stream(
cmd: list[str],
cwd: Path,
*,
log: LogFn | None = None,
env: dict[str, str] | None = None,
) -> CmdResult:
if log:
log("$ " + " ".join(cmd))
try:
proc = subprocess.Popen(
cmd,
cwd=str(cwd),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
bufsize=1,
)
except OSError as exc:
return CmdResult(False, f"Failed to start: {cmd[0]}", str(exc))
assert proc.stdout is not None
lines: list[str] = []
for line in proc.stdout:
line = line.rstrip("\n")
lines.append(line)
if log:
log(line)
code = proc.wait()
detail = "\n".join(lines[-40:])
if code != 0:
return CmdResult(False, f"Command failed (exit {code})", detail)
return CmdResult(True, "OK", detail)
def resolve_framework_root(root: Path) -> Path | None:
"""Return the psxrecomp framework root (bios/ + recompiler/), or None."""
root = root.expanduser().resolve()
for cand in (root / "psxrecomp", root):
if (cand / "bios" / "OpenBIOS.toml").is_file() and (cand / "recompiler").is_dir():
return cand
return None
_MAX_PLAYERS_CMAKE_RE = re.compile(
r"^\s*MAX_PLAYERS\s+(\d+)\s*$", re.MULTILINE | re.IGNORECASE
)
_MAX_PLAYERS_RANGE_RE = re.compile(
r"MAX_PLAYERS must be in\s+(\d+)\.\.(\d+)", re.IGNORECASE
)
def project_max_players(root: Path) -> int | None:
"""``MAX_PLAYERS N`` from the game ``CMakeLists.txt``, if present."""
cmake = root / "CMakeLists.txt"
if not cmake.is_file():
return None
try:
text = cmake.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
m = _MAX_PLAYERS_CMAKE_RE.search(text)
return int(m.group(1)) if m else None
def framework_max_players_range(fw: Path) -> tuple[int, int] | None:
"""``(lo, hi)`` from runtime.cmake's FATAL_ERROR range check, if found."""
cmake = fw / "runtime" / "runtime.cmake"
if not cmake.is_file():
return None
try:
text = cmake.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
m = _MAX_PLAYERS_RANGE_RE.search(text)
if not m:
return None
return int(m.group(1)), int(m.group(2))
def preflight_max_players(root: Path) -> CmdResult | None:
"""Fail fast when the game asks for MAX_PLAYERS the nested framework rejects.
Single-player titles (Ape Escape, Tomba, …) use ``MAX_PLAYERS 1``. Older
psxrecomp pins only allowed 2..5 and abort configure with a cryptic
FATAL_ERROR. Detect that before running cmake.
"""
players = project_max_players(root)
if players is None:
return None
fw = resolve_framework_root(root)
if fw is None:
return None
rng = framework_max_players_range(fw)
if rng is None:
return None
lo, hi = rng
if lo <= players <= hi:
return None
pins = root / "framework_pins.txt"
pin_hint = ""
if pins.is_file():
pin_hint = f" Check {pins.name} vs the checked-out psxrecomp commit."
return CmdResult(
False,
f"MAX_PLAYERS {players} is outside nested psxrecomp range {lo}..{hi}. "
f"Update the psxrecomp submodule to a pin that allows 1..8 "
f"(runtime.cmake after single-player / rewind support).{pin_hint}",
)
def diagnose_configure_failure(detail: str, root: Path) -> str | None:
"""Extra hint appended to a failed cmake configure message."""
blob = detail or ""
if "MAX_PLAYERS must be in" in blob and "got" in blob:
pre = preflight_max_players(root)
if pre is not None:
return pre.message
return (
"MAX_PLAYERS rejected by nested psxrecomp. Single-player titles need "
"a framework pin whose runtime.cmake allows 1..8 — update the "
"psxrecomp submodule (and framework_pins.txt)."
)
if "generated" in blob.lower() and (
"GEN_MARKER" in blob or "dispatch.c" in blob or "missing" in blob.lower()
):
return (
"Game generated C may be missing — run Generate (disc→C) before "
"Configure, or ensure generated/<boot>_dispatch.c exists."
)
return None
def bios_backend_present(fw: Path, stem: str) -> bool:
"""True when generated/<stem>_{full,dispatch}.c look linkable."""
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 _allow_no_bios(extra_args: list[str] | None) -> bool:
if not extra_args:
return False
blob = " ".join(extra_args)
return (
"PSXRECOMP_ALLOW_NO_BIOS=ON" in blob
or "PSXRECOMP_ALLOW_NO_BIOS:BOOL=ON" in blob
or "PSXRECOMP_ALLOW_NO_BIOS=1" in blob
)
def _find_psxrecomp_bios(fw: Path, game_root: Path | None = None) -> Path | None:
names = ("psxrecomp-bios.exe", "psxrecomp-bios")
dirs: list[Path] = [
fw / "recompiler" / "build",
fw / "recompiler" / "build-t2",
]
if game_root is not None:
dirs.append(game_root / "build-recompiler")
for d in dirs:
if not d.is_dir():
continue
for name in names:
p = d / name
if p.is_file():
return p
# Nested generator layouts (e.g. Debug/Release on MSVC)
for sub in d.iterdir():
if not sub.is_dir():
continue
for name in names:
p = sub / name
if p.is_file():
return p
return None
def _recompiler_build_usable(build_dir: Path) -> bool:
cache = build_dir / "CMakeCache.txt"
if not cache.is_file():
return False
try:
text = cache.read_text(encoding="utf-8", errors="ignore")
except OSError:
return False
gen = ""
for line in text.splitlines():
if line.startswith("CMAKE_GENERATOR:INTERNAL="):
gen = line.split("=", 1)[1].strip()
break
if gen.startswith("Ninja"):
return (build_dir / "build.ninja").is_file()
if "Makefiles" in gen:
return (build_dir / "Makefile").is_file()
if gen.startswith("Visual Studio"):
return any(build_dir.glob("*.sln"))
# Unknown generator — trust the cache and let cmake --build diagnose.
return True
def _iter_recompiler_build_dirs(fw: Path) -> list[Path]:
src = fw / "recompiler"
if not src.is_dir():
return []
dirs: list[Path] = []
for name in ("build-t2", "build"):
dirs.append(src / name)
dirs.extend(sorted(src.glob("cmake-build*")))
return dirs
def _any_usable_recompiler_build(fw: Path) -> bool:
return any(_recompiler_build_usable(d) for d in _iter_recompiler_build_dirs(fw))
def ensure_bios_emitter(
fw: Path,
*,
game_root: Path | None = None,
build_type: str = DEFAULT_BUILD_TYPE,
dry_run: bool = False,
log: LogFn | None = None,
) -> CmdResult:
"""Configure ``recompiler/build`` and build ``psxrecomp-bios`` if needed."""
host = detect_host()
if not host.cmake:
return CmdResult(False, "cmake not found on PATH (needed to build psxrecomp-bios)")
existing = _find_psxrecomp_bios(fw, game_root)
if existing is not None and not dry_run:
if log:
log(f"BIOS emitter ready: {existing}")
return CmdResult(True, f"BIOS emitter ready: {existing.name}")
src = fw / "recompiler"
if not (src / "CMakeLists.txt").is_file():
return CmdResult(False, f"recompiler sources missing under {src}")
# Prefer an already-finished tree (matches regen_bios.sh discovery order).
build_dir = src / "build"
for cand in _iter_recompiler_build_dirs(fw):
if _recompiler_build_usable(cand):
build_dir = cand
break
gen = default_generator(host)
cfg = [host.cmake, "-S", str(src), "-B", str(build_dir)]
if gen:
cfg.extend(["-G", gen])
cfg.append(f"-DCMAKE_BUILD_TYPE={build_type}")
if dry_run:
msg = "dry-run: " + " ".join(cfg)
if log:
log(msg)
log(f"dry-run: {host.cmake} --build {build_dir} --target psxrecomp-bios")
return CmdResult(True, msg)
if not _recompiler_build_usable(build_dir):
if log:
log("Configuring recompiler for psxrecomp-bios…")
build_dir.mkdir(parents=True, exist_ok=True)
r = _run_stream(cfg, fw, log=log)
if not r.ok:
return CmdResult(
False,
"Failed to configure recompiler (needed for OpenBIOS regen)",
r.detail,
)
jobs = str(host.jobs)
build_cmd = [
host.cmake,
"--build",
str(build_dir),
"--target",
"psxrecomp-bios",
"-j",
jobs,
]
if log:
log("Building psxrecomp-bios…")
r = _run_stream(build_cmd, fw, log=log)
if not r.ok:
return CmdResult(False, "Failed to build psxrecomp-bios", r.detail)
bios = _find_psxrecomp_bios(fw, game_root)
if bios is None:
return CmdResult(
False,
f"psxrecomp-bios not found after build under {build_dir}",
)
return CmdResult(True, f"Built BIOS emitter: {bios.name}")
def _regen_bios_profile(
fw: Path,
profile_rel: str,
*,
game_root: Path | None = None,
dry_run: bool = False,
log: LogFn | None = None,
) -> CmdResult:
"""Regen one BIOS profile into ``fw/generated/`` (canonical regen_bios.sh)."""
profile = fw / profile_rel
if not profile.is_file():
return CmdResult(False, f"BIOS profile missing: {profile}")
script = fw / "tools" / "regen_bios.sh"
bash = shutil.which("bash") or shutil.which("bash.exe")
if script.is_file() and bash:
cmd = [bash, str(script), "--config", profile_rel]
if dry_run:
msg = "dry-run: " + " ".join(cmd) + f" (cwd={fw})"
if log:
log(msg)
return CmdResult(True, msg)
return _run_stream(cmd, fw, log=log)
# Fallback without bash: invoke emitter + optional fingerprint helper.
r = ensure_bios_emitter(fw, game_root=game_root, dry_run=dry_run, log=log)
if not r.ok:
return r
if dry_run:
return CmdResult(True, f"dry-run: psxrecomp-bios --config {profile_rel}")
bios = _find_psxrecomp_bios(fw, game_root)
if bios is None:
return CmdResult(False, "psxrecomp-bios missing after ensure")
(fw / "generated").mkdir(parents=True, exist_ok=True)
r = _run_stream([str(bios), "--config", profile_rel], fw, log=log)
if not r.ok:
return CmdResult(False, f"psxrecomp-bios failed for {profile_rel}", r.detail)
# Best-effort fingerprint (staleness WARN in runtime.cmake).
fp = fw / "tools" / "bios_emitter_fingerprint.sh"
if fp.is_file() and bash:
stem = OPENBIOS_STEM if "OpenBIOS" in profile_rel else SCPH1001_STEM
try:
proc = subprocess.run(
[bash, str(fp), profile_rel],
cwd=str(fw),
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0 and proc.stdout.strip():
out = fw / "generated" / f"{stem}.emitter.sha"
out.write_text(proc.stdout, encoding="utf-8")
if log:
log(f"Wrote fingerprint {out.name}")
except OSError:
pass
return CmdResult(True, f"Regenerated BIOS profile {profile_rel}")
def ensure_bios_backends(
root: Path,
*,
force: bool = False,
include_scph1001: bool = True,
dry_run: bool = False,
log: LogFn | None = None,
) -> CmdResult:
"""Ensure linkable OpenBIOS (and optional SCPH1001) under ``psxrecomp/generated``.
OpenBIOS is bundled and MIT-licensed. SCPH1001 is regenerated only when the
retail dump ``bios/SCPH1001.BIN`` is already present beside the profile.
"""
root = root.expanduser().resolve()
fw = resolve_framework_root(root)
if fw is None:
return CmdResult(True, "No psxrecomp framework — skipping BIOS ensure")
needed: list[tuple[str, str]] = []
if force or not bios_backend_present(fw, OPENBIOS_STEM):
needed.append((OPENBIOS_STEM, OPENBIOS_PROFILE))
elif log:
log(f"OpenBIOS backend already present under {fw / 'generated'}")
scph_rom = fw / "bios" / "SCPH1001.BIN"
if include_scph1001 and scph_rom.is_file():
if force or not bios_backend_present(fw, SCPH1001_STEM):
needed.append((SCPH1001_STEM, SCPH1001_PROFILE))
elif log:
log("SCPH1001 backend already present")
if not needed:
return CmdResult(True, "BIOS backends already generated")
# Prefer regen_bios.sh (builds emitter + fingerprints). It does not configure
# the recompiler — ensure a usable tree (or a found binary) first.
script = fw / "tools" / "regen_bios.sh"
bash = shutil.which("bash") or shutil.which("bash.exe")
need_emitter_setup = not (
_any_usable_recompiler_build(fw) or _find_psxrecomp_bios(fw, root) is not None
)
if need_emitter_setup or not (script.is_file() and bash):
r = ensure_bios_emitter(fw, game_root=root, dry_run=dry_run, log=log)
if not r.ok:
return r
done: list[str] = []
for stem, profile in needed:
if log:
log(f"Regenerating {stem} via {profile}…")
r = _regen_bios_profile(
fw, profile, game_root=root, dry_run=dry_run, log=log
)
if not r.ok:
return CmdResult(
False,
f"Failed to regenerate {stem}: {r.message}",
r.detail,
)
if not dry_run and not bios_backend_present(fw, stem):
return CmdResult(
False,
f"Regen finished but {stem} backend still missing under {fw / 'generated'}",
)
done.append(stem)
return CmdResult(True, "BIOS ready: " + ", ".join(done))
def configure(
root: Path,
*,
build_dir: str = DEFAULT_BUILD_DIR,
build_type: str = DEFAULT_BUILD_TYPE,
generator: str | None = None,
extra_args: list[str] | None = None,
dry_run: bool = False,
log: LogFn | None = None,
ensure_bios: bool = True,
) -> CmdResult:
root = root.expanduser().resolve()
host = detect_host()
if not host.cmake:
return CmdResult(False, "cmake not found on PATH")
if not (root / "CMakeLists.txt").is_file():
return CmdResult(False, f"No CMakeLists.txt in {root}")
if ensure_bios and not _allow_no_bios(extra_args):
bios_r = ensure_bios_backends(root, dry_run=dry_run, log=log)
if not bios_r.ok:
return bios_r
if log and bios_r.message:
log(bios_r.message)
pre = preflight_max_players(root)
if pre is not None:
if log:
log(pre.message)
return pre
bdir = Path(build_dir)
if not bdir.is_absolute():
bdir = root / bdir
gen = generator if generator is not None else default_generator(host)
cmd = [host.cmake, "-S", str(root), "-B", str(bdir)]
if gen:
cmd.extend(["-G", gen])
cmd.append(f"-DCMAKE_BUILD_TYPE={build_type}")
if extra_args:
cmd.extend(extra_args)
if dry_run:
msg = "dry-run: " + " ".join(cmd)
if log:
log(msg)
return CmdResult(True, msg)
r = _run_stream(cmd, root, log=log)
if r.ok:
r = CmdResult(
True,
f"Configured {bdir.name} ({build_type}" + (f", {gen}" if gen else "") + ")",
r.detail,
)
return r
hint = diagnose_configure_failure(r.detail or "", root)
if hint:
msg = f"{r.message}\n{hint}"
if log:
log(hint)
return CmdResult(False, msg, r.detail)
return r
def build(
root: Path,
*,
build_dir: str = DEFAULT_BUILD_DIR,
target: str = DEFAULT_TARGET,
jobs: int | None = None,
dry_run: bool = False,
log: LogFn | None = None,
) -> CmdResult:
root = root.expanduser().resolve()
host = detect_host()
if not host.cmake:
return CmdResult(False, "cmake not found on PATH")
bdir = Path(build_dir)
if not bdir.is_absolute():
bdir = root / bdir
if not bdir.is_dir():
return CmdResult(False, f"Build dir missing — Configure first: {bdir}")
j = jobs if jobs and jobs > 0 else host.jobs
cmd = [host.cmake, "--build", str(bdir), "--target", target, "-j", str(j)]
if dry_run:
msg = "dry-run: " + " ".join(cmd)
if log:
log(msg)
return CmdResult(True, msg)
r = _run_stream(cmd, root, log=log)
if r.ok:
exe = find_runtime_exe(bdir)
hint = f" → {exe.name}" if exe else ""
r = CmdResult(True, f"Built {target} in {bdir.name}{hint}", r.detail)
return r
def find_runtime_exe(build_dir: Path) -> Path | None:
"""Locate the game product binary under a CMake build tree."""
build_dir = build_dir.expanduser().resolve()
if not build_dir.is_dir():
return None
host = detect_host()
suffixes = {""}
if host.label == "windows":
suffixes = {".exe"}
# Prefer names that look like Recompiled products / known targets.
ranked: list[tuple[int, Path]] = []
skip_dirs = {
"CMakeFiles",
"_deps",
".cmake",
"Testing",
"CMakeTmp",
"assets",
"bios",
"fonts",
"img",
"mods",
}
def consider(p: Path) -> None:
if not p.is_file():
return
name = p.name
lower = name.lower()
if host.label == "windows":
if not lower.endswith(".exe"):
return
stem = name[:-4]
else:
if any(
lower.endswith(ext)
for ext in (".so", ".dll", ".dylib", ".a", ".lib", ".pdb", ".cmake", ".ninja")
):
return
stem = name
if stem.lower() in ("cmake", "ninja", "cpack", "ctest"):
return
score = 0
if "recompil" in lower:
score += 100
if stem in ("psx-runtime", "psx-runtime.exe") or stem == "psx-runtime":
score += 50
if p.parent == build_dir:
score += 20
if host.label != "windows" and not os.access(p, os.X_OK):
return
ranked.append((score, p))
for p in build_dir.iterdir():
if p.is_file():
consider(p)
for sub in build_dir.iterdir():
if not sub.is_dir() or sub.name in skip_dirs or sub.name.startswith("."):
continue
if sub.name in ("Debug", "Release", "RelWithDebInfo", "MinSizeRel") or host.label == "windows":
for p in sub.iterdir():
if p.is_file():
consider(p)
if not ranked:
return None
ranked.sort(key=lambda t: (-t[0], t[1].name.lower()))
return ranked[0][1]
def resolve_build_dir(root: Path, build_dir: str) -> Path:
root = root.expanduser().resolve()
bdir = Path(build_dir)
if not bdir.is_absolute():
bdir = root / bdir
return bdir
def launch(
root: Path,
*,
build_dir: str = DEFAULT_BUILD_DIR,
exe: Path | str | None = None,
env_text: str = "",
extra_args: list[str] | None = None,
dry_run: bool = False,
log: LogFn | None = None,
) -> CmdResult:
"""Start the local product build (non-blocking)."""
global _active_launch
root = root.expanduser().resolve()
bdir = resolve_build_dir(root, build_dir)
exe_path = Path(exe) if exe else find_runtime_exe(bdir)
if exe_path is None:
return CmdResult(False, f"No runtime executable found under {bdir}")
if not exe_path.is_file():
return CmdResult(False, f"Executable missing: {exe_path}")
overlay = parse_env_text(env_text)
env = os.environ.copy()
env.update(overlay)
# Run from game root so relative game.toml / disc / saves resolve.
cmd = [str(exe_path), *(extra_args or [])]
if dry_run:
preview = " ".join(f"{k}={v}" for k, v in overlay.items())
msg = "dry-run: " + (f"env {preview} " if preview else "") + " ".join(cmd)
if log:
log(msg)
return CmdResult(True, msg)
with _launch_lock:
if _active_launch and _active_launch.poll() is None:
return CmdResult(
False,
f"Already running (pid {_active_launch.pid}) — Stop first",
)
try:
kwargs: dict = {
"cwd": str(root),
"env": env,
}
host = detect_host()
if host.label == "windows":
# Detach from Studio console; GUI apps don't need a console.
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
kwargs["stdout"] = subprocess.DEVNULL
kwargs["stderr"] = subprocess.DEVNULL
else:
kwargs["start_new_session"] = True
kwargs["stdout"] = subprocess.DEVNULL
kwargs["stderr"] = subprocess.DEVNULL
proc = subprocess.Popen(cmd, **kwargs)
except OSError as exc:
return CmdResult(False, "Launch failed", str(exc))
_active_launch = LaunchHandle(
proc=proc, exe=exe_path, cwd=root, env_overlay=overlay
)
env_note = ""
if overlay:
env_note = " env=[" + ", ".join(sorted(overlay)) + "]"
msg = f"Launched {exe_path.name} (pid {proc.pid}){env_note}"
if log:
log(msg)
return CmdResult(True, msg)
def stop_launch() -> CmdResult:
global _active_launch
with _launch_lock:
h = _active_launch
if h is None or h.poll() is not None:
_active_launch = None
return CmdResult(False, "No running launch")
pid = h.pid
h.terminate()
try:
h.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
h.proc.kill()
_active_launch = None
return CmdResult(True, f"Stopped pid {pid}")
def launch_status() -> str:
with _launch_lock:
h = _active_launch
if h is None:
return "not running"
code = h.poll()
if code is None:
return f"running pid={h.pid} ({h.exe.name})"
return f"exited code={code} ({h.exe.name})"
# ---------------------------------------------------------------------------
# Local release export
#
# Studio could build a title but never bundle one: the only path to a
# distributable package was "install release.yml, commit, push, wait for CI".
# That made a local mingw/linux export something you did by hand, which is how
# a broken package (an empty Mods page) survived so long -- nobody produced one
# locally to look inside.
#
# This runs the SAME wrapper CI runs (scripts/package_release.sh), so a
# local export and a CI release are the same artifact by construction rather
# than by convention.
# ---------------------------------------------------------------------------
def release_version(root: Path) -> str:
"""Version the packager will stamp: $RELEASE_VERSION, else the VERSION file."""
env_v = os.environ.get("RELEASE_VERSION", "").strip()
if env_v:
return env_v
vf = root / "VERSION"
if vf.is_file():
text = vf.read_text(encoding="utf-8", errors="replace").strip()
if text:
return text
return "0.0.0"
def default_artifact_tag(host: BuildHost | None = None) -> str:
"""CI's matrix tag for this host, so local zips match the released names."""
h = host or detect_host()
if h.label == "windows":
return "windows-x64"
if h.label == "macos":
return "macos-arm64"
return "linux-x64"
def export_release(
root: Path,
build_dir: str = "build-release",
artifact_tag: str | None = None,
recompiler_build: str = "build-recompiler",
*,
exclude_dev_mods: bool = False,
log: LogFn | None = None,
) -> CmdResult:
"""Package a distributable zip from an existing build. Returns its path.
exclude_dev_mods reproduces what CI publishes -- channel = "developer"
packages dropped. A local export keeps them by default, because the point
of exporting locally is to test the work in progress.
"""
from .detect import packager_wrapper
wrapper = packager_wrapper(root)
if wrapper is None:
return CmdResult(
False,
"No scripts/package_release.sh",
"Emit it first (Migrate -> packager), or install release CI.",
)
build_path = root / build_dir
if not build_path.is_dir():
return CmdResult(False, f"No build directory: {build_dir}",
"Build the runtime target first.")
# Fail here rather than inside the packager: this is the exact condition
# that produced mod-less releases, and the message should name the cause.
if not (build_path / "mods").is_dir():
return CmdResult(
False,
f"{build_dir}/mods is missing",
"runtime.cmake stages the mod catalog next to the exe on every "
"build. Rebuild the runtime target; packaging without it would "
"ship a title whose Mods page is empty.",
)
tag = artifact_tag or default_artifact_tag()
env = dict(os.environ)
env.setdefault("RELEASE_VERSION", release_version(root))
# Explicit either way: the packager defaults this from $CI, and a studio
# export must not inherit that by accident on a CI-flavoured machine.
env["EXCLUDE_DEV_MODS"] = "1" if exclude_dev_mods else "0"
res = _run_stream(
["bash", str(wrapper), build_dir, tag, recompiler_build],
root, log=log, env=env,
)
if not res.ok:
return res
zips = sorted((root / "dist").glob(f"*-{tag}.zip"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not zips:
return CmdResult(False, "Packager reported success but wrote no zip",
res.detail)
return CmdResult(True, f"Exported {zips[0].name}", str(zips[0]))