851 lines
34 KiB
C++
851 lines
34 KiB
C++
#include "GameOverlay.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <format>
|
|
#include <string>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
#include "Readers/EntityTypeCache.h"
|
|
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#ifndef NOMINMAX
|
|
#define NOMINMAX
|
|
#endif
|
|
#include <Windows.h>
|
|
#include <winsock2.h>
|
|
#include <ws2tcpip.h>
|
|
#include <iphlpapi.h>
|
|
#pragma comment(lib, "iphlpapi.lib")
|
|
|
|
#include <imgui.h>
|
|
#include <imgui_internal.h>
|
|
|
|
#include "MenuBridge.h"
|
|
|
|
// Per-frame bridge pointer consumed by the vendored Lumin menu (gui.cpp).
|
|
MenuBridge* g_menu = nullptr;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Bone interpolation / extrapolation helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
int64_t GameOverlay::NowMs() {
|
|
using namespace std::chrono;
|
|
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
// Linearly interpolate (or extrapolate when t > 1) between two bone sets.
|
|
static SkeletonBones LerpBones(const SkeletonBones& a, const SkeletonBones& b, float t) {
|
|
auto lv = [t](const Vector3& p, const Vector3& q) -> Vector3 {
|
|
return { p.x + t*(q.x-p.x), p.y + t*(q.y-p.y), p.z + t*(q.z-p.z) };
|
|
};
|
|
SkeletonBones out;
|
|
out.valid = b.valid;
|
|
out.neck = lv(a.neck, b.neck);
|
|
out.head = lv(a.head, b.head);
|
|
out.spine = lv(a.spine, b.spine);
|
|
out.pelvis = lv(a.pelvis, b.pelvis);
|
|
out.rightShoulder = lv(a.rightShoulder, b.rightShoulder);
|
|
out.rightElbow = lv(a.rightElbow, b.rightElbow);
|
|
out.rightHand = lv(a.rightHand, b.rightHand);
|
|
out.leftShoulder = lv(a.leftShoulder, b.leftShoulder);
|
|
out.leftElbow = lv(a.leftElbow, b.leftElbow);
|
|
out.leftHand = lv(a.leftHand, b.leftHand);
|
|
out.rightHip = lv(a.rightHip, b.rightHip);
|
|
out.rightKnee = lv(a.rightKnee, b.rightKnee);
|
|
out.rightAnkle = lv(a.rightAnkle, b.rightAnkle);
|
|
out.leftHip = lv(a.leftHip, b.leftHip);
|
|
out.leftKnee = lv(a.leftKnee, b.leftKnee);
|
|
out.leftAnkle = lv(a.leftAnkle, b.leftAnkle);
|
|
return out;
|
|
}
|
|
|
|
// Update the history when a new DMA read is detected (head bone moved).
|
|
static void UpdateBoneHistory(GameOverlay::BoneHistory& h,
|
|
const SkeletonBones& bones, int64_t nowMs)
|
|
{
|
|
if (!h.initialized) {
|
|
h.prev = h.curr = bones;
|
|
h.prevMs = h.currMs = nowMs;
|
|
h.initialized = true;
|
|
return;
|
|
}
|
|
const Vector3& o = h.curr.head;
|
|
const Vector3& n = bones.head;
|
|
float dx = o.x-n.x, dy = o.y-n.y, dz = o.z-n.z;
|
|
float dist2 = dx*dx + dy*dy + dz*dz;
|
|
// Reject implausible jumps: > 3 m in one update cycle can't happen at
|
|
// any in-game speed — it means the scatter read returned garbage.
|
|
// Keeping the old history prevents one stale read from corrupting the
|
|
// extrapolation state; the DMA-side eviction (cache->valid=false) will
|
|
// fix the pointer within the next boneRefreshMs window.
|
|
if (dist2 > 9.0f) return;
|
|
if (dist2 > 1e-8f) {
|
|
h.prev = h.curr;
|
|
h.prevMs = h.currMs;
|
|
h.curr = bones;
|
|
h.currMs = nowMs;
|
|
}
|
|
}
|
|
|
|
// Return bones extrapolated to nowMs using the recorded velocity.
|
|
// Alpha is clamped to 1.5 update intervals to limit overshoot on direction changes.
|
|
static SkeletonBones GetSmoothedBones(const GameOverlay::BoneHistory& h, int64_t nowMs) {
|
|
if (!h.initialized) return h.curr;
|
|
int64_t dt = h.currMs - h.prevMs;
|
|
if (dt <= 0) return h.curr;
|
|
float alpha = static_cast<float>(nowMs - h.prevMs) / static_cast<float>(dt);
|
|
alpha = std::min(alpha, 1.5f);
|
|
return LerpBones(h.prev, h.curr, alpha);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SetWebRadarPort — resolve LAN IPs and build display URLs
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::SetWebRadarPort(int port) {
|
|
m_webPort = port;
|
|
m_webUrls.clear();
|
|
m_webUrls.push_back(std::format("http://localhost:{}", port));
|
|
|
|
// Enumerate all unicast IPv4 addresses.
|
|
ULONG bufLen = 15000;
|
|
std::vector<BYTE> buf(bufLen);
|
|
auto* table = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
|
|
DWORD ret = GetAdaptersAddresses(AF_INET,
|
|
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
|
|
nullptr, table, &bufLen);
|
|
if (ret == ERROR_BUFFER_OVERFLOW) {
|
|
buf.resize(bufLen);
|
|
table = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
|
|
ret = GetAdaptersAddresses(AF_INET,
|
|
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
|
|
nullptr, table, &bufLen);
|
|
}
|
|
|
|
if (ret == NO_ERROR) {
|
|
for (auto* a = table; a; a = a->Next) {
|
|
if (a->OperStatus != IfOperStatusUp) continue;
|
|
for (auto* ua = a->FirstUnicastAddress; ua; ua = ua->Next) {
|
|
auto* sin = reinterpret_cast<sockaddr_in*>(ua->Address.lpSockaddr);
|
|
char ipStr[INET_ADDRSTRLEN] = {};
|
|
inet_ntop(AF_INET, &sin->sin_addr, ipStr, sizeof(ipStr));
|
|
std::string ip(ipStr);
|
|
if (ip == "127.0.0.1") continue;
|
|
m_webUrls.push_back(std::format("http://{}:{}", ip, port));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
float GameOverlay::Dist(const Vector3& a, const Vector3& b) {
|
|
float dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
|
|
return std::sqrtf(dx*dx + dy*dy + dz*dz);
|
|
}
|
|
|
|
// Returns true if the world position is in front of the camera (depth >= 0.65).
|
|
// Uses the same depth calculation as WorldToScreen so the threshold is consistent.
|
|
// Call this before WorldToScreen to skip the full projection for behind-camera entities.
|
|
static bool IsFacingCamera(const CameraData& cam, const Vector3& pos) {
|
|
float tx = pos.x - cam.translation.x;
|
|
float ty = pos.y - cam.translation.y;
|
|
float tz = pos.z - cam.translation.z;
|
|
float depth = tx*cam.invertedViewForward.x
|
|
+ ty*cam.invertedViewForward.y
|
|
+ tz*cam.invertedViewForward.z;
|
|
return depth >= 0.65f;
|
|
}
|
|
|
|
bool GameOverlay::WorldToScreen(const CameraData& cam,
|
|
const Vector3& world,
|
|
float& sx, float& sy,
|
|
float w, float h)
|
|
{
|
|
float tx = world.x - cam.translation.x;
|
|
float ty = world.y - cam.translation.y;
|
|
float tz = world.z - cam.translation.z;
|
|
|
|
float x = tx*cam.invertedViewRight.x + ty*cam.invertedViewRight.y + tz*cam.invertedViewRight.z;
|
|
float y = tx*cam.invertedViewUp.x + ty*cam.invertedViewUp.y + tz*cam.invertedViewUp.z;
|
|
float z = tx*cam.invertedViewForward.x + ty*cam.invertedViewForward.y + tz*cam.invertedViewForward.z;
|
|
|
|
if (z < 0.65f) return false;
|
|
if (cam.projectionD1x == 0.0f || cam.projectionD2y == 0.0f) return false;
|
|
|
|
float nx = (x / cam.projectionD1x) / z;
|
|
float ny = (y / cam.projectionD2y) / z;
|
|
|
|
sx = roundf((w * 0.5f) + nx * (w * 0.5f));
|
|
sy = roundf((h * 0.5f) - ny * (h * 0.5f));
|
|
|
|
return sx >= 0.0f && sx <= w && sy >= 0.0f && sy <= h;
|
|
}
|
|
|
|
// Viewport-aware projection.
|
|
// For stretch-to-fill (or when no render res is configured) the viewport
|
|
// equals the full overlay — identical to calling WorldToScreen directly.
|
|
// For maintain-aspect-ratio with render != display, computes the inset
|
|
// viewport (letterbox top/bottom or pillarbox left/right) and offsets the
|
|
// result so entities land on the correct monitor pixels.
|
|
bool GameOverlay::Proj(const CameraData& cam, const Vector3& worldPos,
|
|
float& sx, float& sy,
|
|
float overlayW, float overlayH) const
|
|
{
|
|
float vx = 0.0f, vy = 0.0f, vw = overlayW, vh = overlayH;
|
|
|
|
if (m_renderW > 0 && m_renderH > 0 && !m_stretchToFill) {
|
|
float renderAr = static_cast<float>(m_renderW) / static_cast<float>(m_renderH);
|
|
float displayAr = overlayW / overlayH;
|
|
|
|
if (renderAr > displayAr + 0.01f) {
|
|
// Render is wider than display → letterbox (black bars top/bottom)
|
|
vw = overlayW;
|
|
vh = overlayW / renderAr;
|
|
vy = (overlayH - vh) * 0.5f;
|
|
} else if (displayAr > renderAr + 0.01f) {
|
|
// Display is wider than render → pillarbox (black bars left/right)
|
|
vh = overlayH;
|
|
vw = overlayH * renderAr;
|
|
vx = (overlayW - vw) * 0.5f;
|
|
}
|
|
}
|
|
// stretch-to-fill: vx=0, vy=0, vw=overlayW, vh=overlayH — no change
|
|
|
|
if (!WorldToScreen(cam, worldPos, sx, sy, vw, vh)) return false;
|
|
sx += vx;
|
|
sy += vy;
|
|
return sx >= 0.0f && sx <= overlayW && sy >= 0.0f && sy <= overlayH;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Draw — entry point called each ImGui frame
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::SyncConfig() {
|
|
m_cfg.showPlayers = m_showPlayers;
|
|
m_cfg.showAnimals = m_showAnimals;
|
|
m_cfg.showZombies = m_showZombies;
|
|
m_cfg.showItems = m_showItems;
|
|
m_cfg.showBox = m_showBox;
|
|
m_cfg.showSkeleton = m_showSkeleton;
|
|
m_cfg.showHeadDot = m_showHeadDot;
|
|
m_cfg.showCorpses = m_showCorpses;
|
|
m_cfg.showWeapon = m_showWeapon;
|
|
m_cfg.showHealthBar = m_showHealthBar;
|
|
m_cfg.showHealthNumber = m_showHealthNumber;
|
|
m_cfg.itemCategories = m_itemCategories;
|
|
m_cfg.playerMaxDist = m_playerMaxDist;
|
|
m_cfg.animalMaxDist = m_animalMaxDist;
|
|
m_cfg.zombieMaxDist = m_zombieMaxDist;
|
|
m_cfg.itemMaxDist = m_itemMaxDist;
|
|
m_cfg.renderWidth = m_renderW;
|
|
m_cfg.renderHeight = m_renderH;
|
|
m_cfg.stretchToFill = m_stretchToFill;
|
|
}
|
|
|
|
void GameOverlay::Draw(float w, float h) {
|
|
// Refresh snapshot once per frame — shared_ptr copy is 8 bytes, no vector alloc
|
|
m_snapshot = m_service.GetLatestUpdate();
|
|
if (!m_snapshot) return;
|
|
const RuntimeUpdate& u = *m_snapshot;
|
|
|
|
// Record frame time once; used by all bone-smoothing calls this frame.
|
|
m_frameTimeMs = NowMs();
|
|
|
|
// Feed new DMA bone reads into the per-entity history so extrapolation
|
|
// always has the two most recent samples to work from.
|
|
for (const auto& p : u.players)
|
|
if (p.skeleton.valid)
|
|
UpdateBoneHistory(m_playerBoneHistory[p.address], p.skeleton, m_frameTimeMs);
|
|
for (const auto& z : u.zombies)
|
|
if (z.skeleton.valid)
|
|
UpdateBoneHistory(m_zombieBoneHistory[z.address], z.skeleton, m_frameTimeMs);
|
|
|
|
// Prune bone histories for entities no longer present in the snapshot.
|
|
// Prevents ghost extrapolation when a player logs out or leaves the scan zone:
|
|
// their history is cleared immediately so stale bones can't persist.
|
|
{
|
|
std::unordered_set<uint64_t> livePlayers, liveZombies;
|
|
livePlayers.reserve(u.players.size());
|
|
liveZombies.reserve(u.zombies.size());
|
|
for (const auto& p : u.players) livePlayers.insert(p.address);
|
|
for (const auto& z : u.zombies) liveZombies.insert(z.address);
|
|
|
|
for (auto it = m_playerBoneHistory.begin(); it != m_playerBoneHistory.end(); )
|
|
it = livePlayers.count(it->first) ? std::next(it) : m_playerBoneHistory.erase(it);
|
|
for (auto it = m_zombieBoneHistory.begin(); it != m_zombieBoneHistory.end(); )
|
|
it = liveZombies.count(it->first) ? std::next(it) : m_zombieBoneHistory.erase(it);
|
|
}
|
|
|
|
// INSERT key toggles the menu
|
|
if (GetAsyncKeyState(VK_INSERT) & 1) {
|
|
m_menuOpen = !m_menuOpen;
|
|
m_menuAlpha = m_menuOpen ? 1.0f : 0.0f;
|
|
if (!m_menuOpen) {
|
|
// Persist settings back to config on close.
|
|
SyncConfig();
|
|
m_cfg.Save(m_cfgPath);
|
|
}
|
|
}
|
|
|
|
// ---- Lumin menu (vendored under external/lumin; driven via MenuBridge) ----
|
|
if (m_menuOpen) {
|
|
static MenuBridge bridge;
|
|
|
|
bridge.showPlayers = &m_showPlayers;
|
|
bridge.showAnimals = &m_showAnimals;
|
|
bridge.showZombies = &m_showZombies;
|
|
bridge.showItems = &m_showItems;
|
|
bridge.showBox = &m_showBox;
|
|
bridge.showSkeleton = &m_showSkeleton;
|
|
bridge.showHeadDot = &m_showHeadDot;
|
|
bridge.showWeapon = &m_showWeapon;
|
|
bridge.showHealthBar = &m_showHealthBar;
|
|
bridge.showHealthNumber = &m_showHealthNumber;
|
|
bridge.showCorpses = &m_showCorpses;
|
|
bridge.debugSkeleton = &m_debugSkeleton;
|
|
|
|
bridge.playerMaxDist = &m_playerMaxDist;
|
|
bridge.animalMaxDist = &m_animalMaxDist;
|
|
bridge.zombieMaxDist = &m_zombieMaxDist;
|
|
bridge.itemMaxDist = &m_itemMaxDist;
|
|
|
|
bridge.itemCategories = &m_itemCategories;
|
|
|
|
bridge.pendingW = &m_pendingW;
|
|
bridge.pendingH = &m_pendingH;
|
|
bridge.pendingRW = &m_pendingRW;
|
|
bridge.pendingRH = &m_pendingRH;
|
|
bridge.stretchToFill = &m_stretchToFill;
|
|
|
|
// Read-only info panels.
|
|
bridge.connected = u.areBaseObjectsReady;
|
|
bridge.serverName = u.serverName.value_or("");
|
|
bridge.mapName = u.serverMapName.value_or("");
|
|
bridge.status = u.status;
|
|
if (u.localPlayerPosition.has_value()) {
|
|
bridge.hasPos = true;
|
|
bridge.px = u.localPlayerPosition->x;
|
|
bridge.py = u.localPlayerPosition->y;
|
|
bridge.pz = u.localPlayerPosition->z;
|
|
} else {
|
|
bridge.hasPos = false;
|
|
}
|
|
bridge.nPlayers = u.players.size();
|
|
bridge.nAnimals = u.animals.size();
|
|
bridge.nZombies = u.zombies.size();
|
|
bridge.nVehicles = u.carsAndBoats.size();
|
|
bridge.nItems = u.items.size();
|
|
bridge.nBullets = u.bullets.size();
|
|
|
|
bridge.webPort = m_webPort;
|
|
bridge.webUrls = m_webUrls;
|
|
|
|
bridge.onSaveConfig = [this]() {
|
|
SyncConfig();
|
|
m_cfg.Save(m_cfgPath);
|
|
};
|
|
bridge.onExit = [this]() {
|
|
SyncConfig();
|
|
m_cfg.Save(m_cfgPath);
|
|
if (m_exitCallback) m_exitCallback();
|
|
};
|
|
bridge.onApplyDisplayRes = [this]() {
|
|
m_cfg.overlayWidth = m_pendingW;
|
|
m_cfg.overlayHeight = m_pendingH;
|
|
m_cfg.Save(m_cfgPath);
|
|
if (m_resizeCallback) m_resizeCallback(m_pendingW, m_pendingH);
|
|
};
|
|
bridge.onApplyRenderRes = [this]() {
|
|
m_renderW = m_pendingRW;
|
|
m_renderH = m_pendingRH;
|
|
m_cfg.renderWidth = m_pendingRW;
|
|
m_cfg.renderHeight = m_pendingRH;
|
|
m_cfg.stretchToFill = m_stretchToFill;
|
|
m_cfg.Save(m_cfgPath);
|
|
};
|
|
|
|
g_menu = &bridge;
|
|
RenderLuminMenu();
|
|
}
|
|
|
|
// ---- ESP draw lists — camera read directly from service every frame ----
|
|
m_service.GetLatestCamera(m_liveCamera);
|
|
DrawESP(w, h, u, m_liveCamera);
|
|
}
|
|
|
|
void GameOverlay::DrawESP(float w, float h, const RuntimeUpdate& u, const CameraData& cam) {
|
|
if (!u.areBaseObjectsReady || !cam.valid) return;
|
|
|
|
ImDrawList* dl = ImGui::GetBackgroundDrawList();
|
|
if (m_showPlayers) DrawPlayers(dl, u, cam, w, h);
|
|
if (m_showAnimals) DrawAnimals(dl, u, cam, w, h);
|
|
if (m_showZombies) DrawZombies(dl, u, cam, w, h);
|
|
if (m_showItems) DrawItems (dl, u, cam, w, h);
|
|
if (m_debugSkeleton) DrawSkeletonDebug(dl, u, cam, w, h);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DrawSkeleton — render 11 bone segments for one entity
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::DrawSkeleton(ImDrawList* dl, const SkeletonBones& bones,
|
|
const CameraData& cam,
|
|
float w, float h, unsigned int color,
|
|
bool isZombie) const
|
|
{
|
|
// Project each unique bone ONCE, then draw segments from cached screen coords.
|
|
// Old approach called WorldToScreen twice per segment; shared bones (neck, spine)
|
|
// were projected 3-4 times each. 16 projections replaces 28-30.
|
|
enum : int {
|
|
B_Neck=0, B_Head, B_Spine, B_Pelvis,
|
|
B_RShoulder, B_RElbow, B_RHand,
|
|
B_LShoulder, B_LElbow, B_LHand,
|
|
B_RHip, B_RKnee, B_RAnkle,
|
|
B_LHip, B_LKnee, B_LAnkle,
|
|
B_COUNT
|
|
};
|
|
|
|
const Vector3* const kWorld[B_COUNT] = {
|
|
&bones.neck, &bones.head, &bones.spine, &bones.pelvis,
|
|
&bones.rightShoulder, &bones.rightElbow, &bones.rightHand,
|
|
&bones.leftShoulder, &bones.leftElbow, &bones.leftHand,
|
|
&bones.rightHip, &bones.rightKnee, &bones.rightAnkle,
|
|
&bones.leftHip, &bones.leftKnee, &bones.leftAnkle,
|
|
};
|
|
|
|
struct SP { float x, y; bool ok; } scr[B_COUNT];
|
|
for (int i = 0; i < B_COUNT; ++i)
|
|
scr[i].ok = Proj(cam, *kWorld[i], scr[i].x, scr[i].y, w, h);
|
|
|
|
struct Seg { int8_t a, b; };
|
|
static const Seg kPlayerSegs[14] = {
|
|
{B_Neck,B_Head},
|
|
{B_Neck,B_RShoulder},{B_RShoulder,B_RElbow},{B_RElbow,B_RHand},
|
|
{B_Neck,B_LShoulder},{B_LShoulder,B_LElbow},{B_LElbow,B_LHand},
|
|
{B_Neck,B_Spine},
|
|
{B_Spine,B_RHip},{B_RHip,B_RKnee},{B_RKnee,B_RAnkle},
|
|
{B_Spine,B_LHip},{B_LHip,B_LKnee},{B_LKnee,B_LAnkle},
|
|
};
|
|
static const Seg kZombieSegs[15] = {
|
|
{B_Spine,B_Neck},{B_Neck,B_Head},
|
|
{B_Spine,B_LShoulder},{B_LShoulder,B_LElbow},{B_LElbow,B_LHand},
|
|
{B_Spine,B_RShoulder},{B_RShoulder,B_RElbow},{B_RElbow,B_RHand},
|
|
{B_Spine,B_Pelvis},
|
|
{B_Pelvis,B_RHip},{B_RHip,B_RKnee},{B_RKnee,B_RAnkle},
|
|
{B_Pelvis,B_LHip},{B_LHip,B_LKnee},{B_LKnee,B_LAnkle},
|
|
};
|
|
|
|
const Seg* segs = isZombie ? kZombieSegs : kPlayerSegs;
|
|
const int count = isZombie ? 15 : 14;
|
|
|
|
for (int i = 0; i < count; ++i) {
|
|
const SP& a = scr[segs[i].a];
|
|
const SP& b = scr[segs[i].b];
|
|
if (!a.ok || !b.ok) continue;
|
|
dl->AddLine(ImVec2(a.x, a.y), ImVec2(b.x, b.y), color, 1.5f);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DrawSkeletonDebug — named bone dots for the closest player
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u,
|
|
const CameraData& cam, float w, float h) const
|
|
{
|
|
// Find the closest player that has skeleton data (valid or not).
|
|
const DayZPlayerEntry* target = nullptr;
|
|
float bestDist = 1e9f;
|
|
for (const auto& p : u.players) {
|
|
if (!p.position.has_value()) continue;
|
|
float d = Dist(cam.translation, *p.position);
|
|
if (d < 2.0f || d > m_playerMaxDist) continue;
|
|
if (d < bestDist) { bestDist = d; target = &p; }
|
|
}
|
|
|
|
if (!target) return;
|
|
|
|
// Top-left status block.
|
|
const float pad = 10.0f;
|
|
const float lineH = 16.0f;
|
|
float tx = pad, ty = pad;
|
|
|
|
auto txt = [&](ImU32 col, std::string s) {
|
|
dl->AddText(ImVec2(tx, ty), col, s.c_str());
|
|
ty += lineH;
|
|
};
|
|
|
|
const char* name = target->nickname.empty() ? "Player" : target->nickname.c_str();
|
|
txt(IM_COL32(255,255,255,220), std::format("SkelDebug: {} | dist={:.1f}m", name, bestDist));
|
|
txt(IM_COL32(200,200,200,180), std::format("skeleton.valid = {}", target->skeleton.valid ? "true" : "false"));
|
|
|
|
if (!target->skeleton.valid) {
|
|
txt(IM_COL32(255,100,100,200), "No valid skeleton data -- check offsets");
|
|
return;
|
|
}
|
|
|
|
// Named bones with distinct colors — mirrors Spectre stable player bone layout.
|
|
struct BoneDef { const char* name; const Vector3& pos; ImU32 color; };
|
|
const SkeletonBones& sk = target->skeleton;
|
|
const BoneDef bones[] = {
|
|
{ "neck", sk.neck, IM_COL32(255,100, 0,255) },
|
|
{ "head", sk.head, IM_COL32(255, 60, 60,255) },
|
|
{ "spine", sk.spine, IM_COL32(255,165, 0,255) },
|
|
{ "rShoulder", sk.rightShoulder, IM_COL32( 80,255,180,255) },
|
|
{ "rElbow", sk.rightElbow, IM_COL32( 50,220,140,255) },
|
|
{ "rHand", sk.rightHand, IM_COL32( 30,200,120,255) },
|
|
{ "lShoulder", sk.leftShoulder, IM_COL32( 80,200,255,255) },
|
|
{ "lElbow", sk.leftElbow, IM_COL32( 50,160,230,255) },
|
|
{ "lHand", sk.leftHand, IM_COL32( 30,120,255,255) },
|
|
{ "rHip", sk.rightHip, IM_COL32(255,255, 0,255) },
|
|
{ "rKnee", sk.rightKnee, IM_COL32(220,220, 0,255) },
|
|
{ "rAnkle", sk.rightAnkle, IM_COL32(180,180, 0,255) },
|
|
{ "lHip", sk.leftHip, IM_COL32(200,100,255,255) },
|
|
{ "lKnee", sk.leftKnee, IM_COL32(170, 80,230,255) },
|
|
{ "lAnkle", sk.leftAnkle, IM_COL32(140, 60,200,255) },
|
|
};
|
|
|
|
int projected = 0;
|
|
for (const auto& b : bones) {
|
|
float sx{}, sy{};
|
|
bool onScreen = Proj(cam, b.pos, sx, sy, w, h);
|
|
|
|
txt(b.color, std::format(" {} ({:.1f},{:.1f},{:.1f}) {}",
|
|
b.name, b.pos.x, b.pos.y, b.pos.z,
|
|
onScreen ? "" : "[off]"));
|
|
|
|
if (!onScreen) continue;
|
|
++projected;
|
|
|
|
dl->AddCircleFilled(ImVec2(sx, sy), 4.0f, b.color);
|
|
dl->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(0,0,0,200), 0, 1.5f);
|
|
dl->AddText(ImVec2(sx + 7.0f, sy - 6.0f), b.color, b.name);
|
|
}
|
|
|
|
txt(IM_COL32(180,255,180,200), std::format(" {}/17 bones on screen", projected));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Players — red bounding box + name + held item + distance
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
|
|
|
for (const auto& p : u.players) {
|
|
if (p.isDead && !m_showCorpses) continue;
|
|
if (!p.position.has_value()) continue;
|
|
|
|
// Skip the local player by address — more reliable than proximity check.
|
|
if (u.localPlayerEntityAddress.has_value()
|
|
&& p.address == *u.localPlayerEntityAddress) continue;
|
|
|
|
const Vector3& pos = *p.position;
|
|
float dist = Dist(cam.translation, pos);
|
|
if (dist > m_playerMaxDist) continue;
|
|
|
|
if (!IsFacingCamera(cam, pos)) continue;
|
|
|
|
const ImU32 kColor = p.isAdmin ? IM_COL32(255, 200, 0, 255) // gold
|
|
: p.isDead ? IM_COL32(120, 120, 120, 160) // grey
|
|
: IM_COL32(255, 60, 60, 220); // red
|
|
|
|
// ---- Smoothed skeleton (extrapolated between DMA reads) ----
|
|
SkeletonBones smoothBones{};
|
|
bool hasSmoothBones = false;
|
|
if (!p.isDead) {
|
|
auto hit = m_playerBoneHistory.find(p.address);
|
|
if (hit != m_playerBoneHistory.end() && hit->second.initialized
|
|
&& (m_frameTimeMs - hit->second.currMs) < 500LL) {
|
|
smoothBones = GetSmoothedBones(hit->second, m_frameTimeMs);
|
|
hasSmoothBones = true;
|
|
}
|
|
}
|
|
|
|
// ---- Bounding box ----
|
|
// When the skeleton is valid, derive the box from the projected extents of
|
|
// all 16 bones. This keeps the box perfectly in sync with the skeleton
|
|
// (both come from the bone-scatter data, updated at ~60 Hz) instead of
|
|
// lagging one update behind because it uses the entity position (50 Hz).
|
|
float bx0 = 0.0f, by0 = 0.0f, bx1 = 0.0f, by1 = 0.0f;
|
|
bool boxReady = false;
|
|
|
|
if (hasSmoothBones) {
|
|
const Vector3* const kBones[] = {
|
|
&smoothBones.neck, &smoothBones.head,
|
|
&smoothBones.spine, &smoothBones.pelvis,
|
|
&smoothBones.rightShoulder, &smoothBones.rightElbow, &smoothBones.rightHand,
|
|
&smoothBones.leftShoulder, &smoothBones.leftElbow, &smoothBones.leftHand,
|
|
&smoothBones.rightHip, &smoothBones.rightKnee, &smoothBones.rightAnkle,
|
|
&smoothBones.leftHip, &smoothBones.leftKnee, &smoothBones.leftAnkle,
|
|
};
|
|
|
|
float minX = 1e9f, maxX = -1e9f;
|
|
float minY = 1e9f, maxY = -1e9f;
|
|
int hits = 0;
|
|
for (const auto* bp : kBones) {
|
|
// Reject bones whose world position is > 3 m from the entity root.
|
|
// Garbage reads (zero-initialised bones, failed scatter reads) land
|
|
// at or near world origin and would drag the box hundreds of pixels
|
|
// off to the side of the actual player.
|
|
float dbx = bp->x - pos.x, dby = bp->y - pos.y, dbz = bp->z - pos.z;
|
|
if (dbx*dbx + dby*dby + dbz*dbz > 9.0f) continue;
|
|
|
|
float bsx{}, bsy{};
|
|
if (!Proj(cam, *bp, bsx, bsy, w, h)) continue;
|
|
if (bsx < minX) minX = bsx;
|
|
if (bsx > maxX) maxX = bsx;
|
|
if (bsy < minY) minY = bsy;
|
|
if (bsy > maxY) maxY = bsy;
|
|
++hits;
|
|
}
|
|
|
|
if (hits >= 4) {
|
|
constexpr float kPad = 4.0f;
|
|
bx0 = minX - kPad;
|
|
by0 = minY - kPad;
|
|
bx1 = maxX + kPad;
|
|
by1 = maxY + kPad;
|
|
boxReady = true;
|
|
}
|
|
}
|
|
|
|
if (!boxReady) {
|
|
// Fallback: entity ground position + hardcoded 1.8 m head.
|
|
// Used for corpses (no skeleton) and when bones haven't been read yet.
|
|
float fx{}, fy{}, hx{}, hy{};
|
|
if (!Proj(cam, pos, fx, fy, w, h)) continue;
|
|
Vector3 headPos{ pos.x, pos.y + 1.8f, pos.z };
|
|
if (!Proj(cam, headPos, hx, hy, w, h)) continue;
|
|
float bH = fy - hy;
|
|
if (bH < 2.0f) continue;
|
|
float bW = bH * 0.4f;
|
|
bx0 = hx - bW * 0.5f;
|
|
by0 = hy;
|
|
bx1 = hx + bW * 0.5f;
|
|
by1 = fy;
|
|
}
|
|
|
|
// ---- Head circle (computed before the box so by0 can be adjusted) ----
|
|
float hbx{}, hby{};
|
|
float headRadius = 0.0f;
|
|
bool hasHeadCircle = false;
|
|
|
|
if (m_showHeadDot && !p.isDead && hasSmoothBones) {
|
|
if (Proj(cam, smoothBones.head, hbx, hby, w, h)) {
|
|
// Radius = 15% of the rendered body height so the circle scales
|
|
// naturally with distance. The head-neck pixel-distance approach
|
|
// collapses to minimum past ~30 m; box-height fraction stays
|
|
// proportional to the target at any range.
|
|
headRadius = std::clamp((by1 - by0) * 0.15f, 2.0f, 18.0f);
|
|
hasHeadCircle = true;
|
|
by0 = std::min(by0, hby - headRadius - 2.0f);
|
|
}
|
|
}
|
|
|
|
if (by1 - by0 < 2.0f) continue;
|
|
|
|
if (m_showBox)
|
|
dl->AddRect(ImVec2(bx0, by0), ImVec2(bx1, by1), kColor, 0.0f, 0, 1.5f);
|
|
|
|
// ---- Skeleton + head circle (use extrapolated bones) ----
|
|
if (!p.isDead && hasSmoothBones) {
|
|
if (m_showSkeleton)
|
|
DrawSkeleton(dl, smoothBones, cam, w, h, kColor, false);
|
|
|
|
if (hasHeadCircle) {
|
|
dl->AddCircleFilled(ImVec2(hbx, hby), headRadius, IM_COL32(0, 0, 0, 80));
|
|
dl->AddCircle(ImVec2(hbx, hby), headRadius, kColor, 0, 1.5f);
|
|
}
|
|
}
|
|
|
|
// ---- Name + distance label (top-right of box) ----
|
|
char lblBuf[256];
|
|
const char* nick = p.nickname.empty() ? "Player" : p.nickname.c_str();
|
|
if (p.isAdmin && p.isDead)
|
|
snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [DEAD] [%.0fm]", nick, dist);
|
|
else if (p.isAdmin)
|
|
snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [%.0fm]", nick, dist);
|
|
else if (p.isDead)
|
|
snprintf(lblBuf, sizeof(lblBuf), "%s [DEAD] [%.0fm]", nick, dist);
|
|
else
|
|
snprintf(lblBuf, sizeof(lblBuf), "%s [%.0fm]", nick, dist);
|
|
dl->AddText(ImVec2(bx1 + 3.0f, by0), kColor, lblBuf);
|
|
|
|
// ---- Right-side extras (weapon, health bar, health number) ----
|
|
const float kBarW = 4.0f;
|
|
const float kBarGap = 5.0f;
|
|
const float barX = bx1 + kBarGap;
|
|
const float boxH = by1 - by0;
|
|
|
|
if (m_showWeapon && !p.isDead && !p.itemInHands.empty()) {
|
|
constexpr ImU32 kWeaponColor = IM_COL32(220, 220, 100, 200);
|
|
dl->AddText(ImVec2(barX, by0 + 13.0f), kWeaponColor, p.itemInHands.c_str());
|
|
}
|
|
|
|
const bool hasHealth = (p.health >= 0.0f);
|
|
if ((m_showHealthBar || m_showHealthNumber) && !p.isDead && hasHealth) {
|
|
const float hpFrac = std::min(p.health / 100.0f, 1.0f);
|
|
|
|
if (m_showHealthBar) {
|
|
dl->AddRectFilled(
|
|
ImVec2(barX, by0),
|
|
ImVec2(barX + kBarW, by1),
|
|
IM_COL32(0, 0, 0, 140));
|
|
|
|
float r = hpFrac < 0.5f ? 1.0f : 2.0f * (1.0f - hpFrac);
|
|
float g = hpFrac > 0.5f ? 1.0f : 2.0f * hpFrac;
|
|
ImU32 hpColor = IM_COL32(
|
|
static_cast<int>(r * 220),
|
|
static_cast<int>(g * 220),
|
|
0, 220);
|
|
|
|
float filledTop = by0 + boxH * (1.0f - hpFrac);
|
|
dl->AddRectFilled(
|
|
ImVec2(barX, filledTop),
|
|
ImVec2(barX + kBarW, by1),
|
|
hpColor);
|
|
}
|
|
|
|
if (m_showHealthNumber) {
|
|
char hpBuf[32];
|
|
snprintf(hpBuf, sizeof(hpBuf), "%.0f/100", p.health);
|
|
float numX = m_showHealthBar ? barX + kBarW + 2.0f : barX;
|
|
dl->AddText(ImVec2(numX, by1 - 12.0f),
|
|
IM_COL32(200, 200, 200, 200), hpBuf);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Animals — green label
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::DrawAnimals(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
|
constexpr ImU32 kGreen = IM_COL32(50, 230, 50, 200);
|
|
|
|
for (const auto& a : u.animals) {
|
|
if (!a.position.has_value()) continue;
|
|
float dist = Dist(cam.translation, *a.position);
|
|
if (dist > m_animalMaxDist) continue;
|
|
if (!IsFacingCamera(cam, *a.position)) continue;
|
|
|
|
float sx{}, sy{};
|
|
if (!Proj(cam, *a.position, sx, sy, w, h)) continue;
|
|
|
|
std::string raw = a.entityName.empty() ? a.typeName : a.entityName;
|
|
std::string fmtName = raw.empty() ? "Animal" : FormatEntityName(raw);
|
|
char abuf[256];
|
|
snprintf(abuf, sizeof(abuf), "%s [%.0fm]", fmtName.c_str(), dist);
|
|
dl->AddText(ImVec2(sx, sy), kGreen, abuf);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Zombies — yellow label
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::DrawZombies(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
|
constexpr ImU32 kYellow = IM_COL32(230, 230, 50, 180);
|
|
|
|
for (const auto& z : u.zombies) {
|
|
// Resolve position: use fresh read if available, fall back to cache.
|
|
Vector3 pos;
|
|
if (z.position.has_value()) {
|
|
pos = *z.position;
|
|
m_zombieLastPos[z.address] = pos;
|
|
} else {
|
|
auto it = m_zombieLastPos.find(z.address);
|
|
if (it == m_zombieLastPos.end()) continue;
|
|
pos = it->second;
|
|
}
|
|
|
|
float dist = Dist(cam.translation, pos);
|
|
if (dist > m_zombieMaxDist) continue;
|
|
if (!IsFacingCamera(cam, pos)) continue;
|
|
|
|
float sx{}, sy{};
|
|
if (!Proj(cam, pos, sx, sy, w, h)) continue;
|
|
|
|
auto zhit = m_zombieBoneHistory.find(z.address);
|
|
if (zhit != m_zombieBoneHistory.end() && zhit->second.initialized
|
|
&& (m_frameTimeMs - zhit->second.currMs) < 500LL) {
|
|
SkeletonBones zsm = GetSmoothedBones(zhit->second, m_frameTimeMs);
|
|
if (m_showSkeleton)
|
|
DrawSkeleton(dl, zsm, cam, w, h, kYellow, true);
|
|
if (m_showHeadDot) {
|
|
float hbx{}, hby{};
|
|
if (Proj(cam, zsm.head, hbx, hby, w, h)) {
|
|
// Angular size of a ~25 cm head at the zombie's actual distance.
|
|
// Scales from ~18px at 5m down to ~3px at 50m; clamp keeps it
|
|
// always visible and never massive.
|
|
const float projD2y = std::max(cam.projectionD2y, 0.1f);
|
|
float zr = std::clamp((0.25f * h * 0.5f) / (dist * projD2y), 2.0f, 18.0f);
|
|
dl->AddCircleFilled(ImVec2(hbx, hby), zr, IM_COL32(0, 0, 0, 80));
|
|
dl->AddCircle(ImVec2(hbx, hby), zr, kYellow, 0, 1.5f);
|
|
}
|
|
}
|
|
}
|
|
|
|
char zbuf[32];
|
|
snprintf(zbuf, sizeof(zbuf), "Z [%.0fm]", dist);
|
|
dl->AddText(ImVec2(sx, sy), kYellow, zbuf);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Items — white label
|
|
// -------------------------------------------------------------------------
|
|
|
|
void GameOverlay::DrawItems(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
|
constexpr ImU32 kWhite = IM_COL32(255, 255, 255, 200);
|
|
const float maxDistSq = m_itemMaxDist * m_itemMaxDist;
|
|
|
|
for (const auto& item : u.items) {
|
|
// Per-category filter (missing key = enabled)
|
|
if (!item.filterKey.empty()) {
|
|
auto it = m_itemCategories.find(item.filterKey);
|
|
if (it != m_itemCategories.end() && !it->second) continue;
|
|
}
|
|
|
|
if (!item.position.has_value()) continue;
|
|
|
|
// Squared-distance cull avoids sqrtf for every item.
|
|
float dx = item.position->x - cam.translation.x;
|
|
float dy = item.position->y - cam.translation.y;
|
|
float dz = item.position->z - cam.translation.z;
|
|
float distSq = dx*dx + dy*dy + dz*dz;
|
|
if (distSq > maxDistSq) continue;
|
|
|
|
if (!IsFacingCamera(cam, *item.position)) continue;
|
|
|
|
float sx{}, sy{};
|
|
if (!Proj(cam, *item.position, sx, sy, w, h)) continue;
|
|
|
|
const std::string& name = item.cleanName.empty()
|
|
? item.entityName
|
|
: item.cleanName;
|
|
if (name.empty()) continue;
|
|
|
|
float dist = std::sqrtf(distSq); // sqrt only for items that will actually be drawn
|
|
|
|
// Two-tier font: larger close up, smaller at range.
|
|
char ibuf[256];
|
|
snprintf(ibuf, sizeof(ibuf), "%s [%.0fm]", name.c_str(), dist);
|
|
ImFont* font = (dist < 50.0f) ? m_fontLootClose : m_fontLootFar;
|
|
if (font) ImGui::PushFont(font);
|
|
dl->AddText(ImVec2(sx, sy), kWhite, ibuf);
|
|
if (font) ImGui::PopFont();
|
|
}
|
|
}
|