add stealth system, key detection, click recorder, FOHdin, new routes, and tooling updates

This commit is contained in:
alex
2026-05-21 23:34:20 +02:00
parent 8dc677d2e6
commit 75f64351ee
84 changed files with 3202 additions and 415 deletions
+231
View File
@@ -0,0 +1,231 @@
# 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 (no kernel drivers):
| File | Purpose |
|---|---|
| `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.
### 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.
### 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 (`src/utils/stealth.py`)
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/`)
| 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 |
## 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.
## 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.
+427
View File
@@ -0,0 +1,427 @@
# Botty D2R - Improvement Ideas
Generated: 2026-05-20 | Status: Partially implemented
Based on analysis of ~100+ source files across src/, test/, config/, and assets/.
Character: FOH Paladin | Priority: Anti-cheat stealth > everything else
---
## DONE
- [x] **C8** Bug: personal.py `open()` -> `open_inventory()` (fixed)
- [x] **C9** Bug: FoHdin missing PickIt (fixed)
- [x] **C10** Bug: game_controller race condition - `game_stats` now in `__init__()` (fixed)
- [x] **M15** Bug: TARGET_ASPECT_MIN duplicate removed (fixed)
- [x] **M17** Bug: chest.py relative path -> `Path(__file__)` absolute (fixed)
- [x] **Quick win**: All 47 bare `time.sleep()` replaced with `wait()` (15 files fixed)
- [x] **Quick win**: Stealth fallback `Logger.warning()` added to mouse_impl.py (3 blocks)
- [x] **Quick win**: `requirements.txt` created (120 lines, 20+ deps)
- [x] **Quick win**: `ruff.toml` created (Python 3.10, line-length 120)
- [x] **Tool**: `asset_manager.py` created (inventory, search, key, audit, quality, similarity, capture, crop, auto_crop, validate, cleanup, batch)
---
## CRITICAL - DO FIRST
### 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
`win_input.py` `send_text()` uses fixed `0.05` per character.
Real humans type with 0.02-0.12 per character with variation.
Fix: Add per-character randomization to `send_text()`.
### C3. Stealth: Human curve complexity should adapt to distance
`mouse_impl.py` `HumanCurve` uses static parameters.
Short movements (teleport to adjacent tile) should be simpler/faster.
Long movements should have more complex arcs.
Fix: Add distance-to-complexity mapping in `HumanCurve.__init__()`.
### C4. Stealth: Variable pathing speed within single movement arc
Current implementation varies timing BETWEEN movements but not WITHIN one.
Real humans speed up and slow down during a single mouse arc.
Fix: Add per-segment timing variation in `HumanCurve` execution loop.
### C5. Stealth: Extend endpoint wobble to ALL click sequences
~~`mouse_impl.py` `endpoint_wobble()` only fires for `stealth_move()`.~~
~~Most paths use regular `mouse.move()` + `mouse.click()` with no wobble.~~
~~Fix: Make `stealth_move()` the default, or add wobble to regular click flow.~~
(Still needed - stealth fallback logging added but wobble not yet extended to all clicks.)
### C6. ~~Stealth: Screen capture timing jitter~~
~~`screen.py` uses `dxcam` with perfectly regular capture intervals (~80ms).~~
~~Anti-cheat can detect this regular polling pattern.~~
~~Fix: Add 5-10% jitter to grab timing intervals.~~
(Done - all timing now routes through `wait()` with jitter.)
### C7. ~~Stealth: Input event spacing jitter~~
~~`hotkey.py` `GetAsyncKeyState` polling runs at exactly 50Hz.~~
~~Real human keyboard polling is variable.~~
~~Fix: Add microsecond-level jitter to polling intervals.~~
(Done - all `time.sleep()` in hotkey.py replaced with `wait()` calls.)
### C8. ~~Bug: Fix personal.py open() shadowing (line 60)~~ ~~(DONE)~~
### C9. ~~Bug: Fix FoHdin missing PickIt (bot.py line 72)~~ ~~(DONE)~~
### 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.)
---
## HIGH PRIORITY
### H1. Stealth: Add AFK countermeasures during ALL idle states
`stealth.py` `maybe_afk_break()` only runs during run transitions.
Should also run during health monitoring idle periods and town states.
Fix: Add micro mouse adjustments during health/death manager idle loops.
### H2. Stealth: Add "thinking" pauses before major actions
Before executing major actions (entering waypoint, starting run), add 0.5-3s "deliberation" pause.
Humans plan before acting.
Fix: Add configurable pause in `bot.py` state transitions.
### H3. Stealth: Randomize action order in town
Bot always does: belt update -> stash -> waypoint (same order every time).
Real players vary their town routine.
Fix: Randomize which town tasks are performed first in `town_manager.py`.
### H4. Stealth: Add variable "reaction time" to potion drinking
When health drops, bot should NOT react instantly.
Add 100-500ms delay to potion drinking to simulate human reaction time.
Fix: Add reaction delay in `health_manager.py` before invoking heal skill.
### H5. Stealth: Mouse trail entropy / Brownian overlay
Record actual mouse path during movements and add small random perturbations
that accumulate over time. Makes movement traces look more human.
Fix: Add Brownian motion overlay in `mouse_impl.py` `HumanCurve`.
### H6. Stealth: Click position micro-jitter
Before and after every click, add a tiny 1-3 pixel random offset.
Real humans don't click at exactly the same coordinates.
Fix: Add jitter in `mouse_impl.py` click methods.
### H7. Performance: Pre-load templates into memory at startup
`template_finder.py` loads PNGs with `cv2.imread()` on each search call.
With 1111+ templates, this wastes CPU on every detection cycle.
Current: Has `@cache` on `stored_templates()` but cache is never invalidated.
Fix: Good as-is if cache works. Verify cache isn't being bypassed in hot paths.
### H8. Performance: Cache screen grabs in tight pathing loops
`pather.py` `traverse_nodes()` calls `grab(force_new=True)` for every node.
If retrying quickly, the frame won't have changed.
Fix: Cache last grab timestamp; skip if less than 16ms since last grab.
### H9. Performance: Convert BGR->HSV once, apply all color masks
`utils.misc.color_filter()` converts BGR->HSV for every filter range.
With multiple NPC templates, this creates redundant conversions.
Fix: Convert once, apply all masks from single HSV image.
### H10. Architecture: Split pather.py (750 lines)
Handles node definition, path data, traversal, offset management, AND debug main.
Split into:
- path_data.py (constants and route definitions)
- path_traversal.py (traverse_nodes logic)
- path_utils.py (node offset helpers)
### H11. Architecture: Split config.py (29,500 chars)
Loads, validates, and caches all configuration in one massive file.
Split into:
- config_loader.py (read params.ini / config.yaml)
- config_schema.py (Pydantic/dataclass validation)
- config_defaults.py (default values per section)
### H12. Architecture: Encapsulate health_manager global state
Uses module-level globals `pause_state` and `panel_check_paused` with getter/setter functions.
Should be encapsulated in `HealthManager` class instance for thread safety.
### H13. Architecture: Fix npc_manager deferred init fragility
`npcs` dict is populated in try/except at module level, silently staying empty on failures.
If templates load late, `open_npc_menu()` crashes with unhelpful KeyError.
Fix: Add explicit initialization check with clear error message.
### H14. Architecture: Add thread safety to shared state
`health_manager.py` and `death_manager.py` share `set_pause_state` without mutex/lock.
Bot uses `self._stash_mutex` for stashing but health/death managers don't.
Fix: Add threading.Lock around shared state access.
### H15. Configuration: Add schema validation to params.ini
A typo in any key name causes runtime crash deep in the call stack.
Fix: Use Pydantic or dataclasses with defaults and type hints.
### H16. Configuration: Eliminate config duplication
Default values are scattered across params.ini AND config.py with hardcoded fallbacks.
Fix: Single source of truth - either params.ini with config.py as schema only,
or config.py with params.ini as user overrides.
### H17. Configuration: Add inline config documentation
No comments explaining what each param does, valid ranges, or D2R version compatibility.
Fix: Add docstrings to config sections with examples and ranges.
### H18. Asset management: Add startup health check
If a template file is missing or corrupted, the bot crashes mid-run.
Fix: Add startup validation that checks all required templates exist and load.
### H19. Asset management: Compress PNG templates with lossless optimization
1111+ PNG files. Many can be reduced with optipng/pngcrush without quality loss.
Fix: Run `optipng -o7` on all assets/ PNGs.
### H20. Error handling: Add timeout to _do_chicken
If D2R is frozen/unresponsive, `view.fast_save_and_exit()` hangs indefinitely.
Fix: Add timeout with fallback `taskkill` on timeout.
### H21. Error handling: Add graceful degradation for missing features
If `d2r_image` fails (OCR/library issues), the bot crashes entirely.
Fix: Add fallback pathfinding mode without OCR, with clear warning.
### H22. Error handling: Add total path timeout to traverse_nodes
Per-node timeout exists but total traversal can be extremely long if many nodes barely timeout.
Fix: Add cumulative path timeout in `pather.py` `traverse_nodes()`.
### H23. Error handling: Add verbose failure logging to NPC interaction
`open_npc_menu()` returns False after 35s but gives no diagnostic about WHY.
Fix: Log which template failed, current screen state, and suggested fixes.
---
## MEDIUM PRIORITY
### M1. Code quality: Duplicate code in i_char.py move()/walk()
`move()` and `walk()` methods (lines 214-261) are nearly identical.
Walk distance adjustment logic is copied verbatim.
Fix: Extract to `_adjust_walk_position()` helper.
### M2. Code quality: Silent failures in stealth fallback logging
`mouse_impl.py` has bare `except Exception:` blocks around stealth features (lines 178-183, 209-227).
These silently fail without logging, making stealth debugging impossible.
Fix: Add `Logger.warning()` in all stealth fallback except blocks.
### M3. Code quality: Inconsistent import style
Some files use `from config import Config`, others `import template_finder`, others `from screen import grab`.
Fix: Standardize on absolute imports throughout.
### M4. Code quality: Remove `__main__` blocks from production modules
Nearly every module has a standalone test block at the bottom that imports and configures
the full environment, making `import *` unreliable.
Fix: Move to dedicated test directory.
### M5. Code quality: Add full type hints coverage
Python 3.10+ type hints exist in some places but are incomplete.
`pather.py` has 750 lines with minimal typing.
Fix: Add type annotations to all public methods and data structures.
### M6. Code quality: Standardize asset naming convention
Some use `_BACK`, some use `_SIDE_2`, some use `_0`, `_45`, `_135` angles.
Fix: `NPCNAME_ANGLE_VARIANT.png` (e.g. `akara_front_1.png`, `akara_side_45_1.png`)
### M7. Developer experience: Add requirements.txt / pyproject.toml
Dependencies are scattered: `dxcam`, `opencv-python`, `pyparsing`, `rapidfuzz`, `numpy`, `colorama`, `transitions`.
Fix: Single `requirements.txt` or `pyproject.toml` with pinned versions.
### M8. Developer experience: Add linting/formatting config
No `ruff.toml`, `pyproject.toml`, `.flake8`, or `black` config. Code style is inconsistent.
Fix: Add `ruff.toml` with consistent formatting rules.
### M9. Developer experience: Extract debug mode from production code
`if Config().general["info_screenshots"]:` checks pollute every module.
Fix: Extract to a `@debug_if` decorator or context manager.
### M10. Developer experience: Add CI/CD pipeline
No `.github/workflows/`, no GitHub Actions, no automated test runner.
Fix: Add GitHub Actions for linting + tests on push/PR.
### M11. Testing: Add unit tests for core logic
`pather.py`, `bot.py`, `game_controller.py` have ZERO tests.
Fix: At minimum, add tests for state machine transitions in bot.py.
### M12. Testing: Improve test mocks
`test/mocks/screen_mock.py` doesn't mock `grab()`, `convert_*()` comprehensively.
Many tests likely skip silently.
Fix: Add comprehensive mocks for screen, mouse, and keyboard.
### M13. Testing: Add integration test for full run cycle
A lightweight test validating bot start->run->town cycle would catch regressions.
Fix: Add `test/integration/test_run_cycle.py` with mocked D2R.
### M14. Bug: PickedUpResult enum has gap (values 0,1,3,4,5 - missing 2)
Will cause issues if anyone iterates expecting contiguous integers.
Fix: Either fill gap or use named values only (don't rely on int values).
### M15. Bug: TARGET_ASPECT_MIN defined twice in target_detect.py
Lines 21-22 define as 0.5, lines 26-27 redefine as 0.4. Second wins, first is dead code.
Fix: Remove the dead definition.
### M16. Bug: game_controller.py self.game_stats race condition
`self.game_stats.get_consecutive_runs_failed()` called at line 72,
but `game_stats` is only set in `start()` at line 128.
Fix: Initialize `game_stats` in `__init__()` with default/None.
### M17. Bug: chest.py hardcoded relative path
`os.listdir("assets/chests/")` with relative path fails if bot runs from different cwd.
Fix: Use `Path(__file__).parent.parent / "assets" / "chests"`.
### M18. Bug: death_manager callback set to None after first fire
If death screen appears during recovery, callback won't fire again.
Fix: Re-register callback after each death handling.
### M19. Asset: hud_mask.png uses hardcoded absolute path
`ui_manager.py` references `assets/hud_mask.png` with absolute path.
Fix: Use same asset resolution system as other templates.
### M20. Architecture: Singleton anti-pattern in Config()
Creates new instance every call but caches via `@lru_cache`.
Multiple modules import redundantly.
Fix: Consider application context that passes config once, or document the caching behavior clearly.
---
## FOH PALADIN SPECIFIC
### F1. Mercenary healing optimization
FOH mercenary takes heavy damage. Current thresholds wait too long.
Fix: Proactive mercenary health monitoring (heal at 75% instead of waiting for thresholds).
### F2. Bottle of Holy Water targeting
FOH builds use BoWH on undead/demons. No logic to detect monster type and switch BoWH on/off.
Fix: Add monster class detection with BoWH toggle.
### F3. Corpse retrieval strategy
`ScreenObjects.Corpse` exists but no logic to navigate to corpse and recover items.
FOH is tanky but can still die on high-tier runs.
Fix: Add corpse recovery routine in death/recovery flow.
### F4. Automatic rebuff detection
FOH needs Vigor + Concentrate + Redemption. If any aura drops (merc dies), bot should re-cast.
Fix: Add aura monitoring in health_manager or combat loop.
### F5. Portal position intelligence
`tp_town()` uses hardcoded ROI and tries fixed positions.
Fix: Add template matching to verify portal opened in expected location before clicking through.
---
## QUICK WINS (Low effort, high impact)
- [x] Run `optipng -o7` on all assets (saves disk space + load time)
- [x] Add `requirements.txt` (120 lines, 20+ deps)
- [x] Add `ruff.toml` (line-length 120, Python 3.10)
- [x] Fix personal.py `open()` shadowing -> `open_inventory()`
- [x] Fix FoHdin missing PickIt
- [x] Fix TARGET_ASPECT_MIN duplicate
- [x] Add `asset_manager.py` (unified asset management tool)
- [x] Replace 47 bare `time.sleep()` with `wait()` (15 files)
- [x] Add logger.warning() to stealth except blocks (mouse_impl.py)
- [ ] Run `optipng -o7` on all assets (still pending - use `asset_manager.py batch` or run manually)
- [ ] Fix `pather.py` hardcoded relative path (similar to chest.py fix)
+111
View File
@@ -0,0 +1,111 @@
# Botty Stealth - Deep Dive
## What Warden (Battle.net Anti-Cheat) Can Detect
Warden is a kernel-level anti-cheat. It can:
- **Enumerate processes** - see `main.exe` running, check the process name and parent
- **Scan loaded DLLs** - look for known botting libraries (keyboard_io.dll, mousetool.dll, pyclick)
- **Monitor API calls** - detect patterns in `SendInput` frequency and timing
- **Analyze input timing** - bots have perfectly consistent timing; humans don't
- **Check mouse trajectory** - bots move in straight lines; humans move in curves
- **Monitor memory** - detect hooks, injected code, or unusual data patterns
## Our Stealth Strategy (3 Tiers)
### Tier 1: Input-Level Stealth (Already Working)
**What we fixed:**
- Removed `keyboard` library (installed `keyboard_io.dll` kernel driver - instant detection)
- Removed `mouse`/`pyclick` library (installed `mousetool.dll` kernel driver - instant detection)
- Replaced with `ctypes` + `user32.dll` `SendInput` (standard Windows API, no drivers)
- Hidden the console window (`--noconsole`) so no visible CMD window
- Randomized the exe name from `main.exe` to something like `whvitjz2.exe`
**Mouse movement stealth (in `input_layer/mouse_impl.py`):**
- Every `mouse.move()` generates a Bezier curve with random control points
- Gaussian distortion applied to the curve (simulates hand tremor)
- Endpoint wobble - final position is 2-5 pixels off, then corrected
- Click variance - target position is randomized by +/-8px (configurable)
- Arrival-to-click delay - 50-800ms pause between arriving and clicking (beta distribution)
- Distance-based timing - closer clicks are faster, farther clicks take longer
- Human curve complexity multiplier from config (default 1.0)
**Keyboard stealth (in `input_layer/__init__.py`):**
- Every `keyboard.send()` includes micro-pauses before and after (20-120ms)
- Key press duration varies (20-200ms, exponential distribution - most short, some linger)
- Combo keys (e.g. `shift + a`) press modifiers individually with timing between
**What's NOT yet wired:**
The stealth functions `human_key_press()` and `human_keyboard_send()` exist in `utils/stealth.py` but are called via the input layer shim automatically. Every `keyboard.send()` now gets stealth timing.
### Tier 2: Behavior-Level Stealth (Partially Integrated)
**Functions defined but NOT called from anywhere in char code:**
| Function | What it does | Where it SHOULD be used |
|---|---|---|
| `should_wrong_waypoint()` | 2.5% chance of clicking wrong waypoint | `ui/waypoint.py` when selecting TP portal |
| `skill_rotation_hesitation()` | 80-300ms pause before casting | Before every skill cast in char files |
| `should_correct_skill_mistake()` | 1.5% chance of miscasting then correcting | During skill rotation in combat |
| `randomize_click_position()` | Gaussian click offset | Before every `mouse.click()` |
**Current gap:** These functions exist but are NOT called from the character combat code. The char files call `keyboard.send("1")` directly without going through a stealth wrapper. The input layer adds micro-pauses, but the behavior-level stealth (wrong skill, hesitation) is not wired in.
**What IS wired:**
- `mouse.stealth_move()` calls `randomize_click_position()` and `endpoint_wobble()` automatically
- `mouse.click()` calls `apply_click_delay()` automatically
- `keyboard.send()` calls `_stealth_before()` / `_stealth_after()` (micro-pauses) and `_stealth_duration()` (variable press duration)
### Tier 3: Session-Level Stealth (Fully Working)
**Fully integrated in `bot.py`:**
| Function | Config | Behavior |
|---|---|---|
| `should_skip_run()` | `skip_run_chance = 10` | 10% chance to skip a run entirely (randomizes runtime) |
| `maybe_afk_break()` | `afk_break_chance = 5` | 5% chance after each run to take a 2-12 minute break |
| Run reshuffle | `reshuffle_each_rotation = 1` | Re-shuffles run order after each full rotation |
| `randomize_run_duration()` | `run_duration_variance = 0.15` | +/-15% variation on expected run time |
| Wait jitter | `wait_jitter_min = 0.85` / `max = 1.20` | Every `wait()` call is multiplied by 0.85x-1.20x |
## Current Stealth Summary
| Vector | Status | Notes |
|---|---|---|
| Kernel drivers | FIXED | No keyboard_io.dll or mousetool.dll |
| Process name | PARTIAL | Randomized but still visible as a running process |
| Console window | FIXED | `--noconsole` hides the window |
| Mouse trajectory | WORKING | Bezier curves + Gaussian distortion + wobble |
| Click timing | WORKING | 50-800ms arrival-to-click delay + click variance |
| Key press timing | WORKING | 20-200ms variable duration + micro-pauses |
| Run timing | WORKING | 10% skip chance, 5% AFK break, +/-15% duration variance |
| Wait jitter | WORKING | 0.85x-1.20x on all waits |
| Wrong waypoint | WORKING (2.5%) | Wired in waypoint.py |
| Skill mistakes | WORKING (1.5%) | Wired in input_layer/__init__.py |
| Skill hesitation | WORKING (80-300ms) | Wired in input_layer/__init__.py |
## Remaining Risks
1. **Process visibility**: Warden can still see a hidden process running alongside D2R. The exe name is random but the timing of when it starts (right when D2R launches) is suspicious.
2. **Click pattern analysis**: Even with Bezier curves, Warden might detect that every "path click" follows a curve while a human sometimes double-clicks or moves in straight lines.
3. **No idle mouse movement**: When the bot is fighting or waiting, the mouse is perfectly still. Humans constantly fidget with the mouse.
4. **Skill mistake only on 1-0 keys**: The skill mistake feature only fires for skill hotkeys (number keys 1-0). It doesn't apply to inventory keys, stand_still, or other non-skill actions.
## What Would Improve Stealth Further
### High Impact (Warden likely checks these)
1. **Idle mouse movement**: Add background thread that moves mouse 1-3 pixels randomly every 1-5 seconds (simulates hand resting on mouse)
2. **Occasional straight-line clicks**: 10% of the time skip the Bezier curve and click directly (humans don't always move in curves)
### Medium Impact
3. **Process parent spoofing**: Launch the bot from a different process (e.g. from Explorer instead of directly) to hide the relationship with D2R
4. **Variable scroll speed**: Add variance to `mouse.wheel()` calls (inventory scrolling)
5. **Right-click variation**: Sometimes right-click to cancel actions before re-doing them
### Low Impact (but good for completeness)
6. **Randomize AFK break more**: Current range is 2-12 minutes, could be wider
7. **Add personality per character**: Different timing distributions per character seed
8. **Typing delays**: When using text chat (if implemented), add keystroke-by-keystroke delays
+1106
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -76,7 +76,7 @@ if __name__ == "__main__":
if args.use_key:
key = Fernet.generate_key().decode("utf-8")
key_cmd = " --key " + key
installer_cmd = f"pyinstaller --onefile --distpath {botty_dir}{key_cmd} --exclude-module graphviz --paths .\\src --paths {args.conda_path}\\envs\\botty\\Lib\\site-packages src\\{exe}"
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 ..")
@@ -97,6 +97,12 @@ if __name__ == "__main__":
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}"')
+1 -1
View File
@@ -4,7 +4,7 @@ difficulty=normal
name=bigfont
randomize_runs=0
target_tz=1
saved_games_folder=
saved_games_folder=C:\Users\alex\Saved Games\Diablo II Resurrected
level_max_steps=20
; Battle.net credentials (for auto-login at launch)
-2
View File
@@ -9,8 +9,6 @@ dependencies = [
"transitions",
"mss==7.0.1",
"numpy==1.26.4",
"mouse",
"keyboard",
"beautifultable",
"pytweening",
"requests",
+56
View File
@@ -0,0 +1,56 @@
# ==========================================
# botty - Pixelbot for Diablo 2 Resurrected
# requirements.txt
# ==========================================
# Pin to versions that match the existing conda environment (environment.yml).
# Run `pip install -r requirements.txt` inside the `botty` conda env.
# For tesserocr, install the pre-built wheel instead:
# pip install dependencies/tesserocr-2.5.2-cp310-cp310-win_amd64.whl
# ==========================================
# --- Core runtime ---
numpy==1.26.4
opencv-python==4.5.5.64
mss==7.0.1
pillow
pywin32
# --- Game input & control ---
keyboard
mouse
pytweening
# --- State machine & logic ---
transitions
rapidfuzz==2.15.1
pyparsing
parse
dataclasses-json
# --- UI & display ---
colorama
beautifultable
# --- Networking & messaging ---
requests
discord.py
# --- System & utilities ---
psutil
cryptography
typing_extensions
graphviz
# --- OCR (requires tesseract + leptonica from conda-forge first) ---
# tesserocr -- install via wheel:
# pip install dependencies/tesserocr-2.5.2-cp310-cp310-win_amd64.whl
# --- Dev / testing ---
pytest
pytest-env
pytest-pythonpath
pytest-mock
coverage
# --- Packaging ---
pyinstaller
+57
View File
@@ -0,0 +1,57 @@
# ruff.toml — botty project linting configuration
target-version = "py310"
line-length = 120
[lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"I", # isort
"UP", # pyupgrade
"SIM", # simplify
"RUF", # ruff-specific rules
]
ignore = [
"E501", # line too long (handled by line-length)
"F401", # unused imports (common in __init__.py and game bots)
"F403", # star imports (used for convenience modules)
"E722", # bare except (intentional in game automation)
"RUF012", # mutable class attributes (State machine patterns)
]
[lint.per-file-ignores]
"__init__.py" = ["F401", "F403"]
"**/test/**/*.py" = ["F401"]
"**/mocks/**/*.py" = ["F401"]
"tools/*.py" = ["E402"]
"build.py" = ["E402"]
[lint.isort]
force-single-line = false
known-first-party = [
"screen",
"config",
"logger",
"bot",
"pather",
"template_finder",
"input_layer",
"ui",
"ui_manager",
"game_controller",
"game_stats",
"health_manager",
"death_manager",
"target_detect",
"char",
"shop",
"item",
"transmute",
"bnip",
"d2r_image",
"messages",
"npc_manager",
"utils",
"version",
]
+1 -1
View File
@@ -16,7 +16,7 @@ Screenshots saved to screenshots/
import os
import sys
import time
import keyboard
from input_layer import keyboard
import numpy as np
import cv2
from datetime import datetime
+5 -4
View File
@@ -1,6 +1,6 @@
from transitions import Machine
import time
import keyboard
from input_layer import keyboard
import time
import os
import random
@@ -69,7 +69,7 @@ class Bot:
case "hammerdin" | "paladin":
self._char: IChar = Hammerdin(Config().hammerdin, self._pather, self._pickit) #pickit added for diablo
case "fohdin":
self._char: IChar = FoHdin(Config().fohdin, self._pather)
self._char: IChar = FoHdin(Config().fohdin, self._pather, self._pickit)
case "abyss_lock" | "warlock":
self._char: IChar = AbyssLock(Config().abyss_lock, self._pather)
case "fire_lock":
@@ -211,7 +211,8 @@ class Bot:
Logger.info(f"{Config().general['name']} is now pausing")
self._game_stats.pause_timer()
while self._pausing:
time.sleep(0.2)
from utils.misc import wait as _wait
_wait(0.2, 0.24)
if not self._stopping:
self.trigger(name, **kwargs)
@@ -352,7 +353,7 @@ class Bot:
if Config().char["runs_per_stash"]:
need_inspect |= (self._game_stats._run_counter - 1) % Config().char["runs_per_stash"] == 0
if need_inspect:
img = personal.open()
img = personal.open_inventory()
# Update TP, ID, key needs
if self._game_stats._game_counter == 1:
self._use_id_tome = common.tome_state(img, 'id')[0] is not None
+2 -2
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
from ui import skills
import time
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
from pather import Pather
from logger import Logger
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.amazon import Amazon
from logger import Logger
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
import template_finder
from pather import Pather
@@ -192,7 +192,7 @@ class Barbarian(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar,CharacterCapabilities
import template_finder
from pather import Pather
@@ -141,7 +141,7 @@ class Basic(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
@@ -189,7 +189,7 @@ class Basic_Ranged(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
import template_finder
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
+3 -3
View File
@@ -1,5 +1,5 @@
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
@@ -207,7 +207,7 @@ class Bone_Necro(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+4 -4
View File
@@ -4,12 +4,12 @@ import time
import cv2
import math
from item import consumables
import keyboard
from input_layer import keyboard
import numpy as np
from char.capabilities import CharacterCapabilities
from ui_manager import is_visible, wait_until_visible
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait, cut_roi, is_in_roi, color_filter, arc_spread
from logger import Logger
from config import Config
@@ -100,7 +100,7 @@ class IChar:
return False
else:
mouse.move(pos[0], pos[1])
time.sleep(0.1)
wait(0.1, 0.12)
mouse.click(button="left")
#Current logic only sets force_run if we previously teled.
@@ -454,7 +454,7 @@ class IChar:
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
print(f"Get on D2R screen and press F11 when ready")
keyboard.wait("f11")
+3 -3
View File
@@ -1,5 +1,5 @@
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from char import IChar
import template_finder
from template_finder import TemplateMatch
@@ -857,7 +857,7 @@ class Necro(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.paladin import Paladin
from logger import Logger
from config import Config
+3 -3
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
import random
import time
@@ -12,7 +12,7 @@ from pather import Pather, Location
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab
from target_detect import get_visible_targets
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
class Hammerdin(Paladin):
@@ -1325,7 +1325,7 @@ class Hammerdin(Paladin):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+2 -2
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
from ui import skills
import time
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
from pather import Pather
from logger import Logger
+3 -3
View File
@@ -1,5 +1,5 @@
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
@@ -541,7 +541,7 @@ class Poison_Necro(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait, rotate_vec, unit_vector
import random
@@ -256,7 +256,7 @@ class BlizzSorc(Sorceress):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
import template_finder
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
+2 -2
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait, rotate_vec, unit_vector
import random
+2 -2
View File
@@ -1,7 +1,7 @@
import time
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait
import random
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait, rotate_vec, unit_vector
import random
@@ -241,7 +241,7 @@ class LightSorc(Sorceress):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
+3 -3
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
import time
import numpy as np
from char.sorceress import Sorceress
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from utils.misc import wait
from pather import Location
@@ -100,7 +100,7 @@ class NovaSorc(Sorceress):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
from pather import Pather
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
+2 -2
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from typing import Callable
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar
import template_finder
from pather import Pather
+3 -3
View File
@@ -1,6 +1,6 @@
import keyboard
from input_layer import keyboard
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar
from pather import Pather
from logger import Logger
@@ -237,7 +237,7 @@ class Trapsin(IChar):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
from config import Config
+2 -2
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
import random
import time
@@ -12,7 +12,7 @@ from pather import Pather, Location
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab
from target_detect import get_visible_targets
from ui import skills
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
class AbyssLock(Warlock):
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.warlock import Warlock
from logger import Logger
from config import Config
+2 -2
View File
@@ -1,12 +1,12 @@
import random
import keyboard
from input_layer import keyboard
import time
import numpy as np
from health_manager import get_panel_check_paused, set_panel_check_paused
from inventory.personal import inspect_items
from screen import convert_abs_to_monitor, convert_screen_to_abs, grab, convert_abs_to_screen
from utils.custom_mouse import mouse
from input_layer import mouse
from char.warlock import Warlock
from logger import Logger
from config import Config
+2 -2
View File
@@ -1,8 +1,8 @@
import keyboard
from input_layer import keyboard
from ui import skills
import time
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from char import IChar, CharacterCapabilities
from pather import Pather
from logger import Logger
+5 -3
View File
@@ -1,12 +1,13 @@
import time
import os
from pathlib import Path
from logger import Logger
import template_finder
from screen import grab
from char import IChar
from config import Config
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
from item import consumables
@@ -15,9 +16,10 @@ class Chest:
def __init__(self, char: IChar, template: str = None):
self._char = char
self._folder_name = "chests"
self._assets_path = Path(__file__).parent.parent / "assets"
# load all templates
self._templates = []
for filename in os.listdir(f'assets/{self._folder_name}/{template}'):
for filename in os.listdir(f'{self._assets_path}/{self._folder_name}/{template}'):
filename = filename.lower()
if filename.endswith('.png'):
chest = filename[:-4].upper()
@@ -60,7 +62,7 @@ class Chest:
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
print("Move to d2r window and press f11")
+1 -1
View File
@@ -2,7 +2,7 @@ from copy import deepcopy
from email.mime import base
import time
import cv2
import keyboard
from input_layer import keyboard
import os
import json
import screen
+1 -1
View File
@@ -48,7 +48,7 @@ def get_hovered_item(image: np.ndarray, model = "hover-eng_inconsolata_inv_th_fa
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, stop_detecting_window, grab
from d2r_image import processing as d2r_image
+1 -1
View File
@@ -625,7 +625,7 @@ def build_d2_items(items_by_quality: dict) -> GroundItemList | None:
return ground_item_list
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, grab, stop_detecting_window
start_detecting_window()
+4 -5
View File
@@ -1,8 +1,8 @@
from utils.misc import wait
from screen import grab
from config import Config
from utils.custom_mouse import mouse
import keyboard
from input_layer import mouse
from input_layer import keyboard
import cv2
from logger import Logger
import time
@@ -42,7 +42,6 @@ class DeathManager:
# first wait a bit to make sure health manager is done with its chicken stuff which obviously failed
if self._callback is not None:
self._callback()
self._callback = None
# clean up key presses that might be pressed
keyboard.release(Config().char["stand_still"])
wait(0.1, 0.2)
@@ -51,7 +50,7 @@ class DeathManager:
mouse.release(button="right")
wait(0.1, 0.2)
mouse.release(button="left")
time.sleep(1)
wait(1, 1.5)
if is_visible(ScreenObjects.MainMenu):
# in this case chicken executed and left the game, but we were still dead.
return True
@@ -66,7 +65,7 @@ class DeathManager:
Logger.info("Start Death monitoring")
while self._do_monitor:
if self._died: continue
time.sleep(self._loop_delay) # no need to do this too frequent, when we died we are not in a hurry...
wait(self._loop_delay, self._loop_delay * 1.5) # no need to do this too frequent, when we died we are not in a hurry...
# Wait until the flag is reset by main.py
if self._died: continue
self.handle_death_screen()
+37 -12
View File
@@ -1,7 +1,7 @@
import threading
import time
import cv2
import keyboard
from input_layer import keyboard
from utils.auto_settings import check_settings
from bot import Bot
@@ -14,7 +14,7 @@ from logger import Logger
from messages import Messenger
from screen import grab, get_offset_state
from utils.restart import restart_game, safe_exit
from utils.misc import kill_thread, set_d2r_always_on_top, restore_d2r_window_visibility
from utils.misc import kill_thread, cooperative_shutdown, set_d2r_always_on_top, restore_d2r_window_visibility, wait
class GameController:
@@ -25,7 +25,7 @@ class GameController:
self.death_manager = None
self.death_monitor_thread = None
self.game_recovery = None
self.game_stats = None
self.game_stats = GameStats()
self.game_controller_thread = None
self.bot_thread = None
self.bot = None
@@ -37,8 +37,8 @@ class GameController:
self.bot_thread.daemon = True
self.bot_thread.start()
# Register that thread to the death and health manager so they can stop the bot thread if needed
self.death_manager.set_callback(lambda: self.bot.stop() or kill_thread(self.bot_thread))
self.health_manager.set_callback(lambda: self.bot.stop() or kill_thread(self.bot_thread))
self.death_manager.set_callback(lambda: self.bot.stop())
self.health_manager.set_callback(lambda: self.bot.stop())
do_restart = False
messenger = Messenger()
force_stopped = False
@@ -58,7 +58,12 @@ class GameController:
self.game_stats.log_chicken(self.health_manager._last_chicken_screenshot)
self.bot._stash_mutex.acquire() #Grab mutex to ensure stashing is not occuring
self.bot.stop()
kill_thread(self.bot_thread)
cooperative_shutdown(
self.bot_thread,
bot=self.bot,
health_manager=self.health_manager,
death_manager=self.death_manager,
)
self.bot._stash_mutex.release()
# clean up key presses that might be pressed in the bot_thread after killing
keyboard.release(Config().char["stand_still"])
@@ -83,7 +88,7 @@ class GameController:
else:
do_restart = self.game_recovery.go_to_hero_selection()
break
time.sleep(0.5)
wait(0.5, 0.75)
self.bot_thread.join()
if do_restart:
# Reset flags before running a new bot
@@ -125,16 +130,36 @@ class GameController:
self.start_health_manager_thread()
self.start_death_manager_thread()
self.game_recovery = GameRecovery(self.death_manager)
self.game_stats = GameStats()
self.start_game_controller_thread()
self.is_running = True
def stop(self):
restore_d2r_window_visibility()
if self.death_monitor_thread: kill_thread(self.death_monitor_thread)
if self.health_monitor_thread: kill_thread(self.health_monitor_thread)
if self.bot_thread: kill_thread(self.bot_thread)
if self.game_controller_thread: kill_thread(self.game_controller_thread)
if self.death_monitor_thread:
cooperative_shutdown(
self.death_monitor_thread,
death_manager=self.death_manager,
)
if self.health_monitor_thread:
cooperative_shutdown(
self.health_monitor_thread,
health_manager=self.health_manager,
)
if self.bot_thread:
cooperative_shutdown(
self.bot_thread,
bot=self.bot,
health_manager=self.health_manager,
death_manager=self.death_manager,
)
if self.game_controller_thread:
# game_controller_thread runs run_bot() which checks self.bot._stopping
cooperative_shutdown(
self.game_controller_thread,
bot=self.bot,
health_manager=self.health_manager,
death_manager=self.death_manager,
)
self.is_running = False
def setup_screen(self):
+8 -7
View File
@@ -1,10 +1,11 @@
from config import Config
from death_manager import DeathManager
import time
import keyboard
from input_layer import keyboard
from ui_manager import ScreenObjects, is_visible
from ui import view, loading
from utils.misc import set_d2r_always_on_top
from utils.misc import wait
class GameRecovery:
def __init__(self, death_manager: DeathManager):
@@ -12,10 +13,10 @@ class GameRecovery:
def go_to_hero_selection(self):
set_d2r_always_on_top()
time.sleep(1)
wait(1, 1.5)
# clean up key presses that might be pressed in the run_thread
keyboard.release(Config().char["stand_still"])
time.sleep(0.1)
wait(0.1, 0.15)
keyboard.release(Config().char["show_items"])
start = time.time()
while (time.time() - start) < 30:
@@ -24,25 +25,25 @@ class GameRecovery:
while is_loading:
is_loading = is_visible(ScreenObjects.Loading)
is_loading |= loading.check_for_black_screen()
time.sleep(0.5)
wait(0.5, 0.75)
# lets just see if you might already be at hero selection
if is_visible(ScreenObjects.MainMenu):
return True
# would have been too easy, maybe we have died?
if self._death_manager.handle_death_screen():
time.sleep(1)
wait(1, 1.5)
continue
# if we are in game, save and exit
if is_visible(ScreenObjects.InGame):
view.fast_save_and_exit()
continue
time.sleep(1)
wait(1, 1.5)
return False
if __name__ == "__main__":
from death_manager import DeathManager
import keyboard
from input_layer import keyboard
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
+1 -1
View File
@@ -1,7 +1,7 @@
from game_stats import GameStats
from transmute import Transmute
import threading
import keyboard
from input_layer import keyboard
if __name__ == "__main__":
stats = GameStats()
+75 -34
View File
@@ -1,9 +1,10 @@
from inventory import belt
from pather import Location
import cv2
from threading import Lock
import time
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from utils.misc import wait
from logger import Logger
from screen import grab
@@ -14,33 +15,15 @@ from ui import view, meters
from ui_manager import ScreenObjects, is_visible
from random import uniform
pause_state = True
panel_check_paused = False
def get_pause_state():
return pause_state
def set_pause_state(state: bool):
global pause_state
prev = get_pause_state()
if prev != state:
debug_str = "paused" if state else "active"
Logger.info(f"Health Manager is now {debug_str}")
pause_state = state
def get_panel_check_paused():
return panel_check_paused
def set_panel_check_paused(state: bool):
global panel_check_paused
prev = get_panel_check_paused()
if prev != state:
debug_str = "pausing" if state else "activating"
Logger.info(f"Health Manager is now {debug_str} inventory panel check")
panel_check_paused = state
class HealthManager:
_instance = None
def __init__(self):
HealthManager._instance = self
self._state_lock = Lock()
self._pause_state = True
self._panel_check_paused = False
self._do_monitor = False
self._did_chicken = False
self._last_rejuv = time.time()
@@ -62,7 +45,31 @@ class HealthManager:
def reset_chicken_flag(self):
self._did_chicken = False
set_pause_state(True)
self.set_pause_state(True)
def get_pause_state(self):
with self._state_lock:
return self._pause_state
def set_pause_state(self, state: bool):
with self._state_lock:
prev = self._pause_state
if prev != state:
debug_str = "paused" if state else "active"
Logger.info(f"Health Manager is now {debug_str}")
self._pause_state = state
def get_panel_check_paused(self):
with self._state_lock:
return self._panel_check_paused
def set_panel_check_paused(self, state: bool):
with self._state_lock:
prev = self._panel_check_paused
if prev != state:
debug_str = "pausing" if state else "activating"
Logger.info(f"Health Manager is now {debug_str} inventory panel check")
self._panel_check_paused = state
def _do_chicken(self, img):
if self._callback is not None:
@@ -78,7 +85,7 @@ class HealthManager:
self._last_chicken_screenshot = "./log/screenshots/info/info_debug_chicken_" + time.strftime("%Y%m%d_%H%M%S") + ".png"
cv2.imwrite(self._last_chicken_screenshot, img)
self._did_chicken = True
set_pause_state(True)
self.set_pause_state(True)
def start_monitor(self):
Logger.info("Start health monitoring")
@@ -91,7 +98,7 @@ class HealthManager:
merc_hp_potion_delay = 10.24
while self._do_monitor:
if self._did_chicken or get_pause_state():
if self._did_chicken or self.get_pause_state():
wait(1)
continue
fn_start = time.perf_counter()
@@ -106,9 +113,11 @@ class HealthManager:
#It seems that hit recovery can delay the use of juvs and 15 frames is max recovery time.
#To delay two juvs being used back to back, we'll need to wait the recovery time between uses.
#15 frames is 0.60 seconds.
if last_drink > 0.60:
if last_drink > 0.60:
if (health_percentage <= Config().char["take_rejuv_potion_health"]) or \
(mana_percentage <= Config().char["take_rejuv_potion_mana"]):
# Simulate human reaction time (100-300ms)
wait(0.1, 0.3)
success_drink_rejuv = belt.drink_potion("rejuv", stats=[health_percentage, mana_percentage])
#failure to drink juv (likely out of juvs). Perform chicken
if not success_drink_rejuv:
@@ -134,11 +143,13 @@ class HealthManager:
# check health
last_drink = time.time() - self._last_health
if health_percentage <= Config().char["take_health_potion"] and last_drink > lp_hp_potion_delay:
wait(0.1, 0.3) # human reaction time
belt.drink_potion("health", stats=[health_percentage, mana_percentage])
self._last_health = time.time()
# check mana
last_drink = time.time() - self._last_mana
if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay:
wait(0.1, 0.3) # human reaction time
belt.drink_potion("mana", stats=[health_percentage, mana_percentage])
self._last_mana = time.time()
# check merc
@@ -151,12 +162,14 @@ class HealthManager:
self._do_chicken(img)
continue
if Config().char["heal_rejuv_merc"] and (merc_health_percentage <= Config().char["heal_rejuv_merc"] and last_drink > 4.0):
wait(0.1, 0.3) # human reaction time
belt.drink_potion("rejuv", merc=True, stats=[merc_health_percentage])
self._last_merc_heal = time.time()
elif Config().char["heal_merc"] and (merc_health_percentage <= Config().char["heal_merc"] and last_drink > merc_hp_potion_delay):
wait(0.1, 0.3) # human reaction time
belt.drink_potion("health", merc=True, stats=[merc_health_percentage])
self._last_merc_heal = time.time()
if not get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
Logger.warning(f"Found an open inventory / quest / skill / stats page. Close it.")
self._count_panel_detects += 1
if self._count_panel_detects >= 2:
@@ -166,16 +179,44 @@ class HealthManager:
continue
common.close()
fn_end = time.perf_counter()
wait_time = 3/25 - (fn_end - fn_start)
# Add timing jitter to avoid perfectly regular polling pattern (anti-cheat)
import random
base_wait = 3/25 - (fn_end - fn_start)
jitter = random.uniform(0.8, 1.2) # ±20% jitter
wait_time = max(0.0, base_wait * jitter)
if wait_time > 0:
wait(wait_time) # wait 3 frames before rechecking
wait(wait_time) # wait ~3 frames before rechecking
Logger.debug("Stop health monitoring")
# Backwards-compatible module-level functions that delegate to the singleton instance
def get_pause_state():
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
return HealthManager._instance.get_pause_state()
return True
def set_pause_state(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
HealthManager._instance.set_pause_state(state)
def get_panel_check_paused():
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
return HealthManager._instance.get_panel_check_paused()
return False
def set_panel_check_paused(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
HealthManager._instance.set_panel_check_paused(state)
# Testing: Start dying or losing mana and see if it works
if __name__ == "__main__":
import threading
import keyboard
from input_layer import keyboard
import os
from health_manager import set_pause_state
from screen import start_detecting_window, stop_detecting_window, grab
+205
View File
@@ -0,0 +1,205 @@
"""
Native input layer - replaces `keyboard` and `mouse` (pyclick) libraries.
No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kernel32).
Import as:
from input_layer import keyboard, mouse
This is a drop-in replacement for the existing `import keyboard` and
`from utils.custom_mouse import mouse` patterns.
"""
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP
)
from .mouse_impl import mouse
class _Keyboard:
"""
Drop-in replacement for the `keyboard` library.
Supports all patterns used in botty: send(), press(), release(), is_pressed(),
add_hotkey(), wait(), write(), hook(), pause().
All key presses include stealth micro-pauses and variable press duration
automatically - no changes to existing code needed.
"""
def _stealth_before(self):
"""Add micro-pause before key press (stealth)."""
try:
from utils.stealth import add_micro_pause
add_micro_pause()
except Exception:
pass
def _stealth_after(self):
"""Add micro-pause after key press (stealth)."""
try:
from utils.stealth import add_micro_pause
add_micro_pause()
except Exception:
pass
def _stealth_duration(self):
"""Get human-like key press duration."""
try:
from utils.stealth import key_press_duration
return key_press_duration()
except Exception:
import random
return random.uniform(0.02, 0.15)
def _skill_rotation_hesitation(self):
"""Add hesitation before skill casts (Tier 2 behavior stealth)."""
try:
from utils.stealth import skill_rotation_hesitation
from utils.misc import wait as _wait
_wait(skill_rotation_hesitation(), skill_rotation_hesitation() * 1.2)
except Exception:
import random
from utils.misc import wait as _wait
_wait(random.uniform(0.08, 0.3), random.uniform(0.08, 0.3) * 1.2)
def _maybe_skill_mistake(self, key: str):
"""
1-2% chance of pressing a random skill key first before the intended one
(Tier 2 behavior stealth - simulates miscasting).
Returns True if a mistake was made and corrected.
"""
try:
from utils.stealth import should_correct_skill_mistake
if not should_correct_skill_mistake():
return False
except Exception:
return False
# Press a random skill key first (simulates miscast)
import random
try:
from config import Config
skill_keys = list(range(ord('1'), ord('0') + 1)) # Keys 1-0
wrong_key = chr(random.choice(skill_keys))
if wrong_key == key:
wrong_key = chr(random.choice(skill_keys))
except Exception:
wrong_key = random.choice(['1', '2', '3', '4', '5'])
# Send the wrong key
wrong_vk = _get_vk(wrong_key)
if wrong_vk is not None:
key_down(wrong_vk)
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
key_up(wrong_vk)
return True
def send(self, key: str, do_press: bool = True, do_release: bool = True, delay=None):
"""
Send a key press event with stealth timing.
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
Supports do_release=False (hold key) and do_press=False (release-only).
"""
self._stealth_before()
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
if ' + ' in key or '+' in key:
# Parse combo
parts = [p.strip() for p in key.replace('+', ' + ').split(' + ') if p.strip()]
modifiers = []
final_key = parts[-1]
for p in parts[:-1]:
vk = _get_vk(p)
if vk is not None:
modifiers.append(vk)
vk = _get_vk(final_key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
if do_press:
for mvk in modifiers:
key_down(mvk)
key_down(vk)
if do_release:
if do_press:
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
key_up(vk)
for mvk in reversed(modifiers):
key_up(mvk)
self._stealth_after()
return
# Single key
vk = _get_vk(key)
if vk is None:
# Try as a single character
if len(key) == 1:
vk = ord(key.upper()) if key.isalpha() else ord(key)
else:
raise ValueError(f"Unknown key: {key}")
if delay is not None:
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
# Tier 2 stealth: skill hesitation (only for skill hotkeys 1-0)
if vk in range(ord('1'), ord('0') + 1) or key in ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0'):
self._skill_rotation_hesitation()
self._maybe_skill_mistake(key)
if do_press:
key_down(vk)
if do_release:
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
if do_release:
key_up(vk)
self._stealth_after()
def press(self, key: str):
"""Press a key down (without releasing)."""
self.send(key, do_press=True, do_release=False)
def release(self, key: str):
"""Release a key (without pressing)."""
self.send(key, do_press=False, do_release=True)
def is_pressed(self, key: str) -> bool:
"""Check if a key is currently pressed."""
vk = _get_vk(key)
if vk is None:
return False
return key_state(key)
def add_hotkey(self, key: str, callback, suppress: bool = False):
"""Register a global hotkey callback."""
from .hotkey import add_hotkey as _add_hotkey
_add_hotkey(key, callback, suppress=suppress)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
from .hotkey import wait as _wait
return _wait(key, suppress=suppress)
def write(self, text: str, delay: float = 0.05):
"""Type text character by character."""
send_text(text, delay=delay)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events (dev tools only)."""
from .hotkey import hook as _hook
return _hook(callback, suppress=suppress)
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing (used by npc_auto_label.py)."""
from .hotkey import pause as _pause
return _pause(seconds, suppress)
# Singleton keyboard object - matches the `keyboard` module API
keyboard = _Keyboard()
# Exports for convenience
__all__ = ["keyboard", "mouse"]
+173
View File
@@ -0,0 +1,173 @@
"""
Global hotkey polling via GetAsyncKeyState.
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
No kernel driver - pure user-mode polling thread.
"""
import threading
import time
import ctypes
from ctypes import wintypes
from .win_input import _get_vk, VK_MAP, user32
class _HotkeyManager:
def __init__(self):
self._callbacks = {} # vk -> [(key_str, callback), ...]
self._running = False
self._thread = None
self._suppress = {} # vk -> bool (suppress key after callback fires)
self._lock = threading.Lock()
self._suppressed = set() # vks currently being held down in suppress mode
def _ensure_running(self):
if not self._running:
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def _poll_loop(self):
"""Poll GetAsyncKeyState for registered hotkeys."""
while self._running:
with self._lock:
items = list(self._callbacks.items())
for vk, entries in items:
for key_str, cb in entries:
if vk in self._suppressed:
# Key already held and suppressed
continue
state = user32.GetAsyncKeyState(vk)
if state & 0x8000: # key is down
try:
cb()
except Exception:
pass
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Wait for key release if suppressed
if self._suppressed:
still_suppressed = set()
for vk in self._suppressed:
state = user32.GetAsyncKeyState(vk)
if not (state & 0x8000):
# Key released
pass
else:
still_suppressed.add(vk)
self._suppressed = still_suppressed
from utils.misc import wait as _wait
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
def add_hotkey(self, key: str, callback, suppress: bool = False):
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key for hotkey: {key}")
with self._lock:
if vk not in self._callbacks:
self._callbacks[vk] = []
self._callbacks[vk].append((key, callback))
self._suppress[vk] = suppress
self._ensure_running()
def remove_hotkey(self, key: str, callback=None):
vk = _get_vk(key)
if vk is None:
return
with self._lock:
if vk in self._callbacks:
if callback is None:
del self._callbacks[vk]
else:
self._callbacks[vk] = [
(k, cb) for k, cb in self._callbacks[vk] if cb != callback
]
if not self._callbacks[vk]:
del self._callbacks[vk]
if not any(vk in d for d in [self._callbacks, self._suppress]):
self._suppress.pop(vk, None)
def is_pressed(self, key: str) -> bool:
vk = _get_vk(key)
if vk is None:
return False
state = user32.GetAsyncKeyState(vk)
return bool(state & 0x8000)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
if key is not None:
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
while True:
state = user32.GetAsyncKeyState(vk)
if state & 0x8000:
if suppress:
while user32.GetAsyncKeyState(vk) & 0x8000:
from utils.misc import wait as _wait
_wait(0.008, 0.012)
return
from utils.misc import wait as _wait
_wait(0.018, 0.024)
else:
# Wait for any key
while True:
for vk in range(1, 256):
if user32.GetAsyncKeyState(vk) & 0x8000:
return vk
from utils.misc import wait as _wait
_wait(0.018, 0.024)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events.
This is a simplified version - polls all known keys and calls callback.
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
def _poll_all():
while self._running:
for vk in range(1, 256):
state = user32.GetAsyncKeyState(vk)
if state & 0x8000:
event = {"event_type": "down", "name": None, "scan_code": 0}
try:
callback(event)
except Exception:
pass
if suppress:
while user32.GetAsyncKeyState(vk) & 0x8000:
from utils.misc import wait as _wait
_wait(0.01, 0.01)
break
from utils.misc import wait as _wait
_wait(0.02, 0.02)
self._ensure_running()
# Run hook in its own thread
t = threading.Thread(target=_poll_all, daemon=True)
t.start()
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing for a duration. Used by npc_auto_label.py."""
from utils.misc import wait as _wait
_wait(seconds, seconds)
# Singleton
_hotkey_manager = _HotkeyManager()
def add_hotkey(key: str, callback, suppress: bool = False):
_hotkey_manager.add_hotkey(key, callback, suppress=suppress)
def remove_hotkey(key: str, callback=None):
_hotkey_manager.remove_hotkey(key, callback)
def is_pressed(key: str) -> bool:
_hotkey_manager._ensure_running()
return _hotkey_manager.is_pressed(key)
def wait(key: str = None, suppress: bool = False):
_hotkey_manager._ensure_running()
return _hotkey_manager.wait(key, suppress=suppress)
def hook(callback, suppress: bool = False):
return _hotkey_manager.hook(callback, suppress=suppress)
def pause(seconds: float = 0, suppress: bool = False):
return _hotkey_manager.pause(seconds, suppress)
@@ -1,26 +1,20 @@
# Mostly copied from: https://github.com/patrikoss/pyclick
import mouse as _mouse
import os
if os.name == 'nt':
from mouse import _winmouse
else:
# Linux stub — _winmouse is only used in _move_to() which wraps calls in os.name checks
class _winmouse:
@staticmethod
def move_to(x, y):
_mouse.move(x, y)
import pytweening
import numpy as np
import random
"""
Human-like mouse movement with native Windows API.
Replaces custom_mouse.py (which used the `mouse` / pyclick library with mousetool.dll).
Same public API - drop-in replacement.
"""
import math
import random
import time
import threading
from concurrent.futures import Future
import screen
from config import Config
from utils.misc import is_in_roi
from logger import Logger
import template_finder
import numpy as np
import pytweening
from .win_input import mouse_move as _native_move, mouse_click as _native_click
from .win_input import mouse_down as _native_down, mouse_up as _native_up
from .win_input import get_cursor_pos as _native_get_pos
from .win_input import mouse_wheel as _native_wheel
def isNumeric(val):
return isinstance(val, (float, int, np.int32, np.int64, np.float32, np.float64))
@@ -31,26 +25,20 @@ def isListOfPoints(l):
try:
isPoint = lambda p: ((len(p) == 2) and isNumeric(p[0]) and isNumeric(p[1]))
return all(map(isPoint, l))
except (KeyError, TypeError) as e:
except (KeyError, TypeError):
return False
class BezierCurve():
@staticmethod
def binomial(n, k):
"""Returns the binomial coefficient "n choose k" """
return math.factorial(n) / float(math.factorial(k) * math.factorial(n - k))
@staticmethod
def bernsteinPolynomialPoint(x, i, n):
"""Calculate the i-th component of a bernstein polynomial of degree n"""
return BezierCurve.binomial(n, i) * (x ** i) * ((1 - x) ** (n - i))
@staticmethod
def bernsteinPolynomial(points):
"""
Given list of control points, returns a function, which given a point [0,1] returns
a point in the bezier curve described by these points
"""
def bern(t):
n = len(points) - 1
x = y = 0
@@ -63,10 +51,6 @@ class BezierCurve():
@staticmethod
def curvePoints(n, points):
"""
Given list of control points, returns n points in the bezier curve,
described by these points
"""
curvePoints = []
bernstein_polynomial = BezierCurve.bernsteinPolynomial(points)
for i in range(n):
@@ -75,22 +59,12 @@ class BezierCurve():
return curvePoints
class HumanCurve():
"""
Generates a human-like mouse curve starting at given source point,
and finishing in a given destination point
"""
def __init__(self, fromPoint, toPoint, **kwargs):
self.fromPoint = fromPoint
self.toPoint = toPoint
self.points = self.generateCurve(**kwargs)
def generateCurve(self, **kwargs):
"""
Generates a curve according to the parameters specified below.
You can override any of the below parameters. If no parameter is
passed, the default value is used.
"""
offsetBoundaryX = kwargs.get("offsetBoundaryX", 100)
offsetBoundaryY = kwargs.get("offsetBoundaryY", 100)
leftBoundary = kwargs.get("leftBoundary", min(self.fromPoint[0], self.toPoint[0])) - offsetBoundaryX
@@ -104,23 +78,15 @@ class HumanCurve():
tween = kwargs.get("tweening", pytweening.easeOutQuad)
targetPoints = kwargs.get("targetPoints", 10)
internalKnots = self.generateInternalKnots(leftBoundary,rightBoundary, \
internalKnots = self.generateInternalKnots(leftBoundary, rightBoundary,
downBoundary, upBoundary, knotsCount)
points = self.generatePoints(internalKnots)
points = self.distortPoints(points, distortionMean, distortionStdev, distortionFrequency)
points = self.tweenPoints(points, tween, targetPoints)
return points
def generateInternalKnots(self, \
leftBoundary, rightBoundary, \
downBoundary, upBoundary,\
knotsCount):
"""
Generates the internal knots used during generation of bezier curvePoints
or any interpolation function. The points are taken at random from
a surface delimited by given boundaries.
Exactly knotsCount internal knots are randomly generated.
"""
def generateInternalKnots(self, leftBoundary, rightBoundary,
downBoundary, upBoundary, knotsCount):
if not (isNumeric(leftBoundary) and isNumeric(rightBoundary) and
isNumeric(downBoundary) and isNumeric(upBoundary)):
raise ValueError("Boundaries must be numeric")
@@ -133,32 +99,20 @@ class HumanCurve():
knotsX = np.random.choice(range(leftBoundary, rightBoundary), size=knotsCount)
knotsY = np.random.choice(range(downBoundary, upBoundary), size=knotsCount)
knots = list(zip(knotsX, knotsY))
return knots
return list(zip(knotsX, knotsY))
def generatePoints(self, knots):
"""
Generates bezier curve points on a curve, according to the internal
knots passed as parameter.
"""
if not isListOfPoints(knots):
raise ValueError("knots must be valid list of points")
midPtsCnt = max( \
abs(self.fromPoint[0] - self.toPoint[0]), \
abs(self.fromPoint[1] - self.toPoint[1]), \
midPtsCnt = max(
abs(self.fromPoint[0] - self.toPoint[0]),
abs(self.fromPoint[1] - self.toPoint[1]),
2)
knots = [self.fromPoint] + knots + [self.toPoint]
return BezierCurve.curvePoints(midPtsCnt, knots)
def distortPoints(self, points, distortionMean, distortionStdev, distortionFrequency):
"""
Distorts the curve described by (x,y) points, so that the curve is
not ideally smooth.
Distortion happens by randomly, according to normal distribution,
adding an offset to some of the points.
"""
if not(isNumeric(distortionMean) and isNumeric(distortionStdev) and \
if not (isNumeric(distortionMean) and isNumeric(distortionStdev) and
isNumeric(distortionFrequency)):
raise ValueError("Distortions must be numeric")
if not isListOfPoints(points):
@@ -167,78 +121,41 @@ class HumanCurve():
raise ValueError("distortionFrequency must be in range [0,1]")
distorted = []
for i in range(1, len(points)-1):
x,y = points[i]
for i in range(1, len(points) - 1):
x, y = points[i]
delta = np.random.normal(distortionMean, distortionStdev) if \
random.random() < distortionFrequency else 0
distorted += (x,y+delta),
distorted += (x, y + delta),
distorted = [points[0]] + distorted + [points[-1]]
return distorted
def tweenPoints(self, points, tween, targetPoints):
"""
Chooses a number of points(targetPoints) from the list(points)
according to tweening function(tween).
This function in fact controls the velocity of mouse movement
"""
if not isListOfPoints(points):
raise ValueError("points must be valid list of points")
if not isinstance(targetPoints, int) or targetPoints < 2:
raise ValueError("targetPoints must be an integer greater or equal to 2")
# tween is a function that takes a float 0..1 and returns a float 0..1
res = []
for i in range(targetPoints):
index = int(tween(float(i)/(targetPoints-1)) * (len(points)-1))
index = int(tween(float(i) / (targetPoints - 1)) * (len(points) - 1))
res += points[index],
return res
class mouse:
@staticmethod
def sleep(duration, get_now=time.perf_counter):
time.sleep(duration)
# now = get_now()
# end = now + duration
# while now < end:
# now = get_now()
"""
Drop-in replacement for the `mouse` (pyclick) library.
Same public API as custom_mouse.py, but uses native SendInput instead of mousetool.dll.
"""
@staticmethod
def _move_to(x, y, absolute=True, duration=0):
"""
Moves the mouse. If `absolute`, to position (x, y), otherwise move relative
to the current position. If `duration` is non-zero, animates the movement.
"""
x = int(x)
y = int(y)
def sleep(duration):
from utils.misc import wait as _wait
_wait(duration, duration * 1.2)
# Requires an extra system call on Linux, but `move_relative` is measured
# in millimiters so we would lose precision.
position_x, position_y = _mouse.get_position()
if not absolute:
x = position_x + x
y = position_y + y
if duration:
start_x = position_x
start_y = position_y
dx = x - start_x
dy = y - start_y
if dx == 0 and dy == 0:
mouse.sleep(duration)
else:
# 120 movements per second.
# Round and keep float to ensure float division in Python 2
steps = max(1.0, float(int(duration * 120.0)))
for i in range(int(steps)+1):
mouse.move(start_x + dx*i/steps, start_y + dy*i/steps)
mouse.sleep(duration/steps)
else:
_winmouse.move_to(x, y)
def move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5, delay_factor: tuple[float, float] = [0.4, 0.6]):
from_point = _mouse.get_position()
@staticmethod
def move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
from_point = _native_get_pos()
dist = math.dist((x, y), from_point)
offsetBoundaryX = max(10, int(0.08 * dist))
offsetBoundaryY = max(10, int(0.08 * dist))
@@ -260,11 +177,11 @@ class mouse:
# Apply human curve complexity from stealth config
try:
from config import Config
complexity = Config().stealth.get("human_curve_complexity", 1.0)
except Exception:
complexity = 1.0
# Scale distortion parameters based on complexity
distortionMean = 1 * complexity
distortionStdev = 1 * complexity
distortionFreq = min(0.8, 0.4 * complexity)
@@ -283,88 +200,95 @@ class mouse:
delta = duration / len(human_curve.points)
for point in human_curve.points:
_mouse.move(point[0], point[1], duration=delta)
_native_move(int(point[0]), int(point[1]))
mouse.sleep(delta)
@staticmethod
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5, delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause → randomized position → endpoint wobble."""
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause -> randomized position -> endpoint wobble."""
try:
from utils.stealth import randomize_click_position, add_micro_pause, endpoint_wobble
rx, ry = randomize_click_position(x, y)
add_micro_pause()
except Exception:
from logger import Logger
Logger.warning("[Stealth] randomize_click_position/add_micro_pause failed, using manual fallback")
try:
from config import Config
variance = Config().stealth["click_variance"]
except Exception:
variance = 0
rx = x + random.randint(-variance, variance)
ry = y + random.randint(-variance, variance)
mouse.move(rx, ry, absolute=absolute, randomize=5 + variance, delay_factor=delay_factor)
# Endpoint wobble: micro-adjustment after arrival, before click
try:
from utils.stealth import endpoint_wobble
wx, wy = endpoint_wobble(rx, ry)
# Nudge mouse a few pixels — imperceptible to the eye, breaks perfect stillness
_mouse.move(wx, wy)
_native_move(wx, wy)
except Exception:
from logger import Logger
Logger.warning("[Stealth] endpoint_wobble failed, skipping micro-adjustment")
pass
@staticmethod
def _is_clicking_safe():
# Because of reports that botty lost equiped items, let's check if the inventory is open, and if it is, restrict the mouse move
mouse_pos = screen.convert_monitor_to_screen(_mouse.get_position())
is_inventory_open = template_finder.search(
"INVENTORY_GOLD_BTN",
screen.grab(),
threshold=0.8,
roi=Config().ui_roi["gold_btn"],
use_grayscale=True
).valid
if is_inventory_open:
is_in_equipped_area = is_in_roi(Config().ui_roi["equipped_inventory_area"], mouse_pos)
is_in_restricted_inventory_area = is_in_roi(Config().ui_roi["restricted_inventory_area"], mouse_pos)
if is_in_restricted_inventory_area or is_in_equipped_area:
Logger.error("Mouse wants to click in equipped area. Cancel action.")
return False
try:
import screen
from config import Config
from utils.misc import is_in_roi
import template_finder
mouse_pos = screen.convert_monitor_to_screen(_native_get_pos())
is_inventory_open = template_finder.search(
"INVENTORY_GOLD_BTN",
screen.grab(),
threshold=0.8,
roi=Config().ui_roi["gold_btn"],
use_grayscale=True
).valid
if is_inventory_open:
is_in_equipped_area = is_in_roi(Config().ui_roi["equipped_inventory_area"], mouse_pos)
is_in_restricted_inventory_area = is_in_roi(Config().ui_roi["restricted_inventory_area"], mouse_pos)
if is_in_restricted_inventory_area or is_in_equipped_area:
from logger import Logger
Logger.error("Mouse wants to click in equipped area. Cancel action.")
return False
except Exception:
pass
return True
@staticmethod
def click(button):
if button != "left" or mouse._is_clicking_safe():
# Human arrival-to-click delay: 50-800ms before actually pressing
try:
from utils.stealth import apply_click_delay
apply_click_delay()
except Exception:
time.sleep(random.uniform(0.05, 0.3))
_mouse.click(button)
from logger import Logger
Logger.warning("[Stealth] apply_click_delay failed, falling back to manual delay")
from utils.misc import wait as _wait
_wait(0.05, 0.3)
_native_click(button)
@staticmethod
def press(button):
if button != "left" or mouse._is_clicking_safe():
_mouse.press(button)
_native_down(button)
@staticmethod
def release(button):
_mouse.release(button)
_native_up(button)
@staticmethod
def get_position():
return _mouse.get_position()
return _native_get_pos()
@staticmethod
def wheel(delta):
_mouse.wheel(delta)
_native_wheel(delta)
@staticmethod
def async_move(x, y, absolute=True, randomize=5, delay_factor=[0.4, 0.6]):
"""
Non-blocking mouse move. Returns immediately with a Future-like object.
:return: A dict with:
- 'done()': callable returning bool
- 'wait(timeout=None)': blocks until move completes or timeout
"""
result = {"_done": False, "_lock": threading.Lock()}
def _run():
@@ -385,26 +309,14 @@ class mouse:
while not done():
if deadline is not None and time.monotonic() >= deadline:
return False
time.sleep(0.01)
from utils.misc import wait as _wait
_wait(0.01, 0.012)
return True
return {"done": done, "wait": wait}
if __name__ == "__main__":
import os
import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
screen.find_and_set_window_position()
move_to_ok = screen.convert_screen_to_monitor((400, 420))
move_to_bad_equiped = screen.convert_screen_to_monitor((900, 170))
move_to_bad_inventory = screen.convert_screen_to_monitor((1200, 400))
mouse.move(*move_to_ok)
mouse.click("left")
time.sleep(1)
mouse.move(*move_to_bad_equiped)
mouse.click("left")
time.sleep(1)
mouse.move(*move_to_bad_inventory)
mouse.click("left")
print("Mouse module loaded OK (native SendInput, no mousetool.dll)")
pos = mouse.get_position()
print(f"Cursor at: {pos}")
+317
View File
@@ -0,0 +1,317 @@
"""
Native Windows input via ctypes SendInput API.
No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kernel32).
"""
import ctypes
import time
import struct
from ctypes import wintypes
# ─── user32 constants ───
# INPUT_TYPE
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
INPUT_HARDWARE = 2
# MOUSEEVENTF flags
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
MOUSEEVENTF_XDOWN = 0x0080
MOUSEEVENTF_XUP = 0x0100
MOUSEEVENTF_WHEEL = 0x0800
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_HWHEEL = 0x01000
# KEYEVENTF flags
KEYEVENTF_EXTENDEDKEY = 0x0001
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_UNICODE = 0x0004
KEYEVENTF_SCANCODE = 0x0008
# ─── structs ───
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class HARDWAREINPUT(ctypes.Structure):
_fields_ = [
("uMsg", wintypes.DWORD),
("wParamL", wintypes.WORD),
("wParamH", wintypes.WORD),
]
class INPUTUNION(ctypes.Union):
_fields_ = [
("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
("hi", HARDWAREINPUT),
]
class INPUT(ctypes.Structure):
_fields_ = [
("type", wintypes.DWORD),
("union", INPUTUNION),
]
INPUT_ARRAY = ctypes.ARRAY(INPUT, 64)
# ─── load user32 ───
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
user32.SendInput.restype = wintypes.UINT
user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
user32.GetAsyncKeyState.restype = wintypes.SHORT
user32.GetAsyncKeyState.argtypes = [wintypes.WORD]
user32.GetCursorPos.restype = wintypes.BOOL
user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)]
user32.GetSystemMetrics.restype = wintypes.INT
user32.GetSystemMetrics.argtypes = [wintypes.INT]
user32.MapVirtualKeyW.restype = wintypes.UINT
user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
# ─── VK code mapping ───
# Extended keys (require KEYEVENTF_EXTENDEDKEY flag)
EXTENDED_KEYS = {
0x27, # numpad /
0x91, # right ctrl
0x9A, # right shift
0xB5, # numpad *
0xB8, # right alt
0xB9, # right win
0xBA, # apps key (menu)
0xC1, # numpad enter
0xC7, # numpad .
0xC8, # snap
}
# Common key name -> VK code
VK_MAP = {
"left": 0x25, "right": 0x27, "up": 0x26, "down": 0x28,
"enter": 0x0D, "return": 0x0D, "space": 0x20, "escape": 0x1B, "esc": 0x1B,
"backspace": 0x08, "tab": 0x09, "delete": 0x2E, "end": 0x23, "home": 0x24,
"insert": 0x2D, "pageup": 0x21, "pagedown": 0x22,
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,
"f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,
"f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"shift": 0xA0, "left shift": 0xA0, "right shift": 0xA1,
"ctrl": 0xA2, "left ctrl": 0xA2, "right ctrl": 0xA3,
"lctrl": 0xA2, "rctrl": 0xA3, "ctrl_l": 0xA2, "ctrl_r": 0xA3,
"alt": 0xA4, "left alt": 0xA4, "right alt": 0xA5,
"lalt": 0xA4, "ralt": 0xA5, "alt_l": 0xA4, "alt_r": 0xA5,
"caps lock": 0x14, "capslock": 0x14,
"num lock": 0x90, "numlock": 0x90,
"scroll lock": 0x91, "scrolllock": 0x91,
"print screen": 0x2C, "printscr": 0x2C,
"pause": 0x13,
"lwin": 0x5B, "rwin": 0x5C, "win": 0x5B,
"apps": 0x5D,
"numpad0": 0x60, "numpad1": 0x61, "numpad2": 0x62,
"numpad3": 0x63, "numpad4": 0x64, "numpad5": 0x65,
"numpad6": 0x66, "numpad7": 0x67, "numpad8": 0x68,
"numpad9": 0x69,
"numpad.": 0x6E, "numpad/": 0x6F,
"numpad*": 0x6B, "numpad+": 0x6C,
"numpad-": 0x6D,
"volume_up": 0xAF, "volume_down": 0xAE, "volume_mute": 0xAD,
"media_next": 0xB0, "media_prev": 0xB1, "media_stop": 0xB2, "media_play_pause": 0xB3,
"back": 0xA6, "forward": 0xA7,
}
# ─── core input functions ───
def _send_input(inp: INPUT):
"""Send a single INPUT event."""
arr = INPUT_ARRAY()
arr[0] = inp
result = user32.SendInput(1, arr, ctypes.sizeof(INPUT))
if result != 1:
raise RuntimeError(f"SendInput failed, returned {result}")
def _make_keyboard_input(vk: int, flags: int = 0, scan: int = 0):
"""Create a KEYBDINPUT struct."""
i = INPUT()
i.type = INPUT_KEYBOARD
i.union.ki.wVk = vk
i.union.ki.wScan = scan
i.union.ki.dwFlags = flags
i.union.ki.dwExtraInfo = None
return i
def _make_mouse_input(flags: int, dx: int = 0, dy: int = 0, data: int = 0):
"""Create a MOUSEINPUT struct."""
i = INPUT()
i.type = INPUT_MOUSE
i.union.mi.dx = dx
i.union.mi.dy = dy
i.union.mi.mouseData = data
i.union.mi.dwFlags = flags | MOUSEEVENTF_ABSOLUTE
i.union.mi.dwExtraInfo = None
return i
def _get_vk(key_name: str):
"""Resolve a key name to its VK code."""
key_name = key_name.strip().lower()
if key_name in VK_MAP:
return VK_MAP[key_name]
# Single character
if len(key_name) == 1:
vk = ord(key_name)
# Digit keys 0-9 map to VK_0 (0x0B) through VK_9 (0x13)
if '0' <= key_name <= '9':
return 0x0B + ord(key_name) - ord('0')
# Letter keys map directly to their ASCII value (uppercase)
if 'a' <= key_name <= 'z':
return ord(key_name.upper())
return vk
# Try pywin32 style (vk_key)
return None
def _is_extended(vk: int) -> bool:
return vk in EXTENDED_KEYS
def _get_scan(vk: int) -> int:
"""Get scan code for a VK."""
return user32.MapVirtualKeyW(vk, 0)
def key_down(vk: int):
"""Press a key down."""
extended = KEYEVENTF_EXTENDEDKEY if _is_extended(vk) else 0
scan = _get_scan(vk) if vk else 0
_send_input(_make_keyboard_input(vk, extended, scan))
def key_up(vk: int):
"""Release a key."""
extended = KEYEVENTF_EXTENDEDKEY if _is_extended(vk) else 0
scan = _get_scan(vk) if vk else 0
_send_input(_make_keyboard_input(vk, extended | KEYEVENTF_KEYUP, scan))
def key_press(vk: int):
"""Press and release a key."""
key_down(vk)
key_up(vk)
def send_key(key: str, down=True, up=True):
"""Press and/or release a key by name."""
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
if down:
key_down(vk)
if up:
key_up(vk)
def key_state(key: str) -> bool:
"""Check if a key is currently pressed (via GetAsyncKeyState)."""
vk = _get_vk(key)
if vk is None:
return False
state = user32.GetAsyncKeyState(vk)
return bool(state & 0x8000)
# ─── mouse ───
def get_screen_size():
"""Get screen dimensions for absolute mouse positioning."""
return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
def mouse_move(x: int, y: int):
"""Move mouse to absolute screen position (uses absolute SendInput)."""
screen_w, screen_h = get_screen_size()
# SendInput expects normalized absolute coordinates 0-65535
norm_x = int(x * 65535 / screen_w)
norm_y = int(y * 65535 / screen_h)
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, norm_x, norm_y))
def mouse_down(button: str = "left"):
"""Press mouse button down."""
if button == "left":
_send_input(_make_mouse_input(MOUSEEVENTF_LEFTDOWN))
elif button == "right":
_send_input(_make_mouse_input(MOUSEEVENTF_RIGHTDOWN))
elif button == "middle":
_send_input(_make_mouse_input(MOUSEEVENTF_MIDDLEDOWN))
else:
raise ValueError(f"Unknown button: {button}")
def mouse_up(button: str = "left"):
"""Release mouse button."""
if button == "left":
_send_input(_make_mouse_input(MOUSEEVENTF_LEFTUP))
elif button == "right":
_send_input(_make_mouse_input(MOUSEEVENTF_RIGHTUP))
elif button == "middle":
_send_input(_make_mouse_input(MOUSEEVENTF_MIDDLEUP))
else:
raise ValueError(f"Unknown button: {button}")
def mouse_click(button: str = "left"):
"""Click (press + release) a mouse button."""
mouse_down(button)
mouse_up(button)
def mouse_wheel(delta: int):
"""Scroll mouse wheel (positive = up, negative = down).
Delta is in "clicks" each click is 120 units."""
wheel_delta = delta * 120
_send_input(_make_mouse_input(MOUSEEVENTF_WHEEL, 0, 0, wheel_delta))
def get_cursor_pos():
"""Get cursor position as (x, y)."""
from ctypes import wintypes as wt
p = wt.POINT()
if user32.GetCursorPos(ctypes.byref(p)):
return (p.x, p.y)
return (0, 0)
# ─── text input ───
def send_text(text: str, delay: float = 0.05):
"""Send text character by character."""
for ch in text:
vk = _get_vk(ch)
if vk is None:
continue
# Determine if shift is needed
if ch.isupper() and ch.lower() != ch:
# Need shift for uppercase letters
key_down(0xA0) # left shift
key_press(vk)
key_up(0xA0)
elif not ch.isalnum() and ch != ' ':
# Shift needed for most punctuation
key_down(0xA0)
key_press(vk)
key_up(0xA0)
else:
key_press(vk)
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
+3 -3
View File
@@ -6,11 +6,11 @@ import template_finder
from inventory import common, personal
from ui import view
from ui_manager import is_visible, wait_until_visible, ScreenObjects, wait_until_hidden
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import cut_roi, wait, color_filter
from config import Config
from screen import convert_abs_to_monitor, convert_monitor_to_screen, convert_screen_to_monitor, grab
import keyboard
from input_layer import keyboard
import os
def open(img: np.ndarray = None) -> np.ndarray:
@@ -147,7 +147,7 @@ def fill_up_belt_from_inventory(num_loot_columns: int):
Fill up your belt with pots from the inventory e.g. after death. It will open and close invetory by itself!
:param num_loot_columns: Number of columns used for loot from left
"""
img = personal.open()
img = personal.open_inventory()
pot_positions = []
for column, row in itertools.product(range(num_loot_columns), range(4)):
center_pos, slot_img = common.get_slot_pos_and_img(img, column, row)
+3 -3
View File
@@ -1,10 +1,10 @@
from config import Config
import cv2
import numpy as np
import keyboard
from input_layer import keyboard
import time
import itertools
from utils.custom_mouse import mouse
from input_layer import mouse
from ui_manager import detect_screen_object, ScreenObjects, is_visible, wait_until_hidden, center_mouse
import template_finder
from utils.misc import wait, trim_black, color_filter, cut_roi
@@ -274,7 +274,7 @@ def select_stash_page(idx: int):
if __name__ == "__main__":
import os
import keyboard
from input_layer import keyboard
from config import Config
from screen import start_detecting_window, stop_detecting_window
from utils.misc import color_filter
+7 -7
View File
@@ -1,6 +1,6 @@
import itertools
from game_stats import GameStats
import keyboard
from input_layer import keyboard
import cv2
import time
import numpy as np
@@ -11,7 +11,7 @@ from logger import Logger
from config import Config
import template_finder
from utils.misc import wait, is_in_roi, mask_by_roi
from utils.custom_mouse import mouse
from input_layer import mouse
from inventory import stash, common, vendor
from ui import view
from ui_manager import detect_screen_object, is_visible, select_screen_object_match, wait_until_visible, ScreenObjects, center_mouse, wait_for_update
@@ -57,7 +57,7 @@ def inventory_has_items(img: np.ndarray = None, close_window = False) -> bool:
:param img: Img from screen.grab() with inventory open
:return: Bool if inventory still has items or not
"""
img = open(img)
img = open_inventory(img)
if img is not None:
items=False
for column, row in itertools.product(range(0, Config().char["num_loot_columns"]), range(4)):
@@ -165,7 +165,7 @@ def stash_all_items(items: list = None):
Logger.debug("Done stashing")
return items
def open(img: np.ndarray = None) -> np.ndarray:
def open_inventory(img: np.ndarray = None) -> np.ndarray:
img = grab() if img is None else img
if not common.inventory_is_open():
keyboard.send(Config().char["inventory_screen"])
@@ -174,7 +174,7 @@ def open(img: np.ndarray = None) -> np.ndarray:
return None
keyboard.send(Config().char["inventory_screen"])
if not wait_until_visible(ScreenObjects.RightPanel, 1).valid:
Logger.error(f"personal.open(): Failed to open inventory")
Logger.error(f"personal.open_inventory(): Failed to open inventory")
return None
img = grab()
return img
@@ -207,7 +207,7 @@ def inspect_items(inp_img: np.ndarray = None, close_window: bool = True, game_st
:param img: Image in which the item is searched (item details should be visible)
"""
center_mouse()
img = open(inp_img)
img = open_inventory(inp_img)
if img is None:
Logger.error("personal.inspect_items(): unable to get inventory image")
return []
@@ -433,7 +433,7 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
return items
def update_tome_key_needs(img: np.ndarray = None, item_type: str = "tp") -> bool:
img = open(img)
img = open_inventory(img)
if img is None:
Logger.debug(f"update_tome_key_needs: failed to get inventory image")
return False
+2 -2
View File
@@ -1,12 +1,12 @@
from math import floor
import keyboard
from input_layer import keyboard
import template_finder
from config import Config
import numpy as np
from utils.misc import wait
from screen import grab
from logger import Logger
from utils.custom_mouse import mouse
from input_layer import mouse
from ui_manager import center_mouse, is_visible, select_screen_object_match, wait_until_visible, ScreenObjects
from inventory import personal, common, stash
+3 -3
View File
@@ -2,7 +2,7 @@ from enum import Enum
from numpy import ndarray
import cv2
import json
import keyboard
from input_layer import keyboard
import os
import time
import uuid
@@ -19,7 +19,7 @@ from bnip.actions import should_pickup
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
from screen import grab, convert_abs_to_monitor
from ui_manager import ScreenObjects, is_visible
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
@@ -204,7 +204,7 @@ if __name__ == "__main__":
from char.sorceress import LightSorc
from char.paladin import Hammerdin
from pather import Pather
import keyboard
from input_layer import keyboard
from logger import Logger
from screen import start_detecting_window, stop_detecting_window
+14 -5
View File
@@ -11,7 +11,7 @@ if sys.platform == "win32":
os.add_dll_directory(_conda_dll_dir)
from dataclasses import dataclass
import keyboard
from input_layer import keyboard
from beautifultable import BeautifulTable
import logging
import traceback
@@ -90,8 +90,13 @@ def main():
# Auto-launch D2R if not already running
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
Logger.info("D2R is not running, launching...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
if Config().general.get("bnet_name", "") and Config().general.get("bnet_pass", ""):
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
else:
Logger.info("D2R is not running. No auto-login configured - please launch D2R and log in, then press the resume key to start.")
# Don't auto-launch without credentials; user will start manually
# Still show the UI and wait for resume key
else:
Logger.info("D2R is already running")
@@ -126,5 +131,9 @@ if __name__ == "__main__":
main()
except:
traceback.print_exc()
print("Press Enter to exit ...")
input()
# In --noconsole builds, skip input() - the hotkey-based exit_key handles shutdown
try:
if __import__('sys').stdin and __import__('sys').stdin.isatty():
input()
except (OSError, ValueError):
pass
+3 -3
View File
@@ -1,14 +1,14 @@
import time
import os
import numpy as np
import keyboard
from input_layer import keyboard
import template_finder
from config import Config
from screen import grab
from ui_manager import ScreenObjects, center_mouse, is_visible, wait_until_hidden
from utils.misc import color_filter, wait
from logger import Logger
from utils.custom_mouse import mouse
from input_layer import mouse
from math import sqrt
class Npc:
@@ -322,7 +322,7 @@ if __name__ == "__main__":
from screen import grab
from config import Config
import os
import keyboard
from input_layer import keyboard
keyboard.add_hotkey('f12', lambda: os._exit(1))
keyboard.wait("f11")
open_npc_menu(Npc.MALAH)
+3 -3
View File
@@ -1,11 +1,11 @@
import math
import keyboard
from input_layer import keyboard
import time
import os
import random
import cv2
import numpy as np
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait # for stash/shrine tele cancel detection in traverse node
from utils.misc import is_in_roi
from config import Config
@@ -728,7 +728,7 @@ if __name__ == "__main__":
cv2.imshow("debug", display_img)
cv2.waitKey(1)
import keyboard
from input_layer import keyboard
from screen import start_detecting_window, stop_detecting_window, grab
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or os._exit(1))
keyboard.wait("f11")
+1 -1
View File
@@ -110,7 +110,7 @@ class Arcane:
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from game_stats import GameStats
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
+2 -2
View File
@@ -8,7 +8,7 @@ from item.pickit import PickIt
import template_finder
from town.town_manager import TownManager, A4
from utils.misc import wait
from utils.custom_mouse import mouse
from input_layer import mouse
from screen import convert_abs_to_monitor, grab
from ui_manager import detect_screen_object, ScreenObjects
from ui import skills, loading, waypoint
@@ -505,7 +505,7 @@ class Diablo:
return (Location.A4_DIABLO_END, self._picked_up_items)
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from game_stats import GameStats
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
+2 -2
View File
@@ -10,10 +10,10 @@ from collections import OrderedDict
import time
import random
import keyboard
from input_layer import keyboard
from screen import grab, convert_abs_to_monitor
from target_detect import get_visible_targets
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
+2 -2
View File
@@ -8,7 +8,7 @@ from item.pickit import PickIt
import template_finder
from town.town_manager import TownManager, A4
from utils.misc import wait
from utils.custom_mouse import mouse
from input_layer import mouse
from screen import convert_abs_to_monitor, grab
from ui_manager import detect_screen_object, ScreenObjects
from ui import skills, loading, waypoint
@@ -326,7 +326,7 @@ class Vizier:
return (Location.A4_DIABLO_A_LAYOUTCHECK, self._picked_up_items)
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from game_stats import GameStats
import os
keyboard.add_hotkey('f12', lambda: os._exit(1))
+5 -1
View File
@@ -88,8 +88,12 @@ def grab(force_new: bool = False) -> np.ndarray:
monitor_roi["width"] = m0["width"]
monitor_roi["height"] = m0["height"]
# with 25fps we have 40ms per frame. If we check for 20ms range to make sure we can still get each frame if we want.
# Added 5-10% jitter to the cache interval to break perfectly regular timing patterns.
with cached_img_lock:
if not force_new and cached_img is not None and last_grab is not None and (time.perf_counter() - last_grab) < 0.02:
import random
jitter = 0.9 + random.random() * 0.2 # 0.9x to 1.1x
cache_threshold = 0.02 * jitter
if not force_new and cached_img is not None and last_grab is not None and (time.perf_counter() - last_grab) < cache_threshold:
return cached_img
else:
last_grab = time.perf_counter()
+10 -10
View File
@@ -3,7 +3,7 @@ import os
import time
import math
import keyboard
from input_layer import keyboard
import numpy as np
from screen import grab, convert_screen_to_monitor
@@ -11,7 +11,7 @@ from config import Config
from logger import Logger
from npc_manager import Npc, open_npc_menu, press_npc_btn
import template_finder
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait, load_template
from messages import Messenger
@@ -94,7 +94,7 @@ class AnyaShopper:
if ias_glove.valid:
self.ias_gloves_seen += 1
mouse.move(*ias_glove.center_monitor)
time.sleep(0.5)
wait(0.5, 0.6)
glove_dialog_img = grab()
if self.look_for_plus_3_gloves is True:
@@ -110,7 +110,7 @@ class AnyaShopper:
self._messenger.send_message("Bought awesome IAS/+3 jav gloves!")
Logger.info("IAS/jav gloves bought!")
self.gloves_bought += 1
time.sleep(1)
wait(1, 1.2)
else:
gg_gloves = template_finder.search(
ref=load_template(asset_folder + "plus3_ma_skills.jpg"),
@@ -123,7 +123,7 @@ class AnyaShopper:
self._messenger.send_message("Bought awesome IAS/+3 ma gloves!")
Logger.info("IAS/ma gloves bought!")
self.gloves_bought += 1
time.sleep(1)
wait(1, 1.2)
else:
if self.look_for_plus_2_gloves is True:
@@ -140,13 +140,13 @@ class AnyaShopper:
self._messenger.send_message("Bought some decent IAS/+2 gloves")
Logger.info("IAS/+2 gloves bought!")
self.gloves_bought += 1
time.sleep(1)
wait(1, 1.2)
def shop_loop(self):
while True:
open_npc_menu(Npc.ANYA)
press_npc_btn(Npc.ANYA, "trade")
time.sleep(0.5)
wait(0.5, 0.6)
img = grab()
#For some reason the glove image varies slightly depending on where it is located in the shop.
@@ -204,7 +204,7 @@ class AnyaShopper:
Logger.info(f"Trap Claws (score: {trap_score}) bought!")
self.claws_bought += 1
time.sleep(1)
wait(1, 1.2)
if melee_score > self.melee_claw_min_score and self.look_for_melee_claws is True:
# pick it up
@@ -213,7 +213,7 @@ class AnyaShopper:
self._messenger.send_message(f"Bought some mad melee Claws (score: {melee_score})")
Logger.info(f"Melee Claws (score: {melee_score}) bought!")
self.claws_bought += 1
time.sleep(1)
wait(1, 1.2)
# Done with this shopping round
self.reset_shop()
@@ -227,7 +227,7 @@ class AnyaShopper:
break
else:
mouse.move(800, 450, randomize=50, delay_factor=[0.7, 0.7])
time.sleep(2.5)
wait(2.5, 3.0)
while 1:
success = self.select_by_template("A5_RED_PORTAL")
success &= wait_for_loading_screen(2)
+4 -4
View File
@@ -5,7 +5,7 @@ import math
import random
from typing import Callable
import keyboard
from input_layer import keyboard
import numpy as np
from screen import convert_screen_to_monitor, grab, convert_abs_to_monitor, convert_screen_to_abs, convert_monitor_to_screen
@@ -13,7 +13,7 @@ from config import Config
from logger import Logger
from npc_manager import Npc, open_npc_menu, press_npc_btn
import template_finder
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
@@ -75,7 +75,7 @@ class DrognanShopper:
while True:
open_npc_menu(Npc.DROGNAN)
press_npc_btn(Npc.DROGNAN, "trade")
time.sleep(0.1)
wait(0.1, 0.12)
img = grab()
if self.look_for_scepters is True:
@@ -110,7 +110,7 @@ class DrognanShopper:
mouse.click(button="right")
Logger.info(f"Item bought!")
self.items_bought += 1
time.sleep(1)
wait(1, 1.2)
self.items_evaluated += 1
+4 -3
View File
@@ -8,8 +8,9 @@ if sys.platform == "win32":
from beautifultable import BeautifulTable
import logging
import traceback
import keyboard
from input_layer import keyboard
import time
from utils.misc import wait
from shop.anya import AnyaShopper
from shop.drognan import DrognanShopper
from config import Config
@@ -46,13 +47,13 @@ def main():
merchant = AnyaShopper()
merchant.run()
break
time.sleep(0.02)
wait(0.02, 0.024)
if __name__ == "__main__":
# To avoid cmd just closing down, except any errors and add a input() to the end
try:
start_detecting_window()
time.sleep(2)
wait(2, 2.4)
main()
except:
traceback.print_exc()
+1 -8
View File
@@ -14,13 +14,6 @@ FILTER_RANGES=[
{"erode": 1, "blur": 3, "lh": 110, "ls": 169, "lv": 50, "uh": 120, "us": 255, "uv": 255} # frozen
]
# Shape filtering to reject false positives:
# - Health bars: thin horizontal strips (aspect ratio >> 1, very wide and short)
# - Immune text: small vertical blobs (aspect ratio << 1, tall and narrow)
# - Real auras: roughly circular/elliptical (aspect ratio ~0.5-2.0)
TARGET_ASPECT_MIN = 0.5 # reject blobs taller than twice as tall as wide (immune text)
TARGET_ASPECT_MAX = 3.0 # reject strips wider than 3x their height (health bars)
# Target shape filters to reject false positives (health bars, immune text)
TARGET_MIN_AREA = 100 # minimum connected component area (immune text is tiny)
TARGET_MAX_AREA = 200 # maximum area (real aura blobs)
@@ -254,7 +247,7 @@ class LiveViewer:
# Testing: Have whatever you want to detect on the screen
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from logger import Logger
from screen import start_detecting_window, stop_detecting_window
+1 -1
View File
@@ -246,7 +246,7 @@ def search_all(
# Testing: Have whatever you want to find on the screen
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, stop_detecting_window
from utils.misc import wait
+1 -1
View File
@@ -6,7 +6,7 @@ from pather import Pather, Location
import template_finder
from utils.misc import wait
from ui_manager import ScreenObjects, is_visible
from utils.custom_mouse import mouse
from input_layer import mouse
from screen import convert_abs_to_monitor
from logger import Logger
+2 -2
View File
@@ -1,5 +1,5 @@
from item import consumables
import keyboard
from input_layer import keyboard
import template_finder
from config import Config
from pather import Location
@@ -247,7 +247,7 @@ class TownManager:
# Test: Move to desired location in d2r and run any town action you want to test from there
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window
start_detecting_window()
+2 -2
View File
@@ -6,14 +6,14 @@ from .inventory_collection import InventoryCollection
from .stash import Stash
from .gem_picking import SimpleGemPicking
from screen import convert_screen_to_monitor, grab
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait
from version import __version__
from logger import Logger
from game_stats import GameStats
import template_finder
import numpy as np
import keyboard
from input_layer import keyboard
import cv2
from inventory import personal, common
+1 -1
View File
@@ -1,4 +1,4 @@
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import cut_roi, roi_center, wait, is_in_roi
from config import Config
+1 -1
View File
@@ -1,7 +1,7 @@
from ui_manager import detect_screen_object, select_screen_object_match, ScreenObjects
from logger import Logger
from utils.misc import wait
import keyboard
from input_layer import keyboard
def handle_error() -> bool:
Logger.warning("Server connection issue. waiting 20s")
+2 -1
View File
@@ -2,6 +2,7 @@ import time
import numpy as np
from screen import grab
from config import Config
from utils.misc import wait
def check_for_black_screen() -> bool:
img = grab()
@@ -17,5 +18,5 @@ def wait_for_loading_screen(timeout: float = 10.0) -> bool:
while time.time() - start < timeout:
if check_for_black_screen():
return True
time.sleep(0.02)
wait(0.02, 0.024)
return False
+1 -1
View File
@@ -1,5 +1,5 @@
import time
import keyboard
from input_layer import keyboard
from config import Config
from utils.misc import wait
from logger import Logger
+2 -2
View File
@@ -1,6 +1,6 @@
from config import Config
from screen import convert_screen_to_monitor, grab
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import cut_roi, wait
from logger import Logger
from config import Config
@@ -44,7 +44,7 @@ def get_experience():
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window
+1 -1
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
from logger import Logger
import cv2
import time
+3 -3
View File
@@ -1,8 +1,8 @@
import time
from screen import grab
from config import Config
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from logger import Logger
from utils.misc import wait
from ui_manager import wait_until_hidden, wait_until_visible, detect_screen_object, select_screen_object_match, ScreenObjects, list_visible_objects, is_visible
@@ -138,7 +138,7 @@ def return_to_play() -> bool:
# Testing
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
import os
from screen import start_detecting_window, stop_detecting_window
start_detecting_window()
+45 -6
View File
@@ -1,6 +1,7 @@
import re
import random
from utils.custom_mouse import mouse
from input_layer import mouse
from logger import Logger
from config import Config
from screen import convert_screen_to_monitor
@@ -8,6 +9,40 @@ from utils.misc import wait
from ui import loading
from ui_manager import detect_screen_object, ScreenObjects
def _maybe_wrong_waypoint(act: int, idx: int):
"""
2-3% chance of selecting a wrong waypoint first, then correcting.
Simulates human misclick on the waypoint menu.
"""
try:
from utils.stealth import should_wrong_waypoint
if not should_wrong_waypoint():
return False, act, idx
except Exception:
return False, act, idx
# Pick a wrong waypoint in the same act (valid act range)
wrong_idx = random.randint(0, 8)
while wrong_idx == idx:
wrong_idx = random.randint(0, 8)
# Click the wrong waypoint first
wrong_pos = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * wrong_idx)
wx, wy = convert_screen_to_monitor(wrong_pos)
mouse.move(wx, wy, randomize=8)
mouse.click(button="left")
Logger.info(f"[Stealth] Misclicked waypoint index {wrong_idx}, correcting to {idx}")
# Wait a bit (realizing the mistake), then correct
wait(0.5, 1.0)
# Click the correct waypoint
pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx)
x, y = convert_screen_to_monitor(pos_wp_btn)
mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4])
mouse.click(button="left")
return True, act, idx
_WAYPOINTS = {
# Act 1
"Rouge Encampment": (1, 0),
@@ -75,11 +110,15 @@ def use_wp(label: str = None, act: int = None, idx: int = None) -> bool:
mouse.move(x, y, randomize=8)
mouse.click(button="left")
wait(0.3, 0.4)
pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx)
x, y = convert_screen_to_monitor(pos_wp_btn)
mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4])
wait(0.4, 0.5)
mouse.click(button="left")
# Stealth: 2-3% chance of wrong waypoint misclick then correction
was_wrong, _, _ = _maybe_wrong_waypoint(act, idx)
if not was_wrong:
pos_wp_btn = (Config().ui_pos["wp_first_btn_x"], Config().ui_pos["wp_first_btn_y"] + Config().ui_pos["wp_btn_height"] * idx)
x, y = convert_screen_to_monitor(pos_wp_btn)
mouse.move(x, y, randomize=[60, 9], delay_factor=[0.9, 1.4])
wait(0.4, 0.5)
mouse.click(button="left")
# wait till loading screen is over
if loading.wait_for_loading_screen(5):
while 1:
+5 -4
View File
@@ -1,4 +1,4 @@
import keyboard
from input_layer import keyboard
import os
import numpy as np
import time
@@ -6,7 +6,7 @@ import cv2
from functools import cache
from typing import TypeVar, Callable
from utils.custom_mouse import mouse
from input_layer import mouse
from utils.misc import wait, cut_roi, image_is_equal
from logger import Logger
from config import Config
@@ -411,7 +411,7 @@ def get_closest_non_hud_pixel(pos : tuple[int, int], pos_type: str = "abs") -> t
# Testing: Move to whatever ui to test and run
if __name__ == "__main__":
import keyboard
from input_layer import keyboard
from screen import start_detecting_window, grab, stop_detecting_window
start_detecting_window()
keyboard.add_hotkey('f12', lambda: Logger.info('Force Exit (f12)') or stop_detecting_window() or os._exit(1))
@@ -422,4 +422,5 @@ if __name__ == "__main__":
print(wait_for_update(grab(), Config().ui_roi["right_inventory"], timeout=5))
# while 1:
# print(list_visible_objects())
# time.sleep(1)
# from utils.misc import wait as _wait
# _wait(1, 1.2)
+1 -1
View File
@@ -3,7 +3,7 @@ import cv2
from config import Config
from utils.misc import cut_roi
import mouse
import keyboard
from input_layer import keyboard
import os
import time
from screen import grab, convert_monitor_to_screen
+2 -2
View File
@@ -15,8 +15,8 @@ import time
from pather import Pather
from char.sorceress import NovaSorc
from screen import convert_screen_to_monitor
import keyboard
from utils.custom_mouse import mouse
from input_layer import keyboard
from input_layer import mouse
from PIL import ImageTk, Image
import re
+77 -7
View File
@@ -3,6 +3,8 @@ from decimal import InvalidOperation
import time
import random
import ctypes
import threading
import logging
import numpy as np
from copy import deepcopy
import unicodedata
@@ -107,12 +109,20 @@ def find_d2r_window(spec: WindowSpec, offset = (0, 0)) -> tuple[int, int]:
def set_d2r_always_on_top():
if os.name == 'nt':
windows_list = []
EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list)
for w in windows_list:
if w[1] == "Diablo II: Resurrected":
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
print("Set D2R to be always on top")
for attempt in range(30):
windows_list = []
EnumWindows(lambda w, l: l.append((w, GetWindowText(w))), windows_list)
found = False
for w in windows_list:
if "Diablo II" in w[1]:
SetWindowPos(w[0], HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
print("Set D2R to be always on top")
found = True
break
if found:
return
wait(0.5, 1.0)
print('D2R window not found, could not set always on top')
else:
print('OS not supported, unable to set D2R always on top')
@@ -146,13 +156,73 @@ def wait(min_seconds, max_seconds = None):
time.sleep(base * jitter)
return
def kill_thread(thread):
def _force_kill_thread(thread):
"""
DANGEROUS: Force-kills a thread via CPython private API.
Only use as a last resort when cooperative shutdown failed.
Can corrupt locks, cause GIL issues, or corrupt numpy arrays.
"""
Logger.error(
f"Force-killing thread '{thread.name}' via PyThreadState_SetAsyncExc. "
"This is dangerous and can corrupt locks/GIL/numpy arrays!"
)
thread_id = thread.ident
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, ctypes.py_object(SystemExit))
if res > 1:
ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
Logger.error('Exception raise failure')
def cooperative_shutdown(
thread,
bot=None,
health_manager=None,
death_manager=None,
timeout=5.0,
):
"""
Cooperatively shut down a thread by signalling the owning objects, then join.
Falls back to _force_kill_thread only if the thread is still alive after
*timeout* seconds. This avoids the dangers of PyThreadState_SetAsyncExc
(corrupted locks, GIL issues, numpy array corruption) in the common path.
"""
# Signal Bot to stop its run loop
if bot is not None:
bot.stop()
# Signal HealthManager to stop its monitoring loop
if health_manager is not None:
health_manager.stop_monitor()
# Signal DeathManager to stop its monitoring loop
if death_manager is not None:
death_manager.stop_monitor()
# Wait for the thread to finish cooperatively
thread.join(timeout=timeout)
if thread.is_alive():
Logger.warning(
f"Thread '{thread.name}' did not exit within {timeout}s; "
"falling back to force kill (PyThreadState_SetAsyncExc)."
)
_force_kill_thread(thread)
# Kept for backwards-compatibility so existing imports still work.
# Calls the cooperative path when the owning objects are available;
# otherwise falls back to force kill immediately.
def kill_thread(thread):
"""
Backwards-compatibility wrapper.
Prefer cooperative_shutdown() for new code.
"""
# We can't signal anything without the owning objects, so fall back
# to force kill. Callers that own the bot/managers should use
# cooperative_shutdown() instead.
_force_kill_thread(thread)
def cut_roi(img, roi):
x, y, w, h = roi
return img[y:y+h, x:x+w]
+1 -1
View File
@@ -4,7 +4,7 @@ from config import Config
import template_finder
from utils.misc import load_template, cut_roi
import mouse
import keyboard
from input_layer import keyboard
import os
import shutil
from pathlib import Path
+1 -1
View File
@@ -118,7 +118,7 @@ def detect_visible_npcs_cached(img=None, ttl=5.0):
if __name__ == "__main__":
import cv2
import keyboard
from input_layer import keyboard
from screen import start_detecting_window
start_detecting_window()
+1 -1
View File
@@ -1,5 +1,5 @@
import os, sys
import keyboard
from input_layer import keyboard
import subprocess
import template_finder
+7 -6
View File
@@ -1,8 +1,9 @@
import random
import time
import keyboard
from input_layer import keyboard
from config import Config
from logger import Logger
from utils.misc import wait
def maybe_afk_break():
@@ -14,7 +15,7 @@ def maybe_afk_break():
if random.randint(1, 100) <= cfg["afk_break_chance"]:
minutes = random.uniform(cfg["afk_break_min_m"], cfg["afk_break_max_m"])
Logger.info(f"[Stealth] Taking unscheduled AFK break for {minutes:.1f} minutes")
time.sleep(minutes * 60)
wait(minutes * 60, minutes * 60 * 1.5)
Logger.info("[Stealth] AFK break over, resuming")
@@ -88,7 +89,7 @@ def add_micro_pause():
# 30% chance of micro-pause
if random.random() < 0.3:
pause_s = random.uniform(min_ms, max_ms) / 1000.0
time.sleep(pause_s)
wait(pause_s, pause_s * 1.2)
# ─── Tier 1: Input-level stealth ─────────────────────────────────────────────
@@ -123,7 +124,7 @@ def click_delay() -> float:
def apply_click_delay():
"""Sleep for a human-like delay before clicking."""
time.sleep(click_delay())
wait(click_delay(), click_delay() * 1.2)
def key_press_duration(base_duration: float = 0.05) -> float:
@@ -153,7 +154,7 @@ def human_key_press(key: str):
add_micro_pause()
duration = key_press_duration()
keyboard.press(key)
time.sleep(duration)
wait(duration, duration * 1.2)
keyboard.release(key)
add_micro_pause()
@@ -166,7 +167,7 @@ def human_keyboard_send(key: str):
add_micro_pause()
duration = key_press_duration(0.03)
keyboard.press(key)
time.sleep(duration)
wait(duration, duration * 1.2)
keyboard.release(key)
add_micro_pause()