survev.io script -(VoidBacon)

new shit is on people post suggestions in discord

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         survev.io script -(VoidBacon)
// @namespace    VoidBacon
// @version      3.2
// @author       John pork
// @match        *://survev.io/*
// @description  new shit is on people post suggestions in discord
// @grant        none
// @run-at       document-start
// @license MIT
// ==/UserScript==

/*John pork says have fun*/
(function () {
  'use strict';

  // ── App capture via console.log hook ──────────────────────────
  const inject = document.createElement('script');
  inject.textContent = `(function(){
    const _log = console.log.bind(console);
    console.log = function(...args) {
      for (const a of args) {
        if (a && typeof a === 'object' && 'audioManager' in a && 'pixi' in a && 'game' in a && 'inputBinds' in a) {
          window.__Re = a;
        }
      }
      return _log(...args);
    };
  })();`;
  (document.head || document.documentElement).appendChild(inject);
  inject.remove();

  function findRe() {
    return window.__Re || null;
  }

  // ── Config ────────────────────────────────────────────────────
  const cfg = { Esp:true, names:true, healthBars:true, lockOn:false, magnet:false, xray:false, lootEsp:false, grenTimer:true };
  const KEY_W=87, KEY_A=65, KEY_S=83, KEY_D=68;
  let _nearest = null;
  let isPanelVisible = false, panelX = null, panelY = null;
  let _fps=0, _fpsFrames=0, _fpsLast=performance.now();
  let _ms=0, _msLast=performance.now();
  let _magnetKeys = { w:false, a:false, s:false, d:false };
  let _xrayBarnKey = null, _xrayPoolKey = null;

  // ── Grenade fuse times (from survev source, seconds) ─────────
  // frag:  4.0s cookable
  // mirv:  4.0s cookable, spawns 4 children at 1.0s each
  // smoke: 2.5s NOT cookable
  // strobe: 3.0s, pin_wheel: 3.0s, potato: 2.5s
  const GREN_FUSE = {
    'frag':    4.0,
    'mirv':    4.0,
    'bomb':    1.0,  // mirv child bomblet
    'smoke':   2.5,
    'strobe':  3.0,
    'pin':     3.0,  // pin_wheel
    'potato':  2.5,
  };
  function getGrenFuse(typeId) {
    if (!typeId) return 4.0;
    const k = typeId.toLowerCase();
    for (const [name, fuse] of Object.entries(GREN_FUSE)) {
      if (k.includes(name)) return fuse;
    }
    return 4.0;
  }
  // Cook state tracking: { cookStart: timestamp, fuse: seconds, cooking: bool }
  let _grenCook = null;
  // Thrown grenade tracking: Map of projectile object => { spawnTime, fuse, typeId }
  const _thrownGrenades = new Map();

  // ── Player pool helper ────────────────────────────────────────
  function getPlayers(barn) {
    const pp = barn?.playerPool;
    if (!pp) return [];
    for (const k of Object.keys(pp)) {
      const v = pp[k];
      if (Array.isArray(v)) return v;
    }
    return [];
  }

  // ── Loot pool helper ──────────────────────────────────────────
  function getLoot(game) {
    // game.MUqt confirmed from debug: has lootPool + closestLoot
    const lootBarn = game.MUqt;
    if (!lootBarn) return [];
    const lp = lootBarn.lootPool;
    if (!lp) return [];
    // Try array keys inside pool
    for (const k of Object.keys(lp)) {
      const v = lp[k];
      if (Array.isArray(v) && v.length > 0) return v;
    }
    return [];
  }

  // item.type is a plain string e.g. "bar", "ak-47", "frag_grenade", "helmet03"
  function lootColor(item) {
    const t = (typeof item.type === 'string' ? item.type : '').toLowerCase();
    // Tier 3 — rarest drops
    if (/awm|deagle|mk_20|sv-98|mosin|l86|nt-16|mirv|strobe|spas|usas|saiga|qbb|pkp|dp-28|m249|helmet03|vest03|pack03/.test(t)) return '#ff66ff';
    // Tier 2 — good loot
    if (/scar|m416|ak|akm|bar|garand|model_94|mp220|vector|p90|m79|frag|smoke|flare|helmet02|vest02|pack02/.test(t)) return '#ffcc44';
    // Tier 1 — decent
    if (/m9|glock|ump|mp5|cz|p30|mac|helmet01|vest01|pack01/.test(t)) return '#44aaff';
    // Ammo / consumables — grey
    if (/ammo|bandage|medkit|soda|pills|gauze|2xscope|4xscope|8xscope|15xscope/.test(t)) return '#aaaaaa';
    return '#cccccc';
  }

  function lootLabel(item) {
    // item.type is a plain string typeId like "ak-47", "9mm", "helmet02", "frag_grenade"
    const raw = typeof item.type === 'string' ? item.type : '?';
    return raw.replace(/_/g, ' ').toUpperCase();
  }

  // ── Building pool for xray ────────────────────────────────────
  function findBuildingPool(game) {
    // Scan all top-level game objects for a pool-like object containing buildings (have .ceiling)
    for (const k of Object.keys(game)) {
      const v = game[k];
      if (!v || typeof v !== 'object') continue;
      for (const sk of Object.keys(v)) {
        const sv = v[sk];
        if (!sv) continue;
        // Direct array
        if (Array.isArray(sv) && sv.length > 0) {
          const first = sv.find(x => x && typeof x === 'object');
          if (first && 'ceiling' in first) { _xrayBarnKey=k; _xrayPoolKey=sk; return sv; }
        }
        // Object with numeric keys (pool-like)
        if (typeof sv === 'object' && !Array.isArray(sv)) {
          const vals = Object.values(sv);
          if (vals.length > 0 && vals[0] && typeof vals[0] === 'object' && 'ceiling' in vals[0]) {
            _xrayBarnKey=k; _xrayPoolKey=sk; return vals;
          }
        }
      }
    }
    return null;
  }

  function getBuildingPool(game) {
    if (_xrayBarnKey && _xrayPoolKey) {
      const sv = game[_xrayBarnKey]?.[_xrayPoolKey];
      if (Array.isArray(sv)) return sv;
      if (sv && typeof sv === 'object') return Object.values(sv);
    }
    return findBuildingPool(game) || [];
  }

  function applyXray(buildings) {
    for (const b of buildings) {
      if (!b || !b.active) continue;
      // Null out ceiling sprite alpha
      if (b.ceiling) {
        b.ceiling.fadeAlpha = 0;
        if (b.ceiling.sprite) b.ceiling.sprite.alpha = 0;
        if (b.ceiling.container) b.ceiling.container.alpha = 0;
      }
      // Kill any ceiling-tagged images
      for (const img of (b.imgs||[])) {
        if (img && img.isCeiling && img.sprite) img.sprite.alpha = 0;
      }
      // Also try zoomRegions which sometimes hold ceilings
      for (const zr of (b.zoomRegions||[])) {
        if (zr?.ceiling) { zr.ceiling.fadeAlpha=0; if(zr.ceiling.sprite) zr.ceiling.sprite.alpha=0; }
      }
    }
  }

  function tickXray(game) {
    if (!cfg.xray) return;
    applyXray(getBuildingPool(game));
  }

  function restoreXray(game) {
    for (const b of getBuildingPool(game)) {
      if (!b) continue;
      if (b.ceiling) {
        b.ceiling.fadeAlpha = 1;
        if (b.ceiling.sprite) b.ceiling.sprite.alpha = 1;
        if (b.ceiling.container) b.ceiling.container.alpha = 1;
      }
      for (const img of (b.imgs||[])) {
        if (img && img.isCeiling && img.sprite) img.sprite.alpha = img.sprite.imgAlpha ?? 1;
      }
      for (const zr of (b.zoomRegions||[])) {
        if (zr?.ceiling) { zr.ceiling.fadeAlpha=1; if(zr.ceiling.sprite) zr.ceiling.sprite.alpha=1; }
      }
    }
  }

  // ── Magnet ────────────────────────────────────────────────────
  function clearMagnetKeys(input) {
    if (_magnetKeys.w) { input.keys[KEY_W]=false; _magnetKeys.w=false; }
    if (_magnetKeys.a) { input.keys[KEY_A]=false; _magnetKeys.a=false; }
    if (_magnetKeys.s) { input.keys[KEY_S]=false; _magnetKeys.s=false; }
    if (_magnetKeys.d) { input.keys[KEY_D]=false; _magnetKeys.d=false; }
  }

  function applyMagnet(input, dx, dy) {
    const threshold = 0.3;
    const len = Math.sqrt(dx*dx + dy*dy);
    if (len === 0) { clearMagnetKeys(input); return; }
    const nx=dx/len, ny=dy/len;
    input.keys[KEY_W] = ny < -threshold; _magnetKeys.w = input.keys[KEY_W];
    input.keys[KEY_S] = ny >  threshold; _magnetKeys.s = input.keys[KEY_S];
    input.keys[KEY_A] = nx < -threshold; _magnetKeys.a = input.keys[KEY_A];
    input.keys[KEY_D] = nx >  threshold; _magnetKeys.d = input.keys[KEY_D];
  }

  // ── Stats helpers ─────────────────────────────────────────────
  const modeMap = {1:'Solo',2:'Duo',3:'Trio',4:'Squad'};
  function timeAgo(d) {
    const s=Math.floor((Date.now()-new Date(d))/1000);
    return s<60?`${s}s`:s<3600?`${Math.floor(s/60)}m`:s<86400?`${Math.floor(s/3600)}h`:`${Math.floor(s/86400)}d`;
  }

  function fetchStats(slug) {
    if (!slug) return;
    const box=document.getElementById('_hbStatsBox'), hist=document.getElementById('_hbHistoryBox');
    if(box) box.innerHTML='<span style="color:#b8b4ac;font-size:10px">loading...</span>';
    if(hist) hist.innerHTML='<span style="color:#b8b4ac;font-size:10px">loading...</span>';
    fetch('https://api.survev.io/api/user_stats',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,interval:'alltime',mapIdFilter:'-1'})})
    .then(r=>r.json()).then(d=>{
      if(!box) return;
      if(!d||d.banned){box.innerHTML='<span style="color:#e87a7a;font-size:10px">not found</span>';return;}
      const kpg=d.games>0?(d.kills/d.games).toFixed(2):'0.00';
      const wPct=d.games>0?((d.wins/d.games)*100).toFixed(1):'0.0';
      box.innerHTML=`<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:2px;text-align:center">${[['WINS',d.wins??0],['KILLS',d.kills??0],['GAMES',d.games??0],['K/G',kpg],['WIN%',wPct+'%']].map(([l,v])=>`<div style="padding:4px 0"><div style="font-size:15px;font-weight:700;color:#2a2a2a">${v}</div><div style="font-size:8px;color:#bbb;letter-spacing:1px;margin-top:1px">${l}</div></div>`).join('')}</div>`;
    }).catch(()=>{if(box) box.innerHTML='<span style="color:#e87a7a;font-size:10px">error</span>';});
    fetch('https://api.survev.io/api/match_history',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,offset:0,count:10,teamModeFilter:7})})
    .then(r=>r.json()).then(matches=>{
      if(!hist) return;
      if(!Array.isArray(matches)||!matches.length){hist.innerHTML='<span style="color:#b8b4ac;font-size:10px">no matches</span>';return;}
      hist.innerHTML=`<div style="border-radius:7px;overflow:hidden;border:1px solid #e4e0d8">
        <div style="display:grid;grid-template-columns:40px 40px 1fr 50px 28px;gap:4px;padding:5px 10px;background:#edeae4">${['RANK','MODE','KILLS','DMG','AGO'].map(h=>`<span style="font-size:8px;color:#b0aca4;letter-spacing:1px">${h}</span>`).join('')}</div>
        ${matches.map(m=>{
          const rCol=m.rank===1?'#5bc470':m.rank<=3?'#e8c060':'#aaa';
          const mCol=m.team_count===1?'#c8a8ff':m.team_count===2?'#80c8ff':m.team_count===3?'#80e8a0':'#ffb060';
          return `<div style="display:grid;grid-template-columns:40px 40px 1fr 50px 28px;gap:4px;align-items:center;padding:7px 10px;border-bottom:1px solid #f0ede8;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#f8f6f2'" onmouseout="this.style.background=''" onclick="window._hbShowMatch('${m.guid}','${slug}')">
            <span style="font-size:9px;font-weight:700;color:${rCol}">${m.rank===1?'🥇':'#'+m.rank}</span>
            <span style="font-size:9px;font-weight:600;color:${mCol}">${modeMap[m.team_count]||'?'}</span>
            <span style="font-size:9px;color:#666">${m.kills??0}k / ${m.team_kills??0}tk</span>
            <span style="font-size:9px;color:#888">${Math.round((m.damage_dealt??0)/1000*10)/10}k</span>
            <span style="font-size:9px;color:#bbb;text-align:right">${timeAgo(m.end_time)}</span>
          </div>`;
        }).join('')}</div>`;
    }).catch(()=>{if(hist) hist.innerHTML='<span style="color:#e87a7a;font-size:10px">error</span>';});
  }

  window._hbShowMatch = function(guid, slug) {
    const detail=document.getElementById('_hbMatchDetail');
    if(!detail) return;
    detail.style.display='block';
    detail.innerHTML='<div style="color:#b8b4ac;font-size:10px;padding:8px">loading...</div>';
    fetch('https://api.survev.io/api/match_data',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({gameId:guid})})
    .then(r=>r.json()).then(players=>{
      if(!Array.isArray(players)||!players.length){detail.innerHTML='<span style="color:#e87a7a;font-size:10px;padding:8px">no data</span>';return;}
      const rows=players.map(p=>{
        const isMe=p.slug===slug;
        const t=Math.round(p.time_alive??0);
        const rCol=p.rank===1?'#5bc470':p.rank<=3?'#e8c060':'#aaa';
        return `<div style="display:grid;grid-template-columns:28px 1fr 24px 44px 44px 38px;gap:3px;align-items:center;padding:5px 10px;border-bottom:1px solid #f0ede8;${isMe?'background:#edf8f0':''}">
          <span style="font-size:9px;font-weight:700;color:${rCol}">#${p.rank}</span>
          <span style="font-size:9px;font-weight:${isMe?700:400};color:${isMe?'#2a8a44':'#444'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.slug||p.username||'?'}</span>
          <span style="font-size:9px;color:#888">${p.kills??0}</span>
          <span style="font-size:9px;color:#888">${Math.round(p.damage_dealt??0)}</span>
          <span style="font-size:9px;color:#aaa">${Math.round(p.damage_taken??0)}</span>
          <span style="font-size:9px;color:#bbb">${Math.floor(t/60)}:${String(t%60).padStart(2,'0')}</span>
        </div>`;
      }).join('');
      detail.innerHTML=`
        <div style="display:flex;justify-content:space-between;align-items:center;padding:7px 10px;background:#edeae4;border-bottom:1px solid #e4e0d8">
          <span style="font-size:9px;color:#888;letter-spacing:.5px">MATCH DETAIL</span>
          <span style="cursor:pointer;color:#aaa;padding:2px 6px" onclick="document.getElementById('_hbMatchDetail').style.display='none'">✕</span>
        </div>
        <div style="display:grid;grid-template-columns:28px 1fr 24px 44px 44px 38px;gap:3px;padding:5px 10px;background:#f4f2ee">
          ${['#','PLAYER','K','DEALT','TAKEN','TIME'].map(h=>`<span style="font-size:8px;color:#b0aca4;letter-spacing:.5px">${h}</span>`).join('')}
        </div>
        <div style="max-height:200px;overflow-y:auto">${rows}</div>`;
    }).catch(()=>{detail.innerHTML='<span style="color:#e87a7a;font-size:10px;padding:8px">error</span>';});
  };

  // ── Overlay ───────────────────────────────────────────────────
  function startOverlay(Re) {
    if (window._hbRunning) return;
    window._hbRunning = true;

    const canvas = document.createElement('canvas');
    canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9998;';
    document.body.appendChild(canvas);
    window._hbCanvas = canvas;
    const ctx = canvas.getContext('2d');
    const resize = () => { canvas.width=innerWidth; canvas.height=innerHeight; };
    resize();
    window.addEventListener('resize', resize);

    (function draw() {
      window._hbRaf = requestAnimationFrame(draw);
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // FPS / MS counter
      _fpsFrames++;
      const now = performance.now();
      const delta = now - _msLast; _msLast = now;
      _ms = Math.round(_ms * 0.85 + delta * 0.15);
      if (now - _fpsLast >= 1000) {
        _fps = _fpsFrames; _fpsFrames = 0; _fpsLast = now;
        const fpsEl = document.getElementById('_hbFps');
        if (fpsEl) { fpsEl.textContent=`${_fps} fps`; fpsEl.style.color=_fps>=55?'#5bc470':_fps>=30?'#e8c060':'#e87a7a'; }
      }
      const msEl = document.getElementById('_hbMs');
      if (msEl) { msEl.textContent=`${_ms} ms`; msEl.style.color=_ms<=16?'#5bc470':_ms<=33?'#e8c060':'#e87a7a'; }

      const killEl = document.querySelector('.js-ui-player-kills');
      const killDisp = document.getElementById('_hbKills');
      if (killEl && killDisp) killDisp.textContent = killEl.textContent || '0';

      const game = Re.game;
      if (!game?.initialized) {
        if (cfg.magnet && Re.input) clearMagnetKeys(Re.input);
        return;
      }

      // Confirmed minified names from live debug:
      // barn  = game.dnXkCm  (playerPool, playerInfo, playerStatus, groupInfo)
      // myId  = game.fyl     (active player ID)
      const barn = game.dnXkCm;
      const myId = game.fyl;
      if (!barn || !myId) return;

      tickXray(game);

      // World scale from pixi stage children[0] (world container)
      // children[0].x/y = camera offset, scale.x = zoom level
      const worldContainer = Re.pixi?.stage?.children?.[0];
      const worldScale = worldContainer?.scale?.x || 30;
      const STUD = worldScale;

      const players = getPlayers(barn);
      const myInfo = barn.playerInfo?.[myId];
      const myGroup = myInfo?.groupId;
      const myTeam  = myInfo?.teamId;

      const me = players.find(p => p.__id === myId);
      if (!me?.active) return;

      // Screen position via PIXI toGlobal — converts world coords to screen pixels
      let mpos;
      try { mpos = me.container.toGlobal({x:0, y:0}); }
      catch(e) { return; }
      const msx = mpos.x, msy = mpos.y;

      // Find nearest enemy for lock-on / magnet
      let nearestEnemy = null, nearestDist = Infinity;
      for (const p of players) {
        if (!p.active || p.__id===myId || p.dead || !p.container?.visible) continue;
        const info = barn.playerInfo?.[p.__id];
        const isMate = (myGroup>0 && info?.groupId>0 && info.groupId===myGroup) ||
                       (myTeam>0  && info?.teamId>0  && info.teamId===myTeam);
        if (isMate) continue;
        let spos;
        try { spos = p.container.toGlobal({x:0, y:0}); } catch(e) { continue; }
        const d = Math.hypot(spos.x-msx, spos.y-msy);
        if (d < nearestDist) { nearestDist=d; nearestEnemy={p, sx:spos.x, sy:spos.y}; }
      }
      _nearest = nearestEnemy ? {sx:nearestEnemy.sx, sy:nearestEnemy.sy} : null;

      // Lock-on
      if (cfg.lockOn && _nearest && Re.input) {
        Re.input.mousePos.x = _nearest.sx;
        Re.input.mousePos.y = _nearest.sy;
      }

      // Magnet
      if (cfg.magnet && Re.input && nearestEnemy) {
        const distStuds = nearestDist / STUD;
        if (distStuds <= 6) {
          applyMagnet(Re.input, nearestEnemy.sx-msx, nearestEnemy.sy-msy);
          const pulse = (Date.now()%600)/600;
          ctx.save(); ctx.globalAlpha = 0.6+Math.sin(pulse*Math.PI*2)*0.4;
          ctx.beginPath(); ctx.arc(nearestEnemy.sx, nearestEnemy.sy, 8+Math.sin(pulse*Math.PI*2)*3, 0, Math.PI*2);
          ctx.strokeStyle='#ffff00'; ctx.lineWidth=2; ctx.stroke(); ctx.restore();
        } else { clearMagnetKeys(Re.input); }
      } else if (!cfg.magnet && Re.input) { clearMagnetKeys(Re.input); }

      // ── Grenade timer ─────────────────────────────────────────
      // jvj.projectilePool.Wsryz = array of ALL grenades (bullets use separate system)
      // proj.pos = {x, y} in WORLD coords — convert: screenX = worldContainer.x + pos.x * STUD
      if (cfg.grenTimer) {
        const now = Date.now();

        const weapItem = (me.eSBXZ?.item ?? '').toLowerCase();
        const isHoldingGren = /grenade|mirv|smoke|strobe|pin_wheel|potato|throwable/.test(weapItem);
        const throwState = me.throwableState ?? 0;

        // Only frag and mirv are cookable — smoke/strobe/potato always use full fuse on throw
        const isCookable = /frag|mirv/.test(weapItem) && !/bomb|child/.test(weapItem);

        // Cook start: only for cookable grenades
        if (isCookable && throwState > 0 && !_grenCook) {
          _grenCook = { cookStart: now, fuse: getGrenFuse(weapItem), typeId: weapItem, remaining: null, thrownAt: null };
        }
        // Throw detected: stamp remaining time (cook carry-over)
        if (_grenCook && _grenCook.remaining === null && (!isHoldingGren || throwState === 0)) {
          const cooked = (now - _grenCook.cookStart) / 1000;
          _grenCook.remaining = Math.max(0.15, _grenCook.fuse - cooked);
          _grenCook.thrownAt = now;
        }
        // Expire carry-over window after 500ms (enough time for projectile to enter pool)
        if (_grenCook?.thrownAt && (now - _grenCook.thrownAt) > 500) _grenCook = null;
        // Cancel if no longer holding and never threw
        if (!isHoldingGren && _grenCook && !_grenCook.thrownAt) _grenCook = null;

        // Scan projectile pool for new grenades
        let newThisFrame = 0;
        const newProjs = [];
        const projArr = game.jvj?.projectilePool?.Wsryz;
        if (Array.isArray(projArr)) {
          for (const proj of projArr) {
            if (!proj?.active) continue;
            if (!_thrownGrenades.has(proj)) { newThisFrame++; newProjs.push(proj); }
          }
        }

        for (const proj of newProjs) {
          let fuse, typeId;
          if (newThisFrame >= 3) {
            // 3+ spawning simultaneously = MIRV children = 1.8s each
            fuse = 1.8; typeId = 'mirv_child';
          } else if (_grenCook?.remaining != null) {
            // Cookable grenade thrown after cooking — use remaining fuse
            fuse = _grenCook.remaining; typeId = _grenCook.typeId;
            _grenCook = null; // consumed
          } else {
            // Non-cookable (smoke=2.5s, strobe=3s etc) OR enemy nade — full fuse from type
            fuse = getGrenFuse(isHoldingGren ? weapItem : '');
            typeId = isHoldingGren ? weapItem : 'grenade';
          }
          _thrownGrenades.set(proj, { spawnTime: now, fuse, typeId });
        }

        // Expire old
        for (const [proj, data] of _thrownGrenades) {
          const age = (now - data.spawnTime) / 1000;
          if (!proj.active || age > data.fuse + 0.6) _thrownGrenades.delete(proj);
        }

        // Draw cook timer around player (only for cookable grenades: frag/mirv)
        if (isCookable && isHoldingGren && _grenCook && !_grenCook.thrownAt) {
          const elapsed = (now - _grenCook.cookStart) / 1000;
          const remaining = Math.max(0, _grenCook.fuse - elapsed);
          const progress = Math.min(1, elapsed / _grenCook.fuse);
          const urgent = progress > 0.75;
          const col = urgent ? '#ff5050' : '#ffdc3c';
          ctx.save();
          const arcR = 24;
          ctx.lineWidth = 4;
          ctx.beginPath(); ctx.arc(msx, msy, arcR, -Math.PI/2, -Math.PI/2 + Math.PI*2);
          ctx.strokeStyle = 'rgba(80,80,80,0.4)'; ctx.stroke();
          ctx.beginPath(); ctx.arc(msx, msy, arcR, -Math.PI/2, -Math.PI/2 + Math.PI*2*progress);
          ctx.strokeStyle = col; ctx.stroke();
          const lx2 = msx, ly2 = msy + arcR + 12;
          ctx.fillStyle = 'rgba(0,0,0,0.75)';
          ctx.beginPath(); ctx.roundRect(lx2-44, ly2-10, 88, 20, 5); ctx.fill();
          ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
          ctx.strokeStyle = 'rgba(0,0,0,0.9)'; ctx.lineWidth = 3;
          const cookLabel = `💣 ${remaining.toFixed(1)}s`;
          ctx.strokeText(cookLabel, lx2, ly2); ctx.fillStyle = col; ctx.fillText(cookLabel, lx2, ly2);
          if (urgent) {
            const pulse = (now % 250) / 250;
            ctx.globalAlpha = 0.5 * (1 - pulse);
            ctx.beginPath(); ctx.arc(msx, msy, arcR + pulse*14, 0, Math.PI*2);
            ctx.strokeStyle='#ff3333'; ctx.lineWidth=2; ctx.stroke();
          }
          ctx.restore();
        }

        // Draw thrown grenades
        for (const [proj, data] of _thrownGrenades) {
          if (!proj.active || !proj.pos) continue;

          // Correct world→screen formula confirmed from debug:
          // worldContainer.x=-3329, pos.x=173, scale=23.4 → sx=719 ✓  (add)
          // worldContainer.y=12456, pos.y=520, scale=23.4 → sy=288 ✓  (SUBTRACT — Y axis inverted)
          const px = worldContainer.x + proj.pos.x * STUD;
          const py = worldContainer.y - proj.pos.y * STUD;

          const age = (now - data.spawnTime) / 1000;
          const remaining = Math.max(0, data.fuse - age);
          const progress = Math.min(1, age / data.fuse);
          const urgent = remaining < 1.0;
          const col = urgent ? '#ff3333' : remaining < 2.0 ? '#ff9922' : '#ffee44';

          // Blast radius: frag=16 studs, smoke=10, everything else=12
          const blastStuds = data.typeId.includes('smoke') ? 10 :
                             data.typeId.includes('mirv')  ? 8  : 16;
          const blastPx = blastStuds * STUD;

          ctx.save();

          // Blast radius circle
          ctx.beginPath(); ctx.arc(px, py, blastPx, 0, Math.PI*2);
          ctx.fillStyle = col; ctx.globalAlpha = 0.05 + progress * 0.13; ctx.fill();
          ctx.globalAlpha = 0.35 + progress * 0.5;
          ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.stroke();

          // Countdown arc on the grenade itself
          ctx.globalAlpha = 1;
          const arcR = 12;
          ctx.lineWidth = 3;
          ctx.strokeStyle = 'rgba(0,0,0,0.7)';
          ctx.beginPath(); ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + Math.PI*2); ctx.stroke();
          ctx.strokeStyle = col;
          ctx.beginPath(); ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + Math.PI*2*progress); ctx.stroke();

          // Countdown number ON the grenade
          ctx.font = 'bold 12px monospace';
          ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
          ctx.strokeStyle = 'rgba(0,0,0,1)'; ctx.lineWidth = 4;
          ctx.strokeText(remaining.toFixed(1), px, py);
          ctx.fillStyle = col; ctx.fillText(remaining.toFixed(1), px, py);

          // Last second pulse
          if (urgent) {
            const pulse = (now % 200) / 200;
            ctx.globalAlpha = 0.7 * (1 - pulse);
            ctx.beginPath(); ctx.arc(px, py, blastPx * (0.6 + pulse*0.4), 0, Math.PI*2);
            ctx.strokeStyle='#ff1111'; ctx.lineWidth=3; ctx.stroke();
          }
          ctx.restore();
        }
      }
      for (const p of players) {
        if (!p.active || p.dead || !p.container?.visible) continue;
        let spos;
        try { spos = p.container.toGlobal({x:0, y:0}); } catch(e) { continue; }
        const sx = spos.x, sy = spos.y;
        const isSelf = p.__id === myId;
        const info = barn.playerInfo?.[p.__id];
        const isMate = !isSelf && (
          (myGroup>0 && info?.groupId>0 && info.groupId===myGroup) ||
          (myTeam>0  && info?.teamId>0  && info.teamId===myTeam)
        );
        const isLocked = cfg.lockOn && nearestEnemy && p.__id===nearestEnemy.p.__id;
        const color = isSelf?'#5bc470':isMate?'#6aabff':'#ff4444';

        // HP bar
        if (cfg.healthBars && !isSelf) {
          const status = barn.playerStatus?.[p.__id];
          const hp = typeof status?.health === 'number' ? status.health : null;
          if (hp !== null) {
            const BAR_W=80, BAR_H=8, BAR_X=sx-BAR_W/2, BAR_Y=sy-30;
            const filled = Math.max(0,Math.min(1,hp/100))*BAR_W;
            const hpCol = hp>60?'#5bc470':hp>30?'#e8c060':'#e87a7a';
            ctx.globalAlpha=0.75; ctx.fillStyle='rgba(0,0,0,0.55)';
            ctx.beginPath(); ctx.roundRect(BAR_X-1,BAR_Y-1,BAR_W+2,BAR_H+2,3); ctx.fill();
            ctx.globalAlpha=0.4; ctx.fillStyle='#333';
            ctx.beginPath(); ctx.roundRect(BAR_X,BAR_Y,BAR_W,BAR_H,2); ctx.fill();
            ctx.globalAlpha=0.95; ctx.fillStyle=hpCol;
            if (filled>0) { ctx.beginPath(); ctx.roundRect(BAR_X,BAR_Y,filled,BAR_H,2); ctx.fill(); }
            ctx.globalAlpha=1; ctx.font='bold 9px monospace'; ctx.textAlign='center'; ctx.textBaseline='middle';
            ctx.strokeStyle='rgba(0,0,0,0.9)'; ctx.lineWidth=2.5;
            ctx.strokeText(`${Math.round(hp)}`,BAR_X+BAR_W/2,BAR_Y+BAR_H/2);
            ctx.fillStyle='#fff'; ctx.fillText(`${Math.round(hp)}`,BAR_X+BAR_W/2,BAR_Y+BAR_H/2);
          } else if (p.downed) {
            ctx.save(); ctx.globalAlpha=0.8; ctx.font='bold 10px monospace'; ctx.textAlign='center'; ctx.textBaseline='bottom';
            ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=3; ctx.strokeText('↓DOWNED',sx,sy-24);
            ctx.fillStyle='#e8c060'; ctx.fillText('↓DOWNED',sx,sy-24); ctx.restore();
          }
        }

        if (!isSelf) {
          const dist = Math.hypot(sx-msx, sy-msy);
          const alpha = Math.max(0.75, 1-dist/2400);
          const studs = Math.round(dist/STUD);

          if (cfg.Esp) {
            ctx.save(); ctx.beginPath(); ctx.moveTo(msx,msy); ctx.lineTo(sx,sy);
            ctx.setLineDash(isMate?[6,4]:isLocked?[3,3]:[]);
            ctx.strokeStyle=isLocked?'#ff4444':color; ctx.lineWidth=isMate?2:2.5;
            ctx.globalAlpha=alpha; ctx.stroke(); ctx.setLineDash([]); ctx.restore();
          }

          if (cfg.names) {
            const name = info?.nameTruncated||info?.name||`#${p.__id}`;
            const label = isLocked ? `${name}  ${studs}  ◎` : `${name}  ${studs}`;
            const lx=(msx+sx)/2, ly=(msy+sy)/2;
            ctx.save(); ctx.globalAlpha=Math.min(1,alpha+0.2);
            ctx.font='bold 11px monospace'; ctx.textAlign='center'; ctx.textBaseline='middle';
            ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=3; ctx.strokeText(label,lx,ly);
            ctx.fillStyle=isLocked?'#ff4444':color; ctx.fillText(label,lx,ly); ctx.restore();
          }

          // ── Weapon tag: box to the right of player ───────────────
          if (cfg.Esp) {
            // Get weapon from bBXCN (confirmed: contains current weapon string)
            // Scan all string values, skip outfit/helmet/backpack
            let weapId = '';
            try {
              const loadout = p.bBXCN;
              if (loadout) {
                for (const v of Object.values(loadout)) {
                  if (typeof v !== 'string' || v.length < 2) continue;
                  const vl = v.toLowerCase();
                  if (/^outfit|^backpack|^helmet|^skin|^vest/.test(vl)) continue;
                  // Accept weapon-like strings
                  if (/fists|[0-9]|rifle|shotgun|sniper|pistol|gun|mp5|ump|ak|m4|scar|bar|dp|pkp|p90|mac|vector|awm|mosin|sv|nt|l86|m249|qbb|spas|saiga|usas|s686|m870|garand|deagle|glock|cz|m79|model|frag|grenade|mirv|smoke|strobe|flare/.test(vl)) {
                    weapId = v; break;
                  }
                }
              }
              if (!weapId) weapId = p.weapTypeOld ?? '';
            } catch(e) { weapId = p.weapTypeOld ?? ''; }

            if (weapId) {
              const BOX_W = 58, BOX_H = 34;
              const bx = sx + 20, by = sy - BOX_H / 2;

              ctx.save();
              ctx.globalAlpha = Math.min(1, alpha + 0.15);

              // Background
              ctx.fillStyle = 'rgba(0,0,0,0.78)';
              ctx.beginPath(); ctx.roundRect(bx, by, BOX_W, BOX_H, 4); ctx.fill();

              const wl = weapId.toLowerCase();
              const wCol = /spas|saiga|usas|s686|m870|mp220|shotgun/.test(wl) ? '#ff9944'
                         : /awm|mosin|sv-98|sv98|nt-16|l86|sniper/.test(wl)  ? '#cc88ff'
                         : /mp5|ump|vector|p90|mac|smg/.test(wl)             ? '#44ddff'
                         : /grenade|mirv|smoke|strobe|frag/.test(wl)         ? '#ffee44'
                         : /fists|melee/.test(wl)                            ? '#888888'
                         : '#88dd88';
              ctx.strokeStyle = wCol; ctx.lineWidth = 1.5;
              ctx.beginPath(); ctx.roundRect(bx, by, BOX_W, BOX_H, 4); ctx.stroke();

              // Try real game sprite — textures are in loadout atlas as gun-{id}-01.img
              let drewSprite = false;
              try {
                window._hbBuildTexCache?.();
                const tex = window._hbGetWeapTex?.(weapId);
                if (tex) {
                  const src = tex?.baseTexture?.resource?.source;
                  const f = tex?._frame ?? tex?.frame ?? tex?.orig;
                  if (src && f && f.width > 2 && f.height > 2) {
                    const PAD = 2, TEXTROW = 11;
                    const aspect = f.width / f.height;
                    const ih = BOX_H - PAD*2 - TEXTROW;
                    const iw = Math.min(BOX_W - PAD*2, ih * aspect);
                    ctx.imageSmoothingEnabled = true;
                    ctx.imageSmoothingQuality = 'high';
                    ctx.drawImage(src, f.x, f.y, f.width, f.height,
                      bx + (BOX_W-iw)/2, by + PAD, iw, ih);
                    drewSprite = true;
                  }
                }
              } catch(e) {}

              // Vector fallback if no sprite found
              if (!drewSprite) {
                const cx2 = bx+BOX_W/2, cy2 = by+(BOX_H-10)/2;
                ctx.save();
                ctx.strokeStyle=wCol; ctx.fillStyle=wCol;
                ctx.lineWidth=1.5; ctx.lineCap='round'; ctx.lineJoin='round';
                const isShotgun=/spas|saiga|usas|s686|m870|mp220/.test(wl);
                const isSniper=/awm|mosin|sv-98|sv98|nt-16|l86/.test(wl);
                const isGren=/grenade|mirv|smoke|strobe|frag/.test(wl);
                const isFists=/fists/.test(wl);
                const isSMG=/mp5|ump|vector|p90|mac/.test(wl);
                if (isFists) {
                  ctx.beginPath(); ctx.arc(cx2-5,cy2,4,0,Math.PI*2); ctx.stroke();
                  ctx.beginPath(); ctx.arc(cx2+5,cy2,4,0,Math.PI*2); ctx.stroke();
                } else if (isGren) {
                  ctx.beginPath(); ctx.arc(cx2,cy2+1,6,0,Math.PI*2); ctx.stroke();
                  ctx.beginPath(); ctx.moveTo(cx2,cy2-5); ctx.lineTo(cx2,cy2-8);
                  ctx.moveTo(cx2-3,cy2-8); ctx.lineTo(cx2+3,cy2-8); ctx.stroke();
                } else if (isSniper) {
                  ctx.beginPath();
                  ctx.moveTo(bx+3,cy2); ctx.lineTo(bx+50,cy2);
                  ctx.moveTo(bx+42,cy2); ctx.lineTo(bx+50,cy2+5);
                  ctx.moveTo(bx+34,cy2); ctx.lineTo(bx+34,cy2-5);
                  ctx.moveTo(bx+30,cy2-5); ctx.lineTo(bx+38,cy2-5);
                  ctx.stroke();
                } else if (isShotgun) {
                  ctx.beginPath();
                  ctx.moveTo(bx+3,cy2); ctx.lineTo(bx+36,cy2);
                  ctx.moveTo(bx+3,cy2-2); ctx.lineTo(bx+3,cy2+2);
                  ctx.moveTo(bx+28,cy2); ctx.lineTo(bx+36,cy2+6);
                  ctx.moveTo(bx+20,cy2); ctx.lineTo(bx+20,cy2+5);
                  ctx.stroke();
                  ctx.beginPath(); ctx.moveTo(bx+12,cy2+1); ctx.lineTo(bx+18,cy2+1); ctx.lineWidth=3; ctx.stroke();
                } else if (isSMG) {
                  ctx.beginPath();
                  ctx.moveTo(bx+3,cy2); ctx.lineTo(bx+34,cy2);
                  ctx.moveTo(bx+3,cy2-2); ctx.lineTo(bx+3,cy2+2);
                  ctx.moveTo(bx+26,cy2-3); ctx.lineTo(bx+34,cy2-3); ctx.lineTo(bx+34,cy2+2);
                  ctx.moveTo(bx+26,cy2); ctx.lineTo(bx+26,cy2+7);
                  ctx.moveTo(bx+14,cy2); ctx.lineTo(bx+14,cy2+5); ctx.lineTo(bx+20,cy2+5); ctx.lineTo(bx+20,cy2);
                  ctx.stroke();
                } else {
                  ctx.beginPath();
                  ctx.moveTo(bx+3,cy2); ctx.lineTo(bx+46,cy2);
                  ctx.moveTo(bx+3,cy2-2); ctx.lineTo(bx+3,cy2+2);
                  ctx.moveTo(bx+38,cy2); ctx.lineTo(bx+46,cy2+6);
                  ctx.moveTo(bx+30,cy2); ctx.lineTo(bx+30,cy2+6);
                  ctx.moveTo(bx+20,cy2); ctx.lineTo(bx+20,cy2+5); ctx.lineTo(bx+26,cy2+5); ctx.lineTo(bx+26,cy2);
                  ctx.stroke();
                }
                ctx.restore();
              }

              // Weapon name at bottom of box
              const shortName = weapId.replace(/_/g,' ').toUpperCase().slice(0, 9);
              const fs = shortName.length > 7 ? 7 : shortName.length > 5 ? 8 : 9;
              ctx.font = `bold ${fs}px monospace`;
              ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
              ctx.strokeStyle = 'rgba(0,0,0,0.9)'; ctx.lineWidth = 2.5;
              ctx.strokeText(shortName, bx+BOX_W/2, by+BOX_H-1);
              ctx.fillStyle = wCol;
              ctx.fillText(shortName, bx+BOX_W/2, by+BOX_H-1);

              ctx.restore();
            }
          }
        }
      }

      // ── Loot ESP ──────────────────────────────────────────────
      if (cfg.lootEsp) {
        const lootItems = getLoot(game);
        for (const item of lootItems) {
          if (!item?.active || !item.pos) continue;

          // Same world→screen as grenades: X adds, Y subtracts (inverted axis confirmed)
          const lx = worldContainer.x + item.pos.x * STUD;
          const ly = worldContainer.y - item.pos.y * STUD;

          // Skip offscreen
          if (lx < -50 || lx > innerWidth+50 || ly < -50 || ly > innerHeight+50) continue;

          const dist = Math.hypot(lx-msx, ly-msy);
          const col = lootColor(item);
          const label = lootLabel(item);
          const studs = Math.round(dist / STUD);
          const alpha = Math.max(0.5, 1 - dist/2000);
          const count = item.count > 1 ? ` x${item.count}` : '';

          ctx.save();
          ctx.globalAlpha = alpha;

          // Box
          const BOX = 30;
          ctx.shadowColor = col;
          ctx.shadowBlur = 8;
          ctx.strokeStyle = col;
          ctx.lineWidth = 2;
          ctx.strokeRect(lx - BOX/2, ly - BOX/2, BOX, BOX);
          ctx.globalAlpha = alpha * 0.12;
          ctx.fillStyle = col;
          ctx.fillRect(lx - BOX/2, ly - BOX/2, BOX, BOX);
          ctx.globalAlpha = alpha;
          ctx.shadowBlur = 0;

          // Item name label above box
          ctx.font = 'bold 11px monospace';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'bottom';
          ctx.strokeStyle = 'rgba(0,0,0,0.95)';
          ctx.lineWidth = 4;
          const txt = `${label}${count}`;
          ctx.strokeText(txt, lx, ly - BOX/2 - 3);
          ctx.fillStyle = col;
          ctx.shadowColor = col;
          ctx.shadowBlur = 4;
          ctx.fillText(txt, lx, ly - BOX/2 - 3);
          ctx.shadowBlur = 0;

          // Distance below box
          ctx.font = 'bold 9px monospace';
          ctx.textBaseline = 'top';
          ctx.strokeStyle = 'rgba(0,0,0,0.8)';
          ctx.lineWidth = 2;
          ctx.strokeText(`${studs}su`, lx, ly + BOX/2 + 3);
          ctx.fillStyle = 'rgba(255,255,255,0.85)';
          ctx.shadowBlur = 0;
          ctx.fillText(`${studs}su`, lx, ly + BOX/2 + 3);

          ctx.restore();
        }
      }
    })();

    setStatus('active', '#5bc470');
  }

  // ── CSS ───────────────────────────────────────────────────────
  const CSS = `
    @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap');
    #_hbPanel *,#_hbPanel *::before,#_hbPanel *::after{box-sizing:border-box;margin:0;padding:0}
    #_hbPanel{position:fixed;width:340px;background:#f4f3f0;border:1px solid #dbd9d3;border-radius:12px;z-index:10000;font-family:'DM Mono','Courier New',monospace;color:#2a2a2a;box-shadow:0 0 0 1px rgba(255,255,255,.7) inset,0 8px 32px rgba(0,0,0,.14);display:none;flex-direction:column;overflow:hidden;animation:_hbIn .18s cubic-bezier(.34,1.4,.64,1)}
    @keyframes _hbIn{from{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}
    #_hbHeader{display:flex;align-items:center;justify-content:space-between;padding:11px 16px;background:linear-gradient(to bottom,#f0ede8,#ebe8e2);border-bottom:1px solid #dbd9d3;cursor:grab;border-radius:12px 12px 0 0;user-select:none}
    #_hbHeader:active{cursor:grabbing}
    ._hbLogo{display:flex;align-items:center;gap:8px}
    ._hbDot{width:8px;height:8px;border-radius:50%;background:#ccc;box-shadow:0 0 10px #ccc4;transition:all .3s}
    @keyframes _hbShimmer{0%{background-position:200% center}100%{background-position:-200% center}}
    ._hbLogoText{font-size:10px;font-weight:500;letter-spacing:3px;text-transform:uppercase;background:linear-gradient(90deg,#111 0%,#888 40%,#fff 50%,#888 60%,#111 100%);background-size:300% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:_hbShimmer 4s linear infinite}
    ._hbDiscord{font-size:9px;font-weight:500;letter-spacing:1px;text-decoration:none;text-transform:uppercase;background:linear-gradient(90deg,#4752c4 0%,#7289da 40%,#b8c0ff 50%,#7289da 60%,#4752c4 100%);background-size:300% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:_hbShimmer 4s linear infinite;animation-delay:.8s}
    ._hbBadge{font-size:9px;color:#aaa;background:#e4e0d8;padding:2px 7px;border-radius:20px;letter-spacing:1px;border:1px solid #dbd9d3}
    ._hbHeaderRight{display:flex;align-items:center;gap:6px}
    ._hbHotkey{font-size:9px;color:#bbb;background:#ece9e2;padding:3px 8px;border-radius:20px;border:1px solid #dbd9d3}
    ._hbHotkey span{color:#2a2a2a;font-weight:600;background:#e0ddd6;padding:1px 5px;border-radius:3px}
    #_hbClose{background:none;border:none;color:#bbb;font-size:13px;cursor:pointer;padding:4px 6px;border-radius:6px;transition:all .15s}
    #_hbClose:hover{color:#d04040;background:#fae0e0}
    #_hbBody{padding:14px 16px;background:#f4f3f0;display:flex;flex-direction:column;gap:12px;overflow-y:auto;max-height:calc(100vh - 100px)}
    ._hbSectionTitle{font-size:9px;letter-spacing:2.5px;color:#b8b4ac;text-transform:uppercase;margin-bottom:7px;padding-bottom:5px;border-bottom:1px solid #e4e0d8}
    ._hbGrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
    ._hbToggle{display:flex;align-items:center;justify-content:space-between;padding:9px 11px;background:#fff;border:1px solid #e8e4de;border-radius:7px;cursor:pointer;transition:all .15s;user-select:none}
    ._hbToggle:hover{background:#faf9f6;border-color:#d4d0c8}
    ._hbToggle.on{background:#edf8f0;border-color:#b0dfc0}
    ._hbToggleLbl{font-size:10px;font-weight:500;letter-spacing:.5px;color:#999;text-transform:uppercase}
    ._hbToggle.on ._hbToggleLbl{color:#2a8a44}
    ._hbPip{width:7px;height:7px;border-radius:50%;background:#ddd;flex-shrink:0;transition:all .2s}
    ._hbToggle.on ._hbPip{background:#5bc470;box-shadow:0 0 7px #5bc47066}
    ._hbToggle.red.on{background:#fdf2f2;border-color:#f0bfbf}
    ._hbToggle.red.on ._hbToggleLbl{color:#c04040}
    ._hbToggle.red.on ._hbPip{background:#e87a7a;box-shadow:0 0 7px #e87a7a66}
    ._hbToggle.blue.on{background:#f0f4ff;border-color:#a0c0f0}
    ._hbToggle.blue.on ._hbToggleLbl{color:#3060c0}
    ._hbToggle.blue.on ._hbPip{background:#6aabff;box-shadow:0 0 7px #6aabff66}
    ._hbToggle.yellow.on{background:#fffbf0;border-color:#f0d890}
    ._hbToggle.yellow.on ._hbToggleLbl{color:#a07010}
    ._hbToggle.yellow.on ._hbPip{background:#e8c060;box-shadow:0 0 7px #e8c06066}
    ._hbInfoRow{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#fff;border:1px solid #e8e4de;border-radius:7px}
    ._hbInfoLabel{font-size:9px;color:#aaa;letter-spacing:1.5px;text-transform:uppercase}
    ._hbInfoVal{font-size:14px;font-weight:700;color:#2a2a2a}
    ._hbCard{background:#fff;border:1px solid #e8e4de;border-radius:7px;padding:10px 12px}
    ._hbSlugRow{display:flex;gap:6px;margin-bottom:10px}
    ._hbSlugRow input{flex:1;background:#f4f3f0;border:1px solid #e0ddd6;border-radius:5px;padding:5px 8px;font:11px 'DM Mono',monospace;color:#2a2a2a;outline:none}
    ._hbSlugRow input:focus{border-color:#b0b8c8}
    ._hbSlugRow button{background:#2a2a2a;color:#fff;border:none;border-radius:5px;padding:5px 10px;font:10px 'DM Mono',monospace;cursor:pointer;letter-spacing:.5px;transition:all .15s}
    ._hbSlugRow button:hover{background:#444}
    ._hbNote{font-size:9px;color:#c0bab2;font-style:italic;margin-top:5px;line-height:1.4}
    #_hbMatchDetail{display:none;border-radius:7px;overflow:hidden;border:1px solid #e4e0d8;background:#fff;margin-top:8px}
    #_hbStatusBar{padding:7px 16px;background:linear-gradient(to top,#ebe8e2,#edeae4);border-top:1px solid #dbd9d3;display:flex;align-items:center;justify-content:space-between;border-radius:0 0 12px 12px;flex-shrink:0}
    #_hbStatusText{font-size:9px;color:#c8c4bc;letter-spacing:.5px;transition:color .3s}
    ._hbFootRight{display:flex;align-items:center;gap:10px}
    #_hbFps{font-size:9px;color:#5bc470;letter-spacing:.5px}
    #_hbMs{font-size:9px;color:#5bc470;letter-spacing:.5px}
    ._hbVer{font-size:9px;color:#c8c4bc;letter-spacing:1px}
    @keyframes _hbBounce{from{transform:translateX(0)}to{transform:translateX(4px)}}
    @keyframes _hbPulse{0%,100%{box-shadow:0 0 8px #7289da88}50%{box-shadow:0 0 18px #7289dacc,0 0 30px #4752c466}}
    #_hbSidePanel{position:fixed;width:260px;background:#f4f3f0;border:1px solid #dbd9d3;border-radius:12px;z-index:10000;font-family:'DM Mono','Courier New',monospace;color:#2a2a2a;box-shadow:0 8px 32px rgba(0,0,0,.14);display:none;flex-direction:column;overflow:hidden}
    #_hbSidePanel._vis{display:flex;animation:_hbIn .18s cubic-bezier(.34,1.4,.64,1)}
    ._hbSideHeader{padding:10px 14px;background:linear-gradient(to bottom,#f0ede8,#ebe8e2);border-bottom:1px solid #dbd9d3;border-radius:12px 12px 0 0;display:flex;align-items:center;justify-content:space-between}
    ._hbSideTitle{font-size:9px;font-weight:500;letter-spacing:2.5px;color:#2a2a2a;text-transform:uppercase}
    ._hbSideBody{padding:12px 14px;display:flex;flex-direction:column;gap:8px;overflow-y:auto;max-height:80vh}
    ._hbSideStep{font-size:9px;color:#555;line-height:1.7;padding:8px 10px;background:#fff;border:1px solid #e8e4de;border-radius:7px}
    ._hbSideStep b{color:#2a2a2a;font-weight:700}
    ._hbSideStep.warn{background:#fffbf0;border-color:#f0d890;color:#a07010}
    ._hbSideCode{background:#f0ede8;border:1px solid #e4e0d8;border-radius:5px;padding:7px 9px;margin-top:6px;cursor:pointer;transition:background .15s}
    ._hbSideCode:hover{background:#e8e4de}
    ._hbSideCode pre{font-size:9px;color:#2a2a2a;font-family:'DM Mono',monospace;white-space:pre-wrap;word-break:break-all;line-height:1.5;margin:0}
    ._hbCopyHint{font-size:8px;color:#b8b4ac;margin-top:3px;letter-spacing:.5px}
  `;

  function buildUI() {
    if (document.getElementById('_hbPanel')) return;
    const style=document.createElement('style'); style.textContent=CSS; document.head.appendChild(style);
    const panel=document.createElement('div'); panel.id='_hbPanel';
    panel.innerHTML=`
      <div id="_hbHeader">
        <div class="_hbLogo">
          <div class="_hbDot" id="_hbDot"></div>
          <span class="_hbLogoText">VoidBacon</span>
          <span class="_hbBadge">v3.2</span>
        </div>
        <div class="_hbHeaderRight">
          <div class="_hbHotkey"><span>ESC</span></div>
          <button id="_hbClose">✕</button>
        </div>
      </div>
      <div id="_hbBody">
        <div>
          <div class="_hbSectionTitle">Visuals</div>
          <div class="_hbGrid">
            <div class="_hbToggle on"  id="_hbt-Esp"><span class="_hbToggleLbl">Esp</span><div class="_hbPip"></div></div>
            <div class="_hbToggle on"  id="_hbt-names"><span class="_hbToggleLbl">Names</span><div class="_hbPip"></div></div>
            <div class="_hbToggle on"  id="_hbt-healthBars"><span class="_hbToggleLbl">HP Bars</span><div class="_hbPip"></div></div>
          </div>
          <div class="_hbNote">⚠ HP bars = teammates only (server never sends enemy HP)</div>
        </div>
        <div>
          <div class="_hbSectionTitle">Combat</div>
          <div class="_hbGrid">
            <div class="_hbToggle red"    id="_hbt-lockOn"><span class="_hbToggleLbl">Lock-On</span><div class="_hbPip"></div></div>
            <div class="_hbToggle yellow" id="_hbt-magnet"><span class="_hbToggleLbl">Magnet</span><div class="_hbPip"></div></div>
          </div>
          <div class="_hbNote">Lock-On: T key &nbsp;|&nbsp; Magnet: E key (auto-moves when ≤6 studs)</div>
        </div>
        <div>
          <div class="_hbSectionTitle">World</div>
          <div class="_hbGrid">
            <div class="_hbToggle blue"   id="_hbt-xray"><span class="_hbToggleLbl">X-Ray</span><div class="_hbPip"></div></div>
            <div class="_hbToggle yellow" id="_hbt-lootEsp"><span class="_hbToggleLbl">Loot ESP</span><div class="_hbPip"></div></div>
          </div>
          <div class="_hbNote">Loot: purple=tier3  gold=tier2  blue=tier1</div>
        </div>
        <div>
          <div class="_hbSectionTitle">Grenades</div>
          <div class="_hbGrid">
            <div class="_hbToggle on yellow" id="_hbt-grenTimer"><span class="_hbToggleLbl">Nade Timer</span><div class="_hbPip"></div></div>
          </div>
          <div class="_hbNote">Cook timer while holding  |  Countdown + danger ring on thrown nades<br>Frag/Mirv: 4s  |  Smoke/Strobe: 3s  |  Potato: 2.5s</div>
        </div>
        <div>
          <div class="_hbSectionTitle">This Game</div>
          <div class="_hbInfoRow">
            <span class="_hbInfoLabel">Kills</span>
            <span class="_hbInfoVal" id="_hbKills">0</span>
          </div>
        </div>
        <div>
          <div class="_hbSectionTitle">Player Stats</div>
          <div class="_hbCard">
            <div class="_hbSlugRow">
              <input id="_hbSlugInput" type="text" placeholder="slug e.g. fr" />
              <button id="_hbStatsFetch">GO</button>
            </div>
            <div id="_hbStatsBox" style="text-align:center;padding:4px 0;font-size:10px;color:#aaa">enter slug above</div>
          </div>
        </div>
        <div>
          <div class="_hbSectionTitle">Recent Matches <span style="font-size:8px;color:#c0bab2;font-weight:normal;letter-spacing:0">— click row for details</span></div>
          <div id="_hbHistoryBox" style="font-size:10px;color:#aaa;text-align:center;padding:4px 0">search player above</div>
          <div id="_hbMatchDetail"></div>
        </div>
      </div>
      <div id="_hbStatusBar">
        <span id="_hbStatusText">waiting...</span>
        <div class="_hbFootRight">
          <span id="_hbFps">-- fps</span>
          <span id="_hbMs">-- ms</span>
          <span class="_hbFootRight" style="display:flex;align-items:center;gap:4px">
            <span style="font-size:14px;animation:_hbBounce 0.6s infinite alternate">👉</span>
            <a class="_hbDiscord" href="https://discord.gg/7WgfQc4k" target="_blank" style="font-size:12px;font-weight:700;letter-spacing:1.5px;padding:3px 8px;background:linear-gradient(135deg,#4752c4,#7289da);-webkit-background-clip:unset;-webkit-text-fill-color:unset;background-clip:unset;color:#fff;border-radius:5px;text-decoration:none;box-shadow:0 0 10px #7289da88;animation:_hbPulse 1.5s infinite">DISCORD</a>
            <span style="font-size:14px;animation:_hbBounce 0.6s infinite alternate 0.3s">👈</span>
          </span>
          <span class="_hbVer">v3.2</span>
        </div>
      </div>`;
    document.body.appendChild(panel);

    // ── Side setup panel ──────────────────────────────────────────
    const sidePanel = document.createElement('div'); sidePanel.id = '_hbSidePanel';
    sidePanel.innerHTML = `
      <div class="_hbSideHeader">
        <div class="_hbSideTitle">⚙ Setup Guide</div>
        <button id="_hbSideClose" style="background:none;border:none;color:#bbb;font-size:13px;cursor:pointer;padding:2px 6px;border-radius:5px">✕</button>
      </div>
      <div class="_hbSideBody">
        <div class="_hbSideStep"><b>Step 1</b> — Press <b>F12</b> → <b>Sources</b> tab → <b>Snippets</b></div>
        <div class="_hbSideStep">
          <b>Step 2</b> — Create <b>Snippet 1</b> (debugger pause):<br><br>
          <div class="_hbSideCode" id="_hbSideSnip1">
            <pre>debug(getEventListeners(window).resize[0].listener);
window.dispatchEvent(new Event('resize'));</pre>
            <div class="_hbCopyHint">▸ click to copy</div>
          </div>
        </div>
        <div class="_hbSideStep">
          <b>Step 3</b> — Create <b>Snippet 2</b>:<br><br>
          <div class="_hbSideCode" id="_hbSideSnip2">
            <pre>window.__Re = Re</pre>
            <div class="_hbCopyHint">▸ click to copy</div>
          </div>
        </div>
        <div class="_hbSideStep">
          <b>Step 4</b> — On the <b>loading screen</b>:<br><br>
          1. Run <b>Snippet 1</b> — game will pause in debugger<br>
          2. Run <b>Snippet 2</b> in the console<br>
          3. Click <b>Resume ▶</b> to unpause<br><br>
          Done ✓
        </div>
        <div class="_hbSideStep warn">⚠ Do this <b>every time</b> you open survev.io. After 1 game it stays — no need to redo unless you refresh.</div>
      </div>`;
    document.body.appendChild(sidePanel);
    document.getElementById('_hbSideClose').onclick = () => { sidePanel.classList.remove('_vis'); sidePanel._dismissed = true; };
    document.getElementById('_hbSideSnip1').onclick = function() {
      navigator.clipboard.writeText('debug(getEventListeners(window).resize[0].listener);\nwindow.dispatchEvent(new Event(\'resize\'));').then(() => {
        const h = this.querySelector('._hbCopyHint');
        if (h) { const o=h.textContent; h.textContent='✓ copied!'; h.style.color='#5bc470'; setTimeout(()=>{h.textContent=o;h.style.color='';},1500); }
      }).catch(()=>{});
    };
    document.getElementById('_hbSideSnip2').onclick = function() {
      navigator.clipboard.writeText('window.__Re = Re').then(() => {
        const h = this.querySelector('._hbCopyHint');
        if (h) { const o=h.textContent; h.textContent='✓ copied!'; h.style.color='#5bc470'; setTimeout(()=>{h.textContent=o;h.style.color='';},1500); }
      }).catch(()=>{});
    };

    function positionSide() {
      const r = panel.getBoundingClientRect();
      const sw = 260, margin = 10;
      sidePanel.style.left = (r.right + margin + sw > window.innerWidth)
        ? (r.left - margin - sw) + 'px' : (r.right + margin) + 'px';
      sidePanel.style.top = r.top + 'px';
    }
    window._hbPosSide = positionSide;

    // ── Build game texture lookup cache from PIXI spritesheets ────
    // Called once after game loads to cache weapon texture refs
    window._hbTexCache = null;
    window._hbBuildTexCache = function() {
      if (window._hbTexCache) return;
      try {
        window._hbTexCache = {};
        const atlases = window.__Re?.resourceManager?.atlases;
        if (!atlases) return;
        // Weapons are in loadout + main atlases. Format: gun-ak47-01.img
        for (const atlas of Object.values(atlases)) {
          const sheets = atlas?.spritesheets;
          if (!sheets) continue;
          const arr = Array.isArray(sheets) ? sheets : Object.values(sheets);
          for (const sheet of arr) {
            const textures = sheet?.textures;
            if (!textures) continue;
            for (const [name, tex] of Object.entries(textures)) {
              window._hbTexCache[name.toLowerCase()] = tex;
            }
          }
        }
      } catch(e) {}
    };

    // Map weapon id to loadout texture name pattern: gun-{id}-01.img
    window._hbGetWeapTex = function(weapId) {
      const cache = window._hbTexCache;
      if (!cache) return null;
      const id = weapId.toLowerCase().replace(/ /g,'-').replace(/_/g,'-');
      // Try patterns in order of likelihood
      const tries = [
        `gun-${id}-01.img`,
        `gun-${id}-02.img`,
        `loot-weapon-${id}-01.img`,
        `loot-${id}-01.img`,
        `loot-melee-${id}.img`,
        `loot-melee-${id}-01.img`,
        `${id}-01.img`,
        `${id}.img`,
      ];
      for (const t of tries) {
        const tex = cache[t];
        if (tex) return tex;
      }
      // fuzzy: find any key containing the id
      for (const [k, v] of Object.entries(cache)) {
        if (k.includes(id) && (k.startsWith('gun-') || k.startsWith('loot-weapon') || k.startsWith('loot-melee'))) return v;
      }
      return null;
    };

    for (const [id,key] of Object.entries({
      '_hbt-Esp':'Esp','_hbt-names':'names','_hbt-healthBars':'healthBars',
      '_hbt-lockOn':'lockOn','_hbt-magnet':'magnet','_hbt-xray':'xray',
      '_hbt-lootEsp':'lootEsp','_hbt-grenTimer':'grenTimer'
    })) document.getElementById(id).onclick = () => flipToggle(key);

    const slugInput = document.getElementById('_hbSlugInput');
    document.getElementById('_hbStatsFetch').onclick = () => fetchStats(slugInput.value.trim());
    slugInput.addEventListener('keydown', e => { if(e.key==='Enter') fetchStats(slugInput.value.trim()); });

    setTimeout(() => {
      const nameEl = document.getElementById('account-player-name');
      if (nameEl && !slugInput.value) {
        const slug = nameEl.textContent.trim().toLowerCase().replace(/\s+/g,'-');
        const skip = ['log-in','create-account','guest','sign-in'];
        if (slug && !skip.some(s=>slug.includes(s))) { slugInput.value=slug; fetchStats(slug); }
      }
    }, 2000);

    let drag=false, ox=0, oy=0;
    document.getElementById('_hbHeader').onmousedown = e => {
      if (e.target.id==='_hbClose') return;
      drag=true; const r=panel.getBoundingClientRect(); ox=e.clientX-r.left; oy=e.clientY-r.top;
    };
    document.addEventListener('mousemove', e => {
      if (!drag) return;
      panelX=e.clientX-ox; panelY=e.clientY-oy;
      panel.style.left=panelX+'px'; panel.style.top=panelY+'px'; panel.style.right='auto';
      if(window._hbPosSide) window._hbPosSide();
    });
    document.addEventListener('mouseup', () => drag=false);
  }

  function flipToggle(key) {
    cfg[key] = !cfg[key];
    const id = {Esp:'_hbt-Esp',names:'_hbt-names',healthBars:'_hbt-healthBars',lockOn:'_hbt-lockOn',magnet:'_hbt-magnet',xray:'_hbt-xray',lootEsp:'_hbt-lootEsp',grenTimer:'_hbt-grenTimer'}[key];
    const el = document.getElementById(id);
    if (el) el.classList.toggle('on', cfg[key]);
    if (key==='xray' && !cfg.xray) { const game=findRe()?.game; if(game) restoreXray(game); }
    if (key==='magnet' && !cfg.magnet) { const Re=findRe(); if(Re?.input) clearMagnetKeys(Re.input); }
    setStatus(`${key} ${cfg[key]?'on':'off'}`);
  }

  function openPanel() {
    const panel=document.getElementById('_hbPanel'); if(!panel) return;
    if (panelX===null) { panelX=Math.round((innerWidth-340)/2); panelY=Math.round((innerHeight-600)/2); }
    panel.style.left=panelX+'px'; panel.style.top=panelY+'px'; panel.style.right='auto';
    panel.style.display='flex'; isPanelVisible=true;
    const sp=document.getElementById('_hbSidePanel');
    if (sp && !sp._dismissed) { if(window._hbPosSide) window._hbPosSide(); sp.classList.add('_vis'); }
    window._hbBuildTexCache?.();
  }
  function closePanel() {
    const p=document.getElementById('_hbPanel'); if(p) p.style.display='none'; isPanelVisible=false;
    const sp=document.getElementById('_hbSidePanel'); if(sp && !sp._dismissed) sp.classList.remove('_vis');
  }

  let _st=null;
  function setStatus(msg, color='#c8c4bc') {
    const el=document.getElementById('_hbStatusText'), dot=document.getElementById('_hbDot');
    if (!el) return;
    el.style.color=color; el.textContent=msg;
    if (dot) { dot.style.background=color; dot.style.boxShadow=`0 0 10px ${color}44`; }
    clearTimeout(_st);
    if (color!=='#5bc470' && color!=='#c8c4bc') _st=setTimeout(()=>setStatus('ready'),3000);
  }

  document.addEventListener('keydown', e => {
    if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
    if (e.key==='Escape') { e.preventDefault(); isPanelVisible?closePanel():openPanel(); }
    if (e.key==='t'||e.key==='T') flipToggle('lockOn');
    if (e.key==='e'||e.key==='E') flipToggle('magnet');
  }, true);

  function waitForGame() {
    buildUI(); openPanel();
    let tries=0;
    const poll=setInterval(()=>{
      tries++;
      const Re=findRe();
      if (Re?.game?.initialized) { clearInterval(poll); startOverlay(Re); return; }
      if (Re?.game) { setStatus('in lobby','#b8a060'); return; }
      if (tries%10===0) console.log(`[VoidBacon] waiting... __Re:${!!window.__Re}`);
      if (tries>240) {
        clearInterval(poll);
        setStatus('needs setup','#c05050');
        console.warn('[VoidBacon] Could not auto-capture. Run in console:\n  window.__Re = <App object>');
      }
    }, 500);
  }

  const obs=new MutationObserver((_,o)=>{ if(document.getElementById('ui-game')){ o.disconnect(); waitForGame(); } });
  window.addEventListener('DOMContentLoaded', () => {
    if (document.getElementById('ui-game')) waitForGame();
    else obs.observe(document.body, {childList:true, subtree:true});
  });
})();