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 204 deletions

View File

@@ -1,203 +0,0 @@
# FINDINGS: MemPalace Local AI Memory System Assessment & Leverage Plan
**Issue:** #1047
**Date:** 2026-04-10
**Investigator:** mimo-v2-pro (swarm researcher)
---
## 1. What Issue #1047 Claims
The issue (authored by Bezalel, dated 2026-04-07) describes MemPalace as:
- An open-source local-first AI memory system with highest published LongMemEval scores (96.6% R@5)
- A Python CLI + MCP server using ChromaDB + SQLite with a "palace" hierarchy metaphor
- AAAK compression dialect for ~30x context compression
- 19 MCP tools for agent memory
It recommends that every wizard clone/vendor MemPalace, configure rooms, mine workspace, and wire the searcher into heartbeats.
## 2. What Actually Exists in the Codebase (Current State)
The Nexus repo already contains **substantial MemPalace integration** that goes well beyond the original research proposal. Here is the full inventory:
### 2.1 Core Python Layer — `nexus/mempalace/` (3 files, ~290 lines)
| File | Purpose |
|------|---------|
| `config.py` | Environment-driven config: palace paths, fleet path, wing name, core rooms, collection name |
| `searcher.py` | ChromaDB-backed search/write API with `search_memories()`, `search_fleet()`, `add_memory()` |
| `__init__.py` | Package marker |
**Status:** Functional. Clean API. Lazy ChromaDB import with graceful `MemPalaceUnavailable` exception.
### 2.2 Fleet Management Tools — `mempalace/` (8 files, ~800 lines)
| File | Purpose |
|------|---------|
| `rooms.yaml` | Fleet-wide room taxonomy standard (5 core rooms + optional rooms) |
| `validate_rooms.py` | Validates wizard `mempalace.yaml` against fleet standard |
| `audit_privacy.py` | Scans fleet palace for policy violations (raw drawers, oversized closets, private paths) |
| `retain_closets.py` | 90-day retention enforcement for closet aging |
| `export_closets.sh` | Privacy-safe closet export for rsync to Alpha fleet palace |
| `fleet_api.py` | HTTP API for shared fleet palace (search, record, wings) |
| `tunnel_sync.py` | Pull closets from remote wizard's fleet API into local palace |
| `__init__.py` | Package marker |
**Status:** Well-structured. Each tool has clear CLI interface and proper error handling.
### 2.3 Evennia MUD Integration — `nexus/evennia_mempalace/` (6 files, ~580 lines)
| File | Purpose |
|------|---------|
| `commands/recall.py` | `CmdRecall` (semantic search), `CmdEnterRoom` (teleport), `CmdAsk` (NPC query) |
| `commands/write.py` | `CmdRecord`, `CmdNote`, `CmdEvent` (memory writing commands) |
| `typeclasses/rooms.py` | `MemPalaceRoom` typeclass |
| `typeclasses/npcs.py` | `StewardNPC` with question-answering via palace search |
**Status:** Complete. Evennia stub fallback for testing outside live environment.
### 2.4 3D Visualization — `nexus/components/spatial-memory.js` (~665 lines)
Maps memory categories to spatial regions in the Nexus Three.js world:
- Inner ring: Documents, Projects, Code, Conversations, Working Memory, Archive
- Outer ring (MemPalace zones, issue #1168): User Preferences, Project Facts, Tool Knowledge, General Facts
- Crystal geometry with deterministic positioning, connection lines, localStorage persistence
**Status:** Functional 3D visualization with region markers, memory crystals, and animation.
### 2.5 Frontend Integration — `mempalace.js` (~44 lines)
Basic Electron/browser integration class that:
- Initializes a palace wing
- Auto-mines chat content every 30 seconds
- Exposes `search()` method
- Updates stats display
**Status:** Minimal but functional as a bridge between browser UI and CLI mempalace.
### 2.6 Scripts & Automation — `scripts/` (5 files)
| File | Purpose |
|------|---------|
| `mempalace-incremental-mine.sh` | Re-mines only changed files since last run |
| `mempalace_nightly.sh` | Nightly maintenance |
| `mempalace_export.py` | Export utility |
| `validate_mempalace_taxonomy.py` | Taxonomy validation script |
| `audit_mempalace_privacy.py` | Privacy audit script |
| `sync_fleet_to_alpha.sh` | Fleet sync to Alpha server |
### 2.7 Tests — `tests/` (7 test files)
| File | Tests |
|------|-------|
| `test_mempalace_searcher.py` | Searcher API, config |
| `test_mempalace_validate_rooms.py` | Room taxonomy validation |
| `test_mempalace_retain_closets.py` | Closet retention |
| `test_mempalace_audit_privacy.py` | Privacy auditor |
| `test_mempalace_fleet_api.py` | Fleet HTTP API |
| `test_mempalace_tunnel_sync.py` | Remote wizard sync |
| `test_evennia_mempalace_commands.py` | Evennia commands + NPC helpers |
### 2.8 CI/CD
- **ci.yml**: Validates palace taxonomy on every PR, plus Python/JSON/YAML syntax checks
- **weekly-audit.yml**: Monday 05:00 UTC — runs privacy audit + dry-run retention against test fixtures
### 2.9 Documentation
- `docs/mempalace_taxonomy.yaml` — Full taxonomy standard (145 lines)
- `docs/mempalace/rooms.yaml` — Rooms documentation
- `docs/mempalace/bezalel_example.yaml` — Example wizard config
- `docs/bezalel/evennia/` — Evennia integration examples (steward NPC, palace commands)
- `reports/bezalel/2026-04-07-mempalace-field-report.md` — Original field report
## 3. Gap Analysis: Issue #1047 vs. Reality
| Issue #1047 Proposes | Current State | Gap |
|---------------------|---------------|-----|
| "Each wizard should clone/vendor it" | Vendor infrastructure exists (`scripts/mempalace-incremental-mine.sh`) | **DONE** |
| "Write a mempalace.yaml" | Fleet taxonomy standard + validator exist | **DONE** |
| "Run mempalace mine" | Incremental mining script exists | **DONE** |
| "Wire searcher into heartbeat scripts" | `nexus/mempalace/searcher.py` provides API | **DONE** (needs adoption verification) |
| AAAK compression | Not implemented in repo | **OPEN** — no AAAK dialect code |
| MCP server (19 tools) | No MCP server integration | **OPEN** — no MCP tool definitions |
| Benchmark validation | No LongMemEval test harness in repo | **OPEN** — claims unverified locally |
| Fleet-wide adoption | Only Bezalel field report exists | **OPEN** — no evidence of Timmy/Allegro/Ezra adoption |
| Hermes harness integration | No direct harness/memory-tool bridge | **OPEN** — searcher exists but no harness wiring |
## 4. What's Actually Broken
### 4.1 No AAAK Implementation
The issue describes AAAK (~30x compression, ~170 tokens wake-up context) as a key feature, but there is zero AAAK code in the repo. The `nexus/mempalace/` layer has no compression functions. This is a missing feature, not a bug.
### 4.2 No MCP Server Bridge
The upstream MemPalace offers 19 MCP tools, but the Nexus integration only exposes the ChromaDB Python API. There is no MCP server definition, no tool registration for the harness, and no bridge to the `mcp_config.json` at repo root.
### 4.3 Fleet Adoption Gap
Only Bezalel has a documented field report (#1072). There is no evidence that Timmy, Allegro, or Ezra have populated palaces, configured room taxonomies, or run incremental mining. The `export_closets.sh` script hardcodes Bezalel paths.
### 4.4 Frontend Integration Stale
`mempalace.js` references `window.electronAPI.execPython()` which only works in the Electron shell. The main `app.js` (Three.js world) does not import or use `mempalace.js`. The `spatial-memory.js` component defines MemPalace zones but has no data pipeline to populate them from actual palace data.
### 4.5 Upstream Quality Concern
Bezalel's field report notes the upstream repo is "astroturfed hype" — 13.4k LOC in a single commit, 5,769 GitHub stars in 48 hours, ~125 lines of tests. The code is not malicious but is not production-grade. The Nexus has effectively forked/vendored the useful parts and rewritten the critical integration layers.
## 5. What's Working Well
1. **Clean architecture separation**`nexus/mempalace/` is a proper Python package with config/searcher separation. Testable without ChromaDB installed.
2. **Privacy-first fleet design** — closet-only export policy, privacy auditor, retention enforcement, and private path detection are solid operational safeguards.
3. **Taxonomy standardization**`rooms.yaml` + validator ensures consistent memory structure across wizards.
4. **CI integration** — Taxonomy validation in PR checks + weekly privacy audit cron are good DevOps practices.
5. **Evennia integration** — The MUD commands (recall, enter room, ask steward) are well-designed and testable outside Evennia via stubs.
6. **Spatial visualization**`spatial-memory.js` is a creative 3D representation with deterministic positioning and category zones.
## 6. Recommended Actions
### Priority 1: Fleet Adoption Verification (effort: small)
- Confirm each wizard (Timmy, Allegro, Ezra) has run `mempalace mine` and has a populated palace
- Verify `mempalace.yaml` exists on each wizard's VPS
- Update `export_closets.sh` to not hardcode Bezalel paths (use env vars)
### Priority 2: Hermes Harness Bridge (effort: medium)
- Wire `nexus/mempalace/searcher.py` into the Hermes harness as a memory tool
- Add memory search/recall to the agent loop so wizards get cross-session context automatically
- Map MemPalace search to the existing `memory`/`fact_store` tools or add a dedicated `palace_search` tool
### Priority 3: MCP Server Registration (effort: medium)
- Create an MCP server that exposes search, write, and status tools
- Register in `mcp_config.json`
- Enable any harness agent to use MemPalace without Python imports
### Priority 4: AAAK Compression (effort: large, optional)
- Implement or port the AAAK compression dialect
- Generate wake-up context summaries from palace data
- This is a nice-to-have, not critical — the raw ChromaDB search is functional
### Priority 5: 3D Pipeline Bridge (effort: medium)
- Connect `spatial-memory.js` to live palace data via WebSocket or REST
- Populate memory crystals from actual search results
- Visual feedback when new memories are added
## 7. Effort Summary
| Action | Effort | Impact |
|--------|--------|--------|
| Fleet adoption verification | 2-4 hours | High — ensures all wizards have memory |
| Hermes harness bridge | 1-2 days | High — automatic cross-session context |
| MCP server registration | 1 day | Medium — enables any agent to use palace |
| AAAK compression | 2-3 days | Low — nice-to-have |
| 3D pipeline bridge | 1-2 days | Medium — visual representation of memory |
| Fix export_closets.sh hardcoded paths | 30 min | Low — operational hygiene |
## 8. Conclusion
Issue #1047 was a research request from 2026-04-07. Since then, significant implementation work has been completed — far exceeding the original proposal. The core memory infrastructure (searcher, fleet tools, privacy, taxonomy, Evennia integration, tests, CI) is **built and functional**.
The primary remaining gap is **fleet-wide adoption** (only Bezalel has documented use) and **harness integration** (the searcher exists but isn't wired into the agent loop). The AAAK and MCP features from the original research are not implemented but are not blocking — the ChromaDB-backed search provides the core value proposition.
**Verdict:** The MemPalace integration is substantially complete at the infrastructure level. The next bottleneck is operational adoption and harness wiring, not new feature development.

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

@@ -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 "$@"