Загрузка данных


# -*- coding: utf-8 -*-
"""
Gold Ring Autoclicker (Windows)
F6 - toggle, F7 - screenshot, F8 - LMB test
Deps: pip install mss opencv-python numpy keyboard
"""

import ctypes as _ct
try:
    _ct.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
    try:
        _ct.windll.user32.SetProcessDPIAware()
    except Exception:
        pass

import os
import sys
import math
import time
import ctypes
import ctypes.wintypes as wt
import threading
import logging
from dataclasses import dataclass
from typing import Optional, Tuple

import tkinter as tk
from tkinter import ttk

import numpy as np
import cv2
import mss

try:
    import keyboard as _kbd
    HAS_KEYBOARD = True
except Exception:
    HAS_KEYBOARD = False
    _kbd = None


BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ZONE_FILE = os.path.join(BASE_DIR, "search_zone.txt")
LOG_FILE = os.path.join(BASE_DIR, "gold_ring_autoclicker.log")
DEBUG_IMG = os.path.join(BASE_DIR, "gold_ring_debug.jpg")

logger = logging.getLogger("gold_ring")
logger.setLevel(logging.INFO)
if not logger.handlers:
    _fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
    _fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
    logger.addHandler(_fh)
    _sh = logging.StreamHandler(sys.stdout)
    _sh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
    logger.addHandler(_sh)


user32 = ctypes.WinDLL("user32", use_last_error=True)

if ctypes.sizeof(ctypes.c_void_p) == 8:
    ULONG_PTR = ctypes.c_ulonglong
else:
    ULONG_PTR = ctypes.c_ulong


class MOUSEINPUT(ctypes.Structure):
    _fields_ = [
        ("dx", wt.LONG),
        ("dy", wt.LONG),
        ("mouseData", wt.DWORD),
        ("dwFlags", wt.DWORD),
        ("time", wt.DWORD),
        ("dwExtraInfo", ULONG_PTR),
    ]


class KEYBDINPUT(ctypes.Structure):
    _fields_ = [
        ("wVk", wt.WORD),
        ("wScan", wt.WORD),
        ("dwFlags", wt.DWORD),
        ("time", wt.DWORD),
        ("dwExtraInfo", ULONG_PTR),
    ]


class HARDWAREINPUT(ctypes.Structure):
    _fields_ = [
        ("uMsg", wt.DWORD),
        ("wParamL", wt.WORD),
        ("wParamH", wt.WORD),
    ]


class _INPUT_UNION(ctypes.Union):
    _fields_ = [
        ("mi", MOUSEINPUT),
        ("ki", KEYBDINPUT),
        ("hi", HARDWAREINPUT),
    ]


class INPUT(ctypes.Structure):
    _anonymous_ = ("u",)
    _fields_ = [
        ("type", wt.DWORD),
        ("u", _INPUT_UNION),
    ]


INPUT_MOUSE = 0
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004

user32.SendInput.argtypes = (wt.UINT, ctypes.POINTER(INPUT), ctypes.c_int)
user32.SendInput.restype = wt.UINT
user32.SetCursorPos.argtypes = (ctypes.c_int, ctypes.c_int)
user32.SetCursorPos.restype = wt.BOOL
user32.GetCursorPos.argtypes = (ctypes.POINTER(wt.POINT),)
user32.GetCursorPos.restype = wt.BOOL
user32.mouse_event.argtypes = (wt.DWORD, wt.DWORD, wt.DWORD, wt.DWORD, ULONG_PTR)
user32.mouse_event.restype = None


def _send_input_mouse(flags):
    inp = INPUT()
    inp.type = INPUT_MOUSE
    inp.mi.dx = 0
    inp.mi.dy = 0
    inp.mi.mouseData = 0
    inp.mi.dwFlags = flags
    inp.mi.time = 0
    inp.mi.dwExtraInfo = 0
    return user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))


def set_cursor_pos(x, y):
    user32.SetCursorPos(int(x), int(y))


def get_cursor_pos():
    pt = wt.POINT()
    user32.GetCursorPos(ctypes.byref(pt))
    return pt.x, pt.y


def click_sendinput(x, y, move_delay_ms=60, down_up_ms=25):
    set_cursor_pos(x, y)
    time.sleep(move_delay_ms / 1000.0)
    ax, ay = get_cursor_pos()
    _send_input_mouse(MOUSEEVENTF_LEFTDOWN)
    time.sleep(down_up_ms / 1000.0)
    _send_input_mouse(MOUSEEVENTF_LEFTUP)
    return ax, ay


def click_mouse_event(x, y, move_delay_ms=60, down_up_ms=25):
    set_cursor_pos(x, y)
    time.sleep(move_delay_ms / 1000.0)
    ax, ay = get_cursor_pos()
    user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    time.sleep(down_up_ms / 1000.0)
    user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    return ax, ay


def click_pyautogui(x, y, move_delay_ms=60, down_up_ms=25):
    import pyautogui
    pyautogui.FAILSAFE = False
    pyautogui.moveTo(int(x), int(y), duration=0)
    time.sleep(move_delay_ms / 1000.0)
    ax, ay = get_cursor_pos()
    pyautogui.mouseDown(button="left")
    time.sleep(down_up_ms / 1000.0)
    pyautogui.mouseUp(button="left")
    return ax, ay


BACKENDS = {
    "SendInput": click_sendinput,
    "mouse_event": click_mouse_event,
    "PyAutoGUI": click_pyautogui,
}


@dataclass
class Config:
    h_min: int = 12
    h_max: int = 30
    s_min: int = 45
    v_min: int = 145
    min_area: float = 80.0
    min_radius: int = 30
    max_radius: int = 70
    min_circularity: float = 0.60
    min_aspect: float = 0.75
    dark_center_max: int = 125
    move_delay_ms: int = 60
    down_up_ms: int = 25
    new_target_min_dist: int = 50
    new_target_radius_factor: float = 1.2
    backend: str = "SendInput"


def detect_gold_ring(bgr, cfg):
    hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
    lower = np.array([cfg.h_min, cfg.s_min, cfg.v_min], dtype=np.uint8)
    upper = np.array([cfg.h_max, 255, 255], dtype=np.uint8)
    mask = cv2.inRange(hsv, lower, upper)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=1)

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    out = []
    for c in contours:
        area = cv2.contourArea(c)
        if area < cfg.min_area:
            continue
        (cx, cy), radius = cv2.minEnclosingCircle(c)
        if radius < cfg.min_radius or radius > cfg.max_radius:
            continue
        per = cv2.arcLength(c, True)
        if per <= 1.0:
            continue
        circ = 4.0 * math.pi * area / (per * per)
        if circ < cfg.min_circularity:
            continue
        x, y, w, h = cv2.boundingRect(c)
        if h == 0:
            continue
        ar = w / float(h)
        if ar < cfg.min_aspect or ar > 1.0 / cfg.min_aspect:
            continue
        ir = max(4, int(radius * 0.45))
        x0 = max(0, int(cx) - ir)
        y0 = max(0, int(cy) - ir)
        x1 = min(bgr.shape[1], int(cx) + ir)
        y1 = min(bgr.shape[0], int(cy) + ir)
        if x1 <= x0 or y1 <= y0:
            continue
        patch = bgr[y0:y1, x0:x1]
        gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
        if gray.mean() > cfg.dark_center_max:
            continue
        out.append({"cx": int(cx), "cy": int(cy), "r": int(radius)})
    return out, mask


class Worker(threading.Thread):
    def __init__(self, cfg, zone, get_enabled, on_stats, on_click):
        super().__init__(daemon=True)
        self.cfg = cfg
        self.zone = zone
        self.get_enabled = get_enabled
        self.on_stats = on_stats
        self.on_click = on_click
        self._stop = threading.Event()
        self.last_click_center = None
        self.last_click_radius = 0
        self.click_count = 0
        self.frame_count = 0
        self._sct = None

    def stop(self):
        self._stop.set()

    def set_zone(self, zone):
        self.zone = zone

    def reset_state(self):
        self.last_click_center = None
        self.last_click_radius = 0

    def _is_new(self, cx, cy, r):
        if self.last_click_center is None:
            return True
        lx, ly = self.last_click_center
        threshold = max(
            self.cfg.new_target_min_dist,
            self.cfg.new_target_radius_factor * max(r, self.last_click_radius),
        )
        return math.hypot(cx - lx, cy - ly) > threshold

    def run(self):
        try:
            self._sct = mss.mss()
        except Exception as e:
            logger.exception("mss init failed: %s", e)
            return

        while not self._stop.is_set():
            if not self.get_enabled() or not self.zone:
                time.sleep(0.05)
                continue

            l, t, w, h = self.zone
            if w <= 0 or h <= 0:
                time.sleep(0.1)
                continue
            monitor = {"left": int(l), "top": int(t),
                       "width": int(w), "height": int(h)}

            try:
                shot = self._sct.grab(monitor)
                img = np.asarray(shot)[:, :, :3].copy()
            except Exception as e:
                logger.exception("grab failed: %s", e)
                time.sleep(0.1)
                continue

            self.frame_count += 1

            try:
                cands, _ = detect_gold_ring(img, self.cfg)
            except Exception as e:
                logger.exception("detect failed: %s", e)
                time.sleep(0.05)
                continue

            if cands:
                fresh = []
                for c in cands:
                    sx = l + c["cx"]
                    sy = t + c["cy"]
                    if self._is_new(sx, sy, c["r"]):
                        fresh.append((sx, sy, c))

                if fresh:
                    expected = (self.cfg.min_radius + self.cfg.max_radius) / 2.0
                    fresh.sort(key=lambda v: abs(v[2]["r"] - expected))
                    sx, sy, c = fresh[0]

                    backend = BACKENDS.get(self.cfg.backend, click_sendinput)
                    try:
                        ax, ay = backend(sx, sy,
                                         self.cfg.move_delay_ms,
                                         self.cfg.down_up_ms)
                    except Exception as e:
                        logger.exception("click failed: %s", e)
                        time.sleep(0.05)
                        continue

                    drift = math.hypot(ax - sx, ay - sy)
                    if drift > 5:
                        logger.warning("cursor drift %.1fpx: want (%d,%d) got (%d,%d)",
                                       drift, sx, sy, ax, ay)

                    self.last_click_center = (sx, sy)
                    self.last_click_radius = c["r"]
                    self.click_count += 1
                    logger.info("CLICK #%d at (%d,%d) r=%d backend=%s",
                                self.click_count, sx, sy, c["r"], self.cfg.backend)
                    try:
                        self.on_click(sx, sy, c["r"])
                    except Exception:
                        pass
                    try:
                        self.on_stats(self.click_count, self.frame_count)
                    except Exception:
                        pass

                    time.sleep(0.03)

            time.sleep(0.003)

        logger.info("Worker stopped")


class ZoneSelector:
    def __init__(self, parent, on_done):
        self.on_done = on_done
        self.top = tk.Toplevel(parent)
        self.top.title("Zone select")
        self.top.attributes("-fullscreen", True)
        self.top.attributes("-topmost", True)
        self.top.attributes("-alpha", 0.35)
        self.top.configure(bg="black")

        self.canvas = tk.Canvas(self.top, bg="black",
                                highlightthickness=0, cursor="crosshair")
        self.canvas.pack(fill="both", expand=True)

        sw = self.top.winfo_screenwidth()
        self.canvas.create_text(
            sw // 2, 60,
            text="Drag mouse to select zone. ESC to cancel.",
            fill="yellow", font=("Segoe UI", 18, "bold"))

        self.start = None
        self.rect = None
        self.canvas.bind("<ButtonPress-1>", self._press)
        self.canvas.bind("<B1-Motion>", self._drag)
        self.canvas.bind("<ButtonRelease-1>", self._release)
        self.top.bind("<Escape>", lambda e: self._cancel())
        self.top.focus_force()

    def _press(self, e):
        self.start = (e.x_root, e.y_root)
        if self.rect:
            self.canvas.delete(self.rect)
        self.rect = self.canvas.create_rectangle(e.x, e.y, e.x, e.y,
                                                 outline="red", width=3)

    def _drag(self, e):
        if not self.start or not self.rect:
            return
        ox = self.top.winfo_rootx()
        oy = self.top.winfo_rooty()
        self.canvas.coords(self.rect,
                           self.start[0] - ox, self.start[1] - oy,
                           e.x, e.y)

    def _release(self, e):
        if not self.start:
            return
        x0, y0 = self.start
        x1, y1 = e.x_root, e.y_root
        left = min(x0, x1)
        topv = min(y0, y1)
        w = abs(x1 - x0)
        h = abs(y1 - y0)
        self.top.destroy()
        if w < 20 or h < 20:
            self.on_done(None)
        else:
            self.on_done((left, topv, w, h))

    def _cancel(self):
        self.top.destroy()
        self.on_done(None)


class App:
    def __init__(self, root):
        self.root = root
        self.root.title("Gold Ring Autoclicker")
        self.root.geometry("600x760")
        self.root.resizable(False, False)

        self.cfg = Config()
        self.zone = None
        self.enabled = False
        self.worker = None
        self.zone_selector = None

        self._test_click_received = False
        self._test_click_time = None

        self._build_ui()
        self._load_zone()

        self.worker = Worker(
            self.cfg, self.zone,
            get_enabled=lambda: self.enabled,
            on_stats=self._on_worker_stats,
            on_click=self._on_worker_click,
        )
        self.worker.start()

        self._install_hotkeys()
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        self.root.after(200, self._tick_ui)

    def _build_ui(self):
        pad = {"padx": 8, "pady": 4}

        top = tk.Frame(self.root)
        top.pack(fill="x", **pad)
        self.status_lbl = tk.Label(top, text="STOPPED", bg="#a33",
                                   fg="white", font=("Segoe UI", 14, "bold"),
                                   padx=12, pady=8)
        self.status_lbl.pack(side="left")
        self.stats_lbl = tk.Label(top, text="clicks: 0  frames: 0",
                                  font=("Consolas", 10))
        self.stats_lbl.pack(side="right")

        zf = tk.LabelFrame(self.root, text="Search zone")
        zf.pack(fill="x", **pad)
        self.zone_lbl = tk.Label(zf, text="not selected", font=("Consolas", 10))
        self.zone_lbl.pack(side="left", padx=8, pady=6)
        tk.Button(zf, text="Pick with mouse",
                  command=self._pick_zone).pack(side="right", padx=8, pady=6)

        tf = tk.LabelFrame(self.root, text="LMB diagnostics")
        tf.pack(fill="x", **pad)
        self.test_area = tk.Frame(tf, bg="#333", height=70, cursor="hand2")
        self.test_area.pack(fill="x", padx=8, pady=6)
        self.test_area.pack_propagate(False)
        self.test_lbl = tk.Label(
            self.test_area,
            text="LMB TEST - press button below",
            bg="#333", fg="#ddd", font=("Segoe UI", 10, "bold"))
        self.test_lbl.pack(expand=True, fill="both")
        self.test_area.bind("<Button-1>", self._on_test_click_received)
        self.test_lbl.bind("<Button-1>", self._on_test_click_received)

        row = tk.Frame(tf)
        row.pack(fill="x", padx=8, pady=(0, 6))
        self.backend_var = tk.StringVar(value=self.cfg.backend)
        tk.Label(row, text="Backend:").pack(side="left")
        cb =.c ttk.Combobox(rowfg, textvariable=self.backend_var,
                          values=list(BACKENDS.keys()),
                          state="readonly", width=14)
        cb.pack(side="left", padx=6)
        cb.bind("<<ComboboxSelected>>",
                lambda e: setattr(self.cfg, "backend", self.backend_var.get()))
        tk.Button(row, text="Run test",
                  command=self._run_lmb_test).pack(side="right")

        self.test_result = tk.Label(tf, text="", font=("Consolas", 10),
                                    wraplength=560, justify="left")
        self.test_result.pack(anchor="w", padx=8, pady=(0, 6))

        sf = tk.LabelFrame(self.root, text="Timings and thresholds")
        sf.pack(fill="x", **pad)
        self.move_delay_var = tk.IntVar(value=self.cfg.move_delay_ms)
        self.down_up_var = tk.IntVar(value=self.down_up_ms)
        self.min_dist_var = tk.IntVar(value=self.cfg.new_target_min_dist)
        self.radius_factor_var = tk.DoubleVar(value=self.cfg.new_target_radius_factor)

        self._slider(sf, "Delay after cursor move (ms)",
                     self.move_delay_var, 20, 300, 1)
        self._slider(sf, "LMB down-to-up (ms)",
                     self.down_up_var, 5, 150, 1)
        self._slider(sf, "Min distance to new ring (px)",
                     self.min_dist_var, 20, 300, 1)
        self._slider(sf, "New-ring threshold (x radius)",
                     self.radius_factor_var, 0.5, 3.0, 0.1)

        bf = tk.Frame(self.root)
        bf.pack(fill="x", **pad)
        tk.Button(bf, text="START / STOP (F6)",
                  font=("Segoe UI", 12, "bold"),
                  command=self._toggle_enabled
                  ).pack(side="left", expand=True, fill="x", padx=(0, 4))
        tk.Button(bf, text="Screenshot (F7)",
                  command=self._save_debug).pack(side="left", padx=4)
        tk.Button(bf, text="Reset state",
                  command=self._reset_state).pack(side="left", padx=(4, 0))

        lf = tk.LabelFrame(self.root, text="Log")
        lf.pack(fill="both", expand=True, **pad)
        self.log_text = tk.Text(lf, height=12, font=("Consolas", 9), wrap="none")
        self.log_text.pack(fill="both", expand=True, padx=4, pady=4)

        self._gui_log("App started")
        if not HAS_KEYBOARD:
            self._gui_log("WARNING: keyboard module missing. "
                          "Install: pip install keyboard")

    def _slider(self, parent, label, var, lo, hi, res):
        f = tk.Frame(parent)
        f.pack(fill="x", padx=8, pady=2)
        tk.Label(f, text=label, width=42, anchor="w").pack(side="left")
        tk.Scale(f, from_=lo, to=hi, orient="horizontal",
                 variable=var, resolution=res, showvalue=True,
                 length=210, command=lambda v: self._update_timing()
                 ).pack(side="right")

    def _gui_log(self, msg):
        ts = time.strftime("%H:%M:%S")
        try:
            self.log_text.insert("end", "[" + ts + "] " + msg + "\n")
            self.log_text.see("end")
        except Exception:
            pass

    def _update_timing(self):
        self.cfg.move_delay_ms = int(self.move_delay_var.get())
        self.cfg.down_up_ms = int(self.down_up_var.get())
        self.cfg.new_target_min_dist = int(self.min_dist_var.get())
        self.cfg.new_target_radius_factor = float(self.radius_factor_var.get())

    def _toggle_enabled(self):
        if not self.zone:
            self._gui_log("No search zone selected")
            return
        self.enabled = not self.enabled
        if self.enabled:
            self.status_lbl.config(text="RUNNING", bg="#2a7")
            self._gui_log("F6 -> ON")
        else:
            self.status_lbl.config(text="STOPPED", bg="#a33")
            self._gui_log("F6 -> OFF")

    def _reset_state(self):
        if self.worker:
            self.worker.reset_state()
        self._gui_log("last_click reset")

    def _save_debug(self):
        try:
            with mss.mss() as sct:
                if self.zone:
                    l, t, w, h = self.zone
                    mon = {"left": l, "top": t, "width": w, "height": h}
                else:
                    mon = sct.monitors[1]
                shot = sct.grab(mon)
                img = np.asarray(shot)[:, :, :3].copy()

            if self.zone and self.worker and self.worker.last_click_center:
                lx, ly = self.worker.last_click_center
                l0, t0, _, _ = self.zone
                cv2.drawMarker(img, (lx - l0, ly - t0), (0, 0, 255),
                               cv2.MARKER_CROSS, 30, 2)

            cv2.imwrite(DEBUG_IMG, img)
            self._gui_log("Screenshot saved: " + DEBUG_IMG)
        except Exception as e:
            logger.exception("debug save failed: %s", e)
            self._gui_log("Screenshot error: " + str(e))

    def _run_lmb_test(self):
        self._test_click_received = False
        self._test_click_time = time.time()
        self.test_result.config(text="Waiting for click...", fg="#666")
        self.root.lift()
        self.root.attributes("-topmost", True)
        self.root.after(150, lambda: self.root.attributes("-topmost", False))
        self.root.update_idletasks()

        x = self.test_area.winfo_rootx() + self.test_area.winfo_width() // 2
        y = self.test_area.winfo_rooty() + self.test_area.winfo_height() // 2

        def do():
            time.sleep(0.20)
            try:
                backend = BACKENDS.get(self.cfg.backend, click_sendinput)
                ax, ay = backend(x, y, self.cfg.move_delay_ms, self.cfg.down_up_ms)
                logger.info("LMB test: target=(%d,%d) actual=(%d,%d) drift=%.1f",
                            x, y, ax, ay, math.hypot(ax - x, ay - y))
            except Exception as e:
                logger.exception("LMB test failed: %s", e)

        threading.Thread(target=do, daemon=True).start()
        self.root.after(900, self._check_test_result)

    def _on_test_click_received(self, _event):
        if self._test_click_time is None:
            return
        self._test_click_received = True
        dt = (time.time() - self._test_click_time) * 1000.0
        logger.info("LMB test: Tkinter received Button-1 after %.0f ms", dt)

    def _check_test_result(self):
        if self._test_click_received:
            self.test_result.config(
                text="OK: Tkinter got Button-1. Windows accepts LMB. "
                     "If the game ignores it - filter is on app side.",
                fg="#080")
            self._gui_log("LMB test: OK")
        else:
            self.test_result.config(
                text="FAIL: Button-1 not received. Try another backend, "
                     "check DPI, or run as administrator.",
                fg="#a00")
            self._gui_log("LMB test: FAILED")

    def _pick_zone(self):
        if self.zone_selector is not None:
            return
        self.zone_selector = ZoneSelector(self.root, self._zone_selected)

    def _zone_selected(self, zone):
        self.zone_selector = None
        if zone is None:
            self._gui_log("Zone selection cancelled")
            return
        self.zone = zone
        l, t, w, h = zone
        self.zone_lbl.config(text="(" + str(l) + "," + str(t) + ") " +
                                  str(w) + "x" + str(h))
        if self.worker:
            self.worker.set_zone(zone)
            self.worker.reset_state()
        self._save_zone(zone)
        self._gui_log("Zone set")

    def _save_zone(self, zone):
        try:
            with open(ZONE_FILE, "w", encoding="utf-8") as f:
                f.write(str(zone[0]) + " " + str(zone[1]) + " " +
                        str(zone[2]) + " " + str(zone[3]) + "\n")
        except Exception as e:
            logger.exception("zone save failed: %s", e)

    def _load_zone(self):
        if not os.path.exists(ZONE_FILE):
            return
        try:
            with open(ZONE_FILE, "r", encoding="utf-8") as f:
                parts = f.read().split()
            if len(parts) >= 4:
                l, t, w, h = map(int, parts[:4])
                if w > 0 and h > 0:
                    self.zone = (l, t, w, h)
                    self.zone_lbl.config(text="(" + str(l) + "," + str(t) + ") " +
                                              str(w) + "x" + str(h))
                    if self.worker:
                        self.worker.set_zone(self.zone)
                    self._gui_log("Zone loaded from file")
        except Exception as e:
            logger.exception("zone load failed: %s", e)

    def _on_worker_stats(self, clicks, frames):
        txt = "clicks: " + str(clicks) + "  frames: " + str(frames)
        self.root.after(0, lambda: self.stats_lbl.config(text=txt))

    def _on_worker_click(self, x, y, r):
        msg = "CLICK (" + str(x) + "," + str(y) + ") r=" + str(r)
        self.root.after(0, lambda: self._gui_log(msg))

    def _install_hotkeys(self):
        if not HAS_KEYBOARD:
            return
        try:
            _kbd.add_hotkey("f6", lambda: self.root.after(0, self._toggle_enabled))
            _kbd.add_hotkey("f7", lambda: self.root.after(0, self._save_debug))
            _kbd.add_hotkey("f8", lambda: self.root.after(0, self._run_lmb_test))
            logger.info("Hotkeys F6/F7/F8 registered")
        except Exception as e:
            logger.exception("hotkey registration failed: %s", e)
            self._gui_log("Hotkeys failed: " + str(e))

    def _tick_ui(self):
        try:
            if self.enabled:
                self.status_lbl.config(text="RUNNING", bg="#2a7")
            else:
                self.status_lbl.config(text="STOPPED", bg="#a33")
        except Exception:
            pass
        self.root.after(300, self._tick_ui)

    def _on_close(self):
        self.enabled = False
        try:
            if self.worker:
                self.worker.stop()
        except Exception:
            pass
        if HAS_KEYBOARD:
            try:
                _kbd.unhook_all_hotkeys()
            except Exception:
                pass
        self.root.destroy()


def main():
    root = tk.Tk()
    App(root)
    root.mainloop()


if __name__ == "__main__":
    main()