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:
166
morrowind/console.py
Normal file
166
morrowind/console.py
Normal 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.")
|
||||
Reference in New Issue
Block a user