167 lines
4.7 KiB
Python
167 lines
4.7 KiB
Python
|
|
#!/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.")
|