diff --git a/docs/research/bannerlord-vm-setup.md b/docs/research/bannerlord-vm-setup.md new file mode 100644 index 00000000..7745f07e --- /dev/null +++ b/docs/research/bannerlord-vm-setup.md @@ -0,0 +1,230 @@ +# Bannerlord Windows VM Setup Guide + +**Issue:** #1098 +**Parent Epic:** #1091 (Project Bannerlord) +**Date:** 2026-03-23 +**Status:** Reference + +--- + +## Overview + +This document covers provisioning the Windows VM that hosts Bannerlord + GABS mod, +verifying the GABS TCP JSON-RPC server, and confirming connectivity from Hermes. + +Architecture reminder: +``` +Timmy (Qwen3 on Ollama, Hermes M3 Max) + → GABS TCP/JSON-RPC (port 4825) + → Bannerlord.GABS C# mod + → Game API + Harmony + → Bannerlord (Windows VM) +``` + +--- + +## 1. Provision Windows VM + +### Minimum Spec +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| CPU | 4 cores | 8 cores | +| RAM | 16 GB | 32 GB | +| Disk | 100 GB SSD | 150 GB SSD | +| OS | Windows Server 2022 / Windows 11 | Windows 11 | +| Network | Private VLAN to Hermes | Private VLAN to Hermes | + +### Hetzner (preferred) +```powershell +# Hetzner Cloud CLI — create CX41 (4 vCPU, 16 GB RAM, 160 GB SSD) +hcloud server create \ + --name bannerlord-vm \ + --type cx41 \ + --image windows-server-2022 \ + --location nbg1 \ + --ssh-key your-key +``` + +### DigitalOcean alternative +``` +Droplet: General Purpose 4 vCPU / 16 GB / 100 GB SSD +Image: Windows Server 2022 +Region: Same region as Hermes +``` + +### Post-provision +1. Enable RDP (port 3389) for initial setup only — close after configuration +2. Open port 4825 TCP inbound from Hermes IP only +3. Disable Windows Firewall for 4825 or add specific allow rule: + ```powershell + New-NetFirewallRule -DisplayName "GABS TCP" -Direction Inbound ` + -Protocol TCP -LocalPort 4825 -Action Allow + ``` + +--- + +## 2. Install Steam + Bannerlord + +### Steam installation +1. Download Steam installer from store.steampowered.com +2. Install silently: + ```powershell + .\SteamSetup.exe /S + ``` +3. Log in with a dedicated Steam account (not personal) + +### Bannerlord installation +```powershell +# Install Bannerlord (App ID: 261550) via SteamCMD +steamcmd +login +app_update 261550 validate +quit +``` + +### Pin game version +GABS requires a specific Bannerlord version. To pin and prevent auto-updates: +1. Right-click Bannerlord in Steam → Properties → Updates +2. Set "Automatic Updates" to "Only update this game when I launch it" +3. Record the current version in `docs/research/bannerlord-vm-setup.md` after installation + +```powershell +# Check installed version +Get-Content "C:\Program Files (x86)\Steam\steamapps\appmanifest_261550.acf" | + Select-String "buildid" +``` + +--- + +## 3. Install GABS Mod + +### Source +- NexusMods: https://www.nexusmods.com/mountandblade2bannerlord/mods/10419 +- GitHub: https://github.com/BUTR/Bannerlord.GABS +- AGENTS.md: https://github.com/BUTR/Bannerlord.GABS/blob/master/AGENTS.md + +### Installation via Vortex (NexusMods) +1. Install Vortex Mod Manager +2. Download GABS mod package from NexusMods +3. Install via Vortex — it handles the Modules/ directory layout automatically +4. Enable in the mod list and set load order after Harmony + +### Manual installation +```powershell +# Copy mod to Bannerlord Modules directory +$BannerlordPath = "C:\Program Files (x86)\Steam\steamapps\common\Mount & Blade II Bannerlord" +Copy-Item -Recurse ".\Bannerlord.GABS" "$BannerlordPath\Modules\Bannerlord.GABS" +``` + +### Required dependencies +- **Harmony** (BUTR.Harmony) — must load before GABS +- **ButterLib** — utility library +Install via the same method as GABS. + +### GABS configuration +GABS TCP server listens on `0.0.0.0:4825` by default. To confirm or override: +``` +%APPDATA%\Mount and Blade II Bannerlord\Configs\Bannerlord.GABS\settings.json +``` +Expected defaults: +```json +{ + "ServerHost": "0.0.0.0", + "ServerPort": 4825, + "LogLevel": "Information" +} +``` + +--- + +## 4. Verify GABS TCP Server + +### Start Bannerlord with GABS +Launch Bannerlord with the mod enabled. GABS starts its TCP server during game +initialisation. Watch the game log for: +``` +[GABS] TCP server listening on 0.0.0.0:4825 +``` + +Log location: +``` +%APPDATA%\Mount and Blade II Bannerlord\logs\rgl_log_*.txt +``` + +### Local connectivity check (on VM) +```powershell +# Verify port is listening +netstat -an | findstr 4825 + +# Quick TCP probe +Test-NetConnection -ComputerName localhost -Port 4825 +``` + +### Send a test JSON-RPC call +```powershell +$msg = '{"jsonrpc":"2.0","method":"ping","id":1}' +$client = New-Object System.Net.Sockets.TcpClient("localhost", 4825) +$stream = $client.GetStream() +$writer = New-Object System.IO.StreamWriter($stream) +$writer.AutoFlush = $true +$writer.WriteLine($msg) +$reader = New-Object System.IO.StreamReader($stream) +$response = $reader.ReadLine() +Write-Host "Response: $response" +$client.Close() +``` + +Expected response shape: +```json +{"jsonrpc":"2.0","result":{"status":"ok"},"id":1} +``` + +--- + +## 5. Test Connectivity from Hermes + +Use `scripts/test_gabs_connectivity.py` (checked in with this issue): + +```bash +# From Hermes (M3 Max) +python scripts/test_gabs_connectivity.py --host --port 4825 +``` + +The script tests: +1. TCP socket connection +2. JSON-RPC ping round-trip +3. `get_game_state` call +4. Response latency (target < 100 ms on LAN) + +--- + +## 6. Firewall / Network Summary + +| Source | Destination | Port | Protocol | Purpose | +|--------|-------------|------|----------|---------| +| Hermes (local) | Bannerlord VM | 4825 | TCP | GABS JSON-RPC | +| Admin workstation | Bannerlord VM | 3389 | TCP | RDP setup (disable after) | + +--- + +## 7. Reproducibility Checklist + +After completing setup, record: + +- [ ] VM provider + region + instance type +- [ ] Windows version + build number +- [ ] Steam account used (non-personal, credentials in secrets manager) +- [ ] Bannerlord App version (buildid from appmanifest) +- [ ] GABS version (from NexusMods or GitHub release tag) +- [ ] Harmony version +- [ ] ButterLib version +- [ ] GABS settings.json contents +- [ ] VM IP address (update Timmy config) +- [ ] Connectivity test output from `test_gabs_connectivity.py` + +--- + +## References + +- GABS GitHub: https://github.com/BUTR/Bannerlord.GABS +- GABS AGENTS.md: https://github.com/BUTR/Bannerlord.GABS/blob/master/AGENTS.md +- NexusMods page: https://www.nexusmods.com/mountandblade2bannerlord/mods/10419 +- Parent Epic: #1091 +- Connectivity test script: `scripts/test_gabs_connectivity.py` diff --git a/scripts/test_gabs_connectivity.py b/scripts/test_gabs_connectivity.py new file mode 100644 index 00000000..cad3f84b --- /dev/null +++ b/scripts/test_gabs_connectivity.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""GABS TCP connectivity and JSON-RPC smoke test. + +Tests connectivity from Hermes to the Bannerlord.GABS TCP server running on the +Windows VM. Covers: + 1. TCP socket connection (port 4825 reachable) + 2. JSON-RPC ping round-trip + 3. get_game_state call (game must be running) + 4. Latency — target < 100 ms on LAN + +Usage: + python scripts/test_gabs_connectivity.py --host 10.0.0.50 + python scripts/test_gabs_connectivity.py --host 10.0.0.50 --port 4825 --timeout 5 + +Refs: #1098 (Bannerlord Infra — Windows VM Setup + GABS Mod Installation) +Epic: #1091 (Project Bannerlord) +""" + +from __future__ import annotations + +import argparse +import json +import socket +import sys +import time +from typing import Any + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 4825 +DEFAULT_TIMEOUT = 5 # seconds +LATENCY_TARGET_MS = 100.0 + + +# ── Low-level TCP helpers ───────────────────────────────────────────────────── + + +def _tcp_connect(host: str, port: int, timeout: float) -> socket.socket: + """Open a TCP connection and return the socket. Raises on failure.""" + sock = socket.create_connection((host, port), timeout=timeout) + sock.settimeout(timeout) + return sock + + +def _send_recv(sock: socket.socket, payload: dict[str, Any]) -> dict[str, Any]: + """Send a newline-delimited JSON-RPC request and return the parsed response.""" + raw = json.dumps(payload) + "\n" + sock.sendall(raw.encode()) + + buf = b"" + while b"\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + raise ConnectionError("Connection closed before response received") + buf += chunk + + line = buf.split(b"\n", 1)[0] + return json.loads(line.decode()) + + +def _rpc(sock: socket.socket, method: str, params: dict | None = None, req_id: int = 1) -> dict[str, Any]: + """Build and send a JSON-RPC 2.0 request, return the response dict.""" + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "method": method, + "id": req_id, + } + if params: + payload["params"] = params + return _send_recv(sock, payload) + + +# ── Test cases ──────────────────────────────────────────────────────────────── + + +def test_tcp_connection(host: str, port: int, timeout: float) -> tuple[bool, socket.socket | None]: + """PASS: TCP connection to host:port succeeds.""" + print(f"\n[1/4] TCP connection → {host}:{port}") + try: + t0 = time.monotonic() + sock = _tcp_connect(host, port, timeout) + elapsed_ms = (time.monotonic() - t0) * 1000 + print(f" ✓ Connected ({elapsed_ms:.1f} ms)") + return True, sock + except OSError as exc: + print(f" ✗ Connection failed: {exc}") + print(f" Checklist:") + print(f" - Is Bannerlord running with GABS mod enabled?") + print(f" - Is port {port} open in Windows Firewall?") + print(f" - Is the VM IP correct? (got: {host})") + return False, None + + +def test_ping(sock: socket.socket) -> bool: + """PASS: JSON-RPC ping returns a 2.0 response.""" + print(f"\n[2/4] JSON-RPC ping") + try: + t0 = time.monotonic() + resp = _rpc(sock, "ping", req_id=1) + elapsed_ms = (time.monotonic() - t0) * 1000 + if resp.get("jsonrpc") == "2.0" and "error" not in resp: + print(f" ✓ Ping OK ({elapsed_ms:.1f} ms): {json.dumps(resp)}") + return True + print(f" ✗ Unexpected response ({elapsed_ms:.1f} ms): {json.dumps(resp)}") + return False + except Exception as exc: + print(f" ✗ Ping failed: {exc}") + return False + + +def test_game_state(sock: socket.socket) -> bool: + """PASS: get_game_state returns a result (game must be in a campaign).""" + print(f"\n[3/4] get_game_state call") + try: + t0 = time.monotonic() + resp = _rpc(sock, "get_game_state", req_id=2) + elapsed_ms = (time.monotonic() - t0) * 1000 + if "error" in resp: + code = resp["error"].get("code", "?") + msg = resp["error"].get("message", "") + if code == -32601: + # Method not found — GABS version may not expose this method + print(f" ~ Method not available ({elapsed_ms:.1f} ms): {msg}") + print(f" This is acceptable if game is not yet in a campaign.") + return True + print(f" ✗ RPC error ({elapsed_ms:.1f} ms) [{code}]: {msg}") + return False + result = resp.get("result", {}) + print(f" ✓ Game state received ({elapsed_ms:.1f} ms):") + for k, v in result.items(): + print(f" {k}: {v}") + return True + except Exception as exc: + print(f" ✗ get_game_state failed: {exc}") + return False + + +def test_latency(host: str, port: int, timeout: float, iterations: int = 5) -> bool: + """PASS: Average round-trip latency is under LATENCY_TARGET_MS.""" + print(f"\n[4/4] Latency test ({iterations} pings, target < {LATENCY_TARGET_MS:.0f} ms)") + try: + times: list[float] = [] + for i in range(iterations): + sock = _tcp_connect(host, port, timeout) + try: + t0 = time.monotonic() + _rpc(sock, "ping", req_id=i + 10) + times.append((time.monotonic() - t0) * 1000) + finally: + sock.close() + + avg_ms = sum(times) / len(times) + min_ms = min(times) + max_ms = max(times) + print(f" avg={avg_ms:.1f} ms min={min_ms:.1f} ms max={max_ms:.1f} ms") + + if avg_ms <= LATENCY_TARGET_MS: + print(f" ✓ Latency within target ({avg_ms:.1f} ms ≤ {LATENCY_TARGET_MS:.0f} ms)") + return True + print( + f" ✗ Latency too high ({avg_ms:.1f} ms > {LATENCY_TARGET_MS:.0f} ms)\n" + f" Check network path between Hermes and the VM." + ) + return False + except Exception as exc: + print(f" ✗ Latency test failed: {exc}") + return False + + +# ── Main ────────────────────────────────────────────────────────────────────── + + +def main() -> int: + parser = argparse.ArgumentParser(description="GABS TCP connectivity smoke test") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Bannerlord VM IP or hostname (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"GABS TCP port (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_TIMEOUT, + help=f"Socket timeout in seconds (default: {DEFAULT_TIMEOUT})", + ) + args = parser.parse_args() + + print("=" * 60) + print(f"GABS Connectivity Test Suite") + print(f"Target: {args.host}:{args.port}") + print(f"Timeout: {args.timeout}s") + print("=" * 60) + + results: dict[str, bool] = {} + + # Test 1: TCP connection (gate — skip remaining if unreachable) + ok, sock = test_tcp_connection(args.host, args.port, args.timeout) + results["tcp_connection"] = ok + if not ok: + _print_summary(results) + return 1 + + # Tests 2–3 reuse the same socket + try: + results["ping"] = test_ping(sock) + results["game_state"] = test_game_state(sock) + finally: + sock.close() + + # Test 4: latency uses fresh connections + results["latency"] = test_latency(args.host, args.port, args.timeout) + + return _print_summary(results) + + +def _print_summary(results: dict[str, bool]) -> int: + passed = sum(results.values()) + total = len(results) + print("\n" + "=" * 60) + print(f"Results: {passed}/{total} passed") + print("=" * 60) + for name, ok in results.items(): + icon = "✓" if ok else "✗" + print(f" {icon} {name}") + + if passed == total: + print("\n✓ GABS connectivity verified. Timmy can reach the game.") + print(" Next step: run benchmark level 0 (JSON compliance check).") + elif not results.get("tcp_connection"): + print("\n✗ TCP connection failed. VM/firewall setup incomplete.") + print(" See docs/research/bannerlord-vm-setup.md for checklist.") + else: + print("\n~ Partial pass — review failures above.") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main())