Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
73bc86d3a2 fix: [EPIC] Local Bannerlord on Mac — emulator, harness bridge, and portal readiness (closes #719)
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
- 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
6 changed files with 813 additions and 96 deletions

View File

@@ -0,0 +1,151 @@
# Bannerlord Local Mac Setup
> **Status:** READY FOR TESTING
> **Platform:** macOS (Apple Silicon / Intel)
> **Source:** GOG (not Steam)
> **Last Updated:** 2026-04-10
## Problem
Bannerlord is a Windows game. Alexander has it from GOG on macOS.
We need it running locally through emulation before the harness can observe it.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ LOCAL BANNERLORD ON MAC │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Bannerlord │ │ Emulator │ │ macOS Desktop │ │
│ │ (GOG) │───►│ Wine/Whisky/ │───►│ (the screen) │ │
│ │ │ │ CrossOver │ │ │ │
│ └──────────────┘ └──────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┤ │
│ │ Bannerlord Harness │ │
│ │ ┌────────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ capture_ │ │ execute_ │ │ bannerlord│ │ │
│ │ │ state() │ │ action() │ │ _local.py │ │ │
│ │ └────────────┘ └───────────┘ └───────────┘ │ │
│ │ │ ▲ │ │ │
│ │ ▼ │ ▼ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ MCP Servers (desktop-control) │ │ │
│ │ │ Screenshots + keyboard/mouse │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Hermes WebSocket │ │
│ │ Telemetry + ODA loop │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Components
| File | Purpose |
|------|---------|
| `scripts/bannerlord_launcher.sh` | Shell launcher — detects emulator + game, launches |
| `nexus/bannerlord_local.py` | Python module — programmatic readiness + launch control |
| `nexus/bannerlord_harness.py` | Existing harness — extended with `--local` and `--launch-local` |
| `portals.json` | Portal metadata — updated with `local_launch` block |
## Emulator Priority
1. **Whisky**`/Applications/Whisky.app` (preferred, best macOS integration)
2. **CrossOver**`/Applications/CrossOver.app` (good, paid)
3. **Homebrew Wine**`wine64` / `wine` on PATH (free, may need Rosetta on ARM)
## Quick Start
### Check Readiness
```bash
# Shell
./scripts/bannerlord_launcher.sh --check --verbose
# Python
python3 -m nexus.bannerlord_local --check --json
# Through harness
python3 -m nexus.bannerlord_harness --local --mock
```
### Launch Game
```bash
# Shell
./scripts/bannerlord_launcher.sh --launch
# Python
python3 -m nexus.bannerlord_local --launch --json
# Through harness (launches game, then runs ODA)
python3 -m nexus.bannerlord_harness --launch-local --mock
```
### Stop Game
```bash
python3 -m nexus.bannerlord_local --stop
```
## GOG Install Paths Searched
The launcher checks these paths in order:
1. `/Applications/Games/Mount & Blade II Bannerlord`
2. `~/GOG Games/Mount and Blade II Bannerlord`
3. `~/Games/Mount & Blade II Bannerlord`
4. `/Applications/Mount & Blade II Bannerlord`
5. `~/Library/Application Support/GOG.com/Galaxy/Applications/*/`
6. Recursive `find` as last resort
The game must have `bin/Generic/Bannerlord.exe` relative to the install root.
## Portal Metadata
The `portals.json` bannerlord entry now includes:
```json
"environment": "local",
"local_launch": {
"platform": "macos",
"source": "gog",
"emulator_required": true,
"emulator_options": ["whisky", "crossover", "wine"],
"launcher": "scripts/bannerlord_launcher.sh",
"harness_bridge": "nexus/bannerlord_local.py",
"check_command": "python3 -m nexus.bannerlord_local --check --json"
}
```
## Honest Status
| Component | Status |
|-----------|--------|
| Launcher script | Written, needs Mac testing |
| Python local module | Written, needs Mac testing |
| Harness integration | Added `--local`/`--launch-local` flags |
| Portal metadata | Updated |
| MCP observation of emulated window | Untested — depends on emulator window visibility |
| ODA loop with emulated game | Untested — needs game actually running |
## What Could Go Wrong
- **Emulator not installed:** User must install Whisky, CrossOver, or wine
- **Game not found:** User must install GOG Bannerlord to a known path
- **Performance:** Wine on Apple Silicon requires Rosetta + possible DXVK setup
- **Window title:** The emulated window may not match "Mount & Blade II: Bannerlord" — the harness may need to detect the actual window title
- **MCP desktop-control on macOS:** pyautogui on macOS needs Accessibility permissions
## Next Steps
1. Alexander runs `./scripts/bannerlord_launcher.sh --check --verbose` on his Mac
2. If missing emulator, install Whisky (`brew install --cask whisky`)
3. If missing game, install GOG Bannerlord
4. Run `--launch` to verify the game opens
5. Run `--launch-local --mock` to verify harness integration
6. Test MCP screenshots of the emulated window

View File

@@ -836,8 +836,43 @@ async def main():
default=1.0,
help="Delay between iterations in seconds (default: 1.0)",
)
parser.add_argument(
"--local",
action="store_true",
help="Check local macOS Bannerlord readiness before starting",
)
parser.add_argument(
"--launch-local",
action="store_true",
help="Launch local Bannerlord on macOS via emulator before ODA loop",
)
args = parser.parse_args()
# Handle local macOS Bannerlord
if args.local or args.launch_local:
try:
from nexus.bannerlord_local import (
check_local_readiness, launch_bannerlord, LocalStatus,
)
state = check_local_readiness()
log.info(f"Local check: {state.status.value}")
log.info(f" Emulator: {state.emulator.name or 'none'}")
log.info(f" Game: {state.game.game_dir or 'not found'}")
log.info(f" Message: {state.message}")
if args.launch_local:
if state.status == LocalStatus.READY:
state = launch_bannerlord(state)
log.info(f"Launch result: {state.status.value}{state.message}")
elif state.status == LocalStatus.RUNNING:
log.info(f"Already running (PID: {state.process_id})")
else:
log.error(f"Cannot launch: {state.message}")
return
except ImportError:
log.warning("bannerlord_local module not available — skipping local check")
# Create harness
harness = BannerlordHarness(
hermes_ws_url=args.hermes_ws,

394
nexus/bannerlord_local.py Normal file
View File

@@ -0,0 +1,394 @@
#!/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()

View File

@@ -133,9 +133,6 @@ const SpatialMemory = (() => {
let _regionMarkers = {};
let _memoryObjects = {};
let _connectionLines = [];
let _entityLines = []; // entity resolution lines (issue #1167)
let _camera = null; // set by setCamera() for LOD culling
const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint
let _initialized = false;
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
@@ -255,10 +252,6 @@ const SpatialMemory = (() => {
_drawConnections(mem.id, mem.connections);
}
if (mem.entity) {
_drawEntityLines(mem.id, mem);
}
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
@@ -305,77 +298,6 @@ const SpatialMemory = (() => {
});
}
// ─── ENTITY RESOLUTION LINES (#1167) ──────────────────
// Draw lines between crystals that share an entity or are related entities.
// Same entity → thin blue line. Related entities → thin purple dashed line.
function _drawEntityLines(memId, mem) {
if (!mem.entity) return;
const src = _memoryObjects[memId];
if (!src) return;
Object.entries(_memoryObjects).forEach(([otherId, other]) => {
if (otherId === memId) return;
const otherData = other.data;
if (!otherData.entity) return;
let lineType = null;
if (otherData.entity === mem.entity) {
lineType = 'same_entity';
} else if (mem.related_entities && mem.related_entities.includes(otherData.entity)) {
lineType = 'related';
} else if (otherData.related_entities && otherData.related_entities.includes(mem.entity)) {
lineType = 'related';
}
if (!lineType) return;
// Deduplicate — only draw from lower ID to higher
if (memId > otherId) return;
const points = [src.mesh.position.clone(), other.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
let mat;
if (lineType === 'same_entity') {
mat = new THREE.LineBasicMaterial({ color: 0x4488ff, transparent: true, opacity: 0.35 });
} else {
mat = new THREE.LineDashedMaterial({ color: 0x9966ff, dashSize: 0.3, gapSize: 0.2, transparent: true, opacity: 0.25 });
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
return;
}
const line = new THREE.Line(geo, mat);
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
});
}
function _updateEntityLines() {
if (!_camera) return;
const camPos = _camera.position;
_entityLines.forEach(line => {
// Compute midpoint of line
const posArr = line.geometry.attributes.position.array;
const mx = (posArr[0] + posArr[3]) / 2;
const my = (posArr[1] + posArr[4]) / 2;
const mz = (posArr[2] + posArr[5]) / 2;
const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz));
if (dist > ENTITY_LOD_DIST) {
line.visible = false;
} else {
line.visible = true;
// Fade based on distance
const fade = Math.max(0, 1 - (dist / ENTITY_LOD_DIST));
const baseOpacity = line.userData.lineType === 'same_entity' ? 0.35 : 0.25;
line.material.opacity = baseOpacity * fade;
}
});
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
@@ -395,16 +317,6 @@ const SpatialMemory = (() => {
}
}
for (let i = _entityLines.length - 1; i >= 0; i--) {
const line = _entityLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_entityLines.splice(i, 1);
}
}
delete _memoryObjects[memId];
_dirty = true;
saveToStorage();
@@ -430,8 +342,6 @@ const SpatialMemory = (() => {
}
});
_updateEntityLines();
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
@@ -742,11 +652,6 @@ const SpatialMemory = (() => {
return _selectedId;
}
// ─── CAMERA REFERENCE (for entity line LOD) ─────────
function setCamera(camera) {
_camera = camera;
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,

View File

@@ -23,13 +23,22 @@
"rotation": { "y": 0.5 },
"portal_type": "game-world",
"world_category": "strategy-rpg",
"environment": "production",
"environment": "local",
"access_mode": "operator",
"readiness_state": "active",
"telemetry_source": "hermes-harness:bannerlord",
"owner": "Timmy",
"app_id": 261550,
"window_title": "Mount & Blade II: Bannerlord",
"local_launch": {
"platform": "macos",
"source": "gog",
"emulator_required": true,
"emulator_options": ["whisky", "crossover", "wine"],
"launcher": "scripts/bannerlord_launcher.sh",
"harness_bridge": "nexus/bannerlord_local.py",
"check_command": "python3 -m nexus.bannerlord_local --check --json"
},
"destination": {
"url": "https://bannerlord.timmy.foundation",
"type": "harness",

223
scripts/bannerlord_launcher.sh Executable file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# Bannerlord Local Launcher for macOS
# Detects Wine/Whisky/CrossOver, finds GOG Bannerlord install, launches it.
#
# Usage:
# ./scripts/bannerlord_launcher.sh [--check] [--launch] [--verbose]
#
# Modes:
# --check Check environment only (no launch). Exits 0 if ready.
# --launch Launch the game (default if no flags)
# --verbose Print detailed diagnostic info
set -euo pipefail
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════
BANNERLORD_EXE="bin/Generic/Bannerlord.exe"
GOG_PATHS=(
"/Applications/Games/Mount & Blade II Bannerlord"
"$HOME/GOG Games/Mount and Blade II Bannerlord"
"$HOME/Games/Mount & Blade II Bannerlord"
"/Applications/Mount & Blade II Bannerlord"
)
# Also check common GOG Galaxy paths
GOG_GALAXY_PATHS=(
"$HOME/Library/Application Support/GOG.com/Galaxy/Applications/*/Mount & Blade II Bannerlord"
)
# Emulator priority: Whisky > CrossOver > Homebrew Wine > system wine
EMULATOR_NAMES=("Whisky" "CrossOver" "Wine" "wine64" "wine")
VERBOSE=0
CHECK_ONLY=0
LAUNCH=0
# ═══════════════════════════════════════════════════════════════════════════
# ARGUMENT PARSING
# ═══════════════════════════════════════════════════════════════════════════
for arg in "$@"; do
case "$arg" in
--check) CHECK_ONLY=1 ;;
--launch) LAUNCH=1 ;;
--verbose) VERBOSE=1 ;;
*) echo "Unknown arg: $arg"; exit 1 ;;
esac
done
if [ "$CHECK_ONLY" -eq 0 ] && [ "$LAUNCH" -eq 0 ]; then
LAUNCH=1 # Default to launch mode
fi
log() { echo "[bannerlord] $*"; }
vlog() { [ "$VERBOSE" -eq 1 ] && echo "[bannerlord:debug] $*" || true; }
# ═══════════════════════════════════════════════════════════════════════════
# EMULATOR DETECTION
# ═══════════════════════════════════════════════════════════════════════════
find_emulator() {
local emulator_path=""
local emulator_name=""
local emulator_type=""
# Check for Whisky (macOS Wine wrapper)
if [ -d "/Applications/Whisky.app" ]; then
emulator_path="/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
if [ -x "$emulator_path" ]; then
emulator_name="Whisky"
emulator_type="whisky"
fi
fi
# Check for CrossOver
if [ -z "$emulator_path" ] && [ -d "/Applications/CrossOver.app" ]; then
emulator_path="/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
if [ -x "$emulator_path" ]; then
emulator_name="CrossOver"
emulator_type="crossover"
fi
fi
# Check for Homebrew wine
if [ -z "$emulator_path" ]; then
for candidate in wine64 wine; do
if command -v "$candidate" >/dev/null 2>&1; then
emulator_path="$(command -v "$candidate")"
emulator_name="$candidate"
emulator_type="wine"
break
fi
done
fi
if [ -n "$emulator_path" ]; then
EMULATOR_PATH="$emulator_path"
EMULATOR_NAME="$emulator_name"
EMULATOR_TYPE="$emulator_type"
return 0
fi
return 1
}
# ═══════════════════════════════════════════════════════════════════════════
# GAME DETECTION
# ═══════════════════════════════════════════════════════════════════════════
find_bannerlord() {
# Check standard GOG paths
for path in "${GOG_PATHS[@]}"; do
if [ -f "$path/$BANNERLORD_EXE" ]; then
GAME_DIR="$path"
GAME_EXE="$path/$BANNERLORD_EXE"
return 0
fi
done
# Check GOG Galaxy paths (glob expansion)
for pattern in "${GOG_GALAXY_PATHS[@]}"; do
# shellcheck disable=SC2086
for path in $pattern; do
if [ -d "$path" ] && [ -f "$path/$BANNERLORD_EXE" ]; then
GAME_DIR="$path"
GAME_EXE="$path/$BANNERLORD_EXE"
return 0
fi
done
done
# Search with find as last resort
local found
found=$(find /Applications "$HOME/GOG Games" "$HOME/Games" -name "Bannerlord.exe" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
GAME_EXE="$found"
GAME_DIR="$(dirname "$(dirname "$found")")"
return 0
fi
return 1
}
# ═══════════════════════════════════════════════════════════════════════════
# STATUS REPORTING
# ═══════════════════════════════════════════════════════════════════════════
emit_status() {
local status="$1"
local message="$2"
# JSON output for harness consumption
echo "{\"status\":\"$status\",\"emulator\":\"${EMULATOR_NAME:-none}\",\"emulator_type\":\"${EMULATOR_TYPE:-none}\",\"game_dir\":\"${GAME_DIR:-}\",\"game_exe\":\"${GAME_EXE:-}\",\"message\":\"$message\"}"
}
# ═══════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════
main() {
# Verify macOS
if [ "$(uname)" != "Darwin" ]; then
emit_status "error" "Not macOS — this launcher is Mac-only"
exit 1
fi
log "Bannerlord Local Launcher — macOS"
# Find emulator
if find_emulator; then
log "Emulator found: $EMULATOR_NAME ($EMULATOR_PATH)"
vlog " Type: $EMULATOR_TYPE"
else
log "ERROR: No Windows emulator found."
log "Install one of: Whisky, CrossOver, or wine (brew install --cask wine-stable)"
emit_status "missing_emulator" "No Windows emulator installed"
exit 1
fi
# Find game
if find_bannerlord; then
log "Bannerlord found: $GAME_DIR"
vlog " Exe: $GAME_EXE"
else
log "ERROR: Bannerlord not found in known GOG paths."
log "Checked: ${GOG_PATHS[*]}"
emit_status "missing_game" "Bannerlord GOG installation not found"
exit 1
fi
# Check mode
if [ "$CHECK_ONLY" -eq 1 ]; then
log "Check passed. Ready to launch."
emit_status "ready" "Emulator and game both found"
exit 0
fi
# Launch
if [ "$LAUNCH" -eq 1 ]; then
log "Launching Bannerlord via $EMULATOR_NAME..."
emit_status "launching" "Starting Bannerlord through $EMULATOR_NAME"
cd "$GAME_DIR"
# Launch in background, redirect output
"$EMULATOR_PATH" "$GAME_EXE" "$@" >/dev/null 2>&1 &
local pid=$!
log "Bannerlord started (PID: $pid)"
echo "$pid" > /tmp/bannerlord.pid
# Wait a moment and check it's still running
sleep 2
if kill -0 "$pid" 2>/dev/null; then
log "Bannerlord is running."
emit_status "running" "Bannerlord PID $pid"
exit 0
else
log "WARNING: Bannerlord process exited quickly. Check Wine logs."
emit_status "crashed" "Process exited within 2 seconds"
exit 1
fi
fi
}
main "$@"