Added more folders and restructured classes inside them.

This commit is contained in:
2026-09-12 11:10:47 +02:00
parent be7a4782e8
commit 2cbb9315bf
152 changed files with 21734 additions and 914 deletions

28
src/ptdiag/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""PristonTale EU Linux diagnostic harness and shared binary helpers."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .config import AppConfig, ConfigError, load_config
__all__ = ["AppConfig", "ConfigError", "load_config"]
__version__ = "0.1.0"
def __getattr__(name: str) -> Any:
"""Load diagnostic configuration helpers only when the full harness needs them.
The launcher release retains :mod:`ptdiag.assbin` and :mod:`ptdiag.logs` as
compatibility façades. Deferring the larger diagnostic-only imports keeps the
compact package importable without shipping the investigation harness.
"""
if name in {"AppConfig", "ConfigError", "load_config"}:
from .config import AppConfig, ConfigError, load_config
return {
"AppConfig": AppConfig,
"ConfigError": ConfigError,
"load_config": load_config,
}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

6
src/ptdiag/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
from .cli import main
if __name__ == "__main__":
main()

22
src/ptdiag/artifacts.py Normal file
View File

@@ -0,0 +1,22 @@
"""Generated diagnostic output paths for checkout-only experiments."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
from .config import repository_root
RESULTS_ROOT_ENVIRONMENT_VARIABLE = "PTDIAG_RESULTS_ROOT"
def default_results_root(environment: Mapping[str, str] | None = None) -> Path:
"""Return the configurable root for generated diagnostic evidence."""
values = os.environ if environment is None else environment
explicit = values.get(RESULTS_ROOT_ENVIRONMENT_VARIABLE, "").strip()
if explicit:
return Path(explicit).expanduser().absolute()
return repository_root() / ".artifacts/diagnostics/results"

72
src/ptdiag/assbin.py Normal file
View File

@@ -0,0 +1,72 @@
"""Compatibility façade for launcher-owned wrapped Assbin handling."""
from ptlinux.game.compatibility.assbin import (
ASSBIN_CHUNK_AIANIMATION,
ASSBIN_CHUNK_AIMATERIAL,
ASSBIN_CHUNK_AIMATERIALPROPERTY,
ASSBIN_CHUNK_AIMESH,
ASSBIN_CHUNK_AINODE,
ASSBIN_CHUNK_AISCENE,
ASSBIN_CHUNK_AITEXTURE,
ASSBIN_HEADER_LENGTH,
MAX_CAPTURE_LENGTH,
MAX_STRING_LENGTH,
PROPERTY_TYPE_NAMES,
READ_CHUNK_SIZE,
AssbinError,
AssbinHeader,
BlobSummary,
BytesReader,
Chunk,
EmbeddedTexture,
Material,
MaterialProperty,
PngMetadata,
Reader,
SceneCounts,
SceneInspection,
XorFileReader,
_inspect_png,
_png_scanlines_sha256,
extract_embedded_texture,
inspect_wrapped_assbin,
normalize_png_16_rgb_to_8,
normalize_wrapped_assbin_textures,
xor_bytes,
)
__all__ = [
"ASSBIN_CHUNK_AIANIMATION",
"ASSBIN_CHUNK_AIMATERIAL",
"ASSBIN_CHUNK_AIMATERIALPROPERTY",
"ASSBIN_CHUNK_AIMESH",
"ASSBIN_CHUNK_AINODE",
"ASSBIN_CHUNK_AISCENE",
"ASSBIN_CHUNK_AITEXTURE",
"ASSBIN_HEADER_LENGTH",
"MAX_CAPTURE_LENGTH",
"MAX_STRING_LENGTH",
"PROPERTY_TYPE_NAMES",
"READ_CHUNK_SIZE",
"AssbinError",
"AssbinHeader",
"BlobSummary",
"BytesReader",
"Chunk",
"EmbeddedTexture",
"Material",
"MaterialProperty",
"PngMetadata",
"Reader",
"SceneCounts",
"SceneInspection",
"XorFileReader",
"_inspect_png",
"_png_scanlines_sha256",
"extract_embedded_texture",
"inspect_wrapped_assbin",
"normalize_png_16_rgb_to_8",
"normalize_wrapped_assbin_textures",
"xor_bytes",
]

203
src/ptdiag/cli.py Normal file
View File

@@ -0,0 +1,203 @@
"""Command-line interface for diagnostics and controlled runtime experiments."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import Sequence
from .assbin import (
AssbinError,
extract_embedded_texture,
inspect_wrapped_assbin,
normalize_wrapped_assbin_textures,
)
from .config import ConfigError, load_config
from .experiment import run_experiment
from .filesystem import verify_known_game_hashes
from .ini import IniError, override_ini_value
from .logs import parse_native_log
from .process import collect_environment
from .testcase import TestcaseError, load_testcase
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ptdiag")
parser.add_argument("--config", type=Path, help="path to local.toml")
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("env", help="inspect host and configured paths")
subcommands.add_parser("verify", help="verify environment and known core hashes")
log_parser = subcommands.add_parser("log", help="native game-log operations")
log_commands = log_parser.add_subparsers(dest="log_command", required=True)
analyze = log_commands.add_parser("analyze", help="parse a native game log")
analyze.add_argument("path", type=Path)
scene_parser = subcommands.add_parser("scene", help="wrapped scene-cache operations")
scene_commands = scene_parser.add_subparsers(dest="scene_command", required=True)
inspect = scene_commands.add_parser(
"inspect",
help="inspect an XOR-wrapped Assimp .rovug cache",
)
inspect.add_argument("path", type=Path)
inspect.add_argument("--output", type=Path, help="also write the complete JSON result")
extract = scene_commands.add_parser(
"extract-texture",
help="extract and verify one embedded texture from a .rovug cache",
)
extract.add_argument("path", type=Path)
extract.add_argument("index", type=int)
extract.add_argument("output", type=Path)
normalize = scene_commands.add_parser(
"normalize-textures",
help="write a cache with selected 16-bit RGB PNGs normalized to 8-bit",
)
normalize.add_argument("path", type=Path)
normalize.add_argument("output", type=Path)
normalize.add_argument("indices", type=int, nargs="+")
ini_parser = subcommands.add_parser("ini", help="verified INI operations")
ini_commands = ini_parser.add_subparsers(dest="ini_command", required=True)
ini_override = ini_commands.add_parser(
"override",
help="replace one exact INI value after checking its expected value",
)
ini_override.add_argument("path", type=Path)
ini_override.add_argument("section")
ini_override.add_argument("key")
ini_override.add_argument("expected")
ini_override.add_argument("value")
run_parser = subcommands.add_parser("run", help="run a controlled testcase")
run_parser.add_argument("testcase", help="testcase name or TOML path")
run_parser.add_argument(
"--dry-run",
action="store_true",
help="write and validate the complete plan without launching Game.exe",
)
run_parser.add_argument(
"--results-root",
type=Path,
help="override the result-directory root",
)
return parser
def main(argv: Sequence[str] | None = None) -> None:
raise SystemExit(run(argv))
def run(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "log" and args.log_command == "analyze":
try:
analysis = parse_native_log(args.path)
except OSError as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json({"ok": True, **analysis.to_summary()})
return 0
if args.command == "scene" and args.scene_command == "inspect":
try:
inspection = inspect_wrapped_assbin(args.path)
except (AssbinError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
payload = {"ok": True, **inspection.to_dict()}
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2, sort_keys=True)
stream.write("\n")
_print_json(payload)
return 0
if args.command == "scene" and args.scene_command == "extract-texture":
try:
inspection = inspect_wrapped_assbin(args.path)
extraction = extract_embedded_texture(
args.path,
inspection,
args.index,
args.output,
)
except (AssbinError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json({"ok": True, **extraction})
return 0
if args.command == "scene" and args.scene_command == "normalize-textures":
try:
normalization = normalize_wrapped_assbin_textures(
args.path,
args.output,
tuple(args.indices),
)
except (AssbinError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json({"ok": True, **normalization})
return 0
if args.command == "ini" and args.ini_command == "override":
try:
result = override_ini_value(
args.path,
args.section,
args.key,
args.expected,
args.value,
)
except (IniError, OSError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json(result)
return 0
try:
config = load_config(args.config)
except ConfigError as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
if args.command == "run":
try:
testcase = load_testcase(args.testcase)
outcome = run_experiment(
config,
testcase,
dry_run=args.dry_run,
results_root=args.results_root,
)
except (TestcaseError, OSError, ValueError) as exc:
_print_json({"ok": False, "error": str(exc)})
return 2
_print_json(
{
"ok": outcome.summary.get("ok", False),
"status": outcome.summary.get("status"),
"result_directory": str(outcome.result_directory),
}
)
return outcome.exit_code
environment = collect_environment(config)
if args.command == "env":
_print_json(environment)
return 0 if environment["ok"] else 1
hashes = verify_known_game_hashes(config.paths.live_game)
hashes_ok = all(item.ok for item in hashes)
payload = {
"ok": bool(environment["ok"]) and hashes_ok,
"environment": environment,
"hashes": [item.to_dict() for item in hashes],
}
_print_json(payload)
return 0 if payload["ok"] else 1
def _print_json(value: object) -> None:
json.dump(value, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")

167
src/ptdiag/config.py Normal file
View File

@@ -0,0 +1,167 @@
"""Typed TOML configuration loading."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping
import re
import tomllib
DEFAULT_CONFIG_PATH = Path("diagnostics/config/local.toml")
LEGACY_CONFIG_PATH = Path("config/local.toml")
class ConfigError(ValueError):
"""Raised when local configuration is missing or invalid."""
@dataclass(frozen=True)
class PathsConfig:
app_root: Path
live_prefix: Path
live_game: Path
logs: Path
native_logs: Path
@dataclass(frozen=True)
class Proton7Config:
proton: Path
steam_root: Path
compat: Path
prefix: Path
@dataclass(frozen=True)
class GameConfig:
exe: str
args: tuple[str, ...]
virtual_desktop: str
@dataclass(frozen=True)
class AppConfig:
paths: PathsConfig
proton7: Proton7Config
game: GameConfig
source: Path
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
return _paths_to_strings(result)
def project_root() -> Path:
"""Return the Python source root used by checkout-only helper commands."""
return Path(__file__).resolve().parent.parent
def repository_root() -> Path:
"""Return the repository root while retaining flat-install compatibility."""
source_root = project_root()
checkout_root = source_root.parent
if source_root.name == "src" and (checkout_root / "pyproject.toml").is_file():
return checkout_root
return source_root
def diagnostics_root() -> Path:
return repository_root() / "diagnostics"
def resolve_config_path(path: str | Path | None = None) -> Path:
if path is not None:
return Path(path).expanduser().absolute()
root = repository_root()
preferred = root / DEFAULT_CONFIG_PATH
legacy = root / LEGACY_CONFIG_PATH
if preferred.is_file():
return preferred.absolute()
if legacy.is_file():
return legacy.absolute()
return preferred.absolute()
def load_config(path: str | Path | None = None) -> AppConfig:
config_path = resolve_config_path(path)
try:
with config_path.open("rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError as exc:
raise ConfigError(
f"Configuration not found: {config_path}. "
"Copy diagnostics/config/local.example.toml to "
"diagnostics/config/local.toml. Legacy config/local.toml is also recognized."
) from exc
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
paths = _section(data, "paths")
proton7 = _section(data, "proton7")
game = _section(data, "game")
virtual_desktop = _required_string(game, "virtual_desktop")
if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None:
raise ConfigError("game.virtual_desktop must look like 1600x900")
return AppConfig(
paths=PathsConfig(
app_root=_required_path(paths, "app_root"),
live_prefix=_required_path(paths, "live_prefix"),
live_game=_required_path(paths, "live_game"),
logs=_required_path(paths, "logs"),
native_logs=_required_path(paths, "native_logs"),
),
proton7=Proton7Config(
proton=_required_path(proton7, "proton"),
steam_root=_required_path(proton7, "steam_root"),
compat=_required_path(proton7, "compat"),
prefix=_required_path(proton7, "prefix"),
),
game=GameConfig(
exe=_required_string(game, "exe"),
args=_required_string_tuple(game, "args"),
virtual_desktop=virtual_desktop,
),
source=config_path,
)
def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]:
value = data.get(name)
if not isinstance(value, Mapping):
raise ConfigError(f"Missing or invalid [{name}] section")
return value
def _required_string(section: Mapping[str, Any], key: str) -> str:
value = section.get(key)
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{key} must be a non-empty string")
return value
def _required_path(section: Mapping[str, Any], key: str) -> Path:
return Path(_required_string(section, key)).expanduser().absolute()
def _required_string_tuple(section: Mapping[str, Any], key: str) -> tuple[str, ...]:
value = section.get(key)
if not isinstance(value, list) or not value or not all(
isinstance(item, str) and item for item in value
):
raise ConfigError(f"{key} must be a non-empty array of strings")
return tuple(value)
def _paths_to_strings(value: Any) -> Any:
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {key: _paths_to_strings(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_paths_to_strings(item) for item in value]
return value

775
src/ptdiag/experiment.py Normal file
View File

@@ -0,0 +1,775 @@
"""Controlled Proton 7 runtime experiments and result generation."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
from typing import Any, Mapping, Sequence
from .artifacts import default_results_root
from .config import AppConfig, project_root
from .experiments.cleanup import (
DISPOSABLE_DIRECTORY_NAME as _DISPOSABLE_DIRECTORY_NAME,
cleanup_checked as _cleanup_checked_impl,
command_ok as _command_ok,
registry_restore_command as _registry_restore_command_impl,
registry_restore_commands as _registry_restore_commands_impl,
registry_setup_commands as _registry_setup_commands_impl,
remove_disposable_game as _remove_disposable_game_impl,
replace_game_link as _replace_game_link,
require_command as _require_command,
restore_game_link as _restore_game_link,
run_checked_commands as _run_checked_commands,
validate_disposable_path as _validate_disposable_path_impl,
)
from .experiments.evidence import (
collect_dll_overrides as _collect_dll_overrides_impl,
collect_hashes as _collect_hashes_impl,
desktop_name as _desktop_name,
finalize_result as _finalize_impl,
render_report as _report,
serialize_log_state as _serialize_log_state,
serialize_log_states as _serialize_log_states,
setup_failure_summary as _setup_failure_summary,
write_text as _write_text,
)
from .experiments.execution import (
capture_game_process as _capture_game_process_impl,
game_processes as _game_processes_impl,
run_and_watch as _run_and_watch_impl,
stop_process_group as _stop_process_group_impl,
)
from .experiments.log_selection import (
capture_current_native_logs,
native_log_priority as _native_log_priority,
observe_current_native_logs,
scene_import_survived,
)
from .experiments.models import ExperimentError, ExperimentResult
from .experiments.preparation import (
_allocated_tree_bytes,
_apply_disposable_ini_overrides,
_dry_run_commands,
_ini_override_command,
_inspect_ini_overrides,
_launch_argv,
_log_modification_plan,
_preflight as _preflight_impl,
_prepare_disposable_game,
_prepare_state_cache,
_proton_command,
_proton_environment,
_resource_copy_paths,
_runtime_environment,
_safe_game_file,
_safe_resource_relative_path,
_state_cache_file_snapshot,
_state_cache_plan,
)
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
from .performance import PerformanceSampler
from .prefix import (
PrefixSafetyError,
cleanup_dedicated_wineserver,
)
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
from .testcase import Testcase
from .trace import process_trace_shards
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 default_results_root(), 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 default_results_root(), 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 _remove_disposable_game(
config: AppConfig,
result: ResultDirectory,
disposable_game: Path,
logger: CommandLogger,
) -> None:
_remove_disposable_game_impl(
config,
result,
disposable_game,
logger,
directory_name=_DISPOSABLE_DIRECTORY_NAME,
)
def _validate_disposable_path(
staging_root: Path,
path: Path,
expected_name: str,
) -> None:
_validate_disposable_path_impl(
staging_root,
path,
expected_name,
directory_name=_DISPOSABLE_DIRECTORY_NAME,
)
def _preflight(
config: AppConfig,
testcase: Testcase,
environment: Mapping[str, Any],
hashes: list[dict[str, Any]],
) -> list[str]:
return _preflight_impl(
config,
testcase,
environment,
hashes,
game_processes=_game_processes,
)
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]:
return _run_and_watch_impl(
config,
testcase,
result,
logger,
argv,
env_overrides,
baseline,
popen=subprocess.Popen,
clock_ns=time.time_ns,
sleep=time.sleep,
now=lambda: datetime.now().astimezone(),
environment_factory=lambda: os.environ.copy(),
json_writer=write_json,
process_capture=_capture_game_process,
process_stopper=_stop_process_group,
performance_sampler_factory=PerformanceSampler,
log_observer=observe_current_native_logs,
log_capture=capture_current_native_logs,
scene_survived=scene_import_survived,
serialize_state=_serialize_log_state,
text_writer=_write_text,
prioritize_log=_native_log_priority,
discover_logs=discover_current_native_logs,
parse_log=parse_current_run_log,
read_log=read_current_run_log_text,
snapshot_logs=snapshot_native_logs,
)
def _stop_process_group(process: subprocess.Popen[str]) -> None:
_stop_process_group_impl(
process,
kill_group=os.killpg,
signals=(
(signal.SIGINT, 5.0),
(signal.SIGTERM, 5.0),
(signal.SIGKILL, 3.0),
),
)
def _capture_game_process(
testcase: Testcase,
result_directory: Path,
) -> dict[str, Any] | None:
return _capture_game_process_impl(
testcase,
result_directory,
game_process_scan=_game_processes,
proc_root=Path("/proc"),
copy_file=shutil.copyfile,
)
def _game_processes() -> list[int]:
return _game_processes_impl(proc_root=Path("/proc"))
def _registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
) -> list[list[str]]:
return _registry_setup_commands_impl(
config,
desktop_name,
desktop_size,
proton_command=_proton_command,
)
def _registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
) -> list[list[str]]:
return _registry_restore_commands_impl(
config,
snapshot,
proton_command=_proton_command,
)
def _registry_restore_command(
config: AppConfig,
key: str,
name: str,
existed: bool,
value: str | None,
) -> list[str]:
return _registry_restore_command_impl(
config,
key,
name,
existed,
value,
proton_command=_proton_command,
)
def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None:
_cleanup_checked_impl(
config,
logger,
cleaner=cleanup_dedicated_wineserver,
)
def _collect_hashes(
config: AppConfig,
testcase: Testcase,
) -> list[dict[str, Any]]:
return _collect_hashes_impl(
config,
testcase,
repository_root=project_root(),
verifier=verify_known_game_hashes,
hash_file=sha256_file,
)
def _collect_dll_overrides(prefix: Path) -> dict[str, str]:
return _collect_dll_overrides_impl(
prefix,
reader=read_registry_string_values,
)
def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None:
_finalize_impl(
result,
summary,
json_writer=write_json,
text_writer=_write_text,
report_renderer=_report,
)

View File

@@ -0,0 +1 @@
"""Controlled diagnostic experiment implementation modules."""

View File

@@ -0,0 +1,287 @@
"""Experiment cleanup, prefix-link rollback, and registry restoration."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import os
from pathlib import Path
from ..config import AppConfig
from ..prefix import cleanup_dedicated_wineserver
from ..registry import DesktopRegistrySnapshot
from ..result import ResultDirectory
from ..runner import CommandLogger, CommandResult
from .models import ExperimentError
EXPLORER_KEY = r"HKCU\Software\Wine\Explorer"
DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops"
DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable"
ProtonCommand = Callable[..., list[str]]
WineserverCleaner = Callable[..., CommandResult]
def replace_game_link(
prefix_link: Path,
disposable_game: Path,
expected_live_game: Path,
logger: CommandLogger,
) -> str:
"""Point the dedicated prefix at a verified disposable game tree."""
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:
"""Restore the dedicated-prefix game link and verify its exact target."""
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,
*,
directory_name: str = DISPOSABLE_DIRECTORY_NAME,
) -> None:
"""Remove only the result-scoped disposable tree beneath its managed root."""
staging_root = (config.paths.app_root / directory_name).absolute()
validate_disposable_path(
staging_root,
disposable_game,
result.path.name,
directory_name=directory_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,
*,
directory_name: str = DISPOSABLE_DIRECTORY_NAME,
) -> None:
"""Reject cleanup targets outside the managed, result-scoped staging root."""
if path.parent != staging_root or path.name != expected_name:
raise ExperimentError(f"Unsafe disposable game path: {path}")
if staging_root.name != directory_name:
raise ExperimentError(f"Unsafe disposable staging root: {staging_root}")
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 registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
*,
proton_command: ProtonCommand,
) -> 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,
*,
proton_command: ProtonCommand,
) -> list[list[str]]:
return [
registry_restore_command(
config,
EXPLORER_KEY,
"Desktop",
snapshot.selected_desktop.exists,
snapshot.selected_desktop.value,
proton_command=proton_command,
),
registry_restore_command(
config,
DESKTOPS_KEY,
snapshot.desktop_name,
snapshot.desktop_size.exists,
snapshot.desktop_size.value,
proton_command=proton_command,
),
]
def registry_restore_command(
config: AppConfig,
key: str,
name: str,
existed: bool,
value: str | None,
*,
proton_command: ProtonCommand,
) -> 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,
*,
cleaner: WineserverCleaner = cleanup_dedicated_wineserver,
) -> None:
"""Stop the dedicated wineserver while accepting its idle return contract."""
outcome = cleaner(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
):
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

View File

@@ -0,0 +1,257 @@
"""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)

View File

@@ -0,0 +1,332 @@
"""Process execution and runtime observation for diagnostic experiments."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
from typing import Any
from ..config import AppConfig
from ..logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
from ..performance import PerformanceSampler
from ..result import ResultDirectory, write_json
from ..runner import CommandLogger
from ..testcase import Testcase
from .evidence import serialize_log_state, write_text
from .log_selection import (
NativeLogObservation,
NativeLogSelection,
capture_current_native_logs,
native_log_priority,
observe_current_native_logs,
scene_import_survived,
)
from .models import ExperimentError
ProcessFactory = Callable[..., subprocess.Popen[str]]
ProcessCapture = Callable[[Testcase, Path], dict[str, Any] | None]
ProcessStopper = Callable[[subprocess.Popen[str]], None]
PerformanceSamplerFactory = Callable[[int, Path], PerformanceSampler]
JsonWriter = Callable[[Path, Any], None]
TextWriter = Callable[[Path, str], None]
EnvironmentFactory = Callable[[], dict[str, str]]
LogObserver = Callable[..., Sequence[NativeLogObservation]]
LogCapture = Callable[..., NativeLogSelection]
LogStateSerializer = Callable[[LogFileState], dict[str, Any]]
LogPrioritizer = Callable[
[LogFileState, Mapping[str, Any]],
tuple[int, int, int, int, str],
]
def game_processes(*, proc_root: Path = Path("/proc")) -> list[int]:
"""Return the matching game process IDs visible under one proc tree."""
result: list[int] = []
for entry in proc_root.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 capture_game_process(
testcase: Testcase,
result_directory: Path,
*,
game_process_scan: Callable[[], list[int]] = game_processes,
proc_root: Path = Path("/proc"),
copy_file: Callable[[Path, Path], str] = shutil.copyfile,
) -> dict[str, Any] | None:
"""Capture the first matching process identity and optional memory map."""
processes = game_process_scan()
if not processes:
return None
pid = processes[0]
root = proc_root / 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:
copy_file(root / "maps", result_directory / "process-maps.txt")
except OSError as exc:
identity["capture_error"] = str(exc)
return identity
def stop_process_group(
process: subprocess.Popen[str],
*,
kill_group: Callable[[int, int], None] = os.killpg,
signals: Sequence[tuple[int, float]] = (
(signal.SIGINT, 5.0),
(signal.SIGTERM, 5.0),
(signal.SIGKILL, 3.0),
),
) -> None:
"""Stop a launcher process group with bounded escalation."""
if process.poll() is not None:
return
for signum, timeout in signals:
try:
kill_group(process.pid, signum)
except ProcessLookupError:
return
try:
process.wait(timeout=timeout)
return
except subprocess.TimeoutExpired:
continue
def _current_time() -> datetime:
return datetime.now().astimezone()
def _copy_environment() -> dict[str, str]:
return os.environ.copy()
def run_and_watch(
config: AppConfig,
testcase: Testcase,
result: ResultDirectory,
logger: CommandLogger,
argv: Sequence[str],
env_overrides: Mapping[str, str],
baseline: Mapping[str, LogFileState],
*,
popen: ProcessFactory = subprocess.Popen,
clock_ns: Callable[[], int] = time.time_ns,
sleep: Callable[[float], None] = time.sleep,
now: Callable[[], datetime] = _current_time,
environment_factory: EnvironmentFactory = _copy_environment,
json_writer: JsonWriter = write_json,
process_capture: ProcessCapture = capture_game_process,
process_stopper: ProcessStopper = stop_process_group,
performance_sampler_factory: PerformanceSamplerFactory = PerformanceSampler,
log_observer: LogObserver = observe_current_native_logs,
log_capture: LogCapture = capture_current_native_logs,
scene_survived: Callable[..., bool] = scene_import_survived,
serialize_state: LogStateSerializer = serialize_log_state,
text_writer: TextWriter = write_text,
prioritize_log: LogPrioritizer = native_log_priority,
discover_logs: Callable[..., Sequence[LogFileState]] = discover_current_native_logs,
parse_log: Callable[..., Any] = parse_current_run_log,
read_log: Callable[..., str] = read_current_run_log_text,
snapshot_logs: Callable[[Path], Mapping[str, LogFileState]] = snapshot_native_logs,
) -> dict[str, Any]:
"""Run one diagnostic process and collect its current-run evidence."""
runner_log_path = result.path / "runner.log"
environment = environment_factory()
environment.update(env_overrides)
test_start_ns = clock_ns()
started_at = now()
json_writer(
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_observation: NativeLogObservation | None = None
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"
failure_reason: str | None = None
with runner_log_path.open("a", encoding="utf-8") as runner_log:
try:
process = 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 = clock_ns()
if process_identity is None:
process_identity = process_capture(testcase, result.path)
if process_identity is not None and testcase.capture.performance_stats:
try:
performance_sampler = performance_sampler_factory(
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 observation in log_observer(
config.paths.native_logs,
baseline,
test_start_ns,
discoverer=discover_logs,
parser=parse_log,
reader=read_log,
):
current_observation = observation
if observation.failure_reason is not None:
failure_state = observation.state
failure_trigger_mtime_ns = observation.state.mtime_ns
detected_at_ns = now_ns
post_failure_deadline_ns = now_ns + int(
testcase.stop_after_failure_seconds * 1_000_000_000
)
failure_reason = observation.failure_reason
break
if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns:
stop_reason = failure_reason or "load-texture-to-material"
process_stopper(process)
break
if now_ns >= deadline_ns:
timed_out = True
stop_reason = "timeout"
process_stopper(process)
break
sleep(0.1)
if process.poll() is None:
process_stopper(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,
)
selection = log_capture(
config.paths.native_logs,
baseline,
test_start_ns,
result.path,
fallback=current_observation,
failure_state=failure_state,
serialize_state=serialize_state,
write_text=text_writer,
prioritize=prioritize_log,
discoverer=discover_logs,
parser=parse_log,
reader=read_log,
snapshotter=snapshot_logs,
)
current_observation = selection.observation
current_state = current_observation.state if current_observation is not None else None
current_text = current_observation.text if current_observation is not None else ""
current_analysis = (
current_observation.analysis if current_observation is not None else None
)
survived = scene_survived(current_analysis, timed_out=timed_out)
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": now().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": 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": list(selection.captures),
"performance": performance_summary,
"native_log": {
"source": str(current_state.path) if current_state else None,
"state": serialize_state(current_state) if current_state else None,
"current_run_bytes": len(current_text.encode("utf-8")),
},
}

View File

@@ -0,0 +1,212 @@
"""Current-run native-log observation, capture, and selection."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from ..logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
class NativeLogAnalysis(Protocol):
load_texture_to_material_failed: bool
asset_pack_health_failed: bool
def to_summary(self) -> dict[str, Any]: ...
NativeLogDiscoverer = Callable[
[Path, Mapping[str, LogFileState], int],
Sequence[LogFileState],
]
NativeLogParser = Callable[
[LogFileState, Mapping[str, LogFileState]],
NativeLogAnalysis,
]
NativeLogReader = Callable[
[LogFileState, Mapping[str, LogFileState]],
str,
]
NativeLogSnapshotter = Callable[[Path], Mapping[str, LogFileState]]
LogStateSerializer = Callable[[LogFileState], dict[str, Any]]
NativeLogPrioritizer = Callable[
[LogFileState, Mapping[str, Any]],
tuple[int, int, int, int, str],
]
TextWriter = Callable[[Path, str], None]
@dataclass(frozen=True)
class NativeLogObservation:
state: LogFileState
text: str
analysis: dict[str, Any]
failure_reason: str | None
@dataclass(frozen=True)
class NativeLogSelection:
observation: NativeLogObservation | None
captures: tuple[dict[str, Any], ...]
def observe_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
*,
discoverer: NativeLogDiscoverer = discover_current_native_logs,
parser: NativeLogParser = parse_current_run_log,
reader: NativeLogReader = read_current_run_log_text,
) -> tuple[NativeLogObservation, ...]:
"""Parse each current-run candidate and identify an actionable failure."""
observations: list[NativeLogObservation] = []
for state in discoverer(log_directory, baseline, test_start_ns):
parsed = parser(state, baseline)
failure_reason = (
"asset-pack-health"
if parsed.asset_pack_health_failed
else "load-texture-to-material"
if parsed.load_texture_to_material_failed
else None
)
observations.append(
NativeLogObservation(
state=state,
text=reader(state, baseline),
analysis=parsed.to_summary(),
failure_reason=failure_reason,
)
)
return tuple(observations)
def capture_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
result_directory: Path,
*,
fallback: NativeLogObservation | None,
failure_state: LogFileState | None,
serialize_state: LogStateSerializer,
write_text: TextWriter,
prioritize: NativeLogPrioritizer | None = None,
discoverer: NativeLogDiscoverer = discover_current_native_logs,
parser: NativeLogParser = parse_current_run_log,
reader: NativeLogReader = read_current_run_log_text,
snapshotter: NativeLogSnapshotter = snapshot_native_logs,
) -> NativeLogSelection:
"""Capture all current-run logs and choose the strongest primary evidence."""
captures: list[dict[str, Any]] = []
candidates: list[NativeLogObservation] = []
for index, state in enumerate(discoverer(log_directory, baseline, test_start_ns)):
text = reader(state, baseline)
analysis = parser(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_directory
/ "native-logs"
/ f"{index:02d}-{state.path.name}.current.log"
)
write_text(captured_path, text)
captures.append(
{
"source": str(state.path),
"state": serialize_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,
),
"asset_pack_health_failed": analysis.get(
"asset_pack_health_failed",
False,
),
"import_scene_threw": analysis.get("import_scene_threw", False),
}
)
candidates.append(
NativeLogObservation(
state=state,
text=text,
analysis=analysis,
failure_reason=None,
)
)
prioritizer = native_log_priority if prioritize is None else prioritize
selected = (
max(candidates, key=lambda item: prioritizer(item.state, item.analysis))
if candidates
else fallback
)
if failure_state is not None:
final_states = snapshotter(log_directory)
final_state = final_states.get(str(failure_state.path.absolute()))
if final_state is not None:
selected = NativeLogObservation(
state=final_state,
text=reader(final_state, baseline),
analysis=parser(final_state, baseline).to_summary(),
failure_reason=None,
)
if selected is not None:
write_text(result_directory / "native.log", selected.text)
return NativeLogSelection(observation=selected, captures=tuple(captures))
def native_log_priority(
state: LogFileState,
analysis: Mapping[str, Any],
) -> tuple[int, int, int, int, str]:
"""Rank scene-rich engine logs ahead of newer low-signal logs."""
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 scene_import_survived(
analysis: Mapping[str, Any] | None,
*,
timed_out: bool,
) -> bool:
"""Return whether the target scene completed without a captured failure."""
if analysis is None:
return False
return bool(
analysis.get("whispering_vale")
and not timed_out
and not analysis.get("load_texture_to_material_failed", False)
and not analysis.get("asset_pack_health_failed", False)
and not analysis.get("import_scene_threw", False)
and not analysis.get("failed_import_scenes", [])
)

View File

@@ -0,0 +1,18 @@
"""Stable result and error models for controlled diagnostic experiments."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@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."""

View File

@@ -0,0 +1,624 @@
"""Safe testcase preparation for controlled diagnostic experiments."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import sys
from typing import Any
from ..config import AppConfig, project_root
from ..filesystem import sha256_file
from ..ini import IniError, inspect_ini_value
from ..prefix import (
PrefixSafetyError,
cleanup_dedicated_wineserver,
validate_dedicated_prefix,
)
from ..registry import DesktopRegistrySnapshot
from ..result import ResultDirectory
from ..runner import CommandLogger
from ..testcase import TestIniOverride, TestModification, Testcase
from .cleanup import (
registry_restore_commands as _registry_restore_commands_impl,
registry_setup_commands as _registry_setup_commands_impl,
require_command as _require_command,
validate_disposable_path as _validate_disposable_path,
)
from .models import ExperimentError
_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"
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 _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 _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 _preflight(
config: AppConfig,
testcase: Testcase,
environment: Mapping[str, Any],
hashes: list[dict[str, Any]],
*,
game_processes: Callable[[], list[int]],
) -> 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 _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 _registry_setup_commands_impl(
config,
desktop_name,
desktop_size,
proton_command=_proton_command,
)
def _registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
) -> list[list[str]]:
return _registry_restore_commands_impl(
config,
snapshot,
proton_command=_proton_command,
)

59
src/ptdiag/filesystem.py Normal file
View File

@@ -0,0 +1,59 @@
"""Read-only filesystem and hash verification helpers."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from hashlib import sha256
from pathlib import Path
from typing import Mapping
KNOWN_GAME_HASHES: dict[str, str] = {
"Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5",
"game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09",
"gamese.exe": "c3443e50a93c6c778eff456122c351e5c7d2c1ceaea50e5a650bffe846a05c87",
"gamese.dll": "0ae6822f5f347f04bd2b0dc947f0286089c711c84703e110f6f8fc6e0d626131",
}
@dataclass(frozen=True)
class HashVerification:
path: Path
expected_sha256: str
actual_sha256: str | None
ok: bool
error: str | None = None
def to_dict(self) -> dict[str, str | bool | None]:
data = asdict(self)
data["path"] = str(self.path)
return data
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
digest = sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(chunk_size), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_file_hash(path: Path, expected_sha256: str) -> HashVerification:
expected = expected_sha256.casefold()
try:
actual = sha256_file(path)
except (OSError, ValueError) as exc:
return HashVerification(path, expected, None, False, str(exc))
return HashVerification(path, expected, actual, actual == expected)
def verify_hashes(expected: Mapping[Path, str]) -> list[HashVerification]:
return [verify_file_hash(path, digest) for path, digest in expected.items()]
def verify_known_game_hashes(live_game: Path) -> list[HashVerification]:
expected = {live_game / name: digest for name, digest in KNOWN_GAME_HASHES.items()}
return verify_hashes(expected)

112
src/ptdiag/ini.py Normal file
View File

@@ -0,0 +1,112 @@
"""Exact, verified INI value inspection and replacement."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
from typing import Any
from .filesystem import sha256_file
class IniError(ValueError):
"""Raised when an INI override cannot be applied exactly."""
def inspect_ini_value(path: Path, section: str, key: str) -> dict[str, Any]:
if path.is_symlink() or not path.is_file():
raise IniError(f"INI path is missing or unsafe: {path}")
text = _read_ini_text(path)
_, value = _find_ini_value(text, section, key)
state = path.stat()
return {
"path": str(path),
"section": section,
"key": key,
"value": value,
"sha256": sha256_file(path),
"size": state.st_size,
"mtime_ns": state.st_mtime_ns,
}
def override_ini_value(
path: Path,
section: str,
key: str,
expected: str,
value: str,
) -> dict[str, Any]:
before = inspect_ini_value(path, section, key)
if before["value"] != expected:
raise IniError(
f"INI value mismatch for [{section}] {key}: "
f"expected {expected!r}, found {before['value']!r}"
)
text = _read_ini_text(path)
line_index, _ = _find_ini_value(text, section, key)
lines = text.splitlines(keepends=True)
body = lines[line_index].rstrip("\r\n")
newline = lines[line_index][len(body) :]
left, separator, right = body.partition("=")
if not separator:
raise IniError(f"INI key has no equals separator: [{section}] {key}")
leading = right[: len(right) - len(right.lstrip())]
trailing = right[len(right.rstrip()) :]
lines[line_index] = f"{left}={leading}{value}{trailing}{newline}"
updated = "".join(lines)
if updated == text:
raise IniError(f"INI override made no change: [{section}] {key}")
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
if temporary.exists() or temporary.is_symlink():
raise IniError(f"INI temporary path already exists: {temporary}")
try:
with temporary.open("w", encoding="utf-8", newline="") as stream:
stream.write(updated)
stream.flush()
os.fsync(stream.fileno())
shutil.copystat(path, temporary, follow_symlinks=False)
temporary.replace(path)
finally:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
after = inspect_ini_value(path, section, key)
if after["value"] != value:
raise IniError(f"INI override verification failed: [{section}] {key}")
if after["sha256"] == before["sha256"]:
raise IniError(f"INI hash did not change: [{section}] {key}")
return {
"ok": True,
"before": before,
"after": after,
"expected": expected,
"value": value,
}
def _read_ini_text(path: Path) -> str:
with path.open("r", encoding="utf-8", newline="") as stream:
return stream.read()
def _find_ini_value(text: str, section: str, key: str) -> tuple[int, str]:
current_section: str | None = None
matches: list[tuple[int, str]] = []
for index, line in enumerate(text.splitlines(keepends=True)):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
current_section = stripped[1:-1].strip()
continue
if current_section is None or current_section.casefold() != section.casefold():
continue
if not stripped or stripped.startswith((";", "#")):
continue
left, separator, right = line.rstrip("\r\n").partition("=")
if separator and left.strip().casefold() == key.casefold():
matches.append((index, right.strip()))
if not matches:
raise IniError(f"INI key not found: [{section}] {key}")
if len(matches) != 1:
raise IniError(f"INI key is duplicated: [{section}] {key}")
return matches[0]

40
src/ptdiag/logs.py Normal file
View File

@@ -0,0 +1,40 @@
"""Compatibility façade for launcher-owned native game-log parsing."""
from ptlinux.diagnostics.native_logs import (
DEFAULT_NATIVE_LOG_PATTERNS,
AssetReferenceReport,
LogEvent,
LogFileState,
NativeLogAnalysis,
SceneImport,
classify_asset_references,
discover_current_native_logs,
is_current_run_log,
newest_current_native_log,
normalize_asset_reference,
parse_current_run_log,
parse_native_log,
parse_native_log_lines,
read_current_run_log_text,
snapshot_native_logs,
)
__all__ = [
"DEFAULT_NATIVE_LOG_PATTERNS",
"AssetReferenceReport",
"LogEvent",
"LogFileState",
"NativeLogAnalysis",
"SceneImport",
"classify_asset_references",
"discover_current_native_logs",
"is_current_run_log",
"newest_current_native_log",
"normalize_asset_reference",
"parse_current_run_log",
"parse_native_log",
"parse_native_log_lines",
"read_current_run_log_text",
"snapshot_native_logs",
]

278
src/ptdiag/performance.py Normal file
View File

@@ -0,0 +1,278 @@
"""Low-overhead process and device sampling for performance experiments."""
from __future__ import annotations
import csv
from dataclasses import dataclass
import os
from pathlib import Path
import re
import time
from typing import Any, TextIO
_SAMPLE_FIELDS = (
"epoch",
"elapsed_seconds",
"process_cpu_percent",
"system_cpu_percent",
"rss_mib",
"read_mib_per_second",
"write_mib_per_second",
"voluntary_context_switches_per_second",
"nonvoluntary_context_switches_per_second",
"gpu_busy_percent",
"vram_busy_percent",
"gpu_sclk_mhz",
"gpu_mclk_mhz",
"cpu_average_mhz",
"cpu_minimum_mhz",
"cpu_maximum_mhz",
)
@dataclass(frozen=True)
class _Counters:
process_ticks: int
rss_bytes: int
read_bytes: int
write_bytes: int
voluntary_context_switches: int
nonvoluntary_context_switches: int
system_total_ticks: int
system_idle_ticks: int
class PerformanceSampler:
"""Sample one game process and the active AMD GPU at a fixed interval."""
def __init__(
self,
pid: int,
output: Path,
*,
interval_seconds: float = 0.5,
) -> None:
if pid <= 0:
raise ValueError("pid must be positive")
if interval_seconds <= 0:
raise ValueError("interval_seconds must be positive")
self.pid = pid
self.output = output
self.interval_seconds = interval_seconds
self._interval_ns = int(interval_seconds * 1_000_000_000)
self._started_ns = time.time_ns()
self._last_sample_ns: int | None = None
self._previous: _Counters | None = None
self._handle: TextIO = output.open("w", encoding="utf-8", newline="")
self._writer = csv.DictWriter(self._handle, fieldnames=_SAMPLE_FIELDS)
self._writer.writeheader()
self._gpu_device = _discover_gpu_device()
self._sample_count = 0
self._peaks: dict[str, float] = {}
self._first_counters: _Counters | None = None
self._last_counters: _Counters | None = None
def maybe_sample(self, now_ns: int | None = None) -> bool:
now_ns = now_ns if now_ns is not None else time.time_ns()
previous_sample_ns = self._last_sample_ns
if (
previous_sample_ns is not None
and now_ns - previous_sample_ns < self._interval_ns
):
return False
self._last_sample_ns = now_ns
try:
counters = _read_counters(self.pid)
except (OSError, ValueError):
return False
if self._first_counters is None:
self._first_counters = counters
self._last_counters = counters
row = self._sample_row(now_ns, previous_sample_ns, counters)
self._writer.writerow(row)
self._handle.flush()
self._sample_count += 1
for key, value in row.items():
if key in {"epoch", "elapsed_seconds"} or not isinstance(value, (int, float)):
continue
self._peaks[key] = max(float(value), self._peaks.get(key, float("-inf")))
self._previous = counters
return True
def close(self) -> dict[str, Any]:
if not self._handle.closed:
self._handle.close()
first = self._first_counters
last = self._last_counters
return {
"pid": self.pid,
"output": str(self.output),
"interval_seconds": self.interval_seconds,
"sample_count": self._sample_count,
"gpu_device": str(self._gpu_device) if self._gpu_device else None,
"peaks": dict(sorted(self._peaks.items())),
"process_io_bytes": {
"read": max(0, last.read_bytes - first.read_bytes) if first and last else None,
"write": max(0, last.write_bytes - first.write_bytes) if first and last else None,
},
}
def _sample_row(
self,
now_ns: int,
previous_sample_ns: int | None,
counters: _Counters,
) -> dict[str, float | str | None]:
elapsed_seconds = (now_ns - self._started_ns) / 1_000_000_000
row: dict[str, float | str | None] = {
"epoch": now_ns / 1_000_000_000,
"elapsed_seconds": elapsed_seconds,
"process_cpu_percent": None,
"system_cpu_percent": None,
"rss_mib": counters.rss_bytes / 1024**2,
"read_mib_per_second": None,
"write_mib_per_second": None,
"voluntary_context_switches_per_second": None,
"nonvoluntary_context_switches_per_second": None,
"gpu_busy_percent": _read_float(self._gpu_device, "gpu_busy_percent"),
"vram_busy_percent": _read_float(self._gpu_device, "mem_busy_percent"),
"gpu_sclk_mhz": _read_active_mhz(self._gpu_device, "pp_dpm_sclk"),
"gpu_mclk_mhz": _read_active_mhz(self._gpu_device, "pp_dpm_mclk"),
"cpu_average_mhz": None,
"cpu_minimum_mhz": None,
"cpu_maximum_mhz": None,
}
frequencies = _read_cpu_frequencies_mhz()
if frequencies:
row["cpu_average_mhz"] = sum(frequencies) / len(frequencies)
row["cpu_minimum_mhz"] = min(frequencies)
row["cpu_maximum_mhz"] = max(frequencies)
previous = self._previous
if previous is None or previous_sample_ns is None:
return row
delta_seconds = max((now_ns - previous_sample_ns) / 1_000_000_000, 0.001)
clock_ticks = os.sysconf("SC_CLK_TCK")
row["process_cpu_percent"] = (
max(0, counters.process_ticks - previous.process_ticks)
/ clock_ticks
/ delta_seconds
* 100
)
system_total = counters.system_total_ticks - previous.system_total_ticks
system_idle = counters.system_idle_ticks - previous.system_idle_ticks
if system_total > 0:
row["system_cpu_percent"] = max(0, system_total - system_idle) / system_total * 100
row["read_mib_per_second"] = (
max(0, counters.read_bytes - previous.read_bytes) / 1024**2 / delta_seconds
)
row["write_mib_per_second"] = (
max(0, counters.write_bytes - previous.write_bytes) / 1024**2 / delta_seconds
)
row["voluntary_context_switches_per_second"] = (
max(
0,
counters.voluntary_context_switches - previous.voluntary_context_switches,
)
/ delta_seconds
)
row["nonvoluntary_context_switches_per_second"] = (
max(
0,
counters.nonvoluntary_context_switches
- previous.nonvoluntary_context_switches,
)
/ delta_seconds
)
return row
def _read_counters(pid: int) -> _Counters:
root = Path("/proc") / str(pid)
process_ticks, rss_bytes = _parse_proc_stat((root / "stat").read_text(encoding="utf-8"))
io = _parse_key_values((root / "io").read_text(encoding="utf-8"))
status = _parse_key_values((root / "status").read_text(encoding="utf-8"))
system_total, system_idle = _parse_system_cpu(Path("/proc/stat").read_text(encoding="utf-8"))
return _Counters(
process_ticks=process_ticks,
rss_bytes=rss_bytes,
read_bytes=io.get("read_bytes", 0),
write_bytes=io.get("write_bytes", 0),
voluntary_context_switches=status.get("voluntary_ctxt_switches", 0),
nonvoluntary_context_switches=status.get("nonvoluntary_ctxt_switches", 0),
system_total_ticks=system_total,
system_idle_ticks=system_idle,
)
def _parse_proc_stat(value: str) -> tuple[int, int]:
closing = value.rfind(")")
if closing < 0:
raise ValueError("Malformed /proc process stat")
fields = value[closing + 2 :].split()
if len(fields) < 22:
raise ValueError("Truncated /proc process stat")
process_ticks = int(fields[11]) + int(fields[12])
rss_bytes = int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
return process_ticks, rss_bytes
def _parse_key_values(value: str) -> dict[str, int]:
result: dict[str, int] = {}
for line in value.splitlines():
key, separator, raw = line.partition(":")
if not separator:
continue
match = re.match(r"\s*([0-9]+)", raw)
if match:
result[key] = int(match.group(1))
return result
def _parse_system_cpu(value: str) -> tuple[int, int]:
first = value.splitlines()[0].split()
if not first or first[0] != "cpu" or len(first) < 5:
raise ValueError("Malformed /proc/stat CPU line")
values = [int(item) for item in first[1:]]
return sum(values), values[3] + (values[4] if len(values) > 4 else 0)
def _discover_gpu_device() -> Path | None:
for path in sorted(Path("/sys/class/drm").glob("card*/device")):
if (path / "gpu_busy_percent").is_file():
return path
return None
def _read_float(root: Path | None, name: str) -> float | None:
if root is None:
return None
try:
return float((root / name).read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
def _read_active_mhz(root: Path | None, name: str) -> float | None:
if root is None:
return None
try:
value = (root / name).read_text(encoding="utf-8")
except OSError:
return None
return _parse_active_mhz(value)
def _parse_active_mhz(value: str) -> float | None:
match = re.search(r"([0-9]+)\s*Mhz\s*\*", value, flags=re.IGNORECASE)
return float(match.group(1)) if match else None
def _read_cpu_frequencies_mhz() -> list[float]:
result: list[float] = []
for path in sorted(Path("/sys/devices/system/cpu").glob("cpu[0-9]*/cpufreq/scaling_cur_freq")):
try:
result.append(float(path.read_text(encoding="utf-8").strip()) / 1000)
except (OSError, ValueError):
continue
return result

65
src/ptdiag/prefix.py Normal file
View File

@@ -0,0 +1,65 @@
"""Guarded helpers for the dedicated Proton test prefix."""
from __future__ import annotations
from pathlib import Path
from .config import AppConfig
from .runner import CommandLogger, CommandResult
class PrefixSafetyError(RuntimeError):
"""Raised when a cleanup target is not provably the dedicated test prefix."""
def validate_dedicated_prefix(config: AppConfig) -> Path:
prefix = config.proton7.prefix.resolve(strict=False)
compat = config.proton7.compat.resolve(strict=False)
live_prefix = config.paths.live_prefix.resolve(strict=False)
if prefix == live_prefix:
raise PrefixSafetyError("Dedicated prefix resolves to the live launcher prefix")
try:
prefix.relative_to(compat)
except ValueError as exc:
raise PrefixSafetyError(
f"Dedicated prefix {prefix} is not inside compat directory {compat}"
) from exc
if prefix == compat:
raise PrefixSafetyError("Dedicated prefix must be a child of the compat directory")
return prefix
def resolve_proton_wineserver(config: AppConfig) -> Path:
candidates = proton_wineserver_candidates(config)
for candidate in candidates:
if candidate.is_file():
return candidate
raise FileNotFoundError(
"Proton wineserver was not found in dist/bin or files/bin under "
f"{config.proton7.proton.parent}"
)
def proton_wineserver_candidates(config: AppConfig) -> tuple[Path, Path]:
proton_directory = config.proton7.proton.parent
return (
proton_directory / "dist/bin/wineserver",
proton_directory / "files/bin/wineserver",
)
def cleanup_dedicated_wineserver(
config: AppConfig,
command_logger: CommandLogger,
*,
timeout_seconds: float = 15.0,
dry_run: bool = False,
) -> CommandResult:
prefix = validate_dedicated_prefix(config)
wineserver = resolve_proton_wineserver(config)
return command_logger.run(
[str(wineserver), "-k"],
env_overrides={"WINEPREFIX": str(prefix), "WINEDEBUG": "-all"},
timeout_seconds=timeout_seconds,
dry_run=dry_run,
)

105
src/ptdiag/process.py Normal file
View File

@@ -0,0 +1,105 @@
"""Read-only host and configured environment inspection."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import os
from pathlib import Path
import platform
import shutil
import sys
from typing import Any
from .config import AppConfig
from .prefix import proton_wineserver_candidates
@dataclass(frozen=True)
class EnvironmentCheck:
name: str
ok: bool
value: str
required: bool = True
def to_dict(self) -> dict[str, str | bool]:
return asdict(self)
def collect_environment(config: AppConfig) -> dict[str, Any]:
checks = environment_checks(config)
return {
"collected_at": datetime.now().astimezone().isoformat(),
"host": {
"platform": platform.platform(),
"machine": platform.machine(),
"python": sys.version,
"executable": sys.executable,
"cwd": os.getcwd(),
},
"config": config.to_dict(),
"checks": [check.to_dict() for check in checks],
"ok": all(check.ok for check in checks if check.required),
}
def environment_checks(config: AppConfig) -> list[EnvironmentCheck]:
checks = [
_path_check("app_root", config.paths.app_root, "directory"),
_path_check("live_prefix", config.paths.live_prefix, "directory"),
_path_check("live_game", config.paths.live_game, "directory"),
_path_check("logs", config.paths.logs, "directory"),
_path_check("native_logs", config.paths.native_logs, "directory"),
_path_check("proton7", config.proton7.proton, "file"),
_any_path_check("proton7_wineserver", proton_wineserver_candidates(config)),
_path_check("steam_root", config.proton7.steam_root, "directory"),
_path_check("compat", config.proton7.compat, "directory"),
_path_check("dedicated_prefix", config.proton7.prefix, "directory"),
_path_check("game_exe", config.paths.live_game / config.game.exe, "file"),
EnvironmentCheck(
"game_argument",
config.game.args == ("PTEU_REBORN",),
" ".join(config.game.args),
),
EnvironmentCheck(
"virtual_desktop",
config.game.virtual_desktop == "1600x900",
config.game.virtual_desktop,
),
_tool_check("strace", required=False),
_tool_check("gdb", required=False),
_i386_vulkan_check(),
]
return checks
def _path_check(name: str, path: Path, kind: str) -> EnvironmentCheck:
ok = path.is_file() if kind == "file" else path.is_dir()
return EnvironmentCheck(name, ok, str(path))
def _tool_check(name: str, required: bool) -> EnvironmentCheck:
path = shutil.which(name)
return EnvironmentCheck(name, path is not None, path or "not found", required)
def _any_path_check(name: str, candidates: tuple[Path, ...]) -> EnvironmentCheck:
found = next((path for path in candidates if path.is_file()), None)
return EnvironmentCheck(
name,
found is not None,
str(found) if found is not None else "not found: " + ", ".join(map(str, candidates)),
)
def _i386_vulkan_check() -> EnvironmentCheck:
candidates = (
Path("/usr/lib/i386-linux-gnu/libvulkan.so.1"),
Path("/usr/lib/i386-linux-gnu/libvulkan.so"),
)
found = next((path for path in candidates if path.exists()), None)
return EnvironmentCheck(
"i386_vulkan_loader",
found is not None,
str(found) if found is not None else "not found",
)

100
src/ptdiag/registry.py Normal file
View File

@@ -0,0 +1,100 @@
"""Read-only Wine registry snapshots for reversible desktop configuration."""
from __future__ import annotations
import ast
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from .filesystem import sha256_file
_VALUE_RE = re.compile(r'^(?P<name>"(?:\\.|[^"\\])*")=(?P<value>.*)$')
_SECTION_RE = re.compile(r"^\[(?P<name>.*)](?:\s+[0-9]+)?$")
@dataclass(frozen=True)
class RegistryValue:
exists: bool
value: str | None
def to_dict(self) -> dict[str, str | bool | None]:
return asdict(self)
@dataclass(frozen=True)
class DesktopRegistrySnapshot:
user_reg: Path
sha256: str
desktop_name: str
selected_desktop: RegistryValue
desktop_size: RegistryValue
def to_dict(self) -> dict[str, object]:
return {
"user_reg": str(self.user_reg),
"sha256": self.sha256,
"desktop_name": self.desktop_name,
"selected_desktop": self.selected_desktop.to_dict(),
"desktop_size": self.desktop_size.to_dict(),
}
def snapshot_desktop_registry(prefix: Path, desktop_name: str) -> DesktopRegistrySnapshot:
user_reg = prefix / "user.reg"
sections = read_registry_string_values(user_reg)
selected = sections.get(r"Software\Wine\Explorer", {}).get("Desktop")
size = sections.get(r"Software\Wine\Explorer\Desktops", {}).get(desktop_name)
return DesktopRegistrySnapshot(
user_reg=user_reg,
sha256=sha256_file(user_reg),
desktop_name=desktop_name,
selected_desktop=RegistryValue(selected is not None, selected),
desktop_size=RegistryValue(size is not None, size),
)
def desktop_registry_restored(
before: DesktopRegistrySnapshot,
after: DesktopRegistrySnapshot,
) -> bool:
return (
before.desktop_name == after.desktop_name
and before.selected_desktop == after.selected_desktop
and before.desktop_size == after.desktop_size
)
def read_registry_string_values(path: Path) -> dict[str, dict[str, str]]:
sections: dict[str, dict[str, str]] = {}
current: dict[str, str] | None = None
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw in handle:
line = raw.rstrip("\r\n")
section = _SECTION_RE.match(line)
if section is not None:
name = section.group("name").replace(r"\\", "\\")
current = sections.setdefault(name, {})
continue
if current is None:
continue
match = _VALUE_RE.match(line)
if match is None:
continue
name = _decode_string(match.group("name"))
value_text = match.group("value")
if name is None or not value_text.startswith('"'):
continue
value = _decode_string(value_text)
if value is not None:
current[name] = value
return sections
def _decode_string(value: str) -> str | None:
try:
result = ast.literal_eval(value)
except (SyntaxError, ValueError):
return None
return result if isinstance(result, str) else None

71
src/ptdiag/result.py Normal file
View File

@@ -0,0 +1,71 @@
"""Unique result-directory creation and JSON output."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import re
from typing import Any, Mapping
@dataclass(frozen=True)
class ResultDirectory:
path: Path
metadata_path: Path
commands_path: Path
def create_result_directory(
root: Path,
test_name: str,
started_at: datetime | None = None,
) -> ResultDirectory:
name = _slug(test_name)
started = started_at or datetime.now().astimezone()
base_name = f"{started:%Y%m%d-%H%M%S}-{name}"
root.mkdir(parents=True, exist_ok=True)
path = _mkdir_unique(root, base_name)
metadata_path = path / "metadata.json"
commands_path = path / "commands.log"
commands_path.touch(exist_ok=False)
write_json(
metadata_path,
{
"test": name,
"started_at": started.isoformat(),
"result_directory": str(path),
},
)
return ResultDirectory(path, metadata_path, commands_path)
def write_json(path: Path, value: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
temporary.replace(path)
def _slug(value: str) -> str:
result = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
if not result:
raise ValueError("test_name must contain a letter or digit")
return result
def _mkdir_unique(root: Path, base_name: str) -> Path:
for index in range(1000):
suffix = "" if index == 0 else f"-{index:02d}"
candidate = root / f"{base_name}{suffix}"
try:
candidate.mkdir()
except FileExistsError:
continue
return candidate
raise FileExistsError(f"Could not allocate a unique result directory for {base_name}")

164
src/ptdiag/runner.py Normal file
View File

@@ -0,0 +1,164 @@
"""Structured command logging with argument-array subprocess execution."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import subprocess
from typing import Mapping, Sequence
@dataclass(frozen=True)
class CommandResult:
argv: tuple[str, ...]
returncode: int | None
stdout: str
stderr: str
timed_out: bool
dry_run: bool
error: str | None = None
class CommandLogger:
def __init__(self, path: Path) -> None:
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
def run(
self,
argv: Sequence[str],
*,
cwd: Path | None = None,
env_overrides: Mapping[str, str] | None = None,
timeout_seconds: float,
dry_run: bool = False,
) -> CommandResult:
arguments = _validate_argv(argv)
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
overrides = dict(env_overrides or {})
started_at = datetime.now().astimezone()
if dry_run:
result = CommandResult(arguments, None, "", "", False, True)
self._append(started_at, cwd, overrides, timeout_seconds, result)
return result
environment = os.environ.copy()
environment.update(overrides)
try:
completed = subprocess.run(
list(arguments),
cwd=cwd,
env=environment,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
result = CommandResult(
arguments,
completed.returncode,
completed.stdout,
completed.stderr,
False,
False,
)
except subprocess.TimeoutExpired as exc:
result = CommandResult(
arguments,
None,
_decode_timeout_stream(exc.stdout),
_decode_timeout_stream(exc.stderr),
True,
False,
)
except OSError as exc:
result = CommandResult(
arguments,
None,
"",
"",
False,
False,
str(exc),
)
self._append(started_at, cwd, overrides, timeout_seconds, result)
return result
def record_lifecycle(
self,
argv: Sequence[str],
*,
state: str,
cwd: Path | None,
env_overrides: Mapping[str, str],
timeout_seconds: float,
started_at: datetime,
pid: int | None = None,
returncode: int | None = None,
timed_out: bool = False,
error: str | None = None,
) -> None:
arguments = _validate_argv(argv)
record = {
"argv": list(arguments),
"cwd": str(cwd) if cwd is not None else None,
"env_overrides": dict(sorted(env_overrides.items())),
"error": error,
"observed_at": datetime.now().astimezone().isoformat(),
"pid": pid,
"returncode": returncode,
"started_at": started_at.isoformat(),
"state": state,
"timed_out": timed_out,
"timeout_seconds": timeout_seconds,
}
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True))
handle.write("\n")
def _append(
self,
started_at: datetime,
cwd: Path | None,
env_overrides: Mapping[str, str],
timeout_seconds: float,
result: CommandResult,
) -> None:
record = {
"argv": list(result.argv),
"cwd": str(cwd) if cwd is not None else None,
"dry_run": result.dry_run,
"env_overrides": dict(sorted(env_overrides.items())),
"error": result.error,
"finished_at": datetime.now().astimezone().isoformat(),
"returncode": result.returncode,
"started_at": started_at.isoformat(),
"stderr": result.stderr,
"stdout": result.stdout,
"timed_out": result.timed_out,
"timeout_seconds": timeout_seconds,
}
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True))
handle.write("\n")
def _validate_argv(argv: Sequence[str]) -> tuple[str, ...]:
if isinstance(argv, (str, bytes)):
raise TypeError("argv must be a sequence of strings, not a shell command")
arguments = tuple(argv)
if not arguments or not all(isinstance(argument, str) and argument for argument in arguments):
raise ValueError("argv must contain non-empty strings")
return arguments
def _decode_timeout_stream(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value

354
src/ptdiag/testcase.py Normal file
View File

@@ -0,0 +1,354 @@
"""Typed testcase loading with recursive TOML inheritance."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping
import tomllib
from .config import repository_root
DEFAULT_TESTCASE_DIRECTORY = Path("diagnostics/testcases")
LEGACY_TESTCASE_DIRECTORY = Path("testcases")
_PATH_ENVIRONMENT_KEYS = frozenset({"DXVK_CONFIG_FILE"})
class TestcaseError(ValueError):
"""Raised when an experiment testcase is missing or invalid."""
@dataclass(frozen=True)
class TestGame:
args: tuple[str, ...]
cwd: str
@dataclass(frozen=True)
class TestDisplay:
virtual_desktop: bool
size: str
@dataclass(frozen=True)
class TestWatch:
require_new_native_log: bool
@dataclass(frozen=True)
class TestCapture:
process_maps: bool
dll_overrides: bool
strace_files: bool
performance_stats: bool
focus_before_seconds: float
focus_after_seconds: float
@dataclass(frozen=True)
class TestIniOverride:
path: str
section: str
key: str
expected: str
value: str
@dataclass(frozen=True)
class TestModification:
kind: str
source: str
destination: str
texture_indices: tuple[int, ...]
ini_overrides: tuple[TestIniOverride, ...]
@dataclass(frozen=True)
class Testcase:
name: str
description: str
runner: str
timeout_seconds: float
stop_after_failure_seconds: float
game: TestGame
display: TestDisplay
env: dict[str, str]
watch: TestWatch
capture: TestCapture
modification: TestModification | None
source: Path
sources: tuple[Path, ...]
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
result["source"] = str(self.source)
result["sources"] = [str(path) for path in self.sources]
return result
def resolve_testcase_path(value: str | Path) -> Path:
path = Path(value)
if path.suffix or path.parent != Path("."):
return path.expanduser().absolute()
root = repository_root()
filename = f"{path.name}.toml"
preferred = root / DEFAULT_TESTCASE_DIRECTORY / filename
legacy = root / LEGACY_TESTCASE_DIRECTORY / filename
if preferred.is_file():
return preferred.absolute()
if legacy.is_file():
return legacy.absolute()
return preferred.absolute()
def load_testcase(value: str | Path) -> Testcase:
path = resolve_testcase_path(value)
data, sources = _load_inherited(path, ())
game = _section(data, "game")
display = _section(data, "display")
environment = _resolve_environment_paths(
_string_mapping(_section(data, "env"), "env"),
path.parent,
)
watch = _section(data, "watch")
capture = _section(data, "capture")
modification = _optional_section(data, "modification")
return Testcase(
name=_string(data, "name"),
description=_string(data, "description"),
runner=_string(data, "runner"),
timeout_seconds=_positive_number(data, "timeout_seconds"),
stop_after_failure_seconds=_nonnegative_number(
data, "stop_after_failure_seconds"
),
game=TestGame(
args=_string_tuple(game, "args"),
cwd=_string(game, "cwd"),
),
display=TestDisplay(
virtual_desktop=_boolean(display, "virtual_desktop"),
size=_string(display, "size"),
),
env=environment,
watch=TestWatch(
require_new_native_log=_boolean(watch, "require_new_native_log"),
),
capture=TestCapture(
process_maps=_boolean(capture, "process_maps"),
dll_overrides=_boolean(capture, "dll_overrides"),
strace_files=_boolean(capture, "strace_files"),
performance_stats=_optional_boolean(capture, "performance_stats", False),
focus_before_seconds=_optional_nonnegative_number(
capture, "focus_before_seconds", 4.0
),
focus_after_seconds=_optional_nonnegative_number(
capture, "focus_after_seconds", 1.0
),
),
modification=(
TestModification(
kind=_string(modification, "kind"),
source=_string(modification, "source"),
destination=_string(modification, "destination"),
texture_indices=_optional_integer_tuple(
modification,
"texture_indices",
),
ini_overrides=_optional_ini_overrides(modification),
)
if modification is not None
else None
),
source=path,
sources=sources,
)
def _resolve_environment_paths(
environment: Mapping[str, str],
testcase_directory: Path,
) -> dict[str, str]:
result = dict(environment)
for key in _PATH_ENVIRONMENT_KEYS:
value = result.get(key)
if value is None:
continue
path = Path(value).expanduser()
if path.is_absolute():
result[key] = str(path)
continue
candidate = (testcase_directory / path).resolve(strict=False)
if candidate.is_file():
result[key] = str(candidate)
return result
def _load_inherited(
path: Path,
stack: tuple[Path, ...],
) -> tuple[dict[str, Any], tuple[Path, ...]]:
resolved = path.resolve(strict=False)
if resolved in stack:
chain = " -> ".join(str(item) for item in (*stack, resolved))
raise TestcaseError(f"Testcase inheritance cycle: {chain}")
try:
with path.open("rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError as exc:
raise TestcaseError(f"Testcase not found: {path}") from exc
except tomllib.TOMLDecodeError as exc:
raise TestcaseError(f"Invalid TOML in {path}: {exc}") from exc
if not isinstance(data, dict):
raise TestcaseError(f"Testcase root must be a table: {path}")
parent_name = data.pop("extends", None)
if parent_name is None:
return data, (resolved,)
if not isinstance(parent_name, str) or not parent_name.strip():
raise TestcaseError(f"extends must be a non-empty string in {path}")
parent_file = Path(parent_name)
if parent_file.suffix == "":
parent_file = parent_file.with_suffix(".toml")
if not parent_file.is_absolute():
parent_file = path.parent / parent_file
parent, sources = _load_inherited(parent_file, (*stack, resolved))
return _merge(parent, data), (*sources, resolved)
def _merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = dict(base)
for key, value in override.items():
current = result.get(key)
if isinstance(current, Mapping) and isinstance(value, Mapping):
result[key] = _merge(current, value)
else:
result[key] = value
return result
def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]:
value = data.get(name)
if not isinstance(value, Mapping):
raise TestcaseError(f"Missing or invalid [{name}] section")
return value
def _optional_section(data: Mapping[str, Any], name: str) -> Mapping[str, Any] | None:
value = data.get(name)
if value is None:
return None
if not isinstance(value, Mapping):
raise TestcaseError(f"Invalid [{name}] section")
return value
def _string(data: Mapping[str, Any], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise TestcaseError(f"{key} must be a non-empty string")
return value.strip()
def _boolean(data: Mapping[str, Any], key: str) -> bool:
value = data.get(key)
if not isinstance(value, bool):
raise TestcaseError(f"{key} must be true or false")
return value
def _optional_boolean(data: Mapping[str, Any], key: str, default: bool) -> bool:
if key not in data:
return default
return _boolean(data, key)
def _string_tuple(data: Mapping[str, Any], key: str) -> tuple[str, ...]:
value = data.get(key)
if not isinstance(value, list) or not value or not all(
isinstance(item, str) and item for item in value
):
raise TestcaseError(f"{key} must be a non-empty array of strings")
return tuple(value)
def _optional_integer_tuple(data: Mapping[str, Any], key: str) -> tuple[int, ...]:
value = data.get(key, [])
if not isinstance(value, list) or not all(
isinstance(item, int) and not isinstance(item, bool) and item >= 0
for item in value
):
raise TestcaseError(f"{key} must be an array of nonnegative integers")
if len(value) != len(set(value)):
raise TestcaseError(f"{key} must not contain duplicates")
return tuple(value)
def _optional_ini_overrides(data: Mapping[str, Any]) -> tuple[TestIniOverride, ...]:
value = data.get("ini_overrides", [])
if not isinstance(value, list):
raise TestcaseError("ini_overrides must be an array of tables")
result: list[TestIniOverride] = []
identities: set[tuple[str, str, str]] = set()
for item in value:
if not isinstance(item, Mapping):
raise TestcaseError("ini_overrides must contain only tables")
override = TestIniOverride(
path=_string(item, "path"),
section=_string(item, "section"),
key=_string(item, "key"),
expected=_string(item, "expected"),
value=_string(item, "value"),
)
identity = (
override.path.casefold(),
override.section.casefold(),
override.key.casefold(),
)
if identity in identities:
raise TestcaseError("ini_overrides must not contain duplicate keys")
if override.expected == override.value:
raise TestcaseError("INI expected and replacement values must differ")
identities.add(identity)
result.append(override)
return tuple(result)
def _string_mapping(data: Mapping[str, Any], name: str) -> dict[str, str]:
result: dict[str, str] = {}
for key, value in data.items():
if not isinstance(key, str) or not isinstance(value, str):
raise TestcaseError(f"[{name}] must contain only string values")
result[key] = value
return result
def _positive_number(data: Mapping[str, Any], key: str) -> float:
value = _number(data, key)
if value <= 0:
raise TestcaseError(f"{key} must be positive")
return value
def _nonnegative_number(data: Mapping[str, Any], key: str) -> float:
value = _number(data, key)
if value < 0:
raise TestcaseError(f"{key} must be nonnegative")
return value
def _optional_nonnegative_number(
data: Mapping[str, Any],
key: str,
default: float,
) -> float:
if key not in data:
return default
return _nonnegative_number(data, key)
def _number(data: Mapping[str, Any], key: str) -> float:
value = data.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TestcaseError(f"{key} must be a number")
return float(value)

242
src/ptdiag/trace.py Normal file
View File

@@ -0,0 +1,242 @@
"""Streaming merge and normalization for per-PID strace file traces."""
from __future__ import annotations
import ast
from collections import Counter
from contextlib import ExitStack
from dataclasses import asdict, dataclass
import heapq
import json
from pathlib import Path
import re
from typing import Iterator, TextIO
_TIMESTAMP_RE = re.compile(r"^(?P<timestamp>[0-9]+(?:\.[0-9]+)?)\s+(?P<body>.*)$")
_SYSCALL_RE = re.compile(r"^(?P<syscall>[A-Za-z_][A-Za-z0-9_]*)\(")
_ERRNO_RE = re.compile(
r"=\s+-1\s+(?P<errno>E[A-Z0-9]+)\s+\((?P<description>[^)]*)\)"
)
_QUOTED_RE = re.compile(r'"(?:\\.|[^"\\])*"')
_ERROR_CLASSIFICATIONS = {
"ENOENT": "missing",
"ENOTDIR": "path-component-not-directory",
"EACCES": "permission-denied",
"EPERM": "operation-not-permitted",
"ELOOP": "symlink-loop",
"ENAMETOOLONG": "name-too-long",
"EROFS": "read-only-filesystem",
"EIO": "io-error",
}
@dataclass(frozen=True)
class TraceSummary:
shards: int
lines: int
normalized_records: int
focused_lines: int
error_counts: dict[str, int]
top_errors: list[dict[str, object]]
merged_path: Path
normalized_path: Path
focused_path: Path
focused_json_path: Path
def to_dict(self) -> dict[str, object]:
result = asdict(self)
for key in (
"merged_path",
"normalized_path",
"focused_path",
"focused_json_path",
):
result[key] = str(result[key])
return result
def process_trace_shards(
raw_directory: Path,
result_directory: Path,
*,
trigger_epoch: float,
focus_before_seconds: float,
focus_after_seconds: float,
live_game: Path,
) -> TraceSummary:
shards = sorted(
(path for path in raw_directory.glob("trace.*") if path.is_file()),
key=_shard_pid,
)
merged_path = result_directory / "merged.txt"
normalized_path = result_directory / "filesystem.jsonl"
focused_path = result_directory / "focused.txt"
focused_json_path = result_directory / "focused.jsonl"
error_counts: Counter[str] = Counter()
error_paths: Counter[tuple[str, str, str, str | None]] = Counter()
line_count = 0
normalized_count = 0
focused_count = 0
focus_start = trigger_epoch - focus_before_seconds
focus_end = trigger_epoch + focus_after_seconds
with (
merged_path.open("w", encoding="utf-8") as merged,
normalized_path.open("w", encoding="utf-8") as normalized,
focused_path.open("w", encoding="utf-8") as focused,
focused_json_path.open("w", encoding="utf-8") as focused_json,
):
for timestamp, pid, raw in _merge_shards(shards):
line_count += 1
body = _body(raw)
merged_line = f"{timestamp:.6f} [{pid}] {body}\n"
merged.write(merged_line)
record = parse_trace_line(pid, raw, live_game)
if record is not None:
normalized_count += 1
normalized.write(json.dumps(record, sort_keys=True))
normalized.write("\n")
errno = record.get("errno")
classification = record.get("error_classification")
if isinstance(errno, str) and isinstance(classification, str):
error_counts[classification] += 1
paths = record.get("paths")
path = paths[0] if isinstance(paths, list) and paths else ""
game_relative = record.get("game_relative")
error_paths[
(
classification,
errno,
str(path),
str(game_relative) if game_relative is not None else None,
)
] += 1
if focus_start <= timestamp <= focus_end:
focused_count += 1
focused.write(merged_line)
if record is not None:
focused_json.write(json.dumps(record, sort_keys=True))
focused_json.write("\n")
top_errors = [
{
"classification": key[0],
"errno": key[1],
"path": key[2],
"game_relative": key[3],
"count": count,
}
for key, count in error_paths.most_common(200)
]
return TraceSummary(
shards=len(shards),
lines=line_count,
normalized_records=normalized_count,
focused_lines=focused_count,
error_counts=dict(sorted(error_counts.items())),
top_errors=top_errors,
merged_path=merged_path,
normalized_path=normalized_path,
focused_path=focused_path,
focused_json_path=focused_json_path,
)
def parse_trace_line(pid: int, line: str, live_game: Path) -> dict[str, object] | None:
match = _TIMESTAMP_RE.match(line.rstrip("\n"))
if match is None:
return None
timestamp = float(match.group("timestamp"))
body = match.group("body")
syscall_match = _SYSCALL_RE.match(body)
if syscall_match is None:
return None
paths = [_decode_quoted(item) for item in _QUOTED_RE.findall(body)]
paths = [item for item in paths if item is not None]
errno_match = _ERRNO_RE.search(body)
errno = errno_match.group("errno") if errno_match is not None else None
classification = (
_ERROR_CLASSIFICATIONS.get(errno, "other-error") if errno is not None else None
)
game_relative = next(
(
_relative_game_path(path, live_game)
for path in paths
if _relative_game_path(path, live_game) is not None
),
None,
)
return {
"timestamp": timestamp,
"pid": pid,
"syscall": syscall_match.group("syscall"),
"paths": paths,
"errno": errno,
"error_classification": classification,
"game_relative": game_relative,
"raw": body,
}
def _merge_shards(paths: list[Path]) -> Iterator[tuple[float, int, str]]:
with ExitStack() as stack:
iterators: list[Iterator[tuple[float, int, str]]] = []
for path in paths:
handle = stack.enter_context(path.open("r", encoding="utf-8", errors="replace"))
iterators.append(_iter_shard(handle, _shard_pid(path)))
heap: list[tuple[float, int, int, str]] = []
for index, iterator in enumerate(iterators):
try:
timestamp, pid, raw = next(iterator)
except StopIteration:
continue
heapq.heappush(heap, (timestamp, pid, index, raw))
while heap:
timestamp, pid, index, raw = heapq.heappop(heap)
yield timestamp, pid, raw
try:
next_timestamp, next_pid, next_raw = next(iterators[index])
except StopIteration:
continue
heapq.heappush(heap, (next_timestamp, next_pid, index, next_raw))
def _iter_shard(handle: TextIO, pid: int) -> Iterator[tuple[float, int, str]]:
last_timestamp = 0.0
for raw in handle:
match = _TIMESTAMP_RE.match(raw)
if match is not None:
last_timestamp = float(match.group("timestamp"))
yield last_timestamp, pid, raw
def _shard_pid(path: Path) -> int:
try:
return int(path.name.rsplit(".", 1)[1])
except (IndexError, ValueError):
return 0
def _body(raw: str) -> str:
match = _TIMESTAMP_RE.match(raw.rstrip("\n"))
return match.group("body") if match is not None else raw.rstrip("\n")
def _decode_quoted(value: str) -> str | None:
try:
decoded = ast.literal_eval(value)
except (SyntaxError, ValueError):
return None
return decoded if isinstance(decoded, str) else None
def _relative_game_path(value: str, live_game: Path) -> str | None:
normalized = value.replace("\\", "/")
root = str(live_game).replace("\\", "/").rstrip("/")
index = normalized.casefold().find(root.casefold())
if index < 0:
return None
relative = normalized[index + len(root) :].lstrip("/")
return relative or "."