Merge pull request #35 from alexpolo1/fix/hud-click-guard

fix: move() clicked into the HUD and toggled the loot filter
This commit is contained in:
Alex
2026-08-28 15:09:53 +02:00
committed by GitHub
2 changed files with 148 additions and 2 deletions

View File

@@ -231,7 +231,41 @@ class IChar:
if skills.wait_until_right_skill_selected("TELE_ACTIVE") == False:
Logger.error("timeout waiting for tele skill to activate")
@staticmethod
def _hud_safe_target(pos_monitor: tuple[float, float], jitter: int) -> tuple[int, int]:
"""Jitter FIRST, then snap out of the HUD. Returns a point to click with
randomize=0.
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
construction lies exactly on the mask boundary. Applying mouse.move's
randomize AFTER it therefore pushes the cursor straight back inside:
randomize=3 offsets each axis by randrange(-3, 3), i.e. -3..+2. That
made the loot-filter clicks intermittent rather than fixed — the first
version of this guard had exactly that hole, and its test only checked
the guard's output, never the cursor that was finally clicked.
Order matters: jitter, then guard, then move with no further
randomisation. The human-like offset is preserved; the guarantee is not
given away.
"""
x, y = int(pos_monitor[0]), int(pos_monitor[1])
if jitter and jitter > 0:
x += random.randrange(-jitter, jitter)
y += random.randrange(-jitter, jitter)
return get_closest_non_hud_pixel((x, y), "monitor")
def move(self, pos_monitor: tuple[float, float], force_tp: bool = False, force_move: bool = False):
# Never click into the HUD. Neither branch below did this, so a move
# target low on the screen landed on the interface — most visibly the
# loot-filter toggles at the bottom-left (screen x 395-560, y 692-712),
# which a right-click flips. Reported after Larzuk trips: he stands on
# the left of Harrogath, so moves to and from him aim at that corner.
#
# Latent for a long time and surfaced by Enigma: the walk branch shrinks
# its target toward centre via adjust_factor, which mostly kept clicks
# off the HUD by accident, while the teleport branch clicks the raw
# target. The pather's anti-stuck path already guarded this; move() did
# not.
factor = Config().advanced_options["pathing_delay_factor"]
if "teleport" in Config().char and Config().char["teleport"] and (
force_tp
@@ -241,7 +275,8 @@ class IChar:
)
):
self._set_active_skill("right", "teleport")
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, delay_factor=[factor*0.1, factor*0.14])
tx, ty = self._hud_safe_target(pos_monitor, 3)
mouse.move(tx, ty, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
mouse.click(button="right")
wait(self._cast_duration, self._cast_duration + 0.02)
@@ -255,7 +290,8 @@ class IChar:
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
x, y = self._hud_safe_target((x, y), 5)
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
if force_move:
keyboard.send(Config().char["force_move"])

110
test/test_hud_clicks.py Normal file
View File

@@ -0,0 +1,110 @@
"""move() must never click into the HUD.
Reported 2026-08-28: after a Larzuk trip the bot pressed escape and then
toggled the LOOT FILTER. Larzuk stands on the left of Harrogath, so moves to
and from him aim at the bottom-left corner — where D2R puts the seven
loot-filter category buttons (screen x 395-560, y 692-712). A right-click there
flips a filter, which changes what renders and therefore what every later
template match can see.
IChar.move() applied no HUD avoidance in either branch. The pather's anti-stuck
path already called get_closest_non_hud_pixel; move() did not.
Latent for a long time and surfaced by Enigma: the walk branch shrinks its
target toward centre via adjust_factor, which mostly kept clicks off the HUD by
accident, while the teleport branch clicks the raw target.
"""
import inspect
FILTER_ROW = [(400, 700), (470, 700), (550, 700), (470, 710)]
def test_hud_mask_covers_the_loot_filter_buttons():
import cv2
mask = cv2.imread("assets/hud_mask.png", cv2.IMREAD_GRAYSCALE)
mask = cv2.threshold(mask, 1, 255, cv2.THRESH_BINARY)[1]
for x, y in FILTER_ROW:
assert mask[y, x] == 0, f"loot-filter button ({x},{y}) is not masked as HUD"
def test_targets_on_the_filter_row_are_moved_off_it():
from ui_manager import get_closest_non_hud_pixel
for x, y in FILTER_ROW:
ox, oy = get_closest_non_hud_pixel((x + 5, y + 98), "monitor") # monitor offset
assert (oy - 98) < 690, f"({x},{y}) still lands on the HUD at y={oy - 98}"
def test_a_centre_target_is_left_alone():
"""The guard must not perturb ordinary targets."""
from ui_manager import get_closest_non_hud_pixel
assert get_closest_non_hud_pixel((645, 458), "monitor") == (645, 458)
def test_move_applies_the_guard():
"""Every click target must pass through the HUD-safe helper first."""
from char.i_char import IChar
src = inspect.getsource(IChar.move)
assert "_hud_safe_target" in src, "move() can still click into the HUD"
guard_at = src.index("_hud_safe_target")
first_click = min([i for i in (src.find("mouse.move"), src.find("mouse.click")) if i != -1])
assert guard_at < first_click, "the guard must run before any click"
def test_the_guard_itself_masks_out():
from char.i_char import IChar
# Compare CODE only. The docstring mentions both names, and matching it
# reports the order backwards — the same trap as slicing source on a branch
# name and hitting a comment that merely mentions it.
src = inspect.getsource(IChar._hud_safe_target)
body = src.split('"""')[-1]
code = chr(10).join(l for l in body.splitlines() if not l.strip().startswith("#"))
assert "get_closest_non_hud_pixel" in code
jitter_at = code.index("randrange")
guard_at = code.index("get_closest_non_hud_pixel")
assert jitter_at < guard_at, (
"jitter must be applied BEFORE the guard — applying it after re-enters "
"the mask, because the guard returns a boundary pixel"
)
def test_jitter_cannot_push_the_click_back_into_the_hud():
"""The guard returns a BOUNDARY pixel, so jitter applied after it re-enters.
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
construction sits exactly on the mask edge. mouse.move(randomize=3) then
offsets each axis by randrange(-3, 3) = -3..+2, pushing the cursor straight
back inside. That made the loot-filter click intermittent rather than fixed.
This samples the FINAL point, which is what the earlier test failed to do:
it only checked the guard's output and passed while the bug was live.
"""
import cv2
from char.i_char import IChar
mask = cv2.imread("assets/hud_mask.png", cv2.IMREAD_GRAYSCALE)
mask = cv2.threshold(mask, 1, 255, cv2.THRESH_BINARY)[1]
for x, y in FILTER_ROW:
for _ in range(400):
mx, my = IChar._hud_safe_target((x + 5, y + 98), 3)
sx, sy = mx - 5, my - 98
assert mask[sy, sx] != 0, (
f"final click ({sx},{sy}) landed on the HUD from target ({x},{y})"
)
def test_move_clicks_with_randomization_disabled():
"""Jitter belongs before the guard, never after it."""
import inspect
from char.i_char import IChar
src = inspect.getsource(IChar.move)
for line in src.splitlines():
if "mouse.move(" in line:
assert "randomize=0" in line, (
f"move() still randomizes after the guard, which can re-enter "
f"the HUD: {line.strip()}"
)