survev.io script -(VoidBacon)

new shit is on people post suggestions in discord

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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});
  });
})();