"""Shared navigation helpers (home, menu, popups, back/escape).""" # The mailbox/cafe/shop-style header bar renders a plain light background # here; the home screen shows character art instead. SUBSCREEN_HEADER_PROBE = (500, 10) SUBSCREEN_HEADER_MIN_CHANNEL = 200 # Any modal dialog dims the screen behind it to roughly this darkness. MODAL_DIM_PROBE = (960, 200) MODAL_DIM_MAX_CHANNEL = 150 # Shared top-left back-arrow position -- every subscreen calibrated so far # (mailbox, cafe, shop, lesson, event) puts its own back button here # (confirmed identical across config.py's LESSON_BACK_BUTTON/ # SHOP_BACK_BUTTON/EVENT_BACK_BUTTON). Used by return_to_home below as a # generic recovery affordance, independent of which task got stuck. BACK_BUTTON = (85, 55) RETURN_HOME_MAX_ROUNDS = 6 def is_on_subscreen(driver): r, g, b = driver.color_at(*SUBSCREEN_HEADER_PROBE) return r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL def is_modal_open(driver): r, g, b = driver.color_at(*MODAL_DIM_PROBE) return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL # Every confirmed subscreen (mailbox/cafe/shop/lesson/event) renders a # uniform light header bar spanning nearly the full screen width at this y -- # is_on_subscreen only samples one x on that row (SUBSCREEN_HEADER_PROBE), # which is cheap and has been fine for ordinary subscreen-vs-home checks, but # was found live (2026-07-15, cafe.py's rank-up dismiss loop) to misread a # full-screen "絆ランクアップ!" cutscene as "already back on subscreen" for # some characters -- the single x=500 sample happened to land on a bright # patch of that character's own art/background, not the real header. A real # header bar is flat and uniform across its whole width; a photo-real # cutscene composition (hair, uniform, the dark rank-up banner itself) is # very unlikely to coincidentally read bright at MANY spread-out x offsets on # the same row simultaneously. Used where that specific ambiguity matters # (so far only cafe.py's rank-up dismiss) rather than swapped in for # is_on_subscreen everywhere, to avoid changing already-working behavior at # every other call site. HEADER_ROW_Y = 10 HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700) def is_header_bar_visible(driver): # One screenshot for all 8 points (driver.colors_at) rather than 8 # separate color_at() calls -- cheaper, and atomic (every point comes # from the same frame instead of drifting across ~8 sequential captures). points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS] for r, g, b in driver.colors_at(points): if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL): return False return True def _not_home(driver): # "Home" means neither on a subscreen NOR under an open modal. Checking # only is_on_subscreen was found live (2026-07-11, ba_daily.py's # centralized return-to-home audit) to be unsound whenever a modal is # open on top of a subscreen: the modal's own screen-wide dimming # overlay darkens SUBSCREEN_HEADER_PROBE right along with everything # else, so it reads exactly like the true home screen's own dark # header art -- is_on_subscreen returns False in BOTH cases, and # return_to_home was mistaking "subscreen with a stuck modal open" for # "home reached" and stopping immediately without ever pressing # anything. Confirmed via direct pixel comparison: a real stuck event # stage-info modal read (80,81,82) at SUBSCREEN_HEADER_PROBE (fails the # >200 check, same as home), while MODAL_DIM_PROBE correctly read dark # there (51,37,28) and correctly read NOT dark on the true home screen # (126,136,210) -- so checking both together tells the two apart. return is_on_subscreen(driver) or is_modal_open(driver) def return_to_home(driver): """Bounded "press back until the home screen is reached" loop -- the shared recovery path for any task that ends up on an unexpected or wrong subscreen (e.g. event_sweep landing on a stale/finished event's page instead of the current one, see plan.md's Event sweep phase). Generic across tasks: it only depends on is_on_subscreen/is_modal_open and the shared BACK_BUTTON position above, not on any task-specific state. Checks _not_home before every single press and stops the instant it reads False -- never presses Escape/clicks back while already on the home screen. This matters: CLAUDE.md documents a real hazard where a blind Escape press on the home screen itself raises Blue Archive's own "exit the game?" confirmation, which is exactly the failure mode a naive fixed-count blind-press loop could cause here. Escape maps to Cancel on that confirmation dialog too (confirmed live across every dialog in this project), so even a wrongly-timed press against it is safe -- it never presses Enter/OK. Tries Escape first each round (works for most subscreens, confirmed for mailbox/cafe/event's own stage modal) and falls back to clicking BACK_BUTTON if Escape didn't clear it, re-checking after each. Halfway through the round budget, if neither has made any progress, re-raises the game window once via driver.focus_game() before continuing. Confirmed live (2026-07-12, a bounty run): a stray anti-cheat XIGNCODE window can render visibly on top of the game and silently intercept every Escape/BACK_BUTTON press at that exact screen position -- xdotool still reports BlueArchive as the "active" window throughout, so nothing here can detect the overlay directly, only that presses keep not working. windowactivate (already called at the start of every task via driver.focus_game()) doesn't fix this -- it changes input focus, not stacking order -- but windowraise does, confirmed live by reproducing the exact stuck state and watching return_to_home immediately succeed once the window was raised. driver.focus_game() itself now calls windowraise too, so this escalation is a second, later-in-the-budget safety net for the case an overlay appears or becomes input-stealing mid-run, after focus_game()'s own start-of-task call already ran. Returns True once home is confirmed reached, False if still not home after RETURN_HOME_MAX_ROUNDS -- callers should treat False as "abort, don't guess further" rather than assume home was reached. """ escalated = False for round_num in range(1, RETURN_HOME_MAX_ROUNDS + 1): if not _not_home(driver): return True driver.keypress("Escape") driver.wait(1) if not _not_home(driver): return True driver.click(*BACK_BUTTON) driver.wait(1) if not _not_home(driver): return True if not escalated and round_num >= RETURN_HOME_MAX_ROUNDS // 2: escalated = True print("[navigation] still not home halfway through recovery -- re-raising the game window in case a stray overlay (e.g. XIGNCODE) is intercepting input") driver.focus_game() return not _not_home(driver) def click_back(driver, verify_fn, max_attempts=3): """Click the shared BACK_BUTTON with retry, for callers that need to go back exactly ONE level (e.g. a per-region map -> its list screen) rather than return_to_home's "all the way to home" semantics -- so it can't just reuse return_to_home directly, even though it wants the same click-then-verify-then-retry robustness. `verify_fn(driver)` should return True once the click's intended effect is confirmed (whatever that means for the caller's own screen transition). Escalates once, via driver.focus_game() (re-raising the game window), right before the FINAL attempt if every earlier one failed -- the same XIGNCODE-overlay defense return_to_home has (a stray anti-cheat window can silently intercept clicks at this exact shared coordinate even while the game reports itself as the active window; see return_to_home's own docstring for the live-confirmed history). Added 2026-07-12 after an audit found lesson.py's own back-button clicks had no retry or overlay defense at all, unlike return_to_home. Returns True once verify_fn confirms success, False if still unconfirmed after max_attempts. """ for attempt in range(1, max_attempts + 1): if attempt == max_attempts: driver.focus_game() driver.click(*BACK_BUTTON) driver.wait(1.5) if verify_fn(driver): return True return verify_fn(driver) def wait_for_state(driver, config, reactions, ends, max_iterations=30, poll_interval=1.0): """Generic "watch the screen, react to anything recognized, stop once a recognized destination is reached" loop -- the local equivalent of the reference's core/picture.py::co_detect, scoped to what this project actually needs (a handful of named checks) rather than co_detect's full generality (which spans the whole reference project via ~20 image template assets this project doesn't have). `ends`: {check_fn(driver, config) -> bool: outcome_name}. Checked first, every iteration; the first match stops the loop and returns its name. `reactions`: {check_fn(driver, config) -> bool: action(driver)}. Checked if no end matched; the first match runs its action (a click, a keypress, whatever the recognized state calls for) and the loop continues. If neither an end nor a reaction matches, the loop just waits and retries -- it never falls back to a blind click/keypress guess (see CLAUDE.md's exit-game-dialog writeup for why that was a real bug elsewhere). Returns the matched end's outcome name, or None once max_iterations is exhausted without reaching a recognized end -- callers should treat None as "unrecognized state, abort safely." """ for _ in range(max_iterations): for check_fn, outcome_name in ends.items(): if check_fn(driver, config): return outcome_name for check_fn, action in reactions.items(): if check_fn(driver, config): action(driver) break else: driver.wait(poll_interval) return None