Added more folders and restructured classes inside them.
This commit is contained in:
28
src/ptdiag/__init__.py
Normal file
28
src/ptdiag/__init__.py
Normal 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
6
src/ptdiag/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
22
src/ptdiag/artifacts.py
Normal file
22
src/ptdiag/artifacts.py
Normal 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
72
src/ptdiag/assbin.py
Normal 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
203
src/ptdiag/cli.py
Normal 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
167
src/ptdiag/config.py
Normal 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
775
src/ptdiag/experiment.py
Normal 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,
|
||||
)
|
||||
1
src/ptdiag/experiments/__init__.py
Normal file
1
src/ptdiag/experiments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Controlled diagnostic experiment implementation modules."""
|
||||
287
src/ptdiag/experiments/cleanup.py
Normal file
287
src/ptdiag/experiments/cleanup.py
Normal 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
|
||||
257
src/ptdiag/experiments/evidence.py
Normal file
257
src/ptdiag/experiments/evidence.py
Normal 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)
|
||||
332
src/ptdiag/experiments/execution.py
Normal file
332
src/ptdiag/experiments/execution.py
Normal 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")),
|
||||
},
|
||||
}
|
||||
212
src/ptdiag/experiments/log_selection.py
Normal file
212
src/ptdiag/experiments/log_selection.py
Normal 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", [])
|
||||
)
|
||||
18
src/ptdiag/experiments/models.py
Normal file
18
src/ptdiag/experiments/models.py
Normal 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."""
|
||||
624
src/ptdiag/experiments/preparation.py
Normal file
624
src/ptdiag/experiments/preparation.py
Normal 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
59
src/ptdiag/filesystem.py
Normal 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
112
src/ptdiag/ini.py
Normal 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
40
src/ptdiag/logs.py
Normal 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
278
src/ptdiag/performance.py
Normal 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
65
src/ptdiag/prefix.py
Normal 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
105
src/ptdiag/process.py
Normal 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
100
src/ptdiag/registry.py
Normal 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
71
src/ptdiag/result.py
Normal 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
164
src/ptdiag/runner.py
Normal 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
354
src/ptdiag/testcase.py
Normal 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
242
src/ptdiag/trace.py
Normal 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 "."
|
||||
Reference in New Issue
Block a user