Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c06cd8474 | ||
|
|
e17b8c6c94 | ||
|
|
3597c01b17 | ||
|
|
ee39aa5dbf | ||
|
|
e6ba9ad830 | ||
|
|
48f46fba7f | ||
|
|
bc398c471f | ||
|
|
91e201c5f6 | ||
|
|
74c3050163 | ||
|
|
ce394c2cb6 | ||
|
|
0ce92b4e7c | ||
|
|
3942ad4cf5 | ||
|
|
913eeeff75 | ||
|
|
dfd64b8f4d | ||
|
|
a7e9f4e50f | ||
|
|
1f440cfed2 | ||
|
|
c8e63bc9fa | ||
|
|
fd889a4df7 | ||
|
|
2feb13f44a | ||
|
|
5b386391df | ||
|
|
963b19dbe5 | ||
|
|
93185cb93c | ||
|
|
2162046dac | ||
|
|
55075d2c23 | ||
|
|
83c211bd5f | ||
|
|
31dacab9e3 | ||
|
|
0a4575ea6a | ||
|
|
f0f50a9332 | ||
|
|
2966ba4f7b | ||
|
|
8aa150d6a3 | ||
|
|
371abafa35 | ||
|
|
00e047db98 | ||
|
|
4bdc5b90db | ||
|
|
5211564df4 | ||
|
|
e42211231e | ||
|
|
c3557678d5 | ||
|
|
da83f71bef | ||
|
|
3d153e67f5 | ||
|
|
f70e118f88 | ||
|
|
11186e2c7d | ||
|
|
6ac1105fc7 | ||
|
|
cad7d898a4 | ||
|
|
4de8f6192e | ||
|
|
7763afdcb8 | ||
|
|
c0e5241eb9 | ||
|
|
831e0d9319 | ||
|
|
489f779849 | ||
|
|
402c768fc8 | ||
|
|
2e7517f9b8 | ||
|
|
9325c9cc63 | ||
|
|
8da4c8eb9d | ||
|
|
f01610e53f | ||
|
|
e976749910 | ||
|
|
dfacb12393 | ||
|
|
2402c18e06 | ||
|
|
4a5459ab15 | ||
|
|
a213509a03 | ||
|
|
938554e622 | ||
|
|
b7ce002e4e | ||
|
|
fa72da56e3 | ||
|
|
abe0a54849 | ||
|
|
4db82cfcd6 | ||
|
|
97703acf85 | ||
|
|
5df9ff4732 | ||
|
|
98e12c0e00 | ||
|
|
e9b6c71607 | ||
|
|
c26f249268 | ||
|
|
a014fc538e | ||
|
|
09bfd330e6 | ||
|
|
ce930f2a76 | ||
|
|
c8a6657b0a | ||
|
|
1d8a6657e2 | ||
|
|
81c8d65375 | ||
|
|
28b7c210d8 | ||
|
|
aa48c29df8 | ||
|
|
f1b0f6439c | ||
|
|
cf4c557a26 | ||
|
|
7a7950e752 | ||
|
|
fc9a7bfae1 | ||
|
|
fd82f0ff05 | ||
|
|
394aca35f4 | ||
|
|
e091a3cca4 | ||
|
|
f69d5c01c4 | ||
|
|
14f5876524 | ||
|
|
9e4ef6721a | ||
|
|
c04c60ead7 | ||
|
|
35aa13473e | ||
|
|
e03f866fbb | ||
|
|
968c75fefd | ||
|
|
7839b4ed82 | ||
|
|
75e88dbdc2 | ||
|
|
c5d9fe4541 | ||
|
|
648dc1b25c | ||
|
|
00e759ec0a | ||
|
|
6b044a17ea | ||
|
|
8f9185cde3 | ||
|
|
02e9f7856a | ||
|
|
15524e1608 | ||
|
|
6bcfa1af35 | ||
|
|
523cb54e46 | ||
|
|
7431807ee5 | ||
|
|
40ee587fb0 | ||
|
|
d1ba551832 | ||
|
|
3f8e08296e | ||
|
|
4e730e0c5b | ||
|
|
cef59a7df2 | ||
|
|
78f9d07545 | ||
|
|
eeb620696b | ||
|
|
0c246245c4 | ||
|
|
3d12a75b72 | ||
|
|
7cb15837d4 | ||
|
|
ab3f5633fc | ||
|
|
81f160d400 | ||
|
|
7fd0555468 | ||
|
|
b627172f1e | ||
|
|
512f63e0e5 | ||
|
|
6a07865f43 | ||
|
|
a539d3a236 | ||
|
|
710c1c709a | ||
|
|
61a88d2968 | ||
|
|
c1367dbfbd | ||
|
|
a2e2acfbde | ||
|
|
e8a9cdc6cd |
@@ -1,167 +1,213 @@
|
||||
name: Botty - CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, mine]
|
||||
# Pushing a version tag (e.g. `git tag v0.8.5 && git push --tags`) builds,
|
||||
# smoke-tests, then creates the GitHub release with the zip attached — all
|
||||
# in one run. If the build fails, no release is ever created.
|
||||
tags: ['v*']
|
||||
name: CI
|
||||
|
||||
# Default GITHUB_TOKEN is read-only; the build job's release step needs
|
||||
# contents:write to create the release and attach the built zip
|
||||
# (else HTTP 403 "Resource not accessible by integration").
|
||||
permissions:
|
||||
contents: write
|
||||
on:
|
||||
push:
|
||||
branches: [main, stable]
|
||||
pull_request:
|
||||
branches: [main, stable]
|
||||
|
||||
# Cancel in-progress runs on new push
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
install-and-test:
|
||||
name: Install & Test (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment-win11.yml
|
||||
use-only-tar-bz2: false
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-win11.txt
|
||||
shell: bash
|
||||
|
||||
- name: Install Tesseract OCR
|
||||
run: choco install tesseract --no-progress -y
|
||||
|
||||
- name: Python version
|
||||
shell: powershell
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -c "import sys; print(sys.version)"
|
||||
run: python -c "import sys; print(sys.version)"
|
||||
|
||||
- name: Syntax check
|
||||
shell: powershell
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: python -m compileall -q src tools test scripts
|
||||
|
||||
- name: Verify core imports
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m compileall -q src tools test scripts
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
modules = [
|
||||
'config',
|
||||
'logger',
|
||||
'screen',
|
||||
'pather',
|
||||
'template_finder',
|
||||
'game_stats',
|
||||
'health_manager',
|
||||
'death_manager',
|
||||
'd2r_image',
|
||||
'item',
|
||||
'item.pickit',
|
||||
'transmute',
|
||||
'shop',
|
||||
'char',
|
||||
'utils',
|
||||
'utils.os_detect',
|
||||
'messages',
|
||||
]
|
||||
for mod in modules:
|
||||
try:
|
||||
__import__(mod)
|
||||
print(f' {mod}: OK')
|
||||
except ImportError as e:
|
||||
print(f' {mod}: FAILED - {e}')
|
||||
sys.exit(1)
|
||||
print('All core imports successful.')
|
||||
"
|
||||
|
||||
- name: Tests
|
||||
shell: powershell
|
||||
- name: Verify botty runs (import main modules)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src')
|
||||
import ssl
|
||||
ssl.SSLContext.load_default_certs = lambda *a, **k: None
|
||||
|
||||
from version import __version__
|
||||
print(f' Version: {__version__}')
|
||||
|
||||
from config import Config
|
||||
print(' Config: OK')
|
||||
|
||||
from game_controller import GameController
|
||||
print(' GameController: OK')
|
||||
|
||||
from bot import Bot
|
||||
print(' Bot: OK')
|
||||
|
||||
from run.diablo import Diablo
|
||||
print(' Diablo run: OK')
|
||||
|
||||
from run.pindle import Pindle
|
||||
print(' Pindle run: OK')
|
||||
|
||||
from run.arcane import Arcane
|
||||
print(' Arcane run: OK')
|
||||
|
||||
from run.vizier import Vizier
|
||||
print(' Vizier run: OK')
|
||||
|
||||
print('All botty entry modules import successfully.')
|
||||
"
|
||||
|
||||
- name: Verify OCR (pytesseract)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os, tempfile
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
import pytesseract
|
||||
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
version = pytesseract.get_tesseract_version()
|
||||
print(f' Tesseract version: {version}')
|
||||
|
||||
# Create a simple test image with text
|
||||
img = np.full((50, 200), 255, dtype=np.uint8)
|
||||
cv2.putText(img, 'Hello Botty', (20, 35),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 1)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
||||
cv2.imwrite(f.name, img)
|
||||
text = pytesseract.image_to_string(f.name, lang='eng').strip()
|
||||
print(f' OCR result: {text}')
|
||||
|
||||
print('OCR pytesseract: OK')
|
||||
"
|
||||
|
||||
- name: Verify OCR (botty ocr module)
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
PYTESSERACT_TESSERACT_CMD: C:\Program Files\Tesseract-OCR\tesseract.exe
|
||||
run: |
|
||||
python -c "
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
# Import the botty OCR module - it reads PYTESSERACT_TESSERACT_CMD from env
|
||||
from d2r_image.ocr import image_to_text, pytesseract
|
||||
import cv2
|
||||
import numpy as np
|
||||
import tempfile
|
||||
|
||||
if pytesseract is None:
|
||||
print(' pytesseract not available — skipping')
|
||||
sys.exit(0)
|
||||
|
||||
# Create test image (3-channel for invert)
|
||||
img = np.full((50, 200, 3), 255, dtype=np.uint8)
|
||||
cv2.putText(img, '123', (80, 35),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
|
||||
|
||||
# Run through botty's image_to_text
|
||||
results = image_to_text(
|
||||
[img],
|
||||
model='hover-eng_inconsolata_inv_th_fast',
|
||||
psm=7,
|
||||
crop_pad=False,
|
||||
invert=True,
|
||||
threshold=25,
|
||||
)
|
||||
result_text = results[0].text if results else 'empty'
|
||||
print(f' OCR result: {result_text}')
|
||||
print('OCR botty module: OK')
|
||||
"
|
||||
|
||||
- name: Log analyzer tests (self-healing framework)
|
||||
env:
|
||||
PYTHONPATH: ./src:.
|
||||
RUN_ENV: test
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m coverage run -m pytest -v
|
||||
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
|
||||
|
||||
- name: Coverage report
|
||||
shell: powershell
|
||||
- name: Tests with coverage
|
||||
env:
|
||||
PYTHONPATH: ./src
|
||||
PYTHONPATH: ./src:.
|
||||
RUN_ENV: test
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python -m coverage xml --ignore-errors
|
||||
python -m coverage run -m pytest -v --tb=short --ignore=test/auto/test_self_healing.py --ignore=test/smoke_test.py --ignore=test/test_version_consistency.py
|
||||
python -m coverage xml --ignore-errors || true
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Miniconda Python 3.10
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
activate-environment: botty
|
||||
channel-priority: strict
|
||||
environment-file: environment-win11.yml
|
||||
use-only-tar-bz2: false
|
||||
|
||||
- name: Install Tesseract (bundled into the release for click-and-run OCR)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
choco install tesseract --no-progress -y
|
||||
if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) {
|
||||
throw "Tesseract install did not produce tesseract.exe"
|
||||
}
|
||||
|
||||
- name: Build exe
|
||||
shell: powershell
|
||||
env:
|
||||
BOTTY_NO_RENAME: '1'
|
||||
PYTHONPATH: ./src
|
||||
run: |
|
||||
C:\Miniconda\condabin\conda.bat activate botty
|
||||
python build.py --conda_path C:\Miniconda
|
||||
|
||||
- name: Verify Tesseract was bundled
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
$tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe"
|
||||
if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" }
|
||||
Write-Host "Bundled: $tess"
|
||||
|
||||
- name: Launch smoke test (built executables)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
if (-not $BOTTY_DIR) { throw "No botty_v* build directory found." }
|
||||
|
||||
$mainExe = Join-Path $BOTTY_DIR "main.exe"
|
||||
$shopperExe = Join-Path $BOTTY_DIR "shopper.exe"
|
||||
if (-not (Test-Path $mainExe)) { throw "Missing $mainExe" }
|
||||
if (-not (Test-Path $shopperExe)) { throw "Missing $shopperExe" }
|
||||
|
||||
$procs = @()
|
||||
try {
|
||||
$mainProc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 6
|
||||
if ($mainProc.HasExited) { throw "main.exe exited early with code $($mainProc.ExitCode)" }
|
||||
$procs += $mainProc
|
||||
|
||||
$shopperProc = Start-Process -FilePath $shopperExe -PassThru -WindowStyle Hidden
|
||||
Start-Sleep -Seconds 6
|
||||
if ($shopperProc.HasExited) { throw "shopper.exe exited early with code $($shopperProc.ExitCode)" }
|
||||
$procs += $shopperProc
|
||||
}
|
||||
finally {
|
||||
foreach ($p in $procs) {
|
||||
if ($p -and -not $p.HasExited) {
|
||||
Stop-Process -Id $p.Id -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Prepare release zip
|
||||
shell: powershell
|
||||
run: |
|
||||
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
|
||||
Write-Host "Botty dir: $BOTTY_DIR"
|
||||
Get-ChildItem -Path $BOTTY_DIR -Recurse | Select-Object -Property FullName, Length
|
||||
$ZIP = "${BOTTY_DIR}.zip"
|
||||
Compress-Archive -Path "${BOTTY_DIR}\*" -DestinationPath $ZIP -Force
|
||||
Write-Host "Release zip: $ZIP"
|
||||
Get-Item $ZIP | Select-Object -Property FullName, Length
|
||||
|
||||
- name: Upload build artifacts
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
# Non-blocking: this is a convenience artifact, not a gate. When the account's
|
||||
# artifact storage quota is full the upload fails with
|
||||
# "Failed to CreateArtifact: Artifact storage quota has been hit" and marked the
|
||||
# whole run red even though every test step passed. A quota problem is not a
|
||||
# broken build.
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: botty-build
|
||||
path: botty_v*/
|
||||
retention-days: 7
|
||||
|
||||
# On a version-tag push, create the release (if absent) and attach the
|
||||
# zip atomically. Runs only after the build + smoke test above succeed.
|
||||
- name: Create release and upload zip
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: botty_v*.zip
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
@@ -30,8 +30,29 @@ data/d2jsp_pages/
|
||||
data/d2jsp_topic_urls.txt
|
||||
|
||||
# Debug files
|
||||
debug_*.png
|
||||
debug_forum.html
|
||||
|
||||
# Dev tools (moved to tools/)
|
||||
asset_extractor.py
|
||||
asset_manager.py
|
||||
build.py
|
||||
desktop_snap.py
|
||||
development.md
|
||||
quest_debug.py
|
||||
quest_plan.md
|
||||
quest_screenshot_tool.py
|
||||
references/improvements.md
|
||||
run_asset_extractor.bat
|
||||
run_chipped_gems.bat
|
||||
run_gems_all.bat
|
||||
run_quest_debug.bat
|
||||
screenshot_tool.py
|
||||
start_bot_detached.bat
|
||||
start_botty.ps1
|
||||
test_plan.md
|
||||
update.bat
|
||||
|
||||
# Generated/runtime price data (updated automatically by bot)
|
||||
config/fg_daily_estimates.json
|
||||
config/fg_prices.json
|
||||
@@ -100,3 +121,6 @@ src/input_layer/bridge_input.py
|
||||
.hermes/
|
||||
fixtures/
|
||||
test/run/
|
||||
|
||||
# Installer output (generated by run_install_capture.bat)
|
||||
install_log.txt
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# Botty - Open Issues & TODO Plan
|
||||
|
||||
Generated: 2026-06-01
|
||||
|
||||
---
|
||||
|
||||
## HIGH PRIORITY (affects botting reliability)
|
||||
|
||||
### 1. BNIP transpiler broken code (`src/bnip/transpile.py:369`)
|
||||
- **Status:** `# TODO FIX THIS SHIT` — `remove_quantity()` function is hacky
|
||||
- **Problem:** Expression splitting by `#` is fragile; can corrupt BNIP expressions with multiple `#` delimiters
|
||||
- **Impact:** Pickit rules may silently misparse quantity operators
|
||||
- **Fix:** Rewrite `remove_quantity()` to properly handle `#`-delimited expressions with edge cases
|
||||
- **File:** `src/bnip/transpile.py`
|
||||
|
||||
### 2. FOH cast delay missing (`src/char/paladin/fohdin.py:99`)
|
||||
- **Status:** `# TODO: add delay between FOH casts--doesn't properly cast each FOH in sequence`
|
||||
- **Problem:** FOH casts fire too fast; some casts don't land in sequence
|
||||
- **Impact:** Reduced DPS, wasted FOH rotations
|
||||
- **Fix:** Add `wait()` between FOH casts to ensure each cast completes before next
|
||||
- **File:** `src/char/paladin/fohdin.py`
|
||||
|
||||
### 3. Chest opening telekinesis workaround (`src/chest.py:51`)
|
||||
- **Status:** `# TODO: Act as picking up a potion to support telekinesis`
|
||||
- **Problem:** Chest open simulates potion pickup to work around telekinesis skill
|
||||
- **Impact:** Fragile interaction; may break with game updates
|
||||
- **Fix:** Implement proper chest interaction that accounts for telekinesis
|
||||
- **File:** `src/chest.py`
|
||||
|
||||
### 4. Inventory full handling (`src/item/pickit.py:236`)
|
||||
- **Status:** `#TODO Create logic to handle inventory full`
|
||||
- **Problem:** When inventory fills, pickit just stops — doesn't try to stash, sell, or prioritize
|
||||
- **Impact:** Bot stops picking up items mid-run; lost gold/runes
|
||||
- **Fix:** Add fallback logic: stop picking, trigger town run to stash/sell
|
||||
- **File:** `src/item/pickit.py`
|
||||
|
||||
### 5. Overburdened handling (`src/ui/view.py:109`)
|
||||
- **Status:** `#TODO: handle "Overburdened"`
|
||||
- **Problem:** `pickup_corpse()` doesn't detect "Overburdened" state after clicking
|
||||
- **Impact:** Bot may get stuck trying to pickup corpse when overweight
|
||||
- **Fix:** Add template detection for "Overburdened" UI and handle gracefully
|
||||
- **File:** `src/ui/view.py`
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM PRIORITY (code quality / edge cases)
|
||||
|
||||
### 6. BNIP parenthesis cross-section check (`src/bnip/transpile.py:199`)
|
||||
- **Status:** `# TODO Backtrace until the last opening to make sure it wasn't from the past section.`
|
||||
- **Problem:** Parenthesis validation doesn't catch `(` in one BNIP section and `)` in another
|
||||
- **Impact:** Silent BNIP syntax errors that pass validation
|
||||
- **Fix:** Implement backtrace to reject cross-section parentheses
|
||||
- **File:** `src/bnip/transpile.py`
|
||||
|
||||
### 7. BNIP lexer misplaced checks (`src/bnip/lexer.py:319`)
|
||||
- **Status:** `# TODO: The second checks seem a little misplaced`
|
||||
- **Problem:** `NTIPAliasClass` and `TokenType.CLASS:` checks should be in transpiler validation, not lexer
|
||||
- **Impact:** Code organization; potential missed validation
|
||||
- **Fix:** Move validation logic to `transpile.py` and emit warnings
|
||||
- **File:** `src/bnip/lexer.py`, `src/bnip/transpile.py`
|
||||
|
||||
### 8. BNIP actions error handling (`src/bnip/actions.py:209`)
|
||||
- **Status:** `# TODO look at these errors`
|
||||
- **Problem:** BNIP load errors are printed but not properly logged or categorized
|
||||
- **Impact:** Hard to diagnose BNIP parse failures
|
||||
- **Fix:** Replace `print()` with proper `Logger.error()` and structured error reporting
|
||||
- **File:** `src/bnip/actions.py`
|
||||
|
||||
### 9. Pickit return type (`src/item/pickit.py:165`)
|
||||
- **Status:** `TODO :return: return a list of the items that were picked up`
|
||||
- **Problem:** Docstring says it should return a list, but function returns `bool`
|
||||
- **Impact:** Inconsistent API; callers can't know what was actually picked
|
||||
- **Fix:** Return `list[Item]` of picked items instead of `bool`
|
||||
- **File:** `src/item/pickit.py`
|
||||
|
||||
### 10. Consumable auto-belt (`src/inventory/personal.py:350`)
|
||||
- **Status:** `# TODO: logic for trying to add potion to belt if there are needs`
|
||||
- **Problem:** Consumables found during inventory management aren't auto-added to belt
|
||||
- **Impact:** Bot doesn't restock belt potions from inventory during runs
|
||||
- **Fix:** Add logic to detect belt needs and move potions from inventory
|
||||
- **File:** `src/inventory/personal.py`
|
||||
|
||||
### 11. Merc blocking templates (`src/run/nihlathak.py:45`)
|
||||
- **Status:** `# TODO: We might need a second template for each option as merc might run into the template`
|
||||
- **Problem:** Merc can stand on template match location, causing detection failure
|
||||
- **Impact:** Nihlathak run fails to detect layout variant
|
||||
- **Fix:** Add backup templates with offset ROIs for each layout variant
|
||||
- **File:** `src/run/nihlathak.py`
|
||||
|
||||
---
|
||||
|
||||
## LOW PRIORITY (cleanup / refactoring)
|
||||
|
||||
### 12. Character select cleanup (`src/ui/character_select.py:109`)
|
||||
- **Status:** `# TODO: can cleanup logic here, can we utilize a generic ScreenObject or use custom locator?`
|
||||
- **Problem:** Character selection uses ad-hoc template search instead of reusable ScreenObject
|
||||
- **Fix:** Refactor to use `ScreenObjects` pattern
|
||||
- **File:** `src/ui/character_select.py`
|
||||
|
||||
### 13. Screen utility functions (`src/screen.py:104`)
|
||||
- **Status:** `# TODO: Move the below funcs to utils(?)`
|
||||
- **Problem:** `convert_monitor_to_screen()` and related functions live in `screen.py` but could be in utils
|
||||
- **Fix:** Move coordinate conversion functions to `src/utils/`
|
||||
- **File:** `src/screen.py`
|
||||
|
||||
### 14. Graphic debugger re-init (`src/utils/graphic_debugger.py:60`)
|
||||
- **Status:** `# TODO: these two layers variable needs to be reassigned because F10 will not re-init`
|
||||
- **Problem:** Debugger layers don't reinitialize properly on F10 toggle
|
||||
- **Fix:** Move layer state into controller class; reinit on stop/start
|
||||
- **File:** `src/utils/graphic_debugger.py`
|
||||
|
||||
### 15. mttkinter logging (`src/utils/mttkinter.py:62-63`)
|
||||
- **Status:** `# TODO: Replace custom logging functionality with standard logging.Logger`
|
||||
- **Problem:** Custom logging in tkinter utils instead of standard library
|
||||
- **Fix:** Replace with `logging.Logger`
|
||||
- **File:** `src/utils/mttkinter.py`
|
||||
|
||||
### 16. Pickit test note (`test/nip/keep_item_test_cases.py:978`)
|
||||
- **Status:** `# TODO: I had to change from [defense] >= 47 to [plusdefense] >= 47`
|
||||
- **Problem:** `[defense]` is a calculated property; `[plusdefense]` is the raw value. Note for future reference.
|
||||
- **Fix:** Document in BNIP docs that `[defense]` is calculated; `[plusdefense]` is the raw modifier
|
||||
- **File:** `test/nip/keep_item_test_cases.py`
|
||||
|
||||
### 17. New route scaffolding (`src/utils/new_route.py`)
|
||||
- **Status:** Multiple TODO placeholders (by design — it's a code generator template)
|
||||
- **Problem:** Template placeholders are intentional; not bugs
|
||||
- **Fix:** No action needed — these are scaffolding placeholders
|
||||
- **File:** `src/utils/new_route.py`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Priority | Count | Files |
|
||||
|----------|-------|-------|
|
||||
| HIGH | 5 | transpile.py, fohdin.py, chest.py, pickit.py, view.py |
|
||||
| MEDIUM | 6 | transpile.py, lexer.py, actions.py, pickit.py, personal.py, nihlathak.py |
|
||||
| LOW | 6 | character_select.py, screen.py, graphic_debugger.py, mttkinter.py, keep_item_test_cases.py, new_route.py |
|
||||
|
||||
**Total: 17 items across 13 files**
|
||||
@@ -1,87 +0,0 @@
|
||||
# Botty Improvements Implementation Plan
|
||||
|
||||
## Status Legend
|
||||
- [ ] Not started
|
||||
- [~] In progress
|
||||
- [x] Done
|
||||
- [-] Cancelled / low priority
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Key Auto-Detection (Issue #940)
|
||||
Read D2R .key file and auto-fill hotkeys.
|
||||
|
||||
- [x] Create src/utils/key_detector.py module
|
||||
- [x] VK code mapping (partial — needs review for accuracy)
|
||||
- [x] Parse .key file (text format: VK action_type param)
|
||||
- [x] Auto-fill empty [char] hotkeys from detected bindings
|
||||
- [x] Auto-fill build-specific skill hotkeys (fohdin, hammerdin, etc.)
|
||||
- [x] Wire into config.py load_data()
|
||||
- [ ] REVIEW: Verify VK_MAP accuracy (D2R uses its own VK offset scheme)
|
||||
- [ ] REVIEW: Skill slot-to-config matching is heuristic — may misassign
|
||||
- [ ] TEST: Verify against actual D2R .key file on user's machine
|
||||
|
||||
## Phase 2: Target Detection False Positives (Issues #959/#964)
|
||||
Health bars and "immune to X" text mistaken for targets.
|
||||
|
||||
- [ ] Analyze current get_visible_targets() in target_detect.py
|
||||
- [ ] Add shape/size filtering: health bars are thin horizontal strips, immune text is small
|
||||
- [ ] Add aspect ratio check: real targets (poison/freeze auras) are roughly circular/elliptical
|
||||
- [ ] Add minimum bounding box height constraint (filter out thin text)
|
||||
- [ ] Optionally: add color temperature check (immune text is yellow/gold, not blue/green)
|
||||
- [ ] Test with screenshots of edge cases
|
||||
|
||||
## Phase 3: Pickit Timing Fix (Issue #939)
|
||||
Items skipped because bot teleports away before grabbing.
|
||||
|
||||
- [ ] Review pickit.py _yoink_item() for timing issues
|
||||
- [ ] Add configurable pickup_delay parameter (current: fixed timing)
|
||||
- [ ] Add retry logic: if item still visible after pickup attempt, re-try
|
||||
- [ ] Add "slow mode" for large/heavy items (framed/magic items may animate longer)
|
||||
- [ ] Ensure bot doesn't teleport until pickup animation completes
|
||||
- [ ] Test: verify no "Attempt to pick xyz" warnings followed by teleport
|
||||
|
||||
## Phase 4: Parallel Template Search (Issue #848)
|
||||
Speed up template_finder.search() with threading.
|
||||
|
||||
- [ ] Add ThreadPoolExecutor-based search_all_parallel()
|
||||
- [ ] Keep existing search() for single-template (no overhead)
|
||||
- [ ] Only parallelize when searching >3 templates simultaneously
|
||||
- [ ] Benchmark: measure speedup on typical 1280x720 grab
|
||||
|
||||
## Phase 5: Async Mouse Moves (Issue #955)
|
||||
Non-blocking mouse movement.
|
||||
|
||||
- [ ] Add async_move() to utils/custom_mouse.py
|
||||
- [ ] Run movement in background thread
|
||||
- [ ] Add is_moving() / wait_for_move() synchronization
|
||||
- [ ] Integrate into game_controller.py for smoother action chains
|
||||
|
||||
## Phase 6: Hardcore Chicken Loop Fix (Issue #942)
|
||||
Prevent infinite death loops on Hardcore characters.
|
||||
|
||||
- [ ] Review death_manager.py chicken logic
|
||||
- [ ] Add max_chicken_count config parameter (default: 3)
|
||||
- [ ] If max chicken count exceeded on HC, exit gracefully instead of re-entering
|
||||
- [ ] Add defensive chicken config option (chicken to TP instead of full chicken)
|
||||
- [ ] Test: verify HC character exits cleanly after N deaths
|
||||
|
||||
## Phase 7: Auto-Label NPCs (Issue #950)
|
||||
Learn vendor identities automatically during gameplay.
|
||||
|
||||
- [ ] During town states, detect NPC name plates via OCR
|
||||
- [ ] Cross-reference detected names with known NPC list
|
||||
- [ ] Auto-capture NPC templates when confidence is high
|
||||
- [ ] Store learned templates in assets/npc/
|
||||
- [ ] This is a long-term feature — lower priority
|
||||
|
||||
---
|
||||
|
||||
## Priority Order (implement in this order)
|
||||
1. **Phase 1** - Key auto-detection (already partially done, needs review + test)
|
||||
2. **Phase 3** - Pickit timing (high impact on loot collection)
|
||||
3. **Phase 2** - Target detection (high impact on kill reliability)
|
||||
4. **Phase 6** - Hardcore chicken fix (safety critical)
|
||||
5. **Phase 4** - Parallel template search (performance)
|
||||
6. **Phase 5** - Async mouse moves (quality of life)
|
||||
7. **Phase 7** - Auto-label NPCs (long-term feature)
|
||||
@@ -1,114 +0,0 @@
|
||||
# Dia-run test #2 — session state & plan (2026-06-11 ~20:45)
|
||||
|
||||
## OUTCOME (21:29) — ALL FIXES VERIFIED ✅
|
||||
Full Diablo run completed with every fix live: game 1 (21:14 session) ran Pindle (45s, clean,
|
||||
no BC double-swap... BC retried once — template marginal, see below) then run_diablo SUCCEEDED
|
||||
(21:17:28→21:29:04): A5 WP found FIRST TRY at new 0.62 threshold (was 0-for-5 before),
|
||||
vendor trip skipped by gating, CS layout matched 91.5%, all seals, Diablo killed, game ended
|
||||
clean at 872s. Implemented beyond the original plan: quick-mode for open_wp (failure 1 → direct
|
||||
path only on next call; failure 2 → instant fail), sweep 10→6 steps, select timeout 4s, and
|
||||
A5 threshold drops (stash 0.60/0.45, WP 0.62 — safe because success_func gates every click).
|
||||
Remaining known marginals (non-blocking): BC/BO skill icon template (1 extra swap ~2s, 2/3 games),
|
||||
A5_TOWN_0/1 town markers (detect_current_act warns but soft-falls-back), npc body templates
|
||||
(Larzuk/Cain flaky, fallbacks work), numpy truthiness bug in missing-template debug screenshot
|
||||
helper, inventory-full pickit skips until next successful stash. Char left at char-select/lobby,
|
||||
D2R running, no bot processes.
|
||||
|
||||
## Goal
|
||||
User asked: "trigger a full dia run and monitor it for loops and mistakes". A full Diablo run
|
||||
(WP → ROF → CS → 3 seals → kill) must complete while logging all loops/mistakes, then deliver
|
||||
an analysis report.
|
||||
|
||||
## Current state
|
||||
- Bot was F12-stopped at 20:37 (was stuck in A5_WP search loop, game 3, char wandered to town wall).
|
||||
- D2R is OPEN, character "fistman" is IN the stuck game with the **Options→Video menu open**.
|
||||
- Next immediate steps: Esc out of options, click SAVE AND EXIT at **physical (650, 424)**,
|
||||
relaunch bot, F11, re-arm monitor, wait for a full dia run (stealth may randomly skip runs).
|
||||
- After run completes: F12 stop, kill leftover `cmd`/`pwsh` with `run_botty` in commandline,
|
||||
delete `log/_*.png` and `log/_run2_out.txt` scratch files, deliver findings report.
|
||||
|
||||
## How to drive (hard-won specifics)
|
||||
- Display is 1920x1200 physical, 125% scaling (1536x960 logical). **Use the botty env python**
|
||||
(`C:\ProgramData\miniforge3\envs\botty\python.exe`) with `ctypes.windll.user32.SetProcessDPIAware()`
|
||||
+ `src/input_layer/win_input.py` `mouse_move/mouse_click/mouse_wheel` for clicks (physical coords;
|
||||
cwd must be C:\Users\alex\my-botty with sys.path.insert(0,"src")).
|
||||
PowerShell `SetCursorPos`/`mouse_event` are DPI-virtualized → clicks land 1.25x off — do NOT use.
|
||||
- Click into D2R twice (first click only activates the window).
|
||||
- F11/F12 hotkeys work via `keybd_event` from anywhere (GetAsyncKeyState polling).
|
||||
- Screenshots: PIL `ImageGrab.grab(all_screens=True)` in the DPI-aware python = physical pixels.
|
||||
- Launch: `cmd /c C:\Users\alex\my-botty\run_botty.bat *> log\_run2_out.txt` (PowerShell bg task).
|
||||
- Watch `log/stats/events_*.jsonl` (newest) + `log/log.txt`.
|
||||
|
||||
## Findings so far for the final report (test #2, started 20:23)
|
||||
1. **A5_WP selection loop (CRITICAL, 3/3 occurrences after Pindle returns)**: every A5 WP open
|
||||
after a Pindle run fails first try ("Wanted to select A5_WP"); anchor retries (qual_kehk, malah)
|
||||
sometimes recover (~25s cost), but in game 3 (~20:30) ALL anchors + directed sweep failed,
|
||||
char wandered to the town wall off all pather nodes, looped 15+ times until manual intervention.
|
||||
Hypothesis: after Pindle TP return, pather position estimate is wrong; traverses compound the error.
|
||||
2. **NPC detection failures**: Cain (A4) timed out → fell back to A5 Cain (worked); Tyrael resurrect
|
||||
timed out once, retry worked. Town maintenance took ~3 min in game 1 due to these.
|
||||
3. **Battle Command prebuff retry fired 3/3 games** ("Failed to find Battle Command, swapping
|
||||
weapons again") — CTA buff icon detection systematically needs a second swap.
|
||||
4. **Mouse misses (relative mode)**: ~6 occurrences, 12-80px off, all self-corrected via SetCursorPos
|
||||
retry (win_input fallback working as designed).
|
||||
5. **Player chicken at Pindle** game 2 (HP 37.2%, 59s game) — survivability, not logic.
|
||||
6. **Stealth random skip** skipped Diablo in game 1 — by design but reduces dia throughput.
|
||||
7. **D2R settings now verified correct** (in-game screenshots 20:42): DLSS OFF, 1280x720 windowed,
|
||||
texture HIGH, details LOW, AA/AO off → matches assets/d2r_settings.json (startup warning gone).
|
||||
Previous session's CS template failures should be fixed; pentagram matched 95% last session.
|
||||
8. Earlier fixes this session (all verified live): hotkey.wait() no-arg blocking bug, edge-triggered
|
||||
hotkeys, OCR tesseract_cmd wiring, NipSyntaxError→BNipSyntaxError + Schaefershammer typo.
|
||||
|
||||
## PERMANENT FIX PLAN (user-approved direction: fix properly, prefer smarter designs)
|
||||
|
||||
### Fix 1 — A5_WP loop: fail-fast + fresh game (CRITICAL, the 10-min wander)
|
||||
`a5.py:open_wp` already has 3 escalation layers (direct path → 3 anchors → directed sweep,
|
||||
~3.5 min total). The death loop is the OUTER chain: `bot.on_maintenance` retry sites call
|
||||
`buy_consumables`/`go_to_act` again → `town_manager.open_wp` again → full 3.5-min escalation
|
||||
again, from an ever-worse position estimate. Each failed cycle compounds.
|
||||
**Smart fix:** position estimates can't be trusted after a failure, but a NEW GAME gives a
|
||||
guaranteed-known spawn in ~40s. Add a per-game WP-failure budget on the `Bot` instance:
|
||||
- `self._wp_fail_count` reset in `on_init`; `town_manager.open_wp` failure increments it
|
||||
(thread the signal via return or a callback).
|
||||
- In `on_maintenance`/`on_end_run`: if `_wp_fail_count >= 2` → `_save_error_screenshot` +
|
||||
`trigger_or_stop("end_game", failed=True)` immediately. No more wandering retries.
|
||||
- Also cap `a5.open_wp` layer 3 (sweep) to run only on the FIRST failure per game; subsequent
|
||||
calls in the same game go straight to fail (the sweep from an unknown spot is what walked the
|
||||
char onto the town wall).
|
||||
|
||||
### Fix 2 — same family: A5_RED_PORTAL first-click miss (Pindle approach, seen 20:47)
|
||||
Same position-estimate root cause, already has a "retry from town start" recovery that works.
|
||||
Include its failure in the same per-game budget rather than new mechanisms.
|
||||
|
||||
### Fix 3 — reduce A5→A4 Jamella trips (exposure reduction, smarter)
|
||||
`buy_consumables: in A5 — traveling to A4 Jamella (Malah unreliable)` runs every game even when
|
||||
only selling 1-2 junk items. Gate the trip: only travel to A4 if (pots needed below threshold)
|
||||
OR (tp/id tomes low) OR (inventory has >N sell items). Selling junk can wait; stash is in A5.
|
||||
Fewer WP trips = fewer chances to hit Fix-1 territory.
|
||||
|
||||
### Fix 4 — Battle Command prebuff double-swap (3/3 games)
|
||||
`Failed to find Battle Command, swapping weapons again` every game. The buff check runs too
|
||||
soon after weapon swap (buff icons fade in). In the prebuff code (char/hammerdin.py or
|
||||
i_char.pre_buff): add ~0.4-0.6s wait after CTA casts before checking the buff bar, and lower
|
||||
the BC icon threshold slightly (capture shows icons render fine). Saves a full swap cycle/game.
|
||||
|
||||
### Fix 5 — Cain ID: sticky act preference
|
||||
A4 Cain timed out (20s wasted) then A5 Cain worked. Cache `self._last_good_cain_act` on Bot;
|
||||
try that act first next game. One-line behavioral memory, halves ID time after first game.
|
||||
|
||||
### Fix 6 — leave as-is (verified fine)
|
||||
- Mouse misses: ~6/session, all self-corrected by SetCursorPos retry (stealth Bezier primary
|
||||
path is intentional). No change.
|
||||
- Stealth random run skip: by design.
|
||||
- Pindle chicken @ 37% HP: gear/survivability, not code. Mention to user only.
|
||||
|
||||
### Verification after implementing
|
||||
- Unit-light: run 3+ games (`run_pindle`+`run_diablo`), grep log for: no second consecutive
|
||||
`Wanted to select A5_WP` burst per game; `Battle Command` retry absent; Jamella trip skipped
|
||||
when nothing needed; failed-WP game ends < 90s instead of 900s timeout.
|
||||
|
||||
## Stats so far (test #2)
|
||||
- Game 1: Pindle OK (46s) + Diablo stealth-skipped. Maintenance ~3min (Cain/Tyrael/A5_WP issues).
|
||||
- Game 2: Pindle chicken @ HP 37% (59s, failed).
|
||||
- Game 3: Pindle OK (43s), then A5_WP loop before Diablo → manually stopped 20:37.
|
||||
- Diablo run not yet completed in test #2.
|
||||
@@ -1,561 +0,0 @@
|
||||
# Quest Framework + Den of Evil Plan
|
||||
|
||||
## Goal
|
||||
Build a quest automation framework in botty that can interact with D2R NPCs, handle dialogue,
|
||||
track quest progress, and run Den of Evil as the first quest -- all usable by a low-level FoHdin.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The quest framework is a new subsystem that plugs into the existing botty state machine.
|
||||
It follows the same patterns as existing runs (approach -> battle -> return to town) but adds
|
||||
NPC dialogue interaction and quest state persistence.
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
src/quest/
|
||||
__init__.py # Exports
|
||||
quest_manager.py # Quest state machine + persistence (JSON)
|
||||
quest_dialogue.py # OCR-based NPC dialogue interaction
|
||||
quest_items.py # Quest item detection/pickup
|
||||
quest_combat.py # Lightweight combat wrapper (killing trash)
|
||||
a1/
|
||||
__init__.py
|
||||
q_den_of_evil.py # Den of Evil run
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
```
|
||||
src/npc_manager.py # Add TOWN_MAIDEN NPC constant + templates
|
||||
src/pather.py # Add A1_ROARING_CANYON + DoE entrance locations
|
||||
src/bot.py # Add quest state, transitions, handler
|
||||
src/run/__init__.py # Export DenOfEvil
|
||||
src/town/a1.py # (optional) Add can_do_den_of_evil method
|
||||
config/params.ini # Add run_doe to [routes]
|
||||
config/bnip/ # Add town_maiden.png template
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation
|
||||
|
||||
### 1.1 `src/quest/quest_manager.py`
|
||||
|
||||
Purpose: Track which quests are done, persist between sessions, dispatch to quest modules.
|
||||
|
||||
```python
|
||||
class QuestManager:
|
||||
"""Manages quest state: tracks done/available quests per act, persists to JSON."""
|
||||
|
||||
# Quest definitions per act
|
||||
QUESTS = {
|
||||
"a1": ["den_of_evil"],
|
||||
"a2": [], # future: radament, horadric_staff, etc.
|
||||
...
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._state_file = "config/quest_state.json"
|
||||
self._state = self._load()
|
||||
|
||||
def is_done(self, quest_name: str) -> bool:
|
||||
return self._state.get(quest_name, False)
|
||||
|
||||
def mark_done(self, quest_name: str):
|
||||
self._state[quest_name] = True
|
||||
self._save()
|
||||
|
||||
def mark_all_done(self, act: str):
|
||||
for q in self.QUESTS.get(act, []):
|
||||
self._state[q] = True
|
||||
self._save()
|
||||
|
||||
def next_pending(self, act: str) -> str | None:
|
||||
for q in self.QUESTS.get(act, []):
|
||||
if not self.is_done(q):
|
||||
return q
|
||||
return None
|
||||
|
||||
def all_done(self, act: str) -> bool:
|
||||
return all(self._state.get(q, False) for q in self.QUESTS.get(act, []))
|
||||
|
||||
def _load(self) -> dict:
|
||||
if os.path.exists(self._state_file):
|
||||
with open(self._state_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
with open(self._state_file, "w") as f:
|
||||
json.dump(self._state, f, indent=2)
|
||||
```
|
||||
|
||||
JSON format (config/quest_state.json):
|
||||
```json
|
||||
{
|
||||
"den_of_evil": true,
|
||||
"search_for_smith": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 `src/quest/quest_dialogue.py`
|
||||
|
||||
Purpose: Talk to NPCs, read dialogue options via OCR, click the right branch.
|
||||
This is the core of quest automation -- it makes the bot "converse" with NPCs.
|
||||
|
||||
```python
|
||||
class QuestDialogue:
|
||||
"""OCR-based NPC dialogue interaction for quest conversations."""
|
||||
|
||||
# ROI at 1280x720
|
||||
DIALOGUE_TEXT_ROI = (200, 470, 680, 100) # NPC speech text
|
||||
DIALOGUE_OPTIONS_ROI = (200, 560, 680, 140) # Player response buttons
|
||||
DIALOGUE_CLOSE_Y = 670 # Close button area
|
||||
|
||||
@staticmethod
|
||||
def open_dialogue(npc_name: str) -> bool:
|
||||
"""Walk to NPC and open their dialogue menu."""
|
||||
from npc_manager import Npc, open_npc_menu
|
||||
return open_npc_menu(getattr(Npc, npc_name.upper()))
|
||||
|
||||
@staticmethod
|
||||
def read_dialogue() -> dict:
|
||||
"""OCR the current dialogue box. Returns:
|
||||
{
|
||||
'npc_text': str, # What the NPC said
|
||||
'options': [str, ...], # Response options (may be empty if no choice)
|
||||
'has_continue': bool # True if just need to click continue
|
||||
}
|
||||
"""
|
||||
img = grab()
|
||||
npc_text = ocr_roi(img, self.DIALOGUE_TEXT_ROI)
|
||||
options_text = ocr_roi(img, self.DIALOGUE_OPTIONS_ROI)
|
||||
# Parse options: split by line, filter out empty, return list
|
||||
options = [line.strip() for line in options_text.split('\n') if line.strip()]
|
||||
has_continue = len(options) == 0 or "continue" in options_text.lower()
|
||||
return {
|
||||
'npc_text': npc_text.strip(),
|
||||
'options': options,
|
||||
'has_continue': has_continue
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def click_option(option_text: str) -> bool:
|
||||
"""Find and click a specific dialogue option by matching text via OCR.
|
||||
Searches the options ROI for a template match of the option text."""
|
||||
img = grab()
|
||||
options_img = cut_roi(img, self.DIALOGUE_OPTIONS_ROI)
|
||||
# Use template_finder or OCR to locate which button matches
|
||||
# Then click at that position
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def continue_dialogue() -> bool:
|
||||
"""Click the close/continue button to advance dialogue."""
|
||||
# Click in the close button area
|
||||
x, y, w, h = self.DIALOGUE_CLOSE_Y
|
||||
mouse.click at center of close area
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def follow_conversation(expected_options: list[str]) -> bool:
|
||||
"""Follow a multi-step conversation:
|
||||
- Read NPC text
|
||||
- If options present, click the expected one
|
||||
- If no options, click continue
|
||||
- Repeat until dialogue closes or unexpected text appears
|
||||
"""
|
||||
max_steps = 20 # Safety limit
|
||||
for i in range(max_steps):
|
||||
dialogue = self.read_dialogue()
|
||||
if not dialogue['has_continue'] and dialogue['options']:
|
||||
# We have a choice - click the expected option
|
||||
for opt in expected_options:
|
||||
if opt.lower() in ' '.join(dialogue['options']).lower():
|
||||
if not self.click_option(opt):
|
||||
return False
|
||||
break
|
||||
else:
|
||||
Logger.warning(f"Unexpected dialogue options: {dialogue['options']}")
|
||||
return False
|
||||
else:
|
||||
# Just continue
|
||||
if not self.continue_dialogue():
|
||||
return False
|
||||
wait(1.0, 1.5)
|
||||
# Check if dialogue box is still visible
|
||||
if not is_visible(ScreenObjects.NPCDialogue):
|
||||
return True # Done
|
||||
return False # Hit max steps
|
||||
```
|
||||
|
||||
Key design: `follow_conversation()` takes a list of expected response text. It will
|
||||
match against whatever options the NPC presents and click the right one. This handles
|
||||
multi-branch dialogues without hardcoding step-by-step clicks.
|
||||
|
||||
### 1.3 `src/quest/quest_combat.py`
|
||||
|
||||
Purpose: Lightweight combat for clearing trash during quests. Reuses existing char methods.
|
||||
|
||||
```python
|
||||
class QuestCombat:
|
||||
"""Combat helpers for quest areas -- reuses existing character combat logic."""
|
||||
|
||||
@staticmethod
|
||||
def clear_area(pather: Pather, char: IChar, path_nodes: list[int],
|
||||
timeout: float = 60) -> bool:
|
||||
"""Walk a path while killing monsters until timeout or all nodes cleared.
|
||||
This is the core of DoE: walk down, kill, walk back."""
|
||||
return pather.traverse_nodes(path_nodes, char, timeout=timeout, do_combat=True)
|
||||
|
||||
@staticmethod
|
||||
def wait_for_clear(char: IChar, timeout: float = 15) -> bool:
|
||||
"""Wait until no monsters are visible (area is clear)."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
targets = get_visible_targets()
|
||||
if not targets or len(targets) == 0:
|
||||
return True
|
||||
# Attack if enemies present
|
||||
char.attack()
|
||||
wait(0.5)
|
||||
return False
|
||||
```
|
||||
|
||||
### 1.4 `src/quest/quest_items.py`
|
||||
|
||||
Purpose: Detect and pick up quest items (gold glow detection).
|
||||
|
||||
```python
|
||||
class QuestItems:
|
||||
"""Quest item detection and management."""
|
||||
|
||||
@staticmethod
|
||||
def detect_quest_items(img: np.ndarray) -> list[tuple[float, float]]:
|
||||
"""Detect gold-glowing items on screen (quest items).
|
||||
Returns list of (x, y) positions in monitor coords."""
|
||||
quest_item_mask, _ = color_filter(img, Config().colors.get("gold_glow", [
|
||||
(180, 140, 0), (255, 220, 80)
|
||||
]))
|
||||
# Find contours, return centers
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def pick_up_quest_items(char: IChar, img: np.ndarray = None) -> bool:
|
||||
"""Find and pick up any quest items currently visible."""
|
||||
if img is None:
|
||||
img = grab()
|
||||
items = self.detect_quest_items(img)
|
||||
for pos in items:
|
||||
char.pick_up_item(pos, item_name="Quest Item")
|
||||
wait(0.5)
|
||||
return len(items) > 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: NPC & Location additions
|
||||
|
||||
### 2.1 Add Town_Maiden to `src/npc_manager.py`
|
||||
|
||||
```python
|
||||
# In class Npc:
|
||||
TOWN_MAIDEN = "town_maiden" # Act 1, Roaring Canyon
|
||||
|
||||
# In _build_npcs():
|
||||
Npc.TOWN_MAIDEN: {
|
||||
"head": "town_maiden.png", # Need to capture template
|
||||
"actions": {} # No trade/identify - just dialogue
|
||||
}
|
||||
```
|
||||
|
||||
The Town Maiden sits in Roaring Canyon (eastern part of Act 1 town). She has a simple
|
||||
dialogue: you talk to her to "unlock" the Den of Evil entrance, then you talk to her
|
||||
again after clearing it to get the XP reward and reset it for another run.
|
||||
|
||||
### 2.2 Add locations to `src/pather.py`
|
||||
|
||||
```python
|
||||
class Location:
|
||||
# ... existing locations ...
|
||||
|
||||
# Act 1 Roaring Canyon / Den of Evil
|
||||
A1_ROARING_CANYON = "a1_roaring_canyon" # Town area where Maiden is
|
||||
A1_DEN_OF_EVIL_ENTRANCE = "a1_doe_entrance" # Stairs down to DoE
|
||||
A1_DEN_LEVEL_1 = "a1_doe_level_1"
|
||||
A1_DEN_LEVEL_2 = "a1_doe_level_2"
|
||||
A1_DEN_LEVEL_3 = "a1_doe_level_3"
|
||||
A1_DEN_LEVEL_4 = "a1_doe_level_4"
|
||||
# (DoE has 3-5 levels depending on game version - need to confirm)
|
||||
```
|
||||
|
||||
Path nodes will need to be added for the Roaring Canyon area and each DoE level.
|
||||
These are captured via quest_debug.py by walking the path and recording waypoints.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Den of Evil run module
|
||||
|
||||
### 3.1 `src/quest/a1/q_den_of_evil.py`
|
||||
|
||||
```python
|
||||
class DenOfEvil:
|
||||
"""Den of Evil run - Act 1 repeatable quest for XP.
|
||||
|
||||
Flow:
|
||||
1. Ensure character is in Act 1
|
||||
2. Walk to Roaring Canyon (Town Maiden)
|
||||
3. Talk to Town Maiden (unlock entrance if needed)
|
||||
4. Enter Den of Evil
|
||||
5. Pre-buff (FoH + Conviction for FoHdin)
|
||||
6. Walk through each level, killing trash
|
||||
7. Exit back to Roaring Canyon
|
||||
8. Talk to Town Maiden again for reward
|
||||
9. Return to town center
|
||||
"""
|
||||
|
||||
name = "run_doe"
|
||||
|
||||
# Path nodes per level (to be filled in via quest_debug.py)
|
||||
LEVEL_PATHS = {
|
||||
1: [], # Entrance to level 1 stairs
|
||||
2: [], # Level 1 to level 2
|
||||
3: [], # Level 2 to level 3
|
||||
4: [], # Level 3 to level 4 (or final area)
|
||||
}
|
||||
|
||||
def __init__(self, pather, town_manager, char, pickit, runs):
|
||||
self._pather = pather
|
||||
self._town_manager = town_manager
|
||||
self._char = char
|
||||
self._pickit = pickit
|
||||
self._runs = runs
|
||||
self._quest_manager = QuestManager()
|
||||
self._dialogue = QuestDialogue()
|
||||
|
||||
def approach(self, curr_loc: Location, do_buff: bool) -> Location | bool:
|
||||
"""Get to Roaring Canyon and talk to Town Maiden."""
|
||||
Logger.info("Run Den of Evil")
|
||||
|
||||
# Ensure we're in Act 1
|
||||
if TownManager.get_act_from_location(curr_loc) != Location.A1_TOWN_START:
|
||||
curr_loc = self._town_manager.go_to_act(1, curr_loc)
|
||||
if not curr_loc:
|
||||
return False
|
||||
|
||||
# Walk to Roaring Canyon (Town Maiden area)
|
||||
if not self._pather.traverse_nodes(
|
||||
(curr_loc, Location.A1_ROARING_CANYON), self._char, force_move=True
|
||||
):
|
||||
return False
|
||||
|
||||
# Talk to Town Maiden to unlock/open the Den
|
||||
if not self._dialogue.open_dialogue("town_maiden"):
|
||||
return False
|
||||
|
||||
# Follow the conversation (expect "Oh no, not again" or similar)
|
||||
if not self._dialogue.follow_conversation(["Tell me more", "I'll help you"]):
|
||||
return False
|
||||
|
||||
# Enter the Den
|
||||
if not self._pather.traverse_nodes(
|
||||
(Location.A1_ROARING_CANYON, Location.A1_DEN_OF_EVIL_ENTRANCE),
|
||||
self._char, force_move=True
|
||||
):
|
||||
return False
|
||||
|
||||
return Location.A1_DEN_OF_EVIL_ENTRANCE
|
||||
|
||||
def battle(self, do_pre_buff: bool) -> bool | tuple[Location, bool]:
|
||||
"""Fight through the Den of Evil."""
|
||||
# Pre-buff
|
||||
if do_pre_buff:
|
||||
if not self._char.pre_buff():
|
||||
return False
|
||||
|
||||
# Clear each level
|
||||
for level in sorted(self.LEVEL_PATHS.keys()):
|
||||
Logger.info(f"Clearing Den of Evil level {level}")
|
||||
if not self._pather.traverse_nodes(
|
||||
self.LEVEL_PATHS[level], self._char, timeout=120, do_combat=True
|
||||
):
|
||||
Logger.error(f"Failed to clear DoE level {level}")
|
||||
return False
|
||||
|
||||
# Pick up any quest items / loot
|
||||
self._pickit.pick_up_items(self._char)
|
||||
QuestItems.pick_up_quest_items(self._char)
|
||||
|
||||
# Walk back to Roaring Canyon
|
||||
if not self._pather.traverse_nodes(
|
||||
(Location.A1_DEN_OF_EVIL_ENTRANCE, Location.A1_ROARING_CANYON),
|
||||
self._char, force_move=True
|
||||
):
|
||||
return False
|
||||
|
||||
# Talk to Town Maiden for reward
|
||||
if not self._dialogue.open_dialogue("town_maiden"):
|
||||
return False
|
||||
if not self._dialogue.follow_conversation(["Yes", "Thank you"]):
|
||||
Logger.warning("Failed to collect DoE reward from Town Maiden")
|
||||
|
||||
# Mark as done (for non-repeatable quests) or just return success
|
||||
# Note: DoE is repeatable once per real-day, so we DON'T mark permanently done
|
||||
# self._quest_manager.mark_done("den_of_evil") # Only if non-repeatable
|
||||
|
||||
return (Location.A1_ROARING_CANYON, True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Bot integration
|
||||
|
||||
### 4.1 `src/bot.py` changes
|
||||
|
||||
```python
|
||||
# Add import
|
||||
from quest.a1.q_den_of_evil import DenOfEvil
|
||||
|
||||
# In __init__:
|
||||
self._do_runs["run_doe"] = Config().routes.get("run_doe")
|
||||
|
||||
self._doe = DenOfEvil(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)
|
||||
|
||||
# In _states list:
|
||||
# (No new state needed - DoE uses the existing pattern: town -> doe -> end_run -> town)
|
||||
|
||||
# In _transitions list (add):
|
||||
{ 'trigger': 'run_doe', 'source': 'town', 'dest': 'doe', 'before': "on_run_doe" },
|
||||
|
||||
# Add 'doe' to end_run source list:
|
||||
{ 'trigger': 'end_run', 'source': [..., 'doe'], 'dest': 'town', 'before': "on_end_run" },
|
||||
|
||||
# Add end_game source:
|
||||
{ 'trigger': 'end_game', 'source': [..., 'doe'], 'dest': 'initialization', 'before': "on_end_game" },
|
||||
|
||||
# Add handler method:
|
||||
def on_run_doe(self):
|
||||
res = False
|
||||
self._do_runs["run_doe"] = False
|
||||
self._game_stats.update_location("DoE")
|
||||
self._curr_loc = self._doe.approach(self._curr_loc, not self._pre_buffed)
|
||||
if self._curr_loc:
|
||||
set_pause_state(False)
|
||||
res = self._doe.battle(not self._pre_buffed)
|
||||
self._ending_run_helper(res)
|
||||
```
|
||||
|
||||
### 4.2 `src/run/__init__.py` changes
|
||||
|
||||
```python
|
||||
# No change needed if DoE lives in src/quest/ (not src/run/)
|
||||
# But if we want consistency, add:
|
||||
from quest.a1.q_den_of_evil import DenOfEvil
|
||||
```
|
||||
|
||||
### 4.3 `config/params.ini` changes
|
||||
|
||||
```ini
|
||||
[routes]
|
||||
; ... existing runs ...
|
||||
; run_doe (Act 1 Den of Evil - repeatable daily XP)
|
||||
order=run_doe
|
||||
```
|
||||
|
||||
### 4.4 `config/params.ini` FoHdin config
|
||||
|
||||
For a lvl 1 Paladin running DoE, the params.ini needs:
|
||||
|
||||
```ini
|
||||
[char]
|
||||
type=fohdin
|
||||
...
|
||||
|
||||
[fohdin]
|
||||
; FoHdin-specific config for low-level DoE runs
|
||||
teleport=
|
||||
; No teleport at lvl 1-9, so pathing is on foot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Testing workflow
|
||||
|
||||
### What needs user input (I cannot see D2R):
|
||||
|
||||
1. **Capture Town_Maiden template:**
|
||||
- Go to Roaring Canyon in Act 1
|
||||
- Stand near the Town Maiden
|
||||
- Run `quest_debug.py`, press F4 (NPC detection)
|
||||
- Paste output so I can save the template
|
||||
|
||||
2. **Capture DoE path nodes:**
|
||||
- Enter the Den of Evil
|
||||
- Run `quest_debug.py`, press F1 at each waypoint
|
||||
- Walk from entrance through each level
|
||||
- Paste outputs so I can build the path arrays
|
||||
|
||||
3. **Capture dialogue:**
|
||||
- Talk to Town Maiden (both before and after clearing)
|
||||
- Run `quest_debug.py`, press F2 (dialogue OCR)
|
||||
- Paste output so I can code the conversation flow
|
||||
|
||||
4. **Test run:**
|
||||
- After I write the code, you run botty with `run_doe` in the route order
|
||||
- Report what happens / paste terminal output
|
||||
- I iterate based on results
|
||||
|
||||
### Lvl 1 Paladin specifics:
|
||||
|
||||
- **FoHdin requires FOH skill lvl 6 for Feign of Life passive** -- this needs 3 skill points
|
||||
in FoH, meaning character level 9 minimum (or level 4 with a +1 skill weapon)
|
||||
- Before reaching lvl 9, the bot can still run DoE but will be much more fragile
|
||||
- Recommended: manually level Paladin to ~lvl 4-5 (short runs in Area 1 or 2) before
|
||||
letting the bot solo DoE with FoH
|
||||
- The bot pathing should handle the walk-through at low speed with heavy FoH spam
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Write `quest_manager.py` (simple JSON state tracker)
|
||||
2. Write `quest_dialogue.py` (OCR-based NPC interaction)
|
||||
3. Write `quest_items.py` + `quest_combat.py` (lightweight helpers)
|
||||
4. Add Town_Maiden NPC to npc_manager.py
|
||||
5. Write `q_den_of_evil.py` (skeleton with placeholder paths)
|
||||
6. Integrate into bot.py (state, transitions, handler)
|
||||
7. Update params.ini
|
||||
8. **USER TESTS** -- captures templates, paths, dialogue
|
||||
9. I fill in the actual path nodes and dialogue based on your captures
|
||||
10. Full test run and iterate
|
||||
|
||||
---
|
||||
|
||||
## File tree after implementation
|
||||
|
||||
```
|
||||
my-botty/
|
||||
├── config/
|
||||
│ ├── params.ini # Modified: +run_doe in routes
|
||||
│ ├── quest_state.json # New: auto-created by QuestManager
|
||||
│ └── bnip/
|
||||
│ └── town_maiden.png # New: captured template
|
||||
├── src/
|
||||
│ ├── quest/ # New directory
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── quest_manager.py
|
||||
│ │ ├── quest_dialogue.py
|
||||
│ │ ├── quest_items.py
|
||||
│ │ ├── quest_combat.py
|
||||
│ │ └── a1/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── q_den_of_evil.py
|
||||
│ ├── npc_manager.py # Modified: +TOWN_MAIDEN
|
||||
│ ├── pather.py # Modified: +A1_ROARING_CANYON, +A1_DEN_* locations
|
||||
│ ├── bot.py # Modified: +doe state, transitions, handler
|
||||
│ └── run/__init__.py # Modified: +DenOfEvil export
|
||||
```
|
||||
@@ -53,6 +53,27 @@ Handles D2R window detection and screenshot capture via MSS library. Converts be
|
||||
|
||||
Template matching via OpenCV (`cv2.matchTemplate`). Searches against pre-captured asset templates in `assets/templates/`. Returns match position and validity.
|
||||
|
||||
**Asset conventions — read before adding or recapturing any template:**
|
||||
|
||||
- Every `.png` under the `TEMPLATE_PATHS` roots is loaded recursively and keyed by its
|
||||
**uppercased filename** (`a5_red_portal.png` → `A5_RED_PORTAL`). Keys are one flat
|
||||
namespace across all roots, so filenames must be globally unique — and **never leave a
|
||||
backup or scratch `.png` anywhere under `assets/`**, or it silently becomes a live
|
||||
template.
|
||||
- **Keep templates fully opaque (3-channel, or 4-channel with no zero-alpha pixel).**
|
||||
`alpha_to_mask` only produces a mask when the image has 4 channels *and* contains a
|
||||
fully transparent pixel; that mask is then passed to
|
||||
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)`. **OpenCV only properly supports
|
||||
masks for `TM_SQDIFF` and `TM_CCORR_NORMED`** — masked `TM_CCOEFF_NORMED` returns
|
||||
unreliable scores and wandering match positions. A masked template will appear to
|
||||
"work" in isolation and then fail at random. See CLAUDE.md Bug 23.
|
||||
- Crop something **structurally stable and unoccluded**. Animated or partly hidden
|
||||
features (a swirling portal's interior, a ring occluded by scenery) make poor anchors.
|
||||
- **Validate on held-out frames**: build the crop from one capture, score it against
|
||||
*other* captures, and — critically — against frames where the subject is **absent**.
|
||||
A template that scores high on both is matching background, not the subject. Aim for a
|
||||
clear gap straddling the 0.68 default threshold.
|
||||
|
||||
### UI Detection (`src/ui/`)
|
||||
|
||||
| File | Detects |
|
||||
|
||||
@@ -8,11 +8,12 @@ This file is auto-read by Claude Code at session start. It covers what you need
|
||||
|
||||
`my-botty` is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only `SendInput`, `GetCursorPos`, `SetCursorPos` via `ctypes`.
|
||||
|
||||
**Active setup (as of 2026-06-08):**
|
||||
- Character: Hammerdin (`fistman`), Hell difficulty
|
||||
- Runs: `run_diablo`, `run_pindle` (see `config/params.ini`)
|
||||
- OS: Windows 11, D2R 1280×720 windowed
|
||||
**Active setup (as of 2026-08-26):**
|
||||
- Character: FoHdin (`paladalla`, lvl 32), Normal difficulty — profile `config/profiles/paladalla/profile.ini`
|
||||
- Runs: `run_pindle` (see `config/params.ini`)
|
||||
- OS: Windows 11 (desktop at 125% scaling), D2R 1280×720 windowed, forced to `(5, 98)` on init
|
||||
- Start bot: `run_botty.bat` (runs `python src/main.py` from the conda env — always current with source; no exe build exists/needed)
|
||||
- Control without the F11 hotkey: `python scripts/hermes_bot_control.py start|pause|stop|status` (TCP 127.0.0.1:18899)
|
||||
|
||||
**Always start a session by reading logs first:**
|
||||
```
|
||||
@@ -77,11 +78,24 @@ ERROR|WARNING|failed|Approach failed|starting from True|DAMAGED|SendInput.*misse
|
||||
| `SendInput.*missed target` | Win11 pointer acceleration amplifying relative mouse moves — fixed in `win_input.py` |
|
||||
| `Repair/vendor interaction failed` | Flaky NPC detection, best-effort — bot continues |
|
||||
| `Could not identify act from location` | `_curr_loc` is `None` or `True`, not a `Location` string |
|
||||
| `Wanted to select A5_RED_PORTAL, but could not find it` | Red portal template stale/masked — see Bug 23 |
|
||||
| `Loot from <run>: nothing picked up` on **every** run | Boss never actually fought — check XP delta, see Bug 24 |
|
||||
| `already in Pindle area before portal click` | Should no longer exist; if seen, the fake-success shortcut is back — Bug 24 |
|
||||
| `rejecting low-confidence .* implies reversal` | Working as intended — pather refused a phantom node match (Bug 25) |
|
||||
| `Found open panels .* Chickening to be safe` | If the char is at full HP, it walked onto the waypoint — Bug 26 |
|
||||
| `Got stuck exit pather` / char against town wall | Fabricated node position from the low-confidence fallback — Bug 25 |
|
||||
| `expected result .* not found in GEMS convert panel` repeating | Stash tab never switched — gems being handled in the wrong tab, Bug 27 |
|
||||
| `could not activate the .* stash tab` | Tab bar geometry drifted; re-measure tab centres against the live client — Bug 27 |
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs and Fixes (permanent reference)
|
||||
|
||||
> Narrative write-up of the 2026-08-26/27 run_pindle collapse (what happened, in what order,
|
||||
> and the four times the obvious answer was wrong): `docs/postmortem_pindle_2026-08-27.md`.
|
||||
> Bugs 23-30 below are the per-bug detail for that incident.
|
||||
|
||||
|
||||
### Bug 1: `_curr_loc = True` propagation
|
||||
**File:** `src/town/town_manager.py` — `identify()` method (~line 236)
|
||||
|
||||
@@ -325,6 +339,529 @@ numbers, ending in `All stash is full, quitting`.
|
||||
**Test tip:** `tools/testbed.py stash` exercises the gold + open-stash path live; add `all` to scan
|
||||
all 10 inventory columns and exercise the keep-item transfer branch on existing charms.
|
||||
|
||||
### Bug 20: unescaped `)` in an echo killed the conda direct-download path (2026-08-05)
|
||||
**File:** `install.bat` — Miniforge install error branch
|
||||
`echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).` sat inside a parenthesised
|
||||
`if (...)` block. An unescaped `)` inside a block **terminates the block**, leaving `.` as a stray
|
||||
token. cmd parses the whole block when it reaches it, so this aborted the script **even when the
|
||||
installer succeeded and the branch was never taken** — verified with a minimal repro (unescaped form
|
||||
exits 255 on a false condition; escaped form exits 0).
|
||||
Effect: the direct-download fallback — the only path on a machine without winget — installed conda
|
||||
and then died before creating the `botty` env, leaving the bot unusable.
|
||||
**Fix:** escape as `^(exit code %errorlevel%^)`, the convention already used elsewhere in the file
|
||||
(`^(fast path^)`).
|
||||
**Symptom to grep for:** `. was unexpected at this time.` right after `Installing Miniforge3`.
|
||||
|
||||
**Two batch pitfalls that keep recurring in `install.bat` — check both when editing it:**
|
||||
1. A `::` comment line **inside** a `( )` block is a parse error. Put comments above the block.
|
||||
2. Any unescaped `(` or `)` in an `echo` inside a block breaks it. Escape as `^(` / `^)`.
|
||||
|
||||
Audit both across every `.bat` with:
|
||||
```
|
||||
awk '{ if ($0 ~ /^[ \t]*::/) { if (d>0) print FILENAME": "NR": "$0; next }
|
||||
t=$0; gsub(/\^[()]/,"",t); d += gsub(/\(/,"(",t) - gsub(/\)/,")",t); if (d<0) d=0 }' *.bat
|
||||
```
|
||||
|
||||
### Bug 21: installer needed admin, so it silently failed on a normal double-click (2026-08-05)
|
||||
**Files:** `install.bat`, `src/d2r_image/ocr.py`, `run_botty.bat`
|
||||
`winget install` defaulted to **machine scope**, putting conda in `%ProgramData%\miniforge3` — which
|
||||
requires elevation. Double-clicking `install.bat` without admin failed silently and conda never
|
||||
installed. The same bug applied to the Tesseract install.
|
||||
**Fix:** `--scope user` (conda now lands in `%USERPROFILE%\miniforge3`, no admin), plus re-scanning
|
||||
for `conda.exe`/`tesseract.exe` after winget instead of trusting its exit code — winget returns
|
||||
non-zero when a package is *already installed*. Tesseract additionally falls back to a direct NSIS
|
||||
download. `ocr.py` and `run_botty.bat` now also look in `%LOCALAPPDATA%\Programs\Tesseract-OCR`.
|
||||
**Known limitation:** the official Tesseract installer self-elevates and discards `/D=`, so it
|
||||
always installs machine-wide and **does require admin/UAC**. There is no per-user Tesseract install.
|
||||
Conda has no such limitation.
|
||||
**Note:** deleting a conda folder without running its uninstaller leaves stale Add/Remove-Programs
|
||||
entries, which make winget treat the next install as an *upgrade* instead of a fresh install.
|
||||
|
||||
### Bug 22: tesserocr never loaded → bot silently ran on the slow OCR fallback (2026-08-06)
|
||||
**File:** `install.bat` — OCR backend 1 section
|
||||
`install.bat` always printed `tesserocr: not available (DLL issue)` and the bot logged
|
||||
`OCR backend: pytesseract (fallback)`. pytesseract spawns `tesseract.exe` as a **subprocess per OCR
|
||||
call**; tesserocr uses the in-process C++ API, so this was a permanent, silent performance loss on
|
||||
every item hover.
|
||||
|
||||
**Root cause** (found by walking the PE import table with `pefile`, not by guessing):
|
||||
```
|
||||
tesserocr.pyd → tesseract52.dll → leptonica-1.78.0.dll → tiff.dll → libdeflate.dll ← MISSING
|
||||
```
|
||||
Current conda-forge `libdeflate` (>=1.20) installs the library as **`deflate.dll`**, but the older
|
||||
`tiff.dll` from the `tesseract=4.*` stack still imports the previous name **`libdeflate.dll`**.
|
||||
Nothing provided that name, so `tiff.dll` failed to load and every DLL above it failed with
|
||||
**WinError 126 — "The specified module could not be found"**. That message made it look like a
|
||||
missing *module*, which is why earlier fixes chased `os.add_dll_directory` / PATH instead. Adding
|
||||
every DLL directory does NOT help; the file genuinely does not exist under that name.
|
||||
|
||||
**Fix:** install `libdeflate` explicitly next to `tesseract=4.*`, then copy `deflate.dll` to the
|
||||
legacy name when it is absent:
|
||||
```bat
|
||||
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
|
||||
copy /y "...\deflate.dll" "...\libdeflate.dll" >nul
|
||||
)
|
||||
)
|
||||
```
|
||||
**Verify:** `install.bat` should print `tesserocr: OK (fast path)` and the bot should log
|
||||
`OCR backend: tesserocr (primary)`. Deleting `libdeflate.dll` reproduces the failure exactly.
|
||||
|
||||
**Debugging tip for any future "DLL load failed" error:** don't assume it's a search-path problem.
|
||||
Walk the real import chain and try loading each DLL directly — WinError 126 names the *importer*,
|
||||
never the missing dependency:
|
||||
```python
|
||||
import pefile, ctypes
|
||||
pe = pefile.PE(r"...\some.dll", fast_load=True)
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]])
|
||||
print([e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT])
|
||||
```
|
||||
|
||||
### Bug 23: masked `a5_red_portal.png` → portal never clickable (2026-08-26)
|
||||
**File:** `assets/templates/a5_town/a5_red_portal.png`
|
||||
|
||||
`A5_RED_PORTAL` peaked at **0.50–0.60** — under the 0.68 `select_by_template` needs — and its
|
||||
best match was always a false positive on bottom-right scenery near `(934, 62x)`, never the
|
||||
portal. Every Pindle run died on `Wanted to select A5_RED_PORTAL, but could not find it`.
|
||||
|
||||
**Root cause:** it was the **only masked template in `a5_town/`** (4-channel, 60.9% opaque).
|
||||
Every working A5 template is fully opaque:
|
||||
```
|
||||
a5_red_portal.png 4 chan 60.9% opaque MASKED <- the only one that failed
|
||||
a5_town_1..12.png 3 chan 100.0% opaque no mask <- every one that works
|
||||
```
|
||||
The original capture had the "…thak's Te…" hover tooltip baked in, and alpha was used to hide
|
||||
it. `alpha_to_mask` therefore returned a mask, routing it to
|
||||
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)` — a combination **OpenCV does not
|
||||
properly support** (masks are only valid for `TM_SQDIFF` / `TM_CCORR_NORMED`). That produced
|
||||
both the depressed scores and the wandering match positions.
|
||||
|
||||
**Fix:** recaptured as a plain **3-channel opaque** crop of the portal's **upper arch** (the
|
||||
lower ring is occluded by branches on most approaches, which wrecks matching). Verified by
|
||||
held-out validation *and* against portal-absent frames:
|
||||
|
||||
| | score | position |
|
||||
|---|---|---|
|
||||
| portal present (5 frames + live) | **0.949 – 1.000** | correct every time |
|
||||
| portal absent (6 real failure frames) | **0.398 – 0.514** | `valid=False` |
|
||||
|
||||
**Do not "fix" a template by masking part of it.** Recapture it clean. See the asset
|
||||
conventions in `ARCHITECTURE.md` → Image Recognition.
|
||||
|
||||
### Bug 24: Pindle "already in Pindle area" fired in town → silent zero-XP fake success (2026-08-26)
|
||||
**File:** `src/run/pindle.py` — `approach()` pre-click shortcut
|
||||
|
||||
The worst bug of the set, because it **reported success**. `approach()` opened with a
|
||||
"are we already there?" shortcut calling `_verify_in_pindle_area()` before clicking the
|
||||
portal. Harrogath scenery near the portal scores **0.76–0.79 on `PINDLE_7`** — well above its
|
||||
0.62 bar — so the shortcut fired *in town*, returned `Location.A5_PINDLE_START` **without ever
|
||||
clicking the portal**, and the bot ran `kill_pindle()` in the middle of Harrogath.
|
||||
|
||||
Five consecutive games logged `confirmed temple entry (PINDLE_7 @ 75.7%)`, `End game`,
|
||||
`runs_failed_total: 0` — with **zero loot and zero XP**. The logs looked perfect.
|
||||
|
||||
**Fix:** the shortcut is deleted. Entry is proven only by the portal click's loading screen.
|
||||
Real temple entries score **85.8–88.7%**, cleanly above the town false positives.
|
||||
|
||||
**Two traps this bug teaches:**
|
||||
1. **`failed:false` in the event stream is the bot's opinion, not evidence.** Verify a boss
|
||||
run with **XP delta** (`log/stats/mini_stats_*.json` → `current_exp`) and **loot lines**.
|
||||
`Loot from run_pindle: nothing picked up` on *every* run is a red flag, not bad luck.
|
||||
2. **A "we might already be there" fast path is dangerous** when its test can false-positive
|
||||
at the origin. It converts a loud failure into a silent one.
|
||||
|
||||
**Do NOT try to fix this with a red-portal visibility check.** The Nihlathak portal is
|
||||
visible from **both sides** — the return portal renders inside the temple too — so
|
||||
"portal on screen ⇒ still in town" is false and blocks every genuine entry.
|
||||
|
||||
### Bug 25: pather 0.55 fallback fabricated node positions → char walked into the town wall (2026-08-26)
|
||||
**File:** `src/pather.py` — `find_abs_node_pos()`, `traverse_nodes()`
|
||||
|
||||
When the primary 0.68 ROI search missed, `find_abs_node_pos` fell back to a **0.55
|
||||
first-match** full-image search. That fallback invented node positions: `A5_TOWN_1` scored
|
||||
0.60–0.62 on unrelated scenery at `(532,110)`, `(857,522)` and `(1209,86)` across three
|
||||
consecutive failure frames. The pather steered toward each phantom and wedged the character
|
||||
against the Harrogath battlements. Note the landmarks themselves were **healthy** where
|
||||
actually visible (`A5_TOWN_6 @ 0.93`, `A5_TOWN_4 @ 0.85`) — only the fallback was lying.
|
||||
|
||||
**Fix (three parts):**
|
||||
1. Fallback threshold `0.55` → **`0.62`** (`Pather._FALLBACK_THRESHOLD`).
|
||||
2. Fallback forced to **`best_match=True`** — it was `best_match=False`, so an ambiguous
|
||||
frame was decided by **node-dict ordering** rather than by score.
|
||||
3. New `_heading_is_plausible()` gate: a **low-confidence** match implying a >90° reversal
|
||||
from the current heading is rejected (`Pather: rejecting low-confidence X — implies
|
||||
reversal`) and the recovery sweep runs instead. Confident matches are never gated.
|
||||
Heading is tracked **per node** (`node_last_dir`, reset each node) because a >90° turn
|
||||
*between* nodes is normal.
|
||||
|
||||
Fired for real on the very first live session: two `PINDLE_4` phantoms at 62.2%/62.9% were
|
||||
rejected, and that game completed (73 s instead of ~50 s) rather than being lost.
|
||||
|
||||
### Bug 26: waypoint panel counted as a chicken → healthy games thrown away (2026-08-26)
|
||||
**File:** `src/health_manager.py` — panel check in the monitor loop
|
||||
|
||||
A mis-steered pather walks the char **over the Harrogath waypoint stone**, which opens the WP
|
||||
panel. The panel check saw `LeftPanel`/`RightPanel`, counted twice, and killed the game:
|
||||
`Found open panels (inv/quest/stats) twice. Chickening to be safe.` — **3 of 6 games died this
|
||||
way at full health.**
|
||||
|
||||
**Fix:** if `ScreenObjects.WaypointLabel` is visible the panel is self-inflicted, not a
|
||||
threat: send `esc` **without** incrementing `_count_panel_detects`. Bounded by
|
||||
`_MAX_WP_PANEL_ESCAPES = 6` so a WP panel that genuinely will not close still falls through
|
||||
to the normal chicken path, and `_count_wp_panel_detects` resets as soon as a poll sees no
|
||||
panel at all.
|
||||
|
||||
### Bug 27: gem conversion ran in the PERSONAL tab — tab-switch click missed the tab bar (2026-08-26)
|
||||
**Files:** `src/transmute/transmute.py`, `config/params.ini`
|
||||
|
||||
`convert_all_gems` ran its entire routine in whichever stash tab happened to be open —
|
||||
PERSONAL — never the GEMS tab. It logged `[819/999] … expected result
|
||||
INVENTORY_TOPAZ_PERFECT not found in GEMS convert panel; trying first slot fallback` **on
|
||||
every one of 800+ iterations** and kept going, ctrl+shift+clicking blind into the personal
|
||||
stash grid.
|
||||
|
||||
**Root cause:** `GEMS_TAB_Y = 100`. Measured off the live client, the stash tab labels
|
||||
occupy the row **y = 63–78** (selected-tab underline at y=80) and the **stash grid starts at
|
||||
y≈87**. So every `_switch_to_gems_tab()` click at y=100 landed on a *stash slot*, not a tab.
|
||||
The X values were already correct — only Y was wrong, by ~30px.
|
||||
|
||||
Measured tab centres on this client (5 tabs: PERSONAL SHARED GEMS MATERIALS RUNES):
|
||||
|
||||
| PERSONAL | SHARED | GEMS | MATERIALS | RUNES |
|
||||
|---|---|---|---|---|
|
||||
| (68, 70) | (144, 70) | (220, 70) | (295, 70) | (370, 70) |
|
||||
|
||||
**Second, independent bug found alongside it:** `params.ini` had `stash_tabs=6` with only
|
||||
**5** tabs on screen. `inventory/common.tab_properties()` divides the tab bar by that number,
|
||||
so it computed centres of `63/127/192/256/320/384` against real centres of
|
||||
`68/144/220/295/370` — tabs 2, 3 and 4 clicked the **gaps between tabs**. Set to `5`.
|
||||
|
||||
**Fix:**
|
||||
1. `TAB_Y = 70`, plus named X constants for all five tabs.
|
||||
2. `stash_tabs=5`.
|
||||
3. **Tab switches are now verified, not fire-and-forget.** `_switch_to_tab()` clicks, confirms
|
||||
the tab actually became active, retries up to 3×, and returns False. `convert_all_gems`
|
||||
aborts on False instead of converting in the wrong tab.
|
||||
|
||||
**How to detect the active tab** — measure the **cell background**, not the glyphs:
|
||||
`np.percentile(gray_label_cell, 30) > 52` (active ≈ 67–68, inactive ≈ 38–39). A
|
||||
brightness-of-text test does **not** work: label brightness scales with label length, so an
|
||||
active `GEMS` peaks at 167 while an active `PERSONAL` hits 215 — any glyph threshold that
|
||||
catches PERSONAL misreads GEMS as inactive. This was a real wrong turn during the fix.
|
||||
|
||||
**The recurring lesson (same as Bug 24):** an unverified click that "should" have worked will
|
||||
fail silently forever. Any click that changes UI *mode* — a tab, a page, a panel — must be
|
||||
confirmed before the code acts as if it took effect.
|
||||
|
||||
### Bug 28: `detect_current_act` decided the act on a 1.7pp margin → A5 pathing inside Act 4 (2026-08-27)
|
||||
**File:** `src/town/town_manager.py` — `detect_current_act()`
|
||||
|
||||
The single biggest cause of run failures: **25% of games failed**, dominated by
|
||||
`Approach failed for run_pindle [step: click_red_portal]`. The character was running A5
|
||||
pathing while physically standing in **Act 4**.
|
||||
|
||||
**The chain:**
|
||||
1. `buy_consumables` travels A5 → A4 for Jamella (`in A5 — traveling to A4 Jamella (Malah
|
||||
unreliable)`) — 32 times in one session.
|
||||
2. Repair then runs in A4 too (`TownManager repair: starting from a4_town_start`, 32×).
|
||||
3. `Run Pindle` calls `go_to_act(5, a4_town_start)`, which asks `detect_current_act()` to
|
||||
verify. It answered **A5** while in Act 4, so `go_to_act` "corrected" the assumption and
|
||||
**skipped the travel entirely**.
|
||||
4. A5 node pathing ran in Act 4 → portal never found → failure. 11 failures traced directly
|
||||
to step 1 in one session.
|
||||
|
||||
**Root cause:** `detect_current_act` used `search_and_wait(TOWN_MARKERS, best_match=True)`
|
||||
and committed to whatever cleared 0.68 first. Measured on a real Act 4 failure frame:
|
||||
|
||||
```
|
||||
A5_TOWN_1 0.636 <- phantom, always at (1046, 40), the top-right corner
|
||||
A4_TOWN_5 0.619 <- the genuine marker for the act actually occupied
|
||||
```
|
||||
|
||||
A **1.7 percentage point** gap decided which act the bot believed it was in. Pure coin flip.
|
||||
|
||||
**Fix:** the winner must now beat the best marker **from any other act** by
|
||||
`_ACT_DETECT_MARGIN` (0.05) as well as clearing 0.68. Below that it logs
|
||||
`detect_current_act: ambiguous … refusing to guess the act` and returns None.
|
||||
|
||||
**Why returning None is the safe answer:** every caller treats None as "keep the assumed
|
||||
act". In `go_to_act` that means `curr_act` stays `a4_town_start`, which no longer equals the
|
||||
target, so it **actually travels** via waypoint. Refusing to answer produces correct
|
||||
behaviour; guessing wrong does not. Never make this function more willing to commit.
|
||||
|
||||
**Result:** failure rate 25% → 9% over the validation run. The first game of that run was the
|
||||
exact failing case (repair started from `a4_town_start`) and completed successfully.
|
||||
|
||||
**Residual, different cause:** the failures that remain are NPC-detection flakiness — e.g.
|
||||
`open_npc_menu: timed out finding qual_kehk` during merc resurrect burns ~40s and strands the
|
||||
char. Same family as Bugs 3/4/6/7, not act desync. See Bug 29.
|
||||
|
||||
### Bug 29: undetectable resurrect NPC burned ~80s in EVERY game (2026-08-27)
|
||||
**Files:** `src/bot.py`, `src/game_stats.py`
|
||||
|
||||
In nightmare the merc dies most games, so `resurrect_merc` runs constantly — and Qual-Kehk
|
||||
detection was failing **100% of the time** (5 timeouts in 5 attempts, 107 hover attempts
|
||||
across 12 games). Each failed hunt costs ~40s, and the code retried once, so a dead merc cost
|
||||
**~80s per game, forever**. Game length blew out to 185-250s versus a normal ~60s.
|
||||
|
||||
**Why the existing guard didn't help:** `GameStats._merc_resurrect_failed` is reset in
|
||||
`log_start_game`, so it only suppresses a second attempt *within one game*. Nothing carried
|
||||
the knowledge across games.
|
||||
|
||||
**CORRECTED 2026-08-27 — the template was NOT the problem.** The identical 0.424 score at
|
||||
unrelated positions looked like a degenerate template, but `QUAL_NAME_TAG_WHITE` scores
|
||||
**1.000** on a frame where the tag is actually rendered, and **0.997** through the
|
||||
`color_filter` path `npc_manager` really uses. The template is fine. The real cause was
|
||||
`SWEEP_TAG_THRESHOLD` — see Bug 30. The lesson stands but points the other way: a repeated
|
||||
identical score means the search is **settling for noise**, so check the threshold before
|
||||
blaming the asset.
|
||||
|
||||
**Fix (cost containment, not detection):** a cross-game circuit breaker on `GameStats`,
|
||||
deliberately NOT reset by `log_start_game`:
|
||||
- `_merc_resurrect_fail_streak` — consecutive failed resurrects
|
||||
- `_merc_resurrect_skip_until` — game number to resume trying at
|
||||
- `Bot._MERC_RESURRECT_FAIL_LIMIT = 2`, `_MERC_RESURRECT_SKIP_GAMES = 15`
|
||||
- the retry is skipped once the streak is ≥1 (a second guaranteed-futile 40s hunt)
|
||||
- both counters reset on any successful resurrect, so a transient failure can't permanently
|
||||
disable resurrecting
|
||||
|
||||
Over 30 games with an undetectable NPC: **60 hunts → 4** (~40 min of waste → ~2.7 min).
|
||||
Measured live: game times went 250s / 185s → **14s, 14s, 43s, 71s, 111s** once the breaker
|
||||
engaged. The bot runs mercless for 15 games, then tries again.
|
||||
|
||||
**Resolved by Bug 30.** The breaker stays as a safety net, but resurrect should now succeed.
|
||||
|
||||
### Bug 30: NPC grid sweep settled for noise — `SWEEP_TAG_THRESHOLD` too low (2026-08-27)
|
||||
**File:** `src/npc_manager.py` — `open_npc_menu()` grid sweep
|
||||
|
||||
`SWEEP_TAG_THRESHOLD = 0.4`. A rendered name tag matches almost perfectly, so anything
|
||||
mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the first thing over
|
||||
0.4, clicked empty ground, and gave up. Scores measured across a full day of sweeps:
|
||||
|
||||
| NPC | score | outcome |
|
||||
|---|---|---|
|
||||
| akara | 0.980 | real — dialogue opened |
|
||||
| halbu | 0.995 | real |
|
||||
| malah | 0.990 | real |
|
||||
| larzuk | 0.996 | real |
|
||||
| **qual_kehk** | **0.424** | **false — clicked nothing** |
|
||||
| **malah** | **0.501** | **false** |
|
||||
| **larzuk** | **0.494** | **false** |
|
||||
|
||||
Real hits cluster at 0.98–1.00, false ones at 0.42–0.50. Raised to **0.7**, which sits in
|
||||
the gap with margin on both sides. This is why qual_kehk failed 100% (5 timeouts in 5
|
||||
attempts) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.
|
||||
|
||||
**How the template was cleared of suspicion:** walked the char to the NPC with the project's
|
||||
own Pather, hovered a grid of positions capturing full-res frames, found the one where
|
||||
`QUAL-KEHK` renders, and scored the stored template against it — 1.000 raw, 0.997 through
|
||||
`color_filter`. Worth repeating for any "stale template" claim: **prove the template fails on
|
||||
a frame where the subject is definitely visible** before recapturing anything.
|
||||
|
||||
Note `NAME_TAG_THRESHOLD = 0.26` (the hover path, line ~289) is deliberately much lower and
|
||||
was NOT changed — Akara genuinely hovers at ~0.28 (Bug 3). The two thresholds serve different
|
||||
paths; don't unify them.
|
||||
|
||||
### Bug 31: merc panel check left the CHRONICLE panel open for the whole game (2026-08-27)
|
||||
**File:** `src/bot.py` — `on_maintenance()`, merc-alive confirmation
|
||||
|
||||
`resurrect_merc` confirms a live merc by pressing `o` and looking for `MercPanelText`. It
|
||||
then closed the panel **only when that check passed**:
|
||||
|
||||
```python
|
||||
keyboard.send("o")
|
||||
merc_panel_open = is_visible(ScreenObjects.MercPanelText)
|
||||
if merc_panel_open:
|
||||
keyboard.send("o") # the ONLY path that closed anything
|
||||
```
|
||||
|
||||
On this client `o` (skill slot 54) opens the **CHRONICLE** collection panel, not the merc
|
||||
panel. So `MercPanelText` never matched, the closing keypress was never sent, and Chronicle
|
||||
stayed open for the rest of the game — a large centred panel that blanks every subsequent
|
||||
template match. The run then died on `click_red_portal` after ~66s of clicking at a covered
|
||||
screen.
|
||||
|
||||
**Why nothing caught it:** the health manager's panel guard looks for `LeftPanel` /
|
||||
`RightPanel`. Chronicle is centred and matches neither, so it was never auto-escaped.
|
||||
|
||||
**Fix:** always dismiss whatever appeared. If `MercPanelText` is not found, send `esc`, then
|
||||
re-check `LeftPanel`/`RightPanel` and send `esc` again if something is still up.
|
||||
|
||||
**The general rule:** *any* keypress that may open a panel must be paired with an
|
||||
unconditional dismiss. Closing only on the happy path leaves the UI wedged on every other
|
||||
path — and here that cost a whole run each time.
|
||||
|
||||
**Symptom to grep for:** `FAIL>` records showing `step=resurrect_merc` followed by
|
||||
`run.approach!` with a long duration, or an error screenshot with a UI panel covering the map.
|
||||
|
||||
---
|
||||
|
||||
## The run timeline — `grep "TL>"`
|
||||
|
||||
One fixed-width, machine-readable line per step, covering the whole cycle from spawn to
|
||||
loot. Every step is **timed**.
|
||||
|
||||
```bash
|
||||
grep "TL>" log/log.txt
|
||||
```
|
||||
```
|
||||
TL> g2 r1 | game | start | start | char=fohdin difficulty=nightmare routes=['run_pindle']
|
||||
TL> g2 r1 | game | spawn | ok | at a5_town_start (act a5_town_start)
|
||||
TL> g2 r1 | town | maintenance | start | at a5_town_start
|
||||
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2 gold_full=False
|
||||
TL> g2 r1 | town | stash_items | skip | nothing kept and gold not full
|
||||
TL> g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
|
||||
TL> g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
|
||||
TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable — continuing mercless
|
||||
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
|
||||
TL> g2 r1 | run | run_pindle | start | from a5_larzuk
|
||||
TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal
|
||||
TL> g2 r2 | game | end | fail | Approach failed for run_pindle [step: click_red_portal]
|
||||
```
|
||||
|
||||
**Columns:** `game rN | phase | step | status | took= | detail`
|
||||
|
||||
- **phase**: `game` | `town` | `run` | `stlth`
|
||||
- **status**: `start` | `ok` | `skip` | `fail`. `skip` states *why*, so a step that did
|
||||
nothing is distinguishable from one that never ran.
|
||||
- **took=**: emitted on every terminating line. `start` stamps the clock in
|
||||
`Bot._tl_starts`, keyed by `(phase, step)`. This is what makes the slow phase findable —
|
||||
the example above shows a failed game spending **113.6s** of its 137.6s town visit on a
|
||||
merc resurrect that failed.
|
||||
|
||||
**Emitters:**
|
||||
- `Bot.tl()` — the instance method (`src/bot.py`).
|
||||
- `Bot.timeline()` — a **static** entry point for modules that cannot import `Bot` without
|
||||
a circular import. `utils/stealth.py` and `inventory/personal.py` both use it via a lazy
|
||||
guarded import, so item sells/stashes/drops and stealth decisions land in the same
|
||||
stream. It is a no-op when no `Bot` is live.
|
||||
|
||||
**Stealth** appears as phase `stlth`: `afk_break` (start/ok around the sleep, so the break
|
||||
duration is timed), `skip_run`, and `wrong_waypoint`.
|
||||
|
||||
**Keep the format stable** — it is meant to be grepped, not read as prose. The `>` in the
|
||||
prefix matters: a bare `TL`/`TOWN` also matches template names like `A5_TOWN_0`.
|
||||
|
||||
---
|
||||
|
||||
## Is it AFK, or is it stuck?
|
||||
|
||||
The bot sits at the D2R **character-select menu** during a normal AFK break —
|
||||
breaks happen *between* games, after save-and-exit, so the menu is expected.
|
||||
The stuck case looks identical on screen. Do not judge by the menu.
|
||||
|
||||
```bash
|
||||
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
|
||||
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
|
||||
```
|
||||
|
||||
| | Normal AFK break | Stuck |
|
||||
|---|---|---|
|
||||
| At character select | yes, by design | yes |
|
||||
| `status` | `running=True paused=False` | the same |
|
||||
| `select_char: Could not find online/offline tabs` | none | present |
|
||||
| `Restarting bot` | none | every ~20s |
|
||||
| Log | quiet | a new process, repeatedly |
|
||||
|
||||
**The tell is the log filling with restart lines, not the menu.** `status` cannot
|
||||
distinguish them — it reports the game controller, not what the bot is doing.
|
||||
|
||||
### The configured break length is not the real one
|
||||
|
||||
`maybe_afk_break` calls `wait(minutes*60, minutes*60*1.5)` and `wait()` then
|
||||
applies its own jitter (up to 1.44x). The two compound:
|
||||
|
||||
| planned | actual |
|
||||
|---|---|
|
||||
| 3.9m | ~5m+ |
|
||||
| 11.9m | **19.5m** (`took=1167.7s`) |
|
||||
| 20:56 scheduled | **25.5m** (`took=1531.1s`) |
|
||||
|
||||
So `afk_break_max_m = 12` really meant "up to ~26 minutes". A ~25 minute idle is
|
||||
what left D2R unable to re-enter (`select_char`), and 19.5m resumed fine — the
|
||||
tolerated limit is between them. Capped at 7 (=> ~15m worst case) on 2026-08-28.
|
||||
|
||||
**When changing any break duration, multiply by 1.5 x 1.44 before deciding
|
||||
whether it is safe.**
|
||||
|
||||
## Diagnosing a failure — `grep "FAIL>"`
|
||||
|
||||
Each failed game emits a self-contained record. Use this before opening screenshots.
|
||||
|
||||
```
|
||||
FAIL> g7 r5 | Approach failed for run_pindle [step: click_red_portal]
|
||||
FAIL> g7 r5 | at=a5_larzuk | step=resurrect_merc | shot=./log/screenshots/error/...png
|
||||
FAIL> g7 r5 | slowest: town.resurrect_merc=114s, run.approach=72s, town.repair=19s
|
||||
FAIL> g7 r5 | trail: game.spawn > town.repair(19s) > town.resurrect_merc!(114s) > run.approach!(72s)
|
||||
FAIL> g7 r5 | note: 2 failing steps this game: town.resurrect_merc, run.approach
|
||||
```
|
||||
|
||||
**Read the trail, not just the reason.** The step named in the reason is the one that blew
|
||||
up; it is frequently *not* the one that caused the problem. In the example the approach
|
||||
failed only after a merc resurrect had already burned 114s and stranded the character. `!`
|
||||
marks a failing step; the trail holds the last 12 timed steps of that game.
|
||||
|
||||
`note:` appears when more than one step failed in the same game — a strong hint the first
|
||||
failure caused the second rather than them being independent.
|
||||
|
||||
---
|
||||
|
||||
## Using the timing data to tune runs
|
||||
|
||||
The 2-hourly Discord digest (`general.discord_timing_report_h`, 0 disables) aggregates the
|
||||
same timeline, so log and report cannot disagree.
|
||||
|
||||
```
|
||||
Games: 92 (81 ok, 11 failed - 12.0%)
|
||||
Avg town 31s | approach 46s | battle 21s | cycle ~98s
|
||||
__Slowest steps (avg)__
|
||||
`town.resurrect_merc ` 103s x7 (5 fail)
|
||||
`run.approach ` 46s x92 (9 fail)
|
||||
__Failures by step__
|
||||
`run.approach ` 9
|
||||
```
|
||||
|
||||
**How to read it:**
|
||||
|
||||
| Signal | What it means |
|
||||
|---|---|
|
||||
| High avg + low count + high fail | A broken step retrying into a timeout. Worst kind — pure waste. `resurrect_merc` at 103s x7 with 5 fails is the textbook case. |
|
||||
| High avg + high count | The real cost centre. Tuning this moves runs/hour more than anything else. |
|
||||
| Fail count climbing between reports | A regression, or something drifting (template scores, NPC positions). Compare consecutive digests. |
|
||||
| A step that vanishes from the list | It stopped running at all — check for a `skip` reason in `TL>` before assuming it was fixed. |
|
||||
|
||||
**Rankings deliberately exclude** umbrella entries (`maintenance`, the `run_<name>` step)
|
||||
because they contain the others and would always come first, and the `stlth` phase because
|
||||
an AFK break is intentional idling — it gets its own section.
|
||||
|
||||
Cross-check a suspicious step with:
|
||||
```bash
|
||||
grep "TL>" log/log.txt | grep "resurrect_merc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifying a boss run actually worked
|
||||
|
||||
Learned the hard way (Bug 24). Log lines and `failed:false` are **not** proof of a kill.
|
||||
|
||||
| Signal | Trustworthy? |
|
||||
|---|---|
|
||||
| `End game` / `failed:false` | **No** — the bot's own belief; a false-positive area check fakes it |
|
||||
| `confirmed temple entry (X @ nn%)` | **No** on its own — town scenery hit 0.78 on `PINDLE_7` |
|
||||
| `Loot from <run>: <item>` | **Yes** — items only drop from real kills |
|
||||
| `current_exp` delta in `log/stats/mini_stats_*.json` | **Yes** — but a gain only proves *something* died (minions, merc kills), not that the boss did |
|
||||
| A frame from a screen recording | **Yes** — decisive; costs one screenshot |
|
||||
|
||||
Record before/after XP around a verification run:
|
||||
```bash
|
||||
ls -t log/stats/mini_stats_*.json | head -1 | xargs python -c "import json,sys;d=json.load(open(sys.argv[1]));print(d['current_exp'],d['runs_failed_total'])"
|
||||
```
|
||||
|
||||
**A running bot does not pick up source edits.** Python loads modules at process start, so
|
||||
after changing `src/`, kill and restart `main.py` — otherwise you are testing the old code and
|
||||
the log will show messages that no longer exist in the source.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Run
|
||||
@@ -371,6 +908,37 @@ Convert via `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()
|
||||
|
||||
---
|
||||
|
||||
## Pickit / loot rules — which file is actually live
|
||||
|
||||
**The active pickit set is `config/bnip/Den gode.bnip`, which is gitignored** (`.gitignore:89`
|
||||
= `config/bnip/*`). `config/default.bnip` is only a fallback and is NOT in use — editing it
|
||||
has no effect on runs. Resolution order is in `bnip/actions.py::_resolve_bnip_dir()`:
|
||||
|
||||
```
|
||||
config/profiles/<active>/pickit/ > config/pickit_profiles/<general.pickit_profile>/ > config/bnip/ > default.bnip
|
||||
```
|
||||
|
||||
Confirm which file loaded by the startup line `Loaded N nip files with M total expressions`
|
||||
and re-check M after editing — if the count doesn't move, you edited the wrong file.
|
||||
|
||||
**How the three decisions interact** (`bnip/actions.py`):
|
||||
|
||||
| Function | Evaluates | Notes |
|
||||
|---|---|---|
|
||||
| `should_pickup` | only the part **before** `#` | so a rule with stat conditions still picks the item up unidentified |
|
||||
| `should_id` | returns **False** only if a rule **without** a `#` matches | any rule carrying `#` leaves the item to be identified |
|
||||
| `should_keep` | full expression, returns on the **first** match | so ordering matters: specific rules must sit above any catch-all |
|
||||
|
||||
That combination is what lets a catch-all work: `[Quality] == Rare # [Strength] >= 999`
|
||||
placed last picks up and identifies every rare, can never itself keep one, and lets every
|
||||
specific rare rule above it win. Anything falling through is vendored (`sell_junk=1`).
|
||||
|
||||
**Current setup (2026-08-26):** gem transmuting is disabled (`[transmute] transmute=` empty
|
||||
in `params.ini` — this also defeats `force=True`, unlike `transmute_every_x_game=0`).
|
||||
Flawless and Perfect gems are picked up and stashed; chipped/flawed/standard are ignored.
|
||||
|
||||
---
|
||||
|
||||
## Config System
|
||||
|
||||
Singleton `Config()` merges in priority order: `custom.ini` > `params.ini` > `game.ini` > `shop.ini` > `transmute.ini`. First instantiation loads; subsequent calls return the same object. User overrides go in `custom.ini` (not tracked in git).
|
||||
@@ -415,6 +983,23 @@ discord_hook_url=https://discord.com/api/webhooks/...
|
||||
|
||||
**Adding step tracking to a new failure point** — just set `self.approach_fail_step = "descriptive_name"` before `return False`. No other wiring needed.
|
||||
|
||||
**Screen-record a failure instead of guessing.** A 1 fps DPI-aware grab of the D2R client
|
||||
rect is cheap and settles "where is the character actually standing?" in one frame — that is
|
||||
what proved the wall-walk (Bug 25) and the in-town fake kill (Bug 24). Two gotchas: make the
|
||||
recorder **DPI-aware** (`shcore.SetProcessDpiAwareness(2)`) or a 125%-scaled desktop silently
|
||||
crops your capture to 1024×576 of a 1280×720 window; and re-read the window rect **after**
|
||||
the bot starts, since it resizes D2R to 1280×720 at `(5, 98)` on init.
|
||||
|
||||
**Score templates offline against saved frames** rather than re-running the bot. The error
|
||||
screenshots in `log/screenshots/error/` are full-res PNGs — load them and call
|
||||
`template_finder.search(...)` directly to compare primary vs fallback thresholds.
|
||||
Note `import discord` currently fails in the `botty` env with an `ssl.SSLError`, so a
|
||||
standalone diagnostic must stub `sys.modules["discord"]` before importing `src/` modules.
|
||||
|
||||
**Only one bot instance at a time.** Two `main.py` processes both bind the Hermes socket and
|
||||
fight over the `start`/`pause` toggle — one will pause the other mid-run and the logs become
|
||||
nonsense. Check with `tasklist` before starting.
|
||||
|
||||
---
|
||||
|
||||
## File Quick Reference
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Botty First Run Guide
|
||||
|
||||
After running `install.bat` and seeing "Installation complete!", follow these steps.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create your character profile
|
||||
|
||||
Botty uses **profiles** to store your character setup. Each profile is a single file in `config/profiles/`.
|
||||
|
||||
### Find your character type
|
||||
|
||||
Open `config/params.ini` in Notepad, scroll to `[char]`, find `type=`:
|
||||
|
||||
| If you play... | Set `type=` to |
|
||||
|---|---|
|
||||
| Hammerdin Paladin | `hammerdin` |
|
||||
| FoH Paladin | `fohdin` |
|
||||
| Blizzard Sorceress | `sorceress` |
|
||||
| Bone Necromancer | `necro` |
|
||||
| Traps Assassin | `trapsin` |
|
||||
| Amazon | `amazon` |
|
||||
| Barbarian | `barbarian` |
|
||||
| Warlock Druid | `warlock` |
|
||||
|
||||
### Set your keybinds
|
||||
|
||||
In `config/params.ini`, scroll to `[char]` and match these to your D2R key bindings:
|
||||
|
||||
```ini
|
||||
[char]
|
||||
type=hammerdin ; your build (see table above)
|
||||
tp_key=b ; Teleport key
|
||||
stand_still=capslock ; Hold to stand still (capslock is common)
|
||||
show_belt=k ; Open belt
|
||||
weapon_switch=w ; Swap weapons (for CTA builds)
|
||||
force_move=e ; Force movement through mobs
|
||||
```
|
||||
|
||||
**Important:** `stand_still` cannot be `shift` (conflicts with merc healing). `capslock` is the recommended default.
|
||||
|
||||
### Pick your runs
|
||||
|
||||
Scroll to `[routes]` in `config/params.ini`:
|
||||
|
||||
```ini
|
||||
[routes]
|
||||
; Enable the runs you want (1 = on, 0 = off)
|
||||
run_pindle=1 ; Act 5 Nihlathak temple red portal boss
|
||||
run_countess=0 ; Act 1 Forgotten Tower
|
||||
run_andariel=0 ; Act 1 Catacombs
|
||||
run_arcane=0 ; Act 2 Arcane Sanctuary / Summoner (teleport recommended)
|
||||
run_trav=0 ; Act 3 Travincal council
|
||||
run_mephisto=0 ; Act 3 Durance of Hate
|
||||
run_diablo=0 ; Act 4 Chaos Sanctuary (teleport recommended)
|
||||
run_vizier=0 ; Act 4 Chaos, Vizier only (faster than full Diablo)
|
||||
run_nihlathak=0 ; Act 5 Halls of Vaught (teleport recommended)
|
||||
run_eldritch=0 ; Act 5 Frigid Highlands, Eldritch only
|
||||
run_eldritch_shenk=0 ; Act 5 Eldritch then Shenk in the same game
|
||||
|
||||
; Execution order (only includes enabled runs)
|
||||
order=run_pindle
|
||||
```
|
||||
|
||||
> There is no `run_shenk` option — Shenk is Act 5 and is farmed via
|
||||
> `run_eldritch_shenk`. The commented route list at the top of the `[routes]`
|
||||
> section in `params.ini` is always the authoritative set of names.
|
||||
|
||||
Common combinations:
|
||||
- **Pindle only:** `run_pindle=1`, `order=run_pindle`
|
||||
- **Pindle + Arcane:** `run_pindle=1`, `run_arcane=1`, `order=run_pindle, run_arcane`
|
||||
- **Full farm:** Enable all you want, set `order=` to your preferred sequence
|
||||
|
||||
### Set difficulty
|
||||
|
||||
In `[general]`:
|
||||
|
||||
```ini
|
||||
difficulty=hell ; normal, nightmare, or hell
|
||||
```
|
||||
|
||||
Start with `nightmare` if you're testing. Use `hell` for serious farming.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Save as a profile (optional but recommended)
|
||||
|
||||
Profiles survive git pulls. To save your setup:
|
||||
|
||||
1. Create a folder: `config/profiles/mychar/`
|
||||
2. Create `config/profiles/mychar/profile.ini` with:
|
||||
|
||||
```ini
|
||||
[general]
|
||||
name=mychar
|
||||
difficulty=hell
|
||||
|
||||
[char]
|
||||
type=hammerdin
|
||||
tp_key=b
|
||||
stand_still=capslock
|
||||
show_belt=k
|
||||
weapon_switch=w
|
||||
force_move=e
|
||||
|
||||
[routes]
|
||||
run_pindle=1
|
||||
order=run_pindle
|
||||
```
|
||||
|
||||
3. Select it. The profile is chosen by `config/active_profile.txt`, **not** by
|
||||
`name=` in `params.ini` (that field is only a display/label value). Either:
|
||||
- start the bot and press **`end`** to cycle to your profile, or
|
||||
- put the profile folder name on the first line of `config/active_profile.txt`
|
||||
|
||||
At startup the bot logs which one it loaded:
|
||||
`Profile active: mychar (config\profiles\mychar\profile.ini)`
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Launch
|
||||
|
||||
1. **Start D2R** — set to 1280x720 windowed mode
|
||||
2. **Log in** and select your character
|
||||
3. **Double-click `run_botty.bat`**
|
||||
4. The bot shows a hotkey menu — press **`f11`** to start or pause it. Press **`f12`** to stop.
|
||||
|
||||
> The hotkey menu printed at startup is always the source of truth — it reflects
|
||||
> your actual `config/params.ini` keys. Do **not** press `insert`: that is
|
||||
> `restore_settings_from_backup_key`, which overwrites your current D2R settings
|
||||
> with a previously saved backup.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Optional: Discord notifications
|
||||
|
||||
To get bot status in Discord:
|
||||
|
||||
1. Create a Discord webhook: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL
|
||||
2. In `config/params.ini`, under `[general]`:
|
||||
|
||||
```ini
|
||||
custom_message_hook=https://discord.com/api/webhooks/YOUR/WEBHOOK_URL
|
||||
discord_log_chicken=1
|
||||
discord_log_errors=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Could not find botty conda environment"** — Run `install.bat` first.
|
||||
|
||||
**"D2R is not running"** — Launch D2R before running `run_botty.bat`, or set `auto_login=1` in params.ini with your Battle.net credentials.
|
||||
|
||||
**Bot can't see templates / everything fails** — Make sure D2R is 1280x720 windowed, not fullscreen or borderless.
|
||||
|
||||
**OCR not working** — `install.bat` sets up OCR automatically. Check the install output for "tesserocr: OK" or "pytesseract: OK". At least one must work.
|
||||
|
||||
**Check logs:** Open `log/log.txt` in Notepad for detailed output.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: what to edit
|
||||
|
||||
| File | What to change |
|
||||
|---|---|
|
||||
| `config/params.ini` | `type=`, keybinds, `difficulty=`, routes, Discord webhook |
|
||||
| `config/profiles/*/profile.ini` | Per-character overrides (optional) |
|
||||
| `config/game.ini` | **Don't edit** — templates and UI coordinates |
|
||||
| `config/shop.ini` | **Don't edit** — vendor buy/sell lists |
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -1,5 +0,0 @@
|
||||
"""Botty Next offline-first visual QA harness."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Capture backends for offline fixtures and live observer mode."""
|
||||
|
||||
from botty_next.capture.mss_backend import MssCaptureBackend
|
||||
from botty_next.capture.window import WindowRegion, find_window_region
|
||||
|
||||
__all__ = ["MssCaptureBackend", "WindowRegion", "find_window_region"]
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
from botty_next.capture.window import WindowRegion
|
||||
|
||||
|
||||
class MssCaptureBackend:
|
||||
def grab(self, region: WindowRegion | None = None) -> np.ndarray:
|
||||
with mss.mss() as screen_capture:
|
||||
monitor = region.as_mss_monitor() if region else screen_capture.monitors[1]
|
||||
shot = screen_capture.grab(monitor)
|
||||
|
||||
bgra = np.asarray(shot)
|
||||
return cv2.cvtColor(bgra, cv2.COLOR_BGRA2BGR)
|
||||
|
||||
|
||||
def save_frame(frame: np.ndarray, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cv2.imwrite(str(output), frame):
|
||||
raise RuntimeError(f"failed to write screenshot: {output}")
|
||||
return output
|
||||
@@ -1,47 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowRegion:
|
||||
left: int
|
||||
top: int
|
||||
width: int
|
||||
height: int
|
||||
title: str
|
||||
|
||||
def as_mss_monitor(self) -> dict[str, int]:
|
||||
return {
|
||||
"left": self.left,
|
||||
"top": self.top,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
|
||||
def find_window_region(title_contains: str) -> WindowRegion:
|
||||
import win32gui
|
||||
|
||||
matches: list[WindowRegion] = []
|
||||
|
||||
def collect(hwnd: int, _extra) -> bool:
|
||||
if not win32gui.IsWindowVisible(hwnd):
|
||||
return True
|
||||
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
if title_contains.lower() not in title.lower():
|
||||
return True
|
||||
|
||||
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
if width > 0 and height > 0:
|
||||
matches.append(WindowRegion(left, top, width, height, title))
|
||||
return True
|
||||
|
||||
win32gui.EnumWindows(collect, None)
|
||||
if not matches:
|
||||
raise RuntimeError(f"no visible window found containing title: {title_contains}")
|
||||
|
||||
return max(matches, key=lambda region: region.width * region.height)
|
||||
@@ -1,139 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from botty_next.capture.mss_backend import MssCaptureBackend, save_frame
|
||||
from botty_next.capture.window import find_window_region
|
||||
from botty_next.config import load_config
|
||||
from botty_next.vision.fixtures import load_image
|
||||
from botty_next.vision.ocr import run_tesseract_ocr, save_ocr_preprocess_debug
|
||||
from botty_next.vision.template_matching import match_template, save_match_debug
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="botty-next")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
config_parser = subparsers.add_parser("config")
|
||||
config_subparsers = config_parser.add_subparsers(dest="config_command", required=True)
|
||||
validate_parser = config_subparsers.add_parser("validate")
|
||||
validate_parser.add_argument("-c", "--config", required=True, type=Path)
|
||||
validate_parser.set_defaults(handler=validate_config)
|
||||
|
||||
detect_parser = subparsers.add_parser("detect")
|
||||
detect_parser.add_argument("detector", choices=["template"], help="detector to run")
|
||||
detect_parser.add_argument("--image", required=True, type=Path)
|
||||
detect_parser.add_argument("--template", required=True, type=Path)
|
||||
detect_parser.add_argument("--threshold", type=float, default=0.85)
|
||||
detect_parser.add_argument("--debug-output", type=Path)
|
||||
detect_parser.set_defaults(handler=detect)
|
||||
|
||||
capture_parser = subparsers.add_parser("capture")
|
||||
capture_parser.add_argument("--output", required=True, type=Path)
|
||||
capture_parser.add_argument("--window-title", type=str)
|
||||
capture_parser.set_defaults(handler=capture)
|
||||
|
||||
ocr_parser = subparsers.add_parser("ocr")
|
||||
ocr_parser.add_argument("--image", required=True, type=Path)
|
||||
ocr_parser.add_argument("--lang", default="eng")
|
||||
ocr_parser.add_argument("--psm", type=int, default=6)
|
||||
ocr_parser.add_argument("--tesseract-cmd")
|
||||
ocr_parser.add_argument("--debug-output", type=Path)
|
||||
ocr_parser.set_defaults(handler=ocr)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def validate_config(args: argparse.Namespace) -> int:
|
||||
config = load_config(args.config)
|
||||
print(json.dumps(config.model_dump(mode="json"), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def detect(args: argparse.Namespace) -> int:
|
||||
image = load_image(args.image)
|
||||
template = load_image(args.template)
|
||||
result = match_template(image, template, threshold=args.threshold)
|
||||
|
||||
if args.debug_output:
|
||||
save_match_debug(image, result, args.debug_output)
|
||||
|
||||
print(json.dumps(_result_to_dict(result), indent=2))
|
||||
return 0 if result.passed else 1
|
||||
|
||||
|
||||
def capture(args: argparse.Namespace) -> int:
|
||||
region = find_window_region(args.window_title) if args.window_title else None
|
||||
frame = MssCaptureBackend().grab(region)
|
||||
output = save_frame(frame, args.output)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"output": str(output),
|
||||
"shape": tuple(map(int, frame.shape)),
|
||||
"window": region.title if region else None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def ocr(args: argparse.Namespace) -> int:
|
||||
image = load_image(args.image)
|
||||
if args.debug_output:
|
||||
save_ocr_preprocess_debug(image, args.debug_output)
|
||||
|
||||
try:
|
||||
result = run_tesseract_ocr(
|
||||
image,
|
||||
lang=args.lang,
|
||||
psm=args.psm,
|
||||
tesseract_cmd=args.tesseract_cmd,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"error": str(exc),
|
||||
"debug_output": str(args.debug_output) if args.debug_output else None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 2
|
||||
|
||||
print(json.dumps(_ocr_result_to_dict(result), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _result_to_dict(result) -> dict:
|
||||
return {
|
||||
"confidence": result.confidence,
|
||||
"bbox": result.bbox,
|
||||
"passed": result.passed,
|
||||
"method": result.method,
|
||||
"debug": result.debug,
|
||||
}
|
||||
|
||||
|
||||
def _ocr_result_to_dict(result) -> dict:
|
||||
return {
|
||||
"text": result.text,
|
||||
"confidence": result.confidence,
|
||||
"bbox": result.bbox,
|
||||
"debug": result.debug,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Configuration loading and validation."""
|
||||
|
||||
from botty_next.config.models import BottyNextConfig, load_config
|
||||
|
||||
__all__ = ["BottyNextConfig", "load_config"]
|
||||
@@ -1,13 +0,0 @@
|
||||
profile_name: local
|
||||
capture:
|
||||
backend: fixture
|
||||
monitor: 1
|
||||
fps_limit: 10
|
||||
window_title: null
|
||||
vision:
|
||||
template_threshold: 0.85
|
||||
debug_output_dir: botty_next/debug/output
|
||||
input:
|
||||
enabled: false
|
||||
dry_run: true
|
||||
emergency_stop_key: f12
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class CaptureConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
backend: Literal["fixture", "mss", "dxcam"] = "fixture"
|
||||
monitor: int = 1
|
||||
fps_limit: int = Field(default=10, ge=1, le=240)
|
||||
window_title: str | None = None
|
||||
|
||||
|
||||
class VisionConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
template_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
|
||||
debug_output_dir: Path = Path("botty_next/debug/output")
|
||||
|
||||
|
||||
class InputConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
dry_run: bool = True
|
||||
emergency_stop_key: str = "f12"
|
||||
|
||||
@field_validator("dry_run")
|
||||
@classmethod
|
||||
def dry_run_required_when_disabled(cls, value: bool) -> bool:
|
||||
return value
|
||||
|
||||
|
||||
class BottyNextConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profile_name: str = "local"
|
||||
capture: CaptureConfig = Field(default_factory=CaptureConfig)
|
||||
vision: VisionConfig = Field(default_factory=VisionConfig)
|
||||
input: InputConfig = Field(default_factory=InputConfig)
|
||||
|
||||
@field_validator("input")
|
||||
@classmethod
|
||||
def input_must_be_explicit_and_dry_run_by_default(cls, value: InputConfig) -> InputConfig:
|
||||
if value.enabled and value.dry_run is False:
|
||||
raise ValueError("live input cannot be enabled without a future explicit safety gate")
|
||||
return value
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> BottyNextConfig:
|
||||
config_path = Path(path)
|
||||
with config_path.open("r", encoding="utf-8") as handle:
|
||||
raw = yaml.safe_load(handle) or {}
|
||||
return BottyNextConfig.model_validate(raw)
|
||||
@@ -1 +0,0 @@
|
||||
"""Debug image and report output helpers."""
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Input abstraction layer.
|
||||
|
||||
Live input is intentionally not implemented in the bootstrap harness.
|
||||
"""
|
||||
@@ -1 +0,0 @@
|
||||
"""Offline/private routine replay harness."""
|
||||
@@ -1 +0,0 @@
|
||||
"""State detection and transition logic."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the Botty Next harness."""
|
||||
@@ -1,12 +0,0 @@
|
||||
from botty_next.capture.window import WindowRegion
|
||||
|
||||
|
||||
def test_window_region_converts_to_mss_monitor() -> None:
|
||||
region = WindowRegion(left=10, top=20, width=640, height=480, title="Example")
|
||||
|
||||
assert region.as_mss_monitor() == {
|
||||
"left": 10,
|
||||
"top": 20,
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
from botty_next.cli import main
|
||||
|
||||
|
||||
def test_config_validate_cli_starts(capsys) -> None:
|
||||
exit_code = main(["config", "validate", "-c", "botty_next/config/default.yaml"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert '"profile_name": "local"' in captured.out
|
||||
|
||||
|
||||
def test_detect_cli_runs_template_detector(capsys) -> None:
|
||||
exit_code = main(
|
||||
[
|
||||
"detect",
|
||||
"template",
|
||||
"--image",
|
||||
"fixtures/screenshots/sample_scene.ppm",
|
||||
"--template",
|
||||
"fixtures/templates/sample_marker.ppm",
|
||||
"--threshold",
|
||||
"0.99",
|
||||
]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 0
|
||||
assert '"passed": true' in captured.out
|
||||
@@ -1,10 +0,0 @@
|
||||
from botty_next.config import load_config
|
||||
|
||||
|
||||
def test_load_default_config() -> None:
|
||||
config = load_config("botty_next/config/default.yaml")
|
||||
|
||||
assert config.profile_name == "local"
|
||||
assert config.capture.backend == "fixture"
|
||||
assert config.input.enabled is False
|
||||
assert config.input.dry_run is True
|
||||
@@ -1,13 +0,0 @@
|
||||
from botty_next.vision.fixtures import load_screenshot, load_template
|
||||
|
||||
|
||||
def test_load_sample_screenshot_fixture() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
assert image.shape == (8, 8, 3)
|
||||
|
||||
|
||||
def test_load_sample_template_fixture() -> None:
|
||||
template = load_template("sample_marker.ppm")
|
||||
|
||||
assert template.shape == (3, 3, 3)
|
||||
@@ -1,42 +0,0 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from botty_next.vision.fixtures import load_screenshot
|
||||
from botty_next.vision.ocr import preprocess_for_ocr, run_tesseract_ocr, save_ocr_preprocess_debug
|
||||
|
||||
|
||||
def test_preprocess_for_ocr_returns_thresholded_image() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
processed = preprocess_for_ocr(image)
|
||||
|
||||
assert processed.ndim == 2
|
||||
assert processed.shape == (16, 16)
|
||||
|
||||
|
||||
def test_save_ocr_preprocess_debug(tmp_path) -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
|
||||
output = save_ocr_preprocess_debug(image, tmp_path / "ocr.png")
|
||||
|
||||
assert output.exists()
|
||||
|
||||
|
||||
def test_run_tesseract_ocr_uses_pytesseract_adapter(monkeypatch) -> None:
|
||||
fake = SimpleNamespace(
|
||||
Output=SimpleNamespace(DICT="dict"),
|
||||
pytesseract=SimpleNamespace(tesseract_cmd=None),
|
||||
image_to_string=lambda *_args, **_kwargs: "Short Sword\n",
|
||||
image_to_data=lambda *_args, **_kwargs: {"conf": ["95", "-1", "85"]},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "pytesseract", fake)
|
||||
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
result = run_tesseract_ocr(image, tesseract_cmd="C:/Tesseract/tesseract.exe")
|
||||
|
||||
assert result.text == "Short Sword"
|
||||
assert result.confidence == pytest.approx(0.9)
|
||||
assert result.debug["backend"] == "pytesseract"
|
||||
assert fake.pytesseract.tesseract_cmd == "C:/Tesseract/tesseract.exe"
|
||||
@@ -1,24 +0,0 @@
|
||||
from botty_next.vision.fixtures import load_screenshot, load_template
|
||||
from botty_next.vision.template_matching import match_template, save_match_debug
|
||||
|
||||
|
||||
def test_template_match_finds_sample_marker() -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
template = load_template("sample_marker.ppm")
|
||||
|
||||
result = match_template(image, template, threshold=0.99)
|
||||
|
||||
assert result.passed is True
|
||||
assert result.confidence >= 0.99
|
||||
assert result.bbox == (3, 2, 3, 3)
|
||||
assert "image_shape" in result.debug
|
||||
|
||||
|
||||
def test_template_match_can_save_debug_image(tmp_path) -> None:
|
||||
image = load_screenshot("sample_scene.ppm")
|
||||
template = load_template("sample_marker.ppm")
|
||||
result = match_template(image, template, threshold=0.99)
|
||||
|
||||
output = save_match_debug(image, result, tmp_path / "marked.png")
|
||||
|
||||
assert output.exists()
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Vision helpers and detectors."""
|
||||
|
||||
from botty_next.vision.ocr import OcrResult, preprocess_for_ocr, run_tesseract_ocr
|
||||
from botty_next.vision.template_matching import MatchResult, match_template
|
||||
|
||||
__all__ = ["MatchResult", "OcrResult", "match_template", "preprocess_for_ocr", "run_tesseract_ocr"]
|
||||
@@ -1,26 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_image(path: str | Path, *, grayscale: bool = False) -> np.ndarray:
|
||||
image_path = Path(path)
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"image fixture does not exist: {image_path}")
|
||||
|
||||
flag = cv2.IMREAD_GRAYSCALE if grayscale else cv2.IMREAD_COLOR
|
||||
image = cv2.imread(str(image_path), flag)
|
||||
if image is None:
|
||||
raise ValueError(f"OpenCV could not read image fixture: {image_path}")
|
||||
return image
|
||||
|
||||
|
||||
def load_screenshot(name: str, root: str | Path = "fixtures/screenshots") -> np.ndarray:
|
||||
return load_image(Path(root) / name)
|
||||
|
||||
|
||||
def load_template(name: str, root: str | Path = "fixtures/templates") -> np.ndarray:
|
||||
return load_image(Path(root) / name)
|
||||
@@ -1,98 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OcrResult:
|
||||
text: str
|
||||
confidence: float
|
||||
bbox: tuple[int, int, int, int] | None
|
||||
debug: dict[str, Any]
|
||||
|
||||
|
||||
def preprocess_for_ocr(image: np.ndarray, *, scale: float = 2.0) -> np.ndarray:
|
||||
if image.size == 0:
|
||||
raise ValueError("image is empty")
|
||||
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
||||
if scale != 1.0:
|
||||
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
denoised = cv2.GaussianBlur(gray, (3, 3), 0)
|
||||
return cv2.adaptiveThreshold(
|
||||
denoised,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY,
|
||||
31,
|
||||
7,
|
||||
)
|
||||
|
||||
|
||||
def run_tesseract_ocr(
|
||||
image: np.ndarray,
|
||||
*,
|
||||
lang: str = "eng",
|
||||
psm: int = 6,
|
||||
tesseract_cmd: str | None = None,
|
||||
) -> OcrResult:
|
||||
try:
|
||||
pytesseract = import_module("pytesseract")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"pytesseract is not installed; run install.bat or install requirements.txt"
|
||||
) from exc
|
||||
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
|
||||
processed = preprocess_for_ocr(image)
|
||||
config = f"--psm {psm}"
|
||||
text = pytesseract.image_to_string(processed, lang=lang, config=config).strip()
|
||||
confidences = _read_confidences(
|
||||
pytesseract.image_to_data(
|
||||
processed,
|
||||
lang=lang,
|
||||
config=config,
|
||||
output_type=pytesseract.Output.DICT,
|
||||
)
|
||||
)
|
||||
confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||
return OcrResult(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=None,
|
||||
debug={
|
||||
"backend": "pytesseract",
|
||||
"lang": lang,
|
||||
"psm": psm,
|
||||
"preprocessed_shape": tuple(map(int, processed.shape)),
|
||||
"word_confidences": confidences,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_ocr_preprocess_debug(image: np.ndarray, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not cv2.imwrite(str(output), preprocess_for_ocr(image)):
|
||||
raise RuntimeError(f"failed to write OCR debug image: {output}")
|
||||
return output
|
||||
|
||||
|
||||
def _read_confidences(data: dict[str, list[Any]]) -> list[float]:
|
||||
values: list[float] = []
|
||||
for raw in data.get("conf", []):
|
||||
try:
|
||||
confidence = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if confidence >= 0:
|
||||
values.append(confidence / 100.0)
|
||||
return values
|
||||
@@ -1,93 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchResult:
|
||||
confidence: float
|
||||
bbox: tuple[int, int, int, int]
|
||||
passed: bool
|
||||
method: str
|
||||
debug: dict[str, Any]
|
||||
|
||||
|
||||
def _as_gray(image: np.ndarray) -> np.ndarray:
|
||||
if image.ndim == 2:
|
||||
return image
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
def match_template(
|
||||
image: np.ndarray,
|
||||
template: np.ndarray,
|
||||
*,
|
||||
threshold: float = 0.85,
|
||||
method: int = cv2.TM_CCOEFF_NORMED,
|
||||
) -> MatchResult:
|
||||
if image.size == 0:
|
||||
raise ValueError("image is empty")
|
||||
if template.size == 0:
|
||||
raise ValueError("template is empty")
|
||||
if template.shape[0] > image.shape[0] or template.shape[1] > image.shape[1]:
|
||||
raise ValueError("template cannot be larger than image")
|
||||
|
||||
image_gray = _as_gray(image)
|
||||
template_gray = _as_gray(template)
|
||||
response = cv2.matchTemplate(image_gray, template_gray, method)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(response)
|
||||
|
||||
if method in (cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED):
|
||||
top_left = min_loc
|
||||
confidence = 1.0 - float(min_val)
|
||||
else:
|
||||
top_left = max_loc
|
||||
confidence = float(max_val)
|
||||
|
||||
width = int(template.shape[1])
|
||||
height = int(template.shape[0])
|
||||
bbox = (int(top_left[0]), int(top_left[1]), width, height)
|
||||
return MatchResult(
|
||||
confidence=confidence,
|
||||
bbox=bbox,
|
||||
passed=confidence >= threshold,
|
||||
method=_method_name(method),
|
||||
debug={
|
||||
"threshold": threshold,
|
||||
"min_value": float(min_val),
|
||||
"max_value": float(max_val),
|
||||
"min_location": tuple(map(int, min_loc)),
|
||||
"max_location": tuple(map(int, max_loc)),
|
||||
"image_shape": tuple(map(int, image.shape)),
|
||||
"template_shape": tuple(map(int, template.shape)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_match_debug(image: np.ndarray, result: MatchResult, output_path: str | Path) -> Path:
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
marked = image.copy()
|
||||
x, y, width, height = result.bbox
|
||||
color = (0, 255, 0) if result.passed else (0, 0, 255)
|
||||
cv2.rectangle(marked, (x, y), (x + width, y + height), color, 2)
|
||||
cv2.imwrite(str(output), marked)
|
||||
return output
|
||||
|
||||
|
||||
def _method_name(method: int) -> str:
|
||||
names = {
|
||||
cv2.TM_CCOEFF: "TM_CCOEFF",
|
||||
cv2.TM_CCOEFF_NORMED: "TM_CCOEFF_NORMED",
|
||||
cv2.TM_CCORR: "TM_CCORR",
|
||||
cv2.TM_CCORR_NORMED: "TM_CCORR_NORMED",
|
||||
cv2.TM_SQDIFF: "TM_SQDIFF",
|
||||
cv2.TM_SQDIFF_NORMED: "TM_SQDIFF_NORMED",
|
||||
}
|
||||
return names.get(method, str(method))
|
||||
@@ -130,6 +130,13 @@ corpse=459,195,414,213
|
||||
chat_icon=7,555,43,41
|
||||
left_panel_header=0,0,455,56
|
||||
right_panel_header=830,0,455,56
|
||||
; A CENTRED panel (Chronicle, and anything else that opens mid-screen) puts its
|
||||
; close button outside both header ROIs above — the Chronicle's X sits at
|
||||
; (952, 56), inside right_panel_header's x-range but exactly at its 56px height
|
||||
; boundary, so the match centre falls out. Nothing then closes it, and a
|
||||
; centred panel blanks every later template search: measured as a 66s
|
||||
; click_red_portal failure on 2026-08-28 (Bug 31's residual).
|
||||
center_panel_header=400,0,620,92
|
||||
npc_dialogue=456,0,30,150
|
||||
bind_skill=516,619,251,29
|
||||
quest_skill_btn=284,455,710,121
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
; If you chicken/die repeatedly, drop to Nightmare first.
|
||||
difficulty=hell
|
||||
; name: bot profile name used in logs/messages and mod launch option replacement
|
||||
name=fistman
|
||||
name=profile1
|
||||
; randomize_runs: 0 = run in listed order, 1 = shuffle run order
|
||||
randomize_runs=0
|
||||
; target_tz: target Terror Zone id (leave as default unless you know the mapping)
|
||||
@@ -55,6 +55,10 @@ discord_log_errors=1
|
||||
discord_status_runs=10
|
||||
; discord_status_count: legacy fallback, send periodic status every X games (blank/0 disables)
|
||||
discord_status_count=20
|
||||
; discord_timing_report_h: post a timing + failure digest to Discord every N hours
|
||||
; (0 disables). Aggregated from the same TL> timeline the log uses, so the report and the
|
||||
; log can never disagree. The window resets on every send.
|
||||
discord_timing_report_h=2
|
||||
; pickup_drought_window: warn/alert after this many consecutive games with zero
|
||||
; item pickups. Raise this if you run a strict pickit and 0-pickup streaks are
|
||||
; expected/normal for you (a fast boss-only rush route with a tight filter can
|
||||
@@ -65,9 +69,9 @@ message_api_type=discord
|
||||
|
||||
; breaks
|
||||
; break_length_m: scheduled break duration in minutes (0 = disabled)
|
||||
break_length_m=0
|
||||
break_length_m=15
|
||||
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
|
||||
max_runtime_before_break_m=0
|
||||
max_runtime_before_break_m=120
|
||||
|
||||
; timers / fail handling
|
||||
; d2r_path: Diablo II: Resurrected install path
|
||||
@@ -135,12 +139,22 @@ error=1
|
||||
[stealth]
|
||||
; Multiplies all wait() calls by a random value in this range each call
|
||||
; 1.0 = no change. Set range wider for more human-like timing variation.
|
||||
wait_jitter_min = 0.85
|
||||
; wait_jitter_* multiplies EVERY wait() in the codebase, not just mouse moves.
|
||||
; The floor is clamped to jitter_min*0.8, so 0.85 allowed waits to come out 32%
|
||||
; SHORT — the one place jitter stole time from an action instead of adding it
|
||||
; between actions. 0.95 keeps waits at-or-longer (effective floor 0.76 vs the
|
||||
; old 0.68, and the Gaussian centre sits above 1.0), so a wait can no longer
|
||||
; expire before the UI it was waiting on has settled.
|
||||
wait_jitter_min = 0.95
|
||||
wait_jitter_max = 1.20
|
||||
|
||||
; Extra pixel variance added to every mouse click (on top of existing randomize=5)
|
||||
; 0 = off, 10 = +/-10px extra random offset per click
|
||||
click_variance = 8
|
||||
; NOTE: click position randomization lives at the CALL SITES (npc_manager,
|
||||
; ui/waypoint) where it is tuned to real button geometry — 2-3px for NPC
|
||||
; hover targets, +/-9px inside a 47px waypoint button. There is deliberately
|
||||
; no global click-offset knob: stacking one on top of those is what starts
|
||||
; missing NPCs (see CLAUDE.md Bugs 3/4/6/7).
|
||||
|
||||
; Re-shuffle run order after completing a full rotation (vs only at session start)
|
||||
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
|
||||
@@ -156,7 +170,14 @@ skip_run_chance = 10
|
||||
afk_break_chance = 5
|
||||
; Break duration range in minutes
|
||||
afk_break_min_m = 2
|
||||
afk_break_max_m = 12
|
||||
; 12 -> 7 (2026-08-28). The configured value is NOT the real ceiling:
|
||||
; maybe_afk_break does wait(minutes*60, minutes*60*1.5) and wait() then applies
|
||||
; its own jitter (up to 1.44x), so a "12 minute" break can idle for ~26 minutes.
|
||||
; Measured: an 11.9m break ran 1167.7s = 19.5m, and a 20:56 scheduled break ran
|
||||
; 25.5m. A ~25m idle is what left D2R at character select once, unable to
|
||||
; re-enter, with the bot spawning a new process every ~20s.
|
||||
; 7 caps the real-world worst case near 15m, inside the range proven to resume.
|
||||
afk_break_max_m = 7
|
||||
|
||||
; Vary run/battle duration by a Gaussian factor (default +/-15%)
|
||||
; 0.0 = no variation, 0.3 = up to +/-30% variation
|
||||
@@ -167,6 +188,8 @@ micro_pause_min_ms = 20
|
||||
micro_pause_max_ms = 120
|
||||
|
||||
; Vary kill time to avoid perfectly consistent boss fight durations
|
||||
; Varies boss attack duration. Only ever LENGTHENS the fight (1.0-1.4x) —
|
||||
; shortening it risks leaving a boss alive, which is a failed run, not stealth.
|
||||
vary_kill_time = 1
|
||||
|
||||
; Human mouse curve complexity (1.0 = default, 0.5 = more direct, 1.5 = more winding)
|
||||
@@ -174,8 +197,13 @@ human_curve_complexity = 1.0
|
||||
|
||||
; Arrival-to-click delay: human-like pause between mouse arriving and clicking (milliseconds)
|
||||
; 50-800ms range simulates "is this the right thing?" hesitation
|
||||
; Hesitation between arriving at a target and clicking it. OFF by default:
|
||||
; it applies to EVERY click, so the old 800ms ceiling could add minutes per
|
||||
; run. Enable with click_delay_enabled=1 if you want it; 250 is a realistic
|
||||
; ceiling that stays affordable.
|
||||
click_delay_enabled = 0
|
||||
click_delay_min_ms = 50
|
||||
click_delay_max_ms = 800
|
||||
click_delay_max_ms = 250
|
||||
|
||||
; Key press duration variance: how long a key is held (milliseconds)
|
||||
; Most presses are short (20-100ms), some linger (up to 200ms)
|
||||
@@ -189,11 +217,42 @@ skill_hesitation_max_ms = 300
|
||||
|
||||
; Wrong waypoint chance: 2-3% of selecting wrong TP portal then correcting
|
||||
; Humans occasionally misclick waypoint targets
|
||||
; ── session rhythm ───────────────────────────────────────────────────────────
|
||||
; The strongest remaining signal is not per-click timing: averaged over a
|
||||
; session jitter converges, but a player who starts at the same hour, plays the
|
||||
; same length and never does anything unproductive does not.
|
||||
;
|
||||
; session_budget_h: stop the bot after roughly this many hours (0 = unlimited).
|
||||
; Actual length is rolled per run at 0.65-1.35x, so consecutive days differ.
|
||||
; Raised to 10 for an overnight run: the budget is rolled at 0.65-1.35x, so 6
|
||||
; could have stopped the bot after 3.9h and left it idle. 10 guarantees at
|
||||
; least ~6.5h while keeping the day-to-day variation.
|
||||
; Set to 8 for a >= 5h run (2026-08-28). The value is rolled at 0.65-1.35x, so
|
||||
; session_budget_h=5 would average 5h but could stop after 3.25h. 8 gives a
|
||||
; 5.2-10.8h window, guaranteeing the 5 hours while keeping day-to-day variation.
|
||||
session_budget_h = 8
|
||||
; Small cursor movement during long idles. Between actions the cursor otherwise
|
||||
; sits exactly where the last click left it.
|
||||
idle_drift_enabled = 1
|
||||
; Per-game chicken threshold spread. Only ever RAISES the threshold (safer) —
|
||||
; a fixed 0.40 every game is a precise tell, but lowering it would cost deaths.
|
||||
chicken_variance = 0.08
|
||||
; Chance of an unproductive town action (open inventory, close it).
|
||||
town_browse_chance = 0.06
|
||||
; Chance of walking past an item the filter wanted. Costs real loot — keep low.
|
||||
pickup_skip_chance = 0.02
|
||||
wrong_waypoint_chance = 0.025
|
||||
|
||||
; Skill mistake chance: 1-2% chance of miscasting and correcting
|
||||
; Simulates human error during combat
|
||||
skill_mistake_chance = 0.015
|
||||
; Presses a DIFFERENT bound skill before the intended one. Disabled: on a
|
||||
; character mid-fight this swaps the active skill or aura, and the attack
|
||||
; sequences rely on ending in a known skill state. The stealth value is small
|
||||
; next to the risk of a stray keypress landing on something that is not a skill
|
||||
; at all — which is exactly how the loot filter ended up being toggled all
|
||||
; session (params.ini [paladin] vigor=f4, unbound on this char, F4 = filter).
|
||||
; Set back to 0.015 to re-enable.
|
||||
skill_mistake_chance = 0
|
||||
|
||||
[routes]
|
||||
; Controls which farm runs Botty performs each game.
|
||||
@@ -216,6 +275,7 @@ skill_mistake_chance = 0.015
|
||||
; run_countess (Act 1 Forgotten Tower)
|
||||
; run_mephisto (Act 3 Durance of Hate)
|
||||
; run_baal (Act 5 Throne of Destruction)
|
||||
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
|
||||
order=run_pindle, run_diablo
|
||||
|
||||
[char]
|
||||
@@ -244,6 +304,18 @@ safer_routines=1
|
||||
|
||||
; num_loot_columns: Number of empty columns from left to right of inventory to be used for looting.
|
||||
; Store charms, etc. to the right of the inventory.
|
||||
;
|
||||
; This drives ui_roi[restricted_inventory_area] in config.py: the columns to the RIGHT of
|
||||
; this count are protected — mouse._is_clicking_safe() cancels any click landing there, so
|
||||
; the bot never sells, drops or moves anything in them. Both tomes MUST live in that
|
||||
; reserved area: common.tome_state() only searches restricted_inventory_area for them.
|
||||
; 3 columns = 12 loot slots, 28 reserved
|
||||
; 4 columns = 16 loot slots, 24 reserved <- now
|
||||
; 6 columns = 24 loot slots, 16 reserved (tried 2026-08-27, reverted — shrinking the
|
||||
; reserve is the wrong direction when the
|
||||
; books and the charms both live there)
|
||||
; Lower this if the tomes or charms are running out of room; raise it only if the reserved
|
||||
; area is provably empty.
|
||||
num_loot_columns=4
|
||||
|
||||
; game hotkeys:
|
||||
@@ -365,15 +437,43 @@ runs_per_repair=5
|
||||
; 2026-06-10: switched to a5_larzuk — session logs showed Halbu detection failing
|
||||
; 100% (body score ~0.39) and each failed A4 trip desynced the bot's act state.
|
||||
repair_npc=a5_larzuk
|
||||
; resurrect_npc: which NPC revives the mercenary. Blank = whichever act the character is
|
||||
; already in. Set to a4_tyrael to always use Act 4 Tyrael.
|
||||
; Measured 2026-08-27 on this setup:
|
||||
; A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever
|
||||
; A5 Qual-Kehk fail 113.6s / 52.0s / 163.1s / 72.6s - 43 errors
|
||||
; Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC in
|
||||
; the route. A failed hunt costs more than the whole A5->A4 trip, and one 163s hunt pushed
|
||||
; the town visit past max_maintenance_time_s and killed the game.
|
||||
resurrect_npc=a4_tyrael
|
||||
; runs_per_stash: stash/sell every X runs (blank/0 disables)
|
||||
runs_per_stash=4
|
||||
; sell_junk: 1 = vendor non-keep items automatically
|
||||
sell_junk=1
|
||||
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name (recommended safety)
|
||||
protect_shields_from_sell=1
|
||||
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name.
|
||||
; DISABLED 2026-08-27. This is redundant: the EQUIPPED shield is already protected
|
||||
; positionally by mouse._is_clicking_safe(), which cancels any click landing in
|
||||
; ui_roi[equipped_inventory_area] while the inventory is open. The name rule only ever
|
||||
; hit shields sitting in the INVENTORY grid, which the pickit had already judged.
|
||||
; Cost of leaving it on: 130 blocked sells in one day (FIEND SHIELD 64, AERIN SHIELD 41,
|
||||
; HERALDIC SHIELD 23...). The same shields were re-evaluated and re-blocked every single
|
||||
; game — "Discarding FIEND SHIELD." then "Blocked sell for protected item: FIEND SHIELD" —
|
||||
; so they could never leave the pack and permanently occupied slots.
|
||||
protect_shields_from_sell=0
|
||||
; protect_charms_from_sell: 1 = never drop/vendor charms regardless of pickit verdict
|
||||
; (recommended safety — a misread charm can't be un-dropped). Set to 0 to let your
|
||||
; pickit rules decide keep/discard for charms same as any other item.
|
||||
; (safety net — a misread charm can't be un-sold). Set to 0 to let your pickit rules
|
||||
; decide keep/discard for charms same as any other item.
|
||||
;
|
||||
; KEEP THIS AT 1. Charms only give their bonus while they sit in the INVENTORY, and the
|
||||
; resistances matter in nightmare. Briefly set to 0 on 2026-08-27 to reclaim slots and
|
||||
; that was wrong — it vendored LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF
|
||||
; FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS SMALL CHARM OF VITA (+20 life,
|
||||
; cold resist +7%) before it was reverted.
|
||||
;
|
||||
; The "673 blocked sells" in the log are NOT a bug to fix by selling — they are the guard
|
||||
; doing its job, repeatedly, on charms the pickit does not have a keep rule for. If those
|
||||
; slots are needed, the answer is a pickit rule that keeps the good charms and stops
|
||||
; picking up junk ones, NOT disabling this guard.
|
||||
protect_charms_from_sell=1
|
||||
; pick_rares_for_gold: 1 = pick all yellow (rare) ground items; non-keep rares will be sold
|
||||
; Requires sell_junk=1 to convert extra pickups into gold.
|
||||
@@ -386,11 +486,18 @@ pick_gold=1
|
||||
stash_destination=0,1,2,3
|
||||
; Add these possible gems to "transmute" to transmute them:
|
||||
; chipped, flawed, standard, flawless
|
||||
transmute=flawless
|
||||
; DISABLED 2026-08-26: gems are stashed to the GEMS tab and left alone, no cube/convert
|
||||
; runs. An empty value makes run_transmutes() bail at "No gem tiers configured", which
|
||||
; also holds for force=True (tools/gem_transmute.py), unlike transmute_every_x_game=0.
|
||||
; Re-enable by listing tiers again, e.g. transmute=flawless
|
||||
transmute=
|
||||
;how often we want to run transmute routine(e.g. every 100 games)
|
||||
transmute_every_x_game=60
|
||||
; number of stash tabs (ROTW has more than 6, adjust as needed)
|
||||
stash_tabs=6
|
||||
; number of stash tabs — drives tab click geometry in inventory/common.tab_properties().
|
||||
; MUST match the tab bar on screen. This client shows 5: PERSONAL SHARED GEMS MATERIALS RUNES.
|
||||
; It was 6, which made tab_properties compute centres of 63/127/192/256/320/384 while the
|
||||
; real label centres are 68/144/220/295/370 — tabs 2, 3 and 4 clicked the gaps between tabs.
|
||||
stash_tabs=5
|
||||
; potion transmute settings
|
||||
; convert_rejuv: 1 = convert regular Rejuv Potions to Full Rejuv via cube (3 -> 1)
|
||||
convert_rejuv=1
|
||||
@@ -612,6 +719,53 @@ buff_1=
|
||||
buff_2=
|
||||
|
||||
|
||||
; ==== Run: Cold Plains clear ====
|
||||
; Roams an outdoor area and kills with ONE configured skill. Works with any build
|
||||
; and from clvl 1, so it is the levelling counterpart to the boss runs.
|
||||
; Enable by adding run_cold_plains to [routes] order above.
|
||||
;
|
||||
; PREREQUISITE: the character must already have the destination waypoint.
|
||||
; A fresh character has none - walk there once by hand first.
|
||||
[cold_plains]
|
||||
; Any Act 1 waypoint label from src/ui/waypoint.py _WAYPOINTS, e.g.
|
||||
; "Cold Plains", "Stony Field", "Dark Wood", "Black Marsh".
|
||||
area=Cold Plains
|
||||
; Key holding the attack skill. NOTE Fireball is clvl 12 - on a fresh character
|
||||
; put Fire Bolt (clvl 1) here and re-bind to Fireball later; nothing else changes.
|
||||
attack_hotkey=
|
||||
; Most sorc/caster skills cast on right click. Melee builds want "left".
|
||||
attack_button=right
|
||||
; How many roam-or-fight steps before heading back to town.
|
||||
max_steps=12
|
||||
; Casts per engagement before re-scanning for targets.
|
||||
casts_per_target=4
|
||||
; Ignore anything further than this many px from the character.
|
||||
target_radius=600
|
||||
; Delay between casts. Raise if you out-run your cast animation.
|
||||
cast_delay=0.25
|
||||
; Hard runtime cap per run, seconds - stops a bad area pinning the bot.
|
||||
max_runtime_s=180
|
||||
; Engagements per step, so an unkillable/misdetected target cannot stall the run.
|
||||
max_engagements=8
|
||||
|
||||
[baal_xp]
|
||||
; Baal XP farm: join public Baal games, hide, collect XP, leave. Repeat.
|
||||
; Enable by adding run_baal_xp to [routes] order above.
|
||||
enabled=1
|
||||
; Substring to match against game names (case-insensitive). Empty = join first game.
|
||||
game_name_filter=
|
||||
; Max seconds to wait in a game before leaving (XP keeps ticking while you hide).
|
||||
max_wait_s=900
|
||||
; Leave early if XP gained in this game reaches this value.
|
||||
xp_threshold=50000000
|
||||
; Leave immediately if HP drops below this percentage (chicken).
|
||||
min_hp_pct=40
|
||||
; Screen position (client coords) to walk to and stand still while hiding.
|
||||
hide_x=640
|
||||
hide_y=360
|
||||
; Max seconds to wait for a matching game to appear in the browser.
|
||||
join_timeout_s=60
|
||||
|
||||
[advanced_options]
|
||||
; startup hotkeys
|
||||
; select_runs_key: open run selector UI
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,258 +0,0 @@
|
||||
# Anti-Detection Framework for Botty-Go
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the multi-layered anti-detection system built into botty-go.
|
||||
Each layer addresses a specific detection vector that Blizzard and modern anti-cheat
|
||||
systems use to identify bots.
|
||||
|
||||
---
|
||||
|
||||
## 1. Server-Side Behavior Analysis Countermeasures
|
||||
|
||||
### Detection: Session length, timing consistency, pathing patterns, repetition
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 1a. Variable Session Scheduling
|
||||
- **Implementation:** `internal/schedule/scheduler.go`
|
||||
- Randomized session start times using a circadian model
|
||||
- Simulated human sleep patterns: 6-10 hour breaks between sessions
|
||||
- Weekend/weekday behavior variance (humans play differently on weekends)
|
||||
- Random session lengths: 20min to 6hours with exponential distribution
|
||||
- Occasional "just 5 more minutes" overtime and "I'm tired" early stops
|
||||
|
||||
#### 1b. Stochastic Pathing
|
||||
- **Implementation:** `internal/pather/stochastic.go`
|
||||
- Add deliberate pathing imperfection: 5-15% deviation from optimal route
|
||||
- Occasional wrong-way teleports followed by course correction
|
||||
- Non-optimal waypoint selections (humants don't always take shortest path)
|
||||
- Variable route ordering with cooldown-dependent choices
|
||||
- 2-3% chance of "getting lost" and using wrong waypoint first
|
||||
|
||||
#### 1c. Skill Rotation Variance
|
||||
- **Implementation:** `internal/char/behavior.go`
|
||||
- Variable pre-buff timing (humans rush sometimes, sometimes take time)
|
||||
- Occasional wrong skill selection followed by correction
|
||||
- Potion usage with human-like hesitation (check multiple times before drinking)
|
||||
- Merc healing variance: sometimes forget, sometimes over-heal
|
||||
|
||||
#### 1d. Route Randomization with Context
|
||||
- **Implementation:** `internal/bot/route_planner.go`
|
||||
- Dynamic route selection based on:
|
||||
- Time since last run of each type
|
||||
- Current TP scroll count (humans adapt)
|
||||
- Gem/transmute urgency
|
||||
- Occasional "feels like it" switches
|
||||
- Never perfect round-robin; use weighted probability with drift
|
||||
|
||||
#### 1e. Farming Repetition Masking
|
||||
- Never run the same route more than 8 times consecutively
|
||||
- Insert "town breaks": stash visit, shrine check, repair, gamble
|
||||
- 1-2% chance of "I'm bored, switching to different run" mid-session
|
||||
- Vary kill strategies: sometimes rush, sometimes methodical
|
||||
|
||||
---
|
||||
|
||||
## 2. Warden / Client Integrity Countermeasures
|
||||
|
||||
### Detection: Loaded modules, injected DLLs, memory signatures, debuggers
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 2a. Pixel-Only Architecture (No Memory Access)
|
||||
- **Implementation:** entire bot reads game state ONLY via screenshots
|
||||
- NO memory reading, NO DLL injection, NO process hooking
|
||||
- Same attack surface as a human with a camera pointed at the screen
|
||||
- This is the #1 defense: if you only use screen capture + input simulation,
|
||||
there's nothing to scan in process memory
|
||||
|
||||
#### 2b. Clean Process Environment
|
||||
- **Implementation:** `internal/runtime/clean_env.go`
|
||||
- Standard Go binary with no suspicious imports
|
||||
- No debuggers, no memory readers, no process manipulation
|
||||
- Run as a normal application, not injected
|
||||
|
||||
#### 2c. Overlay Avoidance
|
||||
- Never draw on top of game window
|
||||
- No window hooking or injection
|
||||
- Screenshot from a separate thread, not an overlay
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Pattern Analysis Countermeasures
|
||||
|
||||
### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 3a. Human Motor Model
|
||||
- **Implementation:** `internal/mouse/human_model.go`
|
||||
- Full biomechanical mouse model based on Fitts' Law and human motion studies
|
||||
- Real human mouse data characteristics:
|
||||
- Multi-segment movement with micro-pauses (1-3 segments per motion)
|
||||
- Acceleration curve: start slow, peak in middle, decelerate into target
|
||||
- Endpoint micro-adjustments: 2-5 pixel wobble before click
|
||||
- Inter-trial variability: each movement is unique even to same target
|
||||
- Asymmetric error distribution: overshoot more right/down (human bias)
|
||||
|
||||
#### 3b. Click Timing Model
|
||||
- **Implementation:** `internal/mouse/click_model.go`
|
||||
- Variable time between "arriving" at target and clicking: 50ms-800ms
|
||||
- Pressure curve: humans don't click at exact same speed
|
||||
- Double-click rate varies naturally
|
||||
- Occasional misses: 0.5-1% of clicks land slightly off (1-3px)
|
||||
|
||||
#### 3c. Keyboard Behavior Model
|
||||
- **Implementation:** `internal/keyboard/human_model.go`
|
||||
- Key press duration variance: not all keypresses are identical
|
||||
- Typing rhythm for skill hotkeys: natural cadence with micro-pauses
|
||||
- Occasional key repeat (holding too long = rapid fire)
|
||||
- Realistic key-up/key-down timing ratios
|
||||
|
||||
#### 3d. Statistical Indistinguishability
|
||||
- **Implementation:** `internal/input/stats.go`
|
||||
- All input streams modeled from real human motion capture data
|
||||
- Entropy analysis of output matches human baselines
|
||||
- Auto-calibration: measure user's own input if they do manual play
|
||||
- Periodically inject "manual-looking" variance spikes
|
||||
|
||||
---
|
||||
|
||||
## 4. Economy and Item-Flow Countermeasures
|
||||
|
||||
### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 4a. Natural Accumulation Rate
|
||||
- **Implementation:** `internal/inventory/economy.go`
|
||||
- Vary farming intensity: some sessions heavy, some light
|
||||
- Match accumulation to stated playtime (more sessions = more loot)
|
||||
- Occasionally "waste" items on gambling/repairs like a real player
|
||||
|
||||
#### 4b. Realistic Trading Patterns
|
||||
- No mass item funneling
|
||||
- If trading, do it in human-sized batches with natural pauses
|
||||
- Vary trade partners and timing
|
||||
|
||||
#### 4c. Rune Farming Variance
|
||||
- Don't farm the same runes every session
|
||||
- Match rune acquisition to character progression
|
||||
- Occasionally skip rune picks when "full"
|
||||
|
||||
---
|
||||
|
||||
## 5. Ban Wave Defense
|
||||
|
||||
### Detection: Delayed batch bans
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 5a. Graceful Degradation
|
||||
- **Implementation:** `internal/runtime/safe_mode.go`
|
||||
- If one account gets banned, immediately reduce intensity across all
|
||||
- Auto-pause farming for 48-72 hours (simulating "taking a break")
|
||||
- Gradual return with reduced session lengths
|
||||
- Change behavior patterns after any ban event
|
||||
|
||||
#### 5b. Account Diversity
|
||||
- Each account has distinct "personality":
|
||||
- Different session timing preferences
|
||||
- Different route preferences
|
||||
- Different response timing distributions
|
||||
- Different play styles (rusher vs methodical)
|
||||
|
||||
---
|
||||
|
||||
## 6. Server Authority Countermeasures
|
||||
|
||||
### Detection: Server-side validation of movement, drops, combat, inventory
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 6a. Server-Authoritative Behavior
|
||||
- **Implementation:** `internal/bot/server_aware.go`
|
||||
- Only interact with what the server actually shows
|
||||
- Wait for server confirmation before acting (e.g., confirm item picked up)
|
||||
- Respect server-enforced movement limits (no speed hacks)
|
||||
- Process drops in game-authorized order
|
||||
|
||||
#### 6b. No Client Manipulation
|
||||
- Never try to spoof packets, modify client, or exploit desync
|
||||
- Purely reactive: see screen -> decide -> act -> wait for response
|
||||
|
||||
---
|
||||
|
||||
## 7. Social/Reporting System Countermeasures
|
||||
|
||||
### Detection: Player reports + telemetry correlation
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 7a. Social Stealth
|
||||
- **Implementation:** `internal/social/stealth.go`
|
||||
- Play during off-peak hours less suspiciously
|
||||
- Avoid solo-public routes that attract attention
|
||||
- Occasionally join other players' games (with reduced automation)
|
||||
- Inherit human-like chat behavior if configured
|
||||
|
||||
---
|
||||
|
||||
## 8. Hardware/Identity Correlation Countermeasures
|
||||
|
||||
### Detection: IP patterns, hardware fingerprints, VMs, account clusters
|
||||
|
||||
### Countermeasures:
|
||||
|
||||
#### 8a. Clean Deployment
|
||||
- **Implementation:** `internal/deploy/clean.go`
|
||||
- Run on real hardware, not VMs
|
||||
- Use residential IP, not datacenter
|
||||
- One account per hardware profile
|
||||
- No VPN/proxy during play sessions
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
```
|
||||
internal/
|
||||
├── input/ # Human-like input generation
|
||||
│ ├── mouse_model.go # Fitts' Law mouse movement
|
||||
│ ├── click_model.go # Human click timing
|
||||
│ ├── keyboard_model.go # Keyboard behavior
|
||||
│ └── stats.go # Statistical verification
|
||||
├── behavior/ # High-level human behavior simulation
|
||||
│ ├── scheduler.go # Session scheduling
|
||||
│ ├── route_planner.go # Dynamic route selection
|
||||
│ ├── fatigue.go # Simulated fatigue/boredom
|
||||
│ └── personality.go # Per-account personality
|
||||
├── economy/ # Economic behavior masking
|
||||
│ ├── accumulation.go # Natural loot accumulation
|
||||
│ └── trading.go # Human-like trading patterns
|
||||
├── safe_mode/ # Graceful degradation
|
||||
│ ├── detection.go # Ban wave detection
|
||||
│ └── cooldown.go # Auto-pause and return
|
||||
└── deploy/ # Clean deployment helpers
|
||||
└── check.go # Pre-flight integrity checks
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Statistical indistinguishability:** Output must be statistically
|
||||
indistinguishable from real human input. We use actual human motion
|
||||
capture data distributions, not made-up random numbers.
|
||||
|
||||
2. **Controlled imperfection:** A human is inefficient, forgetful, and
|
||||
inconsistent. The bot should be too — but in a way that matches
|
||||
real human distributions.
|
||||
|
||||
3. **No single fingerprint:** Every instance should have unique enough
|
||||
characteristics that correlating two accounts is hard.
|
||||
|
||||
4. **Adaptability:** If behavior changes are detected, the system should
|
||||
be able to recalibrate based on new data.
|
||||
|
||||
5. **Defense in depth:** No single countermeasure is sufficient. The
|
||||
combination across all layers is what provides real protection.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Botty-Go
|
||||
|
||||
D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows).
|
||||
|
||||
Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite
|
||||
in Go that maintains compatibility with the same config files, templates, and run
|
||||
logic while adding native Linux support.
|
||||
|
||||
## Features
|
||||
|
||||
- Cross-platform: Linux (X11/Wayland) and Windows
|
||||
- Same config format as original Botty (params.ini, game.ini, shop.ini)
|
||||
- Template matching with OpenCV Go bindings
|
||||
- Tesseract OCR for item identification
|
||||
- Human-like mouse movement (Bezier curves)
|
||||
- BNIP pickit language
|
||||
- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.)
|
||||
- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo)
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
go build -o botty ./cmd/botty
|
||||
|
||||
# Windows (from Linux with cross-compile)
|
||||
GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config/` from the original Botty project. Params, routes, and character
|
||||
config work identically.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Legacy: Go Rewrite Design Notes
|
||||
|
||||
These docs are archived from an abandoned `~/git/botty-go` directory (May 2026).
|
||||
That project was a planned ground-up Go rewrite of `johannes-do/botty` for
|
||||
cross-platform (Linux + Windows) support. Only design docs existed — no `.go`
|
||||
source was ever written.
|
||||
|
||||
The Python `my-botty` project (this repo) is the active path. These docs are
|
||||
kept here as **reference material**, primarily for Milestone 2 (anti-detection /
|
||||
stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`.
|
||||
|
||||
## Files
|
||||
|
||||
- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers
|
||||
server-side behavior analysis countermeasures (session scheduling, stochastic
|
||||
pathing, skill rotation variance) and more. Directly applicable as the design
|
||||
basis for the Python stealth layer.
|
||||
- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project.
|
||||
Context only — explains feature scope and what the rewrite was aiming for.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Paladalla Melee Hunt Test Log
|
||||
|
||||
Date: 2026-08-23
|
||||
Workspace: `C:\Users\alex\my-botty`
|
||||
Character/profile: `paladalla`
|
||||
Script: `tools/melee_hunt.py`
|
||||
|
||||
## Current State
|
||||
|
||||
- Testing was stopped at the user's request.
|
||||
- No `melee_hunt` Python process was running when checked.
|
||||
- Paladalla was alive in the Rogue Encampment at the end of the session.
|
||||
- The four visible belt potions were manually consumed. The last screenshot shows
|
||||
partial life and an empty belt, so refill and fully heal before the next run.
|
||||
- The game must not be controlled automatically until the user asks to resume.
|
||||
|
||||
Final-state screenshot: [healed_from_belt.png](../log/screenshots/manual/healed_from_belt.png)
|
||||
|
||||
## Commands Used
|
||||
|
||||
The successful short run used:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left
|
||||
```
|
||||
|
||||
The later long tests used:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 1440
|
||||
```
|
||||
|
||||
`F12` is intended to stop the script. It worked during one run but was not
|
||||
reliable while an in-game overlay was open. `Ctrl+C` in the script terminal was
|
||||
the reliable emergency stop.
|
||||
|
||||
## Verified Results
|
||||
|
||||
### Initial ten-minute run
|
||||
|
||||
- Started outside town and completed its configured timer normally.
|
||||
- Result: `engaged 107, roamed 15`.
|
||||
- XP changed from `261/500` to `357/500`.
|
||||
- Final reported life was `69%`.
|
||||
- The process then exited because the default duration is ten minutes. Paladalla
|
||||
later died because nothing was controlling or protecting the character.
|
||||
|
||||
### Corpse recovery and level gain
|
||||
|
||||
- The death prompt was cleared and the game was cycled through Save and Exit.
|
||||
- The corpse appeared beside Paladalla in town and was recovered successfully.
|
||||
- Weapon and attack slots were restored.
|
||||
- Paladalla was manually routed across the bridge into the Blood Moor.
|
||||
- Manual movement/attack clicks later produced a level-up. The next script start
|
||||
read XP as `564/1500`, confirming level 2 and working melee damage.
|
||||
|
||||
Evidence:
|
||||
|
||||
- [corpse_recovered.png](../log/screenshots/manual/corpse_recovered.png)
|
||||
- [blood_moor_ready2.png](../log/screenshots/manual/blood_moor_ready2.png)
|
||||
- [deep_moor_restart.png](../log/screenshots/manual/deep_moor_restart.png)
|
||||
|
||||
## Failures Found
|
||||
|
||||
### 1. Ten-minute default looked like a crash
|
||||
|
||||
The first hunt was not dead or hung. It reached its default ten-minute deadline
|
||||
and exited cleanly. Long unattended tests must pass an explicit `--minutes`
|
||||
value or implement a true continuous mode.
|
||||
|
||||
### 2. Friendly bridge NPC was treated as motion target
|
||||
|
||||
The first 24-hour attempt remained near the town bridge. At the two-minute
|
||||
checkpoint it showed repeated attacks, full life, and unchanged XP
|
||||
(`357/500`). The screenshot showed it attacking the friendly rogue near the
|
||||
bridge. Motion alone cannot distinguish that NPC from a monster.
|
||||
|
||||
Evidence: [hunt_2min_checkpoint.png](../log/screenshots/manual/hunt_2min_checkpoint.png)
|
||||
|
||||
Workaround used: stop the script and move several screens deeper into the Blood
|
||||
Moor before restarting. A permanent NPC rejection/stall detector is still
|
||||
needed.
|
||||
|
||||
### 3. Animated HUD controls were treated as targets
|
||||
|
||||
After reaching level 2, the pulsing new-stat/new-skill controls appeared above
|
||||
the original HUD mask. The motion detector could target that region. During the
|
||||
second long attempt an in-game Loot Filter overlay opened and the script kept
|
||||
issuing clicks behind it.
|
||||
|
||||
Evidence: [restart_live_40s.png](../log/screenshots/manual/restart_live_40s.png)
|
||||
|
||||
### 4. No-potion condition was unsafe
|
||||
|
||||
Life fell through `18%`, `14%`, `10%`, `8%`, and eventually `0%`. The script
|
||||
logged `no healing potion in belt!` but continued its loop. Belt detection also
|
||||
reported no potion even though red potions were visibly present, so belt
|
||||
detection or its profile assumptions need separate investigation.
|
||||
|
||||
### 5. XP OCR became invalid after level-up/UI changes
|
||||
|
||||
The log reported XP OCR values such as `'-'` and `''`, then incorrectly printed
|
||||
`*** LEVEL UP *** now 0/0`. Invalid OCR should not be interpreted as a level-up,
|
||||
and `xp_last` should retain the last valid sample.
|
||||
|
||||
## Code Changes Made
|
||||
|
||||
`tools/melee_hunt.py` was changed in two ways:
|
||||
|
||||
1. The target scan bottom boundary was raised from `640` to `510`, excluding the
|
||||
animated level-up/stat controls and the interactive HUD from motion targets.
|
||||
2. Failure to detect or use a healing potion at the configured health threshold
|
||||
now raises `SafetyStop`, exits the hunt loop, and prints a safety-stop reason.
|
||||
|
||||
The edited script passed:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe -m py_compile tools\melee_hunt.py
|
||||
```
|
||||
|
||||
These changes were not runtime-verified after editing because testing was
|
||||
stopped. The narrower scan may ignore monsters near the bottom of the viewport;
|
||||
that tradeoff must be checked visually.
|
||||
|
||||
## Next Test Checklist
|
||||
|
||||
1. Confirm no old hunt process is running.
|
||||
2. Refill the belt with healing potions and fully heal at Akara.
|
||||
3. Save and Exit, re-enter, and recover any corpse if one appears.
|
||||
4. Spend or dismiss the new stat/skill notifications if practical.
|
||||
5. Walk across the bridge and several screens into the Blood Moor before launch.
|
||||
6. Start with a short five-minute test, not a 24-hour run:
|
||||
|
||||
```powershell
|
||||
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 5
|
||||
```
|
||||
|
||||
7. Capture and inspect screenshots at launch, two minutes, and five minutes.
|
||||
8. At each checkpoint verify: outside town, no modal open, attacking a hostile,
|
||||
XP increasing, life stable, movement occurring, and no repeated target lock.
|
||||
9. Force a controlled low-health/no-potion condition only after normal combat is
|
||||
proven, and verify `SAFETY STOP` exits immediately.
|
||||
10. Use `Ctrl+C` if any modal opens, XP remains unchanged across two checkpoints,
|
||||
life drops rapidly, or the character approaches the town bridge NPC.
|
||||
|
||||
## Follow-up Engineering Work
|
||||
|
||||
- Add a stall detector based on unchanged XP plus repeated target coordinates.
|
||||
- Temporarily blacklist a target position after excessive swings without XP.
|
||||
- Reject invalid XP samples instead of converting them into `0/0` level-ups.
|
||||
- Investigate why `belt.drink_potion("health", ...)` missed visible red potions.
|
||||
- Detect death and modal overlays explicitly and stop before sending more input.
|
||||
- Consider an explicit `--continuous` option instead of using a large minute
|
||||
count.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Post-mortem — run_pindle total failure (26–27 Aug 2026)
|
||||
|
||||
**Outcome:** failure rate 100% → ~8%. Character 32 → 69. Cycle time 185–250s → ~52s.
|
||||
18 commits, `98e12c0` … `402c768`.
|
||||
(Hashes changed on 27 Aug: history was rewritten before the PR to purge a Discord
|
||||
webhook that had been committed into `config/params.ini` by mistake.)
|
||||
|
||||
Published rendering: `docs/postmortems/pindle-2026-08-27.html`
|
||||
(artifact: https://claude.ai/code/artifact/00668e28-dbb8-40e8-b585-db4c8b790d0a)
|
||||
|
||||
Per-bug detail with code references lives in `CLAUDE.md` under **Known Bugs and Fixes**
|
||||
(Bugs 23–30). This document is the narrative: what happened, in what order, and what to do
|
||||
differently.
|
||||
|
||||
---
|
||||
|
||||
## 1. The expensive part: a green log is not evidence
|
||||
|
||||
The original symptom was loud and honest — every run died on
|
||||
`Wanted to select A5_RED_PORTAL, but could not find it`.
|
||||
|
||||
After the portal template was fixed, the logs went clean **and stayed wrong**. Five
|
||||
consecutive games produced:
|
||||
|
||||
```
|
||||
INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)
|
||||
INFO Loot from run_pindle: nothing picked up
|
||||
INFO End game. Elapsed time: 50.98s
|
||||
"runs_failed_total": 0
|
||||
```
|
||||
|
||||
Every line green. The character was standing in the middle of Harrogath swinging at nothing.
|
||||
|
||||
**What gave it away was not the log — it was the XP counter.** 6,779,050 before five
|
||||
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
|
||||
`nothing picked up` is not variance, it is a run that never happened.
|
||||
|
||||
> A boss run's success is only provable by things the bot cannot fake: an item in the loot
|
||||
> line, or a jump in `current_exp`. `failed:false` is the bot's *opinion*, derived from the
|
||||
> very check that was lying.
|
||||
|
||||
---
|
||||
|
||||
## 2. The causal chain
|
||||
|
||||
Four of the first five causes were one failure propagating. Fixing only the visible end
|
||||
would have left the rest armed.
|
||||
|
||||
```
|
||||
Portal template is masked (scores 0.50-0.60, needs 0.68, matches the wrong place)
|
||||
-> portal click fails, approach re-paths from a hardcoded town start it no longer occupies
|
||||
-> landmarks miss, the pather's 0.55 fallback invents a node position from scenery
|
||||
-> char walks at the phantom and wedges on the town wall, or onto the waypoint stone
|
||||
-> waypoint panel opens, health manager reads it as a threat, chickens at full HP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Root causes, in order of cost
|
||||
|
||||
| Bug | Cause | Severity |
|
||||
|---|---|---|
|
||||
| 24 | "Already in Pindle area?" shortcut fired **in town** (0.76–0.79 on `PINDLE_7` vs its 0.62 bar), returned success without clicking the portal | **Silent** |
|
||||
| 28 | `detect_current_act` committed on a **1.7pp margin** (`A5_TOWN_1` 0.636 vs `A4_TOWN_5` 0.619) → A5 pathing ran inside Act 4. **25% of games** | **Silent** |
|
||||
| 30 | NPC grid sweep `SWEEP_TAG_THRESHOLD=0.4` settled for noise (real hits 0.98–1.00, false 0.42–0.50) | Loud |
|
||||
| 23 | `a5_red_portal.png` was the only **masked** template in its folder → unsupported masked `TM_CCOEFF_NORMED` | Loud |
|
||||
| 25 | Pather 0.55 **first-match** fallback fabricated node positions → walked into the town wall | Loud |
|
||||
| 29 | Undetectable resurrect NPC re-hunted every game (~80s), per-game flag reset by `log_start_game` | Loud |
|
||||
| 26 | Waypoint panel counted toward a chicken — 3 of 6 games died at full HP | Loud |
|
||||
| 27 | `GEMS_TAB_Y=100` missed the tab row (labels at y 63–78, grid starts y≈87) → 800+ silent warnings | Loud |
|
||||
| 9b | Pindle retry re-pathed from a hardcoded `A5_TOWN_START` | Loud |
|
||||
|
||||
The two marked **Silent** cost the most, because they reported success.
|
||||
|
||||
---
|
||||
|
||||
## 4. Four corrections — times the obvious answer was wrong
|
||||
|
||||
These are recorded because in each case the wrong answer looked correct.
|
||||
|
||||
**1. The red-portal guard.** To stop the false "already in Pindle area" I added: *if the red
|
||||
portal is on screen we must still be in town.* The portal is visible from **both sides** —
|
||||
the return portal renders inside the temple. The guard blocked every genuine entry. It
|
||||
traded a silent false-success for an honest false-failure: an improvement, still a bug.
|
||||
|
||||
**2. XP as proof of a boss kill.** An XP gain proves *something* died — the minion pack and
|
||||
the mercenary's kills both count. Confirmation needed a loot line naming an actual drop.
|
||||
|
||||
**3. "The template is degenerate."** I blamed the Qual-Kehk asset for a repeated identical
|
||||
0.424 score. Walking the character to the NPC and scoring the stored template against a
|
||||
frame where the tag renders gave **1.000** (0.997 through the real `color_filter` path). The
|
||||
template was perfect; the *threshold* was wrong.
|
||||
|
||||
> A repeated identical score means the search is settling for **noise**. Check the threshold
|
||||
> before blaming the asset.
|
||||
|
||||
**4. Selling the charms.** I read 673 blocked-sell lines as a clog to clear and disabled
|
||||
`protect_charms_from_sell`. It vendored five resistance charms before it was caught. The
|
||||
blocked lines were **the guard working**, and the pickit's `Discarding` verdict was never a
|
||||
judgement that they were junk — `LAPIS SMALL CHARM OF VITA` failed its rule only because the
|
||||
rule wanted `coldresist >= 11` and it had 7.
|
||||
|
||||
---
|
||||
|
||||
## 5. What actually made this expensive
|
||||
|
||||
Every bug above cost hours because the log recorded **events, not structure**. There was no
|
||||
way to ask "where did this game spend its time?" without reading thousands of lines.
|
||||
|
||||
That is now fixed — see `CLAUDE.md` → *The run timeline* and *Diagnosing a failure*:
|
||||
|
||||
- **`grep "TL>"`** — one fixed-width line per step, spawn to loot, every step timed.
|
||||
- **`grep "FAIL>"`** — a self-contained failure record: reason, location, screenshot, the
|
||||
three slowest steps, and a breadcrumb trail of the game with `!` marking failures.
|
||||
- **A Discord digest every 2h** (`general.discord_timing_report_h`), aggregated from the same
|
||||
timeline so report and log cannot drift.
|
||||
|
||||
The first thing the timeline surfaced, unprompted:
|
||||
|
||||
```
|
||||
TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable
|
||||
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
|
||||
TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal
|
||||
```
|
||||
|
||||
That failed game spent **113.6s of its 137.6s town visit** on a merc resurrect that achieved
|
||||
nothing — 83% of the time, on a step that was not the one that ended the game.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rules carried forward
|
||||
|
||||
1. **Verify boss runs with XP delta and loot lines**, never `failed:false`.
|
||||
2. **Never mask part of a template** to hide an artifact — masked `TM_CCOEFF_NORMED` is
|
||||
unsupported in OpenCV and fails unpredictably. Recapture it clean.
|
||||
3. **Prove a template fails on a frame where the subject is definitely visible** before
|
||||
recapturing anything.
|
||||
4. **Confirm any click that changes UI mode** — tab, page, panel. An unverified click that
|
||||
"should" have worked fails silently forever.
|
||||
5. **Never let a detector guess on a thin margin.** Refusing to answer is usually safe; a
|
||||
wrong confident answer skips the recovery path entirely.
|
||||
6. **Read repeated warnings as a system working**, not a clog to clear.
|
||||
7. **Restart the bot after editing `src/` or config** — both load at process start.
|
||||
8. **A "we might already be there" fast path is dangerous** when its test can false-positive
|
||||
at the origin. It converts a loud failure into a silent one.
|
||||
@@ -0,0 +1,726 @@
|
||||
<title>Pindle Run Post-Mortem</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ground:#EDF0F4;
|
||||
--surface:#FFFFFF;
|
||||
--surface-2:#E3E8EF;
|
||||
--ink:#15181E;
|
||||
--ink-2:#3D4652;
|
||||
--slate:#5A6675;
|
||||
--hair:#CBD3DE;
|
||||
--accent:#B01B2E;
|
||||
--accent-soft:#F6E4E7;
|
||||
--good:#1B6B52;
|
||||
--good-soft:#DFEFE8;
|
||||
--warn:#8A5A0B;
|
||||
--warn-soft:#F7EBD6;
|
||||
--shadow:0 1px 2px rgba(21,24,30,.06),0 8px 24px -12px rgba(21,24,30,.18);
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--ground:#101317;
|
||||
--surface:#171B21;
|
||||
--surface-2:#1F242C;
|
||||
--ink:#E6EAF0;
|
||||
--ink-2:#BAC3CF;
|
||||
--slate:#8B97A6;
|
||||
--hair:#2A313A;
|
||||
--accent:#FF5F70;
|
||||
--accent-soft:#2C1A1F;
|
||||
--good:#4FC79B;
|
||||
--good-soft:#14261F;
|
||||
--warn:#E0A44A;
|
||||
--warn-soft:#2A2114;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--ground:#101317;
|
||||
--surface:#171B21;
|
||||
--surface-2:#1F242C;
|
||||
--ink:#E6EAF0;
|
||||
--ink-2:#BAC3CF;
|
||||
--slate:#8B97A6;
|
||||
--hair:#2A313A;
|
||||
--accent:#FF5F70;
|
||||
--accent-soft:#2C1A1F;
|
||||
--good:#4FC79B;
|
||||
--good-soft:#14261F;
|
||||
--warn:#E0A44A;
|
||||
--warn-soft:#2A2114;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;
|
||||
background:var(--ground);
|
||||
color:var(--ink);
|
||||
font-family:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
font-size:16.5px;
|
||||
line-height:1.65;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 24px 96px}
|
||||
.col{max-width:672px;margin-inline:auto}
|
||||
|
||||
h1,h2,h3{font-family:Fraunces,Georgia,serif;text-wrap:balance;margin:0}
|
||||
h1{font-size:clamp(2.1rem,5.2vw,3.15rem);font-weight:600;line-height:1.08;letter-spacing:-.015em}
|
||||
h2{font-size:clamp(1.4rem,3vw,1.75rem);font-weight:600;line-height:1.2}
|
||||
h3{font-size:1.06rem;font-weight:600;line-height:1.3}
|
||||
p{margin:0}
|
||||
a{color:var(--accent)}
|
||||
code,.mono{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace}
|
||||
|
||||
.eyebrow{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:500;
|
||||
letter-spacing:.16em;text-transform:uppercase;color:var(--slate);
|
||||
}
|
||||
|
||||
/* ---- masthead ---- */
|
||||
header{padding:72px 0 40px;border-bottom:1px solid var(--hair)}
|
||||
.head-inner{display:flex;flex-direction:column;gap:20px}
|
||||
.lede{font-size:1.12rem;color:var(--ink-2);max-width:64ch}
|
||||
.verdict{
|
||||
display:inline-flex;align-items:center;gap:10px;align-self:flex-start;
|
||||
background:var(--good-soft);color:var(--good);
|
||||
border:1px solid color-mix(in srgb,var(--good) 30%,transparent);
|
||||
border-radius:2px;padding:7px 13px;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.76rem;font-weight:600;letter-spacing:.06em;
|
||||
}
|
||||
.verdict::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--good)}
|
||||
|
||||
/* ---- metric band ---- */
|
||||
.metrics{
|
||||
display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));
|
||||
gap:1px;background:var(--hair);border:1px solid var(--hair);
|
||||
margin:40px 0 0;
|
||||
}
|
||||
.metric{background:var(--surface);padding:18px 20px;display:flex;flex-direction:column;gap:5px}
|
||||
.metric .k{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--slate);font-family:"IBM Plex Mono",monospace}
|
||||
.metric .v{font-family:Fraunces,serif;font-size:1.6rem;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.metric .n{font-size:.8rem;color:var(--slate)}
|
||||
.metric.hi .v{color:var(--good)}
|
||||
.metric.lo .v{color:var(--accent)}
|
||||
|
||||
section{padding-top:64px}
|
||||
.section-head{display:flex;flex-direction:column;gap:8px;margin-bottom:26px}
|
||||
.body-flow{display:flex;flex-direction:column;gap:18px}
|
||||
|
||||
/* ---- the trap callout ---- */
|
||||
.trap{
|
||||
background:var(--accent-soft);
|
||||
border-left:3px solid var(--accent);
|
||||
padding:22px 24px;display:flex;flex-direction:column;gap:12px;
|
||||
}
|
||||
.trap h3{color:var(--accent)}
|
||||
|
||||
/* ---- log evidence ---- */
|
||||
.log{
|
||||
background:var(--surface);border:1px solid var(--hair);
|
||||
padding:16px 18px;overflow-x:auto;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.79rem;line-height:1.75;
|
||||
color:var(--ink-2);white-space:pre;
|
||||
}
|
||||
.log .ok{color:var(--good);font-weight:600}
|
||||
.log .bad{color:var(--accent);font-weight:600}
|
||||
.log .dim{color:var(--slate)}
|
||||
figure{margin:0;display:flex;flex-direction:column;gap:9px}
|
||||
figcaption{font-size:.82rem;color:var(--slate)}
|
||||
|
||||
/* ---- causal chain ---- */
|
||||
.chain{display:flex;flex-direction:column;gap:0;margin-top:8px}
|
||||
.node{
|
||||
background:var(--surface);border:1px solid var(--hair);
|
||||
padding:14px 18px;display:flex;gap:14px;align-items:baseline;
|
||||
}
|
||||
.node .tag{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;
|
||||
color:var(--accent);letter-spacing:.05em;flex-shrink:0;min-width:52px;
|
||||
}
|
||||
.node .txt{font-size:.93rem;color:var(--ink-2)}
|
||||
.node strong{color:var(--ink);font-weight:600}
|
||||
.arrow{
|
||||
height:22px;width:1px;background:var(--hair);margin-left:34px;position:relative;flex-shrink:0;
|
||||
}
|
||||
.arrow::after{
|
||||
content:"";position:absolute;bottom:0;left:-3px;
|
||||
border-left:3.5px solid transparent;border-right:3.5px solid transparent;
|
||||
border-top:5px solid var(--hair);
|
||||
}
|
||||
.node.terminal{border-color:color-mix(in srgb,var(--accent) 45%,var(--hair));background:var(--accent-soft)}
|
||||
.node.terminal .txt strong{color:var(--accent)}
|
||||
|
||||
/* ---- cause cards ---- */
|
||||
.causes{display:flex;flex-direction:column;gap:14px}
|
||||
.cause{
|
||||
background:var(--surface);border:1px solid var(--hair);box-shadow:var(--shadow);
|
||||
display:flex;flex-direction:column;
|
||||
}
|
||||
.cause-head{
|
||||
display:flex;gap:14px;align-items:flex-start;
|
||||
padding:20px 24px 16px;border-bottom:1px solid var(--hair);
|
||||
}
|
||||
.bugid{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;letter-spacing:.06em;
|
||||
color:var(--surface);background:var(--ink);padding:4px 8px;border-radius:2px;flex-shrink:0;
|
||||
margin-top:3px;
|
||||
}
|
||||
.cause-head .titles{display:flex;flex-direction:column;gap:3px}
|
||||
.cause-head .file{font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate)}
|
||||
.cause-body{padding:20px 24px 24px;display:flex;flex-direction:column;gap:16px}
|
||||
.field{display:flex;flex-direction:column;gap:5px}
|
||||
.field .lbl{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.67rem;letter-spacing:.13em;
|
||||
text-transform:uppercase;color:var(--slate);
|
||||
}
|
||||
.field p{font-size:.94rem;color:var(--ink-2)}
|
||||
.field.fix .lbl{color:var(--good)}
|
||||
.sev{
|
||||
margin-left:auto;flex-shrink:0;font-family:"IBM Plex Mono",monospace;
|
||||
font-size:.66rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
|
||||
padding:4px 8px;border-radius:2px;white-space:nowrap;margin-top:3px;
|
||||
}
|
||||
.sev.silent{background:var(--accent-soft);color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent)}
|
||||
.sev.loud{background:var(--warn-soft);color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 30%,transparent)}
|
||||
|
||||
/* ---- tables ---- */
|
||||
.tablewrap{overflow-x:auto;border:1px solid var(--hair);background:var(--surface)}
|
||||
table{border-collapse:collapse;width:100%;font-size:.86rem}
|
||||
th,td{text-align:left;padding:10px 16px;border-bottom:1px solid var(--hair)}
|
||||
th{
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--slate);font-weight:500;background:var(--surface-2);
|
||||
}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
td.num{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
td .yes{color:var(--good);font-weight:600}
|
||||
td .no{color:var(--accent);font-weight:600}
|
||||
|
||||
/* ---- checklist ---- */
|
||||
.checks{display:flex;flex-direction:column;gap:1px;background:var(--hair);border:1px solid var(--hair)}
|
||||
.check{background:var(--surface);padding:15px 20px;display:flex;gap:13px;align-items:flex-start}
|
||||
.check .mark{color:var(--good);font-weight:700;flex-shrink:0;line-height:1.6}
|
||||
.check.dont .mark{color:var(--accent)}
|
||||
.check .ct{font-size:.92rem;color:var(--ink-2)}
|
||||
.check .ct strong{color:var(--ink)}
|
||||
|
||||
/* ---- wrong turns ---- */
|
||||
.turn{
|
||||
border:1px dashed var(--hair);background:transparent;
|
||||
padding:18px 20px;display:flex;flex-direction:column;gap:8px;
|
||||
}
|
||||
.turn .lbl{font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--warn)}
|
||||
.turn p{font-size:.92rem;color:var(--ink-2)}
|
||||
|
||||
footer{
|
||||
margin-top:80px;padding-top:26px;border-top:1px solid var(--hair);
|
||||
display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;
|
||||
font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate);
|
||||
}
|
||||
@media (max-width:640px){
|
||||
header{padding-top:48px}
|
||||
.cause-head{flex-wrap:wrap}
|
||||
.sev{margin-left:0}
|
||||
}
|
||||
</style>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="col head-inner">
|
||||
<span class="eyebrow">Post-mortem · run_pindle · 26–27 Aug 2026</span>
|
||||
<h1>The bot reported five perfect runs and killed nothing</h1>
|
||||
<p class="lede">
|
||||
<code>run_pindle</code> was failing 100% of the time. Then a fix made it worse in the
|
||||
most expensive way possible: it started reporting success. Nine root causes, four wrong
|
||||
turns of my own, and the instrumentation built so the next one takes minutes.
|
||||
</p>
|
||||
<span class="verdict">Resolved · 17 commits</span>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="metrics">
|
||||
<div class="metric lo">
|
||||
<span class="k">Failure rate</span>
|
||||
<span class="v">100%</span>
|
||||
<span class="n">at the start</span>
|
||||
</div>
|
||||
<div class="metric hi">
|
||||
<span class="k">Now</span>
|
||||
<span class="v">~8%</span>
|
||||
<span class="n">1 in 28 approaches</span>
|
||||
</div>
|
||||
<div class="metric hi">
|
||||
<span class="k">Character</span>
|
||||
<span class="v">32 → 69</span>
|
||||
<span class="n">37 levels</span>
|
||||
</div>
|
||||
<div class="metric hi">
|
||||
<span class="k">Cycle time</span>
|
||||
<span class="v">~52s</span>
|
||||
<span class="n">was 185–250s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">The expensive part</span>
|
||||
<h2>A green log is not evidence</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
The original symptom was loud and honest: every run died on
|
||||
<code>Wanted to select A5_RED_PORTAL, but could not find it</code>. After the template
|
||||
was fixed, the logs went clean — and stayed wrong. Five consecutive games produced this:
|
||||
</p>
|
||||
|
||||
<figure>
|
||||
<div class="log"><span class="ok">INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)</span>
|
||||
<span class="ok">INFO Loot from run_pindle: nothing picked up</span>
|
||||
<span class="ok">INFO End game. Elapsed time: 50.98s</span>
|
||||
<span class="dim"> "runs_failed_total": 0</span></div>
|
||||
<figcaption>Every line green. The character was standing in the middle of town, swinging at nothing.</figcaption>
|
||||
</figure>
|
||||
|
||||
<div class="trap">
|
||||
<h3>What gave it away</h3>
|
||||
<p>
|
||||
Not the logs — the <strong>XP counter never moved</strong>. 6,779,050 before five
|
||||
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
|
||||
<code>nothing picked up</code> is not variance, it is a run that never happened.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
A boss run's success is only provable by things the bot cannot fake: an item in the loot
|
||||
line, or a jump in <code>current_exp</code>. The <code>failed:false</code> flag is the
|
||||
bot's <em>opinion</em>, derived from the very template check that was lying.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">How one bad asset cascaded</span>
|
||||
<h2>The causal chain</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
Four of the first five causes are one failure propagating. This is a real dependency
|
||||
chain, so fixing only the visible end would have left the rest armed.
|
||||
</p>
|
||||
<div class="chain">
|
||||
<div class="node">
|
||||
<span class="tag">CAUSE</span>
|
||||
<span class="txt"><strong>Portal template is masked</strong> — scores 0.50–0.60, below the 0.68 it needs, matching the wrong place entirely.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node">
|
||||
<span class="tag">THEN</span>
|
||||
<span class="txt">Portal click fails, so the approach <strong>re-paths from a hardcoded town start</strong> the character no longer occupies.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node">
|
||||
<span class="tag">THEN</span>
|
||||
<span class="txt">Landmarks miss, so the pather's <strong>0.55 fallback invents a node position</strong> from unrelated scenery.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node">
|
||||
<span class="tag">THEN</span>
|
||||
<span class="txt">The character walks toward the phantom and <strong>wedges against the town wall</strong> — or onto the waypoint stone.</span>
|
||||
</div>
|
||||
<div class="arrow"></div>
|
||||
<div class="node terminal">
|
||||
<span class="tag">RESULT</span>
|
||||
<span class="txt">The waypoint panel opens, the health manager reads it as a threat, and <strong>chickens a game at full HP</strong>.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">Root causes</span>
|
||||
<h2>Nine bugs, in order of cost</h2>
|
||||
</div>
|
||||
<div class="col causes">
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 24</span>
|
||||
<div class="titles">
|
||||
<h3>An optimistic shortcut faked every kill</h3>
|
||||
<span class="file">src/run/pindle.py — approach()</span>
|
||||
</div>
|
||||
<span class="sev silent">Silent</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
<code>approach()</code> opened with an "are we already there?" shortcut that ran
|
||||
before clicking the portal. Harrogath scenery near the portal scores
|
||||
<strong>0.76–0.79 on <code>PINDLE_7</code></strong> — above its 0.62 bar — so the
|
||||
shortcut fired <em>in town</em>, returned <code>A5_PINDLE_START</code> without ever
|
||||
clicking the portal, and the bot ran <code>kill_pindle()</code> in the middle of
|
||||
Harrogath.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>Shortcut deleted. Entry is proven only by the portal click's loading screen. Real temple entries score <strong>85.8–88.7%</strong>, clear of the town false positives.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="lbl">Generalise</span>
|
||||
<p>A "we might already be there" fast path is dangerous whenever its test can false-positive at the origin. It converts a loud failure into a silent one — the worst trade in an unattended system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 28</span>
|
||||
<div class="titles">
|
||||
<h3>The act was decided by a 1.7-point margin</h3>
|
||||
<span class="file">src/town/town_manager.py — detect_current_act()</span>
|
||||
</div>
|
||||
<span class="sev silent">Silent</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
The biggest cause of ordinary failures: <strong>25% of games</strong>. A consumables
|
||||
trip sends the character A5→A4, and <code>go_to_act(5)</code> then asked
|
||||
<code>detect_current_act()</code> to verify. It committed to whatever cleared 0.68
|
||||
first:
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<div class="log"><span class="bad">A5_TOWN_1 0.636</span> <span class="dim"><- phantom, always at (1046, 40), the top-right corner</span>
|
||||
<span class="ok">A4_TOWN_5 0.619</span> <span class="dim"><- the genuine marker for the act actually occupied</span></div>
|
||||
<figcaption>A 1.7 percentage point gap decided which act the bot believed it was in — so it skipped the travel and ran A5 pathing inside Act 4.</figcaption>
|
||||
</figure>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>
|
||||
The winner must now beat the best marker <strong>from any other act</strong> by 5pp.
|
||||
Below that it refuses to answer. Refusing is the safe outcome: callers treat
|
||||
<code>None</code> as "keep the assumed act", so <code>go_to_act</code> actually
|
||||
travels. <strong>Failure rate 25% → 9%.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 30</span>
|
||||
<div class="titles">
|
||||
<h3>The NPC search settled for noise</h3>
|
||||
<span class="file">src/npc_manager.py — grid sweep</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
<code>SWEEP_TAG_THRESHOLD = 0.4</code>. A rendered name tag matches almost perfectly,
|
||||
so anything mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the
|
||||
first match over threshold, clicked empty ground, and gave up.
|
||||
</p>
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>NPC</th><th>Score</th><th>Outcome</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>akara</td><td class="num">0.980</td><td><span class="yes">real — dialogue opened</span></td></tr>
|
||||
<tr><td>halbu</td><td class="num">0.995</td><td><span class="yes">real</span></td></tr>
|
||||
<tr><td>larzuk</td><td class="num">0.996</td><td><span class="yes">real</span></td></tr>
|
||||
<tr><td>qual_kehk</td><td class="num">0.424</td><td><span class="no">false — clicked nothing</span></td></tr>
|
||||
<tr><td>malah</td><td class="num">0.501</td><td><span class="no">false</span></td></tr>
|
||||
<tr><td>larzuk</td><td class="num">0.494</td><td><span class="no">false</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>Raised to <strong>0.7</strong>, in the gap with margin either side. This is why qual_kehk failed 100% (5 timeouts in 5) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 23</span>
|
||||
<div class="titles">
|
||||
<h3>The only masked template in the folder</h3>
|
||||
<span class="file">assets/templates/a5_town/a5_red_portal.png</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
The capture had the portal's hover tooltip baked in, and alpha was used to hide it.
|
||||
That made <code>alpha_to_mask</code> return a mask, routing the search to
|
||||
<code>cv2.matchTemplate(…, TM_CCOEFF_NORMED, mask=…)</code> — a combination
|
||||
<strong>OpenCV only supports for <code>TM_SQDIFF</code> and
|
||||
<code>TM_CCORR_NORMED</code></strong>.
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<div class="log">a5_red_portal.png 4 chan <span class="bad"> 60.9% opaque MASKED</span> <span class="dim"><- the only one that failed</span>
|
||||
a5_town_1..12.png 3 chan <span class="ok">100.0% opaque no mask</span> <span class="dim"><- every one that works</span></div>
|
||||
<figcaption>Found by auditing the folder rather than the file: every working template is opaque; the failing one was the sole outlier.</figcaption>
|
||||
</figure>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>Recaptured as a plain 3-channel opaque crop of the portal's <strong>upper arch</strong> — the lower ring is occluded by branches. Present 0.949–1.000, absent 0.398–0.514.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 25</span>
|
||||
<div class="titles">
|
||||
<h3>The pather invented positions and walked into a wall</h3>
|
||||
<span class="file">src/pather.py — find_abs_node_pos()</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
When the primary 0.68 search missed, the fallback dropped to a <strong>0.55
|
||||
first-match</strong> full-image search. <code>A5_TOWN_1</code> scored 0.60–0.62 on
|
||||
unrelated scenery at three different coordinates across three consecutive frames.
|
||||
The landmarks were healthy where genuinely visible (<code>A5_TOWN_6 @ 0.93</code>) —
|
||||
only the fallback was lying.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix — three parts</span>
|
||||
<p>
|
||||
Threshold to <strong>0.62</strong>; fallback forced to <code>best_match=True</code>
|
||||
(it was <code>False</code>, so ambiguous frames were decided by <em>dict ordering</em>);
|
||||
and a heading gate rejecting any <em>low-confidence</em> match implying a >90°
|
||||
reversal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 29</span>
|
||||
<div class="titles">
|
||||
<h3>An undetectable NPC cost ~80s every game</h3>
|
||||
<span class="file">src/bot.py · src/game_stats.py</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Root cause</span>
|
||||
<p>
|
||||
In nightmare the merc dies most games, so resurrect ran constantly — and it retried
|
||||
once. <code>_merc_resurrect_failed</code> is reset by <code>log_start_game</code>, so
|
||||
it only ever suppressed a second attempt <em>within one game</em>. Game length blew
|
||||
out to 185–250s against a normal ~60s.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field fix">
|
||||
<span class="lbl">Fix</span>
|
||||
<p>
|
||||
A cross-game circuit breaker that <code>log_start_game</code> deliberately does
|
||||
<em>not</em> reset. Over 30 games with an undetectable NPC: <strong>60 hunts → 4</strong>.
|
||||
Measured live, game times went 250s / 185s → 14s, 14s, 43s, 71s.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="cause">
|
||||
<div class="cause-head">
|
||||
<span class="bugid">BUG 26·27</span>
|
||||
<div class="titles">
|
||||
<h3>Two clicks that missed by ~30 pixels</h3>
|
||||
<span class="file">src/health_manager.py · src/transmute/transmute.py</span>
|
||||
</div>
|
||||
<span class="sev loud">Loud</span>
|
||||
</div>
|
||||
<div class="cause-body">
|
||||
<div class="field">
|
||||
<span class="lbl">Waypoint panel counted as a chicken</span>
|
||||
<p>A mis-steered pather walks the char over the waypoint stone, opening its panel. The panel check counted it twice and killed the game — <strong>3 of 6 games died this way at full health</strong>. Now escaped without counting, bounded at six attempts.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="lbl">Gem conversion ran in the wrong tab</span>
|
||||
<p>
|
||||
<code>GEMS_TAB_Y = 100</code>, but the stash tab labels occupy <strong>y 63–78</strong>
|
||||
and the grid starts at y≈87 — so every tab-switch click landed on a stash slot. The
|
||||
whole routine ran in PERSONAL, logging the same warning <strong>800+ times</strong>
|
||||
without one error. Tab switches are now verified, not fire-and-forget.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">Course corrections</span>
|
||||
<h2>Four times I was wrong</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
Each looked correct in isolation, and each was caught by the user or by checking rather
|
||||
than by reasoning. They are recorded because the wrong answer is often the obvious one.
|
||||
</p>
|
||||
<div class="turn">
|
||||
<span class="lbl">1 — the red-portal guard</span>
|
||||
<p>
|
||||
To stop the false "already in Pindle area", I added: if the red portal is on screen we
|
||||
must still be in town. <strong>The portal is visible from both sides</strong> — the
|
||||
return portal renders inside the temple. The guard blocked every genuine entry. It
|
||||
traded a silent false-success for an honest false-failure, which is an improvement and
|
||||
still a bug.
|
||||
</p>
|
||||
</div>
|
||||
<div class="turn">
|
||||
<span class="lbl">2 — XP as proof of a boss kill</span>
|
||||
<p>An XP gain proves <em>something</em> died — the minion pack and the mercenary's kills both count. Confirmation required a loot line naming an actual drop.</p>
|
||||
</div>
|
||||
<div class="turn">
|
||||
<span class="lbl">3 — "the template is degenerate"</span>
|
||||
<p>
|
||||
I blamed the Qual-Kehk asset for a repeated identical 0.424 score. Walking the character
|
||||
to the NPC and scoring the stored template against a frame where the tag renders gave
|
||||
<strong>1.000</strong>. The template was perfect; the <em>threshold</em> was wrong. A
|
||||
repeated identical score means the search is settling for noise — check the threshold
|
||||
before blaming the asset.
|
||||
</p>
|
||||
</div>
|
||||
<div class="turn">
|
||||
<span class="lbl">4 — selling the charms</span>
|
||||
<p>
|
||||
I read 673 blocked-sell log lines as a clog to clear and disabled the guard. It vendored
|
||||
five resistance charms before it was caught. <strong>The blocked lines were the guard
|
||||
working</strong>, and the pickit's "Discarding" verdict was never a judgement that they
|
||||
were junk — <code>LAPIS SMALL CHARM OF VITA</code> failed its rule only because the rule
|
||||
wanted <code>coldresist >= 11</code> and it had 7.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">The durable part</span>
|
||||
<h2>Instrumentation, so the next one takes minutes</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<p>
|
||||
Every bug above cost hours because the log recorded events, not <em>structure</em>. The
|
||||
last commits fix that: one fixed-width, machine-readable line per step, spawn to loot,
|
||||
every step timed.
|
||||
</p>
|
||||
<figure>
|
||||
<div class="log"><span class="dim">grep "TL>" log/log.txt</span>
|
||||
|
||||
TL> g2 r1 | game | spawn | ok | at a5_town_start
|
||||
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2
|
||||
TL> g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
|
||||
TL> g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
|
||||
<span class="bad">TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable</span>
|
||||
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
|
||||
<span class="bad">TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal</span></div>
|
||||
<figcaption>
|
||||
That failed game spent <strong>113.6s of its 137.6s town visit</strong> on a merc
|
||||
resurrect that achieved nothing — 83% of the time, on a step that was not the one that
|
||||
ended the game. That proportion was invisible before.
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
Failures get a self-contained record (<code>grep "FAIL>"</code>) carrying the reason,
|
||||
location, screenshot, the three slowest steps, and a breadcrumb trail of the whole game
|
||||
with <code>!</code> marking failures. <strong>The step named in the reason is frequently
|
||||
not the one that caused the problem</strong> — the trail is what shows that.
|
||||
</p>
|
||||
<p>
|
||||
A digest posts to Discord every two hours, aggregated from the same timeline so the report
|
||||
and the log cannot drift apart.
|
||||
</p>
|
||||
<figure>
|
||||
<div class="log">**Timing report** - last 2.0h
|
||||
Games: 12 (11 ok, 1 failed - 8.3%)
|
||||
Avg town 20s | approach 14s | battle 19s | cycle ~52s
|
||||
|
||||
__Slowest steps (avg)__
|
||||
`town.resurrect_merc ` 33s x6 (2 fail)
|
||||
`run.approach ` 14s x28 (1 fail)</div>
|
||||
<figcaption>Real measured output. Rankings exclude umbrella steps and deliberate stealth pauses, which would otherwise always come first and say nothing.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="col section-head">
|
||||
<span class="eyebrow">Prevention</span>
|
||||
<h2>Rules carried into the docs</h2>
|
||||
</div>
|
||||
<div class="col body-flow">
|
||||
<div class="checks">
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Verify boss runs with XP delta and loot lines.</strong> Never with <code>failed:false</code> — that is the bot agreeing with itself.</span>
|
||||
</div>
|
||||
<div class="check dont">
|
||||
<span class="mark">✕</span>
|
||||
<span class="ct"><strong>Never mask part of a template to hide an artifact.</strong> Masked <code>TM_CCOEFF_NORMED</code> is unsupported in OpenCV and fails unpredictably.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Prove a template fails on a frame where the subject is definitely visible</strong> before recapturing it. A repeated identical score means the threshold is too low, not that the asset is bad.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Confirm any click that changes UI mode</strong> — a tab, a page, a panel. An unverified click that "should" have worked will fail silently forever.</span>
|
||||
</div>
|
||||
<div class="check dont">
|
||||
<span class="mark">✕</span>
|
||||
<span class="ct"><strong>Never let a detector guess on a thin margin.</strong> Refusing to answer is usually the safe outcome; a wrong confident answer skips the recovery path entirely.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Read repeated warnings as a system working, not a clog to clear.</strong> 673 blocked sells were a guard doing its job.</span>
|
||||
</div>
|
||||
<div class="check">
|
||||
<span class="mark">✓</span>
|
||||
<span class="ct"><strong>Restart the bot after editing <code>src/</code> or config.</strong> Python loads modules and config at process start; a running bot keeps executing the old code.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="col" style="display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;width:100%">
|
||||
<span>my-botty · branch <strong>stable</strong> · 17 commits</span>
|
||||
<span>9 root causes · 4 corrections · verified live</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -8,27 +8,4 @@ dependencies:
|
||||
- pkg-config
|
||||
- rapidfuzz=2.15.1
|
||||
- pip:
|
||||
- pyinstaller
|
||||
- opencv-python==4.5.5.64
|
||||
- transitions
|
||||
- mss==7.0.1
|
||||
- numpy==1.26.4
|
||||
- mouse
|
||||
- keyboard
|
||||
- beautifultable
|
||||
- pytweening
|
||||
- requests
|
||||
- coverage
|
||||
- pytest
|
||||
- pytest-env
|
||||
- pytest-mock
|
||||
- pyparsing
|
||||
- graphviz
|
||||
- psutil
|
||||
- cryptography
|
||||
- pillow
|
||||
- discord.py
|
||||
- parse
|
||||
- typing_extensions
|
||||
- colorama
|
||||
- dataclasses-json
|
||||
- -r requirements.txt
|
||||
|
||||
@@ -22,8 +22,15 @@ for %%C in (
|
||||
)
|
||||
)
|
||||
|
||||
echo ERROR: Could not find botty conda environment.
|
||||
echo Run install.bat first.
|
||||
echo.
|
||||
echo ERROR: The 'botty' Python environment is not installed yet.
|
||||
echo.
|
||||
echo Fix: double-click install.bat in this folder and wait for
|
||||
echo "Installation complete!", then try again.
|
||||
echo.
|
||||
echo Already ran install.bat? Then it did not finish successfully.
|
||||
echo Run run_install_capture.bat and check install_log.txt for the error.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
|
||||
@@ -59,13 +59,26 @@ echo This is a one-time setup (~100 MB). Please wait.
|
||||
echo.
|
||||
|
||||
:: --- Method 1: winget (cleanest; available on Win10 1709+ and all Win11) ---
|
||||
:: --scope user is critical: without it winget defaults to a machine-wide
|
||||
:: install (lands in %ProgramData%) which REQUIRES admin. A normal double-click
|
||||
:: (no elevation) then fails silently and conda never installs. --scope user
|
||||
:: installs to %USERPROFILE%\miniforge3 with no admin needed — which is also one
|
||||
:: of the locations :rescan_conda searches.
|
||||
:: winget returns non-zero when the package is already installed, so don't
|
||||
:: trust the exit code -- rescan for conda and only fall through to the direct
|
||||
:: download if it's genuinely still missing. Keep this comment outside the block:
|
||||
:: a "::" line inside a ( ) block is a parse error.
|
||||
winget --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo Installing via winget...
|
||||
winget install --id CondaForge.Miniforge3 --exact --silent ^
|
||||
winget install --id CondaForge.Miniforge3 --exact --silent --scope user ^
|
||||
--accept-package-agreements --accept-source-agreements
|
||||
if !errorlevel! equ 0 goto :rescan_conda
|
||||
echo winget install failed — falling back to direct download.
|
||||
for %%C in (
|
||||
"%LOCALAPPDATA%\miniforge3\Scripts\conda.exe"
|
||||
"%USERPROFILE%\miniforge3\Scripts\conda.exe"
|
||||
"%ProgramData%\miniforge3\Scripts\conda.exe"
|
||||
) do if exist %%C goto :rescan_conda
|
||||
echo winget did not produce a usable conda — falling back to direct download.
|
||||
echo.
|
||||
)
|
||||
|
||||
@@ -78,6 +91,7 @@ if %errorlevel% equ 0 (
|
||||
set "MF_INSTALLER=%TEMP%\Miniforge3-installer.exe"
|
||||
set "MF_URL=https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe"
|
||||
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
echo Downloading Miniforge3...
|
||||
curl -Lk --progress-bar "%MF_URL%" -o "%MF_INSTALLER%" 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
@@ -87,23 +101,52 @@ if %errorlevel% neq 0 (
|
||||
)
|
||||
if not exist "%MF_INSTALLER%" (
|
||||
echo.
|
||||
echo ERROR: Could not download Miniforge3. Check your internet connection, or
|
||||
echo install it manually then re-run install.bat:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo ERROR: Could not download Miniforge3 ^(the Python/conda runtime^).
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo Nothing was downloaded from:
|
||||
echo %MF_URL%
|
||||
echo Usual causes: no internet, or a firewall/proxy blocking github.com.
|
||||
echo.
|
||||
echo Workaround: install Miniforge yourself, then run install.bat again.
|
||||
echo It will detect it and carry on:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo ^(pick Miniforge3-Windows-x86_64.exe and keep the default location^)
|
||||
goto :fail
|
||||
)
|
||||
|
||||
:: Verify the download is a real installer, not a truncated file or an HTML
|
||||
:: error page served with a 200. The Miniforge installer is ~78 MB; anything
|
||||
:: under 40 MB means the download failed even though a file exists.
|
||||
for %%A in ("%MF_INSTALLER%") do set "MF_SIZE=%%~zA"
|
||||
if not defined MF_SIZE set "MF_SIZE=0"
|
||||
if %MF_SIZE% LSS 41943040 (
|
||||
echo.
|
||||
echo ERROR: The Miniforge3 download is incomplete and was not run.
|
||||
echo Got: %MF_SIZE% bytes
|
||||
echo Expected: about 78 MB
|
||||
echo.
|
||||
echo The connection dropped, or a proxy/filter returned an error page
|
||||
echo instead of the file. The bad file has been deleted.
|
||||
echo.
|
||||
echo Try again on a different network, or install Miniforge yourself and
|
||||
echo re-run install.bat:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo Installing Miniforge3 (this takes ~1 minute)...
|
||||
start /wait "" "%MF_INSTALLER%" /S /InstallationType=JustMe /AddToPath=0 /RegisterPython=0 /NoShortcuts=1 /NoRegistry=1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).
|
||||
echo Try running it manually: %MF_INSTALLER%
|
||||
echo ERROR: The Miniforge3 installer did not complete ^(exit code %errorlevel%^).
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo Usual causes: antivirus blocked it, or a UAC prompt was declined.
|
||||
echo.
|
||||
echo Run it by hand to see the real error, accepting any prompts:
|
||||
echo %MF_INSTALLER%
|
||||
echo then run install.bat again.
|
||||
goto :fail
|
||||
)
|
||||
del /q "%MF_INSTALLER%" >nul 2>&1
|
||||
echo Miniforge3 installed.
|
||||
@@ -127,10 +170,18 @@ for %%C in (
|
||||
goto :found_conda
|
||||
)
|
||||
)
|
||||
echo ERROR: Miniforge3 installed but conda.exe still not found. Please restart
|
||||
echo the installer or contact support.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Miniforge3 reported success but conda.exe cannot be found.
|
||||
echo.
|
||||
echo install.bat looked in every standard location, including:
|
||||
echo %%LOCALAPPDATA%%\miniforge3\Scripts\conda.exe
|
||||
echo %%USERPROFILE%%\miniforge3\Scripts\conda.exe
|
||||
echo %%ProgramData%%\miniforge3\Scripts\conda.exe
|
||||
echo.
|
||||
echo If you installed conda somewhere custom, the simplest fix is to
|
||||
echo install Miniforge to its default location:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
echo then run install.bat again.
|
||||
goto :fail
|
||||
|
||||
:found_conda
|
||||
echo Found conda: %CONDA_EXE%
|
||||
@@ -141,11 +192,16 @@ echo Testing conda...
|
||||
"%CONDA_EXE%" --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: conda found but failed to run. Your conda installation may be broken.
|
||||
echo Try re-installing Miniforge: https://github.com/conda-forge/miniforge
|
||||
echo ERROR: conda was found but will not run.
|
||||
echo Location: %CONDA_EXE%
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
echo The install is damaged or blocked. Try, in order:
|
||||
echo 1. Run this by hand to see the real error:
|
||||
echo "%CONDA_EXE%" --version
|
||||
echo 2. Check antivirus is not quarantining conda
|
||||
echo 3. Reinstall Miniforge, then run install.bat again:
|
||||
echo https://github.com/conda-forge/miniforge/releases/latest
|
||||
goto :fail
|
||||
)
|
||||
echo conda is working.
|
||||
echo.
|
||||
@@ -168,14 +224,48 @@ for %%C in (
|
||||
)
|
||||
)
|
||||
|
||||
:: --- Pre-flight: free disk space ---
|
||||
:: The botty env is ~4 GB once conda unpacks it, plus ~1 GB of downloaded
|
||||
:: package archives. Running out midway makes conda fail with a long, opaque
|
||||
:: error, so check up front and say so plainly instead.
|
||||
set "FREE_GB="
|
||||
for /f "delims=" %%A in ('powershell -NoProfile -Command "[int]((Get-PSDrive %SystemDrive:~0,1%).Free/1GB)" 2^>nul') do set "FREE_GB=%%A"
|
||||
if defined FREE_GB (
|
||||
echo Free disk space: %FREE_GB% GB
|
||||
if %FREE_GB% LSS 3 (
|
||||
echo.
|
||||
echo ERROR: Not enough free disk space on %SystemDrive%
|
||||
echo Free now: %FREE_GB% GB
|
||||
echo Needed: about 6 GB ^(the botty environment is ~4 GB, plus
|
||||
echo ~1 GB of package downloads^)
|
||||
echo.
|
||||
echo Free up space and run install.bat again. Quick wins:
|
||||
echo - Empty the Recycle Bin
|
||||
echo - Windows Settings ^> System ^> Storage ^> Temporary files
|
||||
echo - Uninstall apps you no longer use
|
||||
goto :fail
|
||||
)
|
||||
if %FREE_GB% LSS 6 (
|
||||
echo WARNING: Only %FREE_GB% GB free. The install needs about 6 GB and
|
||||
echo may fail partway. Free up space if it does.
|
||||
)
|
||||
)
|
||||
|
||||
:: --- Create botty env ---
|
||||
echo Creating 'botty' conda environment...
|
||||
"%CONDA_EXE%" env create -f "%ENV_FILE%"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: conda env create failed. See output above.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not create the 'botty' conda environment.
|
||||
echo.
|
||||
echo The detailed reason is in the conda output above this message.
|
||||
echo Most common causes:
|
||||
echo - Out of disk space ^(needs about 6 GB free^)
|
||||
echo - No internet, or a company/school network blocking conda-forge
|
||||
echo - Antivirus blocking conda while it unpacks files
|
||||
echo - A previous half-finished install: run this to clear it, then retry
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
goto :fail
|
||||
)
|
||||
|
||||
:check_env
|
||||
@@ -198,9 +288,16 @@ for %%C in (
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ERROR: botty env was created but python.exe was not found.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: The 'botty' environment exists but its python.exe is missing.
|
||||
echo.
|
||||
echo This usually means the environment was only partly created -- often
|
||||
echo because the install ran out of disk space, or antivirus removed files
|
||||
echo while conda was unpacking them.
|
||||
echo.
|
||||
echo Fix: delete the environment and install again:
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
echo install.bat
|
||||
goto :fail
|
||||
|
||||
:env_ready
|
||||
echo Botty Python: %PYTHON%
|
||||
@@ -215,9 +312,17 @@ echo Installing Botty requirements from %REQ_FILE%...
|
||||
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off -r "%REQ_FILE%"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: pip requirements install failed for %REQ_FILE%.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not install the Python packages from %REQ_FILE%.
|
||||
echo.
|
||||
echo The failing package and reason are in the pip output above.
|
||||
echo Most common causes:
|
||||
echo - No internet, or a proxy/firewall blocking pypi.org
|
||||
echo - Out of disk space
|
||||
echo - Antivirus blocking pip while it writes files
|
||||
echo.
|
||||
echo The conda environment itself is fine -- you can just run
|
||||
echo install.bat again; it will skip straight to this step.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo.
|
||||
@@ -249,9 +354,14 @@ for %%C in (
|
||||
goto :env_found
|
||||
)
|
||||
)
|
||||
echo ERROR: Could not find botty env directory.
|
||||
pause
|
||||
exit /b 1
|
||||
echo ERROR: Could not locate the 'botty' environment folder.
|
||||
echo.
|
||||
echo Python was found but the environment directory around it was not,
|
||||
echo which means the conda install is in an unexpected layout.
|
||||
echo Fix: remove and recreate the environment:
|
||||
echo "%CONDA_EXE%" env remove -n botty -y
|
||||
echo install.bat
|
||||
goto :fail
|
||||
|
||||
:env_found
|
||||
set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_ENV_DIR%\DLLs;%BOTTY_ENV_DIR%\Scripts"
|
||||
@@ -267,8 +377,27 @@ set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_EN
|
||||
:: Remove any existing pip/wheel tesserocr
|
||||
"%PYTHON%" -m pip uninstall tesserocr -y >nul 2>&1
|
||||
|
||||
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs
|
||||
"%CONDA_EXE%" install -n botty "tesseract=4.*" -c conda-forge -y >nul 2>&1
|
||||
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs.
|
||||
:: libdeflate is required explicitly: the libtiff that ships with tesseract 4.x
|
||||
:: imports libdeflate, and without it the whole chain below fails to load.
|
||||
"%CONDA_EXE%" install -n botty "tesseract=4.*" libdeflate -c conda-forge -y >nul 2>&1
|
||||
|
||||
:: libdeflate.dll compatibility alias.
|
||||
:: THIS IS WHAT MAKES tesserocr WORK. The DLL chain is:
|
||||
:: tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll -> libdeflate.dll
|
||||
:: Current conda-forge libdeflate (>=1.20) installs the library as "deflate.dll",
|
||||
:: but the older tiff.dll from the tesseract=4.x stack still imports the previous
|
||||
:: name "libdeflate.dll". Nothing provides that name, so tiff.dll fails to load,
|
||||
:: and every DLL above it in the chain fails with WinError 126 ("The specified
|
||||
:: module could not be found") -- which surfaced as tesserocr being permanently
|
||||
:: unavailable and the bot silently falling back to the slower pytesseract.
|
||||
:: Copying deflate.dll to the old name satisfies the import; the exported symbols
|
||||
:: are the same library, verified by tesserocr initialising and running real OCR.
|
||||
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
|
||||
copy /y "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" >nul
|
||||
)
|
||||
)
|
||||
|
||||
:: conda tesseract.exe crashes on Win10 and Win11 — disable it, keep the DLLs
|
||||
if exist "%BOTTY_ENV_DIR%\Library\bin\tesseract.exe" (
|
||||
@@ -288,23 +417,83 @@ if exist "dependencies\tesserocr.cp310-win_amd64.pyd" (
|
||||
)
|
||||
|
||||
:: --- Backend 2: pytesseract (reliable fallback) ---
|
||||
:: Needs tesseract.exe from winget. Works on any Python version.
|
||||
:: On Win10: winget may not be available -- if install fails, offer manual link.
|
||||
:: This is the backend that actually carries OCR on most machines (tesserocr's
|
||||
:: MSVC DLL chain frequently fails), so it must install without admin rights and
|
||||
:: without winget -- neither is guaranteed on a clean Win10 box.
|
||||
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off pytesseract >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo WARNING: Could not install pytesseract Python wrapper.
|
||||
)
|
||||
|
||||
if not exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
echo Installing Tesseract OCR via winget...
|
||||
winget install --id tesseract-ocr.tesseract --silent --accept-package-agreements --accept-source-agreements 2>nul
|
||||
if !errorlevel! neq 0 (
|
||||
echo winget failed -- on Windows 10 you may need to install manually:
|
||||
echo https://github.com/tesseract-ocr/tesseract/releases
|
||||
echo Download the win64 installer, run it, keep the default install path.
|
||||
:: Try winget machine scope, then user scope -- the latter needs no admin.
|
||||
:: winget's exit code is unreliable, being non-zero when the package is already
|
||||
:: installed, so after each attempt we re-resolve tesseract.exe instead of
|
||||
:: trusting errorlevel.
|
||||
:: NOTE: comments must stay OUTSIDE the parenthesised blocks below. A "::" line
|
||||
:: inside a ( ) block is a parse error, and any parenthesis in the comment text
|
||||
:: closes the block early.
|
||||
call :find_tesseract
|
||||
if not defined TESS_EXE (
|
||||
echo Installing Tesseract OCR...
|
||||
winget --version >nul 2>&1
|
||||
if !errorlevel! equ 0 (
|
||||
winget install --id tesseract-ocr.tesseract --exact --silent ^
|
||||
--accept-package-agreements --accept-source-agreements >nul 2>&1
|
||||
call :find_tesseract
|
||||
if not defined TESS_EXE (
|
||||
winget install --id tesseract-ocr.tesseract --exact --silent --scope user ^
|
||||
--accept-package-agreements --accept-source-agreements >nul 2>&1
|
||||
call :find_tesseract
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:: Last resort: direct download of the official NSIS installer. Covers clean
|
||||
:: Win10 machines where winget is missing or fails.
|
||||
:: The size check guards against a truncated download or an HTML error page;
|
||||
:: the installer is ~50 MB.
|
||||
:: NOTE on /D=: measured behaviour is that the official Tesseract installer
|
||||
:: self-elevates (it requests admin) and the elevated relaunch DISCARDS /D=, so
|
||||
:: it always lands machine-wide in "C:\Program Files\Tesseract-OCR" regardless
|
||||
:: of TS_DEST. /D= is kept as best-effort only. The practical consequence is
|
||||
:: that Tesseract needs admin/UAC -- there is no per-user install path with the
|
||||
:: official installer. Either outcome is fine at runtime because find_tesseract
|
||||
:: and src\d2r_image\ocr.py both search the machine-wide and per-user paths.
|
||||
:: /D= must still come last and unquoted, and breaks on paths with spaces, so
|
||||
:: it is only passed when the target path has none.
|
||||
if not defined TESS_EXE (
|
||||
echo winget unavailable or failed -- downloading Tesseract directly...
|
||||
set "TS_INSTALLER=%TEMP%\tesseract-setup.exe"
|
||||
set "TS_URL=https://github.com/tesseract-ocr/tesseract/releases/download/5.5.0/tesseract-ocr-w64-setup-5.5.0.20241111.exe"
|
||||
set "TS_DEST=%LOCALAPPDATA%\Programs\Tesseract-OCR"
|
||||
del /q "!TS_INSTALLER!" >nul 2>&1
|
||||
curl -Lk --progress-bar "!TS_URL!" -o "!TS_INSTALLER!" 2>&1
|
||||
if not exist "!TS_INSTALLER!" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$ProgressPreference='SilentlyContinue'; try { Invoke-WebRequest -Uri '!TS_URL!' -OutFile '!TS_INSTALLER!' -UseBasicParsing; exit 0 } catch { exit 1 }"
|
||||
)
|
||||
set "TS_SIZE=0"
|
||||
if exist "!TS_INSTALLER!" for %%A in ("!TS_INSTALLER!") do set "TS_SIZE=%%~zA"
|
||||
if !TS_SIZE! GEQ 20971520 (
|
||||
echo !TS_DEST! | find " " >nul
|
||||
if !errorlevel! equ 0 (
|
||||
start /wait "" "!TS_INSTALLER!" /S
|
||||
) else (
|
||||
start /wait "" "!TS_INSTALLER!" /S /D=!TS_DEST!
|
||||
)
|
||||
del /q "!TS_INSTALLER!" >nul 2>&1
|
||||
call :find_tesseract
|
||||
)
|
||||
)
|
||||
|
||||
if defined TESS_EXE (
|
||||
echo Tesseract: !TESS_EXE!
|
||||
) else (
|
||||
echo WARNING: Tesseract could not be installed automatically. Install manually:
|
||||
echo https://github.com/tesseract-ocr/tesseract/releases
|
||||
echo Download the win64 installer, run it, then re-run install.bat.
|
||||
)
|
||||
|
||||
:: --- Verify OCR: at least one backend must work ---
|
||||
echo.
|
||||
echo Checking OCR backends...
|
||||
@@ -319,8 +508,8 @@ if %errorlevel% == 0 (
|
||||
echo tesserocr: not available ^(DLL issue -- bot will use pytesseract instead^)
|
||||
)
|
||||
|
||||
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'C:\Program Files\Tesseract-OCR\tesseract.exe'; pytesseract.get_tesseract_version()" >nul 2>&1
|
||||
if defined TESS_EXE (
|
||||
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'!TESS_EXE!'; pytesseract.get_tesseract_version()" >nul 2>&1
|
||||
if !errorlevel! == 0 (
|
||||
echo pytesseract: OK ^(reliable fallback^)
|
||||
set "OCR_READY=1"
|
||||
@@ -384,3 +573,52 @@ echo Installation complete!
|
||||
echo Run botty with: run_botty.bat
|
||||
echo ============================================
|
||||
echo.
|
||||
:: Hold the window open. Every failure path already pauses, but the success
|
||||
:: path did not -- so a user double-clicking install.bat from Explorer saw the
|
||||
:: console vanish the instant it finished and never got to read the result or
|
||||
:: the dependency/OCR verification above. Redirected runs are unaffected:
|
||||
:: run_install_capture.bat feeds stdin from the log redirect, and any
|
||||
:: non-interactive run should invoke this script with "< nul".
|
||||
pause
|
||||
goto :eof
|
||||
|
||||
:: --- Shared failure exit ---
|
||||
:: Every fatal path jumps here with "goto :fail" after printing what went wrong
|
||||
:: and how to fix it. This adds the one instruction that makes a bug report
|
||||
:: actionable: how to produce a full log.
|
||||
:fail
|
||||
echo.
|
||||
echo ------------------------------------------------------------
|
||||
echo INSTALL FAILED - nothing else was changed on your PC.
|
||||
echo.
|
||||
echo Still stuck? Produce a full log and include it when asking
|
||||
echo for help:
|
||||
echo 1. Double-click run_install_capture.bat
|
||||
echo 2. Attach the install_log.txt it creates
|
||||
echo ------------------------------------------------------------
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:: --- Resolve tesseract.exe into TESS_EXE ---
|
||||
:: Checks machine-wide (winget default / manual install) and per-user (winget
|
||||
:: --scope user / our direct NSIS fallback) locations, plus PATH. Sets TESS_EXE
|
||||
:: to the first hit, or clears it if none found.
|
||||
:find_tesseract
|
||||
set "TESS_EXE="
|
||||
for %%T in (
|
||||
"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
|
||||
"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
|
||||
"%ProgramData%\Tesseract-OCR\tesseract.exe"
|
||||
) do (
|
||||
if exist %%T (
|
||||
set "TESS_EXE=%%~T"
|
||||
goto :eof
|
||||
)
|
||||
)
|
||||
for /f "delims=" %%T in ('where tesseract 2^>nul') do (
|
||||
set "TESS_EXE=%%T"
|
||||
goto :eof
|
||||
)
|
||||
goto :eof
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "botty"
|
||||
version = "0.8.1"
|
||||
version = "0.10.0"
|
||||
description = "Pixelbot for Diablo 2 Resurrected"
|
||||
requires-python = ">=3.10,<3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[pytest]
|
||||
pythonpath =
|
||||
.
|
||||
src
|
||||
env =
|
||||
PYTHONPATH=./src
|
||||
PYTHONPATH=./src:.
|
||||
RUN_ENV=test
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiohappyeyeballs==2.6.2
|
||||
pywin32==311
|
||||
aiohttp==3.14.1
|
||||
aiosignal==1.4.0
|
||||
async-timeout==5.0.1
|
||||
|
||||
@@ -17,9 +17,16 @@ set "CONDA_PREFIX=%_ENV%"
|
||||
set "PYTHONUTF8=1"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
set "SSL_CERT_DIR="
|
||||
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation)
|
||||
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation).
|
||||
:: Only export the path if it actually exists -- on machines where Tesseract was
|
||||
:: installed per-user (no admin), it lives under %LOCALAPPDATA%\Programs instead,
|
||||
:: and src\d2r_image\ocr.py resolves that itself.
|
||||
set "TESSDATA_PREFIX=%_ENV%\Library\share"
|
||||
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
|
||||
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
) else if exist "%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe" (
|
||||
set "PYTESSERACT_TESSERACT_CMD=%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
|
||||
)
|
||||
|
||||
echo Launching Botty ...
|
||||
"%PYTHON%" "%BOTTY_DIR%src\main.py"
|
||||
|
||||
@@ -1,2 +1,18 @@
|
||||
@echo off
|
||||
call "%~dp0install.bat" > "%~dp0install_log.txt" 2>&1
|
||||
:: Runs install.bat and captures everything to install_log.txt for support.
|
||||
:: stdin is fed from nul so install.bat's trailing "pause" (and any failure
|
||||
:: pause) cannot silently block behind the redirected output -- otherwise the
|
||||
:: user would see an empty window waiting on a keypress they cannot see.
|
||||
echo Installing and writing a full log to install_log.txt ...
|
||||
echo This can take several minutes. Please wait.
|
||||
call "%~dp0install.bat" < nul > "%~dp0install_log.txt" 2>&1
|
||||
set "RC=%ERRORLEVEL%"
|
||||
echo.
|
||||
if "%RC%"=="0" (
|
||||
echo Install finished. Full log: "%~dp0install_log.txt"
|
||||
) else (
|
||||
echo Install FAILED with exit code %RC%. Send this file for support:
|
||||
echo "%~dp0install_log.txt"
|
||||
)
|
||||
echo.
|
||||
pause
|
||||
|
||||
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 162 KiB |
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
"""Hermes bot control — send commands to botty via TCP socket on 127.0.0.1:18899.
|
||||
|
||||
Usage:
|
||||
python scripts/hermes_bot_control.py start # start/pause bot
|
||||
python scripts/hermes_bot_control.py pause # toggle pause
|
||||
python scripts/hermes_bot_control.py stop # stop bot
|
||||
python scripts/hermes_bot_control.py status # get bot status
|
||||
python scripts/hermes_bot_control.py logs [n] # last n log lines
|
||||
python scripts/hermes_bot_control.py errors [n] # last n error lines
|
||||
python scripts/hermes_bot_control.py runs # run stats
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import glob
|
||||
import socket
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||
LOG_FILE = os.path.join(PROJECT_ROOT, 'log', 'log.txt')
|
||||
SOCKET_PORT = 18899
|
||||
|
||||
def send_command(cmd):
|
||||
"""Send a command to the bot's control socket."""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(3.0)
|
||||
s.connect(('127.0.0.1', SOCKET_PORT))
|
||||
s.sendall(cmd.encode())
|
||||
response = s.recv(1024).decode().strip()
|
||||
s.close()
|
||||
if response:
|
||||
print(response)
|
||||
else:
|
||||
print(f"OK: command '{cmd}' sent")
|
||||
except socket.timeout:
|
||||
print(f"ERROR: no response from bot (socket timeout)")
|
||||
except ConnectionRefusedError:
|
||||
print(f"ERROR: bot not listening on port {SOCKET_PORT} (is it running?)")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
|
||||
def read_logs(n=20):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
print("No log file found")
|
||||
return
|
||||
result = subprocess.run(["tail", "-n", str(n), LOG_FILE],
|
||||
capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
|
||||
def read_errors(n=10):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
print("No log file found")
|
||||
return
|
||||
result = subprocess.run(["grep", "-E", "ERROR|WARNING|Failed|failed|ERROR.*step", LOG_FILE],
|
||||
capture_output=True, text=True)
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for line in lines[-n:]:
|
||||
print(line)
|
||||
|
||||
def run_stats():
|
||||
stats_dir = os.path.join(PROJECT_ROOT, 'log', 'stats')
|
||||
stats_files = glob.glob(os.path.join(stats_dir, 'stats_*.log'))
|
||||
if stats_files:
|
||||
latest = max(stats_files, key=os.path.getmtime)
|
||||
with open(latest) as f:
|
||||
print(f.read())
|
||||
else:
|
||||
print("No stats files found")
|
||||
|
||||
def check_status():
|
||||
send_command('status')
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd in ('start', 'pause', 'stop'):
|
||||
send_command(cmd)
|
||||
elif cmd == 'status':
|
||||
check_status()
|
||||
elif cmd == 'logs':
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||
read_logs(n)
|
||||
elif cmd == 'errors':
|
||||
n = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
read_errors(n)
|
||||
elif cmd == 'runs':
|
||||
run_stats()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
@@ -36,6 +36,19 @@ class IChar:
|
||||
}
|
||||
self._use_safer_routines = Config().char["safer_routines"]
|
||||
|
||||
def atk_len(self, name: str) -> float:
|
||||
"""Attack window for a boss, with stealth kill-time variance applied.
|
||||
|
||||
THE single correct way to read an atk_len_* value. Reading
|
||||
Config().char["atk_len_x"] directly works but silently opts that boss
|
||||
out of vary_kill_time — the failure mode this whole subsystem keeps
|
||||
producing. randomize_run_duration only ever LENGTHENS (1.0-1.4x), so a
|
||||
varied window can never cut a kill short.
|
||||
"""
|
||||
from utils.stealth import randomize_run_duration
|
||||
key = name if name.startswith("atk_len_") else f"atk_len_{name}"
|
||||
return randomize_run_duration(float(Config().char[key]))
|
||||
|
||||
def _set_active_skill(self, mouse_click_type: str = "left", skill: str =""):
|
||||
self._active_skill[mouse_click_type] = skill
|
||||
|
||||
@@ -130,7 +143,8 @@ class IChar:
|
||||
success_func: Callable = None,
|
||||
timeout: float = 8,
|
||||
threshold: float = 0.68,
|
||||
telekinesis: bool = False
|
||||
telekinesis: bool = False,
|
||||
roi: list = None
|
||||
) -> bool:
|
||||
"""
|
||||
Finds any template from the template finder and interacts with it
|
||||
@@ -138,6 +152,10 @@ class IChar:
|
||||
:param success_func: Function that will return True if the interaction is successful e.g. return True when loading screen is reached, defaults to None
|
||||
:param timeout: Timeout for the whole template selection, defaults to None
|
||||
:param threshold: Threshold which determines if a template is found or not. None will use default form .ini files
|
||||
:param roi: Optional [left, top, width, height] to restrict the search. Pass
|
||||
ui_roi["cut_skill_bar"] for WORLD objects: the HUD is static, so a template
|
||||
that happens to resemble part of it wins every frame and the real object is
|
||||
never clicked. Cost a whole evening of A5_WP failures on 2026-08-27.
|
||||
:return: True if success. False otherwise
|
||||
"""
|
||||
templates = template_type if isinstance(template_type, list) else [template_type]
|
||||
@@ -148,7 +166,7 @@ class IChar:
|
||||
wait(0.2, 0.3)
|
||||
start = time.time()
|
||||
while timeout is None or (time.time() - start) < timeout:
|
||||
template_match = template_finder.search(template_type, grab(), threshold=threshold)
|
||||
template_match = template_finder.search(template_type, grab(), threshold=threshold, roi=roi)
|
||||
if template_match.valid:
|
||||
Logger.debug(f"Select {template_match.name} ({template_match.score*100:.1f}% confidence)")
|
||||
mouse.move(*template_match.center_monitor)
|
||||
@@ -213,7 +231,41 @@ class IChar:
|
||||
if skills.wait_until_right_skill_selected("TELE_ACTIVE") == False:
|
||||
Logger.error("timeout waiting for tele skill to activate")
|
||||
|
||||
@staticmethod
|
||||
def _hud_safe_target(pos_monitor: tuple[float, float], jitter: int) -> tuple[int, int]:
|
||||
"""Jitter FIRST, then snap out of the HUD. Returns a point to click with
|
||||
randomize=0.
|
||||
|
||||
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
|
||||
construction lies exactly on the mask boundary. Applying mouse.move's
|
||||
randomize AFTER it therefore pushes the cursor straight back inside:
|
||||
randomize=3 offsets each axis by randrange(-3, 3), i.e. -3..+2. That
|
||||
made the loot-filter clicks intermittent rather than fixed — the first
|
||||
version of this guard had exactly that hole, and its test only checked
|
||||
the guard's output, never the cursor that was finally clicked.
|
||||
|
||||
Order matters: jitter, then guard, then move with no further
|
||||
randomisation. The human-like offset is preserved; the guarantee is not
|
||||
given away.
|
||||
"""
|
||||
x, y = int(pos_monitor[0]), int(pos_monitor[1])
|
||||
if jitter and jitter > 0:
|
||||
x += random.randrange(-jitter, jitter)
|
||||
y += random.randrange(-jitter, jitter)
|
||||
return get_closest_non_hud_pixel((x, y), "monitor")
|
||||
|
||||
def move(self, pos_monitor: tuple[float, float], force_tp: bool = False, force_move: bool = False):
|
||||
# Never click into the HUD. Neither branch below did this, so a move
|
||||
# target low on the screen landed on the interface — most visibly the
|
||||
# loot-filter toggles at the bottom-left (screen x 395-560, y 692-712),
|
||||
# which a right-click flips. Reported after Larzuk trips: he stands on
|
||||
# the left of Harrogath, so moves to and from him aim at that corner.
|
||||
#
|
||||
# Latent for a long time and surfaced by Enigma: the walk branch shrinks
|
||||
# its target toward centre via adjust_factor, which mostly kept clicks
|
||||
# off the HUD by accident, while the teleport branch clicks the raw
|
||||
# target. The pather's anti-stuck path already guarded this; move() did
|
||||
# not.
|
||||
factor = Config().advanced_options["pathing_delay_factor"]
|
||||
if "teleport" in Config().char and Config().char["teleport"] and (
|
||||
force_tp
|
||||
@@ -223,7 +275,8 @@ class IChar:
|
||||
)
|
||||
):
|
||||
self._set_active_skill("right", "teleport")
|
||||
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, delay_factor=[factor*0.1, factor*0.14])
|
||||
tx, ty = self._hud_safe_target(pos_monitor, 3)
|
||||
mouse.move(tx, ty, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
mouse.click(button="right")
|
||||
wait(self._cast_duration, self._cast_duration + 0.02)
|
||||
@@ -237,7 +290,8 @@ class IChar:
|
||||
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
|
||||
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
|
||||
x, y = convert_abs_to_monitor(pos_abs)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
|
||||
x, y = self._hud_safe_target((x, y), 5)
|
||||
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
if force_move:
|
||||
keyboard.send(Config().char["force_move"])
|
||||
@@ -255,7 +309,9 @@ class IChar:
|
||||
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
|
||||
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
|
||||
x, y = convert_abs_to_monitor(pos_abs)
|
||||
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
|
||||
# Same HUD guard as move(). walk() had the identical unguarded click.
|
||||
x, y = self._hud_safe_target((x, y), 5)
|
||||
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
|
||||
wait(0.012, 0.02)
|
||||
if force_move:
|
||||
keyboard.send(Config().char["force_move"])
|
||||
|
||||
@@ -128,7 +128,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_pindle(self) -> bool:
|
||||
atk_len_dur = float(Config().char["atk_len_pindle"])
|
||||
atk_len_dur = self.atk_len("atk_len_pindle")
|
||||
pindle_pos_abs = convert_screen_to_abs(Config().path["pindle_end"][0])
|
||||
|
||||
cast_pos_abs = [pindle_pos_abs[0] * 0.80, pindle_pos_abs[1] * 0.80]
|
||||
@@ -162,7 +162,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_council(self) -> bool:
|
||||
atk_len_dur = float(Config().char["atk_len_trav"])
|
||||
atk_len_dur = self.atk_len("atk_len_trav")
|
||||
|
||||
keyboard.send(self._skill_hotkeys["conviction"])
|
||||
wait(.15)
|
||||
@@ -184,7 +184,7 @@ class FoHdin(Paladin):
|
||||
|
||||
def kill_eldritch(self) -> bool:
|
||||
eld_pos_abs = convert_screen_to_abs(Config().path["eldritch_end"][0])
|
||||
atk_len_dur = float(Config().char["atk_len_eldritch"])
|
||||
atk_len_dur = self.atk_len("atk_len_eldritch")
|
||||
|
||||
self._generic_foh_attack_sequence(default_target_abs=eld_pos_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, default_spray=70)
|
||||
|
||||
@@ -202,7 +202,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_shenk(self):
|
||||
atk_len_dur = float(Config().char["atk_len_shenk"])
|
||||
atk_len_dur = self.atk_len("atk_len_shenk")
|
||||
|
||||
# traverse to shenk
|
||||
keyboard.send(self._skill_hotkeys["conviction"])
|
||||
@@ -221,7 +221,7 @@ class FoHdin(Paladin):
|
||||
|
||||
|
||||
def kill_nihlathak(self, end_nodes: list[int]) -> bool:
|
||||
atk_len_dur = Config().char["atk_len_nihlathak"]
|
||||
atk_len_dur = self.atk_len("atk_len_nihlathak")
|
||||
# Move close to nihlathak
|
||||
self._pather.traverse_nodes(end_nodes, self, timeout=0.8, do_pre_move=False)
|
||||
if self._select_skill("blessed_hammer"):
|
||||
@@ -238,7 +238,7 @@ class FoHdin(Paladin):
|
||||
|
||||
def kill_summoner(self) -> bool:
|
||||
# Attack
|
||||
atk_len_dur = Config().char["atk_len_arc"]
|
||||
atk_len_dur = self.atk_len("atk_len_arc")
|
||||
self._generic_foh_attack_sequence(min_duration=atk_len_dur, max_duration=atk_len_dur*2, default_spray=80)
|
||||
self._activate_cleanse_redemption()
|
||||
return True
|
||||
@@ -784,7 +784,7 @@ class FoHdin(Paladin):
|
||||
def kill_diablo(self) -> bool:
|
||||
### APPROACH ###
|
||||
### ATTACK ###
|
||||
atk_len_dur = float(Config().char["atk_len_diablo"])
|
||||
atk_len_dur = self.atk_len("atk_len_diablo")
|
||||
Logger.debug("Attacking Diablo at position 1/1")
|
||||
diablo_abs = [100,-100] #hardcoded dia pos.
|
||||
self._generic_foh_attack_sequence(default_target_abs=diablo_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, aura="concentration", foh_to_holy_bolt_ratio=2)
|
||||
|
||||
@@ -1278,14 +1278,14 @@ class Hammerdin(Paladin):
|
||||
Logger.debug("Waiting for Diablo to appear...")
|
||||
start = time.time()
|
||||
diablo_found = False
|
||||
while (time.time() - start) < 15:
|
||||
while (time.time() - start) < 20:
|
||||
if get_visible_targets():
|
||||
diablo_found = True
|
||||
Logger.info("Diablo has spawned, engaging!")
|
||||
break
|
||||
wait(0.5, 0.6)
|
||||
if not diablo_found:
|
||||
Logger.warning("Diablo did not appear within 15s, attacking anyway")
|
||||
Logger.warning("Diablo did not appear within 20s, attacking anyway")
|
||||
### ATTACK WITH CONCENTRATION ###
|
||||
# Concentration is the Blessed Hammer damage synergy and a party aura the
|
||||
# merc benefits from. Conviction does nothing for magic-damage hammers, and
|
||||
@@ -1295,10 +1295,17 @@ class Hammerdin(Paladin):
|
||||
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
|
||||
Logger.debug("Attacking Diablo at position 1/1")
|
||||
self._cast_hammers(Config().char["atk_len_diablo"], "concentration")
|
||||
# Re-verify targets mid-fight; if Diablo moved, reposition
|
||||
if get_visible_targets():
|
||||
pos_m = convert_abs_to_monitor((0, 0))
|
||||
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
|
||||
self._move_and_attack((60, 30), Config().char["atk_len_diablo"], "concentration")
|
||||
self._move_and_attack((-60, -30), Config().char["atk_len_diablo"], "concentration")
|
||||
wait(0.1, 0.15)
|
||||
self._cast_hammers(1.2, "redemption")
|
||||
# Final redemption burst to ensure kill
|
||||
wait(0.1, 0.2)
|
||||
self._cast_hammers(0.8, "redemption")
|
||||
### LOOT ###
|
||||
# force=True: Diablo is dead; his death animation/lingering effects register
|
||||
# as targets and the mobs-alive guard would skip his drops entirely.
|
||||
|
||||
@@ -30,6 +30,11 @@ class Paladin(IChar):
|
||||
self._action_frame = 6
|
||||
|
||||
def cast_buffs(self, casting_delay: float):
|
||||
# Skip entirely when Holy Shield is unbound. keyboard.send() tolerates
|
||||
# the empty key now, but the right-click after it would still fire and
|
||||
# cast whatever happens to be on the right slot instead.
|
||||
if not self._skill_hotkeys.get("holy_shield"):
|
||||
return
|
||||
keyboard.send(self._skill_hotkeys["holy_shield"])
|
||||
wait(0.04, 0.1)
|
||||
mouse.click(button="right")
|
||||
|
||||
@@ -26,6 +26,8 @@ class Config:
|
||||
ui_pos = {}
|
||||
routes = {}
|
||||
routes_order = []
|
||||
cold_plains = {}
|
||||
baal_xp = {}
|
||||
char = {}
|
||||
colors = {}
|
||||
shop = {}
|
||||
@@ -259,6 +261,7 @@ class Config:
|
||||
"custom_message_hook": self._select_val("general", "custom_message_hook"),
|
||||
"custom_loot_message_hook": self._select_val("general", "custom_loot_message_hook"),
|
||||
"discord_status_count": False if not self._select_val("general", "discord_status_count") else int(self._select_val("general", "discord_status_count")),
|
||||
"discord_timing_report_h": 2.0 if not self._select_val("general", "discord_timing_report_h") else float(self._select_val("general", "discord_timing_report_h")),
|
||||
"discord_status_runs": False if not self._select_optional("general", "discord_status_runs", "") else int(self._select_optional("general", "discord_status_runs", "")),
|
||||
"discord_log_chicken": bool(int(self._select_val("general", "discord_log_chicken"))),
|
||||
"discord_log_errors": bool(int(self._select_optional("general", "discord_log_errors", "1"))),
|
||||
@@ -295,7 +298,6 @@ class Config:
|
||||
self.stealth = {
|
||||
"wait_jitter_min": float(self._select_optional("stealth", "wait_jitter_min", "0.85")),
|
||||
"wait_jitter_max": float(self._select_optional("stealth", "wait_jitter_max", "1.20")),
|
||||
"click_variance": int(self._select_optional("stealth", "click_variance", "8")),
|
||||
"reshuffle_each_rotation": bool(int(self._select_optional("stealth", "reshuffle_each_rotation", "1"))),
|
||||
"skip_run_chance": int(self._select_optional("stealth", "skip_run_chance", "10")),
|
||||
"afk_break_chance": int(self._select_optional("stealth", "afk_break_chance", "5")),
|
||||
@@ -306,6 +308,29 @@ class Config:
|
||||
"micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")),
|
||||
"human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")),
|
||||
"vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))),
|
||||
# These eight were present in params.ini but never loaded here, so
|
||||
# utils.stealth's cfg.get(key, <hardcoded>) always won and editing
|
||||
# params.ini had NO effect. They only looked correct because the
|
||||
# hardcoded fallbacks happened to match the shipped ini values.
|
||||
"click_delay_min_ms": int(self._select_optional("stealth", "click_delay_min_ms", "50")),
|
||||
"click_delay_max_ms": int(self._select_optional("stealth", "click_delay_max_ms", "250")),
|
||||
"key_press_min_ms": int(self._select_optional("stealth", "key_press_min_ms", "20")),
|
||||
"key_press_max_ms": int(self._select_optional("stealth", "key_press_max_ms", "200")),
|
||||
"skill_hesitation_min_ms": int(self._select_optional("stealth", "skill_hesitation_min_ms", "80")),
|
||||
"skill_hesitation_max_ms": int(self._select_optional("stealth", "skill_hesitation_max_ms", "300")),
|
||||
"wrong_waypoint_chance": float(self._select_optional("stealth", "wrong_waypoint_chance", "0.025")),
|
||||
"skill_mistake_chance": float(self._select_optional("stealth", "skill_mistake_chance", "0.015")),
|
||||
# Opt-in switches for behaviours that alter WHERE or WHEN a click
|
||||
# lands. Both default OFF: the per-call-site randomize= values in
|
||||
# npc_manager/waypoint are hand-tuned against real button geometry,
|
||||
# and stacking a global offset on top is what starts missing NPCs.
|
||||
"click_delay_enabled": bool(int(self._select_optional("stealth", "click_delay_enabled", "0"))),
|
||||
# Session rhythm — the signals per-action jitter cannot reach.
|
||||
"session_budget_h": float(self._select_optional("stealth", "session_budget_h", "0")),
|
||||
"idle_drift_enabled": bool(int(self._select_optional("stealth", "idle_drift_enabled", "1"))),
|
||||
"chicken_variance": float(self._select_optional("stealth", "chicken_variance", "0.08")),
|
||||
"town_browse_chance": float(self._select_optional("stealth", "town_browse_chance", "0.06")),
|
||||
"pickup_skip_chance": float(self._select_optional("stealth", "pickup_skip_chance", "0.02")),
|
||||
}
|
||||
|
||||
self.routes = {}
|
||||
@@ -373,6 +398,7 @@ class Config:
|
||||
"runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")),
|
||||
"runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")),
|
||||
"repair_npc": self._select_optional("char", "repair_npc", "a4_halbu").strip().lower(),
|
||||
"resurrect_npc": self._select_optional("char", "resurrect_npc", "").strip().lower(),
|
||||
"gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","),
|
||||
"sell_junk": bool(int(self._select_val("char", "sell_junk"))),
|
||||
"protect_shields_from_sell": bool(int(self._select_optional("char", "protect_shields_from_sell", "1"))),
|
||||
@@ -382,6 +408,36 @@ class Config:
|
||||
"enable_no_pickup": bool(int(self._select_val("char", "enable_no_pickup"))),
|
||||
"safer_routines": bool(int(self._select_val("char", "safer_routines"))),
|
||||
}
|
||||
# Cold Plains clear run
|
||||
self.cold_plains = dict(self.configs["config"]["parser"]["cold_plains"])
|
||||
if "cold_plains" in self.configs["profile"]["parser"]:
|
||||
self.cold_plains.update(dict(self.configs["profile"]["parser"]["cold_plains"]))
|
||||
if "cold_plains" in self.configs["custom"]["parser"]:
|
||||
self.cold_plains.update(dict(self.configs["custom"]["parser"]["cold_plains"]))
|
||||
# Baal XP farm (join public games, hide, collect XP, leave)
|
||||
self.baal_xp = {
|
||||
"enabled": self._select_optional("baal_xp", "enabled", "0") not in (None, "", "0", "false", "False"),
|
||||
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", "") or "",
|
||||
"max_wait_s": float(self._select_optional("baal_xp", "max_wait_s", "900")),
|
||||
"xp_threshold": int(self._select_optional("baal_xp", "xp_threshold", "50000000")),
|
||||
"min_hp_pct": float(self._select_optional("baal_xp", "min_hp_pct", "40")),
|
||||
"hide_x": int(self._select_optional("baal_xp", "hide_x", "640")),
|
||||
"hide_y": int(self._select_optional("baal_xp", "hide_y", "360")),
|
||||
"join_timeout_s": float(self._select_optional("baal_xp", "join_timeout_s", "60")),
|
||||
}
|
||||
for _baal_xp_src in ("profile", "custom"):
|
||||
if "baal_xp" in self.configs[_baal_xp_src]["parser"]:
|
||||
_baal_xp_over = self.configs[_baal_xp_src]["parser"]["baal_xp"]
|
||||
for k in list(self.baal_xp.keys()):
|
||||
if k not in _baal_xp_over:
|
||||
continue
|
||||
raw = _baal_xp_over[k]
|
||||
if k == "enabled":
|
||||
self.baal_xp[k] = raw not in (None, "", "0", "false", "False")
|
||||
elif isinstance(self.baal_xp[k], str):
|
||||
self.baal_xp[k] = raw if raw is not None else ""
|
||||
else:
|
||||
self.baal_xp[k] = type(self.baal_xp[k])(raw)
|
||||
# Sorc base config
|
||||
sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"])
|
||||
if "sorceress" in self.configs["profile"]["parser"]:
|
||||
|
||||
@@ -5,7 +5,8 @@ from d2r_image.processing_data import Runeword
|
||||
try:
|
||||
from rapidfuzz.string_metric import levenshtein
|
||||
except ImportError:
|
||||
from rapidfuzz.distance import Levenshtein as levenshtein
|
||||
from rapidfuzz.distance import Levenshtein as _Levenshtein_mod
|
||||
levenshtein = _Levenshtein_mod.distance
|
||||
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
|
||||
from bnip.NTIPAliasStat import NTIPAliasStat as NTIP_STATS
|
||||
from logger import Logger
|
||||
|
||||
@@ -46,6 +46,11 @@ try:
|
||||
os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release
|
||||
shutil.which("tesseract"),
|
||||
r"C:\Program Files\Tesseract-OCR\tesseract.exe",
|
||||
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
|
||||
# Per-user installs (winget --scope user, or install.bat's direct NSIS
|
||||
# fallback) land here and are not on PATH -- needed on machines where
|
||||
# the user has no admin rights.
|
||||
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Tesseract-OCR", "tesseract.exe"),
|
||||
]
|
||||
_cmd = next((c for c in _candidates if c and os.path.isfile(c)), None)
|
||||
if _cmd:
|
||||
|
||||
@@ -169,8 +169,8 @@ class GameController:
|
||||
Logger.warning("Your D2R settings differ from the requiered ones. Please use Auto Settings to adjust them. The differences are:")
|
||||
Logger.warning(f"{diff}")
|
||||
set_d2r_always_on_top()
|
||||
if enforce_d2r_window(5, 98):
|
||||
find_and_set_window_position(force=True)
|
||||
enforce_d2r_window(5, 98)
|
||||
find_and_set_window_position(force=True)
|
||||
self.setup_screen()
|
||||
self.start_health_manager_thread()
|
||||
self.start_death_manager_thread()
|
||||
|
||||
@@ -97,6 +97,11 @@ class GameStats:
|
||||
self._last_status_report_run = 0
|
||||
self._last_failure_reason = None
|
||||
self._merc_resurrect_failed = False
|
||||
# Cross-game merc-resurrect circuit breaker. Qual-Kehk detection can fail 100% of
|
||||
# the time (stale name-tag template -> a degenerate constant 0.424 match), and the
|
||||
# per-game flag above does not stop that being retried every game at ~40s a hunt.
|
||||
self._merc_resurrect_fail_streak = 0
|
||||
self._merc_resurrect_skip_until = 0
|
||||
# Auto-downgrade tracking: timestamps of chickens+deaths
|
||||
self._downgrade_events: deque[float] = deque()
|
||||
os.makedirs("log/stats", exist_ok=True)
|
||||
@@ -271,6 +276,10 @@ class GameStats:
|
||||
self._game_counter += 1
|
||||
self._timer = time.time()
|
||||
self._merc_resurrect_failed = False
|
||||
# NOTE: _merc_resurrect_fail_streak / _merc_resurrect_skip_until are deliberately
|
||||
# NOT reset here. They are the cross-game circuit breaker — resetting the per-game
|
||||
# flag alone meant a permanently undetectable resurrect NPC was re-hunted (twice,
|
||||
# ~40s each) in every single game.
|
||||
# Clear the previous game's failure reason so this game's events can't be
|
||||
# attributed to a stale reason from an earlier failure.
|
||||
self._last_failure_reason = None
|
||||
|
||||
@@ -18,6 +18,9 @@ from ui_manager import ScreenObjects, is_visible
|
||||
|
||||
class HealthManager:
|
||||
_instance = None
|
||||
# Consecutive esc attempts allowed against a waypoint panel before it stops being
|
||||
# treated as benign. Reset as soon as a poll sees no panel at all.
|
||||
_MAX_WP_PANEL_ESCAPES = 6
|
||||
|
||||
def __init__(self):
|
||||
HealthManager._instance = self
|
||||
@@ -33,6 +36,8 @@ class HealthManager:
|
||||
self._callback = None
|
||||
self._last_chicken_screenshot = None
|
||||
self._count_panel_detects = 0
|
||||
self._count_wp_panel_detects = 0
|
||||
self._count_center_panel_detects = 0
|
||||
|
||||
def stop_monitor(self):
|
||||
self._do_monitor = False
|
||||
@@ -50,6 +55,7 @@ class HealthManager:
|
||||
# game don't cause an immediate chicken on the next game's first panel.
|
||||
with self._state_lock:
|
||||
self._count_panel_detects = 0
|
||||
self._count_wp_panel_detects = 0
|
||||
|
||||
def get_pause_state(self):
|
||||
with self._state_lock:
|
||||
@@ -155,7 +161,7 @@ class HealthManager:
|
||||
f"but HP is safe at {(health_percentage*100):.1f}%; not chickening"
|
||||
)
|
||||
|
||||
chicken_threshold = Config().char["chicken"]
|
||||
chicken_threshold = get_game_chicken_threshold()
|
||||
if loot_priority_active():
|
||||
chicken_threshold = max(chicken_threshold * 0.5, LOOT_PRIORITY_HP_FLOOR)
|
||||
if health_percentage <= chicken_threshold:
|
||||
@@ -196,8 +202,48 @@ class HealthManager:
|
||||
if belt.drink_potion("health", merc=True, stats=[merc_health]):
|
||||
self._last_merc_heal = time.time()
|
||||
|
||||
# Close any open panels that might block detection
|
||||
# Close any open panels that might block detection
|
||||
# A CENTRED panel (Chronicle) matches neither header ROI, so
|
||||
# nothing ever closed it — and a centred panel blanks every
|
||||
# later template search. Measured 2026-08-28: Chronicle open,
|
||||
# A5_RED_PORTAL unfindable, 66s approach failure. It is not a
|
||||
# threat, so escape it without counting toward a chicken,
|
||||
# bounded the same way as the waypoint panel.
|
||||
if not self.get_panel_check_paused() and is_visible(ScreenObjects.CenterPanel, img):
|
||||
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
|
||||
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
|
||||
Logger.debug(
|
||||
f"Centred panel open (blocks all template matching) — closing it "
|
||||
f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
|
||||
)
|
||||
from input_layer import keyboard as kb
|
||||
kb.send("esc")
|
||||
wait(0.1, 0.2)
|
||||
fn_end = time.perf_counter()
|
||||
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
|
||||
continue
|
||||
Logger.warning("Centred panel would not close — treating as a blocking panel")
|
||||
|
||||
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
|
||||
# A waypoint panel is self-inflicted: the pather walked the char
|
||||
# over the WP stone. That is a navigation problem, not a threat,
|
||||
# and chickening on it threw away healthy games. Escape it without
|
||||
# counting — but stay bounded, so a WP panel we genuinely cannot
|
||||
# close still falls through to the normal chicken path.
|
||||
if is_visible(ScreenObjects.WaypointLabel, img):
|
||||
self._count_wp_panel_detects += 1
|
||||
if self._count_wp_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
|
||||
Logger.debug(
|
||||
f"Waypoint panel open (walked onto the WP) — closing it "
|
||||
f"({self._count_wp_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
|
||||
)
|
||||
from input_layer import keyboard as kb
|
||||
kb.send("esc")
|
||||
wait(0.1, 0.2)
|
||||
fn_end = time.perf_counter()
|
||||
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
|
||||
continue
|
||||
Logger.warning("Waypoint panel would not close — treating as a blocking panel")
|
||||
self._count_panel_detects += 1
|
||||
if self._count_panel_detects >= 2:
|
||||
self._count_panel_detects = 0
|
||||
@@ -205,7 +251,15 @@ class HealthManager:
|
||||
self._do_chicken(img)
|
||||
continue
|
||||
Logger.debug("Found an open panel. Closing it.")
|
||||
common.close()
|
||||
# Send Escape directly — more reliable than common.close() which
|
||||
# only checks inventory_is_open() and may miss belt/panel states
|
||||
from input_layer import keyboard as kb
|
||||
kb.send("esc")
|
||||
wait(0.1, 0.2)
|
||||
else:
|
||||
# Screen is clear, so any waypoint panel we escaped did close.
|
||||
self._count_wp_panel_detects = 0
|
||||
self._count_center_panel_detects = 0
|
||||
|
||||
fn_end = time.perf_counter()
|
||||
# Target ~15 FPS polling with anti-cheat jitter
|
||||
@@ -244,6 +298,25 @@ def get_pause_state():
|
||||
return HealthManager._instance.get_pause_state()
|
||||
return True
|
||||
|
||||
_game_chicken_threshold = None
|
||||
|
||||
|
||||
def set_game_chicken_threshold(value):
|
||||
"""Per-game chicken threshold, rolled by the BOT thread at game start.
|
||||
|
||||
The health manager thread is a read-only monitor by design and must not do
|
||||
the rolling itself — see the threading rules in CLAUDE.md.
|
||||
"""
|
||||
global _game_chicken_threshold
|
||||
_game_chicken_threshold = value
|
||||
|
||||
|
||||
def get_game_chicken_threshold() -> float:
|
||||
if _game_chicken_threshold is not None:
|
||||
return _game_chicken_threshold
|
||||
return Config().char["chicken"]
|
||||
|
||||
|
||||
def set_pause_state(state: bool):
|
||||
"""Backwards-compatible wrapper that delegates to the singleton instance."""
|
||||
if HealthManager._instance is not None:
|
||||
|
||||
@@ -11,6 +11,8 @@ This is a drop-in replacement for the existing `import keyboard` and
|
||||
On non-Windows (Docker/Linux), uses bridge_input to talk to a Windows host
|
||||
via TCP. Set BOTTY_BRIDGE_HOST / BOTTY_BRIDGE_PORT env vars.
|
||||
"""
|
||||
import re as _re
|
||||
from logger import Logger
|
||||
import os as _os
|
||||
import threading as _threading
|
||||
|
||||
@@ -44,6 +46,63 @@ else:
|
||||
def get_pos(self): return get_cursor_pos()
|
||||
mouse = _BridgeMouse()
|
||||
|
||||
# ─── Stealth key classification ───────────────────────────────────────────────
|
||||
# Skill casts may carry human hesitation. Potion/belt keys MUST NOT: they are
|
||||
# driven by health_manager at low HP, so any added latency is a death risk.
|
||||
#
|
||||
# The previous gate was `vk in range(ord('1'), ord('0') + 1)`. ord('1')=49 and
|
||||
# ord('0')=48, so that range is EMPTY and never matched. Only the string
|
||||
# fallback fired — and it listed the digits, i.e. the potion keys. The gate was
|
||||
# therefore aimed at the exact inverse of its intent: every potion press got
|
||||
# 80-300ms of hesitation and a 1.5% chance of pressing a different potion first,
|
||||
# while actual skill casts (f1-f12) were never touched.
|
||||
_SKILL_KEY_RE = _re.compile(r"^f([1-9]|1[0-2])$")
|
||||
_key_class_cache = {}
|
||||
|
||||
|
||||
def _never_stealth_keys() -> set:
|
||||
"""Potion and belt keys — exempt from all stealth timing, by construction."""
|
||||
if "never" not in _key_class_cache:
|
||||
keys = {"1", "2", "3", "4"}
|
||||
try:
|
||||
from config import Config
|
||||
char = Config().char
|
||||
for name in ("potion1", "potion2", "potion3", "potion4", "show_belt"):
|
||||
val = char.get(name)
|
||||
if val:
|
||||
keys.add(str(val).strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
_key_class_cache["never"] = keys
|
||||
return _key_class_cache["never"]
|
||||
|
||||
|
||||
def _bound_skill_keys() -> list:
|
||||
"""Skill hotkeys actually bound by the active character."""
|
||||
if "skills" not in _key_class_cache:
|
||||
found = set()
|
||||
try:
|
||||
from config import Config
|
||||
cfg = Config()
|
||||
section = getattr(cfg, str(cfg.char.get("type", "")).lower(), None)
|
||||
if isinstance(section, dict):
|
||||
for val in section.values():
|
||||
k = str(val).strip().lower()
|
||||
if _SKILL_KEY_RE.match(k):
|
||||
found.add(k)
|
||||
except Exception:
|
||||
pass
|
||||
_key_class_cache["skills"] = sorted(found - _never_stealth_keys())
|
||||
return _key_class_cache["skills"]
|
||||
|
||||
|
||||
def _is_skill_key(key: str) -> bool:
|
||||
k = str(key or "").strip().lower()
|
||||
if k in _never_stealth_keys():
|
||||
return False
|
||||
return bool(_SKILL_KEY_RE.match(k))
|
||||
|
||||
|
||||
class _Keyboard:
|
||||
"""
|
||||
Drop-in replacement for the `keyboard` library.
|
||||
@@ -103,16 +162,22 @@ class _Keyboard:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Press a random skill key first (simulates miscast)
|
||||
# Press a different BOUND SKILL first (simulates a miscast). The old code
|
||||
# built its candidate list from range(ord('1'), ord('0')+1) — an empty
|
||||
# range — so random.choice() raised and the except branch always ran,
|
||||
# picking from ['1'..'5']: potion keys. A "miscast" that drinks a potion
|
||||
# is not a miscast, it is a bug with a costume on.
|
||||
import random
|
||||
candidates = [k for k in _bound_skill_keys() if k != str(key).strip().lower()]
|
||||
if not candidates:
|
||||
return False
|
||||
wrong_key = random.choice(candidates)
|
||||
|
||||
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))
|
||||
from utils.stealth import _tl
|
||||
_tl("skill_mistake", "ok", f"miscast {wrong_key} before {key}")
|
||||
except Exception:
|
||||
wrong_key = random.choice(['1', '2', '3', '4', '5'])
|
||||
pass
|
||||
|
||||
# Send the wrong key
|
||||
wrong_vk = _get_vk(wrong_key)
|
||||
@@ -128,9 +193,19 @@ class _Keyboard:
|
||||
"""
|
||||
Send a key press event with stealth timing.
|
||||
|
||||
An EMPTY key is a no-op, not an error. An unbound optional skill is a
|
||||
legitimate configuration — a FoHdin with no Holy Shield, no Vigor, no
|
||||
Cleansing — and there are 90+ `keyboard.send(self._skill_hotkeys[x])`
|
||||
call sites across the paladin classes alone, almost none of which check
|
||||
first. Raising here kills the whole bot thread for a skill the
|
||||
character was never meant to cast. Guard once, at the boundary.
|
||||
|
||||
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
|
||||
Supports do_release=False (hold key) and do_press=False (release-only).
|
||||
"""
|
||||
if key is None or str(key).strip() == "":
|
||||
Logger.debug("keyboard.send: empty key (unbound skill) — skipping")
|
||||
return
|
||||
self._stealth_before()
|
||||
|
||||
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
|
||||
@@ -173,8 +248,10 @@ class _Keyboard:
|
||||
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'):
|
||||
# Tier 2 stealth: hesitation + miscast, SKILL HOTKEYS ONLY.
|
||||
# Potion/belt keys are excluded by _is_skill_key — see the note above
|
||||
# _SKILL_KEY_RE for why the old digit-based gate did the opposite.
|
||||
if _is_skill_key(key):
|
||||
self._skill_rotation_hesitation()
|
||||
self._maybe_skill_mistake(key)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Global hotkey polling via GetAsyncKeyState.
|
||||
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
|
||||
No kernel driver - pure user-mode polling thread.
|
||||
Global hotkey via keyboard library's WH_KEYBOARD_LL hook.
|
||||
Intercepts all keystrokes regardless of which window has focus.
|
||||
Falls back to GetAsyncKeyState polling if the keyboard library
|
||||
fails to install its hook (e.g. antivirus interference).
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
@@ -9,24 +10,81 @@ import ctypes
|
||||
from ctypes import wintypes
|
||||
from .win_input import _get_vk, VK_MAP, user32
|
||||
|
||||
try:
|
||||
import keyboard as _keyboard
|
||||
_HAS_KEYBOARD = True
|
||||
except Exception:
|
||||
_HAS_KEYBOARD = False
|
||||
|
||||
|
||||
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._suppress = {} # vk -> bool
|
||||
self._lock = threading.Lock()
|
||||
self._suppressed = set() # vks currently being held down in suppress mode
|
||||
self._held = set() # vks seen down on the previous poll (edge-trigger)
|
||||
self._held = set()
|
||||
self._suppressed = set()
|
||||
self._poll_thread = None
|
||||
# Track which keys are registered with the keyboard library
|
||||
self._keyboard_callbacks = {} # vk -> keyboard callback wrapper
|
||||
|
||||
def _ensure_running(self):
|
||||
if not self._running:
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
|
||||
if _HAS_KEYBOARD:
|
||||
# Use keyboard library's global hook - works even when D2R has focus
|
||||
try:
|
||||
self._keyboard_hook = _keyboard.hook(self._keyboard_callback, suppress=False)
|
||||
return
|
||||
except Exception:
|
||||
pass # Fall through to polling
|
||||
|
||||
# Fallback: GetAsyncKeyState polling (only works when bot has focus)
|
||||
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._poll_thread.start()
|
||||
|
||||
def _keyboard_callback(self, event):
|
||||
"""Callback from keyboard library's global hook."""
|
||||
if event.event_type != _keyboard.KEY_DOWN:
|
||||
return
|
||||
|
||||
# Map keyboard event name to VK code
|
||||
vk = None
|
||||
try:
|
||||
# keyboard library uses names like 'f11', 'f12', etc.
|
||||
vk = _get_vk(event.name)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if vk is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if vk not in self._callbacks:
|
||||
return
|
||||
if vk in self._held or vk in self._suppressed:
|
||||
return
|
||||
self._held.add(vk)
|
||||
entries = list(self._callbacks[vk])
|
||||
|
||||
for key_str, cb in entries:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if self._suppress.get(vk, False):
|
||||
self._suppressed.add(vk)
|
||||
# Suppress: block the key from reaching the app
|
||||
event.suppress()
|
||||
break
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Poll GetAsyncKeyState for registered hotkeys."""
|
||||
"""Fallback: poll GetAsyncKeyState when global hook is unavailable."""
|
||||
while self._running:
|
||||
with self._lock:
|
||||
items = list(self._callbacks.items())
|
||||
@@ -36,7 +94,6 @@ class _HotkeyManager:
|
||||
self._held.discard(vk)
|
||||
continue
|
||||
if vk in self._held or vk in self._suppressed:
|
||||
# Still held since last poll - fire only on the down edge
|
||||
continue
|
||||
self._held.add(vk)
|
||||
for key_str, cb in entries:
|
||||
@@ -48,20 +105,18 @@ class _HotkeyManager:
|
||||
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)
|
||||
_wait(0.018, 0.024)
|
||||
|
||||
def add_hotkey(self, key: str, callback, suppress: bool = False):
|
||||
vk = _get_vk(key)
|
||||
@@ -115,15 +170,12 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(0.018, 0.024)
|
||||
else:
|
||||
# keyboard.wait() with no key blocks forever (keeps main thread alive
|
||||
# while the daemon hotkey/bot threads run) - match that semantic.
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
|
||||
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)."""
|
||||
Simplified polling version for dev tools."""
|
||||
def _poll_all():
|
||||
while self._running:
|
||||
for vk in range(1, 256):
|
||||
@@ -142,7 +194,6 @@ class _HotkeyManager:
|
||||
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()
|
||||
|
||||
@@ -151,6 +202,16 @@ class _HotkeyManager:
|
||||
from utils.misc import wait as _wait
|
||||
_wait(seconds, seconds)
|
||||
|
||||
def stop(self):
|
||||
"""Uninstall the hook and stop threads."""
|
||||
self._running = False
|
||||
if _HAS_KEYBOARD and hasattr(self, '_keyboard_hook'):
|
||||
try:
|
||||
_keyboard.unhook(self._keyboard_hook)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Singleton
|
||||
_hotkey_manager = _HotkeyManager()
|
||||
|
||||
@@ -173,3 +234,6 @@ def hook(callback, suppress: bool = False):
|
||||
|
||||
def pause(seconds: float = 0, suppress: bool = False):
|
||||
return _hotkey_manager.pause(seconds, suppress)
|
||||
|
||||
def stop_hotkeys():
|
||||
_hotkey_manager.stop()
|
||||