"""Safe testcase preparation for controlled diagnostic experiments.""" from __future__ import annotations from collections.abc import Callable, Mapping, Sequence import json import os from pathlib import Path, PurePosixPath import shutil import sys from typing import Any from ..config import AppConfig, project_root from ..filesystem import sha256_file from ..ini import IniError, inspect_ini_value from ..prefix import ( PrefixSafetyError, cleanup_dedicated_wineserver, validate_dedicated_prefix, ) from ..registry import DesktopRegistrySnapshot from ..result import ResultDirectory from ..runner import CommandLogger from ..testcase import TestIniOverride, TestModification, Testcase from .cleanup import ( registry_restore_commands as _registry_restore_commands_impl, registry_setup_commands as _registry_setup_commands_impl, require_command as _require_command, validate_disposable_path as _validate_disposable_path, ) from .models import ExperimentError _WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU" _DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable" _PERSISTENT_STATE_CACHE_SENTINEL = "@ptdiag-persistent" _PERSISTENT_STATE_CACHE_DIRECTORY = "ptdiag-cache/dxvk-state" def _resource_copy_paths( live_game: Path, modification: TestModification, ) -> tuple[Path, Path]: if modification.kind not in {"copy-file", "normalize-assbin-textures"}: raise ExperimentError(f"Unsupported modification kind: {modification.kind}") root = live_game.resolve(strict=True) source_relative = _safe_resource_relative_path(modification.source) destination_relative = _safe_resource_relative_path(modification.destination) source = root.joinpath(*source_relative.parts).resolve(strict=True) try: source.relative_to(root) except ValueError as exc: raise ExperimentError("Modification source escapes the live game tree") from exc if not source.is_file(): raise ExperimentError(f"Modification source is not a file: {source}") destination = root.joinpath(*destination_relative.parts) destination_parent = destination.parent.resolve(strict=True) try: destination_parent.relative_to(root) except ValueError as exc: raise ExperimentError("Modification destination escapes the live game tree") from exc if modification.kind == "copy-file" and (destination.exists() or destination.is_symlink()): raise ExperimentError( f"Live modification destination already exists; refusing experiment: {destination}" ) if modification.kind == "normalize-assbin-textures": if source_relative != destination_relative: raise ExperimentError( "Assbin normalization source and destination must be the same disposable file" ) if not modification.texture_indices: raise ExperimentError("Assbin normalization requires texture_indices") if not destination.is_file(): raise ExperimentError(f"Assbin normalization target is not a file: {destination}") return source, destination def _safe_resource_relative_path(value: str) -> PurePosixPath: if "\\" in value: raise ExperimentError("Modification paths must use forward slashes") path = PurePosixPath(value) if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): raise ExperimentError(f"Unsafe relative resource path: {value!r}") return path def _prepare_disposable_game( config: AppConfig, result: ResultDirectory, logger: CommandLogger, modification: TestModification, disposable_game: Path, source: Path, ) -> dict[str, Any]: staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute() _validate_disposable_path(staging_root, disposable_game, result.path.name) if disposable_game.exists() or disposable_game.is_symlink(): raise ExperimentError(f"Disposable game path already exists: {disposable_game}") usage = shutil.disk_usage(staging_root.parent) copy_bytes = _allocated_tree_bytes(config.paths.live_game) if usage.free < copy_bytes + 2 * 1024**3: raise ExperimentError( f"Insufficient free space for disposable game copy: " f"need at least {copy_bytes + 2 * 1024**3}, have {usage.free}" ) _require_command( logger.run( ["mkdir", "-p", "--", str(staging_root)], timeout_seconds=15.0, ), "create disposable staging directory", ) _require_command( logger.run( [ "cp", "-a", "--reflink=auto", "--sparse=always", f"{config.paths.live_game}/.", str(disposable_game), ], timeout_seconds=900.0, ), "copy disposable game tree", ) destination_relative = _safe_resource_relative_path(modification.destination) destination = disposable_game.joinpath(*destination_relative.parts) if modification.kind == "copy-file": if destination.exists() or destination.is_symlink(): raise ExperimentError( f"Disposable destination unexpectedly existed before copy: {destination}" ) _require_command( logger.run( ["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], timeout_seconds=30.0, ), "copy selected resource", ) normalization: dict[str, Any] | None = None destination_existed_before = False elif modification.kind == "normalize-assbin-textures": if not destination.is_file() or destination.is_symlink(): raise ExperimentError( f"Disposable Assbin target is missing or unsafe: {destination}" ) if sha256_file(destination) != sha256_file(source): raise ExperimentError("Disposable Assbin differs from live source before transform") temporary_output = destination.with_name( f".{destination.name}.{result.path.name}.normalized" ) if temporary_output.exists() or temporary_output.is_symlink(): raise ExperimentError(f"Normalization temporary output exists: {temporary_output}") normalize_result = logger.run( [ sys.executable, "-m", "ptdiag", "scene", "normalize-textures", str(destination), str(temporary_output), *(str(index) for index in modification.texture_indices), ], cwd=project_root(), timeout_seconds=240.0, ) _require_command(normalize_result, "normalize embedded Assbin textures") try: normalization = json.loads(normalize_result.stdout) except json.JSONDecodeError as exc: raise ExperimentError("Normalizer returned invalid JSON") from exc if not isinstance(normalization, dict) or not normalization.get("ok"): raise ExperimentError("Normalizer did not report a verified result") _require_command( logger.run( ["mv", "--", str(temporary_output), str(destination)], timeout_seconds=30.0, ), "install normalized Assbin in disposable tree", ) destination_existed_before = True else: raise ExperimentError(f"Unsupported modification kind: {modification.kind}") destination_hash = sha256_file(destination) source_hash = sha256_file(source) if modification.kind == "copy-file" and destination_hash != source_hash: raise ExperimentError("Copied resource hash differs from its source") if modification.kind == "normalize-assbin-textures" and destination_hash == source_hash: raise ExperimentError("Normalized Assbin hash did not change") ini_changes = _apply_disposable_ini_overrides( disposable_game, modification.ini_overrides, logger, ) return { "staging_root": str(staging_root), "free_bytes_before": usage.free, "source_allocated_bytes": copy_bytes, "destination_path": str(destination), "destination_existed_before": destination_existed_before, "destination_sha256_after": destination_hash, "destination_size_after": destination.stat().st_size, "normalization": normalization, "ini_changes": ini_changes, } def _inspect_ini_overrides( game_root: Path, overrides: Sequence[TestIniOverride], ) -> list[dict[str, Any]]: root = game_root.resolve(strict=True) result: list[dict[str, Any]] = [] for override in overrides: path = _safe_game_file(root, override.path) state = inspect_ini_value(path, override.section, override.key) result.append( { "path": str(path), "section": override.section, "key": override.key, "expected": override.expected, "value": override.value, "current": state["value"], "matches_expected": state["value"] == override.expected, "matches_value": state["value"] == override.value, "sha256": state["sha256"], "size": state["size"], "mtime_ns": state["mtime_ns"], } ) return result def _apply_disposable_ini_overrides( disposable_game: Path, overrides: Sequence[TestIniOverride], logger: CommandLogger, ) -> list[dict[str, Any]]: root = disposable_game.resolve(strict=True) result: list[dict[str, Any]] = [] for override in overrides: path = _safe_game_file(root, override.path) outcome = logger.run( _ini_override_command(path, override), cwd=project_root(), timeout_seconds=30.0, ) _require_command(outcome, f"override [{override.section}] {override.key}") try: payload = json.loads(outcome.stdout) except json.JSONDecodeError as exc: raise ExperimentError("INI override returned invalid JSON") from exc if not isinstance(payload, dict) or not payload.get("ok"): raise ExperimentError("INI override did not report a verified result") result.append(payload) return result def _safe_game_file(root: Path, relative: str) -> Path: parts = _safe_resource_relative_path(relative).parts path = root.joinpath(*parts) resolved = path.resolve(strict=True) try: resolved.relative_to(root) except ValueError as exc: raise ExperimentError(f"INI path escapes game tree: {relative}") from exc if path.is_symlink() or not resolved.is_file(): raise ExperimentError(f"INI path is missing or unsafe: {path}") return resolved def _ini_override_command(path: Path, override: TestIniOverride) -> list[str]: return [ sys.executable, "-m", "ptdiag", "ini", "override", str(path), override.section, override.key, override.expected, override.value, ] def _allocated_tree_bytes(root: Path) -> int: total = 0 for directory, _, filenames in os.walk(root, followlinks=False): for filename in filenames: path = Path(directory) / filename try: total += path.lstat().st_blocks * 512 except FileNotFoundError: continue return total def _log_modification_plan( logger: CommandLogger, config: AppConfig, disposable_game: Path, source: Path, modification: TestModification, ) -> None: staging_root = disposable_game.parent destination = disposable_game.joinpath( *_safe_resource_relative_path(modification.destination).parts ) prefix_link = config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU" commands: list[tuple[list[str], float]] = [ (["mkdir", "-p", "--", str(staging_root)], 15.0), ( [ "cp", "-a", "--reflink=auto", "--sparse=always", f"{config.paths.live_game}/.", str(disposable_game), ], 900.0, ), ] if modification.kind == "copy-file": commands.append( (["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], 30.0) ) elif modification.kind == "normalize-assbin-textures": temporary_output = destination.with_name(f".{destination.name}.planned.normalized") commands.extend( ( ( [ sys.executable, "-m", "ptdiag", "scene", "normalize-textures", str(destination), str(temporary_output), *(str(index) for index in modification.texture_indices), ], 240.0, ), (["mv", "--", str(temporary_output), str(destination)], 30.0), ) ) else: raise ExperimentError(f"Unsupported modification kind: {modification.kind}") for override in modification.ini_overrides: ini_path = disposable_game.joinpath( *_safe_resource_relative_path(override.path).parts ) commands.append((_ini_override_command(ini_path, override), 30.0)) commands.extend( ( (["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], 15.0), (["ln", "-sfn", "--", os.readlink(prefix_link), str(prefix_link)], 15.0), (["rm", "-rf", "--one-file-system", "--", str(disposable_game)], 900.0), ) ) for command, timeout in commands: logger.run(command, timeout_seconds=timeout, dry_run=True) def _preflight( config: AppConfig, testcase: Testcase, environment: Mapping[str, Any], hashes: list[dict[str, Any]], *, game_processes: Callable[[], list[int]], ) -> list[str]: errors: list[str] = [] if not environment.get("ok"): errors.append("Required environment checks failed") if any(not item.get("ok", False) for item in hashes if item.get("required", False)): errors.append("A required baseline hash is missing or mismatched") if testcase.runner != "proton7": errors.append(f"Unsupported runner: {testcase.runner}") if testcase.game.args != config.game.args: errors.append("Testcase game arguments differ from the known PTEU_REBORN baseline") if testcase.game.cwd != "live_game": errors.append("Only the verified live_game working directory is supported") if testcase.display.size != config.game.virtual_desktop: errors.append("Testcase desktop size differs from the 1600x900 baseline") if testcase.capture.strace_files and shutil.which("strace") is None: errors.append("scene-strace requires strace") if not testcase.watch.require_new_native_log: errors.append("Runtime tests must require a current-run native log") try: prefix = validate_dedicated_prefix(config) except (OSError, ValueError, PrefixSafetyError) as exc: errors.append(str(exc)) else: linked_game = prefix / "drive_c/Program Files (x86)/Pristontale EU" try: if linked_game.resolve(strict=True) != config.paths.live_game.resolve(strict=True): errors.append("Dedicated prefix game link does not resolve to live_game") except OSError as exc: errors.append(f"Dedicated prefix game link is invalid: {exc}") active = game_processes() if active: errors.append(f"Game.exe is already running: {active}") return errors def _dry_run_commands( config: AppConfig, testcase: Testcase, logger: CommandLogger, registry_before: DesktopRegistrySnapshot, launch_argv: list[str], env_overrides: Mapping[str, str], ) -> None: cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True) if testcase.display.virtual_desktop: for command in _registry_setup_commands( config, registry_before.desktop_name, testcase.display.size ): logger.run( command, cwd=config.paths.live_game, env_overrides=_proton_environment(config), timeout_seconds=15.0, dry_run=True, ) logger.run( launch_argv, cwd=config.paths.live_game, env_overrides=env_overrides, timeout_seconds=testcase.timeout_seconds, dry_run=True, ) cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True) if testcase.display.virtual_desktop: for command in _registry_restore_commands(config, registry_before): logger.run( command, cwd=config.paths.live_game, env_overrides=_proton_environment(config), timeout_seconds=15.0, dry_run=True, ) def _launch_argv(config: AppConfig, testcase: Testcase, result_directory: Path) -> list[str]: game = f"{_WINDOWS_GAME_DIRECTORY}\\{config.game.exe}" proton = _proton_command(config, "runinprefix", game, *testcase.game.args) if not testcase.capture.strace_files: return proton raw_directory = result_directory / "raw-strace" raw_directory.mkdir(exist_ok=False) strace = shutil.which("strace") or "/usr/bin/strace" return [ strace, "-ff", "-qq", "-ttt", "-s", "1024", "-e", "trace=%file", "-o", str(raw_directory / "trace"), *proton, ] def _proton_command(config: AppConfig, *arguments: str) -> list[str]: shim = project_root() / "ptlinux/proton_compat.py" return [ sys.executable, str(shim), str(config.proton7.proton), *arguments, ] def _runtime_environment( config: AppConfig, testcase: Testcase, result_directory: Path, ) -> dict[str, str]: environment = _proton_environment(config) environment.update(testcase.env) if environment.get("DXVK_STATE_CACHE_PATH") == _PERSISTENT_STATE_CACHE_SENTINEL: environment["DXVK_STATE_CACHE_PATH"] = str( (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute() ) environment.update( { "DXVK_LOG_PATH": str(result_directory), "PROTON_LOG": "0", "STEAM_COMPAT_INSTALL_PATH": str(config.paths.live_game), "SteamAppId": "0", "SteamGameId": "0", } ) return environment def _state_cache_plan( config: AppConfig, environment: Mapping[str, str], ) -> dict[str, Any] | None: configured = environment.get("DXVK_STATE_CACHE_PATH") if configured is None: return None cache_directory = Path(configured).expanduser().absolute() cache_file = cache_directory / f"{Path(config.game.exe).stem}.dxvk-cache" return { "directory": str(cache_directory), "cache_file": str(cache_file), "persistent": True, "seed_source": str(config.paths.live_game / cache_file.name), "seeded": False, "before": _state_cache_file_snapshot(cache_file), "after": None, } def _prepare_state_cache( config: AppConfig, environment: Mapping[str, str], logger: CommandLogger, ) -> dict[str, Any]: plan = _state_cache_plan(config, environment) if plan is None: raise ExperimentError("Persistent DXVK state-cache plan is missing") cache_directory = Path(str(plan["directory"])) expected = (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute() if cache_directory != expected: raise ExperimentError( f"Refusing unmanaged DXVK state-cache directory: {cache_directory}; " f"expected {expected}" ) _require_command( logger.run( ["mkdir", "-p", "--", str(cache_directory)], timeout_seconds=15.0, ), "create managed DXVK state-cache directory", ) cache_file = Path(str(plan["cache_file"])) seed_source = Path(str(plan["seed_source"])) if not cache_file.exists() and seed_source.is_file(): _require_command( logger.run( [ "cp", "--preserve=mode,timestamps", "--", str(seed_source), str(cache_file), ], timeout_seconds=30.0, ), "seed managed DXVK state cache", ) plan["seeded"] = True plan["before"] = _state_cache_file_snapshot(cache_file) return plan def _state_cache_file_snapshot(path: Path) -> dict[str, Any]: result: dict[str, Any] = { "path": str(path), "exists": path.is_file(), "size": None, "mtime_ns": None, "sha256": None, } if not result["exists"]: return result try: state = path.stat() result.update( { "size": state.st_size, "mtime_ns": state.st_mtime_ns, "sha256": sha256_file(path), } ) except OSError as exc: result["error"] = str(exc) return result def _proton_environment(config: AppConfig) -> dict[str, str]: return { "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(config.proton7.steam_root), "STEAM_COMPAT_DATA_PATH": str(config.proton7.compat), "WINEPREFIX": str(config.proton7.prefix), "WINEDEBUG": "-all", } def _registry_setup_commands( config: AppConfig, desktop_name: str, desktop_size: str, ) -> list[list[str]]: return _registry_setup_commands_impl( config, desktop_name, desktop_size, proton_command=_proton_command, ) def _registry_restore_commands( config: AppConfig, snapshot: DesktopRegistrySnapshot, ) -> list[list[str]]: return _registry_restore_commands_impl( config, snapshot, proton_command=_proton_command, )