Compare commits

..
14 Commits
Author SHA1 Message Date
alex 74ebef9b22 update params.ini, bot.py, win_input.py 2026-05-22 21:11:59 +02:00
alex 64578db453 add stealth system, key detection, click recorder, FOHdin, new routes, and tooling updates 2026-05-21 23:34:20 +02:00
alex 3d0e102afc document stealth, key detection, click recorder, and FOHdin in README 2026-05-20 20:06:21 +02:00
alex f9f26f36c3 merge main into mine 2026-05-20 20:02:52 +02:00
alex 644c2a106c feat: auto-launch D2R on botty startup
- main.py checks if D2R.exe is running, launches with auto-login if not
- Set char_name=Burr in config for OCR character selection
2026-05-20 09:20:37 +02:00
alex 4b003bd95e feat: auto-login + OCR character selection
- Added bnet_name, bnet_pass, char_name to [general] config
- Config._build_launch_options() appends -bnetname/-bnetpass when set
- character_select.py: OCR fallback when template matching fails
2026-05-20 09:16:48 +02:00
alex f2d23e511d feat: auto-login + OCR character selection
- Added bnet_name, bnet_pass, char_name config options
- Launch options auto-append -bnetname/-bnetpass when credentials set
- character_select.py: OCR fallback when template matching fails
- Scans character list row-by-row, matches by name, clicks and scrolls
2026-05-20 08:55:04 +02:00
alex 07be1a7b9c fix: npc_auto_label global declaration order 2026-05-20 07:44:39 +02:00
alex 8178104612 feat: enhanced stealth layer - click delay, endpoint wobble, behavior model
- custom_mouse.click(): added 50-800ms arrival-to-click delay (human hesitation)
- custom_mouse.stealth_move(): added endpoint wobble (2-5px micro-adjustment)
- stealth.py: new functions for keyboard timing, skill hesitation, wrong waypoint, skill mistakes
- params.ini: 8 new tunable stealth config options with documented defaults

All 46 files using mouse module automatically benefit — stealth is centralized.
2026-05-20 07:22:24 +02:00
alex 23b0f0f66b feat: parallel template search, async mouse moves, NPC auto-label
- template_finder.search(): parallel matching via ThreadPoolExecutor (4 workers)
- utils/custom_mouse.py: async_move() for non-blocking mouse movement
- utils/npc_auto_label.py: detect_visible_npcs() scans 16 NPCs in parallel
2026-05-19 22:47:30 +02:00
alex 2d69b246d9 feat: complete key auto-detection with binary .key parser
- Parse D2R binary .key format (10-byte entries, action=1=skill, action=0=non-skill)
- Detect skill slot bindings and validate against params.ini
- Scan Saved Games for any .key/.keyo file (handles battle tag names)
- Config loads cleanly, warns on skill/non-skill mismatches
2026-05-19 18:32:18 +02:00
alex 4f0f3df8bb docs: improvements reference + d2r-key-detection skill
- Added references/improvements.md tracking all implemented fixes
- Created d2r-key-detection skill for future sessions
2026-05-19 16:50:10 +02:00
alex 2c25b67cf9 fix: target false positives, pickit timing, hardcore death loop
- target_detect: add aspect ratio filtering to reject health bars and immune text
- pickit: add 0.2-0.3s wait after pickup before moving on (fix #939)
- game_controller: detect hardcore mode and stop instead of infinite death loop
- config: add 'hardcore' flag (default 0)

Closes #959, #939, #942
2026-05-19 16:49:24 +02:00
alex f6e700c921 feat: auto-detect D2R key bindings from .key/.keyo file
- Read character .key file from Saved Games / D2R install dir / APPDATA
- Auto-fill empty hotkeys in [char] section (inventory, belt, potions, etc.)
- Match skill slots to configured skills for validation
- Normalize key variants (left alt ~ alt, left shift ~ shift)
- params.ini values always take priority over detected bindings
- Warn on genuine mismatches between config and .key file
2026-05-19 16:46:24 +02:00
325 changed files with 3208 additions and 33053 deletions
-13
View File
@@ -1,21 +1,8 @@
# .coveragerc to control coverage.py
[run]
branch = True
source = src
# Exclude conda/xonsh internal shims that leak into coverage data. The phantom
# is an absolute path like D:\a\...\config-3.py, so a bare "config-3.py" omit
# never matches — use a glob that matches any path ending in config-<n>.py.
omit =
*config-*.py
*/site-packages/*
*/conda-meta/*
[xml]
output = coverage.xml
[report]
# Ignore missing source files (conda shims reference files not in tree)
ignore_errors = True
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
+1
View File
@@ -0,0 +1 @@
PYTHONPATH=./src
-25
View File
@@ -1,25 +0,0 @@
# Botty personal environment overrides.
# Copy this file to ".env" in the repo root and edit your values there.
# ".env" is ignored by git and should never be committed.
#
# Format: BOTTY_<CONFIG_KEY>=<value>
# These override values from config/params.ini and config/custom.ini at runtime.
#
# Common personal settings:
# Bot display/account name used in logs/Discord usernames.
# BOTTY_NAME=zapzap
# Main Discord webhook (status/death/chicken/general messages).
# BOTTY_CUSTOM_MESSAGE_HOOK=https://discord.com/api/webhooks/...
# Optional dedicated loot webhook for item drops.
# BOTTY_CUSTOM_LOOT_MESSAGE_HOOK=https://discord.com/api/webhooks/...
# Optional auto-login credentials (keep private).
# BOTTY_BNET_NAME=your-battlenet-email-or-name
# BOTTY_BNET_PASS=your-battlenet-password
# BOTTY_CHAR_NAME=your-character-name
# Optional path override if you reference it in params.ini:
# BOTTY_SAVED_GAMES_FOLDER=C:\Users\you\Saved Games\Diablo II Resurrected
+47 -204
View File
@@ -1,209 +1,52 @@
name: CI
on:
push:
branches: [main, stable]
pull_request:
branches: [main, stable]
# Cancel in-progress runs on new push
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
name: Botty - CI
on: [pull_request]
jobs:
install-and-test:
name: Install & Test
runs-on: ubuntu-latest
tests:
runs-on: windows-latest
steps:
- name: Checkout (skipped - workspace pre-populated)
run: echo "Using pre-populated workspace"
- name: Set up Python
uses: actions/setup-python@v5
- uses: actions/checkout@v2
- name: Setup Miniconda Python 3.10
uses: actions/setup-python@v2
with:
python-version: "3.10"
# cache disabled for act_runner
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Tesseract OCR
run: |
sudo apt-get update
sudo apt-get install -y tesseract-ocr
- name: Python version
run: python -c "import sys; print(sys.version)"
- name: Syntax check
python-version: '3.10'
# TODO: The below setp only chaches conda packages, need to cache pip seperatly
- name: Cache conda
uses: actions/cache@v2
env:
PYTHONPATH: ./src
run: python -m compileall -q src tools test scripts
- name: Verify core imports
env:
PYTHONPATH: ./src
run: |
python -c "
import sys
sys.path.insert(0, 'src')
modules = [
'config',
'logger',
'screen',
'pather',
'template_finder',
'game_stats',
'health_manager',
'death_manager',
'd2r_image',
'item',
'item.pickit',
'transmute',
'shop',
'char',
'utils',
'utils.os_detect',
'messages',
]
for mod in modules:
try:
__import__(mod)
print(f' {mod}: OK')
except ImportError as e:
print(f' {mod}: FAILED - {e}')
sys.exit(1)
print('All core imports successful.')
"
- name: Verify botty runs (import main modules)
env:
PYTHONPATH: ./src
run: |
python -c "
import sys, os
sys.path.insert(0, 'src')
import ssl
ssl.SSLContext.load_default_certs = lambda *a, **k: None
from version import __version__
print(f' Version: {__version__}')
from config import Config
print(' Config: OK')
from game_controller import GameController
print(' GameController: OK')
from bot import Bot
print(' Bot: OK')
from run.diablo import Diablo
print(' Diablo run: OK')
from run.pindle import Pindle
print(' Pindle run: OK')
from run.arcane import Arcane
print(' Arcane run: OK')
from run.vizier import Vizier
print(' Vizier run: OK')
print('All botty entry modules import successfully.')
"
- name: Verify OCR (pytesseract)
env:
PYTHONPATH: ./src
run: |
python -c "
import sys, os, tempfile
sys.path.insert(0, 'src')
import pytesseract
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
import cv2
import numpy as np
version = pytesseract.get_tesseract_version()
print(f' Tesseract version: {version}')
# Create a simple test image with text
img = np.full((50, 200), 255, dtype=np.uint8)
cv2.putText(img, 'Hello Botty', (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 1)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
cv2.imwrite(f.name, img)
text = pytesseract.image_to_string(f.name, lang='eng').strip()
print(f' OCR result: {text}')
print('OCR pytesseract: OK')
"
- name: Verify OCR (botty ocr module)
env:
PYTHONPATH: ./src
PYTESSERACT_TESSERACT_CMD: /usr/bin/tesseract
run: |
python -c "
import sys, os
sys.path.insert(0, 'src')
# Import the botty OCR module - it reads PYTESSERACT_TESSERACT_CMD from env
from d2r_image.ocr import image_to_text, pytesseract
import cv2
import numpy as np
import tempfile
if pytesseract is None:
print(' pytesseract not available — skipping')
sys.exit(0)
# Create test image (3-channel for invert)
img = np.full((50, 200, 3), 255, dtype=np.uint8)
cv2.putText(img, '123', (80, 35),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
# Run through botty's image_to_text
results = image_to_text(
[img],
model='hover-eng_inconsolata_inv_th_fast',
psm=7,
crop_pad=False,
invert=True,
threshold=25,
)
result_text = results[0].text if results else 'empty'
print(f' OCR result: {result_text}')
print('OCR botty module: OK')
"
- name: Log analyzer tests (self-healing framework)
env:
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
- name: Tests with coverage
env:
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
python -m coverage run -m pytest -v --tb=short --ignore=test/auto/test_self_healing.py --ignore=test/smoke_test.py --ignore=test/test_version_consistency.py
python -m coverage xml --ignore-errors || true
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
continue-on-error: true
# Increase this value to reset cache if environment.yml has not changed
CACHE_NUMBER: 1
with:
name: coverage-report
path: coverage.xml
retention-days: 7
path: ~/conda_pkgs_dir
key: conda-${{ env.CACHE_NUMBER }}-${{hashFiles('environment.yml') }}
- name: Cache pip
uses: actions/cache@v2
env:
# Increase this value to reset cache if environment.yml has not changed
CACHE_NUMBER: 1
with:
path: ~/pip
key: pip-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }}
- name: Install Miniconda
uses: conda-incubator/setup-miniconda@v2
with:
python-version: '3.10'
activate-environment: botty
channel-priority: strict
environment-file: environment.yml
use-only-tar-bz2: true # Needed for caching
- name: Activate conda
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat init powershell
set PYTHONPATH=./src
- name: Pytest & Coverage
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat activate botty
python -c "import sys; print(sys.version)"
coverage run --source=./src -m pytest -v -s
- name: Coverage Report Generation
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat activate botty
coverage xml
+3 -92
View File
@@ -15,75 +15,13 @@ botty_v*/
custom.ini
config/custom.ini
config/custom.*.ini
config/*.local.ini
.vscode
.vs/
# Logs and run reports
log/
log/archive/
log/runs/
log/stats/
*.log
# Scraped data (cached web pages, URLs)
data/d2jsp_pages/
data/d2jsp_topic_urls.txt
# Debug files
debug_*.png
debug_forum.html
# Dev tools (moved to tools/)
asset_extractor.py
asset_manager.py
build.py
desktop_snap.py
development.md
quest_debug.py
quest_plan.md
quest_screenshot_tool.py
references/improvements.md
run_asset_extractor.bat
run_chipped_gems.bat
run_gems_all.bat
run_quest_debug.bat
screenshot_tool.py
start_bot_detached.bat
start_botty.ps1
test_plan.md
update.bat
# Generated/runtime price data (updated automatically by bot)
config/fg_daily_estimates.json
config/fg_prices.json
config/fg_prices.db
config/daily_prices.json
config/daily_prices_history.json
config/traderie_prices.json
# Screenshots (debug/test captures)
screenshots/
# Tools dev cache
tools/__pycache__/
# Build artifacts
botty_v*/
botty_v*.zip
test/assets/
.venv
.env
.env.*
!.env.example
*.bak
.coverage
htmlcov/
# Secrets / account-specific — never commit
cookies.txt
cookies_temp*
*.cookies
config/custom.ini
coverage.xml
utils/live-view/
config/bnip/*
@@ -95,32 +33,5 @@ loot_screenshots/
pickit_screenshots/
*info_log_parsed.txt
*info_*.png
blizzpw.txt
# Local user/workspace files
.claude/
GEMINI.md
test_config_load.py
# Debug directory
botty_debug/
# per-user character profiles (survive git pulls)
config/profiles/
config/active_profile.txt
# Docker / dev-only files (not production)
.dockerignore
Dockerfile
docker-compose.yml
docker-*.bat
DOCKER.md
botty_next/
src/bridge_server.py
src/input_layer/bridge_input.py
.hermes/
fixtures/
test/run/
# Installer output (generated by run_install_capture.bat)
install_log.txt
*.log
log/
+87
View File
@@ -0,0 +1,87 @@
# Botty Improvements Implementation Plan
## Status Legend
- [ ] Not started
- [~] In progress
- [x] Done
- [-] Cancelled / low priority
---
## Phase 1: Key Auto-Detection (Issue #940)
Read D2R .key file and auto-fill hotkeys.
- [x] Create src/utils/key_detector.py module
- [x] VK code mapping (partial — needs review for accuracy)
- [x] Parse .key file (text format: VK action_type param)
- [x] Auto-fill empty [char] hotkeys from detected bindings
- [x] Auto-fill build-specific skill hotkeys (fohdin, hammerdin, etc.)
- [x] Wire into config.py load_data()
- [ ] REVIEW: Verify VK_MAP accuracy (D2R uses its own VK offset scheme)
- [ ] REVIEW: Skill slot-to-config matching is heuristic — may misassign
- [ ] TEST: Verify against actual D2R .key file on user's machine
## Phase 2: Target Detection False Positives (Issues #959/#964)
Health bars and "immune to X" text mistaken for targets.
- [ ] Analyze current get_visible_targets() in target_detect.py
- [ ] Add shape/size filtering: health bars are thin horizontal strips, immune text is small
- [ ] Add aspect ratio check: real targets (poison/freeze auras) are roughly circular/elliptical
- [ ] Add minimum bounding box height constraint (filter out thin text)
- [ ] Optionally: add color temperature check (immune text is yellow/gold, not blue/green)
- [ ] Test with screenshots of edge cases
## Phase 3: Pickit Timing Fix (Issue #939)
Items skipped because bot teleports away before grabbing.
- [ ] Review pickit.py _yoink_item() for timing issues
- [ ] Add configurable pickup_delay parameter (current: fixed timing)
- [ ] Add retry logic: if item still visible after pickup attempt, re-try
- [ ] Add "slow mode" for large/heavy items (framed/magic items may animate longer)
- [ ] Ensure bot doesn't teleport until pickup animation completes
- [ ] Test: verify no "Attempt to pick xyz" warnings followed by teleport
## Phase 4: Parallel Template Search (Issue #848)
Speed up template_finder.search() with threading.
- [ ] Add ThreadPoolExecutor-based search_all_parallel()
- [ ] Keep existing search() for single-template (no overhead)
- [ ] Only parallelize when searching >3 templates simultaneously
- [ ] Benchmark: measure speedup on typical 1280x720 grab
## Phase 5: Async Mouse Moves (Issue #955)
Non-blocking mouse movement.
- [ ] Add async_move() to utils/custom_mouse.py
- [ ] Run movement in background thread
- [ ] Add is_moving() / wait_for_move() synchronization
- [ ] Integrate into game_controller.py for smoother action chains
## Phase 6: Hardcore Chicken Loop Fix (Issue #942)
Prevent infinite death loops on Hardcore characters.
- [ ] Review death_manager.py chicken logic
- [ ] Add max_chicken_count config parameter (default: 3)
- [ ] If max chicken count exceeded on HC, exit gracefully instead of re-entering
- [ ] Add defensive chicken config option (chicken to TP instead of full chicken)
- [ ] Test: verify HC character exits cleanly after N deaths
## Phase 7: Auto-Label NPCs (Issue #950)
Learn vendor identities automatically during gameplay.
- [ ] During town states, detect NPC name plates via OCR
- [ ] Cross-reference detected names with known NPC list
- [ ] Auto-capture NPC templates when confidence is high
- [ ] Store learned templates in assets/npc/
- [ ] This is a long-term feature — lower priority
---
## Priority Order (implement in this order)
1. **Phase 1** - Key auto-detection (already partially done, needs review + test)
2. **Phase 3** - Pickit timing (high impact on loot collection)
3. **Phase 2** - Target detection (high impact on kill reliability)
4. **Phase 6** - Hardcore chicken fix (safety critical)
5. **Phase 4** - Parallel template search (performance)
6. **Phase 5** - Async mouse moves (quality of life)
7. **Phase 7** - Auto-label NPCs (long-term feature)
+561
View File
@@ -0,0 +1,561 @@
# Quest Framework + Den of Evil Plan
## Goal
Build a quest automation framework in botty that can interact with D2R NPCs, handle dialogue,
track quest progress, and run Den of Evil as the first quest -- all usable by a low-level FoHdin.
---
## Architecture
The quest framework is a new subsystem that plugs into the existing botty state machine.
It follows the same patterns as existing runs (approach -> battle -> return to town) but adds
NPC dialogue interaction and quest state persistence.
### New files
```
src/quest/
__init__.py # Exports
quest_manager.py # Quest state machine + persistence (JSON)
quest_dialogue.py # OCR-based NPC dialogue interaction
quest_items.py # Quest item detection/pickup
quest_combat.py # Lightweight combat wrapper (killing trash)
a1/
__init__.py
q_den_of_evil.py # Den of Evil run
```
### Modified files
```
src/npc_manager.py # Add TOWN_MAIDEN NPC constant + templates
src/pather.py # Add A1_ROARING_CANYON + DoE entrance locations
src/bot.py # Add quest state, transitions, handler
src/run/__init__.py # Export DenOfEvil
src/town/a1.py # (optional) Add can_do_den_of_evil method
config/params.ini # Add run_doe to [routes]
config/bnip/ # Add town_maiden.png template
```
---
## Phase 1: Foundation
### 1.1 `src/quest/quest_manager.py`
Purpose: Track which quests are done, persist between sessions, dispatch to quest modules.
```python
class QuestManager:
"""Manages quest state: tracks done/available quests per act, persists to JSON."""
# Quest definitions per act
QUESTS = {
"a1": ["den_of_evil"],
"a2": [], # future: radament, horadric_staff, etc.
...
}
def __init__(self):
self._state_file = "config/quest_state.json"
self._state = self._load()
def is_done(self, quest_name: str) -> bool:
return self._state.get(quest_name, False)
def mark_done(self, quest_name: str):
self._state[quest_name] = True
self._save()
def mark_all_done(self, act: str):
for q in self.QUESTS.get(act, []):
self._state[q] = True
self._save()
def next_pending(self, act: str) -> str | None:
for q in self.QUESTS.get(act, []):
if not self.is_done(q):
return q
return None
def all_done(self, act: str) -> bool:
return all(self._state.get(q, False) for q in self.QUESTS.get(act, []))
def _load(self) -> dict:
if os.path.exists(self._state_file):
with open(self._state_file) as f:
return json.load(f)
return {}
def _save(self):
with open(self._state_file, "w") as f:
json.dump(self._state, f, indent=2)
```
JSON format (config/quest_state.json):
```json
{
"den_of_evil": true,
"search_for_smith": true,
...
}
```
### 1.2 `src/quest/quest_dialogue.py`
Purpose: Talk to NPCs, read dialogue options via OCR, click the right branch.
This is the core of quest automation -- it makes the bot "converse" with NPCs.
```python
class QuestDialogue:
"""OCR-based NPC dialogue interaction for quest conversations."""
# ROI at 1280x720
DIALOGUE_TEXT_ROI = (200, 470, 680, 100) # NPC speech text
DIALOGUE_OPTIONS_ROI = (200, 560, 680, 140) # Player response buttons
DIALOGUE_CLOSE_Y = 670 # Close button area
@staticmethod
def open_dialogue(npc_name: str) -> bool:
"""Walk to NPC and open their dialogue menu."""
from npc_manager import Npc, open_npc_menu
return open_npc_menu(getattr(Npc, npc_name.upper()))
@staticmethod
def read_dialogue() -> dict:
"""OCR the current dialogue box. Returns:
{
'npc_text': str, # What the NPC said
'options': [str, ...], # Response options (may be empty if no choice)
'has_continue': bool # True if just need to click continue
}
"""
img = grab()
npc_text = ocr_roi(img, self.DIALOGUE_TEXT_ROI)
options_text = ocr_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Parse options: split by line, filter out empty, return list
options = [line.strip() for line in options_text.split('\n') if line.strip()]
has_continue = len(options) == 0 or "continue" in options_text.lower()
return {
'npc_text': npc_text.strip(),
'options': options,
'has_continue': has_continue
}
@staticmethod
def click_option(option_text: str) -> bool:
"""Find and click a specific dialogue option by matching text via OCR.
Searches the options ROI for a template match of the option text."""
img = grab()
options_img = cut_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Use template_finder or OCR to locate which button matches
# Then click at that position
...
@staticmethod
def continue_dialogue() -> bool:
"""Click the close/continue button to advance dialogue."""
# Click in the close button area
x, y, w, h = self.DIALOGUE_CLOSE_Y
mouse.click at center of close area
...
@staticmethod
def follow_conversation(expected_options: list[str]) -> bool:
"""Follow a multi-step conversation:
- Read NPC text
- If options present, click the expected one
- If no options, click continue
- Repeat until dialogue closes or unexpected text appears
"""
max_steps = 20 # Safety limit
for i in range(max_steps):
dialogue = self.read_dialogue()
if not dialogue['has_continue'] and dialogue['options']:
# We have a choice - click the expected option
for opt in expected_options:
if opt.lower() in ' '.join(dialogue['options']).lower():
if not self.click_option(opt):
return False
break
else:
Logger.warning(f"Unexpected dialogue options: {dialogue['options']}")
return False
else:
# Just continue
if not self.continue_dialogue():
return False
wait(1.0, 1.5)
# Check if dialogue box is still visible
if not is_visible(ScreenObjects.NPCDialogue):
return True # Done
return False # Hit max steps
```
Key design: `follow_conversation()` takes a list of expected response text. It will
match against whatever options the NPC presents and click the right one. This handles
multi-branch dialogues without hardcoding step-by-step clicks.
### 1.3 `src/quest/quest_combat.py`
Purpose: Lightweight combat for clearing trash during quests. Reuses existing char methods.
```python
class QuestCombat:
"""Combat helpers for quest areas -- reuses existing character combat logic."""
@staticmethod
def clear_area(pather: Pather, char: IChar, path_nodes: list[int],
timeout: float = 60) -> bool:
"""Walk a path while killing monsters until timeout or all nodes cleared.
This is the core of DoE: walk down, kill, walk back."""
return pather.traverse_nodes(path_nodes, char, timeout=timeout, do_combat=True)
@staticmethod
def wait_for_clear(char: IChar, timeout: float = 15) -> bool:
"""Wait until no monsters are visible (area is clear)."""
start = time.time()
while time.time() - start < timeout:
targets = get_visible_targets()
if not targets or len(targets) == 0:
return True
# Attack if enemies present
char.attack()
wait(0.5)
return False
```
### 1.4 `src/quest/quest_items.py`
Purpose: Detect and pick up quest items (gold glow detection).
```python
class QuestItems:
"""Quest item detection and management."""
@staticmethod
def detect_quest_items(img: np.ndarray) -> list[tuple[float, float]]:
"""Detect gold-glowing items on screen (quest items).
Returns list of (x, y) positions in monitor coords."""
quest_item_mask, _ = color_filter(img, Config().colors.get("gold_glow", [
(180, 140, 0), (255, 220, 80)
]))
# Find contours, return centers
...
@staticmethod
def pick_up_quest_items(char: IChar, img: np.ndarray = None) -> bool:
"""Find and pick up any quest items currently visible."""
if img is None:
img = grab()
items = self.detect_quest_items(img)
for pos in items:
char.pick_up_item(pos, item_name="Quest Item")
wait(0.5)
return len(items) > 0
```
---
## Phase 2: NPC & Location additions
### 2.1 Add Town_Maiden to `src/npc_manager.py`
```python
# In class Npc:
TOWN_MAIDEN = "town_maiden" # Act 1, Roaring Canyon
# In _build_npcs():
Npc.TOWN_MAIDEN: {
"head": "town_maiden.png", # Need to capture template
"actions": {} # No trade/identify - just dialogue
}
```
The Town Maiden sits in Roaring Canyon (eastern part of Act 1 town). She has a simple
dialogue: you talk to her to "unlock" the Den of Evil entrance, then you talk to her
again after clearing it to get the XP reward and reset it for another run.
### 2.2 Add locations to `src/pather.py`
```python
class Location:
# ... existing locations ...
# Act 1 Roaring Canyon / Den of Evil
A1_ROARING_CANYON = "a1_roaring_canyon" # Town area where Maiden is
A1_DEN_OF_EVIL_ENTRANCE = "a1_doe_entrance" # Stairs down to DoE
A1_DEN_LEVEL_1 = "a1_doe_level_1"
A1_DEN_LEVEL_2 = "a1_doe_level_2"
A1_DEN_LEVEL_3 = "a1_doe_level_3"
A1_DEN_LEVEL_4 = "a1_doe_level_4"
# (DoE has 3-5 levels depending on game version - need to confirm)
```
Path nodes will need to be added for the Roaring Canyon area and each DoE level.
These are captured via quest_debug.py by walking the path and recording waypoints.
---
## Phase 3: Den of Evil run module
### 3.1 `src/quest/a1/q_den_of_evil.py`
```python
class DenOfEvil:
"""Den of Evil run - Act 1 repeatable quest for XP.
Flow:
1. Ensure character is in Act 1
2. Walk to Roaring Canyon (Town Maiden)
3. Talk to Town Maiden (unlock entrance if needed)
4. Enter Den of Evil
5. Pre-buff (FoH + Conviction for FoHdin)
6. Walk through each level, killing trash
7. Exit back to Roaring Canyon
8. Talk to Town Maiden again for reward
9. Return to town center
"""
name = "run_doe"
# Path nodes per level (to be filled in via quest_debug.py)
LEVEL_PATHS = {
1: [], # Entrance to level 1 stairs
2: [], # Level 1 to level 2
3: [], # Level 2 to level 3
4: [], # Level 3 to level 4 (or final area)
}
def __init__(self, pather, town_manager, char, pickit, runs):
self._pather = pather
self._town_manager = town_manager
self._char = char
self._pickit = pickit
self._runs = runs
self._quest_manager = QuestManager()
self._dialogue = QuestDialogue()
def approach(self, curr_loc: Location, do_buff: bool) -> Location | bool:
"""Get to Roaring Canyon and talk to Town Maiden."""
Logger.info("Run Den of Evil")
# Ensure we're in Act 1
if TownManager.get_act_from_location(curr_loc) != Location.A1_TOWN_START:
curr_loc = self._town_manager.go_to_act(1, curr_loc)
if not curr_loc:
return False
# Walk to Roaring Canyon (Town Maiden area)
if not self._pather.traverse_nodes(
(curr_loc, Location.A1_ROARING_CANYON), self._char, force_move=True
):
return False
# Talk to Town Maiden to unlock/open the Den
if not self._dialogue.open_dialogue("town_maiden"):
return False
# Follow the conversation (expect "Oh no, not again" or similar)
if not self._dialogue.follow_conversation(["Tell me more", "I'll help you"]):
return False
# Enter the Den
if not self._pather.traverse_nodes(
(Location.A1_ROARING_CANYON, Location.A1_DEN_OF_EVIL_ENTRANCE),
self._char, force_move=True
):
return False
return Location.A1_DEN_OF_EVIL_ENTRANCE
def battle(self, do_pre_buff: bool) -> bool | tuple[Location, bool]:
"""Fight through the Den of Evil."""
# Pre-buff
if do_pre_buff:
if not self._char.pre_buff():
return False
# Clear each level
for level in sorted(self.LEVEL_PATHS.keys()):
Logger.info(f"Clearing Den of Evil level {level}")
if not self._pather.traverse_nodes(
self.LEVEL_PATHS[level], self._char, timeout=120, do_combat=True
):
Logger.error(f"Failed to clear DoE level {level}")
return False
# Pick up any quest items / loot
self._pickit.pick_up_items(self._char)
QuestItems.pick_up_quest_items(self._char)
# Walk back to Roaring Canyon
if not self._pather.traverse_nodes(
(Location.A1_DEN_OF_EVIL_ENTRANCE, Location.A1_ROARING_CANYON),
self._char, force_move=True
):
return False
# Talk to Town Maiden for reward
if not self._dialogue.open_dialogue("town_maiden"):
return False
if not self._dialogue.follow_conversation(["Yes", "Thank you"]):
Logger.warning("Failed to collect DoE reward from Town Maiden")
# Mark as done (for non-repeatable quests) or just return success
# Note: DoE is repeatable once per real-day, so we DON'T mark permanently done
# self._quest_manager.mark_done("den_of_evil") # Only if non-repeatable
return (Location.A1_ROARING_CANYON, True)
```
---
## Phase 4: Bot integration
### 4.1 `src/bot.py` changes
```python
# Add import
from quest.a1.q_den_of_evil import DenOfEvil
# In __init__:
self._do_runs["run_doe"] = Config().routes.get("run_doe")
self._doe = DenOfEvil(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)
# In _states list:
# (No new state needed - DoE uses the existing pattern: town -> doe -> end_run -> town)
# In _transitions list (add):
{ 'trigger': 'run_doe', 'source': 'town', 'dest': 'doe', 'before': "on_run_doe" },
# Add 'doe' to end_run source list:
{ 'trigger': 'end_run', 'source': [..., 'doe'], 'dest': 'town', 'before': "on_end_run" },
# Add end_game source:
{ 'trigger': 'end_game', 'source': [..., 'doe'], 'dest': 'initialization', 'before': "on_end_game" },
# Add handler method:
def on_run_doe(self):
res = False
self._do_runs["run_doe"] = False
self._game_stats.update_location("DoE")
self._curr_loc = self._doe.approach(self._curr_loc, not self._pre_buffed)
if self._curr_loc:
set_pause_state(False)
res = self._doe.battle(not self._pre_buffed)
self._ending_run_helper(res)
```
### 4.2 `src/run/__init__.py` changes
```python
# No change needed if DoE lives in src/quest/ (not src/run/)
# But if we want consistency, add:
from quest.a1.q_den_of_evil import DenOfEvil
```
### 4.3 `config/params.ini` changes
```ini
[routes]
; ... existing runs ...
; run_doe (Act 1 Den of Evil - repeatable daily XP)
order=run_doe
```
### 4.4 `config/params.ini` FoHdin config
For a lvl 1 Paladin running DoE, the params.ini needs:
```ini
[char]
type=fohdin
...
[fohdin]
; FoHdin-specific config for low-level DoE runs
teleport=
; No teleport at lvl 1-9, so pathing is on foot
```
---
## Phase 5: Testing workflow
### What needs user input (I cannot see D2R):
1. **Capture Town_Maiden template:**
- Go to Roaring Canyon in Act 1
- Stand near the Town Maiden
- Run `quest_debug.py`, press F4 (NPC detection)
- Paste output so I can save the template
2. **Capture DoE path nodes:**
- Enter the Den of Evil
- Run `quest_debug.py`, press F1 at each waypoint
- Walk from entrance through each level
- Paste outputs so I can build the path arrays
3. **Capture dialogue:**
- Talk to Town Maiden (both before and after clearing)
- Run `quest_debug.py`, press F2 (dialogue OCR)
- Paste output so I can code the conversation flow
4. **Test run:**
- After I write the code, you run botty with `run_doe` in the route order
- Report what happens / paste terminal output
- I iterate based on results
### Lvl 1 Paladin specifics:
- **FoHdin requires FOH skill lvl 6 for Feign of Life passive** -- this needs 3 skill points
in FoH, meaning character level 9 minimum (or level 4 with a +1 skill weapon)
- Before reaching lvl 9, the bot can still run DoE but will be much more fragile
- Recommended: manually level Paladin to ~lvl 4-5 (short runs in Area 1 or 2) before
letting the bot solo DoE with FoH
- The bot pathing should handle the walk-through at low speed with heavy FoH spam
---
## Implementation order
1. Write `quest_manager.py` (simple JSON state tracker)
2. Write `quest_dialogue.py` (OCR-based NPC interaction)
3. Write `quest_items.py` + `quest_combat.py` (lightweight helpers)
4. Add Town_Maiden NPC to npc_manager.py
5. Write `q_den_of_evil.py` (skeleton with placeholder paths)
6. Integrate into bot.py (state, transitions, handler)
7. Update params.ini
8. **USER TESTS** -- captures templates, paths, dialogue
9. I fill in the actual path nodes and dialogue based on your captures
10. Full test run and iterate
---
## File tree after implementation
```
my-botty/
├── config/
│ ├── params.ini # Modified: +run_doe in routes
│ ├── quest_state.json # New: auto-created by QuestManager
│ └── bnip/
│ └── town_maiden.png # New: captured template
├── src/
│ ├── quest/ # New directory
│ │ ├── __init__.py
│ │ ├── quest_manager.py
│ │ ├── quest_dialogue.py
│ │ ├── quest_items.py
│ │ ├── quest_combat.py
│ │ └── a1/
│ │ ├── __init__.py
│ │ └── q_den_of_evil.py
│ ├── npc_manager.py # Modified: +TOWN_MAIDEN
│ ├── pather.py # Modified: +A1_ROARING_CANYON, +A1_DEN_* locations
│ ├── bot.py # Modified: +doe state, transitions, handler
│ └── run/__init__.py # Modified: +DenOfEvil export
```
+10 -124
View File
@@ -34,14 +34,14 @@ main.py (main thread)
### Input Layer (`src/input_layer/`)
Native Windows API input replacement designed to evade kernel-level anti-cheat (Warden). It bypasses common Python libraries like `pyautogui` or `pynput` which can be easily detected.
Native Windows API input replacement (no kernel drivers):
| File | Purpose |
|---|---|
| `win_input.py` | Low-level `ctypes` wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos`. Uses standard Windows user-mode APIs. |
| `mouse_impl.py` | Humanized mouse controller. Features include: Bezier curve trajectories, Gaussian noise (hand tremor), endpoint wobble, and randomized arrival-to-click delays. |
| `hotkey.py` | Polling-based hotkey manager that avoids global hooks. All polling intervals include micro-jitter. |
| `__init__.py` | Drop-in API that shims standard input calls with stealth timing and variable duration automatically. |
| `win_input.py` | ctypes wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos` |
| `mouse_impl.py` | Humanized mouse with Bezier curves, Gaussian distortion, endpoint wobble |
| `hotkey.py` | Polling-based hotkey manager (replaces `keyboard.add_hotkey`) |
| `__init__.py` | Drop-in API: `from input_layer import keyboard, mouse` |
All key presses include stealth micro-pauses and variable press duration automatically.
@@ -53,27 +53,6 @@ Handles D2R window detection and screenshot capture via MSS library. Converts be
Template matching via OpenCV (`cv2.matchTemplate`). Searches against pre-captured asset templates in `assets/templates/`. Returns match position and validity.
**Asset conventions — read before adding or recapturing any template:**
- Every `.png` under the `TEMPLATE_PATHS` roots is loaded recursively and keyed by its
**uppercased filename** (`a5_red_portal.png``A5_RED_PORTAL`). Keys are one flat
namespace across all roots, so filenames must be globally unique — and **never leave a
backup or scratch `.png` anywhere under `assets/`**, or it silently becomes a live
template.
- **Keep templates fully opaque (3-channel, or 4-channel with no zero-alpha pixel).**
`alpha_to_mask` only produces a mask when the image has 4 channels *and* contains a
fully transparent pixel; that mask is then passed to
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)`. **OpenCV only properly supports
masks for `TM_SQDIFF` and `TM_CCORR_NORMED`** — masked `TM_CCOEFF_NORMED` returns
unreliable scores and wandering match positions. A masked template will appear to
"work" in isolation and then fail at random. See CLAUDE.md Bug 23.
- Crop something **structurally stable and unoccluded**. Animated or partly hidden
features (a swirling portal's interior, a ring occluded by scenery) make poor anchors.
- **Validate on held-out frames**: build the crop from one capture, score it against
*other* captures, and — critically — against frames where the subject is **absent**.
A template that scores high on both is matching background, not the subject. Aim for a
clear gap straddling the 0.68 default threshold.
### UI Detection (`src/ui/`)
| File | Detects |
@@ -183,15 +162,12 @@ Singleton that merges config files in priority order:
Supports variable substitution via `[variables]` sections.
### Stealth System (`src/utils/stealth.py`)
### Stealth (`src/utils/stealth.py`)
A multi-tiered approach to mimicking human behavior and evading detection:
- **Tier 1: Input Stealth**: Automatic micro-pauses (20-120ms), variable key press durations (20-200ms), and non-linear mouse paths via `input_layer`.
- **Tier 2: Behavioral Stealth**: Probabilistic "mistakes" such as clicking the wrong waypoint (2.5% chance) or skill hesitation (80-300ms) before casting.
- **Tier 3: Session Stealth**: Randomized run durations (+/-15%), AFK breaks (2-12 mins), and shuffling of farming routes between rotations.
All timing across the bot is routed through `utils.misc.wait()`, which applies Gaussian jitter to every sleep call.
Three-tier stealth system:
- **Tier 1 (Input)**: Micro-pauses, click variance, key press duration, endpoint wobble
- **Tier 2 (Behavior)**: Wrong waypoint chance, skill mistake chance, skill hesitation
- **Tier 3 (Session)**: AFK breaks, run skipping, personality seed per character
### Utilities (`src/utils/`)
@@ -205,17 +181,6 @@ All timing across the bot is routed through `utils.misc.wait()`, which applies G
| `node_recorder.py` | Record new path nodes |
| `stealth.py` | Stealth behavior randomization |
### Auxiliary Tools
Standalone tools located in the root directory for project maintenance and development:
- `asset_manager.py`: Unified interface for auditing, searching, and optimizing template assets.
- `asset_extractor.py`: Screenshot capture and AI-assisted entity cropping workflow.
- `build.py`: PyInstaller wrapper for building production executables.
- `desktop_snap.py`: Lightweight tool for capturing full desktop screenshots.
- `quest_debug.py`: Debugging interface for the questing system.
- `screenshot_tool.py`: Simple utility for taking D2R client area screenshots.
## Data Flow
```
@@ -254,16 +219,6 @@ D2R Game Window
At startup, `key_detector.py` reads the character's `.key`/`.keyo` file from `Saved Games/Diablo II Resurrected/` to auto-detect skill bindings and non-skill keys (inventory, show items, etc.). This eliminates manual key configuration.
Slot-to-key mapping (`CHAR_BINDING_SLOTS`):
| Slot | Config key |
|---|---|
| 41 | `show_belt` |
| 36 | `stand_still` |
| 44 | `weapon_switch` |
| 43 | `force_move` |
If params.ini and the `.keyo` file disagree, params.ini wins and a `"Keeping configured key binding"` line is logged. No disagreement = no log line.
## Coordinate Systems
| System | Origin | Used By |
@@ -274,72 +229,3 @@ If params.ini and the `.keyo` file disagree, params.ini wins and a `"Keeping con
| Relative | Template match position | Inventory grid, NPC interaction |
Conversion functions in `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()`, etc.
## State Machine
`bot.py` uses the `transitions` library. States and transitions are defined at `Bot.__init__` time.
**States**: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
**Key transitions**:
| Trigger | Source | Dest | Notes |
|---|---|---|---|
| `init` | initialization | initialization | Screen detection, routes to create_game or start_from_town |
| `select_character` | initialization | hero_selection | |
| `start_from_town` | initialization/hero_selection | town | |
| `maintenance` | town | town | Heal, buy pots, stash, repair, resurrect merc |
| `run_pindle` | town | pindle | |
| `run_arcane` | town | arcane | |
| `end_run` | any run state | town | TP back; calls `on_end_run` which TPs to town |
| `end_game` | town/any run state | initialization | Save & exit; use when no TP scrolls or unrecoverable |
`end_run` requires working TP scrolls. If charges = 0, trigger `end_game` instead — otherwise the bot loops trying to TP back indefinitely.
## HealthManager — Internal Timing
The background health monitor (`src/health_manager.py`) polls at:
```
interval = max(0, (3/25 - fn_elapsed) * jitter(0.81.2))
```
That's approximately every 3 game frames (96144ms at 25 FPS). The jitter prevents perfectly regular polling patterns from being detectable.
**Rejuv logic**:
1. Minimum 0.60s between rejuv drinks (hit recovery guard).
2. Drinks rejuv if `health ≤ take_rejuv_potion_health` OR `mana ≤ take_rejuv_potion_mana`.
3. "Double rejuv" chicken fires only if `last_drink < 8s` **AND** `health ≤ take_rejuv_potion_health`.
- The HP check is critical: mana-triggered rejuvs can legitimately fire back-to-back at full HP (Hammerdin spending mana fast). Without the HP check, false chickens occur at 99.9% HP.
## PickIt — Item Identity
`GroundItem` has two identity fields:
| Field | Formula | Purpose |
|---|---|---|
| `ID` | `slugify(Name + all as_dict() values including Amount)` | Pickit cache key; different gold amounts = different IDs |
| `UID` | `ID + screen center position` | Deduplication within a single items list; same pile at same coords = same UID |
The fail-detection in `_pick_up_item` uses `item.ID == prev.ID` for gold and `item.UID == prev.UID` for everything else. Two nearby gold piles with different amounts bypass the ID check since they produce different IDs.
`_yoink_item()` always returns `PickedUpResult.PickedUp` regardless of actual success. True pickup failures surface only through `_pick_up_item`'s same-ID/UID repeat detection. On confirmed failure, the item's `ID` is blacklisted in `_cached_pickit_items` so it isn't retried in the same session.
## Configuration Priority
Merge order (highest priority first):
```
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
```
`Config` is a singleton (`__new__` + `data_loaded` class variable). First instantiation loads all files; subsequent calls return the same instance. Key detection runs during `__init__` after `self.char` is populated.
## Known Architectural Issues
See `IMPROVEMENTS.md` for the full list. Highest-priority unresolved items:
- **C10**: `kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API). Can corrupt locks/GIL. Replace with `threading.Event` cooperative shutdown.
- **H12/H14**: Health/death managers use module-level globals for state. HealthManager now has a `_state_lock`; death manager does not yet.
- **M14**: `PickedUpResult` enum has a gap (values 0,1,3,4,5 — missing 2).
- **H10/H11**: `pather.py` (750 lines) and `config.py` are oversized and should be split.
-1029
View File
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
# Botty working-state dependencies (verified 2026-06-11)
Everything required for the bot to run as well as it did during the verified
full Diablo runs on 2026-06-11. If a future setup misbehaves, diff against this.
## Runtime stack
| Layer | Requirement | Verified value |
|---|---|---|
| Python env | conda env `botty` | `C:\ProgramData\miniforge3\envs\botty` (python 3.10.14) — NOTE: `C:\Users\alex\miniforge3\envs\botty` is a broken leftover (no python.exe); `find_python.bat` skips it correctly |
| OCR binary | Tesseract 5.5.0 (winget) | `C:\Program Files\Tesseract-OCR\tesseract.exe` — wired in `src/d2r_image/ocr.py` (env var `PYTESSERACT_TESSERACT_CMD` → PATH → this default). Conda tesseract must stay UNINSTALLED (access violations) |
| OCR backend | pytesseract fallback | tesserocr wheel is DLL-broken (needs Tesseract 4.x libs) — pytesseract is the working path; startup logs `OCR backend: pytesseract (fallback)` |
| Launcher | `run_botty.bat` | sets conda-like PATH, UTF-8, TESSDATA. No exe build exists — bot always runs current source |
## Key python packages (installed, working)
```
opencv-python==4.5.5.64 numpy==1.26.4 pytesseract==0.3.13
mss==7.0.1 beautifultable==1.1.0 colorama==0.4.6
discord.py==2.7.1 aiohttp==3.14.1 certifi==2026.5.20
pillow==12.2.0 rapidfuzz==2.15.1 pywin32==312
psutil==7.2.2 cryptography==48.0.1
```
## D2R requirements (template matching breaks without these)
- Settings must match `assets/d2r_settings.json` — startup warns loudly if not.
Verified in-game 2026-06-11: 1280x720 windowed, resolution scale 100, DLSS OFF,
AA OFF, AO OFF, texture HIGH, character/environment/transparency/shadow LOW.
(DLSS ON was the root cause of the CS template failures earlier that day.)
- Window: bot enforces client area at (5, 98) size 1280x720 (`enforce_d2r_window`).
- Keybinds file: `C:\Users\alex\Saved Games\Diablo II Resurrected\Fistman*.keyo`
(auto-parsed at startup). Skill hotkeys in `config/params.ini` must match the
in-game skill assignments: blessed_hammer=f1, holy_shield=f2, redemption=f3,
vigor=f4, conviction=f5, concentration=f8, teleport=b, BO=7, BC=8.
Use `tools/capture_skill_hotkeys.py` to verify/capture them from the live game.
- Char: hammerdin "fistman", Hell, CTA swap on weapon slot 2.
## Host specifics
- Windows 11 build 26200, display 1920x1200 physical at 125% scaling (1536x960
logical). D2R renders 1:1 physical. Any DPI-unaware automation (PowerShell
SetCursorPos/mouse_event) lands 1.25x off — use the bot's input_layer from the
conda env with `SetProcessDPIAware()` instead.
- Mouse mode: relative (Win11) with SetCursorPos retry fallback (`win_input.py`).
- `max_consecutive_fails=5`, `max_game_length_s=900`, maintenance timeout ~280s,
per-game WP failure budget = 2 (town_manager) are the safety nets that keep a
bad game cheap.
-171
View File
@@ -1,171 +0,0 @@
# Botty First Run Guide
After running `install.bat` and seeing "Installation complete!", follow these steps.
---
## Step 1 — Create your character profile
Botty uses **profiles** to store your character setup. Each profile is a single file in `config/profiles/`.
### Find your character type
Open `config/params.ini` in Notepad, scroll to `[char]`, find `type=`:
| If you play... | Set `type=` to |
|---|---|
| Hammerdin Paladin | `hammerdin` |
| FoH Paladin | `fohdin` |
| Blizzard Sorceress | `sorceress` |
| Bone Necromancer | `necro` |
| Traps Assassin | `trapsin` |
| Amazon | `amazon` |
| Barbarian | `barbarian` |
| Warlock Druid | `warlock` |
### Set your keybinds
In `config/params.ini`, scroll to `[char]` and match these to your D2R key bindings:
```ini
[char]
type=hammerdin ; your build (see table above)
tp_key=b ; Teleport key
stand_still=capslock ; Hold to stand still (capslock is common)
show_belt=k ; Open belt
weapon_switch=w ; Swap weapons (for CTA builds)
force_move=e ; Force movement through mobs
```
**Important:** `stand_still` cannot be `shift` (conflicts with merc healing). `capslock` is the recommended default.
### Pick your runs
Scroll to `[routes]` in `config/params.ini`:
```ini
[routes]
; Enable the runs you want (1 = on, 0 = off)
run_pindle=1 ; Act 5 Nihlathak temple red portal boss
run_countess=0 ; Act 1 Forgotten Tower
run_andariel=0 ; Act 1 Catacombs
run_arcane=0 ; Act 2 Arcane Sanctuary / Summoner (teleport recommended)
run_trav=0 ; Act 3 Travincal council
run_mephisto=0 ; Act 3 Durance of Hate
run_diablo=0 ; Act 4 Chaos Sanctuary (teleport recommended)
run_vizier=0 ; Act 4 Chaos, Vizier only (faster than full Diablo)
run_nihlathak=0 ; Act 5 Halls of Vaught (teleport recommended)
run_eldritch=0 ; Act 5 Frigid Highlands, Eldritch only
run_eldritch_shenk=0 ; Act 5 Eldritch then Shenk in the same game
; Execution order (only includes enabled runs)
order=run_pindle
```
> There is no `run_shenk` option — Shenk is Act 5 and is farmed via
> `run_eldritch_shenk`. The commented route list at the top of the `[routes]`
> section in `params.ini` is always the authoritative set of names.
Common combinations:
- **Pindle only:** `run_pindle=1`, `order=run_pindle`
- **Pindle + Arcane:** `run_pindle=1`, `run_arcane=1`, `order=run_pindle, run_arcane`
- **Full farm:** Enable all you want, set `order=` to your preferred sequence
### Set difficulty
In `[general]`:
```ini
difficulty=hell ; normal, nightmare, or hell
```
Start with `nightmare` if you're testing. Use `hell` for serious farming.
---
## Step 2 — Save as a profile (optional but recommended)
Profiles survive git pulls. To save your setup:
1. Create a folder: `config/profiles/mychar/`
2. Create `config/profiles/mychar/profile.ini` with:
```ini
[general]
name=mychar
difficulty=hell
[char]
type=hammerdin
tp_key=b
stand_still=capslock
show_belt=k
weapon_switch=w
force_move=e
[routes]
run_pindle=1
order=run_pindle
```
3. Select it. The profile is chosen by `config/active_profile.txt`, **not** by
`name=` in `params.ini` (that field is only a display/label value). Either:
- start the bot and press **`end`** to cycle to your profile, or
- put the profile folder name on the first line of `config/active_profile.txt`
At startup the bot logs which one it loaded:
`Profile active: mychar (config\profiles\mychar\profile.ini)`
---
## Step 3 — Launch
1. **Start D2R** — set to 1280x720 windowed mode
2. **Log in** and select your character
3. **Double-click `run_botty.bat`**
4. The bot shows a hotkey menu — press **`f11`** to start or pause it. Press **`f12`** to stop.
> The hotkey menu printed at startup is always the source of truth — it reflects
> your actual `config/params.ini` keys. Do **not** press `insert`: that is
> `restore_settings_from_backup_key`, which overwrites your current D2R settings
> with a previously saved backup.
---
## Step 4 — Optional: Discord notifications
To get bot status in Discord:
1. Create a Discord webhook: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL
2. In `config/params.ini`, under `[general]`:
```ini
custom_message_hook=https://discord.com/api/webhooks/YOUR/WEBHOOK_URL
discord_log_chicken=1
discord_log_errors=1
```
---
## Troubleshooting
**"Could not find botty conda environment"** — Run `install.bat` first.
**"D2R is not running"** — Launch D2R before running `run_botty.bat`, or set `auto_login=1` in params.ini with your Battle.net credentials.
**Bot can't see templates / everything fails** — Make sure D2R is 1280x720 windowed, not fullscreen or borderless.
**OCR not working**`install.bat` sets up OCR automatically. Check the install output for "tesserocr: OK" or "pytesseract: OK". At least one must work.
**Check logs:** Open `log/log.txt` in Notepad for detailed output.
---
## Quick reference: what to edit
| File | What to change |
|---|---|
| `config/params.ini` | `type=`, keybinds, `difficulty=`, routes, Discord webhook |
| `config/profiles/*/profile.ini` | Per-character overrides (optional) |
| `config/game.ini` | **Don't edit** — templates and UI coordinates |
| `config/shop.ini` | **Don't edit** — vendor buy/sell lists |
-323
View File
@@ -1,323 +0,0 @@
# Handover — 2026-08-29
Everything you need to run this yourself. Written after a long debugging session;
`CLAUDE.md` has the deep detail, this is the operating manual.
---
## Running the bot
```bash
run_botty.bat # starts the process (idle)
python scripts/hermes_bot_control.py start # begins playing
python scripts/hermes_bot_control.py status # running=X paused=Y
python scripts/hermes_bot_control.py stop # exits the process
```
**`start` is idempotent** (fixed 2026-08-28) — pressing it twice is safe. `pause`
and `toggle` are the toggle. Before the fix, a repeated `start` paused the bot
and `status` still said `running=True`; that cost ~5 hours once.
**Always verify with `status`, not the "OK: command sent" reply.** And check the
log actually moves — `=== BOT START ===` is the proof it began a game.
### Restarting after a code or config change
A running bot does **not** pick up edits. Python loads modules at process start.
```bash
python scripts/hermes_bot_control.py stop
# wait until nothing is listening:
netstat -ano | grep 18899
run_botty.bat
python scripts/hermes_bot_control.py start
```
If `stop` times out, retry it — the socket occasionally needs two attempts. Only
force-kill as a last resort: killing mid-game leaves D2R in a state the bot
cannot re-enter, and you then have to save+exit to the main menu by hand.
**Only ever run one instance.** Two both bind the control socket and fight over
start/pause, and the logs become nonsense. Check with `tasklist | grep -i python`.
---
## Is it stuck, or just idling?
The bot sits at the D2R **character-select menu** during a normal break. The
stuck case looks identical. Do not judge by the screen.
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
```
| | Normal break | Stuck |
|---|---|---|
| `status` | `running=True paused=True` | the same |
| `select_char` errors | none | present |
| `Restarting bot` | none | every ~20s |
| Log | quiet | new process repeatedly |
**The tell is the log filling with restart lines, not the menu.**
### Break lengths are longer than they look
`maybe_afk_break` calls `wait(m, m*1.5)` and `wait()` applies its own jitter (up
to 1.44x). They compound:
| planned | actual |
|---|---|
| 3.9m | 7.1m |
| 11.9m | 19.5m |
| 20:56 | 25.5m |
So **multiply any break setting by 1.5 x 1.44** before deciding it is safe. A
~25 minute idle is what left D2R unable to re-enter once. `afk_break_max_m` is
capped at 7 for this reason (=> ~15m worst case).
---
## Health check
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt > /tmp/c.log
echo "games $(grep -c 'game | start' /tmp/c.log) | failed $(grep -c 'game | end .*fail' /tmp/c.log) | deaths $(grep -c 'You have died' /tmp/c.log) | crashes $(grep -c 'Uncaught exception' /tmp/c.log)"
```
Useful greps:
| what | grep |
|---|---|
| Step-by-step timeline | `grep "TL>" log/log.txt` |
| Failure records | `grep "FAIL>" log/log.txt` |
| Stealth manifest at startup | `grep "STEALTH>" log/log.txt` |
| Mana threshold crossings | `grep "MANA>" log/log.txt` |
| Level / exp | `ls -t log/stats/mini_stats_*.json \| head -1` |
**`FAIL>` gives the whole story of a failed game** — reason, location, the three
slowest steps, and a breadcrumb trail with `!` marking failures. Read the trail,
not just the reason: the step that blew up is often not the one that caused it.
---
## Temporary settings to revert
| file | setting | now | should be |
|---|---|---|---|
| `config/params.ini` | `session_budget_h` | **20** | 8 |
| `config/params.ini` | `difficulty` | hell | your call |
`session_budget_h = 20` was raised for a levelling push. It rolls to 13-27h,
which largely disables the stop-for-the-day behaviour that the session-rhythm
work exists to provide. **Put it back to 8 once you have the levels.** It is
uncommitted, so `git checkout config/params.ini` reverts it.
---
## Outstanding
**PR #39 is merged and live** (`ec6599f`). Confirmed working 2026-08-29: the
pather abort fired for the first time (`aborting traverse` 1, `taking a random
guess` 8, over 21 games) after being 0-for-152 while it looked correct.
**Travincal (hell) status — 2026-08-29:** 21 games, 2 failed (9.5%), 0 deaths.
Council dies; 10 of 13 runs produced loot. Both failures were `open_wp`, but
with *different* waypoints — `A1_WP` once, `A5_WP` once — because the character's
act varies between games. The A5 case follows a Larzuk repair (stale believed
location, Bug 16 family). Waypoint template matching is now on the critical path
for every Trav game, where Pindle never used it at all.
**FoH is the wrong build for Travincal.** Blessed Hammer does magic damage and
the council are not magic immune; FoH's holy-bolt half only damages *undead*, so
against the living council half its output does nothing. That is the 2-3k vs
~10k gap — not a tuning issue. `atk_len_trav` was raised 6.0 -> 10.0 in the
profile as a stopgap (40s min / 120s max across the four attack sequences).
**Done 2026-08-29** — respecced and verified live: `type=hammerdin`,
`concentration=f6`, `redemption=f3`. Note **Blessed Hammer is NOT on an F-key**:
`_cast_hammers` puts the aura on the right slot and spams left-click, so Hammer
lives on left-click permanently — pressing a hammer hotkey would replace the aura.
`concentration` and `redemption` had to move into `[paladin]`; they were under
`[fohdin]`, which a hammerdin never reads, so both would have been silently
unbound. `atk_len_trav` is now 3.0 (the fight is a fixed clock, so lower = less
exposure, not less damage).
**Trav costs two cross-act waypoint trips per game.** The run leaves the
character in Act 3; maintenance relocates it to Act 4 (stash at `a4_tyrael_stash`,
repair at `a4_halbu`) and the next run then needs the A3 waypoint again. That
travel is *not attributed to any timed step*, so a `FAIL>` trail can show ~38s of
work inside a 250s maintenance window — do not read the trail as the whole cost
here. This blew the 240s budget 5 times; `max_maintenance_time_s` raised to 420.
It also explains the `open_wp` failures: Trav exercises waypoint templates on
every game, where Pindle walked to an in-act portal and never touched them.
Not yet investigated: `a3.py` reports `can_buy_pots`/`can_heal`/`can_stash` all
True, so the trip to Act 4 is *not* a missing A3 capability — something in the
maintenance chain relocates the character. Keeping town business in Act 3 would
remove both waypoint trips and is the real fix if Trav becomes the main route.
(Cosmetic: `a3.py` defines `can_identify` twice, identically — harmless.)
**The `open_wp` failures are NOT a template problem — proven 2026-08-29.**
Scored `a5_wp.png` against all three real failure frames:
* full-frame, no ROI: 0.96-0.98 at (924, 594) — but that is the **belt/mana-orb
HUD false positive** the `a5.py` comment already documents, not the stone. The
`cut_skill_bar` ROI exists precisely to exclude it. Do not "confirm" this
template by scoring without the ROI; it produces a confident wrong answer.
* inside the real `cut_skill_bar` ROI (0,0,1284,653): **0.436-0.480** against a
0.55 threshold, at scattered positions (1,401) / (712,519) / (32,520). Wandering
match positions = noise. `a5_wp_2` (masked, 4-channel) scores 0.26-0.29.
The waypoint is genuinely **not on screen**: the character never reaches it.
Recapturing the template would fix nothing. The cause is stale believed location
after town business happens in a different act (Bug 16 family), consistent with
the `taking a random guess` lines in the same failures.
**Therefore: keeping Trav town business in Act 3 is the real fix**, not a
performance tweak. It removes the A5 waypoint dependency, the two cross-act
trips, and the maintenance-timeout pressure in one change. 7 of 8 waypoint
misses were `A5_WP`; `A1_WP` was a single one-off.
**A3 town landmark coverage is the root cause of the Trav failures (2026-08-29).**
Measured against the two `info_npc_menu_timeout` frames where the bot was stuck:
| frame | a3_town landmarks >=0.68 | >=0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
With zero landmarks over threshold the pather cannot localise at all — hence
6 `taking a random guess` across 2 traverses to `a3_ormus`. The character then
ends up somewhere arbitrary, which produces all three symptoms from one cause:
Ormus not in his ROI, the waypoint not on screen, and maintenance burning its
budget.
Ormus himself is fine: best match INSIDE his ROI (444,13,372,318) is 0.342/0.334
— noise — while the global best (0.479/0.508) sits *outside* it. Do NOT lower
the body threshold; 0.34 is background level and dropping the bar there invites
the Bug 4 false-positive clicks. The NPC is not there to be found.
**CORRECTION to the earlier entry in this file:** "keeping Trav town business in
Act 3 is the real fix" was wrong. That was based on `a3.py` reporting
`can_buy_pots`/`can_heal`/`can_stash` as True — a capability check, not evidence
about pathing. A3 is in fact the worst-supported town in the project; moving more
work into it makes things worse. The fix is A3 landmark coverage.
New pather node templates need the character's absolute position at capture time,
so they cannot be made from saved frames — this needs a live capture pass in A3.
**BELT WAS HALF EMPTY — found 2026-08-30 after the session's only death.**
```ini
belt_hp_columns=1 # 4 healing potions
belt_mp_columns=1 # 4 mana potions
belt_rejuv_columns=2 # 8 slots for potions VENDORS DO NOT SELL
```
Rejuvenation potions cannot be bought in D2 — they only drop. So the bot asked
for `rejuv=8` on every restock, never got them, and ran Travincal on **4 healing
potions** with half the belt permanently empty. 50 `Failed to drink rejuv` events
in 200 games. The death: drank at 47%, chickened at 33.8%, died in the gap with
nothing left to drink. Many of the 37 chickens were likely "belt empty", not burst
damage.
Now `belt_hp_columns=2 / mp=1 / rejuv=1` (8 healing, 4 mana, 4 slots for dropped
rejuvs — `convert_rejuv=1` cubes them to Full). **Verify after restart that the
HP columns actually fill.**
**Repair NPC — settled by measurement (2026-08-30):**
| NPC | result |
|---|---|
| `a5_larzuk` | 6 fail / ~250 — **2.4%** ← use this |
| `a4_halbu` | 6 fail / ~50 — **12%** |
| `a1_charsi` | 0 for 3 — pathing never reaches her |
Bug 12's original reasoning holds: Halbu detection is unreliable even after Bug
30's threshold fix. Charsi failed for a different reason — `Traverse from
a1_wp_north to a1_charsi` fails, so `open_npc_menu` never runs and her "she does
not move" advantage is never tested. A1's wp->charsi route is broken like A3.
**A `repair_npc` code fix went in:** the cross-act destination used to be
hardcoded to A5, so the setting was silently ignored on every route not already
standing in A5. It is now honoured (`town_manager.repair()`).
**Damage profile at hell Travincal** (confirmed from screenshots): council cast
**Hydra (fire), Lightning/Charged Bolt, and Frost Nova (cold)**. Lightning is both
the most common and the weakest resist (~48% with Mara) — **Thundergod's Vigor**
(+10 max lightning res, 20% absorb, +20 vit) is the targeted upgrade, ahead of
Verdungo's. Fire is well covered by Dwarf Star's absorb.
**Do NOT raise `atk_len_trav` to reduce the "running around".** Hydras are
stationary fire turrets, so `kill_council`'s repositioning is actively dodging
sustained damage. 3.0 is correct.
**Merc blocks gold pickups.** `Mizan says: I can't use that` = a pickit click
landing on the merc. ~4 gold piles lost per 18 games. The bot retries twice then
moves on; not worth fixing.
**Lightning charm rules added** to `config/bnip/Den gode.bnip` (gitignored,
backup at `.bak`), 558 -> 561 expressions. The existing resist rules sum all four
resists (`>= 12` for smalls), and a max single-res lightning small charm is 11 —
so pure lightning charms were picked up and then **vendored**. Two older rules
existed but each required a second stat (`[Maxhp] >= 10`, `[Fhr] >= 3`).
**Current bind map (verified live 2026-08-29) — do NOT rebind F7:**
| key | skill | | key | skill |
|---|---|---|---|---|
| F1 | Battle Command (CTA) | | F5 | Teleport |
| F2 | Holy Shield | | F6 | **Concentration** |
| F3 | Redemption | | F7 | **Battle Orders (CTA)** |
| F4 | Town Portal (not a skill) | | F8 | free (Vigor if wanted) |
Blessed Hammer is on **left-click**, not an F-key. An earlier version of this file
said to bind Concentration to F7 — that would overwrite Battle Orders and cost a
large part of the life pool.
**Two things only you can do:**
1. **Resistances** — lightning 23% / poison 12% are the survivability ceiling.
Herald of Zakarum + Mara's took health chickens from 3-in-8 to **0-in-11**, so
this is largely addressed; the remaining holes are gear, not config.
2. **75% FCR** — currently 60% (HotO 40 + Trang's 20). The 48→75 breakpoint means
everything between is wasted; Arachnid Mesh in place of Goldwrap closes it.
**If FCR changes, update `casting_frames`**: 75% = 11 frames, 60% = 12, and
below 48% = 13. The bot derives its cast wait from that number, so a wrong
value cuts every buff short.
**Known-stale:** the `CONVICTION` preflight template scores 45.9% while the bind
is provably correct, so every startup logs a false alarm. Cosmetic.
---
## Checking your binds after any gear change
```bash
python tools/testbed.py spellbook --assets
```
Hovers the whole bind grid, reads each skill from its tooltip and each hotkey
from the icon corner, and prints which config keys disagree with the game. Exits
1 on a mismatch. This exists because an Enigma put Teleport on F5 and displaced
Conviction, and `conviction=f5` would have teleported the character mid-fight.
---
## The one habit worth keeping
Most of what went wrong here was **configured behaviour that never executed**,
and nothing reported it. AFK breaks were 0-for-225 at a configured 5%. The
manifest said `wired` throughout.
When something should be happening and you are not sure it is, **count it**:
```bash
grep -c "<the thing>" log/log.txt
```
A zero where you expected a number is the most informative result in this
project. It found the AFK break, the loot-filter clicks, the Chronicle panel,
and the pather abort — twice.
+19 -9
View File
@@ -24,9 +24,20 @@ Character: FOH Paladin | Priority: Anti-cheat stealth > everything else
## CRITICAL - DO FIRST
- [x] **C1. Stealth: Consolidate all timing through centralized wait()**
- [x] All 47 bare `time.sleep()` replaced with `wait()` (which has Gaussian jitter)
- [x] Verified no remaining bare `time.sleep()` in `src/` (except inside `utils.misc.wait`)
### C1. Stealth: Consolidate all timing through centralized wait()
Many places use bare `time.sleep()` instead of `utils.misc.wait()` (which has Gaussian jitter).
Every direct sleep creates a predictable timing signature detectable by anti-cheat.
Files with bare `time.sleep()`:
- health_manager.py line 14
- chest.py
- pather.py
- game_recovery.py
- npc_manager.py
- bot.py
Fix: Replace all bare `time.sleep(n)` with `wait(n, n*1.2)` for human-like jitter.
### C2. Stealth: Add variable typing rhythm
@@ -72,12 +83,11 @@ Fix: Add per-segment timing variation in `HumanCurve` execution loop.
### C9. ~~Bug: Fix FoHdin missing PickIt (bot.py line 72)~~ ~~(DONE)~~
- [x] **C10. Bug: Replace thread killing with cooperative shutdown**
- [x] `utils.misc.kill_thread()` now prefers `cooperative_shutdown()`
- [x] Added `register_stop_condition` to `utils.misc`
- [x] Centralized `wait()` and `search_and_wait()` now check for shutdown signals
- [x] `Bot`, `HealthManager`, and `DeathManager` register their stop conditions
### C10. ~~Bug: Replace thread killing with cooperative shutdown~~
~~`utils.misc.kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API).~~
~~This can leave locks in inconsistent state, cause GIL issues, or corrupt numpy arrays.~~
~~Fix: Replace with threading.Event flags for cooperative shutdown.~~
(Still needs doing - this is the most dangerous remaining bug.)
---
+4 -76
View File
@@ -4,49 +4,13 @@
Pixelbot for Diablo 2 Resurrected. This project is for informational and educational purposes only.
## Installation (first time)
**Step 1 — Install Miniforge** (only needed once, skip if you already have conda/Miniconda)
Download and run the installer from: https://github.com/conda-forge/miniforge/releases/latest
Pick the Windows x86_64 `.exe`. Keep defaults; tick "Add to PATH" if asked.
**Step 2 — Download Botty**
Click the green **Code** button on this GitHub page → **Download ZIP**. Extract the ZIP anywhere (e.g. `C:\botty`).
**Step 3 — Install dependencies**
Double-click **`install.bat`** inside the extracted folder. It will create the `botty` conda environment and install everything. This takes a few minutes the first time.
`install.bat` detects Windows 10 vs Windows 11 automatically:
- Windows 10 uses `environment-win10.yml`, `requirements-win10.txt`, and absolute mouse input.
- Windows 11 uses `environment-win11.yml`, `requirements-win11.txt`, and relative mouse input.
Botty also runs OS detection at startup and logs the selected requirements profile and mouse mode.
**Step 4 — Configure**
Open `config\params.ini` in Notepad and set at minimum:
- `[char] type=` — your build (`sorceress`, `hammerdin`, `paladin`, `trapsin`, …)
- `[routes] order=` — which bosses to farm (e.g. `run_pindle`)
- Hotkeys under your build's section to match your D2R keybinds
**Step 5 — Start**
Double-click **`run_botty.bat`**. Switch to D2R, go to the hero selection screen, then press **F11** to start. Press **F12** to stop.
> D2R must be in **English** and running at **720p** window mode.
Optional personal setup:
- Copy `.env.example` to `.env` in the repo root and set personal values there.
- `.env` is git-ignored so multiple testers can use different local values without git conflicts.
---
## Getting started & Prerequisites
- D2R needs to be in English Language,
- Botty currently works with 720p D2R window (will be adjusted automatically on auto settings)
### 1) Graphics and Gameplay Settings
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default **ctrl+f9**). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default f9). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
**Note**: Make sure that no other external programs adapt your graphics settings (HDR, Geforce Experience, etc.)
### 2) Supported builds
@@ -59,20 +23,13 @@ Open up D2R and wait till you are at the hero selection screen. Make sure the ch
### 4) Start Botty
Refer to [development.md](development.md) for setup instructions. Once the conda environment is created:
- **Quick start**: Double-click `run_botty.bat` (auto-detects your conda env)
- **Manual**: `conda activate botty` then `python src\main.py`
After starting, focus your D2R window and press the start key (default f11). You can always force stop botty with f12. Note: Botty will use the /nopickup command in the first game to avoid pickup up trash while traversing. This command will only allow item pickup when "show items" is active.
### Stability and safety updates
- XP OCR parsing now tolerates common OCR mistakes (`I/l/| -> 1`, `O/o -> 0`, mixed-case "experience").
- XP status math no longer throws on early-session edge cases; unavailable projections show as `n/a`.
- Routine repair is best-effort for native-teleport builds to reduce fail spirals when NPC detection is flaky.
- Repair fallback from A5 now attempts A4 from Larzuk location for better path reliability.
- Discord message sending now guards invalid embed payloads and has a plain-text fallback.
- Selling now logs item names (not just positions) and shields are protected by default.
## Development
Check out the [development.md](development.md) docu for infos on how to build from source and details of the project structure and code.
@@ -142,10 +99,7 @@ order=run_pindle, run_eldritch
| [routes] | Descriptions |
| ------------ | ------------------------------------------------------------------------ |
| order | Comma-delimited run list. If `randomize_runs=0`, Botty executes left-to-right. If `randomize_runs=1`, enabled runs are shuffled each game. Possible runs: </br> run_trav, run_pindle, run_eldritch, run_eldritch_shenk, run_nihlathak (teleport strongly recommended), run_arcane (teleport strongly recommended), run_diablo (teleport recommended), run_vizier, run_andariel, run_countess, run_mephisto, run_baal |
Hammerdin keyrun example (stable-first):
`order=run_countess, run_arcane, run_nihlathak`
| order | List of runs botty should do. These will be run in the the order listed unless `randomize_runs` is set to 1. Possible runs: </br> run_trav, run_pindle, run_eldritch, run_eldritch_shenk, run_nihlathak (requires teleport), run_arcane (requires teleport), run_diablo (requires teleport, only hammardin)
| [char] | Descriptions |
| ------------------ | -------------------------------------------------------------------------------------------------|
@@ -244,32 +198,6 @@ python tools/click_recorder.py playback # replay with human-like timing
Records mouse clicks with timestamps and replays them with configurable speed and repeat count.
### Asset Manager
A comprehensive tool for managing bot assets (templates). It helps with inventorying, auditing, searching, and optimizing template images.
```bash
python asset_manager.py inventory # List all templates
python asset_manager.py audit # Check for missing/low-quality templates
python asset_manager.py search <name> # Search for a specific template
python asset_manager.py quality # Analyze template quality (SNR, contrast)
python asset_manager.py batch resize 64x64 # Batch process templates
```
Features include similarity checking (to find duplicates), automatic cropping, and validation of template paths.
### Asset Extractor
A workflow tool for capturing and cropping new templates from D2R screenshots. Designed to work alongside an AI agent for rapid asset generation.
```bash
python asset_extractor.py
```
- **F1**: Capture D2R screen to `screenshots/debug/latest.png`.
- **F2**: Crop entities using an AI-generated `latest_annotations.json` file.
- **F3**: List all currently extracted assets.
### Builds
|| [sorceress] | Descriptions |
|| ------------- | ----------------------------------------------------------------------------- |
+2 -2
View File
@@ -8,14 +8,14 @@
"Screen Resolution (Windowed)": "1280x720",
"Resolution Scale": 100,
"Sharpening": 6,
"Game Resolution": 2,
"Game Resolution": 1,
"Light Quality": 2,
"Blended Shadows": 0,
"Perspective": 0,
"VSync": 1,
"Framerate Cap": 60,
"Framerate Target": 0,
"Window Mode": 1,
"Window Mode": 0,
"Graphic Presets": 4,
"Texture Quality": 4,
"Texture Anisotropy": 0,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
3LONE*dkbcy^v0I8
+108
View File
@@ -0,0 +1,108 @@
import os
import shutil
from pathlib import Path
from src.version import __version__
import argparse
import getpass
import random
from cryptography.fernet import Fernet
import string
parser = argparse.ArgumentParser(description="Build Botty")
parser.add_argument(
"-v" , "--version",
type=str,
help="New release version e.g. 0.4.2",
default=""
)
parser.add_argument(
"-c", "--conda_path",
type=str,
help="Path to local conda e.g. C:\\Users\\USER\\miniconda3",
default=f"C:\\Users\\{getpass.getuser()}\\miniconda3")
parser.add_argument(
"-r", "--random_name",
action='store_true',
help="Will generate a random name for the botty exe")
parser.add_argument(
"-k", "--use_key",
action='store_true',
help="Will build with encryption key")
args = parser.parse_args()
# clean up
def clean_up():
# pyinstaller
if os.path.exists("build"):
shutil.rmtree("build")
if os.path.exists("main.spec"):
os.remove("main.spec")
if os.path.exists("health_manager.spec"):
os.remove("health_manager.spec")
if os.path.exists("shopper.spec"):
os.remove("shopper.spec")
if __name__ == "__main__":
new_version_code = None
if args.version != "":
print(f"Releasing new version: {args.version}")
os.system(f"git checkout -b new-release-v{args.version}")
botty_dir = f"botty_v{args.version}"
version_code = ""
with open('src/version.py', 'r') as f:
version_code = f.read()
version_code = version_code.split("=")
new_version_code = f"{version_code[0]}= '{args.version}'"
with open('src/version.py', 'w') as f:
f.write(new_version_code)
else:
botty_dir = f"botty_v{__version__}"
print(f"Building version: {__version__}")
clean_up()
if os.path.exists(botty_dir):
for path in Path(botty_dir).glob("**/*"):
if path.is_file():
os.remove(path)
elif path.is_dir():
shutil.rmtree(path)
shutil.rmtree(botty_dir)
for exe in ["main.py", "shopper.py"]:
key_cmd = " "
if args.use_key:
key = Fernet.generate_key().decode("utf-8")
key_cmd = " --key " + key
installer_cmd = f"pyinstaller --onefile --noconsole --distpath {botty_dir}{key_cmd} --exclude-module graphviz --exclude-module keyboard --exclude-module mouse --exclude-module pyclick --exclude-module mouseinfo --paths .\\src --paths {args.conda_path}\\envs\\botty\\Lib\\site-packages src\\{exe}"
os.system(installer_cmd)
os.system(f"cd {botty_dir} && mkdir config && cd ..")
with open(f"{botty_dir}/config/custom.ini", "w") as f:
f.write("; Add parameters you want to overwrite from param.ini here")
shutil.copy("config/game.ini", f"{botty_dir}/config/")
shutil.copy("config/params.ini", f"{botty_dir}/config/")
shutil.copy("config/shop.ini", f"{botty_dir}/config/")
shutil.copy("config/default.bnip", f"{botty_dir}/config/")
os.makedirs(f"{botty_dir}/config/bnip", exist_ok=True)
shutil.copy("README.md", f"{botty_dir}/")
shutil.copytree("assets", f"{botty_dir}/assets")
clean_up()
if args.random_name:
print("Generate random names")
new_name = ''.join(random.choices(string.ascii_letters, k=random.randint(6, 14)))
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
# Always rename main.exe to avoid Warden flagging the obvious name
if not args.random_name:
new_name = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe')
print(f"Renamed main.exe -> {new_name}.exe")
if new_version_code is not None:
os.system(f'git add .')
os.system(f'git commit -m "Bump version to v{args.version}"')
-15
View File
@@ -1,15 +0,0 @@
@echo off
setlocal
set "BOTTY_DIR=%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%BOTTY_DIR%tools\check_dependencies.ps1"
set "RC=%ERRORLEVEL%"
if not "%RC%"=="0" (
echo.
echo Dependency check reported issues.
)
pause
exit /b %RC%
-23
View File
@@ -1,23 +0,0 @@
; Personal overrides for your local machine/user.
; This file is tracked as an example.
; Copy to config/custom.ini and edit values there.
;
; Botty auto-loads config/custom.ini when present.
[general]
; name=botty
; custom_message_hook=
; custom_loot_message_hook=
; discord_status_runs=10
[char]
; type=blizz_sorc
; atk_len_pindle=3.0
; show_items=alt
[sorceress]
; teleport=t
[blizz_sorc]
; blizzard=f1
; ice_blast=f2
+433 -572
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
{
"generated_at": "2026-08-31T06:30:30.597153+00:00",
"mode": "offline-improved",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-20",
"days": 21,
"topics_scanned": 20,
"skipped": {
"blocked": 2,
"no_topic": 0,
"no_date": 848,
"no_item": 4
},
"filters": {
"min_price_fg": 5.0,
"trim_fraction": 0.2
},
"estimates": {
"day_1": {},
"day_2": {},
"day_3": {},
"day_4": {},
"day_5": {},
"day_6": {
"Cham Rune": {
"median_fg": 7.5,
"avg_fg": 10.0,
"trimmed_mean_fg": 7.5,
"trimmed_median_fg": 7.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 4,
"raw_samples": 6
}
},
"day_7": {
"Aldur's Advance": {
"median_fg": 20.0,
"avg_fg": 21.7,
"trimmed_mean_fg": 20.0,
"trimmed_median_fg": 20.0,
"min_fg": 20.0,
"max_fg": 25.0,
"samples": 3,
"raw_samples": 6
},
"Ist Rune": {
"median_fg": 70.0,
"avg_fg": 94.0,
"trimmed_mean_fg": 66.7,
"trimmed_median_fg": 70.0,
"min_fg": 20.0,
"max_fg": 250.0,
"samples": 5,
"raw_samples": 16
},
"Gul Rune": {
"median_fg": 65.0,
"avg_fg": 65.0,
"trimmed_mean_fg": 65.0,
"trimmed_median_fg": 65.0,
"min_fg": 20.0,
"max_fg": 110.0,
"samples": 2,
"raw_samples": 4
},
"Unid Anni": {
"median_fg": 575.0,
"avg_fg": 575.0,
"trimmed_mean_fg": 575.0,
"trimmed_median_fg": 575.0,
"min_fg": 550.0,
"max_fg": 600.0,
"samples": 2,
"raw_samples": 2
}
},
"day_8": {},
"day_9": {},
"day_10": {},
"day_11": {},
"day_12": {},
"day_13": {},
"day_14": {},
"day_15": {},
"day_16": {},
"day_17": {},
"day_18": {},
"day_19": {},
"day_20": {},
"day_21": {}
}
}
-50
View File
@@ -1,50 +0,0 @@
{
"notes": "Estimated Day 1-14 ladder prices derived from Day 3 snapshot using decay multipliers. Use as planning guidance, not exact market truth.",
"source_day": 3,
"day_multipliers": {
"day_1": 1.45,
"day_2": 1.2,
"day_3": 1.0,
"day_4": 0.93,
"day_5": 0.88,
"day_6": 0.84,
"day_7": 0.8,
"day_8": 0.77,
"day_9": 0.74,
"day_10": 0.72,
"day_11": 0.7,
"day_12": 0.68,
"day_13": 0.66,
"day_14": 0.64
},
"base_day_3_fg": {
"Cham Rune": 800,
"Lo Rune": 900,
"Ohm Rune": 600,
"Vex Rune": 400,
"Gul Rune": 200,
"Ist Rune": 200,
"Mal Rune": 120,
"Um Rune": 90,
"Pul Rune": 50,
"Lem Rune": 70,
"Unid Anni": 400,
"Unid Torch": 1500,
"Unid Griffon": 2000,
"Unid Eth Andy": 2000,
"Shako": 400,
"Mara 30": 1200,
"Mara Mid": 750,
"BK 5": 600,
"War Traveler": 500,
"Death's Fathom": 800,
"5/5 Facet": 500,
"5@res SC": 300,
"20life SC": 100,
"7mf SC": 100,
"Pcomb SK": 200,
"Cold SK": 250,
"Java SK": 200,
"Light SK": 200
}
}
+13 -20
View File
@@ -18,7 +18,7 @@ rejuv_potion=140,50,40,160,255,255
skill_charges=70,30,25,150,163,255
health_globe_red=178,110,20,183,255,255
health_globe_green=47,90,20,54,255,255
mana_globe=110,50,15,125,255,255
mana_globe=117,120,20,121,255,255
blue_slot=102,194,18,138,230,54
green_slot=33,181,18,87,258,69
red_slot=161,204,28,197,240,64
@@ -51,8 +51,8 @@ potion1_y=695
potion_width=30
potion_height=30
potion_next=41
merc_health_top=675
merc_health_left=305
merc_health_top=14
merc_health_left=15
merc_health_width=40
; skills
skill_y=693
@@ -88,7 +88,7 @@ play_btn=426,616,320,71
difficulty_select=536,236,210,320
gold_btn=997,521,20,18
inventory_gold=980,510,150,40
gold_btn_stash=158,518,30,30
gold_btn_stash=160,520,25,25
vendor_gold_digits=186,509,97,16
stash_gold_digits=184,527,100,16
inventory_gold_digits=1017,523,100,16
@@ -97,16 +97,16 @@ health_globe=160,580,240,140
mana_globe=887,580,240,140
health_slice=309,610,7,101
mana_slice=961,610,7,101
cut_skill_bar=0,0,1284,653
cut_skill_bar=0,0,1280,653
reduce_to_center=120,60,1040,540
search_npcs=120,0,1042,620
merc_icon=10,9,56,56
search_npcs=120,0,1040,620
merc_icon=0,0,100,100
loading_left_black=0,0,350,720
death=444,198,397,71
tp_search=353,120,547,400
repair_btn=318,473,90,80
left_inventory=33,84,382,382
right_inventory=868,348,379,152
left_inventory=35,86,378,378
right_inventory=866,348,379,152
transmute_third_slot=242,342,38,38
skill_right=664,673,41,41
skill_right_expanded=655,375,385,255
@@ -128,19 +128,12 @@ cube_btn_roi=160,368,125,57
xp_bar_text=369,630,554,34
corpse=459,195,414,213
chat_icon=7,555,43,41
left_panel_header=0,0,455,56
right_panel_header=830,0,455,56
; A CENTRED panel (Chronicle, and anything else that opens mid-screen) puts its
; close button outside both header ROIs above — the Chronicle's X sits at
; (952, 56), inside right_panel_header's x-range but exactly at its 56px height
; boundary, so the match centre falls out. Nothing then closes it, and a
; centred panel blanks every later template search: measured as a 66s
; click_red_portal failure on 2026-08-28 (Bug 31's residual).
center_panel_header=400,0,620,92
npc_dialogue=456,0,30,150
left_panel_header=0,0,450,54
right_panel_header=830,0,450,54
npc_dialogue=458,4,21,140
bind_skill=516,619,251,29
quest_skill_btn=284,455,710,121
left_inventory_tabs=29,60,389,30
left_inventory_tabs=31,62,385,28
tab_indicator=31,71,385,14
stash_page_select_left=155,475,20,20
stash_page_select_right=270,475,20,20
View File
+75 -468
View File
@@ -1,166 +1,58 @@
; There is detailed documentation for each parameter in the README.md
[general]
; Personal values can reference env keys from repo-root .env:
; Example syntax:
; custom_message_hook=${BOTTY_CUSTOM_MESSAGE_HOOK}
; custom_loot_message_hook=${ENV:BOTTY_CUSTOM_LOOT_MESSAGE_HOOK}
; bnet_name=${BOTTY_BNET_NAME}
; bnet_pass=${BOTTY_BNET_PASS}
; char_name=${BOTTY_CHAR_NAME}
; difficulty: game difficulty to create ("normal", "nightmare", "hell")
;
; Hammerdin difficulty guide (CTA build with Conviction aura):
; NORMAL - Very easy. 1-2h Hammer kills most packs. No CTA needed.
; NIGHTMARE - Recommended starting point. Bosses hit ~100-200 dmg.
; CTA + Conviction drops resists. Need decent armor (ED/HR).
; Gear targets: ~200 AR, ~15 ED, ~30% HR, ~50% FCR on hammer.
; HELL - Bosses hit 400-800+ dmg per swing. Conviction is mandatory.
; Gear targets: ~350+ AR, ~20+ ED, ~50%+ HR, ~60%+ FCR,
; ~150% IAS on weapon. Full Rejuv belt recommended.
; If you chicken/die repeatedly, drop to Nightmare first.
difficulty=hell
; name: bot profile name used in logs/messages and mod launch option replacement
name=profile1
; randomize_runs: 0 = run in listed order, 1 = shuffle run order
difficulty=normal
name=bigfont
randomize_runs=0
; target_tz: target Terror Zone id (leave as default unless you know the mapping)
target_tz=1
; saved_games_folder: optional override path to D2R Saved Games folder (blank = auto)
saved_games_folder=
; level_max_steps: max pathing steps for leveling-style routines
saved_games_folder=C:\Users\alex\Saved Games\Diablo II Resurrected
level_max_steps=20
; Set to 1 to enable auto-login and auto-launch of D2R on startup.
; Credentials below are ONLY used when auto_login=1.
auto_login=0
bnet_name=${BOTTY_BNET_NAME}
bnet_pass=${BOTTY_BNET_PASS}
; Battle.net credentials (for auto-login at launch)
; Leave empty to log in manually
bnet_name=
bnet_pass=
; Character name to auto-select from the character selection screen
; If empty, bot relies on the saved character template from previous sessions
char_name=${BOTTY_CHAR_NAME}
char_name=Burr
; messaging
; custom_loot_message_hook: optional separate webhook for loot notifications
custom_loot_message_hook=${BOTTY_CUSTOM_LOOT_MESSAGE_HOOK}
; custom_message_hook: main webhook for status/death/chicken messages
custom_message_hook=${BOTTY_CUSTOM_MESSAGE_HOOK}
; discord_log_chicken: 1 = send chicken/death style notifications
custom_loot_message_hook=
custom_message_hook=
discord_log_chicken=1
; discord_log_errors: 1 = send a Discord message + error screenshot every time a
; run fails (approach/battle/exception). Set to 0 to keep error screenshots on
; disk only. Can also be toggled via [discord_events] error=0.
discord_log_errors=1
; discord_status_runs: send periodic status every X completed runs (blank/0 disables)
discord_status_runs=10
; discord_status_count: legacy fallback, send periodic status every X games (blank/0 disables)
discord_status_count=20
; discord_timing_report_h: post a timing + failure digest to Discord every N hours
; (0 disables). Aggregated from the same TL> timeline the log uses, so the report and the
; log can never disagree. The window resets on every send.
discord_timing_report_h=2
; pickup_drought_window: warn/alert after this many consecutive games with zero
; item pickups. Raise this if you run a strict pickit and 0-pickup streaks are
; expected/normal for you (a fast boss-only rush route with a tight filter can
; easily go 10 games without a keep-worthy drop).
pickup_drought_window=10
; message_api_type: "" disables messaging, "discord" or "generic_api"
message_api_type=discord
; breaks
; break_length_m: scheduled break duration in minutes (0 = disabled)
break_length_m=15
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
max_runtime_before_break_m=120
break_length_m=0
max_runtime_before_break_m=0
; timers / fail handling
; d2r_path: Diablo II: Resurrected install path
d2r_path=C:\Program Files (x86)\Diablo II Resurrected
; max_consecutive_fails: stop bot after this many failed runs in a row
max_consecutive_fails=5
; max_game_length_s: emergency timeout per run/game
; 900: a full Chaos Sanctuary clear (3 seals + bosses + loot) takes ~10 min;
; 600 force-quit a game while literally waiting for Diablo to spawn (2026-06-10).
; Genuinely stuck games are caught much earlier by max_maintenance_time_s and
; approach step timeouts.
max_game_length_s=900
; max_maintenance_time_s: if the town maintenance loop (heal/buy/stash/repair) takes
; longer than this many seconds, save-and-exit and rejoin a fresh game.
; Prevents the bot staying stuck in A5 town forever when NPCs or pathing fail.
max_maintenance_time_s=420
; auto_downgrade_threshold: if combined chickens+deaths exceed this number within 1 hour,
; bot automatically lowers difficulty by one tier (hell->nightmare->normal) and restarts.
; Set to 0 to disable. (NOTE: feature is parsed but not yet active in bot logic)
auto_downgrade_threshold=0
max_game_length_s=160
restart_d2r_when_stuck=1
; hardcore: 1 enables hardcore-safe assumptions in some routines
; if you set this field to 1, botty will attempt to restart d2 after a crash or failure
restart_d2r_when_stuck=0
hardcore=0
; screenshots
; info_screenshots: save screenshots for info/chicken/death events
info_screenshots=1
; error_screenshots: save a screenshot to log/screenshots/error/ every time a run
; fails (approach failure, battle failure, or an exception) so logs and visuals can
; be reviewed side by side. Falls back to info_screenshots if unset.
error_screenshots=1
; loot_screenshots: save screenshots for picked loot
loot_screenshots=0
; pickit_screenshots: save screenshots for pickit debugging
pickit_screenshots=1
; recovery
; disable_run_after_failures: after this many CONSECUTIVE failures of the same run
; (e.g. run_vizier), the bot disables just that run for the rest of the session and
; keeps doing the other runs instead of stopping. A single success resets the count.
; If every run gets disabled the bot stops for investigation. Set high to effectively
; disable this behaviour.
disable_run_after_failures=5
; stash_scan_interval: scan all stash tabs and export stash_list.csv every X runs (0 disables)
stash_scan_interval=0
[discord_events]
; Fine-grained Discord event toggles (1=send, 0=disable)
; status: periodic status + generic bot messages (breaks, shop notifications, etc.)
status=1
; item_keep: send kept item notifications (loot webhooks/embeds)
item_keep=1
; death: send death notifications
death=1
; chicken: send chicken (emergency leave) notifications
chicken=1
; stash_full: send stash full notifications
stash_full=1
; gold_full: send gold full notifications
gold_full=1
; error: send run-failure notifications (message + error screenshot)
error=1
pickit_screenshots=0
[stealth]
; Multiplies all wait() calls by a random value in this range each call
; 1.0 = no change. Set range wider for more human-like timing variation.
; wait_jitter_* multiplies EVERY wait() in the codebase, not just mouse moves.
; The floor is clamped to jitter_min*0.8, so 0.85 allowed waits to come out 32%
; SHORT — the one place jitter stole time from an action instead of adding it
; between actions. 0.95 keeps waits at-or-longer (effective floor 0.76 vs the
; old 0.68, and the Gaussian centre sits above 1.0), so a wait can no longer
; expire before the UI it was waiting on has settled.
wait_jitter_min = 0.95
wait_jitter_min = 0.85
wait_jitter_max = 1.20
; Extra pixel variance added to every mouse click (on top of existing randomize=5)
; 0 = off, 10 = +/-10px extra random offset per click
; NOTE: click position randomization lives at the CALL SITES (npc_manager,
; ui/waypoint) where it is tuned to real button geometry — 2-3px for NPC
; hover targets, +/-9px inside a 47px waypoint button. There is deliberately
; no global click-offset knob: stacking one on top of those is what starts
; missing NPCs (see CLAUDE.md Bugs 3/4/6/7).
click_variance = 8
; Re-shuffle run order after completing a full rotation (vs only at session start)
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
; there) — the next game then spawns at A4 where Jamella/Cain/Tyrael work,
; avoiding the A5 Malah vendor entirely (stale templates in current patch).
reshuffle_each_rotation = 0
reshuffle_each_rotation = 1
; Probability (0-100) of randomly skipping a run each game
; 0 = never skip, 20 = skip ~1 in 5 runs
@@ -170,14 +62,7 @@ skip_run_chance = 10
afk_break_chance = 5
; Break duration range in minutes
afk_break_min_m = 2
; 12 -> 7 (2026-08-28). The configured value is NOT the real ceiling:
; maybe_afk_break does wait(minutes*60, minutes*60*1.5) and wait() then applies
; its own jitter (up to 1.44x), so a "12 minute" break can idle for ~26 minutes.
; Measured: an 11.9m break ran 1167.7s = 19.5m, and a 20:56 scheduled break ran
; 25.5m. A ~25m idle is what left D2R at character select once, unable to
; re-enter, with the bot spawning a new process every ~20s.
; 7 caps the real-world worst case near 15m, inside the range proven to resume.
afk_break_max_m = 7
afk_break_max_m = 12
; Vary run/battle duration by a Gaussian factor (default +/-15%)
; 0.0 = no variation, 0.3 = up to +/-30% variation
@@ -188,8 +73,6 @@ micro_pause_min_ms = 20
micro_pause_max_ms = 120
; Vary kill time to avoid perfectly consistent boss fight durations
; Varies boss attack duration. Only ever LENGTHENS the fight (1.0-1.4x) —
; shortening it risks leaving a boss alive, which is a failed run, not stealth.
vary_kill_time = 1
; Human mouse curve complexity (1.0 = default, 0.5 = more direct, 1.5 = more winding)
@@ -197,13 +80,8 @@ human_curve_complexity = 1.0
; Arrival-to-click delay: human-like pause between mouse arriving and clicking (milliseconds)
; 50-800ms range simulates "is this the right thing?" hesitation
; Hesitation between arriving at a target and clicking it. OFF by default:
; it applies to EVERY click, so the old 800ms ceiling could add minutes per
; run. Enable with click_delay_enabled=1 if you want it; 250 is a realistic
; ceiling that stays affordable.
click_delay_enabled = 0
click_delay_min_ms = 50
click_delay_max_ms = 250
click_delay_max_ms = 800
; Key press duration variance: how long a key is held (milliseconds)
; Most presses are short (20-100ms), some linger (up to 200ms)
@@ -217,99 +95,38 @@ skill_hesitation_max_ms = 300
; Wrong waypoint chance: 2-3% of selecting wrong TP portal then correcting
; Humans occasionally misclick waypoint targets
; ── session rhythm ───────────────────────────────────────────────────────────
; The strongest remaining signal is not per-click timing: averaged over a
; session jitter converges, but a player who starts at the same hour, plays the
; same length and never does anything unproductive does not.
;
; session_budget_h: stop the bot after roughly this many hours (0 = unlimited).
; Actual length is rolled per run at 0.65-1.35x, so consecutive days differ.
; Raised to 10 for an overnight run: the budget is rolled at 0.65-1.35x, so 6
; could have stopped the bot after 3.9h and left it idle. 10 guarantees at
; least ~6.5h while keeping the day-to-day variation.
; Set to 8 for a >= 5h run (2026-08-28). The value is rolled at 0.65-1.35x, so
; session_budget_h=5 would average 5h but could stop after 3.25h. 8 gives a
; 5.2-10.8h window, guaranteeing the 5 hours while keeping day-to-day variation.
session_budget_h = 8
; daily_budget_h: HARD cap on total runtime per CALENDAR DAY (0 = unlimited).
; session_budget_h above is per PROCESS — a restart re-rolls it, so a bot that
; gets restarted (by you, or by the restart-on-crash path) can run all day and
; never trip it. This cap is persisted to log/.daily_runtime.json keyed on the
; date, so restarts cannot extend the day. The target is rolled ONCE per day
; (+/- daily_budget_jitter) and then FIXED, so restarting cannot re-roll a
; larger allowance. This is a CEILING, not an average: the jitter only ever
; subtracts, so daily_budget_h = 8 with 0.12 jitter runs 7.0-8.0h and NEVER
; more than 8. session_budget_h above can still stop a single process sooner.
daily_budget_h = 8
daily_budget_jitter = 0.12
; daily_budget_close_game: 1 = also close D2R when the daily cap trips (default).
; The cap fires in on_end_game, AFTER save-and-exit, so nothing is in progress and
; nothing is lost. A bot parked at character select for 16h is itself a pattern;
; a real player quits the game. Set 0 to leave D2R running.
; Small cursor movement during long idles. Between actions the cursor otherwise
; sits exactly where the last click left it.
idle_drift_enabled = 1
; Per-game chicken threshold spread. Only ever RAISES the threshold (safer) —
; a fixed 0.40 every game is a precise tell, but lowering it would cost deaths.
chicken_variance = 0.08
; Chance of an unproductive town action (open inventory, close it).
town_browse_chance = 0.06
; Chance of walking past an item the filter wanted. Costs real loot — keep low.
pickup_skip_chance = 0.02
wrong_waypoint_chance = 0.025
; Skill mistake chance: 1-2% chance of miscasting and correcting
; Simulates human error during combat
; Presses a DIFFERENT bound skill before the intended one. Disabled: on a
; character mid-fight this swaps the active skill or aura, and the attack
; sequences rely on ending in a known skill state. The stealth value is small
; next to the risk of a stray keypress landing on something that is not a skill
; at all — which is exactly how the loot filter ended up being toggled all
; session (params.ini [paladin] vigor=f4, unbound on this char, F4 = filter).
; Set back to 0.015 to re-enable.
skill_mistake_chance = 0
skill_mistake_chance = 0.015
[routes]
; Controls which farm runs Botty performs each game.
; "order" is a comma-delimited list and runs left-to-right when randomize_runs=0.
; If randomize_runs=1 (in [general]), Botty shuffles enabled runs each game.
;
; Hammerdin keyrun recommendation (stable-first):
; order=run_countess, run_arcane, run_nihlathak
;
; Route quick notes:
; run_trav (Act 3 Travincal council farm; short/high-density run)
; run_pindle (Act 5 Nihlathak temple red portal boss farm)
; run_eldritch (Act 5 Frigid Highlands Eldritch-only run)
; run_eldritch_shenk (Act 5 Eldritch then Shenk in same game)
; run_nihlathak (Act 5 Halls of Vaught, teleport strongly recommended)
; run_arcane (Act 2 Arcane Sanctuary / Summoner, teleport strongly recommended)
; run_diablo (Act 4 Chaos Sanctuary, teleport recommended)
; run_vizier (Act 4 Chaos Vizier-only route; lighter/faster than full Diablo run)
; Add these possible routes to "order" as a comma delimited list to run them:
; run_trav
; run_pindle
; run_eldritch
; run_eldritch_shenk
; run_nihlathak
; run_arcane
; run_diablo
; run_vizier
; run_andariel (Act 1 Catacombs)
; run_countess (Act 1 Forgotten Tower)
; run_mephisto (Act 3 Durance of Hate)
; run_baal (Act 5 Throne of Destruction)
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
order=run_baal_xp, run_trav
order=run_pindle
[char]
; ==========================
; ==== Mandatory Fields ====
; ==========================
; 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=hammerdin
; belt_rows: in-game belt size (2/3/4)
type=fohdin
belt_rows=4
; casting_frames: your char cast breakpoint (affects action timing)
casting_frames=11
; cta_casting_frames: cast breakpoint on CTA swap (if used)
cta_casting_frames=11
; attack_frames: base attack animation timing for non-cast attacks
casting_frames=8
cta_casting_frames=8
attack_frames=15
; cta_available: 1 if Call to Arms swap exists, else 0
cta_available=1
;Do we want to cast non-cta buffs (ex energy shield) with cta
buff_with_cta=1
@@ -319,22 +136,9 @@ safer_routines=1
; num_loot_columns: Number of empty columns from left to right of inventory to be used for looting.
; Store charms, etc. to the right of the inventory.
;
; This drives ui_roi[restricted_inventory_area] in config.py: the columns to the RIGHT of
; this count are protected — mouse._is_clicking_safe() cancels any click landing there, so
; the bot never sells, drops or moves anything in them. Both tomes MUST live in that
; reserved area: common.tome_state() only searches restricted_inventory_area for them.
; 3 columns = 12 loot slots, 28 reserved
; 4 columns = 16 loot slots, 24 reserved <- now
; 6 columns = 24 loot slots, 16 reserved (tried 2026-08-27, reverted — shrinking the
; reserve is the wrong direction when the
; books and the charms both live there)
; Lower this if the tomes or charms are running out of room; raise it only if the reserved
; area is provably empty.
num_loot_columns=5
num_loot_columns=4
; game hotkeys:
; NOTE: each key must match your in-game binding exactly
force_move=e
inventory_screen=i
potion1=1
@@ -347,13 +151,12 @@ 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=f4
town_portal=f8
teleport=5
town_portal=6
; call to arms settings:
; weapon_switch/battle_orders/battle_command only used when cta_available=1
weapon_switch=w
battle_orders=f6
battle_command=f5
battle_orders=7
battle_command=8
; ==========================
; ==== Optional configs ====
@@ -362,43 +165,13 @@ stash_gold=1
use_merc=1
; Attack length for barbarians should be as high as 8-10 and even 10-12 for trav/shenk
;
; Hammerdin attack lengths (seconds of hammer spam per boss):
; atk_len_trav = 4.0 (Council of 3 - 3 council members, easy)
; atk_len_pindle = 8.0 (Pindle — Hell: 13094-16070 HP, 75% fire, 100% poison)
; Pindle stats by difficulty:
; Normal: 1064-1588 HP, 75% fire, 70% poison
; Nightmare: 4773-5859 HP, 100% poison
; Hell: 13094-16070 HP, 75% fire, 50% cold, 33% light, 100% poison
; With Conviction (-33% res), Hell Pindle = 50% fire, 17% cold, 0% light, 67% poison
; CTA adds +100% dmg to hammer. Need ~60%+ FCR and good IAS to kill in 8s.
; atk_len_nihlathak = 4.0 (Nihlathak - single boss)
; atk_len_eldritch = 3.0 (Eldritch only - single boss)
; atk_len_shenk = 4.0 (Shenk only - single boss)
; atk_len_arc = 2.5 (Summoner in Arcane - single boss)
; atk_len_diablo = 10.0 (Diablo in CS - longest single boss)
; atk_len_countess = 3.0 (Countess - single boss, easy)
; atk_len_andariel = 4.0 (Andariel - single boss)
; atk_len_mephisto = 12.0 (Mephisto - single boss, high HP)
; atk_len_baal = 10.0 (Baal - single boss)
; atk_len_baal_waves = 30.0 (Baal's spawn waves before boss)
;
; Chaos Sanctuary (Diablo run) individual fights:
; atk_len_cs_trashmobs = 2.0 (Trash packs in CS)
; atk_len_diablo_vizier = 2.0 (Vizier of Chaos - seal boss A)
; atk_len_diablo_infector = 4.0 (Infector of Souls - seal boss C)
; atk_len_diablo_deseis = 5.0 (Lord De Seis - seal boss B, hardest seal)
;
; Increase these if your hammer kills slower (low IAS/FCR).
; Decrease if your hammer kills faster (high IAS/FCR, good gear).
; If you die during a fight, the attack length is too long for your defense.
atk_len_arc=2.5
atk_len_eldritch=3.0
atk_len_nihlathak=4.0
atk_len_pindle=8.0
atk_len_pindle=9.0
atk_len_shenk=4.0
atk_len_diablo=3.0
atk_len_trav=5.0
atk_len_trav=3.0
; Boss run attack lengths (per-character defaults in kill_* methods)
; Adjust these if your build is faster/slower against these bosses
@@ -419,112 +192,47 @@ cs_town_visits=0
kill_cs_trash=1
; Belt settings
belt_hp_columns=2
belt_hp_columns=1
belt_mp_columns=1
belt_rejuv_columns=1
belt_rejuv_columns=2
; Potion/chicken settings
take_health_potion=0.60
take_mana_potion=0.40
take_rejuv_potion_health=0.45
take_rejuv_potion_mana=0.10
heal_merc=0.70
heal_rejuv_merc=0.45
chicken=0.40
merc_chicken=0.20
take_health_potion=0.90
take_mana_potion=0.80
take_rejuv_potion_health=0.75
take_rejuv_potion_mana=0.50
heal_merc=0.7
heal_rejuv_merc=0.25
chicken=0.75
merc_chicken=0.25
; Misc.
; helps reduce accidental pickups when enabled especially on walking characters
enable_no_pickup=0
; fill_shared_stash_first: 1 = prefer shared stash tabs before personal stash
enable_no_pickup=1
fill_shared_stash_first=0
; to gamble, add any/all of the following: circlet, ring, coronet, talon, amulet
gamble_items=
; open_chests: 1 = open clickable chests along path when possible
open_chests=0
; pre_buff_every_run: 1 = always recast buffs at run start
pre_buff_every_run=1
; runs_per_repair: visit repair vendor every X runs (blank/0 disables)
runs_per_repair=50
; repair_npc: preferred repair vendor strategy.
; - a5_larzuk (recommended: stays in A5, no cross-act trip; falls back to Halbu)
; - a4_halbu (requires WP trip to A4 every repair — act desync risk if it fails)
; 2026-06-10: switched to a5_larzuk — session logs showed Halbu detection failing
; 100% (body score ~0.39) and each failed A4 trip desynced the bot's act state.
repair_npc=a5_larzuk
; resurrect_npc: which NPC revives the mercenary. Blank = whichever act the character is
; already in. Set to a4_tyrael to always use Act 4 Tyrael.
; Measured 2026-08-27 on this setup:
; A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever
; A5 Qual-Kehk fail 113.6s / 52.0s / 163.1s / 72.6s - 43 errors
; Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC in
; the route. A failed hunt costs more than the whole A5->A4 trip, and one 163s hunt pushed
; the town visit past max_maintenance_time_s and killed the game.
resurrect_npc=a4_tyrael
; runs_per_stash: stash/sell every X runs (blank/0 disables)
runs_per_stash=1
; sell_junk: 1 = vendor non-keep items automatically
open_chests=1
pre_buff_every_run=0
runs_per_repair=0
runs_per_stash=4
sell_junk=0
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name.
; DISABLED 2026-08-27. This is redundant: the EQUIPPED shield is already protected
; positionally by mouse._is_clicking_safe(), which cancels any click landing in
; ui_roi[equipped_inventory_area] while the inventory is open. The name rule only ever
; hit shields sitting in the INVENTORY grid, which the pickit had already judged.
; Cost of leaving it on: 130 blocked sells in one day (FIEND SHIELD 64, AERIN SHIELD 41,
; HERALDIC SHIELD 23...). The same shields were re-evaluated and re-blocked every single
; game — "Discarding FIEND SHIELD." then "Blocked sell for protected item: FIEND SHIELD" —
; so they could never leave the pack and permanently occupied slots.
protect_shields_from_sell=0
; protect_charms_from_sell: 1 = never drop/vendor charms regardless of pickit verdict
; (safety net — a misread charm can't be un-sold). Set to 0 to let your pickit rules
; decide keep/discard for charms same as any other item.
;
; KEEP THIS AT 1. Charms only give their bonus while they sit in the INVENTORY, and the
; resistances matter in nightmare. Briefly set to 0 on 2026-08-27 to reclaim slots and
; that was wrong — it vendored LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF
; FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS SMALL CHARM OF VITA (+20 life,
; cold resist +7%) before it was reverted.
;
; The "673 blocked sells" in the log are NOT a bug to fix by selling — they are the guard
; doing its job, repeatedly, on charms the pickit does not have a keep rule for. If those
; slots are needed, the answer is a pickit rule that keeps the good charms and stops
; picking up junk ones, NOT disabling this guard.
protect_charms_from_sell=0
; pick_rares_for_gold: 1 = pick all yellow (rare) ground items; non-keep rares will be sold
; Requires sell_junk=1 to convert extra pickups into gold.
pick_rares_for_gold=0
; pick_gold: 1 = pick up ground gold piles, 0 = ignore all ground gold
pick_gold=1
[transmute]
;stash tabs by priority where to put transmuted gems
stash_destination=0,1,2,3
stash_destination=3,2,1,0
; Add these possible gems to "transmute" to transmute them:
; chipped, flawed, standard, flawless
; DISABLED 2026-08-26: gems are stashed to the GEMS tab and left alone, no cube/convert
; runs. An empty value makes run_transmutes() bail at "No gem tiers configured", which
; also holds for force=True (tools/gem_transmute.py), unlike transmute_every_x_game=0.
; Re-enable by listing tiers again, e.g. transmute=flawless
transmute=flawless
;how often we want to run transmute routine(e.g. every 100 games)
transmute_every_x_game=800
; number of stash tabs — drives tab click geometry in inventory/common.tab_properties().
; MUST match the tab bar on screen. This client shows 5: PERSONAL SHARED GEMS MATERIALS RUNES.
; It was 6, which made tab_properties compute centres of 63/127/192/256/320/384 while the
; real label centres are 68/144/220/295/370 — tabs 2, 3 and 4 clicked the gaps between tabs.
stash_tabs=5
; potion transmute settings
; convert_rejuv: 1 = convert regular Rejuv Potions to Full Rejuv via cube (3 -> 1)
convert_rejuv=0
; min_rejuv_to_convert: minimum regular rejuv potions in inventory before starting conversion
min_rejuv_to_convert=6
transmute_every_x_game=2000
; ===========================
; ==== Builds: Sorceress ====
; ===========================
[sorceress]
energy_shield=f4
frozen_armor=f7
energy_shield=f3
frozen_armor=f4
static_field=f5
telekinesis=f6
thunder_storm=
@@ -541,14 +249,14 @@ frozen_orb=
; blizzard must be right skill, hotkey required
blizzard=f1
; ice_blast must be left skill (hotkey optional as it shouldnt change)
ice_blast=f2
ice_blast=
[blizzorb_sorc]
; frozen orb must be left skill and preselected (no hotkey required)
;All others must be right skill and hotkey required!
blizzard=f1
glacial_spike=f7
glacial_spike=f2
[nova_sorc]
; nova must be right skill, hotkey required
@@ -566,22 +274,21 @@ hydra=f1
; =========================
[paladin]
cleansing=
holy_shield=f4
redemption=f2
vigor=f3
holy_shield=f2
redemption=f3
vigor=f4
[fohdin]
; foh must be left skill, hotkey required
blessed_hammer=f1
concentration=f8
blessed_hammer=
concentration=
conviction=f5
foh=f6
holy_bolt=f7
[hammerdin]
blessed_hammer=f9
concentration=f1
conviction=
blessed_hammer=f4
concentration=f5
; =========================
; ==== Builds: Warlock ====
@@ -734,127 +441,27 @@ buff_1=
buff_2=
; ==== Run: Cold Plains clear ====
; Roams an outdoor area and kills with ONE configured skill. Works with any build
; and from clvl 1, so it is the levelling counterpart to the boss runs.
; Enable by adding run_cold_plains to [routes] order above.
;
; PREREQUISITE: the character must already have the destination waypoint.
; A fresh character has none - walk there once by hand first.
[cold_plains]
; Any Act 1 waypoint label from src/ui/waypoint.py _WAYPOINTS, e.g.
; "Cold Plains", "Stony Field", "Dark Wood", "Black Marsh".
area=Cold Plains
; Key holding the attack skill. NOTE Fireball is clvl 12 - on a fresh character
; put Fire Bolt (clvl 1) here and re-bind to Fireball later; nothing else changes.
attack_hotkey=
; Most sorc/caster skills cast on right click. Melee builds want "left".
attack_button=right
; How many roam-or-fight steps before heading back to town.
max_steps=12
; Casts per engagement before re-scanning for targets.
casts_per_target=4
; Ignore anything further than this many px from the character.
target_radius=600
; Delay between casts. Raise if you out-run your cast animation.
cast_delay=0.25
; Hard runtime cap per run, seconds - stops a bad area pinning the bot.
max_runtime_s=180
; Engagements per step, so an unkillable/misdetected target cannot stall the run.
max_engagements=8
[baal_xp]
; Baal XP farm: join public Baal games, hide, collect XP, leave. Repeat.
; Enable by adding run_baal_xp to [routes] order above.
enabled=1
; Substring to match against game names (case-insensitive). Empty = join first game.
game_name_filter=
; Max seconds to wait in a game before leaving (XP keeps ticking while you hide).
max_wait_s=900
; Leave early if XP gained in this game reaches this value.
xp_threshold=50000000
; Leave immediately if HP drops below this percentage (chicken).
min_hp_pct=40
; Screen position (client coords) to walk to and stand still while hiding.
hide_x=640
hide_y=360
; Max seconds to wait for a matching game to appear in the browser.
join_timeout_s=60
[advanced_options]
; startup hotkeys
; 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=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=delete
; resume_key: start/pause bot loop
restore_settings_from_backup_key=f7
settings_backup_key=f8
auto_settings_key=f9
graphic_debugger_key=f10
resume_key=f11
; exit_key: hard stop bot
exit_key=f12
; cycle_pickit_profile_key: cycle through pickit profiles in config/pickit_profiles/
cycle_pickit_profile_key=f10
; etc.
; graphic_debugger_layer_creator: 1 = enable interactive layer creator tooling
graphic_debugger_layer_creator=0
; hwnd_window_process: process regex used to locate D2R window handle
hwnd_window_process=D2R\.exe
; hwnd_window_title: optional title regex override for window lookup
hwnd_window_title=
; launch_options: will replace <name> with setting for [general] "name" above
launch_options=-mod <name> -txt
; logg_lvl: logging verbosity (debug/info/warning/error)
logg_lvl=debug
; message_body_template: payload template for generic_api mode
message_body_template={{"content": "{msg}"}}
; message_headers: optional JSON headers for generic_api mode
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=can_teleport_natively
; pathing_delay_factor: movement/click delay multiplier (1 fast .. 10 slow)
override_capabilities=
pathing_delay_factor=2
; If you want to control Hyper-V window from host use 0,51 here
; window_client_area_offset: x,y pixel offset for captured game client area
window_client_area_offset=0,0
[log_rotation]
; Log rotation prevents screenshot directories from filling the disk.
; Each managed directory has a max file count and max total size.
; When limits are exceeded, oldest files are deleted automatically.
; Checked every 60 seconds (to avoid I/O overhead during runs).
;
; The text log (log/log.txt) is size-capped separately: it rotates at 50 MB,
; keeps 5 zipped backups in log/archive/ (max 30 archives). Override the per-file
; cap with the BOTTY_LOG_MAX_MB environment variable (e.g. set 100 for 100 MB).
; pickit directory (log/screenshots/pickit/):
; Can grow very fast — every item scan writes a PNG + JSON.
; pickit_max_files=500 ; max files before rotation kicks in
; pickit_max_mb=500 ; max total size in MB
pickit_max_files=300
pickit_max_mb=200
; info directory (log/screenshots/info/):
; Debug screenshots for deaths, chickens, errors, etc.
; info_max_files=200 ; max files before rotation kicks in
; info_max_mb=500 ; max total size in MB
info_max_files=100
info_max_mb=200
; items directory (log/screenshots/items/):
; Loot screenshots sent to Discord.
; items_max_files=100
; items_max_mb=100
items_max_files=50
items_max_mb=50
; discord_notify_rotation: 1 = send Discord message when log rotation deletes files
discord_notify_rotation=0
-26
View File
@@ -1,26 +0,0 @@
# Shared pickit profiles (git-tracked)
One folder per pickit set, e.g. for season phases:
```
config/pickit_profiles/
season_start/ *.bnip (leveling: keep bases, gems, chipped...)
mid_season/ *.bnip
endgame/ *.bnip (GG-only filter)
```
Drop `.bnip` (or `.nip`) files in a folder; a `.nipignore` works like in
`config/bnip`. These folders ARE committed — build them once, everyone
gets them via git pull.
## Selecting a set
Per user, in your gitignored `config/profiles/<you>/profile.ini`:
```ini
[general]
pickit_profile=season_start
```
Priority: `config/profiles/<you>/pickit/` (personal set, gitignored)
> `config/pickit_profiles/<pickit_profile>/` (shared, from this folder)
> `config/bnip/` > `config/default.bnip`.
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -1,5 +1,4 @@
[claws]
; shop_trap_claws: 1 = enable claw-shopping routine for trap claws from Anya/Drognan flow
; Current scoring for trap claws
; 3 traps: +12
; 2/1 traps: +8
@@ -8,22 +7,17 @@
; x weapon block: +1
; x death sentrey: +4
shop_trap_claws=0
; trap_min_score: minimum combined score required to keep a trap claw
trap_min_score=13
; shop_melee_claws: 1 = enable shopping melee claws (venom/block focused)
; Current scoring for melee claws
; 2 assa: +10
; x venom: +6
; x weapon block: +2
shop_melee_claws=0
; melee_min_score: minimum combined score required to keep a melee claw
melee_min_score=13
[gloves]
; shop_3_skills_ias_gloves: 1 = shop +3 skill tree + IAS gloves
shop_3_skills_ias_gloves=1
; shop_2_skills_ias_gloves: 1 = shop +2 skill tree + IAS gloves
shop_2_skills_ias_gloves=0
;
@@ -35,9 +29,6 @@ shop_2_skills_ias_gloves=0
; apply_pather_adjustment - alternative option that applies an adjustment to movements.
; Should not need this. Try it if you have trouble when it is off.
[scepters]
; shop_hammerdin_scepters: 1 = enable Hammerdin scepter shopping route
shop_hammerdin_scepters=1
; speed_factor: movement compensation for FRW and path timing during shopping loop
speed_factor=0.25
; apply_pather_adjustment: optional alternate node adjustment if default route misses NPC/shop spots
apply_pather_adjustment=0
apply_pather_adjustment=0
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
Binary file not shown.
+2 -18
View File
@@ -11,29 +11,13 @@ git clone https://github.com/Hoblirm/botty.git
cd botty
# Create the conda environment (installs all Python deps + tesserocr with tesseract)
# Windows 10:
conda env create -f environment-win10.yml
# Windows 11:
conda env create -f environment-win11.yml
conda env create -f environment.yml
# Activate
# Activate and run
conda activate botty
# Optional pip profile after the conda env exists:
# Windows 10:
python -m pip install -r requirements-win10.txt
# Windows 11:
python -m pip install -r requirements-win11.txt
# Run
python src/main.py
```
`install.bat` detects Windows 10 vs Windows 11 and installs the matching
environment and requirements profiles automatically. Botty runtime detection
lives in `src/utils/os_detect.py`; startup logs show the selected profile and
mouse mode.
### PowerShell users
```powershell
conda init powershell # One time setup
-141
View File
@@ -1,141 +0,0 @@
# Auto Skill + Attribute Allocation Plan
## Goal
Add an optional system that automatically assigns:
- skill points
- attribute points
based on:
- active character profile (`blizz_sorc`, `fohdin`, `hammerdin`, etc.)
- current character level
without breaking existing manual setups.
## Scope
- Planning and architecture for Botty repo.
- No forced behavior changes: feature must be opt-in.
## Requirements
1. Determine current level reliably at runtime.
2. Select a build template by character profile.
3. Apply points safely only when unspent points exist.
4. Record every allocation in logs/events for audit/replay.
5. Abort safely on uncertainty (wrong UI state, OCR mismatch, missing templates).
## Current Level Detection Strategy
### Primary path
Use `player_bar.get_experience()` (already used in `game_stats.log_exp`) to derive level from XP table.
### Secondary fallback
Open character panel (`C`) and OCR level/name line directly from upper-left panel region.
### Tertiary fallback
If OCR fails repeatedly:
- keep previous known good level for session,
- do **not** allocate points until confidence is restored.
### Confidence rules
- Require two consistent reads before first allocation in a session.
- Reject impossible jumps (e.g., +5 levels at once).
- Persist `last_known_level` in session stats snapshot.
## Build Template Model
Add config-backed build templates, e.g.:
- `config/auto_builds/blizz_sorc.ini`
- `config/auto_builds/hammerdin.ini`
- `config/auto_builds/fohdin.ini`
Each template defines per-level targets:
- desired skill totals by level milestone
- desired attribute distribution (str/dex/vit/ene)
Example concept:
- Level 1-17: early progression targets
- Level 18-29: mid-game unlock path
- Level 30+: core skill maxing order
## Runtime Flow
1. Enter town and open character/skill UI.
2. Detect level and unspent points.
3. Load template for `Config().char["type"]`.
4. Compute delta between current allocation and target-at-level.
5. Apply points stepwise:
- attributes first (optional toggle),
- skills second.
6. Verify post-apply state.
7. Log allocation summary and persist snapshot.
## Safety Guards
- Only run in town.
- Require stash/vendor windows closed.
- Hard cap per cycle (e.g., max 10 clicks per stat/skill group).
- On mismatch/timeout:
- stop allocation immediately,
- screenshot + structured error event,
- continue bot without crashing.
## Config Additions (Planned)
In `[char]` or new `[auto_build]` section:
- `auto_assign_skills=0/1`
- `auto_assign_attributes=0/1`
- `auto_build_profile=` (defaults to `char.type`)
- `auto_build_check_every_x_games=`
- `auto_build_safe_mode=1` (extra verification)
## Logging / Telemetry
Add structured events:
- `auto_build_check_started`
- `auto_build_level_detected`
- `auto_build_points_detected`
- `auto_build_applied`
- `auto_build_skipped`
- `auto_build_error`
Include:
- profile
- level
- points spent
- before/after snapshots
## UI / Input Dependencies
Need stable template references for:
- character panel level region
- unspent attribute points indicator
- unspent skill points indicator
- individual plus-buttons for stats/skills
## Test Plan
1. Unit tests:
- level-to-target mapping
- delta computation
- guard conditions
2. Integration dry-run mode:
- compute and log planned actions without clicking.
3. Live smoke tests per profile:
- `blizz_sorc`, `hammerdin`, `fohdin`
4. Regression:
- ensure normal runs unaffected with feature disabled.
## Inputs Needed From You
1. Screenshots for each supported class at:
- character panel open,
- skill tree open,
- visible unspent points.
2. Preferred leveling templates:
- exact skill priority order by level range.
- attribute rules (e.g., str to gear breakpoint, then vit).
3. Whether respec-aware logic is needed in v1.
## Rollout Phases
1. Phase 1: Level detection + dry-run planner only.
2. Phase 2: Attribute auto-assign (safer, fewer UI branches).
3. Phase 3: Skill auto-assign with full verification.
4. Phase 4: Expanded profile templates + docs.
## Definition of Done
- Feature is opt-in and stable for `blizz_sorc`, `hammerdin`, `fohdin`.
- Level detection is reliable with fallback behavior.
- No crash on detection/allocation failure.
- Full logs available for every auto-allocation decision.
-74
View File
@@ -1,74 +0,0 @@
# BNIP Guide
This guide explains how to edit `config/default.bnip` safely and predictably.
## What BNIP Does
BNIP rules decide which items Botty keeps.
Each line is a filter expression evaluated against detected item data.
## Rule Shape
Typical rule format:
```text
[Name] == Ring && [Quality] == Rare # [Fcr] >= 10 && [Allres] >= 15
```
- Left side (`[Name]`, `[Type]`, `[Quality]`, etc.) narrows item identity.
- Right side after `#` checks stats/rolls.
## Enable or Disable Rules
- Enabled: line starts with `[...`
- Disabled: line starts with `//`
Example:
```text
//[Name] == Lemrune
[Name] == Pulrune
```
## Safe Editing Workflow
1. Copy an existing nearby rule.
2. Keep your new rule commented out initially (`//`).
3. Enable one new rule at a time.
4. Run a few games and verify behavior before adding more.
## Rule Ordering
BNIP files are easier to maintain when ordered from specific to broad.
- Put strict high-value rules first.
- Put broad catch-all rules later.
- Avoid duplicated broad rules in multiple sections.
## Common Fields You Will Use
- `[Name]`
- `[Type]`
- `[Quality]`
- `[Flag]` (for ethereal/sockets behavior)
- Stat aliases like `[Fcr]`, `[Allres]`, `[Enhanceddefense]`, `[Enhanceddamage]`
## Troubleshooting
If a desired item is not kept:
1. Confirm the rule is enabled (no `//`).
2. Relax one condition at a time.
3. Check for typos in stat aliases.
4. Ensure no local BNIP file in `config/bnip/` is overriding expectations.
If too much junk is kept:
1. Tighten broad rules.
2. Disable catch-all rules first.
3. Add stricter stat thresholds.
## Recommended Local Customization
Keep `config/default.bnip` as the team baseline.
Put personal experiments in separate local `.bnip` files under `config/bnip/` and test there first.
-142
View File
@@ -1,142 +0,0 @@
# Broken Runs - Setup Guide
## Current Status
All 4 boss runs (Countess, Andariel, Mephisto, Baal) have **code structure in place** but require **path recording** before they can run.
| Run | Code | Guards | Path Coords | Templates | Walking Fallback |
|---|---|---|---|---|---|
| Countess | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1000-1004 |
| Andariel | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1010-1012 |
| Mephisto | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1020-1021 |
| Baal | OK | (0,0) check | 0,0 placeholder | Empty dir | Nodes 1030-1031 |
## What's Done
- **Run classes**: `countess.py`, `andariel.py`, `mephisto.py`, `baal.py` - all wired in `bot.py`
- **Kill methods**: `kill_countess()`, `kill_andariel()`, `kill_mephisto()`, `kill_baal()`, `kill_baal_waves()` - implemented in Hammerdin/FoHdin/Warlock
- **Path guards**: All 4 runs check for `(0,0)` paths and refuse to run with a clear error message
- **Walking fallback**: `pather.py` has node definitions (1000-1031) and path routes for walking fallback
- **Template dirs**: `assets/templates/countess/`, `andariel/`, `mephisto/`, `baal/` created (empty)
## What's Needed to Enable Each Run
### Option A: Teleport (faster, recommended)
Record path coordinates in `config/game.ini` using `node_recorder.py`:
```bash
cd C:\Users\alex\Downloads\my-botty
python src/utils/node_recorder.py
```
Then follow the on-screen instructions:
- **F8**: Capture template ROI (top-left, then bottom-right) -> saves PNG
- **F9**: Record node position at cursor -> generates path coordinates
- **F10**: Update all nodes with visible templates
### Option B: Walking (slower, needs templates + nodes)
Same as above, but also requires creating template PNGs for each landmark.
---
## 1. COUNTESS (Act 1 Forgotten Tower)
**game.ini keys to record (5):**
| Key | What to Record |
|---|---|
| `a1_tower_level2_enter` | First click inside tower after entering from Black Marsh |
| `a1_tower_level3_enter` | Top of stairs from L2 to L3 |
| `a1_tower_level4_enter` | Top of stairs from L3 to L4 |
| `a1_tower_level5_enter` | Top of stairs from L4 to L5 |
| `a1_countess_safe_dist` | Position near Countess at safe hammer range |
**Templates for walking fallback (in `assets/templates/countess/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `countess_tower_l2.png` | COUNTESS_TOWER_L2 | Tower L2 entrance |
| `countess_tower_l3.png` | COUNTESS_TOWER_L3 | Stairs L2->L3 |
| `countess_tower_l4.png` | COUNTESS_TOWER_L4 | Stairs L3->L4 |
| `countess_tower_l5.png` | COUNTESS_TOWER_L5 | Stairs L4->L5 |
| `countess_boss.png` | COUNTESS_BOSS | Countess boss area |
**Nodes in pather.py:** 1000-1004 (placeholder coords `(0,0)` - update after recording)
---
## 2. ANDARIEL (Act 1 Catacombs)
**game.ini keys to record (3):**
| Key | What to Record |
|---|---|
| `a1_andy_level3_enter` | Catacombs L3 entrance (from L2) |
| `a1_andy_level4_enter` | Catacombs L4 entrance (from L3) |
| `a1_andy_safe_dist` | Position near Andariel's cage at safe hammer range |
**Templates for walking fallback (in `assets/templates/andariel/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `andy_l3_stairs.png` | ANDY_L3_STAIRS | Stairs from L2 to L3 |
| `andy_l4_stairs.png` | ANDY_L4_STAIRS | Stairs from L3 to L4 |
| `andy_cage.png` | ANDY_CAGE | Andariel cage area |
**Nodes in pather.py:** 1010-1012 (placeholder coords `(0,0)` - update after recording)
---
## 3. MEPHISTO (Act 3 Durance of Hate)
**game.ini keys to record (2):**
| Key | What to Record |
|---|---|
| `a3_meph_level3_enter` | Durance of Hate L3 entrance (from L2) |
| `a3_meph_safe_dist` | Position near Mephisto's cage at safe hammer range |
**Templates for walking fallback (in `assets/templates/mephisto/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `meph_l3_stairs.png` | MEPH_L3_STAIRS | Stairs from L2 to L3 |
| `meph_cage.png` | MEPH_CAGE | Mephisto cage area |
**Nodes in pather.py:** 1020-1021 (placeholder coords `(0,0)` - update after recording)
---
## 4. BAAL (Act 5 Throne of Destruction)
**game.ini keys to record (2):**
| Key | What to Record |
|---|---|
| `a5_baal_throne_entry` | Throne of Destruction entrance (from Worldstone Keep L2) |
| `a5_baal_safe_dist` | Position near Baal at safe hammer range (after wave clear) |
**Templates for walking fallback (in `assets/templates/baal/`):**
| Template File | Template Key | Purpose |
|---|---|---|
| `baal_throne_entry.png` | BAAL_THRONE_ENTRY | Entrance to Throne area |
| `baal_arena.png` | BAAL_ARENA | Baal arena / boss position |
**Nodes in pather.py:** 1030-1031 (placeholder coords `(0,0)` - update after recording)
---
## Quick Start: Record Paths for One Run
1. Launch D2R and navigate to the boss area
2. Run `python src/utils/node_recorder.py` from the botty directory
3. Enter the run name when prompted (e.g., `countess`)
4. Position your cursor at the target location in-game
5. Press F8 to capture a template, F9 to record a node
6. Press F10 to update all nodes with visible templates
7. Copy the generated path coordinates to `config/game.ini`
8. Copy the generated template PNGs to `assets/templates/<run>/`
9. Update `pather.py` node definitions with real coordinates from the recorder output
-539
View File
@@ -1,539 +0,0 @@
# Codex Fix Analysis
Source files read:
- `docs/fix_plan.md`
- `src/run/nihlathak.py`
- `src/town/town_manager.py`
- `src/inventory/vendor.py`
- `src/char/i_char.py` (`src/char.py` does 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 in `config/params.ini`
## Priority 1: Nihlathak approach fails
### Finding
The Nihlathak route has three brittle points:
1. `approach()` returns success immediately after clicking the waypoint and never verifies that the Halls of Pain actually loaded.
2. Level 1 layout detection has no fallback if `NI1_A`, `NI1_B`, or `NI1_C` is stale.
3. `traverse_nodes_fixed()` always returns `True`, so a bad static path in `config/game.ini` cannot be detected until the stairs click times out.
The `config/game.ini` Nihlathak path keys are present:
```ini
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`:
```python
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
```
```python
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
```
```python
self._pather.traverse_nodes_fixed(template_match.name.lower(), self._char)
```
### Proposed fix
Replace the waypoint block with a verified load:
```python
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:
```python
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
```
```python
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:
```python
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:
```python
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`:
```python
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:
```python
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:
```python
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:
```python
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:
```python
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:
```python
if open_npc_menu(Npc.MALAH):
press_npc_btn(Npc.MALAH, "trade")
return Location.A5_MALAH
return False
```
to:
```python
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`:
```python
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`:
```python
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:
```python
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`:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
```
```python
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:
```python
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`:
```python
if not self._char.select_by_template(["A1_TOWN_0"], stash_is_open_func):
return False
```
`src/town/a5.py`:
```python
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`:
```python
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:
```python
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:
```python
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:
```python
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:
```python
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`:
```ini
weapon_switch=w
battle_orders=f6
battle_command=f5
```
The current CTA routine has two reliability problems:
1. It invalidates active-skill cache implicitly by switching weapons but does not reset `_active_skill`.
2. 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:
```python
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.")
```
```python
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`:
```python
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:
```python
if skills.is_right_skill_selected(["BC", "BO"]):
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
```
to:
```python
if skills.is_right_skill_selected(["BC", "BO"]):
self._weapon_switch()
```
Change the switch-to-CTA loop from:
```python
keyboard.send(Config().char["weapon_switch"])
wait(0.4, 0.45)
keyboard.send(Config().char["battle_command"])
```
to:
```python
self._weapon_switch()
keyboard.send(Config().char["battle_command"])
```
Change the switch-back loop from:
```python
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:
```python
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:
```python
switch_success = False
```
instead of:
```python
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:
```ini
weapon_switch=w
```
to:
```ini
weapon_switch=capslock
```
Only do that if the in-game key binding is actually Caps Lock; otherwise leave it as `w`.
-57
View File
@@ -1,57 +0,0 @@
# d2jsp Semi-Automated Scraping Guide
Due to Cloudflare's aggressive bot protection, fully automated scraping is currently restricted. This project uses a **Semi-Automated (Offline) Workflow** that leverages your authenticated browser session to safely collect market data.
## Prerequisites
1. **Python Dependencies:** Ensure `keyboard` and `requests` are installed (included in `requirements.txt`).
2. **Browser:** Google Chrome is recommended.
3. **Authentication:** Be logged into [forums.d2jsp.org](https://forums.d2jsp.org/) in your browser.
---
## The Workflow
### 1. Initial Directory Setup
Before running the automation, you must set the default "Save As" path in your browser:
1. Open any topic on d2jsp.
2. Press `Ctrl + S`.
3. Navigate to your bot folder: `data/d2jsp_pages/`.
4. Save the file. Your browser will now remember this location as the default.
### 2. Collect Topic URLs
If you don't have a fresh list of URLs, run the collector on a saved forum listing page:
```powershell
python tools/d2jsp_topic_collector.py --offline-dir data/d2jsp_pages --out data/d2jsp_topic_urls.txt
```
### 3. Run Browser Automation
This script will open the first 20 topics from your list, wait for Cloudflare to pass, and simulate the save command.
```powershell
python tools/browser_auto_save.py
```
**Important:**
* Keep your browser as the active window.
* Do not move the mouse or type while the script is running.
* It will automatically `Ctrl + S` -> `Enter` -> `Ctrl + W` for each tab.
### 4. Generate Price Estimates
Once the HTML files are saved in `data/d2jsp_pages/`, run the offline scraper to update your bot's configuration:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-20 --offline-dir data/d2jsp_pages --out config/fg_daily_estimates.json
```
---
## Troubleshooting
### Cloudflare "Just a Moment" Loop
If the automation is too fast and hits a "Just a Moment" screen that doesn't resolve:
1. Increase the `time.sleep(8)` value in `tools/browser_auto_save.py`.
2. Manually solve one challenge in the browser to "warm up" the IP clearance.
### Files Not Saving to Correct Folder
If files are saving to your "Downloads" folder instead of `data/d2jsp_pages`, the browser's default path was reset. Repeat **Step 1** to fix it.
### Date Parsing Errors
If the scraper reports 0 topics scanned or dates are missing, ensure your browser language isn't translating the page, as the scraper expects English month names (Jan, Feb, Mar, etc.).
-18
View File
@@ -1,18 +0,0 @@
# D2R Window and Input Troubleshooting
Botty expects Diablo II: Resurrected to expose a stable 1280x720 client area. If
the live D2R window reports a slightly different client size, template matching
can still succeed while mouse clicks land at the wrong monitor coordinate.
At startup and before creating a game, Botty now resizes and positions the D2R
client area to the configured `config/game.ini` dimensions. The expected size is
`1280x720`, matching `assets/d2r_settings.json` and the template assets.
If a UI click is detected but the cursor does not visibly move, the native input
layer first tries `SendInput` and then verifies the cursor position. When
`SendInput` misses the target coordinate, Botty falls back to `SetCursorPos` and
logs the fallback.
If movement or clicks still do not reach D2R, check that D2R and the bot process
are running at the same privilege level. Windows can block input from a
non-admin process into an elevated game window.
-41
View File
@@ -1,41 +0,0 @@
# Diablo Waypoint Recovery Notes
Date: 2026-06-07
## What changed
- Diablo is now first in the configured run order, followed by Pindle only.
- After using the Act 4 River of Flame waypoint, the Diablo approach now closes the waypoint panel, resets stale health-manager panel detections, and verifies that River of Flame templates are visible.
- If the first River of Flame waypoint attempt leaves the bot in town, the approach retries from the Act 4 town start before failing the run.
- River of Flame and Pentagram traversal now include template-based verification before continuing into Chaos Sanctuary logic.
- Diablo now has a reusable `_search_and_log()` wrapper for template searches that can report match confidence during route debugging.
- Health manager reset now clears `_count_panel_detects` so detections from a previous game do not immediately chicken the next game.
- `config/fg_daily_estimates.json` was regenerated with the improved offline estimator output, including skipped-topic counts and trimmed price statistics.
## Why
Same-act waypoint use can leave the waypoint panel open without a loading screen. That stale panel could combine with the CTA weapon-swap panel during pre-buffing and trigger the health manager panel-detection chicken path. The bot could also silently remain in town after a missed waypoint click and continue as if it had reached River of Flame.
The new checks make Diablo startup state-based: the bot confirms River of Flame and Pentagram markers before continuing. Failed waypoint transitions are retried once, then reported as approach failures instead of drifting into later pathing.
## Validation
Run a focused route order:
```ini
order=run_diablo, run_pindle
```
Expected log behavior:
- `_verify_in_rof: confirmed in River of Flame ...` after the Act 4 waypoint.
- `CS: Calibrated at PENTAGRAM` after Pentagram traversal.
- No immediate chicken caused by the waypoint panel plus CTA weapon-swap panel sequence.
If River of Flame or Pentagram verification fails repeatedly, refresh the affected templates:
- `DIABLO_ROF_WP_0`
- `DIABLO_ROF_WP_1`
- `DIABLO_ENTRANCE_50` through `DIABLO_ENTRANCE_55`
- `DIA_NEW_PENT_TP`
- `DIA_NEW_PENT_0` through `DIA_NEW_PENT_2`
-44
View File
@@ -1,44 +0,0 @@
# FG Market Scraper (Day 1-14)
This tool estimates FG prices per ladder day by scraping public trade topics from:
- https://forums.d2jsp.org/forum.php?f=271
## Script
- `tools/fg_market_scraper.py`
## What it does
1. Scans forum listing pages for topic links.
2. Fetches topic pages (rate-limited).
3. Detects known item/rune keywords.
4. Extracts `fg` prices from post text.
5. Buckets prices by ladder day index (Day 1..Day N).
6. Writes median estimates + sample counts.
## Usage
From repo root:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-23 --days 14
```
Output:
- `config/fg_daily_estimates.json`
## Tuning
- `--max-forum-pages`: listing pages to scan (default 40)
- `--max-topics`: hard cap on fetched topics (default 800)
- `--delay-s`: delay between requests (default 0.35s)
Example heavier run:
```powershell
python tools/fg_market_scraper.py --ladder-start-date 2026-05-23 --days 14 --max-forum-pages 120 --max-topics 2400 --delay-s 0.5
```
## Notes
- This is a heuristic estimator, not a full market engine.
- Accuracy depends on post format quality and keyword matches.
- Keep request rate polite to avoid stressing the forum.
- Some environments/IPs will receive HTTP `403` from d2jsp. In that case use:
- `config/fg_day_estimates.json` (Day 1-14 estimated multiplier model from Day 3 snapshot),
- and update `config/fg_prices.json` manually from your current market sample.
-91
View File
@@ -1,91 +0,0 @@
# Botty Fix Plan — 2026-06-05
## Symptoms (from today's logs)
- 4 sessions today: 31 + 1 + 9 + 6 games = 47 games total
- 51 deaths across all sessions
- 0 valuable items found (only charms, jewels, gold)
- Nihlathak run fails every time, ending entire games
- Vendor trade button never found (can't buy potions)
- Stash never opens (can't stash items)
- CTA weapon switch fails occasionally
---
## Priority 1: Nihlathak approach fails (game-ending)
**Error:** `Approach failed for run_nihlathak` — ends game after 600-650s
**Impact:** 3+ games ended per session
**Root cause:** Teleport activation times out, bot can't reach Nihlathak
**Fix:**
- Check `config/game.ini` for Nihlathak coordinates (`a5_nihlathak_*`)
- Likely missing or stale path nodes
- Re-record path with `node_recorder.py`
- May need fresh template images for Nihlathak area
**Files to check:**
- `config/game.ini` (Nihlathak section)
- `src/run/nihlathak.py`
- `assets/templates/nihlathak/`
---
## Priority 2: Vendor trade button not found
**Error:** `Could not find trade btn. Should not happen!`
**Impact:** Can't buy potions every town return
**Root cause:** Trade button template outdated or offset wrong for Hell difficulty UI
**Fix:**
- Take fresh screenshot of vendor trade window
- Update trade button template in `assets/templates/`
- Check if trade button position shifted between Normal/Nightmare/Hell
**Files to check:**
- `assets/templates/` (trade button template)
- `src/` (vendor interaction code — search for "trade btn")
---
## Priority 3: Stash detection fails
**Error:** `select_by_template: could not find ['A1_TOWN_0']` then `['A5_STASH', 'A5_STASH_2']`
**Impact:** Can't stash items, inventory fills with junk
**Root cause:** Stash template confidence threshold too high or template outdated
**Fix:**
- Take fresh screenshot of stash UI in Hell difficulty
- Update A1_TOWN_0, A5_STASH, A5_STASH_2 templates
- Consider lowering confidence threshold (currently 0.68)
**Files to check:**
- `assets/templates/a1_town/`
- `assets/templates/a5_stash/`
- Template matching threshold in code
---
## Priority 4: CTA weapon switch fails
**Error:** `_pre_buff_cta: switch to CTA slot failed — retrying`
**Impact:** Occasional, bot retries
**Root cause:** Weapon switch key or template unreliable
**Fix:**
- Verify weapon switch key binding (capslock per memory)
- Check CTA slot detection template
- May need more robust retry logic
**Files to check:**
- `src/` (search for `_pre_buff_cta`)
- `config/` (weapon_switch key)
---
## Approach
All four issues are template/coordinate problems — stale screenshots or wrong positions.
Fix pattern: screenshot current game UI at those locations, update templates.
Do in order: Nihlathak > vendor > stash > CTA.
-87
View File
@@ -1,87 +0,0 @@
# GEMS-tab transmute flow (rewritten in commit 48d8445)
`src/transmute/transmute.py``Transmute.convert_all_gems()` and helpers.
## What changed and why
The old flow drove gem upgrades through the **Horadric Cube UI**: it required the cube to live in
the PERSONAL stash tab, opened the cube, ctrl+shift+right-clicked 3 gems into it, clicked the cube's
Transmute button, then pulled the result back out. This was fragile — it depended on cube placement,
cube-open detection (`ScreenObjects.CubeOpened` via `wait_until_visible`), and a pre-flight
"empty the cube" pass.
The rewrite uses D2R's **native GEMS-tab convert panel** instead of the cube. Gems are moved
directly between the GEMS stack grid and the convert panel, and conversion uses the GEMS tab's own
convert button. The cube is no longer required to be in the stash and, if present in the character
inventory, is left untouched.
## Fixed stack-coordinate grid
`GEMS_TAB_STACK_COORDS` is a new module-level dict mapping every gem template
(`INVENTORY_<FAMILY>_<TIER>`) to a fixed `(x, y)` **screen** coordinate at 1280×720. The grid is:
- **Columns by family** (x): diamond 85, emerald 132, ruby 180, topaz 227, amethyst 275,
sapphire 322, skull 370.
- **Rows by tier** (y): chipped 154, flawed 195, standard 234, flawless 273, perfect 311.
Because gem stacks always render at these fixed slots on the GEMS tab, the flow can click them by
coordinate instead of template-searching for them each time. `_gems_stack_monitor_for(template)`
converts the screen coord to monitor coords (returns `None` if the template isn't in the grid, so
the caller can fall back to a template search).
## Panel constants (1280×720)
| Constant | Value | Meaning |
|----------|-------|---------|
| `GEMS_TAB_X` | `223` | GEMS tab click X (was `200`) |
| `GEMS_CONVERT_BUTTON` | `(225, 500)` | native convert button in the GEMS panel |
| `GEMS_CONVERT_PANEL_ROI` | `(160, 296, 128, 160)` | ROI to search for the converted result |
| `GEMS_CONVERT_FIRST_SLOT` | `(181, 318)` | fallback slot to grab the result if the search misses |
## Per-transmute flow (new)
For each gem type/tier with enough gems to convert:
1. **Switch to GEMS tab.**
2. **Load 3 gems**`_ctrl_shift_left_click_monitor` the stack slot ×3 to move 3 gems into the
convert panel. If exactly 3 weren't loaded, abort that batch.
3. **Convert**`_click_gems_tab_convert_button()` clicks the panel's native convert button.
4. **Recover the result** — template-search `gems_out` within `GEMS_CONVERT_PANEL_ROI`; if found,
ctrl+shift+left-click it back onto the GEMS tab. If not found, fall back to ctrl+shift+left-click
on `GEMS_CONVERT_FIRST_SLOT` (instead of the old behavior of clearing the cube and bailing).
5. **Leave the inventory cube untouched** and return to the GEMS tab for the next iteration.
The old pre-flight "open cube → empty 12 cube slots → reopen stash" pass and the per-iteration
"close cube / reopen stash" steps are **removed**.
## Helper changes
- `_open_cube_from_stash()` / `_open_cube_from_gems_tab()` / `_open_available_cube_for_gems_tab()`
now operate from the **GEMS** tab rather than PERSONAL, and can open the cube from character
inventory (`_open_cube_from_inventory`) when the GEMS tab is active. These remain for older
inventory flows; the new gem flow doesn't need them.
- `_locate_cube()` can now return `'opened'` (cube UI already open on the GEMS tab) in addition to
`'stash'` / `'inventory'`.
- `_ensure_cube_available()` now treats `inventory` as usable ("it will stay there") instead of an
error — the cube no longer has to be moved to PERSONAL.
- `_ensure_cube_in_stash()` dropped the loot-column safety guard: it now ctrl+clicks any positive
`CubeInventory` match, so the cube can be moved from reserved inventory columns too.
- **Keyboard modifier handling** switched from the low-level
`win_input.key_down/key_up(_get_vk(...))` to `keyboard.send(..., do_release=False)` /
`keyboard.release(...)` wrapped in `try/finally`, so ctrl+shift are always released even if the
click raises. Applies to both `_ctrl_shift_click_monitor` (right) and
`_ctrl_shift_left_click_monitor` (left).
- `_empty_cube_to_gems_tab()` now uses `_ctrl_click_monitor` (plain ctrl) rather than ctrl+shift.
## Gem counting (`_count_gems_by_ocr`)
Counting no longer template-searches the left-inventory ROI for each gem. It now reads each fixed
slot from `GEMS_TAB_STACK_COORDS`: crop the slot icon, and **skip empty slots** by checking the
95th-percentile grayscale brightness (`< 45` ⇒ no gem present). Occupied slots still OCR the count
badge in the bottom-right quadrant. This is faster and immune to template-match drift, at the cost
of depending on the fixed 1280×720 grid.
## Resolution assumption
The whole flow is hard-coded to **1280×720**. All coordinates above are screen coordinates at that
resolution, converted to monitor coordinates at click time via `convert_screen_to_monitor`.
-360
View File
@@ -1,360 +0,0 @@
# Hermes Takeover Guide
Practical handoff for continuing Botty development. Read every section before touching code — the trap patterns section alone will save you hours.
---
## 1) Working model
- Treat Botty as a **state machine app** with side effects across D2R UI automation, OCR parsing, inventory/sell/stash routines, and messaging (Discord/webhooks).
- Prefer **small, production-safe patches** over broad refactors.
- Prioritize runtime stability: avoid failing full runs because one subsystem is flaky.
- **Always reproduce from logs first** — most bugs leave a clear trail.
---
## 2) Daily workflow
1. Get fresh logs:
- `log/log.txt`
- `log/stats/events_*.jsonl`
- `log/stats/stats_*.log`
2. Confirm failure path in code with `rg`.
3. Patch narrowly with targeted edits.
4. Validate:
- `python -m compileall <changed files>`
- targeted pytest where available
5. Commit with a clear, single-purpose message.
---
## 3) Core debug commands
From repo root (PowerShell):
```powershell
# Tail errors/warnings
rg -n "ERROR|WARNING|Failed|chicken|exception" log/log.txt | tail -50
# Last 200 log lines
Get-Content log/log.txt | Select-Object -Last 200
# Search source code
rg -n "<keyword>" src/
# Compile check after edits
python -m compileall src/health_manager.py src/item/pickit.py
# Config smoke test (confirm a key's resolved value)
python -c "import sys; sys.path.insert(0,'src'); from config import Config; print(Config().char['show_belt'])"
# Run test suite
python -m pytest -q test/
```
Use `rg` first. It's the fastest way to localize faults.
---
## 4) High-risk areas
### 4.1 Repair/vendor flow
- A5 Larzuk detection is noisy and can fail.
- Current behavior: A5 normal flow → Larzuk direct template click → Act 4 Halbu fallback.
- `repair_npc=a4_halbu` config key skips Larzuk entirely.
Files: `src/town/a5.py`, `src/town/town_manager.py`, `src/bot.py`
### 4.2 Discord messaging
Known issue: `'NoneType' object has no attribute 'to_dict'`
Current fix: `src/messages/discord_embeds.py` `_send_embed` only passes `file=` when attachment exists. Plain text fallback on embed failure.
Config: `config/params.ini``[discord_events]`
### 4.3 Selling safety
Current protections:
- Sell logs include item names (not coordinates only).
- `protect_shields_from_sell=1` blocks selling items whose detected name includes "shield".
Files: `src/inventory/personal.py`, `config/params.ini`, `src/config.py`
### 4.4 XP logging
Two separate concerns:
1. OCR extraction: `src/ui/player_bar.py` — parser handles `I/l/|→1`, `O/o→0`.
2. XP status projection math: `src/game_stats.py` `_create_msg()` — zero denominators guarded.
### 4.5 HealthManager (rejuv / chicken logic) ⚠️
The most subtle source of false positives. See Section 10.2 for full details.
Key thresholds (params.ini `[char]`):
- `take_rejuv_potion_health = 0.80` — drink rejuv if HP ≤ 80%
- `take_rejuv_potion_mana = 0.55` — drink rejuv if mana ≤ 55%
- `chicken = 0.50` — flee if HP ≤ 50%
The "two juvs in 8s → chicken" check **must also verify HP was the trigger**, not just mana. Hammerdins burn mana fast; mana can legitimately trigger back-to-back rejuvs at full HP.
### 4.6 PickIt gold loop ⚠️
`_yoink_item()` **always returns `PickedUpResult.PickedUp`** regardless of actual success (line 129). This means pickup failures are only detectable through `_pick_up_item`'s same-ID or same-UID checks — and different gold pile amounts produce different IDs, bypassing those checks entirely.
Fix in place: `pick_up_items()` now blacklists `item.ID` in `_cached_pickit_items` on `PickedUpFailed`. Do not remove this case.
---
## 5) State machine (bot.py)
States: `initialization → hero_selection → town → [run state] → town → ...`
Full state list: `initialization`, `hero_selection`, `town`, `level`, `pindle`, `shenk`, `trav`, `nihlathak`, `arcane`, `diablo`, `vizier`, `baal`, `mephisto`, `andariel`, `countess`
Key transitions:
| Trigger | Source | Dest | Handler |
|---|---|---|---|
| `init` | initialization | initialization | `on_init` |
| `select_character` | initialization | hero_selection | `on_select_character` |
| `start_from_town` | initialization/hero_selection | town | `on_start_from_town` |
| `maintenance` | town | town | `on_maintenance` |
| `run_pindle` | town | pindle | `on_run_pindle` |
| `run_arcane` | town | arcane | `on_run_arcane` |
| `end_run` | any run state | town | `on_end_run` |
| `end_game` | town/any run state | initialization | `on_end_game` |
**`on_maintenance` guard**: If `_curr_loc` is None (e.g. after a chicken recovery), defaults to `A1_TOWN_START` with a warning log. Do not remove this guard.
**`end_game` vs `end_run`**: When TP charges run out, trigger `end_game` so the bot restarts and restocks in the next game — not `end_run` which tries to TP back and loops.
---
## 6) Config system
Priority order (highest first):
```
custom.ini > params.ini > game.ini > shop.ini > transmute.ini
```
`custom.ini` is gitignored — user-only overrides. Never edit `game.ini` for user settings.
### Key detection flow
On every `Config()` instantiation:
1. Reads `Saved Games/Diablo II Resurrected/<charname>.keyo` binary file.
2. Parses slot-to-key mapping (`CHAR_BINDING_SLOTS` in `key_detector.py`).
3. Slot 41 = `show_belt`, slot 36 = `stand_still`, slot 44 = `weapon_switch`, etc.
4. Compares each detected key against params.ini value.
5. If they match → use detected key silently.
6. If they differ → logs `"Keeping configured key binding for X: 'params_val' (detected 'keyo_val')"` and keeps the params.ini value.
**What "Keeping..." means**: params.ini disagrees with the in-game binding. Usually indicates a config typo or stale params.ini. If you see `Keeping show_belt: 'k' (detected 'n')`, the fix is `show_belt=n` in params.ini `[char]`.
### Config singleton pattern
`Config` uses `__new__` with a `data_loaded` class variable. Calling `Config()` multiple times in the same process returns the same loaded instance. After editing params.ini at runtime, `Config().reload()` is needed (or restart the bot).
---
## 7) Current bot profile (Fistman — as of 2026-06-05)
- **Character**: Hammerdin (`src/char/paladin/hammerdin.py`)
- **Runs**: Pindle + Arcane Sanctuary
- **Key bindings** (from .keyo + params.ini):
- `show_belt = n` (belt hotkey)
- `stand_still = capslock` (overrides detected 'shift')
- `weapon_switch = w`
- `show_items = alt`
- `potion1..4 = 1,2,3,4`
- **Capabilities**: `can_teleport_natively = True` (set via `override_capabilities` in `[advanced_options]`)
- **Thresholds**: `take_rejuv_potion_health=0.80`, `take_rejuv_potion_mana=0.55`, `chicken=0.50`
---
## 8) Testing strategy by change type
### Messaging changes
```powershell
python -m pytest -q test/test_discord_embeds.py
```
### Config parsing changes
```powershell
python -m compileall src/config.py
python -c "import sys; sys.path.insert(0,'src'); from config import Config; c=Config(); print(c.char['show_belt'], c.char['stand_still'])"
```
### Inventory/sell logic
- Validate no exceptions in `inspect_items` / `transfer_items`.
- Log outputs should include item names (not just coordinates).
- Prefer dry functional checks from logs before gameplay runs.
### HealthManager changes
- Check that chicken thresholds still fire at the right HP%.
- Verify the two-rejuv check only triggers when `health_percentage <= take_rejuv_potion_health`.
### PickIt changes
- Confirm `_cached_pickit_items` is populated on both `PickedUp` (cached True) and `PickedUpFailed` (cached False).
- Confirm `_yoink_item` return value is not relied on for success detection.
---
## 9) Git hygiene
- Keep untracked: `.env`, `config/custom.ini`, `log/`, `log/screenshots/`
- Do not revert unrelated user changes.
- Commit frequently with single-purpose messages.
- `python -m compileall src/` must pass cleanly before committing.
---
## 10) Module internals and trap patterns
### 10.1 Config / key detection traps
**Trap**: `apply_key_bindings` runs *after* `self.char` is built in `config.py`. If you add a new key to `self.char` dict and it doesn't exist in `CHAR_BINDING_SLOTS`, the keyo detector won't touch it — but the user still needs to have it in params.ini.
**Trap**: "Keeping configured key binding" is NOT an error. It means the user explicitly configured something different from the game default. It becomes a problem only if the params.ini value is wrong (e.g., 'k' instead of 'n' for show_belt).
**Trap**: `Config()` is a singleton via `__new__`. The first call loads everything. Subsequent calls within the same process return the cached instance. Do NOT expect param changes at runtime to be visible without `Config().reload()`.
### 10.2 HealthManager rejuv traps
The rejuv logic in `start_monitor()`:
```python
if last_drink > 0.60: # minimum between rejuvs
if health <= take_rejuv_potion_health or mana <= take_rejuv_potion_mana:
drink_rejuv()
self._last_rejuv = time.time()
# Two juvs in 8 seconds → chicken ONLY if HP was the trigger
if last_drink < 8 and health_percentage <= Config().char["take_rejuv_potion_health"]:
self._do_chicken(img)
```
**Critical**: The `last_drink < 8` chicken check MUST also check `health_percentage`. Without it, any mana-triggered second rejuv (common for Hammerdins) will false-chicken at 99.9% HP. The fix is already in place — do not revert it.
**Timing**: The monitor polls every `3/25s * jitter(±20%)` ≈ 96144ms. At 25 FPS that's every 3 frames.
**Thread safety**: `_pause_state` and `_panel_check_paused` are protected by `_state_lock`. Module-level `get_pause_state()` / `set_pause_state()` functions delegate to the singleton. Always use these functions from external code.
### 10.3 PickIt ID/UID system
`GroundItem` has two identifiers:
```python
ID = slugify(f"{Name}_{'_'.join([str(v) for _,v in as_dict().items()])}")
# Includes Amount in the string. Two gold piles with different amounts = different IDs.
UID = f"{ID}_{'_'.join([str(v) for v in center])}"
# ID + screen position. Same pile at same coordinates = same UID.
```
**Trap**: `_pick_up_item`'s gold-fail detection uses `item.ID == prev.ID`. If two nearby gold piles have different amounts (e.g., 338g and 157g), they alternate as the "next" item and each one's ID never matches the previous, so the same-ID fail check never fires. The loop runs until timeout (20s).
**Trap**: `_yoink_item` ALWAYS returns `PickedUpResult.PickedUp`. It never returns `PickedUpFailed`. Pickup failures for teleport builds are silently swallowed.
**Fix in place**: `pick_up_items()` match block now has:
```python
case PickedUpResult.PickedUpFailed:
self._cached_pickit_items[item.ID] = False # blacklist this session
```
This stops the alternating-gold loop by blacklisting the item after the first confirmed failure.
### 10.4 Belt system open() key chain
`belt.open()` tries keys in this order:
```python
[config_val, "n", "k", "`", "~"] # deduplicated
```
If `show_belt = n` in params.ini, the first key tried is 'n'. If it works, no fallback keys appear in logs. If you see `"Trying to open belt with key: k"` it means 'n' failed — check if `show_belt` is actually set to 'n' and if the D2R window is focused.
### 10.5 TownManager location routing
`get_act_from_location(loc)` returns `None` for non-string inputs (e.g., `True`, `False`). The isinstance guard at line 36 (`if not isinstance(loc, str): return None`) prevents `AttributeError: 'bool' object has no attribute 'upper'`. Do not remove it.
All town methods that receive a `Location` return `False` (not `None`) on failure. Callers should check `if not new_loc` not `if new_loc is None`.
### 10.6 State machine: `end_game` vs `end_run`
`end_run` sends a TP, waits in town, does maintenance, then starts another run. If something prevents getting back to town (no TP scrolls, merc dead with no body, disconnected), `end_run` loops.
`end_game` saves and exits, restarts the game fresh. Use it when:
- TP charges = 0 (bot will restock on next game start)
- Unrecoverable in-game state
- Max consecutive failed runs reached
Triggering `end_run` when TP is gone causes an infinite "No TP charges left, trying to walk back" loop (pre-fix behavior).
### 10.7 distance calculation (processing_helpers.py)
The y-center of the screen for distance math is `screen_height / 2`, NOT `screen_width / 2`. Using the wrong dimension skews distance sorting for items on the top/bottom half of the screen. Fix is already applied.
---
## 11) Bugs fixed in 2026-06-04/05 session
All fixes were applied and verified by Python compile/config tests:
| Bug | File | Symptom in logs | Fix |
|---|---|---|---|
| `show_belt` wrong key (`n` instead of `k`) | `config/params.ini` | "Recovered belt hotkey using 'k'" on first game, then silent in-memory mutation | `show_belt=n``show_belt=k` |
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | Crash in `get_act_from_location` when `True`/`False` passed as loc | Added `isinstance(loc, str)` guard |
| No-TP → infinite loop | `src/bot.py` | "No TP charges left, trying to walk back" repeated forever | Trigger `end_game` instead of `end_run` on zero TP |
| Distance y-axis wrong | `src/d2r_image/processing_helpers.py` | Items sorted by wrong distance; far items picked first | `screen_width/2``screen_height/2` for y |
| `on_maintenance` crash with no location | `src/bot.py` | Crash after chicken recovery when `_curr_loc=None` | Guard: default to `A1_TOWN_START` if None |
| False-positive chicken on mana rejuv | `src/health_manager.py:133` | "Two juvs drank within 0.63s. Chicken, HP 99.9%!" | Added `and health_percentage <= take_rejuv_potion_health` |
| Gold pickup infinite loop | `src/item/pickit.py:241` | 338g/157g alternating in logs for 20s | Added `PickedUpFailed` case to blacklist `item.ID` |
| Health pots sold when needed | `src/inventory/personal.py:351` | "Discarding SUPER HEALING POTION." + "Confirmed sell SUPER HEALING POTION" despite health needs | Check `get_needs()` before dropping consumable; `continue` to skip sell/drop when pot is needed |
| No fill_from_inventory after failed buy | `src/bot.py` (after line 438) | Belt empty all game despite pots sitting in inventory; "Out of gold" then nothing fills belt | After buy_consumables block, call `fill_up_belt_from_inventory` + `update_pot_needs` when needs > 0 |
| Wrong weapon in combat after chicken mid-buff | `src/char/i_char.py` `_pre_buff_cta` | Character dies immediately; dies with CTA flail/shield instead of main weapon | Added BC skill-bar template verification after each `weapon_switch`; corrects slot if wrong at game start; retries once on failure |
---
## 12) Known pending issues (as of 2026-06-05)
- **C10** (IMPROVEMENTS.md): `kill_thread()` uses `PyThreadState_SetAsyncExc` — can leave locks inconsistent. Replace with `threading.Event` cooperative shutdown. High risk.
- **optipng pass on assets/**: Pending. Run `asset_manager.py batch` or `optipng -o7` on all PNGs.
- **Thread safety** (H14 in IMPROVEMENTS.md): `health_manager` and `death_manager` shared state — Lock is now present in HealthManager but verify all paths use it.
- **PickedUpResult enum gap** (M14): Values are 0,1,3,4,5. Value 2 is missing. Non-critical but confusing.
- **Gold vicious cycle**: Low gold → can't buy pots → health empty → more chickens/deaths → less gold. Monitor runs after the personal.py + bot.py fix — if the cycle still triggers, also check that `inspect_items` isn't being called with vendor_open=True before `fill_up_belt_from_inventory`.
---
## 13) Fast triage mapping
| Symptom in logs | Where to look | Likely cause |
|---|---|---|
| "Recovered belt hotkey using 'k'" on game 1, then silent | `config/params.ini` | `show_belt=n` should be `show_belt=k` |
| "Two juvs drank... Chicken" at HP > 80% | `src/health_manager.py:133` | Missing HP check on two-rejuv condition |
| Gold pile (XYZg) repeating 5+ times | `src/item/pickit.py:241` | `PickedUpFailed` case missing; item not blacklisted |
| "Failed to pick up X" then same X again immediately | `_yoink_item` / `_cached_pickit_items` | Blacklist not being set on failure |
| `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | isinstance guard removed or bypassed |
| "No TP charges left, trying to walk back" (repeating) | `src/bot.py` around `end_run` | `end_run` triggered when should be `end_game` |
| "No current location set" | `src/bot.py on_maintenance` | `_curr_loc` was None after chicken/recovery |
| Discord embed errors | `src/messages/discord_embeds.py` | `file=` kwarg passed when attachment is None |
| Repair fail loops | `src/town/a5.py` + `town_manager.py` | Larzuk template noise; check A4 fallback path |
| "Failed to log exp" | `src/ui/player_bar.py` | OCR misread; check for `I/l``1` ambiguity |
| Sell includes wrong items | `src/inventory/personal.py` | `protect_shields_from_sell` or item filter issue |
| "Discarding SUPER HEALING POTION" + "Confirmed sell..." | `src/inventory/personal.py:351` | Consumable sold despite belt need — fixed by get_needs() guard |
| Belt needs stay health=3/mana=3 game after game; pots never drunk | `src/bot.py` after buy_consumables + `personal.py:351` | Health pots sold during inspect; no fill_from_inventory fallback |
| "started on CTA slot" in logs; dies in first seconds of run | `src/char/i_char.py _pre_buff_cta` | Game saved with CTA slot active (interrupted buff). Use `BC` template check at startup to detect and correct |
| Character enters run at partial HP (e.g. 40% after chicken) | `src/bot.py on_maintenance` | Health manager paused in town; no town-heal loop. Check `meters.get_health` and drink belt pots in maintenance before `update_pot_needs` |
---
## 14) "Done" checklist for a fix
- [ ] Reproduced from logs
- [ ] Root cause identified in source
- [ ] Patch applied in smallest reasonable scope
- [ ] `python -m compileall <changed_files>` passes
- [ ] Target tests pass (or explicitly explain why unavailable)
- [ ] Python smoke test confirms the fix (e.g., `Config().char['show_belt']`)
- [ ] Behavior documented here or in README/params if user-visible
---
If you need to continue immediately: start from latest `main`, run a short bot session, then inspect only the newest 200300 log lines before changing anything.
+258
View File
@@ -0,0 +1,258 @@
# Anti-Detection Framework for Botty-Go
## Overview
This document outlines the multi-layered anti-detection system built into botty-go.
Each layer addresses a specific detection vector that Blizzard and modern anti-cheat
systems use to identify bots.
---
## 1. Server-Side Behavior Analysis Countermeasures
### Detection: Session length, timing consistency, pathing patterns, repetition
### Countermeasures:
#### 1a. Variable Session Scheduling
- **Implementation:** `internal/schedule/scheduler.go`
- Randomized session start times using a circadian model
- Simulated human sleep patterns: 6-10 hour breaks between sessions
- Weekend/weekday behavior variance (humans play differently on weekends)
- Random session lengths: 20min to 6hours with exponential distribution
- Occasional "just 5 more minutes" overtime and "I'm tired" early stops
#### 1b. Stochastic Pathing
- **Implementation:** `internal/pather/stochastic.go`
- Add deliberate pathing imperfection: 5-15% deviation from optimal route
- Occasional wrong-way teleports followed by course correction
- Non-optimal waypoint selections (humants don't always take shortest path)
- Variable route ordering with cooldown-dependent choices
- 2-3% chance of "getting lost" and using wrong waypoint first
#### 1c. Skill Rotation Variance
- **Implementation:** `internal/char/behavior.go`
- Variable pre-buff timing (humans rush sometimes, sometimes take time)
- Occasional wrong skill selection followed by correction
- Potion usage with human-like hesitation (check multiple times before drinking)
- Merc healing variance: sometimes forget, sometimes over-heal
#### 1d. Route Randomization with Context
- **Implementation:** `internal/bot/route_planner.go`
- Dynamic route selection based on:
- Time since last run of each type
- Current TP scroll count (humans adapt)
- Gem/transmute urgency
- Occasional "feels like it" switches
- Never perfect round-robin; use weighted probability with drift
#### 1e. Farming Repetition Masking
- Never run the same route more than 8 times consecutively
- Insert "town breaks": stash visit, shrine check, repair, gamble
- 1-2% chance of "I'm bored, switching to different run" mid-session
- Vary kill strategies: sometimes rush, sometimes methodical
---
## 2. Warden / Client Integrity Countermeasures
### Detection: Loaded modules, injected DLLs, memory signatures, debuggers
### Countermeasures:
#### 2a. Pixel-Only Architecture (No Memory Access)
- **Implementation:** entire bot reads game state ONLY via screenshots
- NO memory reading, NO DLL injection, NO process hooking
- Same attack surface as a human with a camera pointed at the screen
- This is the #1 defense: if you only use screen capture + input simulation,
there's nothing to scan in process memory
#### 2b. Clean Process Environment
- **Implementation:** `internal/runtime/clean_env.go`
- Standard Go binary with no suspicious imports
- No debuggers, no memory readers, no process manipulation
- Run as a normal application, not injected
#### 2c. Overlay Avoidance
- Never draw on top of game window
- No window hooking or injection
- Screenshot from a separate thread, not an overlay
---
## 3. Input Pattern Analysis Countermeasures
### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections
### Countermeasures:
#### 3a. Human Motor Model
- **Implementation:** `internal/mouse/human_model.go`
- Full biomechanical mouse model based on Fitts' Law and human motion studies
- Real human mouse data characteristics:
- Multi-segment movement with micro-pauses (1-3 segments per motion)
- Acceleration curve: start slow, peak in middle, decelerate into target
- Endpoint micro-adjustments: 2-5 pixel wobble before click
- Inter-trial variability: each movement is unique even to same target
- Asymmetric error distribution: overshoot more right/down (human bias)
#### 3b. Click Timing Model
- **Implementation:** `internal/mouse/click_model.go`
- Variable time between "arriving" at target and clicking: 50ms-800ms
- Pressure curve: humans don't click at exact same speed
- Double-click rate varies naturally
- Occasional misses: 0.5-1% of clicks land slightly off (1-3px)
#### 3c. Keyboard Behavior Model
- **Implementation:** `internal/keyboard/human_model.go`
- Key press duration variance: not all keypresses are identical
- Typing rhythm for skill hotkeys: natural cadence with micro-pauses
- Occasional key repeat (holding too long = rapid fire)
- Realistic key-up/key-down timing ratios
#### 3d. Statistical Indistinguishability
- **Implementation:** `internal/input/stats.go`
- All input streams modeled from real human motion capture data
- Entropy analysis of output matches human baselines
- Auto-calibration: measure user's own input if they do manual play
- Periodically inject "manual-looking" variance spikes
---
## 4. Economy and Item-Flow Countermeasures
### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior
### Countermeasures:
#### 4a. Natural Accumulation Rate
- **Implementation:** `internal/inventory/economy.go`
- Vary farming intensity: some sessions heavy, some light
- Match accumulation to stated playtime (more sessions = more loot)
- Occasionally "waste" items on gambling/repairs like a real player
#### 4b. Realistic Trading Patterns
- No mass item funneling
- If trading, do it in human-sized batches with natural pauses
- Vary trade partners and timing
#### 4c. Rune Farming Variance
- Don't farm the same runes every session
- Match rune acquisition to character progression
- Occasionally skip rune picks when "full"
---
## 5. Ban Wave Defense
### Detection: Delayed batch bans
### Countermeasures:
#### 5a. Graceful Degradation
- **Implementation:** `internal/runtime/safe_mode.go`
- If one account gets banned, immediately reduce intensity across all
- Auto-pause farming for 48-72 hours (simulating "taking a break")
- Gradual return with reduced session lengths
- Change behavior patterns after any ban event
#### 5b. Account Diversity
- Each account has distinct "personality":
- Different session timing preferences
- Different route preferences
- Different response timing distributions
- Different play styles (rusher vs methodical)
---
## 6. Server Authority Countermeasures
### Detection: Server-side validation of movement, drops, combat, inventory
### Countermeasures:
#### 6a. Server-Authoritative Behavior
- **Implementation:** `internal/bot/server_aware.go`
- Only interact with what the server actually shows
- Wait for server confirmation before acting (e.g., confirm item picked up)
- Respect server-enforced movement limits (no speed hacks)
- Process drops in game-authorized order
#### 6b. No Client Manipulation
- Never try to spoof packets, modify client, or exploit desync
- Purely reactive: see screen -> decide -> act -> wait for response
---
## 7. Social/Reporting System Countermeasures
### Detection: Player reports + telemetry correlation
### Countermeasures:
#### 7a. Social Stealth
- **Implementation:** `internal/social/stealth.go`
- Play during off-peak hours less suspiciously
- Avoid solo-public routes that attract attention
- Occasionally join other players' games (with reduced automation)
- Inherit human-like chat behavior if configured
---
## 8. Hardware/Identity Correlation Countermeasures
### Detection: IP patterns, hardware fingerprints, VMs, account clusters
### Countermeasures:
#### 8a. Clean Deployment
- **Implementation:** `internal/deploy/clean.go`
- Run on real hardware, not VMs
- Use residential IP, not datacenter
- One account per hardware profile
- No VPN/proxy during play sessions
---
## Implementation Architecture
```
internal/
├── input/ # Human-like input generation
│ ├── mouse_model.go # Fitts' Law mouse movement
│ ├── click_model.go # Human click timing
│ ├── keyboard_model.go # Keyboard behavior
│ └── stats.go # Statistical verification
├── behavior/ # High-level human behavior simulation
│ ├── scheduler.go # Session scheduling
│ ├── route_planner.go # Dynamic route selection
│ ├── fatigue.go # Simulated fatigue/boredom
│ └── personality.go # Per-account personality
├── economy/ # Economic behavior masking
│ ├── accumulation.go # Natural loot accumulation
│ └── trading.go # Human-like trading patterns
├── safe_mode/ # Graceful degradation
│ ├── detection.go # Ban wave detection
│ └── cooldown.go # Auto-pause and return
└── deploy/ # Clean deployment helpers
└── check.go # Pre-flight integrity checks
```
## Key Design Principles
1. **Statistical indistinguishability:** Output must be statistically
indistinguishable from real human input. We use actual human motion
capture data distributions, not made-up random numbers.
2. **Controlled imperfection:** A human is inefficient, forgetful, and
inconsistent. The bot should be too — but in a way that matches
real human distributions.
3. **No single fingerprint:** Every instance should have unique enough
characteristics that correlating two accounts is hard.
4. **Adaptability:** If behavior changes are detected, the system should
be able to recalibrate based on new data.
5. **Defense in depth:** No single countermeasure is sufficient. The
combination across all layers is what provides real protection.
+33
View File
@@ -0,0 +1,33 @@
# Botty-Go
D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows).
Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite
in Go that maintains compatibility with the same config files, templates, and run
logic while adding native Linux support.
## Features
- Cross-platform: Linux (X11/Wayland) and Windows
- Same config format as original Botty (params.ini, game.ini, shop.ini)
- Template matching with OpenCV Go bindings
- Tesseract OCR for item identification
- Human-like mouse movement (Bezier curves)
- BNIP pickit language
- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.)
- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo)
## Building
```bash
# Linux
go build -o botty ./cmd/botty
# Windows (from Linux with cross-compile)
GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty
```
## Configuration
Copy `config/` from the original Botty project. Params, routes, and character
config work identically.
+19
View File
@@ -0,0 +1,19 @@
# Legacy: Go Rewrite Design Notes
These docs are archived from an abandoned `~/git/botty-go` directory (May 2026).
That project was a planned ground-up Go rewrite of `johannes-do/botty` for
cross-platform (Linux + Windows) support. Only design docs existed — no `.go`
source was ever written.
The Python `my-botty` project (this repo) is the active path. These docs are
kept here as **reference material**, primarily for Milestone 2 (anti-detection /
stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`.
## Files
- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers
server-side behavior analysis countermeasures (session scheduling, stochastic
pathing, skill rotation variance) and more. Directly applicable as the design
basis for the Python stealth layer.
- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project.
Context only — explains feature scope and what the rewrite was aiming for.
-83
View File
@@ -1,83 +0,0 @@
# Linux Port Plan (Botty)
## Goal
Make Botty runnable on Linux in phased steps, with clear checkpoints and minimal regressions for current Windows users.
## Current status
Botty is currently Windows-first. Full gameplay flow does not run on Linux due to:
- Windows input stack (`win_input`, Win32 hotkey polling).
- Windows process/window management (`taskkill`, Win32 window APIs, `os.startfile`).
- Windows dependency assumptions (`pywin32`, Windows tesserocr wheel guidance).
- Windows path/env assumptions (`APPDATA`, `C:\...`, `D2R.exe`, `.bat` scripts).
## Principles
- Keep Windows behavior unchanged while adding Linux support.
- Introduce platform abstractions before replacing implementations.
- Land small, testable phases.
- Prefer graceful `NotImplemented` behavior over hard crashes on unsupported paths.
## Phase 1: Platform abstraction layer
1. Add a `platform_adapter` module with interfaces for:
- Input (keyboard/mouse send + hotkeys)
- Window management (find game window, set top-most, geometry)
- Process control (start/stop/check D2R/Battle.net)
2. Route existing Windows calls through adapters.
3. Add Linux stub implementations that fail gracefully with actionable logs.
4. Add unit tests for adapter selection and fallback behavior.
## Phase 2: Linux-safe startup and tooling
1. Add Linux entry script (`run_botty.sh`) and dependency checker shell script.
2. Update startup to avoid Windows-only calls unless platform is Windows.
3. Normalize path handling to `pathlib` where feasible.
4. Ensure `main.py` can start on Linux without immediate import/runtime crashes.
## Phase 3: Linux input backend
1. Implement Linux input backend (X11/Wayland-compatible strategy):
- Candidate libs: `pynput`, `python-xlib`, or tool-backed approach (`xdotool` for X11).
2. Match required Botty features:
- Key press/hold/release
- Mouse move/click with jitter and timing controls
- Hotkey registration/polling
3. Add integration tests/mocks for input primitives.
## Phase 4: Linux screen/window backend
1. Validate capture compatibility for `mss` under target Linux desktop/session.
2. Implement Linux window discovery/focus/geometry handling.
3. Rework DPI/coordinate normalization independent of Win32 APIs.
4. Add diagnostics tool to verify coordinates, capture ROI, and template matching on Linux.
## Phase 5: Process and launcher integration
1. Linux-compatible process management (replace `taskkill` paths).
2. Replace `os.startfile` launcher logic with cross-platform process spawning.
3. Add platform-specific config defaults for game executable path conventions.
## Phase 6: Dependency and OCR strategy
1. Split dependencies by platform (base + windows extras + linux extras).
2. Document Linux OCR setup (tesseract/leptonica packages + python bindings).
3. Add CI matrix entries:
- Windows: full current pipeline
- Linux: import/startup + unit/integration subset first, expand later
## Phase 7: Feature parity validation
1. Verify end-to-end flows:
- Start game, run cycle, maintenance, save/exit, restart handling
2. Validate pickit, stash/sell, discord messaging, stats logging.
3. Benchmark timing-sensitive routines and tune Linux defaults.
## Risk register
- Wayland restrictions can block synthetic input/screen capture depending on compositor.
- Template matching thresholds may differ due to capture pipeline differences.
- Hotkey handling behavior can differ across desktop environments.
- OCR reliability can vary based on font rendering stack.
## Suggested delivery milestones
1. **M1**: Linux no-crash startup + stubs + docs.
2. **M2**: Linux input backend functional in sandbox diagnostics.
3. **M3**: Linux screen/window backend and maintenance loop stable.
4. **M4**: End-to-end run support in supported Linux environments.
## Acceptance criteria
- Botty starts on Linux and logs clear capability status.
- No Windows-only hard failures on Linux code paths.
- Core run loop can execute in a supported Linux environment.
- Windows behavior remains stable and covered by existing tests/CI.
-154
View File
@@ -1,154 +0,0 @@
# Paladalla Melee Hunt Test Log
Date: 2026-08-23
Workspace: `C:\Users\alex\my-botty`
Character/profile: `paladalla`
Script: `tools/melee_hunt.py`
## Current State
- Testing was stopped at the user's request.
- No `melee_hunt` Python process was running when checked.
- Paladalla was alive in the Rogue Encampment at the end of the session.
- The four visible belt potions were manually consumed. The last screenshot shows
partial life and an empty belt, so refill and fully heal before the next run.
- The game must not be controlled automatically until the user asks to resume.
Final-state screenshot: [healed_from_belt.png](../log/screenshots/manual/healed_from_belt.png)
## Commands Used
The successful short run used:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left
```
The later long tests used:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 1440
```
`F12` is intended to stop the script. It worked during one run but was not
reliable while an in-game overlay was open. `Ctrl+C` in the script terminal was
the reliable emergency stop.
## Verified Results
### Initial ten-minute run
- Started outside town and completed its configured timer normally.
- Result: `engaged 107, roamed 15`.
- XP changed from `261/500` to `357/500`.
- Final reported life was `69%`.
- The process then exited because the default duration is ten minutes. Paladalla
later died because nothing was controlling or protecting the character.
### Corpse recovery and level gain
- The death prompt was cleared and the game was cycled through Save and Exit.
- The corpse appeared beside Paladalla in town and was recovered successfully.
- Weapon and attack slots were restored.
- Paladalla was manually routed across the bridge into the Blood Moor.
- Manual movement/attack clicks later produced a level-up. The next script start
read XP as `564/1500`, confirming level 2 and working melee damage.
Evidence:
- [corpse_recovered.png](../log/screenshots/manual/corpse_recovered.png)
- [blood_moor_ready2.png](../log/screenshots/manual/blood_moor_ready2.png)
- [deep_moor_restart.png](../log/screenshots/manual/deep_moor_restart.png)
## Failures Found
### 1. Ten-minute default looked like a crash
The first hunt was not dead or hung. It reached its default ten-minute deadline
and exited cleanly. Long unattended tests must pass an explicit `--minutes`
value or implement a true continuous mode.
### 2. Friendly bridge NPC was treated as motion target
The first 24-hour attempt remained near the town bridge. At the two-minute
checkpoint it showed repeated attacks, full life, and unchanged XP
(`357/500`). The screenshot showed it attacking the friendly rogue near the
bridge. Motion alone cannot distinguish that NPC from a monster.
Evidence: [hunt_2min_checkpoint.png](../log/screenshots/manual/hunt_2min_checkpoint.png)
Workaround used: stop the script and move several screens deeper into the Blood
Moor before restarting. A permanent NPC rejection/stall detector is still
needed.
### 3. Animated HUD controls were treated as targets
After reaching level 2, the pulsing new-stat/new-skill controls appeared above
the original HUD mask. The motion detector could target that region. During the
second long attempt an in-game Loot Filter overlay opened and the script kept
issuing clicks behind it.
Evidence: [restart_live_40s.png](../log/screenshots/manual/restart_live_40s.png)
### 4. No-potion condition was unsafe
Life fell through `18%`, `14%`, `10%`, `8%`, and eventually `0%`. The script
logged `no healing potion in belt!` but continued its loop. Belt detection also
reported no potion even though red potions were visibly present, so belt
detection or its profile assumptions need separate investigation.
### 5. XP OCR became invalid after level-up/UI changes
The log reported XP OCR values such as `'-'` and `''`, then incorrectly printed
`*** LEVEL UP *** now 0/0`. Invalid OCR should not be interpreted as a level-up,
and `xp_last` should retain the last valid sample.
## Code Changes Made
`tools/melee_hunt.py` was changed in two ways:
1. The target scan bottom boundary was raised from `640` to `510`, excluding the
animated level-up/stat controls and the interactive HUD from motion targets.
2. Failure to detect or use a healing potion at the configured health threshold
now raises `SafetyStop`, exits the hunt loop, and prints a safety-stop reason.
The edited script passed:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe -m py_compile tools\melee_hunt.py
```
These changes were not runtime-verified after editing because testing was
stopped. The narrower scan may ignore monsters near the bottom of the viewport;
that tradeoff must be checked visually.
## Next Test Checklist
1. Confirm no old hunt process is running.
2. Refill the belt with healing potions and fully heal at Akara.
3. Save and Exit, re-enter, and recover any corpse if one appears.
4. Spend or dismiss the new stat/skill notifications if practical.
5. Walk across the bridge and several screens into the Blood Moor before launch.
6. Start with a short five-minute test, not a 24-hour run:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 5
```
7. Capture and inspect screenshots at launch, two minutes, and five minutes.
8. At each checkpoint verify: outside town, no modal open, attacking a hostile,
XP increasing, life stable, movement occurring, and no repeated target lock.
9. Force a controlled low-health/no-potion condition only after normal combat is
proven, and verify `SAFETY STOP` exits immediately.
10. Use `Ctrl+C` if any modal opens, XP remains unchanged across two checkpoints,
life drops rapidly, or the character approaches the town bridge NPC.
## Follow-up Engineering Work
- Add a stall detector based on unchanged XP plus repeated target coordinates.
- Temporarily blacklist a target position after excessive swings without XP.
- Reject invalid XP samples instead of converting them into `0/0` level-ups.
- Investigate why `belt.drink_potion("health", ...)` missed visible red potions.
- Detect death and modal overlays explicitly and stop before sending more input.
- Consider an explicit `--continuous` option instead of using a large minute
count.

Some files were not shown because too many files have changed in this diff Show More