"""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)