docs: add Bannerlord VM setup guide and GABS connectivity test (#1098)
Some checks failed
Tests / lint (pull_request) Failing after 16s
Tests / test (pull_request) Has been skipped

- docs/research/bannerlord-vm-setup.md: step-by-step guide for
  provisioning the Windows VM, installing Bannerlord + GABS mod,
  verifying the GABS TCP server on port 4825, and confirming
  connectivity from Hermes; includes reproducibility checklist
- scripts/test_gabs_connectivity.py: smoke test script for GABS
  TCP JSON-RPC connectivity from Hermes (mirrors test_hermes4.py
  style); covers TCP connection, ping round-trip, get_game_state,
  and LAN latency target (< 100 ms)

Fixes #1098

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 14:14:32 -04:00
parent 852fec3681
commit 3b580c4905
2 changed files with 474 additions and 0 deletions

View File

@@ -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 <user> <pass> +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 <VM_IP> --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`

View File

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