diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6851044 --- /dev/null +++ b/ARCHITECTURE.md @@ -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. diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..bc80fa4 --- /dev/null +++ b/IMPROVEMENTS.md @@ -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) diff --git a/STEALTH.md b/STEALTH.md new file mode 100644 index 0000000..fd144c8 --- /dev/null +++ b/STEALTH.md @@ -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 diff --git a/asset_manager.py b/asset_manager.py new file mode 100644 index 0000000..b7d6baa --- /dev/null +++ b/asset_manager.py @@ -0,0 +1,1106 @@ +""" +Botty Asset Manager - Unified asset management tool. + +All-in-one tool for managing D2R template assets: capture, crop, audit, +analyze, and maintain your template library. + +Commands: + inventory List all assets with size, dimensions, category + audit Find issues: duplicates, orphans, naming problems + quality Analyze image quality: resolution, transparency, size + capture Capture D2R window to screenshots/captures/ + crop X Y W H NAME Crop region from latest capture, save as template + auto_crop Interactive: click D2R to select a crop region + search TERM Find assets matching a name/pattern + key NAME Look up the template key to use in code + validate Check all templates load correctly + similarity Find near-duplicate images + cleanup [--yes] Find/remove duplicate assets + batch OP VALUE Batch operation: "resize WxH" or "convert png" + help Show this help + +Examples: + python asset_manager.py inventory + python asset_manager.py audit + python asset_manager.py search akara + python asset_manager.py key akara_front + python asset_manager.py crop 100 200 50 80 my_npc + python asset_manager.py auto_crop + python asset_manager.py similarity + python asset_manager.py cleanup + python asset_manager.py validate + +Template naming convention: + - Use lowercase_with_underscores (e.g. akara_front.png) + - Template key is the filename uppercased (e.g. AKARA_FRONT) + - NPC assets go in assets/npc// + - UI templates go in assets/templates/ui/ + - Item templates go in assets/item_properties/ +""" +import os, sys, argparse, json, hashlib, time, math, re +from pathlib import Path +from datetime import datetime +from collections import defaultdict + +# DPI awareness +try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(2) +except: + pass + +# Fix DLL loading +if sys.platform == "win32": + _dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin") + if os.path.isdir(_dll): + os.add_dll_directory(_dll) + +import cv2 +import numpy as np + +BASE = Path(os.path.dirname(os.path.abspath(__file__))) +ASSETS = BASE / "assets" + +# Template directories that template_finder.py loads +TEMPLATE_DIRS = [ + "templates", + "npc", + "shop", + "item_properties", + "chests", + "gamble", + "items", +] + +# Known NPC names for routing +NPC_NAMES = { + 'akara', 'charsi', 'kashya', 'cain', 'drognan', 'lysander', + 'fara', 'ormus', 'tyrael', 'jamella', 'halbu', 'qual_kehk', + 'malah', 'larzuk', 'anya', 'carrow', 'ashera', 'alkaar', + 'elzix', 'meshiff', 'hrrky', 'izhu', 'essjay', 'seraphina', + 'aluria', 'jermak', 'griswold', 'hugel', 'rodek', 'meathead', + 'gheed', 'act1', 'act2', 'act3', 'act4', 'act5', +} + + +# ===================== IMAGE UTILITIES ===================== + +def img_hash(path): + """MD5 hash of image file content.""" + try: + with open(path, 'rb') as f: + return hashlib.md5(f.read()).hexdigest() + except: + return None + + +def img_hash_fast(path): + """Faster hash: read first/last 4KB of file.""" + try: + sz = os.path.getsize(path) + with open(path, 'rb') as f: + h = hashlib.md5(f.read(4096)).hexdigest() + if sz > 4096: + f.seek(-4096, 2) + h += hashlib.md5(f.read(4096)).hexdigest() + return h + except: + return None + + +def img_dims(path): + """Return (w, h) or None.""" + try: + img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) + if img is None: + return None + return img.shape[1], img.shape[0] + except: + return None + + +def img_quick_info(path): + """Return (w, h, has_alpha) in a single image load.""" + try: + img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) + if img is None: + return None, None, False + w, h = img.shape[1], img.shape[0] + has_alpha = (img.shape[2] == 4 and np.min(img[:, :, 3]) < 255) if len(img.shape) > 1 and img.shape[2] >= 4 else False + return w, h, has_alpha + except: + return None, None, False + + +def img_similarity(path1, path2): + """Compute visual similarity between two images (0-1, higher = more similar). + Uses resized comparison + MSE for speed.""" + try: + img1 = cv2.imread(str(path1)) + img2 = cv2.imread(str(path2)) + if img1 is None or img2 is None: + return 0.0 + # Resize to same size for comparison + img1 = cv2.resize(img1, (64, 64)) + img2 = cv2.resize(img2, (64, 64)) + mse = np.mean((img1.astype('float') - img2.astype('float')) ** 2) + return float(math.exp(-mse / 10000)) + except: + return 0.0 + + +# ===================== ASSET GATHERING ===================== + +def gather_assets(asset_dirs=None): + """Gather all asset file paths with metadata. Uses lazy evaluation for image info.""" + if asset_dirs is None: + asset_dirs = TEMPLATE_DIRS + assets = {} + for d in asset_dirs: + dir_path = ASSETS / d + if not dir_path.exists(): + continue + for f in dir_path.rglob('*.png'): + rel = str(f.relative_to(ASSETS)) + assets[rel] = { + 'path': f, + 'category': d, + 'size': f.stat().st_size, + 'dims': None, # Lazy-loaded + 'hash': img_hash(f), + 'fast_hash': img_hash_fast(f), + 'has_alpha': False, # Lazy-loaded + } + return assets + + +def _ensure_image_info(info): + """Lazy-load image dimensions and alpha info if not already loaded.""" + if info['dims'] is not None: + return + w, h, alpha = img_quick_info(info['path']) + info['dims'] = (w, h) if w is not None else None + info['has_alpha'] = alpha + + +# ===================== COMMANDS ===================== + +def cmd_inventory(args): + """List all assets with details.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + # Group by category + cats = defaultdict(list) + for name, info in sorted(assets.items()): + cats[info['category']].append((name, info)) + + print(f"\n{'='*70}") + print(f" Botty Asset Inventory ({len(assets)} assets)") + print(f"{'='*70}\n") + + total_size = 0 + for cat in sorted(cats.keys()): + items = cats[cat] + cat_size = sum(i['size'] for _, i in items) + total_size += cat_size + print(f" [{cat.upper()}] ({len(items)} files, {cat_size/1024:.1f} KB)") + for name, info in items: + _ensure_image_info(info) + dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" + alpha = " [A]" if info['has_alpha'] else "" + size_str = f"{info['size']/1024:.1f} KB" if info['size'] >= 1024 else f"{info['size']} B" + print(f" {name} {dims_str} {size_str}{alpha}") + print() + + print(f" Total: {len(assets)} files, {total_size/1024:.1f} KB") + print() + + +def cmd_search(args): + """Search assets by name/pattern.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + term = ' '.join(args.args).lower() + + # Exact and fuzzy matches + results = [] + for name, info in assets.items(): + name_lower = name.lower() + stem = Path(name).stem.lower() + + score = 0 + if term in stem: + score = 100 + elif stem in term: + score = 80 + elif term in name_lower: + score = 60 + elif any(w in stem for w in term.split()): + score = 40 + else: + # Check with separators removed + clean = stem.replace('_', '').replace('-', '') + clean_term = term.replace('_', '').replace('-', '') + if clean_term in clean: + score = 30 + elif clean in clean_term: + score = 20 + + if score > 0: + results.append((score, name, info)) + + # Sort by score descending + results.sort(key=lambda x: -x[0]) + + print(f"\n{'='*70}") + print(f" Search: '{term}' ({len(results)} results)") + print(f"{'='*70}\n") + + if not results: + print(" No matches found.") + # Suggest closest + best = None + best_dist = 999 + for name, info in assets.items(): + stem = Path(name).stem.lower() + dist = len(set(term) - set(stem)) + if dist < best_dist and dist < len(term): + best_dist = dist + best = stem + if best: + print(f" Closest: {best}") + else: + for score, name, info in results[:50]: + _ensure_image_info(info) + dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" + template_key = Path(name).stem.upper() + alpha = " [A]" if info['has_alpha'] else "" + print(f" {name} {dims_str} key={template_key}{alpha}") + if len(results) > 50: + print(f" ... and {len(results) - 50} more") + + print() + + +def cmd_key(args): + """Look up the template key to use in code.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + term = ' '.join(args.args) + if not term: + print(" Usage: python asset_manager.py key ") + print(" Example: python asset_manager.py key akara_front") + return + + term_lower = term.lower().replace('-', '_') + + # Find matching assets + matches = [] + for name, info in assets.items(): + stem = Path(name).stem.lower() + if term_lower in stem or stem in term_lower: + template_key = Path(name).stem.upper() + matches.append((name, template_key, info)) + + print(f"\n{'='*70}") + print(f" Template Key Lookup: '{term}'") + print(f"{'='*70}\n") + + if not matches: + print(f" No assets matching '{term}'.") + print(f" Try: python asset_manager.py search {term}") + else: + for name, key, info in matches[:10]: + _ensure_image_info(info) + dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" + print(f" {name}") + print(f" Key: '{key}'") + print(f" Use: template_finder.search('{key}', img, threshold=0.XX)") + print(f" Size: {dims_str}") + print() + print() + + +def cmd_audit(args): + """Find asset issues: duplicates, orphans, naming problems.""" + assets = gather_assets() + + issues = [] + + # 1. Find exact duplicates (same hash) + hash_map = defaultdict(list) + for name, info in assets.items(): + if info['hash']: + hash_map[info['hash']].append(name) + + print(f"\n{'='*70}") + print(f" Botty Asset Audit") + print(f"{'='*70}\n") + + print(" DUPLICATES (identical content):") + dup_count = 0 + for h, names in hash_map.items(): + if len(names) > 1: + dup_count += len(names) - 1 + print(f" {len(names)}x: {', '.join(names)}") + if not dup_count: + print(" None found.") + + # 2. Naming convention issues + print(f"\n NAMING ISSUES:") + naming_issues = 0 + for name, info in assets.items(): + base = Path(name).stem + if ' ' in base: + print(f" {name} - contains spaces") + naming_issues += 1 + if base != base.lower() and base != base.upper(): + print(f" {name} - mixed case") + naming_issues += 1 + if '_' in base and '-' in base: + print(f" {name} - mixed separators") + naming_issues += 1 + # Dots in filename (not extension) + if '.' in base and not base.endswith('.png'): + print(f" {name} - contains dots in name (use underscores)") + naming_issues += 1 + if not naming_issues: + print(" None found.") + + # 3. Oversized assets + print(f"\n OVERSIZED (>500x500, likely full screenshots misused as templates):") + oversized = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims'] and (info['dims'][0] > 500 or info['dims'][1] > 500): + print(f" {name} {info['dims'][0]}x{info['dims'][1]}") + oversized += 1 + if not oversized: + print(" None found.") + + # 4. Tiny assets + print(f"\n TINY (<10x10, likely corrupted or miscropped):") + tiny = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims'] and (info['dims'][0] < 10 or info['dims'][1] < 10): + print(f" {name} {info['dims'][0]}x{info['dims'][1]}") + tiny += 1 + if not tiny: + print(" None found.") + + # 5. Asymmetric assets (potential miscrop) + print(f"\n VERY ASYMMETRIC (ratio >10:1, potential miscrop):") + asym = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims']: + w, h = info['dims'] + ratio = max(w, h) / max(min(w, h), 1) + if ratio > 10 and max(w, h) > 30: + print(f" {name} {w}x{h} ratio {ratio:.0f}:1") + asym += 1 + if not asym: + print(" None found.") + + print(f"\n Summary: {dup_count} duplicates, {naming_issues} naming issues, " + f"{oversized} oversized, {tiny} tiny, {asym} asymmetric") + print() + + +def cmd_quality(args): + """Analyze image quality metrics.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + print(f"\n{'='*70}") + print(f" Botty Asset Quality Report") + print(f"{'='*70}\n") + + # Resolution distribution + dims = defaultdict(int) + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims']: + dims[str(info['dims'][0]) + 'x' + str(info['dims'][1])] += 1 + + print(" Resolution distribution (top 20):") + for d, c in sorted(dims.items(), key=lambda x: -x[1])[:20]: + print(f" {d}: {c} files") + print() + + # File size distribution + sizes = defaultdict(int) + for name, info in assets.items(): + bucket = info['size'] // 1024 + if bucket < 1: + sizes['<1 KB'] += 1 + elif bucket < 10: + sizes['1-10 KB'] += 1 + elif bucket < 50: + sizes['10-50 KB'] += 1 + elif bucket < 100: + sizes['50-100 KB'] += 1 + else: + sizes['>100 KB'] += 1 + + print(" File size distribution:") + for s, c in sorted(sizes.items()): + print(f" {s}: {c} files") + print() + + # Transparency usage + alpha_count = sum(1 for info in assets.values() if info['has_alpha']) + print(f" With transparency (alpha): {alpha_count}/{len(assets)}") + print() + + # Per-category stats + print(" Per-category stats:") + cats = defaultdict(lambda: {'count': 0, 'total_size': 0, 'avg_dims': [0, 0]}) + for name, info in assets.items(): + _ensure_image_info(info) + c = cats[info['category']] + c['count'] += 1 + c['total_size'] += info['size'] + if info['dims']: + c['avg_dims'][0] += info['dims'][0] + c['avg_dims'][1] += info['dims'][1] + + for cat in sorted(cats.keys()): + c = cats[cat] + avg_w = c['avg_dims'][0] // c['count'] if c['count'] else 0 + avg_h = c['avg_dims'][1] // c['count'] if c['count'] else 0 + print(f" {cat}: {c['count']} files, {c['total_size']/1024:.1f} KB, avg {avg_w}x{avg_h}") + print() + + +def find_d2r(): + """Find D2R window handle.""" + import win32gui + import psutil + # Find D2R process first + d2r_pids = set() + for proc in psutil.process_iter(['name']): + try: + if proc.info['name'] and 'D2R' in proc.info['name']: + d2r_pids.add(proc.pid) + except: + pass + + if not d2r_pids: + return None + + hwnds = [] + def cb(h, r): + title = win32gui.GetWindowText(h) + if 'diablo' in title.lower() and win32gui.IsWindowVisible(h): + # Check if this window belongs to D2R process + import win32process + _, pid = win32process.GetWindowThreadProcessId(h) + if pid in d2r_pids: + r.append((h, title)) + win32gui.EnumWindows(cb, hwnds) + + if not hwnds: + return None + # Return the window with most title characters (most likely the game window) + hwnds.sort(key=lambda x: -len(x[1])) + return hwnds[0][0] + + +def grab_d2r(): + """Grab D2R client area at 1280x720.""" + from mss import mss + import win32gui + hwnd = find_d2r() + if not hwnd: + print(" [ERROR] D2R not found. Is it running and visible?") + return None + + client = win32gui.GetClientRect(hwnd) + w, h = client[2] - client[0], client[3] - client[1] + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + + with mss() as sct: + region = { + 'top': screen_pos[1], + 'left': screen_pos[0], + 'width': w, + 'height': h + } + sct_img = sct.grab(region) + img = np.array(sct_img)[:, :, :3] + + if w != 1280 or h != 720: + img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR) + print(f" [RESIZED] {w}x{h} -> 1280x720") + else: + print(f" [CAPTURED] {w}x{h}") + + return img + + +def cmd_capture(args): + """Capture D2R window and save.""" + save_dir = BASE / "screenshots" / "captures" + save_dir.mkdir(parents=True, exist_ok=True) + + img = grab_d2r() + if img is None: + return + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + name = f"capture_{ts}.png" + path = save_dir / name + cv2.imwrite(str(path), img) + print(f"\n [SAVED] {path}") + print(f" Crop with: python asset_manager.py crop X Y W H template_name") + print(f" Or use: python asset_manager.py auto_crop") + print() + + +def cmd_crop(args): + """Crop a region from the latest capture and save as template.""" + x, y, w, h = args.x, args.y, args.w, args.h + name = args.name + + # Find latest capture or grab fresh + save_dir = BASE / "screenshots" / "captures" + captures = sorted(save_dir.glob("capture_*.png"), key=os.path.getmtime) + if captures: + img = cv2.imread(str(captures[-1]), cv2.IMREAD_UNCHANGED) + if img is not None: + print(f" [LOADED] {captures[-1].name}") + else: + img = None + + if img is None: + print(" No recent capture. Grabbing fresh...") + img = grab_d2r() + + if img is None: + return + + # Crop + h_img, w_img = img.shape[:2] + x1, y1 = max(0, x), max(0, y) + x2, y2 = min(w_img, x + w), min(h_img, y + h) + crop = img[y1:y2, x1:x2] + + if crop.size == 0: + print(f" [ERROR] Crop region ({x},{y},{w},{h}) is out of bounds (image is {w_img}x{h_img})") + return + + # Auto-trim black/transparent borders + crop = _trim_borders(crop) + + # Determine save location + save_dir, name_lower = _resolve_save_path(name) + + # Auto-number if exists + fname = f"{name_lower}.png" + save_path = save_dir / fname + variant = 1 + while save_path.exists(): + variant += 1 + fname = f"{name_lower}_{variant}.png" + save_path = save_dir / fname + + cv2.imwrite(str(save_path), crop) + + rel = str(save_path.relative_to(ASSETS)) + print(f"\n [SAVED] {rel} ({crop.shape[1]}x{crop.shape[0]})") + + # Show template key for use in code + template_key = fname[:-4].upper() + print(f" Template key: '{template_key}'") + print(f" Use in code: template_finder.search('{template_key}', img, threshold=0.XX)") + print() + + +def _trim_borders(img): + """Trim black and transparent borders from an image.""" + # Handle grayscale images (1 channel) + if len(img.shape) == 2: + mask = (img > 1).astype(np.uint8) * 255 + elif img.shape[2] == 4: + # RGBA: non-transparent AND non-black pixels + alpha = img[:, :, 3] + gray = cv2.cvtColor(img[:, :, :3], cv2.COLOR_BGR2GRAY) + mask = ((gray > 1) & (alpha > 0)).astype(np.uint8) * 255 + else: + # BGR or other: non-black pixels + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + mask = (gray > 1).astype(np.uint8) * 255 + + coords = cv2.findNonZero(mask) + if coords is None: + return img + + x, y, w, h = cv2.boundingRect(coords) + # Add 2px padding + pad = 2 + h_img, w_img = img.shape[:2] + x = max(0, x - pad) + y = max(0, y - pad) + w = min(w_img - x, w + 2 * pad) + h = min(h_img - y, h + 2 * pad) + + return img[y:y+h, x:x+w] + + +def _resolve_save_path(name): + """Determine where to save a new asset based on its name.""" + name_lower = name.lower().replace('-', '_').replace(' ', '_') + + if name_lower in NPC_NAMES: + save_dir = ASSETS / "npc" / name_lower + elif 'template' in name_lower or 'ui' in name_lower: + save_dir = ASSETS / "templates" / "ui" + elif 'chest' in name_lower: + save_dir = ASSETS / "chests" + elif 'item' in name_lower: + save_dir = ASSETS / "item_properties" + elif 'npc' in name_lower or 'action' in name_lower: + save_dir = ASSETS / "npc" / "action_btn" + elif 'gamble' in name_lower: + save_dir = ASSETS / "gamble" + elif 'shop' in name_lower: + save_dir = ASSETS / "shop" + else: + save_dir = ASSETS / "templates" + + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir, name_lower + + +def cmd_auto_crop(args): + """Interactive crop mode: click D2R to select region.""" + try: + from input_layer import keyboard + except ImportError: + sys.path.insert(0, str(BASE / "src")) + from input_layer import keyboard + + print(f"\n{'='*70}") + print(f" Botty Auto-Crop (Interactive)") + print(f"{'='*70}") + print(f" 1. Press F1 to capture D2R") + print(f" 2. Position mouse over TOP-LEFT corner, press F2") + print(f" 3. Position mouse over BOTTOM-RIGHT corner, press F2") + print(f" 4. Preview shows in window - press:") + print(f" F3 Accept and save (you'll be prompted for name)") + print(f" F4 Retry selection (goes back to step 2)") + print(f" F12 Exit") + print(f" {'='*70}") + print(" Ready. Press F1 to capture D2R.\n") + + img = None + pt1 = None + pt2 = None + + def on_f1(): + nonlocal img + img = grab_d2r() + if img is not None: + print(" [CAPTURED] Press F2 for top-left corner.") + + def on_f2(): + nonlocal pt1, pt2 + from input_layer import mouse + mx, my = mouse.get_position() + # Convert to D2R client coordinates + hwnd = find_d2r() + if hwnd: + import win32gui + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + cx = mx - screen_pos[0] + cy = my - screen_pos[1] + # Scale if needed + if img is not None: + h_img, w_img = img.shape[:2] + cx = int(cx * w_img / 1280) + cy = int(cy * h_img / 720) + + if pt1 is None: + pt1 = (cx, cy) + print(f" Top-left: {pt1}. Now move mouse to bottom-right and press F2 again.") + else: + pt2 = (cx, cy) + print(f" Bottom-right: {pt2}. Preview: F3=save, F4=retry") + _show_preview() + + def _show_preview(): + if img is None or pt1 is None or pt2 is None: + return + h_img, w_img = img.shape[:2] + x1 = max(0, min(pt1[0], pt2[0])) + y1 = max(0, min(pt1[1], pt2[1])) + x2 = min(w_img, max(pt1[0], pt2[0])) + y2 = min(h_img, max(pt1[1], pt2[1])) + + preview = img[y1:y2, x1:x2] + preview = _trim_borders(preview) + # Resize for display if too large + disp = preview.copy() + if max(disp.shape[:2]) > 500: + scale = 500.0 / max(disp.shape[:2]) + disp = cv2.resize(disp, (int(disp.shape[1] * scale), int(disp.shape[0] * scale))) + + cv2.imshow("Auto-Crop Preview", disp) + cv2.waitKey(1) + print(f" Preview: {preview.shape[1]}x{preview.shape[0]} (after trim)") + + def on_f3(): + nonlocal img, pt1, pt2 + if img is None or pt1 is None or pt2 is None: + print(" [ERROR] No selection. Press F1 first, then F2 twice.") + return + h_img, w_img = img.shape[:2] + x1 = max(0, min(pt1[0], pt2[0])) + y1 = max(0, min(pt1[1], pt2[1])) + x2 = min(w_img, max(pt1[0], pt2[0])) + y2 = min(h_img, max(pt1[1], pt2[1])) + crop = img[y1:y2, x1:x2] + crop = _trim_borders(crop) + + # Ask for name + name = input("\n Enter template name: ").strip() + if not name: + name = "new_asset" + name = re.sub(r'[^a-zA-Z0-9_\-]', '_', name) + + save_dir, name_lower = _resolve_save_path(name) + fname = f"{name_lower}.png" + save_path = save_dir / fname + variant = 1 + while save_path.exists(): + variant += 1 + fname = f"{name_lower}_{variant}.png" + save_path = save_dir / fname + + cv2.imwrite(str(save_path), crop) + cv2.destroyWindow("Auto-Crop Preview") + + rel = str(save_path.relative_to(ASSETS)) + template_key = fname[:-4].upper() + print(f"\n [SAVED] {rel} ({crop.shape[1]}x{crop.shape[0]})") + print(f" Template key: '{template_key}'") + print(f" Use in code: template_finder.search('{template_key}', img, threshold=0.XX)") + + # Reset for next crop + pt1 = pt2 = None + + def on_f4(): + nonlocal pt1, pt2 + pt1 = pt2 = None + cv2.destroyWindow("Auto-Crop Preview") + print(" Retry. Press F2 for top-left corner.") + + keyboard.add_hotkey('f1', on_f1) + keyboard.add_hotkey('f2', on_f2) + keyboard.add_hotkey('f3', on_f3) + keyboard.add_hotkey('f4', on_f4) + keyboard.add_hotkey('f12', lambda: (print("\n Bye."), sys.exit(0))) + + try: + keyboard.wait() + except KeyboardInterrupt: + print("\n Bye.") + + +def cmd_validate(args): + """Validate all templates load correctly.""" + assets = gather_assets() + print(f"\n{'='*70}") + print(f" Botty Template Validation") + print(f"{'='*70}\n") + + errors = 0 + warnings = 0 + + for name, info in sorted(assets.items()): + _ensure_image_info(info) + if info['dims'] is None: + print(f" [ERROR] {name} - cannot read image") + errors += 1 + elif info['dims'][0] == 0 or info['dims'][1] == 0: + print(f" [ERROR] {name} - zero dimensions") + errors += 1 + elif info['size'] == 0: + print(f" [ERROR] {name} - empty file") + errors += 1 + else: + # Check template key is usable + template_key = Path(name).stem.upper() + cleaned = ''.join(c for c in template_key if c not in '0123456789_') + if not cleaned.isalpha(): + print(f" [WARN] {name} - key '{template_key}' contains unusual chars") + warnings += 1 + + if not errors and not warnings: + print(" All templates are valid.") + else: + print(f"\n {errors} error(s), {warnings} warning(s)") + print() + + +def cmd_similarity(args): + """Find near-duplicate images using visual similarity.""" + assets = gather_assets() + if len(assets) < 2: + print("Need at least 2 assets to compare.") + return + + print(f"\n{'='*70}") + print(f" Botty Similarity Analysis (fast mode)") + print(f"{'='*70}\n") + print(" Comparing assets within each category...") + print() + + # Group by category for faster comparison + cats = defaultdict(list) + for name, info in assets.items(): + cats[info['category']].append((name, info)) + + pairs_found = 0 + for cat, items in cats.items(): + if len(items) < 2: + continue + + # Quick pre-filter: only compare same-size images + size_groups = defaultdict(list) + for name, info in items: + _ensure_image_info(info) + if info['dims']: + size_groups[(info['dims'][0], info['dims'][1])].append((name, info)) + + for size, group in size_groups.items(): + if len(group) < 2: + continue + + for i in range(len(group)): + for j in range(i + 1, len(group)): + n1, i1 = group[i] + n2, i2 = group[j] + # Skip exact duplicates (those are caught by audit) + if i1['hash'] == i2['hash']: + continue + sim = img_similarity(i1['path'], i2['path']) + if sim > 0.85: + pairs_found += 1 + print(f" [{sim:.2f}] {n1} ~= {n2} ({size[0]}x{size[1]})") + elif sim > 0.70 and cat == 'npc': + pairs_found += 1 + print(f" [{sim:.2f}] {n1} ~= {n2} ({size[0]}x{size[1]})") + + if not pairs_found: + print(" No near-duplicates found.") + else: + print(f"\n {pairs_found} near-duplicate pair(s) found.") + print() + + +def cmd_cleanup(args): + """Remove duplicate assets (keep first occurrence).""" + assets = gather_assets() + hash_map = defaultdict(list) + for name, info in assets.items(): + if info['hash']: + hash_map[info['hash']].append((name, info)) + + print(f"\n{'='*70}") + print(f" Botty Asset Cleanup") + print(f"{'='*70}\n") + + removed = 0 + for h, items in hash_map.items(): + if len(items) > 1: + print(f" Duplicate group ({len(items)} files):") + for i, (name, info) in enumerate(items): + if i == 0: + print(f" [KEEP] {name}") + else: + if args.yes: + os.remove(str(info['path'])) + print(f" [REMOVED] {name}") + removed += 1 + else: + print(f" [WILL REMOVE] {name}") + print() + + if args.yes: + print(f" Removed {removed} duplicates.") + else: + print(f" Would remove {removed} duplicates. Use --yes to actually remove.") + print() + + +def cmd_batch(args): + """Batch operations on assets.""" + operation = args.operation.lower() + + if operation == "resize": + try: + target_w, target_h = map(int, args.value.split('x')) + except: + print(" Usage: python asset_manager.py batch resize WxH") + return + + assets = gather_assets() + count = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims'] and (info['dims'][0] != target_w or info['dims'][1] != target_h): + img = cv2.imread(str(info['path']), cv2.IMREAD_UNCHANGED) + if img is not None: + # Use INTER_AREA for downscaling (better quality), INTER_CUBIC for upscaling + if target_w < info['dims'][0]: + interp = cv2.INTER_AREA + else: + interp = cv2.INTER_CUBIC + resized = cv2.resize(img, (target_w, target_h), interpolation=interp) + cv2.imwrite(str(info['path']), resized) + count += 1 + print(f" Resized {count} assets to {target_w}x{target_h}.") + + elif operation == "convert": + fmt = args.value.lower() + if fmt not in ('png', 'jpg', 'jpeg'): + print(" Supported formats: png, jpg") + return + assets = gather_assets() + count = 0 + for name, info in assets.items(): + if info['path'].suffix.lower() != f'.{fmt}': + new_path = info['path'].with_suffix(f'.{fmt}') + img = cv2.imread(str(info['path']), cv2.IMREAD_UNCHANGED) + if img is not None: + cv2.imwrite(str(new_path), img) + count += 1 + print(f" Converted {count} assets to .{fmt}") + + else: + print(f" Unknown batch operation: {operation}") + print(f" Supported: resize, convert") + + +def print_help(): + print(f""" +{'='*70} + Botty Asset Manager +{'='*70} + +Usage: python asset_manager.py [command] [options] + +Commands: + inventory List all assets with size, dimensions, category + search TERM Find assets matching a name/pattern + key NAME Look up the template key to use in code + audit Find issues: duplicates, naming, oversized, tiny + quality Analyze image quality: resolution, transparency, size + similarity Find near-duplicate images + capture Capture D2R window to screenshots/captures/ + crop X Y W H NAME Crop region from latest capture, save as template + auto_crop Interactive: click D2R to select a crop region + validate Check all templates load correctly + cleanup [--yes] Find/remove duplicate assets + batch OP VALUE Batch operation: "resize WxH" or "convert png" + help Show this help + +Examples: + python asset_manager.py inventory + python asset_manager.py audit + python asset_manager.py quality + python asset_manager.py search akara + python asset_manager.py key akara_front + python asset_manager.py capture + python asset_manager.py crop 100 200 50 80 akara_front + python asset_manager.py crop 300 400 100 120 npc_dialogue + python asset_manager.py auto_crop + python asset_manager.py similarity + python asset_manager.py validate + python asset_manager.py cleanup + python asset_manager.py cleanup --yes + python asset_manager.py batch resize 64x64 + +Template naming convention: + - Use lowercase_with_underscores (e.g. akara_front.png) + - Template key is the filename uppercased (e.g. AKARA_FRONT) + - NPC assets go in assets/npc// + - UI templates go in assets/templates/ui/ + - Item templates go in assets/item_properties/ + +Template Finder search paths: +""") + for d in TEMPLATE_DIRS: + print(f" assets/{d}/") + print() + + +def main(): + parser = argparse.ArgumentParser(description='Botty Asset Manager', add_help=False) + parser.add_argument('command', nargs='?', default='help', + help='Command to run') + parser.add_argument('args', nargs='*', help='Command arguments') + parser.add_argument('--yes', action='store_true', help='Confirm destructive actions') + + parsed = parser.parse_args() + cmd = parsed.command.lower() + + if cmd == 'inventory': + cmd_inventory(parsed) + elif cmd == 'search': + cmd_search(parsed) + elif cmd == 'key': + cmd_key(parsed) + elif cmd == 'audit': + cmd_audit(parsed) + elif cmd == 'quality': + cmd_quality(parsed) + elif cmd == 'capture': + cmd_capture(parsed) + elif cmd == 'crop': + if len(parsed.args) < 5: + print(" Usage: python asset_manager.py crop X Y W H NAME") + print(" Example: python asset_manager.py crop 100 200 50 80 akara_front") + return + parsed.x = int(parsed.args[0]) + parsed.y = int(parsed.args[1]) + parsed.w = int(parsed.args[2]) + parsed.h = int(parsed.args[3]) + parsed.name = parsed.args[4] + cmd_crop(parsed) + elif cmd == 'auto_crop': + cmd_auto_crop(parsed) + elif cmd == 'validate': + cmd_validate(parsed) + elif cmd == 'similarity': + cmd_similarity(parsed) + elif cmd == 'cleanup': + cmd_cleanup(parsed) + elif cmd == 'batch': + if len(parsed.args) < 2: + print(" Usage: python asset_manager.py batch OP VALUE") + print(" Example: python asset_manager.py batch resize 64x64") + return + parsed.operation = parsed.args[0] + parsed.value = parsed.args[1] + cmd_batch(parsed) + else: + print_help() + + +if __name__ == "__main__": + main() diff --git a/build.py b/build.py index dc3b1cf..e39dd26 100644 --- a/build.py +++ b/build.py @@ -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}"') diff --git a/config/params.ini b/config/params.ini index 430b2df..626d05b 100644 --- a/config/params.ini +++ b/config/params.ini @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 0de967a..d43fed9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,6 @@ dependencies = [ "transitions", "mss==7.0.1", "numpy==1.26.4", - "mouse", - "keyboard", "beautifultable", "pytweening", "requests", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3cc07dc --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..fe6eec7 --- /dev/null +++ b/ruff.toml @@ -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", +] diff --git a/screenshot_tool.py b/screenshot_tool.py index 691edf9..e2bedbb 100644 --- a/screenshot_tool.py +++ b/screenshot_tool.py @@ -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 diff --git a/src/bot.py b/src/bot.py index 1adcfdc..67aa165 100644 --- a/src/bot.py +++ b/src/bot.py @@ -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 diff --git a/src/char/amazon/amazon.py b/src/char/amazon/amazon.py index 3fd8426..9697957 100644 --- a/src/char/amazon/amazon.py +++ b/src/char/amazon/amazon.py @@ -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 diff --git a/src/char/amazon/javazon.py b/src/char/amazon/javazon.py index a97cfb9..62cd2ca 100644 --- a/src/char/amazon/javazon.py +++ b/src/char/amazon/javazon.py @@ -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 diff --git a/src/char/barbarian.py b/src/char/barbarian.py index 3ccc14a..43e3e43 100644 --- a/src/char/barbarian.py +++ b/src/char/barbarian.py @@ -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 diff --git a/src/char/basic.py b/src/char/basic.py index 4c0b15e..5580b6f 100644 --- a/src/char/basic.py +++ b/src/char/basic.py @@ -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 diff --git a/src/char/basic_ranged.py b/src/char/basic_ranged.py index fb56928..d3fb608 100644 --- a/src/char/basic_ranged.py +++ b/src/char/basic_ranged.py @@ -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)) diff --git a/src/char/bone_necro.py b/src/char/bone_necro.py index ed8b11a..b8e36cc 100644 --- a/src/char/bone_necro.py +++ b/src/char/bone_necro.py @@ -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 diff --git a/src/char/i_char.py b/src/char/i_char.py index d877f37..4ce852c 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -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") diff --git a/src/char/necro.py b/src/char/necro.py index 17f5233..44e4c53 100644 --- a/src/char/necro.py +++ b/src/char/necro.py @@ -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 diff --git a/src/char/paladin/fohdin.py b/src/char/paladin/fohdin.py index 556150b..8ef1d05 100644 --- a/src/char/paladin/fohdin.py +++ b/src/char/paladin/fohdin.py @@ -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 diff --git a/src/char/paladin/hammerdin.py b/src/char/paladin/hammerdin.py index 89a9510..10084ac 100644 --- a/src/char/paladin/hammerdin.py +++ b/src/char/paladin/hammerdin.py @@ -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 diff --git a/src/char/paladin/paladin.py b/src/char/paladin/paladin.py index 653d30f..5d352e0 100644 --- a/src/char/paladin/paladin.py +++ b/src/char/paladin/paladin.py @@ -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 diff --git a/src/char/poison_necro.py b/src/char/poison_necro.py index b262a81..706cebf 100644 --- a/src/char/poison_necro.py +++ b/src/char/poison_necro.py @@ -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 diff --git a/src/char/sorceress/blizz_sorc.py b/src/char/sorceress/blizz_sorc.py index 39cc7bd..5757304 100644 --- a/src/char/sorceress/blizz_sorc.py +++ b/src/char/sorceress/blizz_sorc.py @@ -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)) diff --git a/src/char/sorceress/blizzorb_sorc.py b/src/char/sorceress/blizzorb_sorc.py index 31c84b4..f060915 100644 --- a/src/char/sorceress/blizzorb_sorc.py +++ b/src/char/sorceress/blizzorb_sorc.py @@ -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 diff --git a/src/char/sorceress/hydra_sorc.py b/src/char/sorceress/hydra_sorc.py index dd555ea..94075c1 100644 --- a/src/char/sorceress/hydra_sorc.py +++ b/src/char/sorceress/hydra_sorc.py @@ -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 diff --git a/src/char/sorceress/light_sorc.py b/src/char/sorceress/light_sorc.py index 10abcf1..3c7cde2 100644 --- a/src/char/sorceress/light_sorc.py +++ b/src/char/sorceress/light_sorc.py @@ -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") diff --git a/src/char/sorceress/nova_sorc.py b/src/char/sorceress/nova_sorc.py index ed6668a..c369b60 100644 --- a/src/char/sorceress/nova_sorc.py +++ b/src/char/sorceress/nova_sorc.py @@ -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") diff --git a/src/char/sorceress/sorceress.py b/src/char/sorceress/sorceress.py index 335a528..59568bf 100644 --- a/src/char/sorceress/sorceress.py +++ b/src/char/sorceress/sorceress.py @@ -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 diff --git a/src/char/trapsin.py b/src/char/trapsin.py index cd05991..8d3785e 100644 --- a/src/char/trapsin.py +++ b/src/char/trapsin.py @@ -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 diff --git a/src/char/warlock/abyss_lock.py b/src/char/warlock/abyss_lock.py index 5f77d7f..b69fdc1 100644 --- a/src/char/warlock/abyss_lock.py +++ b/src/char/warlock/abyss_lock.py @@ -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): diff --git a/src/char/warlock/echo_lock.py b/src/char/warlock/echo_lock.py index 2a03c33..53f3467 100644 --- a/src/char/warlock/echo_lock.py +++ b/src/char/warlock/echo_lock.py @@ -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 diff --git a/src/char/warlock/fire_lock.py b/src/char/warlock/fire_lock.py index 07808a2..4884d1c 100644 --- a/src/char/warlock/fire_lock.py +++ b/src/char/warlock/fire_lock.py @@ -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 diff --git a/src/char/warlock/warlock.py b/src/char/warlock/warlock.py index e3f11f7..97bbf22 100644 --- a/src/char/warlock/warlock.py +++ b/src/char/warlock/warlock.py @@ -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 diff --git a/src/chest.py b/src/chest.py index 6a2100f..f321365 100644 --- a/src/chest.py +++ b/src/chest.py @@ -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") diff --git a/src/d2r_image/demo.py b/src/d2r_image/demo.py index 7b0c6d6..45c9d3b 100644 --- a/src/d2r_image/demo.py +++ b/src/d2r_image/demo.py @@ -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 diff --git a/src/d2r_image/processing.py b/src/d2r_image/processing.py index 91d3497..c3627ee 100644 --- a/src/d2r_image/processing.py +++ b/src/d2r_image/processing.py @@ -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 diff --git a/src/d2r_image/processing_helpers.py b/src/d2r_image/processing_helpers.py index 84c7f18..bd17df7 100644 --- a/src/d2r_image/processing_helpers.py +++ b/src/d2r_image/processing_helpers.py @@ -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() diff --git a/src/death_manager.py b/src/death_manager.py index ffb0b70..a422236 100644 --- a/src/death_manager.py +++ b/src/death_manager.py @@ -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() diff --git a/src/game_controller.py b/src/game_controller.py index d298598..4a567ab 100644 --- a/src/game_controller.py +++ b/src/game_controller.py @@ -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): diff --git a/src/game_recovery.py b/src/game_recovery.py index ed41c6a..ce10888 100644 --- a/src/game_recovery.py +++ b/src/game_recovery.py @@ -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") diff --git a/src/gem_transmute.py b/src/gem_transmute.py index 65ca152..e96d3a6 100644 --- a/src/gem_transmute.py +++ b/src/gem_transmute.py @@ -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() diff --git a/src/health_manager.py b/src/health_manager.py index 0f63e32..08f1ae0 100644 --- a/src/health_manager.py +++ b/src/health_manager.py @@ -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 diff --git a/src/input_layer/__init__.py b/src/input_layer/__init__.py new file mode 100644 index 0000000..cfefa8b --- /dev/null +++ b/src/input_layer/__init__.py @@ -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"] diff --git a/src/input_layer/hotkey.py b/src/input_layer/hotkey.py new file mode 100644 index 0000000..d00095b --- /dev/null +++ b/src/input_layer/hotkey.py @@ -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) diff --git a/src/utils/custom_mouse.py b/src/input_layer/mouse_impl.py similarity index 56% rename from src/utils/custom_mouse.py rename to src/input_layer/mouse_impl.py index 77d0630..a56609a 100644 --- a/src/utils/custom_mouse.py +++ b/src/input_layer/mouse_impl.py @@ -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}") diff --git a/src/input_layer/win_input.py b/src/input_layer/win_input.py new file mode 100644 index 0000000..bf508f1 --- /dev/null +++ b/src/input_layer/win_input.py @@ -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) diff --git a/src/inventory/belt.py b/src/inventory/belt.py index 60e8b01..f20f95f 100644 --- a/src/inventory/belt.py +++ b/src/inventory/belt.py @@ -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) diff --git a/src/inventory/common.py b/src/inventory/common.py index e1444b9..b3e9431 100644 --- a/src/inventory/common.py +++ b/src/inventory/common.py @@ -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 diff --git a/src/inventory/personal.py b/src/inventory/personal.py index dfe12ab..135b6aa 100644 --- a/src/inventory/personal.py +++ b/src/inventory/personal.py @@ -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 diff --git a/src/inventory/vendor.py b/src/inventory/vendor.py index c64ec65..de7c432 100644 --- a/src/inventory/vendor.py +++ b/src/inventory/vendor.py @@ -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 diff --git a/src/item/pickit.py b/src/item/pickit.py index 0c33123..790e430 100644 --- a/src/item/pickit.py +++ b/src/item/pickit.py @@ -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 diff --git a/src/main.py b/src/main.py index 571a463..99de8b7 100644 --- a/src/main.py +++ b/src/main.py @@ -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 diff --git a/src/npc_manager.py b/src/npc_manager.py index 6a5e283..baa0a8c 100644 --- a/src/npc_manager.py +++ b/src/npc_manager.py @@ -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) diff --git a/src/pather.py b/src/pather.py index bf71fc6..f043186 100644 --- a/src/pather.py +++ b/src/pather.py @@ -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") diff --git a/src/run/arcane.py b/src/run/arcane.py index 7364dd4..a2a73ea 100644 --- a/src/run/arcane.py +++ b/src/run/arcane.py @@ -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)) diff --git a/src/run/diablo.py b/src/run/diablo.py index 42410b3..cd5cd99 100644 --- a/src/run/diablo.py +++ b/src/run/diablo.py @@ -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)) diff --git a/src/run/level.py b/src/run/level.py index b7bba12..8da422d 100644 --- a/src/run/level.py +++ b/src/run/level.py @@ -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 diff --git a/src/run/vizier.py b/src/run/vizier.py index 3329a0c..2c25125 100644 --- a/src/run/vizier.py +++ b/src/run/vizier.py @@ -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)) diff --git a/src/screen.py b/src/screen.py index 9ed33b0..3413369 100644 --- a/src/screen.py +++ b/src/screen.py @@ -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() diff --git a/src/shop/anya.py b/src/shop/anya.py index 5e7bb41..34f2283 100644 --- a/src/shop/anya.py +++ b/src/shop/anya.py @@ -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) diff --git a/src/shop/drognan.py b/src/shop/drognan.py index 83fea96..fd64c7c 100644 --- a/src/shop/drognan.py +++ b/src/shop/drognan.py @@ -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 diff --git a/src/shopper.py b/src/shopper.py index eeaecaf..94b3742 100644 --- a/src/shopper.py +++ b/src/shopper.py @@ -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() diff --git a/src/target_detect.py b/src/target_detect.py index 49bbca6..7265f05 100644 --- a/src/target_detect.py +++ b/src/target_detect.py @@ -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 diff --git a/src/template_finder.py b/src/template_finder.py index afc1760..d880b90 100644 --- a/src/template_finder.py +++ b/src/template_finder.py @@ -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 diff --git a/src/town/a3.py b/src/town/a3.py index e011618..220cfa5 100644 --- a/src/town/a3.py +++ b/src/town/a3.py @@ -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 diff --git a/src/town/town_manager.py b/src/town/town_manager.py index 3b26818..03b7ad3 100644 --- a/src/town/town_manager.py +++ b/src/town/town_manager.py @@ -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() diff --git a/src/transmute/transmute.py b/src/transmute/transmute.py index 9e37426..11cfd4f 100644 --- a/src/transmute/transmute.py +++ b/src/transmute/transmute.py @@ -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 diff --git a/src/ui/character_select.py b/src/ui/character_select.py index 198604a..30a53f1 100644 --- a/src/ui/character_select.py +++ b/src/ui/character_select.py @@ -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 diff --git a/src/ui/error_screens.py b/src/ui/error_screens.py index 1684407..a678016 100644 --- a/src/ui/error_screens.py +++ b/src/ui/error_screens.py @@ -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") diff --git a/src/ui/loading.py b/src/ui/loading.py index 9ab64f8..835fb34 100644 --- a/src/ui/loading.py +++ b/src/ui/loading.py @@ -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 diff --git a/src/ui/main_menu.py b/src/ui/main_menu.py index 42c63cc..4b6b4b4 100644 --- a/src/ui/main_menu.py +++ b/src/ui/main_menu.py @@ -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 diff --git a/src/ui/player_bar.py b/src/ui/player_bar.py index 05e2d88..b3981a0 100644 --- a/src/ui/player_bar.py +++ b/src/ui/player_bar.py @@ -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 diff --git a/src/ui/skills.py b/src/ui/skills.py index ecaf13d..92c99d2 100644 --- a/src/ui/skills.py +++ b/src/ui/skills.py @@ -1,4 +1,4 @@ -import keyboard +from input_layer import keyboard from logger import Logger import cv2 import time diff --git a/src/ui/view.py b/src/ui/view.py index ff5a537..daf5ea1 100644 --- a/src/ui/view.py +++ b/src/ui/view.py @@ -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() diff --git a/src/ui/waypoint.py b/src/ui/waypoint.py index 091eaf9..dbec699 100644 --- a/src/ui/waypoint.py +++ b/src/ui/waypoint.py @@ -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: diff --git a/src/ui_manager.py b/src/ui_manager.py index e4b88b9..25c9ad2 100644 --- a/src/ui_manager.py +++ b/src/ui_manager.py @@ -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) diff --git a/src/utils/gen_ocr_samples.py b/src/utils/gen_ocr_samples.py index b281b54..c36d8c1 100644 --- a/src/utils/gen_ocr_samples.py +++ b/src/utils/gen_ocr_samples.py @@ -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 diff --git a/src/utils/graphic_debugger.py b/src/utils/graphic_debugger.py index 8c99d53..d80c38b 100644 --- a/src/utils/graphic_debugger.py +++ b/src/utils/graphic_debugger.py @@ -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 diff --git a/src/utils/misc.py b/src/utils/misc.py index 1bef931..2366638 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -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] diff --git a/src/utils/node_recorder.py b/src/utils/node_recorder.py index 31f9247..b1fd146 100644 --- a/src/utils/node_recorder.py +++ b/src/utils/node_recorder.py @@ -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 diff --git a/src/utils/npc_auto_label.py b/src/utils/npc_auto_label.py index 87d8b2c..aecdb4a 100644 --- a/src/utils/npc_auto_label.py +++ b/src/utils/npc_auto_label.py @@ -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() diff --git a/src/utils/restart.py b/src/utils/restart.py index 9c7b3f7..e51ae2c 100644 --- a/src/utils/restart.py +++ b/src/utils/restart.py @@ -1,5 +1,5 @@ import os, sys -import keyboard +from input_layer import keyboard import subprocess import template_finder diff --git a/src/utils/stealth.py b/src/utils/stealth.py index aa4fe95..742eef1 100644 --- a/src/utils/stealth.py +++ b/src/utils/stealth.py @@ -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()