run_pindle had a 100% failure rate, and worse, was reporting success while doing nothing. Five root causes, found by recording the client and replaying the pather's own matching against the captured failure frames. 1. a5_red_portal.png was the only MASKED template in a5_town/ (4-chan, 60.9% opaque) because a hover tooltip had been baked into the capture and hidden with alpha. That routed it to cv2.matchTemplate(TM_CCOEFF_NORMED, mask=...), which OpenCV only supports for TM_SQDIFF/TM_CCORR_NORMED — hence 0.50-0.60 scores and match positions that wandered onto unrelated scenery. Recaptured as a plain 3-channel opaque crop of the portal's upper arch (the lower ring is occluded by branches). Present 0.949-1.000 / absent 0.398-0.514, straddling the 0.68 threshold. 2. pindle.approach() opened with an "already in Pindle area?" shortcut. Harrogath scenery scores 0.76-0.79 on PINDLE_7, over its 0.62 bar, so it fired in town and returned A5_PINDLE_START without ever clicking the portal — the bot "killed Pindle" in town for five straight games with zero loot and zero XP while logging runs_failed_total: 0. Shortcut removed; entry is proven by the loading screen. 3. pather.find_abs_node_pos fell back to a 0.55 first-match search that fabricated node positions (A5_TOWN_1 at 0.60-0.62 on scenery, three different frames, three different phantom positions), steering the char into the town wall. Raised to 0.62, forced best_match, and added a per-node heading gate that rejects a low-confidence match implying a >90 deg reversal. Confident matches are never gated. 4. Walking onto the waypoint opened the WP panel, which health_manager counted toward a chicken — 3 of 6 games died at full health. WP panels are now escaped without counting, bounded at 6 attempts. 5. pindle retry re-pathed from a hardcoded A5_TOWN_START; it now verifies the act first. Verified live: +349,890 XP over baseline, loot drops (Ring, gold), 6 games, runs_failed_total 0. Docs updated with all four bugs, template asset conventions, and how to verify a boss run actually killed something (XP delta + loot, never failed:false). Co-Authored-By: Claude Opus 5 <[email protected]>
346 lines
14 KiB
Markdown
346 lines
14 KiB
Markdown
# Botty Architecture
|
||
|
||
## Overview
|
||
|
||
Botty is an autonomous bot for Diablo II Resurrected (D2R) that uses computer vision and native Windows API input to run endgame content without kernel drivers. It's built in Python 3.10 and distributed as a PyInstaller onefile executable.
|
||
|
||
## Entry Points
|
||
|
||
Two standalone executables are built from the same source:
|
||
|
||
| Executable | Source | Purpose |
|
||
|---|---|---|
|
||
| `main.exe` | `src/main.py` | Main bot controller |
|
||
| `shop.exe` | `src/shopper.py` | Standalone vendor shopping |
|
||
|
||
## Threading Model
|
||
|
||
main.py spawns three concurrent threads:
|
||
|
||
```
|
||
main.py (main thread)
|
||
├── game_controller.py (bot loop thread)
|
||
│ └── bot.py (state machine thread)
|
||
├── health_manager.py (background thread)
|
||
└── death_manager.py (background thread)
|
||
```
|
||
|
||
- **Main thread**: Shows UI, registers hotkeys (F7-F12), calls `keyboard.wait()` to block
|
||
- **Game Controller**: Orchestrates the bot loop - starts/stops the bot thread, handles game recovery, tracks stats
|
||
- **Health Manager**: Polls health/mana/merc every ~1s, auto-potions, chickens when low
|
||
- **Death Manager**: Polls every ~1s for "You Have Died" screen, triggers recovery
|
||
|
||
## Core Modules
|
||
|
||
### 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.
|
||
|
||
| 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. |
|
||
|
||
All key presses include stealth micro-pauses and variable press duration automatically.
|
||
|
||
### Screen Capture (`src/screen.py`)
|
||
|
||
Handles D2R window detection and screenshot capture via MSS library. Converts between coordinate systems: Monitor (top-left first monitor), Screen (per-monitor), Absolute (character center), Relative (template-matched).
|
||
|
||
### Image Recognition (`src/template_finder.py`)
|
||
|
||
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 |
|
||
|---|---|
|
||
| `main_menu.py` | Main menu buttons (play, save & exit) |
|
||
| `character_select.py` | Character portraits and selection |
|
||
| `skills.py` | Skill bar state, skill availability |
|
||
| `meters.py` | Health/mana globes, potion charges |
|
||
| `player_bar.py` | Player status bar, XP bar |
|
||
| `view.py` | Minimap, inventory screen detection |
|
||
| `waypoint.py` | Waypoint portal selection |
|
||
| `error_screens.py` | Error dialogs, death screen |
|
||
| `loading.py` | Loading screen detection |
|
||
|
||
### Pathing (`src/pather.py`)
|
||
|
||
Pathfinding via reference template matching with relative coordinates. Each location has numbered nodes with template references. The `Location` class defines named destinations (towns, dungeons). Path definitions map (start, end) pairs to node sequences.
|
||
|
||
### Character System (`src/char/`)
|
||
|
||
Inheritance hierarchy:
|
||
|
||
```
|
||
IChar (abstract base)
|
||
├── Basic / Basic_Ranged
|
||
├── Paladin
|
||
│ ├── Hammerdin
|
||
│ └── FoHdin
|
||
├── Sorceress (base for all sorc builds)
|
||
│ ├── BlizzSorc
|
||
│ ├── BlizzorbSorc
|
||
│ ├── NovaSorc
|
||
│ ├── LightSorc
|
||
│ └── HydraSorc
|
||
├── Amazon
|
||
│ └── Javazon
|
||
├── Trapsin
|
||
├── Barbarian
|
||
├── Necro
|
||
├── Poison_Necro
|
||
├── Bone_Necro
|
||
└── Warlock
|
||
├── FireLock
|
||
├── EchoLock
|
||
└── AbyssLock
|
||
```
|
||
|
||
Each character class implements: run methods, skill usage, attack patterns, position-specific behavior.
|
||
|
||
### Run System (`src/run/`)
|
||
|
||
| File | Run |
|
||
|---|---|
|
||
| `pindle.py` | Pindle of Purity |
|
||
| `shenk_eld.py` | Shenk + Eldritch |
|
||
| `trav.py` | Travincal |
|
||
| `nihlathak.py` | Nihlathak's Temple |
|
||
| `arcane.py` | Arcane Sanctuary |
|
||
| `diablo.py` | Chaos Sanctuary + Diablo |
|
||
| `vizier.py` | Vizier (Seal Boss) |
|
||
| `baal.py` | Baal |
|
||
| `mephisto.py` | Mephisto |
|
||
| `andariel.py` | Andariel |
|
||
| `countess.py` | Countess |
|
||
| `level.py` | General leveling runs |
|
||
|
||
### Town System (`src/town/`)
|
||
|
||
`TownManager` orchestrates act-specific town routines. Each act (A1-A5) has its own module with NPC interactions, waypoint usage, and stash handling.
|
||
|
||
### Inventory (`src/inventory/`)
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `belt.py` | Belt slot management, potion detection |
|
||
| `personal.py` | Personal inventory grid, item positions |
|
||
| `vendor.py` | Vendor trade window |
|
||
| `stash.py` | Personal stash, shared stash |
|
||
| `cube.py` | Horadric Cube |
|
||
| `common.py` | Shared inventory utilities |
|
||
|
||
### Item Recognition (`src/item/`)
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `pickit.py` | Pickup decisions, item filtering |
|
||
| `consumables.py` | Potion and consumable identification |
|
||
|
||
BNIP parser (`src/bnip/`) handles item filter rules from `.bnip` files.
|
||
|
||
### Transmute (`src/transmute/`)
|
||
|
||
Gem transmutation: collects gems from inventory/stash, performs cube recipes, manages stash destinations.
|
||
|
||
### Messages (`src/messages/`)
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `messenger.py` | Message dispatcher |
|
||
| `discord_embeds.py` | Discord webhook formatting |
|
||
| `generic_api.py` | Generic HTTP webhook support |
|
||
|
||
### Configuration (`src/config.py`)
|
||
|
||
Singleton that merges config files in priority order:
|
||
`custom.ini` > `params.ini` > `game.ini` > `shop.ini` > `transmute.ini`
|
||
|
||
Supports variable substitution via `[variables]` sections.
|
||
|
||
### Stealth System (`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.
|
||
|
||
### Utilities (`src/utils/`)
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `misc.py` | Window management, DPI awareness, timing utilities |
|
||
| `restart.py` | Game launch/kill, D2R always-on-top |
|
||
| `auto_settings.py` | D2R game settings adjustment |
|
||
| `key_detector.py` | Auto-detect skill bindings from `.key`/`.keyo` files |
|
||
| `graphic_debugger.py` | Visual debugging overlay |
|
||
| `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
|
||
|
||
```
|
||
D2R Game Window
|
||
│
|
||
▼
|
||
screen.grab() ──→ Image (numpy array)
|
||
│
|
||
▼
|
||
template_finder.search() ──→ Position + Validity
|
||
│
|
||
▼
|
||
ui_manager.detect_screen_object() ──→ Current UI state
|
||
│
|
||
▼
|
||
bot.py (state machine) ──→ Decides next action
|
||
│
|
||
▼
|
||
char.run() / pather.go_to() ──→ Movement + Interaction
|
||
│
|
||
▼
|
||
input_layer.keyboard.send() / mouse.click() ──→ SendInput API
|
||
│
|
||
▼
|
||
D2R Game Window (response captured on next grab)
|
||
```
|
||
|
||
## Build System
|
||
|
||
- **Build script**: `build.py` (PyInstaller with `--onefile --noconsole`)
|
||
- **Dependencies**: `pyproject.toml`
|
||
- **Distribution**: Random exe name, no console window, no kernel drivers
|
||
- **Assets**: `assets/` directory copied to build output
|
||
|
||
## Key Detection
|
||
|
||
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 |
|
||
|---|---|---|
|
||
| Monitor | Top-left of first monitor | `screen.grab()` |
|
||
| Screen | Per-monitor client area | UI detection |
|
||
| Absolute | Character at screen center | Pathing, target detection |
|
||
| 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.8–1.2))
|
||
```
|
||
|
||
That's approximately every 3 game frames (96–144ms 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.
|