"""re_sense.py — read game state from WASM memory (the vision-free SENSING half of grind_RE).

HP MODEL (reverse-engineered from the CURRENT build, 2026-07-01, and confirmed live 315->147 on damage):
HP is ANTI-CHEAT XOR-OBFUSCATED on the entity object — which is why every plain value/offset scan failed.
On any entity `e` (and the player controller `*(1230588)` IS the player's entity):
    K       = i32 @ e+2744                         (per-entity XOR key)
    curHP   = (i32 @ e+2768) XOR K                 (deobfuscated CURRENT hp — stable, no realloc issue)
    maxHP   = a STAT FORMULA (vtable[148]=func 11450), all inputs are plain reads:
                classFactor = f32 @ (*(e+3492) + 28)      (HP-per-stat class factor, e.g. 0.9)
                level       = i32 @ e+3580
                baseStat    = i32 @ e+3568                 (STA-ish; vtable[92]=func 11460 base)
                buff92      = XOR-deobf slot @ e+1060 / e+1780 (0 when unbuffed)
                sta         = max(baseStat + buff92, 1)
                maxHP       = trunc( (sta*0.01 + 1) * (classFactor * level * 20) + 150 )
Getter in-game is func 487 = min(maxHP_formula, curHP). Decoy slots at e+1224/1944/1260/1980 stay at
salt/tag constants when unbuffed. See reverseEngineeringWASM/wasm_overview.md.

Mobs: identical — read curHP off each registry-entity object (registry @362748) at +2768^+2744.
"""
import os
import sys
import json
import math
import time
import urllib.request

PLAYER_PTR_ADDR = 1230588
HP_KEY_OFF = 2744       # per-entity XOR key
HP_CUR_OFF = 2768       # current HP, XOR-masked with the key

# ── CAMERA YAW (view direction) — found live via the RE-tab "Find camera yaw" differential scan ──
# The live camera yaw is a DEGREES f32 in the (reallocating) camera object. Reliable read = a CONTROLLER-
# ROOTED pointer chain: camObj = u32(...u32(u32(1230588)+offs[0])...) ; yaw_deg = f32(camObj + offs[-1]).
# Rooted at the controller ptr @1230588 (PROVEN reload-stable) with compile-time-fixed offsets → survives
# reloads. (Earlier "static" roots like 0x17db64 were coincidental heap values — different every session,
# read garbage → the yaw "changed on its own". Those are no longer trusted/persisted.)
# Filled by the scan (set_camera_yaw_source) and PERSISTED to camera_calib.json only when reload-stable.
_cam_src = {"root": None, "offs": None, "addr": None}
# Learned mapping world_angle = sign*yaw_rad + off (both in the map's atan2(worldΔz,worldΔx) convention).
_cam_cal = {"sign": None, "off": None, "plus": [], "minus": []}

_CAM_ADDR_JS = ("(a)=>{const dv=new DataView(HEAPU8.buffer); if(a<0||a+4>HEAPU8.buffer.byteLength) return null;"
                " return dv.getFloat32(a,true);}")
_CAM_CHAIN_JS = ("(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);}")


def set_camera_yaw_source(root=None, offs=None, addr=None, persist=False):
    """Tell re_sense HOW to read the yaw: a pointer chain (root,offs) and/or a raw session address. Chain is
    preferred; addr is the in-session fallback. Only a RELOAD-STABLE chain (controller-rooted) should set
    persist=True — that's the one saved to disk so future sessions read it without re-running the scan."""
    _cam_src["root"], _cam_src["offs"], _cam_src["addr"] = root, offs, addr
    if persist and root is not None and offs:
        _save_cam_calib()


def camera_yaw_source_set():
    return _cam_src["addr"] is not None or (_cam_src["root"] is not None and _cam_src["offs"])


# Persist the yaw chain + the deg→world mapping to disk, so both are learned ONCE (first session / after a
# patch) and remembered forever — no re-scan and no re-calibration on every grind start.
_CAM_CALIB_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "camera_calib.json")


def _save_cam_calib():
    try:
        json.dump({"root": _cam_src["root"], "offs": _cam_src["offs"],
                   "sign": _cam_cal["sign"], "off": _cam_cal["off"]}, open(_CAM_CALIB_FILE, "w"))
    except Exception:
        pass


def _load_cam_calib():
    try:
        d = json.load(open(_CAM_CALIB_FILE))
    except Exception:
        return
    if d.get("root") is not None and d.get("offs"):
        _cam_src["root"], _cam_src["offs"] = int(d["root"]), [int(o) for o in d["offs"]]
    if d.get("sign") is not None and d.get("off") is not None:
        _cam_cal["sign"], _cam_cal["off"] = float(d["sign"]), float(d["off"])


_load_cam_calib()   # apply any saved chain + calibration at import


def has_heap(game):
    try:
        return bool(game.evaluate("typeof HEAPU8 !== 'undefined'"))
    except Exception:
        return False


# ── ANCHOR SELF-HEAL (2026-07-03) ───────────────────────────────────────────────────────────────────────
# Static WASM addresses (controller ptr @1230588, registry @362748/362752, cipher key @338112, exp
# @363328/@363384) live in the module's DATA section, which a game REBUILD can SHIFT by a constant delta —
# breaking EVERY memory read at once (a 2026-07-03 patch shifted everything -16 bytes → all MEM features
# went blank). `_LOC` (prepended to every read's JS) LOCATES that shift ONCE per session and caches it in
# window.__S: if the historical controller ptr @1230588 already resolves to a player-like entity use 0, else
# scan nearby static slots outward for the pointer to a player-like entity (finite world pos + sane
# deobfuscated HP) and take its byte-offset from 1230588. Every static address is then read as (ADDR + S), so
# a uniform data-section shift self-heals with NO code edit. Only a CONFIRMED locate is cached (a transient
# not-in-world state leaves window.__S unset so it retries next call). Struct field OFFSETS (+2744/+2768/
# +152/+2656/…) are UNCHANGED by a rebuild, so they stay literal.
_LOC = r"""
  let S=window.__S;
  {
    const _dv=new DataView(HEAPU8.buffer),_len=HEAPU8.buffer.byteLength,_ok=x=>x>0x10000&&x<_len-4096;
    // LIGHT: the controller ptr at address `a` resolves to a player-like entity (finite pos, sane deobf HP).
    const _light=a=>{const c=_dv.getUint32(a,true);if(!_ok(c))return false;
      const px=_dv.getFloat32(c+152,true),pz=_dv.getFloat32(c+160,true);
      if(!(isFinite(px)&&isFinite(pz))||px<50||px>50000||pz<50||pz>50000)return false;
      const hp=(_dv.getInt32(c+2768,true)^_dv.getInt32(c+2744,true));return hp>=1&&hp<100000;};
    // STRONG: LIGHT *and* the REGISTRY at the same delta is a valid entity hash map (3+ resolvable entities).
    // This is the disambiguator — a stale/garbage ptr won't ALSO have a walkable registry at its delta, so a
    // wrong shift (which read HP off the wrong entity) is rejected. Build-consistent, not session-luck.
    const _strong=a=>{if(!_light(a))return false; const sh=a-1230588;
      const map=_dv.getUint32(362748+sh,true),cap=_dv.getUint32(362752+sh,true);
      if(!_ok(map)||cap<1||cap>1000000)return false; let cnt=0; let bud=2000000;   // budget → a wrong shift can't hang validation
      for(let b=0;b<cap&&cnt<3&&bud>0;b++){bud--;let node=_dv.getUint32(map+b*4,true),g=0;
        while(_ok(node)&&g++<1000&&--bud>0){const e=_dv.getUint32(node+16,true);
          if(_ok(e)&&isFinite(_dv.getFloat32(e+152,true)))cnt++; node=_dv.getUint32(node,true);}}
      return cnt>=3;};
    // Trust a shift only after it passed STRONG once (window.__Sok); thereafter LIGHT-validate per read so a
    // page reload / a wrongly-cached shift RE-locates instead of silently reading a wrong entity forever.
    const _valid=(S!==undefined)&&(window.__Sok?_light(1230588+S):_strong(1230588+S));
    if(!_valid){ let _s=null;
      if(_strong(1230588))_s=0; else{for(let d=4;d<=0x8000;d+=4){if(_strong(1230588-d)){_s=-d;break;}if(_strong(1230588+d)){_s=d;break;}}}
      if(_s!==null){window.__S=_s; window.__Sok=true; S=_s;} else {window.__Sok=false; if(S===undefined)S=0;} }
  }
"""


def current_shift(game):
    """The auto-located static-address shift (window.__S) for this session, or 0 if not located yet. Exposed
    so other code (control.py legacy RE tools, the camera-yaw root) can apply the same delta after a patch."""
    try:
        return int(game.evaluate("window.__S||0"))
    except Exception:
        return 0


# one round-trip: controller + deobfuscated curHP + maxHP formula inputs -> computed HP
_HP_JS = "()=>{" + _LOC + """
  const dv=new DataView(HEAPU8.buffer), U=a=>dv.getUint32(a,true)|0, F=a=>dv.getFloat32(a,true);
  const c=U(1230588+S); if(!c) return null;
  const K=U(c+2744);
  const cur=(U(c+2768)^K)|0;
  const mp=(U(c+2772)^K)|0;               // MP = the +4 sibling of HP, same XOR-mask
  // maxHP stat formula (vtable148=func11450, vtable92=func11460), inputs are plain reads:
  const p3492=U(c+3492), classF=p3492?F(p3492+28):0;
  const level=U(c+3580), baseStat=U(c+3568);
  const s1780=(U(c+1780)^K)|0;
  const buff92 = (s1780===-624022441) ? (((U(c+1060)^K)^1901392089)|0) : ((s1780^-1523461208)|0);
  let sta=baseStat+buff92; if(sta<1) sta=1;
  const maxHP=Math.trunc((sta*0.01+1)*(classF*level*20)+150);
  // Exp: STABLE, always-readable u64s in the CONTROLLER struct — curExp@(controller+5744),
  // maxExp@(controller+5904) — realloc-proof like HP. (Found 2026-07-05 by kill-diffing on Claudius:
  // 6/10/14 → 0.8075/1.3459/1.8843 % with maxExp 743, verified live.) The OLD 363328/363384 globals were
  // a DEAD event-cache (0 when idle AND 0 even right after a kill on the current build). exp% = cur/max*100.
  const ecur=dv.getBigUint64(c+5744,true), emax=dv.getBigUint64(c+5904,true);
  const expPct = emax>0n ? Number(ecur*1000000n/emax)/10000 : null;
  return {c:c, cur:cur, mp:mp, max:maxHP, level:level, classF:classF, sta:sta,
          exp:expPct, expCur:ecur.toString(), expMax:emax.toString()};
}"""


def read_player(game):
    """Full player HP snapshot dict {c, cur, max_base, level, classF, sta} or None.
    `cur` (deobfuscated e@2768^K) is EXACT — the true current HP incl. all gear/buff bonuses.
    `max_base` is the BASE stat formula only; it does NOT include equipment/buff HP, so for geared chars
    the real max can exceed it (observed cur>max_base). For a true max, PEAK-TRACK cur (see HpReader)."""
    if not has_heap(game):
        return None
    try:
        r = game.evaluate(_HP_JS)
        if r and "max" in r:                 # normalize JS key -> honest name
            r["max_base"] = r.pop("max")
        return r
    except Exception:
        return None


def read_player_hp(game):
    """Deobfuscated CURRENT player HP (int), or None. Reliable, exact, vision-free (confirmed live)."""
    r = read_player(game)
    return None if r is None else r["cur"]


def hp_true_max(cur, formula, gear_peak):
    """True current maxHP that CORRECTS DOWN when an HP buff expires — the fix for "buff expired but the bot
    kept healing forever". The in-game stat formula max (`max_base`, from sta=baseStat+buff92) already TRACKS
    STA buffs, so it rises AND falls with them. Equipment HP the formula misses is a near-constant surplus. So
    instead of peak-tracking absolute `cur` (which captured the buffed peak and NEVER released it), we
    peak-track the SURPLUS `cur − formula` (buff-invariant: cur and formula both shift with the buff) and
    return max = formula + surplus — which follows the formula DOWN when a buff drops. Returns
    (max, gear_peak). Falls back to peak-of-cur behaviour if the formula is unavailable (degenerate). Clamps
    max ≥ cur so a transient cur>estimate never reads as <100 %."""
    if formula and formula > 0:
        gear_peak = max(gear_peak, cur - formula)      # equipment surplus over the buff-aware formula max
        mx = formula + max(0, gear_peak)
        return (cur if mx < cur else mx), gear_peak
    return max(cur, 1), gear_peak                       # no formula this tick → treat as full (don't heal-starve)


class HpReader:
    """Stateful HP+MP reader: returns {cur, max, frac, mp, mp_max, mp_frac}. HP `max` = the buff-aware stat
    formula PLUS a peak-tracked equipment surplus (`hp_true_max`), so it rises with an HP buff and FALLS again
    when the buff expires (no more permanent over-heal). MP has no formula, so `mp_max` stays pure peak-tracked
    (rises to the highest MP ever seen). Use this for heal-below-%/food-below-%/MP-potion-below-% decisions —
    the vision-free replacement for pixel/OCR HP+MP bar reads."""
    def __init__(self):
        self.hp_gear = 0        # peak equipment-HP surplus over the buff-aware formula max
        self.mp_peak = 0

    def read(self, game):
        r = read_player(game)
        if not r:
            return None
        mx, self.hp_gear = hp_true_max(r["cur"], r.get("max_base", 0), self.hp_gear)
        out = {"cur": r["cur"], "max": mx, "frac": max(0.0, min(1.0, r["cur"] / mx)),
               "level": r.get("level")}
        if "mp" in r:
            self.mp_peak = max(self.mp_peak, r["mp"])
            mmx = self.mp_peak or 1
            out["mp"] = r["mp"]; out["mp_max"] = mmx
            out["mp_frac"] = max(0.0, min(1.0, r["mp"] / mmx))
        return out


def read_entity_hp(game, entity_addr):
    """Deobfuscated CURRENT hp of ANY entity object (e.g. a mob) at the given linear address."""
    try:
        return game.evaluate("""(e)=>{const dv=new DataView(HEAPU8.buffer);
            const K=dv.getUint32(e+2744,true); return (dv.getUint32(e+2768,true)^K)|0;}""", entity_addr)
    except Exception:
        return None


# SELECTED TARGET: controller+2656 (i64) = the current target's entity id (0 = no target). Resolve it in
# the registry (@362748) -> target entity, then read the SAME deobfuscated HP (+2768^+2744), kind (+204),
# and species/name key (entity+188 -> component +48 = "IDS_PROPMOVER_TXT_NNNNNN").
_TGT_JS = "()=>{" + _LOC + """
  const dv=new DataView(HEAPU8.buffer),heap=HEAPU8.buffer.byteLength;
  const c=dv.getUint32(1230588+S,true); if(!c) return null;
  const tid=dv.getBigUint64(c+2656,true);
  if(tid===0n) return {has:false};
  const map=dv.getUint32(362748+S,true),cap=dv.getUint32(362752+S,true),ok=x=>x>0x10000&&x<heap;
  if(!ok(map)||cap<=0||cap>5000000) return {has:true, resolved:false, id:tid.toString()};   // garbage cap → don't hang
  const kl=Number(tid&0xffffffffn)>>>0,kh=Number(tid>>32n)>>>0; let ent=0; let bud=2000000;   // total-work budget: a bad cap/cycle can't hang the renderer
  for(let b=0;b<cap&&!ent&&bud>0;b++){ bud--; let n=dv.getUint32(map+b*4,true),gd=0;
    while(ok(n)&&gd++<100000&&--bud>0){ if(dv.getUint32(n+8,true)===kl&&dv.getUint32(n+12,true)===kh){ent=dv.getUint32(n+16,true);break;} n=dv.getUint32(n,true);} }
  if(!ok(ent)) return {has:true, resolved:false, id:tid.toString()};
  const K=dv.getUint32(ent+2744,true);
  const comp=dv.getUint32(ent+188,true); let name='';
  if(ok(comp)){ const sp=dv.getUint32(comp+48,true); if(ok(sp)){ for(let i=0;i<48;i++){const ch=HEAPU8[sp+i];if(ch<32||ch>126)break;name+=String.fromCharCode(ch);} } }
  const ex=dv.getFloat32(ent+152,true), ez=dv.getFloat32(ent+160,true);
  const dist=Math.round(Math.hypot(ex-dv.getFloat32(c+152,true), ez-dv.getFloat32(c+160,true)));
  return {has:true, resolved:true, id:tid.toString(),
          hp:(dv.getUint32(ent+2768,true)^K)|0, kind:dv.getInt32(ent+204,true), name:name, dist:dist,
          pos:[Math.round(ex),Math.round(dv.getFloat32(ent+156,true)),Math.round(ez)]};
}"""


# NEARBY ENEMIES: walk the entity registry (@362748), keep LIVING loaded monsters: kind@+204==5,
# dead-flag@+220==0, not self (id@+368 != player id), deobfuscated HP (+2768^+2744) > 1 (a real loaded
# monster; hp<=1 = distant/unsynced or non-combat), and a species key at component(+188)+48 starting with
# "IDS_PROPMOVER_TXT_" (excludes other PLAYERS, whose component holds a character-model file). Read pos
# (+152/+156/+160), sort by horizontal distance. NOTE (learned the hard way): `component+4` is NOT a
# reliable monster flag — the same species reads 12 or 14 — so we filter by HP + species-key, not +4.
# Mob numeric LEVEL is static species data (per IDS_PROPMOVER id), NOT per-entity, so it's not included.
_MOBS_JS = "(a)=>{" + _LOC + """
  const dv=new DataView(HEAPU8.buffer),heap=HEAPU8.buffer.byteLength;
  const U=x=>dv.getUint32(x,true)|0,F=x=>dv.getFloat32(x,true),ok=x=>x>0x10000&&x<heap;
  const c=U(1230588+S); if(!c) return [];
  const px=F(c+152),pz=F(c+160),pidLo=U(c+368),pidHi=U(c+372);
  const map=U(362748+S),cap=U(362752+S),seen=new Set(),mobs=[];
  const rd=(p,n)=>{let s='';for(let i=0;i<n;i++){const ch=HEAPU8[p+i];if(ch<32||ch>126)break;s+=String.fromCharCode(ch);}return s;};
  if(!ok(map)||cap<=0||cap>5000000) return [];
  let bud=2000000;                                                    // total-work budget → a bad cap/cycle can't hang the renderer
  for(let b=0;b<cap&&bud>0;b++){ bud--; let node=U(map+b*4),gd=0;
    while(ok(node)&&gd++<100000&&--bud>0){ const e=U(node+16);
      if(ok(e)&&!seen.has(e)){ seen.add(e);
        if(dv.getInt32(e+204,true)===5 && HEAPU8[e+220]===0 && !(U(e+368)===pidLo&&U(e+372)===pidHi)){
          const K=U(e+2744),hp=(U(e+2768)^K)|0, comp=U(e+188);
          if(hp>1 && ok(comp)){ let nm=''; const sp=U(comp+48); if(ok(sp)) nm=rd(sp,40);
            if(nm.indexOf('IDS_PROPMOVER_TXT_')===0){ const x=F(e+152),z=F(e+160),d=Math.hypot(x-px,z-pz);
              if(!(a.maxd>0 && d>a.maxd)){
                // aggro STATE from the mob's own current-target (i64@+2656):
                //   2 = targeting the PLAYER (attacking us / triggered on us)
                //   1 = targeting something else (in combat / aggro'd, not on us)
                //   0 = no target (idle)
                const tlo=U(e+2656)>>>0, thi=U(e+2660)>>>0;
                const aggro = (tlo===(pidLo>>>0)&&thi===(pidHi>>>0)) ? 2 : ((tlo!==0||thi!==0) ? 1 : 0);
                // NAME = the species-def model filename (comp+0), e.g. "mvr_Luia.o3d" -> "Luia".
                let name=''; const mp=U(comp+0); if(ok(mp)){ name=rd(mp,40);
                  const dot=name.lastIndexOf('.'); if(dot>0) name=name.slice(0,dot);
                  if(name.slice(0,4).toLowerCase()==='mvr_') name=name.slice(4); }
                // LEVEL = comp+96 (species-def level); blank if out of the plausible mob range.
                const lv=dv.getInt32(comp+96,true); const level=(lv>=1&&lv<=170)?lv:0;
                mobs.push([Math.round(d), nm.replace('IDS_PROPMOVER_TXT_',''), hp, Math.round(x), Math.round(F(e+156)), Math.round(z), U(e+368), U(e+372), aggro, name, level]); } } } } }
      node=U(node); } }
  mobs.sort((p,q)=>p[0]-q[0]);
  return {px:Math.round(px), pz:Math.round(pz), mobs:mobs.slice(0, a.limit||40)};
}"""


def read_nearby_mobs(game, max_dist=0, limit=40):
    """Nearby attackable monsters from memory + the player's world position (for a top-down map):
    {'px','pz', 'mobs':[[dist, species, hp, x, y, z, idLo, idHi, aggro], ...]} sorted by distance. max_dist=0
    => no cap. Row = [dist, species, hp, x, y, z, idLo, idHi, aggro, name, level]: species = IDS_PROPMOVER
    number; idLo/idHi = entity i64 id (select/attack); aggro state from i64@+2656 (2=targeting player,
    1=in combat, 0=idle); name = species-def model filename (comp+0, e.g. 'Luia'); level = comp+96 (0 if
    out of range, e.g. players/humans)."""
    try:
        return game.evaluate(_MOBS_JS, {"maxd": max_dist, "limit": limit}) or {"px": 0, "pz": 0, "mobs": []}
    except Exception:
        return {"px": 0, "pz": 0, "mobs": []}


# GRIND view for grind_RE: like read_nearby_mobs but ALSO classifies each mob's combat engagement, in ONE
# registry walk. FOLLOWERS = player avatars (mvr_male/mvr_female) whose own target (+2656) is US (our assist
# targets us to heal/follow). Each mob row gets a 12th field `protect` = 1 if the mob is attacking a follower
# (aggro==1 and its target id is a follower's id) → the grind loop attacks it to defend the assist; else 0.
# Players + pets are excluded from the mob list. Row = [dist, species, hp, x,y,z, idLo, idHi, aggro, name,
# level, protect].
_GRIND_MOBS_JS = "(a)=>{" + _LOC + r"""
  const dv=new DataView(HEAPU8.buffer),heap=HEAPU8.buffer.byteLength;
  const U=x=>dv.getUint32(x,true)|0,F=x=>dv.getFloat32(x,true),ok=x=>x>0x10000&&x<heap;
  const c=U(1230588+S); if(!c) return {px:0,pz:0,mobs:[]};
  const px=F(c+152),pz=F(c+160),pidLo=U(c+368),pidHi=U(c+372);
  const map=U(362748+S),cap=U(362752+S),seen=new Set();
  const rd=(p,n)=>{let s='';for(let i=0;i<n;i++){const ch=HEAPU8[p+i];if(ch<32||ch>126)break;s+=String.fromCharCode(ch);}return s;};
  if(!ok(map)||cap<=0||cap>5000000) return {px:Math.round(px),pz:Math.round(pz),mobs:[]};
  const mobs=[]; const followers=new Set(); const modelHp={}, modelLv={};   // per-model HP + level → GIANT flag
  let bud=2000000;                                                    // total-work budget → a bad cap/cycle can't hang the renderer
  for(let b=0;b<cap&&bud>0;b++){ bud--; let node=U(map+b*4),g=0;
    while(ok(node)&&g++<100000&&--bud>0){ const e=U(node+16);
      if(ok(e)&&!seen.has(e)){ seen.add(e);
        if(dv.getInt32(e+204,true)===5 && HEAPU8[e+220]===0 && !(U(e+368)===pidLo&&U(e+372)===pidHi)){
          const comp=U(e+188);
          if(ok(comp)){ const spk=U(comp+48); const key=ok(spk)?rd(spk,40):'';
            if(key.indexOf('IDS_PROPMOVER_TXT_')===0){
              const mp=U(comp+0); const model=ok(mp)?rd(mp,40).toLowerCase():'';
              const tlo=U(e+2656), thi=U(e+2660);
              if(model==='mvr_male.o3d'||model==='mvr_female.o3d'){          // a PLAYER
                if(tlo===pidLo&&thi===pidHi) followers.add((U(e+368)>>>0)+':'+(U(e+372)>>>0));  // targets us
              } else if(model.indexOf('pet')<0){                            // a MOB (not a pet)
                const K=U(e+2744),hp=(U(e+2768)^K)|0;
                if(hp>1){ const x=F(e+152),z=F(e+160),d=Math.hypot(x-px,z-pz);
                  if(!(a.maxd>0 && d>a.maxd)){
                    const aggro=(tlo===pidLo&&thi===pidHi)?2:((tlo!==0||thi!==0)?1:0);
                    let name=''; if(ok(mp)){ name=rd(mp,40); const dot=name.lastIndexOf('.'); if(dot>0)name=name.slice(0,dot);
                      if(name.slice(0,4).toLowerCase()==='mvr_')name=name.slice(4); }
                    const lv=dv.getInt32(comp+96,true); const level=(lv>=1&&lv<=170)?lv:0;
                    (modelHp[name]=modelHp[name]||[]).push(hp);              // collect for the giant HP-outlier calc
                    (modelLv[name]=modelLv[name]||[]).push(level);           // + level (giant = top level w/ a gap)
                    mobs.push([Math.round(d), key.replace('IDS_PROPMOVER_TXT_',''), hp, Math.round(x), Math.round(F(e+156)),
                               Math.round(z), U(e+368), U(e+372), aggro, name, level, (tlo>>>0)+':'+(thi>>>0), 0]);
                  } } }
            } } }
      }
      node=U(node); }
  }
  const med={}, lvx={}, lv2={};                        // per-model median HP + top-2 distinct levels
  for(const k in modelHp){ const a=modelHp[k].slice().sort((x,y)=>x-y); med[k]=a[a.length>>1]; }
  for(const k in modelLv){ const a=Array.from(new Set(modelLv[k])).sort((x,y)=>y-x); lvx[k]=a[0]; lv2[k]=(a.length>1?a[1]:a[0]); }
  const out=mobs.map(r=>{ const prot=(r[8]===1 && followers.has(r[11]))?1:0; r[11]=prot;
    const mh=med[r[9]]||0;                             // GIANT = HP >= 4× same-model median  OR  the top-level
    const hpG = mh>0 && r[2] >= 4*mh;                  //   variant sitting >=3 levels above the rest (Aaron's
    const lvG = (r[10]===lvx[r[9]]) && (lvx[r[9]] >= lv2[r[9]]+3) && (mh>0 && r[2] > 1.5*mh);  //   Small<Kern<Cap<Giant)
    r[12] = (hpG || lvG) ? 1 : 0; return r; });
  out.sort((p,q)=>p[0]-q[0]);
  return {px:Math.round(px), pz:Math.round(pz), mobs:out.slice(0, a.limit||40)};
}"""


def read_grind_mobs(game, max_dist=0, limit=40):
    """Grind-RE mob view: read_nearby_mobs + `protect` + `giant` flags per mob. Row = [dist, species, hp, x, y,
    z, idLo, idHi, aggro, name, level, protect, giant]. aggro: 0 idle / 1 fighting someone else / 2 attacking US.
    protect=1 when the mob is attacking a FOLLOWER (a player whose own target is us — our assist). giant=1 when
    the mob's render SCALE (f32@+2828) is ~2.0 (a Giant). Players + pets excluded."""
    try:
        return game.evaluate(_GRIND_MOBS_JS, {"maxd": max_dist, "limit": limit}) or {"px": 0, "pz": 0, "mobs": []}
    except Exception:
        return {"px": 0, "pz": 0, "mobs": []}


# ── ACTING via the game's OWN code (Route A — no clicking, no crafted packets) ──────────────────────
# SELECT is client-side: write the mob's i64 registry id to controller+2656 (the current-target field).
# ATTACK = call the game's "attack current target" function (func 4148 = wasmTable[ATTACK_FN_IDX]): it reads
# controller+2656, resolves the target, and dispatches the game's real attack command via call 291 (opcode
# 17). Confirmed live: the char auto-attacks the target to death from ONE call. ⚠️ this SENDS the attack —
# throwaway accounts only. BUILD-SPECIFIC: the wasmTable index shifts on a game patch (re-anchor: find the
# no-arg func that does `i64.load offset=2656 → call 208 → call 707 → call 291(controller,17,1,0,0,0)`).
ATTACK_FN_IDX = 1782   # wasmTable index of func 4148 ("attack current target") for the current build


def select_target(game, id_lo, id_hi):
    """Select the entity with i64 registry id (id_lo,id_hi) as the current target — writes controller+2656
    (client-side, sends NO packet). Returns True on success."""
    try:
        return bool(game.evaluate("(a)=>{" + _LOC + """const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+S,true);
            if(!c) return false; dv.setUint32(c+2656,a[0]>>>0,true); dv.setUint32(c+2660,a[1]>>>0,true); return true;}""",
            [id_lo, id_hi]))
    except Exception:
        return False


def attack_target(game, id_lo, id_hi):
    """Select the mob (write +2656) then invoke the game's OWN attack command (func 4148 via wasmTable) so
    the character attacks it. Uses the game's real command path (NOT a crafted packet). ⚠️ SENDS the attack
    command — THROWAWAY ACCOUNTS ONLY. Returns True if the call ran. NOTE: func 4148 attacks if the mob is
    in MELEE RANGE; if it's out of range the game walks toward it CLIENT-SIDE, which the server rubber-bands
    (resets position) — so this is reliable mainly for in-range mobs (movement control is unsolved)."""
    try:
        return bool(game.evaluate("(a)=>{" + _LOC + """const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+S,true);
            if(!c) return false; dv.setUint32(c+2656,a[0]>>>0,true); dv.setUint32(c+2660,a[1]>>>0,true);
            try{ wasmTable.get(a[2])(); return true; }catch(e){ return false; }}""",
            [id_lo, id_hi, ATTACK_FN_IDX]))
    except Exception:
        return False


def invoke_attack(game):
    """Invoke the game's 'attack current target' command (func 4148) WITHOUT re-writing +2656 — the target
    must already be selected. Used to SUSTAIN an attack without re-selecting (re-selecting each tick restarts
    the walk and desyncs). Returns True if the call ran."""
    try:
        return bool(game.evaluate("()=>{ try{ wasmTable.get(%d)(); return true; }catch(e){ return false; } }" % ATTACK_FN_IDX))
    except Exception:
        return False


# ── MOVEMENT via packet-dest REWRITE (M8e) — server-accepted move to any xyz, NO click-projection ─────────
# A genuine click makes func 459 emit a valid move packet ([seq][startXYZ][flags][destXYZ][8-byte tail]); the
# SERVER walks the char to the packet's DEST field (while the client walks to its own local raycast = the
# click point). So: hook ws.send, fire a tiny trigger-click to make the game emit a REAL packet, REWRITE its
# dest bytes to a chosen xyz + recompute the CRC-32C header, and the server moves us there. Then write the
# client pos (+152/160) to match so client+server agree. CONFIRMED live (a reload — which re-fetches the
# SERVER position — showed the char AT the rewritten dest, on both axes). The 8-byte tail is NOT a MAC, so
# keeping the game's own tail + changing only dest is accepted. ⚠️ sends real movement commands — THROWAWAY
# ACCOUNTS ONLY. Distance is chained in <=MOVE_HOP hops (stay under any anti-teleport cap; less teleport-like).
MOVE_HOP = 20.0          # max world-units per hop. KEY: the server ACCEPTS a move of this size ~instantly
                         # (verified live: a 22u rewrite + immediate client-write HELD past 11s). BIGGER moves
                         # the server WALKS over time, so writing the client ahead rubber-bands. Keeping hops
                         # small lets us sync the client right away -> fast + smooth (vs the ~11s lazy resync).
MOVE_SETTLE = 1.6        # seconds to let the server accept a small hop before we write the client pos to match
_MOVE_HOOK_JS = r"""()=>{
  // SELF-HEALING: re-hook whenever our hook isn't the ACTIVE send (a flag alone can lie if the hook got
  // removed by other tooling). We tag our wrapper with .__mvHook and wrap whatever `send` is current.
  const cur = WebSocket.prototype.send;
  if (cur && cur.__mvHook) return true;
  const orig = cur;
  window.__mvOrigSend = orig; window.__mvHookInstalled = true;
  const crc32c = (a)=>{let c=0xFFFFFFFF>>>0;for(let j=0;j<a.length;j++){c^=a[j];for(let k=0;k<8;k++)c=((c>>>1)^(0x82F63B78&(-(c&1))))>>>0;}return c>>>0;};
  const nh = function(d){
    try {
      // ── SELECT-TRANSFORM: when armed with window.__selId=[lo,hi], turn the next POSITION packet into a
      // real SELECT packet (op 04a80a64, flags 5a 01, target i64 id at byte 22) so the SERVER registers the
      // target. Reuses the intercepted packet's OWN fresh 9-byte tail (tail is body-independent — the move
      // rewrite proves it) + its flags[18:19]. One-shot: clears __selId + bumps __selDone. ⚠️ throwaway only.
      const sid = window.__selId;
      if (sid) {
        const KS = 338112 + (window.__S||0);
        let b=null;
        if (d instanceof ArrayBuffer) b=new Uint8Array(d);
        else if (d && d.buffer instanceof ArrayBuffer) b=new Uint8Array(d.buffer, d.byteOffset||0, d.byteLength);
        if (b && b.length>=48 && b[0]===0x1f) {
          const n=b.length-9, body=new Uint8Array(n);
          for (let i=0;i<n;i++) body[i]=b[9+i]^HEAPU8[KS+((i+1)&31)];
          if (body[0]===0x04&&body[1]===0xa8&&body[2]===0x0a&&body[3]===0x64&&body[16]===0x5a&&n>=39) {
            const sb=new Uint8Array(39);
            sb.set(body.subarray(0,16),0);                          // opcode + startXYZ (fresh)
            sb[16]=0x5a; sb[17]=0x01; sb[18]=body[18]; sb[19]=body[19]; sb[20]=0; sb[21]=0;
            const sdv=new DataView(sb.buffer);
            sdv.setUint32(22, sid[0]>>>0, true); sdv.setUint32(26, sid[1]>>>0, true);  // target i64 id
            sb.set(body.subarray(n-9), 30);                          // reuse the packet's own 9-byte tail
            const crc=crc32c(sb);
            const frame=new Uint8Array(9+39); frame[0]=0x1f;
            const fdv=new DataView(frame.buffer); fdv.setUint32(1, 39, true);
            for(let i=0;i<39;i++) frame[9+i]=(sb[i]^HEAPU8[KS+((i+1)&31)])&0xff;
            fdv.setUint32(5, crc, true);
            window.__selId=null; window.__selDone=(window.__selDone||0)+1;
            return orig.call(this, frame);
          }
        }
      }
      const t = window.__rwDest;
      if (t) {
        const KS = 338112 + (window.__S||0);   // cipher keystream, shifted by the auto-located data delta
        let b=null;
        if (d instanceof ArrayBuffer) b=new Uint8Array(d);
        else if (d && d.buffer instanceof ArrayBuffer) b=new Uint8Array(d.buffer, d.byteOffset||0, d.byteLength);
        if (b && b.length===64 && b[0]===0x1f) {
          const body=new Uint8Array(55);
          for (let i=9;i<64;i++) body[i-9]=b[i]^HEAPU8[KS+(((i-9)+1)&31)];
          if (body[16]===0x5a) {
            // rewrite EVERY move packet's dest to the hop target while a move is in progress (NOT one-shot)
            // — otherwise the trigger-click's own local-walk packets reach the server un-rewritten and drag
            // our position toward the click point instead of the target.
            const bv=new DataView(body.buffer);
            bv.setFloat32(22,t[0],true); bv.setFloat32(30,t[1],true); bv.setFloat32(38,t[2],true);
            const crc=crc32c(body); const nb=new Uint8Array(b);
            for (let i=0;i<55;i++) nb[9+i]=(body[i]^HEAPU8[KS+((i+1)&31)])&0xff;
            nb[5]=crc&0xff;nb[6]=(crc>>>8)&0xff;nb[7]=(crc>>>16)&0xff;nb[8]=(crc>>>24)&0xff;
            window.__rwDone=(window.__rwDone||0)+1;
            return orig.call(this, nb);
          }
        }
      }
    } catch(e){ window.__rwErr=''+e; }
    return orig.apply(this, arguments);
  };
  nh.__mvHook = true;
  WebSocket.prototype.send = nh;
  return true;
}"""


def ensure_move_hook(game):
    """Install the one-shot move-packet dest-rewrite hook (idempotent). When window.__rwDest=[x,y,z] is set,
    the NEXT genuine move packet gets its dest rewritten to that xyz (CRC fixed) and __rwDest is cleared;
    window.__rwDone counts fires. A harmless pass-through when __rwDest is null. The SAME hook also does the
    select-transform when window.__selId is set (see send_select)."""
    try: return bool(game.evaluate(_MOVE_HOOK_JS))
    except Exception: return False


# Capture the live game WebSocket instance (window.__ws) so we can send crafted frames directly. A one-shot
# self-removing wrapper grabs `this` on the next send (heartbeats fire ~1/s) then restores the prior send.
_WS_CAP_JS = r"""()=>{ const cur=WebSocket.prototype.send;
  if(cur && cur.__wsCap) return !!(window.__ws && window.__ws.readyState===1);   // already installed
  const w=function(d){ if(!window.__ws || window.__ws.readyState!==1) window.__ws=this; return cur.apply(this,arguments); };
  w.__wsCap=true; WebSocket.prototype.send=w;                                    // PERMANENT idempotent passthrough
  return !!(window.__ws && window.__ws.readyState===1); }"""


def ensure_ws(game):
    """Pre-capture the live game WebSocket into window.__ws (idempotent, cheap). Call it from a frequent poll
    (e.g. the RE-tab mob refresh) so window.__ws is ready BEFORE the first send_select — otherwise the first
    click races the ws-capture (needs one game heartbeat) and fails. Returns True once captured."""
    try: return bool(game.evaluate(_WS_CAP_JS))
    except Exception: return False

# Craft + DIRECT-SEND a SELECT packet from scratch (no teleport, no trigger-click). Frame =
# [0x1f][body-len u32][CRC-32C u32][XOR-body]; SELECT body(39) = 04a80a64 + startXYZ + flags(5a 01 00 00 00 00)
# + target i64 id @22 + 0x04 @30 + 8-byte tail. Tail + sequence are NOT server-validated (proven 2026-07-03:
# a zeroed-tail move AND a fully from-scratch move were both server-accepted), so from-scratch works. a=[lo,hi].
_SELECT_CRAFT_JS = r"""(a)=>{ const S=window.__S||0, KS=338112+S;
  const dv=new DataView(HEAPU8.buffer); const c=dv.getUint32(1230588+S,true); if(!c) return 'no-controller';
  if(!(window.__ws && window.__ws.readyState===1)) return 'no-ws';
  const body=new Uint8Array(39); const bv=new DataView(body.buffer);
  body[0]=0x04;body[1]=0xa8;body[2]=0x0a;body[3]=0x64;
  bv.setFloat32(4,dv.getFloat32(c+152,true),true);
  bv.setFloat32(8,dv.getFloat32(c+156,true),true);
  bv.setFloat32(12,dv.getFloat32(c+160,true),true);
  body[16]=0x5a;body[17]=0x01;                                   // flags: select
  bv.setUint32(22,a[0]>>>0,true); bv.setUint32(26,a[1]>>>0,true);// target i64 id
  body[30]=0x04;                                                 // entity-target field byte
  const crc32c=(x)=>{let cc=0xFFFFFFFF>>>0;for(let j=0;j<x.length;j++){cc^=x[j];for(let k=0;k<8;k++)cc=((cc>>>1)^(0x82F63B78&(-(cc&1))))>>>0;}return cc>>>0;};
  const crc=crc32c(body);
  const nb=new Uint8Array(48); nb[0]=0x1f; const nd=new DataView(nb.buffer);
  nd.setUint32(1,39,true); nd.setUint32(5,crc,true);
  for(let i=0;i<39;i++) nb[9+i]=(body[i]^HEAPU8[KS+((i+1)&31)])&0xff;
  try{ window.__ws.send(nb.buffer); return 'sent'; }catch(e){ return 'ERR '+e; } }"""


def send_select(game, id_lo, id_hi, status=None):
    """Register the mob (id_lo,id_hi) as the target ON THE SERVER by CRAFTING + directly sending a real SELECT
    packet — so an in-game skill/attack lands (memory +2656 alone is CLIENT-only → server has no target → the
    char shows red markers + walks in but deals ZERO damage). From-scratch craft (no teleport, no trigger-click,
    no movement) — the tail + sequence are NOT server-validated (proven live). Also writes +2656 so the client
    UI matches. ⚠️ sends a real command — THROWAWAY ONLY. Returns True if the select frame was sent."""
    select_target(game, id_lo, id_hi)                      # client-side target too (UI/markers)
    def _say(s):
        if status:
            try: status(s)
            except Exception: pass
    try:
        if not game.evaluate(_WS_CAP_JS):                  # ensure the ws instance is captured
            for _ in range(40):
                time.sleep(0.1)
                if game.evaluate("()=>!!(window.__ws && window.__ws.readyState===1)"):
                    break
        r = game.evaluate(_SELECT_CRAFT_JS, [id_lo & 0xffffffff, id_hi & 0xffffffff])
        if r == "no-ws":                                   # first-call race: wait a heartbeat + retry once
            for _ in range(40):
                time.sleep(0.1)
                if game.evaluate("()=>!!(window.__ws && window.__ws.readyState===1)"):
                    break
            r = game.evaluate(_SELECT_CRAFT_JS, [id_lo & 0xffffffff, id_hi & 0xffffffff])
        _say("🎯 target sent to server — cast your skill" if r == "sent" else f"🎯 select failed: {r}")
        return r == "sent"
    except Exception as e:
        _say(f"🎯 select error: {e}")
        return False


def _pos_xyz(game):
    try:
        return game.evaluate("()=>{" + _LOC + """const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+S,true);
            return c?[dv.getFloat32(c+152,true),dv.getFloat32(c+156,true),dv.getFloat32(c+160,true)]:null;}""")
    except Exception:
        return None


def _write_pos(game, x, y, z):
    try:
        game.evaluate("(t)=>{" + _LOC + """const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+S,true);
            if(c){dv.setFloat32(c+152,t[0],true);dv.setFloat32(c+156,t[1],true);dv.setFloat32(c+160,t[2],true);}}""",
            [x, y, z])
    except Exception:
        pass


def move_to_server(game, x, y, z, status=None):
    """Move the char to world (x,y,z) — SERVER-ACCEPTED — via the packet-dest rewrite primitive (M8e), NO
    world->screen projection. Chains <=MOVE_HOP hops; per hop: arm the rewrite, fire a tiny trigger-click on
    empty ground (func 459 emits a real move packet -> rewritten to the hop target -> server WALKS there over
    ~seconds), then WAIT for the server-arrival snap (the client jumps onto the hop target when the server
    arrives — we do NOT write the client pos, which would rubber-band). `status`=optional callback(str).
    ⚠️ sends movement commands — THROWAWAY ONLY. Returns {'ok':bool,'hops':int,'msg':str}."""
    import math
    ensure_move_hook(game)
    try:
        vp = game.evaluate("()=>({w:innerWidth,h:innerHeight})")
        W, H = vp["w"], vp["h"]
    except Exception:
        return {"ok": False, "hops": 0, "msg": "no viewport"}
    # trigger-click candidates: ground CLOSE to the char (small offset = minimal wrong-way local walk = less
    # jumpy), avoiding the HUD (top) + hotbar (bottom). Just below screen-centre where the char stands.
    trig = [(0.50, 0.60), (0.44, 0.58), (0.56, 0.58), (0.50, 0.64), (0.40, 0.62), (0.60, 0.62)]
    hops = 0
    try:
        for _ in range(14):
            cur = _pos_xyz(game)
            if not cur:
                return {"ok": False, "hops": hops, "msg": "no controller"}
            dx, dz = x - cur[0], z - cur[2]
            d = math.hypot(dx, dz)
            if d < 2.0:
                return {"ok": True, "hops": hops, "msg": f"arrived ({hops} hops)"}
            hop_len = min(d, MOVE_HOP)
            if d > MOVE_HOP:
                hx, hy, hz = cur[0] + dx / d * MOVE_HOP, cur[1], cur[2] + dz / d * MOVE_HOP
            else:
                hx, hy, hz = x, y, z
            # arm the rewrite to THIS hop target; it stays armed (rewriting every move packet to it) until we
            # move it to the next hop / disarm — so the trigger-click's own local-walk packets can't pollute.
            game.evaluate("(t)=>{window.__rwDest=t; window.__rwDone=0;}", [hx, hy, hz])
            fired = False
            for fx, fy in trig:
                try:
                    game.mouse.move(int(W * fx), int(H * fy)); time.sleep(0.05)
                    game.mouse.down(); time.sleep(0.04); game.mouse.up()
                except Exception:
                    pass
                time.sleep(0.26)
                if (game.evaluate("window.__rwDone||0") or 0) > 0:
                    fired = True; break
            if not fired:
                return {"ok": False, "hops": hops, "msg": "trigger-click never hit ground (mobs/UI in the way)"}
            # A SMALL hop is accepted by the server ~instantly, so after a short settle we WRITE the client pos
            # to the hop target and it HOLDS — fast + smooth. (A brief settle avoids writing before the server
            # commits.) Verify it didn't get pulled back; if it did, the hop was walked -> wait + re-sync.
            time.sleep(MOVE_SETTLE)
            _write_pos(game, hx, hy, hz)
            time.sleep(0.4)
            p = _pos_xyz(game)
            if p and math.hypot(p[0] - hx, p[2] - hz) > 5.0:   # pulled back -> server still walking; give it time
                time.sleep(1.6); _write_pos(game, hx, hy, hz); time.sleep(0.3)
            hops += 1
            if status:
                try: status(f"→ {hops} hop(s) · {int(d)}u to go")
                except Exception: pass
        return {"ok": True, "hops": hops, "msg": f"stopped after {hops} hops"}
    finally:
        try: game.evaluate("window.__rwDest=null")   # disarm — normal play/attacks unaffected
        except Exception: pass


# ── GO-TO + ATTACK a specific mob by id (camera-INDEPENDENT: memory move + the game's own attack) ─────────
MELEE_RANGE = 4.0        # world-units; teleport this close so func 4148 attacks IN RANGE (no walk, no rubber-band)


def mob_state(game, id_lo, id_hi):
    """{'x','y','z','hp'} of the registry entity with i64 id (id_lo,id_hi), or None if it's gone.
    Walks the entity registry (@362748); hp is the deobfuscated (+2768^+2744) current HP."""
    try:
        return game.evaluate("(a)=>{" + _LOC + r"""const dv=new DataView(HEAPU8.buffer);const heap=HEAPU8.buffer.byteLength;
          const ok=x=>x>0x10000&&x<heap; const map=dv.getUint32(362748+S,true),cap=dv.getUint32(362752+S,true);
          if(!ok(map)||cap<=0||cap>5000000) return null;                   // garbage cap → don't hang the renderer
          let bud=2000000;                                                 // total-work budget → a bad cap/cycle can't hang
          for(let b=0;b<cap&&bud>0;b++){bud--;let n=dv.getUint32(map+b*4,true),gd=0;while(ok(n)&&gd++<100000&&--bud>0){const e=dv.getUint32(n+16,true);
            if(ok(e)&&dv.getUint32(e+368,true)===(a[0]>>>0)&&dv.getUint32(e+372,true)===(a[1]>>>0))
              return {x:dv.getFloat32(e+152,true),y:dv.getFloat32(e+156,true),z:dv.getFloat32(e+160,true),
                      hp:(dv.getUint32(e+2768,true)^dv.getUint32(e+2744,true))|0};
            n=dv.getUint32(n,true);}}return null;}""", [id_lo & 0xffffffff, id_hi & 0xffffffff])
    except Exception:
        return None


ATTACK_CURSOR = "curattack"


def _cursor_kind(game):
    """Read the game's CSS cursor name ('curbase'/'curattack'/…). 'curattack' = an attackable mob is under
    the pointer (the game's own ground-truth). Not in screenshots — read from the DOM."""
    try:
        css = game.evaluate("()=>{const c=document.querySelector('canvas');return (c&&c.style.cursor)||document.body.style.cursor||(c&&getComputedStyle(c).cursor)||'';}")
        import re as _re
        m = _re.search(r"cursor/(\w+)\.", css or "")
        return m.group(1) if m else ""
    except Exception:
        return ""


def _scan_click_attack(game, settle=0.018, ring=340, step=22):
    """Right after teleporting onto/next to the mob, it renders ~near screen-centre. Hover a NEAREST-FIRST
    spiral out from centre; on the game's `curattack` cursor, real-click SELECT then real-click ATTACK (the
    proven two-click engage). Camera-INDEPENDENT (no projection) + patch-proof (real clicks, not func 1782).
    Returns the clicked (sx,sy) or None. ⚠️ sends a real attack — THROWAWAY ONLY."""
    import math
    vp = game.evaluate("()=>({w:innerWidth,h:innerHeight})")
    W, H = vp["w"], vp["h"]
    cx, cy = W * 0.5, H * 0.5
    for r in range(0, ring, step):
        n = max(1, int(2 * math.pi * max(r, 1) / 26))
        for i in range(n):
            a = 2 * math.pi * i / n
            sx, sy = int(cx + r * math.cos(a)), int(cy + r * math.sin(a))
            if not (4 < sx < W - 4 and 4 < sy < H - 4):
                continue
            game.mouse.move(sx, sy); time.sleep(settle)
            if _cursor_kind(game) == ATTACK_CURSOR:
                game.mouse.down(); time.sleep(0.05); game.mouse.up(); time.sleep(0.14)   # SELECT
                game.mouse.move(sx, sy); time.sleep(0.02)
                game.mouse.down(); time.sleep(0.05); game.mouse.up()                      # ATTACK
                return (sx, sy)
    return None


def goto_and_attack(game, id_lo, id_hi, status=None, passes=3):
    """Go-to + attack a mob with NO vision/projection/calibration and NO stale attack func:
      1) TELEPORT onto the mob (server-accepted `move_to_server`, camera-independent, reliable — dist ~0),
      2) hover-scan out from screen-centre for the game's `curattack` cursor and real-click SELECT+ATTACK.
    The game then auto-attacks to death. Retries (mob may drift between teleport and click — fast flyers).
    ⚠️ SENDS movement + attack commands — THROWAWAY ONLY. Returns True once damage is confirmed."""
    st = mob_state(game, id_lo, id_hi)
    if not st:
        return False
    hp0 = st["hp"]
    def _say(s):
        if status:
            try: status(s)
            except Exception: pass
    for _ in range(passes):
        st = mob_state(game, id_lo, id_hi)
        if not st or st["hp"] <= 0:
            return True                                            # already dead
        _say(f"🚶 teleporting to the mob ({int(st['hp'])} HP)…")
        move_to_server(game, st["x"], st["y"], st["z"])            # onto it, dist ~0
        _say("⚔ clicking to attack…")
        if not _scan_click_attack(game):
            _say("⚔ mob not under cursor (drifted?) — retrying")
            continue
        time.sleep(1.2)
        h = (mob_state(game, id_lo, id_hi) or {}).get("hp", 0)
        if h <= 0 or h < hp0:
            return True                                            # damage confirmed; game finishes it
        hp0 = min(hp0, h) if h else hp0
    return False


def read_target(game):
    """The player's SELECTED target from memory, or None on error.
    -> {has:bool, resolved:bool, id, hp, kind, name, pos}.  has=False => nothing selected.
    `name` is the species key e.g. 'IDS_PROPMOVER_TXT_000002' (canonical id; display name not resolved).
    `kind` 5 = attackable mob. `hp` = exact deobfuscated current HP of the target entity."""
    try:
        return game.evaluate(_TGT_JS)
    except Exception:
        return None


def read_self_id(game):
    """(lo, hi) of OUR OWN player entity's i64 id (controller+368/+372) — used to server-select self, or None."""
    try:
        r = game.evaluate("()=>{" + _LOC + """const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+S,true);
            return c?[dv.getUint32(c+368,true),dv.getUint32(c+372,true)]:null;}""")
        return (r[0], r[1]) if r else None
    except Exception:
        return None


def read_target_id(game):
    """(lo, hi) of the CURRENTLY SELECTED target's i64 id (controller+2656/+2660), or None if nothing selected."""
    try:
        r = game.evaluate("()=>{" + _LOC + """const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+S,true);
            if(!c)return null;const lo=dv.getUint32(c+2656,true),hi=dv.getUint32(c+2660,true);return (lo||hi)?[lo,hi]:null;}""")
        return (r[0], r[1]) if r else None
    except Exception:
        return None


# ── CAMERA YAW read + calibration ────────────────────────────────────────────────────────────────────
def read_camera_yaw_deg(game):
    """Live camera yaw in DEGREES via the source set by the scan (chain preferred, else addr), or None."""
    src = _cam_src
    try:
        if src["root"] is not None and src["offs"]:
            v = game.evaluate(_CAM_CHAIN_JS, {"root": src["root"], "offs": src["offs"]})
            if v is not None:
                return v
        if src["addr"] is not None:
            return game.evaluate(_CAM_ADDR_JS, src["addr"])
    except Exception:
        return None
    return None


def read_camera_yaw_rad(game):
    """Live camera yaw in RADIANS (raw, uncalibrated), or None."""
    d = read_camera_yaw_deg(game)
    return None if d is None else math.radians(d)


def _wrap(a):
    return math.atan2(math.sin(a), math.cos(a))


def _circ_var(vals):
    """Circular variance (0 = all identical, 1 = spread out) of a list of angles."""
    if not vals:
        return 1.0
    c = sum(math.cos(v) for v in vals) / len(vals)
    s = sum(math.sin(v) for v in vals) / len(vals)
    return 1.0 - math.hypot(c, s)


def calibrate_camera(world_fwd_rad, game):
    """Pair the KNOWN camera-forward world angle (e.g. from a walk's measured heading) with the live yaw to
    learn the mapping world_angle = sign*yaw_rad + off. Both sign hypotheses (+1/-1) are tracked; the one
    whose offset stays CONSISTENT across differing yaws wins. Call whenever a fresh world-forward is measured."""
    y = read_camera_yaw_rad(game)
    if y is None or world_fwd_rad is None:
        return
    _cam_cal["plus"].append(_wrap(world_fwd_rad - y))    # if sign=+1, off would be this
    _cam_cal["minus"].append(_wrap(world_fwd_rad + y))   # if sign=-1, off would be this
    for k in ("plus", "minus"):
        if len(_cam_cal[k]) > 10:
            _cam_cal[k] = _cam_cal[k][-10:]
    if len(_cam_cal["plus"]) >= 2:
        vp, vm = _circ_var(_cam_cal["plus"]), _circ_var(_cam_cal["minus"])
        if vp <= vm:
            _cam_cal["sign"], lst = 1.0, _cam_cal["plus"]
        else:
            _cam_cal["sign"], lst = -1.0, _cam_cal["minus"]
        _cam_cal["off"] = math.atan2(sum(math.sin(v) for v in lst) / len(lst),
                                     sum(math.cos(v) for v in lst) / len(lst))
        # confident (≥3 consistent samples, low variance) → persist so it's never re-learned
        if len(lst) >= 3 and min(vp, vm) < 0.03:
            _save_cam_calib()


def camera_calibrated():
    return _cam_cal["sign"] is not None and _cam_cal["off"] is not None


def camera_heading(game):
    """The camera's view direction as a world angle (rad, map atan2(z,x) convention), using the live yaw +
    the learned calibration — or None if not calibrated yet / yaw unreadable. Exact + drift-free."""
    if not camera_calibrated():
        return None
    y = read_camera_yaw_rad(game)
    if y is None:
        return None
    return _wrap(_cam_cal["sign"] * y + _cam_cal["off"])


def camera_arrow_heading(game):
    """For the mini-map self-ARROW: the camera view direction, ALWAYS shown when the yaw is readable.
    Calibrated (exact, map-aligned) once a few walks have run; before that the RAW yaw (rotates correctly
    with the camera, absolute may be off until calibration snaps it to the map's north). None only if the
    yaw chain can't be read (e.g. offsets shifted by a game patch → re-run the scan)."""
    if camera_calibrated():
        h = camera_heading(game)
        if h is not None:
            return h
    return read_camera_yaw_rad(game)


# ── AUTO-LOCATE the yaw (session address) — no manual button, no reload-stable chain needed ──────────────
# There's no reload-stable pointer to the camera on this build (controller BFS doesn't reach it, "static"
# roots are coincidental heap). So each session we RE-FIND the yaw's current address by the same differential
# scan: rotate the camera in equal steps (only camera state changes) and keep the f32 that tracks it linearly
# over an angle-like range and REVERSES when we rotate back. Cheap, ~2s, done once per session.
_YL_INIT = ("()=>{const f=new Float32Array(HEAPU8.buffer),n=f.length;const a=[],v=[];"
            "for(let i=0;i<n;i++){const x=f[i];if(x!==0&&Number.isFinite(x)&&x>=-400&&x<=400){a.push(i*4);v.push(x);}}"
            "window.__yl={a:a,v:v,s:null};return a.length;}")
_YL_STEP = ("()=>{const f=new Float32Array(HEAPU8.buffer),yl=window.__yl,hs=!!yl.s;const na=[],ns=[],nv=[];"
            "for(let i=0;i<yl.a.length;i++){const ad=yl.a[i],cur=f[ad>>2],pv=yl.v[i];"
            "if(!Number.isFinite(cur)||cur<-400||cur>400)continue;if(Math.abs(cur-pv)<1e-4)continue;"
            "na.push(ad);ns.push(hs?yl.s[i].concat([cur]):[pv,cur]);nv.push(cur);}"
            "yl.a=na;yl.s=ns;yl.v=nv;return na.length;}")
_YL_DUMP = "()=>({a:window.__yl.a,s:window.__yl.s})"
_YL_REV = "()=>{const f=new Float32Array(HEAPU8.buffer);return window.__yl.a.map(ad=>f[ad>>2]);}"


def autolocate_yaw(game, drag, fwd=6, rev=6):
    """Re-find the camera yaw's current session address by a differential scan. `drag(sign)` rotates the
    camera by sign*step (a right-drag). Sets the session read-source and returns True on success. Called
    automatically at grind start so the arrow/facing work without pressing the button."""
    try:
        if not has_heap(game):
            return False
        game.evaluate(_YL_INIT)
        for _ in range(fwd):
            drag(+1)
            if game.evaluate(_YL_STEP) == 0:
                return False
        d = game.evaluate(_YL_DUMP)
        for _ in range(rev):                              # rotate back to confirm reversal
            drag(-1)
        revvals = game.evaluate(_YL_REV)
        best = None                                       # (lin, addr)
        for i, ad in enumerate(d["a"]):
            s = d["s"][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) < 1e-4:
                continue
            var = sum((x - md) ** 2 for x in deltas) / len(deltas)
            lin = math.sqrt(var) / (abs(md) + 1e-9)
            rng = max(s) - min(s)
            reversed_ok = (revvals[i] - s[-1]) * md < 0
            if reversed_ok and 15.0 <= rng <= 370.0 and lin < 1.0:
                if best is None or lin < best[0]:
                    best = (lin, ad)
        if best is None:
            return False
        set_camera_yaw_source(addr=best[1])               # session address (not persisted)
        return True
    except Exception:
        return False


# ---- standalone test: live HP read loop ----
def _find_port():
    for p in range(9222, 9251):
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{p}/json/version", timeout=0.3); return p
        except Exception:
            pass
    return None


def main():
    from playwright.sync_api import sync_playwright
    import grind_local as gl
    port = int(sys.argv[1]) if len(sys.argv) > 1 else _find_port()
    if not port:
        print("No live game on 9222-9250."); return
    ctx = sync_playwright().start().chromium.connect_over_cdp(f"http://127.0.0.1:{port}").contexts[0]
    game = next((pg for pg in ctx.pages if "flyff" in (pg.url or "").lower()), ctx.pages[0])
    print(f"connected (port {port}). DEOBFUSCATED memory HP vs vision (max = peak-tracked). Ctrl+C.\n")
    hp = HpReader()
    while True:
        r = hp.read(game)
        try:
            px = gl.hp_fraction(gl.grab(game))
        except Exception:
            px = None
        if r:
            pv = "—" if px is None else f"{px*100:5.1f}%"
            print(f"  memHP {r['cur']:>5}/{r['max']:<5} ({r['frac']*100:5.1f}%)   vision {pv}   [lvl {r['level']}]")
        else:
            print("  (no controller / heap)")
        time.sleep(1.0)


if __name__ == "__main__":
    main()
