237 lines
8.0 KiB
Python
237 lines
8.0 KiB
Python
#!/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())
|