diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index 8fda1136..cff54664 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -268,6 +268,12 @@ is zero-init. - `NETPLAY_LOBBY_URL "ws://host:port"` — compile-time default lobby URL (`PSX_NET_LOBBY_DEFAULT_URL`; env `PSX_NET_LOBBY_URL` still wins at runtime) - `ENABLE_SETUP_WIZARD` — force `PSX_SETUP_WIZARD=ON` for that target +- `PGXP` — build the title's one runtime as the PGXP hook flavor + (`-DPSX_PGXP=1`, overlay cache flavor `_f2`, no `_pgxp` suffix). The generated + C already carries the hook sites, so `generated/` does not change. PGXP stays + off at runtime until `psx.enhancement.pgxp` or the `[video]` keys arm it; with + it off the guest runs exactly as in the base flavor. Savestates of the two + flavors are not interchangeable. See `ENHANCEMENTS.md` G1.11. Minimal opt-in example: diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 8ede4697..f032baf1 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1049,6 +1049,13 @@ if(BUILD_TESTING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mod_catalog_layout.py --cmake ${CMAKE_COMMAND}) + # psxrecomp_add_game_runtime(... PGXP): the real function body under + # cmake -P, forwarding the hook-flavor option for the title's one + # runtime (docs/ENHANCEMENTS.md G1.11). + add_test(NAME game_runtime_pgxp_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_game_runtime_pgxp.py + --cmake ${CMAKE_COMMAND}) # tools/mod_channel_filter.py strips developer features from a staged # catalog; every entry that served one ([[requirement]] included) must # leave with it. diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5463e147..bcb6df95 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -2425,12 +2425,20 @@ endfunction() # ENABLE_SETUP_WIZARD # PRELOADED_MODS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded" # EXCLUDE_BUILTIN_MODS psx.presentation.bezel # optional; builtins not shipped +# PGXP # optional; the runtime is the PGXP hook flavor # ) # # Remaining args are forwarded to psxrecomp_add_runtime_target. # --------------------------------------------------------------------------- function(psxrecomp_add_game_runtime target) - set(options ENABLE_NETPLAY_IF_PRESENT ENABLE_SETUP_WIZARD) + # PGXP: build the title's ONE runtime as the PGXP hook flavor (see the PGXP + # option of psxrecomp_add_runtime_target): -DPSX_PGXP=1, overlay flavor 2, + # no _pgxp suffix and no clone beside it. The generated C already carries + # the PGXP_*() hook sites, so committed generated/ does not change; only + # the compile define does. Parsed here rather than left to the unparsed + # tail so it cannot be swallowed by a multi-value argument written before + # it (CODEGEN_SETUP_SOURCES would take it as a source file). + set(options ENABLE_NETPLAY_IF_PRESENT ENABLE_SETUP_WIZARD PGXP) set(oneValueArgs GEN_MARKER GEN_FULL_FALLBACK @@ -2623,6 +2631,9 @@ function(psxrecomp_add_game_runtime target) endif() set(_psxg_forwarded_args ${PSXG_UNPARSED_ARGUMENTS}) + if(PSXG_PGXP) + list(APPEND _psxg_forwarded_args PGXP) + endif() if(NOT "${PSXG_PRELOADED_MODS_DIR}" STREQUAL "") list(APPEND _psxg_forwarded_args PRELOADED_MODS_DIR "${PSXG_PRELOADED_MODS_DIR}") diff --git a/runtime/tests/test_game_runtime_pgxp.py b/runtime/tests/test_game_runtime_pgxp.py new file mode 100644 index 00000000..00c556c1 --- /dev/null +++ b/runtime/tests/test_game_runtime_pgxp.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""psxrecomp_add_game_runtime(... PGXP) builds the title's one runtime as the +PGXP hook flavor (docs/ENHANCEMENTS.md G1.11). + +Runs the REAL psxrecomp_add_game_runtime body from runtime.cmake under +`cmake -P`, with psxrecomp_add_runtime_target and the target-scoped commands +stubbed to record what they receive. Checks that: + + 1. without PGXP nothing PGXP-related is forwarded (default unchanged); + 2. PGXP is forwarded to psxrecomp_add_runtime_target, as the bare option + that selects the hook flavor for the primary (no PGXP_CLONE, so no _pgxp + suffix and no sibling); + 3. PGXP written after CODEGEN_SETUP_SOURCES is still the option, not a + source file (it used to be swallowed by the multi-value argument); + 4. the setup-host path (no generated C) forwards it the same way. +""" +import argparse +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +RUNTIME_CMAKE = REPO / "runtime" / "runtime.cmake" + + +def extract_function(text: str, name: str) -> str: + m = re.search(r"^function\(%s\b.*?^endfunction\(\)" % re.escape(name), + text, re.S | re.M) + if not m: + raise SystemExit("FAIL: %s not found in runtime.cmake" % name) + return m.group(0) + + +STUBS = r''' +cmake_minimum_required(VERSION 3.20) +set(CMAKE_CURRENT_SOURCE_DIR "@SRC@") +set(PSXRECOMP_ROOT "@SRC@/psxrecomp") +set(CMAKE_BUILD_TYPE Release) +set(PSX_RECOMP_UI OFF) +function(psxrecomp_add_runtime_target target) + set_property(GLOBAL PROPERTY RT_TARGET "${target}") + set_property(GLOBAL PROPERTY RT_ARGS "${ARGN}") +endfunction() +function(psx_split_exclude_builtin_mods ids tail) + set(${ids} "${ARGN}" PARENT_SCOPE) + set(${tail} "" PARENT_SCOPE) +endfunction() +function(target_compile_definitions) +endfunction() +function(target_include_directories) +endfunction() +''' + +REPORT = r''' +get_property(_t GLOBAL PROPERTY RT_TARGET) +get_property(_a GLOBAL PROPERTY RT_ARGS) +message(STATUS "RT_TARGET=${_t}") +message(STATUS "RT_ARGS=${_a}") +''' + + +def run_case(cmake: str, body: str, call: str, game_c: bool): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + (tmp / "generated").mkdir() + if game_c: + (tmp / "generated" / "GAME_dispatch.c").write_text("/* stub */\n") + (tmp / "generated" / "GAME_full.c").write_text("/* stub */\n") + (tmp / "VERSION").write_text("1.2.3\n") + driver = tmp / "driver.cmake" + driver.write_text(STUBS.replace("@SRC@", tmp.as_posix()) + body + + "\n" + call + "\n" + REPORT) + r = subprocess.run([cmake, "-P", str(driver)], capture_output=True, + text=True) + out = r.stdout + r.stderr + if r.returncode != 0: + return None, None, out + target = re.search(r"RT_TARGET=(.*)", out).group(1).strip() + args = re.search(r"RT_ARGS=(.*)", out).group(1).strip().split(";") + return target, args, out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--cmake", default=shutil.which("cmake") or "cmake") + args = ap.parse_args() + if shutil.which(args.cmake) is None and not Path(args.cmake).is_file(): + print("SKIP: no cmake executable available (%s)" % args.cmake) + return 0 + body = extract_function(RUNTIME_CMAKE.read_text(encoding="utf-8"), + "psxrecomp_add_game_runtime") + failures = 0 + + def check(ok, what, out=""): + nonlocal failures + print(("ok " if ok else "FAIL ") + what) + if not ok: + failures += 1 + if out: + print(out) + + base = ('psxrecomp_add_game_runtime(psx-runtime\n' + ' WINDOW_TITLE "Probe"\n' + ' GEN_MARKER "generated/GAME_dispatch.c"\n' + ' GEN_FULL_GLOB "generated/GAME_full.c"\n' + ' @EXTRA@)') + + t, a, out = run_case(args.cmake, body, base.replace("@EXTRA@", ""), True) + check(a is not None, "configure without PGXP runs", out) + if a is not None: + check(t == "psx-runtime", "the primary target is psx-runtime") + check("PGXP" not in a and "PGXP_CLONE" not in a, + "without PGXP nothing PGXP-related is forwarded") + + t, a, out = run_case(args.cmake, body, base.replace("@EXTRA@", "PGXP"), True) + check(a is not None, "configure with PGXP runs", out) + if a is not None: + check(t == "psx-runtime", "PGXP keeps the primary target name") + check(a.count("PGXP") == 1, "PGXP is forwarded once") + check("PGXP_CLONE" not in a, + "the primary is not marked as the _pgxp clone (no suffix)") + + late = base.replace( + "@EXTRA@", + 'CODEGEN_SETUP_SOURCES "codegen_setup.c"\n PGXP') + t, a, out = run_case(args.cmake, body, late, True) + check(a is not None, "configure with PGXP after CODEGEN_SETUP_SOURCES runs", + out) + if a is not None: + check("PGXP" in a, + "PGXP after a multi-value argument is still the option") + + t, a, out = run_case(args.cmake, body, base.replace("@EXTRA@", "PGXP"), + False) + check(a is not None, "setup-host configure with PGXP runs", out) + if a is not None: + check("PGXP" in a, "the setup host gets the same flavor") + + if failures: + print("test_game_runtime_pgxp: %d FAILURES" % failures) + return 1 + print("test_game_runtime_pgxp: all checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())