#include "Runtime/DayZRuntimeService.h"

#include <cmath>
#include <cstring>
#include <fstream>
#include <sstream>
#include <type_traits>
#include <unordered_map>
#include <unordered_set>

#include <spdlog/spdlog.h>

#include "Offsets.h"
#include "RuntimeOffsets.h"
#include "Readers/EntityTypeCache.h"
#include "Memory/MemoryValidation.h"

// -------------------------------------------------------------------------
// RuntimeConfig::Load — INI-style config/config.cfg parser
// -------------------------------------------------------------------------

RuntimeConfig RuntimeConfig::Load(const std::string& path) {
    RuntimeConfig cfg;
    std::ifstream f(path);
    if (!f.is_open()) {
        spdlog::info("RuntimeConfig: {} not found, using defaults", path);
        return cfg;
    }

    std::string line;
    while (std::getline(f, line)) {
        // Strip inline comments and leading/trailing whitespace
        auto commentPos = line.find('#');
        if (commentPos != std::string::npos) line = line.substr(0, commentPos);
        while (!line.empty() && (line.front() == ' ' || line.front() == '\t')) line.erase(line.begin());
        while (!line.empty() && (line.back()  == ' ' || line.back()  == '\t' || line.back() == '\r')) line.pop_back();
        if (line.empty()) continue;

        auto eq = line.find('=');
        if (eq == std::string::npos) continue;

        std::string key = line.substr(0, eq);
        std::string val = line.substr(eq + 1);
        while (!key.empty() && key.back() == ' ') key.pop_back();
        while (!val.empty() && val.front() == ' ') val.erase(val.begin());

        try {
            if      (key == "networkMetadataRefreshMs") cfg.networkMetadataRefreshMs = std::stoi(val);
            else if (key == "scoreboardRefreshMs")      cfg.scoreboardRefreshMs      = std::stoi(val);
            else if (key == "nearEntityRefreshMs")      cfg.nearEntityRefreshMs      = std::stoi(val);
            else if (key == "farEntityRefreshMs")       cfg.farEntityRefreshMs       = std::stoi(val);
            else if (key == "slowEntityRefreshMs")      cfg.slowEntityRefreshMs      = std::stoi(val);
            else if (key == "bulletRefreshMs")          cfg.bulletRefreshMs          = std::stoi(val);
            else if (key == "itemRefreshMs")            cfg.itemRefreshMs            = std::stoi(val);
            else if (key == "playersRefreshMs")         cfg.playersRefreshMs         = std::stoi(val);
            else if (key == "liveLoopIdleMs")           cfg.liveLoopIdleMs           = std::stoi(val);
            else if (key == "reconnectThreshold")       cfg.reconnectThreshold       = std::stoi(val);
            else if (key == "reconnectGracePeriodMs")   cfg.reconnectGracePeriodMs   = std::stoi(val);
            else if (key == "farEntityBatchSize")       cfg.farEntityBatchSize       = std::stoi(val);
            else if (key == "slowEntityBatchSize")      cfg.slowEntityBatchSize      = std::stoi(val);
            else if (key == "itemBatchSize")            cfg.itemBatchSize            = std::stoi(val);
            else if (key == "boneRefreshMs")            cfg.boneRefreshMs            = std::stoi(val);
            else if (key == "bonePlayerMaxDist")        cfg.bonePlayerMaxDist        = std::stof(val);
            else if (key == "boneZombieMaxDist")        cfg.boneZombieMaxDist        = std::stof(val);
            else if (key == "vmmRefreshMs")             cfg.vmmRefreshMs             = std::stoi(val);
            else if (key == "itemSpatialFilterRadius") cfg.itemSpatialFilterRadius  = std::stof(val);
            else if (key == "processName")              cfg.processName              = val;
            else if (key == "useMemoryMap")             cfg.useMemoryMap             = (val == "true" || val == "1");
        } catch (...) {
            spdlog::warn("RuntimeConfig: bad value for key '{}' = '{}'", key, val);
        }
    }

    spdlog::info("RuntimeConfig: loaded from {}", path);
    return cfg;
}

// -------------------------------------------------------------------------
// Constructor / Destructor
// -------------------------------------------------------------------------

DayZRuntimeService::DayZRuntimeService(RuntimeConfig config)
    : m_config(std::move(config))
    , m_filterCatalog()          // resolves config/item_filters.json relative to exe
    , m_farReader(m_config.farEntityBatchSize)
    , m_slowReader(m_config.slowEntityBatchSize)
    , m_itemReader(m_filterCatalog, m_config.itemBatchSize)
    , m_scoreboardReader(m_config.scoreboardRefreshMs)
{
    // Initialise all throttle timestamps to "past" so every reader fires
    // immediately on the first live-loop iteration.
    auto past = std::chrono::steady_clock::time_point{};
    m_state.nextScoreboardRefresh       = past;
    m_state.nextNearRefresh             = past;
    m_state.nextFarRefresh              = past;
    m_state.nextSlowRefresh             = past;
    m_state.nextBulletRefresh           = past;
    m_state.nextItemRefresh             = past;
    m_state.nextPlayersRefresh          = past;
    m_state.nextNetworkMetadataRefresh  = past;
    m_state.nextBoneRefresh             = past;
    m_state.nextVmmRefresh              = past;
}

DayZRuntimeService::~DayZRuntimeService() {
    Stop();
}

// -------------------------------------------------------------------------
// Start / Stop
// -------------------------------------------------------------------------

void DayZRuntimeService::Start() {
    if (m_runtimeThread.joinable()) {
        return; // already running
    }
    m_stopRequested.store(false);

    spdlog::info("Initialising DMA (useMemoryMap={})...", m_config.useMemoryMap);
    if (!m_memory.Initialize(m_config.useMemoryMap)) {
        spdlog::error("DMA initialisation failed — ensure the DMA device is connected.");
        // We still start the thread so it can retry / report status.
    }

    m_runtimeThread = std::thread(&DayZRuntimeService::Run, this);
    m_cameraThread  = std::thread(&DayZRuntimeService::RunCameraLoop, this);
}

void DayZRuntimeService::Stop() {
    m_stopRequested.store(true);
    if (m_runtimeThread.joinable()) {
        m_runtimeThread.join();
    }
    if (m_cameraThread.joinable()) {
        m_cameraThread.join();
    }
}

// -------------------------------------------------------------------------
// GetLatestUpdate / SetUpdateCallback
// -------------------------------------------------------------------------

std::shared_ptr<const RuntimeUpdate> DayZRuntimeService::GetLatestUpdate() const {
    std::lock_guard<std::mutex> lock(m_updateMutex);
    return m_latestUpdate;
}

bool DayZRuntimeService::GetLatestCamera(CameraData& out) const {
    std::lock_guard<std::mutex> lock(m_camMutex);
    if (!m_latestCamForOverlay.valid) return false;
    out = m_latestCamForOverlay;
    return true;
}

void DayZRuntimeService::SetUpdateCallback(UpdateCallback cb) {
    std::lock_guard<std::mutex> lock(m_updateMutex);
    m_callback = std::move(cb);
}

// -------------------------------------------------------------------------
// Run
// Top-level thread function: WaitForSession -> RunLiveLoop, loop on reconnect.
// -------------------------------------------------------------------------

void DayZRuntimeService::Run() {
    while (!m_stopRequested.load()) {
        try {
            RuntimeSession session = WaitForSession();
            if (m_stopRequested.load()) break;

            spdlog::info("Session established. PID={}", session.processId);

            RunLiveLoop(session);
        }
        catch (const ReconnectException&) {
            spdlog::warn("Session lost — reconnecting...");
            ResetAllReaders();

            // Grace period before next attempt.
            auto deadline = std::chrono::steady_clock::now()
                          + std::chrono::milliseconds(m_config.reconnectGracePeriodMs);
            while (!m_stopRequested.load() &&
                   std::chrono::steady_clock::now() < deadline)
            {
                std::this_thread::sleep_for(std::chrono::milliseconds(100));
            }
        }
        catch (const std::exception& ex) {
            spdlog::error("Unhandled exception: {}", ex.what());
            ResetAllReaders();
            std::this_thread::sleep_for(std::chrono::milliseconds(1000));
        }
    }
    spdlog::info("Runtime thread exiting.");
}

// -------------------------------------------------------------------------
// WaitForSession
// -------------------------------------------------------------------------

RuntimeSession DayZRuntimeService::WaitForSession() {
    using Clock = std::chrono::steady_clock;
    using Ms    = std::chrono::milliseconds;

    // Cached across iterations so we don't re-attach every 250 ms.
    uint32_t cachedPid      = 0;
    uint64_t cachedBase     = 0;
    auto     lastAttach     = Clock::time_point{};  // force attach on first entry
    std::string lastStatus;

    // Helper: publish a status update only when the message changes.
    auto setStatus = [&](std::string msg) {
        if (msg == lastStatus) return;
        lastStatus = msg;
        spdlog::info("WaitForSession: {}", msg);
        RuntimeUpdate upd;
        upd.areBaseObjectsReady = false;
        upd.status = std::move(msg);
        PublishUpdate(upd);
    };

    // Tracks consecutive VMM init failures so we can back off before retrying.
    int vmmFailCount = 0;

    while (!m_stopRequested.load()) {
        if (!m_memory.IsInitialized()) {
            // The FPGA can enter a bad PCIe state after extended operation and
            // fail to reinitialize (VMM INIT FAILED / Unable to locate DTB).
            // Retry Initialize() with backoff — the FPGA usually self-recovers
            // within a few seconds of the PCIe link being re-enumerated.
            ++vmmFailCount;
            int delaySec = std::min(vmmFailCount * 5, 60);
            setStatus("VMM not initialised — retrying in " + std::to_string(delaySec) + "s (attempt " + std::to_string(vmmFailCount) + ")...");
            spdlog::warn("WaitForSession: VMM not initialised (attempt {}), reinit in {}s", vmmFailCount, delaySec);
            std::this_thread::sleep_for(std::chrono::seconds(delaySec));
            if (!m_stopRequested.load()) {
                m_memory.Initialize(m_config.useMemoryMap);
            }
            continue;
        }
        vmmFailCount = 0;

        // ---- Re-attach to process every 3 s (or on first entry) ----
        auto now = Clock::now();
        bool needAttach = (cachedPid == 0) ||
                          (now - lastAttach >= std::chrono::seconds(3));

        if (needAttach) {
            m_memory.ForceRefresh();
            lastAttach = now;

            uint32_t pid = 0;
            if (!m_memory.TryFindProcess(m_config.processName, pid)) {
                cachedPid  = 0;
                cachedBase = 0;
                setStatus("Waiting for " + m_config.processName + "...");
                std::this_thread::sleep_for(Ms(500));
                continue;
            }

            uint64_t base = 0;
            if (!m_memory.TryGetModuleBase(pid, m_config.processName, base)) {
                cachedPid  = 0;
                cachedBase = 0;
                setStatus("Waiting for module base of " + m_config.processName + "...");
                std::this_thread::sleep_for(Ms(500));
                continue;
            }

            // Sig scanner — run once per unique module base.
            if (base != m_lastScannedBase) {
                spdlog::info("WaitForSession: running signature scanner (base=0x{:X})...", base);
                auto scanResult = SigScanner::Scan(m_memory, pid, base);
                SigScanner::Apply(scanResult);
                m_lastScannedBase = base;
            }

            cachedPid  = pid;
            cachedBase = base;
        }

        // ---- Server-connection pre-check ----
        // NetworkClient is null when in the main menu or on a loading screen.
        {
            const uint64_t nmAddr = cachedBase + RuntimeOffsets::Base::NetworkManager;
            uint64_t networkClientAddr = 0;
            if (!m_memory.TryReadPointer(cachedPid,
                                          nmAddr + Offsets::Network::ManagerNetworkClient,
                                          networkClientAddr))
            {
                std::vector<uint8_t> raw;
                uint64_t rawVal = 0;
                if (m_memory.ReadBytes(cachedPid,
                                       nmAddr + Offsets::Network::ManagerNetworkClient,
                                       sizeof(uint64_t), raw)
                    && raw.size() == sizeof(uint64_t))
                {
                    std::memcpy(&rawVal, raw.data(), sizeof(uint64_t));
                }
                setStatus(rawVal == 0
                    ? "Waiting for server connection..."
                    : "NetworkClient pointer invalid — possible stale offset");
                std::this_thread::sleep_for(Ms(500));
                continue;
            }
        }

        // ---- TryResolve: world + local player ----
        RuntimeSession session;
        NetworkMetadata metadata;
        LocalPlayerData player;

        if (!m_baseResolver.TryResolve(m_memory, cachedPid, cachedBase,
                                        session, metadata, player))
        {
            setStatus("Waiting for player spawn...");
            std::this_thread::sleep_for(Ms(250));
            continue;
        }

        m_metadata    = metadata;
        m_localPlayer = player;
        return session;
    }

    return RuntimeSession{};
}

// -------------------------------------------------------------------------
// RunLiveLoop
// -------------------------------------------------------------------------

void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
    using Clock = std::chrono::steady_clock;
    using Ms    = std::chrono::milliseconds;

    int consecutiveFailures = 0;

    // Hand the live session to the dedicated camera thread so it can begin
    // reading the camera at high frequency.  Cleared in ResetAllReaders().
    m_camWorldAddr.store(session.worldAddress, std::memory_order_release);
    m_camPid.store(session.processId,          std::memory_order_release);

    while (!m_stopRequested.load()) {
        auto now = Clock::now();

        // ------------------------------------------------------------------
        // 1. Validate the session is still coherent.
        // ------------------------------------------------------------------
        if (!m_baseResolver.IsStillValid(m_memory, session)) {
            throw ReconnectException{};
        }

        bool anyFailure = false;

        // ------------------------------------------------------------------
        // 2. Network metadata (low-frequency).
        // ------------------------------------------------------------------
        if (now >= m_state.nextNetworkMetadataRefresh) {
            NetworkMetadata meta;
            if (m_networkMetadataReader.TryRead(m_memory, session, meta)) {
                m_metadata = meta;
            }
            m_state.nextNetworkMetadataRefresh =
                Clock::now() + Ms(m_config.networkMetadataRefreshMs);
        }

        // ------------------------------------------------------------------
        // 3. Scoreboard (low-frequency, non-critical — failure = empty board).
        // ------------------------------------------------------------------
        if (now >= m_state.nextScoreboardRefresh) {
            if (!m_scoreboardReader.TryRead(m_memory, session,
                                             m_state.scoreboard)) {
                spdlog::trace("RunLiveLoop: scoreboard read failed (non-fatal)");
            }
            m_state.nextScoreboardRefresh =
                Clock::now() + Ms(m_config.scoreboardRefreshMs);
        }

        // ------------------------------------------------------------------
        // 4. Near entities (critical — failure counts toward reconnect).
        // ------------------------------------------------------------------
        if (now >= m_state.nextNearRefresh) {
            if (!m_nearReader.TryRead(m_memory, session,
                                       m_state.nearEntities)) {
                spdlog::trace("RunLiveLoop: near entity read failed");
                anyFailure = true;
            }
            m_state.nextNearRefresh =
                Clock::now() + Ms(m_config.nearEntityRefreshMs);
        }

        // ------------------------------------------------------------------
        // 5. Far entities (critical — failure counts toward reconnect).
        // ------------------------------------------------------------------
        if (now >= m_state.nextFarRefresh) {
            if (!m_farReader.TryRead(m_memory, session,
                                      m_state.farEntities)) {
                spdlog::trace("RunLiveLoop: far entity read failed");
                anyFailure = true;
            }
            m_state.nextFarRefresh =
                Clock::now() + Ms(m_config.farEntityRefreshMs);
        }

        // ------------------------------------------------------------------
        // 6. Slow entities (critical — failure counts toward reconnect).
        // ------------------------------------------------------------------
        if (now >= m_state.nextSlowRefresh) {
            if (!m_slowReader.TryRead(m_memory, session,
                                       m_state.slowEntities)) {
                spdlog::trace("RunLiveLoop: slow entity read failed");
                anyFailure = true;
            }
            m_state.nextSlowRefresh =
                Clock::now() + Ms(m_config.slowEntityRefreshMs);
        }

        // ------------------------------------------------------------------
        // 7. Bullets (non-critical — failure = no bullet data).
        // ------------------------------------------------------------------
        if (now >= m_state.nextBulletRefresh) {
            if (!m_bulletReader.TryRead(m_memory, session,
                                         m_state.bullets)) {
                spdlog::trace("RunLiveLoop: bullet read failed (non-fatal)");
            }
            m_state.nextBulletRefresh =
                Clock::now() + Ms(m_config.bulletRefreshMs);
        }

        // ------------------------------------------------------------------
        // 8. Items (non-critical — failure = no item data).
        // ------------------------------------------------------------------
        if (now >= m_state.nextItemRefresh) {
            if (!m_itemReader.TryRead(m_memory, session,
                                       m_state.items)) {
                spdlog::trace("RunLiveLoop: item read failed (non-fatal)");
            }
            m_state.nextItemRefresh =
                Clock::now() + Ms(m_config.itemRefreshMs);
        }

        // ------------------------------------------------------------------
        // 9. Refresh local player transform.
        // ------------------------------------------------------------------
        {
            LocalPlayerData player;
            if (m_localPlayerResolver.TryResolve(m_memory, session, player)) {
                m_localPlayer = player;
            }
        }

        // ------------------------------------------------------------------
        // 9b. Read camera for the published snapshot (web radar + bone-range
        //     culling).  The overlay does NOT use this copy — m_latestCamForOverlay
        //     is driven independently by the dedicated camera thread at much
        //     lower latency.  This read just keeps m_camera current for the
        //     snapshot path; it's fine for it to run at the loop cadence.
        // ------------------------------------------------------------------
        ReadCameraInto(session.processId, session.worldAddress, m_cachedCamBase, m_camera);

        // PID used by several gates below.
        const uint32_t pid = session.processId;

        // ------------------------------------------------------------------
        // 10. Position-refresh scatter for far entities (every playersRefreshMs).
        //     Keeps far-entity positions smooth without waiting for the slow cursor
        //     scan to circle back around to each entity's table slot.
        // ------------------------------------------------------------------
        if (now >= m_state.nextPlayersRefresh) {
            m_farReader.RefreshPositions(m_memory, session);
        }

        // ------------------------------------------------------------------
        // 11. Project entities into typed categories (throttled).
        // ------------------------------------------------------------------
        if (now >= m_state.nextPlayersRefresh) {
            auto scoreboardNames = BuildScoreboardNames(m_state.scoreboard);

            // --- isDead resolver ---
            auto deadResolver = [&](uint64_t addr) -> bool {
                uint8_t dead = 0;
                m_memory.TryReadValue<uint8_t>(pid, addr + RuntimeOffsets::Player::IsDead, dead);
                return dead != 0;
            };

            // --- heldItemResolver ---
            // Chain: entity → +0x650 → ptr → inventory obj
            //        inventory → +0x1CC → bool (HandItemValid)
            //        inventory → +0x1B0 → ptr → hand item entity
            //        hand item → +0x180 → ptr → type obj → clean name
            auto heldItemResolver = [&](uint64_t addr) -> std::string {
                uint64_t inventoryAddr = 0;
                if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
                    || !MemoryValidation::IsValidUserAddress(inventoryAddr))
                    return {};

                uint8_t handValid = 0;
                if (m_memory.TryReadValue<uint8_t>(pid, inventoryAddr + Offsets::Inventory::HandItemValid, handValid)
                    && !handValid)
                    return {};

                uint64_t handItemAddr = 0;
                if (!m_memory.TryReadPointer(pid, inventoryAddr + RuntimeOffsets::Inventory::Hands, handItemAddr)
                    || !MemoryValidation::IsValidUserAddress(handItemAddr))
                    return {};

                uint64_t typeAddr = 0;
                if (!m_memory.TryReadPointer(pid, handItemAddr + Offsets::Common::Type, typeAddr)
                    || !MemoryValidation::IsValidUserAddress(typeAddr))
                    return {};

                EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
                if (!meta.cleanName.empty())  return meta.cleanName;
                if (!meta.typeName.empty())   return FormatEntityName(meta.typeName);
                if (!meta.configName.empty()) return meta.configName;
                return {};
            };

            // --- adminResolver ---
            // Detects admin-invisible players across known DayZ admin tools:
            //
            //  VP Admin (classic)  — equips clothing with configName ending in "_Admin"
            //                        (Head_Admin, Top_Admin, Pants_Admin, Boots_Admin,
            //                         Arms_Admin, Backpack_Admin)
            //
            //  VPP Admin Tools     — calls SetScale(0) on the entity, which zeroes the
            //                        rotation columns of the VS transform matrix.
            //                        A normal player's right-vector has magnitude ≈ 1.0;
            //                        a scale-0 entity has magnitude ≈ 0.0.
            //
            //  Zomberry Admin      — uses same Enfusion SetScale / SetInvisible path as VPP;
            //                        caught by the same scale check.
            //
            //  CF (Community Framework) — pure RPC infrastructure, no own invisibility;
            //                        no dedicated check needed.
            //
            // Entity empty-model check is handled cheaply in the projector before this runs.
            // adminMethods records HOW each flagged player was detected, keyed by
            // entity address, so the one-shot announce log can explain the trigger.
            std::unordered_map<uint64_t, const char*> adminMethods;
            auto adminResolver = [&](uint64_t addr) -> bool {

                // ── Check 1: VPP / Zomberry — VS matrix scale ≈ 0 ──────────────────────
                {
                    uint64_t vsAddr = 0;
                    if (m_memory.TryReadPointer(pid, addr + Offsets::Common::VisualState, vsAddr)
                        && MemoryValidation::IsValidUserAddress(vsAddr))
                    {
                        struct VSMatrix { float m[12]; };
                        VSMatrix vs{};
                        if (m_memory.TryReadValue<VSMatrix>(pid, vsAddr + 8, vs)) {
                            // Right-vector = m[0..2]; magnitude < 0.01 means scale ≈ 0.
                            const float mag2 = vs.m[0]*vs.m[0] + vs.m[1]*vs.m[1] + vs.m[2]*vs.m[2];
                            if (mag2 < 0.01f) {
                                adminMethods[addr] = "VS scale=0 (VPP/Zomberry SetScale)";
                                return true;
                            }
                        }
                    }
                }

                // ── Check 2: VP Admin classic — worn clothing configName ends in "_Admin" ─
                {
                    uint64_t inventoryAddr = 0;
                    if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
                        || !MemoryValidation::IsValidUserAddress(inventoryAddr))
                        return false;

                    uint64_t clothesGrid = 0;
                    if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
                        || !MemoryValidation::IsValidUserAddress(clothesGrid))
                        return false;

                    uint64_t itemsArray = 0;
                    uint32_t count      = 0;
                    if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
                        || !MemoryValidation::IsValidUserAddress(itemsArray))
                        return false;
                    if (!m_memory.TryReadValue<uint32_t>(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count)
                        || count == 0 || count > 32)
                        return false;

                    auto containsAdmin = [](const std::string& s) -> bool {
                        constexpr std::string_view kNeedle = "_admin";
                        if (s.size() < kNeedle.size()) return false;
                        const size_t last = s.size() - kNeedle.size();
                        for (size_t off = 0; off <= last; ++off) {
                            bool match = true;
                            for (size_t j = 0; j < kNeedle.size(); ++j) {
                                if (std::tolower((unsigned char)s[off + j]) != kNeedle[j]) {
                                    match = false;
                                    break;
                                }
                            }
                            if (match) return true;
                        }
                        return false;
                    };

                    for (uint32_t i = 0; i < count; ++i) {
                        uint64_t itemAddr = 0;
                        if (!m_memory.TryReadPointer(pid, itemsArray + i * sizeof(uint64_t), itemAddr)
                            || !MemoryValidation::IsValidUserAddress(itemAddr))
                            continue;

                        uint64_t typeAddr = 0;
                        if (!m_memory.TryReadPointer(pid, itemAddr + Offsets::Common::Type, typeAddr)
                            || !MemoryValidation::IsValidUserAddress(typeAddr))
                            continue;

                        EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
                        if (containsAdmin(meta.configName) || containsAdmin(meta.typeName)) {
                            adminMethods[addr] = "_Admin clothing (VP Admin)";
                            return true;
                        }
                    }
                }

                return false;
            };

            // --- healthResolver ---
            // Reads a float from entity + Offsets::Player::Health.
            // Returns -1.0f when the offset is unset (0) or the value is out of range.
            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<float>(pid, addr + Offsets::Player::Health, hp)
                    || hp <= 0.0f || hp > 1.01f)
                    return -1.0f;
                return hp * 100.0f;
            };

            // --- wornClothesResolver ---
            // Chain: entity → +0x650 (inventory) → +0x150 (clothes grid ptr)
            //        grid → +0x8 (items array ptr), +0xC (uint32 count)
            //        items[i*8] → item entity → +0x180 (type) → cleanName
            auto wornClothesResolver = [&](uint64_t addr) -> std::vector<std::string> {
                std::vector<std::string> result;

                uint64_t inventoryAddr = 0;
                if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
                    || !MemoryValidation::IsValidUserAddress(inventoryAddr))
                    return result;

                uint64_t clothesGrid = 0;
                if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
                    || !MemoryValidation::IsValidUserAddress(clothesGrid))
                    return result;

                uint64_t itemsArray = 0;
                if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
                    || !MemoryValidation::IsValidUserAddress(itemsArray))
                    return result;

                uint32_t count = 0;
                if (!m_memory.TryReadValue<uint32_t>(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count)
                    || count == 0 || count > 32)
                    return result;

                result.reserve(count);
                for (uint32_t i = 0; i < count; ++i) {
                    uint64_t itemAddr = 0;
                    if (!m_memory.TryReadPointer(pid, itemsArray + i * sizeof(uint64_t), itemAddr)
                        || !MemoryValidation::IsValidUserAddress(itemAddr))
                        continue;

                    uint64_t typeAddr = 0;
                    if (!m_memory.TryReadPointer(pid, itemAddr + Offsets::Common::Type, typeAddr)
                        || !MemoryValidation::IsValidUserAddress(typeAddr))
                        continue;

                    EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
                    std::string name;
                    if      (!meta.cleanName.empty())  name = meta.cleanName;
                    else if (!meta.typeName.empty())   name = FormatEntityName(meta.typeName);
                    else if (!meta.configName.empty()) name = meta.configName;
                    if (!name.empty()) result.push_back(std::move(name));
                }
                return result;
            };

            RuntimeUpdate update;
            update.areBaseObjectsReady = true;
            update.status              = "Live";

            if (!m_metadata.serverName.empty()) {
                update.serverName  = m_metadata.serverName;
                update.gameVersion = m_metadata.gameVersion;
            }
            update.serverMapName = m_metadata.serverMapName;

            if (m_localPlayer.entityAddress != 0) {
                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<uint8_t>(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<float>(pid,
                        ammoType + Offsets::AmmoType::InitSpeed,   initSpeed);
                    m_memory.TryReadValue<float>(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;

            update.scoreboardPlayers = m_state.scoreboard;
            update.nearEntities      = m_state.nearEntities;
            update.farEntities       = m_state.farEntities;
            update.slowEntities      = m_state.slowEntities;
            update.bullets = m_state.bullets;

            // Spatial filter: only ship items within itemSpatialFilterRadius of
            // the local player.  Avoids sending thousands of server-wide items to
            // the renderer on every publish tick.
            if (m_localPlayer.entityAddress != 0) {
                const Vector3& lp  = m_localPlayer.position;
                const float    r2  = m_config.itemSpatialFilterRadius
                                   * m_config.itemSpatialFilterRadius;
                for (const auto& item : m_state.items) {
                    if (!item.position.has_value()) continue;
                    float dx = item.position->x - lp.x;
                    float dy = item.position->y - lp.y;
                    float dz = item.position->z - lp.z;
                    if (dx*dx + dy*dy + dz*dz <= r2)
                        update.items.push_back(item);
                }
            } else {
                update.items = m_state.items;
            }

            update.players = EntityCategoryProjector::BuildPlayers(
                m_state.nearEntities, m_state.farEntities, m_state.slowEntities,
                scoreboardNames, deadResolver, heldItemResolver, healthResolver,
                adminResolver, wornClothesResolver);

            // ---- Announce newly-detected admins once each (name + how + range) ----
            {
                std::unordered_set<uint64_t> adminsThisCycle;
                for (const auto& p : update.players) {
                    if (!p.isAdmin) continue;
                    adminsThisCycle.insert(p.address);
                    if (m_adminAnnounced.insert(p.address).second) {
                        const char* how = "unknown";
                        if (auto it = adminMethods.find(p.address); it != adminMethods.end())
                            how = it->second;
                        std::string name = !p.nickname.empty() ? p.nickname : "<unknown>";
                        float dist = -1.0f;
                        if (p.position && m_localPlayer.entityAddress != 0) {
                            float dx = p.position->x - m_localPlayer.position.x;
                            float dy = p.position->y - m_localPlayer.position.y;
                            float dz = p.position->z - m_localPlayer.position.z;
                            dist = std::sqrt(dx*dx + dy*dy + dz*dz);
                        }
                        spdlog::warn("ADMIN detected: '{}' @ {:.0f}m — via {} [0x{:X}]",
                                     name, dist, how, p.address);
                    }
                }
                // Forget admins that left so a future re-join re-announces them.
                for (auto it = m_adminAnnounced.begin(); it != m_adminAnnounced.end(); )
                    it = adminsThisCycle.count(*it) ? std::next(it)
                                                    : m_adminAnnounced.erase(it);
            }

            update.animals = EntityCategoryProjector::BuildAnimals(
                m_state.nearEntities, m_state.farEntities, m_state.slowEntities);

            update.zombies = EntityCategoryProjector::BuildZombies(
                m_state.nearEntities, m_state.farEntities, m_state.slowEntities);

            update.carsAndBoats = EntityCategoryProjector::BuildCarsAndBoats(
                m_state.nearEntities, m_state.farEntities, m_state.slowEntities);

            // otherEntities: building parts, barrels, tents, etc.  On modded
            // servers with built bases this can be hundreds of objects server-wide.
            // Apply the same spatial filter as items so only nearby ones are sent.
            {
                auto allOther = EntityCategoryProjector::BuildOtherEntities(
                    m_state.nearEntities, m_state.farEntities, m_state.slowEntities);
                if (m_localPlayer.entityAddress != 0) {
                    const Vector3& lp = m_localPlayer.position;
                    const float    r2 = m_config.itemSpatialFilterRadius
                                      * m_config.itemSpatialFilterRadius;
                    for (auto& e : allOther) {
                        if (!e.position.has_value()) continue;
                        float dx = e.position->x - lp.x;
                        float dy = e.position->y - lp.y;
                        float dz = e.position->z - lp.z;
                        if (dx*dx + dy*dy + dz*dz <= r2)
                            update.otherEntities.push_back(std::move(e));
                    }
                } else {
                    update.otherEntities = std::move(allOther);
                }
            }

            // 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);

            m_state.nextPlayersRefresh =
                Clock::now() + Ms(m_config.playersRefreshMs);
        }

        // ------------------------------------------------------------------
        // 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) {
            RuntimeUpdate boneTick = m_liveUpdate;  // copy full entity baseline
            if (!boneTick.players.empty() || !boneTick.zombies.empty()) {
                RefreshBonesScatter(pid, boneTick.players, boneTick.zombies);
            }
            PublishUpdate(boneTick);  // always publish — includes items/bullets
            m_state.nextBoneRefresh = Clock::now() + Ms(m_config.boneRefreshMs);
        }

        // ------------------------------------------------------------------
        // 12. Periodic VMMDLL cache flush.
        //     Without this, the page cache accumulates stale entries as the
        //     game heap reallocates over time, causing reads to silently return
        //     wrong data ~1 hour into a session.
        // ------------------------------------------------------------------
        if (now >= m_state.nextVmmRefresh) {
            m_memory.ForceRefresh();
            spdlog::trace("RunLiveLoop: VMM cache flushed.");
            m_state.nextVmmRefresh = Clock::now() + Ms(m_config.vmmRefreshMs);

            // Prune bone pointer cache: evict addresses no longer in any
            // entity list so the map doesn't grow unboundedly on busy servers.
            std::unordered_set<uint64_t> liveAddrs;
            liveAddrs.reserve(m_state.nearEntities.size()
                            + m_state.farEntities.size()
                            + m_state.slowEntities.size());
            for (const auto& e : m_state.nearEntities) liveAddrs.insert(e.address);
            for (const auto& e : m_state.farEntities)  liveAddrs.insert(e.address);
            for (const auto& e : m_state.slowEntities) liveAddrs.insert(e.address);
            for (auto it = m_bonePointerCache.begin(); it != m_bonePointerCache.end(); ) {
                it = liveAddrs.count(it->first) ? std::next(it)
                                                : m_bonePointerCache.erase(it);
            }
        }

        // ------------------------------------------------------------------
        // 13. Consecutive-failure check.
        // ------------------------------------------------------------------
        if (anyFailure) {
            ++consecutiveFailures;
            if (consecutiveFailures >= m_config.reconnectThreshold) {
                spdlog::warn("{} consecutive reader failures — triggering reconnect.", consecutiveFailures);
                throw ReconnectException{};
            }
        } else {
            consecutiveFailures = 0;
        }

        // ------------------------------------------------------------------
        // 12. Idle sleep to avoid burning the CPU.
        // ------------------------------------------------------------------
        std::this_thread::sleep_for(Ms(m_config.liveLoopIdleMs));
    }
}

// -------------------------------------------------------------------------
// PublishUpdate
// -------------------------------------------------------------------------

void DayZRuntimeService::PublishUpdate(const RuntimeUpdate& update) {
    auto ptr = std::make_shared<const RuntimeUpdate>(update);
    UpdateCallback cb;
    {
        std::lock_guard<std::mutex> lock(m_updateMutex);
        m_latestUpdate = ptr;
        cb = m_callback;
    }
    if (cb) {
        cb(update);
    }
}

// -------------------------------------------------------------------------
// ReadCameraInto
// -------------------------------------------------------------------------

bool DayZRuntimeService::ReadCameraInto(uint32_t pid, uint64_t worldAddr,
                                        uint64_t& camBaseCache, CameraData& out)
{
    if (camBaseCache == 0) {
        uint64_t ptr = 0;
        if (m_memory.TryReadValue<uint64_t>(pid,
                worldAddr + RuntimeOffsets::World::Camera, ptr) && ptr != 0)
            camBaseCache = ptr;
        else
            return false;
    }

    struct CamBlob {
        uint8_t _pad0[0x8];
        float   right[3];            // 0x08 – InvertedViewRight
        float   up[3];               // 0x14 – InvertedViewUp
        float   fwd[3];              // 0x20 – InvertedViewForward
        float   trans[3];            // 0x2C – Translation
        uint8_t _pad1[0xD0 - 0x38];
        float   proj1[3];            // 0xD0 – ProjectionD1
        float   proj2[3];            // 0xDC – ProjectionD2
    };
    static_assert(sizeof(CamBlob) == 0xE8);

    CamBlob blob{};
    if (!m_memory.TryReadValue<CamBlob>(pid, camBaseCache, blob)) {
        camBaseCache = 0;   // pointer went stale — re-resolve next call
        return false;
    }

    out.invertedViewRight   = { blob.right[0], blob.right[1], blob.right[2] };
    out.invertedViewUp      = { blob.up[0],    blob.up[1],    blob.up[2]    };
    out.invertedViewForward = { blob.fwd[0],   blob.fwd[1],   blob.fwd[2]   };
    out.translation         = { blob.trans[0], blob.trans[1], blob.trans[2] };
    out.projectionD1x       = blob.proj1[0];
    out.projectionD2y       = blob.proj2[1];
    out.valid               = true;
    return true;
}

// -------------------------------------------------------------------------
// RunCameraLoop
// Dedicated low-latency camera reader.  A single ~232-byte read per pass,
// run as fast as the DMA round-trip allows, so the overlay's projection
// matrix is never more than a read old.  Without this the camera was only
// refreshed once per heavy main-loop iteration and lagged behind fast flicks.
// -------------------------------------------------------------------------

void DayZRuntimeService::RunCameraLoop() {
    uint32_t lastPid   = 0;
    uint64_t lastWorld = 0;
    uint64_t camBase   = 0;

    while (!m_stopRequested.load()) {
        const uint32_t pid   = m_camPid.load(std::memory_order_acquire);
        const uint64_t world = m_camWorldAddr.load(std::memory_order_acquire);

        if (pid == 0 || world == 0) {
            // No live session — idle without burning a core.
            lastPid = lastWorld = camBase = 0;
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
            continue;
        }

        if (pid != lastPid || world != lastWorld) {
            // Session (re)started — drop any stale cam-base pointer.
            lastPid   = pid;
            lastWorld = world;
            camBase   = 0;
        }

        CameraData cam;
        if (ReadCameraInto(pid, world, camBase, cam)) {
            std::lock_guard<std::mutex> lk(m_camMutex);
            m_latestCamForOverlay = cam;
        }

        // Sub-millisecond yield; the DMA read itself dominates the cadence.
        std::this_thread::sleep_for(std::chrono::microseconds(250));
    }
}

// -------------------------------------------------------------------------
// ResetAllReaders
// -------------------------------------------------------------------------

void DayZRuntimeService::ResetAllReaders() {
    m_nearReader.Reset();
    m_farReader.Reset();
    m_slowReader.Reset();
    m_bulletReader.Reset();
    m_itemReader.Reset();
    m_scoreboardReader.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.
    m_camPid.store(0,       std::memory_order_release);
    m_camWorldAddr.store(0, std::memory_order_release);

    m_camera        = CameraData{};
    m_cachedCamBase = 0;
    {
        std::lock_guard<std::mutex> lk(m_camMutex);
        m_latestCamForOverlay = CameraData{};
    }

    // Reset cached bone offsets and per-entity pointer cache.
    m_bonePlayerSkelOffset = -1;
    m_boneZombieSkelOffset = -1;
    m_boneAnimOffset       = -1;
    m_boneMatrixOffset     = -1;
    m_bonePointerCache.clear();
    m_ammoChainLoggedOnce  = false;
    m_boneDiagCount        = 0;

    m_state.scoreboard.clear();
    m_state.nearEntities.clear();
    m_state.farEntities.clear();
    m_state.slowEntities.clear();
    m_state.bullets.clear();
    m_state.items.clear();

    auto past = std::chrono::steady_clock::time_point{};
    m_state.nextScoreboardRefresh       = past;
    m_state.nextNearRefresh             = past;
    m_state.nextFarRefresh              = past;
    m_state.nextSlowRefresh             = past;
    m_state.nextBulletRefresh           = past;
    m_state.nextItemRefresh             = past;
    m_state.nextPlayersRefresh          = past;
    m_state.nextNetworkMetadataRefresh  = past;
    m_state.nextBoneRefresh             = past;
    m_state.nextVmmRefresh              = past;
}

// -------------------------------------------------------------------------
// RefreshBonesScatter
// -------------------------------------------------------------------------

void DayZRuntimeService::RefreshBonesScatter(
    uint32_t pid,
    std::vector<DayZPlayerEntry>& players,
    std::vector<DayZZombieEntry>& 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).
    //
    // 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)
    };
    // Zombie: 16 bones.  Verified against InfectedBoneId enum 2026-06-13.
    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)
    };

    // 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
        float                    boneXYZ[kMaxBones][3]; // individual local bone translations
        BonePointerCache*        cache;
        bool                     isZombie;
        SkeletonBones*           outSkel;
        std::optional<Vector3>*  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
    };

    std::vector<PerEntityData> eds;
    eds.reserve(players.size() + zombies.size());

    // ---- Skeleton presence tracking → log valid→invalid transitions ----
    // Debounced so a flapping skeleton logs at most once every few seconds.
    const int64_t nowMsBones = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now().time_since_epoch()).count();
    auto noteBoneOk = [&](uint64_t addr) {
        m_bonesTracked.insert(addr);
    };
    auto noteBoneLoss = [&](uint64_t addr, const std::string* label,
                            bool isZombie, const char* reason) {
        // Only the valid→invalid edge is interesting; ignore entities that were
        // never tracked (e.g. just appeared out of range).
        auto it = m_bonesTracked.find(addr);
        if (it == m_bonesTracked.end()) return;
        m_bonesTracked.erase(it);
        int64_t& last = m_boneLostLogAt[addr];
        if (nowMsBones - last < 3000) return;   // debounce
        last = nowMsBones;
        const char* who = (label && !label->empty()) ? label->c_str()
                                                      : (isZombie ? "zombie" : "<player>");
        spdlog::info("Skeleton lost: '{}' — {} [0x{:X}]", who, reason, addr);
    };

    // 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) return &c;

                // 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<uint64_t>(pid,
                            entAddr + Offsets::Common::VisualState, freshVs) && freshVs &&
                        m_memory.TryReadValue<uint64_t>(pid,
                            c.animClass + static_cast<uint64_t>(m_boneMatrixOffset), freshMat) && freshMat)
                    {
                        c.vsAddr  = freshVs;
                        c.matBase = freshMat;
                        c.valid   = true;
                        return &c;
                    }
                    c.animClass = 0;
                }
            }
        }

        // ---- VS address ----
        uint64_t vsAddr = 0;
        if (!m_memory.TryReadValue<uint64_t>(pid, entAddr + Offsets::Common::VisualState, vsAddr) || !vsAddr)
            return nullptr;

        // ---- Skeleton pointer + full probe (once per entity type per session) ----
        // Skel offset is per entity-type; anim/matrix offsets are shared.
        // When any of the three is unknown, run the full triple probe so they are
        // all validated against the same pointer chain simultaneously.
        int64_t&       skelOffCache = isZombie ? m_boneZombieSkelOffset : m_bonePlayerSkelOffset;
        const uint64_t* skelCands  = isZombie ? Offsets::Skeleton::ZombieCand
                                               : Offsets::Skeleton::PlayerCand;
        constexpr int skelCandCount = 2;

        uint64_t skelPtr = 0;

        if (skelOffCache >= 0 && m_boneAnimOffset >= 0 && m_boneMatrixOffset >= 0) {
            // All offsets cached — fast path: single read.
            if (!m_memory.TryReadValue<uint64_t>(pid,
                    entAddr + static_cast<uint64_t>(skelOffCache), skelPtr) || !skelPtr)
                return nullptr;
        } else {
            // Full triple probe: skel × anim × matrix.
            // Validates the entire chain before committing any cached value.
            bool resolved = false;
            for (int si = 0; si < skelCandCount && !resolved; ++si) {
                uint64_t sp = 0;
                if (!m_memory.TryReadValue<uint64_t>(pid, entAddr + skelCands[si], sp) || !sp) continue;
                for (uint64_t ac : Offsets::Skeleton::AnimCand) {
                    uint64_t animClass = 0;
                    if (!m_memory.TryReadValue<uint64_t>(pid, sp + ac, animClass) || !animClass) continue;
                    for (uint64_t mc : Offsets::Skeleton::MatrixCand) {
                        uint64_t matBase = 0;
                        if (!m_memory.TryReadValue<uint64_t>(pid, animClass + mc, matBase) || !matBase) continue;
                        struct Vec3f { float x, y, z; };
                        Vec3f test{};
                        const uint64_t testAddr = matBase + Offsets::Skeleton::BoneTranslationOffset
                                                  + 21 * Offsets::Skeleton::BoneStride;
                        if (!m_memory.TryReadValue<Vec3f>(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.
                        if (test.x == 0.0f && test.y == 0.0f && test.z == 0.0f) continue;
                        if (fabsf(test.x) > 5.0f || fabsf(test.y) > 5.0f || fabsf(test.z) > 5.0f) continue;
                        skelOffCache       = static_cast<int64_t>(skelCands[si]);
                        m_boneAnimOffset   = static_cast<int64_t>(ac);
                        m_boneMatrixOffset = static_cast<int64_t>(mc);
                        skelPtr = sp;
                        spdlog::info("RefreshBonesScatter: {} skelOff=0x{:X} animOff=0x{:X} matrixOff=0x{:X}",
                                     isZombie ? "zombie" : "player", skelCands[si], ac, mc);
                        resolved = true;
                        break;
                    }
                    if (resolved) break;
                }
            }
            if (!resolved) return nullptr;
        }

        // ---- Resolve anim class → matBase ----
        uint64_t animClass = 0;
        if (!m_memory.TryReadValue<uint64_t>(pid,
                skelPtr + static_cast<uint64_t>(m_boneAnimOffset), animClass) || !animClass)
            return nullptr;

        uint64_t matBase = 0;
        if (!m_memory.TryReadValue<uint64_t>(pid,
                animClass + static_cast<uint64_t>(m_boneMatrixOffset), matBase) || !matBase)
            return nullptr;

        BonePointerCache c{ vsAddr, animClass, matBase, true };
        m_bonePointerCache[entAddr] = c;
        return &m_bonePointerCache[entAddr];
    };

    // Collect in-range entities into eds[].
    auto collect = [&](auto& entityVec, bool isZombie, float maxDist2) {
        const int* boneIdxs = isZombie ? kZombieBones : kPlayerBones;
        const int  numBones = isZombie ? 16 : 15;
        for (auto& e : entityVec) {
            if (!e.position) continue;
            float dx = e.position->x - m_camera.translation.x;
            float dy = e.position->y - m_camera.translation.y;
            float dz = e.position->z - m_camera.translation.z;
            if (dx*dx + dy*dy + dz*dz > maxDist2) continue;

            using ElemT = std::decay_t<decltype(e)>;
            const std::string* label = nullptr;
            if constexpr (std::is_same_v<ElemT, DayZPlayerEntry>)
                label = &e.nickname;

            BonePointerCache* cache = ensureCached(e.address, isZombie);
            if (!cache) {
                // 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;
            }

            eds.push_back({});
            auto& ed    = eds.back();
            ed.cache     = cache;
            ed.isZombie  = isZombie;
            ed.outSkel   = &e.skeleton;
            ed.outPos    = &e.position;
            ed.numBones  = numBones;
            ed.boneIdxs  = boneIdxs;
            ed.entityPos = *e.position;
            ed.addr      = e.address;
            ed.label     = label;
            std::memset(ed.vsMatrix, 0, sizeof(ed.vsMatrix));
            std::memset(ed.boneXYZ,  0, sizeof(ed.boneXYZ));
        }
    };

    const float pd = m_config.bonePlayerMaxDist;
    const float zd = m_config.boneZombieMaxDist;
    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;

    // ── 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<uint64_t> freshMatBases(eds.size(), 0);
        std::vector<uint64_t> freshVsAddrs(eds.size(), 0);
        std::vector<VmmAccessor::ScatterEntry> 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<uint64_t>(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<VmmAccessor::ScatterEntry> scatter;
    scatter.reserve(eds.size() * (1 + kMaxBones));

    for (auto& ed : eds) {
        scatter.push_back({ ed.cache->vsAddr + 8,
                            ed.vsMatrix,
                            sizeof(ed.vsMatrix) });
        for (int i = 0; i < ed.numBones; ++i) {
            const uint64_t boneAddr = ed.cache->matBase
                + Offsets::Skeleton::BoneTranslationOffset
                + static_cast<uint64_t>(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;

        // ---- 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 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];
        };

        SkeletonBones& skel = *ed.outSkel;
        int ok = 0;

        if (!ed.isZombie) {
            Vector3* boneOut[15] = {
                &skel.neck, &skel.head, &skel.spine,
                &skel.rightShoulder, &skel.rightElbow, &skel.rightHand,
                &skel.leftShoulder,  &skel.leftElbow,  &skel.leftHand,
                &skel.rightHip, &skel.rightKnee, &skel.rightAnkle,
                &skel.leftHip,  &skel.leftKnee,  &skel.leftAnkle
            };
            for (int i = 0; i < 15; ++i) {
                transform(i, *boneOut[i]);
                if (boneOut[i]->x != 0.0f || boneOut[i]->y != 0.0f || boneOut[i]->z != 0.0f) ++ok;
            }
        } else {
            Vector3* boneOut[16] = {
                &skel.neck, &skel.head, &skel.spine, &skel.pelvis,
                &skel.leftShoulder,  &skel.leftElbow,  &skel.leftHand,
                &skel.rightShoulder, &skel.rightElbow, &skel.rightHand,
                &skel.rightHip, &skel.rightKnee, &skel.rightAnkle,
                &skel.leftHip,  &skel.leftKnee,  &skel.leftAnkle
            };
            for (int i = 0; i < 16; ++i) {
                transform(i, *boneOut[i]);
                if (boneOut[i]->x != 0.0f || boneOut[i]->y != 0.0f || boneOut[i]->z != 0.0f) ++ok;
            }
        }

        skel.valid = (ok >= 4);
        const char* lossReason = skel.valid ? nullptr : "insufficient bones in scatter";
        bool        headDrift  = false;

        // ---- Validate head bone against the FRESH VS translation ----
        if (skel.valid) {
            const float refX = vsOk ? vsX : ed.entityPos.x;
            const float refY = vsOk ? vsY : ed.entityPos.y;
            const float refZ = vsOk ? vsZ : ed.entityPos.z;
            const float hx = skel.head.x - refX;
            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;
                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.
            }
        }

        // Record presence / log the valid→invalid edge for diagnostics.
        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 {
            // 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,
    // so the map can't grow without bound on busy servers.
    if (m_boneLostLogAt.size() > 256) {
        for (auto it = m_boneLostLogAt.begin(); it != m_boneLostLogAt.end(); )
            it = (nowMsBones - it->second > 10000) ? m_boneLostLogAt.erase(it)
                                                   : std::next(it);
    }
}

// -------------------------------------------------------------------------
// BuildScoreboardNames
// -------------------------------------------------------------------------

std::unordered_map<uint32_t, std::string>
DayZRuntimeService::BuildScoreboardNames(
    const std::vector<DayZScoreboardPlayer>& scoreboard)
{
    std::unordered_map<uint32_t, std::string> result;
    result.reserve(scoreboard.size());
    for (const auto& player : scoreboard) {
        if (player.networkId != 0) {
            result[player.networkId] = player.playerName;
        }
    }
    return result;
}
