Files
pristontale-eu-linux/ptdiag/experiment.py
2026-09-03 21:52:06 +02:00

1810 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
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,
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,
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_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
assert process is not None
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_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:
current_state = final_state
current_text = read_current_run_log_text(final_state, baseline)
current_analysis = parse_current_run_log(final_state, baseline).to_summary()
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 = 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 [
_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",
project_root() / "ptlinux/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)