295 lines
11 KiB
Python
Executable File
295 lines
11 KiB
Python
Executable File
#!/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)
|
|
|
|
|
|
@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_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
|
|
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:
|
|
issue_id = f"actor:paired_model:{check.actor_dir}:{gr2_name.lower()}"
|
|
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())
|