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:
190
tools/launch_d2r.py
Normal file
190
tools/launch_d2r.py
Normal 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())
|
||||
Reference in New Issue
Block a user