Files
my-botty/tools/hermes.py
Alex 5c687773b1 Click-and-go install: self-bootstrapping install.bat + tesserocr DLL fix (#2)
Merges fix/stash-full-guard-and-testbed into main. See PR #2 for full description.
2026-06-20 09:27:12 +02:00

221 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Hermes — vision-capable LLM agent for live D2R game analysis and bot development.
Grabs the current D2R window, sends it to the local Qwen vision model, and
lets you ask questions or request feature ideas based on what's on screen.
Usage:
<botty-env-python> tools/hermes.py [initial prompt]
<botty-env-python> tools/hermes.py # interactive mode
The LLM sees the exact screenshot the bot would see (same grab() call, same
resolution). Useful for:
- Understanding why a template match failed ("what does the NPC look like?")
- Prototyping feature logic ("what info is visible on the stash screen?")
- Checking item tooltips without running the full bot
- Building new detection templates from live game state
Controls (interactive mode):
Enter send current message (re-grabs screen each turn)
g / grab force a fresh grab and print its size before next query
q / quit exit
"""
import os
import sys
import json
import base64
import io
import urllib.request
import urllib.error
import time
import textwrap
TOOLS = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(TOOLS)
SRC = os.path.join(ROOT, "src")
if SRC not in sys.path:
sys.path.insert(0, SRC)
HERMES_URL = "http://192.168.1.98:8010/v1/chat/completions"
HERMES_MODEL = "qwen3.6-27b-autoround"
MAX_TOKENS = 1024
IMG_WIDTH = 1280 # resize to keep payload manageable
IMG_QUALITY = 85 # JPEG quality for the grab
def _patch_ssl():
import ssl
_orig = ssl.SSLContext.load_default_certs
def _safe(self, purpose=ssl.Purpose.CLIENT_AUTH):
try:
_orig(self, purpose)
except ssl.SSLError:
try:
import certifi
self.load_verify_locations(certifi.where())
except Exception:
pass
ssl.SSLContext.load_default_certs = _safe
_SYSTEM = textwrap.dedent("""\
You are Hermes, a vision-capable assistant for the my-botty Diablo II: Resurrected
automation project. The bot controls a Hammerdin character in Hell difficulty.
COORDINATE SYSTEMS (for feature/template development):
- Screen: D2R client area top-left = (0,0), size 1280×720
- Left panel (stash/NPC): ROI roughly x=33..415, y=84..466, 10×10 grid, 38×38px slots
- Right panel (character inventory): x=866..1247, y=348..535, 10×4 grid, 38×38px slots
- Belt: y≈540, row of 4 potion columns
- Minimap: top-right corner ~x=1050..1270, y=0..130
- Health orb: bottom-left ~x=0..120, y=560..720
- Mana orb: bottom-right ~x=1160..1280, y=560..720
- WP/stash UI open indicator: gold button visible at ~(95,140) (stash) or (684,140) (inv)
TEMPLATE MATCHING: src/template_finder.py uses OpenCV matchTemplate. Templates live in
assets/templates/. New templates should be ~30×30px crops of the target UI element at
1280×720 resolution.
INPUT LAYER: src/input_layer/win_input.py — SetCursorPos + zero-delta MOUSEEVENTF_MOVE
for accurate positioning (Win11 pointer acceleration bypass). All clicks go through
input_layer.mouse.
When suggesting code, use the existing patterns from the codebase:
- Grabbing: from screen import grab; img = grab(True) [True = monitor coords]
- Template: template_finder.search(["TEMPLATE_NAME"], img, threshold=0.7)
- Mouse: from input_layer import mouse; mouse.click(x, y)
- Wait: from utils.misc import wait; wait(0.1, 0.2)
- Logging: from logger import Logger; Logger.info("msg")
""")
def _grab_b64() -> tuple[str, tuple[int, int]]:
"""Grab D2R screen and return (base64_jpeg, (w, h))."""
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
from screen import grab, start_detecting_window, stop_detecting_window
from PIL import Image
import numpy as np
start_detecting_window()
try:
img_np = grab(True)
finally:
stop_detecting_window()
img_pil = Image.fromarray(img_np[..., ::-1]) # BGR -> RGB
orig_w, orig_h = img_pil.size
if orig_w > IMG_WIDTH:
scale = IMG_WIDTH / orig_w
img_pil = img_pil.resize((IMG_WIDTH, int(orig_h * scale)), Image.LANCZOS)
buf = io.BytesIO()
img_pil.save(buf, format="JPEG", quality=IMG_QUALITY)
b64 = base64.b64encode(buf.getvalue()).decode()
return b64, img_pil.size
def _ask(messages: list, img_b64: str) -> str:
"""Send conversation + current screen to Hermes; return reply text."""
# Inject the screenshot as the last user turn's image
user_content = [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}},
]
if messages and messages[-1]["role"] == "user":
last_text = messages[-1]["content"]
user_content.append({"type": "text", "text": last_text})
send_messages = messages[:-1] + [{"role": "user", "content": user_content}]
else:
send_messages = messages + [{"role": "user", "content": user_content}]
payload = json.dumps({
"model": HERMES_MODEL,
"max_tokens": MAX_TOKENS,
"temperature": 0.3,
"repetition_penalty": 1.15,
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "system", "content": _SYSTEM}] + send_messages,
}).encode()
req = urllib.request.Request(
HERMES_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
r = urllib.request.urlopen(req, timeout=60)
data = json.loads(r.read())
return data["choices"][0]["message"]["content"].strip()
except urllib.error.HTTPError as e:
body = e.read().decode()
return f"[HTTP {e.code}] {body[:300]}"
except Exception as e:
return f"[Error] {e}"
def _print_wrapped(text: str, width: int = 100):
import sys
out = sys.stdout
for line in text.splitlines():
filled = line if len(line) <= width else textwrap.fill(line, width=width, subsequent_indent=" ")
try:
print(filled)
except UnicodeEncodeError:
print(filled.encode("ascii", errors="replace").decode("ascii"))
def main():
_patch_ssl()
print("Hermes — D2R vision agent (q=quit, g=grab)\n")
initial = " ".join(sys.argv[1:]).strip()
history: list[dict] = []
img_b64 = None
img_size = None
def do_grab():
nonlocal img_b64, img_size
print("Grabbing D2R screen...", end=" ", flush=True)
t0 = time.time()
img_b64, img_size = _grab_b64()
print(f"{img_size[0]}×{img_size[1]} ({len(img_b64)//1024}KB) [{time.time()-t0:.1f}s]")
do_grab()
def send(prompt: str):
history.append({"role": "user", "content": prompt})
print("\nHermes: ", end="", flush=True)
t0 = time.time()
reply = _ask(history, img_b64)
history.append({"role": "assistant", "content": reply})
print()
_print_wrapped(reply)
print(f"\n[{time.time()-t0:.1f}s]\n")
if initial:
send(initial)
while True:
try:
raw = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nbye")
break
if not raw:
continue
if raw.lower() in ("q", "quit", "exit"):
break
if raw.lower() in ("g", "grab"):
do_grab()
continue
# Auto-regrab each turn so Hermes always sees fresh state
do_grab()
send(raw)
if __name__ == "__main__":
main()