Files
the-nexus/nexus/bannerlord_local.py
Alexander Whitestone 73bc86d3a2
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 2s
fix: [EPIC] Local Bannerlord on Mac — emulator, harness bridge, and portal readiness (closes #719)
- scripts/bannerlord_launcher.sh: Mac launcher detects Whisky/CrossOver/Wine and GOG Bannerlord install
- nexus/bannerlord_local.py: Python module for programmatic readiness check, launch, and stop
- nexus/bannerlord_harness.py: Added --local and --launch-local CLI flags
- portals.json: Updated bannerlord portal with local_launch metadata and environment=local
- docs/BANNERLORD_LOCAL_MAC.md: Full documentation of local Mac setup
2026-04-10 20:19:07 -04:00

395 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Bannerlord Local Manager — macOS Emulator Bridge
Detects and manages a local Bannerlord installation on macOS.
Provides status queries, launch control, and process monitoring
for the Bannerlord harness.
This module bridges the gap between:
- The GamePortal Protocol (MCP-based observation/action)
- A local GOG Bannerlord running through Wine/Whisky/CrossOver on macOS
The harness does NOT change — this module just manages the game process.
"""
from __future__ import annotations
import json
import logging
import os
import platform
import subprocess
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
log = logging.getLogger("bannerlord.local")
class EmulatorType(Enum):
WHISKY = "whisky"
CROSSOVER = "crossover"
WINE = "wine"
UNKNOWN = "unknown"
class LocalStatus(Enum):
READY = "ready"
MISSING_EMULATOR = "missing_emulator"
MISSING_GAME = "missing_game"
RUNNING = "running"
CRASHED = "crashed"
ERROR = "error"
# Standard GOG install paths on macOS
GOG_SEARCH_PATHS = [
Path("/Applications/Games/Mount & Blade II Bannerlord"),
Path.home() / "GOG Games" / "Mount and Blade II Bannerlord",
Path.home() / "Games" / "Mount & Blade II Bannerlord",
Path("/Applications/Mount & Blade II Bannerlord"),
]
BANNERLORD_EXE_RELATIVE = "bin/Generic/Bannerlord.exe"
LAUNCHER_SCRIPT = Path(__file__).parent.parent / "scripts" / "bannerlord_launcher.sh"
@dataclass
class EmulatorInfo:
"""Detected Windows emulator on macOS."""
name: str = ""
path: str = ""
emulator_type: EmulatorType = EmulatorType.UNKNOWN
found: bool = False
@dataclass
class GameInstall:
"""Detected Bannerlord GOG installation."""
game_dir: str = ""
game_exe: str = ""
found: bool = False
source: str = "" # "gog", "gog-galaxy", "manual"
@dataclass
class LocalState:
"""Full local Bannerlord state."""
status: LocalStatus = LocalStatus.ERROR
emulator: EmulatorInfo = field(default_factory=EmulatorInfo)
game: GameInstall = field(default_factory=GameInstall)
process_id: Optional[int] = None
message: str = ""
is_macos: bool = False
def to_dict(self) -> dict:
return {
"status": self.status.value,
"emulator": {
"name": self.emulator.name,
"path": self.emulator.path,
"type": self.emulator.emulator_type.value,
"found": self.emulator.found,
},
"game": {
"game_dir": self.game.game_dir,
"game_exe": self.game.game_exe,
"found": self.game.found,
"source": self.game.source,
},
"process_id": self.process_id,
"message": self.message,
"is_macos": self.is_macos,
}
def detect_macos() -> bool:
"""Check if running on macOS."""
return platform.system() == "Darwin"
def detect_emulator() -> EmulatorInfo:
"""Find a Windows emulator on macOS."""
info = EmulatorInfo()
# Whisky
whisky_path = "/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
if os.path.isfile(whisky_path) and os.access(whisky_path, os.X_OK):
info.name = "Whisky"
info.path = whisky_path
info.emulator_type = EmulatorType.WHISKY
info.found = True
return info
# CrossOver
cx_path = "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
if os.path.isfile(cx_path) and os.access(cx_path, os.X_OK):
info.name = "CrossOver"
info.path = cx_path
info.emulator_type = EmulatorType.CROSSOVER
info.found = True
return info
# Homebrew wine
for candidate in ["wine64", "wine"]:
try:
result = subprocess.run(
["which", candidate],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
info.name = candidate
info.path = result.stdout.strip()
info.emulator_type = EmulatorType.WINE
info.found = True
return info
except (subprocess.TimeoutExpired, FileNotFoundError):
continue
return info
def detect_game() -> GameInstall:
"""Find the Bannerlord GOG installation."""
install = GameInstall()
# Check standard paths
for path in GOG_SEARCH_PATHS:
exe_path = path / BANNERLORD_EXE_RELATIVE
if exe_path.is_file():
install.game_dir = str(path)
install.game_exe = str(exe_path)
install.found = True
install.source = "gog"
return install
# Check GOG Galaxy paths
galaxy_base = Path.home() / "Library/Application Support/GOG.com/Galaxy/Applications"
if galaxy_base.is_dir():
for child in galaxy_base.iterdir():
candidate = child / "Mount & Blade II Bannerlord" / BANNERLORD_EXE_RELATIVE
if candidate.is_file():
install.game_dir = str(candidate.parent.parent)
install.game_exe = str(candidate)
install.found = True
install.source = "gog-galaxy"
return install
# Last resort: find
try:
result = subprocess.run(
["find", "/Applications", str(Path.home() / "GOG Games"),
str(Path.home() / "Games"), "-name", "Bannerlord.exe",
"-type", "f"],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
first_line = result.stdout.strip().split("\n")[0]
install.game_exe = first_line
install.game_dir = str(Path(first_line).parent.parent)
install.found = True
install.source = "search"
return install
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return install
def check_local_readiness() -> LocalState:
"""Full local readiness check. Returns complete state."""
state = LocalState()
state.is_macos = detect_macos()
if not state.is_macos:
state.status = LocalStatus.ERROR
state.message = "Not macOS — local manager is Mac-only"
return state
state.emulator = detect_emulator()
if not state.emulator.found:
state.status = LocalStatus.MISSING_EMULATOR
state.message = "No Windows emulator found (install Whisky, CrossOver, or wine)"
return state
state.game = detect_game()
if not state.game.found:
state.status = LocalStatus.MISSING_GAME
state.message = "Bannerlord GOG installation not found in known paths"
return state
# Check if already running
pid = _read_pid()
if pid and _is_process_running(pid):
state.status = LocalStatus.RUNNING
state.process_id = pid
state.message = f"Bannerlord already running (PID: {pid})"
else:
state.status = LocalStatus.READY
state.message = "Ready to launch"
return state
def launch_bannerlord(state: Optional[LocalState] = None) -> LocalState:
"""Launch Bannerlord via the emulator. Returns updated state."""
if state is None:
state = check_local_readiness()
if state.status not in (LocalStatus.READY, LocalStatus.RUNNING):
return state
if state.status == LocalStatus.RUNNING:
state.message = f"Already running (PID: {state.process_id})"
return state
# Check if launcher script exists
if LAUNCHER_SCRIPT.is_file():
log.info(f"Using launcher script: {LAUNCHER_SCRIPT}")
try:
result = subprocess.run(
["bash", str(LAUNCHER_SCRIPT), "--launch"],
capture_output=True, text=True, timeout=30,
cwd=str(LAUNCHER_SCRIPT.parent.parent),
)
if result.returncode == 0:
# Parse PID from output
for line in result.stdout.strip().split("\n"):
if "PID:" in line:
try:
pid = int(line.split("PID:")[1].strip().rstrip(")"))
state.process_id = pid
except (ValueError, IndexError):
pass
state.status = LocalStatus.RUNNING
state.message = "Launched via launcher script"
return state
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
log.warning(f"Launcher script failed: {e}, falling back to direct launch")
# Direct launch fallback
try:
log.info(f"Launching Bannerlord directly via {state.emulator.name}")
proc = subprocess.Popen(
[state.emulator.path, state.game.game_exe],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=state.game.game_dir,
)
state.process_id = proc.pid
state.status = LocalStatus.RUNNING
state.message = f"Launched (PID: {proc.pid})"
_write_pid(proc.pid)
except Exception as e:
state.status = LocalStatus.CRASHED
state.message = f"Launch failed: {e}"
return state
def stop_bannerlord() -> bool:
"""Stop a running Bannerlord process."""
pid = _read_pid()
if not pid or not _is_process_running(pid):
_clear_pid()
return False
try:
os.kill(pid, 15) # SIGTERM
time.sleep(1)
if _is_process_running(pid):
os.kill(pid, 9) # SIGKILL
_clear_pid()
log.info(f"Stopped Bannerlord (PID: {pid})")
return True
except ProcessLookupError:
_clear_pid()
return False
# ═══════════════════════════════════════════════════════════════════════════
# PID FILE MANAGEMENT
# ═══════════════════════════════════════════════════════════════════════════
PID_FILE = Path("/tmp/bannerlord.pid")
def _read_pid() -> Optional[int]:
try:
if PID_FILE.is_file():
return int(PID_FILE.read_text().strip())
except (ValueError, OSError):
pass
return None
def _write_pid(pid: int):
try:
PID_FILE.write_text(str(pid))
except OSError as e:
log.warning(f"Failed to write PID file: {e}")
def _clear_pid():
try:
if PID_FILE.is_file():
PID_FILE.unlink()
except OSError:
pass
def _is_process_running(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
# ═══════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════
def main():
import argparse
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Bannerlord Local Manager — macOS")
parser.add_argument("--check", action="store_true", help="Check readiness")
parser.add_argument("--launch", action="store_true", help="Launch the game")
parser.add_argument("--stop", action="store_true", help="Stop running game")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.stop:
stopped = stop_bannerlord()
if args.json:
print(json.dumps({"stopped": stopped}))
else:
print("Stopped." if stopped else "Not running.")
return
if args.launch:
state = launch_bannerlord()
else:
state = check_local_readiness()
if args.json:
print(json.dumps(state.to_dict(), indent=2))
else:
print(f"Status: {state.status.value}")
print(f"Emulator: {state.emulator.name or 'none'} ({state.emulator.emulator_type.value})")
print(f"Game: {state.game.game_dir or 'not found'}")
if state.process_id:
print(f"PID: {state.process_id}")
print(f"Message: {state.message}")
if __name__ == "__main__":
main()