From 6012934e661469578f2bbca691608a72ce489b95 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sun, 21 Jun 2026 16:06:58 +0200 Subject: [PATCH] Add chipped gem conversion runner --- docs/gems_transmute_flow.md | 87 ++++++++++++++++++++++ run_chipped_gems.bat | 35 +++++++++ tools/convert_chipped_gems.py | 132 ++++++++++++++++++++++++++++++++++ tools/testbed.py | 2 + 4 files changed, 256 insertions(+) create mode 100644 docs/gems_transmute_flow.md create mode 100644 run_chipped_gems.bat create mode 100644 tools/convert_chipped_gems.py diff --git a/docs/gems_transmute_flow.md b/docs/gems_transmute_flow.md new file mode 100644 index 0000000..b7b1841 --- /dev/null +++ b/docs/gems_transmute_flow.md @@ -0,0 +1,87 @@ +# GEMS-tab transmute flow (rewritten in commit 48d8445) + +`src/transmute/transmute.py` — `Transmute.convert_all_gems()` and helpers. + +## What changed and why + +The old flow drove gem upgrades through the **Horadric Cube UI**: it required the cube to live in +the PERSONAL stash tab, opened the cube, ctrl+shift+right-clicked 3 gems into it, clicked the cube's +Transmute button, then pulled the result back out. This was fragile — it depended on cube placement, +cube-open detection (`ScreenObjects.CubeOpened` via `wait_until_visible`), and a pre-flight +"empty the cube" pass. + +The rewrite uses D2R's **native GEMS-tab convert panel** instead of the cube. Gems are moved +directly between the GEMS stack grid and the convert panel, and conversion uses the GEMS tab's own +convert button. The cube is no longer required to be in the stash and, if present in the character +inventory, is left untouched. + +## Fixed stack-coordinate grid + +`GEMS_TAB_STACK_COORDS` is a new module-level dict mapping every gem template +(`INVENTORY__`) to a fixed `(x, y)` **screen** coordinate at 1280×720. The grid is: + +- **Columns by family** (x): diamond 85, emerald 132, ruby 180, topaz 227, amethyst 275, + sapphire 322, skull 370. +- **Rows by tier** (y): chipped 154, flawed 195, standard 234, flawless 273, perfect 311. + +Because gem stacks always render at these fixed slots on the GEMS tab, the flow can click them by +coordinate instead of template-searching for them each time. `_gems_stack_monitor_for(template)` +converts the screen coord to monitor coords (returns `None` if the template isn't in the grid, so +the caller can fall back to a template search). + +## Panel constants (1280×720) + +| Constant | Value | Meaning | +|----------|-------|---------| +| `GEMS_TAB_X` | `223` | GEMS tab click X (was `200`) | +| `GEMS_CONVERT_BUTTON` | `(225, 500)` | native convert button in the GEMS panel | +| `GEMS_CONVERT_PANEL_ROI` | `(160, 296, 128, 160)` | ROI to search for the converted result | +| `GEMS_CONVERT_FIRST_SLOT` | `(181, 318)` | fallback slot to grab the result if the search misses | + +## Per-transmute flow (new) + +For each gem type/tier with enough gems to convert: + +1. **Switch to GEMS tab.** +2. **Load 3 gems** — `_ctrl_shift_left_click_monitor` the stack slot ×3 to move 3 gems into the + convert panel. If exactly 3 weren't loaded, abort that batch. +3. **Convert** — `_click_gems_tab_convert_button()` clicks the panel's native convert button. +4. **Recover the result** — template-search `gems_out` within `GEMS_CONVERT_PANEL_ROI`; if found, + ctrl+shift+left-click it back onto the GEMS tab. If not found, fall back to ctrl+shift+left-click + on `GEMS_CONVERT_FIRST_SLOT` (instead of the old behavior of clearing the cube and bailing). +5. **Leave the inventory cube untouched** and return to the GEMS tab for the next iteration. + +The old pre-flight "open cube → empty 12 cube slots → reopen stash" pass and the per-iteration +"close cube / reopen stash" steps are **removed**. + +## Helper changes + +- `_open_cube_from_stash()` / `_open_cube_from_gems_tab()` / `_open_available_cube_for_gems_tab()` + now operate from the **GEMS** tab rather than PERSONAL, and can open the cube from character + inventory (`_open_cube_from_inventory`) when the GEMS tab is active. These remain for older + inventory flows; the new gem flow doesn't need them. +- `_locate_cube()` can now return `'opened'` (cube UI already open on the GEMS tab) in addition to + `'stash'` / `'inventory'`. +- `_ensure_cube_available()` now treats `inventory` as usable ("it will stay there") instead of an + error — the cube no longer has to be moved to PERSONAL. +- `_ensure_cube_in_stash()` dropped the loot-column safety guard: it now ctrl+clicks any positive + `CubeInventory` match, so the cube can be moved from reserved inventory columns too. +- **Keyboard modifier handling** switched from the low-level + `win_input.key_down/key_up(_get_vk(...))` to `keyboard.send(..., do_release=False)` / + `keyboard.release(...)` wrapped in `try/finally`, so ctrl+shift are always released even if the + click raises. Applies to both `_ctrl_shift_click_monitor` (right) and + `_ctrl_shift_left_click_monitor` (left). +- `_empty_cube_to_gems_tab()` now uses `_ctrl_click_monitor` (plain ctrl) rather than ctrl+shift. + +## Gem counting (`_count_gems_by_ocr`) + +Counting no longer template-searches the left-inventory ROI for each gem. It now reads each fixed +slot from `GEMS_TAB_STACK_COORDS`: crop the slot icon, and **skip empty slots** by checking the +95th-percentile grayscale brightness (`< 45` ⇒ no gem present). Occupied slots still OCR the count +badge in the bottom-right quadrant. This is faster and immune to template-match drift, at the cost +of depending on the fixed 1280×720 grid. + +## Resolution assumption + +The whole flow is hard-coded to **1280×720**. All coordinates above are screen coordinates at that +resolution, converted to monitor coordinates at click time via `convert_screen_to_monitor`. diff --git a/run_chipped_gems.bat b/run_chipped_gems.bat new file mode 100644 index 0000000..f646b67 --- /dev/null +++ b/run_chipped_gems.bat @@ -0,0 +1,35 @@ +@echo off +setlocal enabledelayedexpansion + +rem Chipped-only GEMS-tab converter. +rem Requirements: +rem - D2R is open +rem - character is in-game +rem - stash UI is open on or near the GEMS tab +rem This script does not open or click the character-inventory cube. + +set "BOTTY_DIR=%~dp0" +cd /d "%BOTTY_DIR%" + +call "%BOTTY_DIR%find_python.bat" +if errorlevel 1 exit /b 1 + +for %%F in ("%PYTHON%") do set "_ENV=%%~dpF" +set "_ENV=%_ENV:~0,-1%" + +set "PATH=%_ENV%;%_ENV%\Scripts;%_ENV%\Library\bin;%_ENV%\Library\mingw-w64\bin;%_ENV%\Library\usr\bin;%_ENV%\DLLs;%PATH%" +set "CONDA_PREFIX=%_ENV%" +set "PYTHONUTF8=1" +set "PYTHONIOENCODING=utf-8" +set "SSL_CERT_DIR=" +set "TESSDATA_PREFIX=%_ENV%\Library\share" +set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe" + +echo Running chipped-only GEMS converter... +echo Before/after screenshots and a JSON summary will be written to fixtures\screenshots. +echo. + +"%PYTHON%" "%BOTTY_DIR%tools\convert_chipped_gems.py" %* +set "RC=%ERRORLEVEL%" +pause +exit /b %RC% diff --git a/tools/convert_chipped_gems.py b/tools/convert_chipped_gems.py new file mode 100644 index 0000000..eb6eb4e --- /dev/null +++ b/tools/convert_chipped_gems.py @@ -0,0 +1,132 @@ +""" +Chipped-only GEMS-tab conversion runner. + +This is intentionally narrower than tools/testbed.py gems_all: +- stash must already be open in-game +- only chipped gems are converted +- the character-inventory cube is not touched +- before/after screenshots and count snapshots are saved for review +""" +from __future__ import annotations + +import ctypes +import json +import os +import ssl +import sys +from datetime import datetime +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +OUT_DIR = ROOT / "fixtures" / "screenshots" + + +def _patch_ssl() -> None: + ssl.SSLContext.load_default_certs = lambda *args, **kwargs: None + + +def _setup_imports() -> None: + sys.path.insert(0, str(SRC)) + _patch_ssl() + if os.name == "nt": + ctypes.windll.user32.SetProcessDPIAware() + + +def _save_screen(name: str): + from screen import grab + import cv2 + + OUT_DIR.mkdir(parents=True, exist_ok=True) + path = OUT_DIR / name + cv2.imwrite(str(path), grab()) + return path + + +def _print_plan(plan: list[tuple[int, str, list[str], str]]) -> None: + if not plan: + print("No chipped gem stacks with 3+ gems were detected.") + return + print("Planned chipped conversions:") + for n_transmutes, gem_template, gems_out, _tier in plan: + target = gems_out[0] if gems_out else "UNKNOWN" + print(f" {n_transmutes}x {gem_template} -> {target}") + + +def main() -> int: + _setup_imports() + + from game_stats import GameStats + from screen import start_detecting_window, stop_detecting_window + from transmute import Transmute + + run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + transmute = Transmute(GameStats()) + + start_detecting_window() + try: + before_path = _save_screen(f"chipped_script_{run_id}_before.png") + before_counts = transmute._count_gems_for_plan() + before_plan = transmute._filter_transmute_plan( + transmute._plan_transmutes(before_counts), + tier_filter=["chipped"], + ) + + _print_plan(before_plan) + if not before_plan: + return 0 + + transmute.convert_all_gems_to_perfect(tier_filter=["chipped"]) + + after_path = _save_screen(f"chipped_script_{run_id}_after.png") + after_counts = transmute._count_gems_for_plan() + after_plan = transmute._filter_transmute_plan( + transmute._plan_transmutes(after_counts), + tier_filter=["chipped"], + ) + + summary = { + "run_id": run_id, + "before_screenshot": str(before_path.relative_to(ROOT)), + "after_screenshot": str(after_path.relative_to(ROOT)), + "before_counts": before_counts, + "after_counts": after_counts, + "planned_before": [ + { + "count": n, + "input": gem_template, + "output": gems_out[0] if gems_out else None, + "tier": tier, + } + for n, gem_template, gems_out, tier in before_plan + ], + "remaining_chipped_plan": [ + { + "count": n, + "input": gem_template, + "output": gems_out[0] if gems_out else None, + "tier": tier, + } + for n, gem_template, gems_out, tier in after_plan + ], + } + summary_path = OUT_DIR / f"chipped_script_{run_id}_summary.json" + summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + + print(f"Before screenshot: {before_path.relative_to(ROOT)}") + print(f"After screenshot: {after_path.relative_to(ROOT)}") + print(f"Summary: {summary_path.relative_to(ROOT)}") + if after_plan: + print("Warning: some chipped stacks still appear convertible after the run:") + _print_plan(after_plan) + else: + print("Done: no chipped stack with 3+ gems remains in the plan.") + finally: + stop_detecting_window() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/testbed.py b/tools/testbed.py index 5308631..0c936b7 100644 --- a/tools/testbed.py +++ b/tools/testbed.py @@ -18,6 +18,8 @@ Scenarios: standard->flawless->perfect (in a game, stash OPEN). Optional filters: a tier and/or gem type, e.g. "gems_all diamond" or "gems_all flawless diamond". + For chipped-only conversion with before/after analysis, use + run_chipped_gems.bat instead. stash Force-run the stashing routine: deposit gold + stash keep-flagged inventory items, with page rotation (in a game, stash OPEN). keyo Verify Controls-page key bindings (.keyo) match params (anywhere;