Add actor content scenario validation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
203
scripts/validate_actor_scenarios.py
Executable file
203
scripts/validate_actor_scenarios.py
Executable file
@@ -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())
|
||||
Reference in New Issue
Block a user