From 4139f044dfd1b9302578fce1bb01a1cafc346bf6 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 17:25:17 +0200 Subject: [PATCH] Add Wine runtime smoke test helper --- docs/migration.md | 48 +++++++++++ docs/testing.md | 146 ++++++++++++++++++++++++++++++++ scripts/runtime_smoke_wine.py | 153 ++++++++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 docs/testing.md create mode 100755 scripts/runtime_smoke_wine.py diff --git a/docs/migration.md b/docs/migration.md index 62afd24..d63293a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -58,6 +58,54 @@ delivery gradually, not through a single risky cutover. 6. Validate asset loads in logs and in-game. 7. Move to the next pack group. +## Confirmed startup-safe pack group + +The following startup pack set has already been validated against the real +client runtime on the Linux VPS: + +- `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` + +Validation method: + +1. build each pack as `.m2p` +2. remove the matching legacy `.pck` +3. provide the runtime master key through: + - `M2PACK_MASTER_KEY_HEX` + - `M2PACK_KEY_ID=1` +4. start the Linux-built Windows client with `xvfb-run + wine` + +Observed result: + +- `PackInitialize` succeeded +- Python startup succeeded +- `app.Create(...)` succeeded +- client reached `MainStream.SetLoginPhase()` + +Recommended next pack groups: + +1. remaining startup-adjacent patch packs +2. world and map content such as outdoor, terrain, tree, and zone +3. late-load gameplay content such as pc, npc, monster, and sound + ## Risk notes - Do not mix silent fallback with production security claims. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..bf27fa1 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,146 @@ +# Testing + +## Linux headless + +`m2pack-secure` can be validated headless on a Linux VPS without GUI support. + +This covers the archive toolchain and release artifacts, not the full Windows +client runtime. + +Run: + +```bash +cmake -S . -B build +cmake --build build -j +python3 scripts/headless_e2e.py +``` + +Or through npm: + +```bash +npm run headless:e2e +``` + +The headless E2E test covers: + +- `keygen` +- `build` +- `list` +- `verify` +- `diff` +- `export-client-config` +- `export-runtime-key` in `json` +- `export-runtime-key` in `blob` +- `extract` +- byte-for-byte comparison of extracted files + +## What it does not cover + +- Windows SDK compilation of the client +- Direct3D startup +- shared-memory launcher bootstrap on Windows +- in-game runtime behavior of the client loader + +## Full client runtime + +To test the actual client runtime you still need one of these: + +- a Windows build machine +- a Windows VM +- or a Linux host with `wine` plus the required runtime dependencies + +The Linux VPS path is now validated as well. + +Confirmed runtime path on this host: + +- Linux-hosted Windows build of `m2dev-client-src` +- headless execution through `xvfb-run + wine` +- runtime key delivery through: + - `M2PACK_MASTER_KEY_HEX` + - `M2PACK_KEY_ID` + +Confirmed `.m2p` smoke tests: + +- `root.m2p` only, with `root.pck` removed +- `root.m2p` + `patch1.m2p`, with both legacy `.pck` files removed +- `root.m2p` + `patch1.m2p` + `season3_eu.m2p`, with all three legacy `.pck` + files removed +- `root.m2p` + `patch1.m2p` + `patch2.m2p` + `season3_eu.m2p`, with all four + legacy `.pck` files removed +- `root.m2p` + `patch1.m2p` + `patch2.m2p` + `season3_eu.m2p` + + `metin2_patch_snow.m2p` + `metin2_patch_snow_dungeon.m2p` + + `metin2_patch_etc_costume1.m2p` + `metin2_patch_pet1.m2p`, with all eight + matching legacy `.pck` files removed +- `root.m2p` + `patch1.m2p` + `patch2.m2p` + `season3_eu.m2p` + + `metin2_patch_snow.m2p` + `metin2_patch_snow_dungeon.m2p` + + `metin2_patch_etc_costume1.m2p` + `metin2_patch_pet1.m2p` + + `metin2_patch_pet2.m2p` + `metin2_patch_ramadan_costume.m2p` + + `metin2_patch_flame.m2p` + `metin2_patch_flame_dungeon.m2p`, with all + twelve matching legacy `.pck` files removed +- the same twelve-pack startup set plus: + - `locale.m2p` + - `uiscript.m2p` + - `uiloading.m2p` + - `ETC.m2p` + with all sixteen matching legacy `.pck` files removed +- the same sixteen-pack startup and UI bootstrap set plus: + - `item.m2p` + - `effect.m2p` + - `icon.m2p` + - `property.m2p` + with all twenty matching legacy `.pck` files removed + +In every confirmed case the client reached the login bootstrap path under Wine +and completed: + +- `PackInitialize` +- Python bootstrap through `prototype.py` +- `app.Create(...)` +- `app.SetCamera(...)` +- `MainStream.SetLoginPhase()` + +This is now enough to treat the `.m2p` path as validated for the current core +startup, UI bootstrap, and shared login-adjacent content pack set used by the +client runtime. + +At this point the login-phase smoke test stops being a strong validator for +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. + +Current non-fatal runtime issues on the VPS: + +- missing `Tahoma` font mapping in the client runtime +- headless ALSA / audio decoder warnings + +Those do not currently block `.m2p` loading or the login-phase smoke test. + +Example runtime command: + +```bash +M2PACK_MASTER_KEY_HEX="$(cat /home/mt2.jakubkadlec.dev/.secrets/m2pack-secure/2026-04-14/master.key)" \ +M2PACK_KEY_ID=1 \ +M2_TIMEOUT=20 \ +WINEDEBUG=-all \ +/home/mt2.jakubkadlec.dev/metin/repos/m2dev-client-src/scripts/run-wine-headless.sh \ + /home/mt2.jakubkadlec.dev/metin/repos/m2dev-client-src/build-mingw64-lld/bin +``` + +Automated smoke runner for selected packs: + +```bash +python3 scripts/runtime_smoke_wine.py \ + --runtime-root /tmp/m2dev-client-runtime-http \ + --client-repo /home/mt2.jakubkadlec.dev/metin/repos/m2dev-client-src \ + --master-key /home/mt2.jakubkadlec.dev/.secrets/m2pack-secure/2026-04-14/master.key \ + --key-id 1 \ + --pack root \ + --pack patch1 \ + --pack patch2 \ + --pack season3_eu \ + --pack locale \ + --pack uiscript \ + --pack uiloading \ + --pack ETC \ + --json +``` diff --git a/scripts/runtime_smoke_wine.py b/scripts/runtime_smoke_wine.py new file mode 100755 index 0000000..b56354b --- /dev/null +++ b/scripts/runtime_smoke_wine.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Temporarily switch selected legacy client packs to .m2p and run the Wine login smoke test." + ) + parser.add_argument( + "--runtime-root", + type=Path, + required=True, + help="Client runtime root containing pack/ and config/.", + ) + 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( + "--pack", + dest="packs", + action="append", + required=True, + 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 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'}") + + moved: list[tuple[Path, Path]] = [] + try: + for pack_name in args.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.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, + ) + + prototype_trace = (log_dir / "prototype_trace.txt").read_text(encoding="utf-8", errors="ignore") if (log_dir / "prototype_trace.txt").exists() else "" + system_trace = (log_dir / "system_py_trace.txt").read_text(encoding="utf-8", errors="ignore") if (log_dir / "system_py_trace.txt").exists() else "" + syserr = (build_bin / "syserr.txt").read_text(encoding="utf-8", errors="ignore") if (build_bin / "syserr.txt").exists() else "" + + result = { + "ok": "SetLoginPhase ok" in prototype_trace, + "returncode": completed.returncode, + "packs": args.packs, + "prototype_tail": prototype_trace.strip().splitlines()[-10:], + "system_tail": system_trace.strip().splitlines()[-10:], + "syserr_tail": syserr.strip().splitlines()[-10:], + } + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"packs={','.join(args.packs)} ok={result['ok']} returncode={completed.returncode}") + if result["prototype_tail"]: + print("prototype tail:") + for line in result["prototype_tail"]: + print(f" {line}") + + return 0 if result["ok"] else 1 + finally: + restore_moves(moved) + + +if __name__ == "__main__": + sys.exit(main())