# hoverScanSpeedup_PLAN.md — Make the cursor hover-scan faster + more reliable

Status: **DONE 2026-07-02e.** Implemented B → 4 → A (A1-A4) → C, in that order, all in
`grind_local.py` (hot-reload, no restart). `python -m py_compile grind_local.py` and
`python -c "import grind_local"` both pass; `control.py` was NOT touched. See the dated
"Update (2026-07-02e)" entry in `CLAUDE.md`'s Status section for the full writeup.

**Update 2026-07-02i — NEW detection mode "Hybrid 2" (`grind_local.py` + `control.py`; the checkbox/dropdown
option needs a full panel RESTART, the logic hot-reloads on Start).** A fused, candidate-driven detector:
three sources each produce screen-point candidates — (1) MEMORY: project each wanted mob within
VIEW_MOB_RANGE via the rotation-invariant projection (`hybrid2_candidates`); (2) NAMEPLATE pixels:
`rank_targets`→`refine_click_point` body points; (3) MOTION: `_motion_candidates` diffs two frames
MOTION_INTERVAL (0.5s) apart (camera-still, `_CAM_GEN`-gated; reuses the loop's grab) to catch mobs that
shimmer/step. `_fuse_candidates` clusters them (merge within HYBRID2_FUSE_RADIUS, SUM scores so
multi-source agreement boosts a point, score-weight the position), ranks best-first, then `hybrid2_find`
VERIFIES each with the game cursor (`_hover_confirm_spiral` + the reliable `_cursor_over_mob`) and
select+attacks the first real wanted mob — the cursor gate rejects any source's false positive with no
misclick. Mid-fight, `_fight_prefetch_hybrid2` fuses the same three sources (excluding the current target +
inner radius) to pre-locate the NEXT mob into the existing prefetch cache/boxes/TTL. IDLE branch mirrors
cursor mode's memory-gated retry/steer/look-around tail. Tunables: HYBRID2_VERIFY_MAX, HYBRID2_FUSE_RADIUS,
H2_SCORE_MEM/NAME/MOTION + H2_NAME_BODY_BONUS, H2_CONFIRM_OFFSETS, MOTION_INTERVAL/THRESH/MIN_AREA/MAX_AREA.
control.py: new "Hybrid 2" dropdown option + hint, accepted in apply_grind_config, and the learn() colour
sweep now runs for it (uses nameplate colour). Verified py_compile (both files) + import + unit tests
(fusion scoring/corroboration, motion-diff centroid).

**Update 2026-07-02i (follow-ups) — Hybrid 2: self-check after kill, broader confirm hover, ACTIVE motion.**
(a) After a kill / when idle, if the memory map shows a wanted mob within `H2_SELF_CHECK_RANGE` (18u) the
tight self-rings are checked FIRST (a mob already meleeing us wins). (b) The per-candidate confirm hover was
a tight ~32px hand-list ("focused on individual spots") → now a generated centre-out spiral out to
`H2_CONFIRM_MAX_R` (60px), so an off-by-more candidate is still caught (stops on first curattack, so good
candidates stay fast); `HYBRID2_VERIFY_MAX` 8→6 to bound the broader cost. (c) MOTION was wired but DORMANT
in practice — the passive `_motion_candidates` only fires on two same-`_CAM_GEN` frames 500ms apart, which
rarely happens during turn-heavy searching, so the `D` count was ~always 0. Added an ACTIVE `_motion_probe`
(deliberate 2-frame 500ms diff, camera-still-gated, `H2_MOTION_PROBE_EVERY`=1.2s cooldown) that
`hybrid2_find` runs when the cheap sources found nothing AND a mob is hinted (memory/nameplate saw one or the
map shows one near) — so motion now RELIABLY contributes candidates to hunt movers memory/nameplate missed.
Shared `_motion_blobs` extractor; `_h2_verify` factored out. Verified py_compile + import + blob/probe tests.
**Update 2026-07-03 — NEW mode "Hybrid 3" = the (idealised-)OPTIMAL search (`grind_local.py` + `control.py`).**
Derivation: a cursor probe costs a fixed dwell τ and detects a mob ⇔ the point is on its sprite, so minimising
kill→next-attack time = minimising E[probes-to-first-hit] (travel ≪ τ). Search theory (perfect detection,
uniform cost) → probe cells in DECREASING hit-probability ρ. Hybrid 3 builds ρ over an ROI grid (`_h3_field`)
by fusing every source as a bump SHAPED BY ITS UNCERTAINTY — memory+proj = sharp Gaussian; memory scale-only
= a RING (radius trusted, angle not); unprojectable aggro = melee ring; nameplate body = Gaussian; nameplate
tag-drop = vertical ellipse (X sure, Y a guess); motion = medium Gaussian; fight-prefetch = sharpest/highest
weight — SUMMED so corroboration raises ρ quantitatively. Cells are clustered into mob-hypotheses
(`_h3_probe_order`), clusters ordered by peak-ρ then nearest-to-char, cells center-out, and probed
highest-first with the cursor gate until a hit (`hybrid3_find`), hard-stopped at the 5s arc deadline. This
subsumes Hybrid 2's spirals/radius-ring/adaptive-breadth as special cases of one principle, and fixes Hybrid
2's provable suboptimality (per-candidate spirals that exhaust A's outer ring before B's high-prob centre;
symmetric spirals on ring/ellipse-shaped uncertainties). Selectable in the panel; NOT default (Hybrid 2 stays
default until Hybrid 3 is proven live). Tunables H3_GRID / H3_CLUSTER_R / H3_RHO_FLOOR_FRAC / H3_W_* / H3_SIG_*.
Verified py_compile + import + field-fusion test (aggro+prefetch corroboration → peak cell probed first) +
cluster-order test.

**Update 2026-07-02j — Hybrid 2 rebuilt as a BUDGETED, precision-aware pipeline (kill→next-attack review).**
Review found the pieces right but the execution slow: one-size 37-pt broad verify spirals (~1.5s each, up to
~9s for 6 candidates vs the 5s arc budget), no deadline inside a pass, failed candidates re-spiraled every
pass, the motion probe sleeping 0.5s doing nothing, the rotation-invariant radius-ring tool unused, memory
re-read 3-4×/tick, and the self-scan gated on distance-only (wasting ~0.7s/tick on nearby-but-idle mobs).
Fixes: (1) ADAPTIVE verify breadth — candidates carry a precision tag (tight 7 pts / med 19 / broad 37 —
`H2_SPIRAL_TIGHT_R`/`H2_SPIRAL_MED_R`; body-confirmed nameplate + healthy-proj memory = tight, tag-drop /
scale-only = med, motion = broad; fusion keeps the tightest member). (2) `hybrid2_find` is now a staged
pipeline hard-stopped at the 5s arc `deadline`: fused verify → INTERLEAVED active motion (diffs the pass's
own starting frame; stage-1 hovering runs inside the 500ms window so the probe costs ~0s extra) →
RADIUS-RING sweep at the nearest mob's rotation-invariant radius (the one tool that finds a mob when the
projection angle is untrusted and nameplate/motion missed). (3) Spiral-verified-EMPTY spots remembered
`H2_CHECKED_TTL`=2.5s (`_h2_mark_checked`) so a persistent false candidate is never re-paid. (4) ONE
`read_wanted_mobs` per tick reused by every stage. (5) Self-scan gate now uses memory's AGGRO field
(row[8]==2 = targeting the player) or ≤8u, not bare ≤18u. (6) Candidates ranked closest-to-char within
score. (7) Area proven empty by memory → relocate IMMEDIATELY (no 5s wait); the 5s tail uses time.time()
(not the stale loop `now`). Typical hit: ~0.3-0.8s; worst pass = the 5s deadline → 90° turn.

(d) Post-kill: the death handler now engages a cached prefetch candidate IMMEDIATELY (before the CYCLE wait
+ normal search) via `_try_prefetch`, which now checks candidates NEAREST-TO-CENTRE first and takes the
first confirmed mob. (e) Hybrid2 search ORDER is now: brief two-ring SELF-scan FIRST (memory-gated) → aggro
→ prefetch → fused search; and if nothing is found for `H2_SEARCH_TURN_SECS` (5s) it turns the view ~90°
(`rotate_view`) and searches the new arc (steer/relocate when memory says so). The 5s clock (`h2_search_t0`)
is pinned to now during a fight so it counts from the fight's END.

**Update 2026-07-02h — glide reworked to fix missed detection + jumpy circles (`grind_local.py`, Start-only).**
Live testing showed the 2026-07-02g smooth glide MISSED mobs it hovered while the fight-prefetch (per-point
`_cursor_over_mob`) detected fine. Root cause: the glide detected swaps mid-motion via the in-page cursor
logger, but the game only swaps its CSS cursor to `curattack` when the pointer RESTS on a mob for ~1 render
frame — a continuous glide sweeps past a ~30px sprite within a frame, so no swap is ever logged. Fix:
`_glide_scan` now keeps the smooth interpolated TRAVEL between points but DETECTS per-point by DWELLING
`settle` s and calling the same `_cursor_over_mob` read the prefetch uses (logger detection removed from the
scan path; `_confirm_logged_attack`/`_drain_cursor_log`/`_HOVER_BATCH_DEAD` remain only for the vestigial
batched sweep). Also fixed "circles jump too much": each ring is now entered at the point NEAREST the
previous ring's end (glided, not snapped back to angle 0), and reject-listed points are travelled THROUGH
(not `prev=None` teleport-breaks). Two passes = same reliable dwell, differing by spacing + dwell: pass 1
coarse (HOVER_PASS1_STEP) at HOVER_SETTLE (0.035s), pass 2 tight + ±HOVER_PASS2_TOL sub-rings at
HOVER_PASS2_SETTLE (0.045s). `_cursor_over_mob(page, settle=None)` gained the optional per-pass dwell.
HOVER_PASS1_PACE/HOVER_PASS2_PACE now vestigial. Also A (prefetch-ordering) + the death-anchored 2s TTL from
07-02f/g remain. Verified py_compile + import.

**Deviations from the literal plan text (all functionally-motivated, called out for whoever reads
this next):**
- **A1 discovery:** the plan's A1 says "extend [autolocate_yaw's] condition… currently only when
  `MOB_STEER` is on — find that call site in `grind()`." No such call site existed anywhere in the
  codebase (only `CLAUDE.md`/this plan claimed it did — stale/aspirational docs, now corrected). So
  A1 ADDS the first-ever call site in `grind()` (gated on `MOB_STEER or DETECT_MODE in ("cursor",
  "hybrid")`), rather than editing an existing condition.
- **A1 sample shape:** the plan describes passive samples as `(sx, sy, dwx, dwz)`. Implemented as
  `(sx, sy, dwx, dwz, yaw)` — a 5th field (the camera yaw at click time) — because the sign
  self-check (A1's "self-check it… try the flipped sign against the stored samples") needs each
  historical sample's own yaw to de-rotate it into the fit's reference frame; without it there's no
  way to validate a sign hypothesis against history. `_proj_refit` de-rotates each sample into the
  most-recent sample's yaw frame before fitting (best-effort/unrotated when a sample's yaw is None).
- **A1 function split:** the plan's `_proj_feed(page, sx, sy)` is implemented as
  `_proj_feed(page, sx, sy, p0, dest)` — `walk_dir` already reads the pre-click player pos and now
  reads the post-click dest itself (owns click-adjacent TIMING), so `_proj_feed` just owns sample
  ACCEPTANCE + refit, avoiding a redundant `_pos_xyz` read. Same net behaviour, no dedicated
  calibration routine either way.
- **A2 sort:** `read_wanted_mobs` mobs are already distance-sorted (from `re_sense.read_nearby_mobs`),
  so `hover_mob_targeted` filters+slices instead of re-sorting.
- **A4 scope:** the reject-list is wired into both blind-scan paths (`hover_scan_radial`'s per-point
  path and `_hover_scan_batched`) as specified, but NOT into `hover_mob_targeted` — the targeted path
  is already species-filtered via `read_wanted_mobs`, so a reject check there would be redundant (kept
  it out to stay precisely scoped to "the blind fallback" per the plan text).
- Everything else (rAF double-frame read + 400ms safety race, the memory select-confirm poll
  window/fallback, the spiral geometry/step/max, the batched sweep's logger+ring-drain design,
  `HOVER_BATCHED` as a True/False switch) matches the plan as written.

**EXTENSION (same day, 2026-07-02e, follow-up request — also DONE):**
- **ROI restriction:** ALL hover points (blind rings, batched sweep incl. its logged-hit re-hover, and
  the targeted spiral's base + spiral points) now respect the calibrated scan area — `_hover_point_ok`
  = inside `ROI` AND outside `HUD/MINIMAP/BANNER/CHAT_EXCL`, exactly `_roi_mask`'s region, all loaded
  from `scanner_calib.json`. `SELF_EXCL` is deliberately NOT excluded (the innermost rings sit inside
  it; a mob standing next to the char is the first thing we want; hovering the own char never reads
  curattack). The old `_in_excluded` (HUD/minimap/chat only — no ROI, no banner) was folded in — it
  had no other callers. The calibrate-tab overlay (`dump_grind_regions(show_hover=True)`) shows the
  restricted path automatically (it shares `_hover_scan_points`); its info line now says "ROI-clipped".
- **Memory point-filter for blind scans** (`_blind_scan_filter` + `_filter_scan_points`, built ONCE per
  scan from a single `read_wanted_mobs` read; ring ORDER preserved, filtered points skipped): tier 1 =
  projection healthy → keep points within `HOVER_MOB_NEAR_PX` (110 px) of a projected wanted mob;
  tier 2 = camera heading readable → angular sector filter, `HOVER_SECTOR_TOL_DEG` (40°) around each
  mob's predicted screen bearing AND its mirror (mapping sign unestablished); tier 3 = no filter (a
  memory outage never shrinks coverage). One log line per scan: `[hover] blind …: kept/total pts
  (tier)`. `_hover_find`'s backup scan after 2 targeted misses runs `filtered=False` — the projection
  is suspect there, and filtering the backup by the same suspect projection would defeat its purpose.
- Extension deviations: (a) EXTRA SAFETY beyond the request — if a filter would keep ZERO points, the
  scan runs unfiltered instead (guidance may speed a scan up but must never blind it completely);
  (b) tier 2 uses only wanted mobs within `VIEW_MOB_RANGE` (like tier 1) rather than all loaded mobs —
  the blind scan only runs when a close mob exists, and far mobs would only widen sectors pointlessly.

**EXTENSION 2 (2026-07-02e, after live-test feedback "significantly slower + suspect the cursor swap
isn't detected anymore" — also DONE):**
- **Speed regression fixes:** (a) `CURSOR_SYNC_FRAMES` (default **1**) — the always-double-rAF read cost
  ~33ms/pt at 60fps (up to ~67ms under load) on every targeted-spiral point + engage confirm; a single
  rAF suffices because the game's rAF callback (registered at startup) runs before ours within the next
  frame. Set 2 if hover misses return. 400ms safety race kept. (b) Batched-logger HEALTH CHECK: the
  in-page logger now counts raw mousemove events; a full empty sweep that moved the mouse but counted 0
  events = dead listener → loud `[hover] WARNING …` + `_HOVER_BATCH_DEAD` forces the per-point path for
  the rest of the session (a dead logger otherwise silently reads as "no mobs anywhere" — the user's
  exact symptom). Hit-latency audit: confirmed the batched path surfaces a hit at the ring-drain right
  after its ring, NOT at sweep end (no bug found). (c) Per-scan TIMING appended to the `[hover]`
  kept/total lines (`… in 1.4s`, `(hit)` marker) and to targeted hit/miss lines; the kept/total line
  moved from scan START to scan END to carry the elapsed time.
- **Self-first close scan:** `_self_scan_points`/`_hover_self_first` (`HOVER_SELF_RADII` = (28, 56) px,
  8 + 12 points) run FIRST in `_hover_find` — before the targeted spiral and any blind scan. Mobs
  already attacking us stand adjacent to the char and must win over everything; these points are
  ROI-checked + reject-list-checked but deliberately NEVER memory-filtered (projection error / the
  sector filter must not be able to skip a mob standing on top of us).
- **🔵 Live hover-path debug overlay:** page-side `<canvas>` (pointer-events:none, max z-index) + a
  document-level mousemove listener + rAF draw loop — blue circle at the live pointer + a ~3s fading
  trace, drawn entirely in-page from real events (zero extra CDP traffic). `gl.HOVER_VIZ` toggle, wired
  from a new **control.py** checkbox (Grind local → under the 🔍 Mob detection dropdown → "🔵 Show
  hover-scan path"), default OFF; installed at grind start / removed in a `finally` at run end. To keep
  the overlay lifecycle out of the huge loop body, `grind()` became a thin install/try/finally wrapper
  around the renamed `_grind_run()` (same public signature — callers unchanged).
- ⚠️ **control.py WAS edited this round** (the one permitted edit: checkbox HTML + `hoverViz` init/wiring
  + one line in `apply_grind_config`) — the toggle only APPEARS after a FULL PANEL RESTART, not a
  Start/Stop. Both files py_compile OK; `import grind_local` OK.

**EXTENSION 3 (2026-07-02f, after live testing with the overlay — grind_local.py ONLY, NO control.py
change, so Start-only — also DONE):**
- **(1) RADIAL-MEM scan replaces the targeted spiral as primary** (`hover_radial_memory`): scans FULL
  CIRCLES at memory-derived radii around the character instead of scattering spirals around projected
  points. RADIUS MATH: `_proj_screen_radius(dwx,dwz)` = ‖Minv·(dwx,dwz)‖ where Minv is the inverse 2×2
  linear part of the affine (no translation, no yaw rotation). For a (near-)conformal top-down map
  ‖Minv·R·v‖ = scale·‖v‖ for any rotation R, so the screen radius is ROTATION-INVARIANT — trustworthy
  even when the yaw/angle calibration is wrong, which is precisely why we scan a full circle at that
  radius and don't trust the angle. Verified exactly invariant in a unit test (8 angles → identical
  radius). Gated on `_PROJ["coef"] is not None` (NOT `_proj_ok()` — the scale is usable even when the
  fit's ANGLE error is high). Radii binned by `_merge_radii` (merge within `HOVER_RADIAL_STEP` = the
  ±step tolerance band for fit error/movement), rings via `_ring_points` (full circle, `_hover_point_ok`
  + `_reject_recent` guarded). No wanted mob within `VIEW_MOB_RANGE` → returns None (caller repositions —
  the "don't scan close empty space" the user wanted). No coef → falls back to the memory-filtered blind
  radial scan. Wired into `_hover_find` order: self-first → prefetch(item 4) → radial-mem (coef+mob
  close) → blind scan; 2 radial-mem misses → one UNFILTERED blind backup. `hover_mob_targeted` retired
  as primary but kept callable. The `[hover] radial-mem: R rings at radii [..]px from N mobs, hit/miss in
  X.Xs` log + the HOVER_VIZ overlay render these as visible concentric circles automatically.
- **(2) Reliable read restored + selectable:** `HOVER_READ_MODE` (default **"settle"**) — `_cursor_over_mob`
  (new unified helper used at ALL per-point sites: self-scan, radial-mem, blind per-point, batched
  re-hover, engage confirm, prefetch) does move → `sleep(HOVER_SETTLE)` → `cursor_kind` (the proven
  pre-2026-07-02e method). `HOVER_SETTLE` 0.022 → **0.045** (0.022 was too aggressive → missed swaps;
  the game needs ~1 full frame). "raf" mode keeps `cursor_kind_synced`/`CURSOR_SYNC_FRAMES`. Both modes
  get a one-shot re-read on an EMPTY ('' unreadable, ≠ confirmed non-mob) result. Affordable now because
  radial-mem hovers far fewer points.
- **(3) Blocked-region hardening:** `park_cursor` no longer parks at y=0.94 (below ROI → into the
  banner/toolbar → UI overlay + Esc loop); `_park_point` returns a candidate inside ROI and clear of
  every exclusion box (defaults to ~(0.72, 0.86) = bottom-right, clear of the bottom-centre banner).
  `HOVER_EDGE_MARGIN` (0.01) shrinks ROI / expands exclusions in `_hover_point_ok` so points stay a hair
  clear of the reactive UI boundary. Engage cursor-confirm now `_hover_point_ok`-guards the body point
  (skip a candidate in a blocked region). Audited every scan/engage/park `page.mouse.move`: all use
  `steps=1` (endpoint-only event, no path-triggered regions); steps>1 remain only on camera right-drags
  and the captcha human-move (both intentional, not hovers).
- **(4) FIGHT prefetch** (`FIGHT_PREFETCH`=True, `_fight_prefetch`/`_try_prefetch`): during the pure-hold
  memory FIGHT (plain-holding case only), hover `FIGHT_PREFETCH_POINTS` (3) radial-mem points for OTHER
  mobs — EXCLUDING within `HOVER_MOB_NEAR_PX` of the current target's projected screen pos so we don't
  cache the mob we're killing — and cache curattack-confirmed screen points. Hovering never clicks →
  pure-hold safe. Cache is stamped with `_CAM_GEN` (bumped by rotate_view/tilt_forward/calibrate_view/
  walk_dir/face_toward_mobs-drag/_yaw_drag_step — any camera turn OR player walk) + `FIGHT_PREFETCH_TTL`
  (1.0s). On kill→IDLE, `_try_prefetch` (top of `_hover_find`, after self-first) re-hovers cached points
  and returns the first still-curattack one for the caller to `select_and_attack`. Degrades to the normal
  radial-mem/blind scan when disabled / no projection / target unprojectable / no other wanted mob near.
- New tunables: `HOVER_READ_MODE`, `HOVER_SETTLE` (0.022→0.045), `HOVER_EDGE_MARGIN`, `FIGHT_PREFETCH`,
  `FIGHT_PREFETCH_POINTS`, `FIGHT_PREFETCH_TTL`. **NO control.py change this round → Start-only** (a
  Start re-imports grind_local via hot-reload; no panel restart needed).
- Deviations: (a) `_try_prefetch` returns a hover POINT for the caller to select (not doing the select
  itself), so all `_hover_find` branches share one select path — avoids a double-click, species-wanted
  filtering still happens in the caller's `select_and_attack`. (b) The current-target vs other-mob
  exclusion is done purely by SCREEN proximity to the target's projected pos (not entity-id matching):
  `read_target`'s id (+2656 registry key) and `read_nearby_mobs`' id (+368 entity id) aren't the same id
  space, so id-matching would be unreliable; if the target can't be projected we skip that prefetch step.

**EXTENSION 4 (2026-07-02f, prefetch candidates ON the HOVER_VIZ overlay — grind_local.py ONLY,
Start-only — also DONE):** the FIGHT-prefetch candidates now draw as GREEN boxes on the existing blue
hover-path overlay, in lockstep with the cache.
- **Overlay JS API** (added to `_HOVER_VIZ_INSTALL_JS`): `window.__hoverVizSetBoxes([[x,y],…])` stores a
  box list on the overlay state; the rAF draw loop renders a ~28px GREEN (`rgba(70,230,90,0.95)`) square
  outline at each, alongside the blue circle + trace. Zero extra CDP traffic (drawn page-side).
- **Python push** (`_hover_viz_boxes` + `_prefetch_sync_viz`): pushes `_FIGHT_PREFETCH["points"]` to the
  overlay. PURE NO-OP when the overlay isn't installed by us (`_VIZ_ON and HOVER_VIZ` gate → no evaluate
  at all when the toggle is off, so prefetch runs unaffected); try/except so a viz failure never breaks
  prefetch. The box set is driven from the SAME cache list so it can't drift.
- **Lockstep** — `_prefetch_sync_viz(page)` is called after EVERY mutation of the cache: on a confirmed
  add (a box appears), on the per-poll TTL purge in `_fight_prefetch` (boxes for points older than
  `FIGHT_PREFETCH_TTL` disappear within the window), on the `_CAM_GEN` rebuild in `_fight_prefetch`, and
  on both cache clears in `_try_prefetch` (camera-changed path + consume-on-kill path → boxes clear). So
  the three discard conditions (engaged a different mob / camera moved / TTL expired) each empty the boxes
  exactly when they empty the cache.

**EXTENSION 5 (2026-07-02f, two small tunings — grind_local.py ONLY, Start-only — also DONE):**
- **Speedup:** `HOVER_SETTLE` **0.045 → 0.035** (~2 frames @60fps; 0.045 was conservative, 0.035 is the
  speed/reliability balance — still comfortably above the ~1-frame cursor swap; tune up if misses return,
  the empty-result re-read guard still covers a rare slow frame). **Ring density LEFT UNCHANGED** — the
  radial-mem ring arc spacing already ≈ `HOVER_RADIAL_STEP` (~a mob's clickable size); widening it makes
  ANGULAR gaps that the ±`HOVER_RADIAL_STEP` **radial** tolerance band does NOT cover, so it would risk
  stepping over a mob. Per the "if unsure, leave density alone" guidance, only the settle was changed.
- **Prefetch inner-radius exclusion:** new `FIGHT_PREFETCH_MIN_RADIUS` (px, default 140). In
  `_fight_prefetch`'s queue build, skip any radial-mem candidate closer than this to the screen centre
  (the character) — the mob we're FIGHTING sits melee-close to centre, so a near candidate kept
  re-confirming the SAME mob. PREFETCH-ONLY (baked into the prefetch queue): the self-first close scan and
  the idle radial-mem scan are UNCHANGED (they still want close mobs — a mob meleeing us while idle is the
  self-first scan's job). Kept the existing target-projected-pos exclusion too (belt-and-suspenders; that
  one is skipped when the target can't project, this hard radius always applies). Confirmed by test: a mob
  at 100px radius is fully excluded from prefetch while the 200px mob's ring is kept, and self-first still
  hovers its 28/56px rings. Since the green boxes are driven from the cache, an excluded centre candidate
  simply never becomes a box.

**EXTENSION 6 (2026-07-02f, prefetch discard is now DEATH-anchored, not found-time — grind_local.py ONLY,
Start-only — also DONE):**
- **`FIGHT_PREFETCH_TTL` 1.0 → 2.0s, measured from the target's DEATH** (new `_FIGHT_PREFETCH["death_t"]`),
  not from each candidate's found-time. So a candidate confirmed early in a long fight is still valid at
  the kill.
- **No mid-fight age purge** — REMOVED the per-poll TTL purge from `_fight_prefetch`. During the fight
  (death_t is None) candidates NEVER expire by age; they accumulate for the whole fight and the ONLY
  mid-fight invalidation is a camera move (`_CAM_GEN` change → rebuild).
- **Death stamp:** the memory FIGHT→IDLE death/target-gone branch sets `_FIGHT_PREFETCH["death_t"] = now`
  (gated on FIGHT_PREFETCH) — the discard countdown starts here. Reset back to None whenever the cache is
  rebuilt (a fresh fight, detected in `_fight_prefetch` when a live target arrives with a stale death_t) or
  cleared (`_prefetch_clear`), so a new fight's candidates never inherit a stale death stamp.
- **`_try_prefetch` keeps the cache on a FAILED tick:** discards ONLY on (b) camera move or (c) death_t
  older than the TTL (via new `_prefetch_clear`, which empties points/queue + resets death_t + clears the
  boxes). A cached candidate that still reads curattack is returned and the cache consumed ((a) engaged a
  different mob). A tick where nothing re-confirms LEAVES the cache intact — later IDLE ticks within the 2s
  window retry and the green boxes stay visible; prefetch remains a "try first", never a gate (the caller
  still runs radial-mem/blind when it returns None — verified).
- **Box lockstep** preserved: `_prefetch_clear` (discard) pushes empty boxes; a within-TTL miss pushes
  nothing (cache kept → boxes persist). Verified: boxes persist through the fight + up to 2s post-death,
  clearing exactly when the cache does; a fresh fight starts clean (death_t None, old points gone).

**EXTENSION 7 (2026-07-02g, prefetch-ordering bug fix + smooth two-pass glide scan — grind_local.py ONLY,
Start-only — DONE):**
- **A. Prefetch-ordering BUG FIX:** the just-ended fight's cached candidates were dropped whenever the
  memory summary said "no mob close" — `_try_prefetch` lived inside `_hover_find`, which the cursor IDLE
  branch only reaches under `elif close_n > 0:`. Moved `_try_prefetch(page, w, h)` OUT of `_hover_find`
  into the cursor IDLE branch, AFTER the aggro/RED nameplate fast-path but BEFORE the
  `wanted_mob_summary` memory gate. New order: **aggro-red → prefetch → (memory gate) self-first +
  radial-mem/blind**. On a prefetch hit: `clicked = select_and_attack(...); src = "prefetch"` → the
  existing `if clicked:` block goes to FIGHT. It's self-guarding (fast None when cache empty/invalid), so
  it's cheap every idle tick. `_hover_find` now does self-first → radial-mem → blind only (the
  nameplate/hybrid caller doesn't get prefetch — it's a cursor-mode fight feature). Verified the order
  (aggro-red < prefetch < memory-gate) and that `_hover_find` no longer calls `_try_prefetch`.
- **B. Smooth two-pass GLIDE scan** (`HOVER_GLIDE`, default on) replaces the jittery teleport+settle:
  - **Glide primitive** `_glide_scan(page, w, h, rings, pace, should_stop)`: glides the cursor along each
    ring in angular order via interpolated `mouse.move(x, y, steps=k)` with `k = round(seg /
    GLIDE_PX_PER_STEP)` (capped `GLIDE_MAX_STEPS`) → a ~constant smooth pace, no teleport-then-pause.
    Detects curattack DURING the motion via the in-page cursor-swap logger, drained per ring
    (`_confirm_logged_attack` re-hovers + confirms the logged point with `_cursor_over_mob`). A rejected
    point breaks the glide segment so we never chord THROUGH a reject region. Reuses the batched sweep's
    HEALTH check: an empty pass that moved the mouse but the page counted zero mousemove events → set
    `_HOVER_BATCH_DEAD`.
  - **Two passes:** pass 1 fast+coarse (ring radial separation `HOVER_PASS1_STEP`≈1.8× HOVER_RADIAL_STEP,
    pace `HOVER_PASS1_PACE`); if nothing, pass 2 slow+tight (separation HOVER_RADIAL_STEP, pace
    `HOVER_PASS2_PACE`). `hover_radial_memory` glides its memory radii coarse→fast then tight→slow (pass 2
    adds ±`HOVER_PASS2_TOL` tolerance sub-rings via `_mem_rings`); the blind `hover_scan_radial` glides a
    coarse ring set then the full/tight set. Both keep ROI/`_hover_point_ok`/`_reject_recent`,
    should_stop, the blind scan's memory `_filter_scan_points` tiers, and nearest-ring-first order.
  - **Fallback:** when the glide can't run (`HOVER_GLIDE` off, or `_HOVER_BATCH_DEAD` after a dead-logger
    detection), BOTH scans fall through to the current per-point teleport+settle scan — no regression.
    `_hover_scan_rings`/`_ring_points` gained optional `radial_step`/`arc_step` (defaults unchanged, so
    `_hover_scan_points` + the calibration overlay are untouched); the paced-teleport `_hover_scan_batched`
    is superseded (kept for reference, no longer routed to; `HOVER_BATCHED`/`HOVER_SWEEP_PACE` vestigial).
  - New tunables: `HOVER_GLIDE`, `HOVER_PASS1_STEP`, `HOVER_PASS1_PACE`, `HOVER_PASS2_PACE`,
    `HOVER_PASS2_TOL`, `GLIDE_PX_PER_STEP`, `GLIDE_MAX_STEPS`. The HOVER_VIZ overlay shows the smooth glide
    automatically (a good live check). Verified: coarse pass yields fewer rings, glide detects via the
    logger with interpolated moves (steps>1), dead-logger sets the flag + both scans fall back to
    per-point, and both scans run pass1→pass2.

Original plan follows below, unmodified, for reference.

---

Analysis context: the cursor-mode mob finder
(`grind_local.hover_scan_radial`) blind-hovers ~131 ring points × ~30 ms (move round-trip + fixed
22 ms settle + evaluate round-trip) ≈ 4 s per empty scan; it misses swaps under render load (frame
time can hit 100–250 ms so a fixed settle reads a stale cursor), livelocks re-finding the same
unwanted-species mob, and can't cover mobs outside the central 0.33 disc or mobs that move mid-scan.
Meanwhile we ALREADY have every mob's world position from memory (`re_sense.read_nearby_mobs`) and a
PROVEN world→screen projection technique (`click_nav.py`: click-raycast ground truth at
`controller+2540/+2548`, affine fit, <1u error). This plan wires those together.

**All edits in `grind_local.py` unless stated (hot-reloaded — applies on next Start, no panel
restart). Do NOT touch `flyffBot_share/` (stale copy; `prune_for_share.bat` regenerates it). No git
repo — just edit. Match the file's existing comment-heavy style. Verify with
`python -m py_compile grind_local.py` + an import smoke test; no live-game testing (user tests).**

Implementation order: **B → 4 → A → C** (each lands independently useful; A is the big one).

---

## B. rAF-synced cursor read (speed + correctness under load)

Replace the fixed `sleep(HOVER_SETTLE)` + `cursor_kind()` pair with ONE `page.evaluate` that waits
for the game to actually process the hover, then returns the cursor:

- New helper `cursor_kind_synced(page)` next to `cursor_kind`:
  ```js
  () => Promise.race([
    new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => {
      const c = document.querySelector('canvas');
      r((c && c.style.cursor) || document.body.style.cursor || (c && getComputedStyle(c).cursor) || '');
    }))),
    new Promise(r => setTimeout(() => r(''), 400))   // safety: never hang if rAF stalls
  ])
  ```
  Parse with the same `cursor/(\w+)\.` regex (factor the regex parse into a tiny `_cursor_name(css)`
  shared by both readers). Return `""` on any exception (same contract as `cursor_kind`).
- Rationale (put in the comment): the game swaps the CSS cursor in its mousemove handler on the next
  animation frame. Double-rAF = "the frame after the move was processed" — adaptive: ~17–33 ms at
  60 fps (≈ what we pay today), but waits the TRUE frame time under render load instead of reading a
  stale cursor (the fixed 22 ms settle silently misses mobs when frames stretch to 100–250 ms).
- Use it at BOTH per-point sites, removing the sleep+read pair:
  - `hover_scan_radial` (`page.mouse.move(...)` → `cursor_kind_synced(page) == ATTACK_CURSOR`),
  - `engage()`'s `ENGAGE_CURSOR_CONFIRM` block (~line 1127).
- Keep `cursor_kind`/`cursor_over_mob` (other callers + fallback). Keep `HOVER_SETTLE` defined (C
  uses a paced sweep; also a fallback if someone reverts).

## 4. Memory select-confirm in `select_and_attack` (faster + exact)

Today: click → `sleep(SELECT_GAP)` (0.15 s) → `grab_fast` (~18 ms JPEG) → pixel `has_target` /
`is_player_target`, then a SEPARATE `_target_species` memory read. Replace with a memory poll:

- After the select click, poll `re_sense.read_target(page)` every ~0.02 s for up to
  `SELECT_CONFIRM_MAX = 0.35` s (new tunable, near the other timing consts):
  - resolves with `kind == 5` → it's a mob; do the species check from THIS SAME read
    (`name.replace("IDS_PROPMOVER_TXT_","")` vs `MOB_GRIND_SET`, reusing `_wanted_species`) — no
    second evaluate. Wanted → press ATTACK (existing code); unwanted → Tab-deselect, return the
    rejected info (see A4 below).
  - resolves with `kind != 5` → selected a player/NPC → Tab-deselect, return None.
  - `has == False` after the full window → nothing selected. Fall back to ONE legacy pixel check
    (`grab_fast` + `has_target`/`is_player_target`) so a memory outage degrades to today's behaviour,
    then return None/proceed accordingly.
- Typical confirm drops from ~170 ms to ~40–60 ms and the player check becomes exact (`kind != 5`
  replaces the pixel `is_player_target` on the happy path). This mirrors the FIGHT-loop pattern
  (memory primary, pixel fallback) already established on 2026-07-02.

## A. Memory-guided targeted hover (the structural fix)

Goal: stop scanning blind rings; hover a small spiral around each WANTED mob's projected screen
point, nearest first. Empty scans are already skipped via `wanted_mob_summary`; this makes the
non-empty case ~0.3 s instead of ~4 s, targets the RIGHT mob, chases movement, covers the whole
screen, and never hovers unwanted species.

### A1. Projection state + passive calibration (no dedicated calibration walk)

Port the math from `click_nav.py` (`_fit`, `world_to_screen`) into `grind_local.py` module state:

- `_PROJ = {"samples": deque(maxlen=40), "coef": None, "err": None, "yaw_fit": None}` + tunables
  `PROJ_MIN_SAMPLES = 6`, `PROJ_MAX_ERR = 3.0` (world units — beyond this the fit is stale → fall
  back to the blind scan).
- **Sample source = the clicks the bot already makes.** The game writes the world (x,z) a click
  raycasts to at `controller+2540/+2548` (validated in click_nav). Add
  `_proj_feed(page, sx, sy)`: read player pos BEFORE the click (`re_sense._pos_xyz`) and the dest
  AFTER (~0.15 s later, same read as `ClickNav._dest` — a small `page.evaluate` reading
  `f32@(controller+2540)` and `f32@(controller+2548)`); accept the sample `(sx, sy, dwx, dwz)` only
  if `1 < hypot < 40` and the dest differs >0.5u from the previous sample's dest (click_nav's sanity
  gates). Refit with lstsq + the outlier-rejection pass from `ClickNav._fit`/`calibrate` whenever
  ≥`PROJ_MIN_SAMPLES` samples; store mean err.
- **Feed sites:** every GROUND click — `walk_dir` (it already reads pos before/after; add the dest
  read), and any steer/look-around walk that goes through it. Do NOT feed select-clicks on mobs
  (the raycast semantics on a mob hit are unverified).
- **Bootstrap:** at grind start (cursor/hybrid mode), if `click_calib.json` exists (ClickNav's
  cache), load its coeffs as the initial `coef` — `calibrate_view` normalizes the camera to the same
  standard zoom/tilt, so the cached fit is usually valid. Mark err unknown until confirmed by a live
  sample.
- **Camera rotation handling** (the affine is camera-relative; `rotate_view`/`look_around` rotate
  the camera constantly):
  - Record `yaw_fit = re_sense.read_camera_yaw_rad(page)` (may be None) whenever the fit is
    (re)computed or the cache is loaded.
  - In `world_to_screen`, if a current yaw AND `yaw_fit` are available, rotate the world offset by
    the yaw delta before applying the affine (compensates rotations without refitting). The SIGN of
    the compensation is unverified — self-check it: on each new passive sample, compute the
    prediction error with the current compensation; if the running median error exceeds
    `PROJ_MAX_ERR`, try the flipped sign against the stored samples; if neither fits, drop `coef`
    (blind-scan fallback) and refit from fresh samples. Keep this logic small and well-commented.
  - If yaw is NOT readable: invalidate `coef` on every `rotate_view`/`tilt_forward`/
    `calibrate_view` (a module-level `_proj_invalidate()` call at those sites) and let passive
    samples rebuild it. To make yaw usually available, run `re_sense.autolocate_yaw` at grind start
    in cursor mode too (currently only when `MOB_STEER` is on — find that call site in `grind()` and
    extend the condition; it costs ~2.5 s once per session).
- `_proj_ok()` = coef present and (err is None or err ≤ PROJ_MAX_ERR).

### A2. Targeted spiral hover

New `hover_mob_targeted(page, w, h, should_stop=None)`:

- `d = read_wanted_mobs(page)` (already species-filtered). Take mobs with `dist ≤ VIEW_MOB_RANGE`,
  nearest first, cap at `TARGETED_MOB_LIMIT = 4`.
- Per mob: project `(mx - px, mz - pz)` → `(sx, sy)`; skip if outside the viewport (40 px margin) or
  `_in_excluded`. Hover a spiral of offsets — `(0,0)` then rings r = 16, 32, 48, 64, 80 × 8 angles
  (click_nav's `_SPIRAL`; make step/max tunables `TARGETED_SPIRAL_STEP/MAX`) — reading
  `cursor_kind_synced` per point; first `curattack` → return `(x, y)`.
  Halfway through a mob's spiral (mobs move), re-read `read_wanted_mobs` and re-project THAT mob
  (match by idLo/idHi) to re-center the remaining points; drop the mob if it disappeared.
- Respect `should_stop` between points. Return None if all mobs exhausted.
- Worst case ≈ 4 mobs × 41 points × ~20 ms ≈ 3.4 s, typical hit ≈ first mob, first ring ≈ 0.1–0.3 s.

### A3. Grind-loop integration (both call sites of `hover_scan_radial`)

- In cursor mode (`close_n > 0` branch) and hybrid fallback: try
  `hover_mob_targeted` when `_proj_ok()`, else `hover_scan_radial` (blind, unchanged behaviour).
- If the targeted scan ran and found nothing (mobs known but cursor never confirmed — occlusion,
  projection subtly off): count consecutive targeted misses in a local; after 2, run one blind
  `hover_scan_radial` as backup and reset the counter. Log distinctly (`[hover] targeted …` /
  `[hover] blind fallback …`) so the funnel is diagnosable.

### A4. Unwanted-species livelock fix (blind-scan path)

With A2 the targeted path never hovers unwanted mobs. For the blind fallback:

- Module-level `_REJECTED = deque()` of `(x, y, t)`; tunables `REJECT_TTL = 10.0` s,
  `REJECT_RADIUS = 48` px. When `select_and_attack` Tab-rejects an unwanted species, append the
  click point. `hover_scan_radial` (and C's sweep) skip points within `REJECT_RADIUS` of a
  non-expired entry. Purge expired entries lazily.

## C. Batched blind sweep with an in-page cursor logger

Upgrade the blind `hover_scan_radial` to the ~17 ms/point floor and make it swap-loss-proof. Keep it
behind `HOVER_BATCHED = True` (set False → the per-point B path):

- One-time per page (module flag + `page.evaluate`): install `window.__curLog = []` + (a) a passive
  trusted-`mousemove` listener on the canvas recording the latest pointer `(x, y)`, (b) a
  `MutationObserver` on the canvas `style` attribute pushing `{x, y, cur}` (parsed cursor name, last
  pointer pos) on every cursor swap. Cap the array (~200, drop oldest).
- Sweep: `page.mouse.move` through the ring points paced at ~`max(HOVER_SETTLE, one frame)` ≈ 14–17
  ms WITHOUT a per-point read; after each RING, one evaluate fetches-and-clears the log; any
  `curattack` entry → move back to that logged `(x, y)`, confirm with `cursor_kind_synced`, return
  it (the confirm re-hover kills the "pointer moved between hover and observer callback" race).
  Check `should_stop` per ring. Skip A4-rejected points.
- Why it's MORE reliable than sampling: the observer logs every swap even if the sweep has already
  moved on — a hit can't be missed by pacing, only mis-located by a few px, which the re-hover
  confirm fixes. Empty scan drops ~4 s → ~2.2 s.

## Docs + wrap-up

- Add a dated Status entry in `CLAUDE.md` (and the one-line pointers in its cursor-mode/tunables
  sections): B (rAF read), 4 (memory select-confirm), A (targeted hover + passive projection +
  reject-list), C (batched sweep), with the new tunables listed.
- Update `ONBOARDING.md` §4 status line briefly.
- Mark this plan file DONE at the top with any deviations.
- Final check: `python -m py_compile grind_local.py` and `python -c "import grind_local"` (import
  must not require a live page). If `control.py` was touched (it shouldn't need to be), py_compile it
  too and say so (it needs a panel restart, not hot-reload).
