#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import subprocess import sys from dataclasses import dataclass from pathlib import Path TERRAIN_SIZE = 25600 DEFAULT_MAP_NAME = "metin2_map_a1" DEFAULT_PACKS = [ "root", "patch1", "patch2", "season3_eu", "metin2_patch_snow", "metin2_patch_snow_dungeon", "metin2_patch_etc_costume1", "metin2_patch_pet1", "metin2_patch_pet2", "metin2_patch_ramadan_costume", "metin2_patch_flame", "metin2_patch_flame_dungeon", "locale", "uiscript", "uiloading", "ETC", "item", "effect", "icon", "property", "terrain", "tree", "zone", "outdoora1", "outdoora2", "outdoorb1", "outdoorc1", "outdoorsnow1", "pc", "pc2", "guild", "npc", "monster2", "sound", "sound_m", "sound2", "monster", "npc2", "textureset", "outdoora3", "outdoorb3", "outdoorc3", "outdoordesert1", "outdoorflame1", "outdoorfielddungeon1", ] @dataclass class AtlasEntry: name: str base_x: int base_y: int width: int height: int @property def center_x(self) -> int: return self.base_x + (self.width * TERRAIN_SIZE) // 2 @property def center_y(self) -> int: return self.base_y + (self.height * TERRAIN_SIZE) // 2 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Run a headless local map-load scenario under Wine against selected .m2p packs." ) parser.add_argument("--runtime-root", type=Path, required=True, help="Client runtime root containing assets/, config/, and pack/.") parser.add_argument("--client-repo", type=Path, required=True, help="Path to m2dev-client-src checkout.") parser.add_argument("--master-key", type=Path, required=True, help="Path to m2pack runtime master key file.") parser.add_argument("--key-id", type=str, default="1", help="Runtime key_id to inject into the client.") parser.add_argument("--timeout", type=int, default=20, help="Wine run timeout in seconds.") parser.add_argument("--map-name", type=str, default=DEFAULT_MAP_NAME, help="Atlas map name to load. Defaults to metin2_map_a1.") parser.add_argument("--global-x", type=int, default=None, help="Override global X coordinate. Defaults to the atlas-derived map center.") parser.add_argument("--global-y", type=int, default=None, help="Override global Y coordinate. Defaults to the atlas-derived map center.") parser.add_argument("--pack", dest="packs", action="append", default=[], help="Pack basename to test as .m2p. Repeat for multiple packs.") parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON result.") return parser.parse_args() def activate_pack(pack_dir: Path, pack_name: str, moved: list[tuple[Path, Path]]) -> None: offsingle = pack_dir / f"{pack_name}.m2p.offsingle" m2p = pack_dir / f"{pack_name}.m2p" legacy = pack_dir / f"{pack_name}.pck" legacy_backup = pack_dir / f"{pack_name}.pck.testbak" if offsingle.exists(): offsingle.rename(m2p) moved.append((m2p, offsingle)) if legacy.exists(): legacy.rename(legacy_backup) moved.append((legacy_backup, legacy)) def restore_moves(moved: list[tuple[Path, Path]]) -> None: for src, dst in reversed(moved): if src.exists(): src.rename(dst) def load_atlas_entry(runtime_root: Path, map_name: str) -> AtlasEntry: atlas_path = runtime_root / "assets" / "root" / "atlasinfo.txt" if not atlas_path.is_file(): raise SystemExit(f"atlasinfo not found: {atlas_path}") for raw_line in atlas_path.read_text(encoding="utf-8", errors="ignore").splitlines(): parts = raw_line.split() if len(parts) < 5: continue name, base_x, base_y, width, height = parts[:5] if name != map_name: continue try: return AtlasEntry(name=name, base_x=int(base_x), base_y=int(base_y), width=int(width), height=int(height)) except ValueError as exc: raise SystemExit(f"invalid atlas entry for {map_name}: {raw_line}") from exc raise SystemExit(f"map not found in atlasinfo.txt: {map_name}") def tail_lines(path: Path, count: int = 10) -> list[str]: if not path.exists(): return [] return path.read_text(encoding="utf-8", errors="ignore").strip().splitlines()[-count:] def main() -> int: args = parse_args() runtime_root = args.runtime_root.resolve() client_repo = args.client_repo.resolve() pack_dir = runtime_root / "pack" run_script = client_repo / "scripts" / "run-wine-headless.sh" build_bin = client_repo / "build-mingw64-lld" / "bin" log_dir = build_bin / "log" if not pack_dir.is_dir(): raise SystemExit(f"runtime pack dir not found: {pack_dir}") if not run_script.is_file(): raise SystemExit(f"wine runner not found: {run_script}") if not (build_bin / "Metin2_RelWithDebInfo.exe").is_file(): raise SystemExit(f"client binary not found: {build_bin / 'Metin2_RelWithDebInfo.exe'}") atlas_entry = load_atlas_entry(runtime_root, args.map_name) global_x = args.global_x if args.global_x is not None else atlas_entry.center_x global_y = args.global_y if args.global_y is not None else atlas_entry.center_y packs = args.packs or list(DEFAULT_PACKS) moved: list[tuple[Path, Path]] = [] try: for pack_name in packs: activate_pack(pack_dir, pack_name, moved) log_dir.mkdir(parents=True, exist_ok=True) for file_path in log_dir.glob("*.txt"): file_path.unlink(missing_ok=True) for file_path in [build_bin / "syserr.txt", build_bin / "ErrorLog.txt"]: file_path.unlink(missing_ok=True) env = os.environ.copy() env["M2PACK_MASTER_KEY_HEX"] = args.master_key.read_text(encoding="utf-8").strip() env["M2PACK_KEY_ID"] = args.key_id env["M2_TIMEOUT"] = str(args.timeout) env["M2_HEADLESS_SCENARIO"] = "map_load" env["M2_HEADLESS_MAP_NAME"] = args.map_name env["M2_HEADLESS_GLOBAL_X"] = str(global_x) env["M2_HEADLESS_GLOBAL_Y"] = str(global_y) env.setdefault("WINEDEBUG", "-all") completed = subprocess.run( [str(run_script), str(build_bin)], cwd=client_repo, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) headless_trace = tail_lines(log_dir / "headless_map_load_trace.txt", count=20) prototype_tail = tail_lines(log_dir / "prototype_trace.txt") system_tail = tail_lines(log_dir / "system_py_trace.txt") syserr_tail = tail_lines(build_bin / "syserr.txt") markers = { "load_map": any(line == f"LoadMap current_map={args.map_name}" for line in headless_trace), "start_game": any(line == f"StartGame queued current_map={args.map_name}" for line in headless_trace), "game_window": any(line == f"GameWindow.Open current_map={args.map_name}" for line in headless_trace), } result = { "ok": markers["load_map"], "full_transition_ok": all(markers.values()), "returncode": completed.returncode, "map_name": args.map_name, "global_x": global_x, "global_y": global_y, "packs": packs, "markers": markers, "headless_trace_tail": headless_trace, "prototype_tail": prototype_tail, "system_tail": system_tail, "syserr_tail": syserr_tail, } if args.json: print(json.dumps(result, indent=2)) else: print(f"map={args.map_name} ok={result['ok']} returncode={completed.returncode} coords=({global_x},{global_y})") for line in headless_trace: print(f" {line}") return 0 if result["ok"] else 1 finally: restore_moves(moved) if __name__ == "__main__": sys.exit(main())