Add Wine smoke runners and clear runtime baseline
This commit is contained in:
@@ -184,12 +184,7 @@ 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 now reports 10 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/turtle_statue_tree_roof_light01.mse` missing `turtle_statue_tree_roof_light01.mde`
|
||||
On the current real client runtime, the effect validator now passes cleanly.
|
||||
|
||||
Two earlier effect-path issues were cleaned up in the runtime assets by adding
|
||||
safe aliases for already existing textures:
|
||||
@@ -197,10 +192,9 @@ safe aliases for already existing textures:
|
||||
- `effect/background/smh_gatetower01.mse` now resolves `effect/monster2/smoke_dust.dds`
|
||||
- `effect/etc/compete/ready.mse` now resolves `effect/etc/compete/ready.dds`
|
||||
|
||||
The remaining ten effect findings were also checked against the current
|
||||
`m2dev-client` git history and no matching source `*.dds` or `*.mde` assets
|
||||
were found there. They are therefore treated as true content gaps for now, not
|
||||
as simple path regressions.
|
||||
The last orphaned background effect scripts with missing local assets were then
|
||||
removed from `m2dev-client`, so the shared runtime baseline no longer needs any
|
||||
effect exceptions.
|
||||
|
||||
It also now includes an audio scenario validator:
|
||||
|
||||
@@ -212,11 +206,11 @@ previous cross-pack and wrong-path sound references were cleaned up in the
|
||||
runtime assets, and the last remaining `combo7.wav` issue was resolved by
|
||||
aligning `combo_07.mss` with the byte-identical `combo_08` motion variant.
|
||||
|
||||
The current effect findings are recorded in:
|
||||
The shared runtime baseline lives in:
|
||||
|
||||
- `known_issues/runtime_known_issues.json`
|
||||
|
||||
Actor and audio are currently clean in that baseline.
|
||||
World, actor, effect, and audio are currently clean in that baseline.
|
||||
|
||||
That file is now the shared runtime baseline used by the validators and the
|
||||
aggregated release gate.
|
||||
|
||||
@@ -277,12 +277,11 @@ Current baseline on the real runtime:
|
||||
|
||||
- `world`: `0`
|
||||
- `actor`: `0`
|
||||
- `effect`: `10`
|
||||
- `effect`: `0`
|
||||
- `audio`: `0`
|
||||
|
||||
The remaining ten effect baseline entries were checked against the current
|
||||
`m2dev-client` git history and still have no matching source asset files. They
|
||||
are currently treated as real content gaps rather than easy alias/path fixes.
|
||||
The last orphaned effect entries were removed from `m2dev-client`, so the
|
||||
shared baseline is now empty across all four validator groups.
|
||||
|
||||
Audio scenario validator:
|
||||
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
{
|
||||
"world": [],
|
||||
"actor": [],
|
||||
"effect": [
|
||||
"effect:reference:ymir work/effect/background/moonlight_eff_bat.mse:ymir work/effect/pet/halloween_2022_coffin_bat_01.dds",
|
||||
"effect:reference:ymir work/effect/background/moonlight_eff_bat.mse:ymir work/effect/pet/halloween_2022_coffin_bat_02.dds",
|
||||
"effect:reference:ymir work/effect/background/moonlight_eff_bat.mse:ymir work/effect/pet/halloween_2022_coffin_bat_03.dds",
|
||||
"effect:reference:ymir work/effect/background/moonlight_eff_bat_02_20s.mse:ymir work/effect/pet/halloween_2022_coffin_bat_01.dds",
|
||||
"effect:reference:ymir work/effect/background/moonlight_eff_bat_02_20s.mse:ymir work/effect/pet/halloween_2022_coffin_bat_02.dds",
|
||||
"effect:reference:ymir work/effect/background/moonlight_eff_bat_02_20s.mse:ymir work/effect/pet/halloween_2022_coffin_bat_03.dds",
|
||||
"effect:reference:ymir work/effect/background/mushrooma_01.mse:ymir work/effect/background/mushrooma_01.mde",
|
||||
"effect:reference:ymir work/effect/background/mushrooma_03.mse:ymir work/effect/background/mushrooma_03.mde",
|
||||
"effect:reference:ymir work/effect/background/mushrooma_04.mse:ymir work/effect/background/mushrooma_04.mde",
|
||||
"effect:reference:ymir work/effect/background/turtle_statue_tree_roof_light01.mse:ymir work/effect/background/turtle_statue_tree_roof_light01.mde"
|
||||
],
|
||||
"effect": [],
|
||||
"audio": []
|
||||
}
|
||||
|
||||
575
scripts/gm_teleport_smoke_wine.py
Normal file
575
scripts/gm_teleport_smoke_wine.py
Normal file
@@ -0,0 +1,575 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_BINARY = REPO_ROOT / "build" / "m2pack"
|
||||
TERRAIN_SIZE = 25600
|
||||
PASS_MAX_NUM = 16
|
||||
DEFAULT_ACCOUNT_LOGIN = "admin"
|
||||
DEFAULT_MAPS = [
|
||||
"metin2_map_a1",
|
||||
"metin2_map_a3",
|
||||
"metin2_map_trent",
|
||||
"metin2_map_trent02",
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class MapStep:
|
||||
name: str
|
||||
global_x: int
|
||||
global_y: int
|
||||
|
||||
@property
|
||||
def meter_x(self) -> int:
|
||||
return self.global_x // 100
|
||||
|
||||
@property
|
||||
def meter_y(self) -> int:
|
||||
return self.global_y // 100
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run a headless Wine GM teleport smoke scenario with real login and map warps."
|
||||
)
|
||||
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("--content-repo", type=Path, required=True, help="Path to m2dev-client asset checkout used to rebuild root.m2p.")
|
||||
parser.add_argument("--master-key", type=Path, required=True, help="Path to m2pack runtime master key file.")
|
||||
parser.add_argument("--sign-secret-key", type=Path, required=True, help="Path to m2pack signing secret key used to rebuild root.m2p.")
|
||||
parser.add_argument("--key-id", type=str, default="1", help="Runtime key_id to inject into the client and archive build.")
|
||||
parser.add_argument("--timeout", type=int, default=90, help="Wine run timeout in seconds.")
|
||||
parser.add_argument("--server-host", type=str, default=None, help="Server host override. Defaults to runtime assets/root/serverinfo.py.")
|
||||
parser.add_argument("--auth-port", type=int, default=None, help="Auth port override. Defaults to runtime assets/root/serverinfo.py.")
|
||||
parser.add_argument("--channel-port", type=int, default=None, help="Channel port override. Defaults to runtime assets/root/serverinfo.py.")
|
||||
parser.add_argument("--account-login", type=str, default=DEFAULT_ACCOUNT_LOGIN, help="Login name for the GM account.")
|
||||
parser.add_argument("--slot", type=int, default=0, help="Character slot to auto-select.")
|
||||
parser.add_argument("--command-delay", type=float, default=5.0, help="Delay before sending the first GM command after entering game.")
|
||||
parser.add_argument("--settle-delay", type=float, default=2.0, help="Delay between successful warp arrival and the next warp command.")
|
||||
parser.add_argument("--warp-timeout", type=float, default=20.0, help="Timeout per warp command in seconds.")
|
||||
parser.add_argument("--map", dest="maps", action="append", default=[], help="Atlas map name to visit. Repeat for multiple maps.")
|
||||
parser.add_argument("--pack", dest="packs", action="append", default=[], help="Pack basename to activate as .m2p. Repeat for multiple packs.")
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON result.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_binary() -> Path:
|
||||
env_binary = os.environ.get("M2PACK_BINARY")
|
||||
if env_binary:
|
||||
binary = Path(env_binary)
|
||||
else:
|
||||
binary = DEFAULT_BINARY
|
||||
|
||||
if not binary.is_file():
|
||||
raise SystemExit(f"m2pack binary not found: {binary}")
|
||||
return binary
|
||||
|
||||
|
||||
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 sql_quote(value: str) -> str:
|
||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
|
||||
|
||||
def run_mysql(sql: str, database: str = "account") -> str:
|
||||
completed = subprocess.run(
|
||||
["mysql", "-B", "-N", database, "-e", sql],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise RuntimeError(f"mysql failed: {detail}")
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def load_character_state(account_login: str, slot: int) -> dict[str, object] | None:
|
||||
if slot < 0 or slot > 3:
|
||||
raise SystemExit(f"unsupported character slot: {slot}")
|
||||
|
||||
account_id = run_mysql(
|
||||
f"SELECT id FROM account WHERE login={sql_quote(account_login)} LIMIT 1;"
|
||||
)
|
||||
if not account_id:
|
||||
return None
|
||||
|
||||
pid = run_mysql(
|
||||
f"SELECT pid{slot + 1} FROM player_index WHERE id={account_id} LIMIT 1;",
|
||||
database="player",
|
||||
)
|
||||
if not pid or pid == "0":
|
||||
return None
|
||||
|
||||
row = run_mysql(
|
||||
f"SELECT id, name, map_index, x, y FROM player WHERE id={pid} LIMIT 1;",
|
||||
database="player",
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
parts = row.split("\t")
|
||||
if len(parts) != 5:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": int(parts[0]),
|
||||
"name": parts[1],
|
||||
"map_index": int(parts[2]),
|
||||
"x": int(parts[3]),
|
||||
"y": int(parts[4]),
|
||||
}
|
||||
|
||||
|
||||
def restore_character_state(state: dict[str, object]) -> None:
|
||||
run_mysql(
|
||||
"UPDATE player SET map_index=%d, x=%d, y=%d WHERE id=%d;"
|
||||
% (state["map_index"], state["x"], state["y"], state["id"]),
|
||||
database="player",
|
||||
)
|
||||
|
||||
|
||||
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_server_defaults(runtime_root: Path) -> tuple[str, int, int]:
|
||||
server_info_path = runtime_root / "assets" / "root" / "serverinfo.py"
|
||||
defaults = {
|
||||
"SERVER_IP": "173.249.9.66",
|
||||
"PORT_AUTH": 11000,
|
||||
"PORT_CH1": 11011,
|
||||
}
|
||||
|
||||
if not server_info_path.is_file():
|
||||
return defaults["SERVER_IP"], defaults["PORT_AUTH"], defaults["PORT_CH1"]
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
exec(compile(server_info_path.read_text(encoding="utf-8", errors="ignore"), str(server_info_path), "exec"), namespace)
|
||||
host = str(namespace.get("SERVER_IP", defaults["SERVER_IP"]))
|
||||
auth_port = int(namespace.get("PORT_AUTH", defaults["PORT_AUTH"]))
|
||||
channel_port = int(namespace.get("PORT_CH1", defaults["PORT_CH1"]))
|
||||
return host, auth_port, channel_port
|
||||
|
||||
|
||||
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 load_steps(runtime_root: Path, map_names: list[str]) -> list[MapStep]:
|
||||
steps: list[MapStep] = []
|
||||
for map_name in map_names:
|
||||
atlas = load_atlas_entry(runtime_root, map_name)
|
||||
steps.append(MapStep(name=map_name, global_x=atlas.center_x, global_y=atlas.center_y))
|
||||
return steps
|
||||
|
||||
|
||||
def serialize_steps(steps: list[MapStep]) -> str:
|
||||
return "|".join(f"{step.name},{step.global_x},{step.global_y}" for step in steps)
|
||||
|
||||
|
||||
def rebuild_root_pack(
|
||||
binary: Path,
|
||||
content_repo: Path,
|
||||
pack_dir: Path,
|
||||
master_key: Path,
|
||||
sign_secret_key: Path,
|
||||
key_id: str,
|
||||
) -> dict[str, object]:
|
||||
source_root = content_repo / "assets" / "root"
|
||||
target_root = pack_dir / "root.m2p"
|
||||
|
||||
if not source_root.is_dir():
|
||||
raise SystemExit(f"content root assets not found: {source_root}")
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(prefix="root-gm-teleport-", suffix=".m2p", dir=str(pack_dir))
|
||||
os.close(fd)
|
||||
tmp_archive = Path(tmp_name)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(binary),
|
||||
"build",
|
||||
"--input",
|
||||
str(source_root),
|
||||
"--output",
|
||||
str(tmp_archive),
|
||||
"--key",
|
||||
str(master_key),
|
||||
"--sign-secret-key",
|
||||
str(sign_secret_key),
|
||||
"--key-id",
|
||||
str(key_id),
|
||||
"--json",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise RuntimeError(f"m2pack build failed: {detail}")
|
||||
|
||||
build_json = None
|
||||
stdout_text = completed.stdout.strip()
|
||||
if stdout_text:
|
||||
try:
|
||||
build_json = json.loads(stdout_text)
|
||||
except json.JSONDecodeError:
|
||||
build_json = None
|
||||
|
||||
tmp_archive.replace(target_root)
|
||||
return {
|
||||
"ok": True,
|
||||
"stdout_tail": stdout_text.splitlines()[-10:] if stdout_text else [],
|
||||
"stderr_tail": completed.stderr.strip().splitlines()[-10:] if completed.stderr.strip() else [],
|
||||
"json": build_json,
|
||||
}
|
||||
finally:
|
||||
tmp_archive.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_login_info(
|
||||
login_path: Path,
|
||||
server_host: str,
|
||||
auth_port: int,
|
||||
channel_port: int,
|
||||
account_login: str,
|
||||
temp_password: str,
|
||||
slot: int,
|
||||
) -> tuple[Path | None, list[str]]:
|
||||
backup_path = None
|
||||
cleanup: list[str] = []
|
||||
|
||||
if login_path.exists():
|
||||
backup_path = login_path.with_name(login_path.name + ".gmteleportbak")
|
||||
login_path.rename(backup_path)
|
||||
cleanup.append(str(backup_path))
|
||||
|
||||
login_payload = "\n".join(
|
||||
[
|
||||
f"addr={server_host!r}",
|
||||
f"port={channel_port}",
|
||||
f"account_addr={server_host!r}",
|
||||
f"account_port={auth_port}",
|
||||
f"id={account_login!r}",
|
||||
f"pwd={temp_password!r}",
|
||||
f"slot={slot}",
|
||||
"autoLogin=1",
|
||||
"autoSelect=1",
|
||||
"",
|
||||
]
|
||||
)
|
||||
login_path.write_text(login_payload, encoding="utf-8")
|
||||
return backup_path, cleanup
|
||||
|
||||
|
||||
def restore_login_info(login_path: Path, backup_path: Path | None) -> None:
|
||||
login_path.unlink(missing_ok=True)
|
||||
if backup_path and backup_path.exists():
|
||||
backup_path.rename(login_path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
runtime_root = args.runtime_root.resolve()
|
||||
client_repo = args.client_repo.resolve()
|
||||
content_repo = args.content_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"
|
||||
binary = resolve_binary()
|
||||
|
||||
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'}")
|
||||
|
||||
default_host, default_auth_port, default_channel_port = load_server_defaults(runtime_root)
|
||||
server_host = args.server_host or default_host
|
||||
auth_port = args.auth_port or default_auth_port
|
||||
channel_port = args.channel_port or default_channel_port
|
||||
map_names = args.maps or list(DEFAULT_MAPS)
|
||||
packs = args.packs or list(DEFAULT_PACKS)
|
||||
steps = load_steps(runtime_root, map_names)
|
||||
|
||||
temp_password = ("Tmp" + secrets.token_hex(6))[:PASS_MAX_NUM]
|
||||
login_path = build_bin / "loginInfo.py"
|
||||
backup_login_path: Path | None = None
|
||||
moved: list[tuple[Path, Path]] = []
|
||||
original_password_hash = ""
|
||||
build_result: dict[str, object] | None = None
|
||||
original_character_state = load_character_state(args.account_login, args.slot)
|
||||
|
||||
try:
|
||||
build_result = rebuild_root_pack(
|
||||
binary=binary,
|
||||
content_repo=content_repo,
|
||||
pack_dir=pack_dir,
|
||||
master_key=args.master_key.resolve(),
|
||||
sign_secret_key=args.sign_secret_key.resolve(),
|
||||
key_id=args.key_id,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
original_password_hash = run_mysql(
|
||||
f"SELECT password FROM account WHERE login={sql_quote(args.account_login)} LIMIT 1;"
|
||||
)
|
||||
if not original_password_hash:
|
||||
raise SystemExit(f"account not found or missing password hash: {args.account_login}")
|
||||
|
||||
run_mysql(
|
||||
f"UPDATE account SET password=PASSWORD({sql_quote(temp_password)}) WHERE login={sql_quote(args.account_login)};"
|
||||
)
|
||||
|
||||
backup_login_path, _cleanup = write_login_info(
|
||||
login_path=login_path,
|
||||
server_host=server_host,
|
||||
auth_port=auth_port,
|
||||
channel_port=channel_port,
|
||||
account_login=args.account_login,
|
||||
temp_password=temp_password,
|
||||
slot=args.slot,
|
||||
)
|
||||
|
||||
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"] = "gm_teleport"
|
||||
env["M2_HEADLESS_WARP_STEPS"] = serialize_steps(steps)
|
||||
env["M2_HEADLESS_COMMAND_DELAY"] = str(args.command_delay)
|
||||
env["M2_HEADLESS_SETTLE_DELAY"] = str(args.settle_delay)
|
||||
env["M2_HEADLESS_WARP_TIMEOUT"] = str(args.warp_timeout)
|
||||
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_path = log_dir / "headless_gm_teleport_trace.txt"
|
||||
headless_trace_text = headless_trace_path.read_text(encoding="utf-8", errors="ignore") if headless_trace_path.exists() else ""
|
||||
headless_trace_lines = headless_trace_text.strip().splitlines() if headless_trace_text.strip() else []
|
||||
prototype_trace = tail_lines(log_dir / "prototype_trace.txt")
|
||||
system_trace = tail_lines(log_dir / "system_py_trace.txt")
|
||||
syserr_tail = tail_lines(build_bin / "syserr.txt")
|
||||
|
||||
arrivals = {}
|
||||
for index, step in enumerate(steps):
|
||||
marker = f"Warp arrived index={index} map={step.name}"
|
||||
arrivals[step.name] = marker in headless_trace_lines
|
||||
|
||||
success_marker = any(line.startswith("Scenario success current_map=") for line in headless_trace_lines)
|
||||
timeout_marker = any(line.startswith("Warp timeout ") for line in headless_trace_lines)
|
||||
mismatch_marker = any(line.startswith("Warp open mismatch ") for line in headless_trace_lines)
|
||||
|
||||
result = {
|
||||
"ok": success_marker and all(arrivals.values()),
|
||||
"returncode": completed.returncode,
|
||||
"server_host": server_host,
|
||||
"auth_port": auth_port,
|
||||
"channel_port": channel_port,
|
||||
"account_login": args.account_login,
|
||||
"slot": args.slot,
|
||||
"packs": packs,
|
||||
"maps": [step.name for step in steps],
|
||||
"steps": [
|
||||
{
|
||||
"map_name": step.name,
|
||||
"global_x": step.global_x,
|
||||
"global_y": step.global_y,
|
||||
"meter_x": step.meter_x,
|
||||
"meter_y": step.meter_y,
|
||||
}
|
||||
for step in steps
|
||||
],
|
||||
"markers": {
|
||||
"arrivals": arrivals,
|
||||
"success": success_marker,
|
||||
"timeout": timeout_marker,
|
||||
"mismatch": mismatch_marker,
|
||||
},
|
||||
"build_root_pack": build_result,
|
||||
"headless_trace_tail": headless_trace_lines[-30:],
|
||||
"prototype_tail": prototype_trace,
|
||||
"system_tail": system_trace,
|
||||
"syserr_tail": syserr_tail,
|
||||
"stdout_tail": completed.stdout.strip().splitlines()[-10:] if completed.stdout.strip() else [],
|
||||
"stderr_tail": completed.stderr.strip().splitlines()[-10:] if completed.stderr.strip() else [],
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(f"gm_teleport ok={result['ok']} returncode={completed.returncode} maps={','.join(result['maps'])}")
|
||||
for line in result["headless_trace_tail"]:
|
||||
print(f" {line}")
|
||||
|
||||
return 0 if result["ok"] else 1
|
||||
finally:
|
||||
cleanup_errors: list[str] = []
|
||||
|
||||
if original_character_state:
|
||||
try:
|
||||
time.sleep(1.0)
|
||||
restore_character_state(original_character_state)
|
||||
except Exception as exc:
|
||||
cleanup_errors.append(f"restore character failed: {exc}")
|
||||
|
||||
if original_password_hash:
|
||||
try:
|
||||
run_mysql(
|
||||
f"UPDATE account SET password={sql_quote(original_password_hash)} WHERE login={sql_quote(args.account_login)};"
|
||||
)
|
||||
except Exception as exc:
|
||||
cleanup_errors.append(f"restore password failed: {exc}")
|
||||
|
||||
try:
|
||||
restore_login_info(login_path, backup_login_path)
|
||||
except Exception as exc:
|
||||
cleanup_errors.append(f"restore loginInfo failed: {exc}")
|
||||
|
||||
try:
|
||||
restore_moves(moved)
|
||||
except Exception as exc:
|
||||
cleanup_errors.append(f"restore pack moves failed: {exc}")
|
||||
|
||||
if cleanup_errors:
|
||||
raise RuntimeError("; ".join(cleanup_errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
236
scripts/map_load_smoke_wine.py
Normal file
236
scripts/map_load_smoke_wine.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user