This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

203
ptdiag/cli.py Normal file
View File

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