Compare commits
1 Commits
mimo/creat
...
mimo/code/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73bc86d3a2 |
Binary file not shown.
@@ -60,23 +60,6 @@ If the heartbeat is older than --stale-threshold seconds, the
|
|||||||
mind is considered dead even if the process is still running
|
mind is considered dead even if the process is still running
|
||||||
(e.g., hung on a blocking call).
|
(e.g., hung on a blocking call).
|
||||||
|
|
||||||
KIMI HEARTBEAT
|
|
||||||
==============
|
|
||||||
The Kimi triage pipeline writes a cron heartbeat file after each run:
|
|
||||||
|
|
||||||
/var/run/bezalel/heartbeats/kimi-heartbeat.last
|
|
||||||
(fallback: ~/.bezalel/heartbeats/kimi-heartbeat.last)
|
|
||||||
{
|
|
||||||
"job": "kimi-heartbeat",
|
|
||||||
"timestamp": 1711843200.0,
|
|
||||||
"interval_seconds": 900,
|
|
||||||
"pid": 12345,
|
|
||||||
"status": "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
If the heartbeat is stale (>2x declared interval), the watchdog reports
|
|
||||||
a Kimi Heartbeat failure alongside the other checks.
|
|
||||||
|
|
||||||
ZERO DEPENDENCIES
|
ZERO DEPENDENCIES
|
||||||
=================
|
=================
|
||||||
Pure stdlib. No pip installs. Same machine as the nexus.
|
Pure stdlib. No pip installs. Same machine as the nexus.
|
||||||
@@ -121,10 +104,6 @@ DEFAULT_HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
|
|||||||
DEFAULT_STALE_THRESHOLD = 300 # 5 minutes without a heartbeat = dead
|
DEFAULT_STALE_THRESHOLD = 300 # 5 minutes without a heartbeat = dead
|
||||||
DEFAULT_INTERVAL = 60 # seconds between checks in watch mode
|
DEFAULT_INTERVAL = 60 # seconds between checks in watch mode
|
||||||
|
|
||||||
# Kimi Heartbeat — cron job heartbeat file written by the triage pipeline
|
|
||||||
KIMI_HEARTBEAT_JOB = "kimi-heartbeat"
|
|
||||||
KIMI_HEARTBEAT_STALE_MULTIPLIER = 2.0 # stale at 2x declared interval
|
|
||||||
|
|
||||||
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
GITEA_REPO = os.environ.get("NEXUS_REPO", "Timmy_Foundation/the-nexus")
|
GITEA_REPO = os.environ.get("NEXUS_REPO", "Timmy_Foundation/the-nexus")
|
||||||
@@ -366,93 +345,6 @@ def check_syntax_health() -> CheckResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_kimi_heartbeat(
|
|
||||||
job: str = KIMI_HEARTBEAT_JOB,
|
|
||||||
stale_multiplier: float = KIMI_HEARTBEAT_STALE_MULTIPLIER,
|
|
||||||
) -> CheckResult:
|
|
||||||
"""Check if the Kimi Heartbeat cron job is alive.
|
|
||||||
|
|
||||||
Reads the ``<job>.last`` file from the standard Bezalel heartbeat
|
|
||||||
directory (``/var/run/bezalel/heartbeats/`` or fallback
|
|
||||||
``~/.bezalel/heartbeats/``). The file is written atomically by the
|
|
||||||
cron_heartbeat module after each successful triage pipeline run.
|
|
||||||
|
|
||||||
A job is stale when:
|
|
||||||
``time.time() - timestamp > stale_multiplier * interval_seconds``
|
|
||||||
(same rule used by ``check_cron_heartbeats.py``).
|
|
||||||
"""
|
|
||||||
# Resolve heartbeat directory — same logic as cron_heartbeat._resolve
|
|
||||||
primary = Path("/var/run/bezalel/heartbeats")
|
|
||||||
fallback = Path.home() / ".bezalel" / "heartbeats"
|
|
||||||
env_dir = os.environ.get("BEZALEL_HEARTBEAT_DIR")
|
|
||||||
if env_dir:
|
|
||||||
hb_dir = Path(env_dir)
|
|
||||||
elif primary.exists():
|
|
||||||
hb_dir = primary
|
|
||||||
elif fallback.exists():
|
|
||||||
hb_dir = fallback
|
|
||||||
else:
|
|
||||||
return CheckResult(
|
|
||||||
name="Kimi Heartbeat",
|
|
||||||
healthy=False,
|
|
||||||
message="Heartbeat directory not found — no triage pipeline deployed yet",
|
|
||||||
details={"searched": [str(primary), str(fallback)]},
|
|
||||||
)
|
|
||||||
|
|
||||||
hb_file = hb_dir / f"{job}.last"
|
|
||||||
if not hb_file.exists():
|
|
||||||
return CheckResult(
|
|
||||||
name="Kimi Heartbeat",
|
|
||||||
healthy=False,
|
|
||||||
message=f"No heartbeat file at {hb_file} — Kimi triage pipeline has never reported",
|
|
||||||
details={"path": str(hb_file)},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(hb_file.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
return CheckResult(
|
|
||||||
name="Kimi Heartbeat",
|
|
||||||
healthy=False,
|
|
||||||
message=f"Heartbeat file corrupt: {e}",
|
|
||||||
details={"path": str(hb_file), "error": str(e)},
|
|
||||||
)
|
|
||||||
|
|
||||||
timestamp = float(data.get("timestamp", 0))
|
|
||||||
interval = int(data.get("interval_seconds", 0))
|
|
||||||
raw_status = data.get("status", "unknown")
|
|
||||||
age = time.time() - timestamp
|
|
||||||
|
|
||||||
if interval <= 0:
|
|
||||||
# No declared interval — use raw timestamp age (30 min default)
|
|
||||||
interval = 1800
|
|
||||||
|
|
||||||
threshold = stale_multiplier * interval
|
|
||||||
is_stale = age > threshold
|
|
||||||
|
|
||||||
age_str = f"{int(age)}s" if age < 3600 else f"{int(age // 3600)}h {int((age % 3600) // 60)}m"
|
|
||||||
interval_str = f"{int(interval)}s" if interval < 3600 else f"{int(interval // 3600)}h {int((interval % 3600) // 60)}m"
|
|
||||||
|
|
||||||
if is_stale:
|
|
||||||
return CheckResult(
|
|
||||||
name="Kimi Heartbeat",
|
|
||||||
healthy=False,
|
|
||||||
message=(
|
|
||||||
f"Silent for {age_str} "
|
|
||||||
f"(threshold: {stale_multiplier}x {interval_str} = {int(threshold)}s). "
|
|
||||||
f"Status: {raw_status}"
|
|
||||||
),
|
|
||||||
details=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
return CheckResult(
|
|
||||||
name="Kimi Heartbeat",
|
|
||||||
healthy=True,
|
|
||||||
message=f"Alive — last beat {age_str} ago (interval {interval_str}, status={raw_status})",
|
|
||||||
details=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Gitea alerting ───────────────────────────────────────────────────
|
# ── Gitea alerting ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _gitea_request(method: str, path: str, data: Optional[dict] = None) -> Any:
|
def _gitea_request(method: str, path: str, data: Optional[dict] = None) -> Any:
|
||||||
@@ -554,7 +446,6 @@ def run_health_checks(
|
|||||||
check_mind_process(),
|
check_mind_process(),
|
||||||
check_heartbeat(heartbeat_path, stale_threshold),
|
check_heartbeat(heartbeat_path, stale_threshold),
|
||||||
check_syntax_health(),
|
check_syntax_health(),
|
||||||
check_kimi_heartbeat(),
|
|
||||||
]
|
]
|
||||||
return HealthReport(timestamp=time.time(), checks=checks)
|
return HealthReport(timestamp=time.time(), checks=checks)
|
||||||
|
|
||||||
@@ -654,14 +545,6 @@ def main():
|
|||||||
"--json", action="store_true", dest="output_json",
|
"--json", action="store_true", dest="output_json",
|
||||||
help="Output results as JSON (for integration with other tools)",
|
help="Output results as JSON (for integration with other tools)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--kimi-job", default=KIMI_HEARTBEAT_JOB,
|
|
||||||
help=f"Kimi heartbeat job name (default: {KIMI_HEARTBEAT_JOB})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--kimi-stale-multiplier", type=float, default=KIMI_HEARTBEAT_STALE_MULTIPLIER,
|
|
||||||
help=f"Kimi heartbeat staleness multiplier (default: {KIMI_HEARTBEAT_STALE_MULTIPLIER})",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
151
docs/BANNERLORD_LOCAL_MAC.md
Normal file
151
docs/BANNERLORD_LOCAL_MAC.md
Normal 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
|
||||||
@@ -836,8 +836,43 @@ async def main():
|
|||||||
default=1.0,
|
default=1.0,
|
||||||
help="Delay between iterations in seconds (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()
|
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
|
# Create harness
|
||||||
harness = BannerlordHarness(
|
harness = BannerlordHarness(
|
||||||
hermes_ws_url=args.hermes_ws,
|
hermes_ws_url=args.hermes_ws,
|
||||||
|
|||||||
394
nexus/bannerlord_local.py
Normal file
394
nexus/bannerlord_local.py
Normal 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()
|
||||||
11
portals.json
11
portals.json
@@ -23,13 +23,22 @@
|
|||||||
"rotation": { "y": 0.5 },
|
"rotation": { "y": 0.5 },
|
||||||
"portal_type": "game-world",
|
"portal_type": "game-world",
|
||||||
"world_category": "strategy-rpg",
|
"world_category": "strategy-rpg",
|
||||||
"environment": "production",
|
"environment": "local",
|
||||||
"access_mode": "operator",
|
"access_mode": "operator",
|
||||||
"readiness_state": "active",
|
"readiness_state": "active",
|
||||||
"telemetry_source": "hermes-harness:bannerlord",
|
"telemetry_source": "hermes-harness:bannerlord",
|
||||||
"owner": "Timmy",
|
"owner": "Timmy",
|
||||||
"app_id": 261550,
|
"app_id": 261550,
|
||||||
"window_title": "Mount & Blade II: Bannerlord",
|
"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": {
|
"destination": {
|
||||||
"url": "https://bannerlord.timmy.foundation",
|
"url": "https://bannerlord.timmy.foundation",
|
||||||
"type": "harness",
|
"type": "harness",
|
||||||
|
|||||||
223
scripts/bannerlord_launcher.sh
Executable file
223
scripts/bannerlord_launcher.sh
Executable 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 "$@"
|
||||||
Reference in New Issue
Block a user