19 KiB
Codex Fix Analysis
Source files read:
docs/fix_plan.mdsrc/run/nihlathak.pysrc/town/town_manager.pysrc/inventory/vendor.pysrc/char/i_char.py(src/char.pydoes not exist in this repo; CTA is implemented here)config/game.ini- Supporting files needed to trace the failures:
src/town/a1.py,src/town/a5.py,src/town/a4.py,src/npc_manager.py,src/pather.py, and the CTA key section inconfig/params.ini
Priority 1: Nihlathak approach fails
Finding
The Nihlathak route has three brittle points:
approach()returns success immediately after clicking the waypoint and never verifies that the Halls of Pain actually loaded.- Level 1 layout detection has no fallback if
NI1_A,NI1_B, orNI1_Cis stale. traverse_nodes_fixed()always returnsTrue, so a bad static path inconfig/game.inicannot be detected until the stairs click times out.
The config/game.ini Nihlathak path keys are present:
ni1_a=871,472, 1205,600, 1162,600, 1169,584, 1169,584, 1232,213, 1221,237, 1164,228, 1145,572, 1146,547, 1223,185
ni1_b=23,187, 23,187, 23,187, 23,187, 12,192, 10,192, 10,190, 123,70, 378,120
ni1_c=118,500, 158,602, 187,577, 217,563, 184,551, 70,413, 127,240, 154,493, 197,504, 218,545, 83,246, 45,526, 300,380
So the immediate code fix is not "add missing keys"; it is to verify waypoint/area entry and reduce the failure blast radius when stale templates or coordinates are encountered.
Exact code that needs to change
src/run/nihlathak.py:
wait(0.4)
if waypoint.use_wp("Halls of Pain"): # use Halls of Pain Waypoint (5th in A5)
return Location.A5_NIHLATHAK_START
return False
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
if not template_match.valid:
return False
self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char)
Proposed fix
Replace the waypoint block with a verified load:
wait(0.4)
if not waypoint.use_wp("Halls of Pain"):
return False
if not template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.55, timeout=8).valid:
Logger.error("Nihlathak approach: waypoint click did not land in Halls of Pain")
return False
return Location.A5_NIHLATHAK_START
Replace layout detection and static path traversal with a lower-threshold retry and an explicit path result check:
template_match = template_finder.search_and_wait(["NI1_A", "NI1_B", "NI1_C"], threshold=0.65, timeout=20)
if not template_match.valid:
Logger.warning("Nihlathak: strict NI1 layout detection failed, retrying with grayscale/lower threshold")
template_match = template_finder.search_and_wait(
["NI1_A", "NI1_B", "NI1_C"],
threshold=0.55,
best_match=True,
timeout=6,
use_grayscale=True,
)
if not template_match.valid:
return False
if not self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char):
Logger.error(f"Nihlathak: failed static route {template_match.name.lower()}")
return False
If search_and_wait() does not support use_grayscale in this repo version, use this compatible form instead:
if not template_match.valid:
start = time.time()
while time.time() - start < 6:
template_match = template_finder.search(
["NI1_A", "NI1_B", "NI1_C"],
grab(),
threshold=0.55,
best_match=True,
use_grayscale=True,
)
if template_match.valid:
break
wait(0.2)
That compatible form also needs imports:
import time
from screen import grab, convert_abs_to_monitor
Why it will work
This turns the approach from "clicked the waypoint, assume success" into "clicked the waypoint, confirm an NI1 layout is visible." If the waypoint interaction fails or lands somewhere unexpected, the run fails immediately instead of burning 600+ seconds.
The lower-threshold/grayscale retry handles the likely stale-template case without permanently weakening the first pass. The strict threshold still wins when templates are good; the fallback only runs when the current behavior would fail.
The static route check makes future changes safer. traverse_nodes_fixed() currently returns True, but guarding the call is still correct because it protects this runner if path traversal later gains real validation.
Fresh templates and re-recorded ni1_* coordinates are still required if the fallback logs low-confidence matches or reaches the wrong stairs side. The code fix limits total game loss and gives a useful failure point.
Priority 2: Vendor trade button not found
Finding
The failure is not in src/inventory/vendor.py; that file buys items after the vendor panel is already open. The failing log comes from src/npc_manager.py:
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
The current town code also returns a vendor location even if pressing the trade button failed. For A5 Malah:
def open_trade_menu(self, curr_loc: Location) -> Location | bool:
if not self._pather.traverse_nodes((curr_loc, Location.A5_MALAH), self._char, force_move=True): return False
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
And press_npc_btn() does not return True on success:
if res.valid:
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button="left")
wait(0.04, 0.08)
center_mouse()
else:
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
keyboard.send("esc")
The template threshold is also hard-coded very high for white and blue text:
filtered_inp_w, 0.85, roi=Config().ui_roi["cut_skill_bar"]
Exact code that needs to change
src/npc_manager.py, press_npc_btn() needs to return a boolean and use a retry/fallback. A5/A1/A4 trade functions need to check that boolean or verify the vendor panel.
Proposed fix
Replace press_npc_btn() with:
def press_npc_btn(npc_key: Npc, action_btn_key: str) -> bool:
global npcs
for threshold in (0.85, 0.78):
img = grab()
img = escape_dialogue(img)
_, filtered_inp_w = color_filter(img, Config().colors["white"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["white"],
filtered_inp_w,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
)
if not res.valid and "blue" in npcs[npc_key]["action_btns"][action_btn_key]:
_, filtered_inp_b = color_filter(img, Config().colors["blue"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["blue"],
filtered_inp_b,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
)
if not res.valid:
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["white"],
img,
threshold,
roi=Config().ui_roi["cut_skill_bar"],
use_grayscale=True,
)
if res.valid:
mouse.move(*res.center_monitor, randomize=3, delay_factor=[1.0, 1.5])
wait(0.2, 0.4)
mouse.click(button="left")
wait(0.2, 0.3)
center_mouse()
return True
if "red" in npcs[npc_key]["action_btns"][action_btn_key]:
img = grab()
_, filtered_inp_r = color_filter(img, Config().colors["red"])
res = template_finder.search(
npcs[npc_key]["action_btns"][action_btn_key]["red"],
filtered_inp_r,
0.78,
roi=Config().ui_roi["cut_skill_bar"],
)
if res.valid:
Logger.warning(f"Cannot afford {action_btn_key} (red button detected). Skipping...")
keyboard.send("esc")
wait(0.3)
return False
Logger.error(f"Could not find {action_btn_key} btn. Should not happen! Continue...")
keyboard.send("esc")
return False
Then change A5 Malah trade from:
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
to:
if open_npc_menu(Npc.MALAH):
if press_npc_btn(Npc.MALAH, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A5_MALAH
return False
Make the same pattern in src/town/a1.py:
if open_npc_menu(Npc.AKARA):
if press_npc_btn(Npc.AKARA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A1_AKARA
return False
and in src/town/a4.py:
if open_npc_menu(Npc.JAMELLA):
if press_npc_btn(Npc.JAMELLA, "trade") and is_visible(ScreenObjects.GoldBtnVendor):
return Location.A4_JAMELLA
return False
Why it will work
The bot currently proceeds as if trade opened even when the button was not clicked. Returning False stops TownManager.buy_consumables() at the correct point:
new_loc = self._acts[curr_act].open_trade_menu(curr_loc)
if not (new_loc and common.wait_for_left_inventory()): return False, items
The fallback search keeps the current exact template behavior first, then retries with a slightly lower threshold and grayscale. That covers text color/anti-aliasing differences without making every match permissive.
Verifying ScreenObjects.GoldBtnVendor makes the action result state-based. Even if the template click returns true, the caller only continues when the vendor panel is actually open.
Fresh TRADE / TRADE_BLUE templates are still recommended, but this code fix prevents false success and reduces sensitivity to minor UI rendering differences.
Priority 3: Stash detection fails
Finding
The stash failures are in act-specific methods, not in TownManager.stash() itself. A1 and A5 use default IChar.select_by_template() threshold 0.68:
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
return False
The common selector only closes the waypoint menu for A5 stash templates:
if type(template_type) == list and "A5_STASH" in template_type:
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
So A1 stash can fail when a waypoint/dialog is left open, and both A1/A5 have no lower-threshold retry.
Exact code that needs to change
src/town/a1.py:
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
src/town/a5.py:
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, telekinesis=True):
return False
src/char/i_char.py:
if type(template_type) == list and "A5_STASH" in template_type:
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
Proposed fix
Change the selector guard in src/char/i_char.py to handle all stash templates:
templates = template_type if isinstance(template_type, list) else [template_type]
if any(template in ["A1_TOWN_0", "A5_STASH", "A5_STASH_2"] for template in templates):
# sometimes waypoint is opened and stash not found because of that, check for that
if is_visible(ScreenObjects.WaypointLabel):
keyboard.send("esc")
wait(0.2, 0.3)
Change A1 stash to retry lower after the default threshold fails:
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.68, timeout=4.0):
Logger.warning("A1 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func, threshold=0.58, timeout=4.0):
return False
Change A5 stash similarly:
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.68, timeout=4.0, telekinesis=True):
Logger.warning("A5 stash: default threshold failed, retrying with lower threshold")
if not self._char.select_by_template(["A5_STASH", "A5_STASH_2"], stash_is_open_func, threshold=0.58, timeout=4.0, telekinesis=True):
return False
Why it will work
The default threshold remains unchanged for normal cases. The lower threshold only runs after a specific stash attempt fails, which narrows the risk of false-positive clicks.
Closing the waypoint menu for A1 and A5 prevents stale UI overlays from blocking the stash templates. This directly addresses the logged sequence where town/stash templates are not found after other town interactions.
The success function already checks for stash/inventory gold buttons:
found = is_visible(ScreenObjects.GoldBtnInventory, img)
found |= is_visible(ScreenObjects.GoldBtnStash, img)
That means a lower-threshold click must still produce the actual stash UI to count as success.
Fresh A1_TOWN_0, A5_STASH, and A5_STASH_2 templates should still be captured if logs continue to show low match confidence. The code change makes the current templates less brittle and prevents open UI overlays from causing avoidable failures.
Priority 4: CTA weapon switch fails
Finding
The CTA code is in src/char/i_char.py. It depends on Config().char["weapon_switch"], which comes from config/params.ini, not config/game.ini:
weapon_switch=w
battle_orders=f6
battle_command=f5
The current CTA routine has two reliability problems:
- It invalidates active-skill cache implicitly by switching weapons but does not reset
_active_skill. - It verifies the switch back by comparing a screenshot of the previous right-skill icon. That can fail when the same skill exists on both swaps, when the icon is visually similar, or when the UI updates slightly late.
Current code:
while time.time() - start < 4:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
wait(0.2, 0.3)
if skills.is_right_skill_selected(["BC", "BO"]):
switch_sucess = True
break
else:
Logger.warning("Failed to find Battle Command, swapping weapons again.")
while time.time() - start < 4:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
else:
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
return switch_sucess
Exact code that needs to change
Add a helper inside IChar and use it whenever the weapon switch key is sent in _pre_buff_cta().
Proposed fix
Add this method to IChar:
def _weapon_switch(self):
keyboard.send(Config().char["weapon_switch"])
self._set_active_skill("left", "")
self._set_active_skill("right", "")
wait(0.55, 0.65)
Change the first CTA-side check from:
if skills.is_right_skill_selected(["BC", "BO"]):
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
to:
if skills.is_right_skill_selected(["BC", "BO"]):
self._weapon_switch()
Change the switch-to-CTA loop from:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
to:
self._weapon_switch()
keyboard.send(Config().char["battle_command"])
Change the switch-back loop from:
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
else:
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
to:
self._weapon_switch()
if not skills.is_right_skill_selected(["BC", "BO"]):
switch_sucess = True
break
skill_after = cut_roi(grab(), Config().ui_roi["skill_right"])
_, max_val, _, _ = cv2.minMaxLoc(cv2.matchTemplate(skill_after, skill_before, cv2.TM_CCOEFF_NORMED))
if max_val > 0.8:
switch_sucess = True
break
Logger.warning("Failed to switch weapon, try again")
wait(0.5)
Also fix the spelling while touching this code:
switch_success = False
instead of:
switch_sucess = False
Why it will work
Resetting _active_skill after weapon swap prevents _select_skill() from skipping a hotkey press because it thinks the old right skill is still selected. Weapon swap changes the available skill bar, so the cache is no longer trustworthy.
The longer wait gives D2R more time to update the skill icon and weapon state before validation. The current 0.4 second delay is close to the UI transition timing and is likely why the failure is intermittent.
The switch-back validation now accepts the most direct state: the right skill is no longer Battle Command/Battle Orders. The old image comparison remains as a fallback, so this still handles cases where BC/BO detection briefly lags.
The configured key should be checked in config/params.ini, not config/game.ini. If the user's actual D2R weapon switch key is Caps Lock, then this line must change:
weapon_switch=w
to:
weapon_switch=capslock
Only do that if the in-game key binding is actually Caps Lock; otherwise leave it as w.