diff --git a/docs/BANNERLORD_LOCAL_MAC.md b/docs/BANNERLORD_LOCAL_MAC.md new file mode 100644 index 000000000..56ab20ee5 --- /dev/null +++ b/docs/BANNERLORD_LOCAL_MAC.md @@ -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 diff --git a/nexus/bannerlord_harness.py b/nexus/bannerlord_harness.py index c748059ff..f19d9331b 100644 --- a/nexus/bannerlord_harness.py +++ b/nexus/bannerlord_harness.py @@ -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, diff --git a/nexus/bannerlord_local.py b/nexus/bannerlord_local.py new file mode 100644 index 000000000..f7d197157 --- /dev/null +++ b/nexus/bannerlord_local.py @@ -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() diff --git a/portals.json b/portals.json index 6b7e28706..60b6cc063 100644 --- a/portals.json +++ b/portals.json @@ -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", diff --git a/scripts/bannerlord_launcher.sh b/scripts/bannerlord_launcher.sh new file mode 100755 index 000000000..a73b97231 --- /dev/null +++ b/scripts/bannerlord_launcher.sh @@ -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 "$@"