Files
m2pack-secure/scripts/map_load_smoke_wine.py
server 9bbcb67351
Some checks failed
ci / headless-e2e (push) Has been cancelled
runtime-self-hosted / runtime-ci (push) Has been cancelled
Add Wine smoke runners and clear runtime baseline
2026-04-14 21:22:50 +02:00

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())