625 lines
19 KiB
Python
625 lines
19 KiB
Python
"""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,
|
|
)
|