From 7b193cb7f12a430f9c3e595aac5a7e8d622fa0c0 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 18:09:39 +0200 Subject: [PATCH] Add effect graph validation --- docs/migration.md | 13 ++ docs/testing.md | 16 ++ scripts/validate_effect_scenarios.py | 218 +++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100755 scripts/validate_effect_scenarios.py diff --git a/docs/migration.md b/docs/migration.md index f9d22f8..148284f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -157,6 +157,19 @@ The expanded validator did not find additional breakage in: - `.msm` default hit effect references - `.msm` default hit sound references +It also now includes an effect graph validator: + +- `scripts/validate_effect_scenarios.py` + +On the current real client runtime, the effect validator checks 458 text-based +effect files and reports 12 concrete missing references: + +- `effect/background/moonlight_eff_bat*.mse` missing three `effect/pet/halloween_2022_coffin_bat_0{1,2,3}.dds` textures +- `effect/background/mushrooma_{01,03,04}.mse` missing matching `.mde` mesh files +- `effect/background/smh_gatetower01.mse` missing `effect/monster2/smoke_dust.dds` +- `effect/background/turtle_statue_tree_roof_light01.mse` missing `turtle_statue_tree_roof_light01.mde` +- `effect/etc/compete/ready.mse` missing `ready.DDS` + Recommended next pack groups: 1. remaining startup-adjacent patch packs diff --git a/docs/testing.md b/docs/testing.md index b9be423..f725cfa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -213,3 +213,19 @@ This validator checks local actor integrity for `Monster`, `NPC`, and `PC`: - `.msm` effect script targets resolve against the runtime asset set - `.msm` default hit effect targets resolve against the runtime asset set - `.msm` default hit sound targets resolve against the runtime asset set + +Effect graph validator: + +```bash +python3 scripts/validate_effect_scenarios.py \ + --runtime-root /tmp/m2dev-client-runtime-http \ + --json +``` + +This validator checks text-based effect assets in `Effect`: + +- `.mse` particle `TextureFiles` +- `.mse` mesh `meshfilename` +- `.msf` `BombEffect` +- `.msf` `AttachFile` +- derived `.mss` sound scripts and their referenced `.wav` files diff --git a/scripts/validate_effect_scenarios.py b/scripts/validate_effect_scenarios.py new file mode 100755 index 0000000..7c6c421 --- /dev/null +++ b/scripts/validate_effect_scenarios.py @@ -0,0 +1,218 @@ +#!/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 + + +QUOTED_STRING_RE = re.compile(r'"([^"]+)"') +MESH_FILENAME_RE = re.compile(r'^meshfilename\s+"([^"]+)"', re.IGNORECASE) +BOMB_EFFECT_RE = re.compile(r'^bombeffect\s+"([^"]*)"', re.IGNORECASE) +ATTACH_FILE_RE = re.compile(r'^attachfile\s+"([^"]*)"', re.IGNORECASE) +SOUND_DATA_RE = re.compile(r'^SoundData\d+\s+[0-9.]+\s+"([^"]+)"', re.IGNORECASE) +YMIR_PREFIX = "d:/ymir work/" + + +@dataclass +class EffectCheck: + file: str + kind: str + missing_references: list[str] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate effect graph references for text-based .mse/.msf/.mss assets." + ) + parser.add_argument( + "--runtime-root", + type=Path, + required=True, + help="Client runtime root containing assets/.", + ) + 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_lookup(runtime_assets: Path) -> dict[str, Path]: + lookup: dict[str, Path] = {} + for path in runtime_assets.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(runtime_assets) + parts = rel.parts + if len(parts) < 2: + continue + lookup.setdefault(Path(*parts[1:]).as_posix().lower(), path) + return lookup + + +def virtual_dir_for_effect_file(effect_path: Path) -> str: + parts = effect_path.parts + try: + ymir_index = [part.lower() for part in parts].index("ymir work") + except ValueError: + return "" + return Path(*parts[ymir_index:]).parent.as_posix().lower() + + +def resolve_reference(raw: str, effect_virtual_dir: str) -> str: + rel = raw.strip() + if not rel: + return "" + normalized = normalize_virtual_path(rel) + if raw.lower().startswith("d:/") or raw.lower().startswith("d:"): + return normalized + if effect_virtual_dir: + return f"{effect_virtual_dir}/{normalized}".lower() + return normalized + + +def derived_sound_script(effect_virtual_file: str) -> str | None: + effect_virtual_file = effect_virtual_file.lower() + if not effect_virtual_file.startswith("ymir work/"): + return None + stem = Path(effect_virtual_file).with_suffix("") + relative = Path(*stem.parts[2:]).as_posix() + return f"sound/{relative}.mss".lower() + + +def validate_mse(effect_path: Path, rel_from_assets: str, asset_lookup: dict[str, Path]) -> EffectCheck: + effect_virtual_dir = virtual_dir_for_effect_file(effect_path) + missing: list[str] = [] + current_group: str | None = None + + for raw_line in effect_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line: + continue + lower = line.lower() + if lower.startswith("group "): + current_group = lower.split()[1] + continue + mesh_match = MESH_FILENAME_RE.match(line) + if mesh_match: + ref = resolve_reference(mesh_match.group(1), effect_virtual_dir) + if ref and ref not in asset_lookup: + missing.append(mesh_match.group(1)) + continue + if "texturefiles" in lower: + current_group = "texturefiles" + continue + if current_group == "texturefiles": + if line.startswith("}"): + current_group = None + continue + for token in QUOTED_STRING_RE.findall(line): + ref = resolve_reference(token, effect_virtual_dir) + if ref and ref not in asset_lookup: + missing.append(token) + + sound_script = derived_sound_script(rel_from_assets) + if sound_script and sound_script in asset_lookup: + check = validate_mss(sound_script, asset_lookup) + missing.extend(check.missing_references) + + return EffectCheck(file=rel_from_assets, kind="mse", missing_references=sorted(set(missing))) + + +def validate_msf(effect_path: Path, rel_from_assets: str, asset_lookup: dict[str, Path]) -> EffectCheck: + effect_virtual_dir = virtual_dir_for_effect_file(effect_path) + missing: list[str] = [] + for raw_line in effect_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line: + continue + match = BOMB_EFFECT_RE.match(line) + if match and match.group(1).strip(): + ref = resolve_reference(match.group(1), effect_virtual_dir) + if ref and ref not in asset_lookup: + missing.append(match.group(1)) + continue + match = ATTACH_FILE_RE.match(line) + if match and match.group(1).strip(): + ref = resolve_reference(match.group(1), effect_virtual_dir) + if ref and ref not in asset_lookup: + missing.append(match.group(1)) + return EffectCheck(file=rel_from_assets, kind="msf", missing_references=sorted(set(missing))) + + +def validate_mss(rel_from_assets: str, asset_lookup: dict[str, Path]) -> EffectCheck: + mss_path = asset_lookup.get(rel_from_assets.lower()) + missing: list[str] = [] + if not mss_path or not mss_path.is_file(): + return EffectCheck(file=rel_from_assets, kind="mss", missing_references=[]) + for raw_line in mss_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + match = SOUND_DATA_RE.match(line) + if not match: + continue + ref = normalize_virtual_path(match.group(1)) + if ref not in asset_lookup: + missing.append(match.group(1)) + return EffectCheck(file=rel_from_assets, kind="mss", missing_references=sorted(set(missing))) + + +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}") + + asset_lookup = build_asset_lookup(runtime_assets) + checks: list[EffectCheck] = [] + failures: list[str] = [] + + effect_pack_dir = runtime_assets / "Effect" + for path in sorted(effect_pack_dir.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(runtime_assets / "Effect").as_posix() + suffix = path.suffix.lower() + if suffix == ".mse": + check = validate_mse(path, rel, asset_lookup) + elif suffix == ".msf": + check = validate_msf(path, rel, asset_lookup) + else: + continue + checks.append(check) + for missing in check.missing_references: + failures.append(f"{check.file}: missing reference {missing}") + + result = { + "ok": not failures, + "checked_files": len(checks), + "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_files={result['checked_files']}") + for failure in failures: + print(f"FAIL: {failure}") + + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main())