feat(tools): add launch_d2r — direct exe launch with a Play-button fallback

Launching D2R by hand kept failing in two different ways, both silent:

1. D2R.exe direct is fast but can come up with "Cannot Connect to Server" when
   the client has no Battle.net session.
2. The launcher's Play button always yields an authenticated client, but a
   hardcoded coordinate for it clicks the DESKTOP whenever the launcher has
   moved, been minimised to tray, or is DPI-scaled — which opened unrelated
   applications rather than reporting a failure.

So: try the exe, fall back to Play, and find Play by COLOUR rather than a fixed
point. It is the large saturated-blue block in the launcher; sampled live it is
HSV ~(104, 255, 122), and the value channel being that low is why a naive
"bright blue" threshold matches nothing.

show_launcher() also restores/maximises the window first, since the button
cannot be found while the launcher is hidden in the tray.

Never passes params.ini launch_options: those resolve to "-mod profile -txt",
and -mod puts D2R in offline mode where ladder does not exist.

client_size() imports utils.misc for its side effect of setting per-monitor DPI
awareness. Without it GetClientRect returns logical pixels, so a correct
1280x720 client reads as 1024x576 under 125% scaling and looks like a
resolution fault that is not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-23 08:30:39 +02:00
parent c8a6657b0a
commit ce930f2a76

190
tools/launch_d2r.py Normal file
View File

@@ -0,0 +1,190 @@
"""
Launch D2R, preferring the direct exe and falling back to the launcher's Play button.
Direct D2R.exe is fast and needs no UI driving, but it can come up with
"Cannot Connect to Server" when the client has no Battle.net session. The
launcher's Play button always produces an authenticated client, so it is the
fallback.
<botty-env-python> tools/launch_d2r.py # direct, fall back to Play
<botty-env-python> tools/launch_d2r.py --play # skip straight to Play
<botty-env-python> tools/launch_d2r.py --check # just report what is running
The Play button is located by COLOUR, not a fixed coordinate: Battle.net's
window moves, gets hidden to tray and is DPI-scaled, and a hardcoded point
silently clicks the desktop when it is wrong (which is exactly what happened
before this existed). We look for the large saturated-blue rectangle in the
lower-left of the launcher instead.
"""
import argparse
import os
import subprocess
import sys
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "src"))
D2R_EXE = r"C:\Program Files (x86)\Diablo II Resurrected\D2R.exe"
BNET_EXE = r"C:\Program Files (x86)\Battle.net\Battle.net Launcher.exe"
def d2r_running() -> bool:
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq D2R.exe"],
capture_output=True, text=True).stdout
return "D2R.exe" in out
def wait_for_d2r(timeout: float) -> bool:
end = time.time() + timeout
while time.time() < end:
if d2r_running():
return True
time.sleep(3)
return False
def client_size():
"""(w, h) of the D2R client area in PHYSICAL pixels, or None.
Without DPI awareness GetClientRect returns logical pixels, so a correct
1280x720 client reads as 1024x576 under 125% scaling and looks like a
resolution fault that is not there. utils.misc sets per-monitor awareness on
import, which is why the bot's own screen.py reports the true size.
"""
try:
import utils.misc # noqa: F401 (import sets process DPI awareness)
import win32gui
found = []
def cb(h, _):
if win32gui.IsWindowVisible(h) and win32gui.GetWindowText(h) == "Diablo II: Resurrected":
l, t, r, b = win32gui.GetClientRect(h)
found.append((r - l, b - t))
win32gui.EnumWindows(cb, None)
return found[0] if found else None
except Exception:
return None
def find_play_button():
"""Locate the launcher's blue Play button on screen. Returns (x, y) or None."""
import cv2
import numpy as np
import mss
with mss.mss() as s:
img = np.array(s.grab(s.monitors[0]))[:, :, :3]
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# Battle.net's Play button is a large, strongly saturated blue block.
# Sampled from the live button: HSV ~(104, 255, 122). The value channel is
# only ~122, so a high V floor silently matches nothing.
mask = cv2.inRange(hsv, np.array([98, 200, 70]), np.array([112, 255, 255]))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
best = None
for c in cnts:
x, y, w, h = cv2.boundingRect(c)
if w < 150 or h < 40 or h > 130:
continue
if not (2.0 < w / h < 7.0): # wide, short button
continue
if best is None or w * h > best[2] * best[3]:
best = (x, y, w, h)
if best is None:
return None
x, y, w, h = best
return (x + w // 2, y + h // 2)
def show_launcher():
"""Make sure the Battle.net window exists and is visible."""
import win32con
import win32gui
hwnd = []
def cb(h, _):
if win32gui.IsWindowVisible(h) and win32gui.GetWindowText(h) == "Battle.net":
l, t, r, b = win32gui.GetWindowRect(h)
if r - l > 500:
hwnd.append(h)
win32gui.EnumWindows(cb, None)
if not hwnd:
if not os.path.exists(BNET_EXE):
print(f"launcher exe not found: {BNET_EXE}")
return False
subprocess.Popen([BNET_EXE])
for _ in range(20):
time.sleep(2)
hwnd.clear()
win32gui.EnumWindows(cb, None)
if hwnd:
break
if not hwnd:
return False
try:
if win32gui.IsIconic(hwnd[0]):
win32gui.ShowWindow(hwnd[0], win32con.SW_RESTORE)
win32gui.ShowWindow(hwnd[0], win32con.SW_MAXIMIZE)
time.sleep(2.0)
except Exception as e:
print(f"could not raise launcher: {e}")
return True
def click_play() -> bool:
from input_layer import mouse
from utils.misc import wait
if not show_launcher():
print("FALLBACK FAILED: no Battle.net launcher window")
return False
pos = find_play_button()
if pos is None:
print("FALLBACK FAILED: could not find the blue Play button on screen")
return False
print(f"clicking Play at {pos}")
mouse.move(*pos, randomize=4)
wait(0.2, 0.3)
mouse.click(button="left")
return True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--play", action="store_true", help="skip the direct exe, use the launcher")
ap.add_argument("--check", action="store_true", help="report state only")
ap.add_argument("--timeout", type=float, default=90, help="seconds to wait for the window")
opts = ap.parse_args()
if opts.check or d2r_running():
print(f"D2R running: {d2r_running()} client size: {client_size()}")
if opts.check:
return 0
return 0
if not opts.play:
if os.path.exists(D2R_EXE):
# No launch options on purpose: params.ini resolves to "-mod profile
# -txt", and -mod puts D2R in OFFLINE mode where ladder does not
# exist. Never inherit them here.
print("launching D2R.exe directly (no mod flags)")
subprocess.Popen([D2R_EXE], cwd=os.path.dirname(D2R_EXE))
if wait_for_d2r(opts.timeout):
print(f"D2R up. client size: {client_size()}")
print("NOTE: if it shows 'Cannot Connect to Server', re-run with --play")
return 0
print("direct launch produced no window — falling back to the launcher")
else:
print(f"D2R.exe not found at {D2R_EXE} — falling back to the launcher")
if not click_play():
return 2
if wait_for_d2r(opts.timeout):
print(f"D2R up via launcher. client size: {client_size()}")
return 0
print("ERROR: D2R did not start")
return 3
if __name__ == "__main__":
sys.exit(main())