From 4f5df4d1c960ac780f0c03d3b487454c22bf4e2d Mon Sep 17 00:00:00 2001 From: 67 <67@67.sigma> Date: Mon, 22 Jun 2026 16:15:09 +0800 Subject: [PATCH] =?UTF-8?q?WIP:=20Web=20radar=20implementation=20in=20prog?= =?UTF-8?q?ress=20=E2=80=94=20currently=20broken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web radar component is undergoing refactoring and is non-functional. Core memory reading and overlay systems remain operational. --- .gitignore | 18 + CMakeLists.txt | 25 +- TODO.md | 10 +- external/lumin/framework/gui.cpp | 82 +- src/Config.cpp | 6 + src/Config.h | 8 +- src/EmbeddedMaps.h | 5 + src/Memory/VmmAccessor.cpp | 43 + src/Memory/VmmAccessor.h | 11 + src/Offsets.h | 43 +- src/Overlay/GameOverlay.cpp | 1267 +++++++------- src/Overlay/GameOverlay.h | 198 +-- src/Overlay/MenuBridge.h | 79 +- src/Runtime/DayZRuntimeService.cpp | 112 +- src/Runtime/DayZRuntimeService.h | 16 + src/Web/MapTileService.cpp | 7 +- src/Web/MapTileService.h | 5 + src/Web/WebRadarServer.cpp | 147 ++ src/Web/WebRadarServer.h | 11 + src/Web/WebSnapshotService.cpp | 70 + src/main.cpp | 23 +- webroot/app.js | 2466 ++++++++++++++++++---------- webroot/index.html | 333 ++-- webroot/style.css | 1501 +++++++++++------ 24 files changed, 3991 insertions(+), 2495 deletions(-) diff --git a/.gitignore b/.gitignore index 626d4a7..f82c1b5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,21 @@ out/ # Keep these specific files tracked !README.md +!FORMATS.md +!CLAUDE.md + +# 3D map data directories (user-supplied / generated) +data/raw/ +data/processed/ + +# WASM engine build +engine/build/ +engine/_deps/ + +# Python dataprep +dataprep/__pycache__/ +dataprep/*.egg-info/ +dataprep/dayzmap/__pycache__/ +.venv/ +node_modules/ +package-lock.json diff --git a/CMakeLists.txt b/CMakeLists.txt index d64483c..7e65061 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,9 +34,8 @@ FetchContent_Declare(spdlog # cpp-httplib — header-only HTTP/1.1 server used by WebRadarServer FetchContent_Declare(httplib - GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git - GIT_TAG v0.18.1 - GIT_SHALLOW TRUE + URL https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.18.1.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) # stb — single-file image load/write libraries @@ -88,6 +87,7 @@ file(GLOB_RECURSE PROJECT_SOURCES "src/*.cpp") # Explicit list ensures new files are picked up without a CMake cache reset. list(APPEND PROJECT_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/SigScanner/SigScanner.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/BoneInterpolator.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/OverlayWindow.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/GameOverlay.cpp" ) @@ -109,13 +109,15 @@ set(VOLKDMA_SOURCES # # Re-run CMake if you add new PNGs to maps/ after the initial configure. # ------------------------------------------------------------------------- -set(MAPS_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/maps") +set(MAPS_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/maps") set(EMBEDDED_MAPS_RC "${CMAKE_CURRENT_BINARY_DIR}/maps_embedded.rc") set(EMBEDDED_MAPS_CPP "${CMAKE_CURRENT_BINARY_DIR}/EmbeddedMaps.cpp") set(MAP_IDS chernarusplus livonia namalsk banov deadfall deerisle esseker lux sakhal takistan alteria) set(MAP_RIDS 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011) +# ── Base map RCDATA embedding ───────────────────────────────────────────────── + set(RC_LINES "") set(CPP_CASES "") foreach(MAP_ID MAP_RID IN ZIP_LISTS MAP_IDS MAP_RIDS) @@ -128,9 +130,9 @@ foreach(MAP_ID MAP_RID IN ZIP_LISTS MAP_IDS MAP_RIDS) endif() endforeach() -file(WRITE "${EMBEDDED_MAPS_RC}" "// Auto-generated by CMake — do not edit.\n${RC_LINES}") +file(WRITE "${EMBEDDED_MAPS_RC}" "// Auto-generated by CMake - do not edit.\n${RC_LINES}") file(WRITE "${EMBEDDED_MAPS_CPP}" -"// Auto-generated by CMake — do not edit. +"// Auto-generated by CMake - do not edit. #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif @@ -284,6 +286,17 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/maps") ) endif() +# Copy data/processed/ next to the executable so the 3D HTTP routes can serve it. +# Only copy when the directory exists (requires running: py -m dayzmap fakedata). +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data/processed") + add_custom_command(TARGET dayz-memory-cpp POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/data/processed" + "$/data/processed" + COMMENT "Copying processed map data to output directory" + ) +endif() + # ------------------------------------------------------------------------- # Compiler definitions # ------------------------------------------------------------------------- diff --git a/TODO.md b/TODO.md index 6fe6cad..5686770 100644 --- a/TODO.md +++ b/TODO.md @@ -10,4 +10,12 @@ ## Before P2C - change config to use custom pkl format maybe? then apply base 85 encoding after maybe? -- make backend for p2c? include keyauth db hwid and do drm \ No newline at end of file +- make backend for p2c? include keyauth db hwid and do drm + +## Web Radar UI Overhaul +- Redesign filter panel: collapsible sections, icon-based entity toggles +- Entity list sidebar with live counts and click-to-focus +- Player detail popover on marker click (worn items, health, distance) +- Minimap inset (fixed position, independent zoom level) +- Keyboard shortcuts: G=grid, T=topo, C=combat, P=POIs +- Route/waypoint drawing tool (manual overlay path) \ No newline at end of file diff --git a/external/lumin/framework/gui.cpp b/external/lumin/framework/gui.cpp index 850100b..c9c69e9 100644 --- a/external/lumin/framework/gui.cpp +++ b/external/lumin/framework/gui.cpp @@ -224,23 +224,26 @@ static void lumin_kv(const char* label, const std::string& value, const c_vec4& static void render_aim_tab(const visual_widget_filter& w, float section_height) { - if (!g_menu) + if (!g_menu || !g_menu->cfg) return; begin_full_section("Aim Assistance", section_height); { + lumin_heading("Combat Mode"); + w.checkbox("Combat Mode", "Hides loot and animals; shortens bullet trail fade for cleaner combat view", &g_menu->cfg->combatMode); + gui->dummy(c_vec2(0, s_(6))); lumin_heading("Prediction"); - w.checkbox("Ballistic Dot", "Cyan dot showing where to aim to hit centre mass (accounts for gravity + drag)", g_menu->showBallisticDot); + w.checkbox("Ballistic Dot", "Cyan dot showing where to aim to hit centre mass (accounts for gravity + drag)", &g_menu->cfg->showBallisticDot); gui->dummy(c_vec2(0, s_(6))); lumin_heading("Trails"); - w.checkbox("Bullet Trails", "Draw flight path of each bullet from origin to impact or despawn", g_menu->showBulletTrails); + w.checkbox("Bullet Trails", "Draw flight path of each bullet from origin to impact or despawn", &g_menu->cfg->showBulletTrails); } end_visual_section(); } static void render_visuals_tab(const visual_widget_filter& w, float section_height) { - if (!g_menu) + if (!g_menu || !g_menu->cfg) return; if (var->gui.sub_tab_stored == 1) @@ -249,14 +252,14 @@ static void render_visuals_tab(const visual_widget_filter& w, float section_heig gui->begin_group(); { begin_visual_section("Players", section_height); - w.checkbox("Show Players", "Show player ESP", g_menu->showPlayers); - w.checkbox("Bounding Box", "Draw entity box", g_menu->showBox); - w.checkbox("Skeleton", "Draw bone skeleton", g_menu->showSkeleton); - w.checkbox("Head Circle", "Draw head highlight", g_menu->showHeadDot); - w.checkbox("Weapon In Hand", "Show held weapon name", g_menu->showWeapon); - w.checkbox("Health Bar", "Draw player health bar", g_menu->showHealthBar); - w.checkbox("Health Number", "Draw numeric health", g_menu->showHealthNumber); - w.checkbox("Corpses", "Show dead bodies", g_menu->showCorpses); + w.checkbox("Show Players", "Show player ESP", &g_menu->cfg->showPlayers); + w.checkbox("Bounding Box", "Draw entity box", &g_menu->cfg->showBox); + w.checkbox("Skeleton", "Draw bone skeleton", &g_menu->cfg->showSkeleton); + w.checkbox("Head Circle", "Draw head highlight", &g_menu->cfg->showHeadDot); + w.checkbox("Weapon In Hand", "Show held weapon name", &g_menu->cfg->showWeapon); + w.checkbox("Health Bar", "Draw player health bar", &g_menu->cfg->showHealthBar); + w.checkbox("Health Number", "Draw numeric health", &g_menu->cfg->showHealthNumber); + w.checkbox("Corpses", "Show dead bodies", &g_menu->cfg->showCorpses); w.checkbox("Skeleton Debug", "Label every named bone", g_menu->debugSkeleton); end_visual_section(); } @@ -267,7 +270,7 @@ static void render_visuals_tab(const visual_widget_filter& w, float section_heig gui->begin_group(); { begin_visual_section("Draw Distance", section_height); - w.slider("Players", "Max player draw distance", g_menu->playerMaxDist, 50.f, 1000.f, "%.0f m"); + w.slider("Players", "Max player draw distance", &g_menu->cfg->playerMaxDist, 50.f, 1000.f, "%.0f m"); end_visual_section(); } gui->end_group(); @@ -278,9 +281,14 @@ static void render_visuals_tab(const visual_widget_filter& w, float section_heig gui->begin_group(); { begin_visual_section("Entities", section_height); - w.checkbox("Animals", "Show animal ESP", g_menu->showAnimals); - w.checkbox("Zombies", "Show infected ESP", g_menu->showZombies); - w.checkbox("Items", "Show loot ESP", g_menu->showItems); + // Animals and Items are suppressed in combat mode; grey them out. + ImGui::BeginDisabled(g_menu->cfg->combatMode); + w.checkbox("Animals", "Show animal ESP", &g_menu->cfg->showAnimals); + ImGui::EndDisabled(); + w.checkbox("Zombies", "Show infected ESP", &g_menu->cfg->showZombies); + ImGui::BeginDisabled(g_menu->cfg->combatMode); + w.checkbox("Items", "Show loot ESP", &g_menu->cfg->showItems); + ImGui::EndDisabled(); end_visual_section(); } gui->end_group(); @@ -290,9 +298,13 @@ static void render_visuals_tab(const visual_widget_filter& w, float section_heig gui->begin_group(); { begin_visual_section("Draw Distance", section_height); - w.slider("Animals", "Max animal draw distance", g_menu->animalMaxDist, 50.f, 1000.f, "%.0f m"); - w.slider("Zombies", "Max zombie draw distance", g_menu->zombieMaxDist, 50.f, 500.f, "%.0f m"); - w.slider("Items", "Max loot draw distance", g_menu->itemMaxDist, 20.f, 200.f, "%.0f m"); + ImGui::BeginDisabled(g_menu->cfg->combatMode); + w.slider("Animals", "Max animal draw distance", &g_menu->cfg->animalMaxDist, 50.f, 1000.f, "%.0f m"); + ImGui::EndDisabled(); + w.slider("Zombies", "Max zombie draw distance", &g_menu->cfg->zombieMaxDist, 50.f, 500.f, "%.0f m"); + ImGui::BeginDisabled(g_menu->cfg->combatMode); + w.slider("Items", "Max loot draw distance", &g_menu->cfg->itemMaxDist, 20.f, 200.f, "%.0f m"); + ImGui::EndDisabled(); end_visual_section(); } gui->end_group(); @@ -301,7 +313,7 @@ static void render_visuals_tab(const visual_widget_filter& w, float section_heig static void render_loot_tab(const visual_widget_filter& w, float section_height) { - if (!g_menu || !g_menu->itemCategories) + if (!g_menu || !g_menu->cfg) return; struct cat_t { const char* key; const char* label; }; @@ -325,7 +337,9 @@ static void render_loot_tab(const visual_widget_filter& w, float section_height) { "isOtherLoot", "Other" }, } }; - auto& cats = *g_menu->itemCategories; + // Loot categories are irrelevant when combat mode suppresses all loot. + ImGui::BeginDisabled(g_menu->cfg->combatMode); + auto& cats = g_menu->cfg->itemCategories; auto draw_category = [&](const cat_t& c) { @@ -357,6 +371,8 @@ static void render_loot_tab(const visual_widget_filter& w, float section_height) end_visual_section(); } gui->end_group(); + + ImGui::EndDisabled(); } static constexpr const char* kRadarDomain = "radar.charliecharliekirky.christmas"; @@ -438,20 +454,20 @@ static void render_info_tab(float section_height) static void render_config_tab(float section_height) { - if (!g_menu) + if (!g_menu || !g_menu->cfg) return; // Persistent text buffers backing the four resolution fields. Seeded once - // from the live values, then treated as the source of truth (parsed back - // into the int pointers every frame). + // from the live cfg values; parsed back into cfg every frame so Apply picks + // up whatever the user typed. static char ovr_w[8], ovr_h[8], rnd_w[8], rnd_h[8]; static bool buffers_ready = false; if (!buffers_ready) { - ImFormatString(ovr_w, IM_ARRAYSIZE(ovr_w), "%d", *g_menu->pendingW); - ImFormatString(ovr_h, IM_ARRAYSIZE(ovr_h), "%d", *g_menu->pendingH); - ImFormatString(rnd_w, IM_ARRAYSIZE(rnd_w), "%d", *g_menu->pendingRW); - ImFormatString(rnd_h, IM_ARRAYSIZE(rnd_h), "%d", *g_menu->pendingRH); + ImFormatString(ovr_w, IM_ARRAYSIZE(ovr_w), "%d", g_menu->cfg->overlayWidth); + ImFormatString(ovr_h, IM_ARRAYSIZE(ovr_h), "%d", g_menu->cfg->overlayHeight); + ImFormatString(rnd_w, IM_ARRAYSIZE(rnd_w), "%d", g_menu->cfg->renderWidth); + ImFormatString(rnd_h, IM_ARRAYSIZE(rnd_h), "%d", g_menu->cfg->renderHeight); buffers_ready = true; } @@ -463,8 +479,8 @@ static void render_config_tab(float section_height) widgets->text_field("Width", ovr_w, IM_ARRAYSIZE(ovr_w)); widgets->text_field("Height", ovr_h, IM_ARRAYSIZE(ovr_h)); gui->pop_id(); - *g_menu->pendingW = ImMax(0, atoi(ovr_w)); - *g_menu->pendingH = ImMax(0, atoi(ovr_h)); + g_menu->cfg->overlayWidth = ImMax(0, atoi(ovr_w)); + g_menu->cfg->overlayHeight = ImMax(0, atoi(ovr_h)); if (widgets->primary_button("Apply Monitor Resolution") && g_menu->onApplyDisplayRes) g_menu->onApplyDisplayRes(); @@ -476,9 +492,9 @@ static void render_config_tab(float section_height) widgets->text_field("Width", rnd_w, IM_ARRAYSIZE(rnd_w)); widgets->text_field("Height", rnd_h, IM_ARRAYSIZE(rnd_h)); gui->pop_id(); - *g_menu->pendingRW = ImMax(0, atoi(rnd_w)); - *g_menu->pendingRH = ImMax(0, atoi(rnd_h)); - widgets->checkbox("Stretch to fill", "GPU stretches game to fill the monitor", g_menu->stretchToFill); + g_menu->cfg->renderWidth = ImMax(0, atoi(rnd_w)); + g_menu->cfg->renderHeight = ImMax(0, atoi(rnd_h)); + widgets->checkbox("Stretch to fill", "GPU stretches game to fill the monitor", &g_menu->cfg->stretchToFill); if (widgets->primary_button("Apply Render Resolution") && g_menu->onApplyRenderRes) g_menu->onApplyRenderRes(); diff --git a/src/Config.cpp b/src/Config.cpp index 236461f..f016d8d 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -38,6 +38,9 @@ OverlayConfig OverlayConfig::Load(const std::string& path) { if (j.contains("showWeapon")) cfg.showWeapon = j["showWeapon"].get(); if (j.contains("showHealthBar")) cfg.showHealthBar = j["showHealthBar"].get(); if (j.contains("showHealthNumber")) cfg.showHealthNumber = j["showHealthNumber"].get(); + if (j.contains("showBallisticDot")) cfg.showBallisticDot = j["showBallisticDot"].get(); + if (j.contains("showBulletTrails")) cfg.showBulletTrails = j["showBulletTrails"].get(); + if (j.contains("combatMode")) cfg.combatMode = j["combatMode"].get(); // Distances if (j.contains("playerMaxDist")) cfg.playerMaxDist = j["playerMaxDist"].get(); @@ -97,6 +100,9 @@ void OverlayConfig::Save(const std::string& path) const { j["showWeapon"] = showWeapon; j["showHealthBar"] = showHealthBar; j["showHealthNumber"] = showHealthNumber; + j["showBallisticDot"] = showBallisticDot; + j["showBulletTrails"] = showBulletTrails; + j["combatMode"] = combatMode; // Distances j["playerMaxDist"] = playerMaxDist; diff --git a/src/Config.h b/src/Config.h index e26fb00..4bb4a6e 100644 --- a/src/Config.h +++ b/src/Config.h @@ -22,8 +22,12 @@ struct OverlayConfig { bool showWeapon = true; // item-in-hands label below player name bool showHealthBar = true; // vertical health bar to the right of the box bool showHealthNumber = false; // numeric "xxx/100" beside the bar - bool showBallisticDot = false; // cyan dot showing where to aim for center-mass hit - bool showBulletTrails = false; // draws traced path of each bullet in flight + bool showBallisticDot = true; // cyan dot showing where to aim for center-mass hit + bool showBulletTrails = true; // draws traced path of each bullet in flight + + // ---- Combat mode ---- + // When on: animals and items are suppressed; bullet trail fade is shortened. + bool combatMode = false; // ---- Draw-distance limits (metres) ---- float playerMaxDist = 1000.0f; diff --git a/src/EmbeddedMaps.h b/src/EmbeddedMaps.h index 81a2309..c275cb1 100644 --- a/src/EmbeddedMaps.h +++ b/src/EmbeddedMaps.h @@ -9,3 +9,8 @@ // — it points into the exe's memory-mapped image, so no copy is made. // Returns {nullptr, 0} if the map was not compiled into the binary. std::pair GetEmbeddedMap(const std::string& mapId); + +// Topo maps are not embedded — served from maps/topo/.png on disk only. +inline std::pair GetEmbeddedTopoMap(const std::string&) { + return {nullptr, 0}; +} diff --git a/src/Memory/VmmAccessor.cpp b/src/Memory/VmmAccessor.cpp index 5cd6635..afde46e 100644 --- a/src/Memory/VmmAccessor.cpp +++ b/src/Memory/VmmAccessor.cpp @@ -325,6 +325,49 @@ DmaStats VmmAccessor::GetStats() const { return s; } +// ------------------------------------------------------------------------- +// ReadKernelBytes +// ------------------------------------------------------------------------- + +bool VmmAccessor::ReadKernelBytes(uint64_t address, void* buf, size_t size) { + if (!buf || size == 0 || !IsInitialized()) return false; + std::shared_lock lk(m_accessMutex); + if (!m_dma || !m_dma->is_initialized()) return false; + + // win32kbase.sys lives in session space, not global kernel space. + // Reading through PID 4 (System/Session 0) won't translate session-space VAs + // because the System process has no session mapping for it. + // Use the attached game-process PID (same Windows session as the user desktop) + // so the page-table walk goes through the right session CR3. + // Fall back to PID 4 if no process is attached yet. + DWORD readPid = (m_attachedPid != 0) ? static_cast(m_attachedPid) : 4; + + BOOL ok = VMMDLL_MemRead(m_dma->handle.get(), + readPid, + address, + static_cast(buf), + static_cast(size)); + if (ok) m_bytesReadTotal.fetch_add(size, std::memory_order_relaxed); + return ok == TRUE; +} + +// ------------------------------------------------------------------------- +// TryGetKernelModuleBase +// ------------------------------------------------------------------------- + +bool VmmAccessor::TryGetKernelModuleBase(const std::string& moduleName, + uint64_t& outBase) { + if (!IsInitialized()) return false; + std::shared_lock lk(m_accessMutex); + if (!m_dma || !m_dma->is_initialized()) return false; + uint64_t base = VMMDLL_ProcessGetModuleBaseU(m_dma->handle.get(), + 4 /*System PID*/, + moduleName.c_str()); + if (!base) return false; + outBase = base; + return true; +} + std::string VmmAccessor::ReadCString(uint32_t pid, uint64_t address, size_t maxLength) { if (!MemoryValidation::IsValidUserAddress(address)) return {}; diff --git a/src/Memory/VmmAccessor.h b/src/Memory/VmmAccessor.h index c5d0dd2..63cb41c 100644 --- a/src/Memory/VmmAccessor.h +++ b/src/Memory/VmmAccessor.h @@ -119,6 +119,17 @@ public: std::string ReadCString(uint32_t pid, uint64_t address, size_t maxLength = 256); + // ------------------------------------------------------------------ + // Kernel reads — read from game PC kernel address space (PID 4). + // Used by KeyStateReader to access win32kbase.sys global state. + // ------------------------------------------------------------------ + + /// Read bytes from the game PC's kernel virtual address space. + bool ReadKernelBytes(uint64_t address, void* buf, size_t size); + + /// Look up a kernel module base address by name (e.g. "win32kbase.sys"). + bool TryGetKernelModuleBase(const std::string& moduleName, uint64_t& outBase); + // ------------------------------------------------------------------ // State // ------------------------------------------------------------------ diff --git a/src/Offsets.h b/src/Offsets.h index 03884ff..7637c43 100644 --- a/src/Offsets.h +++ b/src/Offsets.h @@ -128,21 +128,16 @@ namespace Offsets { } // Weapon entity struct offsets (entity is already an InventoryItem with a type ptr at +0x180). - // Chain to magazine: entity+0x1B0 (ChamberedPtr) → chambered item → magazine. namespace Weapon { - constexpr uint64_t ChamberedPtr = 0x1B0; // v1.29 [UC dumper r15 2026-06-15] - // Layout (UC dumper r15 2026-06-15, Spectre confirmed): - // 0x6A0: uint64_t MagazinePtr (8-byte ptr, 0x6A0–0x6A7) - // 0x6A8: uint32_t ??? (4-byte field, identity TBD) - // 0x6AC: uint32_t AmmoMagCount (4-byte count, UC confirmed) - // 0x6B0: uint32_t AmmoCapacityA - // 0x6B4: uint32_t AmmoCapacityB - // Prior value was 0x6A8, which overlapped with AmmoMagCount (0x6AC is - // only 4 bytes later, but a uint64_t ptr needs 8 bytes → wrong offset). - constexpr uint64_t MagazinePtr = 0x6A0; // v1.29 — 8-byte ptr to loaded magazine - constexpr uint64_t AmmoMagCount = 0x6AC; // v1.29 [UC dumper r15 2026-06-15] — loaded magazine ammo count - constexpr uint64_t AmmoCapacityA = 0x6B0; // v1.29 [UC dumper r15 2026-06-15] - constexpr uint64_t AmmoCapacityB = 0x6B4; // v1.29 [UC dumper r15 2026-06-15] + // Live-probed 2026-06-20 with MP7A2 + 20rnd mag loaded: + // +0x6A0 = 0x0 (null — unknown field) + // +0x6A8 = 0x8E34DBC90 (valid heap ptr → magazine entity) ← MagazinePtr + // reading uint32 at +0x6AC returns the high 4 bytes of the 0x6A8 ptr, NOT AmmoMagCount + // +0x6B0 = 0x100000001 (two uint32s both 1 — identity unknown) + // The UC dumper r15 note that placed MagazinePtr at 0x6A0 was based on a + // mis-read layout; 0x6A8 is the empirically confirmed correct offset. + // ChamberedPtr at 0x1B0 also probed wrong (returns 0x155, a non-pointer integer). + constexpr uint64_t MagazinePtr = 0x6A8; // v1.29 [live-probed 2026-06-20] } // Magazine entity struct offsets. @@ -195,19 +190,23 @@ namespace Offsets { constexpr uint64_t ZombieOffset = ZombieCand[0]; // Probed candidates: skeleton → animClass pointer (tried in order). - // 0x118 confirmed v1.29 (Spectre stable); 0xA8 confirmed by external source. - constexpr uint64_t AnimCand[4] = { 0x118, 0xA8, 0x98, 0xB0 }; + // 0x110 confirmed v1.29 [IDA sub_1404CF250: *(a1+272), 272=0x110] + // 0x118 previously used; 0xA8 confirmed by external source. + constexpr uint64_t AnimCand[5] = { 0x110, 0x118, 0xA8, 0x98, 0xB0 }; // Probed candidates: animClass → matrixArray pointer (tried in order). // 0xBE8 confirmed v1.29 (Spectre stable); 0xBF0 confirmed by external source. constexpr uint64_t MatrixCand[5] = { 0xBE8, 0xBF0, 0xBD8, 0xB40, 0xB30 }; - // Each bone entry is 48 bytes; translation (x,y,z) starts at byte offset 0x54 - // from the matrixArray base (effective: matrixArray + 0x54 + boneIndex*0x30). - // This gives boneIndex N the same world position as their boneIndex N+1 - // (the +0x54 vs +0x24 difference is exactly one stride). - constexpr uint32_t BoneStride = 48; // v1.29 - constexpr uint32_t BoneTranslationOffset = 0x54; // v1.29 + // Each bone entry is a column-major 3×4 float matrix (48 bytes). + // Translation (x,y,z) is in the 4th column at byte offset 0x24 (floats [9,10,11]). + // Formula: matBase + 0x24 + boneIndex * 0x30 + // IDA confirmed: sub_1402A4F10 returns *(a1+112) + 48*boneIdx; + // sub_1404D9C40 writes translation at result+36 (=0x24) within 48-byte stride. + // Bone indices in DayZRuntimeService::RefreshBonesScatter use the original Enfusion + // PlayerBoneId / InfectedBoneId enum values directly (no -1 adjustment). + constexpr uint32_t BoneStride = 48; // v1.29 [IDA confirmed — 3×4 matrix] + constexpr uint32_t BoneTranslationOffset = 0x24; // v1.29 [IDA confirmed — col3 of 3×4] } } // namespace Offsets diff --git a/src/Overlay/GameOverlay.cpp b/src/Overlay/GameOverlay.cpp index 8c6a53c..b556d86 100644 --- a/src/Overlay/GameOverlay.cpp +++ b/src/Overlay/GameOverlay.cpp @@ -27,11 +27,10 @@ #include "MenuBridge.h" -// Per-frame bridge pointer consumed by the vendored Lumin menu (gui.cpp). MenuBridge* g_menu = nullptr; // ------------------------------------------------------------------------- -// Bone interpolation / extrapolation helpers +// Helpers // ------------------------------------------------------------------------- int64_t GameOverlay::NowMs() { @@ -39,124 +38,11 @@ int64_t GameOverlay::NowMs() { return duration_cast(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 bone read arrives. -// lastReadMs is always updated (drives the staleness gate — prevents blink for -// stationary players). currMs / prevMs are only updated on positional change -// so the extrapolation velocity is derived from actual player movement, not -// every-frame DMA polling. -static void UpdateBoneHistory(GameOverlay::BoneHistory& h, - const SkeletonBones& bones, int64_t nowMs) -{ - h.lastReadMs = nowMs; // always stamp: skeleton visible while reads keep arriving - 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. - 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(nowMs - h.prevMs) / static_cast(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 buf(bufLen); - auto* table = reinterpret_cast(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(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(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; + 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; @@ -167,6 +53,10 @@ static bool IsFacingCamera(const CameraData& cam, const Vector3& pos) { return depth >= 0.65f; } +// ------------------------------------------------------------------------- +// Projection +// ------------------------------------------------------------------------- + bool GameOverlay::WorldToScreen(const CameraData& cam, const Vector3& world, float& sx, float& sy, @@ -188,39 +78,29 @@ bool GameOverlay::WorldToScreen(const CameraData& cam, 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(m_renderW) / static_cast(m_renderH); + if (m_cfg.renderWidth > 0 && m_cfg.renderHeight > 0 && !m_cfg.stretchToFill) { + float renderAr = static_cast(m_cfg.renderWidth) / static_cast(m_cfg.renderHeight); 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; @@ -228,58 +108,145 @@ bool GameOverlay::Proj(const CameraData& cam, const Vector3& worldPos, return sx >= 0.0f && sx <= overlayW && sy >= 0.0f && sy <= overlayH; } +// ------------------------------------------------------------------------- +// ComputeScreenData — project all bones once; compute AABB + head circle. +// ------------------------------------------------------------------------- + +std::optional GameOverlay::ComputeScreenData( + const Vector3& entityPos, + const SkeletonBones* smoothBones, + bool includeHeadCircle, + const CameraData& cam, + float w, float h) const +{ + using Bi = PlayerScreenData::BoneIdx; + PlayerScreenData sd{}; + + if (smoothBones) { + const Vector3* const kWorld[Bi::B_COUNT] = { + &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, + }; + + for (int i = 0; i < Bi::B_COUNT; ++i) + sd.bones[i].ok = Proj(cam, *kWorld[i], sd.bones[i].x, sd.bones[i].y, w, h); + + float minX = 1e9f, maxX = -1e9f, minY = 1e9f, maxY = -1e9f; + int hits = 0; + for (int i = 0; i < Bi::B_COUNT; ++i) { + if (!sd.bones[i].ok) continue; + // Reject bones > 3 m from entity root — garbage reads land near world origin. + const Vector3& bp = *kWorld[i]; + float dbx = bp.x-entityPos.x, dby = bp.y-entityPos.y, dbz = bp.z-entityPos.z; + if (dbx*dbx + dby*dby + dbz*dbz > 9.0f) continue; + if (sd.bones[i].x < minX) minX = sd.bones[i].x; + if (sd.bones[i].x > maxX) maxX = sd.bones[i].x; + if (sd.bones[i].y < minY) minY = sd.bones[i].y; + if (sd.bones[i].y > maxY) maxY = sd.bones[i].y; + ++hits; + } + if (hits >= 4) { + constexpr float kPad = 8.0f; + sd.bx0 = minX - kPad; sd.by0 = minY - kPad; + sd.bx1 = maxX + kPad; sd.by1 = maxY + kPad; + sd.boxReady = true; + } + } + + if (!sd.boxReady) { + // Fallback: project ground position + 1.8 m estimated head. + float fx{}, fy{}, hx{}, hy{}; + if (!Proj(cam, entityPos, fx, fy, w, h)) return std::nullopt; + Vector3 headEst{ entityPos.x, entityPos.y + 1.8f, entityPos.z }; + if (!Proj(cam, headEst, hx, hy, w, h)) return std::nullopt; + float bH = fy - hy; + if (bH < 2.0f) return std::nullopt; + float bW = bH * 0.4f; + sd.bx0 = hx - bW * 0.5f; sd.by0 = hy; + sd.bx1 = hx + bW * 0.5f; sd.by1 = fy; + sd.boxReady = true; + } + + if (sd.by1 - sd.by0 < 2.0f) return std::nullopt; + + // Head circle — always computed when head bone is on screen so callers can + // decide whether to draw it; by0 is only adjusted when requested. + if (smoothBones && sd.bones[Bi::B_Head].ok) { + sd.headRadius = std::clamp((sd.by1 - sd.by0) * 0.128f, 2.0f, 15.0f); + sd.hbx = sd.bones[Bi::B_Head].x; + sd.hby = sd.bones[Bi::B_Head].y; + sd.hasHead = true; + if (includeHeadCircle) + sd.by0 = std::min(sd.by0, sd.hby - sd.headRadius - 2.0f); + } + + return sd; +} + +// ------------------------------------------------------------------------- +// SetWebRadarPort +// ------------------------------------------------------------------------- + +void GameOverlay::SetWebRadarPort(int port) { + m_webPort = port; + m_webUrls.clear(); + m_webUrls.push_back(std::format("http://localhost:{}", port)); + + ULONG bufLen = 15000; + std::vector buf(bufLen); + auto* table = reinterpret_cast(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(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(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)); + } + } + } +} + // ------------------------------------------------------------------------- // 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.showBallisticDot = m_showBallisticDot; - m_cfg.showBulletTrails = m_showBulletTrails; - 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 live bullet positions into the overlay-owned trail tracker. m_bulletTracks.Update(u.bullets, m_frameTimeMs); - // Feed new DMA bone reads into the per-entity history so extrapolation - // always has the two most recent samples to work from. + // Feed new bone reads into the interpolators. + const int64_t boneStampMs = (u.snapshotMs > 0) ? u.snapshotMs : m_frameTimeMs; for (const auto& p : u.players) if (p.skeleton.valid) - UpdateBoneHistory(m_playerBoneHistory[p.address], p.skeleton, m_frameTimeMs); + m_playerBones.Feed(p.address, p.skeleton, boneStampMs); for (const auto& z : u.zombies) if (z.skeleton.valid) - UpdateBoneHistory(m_zombieBoneHistory[z.address], z.skeleton, m_frameTimeMs); + m_zombieBones.Feed(z.address, z.skeleton, boneStampMs); - // 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. + // Prune stale entries for entities no longer in the snapshot. { std::unordered_set livePlayers, liveZombies; livePlayers.reserve(u.players.size()); @@ -287,56 +254,32 @@ void GameOverlay::Draw(float w, float h) { 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); + m_playerBones.Prune(livePlayers); + m_zombieBones.Prune(liveZombies); + + for (auto it = m_playerVel.begin(); it != m_playerVel.end(); ) + it = livePlayers.count(it->first) ? std::next(it) : m_playerVel.erase(it); + for (auto it = m_zombieLastPos.begin(); it != m_zombieLastPos.end(); ) + it = liveZombies.count(it->first) ? std::next(it) : m_zombieLastPos.erase(it); } - // INSERT key toggles the menu - if (GetAsyncKeyState(VK_INSERT) & 1) { + // INSERT key toggles the menu (checked on both the overlay PC and the + // game PC via DMA keyboard state reader). + const bool insertPressed = (GetAsyncKeyState(VK_INSERT) & 1) + || m_service.IsMainKeyPressed(VK_INSERT); + if (insertPressed) { m_menuOpen = !m_menuOpen; m_menuAlpha = m_menuOpen ? 1.0f : 0.0f; - if (!m_menuOpen) { - // Persist settings back to config on close. - SyncConfig(); + if (!m_menuOpen) 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.showBallisticDot = &m_showBallisticDot; - bridge.showBulletTrails = &m_showBulletTrails; + bridge.cfg = &m_cfg; + 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(""); @@ -369,26 +312,17 @@ void GameOverlay::Draw(float w, float h) { } 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); + if (m_resizeCallback) m_resizeCallback(m_cfg.overlayWidth, m_cfg.overlayHeight); }; 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); }; @@ -396,109 +330,454 @@ void GameOverlay::Draw(float w, float h) { RenderLuminMenu(); } - // ---- ESP draw lists — camera read directly from service every frame ---- m_service.GetLatestCamera(m_liveCamera); DrawESP(w, h, u, m_liveCamera); } +// ------------------------------------------------------------------------- +// DrawESP +// ------------------------------------------------------------------------- + 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_showBulletTrails) DrawBulletTrails(dl, cam, w, h); - 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); + + if (m_cfg.showBulletTrails) DrawBulletTrails(dl, cam, w, h); + if (m_cfg.showPlayers) DrawPlayers(dl, u, cam, w, h); + if (m_cfg.showZombies) DrawZombies(dl, u, cam, w, h); + if (!m_cfg.combatMode && m_cfg.showAnimals) DrawAnimals(dl, u, cam, w, h); + if (!m_cfg.combatMode && m_cfg.showItems) DrawItems (dl, u, cam, w, h); + if (m_debugSkeleton) DrawSkeletonDebug(dl, u, cam, w, h); } // ------------------------------------------------------------------------- -// DrawSkeleton — render 11 bone segments for one entity +// DrawSkeleton — draw segments from pre-projected bone screen coords. +// Zero Proj() calls inside; caller pre-computes via ComputeScreenData. // ------------------------------------------------------------------------- -void GameOverlay::DrawSkeleton(ImDrawList* dl, const SkeletonBones& bones, - const CameraData& cam, - float w, float h, unsigned int color, - bool isZombie) const +void GameOverlay::DrawSkeleton(ImDrawList* dl, const PlayerScreenData::SP* bones, + 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); - + using Bi = PlayerScreenData::BoneIdx; 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}, + {Bi::B_Neck,Bi::B_Head}, + {Bi::B_Neck,Bi::B_RShoulder},{Bi::B_RShoulder,Bi::B_RElbow},{Bi::B_RElbow,Bi::B_RHand}, + {Bi::B_Neck,Bi::B_LShoulder},{Bi::B_LShoulder,Bi::B_LElbow},{Bi::B_LElbow,Bi::B_LHand}, + {Bi::B_Neck,Bi::B_Spine}, + {Bi::B_Spine,Bi::B_RHip},{Bi::B_RHip,Bi::B_RKnee},{Bi::B_RKnee,Bi::B_RAnkle}, + {Bi::B_Spine,Bi::B_LHip},{Bi::B_LHip,Bi::B_LKnee},{Bi::B_LKnee,Bi::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}, + {Bi::B_Spine,Bi::B_Neck},{Bi::B_Neck,Bi::B_Head}, + {Bi::B_Spine,Bi::B_LShoulder},{Bi::B_LShoulder,Bi::B_LElbow},{Bi::B_LElbow,Bi::B_LHand}, + {Bi::B_Spine,Bi::B_RShoulder},{Bi::B_RShoulder,Bi::B_RElbow},{Bi::B_RElbow,Bi::B_RHand}, + {Bi::B_Spine,Bi::B_Pelvis}, + {Bi::B_Pelvis,Bi::B_RHip},{Bi::B_RHip,Bi::B_RKnee},{Bi::B_RKnee,Bi::B_RAnkle}, + {Bi::B_Pelvis,Bi::B_LHip},{Bi::B_LHip,Bi::B_LKnee},{Bi::B_LKnee,Bi::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]; + const auto& a = bones[segs[i].a]; + const auto& b = bones[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 +// Per-player sub-draw functions +// ------------------------------------------------------------------------- + +void GameOverlay::DrawPlayerBox(ImDrawList* dl, + const PlayerScreenData& sd, + ImU32 color) const +{ + dl->AddRect(ImVec2(sd.bx0, sd.by0), ImVec2(sd.bx1, sd.by1), color, 0.0f, 0, 1.5f); +} + +void GameOverlay::DrawPlayerLabels(ImDrawList* dl, + const PlayerScreenData& sd, + const DayZPlayerEntry& p, + ImU32 color) const +{ + const char* nick = p.nickname.empty() ? "Player" : p.nickname.c_str(); + char lblBuf[256]; + if (p.isAdmin && p.isDead) + snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [DEAD] [%.0fm]", nick, sd.dist); + else if (p.isAdmin) + snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [%.0fm]", nick, sd.dist); + else if (p.isDead) + snprintf(lblBuf, sizeof(lblBuf), "%s [DEAD] [%.0fm]", nick, sd.dist); + else + snprintf(lblBuf, sizeof(lblBuf), "%s [%.0fm]", nick, sd.dist); + dl->AddText(ImVec2(sd.bx1 + 3.0f, sd.by0), color, lblBuf); + + if (m_cfg.showWeapon && !p.isDead && !p.itemInHands.empty()) { + constexpr ImU32 kWeaponColor = IM_COL32(220, 220, 100, 200); + const float barGap = (m_cfg.showHealthBar || m_cfg.showHealthNumber) ? 11.0f : 5.0f; + dl->AddText(ImVec2(sd.bx1 + barGap, sd.by0 + 13.0f), kWeaponColor, + p.itemInHands.c_str()); + } +} + +void GameOverlay::DrawPlayerHealth(ImDrawList* dl, + const PlayerScreenData& sd, + const DayZPlayerEntry& p) const +{ + if (p.isDead || p.health < 0.0f) return; + if (!m_cfg.showHealthBar && !m_cfg.showHealthNumber) return; + + constexpr float kBarW = 4.0f; + constexpr float kBarGap = 5.0f; + const float barX = sd.bx1 + kBarGap; + const float boxH = sd.by1 - sd.by0; + const float hpFrac = std::min(p.health / 100.0f, 1.0f); + + if (m_cfg.showHealthBar) { + dl->AddRectFilled(ImVec2(barX, sd.by0), ImVec2(barX + kBarW, sd.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; + float filledTop = sd.by0 + boxH * (1.0f - hpFrac); + dl->AddRectFilled(ImVec2(barX, filledTop), ImVec2(barX + kBarW, sd.by1), + IM_COL32(static_cast(r*220), static_cast(g*220), 0, 220)); + } + + if (m_cfg.showHealthNumber) { + char hpBuf[32]; + snprintf(hpBuf, sizeof(hpBuf), "%.0f/100", p.health); + float numX = m_cfg.showHealthBar ? barX + kBarW + 2.0f : barX; + dl->AddText(ImVec2(numX, sd.by1 - 12.0f), IM_COL32(200, 200, 200, 200), hpBuf); + } +} + +void GameOverlay::DrawPlayerBallistic(ImDrawList* dl, + const DayZPlayerEntry& p, + const Vector3& pos, + const RuntimeUpdate& u, + const CameraData& cam, + float w, float h) +{ + if (!m_cfg.showBallisticDot || p.isDead) return; + if (u.localWeaponInitSpeed <= 1.0f || !u.localPlayerPosition.has_value()) return; + // (position was checked before ComputeScreenData was called) + + constexpr float kVelAlpha = 0.35f; + Vector3 vel{}; + auto& ve = m_playerVel[p.address]; + if (ve.ms > 0 && (m_frameTimeMs - ve.ms) < 2000) { + float dt = static_cast(m_frameTimeMs - ve.ms) * 0.001f; + if (dt > 0.016f) { + vel.x = kVelAlpha*(pos.x-ve.pos.x)/dt + (1.0f-kVelAlpha)*ve.vel.x; + vel.y = kVelAlpha*(pos.y-ve.pos.y)/dt + (1.0f-kVelAlpha)*ve.vel.y; + vel.z = kVelAlpha*(pos.z-ve.pos.z)/dt + (1.0f-kVelAlpha)*ve.vel.z; + } else { + vel = ve.vel; + } + } + ve.pos = pos; ve.ms = m_frameTimeMs; ve.vel = vel; + + const Vector3& lp = *u.localPlayerPosition; + float dx = pos.x-lp.x, dz = pos.z-lp.z; + float horizDist = std::sqrt(dx*dx + dz*dz); + + float V0 = u.localWeaponInitSpeed, k = u.localWeaponAirFriction, T; + if (k > 0.0001f) { + float ratio = horizDist * k / V0; + T = (ratio < 0.99f) ? (-std::log(1.0f-ratio)/k) : (horizDist/(V0*0.5f)); + } else { + T = horizDist / V0; + } + T = std::min(T, 8.0f); + + constexpr float kGravity = 9.81f; + constexpr float kCenterMassY = 1.0f; + Vector3 aimPt; + aimPt.x = pos.x + vel.x*T; + aimPt.y = pos.y + kCenterMassY + 0.5f*kGravity*T*T; + aimPt.z = pos.z + vel.z*T; + + // Solid dot at current player position + float spx{}, spy{}; + if (Proj(cam, pos, spx, spy, w, h)) { + dl->AddCircleFilled(ImVec2(spx, spy), 5.0f, IM_COL32(0, 0, 0, 160)); + dl->AddCircleFilled(ImVec2(spx, spy), 3.5f, IM_COL32(0, 255, 220, 240)); + dl->AddCircle (ImVec2(spx, spy), 3.5f, IM_COL32(0, 0, 0, 180), 0, 1.0f); + } + + // Hollow ring at velocity-adjusted aim point (lead prediction) + float adx{}, ady{}; + if (Proj(cam, aimPt, adx, ady, w, h)) { + dl->AddCircle(ImVec2(adx, ady), 5.0f, IM_COL32(0, 0, 0, 160), 0, 1.5f); + dl->AddCircle(ImVec2(adx, ady), 3.5f, IM_COL32(0, 255, 220, 240), 0, 1.5f); + } +} + +// ------------------------------------------------------------------------- +// DrawPlayers +// ------------------------------------------------------------------------- + +void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, + const CameraData& cam, float w, float h) +{ + const float maxDistSq = m_cfg.playerMaxDist * m_cfg.playerMaxDist; + + for (const auto& p : u.players) { + if (p.isDead && !m_cfg.showCorpses) continue; + if (!p.position.has_value()) continue; + if (u.localPlayerEntityAddress.has_value() + && p.address == *u.localPlayerEntityAddress) continue; + + const Vector3& pos = *p.position; + + // dist² pre-cull — avoids sqrtf for most out-of-range entities. + float dx = pos.x-cam.translation.x, dy = pos.y-cam.translation.y, dz = pos.z-cam.translation.z; + float distSq = dx*dx + dy*dy + dz*dz; + if (distSq > maxDistSq) continue; + if (!IsFacingCamera(cam, pos)) continue; + + const float dist = std::sqrtf(distSq); + + const ImU32 kColor = p.isAdmin ? IM_COL32(255, 200, 0, 255) + : p.isDead ? IM_COL32(120, 120, 120, 160) + : IM_COL32(255, 60, 60, 220); + + // Get extrapolated smooth bones (nullopt for dead or before first read). + std::optional smoothOpt; + if (!p.isDead) smoothOpt = m_playerBones.Get(p.address, m_frameTimeMs); + const SkeletonBones* smoothPtr = smoothOpt.has_value() ? &*smoothOpt : nullptr; + + // All 16 bone Proj() calls happen here — once per entity per frame. + const bool wantHead = m_cfg.showHeadDot && !p.isDead && smoothPtr != nullptr; + auto sdOpt = ComputeScreenData(pos, smoothPtr, wantHead, cam, w, h); + if (!sdOpt.has_value()) continue; + PlayerScreenData& sd = *sdOpt; + sd.dist = dist; + + if (sd.by1 - sd.by0 < 2.0f) continue; + + if (m_cfg.showBox) + DrawPlayerBox(dl, sd, kColor); + + if (!p.isDead && smoothPtr != nullptr) { + if (m_cfg.showSkeleton) + DrawSkeleton(dl, sd.bones, kColor, false); + if (m_cfg.showHeadDot && sd.hasHead) { + dl->AddCircleFilled(ImVec2(sd.hbx, sd.hby), sd.headRadius, IM_COL32(0, 0, 0, 80)); + dl->AddCircle(ImVec2(sd.hbx, sd.hby), sd.headRadius, kColor, 0, 1.5f); + } + } + + DrawPlayerLabels(dl, sd, p, kColor); + DrawPlayerHealth(dl, sd, p); + if (!p.isDead && smoothPtr != nullptr) + DrawPlayerBallistic(dl, p, pos, u, cam, w, h); + } +} + +// ------------------------------------------------------------------------- +// DrawBulletTrails +// ------------------------------------------------------------------------- + +void GameOverlay::DrawBulletTrails(ImDrawList* dl, const CameraData& cam, float w, float h) { + auto tracks = m_bulletTracks.GetSnapshot(); + + // Combat mode shortens fade so stale trails don't clutter the screen. + const int64_t kCompletedFadeMs = m_cfg.combatMode ? 500LL : 1500LL; + const int64_t kPhantomFadeMs = m_cfg.combatMode ? 1200LL : 4000LL; + + for (const auto& t : tracks) { + ImU32 lineColor, originColor; + if (t.isCompleted) { + int64_t age = m_frameTimeMs - t.finalizedMs; + int alpha = static_cast(210.0f * std::max(0.0f, + 1.0f - static_cast(age) / static_cast(kCompletedFadeMs))); + if (alpha <= 0) continue; + lineColor = IM_COL32(255, 140, 30, alpha); + originColor = IM_COL32(255, 60, 60, alpha); + } else if (t.isPhantom) { + int64_t age = m_frameTimeMs - t.lastSeenMs; + int alpha = static_cast(160.0f * std::max(0.0f, + 1.0f - static_cast(age) / static_cast(kPhantomFadeMs))); + if (alpha <= 0) continue; + lineColor = IM_COL32(200, 100, 40, alpha); + originColor = IM_COL32(200, 60, 60, alpha); + } else { + lineColor = IM_COL32(255, 210, 50, 230); + originColor = IM_COL32(255, 80, 80, 230); + } + + std::vector pts; + pts.reserve(t.points.size() + 2); + auto pushPt = [&](const Vector3& wp) { + float sx{}, sy{}; + if (Proj(cam, wp, sx, sy, w, h)) pts.push_back(ImVec2(sx, sy)); + }; + pushPt(t.initialPos); + for (const auto& [ts, pos] : t.points) pushPt(pos); + pushPt(t.isCompleted ? t.finalPos : t.currentPos); + if (pts.size() < 2) continue; + + for (size_t i = 1; i < pts.size(); ++i) + dl->AddLine(pts[i-1], pts[i], lineColor, 1.5f); + dl->AddCircleFilled(pts[0], 3.0f, originColor); + if (t.isCompleted && pts.size() >= 2) { + int alpha = static_cast((lineColor >> IM_COL32_A_SHIFT) & 0xFF); + dl->AddCircleFilled(pts.back(), 3.0f, IM_COL32(255, 255, 100, alpha)); + } + } +} + +// ------------------------------------------------------------------------- +// DrawAnimals +// ------------------------------------------------------------------------- + +void GameOverlay::DrawAnimals(ImDrawList* dl, const RuntimeUpdate& u, + const CameraData& cam, float w, float h) +{ + constexpr ImU32 kGreen = IM_COL32(50, 230, 50, 200); + const float maxDistSq = m_cfg.animalMaxDist * m_cfg.animalMaxDist; + + for (const auto& a : u.animals) { + if (!a.position.has_value()) continue; + float dx = a.position->x-cam.translation.x, dy = a.position->y-cam.translation.y, dz = a.position->z-cam.translation.z; + if (dx*dx+dy*dy+dz*dz > maxDistSq) continue; + if (!IsFacingCamera(cam, *a.position)) continue; + + float sx{}, sy{}; + if (!Proj(cam, *a.position, sx, sy, w, h)) continue; + + float dist = std::sqrtf(dx*dx+dy*dy+dz*dz); + 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); + } +} + +// ------------------------------------------------------------------------- +// DrawZombies +// ------------------------------------------------------------------------- + +void GameOverlay::DrawZombies(ImDrawList* dl, const RuntimeUpdate& u, + const CameraData& cam, float w, float h) +{ + constexpr ImU32 kYellow = IM_COL32(230, 230, 50, 180); + const float maxDistSq = m_cfg.zombieMaxDist * m_cfg.zombieMaxDist; + + for (const auto& z : u.zombies) { + // Prefer fresh position; fall back to bone spine (if interpolator valid), + // then to the last-known cache (cold start only). + auto smoothOpt = m_zombieBones.Get(z.address, m_frameTimeMs); + + Vector3 pos; + if (z.position.has_value()) { + pos = *z.position; + m_zombieLastPos[z.address] = pos; + } else if (smoothOpt.has_value()) { + pos = smoothOpt->spine; + } else { + auto it = m_zombieLastPos.find(z.address); + if (it == m_zombieLastPos.end()) continue; + pos = it->second; + } + + float dx = pos.x-cam.translation.x, dy = pos.y-cam.translation.y, dz = pos.z-cam.translation.z; + float distSq = dx*dx + dy*dy + dz*dz; + if (distSq > maxDistSq) continue; + if (!IsFacingCamera(cam, pos)) continue; + + float dist = std::sqrtf(distSq); + float sx{}, sy{}; + if (!Proj(cam, pos, sx, sy, w, h)) continue; + + if (smoothOpt.has_value() && (m_cfg.showSkeleton || m_cfg.showHeadDot)) { + auto sdOpt = ComputeScreenData(pos, &*smoothOpt, false, cam, w, h); + if (sdOpt.has_value()) { + const PlayerScreenData& sd = *sdOpt; + if (m_cfg.showSkeleton) + DrawSkeleton(dl, sd.bones, kYellow, true); + if (m_cfg.showHeadDot && sd.hasHead) { + 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(sd.hbx, sd.hby), zr, IM_COL32(0, 0, 0, 80)); + dl->AddCircle(ImVec2(sd.hbx, sd.hby), zr, kYellow, 0, 1.5f); + } + } + } + + char zbuf[32]; + snprintf(zbuf, sizeof(zbuf), "Z [%.0fm]", dist); + dl->AddText(ImVec2(sx, sy), kYellow, zbuf); + } +} + +// ------------------------------------------------------------------------- +// DrawItems +// ------------------------------------------------------------------------- + +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_cfg.itemMaxDist * m_cfg.itemMaxDist; + + for (const auto& item : u.items) { + if (!item.filterKey.empty()) { + auto it = m_cfg.itemCategories.find(item.filterKey); + if (it != m_cfg.itemCategories.end() && !it->second) continue; + } + if (!item.position.has_value()) continue; + + 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); + 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(); + } +} + +// ------------------------------------------------------------------------- +// DrawSkeletonDebug // ------------------------------------------------------------------------- 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 < 2.0f || d > m_cfg.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; - + float tx = 10.0f, ty = 10.0f; auto txt = [&](ImU32 col, std::string s) { dl->AddText(ImVec2(tx, ty), col, s.c_str()); ty += lineH; @@ -506,14 +785,12 @@ void GameOverlay::DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u, 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")); - + txt(IM_COL32(200,200,200,180), std::format("skeleton.valid = {}", target->skeleton.valid)); 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[] = { @@ -538,455 +815,13 @@ void GameOverlay::DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u, 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]")); - + 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.lastReadMs) < 3000LL) { - 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 = 8.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 ≈ 12.75% of box height (15% × 0.85 = −15% of original). - headRadius = std::clamp((by1 - by0) * 0.128f, 2.0f, 15.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(r * 220), - static_cast(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); - } - } - - // ---- Ballistic prediction dot ---- - if (m_showBallisticDot && !p.isDead && u.localWeaponInitSpeed > 1.0f - && u.localPlayerPosition.has_value()) - { - // --- Velocity estimate (EMA of position delta) --- - constexpr float kVelAlpha = 0.35f; - Vector3 vel{}; - auto& ve = m_playerVel[p.address]; - if (ve.ms > 0 && (m_frameTimeMs - ve.ms) < 2000) { - float dt = static_cast(m_frameTimeMs - ve.ms) * 0.001f; - if (dt > 0.016f) { - float vx = (pos.x - ve.pos.x) / dt; - float vy = (pos.y - ve.pos.y) / dt; - float vz = (pos.z - ve.pos.z) / dt; - vel.x = kVelAlpha * vx + (1.0f - kVelAlpha) * ve.vel.x; - vel.y = kVelAlpha * vy + (1.0f - kVelAlpha) * ve.vel.y; - vel.z = kVelAlpha * vz + (1.0f - kVelAlpha) * ve.vel.z; - } else { - vel = ve.vel; - } - } - ve.pos = pos; - ve.ms = m_frameTimeMs; - ve.vel = vel; - - // --- Time of flight with drag (continuous model) --- - const Vector3& lp = *u.localPlayerPosition; - float dx = pos.x - lp.x; - float dz = pos.z - lp.z; - float horizDist = std::sqrt(dx*dx + dz*dz); - - float V0 = u.localWeaponInitSpeed; - float k = u.localWeaponAirFriction; - float T; - if (k > 0.0001f) { - float ratio = horizDist * k / V0; - T = (ratio < 0.99f) ? (-std::log(1.0f - ratio) / k) - : (horizDist / (V0 * 0.5f)); - } else { - T = horizDist / V0; - } - T = std::min(T, 8.0f); - - // --- Aim point: center mass + lead + drop compensation --- - constexpr float kGravity = 9.81f; - constexpr float kCenterMassY = 1.0f; // m above entity root - Vector3 aimPt; - aimPt.x = pos.x + vel.x * T; - aimPt.y = pos.y + kCenterMassY + 0.5f * kGravity * T * T; - aimPt.z = pos.z + vel.z * T; - - float adx{}, ady{}; - if (Proj(cam, aimPt, adx, ady, w, h)) { - dl->AddCircleFilled(ImVec2(adx, ady), 5.0f, IM_COL32(0, 0, 0, 160)); - dl->AddCircleFilled(ImVec2(adx, ady), 3.5f, IM_COL32(0, 255, 220, 240)); - dl->AddCircle (ImVec2(adx, ady), 3.5f, IM_COL32(0, 0, 0, 180), 0, 1.0f); - } - } - } -} - -// Prune velocity entries for players that left the snapshot. -// Called implicitly each DrawPlayers pass via the map auto-insert; no explicit prune -// needed since the map entries are tiny and the set stays bounded by server pop. - -// ------------------------------------------------------------------------- -// DrawBulletTrails — draws a line strip from each bullet's origin to its -// current or final position, with alpha fade for completed/phantom tracks. -// ------------------------------------------------------------------------- - -void GameOverlay::DrawBulletTrails(ImDrawList* dl, const CameraData& cam, float w, float h) { - auto tracks = m_bulletTracks.GetSnapshot(); - - for (const auto& t : tracks) { - // Pick color + alpha based on track state. - ImU32 lineColor, originColor; - if (t.isCompleted) { - int64_t age = m_frameTimeMs - t.finalizedMs; - int alpha = static_cast(210.0f * std::max(0.0f, - 1.0f - static_cast(age) / 1500.0f)); - if (alpha <= 0) continue; - lineColor = IM_COL32(255, 140, 30, alpha); - originColor = IM_COL32(255, 60, 60, alpha); - } else if (t.isPhantom) { - int64_t age = m_frameTimeMs - t.lastSeenMs; - int alpha = static_cast(160.0f * std::max(0.0f, - 1.0f - static_cast(age) / 4000.0f)); - if (alpha <= 0) continue; - lineColor = IM_COL32(200, 100, 40, alpha); - originColor = IM_COL32(200, 60, 60, alpha); - } else { - // Active bullet — bright yellow-orange. - lineColor = IM_COL32(255, 210, 50, 230); - originColor = IM_COL32(255, 80, 80, 230); - } - - // Collect screen-space points: origin → history → endpoint. - std::vector pts; - pts.reserve(t.points.size() + 2); - - auto pushPt = [&](const Vector3& wp) { - float sx{}, sy{}; - if (Proj(cam, wp, sx, sy, w, h)) - pts.push_back(ImVec2(sx, sy)); - }; - - pushPt(t.initialPos); - for (const auto& [ts, pos] : t.points) - pushPt(pos); - pushPt(t.isCompleted ? t.finalPos : t.currentPos); - - if (pts.size() < 2) continue; - - // Draw line strip. - for (size_t i = 1; i < pts.size(); ++i) - dl->AddLine(pts[i - 1], pts[i], lineColor, 1.5f); - - // Origin dot. - dl->AddCircleFilled(pts[0], 3.0f, originColor); - - // Endpoint dot for completed tracks. - if (t.isCompleted && pts.size() >= 2) { - int alpha = static_cast((lineColor >> IM_COL32_A_SHIFT) & 0xFF); - dl->AddCircleFilled(pts.back(), 3.0f, IM_COL32(255, 255, 100, alpha)); - } - } -} - -// ------------------------------------------------------------------------- -// 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.lastReadMs) < 3000LL) { - 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(); - } + txt(IM_COL32(180,255,180,200), std::format(" {}/15 bones on screen", projected)); } diff --git a/src/Overlay/GameOverlay.h b/src/Overlay/GameOverlay.h index bbff408..c34227b 100644 --- a/src/Overlay/GameOverlay.h +++ b/src/Overlay/GameOverlay.h @@ -1,12 +1,13 @@ #pragma once #include -#include #include +#include #include #include #include #include #include "Config.h" +#include "Overlay/BoneInterpolator.h" #include "Runtime/DayZRuntimeService.h" #include "Web/BulletTrackCache.h" @@ -14,14 +15,25 @@ struct ImDrawList; class GameOverlay { public: - // Exposed so static helpers in GameOverlay.cpp can take it by reference. - struct BoneHistory { - SkeletonBones prev{}; - SkeletonBones curr{}; - int64_t prevMs = 0; - int64_t currMs = 0; // time of the last positional change (for extrapolation velocity) - int64_t lastReadMs = 0; // time of the most recent valid read (for staleness gate) - bool initialized = false; + // Pre-projected per-entity bone screen coordinates — computed once per + // entity per frame and shared by box, skeleton, head-circle draw calls. + struct PlayerScreenData { + enum BoneIdx : 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 = 16 + }; + struct SP { float x = 0.f, y = 0.f; bool ok = false; }; + + SP bones[B_COUNT]{}; + float bx0 = 0.f, by0 = 0.f, bx1 = 0.f, by1 = 0.f; + float hbx = 0.f, hby = 0.f, headRadius = 0.f; + bool boxReady = false; + bool hasHead = false; + float dist = 0.f; }; explicit GameOverlay(DayZRuntimeService& service, @@ -30,165 +42,85 @@ public: : m_service(service) , m_cfg(std::move(cfg)) , m_cfgPath(std::move(cfgPath)) - { - m_showPlayers = m_cfg.showPlayers; - m_showAnimals = m_cfg.showAnimals; - m_showZombies = m_cfg.showZombies; - m_showItems = m_cfg.showItems; - m_showBox = m_cfg.showBox; - m_showSkeleton = m_cfg.showSkeleton; - m_showHeadDot = m_cfg.showHeadDot; - m_showCorpses = m_cfg.showCorpses; - m_showWeapon = m_cfg.showWeapon; - m_showHealthBar = m_cfg.showHealthBar; - m_showHealthNumber = m_cfg.showHealthNumber; - m_showBallisticDot = m_cfg.showBallisticDot; - m_showBulletTrails = m_cfg.showBulletTrails; - m_itemCategories = m_cfg.itemCategories; - m_playerMaxDist = m_cfg.playerMaxDist; - m_animalMaxDist = m_cfg.animalMaxDist; - m_zombieMaxDist = m_cfg.zombieMaxDist; - m_itemMaxDist = m_cfg.itemMaxDist; - m_pendingW = m_cfg.overlayWidth; - m_pendingH = m_cfg.overlayHeight; - m_renderW = m_cfg.renderWidth; - m_renderH = m_cfg.renderHeight; - m_stretchToFill = m_cfg.stretchToFill; - m_pendingRW = m_cfg.renderWidth; - m_pendingRH = m_cfg.renderHeight; - } + {} - /// Called once after the web radar server has started so the Radar tab - /// knows what URL(s) to display. void SetWebRadarPort(int port); - - /// Called once after ImGui fonts are built so DrawItems can tier by distance. void SetLootFonts(ImFont* close, ImFont* lootFar) { m_fontLootClose = close; m_fontLootFar = lootFar; } - - /// Register a callback invoked when the user clicks the exit button. - /// The caller should set its stop flag inside the callback. void SetExitCallback(std::function cb) { m_exitCallback = std::move(cb); } - - /// Register a callback invoked when the user applies a new overlay resolution. void SetResizeCallback(std::function cb) { m_resizeCallback = std::move(cb); } - // Called each frame. w/h a re the overlay window pixel dimensions. + // Called each ImGui frame. void Draw(float w, float h); + bool IsMenuOpen() const { return m_menuOpen; } + private: DayZRuntimeService& m_service; OverlayConfig m_cfg; std::string m_cfgPath; - // Cached entity snapshot (updated once per frame via GetLatestUpdate). std::shared_ptr m_snapshot; - -public: - bool IsMenuOpen() const { return m_menuOpen; } - -private: - - // Live camera — updated every frame directly from the service, - // bypassing the entity snapshot so it reflects the latest DMA read. CameraData m_liveCamera; - // Web radar int m_webPort = 7777; - std::vector m_webUrls; // populated by SetWebRadarPort() + std::vector m_webUrls; - // Exit callback — invoked when the user presses the exit button. - std::function m_exitCallback; - // Resize callback — invoked when the user applies a new resolution. - std::function m_resizeCallback; + std::function m_exitCallback; + std::function m_resizeCallback; // Menu state - bool m_menuOpen = false; - float m_menuAlpha = 0.0f; - int m_tab = 0; - int m_subtab = 0; + bool m_menuOpen = false; + float m_menuAlpha = 0.0f; + int m_tab = 0; + int m_subtab = 0; + bool m_debugSkeleton = false; // not persisted — debug-only - // ESP toggles (initialised from config in constructor) - bool m_showPlayers = true; - bool m_showAnimals = true; - bool m_showZombies = true; - bool m_showItems = true; - bool m_showBox = true; - bool m_showSkeleton = true; - bool m_showHeadDot = false; - bool m_showCorpses = false; - bool m_showWeapon = true; - bool m_showHealthBar = true; - bool m_showHealthNumber = false; - bool m_showBallisticDot = false; - bool m_showBulletTrails = false; - bool m_debugSkeleton = false; // draws named bone dots for the closest player + // Loot fonts (set once via SetLootFonts after atlas build) + ImFont* m_fontLootClose = nullptr; + ImFont* m_fontLootFar = nullptr; - // Per-category item enabled map (key = filterKey, missing = enabled) - std::map m_itemCategories; - - // Loot fonts — set once via SetLootFonts() after ImGui atlas is built. - ImFont* m_fontLootClose = nullptr; // 16 px — used for items within 50 m - ImFont* m_fontLootFar = nullptr; // 11 px — used for items >= 50 m - - // Last-known positions for zombies keyed by entity address. - // Used to avoid a single failed position read causing a visible blink. + // Zombie last-known positions — fallback when position read fails and no + // bone data is available (cold start for newly entered scan radius). std::unordered_map m_zombieLastPos; - // Per-player velocity estimate — updated each frame from position delta. - struct PlayerVelEntry { - Vector3 pos; - int64_t ms = 0; - Vector3 vel; // smoothed m/s, EMA α=0.35 - }; + // Per-player velocity for ballistic prediction (EMA α=0.35) + struct PlayerVelEntry { Vector3 pos; int64_t ms = 0; Vector3 vel; }; std::unordered_map m_playerVel; - // Bullet trail tracker — owned by the overlay, fed from each update's bullet list. + // Bullet trail tracker BulletTrackCache m_bulletTracks; - // ---- Bone interpolation / extrapolation ---- - std::unordered_map m_playerBoneHistory; - std::unordered_map m_zombieBoneHistory; - int64_t m_frameTimeMs = 0; // set once per Draw() call + // Bone interpolators — one per entity class + BoneInterpolator m_playerBones; + BoneInterpolator m_zombieBones; + int64_t m_frameTimeMs = 0; static int64_t NowMs(); - // Pending resolution override — edited in menu, applied on button press. - int m_pendingW = 0; - int m_pendingH = 0; - int m_pendingRW = 0; // render width (stretched res) - int m_pendingRH = 0; // render height (stretched res) - - // Stretched resolution state (active values, applied on "Apply") - int m_renderW = 0; - int m_renderH = 0; - bool m_stretchToFill = true; - - // ESP distance limits (initialised from config in constructor) - float m_playerMaxDist = 1000.0f; - float m_animalMaxDist = 1000.0f; - float m_zombieMaxDist = 500.0f; - float m_itemMaxDist = 200.0f; - - // Low-level NDC projection helper — maps a world position to overlay pixels. - // Returns false if the position is behind the camera or off-screen. static bool WorldToScreen(const CameraData& cam, const Vector3& worldPos, float& sx, float& sy, float w, float h); - // Viewport-aware projection wrapper. Use this instead of WorldToScreen - // directly — it applies stretched-resolution / letterbox offsets. bool Proj(const CameraData& cam, const Vector3& worldPos, float& sx, float& sy, float overlayW, float overlayH) const; static float Dist(const Vector3& a, const Vector3& b); - // Copy live menu state (m_show*, distances, item categories, resolution) - // back into m_cfg prior to a Save. - void SyncConfig(); + // Project all bone positions and compute the bounding box once per entity. + // smoothBones may be nullptr (corpses, bone-cold entities) → falls back to + // ground-pos + hardcoded 1.8 m head. includeHeadCircle extends by0 to + // encompass the head circle radius when showHeadDot is active. + // Returns nullopt if the entity is entirely off-screen or box height < 2 px. + std::optional ComputeScreenData( + const Vector3& entityPos, + const SkeletonBones* smoothBones, + bool includeHeadCircle, + const CameraData& cam, + float w, float h) const; void DrawESP (float w, float h, const RuntimeUpdate& u, const CameraData& cam); void DrawPlayers (ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); @@ -197,12 +129,22 @@ private: void DrawItems (ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); void DrawBulletTrails (ImDrawList* dl, const CameraData& cam, float w, float h); - // Draw skeleton bone segments for a single entity. color = ImU32 (unsigned int). - void DrawSkeleton(ImDrawList* dl, const SkeletonBones& bones, - const CameraData& cam, float w, float h, + // Per-player sub-draw functions — all take a pre-computed PlayerScreenData. + void DrawPlayerBox (ImDrawList* dl, const PlayerScreenData& sd, ImU32 color) const; + void DrawPlayerLabels (ImDrawList* dl, const PlayerScreenData& sd, + const DayZPlayerEntry& p, ImU32 color) const; + void DrawPlayerHealth (ImDrawList* dl, const PlayerScreenData& sd, + const DayZPlayerEntry& p) const; + void DrawPlayerBallistic(ImDrawList* dl, + const DayZPlayerEntry& p, const Vector3& pos, + const RuntimeUpdate& u, + const CameraData& cam, float w, float h); + + // Draw skeleton line segments using pre-projected bone screen coords. + // No Proj() calls inside — caller pre-computes via ComputeScreenData. + void DrawSkeleton(ImDrawList* dl, const PlayerScreenData::SP* bones, unsigned int color, bool isZombie) const; - // Debug: draw every named bone as a labeled dot for the closest player. void DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) const; }; diff --git a/src/Overlay/MenuBridge.h b/src/Overlay/MenuBridge.h index b9e5f78..4dddbd0 100644 --- a/src/Overlay/MenuBridge.h +++ b/src/Overlay/MenuBridge.h @@ -3,84 +3,59 @@ // MenuBridge — the seam between the vendored Lumin ImGui menu (external/lumin) // and this project's overlay state (GameOverlay). // -// GameOverlay fills one of these every frame (pointers into its own members -// for the editable values, plain values for the read-only info panels) and -// the menu's render() reads/writes through the global pointer below. This -// keeps all the actual ESP state owned by GameOverlay while letting Lumin's -// widgets drive it. +// GameOverlay sets cfg = &m_cfg each frame so the menu reads/writes all +// persisted settings directly through the OverlayConfig pointer. The +// remaining fields are either debug-only state or read-only info panels +// that GameOverlay populates each frame. // ------------------------------------------------------------------------- #include +#include #include -#include #include #include +#include "Config.h" struct MenuBridge { - // ---- ESP visibility toggles (point at GameOverlay members) ---- - bool* showPlayers = nullptr; - bool* showAnimals = nullptr; - bool* showZombies = nullptr; - bool* showItems = nullptr; - bool* showBox = nullptr; - bool* showSkeleton = nullptr; - bool* showHeadDot = nullptr; - bool* showWeapon = nullptr; - bool* showHealthBar = nullptr; - bool* showHealthNumber = nullptr; - bool* showCorpses = nullptr; - bool* debugSkeleton = nullptr; - bool* showBallisticDot = nullptr; - bool* showBulletTrails = nullptr; + // All persisted ESP / resolution settings — menu reads/writes through cfg. + OverlayConfig* cfg = nullptr; - // ---- ESP draw-distance limits ---- - float* playerMaxDist = nullptr; - float* animalMaxDist = nullptr; - float* zombieMaxDist = nullptr; - float* itemMaxDist = nullptr; + // Debug skeleton overlay — not persisted, toggled per-session only. + bool* debugSkeleton = nullptr; - // ---- Per-category loot toggles (key = filterKey) ---- - std::map* itemCategories = nullptr; - - // ---- Read-only info (refreshed each frame) ---- + // ---- Read-only info panels (refreshed each frame by GameOverlay) ---- bool connected = false; std::string serverName; std::string mapName; std::string status; bool hasPos = false; float px = 0.f, py = 0.f, pz = 0.f; - std::size_t nPlayers = 0, nAnimals = 0, nZombies = 0, - nVehicles = 0, nItems = 0, nBullets = 0; + std::size_t nPlayers = 0; + std::size_t nAnimals = 0; + std::size_t nZombies = 0; + std::size_t nVehicles = 0; + std::size_t nItems = 0; + std::size_t nBullets = 0; - // ---- DMA handle stats (read-only, refreshed each frame) ---- - bool dmaAttached = false; - float dmaReadMBps = 0.f; // MB/s rolling 1 s - float dmaScatterOps = 0.f; // scatter calls/s rolling 1 s - float dmaTotalGB = 0.f; // cumulative GB read since attach - uint64_t dmaTotalOps = 0; // cumulative scatter calls since attach + // ---- DMA handle stats (read-only) ---- + bool dmaAttached = false; + float dmaReadMBps = 0.f; + float dmaScatterOps = 0.f; + float dmaTotalGB = 0.f; + uint64_t dmaTotalOps = 0; // ---- Web radar ---- int webPort = 7777; std::vector webUrls; - // ---- Resolution settings (point at GameOverlay members) ---- - int* pendingW = nullptr; - int* pendingH = nullptr; - int* pendingRW = nullptr; - int* pendingRH = nullptr; - bool* stretchToFill = nullptr; - // ---- Actions ---- - std::function onApplyDisplayRes; // applies pendingW/H + resize - std::function onApplyRenderRes; // applies pendingRW/RH + stretch + std::function onApplyDisplayRes; // resize overlay window + std::function onApplyRenderRes; // apply stretched-res correction std::function onSaveConfig; std::function onExit; }; -// Set by GameOverlay before each gui->render() call; read by the menu. +// Set by GameOverlay before each RenderLuminMenu() call; read by the menu. extern MenuBridge* g_menu; -// Thin shim implemented in external/lumin/framework/gui.cpp. Lets GameOverlay -// drive the Lumin menu without pulling the whole framework header (and its -// `using namespace ImGui`) into GameOverlay's translation unit. Must be called -// between ImGui::NewFrame() and ImGui::Render(). +// Thin shim implemented in external/lumin/framework/gui.cpp. void RenderLuminMenu(); diff --git a/src/Runtime/DayZRuntimeService.cpp b/src/Runtime/DayZRuntimeService.cpp index 7a7a326..ba9edfb 100644 --- a/src/Runtime/DayZRuntimeService.cpp +++ b/src/Runtime/DayZRuntimeService.cpp @@ -100,6 +100,7 @@ DayZRuntimeService::DayZRuntimeService(RuntimeConfig config) m_state.nextNetworkMetadataRefresh = past; m_state.nextBoneRefresh = past; m_state.nextVmmRefresh = past; + m_state.nextKeyRefresh = past; } DayZRuntimeService::~DayZRuntimeService() { @@ -698,8 +699,9 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { update.localPlayerLookDirection = m_localPlayer.lookDirection; // Weapon ammo chain: entity→inventory→hands(weapon)→magazine→ammoType - // Logged once per session if it fails with a weapon in hand (handsValid=1) - // so offset bugs are diagnosable without a debugger. + // Falls back to chambered-item chain when no magazine is attached. + // Logged once per weapon-equip session; gate resets when no weapon is + // in hand so the next equip always gets fresh diagnostic output. uint64_t inv = 0, weapon = 0, mag = 0, ammoType = 0; uint8_t handsValid = 0; bool invOk = m_memory.TryReadPointer(pid, @@ -709,6 +711,13 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { && m_memory.TryReadValue(pid, inv + Offsets::Inventory::HandItemValid, handsValid) && handsValid; + + // No weapon in hand: reset the "logged once" gate so the next equip + // triggers fresh failure logging (only when inv is readable, to avoid + // resetting on a transient DMA miss that also blanks handOk). + if (invOk && !handOk) + m_ammoChainLoggedOnce = false; + bool weaponOk = handOk && m_memory.TryReadPointer(pid, inv + Offsets::Inventory::Hands, weapon) @@ -723,13 +732,19 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { && MemoryValidation::IsValidUserAddress(ammoType); if (ammoOk) { - m_ammoChainLoggedOnce = false; // reset so failures log again next equip float initSpeed = 0.0f, airFriction = 0.0f; m_memory.TryReadValue(pid, ammoType + Offsets::AmmoType::InitSpeed, initSpeed); m_memory.TryReadValue(pid, ammoType + Offsets::AmmoType::AirFriction, airFriction); if (initSpeed > 1.0f) { + if (!m_ammoChainLoggedOnce) { + m_ammoChainLoggedOnce = true; + spdlog::info("BallisticDot: ammo chain OK — " + "initSpeed={:.1f} m/s airFriction={:.4f} " + "(mag=0x{:X} ammoType=0x{:X})", + initSpeed, airFriction, mag, ammoType); + } update.localWeaponInitSpeed = initSpeed; update.localWeaponAirFriction = std::max(0.0f, airFriction); } else if (!m_ammoChainLoggedOnce) { @@ -738,27 +753,30 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { "(ammoType=0x{:X}+0x{:X}) — check AmmoType::InitSpeed offset", initSpeed, ammoType, Offsets::AmmoType::InitSpeed); } - } else if (!m_ammoChainLoggedOnce) { - if (!invOk) { - m_ammoChainLoggedOnce = true; - spdlog::warn("BallisticDot: inventory ptr invalid " - "(entity=0x{:X}+0x{:X}) — check Inventory::Base offset", - m_localPlayer.entityAddress, Offsets::Inventory::Base); - } else if (!handOk) { - // handsValid==0 = no weapon equipped — silence further "no weapon" noise. - m_ammoChainLoggedOnce = true; - } else if (!weaponOk) { - m_ammoChainLoggedOnce = true; + } else if (!invOk && !m_ammoChainLoggedOnce) { + m_ammoChainLoggedOnce = true; + spdlog::warn("BallisticDot: inventory ptr invalid " + "(entity=0x{:X}+0x{:X}) — check Inventory::Base offset", + m_localPlayer.entityAddress, Offsets::Inventory::Base); + } else if (handOk && !m_ammoChainLoggedOnce) { + m_ammoChainLoggedOnce = true; + if (!weaponOk) { spdlog::warn("BallisticDot: weapon chain fail at Inventory::Hands " "(inv=0x{:X}+0x{:X})", inv, Offsets::Inventory::Hands); } else if (!magOk) { - m_ammoChainLoggedOnce = true; - spdlog::warn("BallisticDot: weapon chain fail at Weapon::MagazinePtr " - "(weapon=0x{:X}+0x{:X}) — verify offset", weapon, Offsets::Weapon::MagazinePtr); + uint64_t rawMagVal = 0; + m_memory.TryReadValue(pid, + weapon + Offsets::Weapon::MagazinePtr, rawMagVal); + spdlog::warn("BallisticDot: mag+chambered both fail " + "(weapon=0x{:X}+0x{:X}) magRaw=0x{:X} — {}", + weapon, Offsets::Weapon::MagazinePtr, rawMagVal, + rawMagVal == 0 + ? "no magazine loaded and no chambered round" + : "verify MagazinePtr offset"); } else { - m_ammoChainLoggedOnce = true; spdlog::warn("BallisticDot: weapon chain fail at Magazine::AmmoTypePtr " - "(mag=0x{:X}+0x{:X}) — verify offset", mag, Offsets::Magazine::AmmoTypePtr); + "(mag=0x{:X}+0x{:X}) — verify offset", + mag, Offsets::Magazine::AmmoTypePtr); } } } @@ -879,6 +897,10 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { if (!boneTick.players.empty() || !boneTick.zombies.empty()) { RefreshBonesScatter(pid, boneTick.players, boneTick.zombies); } + // Stamp with the time bones were actually read so the overlay can + // compute how old this data is and extrapolate positions forward. + boneTick.snapshotMs = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); PublishUpdate(boneTick); // always publish — includes items/bullets m_state.nextBoneRefresh = Clock::now() + Ms(m_config.boneRefreshMs); } @@ -910,7 +932,21 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { } // ------------------------------------------------------------------ - // 13. Consecutive-failure check. + // 13. Game-PC keyboard state poll (gafAsyncKeyState via DMA). + // Poll at ~20 Hz — fast enough for key-press detection, cheap enough + // to ignore if win32kbase.sys isn't found (e.g. pre-attach). + // ------------------------------------------------------------------ + if (now >= m_state.nextKeyRefresh) { + if (!m_keyReader.IsReady()) { + m_keyReader.TryInit(m_memory); + } else { + m_keyReader.Update(m_memory); + } + m_state.nextKeyRefresh = Clock::now() + Ms(50); + } + + // ------------------------------------------------------------------ + // 14. Consecutive-failure check. // ------------------------------------------------------------------ if (anyFailure) { ++consecutiveFailures; @@ -1043,6 +1079,7 @@ void DayZRuntimeService::ResetAllReaders() { m_bulletReader.Reset(); m_itemReader.Reset(); m_scoreboardReader.Reset(); + m_keyReader.Reset(); // Park the camera thread before tearing down camera state so it stops // reading through a Process handle that's about to be re-created. @@ -1083,6 +1120,7 @@ void DayZRuntimeService::ResetAllReaders() { m_state.nextNetworkMetadataRefresh = past; m_state.nextBoneRefresh = past; m_state.nextVmmRefresh = past; + m_state.nextKeyRefresh = past; } // ------------------------------------------------------------------------- @@ -1094,26 +1132,30 @@ void DayZRuntimeService::RefreshBonesScatter( std::vector& players, std::vector& zombies) { - // Bone indices for GetBonePositionWS scatter reads. - // Due to BoneTranslationOffset=0x54 vs the source's 0x24 (difference = 1 stride), - // our index N reads the same world position as their index N+1. All values below - // are already adjusted for this (-1 from the Enfusion PlayerBoneId/InfectedBoneId enums). + // Bone indices — direct Enfusion PlayerBoneId / InfectedBoneId enum values. + // With BoneStride=64 and BoneTranslationOffset=0x30 the formula is: + // matBase + 0x30 + boneIndex * 0x40 + // No index adjustment needed (old -1 adjustment was a 3×4 vs 4×4 stride artefact). // + // boneOut order: neck head spine | rShoulder rElbow rHand | lShoulder lElbow lHand | + // rHip rKnee rAnkle | lHip lKnee lAnkle // Player: 15 bones. Verified against PlayerBoneId enum 2026-06-13. static const int kPlayerBones[15] = { - 21, 23, 18, // neck(22), head(24), spine1(19) - 60, 63, 65, // leftShoulder(61), leftForearm(64), leftHand(66) - 93, 97, 99, // rightShoulder(94), rightForearm(98), rightHand(100) - 1, 4, 6, // leftUpLeg(2), leftLeg(5), leftFoot(7) - 9, 12, 14 // rightUpLeg(10), rightLeg(13), rightFoot(15) + 22, 24, 19, // neck, head, spine1 + 94, 98, 100, // rightShoulder, rightForearm, rightHand + 61, 64, 66, // leftShoulder, leftForearm, leftHand + 2, 5, 7, // rightUpLeg, rightLeg, rightFoot (maps to rHip/rKnee/rAnkle) + 10, 13, 15 // leftUpLeg, leftLeg, leftFoot (maps to lHip/lKnee/lAnkle) }; // Zombie: 16 bones. Verified against InfectedBoneId enum 2026-06-13. + // boneOut order: neck head spine pelvis | lShoulder lElbow lHand | rShoulder rElbow rHand | + // rHip rKnee rAnkle | lHip lKnee lAnkle static const int kZombieBones[16] = { - 19, 21, 18, 0, // neck(20), head(22), spine3(19), pelvis(1) - 23, 26, 28, // leftShoulder(24), leftForearm(27), leftHand(29) - 55, 59, 61, // rightShoulder(56), rightForearm(60), rightHand(62) - 1, 4, 6, // leftUpLeg(2), leftLeg(5), leftFoot(7) - 8, 11, 13 // rightUpLeg(9), rightLeg(12), rightFoot(14) + 20, 22, 19, 1, // neck, head, spine3, pelvis + 24, 27, 29, // leftShoulder, leftForearm, leftHand (→ boneOut[4..6] = lShoulder/Elbow/Hand) + 56, 60, 62, // rightShoulder, rightForearm, rightHand (→ boneOut[7..9] = rShoulder/Elbow/Hand) + 2, 5, 7, // rightUpLeg, rightLeg, rightFoot (→ boneOut[10..12] = rHip/Knee/Ankle) + 9, 12, 14 // leftUpLeg, leftLeg, leftFoot (→ boneOut[13..15] = lHip/Knee/Ankle) }; // Per-entity bone read buffers. Must be stable in memory before we point @@ -1234,7 +1276,7 @@ void DayZRuntimeService::RefreshBonesScatter( struct Vec3f { float x, y, z; }; Vec3f test{}; const uint64_t testAddr = matBase + Offsets::Skeleton::BoneTranslationOffset - + 21 * Offsets::Skeleton::BoneStride; + + 22 * Offsets::Skeleton::BoneStride; if (!m_memory.TryReadValue(pid, testAddr, test)) continue; // Reject all-zero (uninitialised page) and out-of-range garbage. // Local bone translations are always within ±5 m of the skeleton root. diff --git a/src/Runtime/DayZRuntimeService.h b/src/Runtime/DayZRuntimeService.h index 3d704b3..a4ca1b9 100644 --- a/src/Runtime/DayZRuntimeService.h +++ b/src/Runtime/DayZRuntimeService.h @@ -20,6 +20,7 @@ #include "Readers/FarEntityListReader.h" #include "Readers/ItemFilterCatalog.h" #include "Readers/ItemListReader.h" +#include "Readers/KeyStateReader.h" #include "Readers/NearEntityListReader.h" #include "Readers/SlowEntityListReader.h" #include "Resolvers/BaseObjectResolver.h" @@ -62,6 +63,11 @@ struct RuntimeUpdate { std::vector slowEntities; std::optional camera; + + // Steady-clock ms when this snapshot's bone data was captured. + // Set immediately after RefreshBonesScatter so overlay extrapolation + // knows how old the positions are and can predict forward accurately. + int64_t snapshotMs = 0; }; // ------------------------------------------------------------------------- @@ -141,6 +147,14 @@ public: /// Live DMA throughput snapshot — safe to call from the overlay thread each frame. [[nodiscard]] DmaStats GetDmaStats() const { return m_memory.GetStats(); } + /// True while the given virtual key is held on the game PC's keyboard. + /// Sourced from gafAsyncKeyState in win32kbase.sys via DMA. + /// Returns false if KeyStateReader has not yet resolved the address. + [[nodiscard]] bool IsMainKeyDown(int vk) const { return m_keyReader.IsDown(vk); } + + /// True for one poll interval after a key transitions up→down on the game PC. + [[nodiscard]] bool IsMainKeyPressed(int vk) const { return m_keyReader.IsPressed(vk); } + private: // ------------------------------------------------------------------ // Configuration @@ -161,6 +175,7 @@ private: BulletTableReader m_bulletReader; ItemListReader m_itemReader; ClientScoreboardReader m_scoreboardReader; + KeyStateReader m_keyReader; // Last module base address passed to the sig scanner. uint64_t m_lastScannedBase = 0; @@ -265,6 +280,7 @@ private: std::chrono::steady_clock::time_point nextNetworkMetadataRefresh; std::chrono::steady_clock::time_point nextBoneRefresh; std::chrono::steady_clock::time_point nextVmmRefresh; + std::chrono::steady_clock::time_point nextKeyRefresh; } m_state; // Cached metadata, player data, and camera populated during session diff --git a/src/Web/MapTileService.cpp b/src/Web/MapTileService.cpp index b156c5e..2dbd486 100644 --- a/src/Web/MapTileService.cpp +++ b/src/Web/MapTileService.cpp @@ -49,15 +49,16 @@ const MapTileService::Image* MapTileService::LoadOrGet(const std::string& mapId) } // Disk file takes priority — allows map updates without rebuilding. - // Falls back to the PNG baked into the binary as a Windows RCDATA resource. - std::string path = (ExeDir() / "maps" / (mapId + ".png")).string(); + // Falls back to the PNG baked into the binary as a Windows RCDATA resource + // (only for the default "maps" subdir — topo tiles have no embedded fallback). + std::string path = (ExeDir() / m_subdir / (mapId + ".png")).string(); int w = 0, h = 0, channels = 0; unsigned char* raw = nullptr; if (std::filesystem::exists(path)) { raw = stbi_load(path.c_str(), &w, &h, &channels, kChannels); - } else { + } else if (m_subdir == "maps") { auto [ptr, sz] = GetEmbeddedMap(mapId); if (ptr) raw = stbi_load_from_memory(ptr, static_cast(sz), diff --git a/src/Web/MapTileService.h b/src/Web/MapTileService.h index 08c81e9..049338f 100644 --- a/src/Web/MapTileService.h +++ b/src/Web/MapTileService.h @@ -15,6 +15,9 @@ class MapTileService { public: + /// subdir is relative to the exe directory (e.g. "maps" or "maps/topo"). + explicit MapTileService(std::string subdir = "maps") : m_subdir(std::move(subdir)) {} + /// Return PNG-encoded bytes for the requested tile. /// errCode is set to one of: 200, 400, 404, 500. /// An empty vector is returned for any non-200 result. @@ -22,6 +25,8 @@ public: int& errCode); private: + std::string m_subdir; + struct Image { std::vector pixels; // RGBA, row-major int w = 0; diff --git a/src/Web/WebRadarServer.cpp b/src/Web/WebRadarServer.cpp index 5e2f457..6a58443 100644 --- a/src/Web/WebRadarServer.cpp +++ b/src/Web/WebRadarServer.cpp @@ -311,6 +311,49 @@ void WebRadarServer::SetupRoutes() { } }); + // ---- Topo tile (optional overlay layer) -------------------------------- + + m_server->Get("/topo-tile", + [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); + if (!Authorise(req.remote_addr, + req.get_param_value("password"))) { + res.status = 401; + return; + } + + const MapInfo* tileMap = nullptr; + std::string mapIdParam = req.get_param_value("mapId"); + if (!mapIdParam.empty()) + tileMap = MapRegistry::Resolve(mapIdParam); + if (!tileMap) + tileMap = m_currentMap; + + if (!tileMap) { + res.status = 404; + return; + } + + int tx = 0, ty = 0; + try { + tx = std::stoi(req.get_param_value("x")); + ty = std::stoi(req.get_param_value("y")); + } catch (...) { + res.status = 400; + return; + } + + int code = 200; + auto bytes = m_topoTiles.GetTile(*tileMap, tx, ty, code); + res.status = code; + if (code == 200) { + res.set_header("Cache-Control", "public, max-age=86400"); + res.set_content( + std::string(bytes.begin(), bytes.end()), + "image/png"); + } + }); + // ---- Full map image ------------------------------------------------- m_server->Get("/map-image", @@ -350,6 +393,66 @@ void WebRadarServer::SetupRoutes() { res.set_content(std::move(content), "image/png"); }); + // ---- 3D engine static files ----------------------------------------- + + m_server->Get("/engine.js", + [this](const httplib::Request& /*req*/, httplib::Response& res) { + ServeFile(res, "engine.js", "application/javascript; charset=utf-8"); + }); + + m_server->Get("/engine.wasm", + [this](const httplib::Request& /*req*/, httplib::Response& res) { + std::string path = WebrootPath("engine.wasm"); + std::ifstream f(path, std::ios::binary); + if (!f) { res.status = 404; return; } + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + res.set_header("Cache-Control", "no-cache"); + res.set_content(std::move(content), "application/wasm"); + }); + + // ---- Processed data: manifest / terrain / sat / markers ------------- + // All routes resolve to: data/processed//... relative to exe. + + m_server->Get("/data/:mapId/manifest", + [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); + std::string mapId = req.path_params.at("mapId"); + std::string path = DataPath(mapId, mapId + ".manifest.json"); + ServeDataFile(res, path, "application/json"); + }); + + m_server->Get("/data/:mapId/terrain/:file", + [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); + std::string mapId = req.path_params.at("mapId"); + std::string file = req.path_params.at("file"); + std::string path = DataPath(mapId, "terrain/" + file); + res.set_header("Cache-Control", "public, max-age=86400"); + ServeDataFile(res, path, "application/octet-stream"); + }); + + m_server->Get(R"(/data/([^/]+)/sat/(\d+)/(\d+)/(\d+))", + [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); + std::string mapId = req.matches[1]; + std::string z = req.matches[2]; + std::string x = req.matches[3]; + std::string y = req.matches[4]; + std::string path = DataPath(mapId, "sat/" + z + "/" + x + "/" + y + ".webp"); + res.set_header("Cache-Control", "public, max-age=604800"); + ServeDataFile(res, path, "image/webp"); + }); + + m_server->Get("/data/:mapId/markers/:file", + [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); + std::string mapId = req.path_params.at("mapId"); + std::string file = req.path_params.at("file"); + std::string path = DataPath(mapId, "markers/" + file); + ServeDataFile(res, path, "application/json"); + }); + // ---- Debug ---------------------------------------------------------- // No auth — purely diagnostic. Open /api/debug in a browser to see // what the server sees: exe dir, maps/ path, which PNGs exist, and what @@ -475,6 +578,50 @@ std::string WebRadarServer::WebrootPath(const std::string& file) const { return exeDir + "webroot\\" + file; } +// ------------------------------------------------------------------------- +// DataPath — exe_dir/data/processed// +// ------------------------------------------------------------------------- + +std::string WebRadarServer::DataPath(const std::string& mapId, + const std::string& relative) const +{ + wchar_t buf[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, buf, MAX_PATH); + std::wstring wpath(buf); + auto lastSlash = wpath.find_last_of(L"\\/"); + if (lastSlash != std::wstring::npos) + wpath = wpath.substr(0, lastSlash + 1); + std::string exeDir(wpath.begin(), wpath.end()); + + // Sanitise mapId and relative to prevent directory traversal. + // Both must be non-empty and must not contain ".." segments. + if (mapId.empty() || mapId.find("..") != std::string::npos || + relative.find("..") != std::string::npos) + return ""; + + // Replace forward-slashes with backslashes for Windows. + std::string rel = relative; + for (char& c : rel) if (c == '/') c = '\\'; + + return exeDir + "data\\processed\\" + mapId + "\\" + rel; +} + +// ------------------------------------------------------------------------- +// ServeDataFile — serve an arbitrary filesystem path +// ------------------------------------------------------------------------- + +void WebRadarServer::ServeDataFile(httplib::Response& res, + const std::string& path, + const std::string& contentType) +{ + if (path.empty()) { res.status = 400; return; } + std::ifstream f(path, std::ios::binary); + if (!f) { res.status = 404; return; } + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + res.set_content(std::move(content), contentType); +} + // ------------------------------------------------------------------------- // NowMs // ------------------------------------------------------------------------- diff --git a/src/Web/WebRadarServer.h b/src/Web/WebRadarServer.h index a6b9761..0f54f26 100644 --- a/src/Web/WebRadarServer.h +++ b/src/Web/WebRadarServer.h @@ -52,6 +52,7 @@ private: WebSnapshotService m_snapshotSvc; BulletTrackCache m_bullets; MapTileService m_tiles; + MapTileService m_topoTiles{"maps/topo"}; const MapInfo* m_currentMap = nullptr; std::unique_ptr m_server; @@ -80,5 +81,15 @@ private: /// Absolute path to webroot/ relative to the executable directory. std::string WebrootPath(const std::string& file) const; + /// Absolute path to data/processed// relative to the exe. + /// Returns "" if mapId or relative contain ".." (traversal guard). + std::string DataPath(const std::string& mapId, + const std::string& relative) const; + + /// Serve an arbitrary filesystem path with the given content-type. + void ServeDataFile(httplib::Response& res, + const std::string& path, + const std::string& contentType); + static int64_t NowMs(); }; diff --git a/src/Web/WebSnapshotService.cpp b/src/Web/WebSnapshotService.cpp index 3676bd7..ee46d77 100644 --- a/src/Web/WebSnapshotService.cpp +++ b/src/Web/WebSnapshotService.cpp @@ -1,9 +1,16 @@ #include "Web/WebSnapshotService.h" #include +#include #include +#include #include +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + #include using json = nlohmann::json; @@ -14,6 +21,12 @@ using json = nlohmann::json; static constexpr float kPi = 3.14159265358979323846f; +static std::filesystem::path ExeDir() { + wchar_t buf[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, buf, MAX_PATH); + return std::filesystem::path(buf).parent_path(); +} + /// Convert a world address to a hex-string id (e.g. "1A2B3C4D"). static std::string AddrId(uint64_t address) { return std::format("{:X}", address); @@ -451,5 +464,62 @@ std::string WebSnapshotService::BuildBootstrapJson(const RuntimeUpdate& update, j["filters"] = std::move(filters); + // Expose 3D manifest URL when processed data exists for the active map. + // Also expose satellite tile availability independently (no manifest required). + if (map) { + std::filesystem::path manifestPath = + ExeDir() / "data" / "processed" / map->id / (map->id + ".manifest.json"); + if (std::filesystem::exists(manifestPath)) + j["manifestUrl"] = "/data/" + map->id + "/manifest"; + else + j["manifestUrl"] = nullptr; + + std::filesystem::path satDir = + ExeDir() / "data" / "processed" / map->id / "sat"; + if (std::filesystem::exists(satDir) && std::filesystem::is_directory(satDir)) { + j["satUrl"] = "/data/" + map->id + "/sat"; + j["satMaxZoom"] = 7; // matches satellite.py default --max-zoom + } else { + j["satUrl"] = nullptr; + j["satMaxZoom"] = nullptr; + } + } else { + j["manifestUrl"] = nullptr; + j["satUrl"] = nullptr; + j["satMaxZoom"] = nullptr; + } + + // Load POIs for the active map from webroot/pois/.json. + // Coordinates are stored in world-space and translated here to pixel-space + // so the frontend can place them identically to entity markers. + if (map) { + std::filesystem::path poisPath = + ExeDir() / "webroot" / "pois" / (map->id + ".json"); + if (std::filesystem::exists(poisPath)) { + std::ifstream f(poisPath); + if (f) { + auto raw = json::parse(f, nullptr, /*allow_exceptions=*/false); + if (!raw.is_discarded() && raw.is_array()) { + json poiArr = json::array(); + for (const auto& p : raw) { + float px{}, py{}; + MapRegistry::Translate(*map, + p.value("x", 0.0f), + p.value("z", 0.0f), + px, py); + poiArr.push_back({ + {"id", p.value("id", "")}, + {"label", p.value("label", "")}, + {"type", p.value("type", "")}, + {"x", px}, + {"y", py} + }); + } + j["pois"] = std::move(poiArr); + } + } + } + } + return j.dump(); } diff --git a/src/main.cpp b/src/main.cpp index 1e7fc4b..658e611 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -84,12 +84,23 @@ int main() { if (p.isAdmin) ++admins; } - log->info("[Live] Players={} (bones {}/{}, admins {}) Zombies={} Vehicles={} Server='{}'", - update.players.size(), - skelOk, update.players.size(), admins, - update.zombies.size(), - update.carsAndBoats.size(), - update.serverName.value_or("?")); + if (update.localWeaponInitSpeed > 1.0f) { + log->info("[Live] Players={} (bones {}/{}, admins {}) Zombies={} Vehicles={} " + "WeaponSpeed={:.0f}m/s Server='{}'", + update.players.size(), + skelOk, update.players.size(), admins, + update.zombies.size(), + update.carsAndBoats.size(), + update.localWeaponInitSpeed, + update.serverName.value_or("?")); + } else { + log->info("[Live] Players={} (bones {}/{}, admins {}) Zombies={} Vehicles={} Server='{}'", + update.players.size(), + skelOk, update.players.size(), admins, + update.zombies.size(), + update.carsAndBoats.size(), + update.serverName.value_or("?")); + } }); service.Start(); diff --git a/webroot/app.js b/webroot/app.js index b8ec6c7..0cf60ba 100644 --- a/webroot/app.js +++ b/webroot/app.js @@ -1,9 +1,7 @@ const storageKey = "dayz-web-map-settings"; +const MGRS_STEP = 1000; // 1 km MGRS grid square size in world units const params = new URLSearchParams(window.location.search); const password = params.get("password") || ""; -// Optional ?server=http://host:port param — lets the UI be served from a static -// host (Caddy, GitHub Pages, local file) while the C++ backend runs elsewhere. -// Leave empty when the UI is served directly by the C++ server (same origin). const serverOrigin = (params.get("server") || "").replace(/\/$/, ""); const favoriteLootColor = "#ef4444"; const favoriteLootFilterDefinition = { key: "favoriteLoot", label: "Favorites", visibleKey: "showFavoriteLoot", kind: "loot", category: "favorite", color: favoriteLootColor, markerSize: 10, textSize: 14, showLabel: true }; @@ -44,8 +42,6 @@ const previousDefaultFilterColors = { let filterDefinitions = [...entityFilterDefinitions]; const defaultSettings = { - settingsCollapsed: false, - labelSettingsOpen: false, followPlayer: false, showPlayers: true, showZombies: true, @@ -56,6 +52,13 @@ const defaultSettings = { showOtherEntities: true, showFavoriteLoot: true, showLabels: true, + showPOIs: true, + showGrid: false, + showSatellite: false, + showSatmap: false, + showDistanceRings: false, + showMinimap: false, + isCombatMode: false, distanceFilter: 2000, textSize: 14, ordinaryLootSpread: 2, @@ -65,18 +68,40 @@ const defaultSettings = { mergeSameLootLabels: true, favoriteLootNames: [], filterStyles: {}, - filterExpanded: {} + filterExpanded: {}, + // v2 UI state + sidebarState: "full", + activeTab: "tab-map", + theme: "dark", + presets: [null, null, null, null], + waypoints: [], + activePreset: null, + // legacy compat + settingsCollapsed: false, + labelSettingsOpen: false, }; const savedSettings = (() => { try { const parsed = JSON.parse(localStorage.getItem(storageKey) || "{}"); - return { + const merged = { ...defaultSettings, ...parsed, filterStyles: { ...(defaultSettings.filterStyles || {}), ...(parsed.filterStyles || {}) }, - filterExpanded: { ...(defaultSettings.filterExpanded || {}), ...(parsed.filterExpanded || {}) } + filterExpanded: { ...(defaultSettings.filterExpanded || {}), ...(parsed.filterExpanded || {}) }, + presets: Array.isArray(parsed.presets) + ? parsed.presets.slice(0, 4).concat([null, null, null, null]).slice(0, 4) + : [null, null, null, null], + waypoints: Array.isArray(parsed.waypoints) ? parsed.waypoints : [], }; + if (!parsed.sidebarState && parsed.settingsCollapsed) { + merged.sidebarState = "icons"; + } + // migrate old showTopo key + if ("showTopo" in parsed && !("showSatmap" in parsed)) { + merged.showSatmap = parsed.showTopo; + } + return merged; } catch { return { ...defaultSettings }; } @@ -100,7 +125,22 @@ const state = { activePointers: new Map(), pinchDistance: 0, bulletVisuals: new Map(), - bulletAnimationFrame: null + bulletAnimationFrame: null, + // v2 additions + measureMode: false, + measurePointA: null, + measurePointB: null, + knownPlayerAddresses: new Set(), + wasConnected: false, + contextMenuWorldPos: null, + lootSearchText: "", + listDirty: false, + tileElements: new Map(), // key -> img, O(1) tile lookup + topoElements: new Map(), // key -> img, O(1) topo lookup + satElements: new Map(), // "z:tx:ty" -> img, O(1) satellite XYZ tile lookup + tilesRafPending: false, // throttle updateVisibleTiles to one RAF per frame + cachedGridKey: null, // detects when grid needs rebuild + cachedGridNode: null, // cached SVG for the grid }; const elements = { @@ -111,35 +151,38 @@ const elements = { paths: document.getElementById("paths"), itemLabels: document.getElementById("itemLabels"), markers: document.getElementById("markers"), - playersToggle: document.getElementById("playersToggle"), - playersPanel: document.getElementById("playersPanel"), - playersClose: document.getElementById("playersClose"), - playersList: document.getElementById("playersList"), - lootToggle: document.getElementById("lootToggle"), - lootPanel: document.getElementById("lootPanel"), - lootClose: document.getElementById("lootClose"), - lootInfo: document.getElementById("lootInfo"), - lootList: document.getElementById("lootList"), - serverInfo: document.getElementById("serverInfo"), + topoTiles: document.getElementById("topo-tiles"), + satTiles: document.getElementById("sat-tiles"), + connectionDot: document.getElementById("connectionDot"), serverBadge: document.getElementById("serverBadge"), - settingsPanel: document.getElementById("settingsPanel"), - settingsPanelBody: document.getElementById("settingsPanelBody"), - settingsToggle: document.getElementById("settingsToggle"), - collapseButton: document.getElementById("collapseButton"), - labelSettingsToggle: document.getElementById("labelSettingsToggle"), - labelSettingsPanel: document.getElementById("labelSettingsPanel"), - labelSettingsBody: document.getElementById("labelSettingsBody"), - labelSettingsClose: document.getElementById("labelSettingsClose"), - labelSettingsReset: document.getElementById("labelSettingsReset"), - entityFilterList: document.getElementById("entityFilterList"), - lootFilterList: document.getElementById("lootFilterList"), + serverInfo: document.getElementById("serverInfo"), + combatToggle: document.getElementById("combatToggle"), + sidebarToggle: document.getElementById("sidebarToggle"), + sidebar: document.getElementById("sidebar"), + sidebarContent: document.getElementById("sidebarContent"), + badgePlayers: document.getElementById("badge-players"), + badgeZombies: document.getElementById("badge-zombies"), + badgeAnimals: document.getElementById("badge-animals"), + badgeVehicles: document.getElementById("badge-vehicles"), + badgeBullets: document.getElementById("badge-bullets"), + badgeLoot: document.getElementById("badge-loot"), followPlayer: document.getElementById("followPlayer"), - showLoot: document.getElementById("showLoot"), showLabels: document.getElementById("showLabels"), + showPOIs: document.getElementById("showPOIs"), + showGrid: document.getElementById("showGrid"), + showSatellite: document.getElementById("showSatellite"), + showSatmap: document.getElementById("showSatmap"), + showDistanceRings: document.getElementById("showDistanceRings"), + showMinimap: document.getElementById("showMinimap"), distanceFilter: document.getElementById("distanceFilter"), distanceValue: document.getElementById("distanceValue"), - textSize: document.getElementById("textSize"), - textSizeValue: document.getElementById("textSizeValue"), + entityFilterList: document.getElementById("entityFilterList"), + playersList: document.getElementById("playersList"), + lootSearch: document.getElementById("lootSearch"), + showLoot: document.getElementById("showLoot"), + lootFilterList: document.getElementById("lootFilterList"), + lootInfo: document.getElementById("lootInfo"), + lootList: document.getElementById("lootList"), ordinaryLootSpread: document.getElementById("ordinaryLootSpread"), ordinaryLootSpreadValue: document.getElementById("ordinaryLootSpreadValue"), groupedLootSpread: document.getElementById("groupedLootSpread"), @@ -147,7 +190,24 @@ const elements = { sameLootMergeRadius: document.getElementById("sameLootMergeRadius"), sameLootMergeRadiusValue: document.getElementById("sameLootMergeRadiusValue"), lineAnchorMode: document.getElementById("lineAnchorMode"), - mergeSameLootLabels: document.getElementById("mergeSameLootLabels") + mergeSameLootLabels: document.getElementById("mergeSameLootLabels"), + labelSettingsReset: document.getElementById("labelSettingsReset"), + textSize: document.getElementById("textSize"), + textSizeValue: document.getElementById("textSizeValue"), + minimapEl: document.getElementById("minimapEl"), + minimapImg: document.getElementById("minimapImg"), + minimapCanvas: document.getElementById("minimapCanvas"), + measureLayer: document.getElementById("measureLayer"), + measureToggle: document.getElementById("measureToggle"), + coordDisplay: document.getElementById("coordDisplay"), + gridDisplay: document.getElementById("gridDisplay"), + zoomDisplay: document.getElementById("zoomDisplay"), + ctxMenu: document.getElementById("ctxMenu"), + ctxAddWaypoint: document.getElementById("ctxAddWaypoint"), + ctxCopyCoords: document.getElementById("ctxCopyCoords"), + toastStack: document.getElementById("toastStack"), + toggle3d: document.getElementById("toggle3d"), + canvas3d: document.getElementById("canvas3d"), }; let filtersByKey = {}; @@ -220,11 +280,10 @@ function getEntityLabelLines(item) { const handItem = normalizeInlineText(item?.handItem || ""); const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : ""; const firstLine = item?.dead - ? `💀${baseLabel}${distanceText}🪦` + ? `\u{1F480}${baseLabel}${distanceText}\u{1FAA6}` : `${baseLabel}${distanceText}`; - return handItem.length > 0 - ? [firstLine.trim(), `🖐️${handItem}🔫`] + ? [firstLine.trim(), `\u{1F91A}${handItem}\u{1F52B}`] : [firstLine.trim()]; } @@ -268,7 +327,26 @@ function ensureFilterSettings() { ? state.settings.lineAnchorMode : defaultSettings.lineAnchorMode; state.settings.mergeSameLootLabels = state.settings.mergeSameLootLabels !== false; - state.settings.labelSettingsOpen = !!state.settings.labelSettingsOpen; + // v2 fields + if (!["full", "icons", "hidden"].includes(state.settings.sidebarState)) { + state.settings.sidebarState = "full"; + } + if (!["tab-map", "tab-entities", "tab-loot", "tab-settings"].includes(state.settings.activeTab)) { + state.settings.activeTab = "tab-map"; + } + if (!["dark", "darker", "solarized"].includes(state.settings.theme)) { + state.settings.theme = "dark"; + } + if (typeof state.settings.showDistanceRings !== "boolean") state.settings.showDistanceRings = false; + if (typeof state.settings.showMinimap !== "boolean") state.settings.showMinimap = false; + if (!Array.isArray(state.settings.presets)) state.settings.presets = [null, null, null, null]; + while (state.settings.presets.length < 4) state.settings.presets.push(null); + if (!Array.isArray(state.settings.waypoints)) state.settings.waypoints = []; + // Assign IDs to any waypoints missing them + let nextWpId = state.settings.waypoints.reduce((m, w) => Math.max(m, Number(w.id || 0)), 0) + 1; + for (const wp of state.settings.waypoints) { + if (!wp.id) wp.id = nextWpId++; + } } function persistSettings() { @@ -289,9 +367,7 @@ function isFavoriteLoot(item) { function toggleFavoriteLoot(itemName) { const normalized = normalizeLootName(itemName); - if (!normalized) { - return; - } + if (!normalized) return; const favorites = getFavoriteLootNames(); if (favorites.has(normalized)) { favorites.delete(normalized); @@ -300,6 +376,7 @@ function toggleFavoriteLoot(itemName) { } state.settings.favoriteLootNames = Array.from(favorites); persistSettings(); + state.listDirty = true; render(); } @@ -310,15 +387,30 @@ function apiUrl(path) { function tileUrl(tileX, tileY, retry = 0) { const query = new URLSearchParams({ x: String(tileX), y: String(tileY), retry: String(retry) }); - if (state.bootstrap?.mapId) { - query.set("mapId", state.bootstrap.mapId); - } - if (password) { - query.set("password", password); - } + if (state.bootstrap?.mapId) query.set("mapId", state.bootstrap.mapId); + if (password) query.set("password", password); return `${serverOrigin}/tile?${query.toString()}`; } +function topoTileUrl(tileX, tileY) { + const query = new URLSearchParams({ x: String(tileX), y: String(tileY) }); + if (state.bootstrap?.mapId) query.set("mapId", state.bootstrap.mapId); + if (password) query.set("password", password); + return `${serverOrigin}/topo-tile?${query.toString()}`; +} + +function satTileUrl(z, tx, ty) { + const base = state.bootstrap?.satUrl || ""; + return `${serverOrigin}${base}/${z}/${tx}/${ty}.webp`; +} + +function satZoomForScale() { + const { mapSize, satMaxZoom = 7 } = state.bootstrap; + // Target ~300 screen pixels per tile for a good balance of sharpness vs requests. + const z = Math.round(Math.log2(mapSize * state.scale / 300)); + return Math.max(0, Math.min(satMaxZoom, z)); +} + function getViewportMetrics() { const rect = elements.viewport.getBoundingClientRect(); const mapSize = Number(state.bootstrap?.mapSize || 0); @@ -331,35 +423,40 @@ function getViewportMetrics() { function constrainViewState() { const metrics = getViewportMetrics(); - if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) { - return; - } - + if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) return; const minScaleForWidth = metrics.width / metrics.mapSize; const minScaleForHeight = metrics.height / metrics.mapSize; const effectiveMinScale = Math.max(minMapScale, minScaleForWidth, minScaleForHeight); state.scale = Math.min(maxMapScale, Math.max(effectiveMinScale, state.scale)); - const scaledMapWidth = metrics.mapSize * state.scale; const scaledMapHeight = metrics.mapSize * state.scale; const minOffsetX = metrics.width - scaledMapWidth; const minOffsetY = metrics.height - scaledMapHeight; - state.offsetX = clampNumber(state.offsetX, Math.min(minOffsetX, 0), 0); state.offsetY = clampNumber(state.offsetY, Math.min(minOffsetY, 0), 0); } +function scheduleVisibleTilesUpdate() { + if (state.tilesRafPending) return; + state.tilesRafPending = true; + requestAnimationFrame(() => { + state.tilesRafPending = false; + updateVisibleTiles(); + }); +} + function applyTransform() { constrainViewState(); elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`; - updateVisibleTiles(); + elements.canvas.style.setProperty("--inv-scale", 1 / state.scale); + elements.canvas.classList.toggle("grid-labels-visible", state.scale * MGRS_STEP >= 80); + scheduleVisibleTilesUpdate(); + updateZoomDisplay(); + if (state.measureMode) drawMeasureLayer(); } function applyMapMetadata(metadata, clearTiles = false) { - if (!metadata) { - return false; - } - + if (!metadata) return false; const previous = state.bootstrap; const next = { ...(previous || {}), @@ -378,12 +475,13 @@ function applyMapMetadata(metadata, clearTiles = false) { || previous.tileSize !== next.tileSize || previous.tileCountX !== next.tileCountX || previous.tileCountY !== next.tileCountY; - state.bootstrap = next; elements.canvas.style.width = `${next.mapSize}px`; elements.canvas.style.height = `${next.mapSize}px`; elements.tiles.style.width = `${next.mapSize}px`; elements.tiles.style.height = `${next.mapSize}px`; + elements.topoTiles.style.width = `${next.mapSize}px`; + elements.topoTiles.style.height = `${next.mapSize}px`; elements.paths.setAttribute("viewBox", `0 0 ${next.mapSize} ${next.mapSize}`); elements.paths.setAttribute("width", `${next.mapSize}`); elements.paths.setAttribute("height", `${next.mapSize}`); @@ -391,223 +489,33 @@ function applyMapMetadata(metadata, clearTiles = false) { elements.itemLabels.style.height = `${next.mapSize}px`; elements.markers.style.width = `${next.mapSize}px`; elements.markers.style.height = `${next.mapSize}px`; - if (clearTiles || changed) { state.tileState = {}; + for (const img of state.tileElements.values()) img.remove(); + state.tileElements.clear(); + for (const img of state.topoElements.values()) img.remove(); + state.topoElements.clear(); + for (const img of state.satElements.values()) img.remove(); + state.satElements.clear(); + state.cachedGridKey = null; + state.cachedGridNode = null; elements.tiles.replaceChildren(); + elements.topoTiles.replaceChildren(); } - applyTransform(); return changed; } -function setSettingsCollapsed(collapsed) { - state.settings.settingsCollapsed = collapsed; - persistSettings(); - elements.settingsPanel.classList.toggle("collapsed", collapsed); - elements.settingsToggle.classList.toggle("visible", collapsed); -} - -function setPanelOpen(panelElement, open) { - if (open && panelElement === elements.playersPanel) { - elements.lootPanel.classList.remove("open"); - } - if (open && panelElement === elements.lootPanel) { - elements.playersPanel.classList.remove("open"); - } - panelElement.classList.toggle("open", open); -} - -function setLabelSettingsOpen(open) { - state.settings.labelSettingsOpen = open; - persistSettings(); - elements.labelSettingsPanel.classList.toggle("open", open); -} - -function resetLabelSettings() { - state.settings.ordinaryLootSpread = defaultSettings.ordinaryLootSpread; - state.settings.groupedLootSpread = defaultSettings.groupedLootSpread; - state.settings.sameLootMergeRadius = defaultSettings.sameLootMergeRadius; - state.settings.lineAnchorMode = defaultSettings.lineAnchorMode; - state.settings.mergeSameLootLabels = defaultSettings.mergeSameLootLabels; -} - -function syncLabelSettingsUi() { - elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread); - elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`; - elements.groupedLootSpread.value = String(state.settings.groupedLootSpread); - elements.groupedLootSpreadValue.textContent = `${Number(state.settings.groupedLootSpread).toFixed(1)}x`; - elements.sameLootMergeRadius.value = String(state.settings.sameLootMergeRadius); - elements.sameLootMergeRadiusValue.textContent = `${Math.round(Number(state.settings.sameLootMergeRadius))} px`; - elements.lineAnchorMode.value = state.settings.lineAnchorMode; - elements.mergeSameLootLabels.checked = !!state.settings.mergeSameLootLabels; - elements.labelSettingsPanel.classList.toggle("open", !!state.settings.labelSettingsOpen); -} - function centerOn(point) { - if (!point) { - return; - } + if (!point) return; const rect = elements.viewport.getBoundingClientRect(); state.offsetX = (rect.width / 2) - (point.x * state.scale); state.offsetY = (rect.height / 2) - (point.y * state.scale); applyTransform(); } -function getFilterDefinition(kind, item) { - if (kind === "loot") { - if (isFavoriteLoot(item)) { - return filtersByKey.favoriteLoot || null; - } - const filterKey = lootFilterKeyByCategory[item.lootCategory || "other"] || "lootOther"; - return filtersByKey[filterKey] || null; - } - return filtersByKey[filterKeyByKind[kind]] || null; -} - -function getLootCategoryDefinition(item) { - const filterKey = lootFilterKeyByCategory[item?.lootCategory || "other"] || "lootOther"; - return filtersByKey[filterKey] || null; -} - -function getMarkerColor(kind, item) { - const filterDefinition = getFilterDefinition(kind, item); - const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null; - return filterStyle ? filterStyle.color : (kind === "loot" && isFavoriteLoot(item) ? favoriteLootColor : "#f8fafc"); -} - -function isLootCategoryEnabled(category) { - if (!category) { - return true; - } - - if (typeof state.settings[category] !== "boolean") { - state.settings[category] = true; - } - - return state.settings[category]; -} - -function isItemVisibleByFilters(kind, item) { - if (!item) { - return false; - } - if (kind === "loot" && isFavoriteLoot(item)) { - return state.settings.showFavoriteLoot !== false; - } - if (state.settings.distanceFilter > 0 && item.distance > state.settings.distanceFilter) { - return false; - } - if (kind === "loot") { - if (!state.settings.showLoot) { - return false; - } - if (!isLootCategoryEnabled(item.lootCategory)) { - return false; - } - } - if (kind === "players" && !state.settings.showPlayers) { - return false; - } - if (kind === "zombies" && !state.settings.showZombies) { - return false; - } - if (kind === "animals" && !state.settings.showAnimals) { - return false; - } - if (kind === "vehicles" && !state.settings.showVehicles) { - return false; - } - if (kind === "bullets" && !state.settings.showBullets) { - return false; - } - if (kind === "otherEntities" && !state.settings.showOtherEntities) { - return false; - } - if (kind === "bullets") { - const phantomLifetimeMs = Number(state.settings.filterStyles?.bullets?.phantomLifetimeMs || 0); - if (item.isPhantom && phantomLifetimeMs >= 0) { - const lastSeenAtUtcMs = Number(item.lastSeenAtUtcMs || 0); - if (lastSeenAtUtcMs > 0 && (Date.now() - lastSeenAtUtcMs) > phantomLifetimeMs) { - return false; - } - } - } - if (kind === "players" && state.lastSnapshot?.localPlayer) { - const dx = item.x - state.lastSnapshot.localPlayer.x; - const dy = item.y - state.lastSnapshot.localPlayer.y; - if ((dx * dx + dy * dy) <= 9.0) { - return false; - } - } - return true; -} - -function getVisibleCollection(kind, list) { - return (list || []).filter((item) => isItemVisibleByFilters(kind, item)); -} - -function buildLabelText(item) { - const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : ""; - if (item?.kind === "player") { - return getEntityLabelLines(item).join("\n"); - } - return `${String(item?.label || "Unknown")}${distanceText}`; -} - -function isEntityKind(kind) { - return kind !== "loot"; -} - -function getRenderPriority(kind, item) { - if (isEntityKind(kind)) { - return 3; - } - return isFavoriteLoot(item) ? 2 : 1; -} - -function getLayerClass(kind, item) { - const priority = getRenderPriority(kind, item); - if (priority === 3) { - return "layer-entity"; - } - if (priority === 2) { - return "layer-favorite"; - } - return "layer-loot"; -} - -function getLabelConfig(kind, item) { - const filterDefinition = getFilterDefinition(kind, item); - const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null; - if (kind === "bullets") { - return { - filterDefinition, - filterStyle, - showLabel: false, - textSize: filterStyle ? filterStyle.textSize : state.settings.textSize - }; - } - const showLabel = !!(state.settings.showLabels && item.label && (!filterStyle || filterStyle.showLabel)); - return { - filterDefinition, - filterStyle, - showLabel, - textSize: filterStyle ? filterStyle.textSize : state.settings.textSize - }; -} - -function useFixedScreenLabel(kind, itemOrGroup) { - if (kind === "players") { - return true; - } - if (kind === "loot") { - if (Object.prototype.hasOwnProperty.call(itemOrGroup || {}, "priority")) { - return itemOrGroup?.priority === 2; - } - return isFavoriteLoot(itemOrGroup); - } - return false; +function clampNumber(value, min, max) { + return Math.min(max, Math.max(min, value)); } function clampScale(value) { @@ -615,7 +523,6 @@ function clampScale(value) { if (metrics.mapSize <= 0 || metrics.width <= 0 || metrics.height <= 0) { return Math.min(maxMapScale, Math.max(minMapScale, value)); } - const minScaleForWidth = metrics.width / metrics.mapSize; const minScaleForHeight = metrics.height / metrics.mapSize; return Math.min(maxMapScale, Math.max(minMapScale, minScaleForWidth, minScaleForHeight, value)); @@ -640,6 +547,462 @@ function zoomAt(clientX, clientY, nextScale) { applyTransform(); } +function screenToWorld(clientX, clientY) { + const rect = elements.viewport.getBoundingClientRect(); + return { + x: (clientX - rect.left - state.offsetX) / state.scale, + y: (clientY - rect.top - state.offsetY) / state.scale, + }; +} + +function updateCoordDisplay(worldX, worldY) { + if (!state.bootstrap || state.bootstrap.mapSize <= 0) return; + const mapSize = state.bootstrap.mapSize; + elements.coordDisplay.textContent = `${Math.round(worldX)}, ${Math.round(mapSize - worldY)}`; + const eastStr = String(Math.max(0, Math.floor(worldX / 100))).padStart(3, "0"); + const northStr = String(Math.max(0, Math.floor((mapSize - worldY) / 100))).padStart(3, "0"); + elements.gridDisplay.textContent = `${eastStr} ${northStr}`; +} + +function updateZoomDisplay() { + elements.zoomDisplay.textContent = `${Math.round(state.scale * 100)}%`; +} + +// ── Sidebar / tab / theme ────────────────────────────────────────────────── + +function setSidebarState(newState) { + state.settings.sidebarState = newState; + persistSettings(); + document.body.classList.remove("sidebar-icons", "sidebar-hidden"); + if (newState === "icons") document.body.classList.add("sidebar-icons"); + else if (newState === "hidden") document.body.classList.add("sidebar-hidden"); + setTimeout(() => applyTransform(), 200); +} + +function switchTab(tabId) { + state.settings.activeTab = tabId; + persistSettings(); + for (const btn of document.querySelectorAll(".tab-btn")) { + btn.classList.toggle("active", btn.dataset.tab === tabId); + } + for (const panel of document.querySelectorAll(".tab-panel")) { + panel.classList.toggle("active", panel.id === tabId); + } + state.listDirty = true; + render(); +} + +function setTheme(theme) { + state.settings.theme = theme; + persistSettings(); + document.documentElement.setAttribute("data-theme", theme); + for (const btn of document.querySelectorAll(".theme-btn")) { + btn.classList.toggle("active", btn.dataset.theme === theme); + } +} + +// ── Toast system ─────────────────────────────────────────────────────────── + +function showToast(message, type) { + const toasts = elements.toastStack; + while (toasts.children.length >= 3) { + toasts.removeChild(toasts.firstChild); + } + const el = document.createElement("div"); + el.className = `toast${type ? ` toast-${type}` : ""}`; + el.textContent = message; + toasts.appendChild(el); + requestAnimationFrame(() => el.classList.add("show")); + setTimeout(() => { + el.classList.remove("show"); + el.addEventListener("transitionend", () => el.remove(), { once: true }); + }, 4000); +} + +// ── Preset system ────────────────────────────────────────────────────────── + +function getBuiltinPreset(index) { + if (index === 0) { + return { showPlayers: true, showZombies: false, showAnimals: false, showVehicles: false, showBullets: true, showLoot: false, showOtherEntities: false, showFavoriteLoot: false, isCombatMode: true, showDistanceRings: true, showPOIs: false }; + } + if (index === 1) { + return { showPlayers: true, showZombies: false, showAnimals: false, showVehicles: false, showBullets: false, showLoot: true, showOtherEntities: false, showFavoriteLoot: true, isCombatMode: false, showDistanceRings: false, showPOIs: true }; + } + if (index === 2) { + return { showPlayers: true, showZombies: true, showAnimals: true, showVehicles: true, showBullets: true, showLoot: true, showOtherEntities: true, showFavoriteLoot: true, isCombatMode: false, showDistanceRings: false, showPOIs: true }; + } + return null; +} + +function snapshotCurrentFilterState() { + const snap = { + showPlayers: state.settings.showPlayers, + showZombies: state.settings.showZombies, + showAnimals: state.settings.showAnimals, + showVehicles: state.settings.showVehicles, + showBullets: state.settings.showBullets, + showLoot: state.settings.showLoot, + showOtherEntities: state.settings.showOtherEntities, + showFavoriteLoot: state.settings.showFavoriteLoot, + isCombatMode: state.settings.isCombatMode, + showDistanceRings: state.settings.showDistanceRings, + showPOIs: state.settings.showPOIs, + }; + for (const def of filterDefinitions.filter((d) => d.kind === "loot")) { + if (typeof state.settings[def.visibleKey] === "boolean") { + snap[def.visibleKey] = state.settings[def.visibleKey]; + } + } + return snap; +} + +function applyFilterState(data) { + if (!data) return; + for (const [key, val] of Object.entries(data)) { + if (key in defaultSettings) { + state.settings[key] = val; + } + } +} + +function loadPreset(index) { + const preset = state.settings.presets[index] || getBuiltinPreset(index); + if (!preset) return; + applyFilterState(preset); + state.settings.activePreset = index; + persistSettings(); + updateCombatToggleUi(); + syncAllCheckboxes(); + updatePresetButtons(); + state.listDirty = true; + render(); + const names = ["Combat", "Loot", "Full", "Custom"]; + showToast(`Preset: ${names[index] || ""}`, ""); +} + +function savePreset(index) { + state.settings.presets[index] = snapshotCurrentFilterState(); + state.settings.activePreset = index; + persistSettings(); + updatePresetButtons(); + const names = ["Combat", "Loot", "Full", "Custom"]; + showToast(`Saved: ${names[index] || ""}`, "success"); +} + +function updatePresetButtons() { + for (const btn of document.querySelectorAll(".preset-btn")) { + btn.classList.toggle("active", Number(btn.dataset.preset) === state.settings.activePreset); + } +} + +// ── Measurement tool ─────────────────────────────────────────────────────── + +function toggleMeasureMode(active) { + state.measureMode = active; + state.measurePointA = null; + state.measurePointB = null; + document.body.classList.toggle("measure-active", active); + elements.measureToggle.classList.toggle("active", active); + resizeMeasureLayer(); + if (!active) { + const ctx = elements.measureLayer.getContext("2d"); + ctx.clearRect(0, 0, elements.measureLayer.width, elements.measureLayer.height); + } +} + +function resizeMeasureLayer() { + const rect = elements.viewport.getBoundingClientRect(); + elements.measureLayer.width = Math.max(1, Math.round(rect.width)); + elements.measureLayer.height = Math.max(1, Math.round(rect.height)); +} + +function drawMeasureLayer(cursorClientX, cursorClientY) { + resizeMeasureLayer(); + const canvas = elements.measureLayer; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!state.measureMode) return; + + function wsToScreen(wx, wy) { + return { x: wx * state.scale + state.offsetX, y: wy * state.scale + state.offsetY }; + } + + if (!state.measurePointA) { + if (cursorClientX !== undefined) { + const rect = elements.viewport.getBoundingClientRect(); + const cx = cursorClientX - rect.left; + const cy = cursorClientY - rect.top; + ctx.strokeStyle = "rgba(232,160,32,0.6)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx - 10, cy); ctx.lineTo(cx + 10, cy); + ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy + 10); + ctx.stroke(); + } + return; + } + + const as = wsToScreen(state.measurePointA.x, state.measurePointA.y); + ctx.fillStyle = "#e8a020"; + ctx.beginPath(); + ctx.arc(as.x, as.y, 5, 0, Math.PI * 2); + ctx.fill(); + + let ex, ey, endWorld; + if (state.measurePointB) { + const bs = wsToScreen(state.measurePointB.x, state.measurePointB.y); + ex = bs.x; ey = bs.y; + endWorld = state.measurePointB; + } else if (cursorClientX !== undefined) { + const rect = elements.viewport.getBoundingClientRect(); + ex = cursorClientX - rect.left; + ey = cursorClientY - rect.top; + endWorld = screenToWorld(cursorClientX, cursorClientY); + } else { + return; + } + + ctx.strokeStyle = "#e8a020"; + ctx.lineWidth = 2; + ctx.setLineDash([8, 4]); + ctx.beginPath(); + ctx.moveTo(as.x, as.y); + ctx.lineTo(ex, ey); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = "#e8a020"; + ctx.beginPath(); + ctx.arc(ex, ey, 5, 0, Math.PI * 2); + ctx.fill(); + + const dist = Math.hypot(endWorld.x - state.measurePointA.x, endWorld.y - state.measurePointA.y); + const midX = (as.x + ex) / 2; + const midY = (as.y + ey) / 2; + const text = `${Math.round(dist)} m`; + ctx.font = "bold 13px Arial, sans-serif"; + const tw = ctx.measureText(text).width; + ctx.fillStyle = "rgba(13,15,18,0.85)"; + ctx.fillRect(midX - tw / 2 - 4, midY - 10, tw + 8, 18); + ctx.fillStyle = "#e8a020"; + ctx.fillText(text, midX - tw / 2, midY + 4); +} + +// ── Context menu ─────────────────────────────────────────────────────────── + +function findNearestWaypoint(worldX, worldY, radius) { + let nearest = null; + let nearestDist = radius; + for (const wp of (state.settings.waypoints || [])) { + if (typeof wp.x !== "number" || typeof wp.y !== "number") continue; + const dist = Math.hypot(wp.x - worldX, wp.y - worldY); + if (dist < nearestDist) { nearestDist = dist; nearest = wp; } + } + return nearest; +} + +function showContextMenu(x, y, worldX, worldY) { + state.contextMenuWorldPos = { x: worldX, y: worldY }; + const old = document.getElementById("ctxRemoveWaypoint"); + if (old) old.remove(); + const near = findNearestWaypoint(worldX, worldY, 30 / state.scale); + if (near) { + const removeBtn = document.createElement("button"); + removeBtn.id = "ctxRemoveWaypoint"; + removeBtn.className = "ctx-item"; + removeBtn.textContent = `Remove${near.label ? `: ${near.label}` : " waypoint"}`; + removeBtn.addEventListener("click", () => { + state.settings.waypoints = state.settings.waypoints.filter((w) => w.id !== near.id); + persistSettings(); + hideContextMenu(); + state.listDirty = true; + render(); + }); + elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint); + } + elements.ctxMenu.style.left = `${x}px`; + elements.ctxMenu.style.top = `${y}px`; + elements.ctxMenu.classList.add("open"); +} + +function hideContextMenu() { + elements.ctxMenu.classList.remove("open"); + const old = document.getElementById("ctxRemoveWaypoint"); + if (old) old.remove(); +} + +// ── Minimap ──────────────────────────────────────────────────────────────── + +function loadMinimapImage() { + if (!state.bootstrap?.mapId) return; + elements.minimapImg.style.display = ""; + elements.minimapImg.src = apiUrl(`/map-image?mapId=${encodeURIComponent(state.bootstrap.mapId)}`); + elements.minimapImg.addEventListener("error", () => { elements.minimapImg.style.display = "none"; }, { once: true }); +} + +function updateMinimap() { + const show = state.settings.showMinimap && !!state.bootstrap && state.bootstrap.mapSize > 0; + elements.minimapEl.style.display = show ? "" : "none"; + if (!show) return; + + const mm = elements.minimapCanvas; + const dpr = window.devicePixelRatio || 1; + const CSS = 160; + mm.width = Math.round(CSS * dpr); + mm.height = Math.round(CSS * dpr); + const mapSize = state.bootstrap.mapSize; + const mmScale = CSS / mapSize; + + const ctx = mm.getContext("2d"); + // Work in CSS pixel units so all coordinates stay in [0, 160] + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, CSS, CSS); + + const rect = elements.viewport.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const minX = Math.max(0, -state.offsetX / state.scale) * mmScale; + const minY = Math.max(0, -state.offsetY / state.scale) * mmScale; + const maxX = Math.min(mapSize, (-state.offsetX + rect.width) / state.scale) * mmScale; + const maxY = Math.min(mapSize, (-state.offsetY + rect.height) / state.scale) * mmScale; + const vw = maxX - minX; + const vh = maxY - minY; + ctx.fillStyle = "rgba(255,255,255,0.07)"; + ctx.fillRect(minX, minY, vw, vh); + ctx.strokeStyle = "rgba(255,255,255,0.65)"; + ctx.lineWidth = 1 / dpr; + ctx.strokeRect(minX + 0.5 / dpr, minY + 0.5 / dpr, vw - 1 / dpr, vh - 1 / dpr); + } + + if (state.lastSnapshot?.hasLocalPlayer && state.lastSnapshot?.localPlayer) { + const lp = state.lastSnapshot.localPlayer; + const px = lp.x * mmScale; + const py = lp.y * mmScale; + ctx.beginPath(); + ctx.arc(px, py, 3, 0, Math.PI * 2); + ctx.fillStyle = "#ef4444"; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 1 / dpr; + ctx.stroke(); + } + + for (const wp of (state.settings.waypoints || [])) { + if (typeof wp.x !== "number" || typeof wp.y !== "number") continue; + ctx.beginPath(); + ctx.arc(wp.x * mmScale, wp.y * mmScale, 2, 0, Math.PI * 2); + ctx.fillStyle = "#e2e8f0"; + ctx.fill(); + } +} + +// ── Filter / visibility ──────────────────────────────────────────────────── + +function getFilterDefinition(kind, item) { + if (kind === "loot") { + if (isFavoriteLoot(item)) return filtersByKey.favoriteLoot || null; + const filterKey = lootFilterKeyByCategory[item.lootCategory || "other"] || "lootOther"; + return filtersByKey[filterKey] || null; + } + return filtersByKey[filterKeyByKind[kind]] || null; +} + +function getLootCategoryDefinition(item) { + const filterKey = lootFilterKeyByCategory[item?.lootCategory || "other"] || "lootOther"; + return filtersByKey[filterKey] || null; +} + +function getMarkerColor(kind, item) { + const filterDefinition = getFilterDefinition(kind, item); + const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null; + return filterStyle ? filterStyle.color : (kind === "loot" && isFavoriteLoot(item) ? favoriteLootColor : "#f8fafc"); +} + +function isLootCategoryEnabled(category) { + if (!category) return true; + if (typeof state.settings[category] !== "boolean") state.settings[category] = true; + return state.settings[category]; +} + +function isItemVisibleByFilters(kind, item) { + if (!item) return false; + if (state.settings.isCombatMode && kind !== "players" && kind !== "bullets") return false; + if (kind === "loot" && isFavoriteLoot(item)) { + return state.settings.showFavoriteLoot !== false; + } + if (state.settings.distanceFilter > 0 && item.distance > state.settings.distanceFilter) return false; + if (kind === "loot") { + if (!state.settings.showLoot) return false; + if (!isLootCategoryEnabled(item.lootCategory)) return false; + if (state.lootSearchText.length > 0) { + if (!normalizeLootName(item.label || "").includes(state.lootSearchText)) return false; + } + } + if (kind === "players" && !state.settings.showPlayers) return false; + if (kind === "zombies" && !state.settings.showZombies) return false; + if (kind === "animals" && !state.settings.showAnimals) return false; + if (kind === "vehicles" && !state.settings.showVehicles) return false; + if (kind === "bullets" && !state.settings.showBullets) return false; + if (kind === "otherEntities" && !state.settings.showOtherEntities) return false; + if (kind === "bullets") { + const phantomLifetimeMs = Number(state.settings.filterStyles?.bullets?.phantomLifetimeMs || 0); + if (item.isPhantom && phantomLifetimeMs >= 0) { + const lastSeenAtUtcMs = Number(item.lastSeenAtUtcMs || 0); + if (lastSeenAtUtcMs > 0 && (Date.now() - lastSeenAtUtcMs) > phantomLifetimeMs) return false; + } + } + if (kind === "players" && state.lastSnapshot?.localPlayer) { + const dx = item.x - state.lastSnapshot.localPlayer.x; + const dy = item.y - state.lastSnapshot.localPlayer.y; + if ((dx * dx + dy * dy) <= 9.0) return false; + } + return true; +} + +function getVisibleCollection(kind, list) { + return (list || []).filter((item) => isItemVisibleByFilters(kind, item)); +} + +// ── Label layout (PRESERVED VERBATIM) ──────────────────────────────────── + +function buildLabelText(item) { + const distanceText = Number.isFinite(Number(item?.distance)) && Number(item.distance) >= 0 ? ` ${Math.round(Number(item.distance))}m` : ""; + if (item?.kind === "player") return getEntityLabelLines(item).join("\n"); + return `${String(item?.label || "Unknown")}${distanceText}`; +} + +function isEntityKind(kind) { return kind !== "loot"; } + +function getRenderPriority(kind, item) { + if (isEntityKind(kind)) return 3; + return isFavoriteLoot(item) ? 2 : 1; +} + +function getLayerClass(kind, item) { + const priority = getRenderPriority(kind, item); + if (priority === 3) return "layer-entity"; + if (priority === 2) return "layer-favorite"; + return "layer-loot"; +} + +function getLabelConfig(kind, item) { + const filterDefinition = getFilterDefinition(kind, item); + const filterStyle = filterDefinition ? state.settings.filterStyles[filterDefinition.key] : null; + if (kind === "bullets") { + return { filterDefinition, filterStyle, showLabel: false, textSize: filterStyle ? filterStyle.textSize : state.settings.textSize }; + } + const showLabel = !!(state.settings.showLabels && item.label && (!filterStyle || filterStyle.showLabel)); + return { filterDefinition, filterStyle, showLabel, textSize: filterStyle ? filterStyle.textSize : state.settings.textSize }; +} + +function useFixedScreenLabel(kind, itemOrGroup) { + if (kind === "players") return true; + if (kind === "loot") { + if (Object.prototype.hasOwnProperty.call(itemOrGroup || {}, "priority")) return itemOrGroup?.priority === 2; + return isFavoriteLoot(itemOrGroup); + } + return false; +} + function getActivePointerPair() { const pointers = Array.from(state.activePointers.values()); return pointers.length >= 2 ? [pointers[0], pointers[1]] : null; @@ -652,10 +1015,7 @@ function getPointerDistance(first, second) { } function getPointerMidpoint(first, second) { - return { - clientX: (first.clientX + second.clientX) / 2, - clientY: (first.clientY + second.clientY) / 2 - }; + return { clientX: (first.clientX + second.clientX) / 2, clientY: (first.clientY + second.clientY) / 2 }; } function updateMapDrag(clientX, clientY) { @@ -677,7 +1037,6 @@ function getPlacementPenalty(rect, occupiedRects, viewportRect) { for (const occupied of occupiedRects) { overlapArea += getRectIntersectionArea(rect, occupied); } - const overflowX = Math.max(0, viewportRect.left - rect.left) + Math.max(0, rect.right - viewportRect.right); const overflowY = Math.max(0, viewportRect.top - rect.top) + Math.max(0, rect.bottom - viewportRect.bottom); return (overlapArea * 10) + ((overflowX + overflowY) * 120); @@ -699,30 +1058,17 @@ function estimateLabelSize(text, fontSize) { ascent = Math.max(ascent, Math.ceil(metrics.actualBoundingBoxAscent || (safeFontSize * 0.8))); descent = Math.max(descent, Math.ceil(metrics.actualBoundingBoxDescent || (safeFontSize * 0.2))); } - return { - width: Math.max(1, width), - height: Math.max(1, ascent + descent + ((lines.length - 1) * lineHeight)) - }; + return { width: Math.max(1, width), height: Math.max(1, ascent + descent + ((lines.length - 1) * lineHeight)) }; } - const longestLineLength = lines.reduce((maxLength, line) => Math.max(maxLength, String(line || "").length), 0); - return { - width: Math.max(1, Math.round(longestLineLength * safeFontSize * 0.52)), - height: Math.max(1, Math.round(safeFontSize + ((lines.length - 1) * lineHeight))) - }; + return { width: Math.max(1, Math.round(longestLineLength * safeFontSize * 0.52)), height: Math.max(1, Math.round(safeFontSize + ((lines.length - 1) * lineHeight))) }; } function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) { const size = estimateLabelSize(text, fontSize); - if (!useFixedScreenLabel(kind, itemOrGroup)) { - return size; - } - + if (!useFixedScreenLabel(kind, itemOrGroup)) return size; const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1); - return { - width: size.width / effectiveScale, - height: size.height / effectiveScale - }; + return { width: size.width / effectiveScale, height: size.height / effectiveScale }; } function buildPinnedLabelCandidates(entry, size) { @@ -735,13 +1081,7 @@ function buildPinnedLabelCandidates(entry, size) { "top-center": { left: -Math.round(size.width / 2), top: -(size.height + radius + gap), distance: gap }, "bottom-center": { left: -Math.round(size.width / 2), top: radius + gap, distance: gap } }; - - return [ - sideCandidates["middle-left"], - sideCandidates["middle-right"], - sideCandidates["top-center"], - sideCandidates["bottom-center"] - ]; + return [sideCandidates["middle-left"], sideCandidates["middle-right"], sideCandidates["top-center"], sideCandidates["bottom-center"]]; } function buildScreenRect(screenX, screenY, candidate, size, scale) { @@ -753,41 +1093,12 @@ function buildScreenRect(screenX, screenY, candidate, size, scale) { }; } -function clampNumber(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - function isPointOutsideViewport(screenX, screenY, viewportRect) { - return screenX < viewportRect.left - || screenX > viewportRect.right - || screenY < viewportRect.top - || screenY > viewportRect.bottom; + return screenX < viewportRect.left || screenX > viewportRect.right || screenY < viewportRect.top || screenY > viewportRect.bottom; } function getBlockedViewportRects() { - const viewportRect = elements.viewport.getBoundingClientRect(); - const blockedElements = []; - if (!elements.settingsPanel.classList.contains("collapsed")) { - blockedElements.push(elements.settingsPanel); - } - if (elements.playersPanel.classList.contains("open")) { - blockedElements.push(elements.playersPanel); - } - if (elements.lootPanel.classList.contains("open")) { - blockedElements.push(elements.lootPanel); - } - if (elements.labelSettingsPanel.classList.contains("open")) { - blockedElements.push(elements.labelSettingsPanel); - } - - return blockedElements - .map((element) => element.getBoundingClientRect()) - .map((rect) => ({ - left: rect.left - viewportRect.left, - top: rect.top - viewportRect.top, - right: rect.right - viewportRect.left, - bottom: rect.bottom - viewportRect.top - })); + return []; } function buildViewportEdgeCandidates(screenX, screenY, size, viewportRect) { @@ -803,32 +1114,11 @@ function buildViewportEdgeCandidates(screenX, screenY, size, viewportRect) { const overflowTop = screenY < viewportRect.top ? viewportRect.top - screenY : Number.POSITIVE_INFINITY; const overflowBottom = screenY > viewportRect.bottom ? screenY - viewportRect.bottom : Number.POSITIVE_INFINITY; const edgeCandidates = [ - { - edge: "left", - overflow: overflowLeft, - left: availableLeft, - top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom)) - }, - { - edge: "right", - overflow: overflowRight, - left: availableRight, - top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom)) - }, - { - edge: "top", - overflow: overflowTop, - left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)), - top: availableTop - }, - { - edge: "bottom", - overflow: overflowBottom, - left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)), - top: availableBottom - } + { edge: "left", overflow: overflowLeft, left: availableLeft, top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom)) }, + { edge: "right", overflow: overflowRight, left: availableRight, top: clampNumber(screenY - (labelScreenHeight / 2), Math.min(availableTop, availableBottom), Math.max(availableTop, availableBottom)) }, + { edge: "top", overflow: overflowTop, left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)), top: availableTop }, + { edge: "bottom", overflow: overflowBottom, left: clampNumber(screenX - (labelScreenWidth / 2), Math.min(availableLeft, availableRight), Math.max(availableLeft, availableRight)), top: availableBottom } ]; - return edgeCandidates .sort((lhs, rhs) => lhs.overflow - rhs.overflow) .map((candidate) => ({ @@ -864,12 +1154,8 @@ function buildPriorityLabelGroups(entries) { })) .sort((lhs, rhs) => { const byKey = lhs.key.localeCompare(rhs.key); - if (byKey !== 0) { - return byKey; - } - if (lhs.screenY !== rhs.screenY) { - return lhs.screenY - rhs.screenY; - } + if (byKey !== 0) return byKey; + if (lhs.screenY !== rhs.screenY) return lhs.screenY - rhs.screenY; return lhs.screenX - rhs.screenX; }); @@ -878,20 +1164,13 @@ function buildPriorityLabelGroups(entries) { for (const candidate of groupableEntries) { let bestGroup = null; let bestDistance = Number.POSITIVE_INFINITY; - for (const group of groups) { - if (group.key !== candidate.key) { - continue; - } + if (group.key !== candidate.key) continue; const dx = candidate.screenX - group.screenX; const dy = candidate.screenY - group.screenY; const distance = Math.hypot(dx, dy); - if (distance <= clusterDistance && distance < bestDistance) { - bestDistance = distance; - bestGroup = group; - } + if (distance <= clusterDistance && distance < bestDistance) { bestDistance = distance; bestGroup = group; } } - if (!bestGroup) { groups.push({ key: candidate.key, @@ -910,7 +1189,6 @@ function buildPriorityLabelGroups(entries) { }); continue; } - bestGroup.entries.push(candidate.entry); const count = bestGroup.entries.length; bestGroup.anchorX = ((bestGroup.anchorX * (count - 1)) + candidate.entry.item.x) / count; @@ -968,12 +1246,8 @@ function buildOrdinaryLootLabelGroups(entries) { })) .sort((lhs, rhs) => { const byLabel = lhs.labelKey.localeCompare(rhs.labelKey); - if (byLabel !== 0) { - return byLabel; - } - if (lhs.screenY !== rhs.screenY) { - return lhs.screenY - rhs.screenY; - } + if (byLabel !== 0) return byLabel; + if (lhs.screenY !== rhs.screenY) return lhs.screenY - rhs.screenY; return lhs.screenX - rhs.screenX; }); @@ -982,20 +1256,13 @@ function buildOrdinaryLootLabelGroups(entries) { for (const candidate of groupableEntries) { let bestGroup = null; let bestDistance = Number.POSITIVE_INFINITY; - for (const group of groups) { - if (group.labelKey !== candidate.labelKey) { - continue; - } + if (group.labelKey !== candidate.labelKey) continue; const dx = candidate.screenX - group.screenX; const dy = candidate.screenY - group.screenY; const distance = Math.hypot(dx, dy); - if (distance <= clusterDistance && distance < bestDistance) { - bestDistance = distance; - bestGroup = group; - } + if (distance <= clusterDistance && distance < bestDistance) { bestDistance = distance; bestGroup = group; } } - if (!bestGroup) { groups.push({ labelKey: candidate.labelKey, @@ -1010,7 +1277,6 @@ function buildOrdinaryLootLabelGroups(entries) { }); continue; } - bestGroup.entries.push(candidate.entry); const count = bestGroup.entries.length; bestGroup.anchorX = ((bestGroup.anchorX * (count - 1)) + candidate.entry.item.x) / count; @@ -1026,12 +1292,10 @@ function buildOrdinaryLootLabelGroups(entries) { const screenDx = ((entry.item.x * state.scale) + state.offsetX) - group.screenX; const screenDy = ((entry.item.y * state.scale) + state.offsetY) - group.screenY; maxScreenRadius = Math.max(maxScreenRadius, Math.hypot(screenDx, screenDy)); - const worldDx = entry.item.x - group.anchorX; const worldDy = entry.item.y - group.anchorY; maxWorldRadius = Math.max(maxWorldRadius, Math.hypot(worldDx, worldDy)); } - group.clusterRadiusScreen = maxScreenRadius; group.clusterRadiusWorld = maxWorldRadius; group.labelText = group.entries.length > 1 ? `${group.baseText} x${group.entries.length}` : group.baseText; @@ -1046,7 +1310,6 @@ function buildOrdinaryGroupLabelCandidates(group, size) { const radius = Math.max(8, group.clusterRadiusWorld + 8); const primaryGap = Math.round(14 * spreadMultiplier); const secondaryGap = Math.round(26 * spreadMultiplier); - return [ { left: radius + primaryGap, top: 2, anchorX: 2, anchorY: 0, distance: primaryGap }, { left: radius + primaryGap, top: -size.height - 2, anchorX: 2, anchorY: size.height, distance: primaryGap }, @@ -1068,7 +1331,6 @@ function getLabelAnchorPoints(labelPlacement, labelSize) { const middleY = top + (height / 2); const right = left + width; const bottom = top + height; - return { "top-left": { x: left, y: top }, "top-center": { x: middleX, y: top }, @@ -1086,90 +1348,49 @@ function getLabelLineTarget(labelPlacement, labelSize, sourceX = 0, sourceY = 0) if (state.settings.lineAnchorMode !== "auto-nearest") { return anchorPoints[state.settings.lineAnchorMode] || anchorPoints[defaultSettings.lineAnchorMode]; } - let bestPoint = anchorPoints["bottom-center"]; let bestDistance = Number.POSITIVE_INFINITY; - for (const point of Object.values(anchorPoints)) { const dx = point.x - sourceX; const dy = point.y - sourceY; const distance = (dx * dx) + (dy * dy); - if (distance < bestDistance) { - bestDistance = distance; - bestPoint = point; - } + if (distance < bestDistance) { bestDistance = distance; bestPoint = point; } } - return bestPoint; } function computeLabelPlacements(entries) { - const viewportRect = { - left: 0, - top: 0, - right: elements.viewport.clientWidth, - bottom: elements.viewport.clientHeight - }; + const viewportRect = { left: 0, top: 0, right: elements.viewport.clientWidth, bottom: elements.viewport.clientHeight }; const occupiedRects = [...getBlockedViewportRects()]; const priorityGrouping = buildPriorityLabelGroups(entries); - for (const entry of entries) { - entry.labelPlacement = null; - } + for (const entry of entries) entry.labelPlacement = null; for (const entry of entries) { - if (!entry.label.showLabel) { - continue; - } - + if (!entry.label.showLabel) continue; const priority = getRenderPriority(entry.kind, entry.item); const nearAnchor = priority >= 2; const size = getScaledLabelLayoutSize(entry.kind, entry.item, entry.labelText, entry.label.textSize); const screenX = (entry.item.x * state.scale) + state.offsetX; const screenY = (entry.item.y * state.scale) + state.offsetY; const useViewportEdgePlacement = entry.kind === "players" && isPointOutsideViewport(screenX, screenY, viewportRect); - - if (!nearAnchor) { - continue; - } - - if (priorityGrouping.groupedEntries.has(entry)) { - continue; - } - + if (!nearAnchor) continue; + if (priorityGrouping.groupedEntries.has(entry)) continue; const candidates = useViewportEdgePlacement ? buildViewportEdgeCandidates(screenX, screenY, size, viewportRect) : buildPinnedLabelCandidates(entry, size); let bestPlacement = null; let bestScore = Number.POSITIVE_INFINITY; - for (const candidate of candidates) { const rect = buildScreenRect(screenX, screenY, candidate, size, state.scale); const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect); const score = penalty + (candidate.distance * (nearAnchor ? 16 : 8)); - if (score < bestScore) { - bestScore = score; - bestPlacement = { ...candidate, rect }; - } + if (score < bestScore) { bestScore = score; bestPlacement = { ...candidate, rect }; } } - - if (!bestPlacement) { - entry.labelPlacement = null; - continue; - } - - if (!nearAnchor && bestScore > 1400) { - entry.labelPlacement = null; - continue; - } - + if (!bestPlacement) { entry.labelPlacement = null; continue; } + if (!nearAnchor && bestScore > 1400) { entry.labelPlacement = null; continue; } occupiedRects.push(bestPlacement.rect); - entry.labelPlacement = { - left: bestPlacement.left, - top: bestPlacement.top, - anchorX: bestPlacement.anchorX, - anchorY: bestPlacement.anchorY - }; + entry.labelPlacement = { left: bestPlacement.left, top: bestPlacement.top, anchorX: bestPlacement.anchorX, anchorY: bestPlacement.anchorY }; } for (const group of priorityGrouping.groups) { @@ -1178,77 +1399,45 @@ function computeLabelPlacements(entries) { const candidates = buildPinnedLabelCandidates(groupEntry, size); let bestPlacement = null; let bestScore = Number.POSITIVE_INFINITY; - for (const candidate of candidates) { const rect = buildScreenRect(group.screenX, group.screenY, candidate, size, state.scale); const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect); const score = penalty + (candidate.distance * 20); - if (score < bestScore) { - bestScore = score; - bestPlacement = { ...candidate, rect }; - } + if (score < bestScore) { bestScore = score; bestPlacement = { ...candidate, rect }; } } - - if (!bestPlacement) { - group.labelPlacement = null; - continue; - } - + if (!bestPlacement) { group.labelPlacement = null; continue; } occupiedRects.push(bestPlacement.rect); - group.labelPlacement = { - left: bestPlacement.left, - top: bestPlacement.top, - anchorX: bestPlacement.anchorX, - anchorY: bestPlacement.anchorY - }; + group.labelPlacement = { left: bestPlacement.left, top: bestPlacement.top, anchorX: bestPlacement.anchorX, anchorY: bestPlacement.anchorY }; } const groups = buildOrdinaryLootLabelGroups(entries); - for (const group of groups) { const size = estimateLabelSize(group.labelText, group.label.textSize); const candidates = buildOrdinaryGroupLabelCandidates(group, size); let bestPlacement = null; let bestScore = Number.POSITIVE_INFINITY; - for (const candidate of candidates) { const rect = buildScreenRect(group.screenX, group.screenY, candidate, size, state.scale); const penalty = getPlacementPenalty(rect, occupiedRects, viewportRect); const score = penalty + (candidate.distance * 8); - if (score < bestScore) { - bestScore = score; - bestPlacement = { ...candidate, rect }; - } + if (score < bestScore) { bestScore = score; bestPlacement = { ...candidate, rect }; } } - - if (!bestPlacement) { - group.labelPlacement = null; - continue; - } - + if (!bestPlacement) { group.labelPlacement = null; continue; } occupiedRects.push(bestPlacement.rect); - group.labelPlacement = { - left: bestPlacement.left, - top: bestPlacement.top, - anchorX: bestPlacement.anchorX, - anchorY: bestPlacement.anchorY - }; + group.labelPlacement = { left: bestPlacement.left, top: bestPlacement.top, anchorX: bestPlacement.anchorX, anchorY: bestPlacement.anchorY }; } - return { - ordinaryGroups: groups, - priorityGroups: priorityGrouping.groups - }; + return { ordinaryGroups: groups, priorityGroups: priorityGrouping.groups }; } +// ── Filter card rendering ────────────────────────────────────────────────── + function renderFilterCards() { - const buildCard = (definition) => { + const buildEntityCard = (definition) => { const style = state.settings.filterStyles[definition.key]; const visible = !!state.settings[definition.visibleKey]; const expanded = !!state.settings.filterExpanded[definition.key]; - const card = document.createElement("section"); - card.className = `filter-card${expanded ? " expanded" : ""}`; const directionControl = definition.key === "players" ? ` ` : ""; const bulletControls = definition.key === "bullets" ? ` -