258 lines
7.3 KiB
Python
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)
|