Files
psxrecomp/runtime/tests/test_mod_catalog_layout.py

489 lines
21 KiB
Python

#!/usr/bin/env python3
"""Guard the staged mod-catalog layout (<exe-dir>/mods/bundled).
WHY THIS TEST EXISTS
--------------------
Framework commit 4cc04be3 renamed the staged mod catalog from mods/packages to
mods/bundled. The framework's own staging followed, and the shared release
module (tools/release_overlay_stage.ps1, Add-ModCatalog) followed. Five title
CMakeLists did not, because the only thing coupling them to the framework was a
`cmake -E copy_directory` DESTINATION STRING -- no symbol, no header, no link
dependency. Nothing failed to configure, compile or link. The five titles kept
writing their packages into mods/packages, where nothing reads them, and the
defect surfaced only when a release packager ran, in a different repository, on
a later day (bead beads-eio.3.101).
runtime/psx_check_mod_catalog.cmake is the tripwire that makes that state a
build failure. This test exercises it against on-disk fixtures, including a
byte-for-byte reproduction of the broken state as it actually appeared in Ape
Escape's build output: mods/bundled holding the four framework psx.* packages
and mods/packages holding the four game-owned ape.* ones.
It also pins two source invariants in runtime.cmake, so the layout cannot drift
back into per-title copies:
* the framework declares PRELOADED_MODS_DIR and stages the title's catalog,
* the build-time guard is registered through cmake_language(DEFER), which is
what lets it validate the sibling runtime targets in one output directory.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
GUARD = REPO / "runtime" / "psx_check_mod_catalog.cmake"
RUNTIME_CMAKE = REPO / "runtime" / "runtime.cmake"
MAIN_CPP = REPO / "runtime" / "src" / "main.cpp"
FW_IDS = [
"psx.enhancement.cd-speed",
"psx.enhancement.fast-loading",
"psx.enhancement.pgxp",
"psx.presentation.bezel",
]
GAME_IDS = [
"ape.enhancement.frame-smoothing",
"ape.enhancement.widescreen",
"ape.experimental.interpolated-frame-rate",
"ape.presentation.crt",
]
_failures: list[str] = []
def check(cond: bool, what: str) -> None:
if cond:
print(f" ok {what}")
else:
print(f" FAIL {what}")
_failures.append(what)
def make_package(root: Path, pkg_id: str, version: str = "1.0.0") -> None:
d = root / pkg_id / version
d.mkdir(parents=True, exist_ok=True)
(d / "manifest.toml").write_text(
f'format_version = 5\nid = "{pkg_id}"\nversion = "{version}"\n',
encoding="utf-8",
)
def write_manifest(path: Path, ids: list[str]) -> Path:
path.write_text("\n".join(ids) + "\n", encoding="utf-8")
return path
def run_guard(
cmake: str,
mods_dir: Path,
manifest: Path,
*,
require_staged: int = 1,
alt_manifests: list[Path] | None = None,
excluded: Path | None = None,
label: str = "psx-runtime",
) -> subprocess.CompletedProcess[str]:
argv = [
cmake,
f"-DPSX_MODS_DIR={mods_dir.as_posix()}",
f"-DPSX_CATALOG_MANIFEST={manifest.as_posix()}",
f"-DPSX_REQUIRE_STAGED={require_staged}",
f"-DPSX_LABEL={label}",
]
if excluded is not None:
argv.append(f"-DPSX_CATALOG_EXCLUDED={excluded.as_posix()}")
if alt_manifests:
joined = "|".join(p.as_posix() for p in alt_manifests)
argv.append(f"-DPSX_CATALOG_ALT_MANIFESTS={joined}")
argv += ["-P", str(GUARD)]
return subprocess.run(argv, capture_output=True, text=True)
SELECT = REPO / "runtime" / "psx_mod_catalog_select.cmake"
def run_select(
cmake: str,
tmp: Path,
*,
available: list[str],
exclude: list[str] = (),
title: list[str] = (),
allowlist: list[str] = (),
) -> tuple[subprocess.CompletedProcess[str], list[str] | None]:
"""Drive the REAL builtin selection (psx_mod_catalog_select.cmake) that
runtime.cmake's _psxrt_stage_mod_catalog calls, through `cmake -P`."""
def cm_list(name: str, ids) -> str:
return f" {name} " + " ".join(ids) if ids else f" {name}"
driver = tmp / "select_driver.cmake"
driver.write_text(
'cmake_minimum_required(VERSION 3.20)\n'
f'include("{SELECT.as_posix()}")\n'
"psx_select_builtin_mod_ids(_out LABEL psx-runtime"
+ cm_list("AVAILABLE", available)
+ cm_list("ALLOWLIST", allowlist)
+ cm_list("EXCLUDE", exclude)
+ cm_list("TITLE", title)
+ ")\n"
'list(JOIN _out "," _joined)\n'
'message(STATUS "SELECTED=[${_joined}]")\n',
encoding="utf-8",
)
r = subprocess.run([cmake, "-P", str(driver)], capture_output=True, text=True)
selected = None
for line in (r.stdout + r.stderr).splitlines():
if "SELECTED=[" in line:
body = line.split("SELECTED=[", 1)[1].rsplit("]", 1)[0]
selected = [s for s in body.split(",") if s]
return r, selected
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--cmake", default=shutil.which("cmake") or "cmake")
args = ap.parse_args()
if not GUARD.is_file():
print(f"FAIL: guard script missing: {GUARD}")
return 1
if shutil.which(args.cmake) is None and not Path(args.cmake).is_file():
print(f"SKIP: no cmake executable available ({args.cmake})")
return 0
with tempfile.TemporaryDirectory(prefix="psx_mod_catalog_") as tmpdir:
tmp = Path(tmpdir)
all_ids = sorted(FW_IDS + GAME_IDS)
manifest = write_manifest(tmp / "expected.txt", all_ids)
# ---- 1. correct layout: everything under mods/bundled -------------
print("[1] correct layout (framework + game under mods/bundled)")
mods = tmp / "good" / "mods"
for pid in all_ids:
make_package(mods / "bundled", pid)
(mods / "README.md").write_text("mods\n", encoding="utf-8")
r = run_guard(args.cmake, mods, manifest)
check(r.returncode == 0, "accepts a complete mods/bundled catalog")
check(
"staged mod catalog OK: 8 package(s) = 4 game-owned + 4 framework-owned"
in (r.stdout + r.stderr),
"reports the derived 4 game-owned + 4 framework-owned breakdown",
)
# ---- 2. THE BUG: game packages under the legacy mods/packages ----
# This is exactly what a framework-master build of Ape Escape produced
# before the fix: the framework staged its own four into mods/bundled,
# and the title's own copy_directory put the game's four into
# mods/packages, where Add-ModCatalog cannot see them.
print("[2] the regression: game packages under legacy mods/packages")
mods = tmp / "regression" / "mods"
for pid in FW_IDS:
make_package(mods / "bundled", pid)
for pid in GAME_IDS:
make_package(mods / "packages", pid)
r = run_guard(args.cmake, mods, manifest)
out = r.stdout + r.stderr
check(r.returncode != 0, "FAILS the build when packages land in mods/packages")
check("LEGACY mods/packages" in out, "names the legacy layout as the cause")
for pid in GAME_IDS:
check(pid in out, f"names the stranded package {pid}")
check(
"PRELOADED_MODS_DIR" in out,
"tells the reader the fix is to declare PRELOADED_MODS_DIR",
)
# ---- 3. a declared package simply never staged --------------------
print("[3] a declared package never reached mods/bundled")
mods = tmp / "missing" / "mods"
for pid in FW_IDS + GAME_IDS[:-1]:
make_package(mods / "bundled", pid)
r = run_guard(args.cmake, mods, manifest)
out = r.stdout + r.stderr
check(r.returncode != 0, "FAILS when a declared package is absent")
check(GAME_IDS[-1] in out, "names the absent package")
check(
"never reached the staged catalog" in out,
"says the package never reached the catalog",
)
# ---- 4. a package directory with no manifest ----------------------
print("[4] staged package directory without a manifest.toml")
mods = tmp / "manifestless" / "mods"
for pid in all_ids:
make_package(mods / "bundled", pid)
broken = mods / "bundled" / GAME_IDS[0] / "1.0.0" / "manifest.toml"
broken.unlink()
r = run_guard(args.cmake, mods, manifest)
out = r.stdout + r.stderr
check(r.returncode != 0, "FAILS when a staged package has no manifest.toml")
check(GAME_IDS[0] in out, "names the manifest-less package")
# ---- 5. player-installed legacy packages must NOT fail -----------
# mods/packages can legitimately hold packages a player installed under
# the pre-split layout; the runtime's migrate_legacy_root() relocates
# those into mods/installed. Failing a build over them would be wrong.
print("[5] foreign (player-installed) packages under mods/packages")
mods = tmp / "foreign" / "mods"
for pid in all_ids:
make_package(mods / "bundled", pid)
make_package(mods / "packages", "someone.else.mod")
r = run_guard(args.cmake, mods, manifest)
out = r.stdout + r.stderr
check(r.returncode == 0, "does NOT fail over a package this build never staged")
check("someone.else.mod" in out, "still reports the legacy package it found")
check("mods/installed" in out, "points at the runtime's own migration path")
# ---- 6. nothing staged: require_staged distinguishes the callers --
print("[6] nothing staged at all")
mods = tmp / "empty" / "mods"
r = run_guard(args.cmake, mods, manifest, require_staged=0)
check(r.returncode == 0, "ctest invocation SKIPS an unbuilt tree")
check("skipped" in (r.stdout + r.stderr), "says it skipped")
r = run_guard(args.cmake, mods, manifest, require_staged=1)
check(
r.returncode != 0,
"staging invocation FAILS when staging produced nothing",
)
# ---- 7. sibling runtime target sharing one output directory ------
# Tomba 2's US and Italian runtimes both land in the build root, so
# mods/bundled holds whichever linked last. A ctest run after a full
# build must accept either, not demand one.
print("[7] sibling target's catalog in a shared output directory")
ita_ids = sorted(
[
"psx.enhancement.cd-speed",
"psx.enhancement.fast-loading",
"psx.enhancement.pgxp",
"psx.presentation.bezel",
"tombi2.enhancement.skip-fmvs",
"tombi2.enhancement.widescreen",
"tombi2.experimental.interpolated-frame-rate",
]
)
ita_manifest = write_manifest(tmp / "expected_ita.txt", ita_ids)
mods = tmp / "sibling" / "mods"
for pid in ita_ids:
make_package(mods / "bundled", pid)
r = run_guard(args.cmake, mods, manifest, alt_manifests=[ita_manifest])
out = r.stdout + r.stderr
check(r.returncode == 0, "accepts a sibling target's complete catalog")
check("sibling runtime target" in out, "says which case it took")
# ...but an INCOMPLETE sibling catalog is still a failure.
(mods / "bundled" / ita_ids[-1]).rename(
mods / "bundled" / "unexpected.leftover"
)
r = run_guard(args.cmake, mods, manifest, alt_manifests=[ita_manifest])
check(
r.returncode != 0,
"does NOT accept a catalog matching neither target's id set",
)
# ---- 9. EXCLUDE_BUILTIN_MODS: builtin selection ------------------
# WipEout 3 ships its own team-mark bezel and has no use for host-paced
# fast loading, so it declines both framework builtins. Excluded means
# ABSENT from mods/bundled, and a typo must never read as success.
print("[9] EXCLUDE_BUILTIN_MODS builtin selection")
builtins = sorted(FW_IDS + ["psx.enhancement.8mb-ram"])
r, sel = run_select(args.cmake, tmp, available=builtins)
check(r.returncode == 0 and sel == builtins,
"no exclusion stages every builtin")
r, sel = run_select(
args.cmake, tmp, available=builtins,
exclude=["psx.enhancement.fast-loading", "psx.presentation.bezel"],
title=["wipeout3.enhancement.bezel"])
check(r.returncode == 0, "a valid exclusion configures")
check(sel == ["psx.enhancement.8mb-ram", "psx.enhancement.cd-speed",
"psx.enhancement.pgxp"],
f"excluded builtins are dropped, the rest kept in order (got {sel})")
r, sel = run_select(
args.cmake, tmp, available=builtins,
exclude=["psx.enhancement.fast-loading",
"psx.enhancement.fast-loading"])
check(r.returncode == 0 and sel is not None
and "psx.enhancement.fast-loading" not in sel
and len(sel) == len(builtins) - 1,
"a repeated id is excluded once, not an error")
r, sel = run_select(
args.cmake, tmp, available=builtins,
exclude=["psx.enhancement.fast-laoding"])
out = r.stdout + r.stderr
check(r.returncode != 0, "an unknown excluded id FAILS configure")
check("psx.enhancement.fast-laoding" in out, "names the unknown id")
check("psx.enhancement.fast-loading" in out,
"lists the real builtins so the typo is obvious")
check(sel is None, "produces no selection after the failure")
r, sel = run_select(
args.cmake, tmp, available=builtins,
exclude=["wipeout3.enhancement.bezel"],
title=["wipeout3.enhancement.bezel"])
check(r.returncode != 0,
"excluding a TITLE package (not a builtin) FAILS configure")
r, sel = run_select(
args.cmake, tmp, available=builtins,
exclude=["psx.presentation.bezel"],
title=["psx.presentation.bezel"])
out = r.stdout + r.stderr
check(r.returncode != 0,
"excluding a builtin the title also overrides FAILS configure")
check("OVERRIDES" in out, "explains the exclude/override contradiction")
r, sel = run_select(
args.cmake, tmp, available=builtins,
allowlist=["psx.enhancement.pgxp", "psx.presentation.bezel"],
exclude=["psx.presentation.bezel"])
check(r.returncode == 0 and sel == ["psx.enhancement.pgxp"],
f"exclusion applies on top of PSX_BUILTIN_MOD_ALLOWLIST (got {sel})")
r, sel = run_select(
args.cmake, tmp, available=[],
exclude=["psx.presentation.bezel"])
check(r.returncode != 0,
"an exclusion with no builtin catalog at all FAILS configure")
# psxrecomp_add_game_runtime forwards keywords it does not parse, so
# its EXCLUDE_BUILTIN_MODS list can swallow one written after it.
split = tmp / "split_driver.cmake"
split.write_text(
f'include("{SELECT.as_posix()}")\n'
"psx_split_exclude_builtin_mods(_ids _tail psx.enhancement.fast-loading "
"psx.presentation.bezel GAME_OVERLAY_STATIC_C generated/x.c APP_ICON a.ico)\n"
'list(JOIN _ids "," _i)\nlist(JOIN _tail "," _t)\n'
'message(STATUS "IDS=[${_i}] TAIL=[${_t}]")\n', encoding="utf-8")
r = subprocess.run([args.cmake, "-P", str(split)], capture_output=True, text=True)
out = r.stdout + r.stderr
check(r.returncode == 0
and "IDS=[psx.enhancement.fast-loading,psx.presentation.bezel]" in out
and "TAIL=[GAME_OVERLAY_STATIC_C,generated/x.c,APP_ICON,a.ico]" in out,
"a forwarded keyword after EXCLUDE_BUILTIN_MODS is handed back, not "
"swallowed as a package id")
# ---- 10. build-time guard: excluded ids must stay absent ---------
print("[10] build-time guard over excluded builtins")
kept = sorted(i for i in all_ids if i != "psx.presentation.bezel")
kept_manifest = write_manifest(tmp / "expected_excl.txt", kept)
excluded_file = tmp / "expected_excl.excluded.txt"
excluded_file.write_text(
"# framework builtin mod packages psx-runtime excludes\n"
"psx.presentation.bezel\n", encoding="utf-8")
mods = tmp / "excluded_ok" / "mods"
for pid in kept:
make_package(mods / "bundled", pid)
r = run_guard(args.cmake, mods, kept_manifest, excluded=excluded_file)
check(r.returncode == 0, "accepts a catalog with the excluded builtin absent")
mods = tmp / "excluded_back" / "mods"
for pid in kept + ["psx.presentation.bezel"]:
make_package(mods / "bundled", pid)
r = run_guard(args.cmake, mods, kept_manifest, excluded=excluded_file)
out = r.stdout + r.stderr
check(r.returncode != 0,
"FAILS the build when an excluded builtin reappears in mods/bundled")
check("psx.presentation.bezel" in out and "EXCLUDES" in out,
"names the resurrected package and why it is wrong")
sibling = write_manifest(tmp / "expected_sib.txt", all_ids)
r = run_guard(args.cmake, mods, kept_manifest, excluded=excluded_file,
alt_manifests=[sibling])
check(r.returncode == 0,
"a sibling target that ships the id may own the shared directory")
# ---- 8. source invariants in runtime.cmake ---------------------------
print("[8] runtime.cmake source invariants")
text = RUNTIME_CMAKE.read_text(encoding="utf-8", errors="replace")
main_text = MAIN_CPP.read_text(encoding="utf-8", errors="replace")
check(
"PRELOADED_MODS_DIR" in text,
"runtime.cmake accepts PRELOADED_MODS_DIR so titles never name the layout",
)
check(
"set(oneValueArgs" in text
and "PRELOADED_MODS_DIR" in text[text.find("set(oneValueArgs") : text.find("set(multiValueArgs")],
"psxrecomp_add_game_runtime parses PRELOADED_MODS_DIR before multi-value args",
)
check(
"cmake_language(DEFER CALL _psxrt_finalize_mod_catalog_guards" in text,
"the build-time guard is deferred so sibling runtime targets are known",
)
check(
'add_custom_target("${_stage_target}"' in text
and 'add_custom_target("${_guard_target}"' in text
and 'add_dependencies(${_t} "${_guard_target}")' in text,
"catalog staging and validation are portable prerequisite targets",
)
check(
"psx_check_mod_catalog.cmake" in text,
"runtime.cmake wires the guard script into the build",
)
check(
"psx_mod_catalog_select.cmake" in text
and "psx_select_builtin_mod_ids(" in text,
"runtime.cmake stages builtins through the tested selection function",
)
check(
text.count("EXCLUDE_BUILTIN_MODS)") >= 2
and "${PSXRT_EXCLUDE_BUILTIN_MODS}" in text
and "psx_split_exclude_builtin_mods(_psxg_exclude_ids" in text
and "EXCLUDE_BUILTIN_MODS ${_psxg_exclude_ids}" in text,
"both psxrecomp_add_runtime_target and psxrecomp_add_game_runtime "
"parse EXCLUDE_BUILTIN_MODS as a multi-value arg and pass it on",
)
check(
"-DPSX_CATALOG_EXCLUDED=" in text and ".excluded.txt" in text,
"the excluded-id list is published and handed to the build-time guard",
)
forwarded_args = "${_psxg_forwarded_args}"
wrapper_extras = "EXTRAS_SOURCES ${_psxg_extras}"
check(
text.find(forwarded_args) != -1
and text.find(wrapper_extras) != -1
and text.find(forwarded_args) < text.find(wrapper_extras),
"psxrecomp_add_game_runtime forwards target args before EXTRAS_SOURCES",
)
setup_guard = "if(PSX_RECOMP_UI AND PSXG_CODEGEN_SETUP_SOURCES)"
setup_host = '"${PSXRECOMP_ROOT}/host/psxrecomp_codegen_host.c"'
check(
text.find(setup_guard) != -1
and text.find(setup_host) != -1
and text.find(setup_guard) < text.find(setup_host),
"psxrecomp_add_game_runtime only adds setup host sources when requested",
)
check(
"defined(PSX_HAS_GAME_CODEGEN)\nextern \"C\" void psx_game_codegen_setup_apply"
not in main_text
and "defined(PSX_HAS_GAME_CODEGEN)\n psx_game_codegen_setup_apply"
not in main_text
and "defined(PSX_HAS_GAME_CODEGEN)\n psx_game_codegen_setup_apply"
not in main_text
and "defined(PSX_HAS_GAME_CODEGEN)\n if (lr == RECOMP_LAUNCHER_RESULT_RELAUNCH)"
not in main_text
and "defined(PSX_HAS_GAME_CODEGEN)\n if (rui_rc == RECOMP_LAUNCHER_RESULT_RELAUNCH)"
not in main_text,
"setup-host symbols in main.cpp are gated by PSX_HAS_CODEGEN_SETUP_HOST",
)
# The only mods/packages reference left in the staging code must be the
# legacy purge -- never a copy destination.
bad = [
line.strip()
for line in text.splitlines()
if "mods/bundled" not in line
and "mods/packages" in line
and "copy_directory" in line
]
check(not bad, f"no copy_directory targets mods/packages (found {bad})")
print()
if _failures:
print(f"FAILED: {len(_failures)} check(s)")
for f in _failures:
print(f" - {f}")
return 1
print("mod catalog layout guard: all checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())