Files
pristontale-eu-linux/src/ptdiag/experiments/evidence.py

258 lines
7.3 KiB
Python

"""Evidence collection, serialization, and experiment report generation."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import os
from pathlib import Path
from typing import Any, Protocol
from ..config import AppConfig, project_root
from ..filesystem import sha256_file, verify_known_game_hashes
from ..logs import LogFileState
from ..registry import read_registry_string_values
from ..result import ResultDirectory, write_json
from ..testcase import Testcase
class HashVerification(Protocol):
def to_dict(self) -> dict[str, Any]: ...
HashVerifier = Callable[[Path], Sequence[HashVerification]]
HashFile = Callable[[Path], str]
RegistryReader = Callable[[Path], Mapping[str, Mapping[str, str]]]
JsonWriter = Callable[[Path, Any], None]
TextWriter = Callable[[Path, str], None]
ReportRenderer = Callable[[Mapping[str, Any]], str]
def collect_hashes(
config: AppConfig,
testcase: Testcase,
*,
repository_root: Path | None = None,
verifier: HashVerifier = verify_known_game_hashes,
hash_file: HashFile = sha256_file,
) -> list[dict[str, Any]]:
"""Collect the immutable inputs needed to interpret one experiment."""
root = project_root() if repository_root is None else repository_root
items: list[dict[str, Any]] = []
for verification in verifier(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",
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 = (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"] = hash_file(path)
item["ok"] = True
except OSError as error:
item["error"] = str(error)
items.append(item)
return items
def collect_dll_overrides(
prefix: Path,
*,
reader: RegistryReader = read_registry_string_values,
) -> dict[str, str]:
sections = reader(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 item: str(item.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 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 finalize_result(
result: ResultDirectory,
summary: dict[str, Any],
*,
json_writer: JsonWriter = write_json,
text_writer: TextWriter,
report_renderer: ReportRenderer,
) -> None:
json_writer(result.path / "summary.json", summary)
text_writer(result.path / "report.md", report_renderer(summary))
def render_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, *, pid: int | None = None) -> None:
"""Atomically write normalized UTF-8 text evidence."""
path.parent.mkdir(parents=True, exist_ok=True)
process_id = os.getpid() if pid is None else pid
temporary = path.with_name(f".{path.name}.{process_id}.tmp")
temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8")
temporary.replace(path)