"""
control.py — in-browser control panel for the bot. Injects a small floating panel
into the Flyff window (top-right, outside the vision ROI) with Start / Stop /
Rescan buttons you click with your mouse. The supervisor reads the button state
each loop and runs the selected mode, so you start/stop without the terminal.

Run (with host.py already running):  python -u control.py
"""
import os
import json
import time
import traceback
import math
import base64
import struct
import random
import threading
import importlib
from playwright.sync_api import sync_playwright
from session import get_page, viewport
import grind_local as gl
import re_sense
import click_nav
import shop_scanner as ss
import packet_log as plog

PKT_MAXHEX = 96   # hex chars shown per frame in the packet monitor (48 bytes)
HEADER_EVERY = 1.5  # seconds between OCR refreshes of the status header (HP/Exp/mob). Tesseract is
                    # heavy, so keep this modest — the header updates ~every 1.5s during any mode.

HEAL_HOLD_KEY = "Digit1"  # upper-row "1" (NOT numpad — that'd be "Numpad1")
FOLLOW_KEY = "KeyZ"       # "follow the selected target" — re-pressed periodically in heal/heal+buff so the
FOLLOW_EVERY = 5.0        # Ringmaster keeps following the grinder when the game's follow state drops out
HEAL_INTERVAL = 0.1     # seconds between heal re-presses (emulates holding the key). Low so that,
                        # with the "heal only if target HP below %" gate on, it keeps healing at this
                        # cadence until the target's HP reads back above the threshold.
# Heal+buff mode: TWO classes of buffs, each on its OWN refresh timer. A heal (HEAL_HOLD_KEY) is
# interspersed between buffs so the target stays alive during the cast sequence. Upper-row digits
# (NOT numpad). Each entry is (hotbar-page, key).
BUFF_PANELS = ["F2", "F3"]  # hotbar pages; we return to the FIRST page for heal-spam.
BUFF_CLASS1 = ([("F2", f"Digit{k}") for k in range(2, 10)] +   # F2 slots 2..9 …
               [("F3", f"Digit{k}") for k in range(2, 7)])     # … then F3 slots 2..6
BUFF_CLASS2 = [("F3", f"Digit{k}") for k in range(7, 10)]      # F3 slots 7,8,9 (separate, faster refresh)
BUFF_PREBUFF = ("F3", "Digit7")    # a self-buff (F3 slot 7) that IMPROVES the other buffs — cast
                                   # FIRST, before each class-1 buff cycle, so the cycle is better.
BUFF_CLASS1_FULL = [BUFF_PREBUFF] + BUFF_CLASS1   # what a class-1 cycle actually casts
# 🎯 "essential" self-buffs cast on SELF before a full target buff (the minimum that boosts the RM's buffs):
# F3 slot 7 (the prebuff), then F2 slot 2 twice. Adjust here if your essential self-buffs differ.
ESSENTIAL_SELF_BUFF = [("F3", "Digit7"), ("F2", "Digit2"), ("F2", "Digit2")]
SELF_SELECT_SETTLE = 0.18   # 🧙 self-buff: let a crafted SELECT land server-side before/after casting on self
GRE_FIGHT_POLL = 0.1        # grind_RE kill-wait default poll (now panel-set: 💓 Kill-check every ms, `fightPollMs`).
                            # How often to re-read the target's HP → death caught within ~this long of the kill.
BUFF_EVERY = 180.0      # seconds between CLASS-1 buff cycles (customizable in the control window)
BUFF2_EVERY = 50.0      # seconds between CLASS-2 buff cycles (the faster-refresh buffs)
BUFF_STEP_DELAY = 1.30  # delay between EACH buff/heal press inside a cycle (the 1,2,1,3,… pacing),
                        # 1300 ms. Raise it if buffs/heals get skipped (a skill's cast time eats the
                        # next press); lower it to rip through the cycle faster.
PANEL_SWITCH_GAP = 0.08 # gap after an F2/F3 hotbar-page switch — just enough to register (NOT a full
                        # buff delay; the page switch is instant and fits between the buff presses).
ANNOUNCE_BUFF_MSG = "rebuffing now"   # typed in chat at the start of a buff cycle (if "announce buff" on)
HEAL_BURST_MAX = 12     # between-buff healing: max heals cast before forcing the next buff. Between each
                        # buff we read the target's exact HP from memory and heal until it's back at/above
                        # the heal threshold; this caps that burst so sustained heavy damage can't starve
                        # the buff sequence (the next between-buff check heals again).

# 💾 Persisted heal+buff panel settings (slot grids + toggles) — saved by the panel's "Save settings" button,
# LOADED at import so the panel starts with the user's saved config (else the built-in defaults).
HEAL_SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "heal_settings.json")

def _heal_saved_js():
    """JS baked into CONTROL_HTML that MERGES saved heal settings into window.__heal (runs right after the
    defaults, before the UI wiring, so the panel reflects them). Blank string if there's no saved file."""
    try:
        if os.path.exists(HEAL_SETTINGS_FILE):
            with open(HEAL_SETTINGS_FILE) as f:
                data = json.load(f)
            if isinstance(data, dict):
                return "Object.assign(window.__heal, " + json.dumps(data) + ");"
    except Exception:
        pass
    return ""

def _save_heal_settings(cfg):
    """Persist the heal+buff settings dict (window.__heal) so they load next launch."""
    with open(HEAL_SETTINGS_FILE, "w") as f:
        json.dump(cfg, f)

# 🎁 Buff-slot ICONS: base64 PNGs cropped from the F2/F3 hotbar screenshots (built by the crop script;
# regenerate buff_icons.json to update). Baked into CONTROL_HTML as window.__buffIcons for the slot buttons.
BUFF_ICONS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "buff_icons.json")

def _buff_icons_js():
    """JS baked into CONTROL_HTML: `window.__buffIcons = {F2:{'2':dataURI,…}, F3:{…}}` (empty if no file)."""
    try:
        if os.path.exists(BUFF_ICONS_FILE):
            with open(BUFF_ICONS_FILE) as f:
                data = json.load(f)
            if isinstance(data, dict):
                return "window.__buffIcons = " + json.dumps(data) + ";"
    except Exception:
        pass
    return "window.__buffIcons = {};"
# Occasional human-like chatter during heal/heal+buff so the session doesn't look like a
# pure idle macro (ToS/ban-risk mitigation on a throwaway account — see ONBOARDING).
CHAT_MIN_INTERVAL = 30.0   # seconds — soonest after the last chat line
CHAT_MAX_INTERVAL = 80.0   # seconds — latest
# Occasional camera recalibration (zoom/tilt mouse activity) during heal/heal+buff, also to
# look human (a pure stationary healer with zero camera input is a bot tell).
CALIB_MIN_INTERVAL = 90.0  # seconds — soonest between random recalibrations
CALIB_MAX_INTERVAL = 220.0 # seconds — latest
# Occasional "check the map" action (open map, pan + zoom, close) — extra human-like input.
# Toggled + interval set in the control panel; defaults below.
MAP_EVERY = 180.0          # default seconds between map-fiddle actions
MAP_FIDDLE_SECS = 2.0      # how long to pan/zoom the map each time
# Idle MOUSE MOVEMENT — the #1 thing separating a healer from the grind bot is that the grinder moves
# the mouse constantly while a key-only heal loop leaves it frozen (a strong "not a human" signal that
# triggers the captcha). So drift the cursor a little, often. Pure MOVES — never clicks/drags.
IDLE_MOUSE_MIN = 2.5       # seconds — soonest between idle cursor drifts
IDLE_MOUSE_MAX = 7.0       # seconds — latest
KEY_HOLD = (0.045, 0.11)   # randomised key down→up hold (replaces a robotic fixed 0.06s on every press)
# Smooth camera "look around" — a slow right-drag along a Bezier curve through random points, like a
# human glancing about. Opt-in (it briefly holds the heal loop while it drags). The interval is a
# panel-set base (LOOK_EVERY), JITTERED ±35% per drag so it isn't metronomic.
LOOK_EVERY = 18.0         # default seconds between look-around drags (panel-settable "every (s)")
MOUSE_ACTION_GAP = 2.5     # min seconds between ANY two mouse actions (idle drift / look-around / map
                           # fiddle / recalibrate) so they never pile up or overlap into a robotic burst
MP_KEY = "Digit0"          # upper-row "0" — the MP potion hotkey used in heal/heal+buff
MP_AT = 0.40               # default: drink an MP potion when MP falls below this fraction
MP_CHECK_EVERY = 2.0       # how often to read MP for the threshold check (s)
MP_THRESH_COOLDOWN = 3.0   # min seconds between threshold-triggered MP drinks
# Self-heal via FOOD: when OUR OWN HP drops, eat the food bound to FOOD_PAGE slot 1 (switch page →
# press 1 → switch back to the heal page = BUFF_PANELS[0]). Heal/buff already restore your HP via the
# heal skill, but food covers spike damage / when out of MP. Threshold is a panel field (0 = disabled).
FOOD_PAGE = "F4"           # hotbar page where the self-heal FOOD sits (its slot 1)
FOOD_AT = 0.90             # default: eat food when our OWN HP falls below this fraction
FOOD_COOLDOWN = 3.0        # min seconds between food eats
# Follower-healer aggro avoidance: when an AGGRO mob (red name tag) is in view, spam move-clicks
# AWAY from the nearest one (biased toward the followed grind char) until it's clear.
FLEE_RADIUS = 0.30         # how far each flee move-click is from the healer (fraction of min view)
FLEE_BIAS = 0.5            # how much to pull the flee direction toward the followed char (0=away only)
FLEE_SECS = 3.0            # default seconds to flee (clickspam) per burst — set in the control panel
FLEE_CLICK_GAP = 0.35      # seconds between flee clicks
FLEE_MOB_AVOID = 0.07      # keep flee move-clicks at least this far (fraction of min view) from ANY
                           # mob, so running away never clicks/selects a mob
# Line-of-sight to the healed char is blocked ('Hindernis' message) → WASD-wiggle until it clears.
LOS_MAX_MOVES = 10         # max WASD moves before giving up this attempt
LOS_HOLD = (0.4, 0.8)      # random hold range (seconds) per WASD key
CHAT_MESSAGES = ["hi", "hey", "lol", "gl all", "nice", "wb", "ty", "anyone grinding here?",
                 "how's it going", "good drops today?", "brb", "back", "lag is real",
                 "anyone selling?", "lvl up soon", "this spot is good", "afk a sec", "o/"]

# ── Separate control WINDOW (the eventual replacement for the in-game panel) ───────
# A standalone page (own browser window) that hosts the full control UI. It sets the
# same window.__bot / window.__heal state the supervisor polls — but on ITS OWN page,
# so the game canvas is no longer cluttered. The supervisor reads commands from here
# and ACTS on the game page. Keep BOTH windows visible (don't minimise the game) so
# the game's render loop isn't throttled as a hidden tab.
CONTROL_HTML = r"""<!doctype html><html><head><meta charset="utf-8">
<title>Flyff Bot Control</title>
<style>
  *{box-sizing:border-box}
  body{margin:0;background:#0d0d10;color:#eee;font:12px/1.4 system-ui,Segoe UI,monospace;padding:10px}
  h1{font-size:14px;margin:0 0 6px}
  h2{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:#9c9;margin:0 0 6px}
  .stat{color:#8f8;min-height:1.3em;white-space:pre-wrap}
  .card{border:1px solid #2c3a2c;border-radius:8px;padding:8px 10px;background:#101510}
  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px;align-items:start}
  .span2{grid-column:1 / -1}
  .btns{display:grid;grid-template-columns:1fr 1fr;gap:5px}
  button.cmd{padding:7px 8px;border:1px solid #555;border-radius:6px;background:#222;color:#eee;
    cursor:pointer;font:inherit;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  button.cmd:hover{background:#2c2c34}
  .mem{background:#264}.stop{background:#722}.heal{background:#243a24}
  .subtabs{display:flex;flex-wrap:wrap;gap:4px;margin:8px 0}
  .subtab{padding:5px 9px;border:1px solid #444;border-radius:6px;background:#1c1c22;color:#bbb;
    cursor:pointer;font:inherit;font-size:12px;white-space:nowrap}
  .subtab:hover{background:#26262e}
  .subtab.active{background:#2d4a2d;color:#fff;border-color:#3a6}
  .scriptact{display:flex;gap:8px;margin-bottom:10px}
  button.cmd.start{flex:1;background:#2d4a2d;border-color:#3a6;font-weight:bold}
  button.cmd.quickbuff{flex:0 0 90px;background:#26407a;border-color:#4a78c8}
  button.cmd.stop{flex:0 0 90px;background:#5a2222;border-color:#a55}
  .fields{display:grid;grid-template-columns:1fr 1fr;gap:6px 8px}
  .field{display:flex;flex-direction:column;gap:2px}
  .field>span{font-size:10px;color:#9aa}
  .field .r{display:flex;gap:2px}
  .field input{flex:1;min-width:0;width:100%;text-align:center;background:#111;color:#eee;
    border:1px solid #555;border-radius:4px;padding:4px 2px;font:inherit}
  .step{flex:0 0 auto;width:20px;padding:4px 0;border:1px solid #555;border-radius:4px;
    background:#222;color:#eee;cursor:pointer;font:inherit;text-align:center}
  .step:hover{background:#333}
  .chks{display:flex;flex-direction:column;gap:4px;margin-top:6px}
  .chk{display:flex;align-items:center;gap:6px;color:#cdd;font-size:11px}
  details.adv{margin-top:8px;border-top:1px solid #2c2c34;padding-top:6px}
  details.adv summary{cursor:pointer;font-size:12px;color:#9bd;user-select:none}
  details.adv summary:hover{color:#bcf}
  .dim{color:#888;font-size:10px}
  .hdr{position:sticky;top:0;z-index:30;margin:-10px -10px 8px;padding:6px 14px;min-height:64px;
    display:flex;flex-direction:column;justify-content:center;gap:5px;background:#13181a;border-bottom:1px solid #2c3a2c}
  .hrow{display:flex;align-items:center;gap:18px;min-width:0}
  .htag{flex:0 0 auto;width:54px;text-align:center;font-size:9px;font-weight:bold;letter-spacing:.08em;
    padding:2px 0;border-radius:4px}
  .tocr{background:#3a2d4a;color:#d9b8ff;border:1px solid #6a4a9a}
  .tpx{background:#2d4a3a;color:#aef0c8;border:1px solid #3a8a6a}
  .tmem{background:#123a2a;color:#5cf0a8;border:1px solid #1f7a4a}
  .hcell{display:flex;flex-direction:column;gap:1px;min-width:0}
  .hlbl{font-size:9px;letter-spacing:.06em;text-transform:uppercase;color:#7a9}
  .hcell b{font-size:13px;color:#eaf;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .hsub{font-size:10px;color:#8ab;white-space:nowrap}
  .tabs{display:flex;gap:4px;margin:8px 0 0}
  .tab{padding:6px 14px;border:1px solid #555;border-bottom:none;border-radius:6px 6px 0 0;
    background:#1a1a1f;color:#bbb;cursor:pointer;font:inherit}
  .tab.active{background:#101510;color:#eaf;border-color:#4a4}
  .calibimg{width:100%;image-rendering:pixelated;border:1px solid #333;border-radius:4px;
    background:#000;display:block;margin-top:6px}
  .gcalhdr{display:grid;grid-template-columns:1.6fr repeat(4,1fr);gap:4px;margin-top:8px;
    font-size:10px;color:#888;text-align:center}
  .gcalhdr>span:first-child{text-align:left}
  .gcalrow{display:grid;grid-template-columns:1.6fr repeat(4,1fr);gap:4px;align-items:center;margin-top:3px}
  .gcalrow .glbl{font-size:11px;display:flex;align-items:center;gap:4px}
  .gcalrow .sw{width:9px;height:9px;border-radius:2px;flex:0 0 auto}
  .gcalrow input{width:100%;box-sizing:border-box;font-size:11px;padding:2px}
  .statgrp{margin-top:6px}
  .statgrphdr{font-size:11px;font-weight:bold;color:#cbb;margin:6px 0 2px}
  .kbcard{border-left:3px solid #3a6}
  .kbcard h2{font-size:12px}
  details.kbcard>summary{font-size:12px;font-weight:bold;color:#cde;cursor:pointer;padding:2px 0}
  details.kbcard[open]>summary{margin-bottom:4px}
  .kb{list-style:none;padding:0;margin:6px 0 0;font-size:12px}
  .kb li{margin:4px 0;display:flex;align-items:baseline;gap:8px}
  .kb b{flex:0 0 auto;min-width:40px;color:#cfe;background:#243a24;border:1px solid #3a6;
    border-radius:4px;text-align:center;padding:1px 4px;font-size:12px}
  .usage{font-size:13px;color:#ffe;background:#2a2f1a;border:1px solid #6a6a2a;border-radius:5px;
    padding:6px 9px;margin:2px 0 8px}
  .expcanvas{width:100%;height:auto;background:#0a0a0c;border:1px solid #333;border-radius:4px;display:block;margin-top:6px}
  .shopdbwrap{max-height:60vh;overflow:auto;margin-top:8px;border:1px solid #333;border-radius:4px}
  table.shopdb{width:100%;border-collapse:collapse;font-size:12px}
  table.shopdb th{position:sticky;top:0;background:#1c1c22;color:#9bd;text-align:left;padding:4px 8px;border-bottom:1px solid #333}
  table.shopdb td{padding:3px 8px;border-bottom:1px solid #1e1e22;white-space:nowrap}
  table.shopdb td.num{text-align:right;font-family:ui-monospace,monospace;color:#cfe}
  table.shopdb tr:hover td{background:#181820}
  table.shopdb tbody tr{cursor:pointer}
  .shopcount{display:inline-block;font-size:14px;font-weight:bold;color:#aef0c8;background:#1e2b1e;
    border:1px solid #3a6;border-radius:6px;padding:4px 10px;margin:6px 0}
  .expstats{display:flex;flex-wrap:wrap;gap:18px;margin-top:10px}
  .expstats .hcell b{font-size:15px}
  .pkthdr{display:flex;gap:8px;margin-top:6px;font-weight:bold;font-size:11px}
  .pkthdr>div{flex:1}
  .pkts{display:flex;gap:8px;margin-top:4px;height:150px}
  .pktcol{flex:1;min-width:0;overflow:auto;background:#0a0a0c;border:1px solid #333;
    border-radius:4px;padding:4px;font:10px/1.3 ui-monospace,monospace}
  .rrow{color:#7fd1a0;white-space:pre-wrap;word-break:break-all}
  .srow{color:#8fb6ff;white-space:pre-wrap;word-break:break-all}
</style></head><body>
  <div class="hdr">
    <div class="hrow">
      <span class="htag tmem" title="Read straight from game memory (deobfuscated WASM) — exact, no OCR/vision">MEM</span>
      <div class="hcell"><span class="hlbl">❤️ HP</span><b id="hHpMem">—</b></div>
      <div class="hcell"><span class="hlbl">🔷 MP</span><b id="hMpMem">—</b></div>
      <div class="hcell"><span class="hlbl">✦ Exp</span><b id="hExpMem">—</b></div>
      <div class="hcell"><span class="hlbl">⚡ Exp/min</span><b id="hRateMem">—</b></div>
      <div class="hcell" style="flex:1"><span class="hlbl">🎯 Target</span><b id="hTgtMem">— no target</b></div>
      <span class="hsub" id="hRes">—</span>
      <span id="hdrToggle" title="show/hide the OCR + PIXEL rows" style="cursor:pointer;color:#89a;user-select:none;padding:0 4px">▸ OCR/px</span>
    </div>
    <div id="hdrExtra" style="display:none">
      <div class="hrow">
        <span class="htag tocr" title="Exact text read with Tesseract OCR (slow, throttled)">OCR</span>
        <div class="hcell"><span class="hlbl">❤️ HP</span><b id="hHp">—</b></div>
        <div class="hcell"><span class="hlbl">🔷 MP</span><b id="hMp">—</b></div>
        <div class="hcell"><span class="hlbl">✦ Exp</span><b id="hExp">—</b></div>
        <div class="hcell"><span class="hlbl">⚡ Exp/min</span><b id="hRateOcr">—</b></div>
        <div class="hcell" style="flex:1"><span class="hlbl">🎯 Target</span><b id="hMob">— no target</b></div>
      </div>
      <div class="hrow">
        <span class="htag tpx" title="Fast pixel-detection bar scans (what the bot acts on)">PIXEL</span>
        <div class="hcell"><span class="hlbl">❤️ HP</span><b id="hHpBar">—</b></div>
        <div class="hcell"><span class="hlbl">🔷 MP</span><b id="hMpBar">—</b></div>
        <div class="hcell"><span class="hlbl">✦ Exp</span><b id="hExpBar">—</b></div>
        <div class="hcell"><span class="hlbl">⚡ Exp/min</span><b id="hRatePx">—</b></div>
        <div class="hcell" style="flex:1"><span class="hlbl">🎯 Target</span><b id="hMobBar">—</b></div>
      </div>
    </div>
  </div>
  <h1>🤖 Flyff Bot Control</h1>
  <div class="tabs">
    <button class="tab active" data-dash="dashControl">🎛️ Control</button>
    <button class="tab" data-dash="dashCalib">❤️ Calibrate Status Scanner</button>
    <button class="tab" data-dash="dashGrindCalib">🎯 Calibrate Grind scanner</button>
    <button class="tab" data-dash="dashExp">📈 EXP graphs</button>
    <button class="tab" data-dash="dashShopDb">🛒 Shop DB</button>
    <button class="tab" data-dash="dashPackets">📡 Packets</button>
    <button class="tab" data-dash="dashRE">🧬 RE / Memory</button>
  </div>
  <div id="dashControl" class="dash">
  <div id="botStat" class="stat">idle</div>
  <div class="subtabs">
    <button class="subtab active" data-script="local">▶ Grind local</button>
    <button class="subtab" data-script="healbuff">💚➕ Heal+Buff</button>
    <button class="subtab" data-script="heal">💚 Heal</button>
    <button class="subtab" data-script="selected">▶ Grind selected</button>
    <button class="subtab" data-script="local2">▶ Grind mem</button>
    <button class="subtab" data-script="grind_re">🤖 Grind RE</button>
    <button class="subtab" data-script="shopscan">🛒 ShopScanner</button>
    <button class="subtab" data-script="calibrate">⟲ Calibrate</button>
  </div>
  <div class="scriptact">
    <button class="cmd start" id="scriptStart" data-c="local">▶ Start</button>
    <button class="cmd quickbuff" id="quickBuff" data-c="quickbuff" style="display:none" title="one-shot: full buff (class 1+2) on the SELECTED target">✨ Quick buff</button>
    <button class="cmd quickbuff" id="qbSelf" data-c="selfbuff_once" style="display:none" title="one-shot: cast the ticked SELF-buffs on yourself (deselect → self-cast → restore target)">🧙 Self-buff</button>
    <button class="cmd quickbuff" id="qbEss" data-c="essbuff" style="display:none" title="one-shot: essential self-buffs (F3·7, F2·2 ×2) on self, then FULL class-1 buff on the selected target">🎯 Self+target</button>
    <button class="cmd stop" data-c="stop">■ Stop</button>
  </div>
  <label class="chk" style="margin:5px 2px 0;font-size:12px"><input type="checkbox" id="botAlert"> 🔔 Alert for bot detection — beep every 5 s &amp; PAUSE the script while a captcha image-challenge is up (solve it in the game → auto-resumes)</label>
  <div id="scriptCards">
    <details class="card kbcard" data-scripts="local selected local2">
      <summary>📖 Instructions — keybinds &amp; how to start</summary>
      <div class="usage">🕹️ How to start: stand in the middle of several mobs you want to grind, then press ▶ Start.</div>
      <ul class="kb">
        <li><b>0</b><span>your <b style="all:unset;color:#cfe">attack skill</b> — the bot clicks a mob to select it, then presses 0 to attack</span></li>
        <li><b>1</b><span>a <b style="all:unset;color:#cfe">food / HP item</b> — eaten when HP drops below your "Eat food below HP %" setting</span></li>
        <li><b>2</b><span>a <b style="all:unset;color:#cfe">self-buff skill</b> — only used if "Self-buff (2) every" &gt; 0 (leave the slot empty to skip)</span></li>
      </ul>
    </details>
    <details class="card kbcard" data-scripts="heal">
      <summary>📖 Instructions — Heal keybinds &amp; how to start</summary>
      <div class="usage">🕹️ How to start: follow the character you want to support, then press ▶ Start.</div>
      <ul class="kb">
        <li><b>1</b><span>your <b style="all:unset;color:#cfe">heal skill</b> — re-cast on the heal interval</span></li>
        <li><b>0</b><span>an <b style="all:unset;color:#cfe">MP potion</b> — used on the MP threshold / interval</span></li>
      </ul>
    </details>
    <details class="card kbcard" data-scripts="healbuff">
      <summary>📖 Instructions — Heal+Buff keybinds &amp; how to start</summary>
      <div class="usage">🕹️ How to start: follow the character you want to support, then press ▶ Start.</div>
      <ul class="kb">
        <li><b>1</b><span>your <b style="all:unset;color:#cfe">heal skill</b> — on BOTH pages F2 and F3 (cast constantly + between buffs)</span></li>
        <li><b>class 1</b><span><b style="all:unset;color:#cfe">main buffs</b> — F2 slots 2–9, then F3 slots 2–6. Refreshed on the <b style="all:unset;color:#cfe">Buff every</b> timer</span></li>
        <li><b>class 2</b><span><b style="all:unset;color:#cfe">faster buffs</b> — F3 slots 7, 8, 9. Refreshed on the separate <b style="all:unset;color:#cfe">Buff 2 every</b> timer. Put the <b style="all:unset;color:#cfe">self-buff that boosts your buffs on F3 slot 7</b> — it's cast FIRST before every buff cycle</span></li>
        <li><b>0</b><span>an <b style="all:unset;color:#cfe">MP potion</b> (used by the MP-potion timer/threshold, not the buff cycle)</span></li>
      </ul>
      <div class="dim" style="margin-top:4px">Two buff classes with independent refresh rates: class 1 (long-duration) = F2 2–9 + F3 2–6; class 2 (short-duration) = F3 7–9. Empty slots are skipped. Slot 1 = heal on both pages.</div>
    </details>
    <div class="card" data-scripts="healbuff heal">
      <h2>💚 Heal / buff</h2>
      <div class="fields">
        <div class="field"><span>Heal delay (s)</span><div class="r"><button class="step" id="healDec">–</button><input id="healInt" type="number" min="0.1" step="0.1"><button class="step" id="healInc">+</button></div></div>
        <div class="field"><span>💓 Heal check every (ms) <small style="color:#888">react to a spike</small></span><div class="r"><button class="step" id="hpollDec">–</button><input id="healPollMs" type="number" min="30" step="10"><button class="step" id="hpollInc">+</button></div></div>
        <div class="field"><span>🔷 MP potion (0) below MP %</span><div class="r"><button class="step" id="mpAtDec">–</button><input id="mpAtPct" type="number" min="0" step="5"><button class="step" id="mpAtInc">+</button></div></div>
        <div class="field"><span>🍖 Eat food (F4·1) below HP %</span><div class="r"><button class="step" id="foodAtDec">–</button><input id="foodAtPct" type="number" min="0" max="95" step="5"><button class="step" id="foodAtInc">+</button></div></div>
        <div class="field"><span>💚 Heal only if target HP below %</span><div class="r"><button class="step" id="hitDec">–</button><input id="healIfTargetPct" type="number" min="1" max="100" step="5"><button class="step" id="hitInc">+</button></div></div>
        <div class="field"><span>🧲 Follow target (Z) every (s)</span><div class="r"><button class="step" id="folDec">–</button><input id="followEvery" type="number" min="1" step="1"><button class="step" id="folInc">+</button></div></div>
        <div class="field"><span>🦘 Jump if stuck following (s)</span><div class="r"><button class="step" id="jmpDec">–</button><input id="jumpStuckSecs" type="number" min="1" step="1"><button class="step" id="jmpInc">+</button></div></div>
        <div class="field" data-scripts="healbuff"><span>Buff every (s) <small style="color:#888">F2 2-9, F3 2-6</small></span><div class="r"><button class="step" id="buffDec">–</button><input id="buffEvery" type="number" min="1" step="1"><button class="step" id="buffInc">+</button></div></div>
        <div class="field" data-scripts="healbuff"><span>Buff 2 every (s) <small style="color:#888">F3 7-9</small></span><div class="r"><button class="step" id="buff2Dec">–</button><input id="buff2Every" type="number" min="1" step="1"><button class="step" id="buff2Inc">+</button></div></div>
        <div class="field" data-scripts="healbuff"><span>Buff key delay (ms)</span><div class="r"><button class="step" id="bstepDec">–</button><input id="buffStepMs" type="number" min="0" step="50"><button class="step" id="bstepInc">+</button></div></div>
        <div class="field" data-scripts="healbuff"><span>🧙 Self-buff refresh (s) <small style="color:#888">ticked buffs on self</small></span><div class="r"><button class="step" id="sbufDec">–</button><input id="selfbuffEvery" type="number" min="15" step="5"><button class="step" id="sbufInc">+</button></div></div>
      </div>
      <div class="dim" data-scripts="healbuff" style="margin-top:4px">Between each buff, the target's HP is
        read from memory and healed until it's back at/above the "Heal only if target HP below %" threshold,
        then the next buff casts. (No fixed heal count.)</div>
      <div data-scripts="healbuff" style="margin-top:6px">
        <div class="dim" style="font-size:11px">🎁 Buffs to cast on the GRINDER <small>(untick to skip a slot)</small></div>
        <div id="healBuffSlots"></div>
      </div>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="followEnabled"> 🧲 Follow the target (press Z on the interval above — keeps following if it drops)</label>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="jumpStuck"> 🦘 Jump (Space) over obstacles when stuck following — if you're far from the target and not moving toward it for the seconds above</label>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="healIfTarget"> 💚 Only heal when the target's HP is below the % above (else hold heals)</label>
      <label class="chk" data-scripts="healbuff" style="margin-top:6px"><input type="checkbox" id="announceBuff"> 📢 Announce buff in chat ("rebuffing now")</label>
      <label class="chk" data-scripts="healbuff" style="margin-top:6px"><input type="checkbox" id="selfbuff"> 🧙 Self-buff — briefly deselect (Esc) &amp; self-cast the ticked buffs below, then re-target &amp; re-follow the grinder (one buff per pass, only while the grinder is healthy). <b style="all:unset;color:#f99">⚠️ sends a SELECT packet to re-target — throwaway account only</b></label>
      <div data-scripts="healbuff" style="margin-top:4px;margin-left:22px">
        <div class="dim" style="font-size:11px">🧙 Buffs to cast on YOURSELF <small>(untick to skip a slot)</small></div>
        <div id="healSelfSlots"></div>
      </div>
      <label class="chk" data-scripts="healbuff" style="margin-top:6px"><input type="checkbox" id="selfheal"> 🩹 Self-heal if attacked — when YOUR OWN HP drops below the % below, deselect (Esc), cast heal on self until recovered, then re-target &amp; re-follow the grinder. <b style="all:unset;color:#f99">⚠️ sends a SELECT packet to re-target — throwaway account only</b></label>
      <div class="fields" data-scripts="healbuff" style="margin-top:4px;margin-left:22px"><div class="field"><span>🩹 Self-heal below own HP %</span><div class="r"><button class="step" id="shpDec">–</button><input id="selfhealPct" type="number" min="1" max="100" step="5"><button class="step" id="shpInc">+</button></div></div></div>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="fleeEnabled"> 🏃 Flee aggro mobs (red tags)</label>
      <div class="fields" style="margin-top:6px"><div class="field"><span>Flee duration (s)</span><div class="r"><button class="step" id="fleeDec">–</button><input id="fleeSecs" type="number" min="0.5" step="0.5"><button class="step" id="fleeInc">+</button></div></div></div>
      <div style="margin-top:6px"><button id="healSaveBtn" style="padding:4px 12px;background:#0f2a1a;color:#8fe;border:1px solid #2a6a4a;border-radius:5px;cursor:pointer" title="save these heal/buff settings so they load next launch">💾 Save settings</button><span id="healSaveMsg" class="dim" style="font-size:11px;margin-left:8px"></span></div>
      <div id="botHealStat" class="stat" style="margin-top:6px;font-size:11px">idle</div>
    </div>
    <div class="card" data-scripts="healbuff heal">
      <h2>🥷 Anti-detect (heal modes)</h2>
      <div class="chks">
        <label class="chk"><input type="checkbox" id="mouseEnabled"> 🖱️ Idle mouse movement (human-like, recommended)</label>
        <label class="chk"><input type="checkbox" id="lookEnabled"> 🔄 Smoothly look around (slow Bezier camera drags)</label>
        <label class="chk"><input type="checkbox" id="chatEnabled"> 💬 Chat (random typing)</label>
        <label class="chk"><input type="checkbox" id="mapEnabled"> 🗺️ Map fiddle (zoom+drag+click)</label>
      </div>
      <div class="fields" style="margin-top:6px">
        <div class="field"><span>🔄 Look around every (s)</span><div class="r"><button class="step" id="lookDec">–</button><input id="lookEvery" type="number" min="3" step="1"><button class="step" id="lookInc">+</button></div></div>
        <div class="field"><span>Map every (s)</span><div class="r"><button class="step" id="mapDec">–</button><input id="mapEvery" type="number" min="10" step="10"><button class="step" id="mapInc">+</button></div></div>
      </div>
    </div>
    <div class="card" data-scripts="local selected local2">
      <h2>🎯 Grind — search</h2>
      <div class="field" style="margin-bottom:6px"><span>🔍 Mob detection</span>
        <select id="gDetectMode" style="flex:0 0 auto;padding:3px 6px;background:#11131c;color:#dfe;border:1px solid #2a2a3a;border-radius:4px">
          <option value="hybrid2">Hybrid 2 (memory + nameplate + motion, cursor-verified) (default)</option>
          <option value="hybrid3">Hybrid 3 (optimal probability-field search)</option>
          <option value="cursor">Cursor hover-scan (aggro + hover)</option>
          <option value="nameplate">Nameplate colour</option>
          <option value="hybrid">Hybrid (nameplate, hover after 4s)</option>
        </select></div>
      <div class="dim" id="detectModeHint" style="margin:-2px 0 6px 0">Aggro/RED via colour first, then hover-scans outward reading the game cursor to find normal mobs colour misses.</div>
      <label class="chk"><input type="checkbox" id="gHoverViz"> 🔵 Show hover-scan path (debug overlay — blue circle + trace in the game window)</label>
      <div class="fields">
        <div class="field"><span>🍖 Eat food (1) below HP %</span><div class="r"><button class="step" id="haDec">–</button><input id="gHealAt" type="number" min="5" step="5"><button class="step" id="haInc">+</button></div></div>
        <div class="field"><span>Turn step (px≈90°)</span><div class="r"><button class="step" id="rotDec">–</button><input id="gRotDx" type="number" min="20" step="10"><button class="step" id="rotInc">+</button></div></div>
        <div class="field"><span>Turns before walk</span><div class="r"><button class="step" id="turnsDec">–</button><input id="gTurns" type="number" min="1" step="1"><button class="step" id="turnsInc">+</button></div></div>
        <div class="field"><span>Walk dist (% view)</span><div class="r"><button class="step" id="wpctDec">–</button><input id="gWalkPct" type="number" min="1" step="1"><button class="step" id="wpctInc">+</button></div></div>
        <div class="field"><span>Walk time (ms)</span><div class="r"><button class="step" id="wmsDec">–</button><input id="gWalkMs" type="number" min="0" step="100"><button class="step" id="wmsInc">+</button></div></div>
        <div class="field"><span>Camera tilt (px)</span><div class="r"><button class="step" id="tiltDec">–</button><input id="gTilt" type="number" min="0" step="10"><button class="step" id="tiltInc">+</button></div></div>
        <div class="field"><span>Self-buff (2) every (s)</span><div class="r"><button class="step" id="sbDec">–</button><input id="gSelfBuff" type="number" min="5" step="5"><button class="step" id="sbInc">+</button></div></div>
      </div>
      <div class="chks">
        <label class="chk"><input type="checkbox" id="gAvoidGiant"> 🚫 Avoid Giant (skip + don't approach)</label>
        <label class="chk"><input type="checkbox" id="gAvoidSkip"> 🚫 Avoid Catcher / Red Horse</label>
      </div>
      <details class="adv">
        <summary>⚙️ Advanced — fight/engage timing + sword finder</summary>
        <div class="field" style="margin-top:6px"><span>🎯 Mob target sense</span>
          <select id="gMobTarget" style="flex:0 0 auto;padding:3px 6px;background:#11131c;color:#dfe;border:1px solid #2a2a3a;border-radius:4px">
            <option value="memory">Memory — exact, instant death (default)</option>
            <option value="pixel">Pixel — target frame + death-confirm polls</option>
          </select></div>
        <div class="dim" id="mobTargetHint" style="margin:2px 0 4px 0"></div>
        <div class="fields" style="margin-top:6px">
          <div class="field"><span>🚫 No-damage give-up (s)</span><div class="r"><button class="step" id="ndDec">–</button><input id="gNoDmg" type="number" min="1" step="1"><button class="step" id="ndInc">+</button></div></div>
          <div class="field"><span>⚔️ Fight poll (ms) <b style="all:unset;color:#889">pixel only</b></span><div class="r"><button class="step" id="fpDec">–</button><input id="gFightPoll" type="number" min="20" step="10"><button class="step" id="fpInc">+</button></div></div>
          <div class="field"><span>💀 Death-confirm polls <b style="all:unset;color:#889">pixel only</b></span><div class="r"><button class="step" id="dnDec">–</button><input id="gDeathN" type="number" min="1" step="1"><button class="step" id="dnInc">+</button></div></div>
          <div class="field"><span>⚙️ Engage settle (ms)</span><div class="r"><button class="step" id="esDec">–</button><input id="gEngageSettle" type="number" min="50" step="50"><button class="step" id="esInc">+</button></div></div>
          <div class="field"><span>⚙️ Select gap (ms)</span><div class="r"><button class="step" id="sgDec">–</button><input id="gSelectGap" type="number" min="50" step="10"><button class="step" id="sgInc">+</button></div></div>
        </div>
        <div class="dim" style="margin-top:4px">No-damage give-up: if a selected mob takes no damage this long after engaging (path blocked / unreachable) → drop it & search again.</div>
        <div class="dim" style="margin-top:4px">Lower = faster kill→next-mob. In <b>pixel</b> mode the confirm window ≈ Fight poll × Death-confirm polls; <b>memory</b> mode ignores both (exact HP → instant death).</div>
      </details>
      <div class="dim" style="margin-top:4px">applied when you (re)start a grind / calibrate</div>
    </div>
    <div class="card" data-scripts="local selected">
      <h2>🎯 Nearby mob types <span class="dim" style="font-size:11px" id="grindTypesStat">— (open + stand near mobs)</span></h2>
      <div class="dim">Live from WASM memory. <b>Tick</b> = grind this type · <b>untick</b> = avoid it.
        Drives the two options below. (Nothing ticked/unticked = grind everything.)</div>
      <div style="display:flex;gap:12px;margin-top:6px;align-items:flex-start;flex-wrap:wrap">
        <div id="grindTypesList" style="flex:1;min-width:220px;font-size:12px"></div>
        <div><div class="dim" style="font-size:10px;margin-bottom:2px">map <span id="gridMiniStat">— none nearby</span> (you = green)</div>
          <div id="gridMiniMap"></div></div>
      </div>
      <div class="chks" style="margin-top:6px">
        <label class="chk"><input type="checkbox" id="gScanMobType"> 🔍 Scan for mob type — after a hover-scan
          selects a mob, confirm its species from memory and only attack ticked types</label>
        <label class="chk"><input type="checkbox" id="gSteerToMobs"> 🧭 Reposition toward ticked mobs
          (experimental) — walk toward the ticked-mob cluster while grinding instead of wandering</label>
      </div>
    </div>
    <div class="card" data-scripts="local2">
      <h2>▶ Grind mem (hybrid)</h2>
      <div class="dim">Senses mobs from WASM memory (exact ids + HP, bulletproof death detection) and acts
via vision-detected nameplate clicks. Uses the Grind — search params above. Start, then let it run.</div>
    </div>
    <details class="card kbcard" data-scripts="grind_re">
      <summary>📖 Instructions — Grind RE (memory + packet · no vision)</summary>
      <div class="dim">Fully vision-free: reads mobs from WASM memory, selects the nearest wanted mob via a crafted
        SELECT packet, then presses your F1 skills. It never screenshots or clicks. <b>In-game F1 skill bar keybinds:</b>
        F1&nbsp;<b>1</b> = food · F1&nbsp;<b>2–8</b> = your self-buffs · F1&nbsp;<b>0</b> = attack skill.
        The char auto-attacks to death after the attack skill. <b style="color:#f99">⚠️ THROWAWAY ACCOUNTS ONLY —
        this SENDS real select/attack packets.</b></div>
    </details>
    <div class="card" data-scripts="grind_re">
      <h2>🤖 Grind RE <span class="dim" style="font-size:11px">memory + packet · no vision · no clicks</span></h2>
      <label class="chk" style="color:#f99"><input type="checkbox" id="greRisk"> ⚠️ Enable Grind RE
        (<b>THROWAWAY ACCOUNT ONLY</b> — sends real commands). Off by default; Start does nothing until ticked.</label>
      <div class="fields" style="margin-top:6px">
        <div class="field"><span>⚡ Skill delay (ms) <small style="color:#888">F1→key gap</small></span><div class="r"><button class="step" id="greSdDec">–</button><input id="greSkillDelay" type="number" min="0" step="5"><button class="step" id="greSdInc">+</button></div></div>
        <div class="field"><span>💓 Kill-check every (ms) <small style="color:#888">re-select faster</small></span><div class="r"><button class="step" id="greFpDec">–</button><input id="greFightPoll" type="number" min="30" step="10"><button class="step" id="greFpInc">+</button></div></div>
        <div class="field"><span>🍖 Eat food (F1·1) below HP %</span><div class="r"><button class="step" id="greFoodDec">–</button><input id="greFoodPct" type="number" min="0" step="5"><button class="step" id="greFoodInc">+</button></div></div>
        <div class="field"><span>✨ Refresh self-buffs every (s)</span><div class="r"><button class="step" id="greBuffDec">–</button><input id="greBuffEvery" type="number" min="5" step="5"><button class="step" id="greBuffInc">+</button></div></div>
        <div class="field"><span>🧠 Cluster radius (u) <small style="color:#888">smart only</small></span><div class="r"><button class="step" id="greCrDec">–</button><input id="greClusterR" type="number" min="5" step="5"><button class="step" id="greCrInc">+</button></div></div>
        <div class="field"><span>☢️ Giant hazard radius (u)</span><div class="r"><button class="step" id="greGhDec">–</button><input id="greGiantHaz" type="number" min="0" step="1"><button class="step" id="greGhInc">+</button></div></div>
      </div>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="greSmart"> 🧠 Smart targeting — pick the
        highest kills/min (cluster-aware) instead of the nearest mob. Learns your kill-time + walk-speed as it runs,
        then heads toward dense clusters rather than wandering off after a lone close mob.</label>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="greObstacles"> 🧱 Obstacle mapping — learn
        where terrain blocks you (from failed approaches) + where it's clear (from walking), show it in red on the
        map, and bias targeting toward mobs with a clear path.</label>
      <div class="dim" style="margin-top:8px">⌨️ F1 skill slots — <b>tick</b> the self-buffs (2–8) your char has; grey = skipped
        (<span style="color:#8f8">1 = food</span>, <span style="color:#f99">0 = attack</span> are fixed):</div>
      <div id="greSlots" style="display:flex;gap:4px;margin-top:5px;flex-wrap:wrap"></div>
      <div class="chks" style="margin-top:10px">
        <label class="chk"><input type="checkbox" id="greSkipBusy"> 🚫 Skip mobs fighting OTHER players (no
          kill-steal) — but still 🛡️ DEFEND one attacking your follower/assist (a player targeting you)</label>
        <label class="chk"><input type="checkbox" id="greAvoidGiant"> 🚫 Avoid Giant (skip + don't approach)</label>
      </div>
      <div class="dim" style="font-weight:bold;margin-top:10px">🕵️ Anti-bot-detection</div>
      <div class="chks">
        <label class="chk"><input type="checkbox" id="greLook"> 🔄 Smoothly look around (slow bezier camera drag)</label>
        <label class="chk"><input type="checkbox" id="greMouse"> 🖱️ Idle mouse movement (human-like)</label>
      </div>
      <div class="dim" id="greStat2" style="margin-top:8px">— tick ⚠️ Enable, then press ▶ Start Grind RE (top)</div>
    </div>
    <div class="card" data-scripts="grind_re">
      <h2>🎯 Nearby mob types <span class="dim" style="font-size:11px" id="greTypesStat">— (stand near mobs)</span></h2>
      <div class="dim">Live from WASM memory. <b>Tick</b> = grind this type · <b>untick</b> = avoid it
        (e.g. Catcher / Red Horse). Nothing changed = grind everything.</div>
      <div style="display:flex;gap:14px;margin-top:6px;align-items:flex-start;flex-wrap:wrap">
        <div id="greTypesList" style="flex:1;min-width:200px;font-size:12px"></div>
        <div><div class="dim" style="font-size:10px;margin-bottom:2px">🗺️ map <span id="greMiniStat">— none</span>
          · <span style="color:#3bf07a">●</span> you · thin white line = planned kill order</div>
          <div id="greMiniMap"></div>
          <div style="display:flex;gap:5px;align-items:center;margin-top:5px;flex-wrap:wrap;font-size:11px">
            <button id="greZoomOut" class="step" title="zoom out">−</button><button id="greZoomIn" class="step" title="zoom in">+</button>
            <button id="greBrushBtn" style="padding:2px 9px;background:#2a1a0a;color:#fb8;border:1px solid #6a4a2a;border-radius:4px;cursor:pointer" title="drag on the map to paint obstacle zones">🖌️ paint</button>
            <button id="greEraseBtn" style="padding:2px 9px;background:#0a2a1e;color:#8fd;border:1px solid #2a6a4a;border-radius:4px;cursor:pointer" title="drag on the map to erase painted zones under the brush">🧹 erase</button>
            <label class="dim">thick <input id="greBrush" type="number" min="1" step="1" style="width:44px"></label>
            <label class="dim">🚧 margin <input id="greObsMargin" type="number" min="0" step="1" style="width:40px"></label>
            <button id="greClearPaint" style="padding:2px 8px;background:#2a1010;color:#f99;border:1px solid #5a2a2a;border-radius:4px;cursor:pointer" title="clear painted obstacles">🗑️</button>
            <button id="greSaveMap" style="padding:2px 9px;background:#0f2a1a;color:#8fe;border:1px solid #2a6a4a;border-radius:4px;cursor:pointer" title="save this area's map (learned obstacles + painted no-go zones), named by the most common nearby mob">💾 save map</button>
          </div>
          <div id="greMapLoadOffer" style="display:none;margin-top:5px;font-size:11px"></div>
          <div id="greMapStats" class="dim" style="font-size:11px;margin-top:6px;line-height:1.6"></div></div>
      </div>
    </div>
    <div class="card kbcard" data-scripts="shopscan">
      <h2>🛒 ShopScanner — catalog private-shop prices into a database</h2>
      <div class="usage">🕹️ How to use: stand in a city full of private shops (e.g. Flarine). When you open a shop it auto-records the items + prices. Turn on Auto-roam to also find &amp; open shops itself (best-effort). Calibrate the shop box below first.</div>
      <label class="chk"><input type="checkbox" id="shopAutoRoam"> 🚶 Auto-roam (find &amp; open vendor shops automatically)</label>
      <div id="shopStat" class="stat" style="margin-top:6px;font-size:11px">idle</div>
      <div class="dim" style="margin-top:4px">Saved to <b style="all:unset;color:#cfe">shop_db.json</b> (item → price range · date · seller). Needs Tesseract OCR (install.bat sets it up).</div>
    </div>
    <div class="card" data-scripts="shopscan">
      <h2>🎯 Shop-window detection (automatic)</h2>
      <img id="shopImg" class="calibimg" alt="(open a private shop to see it detected here)">
      <div id="shopInfo" class="dim">open a private shop — it's found automatically, wherever it sits on screen</div>
      <div class="dim" style="margin-top:6px">No calibration needed: the window is located by matching its <b style="all:unset;color:#cfe">Sale List / Chat / Message</b> bar, so it works at <b style="all:unset;color:#cfe">any browser-window size</b>. ⚠️ Use a <b style="all:unset;color:#cfe">tall</b> game window so the whole shop fits — a short window clips the shop and fewer items are captured (the text stays readable either way, since the UI is fixed-size).</div>
    </div>
    <div class="card" data-scripts="calibrate">
      <h2>⟲ Calibrate view</h2>
      <div class="dim">Resets the camera to a consistent reference: max zoom-out → zoom in a few steps →
tilt to top-down. Run this once after logging in (or when the view drifts) so detection matches the tuned
scale. Uses the Camera tilt value from Grind — search. Press Start to calibrate, Stop to abort.</div>
    </div>
  </div><!-- /scriptCards -->
  </div><!-- /dashControl -->
  <div id="dashPackets" class="dash" style="display:none">
    <div class="card">
      <div style="display:flex;align-items:center;gap:8px">
        <h2 style="margin:0;flex:1">📡 Raw packets</h2>
        <label class="chk"><input type="checkbox" id="pktPause"> pause</label>
        <button class="step" id="pktClear" style="width:auto;padding:3px 8px">Clear</button>
      </div>
      <div id="pktLog" class="dim" style="margin:4px 0">📝 not logging — start a script to write a timestamped frames+actions log for RE</div>
      <div class="pkthdr"><div style="color:#7fd1a0">◀ RECV</div><div style="color:#8fb6ff;text-align:right">SEND ▶</div></div>
      <div class="pkts" style="height:60vh">
        <div id="pktRecv" class="pktcol"></div>
        <div id="pktSend" class="pktcol"></div>
      </div>
    </div>
  </div>
  <div id="dashRE" class="dash" style="display:none">
    <div class="card" style="border-color:#2d5">
      <h2>🗺️ Nearby enemies <span class="dim" style="font-size:11px" id="memMobsStat">— reading…</span></h2>
      <div class="dim">Live monster list read straight from the WASM <b>entity registry</b> (@362748) — each
        row is a real in-world mob: <b>species</b> (IDS_PROPMOVER id), <b>exact HP</b> (deobfuscated),
        <b>world position</b>, <b>level</b>, and <b>distance</b> from you. Filtered to attackable monsters
        (excludes you, pets, NPCs via kind@+204==5 + component type). Updates ~10×/s while this tab is open.
        The per-mob action buttons are hidden until you tick the ⚠️ risk toggle below (they SEND commands).
        With the toggle on, each row has <b style="color:#9cf">select</b> (writes the target — client-side, no packet) and
        <b style="color:#f99">attack</b> (arms the game's OWN attack command; FIRES when the target is in
        <b>melee range</b> → the char auto-attacks it to death. Out of range it waits, since the game's
        walk-to-target is client-side and the server rubber-bands it back — server-synced movement is not
        solved yet). <b>⚠️ attack SENDS a command — throwaway account only.</b><br>
        Map: <b style="color:#3bf07a">green arrow</b> = you (points the way you last MOVED — a facing proxy;
        it's a dot until you move) · <b style="color:#ff1a1a">thick pulsing red</b> = mob attacking YOU ·
        <b style="color:#ff5a5a">thin red</b> = mob in combat (targeting something else) · dot tint = distance.</div>
      <label class="chk" style="margin-top:6px;color:#f99"><input type="checkbox" id="memRiskOn"> ⚠️ enable
        action buttons (select / attack / go-to) — these SEND commands · <b>THROWAWAY ACCOUNT ONLY</b>. Off
        by default so they can't be clicked by accident.</label>
      <div class="dim" id="memActStat" style="margin-top:4px;min-height:14px">—</div>
      <div class="dim" style="margin-top:4px;font-size:11px">🤖 Automated memory+packet grinding is in the
        <b>Control tab → 🤖 Grind RE</b> sub-panel.</div>
      <div style="display:flex;gap:14px;margin-top:8px;align-items:flex-start;flex-wrap:wrap">
        <div id="memMobsTbl" style="font-size:12px;font-family:monospace;flex:1;min-width:280px"></div>
        <div><div class="dim" style="font-size:10px;margin-bottom:2px">top-down map (you = green; world-oriented; ring labels = units)</div>
          <div id="memMobsMap"></div></div>
      </div>
    </div>
    <div class="card">
      <h2>🧭 Find camera yaw</h2>
      <div class="dim">Locates the live CAMERA view-direction float in memory. <b>Stand still</b> and keep the
        camera free to rotate (not fixed), then press — it right-drags the camera in steps and finds the f32
        that tracks the rotation. Takes ~10 s; don't touch the mouse while it runs. (No packets/commands — just
        camera rotation, safe on any account.)</div>
      <div class="row" style="margin-top:6px">
        <button class="step" id="findYawBtn" style="width:auto;padding:4px 12px">🧭 Find camera yaw</button>
        <span id="findYawStat" class="dim"></span>
      </div>
      <div id="findYawOut" class="dim" style="margin-top:6px;font-family:monospace;font-size:11px;white-space:pre"></div>
    </div>
  </div>
  <div id="dashCalib" class="dash" style="display:none">
    <div class="card">
      <h2>🎯 Scanner calibration — Status (HP / MP / EXP) + TARGET HP</h2>
      <img id="calibImg" class="calibimg" alt="(live crop appears here while this tab is open)">
      <div id="calibInfo" class="dim">waiting for live image… (keep this tab open, game running)</div>
      <div id="statRows" style="margin-top:8px"></div>
      <div class="row" style="margin-top:8px">
        <button class="cmd heal" id="calibSave" style="flex:0 0 auto">💾 Save calibration</button>
        <span id="calibStat" class="dim"></span>
      </div>
      <div class="dim" style="margin-top:6px">For each bar: YELLOW box = search area · GREEN = fill 0% (bar left) · BLUE = fill 100% (bar right).
Adjust each box to hug its bar (HP red, MP dark-blue, EXP light-blue/cyan in the top-LEFT window; TARGET HP = the red bar in the top-CENTRE target frame — select a mob/char first), green at its left edge, blue at its right end — then Save.</div>
    </div>
  </div>
  <div id="dashGrindCalib" class="dash" style="display:none">
    <div class="card">
      <h2>🎯 Scanner calibration — Grind regions</h2>
      <img id="gcalibImg" class="calibimg" alt="(live overlay appears here while this tab is open)">
      <div id="gcalibInfo" class="dim">waiting for live image… (keep this tab open, game running)</div>
      <div class="gcalhdr"><span>region (fraction of view)</span><span>L</span><span>T</span><span>R</span><span>B</span></div>
      <div id="gcalibRows"></div>
      <div class="row" style="margin-top:8px">
        <button class="cmd heal" id="gcalibSave" style="flex:0 0 auto">💾 Save calibration</button>
        <span id="gcalibStat" class="dim"></span>
      </div>
      <label class="chk" style="margin-top:6px"><input type="checkbox" id="gcalibShowHover" checked> 🖱️ show cursor
        hover-scan path (where <b>cursor</b>/<b>hybrid</b> detection hovers the mouse to find mobs colour missed)</label>
      <div class="dim" style="margin-top:6px">GREEN = ROI scan area (where mobs are searched) · RED = ignored zones
(HUD, minimap, banner, chat) · ORANGE = player centre (mobs there are skipped) · <span style="color:#0ff">CYAN dots</span> =
hover-scan sample points (outward rings from the character; only drawn when the checkbox above is on).
Values are fractions of the view (0–1). Adjust so green covers the play area and red/orange cover the UI to ignore, then Save.</div>
    </div>
  </div>
  <div id="dashExp" class="dash" style="display:none">
    <div class="card">
      <h2>📈 EXP progression</h2>
      <canvas id="expCanvas" width="720" height="240" class="expcanvas"></canvas>
      <div class="expstats">
        <div class="hcell"><span class="hlbl">Current Exp</span><b id="exCur">—</b></div>
        <div class="hcell"><span class="hlbl">Last gain (≈1 mob)</span><b id="exStep">—</b></div>
        <div class="hcell"><span class="hlbl">Avg / min (last 60s)</span><b id="exAvg">—</b></div>
        <div class="hcell"><span class="hlbl">Avg / hour (last 60m)</span><b id="exHour">—</b></div>
        <div class="hcell"><span class="hlbl">Session / min</span><b id="exSess">—</b></div>
        <div class="hcell"><span class="hlbl">To next level (est.)</span><b id="exEta">—</b></div>
      </div>
      <div class="row" style="margin-top:8px">
        <button class="step" id="expReset" style="width:auto;padding:3px 10px">↺ Reset graph</button>
        <span class="dim" id="expInfo">Tracks the precise OCR Exp% (falls back to the pixel bar). Samples every ~1.5 s.</span>
      </div>
    </div>
  </div>
  <div id="dashShopDb" class="dash" style="display:none">
    <div class="card">
      <h2>🛒 Shop price database</h2>
      <div id="shopDbCount" class="shopcount">📦 0 items</div>
      <div class="row">
        <input id="shopSearch" placeholder="🔎 search item…" style="flex:1;min-width:0">
        <button class="step" id="shopDbCsv" style="width:auto;padding:3px 10px">⭳ Export CSV</button>
      </div>
      <div id="shopDbInfo" class="dim" style="margin-top:4px">click an item to see its price history</div>
      <div class="shopdbwrap">
        <table class="shopdb">
          <thead><tr><th>Item</th><th>Min</th><th>Median</th><th>Max</th><th>#</th><th>Last seen</th></tr></thead>
          <tbody id="shopDbBody"></tbody>
        </table>
      </div>
    </div>
    <div id="shopDetail" class="card" style="display:none">
      <div class="row"><h2 style="flex:1;margin:0" id="shopDetailName">Item</h2><button class="step" id="shopDetailClose" style="width:auto;padding:3px 10px">✕ close</button></div>
      <div id="shopDetailSummary" class="dim" style="margin-top:4px"></div>
      <div class="shopdbwrap" style="max-height:42vh">
        <table class="shopdb">
          <thead><tr><th>Date</th><th>Price</th><th>Seller</th></tr></thead>
          <tbody id="shopDetailBody"></tbody>
        </table>
      </div>
    </div>
  </div>
<script>
  window.__bot  = {cmd:'stop', mode:'local', gen:0};
  window.__heal = {interval: %DEF%, buffEvery: %BUFFDEF%, buff2Every: %BUFF2DEF%, buffStepMs: %BUFFSTEPMS%, mpAtPct: %MPATPCT%, foodAtPct: %FOODATPCT%, healIfTarget: true, healIfTargetPct: 90, healPollMs: 100, followEnabled: true, followEvery: %FOLLOWEVERY%, jumpStuck: true, jumpStuckSecs: 3, mouseEnabled: false, lookEnabled: false, lookEvery: %LOOKEVERY%, announceBuff: false, selfbuff: true, selfbuffEvery: %BUFFDEF%, selfheal: true, selfhealPct: 70, fleeEnabled: %FLEEEN%, fleeSecs: %FLEESECS%, chatEnabled: %CHATEN%, mapEnabled: %MAPEN%, mapEvery: %MAPEVERY%};
  %HEALSAVED%   // ← Object.assign(window.__heal, <saved heal_settings.json>) if a saved-settings file exists (else blank)
  %BUFFICONS%   // ← window.__buffIcons = {F2:{...},F3:{...}} — base64 buff icons cropped from the hotbar
  // grind search params — applied to grind_local at the start of each grind run
  window.__grind = {detectMode: '%DETECTMODE%', mobTarget: '%MOBTARGET%', scanMobType: false, steerToMobs: false, hoverViz: false, mobSel: {}, noDamageSecs: %NODMGSECS%, healAtPct: %HEALATPCT%, rotateDx: %ROTDX%, turnsBeforeWalk: %TURNS%, walkPct: %WALKPCT%, walkTimeMs: %WALKMS%, tiltBackUp: %TILT%, selfBuffEvery: %SELFBUFF%, fightPollMs: %FIGHTPOLLMS%, deathConfirm: %DEATHN%, engageSettleMs: %ENGAGESETTLEMS%, selectGapMs: %SELECTGAPMS%, avoidGiant: %AVGIANT%, avoidSkip: %AVSKIP%};
  // status header (OCR'd HP/Exp/mob), pushed by the supervisor
  window.__statusSet = (s) => {
    const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v || '—'; };
    const feed = s.feed || 'both';
    // OCR row (exact text) — NOT on a 'px'-only push (its OCR fields are blank; the worker thread
    // pushes the real OCR values separately), so the OCR row persists its last value between OCRs.
    if (feed === 'ocr' || feed === 'both') {
      set('hHp', s.hp);
      set('hMp', s.mp);
      set('hExp', s.exp);
      const e = document.getElementById('hMob');
      if (e) e.textContent = s.mob ? (s.mob + (s.mobhp ? '  ·  ' + s.mobhp : '')) : '— no target';
    }
    if (s.res !== undefined) set('hRes', s.res || '—');
    // FAST PIXEL row (bar scans the bot acts on) — only on a push carrying bars ('px' or 'both')
    if (feed === 'px' || feed === 'both') {
      set('hHpBar', s.hp_bar);
      set('hMpBar', s.mp_bar);
      set('hExpBar', s.exp_bar);
      set('hMobBar', s.mobhp_bar || '— no target');
    }
    // MEMORY row (deobfuscated WASM reads — exact, no OCR/vision). Set whenever provided.
    if (s.hp_mem   !== undefined) set('hHpMem',   s.hp_mem);
    if (s.mp_mem   !== undefined) set('hMpMem',   s.mp_mem);
    if (s.exp_mem  !== undefined) set('hExpMem',  s.exp_mem);
    if (s.rate_mem !== undefined) set('hRateMem', s.rate_mem);
    if (s.tgt_mem  !== undefined) set('hTgtMem',  s.tgt_mem);
    if (window.__expUpdate) window.__expUpdate(s);   // Exp rate + graph
  };
  // ── Exp progression tracking (rate %/min + graph) ──
  window.__exp = {ocr: [], px: [], mem: [], active: false, stepOcr: null, stepPx: null, stepMem: null, lastMemT: 0};
  const _setTxt = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
  const _parseExp = s => { if (s == null) return null; const m = String(s).match(/([0-9]+(?:\.[0-9]+)?)/); return m ? parseFloat(m[1]) : null; };
  const _pushExp = (arr, v) => {
    if (v == null) return;
    const t = Date.now();
    arr.push({t, v});
    const cut = t - 30*60000;                       // keep ~30 min of history
    while (arr.length > 2 && arr[0].t < cut) arr.shift();
  };
  const _ratePerMin = arr => {                       // gain over the last 60 s, in %/min
    if (arr.length < 2) return null;
    const now = arr[arr.length-1].t;
    let i = arr.length-1;
    while (i > 0 && now - arr[i-1].t <= 60000) i--;
    const a = arr[i], b = arr[arr.length-1];
    const dtMin = (b.t - a.t)/60000;
    if (dtMin <= 0) return null;
    const dv = b.v - a.v;
    return dv < 0 ? null : dv/dtMin;                 // dv<0 = level-up inside the window
  };
  const _lastStep = arr => {                          // most recent positive jump (≈1 mob)
    for (let i = arr.length-1; i > 0; i--) {
      const d = arr[i].v - arr[i-1].v;
      if (d > 1e-6) return d;
      if (d < -1e-6) return null;                     // crossed a level
    }
    return null;
  };
  const _sessionPerMin = arr => {                     // avg %/min over the whole session (sums gains)
    if (arr.length < 2) return null;
    let gain = 0;
    for (let i = 1; i < arr.length; i++) { const d = arr[i].v - arr[i-1].v; if (d > 0) gain += d; }
    const mins = (arr[arr.length-1].t - arr[0].t)/60000;
    return mins > 0 ? gain/mins : null;
  };
  const _hourRate = arr => {                          // avg %/HOUR over the last hour; if the session is
    if (arr.length < 2) return null;                  // shorter, dividing the gain by the (shorter) elapsed
    const cut = arr[arr.length-1].t - 60*60000;       // time naturally EXTRAPOLATES it to a full-hour rate.
    let i = arr.length - 1;
    while (i > 0 && arr[i-1].t >= cut) i--;            // first sample within the last 60 min
    let gain = 0;
    for (let j = i+1; j < arr.length; j++) { const d = arr[j].v - arr[j-1].v; if (d > 0) gain += d; }
    const hrs = (arr[arr.length-1].t - arr[i].t)/3600000;
    return hrs > 0 ? gain/hrs : null;
  };
  const _expArr = () => (window.__exp.mem.length >= 2 ? window.__exp.mem       // MEMORY exp is EXACT → prefer it
                       : (window.__exp.ocr.length >= 2 ? window.__exp.ocr : window.__exp.px));
  const _drawExp = () => {
    const cv = document.getElementById('expCanvas'); if (!cv) return;
    const ctx = cv.getContext('2d'); const W = cv.width, H = cv.height, pad = 26;
    ctx.clearRect(0,0,W,H);
    const arr = _expArr();
    if (arr.length < 2) { ctx.fillStyle = '#666'; ctx.font = '13px sans-serif'; ctx.fillText('waiting for Exp samples…', 12, 24); return; }
    const t1 = arr[arr.length-1].t, t0 = Math.max(arr[0].t, t1 - 10*60000);  // last 10 min
    const pts = arr.filter(p => p.t >= t0);
    let vmin = Infinity, vmax = -Infinity;
    pts.forEach(p => { if (p.v < vmin) vmin = p.v; if (p.v > vmax) vmax = p.v; });
    if (vmax - vmin < 0.01) { vmax = vmin + 0.01; }
    const X = t => pad + (t - t0)/((t1 - t0) || 1)*(W - 2*pad);
    const Y = v => H - pad - (v - vmin)/((vmax - vmin) || 1)*(H - 2*pad);
    ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.beginPath();
    ctx.moveTo(pad, H-pad); ctx.lineTo(W-pad, H-pad); ctx.moveTo(pad, pad); ctx.lineTo(pad, H-pad); ctx.stroke();
    ctx.strokeStyle = '#4cf'; ctx.lineWidth = 2; ctx.beginPath();
    pts.forEach((p, i) => { const px = X(p.t), py = Y(p.v); i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); });
    ctx.stroke();
    ctx.fillStyle = '#888'; ctx.font = '10px ui-monospace,monospace';
    ctx.fillText(vmax.toFixed(3)+'%', 2, pad+4); ctx.fillText(vmin.toFixed(3)+'%', 2, H-pad);
    ctx.fillText('-'+Math.round((t1-t0)/60000)+'m', pad, H-8); ctx.fillText('now', W-pad-20, H-8);
  };
  const _refreshExpPanel = () => {
    const arr = _expArr();
    const cur = arr.length ? arr[arr.length-1].v : null;
    _setTxt('exCur', cur == null ? '—' : cur.toFixed(4)+'%');
    const st = _lastStep(arr);  _setTxt('exStep', st == null ? '—' : '+'+st.toFixed(4)+'%');
    const av = _ratePerMin(arr); _setTxt('exAvg', av == null ? '—' : '+'+av.toFixed(3)+'%/min');
    const se = _sessionPerMin(arr); _setTxt('exSess', se == null ? '—' : '+'+se.toFixed(3)+'%/min');
    const hr = _hourRate(arr); _setTxt('exHour', hr == null ? '—' : '+'+hr.toFixed(2)+'%/h');
    // To-next-level ETA is based on the avg %/HOUR (falls back to the per-min rates if no hour stat yet).
    const ratePerMin = (hr != null && hr > 0) ? hr/60 : (av || se);
    if (ratePerMin && cur != null && ratePerMin > 0) {
      const etaMin = (100 - cur)/ratePerMin;          // minutes to the next level
      _setTxt('exEta', etaMin >= 90 ? (etaMin/60).toFixed(1)+' h'
                     : etaMin >= 1  ? etaMin.toFixed(1)+' min'
                                    : Math.round(etaMin*60)+' s');
    } else _setTxt('exEta', '—');
    _drawExp();
  };
  const EXP_OCR_TOL = 15;                              // %: keep an OCR exp reading when it's within this of
                                                       // the COARSE pixel bar. OCR is more precise/reliable,
                                                       // so prefer it whenever they roughly agree (<15% apart);
                                                       // this also fills the OCR series faster so the graph
                                                       // stops falling back to the pixel read early on. A
                                                       // bigger gap = an OCR misread (e.g. 8.5→85) → dropped.
  const _feedExp = (arr, v, stepKey, ref) => {         // filter OCR misreads before recording
    if (v == null || v < 0 || v > 100) return;         // exp% is 0..100 — anything else is a misread
    if (ref != null && ref > 0 && Math.abs(v - ref) > EXP_OCR_TOL) return;  // disagrees with pixel bar → misread
    if (arr.length) {
      const d = v - arr[arr.length-1].v;
      const step = window.__exp[stepKey];
      if (d > 0 && step != null && d > 10*step && d > 0.01) return;   // >10x the normal gain = spike → ignore
      if (d > 0) window.__exp[stepKey] = d;            // remember the latest normal increase (≈1 mob)
    }
    _pushExp(arr, v);
  };
  window.__expUpdate = (s) => {
    const feed = s.feed || 'both';
    const pxV = _parseExp(s.exp_bar);                  // coarse but reliable magnitude — reference for OCR
    // Feed each Exp series from its own source: 'px' pushes (fast loop) → pixel series; 'ocr' pushes
    // (background OCR thread) → OCR series, still gated by the EXP_OCR_TOL pixel-bar agreement check;
    // 'both' (idle inline OCR) → feed both. So during a run the OCR series keeps filling off-thread.
    if (feed !== 'ocr') _feedExp(window.__exp.px,  pxV,             'stepPx',  null);
    if (feed !== 'px')  _feedExp(window.__exp.ocr, _parseExp(s.exp), 'stepOcr', pxV);
    if (feed === 'mem' || feed === 'both') {             // EXACT memory exp — pushed ~10×/s, throttle to ~1/s
      const mv = _parseExp(s.exp_mem), now = Date.now();
      if (mv != null && now - (window.__exp.lastMemT || 0) >= 1000) {
        _feedExp(window.__exp.mem, mv, 'stepMem', null); window.__exp.lastMemT = now;
      }
    }
    const fmt = r => r == null ? '—' : '+'+r.toFixed(3)+'%/min';
    _setTxt('hRateMem', fmt(_ratePerMin(window.__exp.mem)));   // MEM row exp/min (exact, from memory)
    _setTxt('hRateOcr', fmt(_ratePerMin(window.__exp.ocr)));
    _setTxt('hRatePx',  fmt(_ratePerMin(window.__exp.px)));
    if (window.__exp.active) _refreshExpPanel();
  };
  window.__expRefresh = _refreshExpPanel;
  const pktR = document.getElementById('pktRecv'), pktS = document.getElementById('pktSend');
  const pktPause = document.getElementById('pktPause');
  document.getElementById('pktClear').onclick = () => { pktR.textContent=''; pktS.textContent=''; };
  // called by the supervisor with a batch of {dir:'recv'|'send', text:'...'} frames
  window.__pktAdd = (frames) => {
    if (pktPause.checked) return;
    for (const f of frames) {
      const col = f.dir === 'recv' ? pktR : pktS;
      const atBottom = col.scrollTop + col.clientHeight >= col.scrollHeight - 30;
      const row = document.createElement('div');
      row.className = f.dir === 'recv' ? 'rrow' : 'srow';
      row.textContent = f.text;
      col.appendChild(row);
      while (col.childElementCount > 250) col.removeChild(col.firstChild);  // ring buffer
      if (atBottom) col.scrollTop = col.scrollHeight;                       // follow tail
    }
  };
  document.querySelectorAll('button.cmd').forEach(b => b.onclick = () => {
    const c = b.dataset.c;
    if (c === 'stop') window.__bot = {cmd:'stop', mode:window.__bot.mode, gen:Date.now()};
    else window.__bot = {cmd:'start', mode:c, gen:Date.now()};
  });
  // 🔔 bot-detection alert: a Web-Audio beep (created on the tick = a user gesture, so autoplay allows it),
  // called by the supervisor every 5 s while a captcha image-challenge is up.
  const _beep = () => { try { const ctx = window.__beepCtx; if (!ctx) return;
    if (ctx.state === 'suspended') ctx.resume();
    const o = ctx.createOscillator(), g = ctx.createGain();
    o.type = 'square'; o.frequency.value = 880; g.gain.value = 0.2;
    o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime + 0.3); } catch(e){} };
  window.__captchaBeep = _beep;
  const baCb = document.getElementById('botAlert');
  if (baCb) { baCb.checked = false; window.__botAlert = false;
    baCb.onchange = () => { window.__botAlert = baCb.checked;
      if (baCb.checked) { try { window.__beepCtx = window.__beepCtx ||
        new (window.AudioContext||window.webkitAudioContext)(); window.__beepCtx.resume(); _beep(); } catch(e){} } }; }
  // header: OCR + PIXEL rows collapsed by default; the ▸ toggle expands them (MEM row always shown)
  const hdrTog = document.getElementById('hdrToggle');
  if (hdrTog) hdrTog.onclick = () => {
    const ex = document.getElementById('hdrExtra');
    const open = ex.style.display === 'none';
    ex.style.display = open ? '' : 'none';
    hdrTog.textContent = (open ? '▾' : '▸') + ' OCR/px';
  };
  // ── per-script sub-panels: show only the selected script's controls ──
  const SCRIPTLBL = {local:'Grind local', healbuff:'Heal+Buff', heal:'Heal',
    selected:'Grind selected', local2:'Grind mem', grind_re:'Grind RE', shopscan:'ShopScanner', calibrate:'Calibrate'};
  function showScript(key){
    document.querySelectorAll('.subtab').forEach(s => s.classList.toggle('active', s.dataset.script === key));
    document.querySelectorAll('#scriptCards [data-scripts]').forEach(c => {
      c.style.display = c.dataset.scripts.split(' ').includes(key) ? '' : 'none';
    });
    const sb = document.getElementById('scriptStart');
    sb.dataset.c = key;
    sb.textContent = '▶ Start ' + (SCRIPTLBL[key] || key);
    ['quickBuff','qbSelf','qbEss'].forEach(id => { const e = document.getElementById(id);
      if (e) e.style.display = (key === 'healbuff') ? '' : 'none'; });   // healbuff-only one-shot buff buttons
    window.__activeScript = key;
    window.__shopActive = (key === 'shopscan');
  }
  document.querySelectorAll('.subtab').forEach(s => s.onclick = () => showScript(s.dataset.script));
  showScript('local');
  const inp = document.getElementById('healInt');
  inp.value = window.__heal.interval;
  const commit = v => { v = Math.max(0.1, Math.round((parseFloat(v)||0.1)*10)/10); window.__heal.interval = v; inp.value = v; };
  inp.onchange = () => commit(inp.value);
  inp.oninput  = () => { const v = parseFloat(inp.value); if (v > 0) window.__heal.interval = v; };
  document.getElementById('healDec').onclick = () => commit((window.__heal.interval||0.1) - 0.1);
  document.getElementById('healInc').onclick = () => commit((window.__heal.interval||0.1) + 0.1);
  const hpinp = document.getElementById('healPollMs');
  hpinp.value = window.__heal.healPollMs;
  const hpcommit = v => { v = Math.max(30, Math.round((parseFloat(v)||100)/10)*10); window.__heal.healPollMs = v; hpinp.value = v; };
  hpinp.onchange = () => hpcommit(hpinp.value);
  hpinp.oninput  = () => { const v = parseFloat(hpinp.value); if (v >= 30) window.__heal.healPollMs = v; };
  document.getElementById('hpollDec').onclick = () => hpcommit((window.__heal.healPollMs||100) - 10);
  document.getElementById('hpollInc').onclick = () => hpcommit((window.__heal.healPollMs||100) + 10);
  const binp = document.getElementById('buffEvery');
  binp.value = window.__heal.buffEvery;
  const bcommit = v => { v = Math.max(1, Math.round(parseFloat(v)||1)); window.__heal.buffEvery = v; binp.value = v; };
  binp.onchange = () => bcommit(binp.value);
  binp.oninput  = () => { const v = parseFloat(binp.value); if (v >= 1) window.__heal.buffEvery = v; };
  document.getElementById('buffDec').onclick = () => bcommit((window.__heal.buffEvery||180) - 10);
  document.getElementById('buffInc').onclick = () => bcommit((window.__heal.buffEvery||180) + 10);
  const b2inp = document.getElementById('buff2Every');
  b2inp.value = window.__heal.buff2Every;
  const b2commit = v => { v = Math.max(1, Math.round(parseFloat(v)||1)); window.__heal.buff2Every = v; b2inp.value = v; };
  b2inp.onchange = () => b2commit(b2inp.value);
  b2inp.oninput  = () => { const v = parseFloat(b2inp.value); if (v >= 1) window.__heal.buff2Every = v; };
  document.getElementById('buff2Dec').onclick = () => b2commit((window.__heal.buff2Every||50) - 5);
  document.getElementById('buff2Inc').onclick = () => b2commit((window.__heal.buff2Every||50) + 5);
  const sbinp = document.getElementById('selfbuffEvery');
  sbinp.value = window.__heal.selfbuffEvery;
  const sbcommit = v => { v = Math.max(15, Math.round(parseFloat(v)||15)); window.__heal.selfbuffEvery = v; sbinp.value = v; };
  sbinp.onchange = () => sbcommit(sbinp.value);
  sbinp.oninput  = () => { const v = parseFloat(sbinp.value); if (v >= 15) window.__heal.selfbuffEvery = v; };
  document.getElementById('sbufDec').onclick = () => sbcommit((window.__heal.selfbuffEvery||180) - 10);
  document.getElementById('sbufInc').onclick = () => sbcommit((window.__heal.selfbuffEvery||180) + 10);
  const mpainp = document.getElementById('mpAtPct');
  mpainp.value = window.__heal.mpAtPct;
  const mpacommit = v => { v = Math.max(0, Math.min(95, Math.round((parseFloat(v)||0)/5)*5)); window.__heal.mpAtPct = v; mpainp.value = v; };
  mpainp.onchange = () => mpacommit(mpainp.value);
  mpainp.oninput  = () => { const v = parseFloat(mpainp.value); if (v >= 0) window.__heal.mpAtPct = v; };
  document.getElementById('mpAtDec').onclick = () => mpacommit((window.__heal.mpAtPct||0) - 5);
  document.getElementById('mpAtInc').onclick = () => mpacommit((window.__heal.mpAtPct||0) + 5);
  const foinp = document.getElementById('foodAtPct');
  foinp.value = window.__heal.foodAtPct;
  const focommit = v => { v = Math.max(0, Math.min(95, Math.round((parseFloat(v)||0)/5)*5)); window.__heal.foodAtPct = v; foinp.value = v; };
  foinp.onchange = () => focommit(foinp.value);
  foinp.oninput  = () => { const v = parseFloat(foinp.value); if (v >= 0) window.__heal.foodAtPct = v; };
  document.getElementById('foodAtDec').onclick = () => focommit((window.__heal.foodAtPct||0) - 5);
  document.getElementById('foodAtInc').onclick = () => focommit((window.__heal.foodAtPct||0) + 5);
  const hitinp = document.getElementById('healIfTargetPct');
  hitinp.value = window.__heal.healIfTargetPct;
  const hitcommit = v => { v = Math.max(1, Math.min(100, Math.round((parseFloat(v)||90)/5)*5)); window.__heal.healIfTargetPct = v; hitinp.value = v; };
  hitinp.onchange = () => hitcommit(hitinp.value);
  hitinp.oninput  = () => { const v = parseFloat(hitinp.value); if (v >= 1) window.__heal.healIfTargetPct = v; };
  document.getElementById('hitDec').onclick = () => hitcommit((window.__heal.healIfTargetPct||90) - 5);
  document.getElementById('hitInc').onclick = () => hitcommit((window.__heal.healIfTargetPct||90) + 5);
  const cbHit = document.getElementById('healIfTarget');
  cbHit.checked = !!window.__heal.healIfTarget;
  cbHit.onchange = () => { window.__heal.healIfTarget = cbHit.checked; };
  const sinp = document.getElementById('buffStepMs');
  sinp.value = window.__heal.buffStepMs;
  const scommit = v => { v = Math.max(0, Math.round((parseFloat(v)||0)/10)*10); window.__heal.buffStepMs = v; sinp.value = v; };
  sinp.onchange = () => scommit(sinp.value);
  sinp.oninput  = () => { const v = parseFloat(sinp.value); if (v >= 0) window.__heal.buffStepMs = v; };
  document.getElementById('bstepDec').onclick = () => scommit((window.__heal.buffStepMs||1300) - 50);
  document.getElementById('bstepInc').onclick = () => scommit((window.__heal.buffStepMs||1300) + 50);
  const cbFlee = document.getElementById('fleeEnabled');
  cbFlee.checked = !!window.__heal.fleeEnabled;
  cbFlee.onchange = () => { window.__heal.fleeEnabled = cbFlee.checked; };
  const fsinp = document.getElementById('fleeSecs');
  fsinp.value = window.__heal.fleeSecs;
  const fscommit = v => { v = Math.max(0.5, Math.round((parseFloat(v)||3)*2)/2); window.__heal.fleeSecs = v; fsinp.value = v; };
  fsinp.onchange = () => fscommit(fsinp.value);
  fsinp.oninput  = () => { const v = parseFloat(fsinp.value); if (v >= 0.5) window.__heal.fleeSecs = v; };
  document.getElementById('fleeDec').onclick = () => fscommit((window.__heal.fleeSecs||3) - 0.5);
  document.getElementById('fleeInc').onclick = () => fscommit((window.__heal.fleeSecs||3) + 0.5);
  const cbMouse = document.getElementById('mouseEnabled');
  cbMouse.checked = window.__heal.mouseEnabled !== false;
  cbMouse.onchange = () => { window.__heal.mouseEnabled = cbMouse.checked; };
  const cbLook = document.getElementById('lookEnabled');
  cbLook.checked = !!window.__heal.lookEnabled;
  cbLook.onchange = () => { window.__heal.lookEnabled = cbLook.checked; };
  const cbChat = document.getElementById('chatEnabled');
  cbChat.checked = !!window.__heal.chatEnabled;
  cbChat.onchange = () => { window.__heal.chatEnabled = cbChat.checked; };
  const cbMap = document.getElementById('mapEnabled');
  cbMap.checked = !!window.__heal.mapEnabled;
  cbMap.onchange = () => { window.__heal.mapEnabled = cbMap.checked; };
  const cbAnn = document.getElementById('announceBuff');
  cbAnn.checked = !!window.__heal.announceBuff;
  cbAnn.onchange = () => { window.__heal.announceBuff = cbAnn.checked; };
  const cbSelf = document.getElementById('selfbuff');
  cbSelf.checked = !!window.__heal.selfbuff;
  cbSelf.onchange = () => { window.__heal.selfbuff = cbSelf.checked; };
  // 🎁/🧙 buff slot grids — two rows (F2, F3, slots 2–9) each; a slot is ON unless explicitly false.
  // Default (matches Aaron's saved layout): slot 2 OFF on both pages, F3 7–9 OFF → F2 3-9 + F3 3-6.
  if (!window.__heal.buffSlots) window.__heal.buffSlots = {F2:{'2':false}, F3:{'2':false,'7':false,'8':false,'9':false}};
  if (!window.__heal.selfbuffSlots) window.__heal.selfbuffSlots = {F2:{'2':false}, F3:{'2':false,'7':false,'8':false,'9':false}};
  const renderHealSlots = (boxId, cfg) => {
    const box = document.getElementById(boxId); if (!box) return;
    const icons = window.__buffIcons || {};
    const on = (pg, n) => cfg[pg] ? cfg[pg][n] !== false : true;
    const btn = (pg, n) => { const a = on(pg, n); const ic = (icons[pg]||{})[''+n];
      const bg = ic ? ('background-image:url('+ic+');background-size:cover;background-position:center;') : 'background-color:'+(a?'#1a2436':'#141420')+';';
      return '<button data-pg="'+pg+'" data-n="'+n+'" title="'+pg+' slot '+n+(a?' — casting':' — skipped')+'" '
        +'style="position:relative;width:34px;height:34px;padding:0;border-radius:5px;cursor:pointer;'+bg
        +'border:2px solid '+(a?'#3a8ad8':'#33384d')+';filter:'+(a?'none':'grayscale(1) brightness(0.55)')+';opacity:'+(a?'1':'0.6')+'">'
        +'<span style="position:absolute;top:-1px;left:1px;font-size:9px;font-weight:bold;color:#fff;text-shadow:0 0 2px #000,0 0 3px #000">'+n+'</span>'
        +(a?'':'<span style="position:absolute;bottom:-2px;right:0;font-size:11px;color:#f66;text-shadow:0 0 2px #000">✕</span>')
        +'</button>'; };
    let h = '';
    for (const pg of ['F2','F3']) { h += '<div style="display:flex;gap:4px;align-items:center;margin-top:3px"><span class="dim" style="width:24px">'+pg+'</span>';
      for (let n = 2; n <= 9; n++) h += btn(pg, n); h += '</div>'; }
    box.innerHTML = h;
    box.querySelectorAll('button[data-pg]').forEach(b => { b.onclick = () => { const pg = b.dataset.pg, n = b.dataset.n;
      if (!cfg[pg]) cfg[pg] = {}; cfg[pg][n] = (cfg[pg][n] === false); renderHealSlots(boxId, cfg); }; });
  };
  renderHealSlots('healBuffSlots', window.__heal.buffSlots);
  renderHealSlots('healSelfSlots', window.__heal.selfbuffSlots);
  const cbSH = document.getElementById('selfheal');
  cbSH.checked = window.__heal.selfheal !== false;
  cbSH.onchange = () => { window.__heal.selfheal = cbSH.checked; };
  const shinp = document.getElementById('selfhealPct');
  shinp.value = window.__heal.selfhealPct;
  const shcommit = v => { v = Math.max(1, Math.min(100, Math.round(parseFloat(v)||70))); window.__heal.selfhealPct = v; shinp.value = v; };
  shinp.onchange = () => shcommit(shinp.value);
  shinp.oninput  = () => { const v = parseFloat(shinp.value); if (v >= 1 && v <= 100) window.__heal.selfhealPct = v; };
  document.getElementById('shpDec').onclick = () => shcommit((window.__heal.selfhealPct||70) - 5);
  document.getElementById('shpInc').onclick = () => shcommit((window.__heal.selfhealPct||70) + 5);
  // 💾 Save settings — persist window.__heal so it loads next launch (handled by the supervisor)
  const saveBtn = document.getElementById('healSaveBtn');
  if (saveBtn) saveBtn.onclick = () => { window.__healSaveReq = Date.now(); const m = document.getElementById('healSaveMsg'); if (m) m.textContent = 'saving…'; };
  window.__healSaveMsg = (t) => { const m = document.getElementById('healSaveMsg'); if (m) m.textContent = t; };
  const minp = document.getElementById('mapEvery');
  minp.value = window.__heal.mapEvery;
  const mcommit = v => { v = Math.max(10, Math.round((parseFloat(v)||180)/10)*10); window.__heal.mapEvery = v; minp.value = v; };
  minp.onchange = () => mcommit(minp.value);
  minp.oninput  = () => { const v = parseFloat(minp.value); if (v >= 10) window.__heal.mapEvery = v; };
  document.getElementById('mapDec').onclick = () => mcommit((window.__heal.mapEvery||180) - 10);
  document.getElementById('mapInc').onclick = () => mcommit((window.__heal.mapEvery||180) + 10);
  const lkinp = document.getElementById('lookEvery');
  lkinp.value = window.__heal.lookEvery;
  const lkcommit = v => { v = Math.max(3, Math.round(parseFloat(v)||18)); window.__heal.lookEvery = v; lkinp.value = v; };
  lkinp.onchange = () => lkcommit(lkinp.value);
  lkinp.oninput  = () => { const v = parseFloat(lkinp.value); if (v >= 3) window.__heal.lookEvery = v; };
  document.getElementById('lookDec').onclick = () => lkcommit((window.__heal.lookEvery||18) - 1);
  document.getElementById('lookInc').onclick = () => lkcommit((window.__heal.lookEvery||18) + 1);
  const cbFol = document.getElementById('followEnabled');
  cbFol.checked = !!window.__heal.followEnabled;
  cbFol.onchange = () => { window.__heal.followEnabled = cbFol.checked; };
  const folinp = document.getElementById('followEvery');
  folinp.value = window.__heal.followEvery;
  const folcommit = v => { v = Math.max(1, Math.round(parseFloat(v)||5)); window.__heal.followEvery = v; folinp.value = v; };
  folinp.onchange = () => folcommit(folinp.value);
  folinp.oninput  = () => { const v = parseFloat(folinp.value); if (v >= 1) window.__heal.followEvery = v; };
  document.getElementById('folDec').onclick = () => folcommit((window.__heal.followEvery||5) - 1);
  document.getElementById('folInc').onclick = () => folcommit((window.__heal.followEvery||5) + 1);
  const cbJmp = document.getElementById('jumpStuck');
  cbJmp.checked = window.__heal.jumpStuck !== false;
  cbJmp.onchange = () => { window.__heal.jumpStuck = cbJmp.checked; };
  const jmpinp = document.getElementById('jumpStuckSecs');
  jmpinp.value = window.__heal.jumpStuckSecs;
  const jmpcommit = v => { v = Math.max(1, Math.round(parseFloat(v)||3)); window.__heal.jumpStuckSecs = v; jmpinp.value = v; };
  jmpinp.onchange = () => jmpcommit(jmpinp.value);
  jmpinp.oninput  = () => { const v = parseFloat(jmpinp.value); if (v >= 1) window.__heal.jumpStuckSecs = v; };
  document.getElementById('jmpDec').onclick = () => jmpcommit((window.__heal.jumpStuckSecs||3) - 1);
  document.getElementById('jmpInc').onclick = () => jmpcommit((window.__heal.jumpStuckSecs||3) + 1);
  // grind search param fields → window.__grind
  const gWire = (id, key, min, step, def) => {
    const el = document.getElementById(id);
    el.value = window.__grind[key];
    const set = v => { v = Math.max(min, Math.round((parseFloat(v)||def)/step)*step); window.__grind[key] = v; el.value = v; };
    el.onchange = () => set(el.value);
    el.oninput  = () => { const v = parseFloat(el.value); if (v >= min) window.__grind[key] = v; };
    return set;
  };
  const sHealAt = gWire('gHealAt', 'healAtPct', 5, 5, 50);
  document.getElementById('haDec').onclick = () => sHealAt((window.__grind.healAtPct||50) - 5);
  document.getElementById('haInc').onclick = () => sHealAt((window.__grind.healAtPct||50) + 5);
  const sRot = gWire('gRotDx', 'rotateDx', 20, 10, 320);
  document.getElementById('rotDec').onclick = () => sRot((window.__grind.rotateDx||320) - 10);
  document.getElementById('rotInc').onclick = () => sRot((window.__grind.rotateDx||320) + 10);
  const sTurns = gWire('gTurns', 'turnsBeforeWalk', 1, 1, 3);
  document.getElementById('turnsDec').onclick = () => sTurns((window.__grind.turnsBeforeWalk||3) - 1);
  document.getElementById('turnsInc').onclick = () => sTurns((window.__grind.turnsBeforeWalk||3) + 1);
  const sWpct = gWire('gWalkPct', 'walkPct', 1, 1, 22);
  document.getElementById('wpctDec').onclick = () => sWpct((window.__grind.walkPct||22) - 1);
  document.getElementById('wpctInc').onclick = () => sWpct((window.__grind.walkPct||22) + 1);
  const sWms = gWire('gWalkMs', 'walkTimeMs', 0, 100, 1200);
  document.getElementById('wmsDec').onclick = () => sWms((window.__grind.walkTimeMs||1200) - 100);
  document.getElementById('wmsInc').onclick = () => sWms((window.__grind.walkTimeMs||1200) + 100);
  const sTilt = gWire('gTilt', 'tiltBackUp', 0, 10, 120);
  document.getElementById('tiltDec').onclick = () => sTilt((window.__grind.tiltBackUp||0) - 10);
  document.getElementById('tiltInc').onclick = () => sTilt((window.__grind.tiltBackUp||0) + 10);
  const sSelfBuff = gWire('gSelfBuff', 'selfBuffEvery', 5, 5, 50);
  document.getElementById('sbDec').onclick = () => sSelfBuff((window.__grind.selfBuffEvery||50) - 5);
  document.getElementById('sbInc').onclick = () => sSelfBuff((window.__grind.selfBuffEvery||50) + 5);
  const sNoDmg = gWire('gNoDmg', 'noDamageSecs', 1, 1, 5);
  document.getElementById('ndDec').onclick = () => sNoDmg((window.__grind.noDamageSecs||5) - 1);
  document.getElementById('ndInc').onclick = () => sNoDmg((window.__grind.noDamageSecs||5) + 1);
  const sFightPoll = gWire('gFightPoll', 'fightPollMs', 20, 10, 100);
  document.getElementById('fpDec').onclick = () => sFightPoll((window.__grind.fightPollMs||100) - 10);
  document.getElementById('fpInc').onclick = () => sFightPoll((window.__grind.fightPollMs||100) + 10);
  const sDeathN = gWire('gDeathN', 'deathConfirm', 1, 1, 3);
  document.getElementById('dnDec').onclick = () => sDeathN((window.__grind.deathConfirm||3) - 1);
  document.getElementById('dnInc').onclick = () => sDeathN((window.__grind.deathConfirm||3) + 1);
  const sEngageSettle = gWire('gEngageSettle', 'engageSettleMs', 50, 50, 350);
  document.getElementById('esDec').onclick = () => sEngageSettle((window.__grind.engageSettleMs||350) - 50);
  document.getElementById('esInc').onclick = () => sEngageSettle((window.__grind.engageSettleMs||350) + 50);
  const sSelectGap = gWire('gSelectGap', 'selectGapMs', 50, 10, 150);
  document.getElementById('sgDec').onclick = () => sSelectGap((window.__grind.selectGapMs||150) - 10);
  document.getElementById('sgInc').onclick = () => sSelectGap((window.__grind.selectGapMs||150) + 10);
  const selDet = document.getElementById('gDetectMode');
  const detHints = {nameplate: 'Sees the whole frame at once via mob name-tag colour — fast.',
                    cursor: 'Aggro/RED via colour first, then hover-scans outward reading the game cursor to find normal mobs colour misses. Slower but catches more.',
                    hybrid: 'Nameplate colour (fast); if it finds nothing for 4s, one cursor hover-scan catches what colour missed. Best of both.',
                    hybrid2: 'Smartest: fuses memory-map positions + nameplate pixels + frame-diff motion into ranked candidates, then verifies each with the game cursor. Also pre-scans the next mob while fighting.',
                    hybrid3: 'Experimental: fuses every source into a hit-PROBABILITY field (each shaped by its uncertainty) and probes it in mathematically-optimal max-probability order. Same fight pre-scan. Try it vs Hybrid 2.'};
  const _detHint = () => { const e = document.getElementById('detectModeHint'); if (e) e.textContent = detHints[selDet.value] || ''; };
  selDet.value = window.__grind.detectMode || 'hybrid2';
  selDet.onchange = () => { window.__grind.detectMode = selDet.value; _detHint(); };
  _detHint();
  const selMob = document.getElementById('gMobTarget');
  const mobHints = {memory: 'Reads the SELECTED target (alive/HP/kind) from WASM memory — exact, no flicker, so a kill registers instantly. Fight poll + death-confirm are ignored.',
                    pixel: 'Reads the top-centre target frame + HP bar from pixels; needs the Fight poll × Death-confirm window to survive bar flicker. Slower.'};
  const _mobHint = () => { const e = document.getElementById('mobTargetHint'); if (e) e.textContent = mobHints[selMob.value] || ''; };
  selMob.value = window.__grind.mobTarget || 'memory';
  selMob.onchange = () => { window.__grind.mobTarget = selMob.value; _mobHint(); };
  _mobHint();
  const cbAvG = document.getElementById('gAvoidGiant');
  cbAvG.checked = !!window.__grind.avoidGiant;
  cbAvG.onchange = () => { window.__grind.avoidGiant = cbAvG.checked; };
  const cbAvS = document.getElementById('gAvoidSkip');
  cbAvS.checked = !!window.__grind.avoidSkip;
  cbAvS.onchange = () => { window.__grind.avoidSkip = cbAvS.checked; };
  // ── 🎯 Nearby mob types: tick = grind, untick = avoid (stored in window.__grind.mobSel by species#) ──
  const cbScanT = document.getElementById('gScanMobType');
  cbScanT.checked = !!window.__grind.scanMobType;
  cbScanT.onchange = () => { window.__grind.scanMobType = cbScanT.checked; };
  const cbSteer = document.getElementById('gSteerToMobs');
  cbSteer.checked = !!window.__grind.steerToMobs;
  cbSteer.onchange = () => { window.__grind.steerToMobs = cbSteer.checked; };
  const cbHViz = document.getElementById('gHoverViz');
  cbHViz.checked = !!window.__grind.hoverViz;
  cbHViz.onchange = () => { window.__grind.hoverViz = cbHViz.checked; };
  window.__grindTypesSet = (types) => {
    const box = document.getElementById('grindTypesList');
    const st = document.getElementById('grindTypesStat');
    if (!box) return;
    types = types || [];
    if (st) st.textContent = '— ' + types.length + ' type' + (types.length === 1 ? '' : 's') + ' nearby';
    if (!types.length) { box.innerHTML = '<span class="dim">no monsters nearby (stand near some, bot stopped).</span>'; return; }
    const sel = window.__grind.mobSel || (window.__grind.mobSel = {});
    let h = '';
    for (const t of types) {                       // t = [species#, name, level, count, nearestDist]
      const sp = String(t[0]);
      if (sel[sp] === undefined) sel[sp] = true;    // new type defaults to ticked (grind)
      const nm = t[1] || ('#' + sp), lv = t[2] ? (' Lv' + t[2]) : '', cnt = t[3], d = t[4];
      h += '<label class="chk" style="display:flex;gap:6px;padding:1px 0"><input type="checkbox" data-sp="' + sp + '"'
        + (sel[sp] ? ' checked' : '') + '> <span style="flex:1">' + nm + '<span class="dim">' + lv
        + ' · ×' + cnt + ' · nearest ' + d + 'u</span></span></label>';
    }
    box.innerHTML = h;
    box.querySelectorAll('input[data-sp]').forEach(i => {
      i.onchange = () => { window.__grind.mobSel[i.dataset.sp] = i.checked; };
    });
  };
  // ── 🤖 Grind RE sub-panel wiring (config → window.__grindRe) ──
  window.__grindRe = {risk:false, mobSel:{}, avoidGiant:true, skipBusy:true, look:false, mouse:false, smart:true, clusterR:30, obstacles:false, obsMargin:5, brush:8, giantHazard:22, fightPollMs:100,
    skillDelay:10, foodPct:50, buffEvery:61, buffSlots:{'2':true,'3':false,'4':false,'5':false,'6':false,'7':false,'8':false}};
  (function(){
    const gre = window.__grindRe;
    const numf = (inpId, decId, incId, key, step, min) => {
      const inp=document.getElementById(inpId); if(!inp) return;
      inp.value=gre[key];
      const set=v=>{ v=Math.max(min, Math.round(parseFloat(v)||min)); gre[key]=v; inp.value=v; };
      inp.onchange=()=>set(inp.value); inp.oninput=()=>{const v=parseFloat(inp.value); if(!isNaN(v))gre[key]=v;};
      const d=document.getElementById(decId), i=document.getElementById(incId);
      if(d) d.onclick=()=>set((gre[key]||0)-step); if(i) i.onclick=()=>set((gre[key]||0)+step);
    };
    numf('greSkillDelay','greSdDec','greSdInc','skillDelay',5,0);
    numf('greFightPoll','greFpDec','greFpInc','fightPollMs',10,30);
    numf('greFoodPct','greFoodDec','greFoodInc','foodPct',5,0);
    numf('greBuffEvery','greBuffDec','greBuffInc','buffEvery',5,5);
    numf('greClusterR','greCrDec','greCrInc','clusterR',5,5);
    numf('greGiantHaz','greGhDec','greGhInc','giantHazard',1,0);
    const chk=(id,key,cb)=>{ const c=document.getElementById(id); if(!c)return; c.checked=!!gre[key];
      c.onchange=()=>{ gre[key]=c.checked; if(cb)cb(); }; };
    chk('greRisk','risk',()=>{ if(!gre.risk) window.__bot={cmd:'stop',mode:'grind_re',gen:Date.now()}; });   // untick = stop
    chk('greAvoidGiant','avoidGiant'); chk('greSkipBusy','skipBusy'); chk('greSmart','smart'); chk('greObstacles','obstacles'); chk('greLook','look'); chk('greMouse','mouse');
    numf('greBrush', null, null, 'brush', 1, 1);          // paintbrush thickness (world u)
    numf('greObsMargin', null, null, 'obsMargin', 1, 0);  // keep-away margin around obstacles (world u)
    // ── map zoom + 🖌️ paint / 🧹 erase manual obstacle regions ──
    if(window.__greZoom===undefined) window.__greZoom=1.0;
    if(!window.__grePaint) window.__grePaint=[];
    if(window.__greTool===undefined) window.__greTool='';            // '' | 'paint' | 'erase' (mutually exclusive)
    const greRerender=()=>{ if(window.__greLastMap && window.__greMiniSet) window.__greMiniSet(window.__greLastMap); };
    window.__greRerender=greRerender;                    // server calls this after loading a saved map
    const zi=document.getElementById('greZoomIn'), zo=document.getElementById('greZoomOut');
    if(zi) zi.onclick=()=>{ window.__greZoom=Math.min(6,window.__greZoom*1.3); greRerender(); };
    if(zo) zo.onclick=()=>{ window.__greZoom=Math.max(0.3,window.__greZoom/1.3); greRerender(); };
    const bb=document.getElementById('greBrushBtn'), eb=document.getElementById('greEraseBtn');
    const mapDivT=document.getElementById('greMiniMap');
    const toolStyle=()=>{ const t=window.__greTool;
      if(bb){ const on=t==='paint'; bb.style.background=on?'#6a4410':'#2a1a0a'; bb.style.color=on?'#ffe':'#fb8'; }
      if(eb){ const on=t==='erase'; eb.style.background=on?'#106a4a':'#0a2a1e'; eb.style.color=on?'#eff':'#8fd'; }
      if(mapDivT) mapDivT.style.cursor=(t==='paint'||t==='erase')?'crosshair':''; };
    if(bb) bb.onclick=()=>{ window.__greTool=(window.__greTool==='paint'?'':'paint'); toolStyle(); };
    if(eb) eb.onclick=()=>{ window.__greTool=(window.__greTool==='erase'?'':'erase'); toolStyle(); };
    toolStyle();
    const cpB=document.getElementById('greClearPaint');
    if(cpB) cpB.onclick=()=>{ window.__grePaint=[]; greRerender(); };
    // 💾 save / 📂 load area maps (obstacles + painted no-go zones), named server-side by the most common mob
    const smB=document.getElementById('greSaveMap');
    if(smB) smB.onclick=()=>{ window.__greMapAct={gen:Date.now(),op:'save',paint:window.__grePaint||[]}; };
    window.__greMapMsg=(m)=>{ const el=document.getElementById('greMapLoadOffer'); if(!el)return;
      el.style.display=''; el.innerHTML='<span class="dim">'+m+'</span>'; el.__forBase='__msg'; el.__msgUntil=Date.now()+2500; };
    window.__greMapOffer=(base,exists)=>{ const el=document.getElementById('greMapLoadOffer'); if(!el)return;
      if(el.__forBase==='__msg' && el.__msgUntil>Date.now()) return;               // let a fresh save/load message linger
      const show = exists && base && window.__greMapLoaded!==base && window.__greMapDismissed!==base;
      if(!show){ el.style.display='none'; el.__forBase=null; return; }
      if(el.__forBase===base){ el.style.display=''; return; }                      // already built for this base
      el.__forBase=base; el.style.display='';
      el.innerHTML='📂 saved map for <b>'+base+'</b> found — '
        +'<button id="greMapLoadBtn" style="padding:1px 8px;background:#0f2a1a;color:#8fe;border:1px solid #2a6a4a;border-radius:4px;cursor:pointer">load</button> '
        +'<button id="greMapDismiss" style="padding:1px 6px;background:#222;color:#889;border:1px solid #445;border-radius:4px;cursor:pointer" title="keep a fresh map">✕</button>';
      const lb=document.getElementById('greMapLoadBtn'); if(lb) lb.onclick=()=>{ window.__greMapAct={gen:Date.now(),op:'load',base:base}; };
      const db=document.getElementById('greMapDismiss'); if(db) db.onclick=()=>{ window.__greMapDismissed=base; el.style.display='none'; el.__forBase=null; };
    };
    const mapDiv=document.getElementById('greMiniMap');
    if(mapDiv && !mapDiv.__paintBound){ mapDiv.__paintBound=true; let painting=false;
      const evWorld=(ev)=>{ const pr=window.__greMapProj; if(!pr)return null; const rect=mapDiv.getBoundingClientRect();
        return [pr.px+((ev.clientX-rect.left)-pr.C)/pr.sc, pr.pz+((ev.clientY-rect.top)-pr.C)/pr.sc]; };
      const addAt=(ev)=>{ const w=evWorld(ev); if(!w)return; const br=(gre.brush||8);
        for(const p of window.__grePaint){ if(Math.hypot(p[0]-w[0],p[1]-w[1])<br*0.5) return; }   // dedupe overlapping dabs
        window.__grePaint.push([w[0],w[1],br]); greRerender(); };
      const eraseAt=(ev)=>{ const w=evWorld(ev); if(!w)return; const br=(gre.brush||8), before=window.__grePaint.length;
        // remove any painted zone the brush touches (cursor within the zone, or the zone within the brush radius)
        window.__grePaint=window.__grePaint.filter(p=>Math.hypot(p[0]-w[0],p[1]-w[1]) > Math.max(p[2], br*0.5));
        if(window.__grePaint.length!==before) greRerender(); };
      const applyAt=(ev)=>{ if(window.__greTool==='paint') addAt(ev); else if(window.__greTool==='erase') eraseAt(ev); };
      mapDiv.addEventListener('mousedown',(ev)=>{ if(window.__greTool!=='paint'&&window.__greTool!=='erase')return; ev.preventDefault(); painting=true; applyAt(ev); });
      mapDiv.addEventListener('mousemove',(ev)=>{ if(painting) applyAt(ev); });
      window.addEventListener('mouseup',()=>{ painting=false; });
    }
    // F1 skill-slot row: 1=food (fixed), 2–8 = toggle self-buffs, 9 = unused, 0 = attack (fixed)
    const slotBox=document.getElementById('greSlots');
    const renderSlots=()=>{ if(!slotBox) return; let h='';
      const btn=(n,label,bg,fg,br,toggle)=>'<button data-slot="'+n+'"'+(toggle?'':' disabled')
        +' style="min-width:36px;padding:4px 2px;font-size:12px;border-radius:4px;cursor:'+(toggle?'pointer':'default')
        +';background:'+bg+';color:'+fg+';border:1px solid '+br+'">'+label+'</button>';
      for(const n of [1,2,3,4,5,6,7,8,9,0]){
        if(n===1) h+=btn(1,'1 🍖','#16311c','#8f8','#2a6a3a',false);
        else if(n===0) h+=btn(0,'0 ⚔','#311616','#f99','#6a2a2a',false);
        else if(n===9) h+=btn(9,'9','#181822','#667','#2a2a3a',false);
        else { const a=gre.buffSlots[''+n]!==false; h+=btn(n,''+n,a?'#16283a':'#141420',a?'#9cf':'#556',a?'#2a4a6a':'#2a2a3a',true); }
      }
      slotBox.innerHTML=h;
      slotBox.querySelectorAll('button[data-slot]:not([disabled])').forEach(b=>{
        b.onclick=()=>{ const n=''+b.dataset.slot; gre.buffSlots[n]=(gre.buffSlots[n]===false); renderSlots(); }; });
    };
    renderSlots();
    window.__greTypesSet=(types)=>{ const box=document.getElementById('greTypesList'), st=document.getElementById('greTypesStat');
      if(!box) return; types=types||[];
      if(st) st.textContent='— '+types.length+' type'+(types.length===1?'':'s')+' nearby';
      if(!types.length){ box.innerHTML='<span class="dim">no monsters nearby (stand near some, bot stopped).</span>'; return; }
      const sel=gre.mobSel; let h='';
      // While Grind RE is RUNNING the target set is LOCKED — a NEW type defaults to UNticked (not grinded).
      // When stopped/configuring, a new type defaults to ticked (grind what's around).
      const greRunning = window.__bot && window.__bot.cmd==='start' && window.__bot.mode==='grind_re';
      for(const t of types){ const sp=String(t[0]); if(sel[sp]===undefined) sel[sp]=!greRunning;
        const nm=t[1]||('#'+sp), lv=t[2]?(' Lv'+t[2]):'', cnt=t[3], d=t[4], hp=t[5]||0, giant=t[6];
        const hpS = hp>=1000000 ? (hp/1e6).toFixed(1)+'M' : (hp>=1000 ? Math.round(hp/1000)+'k' : hp);
        const gtag = giant ? ' <b style="color:#ff6a6a">⚠️GIANT</b>' : '';
        h+='<label class="chk" style="display:flex;gap:6px;padding:1px 0"><input type="checkbox" data-sp="'+sp+'"'
          +(sel[sp]?' checked':'')+'> <span style="flex:1">'+nm+gtag+'<span class="dim">'+lv+' · ×'+cnt+' · '+hpS+' HP · nearest '+d+'u</span></span></label>';
      }
      box.innerHTML=h;
      box.querySelectorAll('input[data-sp]').forEach(i=>{ i.onchange=()=>{ sel[i.dataset.sp]=i.checked; }; });
    };
    window.__greMiniSet=(data)=>{
      const mb=document.getElementById('greMiniMap'), st=document.getElementById('greMiniStat'), sd=document.getElementById('greMapStats');
      const rows=(data&&data.mobs)||[]; const px=(data&&data.px)||0, pz=(data&&data.pz)||0;
      if(st) st.textContent='— '+rows.length+' nearby';
      if(!mb) return;
      window.__greLastMap=data;                             // remember for zoom / paint re-renders (frame always draws)
      const S=360, C=S/2, PAD=22, sf=S/300;                 // bigger map; sf keeps markers a sensible size
      const st2=window.__greSmartStats||{}, smart=!!gre.smart, R=Math.max(5,gre.clusterR||30);
      const ws=(st2.ws>0?st2.ws:12), ks=(st2.ks>0?st2.ks:6);
      const wanted=gre.mobSel?Object.keys(gre.mobSel).filter(k=>gre.mobSel[k]):null;
      const curated=gre.mobSel&&Object.values(gre.mobSel).some(v=>v===false);
      const keep=(r)=>{                                    // matches run_session's _keep (avoid-giant FIRST)
        if(gre.avoidGiant&&r.length>12&&r[12]===1) return false;
        if((r.length>11&&r[11]===1)||r[8]===2) return true;
        if(curated&&wanted.indexOf(String(r[1]))<0) return false;
        if(gre.skipBusy&&r[8]===1) return false;
        return true; };
      let maxr=25; for(const r of rows) maxr=Math.max(maxr,Math.abs(r[3]-px),Math.abs(r[5]-pz));
      maxr=Math.max(8, Math.ceil(maxr*1.12)/(window.__greZoom||1));   // ± zoom shrinks/grows the view radius
      const sc=(C-PAD)/maxr, X=(wx)=>C+(wx-px)*sc, Y=(wz)=>C+(wz-pz)*sc;
      window.__greMapProj={px:px,pz:pz,sc:sc,C:C};          // screen→world for the paintbrush
      let g='<svg width="'+S+'" height="'+S+'" style="background:#0b0d18;border:1px solid #33384d;border-radius:7px">';
      for(const f of [0.34,0.67,1.0]){ const rr=(C-PAD)*f;
        g+='<circle cx="'+C+'" cy="'+C+'" r="'+rr.toFixed(1)+'" fill="none" stroke="#20263a"/>';
        g+='<text x="'+(C+4)+'" y="'+(C-rr+11).toFixed(1)+'" fill="#667" font-size="10">'+Math.round(maxr*f)+'u</text>'; }
      g+='<line x1="'+C+'" y1="'+PAD+'" x2="'+C+'" y2="'+(S-PAD)+'" stroke="#171b2b"/>';
      g+='<line x1="'+PAD+'" y1="'+C+'" x2="'+(S-PAD)+'" y2="'+C+'" stroke="#171b2b"/>';
      // 🧱 obstacle cells — red hue (opacity ∝ confidence). Drawn under the mobs/path.
      const obs=(data&&data.obstacles)||[], oc=(data&&data.obsCell)||4, ow=Math.max(3,oc*sc);
      for(const o of obs){ const ox=X(o[0]), oy=Y(o[1]);
        g+='<rect x="'+(ox-ow/2).toFixed(1)+'" y="'+(oy-ow/2).toFixed(1)+'" width="'+ow.toFixed(1)+'" height="'+ow.toFixed(1)+'" fill="#ff2a2a" fill-opacity="'+Math.min(0.55,0.14+o[2]*0.14).toFixed(2)+'" rx="1"><title>obstacle ~'+o[2]+'</title></rect>'; }
      // 🖌️ PAINTED obstacle zones (orange) + their keep-away margin ring
      const paint=window.__grePaint||[], marg=(gre.obsMargin||0);
      for(const q of paint){ const qx=X(q[0]), qy=Y(q[1]), rr=q[2]*sc, mr=(q[2]+marg)*sc;
        if(marg>0) g+='<circle cx="'+qx.toFixed(1)+'" cy="'+qy.toFixed(1)+'" r="'+mr.toFixed(1)+'" fill="none" stroke="#ffa040" stroke-opacity="0.4" stroke-dasharray="'+(3*sf).toFixed(1)+','+(3*sf).toFixed(1)+'"/>';
        g+='<circle cx="'+qx.toFixed(1)+'" cy="'+qy.toFixed(1)+'" r="'+Math.max(2,rr).toFixed(1)+'" fill="#ff8c1a" fill-opacity="0.42"><title>painted obstacle (r'+q[2]+'u + '+marg+'u margin)</title></circle>'; }
      // ☢️ GIANT hazard zones — the keep-away radius around each giant mob (drawn under the mob dots)
      const ghz=(gre.giantHazard!==undefined?gre.giantHazard:22);
      if(ghz>0) for(const r of rows){ if(!(r.length>12&&r[12]===1)) continue; const gx=X(r[3]), gy=Y(r[5]), hr=ghz*sc;
        g+='<circle cx="'+gx.toFixed(1)+'" cy="'+gy.toFixed(1)+'" r="'+hr.toFixed(1)+'" fill="#ff1a1a" fill-opacity="0.1" stroke="#ff4040" stroke-opacity="0.6" stroke-width="'+(1.3*sf).toFixed(1)+'" stroke-dasharray="'+(4*sf).toFixed(1)+','+(3*sf).toFixed(1)+'"><title>GIANT hazard — keep '+ghz+'u away</title></circle>'; }
      // PLAN: simulate the loop's next picks with the SAME filters + penalties (paint / giant hazard / obstacles)
      const gAll=rows.filter(r=>gre.avoidGiant&&r.length>12&&r[12]===1);
      const pHit=(x,z)=>{ for(const q of paint){ if(Math.hypot(x-q[0],z-q[1])<=q[2]+marg) return true; } return false; };
      const gHit=(x,z)=>{ if(ghz<=0)return false; for(const q of gAll){ if(Math.hypot(x-q[3],z-q[5])<=ghz) return true; } return false; };
      const pPath=(x0,z0,x1,z1)=>{ const d=Math.hypot(x1-x0,z1-z0); if(d<1)return false; const n=Math.min(20,Math.max(2,d/2|0));
        for(let i=0;i<=n;i++){ const t=i/n; if(pHit(x0+(x1-x0)*t,z0+(z1-z0)*t)) return true; } return false; };
      const oPath=(x0,z0,x1,z1)=>{ if(!gre.obstacles||!obs.length)return 0; const d=Math.hypot(x1-x0,z1-z0); if(d<2)return 0;
        const n=Math.min(20,Math.max(2,d/2|0)); let s=0;
        for(let i=1;i<n;i++){ const t=i/n, ax=x0+(x1-x0)*t, az=z0+(z1-z0)*t;
          for(const o of obs){ if(Math.abs(o[0]-ax)<oc&&Math.abs(o[1]-az)<oc){ s+=o[2]; break; } } } return s; };
      // no-go ZONES (painted +margin, giant hazards) + a straight-line reachability test (mirrors _path_crosses)
      const zones=[]; for(const q of paint) zones.push([q[0],q[1],q[2]+marg]); if(ghz>0) for(const q of gAll) zones.push([q[3],q[5],ghz]);
      const crosses=(sx,sz,gx,gz)=>{ if(!zones.length)return false;
        const act=zones.filter(q=>Math.hypot(sx-q[0],sz-q[1])>q[2]);   // ignore zones we're standing inside (can walk out)
        if(!act.length)return false; const d=Math.hypot(gx-sx,gz-sz); if(d<1)return false;
        const n=Math.min(24,Math.max(2,d/2|0)); for(let i=1;i<=n;i++){ const t=i/n, x=sx+(gx-sx)*t, z=sz+(gz-sz)*t;
          for(const q of act){ if(Math.hypot(x-q[0],z-q[1])<=q[2]) return true; } } return false; };
      const cands=rows.filter(r=>r[2]>1 && keep(r) && !pHit(r[3],r[5]) && !gHit(r[3],r[5]));   // exclude no-go mobs
      const plan=[]; const pool=cands.slice(); let cx=px, cz=pz;
      const pen=(c)=>{ let p=1.0; if(gre.obstacles) p+=0.7*oPath(cx,cz,c[3],c[5]);
        for(const q of gAll){ const md=Math.hypot(c[3]-q[3],c[5]-q[5]), lim=ghz+12; if(md<lim) p+=(lim-md)*0.15; }
        return p; };
      const score=(c)=>{ const cost=Math.hypot(c[3]-cx,c[5]-cz)/Math.max(ws,0.5)+ks;
        let val=0; for(const o of pool) val+=Math.exp(-Math.hypot(o[3]-c[3],o[5]-c[5])/R); return val/Math.max(cost,0.2); };
      const near=(arr)=>arr.reduce((a,b)=>Math.hypot(a[3]-cx,a[5]-cz)<=Math.hypot(b[3]-cx,b[5]-cz)?a:b);
      for(let i=0;i<Math.min(8,cands.length);i++){ if(!pool.length) break;
        const reach=pool.filter(r=>!crosses(cx,cz,r[3],r[5]));   // only mobs reachable without crossing a no-go zone
        const avail=reach.length?reach:pool;                     // (fall back so the plan still draws if all are blocked)
        const threats=avail.filter(r=>(r.length>11&&r[11]===1)||r[8]===2);
        const pick= threats.length? near(threats)
          : (smart? avail.reduce((a,b)=> score(a)/pen(a)>=score(b)/pen(b)?a:b)
                  : avail.reduce((a,b)=> Math.hypot(a[3]-cx,a[5]-cz)*pen(a)<=Math.hypot(b[3]-cx,b[5]-cz)*pen(b)?a:b));
        plan.push(pick); pool.splice(pool.indexOf(pick),1); cx=pick[3]; cz=pick[5]; }
      if(plan.length){ let path='M '+C+' '+C;
        for(const p of plan) path+=' L '+X(p[3]).toFixed(1)+' '+Y(p[5]).toFixed(1);
        g+='<path d="'+path+'" fill="none" stroke="#fff" stroke-width="'+(1.4*sf).toFixed(2)+'" stroke-opacity="0.7" stroke-dasharray="'+(4*sf).toFixed(1)+','+(3*sf).toFixed(1)+'"/>';
        plan.forEach((p,i)=>{ if(i<6) g+='<text x="'+(X(p[3])+5*sf).toFixed(1)+'" y="'+(Y(p[5])-5*sf).toFixed(1)+'" fill="#dff" font-size="'+(10*sf).toFixed(1)+'" font-weight="bold">'+(i+1)+'</text>'; }); }
      for(const r of rows){ const mx=X(r[3]).toFixed(1), my=Y(r[5]).toFixed(1), s=r[8];
        const giant=r.length>12&&r[12]===1;
        const c= giant? '#c489ff' : (r[0]<15?'#ffe14a':(r[0]<40?'#ffb454':'#6aa0ff'));
        const tip='<title>#'+r[1]+'  '+r[2]+' HP  dist '+r[0]+(giant?'  GIANT':'')+(s===2?'  ATTACKING YOU':(s===1?'  in combat':''))+'</title>';
        const rad= giant? 4.4*sf : 2.9*sf;
        if(s===2){ g+='<circle cx="'+mx+'" cy="'+my+'" r="'+(6.5*sf).toFixed(1)+'" fill="none" stroke="#ff1a1a" stroke-width="'+(2.2*sf).toFixed(2)+'"><animate attributeName="stroke-opacity" values="1;0.2;1" dur="0.8s" repeatCount="indefinite"/></circle>'; }
        else if(s===1){ g+='<circle cx="'+mx+'" cy="'+my+'" r="'+(5.2*sf).toFixed(1)+'" fill="none" stroke="#ff5a5a" stroke-width="'+(1.1*sf).toFixed(1)+'"/>'; }
        g+='<circle cx="'+mx+'" cy="'+my+'" r="'+rad.toFixed(1)+'" fill="'+c+'" stroke="#0b0d18" stroke-width="0.7">'+tip+'</circle>'; }
      // SELF: a green CIRCLE (no direction arrow — facing is unreliable)
      g+='<circle cx="'+C+'" cy="'+C+'" r="'+(6*sf).toFixed(1)+'" fill="#3bf07a" stroke="#fff" stroke-width="1.6"><title>you</title></circle>';
      mb.innerHTML=g+'</svg>';
      if(sd){ let fc='—';
        if(plan.length){ let t=0, ax=px, az=pz; for(const p of plan){ t+=Math.hypot(p[3]-ax,p[5]-az)/Math.max(ws,0.5)+ks; ax=p[3]; az=p[5]; }
          fc=(plan.length*60/Math.max(t,0.1)).toFixed(1); }
        sd.innerHTML='🧠 mode <b>'+(smart?'smart (cluster)':'nearest')+'</b> · forecast <b style="color:#8f8">'+fc+' mobs/min</b><br>'
          +'🚶 move speed <b>'+ws.toFixed(1)+'</b> u/s · ⚔ kill time <b>'+ks.toFixed(1)+'</b> s'
          +(st2.ks>0?'':' <span style="color:#889">(defaults — learns while running)</span>'); }
    };
    window.__greStatSet=(m)=>{ const s=document.getElementById('greStat2'); if(s) s.textContent=m; };
  })();
  // ── dashboards / scanner calibration ──
  window.__calibActive = false;
  window.__gcalibActive = false;
  window.__calib = %STATCALIB%;
  window.__gcalib = %GCALIB%;
  document.querySelectorAll('.tab').forEach(t => t.onclick = () => {
    document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
    t.classList.add('active');
    document.querySelectorAll('.dash').forEach(d => d.style.display = 'none');
    document.getElementById(t.dataset.dash).style.display = '';
    window.__calibActive = (t.dataset.dash === 'dashCalib');
    window.__gcalibActive = (t.dataset.dash === 'dashGrindCalib');
    window.__exp.active = (t.dataset.dash === 'dashExp');
    if (window.__exp.active && window.__expRefresh) window.__expRefresh();
    window.__shopDbActive = (t.dataset.dash === 'dashShopDb');
    window.__reActive = (t.dataset.dash === 'dashRE');
    window.__controlActive = (t.dataset.dash === 'dashControl');
  });
  window.__controlActive = true;   // Control dash is the default tab
  // ── RE / Memory workbench — nearby-enemies list ──
  window.__reActive = false;
  // Shared top-down mob-map SVG builder (player-centred, world-oriented). Used by the full RE/Memory map
  // (S=260, interactive click-to-move) AND the small Grind-local mini-map (S=170, non-interactive). Also
  // updates the movement-heading arrow. Marker sizes scale with S so the mini-map stays legible.
  window.__buildMobMap = (data, S, interactive) => {
    const rows = (data && data.mobs) || [];
    const px = (data && data.px) || 0, pz = (data && data.pz) || 0;
    if (window.__memPrevPos) {
      const ddx = px - window.__memPrevPos.px, ddz = pz - window.__memPrevPos.pz;
      if (Math.hypot(ddx, ddz) > 0.5) window.__memHeading = Math.atan2(ddz, ddx);   // heading from movement
    }
    window.__memPrevPos = {px: px, pz: pz};
    const C = S / 2, PAD = Math.max(10, S * 0.06), sf = S / 260;   // sf scales marker sizes
    let maxr = 30;
    for (const r of rows) maxr = Math.max(maxr, Math.abs(r[3] - px), Math.abs(r[5] - pz));
    maxr = Math.ceil(maxr * 1.12);
    const sc = (C - PAD) / maxr;
    if (interactive) window.__memMap = {px: px, pz: pz, sc: sc, C: C};   // for click-to-move
    const attr = interactive
      ? ' onclick="window.__memMapClick(event)" style="background:#0a0a14;border:1px solid #2a2a3a;border-radius:6px;cursor:crosshair"'
      : ' style="background:#0a0a14;border:1px solid #2a2a3a;border-radius:6px"';
    let g = '<svg width="' + S + '" height="' + S + '"' + attr + '>';
    for (const f of [0.34, 0.67, 1.0]) {
      const rr = (C - PAD) * f;
      g += '<circle cx="' + C + '" cy="' + C + '" r="' + rr.toFixed(1) + '" fill="none" stroke="#1c2233"/>';
      g += '<text x="' + (C + 3) + '" y="' + (C - rr + 10).toFixed(1) + '" fill="#556" font-size="9">' + Math.round(maxr * f) + '</text>';
    }
    g += '<line x1="' + C + '" y1="' + PAD + '" x2="' + C + '" y2="' + (S - PAD) + '" stroke="#141824"/>';
    g += '<line x1="' + PAD + '" y1="' + C + '" x2="' + (S - PAD) + '" y2="' + C + '" stroke="#141824"/>';
    for (const r of rows) {
      const mx = (C + (r[3] - px) * sc).toFixed(1), my = (C + (r[5] - pz) * sc).toFixed(1);
      const s = r[8];
      const c = r[0] < 15 ? '#ffe14a' : (r[0] < 40 ? '#ffb454' : '#6aa0ff');
      const tip = '<title>#' + r[1] + '  ' + r[2] + ' HP  dist ' + r[0]
        + (s === 2 ? '  ⚔ ATTACKING YOU' : (s === 1 ? '  • in combat' : '')) + '</title>';
      if (s === 2) {
        g += '<circle cx="' + mx + '" cy="' + my + '" r="' + (6*sf).toFixed(1) + '" fill="none" stroke="#ff1a1a" stroke-width="' + (2.6*sf).toFixed(2) + '">'
          + '<animate attributeName="r" values="' + (5*sf).toFixed(1) + ';' + (8.5*sf).toFixed(1) + ';' + (5*sf).toFixed(1) + '" dur="0.85s" repeatCount="indefinite"/>'
          + '<animate attributeName="stroke-opacity" values="1;0.25;1" dur="0.85s" repeatCount="indefinite"/></circle>';
        g += '<circle cx="' + mx + '" cy="' + my + '" r="' + (3.8*sf).toFixed(1) + '" fill="' + c + '">' + tip + '</circle>';
      } else if (s === 1) {
        g += '<circle cx="' + mx + '" cy="' + my + '" r="' + (5.5*sf).toFixed(1) + '" fill="none" stroke="#ff5a5a" stroke-width="1"/>';
        g += '<circle cx="' + mx + '" cy="' + my + '" r="' + (3.3*sf).toFixed(1) + '" fill="' + c + '">' + tip + '</circle>';
      } else {
        g += '<circle cx="' + mx + '" cy="' + my + '" r="' + (3.2*sf).toFixed(1) + '" fill="' + c + '" stroke="#0a0a14" stroke-width="0.5">' + tip + '</circle>';
      }
    }
    if (rows.length) {   // ✦ CENTER OF MASS of the mobs (magenta) — the "look toward this" direction
      let scx = 0, scz = 0; for (const r of rows) { scx += r[3]; scz += r[5]; }
      scx /= rows.length; scz /= rows.length;
      const cmx = (C + (scx - px) * sc), cmy = (C + (scz - pz) * sc);
      const rr = (5*sf), ll = (7*sf);
      g += '<circle cx="' + cmx.toFixed(1) + '" cy="' + cmy.toFixed(1) + '" r="' + rr.toFixed(1) + '" fill="none" stroke="#ff3bf0" stroke-width="' + (1.6*sf).toFixed(2) + '"/>'
        + '<line x1="' + (cmx-ll).toFixed(1) + '" y1="' + cmy.toFixed(1) + '" x2="' + (cmx+ll).toFixed(1) + '" y2="' + cmy.toFixed(1) + '" stroke="#ff3bf0" stroke-width="' + Math.max(1,sf).toFixed(1) + '"/>'
        + '<line x1="' + cmx.toFixed(1) + '" y1="' + (cmy-ll).toFixed(1) + '" x2="' + cmx.toFixed(1) + '" y2="' + (cmy+ll).toFixed(1) + '" stroke="#ff3bf0" stroke-width="' + Math.max(1,sf).toFixed(1) + '"><title>center of mass of nearby mobs</title></line>';
    }
    // self-arrow: prefer the CAMERA VIEW direction (data.camHeading, estimated from steering calibration);
    // fall back to the movement-heading proxy (window.__memHeading) when the camera direction isn't known.
    const camH = (data && typeof data.camHeading === 'number') ? data.camHeading : null;
    const camCal = !!(data && data.camCal);
    const headA = (camH != null) ? camH : window.__memHeading;
    const headTip = (camH != null)
      ? ('you — CAMERA view direction' + (camCal ? '' : ' (rough; walk a little to align it)'))
      : 'you (facing = last move direction)';
    if (headA != null) {
      const deg = headA * 180 / Math.PI;
      g += '<g transform="rotate(' + deg.toFixed(1) + ',' + C + ',' + C + ')">'
        + '<polygon points="' + (C+10*sf).toFixed(1) + ',' + C + ' ' + (C-6*sf).toFixed(1) + ',' + (C-6*sf).toFixed(1)
        + ' ' + (C-3*sf).toFixed(1) + ',' + C + ' ' + (C-6*sf).toFixed(1) + ',' + (C+6*sf).toFixed(1)
        + '" fill="#3bf07a" stroke="#fff" stroke-width="1" stroke-linejoin="round"><title>' + headTip + '</title></polygon></g>';
    } else {
      g += '<circle cx="' + C + '" cy="' + C + '" r="' + (4.5*sf).toFixed(1) + '" fill="#3bf07a" stroke="#fff" stroke-width="1"><title>you</title></circle>';
    }
    return g + '</svg>';
  };
  // small mob-map in the Grind local panel (fed by the supervisor while that sub-tab is shown)
  window.__gridMapSet = (data) => {
    const box = document.getElementById('gridMiniMap'); if (!box) return;
    const rows = (data && data.mobs) || [];
    const st = document.getElementById('gridMiniStat');
    // DIAGNOSTIC: show the raw camera-yaw read + whether it's calibrated. If 'yaw' stays '—' or frozen when
    // you look around, the chain read is dead this session (address not reload-stable).
    const yaw = (data && typeof data.camYawDeg === 'number') ? (data.camYawDeg.toFixed(0) + '°') : '—';
    const cal = (data && data.camCal) ? ' (cal)' : '';
    if (st) st.textContent = (rows.length ? ('— ' + rows.length + ' nearby') : '— none nearby') + ' · yaw ' + yaw + cal;
    box.innerHTML = rows.length ? window.__buildMobMap(data, 170, false)
      : '<span class="dim" style="font-size:11px">no monsters nearby (memory)</span>';
  };
  window.__memMobsSet = (data) => {
    window.__memMobsLast = data;                 // cached so the risk toggle can re-render without new data
    const box = document.getElementById('memMobsTbl'); if (!box) return;
    const mapBox = document.getElementById('memMobsMap');
    const st = document.getElementById('memMobsStat');
    const rows = (data && data.mobs) || [];
    if (!rows.length) {
      box.innerHTML = '<span class="dim">no monsters nearby (or not in-world / bot running).</span>';
      if (mapBox) mapBox.innerHTML = '';
      if (st) st.textContent = '— 0 monsters'; return;
    }
    const nAtk = rows.filter(r => r[8] === 2).length, nCmb = rows.filter(r => r[8] === 1).length;
    const yaw = (data && typeof data.camYawDeg === 'number') ? (data.camYawDeg.toFixed(0) + '°') : '—';
    if (st) st.textContent = '— ' + rows.length + ' monsters · ' + nAtk + ' ⚔ · ' + nCmb + ' combat · cam yaw ' + yaw;
    const risk = !!window.__memRiskOn;   // action buttons only render when the risk toggle is on
    // ── table (name · level · HP · per-mob select/attack buttons; aggro state flagged) ──
    const col = (w, c, t) => '<span style="width:' + w + 'px;color:' + c + ';display:inline-block;overflow:hidden;text-overflow:ellipsis">' + t + '</span>';
    let h = '<div style="border-bottom:1px solid #2a2a3a;padding:2px 0;color:#7a9;white-space:nowrap">'
      + col(18, '#7a9', '') + col(40, '#7a9', 'dist') + col(118, '#7a9', 'name') + col(34, '#7a9', 'lvl')
      + col(70, '#7a9', 'HP') + '<span style="color:#7a9">' + (risk ? 'actions' : '🔒') + '</span></div>';
    for (const r of rows) {
      const s = r[8];   // 2=attacking you, 1=in combat, 0=idle
      const bt = (a, txt, bg, br, fg) => '<button onclick="window.__memDo(\'' + a + '\',' + r[6] + ',' + r[7] + ')"'
        + ' style="font-size:10px;padding:1px 7px;margin-left:4px;cursor:pointer;background:' + bg
        + ';color:' + fg + ';border:1px solid ' + br + ';border-radius:3px">' + txt + '</button>';
      const mark = s === 2 ? '⚔' : (s === 1 ? '•' : '');
      const markC = s === 2 ? '#ff4a4a' : (s === 1 ? '#e08040' : '#7a9');
      const nmC = s === 2 ? '#ff8a8a' : (s === 1 ? '#f0b080' : '#fda');
      const bgH = s === 2 ? ';background:rgba(255,40,40,.12)' : (s === 1 ? ';background:rgba(224,128,64,.07)' : '');
      const name = r[9] || ('#' + r[1]);
      const lvl = r[10] ? ('Lv ' + r[10]) : '—';
      const actions = risk
        ? (bt('select', 'select', '#16283a', '#2a4a6a', '#9cf') + bt('attack', 'attack', '#3a1616', '#6a2a2a', '#f99')
           + '<button onclick="window.__memGoto(' + r[3] + ',' + r[4] + ',' + r[5] + ')" title="walk the char to this mob (server-side, via packet-dest rewrite)"'
           + ' style="font-size:10px;padding:1px 7px;margin-left:4px;cursor:pointer;background:#163a20;color:#9f9;border:1px solid #2a6a3a;border-radius:3px">go to</button>')
        : '';
      h += '<div style="padding:1px 0;white-space:nowrap' + bgH + '" title="species #' + r[1] + ' · pos ' + r[3] + '/' + r[4] + '/' + r[5] + '">'
        + col(18, markC, mark) + col(40, '#cde', r[0]) + col(118, nmC, name) + col(34, '#9ab', lvl) + col(70, '#8f8', r[2])
        + actions + '</div>';
    }
    box.innerHTML = h;
    if (mapBox) mapBox.innerHTML = window.__buildMobMap(data, 260, true);   // full interactive map
  };
  // ⚠️ risk toggle: the action buttons SEND commands (throwaway only). Off by default; guards every action.
  window.__memRiskOn = false;
  const memRisk = document.getElementById('memRiskOn');
  if (memRisk) {
    memRisk.checked = false;
    memRisk.onchange = () => { window.__memRiskOn = memRisk.checked;
      if (window.__memMobsLast && window.__memMobsSet) window.__memMobsSet(window.__memMobsLast); };  // re-render rows
  }
  // select/attack buttons -> hand the mob id to the supervisor (which acts via re_sense)
  window.__memDo = (act, idLo, idHi) => {
    if (!window.__memRiskOn) return;                         // guarded — buttons hidden, but double-guard the call
    window.__memAct = {gen: Date.now(), act: act, idLo: idLo, idHi: idHi};
    const s = document.getElementById('memActStat');
    if (s) s.textContent = (act === 'attack' ? '⚔ attacking' : '🎯 selecting') + ' id ' + (idHi>>>0) + ':' + (idLo>>>0) + '…';
  };
  window.__memActSet = (msg) => { const s = document.getElementById('memActStat'); if (s) s.textContent = msg; };
  // 🚶 GO TO — hand a world (x,y,z) to the supervisor, which walks the char there server-side (M8e move).
  window.__memGoto = (x, y, z) => {
    if (!window.__memRiskOn) return;                         // guarded (throwaway only)
    window.__memAct = {gen: Date.now(), act: 'goto', x: x, y: (y === undefined ? null : y), z: z};
    const s = document.getElementById('memActStat');
    if (s) s.textContent = '🚶 walking to ' + Math.round(x) + ', ' + Math.round(z) + '…';
  };
  // click anywhere on the mini-map to walk there (invert the map projection back to world x/z)
  window.__memMapClick = (ev) => {
    if (!window.__memRiskOn) return;                         // guarded (throwaway only)
    const m = window.__memMap; if (!m) return;
    const r = ev.currentTarget.getBoundingClientRect();
    const wx = m.px + ((ev.clientX - r.left) - m.C) / m.sc;
    const wz = m.pz + ((ev.clientY - r.top) - m.C) / m.sc;
    window.__memGoto(wx, null, wz);
  };
  // 🧭 Find camera yaw — hand a request to the supervisor (which runs the differential scan)
  window.__findYawReq = 0;
  const fyBtn = document.getElementById('findYawBtn');
  if (fyBtn) fyBtn.onclick = () => {
    window.__findYawReq = Date.now();
    const s = document.getElementById('findYawStat'); if (s) s.textContent = 'scanning… stand still, hands off the mouse';
    const o = document.getElementById('findYawOut'); if (o) o.textContent = '';
  };
  window.__findYawSet = (stat, out) => {
    const s = document.getElementById('findYawStat'); if (s) s.textContent = stat || '';
    const o = document.getElementById('findYawOut'); if (o && out != null) o.textContent = out;
  };
  document.getElementById('expReset').onclick = () => {
    window.__exp.ocr = []; window.__exp.px = []; window.__exp.mem = [];
    window.__exp.stepOcr = null; window.__exp.stepPx = null; window.__exp.stepMem = null; window.__exp.lastMemT = 0;
    if (window.__expRefresh) window.__expRefresh();
  };
  window.__calibSet = (src, info) => {
    const im = document.getElementById('calibImg'); if (im) im.src = src;
    const i = document.getElementById('calibInfo'); if (i) i.textContent = info;
  };
  // build the HP / MP / EXP calibration rows
  const SBARS = [['HP', ['HP_X0','HP_X1','HP_Y0','HP_Y1','FILL_L','FILL_R']],
    ['MP', ['MP_X0','MP_X1','MP_Y0','MP_Y1','MP_FILL_L','MP_FILL_R']],
    ['EXP', ['EXP_X0','EXP_X1','EXP_Y0','EXP_Y1','EXP_FILL_L','EXP_FILL_R']],
    ['TARGET HP', ['TGT_X0','TGT_X1','TGT_Y0','TGT_Y1','TGT_FILL_L','TGT_FILL_R']]];
  const SUBL = ['box left','box right','box top','box bottom','fill 0% (left)','fill 100% (right)'];
  const statRows = document.getElementById('statRows');
  SBARS.forEach(([name, keys]) => {
    const grp = document.createElement('div'); grp.className = 'statgrp';
    grp.innerHTML = `<div class="statgrphdr">${name} bar</div>`;
    const fields = document.createElement('div'); fields.className = 'fields';
    keys.forEach((k, i) => {
      const f = document.createElement('div'); f.className = 'field';
      f.innerHTML = `<span>${SUBL[i]}</span><div class="r"><button class="step" id="c${k}d">–</button>` +
        `<input id="c${k}" type="number" step="1"><button class="step" id="c${k}i">+</button></div>`;
      fields.appendChild(f);
    });
    grp.appendChild(fields); statRows.appendChild(grp);
  });
  SBARS.flatMap(b => b[1]).forEach(k => {
    const el = document.getElementById('c'+k); el.value = window.__calib[k];
    const set = v => { v = Math.round(v); window.__calib[k] = v; el.value = v; };
    el.oninput = () => { const v = parseInt(el.value); if (!isNaN(v)) window.__calib[k] = v; };
    const d = document.getElementById('c'+k+'d'), inc = document.getElementById('c'+k+'i');
    if (d) d.onclick = () => set((window.__calib[k]||0) - 1);
    if (inc) inc.onclick = () => set((window.__calib[k]||0) + 1);
  });
  document.getElementById('calibSave').onclick = () => {
    window.__calibSaveReq = Date.now();
    document.getElementById('calibStat').textContent = 'saving…';
  };
  // ── grind-region calibration ──
  const GREG = [['ROI','ROI scan','#3c3'], ['HUD_EXCL','HUD (ignore)','#e44'],
    ['MINIMAP_EXCL','minimap (ignore)','#e44'], ['BANNER_EXCL','banner (ignore)','#e44'],
    ['CHAT_EXCL','chat (ignore)','#e44'], ['SELF_EXCL','self (skip)','#f90']];
  const grows = document.getElementById('gcalibRows');
  GREG.forEach(([k, label, color]) => {
    const row = document.createElement('div'); row.className = 'gcalrow';
    row.innerHTML = `<span class="glbl"><span class="sw" style="background:${color}"></span>${label}</span>`;
    [0,1,2,3].forEach(i => {
      const inp = document.createElement('input');
      inp.type = 'number'; inp.step = '0.01'; inp.min = '0'; inp.max = '1';
      inp.value = Number(window.__gcalib[k][i]).toFixed(3);
      inp.oninput = () => { const v = parseFloat(inp.value); if (!isNaN(v)) window.__gcalib[k][i] = v; };
      row.appendChild(inp);
    });
    grows.appendChild(row);
  });
  window.__gcalibSet = (src, info) => {
    const im = document.getElementById('gcalibImg'); if (im) im.src = src;
    const i = document.getElementById('gcalibInfo'); if (i) i.textContent = info;
  };
  document.getElementById('gcalibSave').onclick = () => {
    window.__gcalibSaveReq = Date.now();
    document.getElementById('gcalibStat').textContent = 'saving…';
  };
  const gcalibHov = document.getElementById('gcalibShowHover');
  window.__gcalibShowHover = gcalibHov ? gcalibHov.checked : true;   // default ON (checkbox is `checked`)
  if (gcalibHov) gcalibHov.onchange = () => { window.__gcalibShowHover = gcalibHov.checked; };
  // ── ShopScanner ──
  window.__shop = {autoRoam: false};
  window.__shopActive = false;
  const shopAR = document.getElementById('shopAutoRoam');
  shopAR.checked = !!window.__shop.autoRoam;
  shopAR.onchange = () => { window.__shop.autoRoam = shopAR.checked; };
  window.__shopSet = (src, info, stat) => {
    const im = document.getElementById('shopImg'); if (im && src) im.src = src;
    const i = document.getElementById('shopInfo'); if (i && info) i.textContent = info;
    const s = document.getElementById('shopStat'); if (s && stat) s.textContent = stat;
  };
  // ── Shop DB viewer ──
  window.__shopDbActive = false;
  window.__shopdb = {rows: []};
  const _shopFmt = n => (n == null) ? '—' : Number(n).toLocaleString();
  const _shopEsc = s => String(s == null ? '' : s).replace(/[&<>"']/g,
    c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  function renderShopDb(){
    const body = document.getElementById('shopDbBody'); if (!body) return;
    const q = (document.getElementById('shopSearch').value || '').toLowerCase();
    const rows = window.__shopdb.rows.filter(r => !q || String(r[0]).toLowerCase().includes(q));
    body.innerHTML = rows.map(r =>
      '<tr data-name="' + _shopEsc(r[0]) + '"><td>' + _shopEsc(r[0]) + '</td><td class="num">' + _shopFmt(r[1]) +
      '</td><td class="num">' + _shopFmt(r[2]) + '</td><td class="num">' + _shopFmt(r[3]) +
      '</td><td class="num">' + (r[4] || 0) + '</td><td>' + _shopEsc(r[5]) + '</td></tr>').join('');
  }
  window.__shopdbSet = (rows, info, count) => {
    window.__shopdb.rows = rows || [];
    const i = document.getElementById('shopDbInfo'); if (i && info) i.textContent = info;
    const c = document.getElementById('shopDbCount'); if (c && count) c.textContent = count;
    renderShopDb();
  };
  document.getElementById('shopSearch').oninput = renderShopDb;
  document.getElementById('shopDbCsv').onclick = () => {
    window.__shopCsvReq = Date.now();
    document.getElementById('shopDbInfo').textContent = 'exporting…';
  };
  // per-item price history: click a row -> ask Python for its observations
  document.getElementById('shopDbBody').onclick = (e) => {
    const tr = e.target.closest('tr'); if (!tr || !tr.dataset.name) return;
    const name = tr.dataset.name;
    window.__shopHistReq = {n: name, t: Date.now()};
    document.getElementById('shopDetailName').textContent = name;
    document.getElementById('shopDetailSummary').textContent = 'loading…';
    document.getElementById('shopDetailBody').innerHTML = '';
    document.getElementById('shopDetail').style.display = '';
  };
  window.__shopHistSet = (name, summary, rows) => {
    if (document.getElementById('shopDetailName').textContent !== name) return;   // stale click
    document.getElementById('shopDetailSummary').textContent = summary;
    document.getElementById('shopDetailBody').innerHTML = (rows || []).map(o =>
      '<tr><td>' + _shopEsc(o[1]) + '</td><td class="num">' + _shopFmt(o[0]) + '</td><td>' +
      _shopEsc(o[2]) + '</td></tr>').join('');
  };
  document.getElementById('shopDetailClose').onclick = () => {
    document.getElementById('shopDetail').style.display = 'none';
    window.__shopHistReq = null;
  };
</script></body></html>""".replace("%DEF%", str(HEAL_INTERVAL)).replace("%BUFFDEF%", str(int(BUFF_EVERY))).replace("%BUFF2DEF%", str(int(BUFF2_EVERY))).replace("%BUFFSTEPMS%", str(int(BUFF_STEP_DELAY*1000))).replace("%MPATPCT%", str(int(round(MP_AT*100)))).replace("%FOODATPCT%", str(int(round(FOOD_AT*100)))).replace("%FLEEEN%", "false").replace("%FLEESECS%", str(FLEE_SECS)).replace("%CHATEN%", "false").replace("%MAPEN%", "false").replace("%MAPEVERY%", str(int(MAP_EVERY))).replace("%HEALSAVED%", _heal_saved_js()).replace("%BUFFICONS%", _buff_icons_js()).replace("%FOLLOWEVERY%", str(int(FOLLOW_EVERY))).replace("%LOOKEVERY%", str(int(LOOK_EVERY))).replace("%DETECTMODE%", str(getattr(gl, "DETECT_MODE", "hybrid2"))).replace("%MOBTARGET%", str(getattr(gl, "TARGET_SENSE", "memory"))).replace("%NODMGSECS%", str(int(getattr(gl, "NO_DAMAGE_GIVEUP", 5)))).replace("%HEALATPCT%", str(int(round(gl.HEAL_AT*100)))).replace("%ROTDX%", str(int(gl.ROTATE_DX))).replace("%TURNS%", str(int(gl.WALK_AFTER_SCANS))).replace("%WALKPCT%", str(int(gl.WALK_RADIUS*100))).replace("%WALKMS%", str(int(gl.WALK_TIME*1000))).replace("%TILT%", str(int(gl.TILT_BACK_UP))).replace("%SELFBUFF%", str(int(gl.SELF_BUFF_EVERY))).replace("%FIGHTPOLLMS%", str(int(round(gl.FIGHT_POLL*1000)))).replace("%DEATHN%", str(int(gl.DEATH_CONFIRM_CONSECUTIVE_ABSENT))).replace("%ENGAGESETTLEMS%", str(int(round(gl.ENGAGE_SETTLE*1000)))).replace("%SELECTGAPMS%", str(int(round(gl.SELECT_GAP*1000)))).replace("%AVGIANT%", "true").replace("%AVSKIP%", "true").replace("%HPX0%", str(gl.HP_X0)).replace("%HPX1%", str(gl.HP_X1)).replace("%HPY0%", str(gl.HP_Y0)).replace("%HPY1%", str(gl.HP_Y1)).replace("%FILLL%", str(gl.FILL_L)).replace("%FILLR%", str(gl.FILL_R)).replace("%GCALIB%", json.dumps({k: [round(float(v), 4) for v in getattr(gl, k)] for k in gl.GRIND_CALIB_KEYS})).replace("%STATCALIB%", json.dumps({k: int(getattr(gl, k)) for k in gl.HP_CALIB_KEYS}))

def open_control_window(flyff_page):
    """Open the control UI in a SEPARATE browser window and return its Page.
    Tries a real new window (CDP Target.createTarget newWindow), then a new tab,
    then None (caller falls back to the in-game panel)."""
    ctx = flyff_page.context
    page = None
    try:
        client = ctx.new_cdp_session(flyff_page)
        with ctx.expect_page(timeout=5000) as pi:
            client.send("Target.createTarget", {"url": "about:blank", "newWindow": True})
        page = pi.value
    except Exception as e:
        print(f"[control] separate window via CDP failed ({e}); trying a new tab.")
        try:
            page = ctx.new_page()
        except Exception as e2:
            print(f"[control] could not open a control page ({e2}); using in-game panel.")
            return None
    try:
        page.set_content(CONTROL_HTML, wait_until="load")
    except Exception as e:
        print(f"[control] could not load control UI ({e}); using in-game panel.")
        return None
    try:                                   # best-effort: size + place the window
        c2 = ctx.new_cdp_session(page)
        win = c2.send("Browser.getWindowForTarget", {})
        c2.send("Browser.setWindowBounds", {"windowId": win["windowId"],
                "bounds": {"left": 60, "top": 60, "width": 720, "height": 740}})
    except Exception:
        pass
    try:
        page.bring_to_front()
    except Exception:
        pass
    print("[control] control window opened (separate from the game).")
    return page


# The in-game click hook records the user's last click on the GAME page so
# 'Grind selected' knows which mob you picked. It must be installed on the game page
# even when the visible panel lives in the separate window, so it's its own injector.
CLICK_HOOK_JS = r"""(() => {
  window.__clickHookFn = (e) => {
    if (e.target.closest && e.target.closest('#botPanel,#botHealPanel')) return;
    window.__lastClick = {x: e.clientX, y: e.clientY, t: Date.now()};
  };
  if (!window.__clickHook) {
    window.__clickHook = true;
    document.addEventListener('click', (e) => { if (window.__clickHookFn) window.__clickHookFn(e); }, true);
  }
})()"""


def ensure_click_hook(page):
    try:
        page.evaluate(CLICK_HOOK_JS)
    except Exception:
        pass


# ── Raw WebSocket packet monitor ──────────────────────────────────────────────────
# Capture the game's WS frames over CDP (Network domain) — this sees the ALREADY-OPEN
# socket without modifying the page (same method as wscapture.py). Frames are buffered
# in Python and flushed into the control window's two columns: RECV (left), SEND (right).
def _decode_frame(resp):
    op = resp.get("opcode")
    payload = resp.get("payloadData", "")
    if op == 2:                              # binary frame: base64 in CDP → hex
        try:
            data = base64.b64decode(payload)
        except Exception:
            data = b""
        return len(data), data.hex()
    b = (payload or "").encode("utf-8", "replace")   # text frame
    return len(b), "TXT:" + (payload or "")


def _on_frame(direction, pr, pkt):
    resp = pr.get("response", {})
    ln, hx = _decode_frame(resp)
    pkt["pending"].append({"dir": direction,
                           "text": f"{time.strftime('%H:%M:%S')} {ln:>4}  {hx[:PKT_MAXHEX]}"})
    if len(pkt["pending"]) > 400:            # cap if a burst lands during a blocking call
        pkt["pending"] = pkt["pending"][-400:]
    plog.frame(direction, resp.get("opcode"), ln, hx)   # full hex → the per-session RE log (if open)


def start_packet_capture(game_page):
    """Enable WS frame capture on the game page. Returns a state dict (never raises)."""
    pkt = {"pending": [], "cdp": None}
    try:
        cdp = game_page.context.new_cdp_session(game_page)
        cdp.send("Network.enable")
        cdp.on("Network.webSocketFrameReceived", lambda pr: _on_frame("recv", pr, pkt))
        cdp.on("Network.webSocketFrameSent", lambda pr: _on_frame("send", pr, pkt))
        pkt["cdp"] = cdp
        print("[packets] WebSocket capture enabled.")
    except Exception as e:
        print(f"[packets] capture unavailable ({e}).")
    return pkt


def flush_packets(ctrl, pkt):
    """Push buffered frames into the control window's columns. Cheap no-op when idle."""
    if not pkt or not pkt["pending"]:
        return
    frames = pkt["pending"]
    pkt["pending"] = []
    try:
        ctrl.evaluate("(fr)=>{ if (window.__pktAdd) window.__pktAdd(fr); }", frames)
    except Exception:
        pass                                 # control window gone/closing — drop this batch


class _OcrWorker:
    """Runs the HEAVY status OCR (3-5 Tesseract subprocesses, ~1 s) on a BACKGROUND thread so it never
    blocks the running heal/grind loop — that block is exactly why OCR was dropped from the hot path,
    which in turn killed the precise OCR-Exp the EXP graph needs. The main thread submits the frame it
    ALREADY grabbed for the fast pixel row (no extra screenshot); the worker runs gl.read_status(img,
    ocr=True) — pure cv2/Tesseract, NO Playwright, so it's thread-safe — and stashes the result. The
    main thread later pushes the freshest result (all Playwright .evaluate calls stay on the main
    thread). The JS keeps its pixel-bar agreement check (EXP_OCR_TOL) on whatever Exp the OCR returns."""
    def __init__(self):
        self._lock = threading.Lock()
        self._in = None            # newest frame to OCR (main → worker)
        self._out = None           # newest OCR result dict (worker → main)
        self._out_id = 0
        self._pushed = 0           # last _out_id pushed to the panel (main thread only)
        self._wake = threading.Event()
        self._thread = None

    def ensure(self):
        if self._thread is None or not self._thread.is_alive():
            self._thread = threading.Thread(target=self._run, name="exp-ocr", daemon=True)
            self._thread.start()

    def submit(self, img):
        with self._lock:
            self._in = img         # keep only the newest frame — stale ones are dropped
        self._wake.set()

    def take_fresh(self):
        """The newest OCR result NOT yet pushed, or None. (Main thread only.)"""
        with self._lock:
            if self._out is not None and self._out_id != self._pushed:
                self._pushed = self._out_id
                return self._out
        return None

    def reset(self):
        """Drop any pending frame/result so a NEW session never pushes a stale OCR sample from the
        previous one (called at run_session start)."""
        with self._lock:
            self._in = None
            self._pushed = self._out_id            # mark whatever's buffered as already consumed

    def _run(self):
        while True:
            self._wake.wait()
            self._wake.clear()
            with self._lock:
                img = self._in
                self._in = None
            if img is None:
                continue
            try:
                s = gl.read_status(img, ocr=True)      # SLOW — but off the main thread
            except Exception:
                continue
            s["feed"] = "ocr"                          # JS: update the OCR row + OCR-Exp graph only
            with self._lock:
                self._out = s
                self._out_id += 1


_exp_ocr = _OcrWorker()
_last_header = 0.0
_last_mem = 0.0            # separate throttle for the CHEAP MEM row (see MEM_EVERY) — decoupled from the
                          # expensive OCR/screenshot header so HP/MP/target refresh fast, not every 1.5s
MEM_EVERY = 0.1           # seconds between MEM-row pushes (deobfuscated WASM HP/MP/target — cheap JS reads)
_mem_stat = {"hp_gear": 0, "mp_peak": 0, "tgt_id": None, "tgt_peak": 0}   # HP: peak equip-surplus over the buff-aware formula; MP/target: peak


def _push_mem_status(act, ctrl):
    """MEM row of the header: read stats straight from WASM memory (deobfuscated, exact — no OCR/vision).
    Cheap (one HEAPU8 read, no screenshot). HP = i32@(controller+2768)^i32@(controller+2744); MP = the +4
    sibling @+2772 (same XOR key). Max is peak-tracked (the base formula misses gear/buff bonuses). Silent
    no-op if memory/controller isn't ready."""
    try:
        r = re_sense.read_player(act)
        if not r:
            return
        hmx, _mem_stat["hp_gear"] = re_sense.hp_true_max(r["cur"], r.get("max_base", 0), _mem_stat["hp_gear"])
        s = {"feed": "mem", "hp_mem": f"{r['cur']}/{hmx} ({r['cur']/hmx*100:.1f}%)"}
        if "mp" in r:
            _mem_stat["mp_peak"] = max(_mem_stat["mp_peak"], r["mp"])
            mmx = _mem_stat["mp_peak"] or 1
            s["mp_mem"] = f"{r['mp']}/{mmx} ({r['mp']/mmx*100:.1f}%)"
        if r.get("exp") is not None:                   # EXACT exp% from memory (controller+5744 / +5904) — always
            s["exp_mem"] = f"{r['exp']:.4f}%"           # readable, realloc-proof; feeds the MEM row + EXP graph (no OCR)
        t = re_sense.read_target(act)                  # SELECTED target (id@controller+2656 -> registry)
        if t is not None:
            if not t.get("has"):
                s["tgt_mem"] = "— no target"
            elif t.get("resolved"):
                spc = (t.get("name") or "?").replace("IDS_PROPMOVER_TXT_", "#")
                # target HP % via peak-track (reset when the selected target changes; mobs are usually
                # selected at/near full HP, so the first read captures max). No reliable stored mob maxHP.
                if t.get("id") != _mem_stat["tgt_id"]:
                    _mem_stat.update(tgt_id=t.get("id"), tgt_peak=t["hp"])
                _mem_stat["tgt_peak"] = max(_mem_stat["tgt_peak"], t["hp"])
                tmx = _mem_stat["tgt_peak"] or 1
                s["tgt_mem"] = f"{spc}  ·  {t['hp']}HP ({t['hp']/tmx*100:.0f}%)" + \
                    ("" if t.get("kind") == 5 else f"  (kind {t.get('kind')})")
            else:
                s["tgt_mem"] = "target selected (unresolved)"
        ctrl.evaluate("(x)=>{ if (window.__statusSet) window.__statusSet(x); }", s)
    except Exception:
        pass


def _tick_sleep(act, ctrl, seconds):
    """Sleep `seconds`, pushing a fresh MEM HP/MP/target status every ~0.1s so the header stays live
    even during a long blocking wait that doesn't otherwise call `stopped()`/`update_header` — e.g.
    a buff cycle's per-step delay, which can run 15-20+ s across many presses (see `run_buffs`)."""
    end = time.time() + seconds
    while True:
        remaining = end - time.time()
        if remaining <= 0:
            return
        time.sleep(min(0.1, remaining))
        _push_mem_status(act, ctrl)


_mem_mobs_last = 0.0


MEM_MOBS_EVERY = 0.1   # RE/Memory nearby-mobs map refresh (s) — cheap registry walk, so ~10×/s
def update_mem_mobs(act, ctrl):
    """🗺️ RE / Memory tab — push the live nearby-monsters list (from the entity registry) when that tab is
    open. Throttled to MEM_MOBS_EVERY (~10×/s); cheap (one registry-walk evaluate). No-op when the tab
    isn't active."""
    global _mem_mobs_last
    try:
        if not ctrl.evaluate("!!window.__reActive"):
            return
        if time.time() - _mem_mobs_last < MEM_MOBS_EVERY:
            return
        _mem_mobs_last = time.time()
        re_sense.ensure_ws(act)          # pre-capture the ws so the first Select (send_select) doesn't race it
        mobs = re_sense.read_nearby_mobs(act, limit=40)
        _ch = re_sense.camera_arrow_heading(act)                  # camera view dir (raw→exact once calibrated)
        mobs["camHeading"] = _ch if _ch is not None else getattr(gl, "_CAM_HEADING", None)
        mobs["camCal"] = re_sense.camera_calibrated()
        mobs["camYawDeg"] = re_sense.read_camera_yaw_deg(act)     # DIAGNOSTIC raw yaw read (None = chain dead)
        ctrl.evaluate("(r)=>{ if (window.__memMobsSet) window.__memMobsSet(r); }", mobs)
    except Exception:
        pass


_grind_types_last = 0.0
GRIND_TYPES_EVERY = 0.6   # 🎯 Nearby mob types refresh (s) while stopped on the Grind local sub-tab
def update_grind_mobtypes(act, ctrl):
    """🎯 Nearby mob types card — while STOPPED on the Grind local / selected sub-tab, read nearby mobs from
    memory, aggregate by SPECIES (species#, display name, level, count, nearest dist), and push to the panel
    so the user can tick which types to grind/avoid before Start. No-op otherwise (cheap registry walk)."""
    global _grind_types_last
    try:
        if not ctrl.evaluate("!!(window.__controlActive && (window.__activeScript==='local'||window.__activeScript==='selected'))"):
            return
        if time.time() - _grind_types_last < GRIND_TYPES_EVERY:
            return
        _grind_types_last = time.time()
        d = re_sense.read_nearby_mobs(act, limit=60)
        agg = {}                                       # species# -> [sp, name, level, count, nearest_dist]
        for m in d.get("mobs", []):
            sp = str(m[1]); name = m[9] or ("#" + sp); lvl = m[10]; dist = m[0]
            if sp not in agg:
                agg[sp] = [sp, name, lvl, 0, dist]
            agg[sp][3] += 1
            agg[sp][4] = min(agg[sp][4], dist)
        types = sorted(agg.values(), key=lambda r: r[4])   # nearest type first
        ctrl.evaluate("(t)=>{ if (window.__grindTypesSet) window.__grindTypesSet(t); }", types)
    except Exception:
        pass


# ── 🧱 Grind RE OBSTACLE MAP: a crude world occupancy grid learned live ───────────────────────────────────
# Positive cell score = obstacle-likely (we tried to reach a mob there but made no progress → something's in
# the way, placed at the segment MIDPOINT with confidence ∝ 1/distance). Negative = free (we walked through
# it). Used to tint the panel map red and to bias grind-target selection toward clear paths. Module global so
# both the grind loop (writer) and update_grind_re_panel (reader/pusher) share it; cleared on each Start.
_gre_obs = {}
OBS_CELL = 4.0               # world-units per grid cell
OBS_MAX_DIST = 22.0          # a blocked event beyond this distance is too uncertain to record (discarded)
OBS_CLAMP = 3.0

def _obs_key(x, z):
    return (int(round(x / OBS_CELL)), int(round(z / OBS_CELL)))

def _obs_bump(x, z, amt):
    k = _obs_key(x, z); v = max(-OBS_CLAMP, min(OBS_CLAMP, _gre_obs.get(k, 0.0) + amt))
    if abs(v) < 0.05: _gre_obs.pop(k, None)
    else: _gre_obs[k] = v

def _obs_blocked(px, pz, mx, mz):
    """Record a likely obstacle between us and a mob we couldn't reach — at the MIDPOINT, confidence higher
    the SHORTER the distance (a long blocked segment is too ambiguous to place → discard past OBS_MAX_DIST)."""
    d = math.hypot(mx - px, mz - pz)
    if d < 2 or d > OBS_MAX_DIST: return
    _obs_bump((px + mx) / 2, (pz + mz) / 2, 0.9 * (1.0 - d / OBS_MAX_DIST))

def _obs_free(x0, z0, x1, z1):
    """We successfully walked (x0,z0)→(x1,z1): decrement obstacle score along that segment (it's clear)."""
    d = math.hypot(x1 - x0, z1 - z0)
    if d < 0.6: return
    n = min(24, max(1, int(d / (OBS_CELL * 0.5))))
    for i in range(n + 1):
        t = i / n; _obs_bump(x0 + (x1 - x0) * t, z0 + (z1 - z0) * t, -0.35)

def _obs_path(px, pz, mx, mz):
    """Total positive obstacle score sampled BETWEEN us and a candidate mob (path-obstruction, for bias)."""
    d = math.hypot(mx - px, mz - pz)
    if d < 2: return 0.0
    n = min(24, max(2, int(d / (OBS_CELL * 0.5)))); s = 0.0
    for i in range(1, n):
        v = _gre_obs.get(_obs_key(px + (mx - px) * i / n, pz + (mz - pz) * i / n), 0.0)
        if v > 0: s += v
    return s


# ── 💾 Saved area maps: obstacle grid + hand-painted no-go zones persisted per mob AREA (named by the most
# common mob's base model). Offered for load on session start when the area matches; otherwise a fresh map. ──
GRIND_MAPS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "grind_maps")
_gre_loaded_obs = None       # snapshot of a loaded map's obstacle cells → re-seeded on each Start (survives the clear)

def _map_name(base):
    """Sanitise a mob base name into a safe filename stem (alnum/_/-), e.g. 'Kern' → 'Kern'."""
    s = "".join(c if (c.isalnum() or c in "_-") else "_" for c in str(base or "map")).strip("_")
    return s or "map"

def _map_file(base):
    return os.path.join(GRIND_MAPS_DIR, _map_name(base) + ".json")

def _save_grind_map(base, painted):
    """Persist the current obstacle grid + hand-painted no-go zones for this area under the mob base name."""
    os.makedirs(GRIND_MAPS_DIR, exist_ok=True)
    data = {"base": base,
            "obs": [[k[0], k[1], round(v, 3)] for k, v in _gre_obs.items()],
            "paint": [[float(p[0]), float(p[1]), float(p[2])] for p in (painted or [])]}
    with open(_map_file(base), "w") as f:
        json.dump(data, f)
    return _map_file(base)

def _load_grind_map(base):
    """Load a saved area map → seed _gre_obs (+ snapshot it so Start's clear re-seeds, not wipes). Returns paint list or None."""
    global _gre_loaded_obs
    p = _map_file(base)
    if not os.path.exists(p):
        return None
    with open(p) as f:
        data = json.load(f)
    _gre_obs.clear()
    for c in data.get("obs", []):
        _gre_obs[(int(c[0]), int(c[1]))] = float(c[2])
    _gre_loaded_obs = dict(_gre_obs)                        # snapshot so a subsequent Start re-seeds it
    return data.get("paint", []) or []


def _painted_hit(x, z, painted, margin):
    """Is world point (x,z) inside a hand-PAINTED obstacle zone (+ its keep-away margin)?"""
    for q in painted:
        if math.hypot(x - q[0], z - q[1]) <= q[2] + margin:
            return True
    return False


def _painted_path(px, pz, mx, mz, painted, margin):
    """Does the straight path us→(mx,mz) pass through any painted zone (+ margin)?"""
    if not painted: return False
    d = math.hypot(mx - px, mz - pz)
    if d < 1: return False
    n = min(20, max(2, int(d / 2)))
    for i in range(n + 1):
        t = i / n
        if _painted_hit(px + (mx - px) * t, pz + (mz - pz) * t, painted, margin):
            return True
    return False


# ── 🧭 no-go-zone reachability: the game's auto-walk-to-attack paths STRAIGHT to a mob, through our painted
# zones / giant hazards (the game doesn't know them). So we simply DON'T target a mob whose straight line to us
# crosses a no-go zone — pick only mobs we can reach without walking through one. ──
def _route_zones(painted, pmargin, giants, giant_haz):
    """The keep-away circles: painted zones (+margin) + giant hazards. Each = (cx, cz, r)."""
    z = [(float(q[0]), float(q[1]), float(q[2]) + pmargin) for q in (painted or [])]
    if giant_haz > 0:
        z += [(float(g[3]), float(g[5]), float(giant_haz)) for g in (giants or [])]
    return z


def _path_crosses(sx, sz, gx, gz, zones):
    """Does the straight line us→mob pass through any keep-away zone we're NOT already standing in? A zone that
    CONTAINS the start point is ignored (we must be able to walk OUT of a zone we ended up inside — else every
    mob's path starts in it → all excluded → the loop freezes)."""
    if not zones:
        return False
    active = [(cx, cz, r) for (cx, cz, r) in zones if math.hypot(sx - cx, sz - cz) > r]   # skip zones we're inside
    if not active:
        return False
    d = math.hypot(gx - sx, gz - sz)
    if d < 1:
        return False
    n = min(24, max(2, int(d / 2)))
    for i in range(1, n + 1):                             # i≥1 → ignore the exact start point
        t = i / n; x, z = sx + (gx - sx) * t, sz + (gz - sz) * t
        for cx, cz, r in active:
            if math.hypot(x - cx, z - cz) <= r:
                return True
    return False


def _grindable_mob(m):
    """False for registry entities that are NOT grind targets — PLAYER avatars (mvr_male/mvr_female =
    species 000002/000004) and PETS (mvr_Pet*). read_nearby_mobs can't tell them from mobs (all use
    'mvr_' models + IDS_PROPMOVER keys), so Grind RE filters them here so it never lists or attacks a
    real player. m = [dist, species, hp, x, y, z, idLo, idHi, aggro, name, level]."""
    sp = str(m[1]); nm = (m[9] or "").lower()
    if sp in ("000002", "000004"):   return False   # player male / female avatars
    if nm in ("male", "female"):     return False
    if nm.startswith("pet"):         return False   # pets (PetFox, petDemiaKart, …)
    return True


_gre_map_act_gen = 0
def _handle_grind_map_action(act, ctrl, base_area):
    """💾 save / 📂 load the current area map (obstacles + painted no-go zones) + push the load-offer for this
    area (named by base_area = the most common nearby mob's base model). Called each grind_re panel poll."""
    global _gre_map_act_gen
    try:
        a = ctrl.evaluate("window.__greMapAct || {gen:0}")
    except Exception:
        a = {"gen": 0}
    gen = a.get("gen") or 0
    if gen and gen != _gre_map_act_gen:                    # a NEW button press
        _gre_map_act_gen = gen
        op = a.get("op")
        if op == "save":
            if not base_area:
                ctrl.evaluate("()=>{ if(window.__greMapMsg) window.__greMapMsg('⚠️ stand near mobs first — the map is named after them'); }")
            else:
                try:
                    n = os.path.basename(_save_grind_map(base_area, a.get("paint") or []))
                    ctrl.evaluate("(x)=>{ window.__greMapDismissed=x.base; if(window.__greMapMsg)window.__greMapMsg(x.msg); }",
                                  {"base": base_area, "msg": "💾 saved area map: " + n})
                except Exception as e:
                    ctrl.evaluate("(m)=>{ if(window.__greMapMsg) window.__greMapMsg(m); }", "💾 save failed: " + str(e))
        elif op == "load":
            base = a.get("base") or base_area
            try:
                paint = _load_grind_map(base)
                if paint is None:
                    ctrl.evaluate("(m)=>{ if(window.__greMapMsg) window.__greMapMsg(m); }", "📂 no saved map for " + str(base))
                else:
                    ctrl.evaluate("(x)=>{ window.__grePaint=x.paint; window.__greMapLoaded=x.base; if(window.__greRerender)window.__greRerender(); if(window.__greMapMsg)window.__greMapMsg('📂 loaded map: '+x.base); }",
                                  {"paint": paint, "base": base})
            except Exception as e:
                ctrl.evaluate("(m)=>{ if(window.__greMapMsg) window.__greMapMsg(m); }", "📂 load failed: " + str(e))
    # push the load-offer every poll (cheap) — JS decides whether to actually show it (not loaded/dismissed)
    try:
        exists = bool(base_area) and os.path.exists(_map_file(base_area))
        ctrl.evaluate("(a)=>{ if(window.__greMapOffer) window.__greMapOffer(a.base, a.exists); }",
                      {"base": base_area, "exists": exists})
    except Exception:
        pass


_gre_panel_last = 0.0
def update_grind_re_panel(act, ctrl):
    """🤖 Grind RE sub-tab — push nearby mob-TYPES (tick/untick) + the mini-map from memory when that Control
    sub-tab is showing (live both stopped and while grinding). Cheap registry walk; throttled ~10×/s."""
    global _gre_panel_last
    try:
        if not ctrl.evaluate("!!(window.__controlActive && window.__activeScript==='grind_re')"):
            return
        if time.time() - _gre_panel_last < MEM_MOBS_EVERY:
            return
        _gre_panel_last = time.time()
        d = re_sense.read_grind_mobs(act, limit=60)        # 13-col rows (incl. protect/giant) so the map matches the loop
        agg = {}                                       # species# -> [sp, name, level, count, nearest, maxHP, giant]
        for m in d.get("mobs", []):
            if not _grindable_mob(m):                  # never list players / pets
                continue
            sp = str(m[1]); name = m[9] or ("#" + sp); lvl = m[10]; dist = m[0]; hp = m[2]
            giant = 1 if (len(m) > 12 and m[12] == 1) else 0
            if sp not in agg:
                agg[sp] = [sp, name, lvl, 0, dist, hp, giant]
            agg[sp][3] += 1
            agg[sp][4] = min(agg[sp][4], dist)
            agg[sp][5] = max(agg[sp][5], hp)           # so the 4 same-model variants show distinct HP
            agg[sp][6] = agg[sp][6] or giant
        # descriptive tier NAMES by level rank within each MODEL group (Small < base < Captain < Giant): the
        # giant (HP/level outlier) → "Giant X"; when the 3 non-giant tiers are all present, name them Small/base/
        # Captain by ascending level (Aaron's pattern, e.g. Kern 99/100/101/104). Best-effort, display-only.
        _grp = {}
        for r in agg.values(): _grp.setdefault(r[1], []).append(r)
        for base, rows in _grp.items():
            ng = sorted([r for r in rows if not r[6]], key=lambda r: r[2])   # non-giants, ascending level
            if len(ng) == 3:
                for idx, tier in enumerate(("Small ", "", "Captain ")): ng[idx][1] = tier + base
            for r in rows:
                if r[6]: r[1] = "Giant " + base
        types = sorted(agg.values(), key=lambda r: r[4])
        if _gre_obs:                                       # 🧱 obstacle cells (world centre + score) for the map
            obs = sorted(([k[0] * OBS_CELL, k[1] * OBS_CELL, round(v, 2)] for k, v in _gre_obs.items() if v > 0.2),
                         key=lambda o: -o[2])[:250]
            d["obstacles"] = obs; d["obsCell"] = OBS_CELL
        ctrl.evaluate("(a)=>{ if(window.__greTypesSet)window.__greTypesSet(a.types); if(window.__greMiniSet)window.__greMiniSet(a.map); }",
                      {"types": types, "map": d})
        # 💾 area map save/load — named after the most common nearby mob's base (model) name
        base_area = max(_grp, key=lambda b: sum(r[3] for r in _grp[b])) if _grp else None
        _handle_grind_map_action(act, ctrl, base_area)
    except Exception:
        pass


_grind_minimap_last = 0.0
def update_grind_minimap(act, ctrl):
    """Small mob-map in the Grind local panel — push nearby mobs (from memory) to the mini-map whenever the
    Grind local/selected sub-tab is shown on the Control dash, so it's live both while stopped AND while
    grinding. Throttled to MEM_MOBS_EVERY (~10×/s); cheap registry walk. No-op on other tabs."""
    global _grind_minimap_last
    try:
        if not ctrl.evaluate("!!(window.__controlActive && (window.__activeScript==='local'||window.__activeScript==='selected'))"):
            return
        if time.time() - _grind_minimap_last < MEM_MOBS_EVERY:
            return
        _grind_minimap_last = time.time()
        mobs = re_sense.read_nearby_mobs(act, limit=40)
        _ch = re_sense.camera_arrow_heading(act)                  # camera view dir (raw→exact once calibrated)
        mobs["camHeading"] = _ch if _ch is not None else getattr(gl, "_CAM_HEADING", None)
        mobs["camCal"] = re_sense.camera_calibrated()
        mobs["camYawDeg"] = re_sense.read_camera_yaw_deg(act)     # DIAGNOSTIC: raw yaw read (None = chain dead)
        ctrl.evaluate("(r)=>{ if (window.__gridMapSet) window.__gridMapSet(r); }", mobs)
    except Exception:
        pass


# ── 🧭 Find camera yaw (RE tab button) — differential scan: right-drag the camera in equal steps while the
# char stands still, keep the f32s that change EVERY step (≈ only camera state), rank by linearity + angle
# range + reversal. Same method as find_hp_live.py, driven from the panel instead of a terminal script. ──
CAMYAW_STEP_DX = 60; CAMYAW_FWD = 8; CAMYAW_REV = 8; CAMYAW_RANGE = 400.0; CAMYAW_EPS = 1e-4
_CAMYAW_INIT = ("()=>{const f=new Float32Array(HEAPU8.buffer),n=f.length,R=%f;const addr=[],last=[];"
                "for(let i=0;i<n;i++){const v=f[i];if(v!==0&&Number.isFinite(v)&&v>=-R&&v<=R){addr.push(i*4);last.push(v);}}"
                "window.__yc={addr:addr,last:last,series:null};return addr.length;}") % CAMYAW_RANGE
_CAMYAW_STEP = ("()=>{const f=new Float32Array(HEAPU8.buffer),yc=window.__yc,R=%f,EPS=%g,hs=!!yc.series;"
                "const na=[],ns=[],nl=[];for(let i=0;i<yc.addr.length;i++){const a=yc.addr[i],cur=f[a>>2],prev=yc.last[i];"
                "if(!Number.isFinite(cur)||cur<-R||cur>R)continue;if(Math.abs(cur-prev)<EPS)continue;"
                "na.push(a);const s=hs?yc.series[i]:[prev];s.push(cur);ns.push(s);nl.push(cur);}"
                "yc.addr=na;yc.series=ns;yc.last=nl;return na.length;}") % (CAMYAW_RANGE, CAMYAW_EPS)
_CAMYAW_DUMP = "()=>({addr:window.__yc.addr,series:window.__yc.series})"
_CAMYAW_REV = "()=>{const f=new Float32Array(HEAPU8.buffer);return window.__yc.addr.map(a=>f[a>>2]);}"


def _cam_right_drag(act, w, h, dx):
    cx, cy = w * 0.5, h * 0.5
    act.mouse.move(cx, cy); act.mouse.down(button="right")
    act.mouse.move(cx + dx, cy, steps=6); act.mouse.up(button="right")
    time.sleep(0.25)


# read an f32 via a pointer chain {root, offs} — used to VERIFY a resolved chain reads the moving yaw
_CAMYAW_CHAIN_READ = ("(a)=>{const dv=new DataView(HEAPU8.buffer),N=HEAPU8.buffer.byteLength;"
                      " let p=dv.getUint32(a.root,true); if(!p||p<0x10000||p>=N) return null;"
                      " const o=a.offs; for(let i=0;i<o.length-1;i++){ p=dv.getUint32(p+o[i],true); if(!p||p<0x10000||p>=N) return null; }"
                      " const ad=p+o[o.length-1]; if(ad<0||ad+4>N) return null; return dv.getFloat32(ad,true);}")


CAMYAW_STATIC = 0x130000   # TRUE static data-section ceiling: module globals live here (controller ptr @
                           # 0x12C3BC, camera globals @ ~0x59xxx). The earlier 0x500000 wrongly included the
                           # STACK region (~1.5MB), whose values are transient → coincidental "roots" that
                           # differ every session. A pointer HELD at an address below this IS reload-stable.
# Low-data-section pointer scan: find a genuine static global H (< STATIC) that POINTS INTO the yaw's object,
# i.e. u32(H) <= t < u32(H)+W. W is wide (64KB) because the yaw sits deep inside the (large) camera object.
# Reload-stable read: p=u32(H); yaw=f32(p + (t - p)). Returns candidates (smallest offset first = nearest base).
_CAMYAW_RESOLVE = """(t)=>{
  const dv=new DataView(HEAPU8.buffer), N=dv.byteLength|0, W=65536, STATIC=%d;
  const found=[];
  for(let a=0x1000; a+4<=STATIC; a+=4){ const p=dv.getUint32(a,true);
    if(p>0x10000 && p<N && t>=p && t<p+W){ found.push({root:a, offs:[(t-p)|0], depth:1}); if(found.length>=64) break; } }
  found.sort((x,y)=>x.offs[0]-y.offs[0]);   // smallest offset first (likeliest true object base)
  return {ok:found.length>0, chains:found};
}""" % CAMYAW_STATIC

# CONTROLLER-ROOTED resolver: BFS the controller's (@1230588, PROVEN reload-stable) pointer graph and find
# objects that CONTAIN the target yaw address. The chain root=1230588 + fixed offsets survives reloads (it
# re-dereferences from the stable controller each read). This is the reliable anchor (unlike coincidental
# static values). Read model: p=u32(1230588); for off in offs[:-1]: p=u32(p+off); yaw=f32(p+offs[-1]).
_CAMYAW_RESOLVE_CTRL = """(t)=>{
  const dv=new DataView(HEAPU8.buffer), N=dv.byteLength|0, ok=x=>x>0x10000&&x<N;
  const root=1230588, c=dv.getUint32(root,true); if(!ok(c)) return {ok:false};
  const OBJ=4096, DEPTH=6, MAXN=8000; const found=[]; const seen=new Set([c]);
  let level=[[c,[]]]; let count=1;
  for(let d=0; d<DEPTH && found.length<12; d++){
    const next=[];
    for(const nd of level){ const obj=nd[0], path=nd[1];
      if(t>=obj && t<obj+OBJ){ found.push({root:root, offs:path.concat([(t-obj)|0]), depth:d}); if(found.length>=12) break; }
      for(let o=0;o+4<=OBJ;o+=4){ const p=dv.getUint32(obj+o,true);
        if(ok(p) && !seen.has(p) && count<MAXN){ seen.add(p); next.push([p, path.concat([o])]); count++; } }
    }
    if(found.length>=12) break;
    level=next; if(!level.length) break;
  }
  found.sort((a,b)=>a.depth-b.depth);
  return {ok:found.length>0, chains:found};
}"""

# RE-DERIVED camera globals (data-section, reload-stable): globals READ by the camera view-setup func 480
# and the camera update func (~line 1056352) in full.wat. These hold the camera/render singleton pointers.
# Testing ONLY these (vs scanning all memory) avoids coincidences. For each, chain = root=G, offs=[yaw-*(G)].
CAMYAW_GLOBALS = [365160, 362768, 364956, 364880, 364960, 365164, 365168, 1212032, 362772, 364884]
# Chain via a candidate global: p=u32(G); if the yaw address is inside [p, p+64KB) → offs=[yaw-p]. Wider
# window than the blind resolver (the yaw sits deep in the camera object). Returns all matching globals.
_CAMYAW_RESOLVE_GLOBALS = ("(a)=>{const dv=new DataView(HEAPU8.buffer),N=dv.byteLength|0,t=a.t,W=65536;"
    "const out=[];for(const g of a.globals){ if(g+4>N) continue; const p=dv.getUint32(g,true);"
    " if(p>0x10000 && p<N && t>=p && t<p+W){ out.push({root:g, offs:[(t-p)|0], depth:1}); } }"
    " return {ok:out.length>0, chains:out};}")


def run_camera_yaw_scan(act, ctrl):
    """Differential scan for the live camera yaw. Pushes progress + a ranked result table to the RE panel
    via window.__findYawSet. Blocks the idle loop ~10s (user-initiated one-shot). Camera rotation only —
    no packets/commands, safe on any account."""
    import math
    def push(stat, out=None):
        try: ctrl.evaluate("(a)=>{ if (window.__findYawSet) window.__findYawSet(a[0], a[1]); }", [stat, out])
        except Exception: pass
    try:
        if not act.evaluate("typeof HEAPU8 !== 'undefined'"):
            push("HEAPU8 not ready — is the game in-world?", ""); return
        vp = act.evaluate("()=>({w:innerWidth,h:innerHeight})"); w, h = vp["w"], vp["h"]
        n = act.evaluate(_CAMYAW_INIT)
        push(f"scanning… {n} candidates (stand still, hands off the mouse)")
        for k in range(CAMYAW_FWD):
            _cam_right_drag(act, w, h, CAMYAW_STEP_DX)
            n = act.evaluate(_CAMYAW_STEP)
            push(f"forward rotate {k+1}/{CAMYAW_FWD} · {n} floats tracking")
            if n == 0:
                push("nothing tracked the rotation — is the camera free (not fixed)? re-try.", ""); return
        dump = act.evaluate(_CAMYAW_DUMP)
        for k in range(CAMYAW_REV):                          # rotate back to confirm reversal
            _cam_right_drag(act, w, h, -CAMYAW_STEP_DX)
        rev = act.evaluate(_CAMYAW_REV)
        addr, series = dump["addr"], dump["series"]
        rows = []
        for i, a in enumerate(addr):
            s = series[i]
            if len(s) < 3:
                continue
            deltas = [s[j+1] - s[j] for j in range(len(s)-1)]
            md = sum(deltas) / len(deltas)
            if abs(md) < CAMYAW_EPS:
                continue
            var = sum((d - md) ** 2 for d in deltas) / len(deltas)
            lin = math.sqrt(var) / (abs(md) + 1e-9)          # 0 = perfectly linear (yaw); high = sinusoidal
            rng = max(s) - min(s)
            rok = (rev[i] - s[-1]) * md < 0                  # reversed when we rotated back
            band = "rad" if rng <= 7.5 else ("deg" if rng <= 400 else "?")
            rows.append((lin, a, s[0], s[-1], rev[i], rng, band, rok))
        # The YAW is a REVERSING candidate that changed LINEARLY (equal per-step deltas → low `lin`) over a
        # meaningful angular swing (not the tiny secondary/smoothed angles, not >full-circle noise). Rank
        # those clean ones by linearity; only fall back to the raw list if none qualify.
        clean = [r for r in rows if r[7] and 15.0 <= r[5] <= 370.0 and r[0] < 2.0]
        clean.sort(key=lambda r: r[0])
        show = clean if clean else sorted(rows, key=lambda r: (not r[7], r[0]))
        if not show:
            push("no camera-tracking float found — try again (free camera, bigger rotation).", ""); return
        lines = [("CLEAN yaw candidates (linear + reverses):" if clean else "no clean yaw — raw list:"),
                 f"{'address':>12} {'start':>9} {'end':>9} {'afterRev':>9} {'range':>7} band  lin  rev",
                 "-" * 66]
        for lin, a, v0, vl, vr, rng, band, rok in show[:12]:
            lines.append(f"  0x{a:08x} {v0:9.3f} {vl:9.3f} {vr:9.3f} {rng:7.3f} {band:>3} {lin:5.2f} {'YES' if rok else ' . '}")
        best = show[0]
        best_addr = best[1]

        def _readf(a):
            try: return act.evaluate("(x)=>{const dv=new DataView(HEAPU8.buffer);if(x<0||x+4>HEAPU8.buffer.byteLength)return null;return dv.getFloat32(x,true);}", a)
            except Exception: return None

        def _readchain(root, offs):
            try: return act.evaluate(_CAMYAW_CHAIN_READ, {"root": root, "offs": offs})
            except Exception: return None

        def _tracks(reader):
            """reader() -> current value. True if it changes when the camera rotates (then rotates back)."""
            v0 = reader()
            if v0 is None: return False
            _cam_right_drag(act, w, h, CAMYAW_STEP_DX)
            v1 = reader()
            _cam_right_drag(act, w, h, -CAMYAW_STEP_DX)
            return v1 is not None and abs(v1 - v0) >= 0.5

        def _verify_chains(resolve_js, addr, arg=None):
            """Return the first (root,offs,depth) chain from resolve_js(arg or addr) whose read matches the
            address AND tracks rotation, else None."""
            try:
                r = act.evaluate(resolve_js, addr if arg is None else arg)
            except Exception:
                r = None
            if not (r and r.get("chains")):
                return None
            direct = _readf(addr)
            for ch in r["chains"]:
                root, offs = ch["root"], ch["offs"]
                v0 = _readchain(root, offs)
                if v0 is None or direct is None or abs(v0 - direct) > 0.5:
                    continue                                  # chain reads a different slot → false
                if _tracks(lambda: _readchain(root, offs)):   # and it moves with the camera → real
                    return (root, offs, ch["depth"])
            return None

        # Try three RELOAD-STABLE sources, each verified by rotation: (1) RE-derived camera globals (from
        # disassembling the camera view-setup func 480), (2) controller-rooted BFS (@1230588), (3) a scan of
        # the TRUE low data-section (< 0x130000, below the stack — genuine static globals, not coincidences).
        # All three root at real static globals with fixed offsets → survive reloads.
        chosen, via = None, ""
        push("checking RE-derived camera globals…")
        for cand in show[:4]:
            v = _verify_chains(_CAMYAW_RESOLVE_GLOBALS, cand[1], {"t": cand[1], "globals": CAMYAW_GLOBALS})
            if v: chosen, via = (v[0], v[1], cand[1], v[2]), "RE camera global"; break
        if not chosen:
            push("checking controller-rooted chain…")
            for cand in show[:4]:
                v = _verify_chains(_CAMYAW_RESOLVE_CTRL, cand[1])
                if v: chosen, via = (v[0], v[1], cand[1], v[2]), "controller-rooted"; break
        if not chosen:
            push("scanning low data-section for a stable pointer…")
            for cand in show[:4]:
                v = _verify_chains(_CAMYAW_RESOLVE, cand[1])
                if v: chosen, via = (v[0], v[1], cand[1], v[2]), "data-section global"; break

        if chosen:
            root, offs, a, depth = chosen
            re_sense.set_camera_yaw_source(root=root, offs=offs, addr=a, persist=True)   # reload-stable → save
            offs_s = "+".join(hex(o) for o in offs)
            chain_msg = (f"VERIFIED RELOAD-STABLE CHAIN ({via}): root=0x{root:08x} offs=[{offs_s}] (depth {depth}). "
                         f"Saved to camera_calib.json — works every session, no re-press. Tell the agent to hard-wire it.")
            best_addr = a
        else:
            re_sense.set_camera_yaw_source(addr=best_addr)   # no stable chain → session address only
            chain_msg = "no reload-stable chain found — using the session ADDRESS (re-press after a reload)."

        # FINAL PROOF: read via the EXACT path the arrow uses (re_sense.read_camera_yaw_deg), rotate, read
        # again — so the panel shows definitively whether the wired source tracks the camera.
        y0 = re_sense.read_camera_yaw_deg(act)
        _cam_right_drag(act, w, h, CAMYAW_STEP_DX)
        y1 = re_sense.read_camera_yaw_deg(act)
        _cam_right_drag(act, w, h, -CAMYAW_STEP_DX)
        works = (y0 is not None and y1 is not None and abs(y1 - y0) >= 0.5)
        proof = (f"PROOF: yaw read {y0:.1f}° → {y1:.1f}° on rotate → " + ("✅ TRACKS (arrow works)" if works
                 else "❌ NOT tracking — source stale; press again")) if (y0 is not None and y1 is not None) \
                else "PROOF: yaw unreadable — press again (in-world, free camera)."
        push(f"done — {'yaw wired ✓' if works else 'yaw NOT working ✗'} (0x{best_addr:08x}, {best[6]})",
             "\n".join(lines) + "\n\n" + chain_msg + "\n\n" + proof +
             "\n\nIf it TRACKS, walk a little so the arrow aligns to the map (learns zero/handedness).")
    except Exception as e:
        push(f"scan error: {e}", "")


_find_yaw_last = 0
def update_find_yaw(act, ctrl):
    """Run the camera-yaw scan when the RE-tab button was pressed (a new window.__findYawReq)."""
    global _find_yaw_last
    try:
        req = ctrl.evaluate("window.__findYawReq || 0")
    except Exception:
        return
    if req and req != _find_yaw_last:
        _find_yaw_last = req
        run_camera_yaw_scan(act, ctrl)


_mem_act_gen = None
_mem_attack = {"lo": None, "hi": None, "until": 0.0, "last": 0.0}   # sustained-attack target (None = idle)
MEM_MELEE_RANGE = 15   # world-units: only fire the attack when the target is within this (in range) — firing
                       # out of range makes the game WALK toward it client-side, which the server resets.
_re_active = False   # is the RE / Memory tab open (cached by update_mem_action for the idle-loop pacing)


def _mem_act_msg(ctrl, m):
    try: ctrl.evaluate("(x)=>{ if (window.__memActSet) window.__memActSet(x); }", m)
    except Exception: pass


# NOTE: `click_nav` (projection-based smooth go-to/attack) is PARKED — its world→screen projection is
# camera-relative, so a single cached calibration goes stale the moment the camera zoom/tilt/angle changes
# mid-play and every go-to/attack then silently misses. The panel now uses the camera-INDEPENDENT memory
# path instead (re_sense.move_to_server + re_sense.goto_and_attack): teleport to the world point / onto the
# mob, then a centre-out `curattack` hover-scan real-clicks SELECT+ATTACK. Reliable + no calibration.


def update_mem_action(act, ctrl):
    """🎯/🚶/⚔ RE / Memory tab per-mob actions. SELECT = memory highlight (+2656). GO-TO + ATTACK use
    click_nav: a memory-calibrated world->screen projection lets us REAL-click the target, so the char
    walks/attacks via the game's own path — smooth + click-like, server AND client (no teleport). ATTACK
    cursor-confirms the mob (`curattack`), clicks to select then attack; the game auto-attacks to death.
    ⚠️ sends real input/commands — throwaway only. Blocking for the action duration (fine on the idle tab)."""
    global _mem_act_gen, _re_active
    try:
        _re_active = bool(ctrl.evaluate("!!window.__reActive"))
        if not _re_active:
            _mem_attack["lo"] = None                       # leaving the tab stops any sustained attack
            return
        a = ctrl.evaluate("window.__memAct || {gen:0}")
        gen = a.get("gen")
        if gen and gen != _mem_act_gen:                    # a NEW button press
            _mem_act_gen = gen
            actname = a.get("act")
            if actname == "goto":
                # 🚶 walk to a chosen world point — SERVER-ACCEPTED memory move (camera-independent, reliable;
                # small-hop teleport so client+server agree — NO fragile world→screen calibration).
                try:
                    gx, gz = float(a.get("x")), float(a.get("z"))
                    gy = a.get("y")
                    if gy is None:                              # map-click carries no height — use the player's
                        p = re_sense._pos_xyz(act); gy = (p[1] if p else 100.0)
                    else:
                        gy = float(gy)
                    _mem_act_msg(ctrl, f"🚶 walking to {int(gx)},{int(gz)}…")
                    r = re_sense.move_to_server(act, gx, gy, gz, status=lambda s: _mem_act_msg(ctrl, s))
                    _mem_act_msg(ctrl, ("🚶 " + str(r.get("msg", ""))) + (" ✓" if r.get("ok") else " ✗"))
                except Exception as e:
                    _mem_act_msg(ctrl, f"🚶 goto failed: {e}")
            elif actname == "attack":
                # ⚔ go-to + attack: memory move into melee range, then the game's OWN attack (func 4148) — no
                # vision, no projection, no calibration. The game auto-attacks to death once the first hit lands.
                try:
                    lo, hi = int(a.get("idLo") or 0), int(a.get("idHi") or 0)
                    _mem_act_msg(ctrl, "⚔ engaging…")
                    ok = re_sense.goto_and_attack(act, lo, hi, status=lambda s: _mem_act_msg(ctrl, s))
                    _mem_act_msg(ctrl, "⚔ engaged — auto-attacking" if ok else "⚔ couldn't engage (mob moved / unreachable) — retry")
                except Exception as e:
                    _mem_act_msg(ctrl, f"⚔ attack failed: {e}")
            else:                                              # select = SERVER-side (craft+send select packet)
                # so an in-game skill actually lands. From-scratch packet, NO teleport / no trigger-click /
                # no movement (tail + sequence are not server-validated). Also writes +2656 for the client UI.
                lo, hi = int(a.get("idLo") or 0), int(a.get("idHi") or 0)
                try:
                    ok = re_sense.send_select(act, lo, hi, status=lambda s: _mem_act_msg(ctrl, s))
                    if not ok:
                        _mem_act_msg(ctrl, "🎯 select FAILED")
                except Exception as e:
                    _mem_act_msg(ctrl, f"🎯 select error: {e}")
    except Exception:
        pass


def update_header(act, ctrl, ocr=True):
    """Push the header bar: FAST PIXEL row (cheap HP/MP/Exp bar scans) + the OCR row (exact text). When
    ocr=True (IDLE) the heavy Tesseract OCR runs inline (blocking is fine when nothing's grinding). On
    a hot path pass ocr=False: the fast pixel row is pushed immediately and the OCR is offloaded to a
    BACKGROUND thread (so it never blocks the loop) whose result is pushed when ready — this keeps the
    EXP graph's precise OCR Exp alive during a run. The MEM row (cheap WASM reads) refreshes on its own
    fast MEM_EVERY throttle; the expensive pixel/OCR + screenshot is throttled to once per HEADER_EVERY."""
    global _last_header, _last_mem
    now = time.time()
    if now - _last_mem >= MEM_EVERY:                   # MEM row: deobfuscated WASM HP/MP/target — CHEAP,
        _last_mem = now                                # so it refreshes ~10×/s, NOT gated by the OCR throttle
        _push_mem_status(act, ctrl)
    if now - _last_header < HEADER_EVERY:              # pixel/OCR row + screenshot: expensive → slow throttle
        return
    _last_header = now
    try:
        img = gl.grab(act)
        if ocr:
            s = gl.read_status(img, ocr=True)          # IDLE: full OCR inline; feeds both Exp series
            s["feed"] = "both"
            ctrl.evaluate("(s)=>{ if (window.__statusSet) window.__statusSet(s); }", s)
            return
        # RUNNING hot path: fast pixel row now, heavy OCR on the worker thread.
        s = gl.read_status(img, ocr=False)
        s["feed"] = "px"                               # JS: update the pixel row + pixel-Exp graph only
        ctrl.evaluate("(s)=>{ if (window.__statusSet) window.__statusSet(s); }", s)
        _exp_ocr.ensure()
        _exp_ocr.submit(img)                           # reuse the frame we just grabbed — no extra shot
        so = _exp_ocr.take_fresh()
        if so is not None:                             # a fresh OCR landed → push it (OCR row + graph)
            ctrl.evaluate("(s)=>{ if (window.__statusSet) window.__statusSet(s); }", so)
    except Exception:
        pass


_RE_SCAN_JS = """(a)=>{
  const lo=a.lo, hi=a.hi, mlo=a.mlo, mhi=a.mhi, cap=a.cap;
  const buf=HEAPU8.buffer, f=new Float32Array(buf), ii=new Int32Array(buf);
  const out=[]; const n=f.length-4;
  for (let i=0;i<n;i++){ const v=f[i];
    if (v>=lo && v<=hi){ const m=ii[i+2]; if (m>=mlo && m<=mhi){ out.push([i*4, m]); if(out.length>=cap) break; } } }
  return out;
}"""
# RECORDING value-scan: SEED candidate f32 addresses, then POLL them every idle pass into a per-candidate
# rolling RING of recent HP% buckets (so timing-forgiving). Apply an HP -> FILTER keeps candidates whose
# RECENT history hit that HP% (within slack). No OCR/pixel; the user's typed HP is the only oracle.
_REC_K = 12                # ring slots of history per candidate (~last _REC_K idle polls ≈ several seconds)
_REC_SEED_JS = """(a)=>{ const lo=a.lo,hi=a.hi,cap=a.cap,K=a.K; const f=new Float32Array(HEAPU8.buffer);
  const out=[]; const n=f.length; for(let i=0;i<n;i++){ const v=f[i]; if(v>=lo&&v<=hi){ out.push(i*4); if(out.length>=cap) break; } }
  window.__reCands=out; window.__reHist=new Uint8Array(out.length*K).fill(255); window.__reHead=0; window.__reK=K;
  return out.length; }"""
_REC_POLL_JS = """()=>{ const c=window.__reCands, hist=window.__reHist, K=window.__reK;
  if(!c||!hist) return 0; const dv=new DataView(HEAPU8.buffer); const head=window.__reHead;
  for(let i=0;i<c.length;i++){ const v=dv.getFloat32(c[i],true); let b=255;
    if(v>=0 && v<=1.05){ b=Math.round(v*100); if(b<0)b=0; if(b>100)b=100; } hist[i*K+head]=b; }
  window.__reHead=(head+1)%K; return c.length; }"""
_REC_FILTER_JS = """(a)=>{ const tb=a.b, sl=a.sl; const c=window.__reCands, hist=window.__reHist, K=window.__reK;
  if(!c||!hist) return 0; const nc=[], keep=[];
  for(let i=0;i<c.length;i++){ let hit=false;
    for(let j=0;j<K;j++){ const b=hist[i*K+j]; if(b!==255 && b>=tb-sl && b<=tb+sl){ hit=true; break; } }
    if(hit){ nc.push(c[i]); keep.push(i); } }
  const nh=new Uint8Array(nc.length*K);
  for(let k=0;k<keep.length;k++){ const s=keep[k]*K; for(let j=0;j<K;j++) nh[k*K+j]=hist[s+j]; }
  window.__reCands=nc; window.__reHist=nh; window.__reHead=0; return nc.length; }"""
_REC_SAMPLE_JS = """()=>{ const dv=new DataView(HEAPU8.buffer); const c=window.__reCands||[];
  return c.slice(0,16).map(a=>[a, dv.getFloat32(a,true)]); }"""
# live monitor: read the CURRENT value of up to `cap` candidates -> [[addr, ratio], ...] for on-screen bars
_REC_MON_JS = """(cap)=>{ const dv=new DataView(HEAPU8.buffer); const c=window.__reCands||[];
  const n=Math.min(c.length,cap); const out=[]; for(let i=0;i<n;i++) out.push([c[i], dv.getFloat32(c[i],true)]); return out; }"""
RE_MON_MAX = 48            # only stream live bars once candidates have narrowed to <= this many
RE_MON_EVERY = 0.35        # seconds between monitor refreshes (RE tab only — not a grind hot path)
RE_HP_MAXHP = 315          # multiply the ratio by this to show absolute HP (display only)
RE_HP_SEED_TOL = 0.08      # WIDE seed window (first Apply) so a slightly-outdated first reading still catches it
RE_HP_SLACK = 2            # ±% slack when matching a typed HP against a candidate's recent history
_rehp = {"apply_gen": None, "reset_gen": None, "count": None, "last_t": 0.0, "last_poll": 0.0,
         "last_mon": 0.0, "lines": []}


def _re_push(ctrl, stat, out):
    try:
        ctrl.evaluate("(a)=>{ if (window.__reHpSet) window.__reHpSet(a[0], a[1]); }", [stat, out])
    except Exception:
        pass


def update_re_hp(act, ctrl):
    """🩸 RE find-HP (RECORDING manual value-scan). After the first Apply seeds, it POLLS every candidate's
    value each idle pass into a rolling history — so when you type an HP it keeps candidates that hit that
    HP ANY TIME in the recent window (timing-forgiving; you needn't be fast). No OCR/pixel — you're the
    oracle. ↺ Reset clears; then 🔗 Find pointer."""
    try:
        if not ctrl.evaluate("!!window.__reActive"):
            return
        rst = ctrl.evaluate("window.__reReset || {gen:0}")
        ap = ctrl.evaluate("window.__reApply || {frac:0,gen:0}")
        if not act.evaluate("typeof HEAPU8 !== 'undefined'"):
            _re_push(ctrl, "game memory (HEAPU8) not found — is the game fully loaded?", None); return
    except Exception:
        return
    if rst.get("gen") and rst.get("gen") != _rehp["reset_gen"]:      # ↺ Reset pressed
        _rehp.update(reset_gen=rst.get("gen"), count=None, lines=[])
        try: act.evaluate("window.__reCands=[]; window.__reHist=null")
        except Exception: pass
        try: ctrl.evaluate("(a)=>{ if(window.__reMonSet) window.__reMonSet(null, []); }", None)
        except Exception: pass
    # keep RECORDING candidate trajectories every pass while seeded (this is the timing-forgiveness)
    if _rehp["count"] and _rehp["count"] > 0:
        try:
            act.evaluate(_REC_POLL_JS)
        except Exception:
            pass
        # LIVE MONITOR: once narrowed enough, stream each candidate's current value as an HP bar (with the
        # game's pixel-HP as the blue reference) so you can EYEBALL which address tracks real HP as you take
        # damage — no Apply needed. Throttled; RE tab only, so the extra screenshot never touches a grind loop.
        if _rehp["count"] <= RE_MON_MAX and time.time() - _rehp["last_mon"] > RE_MON_EVERY:
            _rehp["last_mon"] = time.time()
            try:
                rows = act.evaluate(_REC_MON_JS, RE_MON_MAX)
                try: px = gl.hp_fraction(gl.grab(act))
                except Exception: px = None
                ctrl.evaluate("(a)=>{ if(window.__reMonSet) window.__reMonSet(a[0], a[1]); }", [px, rows])
            except Exception:
                pass
    gen = ap.get("gen")
    if not gen or gen == _rehp["apply_gen"]:                          # only act on a NEW Apply
        # idle status refresh while recording
        if _rehp["count"] and time.time() - _rehp["last_t"] > 1.2:
            _rehp["last_t"] = time.time()
            _re_push(ctrl, f"recording… {_rehp['count']} candidates — change HP, then type a value + Apply", None)
        return
    _rehp["apply_gen"] = gen
    _rehp["last_t"] = time.time()
    frac = float(ap.get("frac") or 0.0)
    bucket = max(0, min(100, round(frac * 100)))
    try:
        if _rehp["count"] is None:                                   # first Apply -> SEED (wide, forgiving)
            n = act.evaluate(_REC_SEED_JS, {"lo": frac - RE_HP_SEED_TOL, "hi": frac + RE_HP_SEED_TOL,
                                            "cap": 800000, "K": _REC_K})
            _rehp["count"] = n
            note = " ⚠ CAPPED — pick a more distinctive HP, then ↺ Reset + Apply" if n >= 800000 else ""
            _rehp["lines"].append(f"SEED @ {frac*100:.0f}%: {n} candidates{note}")
            _re_push(ctrl, f"seeded {n} @ {frac*100:.0f}%{note} — now change HP and Apply more readings (timing is forgiving)",
                     "\n".join(_rehp["lines"][-18:]))
            return
        n = act.evaluate(_REC_FILTER_JS, {"b": bucket, "sl": RE_HP_SLACK})       # later Apply -> history FILTER
        sample = act.evaluate(_REC_SAMPLE_JS)
    except Exception:
        return
    _rehp["count"] = n
    _rehp["lines"].append(f"filter: HP {frac*100:.0f}% hit recently -> {n} left")
    out = "\n".join(_rehp["lines"][-18:])
    if n == 0:
        out += "\n\nLOST all — no candidate hit that HP in the recent window (typo, or too long since it\nwas at that HP, or realloc). ↺ Reset and redo. Apply readings within a few seconds of seeing them."
        _re_push(ctrl, "0 left — ↺ Reset and retry", out)
    elif n <= 12:
        # pick the survivor whose CURRENT value matches CURRENT HP (excludes noise/stale ones) and scan
        # its pointers RIGHT NOW (same pass — no click-delay, so the address can't reallocate first).
        try:
            px = gl.hp_fraction(gl.grab(act))
        except Exception:
            px = None
        live = [(a, v) for a, v in sample if px is not None and abs(v - px) <= 0.06]
        out += f"\n\n=== CONVERGED (current HP ~{px*100:.0f}%) ===\n" if px is not None else "\n\n=== CONVERGED ===\n"
        for a, v in sample:
            tag = "  <- matches current HP" if any(a == la for la, _ in live) else ""
            out += f"  @{a}  ratio={v:.4f}  hp~{v*RE_HP_MAXHP:.0f}{tag}\n"
        if live:
            try:
                controller = act.evaluate("(x)=>new DataView(HEAPU8.buffer).getUint32(x,true)", 1230588)
                out += "\n" + _re_pointer_report(act, controller, live[0][0])
                if len(live) > 1:
                    out += f"\n({len(live)} addresses match current HP — likely live + display copies)"
            except Exception:
                pass
            out += "\n\nCONFIRM: change HP and Apply once more — the controller+OFFSET that stays constant is the live pointer."
        else:
            out += "\n(no survivor matches current HP — noise or the entity reallocated; Apply 1-2 more distinct HP values)"
        _re_push(ctrl, f"CONVERGED to {n} — pointer chain below", out)
    else:
        _re_push(ctrl, f"{n} left — change HP, Apply another reading", out)


# ── RE: read HP via the entity REGISTRY @362748 (the real, realloc-proof method) ──────────────────
# The game keeps a live unordered_map<i64,entity> (lookup=func 208) at global 362748 (bucket array) /
# 362752 (bucket count). Every entity carries HP=f32@+308 × maxHP=i32@+316 (writer func 11585). Because
# we go THROUGH the registry (keyed by the player's stable i64 id), the read follows the entity even when
# it reallocates — the exact thing that defeated the value-scan. Node layout (from memread.py): next@+0,
# key i64@+8, entity ptr@+16.  Reads the map/cap globals itself (no extra round-trip).
_REG_WALK_JS = """()=>{
  const dv=new DataView(HEAPU8.buffer), heap=HEAPU8.buffer.byteLength;
  const map=dv.getUint32(362748,true), cap=dv.getUint32(362752,true), out=[];
  const ok=p=>p>0x10000&&p<heap;
  if(ok(map)&&cap>0&&cap<=5000000){
    for(let b=0;b<cap;b++){
      let node=dv.getUint32(map+b*4,true), guard=0;
      while(ok(node)&&guard++<100000){
        const kl=dv.getUint32(node+8,true), kh=dv.getUint32(node+12,true), val=dv.getUint32(node+16,true);
        if(ok(val)){ const m=dv.getInt32(val+316,true), r=dv.getFloat32(val+308,true);
          if(m>0&&m<100000&&r>=-0.01&&r<=1.5) out.push([kl,kh,val,m,r]); }
        node=dv.getUint32(node,true);
        if(out.length>20000) break;
      }
      if(out.length>20000) break;
    }
  }
  return {map:map, cap:cap, heap:heap, ents:out};
}"""
# DIAGNOSTIC: dump each entity's "interesting" fields (offset up to MAXOFF) so we can RE-locate HP after a
# patch shifts the struct. Reports offsets holding a plausible ratio f32 (0.02..1.0) or a plausible int
# (1..100000, HP-ish). Flags fields ≈ the current pixel HP (the player's HP ratio). No fixed HP offset.
_REG_DIAG_JS = """(a)=>{
  const dv=new DataView(HEAPU8.buffer), heap=HEAPU8.buffer.byteLength;
  const map=dv.getUint32(362748,true), cap=dv.getUint32(362752,true);
  const ok=p=>p>0x10000&&p<heap, out=[]; const MAXOFF=a.maxoff, LIMIT=a.limit, px=a.px;
  if(!ok(map)||cap<=0||cap>5000000) return {map:map, cap:cap, entries:[]};   // garbage cap → don't hang the renderer
  for(let b=0;b<cap && out.length<LIMIT;b++){
    let node=dv.getUint32(map+b*4,true), guard=0;
    while(ok(node)&&guard++<100000 && out.length<LIMIT){
      const kl=dv.getUint32(node+8,true), val=dv.getUint32(node+16,true);
      if(ok(val)){
        const ratios=[], ints=[]; let hpMatch=-1;
        for(let o=0;o+4<=MAXOFF && val+o+4<=heap;o+=4){
          const iv=dv.getInt32(val+o,true), fv=dv.getFloat32(val+o,true);
          if(fv>0.02 && fv<=1.0001){ ratios.push([o, Math.round(fv*1000)/1000]);
            if(px>0 && Math.abs(fv-px)<=0.06 && hpMatch<0) hpMatch=o; }
          if(iv>0 && iv<100000) ints.push([o, iv]);
        }
        out.push([val, kl, hpMatch, ratios, ints]);
      }
      node=dv.getUint32(node,true);
    }
  }
  return {map:map, cap:cap, entries:out};
}"""
# FIND the HP offset dynamically (patch-proof): scan ALL entities, and for every 4-byte offset tally how
# many entities hold a plausible ratio there (f32 in 0.02..1.0) and how many DISTINCT values occur. The HP
# field is the most-VARIED 0..1 field (every creature at its own HP%); constant fields (scale/alpha=1.0)
# have ~1 distinct value and rank last. Also collect entities whose value ≈ current pixel HP (= the player).
_REG_FINDHP_JS = """(a)=>{
  const dv=new DataView(HEAPU8.buffer), heap=HEAPU8.buffer.byteLength;
  const map=dv.getUint32(362748,true), cap=dv.getUint32(362752,true);
  const ok=p=>p>0x10000&&p<heap; const MAXOFF=a.maxoff, px=a.px, ents=[];
  if(!ok(map)||cap<=0||cap>5000000) return {nents:0, stats:[], near:[]};   // garbage cap → don't hang the renderer
  for(let b=0;b<cap;b++){ let node=dv.getUint32(map+b*4,true), g=0;
    while(ok(node)&&g++<100000){ const kl=dv.getUint32(node+8,true), kh=dv.getUint32(node+12,true), val=dv.getUint32(node+16,true);
      if(ok(val)) ents.push([val,kl,kh]); node=dv.getUint32(node,true); if(ents.length>4000) break; }
    if(ents.length>4000) break; }
  const nOff=(MAXOFF/4)|0, stats=[], near=[];
  for(let oi=0;oi<nOff;oi++){ const o=oi*4; let cnt=0, npx=0; const seen={};
    for(let e=0;e<ents.length;e++){ const val=ents[e][0]; if(val+o+4>heap) continue;
      const fv=dv.getFloat32(val+o,true);
      if(fv>0.02 && fv<=1.0001){ cnt++; seen[Math.round(fv*50)]=1;
        if(px>0 && Math.abs(fv-px)<=0.05){ npx++; if(near.length<60) near.push([o, ents[e][1], ents[e][2], Math.round(fv*1000)/1000]); } } }
    let d=0; for(const k in seen) d++;
    if(cnt>=3 && d>=4) stats.push([o, cnt, d, npx]);
  }
  stats.sort((x,y)=>y[2]-x[2]);
  return {nents:ents.length, stats:stats.slice(0,16), near:near};
}"""
# Look up ONE entity by its i64 registry key -> [entity, ratio@hpoff] or null (re-walks, so realloc-safe).
_REG_READ_JS = """(a)=>{
  const dv=new DataView(HEAPU8.buffer), heap=HEAPU8.buffer.byteLength;
  const map=dv.getUint32(362748,true), cap=dv.getUint32(362752,true);
  const kl=a.kl, kh=a.kh, hpoff=a.hpoff, ok=p=>p>0x10000&&p<heap;
  if(!ok(map)||cap<=0||cap>5000000) return null;
  for(let b=0;b<cap;b++){
    let node=dv.getUint32(map+b*4,true), guard=0;
    while(ok(node)&&guard++<100000){
      if(dv.getUint32(node+8,true)===kl && dv.getUint32(node+12,true)===kh){
        const val=dv.getUint32(node+16,true);
        return ok(val) ? [val, dv.getFloat32(val+hpoff,true)] : null;
      }
      node=dv.getUint32(node,true);
    }
  }
  return null;
}"""
_rereg = {"btn_gen": None, "reset_gen": None, "kl": None, "kh": None, "hp_off": None, "last": 0.0}


def _re_reg_push(ctrl, stat, out):
    try:
        ctrl.evaluate("(a)=>{ if(window.__reRegSet) window.__reRegSet(a[0], a[1]); }", [stat, out])
    except Exception:
        pass


def _re_reg_live(ctrl, ratio, ref, label):
    try:
        ctrl.evaluate("(a)=>{ if(window.__reRegLiveSet) window.__reRegLiveSet(a[0], a[1], a[2]); }",
                      [ratio, ref, label])
    except Exception:
        pass


def update_re_reg(act, ctrl):
    """🧬 Read HP via the entity registry (@362748) — the realloc-proof method. 🔎 Find me walks the
    registry, locks the entity whose HP ratio matches the pixel HP as the player (by its i64 key), then
    tracks it live via registry[key] each pass so the green bar follows real HP through reallocation."""
    try:
        if not ctrl.evaluate("!!window.__reActive"):
            return
        btn = ctrl.evaluate("window.__reReg || {gen:0}")
        rst = ctrl.evaluate("window.__reRegReset || {gen:0}")
        if not act.evaluate("typeof HEAPU8 !== 'undefined'"):
            return
    except Exception:
        return
    if rst.get("gen") and rst.get("gen") != _rereg["reset_gen"]:        # ↺ Unlock
        _rereg.update(reset_gen=rst.get("gen"), kl=None, kh=None, hp_off=None)
        _re_reg_live(ctrl, None, None, None)
    gen = btn.get("gen")
    if gen and gen != _rereg["btn_gen"]:                                # 🔎 Find me — detect HP offset + lock
        _rereg["btn_gen"] = gen
        try:
            px = gl.hp_fraction(gl.grab(act))
            find = act.evaluate(_REG_FINDHP_JS, {"maxoff": 1400, "px": px if px is not None else 0})
        except Exception as e:
            _re_reg_push(ctrl, f"scan failed: {e}", None); return
        nents = int(find.get("nents") or 0)
        stats = find.get("stats") or []      # [[off, count, distinct, npx], ...] most-varied first
        near = find.get("near") or []        # [[off, kl, kh, ratio], ...] entities ≈ pixel HP
        if not nents:
            _re_reg_push(ctrl, "registry read 0 entities — in-world & fully loaded?", None); return
        pxs = f"{px*100:.0f}%" if px is not None else "?"
        lines = [f"scanned {nents} entities @ registry 362748 (pixel HP={pxs})",
                 "HP-offset candidates (most-varied 0..1 field ≈ HP; constants like 1.0 excluded):"]
        for o, c, d, npx in stats[:8]:
            mark = "  <-- has an entity ≈ your HP" if npx >= 1 else ""
            lines.append(f"  +{o}: {c} ents, {d} distinct values, {npx} ≈ your HP{mark}")
        # choose the player: among entities ≈ pixel HP, prefer the one whose offset is the most common HP-like
        # field (highest entity-count in stats), then the ratio closest to px.
        cnt_by_off = {o: c for o, c, d, npx in stats}
        scored = sorted(((cnt_by_off.get(o, 0), -abs(r - (px or 0)), o, kl, kh, r) for o, kl, kh, r in near),
                        reverse=True)
        if px is not None and scored:
            c, _nd, o, kl, kh, r = scored[0]
            _rereg.update(kl=kl, kh=kh, hp_off=o, last=0.0)
            lines.append(f"\nLOCKED YOU: HP@+{o}, key=0x{kh:08x}{kl:08x}, ratio={r:.3f} ({r*100:.0f}%).")
            lines.append("Tracking live via registry[key]+" + str(o) + " — TAKE DAMAGE: if the green LIVE bar")
            lines.append("follows your real HP, HP-sensing is solved. If it doesn't track, ↺ Unlock & retry.")
            if len(scored) > 1:
                lines.append(f"({len(scored)} entities were ≈ your HP; picked the one on the main HP field. If")
                lines.append(" it's wrong, change HP a lot and Find me again — only the true field tracks.)")
            _re_reg_push(ctrl, f"LOCKED HP@+{o} — take damage to confirm the green bar", "\n".join(lines))
        else:
            lines.append("\nNo entity was within 5% of your pixel HP. Either pixel-HP is miscalibrated, or you're")
            lines.append("at an ambiguous HP. Get to a clear ~50% and Find me again; or paste this list.")
            _re_reg_push(ctrl, "no ≈HP entity — retry at a clear partial HP", "\n".join(lines))
        return
    if _rereg["kl"] is not None and time.time() - _rereg["last"] > 0.35:  # live track while locked
        _rereg["last"] = time.time()
        try:
            res = act.evaluate(_REG_READ_JS, {"kl": _rereg["kl"], "kh": _rereg["kh"], "hpoff": _rereg["hp_off"]})
            try: px = gl.hp_fraction(gl.grab(act))
            except Exception: px = None
        except Exception:
            res, px = None, None
        if res:
            val, r = res
            _re_reg_live(ctrl, r, px, f"registry HP {r*100:.1f}% @+{_rereg['hp_off']} (LIVE)")
        else:
            _re_reg_live(ctrl, None, None, None)
            _re_reg_push(ctrl, "lost the entity (id left the registry?) — ↺ Unlock and Find me again", None)


# ── RE: pin HP by DAMAGE-CORRELATION, bounded to the player's object graph (rooted at controller@1230588) ──
# A global value-scan drowns in coincidences; this walks ONLY the objects reachable from the controller in
# a few pointer hops, snapshots their bytes, and after you take damage finds the field that went HP1->HP2.
# Rooted at the player, so the match IS the mover HP — and its (object,offset) maps the pointer chain.
_HPC_WIN = 1024
_HPC_MAX = 1500
_HPC_BFS_JS = """(a)=>{
  const dv=new DataView(HEAPU8.buffer), heap=HEAPU8.buffer.byteLength;
  const ok=p=>p>0x10000&&p<heap-a.win;
  const root=dv.getUint32(a.root,true);
  if(!ok(root)) return {err:'controller ptr invalid: '+root};
  const seen=new Set(), order=[]; let frontier=[root]; seen.add(root); order.push(root);
  for(let depth=0; depth<a.depth && order.length<a.max; depth++){
    const next=[];
    for(const obj of frontier){ const scan = depth===0 ? a.win*2 : 512;
      for(let o=0;o+4<=scan;o+=4){ if(obj+o+4>heap) break;
        const p=dv.getUint32(obj+o,true);
        if(ok(p) && !seen.has(p)){ seen.add(p); order.push(p); next.push(p); if(order.length>=a.max) break; } }
      if(order.length>=a.max) break;
    }
    frontier=next;
  }
  const out=new Uint8Array(order.length*a.win);
  for(let i=0;i<order.length;i++){ const base=order[i]; for(let o=0;o<a.win;o++) out[i*a.win+o]=(base+o<heap)?HEAPU8[base+o]:0; }
  let s=''; const CH=0x8000; for(let i=0;i<out.length;i+=CH) s+=String.fromCharCode.apply(null,out.subarray(i,i+CH));
  return {objs:order, b64:btoa(s)};
}"""
_HPC_REDUMP_JS = """(a)=>{
  const heap=HEAPU8.buffer.byteLength, objs=a.objs, win=a.win, out=new Uint8Array(objs.length*win);
  for(let i=0;i<objs.length;i++){ const base=objs[i]; for(let o=0;o<win;o++) out[i*win+o]=(base+o<heap)?HEAPU8[base+o]:0; }
  let s=''; const CH=0x8000; for(let i=0;i<out.length;i+=CH) s+=String.fromCharCode.apply(null,out.subarray(i,i+CH));
  return btoa(s);
}"""
_hpc = {"snap_gen": None, "corr_gen": None, "objs": None, "snap1": None, "cur1": None, "mx": None}


def _hpc_push(ctrl, stat, out):
    try:
        ctrl.evaluate("(a)=>{ if(window.__hpcSet) window.__hpcSet(a[0], a[1]); }", [stat, out])
    except Exception:
        pass


def update_hp_corr(act, ctrl):
    """🔬 Pin HP by damage-correlation. ① Snapshot walks the controller's object graph + dumps bytes at HP1.
    ② Correlate re-dumps the SAME objects at HP2 and reports the (object,offset) whose value went HP1->HP2
    (int HP, maxHP constant, or 0..1 ratio) — the mover HP field, rooted at the player so it's unambiguous."""
    try:
        if not ctrl.evaluate("!!window.__reActive"):
            return
        sn = ctrl.evaluate("window.__hpcSnap || {gen:0}")
        co = ctrl.evaluate("window.__hpcCorr || {gen:0}")
        if not act.evaluate("typeof HEAPU8 !== 'undefined'"):
            return
    except Exception:
        return
    # ① Snapshot
    if sn.get("gen") and sn.get("gen") != _hpc["snap_gen"]:
        _hpc["snap_gen"] = sn.get("gen")
        try:
            cur1, mx = (int(x) for x in str(sn.get("hp1", "")).split("/"))
        except Exception:
            _hpc_push(ctrl, "bad HP — use cur/max like 173/315", None); return
        try:
            res = act.evaluate(_HPC_BFS_JS, {"root": 1230588, "depth": 3, "max": _HPC_MAX, "win": _HPC_WIN})
        except Exception as e:
            _hpc_push(ctrl, f"snapshot failed: {e}", None); return
        if res.get("err"):
            _hpc_push(ctrl, f"snapshot error: {res['err']}", None); return
        _hpc.update(objs=res["objs"], snap1=base64.b64decode(res["b64"]), cur1=cur1, mx=mx)
        _hpc_push(ctrl, f"snapshot OK: {len(res['objs'])} objects at {cur1}/{mx} — take damage, then ② Correlate",
                  f"snapshotted {len(res['objs'])} player-graph objects × {_HPC_WIN}B at HP {cur1}/{mx}.\n"
                  f"Now TAKE DAMAGE (don't heal), enter your new HP, and click ② Correlate.")
        return
    # ② Correlate
    if co.get("gen") and co.get("gen") != _hpc["corr_gen"]:
        _hpc["corr_gen"] = co.get("gen")
        if not _hpc["objs"] or _hpc["snap1"] is None:
            _hpc_push(ctrl, "click ① Snapshot first (at a known HP)", None); return
        try:
            cur2, mx2 = (int(x) for x in str(co.get("hp2", "")).split("/"))
        except Exception:
            _hpc_push(ctrl, "bad HP — use cur/max", None); return
        try:
            b64 = act.evaluate(_HPC_REDUMP_JS, {"objs": _hpc["objs"], "win": _HPC_WIN})
            snap2 = base64.b64decode(b64)
        except Exception as e:
            _hpc_push(ctrl, f"correlate failed: {e}", None); return
        cur1, mx = _hpc["cur1"], _hpc["mx"]
        snap1, objs = _hpc["snap1"], _hpc["objs"]
        r1, r2 = cur1 / mx, cur2 / max(1, mx2)
        hits = []
        for i, base in enumerate(objs):
            o1 = i * _HPC_WIN
            for o in range(0, _HPC_WIN - 4, 4):
                a1 = struct.unpack_from("<i", snap1, o1 + o)[0]; a2 = struct.unpack_from("<i", snap2, o1 + o)[0]
                f1 = struct.unpack_from("<f", snap1, o1 + o)[0]; f2 = struct.unpack_from("<f", snap2, o1 + o)[0]
                if a1 == cur1 and a2 == cur2:
                    hits.append((0, base, o, "i32", a1, a2, "INT HP (==cur1 & ==cur2)"))
                elif a1 == mx and a2 == mx and mx == mx2:
                    hits.append((2, base, o, "i32", a1, a2, "maxHP? (==max both)"))
                elif abs(f1 - r1) < 0.02 and abs(f2 - r2) < 0.02 and f1 != f2:
                    hits.append((1, base, o, "f32", round(f1, 3), round(f2, 3), "RATIO HP (r1->r2)"))
        hits.sort(key=lambda h: h[0])
        if not hits:
            _hpc_push(ctrl, "no field matched HP1->HP2 — take a BIGGER hit and retry (or graph too shallow)",
                      f"correlated HP {cur1}->{cur2} across {len(objs)} objects: NO match.\n"
                      "Try a larger HP change; if still nothing, HP may be >3 hops from the controller.")
            return
        lines = [f"HP {cur1}->{cur2} (ratio {r1:.3f}->{r2:.3f}) across {len(objs)} player-graph objects:",
                 f"{len(hits)} candidate field(s):", ""]
        for _rank, base, o, typ, v1, v2, why in hits[:40]:
            lines.append(f"  obj 0x{base:x} + {o:<4} {typ}  {v1} -> {v2}   {why}")
        lines.append("")
        lines.append("INT HP / RATIO HP rows = the mover's HP field. Paste this — the object+offset maps the")
        lines.append("controller→mover pointer chain (I'll trace it in the disassembly for an O(1) named read).")
        _hpc_push(ctrl, f"{len(hits)} candidate(s) — HP field found (see below)", "\n".join(lines))
        return


RE_PLAYER_MAX_HP = 315      # the player char's REAL max HP (user-confirmed); the entity fingerprint.
                            # (the correlation's "1000" was noise — 1000 is a very common int in memory)
_PTR_SCAN_JS = """(a)=>{ const t=a.t, cap=a.cap; const u=new Uint32Array(HEAPU8.buffer);
  const out=[]; const n=u.length; for (let i=0;i<n;i++){ if(u[i]===t){ out.push(i*4); if(out.length>=cap) break; } }
  return out; }"""
# pointers whose VALUE lands in [lo,hi] (i.e. point AT or INTO the entity object) -> [[holder, value],...]
_PTR_RANGE_JS = """(a)=>{ const lo=a.lo, hi=a.hi, cap=a.cap; const u=new Uint32Array(HEAPU8.buffer);
  const out=[]; const n=u.length; for (let i=0;i<n;i++){ const v=u[i]; if(v>=lo&&v<=hi){ out.push([i*4, v]); if(out.length>=cap) break; } }
  return out; }"""
_re_hpptr_gen = None


def _re_pointer_report(act, controller, ratio_addr):
    """Scan for pointers pointing AT/INTO the object holding the HP ratio; return report lines."""
    hits = act.evaluate(_PTR_RANGE_JS, {"lo": ratio_addr - 1024, "hi": ratio_addr + 16, "cap": 1000})
    crel = [(h, h - controller, v, ratio_addr - v) for h, v in hits if 0 <= h - controller <= 0x40000]
    glob = [(h, v, ratio_addr - v) for h, v in hits if h < 2_000_000]
    L = [f"HP ratio @{ratio_addr}  (controller={controller})  |  {len(hits)} pointers into the object"]
    if crel:
        L.append("CONTROLLER-RELATIVE  [controller+OFF -> ratio_addr-D]  <- FOLLOW THESE:")
        for h, off, v, d in crel[:14]:
            L.append(f"    controller+{off} (+0x{off:x}) -> {v}  (ratio_addr-{d})  @{h}")
    if glob:
        L.append("GLOBAL holder(s)  [@holder -> ratio_addr-D]:")
        for h, v, d in glob[:14]:
            L.append(f"    @{h} -> {v}  (ratio_addr-{d})")
    if not crel and not glob:
        tally = {}
        for h, v in hits:
            tally[ratio_addr - v] = tally.get(ratio_addr - v, 0) + 1
        L.append("no controller/global pointer — registry/multi-level. target offsets (ratio-D:count): "
                 + ", ".join(f"{d}:{c}" for d, c in sorted(tally.items(), key=lambda kv: -kv[1])[:8]))
        L.append("first holders: " + ", ".join(str(h) for h, v in hits[:8]))
    return "\n".join(L)


def update_re_hpptr(act, ctrl):
    """🔗 'Find HP pointer' button (one-shot). Locates the player entity by its maxHP fingerprint (vision
    only as a tie-breaker), then scans for pointers to it — controller-relative offset = the chain to a
    direct, scan-free, vision-free HP read. Run at PARTIAL HP."""
    global _re_hpptr_gen
    try:
        if not ctrl.evaluate("!!window.__reActive"):
            return
        st = ctrl.evaluate("window.__reHpPtr || {gen:0}")
    except Exception:
        return
    gen = st.get("gen")
    if not gen or gen == _re_hpptr_gen:
        return
    _re_hpptr_gen = gen

    def push(stat, out=None):
        try:
            ctrl.evaluate("(a)=>{ if(window.__reHpPtrSet) window.__reHpPtrSet(a[0],a[1]); }", [stat, out])
        except Exception:
            pass
    try:
        if not act.evaluate("typeof HEAPU8 !== 'undefined'"):
            push("game memory (HEAPU8) not found"); return
        u32 = lambda a: act.evaluate("(x)=>new DataView(HEAPU8.buffer).getUint32(x,true)", a)
        controller = u32(1230588)
        px = gl.hp_fraction(gl.grab(act))
    except Exception as e:
        push(f"error: {e}"); return
    if px is None:
        push("HP unreadable"); return
    if px > 0.95:
        push(f"HP is {px*100:.0f}% — take damage to a PARTIAL HP first, then click again"); return
    # use the CONVERGED HP-ratio address(es) from the value-scan (window.__reCands), pick by pixel HP.
    cands = act.evaluate("(window.__reCands||[]).slice(0,256)")
    if not cands:
        push("run '▶ Start scan' above first (take damage until it CONVERGES), then click this"); return
    ratios = act.evaluate("(A)=>{const dv=new DataView(HEAPU8.buffer); return A.map(a=>dv.getFloat32(a,true));}", cands)
    pairs = [(cands[i], ratios[i]) for i in range(len(cands)) if 0.02 <= ratios[i] <= 1.05]
    if not pairs:
        push("converged addresses no longer hold HP values — re-run Start scan"); return
    ratio_addr, ratio = min(pairs, key=lambda p: abs(p[1] - px))   # the survivor matching current HP
    lines = [f"controller = *(1230588) = {controller}",
             f"pixel HP {px*100:.1f}%  |  {len(pairs)} converged candidate(s)",
             f"HP ratio @{ratio_addr} = {ratio:.4f} (hp~{int(ratio*RE_PLAYER_MAX_HP)})"]
    # scan for pointers pointing INTO the object that holds the ratio (the +308 layout may be stale, so
    # search a wide window BEFORE the ratio: target in [ratio_addr-1024, ratio_addr+16]).
    hits = act.evaluate(_PTR_RANGE_JS, {"lo": ratio_addr - 1024, "hi": ratio_addr + 16, "cap": 1000})
    lines.append(f"pointers INTO the entity object: {len(hits)}")
    crel = [(h, h - controller, v, ratio_addr - v) for h, v in hits if 0 <= h - controller <= 0x40000]
    glob = [(h, v, ratio_addr - v) for h, v in hits if h < 2_000_000]
    if crel:
        lines.append("CONTROLLER-RELATIVE pointer(s)  [holder=controller+OFF -> entity field at ratio-D]:")
        for h, off, v, d in crel[:14]:
            lines.append(f"    controller+{off} (+0x{off:x}) -> {v}  (ratio_addr-{d})  @{h}")
    if glob:
        lines.append("low-address GLOBAL holder(s) [holder -> entity, ratio-D]:")
        for h, v, d in glob[:14]:
            lines.append(f"    @{h} -> {v}  (ratio_addr-{d})")
    if not crel and not glob:
        tally = {}
        for h, v in hits:
            tally[ratio_addr - v] = tally.get(ratio_addr - v, 0) + 1
        lines.append("no controller/global pointer — registry/multi-level. pointer-target offsets (ratio-D:count): "
                     + ", ".join(f"{d}:{c}" for d, c in sorted(tally.items(), key=lambda kv: -kv[1])[:8]))
        lines.append("first holders: " + ", ".join(str(h) for h, v in hits[:8]))
    lines.append("")
    lines.append("CONFIRM: take more damage + click again — the holder/offset that stays the same while")
    lines.append("the ratio ADDRESS changes is the live pointer to follow. Paste this back.")
    push(f"ratio@{ratio_addr}, {len(hits)} pointers into it, {len(crel)} controller-relative",
         "\n".join(lines))


def _apply_calib(ctrl):
    """Read the calibrate dashboard's HP pixel values into gl (live preview)."""
    try:
        c = ctrl.evaluate("window.__calib || null")
    except Exception:
        c = None
    if not c:
        return
    for k in gl.HP_CALIB_KEYS:
        if c.get(k) is not None:
            try:
                setattr(gl, k, int(c[k]))
            except Exception:
                pass


_last_calib = 0.0
_last_calib_save = 0
def update_calibrate(act, ctrl):
    """When the Calibrate dashboard is active: apply its pixel values to gl, dump the annotated HP
    crop, and push the image (base64) + read result to the panel. Also handles its Save button."""
    global _last_calib, _last_calib_save
    try:
        active = bool(ctrl.evaluate("!!window.__calibActive"))
    except Exception:
        active = False
    if not active:
        return
    now = time.time()
    if now - _last_calib < 0.5:
        return
    _last_calib = now
    _apply_calib(ctrl)
    try:                                          # Save button → write hp_calib.json
        req = ctrl.evaluate("window.__calibSaveReq || 0")
        if req and req != _last_calib_save:
            _last_calib_save = req
            ok = gl.save_calib()
            ctrl.evaluate("(t)=>{const e=document.getElementById('calibStat'); if(e) e.textContent=t;}",
                          "saved ✓" if ok else "save failed")
    except Exception:
        pass
    try:
        outdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hpCropTest")
        img = gl.grab(act)
        w, h, redpx = gl.dump_hp_crop(img, outdir)
        frac, edge, _ = gl.hp_report(img)
        with open(os.path.join(outdir, "hp_box_annotated_x3.png"), "rb") as f:
            b64 = base64.b64encode(f.read()).decode()
        info = f"{w}x{h}  ·  HP {frac*100:.1f}%  ·  red {redpx}px  ·  edge x={edge}"
        ctrl.evaluate("(a)=>{ if (window.__calibSet) window.__calibSet(a[0], a[1]); }",
                      ["data:image/png;base64," + b64, info])
    except Exception:
        pass


def _apply_grind_calib(ctrl):
    """Read the grind dashboard's region fractions into gl (live preview)."""
    try:
        c = ctrl.evaluate("window.__gcalib || null")
    except Exception:
        c = None
    if not c:
        return
    for k in gl.GRIND_CALIB_KEYS:
        v = c.get(k)
        if v and len(v) == 4:
            try:
                setattr(gl, k, tuple(float(x) for x in v))
            except Exception:
                pass


_last_gcalib = 0.0
_last_gcalib_save = 0
def update_grind_calib(act, ctrl):
    """When the Grind-calibration dashboard is active: apply its region fractions to gl, dump the
    annotated region overlay, and push it (base64) to the panel. Also handles its Save button."""
    global _last_gcalib, _last_gcalib_save
    try:
        active = bool(ctrl.evaluate("!!window.__gcalibActive"))
    except Exception:
        active = False
    if not active:
        return
    now = time.time()
    if now - _last_gcalib < 0.5:
        return
    _last_gcalib = now
    _apply_grind_calib(ctrl)
    try:                                          # Save button → write scanner_calib.json
        req = ctrl.evaluate("window.__gcalibSaveReq || 0")
        if req and req != _last_gcalib_save:
            _last_gcalib_save = req
            ok = gl.save_calib()
            ctrl.evaluate("(t)=>{const e=document.getElementById('gcalibStat'); if(e) e.textContent=t;}",
                          "saved ✓" if ok else "save failed")
    except Exception:
        pass
    try:
        outdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hpCropTest")
        img = gl.grab(act)
        show_hover = bool(ctrl.evaluate("!!window.__gcalibShowHover"))
        w, h, path, n_hover = gl.dump_grind_regions(img, outdir, show_hover=show_hover)
        with open(path, "rb") as f:
            b64 = base64.b64encode(f.read()).decode()
        info = f"{w}x{h}  ·  GREEN=scan · RED=ignored · ORANGE=self"
        if show_hover:
            info += f" · CYAN={n_hover} hover pts"
        ctrl.evaluate("(a)=>{ if (window.__gcalibSet) window.__gcalibSet(a[0], a[1]); }",
                      ["data:image/png;base64," + b64, info])
    except Exception:
        pass


_last_shopcalib = 0.0
def update_shop_calib(act, ctrl):
    """When the ShopScanner panel is active: push a live preview of the AUTO-DETECTED shop window
    (box + match confidence) + DB count, so the user can see it's locking on."""
    global _last_shopcalib
    try:
        active = bool(ctrl.evaluate("!!window.__shopActive"))
    except Exception:
        active = False
    if not active:
        return
    now = time.time()
    if now - _last_shopcalib < 0.7:
        return
    _last_shopcalib = now
    try:
        outdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hpCropTest")
        img = gl.grab(act)
        path, opn, n, seller, conf = ss.dump_region(img, outdir)
        with open(path, "rb") as f:
            b64 = base64.b64encode(f.read()).decode()
        dbn = len(ss.load_db().get("items", {}))
        info = (f"✅ shop detected ({int(conf*100)}%) · {n} items · seller {seller or '—'}" if opn
                else "no shop in view — open a private shop (and use a large game window)")
        ctrl.evaluate("(a)=>{ if (window.__shopSet) window.__shopSet(a[0], a[1], a[2]); }",
                      ["data:image/png;base64," + b64, info, f"DB: {dbn} items catalogued"])
    except Exception:
        pass


_last_shopdb = 0.0
_last_shopcsv = 0
_last_shophist = None
def update_shop_db(ctrl):
    """When the Shop DB tab is open: push the price-DB rows + count, serve per-item history on click,
    and handle Export CSV. Cheap (reads shop_db.json, no screenshot) so it's safe every tick."""
    global _last_shopdb, _last_shopcsv, _last_shophist
    try:
        active = bool(ctrl.evaluate("!!window.__shopDbActive"))
    except Exception:
        active = False
    if not active:
        return
    try:                                          # Export CSV (handled immediately)
        req = ctrl.evaluate("window.__shopCsvReq || 0")
        if req and req != _last_shopcsv:
            _last_shopcsv = req
            path = ss.export_csv()
            ctrl.evaluate("(t)=>{const e=document.getElementById('shopDbInfo'); if(e) e.textContent=t;}",
                          f"exported → {os.path.basename(path)}")
    except Exception:
        pass
    try:                                          # per-item history (on row click)
        hr = ctrl.evaluate("window.__shopHistReq || null")
        if isinstance(hr, dict) and hr.get("n"):
            key = (hr.get("n"), hr.get("t"))
            if key != _last_shophist:
                _last_shophist = key
                h = ss.item_history(hr["n"])
                if h:
                    summary = (f"min {h['min']:,} · median {h['median']:,} · max {h['max']:,} "
                               f"· {h['count']} observations")
                    ctrl.evaluate("(a)=>{ if (window.__shopHistSet) window.__shopHistSet(a[0],a[1],a[2]); }",
                                  [hr["n"], summary, h["obs"]])
    except Exception:
        pass
    now = time.time()
    if now - _last_shopdb < 1.5:
        return
    _last_shopdb = now
    try:
        rows = ss.db_rows()
        nitems, ntotal, updated = ss.db_stats()
        ctrl.evaluate("(d)=>{ if (window.__shopdbSet) window.__shopdbSet(d.rows, d.info, d.count); }",
                      {"rows": rows, "count": f"📦 {nitems} items",
                       "info": f"{nitems} items · {ntotal} observations · updated {updated or '—'} · click a row for history"})
    except Exception:
        pass


# Idempotent panel injector (IIFE). Buttons set window.__bot = {cmd, mode, gen}.
PANEL_VER = "13"
PANEL_JS = r"""(() => {
  const VER = '%VER%';
  if (!window.__bot) window.__bot = {cmd:'stop', mode:'local', gen:0};
  const old = document.getElementById('botPanel');
  if (old && old.dataset.ver === VER) return;
  if (old) old.remove();
  const p = document.createElement('div');
  p.id = 'botPanel';
  p.dataset.ver = VER;
  // fixed to the viewport, TOP-RIGHT (clear of HP bar at top-left, the top-center
  // target frame, and the mob-detection ROI which excludes the far right). All
  // four offsets are !important because the page forces top:0 on bare divs.
  p.style.cssText = 'position:fixed!important;top:8px!important;right:8px!important;'+
    'left:auto!important;bottom:auto!important;z-index:2147483647;'+
    'direction:ltr;background:rgba(18,18,22,.9);color:#eee;font:12px/1.4 monospace;'+
    'padding:8px;border:1px solid #555;border-radius:8px;width:150px;'+
    'box-shadow:0 2px 10px rgba(0,0,0,.6);user-select:none';
  p.innerHTML =
    '<div style="font-weight:bold;margin-bottom:6px">🤖 Flyff Bot</div>'+
    '<div id="botStat" style="margin-bottom:6px;color:#8f8">idle</div>'+
    '<button data-c="selected" style="width:100%;margin:2px 0">▶ Grind selected</button>'+
    '<button data-c="local"  style="width:100%;margin:2px 0">▶ Grind local</button>'+
    '<button data-c="local2" style="width:100%;margin:2px 0;background:#264">▶ Grind local 2 (mem)</button>'+
    '<button data-c="calibrate" style="width:100%;margin:2px 0">⟲ Calibrate view</button>'+
    '<button data-c="heal" style="width:100%;margin:2px 0;background:#262">💚 Heal (spam 1)</button>'+
    '<button data-c="healbuff" style="width:100%;margin:2px 0;background:#262">💚➕ Heal + buff</button>'+
    '<button data-c="stop"   style="width:100%;margin:2px 0;background:#722">■ Stop</button>';
  (document.documentElement || document.body).appendChild(p);
  // record the user's last in-game click. The handler is a replaceable function so
  // newer panel versions can update its exclusion list without re-adding listeners.
  window.__clickHookFn = (e) => {
    if (e.target.closest && e.target.closest('#botPanel,#botHealPanel')) return; // ignore panel clicks
    window.__lastClick = {x: e.clientX, y: e.clientY, t: Date.now()};
  };
  if (!window.__clickHook) {
    window.__clickHook = true;
    document.addEventListener('click', (e) => { if (window.__clickHookFn) window.__clickHookFn(e); }, true);
  }
  p.querySelectorAll('button').forEach(b => b.onclick = (e) => {
    e.stopPropagation();
    const c = b.dataset.c;
    if (c === 'stop') window.__bot = {cmd:'stop', mode:window.__bot.mode, gen:Date.now()};
    else window.__bot = {cmd:'start', mode:c, gen:Date.now()};
  });
})()""".replace("%VER%", PANEL_VER)


def ensure_panel(page):
    try:
        page.evaluate(PANEL_JS)
    except Exception:
        pass


# Separate draggable settings window for the Heal mode. Writes the chosen delay to
# window.__heal.interval (seconds); the heal loop reads it live, so edits apply at once.
HEAL_PANEL_VER = "2"
HEAL_PANEL_JS = r"""(() => {
  const VER = '%VER%';
  if (!window.__heal) window.__heal = {interval: %DEF%};
  const old = document.getElementById('botHealPanel');
  if (old && old.dataset.ver === VER) return;
  if (old) old.remove();
  const p = document.createElement('div');
  p.id = 'botHealPanel';
  p.dataset.ver = VER;
  // sits to the LEFT of the main panel by default; drag the header to move it.
  p.style.cssText = 'position:fixed!important;top:8px!important;right:166px!important;'+
    'left:auto!important;bottom:auto!important;z-index:2147483647;'+
    'direction:ltr;background:rgba(18,18,22,.92);color:#eee;font:12px/1.4 monospace;'+
    'padding:8px;border:1px solid #4a4;border-radius:8px;width:178px;'+
    'box-shadow:0 2px 10px rgba(0,0,0,.6);user-select:none';
  p.innerHTML =
    '<div id="botHealHdr" style="font-weight:bold;margin-bottom:6px;cursor:move">💚 Heal settings ⠿</div>'+
    '<label style="display:block;margin-bottom:4px;color:#bbb">Delay between heals (sec)</label>'+
    '<div style="display:flex;gap:4px;align-items:center">'+
      '<button id="healDec" style="width:30px">–</button>'+
      '<input id="healInt" type="number" min="0.1" step="0.1" '+
        'style="flex:1;min-width:0;text-align:center;background:#111;color:#eee;border:1px solid #555;border-radius:4px"/>'+
      '<button id="healInc" style="width:30px">+</button>'+
    '</div>'+
    '<div id="botHealStat" style="margin-top:6px;color:#8f8">idle</div>';
  (document.documentElement || document.body).appendChild(p);
  const inp = p.querySelector('#healInt');
  inp.value = window.__heal.interval;
  const commit = (v) => { v = Math.max(0.1, Math.round((parseFloat(v)||0.1)*10)/10); window.__heal.interval = v; inp.value = v; };
  inp.onchange = () => commit(inp.value);
  inp.oninput  = () => { const v = parseFloat(inp.value); if (v > 0) window.__heal.interval = v; };
  p.querySelector('#healDec').onclick = (e) => { e.stopPropagation(); commit((window.__heal.interval||0.1) - 0.1); };
  p.querySelector('#healInc').onclick = (e) => { e.stopPropagation(); commit((window.__heal.interval||0.1) + 0.1); };
  // drag by the header
  const hdr = p.querySelector('#botHealHdr');
  let drag = null;
  hdr.addEventListener('mousedown', (e) => { drag = {x:e.clientX, y:e.clientY, l:p.offsetLeft, t:p.offsetTop}; e.preventDefault(); });
  document.addEventListener('mousemove', (e) => { if (!drag) return;
    p.style.left = (drag.l + e.clientX - drag.x)+'px'; p.style.right = 'auto'; p.style.top = (drag.t + e.clientY - drag.y)+'px'; });
  document.addEventListener('mouseup', () => { drag = null; });
})()""".replace("%VER%", HEAL_PANEL_VER).replace("%DEF%", str(HEAL_INTERVAL))


def ensure_heal_panel(page):
    try:
        page.evaluate(HEAL_PANEL_JS)
    except Exception:
        pass


def read_heal_interval(page):
    """Live heal delay (seconds) from the heal-settings window; fall back to default."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.interval) || null")
        return max(0.1, float(v)) if v else HEAL_INTERVAL
    except Exception:
        return HEAL_INTERVAL


def read_buff_interval(page):
    """Live 'buff every N seconds' (class 1) from the control window; fall back to default."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.buffEvery) || null")
        return max(1.0, float(v)) if v else BUFF_EVERY
    except Exception:
        return BUFF_EVERY


def read_buff2_interval(page):
    """Live 'buff 2 every N seconds' (class 2) from the control window; fall back to default."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.buff2Every) || null")
        return max(1.0, float(v)) if v else BUFF2_EVERY
    except Exception:
        return BUFF2_EVERY


def read_buff_step(page):
    """Live 'buff key delay (ms)' between presses inside a buff cycle, returned in
    SECONDS. From the control window; falls back to BUFF_STEP_DELAY."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.buffStepMs)")
        return max(0.0, float(v) / 1000.0) if v is not None else BUFF_STEP_DELAY
    except Exception:
        return BUFF_STEP_DELAY


def read_heal_target_threshold(page):
    """The target-HP heal threshold as a 0..1 fraction, from the panel's 'Heal only if target HP below %'
    field (healIfTargetPct; default 90%). Used to gate BOTH the main heal-spam and the between-buff
    heal-to-threshold burst — heal the target until its HP is at/above this."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.healIfTargetPct) || 90")
        return max(0.05, min(1.0, float(v) / 100.0))
    except Exception:
        return 0.9


def next_chat_time():
    """When the next chat line should be sent (random 30–80s out)."""
    return time.time() + random.uniform(CHAT_MIN_INTERVAL, CHAT_MAX_INTERVAL)


def next_calib_time():
    """When the next random camera recalibration should run."""
    return time.time() + random.uniform(CALIB_MIN_INTERVAL, CALIB_MAX_INTERVAL)


def tap(act, k):
    """Press a key with a HUMAN-JITTERED hold (not a robotic fixed 0.06 s every time)."""
    act.keyboard.down(k); time.sleep(random.uniform(*KEY_HOLD)); act.keyboard.up(k)


def idle_mouse_drift(act):
    """Small, human-like cursor movement so a heal session isn't pure keyboard input with a FROZEN
    mouse — the main thing that separates it from the grind bot (which moves the mouse constantly) and
    a strong captcha trigger. PURE moves: never clicks or drags, smoothly stepped, within the central
    play area (off the HUD/minimap/chat). A few little hops with tiny pauses, varied each time."""
    try:
        w, h = gl.viewport(act)
        for _ in range(random.randint(1, 3)):
            tx = min(max(w * random.uniform(0.22, 0.78), w*0.08), w*0.92)
            ty = min(max(h * random.uniform(0.25, 0.75), h*0.14), h*0.82)
            act.mouse.move(tx, ty, steps=random.randint(5, 16))     # smooth, human-paced glide
            time.sleep(random.uniform(0.05, 0.30))
    except Exception:
        pass


def _bezier(ctrl, n):
    """n points sampled along the Bezier curve defined by control points `ctrl` (list of (x,y)). The
    curve starts at ctrl[0], ends at ctrl[-1], and bends smoothly toward the points between."""
    m = len(ctrl) - 1
    out = []
    for i in range(n):
        t = i / (n - 1) if n > 1 else 0.0
        x = y = 0.0
        for j, (px, py) in enumerate(ctrl):
            b = math.comb(m, j) * (t ** j) * ((1 - t) ** (m - j))
            x += b * px; y += b * py
        out.append((x, y))
    return out


def smooth_look_around(act):
    """Anti-detect: a slow, SMOOTH camera look-around — glide the cursor along a Bezier curve through a
    few random points while the RIGHT button is held (Flyff rotates/tilts the camera), like a human
    glancing about. Gentle offsets (a look, not a wild swing); the periodic recalibrate re-centres later.
    The right button is ALWAYS released, even on error (a stuck button would break all input)."""
    try:
        w, h = gl.viewport(act)
    except Exception:
        return
    cx, cy = w * 0.5, h * 0.5
    ctrl = [(cx, cy)]
    for _ in range(random.randint(2, 3)):                         # 2-3 random waypoints → a gentle curve
        ctrl.append((cx + random.uniform(-0.22, 0.22) * w,
                     cy + random.uniform(-0.12, 0.12) * h))
    pts = _bezier(ctrl, random.randint(40, 70))
    try:
        sx, sy = pts[0]
        act.mouse.move(sx, sy, steps=4)
        act.mouse.down(button="right")
        for (x, y) in pts[1:]:
            act.mouse.move(min(max(x, 2), w - 2), min(max(y, 2), h - 2), steps=1)
            time.sleep(random.uniform(0.009, 0.026))             # slow, smooth glide (≈1–1.8 s total)
    except Exception:
        pass
    finally:
        try: act.mouse.up(button="right")
        except Exception: pass


def map_fiddle(act):
    """Anti-detection: open the world map (M), pan it with left-drag and zoom in/out for a
    couple seconds, then close it — mimics a human checking the map. Self-contained: leaves
    the map closed and no mouse button held, even on error."""
    try:
        w, h = gl.viewport(act)
    except Exception:
        w, h = 1920, 1200
    cx, cy = w * 0.5, h * 0.5
    try:
        act.keyboard.press("m"); time.sleep(0.5)                     # open map
        end = time.time() + MAP_FIDDLE_SECS
        while time.time() < end:
            x0, y0 = cx + random.randint(-120, 120), cy + random.randint(-90, 90)
            if random.random() < 0.5:
                act.mouse.click(x0, y0)                              # a normal left-click on the map
            else:
                act.mouse.move(x0, y0)
                act.mouse.down()                                     # left-drag to pan
                act.mouse.move(x0 + random.randint(-140, 140), y0 + random.randint(-100, 100), steps=6)
                act.mouse.up()
            act.mouse.wheel(0, random.choice([-120, 120]))           # zoom in/out
            time.sleep(random.uniform(0.2, 0.45))
        act.keyboard.press("m"); time.sleep(0.3)                     # close map (toggle)
    except Exception:
        try: act.mouse.up()
        except Exception: pass
        try: act.keyboard.press("Escape")                            # ensure the map isn't left open
        except Exception: pass


# 🔔 BOT-DETECTION captcha alert/pause. The game's anti-bot is Google reCAPTCHA v2 (RE'd 2026-07-05): tier-1 =
# the "I'm not a robot" checkbox (the vision solve_captcha clicks it), tier-2 = the IMAGE challenge ("select all
# bicycles") which is a VISIBLE `bframe` reCAPTCHA iframe and CANNOT be auto-solved. When tier-2 is up we PAUSE
# + beep so the human solves it. Detection is DOM-based on the GAME page (far more reliable than the white-box scan).
_CAPTCHA_CHALLENGE_JS = r"""() => { try {
  for (const f of document.querySelectorAll('iframe')) {
    if (!/recaptcha/i.test(f.src) || !/bframe/i.test(f.src)) continue;   // the image-challenge iframe
    const r = f.getBoundingClientRect();
    if (r.width < 100 || r.height < 100) continue;
    if (r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth) continue;  // off-screen = collapsed
    let el = f, hidden = false;                                          // not hidden by an ancestor?
    while (el) { const cs = getComputedStyle(el);
      if (cs.visibility === 'hidden' || cs.display === 'none' || parseFloat(cs.opacity) === 0) { hidden = true; break; }
      el = el.parentElement; }
    if (!hidden) return true;
  }
  return false;
} catch(e){ return false; } }"""


def read_captcha_alert(ctrl):
    """Is the 🔔 'Alert for bot detection' box ticked? Default OFF."""
    try:
        return bool(ctrl.evaluate("!!window.__botAlert"))
    except Exception:
        return False


def captcha_challenge_up(act):
    """Is the tier-2 reCAPTCHA IMAGE challenge currently displayed on the game page (a visible bframe iframe)?"""
    try:
        return bool(act.evaluate(_CAPTCHA_CHALLENGE_JS))
    except Exception:
        return False


def captcha_pause(act, ctrl, gen):
    """If the alert is on AND a captcha image-challenge is up, PAUSE here (blocking the loop) — beeping the
    control window every 5 s — until the human clears it OR a Stop is issued. Called from `stopped()`, so it
    pauses whatever mode is running and resumes normally once solved. No-op if the box is off / no challenge."""
    if not read_captcha_alert(ctrl) or not captcha_challenge_up(act):
        return
    set_status(ctrl, "🔔 BOT-DETECTION captcha — PAUSED · solve the image challenge in the game (alerting)…")
    last_beep = 0.0
    while captcha_challenge_up(act):
        b = read_cmd(ctrl)                                             # respect Stop / a new command
        if b.get("cmd") != "start" or b.get("gen") != gen:
            break
        if time.time() - last_beep >= 5.0:
            last_beep = time.time()
            try: ctrl.evaluate("window.__captchaBeep && window.__captchaBeep()")
            except Exception: pass
        time.sleep(0.5)
    set_status(ctrl, "🔔 captcha cleared — resuming")


def read_map_enabled(page):
    """Is the map-fiddle anti-detection action enabled (control-panel checkbox)?"""
    try:
        return bool(page.evaluate("!!(window.__heal && window.__heal.mapEnabled)"))
    except Exception:
        return False


def read_chat_enabled(page):
    """Is the random-chat (typing) anti-detection action enabled? Defaults OFF when unset."""
    try:
        return bool(page.evaluate(
            "(window.__heal && 'chatEnabled' in window.__heal) ? !!window.__heal.chatEnabled : false"))
    except Exception:
        return False


def read_flee_enabled(page):
    """Is aggro-avoidance (flee from red-tag mobs) enabled? Defaults OFF when unset."""
    try:
        return bool(page.evaluate(
            "(window.__heal && 'fleeEnabled' in window.__heal) ? !!window.__heal.fleeEnabled : false"))
    except Exception:
        return False


def sleep_scan_captcha(act, ctrl, label, delay, stopped):
    """Sleep up to `delay`s in 0.1s slices, but scan the time-critical captcha ~every second and
    click it immediately (a slow tick lets the harder image-captcha load behind it). The heal
    loop spends most of its time here, so this gives ~1s captcha reaction. Returns early if a
    captcha was handled or we were stopped."""
    slept = 0.0; nxt = 1.0
    while slept < delay and not stopped():
        time.sleep(0.1); slept += 0.1
        if slept >= nxt:
            nxt += 1.0
            if gl.solve_captcha(act):
                set_status(ctrl, f"[{label}] captcha — clicked 'not a robot'")
                return


def navigate_los(act):
    """If a 'line-of-sight blocked' (Hindernis) message is up — we can't reach the char we're
    healing — wiggle with WASD until the message clears. Returns True if it acted."""
    if not gl.line_of_sight_blocked(gl.grab(act)):
        return False
    for _ in range(LOS_MAX_MOVES):
        k = random.choice(["a", "d", "w", "s"])
        act.keyboard.down(k); time.sleep(random.uniform(*LOS_HOLD)); act.keyboard.up(k)
        time.sleep(0.3)
        if not gl.line_of_sight_blocked(gl.grab(act)):
            break
    return True


def read_flee_duration(page):
    """Live 'flee duration (sec)' per burst from the control panel; falls back to FLEE_SECS."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.fleeSecs) || null")
        return max(0.5, float(v)) if v else FLEE_SECS
    except Exception:
        return FLEE_SECS


def _clear_flee_point(cx, cy, vx, vy, r, mobs, w, h):
    """A move-click point roughly in direction (vx,vy) at radius r that is CLEAR of every mob
    (so fleeing never clicks/selects one). Tries angle + radius variants; None if none is clear."""
    base = math.atan2(vy, vx)
    avoid2 = (min(w, h) * FLEE_MOB_AVOID) ** 2
    for rf in (1.0, 0.75, 1.25):
        for off in (0.0, 0.4, -0.4, 0.8, -0.8, 1.2, -1.2, 1.6, -1.6):
            a = base + off
            px = min(max(cx + r*rf*math.cos(a), 0.12*w), 0.88*w)
            py = min(max(cy + r*rf*math.sin(a), 0.12*h), 0.85*h)
            if all((px-mx)**2 + (py-my)**2 > avoid2 for mx, my in mobs):
                return int(px), int(py)
    return None


def flee_from_aggro(act, ctrl):
    """If an AGGRO mob (red name tag) is in view, spam move-clicks AWAY from the nearest one
    (biased toward the followed char) for up to the flee-duration, stopping early once it's
    clear. Clicks only land on MOB-FREE points so we never select/attack a mob. Returns True if
    it fled."""
    try:
        img = gl.grab(act); w, h = gl.viewport(act)
    except Exception:
        return False
    aggro = gl.find_aggro(img)
    if not aggro:
        return False
    cx, cy = w*0.5, h*0.5
    end = time.time() + read_flee_duration(ctrl)                        # cap the burst (default 3s)
    while time.time() < end:
        mobs = [(m[0], m[1]) for m in gl.find_nameplate_mobs(img, None)]  # all mobs to avoid clicking
        ax, ay = min(aggro, key=lambda p: (p[0]-cx)**2 + (p[1]-cy)**2)   # nearest aggro
        avx, avy = cx-ax, cy-ay                                          # vector AWAY from it
        n = math.hypot(avx, avy) or 1.0
        avx, avy = avx/n, avy/n
        ft = gl.find_follow_target(img)                                  # bias toward the followed char
        if ft:
            tvx, tvy = ft[0]-cx, ft[1]-cy
            tn = math.hypot(tvx, tvy) or 1.0
            avx += FLEE_BIAS * tvx/tn; avy += FLEE_BIAS * tvy/tn
            n2 = math.hypot(avx, avy) or 1.0
            avx, avy = avx/n2, avy/n2
        pt = _clear_flee_point(cx, cy, avx, avy, min(w, h)*FLEE_RADIUS, mobs, w, h)
        if pt:
            act.mouse.click(pt[0], pt[1])                               # move to a mob-FREE point
        time.sleep(FLEE_CLICK_GAP)
        img = gl.grab(act)
        aggro = gl.find_aggro(img)
        if not aggro:
            break
    return True


def read_map_interval(page):
    """Live 'map every N seconds' from the control panel; falls back to MAP_EVERY."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.mapEvery) || null")
        return max(10.0, float(v)) if v else MAP_EVERY
    except Exception:
        return MAP_EVERY


def read_follow_enabled(page):
    """Is the 'follow target (Z)' re-press enabled (heal-modes checkbox)?"""
    try:
        return bool(page.evaluate("!!(window.__heal && window.__heal.followEnabled)"))
    except Exception:
        return True


def read_mouse_enabled(page):
    """Is human-like idle MOUSE movement enabled (heal-modes anti-detect checkbox)? Default ON."""
    try:
        v = page.evaluate("window.__heal ? !!window.__heal.mouseEnabled : true")
        return bool(v)
    except Exception:
        return True


def read_look_enabled(page):
    """Is the smooth camera 'look around' enabled (heal-modes anti-detect checkbox)? Default ON."""
    try:
        return bool(page.evaluate("window.__heal ? !!window.__heal.lookEnabled : true"))
    except Exception:
        return True


def read_look_interval(page):
    """Live 'look around every N s' base from the panel, JITTERED ±35% so it isn't metronomic.
    Falls back to LOOK_EVERY."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.lookEvery) || null")
        base = max(3.0, float(v)) if v else LOOK_EVERY
    except Exception:
        base = LOOK_EVERY
    return base * random.uniform(0.65, 1.35)


def read_follow_interval(page):
    """Live 'follow (Z) every N seconds' from the control panel; falls back to FOLLOW_EVERY."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.followEvery) || null")
        return max(1.0, float(v)) if v else FOLLOW_EVERY
    except Exception:
        return FOLLOW_EVERY


def read_selfbuff_enabled(page):
    """Is 🧙 Self-buff enabled (heal+buff checkbox)? Default OFF (it SENDS select packets — throwaway only)."""
    try:
        return bool(page.evaluate("!!(window.__heal && window.__heal.selfbuff)"))
    except Exception:
        return False


def read_selfbuff_interval(page):
    """Live '🧙 Self-buff refresh every N s' from the panel; falls back to the class-1 buff interval."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.selfbuffEvery) || null")
        return max(15.0, float(v)) if v else float(BUFF_EVERY)
    except Exception:
        return float(BUFF_EVERY)


def read_selfheal_enabled(page):
    """Is 🩹 Self-heal-if-attacked enabled (heal+buff checkbox)? Default ON."""
    try:
        return bool(page.evaluate("window.__heal ? (window.__heal.selfheal !== false) : true"))
    except Exception:
        return True


def read_selfheal_pct(page):
    """Own-HP % below which to self-heal (spell on self). Falls back to 70."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.selfhealPct) || null")
        return max(1.0, min(100.0, float(v))) if v else 70.0
    except Exception:
        return 70.0


def read_buff_slot_masks(page):
    """The two slot masks {F2:{'n':bool}, F3:{…}} for grinder-buffs + self-buffs, from the panel. A slot is ON
    unless explicitly False. Returns (buffMask, selfMask); empty dicts (= all on) on error."""
    try:
        r = page.evaluate("[(window.__heal&&window.__heal.buffSlots)||{}, (window.__heal&&window.__heal.selfbuffSlots)||{}]")
        return (r[0] or {}), (r[1] or {})
    except Exception:
        return {}, {}


def _slot_on(mask, page, key):
    """Is (page, keySlot) enabled in a {F2:{'7':false},…} mask? Default True (a missing entry = on)."""
    pg = mask.get(page)
    if not pg:
        return True
    return pg.get(key[-1], True) is not False          # key e.g. 'Digit7' -> slot '7'


def build_selfbuff_list(self_mask):
    """Ordered [(page,key)] of the ticked self-buff slots: F2 2-9 then F3 2-9."""
    out = []
    for page in ("F2", "F3"):
        pg = self_mask.get(page) or {}
        for n in range(2, 10):
            if pg.get(str(n), True) is not False:
                out.append((page, f"Digit{n}"))
    return out


JUMP_STUCK_FAR = 10.0     # units — the assist is "far" from the followed char beyond this (normal follow keeps ~2-5u)
def read_jump_enabled(page):
    """Is 'jump over obstacles when stuck following' enabled (heal-modes checkbox)? Default ON."""
    try:
        return bool(page.evaluate("window.__heal ? (window.__heal.jumpStuck !== false) : true"))
    except Exception:
        return True


def read_jump_secs(page):
    """Seconds of no-progress-toward-the-followed-char before the assist jumps (panel field). Default 3."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.jumpStuckSecs) || null")
        return max(0.5, float(v)) if v else 3.0
    except Exception:
        return 3.0


def jump_if_stuck(act, ctrl, state, press1):
    """Anti-stuck for the FOLLOWING assist (heal / heal+buff): if the followed char (our memory TARGET = the
    Blade we heal+follow) is FAR and we've made no progress toward it (not moving AND not getting closer) for
    the configured time, we're likely stuck on terrain → JUMP (Space) to hop the obstacle, then re-assert
    follow (Z). `state` persists across calls. Cheap: 2 memory reads. No-op when disabled."""
    try:
        if not read_jump_enabled(ctrl):
            return
        now = time.time()
        p = re_sense._pos_xyz(act)
        t = re_sense.read_target(act)
        dist = t.get("dist") if (t and t.get("resolved")) else None
        moved = bool(p and state.get("pos") and math.hypot(p[0] - state["pos"][0], p[2] - state["pos"][2]) > 0.6)
        closer = (dist is not None and state.get("last_dist") is not None and dist < state["last_dist"] - 0.8)
        if p: state["pos"] = p
        state["last_dist"] = dist
        if dist is None or dist < JUMP_STUCK_FAR or moved or closer:
            state["prog_t"] = now                    # near, or making progress toward the Blade → reset timer
            return
        if now - state.get("prog_t", now) >= read_jump_secs(ctrl) and now - state.get("jump_t", 0) >= 1.2:
            set_status(ctrl, f"[assist] 🦘 stuck following (d{int(dist)}) — jumping the obstacle")
            plog.action("key JUMP 'Space'")
            press1("Space"); time.sleep(0.05); press1(FOLLOW_KEY)   # hop + re-assert follow
            state["jump_t"] = now; state["prog_t"] = now            # wait the full window before hopping again
    except Exception:
        pass


def read_mp_threshold(page):
    """Live 'drink MP potion below MP %' as a 0..1 fraction (0 = disabled). Falls back to MP_AT."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.mpAtPct != null) ? window.__heal.mpAtPct : null")
        if v is None:
            return MP_AT
        return max(0.0, min(0.95, float(v) / 100.0))
    except Exception:
        return MP_AT


def read_food_threshold(page):
    """Live 'eat food below HP %' as a 0..1 fraction (0 = disabled). Falls back to FOOD_AT."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.foodAtPct != null) ? window.__heal.foodAtPct : null")
        if v is None:
            return FOOD_AT
        return max(0.0, min(0.95, float(v) / 100.0))
    except Exception:
        return FOOD_AT


def player_hp_frac(act):
    """Our own HP fraction straight from WASM MEMORY (deobfuscated, exact — no screenshot, no HUD-
    visibility dependency, unlike the old pixel/OCR read_vital). Max = buff-aware formula + peak equipment
    surplus (`re_sense.hp_true_max`), shared with the header's MEM row (_mem_stat['hp_gear']) so they agree
    AND it corrects DOWN when an HP buff expires (no permanent over-heal). None if memory is unreadable."""
    try:
        r = re_sense.read_player(act)
    except Exception:
        return None
    if not r:
        return None
    mx, _mem_stat["hp_gear"] = re_sense.hp_true_max(r["cur"], r.get("max_base", 0), _mem_stat["hp_gear"])
    return r["cur"] / mx


def player_mp_frac(act):
    """Our own MP fraction straight from WASM MEMORY (deobfuscated, peak-tracked, vision-free). Shares
    the header's MEM row peak (_mem_stat['mp_peak']). None if memory is unreadable."""
    try:
        r = re_sense.read_player(act)
    except Exception:
        return None
    if not r or "mp" not in r:
        return None
    _mem_stat["mp_peak"] = max(_mem_stat["mp_peak"], r["mp"])
    mx = _mem_stat["mp_peak"] or 1
    return r["mp"] / mx


def food_threshold_eat(act, ctrl, label, last_eat):
    """Self-heal via FOOD: read OUR OWN HP from MEMORY (player_hp_frac) and, if below the panel
    threshold and past the cooldown, eat the food on FOOD_PAGE slot 1 — switch to that page, press 1,
    switch back to the heal page (BUFF_PANELS[0]) so heal-spam resumes. Returns the (possibly updated)
    last-eat timestamp. Disabled when threshold <= 0."""
    thr = read_food_threshold(ctrl)
    if thr <= 0:
        return last_eat
    hpf = player_hp_frac(act)
    if hpf is not None and hpf < thr and time.time() - last_eat > FOOD_COOLDOWN:
        plog.action(f"FOOD eat ({FOOD_PAGE} 1) (HP {int(hpf*100)}%)")
        act.keyboard.press(FOOD_PAGE); time.sleep(PANEL_SWITCH_GAP)            # → food page
        act.keyboard.down(HEAL_HOLD_KEY); time.sleep(random.uniform(*KEY_HOLD)); act.keyboard.up(HEAL_HOLD_KEY)  # slot 1 = food
        time.sleep(PANEL_SWITCH_GAP)
        act.keyboard.press(BUFF_PANELS[0]); time.sleep(PANEL_SWITCH_GAP)       # ← back to the heal page
        set_status(ctrl, f"[{label}] self HP {int(hpf*100)}% (mem) < {int(thr*100)}% — ate food ({FOOD_PAGE}·1)")
        return time.time()
    return last_eat


def mp_threshold_drink(act, ctrl, label, last_drink):
    """Threshold MP potion: read OUR OWN MP from MEMORY (player_mp_frac) and drink the MP key if below
    the panel threshold and past the cooldown. Returns the (possibly updated) last-drink timestamp.
    Disabled when threshold <= 0."""
    thr = read_mp_threshold(ctrl)
    if thr <= 0:
        return last_drink
    mpf = player_mp_frac(act)
    if mpf is not None and mpf < thr and time.time() - last_drink > MP_THRESH_COOLDOWN:
        plog.action(f"key MP-POTION '{MP_KEY}' (MP {int(mpf*100)}%)")
        act.keyboard.down(MP_KEY); time.sleep(0.06); act.keyboard.up(MP_KEY)
        set_status(ctrl, f"[{label}] MP {int(mpf*100)}% (mem) < {int(thr*100)}% — drank potion (0)")
        return time.time()
    return last_drink


def announce_chat(act, text):
    """Type a FIXED line into chat (Enter open → type → Enter send). Closes chat afterward so the
    next heal/buff keypress casts a skill rather than going into the chat box."""
    try:
        plog.action(f"chat send: {text!r}")
        act.keyboard.press("Enter"); time.sleep(0.25)        # open chat
        act.keyboard.type(text, delay=40)                    # type the message
        time.sleep(0.2)
        act.keyboard.press("Enter"); time.sleep(0.15)        # send (closes chat)
    except Exception:
        pass


def read_announce_buff(page):
    """Is the 'announce buff' checkbox ticked? Defaults OFF."""
    try:
        return bool(page.evaluate("!!(window.__heal && window.__heal.announceBuff)"))
    except Exception:
        return False


def target_hp_frac(act, ctrl):
    """The selected target's EXACT HP fraction from MEMORY (deobfuscated cur / peak-tracked max), or None
    if no target / unreadable. Peak resets when the selected target changes; shares the MEM-row peak."""
    try:
        t = re_sense.read_target(act)
    except Exception:
        return None
    if not t or not t.get("has") or not t.get("resolved"):
        return None
    if t.get("id") != _mem_stat["tgt_id"]:
        _mem_stat.update(tgt_id=t.get("id"), tgt_peak=t["hp"])
    _mem_stat["tgt_peak"] = max(_mem_stat["tgt_peak"], t["hp"])
    return t["hp"] / (_mem_stat["tgt_peak"] or 1)


def heal_pass(act, ctrl, press_fn):
    """One iteration's heal decision for the heal / heal+buff loops. When the '💚 heal only if target HP
    below %' gate is ON, read the target's EXACT HP from MEMORY (fast, deobfuscated) and heal ONLY if it's
    below the threshold — so, called once per loop pass with a Heal-delay sleep between passes, it BURSTS at
    the Heal-delay frequency while the target is under the threshold and stops the moment it recovers above.
    When the gate is OFF, or memory can't resolve a target HP (no target / unreadable), heals unconditionally
    (safe default — no vision/OCR fallback). Returns 1 if it healed this pass, else 0. (Single-shot so the
    loop still re-presses follow (Z) + refreshes buffs each pass — no starvation during a long low-HP
    stretch.)"""
    gate, thr = False, 0.9
    try:
        gate = bool(ctrl.evaluate("!!(window.__heal && window.__heal.healIfTarget)"))
        thr = float(ctrl.evaluate("(window.__heal && window.__heal.healIfTargetPct) || 90")) / 100.0
    except Exception:
        pass
    if gate:
        frac = target_hp_frac(act, ctrl)
        if frac is not None:                                  # exact memory HP gate
            if frac < thr:
                press_fn(HEAL_HOLD_KEY)
                set_heal_status(ctrl, f"⚠ healing · target {frac*100:.0f}% < {int(thr*100)}%")
                return 1
            return 0                                          # target healthy -> don't heal
    # gate off, or memory can't resolve a target HP -> heal unconditionally (safe default)
    press_fn(HEAL_HOLD_KEY)
    return 1


def read_heal_poll(page):
    """Live '💓 Heal check every (ms)' — how often to RE-READ the target's HP so a sudden drop is healed fast,
    independent of the (longer) heal delay. Falls back to 0.1s; clamped ≥ 0.03s."""
    try:
        v = page.evaluate("(window.__heal && window.__heal.healPollMs) || null")
        return max(0.03, float(v) / 1000.0) if v else 0.1
    except Exception:
        return 0.1


def heal_wait(act, ctrl, press_fn, heal_delay, poll, stopped, label):
    """Heal decision + the wait until the next pass, but the target's HP is POLLED every `poll` (~0.1s) so a
    sudden drop is healed within ~poll instead of only being noticed once per (longer) heal delay.
      • '💚 heal only if below %' ON: while the target is at/above the threshold, poll fast and just wait
        (return 0 after ~heal_delay so the main loop can cycle its buffs/follow); the INSTANT it drops below,
        HEAL, then wait heal_delay (the skill's cast/cooldown spacing) before returning.
      • gate OFF / unreadable target HP: HEAL immediately, wait heal_delay (same as before, once per pass).
    Scans captcha ~1/s. Returns 1 if it healed this pass, else 0."""
    gate, thr = False, 0.9
    try:
        gate = bool(ctrl.evaluate("!!(window.__heal && window.__heal.healIfTarget)"))
        thr = float(ctrl.evaluate("(window.__heal && window.__heal.healIfTargetPct) || 90")) / 100.0
    except Exception:
        pass
    end = time.time() + max(heal_delay, poll)          # sit here at most ~heal_delay before letting the loop cycle
    nxt_cap = time.time() + 1.0
    while not stopped():
        frac = target_hp_frac(act, ctrl) if gate else None
        if (not gate) or (frac is None) or (frac < thr):           # HEAL (below threshold / heal-unconditionally)
            press_fn(HEAL_HOLD_KEY)
            if gate and frac is not None:
                set_heal_status(ctrl, f"⚠ healing · target {frac*100:.0f}% < {int(thr*100)}%")
            sleep_scan_captcha(act, ctrl, label, heal_delay, stopped)   # skill cast/cooldown before the next cast
            return 1
        if time.time() >= end:
            return 0                                                # healthy for the window → cycle the main loop
        time.sleep(poll)
        if time.time() >= nxt_cap:                                  # keep the ~1/s captcha reaction
            nxt_cap += 1.0
            if gl.solve_captcha(act):
                set_status(ctrl, f"[{label}] captcha — clicked 'not a robot'")
                return 0


def random_chat(act):
    """Type a random human-ish line into chat: Enter (open), type with key jitter, Enter
    (send). Self-contained — chat is closed again afterward, so the next heal/buff keypress
    triggers the skill rather than going into the chat box."""
    msg = random.choice(CHAT_MESSAGES)
    try:
        act.keyboard.press("Enter"); time.sleep(random.uniform(0.2, 0.4))   # open chat
        act.keyboard.type(msg, delay=random.randint(60, 170))               # human-paced typing
        time.sleep(random.uniform(0.15, 0.4))
        act.keyboard.press("Enter"); time.sleep(0.1)                        # send (closes chat)
    except Exception:
        pass


def apply_grind_config(ctrl):
    """Push the control window's grind search params into grind_local (gl) before a
    grind run. gl was just importlib.reload()ed, so these override the file defaults."""
    try:
        cfg = ctrl.evaluate("window.__grind || null")
    except Exception:
        cfg = None
    if not cfg:
        return
    try:
        if cfg.get("healAtPct") is not None:
            gl.HEAL_AT = max(0.05, min(0.95, float(cfg["healAtPct"]) / 100.0))
        if cfg.get("rotateDx") is not None:
            gl.ROTATE_DX = int(float(cfg["rotateDx"]))
        if cfg.get("turnsBeforeWalk") is not None:
            gl.WALK_AFTER_SCANS = max(1, int(float(cfg["turnsBeforeWalk"])))
        if cfg.get("walkPct") is not None:
            gl.WALK_RADIUS = max(0.01, float(cfg["walkPct"]) / 100.0)
        if cfg.get("walkTimeMs") is not None:
            gl.WALK_TIME = max(0.0, float(cfg["walkTimeMs"]) / 1000.0)
        if cfg.get("tiltBackUp") is not None:
            gl.TILT_BACK_UP = max(0, int(float(cfg["tiltBackUp"])))
        if cfg.get("selfBuffEvery") is not None:
            gl.SELF_BUFF_EVERY = max(5.0, float(cfg["selfBuffEvery"]))
        if cfg.get("fightPollMs") is not None:
            gl.FIGHT_POLL = max(0.02, float(cfg["fightPollMs"]) / 1000.0)
        if cfg.get("deathConfirm") is not None:
            gl.DEATH_CONFIRM_CONSECUTIVE_ABSENT = max(1, int(float(cfg["deathConfirm"])))
        if cfg.get("engageSettleMs") is not None:
            gl.ENGAGE_SETTLE = max(0.05, float(cfg["engageSettleMs"]) / 1000.0)
        if cfg.get("selectGapMs") is not None:
            gl.SELECT_GAP = max(0.05, float(cfg["selectGapMs"]) / 1000.0)
        if cfg.get("noDamageSecs") is not None:
            gl.NO_DAMAGE_GIVEUP = max(1.0, float(cfg["noDamageSecs"]))
        if cfg.get("detectMode") in ("nameplate", "cursor", "hybrid", "hybrid2", "hybrid3"):
            gl.DETECT_MODE = cfg["detectMode"]
        if cfg.get("mobTarget") in ("memory", "pixel"):
            gl.TARGET_SENSE = cfg["mobTarget"]
        if cfg.get("avoidGiant") is not None:
            gl.AVOID_GIANT = bool(cfg["avoidGiant"])
        if cfg.get("avoidSkip") is not None:
            gl.AVOID_SKIP = bool(cfg["avoidSkip"])
        # Mob-type selection (🎯 Nearby mob types): scan-for-type + steering + the wanted-species set.
        gl.MOB_SCAN_TYPE = bool(cfg.get("scanMobType"))
        gl.MOB_STEER = bool(cfg.get("steerToMobs"))
        gl.HOVER_VIZ = bool(cfg.get("hoverViz"))          # 🔵 hover-path debug overlay (blue circle+trace)
        sel = cfg.get("mobSel") or {}                     # {species#: ticked?} from the panel checkboxes
        ticked = {str(k) for k, v in sel.items() if v}
        unticked = {str(k) for k, v in sel.items() if not v}
        # Only enforce a whitelist when the user actually CURATED (unticked at least one type). If nothing
        # is unticked (default all-ticked / untouched) treat as no filter = grind ALL — so a species that
        # wasn't nearby at Start isn't silently excluded.
        gl.MOB_GRIND_SET = ticked if unticked else None
    except Exception:
        pass


def set_heal_status(page, text):
    try:
        page.evaluate("(t)=>{const e=document.getElementById('botHealStat');if(e)e.textContent=t;}", text)
    except Exception:
        pass


def read_cmd(page):
    try:
        return page.evaluate("window.__bot") or {}
    except Exception:
        return {}


def set_status(page, text):
    print(f"[status] {text}")
    try:
        page.evaluate("(t)=>{const e=document.getElementById('botStat');if(e)e.textContent=t;}", text)
    except Exception:
        pass


def run_session(act, ctrl, pkt, w, h, mode, gen):
    """act  = the Flyff game page (screenshots, clicks, keys all target this).
    ctrl = the control surface (separate window, or the game page in fallback);
    commands, the heal delay, and status text live here.
    pkt  = packet-capture state; flushed each loop so the monitor stays live."""
    importlib.reload(gl)        # always run the latest grind_local.py (no restart needed)
    w, h = gl.viewport(act)     # re-read current size (handles a resized window)
    apply_grind_config(ctrl)    # override grind search params from the control window
    _exp_ocr.reset()            # drop any stale OCR result from a previous session before this one feeds
    p = plog.start(mode)        # open a per-session RE log (frames + actions); closed in run_panel
    if p:
        print(f"[plog ] logging frames + actions -> {p}")
        plog.action(f"=== START {mode} ===")
        try:
            ctrl.evaluate("(t)=>{const e=document.getElementById('pktLog'); if(e) e.textContent=t;}",
                          f"📝 logging this session → packet_logs/{os.path.basename(p)}")
        except Exception:
            pass
    def stopped():
        flush_packets(ctrl, pkt)             # keep the packet monitor live during any mode
        update_header(act, ctrl, ocr=False)  # FAST pixel header only — the heavy Tesseract OCR must
                                             # NOT run on this hot path (it blocks healing/timing)
        update_shop_db(ctrl)                 # keep the Shop DB table live during a scan (when open)
        update_mem_mobs(act, ctrl)           # 🗺️ nearby-enemies list live during a run (throttled ~10×/s)
        update_grind_minimap(act, ctrl)      # 🗺️ small mob-map in the Grind local panel, live while grinding
        update_grind_re_panel(act, ctrl)     # 🤖 Grind RE sub-tab — mob types + map live while grinding
        update_mem_action(act, ctrl)         # 🎯/⚔ per-mob select/attack button presses
        captcha_pause(act, ctrl, gen)        # 🔔 pause + beep while a bot-detection captcha image-challenge is up
        b = read_cmd(ctrl)
        return b.get("cmd") != "start" or b.get("gen") != gen
    label = {"local": "grind_local", "selected": "grind_selected", "local2": "grind_local_2"}.get(mode, mode)
    report = lambda t: set_status(ctrl, f"[{label}] {t}")
    if mode == "grind_re":
        # 🤖 THROWAWAY-ONLY memory+packet grind (no vision/clicks): mob-type-filtered nearest-mob SELECT (packet)
        # → F1 skills. F1·1=food (below HP%), F1·2–8=self-buffs (on a timer), F1·0=attack. Anti-detect optional.
        try: cfg = ctrl.evaluate("window.__grindRe") or {}
        except Exception: cfg = {}
        def gstat(t):
            try: ctrl.evaluate("(m)=>window.__greStatSet&&window.__greStatSet(m)", t)
            except Exception: pass
        if not cfg.get("risk"):                              # warning toggle must be on
            gstat("⚠️ tick 'Enable Grind RE' (throwaway only) first — stopped")
            try: ctrl.evaluate("window.__bot={cmd:'stop',mode:window.__bot.mode,gen:Date.now()}")
            except Exception: pass
            return
        skill_gap = max(0.0, float(cfg.get("skillDelay", 10)) / 1000.0)     # F1→key gap (ms→s)
        food_pct  = float(cfg.get("foodPct", 50))
        buff_every = max(5.0, float(cfg.get("buffEvery", 120)))
        slots_cfg = cfg.get("buffSlots") or {}
        buff_slots = [n for n in range(2, 9) if slots_cfg.get(str(n)) is not False]   # active self-buffs
        mobsel = cfg.get("mobSel") or {}
        # LOCK the target species at START: grind exactly the species ticked right now. A NEW type that appears
        # mid-run is NOT in this set → never grinded (unselected by default). Empty (list not populated) → None
        # = grind all, as a safe fallback. Snapshotted once here, so panel changes mid-run don't affect the loop.
        wanted = {str(sp) for sp, v in mobsel.items() if v} if mobsel else None
        do_look = bool(cfg.get("look")); do_mouse = bool(cfg.get("mouse")); skip_busy = bool(cfg.get("skipBusy"))
        avoid_giant = bool(cfg.get("avoidGiant"))
        fight_poll = max(0.03, float(cfg.get("fightPollMs", 100) or 100) / 1000.0)   # 💓 kill-check cadence
        smart = bool(cfg.get("smart")); cluster_R = max(5.0, float(cfg.get("clusterR", 30)))
        obs_on = bool(cfg.get("obstacles")); _gre_obs.clear(); obs_prev = None   # 🧱 fresh obstacle map per Start
        if _gre_loaded_obs: _gre_obs.update(_gre_loaded_obs)                      # …unless a saved area map was loaded → re-seed it
        learn = {"ks": 6.0, "ws": 12.0}                      # EWMA learned: kill-time (s) + walk-speed (u/s)
        def _score(cand, cands):                             # 🧠 cluster_value / (travel + kill) — a kills/min proxy
            cost = cand[0] / max(learn["ws"], 0.5) + learn["ks"]     # cand[0]=dist to player
            val = 0.0
            for o in cands:                                  # soft density of wanted mobs AROUND the candidate
                val += math.exp(-math.hypot(o[3] - cand[3], o[5] - cand[5]) / cluster_R)
            return val / max(cost, 0.2)
        def _keep(m):                                        # is this mob a valid target right now?
            if avoid_giant and len(m) > 12 and m[12] == 1:   # NEVER engage a Giant (scale ~2.0) — too dangerous,
                return False                                 #   even if it's attacking us (we flee it, not fight it)
            if (len(m) > 11 and m[11] == 1) or m[8] == 2:    # a NON-giant attacking OUR RM/assist (protect) or US → defend
                return True                                  #   (bypasses species + kill-steal)
            if wanted is not None and str(m[1]) not in wanted:   # else must be a locked wanted species
                return False
            if skip_busy and m[8] == 1:                      # and NOT busy fighting another player (no kill-steal — we do
                return False                                 #   NOT attack mobs fighting other players; only our assist's)
            return True
        def _prio(m):                                        # target order: #0 defend the RM/assist · #1 fight back · #2 grind
            if len(m) > 11 and m[11] == 1: return 0          # protect==1 = mob attacking a FOLLOWER (our RM), not just anyone
            if m[8] == 2: return 1
            return 2
        def tap(k):
            act.keyboard.down(k); time.sleep(random.uniform(*KEY_HOLD)); act.keyboard.up(k)
        def skill(slot):                                     # press F1 then the slot key (skill delay between)
            tap("F1"); time.sleep(skill_gap); tap(str(slot))
        def wait(secs):
            end = time.time() + secs
            while time.time() < end:
                if stopped(): return True
                time.sleep(0.1)
            return False
        def maybe_food(force_gap=3.0):                       # eat food (F1·1) below the HP threshold
            nonlocal last_food
            if food_pct <= 0 or time.time() - last_food < force_gap:
                return
            hp = hpr.read(act)
            if hp and hp.get("frac") is not None and hp["frac"] * 100 < food_pct:
                gstat(f"· {kills} kills · 🍖 food (HP {hp['frac']*100:.0f}%)")
                skill(1); last_food = time.time()
        hpr = re_sense.HpReader()
        re_sense.ensure_ws(act)
        kills = 0; last_food = 0.0; last_mouse_at = 0.0; next_buff = 0.0
        blocked_until = {}                                   # (idLo,idHi) -> retry-after time (obstacle / no path)
        next_drift = time.time() + random.uniform(IDLE_MOUSE_MIN, IDLE_MOUSE_MAX)
        next_look = time.time() + LOOK_EVERY
        gstat(f"▶ running · buffs F1·{buff_slots or '—'} · food<{food_pct:.0f}% · THROWAWAY ONLY")
        while not stopped():
            now = time.time()
            mfree = (now - last_mouse_at) >= MOUSE_ACTION_GAP
            if do_mouse and now >= next_drift and mfree:                     # anti-detect: idle cursor drift
                idle_mouse_drift(act); last_mouse_at = time.time()
                next_drift = time.time() + random.uniform(IDLE_MOUSE_MIN, IDLE_MOUSE_MAX); continue
            if do_look and now >= next_look and mfree:                       # anti-detect: smooth look-around
                gstat(f"· {kills} kills · 🔄 looking around"); smooth_look_around(act); last_mouse_at = time.time()
                next_look = time.time() + LOOK_EVERY * random.uniform(0.65, 1.35); continue
            maybe_food()
            if buff_slots and now >= next_buff:                             # self-buff refresh
                gstat(f"· {kills} kills · ✨ buffing F1·{buff_slots}")
                for n in buff_slots:
                    if stopped(): break
                    skill(n); time.sleep(skill_gap + 0.05)
                next_buff = time.time() + buff_every; continue
            d = re_sense.read_grind_mobs(act, limit=40)
            px_, pz_ = d.get("px", 0.0), d.get("pz", 0.0)
            if obs_on:                                        # 🧱 we're standing/walking here → free space
                if obs_prev is not None: _obs_free(obs_prev[0], obs_prev[1], px_, pz_)
                obs_prev = (px_, pz_)
            try: pj = ctrl.evaluate("[window.__grePaint||[], (window.__grindRe&&window.__grindRe.obsMargin)||0, (window.__grindRe&&window.__grindRe.giantHazard)||0]")   # 🖌️/☢️ live
            except Exception: pj = [[], 0, 22]
            painted = pj[0] or []; pmargin = float(pj[1] or 0); giant_haz = float(pj[2] or 0)
            giants_all = [g for g in d.get("mobs", []) if avoid_giant and len(g) > 12 and g[12] == 1]
            blocked_ids = {k for k, exp in blocked_until.items() if exp > time.time()}   # unreachable mobs, on cooldown
            zones = _route_zones(painted, pmargin, giants_all, giant_haz)                 # painted (+margin) + giant hazards
            # 🚪 if we walked a bit INTO a no-go zone we DON'T issue move commands to leave (they rubberband/glitch).
            # Instead `_path_crosses` ignores the zone we're standing in, so a normal mob target stays reachable and
            # attacking it (F1·0) auto-walks us back out into the grind region. Threats are still defended FIRST
            # (assist-protection → fight-back via the tier priority below), so being attacked in a zone → defend,
            # then once clear of threats we pick a mob that pulls us out. `in_zone` biases that pick toward the exit.
            in_zone = bool(zones) and any(math.hypot(px_ - cx, pz_ - cz) <= r for (cx, cz, r) in zones)
            mobs = [m for m in d.get("mobs", []) if m[2] > 1 and _grindable_mob(m) and _keep(m)
                    and (m[6], m[7]) not in blocked_ids
                    and not _painted_hit(m[3], m[5], painted, pmargin)                       # not in a painted zone
                    and not (giant_haz > 0 and any(math.hypot(m[3] - g[3], m[5] - g[5]) <= giant_haz for g in giants_all))  # not in a giant hazard
                    and not _path_crosses(px_, pz_, m[3], m[5], zones)]                      # and the straight line to it doesn't cross a no-go zone
            close_giants = [g for g in giants_all if g[0] < giant_haz + 15]
            if close_giants:
                gstat(f"· {kills} kills · ☢️ giant nearby (d{close_giants[0][0]}) — keeping {giant_haz:.0f}u clear")
            if not mobs:
                gstat(f"· {kills} kills · no reachable wanted mobs — waiting")
                if wait(1.0): break
                continue
            def pen(c):
                p = 1.0
                if obs_on: p += 0.7 * _obs_path(px_, pz_, c[3], c[5])
                for g in giants_all:                          # 🏃 FLEE: bias targets AWAY from a giant (out to haz+12)
                    md = math.hypot(c[3] - g[3], c[5] - g[5]); lim = giant_haz + 12
                    if md < lim: p += (lim - md) * 0.15
                return p                                       # (path-crosses-a-no-go-zone is a HARD exclude above, not a penalty)
            threats = [x for x in mobs if _prio(x) < 2]       # #0 defend the RM/assist · #1 fight back — win over grind
            if threats:
                m = min(threats, key=lambda x: (_prio(x), x[0]))   # by tier, then NEAREST
            elif in_zone:                                     # 🚪 in a no-go zone + no threats → target a mob that pulls us OUT
                inside = [z for z in zones if math.hypot(px_ - z[0], pz_ - z[1]) <= z[2]]
                zx, zz, _r = max(inside, key=lambda z: z[2] - math.hypot(px_ - z[0], pz_ - z[1]))  # deepest zone
                ovx, ovz = px_ - zx, pz_ - zz; ol = math.hypot(ovx, ovz) or 1.0
                ovx, ovz = ovx / ol, ovz / ol                 # outward unit direction (away from the zone centre)
                def _exit_cost(c):                            # prefer NEAR mobs lying OUTWARD (attacking them walks us out)
                    dx, dzz = c[3] - px_, c[5] - pz_; dl = math.hypot(dx, dzz) or 1.0
                    align = (dx / dl) * ovx + (dzz / dl) * ovz     # +1 = straight out · −1 = toward the centre
                    return dl * (1.0 if align > 0.2 else (3.0 if align > -0.3 else 8.0))
                m = min(mobs, key=_exit_cost)
            elif smart and len(mobs) > 1:                     # #3 grind: 🧠 cluster-aware, obstacle-path-penalized
                m = max(mobs, key=lambda c: _score(c, mobs) / pen(c))
            else:
                m = min(mobs, key=lambda x: x[0] * pen(x))    # nearest, obstacle-path-penalized
            lo, hi = m[6], m[7]; nm = m[9] or ("#" + str(m[1]))
            tier = _prio(m)
            try: ctrl.evaluate("(s)=>{window.__greSmartStats=s}", {"ks": round(learn["ks"], 2), "ws": round(learn["ws"], 2)})
            except Exception: pass
            tag = ("🛡️ DEFEND RM vs " if tier == 0 else "⚔ fight back vs " if tier == 1
                   else ("🚪 exit no-go zone → " if in_zone else ("🧠 " if smart else "🎯 ")))
            gstat(f"· {kills} kills · {tag}{nm} ({m[2]} HP, d{m[0]}"
                  + (f" · ~{learn['ks']:.0f}s/kill {learn['ws']:.0f}u/s)" if smart else ")"))
            if not re_sense.send_select(act, lo, hi):
                if wait(0.5): break
                continue
            time.sleep(skill_gap)
            gstat(f"· {kills} kills · ⚔ {nm} — F1·0"); skill(0)             # attack skill (F1·0)
            t0 = time.time(); init_dist = m[0]; best_hp = m[2]; last_drop = time.time()
            hit_t = None; next_threat = time.time() + 1.0                    # kill wait (+ learn + obstacle detection)
            ppos = re_sense._pos_xyz(act); last_move = time.time()          # track our own movement toward the mob
            next_pos_check = time.time() + 0.4; next_food = time.time()     # movement/food on SLOWER cadences (below)
            # Poll the target's HP FAST (GRE_FIGHT_POLL) so death is caught within ~0.1s of the kill (was ~0.3s
            # → the visible variable gap before re-selecting). Movement/blocked + food run on their OWN slower
            # timers so the per-step position delta stays meaningful (a 0.1s walk step is < the 0.6u threshold).
            while not stopped() and time.time() - t0 < 25:
                st = re_sense.mob_state(act, lo, hi)
                if not st or st["hp"] <= 0:
                    kills += 1
                    if hit_t is not None:                                    # learn kill-time (first-hit → death)
                        kt = time.time() - hit_t
                        if 0.2 < kt < 90: learn["ks"] = 0.7 * learn["ks"] + 0.3 * kt
                    gstat(f"· {kills} kills · ✓ {nm} defeated"); break
                if st["hp"] < best_hp:                                       # taking damage = making progress
                    if hit_t is None:                                        # first damage → learn walk-speed
                        hit_t = time.time(); tt = hit_t - t0
                        if init_dist > 3 and tt > 0.3:
                            ws = init_dist / tt
                            if 0.5 < ws < 60: learn["ws"] = 0.7 * learn["ws"] + 0.3 * ws
                    best_hp = st["hp"]; last_drop = last_move = time.time()
                elif time.time() >= next_pos_check:                         # not damaging → walking? (~0.4s cadence)
                    next_pos_check = time.time() + 0.4
                    p = re_sense._pos_xyz(act)
                    if p and ppos and math.hypot(p[0] - ppos[0], p[2] - ppos[2]) > 0.6:
                        last_move = time.time()                             # we ARE moving = progress
                        if obs_on: _obs_free(ppos[0], ppos[2], p[0], p[2])  # 🧱 the path we walked is free
                    dm = math.hypot(st["x"] - p[0], st["z"] - p[2]) if p else 999
                    if p: ppos = p
                    if dm > 5 and time.time() - last_move > 1.5:            # far from mob AND standing still = BLOCKED
                        if obs_on and p: _obs_blocked(p[0], p[2], st["x"], st["z"])   # 🧱 obstacle between us & mob
                        blocked_until[(lo, hi)] = time.time() + 12
                        gstat(f"· {kills} kills · 🚧 blocked (no path to {nm}) — next mob"); break
                    if time.time() - last_drop > 8:                         # in-range but no damage (backstop)
                        blocked_until[(lo, hi)] = time.time() + 12
                        gstat(f"· {kills} kills · {nm} no damage — re-selecting"); break
                # DEFENSE INTERRUPT: mid-fight, switch to a HIGHER-priority threat (a mob newly attacking us
                # or our RM) instead of grinding the current one to death first.
                if tier > 0 and time.time() >= next_threat:
                    next_threat = time.time() + 1.0
                    dd = re_sense.read_grind_mobs(act, limit=40)
                    threat = next((mm for mm in dd.get("mobs", []) if _grindable_mob(mm) and _keep(mm)
                                   and _prio(mm) < tier and not (mm[6] == lo and mm[7] == hi)), None)
                    if threat:
                        gstat(f"· {kills} kills · ⚔ interrupt → defend {threat[9] or threat[1]}"); break
                if time.time() >= next_food:                                # own-HP food check (~0.5s cadence)
                    next_food = time.time() + 0.5; maybe_food()
                time.sleep(fight_poll)
        gstat(f"■ stopped · {kills} kills")
        return
    if mode == "heal":
        # Ringmaster permanent heal: re-press the heal key on an interval until Stop.
        # A single held keydown casts only once (CDP does no OS key-repeat), so we
        # emulate holding by repeatedly pressing — same press cycle as the food heal.
        # The delay is read LIVE from the control window each cast.
        casts = 0
        next_chat = next_chat_time()
        next_calib = next_calib_time()
        next_map = time.time() + read_map_interval(ctrl)
        next_follow = time.time() + read_follow_interval(ctrl); jump_state = {}
        next_drift = time.time() + random.uniform(IDLE_MOUSE_MIN, IDLE_MOUSE_MAX)
        next_look = time.time() + read_look_interval(ctrl)
        last_mp_drink = 0.0; last_food_eat = 0.0; next_mp_check = time.time(); last_mouse_at = 0.0
        while not stopped():
            mouse_free = lambda: time.time() - last_mouse_at >= MOUSE_ACTION_GAP  # spread mouse actions out
            if gl.solve_captcha(act):                     # anti-bot captcha → human-move + tick the box
                set_status(ctrl, "[heal] captcha — clicked 'not a robot'")
                continue
            if read_mouse_enabled(ctrl) and time.time() >= next_drift and mouse_free():   # idle cursor move
                idle_mouse_drift(act)
                next_drift = time.time() + random.uniform(IDLE_MOUSE_MIN, IDLE_MOUSE_MAX); last_mouse_at = time.time()
            if read_look_enabled(ctrl) and time.time() >= next_look and mouse_free():      # smooth look-around
                set_status(ctrl, "[heal] looking around…")
                smooth_look_around(act)
                next_look = time.time() + read_look_interval(ctrl); last_mouse_at = time.time()
            if read_flee_enabled(ctrl) and flee_from_aggro(act, ctrl):   # aggro mob in view → flee
                set_status(ctrl, "[heal] aggro mob — fleeing")
                continue
            if navigate_los(act):                         # line-of-sight blocked → WASD around it
                set_status(ctrl, "[heal] line of sight blocked — navigating (WASD)")
                continue
            if time.time() >= next_mp_check:              # MP potion + FOOD self-heal by THRESHOLD
                next_mp_check = time.time() + MP_CHECK_EVERY
                last_mp_drink = mp_threshold_drink(act, ctrl, "heal", last_mp_drink)
                last_food_eat = food_threshold_eat(act, ctrl, "heal", last_food_eat)
            if read_chat_enabled(ctrl) and time.time() >= next_chat:   # occasional human-like chatter
                set_status(ctrl, "[heal] saying something in chat…")
                random_chat(act)
                next_chat = next_chat_time()
            if time.time() >= next_calib and mouse_free():   # occasional camera recalibration (human-like)
                set_status(ctrl, "[heal] recalibrating view…")
                try: gl.calibrate_view(act)
                except Exception: pass
                next_calib = next_calib_time(); last_mouse_at = time.time()
            if read_map_enabled(ctrl) and time.time() >= next_map and mouse_free():   # occasional "check the map"
                set_status(ctrl, "[heal] checking the map…")
                map_fiddle(act)
                next_map = time.time() + read_map_interval(ctrl); last_mouse_at = time.time()
            if read_follow_enabled(ctrl) and time.time() >= next_follow:   # re-assert FOLLOW (Z)
                plog.action(f"key FOLLOW '{FOLLOW_KEY}'")
                tap(act, FOLLOW_KEY)
                next_follow = time.time() + read_follow_interval(ctrl)
            if read_follow_enabled(ctrl):                                  # 🦘 hop terrain when stuck following
                jump_if_stuck(act, ctrl, jump_state, lambda k: tap(act, k))
            def _heal_press(k):
                plog.action(f"key HEAL '{k}'"); tap(act, k)
            delay = read_heal_interval(ctrl) * random.uniform(0.85, 1.3)   # jitter so it's not metronomic
            set_status(ctrl, f"[heal] cast {casts} · every {delay:.1f}s")
            set_heal_status(ctrl, f"cast {casts} · every {delay:.1f}s")
            # memory gate: heal iff target HP below threshold — POLLS the HP every read_heal_poll so a spike
            # is healed fast (not just once per heal delay); waits the heal delay between casts + scans captcha.
            casts += heal_wait(act, ctrl, _heal_press, delay, read_heal_poll(ctrl), stopped, "heal")
        set_heal_status(ctrl, "idle")
        return
    if mode == "healbuff":
        # Like heal, but every 'buff every N sec' it runs a buff cycle that inserts a
        # heal before each buff key: 1,2,1,3,1,4,…,1,9,1,0 — then returns to heal-spam.
        # Starts with a buff cycle immediately, then heals (with the heal delay) until
        # the next cycle is due. BUFF_STEP_DELAY paces the presses WITHIN a cycle.
        def press(k):
            plog.action(f"key '{k}'")
            act.keyboard.down(k); time.sleep(random.uniform(*KEY_HOLD)); act.keyboard.up(k)
        def run_buffs(buff_list, label, announce=False):
            """Cast one class of buffs once (each entry = (page, key)). BETWEEN each buff it reads the
            TARGET's exact HP from memory and heals until the target is back at/above the heal threshold
            (the "Heal only if target HP below %" field), THEN casts the next buff — instead of a fixed heal
            count. So a healthy target gets buffed straight through; a hurt one gets topped up first. Switches
            hotbar page only when it changes. If announce is on (and the panel box is ticked) it types the
            ANNOUNCE_BUFF_MSG line into chat first. Also checks OUR OWN HP after every buff and eats food
            (FOOD_PAGE.1) below its threshold — a buff cycle runs many seconds, too long to wait for the outer
            loop's food check. The waits use _tick_sleep so the header's MEM HP/MP row keeps refreshing
            ~every 0.1s mid-cycle instead of freezing between presses. False on abort."""
            nonlocal last_food_eat
            buff_mask, _ = read_buff_slot_masks(ctrl)             # 🎁 which F2/F3 slots to cast on the grinder
            buff_list = [e for e in buff_list if _slot_on(buff_mask, e[0], e[1])]   # skip unticked slots
            step = read_buff_step(ctrl)                            # buff key delay (a buff's cast time)
            heal_delay = read_heal_interval(ctrl)                  # spacing between the between-buff heals
            thr = read_heal_target_threshold(ctrl)                 # heal the target until its HP >= this (0..1)
            # buff<->heal boundaries use the BUFF KEY DELAY (`step`) so the buff gets its full cast time on
            # each side and an adjacent heal press can't clip it; heal<->heal uses the (shorter) heal delay.
            if announce and read_announce_buff(ctrl):             # tell the party we're rebuffing
                set_status(ctrl, f"[heal+buff] announcing: {ANNOUNCE_BUFF_MSG}")
                announce_chat(act, ANNOUNCE_BUFF_MSG)
            set_status(ctrl, f"[heal+buff] {label} — {len(buff_list)} buffs, heal target→{int(thr*100)}%")
            cur_panel = None
            for i, (panel, k) in enumerate(buff_list, 1):
                if stopped():
                    act.keyboard.press(BUFF_PANELS[0]); return False
                if panel != cur_panel:
                    act.keyboard.press(panel); time.sleep(PANEL_SWITCH_GAP); cur_panel = panel   # switch hotbar page
                press(k); _tick_sleep(act, ctrl, step)             # the buff FIRST + buff-key-delay cast wait
                # BETWEEN BUFFS: heal the target until it's back at/above the threshold, THEN cast the next
                # buff. Reads the target's EXACT HP from memory each pass; heals are spaced by the heal delay.
                # Capped at HEAL_BURST_MAX 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.
                healed = 0
                while healed < HEAL_BURST_MAX:
                    if stopped():
                        act.keyboard.press(BUFF_PANELS[0]); return False
                    frac = target_hp_frac(act, ctrl)
                    if frac is not None and frac >= thr:
                        break                                     # target healthy → cast the next buff
                    press(HEAL_HOLD_KEY); healed += 1
                    if frac is None:
                        _tick_sleep(act, ctrl, heal_delay); break  # can't gate → single safety heal
                    set_heal_status(ctrl, f"{label} {i}/{len(buff_list)} · ⚠ target {frac*100:.0f}% < {int(thr*100)}%")
                    _tick_sleep(act, ctrl, heal_delay)
                if healed:                                         # last heal → next buff = buff key delay
                    _tick_sleep(act, ctrl, max(0.0, step - heal_delay))
                before = last_food_eat
                last_food_eat = food_threshold_eat(act, ctrl, "heal+buff", last_food_eat)
                if last_food_eat != before:
                    cur_panel = BUFF_PANELS[0]   # food_threshold_eat left us on the heal page — resync
                set_heal_status(ctrl, f"{label} {i}/{len(buff_list)}")
            act.keyboard.press(BUFF_PANELS[0]); time.sleep(PANEL_SWITCH_GAP)  # back to the heal page for heal-spam
            return True
        def do_one_self_buff(entry):
            """🧙 Cast ONE class-1 buff on OURSELF: DESELECT the grinder (Esc → no target ⇒ the buff self-casts)
            → cast the buff → server-RE-select the grinder (send_select) → re-follow (Z). Returns True on success.
            NOTE: we DESELECT (Esc) rather than server-selecting self — a SELECT packet targeting your OWN id
            no-ops server-side, so 'targeting self' didn't work; a no-target buff self-casts. The RE-select of the
            grinder (a different entity) still uses send_select. Ends on the heal page. THROWAWAY account only."""
            nonlocal blade_id
            me = re_sense.read_self_id(act)
            cur = re_sense.read_target_id(act)                    # the grinder we're currently following
            if cur and cur != me:
                blade_id = cur                                    # keep the grinder id fresh from the live target
            if not blade_id:
                set_heal_status(ctrl, "🧙 self-buff: select the grinder first")
                return False
            page, k = entry
            set_status(ctrl, f"[heal+buff] 🧙 self-buff {page}·{k[-1]} — deselect + self-cast")
            press("Escape")                                      # deselect the grinder → the buff casts on SELF (no target)
            time.sleep(SELF_SELECT_SETTLE)
            act.keyboard.press(page); time.sleep(PANEL_SWITCH_GAP)
            press(k); _tick_sleep(act, ctrl, read_buff_step(ctrl))   # cast on self (buff key delay = cast time)
            act.keyboard.press(BUFF_PANELS[0]); time.sleep(PANEL_SWITCH_GAP)   # back to the heal page
            re_sense.send_select(act, blade_id[0], blade_id[1])   # re-target the grinder so heals/buffs land on it again
            time.sleep(SELF_SELECT_SETTLE)
            press(FOLLOW_KEY)                                     # re-assert follow (Z)
            return True
        def do_self_heal():
            """🩹 Heal OURSELF (same target-switch as self-buff): DESELECT the grinder (Esc → no target ⇒ heal
            self-casts) → cast the HEAL key on self, repeating until our OWN HP recovers (capped) → RE-select the
            grinder + re-follow. For when a mob attacks the RM. Returns True on success. THROWAWAY account only."""
            nonlocal blade_id
            me = re_sense.read_self_id(act)
            cur = re_sense.read_target_id(act)
            if cur and cur != me:
                blade_id = cur
            if not blade_id:
                return False
            thr = read_selfheal_pct(ctrl) / 100.0
            set_status(ctrl, "[heal+buff] 🩹 self-heal — deselect + heal self")
            press("Escape")                                      # deselect → the heal casts on SELF (no target)
            time.sleep(SELF_SELECT_SETTLE)
            act.keyboard.press(BUFF_PANELS[0]); time.sleep(PANEL_SWITCH_GAP)   # ensure the heal page
            healed = 0
            while healed < HEAL_BURST_MAX and not stopped():
                hpf = player_hp_frac(act)                         # OUR OWN HP from memory
                if hpf is not None and hpf >= thr:
                    break                                         # recovered → stop
                press(HEAL_HOLD_KEY); healed += 1                 # heal self (no target)
                set_heal_status(ctrl, f"🩹 self-heal {int((hpf or 0)*100)}% → {int(thr*100)}%")
                _tick_sleep(act, ctrl, read_heal_interval(ctrl))
            re_sense.send_select(act, blade_id[0], blade_id[1])   # re-target the grinder
            time.sleep(SELF_SELECT_SETTLE)
            press(FOLLOW_KEY)                                     # re-assert follow (Z)
            return True
        casts = 0; buffs = 0
        last_food_eat = 0.0
        re_sense.ensure_ws(act)                          # capture window.__ws so 🧙 self-buff's SELECT packets can send
        blade_id = re_sense.read_target_id(act)          # the grinder we support (selected by the user before Start)
        self_buff_idx = 0                                # which class-1 buff to self-cast next
        next_self_buff = time.time() + 5.0               # first self-buff pass shortly after Start
        if run_buffs(BUFF_CLASS1_FULL, "buff cycle 1", announce=True):  # self-buff (F3 7) FIRST, then class 1
            buffs += 1
        run_buffs(BUFF_CLASS2, "buff-2 cycle 1")
        last_buff = time.time()                          # class-1 timer
        last_buff2 = time.time()                         # class-2 timer
        next_chat = next_chat_time()
        next_calib = next_calib_time()
        next_map = time.time() + read_map_interval(ctrl)
        next_follow = time.time() + read_follow_interval(ctrl); jump_state = {}
        next_drift = time.time() + random.uniform(IDLE_MOUSE_MIN, IDLE_MOUSE_MAX)
        next_look = time.time() + read_look_interval(ctrl)
        last_mp_drink = 0.0; next_mp_check = time.time(); last_mouse_at = 0.0
        while not stopped():
            mouse_free = lambda: time.time() - last_mouse_at >= MOUSE_ACTION_GAP  # spread mouse actions out
            update_heal_save(ctrl)                       # 💾 handle the Save-settings button even mid-run
            if gl.solve_captcha(act):                    # anti-bot captcha → human-move + tick the box
                set_status(ctrl, "[heal+buff] captcha — clicked 'not a robot'")
                continue
            if read_mouse_enabled(ctrl) and time.time() >= next_drift and mouse_free():   # idle cursor move
                idle_mouse_drift(act)
                next_drift = time.time() + random.uniform(IDLE_MOUSE_MIN, IDLE_MOUSE_MAX); last_mouse_at = time.time()
            if read_look_enabled(ctrl) and time.time() >= next_look and mouse_free():      # smooth look-around
                set_status(ctrl, "[heal+buff] looking around…")
                smooth_look_around(act)
                next_look = time.time() + read_look_interval(ctrl); last_mouse_at = time.time()
            if read_flee_enabled(ctrl) and flee_from_aggro(act, ctrl):   # aggro mob in view → flee
                set_status(ctrl, "[heal+buff] aggro mob — fleeing")
                continue
            if navigate_los(act):                        # line-of-sight blocked → WASD around it
                set_status(ctrl, "[heal+buff] line of sight blocked — navigating (WASD)")
                continue
            if read_selfheal_enabled(ctrl):              # 🩹 self-heal if attacked (own HP low) — URGENT, before buffs
                ownf = player_hp_frac(act)
                if ownf is not None and ownf * 100 < read_selfheal_pct(ctrl):
                    do_self_heal()
                    if stopped():
                        break
                    continue                             # re-loop; grinder is re-selected, resume normal routine
            if time.time() >= next_mp_check:             # MP potion + FOOD self-heal by THRESHOLD
                next_mp_check = time.time() + MP_CHECK_EVERY
                last_mp_drink = mp_threshold_drink(act, ctrl, "heal+buff", last_mp_drink)
                last_food_eat = food_threshold_eat(act, ctrl, "heal+buff", last_food_eat)
            if read_chat_enabled(ctrl) and time.time() >= next_chat:   # occasional human-like chatter
                set_status(ctrl, "[heal+buff] saying something in chat…")
                random_chat(act)
                next_chat = next_chat_time()
            if time.time() >= next_calib and mouse_free():   # occasional camera recalibration (human-like)
                set_status(ctrl, "[heal+buff] recalibrating view…")
                try: gl.calibrate_view(act)
                except Exception: pass
                next_calib = next_calib_time(); last_mouse_at = time.time()
            if read_map_enabled(ctrl) and time.time() >= next_map and mouse_free():   # occasional "check the map"
                set_status(ctrl, "[heal+buff] checking the map…")
                map_fiddle(act)
                next_map = time.time() + read_map_interval(ctrl); last_mouse_at = time.time()
            if read_follow_enabled(ctrl) and time.time() >= next_follow:   # re-assert FOLLOW (Z)
                press(FOLLOW_KEY)
                next_follow = time.time() + read_follow_interval(ctrl)
            if read_follow_enabled(ctrl):                                  # 🦘 hop terrain when stuck following
                jump_if_stuck(act, ctrl, jump_state, press)
            if time.time() - last_buff >= read_buff_interval(ctrl):        # class-1 buffs due
                if run_buffs(BUFF_CLASS1_FULL, f"buff cycle {buffs + 1}", announce=True):  # self-buff (F3 7) first
                    buffs += 1
                last_buff = time.time()
                if stopped():
                    break
            if time.time() - last_buff2 >= read_buff2_interval(ctrl):      # class-2 buffs due (faster)
                run_buffs(BUFF_CLASS2, "buff-2 cycle")
                last_buff2 = time.time()
                if stopped():
                    break
            if read_selfbuff_enabled(ctrl) and time.time() >= next_self_buff:   # 🧙 self-buff ONE ticked buff (grinder-safe)
                _, self_mask = read_buff_slot_masks(ctrl)
                sb_list = build_selfbuff_list(self_mask)                    # the ticked self-buff slots (F2/F3 2-9)
                if not sb_list:
                    next_self_buff = time.time() + 10.0                     # nothing ticked → check again later
                else:
                    gfrac = target_hp_frac(act, ctrl)                      # only leave the grinder if it's HEALTHY
                    if gfrac is None or gfrac >= read_heal_target_threshold(ctrl):
                        if self_buff_idx >= len(sb_list): self_buff_idx = 0
                        if do_one_self_buff(sb_list[self_buff_idx]):
                            self_buff_idx += 1
                            if self_buff_idx >= len(sb_list):             # whole set cast → wait for the refresh timer
                                self_buff_idx = 0
                                next_self_buff = time.time() + read_selfbuff_interval(ctrl)
                                set_heal_status(ctrl, f"🧙 self-buffed ({len(sb_list)}) · next in {int(read_selfbuff_interval(ctrl))}s")
                            else:                                         # brief pause → heal the grinder between buffs
                                next_self_buff = time.time() + max(2.0, read_heal_interval(ctrl))
                        else:
                            next_self_buff = time.time() + 5.0            # couldn't (no target/ws) → retry soon
                        if stopped():
                            break
                        continue                                          # re-loop: heal the grinder before the next self-buff
                    # grinder hurt → don't leave it; heal first, retry the self-buff next pass (timer stays due)
            delay = read_heal_interval(ctrl)
            left = max(0, int(read_buff_interval(ctrl) - (time.time() - last_buff)))
            left2 = max(0, int(read_buff2_interval(ctrl) - (time.time() - last_buff2)))
            set_status(ctrl, f"[heal+buff] cast {casts} · buffs {buffs} · heal {delay:.1f}s · buff1 {left}s · buff2 {left2}s")
            set_heal_status(ctrl, f"cast {casts} · buffs {buffs} · buff1 {left}s · buff2 {left2}s")
            # memory gate: heal iff target HP below threshold — POLLS the HP every read_heal_poll so a spike
            # is healed fast (not just once per heal delay); waits the heal delay between casts + scans captcha.
            casts += heal_wait(act, ctrl, press, delay, read_heal_poll(ctrl), stopped, "heal+buff")
        set_heal_status(ctrl, "idle")
        return
    if mode == "quickbuff":
        # One-shot: run ALL buffs (both classes) across the hotbar pages ONCE, NO heals, then stop.
        step = read_buff_step(ctrl)
        def press(k):
            plog.action(f"key '{k}'")
            act.keyboard.down(k); time.sleep(0.06); act.keyboard.up(k)
        # self-buff (F3 7) FIRST, then the FULL sequence of buffs (class 1 + class 2), minus any unticked slots
        all_buffs = [BUFF_PREBUFF] + BUFF_CLASS1 + BUFF_CLASS2
        _qmask, _ = read_buff_slot_masks(ctrl)
        all_buffs = [e for e in all_buffs if _slot_on(_qmask, e[0], e[1])]
        set_status(ctrl, "[quickbuff] buffing once (no heal)…")
        cur_panel = None
        for i, (panel, k) in enumerate(all_buffs, 1):
            if stopped():
                act.keyboard.press(BUFF_PANELS[0]); set_heal_status(ctrl, "idle"); return
            if panel != cur_panel:
                act.keyboard.press(panel); time.sleep(PANEL_SWITCH_GAP); cur_panel = panel
            press(k); time.sleep(step)
            set_heal_status(ctrl, f"quick-buff {i}/{len(all_buffs)}")
        act.keyboard.press(BUFF_PANELS[0]); time.sleep(PANEL_SWITCH_GAP)  # back to the heal page
        set_status(ctrl, "[quickbuff] done")
        set_heal_status(ctrl, "quick-buff done")
        ctrl.evaluate("window.__bot={cmd:'stop',mode:window.__bot.mode,gen:Date.now()}")  # one-shot
        return
    if mode in ("selfbuff_once", "essbuff"):
        # One-shot self-buff helpers (deselect → self-cast → re-select the saved target). SELECT packet = throwaway.
        step = read_buff_step(ctrl)
        def press(k):
            plog.action(f"key '{k}'"); act.keyboard.down(k); time.sleep(0.06); act.keyboard.up(k)
        def cast_seq(seq, label):
            cur_panel = None
            for i, (panel, k) in enumerate(seq, 1):
                if stopped(): return False
                if panel != cur_panel:
                    act.keyboard.press(panel); time.sleep(PANEL_SWITCH_GAP); cur_panel = panel
                press(k); time.sleep(step)
                set_heal_status(ctrl, f"{label} {i}/{len(seq)}")
            act.keyboard.press(BUFF_PANELS[0]); time.sleep(PANEL_SWITCH_GAP)   # back to heal page
            return True
        re_sense.ensure_ws(act)                          # so the re-select SELECT packet can send
        tgt = re_sense.read_target_id(act)               # save the grinder we support (if selected)
        if mode == "selfbuff_once":
            _, self_mask = read_buff_slot_masks(ctrl)
            sb = build_selfbuff_list(self_mask)          # the ticked SELF-buff slots (F2/F3 grid)
            if not sb:
                set_heal_status(ctrl, "🧙 no self-buffs ticked");
            else:
                set_status(ctrl, "[self-buff] deselect + self-cast…")
                press("Escape"); time.sleep(SELF_SELECT_SETTLE)             # deselect → the buffs cast on SELF
                cast_seq(sb, "self-buff")
                if tgt:                                                     # restore the grinder + follow
                    re_sense.send_select(act, tgt[0], tgt[1]); time.sleep(SELF_SELECT_SETTLE); press(FOLLOW_KEY)
                set_heal_status(ctrl, "🧙 self-buff done")
        else:  # essbuff = essential self-buffs, then FULL class-1 buff on the target
            if not tgt:
                set_status(ctrl, "[essbuff] select the grinder first"); set_heal_status(ctrl, "🎯 select a target first")
            else:
                qmask, _ = read_buff_slot_masks(ctrl)
                class1 = [e for e in BUFF_CLASS1 if _slot_on(qmask, e[0], e[1])]   # all class 1 (F2 2-9, F3 2-6), minus unticked
                set_status(ctrl, "[essbuff] essential self-buffs…")
                press("Escape"); time.sleep(SELF_SELECT_SETTLE)             # deselect → essential self-buffs on SELF
                cast_seq(ESSENTIAL_SELF_BUFF, "self-buff")
                re_sense.send_select(act, tgt[0], tgt[1]); time.sleep(SELF_SELECT_SETTLE); press(FOLLOW_KEY)  # re-select grinder
                set_status(ctrl, "[essbuff] full buff on target…")
                cast_seq(class1, "target buff")
                press(FOLLOW_KEY)
                set_heal_status(ctrl, "🎯 self + target buffed")
        set_status(ctrl, f"[{mode}] done")
        ctrl.evaluate("window.__bot={cmd:'stop',mode:window.__bot.mode,gen:Date.now()}")  # one-shot
        return
    if mode == "local2":
        import grind_local_2 as gl2
        importlib.reload(gl2)          # hot-reload grind_local_2 too
        gl2.grind(act, w, h, should_stop=stopped, report=report)
        return
    if mode == "shopscan":
        importlib.reload(ss)           # hot-reload shop_scanner too
        auto = bool(ctrl.evaluate("!!(window.__shop && window.__shop.autoRoam)"))
        ss.scan(act, w, h, should_stop=stopped, report=report, auto_roam=auto)
        return
    if mode == "calibrate":
        set_status(ctrl, "calibrating view…")
        gl.calibrate_view(act)
        set_status(ctrl, "view calibrated")
        ctrl.evaluate("window.__bot={cmd:'stop',mode:window.__bot.mode,gen:Date.now()}")  # one-shot
        return
    if mode == "local":
        # The colour LEARN sweep (a 6-view camera "look-around") only matters for the COLOUR name-tag
        # detect modes ("nameplate"/"hybrid"), which filter candidates by learned mob colour. The DEFAULT
        # "cursor" mode finds mobs by the game's CURSOR (curattack), not colour — and the aggro/RED
        # fast-path bypasses the colour filter — so it needs NO colour ranges. Skip the look-around there
        # (faster start; nothing to learn). calibrate_view() still runs inside grind() to fix zoom/tilt.
        if getattr(gl, "DETECT_MODE", "hybrid2") in ("nameplate", "hybrid", "hybrid2", "hybrid3"):
            ranges = gl.learn(act, w, h, report=report, should_stop=stopped)
            if stopped():
                return
            if not ranges:
                set_status(ctrl, "no mobs learned — move + rescan")
                return
        else:
            ranges = None                                  # cursor detection — no colour learn needed
            set_status(ctrl, "[grind_local] cursor detection — skipping colour look-around")
        gl.grind(act, w, h, ranges, should_stop=stopped, report=report)
    elif mode == "selected":
        lc = act.evaluate("window.__lastClick || null")    # the user's pick is on the GAME page
        near = (lc["x"], lc["y"]) if lc else None
        ranges = gl.learn_selected(act, report=report, near=near)
        if stopped():
            return
        # Grind exactly like grind_local: if the colour couldn't be read, ranges is []
        # -> pass None so find_nameplate_mobs targets ANY name-tagged mob. When the
        # colour IS read it just prioritises that mob type.
        gl.grind(act, w, h, ranges or None, should_stop=stopped, report=report)


_heal_save_gen = 0
def update_heal_save(ctrl):
    """💾 Persist heal+buff settings (window.__heal) to heal_settings.json when the panel's Save button is
    pressed (window.__healSaveReq bumps). Cheap poll; only reads the full __heal on an actual save."""
    global _heal_save_gen
    try:
        req = ctrl.evaluate("window.__healSaveReq || 0")
    except Exception:
        return
    if req and req != _heal_save_gen:
        _heal_save_gen = req
        try:
            cfg = ctrl.evaluate("window.__heal || null")
            if cfg:
                _save_heal_settings(cfg)
                ctrl.evaluate("(m)=>{ if(window.__healSaveMsg) window.__healSaveMsg(m); }", "✓ saved — loads next launch")
        except Exception as e:
            try: ctrl.evaluate("(m)=>{ if(window.__healSaveMsg) window.__healSaveMsg(m); }", "save failed: " + str(e))
            except Exception: pass


def run_panel(page, w, h):
    """Drive one bot instance forever. `page` is the Flyff game page. The control UI
    opens in its OWN browser window; if that can't be opened we fall back to the
    in-game injected panel. Shared by control.py (dev host via CDP) and flyffbot.py."""
    ensure_click_hook(page)              # 'Grind selected' needs the click hook on the game page
    ctrl = open_control_window(page)
    in_game = ctrl is None               # fallback mode: panel injected into the game page
    if in_game:
        ctrl = page
        ensure_panel(page); ensure_heal_panel(page)
        print("Control panel ready (top-right of the game window). Click the buttons.")
    else:
        print("Control window ready (separate window). Keep the game window visible.")
    pkt = start_packet_capture(page)     # capture WS frames → control window's RECV/SEND columns
    while True:
      try:
        ensure_click_hook(page)
        if in_game:
            ensure_panel(page); ensure_heal_panel(page)
        elif ctrl.is_closed():           # user closed the control window — reopen it
            ctrl = open_control_window(page) or page
            in_game = ctrl is page
            if in_game:
                ensure_panel(page); ensure_heal_panel(page)
        flush_packets(ctrl, pkt)
        b = read_cmd(ctrl)
        if b.get("cmd") == "start":
            try:
                run_session(page, ctrl, pkt, w, h, b.get("mode", "local"), b.get("gen"))
            except Exception as e:
                # A crash inside a mode must NOT kill the panel — log it, clear the command so we
                # don't instantly re-enter the same failing run, and drop back to idle.
                traceback.print_exc()
                try: set_status(ctrl, f"⚠ error: {e} — stopped, back to idle (see console)")
                except Exception: pass
                try: ctrl.evaluate("window.__bot = {cmd:'stop', mode:(window.__bot&&window.__bot.mode)||'local', gen:Date.now()}")
                except Exception: pass
            finally:
                plog.action("=== STOP ==="); plog.stop()   # close this session's RE log
                try: ctrl.evaluate("(t)=>{const e=document.getElementById('pktLog'); if(e) e.textContent=t;}",
                                   "📝 not logging — start a script to log a session")
                except Exception: pass
            set_status(ctrl, "idle")
        else:
            update_mem_action(page, ctrl)    # 🎯/⚔ per-mob select/attack — FIRST, so buttons are responsive
            update_heal_save(ctrl)           # 💾 persist heal+buff settings when the Save button is pressed
            update_calibrate(page, ctrl)     # live HP-scanner calibration dashboard (when active)
            update_grind_calib(page, ctrl)   # live grind-region calibration dashboard (when active)
            update_shop_calib(page, ctrl)    # live ShopScanner region preview (when active)
            update_shop_db(ctrl)             # live Shop DB table (when its tab is open)
            update_mem_mobs(page, ctrl)      # 🗺️ RE/Memory nearby-enemies list (when that tab is open)
            update_find_yaw(page, ctrl)      # 🧭 camera-yaw scan (RE tab button; one-shot on request)
            update_grind_mobtypes(page, ctrl)  # 🎯 Nearby mob types list (Grind local sub-tab, stopped)
            update_grind_minimap(page, ctrl)   # 🗺️ small mob-map in the Grind local panel
            update_grind_re_panel(page, ctrl)  # 🤖 Grind RE sub-tab — mob types + mini-map
            update_header(page, ctrl)         # OCR'd status header (HP/Exp/mob), throttled
            set_status(ctrl, "idle")
            # Idle sleep, but keep the cheap MEM HP/MP/target row refreshing at ~10Hz (via _tick_sleep)
            # so it doesn't visibly lag between the (slower) full idle iterations. On the RE tab we tick at
            # ~100ms so the nearby-mobs map (update_mem_mobs) refreshes at its ~10×/s throttle.
            _tick_sleep(page, ctrl, 0.1 if (_re_active or _mem_attack["lo"] is not None) else 0.4)
      except KeyboardInterrupt:
        raise
      except Exception:
        # Last-resort guard: never let a transient error (window closing, eval race, etc.) kill the
        # supervisor loop. Log and keep going after a short pause to avoid a tight crash-loop.
        traceback.print_exc()
        time.sleep(0.5)


def main():
    # Dev mode: attach to an already-running host.py via CDP.
    with sync_playwright() as p:
        browser, page = get_page(p)
        w, h = viewport(page)
        run_panel(page, w, h)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nsupervisor stopped.")
