# CLAUDE.md — Agent operating guide

Project: **Flyff Universe bot suite.** A multi-mode bot driven from a control panel
(opened in a SEPARATE browser window). Modes: **grind** (vision auto-attack + food
heal), **heal / heal+buff** (a follower Ringmaster that spams heal + runs a buff
rotation), and **ShopScanner** (walks a city, OCRs private shops, builds a price
database). Python + Playwright drives a real headed Chromium (trusted CDP input);
sensing is OpenCV + Tesseract OCR on screenshots. See `PROJECT_OVERVIEW.md` for rationale.

**Distribution:** built to share — a new user runs **`install.bat`** (installs
Python/venv/deps/Chromium/Tesseract) then **`flyffbot.bat`**. `prune_for_share.bat`
makes a clean shareable copy. `START_HERE.txt` is the end-user quick start.

**License gate (2026-07-05, AWS):** the shared copy is restricted to holders of a valid **license key** you can
**revoke**. `flyffbot.py` calls `license_check.require()` on launch: it POSTs the key (from `license.key`) to an
AWS API, which returns a **KMS-signed** `{valid, exp}` that the client verifies against a **pinned public key**
(`license_config.py`), so a fake server can't forge a "valid" reply. Keys are **floating** (no device binding) +
**perpetual until revoked**; a signed token is cached for `GraceDays` **offline grace**. Backend = serverless SAM
(`license_server/`: HTTP API + 2 Lambdas + DynamoDB + KMS; a password-gated web **admin page** at `/admin` to
issue/revoke/list). Your DEV copy has a **`LICENSE_DEV`** marker → the check is a NO-OP; `prune_for_share.bat`
EXCLUDES that marker + `license_server/` + `license.key`, so only the SHARE enforces. Setup + deploy in
`license_server/README.md`. ⚠️ Plaintext-Python client = a deterrent + revocation, NOT unbreakable DRM.

## How to run

**Normal use — `flyffbot.bat`** (one self-contained instance):
- `flyffbot.bat` → instance "main"; `flyffbot.bat farmer2` → a named instance.
- Each opens its OWN Chromium with its OWN profile (`profiles/<name>`), so you log
  into a different account per instance. Multiple can run in parallel — they don't
  share a cursor (input goes to each window via Playwright).
- **Chromium can't share one profile across two windows** (a 2nd launch on the same
  `user-data-dir` just hands off to the open window and exits → looks like a launch
  failure). So a NO-NAME launch auto-picks a unique name when one's already running:
  `instance_name()` reuses "main" only when base port 9222 is free, else `main2`,
  `main3`, … (keyed off the auto-assigned debug port). Closing "main" frees the slot,
  so the next no-name launch reuses "main" + its login. An explicit name always wins;
  if that profile is already open the launch prints a friendly "use another name" hint.
- Log into the account/char in the window, then use the **panel** (top-right).
- `flyffbot.py [name]` is the launcher; it auto-assigns a free debug port and runs
  `control.run_panel(page, w, h)`.

**Dev harness** (iterate with probe scripts against one shared window):
- `python host.py` (background) → keeps ONE headed Chromium open (profile
  `./chrome-profile`, CDP port 9222). Log in once.
- `python -u control.py` → attaches via `session.get_page` and runs the same panel.
- Probe scripts (`shot.py`, etc.) attach via `session.py` too. Stop a script with
  PowerShell `Get-CimInstance Win32_Process … -match '<file>.py' | Stop-Process`.

## Hot-reload (IMPORTANT)
`control.run_session` calls `importlib.reload(...)` on every Start, so **edits to the
mode modules apply on the next Start — no restart:** `grind_local.py`, `grind_local_2.py`,
and **`shop_scanner.py`**. Edits to **`control.py`** itself (panel HTML, supervisor loop,
the heal/heal+buff/quickbuff loops, the `update_*` panel functions) **DO need a restart.**

## The control panel (separate browser window — `CONTROL_HTML` in `control.py`)
Opens in its OWN window (`open_control_window`); falls back to an in-game injected panel.
Buttons set `window.__bot = {cmd, mode, gen}`; the supervisor (`run_panel`) polls it and
dispatches `run_session`. Panel params live in `window.__heal`, `window.__grind`, `window.__shop`.

**Top-level TABS** (dashboards): 🎛️ Control · ❤️ Calibrate Status Scanner ·
🎯 Calibrate Grind scanner · 📈 EXP graphs · 🛒 Shop DB · 📡 Packets.

**The Control tab has per-script SUB-PANELS** (`showScript` shows only the selected
script's cards by `data-scripts`): **Grind local · Heal+Buff · Heal · Grind selected ·
Grind mem · ShopScanner · Calibrate**. A shared **▶ Start** (label/command follow the
sub-tab) + **■ Stop** row sits on top; Heal+Buff additionally shows a blue **✨ Quick buff**
button (one-shot full buff pass, no heals). Each sub-panel leads with a ⌨️ Keybinds card +
🕹️ how-to-start note. Status line: `[mode] state · …`.

## Modes (all dispatched in `control.run_session`)
- **grind_local / grind_selected / calibrate** — vision bot in `grind_local.py`.
- **heal / heal+buff** — a follower **Ringmaster** support loop (in `control.py`). `heal`
  spams the heal key on an interval; `heal+buff` also runs a buff rotation. See "Heal/Heal+Buff".
- **shopscan** — **ShopScanner** (`shop_scanner.py`). See "ShopScanner".
- **grind_local_2 (HYBRID, `grind_local_2.py`, parked)** — SENSE mobs from WASM **memory** (entity
  registry @362748: exact ids + world positions, ROCK-SOLID), ACT via **clicks**. The world→screen
  projection bridge was NEVER made reliable, so it clicks ONLY vision-detected nameplates; it ≈
  grind_local and underperforms. Parked behind the vision bot. Crafted packets do NOT drive the
  character (proven). See `reverseEngineeringWASM/PacketCrafting_PLAN.md`.

## Heal / Heal+Buff (Ringmaster support — in `control.py`)
Follow the char you support, press Start. Keys (top number row, NOT numpad): **1** = heal,
**0** = MP potion, **Z** = follow target. **flee-aggro and random-chat default OFF**; map-fiddle on.
- **Follow re-press** (default ON): both loops re-press **`FOLLOW_KEY`=Z** every **`FOLLOW_EVERY`** (5 s,
  panel field "🧲 Follow target (Z) every (s)" + checkbox) so the Ringmaster keeps following the grinder
  when the game's follow state drops out. Assumes Z re-asserts follow (idempotent), not a toggle.
- **🦘 Anti-stuck JUMP** (`jump_if_stuck`, default ON; both loops when follow is on) — the #1 cause of the
  grinder (Blade) dying was the assist getting stuck on terrain, dropping out of heal range. Fix: track the
  assist's OWN pos + the followed char (= our memory TARGET `re_sense.read_target`, the Blade we heal+follow);
  if the target is FAR (`JUMP_STUCK_FAR`=10 u) AND we make NO progress toward it (not moving >0.6 u AND not
  getting >0.8 u closer) for the panel time ("🦘 Jump if stuck following (s)", `jumpStuckSecs` default 3), press
  **Space** to hop the obstacle + re-press Z. ≥1.2 s between hops. Panel: checkbox + seconds field in the Heal
  card (`window.__heal.jumpStuck`/`jumpStuckSecs`).
- **Anti-captcha (heal modes get the captcha; grind doesn't — grind moves the mouse constantly, a
  key-only heal loop doesn't):**
  - `idle_mouse_drift` glides the cursor a few small hops every `IDLE_MOUSE_MIN..MAX` (2.5–7 s; PURE
    moves, never clicks) — "🖱️ Idle mouse movement" (default ON, `read_mouse_enabled`).
  - `smooth_look_around` — slow right-drag camera look-around along a **Bezier curve** (`_bezier`)
    through random points, every `read_look_interval` (panel "🔄 Look around every (s)", `LOOK_EVERY`
    default 18 s, jittered ±35%) — "🔄 Smoothly look around" checkbox (default **ON**, `read_look_enabled`);
    right button always released in `finally`. Briefly holds the heal loop (~1–2 s).
  - **`tap(act,k)`** presses keys with a JITTERED hold (`KEY_HOLD` 0.045–0.11 s, not a robotic fixed
    0.06); the heal interval is jittered ×0.85–1.3 so it isn't metronomic.
  - **All MOUSE actions (drift / look / map-fiddle / recalibrate) are spaced ≥`MOUSE_ACTION_GAP` (2.5 s)
    apart** via a per-loop `last_mouse_at` + `mouse_free()` gate, so they never pile up into a burst.
  - (On top of the existing flee/chat. The **flee** toggle now lives in the 💚 Heal/buff card, not the
    anti-detect card.)
- **🔔 Alert for bot detection (2026-07-05, global checkbox `window.__botAlert`, default OFF).** The anti-bot
  captcha is **Google reCAPTCHA v2** (RE'd from the WASM: `grecaptcha`/`captchaSiteKey`; `api.js?render=explicit`;
  the WASM renders it via emval on a SERVER-delivered site key; tier-2 = the "select all bicycles" IMAGE
  challenge = a VISIBLE `bframe` reCAPTCHA iframe, which CANNOT be auto-solved). The old vision `solve_captcha`
  (find a solid-white box → click the checkbox) only handles tier-1. When this box is on, `captcha_pause`
  (called from `stopped()`, so ALL modes) DOM-detects the tier-2 challenge on the game page
  (`captcha_challenge_up` = a visible on-screen non-hidden `bframe` iframe — far more reliable than the
  white-box scan) and **PAUSES (blocks the loop) + beeps the control window every 5 s** (Web-Audio oscillator,
  `window.__captchaBeep`; the AudioContext is created on the checkbox tick = a user gesture, satisfying autoplay)
  until the human solves it (challenge gone) or Stop is pressed — then it **resumes normally**. RE tools:
  `captcha_probe.py` (arms grecaptcha.render/getResponse hooks + a bframe MutationObserver on the live page).
- **Heal delay** default **0.5 s** (`HEAL_INTERVAL`). **MP potion = THRESHOLD only** (the old
  "every N s" interval was removed as redundant): `mp_threshold_drink` reads OUR OWN MP straight from
  **WASM memory** (`player_mp_frac`, deobfuscated + peak-tracked — no screenshot/OCR) and drinks `0`
  below the set % (default 40).
- **Self-heal via FOOD** (`food_threshold_eat`, both loops, same 2 s `MP_CHECK_EVERY` tick, **and now
  also checked after every buff step inside a buff cycle** — see Heal+Buff below): reads OUR OWN HP
  from **WASM memory** (`player_hp_frac`, same deobfuscated read as the MEM header row) and below the
  panel % ("🍖 Eat food (F4·1) below HP %", default **90**, `0`=off) eats the food on **`FOOD_PAGE`=F4
  slot 1** — press F4 → press 1 → press `BUFF_PANELS[0]`=F2 back to the heal page (so heal-spam
  resumes; **food + heal must be slot 1 on F4 / F2**). `FOOD_COOLDOWN` 3 s between eats. Covers spike
  damage / out-of-MP when the heal skill alone isn't enough.
- **Heal only if target HP below % + BURST-heal (memory)** (checkbox + `healIfTargetPct`, default off/90):
  when on, `heal_pass` reads the SELECTED target's EXACT HP from **memory** (`re_sense.read_target`,
  peak-tracked max via `target_hp_frac`) and heals ONLY when it's below the threshold. Called once per loop
  pass with a Heal-delay sleep between passes, it therefore **bursts at the Heal-delay frequency while the
  target is under the threshold and stops the instant it recovers above** — single-shot per pass so follow
  (Z) + buff refresh aren't starved during a long low-HP stretch. When the gate is off, or memory can't
  resolve a target (no target / unreadable), heals unconditionally — **no vision/OCR fallback** (the old
  `should_heal`/`gl.mob_hp` pixel gate was removed 2026-07-02; all self/support HP+MP gauges in
  grind_local/heal/heal+buff now read WASM memory exclusively). Both heal & heal+buff loops use it.
  Restart to apply (control.py).
- **💓 Fast heal POLL (2026-07-05) — react to a spike within ~0.1 s, not once per heal delay.** `heal_pass`+a
  fixed `sleep(heal_delay)` REPLACED by **`heal_wait`** (both heal & heal+buff loops): while the target is
  at/above the threshold it POLLS its memory HP every **`healPollMs`** (panel "💓 Heal check every (ms)",
  default **100 ms**) and heals the INSTANT it drops below; after a heal it waits the (longer) **Heal delay**
  as the cast/cooldown spacing, so the CAST rate is unchanged but the REACTION is ~heal-poll. Decouples
  detection cadence from the heal delay (before, a drop was only noticed once per ~0.5 s heal-delay sleep).
  Gate-off / unreadable HP → heal once then wait heal-delay (as before). Still scans captcha ~1/s.
- **Heal+Buff = TWO buff classes, each on its own refresh timer.** `BUFF_CLASS1` = pages **F2 2–9
  then F3 2–6** (refresh `Buff every`, default 180 s); `BUFF_CLASS2` = **F3 7–9** (refresh `Buff 2
  every`, default 50 s). **Buff key delay** default **1300 ms** (`BUFF_STEP_DELAY`); F2/F3 page switches
  use only a tiny `PANEL_SWITCH_GAP` (0.08 s), not the full buff delay.
- **🎁 Buff-slot ICONS (2026-07-05):** the slot buttons show the actual in-game skill icon as their background —
  cropped from F2/F3 hotbar screenshots into **`buff_icons.json`** (base64 PNGs, `{F2:{'2':dataURI,…},F3:{…}}`),
  baked into `CONTROL_HTML` as `window.__buffIcons` via `_buff_icons_js()` (placeholder `%BUFFICONS%`). `renderHealSlots`
  sets `background-image`; ON = full colour + blue border + slot#, OFF = grayscale/dim + red ✕. Hotbar cell pitch
  is **36 px**, cell 1 = slot **1** (heal), so buff slots 2–9 = hotbar cells 2–9. To refresh: re-crop and overwrite
  `buff_icons.json` (empty slots just show the number). Icons present: F2 2–9, F3 2/3/4/7.
- **🎁 Per-slot buff SELECTION (2026-07-05):** two panel button rows (F2, F3 · slots 2–9) let you tick/untick
  which buffs get cast on the GRINDER (`window.__heal.buffSlots` = `{F2:{'n':bool},F3:{…}}`, a slot is ON unless
  explicitly false = default all-on) + a SEPARATE two-row grid for the SELF-buffs (`selfbuffSlots`, default =
  class-1 = F3 7–9 off). `run_buffs` filters its list through `_slot_on(buff_mask,…)` (also applied to quickbuff);
  the 🧙 self-buff builds its sequence from `build_selfbuff_list(self_mask)`. Masks live in `window.__heal`,
  rendered by `renderHealSlots(boxId,cfg)`; read via `read_buff_slot_masks`. Restart to apply (control.py).
- **BETWEEN-BUFF healing = HEAL-TO-THRESHOLD (memory), NOT a fixed count** (2026-07-02, replaced the old
  `Heals between buffs` param, which was REMOVED — panel field + `read_heals_between` + `HEALS_BETWEEN`
  gone). In `run_buffs`, after each buff it reads the SELECTED target's EXACT HP from memory
  (`target_hp_frac`) and **heals repeatedly (spaced by the heal delay) until the target is back at/above
  the heal threshold** (`read_heal_target_threshold` = the "💚 Heal only if target HP below %" field,
  default 90 %), THEN casts the next buff. A healthy target gets buffed straight through (0 heals); a hurt
  one is topped up first. Capped at **`HEAL_BURST_MAX`=12** heals per gap so sustained damage can't starve
  the buff sequence (the next between-buff check heals again). Unreadable target HP (no target / memory
  miss) → one safety heal, then continue. **Timing:** buff↔heal boundaries use the **buff key delay**
  (`step`) — buff pressed → `_tick_sleep(step)` before the first heal, and after the last heal a
  `max(0, step − heal_delay)` top-up so heal→next-buff ≈ `step` — while heal↔heal uses the (shorter) heal
  delay. So the buff still gets its full cast time on each side; only the heal COUNT is now dynamic.
- `BUFF_PREBUFF` = **F3 7** is cast FIRST before every class-1 cycle (a self-buff that boosts buffing).
  **📢 Announce buff** (checkbox) types "rebuffing now" in chat at each class-1 cycle.
- **THREE one-shot buff BUTTONS in the Heal+Buff Start row** (each a `run_session` mode that self-stops):
  **✨ Quick buff** (`quickbuff`) = full buff (class 1+2, minus unticked slots) on the SELECTED target, no heals ·
  **🧙 Self-buff** (`selfbuff_once`) = the ticked `selfbuffSlots` cast on SELF (Esc-deselect → self-cast →
  re-select+refollow the grinder) · **🎯 Self+target** (`essbuff`) = **essential self-buffs**
  (`ESSENTIAL_SELF_BUFF` = F3·7, F2·2 ×2) on self, then re-select the saved target and cast **full class-1**
  (F2 2-9, F3 2-6, minus unticked) on it. All use the same Esc-deselect / `send_select`-reselect target-switch
  as the loop self-buff (⚠️ SELECT packet = throwaway). Shown only on the healbuff sub-tab (`showScript`).
- **🧙 SELF-BUFF (2026-07-05, heal+buff only, checkbox `selfbuff`, default OFF) — buff the RINGMASTER itself by
  briefly target-switching.** The RM heals/buffs whoever is SELECTED (the grinder, kept via Z-follow), so a buff
  hits the grinder, not the RM. To self-buff: `do_one_self_buff` caches the grinder id (`re_sense.read_target_id`
  =controller+2656 → `blade_id`), presses **Esc to DESELECT the grinder** (a no-target buff then casts on SELF),
  casts ONE class-1 buff, then **re-selects the grinder** (`re_sense.send_select`, a crafted SELECT packet) +
  re-presses Z. **⚠️ Do NOT try to server-SELECT self** — a SELECT packet with your OWN id no-ops server-side (the
  client `+2656` got written so it *looked* self-targeted, but the server kept the grinder and the buff hit the
  grinder); a real **Esc** input triggers the game's actual server-side deselect, so the buff self-casts (Aaron
  2026-07-05). The RE-select of the grinder (a DIFFERENT entity) via `send_select` works like it does for mobs in
  Grind RE. Done **one buff per pass** and ONLY while the
  grinder is HEALTHY (`target_hp_frac >= heal threshold`) — a hurt grinder is healed first, self-buff deferred
  — so the grinder is never abandoned mid-danger. Cycles through the ticked **`selfbuffSlots`** (see 🎁 Per-slot
  buff SELECTION above; `build_selfbuff_list`, idx `self_buff_idx`); when the whole set is cast it waits
  **`selfbuffEvery`** ("🧙 Self-buff refresh (s)", default = class-1 interval) before
  re-doing them. `re_sense.ensure_ws` is called at healbuff Start so the SELECT packets can send. **⚠️ SENDS
  SELECT packets → THROWAWAY account only** (user chose the packet route over click-on-party-portraits, 2026-07-05).
  Restart to apply (control.py). **Default ON (2026-07-05).**
- **🩹 SELF-HEAL if attacked (2026-07-05, heal+buff only, checkbox `selfheal`, default ON) — same target-switch
  as self-buff.** When OUR OWN HP (`player_hp_frac`) drops below **`selfhealPct`** ("🩹 Self-heal below own HP %",
  default 70) — i.e. a mob is hitting the RM — `do_self_heal` presses **Esc** (deselect → the heal self-casts),
  spams the HEAL key on self until own HP recovers (capped `HEAL_BURST_MAX`), then `send_select`-re-targets the
  grinder + re-follows. Checked HIGH-PRIORITY at the top of the loop (before buffs), so a threatened RM heals
  itself first. THROWAWAY only (the re-select is a SELECT packet).
- **💾 SAVE/LOAD heal+buff settings (2026-07-05).** "💾 Save settings" button writes ALL of `window.__heal`
  (slot grids + every toggle/threshold) to **`heal_settings.json`** (`_save_heal_settings`, polled via
  `window.__healSaveReq` in `update_heal_save` — works idle AND mid-run). At import, `_heal_saved_js()` bakes an
  `Object.assign(window.__heal, <saved>)` into `CONTROL_HTML` (placeholder `%HEALSAVED%`, runs right after the
  defaults + before the UI wiring, so saved settings win and the UI reflects them). So panel settings persist
  across launches. **Changed DEFAULTS (2026-07-05):** heal `mouseEnabled`/`lookEnabled`/`mapEnabled` → **OFF**
  and grind_re `look`/`mouse` → **OFF** (idle-mouse / look-around / map-fiddle off everywhere by default);
  `selfbuff`+`selfheal`+`healIfTarget` → **ON**; slot grids default to **F2 3–9 + F3 3–6** (slot 2 + F3 7–9 off).

## ShopScanner (`shop_scanner.py`)
Walks a shop city; reads OPEN private-shop windows via OCR into **`shop_db.json`** (item → price
range, date, seller). View/search/export in the **🛒 Shop DB** tab (per-item history, count, CSV).
- **Window located by TEMPLATE MATCH** (`templates/shop_anchor.png` = the "Sale List / Chat /
  Message" bar) — NOT a fixed box. Flyff's UI is **fixed-pixel** (proven: HP bar = 24 px wide at
  both 1917 px and 935 px viewports), so a fixed-size anchor matches at any browser-window size and
  any window position. **No calibration needed.** Use a TALL game window so whole shops fit.
- OCR: names are **white** → isolate white-only (kills colored-icon bleed); prices are **colored**.
  Tesseract drops inter-word spaces at this size → names come out concatenated (`PactBoots5`) — fine
  as a consistent DB key. Cells found by their colorful icons; split-icon rows merged.
- **Auto-roam** (toggle, best-effort): vendor `[Vendor]/[Händler]` tags are light **BLUE** (hue
  ~110–140, NOT cyan); walk to the nearest, wait for the shop to open, record, move on. Uses
  `calibrate_view`/`rotate_view`; only moves toward detected vendors (no blind ground-clicks).
  Close a shop with a short **`w`** tap (Escape does NOT close private shops).

## Verified game mechanics (don't re-derive)
- **Attack = TWO separate left-clicks** on the mob (select, then attack); the
  character then auto-attacks to the death. `engage()` does: click below the name
  tag (select) → click the **selection-arrows' center** (precise attack point).
  `dblclick`/`Ctrl+Click` do NOT attack.
- **Never click mid-fight** — it cancels the auto-attack / walks you off. The fight
  loop is **pure hold**: while a mob is selected, do nothing; selection lost = dead.
  **(2026-07-02) FIGHT-state progress-tracking + kill/death-confirm now read the SELECTED TARGET straight
  from WASM MEMORY** (`re_sense.read_target` — deobfuscated exact HP + `kind` off `controller+2656` ->
  registry), not the vision target frame — no bar-band ambiguity, and it fires the instant HP hits 0
  rather than waiting for the frame to visually vanish. `kind != 5` (not a mob, e.g. a player) drops the
  target and re-searches immediately, replacing the old vision "#"-tag player check. **Death is confirmed
  by N consecutive UNRESOLVED/dead memory polls** (`DEATH_CONFIRM_CONSECUTIVE_ABSENT`, default 3, spaced
  `FIGHT_POLL`=0.1 s → ≈0.3 s window) so a 1-frame stale/unsynced read never abandons a live mob. Safety
  re-engage only after `FIGHT_TIMEOUT` (20 s); hard give-up after `GIVE_UP_AFTER`.
  The vision **top-center target frame** (`target_present`, detected by its **RED HP bar with a
  CYAN-BLUE MP bar directly BELOW it, both FILLED LEFT→RIGHT** — `_left_run` ≥
  `TARGET_BAR_MIN_RUN`/`TARGET_BAR_RED_MIN`; `_target_masks`) is **still used** for `engage()`'s
  selection-confirm (right after a select-click, before memory has necessarily updated) and the header's
  PIXEL/OCR row — just no longer for the live grind loop's kill-counting. Requiring both-stacked rejects
  the single-colour false positives that bit us: a lone **RED aggro NAME TAG** in the top-centre (no blue
  under it) and blue **WATER** (no red above it). **CRITICAL: the box is ABSOLUTE pixels — `TARGET_BAR_X`
  (x RELATIVE TO WINDOW CENTRE, the frame is centred) + `TARGET_BAR_Y` (px from the TOP).**
- **Selection arrows**: cyan when SELECTED, **red when actively ATTACKING**. Useful
  signal but a GLOBAL red count is confounded in red-mob areas (red everywhere) —
  use it only locally if at all.
- **Detection primary = MOB NAME TAGS by colour** (`find_nameplate_mobs`): mob tags are
  **light YELLOW/BEIGE** (normal) or **RED** (aggro). **PLAYER tags are WHITE or GREEN and
  are deliberately NOT matched**; **PET name tags are LIGHT PURPLE** (`TAG_PURPLE_*`, hue ~131) and
  the pet's BODY (an orange fox reads as beige) is excluded via a zone `PET_BODY_SPAN` px BELOW each
  purple name. **Safety: >`PET_MAX_ZONES` (3) purple "pet" tags = a purple effect/UI false-firing →
  ALL pet zones ignored** (else a transient purple blob silently masks real mobs and the bot wanders
  past them). Tunables: `TAG_YELLOW_H/SMIN/VMIN` (raise `TAG_YELLOW_SMIN` if players still get
  clicked; lower if pale beige mob tags are missed). Mob ≈ `NAMEPLATE_DROP` px below the tag.
- **Detection funnel diag (`funnel_summary`/`_LAST_FUNNEL`):** when grind goes IDLE→wander, it prints
  `[wander] … raw_tags=N … kept=K | dropped: pet/self-banner/avoid/colour … | pet_zones=…`. Read it to
  diagnose a "mobs visible but ignored after a while" spell: **raw_tags=0** = view/zoom/colour/ROI drift
  (NOT a filter — recalibrate); **raw>0 but kept=0** = the named filter ate them (usually `pet`).
- **Target/mob HP** (`mob_hp`): the SELECTED target's HP from the **top-centre target frame** red
  bar (same rightmost-red-column method as the player HP bar), on a **calibratable absolute-pixel
  box** `TGT_*` (in `scanner_calib.json`). Now only feeds the header's PIXEL/OCR row — grind
  progress-tracking/kill-confirm and the healer's "only heal if target HP < %" gate both read
  MEMORY instead (`re_sense.read_target` — see "Verified game mechanics" above and target_hp_frac
  in `control.py`).
- **`calibrate_view`**: max zoom-out (consistent reference) → zoom IN `ZOOM_IN_STEPS`
  → tilt to top-down. Wheel `-120`=out, `+120`=in. Right-drag DOWN = top-down. **Also runs
  on a TIMER** mid-grind (`CALIBRATE_EVERY`, default 35 s, when not fighting) so the view
  never drifts.
- HP heal: read OWN HP straight from **WASM memory** (`re_sense.HpReader`, deobfuscated + peak-tracked,
  vision-free — instantiated once per `grind()` call); press food key (`FOOD_KEY="1"`) under the
  threshold. The grind food threshold is a **panel param** ("Eat food below HP %", applied to `HEAL_AT`
  on Start). (The old pixel-preferred/OCR-fallback `read_vital` path is still used elsewhere — e.g. the
  header's PIXEL/OCR row and `mp_report`/`hp_report` — just not for this decision anymore.)
- **Aggro alert bubble → click + attack `0`** (`find_aggro_symbol`): when a mob aggros, a white
  rounded-square bubble with an **orange anger-mark** pops over it. Detected by *orange surrounded
  by white* (scale-tolerant; ignores red name text & gold banners). Grind clicks it + presses
  `ATTACK_KEY` ("0") as a high-priority fast-path (only when not already fighting).
- **Idle search (don't regress):** rank targets **ONCE** on the loop's screenshot
  (`rank_targets(img)`); only **turn/walk when NONE are visible**. When a mob IS visible, hand the
  frame + ranked candidates straight into `engage(img0=img, cands0=…)` so it doesn't re-screenshot /
  re-detect on its first attempt. Retry `engage` up to `ENGAGE_RETRY` (2) times before turning (else
  we'd stand still on an unreachable/false tag). **Hovering a ground item** → `park_cursor` (move the
  in-canvas cursor to bottom-center) clears the tooltip; press Esc ONLY if a real context menu
  persists after the move (don't Esc-spam item hovers).
- **Three MOB-DETECTION modes (`DETECT_MODE`, panel dropdown "🔍 Mob detection", applied on Start):**
  - **`nameplate` (default):** the original colour name-tag scan (`rank_targets`→`find_nameplate_mobs`)
    — sees the whole frame at once, fast.
  - **`hybrid`:** runs `nameplate`, but if it finds NOTHING for `HYBRID_FALLBACK_SECS` (4 s; tracked by
    `last_nameplate_hit`) it does ONE `hover_scan_radial` before the next look-around — fast when colour
    works, cursor-thorough when it doesn't.
  - **`cursor`:** reads the game's **CSS CURSOR** to find mobs colour missed. Flyff's cursor is a CSS
    cursor (`url(.../cursor/<name>.cur)`) the game SWAPS by what's under the pointer: `curbase` over
    ground, **`curattack` over an attackable mob** (`ATTACK_CURSOR`). It's the OS/CSS cursor so it is
    **NOT in screenshots** (a screen RECORDING shows it, a screenshot doesn't) — we read it from the DOM
    (`cursor_kind`/`cursor_over_mob` = `page.evaluate` on `canvas.style.cursor`). Per IDLE cycle: (1)
    aggro/**RED** mobs via colour first (fast, reliable) → engage; (2) else **`hover_scan_radial`** —
    hover OUTWARD from centre in rings (`HOVER_RADIAL_STEP`/`_MAX`), nearest mob first, click on the
    first `curattack` (`select_and_attack`, still verifies the flag + `is_player_target`); (3) else the
    shared `look_around`. A present mob is found in ~0.1 s (stops early); an EMPTY scan is ~4 s (bounded
    by step/max ≈ π·(MAX/STEP)² points). Hovering never clicks, so a wrong point is harmless. The aggro
    ALERT-BUBBLE fast-path (`find_aggro_symbol`, `state!="FIGHT"`) runs before ALL modes. **No template,
    no setup.** Toggle `HOVER_SCAN_ON`. The phased search escalation is now `look_around()` (shared).
- **`engage` cursor-confirm (`ENGAGE_CURSOR_CONFIRM`, default ON, ALL modes):** before a select-CLICK,
  move the cursor to the chosen body point and require `cursor_over_mob` (the game's `curattack`) — else
  skip that candidate. The cursor is the game's GROUND TRUTH that a mob is there, so this kills the
  "clicked ground → character WALKS" misfires that were the main vision limiter. Costs ~`HOVER_SETTLE`
  per checked candidate; set False to revert to body-blob confidence only.
- **Search/engage speed (tuned 2026-06-28):** `CYCLE` 0.28 s (post-turn settle), `FIGHT_POLL` 0.1 s,
  `ENGAGE_SETTLE` 0.35 s, `SELECT_GAP` 0.15 s — all lowered for faster kill→next-mob. The last four
  are panel-exposed (Grind local → **⚙️ Advanced** dropdown).
- **Flyff UI is FIXED-PIXEL** (does NOT scale with the browser window — HP bar is 24 px at any
  viewport). So HUD/shop/status elements are read at ABSOLUTE pixels or located by template match,
  not fractions of the viewport.
- CDP input is trusted; DPR=1 (screenshot px == CSS px), so coords map 1:1.

## Status header + scanner calibration (HP/MP/EXP + TARGET)
The panel header has THREE rows: **OCR** (exact `Y/X (Z%)` text via Tesseract), **PIXEL** (fast bar
scans), and **MEM** (green tag — read straight from WASM memory, deobfuscated, exact). **MEM shows HP +
MP + 🎯 Target** via `re_sense` (`curHP=i32@(controller+2768)^i32@(controller+2744)`, `curMP=+2772^K`,
max peak-tracked; **target** = `i64@(controller+2656)`→registry→entity, exact HP + species key
`IDS_PROPMOVER_TXT_*` + kind@+204); pushed by `_push_mem_status` every header tick (cheap — no
OCR/screenshot). **MEM Exp is now EXACT from memory (2026-07-05):** `curExp=u64@(controller+5744)` /
`maxExp=u64@(controller+5904)`, `exp% = cur/max*100` — stable, always-readable, realloc-proof (found by
kill-diffing on Claudius; verified 6/10/14 → 0.8075/1.3459/1.8843 %). The **EXP graph now PREFERS the
memory-exp series** (`window.__exp.mem`, fed from `exp_mem`, throttled ~1/s) over OCR/pixel — no OCR needed.
OCR/PIXEL both show HP/MP/Exp + an ⚡
Exp/min rate. Bars read at absolute pixels, calibrated in the
**Calibrate Status Scanner** tab and saved to **`scanner_calib.json`** (shared with grind regions).
Bar colours: **HP=red, MP=dark-blue, EXP=light-blue/cyan** (top-LEFT window) + **TARGET HP=red**
(top-CENTRE target frame — the calib preview crop is widened to include it; select a mob/char to see
it; recalibrate if you change window WIDTH since the target frame is centred). OCR boxes are derived
from the calibrated bar pixels (the numbers sit on the bars). The **EXP graphs** tab plots Exp% over
time with last-gain, avg/min, **avg/hour (last 60 min, extrapolated when the session is shorter)**, and
a to-next-level **ETA derived from that avg/hour**. The graph PREFERS the precise OCR Exp over the
coarse pixel-bar read whenever they roughly agree; OCR is sanity-checked (range, ≤10× last step, within
`EXP_OCR_TOL`=**15** %pts of the pixel bar — a bigger gap = an OCR misread, dropped).

**PERFORMANCE GOTCHA (don't reintroduce):** the status OCR (`gl.read_status`, 3–5 Tesseract
subprocesses) costs **~1 s** and runs synchronously. The supervisor's per-loop `stopped()` callback
calls `update_header(..., ocr=False)` so the heavy OCR **never blocks the heal/grind loop** (it was
silently throttling healing to ~⅓ rate). Keep synchronous OCR off any hot path.
**OCR Exp during a run is THREADED, not dropped** (`_OcrWorker` in `control.py`): on the hot path
`update_header(ocr=False)` pushes the fast pixel row immediately, then hands the frame it ALREADY
grabbed to a background thread that runs `read_status(img, ocr=True)` (pure cv2/Tesseract — **no
Playwright, so thread-safe**; all `.evaluate` stays on the main thread) and pushes the result when
ready. So the EXP graph keeps its precise OCR-Exp (and the `EXP_OCR_TOL` pixel-bar agreement check)
during a run without blocking the loop. Pushes are tagged `feed` = `px` (fast loop → pixel row + pixel
Exp series) / `ocr` (worker → OCR row + OCR Exp series) / `both` (idle inline OCR → both); the JS
`__statusSet`/`__expUpdate` route each source to its own row + Exp series. **Never call `read_status`
with `ocr=True` synchronously on the loop** — submit it to `_exp_ocr` instead.

## Detection ROI
`_roi_mask` = central play area (`ROI`), MINUS the top-left HUD, top-right
minimap/panel, and a center box for the player + own "Claudius" tag. **Keep the
game menu and chat/message windows CLOSED** — bright yellow/beige UI text can read as
false mob tags. (White/green text no longer matches now that those = player tags.)

## Key tunables
- **`grind_local.py`** (hot-reloaded): `FOOD_KEY, HEAL_AT`, `NAMEPLATE_DROP`, `ZOOM_IN_STEPS`,
  `MIN_ENGAGE/MAX_MOB`, `ROI`, `CALIBRATE_EVERY` (periodic recalib), `ENGAGE_RETRY`, `CYCLE` (search
  cadence), `AGGRO_DROP/AGGRO_WHITE_FRAC` (aggro bubble), `TAG_PURPLE_*/PET_BODY_SPAN` (pet exclude),
  HP/MP/EXP/**TGT** bar pixel boxes + grind ROI/exclusions (live-editable in the calibrate dashboards
  → `scanner_calib.json`).
- **Fight/engage timing** (in `grind_local.py`, but also **panel-exposed** in Grind local → ⚙️
  Advanced, applied on Start): `FIGHT_POLL` (0.1 s), `DEATH_CONFIRM_CONSECUTIVE_ABSENT` (3),
  `ENGAGE_SETTLE` (0.35 s), `SELECT_GAP` (0.15 s); also `FIGHT_TIMEOUT`/`GIVE_UP_AFTER`.
- **`control.py`** (needs restart): `HEAL_INTERVAL` (0.5), `BUFF_EVERY`/`BUFF2_EVERY` (180/50),
  `BUFF_STEP_DELAY` (1.3), `PANEL_SWITCH_GAP` (0.08), `MP_AT`, `BUFF_CLASS1/2`, `BUFF_PREBUFF`,
  `ANNOUNCE_BUFF_MSG`, `HEADER_EVERY`.
- **`shop_scanner.py`** (hot-reloaded): `ANCHOR_THRESH`, `WIN_W/H`, cell `ICON_BANDS/TEXT_BANDS`,
  `VENDOR_H` (vendor-tag hue), `CALIB_EVERY/OPEN_WAIT` (auto-roam).

## Packet logging for RE (`packet_log.py` → `packet_logs/`)
While a script runs, every WS frame AND every bot ACTION is written to a per-session file
`packet_logs/<ts>_<mode>.txt`, interleaved by timestamp (`wallclock  +rel_s  KIND  detail`;
KIND = SENT/RECV with full hex, or ACTION). Opened in `run_session` (Start), closed in `run_panel`
(Stop/crash, via `finally`). `_on_frame` calls `plog.frame(...)`; ~14 action sites across
`grind_local.py` + `control.py` call `plog.action(...)` (attack select-click, ATTACK key, aggro,
food, self-buff, walk, heal/buff keypresses, MP potion, chat). **Goal: correlate an action to the
encrypted frame it triggers by TIMING** (body is XOR-encrypted, but direction/length/opcode/header/
timing are visible) — e.g. the SENT frame of consistent length right after every `ACTION key ATTACK`
is the attack packet. `packet_log` is **NOT hot-reloaded** (its open-file state must persist across
`grind_local` reloads); calls are cheap no-ops when no session is open. The **Packets** tab shows the
current log file. Camera turns / F-page switches are deliberately NOT logged (client-side, no frame).

## Network / WASM (investigated, parked)
The game's WebSocket IS reachable from the page (`wstest.py` hooks `window.WebSocket`
— a SEPARATE, removable experiment behind the **⚡ WS test** button). BUT commands are
built + encrypted **inside the WASM** (8.5 MB, stripped, custom cipher) and sent via
embind — plaintext never reaches JS, outgoing frames are sequenced (replay rejected),
incoming is encrypted. So **packet crafting is impractical** (full RE parked in
`ReverseEngineerWasm_PLAN.md`). **To remove the experiment:** delete `wstest.py`, the
`import wstest`/`wstest.install` lines in `flyffbot.py`, and the button + `wstest`
block in `control.py`.

**Chosen next direction = memory reading (HP/MP DONE 2026-07-01):** the client keeps state in WASM
linear memory (`HEAPU8`, readable via `page.evaluate`). **Player HP + MP are now read deobfuscated**
(`re_sense.py`; XOR-masked on the stable controller — NOT a Cheat-Engine value-scan, which fails because
HP is obfuscated + the entity reallocates). Mob HP + plaintext position/kind/id read from the registry
(@362748). **ACTING:** select = write `controller+2656`; attack-IN-RANGE = `wasmTable.get(1782)`=func 4148.
**MOVEMENT — SOLVED via packet-dest REWRITE (M8e, 2026-07-01):** a tiny trigger-click makes func 459 emit a
genuine move packet; a `ws.send` hook **rewrites its `dest` bytes to a chosen xyz (mob position from memory)
+ recomputes the CRC → the SERVER moves the char there** (confirmed via reload, both axes). Then write
`+152/160` to match so client+server agree. NO world→screen projection needed — the trigger-click can be
anywhere; the destination is memory-xyz. (Earlier notes calling movement a "dead end" / "from-scratch
crafted packets move nothing" were WRONG — they only checked the CLIENT; a from-scratch craft fails only on
a stale `obj+48` tail, but rewriting a REAL packet keeps a valid tail. Ignore the older `+3632/+3932`
static-field leads — they read 0 live.) CRC = reflected CRC-32C poly 0x82F63B78, init 0xFFFFFFFF, no final
XOR. THROWAWAY ONLY. See `reverseEngineeringWasm_Overview.md` (M7, M8e).
**IMPLEMENTED (M8f):** `re_sense.move_to_server(game,x,y,z)` — chains ≤20u hops (small hops are accepted
~instantly; big ones the server WALKS over ~4 u/s, and writing the client AHEAD rubber-bands, so hops stay
small + we sync the client right after each). Panel RE/Memory tab: per-mob **"go to"** + **click-minimap-to-walk**
(`update_mem_action` act=="goto"). Fast (~2.4s/16u, ~5.3s/34u), holds, no rubber-band. Restart to load
(re_sense/control not hot-reloaded). ⚠️ **The memory-ATTACK (`wasmTable.get(1782)`=func 4148) is STALE after
the patch** — red markers, ZERO damage even in-range. TODO (Fable's plan, `reverseEngineeringWasm_Overview.md`
M8f): frame-diff real-click-attack vs the call; verify `controller+2656` still holds the target (offsets may
have MOVED — re-validate all anchors); then do **attack via packet capture-and-rewrite** (likely need the
server target-SELECT packet). Bigger win: find the send **sequence counter** in memory → craft any packet
from scratch (drops the trigger-click hack + gives a clean attack).
**✅ DELIVERED (2026-07-02) — panel go-to + attack, CAMERA-INDEPENDENT (`re_sense.move_to_server` +
`re_sense.goto_and_attack`).** This is the shipped acting layer; 4/4 mobs engaged incl. fast aibats.
- **Go-to** = `move_to_server` (the M8e packet-dest-rewrite small-hop teleport): server-accepted, writes the
  client pos to match → both agree, no rubber-band. Camera-independent, instant, NO calibration.
- **Attack** = `goto_and_attack`: TELEPORT onto the mob (`move_to_server`, lands dist ~0 → the mob renders
  ~near screen-centre) → hover a NEAREST-FIRST spiral out from centre until the game cursor reads
  **`curattack`** → real-click SELECT then real-click ATTACK → the game auto-attacks to death. Retries if the
  mob drifts. Patch-proof (real clicks, NOT the stale func-1782/4148) + camera-independent (no projection).
- Panel RE/Memory: "go to"/map-click → `move_to_server`; per-mob "attack" → `goto_and_attack` (control.py
  `update_mem_action`, NEEDS RESTART — control.py/re_sense.py aren't hot-reloaded).
- **⚠️ func 1782/4148 (`invoke_attack`/`attack_target`) is STALE after a game patch — confirmed no-op** (a
  wasmTable index is build-specific; re-anchor if you ever want the pure-memory attack back). The click-scan
  attack sidesteps it entirely.
- **🎉 FULL FROM-SCRATCH PACKET CRAFTING UNLOCKED (2026-07-03) — the tail + sequence are NOT server-validated.**
  This overturns the old "crafted packets move nothing / outgoing is sequenced (replay rejected)" conclusion
  (that was WRONG — it used a stale tail, but the tail simply doesn't matter). PROVEN live on Claudius, via the
  reliable reload/server-walk position checks (client position alone is useless — a pure client-write to +25u
  HELD 14s+ with no server move): (1) a MOVE rewritten with an **all-zero tail** → server walked the char to the
  dest (reload confirmed +30u). (2) A **fully from-scratch MOVE built in JS and sent directly via `ws.send`**
  (no game emit, no trigger-click) → server walked it +40u. So the server accepts ANY well-formed frame
  (`[0x1f][body-len u32][CRC-32C over decrypted body][XOR-body]`, XOR keystream @ `338112+S`, CRC-32C reflected
  poly 0x82F63B78 init 0xFFFFFFFF no final-XOR) with a valid opcode/structure — **no per-frame sequence, no tail
  MAC**. Capture the live ws once (`_WS_CAP_JS` → `window.__ws`, grabbed on the next heartbeat via a self-removing
  wrapper) then `window.__ws.send(frame)`.
- **SERVER-SIDE SELECT (`re_sense.send_select`) — panel-selected mobs hit with your OWN in-game skills, NO
  teleport / trigger-click / movement.** Memory select (`select_target`, `controller+2656`) is CLIENT-only → the
  server has no target → an in-game skill shows red markers + the char walks in but deals ZERO damage. Fix:
  CRAFT + direct-send the SELECT packet (`_SELECT_CRAFT_JS`). SELECT body (39B) = `04 a8 0a 64` + startXYZ(12) +
  flags `5a 01 00 00 00 00` + **target i64 id @ byte22** + `04` @ byte30 + 8-byte tail(zeros). Wired to the panel
  **Select** button (`update_mem_action`, NEEDS RESTART). ⚠️ can't self-test (Playwright synthetic KEY events
  don't reach the game — only trusted mouse — so a skill can't be scripted; validate by pressing your real skill).
  If it fails, flags `[18:19]` (0 here; real selects carry a varying value, maybe heading) is the first suspect.
  **Next (big win now trivial):** craft the ATTACK packet the same way for a clean no-click/no-teleport attack —
  needs decoding the `68a80a64` attack body (its target ref looked like a handle `0x0077b2xx`, not the registry
  id — decode which field). Move too can drop its trigger-click (from-scratch `ws.send`).
- **✅ `grind_RE` LOOP (2026-07-03, `run_session` mode `"grind_re"`) — vision-free memory+packet grind + a full
  Control-tab sub-panel "🤖 Grind RE".** Loop: (optional anti-detect look-around/mouse-drift, reused from heal) →
  eat FOOD (F1·1) when own HP% (`re_sense.HpReader`) is below the panel threshold → refresh SELF-BUFFS (F1·2–8,
  only ticked slots) on a timer → read the NEAREST WANTED mob from memory (species-filtered by the mob-type ticks)
  → `send_select` (server-side select packet) → **attack (F1·0)** via Playwright `keyboard.down/up` (DOES reach the
  game; the F1→key gap = the panel "skill delay", default 10 ms) → wait for the kill (HP≤0 from memory) with an
  **8 s no-damage give-up** that re-selects. The char auto-attacks to death after the attack skill, so one F1·0
  kills even a 745-HP mob. **Kill-wait POLL cadence (2026-07-05):** the loop re-reads the target HP every
  **`fightPollMs`** (panel "💓 Kill-check every (ms)", default **100 ms**) so death is caught within ~0.1 s of
  the kill (was 0.3 s → a visible, variable gap before re-selecting). Movement/blocked tracking
  (`next_pos_check`, 0.4 s) + food (`next_food`, 0.5 s) run on their OWN slower timers so the per-step position
  delta stays above the 0.6 u "are we moving" threshold (a 0.1 s walk step is too small to measure). **In-game F1 skill-bar keybinds the user must set: 1=food, 2–8=self-buffs, 0=attack.**
  Panel (Control tab → 🤖 Grind RE sub-tab, `data-scripts="grind_re"`): ⚠️ Enable toggle (`window.__grindRe.risk`,
  loop aborts+stops if off), skill-delay/food-%/buff-refresh fields, a **F1 1–0 slot-toggle row** (2–8 tick which
  self-buffs the char has → `buffSlots`), 🚫 Avoid Giant + anti-detect checkboxes, the memory mob-type tick-list +
  mini-map (`update_grind_re_panel`). Config = `window.__grindRe`. `window.__ws` pre-captured in `update_mem_mobs`
  (`re_sense.ensure_ws`, a PERMANENT idempotent send-passthrough — `_WS_CAP_JS`) so the first Select doesn't race it.
  ⚠️ THROWAWAY ONLY. NEEDS RESTART (control.py). Known: re-engage on a FAR mob right after a kill can miss the
  first attempt (the no-damage give-up recovers); tune move-into-range if needed.
- **`re_sense.read_grind_mobs` (2026-07-03) — the grind_RE mob view: read_nearby_mobs + `protect` + `giant`
  flags in ONE registry walk.** Row = `[dist, species, hp, x,y,z, idLo, idHi, aggro, name, level, protect, giant]`.
  Players (mvr_male/mvr_female, species 000002/000004) + pets (mvr_Pet*) are excluded (`_grindable_mob` also
  guards). **`aggro`** (mob's own `+2656` target): 0 idle / 1 fighting someone-else / 2 attacking US.
  **`protect`=1** when the mob is attacking a FOLLOWER — a player whose own `+2656` targets US (our healbuff
  assist selects us to heal) — so grind_RE DEFENDS the assist (attacks that mob first) instead of skipping.
  **`giant`=1 by HP OUTLIER + LEVEL GAP (2026-07-04):** `hp ≥ 4× same-model median HP` **OR** the mob is the
  top-level variant of its model sitting **≥3 levels above the rest** (Aaron: the 4 variants — Small/base/
  Captain/Giant — are consecutive species sharing a model, ascending level, e.g. Kern 99/100/101/104; the Giant
  jumps ≥3). Two-pass in the registry walk (collect per-model HP + levels). Verified: Giant Kern (species 000566,
  1.235M HP, lv104) flagged, normals not. The panel **type-list now names the tiers by level rank** (Small/base/
  Captain when all 3 present + Giant on the HP/level flag) so the 4 same-model "Kern" are distinguishable, each
  with its HP + a ⚠️GIANT tag. **⚠️ the old `+2828` "scale ~2.0=giant" was WRONG** (real giant = scale 1.0). ⚠️ **The earlier `+2828` "render
  scale ~2.0 = giant" was WRONG** — the real giant reads scale 1.0; +2828=2.0 was flagging a NORMAL mob. Giants
  are also a DISTINCT SPECIES (Small/Kern/Captain/Giant = consecutive IDS_PROPMOVER 000560/562/564/566, same model
  "Kern") — so the panel type-list shows each variant's HP + a ⚠️GIANT tag to tell them apart (the localized
  display name "Giant Kern" is NOT in a directly-linked field — it's a string-keyed localization map, unresolved).
  Grind RE panel toggles: **🚫 Skip mobs fighting OTHER players** (default on; defends the assist exception) +
  **🚫 Avoid Giant** (wins over defense — a giant is fled, never fought — + flee bias; see below).
- **Grind RE target PRIORITY (2026-07-03) — 3 tiers, in `_prio`:** #0 🛡️ **defend the RM/assist** (a mob
  attacking a FOLLOWER, `protect==1`) · #1 ⚔ **fight back** (a mob attacking US = the grinder, `aggro==2`) ·
  #2 🎯 **grind** (nearest/smart wanted idle mob). Within a tier → **NEAREST first**. Tiers #0/#1 **bypass** the
  wanted-species filter + the kill-steal skip (defend against ANY species). **⚠️ We do NOT attack mobs fighting
  OTHER players** (Aaron 2026-07-05): `protect==1` means a mob attacking OUR follower specifically (a player whose
  `+2656` targets us — i.e. our RM healing us), NOT any `aggro==1` mob. A mob fighting an unrelated player has
  `protect==0`/`aggro==1` → skipped by `skip_busy` (the "🚫 Skip mobs fighting OTHER players" default-on toggle).
  **A short-lived `aggroDist` "nearby aggro" tier (2026-07-04) was REMOVED (2026-07-05)** — it keyed on `aggro>=1`
  so it wrongly engaged mobs attacking OTHER players; `protect` already covers "attacking our RM". **BUT
  Avoid-Giant WINS over defense** (2026-07-04): in `_keep` the Giant check is FIRST,
  so a Giant is NEVER engaged even when it's attacking us — a 56k-HP giant is fled, not fought (non-giant
  attackers are still defended). When Avoid-Giant is on, each giant gets a **HAZARD ZONE** of radius
  **`giantHazard`** (panel field "☢️ Giant hazard radius (u)", default 22) — drawn on the map as a red dashed
  keep-away ring, and enforced: the loop **excludes any mob inside a giant's hazard radius** (never grind there),
  **won't target a mob whose straight LINE to us passes through a giant hazard** (giant hazards are part of the
  no-go/no-passthrough `zones` in `_route_zones`/`_path_crosses` — same treatment as painted zones), + a soft
  **flee bias** in `pen()` steers targets away out to `hazard+12 u`. Status: ☢️ giant nearby — keeping
  Nu clear. **DEFENSE INTERRUPT:**
  mid-fight (in the kill-wait loop) it re-scans ~1×/s and, if a strictly-higher-priority threat appears (a NEW
  mob attacking us/the RM that isn't the current target), it BREAKS to re-select it — so it doesn't grind the
  current 49k-HP mob to death while being attacked. Status line: 🛡️ DEFEND RM / ⚔ fight back / 🎯 grind.
- **Grind RE OBSTACLE detection (2026-07-03) — movement-based, fast.** Replaces the slow 8 s no-HP-drop giveup
  as the primary bail: during the approach it reads OUR pos (`_pos_xyz`) each poll; if we're **>5 u from the
  mob AND haven't moved >0.6 u for 1.5 s** (the "obstacle in the way, char stands still" case) it drops the mob
  IMMEDIATELY (🚧 blocked) and adds its id to `blocked_until` (a 12 s cooldown so it isn't re-picked at once);
  the 8 s no-damage check stays as an in-range backstop. Taking damage or moving = progress (resets the timer).
- **Grind RE 🧠 SMART targeting (2026-07-03, toggle `smart`, default ON):** replaces the greedy
  nearest-mob pick (tier #3 grind only — defense tiers still pick the threat) with a one-step-lookahead that
  maximizes kills/min. Score(c) = `Σ_j exp(-dist(c,j)/clusterR) / (dist_to_c/walk_speed + kill_time)` over wanted
  candidates j — soft cluster density around c divided by the time to reach+kill c, so it heads INTO a dense
  cluster instead of wandering off after a lone-close mob. `walk_speed` (u/s) + `kill_time` (s) are LEARNED live
  (EWMA) from each fight for free: attack→first-damage gives walk_speed = dist/that; first-damage→death gives
  kill_time. Panel: 🧠 Smart-targeting checkbox + 🧠 Cluster radius (u, default 30). Status shows `~Ns/kill Nu/s`.
  **Grind RE MAP (dedicated `__greMiniSet`, 300px — NOT the shared `__buildMobMap`):** self = a green CIRCLE (no
  facing arrow — yaw is unreliable), giants tinted purple + bigger, aggro/combat rings; a **thin white dashed
  path** shows the planned kill ORDER (JS forward-simulates the loop's next ≤8 picks). **(2026-07-04) The plan
  sim mirrors the loop's REAL avoidance** — same `keep` (avoid-giant FIRST, then defend, then wanted/skipBusy),
  same **exclusions** (mobs inside a painted zone+margin or a giant hazard are dropped; a mob whose straight
  line from the current position crosses a no-go zone is skipped — JS `crosses()`, mirroring `_path_crosses`)
  and same **`pen()` penalties** (obstacle path ×0.7, giant-flee bias out to `hazard+12`) applied to the
  smart-score / nearest pick — so the drawn path no longer routes through painted no-go zones and matches what
  the bot actually does. (It's still straight lines between planned targets, not the game's curved walk.) Below
  it, a stats block: **forecast mobs/min** (from the planned route +
  learned speed/kill-time), avg **move speed u/s**, avg **kill time s**. Fed by `read_grind_mobs` (13-col rows
  with giant/protect); the loop pushes learned `{ks,ws}` to `window.__greSmartStats` each target.
- **Grind RE 🧱 OBSTACLE MAP (2026-07-04, toggle `obstacles`, default off):** a crude world occupancy grid
  (`_gre_obs`, module global, `OBS_CELL`=4 u, cleared per Start). A **blocked** approach (the movement-based
  no-progress detection) adds obstacle score at the **midpoint** of the us→mob segment, confidence `0.9*(1-d/
  OBS_MAX_DIST)` — shorter = more confident, discarded past `OBS_MAX_DIST`=22 u. **Walking** anywhere subtracts
  score along the traversed segment (`_obs_free`, sampled per-poll so it traces the real path) → self-corrects
  false positives. Rendered on the Grind RE map as **red cells** (opacity ∝ score, pushed via
  `d.obstacles`/`d.obsCell` from `update_grind_re_panel`). When on, target selection is **path-penalized**:
  `_obs_path(us→mob)` sums obstacle score between us and each candidate; smart score is divided (and nearest
  distance multiplied) by `1+0.7*path` — biasing toward mobs with a clear path. Defense tiers stay unpenalized.
- **Grind RE MAP zoom + 🖌️ PAINT / 🧹 ERASE (2026-07-04):** the map (now 360px) has **± zoom** buttons
  (`window.__greZoom`, shrinks/grows the view radius) and a **paint/erase toolset** — `window.__greTool` ∈
  `''`/`'paint'`/`'erase'` (mutually exclusive). Toggle 🖌️ paint, then drag on the map to add manual obstacle
  zones (`window.__grePaint`=`[[wx,wz,r],…]`, screen→world via `window.__greMapProj`; brush thickness =
  `gre.brush`). Toggle 🧹 **erase** then drag to REMOVE any painted zone the brush touches (`eraseAt` filters
  `__grePaint` by `dist > max(zoneR, brush/2)`). 🗑️ still clears ALL painted zones at once. Painted zones draw ORANGE + a dashed **keep-away margin ring** (`gre.obsMargin`,
  default 5 u). The loop live-reads `window.__grePaint`+`obsMargin` each pass and **won't target a mob that is
  inside a painted zone+margin** (`_painted_hit`) **OR whose straight LINE from us crosses one** — see the
  reachability rule below. 🗑️ clears them. Separate from the auto-learned `_gre_obs` grid — always-active override.
- **Grind RE 🧭 no-go-zone REACHABILITY (2026-07-04):** the game's auto-walk-to-attack (select→attack skill)
  paths STRAIGHT to a mob, so it would walk through our painted zones / giant hazards (the game doesn't know
  them). Rather than pathfind a detour (over-engineered), the loop simply **won't target a mob whose straight
  line from us crosses a no-go zone** — `_path_crosses(px,pz,mx,mz, zones)` where `zones = _route_zones(...)` =
  painted zones (+`obsMargin`) + every giant hazard (`giantHazard`). Added to the `mobs` candidate filter (next
  to the inside-zone excludes); mobs behind a zone are skipped until we've moved somewhere with a clear line
  (or "no reachable wanted mobs — waiting"). The sampler skips the exact start point so standing in/near a zone
  doesn't exclude everything. The MAP plan sim mirrors it (JS `crosses()` filters each greedy pick to mobs
  reachable from the current sim position), so the drawn white path only routes to mobs with a clear line.
  **🚪 ANTI-FREEZE + escape-by-targeting (2026-07-04):** if we walk a bit INTO a zone, every mob's path starts
  inside it → all excluded → the loop would sit frozen "waiting". Fixes: (1) `_path_crosses` **ignores any zone
  that CONTAINS the start point** (we must be able to walk out of one we're in), so targets stay reachable; JS
  `crosses()` does the same. (2) We do **NOT** issue move commands to leave — `move_to_server` rubberbands/glitches
  (Aaron, 2026-07-04). Instead we ESCAPE by TARGETING: when `in_zone` and **no threats**, pick the reachable mob
  that pulls us OUTWARD (`_exit_cost` = distance × a direction multiplier: 1× if the mob lies away from the zone
  centre, 3× sideways, 8× toward the centre) and attack it (F1·0) — the game's auto-walk carries us out into the
  grind region (status `🚪 exit no-go zone → …`). **Threats still win FIRST** (tier #1 assist-protection → #2
  fight-back), so being attacked inside a zone → defend; only once no threat remains do we target-to-escape.
- **Grind RE 💾 SAVE / 📂 LOAD area maps (2026-07-04):** a map (learned `_gre_obs` obstacles + hand-painted
  no-go zones `window.__grePaint`) can be **saved per grinding AREA** under `grind_maps/<base>.json`, where
  `<base>` = the **most common nearby mob's base model name** (e.g. `Kern.json`; `update_grind_re_panel` computes
  it from the `_grp` species groups). **💾 save map** button (next to 🗑️) → `window.__greMapAct={op:'save',paint}`
  → `_save_grind_map`. On each panel poll, `_handle_grind_map_action` checks whether `grind_maps/<base>.json`
  exists for the current area and, if so, shows a **📂 load offer under the map** (`window.__greMapOffer`) — the
  user clicks **load** (`_load_grind_map` seeds `_gre_obs` + pushes `window.__grePaint`, re-renders) or ✕ to keep
  a fresh map. **Default = always start a fresh map**; loading is opt-in. A loaded map is snapshotted
  (`_gre_loaded_obs`) and **re-seeded after Start's `_gre_obs.clear()`** so it survives into the run (auto-learning
  then refines it; re-save to persist). Offer is suppressed for a base once loaded (`__greMapLoaded`) or dismissed
  (`__greMapDismissed`); saving also dismisses (you're actively editing that map). No hot-reload (control.py) →
  needs a panel restart.
- **Grind RE target-species LOCK (2026-07-03):** the wanted set is snapshotted at Start (`wanted = ticked
  species now`), so a NEW mob type appearing mid-run is NOT grinded; the panel also defaults new types to
  UNticked while the loop is running (ticked when stopped). Empty mobSel at Start → grind all (fallback).
  ⚠️ **Live-session hook hygiene:** stacking ad-hoc `WebSocket.prototype.send` wrappers in probe scripts caused a
  recursion tangle that broke the game's send (had to recover native `send` from a throwaway iframe). Keep the ws
  hook to the ONE idempotent tagged passthrough; don't layer capture wrappers on a session the panel is using.
- **PARKED: `click_nav.py`** (projection-based smooth clicks). Its world→screen projection is CAMERA-RELATIVE,
  so one cached calibration goes stale the instant the camera zoom/tilt/angle changes mid-play and every
  go-to/attack silently misses — the fragility that made it "work even less" than the teleport. Kept on disk
  as a reference (key find still valid: **`controller+2540/+2548` = the world (x,z) a click raycasts to**).
- **Bonus:** `re/anchor_check.py` = READ-ONLY startup validator for all RE offsets (catches patch drift).
THROWAWAY ONLY (sends move+attack commands). NEVER touches the HP/MP/mob/view reads (only +2540/positions).

## Environment
Windows 11; Python 3.12 (`python`, not `py`). Deps (`requirements.txt`): `playwright
opencv-python numpy pytesseract` + `python -m playwright install chromium` + **Tesseract OCR**
(winget `UB-Mannheim.tesseract-ocr`; `grind_local` auto-finds it in Program Files / LOCALAPPDATA /
PATH). `install.bat` sets all of this up into a `.venv`. Run Chromium headed. Without Tesseract the
bot still grinds (fast pixel reads); only the OCR text row + ShopScanner need it.

## Status

**Update (2026-06-27).** The project grew from a grind bot into a **multi-mode suite** with a
tabbed, per-script control panel:
- **Heal / Heal+Buff** Ringmaster modes (two-class buff rotation, quick-buff, MP-threshold potions,
  anti-detect; heal-delay 0.5 s default).
- **ShopScanner** + price **Shop DB** (template-located shop window, white-isolation OCR, auto-roam,
  CSV export, per-item history).
- **Status header** (OCR + PIXEL rows for HP/MP/EXP) + **EXP graphs**; **scanner calibration**
  dashboards (HP/MP/EXP bars, grind ROI/exclusions) saved to `scanner_calib.json`.
- **Distribution**: `install.bat` (one-click venv setup) + `prune_for_share.bat` + `START_HERE.txt`.
- **Grind refinements**: aggro-bubble fast-path, periodic `calibrate_view`, idle "only turn when no
  mobs visible" gate (+`ENGAGE_RETRY`), `park_cursor` instead of Esc-spam on item hovers.
- **Key fix**: the status OCR was blocking the heal loop (~1 s every 1.5 s → healing ran at ~⅓ the
  set rate); now `stopped()` uses `update_header(ocr=False)` so OCR never runs on a hot path.

**Update (2026-06-28) — grind LOOP SPEED.** Cut the dead time between kills:
- **Faster mob-finding**: IDLE now ranks targets ONCE on the loop's screenshot and only calls the
  heavy `engage()` when a mob is in view (empty spots just turn); `CYCLE` 0.45→0.28 s.
- **Faster fight-end**: `FIGHT_POLL` 0.2→0.1 s; the fixed-seconds death window was replaced by
  `DEATH_CONFIRM_CONSECUTIVE_ABSENT` (N absent polls ≈ `FIGHT_POLL`×N).
- **Faster engage**: `engage()` reuses the IDLE frame + ranked candidates on its first attempt
  (no redundant grab/detect); `ENGAGE_SETTLE` 0.7→0.35 s, `SELECT_GAP` 0.22→0.15 s.
- These last four are now tunable live in **Grind local → ⚙️ Advanced**.
- Open speed lever (parked): `grab()` PNG-encodes every frame — a global JPEG/clip switch risks OCR +
  tight colour thresholds, so detection (`rank_targets`/nameplates) stays on PNG `grab()`.

**Update (2026-06-28c) — screenshot SPEED + engage retries.** A `page.screenshot()` is a ~35 ms fixed
CDP round-trip (clipping does NOT help — overhead is per-call, not per-pixel; PNG decode is only ~4 ms)
and spikes to ~250 ms under game render-load. It's called on every engage-confirm AND every death-poll,
so it dominated the ~800 ms "is a mob selected?" latency — compounded by `engage()` doing up to
`ENGAGE_ATTEMPTS` retries, each `SELECT_GAP` + 2 grabs (measured 1247 ms worst-case for 3 attempts).
Fixes (all in `grind_local.py`, hot-reloaded): (1) **`grab_fast(page)`** — raw CDP
`Page.captureScreenshot` with JPEG + `optimizeForSpeed` (~18 ms median, ~2× PNG). LOSSY → used ONLY for
the binary frame-checks (engage-confirm `shot`, the death-poll); verified it agrees with PNG on the
red/cyan-blue bar bands. Colour DETECTION stays on PNG `grab()`. (2) **`ENGAGE_ATTEMPTS` 3→2** — caps a
full-miss at ~700 ms and curbs phantom-candidate move-clicks (the outer `ENGAGE_RETRY` re-tries anyway).
Further levers are PANEL-controlled (so a file edit needs a restart to propagate, or just change them in
⚙️ Advanced): **Select gap** 150→~100 ms (faster select→attack confirm) and **Death confirm** 3→2 /
**Fight poll** (faster mob-gone detection) — both now safer given the stable bar-band target detection.

**Update (2026-06-28b).** Healer + RE additions:
- **Target HP** made calibratable (`mob_hp` → `TGT_*` absolute-px bar; TARGET HP group added to the
  Status Scanner) + a **"heal only if target HP < %"** gate in Heal/Heal+Buff (`should_heal`).
- **MP potion** is now **threshold-only** (the time-interval was removed). **Pet exclusion** (purple
  pet name → skip the body below it). Buff timing: **F-page switches use `PANEL_SWITCH_GAP`** not the
  full delay; **buff delay default 1300 ms**; **📢 Announce buff** chat option.
- **RE groundwork: `packet_log.py`** — per-session interleaved **frames + actions** log in
  `packet_logs/` (see its own section) to correlate actions ↔ encrypted frames by timing. This is
  the active reverse-engineering data-collection path.

**Update (2026-07-01) — MEMORY SENSING: HP + MP SOLVED (deobfuscated).** Read player HP/MP straight
from WASM memory, exact + vision-free (`re_sense.py`, shown in the panel header **MEM row**):
- **HP is anti-cheat XOR-obfuscated on the STABLE controller** (`*(1230588)`): `curHP =
  i32@+2768 ^ i32@+2744` (key@+2744), `curMP = +2772 ^ K`. CONFIRMED live (315→147 on damage). **maxHP =
  the buff-aware stat formula (`max_base`, sta=baseStat+buff92) PLUS a peak-tracked EQUIPMENT SURPLUS**
  (`re_sense.hp_true_max`; gear HP the formula misses = peak of `cur−formula`, buff-invariant). This
  CORRECTS DOWN when an HP buff expires — do NOT go back to peak-tracking absolute `cur` (that captured the
  buffed peak and never released it → the bot healed forever after a buff dropped, 2026-07-04). **maxMP** has
  no formula → still pure peak-track. This is why every value/ratio-scan failed (obfuscated + the entity
  reallocates); reading the stable controller sidesteps both.
- **The old "+308/+316 HP ratio" was WRONG** — its source func 11585 is the 3D-model deserializer, not
  HP. Debunked by re-disassembling the **current** wasm (the June copy was stale — game patched) +
  mapping structs by NAME (`schema.js`/`findprop.js` off the `call 181` string-key deserializers).
- **Mobs:** same `+2768^+2744` on each registry entity (@362748), which also carries plaintext **pos
  x/y/z @+152/+156/+160, kind @+204 (5=mob), id @+368** → a memory mob-list for grind_RE (next step,
  needs world→screen projection to click).
- **Exp: ✅ SOLVED (2026-07-05).** `curExp = u64@(controller+5744)`, `maxExp = u64@(controller+5904)` —
  STABLE, always-readable, realloc-proof (in the same controller struct as HP). Found by full-heap kill-diff
  on Claudius (`exp_hunt.py`: snapshot → kill → keep u32s that rose by a plausible amount; the % readings
  6/10/14 → 0.8075/1.3459/1.8843 % pinned curExp AND maxExp=743). The OLD `363328/363384` globals are a DEAD
  event-cache (0 when idle AND 0 even right after a kill on build v293) — abandoned. Wired into `re_sense`
  `_HP_JS`/`read_player` (`exp`/`expCur`/`expMax`) → the MEM header + a memory EXP-graph series
  (`window.__exp.mem`, preferred over OCR/pixel). Tools: `exp_hunt.py` (heap-diff hunter), `exp_watch.py`.
- **✅✅ ACTING SOLVED — SELECT + ATTACK via the game's OWN functions (Route A, no clicks/packets).** The
  page exposes **`wasmTable`** (12686) + `Module`/`stackAlloc`/`_malloc`, so we CALL WASM funcs from JS.
  **Select** = write the mob's i64 id to `controller+2656` (client-side). **Attack** = `wasmTable.get(1782)()`
  = func 4148, which reads +2656 and dispatches the game's real attack command (`call 291(...,17,...)`);
  CONFIRMED live — one call and the char auto-attacks the mob to death. NOT a crafted packet (the game's
  own path). `re_sense.select_target/attack_target`; panel per-mob **select/attack buttons**
  (`update_mem_action`, `ATTACK_FN_IDX`=1782 — BUILD-SPECIFIC, re-anchor after a patch). ⚠️ attack sends a
  command → throwaway only. ⚠️ **Attack fires ONLY in melee range** — out of range the client-side walk
  rubber-bands. **MOVEMENT from memory is a PROVEN DEAD END (M8):** no callable move function, static dest
  offsets read 0 live, field writes don't move, packet/position injection rubber-bands. **So the grind_RE
  loop is NOT closed by memory alone** — server-accepted movement needs a REAL click. Path: project mob
  memory-pos → screen px → real click (move+attack via the click pipeline), auto-calibrating world→screen
  from each click's true world landing. See `reverseEngineeringWasm_Overview.md` (M8).
- **SELECTED TARGET + NEARBY ENEMIES (memory):** target = `i64@(controller+2656)` (0=none) → registry →
  entity → HP/kind@+204/species(`comp+48`); shown in the MEM row 🎯 cell (HP% peak-tracked, no stored mob
  max). The **RE / Memory tab** shows a live **🗺️ nearby-enemies list + top-down SVG mini-map** (registry
  walk → monsters: kind5, `hp>1`, species-key `IDS_PROPMOVER_*`, exclude self/players/pets/NPCs) with
  species/HP/pos/distance, player-centred + world-oriented (`re_sense.read_nearby_mobs` returns
  `{px,pz,mobs}`; `update_mem_mobs`). Mob numeric level isn't per-entity (static species data). This is the
  sensing base for a vision-free grind_RE target picker — remaining click blocker is world→screen projection.

Engage accuracy on hard mobs remains the core vision limiter (below). Grind works well on **clean
cases** (aibats: distinct colour, dense, close). The
**Captain Mushpang area is a worst case** (red mobs on tan ground, spread out, red
everywhere) — name-tag detection helps but engages still miss and signals get
confounded. For reliable behaviour, test on mobs that contrast with the ground
(blue Burudeng/aibats). Fight logic is **pure-hold** (no walk-away, no false-fighting);
engage accuracy is the main vision limiter.

**Direction (2026-06-21):** the memory-sensing detour (grind_local_2) did NOT beat vision —
memory gives perfect mob data but the world→screen bridge to turn it into a click was never
reliable (see Modes). So the bottleneck is unchanged: **turning a detected mob into an accurate
attack click.** We are now **refining `grind_local.py` (the vision bot) directly** — improving
nameplate detection + engage accuracy + fight-loop robustness — since clicking a detected
nameplate is the one primitive that has always worked on clean mobs. grind_local_2 / memory /
WASM-RE remain available but are parked behind the vision refinement.

**Engage refinement (2026-06-21, IN PROGRESS — `grind_local.py`).** Reworked `engage()` to fix
misclicks. New helpers: `rank_targets` (all candidates best-first), `refine_click_point` (the
name-tag X is reliable but Y is a guessed drop, so scan the strip below the tag for the mob's
BODY blob — learned colour, **and if the learned colour matches nothing it FALLS BACK to salient
non-ground** (a poor `learn()` sweep otherwise made `engage` miss EVERY mob → bot detects mobs but
can't click them → just walks around visible mobs; the blank-area case still returns not-confident so
it never clicks bare ground), arrows excluded — and click its centroid),
`_attack_point` (for the 2nd/attack click, RELOCATE the mob on a fresh screenshot: arrows centre
only if sanely near the select point, else re-detect the nearest name-tag mob's body, else the
select point). `engage()` now VERIFIES selection (target frame OR arrows) before attacking and
RETRIES the next candidate on a miss instead of faking a fight. **RESULT: still misclicks — both
the 1st (select) and 2nd (attack) clicks land off-mob too often. NOT solved.**
**Leading hypothesis:** at `calibrate_view`'s max zoom-out, mobs are TINY, so a few-px error =
off-body click = a stray left-click = Flyff MOVE = the character walks. **Top untried lever: wire
`ZOOM_IN_STEPS` into `calibrate_view`** (currently defined but unused — view is full-out only) so
mobs are bigger and clicks far more forgiving; then re-tune `NAMEPLATE_DROP`/ROI for the new scale.
Other levers: tighten the cyan-arrow threshold; verify a left-click on a selected mob's body
actually attacks (vs. needing the exact arrows point); consider a tiny post-miss "stop" to curb
walking. **Resume here.**

**Update (2026-07-02) — self/support HP+MP gauges moved to MEMORY (all three self/support decisions,
NOT the vision detection primitives).** `grind_local.py`, `heal`, and `heal+buff` no longer read
pixel/OCR for **self-heal food-eat**, **MP potion**, or **heal-as-support / kill-confirm**:
- **Own HP/MP** (self-heal food-eat + MP potion): `grind_local.grind()` now owns a `re_sense.HpReader()`
  (extended to also peak-track MP: `cur/max/frac` + `mp/mp_max/mp_frac`) instead of `read_vital`.
  `control.py` gained `player_hp_frac`/`player_mp_frac` (share the header's `_mem_stat` peaks) and
  `food_threshold_eat`/`mp_threshold_drink` now call those instead of `gl.read_vital(..., recover=True)`
  — no screenshot, no HUD-visibility dependency.
- **Heal (as support)**: `heal_pass`'s vision fallback (`should_heal`/`gl.mob_hp`) was DELETED — when the
  "heal only if target HP < %" gate can't resolve a memory target it now heals unconditionally (the old
  safe default) instead of falling back to a screenshot.
- **Mob killed (grind) — PIXEL vs MEMORY, selectable, MEMORY default.** New panel dropdown **Grind local →
  ⚙️ Advanced → "🎯 Mob target sense"** (`window.__grind.mobTarget` → `apply_grind_config` → `gl.TARGET_SENSE`
  ∈ {`memory`(default), `pixel`}). The `FIGHT` state in `grind_local.grind()` now branches on it:
  - **`memory` (default):** reads the SELECTED target (alive/HP/`kind`) from `re_sense.read_target`
    (`controller+2656` → registry → exact deobfuscated HP; a JS eval ~2-4 ms, CHEAPER than the old ~18 ms
    fast-JPEG bar check). `kind != 5` = not a mob (drop + re-search, replacing the old vision "#"-tag player
    check). Death is **TRUSTED IMMEDIATELY** — `hp<=0` or a cleared target = dead NOW, **no `FIGHT_POLL`, no
    `DEATH_CONFIRM` poll window** (memory HP is exact and never flickers). Fight loop re-reads at a fixed
    `MEM_FIGHT_POLL`≈0.1 s. Fastest kill→next-mob; the Fight-poll + Death-confirm Advanced fields are IGNORED
    in this mode (labelled "pixel only").
  - **`pixel`:** the original vision path — top-centre target FRAME (`target_present`) + `mob_hp` bar +
    `is_player_target`, with the `FIGHT_POLL` × `DEATH_CONFIRM_CONSECUTIVE_ABSENT` confirm window (needed
    because the pixel bar can flicker). Kept intact for when memory sensing is unavailable/undesired.
- **Unchanged / still vision**: `engage()`'s selection-confirm (right after a select-CLICK, before memory
  is guaranteed to reflect it yet) and the header's PIXEL/OCR row (`read_status`, `mob_hp`, `target_present`)
  — those aren't self/support HP+MP *decisions*, so they were left alone.
- Also fixed a starvation bug: **food-threshold self-heal now also runs after every buff step INSIDE a
  heal+buff buff cycle** (`run_buffs`), not just on the outer loop's 2 s tick — a class-1 buff cycle can run
  15-20+ s, during which the old code never checked own HP at all.
- **MEM header row (HP/MP/target) now refreshes ~10×/s (was every 1.5 s).** ROOT CAUSE: `_push_mem_status`
  (cheap — 2 JS reads, no screenshot) was called INSIDE `update_header`, gated by the shared
  `HEADER_EVERY`=1.5 s throttle that exists for the EXPENSIVE OCR/screenshot row. Fix: decoupled it onto its
  own `MEM_EVERY`=0.1 s throttle (`_last_mem`), so the cheap MEM push runs ~10×/s while the pixel/OCR grab
  stays at 1.5 s. Also: the idle supervisor loop's trailing sleep now uses `_tick_sleep` (pushes MEM every
  0.1 s during the wait) so MEM stays live between the slower full idle iterations; `run_buffs`'s per-buff/
  per-heal step waits likewise use `_tick_sleep` so it doesn't freeze mid-buff-cycle. `hp_mem`/`mp_mem`
  percentages also went `.0f` → `.1f` precision.
- **Grind-calibration overlay shows the cursor hover-scan PATH (default ON)**: a "🖱️ show cursor hover-scan
  path" checkbox (now **`checked` by default**) on the **Calibrate Grind scanner** tab draws the
  outward-spiral hover PATH (a cyan polyline through the sample points in scan order + a dot at each + an
  orange centre cross = the character) at every point `hover_scan_radial` would hover. Scan geometry factored
  into `grind_local._hover_scan_points(w,h)`, shared by the real scan and the overlay so what you see is
  exactly what runs. Wired via `window.__gcalibShowHover` → `update_grind_calib` →
  `gl.dump_grind_regions(..., show_hover=True)`. **GOTCHA (fixed):** the marks MUST be drawn AFTER the ≤760 px
  panel-downscale at a fixed pixel size — drawing them at full-res coords first made them shrink to <1 px and
  vanish. **Also: this dashboard uses the module-level `gl`, only `importlib.reload`ed on a grind START, so a
  `grind_local.py` edit to the overlay only shows after a panel RESTART or one Start/Stop — and the whole
  panel/JS (control.py) is never hot-reloaded, so a stale still-running process (e.g. a Chromium
  profile-handoff launch that left the OLD process serving the panel) is the usual "I restarted but nothing
  changed" cause; fully kill the old python+window first.** The info line appends "· CYAN=N hover pts" when
  on — a quick check that `show_hover` is wired.
- **Faster hover-scan**: `HOVER_SETTLE` 0.05 → **0.022 s** (the game swaps the CSS cursor on the mousemove
  handler, ~1 frame ≈16 ms, so ~1 frame is enough for both a hit and a miss). Nearly halves the empty-scan
  time; a present mob is still found in the first few points and stops early. A rare slow-frame miss
  self-corrects (the scan re-runs every idle cycle). (Playwright sync drives ONE cursor over sequential CDP
  calls, so true multi-threaded hovering isn't possible; throughput per point is the lever.)
- **Grind start: colour LEARN sweep skipped for cursor detection.** `run_session` "local" now only runs
  `gl.learn()` (the 6-view camera "look-around" that builds colour ranges) when `gl.DETECT_MODE` is
  `nameplate`/`hybrid` (which filter by colour). The DEFAULT `cursor` mode finds mobs by the game CURSOR (no
  colour) so it skips the look-around and starts grinding immediately (ranges=None). NOTE: detection is still
  VISION (cursor/nameplate) — only the FIGHT target sense moved to memory — so `calibrate_view()` (zoom/tilt
  reset, NOT colour) still runs inside `grind()`.
- **Removed the redundant top-of-panel HP bar** (`update_hp_display`/`__hpSet`/`.hpwrap` + its per-idle-tick
  `gl.grab` screenshot & `dump_hp_crop`): HP already shows in the header's MEM (exact) + OCR + PIXEL rows, so
  the standalone vision HP bar under the status line was duplicate and cost an extra screenshot every idle
  loop. Gone.
- **RE/Memory map: player marker is a heading ARROW** (points the way you last MOVED — a facing proxy;
  falls back to the green dot until you move). We have **no facing/yaw field in memory** (not RE'd), so the
  heading is DERIVED in JS from the delta of the player world pos (`data.px/pz`) between map updates
  (`window.__memPrevPos`/`__memHeading`; screen x=world x, screen y=world z). A true camera-facing arrow
  would need finding the yaw offset in the controller struct (open RE task).
- **(2026-07-02b) RE/Memory map refresh 100ms + action buttons behind a RISK TOGGLE.** `update_mem_mobs`
  throttle 1 s → **`MEM_MOBS_EVERY`=0.1 s**, and the idle loop ticks 0.1 s on the RE tab so the map is
  ~10×/s. The per-mob **select/attack/go-to** buttons (and click-to-move) are now HIDDEN until the
  "⚠️ enable action buttons (throwaway only)" checkbox (`window.__memRiskOn`, default OFF) is ticked —
  `__memDo`/`__memGoto`/`__memMapClick` are also guarded so they no-op when off. Prevents accidental
  command-sends on a real account.
- **(2026-07-02b) Mob-type selection + type-aware grinding (`grind_local`, cursor mode).** New Grind-local
  card **"🎯 Nearby mob types"**: while STOPPED on the Grind local/selected sub-tab, `update_grind_mobtypes`
  reads nearby mobs from memory, aggregates by SPECIES (species#, model name, level, count, nearest dist),
  and lists them with a **tick = grind / untick = avoid** checkbox each (`window.__grind.mobSel`, new types
  default ticked). On Start, `apply_grind_config` builds `gl.MOB_GRIND_SET` = ticked species (None = grind
  all). Two toggles drive behaviour (both default OFF; `apply_grind_config` → `gl.MOB_SCAN_TYPE`/`MOB_STEER`):
  - **🔍 Scan for mob type**: in `select_and_attack` (the cursor hover-scan path), after a hover SELECTS a
    mob, read its species from MEMORY (`_target_species` via `re_sense.read_target`) and only press ATTACK if
    it's wanted (`_wanted_species` ∈ `MOB_GRIND_SET`); else Tab-deselect and keep scanning. No projection
    needed — the species is read from the selected-target memory after the click.
  - **🧭 Reposition toward ticked mobs (EXPERIMENTAL)**: when idle with no engageable mob, `grind()` consults
    the memory mob-map (`wanted_centroid_dir` = world-angle to the wanted-mob centroid, filtered by
    `MOB_GRIND_SET`). If the nearest wanted mob is beyond `MOB_STEER_FAR` (60 u) it SKIPS the close
    hover-scan and walks toward the cluster; if the hover-scan finds nothing but a wanted cluster exists
    (> `MOB_STEER_MIN_DIST` 12 u) it walks toward it instead of a random wander. Walking uses
    `steer_toward_mobs` — normal click-to-move (NO packets → account-safe) with a **self-calibrating
    screen↔world camera offset** (`steer_state['cam_off']`, learned/updated from each walk's actual world
    delta; bootstraps by walking "forward"). ⚠️ EXPERIMENTAL first cut: assumes a pure camera rotation
    (a reflection would make it oscillate before self-correcting); it repositions but does NOT yet
    rotate-to-face the cluster (camera-facing needs the yaw calibration above). Default OFF so normal grind
    is unchanged.
  - **Whitelist semantics:** `MOB_GRIND_SET` = ticked species ONLY when the user actually unticked at least
    one (curated); all-ticked/untouched → None = grind ALL (so a species not present at Start isn't silently
    excluded). `read_wanted_mobs` filters by it; `_wanted_species` enforces it at attack-time (scan-type on).
- **(2026-07-02b) Cut UNNECESSARY camera adjustments (memory-gated).** Using the memory mob-map, `grind()`
  now avoids turning/recalibrating when wanted mobs are already here: (1) the periodic `calibrate_view`
  (`CALIBRATE_EVERY`) is SKIPPED when `nearby_wanted_count(page, VIEW_MOB_RANGE=45u) > 0` (productive spot →
  don't disrupt a working view; just reset the timer) — it only recalibrates when the spot is empty; (2)
  when detection finds nothing but wanted mobs ARE near (memory), it retries in place up to `MEM_NEAR_RETRY`
  (3) times BEFORE any `look_around` turn (they're here, detection just missed them) — only after retries
  run out does it turn/steer to bring them into view. `mem_retries` counter, reset on engage. Always on
  (safe fallback: memory-unreadable → count 0 → old turn-to-search behaviour).
  - **(3) SKIP the hover-scan when memory shows no close mob.** The cursor hover-scan costs seconds on an
    empty area, so cursor/hybrid mode now reads the memory mob-map ONCE per idle pass (`wanted_mob_summary`)
    and only hover-scans when a wanted mob is within `VIEW_MOB_RANGE`. No mob loaded near → skip the scan and
    reposition immediately (steer toward a far cluster if steering's on + one exists, else look_around);
    hybrid's cursor fallback is likewise gated on a close mob. Avoids scanning space we already KNOW is empty.
- **(2026-07-02b) No-damage give-up (`NO_DAMAGE_GIVEUP`, default 5 s, panel: Grind local → ⚙️ Advanced
  "🚫 No-damage give-up (s)").** If a selected target takes NO damage (its exact memory HP — or the mob_hp
  bar in pixel mode — never drops below the max seen) within this many seconds of engaging, we assume it's
  unreachable (path blocked / stuck out of range) and drop it to re-search, instead of waiting for the long
  `GIVE_UP_AFTER` (30 s) backstop. Checked in BOTH FIGHT branches; pixel mode gates on a readable mob_hp so
  a bar-read failure doesn't false-bail.
- **(2026-07-02b) Panel UI: mini-map + collapsible rows.** (a) A **small mob-map** (170px, shared builder
  `window.__buildMobMap(data,S,interactive)` with the RE/Memory full map) now sits in the **Grind local**
  panel beside the mob-type list, fed by `update_grind_minimap` (~10×/s, live while stopped AND grinding).
  (b) The header's **OCR + PIXEL rows are collapsed by default** behind a "▸ OCR/px" toggle (`#hdrExtra`);
  the **MEM row stays always-visible** (it's the exact one). (c) The per-mode **keybind cards are now
  `<details>`** ("📖 Instructions" summary), collapsed by default, to free space for the map + params.
- **(2026-07-02b) Map CENTER OF MASS + camera-FACING + camera-view self-arrow.** (a) Both maps draw a
  **magenta ✦ at the mob centre of mass** (mean of the shown mobs' world pos). (b) When steering, `grind()`
  now `reposition_toward_mobs` = **`face_toward_mobs` (rotate the camera so the wanted-mob centroid comes to
  screen-forward → see the most mobs) then `steer_toward_mobs` (walk)**. Facing uses the self-calibrated
  `cam_off` (right-drag ∝ angular error, `ROTATE_DX`≈90°; `FACE_TOLERANCE`≈29° dead-zone; `FACE_SIGN` flips
  the turn direction if wrong) — EXPERIMENTAL, gated by `MOB_STEER`. (c) The mini-map **self-arrow now shows
  the CAMERA VIEW direction**, not movement (`data.camHeading` → mini-map self-arrow).
- **(2026-07-02c/d) Camera yaw — read is a DEGREES f32 in the camera object; NO reload-stable pointer.**
  The yaw is read via `re_sense._cam_src` (chain root+offs, or a raw session address) by `read_camera_yaw_deg/
  _rad`. Key finding: there is **no reload-stable pointer chain** on this build — the controller-graph BFS
  (`_CAMYAW_RESOLVE_CTRL`) doesn't reach the camera, and the "static" resolver roots (`0x17db64`, `0x17e2bc`,
  …) are **coincidental heap values, different every session** (persisting one caused the "yaw changes on its
  own" garbage read → that path no longer persists, camera_calib.json only stores the deg→world mapping).
- **(2026-07-02d) So the yaw ADDRESS is AUTO-LOCATED once per session** — no manual button. At grind start
  (when `MOB_STEER` is on, **and — 2026-07-02e — also in `cursor`/`hybrid` DETECT_MODE, see the
  hoverScanSpeedup entry below; there was in fact no grind()-side call site for this until 2026-07-02e**),
  `re_sense.autolocate_yaw` briefly rotates the camera (6 fwd + 6 rev right-drags,
  ~2.5 s), keeps the f32 that tracks linearly + reverses (angle-like range), and sets the session read source;
  then `calibrate_view` re-centres. The **degrees→world mapping (sign+zero) IS persisted** to
  `camera_calib.json` (learned once via `walk_dir`→`calibrate_camera`, circular-variance sign pick), so only
  the address is re-found each session, not the calibration. The RE-tab **🧭 Find camera yaw** button does the
  same locate + a live PROOF ("yaw A°→B° ✅ TRACKS") and reports a controller-rooted chain IF one ever exists.
  `camera_arrow_heading` = calibrated world dir if available else raw yaw → mini-map self-arrow; `face_toward_
  mobs` closed-loops on `camera_heading` to turn the view toward the mob centre of mass.
- **(2026-07-02d) Camera facing = PERSISTED, learned once.** Both the yaw CHAIN and the degrees→world
  mapping (sign+off) are saved to **`camera_calib.json`** (`re_sense._save/_load_cam_calib`, loaded at
  import) — so after the FIRST grind auto-learns the mapping during its normal walks (a walk or two), every
  later session reads the yaw AND faces correctly with NO re-scan and NO re-calibration. **`face_toward_mobs`
  is now CLOSED-LOOP on the exact yaw** (`re_sense.camera_heading`): read the camera's world facing → right-
  drag by the error → re-read → repeat (≤3), self-correcting `FACE_SIGN` if a turn worsens the error.
  `reposition_toward_mobs` (gated by the 🧭 "Reposition toward ticked mobs" toggle) = FACE the wanted-mob
  centre of mass, then WALK FORWARD into it (falls back to the walk-based steer until the yaw is calibrated,
  which itself calibrates it). All vision/clicks — no packets.

**Update (2026-07-02e) — hoverScanSpeedup: faster/more-reliable cursor-mode hover-scan** (see
`hoverScanSpeedup_PLAN.md`, DONE). All in `grind_local.py` (hot-reload, no restart needed):
- **B — rAF-synced cursor read.** `cursor_kind_synced`/`cursor_over_mob_synced` replace a fixed
  `sleep(HOVER_SETTLE)` + cursor read with ONE `page.evaluate` that double-`requestAnimationFrame`s
  (waits for "the frame AFTER the hover was processed", with a 400 ms `setTimeout` safety valve so a
  stalled rAF can't hang the bot) — adaptive to the TRUE frame time instead of a fixed ~22 ms guess that
  silently misses mobs when render load stretches a frame to 100–250 ms. Used at both per-point cursor
  reads: `hover_scan_radial`'s blind per-point path and `engage()`'s `ENGAGE_CURSOR_CONFIRM` block.
  `cursor_kind`/`cursor_over_mob` (fixed-read) are kept for any other caller/fallback but no longer used
  on these hot paths. Regex parsing factored into `_cursor_name`, shared by both readers.
- **(4.) Memory select-confirm.** `select_and_attack` (the cursor hover-scan's click) now polls
  `re_sense.read_target` every `SELECT_CONFIRM_POLL`=0.02 s for up to `SELECT_CONFIRM_MAX`=0.35 s instead
  of a fixed `SELECT_GAP` sleep + JPEG grab: `kind==5` (a mob) → species-checked from THAT SAME read (no
  extra evaluate) → attack if wanted, Tab-deselect + reject-list (A4) if not; `kind!=5` (player/NPC) →
  Tab-deselect immediately, no need to wait out the window (memory is exact, not a flicker); nothing
  resolves in the window (memory outage) → ONE legacy `grab_fast`+`has_target`/`is_player_target` check,
  same as before this change, so a memory outage degrades gracefully instead of silently doing nothing.
  Typical confirm ~170 ms → ~40–60 ms.
- **A — memory-guided TARGETED hover (the structural fix).** Instead of scanning blind rings, project
  each nearby WANTED mob's memory world position to a screen point and hover a small spiral there,
  nearest mob first — non-empty scans drop from ~4 s to ~0.3 s, target the RIGHT mob, chase its movement,
  and (being memory-species-filtered) never hover an unwanted species.
  - **A1 — passive world↔screen projection**, ported from `click_nav.py`'s proven `_fit`/`world_to_screen`
    (the game writes the world point a click raycasts to at `controller+2540/+2548`) but fed from clicks
    the bot ALREADY makes — **no dedicated calibration walk**. `walk_dir` reads the click's dest ~0.15 s
    after clicking (before the full `WALK_TIME` walk finishes) and feeds `_proj_feed`, which sanity-gates
    the sample (plausible distance, not a near-duplicate dest — click_nav's gates) and refits
    (`_proj_refit`, lstsq + outlier rejection) once `PROJ_MIN_SAMPLES`=6 samples have accumulated.
    Bootstraps from `click_nav.py`'s cached `click_calib.json` at grind start (cursor/hybrid) if present
    (`_proj_bootstrap`) so the targeted hover works from the FIRST cycle. Since the camera keeps rotating
    during normal play, `_proj_world_to_screen` rotates the query by the LIVE yaw delta (vs the fit's
    `yaw_fit`) before applying the affine — no refit needed for a pure rotation. The rotation SIGN
    (`_PROJ_YAW_SIGN`) is unverified a priori, so it's self-checked per new sample against both sign
    hypotheses (`_proj_check_sign`) and flipped if the other fits meaningfully better; if NEITHER fits
    within `PROJ_MAX_ERR`=3.0 world-units, the fit is dropped and rebuilds from fresh samples
    (`_proj_ok()` gates every consumer on this). `rotate_view`/`tilt_forward`/`calibrate_view` call
    `_proj_invalidate()`, which only actually drops the fit when the live camera yaw ISN'T readable (when
    it IS, the yaw-delta compensation handles the rotation, so this is normally a no-op). **The camera-yaw
    auto-locate (`re_sense.autolocate_yaw`) now also runs at grind start in `cursor`/`hybrid` DETECT_MODE**
    (previously only when `MOB_STEER` was on — that condition existed only in this doc/plan, not in code
    until now; there was no actual call site before this change), so yaw is usually available for the
    projection too. Still a no-op once already located this session (`re_sense._cam_src` persists across
    grind Starts within one panel process — `re_sense` is never hot-reloaded, unlike `grind_local.py`).
  - **A2 — targeted spiral hover** (`hover_mob_targeted`): projects the nearest `TARGETED_MOB_LIMIT`=4
    wanted mobs (from `read_wanted_mobs`, already species-filtered), hovers a click_nav-style spiral
    (`TARGETED_SPIRAL_STEP`=16 px rings to `TARGETED_SPIRAL_MAX`=80 px, 8 points/ring) around each
    projected point, re-projecting THAT mob (matched by entity id) halfway through its spiral to chase
    movement. Skips a mob whose projection falls within `TARGETED_VIEWPORT_MARGIN`=40 px of the edge.
  - **A3 — grind-loop integration** (`_hover_find`, shared by both `hover_scan_radial` call sites — the
    `cursor`-mode close-mob branch and the `hybrid` fallback): tries the targeted spiral when `_proj_ok()`,
    else the blind scan; after 2 consecutive targeted MISSES (mobs known but never confirmed under the
    cursor — occlusion, a subtly-off fit) it spends one blind scan as a backup and resets the streak, so a
    bad projection can't strand the bot.
  - **A4 — unwanted-species reject-list** (blind-scan livelock fix, `_REJECTED`/`_reject_recent`): when
    `select_and_attack` Tab-rejects an unwanted species, the click point is remembered for
    `REJECT_TTL`=10 s; the blind scans (`hover_scan_radial`'s per-point path AND the batched sweep) skip
    any point within `REJECT_RADIUS`=48 px of a fresh reject, so they don't just re-select the same
    livelocking mob (the targeted path never needs this — it's already species-filtered).
- **C — batched blind sweep** (`HOVER_BATCHED`=True, default; False reverts to the per-point B path).
  `_hover_scan_batched` installs a one-time-per-page in-page logger (`_ensure_cursor_logger`): a passive
  `mousemove` listener tracks the pointer + a `MutationObserver` on the canvas `style` attribute records
  `{x,y,cur}` on every CSS cursor swap (capped ~200 entries). The sweep moves through each RING
  (`_hover_scan_rings`, the same points as `_hover_scan_points`/the calibration overlay, now grouped) at a
  steady `HOVER_SWEEP_PACE` (default `max(HOVER_SETTLE, ~1 frame)`) with NO per-point read, then drains
  the log once per ring; any `curattack` entry re-hovers that logged point and confirms with
  `cursor_kind_synced` before returning it (kills the "sweep already moved on" race). More reliable than
  per-point sampling because the observer can't miss a swap by pacing, only mis-locate it by a few px —
  fixed by the re-hover confirm. Empty scan ~4 s → ~2.2 s.
- New tunables (all `grind_local.py`, hot-reload): `SELECT_CONFIRM_MAX`/`SELECT_CONFIRM_POLL`,
  `REJECT_TTL`/`REJECT_RADIUS`, `PROJ_MIN_SAMPLES`/`PROJ_MAX_ERR`, `TARGETED_MOB_LIMIT`/
  `TARGETED_SPIRAL_STEP`/`TARGETED_SPIRAL_MAX`/`TARGETED_VIEWPORT_MARGIN`, `HOVER_BATCHED`/
  `HOVER_SWEEP_PACE`. `HOVER_SETTLE` is kept (used as C's pace floor + a fallback reference) even though
  it's no longer used as a per-point sleep on the B paths.
- **Extension (same day): ROI restriction + memory point-filter for the blind scans.** (1) ALL hover
  points (blind rings, batched sweep incl. its logged-hit re-hover, targeted spiral) are now clipped to
  the CALIBRATED scan area: `_hover_point_ok` = inside `ROI` AND outside `HUD/MINIMAP/BANNER/CHAT_EXCL`
  — exactly `_roi_mask`'s region, from `scanner_calib.json` (replaces the old `_in_excluded`, folded in,
  which missed ROI + banner). `SELF_EXCL` deliberately NOT excluded: the innermost rings sit inside it,
  a mob next to the char is the first thing to find, and hovering the own char never reads curattack.
  The calibrate-tab hover overlay shows the clipped path automatically (shared `_hover_scan_points`;
  info line says "ROI-clipped"). (2) Blind scans pre-filter their ring points from ONE memory mob-map
  read (`_blind_scan_filter`; outward nearest-ring-first ORDER kept, empty regions skipped): tier 1 =
  projection healthy → keep points within `HOVER_MOB_NEAR_PX` (110 px) of a projected wanted mob;
  tier 2 = camera heading readable → sector filter `HOVER_SECTOR_TOL_DEG` (40°) around each close mob's
  predicted screen bearing AND its mirror (the mapping's mirror sign is unestablished); tier 3 /
  memory outage / a filter that would keep 0 points → full unfiltered scan (memory guidance may speed a
  scan up but must never shrink coverage). `_hover_find`'s backup blind scan after 2 targeted misses is
  always UNFILTERED (there the projection itself is suspect). Per-scan log:
  `[hover] blind …: kept/total pts (tier)`.
- **Extension 2 (same day, after live testing reported "significantly slower + cursor swap seemingly
  not detected"):** (1) **`CURSOR_SYNC_FRAMES`=1** — the rAF-synced read now waits a SINGLE rAF by
  default (the game's own rAF callback, registered at startup, runs before ours within the next frame;
  the original always-double-rAF cost ~2× per point on every targeted-spiral point + engage confirm and
  was the main slowdown; set 2 if misses return; 400 ms safety race kept). (2) **Batched-logger HEALTH
  CHECK**: the in-page logger counts raw mousemove events; a full empty sweep that moved the mouse but
  counted ZERO events = dead listener (the "swap not detected" symptom — it silently reads as "no mobs
  anywhere") → loud `[hover] WARNING …` + `_HOVER_BATCH_DEAD` forces the per-point path for the rest of
  the session (reset on Start). Audited hit latency: a batched hit surfaces at the ring-drain right
  after its ring, not at sweep end. (3) **Per-scan TIMING** on the `[hover]` diagnostics (kept/total
  line now printed at scan END with `… in X.Xs` + a `(hit)` marker; targeted hit/miss lines timed too).
  (4) **SELF-FIRST close scan** (`HOVER_SELF_RADII`=(28, 56) px, ~8+12 pts): `_hover_find` hovers tight
  rings around the character BEFORE the targeted spiral and any blind scan — a mob already attacking us
  stands adjacent to the char and must win; these points are ROI+reject-checked but NEVER
  memory-filtered (a projection error / the sector filter must not skip a mob standing on top of us).
  (5) **🔵 live hover-path debug overlay** (`HOVER_VIZ`, default OFF): a pointer-events-none page-side
  `<canvas>` + mousemove listener + rAF loop draws a blue circle at the live pointer + a ~3 s fading
  trace (zero extra CDP traffic); installed at grind start, removed in a `finally` when the run ends
  (`grind()` is now a thin wrapper around the renamed `_grind_run()`, same signature). Panel toggle:
  Grind local → under 🔍 Mob detection → "🔵 Show hover-scan path" → `window.__grind.hoverViz` →
  `apply_grind_config` → `gl.HOVER_VIZ`. ⚠️ that checkbox is a **control.py** edit — the toggle appears
  only after a FULL PANEL RESTART (everything else in this entry is grind_local.py = hot-reloaded).
- **Extension 3 (2026-07-02f, after live testing WITH the overlay — grind_local.py ONLY, Start-only):**
  (1) **RADIAL-MEM primary scan** (`hover_radial_memory`) replaces the targeted spiral: scans FULL
  CIRCLES at memory-derived radii about the character. The screen RADIUS = ‖Minv·(world offset)‖ (inverse
  linear part of the projection affine) is **rotation-invariant** for a conformal top-down map, so it's
  trustworthy even when the camera-yaw ANGLE calibration is wrong — hence scan a full circle at that
  radius, never trust the angle to pick a sector. Gated on `_PROJ["coef"] is not None` (scale usable even
  when `_proj_ok()` is False); radii binned by `_merge_radii` (±`HOVER_RADIAL_STEP` tolerance), rings via
  `_ring_points`. No close wanted mob → None (caller repositions); no coef → memory-filtered blind scan.
  `_hover_find` order is now self-first → fight-prefetch → radial-mem → blind (2 misses → unfiltered blind
  backup). Renders as visible concentric circles under HOVER_VIZ. (2) **Reliable read restored**:
  `HOVER_READ_MODE`="settle" (default) — the unified `_cursor_over_mob` (used at EVERY per-point site) does
  move→`sleep(HOVER_SETTLE)`→`cursor_kind`; `HOVER_SETTLE` 0.022→**0.045** (0.022 missed swaps). "raf" mode
  keeps the rAF read selectable. Both re-read once on an EMPTY (unreadable ≠ non-mob) result. Affordable
  now that radial-mem hovers far fewer points. (3) **Blocked-region hardening**: `park_cursor` parks via
  `_park_point` (inside ROI, clear of every exclusion — the old y=0.94 landed in the banner/toolbar);
  `HOVER_EDGE_MARGIN`=0.01 shrinks ROI/expands exclusions in `_hover_point_ok`; engage cursor-confirm is
  `_hover_point_ok`-guarded; all scan/engage/park moves audited to `steps=1`. (4) **FIGHT prefetch**
  (`FIGHT_PREFETCH`=True): during the pure-hold memory FIGHT, hover `FIGHT_PREFETCH_POINTS`(3) radial-mem
  points for OTHER mobs (excluding within `HOVER_MOB_NEAR_PX` of the current target's projected pos) and
  cache curattack hits; on kill `_try_prefetch` (top of `_hover_find`) re-hovers them for a warm engage.
  Cache is `_CAM_GEN`-stamped (bumped by every camera turn/tilt/reset + player walk) + `FIGHT_PREFETCH_TTL`
  (1 s); hovering never clicks so the pure-hold fight is undisturbed. New tunables: `HOVER_READ_MODE`,
  `HOVER_SETTLE`(→0.045), `HOVER_EDGE_MARGIN`, `FIGHT_PREFETCH`/`_POINTS`/`_TTL`. NO control.py change →
  **Start-only** (hot-reload). (5) **Prefetch candidates ON the HOVER_VIZ overlay**: confirmed prefetch
  points draw as GREEN ~28px boxes on the blue hover-path overlay (`window.__hoverVizSetBoxes([[x,y],…])`
  added to the overlay; `_hover_viz_boxes`/`_prefetch_sync_viz` push the cache page-side, zero extra CDP,
  no-op when the toggle is off). Boxes are driven from the SAME `_FIGHT_PREFETCH["points"]` list so they
  stay in lockstep — appear as points are confirmed, clear exactly when the cache is invalidated (engaged a
  different mob / camera moved / TTL expired). (6) **Two tunings:** `HOVER_SETTLE` **0.045 → 0.035**
  (speed/reliability balance, ~2 frames; ring density left unchanged since wider arc spacing would risk
  angular gaps the radial tolerance doesn't cover); new **`FIGHT_PREFETCH_MIN_RADIUS`** (140 px) — a
  PREFETCH-ONLY hard inner-radius exclusion so prefetch stops re-confirming the mob we're fighting
  (melee-close to screen centre); self-first + idle radial-mem scans keep their close rings. (7) **Prefetch
  discard is DEATH-anchored**: `FIGHT_PREFETCH_TTL` 1.0→**2.0s measured from the target's DEATH**
  (`_FIGHT_PREFETCH["death_t"]`), not from each candidate's found-time — the per-poll age purge was
  REMOVED, so during a fight candidates NEVER expire by age (only a camera move invalidates them) and one
  found early in a long fight is still valid at the kill. The FIGHT→IDLE death branch stamps `death_t`;
  `_try_prefetch` discards (via `_prefetch_clear`: empties cache + resets death_t + clears boxes) ONLY on a
  camera move or death_t older than the TTL, and KEEPS the cache on a failed tick (so IDLE ticks within the
  2s window retry + boxes stay visible); a confirmed candidate is returned and the cache consumed. death_t
  resets to None on any cache rebuild/clear so the next fight starts clean. Prefetch stays a "try first",
  never a gate. (8, 2026-07-02g) **Prefetch-ordering fix + smooth two-pass GLIDE scan.** (A) The kill's
  cached candidates were dropped whenever memory said "no mob close" (`_try_prefetch` was inside
  `_hover_find`, only reached under `close_n>0`); MOVED it into the cursor IDLE branch AFTER the aggro-red
  fast-path but BEFORE the `wanted_mob_summary` gate → order aggro-red → **prefetch** → memory-gate
  (self-first + radial-mem/blind). Prefetch hit → `select_and_attack`, `src="prefetch"`. `_hover_find` no
  longer calls prefetch. (B) The jittery teleport(steps=1)+settle per point is replaced by a SMOOTH
  two-pass GLIDE (`HOVER_GLIDE`, default on): `_glide_scan` glides the cursor along each ring with
  interpolated `mouse.move(...,steps=k)` (k∝segment length, `GLIDE_PX_PER_STEP`/`GLIDE_MAX_STEPS`) and
  detects curattack DURING the motion via the in-page cursor-swap logger (drained per ring, re-hover
  confirm). Pass 1 = coarse rings (`HOVER_PASS1_STEP`≈1.8×`HOVER_RADIAL_STEP`) glided fast
  (`HOVER_PASS1_PACE`); pass 2 (only if pass 1 empty) = tight rings glided slower (`HOVER_PASS2_PACE`,
  radial-mem adds ±`HOVER_PASS2_TOL` sub-rings). Applies to both `hover_radial_memory` (its memory radii)
  and the blind `hover_scan_radial` (keeps the `_filter_scan_points` memory tiers + nearest-first order).
  Falls back to the per-point settle scan when the logger is dead (`_HOVER_BATCH_DEAD`, reused health
  check) or `HOVER_GLIDE` off — no regression. New tunables: `HOVER_GLIDE`, `HOVER_PASS1_STEP`,
  `HOVER_PASS1_PACE`, `HOVER_PASS2_PACE`, `HOVER_PASS2_TOL`, `GLIDE_PX_PER_STEP`, `GLIDE_MAX_STEPS`
  (the teleport `_hover_scan_batched`/`HOVER_BATCHED` are superseded, kept for reference). The HOVER_VIZ
  overlay renders the smooth glide live.

## Reverse-engineering the WASM (ACTIVE — `reverseEngineeringWASM/`)
We are RE-ing `re/main-wasm32.wasm` to read state from memory and craft packets (vision is
unreliable). Tooling + per-function disasm + mechanical call graph are built; the **outgoing send
path + frame format are fully solved** (header `1f 37`+len+CRC32, body XOR-keystream@338112; move
opcode 1054, serializer func 4112); the cipher is func 2779.
**Memory-read sensor (M7 — HP ✅ SOLVED 2026-07-01):** `controller = *(mem 1230588)` (stable) and IS
the player's entity object. **HP is anti-cheat XOR-OBFUSCATED** (why all value/offset scans failed).
**`curHP = i32@(controller+2768) XOR i32@(controller+2744)`** — deobfuscated, EXACT, vision-free,
realloc-proof; CONFIRMED live (315→147 on damage). maxHP = the buff-aware stat formula (misses only
EQUIPMENT HP) + a peak-tracked equipment surplus (`re_sense.hp_true_max`, 2026-07-04) — so it tracks HP
buffs UP and DOWN (the formula includes buffed STA via buff92); NOT peak-of-cur (that stuck at the buffed
peak → healed forever after a buff expired). Mobs: same `+2768^+2744` on each registry-entity object (registry
@362748; entity also carries plaintext **pos x/y/z @+152/+156/+160**, kind @+204 (5=mob), id @+368).
Implemented in **`re_sense.py`**: `read_player_hp` / `HpReader` / `read_entity_hp`. ⚠️ The OLD note
"HP = f32@+308 × i32@+316 (a 0–1 ratio)" was WRONG — func 11585 (its source) is the 3D-model/skeleton
deserializer, not HP; `+308`=transform matrix, `+316`=RHandId. Getter=func 487; vtable[148]=func 11450
(maxHP formula). **Re-anchor after patches** (the June wasm was stale; grab current via `re/grab_wasm.py`
+ re-run `reverseEngineeringWASM/disasm.js`+`split_and_graph.js`; tools `schema.js`/`findprop.js` map
struct fields BY NAME via the `call 181` string-key deserializers).
**✅ AUTO SELF-HEAL for static-address shift (2026-07-03).** A game rebuild can SHIFT the whole DATA section
by a constant delta, so the static anchors (controller ptr @1230588, registry @362748/362752, cipher key
@338112, exp @363328/363384) all move at once and EVERY memory read breaks (a 2026-07-03 patch shifted
everything **−16 bytes** → all MEM features went blank: HP/MP/mobs/target/select/go-to/attack). FIX:
`re_sense._LOC` is prepended to every read's JS and **auto-locates the shift once per session** (cached in
`window.__S`): find the shift whose controller ptr resolves to a player-like entity (finite world pos + sane
deobf HP) **AND whose REGISTRY (@362748) at the same delta is a valid walkable entity map** — the registry
cross-check is the disambiguator (a weak HP-only check locked onto the WRONG entity in some sessions →
NaxozRM read a wrong HP, 2026-07-03). Prefer shift 0, else scan outward from 1230588. The chosen shift is
LIGHT-revalidated every read (`window.__Sok`), so a page reload / a wrongly-cached shift RE-locates instead
of reading a wrong entity forever. Every static address is then read as `(ADDR + S)`, so a uniform shift self-heals with
NO code edit. `re_sense.current_shift(game)` exposes it for other code (control.py legacy RE tools + the
camera-yaw root still hardcode 1230588 — apply the shift there if they matter after a patch). Struct field
OFFSETS (+2744/+2768/+152/+2656/…) are unchanged by a rebuild, so they stay literal; if a patch ever changes
THOSE (not just addresses) the self-heal won't cover it → full re-anchor as above.
**⚠️ REGISTRY-WALK HANG GUARD (2026-07-04) — every `for(b<cap)` registry walk MUST be work-bounded.** A
mislocated/garbage anchor makes `cap = *(362752+S)` read as a huge number; an unbounded `for(let b=0;b<cap;b++)`
then loops billions of times, **hanging the RENDERER so the synchronous `page.evaluate` NEVER returns → the whole
bot HARD-FREEZES with no clean stop** (this froze the **Naxoz** grind_RE session — `mob_state`, called every 0.3 s
in the kill-wait loop, walked a garbage cap when the mob despawned). FIX: every walk (`re_sense._TGT_JS`,
`_MOBS_JS`, `_GRIND_MOBS_JS`, `mob_state`, the `_LOC` `_strong` validator; control.py `_REG_*_JS`) now (a) rejects
`cap<=0||cap>5000000` up front and (b) carries a **total-work budget** (`let bud=2000000; … b<cap&&bud>0; bud--;`
in the outer loop, `&&--bud>0` in the inner while) so a bad cap OR a corrupted chain-cycle can never exceed ~2M
ops (<~150 ms) — real registries (small) are unaffected. **Never add a `b<cap` registry loop without this budget.**
Two docs, keep both updated:
- `reverseEngineeringWASM/reverseEngineeringWasm_Overview.md` — **plain-English status** of the
  RE effort (read FIRST to understand where it stands).
- `reverseEngineeringWASM/wasm_overview.md` — **technical per-function lookup** + memory map
  (a long reference list; append findings here as functions are analysed, don't prose it).

## Doc map
`PROJECT_OVERVIEW.md` (what/why, architecture, roadmap) · `ONBOARDING.md`
(agent start-here) · `START_HERE.txt` (END-USER quick start: install.bat → flyffbot.bat +
hotkey/keybind setup) · `CLAUDE.md` (this — operational rules) ·
`ReverseEngineerWasm_PLAN.md` (RE strategy/method/phases) ·
`reverseEngineeringWASM/reverseEngineeringWasm_Overview.md` (RE plain-English status) ·
`reverseEngineeringWASM/wasm_overview.md` (RE technical function lookup).
