Add Wine runtime smoke test helper
This commit is contained in:
@@ -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.
|
||||
|
||||
146
docs/testing.md
Normal file
146
docs/testing.md
Normal file
@@ -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
|
||||
```
|
||||
153
scripts/runtime_smoke_wine.py
Executable file
153
scripts/runtime_smoke_wine.py
Executable file
@@ -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())
|
||||
Reference in New Issue
Block a user