Nik Afiq a3cea4d192 feat(login): implement faster stuck-loading detection with _loading_buffer_visible
- Added _loading_buffer_visible function to detect the loading spinner badge
- Updated _wait_for_home to utilize the new detection for early recovery
- Introduced configuration options for loading buffer detection parameters
- Improved handling of stuck loading state to reduce wait time

fix(cron): make ba_cron_run.sh executable

- Changed file mode of ba_cron_run.sh to 755 to ensure it is executable
- Verified that cron can now execute the script as intended

fix(navigation): prevent crash in return_to_home when window disappears

- Added guard to return_to_home to check if the game window exists before focusing
- Ensured robustness in cleanup path during game exit
2026-07-20 01:21:53 +09:00

441 lines
25 KiB
Python

"""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")