diff --git a/TODO.md b/TODO.md index 5fa5263..6fe6cad 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,13 @@ # TODO -(none) -item esp doesnt render past a certain distance -inventory detection -ballistic prediction -combat mode on web radar -create a grid coord system for web radar \ No newline at end of file +## TO FIX +- item esp doesnt render past a certain distance +- inventory detection +- ballistic prediction +- combat mode on web radar + - combat mode makes web radar only show players and maybe does some other combat only visuals +- create a grid coord system for web radar + +## 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 diff --git a/external/lumin/framework/gui.cpp b/external/lumin/framework/gui.cpp index c499f90..850100b 100644 --- a/external/lumin/framework/gui.cpp +++ b/external/lumin/framework/gui.cpp @@ -220,47 +220,86 @@ static void lumin_kv(const char* label, const std::string& value, const c_vec4& draw->text_clipped(w->DrawList, font->get(inter_medium, 11), rect.Min, rect.Max - s_(12, 0), draw->get_clr(value_clr), value.data(), 0, 0, { 1.f, 0.5f }); } -// ---- ESP / Items content -------------------------------------------------- +// ---- Tab content ---------------------------------------------------------- -static void render_esp_tab(const visual_widget_filter& w, float section_height) +static void render_aim_tab(const visual_widget_filter& w, float section_height) { if (!g_menu) return; - gui->begin_group(); + begin_full_section("Aim Assistance", section_height); { - begin_visual_section("Entities", section_height); - w.checkbox("Players", "Show player ESP", g_menu->showPlayers); - 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); - w.checkbox("Corpses", "Show dead bodies", g_menu->showCorpses); - 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("Skeleton Debug", "Label every named bone", g_menu->debugSkeleton); - end_visual_section(); + lumin_heading("Prediction"); + w.checkbox("Ballistic Dot", "Cyan dot showing where to aim to hit centre mass (accounts for gravity + drag)", g_menu->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); } - gui->end_group(); - - gui->sameline(); - - 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("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"); - end_visual_section(); - } - gui->end_group(); + end_visual_section(); } -static void render_items_tab(const visual_widget_filter& w, float section_height) +static void render_visuals_tab(const visual_widget_filter& w, float section_height) +{ + if (!g_menu) + return; + + if (var->gui.sub_tab_stored == 1) + { + // ---- Players sub-tab ---- + 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("Skeleton Debug", "Label every named bone", g_menu->debugSkeleton); + end_visual_section(); + } + gui->end_group(); + + gui->sameline(); + + 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"); + end_visual_section(); + } + gui->end_group(); + } + else + { + // ---- World sub-tab ---- + 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); + end_visual_section(); + } + gui->end_group(); + + gui->sameline(); + + 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"); + end_visual_section(); + } + gui->end_group(); + } +} + +static void render_loot_tab(const visual_widget_filter& w, float section_height) { if (!g_menu || !g_menu->itemCategories) return; @@ -320,13 +359,17 @@ static void render_items_tab(const visual_widget_filter& w, float section_height gui->end_group(); } +static constexpr const char* kRadarDomain = "radar.charliecharliekirky.christmas"; + static void render_info_tab(float section_height) { const c_vec4 green = c_col(75, 225, 145).Value; const c_vec4 orange = c_col(255, 175, 75).Value; + const c_vec4 red = c_col(225, 70, 70).Value; - begin_full_section("Info", section_height); + begin_full_section("Info", section_height, true); { + // ---- Server ---- lumin_heading("Server"); if (g_menu && g_menu->connected) { @@ -353,26 +396,39 @@ static void render_info_tab(float section_height) { lumin_kv("Status", (g_menu && !g_menu->status.empty()) ? g_menu->status : "Offline", orange); } - } - end_visual_section(); -} -static constexpr const char* kRadarDomain = "radar.charliecharliekirky.christmas"; + // ---- DMA Handle ---- + gui->dummy(c_vec2(0, s_(6))); + lumin_heading("DMA Handle"); + if (g_menu) + { + lumin_kv("Handle", g_menu->dmaAttached ? "Attached" : "Detached", + g_menu->dmaAttached ? green : red); -static void render_radar_tab(float section_height) -{ - begin_full_section("Radar", section_height, true); - { + char buf[64]; + ImFormatString(buf, IM_ARRAYSIZE(buf), "%.2f MB/s", g_menu->dmaReadMBps); + lumin_kv("Read Speed", buf, clr->accent.Value); + + ImFormatString(buf, IM_ARRAYSIZE(buf), "%.0f /s", g_menu->dmaScatterOps); + lumin_kv("Scatter Calls", buf, clr->accent.Value); + + ImFormatString(buf, IM_ARRAYSIZE(buf), "%.3f GB", g_menu->dmaTotalGB); + lumin_kv("Total Read", buf, clr->text.Value); + + ImFormatString(buf, IM_ARRAYSIZE(buf), "%llu", static_cast(g_menu->dmaTotalOps)); + lumin_kv("Total Ops", buf, clr->text.Value); + } + + // ---- Web Radar ---- + gui->dummy(c_vec2(0, s_(6))); lumin_heading("Web Radar"); lumin_note("Open this address in a browser:"); gui->dummy(c_vec2(0, s_(4))); if (widgets->action_button(kRadarDomain, "copy")) ImGui::SetClipboardText(kRadarDomain); - if (g_menu) { - gui->dummy(c_vec2(0, s_(6))); - lumin_heading("Connection"); + gui->dummy(c_vec2(0, s_(4))); lumin_kv("Port", std::to_string(g_menu->webPort), clr->accent.Value); lumin_kv("Password", "Set in config.cfg", clr->text.Value); } @@ -380,24 +436,7 @@ static void render_radar_tab(float section_height) end_visual_section(); } -static void render_exit_tab(float section_height) -{ - begin_full_section("Exit", section_height); - { - lumin_heading("Exit Application"); - lumin_note("This will close the overlay and exit the program."); - gui->dummy(c_vec2(0, s_(10))); - - const c_col saved_accent = clr->accent; - clr->accent = c_col(225, 70, 70); - if (widgets->primary_button("Exit", "back") && g_menu && g_menu->onExit) - g_menu->onExit(); - clr->accent = saved_accent; - } - end_visual_section(); -} - -static void render_settings_tab(float section_height) +static void render_config_tab(float section_height) { if (!g_menu) return; @@ -416,7 +455,7 @@ static void render_settings_tab(float section_height) buffers_ready = true; } - begin_full_section("Settings", section_height, true); + begin_full_section("Config", section_height, true); { lumin_heading("Overlay (Monitor) Resolution"); lumin_note("Set to your MONITOR size. 0 x 0 = auto-detect."); @@ -449,6 +488,16 @@ static void render_settings_tab(float section_height) gui->dummy(c_vec2(0, s_(4))); if (widgets->primary_button("Save Config", "active") && g_menu->onSaveConfig) g_menu->onSaveConfig(); + + gui->dummy(c_vec2(0, s_(10))); + lumin_heading("Exit"); + lumin_note("This will close the overlay and exit the program."); + gui->dummy(c_vec2(0, s_(4))); + const c_col saved_accent = clr->accent; + clr->accent = c_col(225, 70, 70); + if (widgets->primary_button("Exit", "back") && g_menu->onExit) + g_menu->onExit(); + clr->accent = saved_accent; } end_visual_section(); } @@ -491,7 +540,7 @@ void c_gui::render() gui->set_pos(c_vec2(s_(visual_outer_padding), s_(top_row_y)), pos_all); gui->begin_content("TopBrand", c_vec2(s_(visual_sidebar_width), s_(top_row_height)), s_(0, 0), s_(0, 0), window_flags_no_scrollbar | window_flags_no_background, child_flags_none); { - widgets->brand_header("KarachiHook", var->gui.profile_name[0] != '\0' ? var->gui.profile_name : "DayZ Overlay"); + widgets->brand_header("DayZ DMA", var->gui.profile_name[0] != '\0' ? var->gui.profile_name : "DayZ Overlay"); } gui->end_content(); @@ -512,12 +561,11 @@ void c_gui::render() } } - widgets->tab_button("ESP", "visuals", 1); - widgets->tab_button("Items", "loot", 2); - widgets->tab_button("Radar", "world", 3); - widgets->tab_button("Settings", "polish", 4); - widgets->tab_button("Exit", "back", 5); - widgets->tab_button("Info", "stats", 6); + widgets->tab_button("Aim", "aim", 1); + widgets->tab_button("Visuals", "visuals", 2); + widgets->tab_button("Loot", "loot", 3); + widgets->tab_button("Info", "stats", 4); + widgets->tab_button("Config", "polish", 5); { static c_vec4 sidebar_indicator = c_vec4(0, 0, 0, 0); @@ -555,6 +603,29 @@ void c_gui::render() gui->begin_content("FeatureHeader", c_vec2(s_(feature_width), s_(feature_header_height)), s_(8, 4), s_(8, 0), window_flags_no_scrollbar | window_flags_no_background, child_flags_none); { const float search_width = s_(178.f); + + if (var->gui.tab == 2) // Visuals — show sub-tab row + { + // Animated pill highlight behind the selected sub_tab_button. + static c_vec4 pill_anim = c_vec4(0, 0, 0, 0); + c_window* pill_win = gui->get_window(); + gui->easing(pill_anim, g_pill_selected_rect, 18.f, dynamic_easing); + if (pill_anim.z > pill_anim.x + 1.f) + draw->rect_filled(pill_win->DrawList, + c_vec2(pill_anim.x, pill_anim.y), c_vec2(pill_anim.z, pill_anim.w), + draw->get_clr(clr->widget), s_(9.1f)); + + widgets->sub_tab_button("Players", "player", 1); + gui->sameline(0.f, s_(4.f)); + widgets->sub_tab_button("World", "world", 2); + // Keep on the same row; jump cursor to right edge for search. + ImGui::SameLine(ImGui::GetContentRegionMax().x - search_width); + } + else + { + ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - search_width); + } + widgets->search_field("Search", var->gui.feature_search, IM_ARRAYSIZE(var->gui.feature_search), c_vec2(search_width, s_(32.f))); } gui->end_content(); @@ -572,12 +643,11 @@ void c_gui::render() switch (var->gui.tab) { - case 1: render_esp_tab(visual_widgets, section_height); break; - case 2: render_items_tab(visual_widgets, section_height); break; - case 3: render_radar_tab(section_height); break; - case 4: render_settings_tab(section_height); break; - case 5: render_exit_tab(section_height); break; - case 6: render_info_tab(section_height); break; + case 1: render_aim_tab(visual_widgets, section_height); break; + case 2: render_visuals_tab(visual_widgets, section_height); break; + case 3: render_loot_tab(visual_widgets, section_height); break; + case 4: render_info_tab(section_height); break; + case 5: render_config_tab(section_height); break; default: break; } diff --git a/src/Config.h b/src/Config.h index 47967d6..e26fb00 100644 --- a/src/Config.h +++ b/src/Config.h @@ -17,11 +17,13 @@ struct OverlayConfig { bool showSkeleton = true; // bone skeleton lines // ---- Extra ESP options ---- - bool showHeadDot = false; // dot at head bone for players/zombies - bool showCorpses = false; // show dead players/zombies (dimmed grey) - 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 showHeadDot = false; // dot at head bone for players/zombies + bool showCorpses = false; // show dead players/zombies (dimmed grey) + 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 // ---- Draw-distance limits (metres) ---- float playerMaxDist = 1000.0f; diff --git a/src/Memory/VmmAccessor.cpp b/src/Memory/VmmAccessor.cpp index 845f678..5cd6635 100644 --- a/src/Memory/VmmAccessor.cpp +++ b/src/Memory/VmmAccessor.cpp @@ -36,6 +36,14 @@ bool VmmAccessor::Initialize(bool useMemoryMap) { void VmmAccessor::ForceRefresh() { if (!IsInitialized()) return; VMMDLL_ConfigSet(m_dma->handle.get(), VMMDLL_OPT_REFRESH_ALL, 1); + // VMMDLL_OPT_REFRESH_ALL resets per-process DTB back to the kernel default, + // losing the fix_cr3 result. Re-apply immediately so subsequent reads still + // use the correct UserDirectoryTableBase paging context. + if (m_process && !m_processName.empty()) { + if (!m_process->fix_cr3(m_processName)) { + spdlog::warn("VmmAccessor: fix_cr3 re-apply failed after ForceRefresh."); + } + } } // ------------------------------------------------------------------------- @@ -64,6 +72,7 @@ bool VmmAccessor::TryFindProcess(const std::string& processName, std::unique_lock lk(m_accessMutex); m_process = std::make_unique(*m_dma, processName); m_attachedPid = static_cast(pid); + m_processName = processName; outPid = m_attachedPid; // Apply the CR3 fix so reads use UserDirectoryTableBase instead of the @@ -119,7 +128,9 @@ bool VmmAccessor::ReadRaw(uint32_t /*pid*/, uint64_t address, if (!buf || size == 0) return false; std::shared_lock lk(m_accessMutex); if (!m_process) return false; - return m_process->read(address, buf, size); + bool ok = m_process->read(address, buf, size); + if (ok) m_bytesReadTotal.fetch_add(size, std::memory_order_relaxed); + return ok; } // ------------------------------------------------------------------------- @@ -158,6 +169,12 @@ bool VmmAccessor::ScatterRead(uint32_t pid, std::vector& entries) m_process->execute_scatter(hScatter, static_cast(pid)); m_process->close_scatter(hScatter); + + // Account for all scheduled bytes (VolkDMA zero-fills failures; total is still representative). + size_t totalBytes = 0; + for (const auto& e : entries) totalBytes += e.size; + m_bytesReadTotal.fetch_add(totalBytes, std::memory_order_relaxed); + m_scatterOpsTotal.fetch_add(1, std::memory_order_relaxed); return true; } @@ -275,6 +292,39 @@ std::string VmmAccessor::ReadArmaString(uint32_t pid, uint64_t address, // ReadCString // ------------------------------------------------------------------------- +// ------------------------------------------------------------------------- +// GetStats +// ------------------------------------------------------------------------- + +DmaStats VmmAccessor::GetStats() const { + using Clock = std::chrono::steady_clock; + + const uint64_t bytes = m_bytesReadTotal.load(std::memory_order_relaxed); + const uint64_t ops = m_scatterOpsTotal.load(std::memory_order_relaxed); + + const auto now = Clock::now(); + const double dt = std::chrono::duration(now - m_statsLastSample).count(); + + // Recompute at most 10 Hz to avoid thrashing on high-frequency overlay calls. + if (dt >= 0.1) { + const double bytesDelta = static_cast(bytes - m_statsLastBytes); + const double opsDelta = static_cast(ops - m_statsLastOps); + m_statsMBps = static_cast(bytesDelta / dt / (1024.0 * 1024.0)); + m_statsOpsPs = static_cast(opsDelta / dt); + m_statsLastSample = now; + m_statsLastBytes = bytes; + m_statsLastOps = ops; + } + + DmaStats s; + s.attached = IsAttached(); + s.readMBps = m_statsMBps; + s.scatterOpsPs = m_statsOpsPs; + s.totalGB = static_cast(bytes) / (1024.f * 1024.f * 1024.f); + s.totalOps = ops; + return s; +} + 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 d2a6fb8..c5d0dd2 100644 --- a/src/Memory/VmmAccessor.h +++ b/src/Memory/VmmAccessor.h @@ -1,4 +1,6 @@ #pragma once +#include +#include #include #include #include @@ -7,6 +9,15 @@ #include #include // memcpy +// Rolling 1-second DMA throughput snapshot, refreshed by GetStats(). +struct DmaStats { + float readMBps = 0.f; // bytes read per second (MB) + float scatterOpsPs = 0.f; // scatter-read calls per second + float totalGB = 0.f; // total bytes read since attach (GB) + uint64_t totalOps = 0; // total scatter calls since attach + bool attached = false; +}; + // Windows must come before vmmdll / VolkDMA headers. #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN @@ -63,6 +74,10 @@ public: /// Force a full refresh of VMMDLL's internal caches. void ForceRefresh(); + /// Sample rolling throughput and return a DmaStats snapshot. + /// Rates are computed over the interval since the previous call (min 100 ms). + [[nodiscard]] DmaStats GetStats() const; + // ------------------------------------------------------------------ // Memory reads // ------------------------------------------------------------------ @@ -121,6 +136,7 @@ private: std::unique_ptr m_dma; std::unique_ptr m_process; uint32_t m_attachedPid = 0; + std::string m_processName; // stored so fix_cr3 can be re-run after ForceRefresh // Guards m_process / m_dma lifetime against concurrent reads. // Reads (ReadRaw / ScatterRead) take a SHARED lock so they run in parallel; @@ -129,6 +145,17 @@ private: // runtime thread without a use-after-free when a reconnect re-creates Process. mutable std::shared_mutex m_accessMutex; + // Cumulative counters — incremented from any thread via atomics. + std::atomic m_bytesReadTotal{0}; + std::atomic m_scatterOpsTotal{0}; + + // Mutable cache for GetStats() rolling-window computation (overlay thread only). + mutable std::chrono::steady_clock::time_point m_statsLastSample{}; + mutable uint64_t m_statsLastBytes = 0; + mutable uint64_t m_statsLastOps = 0; + mutable float m_statsMBps = 0.f; + mutable float m_statsOpsPs = 0.f; + /// Low-level read via VolkDMA Process. Returns false on read error. bool ReadRaw(uint32_t pid, uint64_t address, void* buf, size_t size); diff --git a/src/Offsets.h b/src/Offsets.h index dc21fb3..03884ff 100644 --- a/src/Offsets.h +++ b/src/Offsets.h @@ -131,6 +131,15 @@ namespace Offsets { // 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] diff --git a/src/Overlay/GameOverlay.cpp b/src/Overlay/GameOverlay.cpp index c670220..8c6a53c 100644 --- a/src/Overlay/GameOverlay.cpp +++ b/src/Overlay/GameOverlay.cpp @@ -65,10 +65,15 @@ static SkeletonBones LerpBones(const SkeletonBones& a, const SkeletonBones& b, f return out; } -// Update the history when a new DMA read is detected (head bone moved). +// 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; @@ -81,9 +86,6 @@ static void UpdateBoneHistory(GameOverlay::BoneHistory& h, float dist2 = dx*dx + dy*dy + dz*dz; // Reject implausible jumps: > 3 m in one update cycle can't happen at // any in-game speed — it means the scatter read returned garbage. - // Keeping the old history prevents one stale read from corrupting the - // extrapolation state; the DMA-side eviction (cache->valid=false) will - // fix the pointer within the next boneRefreshMs window. if (dist2 > 9.0f) return; if (dist2 > 1e-8f) { h.prev = h.curr; @@ -239,9 +241,11 @@ void GameOverlay::SyncConfig() { 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.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; @@ -261,6 +265,9 @@ void GameOverlay::Draw(float w, float h) { // 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. for (const auto& p : u.players) @@ -308,11 +315,13 @@ void GameOverlay::Draw(float w, float h) { 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.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.playerMaxDist = &m_playerMaxDist; bridge.animalMaxDist = &m_animalMaxDist; @@ -350,6 +359,15 @@ void GameOverlay::Draw(float w, float h) { bridge.webPort = m_webPort; bridge.webUrls = m_webUrls; + { + auto ds = m_service.GetDmaStats(); + bridge.dmaAttached = ds.attached; + bridge.dmaReadMBps = ds.readMBps; + bridge.dmaScatterOps = ds.scatterOpsPs; + bridge.dmaTotalGB = ds.totalGB; + bridge.dmaTotalOps = ds.totalOps; + } + bridge.onSaveConfig = [this]() { SyncConfig(); m_cfg.Save(m_cfgPath); @@ -387,6 +405,7 @@ void GameOverlay::DrawESP(float w, float h, const RuntimeUpdate& u, const Camera 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); @@ -565,7 +584,7 @@ void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const Came if (!p.isDead) { auto hit = m_playerBoneHistory.find(p.address); if (hit != m_playerBoneHistory.end() && hit->second.initialized - && (m_frameTimeMs - hit->second.currMs) < 500LL) { + && (m_frameTimeMs - hit->second.lastReadMs) < 3000LL) { smoothBones = GetSmoothedBones(hit->second, m_frameTimeMs); hasSmoothBones = true; } @@ -610,7 +629,7 @@ void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const Came } if (hits >= 4) { - constexpr float kPad = 4.0f; + constexpr float kPad = 8.0f; bx0 = minX - kPad; by0 = minY - kPad; bx1 = maxX + kPad; @@ -642,11 +661,8 @@ void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const Came if (m_showHeadDot && !p.isDead && hasSmoothBones) { if (Proj(cam, smoothBones.head, hbx, hby, w, h)) { - // Radius = 15% of the rendered body height so the circle scales - // naturally with distance. The head-neck pixel-distance approach - // collapses to minimum past ~30 m; box-height fraction stays - // proportional to the target at any range. - headRadius = std::clamp((by1 - by0) * 0.15f, 2.0f, 18.0f); + // 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); } @@ -724,6 +740,132 @@ void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const Came 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)); + } } } @@ -779,7 +921,7 @@ void GameOverlay::DrawZombies(ImDrawList* dl, const RuntimeUpdate& u, const Came auto zhit = m_zombieBoneHistory.find(z.address); if (zhit != m_zombieBoneHistory.end() && zhit->second.initialized - && (m_frameTimeMs - zhit->second.currMs) < 500LL) { + && (m_frameTimeMs - zhit->second.lastReadMs) < 3000LL) { SkeletonBones zsm = GetSmoothedBones(zhit->second, m_frameTimeMs); if (m_showSkeleton) DrawSkeleton(dl, zsm, cam, w, h, kYellow, true); diff --git a/src/Overlay/GameOverlay.h b/src/Overlay/GameOverlay.h index d145b33..bbff408 100644 --- a/src/Overlay/GameOverlay.h +++ b/src/Overlay/GameOverlay.h @@ -8,6 +8,7 @@ #include #include "Config.h" #include "Runtime/DayZRuntimeService.h" +#include "Web/BulletTrackCache.h" struct ImDrawList; @@ -18,7 +19,8 @@ public: SkeletonBones prev{}; SkeletonBones curr{}; int64_t prevMs = 0; - int64_t currMs = 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; }; @@ -37,9 +39,11 @@ public: 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_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; @@ -115,10 +119,12 @@ private: 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_debugSkeleton = false; // draws named bone dots for the closest player + 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 // Per-category item enabled map (key = filterKey, missing = enabled) std::map m_itemCategories; @@ -131,6 +137,17 @@ private: // Used to avoid a single failed position read causing a visible blink. 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 + }; + std::unordered_map m_playerVel; + + // Bullet trail tracker — owned by the overlay, fed from each update's bullet list. + BulletTrackCache m_bulletTracks; + // ---- Bone interpolation / extrapolation ---- std::unordered_map m_playerBoneHistory; std::unordered_map m_zombieBoneHistory; @@ -173,11 +190,12 @@ private: // back into m_cfg prior to a Save. void SyncConfig(); - 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); - void DrawAnimals(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); - void DrawZombies(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); - void DrawItems (ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); + 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); + void DrawAnimals (ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); + void DrawZombies (ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h); + 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, diff --git a/src/Overlay/MenuBridge.h b/src/Overlay/MenuBridge.h index 76f3804..b9e5f78 100644 --- a/src/Overlay/MenuBridge.h +++ b/src/Overlay/MenuBridge.h @@ -24,11 +24,13 @@ struct MenuBridge { 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* showWeapon = nullptr; + bool* showHealthBar = nullptr; + bool* showHealthNumber = nullptr; + bool* showCorpses = nullptr; + bool* debugSkeleton = nullptr; + bool* showBallisticDot = nullptr; + bool* showBulletTrails = nullptr; // ---- ESP draw-distance limits ---- float* playerMaxDist = nullptr; @@ -49,6 +51,13 @@ struct MenuBridge { std::size_t nPlayers = 0, nAnimals = 0, nZombies = 0, nVehicles = 0, nItems = 0, 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 + // ---- Web radar ---- int webPort = 7777; std::vector webUrls; diff --git a/src/Overlay/OverlayWindow.cpp b/src/Overlay/OverlayWindow.cpp index b314688..16ccb52 100644 --- a/src/Overlay/OverlayWindow.cpp +++ b/src/Overlay/OverlayWindow.cpp @@ -87,7 +87,7 @@ bool OverlayWindow::CreateWin32Window() { m_hwnd = CreateWindowExW( WS_EX_APPWINDOW, - kWndClass, L"KarachiHook", + kWndClass, L"DayZ DMA", WS_POPUP | WS_VISIBLE, 0, 0, m_w, m_h, nullptr, nullptr, wc.hInstance, nullptr diff --git a/src/Runtime/DayZRuntimeService.cpp b/src/Runtime/DayZRuntimeService.cpp index 7262fdd..7a7a326 100644 --- a/src/Runtime/DayZRuntimeService.cpp +++ b/src/Runtime/DayZRuntimeService.cpp @@ -626,10 +626,11 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { auto healthResolver = [&](uint64_t addr) -> float { if (Offsets::Player::Health == 0) return -1.0f; float hp = -1.0f; + // Quality field stores [0.25, 0.50, 0.75, 1.0] — scale to [0, 100]. if (!m_memory.TryReadValue(pid, addr + Offsets::Player::Health, hp) - || hp < 0.0f || hp > 100.0f) + || hp <= 0.0f || hp > 1.01f) return -1.0f; - return hp; + return hp * 100.0f; }; // --- wornClothesResolver --- @@ -695,6 +696,71 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { update.localPlayerEntityAddress = m_localPlayer.entityAddress; update.localPlayerPosition = m_localPlayer.position; 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. + uint64_t inv = 0, weapon = 0, mag = 0, ammoType = 0; + uint8_t handsValid = 0; + bool invOk = m_memory.TryReadPointer(pid, + m_localPlayer.entityAddress + Offsets::Inventory::Base, inv) + && MemoryValidation::IsValidUserAddress(inv); + bool handOk = invOk + && m_memory.TryReadValue(pid, + inv + Offsets::Inventory::HandItemValid, handsValid) + && handsValid; + bool weaponOk = handOk + && m_memory.TryReadPointer(pid, + inv + Offsets::Inventory::Hands, weapon) + && MemoryValidation::IsValidUserAddress(weapon); + bool magOk = weaponOk + && m_memory.TryReadPointer(pid, + weapon + Offsets::Weapon::MagazinePtr, mag) + && MemoryValidation::IsValidUserAddress(mag); + bool ammoOk = magOk + && m_memory.TryReadPointer(pid, + mag + Offsets::Magazine::AmmoTypePtr, ammoType) + && 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) { + update.localWeaponInitSpeed = initSpeed; + update.localWeaponAirFriction = std::max(0.0f, airFriction); + } else if (!m_ammoChainLoggedOnce) { + m_ammoChainLoggedOnce = true; + spdlog::warn("BallisticDot: ammo chain resolved but initSpeed={:.1f} " + "(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; + 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); + } else { + m_ammoChainLoggedOnce = true; + spdlog::warn("BallisticDot: weapon chain fail at Magazine::AmmoTypePtr " + "(mag=0x{:X}+0x{:X}) — verify offset", mag, Offsets::Magazine::AmmoTypePtr); + } + } } if (m_camera.valid) update.camera = m_camera; @@ -789,25 +855,31 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) { } } - // Store as baseline for the 16ms bone-scatter refresh. + // Store as baseline for the bone-scatter refresh. Do NOT publish + // here — bones are still skeleton.valid=false at this point. The + // bone tick (below, every boneRefreshMs) copies m_liveUpdate, fills + // in the skeletons, and is the sole publisher. This prevents the + // overlay from ever seeing a bones=0 frame mid-session. m_liveUpdate = std::move(update); - PublishUpdate(m_liveUpdate); m_state.nextPlayersRefresh = Clock::now() + Ms(m_config.playersRefreshMs); } // ------------------------------------------------------------------ - // 11b. Bone-scatter refresh (~16 ms / ~60 Hz). - // Re-reads all skeleton bones using the warm pointer cache and - // a single DMA scatter call, then publishes with fresh bones. + // 11b. Bone-scatter refresh (~4 ms / ~250 Hz). + // Copies the current entity baseline, re-reads all skeleton bones + // via DMA scatter, then publishes the full snapshot (players, + // zombies, items, bullets, etc.) with fresh bones attached. + // This is the ONLY place PublishUpdate is called during the live + // loop, so the overlay never sees a bones=0 snapshot. // ------------------------------------------------------------------ if (now >= m_state.nextBoneRefresh) { - if (!m_liveUpdate.players.empty() || !m_liveUpdate.zombies.empty()) { - RuntimeUpdate boneTick = m_liveUpdate; // copy entity list + RuntimeUpdate boneTick = m_liveUpdate; // copy full entity baseline + if (!boneTick.players.empty() || !boneTick.zombies.empty()) { RefreshBonesScatter(pid, boneTick.players, boneTick.zombies); - PublishUpdate(boneTick); } + PublishUpdate(boneTick); // always publish — includes items/bullets m_state.nextBoneRefresh = Clock::now() + Ms(m_config.boneRefreshMs); } @@ -990,6 +1062,8 @@ void DayZRuntimeService::ResetAllReaders() { m_boneAnimOffset = -1; m_boneMatrixOffset = -1; m_bonePointerCache.clear(); + m_ammoChainLoggedOnce = false; + m_boneDiagCount = 0; m_state.scoreboard.clear(); m_state.nearEntities.clear(); @@ -1042,27 +1116,29 @@ void DayZRuntimeService::RefreshBonesScatter( 8, 11, 13 // rightUpLeg(9), rightLeg(12), rightFoot(14) }; - // Player max bone index = 99 (lHand), zombie = 60 (rHand). - // Read the full contiguous block from BoneTranslationOffset to cover all - // needed indices in one scatter entry instead of 15-16 individual reads. - // Block size: (maxIndex+1) * BoneStride bytes; zombies fit within players' block. - static constexpr int kMaxBoneIndex = 100; - static constexpr size_t kBoneBlockSize = kMaxBoneIndex * Offsets::Skeleton::BoneStride; // 4800 B - // Per-entity bone read buffers. Must be stable in memory before we point // scatter entries at them — reserve upfront, then only push_back. + // + // We used to read bones as a single 4800-byte contiguous block per entity. + // That block spans 2 OS pages; when any page is not physically contiguous in + // RAM the VMMDLL scatter read silently zero-fills it — same failure mode as + // the SigScanner's 4 MB sequential reads. The fix: one 12-byte scatter entry + // per bone (xyz translation only). Small reads are always single-page and + // reliably succeed even under DMA page fragmentation. + static constexpr int kMaxBones = 16; // max(15 player, 16 zombie) bones per entity + struct PerEntityData { - float vsMatrix[12]; // VS transform (48 B) at vsAddr+8 - uint8_t boneBlock[kBoneBlockSize]; // contiguous bone matrix block + float vsMatrix[12]; // VS transform (48 B) at vsAddr+8 + float boneXYZ[kMaxBones][3]; // individual local bone translations BonePointerCache* cache; bool isZombie; SkeletonBones* outSkel; - std::optional* outPos; // pointer back into boneTick entity list + std::optional* outPos; // pointer back into boneTick entity list int numBones; const int* boneIdxs; - Vector3 entityPos; // fallback position if VS translation is zero - uint64_t addr; // entity address (for loss logging) - const std::string* label; // player nickname; nullptr for zombies + Vector3 entityPos; // fallback position if VS translation is zero + uint64_t addr; // entity address (for loss logging) + const std::string* label; // player nickname; nullptr for zombies }; std::vector eds; @@ -1090,35 +1166,20 @@ void DayZRuntimeService::RefreshBonesScatter( spdlog::info("Skeleton lost: '{}' — {} [0x{:X}]", who, reason, addr); }; - // matBase is re-read from animClass every this many bone ticks while the cache - // entry is valid. At the default 4 ms bone cadence, 60 ticks ≈ 240 ms. - constexpr uint32_t kMatBaseResyncTicks = 60; - // Resolve and cache the pointer chain for one entity (sequential reads, // amortised over the lifetime of the entity). + // matBase freshening is handled by the pre-pass scatter below — ensureCached + // only needs to establish the animClass pointer and initial matBase once. auto ensureCached = [&](uint64_t entAddr, bool isZombie) -> BonePointerCache* { { auto it = m_bonePointerCache.find(entAddr); if (it != m_bonePointerCache.end()) { auto& c = it->second; - if (c.valid) { - if (++c.syncAge >= kMatBaseResyncTicks) { - c.syncAge = 0; - uint64_t freshMat = 0; - if (m_memory.TryReadValue(pid, - c.animClass + static_cast(m_boneMatrixOffset), - freshMat) && freshMat) { - c.matBase = freshMat; - } else { - c.valid = false; // animClass itself went stale - } - } - if (c.valid) return &c; - } + if (c.valid) return &c; - // Cache was invalidated (stale matBase). If animClass is still - // cached we can skip the full 4-read pointer walk and just re-read - // vsAddr + matBase — 2 reads instead of 4, no probe retry needed. + // Cache was invalidated (stale matBase/animClass). + // If animClass is still plausibly valid, try a quick 2-read recovery + // before falling through to the full probe. if (c.animClass != 0 && m_boneAnimOffset >= 0 && m_boneMatrixOffset >= 0) { uint64_t freshVs = 0, freshMat = 0; if (m_memory.TryReadValue(pid, @@ -1131,7 +1192,6 @@ void DayZRuntimeService::RefreshBonesScatter( c.valid = true; return &c; } - // animClass also stale — clear it and fall through to full re-resolve. c.animClass = 0; } } @@ -1229,8 +1289,33 @@ void DayZRuntimeService::RefreshBonesScatter( BonePointerCache* cache = ensureCached(e.address, isZombie); if (!cache) { - // Pointer chain unresolved this tick — if the entity previously - // had a skeleton, that's a real disappearance worth logging. + // Pointer chain unresolved — engine deactivated animation for this + // entity (out of frustum / LOD). If we have a recent cached pose, + // apply it here so the skeleton doesn't blink off between updates. + auto cit = m_bonePointerCache.find(e.address); + if (cit != m_bonePointerCache.end()) { + auto& c = cit->second; + if (c.lastSkelMs > 0 + && nowMsBones - c.lastSkelMs < 3000 + && c.lastSkel.valid) + { + // Use entity list position as the current reference; apply delta. + const float odx = e.position ? e.position->x - c.lastEntityPos.x : 0.f; + const float ody = e.position ? e.position->y - c.lastEntityPos.y : 0.f; + const float odz = e.position ? e.position->z - c.lastEntityPos.z : 0.f; + SkeletonBones adj = c.lastSkel; + auto shift = [&](Vector3& v){ v.x+=odx; v.y+=ody; v.z+=odz; }; + shift(adj.neck); shift(adj.head); shift(adj.spine); + shift(adj.pelvis); + shift(adj.leftShoulder); shift(adj.leftElbow); shift(adj.leftHand); + shift(adj.rightShoulder); shift(adj.rightElbow); shift(adj.rightHand); + shift(adj.leftHip); shift(adj.leftKnee); shift(adj.leftAnkle); + shift(adj.rightHip); shift(adj.rightKnee); shift(adj.rightAnkle); + e.skeleton = adj; + noteBoneOk(e.address); + continue; + } + } noteBoneLoss(e.address, label, isZombie, "pointer chain unresolved"); continue; } @@ -1246,8 +1331,8 @@ void DayZRuntimeService::RefreshBonesScatter( ed.entityPos = *e.position; ed.addr = e.address; ed.label = label; - std::memset(ed.vsMatrix, 0, sizeof(ed.vsMatrix)); - std::memset(ed.boneBlock, 0, sizeof(ed.boneBlock)); + std::memset(ed.vsMatrix, 0, sizeof(ed.vsMatrix)); + std::memset(ed.boneXYZ, 0, sizeof(ed.boneXYZ)); } }; @@ -1256,41 +1341,204 @@ void DayZRuntimeService::RefreshBonesScatter( collect(players, false, pd * pd); collect(zombies, true, zd * zd); + const bool diag = (m_boneDiagCount < kBoneDiagPasses); + if (diag) + spdlog::info("BoneDiag[{}] collect: eds={} players={} zombies={}", + m_boneDiagCount, eds.size(), players.size(), zombies.size()); + if (eds.empty()) return; - // Build scatter list — 2 entries per entity: - // [0] VS matrix (48 B at vsAddr+8) - // [1] bone block (4800 B at matBase+BoneTranslationOffset, covers indices 0-99) - // Previously: 17 entries per entity (1 VS + 16 individual bone reads). - // Now: 2 entries per entity regardless of bone count — 85% fewer DMA operations. + // ── Pre-pass: freshen matBase + vsAddr for every entity in one scatter round-trip ── + // matBase at animClass+matrixOff is double-buffered by the engine animation system + // and can change on every frame (~16ms at 60Hz). vsAddr (VisualState pointer) can + // also change when the entity re-spawns or its visual representation is rebuilt. + // Reading both here — immediately before the bone scatter — ensures the subsequent + // read uses the current buffer pointer and the correct VS matrix address. + { + // Two 8-byte reads per entity (matBase + vsAddr); store in parallel vectors so + // ScatterEntry destination pointers remain stable during the scatter. + std::vector freshMatBases(eds.size(), 0); + std::vector freshVsAddrs(eds.size(), 0); + std::vector prescan; + prescan.reserve(eds.size() * 2); + + for (size_t i = 0; i < eds.size(); ++i) { + if (eds[i].cache->animClass == 0) continue; + prescan.push_back({ + eds[i].cache->animClass + static_cast(m_boneMatrixOffset), + &freshMatBases[i], + sizeof(uint64_t) + }); + prescan.push_back({ + eds[i].addr + Offsets::Common::VisualState, + &freshVsAddrs[i], + sizeof(uint64_t) + }); + } + + if (!prescan.empty()) { + m_memory.ScatterRead(pid, prescan); + int freshOk = 0, freshZero = 0; + for (size_t i = 0; i < eds.size(); ++i) { + if (eds[i].cache->animClass == 0) continue; + + const uint64_t fresh = freshMatBases[i]; + if (fresh) { + eds[i].cache->matBase = fresh; + ++freshOk; + } else { + ++freshZero; + } + + const uint64_t freshVs = freshVsAddrs[i]; + if (freshVs && MemoryValidation::IsValidUserAddress(freshVs)) { + eds[i].cache->vsAddr = freshVs; + } + } + if (diag) + spdlog::info("BoneDiag[{}] pre-pass: freshOk={} freshZero={} " + "sample animClass=0x{:X} matBase=0x{:X}", + m_boneDiagCount, freshOk, freshZero, + eds.empty() ? 0 : eds[0].cache->animClass, + eds.empty() ? 0 : eds[0].cache->matBase); + } + + const size_t edsBefore = eds.size(); + // Only drop entities with no matBase at all (never populated this tick). + eds.erase(std::remove_if(eds.begin(), eds.end(), + [](const PerEntityData& ed) { + return !ed.cache->matBase; + }), eds.end()); + if (diag) + spdlog::info("BoneDiag[{}] post-filter: eds={} (dropped {} with matBase=0)", + m_boneDiagCount, eds.size(), edsBefore - eds.size()); + } + + if (eds.empty()) return; + + // Build scatter list — 1 + numBones entries per entity: + // [0] VS matrix (48 B at vsAddr+8) + // [1-N] bone XYZ (12 B each — translation only, one entry per bone index) + // Each bone read is 12 bytes (one float3), well within a single OS page and + // immune to the cross-page zero-fill that the old 4800-byte block suffered. std::vector scatter; - scatter.reserve(eds.size() * 2); + scatter.reserve(eds.size() * (1 + kMaxBones)); for (auto& ed : eds) { scatter.push_back({ ed.cache->vsAddr + 8, ed.vsMatrix, sizeof(ed.vsMatrix) }); - scatter.push_back({ ed.cache->matBase + Offsets::Skeleton::BoneTranslationOffset, - ed.boneBlock, - kBoneBlockSize }); + for (int i = 0; i < ed.numBones; ++i) { + const uint64_t boneAddr = ed.cache->matBase + + Offsets::Skeleton::BoneTranslationOffset + + static_cast(ed.boneIdxs[i]) * Offsets::Skeleton::BoneStride; + scatter.push_back({ boneAddr, ed.boneXYZ[i], 12 }); + } } // Single DMA round-trip for ALL entities. m_memory.ScatterRead(pid, scatter); + // Diagnostic: inspect scatter results for the first entity. + if (diag && !eds.empty()) { + const auto& ed0 = eds[0]; + int nz = 0; + for (int i = 0; i < ed0.numBones; ++i) + if (ed0.boneXYZ[i][0]!=0||ed0.boneXYZ[i][1]!=0||ed0.boneXYZ[i][2]!=0) ++nz; + spdlog::info("BoneDiag[{}] scatter[0]: matBase=0x{:X} vsAddr=0x{:X} " + "vsT=({:.1f},{:.1f},{:.1f}) nz={}/{} " + "bone0=({:.4f},{:.4f},{:.4f}) bone1=({:.4f},{:.4f},{:.4f})", + m_boneDiagCount, + ed0.cache->matBase, ed0.cache->vsAddr, + ed0.vsMatrix[9], ed0.vsMatrix[10], ed0.vsMatrix[11], + nz, ed0.numBones, + ed0.boneXYZ[0][0], ed0.boneXYZ[0][1], ed0.boneXYZ[0][2], + ed0.boneXYZ[1][0], ed0.boneXYZ[1][1], ed0.boneXYZ[1][2]); + } + + // How long to show a cached (stale) skeleton before hiding it entirely. + // DayZ only populates bone matrices when the client decides the entity needs + // full animation (nearby LOD, frame budget, etc.). During gaps the buffer + // is zeroed. We keep the last valid world-space pose and translate it by + // the entity's movement delta so skeletons stay visible between sparse reads. + static constexpr int64_t kSkelPersistMs = 3000; + + // Helper: apply a cached skeleton to ed.outSkel, translated by the delta + // between the current VS position and the position when it was captured. + auto applyCachedSkel = [&](PerEntityData& ed, float vsX, float vsY, float vsZ) -> bool { + if (ed.cache->lastSkelMs == 0) return false; + if (nowMsBones - ed.cache->lastSkelMs >= kSkelPersistMs) return false; + if (!ed.cache->lastSkel.valid) return false; + + const float dx = vsX - ed.cache->lastEntityPos.x; + const float dy = vsY - ed.cache->lastEntityPos.y; + const float dz = vsZ - ed.cache->lastEntityPos.z; + + SkeletonBones adj = ed.cache->lastSkel; + auto shift = [&](Vector3& v) { v.x += dx; v.y += dy; v.z += dz; }; + shift(adj.neck); shift(adj.head); shift(adj.spine); + shift(adj.pelvis); + shift(adj.leftShoulder); shift(adj.leftElbow); shift(adj.leftHand); + shift(adj.rightShoulder);shift(adj.rightElbow); shift(adj.rightHand); + shift(adj.leftHip); shift(adj.leftKnee); shift(adj.leftAnkle); + shift(adj.rightHip); shift(adj.rightKnee); shift(adj.rightAnkle); + *ed.outSkel = adj; + return true; + }; + // Transform local bone translations → world space using the VS matrix. for (auto& ed : eds) { const float* m = ed.vsMatrix; - // Extract bone i's translation from the contiguous block. - // boneBlock starts at matBase+BoneTranslationOffset; bone i's entry - // begins at boneIdxs[slot]*BoneStride within the block, and the first - // 12 bytes are the local translation [x, y, z]. + // ---- Always extract VS translation first ---- + // Even when bone data is stale the VS matrix gives the entity's current + // world position, which we write back so the ESP box stays on the player. + const float vsX = m[9]; + const float vsY = m[10]; + const float vsZ = m[11]; + const bool vsOk = (vsX != 0.0f || vsY != 0.0f || vsZ != 0.0f); + if (vsOk && ed.outPos) + *ed.outPos = Vector3{ vsX, vsY, vsZ }; + + // ---- Pre-transform stale matBase check ---- + // Count how many of the sampled bone slots have a non-zero local translation. + // When the engine's LOD/animation scheduler hasn't populated the buffer for + // this entity (far players, CPU budget cuts), all entries are zero. + // Detecting this directly on the raw boneBlock avoids the indirect world-space + // head.y ≈ vsY check, which false-triggers for prone/crawling players whose + // head is only ~20–30 cm above the entity root in world Y. + { + int localNonZero = 0; + for (int i = 0; i < ed.numBones; ++i) { + if (ed.boneXYZ[i][0] != 0.f || ed.boneXYZ[i][1] != 0.f || ed.boneXYZ[i][2] != 0.f) + ++localNonZero; + } + if (localNonZero < 2) { + // Buffer empty — matBase pointed to an unpopulated buffer. + // Keep animClass so the fast 2-read recovery in ensureCached can + // pick up a fresh matBase next tick without the expensive full probe. + ed.cache->valid = false; + ed.cache->matBase = 0; + + // Show the last-known-good pose (translated by entity delta) so + // the skeleton doesn't blink out during intermittent LOD gaps. + if (vsOk && applyCachedSkel(ed, vsX, vsY, vsZ)) { + noteBoneOk(ed.addr); + } else { + ed.outSkel->valid = false; + noteBoneLoss(ed.addr, ed.label, ed.isZombie, + "collapsed skeleton (stale matBase)"); + } + continue; + } + } + + // Transform bone slot's local translation → world space via VS matrix. + // boneXYZ[slot] was read directly from the individual scatter entry above. auto transform = [&](int slot, Vector3& pos) { - const size_t off = static_cast(ed.boneIdxs[slot]) - * Offsets::Skeleton::BoneStride; - const float* t = reinterpret_cast(ed.boneBlock + off); - const float bx = t[0], by = t[1], bz = t[2]; + const float bx = ed.boneXYZ[slot][0]; + const float by = ed.boneXYZ[slot][1]; + const float bz = ed.boneXYZ[slot][2]; pos.x = bx * m[0] + by * m[3] + bz * m[6] + m[9]; pos.y = bx * m[1] + by * m[4] + bz * m[7] + m[10]; pos.z = bx * m[2] + by * m[5] + bz * m[8] + m[11]; @@ -1327,26 +1575,9 @@ void DayZRuntimeService::RefreshBonesScatter( skel.valid = (ok >= 4); const char* lossReason = skel.valid ? nullptr : "insufficient bones in scatter"; - - // ---- Update entity position from VS matrix translation ---- - // The VS matrix is freshly read this scatter; its translation column - // (m[9..11]) is the entity's current visual position in world space. - // Writing it back to boneTick.players[i].position means every 60Hz bone - // tick publishes an up-to-date position, not the stale scan-time value. - // This eliminates the "ghost at old position" artifact between entity scans. - const float vsX = ed.vsMatrix[9]; - const float vsY = ed.vsMatrix[10]; - const float vsZ = ed.vsMatrix[11]; - const bool vsOk = (vsX != 0.0f || vsY != 0.0f || vsZ != 0.0f); - if (vsOk && ed.outPos) - *ed.outPos = Vector3{ vsX, vsY, vsZ }; + bool headDrift = false; // ---- Validate head bone against the FRESH VS translation ---- - // Previous code used ed.entityPos (scan-time, possibly seconds old). - // A player running between scans can move >3 m, causing false evictions - // that continuously re-resolved the pointer cache and dropped skeletons. - // Using the VS translation (same scatter, same timestamp as the bones) - // makes this check accurate regardless of entity scan frequency. if (skel.valid) { const float refX = vsOk ? vsX : ed.entityPos.x; const float refY = vsOk ? vsY : ed.entityPos.y; @@ -1355,32 +1586,46 @@ void DayZRuntimeService::RefreshBonesScatter( const float hy = skel.head.y - refY; const float hz = skel.head.z - refZ; if (hx*hx + hy*hy + hz*hz > 9.0f) { // > 3 m from VS origin → stale pointer - skel.valid = false; - ed.cache->valid = false; - ed.cache->animClass = 0; // skip fast-path; force full re-probe - lossReason = "stale pointer (head drift > 3m)"; + skel.valid = false; + ed.cache->valid = false; + headDrift = true; + lossReason = "stale pointer (head drift > 3m)"; + // Don't clear animClass yet — might be a DMA double-buffer race rather + // than a genuinely bad pointer chain. Only evict if cachedSkel also + // fails (no fallback), meaning the chain is consistently broken. } } - // Detect stale matBase via "collapsed skeleton": when the boneBlock scatter - // returns all-zero local translations, the transform sets every bone to - // (vsX, vsY, vsZ). ok reaches 15 (all "non-zero") so the valid flag stays - // set, but head.y ≈ vsY — impossible for a standing or crouching player - // (head is always 1+ m above the entity root in DayZ). Evicting the cache - // here causes ensureCached to re-read a fresh matBase on the next tick via - // the fast 2-read path (animClass is still cached). - if (skel.valid && vsOk && (skel.head.y - vsY) < 0.3f) { - skel.valid = false; - ed.cache->valid = false; - ed.cache->animClass = 0; // stale matBase implies animClass may also be bad; force full re-probe - lossReason = "collapsed skeleton (stale matBase)"; - } - // Record presence / log the valid→invalid edge for diagnostics. - if (skel.valid) + if (skel.valid) { + // Save last-known-good skeleton with the current VS position. + ed.cache->lastSkel = skel; + ed.cache->lastEntityPos = Vector3{ vsX, vsY, vsZ }; + ed.cache->lastSkelMs = nowMsBones; noteBoneOk(ed.addr); - else - noteBoneLoss(ed.addr, ed.label, ed.isZombie, lossReason); + } else { + // Transform failed (race, stale buffer, or bad indices). Try the cached + // skeleton before publishing an invalid frame so the overlay doesn't blink + // on transient single-tick failures. + if (vsOk && applyCachedSkel(ed, vsX, vsY, vsZ)) { + noteBoneOk(ed.addr); + } else { + if (headDrift) + ed.cache->animClass = 0; // chain is genuinely bad — force re-probe + noteBoneLoss(ed.addr, ed.label, ed.isZombie, lossReason); + } + } + } + + // Summary diagnostic after transform. + if (diag) { + int validCount = 0, staleCount = 0, driftCount = 0; + for (const auto& ed : eds) { + if (ed.outSkel->valid) ++validCount; + } + spdlog::info("BoneDiag[{}] transform done: eds={} valid={} m_bonesTracked={}", + m_boneDiagCount, eds.size(), validCount, m_bonesTracked.size()); + ++m_boneDiagCount; } // Drop loss-debounce timestamps for entities no longer tracked or pending, diff --git a/src/Runtime/DayZRuntimeService.h b/src/Runtime/DayZRuntimeService.h index 3550a5a..3d704b3 100644 --- a/src/Runtime/DayZRuntimeService.h +++ b/src/Runtime/DayZRuntimeService.h @@ -44,6 +44,11 @@ struct RuntimeUpdate { std::optional localPlayerPosition; std::optional localPlayerLookDirection; + // Ammo type data for the weapon currently in local player's hands. + // 0.0f = no weapon / unresolved. Used by the ballistic prediction overlay. + float localWeaponInitSpeed = 0.0f; // m/s + float localWeaponAirFriction = 0.0f; // per-second drag coefficient + std::vector scoreboardPlayers; std::vector players; std::vector animals; @@ -133,6 +138,9 @@ public: /// Register a callback invoked (from the background thread) on each update. void SetUpdateCallback(UpdateCallback cb); + /// Live DMA throughput snapshot — safe to call from the overlay thread each frame. + [[nodiscard]] DmaStats GetDmaStats() const { return m_memory.GetStats(); } + private: // ------------------------------------------------------------------ // Configuration @@ -177,7 +185,15 @@ private: uint64_t animClass = 0; uint64_t matBase = 0; bool valid = false; - uint32_t syncAge = 0; // bone-tick counter; matBase re-read when this reaches kMatBaseResyncTicks + + // Last-known-good world-space skeleton for gap-filling. + // DayZ only populates bone matrices when the client believes the entity + // needs full animation (LOD, distance, frame scheduling). Caching the + // last valid pose and adjusting it by the entity's movement delta lets + // the overlay show continuous skeletons between sparse bone updates. + SkeletonBones lastSkel; + Vector3 lastEntityPos; // world pos when lastSkel was captured + int64_t lastSkelMs = 0; // steady_clock ms when lastSkel was captured }; std::unordered_map m_bonePointerCache; @@ -191,6 +207,12 @@ private: // addr → steady-clock ms of the last "skeleton lost" log, for debouncing // flapping skeletons to at most one message every few seconds. std::unordered_map m_boneLostLogAt; + // Emitted once per session so a broken weapon ammo chain doesn't flood the log. + bool m_ammoChainLoggedOnce = false; + // Bone-scatter diagnostic pass counter. Logs detailed bone pipeline info + // for the first kBoneDiagPasses calls to RefreshBonesScatter each session. + int m_boneDiagCount = 0; + static constexpr int kBoneDiagPasses = 8; // Separate lightweight camera state for the overlay. // Updated by the dedicated camera thread (RunCameraLoop) at high frequency, diff --git a/src/SigScanner/SigScanner.cpp b/src/SigScanner/SigScanner.cpp index 1de8219..b6d2de8 100644 --- a/src/SigScanner/SigScanner.cpp +++ b/src/SigScanner/SigScanner.cpp @@ -13,8 +13,14 @@ // Constants // ----------------------------------------------------------------------- -static constexpr size_t kChunkSize = 4 * 1024 * 1024; // 4 MB per DMA read -static constexpr size_t kOverlap = 512; // cross-boundary safety +// Large sequential reads via Process::read() reliably fail on DayZ's module +// because its code/data sections are not contiguous in physical memory. +// Scatter reads handle non-contiguous pages natively and zero-fill failures, +// so we scan using one scatter entry per 4 KB OS page (matching VMM granularity). +static constexpr size_t kPageSize = 0x1000; // 4 KB — one OS page per scatter entry +static constexpr size_t kPagesPerBatch = 256; // 256 pages = 1 MB per scatter call +static constexpr size_t kBatchSize = kPagesPerBatch * kPageSize; +static constexpr size_t kOverlap = 512; // cross-boundary safety (unchanged) static constexpr size_t kDefaultScanSize = 64 * 1024 * 1024; // fallback if PE read fails // ----------------------------------------------------------------------- @@ -195,10 +201,13 @@ SigScanner::Scan(VmmAccessor& mem, uint32_t pid, uint64_t moduleBase) "48 8B 8B ? ? ? ? 48 8B F8 48 85 C9"); // ==================================================================== - // Chunk scan + // Chunk scan — scatter-based, one 4 KB scatter entry per OS page. // ==================================================================== std::vector chunk; - chunk.reserve(kChunkSize + kOverlap); + chunk.reserve(kBatchSize); + + std::vector pages; + pages.reserve(kPagesPerBatch); size_t offset = 0; while (offset < scanSize) { @@ -218,11 +227,23 @@ SigScanner::Scan(VmmAccessor& mem, uint32_t pid, uint64_t moduleBase) break; } - const size_t readSize = std::min(kChunkSize, scanSize - offset); - if (!mem.ReadBytes(pid, moduleBase + offset, readSize, chunk)) { - offset += readSize; - continue; + const size_t batchBytes = std::min(kBatchSize, scanSize - offset); + const size_t numPages = (batchBytes + kPageSize - 1) / kPageSize; + + // Zero-initialise: failed scatter pages stay zero and won't match any pattern. + chunk.assign(batchBytes, 0); + pages.clear(); + for (size_t p = 0; p < numPages; ++p) { + const size_t pageOff = p * kPageSize; + const size_t rdLen = std::min(kPageSize, batchBytes - pageOff); + pages.push_back({ moduleBase + offset + pageOff, + chunk.data() + pageOff, + rdLen }); } + mem.ScatterRead(pid, pages); // zero-fills any pages that fail to read + + // readSize drives the overlap-advance at the bottom of the loop. + const size_t readSize = batchBytes; // -- MovCs -- if (!result.baseWorld) { diff --git a/src/Web/MapTileService.cpp b/src/Web/MapTileService.cpp index b9187f5..b156c5e 100644 --- a/src/Web/MapTileService.cpp +++ b/src/Web/MapTileService.cpp @@ -95,6 +95,19 @@ std::vector MapTileService::GetTile(const MapInfo& map, int tileX, std::lock_guard lk(m_mutex); + // Tile PNG cache — keyed by "mapId:tx:ty". PNG encoding is expensive + // (zlib compression on a 512×512 RGBA block); cache the result so repeated + // requests for the same tile (pan/zoom, reconnect, second browser tab) are + // just a map lookup + memcpy instead of a full re-encode. + std::string tileKey = map.id + ':' + std::to_string(tileX) + ':' + std::to_string(tileY); + { + auto it = m_tileCache.find(tileKey); + if (it != m_tileCache.end()) { + errCode = 200; + return it->second; + } + } + const Image* img = LoadOrGet(map.id); if (!img || img->pixels.empty() || img->w == 0 || img->h == 0) { errCode = 404; @@ -153,6 +166,8 @@ std::vector MapTileService::GetTile(const MapInfo& map, int tileX, return {}; } + m_tileCache.emplace(std::move(tileKey), png); + errCode = 200; return png; } diff --git a/src/Web/MapTileService.h b/src/Web/MapTileService.h index 3036e30..08c81e9 100644 --- a/src/Web/MapTileService.h +++ b/src/Web/MapTileService.h @@ -29,7 +29,8 @@ private: }; std::mutex m_mutex; - std::unordered_map m_cache; // mapId → loaded image + std::unordered_map m_cache; // mapId → loaded image + std::unordered_map> m_tileCache; // "mapId:tx:ty" → PNG bytes /// Return cached Image or attempt to load it. Returns nullptr on failure. const Image* LoadOrGet(const std::string& mapId); diff --git a/src/Web/WebRadarServer.cpp b/src/Web/WebRadarServer.cpp index 558eb39..5e2f457 100644 --- a/src/Web/WebRadarServer.cpp +++ b/src/Web/WebRadarServer.cpp @@ -147,8 +147,16 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) { // SetupRoutes // ------------------------------------------------------------------------- +// Attach CORS headers that allow the UI to be served from a different origin +// (e.g. Caddy on :8080 or a remote static host) while the API runs here. +static void AddCors(httplib::Response& res) { + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_header("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.set_header("Access-Control-Allow-Headers", "Content-Type"); +} + void WebRadarServer::SetupRoutes() { - // ---- Static files --------------------------------------------------- + // ---- Static files (fallback — normally served by Caddy or another static host) --- m_server->Get("/", [this](const httplib::Request& /*req*/, httplib::Response& res) { @@ -170,10 +178,20 @@ void WebRadarServer::SetupRoutes() { ServeFile(res, "favicon.ico", "image/x-icon"); }); + // ---- CORS preflight ------------------------------------------------- + // Browsers send an OPTIONS preflight for cross-origin requests. + + m_server->Options(".*", + [](const httplib::Request& /*req*/, httplib::Response& res) { + AddCors(res); + res.status = 204; + }); + // ---- Bootstrap ------------------------------------------------------ m_server->Get("/api/bootstrap", [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); if (!Authorise(req.remote_addr, req.get_param_value("password"))) { res.status = 401; @@ -187,6 +205,7 @@ void WebRadarServer::SetupRoutes() { m_server->Get("/api/state", [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); if (!Authorise(req.remote_addr, req.get_param_value("password"))) { res.status = 401; @@ -200,6 +219,7 @@ void WebRadarServer::SetupRoutes() { m_server->Get("/events", [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); if (!Authorise(req.remote_addr, req.get_param_value("password"))) { res.status = 401; @@ -246,6 +266,7 @@ void WebRadarServer::SetupRoutes() { m_server->Get("/tile", [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); if (!Authorise(req.remote_addr, req.get_param_value("password"))) { res.status = 401; @@ -294,6 +315,7 @@ void WebRadarServer::SetupRoutes() { m_server->Get("/map-image", [this](const httplib::Request& req, httplib::Response& res) { + AddCors(res); if (!Authorise(req.remote_addr, req.get_param_value("password"))) { res.status = 401; @@ -335,6 +357,7 @@ void WebRadarServer::SetupRoutes() { m_server->Get("/api/debug", [this](const httplib::Request& /*req*/, httplib::Response& res) { + AddCors(res); namespace fs = std::filesystem; wchar_t exeBuf[MAX_PATH] = {}; diff --git a/webroot/app.js b/webroot/app.js index 7921293..b8ec6c7 100644 --- a/webroot/app.js +++ b/webroot/app.js @@ -1,6 +1,10 @@ const storageKey = "dayz-web-map-settings"; 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 }; const lootPalette = ["#f43f5e", "#f59e0b", "#22c55e", "#eab308", "#84cc16", "#ec4899", "#0ea5e9", "#14b8a6", "#c084fc", "#f97316", "#d946ef", "#dc2626", "#fbbf24", "#fb923c", "#0891b2", "#64748b"]; @@ -300,7 +304,8 @@ function toggleFavoriteLoot(itemName) { } function apiUrl(path) { - return password ? `${path}?password=${encodeURIComponent(password)}` : path; + const base = serverOrigin + path; + return password ? `${base}?password=${encodeURIComponent(password)}` : base; } function tileUrl(tileX, tileY, retry = 0) { @@ -311,7 +316,7 @@ function tileUrl(tileX, tileY, retry = 0) { if (password) { query.set("password", password); } - return `/tile?${query.toString()}`; + return `${serverOrigin}/tile?${query.toString()}`; } function getViewportMetrics() {