- 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
395 lines
12 KiB
Python
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()
|