Added asset checks and fixes for missing data in game client.

This commit is contained in:
2026-09-05 21:52:41 +02:00
parent cccce05a99
commit f0d631be66
7 changed files with 1011 additions and 211 deletions

View File

@@ -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)