diff --git a/tools/launch_d2r.py b/tools/launch_d2r.py new file mode 100644 index 0000000..db43724 --- /dev/null +++ b/tools/launch_d2r.py @@ -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. + + tools/launch_d2r.py # direct, fall back to Play + tools/launch_d2r.py --play # skip straight to Play + 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())