From cb3d49fa225519046dec08cc3783964ce22d4cd8 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 1 Jun 2026 11:01:00 +0200 Subject: [PATCH] Fix window offset drift causing pathing and template matching failures - src/screen.py: Add force param to find_and_set_window_position/set_window_position to bypass early-return guard and skip wait when called programmatically - src/run/pindle.py: Force window re-detection before each traverse_nodes and select_by_template call to handle offset drift between runs - src/pather.py: Add fallback full-image search with lower threshold (0.55) when cropped ROI search fails in find_abs_node_pos - src/bot.py: Move D2R window to stable position (0,0) at game start via move_d2r_window; skip merc resurrect after first failure (no gold) - src/game_stats.py: Add _merc_resurrect_failed flag (reset each game) - src/utils/misc.py: Add move_d2r_window() function - config/params.ini: Updated teleport=b, show_belt=k, override_capabilities, restore_settings_from_backup_key=insert, graphic_debugger_key=delete --- config/params.ini | 16 ++++++++-------- src/bot.py | 30 +++++++++++++++++++++--------- src/game_stats.py | 2 ++ src/pather.py | 10 ++++++++++ src/run/pindle.py | 7 +++++++ src/screen.py | 11 ++++++----- src/utils/misc.py | 20 ++++++++++++++++++++ 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/config/params.ini b/config/params.ini index 4511704..2da2219 100644 --- a/config/params.ini +++ b/config/params.ini @@ -176,7 +176,7 @@ order=run_pindle ; These configs have to be alligned with your d2r settings and char build ; type: character build profile to use (must match one section below) ; examples: blizz_sorc, hammerdin, fohdin -type=fohdin +type=hammerdin ; belt_rows: in-game belt size (2/3/4) belt_rows=4 ; casting_frames: your char cast breakpoint (affects action timing) @@ -206,12 +206,12 @@ potion2=2 potion3=3 potion4=4 ; show_belt is different from the default hotkey as "~" is for many keyboards not reachable without also pressing altgr -show_belt=n +show_belt=k show_items=alt ; stand_still cannot be the default "shift" as it would interfere with merc healing stand_still=capslock ; teleport: leave empty if you can't use -teleport= +teleport=b town_portal=6 ; call to arms settings: ; weapon_switch/battle_orders/battle_command only used when cta_available=1 @@ -372,8 +372,8 @@ foh=f6 holy_bolt=f7 [hammerdin] -blessed_hammer=f4 -concentration=f5 +blessed_hammer=f1 +concentration=f8 ; ========================= ; ==== Builds: Warlock ==== @@ -531,13 +531,13 @@ buff_2= ; select_runs_key: open run selector UI select_runs_key=pagedown ; restore_settings_from_backup_key: restore backed-up D2R settings -restore_settings_from_backup_key=f10 +restore_settings_from_backup_key=insert ; settings_backup_key: create backup of current D2R settings settings_backup_key=pause ; auto_settings_key: auto-apply required D2R settings auto_settings_key=pageup ; graphic_debugger_key: toggle on-screen debug layers -graphic_debugger_key=insert +graphic_debugger_key=delete ; resume_key: start/pause bot loop resume_key=f11 ; exit_key: hard stop bot @@ -561,7 +561,7 @@ message_headers= ; ocr_during_pickit: 1 = run OCR while looting (slower, more diagnostics) ocr_during_pickit=0 ;use "can_teleport_natively" or "can_teleport_with_charges" if you want to force certain behavior in case autodetection isn't working properly -override_capabilities= +override_capabilities=can_teleport_natively ; pathing_delay_factor: movement/click delay multiplier (1 fast .. 10 slow) pathing_delay_factor=2 ; If you want to control Hyper-V window from host use 0,51 here diff --git a/src/bot.py b/src/bot.py index 1fcfe3f..15133c9 100644 --- a/src/bot.py +++ b/src/bot.py @@ -261,6 +261,10 @@ class Bot: def on_init(self): self._game_stats.log_start_game() keyboard.release(Config().char["stand_still"]) + # Ensure D2R window is at a stable position to prevent template matching drift + from utils.misc import move_d2r_window + move_d2r_window(0, 0) + wait(0.3) # If we're only doing run_level and character is already in-game, skip # the town detection and go straight to the run if list(self._do_runs.keys()) == ["run_level"]: @@ -459,15 +463,23 @@ class Bot: self._curr_loc = Location.A5_TOWN_START # Check if merc needs to be revived - if not is_visible(ScreenObjects.MercIcon) and Config().char["use_merc"]: - Logger.info("Resurrect merc") - new_loc = self._town_manager.resurrect(self._curr_loc) - if new_loc is False: - # Failed to resurrect (can't afford or other error) - don't log death, just continue - Logger.warning("Failed to resurrect merc, continuing without merc") - else: - self._game_stats.log_merc_death() - self._curr_loc = new_loc + if Config().char["use_merc"]: + try: + merc_visible = is_visible(ScreenObjects.MercIcon) + except Exception: + # ROI/template shape error - skip resurrect check + Logger.debug("Merc icon detection error (ROI/template issue), skipping resurrect check") + merc_visible = True + if not merc_visible and not self._game_stats._merc_resurrect_failed: + Logger.info("Resurrect merc") + new_loc = self._town_manager.resurrect(self._curr_loc) + if new_loc is False: + # Failed to resurrect (can't afford or other error) - don't log death, just continue + Logger.warning("Failed to resurrect merc, continuing without merc") + self._game_stats._merc_resurrect_failed = True + else: + self._game_stats.log_merc_death() + self._curr_loc = new_loc # Gamble if needed while vendor.get_gamble_status() and Config().char["gamble_items"]: diff --git a/src/game_stats.py b/src/game_stats.py index fd5d0d6..98e6ca9 100644 --- a/src/game_stats.py +++ b/src/game_stats.py @@ -88,6 +88,7 @@ class GameStats: self._last_status_report_game = 0 self._last_status_report_run = 0 self._last_failure_reason = None + self._merc_resurrect_failed = False os.makedirs("log/stats", exist_ok=True) def set_failure_reason(self, reason: str): @@ -257,6 +258,7 @@ class GameStats: self._send_status_update() self._game_counter += 1 self._timer = time.time() + self._merc_resurrect_failed = False Logger.info(f"Starting game #{self._game_counter}") self._log_event("game_started") self._persist_snapshot() diff --git a/src/pather.py b/src/pather.py index 988540b..3476b3c 100644 --- a/src/pather.py +++ b/src/pather.py @@ -547,6 +547,7 @@ class Pather: def find_abs_node_pos(self, node_idx: int, img: np.ndarray, threshold: float = 0.68) -> tuple[float, float]: node = self._nodes[node_idx] + # Try with cropped ROI first (cuts skill bar for better matching) template_match = template_finder.search( [*node], img, @@ -555,6 +556,15 @@ class Pather: roi=Config().ui_roi["cut_skill_bar"], use_grayscale=True ) + # If cropped search fails, try full image with lower threshold to handle window offset drift + if not template_match.valid: + template_match = template_finder.search( + [*node], + img, + best_match=False, + threshold=0.55, + use_grayscale=True + ) if template_match.valid: # Get reference position of template in abs coordinates ref_pos_abs = convert_screen_to_abs(template_match.center) diff --git a/src/run/pindle.py b/src/run/pindle.py index 923609f..cfd7905 100644 --- a/src/run/pindle.py +++ b/src/run/pindle.py @@ -32,6 +32,9 @@ class Pindle: loc = self._town_manager.go_to_act(5, start_loc) if not loc: return False + # Force window re-detection before pathing to handle offset drift + from screen import find_and_set_window_position + find_and_set_window_position(force=True) if not self._pather.traverse_nodes((loc, Location.A5_NIHLATHAK_PORTAL), self._char): return False wait(0.5, 0.6) @@ -39,11 +42,15 @@ class Pindle: if do_pre_buff: self._char.pre_buff() found_loading_screen_func = lambda: loading.wait_for_loading_screen(2.0) + # Re-detect window before template search + find_and_set_window_position(force=True) if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False): Logger.warning("Pindle approach: first red portal click failed, retrying from town start") + find_and_set_window_position(force=True) if not self._pather.traverse_nodes((Location.A5_TOWN_START, Location.A5_NIHLATHAK_PORTAL), self._char): return False wait(0.5, 0.7) + find_and_set_window_position(force=True) if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False): return False return Location.A5_PINDLE_START diff --git a/src/screen.py b/src/screen.py index d240fc5..18430b5 100644 --- a/src/screen.py +++ b/src/screen.py @@ -42,16 +42,17 @@ def detect_window_position(): find_and_set_window_position() Logger.debug('Detect window thread stopped') -def find_and_set_window_position(): +def find_and_set_window_position(force: bool = False): position = find_d2r_window(FIND_WINDOW, offset=Config( ).advanced_options["window_client_area_offset"]) if position is not None: - set_window_position(*position) - wait(1) + set_window_position(*position, force=force) + if not force: + wait(1) -def set_window_position(offset_x: int, offset_y: int): +def set_window_position(offset_x: int, offset_y: int, force: bool = False): global monitor_roi, monitor_x_range, monitor_y_range, found_offsets - if found_offsets and monitor_roi["top"] == offset_y and monitor_roi["left"] == offset_x: + if not force and found_offsets and monitor_roi["top"] == offset_y and monitor_roi["left"] == offset_x: return Logger.debug(f"Set offsets: left {offset_x}px, top {offset_y}px") monitor_roi["top"] = offset_y diff --git a/src/utils/misc.py b/src/utils/misc.py index 4971c7e..2f379c8 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -111,6 +111,26 @@ def find_d2r_window(spec: WindowSpec, offset = (0, 0)) -> tuple[int, int]: return (left + offset_x, top + offset_y) return None +def move_d2r_window(x, y): + """Move D2R window to a specific screen position 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]: + cr = wintypes.RECT() + user32.GetClientRect(w[0], ctypes.byref(cr)) + w_width = cr.right + w_height = cr.bottom + SetWindowPos(w[0], HWND_TOPMOST, x, y, w_width, w_height, SWP_SHOWWINDOW) + Logger.debug(f"Moved D2R window to ({x}, {y})") + return True + return False + def set_d2r_always_on_top(): if os.name == 'nt': for attempt in range(30):