from dataclasses import dataclass from decimal import InvalidOperation import time import random import ctypes import threading import logging import numpy as np from copy import deepcopy import unicodedata import re from pyparsing import Regex from logger import Logger import cv2 import os from math import cos, sin, dist import subprocess import psutil if os.name == 'nt': # Set DPI awareness BEFORE any Win32 GUI calls. # Without this, Windows at 80% DPI reports 1280x720 as 1024x576. try: ctypes.windll.shcore.SetProcessDpiAwareness(2) # Per-monitor v2 except: try: ctypes.windll.shcore.SetProcessDpiAwareness(1) # Per-monitor v1 except: pass from win32con import HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, HWND_NOTOPMOST from win32gui import GetWindowText, SetWindowPos, EnumWindows, GetClientRect, ClientToScreen from win32api import GetMonitorInfo, MonitorFromWindow from win32process import GetWindowThreadProcessId else: # Linux stubs — these functions are never called outside os.name == 'nt' blocks, # but they must exist for the module to import cleanly on non-Windows systems. HWND_TOPMOST = None SWP_NOMOVE = None SWP_NOSIZE = None HWND_NOTOPMOST = None def GetWindowText(hwnd): return None def SetWindowPos(hwnd, flags, x, y, w, h, extra): pass def EnumWindows(callback, lParam): pass def GetClientRect(hwnd): return (0, 0, 0, 0) def ClientToScreen(hwnd, pt): return pt def GetMonitorInfo(hwnd, info): return None def MonitorFromWindow(hwnd, flags): return None def GetWindowThreadProcessId(hwnd): return (0, 0) from rapidfuzz.process import extract as _rf_extract try: from rapidfuzz.string_metric import levenshtein as _levenshtein_scorer except ImportError: from rapidfuzz.distance import Levenshtein as _Levenshtein _levenshtein_scorer = _Levenshtein.distance def close_down_d2(): subprocess.call(["taskkill","/F","/IM","D2R.exe"], stderr=subprocess.DEVNULL) def close_down_bnet_launcher(): subprocess.call(["taskkill","/F","/IM","Battle.net.exe"], stderr=subprocess.DEVNULL) @dataclass class WindowSpec: title_regex: 'str | None' = None process_name_regex: 'str | None' = None def match(self, hwnd) -> bool: result = True if self.title_regex is not None: result = result and Regex(self.title_regex).matches(GetWindowText(hwnd)) if self.process_name_regex is not None: _, process_id = GetWindowThreadProcessId(hwnd) if process_id > 0: try: result = result and Regex(self.process_name_regex).matches(psutil.Process(process_id).name()) except psutil.NoSuchProcess: result = False if self.title_regex is None and self.process_name_regex is None: result = False return result def find_d2r_window(spec: WindowSpec, offset = (0, 0)) -> tuple[int, int]: offset_x, offset_y = offset if os.name == 'nt': window_list = [] EnumWindows(lambda w, l: l.append(w), window_list) for hwnd in window_list: if spec.match(hwnd): left, top, right, bottom = GetClientRect(hwnd) (left, top), (right, bottom) = ClientToScreen(hwnd, (left, top)), ClientToScreen(hwnd, (right, bottom)) return (left + offset_x, top + offset_y) return None def move_d2r_window(client_x, client_y): """Move D2R window so the client area is at (client_x, client_y) to prevent offset drift.""" if os.name == 'nt': import ctypes from ctypes import wintypes from win32con import SWP_SHOWWINDOW user32 = ctypes.windll.user32 window_list = [] EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), window_list) for w in window_list: if "Diablo II" in w[1]: # Get current client area position tl = wintypes.POINT() user32.ClientToScreen(w[0], ctypes.byref(tl)) # Get window dimensions wr = wintypes.RECT() user32.GetWindowRect(w[0], ctypes.byref(wr)) w_width = wr.right - wr.left w_height = wr.bottom - wr.top # Calculate outer window position to place client area at target outer_x = client_x - (tl.x - wr.left) outer_y = client_y - (tl.y - wr.top) SetWindowPos(w[0], HWND_TOPMOST, outer_x, outer_y, w_width, w_height, SWP_SHOWWINDOW) Logger.debug(f"Moved D2R client area to ({client_x}, {client_y})") return True return False def set_d2r_always_on_top(): if os.name == 'nt': for attempt in range(30): windows_list = [] EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list) found = False for w in windows_list: if "Diablo II" in w[1]: SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE) print("Set D2R to be always on top") found = True break if found: return wait(0.5, 1.0) print('D2R window not found, could not set always on top') else: print('OS not supported, unable to set D2R always on top') def restore_d2r_window_visibility(): if os.name == 'nt': windows_list = [] EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list) for w in windows_list: if w[1] == "Diablo II: Resurrected": SetWindowPos(w[0], HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE) print("Restored D2R window visibility") else: print('OS not supported, unable to set D2R always on top') def wait(min_seconds, max_seconds = None): if max_seconds is None: max_seconds = min_seconds base = random.uniform(min_seconds, max_seconds) try: from config import Config cfg = Config().stealth # Use Gaussian jitter for more natural-feeling delays jitter_min = cfg["wait_jitter_min"] jitter_max = cfg["wait_jitter_max"] center = (jitter_min + jitter_max) / 2 sigma = (jitter_max - jitter_min) / 4 jitter = random.gauss(center, sigma) jitter = max(jitter_min * 0.8, min(jitter_max * 1.2, jitter)) except Exception: jitter = 1.0 time.sleep(base * jitter) return def _force_kill_thread(thread): """ DANGEROUS: Force-kills a thread via CPython private API. Only use as a last resort when cooperative shutdown failed. Can corrupt locks, cause GIL issues, or corrupt numpy arrays. """ Logger.error( f"Force-killing thread '{thread.name}' via PyThreadState_SetAsyncExc. " "This is dangerous and can corrupt locks/GIL/numpy arrays!" ) thread_id = thread.ident res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, ctypes.py_object(SystemExit)) if res > 1: ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0) Logger.error('Exception raise failure') def cooperative_shutdown( thread, bot=None, health_manager=None, death_manager=None, timeout=5.0, allow_force_kill=True, ): """ Cooperatively shut down a thread by signalling the owning objects, then join. Falls back to _force_kill_thread only if the thread is still alive after *timeout* seconds. This avoids the dangers of PyThreadState_SetAsyncExc (corrupted locks, GIL issues, numpy array corruption) in the common path. """ # Signal Bot to stop its run loop if bot is not None: bot.stop() # Signal HealthManager to stop its monitoring loop if health_manager is not None: health_manager.stop_monitor() # Signal DeathManager to stop its monitoring loop if death_manager is not None: death_manager.stop_monitor() # Wait for the thread to finish cooperatively thread.join(timeout=timeout) if thread.is_alive(): if not allow_force_kill: Logger.warning( f"Thread '{thread.name}' did not exit within {timeout}s; " "continuing without force-kill to avoid unsafe async exceptions." ) return Logger.warning( f"Thread '{thread.name}' did not exit within {timeout}s; " "falling back to force kill (PyThreadState_SetAsyncExc)." ) _force_kill_thread(thread) # Kept for backwards-compatibility so existing imports still work. # Calls the cooperative path when the owning objects are available; # otherwise falls back to force kill immediately. def kill_thread(thread): """ Backwards-compatibility wrapper. Prefer cooperative_shutdown() for new code. """ # We can't signal anything without the owning objects, so fall back # to force kill. Callers that own the bot/managers should use # cooperative_shutdown() instead. _force_kill_thread(thread) def cut_roi(img, roi): x, y, w, h = roi return img[y:y+h, x:x+w] def mask_by_roi(img, roi, type: str = "regular"): x, y, w, h = roi if type == "regular": masked = np.zeros(img.shape, dtype=np.uint8) masked[y:y+h, x:x+w] = img[y:y+h, x:x+w] elif type == "inverse": masked = cv2.rectangle(img, (x, y), (x+w, y+h), (0, 0, 0), -1) else: return None return masked def is_in_roi(roi: list[float], pos: tuple[float, float]): x, y, w, h = roi is_in_x_range = x < pos[0] < x + w is_in_y_range = y < pos[1] < y + h return is_in_x_range and is_in_y_range def trim_black(image): y_nonzero, x_nonzero = np.nonzero(image) roi = np.min(x_nonzero), np.min(y_nonzero), np.max(x_nonzero) - np.min(x_nonzero), np.max(y_nonzero) - np.min(y_nonzero) img = image[np.min(y_nonzero):np.max(y_nonzero), np.min(x_nonzero):np.max(x_nonzero)] return img, roi def erode_to_black(img: np.ndarray, threshold: int = 14): # Cleanup image with erosion image as marker with morphological reconstruction gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) thresh = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)[1] kernel = np.ones((3, 3), np.uint8) marker = thresh.copy() marker[1:-1, 1:-1] = 0 while True: tmp = marker.copy() marker = cv2.dilate(marker, kernel) marker = cv2.min(thresh, marker) difference = cv2.subtract(marker, tmp) if cv2.countNonZero(difference) <= 0: break mask_r = cv2.bitwise_not(marker) mask_color_r = cv2.cvtColor(mask_r, cv2.COLOR_GRAY2BGR) img = cv2.bitwise_and(img, mask_color_r) return img def roi_center(roi: list[float] = None): x, y, w, h = roi return round(x + w/2), round(y + h/2) def color_filter(img, color_range): color_ranges=[] # ex: [array([ -9, 201, 25]), array([ 9, 237, 61])] if color_range[0][0] < 0: lower_range = deepcopy(color_range) lower_range[0][0] = 0 color_ranges.append(lower_range) upper_range = deepcopy(color_range) upper_range[0][0] = 180 + color_range[0][0] upper_range[1][0] = 180 color_ranges.append(upper_range) # ex: [array([ 170, 201, 25]), array([ 188, 237, 61])] elif color_range[1][0] > 180: upper_range = deepcopy(color_range) upper_range[1][0] = 180 color_ranges.append(upper_range) lower_range = deepcopy(color_range) lower_range[0][0] = 0 lower_range[1][0] = color_range[1][0] - 180 color_ranges.append(lower_range) else: color_ranges.append(color_range) color_masks = [] for color_range in color_ranges: hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv_img, color_range[0], color_range[1]) color_masks.append(mask) color_mask = np.bitwise_or.reduce(color_masks) if len(color_masks) > 0 else color_masks[0] filtered_img = cv2.bitwise_and(img, img, mask=color_mask) return color_mask, filtered_img def hms(seconds: int): seconds = int(seconds) h = seconds // 3600 m = seconds % 3600 // 60 s = seconds % 3600 % 60 return '{:02d}:{:02d}:{:02d}'.format(h, m, s) def load_template(path): if os.path.isfile(path): try: template_img = cv2.imread(path, cv2.IMREAD_UNCHANGED) return template_img except Exception as e: print(e) raise ValueError(f"Could not load template: {path}") else: Logger.error(f"Template does not exist: {path}") return None def alpha_to_mask(img: np.ndarray): # create a mask from template where alpha == 0 if img.shape[2] == 4: if np.min(img[:, :, 3]) == 0: _, mask = cv2.threshold(img[:,:,3], 1, 255, cv2.THRESH_BINARY) return mask return None def list_files_in_folder(path: str): r = [] for root, _, files in os.walk(path): for name in files: r.append(os.path.join(root, name)) return r def rotate_vec(vec: np.ndarray, deg: float) -> np.ndarray: theta = np.deg2rad(deg) rot_matrix = np.array([[cos(theta), -sin(theta)], [sin(theta), cos(theta)]]) return np.dot(rot_matrix, vec) def unit_vector(vec: np.ndarray) -> np.ndarray: return vec / dist(vec, (0, 0)) def image_is_equal(img1: np.ndarray, img2: np.ndarray) -> bool: shape_equal = img1.shape == img2.shape if not shape_equal: Logger.debug("image_is_equal: Image shape is not equal") return False return not(np.bitwise_xor(img1, img2).any()) def arc_spread(cast_dir: tuple[float,float], spread_deg: float=10, radius_spread: tuple[float, float] = [.95, 1.05]): """ Given an x,y vec (target), generate a new target that is the same vector but rotated by +/- spread_deg/2 """ cast_dir = np.array(cast_dir) length = dist(cast_dir, (0, 0)) adj = (radius_spread[1] - radius_spread[0])*random.random() + radius_spread[0] rot = spread_deg*(random.random() - .5) return rotate_vec(unit_vector(cast_dir)*(length+adj), rot) @dataclass class BestMatchResult: match: str score: float score_normalized: float def find_best_match(in_str: str, str_list: list[str]) -> BestMatchResult: # extractOne maximizes its scorer — wrong for distance metrics (lower=better). # Use extract+min to correctly minimise Levenshtein distance. results = _rf_extract(in_str, str_list, scorer=_levenshtein_scorer, limit=None) best_match, best_lev, _ = min(results, key=lambda x: x[1]) best_lev_normalized = 1 - best_lev / max(1, len(in_str)) return BestMatchResult(best_match, best_lev, best_lev_normalized) def slugify(value, allow_unicode=False): """ Taken from https://github.com/django/django/blob/master/django/utils/text.py Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated dashes to single dashes. Remove characters that aren't alphanumerics, underscores, or hyphens. Convert to lowercase. Also strip leading and trailing whitespace, dashes, and underscores. """ value = str(value) if allow_unicode: value = unicodedata.normalize('NFKC', value) else: value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') value = re.sub(r'[^\w\s-]', '', value.lower()) return re.sub(r'[-\s]+', '-', value).strip('-_') def only_lowercase_letters(value): if not (x := ''.join(filter( lambda x: x in 'abcdefghijklmnopqrstuvwxyz', value ))): x = "botty" return x