add stealth system, key detection, click recorder, FOHdin, new routes, and tooling updates

This commit is contained in:
alex
2026-05-21 23:34:20 +02:00
parent 8dc677d2e6
commit 75f64351ee
84 changed files with 3202 additions and 415 deletions
+5 -4
View File
@@ -1,6 +1,6 @@
from transitions import Machine
import time
import keyboard
from input_layer import keyboard
import time
import os
import random
@@ -69,7 +69,7 @@ class Bot:
case "hammerdin" | "paladin":
self._char: IChar = Hammerdin(Config().hammerdin, self._pather, self._pickit) #pickit added for diablo
case "fohdin":
self._char: IChar = FoHdin(Config().fohdin, self._pather)
self._char: IChar = FoHdin(Config().fohdin, self._pather, self._pickit)
case "abyss_lock" | "warlock":
self._char: IChar = AbyssLock(Config().abyss_lock, self._pather)
case "fire_lock":
@@ -211,7 +211,8 @@ class Bot:
Logger.info(f"{Config().general['name']} is now pausing")
self._game_stats.pause_timer()
while self._pausing:
time.sleep(0.2)
from utils.misc import wait as _wait
_wait(0.2, 0.24)
if not self._stopping:
self.trigger(name, **kwargs)
@@ -352,7 +353,7 @@ class Bot:
if Config().char["runs_per_stash"]:
need_inspect |= (self._game_stats._run_counter - 1) % Config().char["runs_per_stash"] == 0
if need_inspect:
img = personal.open()
img = personal.open_inventory()
# Update TP, ID, key needs
if self._game_stats._game_counter == 1:
self._use_id_tome = common.tome_state(img, 'id')[0] is not None
+2 -2
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
from ui import skills
import time
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
from pather import Pather
from logger import Logger
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.amazon import Amazon
from logger import Logger
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
import template_finder
from pather import Pather
@@ -192,7 +192,7 @@ class Barbarian(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar,CharacterCapabilities
import template_finder
from pather import Pather
@@ -141,7 +141,7 @@ class Basic(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
@@ -189,7 +189,7 @@ class Basic_Ranged(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
import template_finder
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
+3 -3
View File
@@ -1,5 +1,5 @@
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
@@ -207,7 +207,7 @@ class Bone_Necro(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+4 -4
View File
@@ -4,12 +4,12 @@ import time
import cv2
import math
from item import consumables
import keyboard
from input_layer import keyboard
import numpy as np
from char.capabilities import CharacterCapabilities
from ui_manager import is_visible, wait_until_visible
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait, cut_roi, is_in_roi, color_filter, arc_spread
from logger import Logger
from config import Config
@@ -100,7 +100,7 @@ class IChar:
return False
else:
mouse.move(pos[0], pos[1])
time.sleep(0.1)
wait(0.1, 0.12)
mouse.click(button="left")
#Current logic only sets force_run if we previously teled.
@@ -454,7 +454,7 @@ class IChar:
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
print(f"Get on D2R screen and press F11 when ready")
keyboard.wait("f11")
+3 -3
View File
@@ -1,5 +1,5 @@
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from char import IChar
import template_finder
from template_finder import TemplateMatch
@@ -857,7 +857,7 @@ class Necro(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.paladin import Paladin
from logger import Logger
from config import Config
+3 -3
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
import random
import time
@@ -12,7 +12,7 @@ from pather import Pather, Location
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab
from target_detect import get_visible_targets
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
class Hammerdin(Paladin):
@@ -1325,7 +1325,7 @@ class Hammerdin(Paladin):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+2 -2
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
from ui import skills
import time
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
from pather import Pather
from logger import Logger
+3 -3
View File
@@ -1,5 +1,5 @@
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
@@ -541,7 +541,7 @@ class Poison_Necro(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait, rotate_vec, unit_vector
import random
@@ -256,7 +256,7 @@ class BlizzSorc(Sorceress):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
import template_finder
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
+2 -2
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait, rotate_vec, unit_vector
import random
+2 -2
View File
@@ -1,7 +1,7 @@
import time
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait
import random
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait, rotate_vec, unit_vector
import random
@@ -241,7 +241,7 @@ class LightSorc(Sorceress):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
+3 -3
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
import time
import numpy as np
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait
from pather import Location
@@ -100,7 +100,7 @@ class NovaSorc(Sorceress):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
+2 -2
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from typing import Callable
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar
from pather import Pather
from logger import Logger
@@ -237,7 +237,7 @@ class Trapsin(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+2 -2
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
import random
import time
@@ -12,7 +12,7 @@ from pather import Pather, Location
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab
from target_detect import get_visible_targets
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
class AbyssLock(Warlock):
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.warlock import Warlock
from logger import Logger
from config import Config
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.warlock import Warlock
from logger import Logger
from config import Config
+2 -2
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
from ui import skills
import time
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
from pather import Pather
from logger import Logger
+5 -3
View File
@@ -1,12 +1,13 @@
import time
import os
from pathlib import Path
from logger import Logger
import template_finder
from screen import grab
from char import IChar
from config import Config
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
from item import consumables
@@ -15,9 +16,10 @@ class Chest:
def __init__(self, char: IChar, template: str = None):
self._char = char
self._folder_name = "chests"
self._assets_path = Path(__file__).parent.parent / "assets"
# load all templates
self._templates = []
for filename in os.listdir(f'assets/{self._folder_name}/{template}'):
for filename in os.listdir(f'{self._assets_path}/{self._folder_name}/{template}'):
filename = filename.lower()
if filename.endswith('.png'):
chest = filename[:-4].upper()
@@ -60,7 +62,7 @@ class Chest:
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
print("Move to d2r window and press f11")
+1 -1
View File
@@ -2,7 +2,7 @@ from copy import deepcopy
from email.mime import base
import time
import cv2
import keyboard
from input_layer import keyboard
import os
import json
import screen
+1 -1
View File
@@ -48,7 +48,7 @@ def get_hovered_item(image: np.ndarray, model = "hover-eng_inconsolata_inv_th_fa
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, stop_detecting_window, grab
from d2r_image import processing as d2r_image
+1 -1
View File
@@ -625,7 +625,7 @@ def build_d2_items(items_by_quality: dict) -> GroundItemList | None:
return ground_item_list
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, grab, stop_detecting_window
start_detecting_window()
+4 -5
View File
@@ -1,8 +1,8 @@
from utils.misc import wait
from screen import grab
from config import Config
from utils.custom_mouse import mouse
import keyboard
from input_layer import mouse
from input_layer import keyboard
import cv2
from logger import Logger
import time
@@ -42,7 +42,6 @@ class DeathManager:
# first wait a bit to make sure health manager is done with its chicken stuff which obviously failed
if self._callback is not None:
self._callback()
self._callback = None
# clean up key presses that might be pressed
keyboard.release(Config().char["stand_still"])
wait(0.1, 0.2)
@@ -51,7 +50,7 @@ class DeathManager:
mouse.release(button="right")
wait(0.1, 0.2)
mouse.release(button="left")
time.sleep(1)
wait(1, 1.5)
if is_visible(ScreenObjects.MainMenu):
# in this case chicken executed and left the game, but we were still dead.
return True
@@ -66,7 +65,7 @@ class DeathManager:
Logger.info("Start Death monitoring")
while self._do_monitor:
if self._died: continue
time.sleep(self._loop_delay) # no need to do this too frequent, when we died we are not in a hurry...
wait(self._loop_delay, self._loop_delay * 1.5) # no need to do this too frequent, when we died we are not in a hurry...
# Wait until the flag is reset by main.py
if self._died: continue
self.handle_death_screen()
+37 -12
View File
@@ -1,7 +1,7 @@
import threading
import time
import cv2
import keyboard
from input_layer import keyboard
from utils.auto_settings import check_settings
from bot import Bot
@@ -14,7 +14,7 @@ from logger import Logger
from messages import Messenger
from screen import grab, get_offset_state
from utils.restart import restart_game, safe_exit
from utils.misc import kill_thread, set_d2r_always_on_top, restore_d2r_window_visibility
from utils.misc import kill_thread, cooperative_shutdown, set_d2r_always_on_top, restore_d2r_window_visibility, wait
class GameController:
@@ -25,7 +25,7 @@ class GameController:
self.death_manager = None
self.death_monitor_thread = None
self.game_recovery = None
self.game_stats = None
self.game_stats = GameStats()
self.game_controller_thread = None
self.bot_thread = None
self.bot = None
@@ -37,8 +37,8 @@ class GameController:
self.bot_thread.daemon = True
self.bot_thread.start()
# Register that thread to the death and health manager so they can stop the bot thread if needed
self.death_manager.set_callback(lambda: self.bot.stop() or kill_thread(self.bot_thread))
self.health_manager.set_callback(lambda: self.bot.stop() or kill_thread(self.bot_thread))
self.death_manager.set_callback(lambda: self.bot.stop())
self.health_manager.set_callback(lambda: self.bot.stop())
do_restart = False
messenger = Messenger()
force_stopped = False
@@ -58,7 +58,12 @@ class GameController:
self.game_stats.log_chicken(self.health_manager._last_chicken_screenshot)
self.bot._stash_mutex.acquire() #Grab mutex to ensure stashing is not occuring
self.bot.stop()
kill_thread(self.bot_thread)
cooperative_shutdown(
self.bot_thread,
bot=self.bot,
health_manager=self.health_manager,
death_manager=self.death_manager,
)
self.bot._stash_mutex.release()
# clean up key presses that might be pressed in the bot_thread after killing
keyboard.release(Config().char["stand_still"])
@@ -83,7 +88,7 @@ class GameController:
else:
do_restart = self.game_recovery.go_to_hero_selection()
break
time.sleep(0.5)
wait(0.5, 0.75)
self.bot_thread.join()
if do_restart:
# Reset flags before running a new bot
@@ -125,16 +130,36 @@ class GameController:
self.start_health_manager_thread()
self.start_death_manager_thread()
self.game_recovery = GameRecovery(self.death_manager)
self.game_stats = GameStats()
self.start_game_controller_thread()
self.is_running = True
def stop(self):
restore_d2r_window_visibility()
if self.death_monitor_thread: kill_thread(self.death_monitor_thread)
if self.health_monitor_thread: kill_thread(self.health_monitor_thread)
if self.bot_thread: kill_thread(self.bot_thread)
if self.game_controller_thread: kill_thread(self.game_controller_thread)
if self.death_monitor_thread:
cooperative_shutdown(
self.death_monitor_thread,
death_manager=self.death_manager,
)
if self.health_monitor_thread:
cooperative_shutdown(
self.health_monitor_thread,
health_manager=self.health_manager,
)
if self.bot_thread:
cooperative_shutdown(
self.bot_thread,
bot=self.bot,
health_manager=self.health_manager,
death_manager=self.death_manager,
)
if self.game_controller_thread:
# game_controller_thread runs run_bot() which checks self.bot._stopping
cooperative_shutdown(
self.game_controller_thread,
bot=self.bot,
health_manager=self.health_manager,
death_manager=self.death_manager,
)
self.is_running = False
def setup_screen(self):
+8 -7
View File
@@ -1,10 +1,11 @@
from config import Config
from death_manager import DeathManager
import time
import keyboard
from input_layer import keyboard
from ui_manager import ScreenObjects, is_visible
from ui import view, loading
from utils.misc import set_d2r_always_on_top
from utils.misc import wait
class GameRecovery:
def __init__(self, death_manager: DeathManager):
@@ -12,10 +13,10 @@ class GameRecovery:
def go_to_hero_selection(self):
set_d2r_always_on_top()
time.sleep(1)
wait(1, 1.5)
# clean up key presses that might be pressed in the run_thread
keyboard.release(Config().char["stand_still"])
time.sleep(0.1)
wait(0.1, 0.15)
keyboard.release(Config().char["show_items"])
start = time.time()
while (time.time() - start) < 30:
@@ -24,25 +25,25 @@ class GameRecovery:
while is_loading:
is_loading = is_visible(ScreenObjects.Loading)
is_loading |= loading.check_for_black_screen()
time.sleep(0.5)
wait(0.5, 0.75)
# lets just see if you might already be at hero selection
if is_visible(ScreenObjects.MainMenu):
return True
# would have been too easy, maybe we have died?
if self._death_manager.handle_death_screen():
time.sleep(1)
wait(1, 1.5)
continue
# if we are in game, save and exit
if is_visible(ScreenObjects.InGame):
view.fast_save_and_exit()
continue
time.sleep(1)
wait(1, 1.5)
return False
if __name__ == "__main__":
from death_manager import DeathManager
import keyboard
from input_layer import keyboard
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
+1 -1
View File
@@ -1,7 +1,7 @@
from game_stats import GameStats
from transmute import Transmute
import threading
import keyboard
from input_layer import keyboard
if __name__ == "__main__":
stats = GameStats()
+75 -34
View File
@@ -1,9 +1,10 @@
from inventory import belt
from pather import Location
import cv2
from threading import Lock
import time
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from utils.misc import wait
from logger import Logger
from screen import grab
@@ -14,33 +15,15 @@ from ui import view, meters
from ui_manager import ScreenObjects, is_visible
from random import uniform
pause_state = True
panel_check_paused = False
def get_pause_state():
return pause_state
def set_pause_state(state: bool):
global pause_state
prev = get_pause_state()
if prev != state:
debug_str = "paused" if state else "active"
Logger.info(f"Health Manager is now {debug_str}")
pause_state = state
def get_panel_check_paused():
return panel_check_paused
def set_panel_check_paused(state: bool):
global panel_check_paused
prev = get_panel_check_paused()
if prev != state:
debug_str = "pausing" if state else "activating"
Logger.info(f"Health Manager is now {debug_str} inventory panel check")
panel_check_paused = state
class HealthManager:
_instance = None
def __init__(self):
HealthManager._instance = self
self._state_lock = Lock()
self._pause_state = True
self._panel_check_paused = False
self._do_monitor = False
self._did_chicken = False
self._last_rejuv = time.time()
@@ -62,7 +45,31 @@ class HealthManager:
def reset_chicken_flag(self):
self._did_chicken = False
set_pause_state(True)
self.set_pause_state(True)
def get_pause_state(self):
with self._state_lock:
return self._pause_state
def set_pause_state(self, state: bool):
with self._state_lock:
prev = self._pause_state
if prev != state:
debug_str = "paused" if state else "active"
Logger.info(f"Health Manager is now {debug_str}")
self._pause_state = state
def get_panel_check_paused(self):
with self._state_lock:
return self._panel_check_paused
def set_panel_check_paused(self, state: bool):
with self._state_lock:
prev = self._panel_check_paused
if prev != state:
debug_str = "pausing" if state else "activating"
Logger.info(f"Health Manager is now {debug_str} inventory panel check")
self._panel_check_paused = state
def _do_chicken(self, img):
if self._callback is not None:
@@ -78,7 +85,7 @@ class HealthManager:
self._last_chicken_screenshot = "./log/screenshots/info/info_debug_chicken_" + time.strftime("%Y%m%d_%H%M%S") + ".png"
cv2.imwrite(self._last_chicken_screenshot, img)
self._did_chicken = True
set_pause_state(True)
self.set_pause_state(True)
def start_monitor(self):
Logger.info("Start health monitoring")
@@ -91,7 +98,7 @@ class HealthManager:
merc_hp_potion_delay = 10.24
while self._do_monitor:
if self._did_chicken or get_pause_state():
if self._did_chicken or self.get_pause_state():
wait(1)
continue
fn_start = time.perf_counter()
@@ -106,9 +113,11 @@ class HealthManager:
#It seems that hit recovery can delay the use of juvs and 15 frames is max recovery time.
#To delay two juvs being used back to back, we'll need to wait the recovery time between uses.
#15 frames is 0.60 seconds.
if last_drink > 0.60:
if last_drink > 0.60:
if (health_percentage <= Config().char["take_rejuv_potion_health"]) or \
(mana_percentage <= Config().char["take_rejuv_potion_mana"]):
# Simulate human reaction time (100-300ms)
wait(0.1, 0.3)
success_drink_rejuv = belt.drink_potion("rejuv", stats=[health_percentage, mana_percentage])
#failure to drink juv (likely out of juvs). Perform chicken
if not success_drink_rejuv:
@@ -134,11 +143,13 @@ class HealthManager:
# check health
last_drink = time.time() - self._last_health
if health_percentage <= Config().char["take_health_potion"] and last_drink > lp_hp_potion_delay:
wait(0.1, 0.3) # human reaction time
belt.drink_potion("health", stats=[health_percentage, mana_percentage])
self._last_health = time.time()
# check mana
last_drink = time.time() - self._last_mana
if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay:
wait(0.1, 0.3) # human reaction time
belt.drink_potion("mana", stats=[health_percentage, mana_percentage])
self._last_mana = time.time()
# check merc
@@ -151,12 +162,14 @@ class HealthManager:
self._do_chicken(img)
continue
if Config().char["heal_rejuv_merc"] and (merc_health_percentage <= Config().char["heal_rejuv_merc"] and last_drink > 4.0):
wait(0.1, 0.3) # human reaction time
belt.drink_potion("rejuv", merc=True, stats=[merc_health_percentage])
self._last_merc_heal = time.time()
elif Config().char["heal_merc"] and (merc_health_percentage <= Config().char["heal_merc"] and last_drink > merc_hp_potion_delay):
wait(0.1, 0.3) # human reaction time
belt.drink_potion("health", merc=True, stats=[merc_health_percentage])
self._last_merc_heal = time.time()
if not get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
Logger.warning(f"Found an open inventory / quest / skill / stats page. Close it.")
self._count_panel_detects += 1
if self._count_panel_detects >= 2:
@@ -166,16 +179,44 @@ class HealthManager:
continue
common.close()
fn_end = time.perf_counter()
wait_time = 3/25 - (fn_end - fn_start)
# Add timing jitter to avoid perfectly regular polling pattern (anti-cheat)
import random
base_wait = 3/25 - (fn_end - fn_start)
jitter = random.uniform(0.8, 1.2) # ±20% jitter
wait_time = max(0.0, base_wait * jitter)
if wait_time > 0:
wait(wait_time) # wait 3 frames before rechecking
wait(wait_time) # wait ~3 frames before rechecking
Logger.debug("Stop health monitoring")
# Backwards-compatible module-level functions that delegate to the singleton instance
def get_pause_state():
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
return HealthManager._instance.get_pause_state()
return True
def set_pause_state(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
HealthManager._instance.set_pause_state(state)
def get_panel_check_paused():
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
return HealthManager._instance.get_panel_check_paused()
return False
def set_panel_check_paused(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
HealthManager._instance.set_panel_check_paused(state)
# Testing: Start dying or losing mana and see if it works
if __name__ == "__main__":
import threading
import keyboard
from input_layer import keyboard
import os
from health_manager import set_pause_state
from screen import start_detecting_window, stop_detecting_window, grab
+205
View File
@@ -0,0 +1,205 @@
"""
Native input layer - replaces `keyboard` and `mouse` (pyclick) libraries.
No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kernel32).
Import as:
from input_layer import keyboard, mouse
This is a drop-in replacement for the existing `import keyboard` and
`from utils.custom_mouse import mouse` patterns.
"""
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP
)
from .mouse_impl import mouse
class _Keyboard:
"""
Drop-in replacement for the `keyboard` library.
Supports all patterns used in botty: send(), press(), release(), is_pressed(),
add_hotkey(), wait(), write(), hook(), pause().
All key presses include stealth micro-pauses and variable press duration
automatically - no changes to existing code needed.
"""
def _stealth_before(self):
"""Add micro-pause before key press (stealth)."""
try:
from utils.stealth import add_micro_pause
add_micro_pause()
except Exception:
pass
def _stealth_after(self):
"""Add micro-pause after key press (stealth)."""
try:
from utils.stealth import add_micro_pause
add_micro_pause()
except Exception:
pass
def _stealth_duration(self):
"""Get human-like key press duration."""
try:
from utils.stealth import key_press_duration
return key_press_duration()
except Exception:
import random
return random.uniform(0.02, 0.15)
def _skill_rotation_hesitation(self):
"""Add hesitation before skill casts (Tier 2 behavior stealth)."""
try:
from utils.stealth import skill_rotation_hesitation
from utils.misc import wait as _wait
_wait(skill_rotation_hesitation(), skill_rotation_hesitation() * 1.2)
except Exception:
import random
from utils.misc import wait as _wait
_wait(random.uniform(0.08, 0.3), random.uniform(0.08, 0.3) * 1.2)
def _maybe_skill_mistake(self, key: str):
"""
1-2% chance of pressing a random skill key first before the intended one
(Tier 2 behavior stealth - simulates miscasting).
Returns True if a mistake was made and corrected.
"""
try:
from utils.stealth import should_correct_skill_mistake
if not should_correct_skill_mistake():
return False
except Exception:
return False
# Press a random skill key first (simulates miscast)
import random
try:
from config import Config
skill_keys = list(range(ord('1'), ord('0') + 1)) # Keys 1-0
wrong_key = chr(random.choice(skill_keys))
if wrong_key == key:
wrong_key = chr(random.choice(skill_keys))
except Exception:
wrong_key = random.choice(['1', '2', '3', '4', '5'])
# Send the wrong key
wrong_vk = _get_vk(wrong_key)
if wrong_vk is not None:
key_down(wrong_vk)
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
key_up(wrong_vk)
return True
def send(self, key: str, do_press: bool = True, do_release: bool = True, delay=None):
"""
Send a key press event with stealth timing.
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
Supports do_release=False (hold key) and do_press=False (release-only).
"""
self._stealth_before()
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
if ' + ' in key or '+' in key:
# Parse combo
parts = [p.strip() for p in key.replace('+', ' + ').split(' + ') if p.strip()]
modifiers = []
final_key = parts[-1]
for p in parts[:-1]:
vk = _get_vk(p)
if vk is not None:
modifiers.append(vk)
vk = _get_vk(final_key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
if do_press:
for mvk in modifiers:
key_down(mvk)
key_down(vk)
if do_release:
if do_press:
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
key_up(vk)
for mvk in reversed(modifiers):
key_up(mvk)
self._stealth_after()
return
# Single key
vk = _get_vk(key)
if vk is None:
# Try as a single character
if len(key) == 1:
vk = ord(key.upper()) if key.isalpha() else ord(key)
else:
raise ValueError(f"Unknown key: {key}")
if delay is not None:
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
# Tier 2 stealth: skill hesitation (only for skill hotkeys 1-0)
if vk in range(ord('1'), ord('0') + 1) or key in ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0'):
self._skill_rotation_hesitation()
self._maybe_skill_mistake(key)
if do_press:
key_down(vk)
if do_release:
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
if do_release:
key_up(vk)
self._stealth_after()
def press(self, key: str):
"""Press a key down (without releasing)."""
self.send(key, do_press=True, do_release=False)
def release(self, key: str):
"""Release a key (without pressing)."""
self.send(key, do_press=False, do_release=True)
def is_pressed(self, key: str) -> bool:
"""Check if a key is currently pressed."""
vk = _get_vk(key)
if vk is None:
return False
return key_state(key)
def add_hotkey(self, key: str, callback, suppress: bool = False):
"""Register a global hotkey callback."""
from .hotkey import add_hotkey as _add_hotkey
_add_hotkey(key, callback, suppress=suppress)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
from .hotkey import wait as _wait
return _wait(key, suppress=suppress)
def write(self, text: str, delay: float = 0.05):
"""Type text character by character."""
send_text(text, delay=delay)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events (dev tools only)."""
from .hotkey import hook as _hook
return _hook(callback, suppress=suppress)
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing (used by npc_auto_label.py)."""
from .hotkey import pause as _pause
return _pause(seconds, suppress)
# Singleton keyboard object - matches the `keyboard` module API
keyboard = _Keyboard()
# Exports for convenience
__all__ = ["keyboard", "mouse"]
+173
View File
@@ -0,0 +1,173 @@
"""
Global hotkey polling via GetAsyncKeyState.
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
No kernel driver - pure user-mode polling thread.
"""
import threading
import time
import ctypes
from ctypes import wintypes
from .win_input import _get_vk, VK_MAP, user32
class _HotkeyManager:
def __init__(self):
self._callbacks = {} # vk -> [(key_str, callback), ...]
self._running = False
self._thread = None
self._suppress = {} # vk -> bool (suppress key after callback fires)
self._lock = threading.Lock()
self._suppressed = set() # vks currently being held down in suppress mode
def _ensure_running(self):
if not self._running:
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def _poll_loop(self):
"""Poll GetAsyncKeyState for registered hotkeys."""
while self._running:
with self._lock:
items = list(self._callbacks.items())
for vk, entries in items:
for key_str, cb in entries:
if vk in self._suppressed:
# Key already held and suppressed
continue
state = user32.GetAsyncKeyState(vk)
if state & 0x8000: # key is down
try:
cb()
except Exception:
pass
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Wait for key release if suppressed
if self._suppressed:
still_suppressed = set()
for vk in self._suppressed:
state = user32.GetAsyncKeyState(vk)
if not (state & 0x8000):
# Key released
pass
else:
still_suppressed.add(vk)
self._suppressed = still_suppressed
from utils.misc import wait as _wait
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
def add_hotkey(self, key: str, callback, suppress: bool = False):
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key for hotkey: {key}")
with self._lock:
if vk not in self._callbacks:
self._callbacks[vk] = []
self._callbacks[vk].append((key, callback))
self._suppress[vk] = suppress
self._ensure_running()
def remove_hotkey(self, key: str, callback=None):
vk = _get_vk(key)
if vk is None:
return
with self._lock:
if vk in self._callbacks:
if callback is None:
del self._callbacks[vk]
else:
self._callbacks[vk] = [
(k, cb) for k, cb in self._callbacks[vk] if cb != callback
]
if not self._callbacks[vk]:
del self._callbacks[vk]
if not any(vk in d for d in [self._callbacks, self._suppress]):
self._suppress.pop(vk, None)
def is_pressed(self, key: str) -> bool:
vk = _get_vk(key)
if vk is None:
return False
state = user32.GetAsyncKeyState(vk)
return bool(state & 0x8000)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
if key is not None:
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
while True:
state = user32.GetAsyncKeyState(vk)
if state & 0x8000:
if suppress:
while user32.GetAsyncKeyState(vk) & 0x8000:
from utils.misc import wait as _wait
_wait(0.008, 0.012)
return
from utils.misc import wait as _wait
_wait(0.018, 0.024)
else:
# Wait for any key
while True:
for vk in range(1, 256):
if user32.GetAsyncKeyState(vk) & 0x8000:
return vk
from utils.misc import wait as _wait
_wait(0.018, 0.024)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events.
This is a simplified version - polls all known keys and calls callback.
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
def _poll_all():
while self._running:
for vk in range(1, 256):
state = user32.GetAsyncKeyState(vk)
if state & 0x8000:
event = {"event_type": "down", "name": None, "scan_code": 0}
try:
callback(event)
except Exception:
pass
if suppress:
while user32.GetAsyncKeyState(vk) & 0x8000:
from utils.misc import wait as _wait
_wait(0.01, 0.01)
break
from utils.misc import wait as _wait
_wait(0.02, 0.02)
self._ensure_running()
# Run hook in its own thread
t = threading.Thread(target=_poll_all, daemon=True)
t.start()
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing for a duration. Used by npc_auto_label.py."""
from utils.misc import wait as _wait
_wait(seconds, seconds)
# Singleton
_hotkey_manager = _HotkeyManager()
def add_hotkey(key: str, callback, suppress: bool = False):
_hotkey_manager.add_hotkey(key, callback, suppress=suppress)
def remove_hotkey(key: str, callback=None):
_hotkey_manager.remove_hotkey(key, callback)
def is_pressed(key: str) -> bool:
_hotkey_manager._ensure_running()
return _hotkey_manager.is_pressed(key)
def wait(key: str = None, suppress: bool = False):
_hotkey_manager._ensure_running()
return _hotkey_manager.wait(key, suppress=suppress)
def hook(callback, suppress: bool = False):
return _hotkey_manager.hook(callback, suppress=suppress)
def pause(seconds: float = 0, suppress: bool = False):
return _hotkey_manager.pause(seconds, suppress)
@@ -1,26 +1,20 @@
# Mostly copied from: https://github.com/patrikoss/pyclick
import mouse as _mouse
import os
if os.name == 'nt':
from mouse import _winmouse
else:
# Linux stub — _winmouse is only used in _move_to() which wraps calls in os.name checks
class _winmouse:
@staticmethod
def move_to(x, y):
_mouse.move(x, y)
import pytweening
import numpy as np
import random
"""
Human-like mouse movement with native Windows API.
Replaces custom_mouse.py (which used the `mouse` / pyclick library with mousetool.dll).
Same public API - drop-in replacement.
"""
import math
import random
import time
import threading
from concurrent.futures import Future
import screen
from config import Config
from utils.misc import is_in_roi
from logger import Logger
import template_finder
import numpy as np
import pytweening
from .win_input import mouse_move as _native_move, mouse_click as _native_click
from .win_input import mouse_down as _native_down, mouse_up as _native_up
from .win_input import get_cursor_pos as _native_get_pos
from .win_input import mouse_wheel as _native_wheel
def isNumeric(val):
return isinstance(val, (float, int, np.int32, np.int64, np.float32, np.float64))
@@ -31,26 +25,20 @@ def isListOfPoints(l):
try:
isPoint = lambda p: ((len(p) == 2) and isNumeric(p[0]) and isNumeric(p[1]))
return all(map(isPoint, l))
except (KeyError, TypeError) as e:
except (KeyError, TypeError):
return False
class BezierCurve():
@staticmethod
def binomial(n, k):
"""Returns the binomial coefficient "n choose k" """
return math.factorial(n) / float(math.factorial(k) * math.factorial(n - k))
@staticmethod
def bernsteinPolynomialPoint(x, i, n):
"""Calculate the i-th component of a bernstein polynomial of degree n"""
return BezierCurve.binomial(n, i) * (x ** i) * ((1 - x) ** (n - i))
@staticmethod
def bernsteinPolynomial(points):
"""
Given list of control points, returns a function, which given a point [0,1] returns
a point in the bezier curve described by these points
"""
def bern(t):
n = len(points) - 1
x = y = 0
@@ -63,10 +51,6 @@ class BezierCurve():
@staticmethod
def curvePoints(n, points):
"""
Given list of control points, returns n points in the bezier curve,
described by these points
"""
curvePoints = []
bernstein_polynomial = BezierCurve.bernsteinPolynomial(points)
for i in range(n):
@@ -75,22 +59,12 @@ class BezierCurve():
return curvePoints
class HumanCurve():
"""
Generates a human-like mouse curve starting at given source point,
and finishing in a given destination point
"""
def __init__(self, fromPoint, toPoint, **kwargs):
self.fromPoint = fromPoint
self.toPoint = toPoint
self.points = self.generateCurve(**kwargs)
def generateCurve(self, **kwargs):
"""
Generates a curve according to the parameters specified below.
You can override any of the below parameters. If no parameter is
passed, the default value is used.
"""
offsetBoundaryX = kwargs.get("offsetBoundaryX", 100)
offsetBoundaryY = kwargs.get("offsetBoundaryY", 100)
leftBoundary = kwargs.get("leftBoundary", min(self.fromPoint[0], self.toPoint[0])) - offsetBoundaryX
@@ -104,23 +78,15 @@ class HumanCurve():
tween = kwargs.get("tweening", pytweening.easeOutQuad)
targetPoints = kwargs.get("targetPoints", 10)
internalKnots = self.generateInternalKnots(leftBoundary,rightBoundary, \
internalKnots = self.generateInternalKnots(leftBoundary, rightBoundary,
downBoundary, upBoundary, knotsCount)
points = self.generatePoints(internalKnots)
points = self.distortPoints(points, distortionMean, distortionStdev, distortionFrequency)
points = self.tweenPoints(points, tween, targetPoints)
return points
def generateInternalKnots(self, \
leftBoundary, rightBoundary, \
downBoundary, upBoundary,\
knotsCount):
"""
Generates the internal knots used during generation of bezier curvePoints
or any interpolation function. The points are taken at random from
a surface delimited by given boundaries.
Exactly knotsCount internal knots are randomly generated.
"""
def generateInternalKnots(self, leftBoundary, rightBoundary,
downBoundary, upBoundary, knotsCount):
if not (isNumeric(leftBoundary) and isNumeric(rightBoundary) and
isNumeric(downBoundary) and isNumeric(upBoundary)):
raise ValueError("Boundaries must be numeric")
@@ -133,32 +99,20 @@ class HumanCurve():
knotsX = np.random.choice(range(leftBoundary, rightBoundary), size=knotsCount)
knotsY = np.random.choice(range(downBoundary, upBoundary), size=knotsCount)
knots = list(zip(knotsX, knotsY))
return knots
return list(zip(knotsX, knotsY))
def generatePoints(self, knots):
"""
Generates bezier curve points on a curve, according to the internal
knots passed as parameter.
"""
if not isListOfPoints(knots):
raise ValueError("knots must be valid list of points")
midPtsCnt = max( \
abs(self.fromPoint[0] - self.toPoint[0]), \
abs(self.fromPoint[1] - self.toPoint[1]), \
midPtsCnt = max(
abs(self.fromPoint[0] - self.toPoint[0]),
abs(self.fromPoint[1] - self.toPoint[1]),
2)
knots = [self.fromPoint] + knots + [self.toPoint]
return BezierCurve.curvePoints(midPtsCnt, knots)
def distortPoints(self, points, distortionMean, distortionStdev, distortionFrequency):
"""
Distorts the curve described by (x,y) points, so that the curve is
not ideally smooth.
Distortion happens by randomly, according to normal distribution,
adding an offset to some of the points.
"""
if not(isNumeric(distortionMean) and isNumeric(distortionStdev) and \
if not (isNumeric(distortionMean) and isNumeric(distortionStdev) and
isNumeric(distortionFrequency)):
raise ValueError("Distortions must be numeric")
if not isListOfPoints(points):
@@ -167,78 +121,41 @@ class HumanCurve():
raise ValueError("distortionFrequency must be in range [0,1]")
distorted = []
for i in range(1, len(points)-1):
x,y = points[i]
for i in range(1, len(points) - 1):
x, y = points[i]
delta = np.random.normal(distortionMean, distortionStdev) if \
random.random() < distortionFrequency else 0
distorted += (x,y+delta),
distorted += (x, y + delta),
distorted = [points[0]] + distorted + [points[-1]]
return distorted
def tweenPoints(self, points, tween, targetPoints):
"""
Chooses a number of points(targetPoints) from the list(points)
according to tweening function(tween).
This function in fact controls the velocity of mouse movement
"""
if not isListOfPoints(points):
raise ValueError("points must be valid list of points")
if not isinstance(targetPoints, int) or targetPoints < 2:
raise ValueError("targetPoints must be an integer greater or equal to 2")
# tween is a function that takes a float 0..1 and returns a float 0..1
res = []
for i in range(targetPoints):
index = int(tween(float(i)/(targetPoints-1)) * (len(points)-1))
index = int(tween(float(i) / (targetPoints - 1)) * (len(points) - 1))
res += points[index],
return res
class mouse:
@staticmethod
def sleep(duration, get_now=time.perf_counter):
time.sleep(duration)
# now = get_now()
# end = now + duration
# while now < end:
# now = get_now()
"""
Drop-in replacement for the `mouse` (pyclick) library.
Same public API as custom_mouse.py, but uses native SendInput instead of mousetool.dll.
"""
@staticmethod
def _move_to(x, y, absolute=True, duration=0):
"""
Moves the mouse. If `absolute`, to position (x, y), otherwise move relative
to the current position. If `duration` is non-zero, animates the movement.
"""
x = int(x)
y = int(y)
def sleep(duration):
from utils.misc import wait as _wait
_wait(duration, duration * 1.2)
# Requires an extra system call on Linux, but `move_relative` is measured
# in millimiters so we would lose precision.
position_x, position_y = _mouse.get_position()
if not absolute:
x = position_x + x
y = position_y + y
if duration:
start_x = position_x
start_y = position_y
dx = x - start_x
dy = y - start_y
if dx == 0 and dy == 0:
mouse.sleep(duration)
else:
# 120 movements per second.
# Round and keep float to ensure float division in Python 2
steps = max(1.0, float(int(duration * 120.0)))
for i in range(int(steps)+1):
mouse.move(start_x + dx*i/steps, start_y + dy*i/steps)
mouse.sleep(duration/steps)
else:
_winmouse.move_to(x, y)
def move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5, delay_factor: tuple[float, float] = [0.4, 0.6]):
from_point = _mouse.get_position()
@staticmethod
def move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
from_point = _native_get_pos()
dist = math.dist((x, y), from_point)
offsetBoundaryX = max(10, int(0.08 * dist))
offsetBoundaryY = max(10, int(0.08 * dist))
@@ -260,11 +177,11 @@ class mouse:
# Apply human curve complexity from stealth config
try:
from config import Config
complexity = Config().stealth.get("human_curve_complexity", 1.0)
except Exception:
complexity = 1.0
# Scale distortion parameters based on complexity
distortionMean = 1 * complexity
distortionStdev = 1 * complexity
distortionFreq = min(0.8, 0.4 * complexity)
@@ -283,88 +200,95 @@ class mouse:
delta = duration / len(human_curve.points)
for point in human_curve.points:
_mouse.move(point[0], point[1], duration=delta)
_native_move(int(point[0]), int(point[1]))
mouse.sleep(delta)
@staticmethod
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5, delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause → randomized position → endpoint wobble."""
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause -> randomized position -> endpoint wobble."""
try:
from utils.stealth import randomize_click_position, add_micro_pause, endpoint_wobble
rx, ry = randomize_click_position(x, y)
add_micro_pause()
except Exception:
from logger import Logger
Logger.warning("[Stealth] randomize_click_position/add_micro_pause failed, using manual fallback")
try:
from config import Config
variance = Config().stealth["click_variance"]
except Exception:
variance = 0
rx = x + random.randint(-variance, variance)
ry = y + random.randint(-variance, variance)
mouse.move(rx, ry, absolute=absolute, randomize=5 + variance, delay_factor=delay_factor)
# Endpoint wobble: micro-adjustment after arrival, before click
try:
from utils.stealth import endpoint_wobble
wx, wy = endpoint_wobble(rx, ry)
# Nudge mouse a few pixels — imperceptible to the eye, breaks perfect stillness
_mouse.move(wx, wy)
_native_move(wx, wy)
except Exception:
from logger import Logger
Logger.warning("[Stealth] endpoint_wobble failed, skipping micro-adjustment")
pass
@staticmethod
def _is_clicking_safe():
# Because of reports that botty lost equiped items, let's check if the inventory is open, and if it is, restrict the mouse move
mouse_pos = screen.convert_monitor_to_screen(_mouse.get_position())
is_inventory_open = template_finder.search(
"INVENTORY_GOLD_BTN",
screen.grab(),
threshold=0.8,
roi=Config().ui_roi["gold_btn"],
use_grayscale=True
).valid
if is_inventory_open:
is_in_equipped_area = is_in_roi(Config().ui_roi["equipped_inventory_area"], mouse_pos)
is_in_restricted_inventory_area = is_in_roi(Config().ui_roi["restricted_inventory_area"], mouse_pos)
if is_in_restricted_inventory_area or is_in_equipped_area:
Logger.error("Mouse wants to click in equipped area. Cancel action.")
return False
try:
import screen
from config import Config
from utils.misc import is_in_roi
import template_finder
mouse_pos = screen.convert_monitor_to_screen(_native_get_pos())
is_inventory_open = template_finder.search(
"INVENTORY_GOLD_BTN",
screen.grab(),
threshold=0.8,
roi=Config().ui_roi["gold_btn"],
use_grayscale=True
).valid
if is_inventory_open:
is_in_equipped_area = is_in_roi(Config().ui_roi["equipped_inventory_area"], mouse_pos)
is_in_restricted_inventory_area = is_in_roi(Config().ui_roi["restricted_inventory_area"], mouse_pos)
if is_in_restricted_inventory_area or is_in_equipped_area:
from logger import Logger
Logger.error("Mouse wants to click in equipped area. Cancel action.")
return False
except Exception:
pass
return True
@staticmethod
def click(button):
if button != "left" or mouse._is_clicking_safe():
# Human arrival-to-click delay: 50-800ms before actually pressing
try:
from utils.stealth import apply_click_delay
apply_click_delay()
except Exception:
time.sleep(random.uniform(0.05, 0.3))
_mouse.click(button)
from logger import Logger
Logger.warning("[Stealth] apply_click_delay failed, falling back to manual delay")
from utils.misc import wait as _wait
_wait(0.05, 0.3)
_native_click(button)
@staticmethod
def press(button):
if button != "left" or mouse._is_clicking_safe():
_mouse.press(button)
_native_down(button)
@staticmethod
def release(button):
_mouse.release(button)
_native_up(button)
@staticmethod
def get_position():
return _mouse.get_position()
return _native_get_pos()
@staticmethod
def wheel(delta):
_mouse.wheel(delta)
_native_wheel(delta)
@staticmethod
def async_move(x, y, absolute=True, randomize=5, delay_factor=[0.4, 0.6]):
"""
Non-blocking mouse move. Returns immediately with a Future-like object.
:return: A dict with:
- 'done()': callable returning bool
- 'wait(timeout=None)': blocks until move completes or timeout
"""
result = {"_done": False, "_lock": threading.Lock()}
def _run():
@@ -385,26 +309,14 @@ class mouse:
while not done():
if deadline is not None and time.monotonic() >= deadline:
return False
time.sleep(0.01)
from utils.misc import wait as _wait
_wait(0.01, 0.012)
return True
return {"done": done, "wait": wait}
if __name__ == "__main__":
import os
import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
screen.find_and_set_window_position()
move_to_ok = screen.convert_screen_to_monitor((400, 420))
move_to_bad_equiped = screen.convert_screen_to_monitor((900, 170))
move_to_bad_inventory = screen.convert_screen_to_monitor((1200, 400))
mouse.move(*move_to_ok)
mouse.click("left")
time.sleep(1)
mouse.move(*move_to_bad_equiped)
mouse.click("left")
time.sleep(1)
mouse.move(*move_to_bad_inventory)
mouse.click("left")
print("Mouse module loaded OK (native SendInput, no mousetool.dll)")
pos = mouse.get_position()
print(f"Cursor at: {pos}")
+317
View File
@@ -0,0 +1,317 @@
"""
Native Windows input via ctypes SendInput API.
No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kernel32).
"""
import ctypes
import time
import struct
from ctypes import wintypes
# ─── user32 constants ───
# INPUT_TYPE
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
INPUT_HARDWARE = 2
# MOUSEEVENTF flags
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
MOUSEEVENTF_XDOWN = 0x0080
MOUSEEVENTF_XUP = 0x0100
MOUSEEVENTF_WHEEL = 0x0800
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_HWHEEL = 0x01000
# KEYEVENTF flags
KEYEVENTF_EXTENDEDKEY = 0x0001
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_UNICODE = 0x0004
KEYEVENTF_SCANCODE = 0x0008
# ─── structs ───
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class HARDWAREINPUT(ctypes.Structure):
_fields_ = [
("uMsg", wintypes.DWORD),
("wParamL", wintypes.WORD),
("wParamH", wintypes.WORD),
]
class INPUTUNION(ctypes.Union):
_fields_ = [
("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
("hi", HARDWAREINPUT),
]
class INPUT(ctypes.Structure):
_fields_ = [
("type", wintypes.DWORD),
("union", INPUTUNION),
]
INPUT_ARRAY = ctypes.ARRAY(INPUT, 64)
# ─── load user32 ───
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
user32.SendInput.restype = wintypes.UINT
user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
user32.GetAsyncKeyState.restype = wintypes.SHORT
user32.GetAsyncKeyState.argtypes = [wintypes.WORD]
user32.GetCursorPos.restype = wintypes.BOOL
user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)]
user32.GetSystemMetrics.restype = wintypes.INT
user32.GetSystemMetrics.argtypes = [wintypes.INT]
user32.MapVirtualKeyW.restype = wintypes.UINT
user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
# ─── VK code mapping ───
# Extended keys (require KEYEVENTF_EXTENDEDKEY flag)
EXTENDED_KEYS = {
0x27, # numpad /
0x91, # right ctrl
0x9A, # right shift
0xB5, # numpad *
0xB8, # right alt
0xB9, # right win
0xBA, # apps key (menu)
0xC1, # numpad enter
0xC7, # numpad .
0xC8, # snap
}
# Common key name -> VK code
VK_MAP = {
"left": 0x25, "right": 0x27, "up": 0x26, "down": 0x28,
"enter": 0x0D, "return": 0x0D, "space": 0x20, "escape": 0x1B, "esc": 0x1B,
"backspace": 0x08, "tab": 0x09, "delete": 0x2E, "end": 0x23, "home": 0x24,
"insert": 0x2D, "pageup": 0x21, "pagedown": 0x22,
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,
"f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,
"f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"shift": 0xA0, "left shift": 0xA0, "right shift": 0xA1,
"ctrl": 0xA2, "left ctrl": 0xA2, "right ctrl": 0xA3,
"lctrl": 0xA2, "rctrl": 0xA3, "ctrl_l": 0xA2, "ctrl_r": 0xA3,
"alt": 0xA4, "left alt": 0xA4, "right alt": 0xA5,
"lalt": 0xA4, "ralt": 0xA5, "alt_l": 0xA4, "alt_r": 0xA5,
"caps lock": 0x14, "capslock": 0x14,
"num lock": 0x90, "numlock": 0x90,
"scroll lock": 0x91, "scrolllock": 0x91,
"print screen": 0x2C, "printscr": 0x2C,
"pause": 0x13,
"lwin": 0x5B, "rwin": 0x5C, "win": 0x5B,
"apps": 0x5D,
"numpad0": 0x60, "numpad1": 0x61, "numpad2": 0x62,
"numpad3": 0x63, "numpad4": 0x64, "numpad5": 0x65,
"numpad6": 0x66, "numpad7": 0x67, "numpad8": 0x68,
"numpad9": 0x69,
"numpad.": 0x6E, "numpad/": 0x6F,
"numpad*": 0x6B, "numpad+": 0x6C,
"numpad-": 0x6D,
"volume_up": 0xAF, "volume_down": 0xAE, "volume_mute": 0xAD,
"media_next": 0xB0, "media_prev": 0xB1, "media_stop": 0xB2, "media_play_pause": 0xB3,
"back": 0xA6, "forward": 0xA7,
}
# ─── core input functions ───
def _send_input(inp: INPUT):
"""Send a single INPUT event."""
arr = INPUT_ARRAY()
arr[0] = inp
result = user32.SendInput(1, arr, ctypes.sizeof(INPUT))
if result != 1:
raise RuntimeError(f"SendInput failed, returned {result}")
def _make_keyboard_input(vk: int, flags: int = 0, scan: int = 0):
"""Create a KEYBDINPUT struct."""
i = INPUT()
i.type = INPUT_KEYBOARD
i.union.ki.wVk = vk
i.union.ki.wScan = scan
i.union.ki.dwFlags = flags
i.union.ki.dwExtraInfo = None
return i
def _make_mouse_input(flags: int, dx: int = 0, dy: int = 0, data: int = 0):
"""Create a MOUSEINPUT struct."""
i = INPUT()
i.type = INPUT_MOUSE
i.union.mi.dx = dx
i.union.mi.dy = dy
i.union.mi.mouseData = data
i.union.mi.dwFlags = flags | MOUSEEVENTF_ABSOLUTE
i.union.mi.dwExtraInfo = None
return i
def _get_vk(key_name: str):
"""Resolve a key name to its VK code."""
key_name = key_name.strip().lower()
if key_name in VK_MAP:
return VK_MAP[key_name]
# Single character
if len(key_name) == 1:
vk = ord(key_name)
# Digit keys 0-9 map to VK_0 (0x0B) through VK_9 (0x13)
if '0' <= key_name <= '9':
return 0x0B + ord(key_name) - ord('0')
# Letter keys map directly to their ASCII value (uppercase)
if 'a' <= key_name <= 'z':
return ord(key_name.upper())
return vk
# Try pywin32 style (vk_key)
return None
def _is_extended(vk: int) -> bool:
return vk in EXTENDED_KEYS
def _get_scan(vk: int) -> int:
"""Get scan code for a VK."""
return user32.MapVirtualKeyW(vk, 0)
def key_down(vk: int):
"""Press a key down."""
extended = KEYEVENTF_EXTENDEDKEY if _is_extended(vk) else 0
scan = _get_scan(vk) if vk else 0
_send_input(_make_keyboard_input(vk, extended, scan))
def key_up(vk: int):
"""Release a key."""
extended = KEYEVENTF_EXTENDEDKEY if _is_extended(vk) else 0
scan = _get_scan(vk) if vk else 0
_send_input(_make_keyboard_input(vk, extended | KEYEVENTF_KEYUP, scan))
def key_press(vk: int):
"""Press and release a key."""
key_down(vk)
key_up(vk)
def send_key(key: str, down=True, up=True):
"""Press and/or release a key by name."""
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
if down:
key_down(vk)
if up:
key_up(vk)
def key_state(key: str) -> bool:
"""Check if a key is currently pressed (via GetAsyncKeyState)."""
vk = _get_vk(key)
if vk is None:
return False
state = user32.GetAsyncKeyState(vk)
return bool(state & 0x8000)
# ─── mouse ───
def get_screen_size():
"""Get screen dimensions for absolute mouse positioning."""
return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
def mouse_move(x: int, y: int):
"""Move mouse to absolute screen position (uses absolute SendInput)."""
screen_w, screen_h = get_screen_size()
# SendInput expects normalized absolute coordinates 0-65535
norm_x = int(x * 65535 / screen_w)
norm_y = int(y * 65535 / screen_h)
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, norm_x, norm_y))
def mouse_down(button: str = "left"):
"""Press mouse button down."""
if button == "left":
_send_input(_make_mouse_input(MOUSEEVENTF_LEFTDOWN))
elif button == "right":
_send_input(_make_mouse_input(MOUSEEVENTF_RIGHTDOWN))
elif button == "middle":
_send_input(_make_mouse_input(MOUSEEVENTF_MIDDLEDOWN))
else:
raise ValueError(f"Unknown button: {button}")
def mouse_up(button: str = "left"):
"""Release mouse button."""
if button == "left":
_send_input(_make_mouse_input(MOUSEEVENTF_LEFTUP))
elif button == "right":
_send_input(_make_mouse_input(MOUSEEVENTF_RIGHTUP))
elif button == "middle":
_send_input(_make_mouse_input(MOUSEEVENTF_MIDDLEUP))
else:
raise ValueError(f"Unknown button: {button}")
def mouse_click(button: str = "left"):
"""Click (press + release) a mouse button."""
mouse_down(button)
mouse_up(button)
def mouse_wheel(delta: int):
"""Scroll mouse wheel (positive = up, negative = down).
Delta is in "clicks" — each click is 120 units."""
wheel_delta = delta * 120
_send_input(_make_mouse_input(MOUSEEVENTF_WHEEL, 0, 0, wheel_delta))
def get_cursor_pos():
"""Get cursor position as (x, y)."""
from ctypes import wintypes as wt
p = wt.POINT()
if user32.GetCursorPos(ctypes.byref(p)):
return (p.x, p.y)
return (0, 0)
# ─── text input ───
def send_text(text: str, delay: float = 0.05):
"""Send text character by character."""
for ch in text:
vk = _get_vk(ch)
if vk is None:
continue
# Determine if shift is needed
if ch.isupper() and ch.lower() != ch:
# Need shift for uppercase letters
key_down(0xA0) # left shift
key_press(vk)
key_up(0xA0)
elif not ch.isalnum() and ch != ' ':
# Shift needed for most punctuation
key_down(0xA0)
key_press(vk)
key_up(0xA0)
else:
key_press(vk)
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
+3 -3
View File
@@ -6,11 +6,11 @@ import template_finder
from inventory import common, personal
from ui import view
from ui_manager import is_visible, wait_until_visible, ScreenObjects, wait_until_hidden
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import cut_roi, wait, color_filter
from config import Config
from screen import convert_abs_to_monitor, convert_monitor_to_screen, convert_screen_to_monitor, grab
import keyboard
from input_layer import keyboard
import os
def open(img: np.ndarray = None) -> np.ndarray:
@@ -147,7 +147,7 @@ def fill_up_belt_from_inventory(num_loot_columns: int):
Fill up your belt with pots from the inventory e.g. after death. It will open and close invetory by itself!
:param num_loot_columns: Number of columns used for loot from left
"""
img = personal.open()
img = personal.open_inventory()
pot_positions = []
for column, row in itertools.product(range(num_loot_columns), range(4)):
center_pos, slot_img = common.get_slot_pos_and_img(img, column, row)
+3 -3
View File
@@ -1,10 +1,10 @@
from config import Config
import cv2
import numpy as np
import keyboard
from input_layer import keyboard
import time
import itertools
from utils.custom_mouse import mouse
from input_layer import mouse
from ui_manager import detect_screen_object, ScreenObjects, is_visible, wait_until_hidden, center_mouse
import template_finder
from utils.misc import wait, trim_black, color_filter, cut_roi
@@ -274,7 +274,7 @@ def select_stash_page(idx: int):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
from config import Config
from screen import start_detecting_window, stop_detecting_window
from utils.misc import color_filter
+7 -7
View File
@@ -1,6 +1,6 @@
import itertools
from game_stats import GameStats
import keyboard
from input_layer import keyboard
import cv2
import time
import numpy as np
@@ -11,7 +11,7 @@ from logger import Logger
from config import Config
import template_finder
from utils.misc import wait, is_in_roi, mask_by_roi
from utils.custom_mouse import mouse
from input_layer import mouse
from inventory import stash, common, vendor
from ui import view
from ui_manager import detect_screen_object, is_visible, select_screen_object_match, wait_until_visible, ScreenObjects, center_mouse, wait_for_update
@@ -57,7 +57,7 @@ def inventory_has_items(img: np.ndarray = None, close_window = False) -> bool:
:param img: Img from screen.grab() with inventory open
:return: Bool if inventory still has items or not
"""
img = open(img)
img = open_inventory(img)
if img is not None:
items=False
for column, row in itertools.product(range(0, Config().char["num_loot_columns"]), range(4)):
@@ -165,7 +165,7 @@ def stash_all_items(items: list = None):
Logger.debug("Done stashing")
return items
def open(img: np.ndarray = None) -> np.ndarray:
def open_inventory(img: np.ndarray = None) -> np.ndarray:
img = grab() if img is None else img
if not common.inventory_is_open():
keyboard.send(Config().char["inventory_screen"])
@@ -174,7 +174,7 @@ def open(img: np.ndarray = None) -> np.ndarray:
return None
keyboard.send(Config().char["inventory_screen"])
if not wait_until_visible(ScreenObjects.RightPanel, 1).valid:
Logger.error(f"personal.open(): Failed to open inventory")
Logger.error(f"personal.open_inventory(): Failed to open inventory")
return None
img = grab()
return img
@@ -207,7 +207,7 @@ def inspect_items(inp_img: np.ndarray = None, close_window: bool = True, game_st
:param img: Image in which the item is searched (item details should be visible)
"""
center_mouse()
img = open(inp_img)
img = open_inventory(inp_img)
if img is None:
Logger.error("personal.inspect_items(): unable to get inventory image")
return []
@@ -433,7 +433,7 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
return items
def update_tome_key_needs(img: np.ndarray = None, item_type: str = "tp") -> bool:
img = open(img)
img = open_inventory(img)
if img is None:
Logger.debug(f"update_tome_key_needs: failed to get inventory image")
return False
+2 -2
View File
@@ -1,12 +1,12 @@
from math import floor
import keyboard
from input_layer import keyboard
import template_finder
from config import Config
import numpy as np
from utils.misc import wait
from screen import grab
from logger import Logger
from utils.custom_mouse import mouse
from input_layer import mouse
from ui_manager import center_mouse, is_visible, select_screen_object_match, wait_until_visible, ScreenObjects
from inventory import personal, common, stash
+3 -3
View File
@@ -2,7 +2,7 @@ from enum import Enum
from numpy import ndarray
import cv2
import json
import keyboard
from input_layer import keyboard
import os
import time
import uuid
@@ -19,7 +19,7 @@ from bnip.actions import should_pickup
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
from screen import grab, convert_abs_to_monitor
from ui_manager import ScreenObjects, is_visible
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
@@ -204,7 +204,7 @@ if __name__ == "__main__":
from char.sorceress import LightSorc
from char.paladin import Hammerdin
from pather import Pather
import keyboard
from input_layer import keyboard
from logger import Logger
from screen import start_detecting_window, stop_detecting_window
+14 -5
View File
@@ -11,7 +11,7 @@ if sys.platform == "win32":
os.add_dll_directory(_conda_dll_dir)
from dataclasses import dataclass
import keyboard
from input_layer import keyboard
from beautifultable import BeautifulTable
import logging
import traceback
@@ -90,8 +90,13 @@ def main():
# Auto-launch D2R if not already running
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
Logger.info("D2R is not running, launching...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
if Config().general.get("bnet_name", "") and Config().general.get("bnet_pass", ""):
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
else:
Logger.info("D2R is not running. No auto-login configured - please launch D2R and log in, then press the resume key to start.")
# Don't auto-launch without credentials; user will start manually
# Still show the UI and wait for resume key
else:
Logger.info("D2R is already running")
@@ -126,5 +131,9 @@ if __name__ == "__main__":
main()
except:
traceback.print_exc()
print("Press Enter to exit ...")
input()
# In --noconsole builds, skip input() - the hotkey-based exit_key handles shutdown
try:
if __import__('sys').stdin and __import__('sys').stdin.isatty():
input()
except (OSError, ValueError):
pass
+3 -3
View File
@@ -1,14 +1,14 @@
import time
import os
import numpy as np
import keyboard
from input_layer import keyboard
import template_finder
from config import Config
from screen import grab
from ui_manager import ScreenObjects, center_mouse, is_visible, wait_until_hidden
from utils.misc import color_filter, wait
from logger import Logger
from utils.custom_mouse import mouse
from input_layer import mouse
from math import sqrt
class Npc:
@@ -322,7 +322,7 @@ if __name__ == "__main__":
from screen import grab
from config import Config
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
open_npc_menu(Npc.MALAH)
+3 -3
View File
@@ -1,11 +1,11 @@
import math
import keyboard
from input_layer import keyboard
import time
import os
import random
import cv2
import numpy as np
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait # for stash/shrine tele cancel detection in traverse node
from utils.misc import is_in_roi
from config import Config
@@ -728,7 +728,7 @@ if __name__ == "__main__":
cv2.imshow("debug", display_img)
cv2.waitKey(1)
import keyboard
from input_layer import keyboard
from screen import start_detecting_window, stop_detecting_window, grab
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
+1 -1
View File
@@ -110,7 +110,7 @@ class Arcane:
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from game_stats import GameStats
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
+2 -2
View File
@@ -8,7 +8,7 @@ from item.pickit import PickIt
import template_finder
from town.town_manager import TownManager, A4
from utils.misc import wait
from utils.custom_mouse import mouse
from input_layer import mouse
from screen import convert_abs_to_monitor, grab
from ui_manager import detect_screen_object, ScreenObjects
from ui import skills, loading, waypoint
@@ -505,7 +505,7 @@ class Diablo:
return (Location.A4_DIABLO_END, self._picked_up_items)
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from game_stats import GameStats
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
+2 -2
View File
@@ -10,10 +10,10 @@ from collections import OrderedDict
import time
import random
import keyboard
from input_layer import keyboard
from screen import grab, convert_abs_to_monitor
from target_detect import get_visible_targets
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
+2 -2
View File
@@ -8,7 +8,7 @@ from item.pickit import PickIt
import template_finder
from town.town_manager import TownManager, A4
from utils.misc import wait
from utils.custom_mouse import mouse
from input_layer import mouse
from screen import convert_abs_to_monitor, grab
from ui_manager import detect_screen_object, ScreenObjects
from ui import skills, loading, waypoint
@@ -326,7 +326,7 @@ class Vizier:
return (Location.A4_DIABLO_A_LAYOUTCHECK, self._picked_up_items)
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from game_stats import GameStats
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
+5 -1
View File
@@ -88,8 +88,12 @@ def grab(force_new: bool = False) -> np.ndarray:
monitor_roi["width"] = m0["width"]
monitor_roi["height"] = m0["height"]
# with 25fps we have 40ms per frame. If we check for 20ms range to make sure we can still get each frame if we want.
# Added 5-10% jitter to the cache interval to break perfectly regular timing patterns.
with cached_img_lock:
if not force_new and cached_img is not None and last_grab is not None and (time.perf_counter() - last_grab) < 0.02:
import random
jitter = 0.9 + random.random() * 0.2 # 0.9x to 1.1x
cache_threshold = 0.02 * jitter
if not force_new and cached_img is not None and last_grab is not None and (time.perf_counter() - last_grab) < cache_threshold:
return cached_img
else:
last_grab = time.perf_counter()
+10 -10
View File
@@ -3,7 +3,7 @@ import os
import time
import math
import keyboard
from input_layer import keyboard
import numpy as np
from screen import grab, convert_screen_to_monitor
@@ -11,7 +11,7 @@ from config import Config
from logger import Logger
from npc_manager import Npc, open_npc_menu, press_npc_btn
import template_finder
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait, load_template
from messages import Messenger
@@ -94,7 +94,7 @@ class AnyaShopper:
if ias_glove.valid:
self.ias_gloves_seen += 1
mouse.move(*ias_glove.center_monitor)
time.sleep(0.5)
wait(0.5, 0.6)
glove_dialog_img = grab()
if self.look_for_plus_3_gloves is True:
@@ -110,7 +110,7 @@ class AnyaShopper:
self._messenger.send_message("Bought awesome IAS/+3 jav gloves!")
Logger.info("IAS/jav gloves bought!")
self.gloves_bought += 1
time.sleep(1)
wait(1, 1.2)
else:
gg_gloves = template_finder.search(
ref=load_template(asset_folder + "plus3_ma_skills.jpg"),
@@ -123,7 +123,7 @@ class AnyaShopper:
self._messenger.send_message("Bought awesome IAS/+3 ma gloves!")
Logger.info("IAS/ma gloves bought!")
self.gloves_bought += 1
time.sleep(1)
wait(1, 1.2)
else:
if self.look_for_plus_2_gloves is True:
@@ -140,13 +140,13 @@ class AnyaShopper:
self._messenger.send_message("Bought some decent IAS/+2 gloves")
Logger.info("IAS/+2 gloves bought!")
self.gloves_bought += 1
time.sleep(1)
wait(1, 1.2)
def shop_loop(self):
while True:
open_npc_menu(Npc.ANYA)
press_npc_btn(Npc.ANYA, "trade")
time.sleep(0.5)
wait(0.5, 0.6)
img = grab()
#For some reason the glove image varies slightly depending on where it is located in the shop.
@@ -204,7 +204,7 @@ class AnyaShopper:
Logger.info(f"Trap Claws (score: {trap_score}) bought!")
self.claws_bought += 1
time.sleep(1)
wait(1, 1.2)
if melee_score > self.melee_claw_min_score and self.look_for_melee_claws is True:
# pick it up
@@ -213,7 +213,7 @@ class AnyaShopper:
self._messenger.send_message(f"Bought some mad melee Claws (score: {melee_score})")
Logger.info(f"Melee Claws (score: {melee_score}) bought!")
self.claws_bought += 1
time.sleep(1)
wait(1, 1.2)
# Done with this shopping round
self.reset_shop()
@@ -227,7 +227,7 @@ class AnyaShopper:
break
else:
mouse.move(800, 450, randomize=50, delay_factor=[0.7, 0.7])
time.sleep(2.5)
wait(2.5, 3.0)
while 1:
success = self.select_by_template("A5_RED_PORTAL")
success &= wait_for_loading_screen(2)
+4 -4
View File
@@ -5,7 +5,7 @@ import math
import random
from typing import Callable
import keyboard
from input_layer import keyboard
import numpy as np
from screen import convert_screen_to_monitor, grab, convert_abs_to_monitor, convert_screen_to_abs, convert_monitor_to_screen
@@ -13,7 +13,7 @@ from config import Config
from logger import Logger
from npc_manager import Npc, open_npc_menu, press_npc_btn
import template_finder
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
@@ -75,7 +75,7 @@ class DrognanShopper:
while True:
open_npc_menu(Npc.DROGNAN)
press_npc_btn(Npc.DROGNAN, "trade")
time.sleep(0.1)
wait(0.1, 0.12)
img = grab()
if self.look_for_scepters is True:
@@ -110,7 +110,7 @@ class DrognanShopper:
mouse.click(button="right")
Logger.info(f"Item bought!")
self.items_bought += 1
time.sleep(1)
wait(1, 1.2)
self.items_evaluated += 1
+4 -3
View File
@@ -8,8 +8,9 @@ if sys.platform == "win32":
from beautifultable import BeautifulTable
import logging
import traceback
import keyboard
from input_layer import keyboard
import time
from utils.misc import wait
from shop.anya import AnyaShopper
from shop.drognan import DrognanShopper
from config import Config
@@ -46,13 +47,13 @@ def main():
merchant = AnyaShopper()
merchant.run()
break
time.sleep(0.02)
wait(0.02, 0.024)
if __name__ == "__main__":
# To avoid cmd just closing down, except any errors and add a input() to the end
try:
start_detecting_window()
time.sleep(2)
wait(2, 2.4)
main()
except:
traceback.print_exc()
+1 -8
View File
@@ -14,13 +14,6 @@ FILTER_RANGES=[
{"erode": 1, "blur": 3, "lh": 110, "ls": 169, "lv": 50, "uh": 120, "us": 255, "uv": 255} # frozen
]
# Shape filtering to reject false positives:
# - Health bars: thin horizontal strips (aspect ratio >> 1, very wide and short)
# - Immune text: small vertical blobs (aspect ratio << 1, tall and narrow)
# - Real auras: roughly circular/elliptical (aspect ratio ~0.5-2.0)
TARGET_ASPECT_MIN = 0.5 # reject blobs taller than twice as tall as wide (immune text)
TARGET_ASPECT_MAX = 3.0 # reject strips wider than 3x their height (health bars)
# Target shape filters to reject false positives (health bars, immune text)
TARGET_MIN_AREA = 100 # minimum connected component area (immune text is tiny)
TARGET_MAX_AREA = 200 # maximum area (real aura blobs)
@@ -254,7 +247,7 @@ class LiveViewer:
# Testing: Have whatever you want to detect on the screen
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from logger import Logger
from screen import start_detecting_window, stop_detecting_window
+1 -1
View File
@@ -246,7 +246,7 @@ def search_all(
# Testing: Have whatever you want to find on the screen
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, stop_detecting_window
from utils.misc import wait
+1 -1
View File
@@ -6,7 +6,7 @@ from pather import Pather, Location
import template_finder
from utils.misc import wait
from ui_manager import ScreenObjects, is_visible
from utils.custom_mouse import mouse
from input_layer import mouse
from screen import convert_abs_to_monitor
from logger import Logger
+2 -2
View File
@@ -1,5 +1,5 @@
from item import consumables
import keyboard
from input_layer import keyboard
import template_finder
from config import Config
from pather import Location
@@ -247,7 +247,7 @@ class TownManager:
# Test: Move to desired location in d2r and run any town action you want to test from there
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window
start_detecting_window()
+2 -2
View File
@@ -6,14 +6,14 @@ from .inventory_collection import InventoryCollection
from .stash import Stash
from .gem_picking import SimpleGemPicking
from screen import convert_screen_to_monitor, grab
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
from version import __version__
from logger import Logger
from game_stats import GameStats
import template_finder
import numpy as np
import keyboard
from input_layer import keyboard
import cv2
from inventory import personal, common
+1 -1
View File
@@ -1,4 +1,4 @@
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import cut_roi, roi_center, wait, is_in_roi
from config import Config
+1 -1
View File
@@ -1,7 +1,7 @@
from ui_manager import detect_screen_object, select_screen_object_match, ScreenObjects
from logger import Logger
from utils.misc import wait
import keyboard
from input_layer import keyboard
def handle_error() -> bool:
Logger.warning("Server connection issue. waiting 20s")
+2 -1
View File
@@ -2,6 +2,7 @@ import time
import numpy as np
from screen import grab
from config import Config
from utils.misc import wait
def check_for_black_screen() -> bool:
img = grab()
@@ -17,5 +18,5 @@ def wait_for_loading_screen(timeout: float = 10.0) -> bool:
while time.time() - start < timeout:
if check_for_black_screen():
return True
time.sleep(0.02)
wait(0.02, 0.024)
return False
+1 -1
View File
@@ -1,5 +1,5 @@
import time
import keyboard
from input_layer import keyboard
from config import Config
from utils.misc import wait
from logger import Logger
+2 -2
View File
@@ -1,6 +1,6 @@
from config import Config
from screen import convert_screen_to_monitor, grab
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import cut_roi, wait
from logger import Logger
from config import Config
@@ -44,7 +44,7 @@ def get_experience():
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window
+1 -1
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
from logger import Logger
import cv2
import time
+3 -3
View File
@@ -1,8 +1,8 @@
import time
from screen import grab
from config import Config
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from logger import Logger
from utils.misc import wait
from ui_manager import wait_until_hidden, wait_until_visible, detect_screen_object, select_screen_object_match, ScreenObjects, list_visible_objects, is_visible
@@ -138,7 +138,7 @@ def return_to_play() -> bool:
# Testing
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, stop_detecting_window
start_detecting_window()
+45 -6
View File
@@ -1,6 +1,7 @@
import re
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from config import Config
from screen import convert_screen_to_monitor
@@ -8,6 +9,40 @@ from utils.misc import wait
from ui import loading
from ui_manager import detect_screen_object, ScreenObjects
def _maybe_wrong_waypoint(act: int, idx: int):
"""
2-3% chance of selecting a wrong waypoint first, then correcting.
Simulates human misclick on the waypoint menu.
"""
try:
from utils.stealth import should_wrong_waypoint
if not should_wrong_waypoint():
return False, act, idx
except Exception:
return False, act, idx
# Pick a wrong waypoint in the same act (valid act range)
wrong_idx = random.randint(0, 8)
while wrong_idx == idx:
wrong_idx = random.randint(0, 8)
# Click the wrong waypoint first
wrong_pos = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * wrong_idx)
wx, wy = convert_screen_to_monitor(wrong_pos)
mouse.move(wx, wy, randomize=8)
mouse.click(button="left")
Logger.info(f"[Stealth] Misclicked waypoint index {wrong_idx}, correcting to {idx}")
# Wait a bit (realizing the mistake), then correct
wait(0.5, 1.0)
# Click the correct waypoint
pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx)
x, y = convert_screen_to_monitor(pos_wp_btn)
mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4])
mouse.click(button="left")
return True, act, idx
_WAYPOINTS = {
# Act 1
"Rouge Encampment": (1, 0),
@@ -75,11 +110,15 @@ def use_wp(label: str = None, act: int = None, idx: int = None) -> bool:
mouse.move(x, y, randomize=8)
mouse.click(button="left")
wait(0.3, 0.4)
pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx)
x, y = convert_screen_to_monitor(pos_wp_btn)
mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4])
wait(0.4, 0.5)
mouse.click(button="left")
# Stealth: 2-3% chance of wrong waypoint misclick then correction
was_wrong, _, _ = _maybe_wrong_waypoint(act, idx)
if not was_wrong:
pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx)
x, y = convert_screen_to_monitor(pos_wp_btn)
mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4])
wait(0.4, 0.5)
mouse.click(button="left")
# wait till loading screen is over
if loading.wait_for_loading_screen(5):
while 1:
+5 -4
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
import os
import numpy as np
import time
@@ -6,7 +6,7 @@ import cv2
from functools import cache
from typing import TypeVar, Callable
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait, cut_roi, image_is_equal
from logger import Logger
from config import Config
@@ -411,7 +411,7 @@ def get_closest_non_hud_pixel(pos : tuple[int, int], pos_type: str = "abs") -> t
# Testing: Move to whatever ui to test and run
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from screen import start_detecting_window, grab, stop_detecting_window
start_detecting_window()
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or stop_detecting_window() or os._exit(1))
@@ -422,4 +422,5 @@ if __name__ == "__main__":
print(wait_for_update(grab(), Config().ui_roi["right_inventory"], timeout=5))
# while 1:
# print(list_visible_objects())
# time.sleep(1)
# from utils.misc import wait as _wait
# _wait(1, 1.2)
+1 -1
View File
@@ -3,7 +3,7 @@ import cv2
from config import Config
from utils.misc import cut_roi
import mouse
import keyboard
from input_layer import keyboard
import os
import time
from screen import grab, convert_monitor_to_screen
+2 -2
View File
@@ -15,8 +15,8 @@ import time
from pather import Pather
from char.sorceress import NovaSorc
from screen import convert_screen_to_monitor
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from PIL import ImageTk, Image
import re
+77 -7
View File
@@ -3,6 +3,8 @@ from decimal import InvalidOperation
import time
import random
import ctypes
import threading
import logging
import numpy as np
from copy import deepcopy
import unicodedata
@@ -107,12 +109,20 @@ def find_d2r_window(spec: WindowSpec, offset = (0, 0)) -> tuple[int, int]:
def set_d2r_always_on_top():
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_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
print("Set D2R to be always on top")
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')
@@ -146,13 +156,73 @@ def wait(min_seconds, max_seconds = None):
time.sleep(base * jitter)
return
def kill_thread(thread):
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,
):
"""
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():
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]
+1 -1
View File
@@ -4,7 +4,7 @@ from config import Config
import template_finder
from utils.misc import load_template, cut_roi
import mouse
import keyboard
from input_layer import keyboard
import os
import shutil
from pathlib import Path
+1 -1
View File
@@ -118,7 +118,7 @@ def detect_visible_npcs_cached(img=None, ttl=5.0):
if __name__ == "__main__":
import cv2
import keyboard
from input_layer import keyboard
from screen import start_detecting_window
start_detecting_window()
+1 -1
View File
@@ -1,5 +1,5 @@
import os, sys
import keyboard
from input_layer import keyboard
import subprocess
import template_finder
+7 -6
View File
@@ -1,8 +1,9 @@
import random
import time
import keyboard
from input_layer import keyboard
from config import Config
from logger import Logger
from utils.misc import wait
def maybe_afk_break():
@@ -14,7 +15,7 @@ def maybe_afk_break():
if random.randint(1, 100) <= cfg["afk_break_chance"]:
minutes = random.uniform(cfg["afk_break_min_m"], cfg["afk_break_max_m"])
Logger.info(f"[Stealth] Taking unscheduled AFK break for {minutes:.1f} minutes")
time.sleep(minutes * 60)
wait(minutes * 60, minutes * 60 * 1.5)
Logger.info("[Stealth] AFK break over, resuming")
@@ -88,7 +89,7 @@ def add_micro_pause():
# 30% chance of micro-pause
if random.random() < 0.3:
pause_s = random.uniform(min_ms, max_ms) / 1000.0
time.sleep(pause_s)
wait(pause_s, pause_s * 1.2)
# ─── Tier 1: Input-level stealth ─────────────────────────────────────────────
@@ -123,7 +124,7 @@ def click_delay() -> float:
def apply_click_delay():
"""Sleep for a human-like delay before clicking."""
time.sleep(click_delay())
wait(click_delay(), click_delay() * 1.2)
def key_press_duration(base_duration: float = 0.05) -> float:
@@ -153,7 +154,7 @@ def human_key_press(key: str):
add_micro_pause()
duration = key_press_duration()
keyboard.press(key)
time.sleep(duration)
wait(duration, duration * 1.2)
keyboard.release(key)
add_micro_pause()
@@ -166,7 +167,7 @@ def human_keyboard_send(key: str):
add_micro_pause()
duration = key_press_duration(0.03)
keyboard.press(key)
time.sleep(duration)
wait(duration, duration * 1.2)
keyboard.release(key)
add_micro_pause()