From da0c98b936f19ce0eaac27a2bbb6de0053a5f231 Mon Sep 17 00:00:00 2001 From: Zap0xfce2 <902946+zap0xfce2@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:45:02 +0200 Subject: [PATCH] feat(runtime): MEMCARD_SLOTS for titles that only use port 1 (#704) --- runtime/CMakeLists.txt | 4 ++ runtime/runtime.cmake | 17 +++++ runtime/src/main.cpp | 11 +++- .../tests/test_game_runtime_memcard_slots.py | 66 +++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 runtime/tests/test_game_runtime_memcard_slots.py diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 0340c49d..ea1d52bc 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -2127,6 +2127,10 @@ if(BUILD_TESTING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_game_runtime_pgxp.py --cmake ${CMAKE_COMMAND}) + add_test(NAME game_runtime_memcard_slots_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_game_runtime_memcard_slots.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 f4d8d849..3879f5f4 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -1453,6 +1453,18 @@ function(_psxrt_stage_mod_catalog target preloaded_dir) endif() endfunction() +# Memory-card slots a title uses (MEMCARD_SLOTS, default 2). 1 for games that +# only talk to port 1: the launcher hides slot 2 and the runtime leaves it empty. +function(psxrt_resolve_memcard_slots out_var value) + if(value STREQUAL "") + set(value 2) + endif() + if(NOT "${value}" MATCHES "^[12]$") + message(FATAL_ERROR "MEMCARD_SLOTS must be 1 or 2 (got ${value})") + endif() + set(${out_var} ${value} PARENT_SCOPE) +endfunction() + function(psxrecomp_add_runtime_target target) # PGXP: build this target's objects with -DPSX_PGXP=1 so the PGXP_*() # hook macros the emitter writes into ALL generated C become real calls @@ -1479,6 +1491,7 @@ function(psxrecomp_add_runtime_target target) EXE_NAME GAME_VERSION MAX_PLAYERS + MEMCARD_SLOTS APP_ICON # The title's own mod catalog source directory -- the one shaped like # /mods/preloaded, holding packages///manifest.toml @@ -2010,6 +2023,8 @@ function(psxrecomp_add_runtime_target target) endif() message(STATUS "psxrecomp ${target}: PSX_MAX_PLAYERS=${PSXRT_MAX_PLAYERS}") + psxrt_resolve_memcard_slots(PSXRT_MEMCARD_SLOTS "${PSXRT_MEMCARD_SLOTS}") + target_compile_definitions(${target} PRIVATE DEFAULT_DEBUG_PORT=${PSXRT_DEBUG_PORT} PSX_DEFAULT_BIOS_PATH="${PSXRT_DEFAULT_BIOS_PATH}" @@ -2025,6 +2040,7 @@ function(psxrecomp_add_runtime_target target) PSX_DEFAULT_GAME_CONFIG_PATH="${PSXRT_DEFAULT_GAME_CONFIG_PATH}" PSX_WINDOW_TITLE="${PSXRT_WINDOW_TITLE}" PSX_MAX_PLAYERS=${PSXRT_MAX_PLAYERS} + PSX_MEMCARD_SLOTS=${PSXRT_MEMCARD_SLOTS} FMT_HEADER_ONLY=1 $<$:NOMINMAX> $<$:PSX_SDL3=1> @@ -2630,6 +2646,7 @@ endfunction() # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga" # APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico" # MAX_PLAYERS 2 +# MEMCARD_SLOTS 1 # optional; title only uses memory card port 1 # ENABLE_NETPLAY_IF_PRESENT # ENABLE_SETUP_WIZARD # PRELOADED_MODS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded" diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b46655f8..4cf6c749 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -82,6 +82,9 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif +#ifndef PSX_MEMCARD_SLOTS +#define PSX_MEMCARD_SLOTS 2 /* runtime.cmake MEMCARD_SLOTS */ +#endif #include "psx_netplay.h" #include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ #include "psx_trigger.h" /* continuous SDL trigger -> 0..255 magnitude */ @@ -16552,6 +16555,9 @@ namespace { * the generic Display -> View mode launcher row. */ gi->widescreen_supported = 0; gi->num_players = game_players_n; +#if defined(RECOMP_LAUNCHER_HAS_MEMCARD_SLOTS) + gi->memcard_slots = PSX_MEMCARD_SLOTS; +#endif #if defined(RECOMP_LAUNCHER_HAS_SHARED_GAMEPAD) && !defined(PSX_NO_DEBUG_TOOLS) gi->allow_shared_gamepad = 1; #endif @@ -20715,9 +20721,12 @@ session_reboot: { std::string mc1 = memcard1_path.string(); std::string mc2 = memcard2_path.string(); + /* A one-slot title (MEMCARD_SLOTS 1) never reads port 2: leave it empty + * whatever settings.toml or the launcher stored. */ + const bool slot2_on = memcard2_enabled && PSX_MEMCARD_SLOTS >= 2; const MemcardSlotConfig slots[2] = { { mc1.empty() ? nullptr : mc1.c_str(), memcard1_enabled ? 1 : 0 }, - { mc2.empty() ? nullptr : mc2.c_str(), memcard2_enabled ? 1 : 0 }, + { mc2.empty() ? nullptr : mc2.c_str(), slot2_on ? 1 : 0 }, }; memcard_init_slots(memcard_dir_str.c_str(), slots); } diff --git a/runtime/tests/test_game_runtime_memcard_slots.py b/runtime/tests/test_game_runtime_memcard_slots.py new file mode 100644 index 00000000..4fd34376 --- /dev/null +++ b/runtime/tests/test_game_runtime_memcard_slots.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""psxrecomp_add_game_runtime(... MEMCARD_SLOTS n): runs the REAL +psxrt_resolve_memcard_slots from runtime.cmake under `cmake -P`. Unset keeps +both slots, 1 and 2 pass through, anything else fails the configure. +""" +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) + + +def resolve(cmake: str, body: str, value: str): + with tempfile.TemporaryDirectory() as td: + driver = Path(td) / "driver.cmake" + driver.write_text(body + '\npsxrt_resolve_memcard_slots(_n "%s")\n' + 'message(STATUS "SLOTS=${_n}")\n' % value) + r = subprocess.run([cmake, "-P", str(driver)], capture_output=True, text=True) + m = re.search(r"SLOTS=(\S+)", r.stdout + r.stderr) + return (m.group(1) if r.returncode == 0 and m else None) + + +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"), + "psxrt_resolve_memcard_slots") + cases = (("", "2", "unset keeps both slots"), + ("1", "1", "a port-1-only title gets one slot"), + ("2", "2", "two slots pass through"), + ("3", None, "three slots fail the configure"), + ("0", None, "zero slots fail the configure"), + ("1.5", None, "fractional slots fail the configure"), + ("one", None, "non-numeric slots fail the configure"), + ("1;2", None, "lists fail the configure")) + failures = 0 + for value, want, what in cases: + ok = resolve(args.cmake, body, value) == want + print(("ok " if ok else "FAIL ") + what) + failures += not ok + if failures: + print("test_game_runtime_memcard_slots: %d FAILURES" % failures) + return 1 + print("test_game_runtime_memcard_slots: all checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())