#!/usr/bin/env python3 from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, asdict from pathlib import Path from known_issues import classify_issue_ids, load_known_issue_ids BASE_MODEL_RE = re.compile(r'^BaseModelFileName\s+"([^"]+)"', re.IGNORECASE) EFFECT_SCRIPT_RE = re.compile(r'^EffectScriptName\s+"([^"]+)"', re.IGNORECASE) DEFAULT_HIT_EFFECT_RE = re.compile(r'^DefaultHitEffectFileName\s+"([^"]*)"', re.IGNORECASE) DEFAULT_HIT_SOUND_RE = re.compile(r'^DefaultHitSoundFileName\s+"([^"]*)"', re.IGNORECASE) MOTION_FILE_RE = re.compile(r'^MotionFileName\s+"([^"]+)"', re.IGNORECASE) @dataclass class ActorDirCheck: pack: str actor_dir: str motlist_present: bool motlist_entries: int missing_msa: list[str] missing_gr2_for_motions: list[str] msm_files: int missing_base_models: list[str] missing_effect_scripts: list[str] missing_hit_effects: list[str] missing_hit_sounds: list[str] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate local actor/model runtime references for monster, npc, and pc packs." ) parser.add_argument( "--runtime-root", type=Path, required=True, help="Client runtime root containing assets/.", ) parser.add_argument( "--pack", action="append", default=[], help="Specific asset directory to validate. Repeatable. Defaults to Monster/NPC/PC.", ) parser.add_argument( "--limit", type=int, default=0, help="Optional limit of actor directories per pack for quicker sampling.", ) parser.add_argument( "--json", action="store_true", help="Emit JSON output.", ) parser.add_argument( "--known-issues", type=str, default=None, help="Optional known issues baseline JSON. Defaults to repo known_issues/runtime_known_issues.json if present.", ) parser.add_argument( "--strict-known-issues", action="store_true", help="Also fail when the known-issues baseline contains stale entries not observed anymore.", ) return parser.parse_args() def normalize_virtual_path(value: str) -> str: value = value.strip().strip('"').replace("\\", "/").lower() for prefix in ("d:/", "d:"): if value.startswith(prefix): value = value[len(prefix):] break return value.lstrip("/") def build_asset_index(runtime_assets: Path) -> set[str]: index: set[str] = set() for path in runtime_assets.rglob("*"): if not path.is_file(): continue try: rel = path.relative_to(runtime_assets) except ValueError: continue parts = rel.parts if len(parts) < 2: continue index.add(Path(*parts[1:]).as_posix().lower()) return index def parse_motlist(path: Path) -> list[str]: motions: list[str] = [] for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw_line.strip() if not line or line.startswith("//"): continue parts = line.split() if len(parts) >= 3 and parts[2].lower().endswith(".msa"): motions.append(parts[2]) return motions def parse_msa_motion_file(path: Path) -> str | None: for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw_line.strip() match = MOTION_FILE_RE.match(line) if match: return match.group(1) return None def parse_msm_references(path: Path) -> tuple[str | None, list[str], list[str], list[str]]: base_model: str | None = None effect_scripts: list[str] = [] hit_effects: list[str] = [] hit_sounds: list[str] = [] for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw_line.strip() match = BASE_MODEL_RE.match(line) if match: base_model = match.group(1) continue match = EFFECT_SCRIPT_RE.match(line) if match and match.group(1).strip(): effect_scripts.append(match.group(1)) continue match = DEFAULT_HIT_EFFECT_RE.match(line) if match and match.group(1).strip(): hit_effects.append(match.group(1)) continue match = DEFAULT_HIT_SOUND_RE.match(line) if match and match.group(1).strip(): hit_sounds.append(match.group(1)) return base_model, effect_scripts, hit_effects, hit_sounds def validate_actor_dir(pack: str, pack_dir: Path, actor_dir: Path, asset_index: set[str]) -> ActorDirCheck: motlist_path = actor_dir / "motlist.txt" motions = parse_motlist(motlist_path) if motlist_path.is_file() else [] missing_msa: list[str] = [] missing_gr2_for_motions: list[str] = [] for motion in motions: msa_path = actor_dir / motion if not msa_path.is_file(): missing_msa.append(motion) continue motion_file = parse_msa_motion_file(msa_path) if motion_file: resolved_motion = normalize_virtual_path(motion_file) if resolved_motion not in asset_index: missing_gr2_for_motions.append(resolved_motion) continue gr2_name = Path(motion).with_suffix(".gr2").name if not (actor_dir / gr2_name).is_file(): missing_gr2_for_motions.append(gr2_name) missing_base_models: list[str] = [] missing_effect_scripts: list[str] = [] missing_hit_effects: list[str] = [] missing_hit_sounds: list[str] = [] msm_files = 0 for msm_path in sorted(actor_dir.glob("*.msm")): msm_files += 1 base_model, effect_scripts, hit_effects, hit_sounds = parse_msm_references(msm_path) if base_model: resolved = normalize_virtual_path(base_model) if resolved not in asset_index: missing_base_models.append(base_model) for effect_script in effect_scripts: if normalize_virtual_path(effect_script) not in asset_index: missing_effect_scripts.append(effect_script) for hit_effect in hit_effects: if normalize_virtual_path(hit_effect) not in asset_index: missing_hit_effects.append(hit_effect) for hit_sound in hit_sounds: if normalize_virtual_path(hit_sound) not in asset_index: missing_hit_sounds.append(hit_sound) return ActorDirCheck( pack=pack, actor_dir=actor_dir.relative_to(pack_dir).as_posix(), motlist_present=motlist_path.is_file(), motlist_entries=len(motions), missing_msa=missing_msa, missing_gr2_for_motions=missing_gr2_for_motions, msm_files=msm_files, missing_base_models=missing_base_models, missing_effect_scripts=missing_effect_scripts, missing_hit_effects=missing_hit_effects, missing_hit_sounds=missing_hit_sounds, ) def collect_actor_dirs(pack_dir: Path) -> list[Path]: result: list[Path] = [] for path in sorted(pack_dir.rglob("*")): if not path.is_dir(): continue has_motlist = (path / "motlist.txt").is_file() has_msm = any(path.glob("*.msm")) if has_motlist or has_msm: result.append(path) return result def main() -> int: args = parse_args() runtime_root = args.runtime_root.resolve() runtime_assets = runtime_root / "assets" if not runtime_assets.is_dir(): raise SystemExit(f"assets dir not found: {runtime_assets}") packs = args.pack or ["Monster", "NPC", "PC"] checks: list[ActorDirCheck] = [] failures: list[str] = [] issue_map: dict[str, str] = {} asset_index = build_asset_index(runtime_assets) for pack in packs: pack_dir = runtime_assets / pack if not pack_dir.is_dir(): issue_id = f"actor:pack_dir:{pack}" message = f"missing pack dir: {pack}" failures.append(message) issue_map[issue_id] = message continue actor_dirs = collect_actor_dirs(pack_dir) if args.limit > 0: actor_dirs = actor_dirs[: args.limit] for actor_dir in actor_dirs: check = validate_actor_dir(pack, pack_dir, actor_dir, asset_index) checks.append(check) for motion in check.missing_msa: issue_id = f"actor:motion_file:{check.actor_dir}:{motion.lower()}" message = f"{check.actor_dir}: missing motion file {motion}" failures.append(message) issue_map[issue_id] = message for gr2_name in check.missing_gr2_for_motions: normalized_gr2 = normalize_virtual_path(gr2_name) issue_id = f"actor:paired_model:{check.actor_dir}:{normalized_gr2}" message = f"{check.actor_dir}: missing paired model {gr2_name}" failures.append(message) issue_map[issue_id] = message for base_model in check.missing_base_models: issue_id = f"actor:base_model:{check.actor_dir}:{normalize_virtual_path(base_model)}" message = f"{check.actor_dir}: missing base model {base_model}" failures.append(message) issue_map[issue_id] = message for effect_script in check.missing_effect_scripts: issue_id = f"actor:effect_script:{check.actor_dir}:{normalize_virtual_path(effect_script)}" message = f"{check.actor_dir}: missing effect script {effect_script}" failures.append(message) issue_map[issue_id] = message for hit_effect in check.missing_hit_effects: issue_id = f"actor:hit_effect:{check.actor_dir}:{normalize_virtual_path(hit_effect)}" message = f"{check.actor_dir}: missing hit effect {hit_effect}" failures.append(message) issue_map[issue_id] = message for hit_sound in check.missing_hit_sounds: issue_id = f"actor:hit_sound:{check.actor_dir}:{normalize_virtual_path(hit_sound)}" message = f"{check.actor_dir}: missing hit sound {hit_sound}" failures.append(message) issue_map[issue_id] = message observed_issue_ids = set(issue_map.keys()) known_path, known_issue_ids = load_known_issue_ids(__file__, "actor", args.known_issues) known_observed, unexpected_issue_ids, stale_known_issue_ids = classify_issue_ids(observed_issue_ids, known_issue_ids) result = { "ok": not unexpected_issue_ids and (not args.strict_known_issues or not stale_known_issue_ids), "checked_actor_dirs": len(checks), "packs": packs, "failures": failures, "issue_ids": sorted(observed_issue_ids), "known_issue_ids": sorted(known_observed), "unexpected_issue_ids": sorted(unexpected_issue_ids), "stale_known_issue_ids": sorted(stale_known_issue_ids), "unexpected_failures": [issue_map[issue_id] for issue_id in sorted(unexpected_issue_ids)], "stale_known_failures": sorted(stale_known_issue_ids), "known_issues_path": str(known_path) if known_path else None, "checks": [asdict(check) for check in checks], } if args.json: print(json.dumps(result, indent=2)) else: print(f"ok={result['ok']} checked_actor_dirs={result['checked_actor_dirs']}") for failure in result["unexpected_failures"]: print(f"FAIL: {failure}") for issue_id in result["stale_known_issue_ids"]: print(f"STALE: {issue_id}") return 0 if result["ok"] else 1 if __name__ == "__main__": sys.exit(main())