#!/usr/bin/env python3 """ Bannerlord Runtime Manager — Apple Silicon via Whisky Provides programmatic access to the Whisky/Wine runtime for Bannerlord. Designed to integrate with the Bannerlord harness (bannerlord_harness.py). Runtime choice documented in docs/BANNERLORD_RUNTIME.md. Issue #720. """ from __future__ import annotations import json import logging import os import subprocess import time from dataclasses import dataclass, field from pathlib import Path from typing import Optional log = logging.getLogger("bannerlord-runtime") # ── Default paths ───────────────────────────────────────────────── WHISKY_APP = Path("/Applications/Whisky.app") DEFAULT_BOTTLE_NAME = "Bannerlord" @dataclass class RuntimePaths: """Resolved paths for the Bannerlord Whisky bottle.""" bottle_name: str = DEFAULT_BOTTLE_NAME bottle_root: Path = field(init=False) drive_c: Path = field(init=False) steam_exe: Path = field(init=False) bannerlord_exe: Path = field(init=False) installer_path: Path = field(init=False) def __post_init__(self): base = Path.home() / "Library/Application Support/Whisky/Bottles" / self.bottle_name self.bottle_root = base self.drive_c = base / "drive_c" self.steam_exe = ( base / "drive_c/Program Files (x86)/Steam/Steam.exe" ) self.bannerlord_exe = ( base / "drive_c/Program Files (x86)/Steam/steamapps/common" / "Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe" ) self.installer_path = Path("/tmp/SteamSetup.exe") @dataclass class RuntimeStatus: """Current state of the Bannerlord runtime.""" whisky_installed: bool = False whisky_version: str = "" bottle_exists: bool = False drive_c_populated: bool = False steam_installed: bool = False bannerlord_installed: bool = False gptk_available: bool = False macos_version: str = "" macos_ok: bool = False errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) @property def ready(self) -> bool: return ( self.whisky_installed and self.bottle_exists and self.steam_installed and self.bannerlord_installed and self.macos_ok ) def to_dict(self) -> dict: return { "whisky_installed": self.whisky_installed, "whisky_version": self.whisky_version, "bottle_exists": self.bottle_exists, "drive_c_populated": self.drive_c_populated, "steam_installed": self.steam_installed, "bannerlord_installed": self.bannerlord_installed, "gptk_available": self.gptk_available, "macos_version": self.macos_version, "macos_ok": self.macos_ok, "ready": self.ready, "errors": self.errors, "warnings": self.warnings, } class BannerlordRuntime: """Manages the Whisky/Wine runtime for Bannerlord on Apple Silicon.""" def __init__(self, bottle_name: str = DEFAULT_BOTTLE_NAME): self.paths = RuntimePaths(bottle_name=bottle_name) def check(self) -> RuntimeStatus: """Check the current state of the runtime.""" status = RuntimeStatus() # macOS version try: result = subprocess.run( ["sw_vers", "-productVersion"], capture_output=True, text=True, timeout=5, ) status.macos_version = result.stdout.strip() major = int(status.macos_version.split(".")[0]) status.macos_ok = major >= 14 if not status.macos_ok: status.errors.append(f"macOS {status.macos_version} too old, need 14+") except Exception as e: status.errors.append(f"Cannot detect macOS version: {e}") # Whisky installed if WHISKY_APP.exists(): status.whisky_installed = True try: result = subprocess.run( [ "defaults", "read", str(WHISKY_APP / "Contents/Info.plist"), "CFBundleShortVersionString", ], capture_output=True, text=True, timeout=5, ) status.whisky_version = result.stdout.strip() except Exception: status.whisky_version = "unknown" else: status.errors.append(f"Whisky not found at {WHISKY_APP}") # Bottle status.bottle_exists = self.paths.bottle_root.exists() if not status.bottle_exists: status.errors.append(f"Bottle not found: {self.paths.bottle_root}") # drive_c status.drive_c_populated = self.paths.drive_c.exists() if not status.drive_c_populated and status.bottle_exists: status.warnings.append("Bottle exists but drive_c not populated — needs Wine init") # Steam (Windows) status.steam_installed = self.paths.steam_exe.exists() if not status.steam_installed: status.warnings.append("Steam (Windows) not installed in bottle") # Bannerlord status.bannerlord_installed = self.paths.bannerlord_exe.exists() if not status.bannerlord_installed: status.warnings.append("Bannerlord not installed") # GPTK/D3DMetal whisky_support = Path.home() / "Library/Application Support/Whisky" if whisky_support.exists(): gptk_files = list(whisky_support.rglob("*gptk*")) + \ list(whisky_support.rglob("*d3dmetal*")) + \ list(whisky_support.rglob("*dxvk*")) status.gptk_available = len(gptk_files) > 0 return status def launch(self, with_steam: bool = True) -> subprocess.Popen | None: """ Launch Bannerlord via Whisky. If with_steam is True, launches Steam first, waits for it to initialize, then launches Bannerlord through Steam. """ status = self.check() if not status.ready: log.error("Runtime not ready: %s", "; ".join(status.errors or status.warnings)) return None if with_steam: log.info("Launching Steam (Windows) via Whisky...") steam_proc = self._run_exe(str(self.paths.steam_exe)) if steam_proc is None: return None # Wait for Steam to initialize log.info("Waiting for Steam to initialize (15s)...") time.sleep(15) # Launch Bannerlord via steam://rungameid/ log.info("Launching Bannerlord via Steam protocol...") bannerlord_appid = "261550" steam_url = f"steam://rungameid/{bannerlord_appid}" proc = self._run_exe(str(self.paths.steam_exe), args=[steam_url]) if proc: log.info("Bannerlord launch command sent (PID: %d)", proc.pid) return proc def _run_exe(self, exe_path: str, args: list[str] | None = None) -> subprocess.Popen | None: """Run a Windows executable through Whisky's wine64-preloader.""" # Whisky uses wine64-preloader from its bundled Wine wine64 = self._find_wine64() if wine64 is None: log.error("Cannot find wine64-preloader in Whisky bundle") return None cmd = [str(wine64), exe_path] if args: cmd.extend(args) env = os.environ.copy() env["WINEPREFIX"] = str(self.paths.bottle_root) try: proc = subprocess.Popen( cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) return proc except Exception as e: log.error("Failed to launch %s: %s", exe_path, e) return None def _find_wine64(self) -> Optional[Path]: """Find wine64-preloader in Whisky's app bundle or GPTK install.""" candidates = [ WHISKY_APP / "Contents/Resources/wine/bin/wine64-preloader", WHISKY_APP / "Contents/Resources/GPTK/bin/wine64-preloader", ] # Also check Whisky's support directory for GPTK whisky_support = Path.home() / "Library/Application Support/Whisky" if whisky_support.exists(): for p in whisky_support.rglob("wine64-preloader"): candidates.append(p) for c in candidates: if c.exists() and os.access(c, os.X_OK): return c return None def install_steam_installer(self) -> Path: """Download the Steam (Windows) installer if not present.""" installer = self.paths.installer_path if installer.exists(): log.info("Steam installer already at: %s", installer) return installer log.info("Downloading Steam (Windows) installer...") url = "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe" subprocess.run( ["curl", "-L", "-o", str(installer), url], check=True, ) log.info("Steam installer saved to: %s", installer) return installer if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") rt = BannerlordRuntime() status = rt.check() print(json.dumps(status.to_dict(), indent=2))