Added asset checks and fixes for missing data in game client.
This commit is contained in:
193
ptdiag/logs.py
193
ptdiag/logs.py
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
@@ -24,6 +24,28 @@ _FAILED_IMPORT_RE = re.compile(
|
||||
r"Failed to import scene:\s*(?P<scene>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILED_TEXTURE_RE = re.compile(
|
||||
r"Failed to find texture\s*:\s*(?P<path>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILED_EFFECT_RE = re.compile(
|
||||
r"Failed to find effect\s*:\s*(?P<path>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MISSING_IMAGE_RE = re.compile(
|
||||
r"Missing file for image\s*:\s*(?P<path>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILED_PACK_IMAGE_RE = re.compile(
|
||||
r"Failed to LoadPackImage asset for\s*(?P<path>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILED_CREATE_EFFECT_RE = re.compile(
|
||||
r"Failed to create effect\s*:\s*(?P<path>.*?)"
|
||||
r"(?:\s+0x[0-9a-f]+)?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OUT_OF_MEMORY_RE = re.compile(r"E_OUTOFMEMORY|Ran out of memory", re.IGNORECASE)
|
||||
_MODULE_CODE_SYNC_RE = re.compile(
|
||||
r"Module Code Sync Error\s*\(\[(?P<left>-?\d+)]\s*!=\s*"
|
||||
r"\[(?P<right>-?\d+)]\)",
|
||||
@@ -68,6 +90,30 @@ class SceneImport:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetReferenceReport:
|
||||
"""Manifest-aware classification of asset paths reported by the game."""
|
||||
|
||||
total_events: int
|
||||
unique_valid_references: int
|
||||
invalid_reference_events: int
|
||||
catalog_available: bool
|
||||
managed_present_paths: tuple[str, ...]
|
||||
managed_missing_paths: tuple[str, ...]
|
||||
publisher_unmanaged_paths: tuple[str, ...]
|
||||
unclassified_paths: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def managed_integrity_failed(self) -> bool:
|
||||
return bool(self.managed_missing_paths)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
**asdict(self),
|
||||
"managed_integrity_failed": self.managed_integrity_failed,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NativeLogAnalysis:
|
||||
scenes: dict[str, list[SceneImport]] = field(default_factory=dict)
|
||||
@@ -79,6 +125,14 @@ class NativeLogAnalysis:
|
||||
failed_asset_file_count: int = 0
|
||||
failed_texture_file_count: int = 0
|
||||
failed_effect_file_count: int = 0
|
||||
asset_reference_events: list[LogEvent] = field(default_factory=list)
|
||||
missing_texture_reference_count: int = 0
|
||||
missing_effect_reference_count: int = 0
|
||||
missing_image_reference_count: int = 0
|
||||
missing_pack_image_reference_count: int = 0
|
||||
effect_creation_failures: list[LogEvent] = field(default_factory=list)
|
||||
out_of_memory_events: list[LogEvent] = field(default_factory=list)
|
||||
out_of_memory_effect_paths: list[str] = field(default_factory=list)
|
||||
failed_import_scenes: list[str] = field(default_factory=list)
|
||||
load_texture_to_material_failed: bool = False
|
||||
import_scene_threw: bool = False
|
||||
@@ -90,6 +144,10 @@ class NativeLogAnalysis:
|
||||
module_code_sync_delta: int | None = None
|
||||
banned_code_1: bool = False
|
||||
|
||||
@property
|
||||
def runtime_out_of_memory(self) -> bool:
|
||||
return bool(self.out_of_memory_events)
|
||||
|
||||
@property
|
||||
def asset_pack_health_failed(self) -> bool:
|
||||
return (
|
||||
@@ -119,6 +177,17 @@ class NativeLogAnalysis:
|
||||
"failed_asset_file_count": self.failed_asset_file_count,
|
||||
"failed_texture_file_count": self.failed_texture_file_count,
|
||||
"failed_effect_file_count": self.failed_effect_file_count,
|
||||
"asset_reference_event_count": len(self.asset_reference_events),
|
||||
"missing_texture_reference_count": self.missing_texture_reference_count,
|
||||
"missing_effect_reference_count": self.missing_effect_reference_count,
|
||||
"missing_image_reference_count": self.missing_image_reference_count,
|
||||
"missing_pack_image_reference_count": self.missing_pack_image_reference_count,
|
||||
"effect_creation_failures": [
|
||||
event.to_dict() for event in self.effect_creation_failures
|
||||
],
|
||||
"runtime_out_of_memory": self.runtime_out_of_memory,
|
||||
"out_of_memory_count": len(self.out_of_memory_events),
|
||||
"out_of_memory_effect_paths": self.out_of_memory_effect_paths,
|
||||
"asset_pack_health_failed": self.asset_pack_health_failed,
|
||||
"failed_import_scenes": self.failed_import_scenes,
|
||||
"failed_files": [event.to_dict() for event in self.failed_files],
|
||||
@@ -251,6 +320,7 @@ def parse_current_run_log(
|
||||
|
||||
def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
|
||||
analysis = NativeLogAnalysis()
|
||||
latest_out_of_memory_line: int | None = None
|
||||
for line_number, raw_line in enumerate(lines, start=1):
|
||||
raw = raw_line.rstrip("\r\n")
|
||||
timestamp = _timestamp(raw)
|
||||
@@ -320,12 +390,59 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
|
||||
else:
|
||||
analysis.failed_file_read_count += 1
|
||||
if _is_game_asset_path(value):
|
||||
analysis.asset_reference_events.append(event)
|
||||
analysis.failed_asset_file_count += 1
|
||||
if _is_texture_path(value):
|
||||
analysis.failed_texture_file_count += 1
|
||||
if _is_effect_path(value):
|
||||
analysis.failed_effect_file_count += 1
|
||||
|
||||
for kind, pattern, count_field in (
|
||||
("missing_texture_reference", _FAILED_TEXTURE_RE, "missing_texture_reference_count"),
|
||||
("missing_effect_reference", _FAILED_EFFECT_RE, "missing_effect_reference_count"),
|
||||
("missing_image_reference", _MISSING_IMAGE_RE, "missing_image_reference_count"),
|
||||
(
|
||||
"missing_pack_image_reference",
|
||||
_FAILED_PACK_IMAGE_RE,
|
||||
"missing_pack_image_reference_count",
|
||||
),
|
||||
):
|
||||
match = pattern.search(raw)
|
||||
if match is None:
|
||||
continue
|
||||
value = match.group("path").strip()
|
||||
event = LogEvent(kind, line_number, raw, timestamp, value)
|
||||
analysis.asset_reference_events.append(event)
|
||||
analysis.events.append(event)
|
||||
setattr(analysis, count_field, getattr(analysis, count_field) + 1)
|
||||
break
|
||||
|
||||
if _OUT_OF_MEMORY_RE.search(raw) is not None:
|
||||
latest_out_of_memory_line = line_number
|
||||
event = LogEvent("out_of_memory", line_number, raw, timestamp)
|
||||
analysis.out_of_memory_events.append(event)
|
||||
analysis.events.append(event)
|
||||
|
||||
failed_create_effect = _FAILED_CREATE_EFFECT_RE.search(raw)
|
||||
if failed_create_effect is not None:
|
||||
value = failed_create_effect.group("path").strip()
|
||||
event = LogEvent(
|
||||
"failed_create_effect",
|
||||
line_number,
|
||||
raw,
|
||||
timestamp,
|
||||
value,
|
||||
)
|
||||
analysis.effect_creation_failures.append(event)
|
||||
analysis.events.append(event)
|
||||
if (
|
||||
value
|
||||
and latest_out_of_memory_line is not None
|
||||
and line_number - latest_out_of_memory_line <= 2
|
||||
and value not in analysis.out_of_memory_effect_paths
|
||||
):
|
||||
analysis.out_of_memory_effect_paths.append(value)
|
||||
|
||||
if "Stage without new stage" in raw:
|
||||
analysis.stage_without_new_stage = True
|
||||
analysis.events.append(
|
||||
@@ -337,6 +454,80 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
|
||||
return analysis
|
||||
|
||||
|
||||
def classify_asset_references(
|
||||
analysis: NativeLogAnalysis,
|
||||
managed_paths: Iterable[str] | None,
|
||||
*,
|
||||
source_game: Path | None = None,
|
||||
) -> AssetReferenceReport:
|
||||
"""Classify logged paths without treating publisher omissions as corruption."""
|
||||
|
||||
if managed_paths is None:
|
||||
managed: dict[str, str] | None = None
|
||||
else:
|
||||
managed_lookup: dict[str, str] = {}
|
||||
for path in managed_paths:
|
||||
normalized = normalize_asset_reference(path)
|
||||
if normalized is not None:
|
||||
managed_lookup[normalized.casefold()] = normalized
|
||||
managed = managed_lookup
|
||||
|
||||
unique: dict[str, str] = {}
|
||||
invalid_events = 0
|
||||
for event in analysis.asset_reference_events:
|
||||
normalized = normalize_asset_reference(event.value or "")
|
||||
if normalized is None:
|
||||
invalid_events += 1
|
||||
continue
|
||||
unique.setdefault(normalized.casefold(), normalized)
|
||||
|
||||
managed_present: list[str] = []
|
||||
managed_missing: list[str] = []
|
||||
publisher_unmanaged: list[str] = []
|
||||
unclassified: list[str] = []
|
||||
for key in sorted(unique):
|
||||
observed = unique[key]
|
||||
if managed is None:
|
||||
unclassified.append(observed)
|
||||
continue
|
||||
canonical = managed.get(key)
|
||||
if canonical is None:
|
||||
publisher_unmanaged.append(observed)
|
||||
continue
|
||||
if source_game is None or (source_game / PurePosixPath(canonical)).is_file():
|
||||
managed_present.append(canonical)
|
||||
else:
|
||||
managed_missing.append(canonical)
|
||||
|
||||
return AssetReferenceReport(
|
||||
total_events=len(analysis.asset_reference_events),
|
||||
unique_valid_references=len(unique),
|
||||
invalid_reference_events=invalid_events,
|
||||
catalog_available=managed is not None,
|
||||
managed_present_paths=tuple(managed_present),
|
||||
managed_missing_paths=tuple(managed_missing),
|
||||
publisher_unmanaged_paths=tuple(publisher_unmanaged),
|
||||
unclassified_paths=tuple(unclassified),
|
||||
)
|
||||
|
||||
|
||||
def normalize_asset_reference(value: str) -> str | None:
|
||||
"""Return a safe, slash-normalized relative asset path for comparison."""
|
||||
|
||||
if not value or "\x00" in value:
|
||||
return None
|
||||
normalized = re.sub(r"[\\/]+", "/", value.strip())
|
||||
if not normalized or normalized.startswith("/"):
|
||||
return None
|
||||
parts = PurePosixPath(normalized).parts
|
||||
if (
|
||||
not parts
|
||||
or any(part in ("", ".", "..") or ":" in part for part in parts)
|
||||
):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _state(path: Path) -> LogFileState:
|
||||
stat = path.stat()
|
||||
return LogFileState(path.absolute(), stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
||||
|
||||
Reference in New Issue
Block a user