From 836e95d1f1b673effa9d55b603099e67317efafe Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 18:00:13 +0200 Subject: [PATCH] Add actor content scenario validation --- docs/migration.md | 14 ++ docs/testing.md | 14 ++ scripts/validate_actor_scenarios.py | 203 ++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100755 scripts/validate_actor_scenarios.py diff --git a/docs/migration.md b/docs/migration.md index 82acc64..a48aec8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -136,6 +136,20 @@ The repository now also includes a scenario validator for common world packs: It verifies that selected `Outdoor*` maps reference valid `textureset` and environment assets across pack boundaries. +It also now includes an actor/content validator: + +- `scripts/validate_actor_scenarios.py` + +On the current real client runtime, the full actor validator reports five data +issues that look like pre-existing content inconsistencies rather than `.m2p` +loader regressions: + +- `Monster/misterious_diseased_host` missing `25.gr2` +- `Monster/skeleton_king` missing `24.gr2` +- `Monster/thief2` missing `03_1.gr2` +- `NPC/christmas_tree` missing `wait.gr2` +- `NPC/guild_war_flag` missing `wait.gr2` + Recommended next pack groups: 1. remaining startup-adjacent patch packs diff --git a/docs/testing.md b/docs/testing.md index bce14bc..d4509e4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -196,3 +196,17 @@ This validator checks real cross-pack world references: - each selected `Outdoor*` map pack contains `mapproperty.txt` - `TextureSet` targets from `setting.txt` exist in `textureset` - `Environment` targets from `setting.txt` exist in `ETC/ymir work/environment` + +Actor/content scenario validator: + +```bash +python3 scripts/validate_actor_scenarios.py \ + --runtime-root /tmp/m2dev-client-runtime-http \ + --json +``` + +This validator checks local actor integrity for `Monster`, `NPC`, and `PC`: + +- `motlist.txt` motion files exist +- each motion has a paired `.gr2` in the same actor directory +- `.msm` base model targets resolve against the runtime asset set diff --git a/scripts/validate_actor_scenarios.py b/scripts/validate_actor_scenarios.py new file mode 100755 index 0000000..5d3b902 --- /dev/null +++ b/scripts/validate_actor_scenarios.py @@ -0,0 +1,203 @@ +#!/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 + + +BASE_MODEL_RE = re.compile(r'^BaseModelFileName\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] + + +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.", + ) + 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_base_model(path: Path) -> str | None: + for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + match = BASE_MODEL_RE.match(raw_line.strip()) + if match: + return match.group(1) + return None + + +def validate_actor_dir(pack: str, 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] = [] + msm_files = 0 + for msm_path in sorted(actor_dir.glob("*.msm")): + msm_files += 1 + base_model = parse_msm_base_model(msm_path) + if not base_model: + continue + resolved = normalize_virtual_path(base_model) + if resolved not in asset_index: + missing_base_models.append(base_model) + + return ActorDirCheck( + pack=pack, + actor_dir=actor_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, + ) + + +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] = [] + asset_index = build_asset_index(runtime_assets) + + for pack in packs: + pack_dir = runtime_assets / pack + if not pack_dir.is_dir(): + failures.append(f"missing pack dir: {pack}") + 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, actor_dir, asset_index) + checks.append(check) + for motion in check.missing_msa: + failures.append(f"{check.actor_dir}: missing motion file {motion}") + for gr2_name in check.missing_gr2_for_motions: + failures.append(f"{check.actor_dir}: missing paired model {gr2_name}") + for base_model in check.missing_base_models: + failures.append(f"{check.actor_dir}: missing base model {base_model}") + + result = { + "ok": not failures, + "checked_actor_dirs": len(checks), + "packs": packs, + "failures": failures, + "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 failures: + print(f"FAIL: {failure}") + + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main())