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
This commit is contained in:
alex
2026-06-01 11:01:00 +02:00
parent 5244a50ea3
commit cb3d49fa22
7 changed files with 74 additions and 22 deletions
+8 -8
View File
@@ -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
+21 -9
View File
@@ -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"]:
+2
View File
@@ -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()
+10
View File
@@ -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)
+7
View File
@@ -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
+6 -5
View File
@@ -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
+20
View File
@@ -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):