"""Local PC/Steam/Proton control backend (xdotool/scrot wrappers).""" import os import subprocess import time import cv2 from ba_auto import config PROBE_SHOT_PATH = os.path.join(config.SCRATCHPAD_DIR, "probe.png") def run_command(args, **kwargs): kwargs.setdefault("env", config.ENV) kwargs.setdefault("check", True) return subprocess.run(args, **kwargs) def focus_game(): result = run_command( ["xdotool", "search", "--name", config.WINDOW_NAME], check=False, capture_output=True, text=True, ) window_ids = result.stdout.split() if not window_ids: raise RuntimeError("Blue Archive window not found. Is the game running?") run_command(["xdotool", "windowactivate", window_ids[0]]) # windowactivate alone isn't enough -- confirmed live (twice now, see # Handoff.md's original finding and plan.md's Bounty phase for a second # occurrence) that a stray anti-cheat XIGNCODE window can render # visibly on top of the game and intercept clicks at fixed positions # (notably the shared BACK_BUTTON coordinate) even while xdotool still # reports BlueArchive as the "active" window -- windowactivate changes # input focus, not stacking order, so it doesn't fix this by itself. # windowraise does. Cheap and harmless when no overlay is present. run_command(["xdotool", "windowraise", window_ids[0]]) wait(0.5) def click(x, y): # A combined "mousemove X Y click 1" invocation is unreliable here -- # empirically (see plan.md's click-flakiness writeups) the game's input # handler sometimes misses clicks fired before it's processed the move. # Splitting into separate commands with a short pause between fixes it. run_command(["xdotool", "mousemove", str(x), str(y)]) wait(0.2) run_command(["xdotool", "click", "1"]) wait(0.5) def move_mouse(x, y): run_command(["xdotool", "mousemove", str(x), str(y)]) def drag(start_x, start_y, end_x, end_y, duration=0.6, steps=12): """Click-and-drag gesture: mousedown at (start_x, start_y), move to (end_x, end_y) over `duration` seconds in `steps` increments, mouseup. Distinct from scroll()'s wheel-based gesture -- scroll()'s own comment already documents that a plain drag does NOT register as a list-scroll gesture in this Proton client, but that finding was specific to scrollable list widgets (mailbox/lesson/bounty/etc.); a room-view camera (e.g. cafe.py's horizontal pan) is a different UI surface, not a list, and needs an actual drag rather than a wheel click. """ run_command(["xdotool", "mousemove", str(start_x), str(start_y)]) wait(0.2) run_command(["xdotool", "mousedown", "1"]) step_delay = duration / steps for i in range(1, steps + 1): x = start_x + (end_x - start_x) * i // steps y = start_y + (end_y - start_y) * i // steps run_command(["xdotool", "mousemove", str(x), str(y)]) wait(step_delay) run_command(["xdotool", "mouseup", "1"]) wait(0.3) def scroll(x, y, direction, clicks=1): # xdotool button 4/5 = scroll wheel up/down. A drag (mousedown/move/mouseup) # does not register as a list-scroll gesture in this Proton client; the # wheel does. button = "4" if direction == "up" else "5" run_command(["xdotool", "mousemove", str(x), str(y)]) wait(0.2) for _ in range(clicks): run_command(["xdotool", "click", button]) wait(0.15) wait(0.3) def keypress(key): run_command(["xdotool", "key", key]) wait(0.5) def screenshot(path): run_command(["scrot", "-a", "0,0,1920,1200", "-o", path]) def color_at(x, y): screenshot(PROBE_SHOT_PATH) image = cv2.imread(PROBE_SHOT_PATH) b, g, r = image[y, x] return int(r), int(g), int(b) def colors_at(points): """Sample multiple (x, y) points from a single screenshot, instead of one scrot capture per point -- added for navigation.is_header_bar_visible's multi-point header check, which otherwise called color_at() 8 times (8 full-screen captures) for one logical check. Also more correct than looping color_at(): all points come from the exact same frame rather than points sampled sequentially across several hundred ms of separate captures, which could straddle a screen transition. Returns a list of (r, g, b) tuples in the same order as `points`. """ screenshot(PROBE_SHOT_PATH) image = cv2.imread(PROBE_SHOT_PATH) colors = [] for x, y in points: b, g, r = image[y, x] colors.append((int(r), int(g), int(b))) return colors def wait(seconds): time.sleep(seconds) def kill_game(): # check=False -- pkill exits 1 with no output when nothing matches, # which is a normal outcome here (e.g. the process already died on its # own), not an error worth raising on. run_command(["pkill", "-f", config.GAME_PROCESS_NAME], check=False) def launch_game(): # The launch script (see config.GAME_LAUNCH_SCRIPT) blocks until the # game process exits, by design (it's meant to be run as a foreground # session launcher) -- Popen + start_new_session=True detaches it so # this process can keep polling for the window instead of hanging for # the whole play session. subprocess.Popen( [config.GAME_LAUNCH_SCRIPT], env=config.ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True, ) def is_game_running(): result = run_command( ["pgrep", "-f", config.GAME_PROCESS_NAME], check=False, capture_output=True, text=True, ) return bool(result.stdout.strip()) def window_exists(): result = run_command( ["xdotool", "search", "--name", config.WINDOW_NAME], check=False, capture_output=True, text=True, ) return bool(result.stdout.strip())