diff --git a/docs/PLAYTEST_DIAGNOSTICS.md b/docs/PLAYTEST_DIAGNOSTICS.md index 6a5e1caa..be7bebf3 100644 --- a/docs/PLAYTEST_DIAGNOSTICS.md +++ b/docs/PLAYTEST_DIAGNOSTICS.md @@ -67,8 +67,8 @@ offline recorder does not claim to reproduce network sessions. ## Replay -Use Python 3.11+ with the recording's original native executable, disc and game -configuration. The tool copies the executable, runtime dependencies and +Use Python 3.8+ (install `tomli` before Python 3.11) with the recording's original +native executable, disc and game configuration. The tool copies the executable, runtime dependencies and starting state into a newly created private directory. Personal settings/cards and the original installation are untouched. diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index c9d1c25b..5e202407 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1136,6 +1136,7 @@ if(BUILD_TESTING) # template, scaffold, check_generated.sh, Project Studio audit/ops). foreach(_t IN ITEMS aligned_lzss_banks + audit_playtest_diagnostics_package bundled_release_layout collect_game_misses cursor_writers diff --git a/runtime/tests/test_setup_bios_assets.py b/runtime/tests/test_setup_bios_assets.py index 2adde84d..9e6e5705 100644 --- a/runtime/tests/test_setup_bios_assets.py +++ b/runtime/tests/test_setup_bios_assets.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 """Exercise the production SDK BIOS gate with synthetic files only.""" import importlib.util +import os +import shutil +import subprocess import tempfile import unittest from pathlib import Path @@ -28,6 +31,19 @@ class GateTests(unittest.TestCase): self.asset("SCPH5552.toml") self.assertEqual(gate.check(self.root), [str(Path("psxrecomp/bios/SCPH5552.toml"))]) + def test_omit_requires_retail_recipe_and_no_openbios_assets(self): + self.recipe('[runtime]\nopenbios=true\n') + with self.assertRaisesRegex(ValueError, "requires runtime.openbios=false"): + gate.check(self.root, omit_openbios=True) + self.recipe('[runtime]\nopenbios=false\n') + self.asset("SCPH1001.toml") + self.assertEqual(len(gate.check(self.root, omit_openbios=True)), 1) + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + self.asset(name) + with self.assertRaisesRegex(ValueError, "survived"): + gate.check(self.root, omit_openbios=True) + (self.root / "psxrecomp/bios" / name).unlink() + def test_retail_missing_profile_fails(self): self.recipe('[runtime]\nopenbios=false\n[recompiler]\nbios_config="psxrecomp/bios/SCPH5552.toml"\n') with self.assertRaisesRegex(ValueError, "SCPH5552"): @@ -118,5 +134,87 @@ class GateTests(unittest.TestCase): self.assertEqual(len(gate.check(self.root)), 3) +@unittest.skipIf(os.name == "nt" or not shutil.which("bash"), + "full shell packaging fixture requires POSIX bash") +class SetupPackageTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + for path in ("build/assets/fonts/font.txt", "build/assets/img/image.txt", + "build/mods/bundled/fixture/1.0.0/manifest.toml", + "mods/preloaded/packages/fixture/1.0.0/manifest.toml", + "psxrecomp/psxrecomp_cli.py", "recomp-ui/CMakeLists.txt", + "emitters/psxrecomp-game", "emitters/psxrecomp-bios"): + self.write(path, "synthetic fixture\n") + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE", "SCPH1001.toml"): + self.write("psxrecomp/bios/" + name, "synthetic BIOS fixture") + self.write("CMakeLists.txt", "# synthetic title\n") + self.write("VERSION", "0.0.1\n") + self.write("build/psx_game_version.txt", "0.0.1\n") + self.write("build/Fixture", "synthetic executable, never run\n") + self.write("build/replay-diagnostics.py", "# staged replay helper\n") + # Packaging tests exercise staging, not a system compression utility. + # Ubuntu developer images may omit zip; write a real ZIP with stdlib. + self.write("fixture-bin/zip", '''#!/usr/bin/env python3 +import pathlib, sys, zipfile +with zipfile.ZipFile(sys.argv[-2], "w") as archive: + for path in pathlib.Path(".").rglob("*"): + if path.is_file(): + archive.write(path, path.as_posix()) +''') + (self.root / "fixture-bin/zip").chmod(0o755) + + def write(self, name, text): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def package(self, retail): + self.write("game.toml", "[runtime]\noverlay_cache=true\nopenbios=" + + ("false" if retail else "true") + "\n") + args = ["bash", str(ROOT / "tools/package_setup_host.sh"), + "--root", str(self.root), "--build-dir", "build", "--artifact", "fixture", + "--zip-prefix", "test", "--exe-name", "Fixture", "--recompiler-build", "emitters", + "--project-file", "CMakeLists.txt", "--project-file", "game.toml"] + if retail: + args += ["--omit-openbios", "--bios-hint", "a legal retail BIOS dump (required)"] + env = dict(os.environ, RELEASE_VERSION="0.0.1", EXCLUDE_DEV_MODS="0") + env["PATH"] = str(self.root / "fixture-bin") + os.pathsep + env["PATH"] + return subprocess.run(args, cwd=self.root, env=env, capture_output=True, text=True) + + def test_default_and_retail_packages_use_real_sdk_gate_and_keep_helper(self): + for retail in (False, True): + with self.subTest(retail=retail): + result = self.package(retail) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + stage = self.root / "dist/stage-setup-fixture" + self.assertTrue((stage / "replay-diagnostics.py").is_file()) + self.assertTrue((stage / "psxrecomp/bios/SCPH1001.toml").is_file()) + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + self.assertEqual((stage / "psxrecomp/bios" / name).exists(), not retail) + readme = (stage / "README-SETUP.txt").read_text() + if retail: + self.assertIn("a legal retail BIOS dump (required)", readme) + self.assertNotIn("otherwise OpenBIOS", readme) + else: + self.assertIn("otherwise OpenBIOS is regenerated locally", readme) + + def test_sdk_omission_runs_after_overlay_and_rejects_openbios_recipe(self): + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + self.write("overlay/bios/" + name, "overlay BIOS fixture") + self.write("game.toml", "[runtime]\nopenbios=false\n") + args = ["bash", str(ROOT / "tools/stage_setup_sdk.sh"), "--stage", str(self.root), + "--framework", str(self.root / "psxrecomp"), "--sdk-overlay", str(self.root / "overlay"), + "--recompiler-build", str(self.root / "emitters"), "--allow-no-toolchain", "--omit-openbios"] + result = subprocess.run(args, cwd=self.root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse((self.root / "psxrecomp/bios/openbios.bin").exists()) + self.write("game.toml", "[runtime]\nopenbios=true\n") + result = subprocess.run(args, cwd=self.root, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("requires runtime.openbios=false", result.stderr) + + if __name__ == "__main__": unittest.main() diff --git a/tools/check_setup_bios_assets.py b/tools/check_setup_bios_assets.py index fb534134..992f0984 100644 --- a/tools/check_setup_bios_assets.py +++ b/tools/check_setup_bios_assets.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Require BIOS assets selected by the staged title recipe.""" +import argparse import sys from pathlib import Path try: @@ -8,7 +9,7 @@ except ModuleNotFoundError: import tomli as tomllib -def check(stage): +def check(stage, omit_openbios=False): stage = Path(stage).resolve() recipe = stage / "game.toml" if not recipe.is_file(): @@ -18,6 +19,13 @@ def check(stage): openbios = config.get("runtime", {}).get("openbios", True) if not isinstance(openbios, bool): raise ValueError("runtime.openbios must be a boolean") + if omit_openbios and openbios: + raise ValueError("--omit-openbios requires runtime.openbios=false") + if omit_openbios: + for root in (stage / "bios", stage / "psxrecomp/bios"): + for name in ("OpenBIOS.toml", "openbios.bin", "OpenBIOS.LICENSE"): + if (root / name).exists(): + raise ValueError("OpenBIOS asset survived --omit-openbios: " + str(root / name)) profile = config.get("recompiler", {}).get("bios_config") required = [] if profile: @@ -40,7 +48,11 @@ def check(stage): if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("stage") + parser.add_argument("--omit-openbios", action="store_true") + args = parser.parse_args() try: - print("staged BIOS policy: " + ", ".join(check(sys.argv[1]))) + print("staged BIOS policy: " + ", ".join(check(args.stage, args.omit_openbios))) except (ValueError, OSError) as error: sys.exit("error: " + str(error)) diff --git a/tools/ci/audit_playtest_diagnostics_package.py b/tools/ci/audit_playtest_diagnostics_package.py new file mode 100644 index 00000000..92254955 --- /dev/null +++ b/tools/ci/audit_playtest_diagnostics_package.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Audit a flat game/setup ZIP without extracting or launching its contents.""" +import argparse +from pathlib import PurePosixPath +import re +import sys +import zipfile + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +HD_SURFACE = re.compile(r"hd[ ._-]*(?:textures?|packs?)|texture[ ._-]*packs?", re.I) +# These checked-in synthetic cards are SDK test inputs, never player saves. +SDK_CARD_FIXTURES = { + "psxrecomp/runtime/tests/fixtures/memcards/card1.mcd", + "psxrecomp/runtime/tests/fixtures/memcards/card2.mcd", + "psxrecomp/runtime/tests/fixtures/memcards/dummy.0.mcr", + "psxrecomp/runtime/tests/fixtures/memcards/dummy.1.mcr", + "psxrecomp/runtime/tests/test_card_synth.mcd", + "psxrecomp/runtime/tests/test_dir/card1.mcd", +} +PERSONAL_NAMES = { + "state.toml", "state.toml.tmp", "settings.toml", "keybinds.ini", + "disc.cfg", "bios.cfg", "overlay_captures.json", "diagnostics-location.txt", +} + + +def audit(path, game_id, diagnostics_id): + with zipfile.ZipFile(path) as archive: + files = {} + for entry in archive.infolist(): + name = entry.filename.replace("\\", "/") + parts = PurePosixPath(name).parts + if name.startswith("/") or ".." in parts or (parts and ":" in parts[0]): + raise ValueError("unsafe archive path: " + name) + if not entry.is_dir(): + if name in files: + raise ValueError("duplicate archive entry: " + name) + files[name] = entry + # Some publishers put the same flat payload inside one named folder. + if "game.toml" not in files: + recipes = [name for name in files if name.count("/") == 1 and name.endswith("/game.toml")] + if len(recipes) == 1: + prefix = recipes[0].rsplit("/", 1)[0] + "/" + if all(name.startswith(prefix) for name in files): + files = {name[len(prefix):]: entry for name, entry in files.items()} + + def read(name): + entry = files.get(name) + if entry is None or entry.file_size == 0: + raise ValueError("missing or empty package file: " + name) + if entry.file_size > 4 * 1024 * 1024: + raise ValueError("unexpectedly large package metadata: " + name) + return archive.read(entry) + + def executable(name, entry): + if "/" in name: + return False + if name.lower().endswith(".exe"): + return entry.file_size > 0 + with archive.open(entry) as stream: + return stream.read(4) == b"\x7fELF" + + recipe = tomllib.loads(read("game.toml").decode("utf-8-sig")) + if recipe.get("game", {}).get("id") != game_id: + raise ValueError("game.toml game ID does not match " + game_id) + read("replay-diagnostics.py") + if not any(executable(name, entry) for name, entry in files.items()): + raise ValueError("no executable adjacent to replay-diagnostics.py") + + runtime = [] + for name in sorted(files): + lower = name.lower() + parts = PurePosixPath(lower).parts + leaf = parts[-1] + if leaf in PERSONAL_NAMES or "saves" in parts or "diagnostics" in parts or ( + lower.startswith("mods/installed/")): + raise ValueError("personal state in package: " + name) + suffix = PurePosixPath(lower).suffix + if recipe.get("runtime", {}).get("openbios") is False and lower in { + root + leaf for root in ("bios/", "psxrecomp/bios/") + for leaf in ("openbios.bin", "openbios.toml", "openbios.license")}: + raise ValueError("OpenBIOS asset in retail-BIOS-only package: " + name) + if suffix in {".cue", ".iso", ".img", ".chd", ".mcd", ".mcr", ".rom", ".pst"}: + if name not in SDK_CARD_FIXTURES: + raise ValueError("disc, BIOS or save payload in package: " + name) + if suffix == ".bin" and lower not in {"bios/openbios.bin", "psxrecomp/bios/openbios.bin"}: + raise ValueError("unexpected binary image in package: " + name) + if re.match(r"scph.*\.(bin|rom)(?:\..*)?$", leaf): + raise ValueError("retail BIOS image in package: " + name) + # The SDK retains its framework built-in authoring catalog. The + # title's CMake exclusion controls whether it reaches runtime. + if lower.startswith("mods/") and suffix in {".toml", ".json"}: + text = read(name).decode("utf-8-sig") + if HD_SURFACE.search(text) or HD_SURFACE.search(name): + raise ValueError("HD texture surface in title/runtime catalog: " + name) + if leaf == "manifest.toml": + manifest = tomllib.loads(text) + if lower.startswith("mods/bundled/" + diagnostics_id.lower() + "/") and manifest.get("id") == diagnostics_id: + runtime.append((name, manifest)) + if manifest.get("id") == diagnostics_id: + validate_diagnostics(manifest, name, game_id, diagnostics_id) + if not runtime: + raise ValueError("runtime mods/bundled catalog is missing " + diagnostics_id) + return len(runtime) + + +def validate_diagnostics(manifest, name, game_id, diagnostics_id): + if not any(target.get("game_id") == game_id for target in manifest.get("target", [])): + raise ValueError("diagnostics manifest has the wrong game target: " + name) + features = {feature.get("id"): feature for feature in manifest.get("feature", [])} + for feature_id, plugin_id in (("recording", diagnostics_id), ("osd", diagnostics_id + ".osd")): + feature = features.get(feature_id, {}) + if feature.get("default_enabled") is not True or feature.get("channel") == "developer": + raise ValueError("diagnostics " + feature_id + " must ship enabled by default: " + name) + if not any(plugin.get("feature") == feature_id and plugin.get("id") == plugin_id + for plugin in manifest.get("plugin", [])): + raise ValueError("diagnostics " + feature_id + " plugin is missing: " + name) + if manifest.get("channel") == "developer": + raise ValueError("diagnostics package cannot be developer-only: " + name) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("zip") + parser.add_argument("--game-id", required=True) + parser.add_argument("--diagnostics-id", required=True) + args = parser.parse_args() + try: + count = audit(args.zip, args.game_id, args.diagnostics_id) + except (ValueError, OSError, zipfile.BadZipFile, KeyError, UnicodeError) as error: + sys.exit("diagnostics package audit failed: " + str(error)) + print("Diagnostics package audit passed: {} ({} runtime manifest(s))".format(args.diagnostics_id, count)) + + +if __name__ == "__main__": + main() diff --git a/tools/package_setup_host.sh b/tools/package_setup_host.sh index 000c9215..426939d2 100755 --- a/tools/package_setup_host.sh +++ b/tools/package_setup_host.sh @@ -22,6 +22,8 @@ # [--project-file REL]... [--project-dir REL]... \ # [--runtime-dir NAME]... [--runtime-dir-optional NAME]... \ # [--disc-hint "your legally owned disc"] \ +# [--bios-hint "an optional retail SCPH-1001 BIOS; otherwise OpenBIOS"] \ +# [--omit-openbios] # retail-BIOS-only titles; game.toml openbios=false # [--version-env BPE_RELEASE_VERSION] \ # [--embed-toolchain] # optional: copy PSXRECOMP_TOOLCHAIN_DIR into zip # @@ -69,6 +71,8 @@ DISPLAY_NAME="" RECOMPILER_BUILD="build-recompiler" VERSION_ENV="RELEASE_VERSION" DISC_HINT="your legally owned game disc" +BIOS_HINT="" +OMIT_OPENBIOS=0 # Wave-5 F4: a packaged game.toml without `overlay_cache = true` ships a runtime that never # initialises the overlay loader, so every streamed overlay runs on the interpreter (78 of # 105 releases measured 2026-09-16). Refuse unless the caller states why. @@ -113,6 +117,8 @@ while [[ $# -gt 0 ]]; do --recompiler-build) RECOMPILER_BUILD="${2:?}"; shift 2 ;; --version-env) VERSION_ENV="${2:?}"; shift 2 ;; --disc-hint) DISC_HINT="${2:?}"; shift 2 ;; + --bios-hint) BIOS_HINT="${2:?}"; shift 2 ;; + --omit-openbios) OMIT_OPENBIOS=1; shift ;; --project-file) PROJECT_FILES+=("${2:?}"); shift 2 ;; --project-dir) PROJECT_DIRS+=("${2:?}"); shift 2 ;; --no-mods) STAGE_MODS=0; shift ;; @@ -132,6 +138,14 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -z "${BIOS_HINT}" ]]; then + if [[ "${OMIT_OPENBIOS}" -eq 1 ]]; then + BIOS_HINT="a legal retail SCPH-1001 BIOS dump (required)" + else + BIOS_HINT="an optional retail SCPH-1001 BIOS; otherwise OpenBIOS is regenerated locally" + fi +fi + # Default the mods catalog into the required set unless the caller already # named it (in either list) or opted out. Appending rather than overriding # keeps an explicit --runtime-dir-optional mods meaningful for a title that @@ -485,6 +499,9 @@ stage_args=( --host-exe "${STAGE}/${EXE_BASENAME}" --recompiler-build "${RECOMPILER_BUILD}" ) +if [[ "${OMIT_OPENBIOS}" -eq 1 ]]; then + stage_args+=(--omit-openbios) +fi if [[ "${EMBED_TOOLCHAIN}" -eq 1 ]]; then if [[ -z "${PSXRECOMP_TOOLCHAIN_DIR:-${TOOLCHAIN_DIR:-${BPE_TOOLCHAIN_DIR:-}}}" ]]; then echo "error: --embed-toolchain requires PSXRECOMP_TOOLCHAIN_DIR (or TOOLCHAIN_DIR)" >&2 @@ -519,8 +536,7 @@ BIOS dumps, pre-generated game C, or a portable cmake/clang pack. Emitters Standalone: 1. Install Python 3. 2. Run ${EXE_BASENAME}. -3. Provide ${DISC_HINT} (and optional retail SCPH-1001 BIOS; otherwise - OpenBIOS is regenerated locally). +3. Provide ${DISC_HINT} and ${BIOS_HINT}. 4. Follow the Generate & rebuild wizard. On first rebuild the host downloads cmake-clang-v1 from RetroPortingToolKit/RetroPorting-Toolchains (or you can pick a local cmake-clang-v1-*.zip for offline builds). System cmake/ninja diff --git a/tools/replay_diagnostics.py b/tools/replay_diagnostics.py index acb8f0fa..5ed83869 100644 --- a/tools/replay_diagnostics.py +++ b/tools/replay_diagnostics.py @@ -29,7 +29,10 @@ import re import shutil import subprocess import time -import tomllib +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib FORMATS = (b'# psxrecomp-input-v1', b'# psxrecomp-input-v2') ENV_PREFIXES = ('PSX_', 'RNET_', 'LNG_', 'PSX_DIAGNOSTIC_', 'RECOMP_') diff --git a/tools/stage_setup_sdk.sh b/tools/stage_setup_sdk.sh index 2a739dee..bf04e655 100755 --- a/tools/stage_setup_sdk.sh +++ b/tools/stage_setup_sdk.sh @@ -19,6 +19,7 @@ # --runtime-bin DIR MinGW runtime DLL search dir (repeatable) # --search-dir DIR Extra DLL search dir (repeatable) # --require-cli Require psxrecomp_cli.py after overlays (default on) +# --omit-openbios Remove OpenBIOS assets for a retail-BIOS-only recipe # # Env aliases for toolchain dir (first wins): # PSXRECOMP_TOOLCHAIN_DIR, TOOLCHAIN_DIR, BPE_TOOLCHAIN_DIR @@ -39,6 +40,7 @@ HOST_EXE="" RUNTIME_BINS=() SEARCH_DIRS=() REQUIRE_CLI=1 +OMIT_OPENBIOS=0 usage() { sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//' @@ -59,6 +61,7 @@ while [[ $# -gt 0 ]]; do --search-dir) SEARCH_DIRS+=("${2:?}"); shift 2 ;; --require-cli) REQUIRE_CLI=1; shift ;; --no-require-cli) REQUIRE_CLI=0; shift ;; + --omit-openbios) OMIT_OPENBIOS=1; shift ;; *) echo "error: unknown arg: $1" >&2 usage @@ -209,7 +212,16 @@ else echo "error: Python is required to check the staged BIOS policy" >&2 exit 1 fi -"${SDK_PYTHON}" "${SCRIPT_DIR}/check_setup_bios_assets.py" "${STAGE}" +bios_args=() +if [[ "${OMIT_OPENBIOS}" -eq 1 ]]; then + # Run after overlays so they cannot restore an unwanted BIOS surface. + for bios_root in "${STAGE}/bios" "${STAGE}/psxrecomp/bios"; do + rm -f "${bios_root}/openbios.bin" "${bios_root}/OpenBIOS.toml" \ + "${bios_root}/OpenBIOS.LICENSE" + done + bios_args+=(--omit-openbios) +fi +"${SDK_PYTHON}" "${SCRIPT_DIR}/check_setup_bios_assets.py" "${STAGE}" "${bios_args[@]}" if [[ "${REQUIRE_CLI}" -eq 1 && ! -f "${STAGE}/psxrecomp/psxrecomp_cli.py" ]]; then echo "error: missing psxrecomp/psxrecomp_cli.py in staged tree" >&2 diff --git a/tools/tests/test_audit_playtest_diagnostics_package.py b/tools/tests/test_audit_playtest_diagnostics_package.py new file mode 100644 index 00000000..d6d4cde6 --- /dev/null +++ b/tools/tests/test_audit_playtest_diagnostics_package.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Exercise the release ZIP auditor against actual synthetic archives.""" +import importlib.util +import ast +import builtins +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +import warnings +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "tools/ci/audit_playtest_diagnostics_package.py" +spec = importlib.util.spec_from_file_location("package_audit", SCRIPT) +audit = importlib.util.module_from_spec(spec) +spec.loader.exec_module(audit) +DIAG = "fixture.diagnostics" +GAME = "SLUS-00000" +MANIFEST = "mods/bundled/{}/1.0.0/manifest.toml".format(DIAG) +SOURCE = "mods/preloaded/packages/{}/1.0.0/manifest.toml".format(DIAG) +TEXT = '''id = "fixture.diagnostics" +[[target]] +game_id = "SLUS-00000" +[[feature]] +id = "recording" +default_enabled = true +[[feature]] +id = "osd" +default_enabled = true +[[plugin]] +feature = "recording" +id = "fixture.diagnostics" +[[plugin]] +feature = "osd" +id = "fixture.diagnostics.osd" +''' + + +class ArchiveTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.zip = Path(self.tmp.name) / "release.zip" + self.files = {"game.toml": '[game]\nid="SLUS-00000"\n', + "Game.exe": b"MZfixture", "replay-diagnostics.py": "# helper\n", + MANIFEST: TEXT} + + def write(self, prefix=""): + with zipfile.ZipFile(self.zip, "w") as archive: + for name, content in self.files.items(): + archive.writestr(prefix + name, content) + + def check(self): + self.write() + return audit.audit(self.zip, GAME, DIAG) + + def test_valid_flat_and_one_folder_packages(self): + self.assertEqual(self.check(), 1) + self.write("Release/") + self.assertEqual(audit.audit(self.zip, GAME, DIAG), 1) + + def test_valid_setup_sources_and_excluded_framework_hd_catalog(self): + self.files[SOURCE] = TEXT + self.files["psxrecomp/mods/builtin/packages/psx.enhancement.hd-textures/1.0.0/manifest.toml"] = 'id="psx.enhancement.hd-textures"\n' + self.files["psxrecomp/runtime/tests/fixtures/memcards/card1.mcd"] = b"synthetic" + self.assertEqual(self.check(), 1) + + def test_cli_and_native_linux_executable(self): + del self.files["Game.exe"] + self.files["Game"] = b"\x7fELFfixture" + self.write() + result = subprocess.run([sys.executable, str(SCRIPT), str(self.zip), + "--game-id", GAME, "--diagnostics-id", DIAG], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("audit passed", result.stdout) + + def test_python38_syntax_and_tomli_fallback(self): + original_import = builtins.__import__ + + def import_without_tomllib(name, *args, **kwargs): + if name == "tomllib": + raise ModuleNotFoundError("simulated Python 3.8") + if name == "tomli": + return audit.tomllib + return original_import(name, *args, **kwargs) + + for script in (SCRIPT, ROOT / "tools/replay_diagnostics.py", ROOT / "tools/check_setup_bios_assets.py"): + with self.subTest(script=script.name): + ast.parse(script.read_text(encoding="utf-8"), feature_version=8) + with mock.patch("builtins.__import__", side_effect=import_without_tomllib): + namespace = runpy.run_path(str(script)) + self.assertIs(namespace["tomllib"], audit.tomllib) + + def test_each_default_and_plugin_is_required(self): + for bad in (TEXT.replace("default_enabled = true", "default_enabled = false", 1), + TEXT.replace('id = "osd"\ndefault_enabled = true', 'id = "osd"\ndefault_enabled = false'), + TEXT.replace('id = "fixture.diagnostics.osd"', 'id = "wrong.osd"'), + 'channel="developer"\n' + TEXT): + with self.subTest(manifest=bad): + self.files[MANIFEST] = bad + with self.assertRaises(ValueError): + self.check() + + def test_source_catalog_defaults_also_required(self): + self.files[SOURCE] = TEXT.replace("default_enabled = true", "default_enabled = false") + with self.assertRaisesRegex(ValueError, "enabled by default"): + self.check() + + def test_missing_helper_executable_or_runtime_catalog(self): + for name in ("replay-diagnostics.py", "Game.exe", MANIFEST): + with self.subTest(file=name): + content = self.files.pop(name) + with self.assertRaises(ValueError): + self.check() + self.files[name] = content + + def test_wrong_game_recipe_and_manifest_target(self): + self.files["game.toml"] = '[game]\nid="WRONG"\n' + with self.assertRaisesRegex(ValueError, "game ID"): + self.check() + self.files["game.toml"] = '[game]\nid="SLUS-00000"\n' + self.files[MANIFEST] = TEXT.replace(GAME, "WRONG") + with self.assertRaisesRegex(ValueError, "target"): + self.check() + + def test_hd_package_plugin_and_control_surfaces_fail(self): + for name, content in ( + ("mods/bundled/psx.enhancement.hd-textures/1.0.0/manifest.toml", 'id="psx.enhancement.hd-textures"'), + (SOURCE, TEXT + '\n[[plugin]]\nid="psx.enhancement.hd-textures"\n'), + ("mods/preloaded/packages/fixture/1.0.0/options.toml", 'texture_pack_mode="off"\n')): + with self.subTest(file=name): + self.files[name] = content + with self.assertRaisesRegex(ValueError, "HD texture"): + self.check() + del self.files[name] + + def test_personal_state_save_and_retail_images_fail(self): + for name in ("mods/state.toml", "settings.toml", "saves/card1.mcd", "saves/diagnostics/inputs.csv", + "mods/installed/private.psxmod", "SCPH1001.BIN", "disc/game.cue", + "psxrecomp/bios/SCPH5552.rom", "psxrecomp/runtime/tests/private.mcd"): + with self.subTest(file=name): + self.files[name] = "private" + with self.assertRaises(ValueError): + self.check() + del self.files[name] + + def test_retail_only_recipe_rejects_openbios(self): + self.files["game.toml"] += '[runtime]\nopenbios=false\n' + self.assertEqual(self.check(), 1) + self.files["psxrecomp/bios/openbios.bin"] = "synthetic" + with self.assertRaisesRegex(ValueError, "retail-BIOS-only"): + self.check() + + def test_unsafe_and_duplicate_paths_fail(self): + self.files["../escape.txt"] = "bad" + with self.assertRaisesRegex(ValueError, "unsafe"): + self.check() + del self.files["../escape.txt"] + self.write() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(self.zip, "a") as archive: + archive.writestr(MANIFEST, TEXT) + with self.assertRaisesRegex(ValueError, "duplicate"): + audit.audit(self.zip, GAME, DIAG) + + +if __name__ == "__main__": + unittest.main()