diff --git a/ba_auto/config.py b/ba_auto/config.py index bdd56e0..575d8f6 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -1307,3 +1307,33 @@ LOGIN_TIMEOUT_SECONDS = 240 LOGIN_MAX_RELAUNCHES = 2 LOGIN_KILL_WAIT_ATTEMPTS = 10 # ~20s for the old process to fully exit LOGIN_RELAUNCH_WAIT_ATTEMPTS = 45 # ~90s+ for the window to reappear + +# The full-bleed loading transition (rotating splash art, no chrome at all -- +# see login.py's module docstring, state 3's "second variant") renders a +# small gray spinner badge dead center on screen regardless of which splash +# art frame is showing behind it. Live-captured 2026-07-19 during a real +# stuck instance (the user ran `ba_cron_run.sh daily` manually and reported +# the game stuck at login -- screenshots/daily_login/stuck_loading_buffer.png, +# calibrated via 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 of the +# colorful splash-art pixels sampled elsewhere on the same screenshot +# (all clearly non-gray, e.g. (216,233,207), (107,103,158)) -- across the +# whole sampled y-range at x=928 and x=992 (the badge spans roughly +# x=[928,992], y=[571,627], centered on the screen's own true center, +# (960,600)). Deliberately samples only these flat edge columns, not the +# badge's own interior (which has a white spinner icon washing out some +# interior pixels to near-white) -- same "avoid the part that visibly +# varies" reasoning as every other multi-point probe in this project. +LOGIN_LOADING_BUFFER_PROBES = ((928, 580), (928, 600), (928, 620), (992, 580), (992, 600), (992, 620)) +LOGIN_LOADING_BUFFER_RGB = (118, 118, 118) +LOGIN_LOADING_BUFFER_TOLERANCE = 12 +# How long this exact badge must be seen continuously before treating it as +# stuck rather than a normal (if slow) loading transition -- the "brief" +# loading variant in login.py's docstring resolved in a few seconds during +# calibration, and there's no real data on how long the full-bleed variant +# normally takes when it ISN'T stuck, so this stays well above that to avoid +# killing a genuinely-progressing load. Far shorter than the generic +# LOGIN_TIMEOUT_SECONDS=240 blind wall-clock budget, though, since this is a +# specific, well-understood bad state (not "anything unrecognized") -- no +# reason to wait the full 4 minutes once it's confidently identified. +LOGIN_LOADING_BUFFER_STUCK_SECONDS = 60 diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index 3b501c3..ee0c0dd 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -130,6 +130,28 @@ def return_to_home(driver): -- correct for them, since they have no ability to start the game -- this only changes behavior for the specific "no window yet" case this function itself can't do anything about anyway. + + The escalation call itself now has the same window_exists() guard, + added 2026-07-20 after a real crash: the entry check above only covers + the window being gone at the START of this function, not it + disappearing mid-loop. Confirmed live via a real q4h cron run's + traceback -- exit_game closed the game and printed its own "closed" + confirmation, then ba_daily.py's post-task cleanup called this function + immediately afterward with zero delay; driver.window_exists() still + read True at that exact instant (the game's own teardown apparently + isn't instantaneous -- the window can take a moment to fully + disappear from xdotool's search after the in-game exit is confirmed), + so the entry guard passed and the retry loop began pressing + Escape/BACK_BUTTON against a game that was already exiting. By the + time the loop reached its halfway escalation point a few seconds + later, the window had fully disappeared for real, and the unguarded + driver.focus_game() call crashed the whole script with an uncaught + RuntimeError. Fixed by checking window_exists() again right at the + escalation point too (matching click_back's own already-established + convention below), returning False instead of crashing if the window + is gone by then -- this is a general robustness fix benefiting any + task's cleanup path where the window can disappear mid-retry, not just + exit_game's. """ if not driver.window_exists(): return False @@ -148,6 +170,8 @@ def return_to_home(driver): if not escalated and round_num >= RETURN_HOME_MAX_ROUNDS // 2: escalated = True + if not driver.window_exists(): + return False 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) diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 896d3c3..f714d95 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -12,7 +12,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | | Circle (Group/Club) daily check-in | `module/group.py` | `implement`, `to_group` | `ba_auto/tasks/circle.py` | Reference's `picture.co_detect` polling against fixed screen positions + `rgb_possible`/`img_possible` template states → this client's own 2-click nav (home → bottom-nav ソーシャル icon → サークル card) verified with the existing shared `navigation.is_modal_open`/`is_on_subscreen` (no new probe needed — pixel-confirmed both real "reached the circle screen" states, with or without the reward modal, satisfy that combined check) | Done. Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end via raw xdotool/scrot — this **genuinely claimed the account's real circle check-in reward for the day** (+10 AP, confirmed via the real reward dialog "今日のサークルへの参加報酬... AP x10... 報酬はメールボックスから受け取ることができます"), which calibrated both real states from real data: the first-entry reward modal, and (by re-entering immediately after) the already-checked-in straight-to-chat state with no modal. Also live-confirmed a real `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click mid-calibration (same recurring gotcha documented elsewhere in this project) — recovered via the standard `windowactivate`+`windowraise` escalation. Per explicit user direction, the task presses Escape directly to return home (confirmed live: a single Escape from the サークル screen returns straight to true home, skipping back through the intermediate ソーシャル hub page) rather than clicking `navigation.BACK_BUTTON`. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a member, and the task's scope (per explicit user direction) is entry only, no mailbox claim. The actual `circle.py` module was then live-tested for real via `./ba_dailies.sh circle`, correctly reading the "already checked in today" state left over from calibration and returning cleanly home. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the time the module existed — same disclosed gap shape as gem_shop's own "available → claim" path. Added to `DEFAULT_ORDER` alongside mailbox/cafe/stamina/gem_shop, since it's a pure free reclaim with no decision to make. **A `reference-parity-reviewer` pass the same day caught and fixed three real gaps**: a too-generic terminal-state check that could false-positive "already checked in" from a stuck non-home starting state (fixed via `navigation.return_to_home(driver)` at the top of `run()`, matching arena.py/bounty.py precedent); an unverified reward-dismiss Enter press and closing Escape (fixed via `_dismiss_reward`/`_leave_circle`, bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop`); and a missing `driver.focus_game()` escalation in the entry retry loop for the `XIGNCODE` overlay (which had already struck live during this task's own calibration) — fixed to match `navigation.click_back`'s own escalation convention. Re-confirmed live via `./ba_dailies.sh circle` after all three fixes. See `plan.md`'s Phase 17 for the full writeup. | | Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). **Real hazard found and fixed via a real unattended `daily` cron run (2026-07-17)**: reported live by the user -- a real "Battle Complete" screen (a live ~3-minute combat timer) was found on the account instead of an instant sweep, with `_watch_sweep_result` having reported "swept" cleanly and no warnings anywhere in the log; every task that ran for the rest of that `daily` invocation failed to open its own screen. Manually reproducing the flow live (reaching the real confirm dialog and cancelling before confirming, the same safe-calibration pattern the original live-testing used) found the actual hazard: the 任務情報 modal has TWO separate action buttons stacked vertically -- the intended cyan 掃討開始 (start sweep, instant) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical ticket-cost preview. Every check along the commit path (`_count_raised_above_one`/`_is_sweep_usage_confirm`/`_watch_sweep_result`'s own "swept" conditions) is a generic color/position probe with no verification of what's actually showing. Fixed with `_confirm_dialog_is_sweep`, an OCR text check (`config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT`, captured live from the real dialog: "指名手配チケットをN使用して、掃討をN回行いますか?") gating the one irreversible click in this flow -- cancels rather than confirms if the dialog doesn't read as a sweep. The exact mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live (would mean deliberately repeating a real battle); the fix closes the hazard regardless of the exact upstream cause. Not yet re-verified live against a fresh real sweep. | -| Login | `core/Baas_thread.py`, `module/restart.py` | `to_main_page` (generic post-launch arrival routine, reused by every other reference feature's own navigation -- no separate "login" module exists in the reference), `restart.py`'s `implement`/`start` (check app running, launch if not) | `ba_auto/tasks/login.py` | Reference detects every one-off popup (~20 named `img_reactions`/`rgb_possibles`) via `picture.co_detect` image-template matching; this port scopes down to what was actually confirmed live (title screen via a fixed-chrome logo-color probe, a real network-error notice, the daily attendance card, S.C.H.A.L.E NEWS) plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape but using this project's own established Enter-dismiss idiom rather than a blind coordinate click. `module/restart.py`'s kill-then-relaunch pattern ported directly via new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives (`pkill -f`/the account's own `/usr/local/bin/launch-blue-archive.sh`/`pgrep -f`/`xdotool search`) | Done. Live-calibrated 2026-07-16 on nik-gpu against the account's real overnight login-screen state (native 1920x1200 captures throughout, not the non-native `screenshots/daily_login/*.png` reference photos originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed live: the title screen's own logo reads a fixed brand-chrome color independent of rotating seasonal background art (confirmed across two different pieces), bright when clean and uniformly dimmed when a real notice is open on top of it (a genuine "ネットワークへの接続に失敗しました" network error surfaced unprompted during calibration); the daily attendance card only appears once per day (confirmed absent on an immediate same-day re-run after being claimed) and, like the network notice, responds to a plain Enter with no dedicated detection needed; the S.C.H.A.L.E NEWS popup needed a dedicated header-color probe since `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable on it (same class of mismatch as `gem_shop.py`'s own dialog -- confirmed live by direct pixel comparison). **A real stuck-loading incident hit live during calibration itself**: the loading transition (a full-bleed variant with no chrome, distinct from a brief chrome-visible variant also seen) stalled past 6 minutes with zero progress, confirmed not a network/process-health issue; the user's own live guidance ("kill the game and rerun it") identified the relaunch script, and a second attempt after manually killing+relaunching completed the entire remaining flow (title tap -> attendance card -> home) in well under 15 seconds, confirming the stall was a genuine stuck state now handled automatically by `_recover`. Also observed live (twice, non-deterministically) but deliberately NOT worked around: a known pre-existing client rendering bug (per the user) where the news popup's own promo image can get stuck as a blank white rectangle after closing -- confirmed harmless to this module specifically (sits clear of every probe point used here, and the shared home-check probes still read correctly through it), and the user's own fix for it (reload the app) is already this module's existing stuck-recovery path. Added as the very first step in `DEFAULT_ORDER`, ahead of mailbox, since no other task can reach home from the title/loading/attendance-card states on its own. **Not yet live-confirmed**: the infrequent 業務復帰ログインボーナス welcome-back login bonus card from the user's own reference screenshots (account wasn't in that state during calibration) -- expected to fall through to the same generic-Enter path already confirmed for the attendance card and network notice, but not yet exercised for real; and the kill+relaunch recovery path has only been exercised once, manually, not yet through a fresh invocation of the actual `login.py` module hitting a real stuck state on its own. **Connectivity-probe fix, `_connectivity_confirmed` (2026-07-17)**: reported live by the user via real pulled cron logs -- a 4:30 AM `daily` fire logged a clean `[login] reached home`, but every task that ran afterward (cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, stamina) failed to open its own screen, persisting across multiple consecutive `q4h` fires spanning hours. Per the user's own direct knowledge: the server's daily reset or a concurrent login from another device can silently kill the session while the client keeps showing a cached-looking, visually normal home screen -- the error only surfaces as the game's own "connection lost" popup once an actual navigation/API call is attempted, which `_true_home`'s purely-visual checks never triggered. Fixed per the user's own suggested design ("enter the stamina claim/reward area then go back home"): `_connectivity_confirmed` now opens the Mission panel and closes it again before `_wait_for_home` will return success. Reproducing this live (the user logged into the account on their phone to trigger a real session kill) surfaced two FURTHER false positives in the same family, both from an unusually long, multi-frame animated loading sequence that never resolved on its own: first with `navigation.is_on_subscreen` (single-pixel) as the panel-opened check, then again after upgrading to `navigation.is_header_bar_visible` (8-point) -- each independently got fooled by a different coincidental splash frame within the same run. Fixed by requiring `_true_home` to re-verify a second time, after a short wait, before finally trusting a passed connectivity check. **Confirmed live**: the stuck session was cleared with a manual kill+relaunch, then a genuinely fresh cold-start `login` run (game process not running at all) correctly launched the game, passed through the title screen, and reached independently-verified true home (full HUD -- Lv/AP/credits/gems/bottom nav all visible, confirmed via direct screenshot and a separate `_true_home`/`is_header_bar_visible` check, not just the module's own printed message). Not yet re-confirmed against a fresh instance of the original silent-session-death failure mode specifically (the live reproduction available this session was the full-kick-to-login-screen variant, not the "still looks like home but isn't" variant from the cron logs), though the same `_connectivity_confirmed` mechanism covers both by design. | +| Login | `core/Baas_thread.py`, `module/restart.py` | `to_main_page` (generic post-launch arrival routine, reused by every other reference feature's own navigation -- no separate "login" module exists in the reference), `restart.py`'s `implement`/`start` (check app running, launch if not) | `ba_auto/tasks/login.py` | Reference detects every one-off popup (~20 named `img_reactions`/`rgb_possibles`) via `picture.co_detect` image-template matching; this port scopes down to what was actually confirmed live (title screen via a fixed-chrome logo-color probe, a real network-error notice, the daily attendance card, S.C.H.A.L.E NEWS) plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape but using this project's own established Enter-dismiss idiom rather than a blind coordinate click. `module/restart.py`'s kill-then-relaunch pattern ported directly via new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives (`pkill -f`/the account's own `/usr/local/bin/launch-blue-archive.sh`/`pgrep -f`/`xdotool search`) | Done. Live-calibrated 2026-07-16 on nik-gpu against the account's real overnight login-screen state (native 1920x1200 captures throughout, not the non-native `screenshots/daily_login/*.png` reference photos originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed live: the title screen's own logo reads a fixed brand-chrome color independent of rotating seasonal background art (confirmed across two different pieces), bright when clean and uniformly dimmed when a real notice is open on top of it (a genuine "ネットワークへの接続に失敗しました" network error surfaced unprompted during calibration); the daily attendance card only appears once per day (confirmed absent on an immediate same-day re-run after being claimed) and, like the network notice, responds to a plain Enter with no dedicated detection needed; the S.C.H.A.L.E NEWS popup needed a dedicated header-color probe since `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable on it (same class of mismatch as `gem_shop.py`'s own dialog -- confirmed live by direct pixel comparison). **A real stuck-loading incident hit live during calibration itself**: the loading transition (a full-bleed variant with no chrome, distinct from a brief chrome-visible variant also seen) stalled past 6 minutes with zero progress, confirmed not a network/process-health issue; the user's own live guidance ("kill the game and rerun it") identified the relaunch script, and a second attempt after manually killing+relaunching completed the entire remaining flow (title tap -> attendance card -> home) in well under 15 seconds, confirming the stall was a genuine stuck state now handled automatically by `_recover`. Also observed live (twice, non-deterministically) but deliberately NOT worked around: a known pre-existing client rendering bug (per the user) where the news popup's own promo image can get stuck as a blank white rectangle after closing -- confirmed harmless to this module specifically (sits clear of every probe point used here, and the shared home-check probes still read correctly through it), and the user's own fix for it (reload the app) is already this module's existing stuck-recovery path. Added as the very first step in `DEFAULT_ORDER`, ahead of mailbox, since no other task can reach home from the title/loading/attendance-card states on its own. **Not yet live-confirmed**: the infrequent 業務復帰ログインボーナス welcome-back login bonus card from the user's own reference screenshots (account wasn't in that state during calibration) -- expected to fall through to the same generic-Enter path already confirmed for the attendance card and network notice, but not yet exercised for real; and the kill+relaunch recovery path has only been exercised once, manually, not yet through a fresh invocation of the actual `login.py` module hitting a real stuck state on its own. **Connectivity-probe fix, `_connectivity_confirmed` (2026-07-17)**: reported live by the user via real pulled cron logs -- a 4:30 AM `daily` fire logged a clean `[login] reached home`, but every task that ran afterward (cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, stamina) failed to open its own screen, persisting across multiple consecutive `q4h` fires spanning hours. Per the user's own direct knowledge: the server's daily reset or a concurrent login from another device can silently kill the session while the client keeps showing a cached-looking, visually normal home screen -- the error only surfaces as the game's own "connection lost" popup once an actual navigation/API call is attempted, which `_true_home`'s purely-visual checks never triggered. Fixed per the user's own suggested design ("enter the stamina claim/reward area then go back home"): `_connectivity_confirmed` now opens the Mission panel and closes it again before `_wait_for_home` will return success. Reproducing this live (the user logged into the account on their phone to trigger a real session kill) surfaced two FURTHER false positives in the same family, both from an unusually long, multi-frame animated loading sequence that never resolved on its own: first with `navigation.is_on_subscreen` (single-pixel) as the panel-opened check, then again after upgrading to `navigation.is_header_bar_visible` (8-point) -- each independently got fooled by a different coincidental splash frame within the same run. Fixed by requiring `_true_home` to re-verify a second time, after a short wait, before finally trusting a passed connectivity check. **Confirmed live**: the stuck session was cleared with a manual kill+relaunch, then a genuinely fresh cold-start `login` run (game process not running at all) correctly launched the game, passed through the title screen, and reached independently-verified true home (full HUD -- Lv/AP/credits/gems/bottom nav all visible, confirmed via direct screenshot and a separate `_true_home`/`is_header_bar_visible` check, not just the module's own printed message). Not yet re-confirmed against a fresh instance of the original silent-session-death failure mode specifically (the live reproduction available this session was the full-kick-to-login-screen variant, not the "still looks like home but isn't" variant from the cron logs), though the same `_connectivity_confirmed` mechanism covers both by design. **Faster stuck-loading detection, `_loading_buffer_visible` (2026-07-19)**: a manual `ba_cron_run.sh daily` run left the game stuck at login; `_recover`'s existing kill+relaunch DID fire correctly once the generic 240s timeout elapsed (confirmed live -- the user reported "the game did relaunched"), but waiting the full generic timeout for this specific, recognizable state is slower than necessary. Live-captured the real stuck screen and pixel-probed it (`screenshots/daily_login/stuck_loading_buffer.png`, calibrated via `scratchpad/probe_login_buffer.py`): the full-bleed loading variant's center spinner badge reads a flat, exact neutral gray `(118,118,118)` at a fixed screen-center position, independent of whichever splash-art frame is rotating behind it -- unlike the earlier `_true_home`/`_connectivity_confirmed` false positives, this doesn't share the "different frame fools a single-snapshot check" failure class, since the badge is a fixed UI overlay, not art. `_wait_for_home` now tracks continuous badge visibility and bails out to `_recover` after `config.LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s) instead of the full 240s once this exact state is confidently recognized. Verified offline against the real capture (all 6 probe points matched). Not yet live-confirmed against a fresh stuck instance recovering via this specific faster path (the triggering instance had already recovered via the pre-existing generic timeout by the time the fix was written). | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | Exit game | None -- searched `~/repo/baas-reference/` thoroughly, no counterpart exists. The closest match, `core/Baas_thread.py`'s `shutdown()`/`start_shutdown()` (line ~984), is an optional full Windows OS shutdown (`subprocess.run(["shutdown", "-s", "-t", "60"])`) gated by a user toggle, not a game-client exit -- out of scope (this project's game and X display run on `nik-gpu`, shutting the host down would kill the SSH session too, nothing like what was requested). The only other app-closing call, `connection.py`'s `close_current_app` (line 381, plain ADB `app_stop`), exists purely for `Baas_thread.py`'s own error-recovery restarts (`deal_with_package_incorrect`/`deal_with_func_call_timeout`) inside a persistent background scheduler that's designed to keep running indefinitely -- it never deliberately exits once daily tasks finish. This feature is new, desktop-specific convenience logic motivated by this project's different architecture (cron launches a fresh one-shot process per preset, so closing the game after a run has real value the reference's always-on scheduler never needed), not a port of anything. | `ba_auto/tasks/exit_game.py` | No new driver/detector primitives needed -- reuses `navigation.return_to_home` (must-be-true-home gate), `navigation.is_modal_open` (verifies the Escape-triggered "exit the game?" dialog actually opened, the same generic dim-probe reused across bounty/gem_shop/circle's own confirm dialogs), `driver.keypress("Escape"/"Return")`, and `driver.window_exists()` (verifies the game process/window is actually gone afterward, not just that Enter was sent) | Done. `navigation.return_to_home`'s own docstring already documents the mechanism this relies on: an Escape press on the confirmed true home screen (nowhere else) raises this exact dialog -- every other task treats that as a hazard to avoid triggering by accident; this is the one task that wants it, gated the same way (only fires from a verified true-home state). No force-kill fallback if the graceful Escape/Enter path doesn't verify -- matches this project's established "abort cleanly on unknown state rather than guess" convention (gem_shop/bounty), rather than reaching for `driver.kill_game()`. Opt-in only, appended to the end of the `daily`/`q4h` presets (not `DEFAULT_ORDER`), since it deliberately ends the session. **Confirmed live (2026-07-18)** via a standalone `./ba_dailies.sh exit_game` run, user-reported "works well" -- `is_modal_open`'s generic dim-probe correctly read the exit-confirmation dialog after Escape, and the game closed cleanly after Enter. Not yet exercised as the tail end of a full `daily`/`q4h` preset run (only standalone so far), though nothing in its own logic depends on which task ran before it. | | Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). **This DID subsequently run live via cron and surfaced a real hazard (2026-07-17)**: the log showed "battle skip already on" for all 5 fights and a clean "fought 5 battle(s) this run", but the account was left showing a real "Battle Complete" screen with a ~3-minute combat timer -- battles had run in full, not skipped -- and every task for the rest of that `daily` invocation failed to open its own screen. Two compounding bugs: (1) `_ensure_skip_on`'s "on" detection was never actually verified to reject a real "off" state (`ARENA_SKIP_ON_RGB` was only ever confirmed against a session where skip happened to already be on); (2) `_wait_for_result` had no early-exit signal and always ran its full fixed 15s budget before unconditionally declaring success -- far too short for a real battle -- and `_fight_one` discarded `_wait_for_result`'s return value entirely, so even a correctly detected failure never stopped the fight loop. Fixed by widening `RESULT_MODAL_MAX_POLLS` substantially (tolerating a real battle's full duration regardless of whether the skip-mode detection gets fully root-caused) plus a new `_back_on_challenge_list` early exit (so the normal fast case doesn't slow down), and by making `_fight_one` actually stop on a `_wait_for_result` failure. The skip-toggle's own "off" detection was not independently re-verified (would need a real ticket at a moment the account had none left) -- deliberately structured so the fix is safe even if that root cause isn't fully resolved. | diff --git a/ba_auto/tasks/login.py b/ba_auto/tasks/login.py index fc44e6e..48e6afa 100644 --- a/ba_auto/tasks/login.py +++ b/ba_auto/tasks/login.py @@ -212,6 +212,38 @@ 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 @@ -236,6 +268,19 @@ def _news_dialog_open(driver, config): 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: @@ -306,7 +351,16 @@ def _connectivity_confirmed(driver, config): 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") diff --git a/ba_cron_run.sh b/ba_cron_run.sh old mode 100644 new mode 100755 diff --git a/plan.md b/plan.md index 26a327c..c0b6bde 100644 --- a/plan.md +++ b/plan.md @@ -941,6 +941,38 @@ Live steps taken on nik-gpu (not just docs): **Verification**: a standalone `~/repo/ba-auto-daily/ba_dailies.sh --list-commands` / task dispatch through the new path, plus confirming the crontab's next fire actually reaches `exit_game`, are the two things worth checking to fully close this out -- see plan.md's "Immediate next steps"-style follow-up once the next scheduled `q4h`/`daily` fire happens. +### Phase 20 follow-up: `ba_cron_run.sh` was never actually executable in git (2026-07-19) + +The morning after Phase 20 landed, the user pulled logs and reported nothing had run since the fix -- worth checking, since by then 5 scheduled fires had already passed (q4h@01:00, daily@3:30, daily@4:30, q4h@5:00, q4h@9:00) with zero new log lines, not just "too early to tell." + +Root cause: `git ls-files --stage ba_cron_run.sh` showed mode `100644` (not executable) -- unlike `ba_dailies.sh`/`setup.sh`, both correctly `100755`. This had been true in the repo the whole time; it was invisible under the old deploy model because cron called the manually-deployed `~/ba_cron_run.sh` copy, which had its own executable bit set independently (by hand, at some point, outside git and outside `setup.sh` -- the original `setup.sh` never even copied this file, see its pre-Phase-20 content). Phase 20 pointed cron directly at the checkout's own file for the first time, and its real, always-644, never-noticed git-tracked mode finally mattered -- cron silently couldn't execute it at all, for every fire since the migration. + +Fixed with `chmod +x ba_cron_run.sh` (both locally, so the correct mode is preserved going forward via git's own tracked file mode -- as of this writing not yet committed, since commits in this project only happen on explicit request -- and immediately on nik-gpu's live checkout, so cron didn't have to wait for another rsync). Verified via the same safe bogus-preset dry run this project has used before (`~/repo/ba-auto-daily/ba_cron_run.sh bogus_preset_test`): correctly executed, acquired the lock, dispatched to `ba_dailies.sh`, got the expected "Unknown phase" rejection, and logged `FAILED (exit 1)` -- no game interaction, confirms the wrapper itself now runs. + +Not yet confirmed: an actual real scheduled fire succeeding end-to-end post-fix (next one due at q4h's 13:00 JST slot). The user does not need to manually run `ba_cron_run.sh` themselves -- cron will pick it up automatically now that the file is executable; the open item is just watching that next natural fire's log. + +### Phase 20 follow-up #2: faster stuck-login-loading detection, `_loading_buffer_visible` (2026-07-19) + +The user manually ran `./ba_cron_run.sh daily` (to sanity-check the Phase 20 fix without waiting for the next cron fire) and reported the game stuck at login. Checked timing first: the run had only been going ~3.5 minutes, still within `login.py`'s own first `LOGIN_TIMEOUT_SECONDS` (240s) per-attempt budget -- not actually broken, just legitimately early in a bounded wait. Confirmed correct shortly after: the user reported "Oh the game did relaunched" once the 240s mark passed, meaning the pre-existing `_recover` kill+relaunch fired exactly as designed. + +The user still wanted this faster -- specifically asking to detect the stuck state via "the buffer at center" and trigger kill+relaunch on it, rather than only via the blind wall-clock timeout. Live-captured the actual stuck screen via `scrot` over ssh into `scratchpad/` first (this repo's scratchpad, not `/tmp`), then kept as `screenshots/daily_login/stuck_loading_buffer.png` since it directly calibrated a permanent detector -- while the real stuck instance was still up, confirmed it's exactly state 3's full-bleed loading variant from `login.py`'s own module docstring (rotating splash art, no chrome), with a small gray spinner badge dead center. Pixel-probed it (`scratchpad/probe_login_buffer.py`): the badge's left/right edge columns read a flat, exact neutral gray `(118,118,118)` -- R==G==B -- across their whole sampled vertical span, clearly distinct from every sampled splash-art pixel elsewhere on the same screenshot (all clearly non-gray). The badge's bounding box (roughly x=[928,992], y=[571,627]) is centered almost exactly on the screen's true center (960,600), matching the user's own description. + +Added `config.LOGIN_LOADING_BUFFER_PROBES`/`_RGB`/`_TOLERANCE`/`_STUCK_SECONDS` and `login._loading_buffer_visible`. `_wait_for_home` now tracks how long this specific badge has been seen continuously (`buffer_since`) and returns False early -- triggering `run()`'s existing `_recover` call, no new call site needed -- once it's been visible for `LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s), instead of only bailing out after the full generic 240s. 60s was chosen as a deliberate middle ground: well above the "brief" loading variant's few-second normal case (no real data exists on how long the full-bleed variant normally takes when it ISN'T stuck, so this stays conservative), but far below the generic 240s budget, since this is now a specific, well-understood bad state rather than "anything unrecognized." + +Why this one doesn't share the earlier false-positive problem: `_true_home`/`_connectivity_confirmed` got fooled twice (see Phase 18 follow-up #3) because different ROTATING SPLASH FRAMES could coincidentally satisfy a single-snapshot visual check. This badge is a fixed UI overlay independent of whatever art is rotating behind it, so it doesn't inherit that failure mode. + +Verified offline: all 6 probe points read within tolerance against the real captured screenshot. **Not yet live-confirmed** against a fresh stuck instance actually recovering via this specific faster path -- the triggering instance had already self-recovered via the pre-existing generic-timeout path by the time this fix was written and deployed. + +### Phase 20 follow-up #3: `navigation.return_to_home` crash when the window disappears mid-loop (2026-07-20) + +A real unattended `q4h` cron fire crashed outright (`FAILED (exit 1)`, uncaught `RuntimeError`), reported live by the user with the full traceback and pulled log. Sequence from the log: `event_sweep` swept successfully and finished, `exit_game` ran and printed its own `"[exit_game] game closed."` (meaning its `_confirm_exit` loop had itself already observed `driver.window_exists()` read False), then immediately -- `ba_daily.py`'s centralized post-task cleanup (`_run_task`'s `finally` block, see Phase 19) called `navigation.return_to_home(driver)`, which crashed with `RuntimeError: Blue Archive window not found` from inside `driver.focus_game()`. + +Root-caused by re-reading `return_to_home`'s own code: it already guards its OWN entry (`if not driver.window_exists(): return False`, added for Phase 18's login cold-start case) but the escalation call deeper in its retry loop (`driver.focus_game()`, fired halfway through the round budget as a XIGNCODE-overlay defense -- see the function's own docstring) had no equivalent guard. The real sequence: `_run_task`'s `finally` block's own `driver.window_exists()` check (added in Phase 19 specifically so this check gets skipped once exit_game legitimately closes the game) still read **True** at that exact instant -- the game's teardown after the in-game exit confirmation apparently isn't instantaneous, and xdotool's window query caught it mid-teardown, a few hundred ms before the window was actually, fully gone. That let `return_to_home`'s own entry guard pass too, so it entered the retry loop and started pressing Escape/BACK_BUTTON against a game that was already in the process of exiting. By the time the loop reached its halfway escalation point a few seconds later, the window really had fully disappeared, and the unguarded `driver.focus_game()` call crashed with an uncaught `RuntimeError`, killing the whole script. + +Confirmed via direct inspection on nik-gpu right after the crash that nothing was actually left in a bad state: no `BlueArchive` window, no `BlueArchive.exe` process, and the cron lock file held by nothing -- the game really had closed cleanly as `exit_game` intended; the ONLY problem was the crash itself in the cleanup path immediately afterward, not any lingering bad game state. + +Fixed with the same one-line defensive pattern `click_back` already uses for its own `focus_game()` call: check `driver.window_exists()` again right at the escalation point, returning `False` instead of crashing if the window is already gone by then. This is a general robustness fix to shared navigation cleanup, not something specific to `exit_game` -- any task whose cleanup runs while the game window is disappearing (not just a deliberate `exit_game` close) could in principle hit the same race. Deployed; not yet re-confirmed against a fresh real `exit_game`-then-cleanup sequence (would need another real `q4h`/`daily` fire that includes `exit_game`). + ## Prerequisites ### OCR diff --git a/screenshots/daily_login/stuck_loading_buffer.png b/screenshots/daily_login/stuck_loading_buffer.png new file mode 100644 index 0000000..78b1bfa Binary files /dev/null and b/screenshots/daily_login/stuck_loading_buffer.png differ