From 91e201c5f6665b2cd3a1feffec03fb3a16cfacb3 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 14:55:38 +0200 Subject: [PATCH 1/2] fix(char): move() clicked into the HUD and toggled the loot filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported after Larzuk trips: the bot pressed escape, then flipped 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 toggles a filter, which changes what renders and therefore what every later template match can see. IChar.move() applied NO HUD avoidance in either branch: # teleport mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, ...) mouse.click(button="right") # walk x, y = convert_abs_to_monitor(pos_abs) mouse.move(x, y, randomize=5, ...) The pather's anti-stuck path already called get_closest_non_hud_pixel; move() never did, and assets/hud_mask.png covers those buttons correctly — the mask was simply not consulted. 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; the teleport branch clicks the raw target, so any low aim point lands on the interface. The escape that precedes it is unrelated and correct — common.close() dismissing the repair panel. Verified: filter-row targets are moved from y=700 to y=508, a centre target is returned unchanged. Co-Authored-By: Claude Opus 5 --- src/char/i_char.py | 12 ++++++++++ test/test_hud_clicks.py | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/test_hud_clicks.py diff --git a/src/char/i_char.py b/src/char/i_char.py index 67b447f..366e590 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -232,6 +232,18 @@ class IChar: Logger.error("timeout waiting for tele skill to activate") 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. + pos_monitor = get_closest_non_hud_pixel(pos_monitor, "monitor") factor = Config().advanced_options["pathing_delay_factor"] if "teleport" in Config().char and Config().char["teleport"] and ( force_tp diff --git a/test/test_hud_clicks.py b/test/test_hud_clicks.py new file mode 100644 index 0000000..05dfcb0 --- /dev/null +++ b/test/test_hud_clicks.py @@ -0,0 +1,50 @@ +"""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(): + from char.i_char import IChar + src = inspect.getsource(IChar.move) + assert "get_closest_non_hud_pixel" in src, "move() can still click into the HUD" + guard_at = src.index("get_closest_non_hud_pixel") + 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" From bc398c471fb9c801e327af9835537e13e9b628da Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 15:04:01 +0200 Subject: [PATCH 2/2] fix(char): apply move jitter BEFORE the HUD guard, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch on the previous commit — the guard was real but leaky. get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by construction sits exactly on the mask boundary. mouse.move(randomize=3) then offsets each axis by randrange(-3, 3) = -3..+2, so the cursor can land back inside the masked region before the right-click. The loot-filter click was made intermittent, not fixed. The order is now: jitter -> guard -> move with randomize=0. The human-like offset is preserved; the guarantee is no longer given away. Same treatment for the walk branch (randomize=5). My test missed this because it only checked the guard's OUTPUT, never the point finally clicked — it passed while the bug was live. The new test samples 400 jittered targets per filter button and asserts every FINAL point is unmasked, plus a source check that no mouse.move in move() randomizes after the guard. Verified by falsification: restoring guard-then-randomize makes the suite fail with "move() still randomizes after the guard, which can re-enter the HUD". Note on the test itself: an intermediate version compared string indexes over the whole function source and reported the ordering backwards, because the DOCSTRING mentions both names. It now compares code with the docstring and comments stripped — the third time today a source-order assertion was fooled by prose rather than code. Co-Authored-By: Claude Opus 5 --- src/char/i_char.py | 30 +++++++++++++++++-- test/test_hud_clicks.py | 64 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/char/i_char.py b/src/char/i_char.py index 366e590..cb8dfc9 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -231,6 +231,29 @@ 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 @@ -243,7 +266,6 @@ class IChar: # 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. - pos_monitor = get_closest_non_hud_pixel(pos_monitor, "monitor") factor = Config().advanced_options["pathing_delay_factor"] if "teleport" in Config().char and Config().char["teleport"] and ( force_tp @@ -253,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) @@ -267,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"]) diff --git a/test/test_hud_clicks.py b/test/test_hud_clicks.py index 05dfcb0..93c85c2 100644 --- a/test/test_hud_clicks.py +++ b/test/test_hud_clicks.py @@ -42,9 +42,69 @@ def test_a_centre_target_is_left_alone(): 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 "get_closest_non_hud_pixel" in src, "move() can still click into the HUD" - guard_at = src.index("get_closest_non_hud_pixel") + 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()}" + )