From 5d960cde66b13fb70d5f7b333cda5313a4bc33d5 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 7 Aug 2026 22:25:26 +0200 Subject: [PATCH] archive: botty_next test harness, legacy-go docs, and dev tools from my-botty --- botty_next/README.md | 93 ++ botty_next/__init__.py | 5 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 241 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 256 bytes botty_next/__pycache__/cli.cpython-310.pyc | Bin 0 -> 3913 bytes botty_next/__pycache__/cli.cpython-313.pyc | Bin 0 -> 6668 bytes botty_next/capture/__init__.py | 6 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 423 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 435 bytes .../__pycache__/mss_backend.cpython-310.pyc | Bin 0 -> 1299 bytes .../__pycache__/mss_backend.cpython-313.pyc | Bin 0 -> 1880 bytes .../__pycache__/window.cpython-310.pyc | Bin 0 -> 1676 bytes .../__pycache__/window.cpython-313.pyc | Bin 0 -> 2357 bytes botty_next/capture/mss_backend.py | 27 + botty_next/capture/window.py | 47 + botty_next/cli.py | 139 +++ botty_next/config/__init__.py | 5 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 314 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 330 bytes .../config/__pycache__/models.cpython-310.pyc | Bin 0 -> 2810 bytes .../config/__pycache__/models.cpython-313.pyc | Bin 0 -> 3637 bytes botty_next/config/default.yaml | 13 + botty_next/config/models.py | 59 + botty_next/debug/__init__.py | 1 + botty_next/input/__init__.py | 4 + botty_next/routines/__init__.py | 1 + botty_next/state/__init__.py | 1 + botty_next/tests/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 189 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 196 bytes .../test_capture.cpython-310-pytest-9.1.1.pyc | Bin 0 -> 1164 bytes .../test_capture.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 1705 bytes .../test_cli.cpython-310-pytest-9.1.1.pyc | Bin 0 -> 1578 bytes .../test_cli.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 3463 bytes .../test_config.cpython-310-pytest-9.1.1.pyc | Bin 0 -> 1794 bytes .../test_config.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 3648 bytes ...test_fixtures.cpython-310-pytest-9.1.1.pyc | Bin 0 -> 1235 bytes ...test_fixtures.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 2258 bytes .../test_ocr.cpython-310-pytest-9.1.1.pyc | Bin 0 -> 3878 bytes .../test_ocr.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 7207 bytes ...late_matching.cpython-310-pytest-9.1.1.pyc | Bin 0 -> 2382 bytes ...late_matching.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 4514 bytes botty_next/tests/test_capture.py | 12 + botty_next/tests/test_cli.py | 28 + botty_next/tests/test_config.py | 10 + botty_next/tests/test_fixtures.py | 13 + botty_next/tests/test_ocr.py | 42 + botty_next/tests/test_template_matching.py | 24 + botty_next/vision/__init__.py | 6 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 445 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 460 bytes .../__pycache__/fixtures.cpython-310.pyc | Bin 0 -> 1116 bytes .../__pycache__/fixtures.cpython-313.pyc | Bin 0 -> 1473 bytes .../vision/__pycache__/ocr.cpython-310.pyc | Bin 0 -> 2984 bytes .../vision/__pycache__/ocr.cpython-313.pyc | Bin 0 -> 4481 bytes .../template_matching.cpython-310.pyc | Bin 0 -> 2811 bytes .../template_matching.cpython-313.pyc | Bin 0 -> 4743 bytes botty_next/vision/fixtures.py | 26 + botty_next/vision/ocr.py | 98 ++ botty_next/vision/template_matching.py | 93 ++ legacy-go/ANTI_DETECTION.md | 258 ++++ legacy-go/GO_REWRITE_README.md | 33 + legacy-go/INDEX.md | 19 + tools/asset_extractor.py | 198 +++ tools/asset_manager.py | 1106 +++++++++++++++++ tools/build.py | 179 +++ tools/desktop_snap.py | 80 ++ tools/quest_debug.py | 224 ++++ tools/quest_screenshot_tool.py | 246 ++++ tools/run_asset_extractor.bat | 13 + tools/start_bot_detached.bat | 9 + tools/start_botty.ps1 | 11 + 72 files changed, 3130 insertions(+) create mode 100644 botty_next/README.md create mode 100644 botty_next/__init__.py create mode 100644 botty_next/__pycache__/__init__.cpython-310.pyc create mode 100644 botty_next/__pycache__/__init__.cpython-313.pyc create mode 100644 botty_next/__pycache__/cli.cpython-310.pyc create mode 100644 botty_next/__pycache__/cli.cpython-313.pyc create mode 100644 botty_next/capture/__init__.py create mode 100644 botty_next/capture/__pycache__/__init__.cpython-310.pyc create mode 100644 botty_next/capture/__pycache__/__init__.cpython-313.pyc create mode 100644 botty_next/capture/__pycache__/mss_backend.cpython-310.pyc create mode 100644 botty_next/capture/__pycache__/mss_backend.cpython-313.pyc create mode 100644 botty_next/capture/__pycache__/window.cpython-310.pyc create mode 100644 botty_next/capture/__pycache__/window.cpython-313.pyc create mode 100644 botty_next/capture/mss_backend.py create mode 100644 botty_next/capture/window.py create mode 100644 botty_next/cli.py create mode 100644 botty_next/config/__init__.py create mode 100644 botty_next/config/__pycache__/__init__.cpython-310.pyc create mode 100644 botty_next/config/__pycache__/__init__.cpython-313.pyc create mode 100644 botty_next/config/__pycache__/models.cpython-310.pyc create mode 100644 botty_next/config/__pycache__/models.cpython-313.pyc create mode 100644 botty_next/config/default.yaml create mode 100644 botty_next/config/models.py create mode 100644 botty_next/debug/__init__.py create mode 100644 botty_next/input/__init__.py create mode 100644 botty_next/routines/__init__.py create mode 100644 botty_next/state/__init__.py create mode 100644 botty_next/tests/__init__.py create mode 100644 botty_next/tests/__pycache__/__init__.cpython-310.pyc create mode 100644 botty_next/tests/__pycache__/__init__.cpython-313.pyc create mode 100644 botty_next/tests/__pycache__/test_capture.cpython-310-pytest-9.1.1.pyc create mode 100644 botty_next/tests/__pycache__/test_capture.cpython-313-pytest-9.0.3.pyc create mode 100644 botty_next/tests/__pycache__/test_cli.cpython-310-pytest-9.1.1.pyc create mode 100644 botty_next/tests/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc create mode 100644 botty_next/tests/__pycache__/test_config.cpython-310-pytest-9.1.1.pyc create mode 100644 botty_next/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc create mode 100644 botty_next/tests/__pycache__/test_fixtures.cpython-310-pytest-9.1.1.pyc create mode 100644 botty_next/tests/__pycache__/test_fixtures.cpython-313-pytest-9.0.3.pyc create mode 100644 botty_next/tests/__pycache__/test_ocr.cpython-310-pytest-9.1.1.pyc create mode 100644 botty_next/tests/__pycache__/test_ocr.cpython-313-pytest-9.0.3.pyc create mode 100644 botty_next/tests/__pycache__/test_template_matching.cpython-310-pytest-9.1.1.pyc create mode 100644 botty_next/tests/__pycache__/test_template_matching.cpython-313-pytest-9.0.3.pyc create mode 100644 botty_next/tests/test_capture.py create mode 100644 botty_next/tests/test_cli.py create mode 100644 botty_next/tests/test_config.py create mode 100644 botty_next/tests/test_fixtures.py create mode 100644 botty_next/tests/test_ocr.py create mode 100644 botty_next/tests/test_template_matching.py create mode 100644 botty_next/vision/__init__.py create mode 100644 botty_next/vision/__pycache__/__init__.cpython-310.pyc create mode 100644 botty_next/vision/__pycache__/__init__.cpython-313.pyc create mode 100644 botty_next/vision/__pycache__/fixtures.cpython-310.pyc create mode 100644 botty_next/vision/__pycache__/fixtures.cpython-313.pyc create mode 100644 botty_next/vision/__pycache__/ocr.cpython-310.pyc create mode 100644 botty_next/vision/__pycache__/ocr.cpython-313.pyc create mode 100644 botty_next/vision/__pycache__/template_matching.cpython-310.pyc create mode 100644 botty_next/vision/__pycache__/template_matching.cpython-313.pyc create mode 100644 botty_next/vision/fixtures.py create mode 100644 botty_next/vision/ocr.py create mode 100644 botty_next/vision/template_matching.py create mode 100644 legacy-go/ANTI_DETECTION.md create mode 100644 legacy-go/GO_REWRITE_README.md create mode 100644 legacy-go/INDEX.md create mode 100644 tools/asset_extractor.py create mode 100644 tools/asset_manager.py create mode 100644 tools/build.py create mode 100644 tools/desktop_snap.py create mode 100644 tools/quest_debug.py create mode 100644 tools/quest_screenshot_tool.py create mode 100644 tools/run_asset_extractor.bat create mode 100644 tools/start_bot_detached.bat create mode 100644 tools/start_botty.ps1 diff --git a/botty_next/README.md b/botty_next/README.md new file mode 100644 index 0000000..45ba478 --- /dev/null +++ b/botty_next/README.md @@ -0,0 +1,93 @@ +# botty_next — visual test harness + +Bootstrapped in commit `48d8445`. A standalone, importable package (`botty_next/`) that +exercises the vision primitives — screen capture, template matching, OCR — in isolation from +the live bot, so they can be validated against fixtures in CI without D2R running. + +It does **not** drive the game. Input is gated off by default and there is no live-input path yet +(see `InputConfig` below). Think of it as a test bench for the perception layer that the legacy +`src/` bot will eventually be ported onto. + +## Layout + +``` +botty_next/ + cli.py argparse entry point: config / detect / capture / ocr + capture/ + window.py WindowRegion + find_window_region() (win32gui enumerate) + mss_backend.py MssCaptureBackend.grab() -> BGR ndarray; save_frame() + vision/ + fixtures.py load_image / load_screenshot / load_template (cv2.imread) + template_matching.py match_template() -> MatchResult; save_match_debug() + ocr.py preprocess_for_ocr(), run_tesseract_ocr() -> OcrResult + config/ + models.py pydantic config models + load_config() + default.yaml default profile + tests/ pytest suite for each module + debug/ debug-image output dir +``` + +## CLI + +`python -m botty_next.cli ` (entry: `cli.main`). Every command prints a JSON result and +returns a process exit code. + +| Command | Args | Does | Exit code | +|---------|------|------|-----------| +| `config validate` | `-c/--config PATH` | Loads + validates a YAML profile, prints the resolved config | 0 | +| `detect template` | `--image --template [--threshold 0.85] [--debug-output]` | Runs `match_template`, optionally writes an annotated debug image | 0 if `passed`, else 1 | +| `capture` | `--output [--window-title]` | Grabs a frame (full monitor, or the matched window region) and saves it | 0 | +| `ocr` | `--image [--lang eng] [--psm 6] [--tesseract-cmd] [--debug-output]` | OCRs an image; writes the preprocessed debug image first if requested | 0 ok / 2 if pytesseract missing | + +## Core logic + +### Capture (`capture/`) +- `find_window_region(title_contains)` enumerates visible top-level windows via `win32gui`, + case-insensitively substring-matches the title, and returns the **largest** match as a + `WindowRegion(left, top, width, height, title)`. Raises if none found. +- `WindowRegion.as_mss_monitor()` adapts it to the dict `mss` expects. +- `MssCaptureBackend.grab(region)` grabs that region (or `monitors[1]` = primary monitor when + `region is None`) and converts the raw BGRA to **BGR** so it matches OpenCV's convention. +- `save_frame()` creates parent dirs and writes via `cv2.imwrite`, raising on failure. + +### Template matching (`vision/template_matching.py`) +- `match_template(image, template, threshold=0.85, method=TM_CCOEFF_NORMED)`: + - Validates non-empty inputs and that the template isn't larger than the image. + - Converts both to grayscale, runs `cv2.matchTemplate` + `cv2.minMaxLoc`. + - For `TM_SQDIFF*` methods the **min** location wins and `confidence = 1 - min_val`; for all + other methods the **max** location wins and `confidence = max_val`. This normalizes so + "higher confidence = better" regardless of method. + - Returns a frozen `MatchResult(confidence, bbox, passed, method, debug)` where + `passed = confidence >= threshold` and `debug` carries the raw min/max values and shapes. +- `save_match_debug()` draws the bbox green if passed, red if not, and writes it. + +### OCR (`vision/ocr.py`) +- `preprocess_for_ocr(image, scale=2.0)`: grayscale → 2× upscale (`INTER_CUBIC`) → + Gaussian blur → adaptive Gaussian threshold (block 31, C 7). This is the single source of + truth for OCR preprocessing — both the OCR run and the debug image use it. +- `run_tesseract_ocr(...)`: lazily imports `pytesseract` (raising a `RuntimeError` with install + guidance if absent), optionally sets `tesseract_cmd`, runs `image_to_string` for text and + `image_to_data` for per-word confidences, and returns `OcrResult(text, confidence, bbox, debug)`. + Confidence is the mean of word confidences (each normalized 0–1, negatives dropped). + +### Config (`config/models.py`) +Pydantic models with `extra="forbid"` (unknown keys are rejected). `load_config(path)` reads YAML +and validates it into `BottyNextConfig`: +- `CaptureConfig` — `backend` (`fixture`/`mss`/`dxcam`, default `fixture`), `monitor`, + `fps_limit` (1–240), optional `window_title`. +- `VisionConfig` — `template_threshold` (0–1), `debug_output_dir`. +- `InputConfig` — **safety gate**: `enabled=False`, `dry_run=True` by default. A model validator on + `BottyNextConfig` raises if `input.enabled` is true while `dry_run` is false — i.e. live input + is impossible until a future explicit safety gate is added. + +## Fixtures + +`fixtures/screenshots/sample_scene.ppm` and `fixtures/templates/sample_marker.ppm` are committed +(PPM so they diff/version cleanly) and let the test suite run with no external assets. +`fixtures/ocr_samples/.gitkeep` reserves the OCR sample dir. + +## Tests + +`botty_next/tests/` has one module per concern (`test_capture`, `test_cli`, `test_config`, +`test_fixtures`, `test_ocr`, `test_template_matching`). They run against the committed fixtures, +so the harness is CI-safe without a display or a running game. diff --git a/botty_next/__init__.py b/botty_next/__init__.py new file mode 100644 index 0000000..3a58fcd --- /dev/null +++ b/botty_next/__init__.py @@ -0,0 +1,5 @@ +"""Botty Next offline-first visual QA harness.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/botty_next/__pycache__/__init__.cpython-310.pyc b/botty_next/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e38cef7ca933f9fdbfd2454f854a3b9a2a330972 GIT binary patch literal 241 zcmd1j<>g`kf@SYYvND16V-N=!FakLaKwK;YBvKfn7*ZJ18KW3en4*|cn1dNKS*o<1 z@=Ho875q{wN)+f6LS;-9ThSXi}F&7i}h}C$H$kY78Pga z=f%fYu^Q+Z>KXWHGT&m4k59=@j*ka15_59m?gSNSy!x literal 0 HcmV?d00001 diff --git a/botty_next/__pycache__/cli.cpython-310.pyc b/botty_next/__pycache__/cli.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9494f3937e4ef63792739fdb296d181bba73578b GIT binary patch literal 3913 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 Zoqw`>uAKSV*`mECFZ68kMCf8O`7g@wEO-C_ literal 0 HcmV?d00001 diff --git a/botty_next/capture/__init__.py b/botty_next/capture/__init__.py new file mode 100644 index 0000000..773477c --- /dev/null +++ b/botty_next/capture/__init__.py @@ -0,0 +1,6 @@ +"""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"] diff --git a/botty_next/capture/__pycache__/__init__.cpython-310.pyc b/botty_next/capture/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83bb8bc699f57604fa6f126aa15e4ef2bfdbd360 GIT binary patch literal 423 zcmZvZO-sZu5QdYq`+;;pJqTVT*Mj!uMO4H+>p>Br6ozh-F3~hulJ4iv@gVq@diCVR zAK=NPU04qek_njxa)>KKUEOB{y!_Qzs0ih+#$~u1HF--v=_N z!ZfUUX%C|*V&NN3`>gkf;{N7poX_gH;dGK0uUs)pOKqqwODPnmrC9kxOLN7j6bnxE z#B#IXhE|&KVHVA1gBW}I%5S$QA@4=pDOz`v7{UC-Zy`g3oflzZ}vZDh1V`Wvkx)=hIT-k6Iw3ECYBZ3nN1G zLHjVgHfyLb%J?iC&i6`6_{Rz>!4M!7X%66z=6a=Nb{=Q^7@LM-jzH9=ao;wj^o6uP QNb8lHy){lB>bPp^KgMZ-5C8xG literal 0 HcmV?d00001 diff --git a/botty_next/capture/__pycache__/mss_backend.cpython-310.pyc b/botty_next/capture/__pycache__/mss_backend.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b44cb1ef3e67d4444d6cc3b48a41471ba95094b GIT binary patch literal 1299 zcmZWp&5qB9B66xghs2?vMsb1=tTo)A!;e4+nJrrBu-Bf+ z+Yi{tGeW5T1{6UAtw>E%N>OJOtG(27x>xa(gUze_)OX(??ZKBwN+zsSw;6qMnsQi|sMuy_qC3v}Lo zI82@)$OoVlB&C9+OoXB@0@1tiQ%_Jx64`e@*LspK%}N!|^TlZ)#ncAM#k{@0onh3D zrj0Cc5uD|1B$s0;a;5Ui1>4FTfF8m-!Q|h8V#u0o$WIThSWDHQrE9t&lDF)H3A#_N zyq2xK*4xl4{t7C{VO&G~WcY~a56+>gIKG9~@U`D^@bgWu?zJFK!VQB%u<2V0d6K^M zYOQUY>kPhG-N@23Dh=-xZTV}J&n=Tn+sm~>ZoS3X<1}2HnMqTDZ#;SV*~`Q1`HRC( z9)m~%%XLv5DLhmf>xC)`nJv0 zKT;6VWw3*0WtJ70s%bvnfwBlA83nR=hLqeW_!zA71C%M78j*9S#$d4;Za^zg&3J; zbgoKMjJmdAj}AsjPay~!bo;DdpNdkcU9hFSvUVG+2M4jk!<95;U3{ukqY_WO2QCUP zlMT8<|EF9w()n4Dx#H}Bo5r6=NOxhxlvDK~Op~AsgWYkl)IMsiV+bo5VxNvycpI}! z;kCjOn&YaRJKPSa8lc^ms~YGMqrU$St;Tn>8lStrgTivVzBvF}n31Lm`)tD}9W$Qd UEz($uAsMoeg*2o?7JG5@7e=c;(EtDd literal 0 HcmV?d00001 diff --git a/botty_next/capture/__pycache__/mss_backend.cpython-313.pyc b/botty_next/capture/__pycache__/mss_backend.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..498ab99ded3e58c2d66b0961a10bdf04a4c45650 GIT binary patch literal 1880 zcmZWq-ESL35a0W<9XoOAgr*_1jccNaYpH?Ilzs(?B&DJuPUWkKDqB5W>~ngpeP?#p zO%wVMULcAO)@;D4yn6BuIq7&JD$s6 zs9v-9tqcgAvyNNx7P9s{Xo|re0SC8{q}>$ohonHLWDqJF5>*V%2pI|Chlx@1WZ%^9xGx409ZASW_-jFmHx(aB+2j>Ghr;LJENG`@5zE)h5Bp(Sh z;HjV6)+p#xWL9mj)$(|6pR)0mwBM&l?w|$S+37m8HoH3e)s6M^@9(_#puE+0s?m4qm%h_aZf=cTZ;V~v$bHfnn_aoG-FsrI zcev3z{CHur_pO!5J&nYV{2d{Ode)X!m)3_iy9Za)o$j8s`>XfYC%=1rD|M!kI0oZLDo*RH!Z_5l!iOuSD9 z)Nh=;+qSo*oIFHyHFI5;MYv{B+vQ9|?0XL5rgvxQXxVZqb}7ld->dW7cIt5B8Q zD8IU2z3FE4rWgEw5sE+v;D@*LaT&l3aVa8~0OKT)I5SG;Hp7y-#IKkOG2tziP35f&Qn=xi#S(ZTX!S*IFnb5RbnsM*;!;v)W z2R)bA;Mv3ef+S#H0{%<7=9Ei*z>-6%)MI;`K-6@VN{`Z~s;^XbI!yxY_rJ^w&nM&$ zG!`2N<1ryrZ$U>8K{Jxm0j1by8OyzaM~U==C-@usj0j%@=R^c&?2Hb$=!m9ho%4Y& z+PkFR{S!U+scpqYQJUDKWud`b60wPsEY^AttYD=BgX*4dTC)S&*aEh&y$ z#|JQ`jk3N;O(t!iq?xGVZC9j;`2i-r=)vY66AHL)8ulTa^gHAEW^+xp>&?Z!IFHT-3fno@Pak;W4^;}pN(@6(SKBe8ye{dS+J4lH*Y z`@RjMsEBhJMYb7%Pcg}`-;Sc6CUG{OS&pKG1V@p27aiag7Q`x=7@`1vjj);|WFf7; zE^~9X>p~tSWntp9(5i*I5YN46GZ;Xk0!66)7CHjRk&)MIOkNYwz91L$f{i>8h{kz) zN@whnR23S>kWe(b+#3z)AEC(LMVjm5Lmun zmRW*l-!x1g<`M2o^)YndB*^*|)eS*+(SrP6(J7nJOLj#nhVm6vYx7wTXNP?1P5IQH z29;lV56HAp1r-M!Rt>>kap@i#VhWbnD15h@IV7-b}8;m>BT3@*m z36O?me#hZ!&o=VdBnMKf2e>~3RzCWAJV|RBAOmb8DYFa?{1^;ZC{5sGldXVUb!=Fn zYtTP^I08mT-~dWCQm$s#H?uN{GrhGw_i_4pQTATdx9rtRd!uqvh~At(X)*3OLT&W! z@dwk3dECXk;q7j!^*tEsQ>=m8%ET9{KL1~2w&7+L=ldew`U>xY{@Ud9v;V_=vjKd1 zFXc&}yExxbOnnGWVDwW^gVxhxlGl0Dw!JecOq$E5N|nlbd3+2QG05s63Ri1A?71MX z{ujjiFzKo;q@1m?_A;kky24Z+#<$@}=j*oGY8nJs^P>qqFi_NZLEE~0x*^H3$QBbew?83i``}B1A&pI+dhX2bg4CGwB%C?-{M`H9 zv*(=4PBexr*T!I~1Ya}D?-q#%VEL01{f$W&%W*sC1oWR&6Bkisc^ z9}`0$2`xwwds!NZP_Oqvm{$UduLa%Zu`uP22cJ`YoFS5CnwFzEhGp8Y4CFLN%jPxP zPQgl?#MXV?%vSesKb*H@lA;$afHVVF5J0Y;YuL1FjSEu3s)8$)vbswcH`7PHBj$wU>-iEX^{d z9OQhw+Op69Tna_dIkvZ2kUBF?LAjJ-6&{*Y=wqR5e507wUv_U120#J#XLdRudH|57 zq~zYh_O^k7uI*mO0A5oJc)t#J-P>eS!DJn2bu;+XhDDlvll2muq1Dc*-)VQ3T~tMA zp_!hU>tjj+`oi>$<=n47?Niu`pTjO~2&Mg1`6xoEK$6~kAC1#?a%rq*Y%b0AR*~24 zF2jxE+-S;8V_YQBVcTnr>xOMiR$M$p z_~}_QM?@6@u!f^>{BCO#0lQ;Iq}^&2b~~N;3YwFh)@>4Thmo4KOh+?JoA{xN&%HZU zG;BH+I9iW_5)ylmry0z zycz8(N4r+}&1j+=O+cG(HN-X>I?D~6E6zs4nMI)@9odwQm!;zy(uvikC#`F(Umx3$ z&Qs6!)0^#m<@Ua(9UJZMmHqM}|FE~x(6khSr~}PQM;FD4)bQ|~Px>AoUODkaOC{9! z+317OFZ);dZ+gE!xzRfGEHqq+Ho8rB|CDLTA9*>Ky&K4rR?*DK)ifC9lML%3(!LhjIG_DfxcW`>bz;|rP$w}*9sFkZGd#VDaSRhvoy(ca+lER z`6vBr{ZBi;OMIL7@${g3SvZ5cr_aZE+zCFImMx9}Bm9JAzaZl`Hrg0fST(I}ffJa^>Fmp$AE|BDu101rAT?Q6jPDWRYE#;vxF z(%?%zEF5})fNj5pgzzRGEAz3(%_|+x_^w~2gFi=F){hPT5Lq9*vEDki5xKeUzxlfq zS)45&9V|;j>->;=x6v9d`iQu<^14E#Q-88ZUnQ8-bixp+{{Mn*V~X3G*c9KsQBM5* z-h(lKFM*X#Gn?jkn`0Q}7u5C#I`Kk8tn{#>%rw7b!wfu2z;-yq# 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 diff --git a/botty_next/capture/window.py b/botty_next/capture/window.py new file mode 100644 index 0000000..ddfa084 --- /dev/null +++ b/botty_next/capture/window.py @@ -0,0 +1,47 @@ +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) diff --git a/botty_next/cli.py b/botty_next/cli.py new file mode 100644 index 0000000..450842f --- /dev/null +++ b/botty_next/cli.py @@ -0,0 +1,139 @@ +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()) diff --git a/botty_next/config/__init__.py b/botty_next/config/__init__.py new file mode 100644 index 0000000..4311695 --- /dev/null +++ b/botty_next/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration loading and validation.""" + +from botty_next.config.models import BottyNextConfig, load_config + +__all__ = ["BottyNextConfig", "load_config"] diff --git a/botty_next/config/__pycache__/__init__.cpython-310.pyc b/botty_next/config/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0aa4ec3562fca48817ed9822ca926a440f88518e GIT binary patch literal 314 zcmYj~u};G<5QZHmEeNP!WX!^lflN#YAw>5sh{dvWGCdO+IW}@!)bKbANW4;3CKeum zspn)UC*6O#)Bk_x7mFiC_4ylLK4^ZY<$rNn?x?$x1esu%A-7q}1#feaXAE;J8ZHW) zJ+Ny2ezElK*)*G=qVcY@UZHVKsT`JX${HNc^$)>V#fQ^-A7j7L+c?FB;}n4P$O!3o z$Vpb!Y&ZZwzXlNJPbmYALiJ>Hk3k$5V1hC}D*zc&8G;##7=jstnY09dM`nyYBJs8 zcgim*sq{;&D1m6Z#SPLBpA0smh#9D;hy_UaX|mp8kB?8uPmYhjC6NTu6Q2jvqz6%` zmz$rGnp2EW4^o_%lM^4mlHoJRrduY?RxzQ)sYS&xi8-kiF}anxU_)XMM#VskiiwZU z%*!l^kJl@xyv1PyG`KV;)vkyeXb2+^7mEUk56p~=j5isypEEc<;Fg(@dx1-*k-dl$ GC<6duyI-;Z literal 0 HcmV?d00001 diff --git a/botty_next/config/__pycache__/models.cpython-310.pyc b/botty_next/config/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b52906de467110fac415de37b476224ca210960c GIT binary patch literal 2810 zcmb7G-ESL35Wl?-pM5^Zj$4%iZ8<)JOViR;<)f6gN}vb{ECjTOBWQKLT|3v_hr73K z;wXI~eXWrA3)&Z?{7d`FQ-p;2+!vVHbBa|G1ZR0XGkf0Mncr__w`jFI1D;?1khd?n zhVdI3^N$V2b;BU#FQ71kk<`dYNC@g?YGzhwY28Zg%n2Q>+i5MV!MKyUSv{<0jj)k< zp_etoCNVxUSdF<44CeCsjurY~*I5JXhPGQ^d#nj|Q`-w*`>X|aOWSRBge|h}13T<6 z`x~QobPspiBdQtYc_Aa26nP1z{Z%B_;n)2lkz7P+zh|q)N>uVM3&vCBtrqz(8GVw( zQaLvgo-)-Q;t#zSr3s56mEos8KHU8;1(myd`Lo@S)s#hUSrM` z2r_gb#2Tw#F*pPWK|@#%t+1iJKD^NiJ+=UEv{8ppI;yoAjpan}YICYKEW{vTyF@vB zQ;JBr!(->{`lYd>Bnqsmu{qX3AA#I4)_LWQpu=d8HJ=cF-?!uKbN8XT=7UiA;H~sp^#G zQN}4%9;I2qCMoJZrFSP$T8)SruI$5FfpV%HF_ zppN3{h1JVzH%l(cwJ7DAYuR*pP)Ipl(~{r%>Pw;tdqfQeJ5R`d$Tb1iob*glBl< zn0b_F@8L=O&qaa-<3ie?Q$P=ZGJ%4DBH1C_V;$CF3lHoa0ts%iHY9dYH9yZG=I8Tz zL)n9(NR`X;XpnODE$~`QshH%UH9UVoSwLjfQ+mZpN=og{)Yy0Ol4z@*fB z!*rH7xovHoo{Q$}zyA$dShd<)@K02C7z&wi_P0d@2=pK-zE zzV@`>M5r-noP2@#z>YTSvNr3?*wJB&fXxxrUct7|hko!pLzI&iag>T4Y)tuMQ4AAk zSlFlFm}t+MARR_AG{~xVC^xS9v8KAJ-P0AV<5o_BEUfTADE>hl#8(ps3vZj;HiQq7 zFfsv6n#|lGKM_qRtAAr_B~9+}K+hS(x-$j?9?ZBLY$SlwLq?LCIS^cRHg%Hc&n1eUK@nyXf%_gO!15WqngQm>rJ_`p^I?D4wr4y>0#!Ult+#kMk1yhzn#a0tVPhAUP+%#E_fY7`-bW1y zF5X4)A&P^DeI5odS!EYQMeHt6y1P$p99Q-jt`JXWCm>!Y^j>4^2>TB<1zb)8-3LV) z+r|#L55OYS%w*O>^XK+`Q<{&gZSyf1cN62j^&Q-vZ20WJ-6^l1H_DS6aHGP{_wbeW zBWK%sjJ?M0Di5xC}04%LT>nd21k9xJL=i9}Y=gOW&SsFHw3lt6yCUE=VLUroS z?lT;k$7OIsVGg5vQ6R+L+f8&#*e2o*i=HPtOz(Gi*># zDn0_eLK2sTCJi@&imGt5V1MY&93;h;|+R2plI wrUqPNtzpJR^!jsq)-VS!pz2qu%g9wks>DOpH?VQl&EuqFbs8PF(^}~K1+lh~c>n+a literal 0 HcmV?d00001 diff --git a/botty_next/config/__pycache__/models.cpython-313.pyc b/botty_next/config/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c59091613a03a655787bc742a26074e353239e7 GIT binary patch literal 3637 zcmb7H&2JmW6`$qq^7})QCD)QIYc1MP>Bir78&{SCNwE`4D$#l)2d!x=mfRt=&T^NT zUD~GGgKiaiv4FyW0|aPJh1`>mz4kwl$~Kk;4w@kC!6;A|po#!J^}ShAp$)@nN8p>8 zH?y;|@BM!7J#{jfB!Tw(e+9QM#0dEsC%vYI1F7=|7(668(WOPQATde9abz*FATwEv zJerg6Q(8reS zfHKo578IVCGky9K&!P@b&UDZPTc&rlMIF!+_lR8xx zJR~K;B%QE`E;0ESDJk%w5naA0F;!QAUxuahEbPmP*#Jw|GKE2&nKv6jlhN?9&(V84TJ>1jwjN7drS}46@>tcrhnwzC zc0cvzR97?gmNr%QeeA4z6|+8di^s}lYi)E_db6Cs>d>qS+r_4 zoq$Jblt&!O6V--qICk9*iuaR#z_ed!MbD)?eb;s^@2(NpfkPjYLWai;!!_&FFnH21 z>YmkfaGW-bkDI2`UC9|n)n5oMA-|1R!K<11rwIS zmzKm0{F99qOTmLepuzpiAbw4D_Kkn`{?@(|t4m*=onC!!Gjpn~OmFWSUVU#(Z!5z) zBV(VP-8k|@-WoZ-dTq^bE9bW-@~cbh$3Iu{>}c=6*JJ7iEWZg;r6}M=(N#UF#{f4m zz)idlQOOJFOlW{8ppU+Xs9Ls=;Ct?wcsg>mYcOxUNdMk1E%1E143nlD#Nmwa7nR{ zX5+BT4x^Yrp`kzsup=mhdynI25(S>cet;s60^J%K^B9h%dIG=7KLeuQPhV-rHmoQ9 zAN?(DepA{1NNp?gJL5+-C#T!vmo}B*b?bBGQg|93^pb1vz0jo29guHw&1>k6?nR(5~kM=Jz~zl`Cb> zb9jupX4#?E2S8iaGFa2)a`ntvE(1^b5Uo>Iqi&@IB=H)?N3>Omgl`iPVI$*LVsv{* zmZhT*NQ=VCk0V^JI6hY)95$(sPiY!MtMkL%7a5Fx+_V|BjJqq;1t0t3Cj5Bctxvvr zFS^`MfLJ9jqGVuXEgkv;{R{z{y$wA(1%gKzg&60GNg?|kAUchY3H%@?*m)HA7M`j& zrtjBju;N+lBC1~ps}s0@0!{8Ofar(Sm&2pL`48I4==Sh|aCBh%a2^pRx0U=?>hSu{ z+v>!&I?#zo>PSZ>(LoeiM}cYheR%lm@%AaK{O@=x#U~-DKv0mn$s|lOs7(oNx(|P- z%?NE)PXj750+l&HWo8eR1NvZ?TYG5i^=wejv7y>PVTkAEFn|^za(0o4=X@JexaxQn z(_!a8%hL_!Rc*)u82lj{=eoJYsG1c>Ibo>fu}YZc1fhBKjtE%716;L{0xzK!5F4Vl zBy6=TU5EbF_WiEH1RuhVe$;nYbZLp05b1ArD<9x%q-O^z2`Adkx2~*n#GoRT{Lk8U)RP%mgA4j68V*33Y`rkFg(u z;w5UG5brVSO+PTo)aV@sdC)R?Oe(jGUV@%^#SiZCzI>^$lOg5K?& z!>-^63Ck{_cppU`1;&NG_>Y;Hp}^<;GKiOmdSC+bZ@#Te?2J!7nb{hj1-9O5E3-QX zwI_$R4qjfpy0+9-F7J#U-uUI#==AE9wQ0!dNZ=3K>bM}V+(+QU9R;T0>+z)(44=J* z5fp$ab^Zc_hveqo+A`^Z$^}qehdr-0s0gukj6{eVx!FxTgk0}doc+5e$?_gVXQUp) zEdaMhP7_y#LLB}sbiuBXn`mayuA{AAX&=hHn;j)b$XjIZJh*!`qLB$WRU^gxj66ZA zQb8$liQQM5LG{Gjz~nfj70UEPg8}hRAD3E zPMzFTPJW|;_OFT4|MAgOpK7zC@}EYNvr{ohLq7veukyuzE0};hVgPD%sB}Z_L)nSm z 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) diff --git a/botty_next/debug/__init__.py b/botty_next/debug/__init__.py new file mode 100644 index 0000000..71deadb --- /dev/null +++ b/botty_next/debug/__init__.py @@ -0,0 +1 @@ +"""Debug image and report output helpers.""" diff --git a/botty_next/input/__init__.py b/botty_next/input/__init__.py new file mode 100644 index 0000000..03c9b60 --- /dev/null +++ b/botty_next/input/__init__.py @@ -0,0 +1,4 @@ +"""Input abstraction layer. + +Live input is intentionally not implemented in the bootstrap harness. +""" diff --git a/botty_next/routines/__init__.py b/botty_next/routines/__init__.py new file mode 100644 index 0000000..90997dc --- /dev/null +++ b/botty_next/routines/__init__.py @@ -0,0 +1 @@ +"""Offline/private routine replay harness.""" diff --git a/botty_next/state/__init__.py b/botty_next/state/__init__.py new file mode 100644 index 0000000..530eb30 --- /dev/null +++ b/botty_next/state/__init__.py @@ -0,0 +1 @@ +"""State detection and transition logic.""" diff --git a/botty_next/tests/__init__.py b/botty_next/tests/__init__.py new file mode 100644 index 0000000..ca21b65 --- /dev/null +++ b/botty_next/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Botty Next harness.""" diff --git a/botty_next/tests/__pycache__/__init__.cpython-310.pyc b/botty_next/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8e63a8dd76aed69dd7cd03fb365ff6bbafc602e GIT binary patch literal 189 zcmd1j<>g`kf@SYYvNVD8V-N=!FakLaKwQiLBvKfn7*ZI688n%y6hl&rONtfJ@{1Ho zGEx)2$EV~c$H%W^C}INY2NS=HovmU*i&Kk= zV-j;xD`Ijhb(284W58s59?*=K5|H6B@$s2?nI-Y@dIgoYIBatBQ%ZAE?LZDH1{ub| F001!hGO+*v literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/__init__.cpython-313.pyc b/botty_next/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fdaed600e4ea5440b37e65408d7e46a728c6c68e GIT binary patch literal 196 zcmey&%ge<81k2u)WN8BF#~=<2FhUuhIe?6*48aUV4C#!TOjU{@sl_G53TgR83MCn- z3QqYYC6x+(sTCy(8Hq)Csl~;5ewvK8*yH0<@{{A^S2BDCnRCn7*(xTqIJKxaCNU?q zA||&|HwmOM229510ky}JfXt1FkI&4@EQycTE2zB1VUwGmQks)$SHuQ11LUA$kYheD OGcq#XVo)z)0dfGP3^tDd literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_capture.cpython-310-pytest-9.1.1.pyc b/botty_next/tests/__pycache__/test_capture.cpython-310-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7ef679a75ad8fd2889d045eda42b78beb147855 GIT binary patch literal 1164 zcmYjQ%Wl*#6tx{EnVC*Uyo6fVbORztj9Ms^B8rNtZjh+NOGwd3QCfRCfxKjUm`+I~ zMA`BOESL=v|G<}cg~X~GKA>g6buxuXbdT@D_PzJm&gSP^2)=K>)thG+pf6{vNbDr8?}F|y%Ir_nZ!r7=YnEn3q<|^P>_)kW|V$HH~>+X-j3fwnq_D1MLiWv_DYVR*`}!R5-9$7*0gPKoh{jCuC#8!-r3=-N?tQ zo@~9JDoYznM!S9H0%hu2_GCAdK_Wr2G*X#pl{dUrxwKI)S@Wbh5pyyg)mA=UHgMa( zst>Ss*a@ZNLRAUYJ!zclUQ0k*$cNbM*1ENsj}@0H2!%c$?1gb02%d|*EXhN`bsNIL z?l6i~ltS7#gBemU1agoKV^&QK_Ty|fjDs{xxNZhA>~ph&?mVulfHJp*$b`Na^fQr! zD&Pls9HyZ%4Y;BWRp~jCrmlL$pE2&t8uw<6yR}hodEc+J31ThU#E1OWy&hGEmtp zffN$SCjb|4n-E<8m;MmDb4qM_N^J*5Qt4)-;Hz!po0WyBy>Ke7!gd}J=0WdxP2D)- zUt9o>UJU9JgG*vg8EWeaJ`#}v_W!xGJN1cku*P5f-*We?&$_+pR#X`)&Sv0>N!ECn SWNaAoNAMhwG|gT|9qTXpzD{5O literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_capture.cpython-313-pytest-9.0.3.pyc b/botty_next/tests/__pycache__/test_capture.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9541d7f56cc7182f98b37fba6eb2a4166da55da GIT binary patch literal 1705 zcma)6-)kI29G}@=_hav3FKyN8%W}0eE96Myr7_hLrB)=xh_@UHIaoHi&1K8p?lLnm zxk@1qKKKt5eDq21%}4(P-?WGr2El?)dC}Cf7JTwMvpcy96m?*~zvlD(G2i*_OzQP2 zg7xKZ?9K-mpjp6$v7_C#)=Q)Wvf=43pgRUB z53y?=ix3+w=Cu!_WRUK6!(o&r+eKi>%0PAaCg&jEeY6Qxg`Q}qKBbnCF8A~s?*iiOg<^m39YULia_(A6n(`*{$~~ivy~;0fQa4M8r^WTcpXWzBa48GOJvlRaSB( z5BX|dXRcgZq9uswi}O=Sp#d4&BVj8W z^Ot2?_T_^D2Nf@hznvx@LuaYa(%CMOmms0|PpAcBbBKPfU;3PULB2l!jrVQkLA`tU z&O^KLlkGgPofBl5*7vsagWZ(k(Yg7fXBtP13rF)8A63k&mb3!7YCS?`&3Yp66G48j z9-}{P>D`ppKa}-}us&AWiO@c~E+KzC&3Ni3kx+|-%Df^+Va;=#waT(2dP<otk*5u#t<>XL@Zd zbyxWn$?=vK@f9Lh081_lhw|&Abg&nPZ;(Y$#cN2#GjXD0jDJDsQ}iQRer%PoC4K$@ D&+3Fy literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_cli.cpython-310-pytest-9.1.1.pyc b/botty_next/tests/__pycache__/test_cli.cpython-310-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13d50c5bc253ab9fdcd7c3e36e19945cd5eed9b6 GIT binary patch literal 1578 zcmZ`(&5zqe6!+MU?Rc|kX`!Vc!78GKP?FtcDWw%#6)LWKsklUutXa=w*IR#SX0jx~ zzVLD7FX&;z0V)5|Tsd*!!mSs0Z@inP2Q2xQH}CT|@4dL#-VQBz{`gy+g^p$YiHFSx zbkL>OR7%2CmwY1JH`7zkNiY$rv3?v_=oPZr=XGxg%(!OQYE#|YJ zY9$0@PX%CYSKbop{si}Rt=g>})~@}^J`3xh@~hz70v3+^6+9glLRG@HP>(V9_yh0G`_~9jwf{>{{g+7VS$G?F|+_FmdXDSG+|Wng*}KD9~>7-&7+*p)NbRj%lL#&Q#G8^EEU-D6?tVJ9<;)3y&tsO8U9K7mMDY;O_?E$!f^1Uceo#as9K4#sA0!e-q8UBbmNU^i7IV`3staT9664rvVl{13$`(hioifaIGRDal1 zf4JHbHrRA1IAxH53!%fO*8A}aJ-5l~4Z!AlVrSxE)g9WY17( zU>*C>I^;^V4#=o@!G(#~f{0Vdds?Jy;qDC|Ju=%a@T(%)2xcH#D0LBVwHDo8iGyLV zy-G~ExQ><^2skY}TboL|b&m>O2(;{k_7S&4aBuzq_Kq62si}T1VKWavRco)@Au}t)qCUp z!ND|w=bPWchiL_&pBN$@xdZGk0PqOuNSE#)BJo{bR027V7F9BkXB_#CwwNFZfCqHt zJbI{-BtR;MQfpFC`-K_1Ta>6;Gi|pjb!@XO0YC7K_LpFNgqCC8ICy+dn(5%KR6$JQ z;4{-9xUw#LlNDv|7DC?JS2|SdYlN0np|hk3sJj=Cu2j@%NmuWkoR()gviiVsQphav z90@Gi3rP5V9SGKiJ}=pRS%wu*B|eFW%{^Wv2{Ywx;wvQ2Tkwzp1A@vUI8|DT z;n`1D6v%$&UZb1+p4bjQXF|lJXaDm$$-=fc#gXb1YeAi|{}_K#_|ON-N>mwrNFT0f zojRrdMV(T@me`{@xe2J%jWtxv-7TtAt9tILy+)HCnvQLmA#TZ~YU}0uUKlnF7dOK3 zNMPK;t7hE^%T2T95Y__P(hGThzAd#*T)yHrCyGISem>8@sbWA=xM7Dz)w6J0rV6mx zGWBA}3<69-E;a*VK%8lvE%?M+wH<7@W(^nS@&(7MnoglDQQ3CE!5FhT2@Y&mXr@1A zG%%5meU`m?nEr$&tEL~;3AWk_Ge1cNAyzUBOP}}ak@xHE3}L;4rU^DJuz(4n={Nmm zh=b5D2^}#Y6vrUgCsnWJn*`G=u#Nk5+X-zqpc)T0py!O>fme4dE^4ef-hI>I2cjt> zFjq0NK!@Jq=eIrg9YQ=pPa3NpshOdH8@^+@W(XXrKp5>bRL1m!W51qO>w^D&h1uLe58!NSDiGL6)-Opl$B`Dyl zQFZLH-=r6L&Z5u75T6rc8bN51Fd!vx!WvcZGb|hE``oFW)B2O#`y01^96r4_oZlYK zKN&7;+1WX4_fV z9%r{R(|c;?-BG8vGM6IsX%8F=0P%4$#saM96@OY-N zoUsSy?eR>DQo(X@gqfD4nuSAL4QVpOHE63a(-NhUT{G8k>+-7IVACNO52^%X7kYLG zunBV52&&k{W#6y0(xp zKAvF^ySng+)EX-Ite;yj^g>d{B*!@H*2Nn>VRtD4mqA3%vgI6GUV-I6yCzqGMqlOa zH#$1+x}@#~hKSjS7#<7cjkZU#TT~Jv9H#@ zT!(FQeLFL@r*_^Qb!;nhAwr+_z_9=jHybgw-Nkm9shAN1AWzD0+@ef1g5d*j?-tWE zDTX+C33znWh{k?78nG~n%LIQ!>=4sbOrnVnjhtsXL*0p3hDpco5`50_G`A7=8r3KH qM$NP84qhO0pvnHc1gxsh6-knQKS literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_config.cpython-310-pytest-9.1.1.pyc b/botty_next/tests/__pycache__/test_config.cpython-310-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0875307c0581422a198b60dbe3d12860fc08bf9 GIT binary patch literal 1794 zcmcJQKabls6u?PImSp?Rxl4c|S&AE)v>M|4sV`}YUXkM34n=WG(&b=4piMfFL;Wd9 zC+DlQw4JgvAK->~36QUlrQd@wo9)j}h zZ#DlKBlM@~w2KR!+fd~Q8j2|9=n&hlle!MVDD@6I*k~N?r@>(WvQItQ`PDlNDZYcE z;4fH-4kE1kJSQ}s=GiQ{r+vZHLS$2;T$e5g0n~k{vTzVm;P4k*p%Tq;g{f0HB`%!@ zPUV)a+*i(1mx46I)HCBVX9bRx_q4M@50U%`^nQs}SVSNNM#?bgjf_{=ew`Jj{)zhm zsyrIdu=ElPaz}-r?UtPtc>fu;>!Aumb-_QH_bOcCdB1XLxAIH3^noWR1Nj@QF+2hA z^vw7hJRI~j&yUvk+j!iD%LlIhiDy{o2Hh+>mMd(y!Z*2qEdUlY->__(z&5Da7N7yP zKP=lB%eM6vwimD1et*ww17O>3vpvCb*rGf8Z#tOKU-Lb|V(a}7QUsUv+`;jOlU%7~ zoUtQyrM|0ID4UT5SHmSqx$f|MN_d1{I(vH&J|12Cq*z{$Wbda&*xB1L($z=~3z5$f z&f<)utXJ*q)tcQ%YQMG|x!Q%PrnzpK-Qxj~k_lx^+=PiRcvAyal+x`_}P<7p(!WAGZot(xK1w~)V1B7xw$|3{Ey z#X^C9mXQf(#(yH$ySw}D=BQZ4Vv)UOE;=LN%|LpieeSw@q9wK4WsVHlFXQ~pa);sSS5M(jS#ue z=i*r|QlesZRB)0J1sWYRA<#h+0o}1jqJ!o{bkOW2>I&cB_YF~f4X(92qju-hx>Gy6 z(e8ZKp4Mn;YrFMk>owP|K1bpr_n~d3G76{XoLh3CoTvn;=+kHyED6X(}DsLLZYk9%X(_ zvl*XFPG%82U;g6Xdq+U%Co1qqWZ;4#vXF5E&`2zqf#^29val5rwWE#j@@Rtl#8 z%er(K-IHqgCW8qI?miyL$)XeIbN`vTBovXqqtu}x>S`)f-c{gDT!s~t0#8T+-D~! z3YWru36+`R4e0AHkIgbM?n|X5$eOQ!=Vrb%Hfrk6N4+mVPVq{f?j2R>FFi$`jh@t` zD^M3l)>C@ADlZFxl-SMjd`4o;a9!NP$`U*l?=9FC6~PfILVE?+dNKCq$j$A*>YscZ zNzb^oYBJ#PKCe^#o3T1Qi^CLfn7y<5KjKj0^&Vm9>H8wdJ#|HsLw@xMeX^QhY^N{4 zcACfHeayBa0plM7<8x0k3uiL7bNRd$7@wky-$w4m_@1s(zF&CG_xYN_iTrQy{mS{= zfbZNt;Ctpd-{))kZ}WYC)Y-yM|*O`d7pM4499F)*$4SbXep~6NrsVCiAdl|n-$uW zkETq|v#=k8xB-r!yg^cp=Aqxg7EE`W#xC*c0I=zvkwMLD(}CFs+P+!)$a3m&8W3HD z`wNsEXP}`om9PS6EQhr)hqaqV|oB`C?2DaL`=C;}Oy95$+qUVLK3GVK zQ9x3R0g?zxLQ2Z`^mZfrL~jE^@lfqRB}UQexS_vWwlobnt`GYW%eG+YG0X;#1Ndy>SwEw zZV*dgh`di|yvQj6p#DzDeZKiGt)ArK&8mf8*x-v0?8L7SrWbGg8g)!>wA^~fwl?uHi1ZhjM}GqRDhYz{ R145soQ>6ZuObW$d%pZ?vlr#VU literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_fixtures.cpython-310-pytest-9.1.1.pyc b/botty_next/tests/__pycache__/test_fixtures.cpython-310-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74c3b2ffe3c1b452a07099823b4ffe906ba4a80f GIT binary patch literal 1235 zcmZ{k%}*0S6u@WptK0G+h$7%u5lCDU8buLetU-xaF2+mKWV3Xqu&`g5nPQ9WMLm1; zzPWmR|y?;?sA;DWAhWWY5vm zwJ`B~SF|`|u{cbmn#v54MQP|srYz26hsQ09FCGho60BWV7vBI9GWtvhq)(3Mfcn}% z>(jn=tPS+OE_S7Mru!hZsc)dYtqmb7X`Gouazex^_^mz}Qtp6MLMf);N0|<3_SS~f zx4!H1WMKFu-|ia$1=*A~c*}ir2&aF7`!L9W!qNwtPq50p(;4AGAq)y+F{`C%o@ngpGu5%pNF=Ru4ADyy`S?7}&GJ}`}K+|wO_;th_;s)X-Vjgh|v4B`a zECJwdS5dx=xP!QhxQDopsQ$q+NNV|?8z_<~>cZ%M=kwrQSBrU_?x0V*}UfUzU77_SWJt7r-+WHL;l1`NX literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_fixtures.cpython-313-pytest-9.0.3.pyc b/botty_next/tests/__pycache__/test_fixtures.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ad176b0b71041e74da9dc0daa733cac83f2b948 GIT binary patch literal 2258 zcmeHIOKTiQ5bk;GL%XY&m4_|M$x3T0o`W{Btc``06?q^qGRUA|AwdhnNZXQEnVlKB zM~T)+K!+UclTQZ!f`I-)enF0sWg}<;G32C62q?Mu#FMN?&|8U zug?Yt3k1F&e~)%{G(vtuMSXfAoP7qu6JirvyGxiRTYbggdYP=4EK|lF{cd(8$8sxq zmIpm!8{=fhWCI|XQYvg~R`wJpT(xvD>;-Pi;hT(7pWhE6F(?fY?S!5i(amIG+2jBX z_@Dg@@jW5yfbnN*wM6zwgRH4&RX4Q0X6wOZL%)58kl^O^q^I>0A?v1cTgxh`*G>`J zXc+UFZGLfKUauv-c4j@VeAZ;vloq#Bq?Q;Kp>170w@T@}FKSZ|wc3G-&3)(GsG)xz zbx(tsdOLZucbcVt_0&}~cD8EFKwf;)&e{2fIjc!6vp%Zg>6`0qZOyB>DuEME9F z5z6&4O0yQP^80Q`3*VRPv8iTpk!g3_ZF*n`16&a15NfZ6T+S(rDiYmXG?C1&%2!%TFwPe!JO#ZS$t<(Y*{vM8u`4j!IRma%7XR5?(?69l$>MrF8jlqIz8VaDVw_apdvhqs2qB^3;-W zRIHqs$?ut2dAOKrvG4+HwbXXh)376PKE~NnpHz5mR?c93@;`H676@5;GZ&NodLU{yMt2^g!&=r^n#0n?=>kxOhcsbaiGG}W)$9LFeOdJpyMx4F;$DN?H>(M1P16}yqb3Bwk3{BI1CFBQkLYDu`8rs#rN{04<9QYcm CQoKX} literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_ocr.cpython-310-pytest-9.1.1.pyc b/botty_next/tests/__pycache__/test_ocr.cpython-310-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..66c8dcb0ac94dd02c22463ae6a17b972f9a97b71 GIT binary patch literal 3878 zcmb_f&2QYs73bG-N$!`lk{!!VZN*Jm6OGqe+ma>Mc4Ig}kwaCWPJ37gAgCQm+SPJ* zF`Ts}m3ygk3hKg_-h8kMG=STGAb&y+?WM=L_GF;QA7J#*-y2dgB`0YPU4f5p-VEQz zyibj4HBZCu%dbWMuO&_U7cGiE6N``VBzpi%WBNer=<036rlILtY;{U{wqti}>==V` z>~x%*=5}1r%z+nIIu%8;2GzLMsm1k9J#KUwpjDa8%AZ@E8DI``fz1N*SOr*<=?^u( z_6pLqbzhf_hrM__h}Oe6O2%O~BGOka^BjKB}Ip)!rkGfHk1Y08>jJjOT z^2sm|phP_E3PoB-!bcHdLE$`$HYeNC=8>52VVC3{{mMY-d4MOmqHCJKeLT^pn$S}X zZ#~uf#?)ZO)J%=k+%=|FY9&7x<_n9NskNmui|AX%p~Xs~^uj*W9)S_|%c*{-^Q*ud z!iGBb2{#UP^)?T6Wr3ud@DnCJD)L@KZF5TH|s2%D^?g)L$ES@A~?|haX%S z?_c*5@3%R!cz2PoB|m8mSuggci+3~1vY$vdcW%U9n$pB60@n#FzpjVmVxhjig|i^u z@{LzBkjCc#AnfXQ2upUtadbkOU*(hq+yammDI>j)#`_2*5rl}Vpc@Vb0R>!l6puq5 z$tt#k%}H+{dc#E8gAq<7@?3!EoD7&c8f*_no8cfBB9NsUB;i&>9Ay2z(!Mtue!}^P z%Q=eQI21v&Hy(t;P+&)vl#+f~mU?lx9q}q@Rw1F8?d{^?W^r*NTVy;}ii_2k8h;;r zUf+0dd*j!*!DJ&GM0*?Y{?*Nq5c?Yn2E%AiY>?@Rf|NY1@xE-U+bCpFo^`O5(kKbU z4v%2|fJH1&N>kZM8~{gmj4zBo==?`mbdgoZKN}c5D&JBddA{k$H)I?$E@)4XZ>fQ- zGgI>kA|Ls-%1mZaq^s<@M0rMe#NP*2CJfnSDI8g)-WIZ}lx3H2KylMjA*0NxrDE7# z=cMjIS(!ZCKDb{9w3|nf3nY)+ZwcmTuZPR?rdND#D(%rkj3=mAybjj90YLG3)AzEF zBkQG|eZt?NBj*U5A+SK;Jb?=Y-UaY&>4T^8$PH^OR1HXP*v^JPP(G}!eQ0aMpdm5 zT~*b(a9?=J?;m5olETCMQ(#rX;P<-1;cxYZmsvykz079VY+6=T)e$x9)>Eet0~V^P z4f$rjF}1`@e|GA!=G05w)PuGa$gkvj{SDt9=`~06Q+hqaex}zSl@6!$V)Mrq{FzRQ z8CdWaI!PETm{(u9xdmSSuE3Hr&u#RCDm%;GNvq0|T5d`0v?Z{{#p6M9v)^Rr7PM)d zIk?w)YQv63+IXfXCM1z14czbo?T{r(dOeqlzQfKxHz0L}U10B~GfL`gE_JpbwGL?w z@Ynlul%xImsm;7;Gquwu(XMquE1qCT>9J_oEldb)j40XELRen>SB_{v zauPa2D;#5R*+cOs8LNJxdO$Wx`1YF~z^_0jY6L4CfYpvQ-fu4*Gz$Hujqakz*JXJ# z?CwTG2GyIywoMYVQ3)b_gZQ)+$AjW%1jYDIAqJoQhCciL9jxG1Z`wL`P=LgEbJsL^ zSzF<51Srl|%bmg)cB{Bp>!_(Po4nfX6RUhG%lx}kD85zj{ literal 0 HcmV?d00001 diff --git a/botty_next/tests/__pycache__/test_ocr.cpython-313-pytest-9.0.3.pyc b/botty_next/tests/__pycache__/test_ocr.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c5fd093be5b55d07bc8266206738746bc5fc6cb GIT binary patch literal 7207 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 J$JkD~_FvoXMs)xH literal 0 HcmV?d00001 diff --git a/botty_next/tests/test_capture.py b/botty_next/tests/test_capture.py new file mode 100644 index 0000000..45f82d5 --- /dev/null +++ b/botty_next/tests/test_capture.py @@ -0,0 +1,12 @@ +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, + } diff --git a/botty_next/tests/test_cli.py b/botty_next/tests/test_cli.py new file mode 100644 index 0000000..e338d77 --- /dev/null +++ b/botty_next/tests/test_cli.py @@ -0,0 +1,28 @@ +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 diff --git a/botty_next/tests/test_config.py b/botty_next/tests/test_config.py new file mode 100644 index 0000000..1b19e70 --- /dev/null +++ b/botty_next/tests/test_config.py @@ -0,0 +1,10 @@ +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 diff --git a/botty_next/tests/test_fixtures.py b/botty_next/tests/test_fixtures.py new file mode 100644 index 0000000..fee40c0 --- /dev/null +++ b/botty_next/tests/test_fixtures.py @@ -0,0 +1,13 @@ +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) diff --git a/botty_next/tests/test_ocr.py b/botty_next/tests/test_ocr.py new file mode 100644 index 0000000..791d9ab --- /dev/null +++ b/botty_next/tests/test_ocr.py @@ -0,0 +1,42 @@ +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" diff --git a/botty_next/tests/test_template_matching.py b/botty_next/tests/test_template_matching.py new file mode 100644 index 0000000..9413090 --- /dev/null +++ b/botty_next/tests/test_template_matching.py @@ -0,0 +1,24 @@ +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() diff --git a/botty_next/vision/__init__.py b/botty_next/vision/__init__.py new file mode 100644 index 0000000..4a43697 --- /dev/null +++ b/botty_next/vision/__init__.py @@ -0,0 +1,6 @@ +"""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"] diff --git a/botty_next/vision/__pycache__/__init__.cpython-310.pyc b/botty_next/vision/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb37517a5ecee4e59e9664e4572347574c907f79 GIT binary patch literal 445 zcmZWmOG^VW5YA>FZLJj*JqiU+dvLw ze}E^G))zRC4`!I}F+>dpYslC0yS+b*5PA*G-@?8b`)5WT7$S&842wu&f@Mr%nUEw3 zvsk1uBbn@wPJ||iB~KCQvh)h&+2h_#ZEDpFr(CpLn_<)=)tF$TJyfDgR*Snzl zlAD>ZZoAdI)rxXs;7(~!RJ$!bYry&m*A=y4lw;RFt1O*#y}CJ!yV#o?Yi_ndQK=IwS1se1epw_O9FcTLu1u9|sIH4i98fMO#y`$lAf z8ChUOHrT$NS$!vR!HqodeAGp;ePKXj=mhR;|0?G>tNalyY6{#>tIW@+phPg>;ipar zEZICKFra)~2)R=Ot$`8BIlf_l83DNs;|dEkp^y@xM}a9fPE$cfE7fMHiP|aaA{8`n zz*Wv*Y49}I8XN^@K~s7dXN+JhcdwZcQ(V!B2=5k$hRS>?`&G!!|E+ZpUgYN0aG9s0 zw0l|<7(b!8qDb~m4wFlDI#1GqPLgtZu<%R%T$5#Xf^lBu0^_ir%EnR2vT;F=VN02) euR(lI!M9}?#tZ6vpusa5yrTYFi`;wAR!=`)^@eKz literal 0 HcmV?d00001 diff --git a/botty_next/vision/__pycache__/fixtures.cpython-310.pyc b/botty_next/vision/__pycache__/fixtures.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..afdce7494fbc5b4bf75151a43f322fd2999c8a5c GIT binary patch literal 1116 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 L+aGG5P5S=@XU6I$ literal 0 HcmV?d00001 diff --git a/botty_next/vision/__pycache__/ocr.cpython-313.pyc b/botty_next/vision/__pycache__/ocr.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e6a0fc7ff8111e544723e7f31cbf764fddef769 GIT binary patch literal 4481 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 literal 0 HcmV?d00001 diff --git a/botty_next/vision/__pycache__/template_matching.cpython-313.pyc b/botty_next/vision/__pycache__/template_matching.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..702c92e31a08ed36bee13e6a2e551ad30c31de4c GIT binary patch literal 4743 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 literal 0 HcmV?d00001 diff --git a/botty_next/vision/fixtures.py b/botty_next/vision/fixtures.py new file mode 100644 index 0000000..e94a0b7 --- /dev/null +++ b/botty_next/vision/fixtures.py @@ -0,0 +1,26 @@ +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) diff --git a/botty_next/vision/ocr.py b/botty_next/vision/ocr.py new file mode 100644 index 0000000..dc6c87a --- /dev/null +++ b/botty_next/vision/ocr.py @@ -0,0 +1,98 @@ +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 diff --git a/botty_next/vision/template_matching.py b/botty_next/vision/template_matching.py new file mode 100644 index 0000000..27b41b3 --- /dev/null +++ b/botty_next/vision/template_matching.py @@ -0,0 +1,93 @@ +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)) diff --git a/legacy-go/ANTI_DETECTION.md b/legacy-go/ANTI_DETECTION.md new file mode 100644 index 0000000..3831370 --- /dev/null +++ b/legacy-go/ANTI_DETECTION.md @@ -0,0 +1,258 @@ +# Anti-Detection Framework for Botty-Go + +## Overview + +This document outlines the multi-layered anti-detection system built into botty-go. +Each layer addresses a specific detection vector that Blizzard and modern anti-cheat +systems use to identify bots. + +--- + +## 1. Server-Side Behavior Analysis Countermeasures + +### Detection: Session length, timing consistency, pathing patterns, repetition + +### Countermeasures: + +#### 1a. Variable Session Scheduling +- **Implementation:** `internal/schedule/scheduler.go` +- Randomized session start times using a circadian model +- Simulated human sleep patterns: 6-10 hour breaks between sessions +- Weekend/weekday behavior variance (humans play differently on weekends) +- Random session lengths: 20min to 6hours with exponential distribution +- Occasional "just 5 more minutes" overtime and "I'm tired" early stops + +#### 1b. Stochastic Pathing +- **Implementation:** `internal/pather/stochastic.go` +- Add deliberate pathing imperfection: 5-15% deviation from optimal route +- Occasional wrong-way teleports followed by course correction +- Non-optimal waypoint selections (humants don't always take shortest path) +- Variable route ordering with cooldown-dependent choices +- 2-3% chance of "getting lost" and using wrong waypoint first + +#### 1c. Skill Rotation Variance +- **Implementation:** `internal/char/behavior.go` +- Variable pre-buff timing (humans rush sometimes, sometimes take time) +- Occasional wrong skill selection followed by correction +- Potion usage with human-like hesitation (check multiple times before drinking) +- Merc healing variance: sometimes forget, sometimes over-heal + +#### 1d. Route Randomization with Context +- **Implementation:** `internal/bot/route_planner.go` +- Dynamic route selection based on: + - Time since last run of each type + - Current TP scroll count (humans adapt) + - Gem/transmute urgency + - Occasional "feels like it" switches +- Never perfect round-robin; use weighted probability with drift + +#### 1e. Farming Repetition Masking +- Never run the same route more than 8 times consecutively +- Insert "town breaks": stash visit, shrine check, repair, gamble +- 1-2% chance of "I'm bored, switching to different run" mid-session +- Vary kill strategies: sometimes rush, sometimes methodical + +--- + +## 2. Warden / Client Integrity Countermeasures + +### Detection: Loaded modules, injected DLLs, memory signatures, debuggers + +### Countermeasures: + +#### 2a. Pixel-Only Architecture (No Memory Access) +- **Implementation:** entire bot reads game state ONLY via screenshots +- NO memory reading, NO DLL injection, NO process hooking +- Same attack surface as a human with a camera pointed at the screen +- This is the #1 defense: if you only use screen capture + input simulation, + there's nothing to scan in process memory + +#### 2b. Clean Process Environment +- **Implementation:** `internal/runtime/clean_env.go` +- Standard Go binary with no suspicious imports +- No debuggers, no memory readers, no process manipulation +- Run as a normal application, not injected + +#### 2c. Overlay Avoidance +- Never draw on top of game window +- No window hooking or injection +- Screenshot from a separate thread, not an overlay + +--- + +## 3. Input Pattern Analysis Countermeasures + +### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections + +### Countermeasures: + +#### 3a. Human Motor Model +- **Implementation:** `internal/mouse/human_model.go` +- Full biomechanical mouse model based on Fitts' Law and human motion studies +- Real human mouse data characteristics: + - Multi-segment movement with micro-pauses (1-3 segments per motion) + - Acceleration curve: start slow, peak in middle, decelerate into target + - Endpoint micro-adjustments: 2-5 pixel wobble before click + - Inter-trial variability: each movement is unique even to same target + - Asymmetric error distribution: overshoot more right/down (human bias) + +#### 3b. Click Timing Model +- **Implementation:** `internal/mouse/click_model.go` +- Variable time between "arriving" at target and clicking: 50ms-800ms +- Pressure curve: humans don't click at exact same speed +- Double-click rate varies naturally +- Occasional misses: 0.5-1% of clicks land slightly off (1-3px) + +#### 3c. Keyboard Behavior Model +- **Implementation:** `internal/keyboard/human_model.go` +- Key press duration variance: not all keypresses are identical +- Typing rhythm for skill hotkeys: natural cadence with micro-pauses +- Occasional key repeat (holding too long = rapid fire) +- Realistic key-up/key-down timing ratios + +#### 3d. Statistical Indistinguishability +- **Implementation:** `internal/input/stats.go` +- All input streams modeled from real human motion capture data +- Entropy analysis of output matches human baselines +- Auto-calibration: measure user's own input if they do manual play +- Periodically inject "manual-looking" variance spikes + +--- + +## 4. Economy and Item-Flow Countermeasures + +### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior + +### Countermeasures: + +#### 4a. Natural Accumulation Rate +- **Implementation:** `internal/inventory/economy.go` +- Vary farming intensity: some sessions heavy, some light +- Match accumulation to stated playtime (more sessions = more loot) +- Occasionally "waste" items on gambling/repairs like a real player + +#### 4b. Realistic Trading Patterns +- No mass item funneling +- If trading, do it in human-sized batches with natural pauses +- Vary trade partners and timing + +#### 4c. Rune Farming Variance +- Don't farm the same runes every session +- Match rune acquisition to character progression +- Occasionally skip rune picks when "full" + +--- + +## 5. Ban Wave Defense + +### Detection: Delayed batch bans + +### Countermeasures: + +#### 5a. Graceful Degradation +- **Implementation:** `internal/runtime/safe_mode.go` +- If one account gets banned, immediately reduce intensity across all +- Auto-pause farming for 48-72 hours (simulating "taking a break") +- Gradual return with reduced session lengths +- Change behavior patterns after any ban event + +#### 5b. Account Diversity +- Each account has distinct "personality": + - Different session timing preferences + - Different route preferences + - Different response timing distributions + - Different play styles (rusher vs methodical) + +--- + +## 6. Server Authority Countermeasures + +### Detection: Server-side validation of movement, drops, combat, inventory + +### Countermeasures: + +#### 6a. Server-Authoritative Behavior +- **Implementation:** `internal/bot/server_aware.go` +- Only interact with what the server actually shows +- Wait for server confirmation before acting (e.g., confirm item picked up) +- Respect server-enforced movement limits (no speed hacks) +- Process drops in game-authorized order + +#### 6b. No Client Manipulation +- Never try to spoof packets, modify client, or exploit desync +- Purely reactive: see screen -> decide -> act -> wait for response + +--- + +## 7. Social/Reporting System Countermeasures + +### Detection: Player reports + telemetry correlation + +### Countermeasures: + +#### 7a. Social Stealth +- **Implementation:** `internal/social/stealth.go` +- Play during off-peak hours less suspiciously +- Avoid solo-public routes that attract attention +- Occasionally join other players' games (with reduced automation) +- Inherit human-like chat behavior if configured + +--- + +## 8. Hardware/Identity Correlation Countermeasures + +### Detection: IP patterns, hardware fingerprints, VMs, account clusters + +### Countermeasures: + +#### 8a. Clean Deployment +- **Implementation:** `internal/deploy/clean.go` +- Run on real hardware, not VMs +- Use residential IP, not datacenter +- One account per hardware profile +- No VPN/proxy during play sessions + +--- + +## Implementation Architecture + +``` +internal/ +├── input/ # Human-like input generation +│ ├── mouse_model.go # Fitts' Law mouse movement +│ ├── click_model.go # Human click timing +│ ├── keyboard_model.go # Keyboard behavior +│ └── stats.go # Statistical verification +├── behavior/ # High-level human behavior simulation +│ ├── scheduler.go # Session scheduling +│ ├── route_planner.go # Dynamic route selection +│ ├── fatigue.go # Simulated fatigue/boredom +│ └── personality.go # Per-account personality +├── economy/ # Economic behavior masking +│ ├── accumulation.go # Natural loot accumulation +│ └── trading.go # Human-like trading patterns +├── safe_mode/ # Graceful degradation +│ ├── detection.go # Ban wave detection +│ └── cooldown.go # Auto-pause and return +└── deploy/ # Clean deployment helpers + └── check.go # Pre-flight integrity checks +``` + +## Key Design Principles + +1. **Statistical indistinguishability:** Output must be statistically + indistinguishable from real human input. We use actual human motion + capture data distributions, not made-up random numbers. + +2. **Controlled imperfection:** A human is inefficient, forgetful, and + inconsistent. The bot should be too — but in a way that matches + real human distributions. + +3. **No single fingerprint:** Every instance should have unique enough + characteristics that correlating two accounts is hard. + +4. **Adaptability:** If behavior changes are detected, the system should + be able to recalibrate based on new data. + +5. **Defense in depth:** No single countermeasure is sufficient. The + combination across all layers is what provides real protection. diff --git a/legacy-go/GO_REWRITE_README.md b/legacy-go/GO_REWRITE_README.md new file mode 100644 index 0000000..78e9477 --- /dev/null +++ b/legacy-go/GO_REWRITE_README.md @@ -0,0 +1,33 @@ +# Botty-Go + +D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows). + +Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite +in Go that maintains compatibility with the same config files, templates, and run +logic while adding native Linux support. + +## Features + +- Cross-platform: Linux (X11/Wayland) and Windows +- Same config format as original Botty (params.ini, game.ini, shop.ini) +- Template matching with OpenCV Go bindings +- Tesseract OCR for item identification +- Human-like mouse movement (Bezier curves) +- BNIP pickit language +- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.) +- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo) + +## Building + +```bash +# Linux +go build -o botty ./cmd/botty + +# Windows (from Linux with cross-compile) +GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty +``` + +## Configuration + +Copy `config/` from the original Botty project. Params, routes, and character +config work identically. diff --git a/legacy-go/INDEX.md b/legacy-go/INDEX.md new file mode 100644 index 0000000..47ba0eb --- /dev/null +++ b/legacy-go/INDEX.md @@ -0,0 +1,19 @@ +# Legacy: Go Rewrite Design Notes + +These docs are archived from an abandoned `~/git/botty-go` directory (May 2026). +That project was a planned ground-up Go rewrite of `johannes-do/botty` for +cross-platform (Linux + Windows) support. Only design docs existed — no `.go` +source was ever written. + +The Python `my-botty` project (this repo) is the active path. These docs are +kept here as **reference material**, primarily for Milestone 2 (anti-detection / +stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`. + +## Files + +- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers + server-side behavior analysis countermeasures (session scheduling, stochastic + pathing, skill rotation variance) and more. Directly applicable as the design + basis for the Python stealth layer. +- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project. + Context only — explains feature scope and what the rewrite was aiming for. diff --git a/tools/asset_extractor.py b/tools/asset_extractor.py new file mode 100644 index 0000000..d5afef4 --- /dev/null +++ b/tools/asset_extractor.py @@ -0,0 +1,198 @@ +""" +D2R Asset Extractor + +Runs on your local Windows machine. Captures D2R, saves screenshot. +You then send the screenshot to the AI agent for analysis. +AI returns bounding boxes -> run crop.py to extract PNGs. + +Usage: + Run: python asset_extractor.py + F1: Capture D2R screen -> screenshots/debug/latest.png + F2: Crop entities from screenshots/debug/latest_annotations.json + F3: List existing assets + F12: Exit + +Workflow: + 1. Run this script in the botty conda env + 2. F1 to capture + 3. Tell your AI agent to analyze screenshots/debug/latest.png + 4. AI writes screenshots/debug/latest_annotations.json with bounding boxes + 5. F2 to crop entities into assets/enemies/ or assets/npc/ +""" +import os, sys, cv2, numpy as np, keyboard, json, ctypes, win32gui +from datetime import datetime +from mss import mss + +# DPI awareness - must be first +try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) +except: + try: + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except: + pass + +# Fix tesserocr DLLs +if sys.platform == "win32": + _dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin") + if os.path.isdir(_dll): + os.add_dll_directory(_dll) + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")) + +BASE = os.path.dirname(os.path.abspath(__file__)) +SAVE_DIR = os.path.join(BASE, "screenshots", "debug") +ENEMIES_DIR = os.path.join(BASE, "assets", "enemies") +NPC_DIR = os.path.join(BASE, "assets", "npc") + +for d in [SAVE_DIR, ENEMIES_DIR, NPC_DIR]: + os.makedirs(d, exist_ok=True) + +LATEST_PATH = os.path.join(SAVE_DIR, "latest.png") +ANNOTATIONS_PATH = os.path.join(SAVE_DIR, "latest_annotations.json") + +# Known NPC names for routing +NPC_NAMES = { + 'akara', 'charsi', 'kashya', 'cain', 'drognan', 'lysander', + 'fara', 'ormus', 'tyrael', 'jamella', 'halbu', 'qual_kehk', + 'qual-kehk', 'qualkehk', 'malah', 'larzuk', 'anya' +} + + +def find_d2r(): + hwnds = [] + def cb(h, r): + title = win32gui.GetWindowText(h) + if 'diablo' in title.lower() and win32gui.IsWindowVisible(h): + r.append(h) + win32gui.EnumWindows(cb, hwnds) + return hwnds[0] if hwnds else None + + +def grab(): + """Grab D2R client area. Resizes to 1280x720 if needed.""" + hwnd = find_d2r() + if not hwnd: + print(" [ERROR] D2R not found. Is it running and visible?") + return None + + client = win32gui.GetClientRect(hwnd) + w, h = client[2] - client[0], client[3] - client[1] + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + + with mss() as sct: + region = { + 'top': screen_pos[1], + 'left': screen_pos[0], + 'width': w, + 'height': h + } + sct_img = sct.grab(region) + img = np.array(sct_img)[:, :, :3] # BGRA -> BGR + + if w != 1280 or h != 720: + img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR) + print(f" [RESIZED] {w}x{h} -> 1280x720") + else: + print(f" [CAPTURED] {w}x{h}") + + return img + + +def on_f1(): + """Capture D2R and save.""" + print("\n[=== CAPTURING ===]") + img = grab() + if not img: + return + cv2.imwrite(LATEST_PATH, img) + print(f" [SAVED] {LATEST_PATH}") + print(f" Now ask your AI agent to analyze: {LATEST_PATH}") + print(f" AI should write: {ANNOTATIONS_PATH}") + print(' Format: [{"name":"skeleton","x":100,"y":200,"w":60,"h":80}, ...]') + + +def on_f2(): + """Crop entities from latest capture using annotations JSON.""" + print("\n[=== CROPPING ENTITIES ===]") + if not os.path.exists(LATEST_PATH): + print(" [ERROR] No capture found. Press F1 first.") + return + if not os.path.exists(ANNOTATIONS_PATH): + print(" [ERROR] No annotations found.") + print(f" Create: {ANNOTATIONS_PATH}") + print(' [{"name":"skeleton","x":100,"y":200,"w":60,"h":80}, ...]') + return + + img = cv2.imread(LATEST_PATH) + with open(ANNOTATIONS_PATH) as f: + entities = json.load(f) + + print(f" Image: {img.shape[1]}x{img.shape[0]}, Entities: {len(entities)}") + + saved = 0 + for ent in entities: + name = ent['name'].lower().replace(' ', '_') + x, y = int(ent['x']), int(ent['y']) + w, h = int(ent['w']), int(ent['h']) + i_w, i_h = img.shape[1], img.shape[0] + + # Crop with 5px padding + pad = 5 + x1, y1 = max(0, x - pad), max(0, y - pad) + x2, y2 = min(i_w, x + w + pad), min(i_h, y + h + pad) + crop = img[y1:y2, x1:x2] + + # Route to npc or enemies folder + if name in NPC_NAMES: + save_dir = NPC_DIR + else: + save_dir = ENEMIES_DIR + + # Auto-number duplicates + fname = f"{name}.png" + save_path = os.path.join(save_dir, fname) + variant = 1 + while os.path.exists(save_path): + variant += 1 + fname = f"{name}_{variant}.png" + save_path = os.path.join(save_dir, fname) + + cv2.imwrite(save_path, crop) + print(f" [SAVED] {save_path} ({crop.shape[1]}x{crop.shape[0]})") + saved += 1 + + print(f"\n Total: {saved} assets cropped.") + + +def on_f3(): + """List existing assets.""" + print("\n[=== ASSETS INVENTORY ===]") + for label, d in [("enemies", ENEMIES_DIR), ("npc", NPC_DIR)]: + if os.path.isdir(d): + files = sorted(os.listdir(d)) + print(f"\n assets/{label}/ ({len(files)} files):") + for f in files: + sz = os.path.getsize(os.path.join(d, f)) + print(f" {f} ({sz}b)") + else: + print(f"\n assets/{label}/ - EMPTY") + + +def run(): + print("=== D2R Asset Extractor ===") + print(" F1 - Capture D2R screen") + print(" F2 - Crop entities from annotations") + print(" F3 - List assets") + print(" F12 - Exit") + print("Ready.") + + keyboard.add_hotkey('f1', on_f1) + keyboard.add_hotkey('f2', on_f2) + keyboard.add_hotkey('f3', on_f3) + keyboard.add_hotkey('f12', lambda: (print("\nBye."), sys.exit(0))) + keyboard.wait() + + +if __name__ == "__main__": + run() diff --git a/tools/asset_manager.py b/tools/asset_manager.py new file mode 100644 index 0000000..b7d6baa --- /dev/null +++ b/tools/asset_manager.py @@ -0,0 +1,1106 @@ +""" +Botty Asset Manager - Unified asset management tool. + +All-in-one tool for managing D2R template assets: capture, crop, audit, +analyze, and maintain your template library. + +Commands: + inventory List all assets with size, dimensions, category + audit Find issues: duplicates, orphans, naming problems + quality Analyze image quality: resolution, transparency, size + capture Capture D2R window to screenshots/captures/ + crop X Y W H NAME Crop region from latest capture, save as template + auto_crop Interactive: click D2R to select a crop region + search TERM Find assets matching a name/pattern + key NAME Look up the template key to use in code + validate Check all templates load correctly + similarity Find near-duplicate images + cleanup [--yes] Find/remove duplicate assets + batch OP VALUE Batch operation: "resize WxH" or "convert png" + help Show this help + +Examples: + python asset_manager.py inventory + python asset_manager.py audit + python asset_manager.py search akara + python asset_manager.py key akara_front + python asset_manager.py crop 100 200 50 80 my_npc + python asset_manager.py auto_crop + python asset_manager.py similarity + python asset_manager.py cleanup + python asset_manager.py validate + +Template naming convention: + - Use lowercase_with_underscores (e.g. akara_front.png) + - Template key is the filename uppercased (e.g. AKARA_FRONT) + - NPC assets go in assets/npc// + - UI templates go in assets/templates/ui/ + - Item templates go in assets/item_properties/ +""" +import os, sys, argparse, json, hashlib, time, math, re +from pathlib import Path +from datetime import datetime +from collections import defaultdict + +# DPI awareness +try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(2) +except: + pass + +# Fix DLL loading +if sys.platform == "win32": + _dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin") + if os.path.isdir(_dll): + os.add_dll_directory(_dll) + +import cv2 +import numpy as np + +BASE = Path(os.path.dirname(os.path.abspath(__file__))) +ASSETS = BASE / "assets" + +# Template directories that template_finder.py loads +TEMPLATE_DIRS = [ + "templates", + "npc", + "shop", + "item_properties", + "chests", + "gamble", + "items", +] + +# Known NPC names for routing +NPC_NAMES = { + 'akara', 'charsi', 'kashya', 'cain', 'drognan', 'lysander', + 'fara', 'ormus', 'tyrael', 'jamella', 'halbu', 'qual_kehk', + 'malah', 'larzuk', 'anya', 'carrow', 'ashera', 'alkaar', + 'elzix', 'meshiff', 'hrrky', 'izhu', 'essjay', 'seraphina', + 'aluria', 'jermak', 'griswold', 'hugel', 'rodek', 'meathead', + 'gheed', 'act1', 'act2', 'act3', 'act4', 'act5', +} + + +# ===================== IMAGE UTILITIES ===================== + +def img_hash(path): + """MD5 hash of image file content.""" + try: + with open(path, 'rb') as f: + return hashlib.md5(f.read()).hexdigest() + except: + return None + + +def img_hash_fast(path): + """Faster hash: read first/last 4KB of file.""" + try: + sz = os.path.getsize(path) + with open(path, 'rb') as f: + h = hashlib.md5(f.read(4096)).hexdigest() + if sz > 4096: + f.seek(-4096, 2) + h += hashlib.md5(f.read(4096)).hexdigest() + return h + except: + return None + + +def img_dims(path): + """Return (w, h) or None.""" + try: + img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) + if img is None: + return None + return img.shape[1], img.shape[0] + except: + return None + + +def img_quick_info(path): + """Return (w, h, has_alpha) in a single image load.""" + try: + img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) + if img is None: + return None, None, False + w, h = img.shape[1], img.shape[0] + has_alpha = (img.shape[2] == 4 and np.min(img[:, :, 3]) < 255) if len(img.shape) > 1 and img.shape[2] >= 4 else False + return w, h, has_alpha + except: + return None, None, False + + +def img_similarity(path1, path2): + """Compute visual similarity between two images (0-1, higher = more similar). + Uses resized comparison + MSE for speed.""" + try: + img1 = cv2.imread(str(path1)) + img2 = cv2.imread(str(path2)) + if img1 is None or img2 is None: + return 0.0 + # Resize to same size for comparison + img1 = cv2.resize(img1, (64, 64)) + img2 = cv2.resize(img2, (64, 64)) + mse = np.mean((img1.astype('float') - img2.astype('float')) ** 2) + return float(math.exp(-mse / 10000)) + except: + return 0.0 + + +# ===================== ASSET GATHERING ===================== + +def gather_assets(asset_dirs=None): + """Gather all asset file paths with metadata. Uses lazy evaluation for image info.""" + if asset_dirs is None: + asset_dirs = TEMPLATE_DIRS + assets = {} + for d in asset_dirs: + dir_path = ASSETS / d + if not dir_path.exists(): + continue + for f in dir_path.rglob('*.png'): + rel = str(f.relative_to(ASSETS)) + assets[rel] = { + 'path': f, + 'category': d, + 'size': f.stat().st_size, + 'dims': None, # Lazy-loaded + 'hash': img_hash(f), + 'fast_hash': img_hash_fast(f), + 'has_alpha': False, # Lazy-loaded + } + return assets + + +def _ensure_image_info(info): + """Lazy-load image dimensions and alpha info if not already loaded.""" + if info['dims'] is not None: + return + w, h, alpha = img_quick_info(info['path']) + info['dims'] = (w, h) if w is not None else None + info['has_alpha'] = alpha + + +# ===================== COMMANDS ===================== + +def cmd_inventory(args): + """List all assets with details.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + # Group by category + cats = defaultdict(list) + for name, info in sorted(assets.items()): + cats[info['category']].append((name, info)) + + print(f"\n{'='*70}") + print(f" Botty Asset Inventory ({len(assets)} assets)") + print(f"{'='*70}\n") + + total_size = 0 + for cat in sorted(cats.keys()): + items = cats[cat] + cat_size = sum(i['size'] for _, i in items) + total_size += cat_size + print(f" [{cat.upper()}] ({len(items)} files, {cat_size/1024:.1f} KB)") + for name, info in items: + _ensure_image_info(info) + dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" + alpha = " [A]" if info['has_alpha'] else "" + size_str = f"{info['size']/1024:.1f} KB" if info['size'] >= 1024 else f"{info['size']} B" + print(f" {name} {dims_str} {size_str}{alpha}") + print() + + print(f" Total: {len(assets)} files, {total_size/1024:.1f} KB") + print() + + +def cmd_search(args): + """Search assets by name/pattern.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + term = ' '.join(args.args).lower() + + # Exact and fuzzy matches + results = [] + for name, info in assets.items(): + name_lower = name.lower() + stem = Path(name).stem.lower() + + score = 0 + if term in stem: + score = 100 + elif stem in term: + score = 80 + elif term in name_lower: + score = 60 + elif any(w in stem for w in term.split()): + score = 40 + else: + # Check with separators removed + clean = stem.replace('_', '').replace('-', '') + clean_term = term.replace('_', '').replace('-', '') + if clean_term in clean: + score = 30 + elif clean in clean_term: + score = 20 + + if score > 0: + results.append((score, name, info)) + + # Sort by score descending + results.sort(key=lambda x: -x[0]) + + print(f"\n{'='*70}") + print(f" Search: '{term}' ({len(results)} results)") + print(f"{'='*70}\n") + + if not results: + print(" No matches found.") + # Suggest closest + best = None + best_dist = 999 + for name, info in assets.items(): + stem = Path(name).stem.lower() + dist = len(set(term) - set(stem)) + if dist < best_dist and dist < len(term): + best_dist = dist + best = stem + if best: + print(f" Closest: {best}") + else: + for score, name, info in results[:50]: + _ensure_image_info(info) + dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" + template_key = Path(name).stem.upper() + alpha = " [A]" if info['has_alpha'] else "" + print(f" {name} {dims_str} key={template_key}{alpha}") + if len(results) > 50: + print(f" ... and {len(results) - 50} more") + + print() + + +def cmd_key(args): + """Look up the template key to use in code.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + term = ' '.join(args.args) + if not term: + print(" Usage: python asset_manager.py key ") + print(" Example: python asset_manager.py key akara_front") + return + + term_lower = term.lower().replace('-', '_') + + # Find matching assets + matches = [] + for name, info in assets.items(): + stem = Path(name).stem.lower() + if term_lower in stem or stem in term_lower: + template_key = Path(name).stem.upper() + matches.append((name, template_key, info)) + + print(f"\n{'='*70}") + print(f" Template Key Lookup: '{term}'") + print(f"{'='*70}\n") + + if not matches: + print(f" No assets matching '{term}'.") + print(f" Try: python asset_manager.py search {term}") + else: + for name, key, info in matches[:10]: + _ensure_image_info(info) + dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" + print(f" {name}") + print(f" Key: '{key}'") + print(f" Use: template_finder.search('{key}', img, threshold=0.XX)") + print(f" Size: {dims_str}") + print() + print() + + +def cmd_audit(args): + """Find asset issues: duplicates, orphans, naming problems.""" + assets = gather_assets() + + issues = [] + + # 1. Find exact duplicates (same hash) + hash_map = defaultdict(list) + for name, info in assets.items(): + if info['hash']: + hash_map[info['hash']].append(name) + + print(f"\n{'='*70}") + print(f" Botty Asset Audit") + print(f"{'='*70}\n") + + print(" DUPLICATES (identical content):") + dup_count = 0 + for h, names in hash_map.items(): + if len(names) > 1: + dup_count += len(names) - 1 + print(f" {len(names)}x: {', '.join(names)}") + if not dup_count: + print(" None found.") + + # 2. Naming convention issues + print(f"\n NAMING ISSUES:") + naming_issues = 0 + for name, info in assets.items(): + base = Path(name).stem + if ' ' in base: + print(f" {name} - contains spaces") + naming_issues += 1 + if base != base.lower() and base != base.upper(): + print(f" {name} - mixed case") + naming_issues += 1 + if '_' in base and '-' in base: + print(f" {name} - mixed separators") + naming_issues += 1 + # Dots in filename (not extension) + if '.' in base and not base.endswith('.png'): + print(f" {name} - contains dots in name (use underscores)") + naming_issues += 1 + if not naming_issues: + print(" None found.") + + # 3. Oversized assets + print(f"\n OVERSIZED (>500x500, likely full screenshots misused as templates):") + oversized = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims'] and (info['dims'][0] > 500 or info['dims'][1] > 500): + print(f" {name} {info['dims'][0]}x{info['dims'][1]}") + oversized += 1 + if not oversized: + print(" None found.") + + # 4. Tiny assets + print(f"\n TINY (<10x10, likely corrupted or miscropped):") + tiny = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims'] and (info['dims'][0] < 10 or info['dims'][1] < 10): + print(f" {name} {info['dims'][0]}x{info['dims'][1]}") + tiny += 1 + if not tiny: + print(" None found.") + + # 5. Asymmetric assets (potential miscrop) + print(f"\n VERY ASYMMETRIC (ratio >10:1, potential miscrop):") + asym = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims']: + w, h = info['dims'] + ratio = max(w, h) / max(min(w, h), 1) + if ratio > 10 and max(w, h) > 30: + print(f" {name} {w}x{h} ratio {ratio:.0f}:1") + asym += 1 + if not asym: + print(" None found.") + + print(f"\n Summary: {dup_count} duplicates, {naming_issues} naming issues, " + f"{oversized} oversized, {tiny} tiny, {asym} asymmetric") + print() + + +def cmd_quality(args): + """Analyze image quality metrics.""" + assets = gather_assets() + if not assets: + print("No assets found.") + return + + print(f"\n{'='*70}") + print(f" Botty Asset Quality Report") + print(f"{'='*70}\n") + + # Resolution distribution + dims = defaultdict(int) + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims']: + dims[str(info['dims'][0]) + 'x' + str(info['dims'][1])] += 1 + + print(" Resolution distribution (top 20):") + for d, c in sorted(dims.items(), key=lambda x: -x[1])[:20]: + print(f" {d}: {c} files") + print() + + # File size distribution + sizes = defaultdict(int) + for name, info in assets.items(): + bucket = info['size'] // 1024 + if bucket < 1: + sizes['<1 KB'] += 1 + elif bucket < 10: + sizes['1-10 KB'] += 1 + elif bucket < 50: + sizes['10-50 KB'] += 1 + elif bucket < 100: + sizes['50-100 KB'] += 1 + else: + sizes['>100 KB'] += 1 + + print(" File size distribution:") + for s, c in sorted(sizes.items()): + print(f" {s}: {c} files") + print() + + # Transparency usage + alpha_count = sum(1 for info in assets.values() if info['has_alpha']) + print(f" With transparency (alpha): {alpha_count}/{len(assets)}") + print() + + # Per-category stats + print(" Per-category stats:") + cats = defaultdict(lambda: {'count': 0, 'total_size': 0, 'avg_dims': [0, 0]}) + for name, info in assets.items(): + _ensure_image_info(info) + c = cats[info['category']] + c['count'] += 1 + c['total_size'] += info['size'] + if info['dims']: + c['avg_dims'][0] += info['dims'][0] + c['avg_dims'][1] += info['dims'][1] + + for cat in sorted(cats.keys()): + c = cats[cat] + avg_w = c['avg_dims'][0] // c['count'] if c['count'] else 0 + avg_h = c['avg_dims'][1] // c['count'] if c['count'] else 0 + print(f" {cat}: {c['count']} files, {c['total_size']/1024:.1f} KB, avg {avg_w}x{avg_h}") + print() + + +def find_d2r(): + """Find D2R window handle.""" + import win32gui + import psutil + # Find D2R process first + d2r_pids = set() + for proc in psutil.process_iter(['name']): + try: + if proc.info['name'] and 'D2R' in proc.info['name']: + d2r_pids.add(proc.pid) + except: + pass + + if not d2r_pids: + return None + + hwnds = [] + def cb(h, r): + title = win32gui.GetWindowText(h) + if 'diablo' in title.lower() and win32gui.IsWindowVisible(h): + # Check if this window belongs to D2R process + import win32process + _, pid = win32process.GetWindowThreadProcessId(h) + if pid in d2r_pids: + r.append((h, title)) + win32gui.EnumWindows(cb, hwnds) + + if not hwnds: + return None + # Return the window with most title characters (most likely the game window) + hwnds.sort(key=lambda x: -len(x[1])) + return hwnds[0][0] + + +def grab_d2r(): + """Grab D2R client area at 1280x720.""" + from mss import mss + import win32gui + hwnd = find_d2r() + if not hwnd: + print(" [ERROR] D2R not found. Is it running and visible?") + return None + + client = win32gui.GetClientRect(hwnd) + w, h = client[2] - client[0], client[3] - client[1] + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + + with mss() as sct: + region = { + 'top': screen_pos[1], + 'left': screen_pos[0], + 'width': w, + 'height': h + } + sct_img = sct.grab(region) + img = np.array(sct_img)[:, :, :3] + + if w != 1280 or h != 720: + img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR) + print(f" [RESIZED] {w}x{h} -> 1280x720") + else: + print(f" [CAPTURED] {w}x{h}") + + return img + + +def cmd_capture(args): + """Capture D2R window and save.""" + save_dir = BASE / "screenshots" / "captures" + save_dir.mkdir(parents=True, exist_ok=True) + + img = grab_d2r() + if img is None: + return + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + name = f"capture_{ts}.png" + path = save_dir / name + cv2.imwrite(str(path), img) + print(f"\n [SAVED] {path}") + print(f" Crop with: python asset_manager.py crop X Y W H template_name") + print(f" Or use: python asset_manager.py auto_crop") + print() + + +def cmd_crop(args): + """Crop a region from the latest capture and save as template.""" + x, y, w, h = args.x, args.y, args.w, args.h + name = args.name + + # Find latest capture or grab fresh + save_dir = BASE / "screenshots" / "captures" + captures = sorted(save_dir.glob("capture_*.png"), key=os.path.getmtime) + if captures: + img = cv2.imread(str(captures[-1]), cv2.IMREAD_UNCHANGED) + if img is not None: + print(f" [LOADED] {captures[-1].name}") + else: + img = None + + if img is None: + print(" No recent capture. Grabbing fresh...") + img = grab_d2r() + + if img is None: + return + + # Crop + h_img, w_img = img.shape[:2] + x1, y1 = max(0, x), max(0, y) + x2, y2 = min(w_img, x + w), min(h_img, y + h) + crop = img[y1:y2, x1:x2] + + if crop.size == 0: + print(f" [ERROR] Crop region ({x},{y},{w},{h}) is out of bounds (image is {w_img}x{h_img})") + return + + # Auto-trim black/transparent borders + crop = _trim_borders(crop) + + # Determine save location + save_dir, name_lower = _resolve_save_path(name) + + # Auto-number if exists + fname = f"{name_lower}.png" + save_path = save_dir / fname + variant = 1 + while save_path.exists(): + variant += 1 + fname = f"{name_lower}_{variant}.png" + save_path = save_dir / fname + + cv2.imwrite(str(save_path), crop) + + rel = str(save_path.relative_to(ASSETS)) + print(f"\n [SAVED] {rel} ({crop.shape[1]}x{crop.shape[0]})") + + # Show template key for use in code + template_key = fname[:-4].upper() + print(f" Template key: '{template_key}'") + print(f" Use in code: template_finder.search('{template_key}', img, threshold=0.XX)") + print() + + +def _trim_borders(img): + """Trim black and transparent borders from an image.""" + # Handle grayscale images (1 channel) + if len(img.shape) == 2: + mask = (img > 1).astype(np.uint8) * 255 + elif img.shape[2] == 4: + # RGBA: non-transparent AND non-black pixels + alpha = img[:, :, 3] + gray = cv2.cvtColor(img[:, :, :3], cv2.COLOR_BGR2GRAY) + mask = ((gray > 1) & (alpha > 0)).astype(np.uint8) * 255 + else: + # BGR or other: non-black pixels + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + mask = (gray > 1).astype(np.uint8) * 255 + + coords = cv2.findNonZero(mask) + if coords is None: + return img + + x, y, w, h = cv2.boundingRect(coords) + # Add 2px padding + pad = 2 + h_img, w_img = img.shape[:2] + x = max(0, x - pad) + y = max(0, y - pad) + w = min(w_img - x, w + 2 * pad) + h = min(h_img - y, h + 2 * pad) + + return img[y:y+h, x:x+w] + + +def _resolve_save_path(name): + """Determine where to save a new asset based on its name.""" + name_lower = name.lower().replace('-', '_').replace(' ', '_') + + if name_lower in NPC_NAMES: + save_dir = ASSETS / "npc" / name_lower + elif 'template' in name_lower or 'ui' in name_lower: + save_dir = ASSETS / "templates" / "ui" + elif 'chest' in name_lower: + save_dir = ASSETS / "chests" + elif 'item' in name_lower: + save_dir = ASSETS / "item_properties" + elif 'npc' in name_lower or 'action' in name_lower: + save_dir = ASSETS / "npc" / "action_btn" + elif 'gamble' in name_lower: + save_dir = ASSETS / "gamble" + elif 'shop' in name_lower: + save_dir = ASSETS / "shop" + else: + save_dir = ASSETS / "templates" + + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir, name_lower + + +def cmd_auto_crop(args): + """Interactive crop mode: click D2R to select region.""" + try: + from input_layer import keyboard + except ImportError: + sys.path.insert(0, str(BASE / "src")) + from input_layer import keyboard + + print(f"\n{'='*70}") + print(f" Botty Auto-Crop (Interactive)") + print(f"{'='*70}") + print(f" 1. Press F1 to capture D2R") + print(f" 2. Position mouse over TOP-LEFT corner, press F2") + print(f" 3. Position mouse over BOTTOM-RIGHT corner, press F2") + print(f" 4. Preview shows in window - press:") + print(f" F3 Accept and save (you'll be prompted for name)") + print(f" F4 Retry selection (goes back to step 2)") + print(f" F12 Exit") + print(f" {'='*70}") + print(" Ready. Press F1 to capture D2R.\n") + + img = None + pt1 = None + pt2 = None + + def on_f1(): + nonlocal img + img = grab_d2r() + if img is not None: + print(" [CAPTURED] Press F2 for top-left corner.") + + def on_f2(): + nonlocal pt1, pt2 + from input_layer import mouse + mx, my = mouse.get_position() + # Convert to D2R client coordinates + hwnd = find_d2r() + if hwnd: + import win32gui + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + cx = mx - screen_pos[0] + cy = my - screen_pos[1] + # Scale if needed + if img is not None: + h_img, w_img = img.shape[:2] + cx = int(cx * w_img / 1280) + cy = int(cy * h_img / 720) + + if pt1 is None: + pt1 = (cx, cy) + print(f" Top-left: {pt1}. Now move mouse to bottom-right and press F2 again.") + else: + pt2 = (cx, cy) + print(f" Bottom-right: {pt2}. Preview: F3=save, F4=retry") + _show_preview() + + def _show_preview(): + if img is None or pt1 is None or pt2 is None: + return + h_img, w_img = img.shape[:2] + x1 = max(0, min(pt1[0], pt2[0])) + y1 = max(0, min(pt1[1], pt2[1])) + x2 = min(w_img, max(pt1[0], pt2[0])) + y2 = min(h_img, max(pt1[1], pt2[1])) + + preview = img[y1:y2, x1:x2] + preview = _trim_borders(preview) + # Resize for display if too large + disp = preview.copy() + if max(disp.shape[:2]) > 500: + scale = 500.0 / max(disp.shape[:2]) + disp = cv2.resize(disp, (int(disp.shape[1] * scale), int(disp.shape[0] * scale))) + + cv2.imshow("Auto-Crop Preview", disp) + cv2.waitKey(1) + print(f" Preview: {preview.shape[1]}x{preview.shape[0]} (after trim)") + + def on_f3(): + nonlocal img, pt1, pt2 + if img is None or pt1 is None or pt2 is None: + print(" [ERROR] No selection. Press F1 first, then F2 twice.") + return + h_img, w_img = img.shape[:2] + x1 = max(0, min(pt1[0], pt2[0])) + y1 = max(0, min(pt1[1], pt2[1])) + x2 = min(w_img, max(pt1[0], pt2[0])) + y2 = min(h_img, max(pt1[1], pt2[1])) + crop = img[y1:y2, x1:x2] + crop = _trim_borders(crop) + + # Ask for name + name = input("\n Enter template name: ").strip() + if not name: + name = "new_asset" + name = re.sub(r'[^a-zA-Z0-9_\-]', '_', name) + + save_dir, name_lower = _resolve_save_path(name) + fname = f"{name_lower}.png" + save_path = save_dir / fname + variant = 1 + while save_path.exists(): + variant += 1 + fname = f"{name_lower}_{variant}.png" + save_path = save_dir / fname + + cv2.imwrite(str(save_path), crop) + cv2.destroyWindow("Auto-Crop Preview") + + rel = str(save_path.relative_to(ASSETS)) + template_key = fname[:-4].upper() + print(f"\n [SAVED] {rel} ({crop.shape[1]}x{crop.shape[0]})") + print(f" Template key: '{template_key}'") + print(f" Use in code: template_finder.search('{template_key}', img, threshold=0.XX)") + + # Reset for next crop + pt1 = pt2 = None + + def on_f4(): + nonlocal pt1, pt2 + pt1 = pt2 = None + cv2.destroyWindow("Auto-Crop Preview") + print(" Retry. Press F2 for top-left corner.") + + keyboard.add_hotkey('f1', on_f1) + keyboard.add_hotkey('f2', on_f2) + keyboard.add_hotkey('f3', on_f3) + keyboard.add_hotkey('f4', on_f4) + keyboard.add_hotkey('f12', lambda: (print("\n Bye."), sys.exit(0))) + + try: + keyboard.wait() + except KeyboardInterrupt: + print("\n Bye.") + + +def cmd_validate(args): + """Validate all templates load correctly.""" + assets = gather_assets() + print(f"\n{'='*70}") + print(f" Botty Template Validation") + print(f"{'='*70}\n") + + errors = 0 + warnings = 0 + + for name, info in sorted(assets.items()): + _ensure_image_info(info) + if info['dims'] is None: + print(f" [ERROR] {name} - cannot read image") + errors += 1 + elif info['dims'][0] == 0 or info['dims'][1] == 0: + print(f" [ERROR] {name} - zero dimensions") + errors += 1 + elif info['size'] == 0: + print(f" [ERROR] {name} - empty file") + errors += 1 + else: + # Check template key is usable + template_key = Path(name).stem.upper() + cleaned = ''.join(c for c in template_key if c not in '0123456789_') + if not cleaned.isalpha(): + print(f" [WARN] {name} - key '{template_key}' contains unusual chars") + warnings += 1 + + if not errors and not warnings: + print(" All templates are valid.") + else: + print(f"\n {errors} error(s), {warnings} warning(s)") + print() + + +def cmd_similarity(args): + """Find near-duplicate images using visual similarity.""" + assets = gather_assets() + if len(assets) < 2: + print("Need at least 2 assets to compare.") + return + + print(f"\n{'='*70}") + print(f" Botty Similarity Analysis (fast mode)") + print(f"{'='*70}\n") + print(" Comparing assets within each category...") + print() + + # Group by category for faster comparison + cats = defaultdict(list) + for name, info in assets.items(): + cats[info['category']].append((name, info)) + + pairs_found = 0 + for cat, items in cats.items(): + if len(items) < 2: + continue + + # Quick pre-filter: only compare same-size images + size_groups = defaultdict(list) + for name, info in items: + _ensure_image_info(info) + if info['dims']: + size_groups[(info['dims'][0], info['dims'][1])].append((name, info)) + + for size, group in size_groups.items(): + if len(group) < 2: + continue + + for i in range(len(group)): + for j in range(i + 1, len(group)): + n1, i1 = group[i] + n2, i2 = group[j] + # Skip exact duplicates (those are caught by audit) + if i1['hash'] == i2['hash']: + continue + sim = img_similarity(i1['path'], i2['path']) + if sim > 0.85: + pairs_found += 1 + print(f" [{sim:.2f}] {n1} ~= {n2} ({size[0]}x{size[1]})") + elif sim > 0.70 and cat == 'npc': + pairs_found += 1 + print(f" [{sim:.2f}] {n1} ~= {n2} ({size[0]}x{size[1]})") + + if not pairs_found: + print(" No near-duplicates found.") + else: + print(f"\n {pairs_found} near-duplicate pair(s) found.") + print() + + +def cmd_cleanup(args): + """Remove duplicate assets (keep first occurrence).""" + assets = gather_assets() + hash_map = defaultdict(list) + for name, info in assets.items(): + if info['hash']: + hash_map[info['hash']].append((name, info)) + + print(f"\n{'='*70}") + print(f" Botty Asset Cleanup") + print(f"{'='*70}\n") + + removed = 0 + for h, items in hash_map.items(): + if len(items) > 1: + print(f" Duplicate group ({len(items)} files):") + for i, (name, info) in enumerate(items): + if i == 0: + print(f" [KEEP] {name}") + else: + if args.yes: + os.remove(str(info['path'])) + print(f" [REMOVED] {name}") + removed += 1 + else: + print(f" [WILL REMOVE] {name}") + print() + + if args.yes: + print(f" Removed {removed} duplicates.") + else: + print(f" Would remove {removed} duplicates. Use --yes to actually remove.") + print() + + +def cmd_batch(args): + """Batch operations on assets.""" + operation = args.operation.lower() + + if operation == "resize": + try: + target_w, target_h = map(int, args.value.split('x')) + except: + print(" Usage: python asset_manager.py batch resize WxH") + return + + assets = gather_assets() + count = 0 + for name, info in assets.items(): + _ensure_image_info(info) + if info['dims'] and (info['dims'][0] != target_w or info['dims'][1] != target_h): + img = cv2.imread(str(info['path']), cv2.IMREAD_UNCHANGED) + if img is not None: + # Use INTER_AREA for downscaling (better quality), INTER_CUBIC for upscaling + if target_w < info['dims'][0]: + interp = cv2.INTER_AREA + else: + interp = cv2.INTER_CUBIC + resized = cv2.resize(img, (target_w, target_h), interpolation=interp) + cv2.imwrite(str(info['path']), resized) + count += 1 + print(f" Resized {count} assets to {target_w}x{target_h}.") + + elif operation == "convert": + fmt = args.value.lower() + if fmt not in ('png', 'jpg', 'jpeg'): + print(" Supported formats: png, jpg") + return + assets = gather_assets() + count = 0 + for name, info in assets.items(): + if info['path'].suffix.lower() != f'.{fmt}': + new_path = info['path'].with_suffix(f'.{fmt}') + img = cv2.imread(str(info['path']), cv2.IMREAD_UNCHANGED) + if img is not None: + cv2.imwrite(str(new_path), img) + count += 1 + print(f" Converted {count} assets to .{fmt}") + + else: + print(f" Unknown batch operation: {operation}") + print(f" Supported: resize, convert") + + +def print_help(): + print(f""" +{'='*70} + Botty Asset Manager +{'='*70} + +Usage: python asset_manager.py [command] [options] + +Commands: + inventory List all assets with size, dimensions, category + search TERM Find assets matching a name/pattern + key NAME Look up the template key to use in code + audit Find issues: duplicates, naming, oversized, tiny + quality Analyze image quality: resolution, transparency, size + similarity Find near-duplicate images + capture Capture D2R window to screenshots/captures/ + crop X Y W H NAME Crop region from latest capture, save as template + auto_crop Interactive: click D2R to select a crop region + validate Check all templates load correctly + cleanup [--yes] Find/remove duplicate assets + batch OP VALUE Batch operation: "resize WxH" or "convert png" + help Show this help + +Examples: + python asset_manager.py inventory + python asset_manager.py audit + python asset_manager.py quality + python asset_manager.py search akara + python asset_manager.py key akara_front + python asset_manager.py capture + python asset_manager.py crop 100 200 50 80 akara_front + python asset_manager.py crop 300 400 100 120 npc_dialogue + python asset_manager.py auto_crop + python asset_manager.py similarity + python asset_manager.py validate + python asset_manager.py cleanup + python asset_manager.py cleanup --yes + python asset_manager.py batch resize 64x64 + +Template naming convention: + - Use lowercase_with_underscores (e.g. akara_front.png) + - Template key is the filename uppercased (e.g. AKARA_FRONT) + - NPC assets go in assets/npc// + - UI templates go in assets/templates/ui/ + - Item templates go in assets/item_properties/ + +Template Finder search paths: +""") + for d in TEMPLATE_DIRS: + print(f" assets/{d}/") + print() + + +def main(): + parser = argparse.ArgumentParser(description='Botty Asset Manager', add_help=False) + parser.add_argument('command', nargs='?', default='help', + help='Command to run') + parser.add_argument('args', nargs='*', help='Command arguments') + parser.add_argument('--yes', action='store_true', help='Confirm destructive actions') + + parsed = parser.parse_args() + cmd = parsed.command.lower() + + if cmd == 'inventory': + cmd_inventory(parsed) + elif cmd == 'search': + cmd_search(parsed) + elif cmd == 'key': + cmd_key(parsed) + elif cmd == 'audit': + cmd_audit(parsed) + elif cmd == 'quality': + cmd_quality(parsed) + elif cmd == 'capture': + cmd_capture(parsed) + elif cmd == 'crop': + if len(parsed.args) < 5: + print(" Usage: python asset_manager.py crop X Y W H NAME") + print(" Example: python asset_manager.py crop 100 200 50 80 akara_front") + return + parsed.x = int(parsed.args[0]) + parsed.y = int(parsed.args[1]) + parsed.w = int(parsed.args[2]) + parsed.h = int(parsed.args[3]) + parsed.name = parsed.args[4] + cmd_crop(parsed) + elif cmd == 'auto_crop': + cmd_auto_crop(parsed) + elif cmd == 'validate': + cmd_validate(parsed) + elif cmd == 'similarity': + cmd_similarity(parsed) + elif cmd == 'cleanup': + cmd_cleanup(parsed) + elif cmd == 'batch': + if len(parsed.args) < 2: + print(" Usage: python asset_manager.py batch OP VALUE") + print(" Example: python asset_manager.py batch resize 64x64") + return + parsed.operation = parsed.args[0] + parsed.value = parsed.args[1] + cmd_batch(parsed) + else: + print_help() + + +if __name__ == "__main__": + main() diff --git a/tools/build.py b/tools/build.py new file mode 100644 index 0000000..f6470c4 --- /dev/null +++ b/tools/build.py @@ -0,0 +1,179 @@ +import os +import shutil +import sys +from pathlib import Path +from src.version import __version__ +import argparse +import getpass +import random +from cryptography.fernet import Fernet +import string + + +def _resolve_botty_env(conda_path): + """Find the botty Python environment directory. + + Tries (in order): + 1. Explicit conda path (conda_path/envs/botty) + 2. Current sys.prefix if it looks like a conda env + 3. Fallback to sys.prefix (pip/virtualenv installs) + """ + # 1. Explicit conda path + botty_env = os.path.join(conda_path, "envs", "botty") + if os.path.isdir(botty_env): + return botty_env + + # 2. Current prefix is a conda env + if os.path.isfile(os.path.join(sys.prefix, "conda-meta", "history")) or \ + os.path.isdir(os.path.join(sys.prefix, "Library")): + return sys.prefix + + # 3. Plain pip / virtualenv — sys.prefix is the site + return sys.prefix + + +parser = argparse.ArgumentParser(description="Build Botty") +parser.add_argument( + "-v" , "--version", + type=str, + help="New release version e.g. 0.4.2", + default="" +) +parser.add_argument( + "-c", "--conda_path", + type=str, + help="Path to local conda e.g. C:\\Users\\USER\\miniconda3", + default=f"C:\\Users\\{getpass.getuser()}\\miniconda3") +parser.add_argument( + "-r", "--random_name", + action='store_true', + help="Will generate a random name for the botty exe") +parser.add_argument( + "-k", "--use_key", + action='store_true', + help="Will build with encryption key") +args = parser.parse_args() + + +# clean up +def clean_up(): + # pyinstaller + if os.path.exists("build"): + shutil.rmtree("build") + if os.path.exists("main.spec"): + os.remove("main.spec") + if os.path.exists("health_manager.spec"): + os.remove("health_manager.spec") + if os.path.exists("shopper.spec"): + os.remove("shopper.spec") + +if __name__ == "__main__": + new_version_code = None + if args.version != "": + print(f"Releasing new version: {args.version}") + os.system(f"git checkout -b new-release-v{args.version}") + botty_dir = f"botty_v{args.version}" + version_code = "" + with open('src/version.py', 'r') as f: + version_code = f.read() + version_code = version_code.split("=") + new_version_code = f"{version_code[0]}= '{args.version}'" + with open('src/version.py', 'w') as f: + f.write(new_version_code) + else: + botty_dir = f"botty_v{__version__}" + print(f"Building version: {__version__}") + + clean_up() + + if os.path.exists(botty_dir): + for path in Path(botty_dir).glob("**/*"): + if path.is_file(): + os.remove(path) + elif path.is_dir(): + shutil.rmtree(path) + shutil.rmtree(botty_dir) + + botty_env = _resolve_botty_env(args.conda_path) + pyinstaller_exe = os.path.join(botty_env, "Scripts", "pyinstaller.exe") + if not os.path.isfile(pyinstaller_exe): + raise RuntimeError(f"PyInstaller not found at {pyinstaller_exe}. " + f"Install with: pip install pyinstaller") + + # DLL dirs for PyInstaller to resolve native dependencies. + # Conda: Library\bin, Library\lib, DLLs + # pip/virtualenv: just the system DLLs under sys.prefix + dll_dirs = [] + for d in ["Library/bin", "Library/lib", "DLLs"]: + p = os.path.join(botty_env, d) + if os.path.isdir(p): + dll_dirs.append(p) + if dll_dirs: + os.environ["PATH"] = os.pathsep.join(dll_dirs) + os.pathsep + os.environ.get("PATH", "") + + for exe in ["main.py", "shopper.py"]: + key_cmd = " " + if args.use_key: + key = Fernet.generate_key().decode("utf-8") + key_cmd = " --key " + key + installer_cmd = f'{pyinstaller_exe} --onefile --noconsole --distpath {botty_dir}{key_cmd} --exclude-module graphviz --exclude-module keyboard --exclude-module mouse --exclude-module pyclick --exclude-module mouseinfo --paths .\\src --paths "{botty_env}\\Lib\\site-packages" src\\{exe}' + ret = os.system(installer_cmd) + if ret != 0: + raise RuntimeError(f"PyInstaller failed for {exe} (exit {ret})") + + os.makedirs(f"{botty_dir}/config", exist_ok=True) + + with open(f"{botty_dir}/config/custom.ini", "w") as f: + f.write("; Add parameters you want to overwrite from param.ini here") + shutil.copy("config/game.ini", f"{botty_dir}/config/") + shutil.copy("config/params.ini", f"{botty_dir}/config/") + shutil.copy("config/shop.ini", f"{botty_dir}/config/") + shutil.copy("config/default.bnip", f"{botty_dir}/config/") + os.makedirs(f"{botty_dir}/config/bnip", exist_ok=True) + shutil.copy("README.md", f"{botty_dir}/") + shutil.copytree("assets", f"{botty_dir}/assets") + shutil.copytree("src", f"{botty_dir}/src") + shutil.copy("environment.yml", f"{botty_dir}/") + shutil.copy("install.bat", f"{botty_dir}/") + shutil.copy("find_python.bat", f"{botty_dir}/") + shutil.copy("run_botty.bat", f"{botty_dir}/") + shutil.copy("run.bat", f"{botty_dir}/") + if os.path.exists("dependencies"): + shutil.copytree("dependencies", f"{botty_dir}/dependencies") + + # Bundle a portable Tesseract so the standalone exe is click-and-run with + # working OCR and no separate install. ocr.py prefers /tesseract/ + # tesseract.exe. Source: TESSERACT_DIR env or the default UB Mannheim path. + # Skipped (with a warning) if not present — the bot still works once the + # user runs install.bat, which sets OCR up the conda way. + tesseract_src = os.environ.get("TESSERACT_DIR", r"C:\Program Files\Tesseract-OCR") + tess_exe = os.path.join(tesseract_src, "tesseract.exe") + if os.path.isfile(tess_exe): + print(f"Bundling Tesseract from {tesseract_src}") + # Copy the exe + DLLs; skip their tessdata (we ship our own trained + # models in assets/tessdata and pass --tessdata-dir to point at them). + os.makedirs(f"{botty_dir}/tesseract", exist_ok=True) + for entry in os.listdir(tesseract_src): + src = os.path.join(tesseract_src, entry) + if os.path.isfile(src) and entry.lower().endswith((".exe", ".dll")): + shutil.copy(src, f"{botty_dir}/tesseract/") + else: + print(f"WARNING: Tesseract not found at {tesseract_src} — release will " + f"rely on install.bat for OCR setup. Set TESSERACT_DIR to bundle it.") + clean_up() + + if args.random_name: + print("Generate random names") + new_name = ''.join(random.choices(string.ascii_letters, k=random.randint(6, 14))) + os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe') + + # Rename main.exe to avoid Warden flagging the obvious name + # In CI/production builds (env BOTTY_NO_RENAME=1) keep main.exe as-is + if not args.random_name and not os.environ.get("BOTTY_NO_RENAME"): + new_name = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) + os.rename(f'{botty_dir}/main.exe', f'{botty_dir}/{new_name}.exe') + print(f"Renamed main.exe -> {new_name}.exe") + + if new_version_code is not None: + os.system(f'git add .') + os.system(f'git commit -m "Bump version to v{args.version}"') \ No newline at end of file diff --git a/tools/desktop_snap.py b/tools/desktop_snap.py new file mode 100644 index 0000000..14078e0 --- /dev/null +++ b/tools/desktop_snap.py @@ -0,0 +1,80 @@ +""" +Desktop screenshot tool - captures the full Windows desktop or a specific window. +Usage: + python desktop_snap.py # capture full desktop + python desktop_snap.py D2R # capture D2R window only +Saves to screenshots/desktop_snap.png +""" +import os +import sys +import cv2 +from mss import mss + +SAVE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots", "desktop_snap.png") +os.makedirs(os.path.dirname(SAVE_PATH), exist_ok=True) + + +def snap_full_desktop(): + """Capture the full desktop.""" + with mss() as sct: + img = sct.grab(sct.monitors[1]) # monitors[1] = primary display + # Convert from BGRA to BGR + img_bgr = img.rgb + cv2.imwrite(SAVE_PATH, img_bgr) + print(f"Saved full desktop to: {SAVE_PATH}") + print(f"Shape: {cv2.imread(SAVE_PATH).shape}") + + +def snap_d2r_window(): + """Capture the D2R window.""" + import numpy as np + import win32gui + import win32ui + import win32con + + # Find D2R window + def enum_cb(hwnd, results): + if win32gui.IsWindowVisible(hwnd): + title = win32gui.GetWindowText(hwnd) + if "diablo" in title.lower() or "d2r" in title.lower(): + results.append(hwnd) + + hwnds = [] + win32gui.EnumWindows(enum_cb, hwnds) + + if not hwnds: + print("ERROR: D2R window not found. Is it running?") + return + + hwnd = hwnds[0] + print(f"Found D2R window: {win32gui.GetWindowText(hwnd)}") + + # Get window client area + rect = win32gui.GetClientRect(hwnd) + w, h = rect[2] - rect[0], rect[3] - rect[1] + + # Capture client area + hdc = win32gui.GetDC(hwnd) + hdc_mem = win32gui.CreateCompatibleDC(hdc) + bmp = win32gui.CreateCompatibleBitmap(hdc, w, h) + win32gui.SelectObject(hdc_mem, bmp) + win32gui.BitBlt(hdc_mem, 0, 0, w, h, hdc, 0, 0, win32con.SRCCOPY) + + # Convert to image + bmp_info = win32ui.CreateBitmapFromHandle(bmp) + bmp_info.SaveBitmapFile(hdc_mem, SAVE_PATH) + + win32gui.DeleteObject(bmp) + win32gui.DeleteDC(hdc_mem) + win32gui.ReleaseDC(hwnd, hdc) + + img = cv2.imread(SAVE_PATH) + print(f"Saved D2R window to: {SAVE_PATH}") + print(f"Shape: {img.shape}") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "D2R": + snap_d2r_window() + else: + snap_full_desktop() diff --git a/tools/quest_debug.py b/tools/quest_debug.py new file mode 100644 index 0000000..a4cd86b --- /dev/null +++ b/tools/quest_debug.py @@ -0,0 +1,224 @@ +""" +D2R capture tool - works with Windows DPI scaling. + +Set DPI awareness then grab the D2R client area directly. + +Keys: F1-full OCR F2-dialogue F3-questlog F4-NPCs F5-pixel F12-exit +""" +import os, sys, cv2, numpy as np, keyboard, win32gui, win32con, ctypes +from datetime import datetime + +# Set DPI awareness - this makes Win32 APIs return logical (unscaled) coordinates +try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) +except: + try: + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except: + pass + +# Fix tesserocr DLLs +if sys.platform == "win32": + _dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin") + if os.path.isdir(_dll): + os.add_dll_directory(_dll) + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")) + +SAVE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots", "debug") +os.makedirs(SAVE, exist_ok=True) + + +def find_d2r(): + hwnds = [] + def cb(h, r): + if 'diablo' in win32gui.GetWindowText(h).lower() and win32gui.IsWindowVisible(h): + r.append(h) + win32gui.EnumWindows(cb, hwnds) + return hwnds[0] if hwnds else None + + +def grab(): + """Grab D2R client area at native 1280x720 resolution.""" + from mss import mss + hwnd = find_d2r() + if not hwnd: + print(" [ERROR] D2R not found") + return None + + client = win32gui.GetClientRect(hwnd) + w, h = client[2]-client[0], client[3]-client[1] + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + + with mss() as sct: + region = { + 'top': screen_pos[1], + 'left': screen_pos[0], + 'width': w, + 'height': h + } + sct_img = sct.grab(region) + img = np.array(sct_img)[:, :, :3] # BGRA -> BGR + + # Resize to 1280x720 if needed + if w != 1280 or h != 720: + img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR) + print(f" [RESIZED] {w}x{h} -> 1280x720") + else: + print(f" [CAPTURED] {w}x{h}") + + return img + + +def ocr(img, roi=None): + try: + from d2r_image.ocr import image_to_text + target = img if roi is None else img[roi[1]:roi[1]+roi[3], roi[0]:roi[0]+roi[2]] + result = image_to_text(target, psm=6, scale=1.5, threshold=25) + return [r.text.strip() for r in result if r.text.strip()] + except Exception as e: + return [f"[OCR ERROR] {e}"] + + +def save(img, label): + path = os.path.join(SAVE, f"{label}_{datetime.now().strftime('%H%M%S')}.png") + cv2.imwrite(path, img) + print(f" [SAVED] {path}") + return path + + +# === Handlers === + +def on_f1(): + print("\n[=== FULL CAPTURE ===]") + img = grab() + if not img: + return + h, w = img.shape[:2] + print(f" Size: {w}x{h}") + save(img, "full") + + # UI detection + print(" UI:") + try: + from ui_manager import ScreenObjects, is_visible + found = False + for name in ['InGame', 'Loading', 'MainMenu', 'OnlineStatus', 'DeathScreen', + 'NPCDialogue', 'RightPanel', 'LeftPanel', 'SkillsExpanded']: + obj = getattr(ScreenObjects, name, None) + if obj and is_visible(obj, img): + print(f" [VISIBLE] {name}") + found = True + if not found: + print(" (none)") + except Exception as e: + print(f" [err] {e}") + + # Full OCR + print(" OCR:") + lines = ocr(img) + for l in lines[:30]: + print(f" {l}") + if len(lines) > 30: + print(f" ... and {len(lines)-30} more") + + +def on_f2(): + print("\n[=== DIALOGUE ===]") + img = grab() + if not img: + return + save(img, "dialogue") + + text = ocr(img, (200, 460, 880, 100)) + if text: + print(" NPC says:") + for l in text: + print(f" {l}") + else: + print(" (no NPC text)") + + opts = ocr(img, (200, 560, 880, 140)) + if opts: + print(" Options:") + for i, o in enumerate(opts): + print(f" [{i}] {o}") + else: + print(" (no options detected - is dialogue box open?)") + + +def on_f3(): + print("\n[=== QUEST LOG ===]") + img = grab() + if not img: + return + save(img, "quest_log") + for l in ocr(img, (200, 100, 880, 520)): + print(f" {l}") + + +def on_f4(): + print("\n[=== NPC DETECTION ===]") + img = grab() + if not img: + return + save(img, "npcs") + try: + import template_finder + from npc_manager import npcs + found = [] + for name, data in npcs.items(): + for t in data.get("template_group", []): + r = template_finder.search(t, img, threshold=0.35) + if r.valid: + found.append(f" {name} at {r.center_monitor} ({r.score:.2f})") + break + for f in found: + print(f) + if not found: + print(" (none)") + except Exception as e: + print(f" [ERROR] {e}") + + +def on_f5(): + img = grab() + if not img: + return + import mouse as _mouse + mx, my = _mouse.get_position() + hwnd = find_d2r() + if hwnd: + screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) + ix = mx - screen_pos[0] + iy = my - screen_pos[1] + if 0 <= ix < img.shape[1] and 0 <= iy < img.shape[0]: + b, g, r = img[iy, ix] + print(f" ({ix},{iy}) RGB({r},{g},{b})") + else: + print(" Mouse outside D2R client area") + + +# === Run === + +def run(): + print("=== Botty Capture Tool ===") + print(" F1 - Full capture + OCR + UI detection") + print(" F2 - Dialogue capture + OCR") + print(" F3 - Quest log OCR (press O in D2R first)") + print(" F4 - Detect NPCs") + print(" F5 - Mouse pixel color") + print(" F12 - Exit") + print("Ready.") + + keyboard.add_hotkey('f1', on_f1) + keyboard.add_hotkey('f2', on_f2) + keyboard.add_hotkey('f3', on_f3) + keyboard.add_hotkey('f4', on_f4) + keyboard.add_hotkey('f5', on_f5) + keyboard.add_hotkey('f12', lambda: (print("\nBye."), sys.exit(0))) + keyboard.wait() + + +if __name__ == "__main__": + run() diff --git a/tools/quest_screenshot_tool.py b/tools/quest_screenshot_tool.py new file mode 100644 index 0000000..8443768 --- /dev/null +++ b/tools/quest_screenshot_tool.py @@ -0,0 +1,246 @@ +""" +Quest screenshot capture tool. +Guides you through capturing specific game screens needed for building +the quest system. + +Usage: + 1. Launch D2R, create/select your character + 2. Run: python quest_screenshot_tool.py + 3. Follow the prompts + +Screenshots saved to screenshots/quest/ +""" + +import os +import sys +import time +import numpy as np +import cv2 +from datetime import datetime + +# Fix tesserocr DLL loading +if sys.platform == "win32": + _conda_dll_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "conda_env", "Library", "bin") + if not os.path.isdir(_conda_dll_dir): + _conda_dll_dir = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin") + if os.path.isdir(_conda_dll_dir): + os.add_dll_directory(_conda_dll_dir) + +from screen import find_and_set_window_position, get_offset_state, grab as screen_grab + +QUEST_DIR = "screenshots/quest" +os.makedirs(QUEST_DIR, exist_ok=True) + + +def capture(): + """Capture the D2R window using botty's grab().""" + find_and_set_window_position() + if not get_offset_state(): + print("[ERROR] Could not find D2R window. Is D2R running and visible?") + return None + return screen_grab(force_new=True) + + +def save(img, name): + """Save with timestamp prefix.""" + ts = datetime.now().strftime("%H%M%S") + path = os.path.join(QUEST_DIR, f"{ts}_{name}.png") + cv2.imwrite(path, img) + abs_path = os.path.abspath(path) + print(f" Saved: {abs_path}") + return abs_path + + +STEPS = [ + { + "name": "act1_town_overview", + "instructions": ( + "\nSTEP 1: Act 1 Town Overview\n" + "----------------------------------------\n" + "1. Stand in the middle of Act 1 town (New Tristram)\n" + "2. Make sure NO menus are open (close inventory, skills, etc.)\n" + "3. Position your character so all NPCs are visible\n" + "Press ENTER when the screen shows the full town...\n" + ), + }, + { + "name": "akara_dialogue_first", + "instructions": ( + "\nSTEP 2: Akara - First Dialogue Screen\n" + "----------------------------------------\n" + "1. Walk up to Akara and LEFT-CLICK her\n" + "2. The dialogue box should appear with options\n" + "3. DO NOT click any option - just show this screen\n" + "Press ENTER when the first dialogue is visible...\n" + ), + }, + { + "name": "akara_dialogue_second", + "instructions": ( + "\nSTEP 3: Akara - Second Dialogue Screen\n" + "----------------------------------------\n" + "1. Click the FIRST dialogue option (usually the quest-related one)\n" + "2. The next set of options should appear\n" + "3. This shows the quest dialogue choices\n" + "4. DO NOT click any option - just show this screen\n" + "Press ENTER when the second dialogue is visible...\n" + ), + }, + { + "name": "akara_quest_given", + "instructions": ( + "\nSTEP 4: Quest Given Notification\n" + "----------------------------------------\n" + "1. Click the quest-related option to accept the quest\n" + "2. After the dialogue completes, press ESC to close it\n" + "3. Show the screen with the quest notification/text\n" + " (a message should appear on screen about the quest)\n" + "Press ENTER when you see the quest notification...\n" + ), + }, + { + "name": "quest_log_open", + "instructions": ( + "\nSTEP 5: Quest Log\n" + "----------------------------------------\n" + "1. Press 'O' to open the Quest Log\n" + "2. The quest log panel should be visible\n" + "3. Show the full quest log\n" + "Press ENTER when the quest log is open...\n" + ), + }, + { + "name": "charsi_dialogue", + "instructions": ( + "\nSTEP 6: Charsi NPC Dialogue\n" + "----------------------------------------\n" + "1. Walk up to Charsi\n" + "2. LEFT-CLICK her to open dialogue\n" + "3. Show the first dialogue screen\n" + "Press ENTER when Charsi's dialogue is open...\n" + ), + }, + { + "name": "kashya_dialogue", + "instructions": ( + "\nSTEP 7: Kashya NPC Dialogue\n" + "----------------------------------------\n" + "1. Walk up to Kashya (the skill teacher)\n" + "2. LEFT-CLICK her to open dialogue\n" + "3. Show the first dialogue screen\n" + "Press ENTER when Kashya's dialogue is open...\n" + ), + }, + { + "name": "sewer_entrance", + "instructions": ( + "\nSTEP 8: Sewer / Rat Area Entrance\n" + "----------------------------------------\n" + "1. Go to the sewer entrance (south of town)\n" + "2. Stand near the entrance looking into the rat area\n" + "3. This is for the first quest (kill rats)\n" + "Press ENTER when you can see the rat area...\n" + ), + }, + { + "name": "item_on_ground", + "instructions": ( + "\nSTEP 9: Item on the Ground\n" + "----------------------------------------\n" + "1. Kill some rats in the sewer\n" + "2. If a quest item drops (gold glow), show it on the ground\n" + "3. If no quest item drops, show ANY item on the ground\n" + "4. The item name tooltip should be visible\n" + "Press ENTER when an item is visible on the ground...\n" + ), + }, + { + "name": "inventory_with_item", + "instructions": ( + "\nSTEP 10: Inventory with Item Tooltip\n" + "----------------------------------------\n" + "1. Pick up the item\n" + "2. Press 'I' to open inventory\n" + "3. Hover over the item to show its tooltip\n" + "4. Show the tooltip with the item name visible\n" + "Press ENTER when the item tooltip is visible...\n" + ), + }, + { + "name": "dialogue_box_full", + "instructions": ( + "\nSTEP 11: Full Dialogue Box (Any NPC)\n" + "----------------------------------------\n" + "1. Talk to ANY NPC\n" + "2. Get to a screen with 3+ dialogue options\n" + "3. Show the full dialogue box with all options visible\n" + "4. This helps us measure the dialogue button positions\n" + "Press ENTER when a dialogue with multiple options is visible...\n" + ), + }, + { + "name": "game_start_menu", + "instructions": ( + "\nSTEP 12: Game Start / Difficulty Selection\n" + "----------------------------------------\n" + "1. Save & Exit to return to hero selection\n" + "2. Click Play (or let botty do it)\n" + "3. Show the difficulty selection screen\n" + " (Normal/Nightmare/Hell buttons)\n" + "Press ENTER when the difficulty screen is visible...\n" + ), + }, +] + + +def run(): + print("=" * 60) + print(" Botty Quest Screenshot Tool") + print("=" * 60) + print() + print("Make sure D2R is running and visible on screen.") + print("You'll be guided through capturing each needed screen.") + print() + print("Press ENTER to start...") + input() + + captured = [] + failed = [] + + for i, step in enumerate(STEPS, 1): + print() + print(step["instructions"]) + + try: + input() # wait for user + img = capture() + if img is not None: + path = save(img, step["name"]) + captured.append((step["name"], path)) + print(f" [OK] {step['name']}") + else: + print(f" [FAIL] Could not capture for {step['name']}") + failed.append(step["name"]) + except KeyboardInterrupt: + print("\n[STOPPED]") + break + + # Summary + print() + print("=" * 60) + print(" Capture Summary") + print("=" * 60) + print(f" Captured: {len(captured)}/{len(STEPS)}") + for name, path in captured: + print(f" [OK] {name}") + if failed: + print(f" Failed: {len(failed)}") + for name in failed: + print(f" [XX] {name}") + print() + print(f"All screenshots in: {os.path.abspath(QUEST_DIR)}") + print() + + +if __name__ == "__main__": + run() diff --git a/tools/run_asset_extractor.bat b/tools/run_asset_extractor.bat new file mode 100644 index 0000000..ac9bca5 --- /dev/null +++ b/tools/run_asset_extractor.bat @@ -0,0 +1,13 @@ +@echo off +setlocal +set "BOTTY_DIR=%~dp0" +cd /d "%BOTTY_DIR%" + +call "%BOTTY_DIR%find_python.bat" + +echo === D2R Quick Capture === +echo Run this, D2R must be visible +echo Press ENTER when D2R is ready... +pause >nul + +%PYTHON% "%BOTTY_DIR%asset_extractor.py" diff --git a/tools/start_bot_detached.bat b/tools/start_bot_detached.bat new file mode 100644 index 0000000..626eafe --- /dev/null +++ b/tools/start_bot_detached.bat @@ -0,0 +1,9 @@ +@echo off +:: Launch botty detached with console output captured to log\console_.log +:: (used for unattended/remote starts where no interactive console exists) +cd /d "C:\Users\alex\Downloads\my-botty" +:: Single-instance guard: F11/F12 are GLOBAL hotkeys, so two bot instances +:: receive every press and fight each other (one starts, the other pauses). +powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | Where-Object {$_.CommandLine -like '*my-botty*main.py*'} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" +set "TS=%RANDOM%" +call "C:\Users\alex\Downloads\my-botty\run_botty.bat" > "C:\Users\alex\Downloads\my-botty\log\console_%TS%.log" 2>&1 diff --git a/tools/start_botty.ps1 b/tools/start_botty.ps1 new file mode 100644 index 0000000..0b1a21d --- /dev/null +++ b/tools/start_botty.ps1 @@ -0,0 +1,11 @@ +$taskName = 'RunBottyNow' +Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + +$action = New-ScheduledTaskAction -Execute 'C:\Users\alex\.conda\envs\botty\python.exe' -Argument 'C:\Users\alex\Downloads\my-botty\src\main.py' -WorkingDirectory 'C:\Users\alex\Downloads\my-botty' +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +$principal = New-ScheduledTaskPrincipal -UserId 'alex' -LogonType Interactive -RunLevel Limited +Register-ScheduledTask -TaskName $taskName -Action $action -Settings $settings -Principal $principal -Force +Start-ScheduledTask -TaskName $taskName +Start-Sleep -Seconds 5 +Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +Get-Process python -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq 1 } | Select-Object Id,SessionId,StartTime | Format-Table