diff --git a/ba_auto/detector.py b/ba_auto/detector.py index 17874d8..eba301b 100644 --- a/ba_auto/detector.py +++ b/ba_auto/detector.py @@ -40,7 +40,8 @@ SPARKLE_SHOT_PATH = os.path.join(config.SCRATCHPAD_DIR, "cafe_live.png") # rather than inferred from a single ambiguous signal like the (0,0) attempt # was. (75, 47) was oversized (the close-together overshoot bug) and (0, 0) # was undersized (clicks the sparkle glyph itself, not the character) -- -# (50, 15) is the empirically-confirmed middle ground. +# (50, 15) was the empirically-confirmed middle ground, then nudged 1px +# further right by the user after watching it land live. SPARKLE_CLICK_OFFSET = (51, 15) SPARKLE_THRESHOLD = 0.97 diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 3bdcda3..e47cc5b 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -5,7 +5,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Local feature | Reference file | Reference functions/classes | Local file | Backend replacements | Status | |---|---|---|---|---|---| | Mailbox | `module/mail.py` | `to_mail`, `implement` | `ba_auto/tasks/mailbox.py` | tap/click via xdotool, screenshot via scrot, `color.rgb_in_range` → `driver.color_at` pixel-probe check | Migrated: real Python, state-verified via color probe (no legacy bridge) | -| Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — room 1's own invite had just taken the account's highest-affection students) then invited row 3 cleanly. Both newly-invited students were immediately patted successfully in the same run (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. **Two further real-usage fixes (2026-07-15, `next_fix.md`)**: (1) the cooldown-notice dismiss above now presses Escape and verifies via `navigation.is_modal_open` with bounded retry, instead of a fixed-coordinate click that could miss and leave the notice open under the following pan drags; (2) `_dismiss_rank_up_if_shown` switched from `navigation.is_on_subscreen`'s single-pixel header probe (found live to misread some characters' rank-up cutscene art as bright) to a new `navigation.is_header_bar_visible`, requiring 8 spread-out header-row points to all read bright via one atomic multi-point capture (`driver.colors_at`, added alongside it). Both best-effort — not yet re-confirmed live post-fix. **A real rank-up happened naturally via cron (2026-07-16)**, reported live with a screenshot of the game left stuck on the cutscene — exposed a third bug, a timing gap rather than a threshold problem: `_dismiss_rank_up_if_shown` was only ever called once, immediately after a pat, with no wait beforehand, but the cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view and conclude "clear" a beat before the actual cutscene appeared. Confirmed via a live pixel-check that the resulting stuck cutscene reads under `is_on_subscreen`/`is_modal_open`'s thresholds too (`SUBSCREEN_HEADER_PROBE` read `(180,227,244)`, r=180 < the 200 both checks need), so `navigation.return_to_home`'s generic cleanup also couldn't recover it — every "are we home" check downstream falsely agreed nothing was wrong, and the whole `q4h` cron run finished "successfully" with the game actually left stuck. Fixed by checking on every `_pat_current_view` poll iteration instead of only right after a pat, so a delayed cutscene appearance is caught (and dismissed) roughly a second later instead of never. **Confirmed live**: manually reproduced the exact stuck state left by the incident and confirmed a single Enter (the same action `_dismiss_rank_up_if_shown` already takes) cleared it back to the real cafe room view; the per-iteration check itself has not yet been re-exercised against a fresh live rank-up trigger end-to-end (same "could not force one on demand" caveat as the original fix). | +| Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — room 1's own invite had just taken the account's highest-affection students) then invited row 3 cleanly. Both newly-invited students were immediately patted successfully in the same run (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. **Two further real-usage fixes (2026-07-15, `next_fix.md`)**: (1) the cooldown-notice dismiss above now presses Escape and verifies via `navigation.is_modal_open` with bounded retry, instead of a fixed-coordinate click that could miss and leave the notice open under the following pan drags; (2) `_dismiss_rank_up_if_shown` switched from `navigation.is_on_subscreen`'s single-pixel header probe (found live to misread some characters' rank-up cutscene art as bright) to a new `navigation.is_header_bar_visible`, requiring 8 spread-out header-row points to all read bright via one atomic multi-point capture (`driver.colors_at`, added alongside it). Both best-effort — not yet re-confirmed live post-fix. **A real rank-up happened naturally via cron (2026-07-16)**, reported live with a screenshot of the game left stuck on the cutscene — exposed a third bug, a timing gap rather than a threshold problem: `_dismiss_rank_up_if_shown` was only ever called once, immediately after a pat, with no wait beforehand, but the cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view and conclude "clear" a beat before the actual cutscene appeared. Confirmed via a live pixel-check that the resulting stuck cutscene reads under `is_on_subscreen`/`is_modal_open`'s thresholds too (`SUBSCREEN_HEADER_PROBE` read `(180,227,244)`, r=180 < the 200 both checks need), so `navigation.return_to_home`'s generic cleanup also couldn't recover it — every "are we home" check downstream falsely agreed nothing was wrong, and the whole `q4h` cron run finished "successfully" with the game actually left stuck. Fixed by checking on every `_pat_current_view` poll iteration instead of only right after a pat, so a delayed cutscene appearance is caught (and dismissed) roughly a second later instead of never. **Confirmed live**: manually reproduced the exact stuck state left by the incident and confirmed a single Enter (the same action `_dismiss_rank_up_if_shown` already takes) cleared it back to the real cafe room view; the per-iteration check itself has not yet been re-exercised against a fresh live rank-up trigger end-to-end (same "could not force one on demand" caveat as the original fix). **`SPARKLE_CLICK_OFFSET` recalibrated (2026-07-17)**: reported live with a screenshot (`screenshots/cafe/bug_student_close_together.png`) — when two students stand close together, the original `(75, 47)` offset (ported byte-for-byte from `scripts/detect_and_click.py`, never independently verified) overshoots past the intended target's head and lands on a different, closer student instead; the click doesn't register, so `find_cafe_sparkle()` matches the same still-showing sparkle again next iteration, repeatedly clicking nearly the same point for the rest of the room's budget (confirmed live: 13 of 16 "pats" in one run clustered within a ~10px box). First recalibration attempt, `(0, 0)` (raw template-match center), was also wrong — reported live ("clicking the actual sparkle instead of the student head"): a coincidental ambient thought-bubble animation at the test position had been misread as a hover confirmation. Properly recalibrated via numbered candidate-point overlays (screenshots with several labeled offset options drawn on a real live sparkle; the user picked the correct one directly on-screen) against two independent real students in different poses (sitting at an arcade cabinet, lying on a couch) — both converged on the same small offset, `(51, 15)`. **Confirmed live** via the real `./ba_dailies.sh cafe` CLI path: 6 sparkles patted across both rooms at genuinely distinct coordinates with zero repeated-click clustering, clean `[cafe] Done.` and true-home return. | | Stamina/AP | `module/collect_daily_task_power.py` | `to_tasks`/`implement` | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Migrated: Mission-panel claim done (see `plan.md` Phase 8). The gem shop's own Daily Free Power flow (`module/collect_daily_free_power.py`) is a separate task now — see the "Gem shop daily free package" row below. **Daily gem reward bugfix (2026-07-16)**: reported live by the user against a real account state — the reference's own `implement` checks TWO separate button regions (the main 一括受取 area, then a second, separate "claim daily pyroxenes" button checked independently after), but only the first was ported originally. Fixed by porting the second region too: a new `MISSION_DAILY_GEM_CLAIM_BUTTON` (config.py), reusing the same bright-yellow-vs-grey color-probe style as the existing `MISSION_CLAIM_PROBE`, clicked via `driver.click` (not Enter — unlike 一括受取, this button has no keybind shown on screen) with a click-then-verify retry loop matching this project's established discipline. **Confirmed live**: gem balance went from 12,384 → 12,404 (exactly the reward's stated +20), clean return to the true home screen, no warnings. | | Gem shop daily free package | `module/collect_daily_free_power.py` | `implement`, `to_purchase_pyroxenes_menu`, `to_purchase_type`, `detect_free_power_availability`, `collect_daily_free_power`, `return_to_main_page` | `ba_auto/tasks/gem_shop.py` | Reference detects every step via `core.picture.co_detect` + fixed-region OpenCV template matching (no OCR anywhere in this reference flow) — ported here as plain color probes instead, matching this project's own established equivalent for a simple flat-color state-A/state-B difference (see `cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`) rather than building new template assets. The free card's own status bar reads flat dark navy (~(41,65,90)) when available vs flat dark red (~(144,38,47)) once claimed today — a clean, high-contrast signal pixel-scanned from real captures, no OCR of the "0"/"1" count text needed. A dedicated 4-point "is this dialog (or a nested confirm/notice card) showing" check (`GEM_SHOP_DIALOG_PROBES`) was needed because `navigation.is_on_subscreen`/`is_modal_open`'s default probes both proved unreliable on this dialog (it overlays the home screen directly rather than being a full subscreen, and its own opaque white card sits right on top of `is_modal_open`'s probe point) — same class of default-probe mismatch `story_sweep.py`/`bounty.py` already documented for their own wide modals, fixed the same multi-point-beats-single-point way `navigation.is_header_bar_visible` was fixed for cafe's rank-up cutscene the same day. | Done. Live-calibrated against nik-gpu 2026-07-15 by manually driving the real dialog end-to-end via raw xdotool/scrot (see `scratchpad/gem_shop_*.png`) — this **genuinely claimed the account's real free package for the day** (0 yen, confirmed +10 AP / +10,000 credits via before/after counter values: AP 64→74, credits 161,391,144→161,401,144), which calibrated both the "available" and "already claimed" visual states from real data. The actual `gem_shop.py` module was then live-tested for real against the resulting "already claimed today" state. The "available → claim" code path itself uses the same coordinates/logic already confirmed live via the manual walkthrough, but has not yet been exercised by the module itself end-to-end — worth a follow-up live check the next time the package resets and hasn't been claimed yet. Added to `DEFAULT_ORDER` (unlike most recent additions): it only ever reclaims a genuinely free, once-per-day resource with no choice to make, matching mailbox/cafe/stamina's own "reclaim something free" category rather than the "spends a resource, needs opt-in" category. | | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow | diff --git a/plan.md b/plan.md index d413355..68fd080 100644 --- a/plan.md +++ b/plan.md @@ -366,6 +366,18 @@ This also explained why the game was left genuinely stuck rather than self-heali Fixed by moving the `_dismiss_rank_up_if_shown` check to the top of `_pat_current_view`'s loop, run on every iteration rather than only right after a pat -- a delayed cutscene appearance now gets caught (and dismissed via the function's own existing Enter-press retry loop) on the very next iteration, roughly a second later, instead of never. **Confirmed live**: reproduced the exact real stuck state left by the incident (the game was still sitting on it) and confirmed a single Enter -- the same action `_dismiss_rank_up_if_shown` already takes -- cleared it back to the real cafe room view, then `navigation.return_to_home` confirmed true home afterward. The per-iteration check itself has not yet been re-exercised against a fresh live rank-up trigger end-to-end (same "could not force one on demand" caveat follow-up #5 already carried). +#### Phase 6 follow-up #7: sparkle click offset overshot into a neighboring student when close together (2026-07-17) + +Reported live with a screenshot (`screenshots/cafe/bug_student_close_together.png`): "This happen when student are too close together... where the click land on student in front. The click should happen on top of the head, very close to the sparkle." + +`detector.find_cafe_sparkle`'s `SPARKLE_CLICK_OFFSET = (75, 47)` was ported byte-for-byte from the original `scripts/detect_and_click.py` (see Phase 6's own writeup above) and never independently recalibrated against this project's real gameplay. A live log showed the actual failure mode: 13 of 16 "successful" pats in one run clustered within a ~10px box (e.g. `(934,522)`, `(936,520)`, `(937,521)`, `(938,521)`...) instead of spreading across genuinely different sparkles -- the offset was reaching far enough right+down to land on a *different*, closer student's body when two stood close together, which doesn't register as a pat, so the same still-showing sparkle just gets matched and "clicked" again next iteration. + +First recalibration attempt set the offset to `(0, 0)` (the raw template-match center, no offset at all) after moving the cursor there against a real live sparkle and observing what looked like a hover-confirmation (a thought-bubble animation appearing above the target character). Deployed and live-tested via the real `./ba_dailies.sh cafe` CLI path -- but the user, watching directly, reported it was now "clicking the actual sparkle instead of the student head" and the run had to be killed (both the local task and the actual remote `ba_daily.py cafe` process, which kept running after the SSH command was stopped). The thought-bubble "confirmation" turned out to be a coincidental ambient animation unrelated to cursor position, not a real signal -- (0, 0) undershoots just as `(75, 47)` overshoots. + +Properly recalibrated via a new interactive technique, per the user's own suggestion ("You may write number on the grid and ask me to choose"): a script matches the live sparkle, draws several numbered candidate offset points directly on a cropped screenshot of the real, current sparkle, and the user picks the correct one by eye. Run against two independent real students in different poses -- a sitting arcade-cabinet pose and a lying-down couch pose -- both converged close to the same small offset (the couch pose's second-round refinement and the arcade pose's confirmed pick landed within a few px of each other), confirming a small offset generalizes reasonably across poses even though a single fixed offset can never be exactly right for every pose. Landed on `(51, 15)` (`(50, 15)` empirically confirmed by the user via the numbered-candidate picker, then nudged 1px right by the user directly after watching a live run land). + +**Confirmed live** via a fresh real `./ba_dailies.sh cafe` run with the new offset: 6 sparkles patted across both rooms, all at genuinely distinct coordinates (no repeated-click clustering), clean `[cafe] Done.` and confirmed true-home return afterward. Not specifically re-reproduced against a live "two students close together" scenario (can't force that layout on demand), but the smaller offset directly addresses the overshoot mechanism the original bug report diagnosed. + ### Phase 7: setup.sh update **Status: Done — `setup.sh` deploys `ba_daily.py` and `ba_auto/`.** `scripts/ba_dailies_legacy.sh` and `scripts/detect_and_click.py` were deleted once mailbox and cafe both migrated off them (Phases 5–6); `setup.sh` no longer references either.