1813 lines
53 KiB
Python
1813 lines
53 KiB
Python
"""Controlled Proton 7 runtime experiments and result generation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
from datetime import datetime
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Any, Mapping, Sequence, TextIO
|
|
|
|
from .config import AppConfig, project_root
|
|
from .filesystem import sha256_file, verify_known_game_hashes
|
|
from .logs import (
|
|
LogFileState,
|
|
discover_current_native_logs,
|
|
parse_current_run_log,
|
|
read_current_run_log_text,
|
|
snapshot_native_logs,
|
|
)
|
|
from .ini import IniError, inspect_ini_value
|
|
from .performance import PerformanceSampler
|
|
from .prefix import (
|
|
PrefixSafetyError,
|
|
cleanup_dedicated_wineserver,
|
|
validate_dedicated_prefix,
|
|
)
|
|
from .process import collect_environment
|
|
from .registry import (
|
|
DesktopRegistrySnapshot,
|
|
desktop_registry_restored,
|
|
read_registry_string_values,
|
|
snapshot_desktop_registry,
|
|
)
|
|
from .result import ResultDirectory, create_result_directory, write_json
|
|
from .runner import CommandLogger, CommandResult
|
|
from .testcase import TestIniOverride, TestModification, Testcase
|
|
from .trace import process_trace_shards
|
|
|
|
|
|
_EXPLORER_KEY = r"HKCU\Software\Wine\Explorer"
|
|
_DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops"
|
|
_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"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExperimentResult:
|
|
result_directory: Path
|
|
summary: dict[str, Any]
|
|
exit_code: int
|
|
|
|
|
|
class ExperimentError(RuntimeError):
|
|
"""Raised for a controlled experiment setup or lifecycle failure."""
|
|
|
|
|
|
def run_experiment(
|
|
config: AppConfig,
|
|
testcase: Testcase,
|
|
*,
|
|
dry_run: bool,
|
|
results_root: Path | None = None,
|
|
) -> ExperimentResult:
|
|
if testcase.modification is not None:
|
|
return _run_modified_experiment(
|
|
config,
|
|
testcase,
|
|
dry_run=dry_run,
|
|
results_root=results_root,
|
|
)
|
|
return _run_standard_experiment(
|
|
config,
|
|
testcase,
|
|
dry_run=dry_run,
|
|
results_root=results_root,
|
|
)
|
|
|
|
|
|
def _run_standard_experiment(
|
|
config: AppConfig,
|
|
testcase: Testcase,
|
|
*,
|
|
dry_run: bool,
|
|
results_root: Path | None = None,
|
|
prepared_result: ResultDirectory | None = None,
|
|
started_at: datetime | None = None,
|
|
) -> ExperimentResult:
|
|
started_at = started_at or datetime.now().astimezone()
|
|
result = prepared_result or create_result_directory(
|
|
results_root or project_root() / "results", testcase.name, started_at
|
|
)
|
|
logger = CommandLogger(result.commands_path)
|
|
(result.path / "runner.log").touch(exist_ok=True)
|
|
(result.path / "native.log").touch(exist_ok=True)
|
|
environment = collect_environment(config)
|
|
hashes = _collect_hashes(config, testcase)
|
|
write_json(result.path / "hashes.json", {"files": hashes})
|
|
|
|
desktop_name = _desktop_name(result.path)
|
|
metadata: dict[str, Any] = {
|
|
"test": testcase.name,
|
|
"description": testcase.description,
|
|
"started_at": started_at.isoformat(),
|
|
"result_directory": str(result.path),
|
|
"dry_run": dry_run,
|
|
"testcase": testcase.to_dict(),
|
|
"config_source": str(config.source),
|
|
"desktop_name": desktop_name,
|
|
"native_log_directory": str(config.paths.native_logs),
|
|
}
|
|
write_json(result.metadata_path, metadata)
|
|
|
|
preflight_errors = _preflight(config, testcase, environment, hashes)
|
|
baseline = snapshot_native_logs(config.paths.native_logs)
|
|
metadata["old_log_baseline"] = _serialize_log_states(baseline)
|
|
write_json(result.metadata_path, metadata)
|
|
registry_before: DesktopRegistrySnapshot | None = None
|
|
try:
|
|
registry_before = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
|
|
except OSError as exc:
|
|
preflight_errors.append(f"Could not snapshot dedicated-prefix registry: {exc}")
|
|
|
|
launch_argv = _launch_argv(config, testcase, result.path)
|
|
env_overrides = _runtime_environment(config, testcase, result.path)
|
|
state_cache_summary = _state_cache_plan(config, env_overrides)
|
|
dll_overrides = (
|
|
_collect_dll_overrides(config.proton7.prefix)
|
|
if testcase.capture.dll_overrides
|
|
else {}
|
|
)
|
|
_write_text(
|
|
result.path / "environment.txt",
|
|
json.dumps(
|
|
{
|
|
"preflight": environment,
|
|
"runtime_overrides": dict(sorted(env_overrides.items())),
|
|
"dll_overrides": dll_overrides,
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
),
|
|
)
|
|
metadata["dll_overrides"] = dll_overrides
|
|
write_json(result.metadata_path, metadata)
|
|
summary: dict[str, Any] = {
|
|
"ok": False,
|
|
"status": "preflight-failed" if preflight_errors else "prepared",
|
|
"dry_run": dry_run,
|
|
"test": testcase.name,
|
|
"result_directory": str(result.path),
|
|
"preflight": {
|
|
"ok": not preflight_errors,
|
|
"errors": preflight_errors,
|
|
},
|
|
"launch": {
|
|
"argv": launch_argv,
|
|
"cwd": str(config.paths.live_game),
|
|
"env_overrides": dict(sorted(env_overrides.items())),
|
|
"timeout_seconds": testcase.timeout_seconds,
|
|
},
|
|
"native_log": {
|
|
"directory": str(config.paths.native_logs),
|
|
"baseline": _serialize_log_states(baseline),
|
|
"source": None,
|
|
"current_run_bytes": 0,
|
|
},
|
|
"registry": {
|
|
"temporary_change": testcase.display.virtual_desktop,
|
|
"before": registry_before.to_dict() if registry_before else None,
|
|
"after": None,
|
|
"restored": None,
|
|
},
|
|
"dxvk_state_cache": state_cache_summary,
|
|
}
|
|
|
|
if preflight_errors:
|
|
_finalize(result, summary)
|
|
return ExperimentResult(result.path, summary, 2)
|
|
assert registry_before is not None
|
|
|
|
if dry_run:
|
|
_dry_run_commands(
|
|
config,
|
|
testcase,
|
|
result,
|
|
logger,
|
|
registry_before,
|
|
launch_argv,
|
|
env_overrides,
|
|
)
|
|
registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
|
|
summary["registry"]["after"] = registry_after.to_dict()
|
|
summary["registry"]["restored"] = desktop_registry_restored(
|
|
registry_before, registry_after
|
|
)
|
|
summary["status"] = "dry-run"
|
|
summary["ok"] = True
|
|
summary["review_gate"] = (
|
|
"Phase C dry-run complete; review artifacts before a live Game.exe launch."
|
|
)
|
|
_finalize(result, summary)
|
|
return ExperimentResult(result.path, summary, 0)
|
|
|
|
runtime_exit = 1
|
|
setup_complete = False
|
|
try:
|
|
if state_cache_summary is not None:
|
|
state_cache_summary = _prepare_state_cache(
|
|
config,
|
|
env_overrides,
|
|
logger,
|
|
)
|
|
summary["dxvk_state_cache"] = state_cache_summary
|
|
_cleanup_checked(config, logger)
|
|
if testcase.display.virtual_desktop:
|
|
_run_checked_commands(
|
|
logger,
|
|
_registry_setup_commands(config, desktop_name, testcase.display.size),
|
|
cwd=config.paths.live_game,
|
|
env_overrides=_proton_environment(config),
|
|
)
|
|
setup_complete = True
|
|
baseline = snapshot_native_logs(config.paths.native_logs)
|
|
metadata["old_log_baseline"] = _serialize_log_states(baseline)
|
|
write_json(result.metadata_path, metadata)
|
|
runtime = _run_and_watch(
|
|
config,
|
|
testcase,
|
|
result,
|
|
logger,
|
|
launch_argv,
|
|
env_overrides,
|
|
baseline,
|
|
)
|
|
summary["runtime"] = runtime
|
|
summary["native_log"].update(runtime.pop("native_log"))
|
|
runtime_exit = (
|
|
0
|
|
if runtime.get("failure_detected") or runtime.get("scene_import_survived")
|
|
else 1
|
|
)
|
|
trigger_epoch = runtime.get("trigger_epoch")
|
|
if testcase.capture.strace_files and isinstance(trigger_epoch, (int, float)):
|
|
trace = process_trace_shards(
|
|
result.path / "raw-strace",
|
|
result.path,
|
|
trigger_epoch=float(trigger_epoch),
|
|
focus_before_seconds=testcase.capture.focus_before_seconds,
|
|
focus_after_seconds=testcase.capture.focus_after_seconds,
|
|
live_game=config.paths.live_game,
|
|
)
|
|
summary["trace"] = trace.to_dict()
|
|
else:
|
|
summary["trace"] = None
|
|
if runtime.get("failure_detected"):
|
|
summary["status"] = "captured-scene-failure"
|
|
elif runtime.get("scene_import_survived"):
|
|
summary["status"] = "scene-import-survived"
|
|
else:
|
|
summary["status"] = "no-trigger"
|
|
summary["ok"] = bool(
|
|
runtime.get("failure_detected") or runtime.get("scene_import_survived")
|
|
)
|
|
except (
|
|
ExperimentError,
|
|
OSError,
|
|
ValueError,
|
|
PrefixSafetyError,
|
|
subprocess.SubprocessError,
|
|
) as exc:
|
|
summary["status"] = "runner-error"
|
|
summary["error"] = str(exc)
|
|
summary["ok"] = False
|
|
runtime_exit = 2
|
|
finally:
|
|
cleanup_error: str | None = None
|
|
try:
|
|
_cleanup_checked(config, logger)
|
|
except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc:
|
|
cleanup_error = str(exc)
|
|
if testcase.display.virtual_desktop and setup_complete:
|
|
try:
|
|
_run_checked_commands(
|
|
logger,
|
|
_registry_restore_commands(config, registry_before),
|
|
cwd=config.paths.live_game,
|
|
env_overrides=_proton_environment(config),
|
|
)
|
|
except ExperimentError as exc:
|
|
cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc)
|
|
try:
|
|
registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
|
|
summary["registry"]["after"] = registry_after.to_dict()
|
|
summary["registry"]["restored"] = desktop_registry_restored(
|
|
registry_before, registry_after
|
|
)
|
|
except OSError as exc:
|
|
cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc)
|
|
if cleanup_error:
|
|
summary["cleanup_error"] = cleanup_error
|
|
summary["ok"] = False
|
|
runtime_exit = 2
|
|
if state_cache_summary is not None:
|
|
state_cache_summary["after"] = _state_cache_file_snapshot(
|
|
Path(str(state_cache_summary["cache_file"]))
|
|
)
|
|
summary["dxvk_state_cache"] = state_cache_summary
|
|
|
|
_finalize(result, summary)
|
|
return ExperimentResult(result.path, summary, runtime_exit)
|
|
|
|
|
|
def _run_modified_experiment(
|
|
config: AppConfig,
|
|
testcase: Testcase,
|
|
*,
|
|
dry_run: bool,
|
|
results_root: Path | None,
|
|
) -> ExperimentResult:
|
|
modification = testcase.modification
|
|
assert modification is not None
|
|
started_at = datetime.now().astimezone()
|
|
result = create_result_directory(
|
|
results_root or project_root() / "results", testcase.name, started_at
|
|
)
|
|
(result.path / "runner.log").touch(exist_ok=True)
|
|
(result.path / "native.log").touch(exist_ok=True)
|
|
logger = CommandLogger(result.commands_path)
|
|
source_environment = collect_environment(config)
|
|
source_hashes = _collect_hashes(config, testcase)
|
|
source_errors = _preflight(config, testcase, source_environment, source_hashes)
|
|
write_json(
|
|
result.path / "source-preflight.json",
|
|
{
|
|
"environment": source_environment,
|
|
"hashes": source_hashes,
|
|
"ok": not source_errors,
|
|
"errors": source_errors,
|
|
},
|
|
)
|
|
disposable_game = (
|
|
config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME / result.path.name
|
|
).absolute()
|
|
modification_summary: dict[str, Any] = {
|
|
"kind": modification.kind,
|
|
"source": modification.source,
|
|
"destination": modification.destination,
|
|
"texture_indices": list(modification.texture_indices),
|
|
"ini_overrides": [
|
|
{
|
|
"path": item.path,
|
|
"section": item.section,
|
|
"key": item.key,
|
|
"expected": item.expected,
|
|
"value": item.value,
|
|
}
|
|
for item in modification.ini_overrides
|
|
],
|
|
"disposable_game": str(disposable_game),
|
|
"source_preflight_ok": not source_errors,
|
|
"prepared": False,
|
|
"prefix_link_restored": None,
|
|
"disposable_removed": None,
|
|
}
|
|
write_json(result.path / "modification.json", modification_summary)
|
|
if source_errors:
|
|
summary = _setup_failure_summary(
|
|
testcase, result, dry_run, source_errors, modification_summary
|
|
)
|
|
_finalize(result, summary)
|
|
return ExperimentResult(result.path, summary, 2)
|
|
|
|
try:
|
|
source, live_destination = _resource_copy_paths(
|
|
config.paths.live_game, modification
|
|
)
|
|
ini_preflight = _inspect_ini_overrides(
|
|
config.paths.live_game,
|
|
modification.ini_overrides,
|
|
)
|
|
for item in ini_preflight:
|
|
if not item["matches_expected"]:
|
|
raise ExperimentError(
|
|
f"INI preflight mismatch for [{item['section']}] {item['key']}: "
|
|
f"expected {item['expected']!r}, found {item['current']!r}"
|
|
)
|
|
except (OSError, ValueError, ExperimentError) as exc:
|
|
summary = _setup_failure_summary(
|
|
testcase, result, dry_run, [str(exc)], modification_summary
|
|
)
|
|
_finalize(result, summary)
|
|
return ExperimentResult(result.path, summary, 2)
|
|
|
|
modification_summary.update(
|
|
{
|
|
"source_path": str(source),
|
|
"live_destination_path": str(live_destination),
|
|
"live_destination_existed_before": live_destination.exists(),
|
|
"source_sha256": sha256_file(source),
|
|
"source_size": source.stat().st_size,
|
|
"ini_preflight": ini_preflight,
|
|
}
|
|
)
|
|
if dry_run:
|
|
_log_modification_plan(
|
|
logger,
|
|
config,
|
|
disposable_game,
|
|
source,
|
|
modification,
|
|
)
|
|
outcome = _run_standard_experiment(
|
|
config,
|
|
testcase,
|
|
dry_run=True,
|
|
prepared_result=result,
|
|
started_at=started_at,
|
|
)
|
|
modification_summary["status"] = "planned-only"
|
|
outcome.summary["modification"] = modification_summary
|
|
write_json(result.path / "modification.json", modification_summary)
|
|
_finalize(result, outcome.summary)
|
|
return outcome
|
|
|
|
prefix_link = (
|
|
config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU"
|
|
)
|
|
original_link_target: str | None = None
|
|
link_changed = False
|
|
disposable_created = False
|
|
runtime_config = config
|
|
outcome: ExperimentResult | None = None
|
|
setup_error: str | None = None
|
|
cleanup_errors: list[str] = []
|
|
try:
|
|
_cleanup_checked(config, logger)
|
|
preparation = _prepare_disposable_game(
|
|
config,
|
|
result,
|
|
logger,
|
|
modification,
|
|
disposable_game,
|
|
source,
|
|
)
|
|
disposable_created = True
|
|
modification_summary.update(preparation)
|
|
original_link_target = _replace_game_link(
|
|
prefix_link,
|
|
disposable_game,
|
|
config.paths.live_game,
|
|
logger,
|
|
)
|
|
link_changed = True
|
|
modification_summary.update(
|
|
{
|
|
"prefix_link": str(prefix_link),
|
|
"prefix_link_original_target": original_link_target,
|
|
"prefix_link_runtime_target": str(disposable_game),
|
|
}
|
|
)
|
|
runtime_config = replace(
|
|
config,
|
|
paths=replace(
|
|
config.paths,
|
|
live_game=disposable_game,
|
|
native_logs=disposable_game / "save/logs",
|
|
),
|
|
)
|
|
modification_summary["prepared"] = True
|
|
write_json(result.path / "modification.json", modification_summary)
|
|
outcome = _run_standard_experiment(
|
|
runtime_config,
|
|
testcase,
|
|
dry_run=False,
|
|
prepared_result=result,
|
|
started_at=started_at,
|
|
)
|
|
except (
|
|
ExperimentError,
|
|
OSError,
|
|
ValueError,
|
|
PrefixSafetyError,
|
|
subprocess.SubprocessError,
|
|
) as exc:
|
|
setup_error = str(exc)
|
|
finally:
|
|
try:
|
|
_cleanup_checked(runtime_config, logger)
|
|
except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc:
|
|
cleanup_errors.append(f"wineserver cleanup: {exc}")
|
|
if disposable_created and disposable_game.exists() and modification.ini_overrides:
|
|
try:
|
|
modification_summary["ini_runtime_final"] = _inspect_ini_overrides(
|
|
disposable_game,
|
|
modification.ini_overrides,
|
|
)
|
|
except (ExperimentError, IniError, OSError) as exc:
|
|
cleanup_errors.append(f"final INI capture: {exc}")
|
|
if link_changed and original_link_target is not None:
|
|
try:
|
|
_restore_game_link(
|
|
prefix_link,
|
|
original_link_target,
|
|
config.paths.live_game,
|
|
logger,
|
|
)
|
|
modification_summary["prefix_link_restored"] = True
|
|
except (ExperimentError, OSError) as exc:
|
|
modification_summary["prefix_link_restored"] = False
|
|
cleanup_errors.append(f"prefix link restore: {exc}")
|
|
if disposable_created or disposable_game.exists():
|
|
try:
|
|
_remove_disposable_game(config, result, disposable_game, logger)
|
|
modification_summary["disposable_removed"] = True
|
|
except (ExperimentError, OSError) as exc:
|
|
modification_summary["disposable_removed"] = False
|
|
cleanup_errors.append(f"disposable cleanup: {exc}")
|
|
|
|
if outcome is None:
|
|
errors = [setup_error or "Modified experiment did not start", *cleanup_errors]
|
|
summary = _setup_failure_summary(
|
|
testcase, result, False, errors, modification_summary
|
|
)
|
|
exit_code = 2
|
|
else:
|
|
summary = outcome.summary
|
|
exit_code = outcome.exit_code
|
|
if setup_error:
|
|
summary["status"] = "setup-error"
|
|
summary["error"] = setup_error
|
|
summary["ok"] = False
|
|
exit_code = 2
|
|
if cleanup_errors:
|
|
summary["cleanup_error"] = "; ".join(cleanup_errors)
|
|
summary["ok"] = False
|
|
exit_code = 2
|
|
summary["modification"] = modification_summary
|
|
write_json(result.path / "modification.json", modification_summary)
|
|
_finalize(result, summary)
|
|
return ExperimentResult(result.path, summary, exit_code)
|
|
|
|
|
|
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 _replace_game_link(
|
|
prefix_link: Path,
|
|
disposable_game: Path,
|
|
expected_live_game: Path,
|
|
logger: CommandLogger,
|
|
) -> str:
|
|
if not prefix_link.is_symlink():
|
|
raise ExperimentError(f"Dedicated-prefix game path is not a symlink: {prefix_link}")
|
|
if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True):
|
|
raise ExperimentError("Dedicated-prefix game link changed after source preflight")
|
|
original_target = os.readlink(prefix_link)
|
|
_require_command(
|
|
logger.run(
|
|
["ln", "-sfn", "--", str(disposable_game), str(prefix_link)],
|
|
cwd=prefix_link.parent,
|
|
timeout_seconds=15.0,
|
|
),
|
|
"point dedicated prefix at disposable game",
|
|
)
|
|
if not prefix_link.is_symlink() or prefix_link.resolve(strict=True) != disposable_game.resolve(strict=True):
|
|
raise ExperimentError("Dedicated-prefix game link did not resolve to disposable copy")
|
|
return original_target
|
|
|
|
|
|
def _restore_game_link(
|
|
prefix_link: Path,
|
|
original_target: str,
|
|
expected_live_game: Path,
|
|
logger: CommandLogger,
|
|
) -> None:
|
|
_require_command(
|
|
logger.run(
|
|
["ln", "-sfn", "--", original_target, str(prefix_link)],
|
|
cwd=prefix_link.parent,
|
|
timeout_seconds=15.0,
|
|
),
|
|
"restore dedicated-prefix game link",
|
|
)
|
|
if not prefix_link.is_symlink():
|
|
raise ExperimentError("Restored dedicated-prefix game path is not a symlink")
|
|
if os.readlink(prefix_link) != original_target:
|
|
raise ExperimentError("Dedicated-prefix game link text was not restored exactly")
|
|
if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True):
|
|
raise ExperimentError("Dedicated-prefix game link target was not restored")
|
|
|
|
|
|
def _remove_disposable_game(
|
|
config: AppConfig,
|
|
result: ResultDirectory,
|
|
disposable_game: Path,
|
|
logger: CommandLogger,
|
|
) -> None:
|
|
staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute()
|
|
_validate_disposable_path(staging_root, disposable_game, result.path.name)
|
|
_require_command(
|
|
logger.run(
|
|
["rm", "-rf", "--one-file-system", "--", str(disposable_game)],
|
|
timeout_seconds=900.0,
|
|
),
|
|
"remove disposable game tree",
|
|
)
|
|
if disposable_game.exists() or disposable_game.is_symlink():
|
|
raise ExperimentError(f"Disposable game cleanup left its target: {disposable_game}")
|
|
try:
|
|
staging_root.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _validate_disposable_path(staging_root: Path, path: Path, expected_name: str) -> None:
|
|
if path.parent != staging_root or path.name != expected_name:
|
|
raise ExperimentError(f"Unsafe disposable game path: {path}")
|
|
if staging_root.name != _DISPOSABLE_DIRECTORY_NAME:
|
|
raise ExperimentError(f"Unsafe disposable staging root: {staging_root}")
|
|
|
|
|
|
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 _require_command(outcome: CommandResult, operation: str) -> None:
|
|
if not _command_ok(outcome):
|
|
raise ExperimentError(
|
|
f"Could not {operation} ({outcome.returncode}): "
|
|
f"{outcome.error or outcome.stderr.strip() or 'command failed'}"
|
|
)
|
|
|
|
|
|
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 _setup_failure_summary(
|
|
testcase: Testcase,
|
|
result: ResultDirectory,
|
|
dry_run: bool,
|
|
errors: Sequence[str],
|
|
modification: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"ok": False,
|
|
"status": "preflight-failed" if errors else "setup-error",
|
|
"dry_run": dry_run,
|
|
"test": testcase.name,
|
|
"result_directory": str(result.path),
|
|
"preflight": {"ok": False, "errors": list(errors)},
|
|
"modification": dict(modification),
|
|
"native_log": {"source": None, "current_run_bytes": 0},
|
|
"registry": {"restored": None},
|
|
}
|
|
|
|
|
|
def _preflight(
|
|
config: AppConfig,
|
|
testcase: Testcase,
|
|
environment: Mapping[str, Any],
|
|
hashes: list[dict[str, Any]],
|
|
) -> 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,
|
|
result: ResultDirectory,
|
|
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 _run_and_watch(
|
|
config: AppConfig,
|
|
testcase: Testcase,
|
|
result: ResultDirectory,
|
|
logger: CommandLogger,
|
|
argv: Sequence[str],
|
|
env_overrides: Mapping[str, str],
|
|
baseline: Mapping[str, LogFileState],
|
|
) -> dict[str, Any]:
|
|
runner_log_path = result.path / "runner.log"
|
|
environment = os.environ.copy()
|
|
environment.update(env_overrides)
|
|
test_start_ns = time.time_ns()
|
|
started_at = datetime.now().astimezone()
|
|
write_json(
|
|
result.path / "test-start.json",
|
|
{
|
|
"test_start_ns": test_start_ns,
|
|
"test_start_epoch": test_start_ns / 1_000_000_000,
|
|
"recorded_at": started_at.isoformat(),
|
|
},
|
|
)
|
|
process: subprocess.Popen[str] | None = None
|
|
process_identity: dict[str, Any] | None = None
|
|
failure_state: LogFileState | None = None
|
|
failure_analysis: dict[str, Any] | None = None
|
|
failure_text = ""
|
|
failure_trigger_mtime_ns: int | None = None
|
|
current_state: LogFileState | None = None
|
|
current_analysis: dict[str, Any] | None = None
|
|
current_text = ""
|
|
current_log_captures: list[dict[str, Any]] = []
|
|
performance_sampler: PerformanceSampler | None = None
|
|
performance_summary: dict[str, Any] | None = None
|
|
detected_at_ns: int | None = None
|
|
timed_out = False
|
|
stop_reason = "process-exited"
|
|
with runner_log_path.open("a", encoding="utf-8") as runner_log:
|
|
try:
|
|
process = subprocess.Popen(
|
|
list(argv),
|
|
cwd=config.paths.live_game,
|
|
env=environment,
|
|
stdout=runner_log,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
start_new_session=True,
|
|
)
|
|
except OSError as exc:
|
|
logger.record_lifecycle(
|
|
argv,
|
|
state="launch-error",
|
|
cwd=config.paths.live_game,
|
|
env_overrides=env_overrides,
|
|
timeout_seconds=testcase.timeout_seconds,
|
|
started_at=started_at,
|
|
error=str(exc),
|
|
)
|
|
raise ExperimentError(f"Could not launch experiment: {exc}") from exc
|
|
logger.record_lifecycle(
|
|
argv,
|
|
state="started",
|
|
cwd=config.paths.live_game,
|
|
env_overrides=env_overrides,
|
|
timeout_seconds=testcase.timeout_seconds,
|
|
started_at=started_at,
|
|
pid=process.pid,
|
|
)
|
|
deadline_ns = test_start_ns + int(testcase.timeout_seconds * 1_000_000_000)
|
|
post_failure_deadline_ns: int | None = None
|
|
while process.poll() is None:
|
|
now_ns = time.time_ns()
|
|
if process_identity is None:
|
|
process_identity = _capture_game_process(testcase, result.path)
|
|
if process_identity is not None and testcase.capture.performance_stats:
|
|
try:
|
|
performance_sampler = PerformanceSampler(
|
|
int(process_identity["pid"]),
|
|
result.path / "performance.csv",
|
|
)
|
|
except (OSError, ValueError, KeyError) as exc:
|
|
performance_summary = {"error": str(exc)}
|
|
if performance_sampler is not None:
|
|
performance_sampler.maybe_sample(now_ns)
|
|
if failure_state is None:
|
|
for state in discover_current_native_logs(
|
|
config.paths.native_logs, baseline, test_start_ns
|
|
):
|
|
analysis = parse_current_run_log(state, baseline)
|
|
current_state = state
|
|
current_analysis = analysis.to_summary()
|
|
current_text = read_current_run_log_text(state, baseline)
|
|
if analysis.load_texture_to_material_failed:
|
|
failure_state = state
|
|
failure_analysis = analysis.to_summary()
|
|
failure_text = read_current_run_log_text(state, baseline)
|
|
failure_trigger_mtime_ns = state.mtime_ns
|
|
detected_at_ns = now_ns
|
|
post_failure_deadline_ns = now_ns + int(
|
|
testcase.stop_after_failure_seconds * 1_000_000_000
|
|
)
|
|
break
|
|
if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns:
|
|
stop_reason = "load-texture-to-material"
|
|
_stop_process_group(process)
|
|
break
|
|
if now_ns >= deadline_ns:
|
|
timed_out = True
|
|
stop_reason = "timeout"
|
|
_stop_process_group(process)
|
|
break
|
|
time.sleep(0.1)
|
|
if process.poll() is None:
|
|
_stop_process_group(process)
|
|
returncode = process.wait(timeout=5.0)
|
|
if performance_sampler is not None:
|
|
performance_summary = performance_sampler.close()
|
|
logger.record_lifecycle(
|
|
argv,
|
|
state="finished",
|
|
cwd=config.paths.live_game,
|
|
env_overrides=env_overrides,
|
|
timeout_seconds=testcase.timeout_seconds,
|
|
started_at=started_at,
|
|
pid=process.pid,
|
|
returncode=returncode,
|
|
timed_out=timed_out,
|
|
)
|
|
|
|
final_current = discover_current_native_logs(
|
|
config.paths.native_logs, baseline, test_start_ns
|
|
)
|
|
if final_current:
|
|
candidates: list[
|
|
tuple[tuple[int, int, int, int, str], LogFileState, str, dict[str, Any]]
|
|
] = []
|
|
for index, state in enumerate(final_current):
|
|
text = read_current_run_log_text(state, baseline)
|
|
analysis = parse_current_run_log(state, baseline).to_summary()
|
|
scenes = analysis.get("scenes")
|
|
scene_names = (
|
|
sorted(str(name) for name in scenes)
|
|
if isinstance(scenes, Mapping)
|
|
else []
|
|
)
|
|
captured_path = (
|
|
result.path / "native-logs" / f"{index:02d}-{state.path.name}.current.log"
|
|
)
|
|
_write_text(captured_path, text)
|
|
current_log_captures.append(
|
|
{
|
|
"source": str(state.path),
|
|
"state": _serialize_log_state(state),
|
|
"captured_path": str(captured_path),
|
|
"current_run_bytes": len(text.encode("utf-8")),
|
|
"scene_names": scene_names,
|
|
"load_texture_to_material_failed": analysis.get(
|
|
"load_texture_to_material_failed", False
|
|
),
|
|
"import_scene_threw": analysis.get("import_scene_threw", False),
|
|
}
|
|
)
|
|
candidates.append(
|
|
(_native_log_priority(state, analysis), state, text, analysis)
|
|
)
|
|
_, current_state, current_text, current_analysis = max(
|
|
candidates,
|
|
key=lambda item: item[0],
|
|
)
|
|
if failure_state is not None:
|
|
final_states = snapshot_native_logs(config.paths.native_logs)
|
|
final_state = final_states.get(str(failure_state.path.absolute()))
|
|
if final_state is not None:
|
|
failure_state = final_state
|
|
failure_text = read_current_run_log_text(final_state, baseline)
|
|
failure_analysis = parse_current_run_log(final_state, baseline).to_summary()
|
|
current_state = final_state
|
|
current_text = failure_text
|
|
current_analysis = failure_analysis
|
|
if current_state is not None:
|
|
_write_text(result.path / "native.log", current_text)
|
|
whispering_vale = (
|
|
current_analysis.get("whispering_vale")
|
|
if isinstance(current_analysis, Mapping)
|
|
else None
|
|
)
|
|
scene_import_survived = bool(
|
|
whispering_vale
|
|
and not timed_out
|
|
and not current_analysis.get("load_texture_to_material_failed", False)
|
|
and not current_analysis.get("import_scene_threw", False)
|
|
and not current_analysis.get("failed_import_scenes", [])
|
|
) if isinstance(current_analysis, Mapping) else False
|
|
trigger_ns = (
|
|
max(test_start_ns, failure_trigger_mtime_ns)
|
|
if failure_trigger_mtime_ns is not None
|
|
else None
|
|
)
|
|
return {
|
|
"test_start_ns": test_start_ns,
|
|
"test_start_epoch": test_start_ns / 1_000_000_000,
|
|
"finished_at": datetime.now().astimezone().isoformat(),
|
|
"launcher_pid": process.pid if process is not None else None,
|
|
"game_process": process_identity,
|
|
"returncode": process.returncode if process is not None else None,
|
|
"timed_out": timed_out,
|
|
"stop_reason": stop_reason,
|
|
"failure_detected": failure_state is not None,
|
|
"scene_import_survived": scene_import_survived,
|
|
"detected_at_ns": detected_at_ns,
|
|
"trigger_epoch": trigger_ns / 1_000_000_000 if trigger_ns is not None else None,
|
|
"analysis": current_analysis,
|
|
"native_logs": current_log_captures,
|
|
"performance": performance_summary,
|
|
"native_log": {
|
|
"source": str(current_state.path) if current_state else None,
|
|
"state": _serialize_log_state(current_state) if current_state else None,
|
|
"current_run_bytes": len(current_text.encode("utf-8")),
|
|
},
|
|
}
|
|
|
|
|
|
def _native_log_priority(
|
|
state: LogFileState,
|
|
analysis: Mapping[str, Any],
|
|
) -> tuple[int, int, int, int, str]:
|
|
scenes = analysis.get("scenes")
|
|
scene_count = 0
|
|
if isinstance(scenes, Mapping):
|
|
for imports in scenes.values():
|
|
if isinstance(imports, list):
|
|
scene_count += len(imports)
|
|
return (
|
|
int(scene_count > 0),
|
|
scene_count,
|
|
int(state.path.name.casefold().startswith("log_")),
|
|
state.mtime_ns,
|
|
str(state.path),
|
|
)
|
|
|
|
|
|
def _stop_process_group(process: subprocess.Popen[str]) -> None:
|
|
if process.poll() is not None:
|
|
return
|
|
for signum, timeout in (
|
|
(signal.SIGINT, 5.0),
|
|
(signal.SIGTERM, 5.0),
|
|
(signal.SIGKILL, 3.0),
|
|
):
|
|
try:
|
|
os.killpg(process.pid, signum)
|
|
except ProcessLookupError:
|
|
return
|
|
try:
|
|
process.wait(timeout=timeout)
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
continue
|
|
|
|
|
|
def _capture_game_process(testcase: Testcase, result_directory: Path) -> dict[str, Any] | None:
|
|
processes = _game_processes()
|
|
if not processes:
|
|
return None
|
|
pid = processes[0]
|
|
root = Path("/proc") / str(pid)
|
|
identity: dict[str, Any] = {"pid": pid}
|
|
try:
|
|
identity["comm"] = (root / "comm").read_text(encoding="utf-8").strip()
|
|
identity["cmdline"] = (
|
|
(root / "cmdline").read_bytes().decode("utf-8", errors="replace").split("\0")
|
|
)
|
|
identity["cwd"] = str((root / "cwd").readlink())
|
|
identity["exe"] = str((root / "exe").readlink())
|
|
if testcase.capture.process_maps:
|
|
shutil.copyfile(root / "maps", result_directory / "process-maps.txt")
|
|
except OSError as exc:
|
|
identity["capture_error"] = str(exc)
|
|
return identity
|
|
|
|
|
|
def _game_processes() -> list[int]:
|
|
result: list[int] = []
|
|
for entry in Path("/proc").iterdir():
|
|
if not entry.name.isdigit():
|
|
continue
|
|
try:
|
|
comm = (entry / "comm").read_text(encoding="utf-8").strip().casefold()
|
|
except OSError:
|
|
continue
|
|
if comm in {"game.exe", "gamese.exe"}:
|
|
result.append(int(entry.name))
|
|
return sorted(result)
|
|
|
|
|
|
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 = Path(__file__).with_name("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 [
|
|
_proton_command(
|
|
config,
|
|
"runinprefix",
|
|
"reg.exe",
|
|
"add",
|
|
_EXPLORER_KEY,
|
|
"/v",
|
|
"Desktop",
|
|
"/t",
|
|
"REG_SZ",
|
|
"/d",
|
|
desktop_name,
|
|
"/f",
|
|
),
|
|
_proton_command(
|
|
config,
|
|
"runinprefix",
|
|
"reg.exe",
|
|
"add",
|
|
_DESKTOPS_KEY,
|
|
"/v",
|
|
desktop_name,
|
|
"/t",
|
|
"REG_SZ",
|
|
"/d",
|
|
desktop_size,
|
|
"/f",
|
|
),
|
|
]
|
|
|
|
|
|
def _registry_restore_commands(
|
|
config: AppConfig,
|
|
snapshot: DesktopRegistrySnapshot,
|
|
) -> list[list[str]]:
|
|
return [
|
|
_registry_restore_command(
|
|
config,
|
|
_EXPLORER_KEY,
|
|
"Desktop",
|
|
snapshot.selected_desktop.exists,
|
|
snapshot.selected_desktop.value,
|
|
),
|
|
_registry_restore_command(
|
|
config,
|
|
_DESKTOPS_KEY,
|
|
snapshot.desktop_name,
|
|
snapshot.desktop_size.exists,
|
|
snapshot.desktop_size.value,
|
|
),
|
|
]
|
|
|
|
|
|
def _registry_restore_command(
|
|
config: AppConfig,
|
|
key: str,
|
|
name: str,
|
|
existed: bool,
|
|
value: str | None,
|
|
) -> list[str]:
|
|
if existed:
|
|
assert value is not None
|
|
return _proton_command(
|
|
config,
|
|
"runinprefix",
|
|
"reg.exe",
|
|
"add",
|
|
key,
|
|
"/v",
|
|
name,
|
|
"/t",
|
|
"REG_SZ",
|
|
"/d",
|
|
value,
|
|
"/f",
|
|
)
|
|
return _proton_command(
|
|
config,
|
|
"runinprefix",
|
|
"reg.exe",
|
|
"delete",
|
|
key,
|
|
"/v",
|
|
name,
|
|
"/f",
|
|
)
|
|
|
|
|
|
def _run_checked_commands(
|
|
logger: CommandLogger,
|
|
commands: Sequence[Sequence[str]],
|
|
*,
|
|
cwd: Path,
|
|
env_overrides: Mapping[str, str],
|
|
) -> None:
|
|
for command in commands:
|
|
outcome = logger.run(
|
|
command,
|
|
cwd=cwd,
|
|
env_overrides=env_overrides,
|
|
timeout_seconds=15.0,
|
|
)
|
|
if not _command_ok(outcome):
|
|
raise ExperimentError(
|
|
f"Command failed ({outcome.returncode}): {list(command)!r}: "
|
|
f"{outcome.error or outcome.stderr.strip()}"
|
|
)
|
|
|
|
|
|
def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None:
|
|
outcome = cleanup_dedicated_wineserver(
|
|
config,
|
|
logger,
|
|
timeout_seconds=15.0,
|
|
)
|
|
if (
|
|
outcome.returncode == 1
|
|
and not outcome.timed_out
|
|
and outcome.error is None
|
|
and not outcome.stdout
|
|
and not outcome.stderr
|
|
):
|
|
# Proton 7 wineserver -k returns 1 when this dedicated prefix has no
|
|
# server to stop. That is the desired idempotent cleanup state.
|
|
return
|
|
if not _command_ok(outcome):
|
|
raise ExperimentError(
|
|
"Dedicated-prefix wineserver cleanup failed: "
|
|
f"{outcome.error or outcome.stderr.strip() or outcome.returncode}"
|
|
)
|
|
|
|
|
|
def _command_ok(result: CommandResult) -> bool:
|
|
return not result.timed_out and result.error is None and result.returncode == 0
|
|
|
|
|
|
def _collect_hashes(config: AppConfig, testcase: Testcase) -> list[dict[str, Any]]:
|
|
items: list[dict[str, Any]] = []
|
|
for verification in verify_known_game_hashes(config.paths.live_game):
|
|
item = verification.to_dict()
|
|
item["required"] = True
|
|
item["role"] = "known-game-baseline"
|
|
items.append(item)
|
|
extra = {
|
|
config.proton7.proton: "proton-launcher",
|
|
config.proton7.prefix / "drive_c/windows/syswow64/d3dx9_43.dll": "native-d3dx9-43",
|
|
config.proton7.prefix
|
|
/ "drive_c/windows/syswow64/d3dcompiler_43.dll": "native-d3dcompiler-43",
|
|
config.proton7.prefix / "drive_c/windows/syswow64/d3d9.dll": "dxvk-d3d9",
|
|
Path(__file__).with_name("proton_compat.py"): "proton-python314-shim",
|
|
config.source: "local-config",
|
|
testcase.source: "testcase",
|
|
}
|
|
dxvk_config = testcase.env.get("DXVK_CONFIG_FILE")
|
|
if dxvk_config:
|
|
dxvk_config_path = Path(dxvk_config).expanduser()
|
|
if not dxvk_config_path.is_absolute():
|
|
dxvk_config_path = (project_root() / dxvk_config_path).absolute()
|
|
extra[dxvk_config_path] = "dxvk-config"
|
|
for path, role in extra.items():
|
|
item: dict[str, Any] = {
|
|
"path": str(path),
|
|
"role": role,
|
|
"required": role not in {"local-config", "testcase"},
|
|
"expected_sha256": None,
|
|
"actual_sha256": None,
|
|
"ok": False,
|
|
"error": None,
|
|
}
|
|
try:
|
|
item["actual_sha256"] = sha256_file(path)
|
|
item["ok"] = True
|
|
except OSError as exc:
|
|
item["error"] = str(exc)
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def _collect_dll_overrides(prefix: Path) -> dict[str, str]:
|
|
sections = read_registry_string_values(prefix / "user.reg")
|
|
return dict(sorted(sections.get(r"Software\Wine\DllOverrides", {}).items()))
|
|
|
|
|
|
def _serialize_log_states(states: Mapping[str, LogFileState]) -> list[dict[str, Any]]:
|
|
return [_serialize_log_state(state) for state in sorted(states.values(), key=lambda x: str(x.path))]
|
|
|
|
|
|
def _serialize_log_state(state: LogFileState) -> dict[str, Any]:
|
|
return {
|
|
"path": str(state.path),
|
|
"mtime_ns": state.mtime_ns,
|
|
"size": state.size,
|
|
"inode": state.inode,
|
|
}
|
|
|
|
|
|
def _desktop_name(result_directory: Path) -> str:
|
|
digits = "".join(character for character in result_directory.name if character.isdigit())
|
|
return f"PTDiag{digits[-12:]}"
|
|
|
|
|
|
def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None:
|
|
write_json(result.path / "summary.json", summary)
|
|
_write_text(result.path / "report.md", _report(summary))
|
|
|
|
|
|
def _report(summary: Mapping[str, Any]) -> str:
|
|
preflight = summary.get("preflight", {})
|
|
launch = summary.get("launch", {})
|
|
registry = summary.get("registry", {})
|
|
native_log = summary.get("native_log", {})
|
|
modification = summary.get("modification")
|
|
state_cache = summary.get("dxvk_state_cache")
|
|
runtime = summary.get("runtime")
|
|
lines = [
|
|
f"# {summary.get('test', 'experiment')}",
|
|
"",
|
|
f"Status: `{summary.get('status')}`",
|
|
f"Result directory: `{summary.get('result_directory')}`",
|
|
f"Dry run: `{summary.get('dry_run')}`",
|
|
"",
|
|
"## Preflight",
|
|
"",
|
|
f"Passed: `{preflight.get('ok')}`" if isinstance(preflight, Mapping) else "Passed: `False`",
|
|
]
|
|
if isinstance(preflight, Mapping):
|
|
for error in preflight.get("errors", []):
|
|
lines.append(f"- {error}")
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Launch",
|
|
"",
|
|
f"CWD: `{launch.get('cwd')}`" if isinstance(launch, Mapping) else "",
|
|
f"Timeout: `{launch.get('timeout_seconds')}` seconds"
|
|
if isinstance(launch, Mapping)
|
|
else "",
|
|
"",
|
|
"## Current-run native log",
|
|
"",
|
|
f"Source: `{native_log.get('source')}`" if isinstance(native_log, Mapping) else "",
|
|
f"Captured bytes: `{native_log.get('current_run_bytes')}`"
|
|
if isinstance(native_log, Mapping)
|
|
else "",
|
|
"",
|
|
"## Registry rollback",
|
|
"",
|
|
f"Restored: `{registry.get('restored')}`" if isinstance(registry, Mapping) else "",
|
|
]
|
|
)
|
|
if summary.get("review_gate"):
|
|
lines.extend(["", "## Review gate", "", str(summary["review_gate"])])
|
|
if isinstance(runtime, Mapping) and isinstance(runtime.get("performance"), Mapping):
|
|
performance = runtime["performance"]
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Performance capture",
|
|
"",
|
|
f"Samples: `{performance.get('sample_count')}`",
|
|
f"Interval: `{performance.get('interval_seconds')}` seconds",
|
|
f"CSV: `{performance.get('output')}`",
|
|
]
|
|
)
|
|
if isinstance(state_cache, Mapping):
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## DXVK state cache",
|
|
"",
|
|
f"Directory: `{state_cache.get('directory')}`",
|
|
f"Seeded: `{state_cache.get('seeded')}`",
|
|
f"Before: `{state_cache.get('before')}`",
|
|
f"After: `{state_cache.get('after')}`",
|
|
]
|
|
)
|
|
if isinstance(modification, Mapping):
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Disposable modification",
|
|
"",
|
|
f"Kind: `{modification.get('kind')}`",
|
|
f"Source: `{modification.get('source')}`",
|
|
f"Destination: `{modification.get('destination')}`",
|
|
f"Prepared: `{modification.get('prepared')}`",
|
|
f"Prefix link restored: `{modification.get('prefix_link_restored')}`",
|
|
f"Disposable copy removed: `{modification.get('disposable_removed')}`",
|
|
]
|
|
)
|
|
if summary.get("error"):
|
|
lines.extend(["", "## Error", "", str(summary["error"])])
|
|
return "\n".join(line for line in lines if line is not None).rstrip() + "\n"
|
|
|
|
|
|
def _write_text(path: Path, value: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8")
|
|
temporary.replace(path)
|