From aacc0651b047438d8708ce0b637ab7f4ed75f2d6 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 17:57:22 +0200 Subject: [PATCH] Add world scenario validation coverage --- docs/migration.md | 24 +++- docs/testing.md | 28 +++- scripts/validate_runtime_scenarios.py | 178 ++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 5 deletions(-) create mode 100755 scripts/validate_runtime_scenarios.py diff --git a/docs/migration.md b/docs/migration.md index edca8e9..82acc64 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -99,6 +99,15 @@ client runtime on the Linux VPS: - `sound` - `sound_m` - `sound2` +- `monster` +- `npc2` +- `textureset` +- `outdoora3` +- `outdoorb3` +- `outdoorc3` +- `outdoordesert1` +- `outdoorflame1` +- `outdoorfielddungeon1` Validation method: @@ -116,15 +125,22 @@ Observed result: - `app.Create(...)` succeeded - client reached `MainStream.SetLoginPhase()` -For the world, audio, and gameplay-adjacent packs in this list, the current -validation level is startup regression smoke only. Full confidence for those -packs still requires map-load or in-game coverage. +For the world, audio, actor, and gameplay-adjacent packs in this list, the +current validation level is startup regression smoke only. Full confidence for +those packs still requires map-load or in-game coverage. + +The repository now also includes a scenario validator for common world packs: + +- `scripts/validate_runtime_scenarios.py` + +It verifies that selected `Outdoor*` maps reference valid `textureset` and +environment assets across pack boundaries. Recommended next pack groups: 1. remaining startup-adjacent patch packs 2. remaining late-load gameplay content not covered yet -3. scenario-based validation for world, actor, and sound packs +3. broader scenario-based validation for actor, effect, and sound content ## Risk notes diff --git a/docs/testing.md b/docs/testing.md index 9c4bbad..bce14bc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -109,6 +109,17 @@ Confirmed `.m2p` smoke tests: - `sound_m.m2p` - `sound2.m2p` with all thirty-six matching legacy `.pck` files removed +- the same thirty-six-pack set plus: + - `monster.m2p` + - `npc2.m2p` + - `textureset.m2p` + - `outdoora3.m2p` + - `outdoorb3.m2p` + - `outdoorc3.m2p` + - `outdoordesert1.m2p` + - `outdoorflame1.m2p` + - `outdoorfielddungeon1.m2p` + with all forty-five matching legacy `.pck` files removed In every confirmed case the client reached the login bootstrap path under Wine and completed: @@ -128,7 +139,7 @@ additional packs such as world, NPC, monster, and late-load gameplay content. Those should be validated with map loads or in-game scenario coverage rather than startup-only checks. -The world, audio, and gameplay-adjacent packs listed above are therefore +The world, audio, actor, and gameplay-adjacent packs listed above are therefore validated only as startup-time regression smoke coverage. They do prove that the additional `.m2p` archives do not break bootstrap or early asset resolution, but they do @@ -170,3 +181,18 @@ python3 scripts/runtime_smoke_wine.py \ --pack ETC \ --json ``` + +World/map scenario validator: + +```bash +python3 scripts/validate_runtime_scenarios.py \ + --runtime-root /tmp/m2dev-client-runtime-http \ + --json +``` + +This validator checks real cross-pack world references: + +- each selected `Outdoor*` map pack contains `setting.txt` +- 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` diff --git a/scripts/validate_runtime_scenarios.py b/scripts/validate_runtime_scenarios.py new file mode 100755 index 0000000..828a889 --- /dev/null +++ b/scripts/validate_runtime_scenarios.py @@ -0,0 +1,178 @@ +#!/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 + + +SETTING_TEXTURESET_RE = re.compile(r"^TextureSet\s+(.+)$", re.IGNORECASE) +SETTING_ENV_RE = re.compile(r"^Environment\s+(.+)$", re.IGNORECASE) + + +@dataclass +class MapCheck: + pack: str + map_dir: str + setting_ok: bool + mapproperty_ok: bool + textureset_ref: str | None + textureset_exists: bool + environment_ref: str | None + environment_exists: bool + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate cross-pack runtime references for Metin2 map/world content." + ) + parser.add_argument( + "--runtime-root", + type=Path, + required=True, + help="Client runtime root containing assets/.", + ) + parser.add_argument( + "--pack", + action="append", + default=[], + help="Specific outdoor/world asset directory to validate. Repeatable. Defaults to common world packs.", + ) + 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() + prefixes = ["d:/", "d:"] + for prefix in prefixes: + if value.startswith(prefix): + value = value[len(prefix):] + break + return value.lstrip("/") + + +def resolve_textureset_path(runtime_assets: Path, ref: str) -> Path: + rel = normalize_virtual_path(ref) + if not rel.startswith("textureset/"): + rel = f"textureset/{rel}" + return runtime_assets / "textureset" / rel + + +def resolve_environment_path(runtime_assets: Path, ref: str) -> Path: + rel = normalize_virtual_path(ref) + if "/" not in rel: + rel = f"ymir work/environment/{rel}" + return runtime_assets / "ETC" / rel + + +def validate_map_dir(pack_name: str, map_dir: Path, runtime_assets: Path) -> MapCheck: + setting_path = map_dir / "setting.txt" + mapproperty_path = map_dir / "mapproperty.txt" + + textureset_ref = None + environment_ref = None + textureset_exists = False + environment_exists = False + + if setting_path.is_file(): + for raw_line in setting_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line: + continue + texture_match = SETTING_TEXTURESET_RE.match(line) + if texture_match: + textureset_ref = texture_match.group(1).strip() + textureset_exists = resolve_textureset_path(runtime_assets, textureset_ref).is_file() + continue + environment_match = SETTING_ENV_RE.match(line) + if environment_match: + environment_ref = environment_match.group(1).strip() + environment_exists = resolve_environment_path(runtime_assets, environment_ref).is_file() + + return MapCheck( + pack=pack_name, + map_dir=map_dir.name, + setting_ok=setting_path.is_file(), + mapproperty_ok=mapproperty_path.is_file(), + textureset_ref=textureset_ref, + textureset_exists=textureset_exists if textureset_ref else False, + environment_ref=environment_ref, + environment_exists=environment_exists if environment_ref else False, + ) + + +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 [ + "OutdoorA1", + "OutdoorA2", + "OutdoorA3", + "OutdoorB1", + "OutdoorB3", + "OutdoorC1", + "OutdoorC3", + "OutdoorSnow1", + "outdoordesert1", + "outdoorflame1", + "outdoorfielddungeon1", + ] + + checks: list[MapCheck] = [] + failures: list[str] = [] + + for pack_name in packs: + pack_dir = runtime_assets / pack_name + if not pack_dir.is_dir(): + failures.append(f"missing pack dir: {pack_name}") + continue + + map_dirs = sorted([p for p in pack_dir.iterdir() if p.is_dir()]) + if not map_dirs: + failures.append(f"no map dirs in pack: {pack_name}") + continue + + for map_dir in map_dirs: + check = validate_map_dir(pack_name, map_dir, runtime_assets) + checks.append(check) + if not check.setting_ok: + failures.append(f"{pack_name}/{map_dir.name}: missing setting.txt") + if not check.mapproperty_ok: + failures.append(f"{pack_name}/{map_dir.name}: missing mapproperty.txt") + if not check.textureset_ref or not check.textureset_exists: + failures.append(f"{pack_name}/{map_dir.name}: missing textureset target for {check.textureset_ref!r}") + if not check.environment_ref or not check.environment_exists: + failures.append(f"{pack_name}/{map_dir.name}: missing environment target for {check.environment_ref!r}") + + result = { + "ok": not failures, + "checked_map_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_map_dirs={result['checked_map_dirs']}") + for failure in failures: + print(f"FAIL: {failure}") + + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main())