"""Login: title screen through daily popups to the true home screen. Reference flow: `module/restart.py`'s `implement`/`start` (check the app is running, launch it if not) hands off into `core/Baas_thread.py`'s `to_main_page` -- the reference's own generic post-launch arrival routine. `to_main_page` is a `picture.co_detect` call wired with ~20 named img_reactions/rgb_possibles (download notices, the daily attendance card, a login-feature/login-store banner, news, rank-up cutscenes, etc.): whatever recognized state appears gets clicked through, repeatedly, until the 'main_page' rgb state is reached. There's no separate "login" module in the reference -- it's baked into that shared arrival routine, reused by every other feature's own navigation too. This project doesn't have image-template assets for that whole catalog, so rather than one-off hand-calibrating every possible reference dialog, this port scopes down to what was actually confirmed live on nik-gpu (see below) plus a bounded generic "press Enter" fallback for anything else recognized neither as the title screen nor the news popup -- Enter is this project's own established safe dismiss/advance action for exactly this class of one-off notice/card (see gem_shop.py's `_claim_free_package`, circle.py's `_dismiss_reward`), and this flow never reaches a screen where Enter is dangerous (that hazard, per CLAUDE.md, is specific to in-battle/spend confirmations arena.py/story_sweep.py guard against, not the pre-login popup chain). This mirrors the shape of the reference's own co_detect fallback (a blind action once nothing recognized matches for a while) while staying closer to this project's own established idiom than reference's blind coordinate tentative_click. Live-calibrated 2026-07-16 on nik-gpu by driving the real flow end-to-end from the account's actual overnight login-screen state (native 1920x1200 captures throughout, saved to scratchpad/login_probe_*.png during the session -- NOT the non-native screenshots/daily_login/*.png the user originally supplied, same "not 1:1 with real coordinates" gap already documented for screenshots/gem_shop/ and screenshots/cafe/student/). Confirmed states, in the order they were actually hit: 1. Title screen ("TOUCH TO START") -- `_logo_state` reads "bright" via the ブルーアーカイブ logo's fixed brand-chrome color (see config.py's `LOGIN_LOGO_*`), confirmed identical across two completely different seasonal background arts. Click `LOGIN_TOUCH_TO_START`. 2. A real "ネットワークへの接続に失敗しました" (network connection failed) notice, live-hit unprompted during calibration -- `_logo_state` reads "dimmed" (same logo, uniformly darkened by the notice's own overlay). Enter dismisses it, dropping back to the plain title screen (state 1 again). 3. A loading transition. Two visually distinct variants were both hit live: a brief one that keeps the logo/UID chrome visible (resolves in a few seconds), and a full-bleed one that replaces the ENTIRE screen with rotating splash art and a center spinner, no chrome at all. The first real run's loading genuinely got stuck in the second variant for over 6 minutes with zero progress (confirmed not a network issue -- ping and the game process were both healthy) -- the user's own live guidance was "kill the game and rerun it", identifying `/usr/local/bin/ launch-blue-archive.sh` as the relaunch entry point (see `_recover` and config.py's `GAME_PROCESS_NAME`/`GAME_LAUNCH_SCRIPT`). This directly parallels the reference's own `restart.py` kill+relaunch pattern, not an invented workaround. A second attempt after relaunching completed the entire remaining flow in well under 15 seconds, confirming the first attempt's multi-minute stall was a genuine stuck state, not normal loading variance. 4. アロナの毎日出席簿 (Arona's daily attendance card, a 10-day stamp calendar) -- only appears if not yet claimed today (confirmed: absent entirely on a same-day second run after the first run's Enter already claimed it). No distinct visual detection was built for this specific card -- it falls through to the generic Enter fallback like every other unrecognized pre-home state, confirmed live to correctly claim/dismiss it in one press. 5. True home, usually with the S.C.H.A.L.E NEWS popup open on top -- `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable for detecting this dialog specifically (confirmed live via direct pixel comparison: it overlays home directly, and is_modal_open's probe point lands on the dialog's own bright header/body rather than a dimmed backdrop, reading "no modal" whether it's open or not -- same class of mismatch already documented for gem_shop.py's own dialog). Fixed with a dedicated `_news_dialog_open` multi-point header-color check; closed via its own X button (`LOGIN_NEWS_CLOSE_BUTTON`), not Enter -- not confirmed to be Enter-bound, unlike every other popup in this flow. `_true_home` combines this with the existing shared is_on_subscreen/is_modal_open so the terminal check is correct whether or not the news popup happens to show that day. One additional real client bug surfaced live but is explicitly NOT ported around: a known intermittent rendering bug (per the user, pre-existing and unrelated to this project) where the news popup's own promotional image can get stuck on-screen as a blank white rectangle after the dialog is otherwise closed. Confirmed live twice in the same session -- present once, absent on an immediately-following clean run with identical navigation, so not deterministic. Confirmed harmless to this task specifically: pixel- checked live that the stuck rectangle sits well clear of every probe point this module and navigation.py use (`LOGIN_LOGO_PROBES`, `LOGIN_NEWS_HEADER_PROBES`, `navigation.SUBSCREEN_HEADER_PROBE`, `navigation.MODAL_DIM_PROBE`), and `navigation.is_on_subscreen`/ `is_modal_open` both still correctly read true-home with the artifact on-screen. The user's own fix (reload the app) is already this module's existing stuck-recovery path, so no separate handling was added. A real correctness bug was found and fixed the same session, via the actual module's own first live run (not manual clicking): `_true_home`'s original definition (not-subscreen AND not-modal AND no-news-dialog) is a set of negative conditions never actually calibrated against the pre-login states this module itself introduces -- both the plain title screen AND the unclaimed daily attendance card independently satisfy all three of them (neither looks like a subscreen or an open modal to those checks, which were built assuming the game world is always already reached). The first real `login` run false-positived "reached home" while still sitting on the title screen, before ever clicking anything. Fixed by adding `_home_nav_bar_visible`, a positive check for the bottom nav bar's own flat, near-white background band -- confirmed live to hold uniquely on true home and fail on every other captured state, including home with the news dialog still open (which dims that same band). Re-run afterward: a real cold start (game process not running at all) correctly launched the game, clicked through the title screen, and reached the genuinely-confirmed home screen. A second real correctness bug was found via real unattended cron runs the following day (2026-07-17), reported live by the user together with the actual pulled cron logs (`~/ba_logs/daily.log`/`q4h.log`): the 4:30 AM `daily` fire logged `[login] reached home` cleanly, but then every single task that ran afterward failed to open its own screen -- garbled OCR reads, "screen/panel not detected after click" repeated across cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, and stamina -- and this persisted across multiple consecutive `q4h` fires spanning hours (the account only recovered around the 17:00 fire the following day). Per the user's own direct knowledge of this game's real behavior: the server's own daily reset (~4 AM JST) or a concurrent login from another device (e.g. the phone) can silently kill the session while the client keeps showing the last-known-good home screen with no visible error -- the error only surfaces once an actual navigation/API call is attempted, as the game's own "connection lost" popup, which no individual task's own screen-detection logic was built to recognize. `_true_home`'s checks are all purely visual (nav bar shape, subscreen/modal darkness, news-dialog color), so a dead session that still *looks* like home sailed straight through undetected. Separately, reproducing this live (the user logged into the account on their phone specifically to trigger it) surfaced a THIRD, distinct false positive in the same family: a real run printed "reached home" while the game was still showing a bright, fully non-interactive loading/transition frame (tower splash art, zero HUD) -- a transient frame apparently bright and flat enough to coincidentally satisfy `_home_nav_bar_visible` for one screenshot, moments before the actual home screen rendered. Both gaps share one fix, per the user's own suggested design ("change the login to enter the stamina claim/reward area then go back home"): `_connectivity_confirmed` now opens the Mission panel (config.MISSION_ICON, the same icon stamina.py uses) and closes it again, right after `_true_home` passes, before `_wait_for_home` will return success. A transition frame's false positive gets caught because the click lands on non-interactive background art and the panel never opens; a truly dead session gets caught because the game's own connection-error dialog appears instead of the Mission panel -- either way, `_wait_for_home` just treats it as "not home yet" and retries the whole cycle, which will naturally fall through to the existing generic Enter-fallback if an actual dialog needs dismissing, and escalate to `_recover`'s kill+relaunch if the whole thing never resolves within the timeout. **Confirmed live**: reproduced the real stuck-login state from the phone-login trigger, ran the actual `login` task against it, and caught it printing a false "reached home" while genuinely still on a loading transition frame (verified via a follow-up screenshot showing no HUD at all) -- this is the exact live evidence that led to `_connectivity_confirmed`. That first version of `_connectivity_confirmed` used `navigation. is_on_subscreen` (a single-pixel check) to verify the Mission panel opened -- re-run live against the SAME still-stuck session and it ALSO false-positived: a different coincidental frame in the extended animated loading sequence satisfied the single subscreen-header pixel right after the click, on the very same run whose `_true_home` check had already been fooled by yet another frame. Switched to `navigation.is_header_bar_visible` (8 spread-out points, all must read bright) -- the same "harder to fool by one coincidental bright pixel" upgrade this project already made for cafe.py's rank-up detection, reused here for the identical reason. Re-run live a third time against the same still-stuck session: STILL a false positive, confirmed by an immediate independent follow-up check (screenshot plus a direct re-run of `_true_home`/`is_header_bar_visible`/ `_connectivity_confirmed`, all reading false) moments later, on a THIRD different splash frame (two characters on a tank, a `巡回中` sign). This extended loading sequence turned out to genuinely never resolve on its own across three separate `login` invocations and several real minutes of elapsed time (matching the user's own "if this doesn't solve in 3-5 minutes, restart" guidance) -- rich enough in distinct splash frames that two DIFFERENT independent checks (nav-bar shape, then header-bar brightness) could each individually be fooled by a different frame within the same run, even though neither check alone is easily fooled. Rather than chasing a fourth, even-stronger single-snapshot probe, `_wait_for_home` now re-verifies `_true_home` a second time, after a short wait, before finally trusting a passed `_connectivity_confirmed` check -- two DIFFERENT coincidental frames independently satisfying the full check sequence twice in a row, moments apart, is far less likely than either false positive alone. The stuck session itself was cleared with a manual kill+relaunch (the user's own direct guidance) rather than continuing to poke at an already-anomalous, hours-old stuck state. Deployed; not yet re-confirmed live against a fresh instance of either original failure mode end-to-end (a truly dead session, or the ordinary transition-frame case), though the double-confirmation logic itself follows directly from live evidence, not guesswork. Not yet live-confirmed: the ~infrequent 業務復帰ログインボーナス (welcome-back login bonus, a "long trip" returning-player reward, `screenshots/daily_login/3_claim2(...).png`) card the user's own reference screenshots showed -- did not appear during calibration (this account was not in that state). Expected to fall through to the same generic Enter fallback as the daily attendance card, matching the same "any one-off pre-home card responds to Enter" pattern already confirmed for state 4 above and the network notice in state 2, but not yet exercised for real. Added to `ba_daily.py`'s `DEFAULT_ORDER` as the very first step, ahead of mailbox -- every other task's own navigation assumes the home screen is already reachable via `navigation.return_to_home`'s ordinary Escape/BACK_BUTTON press loop, which has no way to get there from the title/loading/attendance-card states this module handles. `_wait_for_home` checks true-home FIRST on every iteration (same contract as `navigation.wait_for_state`), so a session that's already logged in and sitting at home is a fast no-op, not a risky blind click. **Faster stuck-loading detection, `_loading_buffer_visible` (2026-07-19)**: the user manually ran `ba_cron_run.sh daily` and reported the game stuck at login; by the time it was checked, the run was still well within its own first `LOGIN_TIMEOUT_SECONDS` (240s) budget -- not actually broken, just early in a bounded wait that hadn't reached its own timeout/recover point yet (confirmed: `_recover`'s existing kill+relaunch DID fire once that budget elapsed, per the user's own follow-up "Oh the game did relaunched"). But blindly waiting out the full generic timeout for a state this recognizable is slower than it needs to be. Live-captured the real stuck screen (`screenshots/daily_login/stuck_loading_buffer.png`, pulled via `scrot` over ssh) -- exactly state 3's full-bleed loading variant from this docstring's own account above, rotating splash art with a small gray spinner badge dead center. Pixel-probed it (`scratchpad/probe_login_buffer.py`): the badge's left/right edges read a flat, exact neutral gray `(118,118,118)` -- R==G==B, unlike any sampled splash-art pixel elsewhere on the same screenshot -- consistently across its whole vertical span, centered on the screen's own true center. Since this is a fixed UI overlay independent of whichever splash-art frame happens to be showing behind it, it doesn't share the "different frame fools a different single-snapshot check" failure class that already bit `_true_home`/`_connectivity_confirmed` twice (see above). `_wait_for_home` now tracks how long this specific badge has been seen continuously and bails out to `_recover` after `LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s, well above the "brief" loading variant's few-second normal case, far below the generic 240s budget) rather than waiting out the full generic timeout once this exact, well-understood bad state is confidently recognized. Verified offline against the real capture (all 6 probe points read within tolerance of the target). Not yet live-confirmed against a fresh real stuck instance recovering via this faster path specifically (the instance that prompted this fix had already recovered via the pre-existing generic-timeout path by the time the fix was written). """ import time from ba_auto import navigation def _color_matches(rgb, target, tolerance): return all(abs(c - t) <= tolerance for c, t in zip(rgb, target)) def _logo_state(driver, config): colors = driver.colors_at(config.LOGIN_LOGO_PROBES) if all(_color_matches(c, config.LOGIN_LOGO_BRIGHT_RGB, config.LOGIN_LOGO_COLOR_TOLERANCE) for c in colors): return "bright" if all(_color_matches(c, config.LOGIN_LOGO_DIMMED_RGB, config.LOGIN_LOGO_COLOR_TOLERANCE) for c in colors): return "dimmed" return "absent" def _news_dialog_open(driver, config): colors = driver.colors_at(config.LOGIN_NEWS_HEADER_PROBES) return all(_color_matches(c, config.LOGIN_NEWS_HEADER_RGB, config.LOGIN_NEWS_HEADER_TOLERANCE) for c in colors) def _loading_buffer_visible(driver, config): """Positive detector for the full-bleed loading transition's center spinner badge (see config.py's LOGIN_LOADING_BUFFER_* comment for the live pixel calibration) -- confirms we're in that specific known-can- get-stuck state rather than one of the many other unrecognized dialogs the generic Enter-fallback handles, so _wait_for_home can bail out to _recover on a much shorter, purpose-specific timeout instead of the full generic wall-clock budget. """ colors = driver.colors_at(config.LOGIN_LOADING_BUFFER_PROBES) return all(_color_matches(c, config.LOGIN_LOADING_BUFFER_RGB, config.LOGIN_LOADING_BUFFER_TOLERANCE) for c in colors) def _home_nav_bar_visible(driver, config): for r, g, b in driver.colors_at(config.LOGIN_HOME_NAV_BAR_PROBES): if min(r, g, b) < config.LOGIN_HOME_NAV_BAR_MIN_CHANNEL: return False if max(r, g, b) - min(r, g, b) > config.LOGIN_HOME_NAV_BAR_MAX_SPREAD: return False return True def _true_home(driver, config): return ( _home_nav_bar_visible(driver, config) and not navigation.is_on_subscreen(driver) and not navigation.is_modal_open(driver) and not _news_dialog_open(driver, config) ) def _connectivity_confirmed(driver, config): """Opens the Mission panel (a cheap, always-available screen, same icon stamina.py uses) and closes it again, to prove the session is actually live -- not just that this exact screenshot happens to satisfy _true_home's checks. Two distinct real failure modes this catches: 1. A bright full-screen transition/wipe frame between loading and home can transiently satisfy every _true_home probe at once (confirmed live 2026-07-17: a real run printed "reached home" while still mid-transition -- no HUD at all, a tower splash-art background -- moments before the real home screen actually rendered). 2. A genuinely dead session: the server's own daily reset around 4 AM JST, or a concurrent login from another device, leaves the client showing a cached-looking, visually normal home screen with no error until an actual navigation/API call is attempted -- at which point the game's own "connection lost" popup appears instead of the requested screen. Reported live by the user with real cron logs: after one such silent-death login, EVERY task that ran afterward (cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, stamina) failed to open its own screen, each in its own inconsistent way, across multiple consecutive cron fires spanning hours -- because nothing had verified the session was actually alive before handing off to them. Catching either here, once, right after login, is far more reliable than leaving it to be discovered piecemeal by whichever task happens to run next. If the click doesn't open the panel, we don't know whether that's cafe-empty-air (transition frame, will resolve) or a real connection-error dialog -- either way we just report failure and let _wait_for_home's own loop retry the whole cycle (including this probe) on its next iteration, exactly like any other not-yet-home state. """ # is_on_subscreen (a single-pixel check) was tried first and also # false-positived live (2026-07-17): during an extended, multi-frame # animated loading sequence, a *different* coincidental frame satisfied # the single subscreen-header pixel right after the click, on the same # run whose _true_home check had already been fooled by yet another # frame. navigation.is_header_bar_visible (8 spread-out points, all # must read bright) is the same "harder to fool by a single coincidental # bright pixel" upgrade this project already made for cafe.py's rank-up # detection -- reused here for the identical reason. driver.click(*config.MISSION_ICON) driver.wait(2) opened = navigation.is_header_bar_visible(driver) if opened: driver.keypress("Escape") driver.wait(1.5) return opened def _wait_for_home(driver, config): start = time.time() buffer_since = None while time.time() - start < config.LOGIN_TIMEOUT_SECONDS: if _loading_buffer_visible(driver, config): if buffer_since is None: buffer_since = time.time() elif time.time() - buffer_since >= config.LOGIN_LOADING_BUFFER_STUCK_SECONDS: print(f"[login] loading buffer stuck for {config.LOGIN_LOADING_BUFFER_STUCK_SECONDS}s+ -- not waiting out the full timeout") return False else: buffer_since = None if _true_home(driver, config): if not _connectivity_confirmed(driver, config): print("[login] looked like home but the mission panel wouldn't open -- possible transition frame or dead session, retrying") driver.wait(config.LOGIN_POLL_INTERVAL) continue # Even the 8-point header check was fooled live (2026-07-17, # same session): an extended, highly-varied animated loading # sequence apparently cycles through enough different splash # frames that occasionally ONE frame coincidentally satisfies # _true_home and a LATER, DIFFERENT frame coincidentally # satisfies the connectivity probe too -- two independent # false positives stacking, confirmed by an immediate follow-up # check moments later reading false on all three signals at # once. A single coincidental frame is unlikely; two DIFFERENT # coincidental frames in a row, moments apart, is much less # likely still -- so re-verify _true_home again after a short # wait before finally trusting it. driver.wait(config.LOGIN_POLL_INTERVAL) if _true_home(driver, config): return True print("[login] home check didn't hold on re-verification -- treating as a coincidental frame, retrying") continue if _news_dialog_open(driver, config): driver.click(*config.LOGIN_NEWS_CLOSE_BUTTON) elif _logo_state(driver, config) == "bright": driver.click(*config.LOGIN_TOUCH_TO_START) else: # Dimmed title-screen notice, a loading transition, the daily # attendance card, an infrequent login-bonus card, or any other # one-off dialog -- see module docstring for why a blind Enter # is this flow's established safe generic action here. driver.keypress("Return") driver.wait(config.LOGIN_POLL_INTERVAL) return False def _wait_for_window(driver, config): for _ in range(config.LOGIN_RELAUNCH_WAIT_ATTEMPTS): if driver.window_exists(): driver.wait(3) return True driver.wait(2) return False def _recover(driver, config): print("[login] not home after the timeout -- killing and relaunching the game") driver.kill_game() for _ in range(config.LOGIN_KILL_WAIT_ATTEMPTS): if not driver.is_game_running(): break driver.wait(2) driver.launch_game() if not _wait_for_window(driver, config): print("[login] warning: game window did not reappear after relaunch") return False driver.focus_game() return True def run(driver, config): if not driver.window_exists(): print("[login] game window not found -- launching") driver.launch_game() if not _wait_for_window(driver, config): print("[login] warning: game window never appeared after launch -- giving up") return driver.focus_game() for attempt in range(1, config.LOGIN_MAX_RELAUNCHES + 1): if _wait_for_home(driver, config): print("[login] reached home") return if attempt < config.LOGIN_MAX_RELAUNCHES: _recover(driver, config) print(f"[login] warning: could not reach home after {config.LOGIN_MAX_RELAUNCHES} attempts -- manual check needed")