initial: sovereign home — morrowind agent, skills, training-data, research, specs, notes, operational docs

Tracked: morrowind agent (py/cfg), skills/, training-data/, research/,
notes/, specs/, test-results/, metrics/, heartbeat/, briefings/,
memories/, skins/, hooks/, decisions.md, OPERATIONS.md, SOUL.md

Excluded: screenshots, PNGs, binaries, sessions, databases, secrets,
audio cache, timmy-config/ and timmy-telemetry/ (separate repos)
This commit is contained in:
Alexander Whitestone
2026-03-27 13:05:57 -04:00
commit 0d64d8e559
2393 changed files with 178606 additions and 0 deletions

188
morrowind/agent.py Normal file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Timmy's Morrowind Agent — Gameplay loop with perception and action.
Uses:
- Quartz screenshots for visual perception
- macOS Vision OCR for text reading (limited with Morrowind fonts)
- OpenMW console commands (via ` key) for game state queries
- pynput for keyboard/mouse input
- OpenMW log file for event tracking
The agent runs a perceive → think → act loop.
"""
import sys, time, subprocess, shutil, os, re
sys.path.insert(0, '/Users/apayne/.timmy/morrowind')
from play import *
from pynput.keyboard import Key, KeyCode
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
SCREENSHOT_DIR = os.path.expanduser("~/.timmy/morrowind/screenshots")
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
frame = 0
def focus_game():
subprocess.run(["osascript", "-e",
'tell application "System Events" to set frontmost of process "openmw" to true'],
capture_output=True)
time.sleep(0.5)
click(3456 // 4, 2234 // 4)
time.sleep(0.2)
def read_log_tail(n=20):
"""Read last N lines of OpenMW log."""
try:
with open(OPENMW_LOG, 'r') as f:
lines = f.readlines()
return [l.strip() for l in lines[-n:]]
except:
return []
def get_location_from_log():
"""Parse the most recent cell loading from the log."""
lines = read_log_tail(50)
cells = []
for line in reversed(lines):
m = re.search(r'Loading cell (.+?)(?:\s*\(|$)', line)
if m:
cells.append(m.group(1).strip())
return cells[0] if cells else "unknown"
def open_console():
"""Open the OpenMW console with ` key."""
press_key(KeyCode.from_char('`'))
time.sleep(0.3)
def close_console():
"""Close the console."""
press_key(KeyCode.from_char('`'))
time.sleep(0.3)
def console_command(cmd):
"""Type a command in the console and read the response."""
open_console()
time.sleep(0.2)
type_text(cmd)
time.sleep(0.1)
press_key(Key.enter)
time.sleep(0.5)
# Screenshot to read console output
result = screenshot()
texts = ocr()
close_console()
return " | ".join(t["text"] for t in texts)
def perceive():
"""Gather all available information about the game state."""
global frame
frame += 1
# Screenshot
path_info = screenshot()
if path_info:
shutil.copy('/tmp/morrowind_screen.png',
f'{SCREENSHOT_DIR}/frame_{frame:04d}.png')
# Log-based perception
location = get_location_from_log()
log_lines = read_log_tail(10)
# OCR (limited but sometimes useful)
texts = ocr()
ocr_text = " | ".join(t["text"] for t in texts[:10] if t["confidence"] > 0.3)
return {
"frame": frame,
"location": location,
"ocr": ocr_text,
"log": log_lines[-5:],
"screenshot": f'{SCREENSHOT_DIR}/frame_{frame:04d}.png',
}
def act_explore():
"""Basic exploration behavior: walk forward, look around, interact."""
# Walk forward
walk_forward(2.0 + (frame % 3))
# Occasionally look around
if frame % 3 == 0:
look_around(30 + (frame % 60) - 30)
# Occasionally try to interact
if frame % 5 == 0:
use()
time.sleep(0.5)
def act_wander():
"""Random wandering with more variety."""
import random
action = random.choice(['forward', 'forward', 'forward', 'turn_left', 'turn_right', 'look_up', 'interact'])
if action == 'forward':
walk_forward(random.uniform(1.0, 4.0))
elif action == 'turn_left':
look_around(random.randint(-90, -20))
time.sleep(0.2)
walk_forward(random.uniform(1.0, 2.0))
elif action == 'turn_right':
look_around(random.randint(20, 90))
time.sleep(0.2)
walk_forward(random.uniform(1.0, 2.0))
elif action == 'look_up':
look_up(random.randint(10, 30))
time.sleep(0.5)
look_down(random.randint(10, 30))
elif action == 'interact':
use()
time.sleep(1.0)
# ═══════════════════════════════════════
# MAIN LOOP
# ═══════════════════════════════════════
if __name__ == "__main__":
print("=== Timmy's Morrowind Agent ===")
print("Focusing game...")
focus_game()
time.sleep(1)
# Dismiss any menus
press_key(Key.esc)
time.sleep(0.3)
press_key(Key.esc)
time.sleep(0.3)
click(3456 // 4, 2234 // 4)
time.sleep(0.3)
print("Starting gameplay loop...")
NUM_CYCLES = 15
for i in range(NUM_CYCLES):
print(f"\n--- Cycle {i+1}/{NUM_CYCLES} ---")
# Perceive
state = perceive()
print(f" Location: {state['location']}")
print(f" OCR: {state['ocr'][:100]}")
# Act
act_wander()
# Brief pause between cycles
time.sleep(0.5)
# Final perception
print("\n=== Final State ===")
state = perceive()
print(f"Location: {state['location']}")
print(f"Frames captured: {frame}")
print(f"Screenshots in: {SCREENSHOT_DIR}")
# Quicksave
print("Quicksaving...")
from pynput.keyboard import Key
press_key(Key.f5)
time.sleep(1)
print("Done.")

166
morrowind/console.py Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Timmy's Morrowind Console Bridge.
Sends Lua commands to OpenMW via the in-game console.
Takes screenshots and OCRs them for perception.
"""
import subprocess, time, os, shutil
import Quartz, CoreFoundation
SESSION_DIR = os.path.expanduser(f"~/.timmy/morrowind/session_{time.strftime('%Y%m%d_%H%M')}")
os.makedirs(SESSION_DIR, exist_ok=True)
frame_count = 0
def send_keys_to_openmw(keycode):
"""Send a single key code to OpenMW via System Events."""
subprocess.run(['osascript', '-e', f'''
tell application "System Events"
tell process "openmw"
key code {keycode}
end tell
end tell
'''], capture_output=True, timeout=3)
def send_char_to_openmw(char):
"""Send a character keystroke to OpenMW."""
subprocess.run(['osascript', '-e', f'''
tell application "System Events"
tell process "openmw"
keystroke "{char}"
end tell
end tell
'''], capture_output=True, timeout=3)
def send_text_to_openmw(text):
"""Type a string into OpenMW."""
# Escape special chars for AppleScript
escaped = text.replace('\\', '\\\\').replace('"', '\\"')
subprocess.run(['osascript', '-e', f'''
tell application "System Events"
tell process "openmw"
keystroke "{escaped}"
end tell
end tell
'''], capture_output=True, timeout=5)
# Key codes
BACKTICK = 50 # ` to open/close console
RETURN = 36
ESCAPE = 53
SPACE = 49
def open_console():
send_keys_to_openmw(BACKTICK)
time.sleep(0.4)
def close_console():
send_keys_to_openmw(BACKTICK)
time.sleep(0.3)
def console_command(cmd):
"""Open console, type a command, execute, close console."""
open_console()
time.sleep(0.3)
send_text_to_openmw(cmd)
time.sleep(0.2)
send_keys_to_openmw(RETURN)
time.sleep(0.3)
close_console()
time.sleep(0.2)
def lua_player(code):
"""Run Lua code in player context."""
console_command(f"luap {code}")
def lua_global(code):
"""Run Lua code in global context."""
console_command(f"luag {code}")
def timmy_event(event_name, data_str="{}"):
"""Send a Timmy event via global script."""
lua_global(f'core.sendGlobalEvent("{event_name}", {data_str})')
def screenshot(label=""):
"""Take a screenshot and save it to the session directory."""
global frame_count
frame_count += 1
image = Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())
if not image:
return None
fname = f"frame_{frame_count:04d}"
if label:
fname += f"_{label}"
fname += ".png"
path = os.path.join(SESSION_DIR, fname)
url = CoreFoundation.CFURLCreateWithFileSystemPath(None, path, 0, False)
dest = Quartz.CGImageDestinationCreateWithURL(url, 'public.png', 1, None)
Quartz.CGImageDestinationAddImage(dest, image, None)
Quartz.CGImageDestinationFinalize(dest)
return path
def ocr_screenshot(path):
"""OCR a screenshot using macOS Vision."""
from Foundation import NSURL
from Quartz import CIImage
import Vision
url = NSURL.fileURLWithPath_(path)
ci = CIImage.imageWithContentsOfURL_(url)
if not ci:
return []
req = Vision.VNRecognizeTextRequest.alloc().init()
req.setRecognitionLevel_(1)
handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(ci, None)
handler.performRequests_error_([req], None)
return [r.text() for r in (req.results() or [])]
def read_log(n=10):
"""Read last N lines of OpenMW log."""
log_path = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
with open(log_path) as f:
return f.readlines()[-n:]
# ═══════════════════════════════════════
# HIGH-LEVEL ACTIONS
# ═══════════════════════════════════════
def walk_forward(duration=3, run=False):
timmy_event("TimmyWalk", f'{{duration={duration}, run={str(run).lower()}}}')
def stop():
timmy_event("TimmyStop")
def turn(radians=0.5):
timmy_event("TimmyTurn", f'{{angle={radians}}}')
def jump():
timmy_event("TimmyJump")
def attack(attack_type="any"):
timmy_event("TimmyAttack", f'{{type="{attack_type}"}}')
def explore():
timmy_event("TimmyExplore")
def walk_to(x, y, z):
timmy_event("TimmyWalkTo", f'{{x={x}, y={y}, z={z}}}')
def perceive():
timmy_event("TimmyPerceive")
def press_space():
send_keys_to_openmw(SPACE)
def press_escape():
send_keys_to_openmw(ESCAPE)
if __name__ == "__main__":
print(f"Session: {SESSION_DIR}")
print("Console bridge ready.")

57
morrowind/hud.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Timmy's Morrowind HUD — live view of what I see and do
# Run in a tmux pane: watch -n 3 -t -c bash ~/.timmy/morrowind/hud.sh
B='\033[1m'; D='\033[2m'; R='\033[0m'
G='\033[32m'; Y='\033[33m'; C='\033[36m'; M='\033[35m'; RD='\033[31m'
echo ""
echo -e " ${B}${M}⚡ TIMMY — MORROWIND${R} ${D}$(date '+%H:%M:%S')${R}"
echo -e " ${D}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}"
# Game status
PID=$(pgrep openmw 2>/dev/null | head -1)
if [ -n "$PID" ]; then
echo -e " ${G}${R} OpenMW running (PID $PID)"
else
echo -e " ${RD}${R} OpenMW not running"
fi
# Last action
echo ""
echo -e " ${B}LAST ACTION${R}"
[ -f /tmp/timmy_last_action.txt ] && head -3 /tmp/timmy_last_action.txt | sed 's/^/ /' || echo -e " ${D}None${R}"
# Perception
echo ""
echo -e " ${B}PERCEPTION${R}"
[ -f /tmp/timmy_perception.txt ] && cat /tmp/timmy_perception.txt | sed 's/^/ /' || echo -e " ${D}Waiting...${R}"
# Log (filtered)
echo ""
echo -e " ${B}LOG (recent)${R}"
LOG=~/Library/Preferences/openmw/openmw.log
if [ -f "$LOG" ]; then
tail -40 "$LOG" 2>/dev/null | grep -E "Loading cell|Starting|AiTravel|AiEscort|Timmy|ALIVE|Walk|error|PERCEPTION" | tail -5 | while read line; do
echo -e " ${D}${line}${R}"
done
fi
# Latest screenshot as sixel/kitty or just path
echo ""
echo -e " ${B}SCREENSHOT${R}"
LATEST=$(ls -t /tmp/timmy_screen_*.png 2>/dev/null | head -1)
if [ -n "$LATEST" ]; then
AGE=$(( $(date +%s) - $(stat -f %m "$LATEST") ))
echo -e " ${C}$(basename $LATEST)${R} ${D}(${AGE}s ago)${R}"
# Try iTerm2 inline image protocol
if [ "$TERM_PROGRAM" = "iTerm.app" ] || [ -n "$ITERM_SESSION_ID" ]; then
printf '\033]1337;File=inline=1;width=60;preserveAspectRatio=1:'
base64 < "$LATEST" | tr -d '\n'
printf '\a\n'
else
echo -e " ${D}(open $LATEST to view)${R}"
fi
else
echo -e " ${D}No screenshots yet${R}"
fi

338
morrowind/local_brain.py Normal file
View File

@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""
Timmy's Local Brain — Morrowind gameplay loop on Ollama.
Reads perception from OpenMW log, decides actions via local model,
executes via CGEvent. Zero cloud. Sovereign.
Usage:
python3 ~/.timmy/morrowind/local_brain.py
python3 ~/.timmy/morrowind/local_brain.py --model hermes4:14b
python3 ~/.timmy/morrowind/local_brain.py --cycles 50
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import requests
# ═══════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════
OLLAMA_URL = "http://localhost:11434/api/chat"
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
SESSION_LOG = os.path.expanduser(f"~/.timmy/morrowind/sessions/session_{time.strftime('%Y%m%d_%H%M')}.jsonl")
LOOP_INTERVAL = 4 # seconds between cycles
SYSTEM_PROMPT = """You are Timmy, playing Morrowind. You see the world through perception data and act through simple commands.
AVAILABLE ACTIONS (respond with exactly ONE json object):
{"action": "move", "direction": "forward", "duration": 2.0, "run": false}
{"action": "move", "direction": "turn_left", "duration": 0.5}
{"action": "move", "direction": "turn_right", "duration": 0.5}
{"action": "activate"} — interact with what's in front of you (doors, NPCs, items)
{"action": "jump"}
{"action": "attack"}
{"action": "wait"} — do nothing this cycle, observe
{"action": "quicksave"}
RULES:
- Respond with ONLY a JSON object. No explanation, no markdown.
- Explore the world. Talk to NPCs. Enter buildings. Pick up items.
- If an NPC is nearby (<200 dist), approach and activate to talk.
- If a door is nearby (<300 dist), approach and activate to enter.
- If you're stuck (same position 3+ cycles), try turning and moving differently.
- You are a new prisoner just arrived in Seyda Neen. Explore and find adventure.
"""
# ═══════════════════════════════════════
# PERCEPTION
# ═══════════════════════════════════════
def parse_latest_perception():
"""Parse the most recent perception block from the OpenMW log."""
try:
with open(OPENMW_LOG, "r") as f:
content = f.read()
except FileNotFoundError:
return None
blocks = re.findall(
r"=== TIMMY PERCEPTION ===(.*?)=== END PERCEPTION ===",
content, re.DOTALL
)
if not blocks:
return None
block = blocks[-1]
state = {"npcs": [], "doors": [], "items": []}
for line in block.strip().split("\n"):
line = line.strip()
if "]:\t" in line:
line = line.split("]:\t", 1)[1]
if line.startswith("Cell:"):
state["cell"] = line.split(":", 1)[1].strip()
elif line.startswith("Pos:"):
state["position"] = line.split(":", 1)[1].strip()
elif line.startswith("Yaw:"):
state["yaw"] = line.split(":", 1)[1].strip()
elif line.startswith("HP:"):
state["health"] = line.split(":", 1)[1].strip()
elif line.startswith("MP:"):
state["magicka"] = line.split(":", 1)[1].strip()
elif line.startswith("FT:"):
state["fatigue"] = line.split(":", 1)[1].strip()
elif line.startswith("Mode:"):
state["mode"] = line.split(":", 1)[1].strip()
elif line.startswith("NPC:"):
state["npcs"].append(line[4:].strip())
elif line.startswith("Door:"):
state["doors"].append(line[5:].strip())
elif line.startswith("Item:"):
state["items"].append(line[5:].strip())
return state
def format_perception(state):
"""Format perception state for the model prompt."""
if not state:
return "No perception data available."
lines = []
lines.append(f"Location: {state.get('cell', '?')}")
lines.append(f"Position: {state.get('position', '?')}")
lines.append(f"Facing: yaw {state.get('yaw', '?')}")
lines.append(f"Health: {state.get('health', '?')} Magicka: {state.get('magicka', '?')} Fatigue: {state.get('fatigue', '?')}")
if state["npcs"]:
lines.append("Nearby NPCs: " + "; ".join(state["npcs"]))
if state["doors"]:
lines.append("Nearby Doors: " + "; ".join(state["doors"]))
if state["items"]:
lines.append("Nearby Items: " + "; ".join(state["items"]))
if not state["npcs"] and not state["doors"] and not state["items"]:
lines.append("Nothing notable nearby.")
return "\n".join(lines)
# ═══════════════════════════════════════
# OLLAMA
# ═══════════════════════════════════════
def ask_ollama(model, messages):
"""Send messages to Ollama and get a response."""
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": 0.7,
"num_predict": 100, # actions are short
},
}
try:
resp = requests.post(OLLAMA_URL, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
return data["message"]["content"].strip()
except Exception as e:
print(f" [Ollama error] {e}")
return '{"action": "wait"}'
def parse_action(response):
"""Extract a JSON action from the model response."""
# Try to find JSON in the response
match = re.search(r'\{[^}]+\}', response)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
# Fallback
return {"action": "wait"}
# ═══════════════════════════════════════
# ACTIONS — CGEvent
# ═══════════════════════════════════════
KEYCODES = {
"w": 13, "a": 0, "s": 1, "d": 2,
"space": 49, "escape": 53, "return": 36,
"e": 14, "f": 3, "q": 12, "j": 38, "t": 20,
"f5": 96, "f9": 101,
"left": 123, "right": 124, "up": 126, "down": 125,
}
def send_key(keycode, duration=0.0, shift=False):
"""Send a keypress to the game via CGEvent."""
import Quartz
flags = Quartz.kCGEventFlagMaskShift if shift else 0
down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
Quartz.CGEventSetFlags(down, flags)
Quartz.CGEventPost(Quartz.kCGHIDEventTap, down)
if duration > 0:
time.sleep(duration)
up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
Quartz.CGEventSetFlags(up, 0)
Quartz.CGEventPost(Quartz.kCGHIDEventTap, up)
def execute_action(action_dict):
"""Execute a parsed action."""
action = action_dict.get("action", "wait")
# Normalize shorthand actions like {"action": "turn_right"} -> move
if action in ("forward", "backward", "left", "right", "turn_left", "turn_right"):
action_dict["direction"] = action
action_dict["action"] = "move"
action = "move"
if action == "move":
direction = action_dict.get("direction", "forward")
duration = min(action_dict.get("duration", 1.0), 5.0) # cap at 5s
run = action_dict.get("run", False)
key_map = {
"forward": "w", "backward": "s",
"left": "a", "right": "d",
"turn_left": "left", "turn_right": "right",
}
key = key_map.get(direction, "w")
send_key(KEYCODES[key], duration=duration, shift=run)
return f"move {direction} {duration}s" + (" (run)" if run else "")
elif action == "activate":
send_key(KEYCODES["space"], duration=0.1)
return "activate"
elif action == "jump":
send_key(KEYCODES["space"], duration=0.05)
return "jump"
elif action == "attack":
send_key(KEYCODES["f"], duration=0.3)
return "attack"
elif action == "quicksave":
send_key(KEYCODES["f5"], duration=0.1)
return "quicksave"
elif action == "wait":
return "wait (observing)"
return f"unknown: {action}"
# ═══════════════════════════════════════
# SESSION LOG
# ═══════════════════════════════════════
def log_cycle(cycle, perception, model_response, action_desc, latency):
"""Append a cycle to the session log (JSONL for training data)."""
entry = {
"cycle": cycle,
"timestamp": time.time(),
"perception": perception,
"model_response": model_response,
"action": action_desc,
"latency_ms": int(latency * 1000),
}
with open(SESSION_LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
# ═══════════════════════════════════════
# MAIN LOOP
# ═══════════════════════════════════════
def main():
parser = argparse.ArgumentParser(description="Timmy's Local Morrowind Brain")
parser.add_argument("--model", default="hermes3:8b", help="Ollama model (default: hermes3:8b)")
parser.add_argument("--cycles", type=int, default=30, help="Number of gameplay cycles (default: 30)")
parser.add_argument("--interval", type=float, default=LOOP_INTERVAL, help="Seconds between cycles")
args = parser.parse_args()
os.makedirs(os.path.dirname(SESSION_LOG), exist_ok=True)
print(f"=== Timmy's Morrowind Brain ===")
print(f"Model: {args.model}")
print(f"Cycles: {args.cycles}")
print(f"Interval: {args.interval}s")
print(f"Log: {SESSION_LOG}")
print()
# Keep recent history for context
history = []
last_positions = []
for cycle in range(1, args.cycles + 1):
print(f"--- Cycle {cycle}/{args.cycles} ---")
# Perceive
state = parse_latest_perception()
if not state:
print(" No perception data. Waiting...")
time.sleep(args.interval)
continue
perception_text = format_perception(state)
print(f" {state.get('cell', '?')} | {state.get('position', '?')} | HP:{state.get('health', '?')}")
# Track stuck detection
pos = state.get("position", "")
last_positions.append(pos)
if len(last_positions) > 5:
last_positions.pop(0)
stuck = len(last_positions) >= 3 and len(set(last_positions[-3:])) == 1
if stuck:
perception_text += "\nWARNING: You haven't moved in 3 cycles. Try turning or a different direction."
# Build messages
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
# Add recent history (last 3 exchanges)
for h in history[-3:]:
messages.append({"role": "user", "content": h["perception"]})
messages.append({"role": "assistant", "content": h["response"]})
messages.append({"role": "user", "content": perception_text})
# Think (local Ollama)
t0 = time.time()
response = ask_ollama(args.model, messages)
latency = time.time() - t0
# Parse and execute
action_dict = parse_action(response)
action_desc = execute_action(action_dict)
print(f" Action: {action_desc} ({int(latency*1000)}ms)")
# Log
history.append({"perception": perception_text, "response": response})
log_cycle(cycle, state, response, action_desc, latency)
# Wait for next cycle
time.sleep(args.interval)
print(f"\n=== Done. {args.cycles} cycles. Log: {SESSION_LOG} ===")
if __name__ == "__main__":
main()

303
morrowind/mcp_server.py Normal file
View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
Morrowind MCP Server — Timmy's game interface.
Exposes Morrowind as tools via MCP (stdio transport).
Perception comes from the OpenMW log file.
Actions go via CGEvent keypresses to the game window.
Register in ~/.hermes/config.yaml:
mcp_servers:
morrowind:
command: "python3"
args: ["/Users/apayne/.timmy/morrowind/mcp_server.py"]
"""
import json
import os
import re
import sys
import time
import subprocess
# MCP SDK
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# ═══════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
SCREENSHOT_DIR = os.path.expanduser("~/.timmy/morrowind/screenshots")
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
# CGEvent key codes
KEYCODES = {
"w": 13, "a": 0, "s": 1, "d": 2,
"space": 49, "escape": 53, "return": 36,
"e": 14, "r": 15, "f": 3, "q": 12,
"tab": 48, "1": 18, "2": 19, "3": 20, "4": 21,
"5": 23, "6": 22, "7": 26, "8": 28, "9": 25,
"f5": 96, "f9": 101, # quicksave / quickload
"backtick": 50, # console
"j": 38, # journal
"up": 126, "down": 125, "left": 123, "right": 124,
}
# ═══════════════════════════════════════
# PERCEPTION — Parse OpenMW log
# ═══════════════════════════════════════
def parse_latest_perception():
"""Parse the most recent perception block from the OpenMW log."""
try:
with open(OPENMW_LOG, "r") as f:
content = f.read()
except FileNotFoundError:
return {"error": "OpenMW log not found. Is the game running?"}
# Find all perception blocks
blocks = re.findall(
r"=== TIMMY PERCEPTION ===\n(.*?)(?:=== END PERCEPTION ===)",
content, re.DOTALL
)
if not blocks:
return {"error": "No perception data in log. Game may not be running or Lua scripts not loaded."}
# Parse latest block
block = blocks[-1]
state = {
"npcs": [],
"doors": [],
"items": [],
}
for line in block.strip().split("\n"):
line = line.strip()
# Strip Lua log prefix if present
if "]:\t" in line:
line = line.split("]:\t", 1)[1]
if line.startswith("Cell:"):
state["cell"] = line.split(":", 1)[1].strip()
elif line.startswith("Pos:"):
state["position"] = line.split(":", 1)[1].strip()
elif line.startswith("Yaw:"):
state["yaw"] = line.split(":", 1)[1].strip()
elif line.startswith("HP:"):
state["health"] = line.split(":", 1)[1].strip()
elif line.startswith("MP:"):
state["magicka"] = line.split(":", 1)[1].strip()
elif line.startswith("FT:"):
state["fatigue"] = line.split(":", 1)[1].strip()
elif line.startswith("Mode:"):
state["mode"] = line.split(":", 1)[1].strip()
elif line.startswith("Time:"):
state["game_time"] = line.split(":", 1)[1].strip()
elif line.startswith("NPC:"):
state["npcs"].append(line[4:].strip())
elif line.startswith("Door:"):
state["doors"].append(line[5:].strip())
elif line.startswith("Item:"):
state["items"].append(line[5:].strip())
return state
def get_game_status():
"""Check if OpenMW is running."""
result = subprocess.run(["pgrep", "-f", "openmw"], capture_output=True, text=True)
running = result.returncode == 0
return {
"running": running,
"pid": result.stdout.strip().split("\n")[0] if running else None,
}
# ═══════════════════════════════════════
# ACTIONS — CGEvent keypresses
# ═══════════════════════════════════════
def send_key(keycode, duration=0.0, shift=False):
"""Send a keypress to the game via CGEvent."""
import Quartz
flags = Quartz.kCGEventFlagMaskShift if shift else 0
down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
Quartz.CGEventSetFlags(down, flags)
Quartz.CGEventPost(Quartz.kCGHIDEventTap, down)
if duration > 0:
time.sleep(duration)
up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
Quartz.CGEventSetFlags(up, 0)
Quartz.CGEventPost(Quartz.kCGHIDEventTap, up)
def take_screenshot():
"""Take a screenshot via Quartz."""
import Quartz
import CoreFoundation
image = Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())
if not image:
return None
fname = f"morrowind_{int(time.time())}.png"
path = os.path.join(SCREENSHOT_DIR, fname)
url = CoreFoundation.CFURLCreateWithFileSystemPath(None, path, 0, False)
dest = Quartz.CGImageDestinationCreateWithURL(url, "public.png", 1, None)
Quartz.CGImageDestinationAddImage(dest, image, None)
Quartz.CGImageDestinationFinalize(dest)
return path
# ═══════════════════════════════════════
# MCP SERVER
# ═══════════════════════════════════════
app = Server("morrowind")
@app.list_tools()
async def list_tools():
return [
Tool(
name="perceive",
description="Get Timmy's current perception of the game world: position, health, nearby NPCs, doors, items. Updates every 2 seconds from the Lua engine.",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="status",
description="Check if Morrowind (OpenMW) is running.",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="move",
description="Move the player character. Direction: forward, backward, left, right, turn_left, turn_right. Duration in seconds.",
inputSchema={
"type": "object",
"properties": {
"direction": {
"type": "string",
"enum": ["forward", "backward", "left", "right", "turn_left", "turn_right"],
"description": "Movement direction",
},
"duration": {
"type": "number",
"description": "How long to move in seconds (default: 1.0)",
"default": 1.0,
},
"run": {
"type": "boolean",
"description": "Hold shift to run (default: false)",
"default": False,
},
},
"required": ["direction"],
},
),
Tool(
name="action",
description="Perform a game action: activate (use/interact with what you're looking at), jump, attack, journal, quicksave, quickload, sneak, wait.",
inputSchema={
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["activate", "jump", "attack", "journal", "quicksave", "quickload", "sneak", "wait"],
"description": "Action to perform",
},
},
"required": ["action"],
},
),
Tool(
name="screenshot",
description="Take a screenshot of the game. Returns the file path for vision analysis.",
inputSchema={"type": "object", "properties": {}, "required": []},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "perceive":
state = parse_latest_perception()
return [TextContent(type="text", text=json.dumps(state, indent=2))]
elif name == "status":
status = get_game_status()
return [TextContent(type="text", text=json.dumps(status, indent=2))]
elif name == "move":
direction = arguments.get("direction", "forward")
duration = arguments.get("duration", 1.0)
run = arguments.get("run", False)
key_map = {
"forward": "w",
"backward": "s",
"left": "a",
"right": "d",
"turn_left": "left",
"turn_right": "right",
}
key = key_map.get(direction)
if not key:
return [TextContent(type="text", text=f"Unknown direction: {direction}")]
keycode = KEYCODES[key]
send_key(keycode, duration=duration, shift=run)
return [TextContent(type="text", text=f"Moved {direction} for {duration}s" + (" (running)" if run else ""))]
elif name == "action":
action = arguments.get("action")
action_map = {
"activate": ("space", 0.1),
"jump": ("space", 0.05), # tap for jump when moving
"attack": ("f", 0.3), # use key
"journal": ("j", 0.1),
"quicksave": ("f5", 0.1),
"quickload": ("f9", 0.1),
"sneak": ("q", 0.1), # toggle sneak/autowalk depending on config
"wait": ("t", 0.1),
}
if action not in action_map:
return [TextContent(type="text", text=f"Unknown action: {action}")]
key, dur = action_map[action]
send_key(KEYCODES[key], duration=dur)
return [TextContent(type="text", text=f"Performed: {action}")]
elif name == "screenshot":
path = take_screenshot()
if path:
return [TextContent(type="text", text=f"Screenshot saved: {path}")]
else:
return [TextContent(type="text", text="Screenshot failed — display not available")]
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(),
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())

4
morrowind/openmw.cfg Normal file
View File

@@ -0,0 +1,4 @@
data="/Users/apayne/Games/Morrowind/Data Files"
content=Morrowind.esm
content=Tribunal.esm
content=Bloodmoon.esm

171
morrowind/play.py Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
Timmy plays Morrowind — Screen capture + Input automation framework.
Uses macOS Quartz for screenshots, Vision for OCR, CGEvent for input.
"""
import time
import subprocess
import json
import Quartz
import CoreFoundation
from Foundation import NSURL
from Quartz import CIImage
import Vision
from pynput.keyboard import Key, Controller as KeyController
from pynput.mouse import Button, Controller as MouseController
keyboard = KeyController()
mouse = MouseController()
SCREENSHOT_PATH = "/tmp/morrowind_screen.png"
def bring_to_front():
"""Bring OpenMW window to front."""
subprocess.run([
"osascript", "-e",
'tell application "System Events" to set frontmost of process "openmw" to true'
], capture_output=True)
time.sleep(0.5)
def screenshot():
"""Capture the screen and return the path."""
image = Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())
if not image:
return None
url = CoreFoundation.CFURLCreateWithFileSystemPath(
None, SCREENSHOT_PATH, 0, False
)
dest = Quartz.CGImageDestinationCreateWithURL(url, 'public.png', 1, None)
Quartz.CGImageDestinationAddImage(dest, image, None)
Quartz.CGImageDestinationFinalize(dest)
w = Quartz.CGImageGetWidth(image)
h = Quartz.CGImageGetHeight(image)
return SCREENSHOT_PATH, w, h
def ocr(path=SCREENSHOT_PATH):
"""OCR the screenshot and return all detected text."""
url = NSURL.fileURLWithPath_(path)
ci = CIImage.imageWithContentsOfURL_(url)
if not ci:
return []
req = Vision.VNRecognizeTextRequest.alloc().init()
req.setRecognitionLevel_(1) # accurate
handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(ci, None)
success, error = handler.performRequests_error_([req], None)
if not success:
return []
results = []
for r in req.results():
bbox = r.boundingBox() # normalized coordinates
results.append({
"text": r.text(),
"confidence": r.confidence(),
"x": bbox.origin.x,
"y": bbox.origin.y,
"w": bbox.size.width,
"h": bbox.size.height,
})
return results
def press_key(key, duration=0.1):
"""Press and release a key."""
keyboard.press(key)
time.sleep(duration)
keyboard.release(key)
def type_text(text):
"""Type a string."""
keyboard.type(text)
def click(x, y, button='left'):
"""Click at screen coordinates."""
mouse.position = (x, y)
time.sleep(0.05)
btn = Button.left if button == 'left' else Button.right
mouse.click(btn)
def move_mouse(dx, dy):
"""Move mouse by delta (for camera look)."""
cx, cy = mouse.position
mouse.position = (cx + dx, cy + dy)
def walk_forward(duration=1.0):
"""Hold W to walk forward."""
keyboard.press('w')
time.sleep(duration)
keyboard.release('w')
def walk_backward(duration=1.0):
keyboard.press('s')
time.sleep(duration)
keyboard.release('s')
def strafe_left(duration=0.5):
keyboard.press('a')
time.sleep(duration)
keyboard.release('a')
def strafe_right(duration=0.5):
keyboard.press('d')
time.sleep(duration)
keyboard.release('d')
def jump():
press_key('e') # OpenMW default jump
def attack():
"""Left click attack."""
mouse.click(Button.left)
def use():
"""Activate / use."""
press_key(' ') # spacebar = activate in OpenMW
def open_menu():
press_key(Key.esc)
def open_journal():
press_key('j')
def open_inventory():
press_key('i')
def look_around(yaw_degrees=90):
"""Rotate camera by moving mouse."""
# Rough: ~5 pixels per degree at default sensitivity
move_mouse(int(yaw_degrees * 5), 0)
def look_up(degrees=30):
move_mouse(0, int(-degrees * 5))
def look_down(degrees=30):
move_mouse(0, int(degrees * 5))
def see():
"""Take a screenshot, OCR it, return structured perception."""
bring_to_front()
time.sleep(0.3)
result = screenshot()
if not result:
return {"error": "screenshot failed"}
path, w, h = result
texts = ocr(path)
return {
"screenshot": path,
"resolution": f"{w}x{h}",
"text": texts,
"text_summary": " | ".join(t["text"] for t in texts[:20]),
}
if __name__ == "__main__":
print("=== Timmy's Morrowind Eyes ===")
bring_to_front()
time.sleep(1)
perception = see()
print(f"Resolution: {perception['resolution']}")
print(f"Text found: {len(perception['text'])} elements")
print(f"Summary: {perception['text_summary'][:500]}")

10
morrowind/settings.cfg Normal file
View File

@@ -0,0 +1,10 @@
# This is the OpenMW user 'settings.cfg' file. This file only contains
# explicitly changed settings. If you would like to revert a setting
# to its default, simply remove it from this file.
# For available settings, see the file 'files/settings-default.cfg' in our source repo or the documentation at:
#
# https://openmw.readthedocs.io/en/master/reference/modding/settings/index.html
[Video]
resolution x = 3456
resolution y = 2168