Initial commit: DayZ memory C++ port with DMA backend and overlay
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
#include "Config.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
static constexpr const char* kPath = "config/overlay.json";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Load
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
OverlayConfig OverlayConfig::Load(const std::string& path) {
|
||||
OverlayConfig cfg;
|
||||
|
||||
std::ifstream f(path);
|
||||
if (!f.is_open()) {
|
||||
spdlog::info("Config: {} not found, using defaults", path);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
try {
|
||||
json j = json::parse(f);
|
||||
|
||||
// ESP toggles
|
||||
if (j.contains("showPlayers")) cfg.showPlayers = j["showPlayers"].get<bool>();
|
||||
if (j.contains("showAnimals")) cfg.showAnimals = j["showAnimals"].get<bool>();
|
||||
if (j.contains("showZombies")) cfg.showZombies = j["showZombies"].get<bool>();
|
||||
if (j.contains("showItems")) cfg.showItems = j["showItems"].get<bool>();
|
||||
if (j.contains("showBox")) cfg.showBox = j["showBox"].get<bool>();
|
||||
if (j.contains("showSkeleton")) cfg.showSkeleton = j["showSkeleton"].get<bool>();
|
||||
if (j.contains("showHeadDot")) cfg.showHeadDot = j["showHeadDot"].get<bool>();
|
||||
if (j.contains("showCorpses")) cfg.showCorpses = j["showCorpses"].get<bool>();
|
||||
if (j.contains("showWeapon")) cfg.showWeapon = j["showWeapon"].get<bool>();
|
||||
if (j.contains("showHealthBar")) cfg.showHealthBar = j["showHealthBar"].get<bool>();
|
||||
if (j.contains("showHealthNumber")) cfg.showHealthNumber = j["showHealthNumber"].get<bool>();
|
||||
|
||||
// Distances
|
||||
if (j.contains("playerMaxDist")) cfg.playerMaxDist = j["playerMaxDist"].get<float>();
|
||||
if (j.contains("animalMaxDist")) cfg.animalMaxDist = j["animalMaxDist"].get<float>();
|
||||
if (j.contains("zombieMaxDist")) cfg.zombieMaxDist = j["zombieMaxDist"].get<float>();
|
||||
if (j.contains("itemMaxDist")) cfg.itemMaxDist = j["itemMaxDist"].get<float>();
|
||||
|
||||
// Per-category item toggles
|
||||
if (j.contains("itemCategories") && j["itemCategories"].is_object()) {
|
||||
for (auto& [key, val] : j["itemCategories"].items()) {
|
||||
if (val.is_boolean()) cfg.itemCategories[key] = val.get<bool>();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution override
|
||||
if (j.contains("overlayWidth")) cfg.overlayWidth = j["overlayWidth"].get<int>();
|
||||
if (j.contains("overlayHeight")) cfg.overlayHeight = j["overlayHeight"].get<int>();
|
||||
|
||||
// Stretched resolution
|
||||
if (j.contains("renderWidth")) cfg.renderWidth = j["renderWidth"].get<int>();
|
||||
if (j.contains("renderHeight")) cfg.renderHeight = j["renderHeight"].get<int>();
|
||||
if (j.contains("stretchToFill")) cfg.stretchToFill = j["stretchToFill"].get<bool>();
|
||||
|
||||
// Web radar
|
||||
if (j.contains("webBindAddress")) cfg.webBindAddress = j["webBindAddress"].get<std::string>();
|
||||
if (j.contains("webPort")) cfg.webPort = j["webPort"].get<int>();
|
||||
if (j.contains("webPassword")) cfg.webPassword = j["webPassword"].get<std::string>();
|
||||
|
||||
spdlog::info("Config: loaded from {}", path);
|
||||
} catch (const std::exception& ex) {
|
||||
spdlog::warn("Config: parse error in {} — {} — using defaults", path, ex.what());
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Save
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void OverlayConfig::Save(const std::string& path) const {
|
||||
try {
|
||||
std::filesystem::create_directories(
|
||||
std::filesystem::path(path).parent_path());
|
||||
|
||||
json j;
|
||||
|
||||
// ESP toggles
|
||||
j["showPlayers"] = showPlayers;
|
||||
j["showAnimals"] = showAnimals;
|
||||
j["showZombies"] = showZombies;
|
||||
j["showItems"] = showItems;
|
||||
j["showBox"] = showBox;
|
||||
j["showSkeleton"] = showSkeleton;
|
||||
j["showHeadDot"] = showHeadDot;
|
||||
j["showCorpses"] = showCorpses;
|
||||
j["showWeapon"] = showWeapon;
|
||||
j["showHealthBar"] = showHealthBar;
|
||||
j["showHealthNumber"] = showHealthNumber;
|
||||
|
||||
// Distances
|
||||
j["playerMaxDist"] = playerMaxDist;
|
||||
j["animalMaxDist"] = animalMaxDist;
|
||||
j["zombieMaxDist"] = zombieMaxDist;
|
||||
j["itemMaxDist"] = itemMaxDist;
|
||||
|
||||
// Per-category item toggles
|
||||
json cats = json::object();
|
||||
for (const auto& [key, enabled] : itemCategories)
|
||||
cats[key] = enabled;
|
||||
j["itemCategories"] = cats;
|
||||
|
||||
// Resolution override
|
||||
j["overlayWidth"] = overlayWidth;
|
||||
j["overlayHeight"] = overlayHeight;
|
||||
|
||||
// Stretched resolution
|
||||
j["renderWidth"] = renderWidth;
|
||||
j["renderHeight"] = renderHeight;
|
||||
j["stretchToFill"] = stretchToFill;
|
||||
|
||||
// Web radar
|
||||
j["webBindAddress"] = webBindAddress;
|
||||
j["webPort"] = webPort;
|
||||
j["webPassword"] = webPassword;
|
||||
|
||||
std::ofstream f(path);
|
||||
f << j.dump(2);
|
||||
spdlog::debug("Config: saved to {}", path);
|
||||
} catch (const std::exception& ex) {
|
||||
spdlog::warn("Config: failed to save {} — {}", path, ex.what());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OverlayConfig — all user-editable runtime settings.
|
||||
// Persisted as config/overlay.json (nlohmann/json).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct OverlayConfig {
|
||||
// ---- ESP toggles ----
|
||||
bool showPlayers = true;
|
||||
bool showAnimals = true;
|
||||
bool showZombies = true;
|
||||
bool showItems = true;
|
||||
bool showBox = true; // bounding box around players
|
||||
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
|
||||
|
||||
// ---- Draw-distance limits (metres) ----
|
||||
float playerMaxDist = 1000.0f;
|
||||
float animalMaxDist = 1000.0f;
|
||||
float zombieMaxDist = 500.0f;
|
||||
float itemMaxDist = 200.0f;
|
||||
|
||||
// ---- Per-category item toggles (key = filterKey from item_filters.json) ----
|
||||
// Missing key → enabled by default.
|
||||
std::map<std::string, bool> itemCategories;
|
||||
|
||||
// ---- Overlay resolution override ----
|
||||
// 0 = auto-detect from GetSystemMetrics (default behaviour).
|
||||
// Set both to your MONITOR (display) resolution, not the game render resolution.
|
||||
int overlayWidth = 0;
|
||||
int overlayHeight = 0;
|
||||
|
||||
// ---- Stretched resolution support ----
|
||||
// renderWidth / renderHeight: the resolution DayZ actually renders at
|
||||
// (e.g. 1280x960 when running 4:3 stretched on a 1920x1080 monitor).
|
||||
// Set to 0 to disable (assumes render == display).
|
||||
//
|
||||
// stretchToFill: true = GPU stretches the render to fill the monitor (no black bars).
|
||||
// false = game maintains aspect ratio with letterbox/pillarbox bars.
|
||||
//
|
||||
// overlayWidth/Height should ALWAYS equal your monitor resolution regardless of mode.
|
||||
int renderWidth = 0;
|
||||
int renderHeight = 0;
|
||||
bool stretchToFill = true;
|
||||
|
||||
// ---- Web radar ----
|
||||
std::string webBindAddress = "0.0.0.0";
|
||||
int webPort = 7777;
|
||||
std::string webPassword = "";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Load from path (returns default-constructed config on any error).
|
||||
static OverlayConfig Load(const std::string& path);
|
||||
|
||||
// Save to path (creates parent directories if needed).
|
||||
void Save(const std::string& path) const;
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Primitive math types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct Vector3 {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
float z = 0.0f;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Local player
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct LocalPlayerData {
|
||||
uint64_t entityAddress = 0;
|
||||
uint64_t visualStateAddress = 0;
|
||||
Vector3 position;
|
||||
float lookDirection = 0.0f; // heading in degrees
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Network / server metadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct NetworkMetadata {
|
||||
std::string serverName;
|
||||
std::string gameVersion;
|
||||
std::optional<std::string> serverMapName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scoreboard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZScoreboardPlayer {
|
||||
uint32_t networkId = 0;
|
||||
std::string steamId;
|
||||
std::string playerName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Near entity list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZNearEntityEntry {
|
||||
uint64_t address = 0;
|
||||
uint32_t networkId = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Far entity list (same schema as near)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZFarEntityEntry {
|
||||
uint64_t address = 0;
|
||||
uint32_t networkId = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Slow entity list (no networkId)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZSlowEntityEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Skeleton bones (12 named bones used for segment rendering)
|
||||
// Layout matches Spectre PLAYER_BONE_IDS / INFECTED_BONE_IDS segment topology.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Bone layout mirrors Spectre stable segments.
|
||||
// Player: spine→neck→head; neck→shoulders→elbows→hands; spine→hips→knees→ankles
|
||||
// Zombie: spine→neck→head; spine→shoulders→elbows→hands; spine→pelvis→hips→knees→ankles
|
||||
struct SkeletonBones {
|
||||
Vector3 neck;
|
||||
Vector3 head;
|
||||
Vector3 spine; // chest hub (player bone 18, zombie bone 19)
|
||||
Vector3 pelvis; // zombie only — connects spine to hips
|
||||
Vector3 rightShoulder;
|
||||
Vector3 rightElbow;
|
||||
Vector3 rightHand;
|
||||
Vector3 leftShoulder;
|
||||
Vector3 leftElbow;
|
||||
Vector3 leftHand;
|
||||
Vector3 rightHip;
|
||||
Vector3 rightKnee;
|
||||
Vector3 rightAnkle;
|
||||
Vector3 leftHip;
|
||||
Vector3 leftKnee;
|
||||
Vector3 leftAnkle;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Player entry (full)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZPlayerEntry {
|
||||
uint64_t address = 0;
|
||||
uint32_t networkId = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
bool isDead = false;
|
||||
bool isAdmin = false; // model matches a known invisible/admin model path
|
||||
std::string nickname;
|
||||
std::string itemInHands;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
SkeletonBones skeleton;
|
||||
// health in the range [0, 100]. -1.0f = not yet read / offset unknown.
|
||||
float health = -1.0f;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bullet entry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZBulletEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<Vector3> direction;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string directionSource; // e.g. "visual", "raw"
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Item list entry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZItemListEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::string entityName;
|
||||
std::string cleanName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
std::string filterKey; // lower-case composite for filtering
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Specialised slow-list entity types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct DayZAnimalEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
struct DayZZombieEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
SkeletonBones skeleton;
|
||||
};
|
||||
|
||||
// Vehicles: cars, boats, helicopters, etc.
|
||||
enum class VehicleKind : uint8_t {
|
||||
Unknown = 0,
|
||||
Car,
|
||||
Boat,
|
||||
Helicopter,
|
||||
Plane,
|
||||
};
|
||||
|
||||
struct DayZCarAndBoatEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
VehicleKind kind = VehicleKind::Unknown;
|
||||
};
|
||||
|
||||
struct DayZOtherEntityEntry {
|
||||
uint64_t address = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Camera data (read from World+0x1B8 inline struct each frame)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct CameraData {
|
||||
Vector3 invertedViewRight;
|
||||
Vector3 invertedViewUp;
|
||||
Vector3 invertedViewForward;
|
||||
Vector3 translation;
|
||||
float projectionD1x = 0.0f; // horizontal FOV scale
|
||||
float projectionD2y = 0.0f; // vertical FOV scale
|
||||
bool valid = false;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
struct RuntimeSession {
|
||||
uint32_t processId = 0;
|
||||
uint64_t gameBaseAddress = 0;
|
||||
uint64_t worldAddress = 0;
|
||||
uint64_t networkManagerAddress = 0;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
// Returns a direct pointer into the Windows RCDATA resource for the given map
|
||||
// ID, plus its byte length. The pointer is valid for the entire process lifetime
|
||||
// — it points into the exe's memory-mapped image, so no copy is made.
|
||||
// Returns {nullptr, 0} if the map was not compiled into the binary.
|
||||
std::pair<const uint8_t*, size_t> GetEmbeddedMap(const std::string& mapId);
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h> // needed before spdlog on MSVC to avoid winsock conflicts
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <spdlog/sinks/stdout_color_sinks.h>
|
||||
#include <spdlog/sinks/basic_file_sink.h>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// AppLogger
|
||||
// Call AppLogger::Init() once from main() before anything else.
|
||||
// Then use spdlog::get("dayz") or the DAYZ_LOG_* macros everywhere.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
namespace AppLogger {
|
||||
|
||||
inline void Init(spdlog::level::level_enum level = spdlog::level::info) {
|
||||
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
|
||||
console_sink->set_level(level);
|
||||
console_sink->set_pattern("[%H:%M:%S.%e] [%^%l%$] [%n] %v");
|
||||
|
||||
auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>("logs/dayz-memory.log", /*truncate=*/true);
|
||||
file_sink->set_level(spdlog::level::trace);
|
||||
file_sink->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%n] %v");
|
||||
|
||||
auto logger = std::make_shared<spdlog::logger>(
|
||||
"dayz",
|
||||
spdlog::sinks_init_list{console_sink, file_sink}
|
||||
);
|
||||
logger->set_level(spdlog::level::trace);
|
||||
logger->flush_on(spdlog::level::warn);
|
||||
|
||||
spdlog::register_logger(logger);
|
||||
spdlog::set_default_logger(logger);
|
||||
}
|
||||
|
||||
inline std::shared_ptr<spdlog::logger> Get() {
|
||||
return spdlog::get("dayz");
|
||||
}
|
||||
|
||||
} // namespace AppLogger
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <numbers> // std::numbers::pi (C++20)
|
||||
|
||||
#include "../Core/Models.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Header-only port of C# MemoryValidation.cs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
namespace MemoryValidation {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Address sanity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Returns true if the address falls in the valid user-mode range:
|
||||
/// [0x0000000100000000, 0x0000FFFF00000000)
|
||||
[[nodiscard]] inline bool IsValidUserAddress(uint64_t address) noexcept {
|
||||
constexpr uint64_t kLow = 0x0000000100000000ULL;
|
||||
constexpr uint64_t kHigh = 0x0000FFFF00000000ULL;
|
||||
return address >= kLow && address < kHigh;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vector plausibility
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Returns true when all three components are finite and within ±100 000 units.
|
||||
[[nodiscard]] inline bool IsValidVector(const Vector3& v) noexcept {
|
||||
constexpr float kLimit = 100000.0f;
|
||||
return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z)
|
||||
&& std::abs(v.x) < kLimit
|
||||
&& std::abs(v.y) < kLimit
|
||||
&& std::abs(v.z) < kLimit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// String helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Returns true when the string is non-empty and not all whitespace.
|
||||
[[nodiscard]] inline bool IsUsableString(const std::string& s) noexcept {
|
||||
if (s.empty()) return false;
|
||||
for (char c : s) {
|
||||
if (c != ' ' && c != '\t' && c != '\r' && c != '\n') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
/// Classifies a char as "readable": printable ASCII (0x20-0x7E) or high-byte
|
||||
/// (Latin-1 / Windows-1251 printable range 0xA0-0xFF).
|
||||
[[nodiscard]] inline bool IsReadableChar(unsigned char c) noexcept {
|
||||
return (c >= 0x20 && c <= 0x7E) || (c >= 0xA0);
|
||||
}
|
||||
|
||||
/// Returns the fraction of chars that are readable (0.0–1.0).
|
||||
[[nodiscard]] inline double ReadableRatio(const std::string& s) noexcept {
|
||||
if (s.empty()) return 0.0;
|
||||
int readable = 0;
|
||||
for (unsigned char c : s) {
|
||||
if (IsReadableChar(c)) ++readable;
|
||||
}
|
||||
return static_cast<double>(readable) / static_cast<double>(s.size());
|
||||
}
|
||||
|
||||
/// Returns true when the string contains at least one control character
|
||||
/// (excluding normal whitespace: space, tab, CR, LF).
|
||||
[[nodiscard]] inline bool HasUnwantedControlChar(const std::string& s) noexcept {
|
||||
for (unsigned char c : s) {
|
||||
if (c < 0x20 && c != '\t' && c != '\r' && c != '\n') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
/// Returns true when the server name is plausible:
|
||||
/// - length 3–96
|
||||
/// - no control characters
|
||||
/// - readable-char ratio > 50 %
|
||||
/// - contains at least one ASCII alphanumeric character
|
||||
[[nodiscard]] inline bool IsPlausibleServerName(const std::string& s) noexcept {
|
||||
if (s.size() < 3 || s.size() > 96) return false;
|
||||
if (detail::HasUnwantedControlChar(s)) return false;
|
||||
if (detail::ReadableRatio(s) < 0.5) return false;
|
||||
for (char c : s) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true when the game version string is plausible:
|
||||
/// - length 3–32
|
||||
/// - contains at least one digit
|
||||
/// - contains at least one of '.', '-', '_'
|
||||
[[nodiscard]] inline bool IsPlausibleGameVersion(const std::string& s) noexcept {
|
||||
if (s.size() < 3 || s.size() > 32) return false;
|
||||
bool hasDigit = false;
|
||||
bool hasSeparator = false;
|
||||
for (char c : s) {
|
||||
if (std::isdigit(static_cast<unsigned char>(c))) hasDigit = true;
|
||||
if (c == '.' || c == '-' || c == '_') hasSeparator = true;
|
||||
}
|
||||
return hasDigit && hasSeparator;
|
||||
}
|
||||
|
||||
/// Sanitizes an entity string read from memory.
|
||||
/// Returns empty string if the content has unwanted control characters
|
||||
/// or if the readable-char ratio is below 50 %.
|
||||
/// Otherwise returns the original string.
|
||||
[[nodiscard]] inline std::string SanitizeEntityString(const std::string& s) {
|
||||
if (s.empty()) return {};
|
||||
if (detail::HasUnwantedControlChar(s)) return {};
|
||||
if (detail::ReadableRatio(s) < 0.5) return {};
|
||||
return s;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Heading helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Normalises an arbitrary heading (degrees) into [0, 360).
|
||||
[[nodiscard]] inline float NormalizeHeading(float degrees) noexcept {
|
||||
float result = std::fmod(degrees, 360.0f);
|
||||
if (result < 0.0f) result += 360.0f;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Computes a world heading (degrees, 0 = north/+Z) from a 2-D direction
|
||||
/// vector (dirX, dirY) where dirY typically corresponds to the forward axis.
|
||||
///
|
||||
/// Formula mirrors the C# original:
|
||||
/// heading = atan2(dirY, dirX) * 180 / PI + 90
|
||||
/// then normalized to [0, 360).
|
||||
///
|
||||
/// Returns false when the direction vector is degenerate (near-zero length).
|
||||
[[nodiscard]] inline bool TryGetCorrectedHeadingFromDirection(
|
||||
float dirX, float dirY, float& heading) noexcept
|
||||
{
|
||||
constexpr float kEpsilon = 1e-6f;
|
||||
if (std::abs(dirX) < kEpsilon && std::abs(dirY) < kEpsilon) {
|
||||
heading = 0.0f;
|
||||
return false;
|
||||
}
|
||||
constexpr float kRadToDeg = 180.0f / static_cast<float>(std::numbers::pi);
|
||||
heading = NormalizeHeading(std::atan2(dirY, dirX) * kRadToDeg + 90.0f);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace MemoryValidation
|
||||
@@ -0,0 +1,299 @@
|
||||
#include "VmmAccessor.h"
|
||||
|
||||
// VmmAccessor.h includes <Windows.h> with WIN32_LEAN_AND_MEAN, which strips
|
||||
// <winternl.h>. vmmdll.h needs NTSTATUS from <winternl.h>, so pull it in
|
||||
// explicitly here before including vmmdll.h.
|
||||
#include <winternl.h>
|
||||
|
||||
// vmmdll.h is needed here only for VMMDLL_ConfigSet / VMMDLL_OPT_REFRESH_ALL.
|
||||
#include "vmmdll.h"
|
||||
|
||||
#include <algorithm> // std::find
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialize
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::Initialize(bool useMemoryMap) {
|
||||
std::unique_lock<std::shared_mutex> lk(m_accessMutex);
|
||||
m_process.reset();
|
||||
m_attachedPid = 0;
|
||||
spdlog::trace("VmmAccessor: initialising DMA (useMemoryMap={})...", useMemoryMap);
|
||||
m_dma = std::make_unique<DMA>(useMemoryMap);
|
||||
if (!m_dma->is_initialized()) {
|
||||
spdlog::error("VmmAccessor: DMA initialisation failed.");
|
||||
return false;
|
||||
}
|
||||
spdlog::info("VmmAccessor: DMA initialised.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ForceRefresh
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void VmmAccessor::ForceRefresh() {
|
||||
if (!IsInitialized()) return;
|
||||
VMMDLL_ConfigSet(m_dma->handle.get(), VMMDLL_OPT_REFRESH_ALL, 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryFindProcess
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::TryFindProcess(const std::string& processName,
|
||||
uint32_t& outPid) {
|
||||
if (!IsInitialized()) return false;
|
||||
|
||||
DWORD pid = m_dma->get_process_id(processName);
|
||||
if (pid == 0) return false;
|
||||
|
||||
// Already attached to this exact PID — reuse the existing Process handle.
|
||||
// WaitForSession re-attaches every 3 s while waiting for spawn; without this
|
||||
// early-out we'd needlessly tear down a valid handle and re-run the CR3 fix
|
||||
// (and log it) on every one of those ticks.
|
||||
if (m_process && m_attachedPid == static_cast<uint32_t>(pid)) {
|
||||
outPid = m_attachedPid;
|
||||
return true;
|
||||
}
|
||||
|
||||
// (Re-)create Process bound to this PID so all subsequent reads use it.
|
||||
// Unique lock: blocks the camera thread's reads while the handle is swapped
|
||||
// so it can never dereference a half-destroyed Process.
|
||||
std::unique_lock<std::shared_mutex> lk(m_accessMutex);
|
||||
m_process = std::make_unique<Process>(*m_dma, processName);
|
||||
m_attachedPid = static_cast<uint32_t>(pid);
|
||||
outPid = m_attachedPid;
|
||||
|
||||
// Apply the CR3 fix so reads use UserDirectoryTableBase instead of the
|
||||
// kernel DTB. Required for games (including DayZ) that run user-mode
|
||||
// code under a separate paging context. No-op if already on the right DTB.
|
||||
if (!m_process->fix_cr3(processName)) {
|
||||
spdlog::warn("VmmAccessor: fix_cr3 failed for '{}' — reads may be unreliable.", processName);
|
||||
} else {
|
||||
spdlog::info("VmmAccessor: CR3 fix applied for '{}'.", processName);
|
||||
}
|
||||
|
||||
spdlog::trace("VmmAccessor: attached to '{}' PID={}", processName, m_attachedPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryGetModuleBase
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::TryGetModuleBase(uint32_t /*pid*/,
|
||||
const std::string& moduleName,
|
||||
uint64_t& outBase) {
|
||||
if (!IsAttached()) return false;
|
||||
|
||||
uint64_t base = m_process->get_base_address(moduleName);
|
||||
if (base == 0) {
|
||||
spdlog::warn("VmmAccessor: module '{}' base not found.", moduleName);
|
||||
return false;
|
||||
}
|
||||
spdlog::trace("VmmAccessor: module '{}' base=0x{:X}", moduleName, base);
|
||||
outBase = base;
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IsProcessAlive
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::IsProcessAlive(uint32_t pid,
|
||||
const std::string& processName) {
|
||||
if (!IsInitialized()) return false;
|
||||
|
||||
DWORD livePid = m_dma->get_process_id(processName);
|
||||
return livePid != 0 && static_cast<uint32_t>(livePid) == pid;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadRaw (private)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::ReadRaw(uint32_t /*pid*/, uint64_t address,
|
||||
void* buf, size_t size) {
|
||||
if (!buf || size == 0) return false;
|
||||
std::shared_lock<std::shared_mutex> lk(m_accessMutex);
|
||||
if (!m_process) return false;
|
||||
return m_process->read(address, buf, size);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryReadPointer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::TryReadPointer(uint32_t pid, uint64_t address,
|
||||
uint64_t& outPointer) {
|
||||
if (!MemoryValidation::IsValidUserAddress(address)) return false;
|
||||
|
||||
uint64_t ptr = 0;
|
||||
if (!ReadRaw(pid, address, &ptr, sizeof(ptr))) return false;
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(ptr)) return false;
|
||||
|
||||
outPointer = ptr;
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ScatterRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::ScatterRead(uint32_t pid, std::vector<ScatterEntry>& entries) {
|
||||
if (entries.empty()) return false;
|
||||
std::shared_lock<std::shared_mutex> lk(m_accessMutex);
|
||||
if (!m_process) return false;
|
||||
|
||||
VMMDLL_SCATTER_HANDLE hScatter = m_process->create_scatter(static_cast<DWORD>(pid));
|
||||
if (!hScatter) return false;
|
||||
|
||||
for (auto& e : entries) {
|
||||
if (e.buffer && e.size > 0 && MemoryValidation::IsValidUserAddress(e.address))
|
||||
m_process->add_read_scatter(hScatter, e.address, e.buffer, e.size);
|
||||
}
|
||||
|
||||
m_process->execute_scatter(hScatter, static_cast<DWORD>(pid));
|
||||
m_process->close_scatter(hScatter);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadBytes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool VmmAccessor::ReadBytes(uint32_t pid,
|
||||
uint64_t address,
|
||||
size_t count,
|
||||
std::vector<uint8_t>& outBytes) {
|
||||
if (count == 0) {
|
||||
outBytes.clear();
|
||||
return true;
|
||||
}
|
||||
outBytes.resize(count);
|
||||
if (!ReadRaw(pid, address, outBytes.data(), count)) {
|
||||
outBytes.clear();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DecodeArmaBytes (static, private)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string VmmAccessor::DecodeArmaBytes(const std::vector<uint8_t>& bytes) {
|
||||
if (bytes.empty()) return {};
|
||||
|
||||
// Determine effective length (stop at first null terminator).
|
||||
size_t len = 0;
|
||||
while (len < bytes.size() && bytes[len] != 0) ++len;
|
||||
if (len == 0) return {};
|
||||
|
||||
// --- Attempt 1: validate as UTF-8 ---
|
||||
auto isValidUtf8 = [](const uint8_t* data, size_t n) -> bool {
|
||||
size_t i = 0;
|
||||
while (i < n) {
|
||||
uint8_t b = data[i];
|
||||
int seqLen = 0;
|
||||
if ((b & 0x80) == 0x00) seqLen = 1;
|
||||
else if ((b & 0xE0) == 0xC0) seqLen = 2;
|
||||
else if ((b & 0xF0) == 0xE0) seqLen = 3;
|
||||
else if ((b & 0xF8) == 0xF0) seqLen = 4;
|
||||
else return false;
|
||||
if (i + seqLen > n) return false;
|
||||
for (int j = 1; j < seqLen; ++j) {
|
||||
if ((data[i + j] & 0xC0) != 0x80) return false;
|
||||
}
|
||||
i += seqLen;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (isValidUtf8(bytes.data(), len)) {
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data()), len);
|
||||
}
|
||||
|
||||
// --- Attempt 2: treat as Windows CP1251, convert to UTF-8 ---
|
||||
int wideLen = MultiByteToWideChar(1251 /*CP1251*/, 0,
|
||||
reinterpret_cast<LPCCH>(bytes.data()),
|
||||
static_cast<int>(len),
|
||||
nullptr, 0);
|
||||
if (wideLen <= 0) {
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data()), len);
|
||||
}
|
||||
|
||||
std::wstring wide(static_cast<size_t>(wideLen), L'\0');
|
||||
MultiByteToWideChar(1251, 0,
|
||||
reinterpret_cast<LPCCH>(bytes.data()),
|
||||
static_cast<int>(len),
|
||||
wide.data(), wideLen);
|
||||
|
||||
int utf8Len = WideCharToMultiByte(CP_UTF8, 0,
|
||||
wide.data(), wideLen,
|
||||
nullptr, 0,
|
||||
nullptr, nullptr);
|
||||
if (utf8Len <= 0) {
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data()), len);
|
||||
}
|
||||
|
||||
std::string utf8(static_cast<size_t>(utf8Len), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0,
|
||||
wide.data(), wideLen,
|
||||
utf8.data(), utf8Len,
|
||||
nullptr, nullptr);
|
||||
return utf8;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadArmaString
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string VmmAccessor::ReadArmaString(uint32_t pid, uint64_t address,
|
||||
size_t maxLength) {
|
||||
if (!MemoryValidation::IsValidUserAddress(address)) return {};
|
||||
|
||||
// Read the 16-bit length field at address + 0x8
|
||||
uint16_t strLen = 0;
|
||||
if (!ReadRaw(pid, address + 0x8, &strLen, sizeof(strLen))) return {};
|
||||
if (strLen == 0) return {};
|
||||
|
||||
size_t readLen = static_cast<size_t>(strLen);
|
||||
if (readLen > maxLength) readLen = maxLength;
|
||||
|
||||
// Read the character data at address + 0x10
|
||||
std::vector<uint8_t> buf(readLen);
|
||||
if (!ReadRaw(pid, address + 0x10, buf.data(), readLen)) return {};
|
||||
|
||||
return DecodeArmaBytes(buf);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadCString
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string VmmAccessor::ReadCString(uint32_t pid, uint64_t address,
|
||||
size_t maxLength) {
|
||||
if (!MemoryValidation::IsValidUserAddress(address)) return {};
|
||||
if (maxLength == 0 || !IsAttached()) return {};
|
||||
|
||||
std::vector<uint8_t> buf(maxLength);
|
||||
if (!ReadRaw(pid, address, buf.data(), maxLength)) {
|
||||
// Partial reads: try progressively smaller chunks down to 1 page.
|
||||
// If even that fails, give up.
|
||||
if (maxLength <= 64 || !ReadRaw(pid, address, buf.data(), 64)) {
|
||||
return {};
|
||||
}
|
||||
buf.resize(64);
|
||||
}
|
||||
|
||||
// Truncate at null terminator if present.
|
||||
auto nullPos = std::find(buf.begin(), buf.end(), uint8_t{0});
|
||||
buf.erase(nullPos, buf.end());
|
||||
if (buf.empty()) return {};
|
||||
|
||||
return DecodeArmaBytes(buf);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
#include <type_traits>
|
||||
#include <cstring> // memcpy
|
||||
|
||||
// Windows must come before vmmdll / VolkDMA headers.
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
|
||||
// VolkDMA wrapper classes (DMA handles FPGA init, Process handles per-process reads)
|
||||
#include <VolkDMA/dma.hh>
|
||||
#include <VolkDMA/process.hh>
|
||||
|
||||
#include "MemoryValidation.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// VmmAccessor
|
||||
// Thin wrapper around VolkDMA's DMA + Process classes providing the same
|
||||
// read-oriented API used throughout this project.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class VmmAccessor {
|
||||
public:
|
||||
VmmAccessor() = default;
|
||||
~VmmAccessor() = default;
|
||||
|
||||
// Non-copyable, movable
|
||||
VmmAccessor(const VmmAccessor&) = delete;
|
||||
VmmAccessor& operator=(const VmmAccessor&) = delete;
|
||||
VmmAccessor(VmmAccessor&&) = default;
|
||||
VmmAccessor& operator=(VmmAccessor&&) = default;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Initialise the DMA handle.
|
||||
/// @param useMemoryMap When true, VolkDMA auto-generates / loads memory_map.txt
|
||||
/// for faster physical-memory resolution (recommended).
|
||||
bool Initialize(bool useMemoryMap = true);
|
||||
|
||||
/// Find a running process by name, store its PID, and create the
|
||||
/// internal Process object. Must be called before any reads.
|
||||
bool TryFindProcess(const std::string& processName, uint32_t& outPid);
|
||||
|
||||
/// Get the base virtual address of a named module inside the attached process.
|
||||
bool TryGetModuleBase(uint32_t pid,
|
||||
const std::string& moduleName,
|
||||
uint64_t& outBase);
|
||||
|
||||
/// Returns true when the process is still alive (re-queries the DMA layer).
|
||||
bool IsProcessAlive(uint32_t pid, const std::string& processName);
|
||||
|
||||
/// Force a full refresh of VMMDLL's internal caches.
|
||||
void ForceRefresh();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Memory reads
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Read a 64-bit pointer. Validates both the source and result address.
|
||||
bool TryReadPointer(uint32_t pid, uint64_t address, uint64_t& outPointer);
|
||||
|
||||
/// Read a plain-old-data value of type T.
|
||||
template<typename T>
|
||||
bool TryReadValue(uint32_t pid, uint64_t address, T& outValue);
|
||||
|
||||
/// Read raw bytes into a caller-supplied vector.
|
||||
bool ReadBytes(uint32_t pid,
|
||||
uint64_t address,
|
||||
size_t count,
|
||||
std::vector<uint8_t>& outBytes);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Scatter reads — batch multiple reads into one DMA round-trip.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
struct ScatterEntry {
|
||||
uint64_t address;
|
||||
void* buffer;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
/// Queue all entries and execute them in a single DMA transaction.
|
||||
/// Returns false only if the scatter handle itself couldn't be created.
|
||||
/// Individual failed reads zero-fill their buffer (VMMDLL guarantee).
|
||||
bool ScatterRead(uint32_t pid, std::vector<ScatterEntry>& entries);
|
||||
|
||||
/// Read an Arma/DayZ engine string (uint16_t length at addr+0x8,
|
||||
/// data at addr+0x10). Falls back from UTF-8 to CP1251 if needed.
|
||||
std::string ReadArmaString(uint32_t pid, uint64_t address,
|
||||
size_t maxLength = 256);
|
||||
|
||||
/// Read a null-terminated C string.
|
||||
std::string ReadCString(uint32_t pid, uint64_t address,
|
||||
size_t maxLength = 256);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// State
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[[nodiscard]] bool IsInitialized() const noexcept {
|
||||
return m_dma && m_dma->is_initialized();
|
||||
}
|
||||
|
||||
[[nodiscard]] bool IsAttached() const noexcept {
|
||||
return m_process != nullptr;
|
||||
}
|
||||
|
||||
|
||||
private:
|
||||
std::unique_ptr<DMA> m_dma;
|
||||
std::unique_ptr<Process> m_process;
|
||||
uint32_t m_attachedPid = 0;
|
||||
|
||||
// Guards m_process / m_dma lifetime against concurrent reads.
|
||||
// Reads (ReadRaw / ScatterRead) take a SHARED lock so they run in parallel;
|
||||
// Initialize / TryFindProcess take a UNIQUE lock while swapping the handle.
|
||||
// This lets the dedicated camera thread read concurrently with the main
|
||||
// runtime thread without a use-after-free when a reconnect re-creates Process.
|
||||
mutable std::shared_mutex m_accessMutex;
|
||||
|
||||
/// Low-level read via VolkDMA Process. Returns false on read error.
|
||||
bool ReadRaw(uint32_t pid, uint64_t address, void* buf, size_t size);
|
||||
|
||||
/// Attempt to decode a byte buffer as UTF-8; if that fails,
|
||||
/// re-interpret as Windows CP1251 and convert to UTF-8.
|
||||
static std::string DecodeArmaBytes(const std::vector<uint8_t>& bytes);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Template implementation (must live in the header)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
template<typename T>
|
||||
bool VmmAccessor::TryReadValue(uint32_t pid, uint64_t address, T& outValue) {
|
||||
static_assert(std::is_trivially_copyable_v<T>,
|
||||
"TryReadValue requires a trivially-copyable type");
|
||||
T tmp{};
|
||||
if (!ReadRaw(pid, address, &tmp, sizeof(T))) return false;
|
||||
outValue = tmp;
|
||||
return true;
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// ============================================================================
|
||||
// Offsets.h — DayZ Enfusion engine memory offsets
|
||||
//
|
||||
// Verification log (update whenever you confirm offsets against a live client):
|
||||
//
|
||||
// DayZ 1.29 | 2026-06-13
|
||||
// Base::* — SigScanner-scanned; patterns in SigScanner.cpp
|
||||
// World::* — live-debugged via Spectre stable
|
||||
// Common::*, Network::* — live-debugged; cross-checked with C# port
|
||||
// Player::*, Inventory::* — live-debugged; SigScanner covers IsDead/Inventory
|
||||
// Skeleton::* — animClass/matrixClass probed at runtime (see cands)
|
||||
//
|
||||
// When the game patches, update the SigScanner patterns first (they cover the
|
||||
// most volatile offsets). Hard-coded offsets that the scanner does NOT cover
|
||||
// are marked [manual] and must be verified by hand after each patch.
|
||||
// ============================================================================
|
||||
|
||||
namespace Offsets {
|
||||
|
||||
// [manual] Module-relative RVAs — also covered by SigScanner (World, NetworkManager)
|
||||
namespace Base {
|
||||
constexpr uint64_t World = 0x4264028; // v1.29
|
||||
constexpr uint64_t NetworkManager = 0x100FC10; // v1.29
|
||||
constexpr uint64_t Tick = 0xFF4998; // v1.29 [manual]
|
||||
constexpr uint64_t Fov = 0x100A7D8; // v1.29 [manual]
|
||||
}
|
||||
|
||||
// World struct member offsets — also covered by SigScanner
|
||||
namespace World {
|
||||
constexpr uint64_t NearEntityList = 0xF48; // v1.29
|
||||
constexpr uint64_t NearTableSize = 0xF50; // v1.29
|
||||
constexpr uint64_t FarEntityList = 0x1090; // v1.29
|
||||
constexpr uint64_t FarTableSize = 0x1098; // v1.29
|
||||
constexpr uint64_t SlowEntityList = 0x2010; // v1.29 [manual]
|
||||
constexpr uint64_t SlowTableAllocCount= 0x2018; // v1.29 [manual]
|
||||
constexpr uint64_t SlowTableValidCount= 0x1FA0; // v1.29 [manual]
|
||||
constexpr uint64_t LocalPlayer = 0x2960; // v1.29 [manual]
|
||||
constexpr uint64_t ItemList = 0x2060; // v1.29
|
||||
constexpr uint64_t ItemTableAllocCount= 0x2068; // v1.29
|
||||
constexpr uint64_t ItemTableValidCount= 0x2070; // v1.29
|
||||
constexpr uint64_t BulletTable = 0xE00; // v1.29
|
||||
constexpr uint64_t BulletCount = 0xE08; // v1.29
|
||||
constexpr uint64_t Camera = 0x1B8; // v1.29 [manual]
|
||||
}
|
||||
|
||||
// [manual] Common entity struct offsets — VisualState also covered by SigScanner
|
||||
namespace Common {
|
||||
constexpr uint64_t Item = 0x18; // v1.29
|
||||
constexpr uint64_t Path = 0xB0; // v1.29
|
||||
constexpr uint64_t Size = 0x8; // v1.29
|
||||
constexpr uint64_t Type = 0x180; // v1.29
|
||||
constexpr uint64_t VisualState = 0x1C8; // v1.29
|
||||
constexpr uint64_t FutureVisualState = 0x120; // v1.29 [manual]
|
||||
constexpr uint64_t DirectionX = 0x20; // v1.29 [manual]
|
||||
constexpr uint64_t DirectionY = 0x28; // v1.29 [manual]
|
||||
constexpr uint64_t Position = 0x2C; // v1.29 [manual]
|
||||
}
|
||||
|
||||
// [manual] Network manager struct offsets
|
||||
namespace Network {
|
||||
constexpr uint64_t ManagerNetworkClient = 0x50; // v1.29
|
||||
constexpr uint64_t EntityNetworkId = 0x6DC; // v1.29
|
||||
constexpr uint64_t ClientScoreboard = 0x18; // v1.29
|
||||
constexpr uint64_t ClientPlayerCount = 0x24; // v1.29
|
||||
constexpr uint64_t ClientPlayerIdentitySize = 0x170; // v1.29
|
||||
constexpr uint64_t PlayerIdentityNetworkId = 0x30; // v1.29
|
||||
constexpr uint64_t PlayerIdentitySteamId = 0xA0; // v1.29
|
||||
constexpr uint64_t PlayerIdentityPlayerName = 0xF8; // v1.29
|
||||
constexpr uint64_t PlayerCount = 0x24; // v1.29
|
||||
constexpr uint64_t ServerName = 0x308; // v1.29 [manual]
|
||||
constexpr uint64_t GameVersion = 0x350; // v1.29 [manual]
|
||||
constexpr uint64_t MapName = 0x38; // v1.29 [manual]
|
||||
constexpr uint64_t ThirdPerson = 0x9C; // v1.29 [manual]
|
||||
constexpr uint64_t Crosshair = 0xA0; // v1.29 [manual]
|
||||
}
|
||||
|
||||
// [manual] Entity type object offsets
|
||||
namespace EntityType {
|
||||
constexpr uint64_t ModelName = 0xB0; // v1.29
|
||||
constexpr uint64_t ConfigName = 0x98; // v1.29
|
||||
constexpr uint64_t TypeName = 0xD0; // v1.29
|
||||
constexpr uint64_t CleanName = 0x518; // v1.29
|
||||
}
|
||||
|
||||
// [manual] Camera struct offsets (relative to World::Camera pointer target)
|
||||
namespace Camera {
|
||||
constexpr uint64_t InvertedViewRight = 0x8; // v1.29
|
||||
constexpr uint64_t InvertedViewUp = 0x14; // v1.29
|
||||
constexpr uint64_t InvertedViewForward = 0x20; // v1.29
|
||||
constexpr uint64_t Translation = 0x2C; // v1.29
|
||||
constexpr uint64_t ViewPortSize = 0x58; // v1.29
|
||||
constexpr uint64_t ProjectionD1 = 0xD0; // v1.29
|
||||
constexpr uint64_t ProjectionD2 = 0xDC; // v1.29
|
||||
}
|
||||
|
||||
namespace CameraFov {
|
||||
// Camera FOV offsets (derived from Base::Fov)
|
||||
// Populated as needed; base offset is Base::Fov
|
||||
}
|
||||
|
||||
// Player entity struct offsets — IsDead/Inventory also covered by SigScanner
|
||||
namespace Player {
|
||||
constexpr uint64_t Skeleton = 0x7E0; // v1.29 [manual]; alt: 0x810 (external source)
|
||||
constexpr uint64_t InputController = 0x7E8; // v1.29 [manual]
|
||||
constexpr uint64_t IsDead = 0xE2; // v1.29
|
||||
constexpr uint64_t EntityDead = 0x15D; // v1.29 [manual]
|
||||
// TODO: verify health offset for current game version.
|
||||
// Set to 0 to disable health reading; set to the correct entity offset
|
||||
// for a float in the range 0..100 once confirmed via RE.
|
||||
constexpr uint64_t Health = 0x0;
|
||||
}
|
||||
|
||||
namespace Infected {
|
||||
// Infected share common entity offsets; extend here as needed
|
||||
// Skeleton pointer: see Skeleton::ZombieCand
|
||||
}
|
||||
|
||||
// Inventory struct offsets — Base/Hands also covered by SigScanner
|
||||
namespace Inventory {
|
||||
constexpr uint64_t Base = 0x650; // v1.29
|
||||
constexpr uint64_t ItemQuality = 0x194; // v1.29 [manual]
|
||||
constexpr uint64_t Hands = 0x1B0; // v1.29
|
||||
constexpr uint64_t HandItemValid = 0x1CC; // v1.29 [manual]
|
||||
constexpr uint64_t WornClothes = 0x150; // v1.29 [manual] — ptr to weared-clothes grid object
|
||||
constexpr uint64_t PlayerCargoGrid = 0x150; // alias kept for compatibility
|
||||
constexpr uint64_t CargoGridCount = 0xC; // v1.29 [manual] — uint32 count at grid+0xC
|
||||
constexpr uint64_t ItemPtr = 0x8; // v1.29 [manual] — ptr to items array at grid+0x8
|
||||
constexpr uint64_t ItemSize = 0x10; // v1.29 [manual] — stride per item slot (16 bytes)
|
||||
}
|
||||
|
||||
namespace SlowTable {
|
||||
constexpr uint64_t EntrySize = 0x18; // v1.29
|
||||
constexpr uint64_t EntryPointerOffset = 0x8; // v1.29
|
||||
}
|
||||
|
||||
namespace Skeleton {
|
||||
// Entity → skeleton pointer offset candidates (probed at runtime, first match wins).
|
||||
// v1.29 confirmed: player=0x7E0, zombie=0x670.
|
||||
// External source (unknown version): player=0x810, zombie=0x680.
|
||||
constexpr uint64_t PlayerCand[2] = { 0x7E0, 0x810 };
|
||||
constexpr uint64_t ZombieCand[2] = { 0x670, 0x680 };
|
||||
|
||||
// Convenience aliases (first candidate = last-known-good).
|
||||
constexpr uint64_t PlayerOffset = PlayerCand[0];
|
||||
constexpr uint64_t ZombieOffset = ZombieCand[0];
|
||||
|
||||
// Probed candidates: skeleton → animClass pointer (tried in order).
|
||||
// 0x118 confirmed v1.29 (Spectre stable); 0xA8 confirmed by external source.
|
||||
constexpr uint64_t AnimCand[4] = { 0x118, 0xA8, 0x98, 0xB0 };
|
||||
|
||||
// Probed candidates: animClass → matrixArray pointer (tried in order).
|
||||
// 0xBE8 confirmed v1.29 (Spectre stable); 0xBF0 confirmed by external source.
|
||||
constexpr uint64_t MatrixCand[5] = { 0xBE8, 0xBF0, 0xBD8, 0xB40, 0xB30 };
|
||||
|
||||
// Each bone entry is 48 bytes; translation (x,y,z) starts at byte offset 0x54
|
||||
// from the matrixArray base (effective: matrixArray + 0x54 + boneIndex*0x30).
|
||||
// This gives boneIndex N the same world position as their boneIndex N+1
|
||||
// (the +0x54 vs +0x24 difference is exactly one stride).
|
||||
constexpr uint32_t BoneStride = 48; // v1.29
|
||||
constexpr uint32_t BoneTranslationOffset = 0x54; // v1.29
|
||||
}
|
||||
|
||||
} // namespace Offsets
|
||||
@@ -0,0 +1,850 @@
|
||||
#include "GameOverlay.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "Readers/EntityTypeCache.h"
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <iphlpapi.h>
|
||||
#pragma comment(lib, "iphlpapi.lib")
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_internal.h>
|
||||
|
||||
#include "MenuBridge.h"
|
||||
|
||||
// Per-frame bridge pointer consumed by the vendored Lumin menu (gui.cpp).
|
||||
MenuBridge* g_menu = nullptr;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bone interpolation / extrapolation helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
int64_t GameOverlay::NowMs() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// Linearly interpolate (or extrapolate when t > 1) between two bone sets.
|
||||
static SkeletonBones LerpBones(const SkeletonBones& a, const SkeletonBones& b, float t) {
|
||||
auto lv = [t](const Vector3& p, const Vector3& q) -> Vector3 {
|
||||
return { p.x + t*(q.x-p.x), p.y + t*(q.y-p.y), p.z + t*(q.z-p.z) };
|
||||
};
|
||||
SkeletonBones out;
|
||||
out.valid = b.valid;
|
||||
out.neck = lv(a.neck, b.neck);
|
||||
out.head = lv(a.head, b.head);
|
||||
out.spine = lv(a.spine, b.spine);
|
||||
out.pelvis = lv(a.pelvis, b.pelvis);
|
||||
out.rightShoulder = lv(a.rightShoulder, b.rightShoulder);
|
||||
out.rightElbow = lv(a.rightElbow, b.rightElbow);
|
||||
out.rightHand = lv(a.rightHand, b.rightHand);
|
||||
out.leftShoulder = lv(a.leftShoulder, b.leftShoulder);
|
||||
out.leftElbow = lv(a.leftElbow, b.leftElbow);
|
||||
out.leftHand = lv(a.leftHand, b.leftHand);
|
||||
out.rightHip = lv(a.rightHip, b.rightHip);
|
||||
out.rightKnee = lv(a.rightKnee, b.rightKnee);
|
||||
out.rightAnkle = lv(a.rightAnkle, b.rightAnkle);
|
||||
out.leftHip = lv(a.leftHip, b.leftHip);
|
||||
out.leftKnee = lv(a.leftKnee, b.leftKnee);
|
||||
out.leftAnkle = lv(a.leftAnkle, b.leftAnkle);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Update the history when a new DMA read is detected (head bone moved).
|
||||
static void UpdateBoneHistory(GameOverlay::BoneHistory& h,
|
||||
const SkeletonBones& bones, int64_t nowMs)
|
||||
{
|
||||
if (!h.initialized) {
|
||||
h.prev = h.curr = bones;
|
||||
h.prevMs = h.currMs = nowMs;
|
||||
h.initialized = true;
|
||||
return;
|
||||
}
|
||||
const Vector3& o = h.curr.head;
|
||||
const Vector3& n = bones.head;
|
||||
float dx = o.x-n.x, dy = o.y-n.y, dz = o.z-n.z;
|
||||
float dist2 = dx*dx + dy*dy + dz*dz;
|
||||
// Reject implausible jumps: > 3 m in one update cycle can't happen at
|
||||
// any in-game speed — it means the scatter read returned garbage.
|
||||
// Keeping the old history prevents one stale read from corrupting the
|
||||
// extrapolation state; the DMA-side eviction (cache->valid=false) will
|
||||
// fix the pointer within the next boneRefreshMs window.
|
||||
if (dist2 > 9.0f) return;
|
||||
if (dist2 > 1e-8f) {
|
||||
h.prev = h.curr;
|
||||
h.prevMs = h.currMs;
|
||||
h.curr = bones;
|
||||
h.currMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Return bones extrapolated to nowMs using the recorded velocity.
|
||||
// Alpha is clamped to 1.5 update intervals to limit overshoot on direction changes.
|
||||
static SkeletonBones GetSmoothedBones(const GameOverlay::BoneHistory& h, int64_t nowMs) {
|
||||
if (!h.initialized) return h.curr;
|
||||
int64_t dt = h.currMs - h.prevMs;
|
||||
if (dt <= 0) return h.curr;
|
||||
float alpha = static_cast<float>(nowMs - h.prevMs) / static_cast<float>(dt);
|
||||
alpha = std::min(alpha, 1.5f);
|
||||
return LerpBones(h.prev, h.curr, alpha);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SetWebRadarPort — resolve LAN IPs and build display URLs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::SetWebRadarPort(int port) {
|
||||
m_webPort = port;
|
||||
m_webUrls.clear();
|
||||
m_webUrls.push_back(std::format("http://localhost:{}", port));
|
||||
|
||||
// Enumerate all unicast IPv4 addresses.
|
||||
ULONG bufLen = 15000;
|
||||
std::vector<BYTE> buf(bufLen);
|
||||
auto* table = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
|
||||
DWORD ret = GetAdaptersAddresses(AF_INET,
|
||||
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
|
||||
nullptr, table, &bufLen);
|
||||
if (ret == ERROR_BUFFER_OVERFLOW) {
|
||||
buf.resize(bufLen);
|
||||
table = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buf.data());
|
||||
ret = GetAdaptersAddresses(AF_INET,
|
||||
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
|
||||
nullptr, table, &bufLen);
|
||||
}
|
||||
|
||||
if (ret == NO_ERROR) {
|
||||
for (auto* a = table; a; a = a->Next) {
|
||||
if (a->OperStatus != IfOperStatusUp) continue;
|
||||
for (auto* ua = a->FirstUnicastAddress; ua; ua = ua->Next) {
|
||||
auto* sin = reinterpret_cast<sockaddr_in*>(ua->Address.lpSockaddr);
|
||||
char ipStr[INET_ADDRSTRLEN] = {};
|
||||
inet_ntop(AF_INET, &sin->sin_addr, ipStr, sizeof(ipStr));
|
||||
std::string ip(ipStr);
|
||||
if (ip == "127.0.0.1") continue;
|
||||
m_webUrls.push_back(std::format("http://{}:{}", ip, port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float GameOverlay::Dist(const Vector3& a, const Vector3& b) {
|
||||
float dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
|
||||
return std::sqrtf(dx*dx + dy*dy + dz*dz);
|
||||
}
|
||||
|
||||
// Returns true if the world position is in front of the camera (depth >= 0.65).
|
||||
// Uses the same depth calculation as WorldToScreen so the threshold is consistent.
|
||||
// Call this before WorldToScreen to skip the full projection for behind-camera entities.
|
||||
static bool IsFacingCamera(const CameraData& cam, const Vector3& pos) {
|
||||
float tx = pos.x - cam.translation.x;
|
||||
float ty = pos.y - cam.translation.y;
|
||||
float tz = pos.z - cam.translation.z;
|
||||
float depth = tx*cam.invertedViewForward.x
|
||||
+ ty*cam.invertedViewForward.y
|
||||
+ tz*cam.invertedViewForward.z;
|
||||
return depth >= 0.65f;
|
||||
}
|
||||
|
||||
bool GameOverlay::WorldToScreen(const CameraData& cam,
|
||||
const Vector3& world,
|
||||
float& sx, float& sy,
|
||||
float w, float h)
|
||||
{
|
||||
float tx = world.x - cam.translation.x;
|
||||
float ty = world.y - cam.translation.y;
|
||||
float tz = world.z - cam.translation.z;
|
||||
|
||||
float x = tx*cam.invertedViewRight.x + ty*cam.invertedViewRight.y + tz*cam.invertedViewRight.z;
|
||||
float y = tx*cam.invertedViewUp.x + ty*cam.invertedViewUp.y + tz*cam.invertedViewUp.z;
|
||||
float z = tx*cam.invertedViewForward.x + ty*cam.invertedViewForward.y + tz*cam.invertedViewForward.z;
|
||||
|
||||
if (z < 0.65f) return false;
|
||||
if (cam.projectionD1x == 0.0f || cam.projectionD2y == 0.0f) return false;
|
||||
|
||||
float nx = (x / cam.projectionD1x) / z;
|
||||
float ny = (y / cam.projectionD2y) / z;
|
||||
|
||||
sx = roundf((w * 0.5f) + nx * (w * 0.5f));
|
||||
sy = roundf((h * 0.5f) - ny * (h * 0.5f));
|
||||
|
||||
return sx >= 0.0f && sx <= w && sy >= 0.0f && sy <= h;
|
||||
}
|
||||
|
||||
// Viewport-aware projection.
|
||||
// For stretch-to-fill (or when no render res is configured) the viewport
|
||||
// equals the full overlay — identical to calling WorldToScreen directly.
|
||||
// For maintain-aspect-ratio with render != display, computes the inset
|
||||
// viewport (letterbox top/bottom or pillarbox left/right) and offsets the
|
||||
// result so entities land on the correct monitor pixels.
|
||||
bool GameOverlay::Proj(const CameraData& cam, const Vector3& worldPos,
|
||||
float& sx, float& sy,
|
||||
float overlayW, float overlayH) const
|
||||
{
|
||||
float vx = 0.0f, vy = 0.0f, vw = overlayW, vh = overlayH;
|
||||
|
||||
if (m_renderW > 0 && m_renderH > 0 && !m_stretchToFill) {
|
||||
float renderAr = static_cast<float>(m_renderW) / static_cast<float>(m_renderH);
|
||||
float displayAr = overlayW / overlayH;
|
||||
|
||||
if (renderAr > displayAr + 0.01f) {
|
||||
// Render is wider than display → letterbox (black bars top/bottom)
|
||||
vw = overlayW;
|
||||
vh = overlayW / renderAr;
|
||||
vy = (overlayH - vh) * 0.5f;
|
||||
} else if (displayAr > renderAr + 0.01f) {
|
||||
// Display is wider than render → pillarbox (black bars left/right)
|
||||
vh = overlayH;
|
||||
vw = overlayH * renderAr;
|
||||
vx = (overlayW - vw) * 0.5f;
|
||||
}
|
||||
}
|
||||
// stretch-to-fill: vx=0, vy=0, vw=overlayW, vh=overlayH — no change
|
||||
|
||||
if (!WorldToScreen(cam, worldPos, sx, sy, vw, vh)) return false;
|
||||
sx += vx;
|
||||
sy += vy;
|
||||
return sx >= 0.0f && sx <= overlayW && sy >= 0.0f && sy <= overlayH;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Draw — entry point called each ImGui frame
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::SyncConfig() {
|
||||
m_cfg.showPlayers = m_showPlayers;
|
||||
m_cfg.showAnimals = m_showAnimals;
|
||||
m_cfg.showZombies = m_showZombies;
|
||||
m_cfg.showItems = m_showItems;
|
||||
m_cfg.showBox = m_showBox;
|
||||
m_cfg.showSkeleton = m_showSkeleton;
|
||||
m_cfg.showHeadDot = m_showHeadDot;
|
||||
m_cfg.showCorpses = m_showCorpses;
|
||||
m_cfg.showWeapon = m_showWeapon;
|
||||
m_cfg.showHealthBar = m_showHealthBar;
|
||||
m_cfg.showHealthNumber = m_showHealthNumber;
|
||||
m_cfg.itemCategories = m_itemCategories;
|
||||
m_cfg.playerMaxDist = m_playerMaxDist;
|
||||
m_cfg.animalMaxDist = m_animalMaxDist;
|
||||
m_cfg.zombieMaxDist = m_zombieMaxDist;
|
||||
m_cfg.itemMaxDist = m_itemMaxDist;
|
||||
m_cfg.renderWidth = m_renderW;
|
||||
m_cfg.renderHeight = m_renderH;
|
||||
m_cfg.stretchToFill = m_stretchToFill;
|
||||
}
|
||||
|
||||
void GameOverlay::Draw(float w, float h) {
|
||||
// Refresh snapshot once per frame — shared_ptr copy is 8 bytes, no vector alloc
|
||||
m_snapshot = m_service.GetLatestUpdate();
|
||||
if (!m_snapshot) return;
|
||||
const RuntimeUpdate& u = *m_snapshot;
|
||||
|
||||
// Record frame time once; used by all bone-smoothing calls this frame.
|
||||
m_frameTimeMs = NowMs();
|
||||
|
||||
// Feed new DMA bone reads into the per-entity history so extrapolation
|
||||
// always has the two most recent samples to work from.
|
||||
for (const auto& p : u.players)
|
||||
if (p.skeleton.valid)
|
||||
UpdateBoneHistory(m_playerBoneHistory[p.address], p.skeleton, m_frameTimeMs);
|
||||
for (const auto& z : u.zombies)
|
||||
if (z.skeleton.valid)
|
||||
UpdateBoneHistory(m_zombieBoneHistory[z.address], z.skeleton, m_frameTimeMs);
|
||||
|
||||
// Prune bone histories for entities no longer present in the snapshot.
|
||||
// Prevents ghost extrapolation when a player logs out or leaves the scan zone:
|
||||
// their history is cleared immediately so stale bones can't persist.
|
||||
{
|
||||
std::unordered_set<uint64_t> livePlayers, liveZombies;
|
||||
livePlayers.reserve(u.players.size());
|
||||
liveZombies.reserve(u.zombies.size());
|
||||
for (const auto& p : u.players) livePlayers.insert(p.address);
|
||||
for (const auto& z : u.zombies) liveZombies.insert(z.address);
|
||||
|
||||
for (auto it = m_playerBoneHistory.begin(); it != m_playerBoneHistory.end(); )
|
||||
it = livePlayers.count(it->first) ? std::next(it) : m_playerBoneHistory.erase(it);
|
||||
for (auto it = m_zombieBoneHistory.begin(); it != m_zombieBoneHistory.end(); )
|
||||
it = liveZombies.count(it->first) ? std::next(it) : m_zombieBoneHistory.erase(it);
|
||||
}
|
||||
|
||||
// INSERT key toggles the menu
|
||||
if (GetAsyncKeyState(VK_INSERT) & 1) {
|
||||
m_menuOpen = !m_menuOpen;
|
||||
m_menuAlpha = m_menuOpen ? 1.0f : 0.0f;
|
||||
if (!m_menuOpen) {
|
||||
// Persist settings back to config on close.
|
||||
SyncConfig();
|
||||
m_cfg.Save(m_cfgPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Lumin menu (vendored under external/lumin; driven via MenuBridge) ----
|
||||
if (m_menuOpen) {
|
||||
static MenuBridge bridge;
|
||||
|
||||
bridge.showPlayers = &m_showPlayers;
|
||||
bridge.showAnimals = &m_showAnimals;
|
||||
bridge.showZombies = &m_showZombies;
|
||||
bridge.showItems = &m_showItems;
|
||||
bridge.showBox = &m_showBox;
|
||||
bridge.showSkeleton = &m_showSkeleton;
|
||||
bridge.showHeadDot = &m_showHeadDot;
|
||||
bridge.showWeapon = &m_showWeapon;
|
||||
bridge.showHealthBar = &m_showHealthBar;
|
||||
bridge.showHealthNumber = &m_showHealthNumber;
|
||||
bridge.showCorpses = &m_showCorpses;
|
||||
bridge.debugSkeleton = &m_debugSkeleton;
|
||||
|
||||
bridge.playerMaxDist = &m_playerMaxDist;
|
||||
bridge.animalMaxDist = &m_animalMaxDist;
|
||||
bridge.zombieMaxDist = &m_zombieMaxDist;
|
||||
bridge.itemMaxDist = &m_itemMaxDist;
|
||||
|
||||
bridge.itemCategories = &m_itemCategories;
|
||||
|
||||
bridge.pendingW = &m_pendingW;
|
||||
bridge.pendingH = &m_pendingH;
|
||||
bridge.pendingRW = &m_pendingRW;
|
||||
bridge.pendingRH = &m_pendingRH;
|
||||
bridge.stretchToFill = &m_stretchToFill;
|
||||
|
||||
// Read-only info panels.
|
||||
bridge.connected = u.areBaseObjectsReady;
|
||||
bridge.serverName = u.serverName.value_or("");
|
||||
bridge.mapName = u.serverMapName.value_or("");
|
||||
bridge.status = u.status;
|
||||
if (u.localPlayerPosition.has_value()) {
|
||||
bridge.hasPos = true;
|
||||
bridge.px = u.localPlayerPosition->x;
|
||||
bridge.py = u.localPlayerPosition->y;
|
||||
bridge.pz = u.localPlayerPosition->z;
|
||||
} else {
|
||||
bridge.hasPos = false;
|
||||
}
|
||||
bridge.nPlayers = u.players.size();
|
||||
bridge.nAnimals = u.animals.size();
|
||||
bridge.nZombies = u.zombies.size();
|
||||
bridge.nVehicles = u.carsAndBoats.size();
|
||||
bridge.nItems = u.items.size();
|
||||
bridge.nBullets = u.bullets.size();
|
||||
|
||||
bridge.webPort = m_webPort;
|
||||
bridge.webUrls = m_webUrls;
|
||||
|
||||
bridge.onSaveConfig = [this]() {
|
||||
SyncConfig();
|
||||
m_cfg.Save(m_cfgPath);
|
||||
};
|
||||
bridge.onExit = [this]() {
|
||||
SyncConfig();
|
||||
m_cfg.Save(m_cfgPath);
|
||||
if (m_exitCallback) m_exitCallback();
|
||||
};
|
||||
bridge.onApplyDisplayRes = [this]() {
|
||||
m_cfg.overlayWidth = m_pendingW;
|
||||
m_cfg.overlayHeight = m_pendingH;
|
||||
m_cfg.Save(m_cfgPath);
|
||||
if (m_resizeCallback) m_resizeCallback(m_pendingW, m_pendingH);
|
||||
};
|
||||
bridge.onApplyRenderRes = [this]() {
|
||||
m_renderW = m_pendingRW;
|
||||
m_renderH = m_pendingRH;
|
||||
m_cfg.renderWidth = m_pendingRW;
|
||||
m_cfg.renderHeight = m_pendingRH;
|
||||
m_cfg.stretchToFill = m_stretchToFill;
|
||||
m_cfg.Save(m_cfgPath);
|
||||
};
|
||||
|
||||
g_menu = &bridge;
|
||||
RenderLuminMenu();
|
||||
}
|
||||
|
||||
// ---- ESP draw lists — camera read directly from service every frame ----
|
||||
m_service.GetLatestCamera(m_liveCamera);
|
||||
DrawESP(w, h, u, m_liveCamera);
|
||||
}
|
||||
|
||||
void GameOverlay::DrawESP(float w, float h, const RuntimeUpdate& u, const CameraData& cam) {
|
||||
if (!u.areBaseObjectsReady || !cam.valid) return;
|
||||
|
||||
ImDrawList* dl = ImGui::GetBackgroundDrawList();
|
||||
if (m_showPlayers) DrawPlayers(dl, u, cam, w, h);
|
||||
if (m_showAnimals) DrawAnimals(dl, u, cam, w, h);
|
||||
if (m_showZombies) DrawZombies(dl, u, cam, w, h);
|
||||
if (m_showItems) DrawItems (dl, u, cam, w, h);
|
||||
if (m_debugSkeleton) DrawSkeletonDebug(dl, u, cam, w, h);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DrawSkeleton — render 11 bone segments for one entity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::DrawSkeleton(ImDrawList* dl, const SkeletonBones& bones,
|
||||
const CameraData& cam,
|
||||
float w, float h, unsigned int color,
|
||||
bool isZombie) const
|
||||
{
|
||||
// Project each unique bone ONCE, then draw segments from cached screen coords.
|
||||
// Old approach called WorldToScreen twice per segment; shared bones (neck, spine)
|
||||
// were projected 3-4 times each. 16 projections replaces 28-30.
|
||||
enum : int {
|
||||
B_Neck=0, B_Head, B_Spine, B_Pelvis,
|
||||
B_RShoulder, B_RElbow, B_RHand,
|
||||
B_LShoulder, B_LElbow, B_LHand,
|
||||
B_RHip, B_RKnee, B_RAnkle,
|
||||
B_LHip, B_LKnee, B_LAnkle,
|
||||
B_COUNT
|
||||
};
|
||||
|
||||
const Vector3* const kWorld[B_COUNT] = {
|
||||
&bones.neck, &bones.head, &bones.spine, &bones.pelvis,
|
||||
&bones.rightShoulder, &bones.rightElbow, &bones.rightHand,
|
||||
&bones.leftShoulder, &bones.leftElbow, &bones.leftHand,
|
||||
&bones.rightHip, &bones.rightKnee, &bones.rightAnkle,
|
||||
&bones.leftHip, &bones.leftKnee, &bones.leftAnkle,
|
||||
};
|
||||
|
||||
struct SP { float x, y; bool ok; } scr[B_COUNT];
|
||||
for (int i = 0; i < B_COUNT; ++i)
|
||||
scr[i].ok = Proj(cam, *kWorld[i], scr[i].x, scr[i].y, w, h);
|
||||
|
||||
struct Seg { int8_t a, b; };
|
||||
static const Seg kPlayerSegs[14] = {
|
||||
{B_Neck,B_Head},
|
||||
{B_Neck,B_RShoulder},{B_RShoulder,B_RElbow},{B_RElbow,B_RHand},
|
||||
{B_Neck,B_LShoulder},{B_LShoulder,B_LElbow},{B_LElbow,B_LHand},
|
||||
{B_Neck,B_Spine},
|
||||
{B_Spine,B_RHip},{B_RHip,B_RKnee},{B_RKnee,B_RAnkle},
|
||||
{B_Spine,B_LHip},{B_LHip,B_LKnee},{B_LKnee,B_LAnkle},
|
||||
};
|
||||
static const Seg kZombieSegs[15] = {
|
||||
{B_Spine,B_Neck},{B_Neck,B_Head},
|
||||
{B_Spine,B_LShoulder},{B_LShoulder,B_LElbow},{B_LElbow,B_LHand},
|
||||
{B_Spine,B_RShoulder},{B_RShoulder,B_RElbow},{B_RElbow,B_RHand},
|
||||
{B_Spine,B_Pelvis},
|
||||
{B_Pelvis,B_RHip},{B_RHip,B_RKnee},{B_RKnee,B_RAnkle},
|
||||
{B_Pelvis,B_LHip},{B_LHip,B_LKnee},{B_LKnee,B_LAnkle},
|
||||
};
|
||||
|
||||
const Seg* segs = isZombie ? kZombieSegs : kPlayerSegs;
|
||||
const int count = isZombie ? 15 : 14;
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const SP& a = scr[segs[i].a];
|
||||
const SP& b = scr[segs[i].b];
|
||||
if (!a.ok || !b.ok) continue;
|
||||
dl->AddLine(ImVec2(a.x, a.y), ImVec2(b.x, b.y), color, 1.5f);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DrawSkeletonDebug — named bone dots for the closest player
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u,
|
||||
const CameraData& cam, float w, float h) const
|
||||
{
|
||||
// Find the closest player that has skeleton data (valid or not).
|
||||
const DayZPlayerEntry* target = nullptr;
|
||||
float bestDist = 1e9f;
|
||||
for (const auto& p : u.players) {
|
||||
if (!p.position.has_value()) continue;
|
||||
float d = Dist(cam.translation, *p.position);
|
||||
if (d < 2.0f || d > m_playerMaxDist) continue;
|
||||
if (d < bestDist) { bestDist = d; target = &p; }
|
||||
}
|
||||
|
||||
if (!target) return;
|
||||
|
||||
// Top-left status block.
|
||||
const float pad = 10.0f;
|
||||
const float lineH = 16.0f;
|
||||
float tx = pad, ty = pad;
|
||||
|
||||
auto txt = [&](ImU32 col, std::string s) {
|
||||
dl->AddText(ImVec2(tx, ty), col, s.c_str());
|
||||
ty += lineH;
|
||||
};
|
||||
|
||||
const char* name = target->nickname.empty() ? "Player" : target->nickname.c_str();
|
||||
txt(IM_COL32(255,255,255,220), std::format("SkelDebug: {} | dist={:.1f}m", name, bestDist));
|
||||
txt(IM_COL32(200,200,200,180), std::format("skeleton.valid = {}", target->skeleton.valid ? "true" : "false"));
|
||||
|
||||
if (!target->skeleton.valid) {
|
||||
txt(IM_COL32(255,100,100,200), "No valid skeleton data -- check offsets");
|
||||
return;
|
||||
}
|
||||
|
||||
// Named bones with distinct colors — mirrors Spectre stable player bone layout.
|
||||
struct BoneDef { const char* name; const Vector3& pos; ImU32 color; };
|
||||
const SkeletonBones& sk = target->skeleton;
|
||||
const BoneDef bones[] = {
|
||||
{ "neck", sk.neck, IM_COL32(255,100, 0,255) },
|
||||
{ "head", sk.head, IM_COL32(255, 60, 60,255) },
|
||||
{ "spine", sk.spine, IM_COL32(255,165, 0,255) },
|
||||
{ "rShoulder", sk.rightShoulder, IM_COL32( 80,255,180,255) },
|
||||
{ "rElbow", sk.rightElbow, IM_COL32( 50,220,140,255) },
|
||||
{ "rHand", sk.rightHand, IM_COL32( 30,200,120,255) },
|
||||
{ "lShoulder", sk.leftShoulder, IM_COL32( 80,200,255,255) },
|
||||
{ "lElbow", sk.leftElbow, IM_COL32( 50,160,230,255) },
|
||||
{ "lHand", sk.leftHand, IM_COL32( 30,120,255,255) },
|
||||
{ "rHip", sk.rightHip, IM_COL32(255,255, 0,255) },
|
||||
{ "rKnee", sk.rightKnee, IM_COL32(220,220, 0,255) },
|
||||
{ "rAnkle", sk.rightAnkle, IM_COL32(180,180, 0,255) },
|
||||
{ "lHip", sk.leftHip, IM_COL32(200,100,255,255) },
|
||||
{ "lKnee", sk.leftKnee, IM_COL32(170, 80,230,255) },
|
||||
{ "lAnkle", sk.leftAnkle, IM_COL32(140, 60,200,255) },
|
||||
};
|
||||
|
||||
int projected = 0;
|
||||
for (const auto& b : bones) {
|
||||
float sx{}, sy{};
|
||||
bool onScreen = Proj(cam, b.pos, sx, sy, w, h);
|
||||
|
||||
txt(b.color, std::format(" {} ({:.1f},{:.1f},{:.1f}) {}",
|
||||
b.name, b.pos.x, b.pos.y, b.pos.z,
|
||||
onScreen ? "" : "[off]"));
|
||||
|
||||
if (!onScreen) continue;
|
||||
++projected;
|
||||
|
||||
dl->AddCircleFilled(ImVec2(sx, sy), 4.0f, b.color);
|
||||
dl->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(0,0,0,200), 0, 1.5f);
|
||||
dl->AddText(ImVec2(sx + 7.0f, sy - 6.0f), b.color, b.name);
|
||||
}
|
||||
|
||||
txt(IM_COL32(180,255,180,200), std::format(" {}/17 bones on screen", projected));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Players — red bounding box + name + held item + distance
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::DrawPlayers(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
||||
|
||||
for (const auto& p : u.players) {
|
||||
if (p.isDead && !m_showCorpses) continue;
|
||||
if (!p.position.has_value()) continue;
|
||||
|
||||
// Skip the local player by address — more reliable than proximity check.
|
||||
if (u.localPlayerEntityAddress.has_value()
|
||||
&& p.address == *u.localPlayerEntityAddress) continue;
|
||||
|
||||
const Vector3& pos = *p.position;
|
||||
float dist = Dist(cam.translation, pos);
|
||||
if (dist > m_playerMaxDist) continue;
|
||||
|
||||
if (!IsFacingCamera(cam, pos)) continue;
|
||||
|
||||
const ImU32 kColor = p.isAdmin ? IM_COL32(255, 200, 0, 255) // gold
|
||||
: p.isDead ? IM_COL32(120, 120, 120, 160) // grey
|
||||
: IM_COL32(255, 60, 60, 220); // red
|
||||
|
||||
// ---- Smoothed skeleton (extrapolated between DMA reads) ----
|
||||
SkeletonBones smoothBones{};
|
||||
bool hasSmoothBones = false;
|
||||
if (!p.isDead) {
|
||||
auto hit = m_playerBoneHistory.find(p.address);
|
||||
if (hit != m_playerBoneHistory.end() && hit->second.initialized
|
||||
&& (m_frameTimeMs - hit->second.currMs) < 500LL) {
|
||||
smoothBones = GetSmoothedBones(hit->second, m_frameTimeMs);
|
||||
hasSmoothBones = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bounding box ----
|
||||
// When the skeleton is valid, derive the box from the projected extents of
|
||||
// all 16 bones. This keeps the box perfectly in sync with the skeleton
|
||||
// (both come from the bone-scatter data, updated at ~60 Hz) instead of
|
||||
// lagging one update behind because it uses the entity position (50 Hz).
|
||||
float bx0 = 0.0f, by0 = 0.0f, bx1 = 0.0f, by1 = 0.0f;
|
||||
bool boxReady = false;
|
||||
|
||||
if (hasSmoothBones) {
|
||||
const Vector3* const kBones[] = {
|
||||
&smoothBones.neck, &smoothBones.head,
|
||||
&smoothBones.spine, &smoothBones.pelvis,
|
||||
&smoothBones.rightShoulder, &smoothBones.rightElbow, &smoothBones.rightHand,
|
||||
&smoothBones.leftShoulder, &smoothBones.leftElbow, &smoothBones.leftHand,
|
||||
&smoothBones.rightHip, &smoothBones.rightKnee, &smoothBones.rightAnkle,
|
||||
&smoothBones.leftHip, &smoothBones.leftKnee, &smoothBones.leftAnkle,
|
||||
};
|
||||
|
||||
float minX = 1e9f, maxX = -1e9f;
|
||||
float minY = 1e9f, maxY = -1e9f;
|
||||
int hits = 0;
|
||||
for (const auto* bp : kBones) {
|
||||
// Reject bones whose world position is > 3 m from the entity root.
|
||||
// Garbage reads (zero-initialised bones, failed scatter reads) land
|
||||
// at or near world origin and would drag the box hundreds of pixels
|
||||
// off to the side of the actual player.
|
||||
float dbx = bp->x - pos.x, dby = bp->y - pos.y, dbz = bp->z - pos.z;
|
||||
if (dbx*dbx + dby*dby + dbz*dbz > 9.0f) continue;
|
||||
|
||||
float bsx{}, bsy{};
|
||||
if (!Proj(cam, *bp, bsx, bsy, w, h)) continue;
|
||||
if (bsx < minX) minX = bsx;
|
||||
if (bsx > maxX) maxX = bsx;
|
||||
if (bsy < minY) minY = bsy;
|
||||
if (bsy > maxY) maxY = bsy;
|
||||
++hits;
|
||||
}
|
||||
|
||||
if (hits >= 4) {
|
||||
constexpr float kPad = 4.0f;
|
||||
bx0 = minX - kPad;
|
||||
by0 = minY - kPad;
|
||||
bx1 = maxX + kPad;
|
||||
by1 = maxY + kPad;
|
||||
boxReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!boxReady) {
|
||||
// Fallback: entity ground position + hardcoded 1.8 m head.
|
||||
// Used for corpses (no skeleton) and when bones haven't been read yet.
|
||||
float fx{}, fy{}, hx{}, hy{};
|
||||
if (!Proj(cam, pos, fx, fy, w, h)) continue;
|
||||
Vector3 headPos{ pos.x, pos.y + 1.8f, pos.z };
|
||||
if (!Proj(cam, headPos, hx, hy, w, h)) continue;
|
||||
float bH = fy - hy;
|
||||
if (bH < 2.0f) continue;
|
||||
float bW = bH * 0.4f;
|
||||
bx0 = hx - bW * 0.5f;
|
||||
by0 = hy;
|
||||
bx1 = hx + bW * 0.5f;
|
||||
by1 = fy;
|
||||
}
|
||||
|
||||
// ---- Head circle (computed before the box so by0 can be adjusted) ----
|
||||
float hbx{}, hby{};
|
||||
float headRadius = 0.0f;
|
||||
bool hasHeadCircle = false;
|
||||
|
||||
if (m_showHeadDot && !p.isDead && hasSmoothBones) {
|
||||
if (Proj(cam, smoothBones.head, hbx, hby, w, h)) {
|
||||
// Radius = 15% of the rendered body height so the circle scales
|
||||
// naturally with distance. The head-neck pixel-distance approach
|
||||
// collapses to minimum past ~30 m; box-height fraction stays
|
||||
// proportional to the target at any range.
|
||||
headRadius = std::clamp((by1 - by0) * 0.15f, 2.0f, 18.0f);
|
||||
hasHeadCircle = true;
|
||||
by0 = std::min(by0, hby - headRadius - 2.0f);
|
||||
}
|
||||
}
|
||||
|
||||
if (by1 - by0 < 2.0f) continue;
|
||||
|
||||
if (m_showBox)
|
||||
dl->AddRect(ImVec2(bx0, by0), ImVec2(bx1, by1), kColor, 0.0f, 0, 1.5f);
|
||||
|
||||
// ---- Skeleton + head circle (use extrapolated bones) ----
|
||||
if (!p.isDead && hasSmoothBones) {
|
||||
if (m_showSkeleton)
|
||||
DrawSkeleton(dl, smoothBones, cam, w, h, kColor, false);
|
||||
|
||||
if (hasHeadCircle) {
|
||||
dl->AddCircleFilled(ImVec2(hbx, hby), headRadius, IM_COL32(0, 0, 0, 80));
|
||||
dl->AddCircle(ImVec2(hbx, hby), headRadius, kColor, 0, 1.5f);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Name + distance label (top-right of box) ----
|
||||
char lblBuf[256];
|
||||
const char* nick = p.nickname.empty() ? "Player" : p.nickname.c_str();
|
||||
if (p.isAdmin && p.isDead)
|
||||
snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [DEAD] [%.0fm]", nick, dist);
|
||||
else if (p.isAdmin)
|
||||
snprintf(lblBuf, sizeof(lblBuf), "[ADMIN] %s [%.0fm]", nick, dist);
|
||||
else if (p.isDead)
|
||||
snprintf(lblBuf, sizeof(lblBuf), "%s [DEAD] [%.0fm]", nick, dist);
|
||||
else
|
||||
snprintf(lblBuf, sizeof(lblBuf), "%s [%.0fm]", nick, dist);
|
||||
dl->AddText(ImVec2(bx1 + 3.0f, by0), kColor, lblBuf);
|
||||
|
||||
// ---- Right-side extras (weapon, health bar, health number) ----
|
||||
const float kBarW = 4.0f;
|
||||
const float kBarGap = 5.0f;
|
||||
const float barX = bx1 + kBarGap;
|
||||
const float boxH = by1 - by0;
|
||||
|
||||
if (m_showWeapon && !p.isDead && !p.itemInHands.empty()) {
|
||||
constexpr ImU32 kWeaponColor = IM_COL32(220, 220, 100, 200);
|
||||
dl->AddText(ImVec2(barX, by0 + 13.0f), kWeaponColor, p.itemInHands.c_str());
|
||||
}
|
||||
|
||||
const bool hasHealth = (p.health >= 0.0f);
|
||||
if ((m_showHealthBar || m_showHealthNumber) && !p.isDead && hasHealth) {
|
||||
const float hpFrac = std::min(p.health / 100.0f, 1.0f);
|
||||
|
||||
if (m_showHealthBar) {
|
||||
dl->AddRectFilled(
|
||||
ImVec2(barX, by0),
|
||||
ImVec2(barX + kBarW, by1),
|
||||
IM_COL32(0, 0, 0, 140));
|
||||
|
||||
float r = hpFrac < 0.5f ? 1.0f : 2.0f * (1.0f - hpFrac);
|
||||
float g = hpFrac > 0.5f ? 1.0f : 2.0f * hpFrac;
|
||||
ImU32 hpColor = IM_COL32(
|
||||
static_cast<int>(r * 220),
|
||||
static_cast<int>(g * 220),
|
||||
0, 220);
|
||||
|
||||
float filledTop = by0 + boxH * (1.0f - hpFrac);
|
||||
dl->AddRectFilled(
|
||||
ImVec2(barX, filledTop),
|
||||
ImVec2(barX + kBarW, by1),
|
||||
hpColor);
|
||||
}
|
||||
|
||||
if (m_showHealthNumber) {
|
||||
char hpBuf[32];
|
||||
snprintf(hpBuf, sizeof(hpBuf), "%.0f/100", p.health);
|
||||
float numX = m_showHealthBar ? barX + kBarW + 2.0f : barX;
|
||||
dl->AddText(ImVec2(numX, by1 - 12.0f),
|
||||
IM_COL32(200, 200, 200, 200), hpBuf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Animals — green label
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::DrawAnimals(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
||||
constexpr ImU32 kGreen = IM_COL32(50, 230, 50, 200);
|
||||
|
||||
for (const auto& a : u.animals) {
|
||||
if (!a.position.has_value()) continue;
|
||||
float dist = Dist(cam.translation, *a.position);
|
||||
if (dist > m_animalMaxDist) continue;
|
||||
if (!IsFacingCamera(cam, *a.position)) continue;
|
||||
|
||||
float sx{}, sy{};
|
||||
if (!Proj(cam, *a.position, sx, sy, w, h)) continue;
|
||||
|
||||
std::string raw = a.entityName.empty() ? a.typeName : a.entityName;
|
||||
std::string fmtName = raw.empty() ? "Animal" : FormatEntityName(raw);
|
||||
char abuf[256];
|
||||
snprintf(abuf, sizeof(abuf), "%s [%.0fm]", fmtName.c_str(), dist);
|
||||
dl->AddText(ImVec2(sx, sy), kGreen, abuf);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zombies — yellow label
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::DrawZombies(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
||||
constexpr ImU32 kYellow = IM_COL32(230, 230, 50, 180);
|
||||
|
||||
for (const auto& z : u.zombies) {
|
||||
// Resolve position: use fresh read if available, fall back to cache.
|
||||
Vector3 pos;
|
||||
if (z.position.has_value()) {
|
||||
pos = *z.position;
|
||||
m_zombieLastPos[z.address] = pos;
|
||||
} else {
|
||||
auto it = m_zombieLastPos.find(z.address);
|
||||
if (it == m_zombieLastPos.end()) continue;
|
||||
pos = it->second;
|
||||
}
|
||||
|
||||
float dist = Dist(cam.translation, pos);
|
||||
if (dist > m_zombieMaxDist) continue;
|
||||
if (!IsFacingCamera(cam, pos)) continue;
|
||||
|
||||
float sx{}, sy{};
|
||||
if (!Proj(cam, pos, sx, sy, w, h)) continue;
|
||||
|
||||
auto zhit = m_zombieBoneHistory.find(z.address);
|
||||
if (zhit != m_zombieBoneHistory.end() && zhit->second.initialized
|
||||
&& (m_frameTimeMs - zhit->second.currMs) < 500LL) {
|
||||
SkeletonBones zsm = GetSmoothedBones(zhit->second, m_frameTimeMs);
|
||||
if (m_showSkeleton)
|
||||
DrawSkeleton(dl, zsm, cam, w, h, kYellow, true);
|
||||
if (m_showHeadDot) {
|
||||
float hbx{}, hby{};
|
||||
if (Proj(cam, zsm.head, hbx, hby, w, h)) {
|
||||
// Angular size of a ~25 cm head at the zombie's actual distance.
|
||||
// Scales from ~18px at 5m down to ~3px at 50m; clamp keeps it
|
||||
// always visible and never massive.
|
||||
const float projD2y = std::max(cam.projectionD2y, 0.1f);
|
||||
float zr = std::clamp((0.25f * h * 0.5f) / (dist * projD2y), 2.0f, 18.0f);
|
||||
dl->AddCircleFilled(ImVec2(hbx, hby), zr, IM_COL32(0, 0, 0, 80));
|
||||
dl->AddCircle(ImVec2(hbx, hby), zr, kYellow, 0, 1.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
char zbuf[32];
|
||||
snprintf(zbuf, sizeof(zbuf), "Z [%.0fm]", dist);
|
||||
dl->AddText(ImVec2(sx, sy), kYellow, zbuf);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Items — white label
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void GameOverlay::DrawItems(ImDrawList* dl, const RuntimeUpdate& u, const CameraData& cam, float w, float h) {
|
||||
constexpr ImU32 kWhite = IM_COL32(255, 255, 255, 200);
|
||||
const float maxDistSq = m_itemMaxDist * m_itemMaxDist;
|
||||
|
||||
for (const auto& item : u.items) {
|
||||
// Per-category filter (missing key = enabled)
|
||||
if (!item.filterKey.empty()) {
|
||||
auto it = m_itemCategories.find(item.filterKey);
|
||||
if (it != m_itemCategories.end() && !it->second) continue;
|
||||
}
|
||||
|
||||
if (!item.position.has_value()) continue;
|
||||
|
||||
// Squared-distance cull avoids sqrtf for every item.
|
||||
float dx = item.position->x - cam.translation.x;
|
||||
float dy = item.position->y - cam.translation.y;
|
||||
float dz = item.position->z - cam.translation.z;
|
||||
float distSq = dx*dx + dy*dy + dz*dz;
|
||||
if (distSq > maxDistSq) continue;
|
||||
|
||||
if (!IsFacingCamera(cam, *item.position)) continue;
|
||||
|
||||
float sx{}, sy{};
|
||||
if (!Proj(cam, *item.position, sx, sy, w, h)) continue;
|
||||
|
||||
const std::string& name = item.cleanName.empty()
|
||||
? item.entityName
|
||||
: item.cleanName;
|
||||
if (name.empty()) continue;
|
||||
|
||||
float dist = std::sqrtf(distSq); // sqrt only for items that will actually be drawn
|
||||
|
||||
// Two-tier font: larger close up, smaller at range.
|
||||
char ibuf[256];
|
||||
snprintf(ibuf, sizeof(ibuf), "%s [%.0fm]", name.c_str(), dist);
|
||||
ImFont* font = (dist < 50.0f) ? m_fontLootClose : m_fontLootFar;
|
||||
if (font) ImGui::PushFont(font);
|
||||
dl->AddText(ImVec2(sx, sy), kWhite, ibuf);
|
||||
if (font) ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#include <imgui.h>
|
||||
#include "Config.h"
|
||||
#include "Runtime/DayZRuntimeService.h"
|
||||
|
||||
struct ImDrawList;
|
||||
|
||||
class GameOverlay {
|
||||
public:
|
||||
// Exposed so static helpers in GameOverlay.cpp can take it by reference.
|
||||
struct BoneHistory {
|
||||
SkeletonBones prev{};
|
||||
SkeletonBones curr{};
|
||||
int64_t prevMs = 0;
|
||||
int64_t currMs = 0;
|
||||
bool initialized = false;
|
||||
};
|
||||
|
||||
explicit GameOverlay(DayZRuntimeService& service,
|
||||
OverlayConfig cfg,
|
||||
std::string cfgPath)
|
||||
: m_service(service)
|
||||
, m_cfg(std::move(cfg))
|
||||
, m_cfgPath(std::move(cfgPath))
|
||||
{
|
||||
m_showPlayers = m_cfg.showPlayers;
|
||||
m_showAnimals = m_cfg.showAnimals;
|
||||
m_showZombies = m_cfg.showZombies;
|
||||
m_showItems = m_cfg.showItems;
|
||||
m_showBox = m_cfg.showBox;
|
||||
m_showSkeleton = m_cfg.showSkeleton;
|
||||
m_showHeadDot = m_cfg.showHeadDot;
|
||||
m_showCorpses = m_cfg.showCorpses;
|
||||
m_showWeapon = m_cfg.showWeapon;
|
||||
m_showHealthBar = m_cfg.showHealthBar;
|
||||
m_showHealthNumber = m_cfg.showHealthNumber;
|
||||
m_itemCategories = m_cfg.itemCategories;
|
||||
m_playerMaxDist = m_cfg.playerMaxDist;
|
||||
m_animalMaxDist = m_cfg.animalMaxDist;
|
||||
m_zombieMaxDist = m_cfg.zombieMaxDist;
|
||||
m_itemMaxDist = m_cfg.itemMaxDist;
|
||||
m_pendingW = m_cfg.overlayWidth;
|
||||
m_pendingH = m_cfg.overlayHeight;
|
||||
m_renderW = m_cfg.renderWidth;
|
||||
m_renderH = m_cfg.renderHeight;
|
||||
m_stretchToFill = m_cfg.stretchToFill;
|
||||
m_pendingRW = m_cfg.renderWidth;
|
||||
m_pendingRH = m_cfg.renderHeight;
|
||||
}
|
||||
|
||||
/// Called once after the web radar server has started so the Radar tab
|
||||
/// knows what URL(s) to display.
|
||||
void SetWebRadarPort(int port);
|
||||
|
||||
/// Called once after ImGui fonts are built so DrawItems can tier by distance.
|
||||
void SetLootFonts(ImFont* close, ImFont* lootFar) {
|
||||
m_fontLootClose = close;
|
||||
m_fontLootFar = lootFar;
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the user clicks the exit button.
|
||||
/// The caller should set its stop flag inside the callback.
|
||||
void SetExitCallback(std::function<void()> cb) { m_exitCallback = std::move(cb); }
|
||||
|
||||
/// Register a callback invoked when the user applies a new overlay resolution.
|
||||
void SetResizeCallback(std::function<void(int,int)> cb) { m_resizeCallback = std::move(cb); }
|
||||
|
||||
// Called each frame. w/h a re the overlay window pixel dimensions.
|
||||
void Draw(float w, float h);
|
||||
|
||||
private:
|
||||
DayZRuntimeService& m_service;
|
||||
OverlayConfig m_cfg;
|
||||
std::string m_cfgPath;
|
||||
|
||||
// Cached entity snapshot (updated once per frame via GetLatestUpdate).
|
||||
std::shared_ptr<const RuntimeUpdate> m_snapshot;
|
||||
|
||||
public:
|
||||
bool IsMenuOpen() const { return m_menuOpen; }
|
||||
|
||||
private:
|
||||
|
||||
// Live camera — updated every frame directly from the service,
|
||||
// bypassing the entity snapshot so it reflects the latest DMA read.
|
||||
CameraData m_liveCamera;
|
||||
|
||||
// Web radar
|
||||
int m_webPort = 7777;
|
||||
std::vector<std::string> m_webUrls; // populated by SetWebRadarPort()
|
||||
|
||||
// Exit callback — invoked when the user presses the exit button.
|
||||
std::function<void()> m_exitCallback;
|
||||
// Resize callback — invoked when the user applies a new resolution.
|
||||
std::function<void(int, int)> m_resizeCallback;
|
||||
|
||||
// Menu state
|
||||
bool m_menuOpen = false;
|
||||
float m_menuAlpha = 0.0f;
|
||||
int m_tab = 0;
|
||||
int m_subtab = 0;
|
||||
|
||||
// ESP toggles (initialised from config in constructor)
|
||||
bool m_showPlayers = true;
|
||||
bool m_showAnimals = true;
|
||||
bool m_showZombies = true;
|
||||
bool m_showItems = true;
|
||||
bool m_showBox = true;
|
||||
bool m_showSkeleton = true;
|
||||
bool m_showHeadDot = false;
|
||||
bool m_showCorpses = false;
|
||||
bool m_showWeapon = true;
|
||||
bool m_showHealthBar = true;
|
||||
bool m_showHealthNumber = false;
|
||||
bool m_debugSkeleton = false; // draws named bone dots for the closest player
|
||||
|
||||
// Per-category item enabled map (key = filterKey, missing = enabled)
|
||||
std::map<std::string, bool> m_itemCategories;
|
||||
|
||||
// Loot fonts — set once via SetLootFonts() after ImGui atlas is built.
|
||||
ImFont* m_fontLootClose = nullptr; // 16 px — used for items within 50 m
|
||||
ImFont* m_fontLootFar = nullptr; // 11 px — used for items >= 50 m
|
||||
|
||||
// Last-known positions for zombies keyed by entity address.
|
||||
// Used to avoid a single failed position read causing a visible blink.
|
||||
std::unordered_map<uint64_t, Vector3> m_zombieLastPos;
|
||||
|
||||
// ---- Bone interpolation / extrapolation ----
|
||||
std::unordered_map<uint64_t, BoneHistory> m_playerBoneHistory;
|
||||
std::unordered_map<uint64_t, BoneHistory> m_zombieBoneHistory;
|
||||
int64_t m_frameTimeMs = 0; // set once per Draw() call
|
||||
|
||||
static int64_t NowMs();
|
||||
|
||||
// Pending resolution override — edited in menu, applied on button press.
|
||||
int m_pendingW = 0;
|
||||
int m_pendingH = 0;
|
||||
int m_pendingRW = 0; // render width (stretched res)
|
||||
int m_pendingRH = 0; // render height (stretched res)
|
||||
|
||||
// Stretched resolution state (active values, applied on "Apply")
|
||||
int m_renderW = 0;
|
||||
int m_renderH = 0;
|
||||
bool m_stretchToFill = true;
|
||||
|
||||
// ESP distance limits (initialised from config in constructor)
|
||||
float m_playerMaxDist = 1000.0f;
|
||||
float m_animalMaxDist = 1000.0f;
|
||||
float m_zombieMaxDist = 500.0f;
|
||||
float m_itemMaxDist = 200.0f;
|
||||
|
||||
// Low-level NDC projection helper — maps a world position to overlay pixels.
|
||||
// Returns false if the position is behind the camera or off-screen.
|
||||
static bool WorldToScreen(const CameraData& cam,
|
||||
const Vector3& worldPos,
|
||||
float& sx, float& sy,
|
||||
float w, float h);
|
||||
|
||||
// Viewport-aware projection wrapper. Use this instead of WorldToScreen
|
||||
// directly — it applies stretched-resolution / letterbox offsets.
|
||||
bool Proj(const CameraData& cam, const Vector3& worldPos,
|
||||
float& sx, float& sy, float overlayW, float overlayH) const;
|
||||
|
||||
static float Dist(const Vector3& a, const Vector3& b);
|
||||
|
||||
// Copy live menu state (m_show*, distances, item categories, resolution)
|
||||
// back into m_cfg prior to a Save.
|
||||
void SyncConfig();
|
||||
|
||||
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);
|
||||
|
||||
// Draw skeleton bone segments for a single entity. color = ImU32 (unsigned int).
|
||||
void DrawSkeleton(ImDrawList* dl, const SkeletonBones& bones,
|
||||
const CameraData& cam, float w, float h,
|
||||
unsigned int color, bool isZombie) const;
|
||||
|
||||
// Debug: draw every named bone as a labeled dot for the closest player.
|
||||
void DrawSkeletonDebug(ImDrawList* dl, const RuntimeUpdate& u,
|
||||
const CameraData& cam, float w, float h) const;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
// -------------------------------------------------------------------------
|
||||
// MenuBridge — the seam between the vendored Lumin ImGui menu (external/lumin)
|
||||
// and this project's overlay state (GameOverlay).
|
||||
//
|
||||
// GameOverlay fills one of these every frame (pointers into its own members
|
||||
// for the editable values, plain values for the read-only info panels) and
|
||||
// the menu's render() reads/writes through the global pointer below. This
|
||||
// keeps all the actual ESP state owned by GameOverlay while letting Lumin's
|
||||
// widgets drive it.
|
||||
// -------------------------------------------------------------------------
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct MenuBridge {
|
||||
// ---- ESP visibility toggles (point at GameOverlay members) ----
|
||||
bool* showPlayers = nullptr;
|
||||
bool* showAnimals = nullptr;
|
||||
bool* showZombies = nullptr;
|
||||
bool* showItems = nullptr;
|
||||
bool* showBox = nullptr;
|
||||
bool* showSkeleton = nullptr;
|
||||
bool* showHeadDot = nullptr;
|
||||
bool* showWeapon = nullptr;
|
||||
bool* showHealthBar = nullptr;
|
||||
bool* showHealthNumber = nullptr;
|
||||
bool* showCorpses = nullptr;
|
||||
bool* debugSkeleton = nullptr;
|
||||
|
||||
// ---- ESP draw-distance limits ----
|
||||
float* playerMaxDist = nullptr;
|
||||
float* animalMaxDist = nullptr;
|
||||
float* zombieMaxDist = nullptr;
|
||||
float* itemMaxDist = nullptr;
|
||||
|
||||
// ---- Per-category loot toggles (key = filterKey) ----
|
||||
std::map<std::string, bool>* itemCategories = nullptr;
|
||||
|
||||
// ---- Read-only info (refreshed each frame) ----
|
||||
bool connected = false;
|
||||
std::string serverName;
|
||||
std::string mapName;
|
||||
std::string status;
|
||||
bool hasPos = false;
|
||||
float px = 0.f, py = 0.f, pz = 0.f;
|
||||
std::size_t nPlayers = 0, nAnimals = 0, nZombies = 0,
|
||||
nVehicles = 0, nItems = 0, nBullets = 0;
|
||||
|
||||
// ---- Web radar ----
|
||||
int webPort = 7777;
|
||||
std::vector<std::string> webUrls;
|
||||
|
||||
// ---- Resolution settings (point at GameOverlay members) ----
|
||||
int* pendingW = nullptr;
|
||||
int* pendingH = nullptr;
|
||||
int* pendingRW = nullptr;
|
||||
int* pendingRH = nullptr;
|
||||
bool* stretchToFill = nullptr;
|
||||
|
||||
// ---- Actions ----
|
||||
std::function<void()> onApplyDisplayRes; // applies pendingW/H + resize
|
||||
std::function<void()> onApplyRenderRes; // applies pendingRW/RH + stretch
|
||||
std::function<void()> onSaveConfig;
|
||||
std::function<void()> onExit;
|
||||
};
|
||||
|
||||
// Set by GameOverlay before each gui->render() call; read by the menu.
|
||||
extern MenuBridge* g_menu;
|
||||
|
||||
// Thin shim implemented in external/lumin/framework/gui.cpp. Lets GameOverlay
|
||||
// drive the Lumin menu without pulling the whole framework header (and its
|
||||
// `using namespace ImGui`) into GameOverlay's translation unit. Must be called
|
||||
// between ImGui::NewFrame() and ImGui::Render().
|
||||
void RenderLuminMenu();
|
||||
@@ -0,0 +1,296 @@
|
||||
#include "OverlayWindow.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <imgui_impl_win32.h>
|
||||
#include <imgui_impl_dx11.h>
|
||||
|
||||
// Vendored Lumin framework — provides the FreeType-backed font system (`font`),
|
||||
// shared state (`var`) and the embedded font data (inter_*, icon_font).
|
||||
#include "framework/headers/includes.h"
|
||||
|
||||
#include <dwmapi.h>
|
||||
#pragma comment(lib, "dwmapi.lib")
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND, UINT, WPARAM, LPARAM);
|
||||
|
||||
static constexpr wchar_t kWndClass[] = L"DayZEspWindow";
|
||||
|
||||
// ESP fonts go through the Lumin font system so they survive its atlas
|
||||
// rebuilds. dpi is locked to 1.0 (variables.h) so these map to exact pixels.
|
||||
static const char* kEspFontPath = "C:\\Windows\\Fonts\\tahoma.ttf";
|
||||
|
||||
// Register every font size the Lumin menu can request, so the atlas is built
|
||||
// up front and (ideally) never rebuilt mid-run. Any size missed here is still
|
||||
// handled gracefully by the per-frame rebuild check in Run().
|
||||
static void WarmMenuFonts() {
|
||||
font->get(inter_medium, 10);
|
||||
font->get(inter_medium, 11);
|
||||
font->get(inter_semibold, 8);
|
||||
font->get(inter_semibold, 10);
|
||||
font->get(inter_semibold, 11);
|
||||
font->get(inter_semibold, 12);
|
||||
font->get(inter_semibold, 13);
|
||||
font->get(inter_semibold, 16);
|
||||
font->get(inter_semibold, 18);
|
||||
font->get(icon_font, 13);
|
||||
font->get(icon_font, 14);
|
||||
font->get(icon_font, 15);
|
||||
font->get_file(flaticon_uicons_regular_rounded_path, 12.0f, true);
|
||||
font->get_file(flaticon_uicons_regular_rounded_path, 12.5f, true);
|
||||
font->get_file("C:\\Windows\\Fonts\\segmdl2.ttf", 11.f, true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WndProc
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
LRESULT WINAPI OverlayWindow::WndProc(HWND h, UINT msg, WPARAM w, LPARAM l) {
|
||||
if (ImGui_ImplWin32_WndProcHandler(h, msg, w, l)) return true;
|
||||
switch (msg) {
|
||||
case WM_SIZE:
|
||||
if (w != SIZE_MINIMIZED) {
|
||||
// Store pending resize; handled at top of render loop
|
||||
if (auto* self = reinterpret_cast<OverlayWindow*>(GetWindowLongPtrW(h, GWLP_USERDATA))) {
|
||||
self->m_resizeW = LOWORD(l);
|
||||
self->m_resizeH = HIWORD(l);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
case WM_SYSCOMMAND:
|
||||
if ((w & 0xFFF0) == SC_KEYMENU) return 0;
|
||||
break;
|
||||
}
|
||||
return DefWindowProcW(h, msg, w, l);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CreateWin32Window
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool OverlayWindow::CreateWin32Window() {
|
||||
m_w = (m_overrideW > 0) ? m_overrideW : GetSystemMetrics(SM_CXSCREEN);
|
||||
m_h = (m_overrideH > 0) ? m_overrideH : GetSystemMetrics(SM_CYSCREEN);
|
||||
|
||||
WNDCLASSEXW wc{};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.style = CS_CLASSDC;
|
||||
wc.lpfnWndProc = WndProc;
|
||||
wc.hInstance = GetModuleHandle(nullptr);
|
||||
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||
wc.lpszClassName = kWndClass;
|
||||
if (!RegisterClassExW(&wc)) return false;
|
||||
|
||||
m_hwnd = CreateWindowExW(
|
||||
WS_EX_APPWINDOW,
|
||||
kWndClass, L"KarachiHook",
|
||||
WS_POPUP | WS_VISIBLE,
|
||||
0, 0, m_w, m_h,
|
||||
nullptr, nullptr, wc.hInstance, nullptr
|
||||
);
|
||||
if (!m_hwnd) return false;
|
||||
|
||||
// Store 'this' so WndProc can find us for resize events
|
||||
SetWindowLongPtrW(m_hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
||||
ShowWindow(m_hwnd, SW_SHOWDEFAULT);
|
||||
UpdateWindow(m_hwnd);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ResizeTo
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void OverlayWindow::ResizeTo(int w, int h) {
|
||||
if (!m_hwnd || w <= 0 || h <= 0) return;
|
||||
SetWindowPos(m_hwnd, nullptr, 0, 0, w, h,
|
||||
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
// WM_SIZE will be posted; the render loop picks it up via m_resizeW/m_resizeH.
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CreateDX11
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool OverlayWindow::CreateDX11() {
|
||||
DXGI_SWAP_CHAIN_DESC sd{};
|
||||
sd.BufferCount = 2;
|
||||
sd.BufferDesc.Width = 0;
|
||||
sd.BufferDesc.Height = 0;
|
||||
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
sd.BufferDesc.RefreshRate.Numerator = 60;
|
||||
sd.BufferDesc.RefreshRate.Denominator = 1;
|
||||
sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
|
||||
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
|
||||
sd.OutputWindow = m_hwnd;
|
||||
sd.SampleDesc.Count = 1;
|
||||
sd.Windowed = TRUE;
|
||||
sd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
|
||||
|
||||
const D3D_FEATURE_LEVEL fla[] = { D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_0 };
|
||||
D3D_FEATURE_LEVEL fl{};
|
||||
|
||||
HRESULT hr = D3D11CreateDeviceAndSwapChain(
|
||||
nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0,
|
||||
fla, 2, D3D11_SDK_VERSION,
|
||||
&sd, &m_sc, &m_dev, &fl, &m_ctx
|
||||
);
|
||||
if (FAILED(hr))
|
||||
hr = D3D11CreateDeviceAndSwapChain(
|
||||
nullptr, D3D_DRIVER_TYPE_WARP, nullptr, 0,
|
||||
fla, 2, D3D11_SDK_VERSION,
|
||||
&sd, &m_sc, &m_dev, &fl, &m_ctx);
|
||||
|
||||
return SUCCEEDED(hr);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RTV helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void OverlayWindow::CreateRTV() {
|
||||
ID3D11Texture2D* back = nullptr;
|
||||
if (SUCCEEDED(m_sc->GetBuffer(0, IID_PPV_ARGS(&back)))) {
|
||||
m_dev->CreateRenderTargetView(back, nullptr, &m_rtv);
|
||||
back->Release();
|
||||
}
|
||||
}
|
||||
|
||||
void OverlayWindow::CleanupRTV() {
|
||||
if (m_rtv) { m_rtv->Release(); m_rtv = nullptr; }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Destroy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void OverlayWindow::Destroy() {
|
||||
ImGui_ImplDX11_Shutdown();
|
||||
ImGui_ImplWin32_Shutdown();
|
||||
if (ImGui::GetCurrentContext()) ImGui::DestroyContext();
|
||||
CleanupRTV();
|
||||
if (m_sc) { m_sc->Release(); m_sc = nullptr; }
|
||||
if (m_ctx) { m_ctx->Release(); m_ctx = nullptr; }
|
||||
if (m_dev) { m_dev->Release(); m_dev = nullptr; }
|
||||
if (m_hwnd){ DestroyWindow(m_hwnd); m_hwnd = nullptr; }
|
||||
UnregisterClassW(kWndClass, GetModuleHandle(nullptr));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Run
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void OverlayWindow::Run(std::atomic<bool>& stopFlag) {
|
||||
if (!CreateWin32Window() || !CreateDX11()) {
|
||||
spdlog::error("OverlayWindow: init failed.");
|
||||
Destroy();
|
||||
stopFlag.store(true);
|
||||
return;
|
||||
}
|
||||
CreateRTV();
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
|
||||
|
||||
ImGui::StyleColorsDark();
|
||||
// Make window backgrounds semi-transparent so the game is partially visible
|
||||
ImGui::GetStyle().Colors[ImGuiCol_WindowBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
|
||||
ImGui_ImplWin32_Init(m_hwnd);
|
||||
ImGui_ImplDX11_Init(m_dev, m_ctx);
|
||||
|
||||
// ---- Fonts via the Lumin FreeType atlas (must run after backend init) ----
|
||||
WarmMenuFonts();
|
||||
font->get_file(kEspFontPath, 14.f); // ESP base -> io.FontDefault
|
||||
font->get_file(kEspFontPath, 16.f); // loot close (<50 m)
|
||||
font->get_file(kEspFontPath, 11.f); // loot far (>=50 m)
|
||||
|
||||
var->gui.dpi_changed = true;
|
||||
font->update(); // build the atlas once
|
||||
|
||||
if (ImFont* base = font->get_file(kEspFontPath, 14.f)) io.FontDefault = base;
|
||||
ImFont* fontLootClose = font->get_file(kEspFontPath, 16.f);
|
||||
ImFont* fontLootFar = font->get_file(kEspFontPath, 11.f);
|
||||
|
||||
// Notify caller that fonts are ready — must be done after the atlas build
|
||||
// so the ImFont pointers are fully populated.
|
||||
if (m_onFontReady) m_onFontReady(fontLootClose, fontLootFar);
|
||||
|
||||
spdlog::info("ESP window running ({}x{}).", m_w, m_h);
|
||||
|
||||
MSG msg{};
|
||||
bool done = false;
|
||||
while (!stopFlag.load() && !done) {
|
||||
while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessage(&msg);
|
||||
if (msg.message == WM_QUIT) { done = true; break; }
|
||||
}
|
||||
if (done) break;
|
||||
|
||||
// Handle deferred resize
|
||||
if (m_resizeW != 0 && m_resizeH != 0) {
|
||||
CleanupRTV();
|
||||
m_sc->ResizeBuffers(0, m_resizeW, m_resizeH, DXGI_FORMAT_UNKNOWN, 0);
|
||||
m_w = static_cast<int>(m_resizeW);
|
||||
m_h = static_cast<int>(m_resizeH);
|
||||
m_resizeW = m_resizeH = 0;
|
||||
CreateRTV();
|
||||
}
|
||||
|
||||
// The Lumin menu registers font sizes lazily; rebuild the atlas when a
|
||||
// new one appears (must happen outside a frame) and refresh the cached
|
||||
// ESP font pointers so they never dangle across a rebuild.
|
||||
if (var->gui.dpi_changed) {
|
||||
font->update();
|
||||
if (ImFont* base = font->get_file(kEspFontPath, 14.f)) io.FontDefault = base;
|
||||
if (m_onFontReady)
|
||||
m_onFontReady(font->get_file(kEspFontPath, 16.f),
|
||||
font->get_file(kEspFontPath, 11.f));
|
||||
}
|
||||
|
||||
ImGui_ImplDX11_NewFrame();
|
||||
ImGui_ImplWin32_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
if (m_draw) m_draw(static_cast<float>(m_w), static_cast<float>(m_h));
|
||||
|
||||
// Toggle interactivity: remove WS_EX_NOACTIVATE when the menu is open
|
||||
// so ImGui can receive mouse/keyboard input, restore it when closed so
|
||||
// the overlay doesn't steal focus during gameplay.
|
||||
if (m_queryInput) {
|
||||
bool wantInput = m_queryInput();
|
||||
if (wantInput != m_prevWantInput) {
|
||||
LONG_PTR ex = GetWindowLongPtrW(m_hwnd, GWL_EXSTYLE);
|
||||
if (wantInput) {
|
||||
ex &= ~static_cast<LONG_PTR>(WS_EX_NOACTIVATE);
|
||||
SetWindowLongPtrW(m_hwnd, GWL_EXSTYLE, ex);
|
||||
SetForegroundWindow(m_hwnd);
|
||||
} else {
|
||||
ex |= WS_EX_NOACTIVATE;
|
||||
SetWindowLongPtrW(m_hwnd, GWL_EXSTYLE, ex);
|
||||
}
|
||||
m_prevWantInput = wantInput;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Render();
|
||||
|
||||
// Pure black background
|
||||
const float clear[4] = { 0.0f, 0.0f, 0.0f, 1.0f };
|
||||
m_ctx->OMSetRenderTargets(1, &m_rtv, nullptr);
|
||||
m_ctx->ClearRenderTargetView(m_rtv, clear);
|
||||
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
|
||||
DwmFlush(); // sync to DWM compositor vblank before present
|
||||
m_sc->Present(1, 0); // 1 = vsync interval
|
||||
}
|
||||
|
||||
Destroy();
|
||||
stopFlag.store(true);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <imgui.h>
|
||||
|
||||
// Standard ImGui + DX11 window, always-on-top, fullscreen popup.
|
||||
// The game is visible through the mostly-dark background while ImGui
|
||||
// draw-list calls render the ESP data on top.
|
||||
class OverlayWindow {
|
||||
public:
|
||||
using DrawFn = std::function<void(float w, float h)>;
|
||||
using InputQueryFn = std::function<bool()>;
|
||||
using FontReadyFn = std::function<void(ImFont* lootClose, ImFont* lootFar)>;
|
||||
|
||||
OverlayWindow() = default;
|
||||
~OverlayWindow() = default;
|
||||
|
||||
OverlayWindow(const OverlayWindow&) = delete;
|
||||
OverlayWindow& operator=(const OverlayWindow&) = delete;
|
||||
|
||||
void SetDrawCallback(DrawFn fn) { m_draw = std::move(fn); }
|
||||
void SetInputQueryCallback(InputQueryFn fn) { m_queryInput = std::move(fn); }
|
||||
// Called once after ImGui fonts are built, before the first frame.
|
||||
void SetFontReadyCallback(FontReadyFn fn) { m_onFontReady = std::move(fn); }
|
||||
|
||||
// Override the window size used at creation (0 = use GetSystemMetrics).
|
||||
void SetResolutionOverride(int w, int h) { m_overrideW = w; m_overrideH = h; }
|
||||
|
||||
// Resize the live window; triggers WM_SIZE which the render loop handles.
|
||||
void ResizeTo(int w, int h);
|
||||
|
||||
// Blocks until stopFlag becomes true or the window is closed.
|
||||
void Run(std::atomic<bool>& stopFlag);
|
||||
|
||||
// Called from WndProc for WM_SIZE
|
||||
UINT m_resizeW = 0;
|
||||
UINT m_resizeH = 0;
|
||||
|
||||
// Resolution override — set before Run(); 0 = auto from screen metrics.
|
||||
int m_overrideW = 0;
|
||||
int m_overrideH = 0;
|
||||
|
||||
private:
|
||||
DrawFn m_draw;
|
||||
InputQueryFn m_queryInput;
|
||||
FontReadyFn m_onFontReady;
|
||||
bool m_prevWantInput = false;
|
||||
|
||||
HWND m_hwnd = nullptr;
|
||||
ID3D11Device* m_dev = nullptr;
|
||||
ID3D11DeviceContext* m_ctx = nullptr;
|
||||
IDXGISwapChain* m_sc = nullptr;
|
||||
ID3D11RenderTargetView* m_rtv = nullptr;
|
||||
|
||||
int m_w = 0;
|
||||
int m_h = 0;
|
||||
|
||||
bool CreateWin32Window();
|
||||
bool CreateDX11();
|
||||
void CreateRTV();
|
||||
void CleanupRTV();
|
||||
void Destroy();
|
||||
|
||||
static LRESULT WINAPI WndProc(HWND h, UINT msg, WPARAM w, LPARAM l);
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
#include "Readers/BulletTableReader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <numbers>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "RuntimeOffsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool BulletTableReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZBulletEntry>& entries)
|
||||
{
|
||||
entries.clear();
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// 1. Bullet count — read first; the table pointer is null when count==0
|
||||
int32_t rawCount = 0;
|
||||
if (!mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::BulletCount,
|
||||
rawCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int count = std::clamp(rawCount, 0, kMaxBullets);
|
||||
|
||||
// 2. Nothing alive — table pointer may be null, safe to return early
|
||||
if (count == 0) {
|
||||
m_tableUnavailable = false;
|
||||
m_lastPositions.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Bullet table pointer (only valid when count > 0)
|
||||
uint64_t bulletTablePtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::BulletTable,
|
||||
bulletTablePtr))
|
||||
{
|
||||
m_tableUnavailable = true;
|
||||
return false;
|
||||
}
|
||||
m_tableUnavailable = false;
|
||||
|
||||
// 4. Bulk-read the pointer table (count * 8 bytes)
|
||||
const size_t tableBytes = static_cast<size_t>(count) * 8;
|
||||
std::vector<uint8_t> buf;
|
||||
buf.reserve(tableBytes);
|
||||
if (!mem.ReadBytes(pid, bulletTablePtr, tableBytes, buf)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Iterate pointers, build entries for valid distinct addresses
|
||||
std::unordered_set<uint64_t> seen;
|
||||
seen.reserve(static_cast<size_t>(count));
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
uint64_t addr = 0;
|
||||
std::memcpy(&addr, buf.data() + static_cast<size_t>(i) * 8, sizeof(uint64_t));
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(addr)) continue;
|
||||
if (!seen.insert(addr).second) continue; // skip duplicates
|
||||
|
||||
entries.push_back(BuildBulletEntry(mem, session, addr));
|
||||
}
|
||||
|
||||
// 6. Sort by address for deterministic ordering
|
||||
std::sort(entries.begin(), entries.end(),
|
||||
[](const DayZBulletEntry& a, const DayZBulletEntry& b) {
|
||||
return a.address < b.address;
|
||||
});
|
||||
|
||||
// 7. Sync last-position cache
|
||||
UpdateLastPositions(entries);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void BulletTableReader::Reset()
|
||||
{
|
||||
m_lastPositions.clear();
|
||||
m_tableUnavailable = false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildBulletEntry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DayZBulletEntry BulletTableReader::BuildBulletEntry(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t address)
|
||||
{
|
||||
DayZBulletEntry entry;
|
||||
entry.address = address;
|
||||
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// 1. VisualState pointer
|
||||
uint64_t visualStateAddr = 0;
|
||||
if (!mem.TryReadPointer(pid, address + Offsets::Common::VisualState, visualStateAddr)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// 2. Position
|
||||
Vector3 pos{};
|
||||
if (TryReadBulletPosition(mem, pid, visualStateAddr, pos)) {
|
||||
entry.position = pos;
|
||||
}
|
||||
|
||||
// 3. Direction from stored visual vectors
|
||||
Vector3 dir{};
|
||||
float heading = 0.0f;
|
||||
if (TryReadBulletDirection(mem, pid, visualStateAddr, dir, heading)) {
|
||||
entry.direction = dir;
|
||||
entry.headingDegrees = heading;
|
||||
entry.directionSource = "visual";
|
||||
return entry;
|
||||
}
|
||||
|
||||
// 4. Direction fallback: flight delta from previous position
|
||||
if (entry.position.has_value()) {
|
||||
auto prevIt = m_lastPositions.find(address);
|
||||
if (prevIt != m_lastPositions.end()) {
|
||||
Vector3 flightDir{};
|
||||
float flightHeading = 0.0f;
|
||||
if (TryGetFlightDirection(prevIt->second, *entry.position,
|
||||
flightDir, flightHeading))
|
||||
{
|
||||
entry.direction = flightDir;
|
||||
entry.headingDegrees = flightHeading;
|
||||
entry.directionSource = "raw";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryReadBulletPosition
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool BulletTableReader::TryReadBulletPosition(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t visualStateAddr,
|
||||
Vector3& pos)
|
||||
{
|
||||
if (!mem.TryReadValue<Vector3>(pid,
|
||||
visualStateAddr + Offsets::Common::Position,
|
||||
pos))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return MemoryValidation::IsValidVector(pos);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryReadBulletDirection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool BulletTableReader::TryReadBulletDirection(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t visualStateAddr,
|
||||
Vector3& dir,
|
||||
float& heading)
|
||||
{
|
||||
// DirectionX and DirectionY are stored as float[3] each (x,y,z components
|
||||
// of the engine's view-matrix row). We use X-component from each row to
|
||||
// reconstruct a 2-D horizontal direction that matches the C# original.
|
||||
float dirX = 0.0f;
|
||||
float dirY = 0.0f;
|
||||
if (!mem.TryReadValue<float>(pid, visualStateAddr + Offsets::Common::DirectionX, dirX))
|
||||
return false;
|
||||
if (!mem.TryReadValue<float>(pid, visualStateAddr + Offsets::Common::DirectionY, dirY))
|
||||
return false;
|
||||
|
||||
// Construct a normalized 3-D direction in the horizontal plane
|
||||
const float len = std::sqrt(dirX * dirX + dirY * dirY);
|
||||
if (len < kMinDirectionDelta) return false;
|
||||
|
||||
dir.x = dirX / len;
|
||||
dir.y = 0.0f;
|
||||
dir.z = dirY / len;
|
||||
|
||||
constexpr float kRadToDeg = 180.0f / static_cast<float>(std::numbers::pi);
|
||||
heading = MemoryValidation::NormalizeHeading(
|
||||
std::atan2(dirY, dirX) * kRadToDeg + 90.0f);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryGetFlightDirection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool BulletTableReader::TryGetFlightDirection(const Vector3& prev,
|
||||
const Vector3& curr,
|
||||
Vector3& dir,
|
||||
float& heading)
|
||||
{
|
||||
const float dx = curr.x - prev.x;
|
||||
const float dy = curr.y - prev.y;
|
||||
const float dz = curr.z - prev.z;
|
||||
|
||||
const float lenSq = dx * dx + dy * dy + dz * dz;
|
||||
if (lenSq < kMinDirectionDelta * kMinDirectionDelta) return false;
|
||||
|
||||
// Horizontal components must also be meaningful
|
||||
const float hDelta = std::sqrt(dx * dx + dz * dz);
|
||||
if (hDelta < kMinDirectionDelta) return false;
|
||||
|
||||
const float len = std::sqrt(lenSq);
|
||||
dir.x = dx / len;
|
||||
dir.y = dy / len;
|
||||
dir.z = dz / len;
|
||||
|
||||
constexpr float kRadToDeg = 180.0f / static_cast<float>(std::numbers::pi);
|
||||
heading = MemoryValidation::NormalizeHeading(
|
||||
std::atan2(dz, dx) * kRadToDeg + 90.0f);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UpdateLastPositions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void BulletTableReader::UpdateLastPositions(const std::vector<DayZBulletEntry>& entries)
|
||||
{
|
||||
// Collect the set of currently-alive bullet addresses
|
||||
std::unordered_set<uint64_t> alive;
|
||||
alive.reserve(entries.size());
|
||||
|
||||
for (const auto& e : entries) {
|
||||
alive.insert(e.address);
|
||||
if (e.position.has_value()) {
|
||||
m_lastPositions[e.address] = *e.position;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove entries that are no longer alive
|
||||
for (auto it = m_lastPositions.begin(); it != m_lastPositions.end(); ) {
|
||||
if (alive.find(it->first) == alive.end()) {
|
||||
it = m_lastPositions.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BulletTableReader
|
||||
// Reads the world's active bullet table each call (non-incremental).
|
||||
// Direction is derived from the visual state's stored direction vectors;
|
||||
// if those are unavailable the reader falls back to computing a flight
|
||||
// delta from the previous frame's position.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class BulletTableReader {
|
||||
public:
|
||||
/// Read all active bullets into @p entries.
|
||||
/// Returns false only when the bullet table pointer cannot be read.
|
||||
bool TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZBulletEntry>& entries);
|
||||
|
||||
/// Reset cached position state. Call when the game session restarts.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Per-bullet state (previous-frame position for flight-delta fallback)
|
||||
// ------------------------------------------------------------------
|
||||
std::unordered_map<uint64_t, Vector3> m_lastPositions;
|
||||
|
||||
bool m_tableUnavailable = false;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Entry construction
|
||||
// ------------------------------------------------------------------
|
||||
DayZBulletEntry BuildBulletEntry(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t address);
|
||||
|
||||
/// Read the bullet's current position from its visual state.
|
||||
static bool TryReadBulletPosition(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t visualStateAddr,
|
||||
Vector3& pos);
|
||||
|
||||
/// Read the stored direction vectors (DirectionX / DirectionY) from the
|
||||
/// visual state and derive a normalized world direction + heading.
|
||||
static bool TryReadBulletDirection(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t visualStateAddr,
|
||||
Vector3& dir,
|
||||
float& heading);
|
||||
|
||||
/// Derive flight direction from the delta between previous and current
|
||||
/// positions. Returns false when the delta is too small to be useful.
|
||||
bool TryGetFlightDirection(const Vector3& prev,
|
||||
const Vector3& curr,
|
||||
Vector3& dir,
|
||||
float& heading);
|
||||
|
||||
/// Sync m_lastPositions with the entries that were just built:
|
||||
/// add/update entries still alive, remove entries no longer present.
|
||||
void UpdateLastPositions(const std::vector<DayZBulletEntry>& entries);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Constants
|
||||
// ------------------------------------------------------------------
|
||||
static constexpr int kMaxBullets = 512;
|
||||
static constexpr float kMinDirectionDelta = 0.001f;
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
#include "Readers/ClientScoreboardReader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
ClientScoreboardReader::ClientScoreboardReader(int refreshIntervalMs)
|
||||
: m_networkIdRefreshInterval(refreshIntervalMs)
|
||||
{}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RefreshNetworkId
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ClientScoreboardReader::RefreshNetworkId(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t identityAddr,
|
||||
IdentityMetadata& meta)
|
||||
{
|
||||
uint32_t id = 0;
|
||||
if (mem.TryReadValue<uint32_t>(pid,
|
||||
identityAddr + Offsets::Network::PlayerIdentityNetworkId,
|
||||
id))
|
||||
{
|
||||
meta.networkId = id;
|
||||
}
|
||||
meta.lastNetworkIdReadTime = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool ClientScoreboardReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZScoreboardPlayer>& players)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. NetworkClient pointer
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t networkClientAddr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.networkManagerAddress + Offsets::Network::ManagerNetworkClient,
|
||||
networkClientAddr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. Scoreboard base pointer
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t scoreboardPtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
networkClientAddr + Offsets::Network::ClientScoreboard,
|
||||
scoreboardPtr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. Player count
|
||||
// -----------------------------------------------------------------
|
||||
int32_t rawPlayerCount = 0;
|
||||
if (!mem.TryReadValue<int32_t>(pid,
|
||||
networkClientAddr + Offsets::Network::ClientPlayerCount,
|
||||
rawPlayerCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int playerCount = std::clamp(rawPlayerCount, 0, kMaxPlayers);
|
||||
if (playerCount == 0) {
|
||||
players.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Bulk-read identity pointer array (kScoreboardCapacity * 8 bytes)
|
||||
// -----------------------------------------------------------------
|
||||
const int slotsToRead = std::min(playerCount, kScoreboardCapacity);
|
||||
std::vector<uint8_t> ptrTable;
|
||||
ptrTable.reserve(static_cast<size_t>(slotsToRead) * 8);
|
||||
if (!mem.ReadBytes(pid, scoreboardPtr,
|
||||
static_cast<size_t>(slotsToRead) * 8, ptrTable))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 5. Build list of valid identity addresses from the table
|
||||
// -----------------------------------------------------------------
|
||||
std::vector<uint64_t> validAddresses;
|
||||
validAddresses.reserve(static_cast<size_t>(slotsToRead));
|
||||
|
||||
for (int i = 0; i < slotsToRead; ++i) {
|
||||
uint64_t identityAddr = 0;
|
||||
std::memcpy(&identityAddr,
|
||||
ptrTable.data() + static_cast<size_t>(i) * 8,
|
||||
sizeof(uint64_t));
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(identityAddr)) {
|
||||
continue;
|
||||
}
|
||||
validAddresses.push_back(identityAddr);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 6. Ensure every address has a cached metadata entry
|
||||
// Read identity strings once; they are stable for the session.
|
||||
// -----------------------------------------------------------------
|
||||
for (uint64_t identityAddr : validAddresses) {
|
||||
if (m_metadataByAddress.count(identityAddr)) {
|
||||
continue; // already cached
|
||||
}
|
||||
|
||||
IdentityMetadata meta;
|
||||
|
||||
// SteamId: read ptr at offset, then ArmaString
|
||||
{
|
||||
uint64_t steamIdPtr = 0;
|
||||
if (mem.TryReadPointer(pid,
|
||||
identityAddr + Offsets::Network::PlayerIdentitySteamId,
|
||||
steamIdPtr))
|
||||
{
|
||||
meta.steamId = mem.ReadArmaString(pid, steamIdPtr);
|
||||
}
|
||||
}
|
||||
|
||||
// PlayerName: read ptr at offset, then ArmaString
|
||||
{
|
||||
uint64_t namePtr = 0;
|
||||
if (mem.TryReadPointer(pid,
|
||||
identityAddr + Offsets::Network::PlayerIdentityPlayerName,
|
||||
namePtr))
|
||||
{
|
||||
meta.playerName = mem.ReadArmaString(pid, namePtr);
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkId (initial read — will be refreshed on schedule)
|
||||
RefreshNetworkId(mem, pid, identityAddr, meta);
|
||||
|
||||
m_metadataByAddress[identityAddr] = std::move(meta);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 7. Round-robin refresh of stale NetworkIds
|
||||
// -----------------------------------------------------------------
|
||||
if (!validAddresses.empty()) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
int refreshed = 0;
|
||||
|
||||
const int total = static_cast<int>(validAddresses.size());
|
||||
// Clamp cursor to valid range after potential list shrinkage
|
||||
if (m_networkIdRefreshCursor >= total) {
|
||||
m_networkIdRefreshCursor = 0;
|
||||
}
|
||||
|
||||
for (int attempt = 0;
|
||||
attempt < total && refreshed < kNetworkIdRefreshBatchSize;
|
||||
++attempt)
|
||||
{
|
||||
const int idx = (m_networkIdRefreshCursor + attempt) % total;
|
||||
uint64_t addr = validAddresses[static_cast<size_t>(idx)];
|
||||
|
||||
auto it = m_metadataByAddress.find(addr);
|
||||
if (it == m_metadataByAddress.end()) continue;
|
||||
|
||||
IdentityMetadata& meta = it->second;
|
||||
if (now - meta.lastNetworkIdReadTime >= m_networkIdRefreshInterval) {
|
||||
RefreshNetworkId(mem, pid, addr, meta);
|
||||
++refreshed;
|
||||
}
|
||||
}
|
||||
|
||||
m_networkIdRefreshCursor =
|
||||
(m_networkIdRefreshCursor + refreshed) % total;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 8. Build result, deduplicate by NetworkId (or playerName when id==0)
|
||||
// -----------------------------------------------------------------
|
||||
std::vector<DayZScoreboardPlayer> result;
|
||||
result.reserve(validAddresses.size());
|
||||
|
||||
std::unordered_set<uint32_t> seenNetworkIds;
|
||||
std::unordered_set<std::string> seenNames;
|
||||
|
||||
for (uint64_t identityAddr : validAddresses) {
|
||||
auto it = m_metadataByAddress.find(identityAddr);
|
||||
if (it == m_metadataByAddress.end()) continue;
|
||||
|
||||
const IdentityMetadata& meta = it->second;
|
||||
|
||||
if (meta.networkId != 0) {
|
||||
if (!seenNetworkIds.insert(meta.networkId).second) {
|
||||
continue; // duplicate
|
||||
}
|
||||
} else {
|
||||
// No network ID yet — deduplicate by name
|
||||
if (meta.playerName.empty()) continue;
|
||||
if (!seenNames.insert(meta.playerName).second) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
DayZScoreboardPlayer player;
|
||||
player.networkId = meta.networkId;
|
||||
player.steamId = meta.steamId;
|
||||
player.playerName = meta.playerName;
|
||||
result.push_back(std::move(player));
|
||||
}
|
||||
|
||||
// Sort by player name (case-sensitive, matches C# behaviour)
|
||||
std::sort(result.begin(), result.end(),
|
||||
[](const DayZScoreboardPlayer& a, const DayZScoreboardPlayer& b) {
|
||||
return a.playerName < b.playerName;
|
||||
});
|
||||
|
||||
players = std::move(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ClientScoreboardReader::Reset()
|
||||
{
|
||||
m_metadataByAddress.clear();
|
||||
m_cachedResult.clear();
|
||||
m_networkIdRefreshCursor = 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ClientScoreboardReader
|
||||
// Reads the client-side player scoreboard from the network manager.
|
||||
//
|
||||
// Strategy
|
||||
// --------
|
||||
// - Player identity strings (steamId, playerName) are cached by identity
|
||||
// pointer address: they are stable for the lifetime of a session.
|
||||
// - NetworkIds are re-read in a round-robin fashion because the engine
|
||||
// updates them after map-load. Up to 16 IDs are refreshed per call;
|
||||
// the refresh interval is configurable (default 30 s).
|
||||
// - The final result is de-duplicated by NetworkId (or by playerName when
|
||||
// networkId == 0) and sorted by playerName.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class ClientScoreboardReader {
|
||||
public:
|
||||
explicit ClientScoreboardReader(int refreshIntervalMs = 30000);
|
||||
|
||||
/// Populate @p players with the current scoreboard state.
|
||||
/// Returns false only when the fundamental scoreboard reads fail.
|
||||
bool TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZScoreboardPlayer>& players);
|
||||
|
||||
/// Clear all cached identity metadata. Call on session restart.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
// Maximum number of identity slots in the scoreboard array
|
||||
static constexpr int kScoreboardCapacity = 120;
|
||||
static constexpr int kMaxPlayers = 128;
|
||||
// How many stale NetworkIds to refresh per TryRead call
|
||||
static constexpr int kNetworkIdRefreshBatchSize = 16;
|
||||
|
||||
struct IdentityMetadata {
|
||||
std::string steamId;
|
||||
std::string playerName;
|
||||
uint32_t networkId = 0;
|
||||
std::chrono::steady_clock::time_point lastNetworkIdReadTime{};
|
||||
};
|
||||
|
||||
std::unordered_map<uint64_t, IdentityMetadata> m_metadataByAddress;
|
||||
std::vector<DayZScoreboardPlayer> m_cachedResult;
|
||||
int m_networkIdRefreshCursor = 0;
|
||||
std::chrono::milliseconds m_networkIdRefreshInterval;
|
||||
|
||||
/// Read or refresh the NetworkId for a single identity entry.
|
||||
void RefreshNetworkId(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t identityAddr,
|
||||
IdentityMetadata& meta);
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "Readers/EntityCategoryProjector.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string_view>
|
||||
#include <unordered_set>
|
||||
|
||||
// Returns true if the player entity itself looks like an admin-invisible entity.
|
||||
// Currently: entity has no model at all (empty model name = never a real survivor).
|
||||
static bool IsAdminEntityModel(const std::string& modelName)
|
||||
{
|
||||
return modelName.empty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EnumerateEntities
|
||||
// Merges near + far + slow lists, deduplicates by address, and returns a
|
||||
// stable-sorted flat vector.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<EntityCategoryProjector::EntityProjection>
|
||||
EntityCategoryProjector::EnumerateEntities(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow)
|
||||
{
|
||||
std::unordered_set<uint64_t> seen;
|
||||
std::vector<EntityProjection> result;
|
||||
|
||||
// Reserve a reasonable upper bound to avoid repeated reallocations.
|
||||
result.reserve(near.size() + far.size() + slow.size());
|
||||
|
||||
// Lambda: insert an EntityProjection from a near or far entry (has networkId).
|
||||
auto addWithId = [&](uint64_t address, uint32_t networkId,
|
||||
const std::optional<Vector3>& pos,
|
||||
const std::optional<float>& heading,
|
||||
const std::string& entityName,
|
||||
const std::string& typeName,
|
||||
const std::string& configName,
|
||||
const std::string& modelName)
|
||||
{
|
||||
if (address == 0) return;
|
||||
if (!seen.insert(address).second) return; // duplicate
|
||||
|
||||
EntityProjection ep;
|
||||
ep.address = address;
|
||||
ep.networkId = networkId;
|
||||
ep.position = pos;
|
||||
ep.headingDegrees = heading;
|
||||
ep.entityName = entityName;
|
||||
ep.typeName = typeName;
|
||||
ep.configName = configName;
|
||||
ep.modelName = modelName;
|
||||
result.push_back(std::move(ep));
|
||||
};
|
||||
|
||||
for (const auto& e : near) {
|
||||
addWithId(e.address, e.networkId, e.position, e.headingDegrees,
|
||||
e.entityName, e.typeName, e.configName, e.modelName);
|
||||
}
|
||||
|
||||
for (const auto& e : far) {
|
||||
addWithId(e.address, e.networkId, e.position, e.headingDegrees,
|
||||
e.entityName, e.typeName, e.configName, e.modelName);
|
||||
}
|
||||
|
||||
// Slow entries have no networkId.
|
||||
for (const auto& e : slow) {
|
||||
if (e.address == 0) continue;
|
||||
if (!seen.insert(e.address).second) continue;
|
||||
|
||||
EntityProjection ep;
|
||||
ep.address = e.address;
|
||||
ep.networkId = 0;
|
||||
ep.position = e.position;
|
||||
ep.headingDegrees = e.headingDegrees;
|
||||
ep.entityName = e.entityName;
|
||||
ep.typeName = e.typeName;
|
||||
ep.configName = e.configName;
|
||||
ep.modelName = e.modelName;
|
||||
result.push_back(std::move(ep));
|
||||
}
|
||||
|
||||
// Stable sort by (entityName, address) for deterministic output.
|
||||
std::sort(result.begin(), result.end(),
|
||||
[](const EntityProjection& a, const EntityProjection& b) {
|
||||
if (a.entityName != b.entityName) return a.entityName < b.entityName;
|
||||
return a.address < b.address;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IsKnownCategoryType
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool EntityCategoryProjector::IsKnownCategoryType(const std::string& typeName) {
|
||||
return typeName == kTypePlayer ||
|
||||
typeName == kTypeAnimal ||
|
||||
typeName == kTypeZombie ||
|
||||
typeName == kTypeBoat ||
|
||||
typeName == kTypeCar ||
|
||||
typeName == kTypeHeli ||
|
||||
typeName == kTypePlane;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetVehicleKind
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::optional<VehicleKind>
|
||||
EntityCategoryProjector::GetVehicleKind(const std::string& typeName) {
|
||||
if (typeName == kTypeCar) return VehicleKind::Car;
|
||||
if (typeName == kTypeBoat) return VehicleKind::Boat;
|
||||
if (typeName == kTypeHeli) return VehicleKind::Helicopter;
|
||||
if (typeName == kTypePlane)return VehicleKind::Plane;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildPlayers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZPlayerEntry> EntityCategoryProjector::BuildPlayers(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow,
|
||||
const std::unordered_map<uint32_t, std::string>& scoreboardNames,
|
||||
std::function<bool(uint64_t)> deadResolver,
|
||||
std::function<std::string(uint64_t)> heldItemResolver,
|
||||
std::function<float(uint64_t)> healthResolver,
|
||||
std::function<bool(uint64_t)> adminResolver)
|
||||
{
|
||||
auto all = EnumerateEntities(near, far, slow);
|
||||
|
||||
std::vector<DayZPlayerEntry> result;
|
||||
result.reserve(all.size());
|
||||
|
||||
for (const auto& ep : all) {
|
||||
if (ep.typeName != kTypePlayer) continue;
|
||||
|
||||
DayZPlayerEntry entry;
|
||||
entry.address = ep.address;
|
||||
entry.networkId = ep.networkId;
|
||||
entry.position = ep.position;
|
||||
entry.headingDegrees = ep.headingDegrees;
|
||||
entry.typeName = ep.typeName;
|
||||
entry.configName = ep.configName;
|
||||
entry.modelName = ep.modelName;
|
||||
// Entity has no model → almost certainly admin-invisible; otherwise check clothes.
|
||||
entry.isAdmin = IsAdminEntityModel(ep.modelName)
|
||||
|| (adminResolver && adminResolver(ep.address));
|
||||
|
||||
// Nickname from scoreboard (keyed by networkId).
|
||||
if (ep.networkId != 0) {
|
||||
auto it = scoreboardNames.find(ep.networkId);
|
||||
if (it != scoreboardNames.end()) {
|
||||
entry.nickname = it->second;
|
||||
}
|
||||
}
|
||||
|
||||
// Dead state, held item, and health — resolved via caller-provided callbacks.
|
||||
if (deadResolver) entry.isDead = deadResolver(ep.address);
|
||||
if (heldItemResolver)entry.itemInHands = heldItemResolver(ep.address);
|
||||
if (healthResolver) entry.health = healthResolver(ep.address);
|
||||
|
||||
result.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildAnimals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZAnimalEntry> EntityCategoryProjector::BuildAnimals(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow)
|
||||
{
|
||||
auto all = EnumerateEntities(near, far, slow);
|
||||
|
||||
std::vector<DayZAnimalEntry> result;
|
||||
result.reserve(all.size());
|
||||
|
||||
for (const auto& ep : all) {
|
||||
if (ep.typeName != kTypeAnimal) continue;
|
||||
|
||||
DayZAnimalEntry entry;
|
||||
entry.address = ep.address;
|
||||
entry.position = ep.position;
|
||||
entry.headingDegrees = ep.headingDegrees;
|
||||
entry.entityName = ep.entityName;
|
||||
entry.typeName = ep.typeName;
|
||||
entry.configName = ep.configName;
|
||||
entry.modelName = ep.modelName;
|
||||
result.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildZombies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZZombieEntry> EntityCategoryProjector::BuildZombies(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow)
|
||||
{
|
||||
auto all = EnumerateEntities(near, far, slow);
|
||||
|
||||
std::vector<DayZZombieEntry> result;
|
||||
result.reserve(all.size());
|
||||
|
||||
for (const auto& ep : all) {
|
||||
if (ep.typeName != kTypeZombie) continue;
|
||||
|
||||
DayZZombieEntry entry;
|
||||
entry.address = ep.address;
|
||||
entry.position = ep.position;
|
||||
entry.headingDegrees = ep.headingDegrees;
|
||||
entry.entityName = ep.entityName;
|
||||
entry.typeName = ep.typeName;
|
||||
entry.configName = ep.configName;
|
||||
entry.modelName = ep.modelName;
|
||||
result.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildCarsAndBoats
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZCarAndBoatEntry> EntityCategoryProjector::BuildCarsAndBoats(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow)
|
||||
{
|
||||
auto all = EnumerateEntities(near, far, slow);
|
||||
|
||||
std::vector<DayZCarAndBoatEntry> result;
|
||||
result.reserve(all.size());
|
||||
|
||||
for (const auto& ep : all) {
|
||||
auto kind = GetVehicleKind(ep.typeName);
|
||||
if (!kind.has_value()) continue;
|
||||
|
||||
DayZCarAndBoatEntry entry;
|
||||
entry.address = ep.address;
|
||||
entry.position = ep.position;
|
||||
entry.headingDegrees = ep.headingDegrees;
|
||||
entry.entityName = ep.entityName;
|
||||
entry.typeName = ep.typeName;
|
||||
entry.configName = ep.configName;
|
||||
entry.modelName = ep.modelName;
|
||||
entry.kind = *kind;
|
||||
result.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildOtherEntities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZOtherEntityEntry> EntityCategoryProjector::BuildOtherEntities(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow)
|
||||
{
|
||||
auto all = EnumerateEntities(near, far, slow);
|
||||
|
||||
std::vector<DayZOtherEntityEntry> result;
|
||||
result.reserve(all.size());
|
||||
|
||||
for (const auto& ep : all) {
|
||||
// Skip entities that belong to an explicitly-handled category.
|
||||
if (IsKnownCategoryType(ep.typeName)) continue;
|
||||
|
||||
DayZOtherEntityEntry entry;
|
||||
entry.address = ep.address;
|
||||
entry.position = ep.position;
|
||||
entry.headingDegrees = ep.headingDegrees;
|
||||
entry.entityName = ep.entityName;
|
||||
entry.typeName = ep.typeName;
|
||||
entry.configName = ep.configName;
|
||||
entry.modelName = ep.modelName;
|
||||
result.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "Core/Models.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EntityCategoryProjector
|
||||
// Merges the three entity lists (near / far / slow) and projects them into
|
||||
// typed category vectors. All methods are static — no instance state.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class EntityCategoryProjector {
|
||||
public:
|
||||
// ------------------------------------------------------------------
|
||||
// Public projection methods
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static std::vector<DayZPlayerEntry> BuildPlayers(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow,
|
||||
const std::unordered_map<uint32_t, std::string>& scoreboardNames,
|
||||
std::function<bool(uint64_t)> deadResolver,
|
||||
std::function<std::string(uint64_t)> heldItemResolver,
|
||||
std::function<float(uint64_t)> healthResolver,
|
||||
std::function<bool(uint64_t)> adminResolver = nullptr);
|
||||
|
||||
static std::vector<DayZAnimalEntry> BuildAnimals(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow);
|
||||
|
||||
static std::vector<DayZZombieEntry> BuildZombies(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow);
|
||||
|
||||
static std::vector<DayZCarAndBoatEntry> BuildCarsAndBoats(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow);
|
||||
|
||||
static std::vector<DayZOtherEntityEntry> BuildOtherEntities(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow);
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Unified flattened view of one entity from any list
|
||||
// ------------------------------------------------------------------
|
||||
struct EntityProjection {
|
||||
uint64_t address = 0;
|
||||
uint32_t networkId = 0;
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> headingDegrees;
|
||||
std::string entityName;
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Merge all three lists, deduplicate by address, and sort by
|
||||
/// (entityName, address) for deterministic ordering.
|
||||
static std::vector<EntityProjection> EnumerateEntities(
|
||||
const std::vector<DayZNearEntityEntry>& near,
|
||||
const std::vector<DayZFarEntityEntry>& far,
|
||||
const std::vector<DayZSlowEntityEntry>& slow);
|
||||
|
||||
/// Returns true if typeName belongs to one of the explicitly-handled
|
||||
/// categories (player, animal, zombie, vehicle).
|
||||
static bool IsKnownCategoryType(const std::string& typeName);
|
||||
|
||||
/// Returns the VehicleKind for known vehicle type names, or nullopt.
|
||||
static std::optional<VehicleKind> GetVehicleKind(const std::string& typeName);
|
||||
|
||||
// Type-name constants
|
||||
static constexpr const char* kTypePlayer = "dayzplayer";
|
||||
static constexpr const char* kTypeAnimal = "dayzanimal";
|
||||
static constexpr const char* kTypeZombie = "dayzinfected";
|
||||
static constexpr const char* kTypeBoat = "boat";
|
||||
static constexpr const char* kTypeCar = "car";
|
||||
static constexpr const char* kTypeHeli = "helicopter";
|
||||
static constexpr const char* kTypePlane = "plane";
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "Readers/EntityTypeCache.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FormatEntityName
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string FormatEntityName(const std::string& raw)
|
||||
{
|
||||
if (raw.empty()) return raw;
|
||||
|
||||
// Strip known prefixes (case-sensitive — DayZ uses "Dayz_" and "DayZ_")
|
||||
std::string s = raw;
|
||||
for (const char* prefix : { "DayZ_", "Dayz_", "dayz_", "DAYZ_" }) {
|
||||
size_t len = std::strlen(prefix);
|
||||
if (s.size() > len && s.compare(0, len, prefix) == 0) {
|
||||
s = s.substr(len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Split PascalCase: insert space before an uppercase letter that is
|
||||
// preceded by a lowercase letter or digit.
|
||||
std::string result;
|
||||
result.reserve(s.size() + 8);
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
if (i > 0 && std::isupper(c)) {
|
||||
unsigned char prev = static_cast<unsigned char>(s[i - 1]);
|
||||
if (std::islower(prev) || std::isdigit(prev))
|
||||
result += ' ';
|
||||
}
|
||||
result += static_cast<char>(c == '_' ? ' ' : c);
|
||||
}
|
||||
|
||||
// Collapse multiple consecutive spaces (e.g. from "CQB_Rifle" → "CQB Rifle")
|
||||
std::string out;
|
||||
out.reserve(result.size());
|
||||
bool prevSpace = false;
|
||||
for (char c : result) {
|
||||
if (c == ' ') { if (!prevSpace) { out += ' '; prevSpace = true; } }
|
||||
else { out += c; prevSpace = false; }
|
||||
}
|
||||
// Trim leading/trailing space
|
||||
size_t start = out.find_first_not_of(' ');
|
||||
size_t end = out.find_last_not_of(' ');
|
||||
return (start == std::string::npos) ? "" : out.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SanitizeEntityString
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string SanitizeEntityString(const std::string& value)
|
||||
{
|
||||
if (value.empty()) return {};
|
||||
|
||||
// Reject strings containing unwanted control characters
|
||||
for (unsigned char c : value) {
|
||||
if (c < 0x20 && c != '\t' && c != '\r' && c != '\n') {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Count "readable" characters: letters, digits, whitespace, and common
|
||||
// punctuation used in DayZ type names.
|
||||
int readableCount = 0;
|
||||
for (unsigned char c : value) {
|
||||
if (std::isalpha(c) ||
|
||||
std::isdigit(c) ||
|
||||
std::isspace(c) ||
|
||||
c == '.' || c == ',' || c == ':' || c == ';' ||
|
||||
c == '-' || c == '_' || c == '+' || c == '/' ||
|
||||
c == '\\' || c == '(' || c == ')' || c == '[' ||
|
||||
c == ']' || c == '{' || c == '}' || c == '#')
|
||||
{
|
||||
++readableCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Require readableCount * 2 >= length (i.e. >= 50 % readable)
|
||||
if (readableCount * 2 < static_cast<int>(value.size())) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetBestEntityName
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string GetBestEntityName(const std::string& typeName,
|
||||
const std::string& configName,
|
||||
const std::string& cleanName,
|
||||
const std::string& modelName,
|
||||
uint64_t entityAddress)
|
||||
{
|
||||
if (!typeName.empty()) return typeName;
|
||||
if (!configName.empty()) return configName;
|
||||
if (!cleanName.empty()) return cleanName;
|
||||
if (!modelName.empty()) return modelName;
|
||||
|
||||
// Last resort: hex address
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "0x%016llX",
|
||||
static_cast<unsigned long long>(entityAddress));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadEntityTypeString
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string ReadEntityTypeString(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t typeAddress,
|
||||
uint64_t offset)
|
||||
{
|
||||
uint64_t strPtr = 0;
|
||||
if (!mem.TryReadPointer(pid, typeAddress + offset, strPtr)) {
|
||||
return {};
|
||||
}
|
||||
return mem.ReadArmaString(pid, strPtr);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadEntityTypeMetadata
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
EntityTypeMetadata ReadEntityTypeMetadata(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t typeAddress)
|
||||
{
|
||||
EntityTypeMetadata meta;
|
||||
|
||||
meta.typeName = SanitizeEntityString(
|
||||
ReadEntityTypeString(mem, pid, typeAddress, Offsets::EntityType::TypeName));
|
||||
meta.configName = SanitizeEntityString(
|
||||
ReadEntityTypeString(mem, pid, typeAddress, Offsets::EntityType::ConfigName));
|
||||
meta.cleanName = SanitizeEntityString(
|
||||
ReadEntityTypeString(mem, pid, typeAddress, Offsets::EntityType::CleanName));
|
||||
meta.modelName = SanitizeEntityString(
|
||||
ReadEntityTypeString(mem, pid, typeAddress, Offsets::EntityType::ModelName));
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ReadEntityTransform
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool ReadEntityTransform(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t entityAddress,
|
||||
std::optional<Vector3>& position,
|
||||
std::optional<float>& headingDegrees)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// VisualState pointer
|
||||
uint64_t visualStateAddr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
entityAddress + Offsets::Common::VisualState,
|
||||
visualStateAddr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Position
|
||||
Vector3 pos{};
|
||||
if (!mem.TryReadValue<Vector3>(pid,
|
||||
visualStateAddr + Offsets::Common::Position,
|
||||
pos))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MemoryValidation::IsValidVector(pos)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
position = pos;
|
||||
|
||||
// Heading (non-fatal if direction reads fail)
|
||||
float dirX = 0.0f;
|
||||
float dirY = 0.0f;
|
||||
if (mem.TryReadValue<float>(pid, visualStateAddr + Offsets::Common::DirectionX, dirX) &&
|
||||
mem.TryReadValue<float>(pid, visualStateAddr + Offsets::Common::DirectionY, dirY))
|
||||
{
|
||||
float heading = 0.0f;
|
||||
if (MemoryValidation::TryGetCorrectedHeadingFromDirection(dirX, dirY, heading)) {
|
||||
headingDegrees = heading;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <optional>
|
||||
#include <cstdint>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EntityTypeMetadata
|
||||
// All type-level strings read from the entity's type object.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct EntityTypeMetadata {
|
||||
std::string typeName;
|
||||
std::string configName;
|
||||
std::string cleanName;
|
||||
std::string modelName;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EntityTypeCache
|
||||
// In-process cache keyed by type object address.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class EntityTypeCache {
|
||||
std::unordered_map<uint64_t, EntityTypeMetadata> m_cache;
|
||||
public:
|
||||
/// Return true (and populate @p out) if the address is already cached.
|
||||
bool GetOrRead(uint64_t typeAddress, EntityTypeMetadata& out) {
|
||||
auto it = m_cache.find(typeAddress);
|
||||
if (it != m_cache.end()) { out = it->second; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
void Set(uint64_t typeAddress, const EntityTypeMetadata& meta) {
|
||||
m_cache[typeAddress] = meta;
|
||||
}
|
||||
|
||||
void Clear() { m_cache.clear(); }
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Free functions implemented in EntityTypeCache.cpp
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Format a raw PascalCase entity name into a human-readable string.
|
||||
/// Strips prefixes like "Dayz_", splits on capital letter boundaries,
|
||||
/// and replaces underscores with spaces.
|
||||
/// e.g. "KizlyarKnife" → "Kizlyar Knife", "CivilianSedan" → "Civilian Sedan"
|
||||
std::string FormatEntityName(const std::string& raw);
|
||||
|
||||
/// Sanitize an entity type string read from memory.
|
||||
/// Returns empty string if the value contains control characters or the
|
||||
/// fraction of readable characters (letters/digits/whitespace/punctuation) is
|
||||
/// below 50 %.
|
||||
std::string SanitizeEntityString(const std::string& value);
|
||||
|
||||
/// Pick the best display name from the four candidate strings.
|
||||
/// Falls back through typeName -> configName -> cleanName -> modelName, then
|
||||
/// to a hex address string if all are empty.
|
||||
std::string GetBestEntityName(const std::string& typeName,
|
||||
const std::string& configName,
|
||||
const std::string& cleanName,
|
||||
const std::string& modelName,
|
||||
uint64_t entityAddress);
|
||||
|
||||
/// Read a single type string: dereference ptr at typeAddress+offset, then
|
||||
/// call ReadArmaString on the resulting pointer.
|
||||
std::string ReadEntityTypeString(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t typeAddress,
|
||||
uint64_t offset);
|
||||
|
||||
/// Read all four type strings for a type object and return the populated
|
||||
/// EntityTypeMetadata.
|
||||
EntityTypeMetadata ReadEntityTypeMetadata(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t typeAddress);
|
||||
|
||||
/// Read the entity transform (position + heading) from the entity's
|
||||
/// VisualState. Both outputs are optional; if a read or validation step
|
||||
/// fails the corresponding optional remains empty.
|
||||
/// Returns true if at least position was read successfully.
|
||||
bool ReadEntityTransform(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t entityAddress,
|
||||
std::optional<Vector3>& position,
|
||||
std::optional<float>& headingDegrees);
|
||||
@@ -0,0 +1,417 @@
|
||||
#include "Readers/FarEntityListReader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "RuntimeOffsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
FarEntityListReader::FarEntityListReader(int batchSize,
|
||||
int readBudgetMs,
|
||||
int staleAfterMs)
|
||||
: m_batchSize(batchSize)
|
||||
, m_readBudget(readBudgetMs)
|
||||
, m_staleAfter(staleAfterMs)
|
||||
{}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool FarEntityListReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZFarEntityEntry>& entries)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// 1. Far-entity list pointer
|
||||
uint64_t farListPtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::FarEntityList,
|
||||
farListPtr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Table size
|
||||
int32_t rawCount = 0;
|
||||
if (!mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::FarTableSize,
|
||||
rawCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int count = std::clamp(rawCount, 0, kMaxEntities);
|
||||
|
||||
// 3. If cursor has reached the end of the table, complete the current scan
|
||||
if (m_cursorOffset >= count * 8) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
CompleteScan(now);
|
||||
PruneExpiredEntities(now);
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Calculate chunk: how many pointer-sized slots remain vs batch limit
|
||||
const int remaining = count - (m_cursorOffset / 8);
|
||||
const int chunkEntries = std::min(remaining, m_batchSize);
|
||||
|
||||
// 5. Bulk-read chunk of pointers
|
||||
const size_t chunkBytes = static_cast<size_t>(chunkEntries) * 8;
|
||||
std::vector<uint8_t> tableBytes;
|
||||
tableBytes.reserve(chunkBytes);
|
||||
if (!mem.ReadBytes(pid, farListPtr + static_cast<uint64_t>(m_cursorOffset),
|
||||
chunkBytes, tableBytes))
|
||||
{
|
||||
// On failure advance cursor to avoid getting stuck
|
||||
m_cursorOffset += static_cast<int>(chunkBytes);
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. Process entities within the read budget
|
||||
const auto batchStart = std::chrono::steady_clock::now();
|
||||
|
||||
for (int i = 0; i < chunkEntries; ++i) {
|
||||
uint64_t entityAddr = 0;
|
||||
std::memcpy(&entityAddr,
|
||||
tableBytes.data() + static_cast<size_t>(i) * 8,
|
||||
sizeof(uint64_t));
|
||||
|
||||
if (MemoryValidation::IsValidUserAddress(entityAddr)) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
ProcessEntity(mem, session, entityAddr, now);
|
||||
|
||||
// Check time budget after each entity
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - batchStart);
|
||||
if (elapsed >= m_readBudget) {
|
||||
// Advance cursor to account for entities processed so far
|
||||
m_cursorOffset += (i + 1) * 8;
|
||||
PruneExpiredEntities(std::chrono::steady_clock::now());
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Advance cursor by the full chunk
|
||||
m_cursorOffset += chunkEntries * 8;
|
||||
|
||||
// 8. Prune stale entries
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
PruneExpiredEntities(now);
|
||||
|
||||
// 9. If cursor reached end, complete scan
|
||||
if (m_cursorOffset >= count * 8) {
|
||||
CompleteScan(now);
|
||||
}
|
||||
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void FarEntityListReader::Reset()
|
||||
{
|
||||
m_entities.clear();
|
||||
m_typeCache.Clear();
|
||||
m_cachedResult.clear();
|
||||
m_sortedAddresses.clear();
|
||||
m_cursorOffset = 0;
|
||||
m_scanGeneration = 1;
|
||||
m_hasCompletedScan = false;
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ProcessEntity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void FarEntityListReader::ProcessEntity(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t entityAddr,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// Read entity type pointer
|
||||
uint64_t typeAddr = 0;
|
||||
if (!mem.TryReadPointer(pid, entityAddr + Offsets::Common::Type, typeAddr)) {
|
||||
MarkTypeReadFailure(entityAddr, nullptr, now);
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up or read type metadata
|
||||
EntityTypeMetadata typeMeta;
|
||||
if (!m_typeCache.GetOrRead(typeAddr, typeMeta)) {
|
||||
typeMeta = ReadEntityTypeMetadata(mem, pid, typeAddr);
|
||||
if (typeMeta.typeName.empty() && typeMeta.configName.empty() &&
|
||||
typeMeta.cleanName.empty() && typeMeta.modelName.empty())
|
||||
{
|
||||
// Type read produced nothing useful — track failure on cached entity
|
||||
auto it = m_entities.find(entityAddr);
|
||||
MarkTypeReadFailure(entityAddr,
|
||||
(it != m_entities.end()) ? &it->second : nullptr,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
m_typeCache.Set(typeAddr, typeMeta);
|
||||
}
|
||||
|
||||
// Network ID
|
||||
uint32_t networkId = 0;
|
||||
mem.TryReadValue<uint32_t>(pid, entityAddr + Offsets::Network::EntityNetworkId, networkId);
|
||||
|
||||
// Transform — use cached position/heading if the read fails.
|
||||
// Also cache the VS pointer for use in RefreshPositions.
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> heading;
|
||||
|
||||
uint64_t vsAddr = 0;
|
||||
mem.TryReadValue<uint64_t>(pid, entityAddr + Offsets::Common::VisualState, vsAddr);
|
||||
|
||||
bool transformOk = ReadEntityTransform(mem, session, entityAddr, position, heading);
|
||||
|
||||
// Retrieve or create cached entry
|
||||
auto& cached = m_entities[entityAddr];
|
||||
|
||||
if (vsAddr) cached.visualStateAddr = vsAddr;
|
||||
|
||||
if (!transformOk && cached.entry.position.has_value()) {
|
||||
position = cached.entry.position;
|
||||
heading = cached.entry.headingDegrees;
|
||||
}
|
||||
|
||||
// Build display name
|
||||
const std::string entityName = GetBestEntityName(typeMeta.typeName,
|
||||
typeMeta.configName,
|
||||
typeMeta.cleanName,
|
||||
typeMeta.modelName,
|
||||
entityAddr);
|
||||
|
||||
// Update cached entry
|
||||
cached.entry.address = entityAddr;
|
||||
cached.entry.networkId = networkId;
|
||||
cached.entry.position = position;
|
||||
cached.entry.headingDegrees = heading;
|
||||
cached.entry.entityName = entityName;
|
||||
cached.entry.typeName = typeMeta.typeName;
|
||||
cached.entry.configName = typeMeta.configName;
|
||||
cached.entry.modelName = typeMeta.modelName;
|
||||
cached.lastSeenGeneration = m_scanGeneration;
|
||||
cached.failedTypeReads = 0;
|
||||
|
||||
if (transformOk) {
|
||||
cached.lastSuccessfulRead = now;
|
||||
}
|
||||
|
||||
m_resultDirty = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CompleteScan
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void FarEntityListReader::CompleteScan(std::chrono::steady_clock::time_point /*now*/)
|
||||
{
|
||||
// Remove entities not seen in the current scan generation
|
||||
for (auto it = m_entities.begin(); it != m_entities.end(); ) {
|
||||
if (it->second.lastSeenGeneration != m_scanGeneration) {
|
||||
it = m_entities.erase(it);
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
m_cursorOffset = 0;
|
||||
m_hasCompletedScan = true;
|
||||
++m_scanGeneration;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PruneExpiredEntities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void FarEntityListReader::PruneExpiredEntities(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
for (auto it = m_entities.begin(); it != m_entities.end(); ) {
|
||||
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now - it->second.lastSuccessfulRead);
|
||||
if (age > m_staleAfter) {
|
||||
it = m_entities.erase(it);
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildResult
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZFarEntityEntry>& FarEntityListReader::BuildResult()
|
||||
{
|
||||
if (!m_resultDirty) {
|
||||
return m_cachedResult;
|
||||
}
|
||||
|
||||
// Rebuild sorted address list when topology changes
|
||||
if (m_sortDirty) {
|
||||
m_sortedAddresses.clear();
|
||||
m_sortedAddresses.reserve(m_entities.size());
|
||||
for (const auto& kv : m_entities) {
|
||||
m_sortedAddresses.push_back(kv.first);
|
||||
}
|
||||
std::sort(m_sortedAddresses.begin(), m_sortedAddresses.end(),
|
||||
[this](uint64_t a, uint64_t b) {
|
||||
const auto& ea = m_entities.at(a).entry;
|
||||
const auto& eb = m_entities.at(b).entry;
|
||||
if (ea.entityName != eb.entityName)
|
||||
return ea.entityName < eb.entityName;
|
||||
return a < b;
|
||||
});
|
||||
m_sortDirty = false;
|
||||
}
|
||||
|
||||
m_cachedResult.clear();
|
||||
m_cachedResult.reserve(m_sortedAddresses.size());
|
||||
for (uint64_t addr : m_sortedAddresses) {
|
||||
auto it = m_entities.find(addr);
|
||||
if (it != m_entities.end()) {
|
||||
m_cachedResult.push_back(it->second.entry);
|
||||
}
|
||||
}
|
||||
|
||||
m_resultDirty = false;
|
||||
return m_cachedResult;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RefreshPositions
|
||||
// Two-pass scatter read: all VS pointers in pass A, all positions in pass B.
|
||||
// Entities with a stale/unknown VS pointer fall back to a sequential re-read.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void FarEntityListReader::RefreshPositions(VmmAccessor& mem,
|
||||
const RuntimeSession& session)
|
||||
{
|
||||
if (m_entities.empty()) return;
|
||||
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// Collect entities that have a cached VS pointer and those that don't.
|
||||
struct PosSlot {
|
||||
uint64_t entityAddr;
|
||||
uint64_t vsAddr;
|
||||
Vector3 posOut;
|
||||
bool ok = false;
|
||||
};
|
||||
|
||||
std::vector<PosSlot> slots;
|
||||
slots.reserve(m_entities.size());
|
||||
|
||||
// Pass A: scatter-read VS pointers for entities whose cached value is 0.
|
||||
{
|
||||
std::vector<VmmAccessor::ScatterEntry> vsScatter;
|
||||
// We need a stable backing store for the scatter buffers.
|
||||
// Use a parallel vector of uint64_t.
|
||||
std::vector<uint64_t> vsBufs(m_entities.size(), 0);
|
||||
int idx = 0;
|
||||
for (auto& [addr, cached] : m_entities) {
|
||||
PosSlot s;
|
||||
s.entityAddr = addr;
|
||||
s.vsAddr = cached.visualStateAddr;
|
||||
slots.push_back(s);
|
||||
|
||||
if (cached.visualStateAddr == 0) {
|
||||
vsScatter.push_back({ addr + Offsets::Common::VisualState,
|
||||
&vsBufs[idx], sizeof(uint64_t) });
|
||||
}
|
||||
++idx;
|
||||
}
|
||||
|
||||
if (!vsScatter.empty()) {
|
||||
mem.ScatterRead(pid, vsScatter);
|
||||
}
|
||||
|
||||
// Fill cached VS pointers from scatter result.
|
||||
idx = 0;
|
||||
for (auto& [addr, cached] : m_entities) {
|
||||
if (cached.visualStateAddr == 0 && vsBufs[idx] != 0) {
|
||||
cached.visualStateAddr = vsBufs[idx];
|
||||
slots[idx].vsAddr = vsBufs[idx];
|
||||
}
|
||||
++idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass B: scatter-read positions for all slots that have a VS pointer.
|
||||
{
|
||||
std::vector<VmmAccessor::ScatterEntry> posScatter;
|
||||
posScatter.reserve(slots.size());
|
||||
|
||||
for (auto& slot : slots) {
|
||||
if (slot.vsAddr == 0) continue;
|
||||
posScatter.push_back({ slot.vsAddr + Offsets::Common::Position,
|
||||
&slot.posOut, sizeof(Vector3) });
|
||||
}
|
||||
|
||||
if (!posScatter.empty()) {
|
||||
mem.ScatterRead(pid, posScatter);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply results: update position on cached entities.
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
for (auto& slot : slots) {
|
||||
if (slot.vsAddr == 0) continue;
|
||||
if (!MemoryValidation::IsValidVector(slot.posOut)) {
|
||||
// Possibly stale VS pointer — invalidate so pass A re-reads it next time.
|
||||
auto it = m_entities.find(slot.entityAddr);
|
||||
if (it != m_entities.end())
|
||||
it->second.visualStateAddr = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto it = m_entities.find(slot.entityAddr);
|
||||
if (it != m_entities.end()) {
|
||||
it->second.entry.position = slot.posOut;
|
||||
it->second.lastSuccessfulRead = now;
|
||||
m_resultDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MarkTypeReadFailure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void FarEntityListReader::MarkTypeReadFailure(uint64_t entityAddr,
|
||||
CachedEntity* cached,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
if (cached == nullptr) {
|
||||
// Entity not yet in cache — nothing more to do
|
||||
return;
|
||||
}
|
||||
++cached->failedTypeReads;
|
||||
|
||||
// Still mark it seen this generation so it isn't pruned by CompleteScan
|
||||
cached->lastSeenGeneration = m_scanGeneration;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <chrono>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
#include "Readers/EntityTypeCache.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FarEntityListReader
|
||||
// Incremental reader for the world's far-entity pointer table.
|
||||
// Processes entities in time-budgeted batches across calls, maintains a
|
||||
// cached result set, and prunes stale entries by timestamp.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class FarEntityListReader {
|
||||
public:
|
||||
FarEntityListReader(int batchSize = 64,
|
||||
int readBudgetMs = 5,
|
||||
int staleAfterMs = 5000);
|
||||
|
||||
/// Incrementally read far entities into @p entries.
|
||||
/// Returns false only when the table pointer itself cannot be read.
|
||||
bool TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZFarEntityEntry>& entries);
|
||||
|
||||
/// Position-only scatter refresh: re-reads positions for all entities
|
||||
/// already known from previous TryRead calls without advancing the cursor.
|
||||
/// Call this every frame / playersRefreshMs for smooth movement.
|
||||
void RefreshPositions(VmmAccessor& mem, const RuntimeSession& session);
|
||||
|
||||
/// Reset all state. Call when the game session restarts.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Per-entity bookkeeping
|
||||
// ------------------------------------------------------------------
|
||||
struct CachedEntity {
|
||||
DayZFarEntityEntry entry;
|
||||
uint64_t visualStateAddr = 0; // cached for RefreshPositions
|
||||
int lastSeenGeneration = 0;
|
||||
std::chrono::steady_clock::time_point lastSuccessfulRead;
|
||||
int failedTypeReads = 0;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ------------------------------------------------------------------
|
||||
void ProcessEntity(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t entityAddr,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
void CompleteScan(std::chrono::steady_clock::time_point now);
|
||||
|
||||
void PruneExpiredEntities(std::chrono::steady_clock::time_point now);
|
||||
|
||||
std::vector<DayZFarEntityEntry>& BuildResult();
|
||||
|
||||
void MarkTypeReadFailure(uint64_t entityAddr,
|
||||
CachedEntity* cached,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// State
|
||||
// ------------------------------------------------------------------
|
||||
std::unordered_map<uint64_t, CachedEntity> m_entities;
|
||||
EntityTypeCache m_typeCache;
|
||||
std::vector<DayZFarEntityEntry> m_cachedResult;
|
||||
std::vector<uint64_t> m_sortedAddresses;
|
||||
|
||||
int m_cursorOffset = 0;
|
||||
int m_scanGeneration = 1;
|
||||
bool m_hasCompletedScan = false;
|
||||
bool m_sortDirty = true;
|
||||
bool m_resultDirty = true;
|
||||
|
||||
int m_batchSize;
|
||||
std::chrono::milliseconds m_readBudget;
|
||||
std::chrono::milliseconds m_staleAfter;
|
||||
|
||||
static constexpr int kMaxEntities = 4096;
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
#include "Readers/ItemFilterCatalog.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Default config JSON written when item_filters.json is absent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static constexpr const char* kDefaultConfigJson = R"({
|
||||
"isHouse": {
|
||||
"desc": "Structures and storage",
|
||||
"Color": "#06b6d4",
|
||||
"ModelName": [
|
||||
"\\containers\\",
|
||||
"\\camping\\",
|
||||
"\\cooking\\"
|
||||
]
|
||||
},
|
||||
"isWeapon": {"desc": "Weapons", "Color": "#f43f5e", "TypeName": "weapon"},
|
||||
"isAmmo": {"desc": "Ammo", "Color": "#f59e0b", "ModelName": "\\ammunition\\"},
|
||||
"isFood": {"desc": "Food and drinks", "Color": "#22c55e", "ModelName": ["\\food\\", "\\drinks\\"]},
|
||||
"isClothing": {"desc": "Clothing", "Color": "#eab308", "TypeName": "clothing"},
|
||||
"isBackpack": {"desc": "Backpacks", "Color": "#84cc16", "TypeName": "backpack"},
|
||||
"isMedical": {"desc": "Medical supplies", "Color": "#ec4899", "ModelName": "\\medical\\"},
|
||||
"isVehiclePart": {"desc": "Vehicle parts", "Color": "#0ea5e9", "TypeName": "carwheel"},
|
||||
"isTool": {"desc": "Tools", "Color": "#14b8a6", "ModelName": "\\tools\\"},
|
||||
"isCrafting": {"desc": "Crafting materials", "Color": "#c084fc", "ModelName": "\\crafting\\"},
|
||||
"isConsumables": {"desc": "Consumables", "Color": "#f97316", "ModelName": "\\consumables\\"},
|
||||
"isOptics": {"desc": "Optics", "Color": "#d946ef", "TypeName": "itemoptics"},
|
||||
"isMelee": {"desc": "Melee weapons", "Color": "#dc2626", "ModelName": "\\weapons\\melee\\"},
|
||||
"isWeaponAttachments": {"desc": "Weapon attachments", "Color": "#fbbf24", "ModelName": "weapons\\attachments\\"},
|
||||
"isExplosives": {"desc": "Explosives", "Color": "#fb923c", "ModelName": "\\explosives\\"},
|
||||
"isForBuilding": {"desc": "Base building", "Color": "#0891b2", "ModelName": "\\codelock\\"},
|
||||
"isOtherLoot": {"desc": "Other loot", "Color": "#64748b"}
|
||||
})";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers: resolve exe-relative path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static std::string GetExeDir() {
|
||||
char buf[MAX_PATH] = {};
|
||||
GetModuleFileNameA(nullptr, buf, MAX_PATH);
|
||||
std::filesystem::path p(buf);
|
||||
return p.parent_path().string();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
ItemFilterCatalog::ItemFilterCatalog()
|
||||
: m_configPath(GetExeDir() + "/config/item_filters.json")
|
||||
{
|
||||
Load();
|
||||
}
|
||||
|
||||
ItemFilterCatalog::ItemFilterCatalog(const std::string& configPath)
|
||||
: m_configPath(configPath)
|
||||
{
|
||||
Load();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Load
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ItemFilterCatalog::Load() {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Write default config if the file does not exist.
|
||||
if (!fs::exists(m_configPath)) {
|
||||
try {
|
||||
fs::create_directories(fs::path(m_configPath).parent_path());
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[ItemFilterCatalog] Could not create config directory: "
|
||||
<< e.what() << "\n";
|
||||
}
|
||||
WriteDefaultConfig();
|
||||
}
|
||||
|
||||
// Read and parse the JSON file.
|
||||
std::ifstream ifs(m_configPath);
|
||||
if (!ifs.is_open()) {
|
||||
std::cerr << "[ItemFilterCatalog] Failed to open: " << m_configPath << "\n";
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json j;
|
||||
try {
|
||||
ifs >> j;
|
||||
} catch (const nlohmann::json::parse_error& e) {
|
||||
std::cerr << "[ItemFilterCatalog] JSON parse error in " << m_configPath
|
||||
<< ": " << e.what() << "\n";
|
||||
return;
|
||||
}
|
||||
|
||||
m_filters.clear();
|
||||
|
||||
// JSON object preserves insertion order via nlohmann's ordered_json —
|
||||
// but we use the standard json type here, so iterate as-is.
|
||||
for (auto& [key, val] : j.items()) {
|
||||
ItemFilterDefinition def;
|
||||
def.key = key;
|
||||
|
||||
if (val.contains("desc") && val["desc"].is_string()) {
|
||||
def.description = val["desc"].get<std::string>();
|
||||
}
|
||||
|
||||
if (val.contains("Color") && val["Color"].is_string()) {
|
||||
def.color = val["Color"].get<std::string>();
|
||||
}
|
||||
|
||||
def.modelNames = NormalizeAll(ParseStringOrArray(val, "ModelName"));
|
||||
def.configNames = NormalizeAll(ParseStringOrArray(val, "ConfigName"));
|
||||
def.typeNames = NormalizeAll(ParseStringOrArray(val, "TypeName"));
|
||||
def.names = NormalizeAll(ParseStringOrArray(val, "Name"));
|
||||
|
||||
m_filters.push_back(std::move(def));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WriteDefaultConfig
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ItemFilterCatalog::WriteDefaultConfig() const {
|
||||
std::ofstream ofs(m_configPath);
|
||||
if (!ofs.is_open()) {
|
||||
std::cerr << "[ItemFilterCatalog] Could not write default config to: "
|
||||
<< m_configPath << "\n";
|
||||
return;
|
||||
}
|
||||
ofs << kDefaultConfigJson;
|
||||
std::cout << "[ItemFilterCatalog] Wrote default config to: " << m_configPath << "\n";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Classify
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string ItemFilterCatalog::Classify(const std::string& entityName,
|
||||
const std::string& typeName,
|
||||
const std::string& configName,
|
||||
const std::string& modelName) const
|
||||
{
|
||||
const std::string normEntity = Normalize(entityName);
|
||||
const std::string normType = Normalize(typeName);
|
||||
const std::string normConfig = Normalize(configName);
|
||||
const std::string normModel = Normalize(modelName);
|
||||
|
||||
for (const auto& filter : m_filters) {
|
||||
// Skip the fallback entry during forward scanning.
|
||||
if (filter.key == kFallbackKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MatchesAny(filter.modelNames, normModel) ||
|
||||
MatchesAny(filter.configNames, normConfig) ||
|
||||
MatchesAny(filter.typeNames, normType) ||
|
||||
MatchesAny(filter.names, normEntity))
|
||||
{
|
||||
return filter.key;
|
||||
}
|
||||
}
|
||||
|
||||
return kFallbackKey;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MatchesAny
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool ItemFilterCatalog::MatchesAny(const std::vector<std::string>& patterns,
|
||||
const std::string& value)
|
||||
{
|
||||
if (patterns.empty() || value.empty()) return false;
|
||||
|
||||
for (const auto& pattern : patterns) {
|
||||
if (pattern.empty()) continue;
|
||||
// Check if pattern is contained within value (substring match)
|
||||
// or equals value exactly.
|
||||
if (value.find(pattern) != std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ParseStringOrArray
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<std::string> ItemFilterCatalog::ParseStringOrArray(
|
||||
const nlohmann::json& j, const std::string& key)
|
||||
{
|
||||
if (!j.contains(key)) return {};
|
||||
|
||||
const auto& val = j[key];
|
||||
|
||||
if (val.is_string()) {
|
||||
return { val.get<std::string>() };
|
||||
}
|
||||
|
||||
if (val.is_array()) {
|
||||
std::vector<std::string> result;
|
||||
result.reserve(val.size());
|
||||
for (const auto& elem : val) {
|
||||
if (elem.is_string()) {
|
||||
result.push_back(elem.get<std::string>());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Normalize
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string ItemFilterCatalog::Normalize(const std::string& value) {
|
||||
// Trim leading/trailing whitespace, then convert to lowercase.
|
||||
auto start = value.begin();
|
||||
while (start != value.end() && std::isspace(static_cast<unsigned char>(*start))) {
|
||||
++start;
|
||||
}
|
||||
auto end = value.end();
|
||||
while (end != start) {
|
||||
--end;
|
||||
if (!std::isspace(static_cast<unsigned char>(*end))) {
|
||||
++end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::string result(start, end);
|
||||
std::transform(result.begin(), result.end(), result.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NormalizeAll
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<std::string> ItemFilterCatalog::NormalizeAll(
|
||||
const std::vector<std::string>& values)
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
result.reserve(values.size());
|
||||
for (const auto& v : values) {
|
||||
result.push_back(Normalize(v));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
|
||||
// nlohmann/json is included here only for the ParseStringOrArray helper.
|
||||
// All other TUs that include this header will also pull in the JSON library,
|
||||
// which is acceptable since nlohmann/json is header-only and already a project
|
||||
// dependency.
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
struct ItemFilterDefinition {
|
||||
std::string key;
|
||||
std::string description;
|
||||
std::optional<std::string> color;
|
||||
std::vector<std::string> modelNames;
|
||||
std::vector<std::string> configNames;
|
||||
std::vector<std::string> typeNames;
|
||||
std::vector<std::string> names;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ItemFilterCatalog
|
||||
// Loads item-filter definitions from a JSON config file and classifies
|
||||
// entities by matching their name/type/config/model strings against the
|
||||
// patterns defined in each filter.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class ItemFilterCatalog {
|
||||
public:
|
||||
/// Key used when no filter matches.
|
||||
static constexpr const char* kFallbackKey = "isOtherLoot";
|
||||
|
||||
/// Default constructor — resolves config path relative to the executable.
|
||||
ItemFilterCatalog();
|
||||
|
||||
/// Explicit config path constructor.
|
||||
explicit ItemFilterCatalog(const std::string& configPath);
|
||||
|
||||
// Non-copyable, movable
|
||||
ItemFilterCatalog(const ItemFilterCatalog&) = delete;
|
||||
ItemFilterCatalog& operator=(const ItemFilterCatalog&) = delete;
|
||||
ItemFilterCatalog(ItemFilterCatalog&&) = default;
|
||||
ItemFilterCatalog& operator=(ItemFilterCatalog&&) = default;
|
||||
|
||||
/// Classify an entity against the loaded filters.
|
||||
/// Returns the key of the first matching filter, or kFallbackKey.
|
||||
std::string Classify(const std::string& entityName,
|
||||
const std::string& typeName,
|
||||
const std::string& configName,
|
||||
const std::string& modelName) const;
|
||||
|
||||
[[nodiscard]] const std::vector<ItemFilterDefinition>& GetFilters() const {
|
||||
return m_filters;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<ItemFilterDefinition> m_filters;
|
||||
std::string m_configPath;
|
||||
|
||||
void Load();
|
||||
|
||||
/// Returns true if any pattern is a substring of (or equals) value
|
||||
/// after both are normalised to lowercase.
|
||||
static bool MatchesAny(const std::vector<std::string>& patterns,
|
||||
const std::string& value);
|
||||
|
||||
/// Parse a JSON field that may be either a string or an array of strings.
|
||||
/// Returns an empty vector when the key is absent.
|
||||
static std::vector<std::string> ParseStringOrArray(const nlohmann::json& j,
|
||||
const std::string& key);
|
||||
|
||||
/// Trim whitespace and convert to lowercase.
|
||||
static std::string Normalize(const std::string& value);
|
||||
|
||||
/// Apply Normalize to every element of a vector.
|
||||
static std::vector<std::string> NormalizeAll(const std::vector<std::string>& values);
|
||||
|
||||
/// Write a minimal default config to m_configPath.
|
||||
void WriteDefaultConfig() const;
|
||||
};
|
||||
@@ -0,0 +1,379 @@
|
||||
#include "Readers/ItemListReader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "RuntimeOffsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
#include "Readers/ItemFilterCatalog.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
ItemListReader::ItemListReader(ItemFilterCatalog& catalog,
|
||||
int batchSize,
|
||||
int readBudgetMs,
|
||||
int staleAfterMs)
|
||||
: m_filterCatalog(catalog)
|
||||
, m_batchSize(batchSize)
|
||||
, m_readBudget(readBudgetMs)
|
||||
, m_staleAfter(staleAfterMs)
|
||||
{}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool ItemListReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZItemListEntry>& items)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// 1. Item list pointer
|
||||
uint64_t itemListPtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::ItemList,
|
||||
itemListPtr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Alloc count (total slots) and valid count (live items)
|
||||
int32_t rawAllocCount = 0;
|
||||
int32_t rawValidCount = 0;
|
||||
if (!mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::ItemTableAllocCount,
|
||||
rawAllocCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::ItemTableValidCount,
|
||||
rawValidCount);
|
||||
|
||||
const int allocCount = std::clamp(rawAllocCount, 0, kMaxItems);
|
||||
const int safeValidCount = std::clamp(rawValidCount, 0, allocCount);
|
||||
|
||||
// 3. If cursor has reached the end, complete the current scan
|
||||
if (m_cursorOffset >= allocCount) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
CompleteScan(now);
|
||||
PruneExpiredItems(now);
|
||||
items = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Chunk size: max(batchSize, kMinTableChunkEntries), clamped to remaining
|
||||
const int remaining = allocCount - m_cursorOffset;
|
||||
const int chunkEntries = std::min(remaining,
|
||||
std::max(m_batchSize, kMinTableChunkEntries));
|
||||
|
||||
// 5. Bulk-read chunk — each slot is Offsets::SlowTable::EntrySize bytes
|
||||
const size_t entrySize = static_cast<size_t>(Offsets::SlowTable::EntrySize);
|
||||
const size_t chunkBytes = static_cast<size_t>(chunkEntries) * entrySize;
|
||||
const uint64_t chunkAddr = itemListPtr +
|
||||
static_cast<uint64_t>(m_cursorOffset) * entrySize;
|
||||
|
||||
std::vector<uint8_t> tableBytes;
|
||||
tableBytes.reserve(chunkBytes);
|
||||
if (!mem.ReadBytes(pid, chunkAddr, chunkBytes, tableBytes)) {
|
||||
m_cursorOffset += chunkEntries;
|
||||
items = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. Process entries within the read budget
|
||||
const auto batchStart = std::chrono::steady_clock::now();
|
||||
bool budgetExceeded = false;
|
||||
|
||||
for (int i = 0; i < chunkEntries; ++i) {
|
||||
const uint8_t* slot = tableBytes.data() + static_cast<size_t>(i) * entrySize;
|
||||
|
||||
// Flag at offset 0 — uint16_t, valid == 1
|
||||
uint16_t flag = 0;
|
||||
std::memcpy(&flag, slot, sizeof(uint16_t));
|
||||
if (flag != 1) continue;
|
||||
|
||||
// Entity pointer at offset 0x8
|
||||
uint64_t itemAddr = 0;
|
||||
std::memcpy(&itemAddr,
|
||||
slot + static_cast<size_t>(Offsets::SlowTable::EntryPointerOffset),
|
||||
sizeof(uint64_t));
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(itemAddr)) continue;
|
||||
|
||||
++m_validEntriesSeenInScan;
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
ProcessItem(mem, session, itemAddr, now);
|
||||
|
||||
// Stop early when all valid entries have been seen
|
||||
if (safeValidCount > 0 && m_validEntriesSeenInScan >= safeValidCount) {
|
||||
m_cursorOffset += (i + 1);
|
||||
CompleteScan(now);
|
||||
PruneExpiredItems(now);
|
||||
items = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check time budget
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - batchStart);
|
||||
if (elapsed >= m_readBudget) {
|
||||
m_cursorOffset += (i + 1);
|
||||
budgetExceeded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!budgetExceeded) {
|
||||
m_cursorOffset += chunkEntries;
|
||||
}
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
PruneExpiredItems(now);
|
||||
|
||||
if (m_cursorOffset >= allocCount) {
|
||||
CompleteScan(now);
|
||||
}
|
||||
|
||||
items = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ItemListReader::Reset()
|
||||
{
|
||||
m_items.clear();
|
||||
m_typeCache.Clear();
|
||||
m_cachedResult.clear();
|
||||
m_sortedAddresses.clear();
|
||||
m_cursorOffset = 0;
|
||||
m_validEntriesSeenInScan = 0;
|
||||
m_scanGeneration = 1;
|
||||
m_hasCompletedScan = false;
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ProcessItem
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ItemListReader::ProcessItem(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t itemAddr,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// 1. Entity type pointer
|
||||
uint64_t typeAddr = 0;
|
||||
if (!mem.TryReadPointer(pid, itemAddr + Offsets::Common::Type, typeAddr)) {
|
||||
auto it = m_items.find(itemAddr);
|
||||
if (it != m_items.end()) {
|
||||
++it->second.failedTypeReads;
|
||||
it->second.lastSeenGeneration = m_scanGeneration;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Type metadata (cached)
|
||||
EntityTypeMetadata typeMeta;
|
||||
if (!m_typeCache.GetOrRead(typeAddr, typeMeta)) {
|
||||
typeMeta = ReadEntityTypeMetadata(mem, pid, typeAddr);
|
||||
if (typeMeta.typeName.empty() && typeMeta.configName.empty() &&
|
||||
typeMeta.cleanName.empty() && typeMeta.modelName.empty())
|
||||
{
|
||||
auto it = m_items.find(itemAddr);
|
||||
if (it != m_items.end()) {
|
||||
++it->second.failedTypeReads;
|
||||
it->second.lastSeenGeneration = m_scanGeneration;
|
||||
}
|
||||
return;
|
||||
}
|
||||
m_typeCache.Set(typeAddr, typeMeta);
|
||||
}
|
||||
|
||||
// 3. Position: VisualState ptr -> Vector3 at +Position
|
||||
std::optional<Vector3> position;
|
||||
uint64_t visualStateAddr = 0;
|
||||
if (mem.TryReadPointer(pid, itemAddr + Offsets::Common::VisualState, visualStateAddr)) {
|
||||
Vector3 pos{};
|
||||
if (mem.TryReadValue<Vector3>(pid,
|
||||
visualStateAddr + Offsets::Common::Position,
|
||||
pos) &&
|
||||
MemoryValidation::IsValidVector(pos))
|
||||
{
|
||||
position = pos;
|
||||
}
|
||||
}
|
||||
|
||||
// Use cached position if current read failed
|
||||
auto& cached = m_items[itemAddr];
|
||||
if (!position.has_value() && cached.entry.position.has_value()) {
|
||||
position = cached.entry.position;
|
||||
++cached.failedPositionReads;
|
||||
} else if (!position.has_value()) {
|
||||
++cached.failedPositionReads;
|
||||
}
|
||||
|
||||
// 4. Build display name
|
||||
const std::string entityName = GetBestItemName(typeMeta.cleanName,
|
||||
typeMeta.configName,
|
||||
typeMeta.modelName,
|
||||
typeMeta.typeName,
|
||||
itemAddr);
|
||||
|
||||
// 5. Classify via filter catalog
|
||||
const std::string filterKey = m_filterCatalog.Classify(entityName,
|
||||
typeMeta.typeName,
|
||||
typeMeta.configName,
|
||||
typeMeta.modelName);
|
||||
|
||||
// 6. Update cached entry
|
||||
cached.entry.address = itemAddr;
|
||||
cached.entry.position = position;
|
||||
cached.entry.entityName = entityName;
|
||||
cached.entry.cleanName = typeMeta.cleanName;
|
||||
cached.entry.typeName = typeMeta.typeName;
|
||||
cached.entry.configName = typeMeta.configName;
|
||||
cached.entry.modelName = typeMeta.modelName;
|
||||
cached.entry.filterKey = filterKey;
|
||||
|
||||
cached.lastSeenGeneration = m_scanGeneration;
|
||||
cached.lastSeenTime = now;
|
||||
cached.failedTypeReads = 0;
|
||||
|
||||
if (position.has_value()) {
|
||||
cached.lastSuccessfulRead = now;
|
||||
cached.failedPositionReads = 0;
|
||||
}
|
||||
|
||||
m_resultDirty = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CompleteScan
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ItemListReader::CompleteScan(std::chrono::steady_clock::time_point /*now*/)
|
||||
{
|
||||
for (auto it = m_items.begin(); it != m_items.end(); ) {
|
||||
if (it->second.lastSeenGeneration != m_scanGeneration) {
|
||||
it = m_items.erase(it);
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
m_cursorOffset = 0;
|
||||
m_validEntriesSeenInScan = 0;
|
||||
m_hasCompletedScan = true;
|
||||
++m_scanGeneration;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PruneExpiredItems
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void ItemListReader::PruneExpiredItems(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
for (auto it = m_items.begin(); it != m_items.end(); ) {
|
||||
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now - it->second.lastSuccessfulRead);
|
||||
if (age > m_staleAfter) {
|
||||
it = m_items.erase(it);
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildResult
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZItemListEntry>& ItemListReader::BuildResult()
|
||||
{
|
||||
if (!m_resultDirty) {
|
||||
return m_cachedResult;
|
||||
}
|
||||
|
||||
if (m_sortDirty) {
|
||||
m_sortedAddresses.clear();
|
||||
m_sortedAddresses.reserve(m_items.size());
|
||||
for (const auto& kv : m_items) {
|
||||
m_sortedAddresses.push_back(kv.first);
|
||||
}
|
||||
std::sort(m_sortedAddresses.begin(), m_sortedAddresses.end(),
|
||||
[this](uint64_t a, uint64_t b) {
|
||||
const auto& ea = m_items.at(a).entry;
|
||||
const auto& eb = m_items.at(b).entry;
|
||||
if (ea.entityName != eb.entityName)
|
||||
return ea.entityName < eb.entityName;
|
||||
return a < b;
|
||||
});
|
||||
m_sortDirty = false;
|
||||
}
|
||||
|
||||
m_cachedResult.clear();
|
||||
m_cachedResult.reserve(m_sortedAddresses.size());
|
||||
for (uint64_t addr : m_sortedAddresses) {
|
||||
auto it = m_items.find(addr);
|
||||
if (it != m_items.end()) {
|
||||
m_cachedResult.push_back(it->second.entry);
|
||||
}
|
||||
}
|
||||
|
||||
m_resultDirty = false;
|
||||
return m_cachedResult;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetBestItemName
|
||||
// Priority: cleanName > configName > filename-from-modelName > typeName
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string ItemListReader::GetBestItemName(const std::string& cleanName,
|
||||
const std::string& configName,
|
||||
const std::string& modelName,
|
||||
const std::string& typeName,
|
||||
uint64_t itemAddress)
|
||||
{
|
||||
if (!cleanName.empty()) return cleanName;
|
||||
if (!configName.empty()) return configName;
|
||||
|
||||
// Extract filename from model path (strip directory and extension)
|
||||
if (!modelName.empty()) {
|
||||
// Find last slash (forward or back)
|
||||
const auto slashPos = modelName.find_last_of("/\\");
|
||||
std::string filename = (slashPos != std::string::npos)
|
||||
? modelName.substr(slashPos + 1)
|
||||
: modelName;
|
||||
// Strip extension
|
||||
const auto dotPos = filename.rfind('.');
|
||||
if (dotPos != std::string::npos) {
|
||||
filename = filename.substr(0, dotPos);
|
||||
}
|
||||
if (!filename.empty()) return filename;
|
||||
}
|
||||
|
||||
if (!typeName.empty()) return typeName;
|
||||
|
||||
// Last resort: hex address
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "0x%016llX",
|
||||
static_cast<unsigned long long>(itemAddress));
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
#include "Readers/EntityTypeCache.h"
|
||||
|
||||
// Forward declaration — full definition lives in ItemFilterCatalog.h
|
||||
class ItemFilterCatalog;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ItemListReader
|
||||
// Incremental reader for the world's item slow-table.
|
||||
// Uses the same SlowTable wire format (0x18-byte slots, flag at +0x0,
|
||||
// entity pointer at +0x8) as SlowEntityListReader.
|
||||
// Only items whose entity name passes the ItemFilterCatalog are kept.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class ItemListReader {
|
||||
public:
|
||||
explicit ItemListReader(ItemFilterCatalog& catalog,
|
||||
int batchSize = 64,
|
||||
int readBudgetMs = 10,
|
||||
int staleAfterMs = 5000);
|
||||
|
||||
/// Incrementally read items into @p items.
|
||||
/// Returns false only when the table pointer itself cannot be read.
|
||||
bool TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZItemListEntry>& items);
|
||||
|
||||
/// Reset all state. Call when the game session restarts.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Per-item bookkeeping
|
||||
// ------------------------------------------------------------------
|
||||
struct CachedItem {
|
||||
DayZItemListEntry entry;
|
||||
int lastSeenGeneration = 0;
|
||||
std::chrono::steady_clock::time_point lastSeenTime;
|
||||
std::chrono::steady_clock::time_point lastSuccessfulRead;
|
||||
int failedTypeReads = 0;
|
||||
int failedPositionReads = 0;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ------------------------------------------------------------------
|
||||
void ProcessItem(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t itemAddr,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
void CompleteScan(std::chrono::steady_clock::time_point now);
|
||||
|
||||
void PruneExpiredItems(std::chrono::steady_clock::time_point now);
|
||||
|
||||
std::vector<DayZItemListEntry>& BuildResult();
|
||||
|
||||
/// Choose the best display name for an item from its type strings.
|
||||
/// Priority: cleanName > configName > filename-from-modelName > typeName.
|
||||
static std::string GetBestItemName(const std::string& cleanName,
|
||||
const std::string& configName,
|
||||
const std::string& modelName,
|
||||
const std::string& typeName,
|
||||
uint64_t itemAddress);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// State
|
||||
// ------------------------------------------------------------------
|
||||
ItemFilterCatalog& m_filterCatalog;
|
||||
|
||||
std::unordered_map<uint64_t, CachedItem> m_items;
|
||||
EntityTypeCache m_typeCache;
|
||||
std::vector<DayZItemListEntry> m_cachedResult;
|
||||
std::vector<uint64_t> m_sortedAddresses;
|
||||
|
||||
int m_cursorOffset = 0;
|
||||
int m_validEntriesSeenInScan = 0;
|
||||
int m_scanGeneration = 1;
|
||||
bool m_hasCompletedScan = false;
|
||||
bool m_sortDirty = true;
|
||||
bool m_resultDirty = true;
|
||||
|
||||
int m_batchSize;
|
||||
std::chrono::milliseconds m_readBudget;
|
||||
std::chrono::milliseconds m_staleAfter;
|
||||
|
||||
static constexpr int kMinTableChunkEntries = 256;
|
||||
static constexpr int kMaxItems = 4096;
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "Readers/NearEntityListReader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "RuntimeOffsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
bool NearEntityListReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZNearEntityEntry>& entries)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Near-entity list base pointer
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t nearListPtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::NearEntityList,
|
||||
nearListPtr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. Table size
|
||||
// -----------------------------------------------------------------
|
||||
int32_t rawCount = 0;
|
||||
if (!mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + RuntimeOffsets::World::NearTableSize,
|
||||
rawCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int count = std::clamp(rawCount, 0, kMaxEntities);
|
||||
if (count == 0) {
|
||||
entries.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. Bulk-read pointer table (count * 8 bytes)
|
||||
// -----------------------------------------------------------------
|
||||
std::vector<uint8_t> tableBytes;
|
||||
tableBytes.reserve(static_cast<size_t>(count) * 8);
|
||||
if (!mem.ReadBytes(pid, nearListPtr, static_cast<size_t>(count) * 8, tableBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode entity addresses and filter invalid ones.
|
||||
struct EntitySlot {
|
||||
uint64_t entityAddr;
|
||||
int tableIndex;
|
||||
};
|
||||
std::vector<EntitySlot> slots;
|
||||
slots.reserve(static_cast<size_t>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
uint64_t addr = 0;
|
||||
std::memcpy(&addr, tableBytes.data() + static_cast<size_t>(i) * 8, 8);
|
||||
if (MemoryValidation::IsValidUserAddress(addr))
|
||||
slots.push_back({ addr, i });
|
||||
}
|
||||
|
||||
if (slots.empty()) {
|
||||
entries.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Scatter pass A — read Type pointer + VisualState pointer + NetworkId
|
||||
// for all valid entities in one DMA round-trip.
|
||||
// -----------------------------------------------------------------
|
||||
struct PerEntity {
|
||||
uint64_t typeAddr = 0;
|
||||
uint64_t visualStatePtr = 0;
|
||||
uint32_t networkId = 0;
|
||||
uint32_t _pad = 0;
|
||||
};
|
||||
std::vector<PerEntity> perEntity(slots.size());
|
||||
|
||||
{
|
||||
std::vector<VmmAccessor::ScatterEntry> scat;
|
||||
scat.reserve(slots.size() * 3);
|
||||
for (size_t i = 0; i < slots.size(); ++i) {
|
||||
uint64_t base = slots[i].entityAddr;
|
||||
scat.push_back({ base + Offsets::Common::Type,
|
||||
&perEntity[i].typeAddr, sizeof(uint64_t) });
|
||||
scat.push_back({ base + RuntimeOffsets::Common::VisualState,
|
||||
&perEntity[i].visualStatePtr, sizeof(uint64_t) });
|
||||
scat.push_back({ base + Offsets::Network::EntityNetworkId,
|
||||
&perEntity[i].networkId, sizeof(uint32_t) });
|
||||
}
|
||||
mem.ScatterRead(pid, scat);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 5. Scatter pass B — read position (Vector3) from each VisualState
|
||||
// -----------------------------------------------------------------
|
||||
std::vector<Vector3> positions(slots.size(), Vector3{});
|
||||
|
||||
{
|
||||
std::vector<VmmAccessor::ScatterEntry> scat;
|
||||
scat.reserve(slots.size());
|
||||
for (size_t i = 0; i < slots.size(); ++i) {
|
||||
if (!MemoryValidation::IsValidUserAddress(perEntity[i].visualStatePtr))
|
||||
continue;
|
||||
scat.push_back({ perEntity[i].visualStatePtr + Offsets::Common::Position,
|
||||
&positions[i], sizeof(Vector3) });
|
||||
}
|
||||
mem.ScatterRead(pid, scat);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 6. Assemble entries
|
||||
// -----------------------------------------------------------------
|
||||
entries.clear();
|
||||
entries.reserve(slots.size());
|
||||
|
||||
for (size_t i = 0; i < slots.size(); ++i) {
|
||||
if (!MemoryValidation::IsValidUserAddress(perEntity[i].typeAddr))
|
||||
continue;
|
||||
|
||||
EntityTypeMetadata typeMeta;
|
||||
if (!m_typeCache.GetOrRead(perEntity[i].typeAddr, typeMeta)) {
|
||||
typeMeta = ReadEntityTypeMetadata(mem, pid, perEntity[i].typeAddr);
|
||||
m_typeCache.Set(perEntity[i].typeAddr, typeMeta);
|
||||
}
|
||||
|
||||
std::optional<Vector3> position;
|
||||
if (MemoryValidation::IsValidVector(positions[i]))
|
||||
position = positions[i];
|
||||
|
||||
DayZNearEntityEntry entry;
|
||||
entry.address = slots[i].entityAddr;
|
||||
entry.networkId = perEntity[i].networkId;
|
||||
entry.position = position;
|
||||
entry.headingDegrees = std::nullopt;
|
||||
entry.typeName = typeMeta.typeName;
|
||||
entry.configName = typeMeta.configName;
|
||||
entry.modelName = typeMeta.modelName;
|
||||
entry.entityName = GetBestEntityName(typeMeta.typeName,
|
||||
typeMeta.configName,
|
||||
typeMeta.cleanName,
|
||||
typeMeta.modelName,
|
||||
slots[i].entityAddr);
|
||||
entries.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void NearEntityListReader::Reset()
|
||||
{
|
||||
m_typeCache.Clear();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
#include "Readers/EntityTypeCache.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NearEntityListReader
|
||||
// Reads the world's near-entity pointer table and builds a flat list of
|
||||
// DayZNearEntityEntry values. Entity type metadata is cached across calls
|
||||
// via an internal EntityTypeCache.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class NearEntityListReader {
|
||||
public:
|
||||
/// Read the current near-entity list into @p entries.
|
||||
/// Replaces the contents of @p entries on each successful call.
|
||||
/// Returns false only when the initial table reads fail entirely.
|
||||
bool TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZNearEntityEntry>& entries);
|
||||
|
||||
/// Clear cached type metadata. Call when the game session restarts.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
static constexpr int kMaxEntities = 4096;
|
||||
|
||||
EntityTypeCache m_typeCache;
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
#include "Readers/SlowEntityListReader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
SlowEntityListReader::SlowEntityListReader(int batchSize,
|
||||
int readBudgetMs,
|
||||
int staleAfterMs)
|
||||
: m_batchSize(batchSize)
|
||||
, m_readBudget(readBudgetMs)
|
||||
, m_staleAfter(staleAfterMs)
|
||||
{}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TryRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool SlowEntityListReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZSlowEntityEntry>& entries)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// 1. Slow-entity list pointer
|
||||
uint64_t slowListPtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.worldAddress + Offsets::World::SlowEntityList,
|
||||
slowListPtr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Alloc count (total slots) and valid count (live entities)
|
||||
int32_t rawAllocCount = 0;
|
||||
int32_t rawValidCount = 0;
|
||||
if (!mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + Offsets::World::SlowTableAllocCount,
|
||||
rawAllocCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
mem.TryReadValue<int32_t>(pid,
|
||||
session.worldAddress + Offsets::World::SlowTableValidCount,
|
||||
rawValidCount);
|
||||
|
||||
const int allocCount = std::clamp(rawAllocCount, 0, kMaxEntities);
|
||||
const int safeValidCount = std::clamp(rawValidCount, 0, allocCount);
|
||||
|
||||
// 3. If cursor has reached the end of the table, complete the current scan
|
||||
if (m_cursorOffset >= allocCount) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
CompleteScan(now);
|
||||
PruneExpiredEntities(now);
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Chunk size: max(batchSize, kMinTableChunkEntries) entries, clamped to remaining
|
||||
const int remaining = allocCount - m_cursorOffset;
|
||||
const int chunkEntries = std::min(remaining,
|
||||
std::max(m_batchSize, kMinTableChunkEntries));
|
||||
|
||||
// 5. Bulk-read chunk — each slot is Offsets::SlowTable::EntrySize (0x18) bytes
|
||||
const size_t entrySize = static_cast<size_t>(Offsets::SlowTable::EntrySize);
|
||||
const size_t chunkBytes = static_cast<size_t>(chunkEntries) * entrySize;
|
||||
const uint64_t chunkAddr = slowListPtr +
|
||||
static_cast<uint64_t>(m_cursorOffset) * entrySize;
|
||||
|
||||
std::vector<uint8_t> tableBytes;
|
||||
tableBytes.reserve(chunkBytes);
|
||||
if (!mem.ReadBytes(pid, chunkAddr, chunkBytes, tableBytes)) {
|
||||
m_cursorOffset += chunkEntries;
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. Process entries within the read budget
|
||||
const auto batchStart = std::chrono::steady_clock::now();
|
||||
bool budgetExceeded = false;
|
||||
|
||||
for (int i = 0; i < chunkEntries; ++i) {
|
||||
const uint8_t* slot = tableBytes.data() + static_cast<size_t>(i) * entrySize;
|
||||
|
||||
// Flag at offset 0 — uint16_t, valid == 1
|
||||
uint16_t flag = 0;
|
||||
std::memcpy(&flag, slot, sizeof(uint16_t));
|
||||
if (flag != 1) continue;
|
||||
|
||||
// Entity pointer at offset 0x8
|
||||
uint64_t entityAddr = 0;
|
||||
std::memcpy(&entityAddr,
|
||||
slot + static_cast<size_t>(Offsets::SlowTable::EntryPointerOffset),
|
||||
sizeof(uint64_t));
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(entityAddr)) continue;
|
||||
|
||||
++m_validEntriesSeenInScan;
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
ProcessEntity(mem, session, entityAddr, now);
|
||||
|
||||
// Stop early when we have seen all valid entries
|
||||
if (safeValidCount > 0 && m_validEntriesSeenInScan >= safeValidCount) {
|
||||
m_cursorOffset += (i + 1);
|
||||
CompleteScan(now);
|
||||
PruneExpiredEntities(now);
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check time budget
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - batchStart);
|
||||
if (elapsed >= m_readBudget) {
|
||||
m_cursorOffset += (i + 1);
|
||||
budgetExceeded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!budgetExceeded) {
|
||||
m_cursorOffset += chunkEntries;
|
||||
}
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
PruneExpiredEntities(now);
|
||||
|
||||
if (m_cursorOffset >= allocCount) {
|
||||
CompleteScan(now);
|
||||
}
|
||||
|
||||
entries = BuildResult();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reset
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void SlowEntityListReader::Reset()
|
||||
{
|
||||
m_entities.clear();
|
||||
m_typeCache.Clear();
|
||||
m_cachedResult.clear();
|
||||
m_sortedAddresses.clear();
|
||||
m_cursorOffset = 0;
|
||||
m_validEntriesSeenInScan = 0;
|
||||
m_scanGeneration = 1;
|
||||
m_hasCompletedScan = false;
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ProcessEntity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void SlowEntityListReader::ProcessEntity(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t entityAddr,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// Read entity type pointer
|
||||
uint64_t typeAddr = 0;
|
||||
if (!mem.TryReadPointer(pid, entityAddr + Offsets::Common::Type, typeAddr)) {
|
||||
auto it = m_entities.find(entityAddr);
|
||||
MarkTypeReadFailure(entityAddr,
|
||||
(it != m_entities.end()) ? &it->second : nullptr,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up or read type metadata
|
||||
EntityTypeMetadata typeMeta;
|
||||
if (!m_typeCache.GetOrRead(typeAddr, typeMeta)) {
|
||||
typeMeta = ReadEntityTypeMetadata(mem, pid, typeAddr);
|
||||
if (typeMeta.typeName.empty() && typeMeta.configName.empty() &&
|
||||
typeMeta.cleanName.empty() && typeMeta.modelName.empty())
|
||||
{
|
||||
auto it = m_entities.find(entityAddr);
|
||||
MarkTypeReadFailure(entityAddr,
|
||||
(it != m_entities.end()) ? &it->second : nullptr,
|
||||
now);
|
||||
return;
|
||||
}
|
||||
m_typeCache.Set(typeAddr, typeMeta);
|
||||
}
|
||||
|
||||
// Skip entities with no model — slow list contains many invisible objects
|
||||
if (typeMeta.modelName.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform
|
||||
std::optional<Vector3> position;
|
||||
std::optional<float> heading;
|
||||
bool transformOk = ReadEntityTransform(mem, session, entityAddr, position, heading);
|
||||
|
||||
// Retrieve or create cached entry
|
||||
auto& cached = m_entities[entityAddr];
|
||||
|
||||
if (!transformOk && cached.entry.position.has_value()) {
|
||||
position = cached.entry.position;
|
||||
heading = cached.entry.headingDegrees;
|
||||
}
|
||||
|
||||
// Build display name: configName first, then cleanName, typeName, modelName
|
||||
const std::string entityName = GetBestEntityName(typeMeta.configName,
|
||||
typeMeta.cleanName,
|
||||
typeMeta.typeName,
|
||||
typeMeta.modelName,
|
||||
entityAddr);
|
||||
|
||||
// Update cached entry
|
||||
cached.entry.address = entityAddr;
|
||||
cached.entry.position = position;
|
||||
cached.entry.headingDegrees = heading;
|
||||
cached.entry.entityName = entityName;
|
||||
cached.entry.typeName = typeMeta.typeName;
|
||||
cached.entry.configName = typeMeta.configName;
|
||||
cached.entry.modelName = typeMeta.modelName;
|
||||
cached.lastSeenGeneration = m_scanGeneration;
|
||||
cached.failedTypeReads = 0;
|
||||
|
||||
if (transformOk) {
|
||||
cached.lastSuccessfulRead = now;
|
||||
}
|
||||
|
||||
m_resultDirty = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CompleteScan
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void SlowEntityListReader::CompleteScan(std::chrono::steady_clock::time_point /*now*/)
|
||||
{
|
||||
// Remove entities not seen in the current scan generation
|
||||
for (auto it = m_entities.begin(); it != m_entities.end(); ) {
|
||||
if (it->second.lastSeenGeneration != m_scanGeneration) {
|
||||
it = m_entities.erase(it);
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
m_cursorOffset = 0;
|
||||
m_validEntriesSeenInScan = 0;
|
||||
m_hasCompletedScan = true;
|
||||
++m_scanGeneration;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PruneExpiredEntities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void SlowEntityListReader::PruneExpiredEntities(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
for (auto it = m_entities.begin(); it != m_entities.end(); ) {
|
||||
const auto age = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now - it->second.lastSuccessfulRead);
|
||||
if (age > m_staleAfter) {
|
||||
it = m_entities.erase(it);
|
||||
m_sortDirty = true;
|
||||
m_resultDirty = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BuildResult
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<DayZSlowEntityEntry>& SlowEntityListReader::BuildResult()
|
||||
{
|
||||
if (!m_resultDirty) {
|
||||
return m_cachedResult;
|
||||
}
|
||||
|
||||
if (m_sortDirty) {
|
||||
m_sortedAddresses.clear();
|
||||
m_sortedAddresses.reserve(m_entities.size());
|
||||
for (const auto& kv : m_entities) {
|
||||
m_sortedAddresses.push_back(kv.first);
|
||||
}
|
||||
std::sort(m_sortedAddresses.begin(), m_sortedAddresses.end(),
|
||||
[this](uint64_t a, uint64_t b) {
|
||||
const auto& ea = m_entities.at(a).entry;
|
||||
const auto& eb = m_entities.at(b).entry;
|
||||
if (ea.entityName != eb.entityName)
|
||||
return ea.entityName < eb.entityName;
|
||||
return a < b;
|
||||
});
|
||||
m_sortDirty = false;
|
||||
}
|
||||
|
||||
m_cachedResult.clear();
|
||||
m_cachedResult.reserve(m_sortedAddresses.size());
|
||||
for (uint64_t addr : m_sortedAddresses) {
|
||||
auto it = m_entities.find(addr);
|
||||
if (it != m_entities.end()) {
|
||||
m_cachedResult.push_back(it->second.entry);
|
||||
}
|
||||
}
|
||||
|
||||
m_resultDirty = false;
|
||||
return m_cachedResult;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MarkTypeReadFailure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void SlowEntityListReader::MarkTypeReadFailure(uint64_t /*entityAddr*/,
|
||||
CachedEntity* cached,
|
||||
std::chrono::steady_clock::time_point /*now*/)
|
||||
{
|
||||
if (cached == nullptr) return;
|
||||
|
||||
++cached->failedTypeReads;
|
||||
// Still mark it seen this generation so it isn't pruned by CompleteScan
|
||||
cached->lastSeenGeneration = m_scanGeneration;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <chrono>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
#include "Readers/EntityTypeCache.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SlowEntityListReader
|
||||
// Incremental reader for the world's slow-entity table.
|
||||
// Entries use the SlowTable format: 0x18-byte slots, uint16_t flag at +0x0
|
||||
// (valid == 1), entity pointer at +0x8.
|
||||
// Processing stops early when validEntriesSeenInScan reaches safeValidCount.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class SlowEntityListReader {
|
||||
public:
|
||||
SlowEntityListReader(int batchSize = 64,
|
||||
int readBudgetMs = 5,
|
||||
int staleAfterMs = 5000);
|
||||
|
||||
/// Incrementally read slow entities into @p entries.
|
||||
/// Returns false only when the table pointer itself cannot be read.
|
||||
bool TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
std::vector<DayZSlowEntityEntry>& entries);
|
||||
|
||||
/// Reset all state. Call when the game session restarts.
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Per-entity bookkeeping
|
||||
// ------------------------------------------------------------------
|
||||
struct CachedEntity {
|
||||
DayZSlowEntityEntry entry;
|
||||
int lastSeenGeneration = 0;
|
||||
std::chrono::steady_clock::time_point lastSuccessfulRead;
|
||||
int failedTypeReads = 0;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ------------------------------------------------------------------
|
||||
void ProcessEntity(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
uint64_t entityAddr,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
void CompleteScan(std::chrono::steady_clock::time_point now);
|
||||
|
||||
void PruneExpiredEntities(std::chrono::steady_clock::time_point now);
|
||||
|
||||
std::vector<DayZSlowEntityEntry>& BuildResult();
|
||||
|
||||
void MarkTypeReadFailure(uint64_t entityAddr,
|
||||
CachedEntity* cached,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// State
|
||||
// ------------------------------------------------------------------
|
||||
std::unordered_map<uint64_t, CachedEntity> m_entities;
|
||||
EntityTypeCache m_typeCache;
|
||||
std::vector<DayZSlowEntityEntry> m_cachedResult;
|
||||
std::vector<uint64_t> m_sortedAddresses;
|
||||
|
||||
int m_cursorOffset = 0;
|
||||
int m_validEntriesSeenInScan = 0;
|
||||
int m_scanGeneration = 1;
|
||||
bool m_hasCompletedScan = false;
|
||||
bool m_sortDirty = true;
|
||||
bool m_resultDirty = true;
|
||||
|
||||
int m_batchSize;
|
||||
std::chrono::milliseconds m_readBudget;
|
||||
std::chrono::milliseconds m_staleAfter;
|
||||
|
||||
// Minimum number of table entries to read per chunk (ensures we always
|
||||
// make meaningful progress even with a small batchSize).
|
||||
static constexpr int kMinTableChunkEntries = 256;
|
||||
static constexpr int kMaxEntities = 4096;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "Resolvers/BaseObjectResolver.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "RuntimeOffsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
bool BaseObjectResolver::TryResolve(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t gameBase,
|
||||
RuntimeSession& session,
|
||||
NetworkMetadata& metadata,
|
||||
LocalPlayerData& player)
|
||||
{
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Validate the game base address
|
||||
// -----------------------------------------------------------------
|
||||
if (!MemoryValidation::IsValidUserAddress(gameBase)) {
|
||||
m_lastFailureReason = "gameBase is not a valid user address";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. World pointer (indirect read)
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t worldAddress = 0;
|
||||
if (!mem.TryReadPointer(pid, gameBase + RuntimeOffsets::Base::World, worldAddress)) {
|
||||
m_lastFailureReason = "Failed to read World pointer from gameBase";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. NetworkManager address (direct offset, NOT a pointer read)
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t networkManagerAddress = gameBase + RuntimeOffsets::Base::NetworkManager;
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(networkManagerAddress)) {
|
||||
m_lastFailureReason = "Computed networkManagerAddress is not a valid user address";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Build session
|
||||
// -----------------------------------------------------------------
|
||||
session = RuntimeSession{
|
||||
.processId = pid,
|
||||
.gameBaseAddress = gameBase,
|
||||
.worldAddress = worldAddress,
|
||||
.networkManagerAddress = networkManagerAddress,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 5. Network metadata (non-fatal — null NetworkClient is normal while
|
||||
// the game is loading or in the main menu; the live loop retries it)
|
||||
// -----------------------------------------------------------------
|
||||
if (!m_networkMetadataReader.TryRead(mem, session, metadata)) {
|
||||
spdlog::debug("BaseObjectResolver: NetworkMetadata unavailable ({}); proceeding.",
|
||||
m_networkMetadataReader.LastFailureReason());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 6. Local player — required to confirm the player is in-game
|
||||
// -----------------------------------------------------------------
|
||||
if (!m_localPlayerResolver.TryResolve(mem, session, player)) {
|
||||
m_lastFailureReason = "LocalPlayerResolver failed: "
|
||||
+ m_localPlayerResolver.LastFailureReason();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_lastFailureReason.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BaseObjectResolver::IsStillValid(VmmAccessor& mem, const RuntimeSession& session)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// Re-read world pointer and compare
|
||||
uint64_t currentWorld = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.gameBaseAddress + RuntimeOffsets::Base::World,
|
||||
currentWorld))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentWorld != session.worldAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-compute network manager and compare
|
||||
uint64_t currentNetworkManager = session.gameBaseAddress + RuntimeOffsets::Base::NetworkManager;
|
||||
if (currentNetworkManager != session.networkManagerAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
#include "Resolvers/LocalPlayerResolver.h"
|
||||
#include "Resolvers/NetworkMetadataReader.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BaseObjectResolver
|
||||
// Entry-point resolver that:
|
||||
// - validates the game base address
|
||||
// - resolves worldAddress and networkManagerAddress
|
||||
// - constructs a RuntimeSession
|
||||
// - delegates to NetworkMetadataReader and LocalPlayerResolver
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class BaseObjectResolver {
|
||||
public:
|
||||
/// Build a fresh RuntimeSession and populate metadata and player data.
|
||||
/// Returns true only when all sub-resolvers succeed.
|
||||
bool TryResolve(VmmAccessor& mem,
|
||||
uint32_t pid,
|
||||
uint64_t gameBase,
|
||||
RuntimeSession& session,
|
||||
NetworkMetadata& metadata,
|
||||
LocalPlayerData& player);
|
||||
|
||||
/// Validate that the currently-cached session is still coherent
|
||||
/// (world pointer and network manager address have not changed).
|
||||
bool IsStillValid(VmmAccessor& mem, const RuntimeSession& session);
|
||||
|
||||
[[nodiscard]] const std::string& LastFailureReason() const noexcept {
|
||||
return m_lastFailureReason;
|
||||
}
|
||||
|
||||
private:
|
||||
NetworkMetadataReader m_networkMetadataReader;
|
||||
LocalPlayerResolver m_localPlayerResolver;
|
||||
std::string m_lastFailureReason;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "Resolvers/LocalPlayerResolver.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
bool LocalPlayerResolver::TryResolve(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
LocalPlayerData& player)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Read the local player pointer from the world object
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t localPlayerPointer = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
session.worldAddress + Offsets::World::LocalPlayer,
|
||||
localPlayerPointer))
|
||||
{
|
||||
m_lastFailureReason = "Failed to read LocalPlayer pointer from world";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. Second hop — the actual entity sits 0xA8 bytes before the
|
||||
// pointed-to structure.
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t secondHop = 0;
|
||||
if (!mem.TryReadValue<uint64_t>(pid, localPlayerPointer + 0x8, secondHop)) {
|
||||
m_lastFailureReason = "Failed to read secondHop from LocalPlayer";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (secondHop <= 0xA8) {
|
||||
m_lastFailureReason = "secondHop value too small";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t chainedEntity = secondHop - 0xA8;
|
||||
|
||||
if (!MemoryValidation::IsValidUserAddress(chainedEntity)) {
|
||||
m_lastFailureReason = "chainedEntity address is not a valid user address";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. VisualState pointer
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t visualStateAddr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
chainedEntity + Offsets::Common::VisualState,
|
||||
visualStateAddr))
|
||||
{
|
||||
m_lastFailureReason = "Failed to read VisualState pointer";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Position
|
||||
// -----------------------------------------------------------------
|
||||
Vector3 pos{};
|
||||
if (!mem.TryReadValue<Vector3>(pid,
|
||||
visualStateAddr + Offsets::Common::Position,
|
||||
pos))
|
||||
{
|
||||
m_lastFailureReason = "Failed to read position from VisualState";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MemoryValidation::IsValidVector(pos)) {
|
||||
m_lastFailureReason = "Position vector is not valid";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 5. Heading from direction vector
|
||||
// -----------------------------------------------------------------
|
||||
float dirX = 0.0f;
|
||||
float dirY = 0.0f;
|
||||
mem.TryReadValue<float>(pid, visualStateAddr + Offsets::Common::DirectionX, dirX);
|
||||
mem.TryReadValue<float>(pid, visualStateAddr + Offsets::Common::DirectionY, dirY);
|
||||
|
||||
float heading = 0.0f;
|
||||
MemoryValidation::TryGetCorrectedHeadingFromDirection(dirX, dirY, heading);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 6. Commit
|
||||
// -----------------------------------------------------------------
|
||||
player = LocalPlayerData{
|
||||
.entityAddress = chainedEntity,
|
||||
.visualStateAddress = visualStateAddr,
|
||||
.position = pos,
|
||||
.lookDirection = heading,
|
||||
};
|
||||
|
||||
m_lastFailureReason.clear();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LocalPlayerResolver
|
||||
// Resolves the local player entity from the world's LocalPlayer pointer
|
||||
// and reads its transform (position + heading).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class LocalPlayerResolver {
|
||||
public:
|
||||
/// Attempt to resolve and populate @p player.
|
||||
/// Returns true only when all required reads succeed and the position
|
||||
/// passes the IsValidVector check.
|
||||
bool TryResolve(VmmAccessor& mem, const RuntimeSession& session, LocalPlayerData& player);
|
||||
|
||||
[[nodiscard]] const std::string& LastFailureReason() const noexcept {
|
||||
return m_lastFailureReason;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string m_lastFailureReason;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
#include "Resolvers/NetworkMetadataReader.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Memory/MemoryValidation.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
bool NetworkMetadataReader::TryRead(VmmAccessor& mem,
|
||||
const RuntimeSession& session,
|
||||
NetworkMetadata& meta)
|
||||
{
|
||||
const uint32_t pid = session.processId;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. NetworkClient pointer
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t probeAddr = session.networkManagerAddress + Offsets::Network::ManagerNetworkClient;
|
||||
spdlog::trace("NetworkMetadataReader: reading NetworkClient ptr at 0x{:X} "
|
||||
"(networkManager=0x{:X} + 0x{:X})",
|
||||
probeAddr,
|
||||
session.networkManagerAddress,
|
||||
Offsets::Network::ManagerNetworkClient);
|
||||
|
||||
uint64_t networkClientAddr = 0;
|
||||
if (!mem.TryReadPointer(pid, probeAddr, networkClientAddr))
|
||||
{
|
||||
// Read the raw 8 bytes to see what value was there (may be 0 or an
|
||||
// out-of-range pointer that failed IsValidUserAddress).
|
||||
std::vector<uint8_t> raw;
|
||||
if (mem.ReadBytes(pid, probeAddr, sizeof(uint64_t), raw) && raw.size() == 8) {
|
||||
uint64_t rawVal = 0;
|
||||
std::memcpy(&rawVal, raw.data(), 8);
|
||||
spdlog::debug("NetworkMetadataReader: NetworkClient ptr invalid "
|
||||
"(raw=0x{:X}) at 0x{:X}", rawVal, probeAddr);
|
||||
} else {
|
||||
spdlog::debug("NetworkMetadataReader: NetworkClient ptr unreadable at 0x{:X}",
|
||||
probeAddr);
|
||||
}
|
||||
m_lastFailureReason = "Failed to read NetworkClient pointer";
|
||||
return false;
|
||||
}
|
||||
spdlog::debug("NetworkMetadataReader: NetworkClient=0x{:X}", networkClientAddr);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. Server name (ptr at networkClient + offset, then ArmaString)
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t serverNamePtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
networkClientAddr + Offsets::Network::ServerName,
|
||||
serverNamePtr))
|
||||
{
|
||||
m_lastFailureReason = "Failed to read ServerName pointer";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string serverName = mem.ReadArmaString(pid, serverNamePtr);
|
||||
if (!MemoryValidation::IsUsableString(serverName)) {
|
||||
m_lastFailureReason = "ServerName is not a usable string";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. Game version (ptr at networkClient + offset, then ArmaString)
|
||||
// -----------------------------------------------------------------
|
||||
uint64_t gameVersionPtr = 0;
|
||||
if (!mem.TryReadPointer(pid,
|
||||
networkClientAddr + Offsets::Network::GameVersion,
|
||||
gameVersionPtr))
|
||||
{
|
||||
m_lastFailureReason = "Failed to read GameVersion pointer";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string gameVersion = mem.ReadArmaString(pid, gameVersionPtr);
|
||||
if (!MemoryValidation::IsUsableString(gameVersion)) {
|
||||
m_lastFailureReason = "GameVersion is not a usable string";
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Map name (optional — failure is non-fatal)
|
||||
// -----------------------------------------------------------------
|
||||
std::optional<std::string> mapName;
|
||||
{
|
||||
uint64_t mapNamePtr = 0;
|
||||
if (mem.TryReadPointer(pid,
|
||||
networkClientAddr + Offsets::Network::MapName,
|
||||
mapNamePtr))
|
||||
{
|
||||
std::string raw = mem.ReadArmaString(pid, mapNamePtr);
|
||||
if (MemoryValidation::IsUsableString(raw)) {
|
||||
mapName = std::move(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 5. Commit
|
||||
// -----------------------------------------------------------------
|
||||
meta = NetworkMetadata{
|
||||
.serverName = std::move(serverName),
|
||||
.gameVersion = std::move(gameVersion),
|
||||
.serverMapName = std::move(mapName),
|
||||
};
|
||||
|
||||
m_lastFailureReason.clear();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NetworkMetadataReader
|
||||
// Reads server name, game version, and map name from the game's network
|
||||
// manager structures.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class NetworkMetadataReader {
|
||||
public:
|
||||
/// Attempt to read network metadata into @p meta.
|
||||
/// Returns true only when serverName and gameVersion are both usable.
|
||||
bool TryRead(VmmAccessor& mem, const RuntimeSession& session, NetworkMetadata& meta);
|
||||
|
||||
[[nodiscard]] const std::string& LastFailureReason() const noexcept {
|
||||
return m_lastFailureReason;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string m_lastFailureReason;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "Core/Models.h"
|
||||
#include "Core/RuntimeSession.h"
|
||||
#include "Memory/VmmAccessor.h"
|
||||
#include "Readers/BulletTableReader.h"
|
||||
#include "Readers/ClientScoreboardReader.h"
|
||||
#include "Readers/EntityCategoryProjector.h"
|
||||
#include "Readers/FarEntityListReader.h"
|
||||
#include "Readers/ItemFilterCatalog.h"
|
||||
#include "Readers/ItemListReader.h"
|
||||
#include "Readers/NearEntityListReader.h"
|
||||
#include "Readers/SlowEntityListReader.h"
|
||||
#include "Resolvers/BaseObjectResolver.h"
|
||||
#include "Resolvers/LocalPlayerResolver.h"
|
||||
#include "Resolvers/NetworkMetadataReader.h"
|
||||
#include "SigScanner/SigScanner.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RuntimeUpdate
|
||||
// Snapshot of all reader state delivered to the callback on each cycle.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct RuntimeUpdate {
|
||||
bool areBaseObjectsReady = false;
|
||||
std::string status;
|
||||
|
||||
std::optional<std::string> serverName;
|
||||
std::optional<std::string> gameVersion;
|
||||
std::optional<std::string> serverMapName;
|
||||
|
||||
std::optional<uint64_t> localPlayerEntityAddress;
|
||||
std::optional<Vector3> localPlayerPosition;
|
||||
std::optional<float> localPlayerLookDirection;
|
||||
|
||||
std::vector<DayZScoreboardPlayer> scoreboardPlayers;
|
||||
std::vector<DayZPlayerEntry> players;
|
||||
std::vector<DayZAnimalEntry> animals;
|
||||
std::vector<DayZZombieEntry> zombies;
|
||||
std::vector<DayZCarAndBoatEntry> carsAndBoats;
|
||||
std::vector<DayZOtherEntityEntry> otherEntities;
|
||||
std::vector<DayZBulletEntry> bullets;
|
||||
std::vector<DayZItemListEntry> items;
|
||||
std::vector<DayZNearEntityEntry> nearEntities;
|
||||
std::vector<DayZFarEntityEntry> farEntities;
|
||||
std::vector<DayZSlowEntityEntry> slowEntities;
|
||||
|
||||
std::optional<CameraData> camera;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RuntimeConfig
|
||||
// Tunable parameters for the service.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct RuntimeConfig {
|
||||
std::string processName = "DayZ_x64.exe";
|
||||
bool useMemoryMap = true; // passed to VolkDMA; auto-generates memory_map.txt
|
||||
|
||||
int networkMetadataRefreshMs = 30000;
|
||||
int scoreboardRefreshMs = 30000;
|
||||
int nearEntityRefreshMs = 20; // was 50 — 50 Hz keeps boxes smooth in combat
|
||||
int farEntityRefreshMs = 50; // was 100
|
||||
int slowEntityRefreshMs = 100;
|
||||
int bulletRefreshMs = 50;
|
||||
int itemRefreshMs = 200;
|
||||
int playersRefreshMs = 16; // was 50 — matches bone-scatter cadence (60 Hz rebuild)
|
||||
int liveLoopIdleMs = 1;
|
||||
int reconnectThreshold = 5;
|
||||
int reconnectGracePeriodMs = 5000;
|
||||
int farEntityBatchSize = 64;
|
||||
int slowEntityBatchSize = 64;
|
||||
int itemBatchSize = 64;
|
||||
int boneRefreshMs = 4; // bone-scatter cadence — 4 ms = 250 Hz
|
||||
float bonePlayerMaxDist = 500.0f; // players beyond this skip bone reads
|
||||
float boneZombieMaxDist = 250.0f; // zombies beyond this skip bone reads
|
||||
int vmmRefreshMs = 30000; // VMMDLL_OPT_REFRESH_ALL cadence — must be called
|
||||
// periodically during the live loop or the page cache
|
||||
// goes stale and reads silently return garbage (~1 h)
|
||||
float itemSpatialFilterRadius = 300.0f; // items beyond this distance from the local player are
|
||||
// excluded from the published snapshot entirely
|
||||
|
||||
/// Load from an INI-style config.cfg (key = value, # comments).
|
||||
/// Missing keys keep their default values.
|
||||
static RuntimeConfig Load(const std::string& path);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DayZRuntimeService
|
||||
// Background service that attaches to DayZ, reads all entity/bullet/item
|
||||
// data in a throttled loop, and delivers snapshots via a callback.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class DayZRuntimeService {
|
||||
public:
|
||||
using UpdateCallback = std::function<void(const RuntimeUpdate&)>;
|
||||
|
||||
explicit DayZRuntimeService(RuntimeConfig config = {});
|
||||
~DayZRuntimeService();
|
||||
|
||||
// Non-copyable, non-movable (owns threads + mutexes)
|
||||
DayZRuntimeService(const DayZRuntimeService&) = delete;
|
||||
DayZRuntimeService& operator=(const DayZRuntimeService&) = delete;
|
||||
DayZRuntimeService(DayZRuntimeService&&) = delete;
|
||||
DayZRuntimeService& operator=(DayZRuntimeService&&) = delete;
|
||||
|
||||
/// Start the background thread. Safe to call only once.
|
||||
void Start();
|
||||
|
||||
/// Signal the background thread to stop and join it.
|
||||
void Stop();
|
||||
|
||||
/// Thread-safe snapshot of the most recent entity update (50 ms cadence).
|
||||
[[nodiscard]] std::shared_ptr<const RuntimeUpdate> GetLatestUpdate() const;
|
||||
|
||||
/// Lightweight camera read — fills out with the latest camera data.
|
||||
/// Updated every background loop tick (~1 ms), completely separate from the
|
||||
/// entity snapshot so the overlay always gets the freshest camera.
|
||||
/// Returns false if no valid camera is available yet.
|
||||
bool GetLatestCamera(CameraData& out) const;
|
||||
|
||||
/// Register a callback invoked (from the background thread) on each update.
|
||||
void SetUpdateCallback(UpdateCallback cb);
|
||||
|
||||
private:
|
||||
// ------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ------------------------------------------------------------------
|
||||
RuntimeConfig m_config;
|
||||
ItemFilterCatalog m_filterCatalog;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Memory + resolvers
|
||||
// ------------------------------------------------------------------
|
||||
VmmAccessor m_memory;
|
||||
BaseObjectResolver m_baseResolver;
|
||||
LocalPlayerResolver m_localPlayerResolver;
|
||||
NetworkMetadataReader m_networkMetadataReader;
|
||||
NearEntityListReader m_nearReader;
|
||||
FarEntityListReader m_farReader;
|
||||
SlowEntityListReader m_slowReader;
|
||||
BulletTableReader m_bulletReader;
|
||||
ItemListReader m_itemReader;
|
||||
ClientScoreboardReader m_scoreboardReader;
|
||||
|
||||
// Last module base address passed to the sig scanner.
|
||||
uint64_t m_lastScannedBase = 0;
|
||||
|
||||
// Cached camera base pointer (resolved once per session; 0 = not yet known).
|
||||
// Caching this avoids re-reading World+0x1B8 on every tick.
|
||||
uint64_t m_cachedCamBase = 0;
|
||||
|
||||
// Resolved skeleton pointer-chain offsets (probed once, cached per session).
|
||||
// Skel offsets are per entity-type (player vs zombie differ); anim/matrix are shared.
|
||||
int64_t m_bonePlayerSkelOffset = -1;
|
||||
int64_t m_boneZombieSkelOffset = -1;
|
||||
int64_t m_boneAnimOffset = -1;
|
||||
int64_t m_boneMatrixOffset = -1;
|
||||
|
||||
// Per-entity bone pointer cache: entity address → (vsAddr, animClass, matBase).
|
||||
// animClass is stable for the entity lifetime; matBase is dynamic (can be
|
||||
// reallocated by the engine's animation system). Caching animClass separately
|
||||
// lets re-resolve after a stale matBase skip the full 4-read pointer walk.
|
||||
struct BonePointerCache {
|
||||
uint64_t vsAddr = 0;
|
||||
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
|
||||
};
|
||||
std::unordered_map<uint64_t, BonePointerCache> m_bonePointerCache;
|
||||
|
||||
// ---- Diagnostic logging bookkeeping ----
|
||||
// Admins already announced to the log (so each is reported once, not every
|
||||
// cycle). Cleared when the player leaves the snapshot so a re-join re-logs.
|
||||
std::unordered_set<uint64_t> m_adminAnnounced;
|
||||
// Entities whose skeleton currently resolves to valid bones. Used to detect
|
||||
// the valid→invalid edge so we can log when a skeleton drops out.
|
||||
std::unordered_set<uint64_t> m_bonesTracked;
|
||||
// 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<uint64_t, int64_t> m_boneLostLogAt;
|
||||
|
||||
// Separate lightweight camera state for the overlay.
|
||||
// Updated by the dedicated camera thread (RunCameraLoop) at high frequency,
|
||||
// decoupled from the heavy entity/bone reads so the overlay always projects
|
||||
// through the freshest possible camera — this is what keeps boxes/skeletons
|
||||
// glued to fast viewangle flicks instead of lagging a few ms behind.
|
||||
mutable std::mutex m_camMutex;
|
||||
CameraData m_latestCamForOverlay;
|
||||
|
||||
// Session handoff for the camera thread. Set when a live session starts,
|
||||
// cleared (0) on reconnect so the camera thread idles while the Process
|
||||
// handle is being re-created. Atomics: written by the runtime thread,
|
||||
// read by the camera thread.
|
||||
std::atomic<uint32_t> m_camPid{0};
|
||||
std::atomic<uint64_t> m_camWorldAddr{0};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Threading
|
||||
// ------------------------------------------------------------------
|
||||
std::thread m_runtimeThread;
|
||||
std::thread m_cameraThread;
|
||||
std::atomic<bool> m_stopRequested{false};
|
||||
mutable std::mutex m_updateMutex;
|
||||
std::shared_ptr<const RuntimeUpdate> m_latestUpdate;
|
||||
UpdateCallback m_callback;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Live update baseline — entity lists refreshed every playersRefreshMs,
|
||||
// camera/localPlayer overwritten every loop tick.
|
||||
RuntimeUpdate m_liveUpdate;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-iteration reader state / throttle timestamps
|
||||
// ------------------------------------------------------------------
|
||||
struct ReaderState {
|
||||
std::vector<DayZScoreboardPlayer> scoreboard;
|
||||
std::vector<DayZNearEntityEntry> nearEntities;
|
||||
std::vector<DayZFarEntityEntry> farEntities;
|
||||
std::vector<DayZSlowEntityEntry> slowEntities;
|
||||
std::vector<DayZBulletEntry> bullets;
|
||||
std::vector<DayZItemListEntry> items;
|
||||
|
||||
std::chrono::steady_clock::time_point nextScoreboardRefresh;
|
||||
std::chrono::steady_clock::time_point nextNearRefresh;
|
||||
std::chrono::steady_clock::time_point nextFarRefresh;
|
||||
std::chrono::steady_clock::time_point nextSlowRefresh;
|
||||
std::chrono::steady_clock::time_point nextBulletRefresh;
|
||||
std::chrono::steady_clock::time_point nextItemRefresh;
|
||||
std::chrono::steady_clock::time_point nextPlayersRefresh;
|
||||
std::chrono::steady_clock::time_point nextNetworkMetadataRefresh;
|
||||
std::chrono::steady_clock::time_point nextBoneRefresh;
|
||||
std::chrono::steady_clock::time_point nextVmmRefresh;
|
||||
} m_state;
|
||||
|
||||
// Cached metadata, player data, and camera populated during session
|
||||
NetworkMetadata m_metadata;
|
||||
LocalPlayerData m_localPlayer;
|
||||
CameraData m_camera;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Exception used to trigger a reconnect from RunLiveLoop
|
||||
// ------------------------------------------------------------------
|
||||
struct ReconnectException {};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Private methods
|
||||
// ------------------------------------------------------------------
|
||||
void Run();
|
||||
|
||||
/// Block until a valid session is established. Returns the session.
|
||||
RuntimeSession WaitForSession();
|
||||
|
||||
/// Main live-data loop. Throws ReconnectException when the session
|
||||
/// is no longer valid or the consecutive-failure threshold is reached.
|
||||
void RunLiveLoop(const RuntimeSession& session);
|
||||
|
||||
/// Dedicated high-frequency camera reader. Runs for the whole program
|
||||
/// lifetime; idles until m_camPid / m_camWorldAddr are set by a live session.
|
||||
void RunCameraLoop();
|
||||
|
||||
/// Read the camera transform block into `out`. Resolves camBaseCache from
|
||||
/// worldAddr when it is 0; resets it to 0 on a failed read to force a
|
||||
/// re-resolve next call. Returns true on success.
|
||||
bool ReadCameraInto(uint32_t pid, uint64_t worldAddr,
|
||||
uint64_t& camBaseCache, CameraData& out);
|
||||
|
||||
/// Publish an update snapshot under the mutex and invoke the callback.
|
||||
void PublishUpdate(const RuntimeUpdate& update);
|
||||
|
||||
/// Reset all readers and clear m_state vectors.
|
||||
void ResetAllReaders();
|
||||
|
||||
/// Build a scoreboard lookup map (networkId -> playerName).
|
||||
static std::unordered_map<uint32_t, std::string>
|
||||
BuildScoreboardNames(const std::vector<DayZScoreboardPlayer>& scoreboard);
|
||||
|
||||
/// Scatter-read bones for all players/zombies in range using the pointer cache.
|
||||
/// Cache misses resolve the pointer chain sequentially (one-time per entity).
|
||||
/// All subsequent reads for cached entities are batched into one DMA call.
|
||||
void RefreshBonesScatter(uint32_t pid,
|
||||
std::vector<DayZPlayerEntry>& players,
|
||||
std::vector<DayZZombieEntry>& zombies);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include "Offsets.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RuntimeOffsets
|
||||
//
|
||||
// Mutable counterparts to the compile-time Offsets:: namespace.
|
||||
// SigScanner::Apply() updates these at startup from scanned byte patterns.
|
||||
// All memory readers use RuntimeOffsets:: so a single scanner run covers a
|
||||
// game patch without needing a recompile.
|
||||
//
|
||||
// Defaults are the last-known good values from Offsets.h; the scanner will
|
||||
// overwrite them when matching patterns are found in the game binary.
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace RuntimeOffsets {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Base — module-relative RVAs of global variables
|
||||
// (resolved via RIP-relative MOV/LEA, ScanType::MovCs)
|
||||
// -----------------------------------------------------------------------
|
||||
namespace Base {
|
||||
inline uint64_t World = Offsets::Base::World;
|
||||
inline uint64_t NetworkManager = Offsets::Base::NetworkManager;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// World — struct member offsets within the World object
|
||||
// (resolved via MOV reg, [reg+offset], ScanType::MovReg)
|
||||
// -----------------------------------------------------------------------
|
||||
namespace World {
|
||||
inline uint64_t NearEntityList = Offsets::World::NearEntityList;
|
||||
inline uint64_t NearTableSize = Offsets::World::NearTableSize;
|
||||
inline uint64_t FarEntityList = Offsets::World::FarEntityList;
|
||||
inline uint64_t FarTableSize = Offsets::World::FarTableSize;
|
||||
inline uint64_t BulletTable = Offsets::World::BulletTable;
|
||||
inline uint64_t BulletCount = Offsets::World::BulletCount;
|
||||
inline uint64_t Camera = Offsets::World::Camera;
|
||||
inline uint64_t ItemList = Offsets::World::ItemList;
|
||||
inline uint64_t ItemTableAllocCount = Offsets::World::ItemTableAllocCount;
|
||||
inline uint64_t ItemTableValidCount = Offsets::World::ItemTableValidCount;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Entity / common struct offsets
|
||||
// -----------------------------------------------------------------------
|
||||
namespace Common {
|
||||
inline uint64_t VisualState = Offsets::Common::VisualState;
|
||||
}
|
||||
|
||||
namespace Player {
|
||||
inline uint64_t IsDead = Offsets::Player::IsDead;
|
||||
}
|
||||
|
||||
namespace Inventory {
|
||||
inline uint64_t Base = Offsets::Inventory::Base;
|
||||
inline uint64_t Hands = Offsets::Inventory::Hands;
|
||||
}
|
||||
|
||||
} // namespace RuntimeOffsets
|
||||
@@ -0,0 +1,360 @@
|
||||
#include "SigScanner/SigScanner.h"
|
||||
#include "RuntimeOffsets.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static constexpr size_t kChunkSize = 4 * 1024 * 1024; // 4 MB per DMA read
|
||||
static constexpr size_t kOverlap = 512; // cross-boundary safety
|
||||
static constexpr size_t kDefaultScanSize = 64 * 1024 * 1024; // fallback if PE read fails
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ParsePattern
|
||||
// -----------------------------------------------------------------------
|
||||
std::vector<SigScanner::PatternByte>
|
||||
SigScanner::ParsePattern(const std::string& hex)
|
||||
{
|
||||
std::vector<PatternByte> out;
|
||||
std::istringstream ss(hex);
|
||||
std::string token;
|
||||
while (ss >> token) {
|
||||
if (token == "?" || token == "??") {
|
||||
out.push_back({ 0x00, true });
|
||||
} else {
|
||||
uint8_t byte = static_cast<uint8_t>(std::stoul(token, nullptr, 16));
|
||||
out.push_back({ byte, false });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// FindFirst
|
||||
// -----------------------------------------------------------------------
|
||||
std::optional<size_t> SigScanner::FindFirst(
|
||||
const std::vector<uint8_t>& haystack,
|
||||
const std::vector<PatternByte>& pattern,
|
||||
size_t startPos)
|
||||
{
|
||||
if (pattern.empty()) return std::nullopt;
|
||||
const size_t patLen = pattern.size();
|
||||
if (haystack.size() < patLen) return std::nullopt;
|
||||
const size_t limit = haystack.size() - patLen;
|
||||
|
||||
for (size_t i = startPos; i <= limit; ++i) {
|
||||
bool ok = true;
|
||||
for (size_t j = 0; j < patLen; ++j) {
|
||||
if (!pattern[j].wildcard && haystack[i + j] != pattern[j].value) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return i;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReadU32
|
||||
// -----------------------------------------------------------------------
|
||||
uint32_t SigScanner::ReadU32(const std::vector<uint8_t>& bytes, size_t offset)
|
||||
{
|
||||
uint32_t v = 0;
|
||||
std::memcpy(&v, bytes.data() + offset, sizeof(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ResolveRip (MovCs)
|
||||
// target = matchRva + instrLen + sign_extend(disp32)
|
||||
// -----------------------------------------------------------------------
|
||||
uint64_t SigScanner::ResolveRip(uint64_t matchRva, int instrLen, uint32_t disp32Raw)
|
||||
{
|
||||
const int32_t disp = static_cast<int32_t>(disp32Raw);
|
||||
const int64_t target = static_cast<int64_t>(matchRva)
|
||||
+ static_cast<int64_t>(instrLen)
|
||||
+ static_cast<int64_t>(disp);
|
||||
return static_cast<uint64_t>(target);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ExtractMovReg
|
||||
// Find the first wildcard byte in the pattern; read uint32 from that
|
||||
// position in the original chunk. For DayZ, the 4 wildcard bytes that
|
||||
// immediately follow the opcode prefix are the struct member offset.
|
||||
// -----------------------------------------------------------------------
|
||||
uint32_t SigScanner::ExtractMovReg(const std::vector<uint8_t>& chunk,
|
||||
size_t matchPos,
|
||||
const std::vector<PatternByte>& pattern)
|
||||
{
|
||||
for (size_t i = 0; i < pattern.size(); ++i) {
|
||||
if (pattern[i].wildcard) {
|
||||
return ReadU32(chunk, matchPos + i);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReadModuleSize
|
||||
// -----------------------------------------------------------------------
|
||||
size_t SigScanner::ReadModuleSize(VmmAccessor& mem, uint32_t pid, uint64_t moduleBase)
|
||||
{
|
||||
uint16_t magic = 0;
|
||||
if (!mem.TryReadValue<uint16_t>(pid, moduleBase, magic) || magic != 0x5A4D) return 0;
|
||||
|
||||
uint32_t e_lfanew = 0;
|
||||
if (!mem.TryReadValue<uint32_t>(pid, moduleBase + 0x3C, e_lfanew)) return 0;
|
||||
|
||||
uint32_t peSig = 0;
|
||||
if (!mem.TryReadValue<uint32_t>(pid, moduleBase + e_lfanew, peSig) || peSig != 0x00004550) return 0;
|
||||
|
||||
// SizeOfImage: PE_header + 4 (PE sig) + 20 (COFF) + 0x38 (optional header offset)
|
||||
uint32_t sizeOfImage = 0;
|
||||
if (!mem.TryReadValue<uint32_t>(pid, moduleBase + e_lfanew + 24 + 0x38, sizeOfImage)) return 0;
|
||||
|
||||
return static_cast<size_t>(sizeOfImage);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scan
|
||||
// -----------------------------------------------------------------------
|
||||
SigScanner::ScanResult
|
||||
SigScanner::Scan(VmmAccessor& mem, uint32_t pid, uint64_t moduleBase)
|
||||
{
|
||||
ScanResult result;
|
||||
|
||||
// ---- Determine scan range ----
|
||||
size_t scanSize = ReadModuleSize(mem, pid, moduleBase);
|
||||
if (scanSize == 0) {
|
||||
spdlog::warn("SigScanner: PE header unreadable — defaulting to {} MB",
|
||||
kDefaultScanSize / (1024 * 1024));
|
||||
scanSize = kDefaultScanSize;
|
||||
} else {
|
||||
spdlog::debug("SigScanner: SizeOfImage = {} MB", scanSize / (1024 * 1024));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Pattern table — DayZ 1.29
|
||||
// ====================================================================
|
||||
|
||||
// ---- MovCs: RIP-relative globals ----
|
||||
// Modbase::World (48 8B 05 [disp32] 48 8D 54 24 ?? 48 8B 48 30)
|
||||
auto patWorld = ParsePattern(
|
||||
"48 8B 05 ? ? ? ? 48 8D 54 24 ? 48 8B 48 30");
|
||||
|
||||
// Modbase::Network (48 8D 0D [disp32] E8 ?? ?? ?? ?? 48 8B 1D ?? ?? ?? ?? 84 C0 74 20 41)
|
||||
auto patNetMgr = ParsePattern(
|
||||
"48 8D 0D ? ? ? ? E8 ? ? ? ? 48 8B 1D ? ? ? ? 84 C0 74 20 41");
|
||||
|
||||
// ---- MovReg: World struct member offsets ----
|
||||
// World::NearEntList (48 8B 83 [off32] 49 8B 14 06 48 3B D5)
|
||||
auto patNear = ParsePattern(
|
||||
"48 8B 83 ? ? ? ? 49 8B 14 06 48 3B D5");
|
||||
|
||||
// World::FarEntList (48 8B 83 [off32] 49 8B 0C 06 48 3B CD ...)
|
||||
auto patFar = ParsePattern(
|
||||
"48 8B 83 ? ? ? ? 49 8B 0C 06 48 3B CD 74 17 80 B9 ? ? ? ? ? 75 0E");
|
||||
|
||||
// World::BulletList (48 8B 83 [off32] 49 8B CF 48 03 0C F8)
|
||||
auto patBullet = ParsePattern(
|
||||
"48 8B 83 ? ? ? ? 49 8B CF 48 03 0C F8");
|
||||
|
||||
// World::Camera (4C 8B 83 [off32] 4C 8B 11 48 89 70 08)
|
||||
auto patCamera = ParsePattern(
|
||||
"4C 8B 83 ? ? ? ? 4C 8B 11 48 89 70 08");
|
||||
|
||||
// World::ItemList (48 89 85 [off32] 48 8D 15 ?? ?? ?? ?? 48 89 9D ?? ?? ?? ?? 48 8D 8D)
|
||||
auto patItem = ParsePattern(
|
||||
"48 89 85 ? ? ? ? 48 8D 15 ? ? ? ? 48 89 9D ? ? ? ? 48 8D 8D");
|
||||
|
||||
// ---- MovReg: entity struct offsets ----
|
||||
// Human::VisualState (48 8B 9F [off32] 49 8B CE FF 90 ?? ?? ?? ?? 8B 10)
|
||||
auto patVS = ParsePattern(
|
||||
"48 8B 9F ? ? ? ? 49 8B CE FF 90 ? ? ? ? 8B 10");
|
||||
|
||||
// Human::IsDead (0F B6 9A [off32] 48 89 7C 24 20 41 0F B6 B8 ?? ?? ?? ?? FF 90 B0 00)
|
||||
auto patDead = ParsePattern(
|
||||
"0F B6 9A ? ? ? ? 48 89 7C 24 20 41 0F B6 B8 ? ? ? ? FF 90 B0 00");
|
||||
|
||||
// DayZPlayer::Inventory (48 8B 8B [off32] 48 8B 01 FF 90 ?? ?? ?? ?? EB 02)
|
||||
auto patInv = ParsePattern(
|
||||
"48 8B 8B ? ? ? ? 48 8B 01 FF 90 ? ? ? ? EB 02");
|
||||
|
||||
// DayZPlayerInventory::Hands (48 8B 8B [off32] 48 8B F8 48 85 C9)
|
||||
auto patHands = ParsePattern(
|
||||
"48 8B 8B ? ? ? ? 48 8B F8 48 85 C9");
|
||||
|
||||
// ====================================================================
|
||||
// Chunk scan
|
||||
// ====================================================================
|
||||
std::vector<uint8_t> chunk;
|
||||
chunk.reserve(kChunkSize + kOverlap);
|
||||
|
||||
size_t offset = 0;
|
||||
while (offset < scanSize) {
|
||||
// Early-out once everything is found
|
||||
if (result.baseWorld &&
|
||||
result.baseNetworkManager &&
|
||||
result.worldNearEntList &&
|
||||
result.worldFarEntList &&
|
||||
result.worldBulletList &&
|
||||
result.worldCamera &&
|
||||
result.worldItemList &&
|
||||
result.humanVisualState &&
|
||||
result.humanIsDead &&
|
||||
result.playerInventory &&
|
||||
result.inventoryHands)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
const size_t readSize = std::min(kChunkSize, scanSize - offset);
|
||||
if (!mem.ReadBytes(pid, moduleBase + offset, readSize, chunk)) {
|
||||
offset += readSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
// -- MovCs --
|
||||
if (!result.baseWorld) {
|
||||
if (auto pos = FindFirst(chunk, patWorld)) {
|
||||
result.baseWorld = ResolveRip(offset + *pos, 7, ReadU32(chunk, *pos + 3));
|
||||
}
|
||||
}
|
||||
if (!result.baseNetworkManager) {
|
||||
if (auto pos = FindFirst(chunk, patNetMgr)) {
|
||||
result.baseNetworkManager = ResolveRip(offset + *pos, 7, ReadU32(chunk, *pos + 3));
|
||||
}
|
||||
}
|
||||
|
||||
// -- MovReg (World struct) --
|
||||
if (!result.worldNearEntList) {
|
||||
if (auto pos = FindFirst(chunk, patNear)) {
|
||||
result.worldNearEntList = ExtractMovReg(chunk, *pos, patNear);
|
||||
}
|
||||
}
|
||||
if (!result.worldFarEntList) {
|
||||
if (auto pos = FindFirst(chunk, patFar)) {
|
||||
result.worldFarEntList = ExtractMovReg(chunk, *pos, patFar);
|
||||
}
|
||||
}
|
||||
if (!result.worldBulletList) {
|
||||
if (auto pos = FindFirst(chunk, patBullet)) {
|
||||
result.worldBulletList = ExtractMovReg(chunk, *pos, patBullet);
|
||||
}
|
||||
}
|
||||
if (!result.worldCamera) {
|
||||
if (auto pos = FindFirst(chunk, patCamera)) {
|
||||
result.worldCamera = ExtractMovReg(chunk, *pos, patCamera);
|
||||
}
|
||||
}
|
||||
if (!result.worldItemList) {
|
||||
if (auto pos = FindFirst(chunk, patItem)) {
|
||||
result.worldItemList = ExtractMovReg(chunk, *pos, patItem);
|
||||
}
|
||||
}
|
||||
|
||||
// -- MovReg (entity struct) --
|
||||
if (!result.humanVisualState) {
|
||||
if (auto pos = FindFirst(chunk, patVS)) {
|
||||
result.humanVisualState = ExtractMovReg(chunk, *pos, patVS);
|
||||
}
|
||||
}
|
||||
if (!result.humanIsDead) {
|
||||
if (auto pos = FindFirst(chunk, patDead)) {
|
||||
result.humanIsDead = ExtractMovReg(chunk, *pos, patDead);
|
||||
}
|
||||
}
|
||||
if (!result.playerInventory) {
|
||||
if (auto pos = FindFirst(chunk, patInv)) {
|
||||
result.playerInventory = ExtractMovReg(chunk, *pos, patInv);
|
||||
}
|
||||
}
|
||||
if (!result.inventoryHands) {
|
||||
if (auto pos = FindFirst(chunk, patHands)) {
|
||||
result.inventoryHands = ExtractMovReg(chunk, *pos, patHands);
|
||||
}
|
||||
}
|
||||
|
||||
if (readSize <= kOverlap) break;
|
||||
offset += readSize - kOverlap;
|
||||
}
|
||||
|
||||
// ---- Report ----
|
||||
auto log = [](const char* name, auto val) {
|
||||
if (val) spdlog::info("SigScanner: {:35s} = 0x{:X}", name, *val);
|
||||
else spdlog::warn("SigScanner: {:35s} NOT FOUND", name);
|
||||
};
|
||||
log("base::world", result.baseWorld);
|
||||
log("base::network_manager", result.baseNetworkManager);
|
||||
log("World::NearEntityList", result.worldNearEntList);
|
||||
log("World::FarEntityList", result.worldFarEntList);
|
||||
log("World::BulletTable", result.worldBulletList);
|
||||
log("World::Camera", result.worldCamera);
|
||||
log("World::ItemList", result.worldItemList);
|
||||
log("Common::VisualState", result.humanVisualState);
|
||||
log("Player::IsDead", result.humanIsDead);
|
||||
log("Inventory::Base", result.playerInventory);
|
||||
log("Inventory::Hands", result.inventoryHands);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Apply — write results into RuntimeOffsets, log each change
|
||||
// -----------------------------------------------------------------------
|
||||
void SigScanner::Apply(const ScanResult& result)
|
||||
{
|
||||
auto applyU64 = [](const char* name, uint64_t& dst, std::optional<uint64_t> src) {
|
||||
if (!src) return;
|
||||
if (*src != dst) {
|
||||
spdlog::info("SigScanner::Apply: {} 0x{:X} -> 0x{:X}", name, dst, *src);
|
||||
dst = *src;
|
||||
}
|
||||
};
|
||||
auto applyU32 = [](const char* name, uint64_t& dst, std::optional<uint32_t> src) {
|
||||
if (!src) return;
|
||||
if (static_cast<uint64_t>(*src) != dst) {
|
||||
spdlog::info("SigScanner::Apply: {} 0x{:X} -> 0x{:X}", name, dst, *src);
|
||||
dst = static_cast<uint64_t>(*src);
|
||||
}
|
||||
};
|
||||
|
||||
applyU64("Base::World", RuntimeOffsets::Base::World, result.baseWorld);
|
||||
applyU64("Base::NetworkManager", RuntimeOffsets::Base::NetworkManager, result.baseNetworkManager);
|
||||
|
||||
applyU32("World::NearEntityList", RuntimeOffsets::World::NearEntityList, result.worldNearEntList);
|
||||
applyU32("World::FarEntityList", RuntimeOffsets::World::FarEntityList, result.worldFarEntList);
|
||||
applyU32("World::BulletTable", RuntimeOffsets::World::BulletTable, result.worldBulletList);
|
||||
applyU32("World::Camera", RuntimeOffsets::World::Camera, result.worldCamera);
|
||||
applyU32("World::ItemList", RuntimeOffsets::World::ItemList, result.worldItemList);
|
||||
|
||||
// Derive companion offsets that always follow the primary by +8
|
||||
if (result.worldNearEntList) {
|
||||
RuntimeOffsets::World::NearTableSize = RuntimeOffsets::World::NearEntityList + 8;
|
||||
}
|
||||
if (result.worldFarEntList) {
|
||||
RuntimeOffsets::World::FarTableSize = RuntimeOffsets::World::FarEntityList + 8;
|
||||
}
|
||||
if (result.worldBulletList) {
|
||||
RuntimeOffsets::World::BulletCount = RuntimeOffsets::World::BulletTable + 8;
|
||||
}
|
||||
if (result.worldItemList) {
|
||||
RuntimeOffsets::World::ItemTableAllocCount = RuntimeOffsets::World::ItemList + 8;
|
||||
RuntimeOffsets::World::ItemTableValidCount = RuntimeOffsets::World::ItemList + 16;
|
||||
}
|
||||
|
||||
applyU32("Common::VisualState", RuntimeOffsets::Common::VisualState, result.humanVisualState);
|
||||
applyU32("Player::IsDead", RuntimeOffsets::Player::IsDead, result.humanIsDead);
|
||||
applyU32("Inventory::Base", RuntimeOffsets::Inventory::Base, result.playerInventory);
|
||||
applyU32("Inventory::Hands", RuntimeOffsets::Inventory::Hands, result.inventoryHands);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Memory/VmmAccessor.h"
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SigScanner
|
||||
//
|
||||
// Reads the game module in chunks via DMA and searches for byte patterns
|
||||
// to extract offsets that change with each game update.
|
||||
//
|
||||
// Supported scan types:
|
||||
// MovCs — RIP-relative MOV/LEA: read int32 disp from the first wildcard
|
||||
// position, resolve to a module-relative RVA.
|
||||
// MovReg — Struct-member access: read uint32 from the first wildcard
|
||||
// position; the value IS the member offset within the struct.
|
||||
//
|
||||
// Usage:
|
||||
// auto result = SigScanner::Scan(mem, pid, moduleBase);
|
||||
// SigScanner::Apply(result); // writes into RuntimeOffsets
|
||||
// -----------------------------------------------------------------------
|
||||
class SigScanner {
|
||||
public:
|
||||
struct ScanResult {
|
||||
// ------------------------------------------------------------------
|
||||
// Base:: — module-relative RVAs of global-variable slots
|
||||
// (MovCs — resolved from RIP-relative displacement)
|
||||
// ------------------------------------------------------------------
|
||||
std::optional<uint64_t> baseWorld; // Base::World
|
||||
std::optional<uint64_t> baseNetworkManager; // Base::NetworkManager
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// World:: — struct member offsets within the World object
|
||||
// (MovReg — 4-byte little-endian value at the first wildcard pos)
|
||||
// ------------------------------------------------------------------
|
||||
std::optional<uint32_t> worldNearEntList; // World::NearEntityList
|
||||
std::optional<uint32_t> worldFarEntList; // World::FarEntityList
|
||||
std::optional<uint32_t> worldBulletList; // World::BulletTable
|
||||
std::optional<uint32_t> worldCamera; // World::Camera
|
||||
std::optional<uint32_t> worldItemList; // World::ItemList
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Entity struct offsets (MovReg)
|
||||
// ------------------------------------------------------------------
|
||||
std::optional<uint32_t> humanVisualState; // Common::VisualState
|
||||
std::optional<uint32_t> humanIsDead; // Player::IsDead
|
||||
std::optional<uint32_t> playerInventory; // Inventory::Base
|
||||
std::optional<uint32_t> inventoryHands; // Inventory::Hands
|
||||
};
|
||||
|
||||
// Scan the game module for all configured patterns.
|
||||
// moduleBase: runtime VA of the module's first mapped byte.
|
||||
static ScanResult Scan(VmmAccessor& mem, uint32_t pid, uint64_t moduleBase);
|
||||
|
||||
// Write scanned values into RuntimeOffsets, logging each change.
|
||||
static void Apply(const ScanResult& result);
|
||||
|
||||
private:
|
||||
struct PatternByte { uint8_t value; bool wildcard; };
|
||||
|
||||
// Parse a hex pattern string like "4C 8B 05 ? ? ? ? 48" into PatternByte vec.
|
||||
static std::vector<PatternByte> ParsePattern(const std::string& hex);
|
||||
|
||||
// Return the byte-offset of the first match in [haystack], or nullopt.
|
||||
static std::optional<size_t> FindFirst(const std::vector<uint8_t>& haystack,
|
||||
const std::vector<PatternByte>& pattern,
|
||||
size_t startPos = 0);
|
||||
|
||||
// Read a 4-byte little-endian uint32 from bytes[offset].
|
||||
static uint32_t ReadU32(const std::vector<uint8_t>& bytes, size_t offset);
|
||||
|
||||
// For MovCs: target = matchRva + instrLen + (int32_t)disp32
|
||||
static uint64_t ResolveRip(uint64_t matchRva, int instrLen, uint32_t disp32Raw);
|
||||
|
||||
// For MovReg: return the uint32 at the first wildcard position in the pattern.
|
||||
static uint32_t ExtractMovReg(const std::vector<uint8_t>& chunk,
|
||||
size_t matchPos,
|
||||
const std::vector<PatternByte>& pattern);
|
||||
|
||||
// Read SizeOfImage from the module's PE optional header.
|
||||
static size_t ReadModuleSize(VmmAccessor& mem, uint32_t pid, uint64_t moduleBase);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "Web/BulletTrackCache.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <unordered_set>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static float Distance3(const Vector3& a, const Vector3& b) {
|
||||
float dx = b.x - a.x;
|
||||
float dy = b.y - a.y;
|
||||
float dz = b.z - a.z;
|
||||
return std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
static float Length3(const Vector3& v) {
|
||||
return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BulletTrackCache::Update
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void BulletTrackCache::Update(const std::vector<DayZBulletEntry>& bullets,
|
||||
int64_t nowMs)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
|
||||
// Build set of addresses present in this snapshot.
|
||||
std::unordered_set<uint64_t> liveSet;
|
||||
liveSet.reserve(bullets.size());
|
||||
for (const auto& b : bullets) {
|
||||
if (b.address != 0)
|
||||
liveSet.insert(b.address);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Process each live bullet entry.
|
||||
// ----------------------------------------------------------------
|
||||
for (const auto& entry : bullets) {
|
||||
if (entry.address == 0 || !entry.position.has_value()) continue;
|
||||
|
||||
const Vector3& pos = entry.position.value();
|
||||
Vector3 dir = entry.direction.has_value() ? entry.direction.value() : Vector3{};
|
||||
|
||||
auto it = m_tracks.find(entry.address);
|
||||
if (it == m_tracks.end()) {
|
||||
// New bullet — create track.
|
||||
BulletTrack t;
|
||||
t.address = entry.address;
|
||||
t.firstSeenMs = nowMs;
|
||||
t.lastSeenMs = nowMs;
|
||||
t.currentPos = pos;
|
||||
t.initialPos = pos;
|
||||
t.finalPos = pos;
|
||||
t.initialDir = dir;
|
||||
t.lastDir = dir;
|
||||
t.speedMps = 0.0f;
|
||||
t.isActive = true;
|
||||
t.isPhantom = false;
|
||||
t.isCompleted = false;
|
||||
t.points.push_back({ nowMs, pos });
|
||||
m_tracks.emplace(entry.address, std::move(t));
|
||||
} else {
|
||||
BulletTrack& t = it->second;
|
||||
|
||||
// Speed calculation.
|
||||
float elapsed = static_cast<float>(nowMs - t.lastSeenMs) / 1000.0f;
|
||||
if (elapsed > 0.0f) {
|
||||
float dist = Distance3(t.currentPos, pos);
|
||||
float speed = dist / elapsed;
|
||||
// Clamp to [0, 900] m/s (well above any DayZ projectile).
|
||||
t.speedMps = std::max(0.0f, std::min(900.0f, speed));
|
||||
}
|
||||
|
||||
t.lastSeenMs = nowMs;
|
||||
t.currentPos = pos;
|
||||
t.finalPos = pos;
|
||||
if (Length3(dir) > 0.001f)
|
||||
t.lastDir = dir;
|
||||
t.isActive = true;
|
||||
t.isPhantom = false;
|
||||
t.points.push_back({ nowMs, pos });
|
||||
|
||||
// Finalise check: last 2+ points within kStillM over kStillMs.
|
||||
if (!t.isCompleted && t.points.size() >= 2) {
|
||||
const auto& prev = t.points[t.points.size() - 2];
|
||||
int64_t span = nowMs - prev.first;
|
||||
float dist = Distance3(prev.second, pos);
|
||||
if (span >= kStillMs && dist <= kStillM) {
|
||||
t.isCompleted = true;
|
||||
t.finalizedMs = nowMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Mark tracks not in this snapshot as phantom.
|
||||
// ----------------------------------------------------------------
|
||||
for (auto& [addr, t] : m_tracks) {
|
||||
if (liveSet.find(addr) == liveSet.end()) {
|
||||
if (!t.isCompleted) {
|
||||
t.isActive = false;
|
||||
t.isPhantom = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Erase stale tracks.
|
||||
// ----------------------------------------------------------------
|
||||
for (auto it = m_tracks.begin(); it != m_tracks.end();) {
|
||||
const BulletTrack& t = it->second;
|
||||
bool erase = false;
|
||||
|
||||
if (t.isCompleted) {
|
||||
// Keep for kRetentionMs after finalisation.
|
||||
erase = (nowMs - t.finalizedMs) > kRetentionMs;
|
||||
} else if (t.isPhantom) {
|
||||
erase = (nowMs - t.lastSeenMs) > kPhantomLifetimeMs;
|
||||
}
|
||||
|
||||
if (erase)
|
||||
it = m_tracks.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BulletTrackCache::GetSnapshot
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<BulletTrack> BulletTrackCache::GetSnapshot() const {
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
std::vector<BulletTrack> out;
|
||||
out.reserve(m_tracks.size());
|
||||
for (const auto& [addr, t] : m_tracks)
|
||||
out.push_back(t);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "Core/Models.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BulletTrack — full history for one bullet address.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct BulletTrack {
|
||||
uint64_t address = 0;
|
||||
|
||||
int64_t firstSeenMs = 0;
|
||||
int64_t lastSeenMs = 0;
|
||||
int64_t finalizedMs = 0; // 0 = not yet finalised
|
||||
|
||||
Vector3 currentPos;
|
||||
Vector3 initialPos;
|
||||
Vector3 finalPos;
|
||||
|
||||
Vector3 initialDir;
|
||||
Vector3 lastDir;
|
||||
|
||||
float speedMps = 0.0f;
|
||||
|
||||
bool isActive = false; // seen in the most-recent update
|
||||
bool isPhantom = false; // no longer in live list
|
||||
bool isCompleted = false; // came to rest
|
||||
|
||||
// (timestamp_ms, world-position) pairs, oldest first.
|
||||
std::vector<std::pair<int64_t, Vector3>> points;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BulletTrackCache
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class BulletTrackCache {
|
||||
public:
|
||||
/// Merge a fresh bullet snapshot into the cache.
|
||||
void Update(const std::vector<DayZBulletEntry>& bullets, int64_t nowMs);
|
||||
|
||||
/// Thread-safe snapshot of all currently tracked bullets.
|
||||
std::vector<BulletTrack> GetSnapshot() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex m_mutex;
|
||||
std::unordered_map<uint64_t, BulletTrack> m_tracks;
|
||||
|
||||
// A bullet that hasn't moved more than kStillM over kStillMs is
|
||||
// considered stopped and its track is finalised.
|
||||
static constexpr int64_t kStillMs = 75;
|
||||
static constexpr float kStillM = 0.05f;
|
||||
|
||||
// How long to retain a completed track after it was finalised.
|
||||
static constexpr int64_t kRetentionMs = 1500;
|
||||
|
||||
// How long to keep a phantom (disappeared but not completed) track.
|
||||
static constexpr int64_t kPhantomLifetimeMs = 5000;
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "Web/MapRegistry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
|
||||
#include "EmbeddedMaps.h"
|
||||
|
||||
// Returns the directory that contains the running exe.
|
||||
// Used so that maps/ is always resolved relative to the exe, not the CWD.
|
||||
static std::filesystem::path ExeDir() {
|
||||
wchar_t buf[MAX_PATH] = {};
|
||||
GetModuleFileNameW(nullptr, buf, MAX_PATH);
|
||||
return std::filesystem::path(buf).parent_path();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Static map table
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static const MapInfo kMaps[] = {
|
||||
{ "chernarusplus", "ChernarusPlus", 15360, 0 },
|
||||
{ "livonia", "Livonia", 15360, 1 },
|
||||
{ "namalsk", "Namalsk", 12800, 2 },
|
||||
{ "banov", "Banov", 15360, 3 },
|
||||
{ "deadfall", "Deadfall", 10000, 4 },
|
||||
{ "deerisle", "Deerisle", 16384, 5 },
|
||||
{ "esseker", "Esseker", 12800, 6 },
|
||||
{ "lux", "Lux", 15360, 7 },
|
||||
{ "sakhal", "Sakhal", 15360, 8 },
|
||||
{ "takistan", "Takistan", 12800, 9 },
|
||||
{ "alteria", "Alteria", 8192, 10 },
|
||||
};
|
||||
|
||||
static constexpr int kMapCount = static_cast<int>(sizeof(kMaps) / sizeof(kMaps[0]));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Alias table: normalised-key → canonical id
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct Alias { const char* from; const char* to; };
|
||||
|
||||
static const Alias kAliases[] = {
|
||||
{ "enoch", "livonia" },
|
||||
{ "chernarus", "chernarusplus" },
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Normalise: lowercase, keep only alphanumeric chars.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static std::string Normalise(std::string_view sv) {
|
||||
std::string out;
|
||||
out.reserve(sv.size());
|
||||
for (unsigned char c : sv) {
|
||||
if (std::isalnum(c))
|
||||
out += static_cast<char>(std::tolower(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MapRegistry implementation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const MapInfo* MapRegistry::Resolve(std::string_view serverMapName) {
|
||||
std::string key = Normalise(serverMapName);
|
||||
if (key.empty()) return nullptr;
|
||||
|
||||
// Check alias table first.
|
||||
for (const auto& alias : kAliases) {
|
||||
if (key == alias.from) {
|
||||
key = alias.to;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Exact match against canonical id.
|
||||
for (const auto& m : kMaps) {
|
||||
if (key == m.id)
|
||||
return &m;
|
||||
}
|
||||
|
||||
// Partial prefix match (e.g. "chernarusplus_2022" → "chernarusplus").
|
||||
for (const auto& m : kMaps) {
|
||||
if (key.rfind(m.id, 0) == 0)
|
||||
return &m;
|
||||
}
|
||||
|
||||
// Partial suffix / contains match (e.g. "dayz_namalsk").
|
||||
for (const auto& m : kMaps) {
|
||||
if (key.find(m.id) != std::string::npos)
|
||||
return &m;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void MapRegistry::Translate(const MapInfo& m, float worldX, float worldZ,
|
||||
float& outX, float& outY) {
|
||||
if (m.id == "livonia") {
|
||||
outX = worldX * 1.2f;
|
||||
outY = (12800.0f - worldZ) * 1.2f;
|
||||
} else {
|
||||
outX = worldX;
|
||||
outY = static_cast<float>(m.worldSize) - worldZ;
|
||||
}
|
||||
}
|
||||
|
||||
bool MapRegistry::HasMapImage(const MapInfo& m) {
|
||||
std::filesystem::path p = ExeDir() / "maps" / (m.id + ".png");
|
||||
if (std::filesystem::exists(p)) return true;
|
||||
auto [ptr, sz] = GetEmbeddedMap(m.id);
|
||||
return ptr != nullptr;
|
||||
}
|
||||
|
||||
const MapInfo* MapRegistry::AllMaps(int& count) {
|
||||
count = kMapCount;
|
||||
return kMaps;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MapInfo — metadata for a single supported DayZ map.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct MapInfo {
|
||||
std::string id; // canonical lowercase key (e.g. "chernarusplus")
|
||||
std::string name; // human-readable display name
|
||||
int worldSize; // world-space units that span the full map
|
||||
int index; // stable ordering index
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MapRegistry — static map database + helpers.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class MapRegistry {
|
||||
public:
|
||||
MapRegistry() = delete;
|
||||
|
||||
/// Return a pointer to the best-matching MapInfo for the given
|
||||
/// server map name, or nullptr if no match is found.
|
||||
static const MapInfo* Resolve(std::string_view serverMapName);
|
||||
|
||||
/// Convert a world-space position (worldX, worldZ) to 2-D map-image
|
||||
/// pixel-space coordinates (outX, outY). The transform differs per map.
|
||||
static void Translate(const MapInfo& m, float worldX, float worldZ,
|
||||
float& outX, float& outY);
|
||||
|
||||
/// Return true if maps/<id>.png exists relative to the working directory.
|
||||
static bool HasMapImage(const MapInfo& m);
|
||||
|
||||
/// Return the full list of known maps (used for bootstrap JSON).
|
||||
static const MapInfo* AllMaps(int& count);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "Web/MapTileService.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
|
||||
#include "EmbeddedMaps.h"
|
||||
|
||||
static std::filesystem::path ExeDir() {
|
||||
wchar_t buf[MAX_PATH] = {};
|
||||
GetModuleFileNameW(nullptr, buf, MAX_PATH);
|
||||
return std::filesystem::path(buf).parent_path();
|
||||
}
|
||||
|
||||
// stb_image / stb_image_write are implemented in stb_impl.cpp.
|
||||
// Include the headers only — no implementation macros here.
|
||||
#include <stb_image.h>
|
||||
#include <stb_image_write.h>
|
||||
|
||||
static constexpr int kTileSize = 512;
|
||||
static constexpr int kChannels = 4; // RGBA
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PNG write callback: appends bytes to a vector<uint8_t>*.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static void PngWriteCallback(void* context, void* data, int size) {
|
||||
auto* buf = static_cast<std::vector<uint8_t>*>(context);
|
||||
const auto* bytes = static_cast<const uint8_t*>(data);
|
||||
buf->insert(buf->end(), bytes, bytes + size);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MapTileService::LoadOrGet
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const MapTileService::Image* MapTileService::LoadOrGet(const std::string& mapId) {
|
||||
// Caller must already hold m_mutex.
|
||||
auto it = m_cache.find(mapId);
|
||||
if (it != m_cache.end()) {
|
||||
return &it->second; // cache hit
|
||||
}
|
||||
|
||||
// Disk file takes priority — allows map updates without rebuilding.
|
||||
// Falls back to the PNG baked into the binary as a Windows RCDATA resource.
|
||||
std::string path = (ExeDir() / "maps" / (mapId + ".png")).string();
|
||||
|
||||
int w = 0, h = 0, channels = 0;
|
||||
unsigned char* raw = nullptr;
|
||||
|
||||
if (std::filesystem::exists(path)) {
|
||||
raw = stbi_load(path.c_str(), &w, &h, &channels, kChannels);
|
||||
} else {
|
||||
auto [ptr, sz] = GetEmbeddedMap(mapId);
|
||||
if (ptr)
|
||||
raw = stbi_load_from_memory(ptr, static_cast<int>(sz),
|
||||
&w, &h, &channels, kChannels);
|
||||
}
|
||||
|
||||
Image img;
|
||||
if (raw && w > 0 && h > 0) {
|
||||
img.w = w;
|
||||
img.h = h;
|
||||
img.pixels.assign(raw, raw + static_cast<size_t>(w) * h * kChannels);
|
||||
stbi_image_free(raw);
|
||||
} else {
|
||||
if (raw) stbi_image_free(raw);
|
||||
// Don't cache the failure — the user may drop the PNG in while running.
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto [inserted_it, ok] = m_cache.emplace(mapId, std::move(img));
|
||||
(void)ok;
|
||||
return &inserted_it->second;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MapTileService::GetTile
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::vector<uint8_t> MapTileService::GetTile(const MapInfo& map, int tileX,
|
||||
int tileY, int& errCode)
|
||||
{
|
||||
if (tileX < 0 || tileY < 0) {
|
||||
errCode = 400;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
|
||||
const Image* img = LoadOrGet(map.id);
|
||||
if (!img || img->pixels.empty() || img->w == 0 || img->h == 0) {
|
||||
errCode = 404;
|
||||
return {};
|
||||
}
|
||||
|
||||
const int pixW = img->w;
|
||||
const int pixH = img->h;
|
||||
const float ws = static_cast<float>(map.worldSize);
|
||||
|
||||
const float scaleX = static_cast<float>(pixW) / ws;
|
||||
const float scaleY = static_cast<float>(pixH) / ws;
|
||||
|
||||
int srcX = static_cast<int>(static_cast<float>(tileX) * kTileSize * scaleX);
|
||||
int srcY = static_cast<int>(static_cast<float>(tileY) * kTileSize * scaleY);
|
||||
int srcW = static_cast<int>(kTileSize * scaleX);
|
||||
int srcH = static_cast<int>(kTileSize * scaleY);
|
||||
|
||||
// Clamp to image extents.
|
||||
if (srcX >= pixW || srcY >= pixH) {
|
||||
errCode = 404;
|
||||
return {};
|
||||
}
|
||||
srcW = std::max(1, std::min(srcW, pixW - srcX));
|
||||
srcH = std::max(1, std::min(srcH, pixH - srcY));
|
||||
|
||||
// Build 512×512 RGBA output via nearest-neighbour scaling.
|
||||
std::vector<uint8_t> tile(static_cast<size_t>(kTileSize) * kTileSize * kChannels, 0);
|
||||
|
||||
for (int ty = 0; ty < kTileSize; ++ty) {
|
||||
// Map output pixel row → source row.
|
||||
int sy = srcY + static_cast<int>((static_cast<float>(ty) / kTileSize) * srcH);
|
||||
sy = std::clamp(sy, 0, pixH - 1);
|
||||
|
||||
for (int tx = 0; tx < kTileSize; ++tx) {
|
||||
int sx = srcX + static_cast<int>((static_cast<float>(tx) / kTileSize) * srcW);
|
||||
sx = std::clamp(sx, 0, pixW - 1);
|
||||
|
||||
const size_t srcOff = (static_cast<size_t>(sy) * pixW + sx) * kChannels;
|
||||
const size_t dstOff = (static_cast<size_t>(ty) * kTileSize + tx) * kChannels;
|
||||
|
||||
std::memcpy(tile.data() + dstOff, img->pixels.data() + srcOff, kChannels);
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to PNG.
|
||||
std::vector<uint8_t> png;
|
||||
png.reserve(kTileSize * kTileSize); // rough estimate
|
||||
|
||||
int ok = stbi_write_png_to_func(PngWriteCallback, &png,
|
||||
kTileSize, kTileSize, kChannels,
|
||||
tile.data(),
|
||||
kTileSize * kChannels);
|
||||
if (!ok || png.empty()) {
|
||||
errCode = 500;
|
||||
return {};
|
||||
}
|
||||
|
||||
errCode = 200;
|
||||
return png;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "Web/MapRegistry.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MapTileService
|
||||
// Loads a full map PNG on first request, caches it in memory, and serves
|
||||
// 512×512 tiles on demand (nearest-neighbour resampled).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class MapTileService {
|
||||
public:
|
||||
/// Return PNG-encoded bytes for the requested tile.
|
||||
/// errCode is set to one of: 200, 400, 404, 500.
|
||||
/// An empty vector is returned for any non-200 result.
|
||||
std::vector<uint8_t> GetTile(const MapInfo& map, int tileX, int tileY,
|
||||
int& errCode);
|
||||
|
||||
private:
|
||||
struct Image {
|
||||
std::vector<uint8_t> pixels; // RGBA, row-major
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
};
|
||||
|
||||
std::mutex m_mutex;
|
||||
std::unordered_map<std::string, Image> m_cache; // mapId → loaded image
|
||||
|
||||
/// Return cached Image or attempt to load it. Returns nullptr on failure.
|
||||
const Image* LoadOrGet(const std::string& mapId);
|
||||
};
|
||||
@@ -0,0 +1,463 @@
|
||||
// Pull in Windows headers before httplib so WinSock2 is set up correctly.
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
|
||||
// httplib is a single-header library; the full definition is only needed here.
|
||||
#include <httplib.h>
|
||||
|
||||
#include "Web/WebRadarServer.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructor / Destructor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
WebRadarServer::WebRadarServer(WebRadarConfig config)
|
||||
: m_config(std::move(config))
|
||||
{}
|
||||
|
||||
WebRadarServer::~WebRadarServer() {
|
||||
Stop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Start / Stop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void WebRadarServer::Start() {
|
||||
// Pre-select the first map that has a PNG in maps/ so the browser
|
||||
// can show tiles immediately, before the game session connects.
|
||||
if (!m_currentMap) {
|
||||
int mapCount = 0;
|
||||
const MapInfo* allMaps = MapRegistry::AllMaps(mapCount);
|
||||
for (int i = 0; i < mapCount; ++i) {
|
||||
if (MapRegistry::HasMapImage(allMaps[i])) {
|
||||
m_currentMap = &allMaps[i];
|
||||
spdlog::info("Web radar: pre-selected map '{}' from maps/ directory",
|
||||
allMaps[i].id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!m_currentMap) {
|
||||
spdlog::warn("Web radar: no map PNG found in maps/ — tiles will return 404 until "
|
||||
"a PNG named after a map ID is placed there "
|
||||
"(e.g. maps/chernarusplus.png, maps/sakhal.png).");
|
||||
}
|
||||
}
|
||||
|
||||
// Build the initial bootstrap JSON now so /api/bootstrap returns valid JSON
|
||||
// immediately when the browser loads, before any game session connects.
|
||||
// Without this, m_bootstrapJson is "" and response.json() throws in the JS,
|
||||
// leaving the page with a black background and no map tiles.
|
||||
{
|
||||
RuntimeUpdate empty{};
|
||||
m_bootstrapJson = m_snapshotSvc.BuildBootstrapJson(empty, m_currentMap, m_config.port);
|
||||
m_lastBootstrapMapId = m_currentMap ? m_currentMap->id : "";
|
||||
}
|
||||
|
||||
m_server = std::make_unique<httplib::Server>();
|
||||
m_server->new_task_queue = [] {
|
||||
return new httplib::ThreadPool(16);
|
||||
};
|
||||
|
||||
SetupRoutes();
|
||||
|
||||
m_thread = std::thread([this] {
|
||||
spdlog::info("Web radar listening on {}:{}",
|
||||
m_config.bindAddress, m_config.port);
|
||||
if (!m_server->listen(m_config.bindAddress, m_config.port)) {
|
||||
spdlog::error("Web radar failed to bind {}:{}",
|
||||
m_config.bindAddress, m_config.port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void WebRadarServer::Stop() {
|
||||
m_stopping.store(true);
|
||||
m_cv.notify_all();
|
||||
if (m_server) m_server->stop();
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PushSnapshot
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
|
||||
int64_t now = NowMs();
|
||||
|
||||
// Always update bullet tracks — they need sub-100ms freshness for
|
||||
// trajectory rendering, and the update is cheap (no JSON involved).
|
||||
m_bullets.Update(update.bullets, now);
|
||||
|
||||
if (update.serverMapName.has_value()) {
|
||||
const MapInfo* resolved = MapRegistry::Resolve(*update.serverMapName);
|
||||
if (resolved) {
|
||||
if (m_currentMap != resolved)
|
||||
spdlog::info("Web radar: map changed to '{}'", resolved->id);
|
||||
m_currentMap = resolved;
|
||||
} else {
|
||||
spdlog::warn("Web radar: unrecognised server map name '{}' — keeping current map",
|
||||
*update.serverMapName);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limit JSON serialization. At 60 Hz with large entity counts
|
||||
// (modded server, built bases) BuildStateJson can take several ms and
|
||||
// produce hundreds of KB per call. 10 Hz is more than enough for a map.
|
||||
if (now < m_nextSnapshotMs) return;
|
||||
m_nextSnapshotMs = now + m_config.snapshotIntervalMs;
|
||||
|
||||
std::string jsonStr = m_snapshotSvc.BuildStateJson(
|
||||
update, m_bullets.GetSnapshot(), m_currentMap, now);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_cvMutex);
|
||||
|
||||
// Bootstrap only changes when the active map changes — don't rebuild
|
||||
// it every tick.
|
||||
std::string mapId = m_currentMap ? m_currentMap->id : "";
|
||||
if (mapId != m_lastBootstrapMapId) {
|
||||
m_bootstrapJson = m_snapshotSvc.BuildBootstrapJson(
|
||||
update, m_currentMap, m_config.port);
|
||||
m_lastBootstrapMapId = std::move(mapId);
|
||||
}
|
||||
|
||||
m_latestJson = std::move(jsonStr);
|
||||
m_latestPayload = "event: state\ndata: " + m_latestJson + "\n\n";
|
||||
++m_broadcastSeq;
|
||||
}
|
||||
m_cv.notify_all();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SetupRoutes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void WebRadarServer::SetupRoutes() {
|
||||
// ---- Static files ---------------------------------------------------
|
||||
|
||||
m_server->Get("/", [this](const httplib::Request& /*req*/,
|
||||
httplib::Response& res) {
|
||||
ServeFile(res, "index.html", "text/html; charset=utf-8");
|
||||
});
|
||||
|
||||
m_server->Get("/app.js", [this](const httplib::Request& /*req*/,
|
||||
httplib::Response& res) {
|
||||
ServeFile(res, "app.js", "application/javascript; charset=utf-8");
|
||||
});
|
||||
|
||||
m_server->Get("/style.css", [this](const httplib::Request& /*req*/,
|
||||
httplib::Response& res) {
|
||||
ServeFile(res, "style.css", "text/css; charset=utf-8");
|
||||
});
|
||||
|
||||
m_server->Get("/favicon.ico", [this](const httplib::Request& /*req*/,
|
||||
httplib::Response& res) {
|
||||
ServeFile(res, "favicon.ico", "image/x-icon");
|
||||
});
|
||||
|
||||
// ---- Bootstrap ------------------------------------------------------
|
||||
|
||||
m_server->Get("/api/bootstrap",
|
||||
[this](const httplib::Request& req, httplib::Response& res) {
|
||||
if (!Authorise(req.remote_addr,
|
||||
req.get_param_value("password"))) {
|
||||
res.status = 401;
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(m_cvMutex);
|
||||
res.set_content(m_bootstrapJson, "application/json");
|
||||
});
|
||||
|
||||
// ---- Polling state --------------------------------------------------
|
||||
|
||||
m_server->Get("/api/state",
|
||||
[this](const httplib::Request& req, httplib::Response& res) {
|
||||
if (!Authorise(req.remote_addr,
|
||||
req.get_param_value("password"))) {
|
||||
res.status = 401;
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(m_cvMutex);
|
||||
res.set_content(m_latestJson, "application/json");
|
||||
});
|
||||
|
||||
// ---- SSE stream -----------------------------------------------------
|
||||
|
||||
m_server->Get("/events",
|
||||
[this](const httplib::Request& req, httplib::Response& res) {
|
||||
if (!Authorise(req.remote_addr,
|
||||
req.get_param_value("password"))) {
|
||||
res.status = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
res.set_header("Cache-Control", "no-cache");
|
||||
res.set_header("X-Accel-Buffering", "no");
|
||||
|
||||
auto lastSeq = std::make_shared<int64_t>(-1);
|
||||
|
||||
res.set_chunked_content_provider(
|
||||
"text/event-stream",
|
||||
[this, lastSeq](size_t /*offset*/,
|
||||
httplib::DataSink& sink) -> bool {
|
||||
if (m_stopping.load()) return false;
|
||||
|
||||
std::string payload;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(m_cvMutex);
|
||||
m_cv.wait_for(lk, std::chrono::seconds(10),
|
||||
[this, &lastSeq] {
|
||||
return m_broadcastSeq != *lastSeq
|
||||
|| m_stopping.load();
|
||||
});
|
||||
|
||||
if (m_stopping.load()) return false;
|
||||
|
||||
if (m_broadcastSeq == *lastSeq) {
|
||||
// Keepalive comment to prevent proxy timeouts.
|
||||
payload = ": keepalive\n\n";
|
||||
} else {
|
||||
payload = m_latestPayload;
|
||||
*lastSeq = m_broadcastSeq;
|
||||
}
|
||||
}
|
||||
|
||||
return sink.write(payload.c_str(), payload.size());
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ---- Map tile -------------------------------------------------------
|
||||
|
||||
m_server->Get("/tile",
|
||||
[this](const httplib::Request& req, httplib::Response& res) {
|
||||
if (!Authorise(req.remote_addr,
|
||||
req.get_param_value("password"))) {
|
||||
res.status = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the explicit mapId param (sent by app.js); fall back to
|
||||
// the currently active map resolved from the game session.
|
||||
const MapInfo* tileMap = nullptr;
|
||||
std::string mapIdParam = req.get_param_value("mapId");
|
||||
if (!mapIdParam.empty())
|
||||
tileMap = MapRegistry::Resolve(mapIdParam);
|
||||
if (!tileMap)
|
||||
tileMap = m_currentMap;
|
||||
|
||||
if (!tileMap) {
|
||||
res.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
int tx = 0, ty = 0;
|
||||
try {
|
||||
tx = std::stoi(req.get_param_value("x"));
|
||||
ty = std::stoi(req.get_param_value("y"));
|
||||
} catch (...) {
|
||||
res.status = 400;
|
||||
return;
|
||||
}
|
||||
|
||||
int code = 200;
|
||||
auto bytes = m_tiles.GetTile(*tileMap, tx, ty, code);
|
||||
res.status = code;
|
||||
if (code == 200) {
|
||||
// Cache tiles aggressively — they're static PNG slices that only
|
||||
// change when the map PNG is replaced (different URL via mapId param).
|
||||
// Cloudflare tunnel and browsers will both benefit from this.
|
||||
res.set_header("Cache-Control", "public, max-age=86400");
|
||||
res.set_header("Vary", "Accept-Encoding");
|
||||
res.set_content(
|
||||
std::string(bytes.begin(), bytes.end()),
|
||||
"image/png");
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Full map image -------------------------------------------------
|
||||
|
||||
m_server->Get("/map-image",
|
||||
[this](const httplib::Request& req, httplib::Response& res) {
|
||||
if (!Authorise(req.remote_addr,
|
||||
req.get_param_value("password"))) {
|
||||
res.status = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
const MapInfo* imgMap = nullptr;
|
||||
std::string mapIdParam = req.get_param_value("mapId");
|
||||
if (!mapIdParam.empty())
|
||||
imgMap = MapRegistry::Resolve(mapIdParam);
|
||||
if (!imgMap)
|
||||
imgMap = m_currentMap;
|
||||
|
||||
if (!imgMap) {
|
||||
res.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
wchar_t mapPathBuf[MAX_PATH] = {};
|
||||
GetModuleFileNameW(nullptr, mapPathBuf, MAX_PATH);
|
||||
std::string path = (std::filesystem::path(mapPathBuf).parent_path()
|
||||
/ "maps" / (imgMap->id + ".png")).string();
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) {
|
||||
res.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
std::string content(
|
||||
(std::istreambuf_iterator<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
res.set_content(std::move(content), "image/png");
|
||||
});
|
||||
|
||||
// ---- Debug ----------------------------------------------------------
|
||||
// No auth — purely diagnostic. Open /api/debug in a browser to see
|
||||
// what the server sees: exe dir, maps/ path, which PNGs exist, and what
|
||||
// the bootstrap JSON currently contains.
|
||||
|
||||
m_server->Get("/api/debug",
|
||||
[this](const httplib::Request& /*req*/, httplib::Response& res) {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
wchar_t exeBuf[MAX_PATH] = {};
|
||||
GetModuleFileNameW(nullptr, exeBuf, MAX_PATH);
|
||||
fs::path exeDir = fs::path(exeBuf).parent_path();
|
||||
fs::path mapsDir = exeDir / "maps";
|
||||
|
||||
// Escape a string for inline JSON (handles backslashes in Windows paths).
|
||||
auto jsStr = [](const std::string& s) {
|
||||
std::string r;
|
||||
r.reserve(s.size() + 4);
|
||||
for (char c : s) {
|
||||
if (c == '\\') r += "\\\\";
|
||||
else if (c == '"') r += "\\\"";
|
||||
else r += c;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
std::ostringstream j;
|
||||
j << "{\n";
|
||||
j << " \"exeDir\": \"" << jsStr(exeDir.string()) << "\",\n";
|
||||
j << " \"mapsDir\": \"" << jsStr(mapsDir.string()) << "\",\n";
|
||||
j << " \"mapsDirExists\": " << (fs::exists(mapsDir) ? "true" : "false") << ",\n";
|
||||
j << " \"currentMapId\": \"" << (m_currentMap ? m_currentMap->id : "") << "\",\n";
|
||||
|
||||
// PNGs actually present in maps/.
|
||||
j << " \"pngsFound\": [";
|
||||
{
|
||||
std::error_code ec;
|
||||
bool first = true;
|
||||
for (const auto& e : fs::directory_iterator(mapsDir, ec)) {
|
||||
if (!ec && e.path().extension() == ".png") {
|
||||
if (!first) j << ", ";
|
||||
j << "\"" << jsStr(e.path().filename().string()) << "\"";
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
j << "],\n";
|
||||
|
||||
// All known map IDs with whether their PNG was found.
|
||||
j << " \"knownMaps\": [";
|
||||
{
|
||||
int mapCount = 0;
|
||||
const MapInfo* allMaps = MapRegistry::AllMaps(mapCount);
|
||||
for (int i = 0; i < mapCount; ++i) {
|
||||
if (i > 0) j << ", ";
|
||||
j << "{\"id\": \"" << allMaps[i].id << "\", \"hasImage\": "
|
||||
<< (MapRegistry::HasMapImage(allMaps[i]) ? "true" : "false") << "}";
|
||||
}
|
||||
}
|
||||
j << "]\n}";
|
||||
|
||||
res.set_content(j.str(), "application/json");
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ServeFile
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void WebRadarServer::ServeFile(httplib::Response& res,
|
||||
const std::string& filename,
|
||||
const std::string& contentType)
|
||||
{
|
||||
std::string path = WebrootPath(filename);
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) {
|
||||
res.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
std::string content(
|
||||
(std::istreambuf_iterator<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
res.set_content(std::move(content), contentType);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Authorise
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool WebRadarServer::Authorise(const std::string& addr,
|
||||
const std::string& pwd) const
|
||||
{
|
||||
// Always allow loopback connections.
|
||||
if (addr == "127.0.0.1" || addr == "::1" || addr == "localhost")
|
||||
return true;
|
||||
|
||||
// If no password configured, allow everyone.
|
||||
if (m_config.password.empty())
|
||||
return true;
|
||||
|
||||
return pwd == m_config.password;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebrootPath
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string WebRadarServer::WebrootPath(const std::string& file) const {
|
||||
wchar_t buf[MAX_PATH] = {};
|
||||
GetModuleFileNameW(nullptr, buf, MAX_PATH);
|
||||
|
||||
// Convert wide string to narrow.
|
||||
std::wstring wpath(buf);
|
||||
// Strip the filename component to get the exe directory.
|
||||
auto lastSlash = wpath.find_last_of(L"\\/");
|
||||
if (lastSlash != std::wstring::npos)
|
||||
wpath = wpath.substr(0, lastSlash + 1);
|
||||
|
||||
// Convert to std::string (ASCII/UTF-8 — path characters only).
|
||||
std::string exeDir(wpath.begin(), wpath.end());
|
||||
return exeDir + "webroot\\" + file;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NowMs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
int64_t WebRadarServer::NowMs() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(
|
||||
system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "Web/BulletTrackCache.h"
|
||||
#include "Web/MapRegistry.h"
|
||||
#include "Web/MapTileService.h"
|
||||
#include "Web/WebSnapshotService.h"
|
||||
|
||||
// Forward-declare httplib types to avoid pulling the whole header (which
|
||||
// includes <Winsock2.h>) into every translation unit that includes this header.
|
||||
namespace httplib { class Server; struct Response; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebRadarConfig — tuneable startup parameters.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct WebRadarConfig {
|
||||
std::string bindAddress = "0.0.0.0";
|
||||
int port = 7777;
|
||||
std::string password = "";
|
||||
int snapshotIntervalMs = 100; // max web snapshot rate (default 10 Hz)
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebRadarServer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class WebRadarServer {
|
||||
public:
|
||||
explicit WebRadarServer(WebRadarConfig config = {});
|
||||
~WebRadarServer();
|
||||
|
||||
WebRadarServer(const WebRadarServer&) = delete;
|
||||
WebRadarServer& operator=(const WebRadarServer&) = delete;
|
||||
|
||||
/// Start the HTTP server on a background thread.
|
||||
void Start();
|
||||
|
||||
/// Stop the HTTP server and join the background thread.
|
||||
void Stop();
|
||||
|
||||
/// Feed a new RuntimeUpdate into the server. Thread-safe.
|
||||
void PushSnapshot(const RuntimeUpdate& update);
|
||||
|
||||
private:
|
||||
WebRadarConfig m_config;
|
||||
WebSnapshotService m_snapshotSvc;
|
||||
BulletTrackCache m_bullets;
|
||||
MapTileService m_tiles;
|
||||
const MapInfo* m_currentMap = nullptr;
|
||||
|
||||
std::unique_ptr<httplib::Server> m_server;
|
||||
std::thread m_thread;
|
||||
|
||||
// SSE broadcast state.
|
||||
mutable std::mutex m_cvMutex;
|
||||
std::condition_variable m_cv;
|
||||
std::string m_latestPayload; // full "event: state\ndata: ...\n\n"
|
||||
std::string m_latestJson; // JSON only (for /api/state polling)
|
||||
std::string m_bootstrapJson; // /api/bootstrap response
|
||||
int64_t m_broadcastSeq = 0;
|
||||
std::atomic<bool> m_stopping{false};
|
||||
int64_t m_nextSnapshotMs = 0; // rate-limit for JSON builds
|
||||
std::string m_lastBootstrapMapId; // only rebuild bootstrap on map change
|
||||
|
||||
void SetupRoutes();
|
||||
void ServeFile(httplib::Response& res, const std::string& filename,
|
||||
const std::string& contentType);
|
||||
|
||||
/// Returns true if the request is permitted (localhost, no password, or
|
||||
/// correct password query parameter).
|
||||
bool Authorise(const std::string& remoteAddr,
|
||||
const std::string& passwordParam) const;
|
||||
|
||||
/// Absolute path to webroot/<file> relative to the executable directory.
|
||||
std::string WebrootPath(const std::string& file) const;
|
||||
|
||||
static int64_t NowMs();
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
#include "Web/WebSnapshotService.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static constexpr float kPi = 3.14159265358979323846f;
|
||||
|
||||
/// Convert a world address to a hex-string id (e.g. "1A2B3C4D").
|
||||
static std::string AddrId(uint64_t address) {
|
||||
return std::format("{:X}", address);
|
||||
}
|
||||
|
||||
/// Translate world-space (x, z) to map-image space.
|
||||
/// If map is null, returns the raw coordinates.
|
||||
static std::pair<float, float> WorldToMap(const MapInfo* map,
|
||||
float worldX, float worldZ)
|
||||
{
|
||||
float ox = worldX, oy = worldZ;
|
||||
if (map)
|
||||
MapRegistry::Translate(*map, worldX, worldZ, ox, oy);
|
||||
return { ox, oy };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Entity JSON builders
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static json PlayerEntityJson(const DayZPlayerEntry& p, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(p.address);
|
||||
obj["kind"] = "player";
|
||||
|
||||
// Label: prefer nickname, fall back to typeName.
|
||||
obj["label"] = p.nickname.empty() ? p.typeName : p.nickname;
|
||||
|
||||
if (p.position.has_value()) {
|
||||
auto [mx, my] = WorldToMap(map, p.position->x, p.position->z);
|
||||
obj["x"] = mx;
|
||||
obj["y"] = my;
|
||||
} else {
|
||||
obj["x"] = 0.0f;
|
||||
obj["y"] = 0.0f;
|
||||
}
|
||||
|
||||
// headingDegrees is already in degrees from the reader.
|
||||
obj["rotation"] = p.headingDegrees.value_or(0.0f);
|
||||
obj["distance"] = -1;
|
||||
obj["networkId"] = p.networkId;
|
||||
obj["steamId"] = "";
|
||||
obj["dead"] = p.isDead;
|
||||
obj["handItem"] = p.itemInHands;
|
||||
obj["visibleOnMap"] = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static json PlayerListEntryJson(const DayZPlayerEntry& p) {
|
||||
json obj;
|
||||
obj["networkId"] = p.networkId;
|
||||
obj["label"] = p.nickname.empty() ? p.typeName : p.nickname;
|
||||
obj["steamId"] = "";
|
||||
obj["visibleOnMap"] = true;
|
||||
obj["dead"] = p.isDead;
|
||||
obj["handItem"] = p.itemInHands;
|
||||
obj["distance"] = -1;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static json ZombieEntityJson(const DayZZombieEntry& z, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(z.address);
|
||||
obj["kind"] = "zombie";
|
||||
obj["label"] = z.entityName.empty() ? z.typeName : z.entityName;
|
||||
|
||||
if (z.position.has_value()) {
|
||||
auto [mx, my] = WorldToMap(map, z.position->x, z.position->z);
|
||||
obj["x"] = mx;
|
||||
obj["y"] = my;
|
||||
} else {
|
||||
obj["x"] = 0.0f;
|
||||
obj["y"] = 0.0f;
|
||||
}
|
||||
|
||||
obj["rotation"] = z.headingDegrees.value_or(0.0f);
|
||||
obj["distance"] = -1;
|
||||
obj["visibleOnMap"] = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static json AnimalEntityJson(const DayZAnimalEntry& a, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(a.address);
|
||||
obj["kind"] = "animal";
|
||||
obj["label"] = a.entityName.empty() ? a.typeName : a.entityName;
|
||||
|
||||
if (a.position.has_value()) {
|
||||
auto [mx, my] = WorldToMap(map, a.position->x, a.position->z);
|
||||
obj["x"] = mx;
|
||||
obj["y"] = my;
|
||||
} else {
|
||||
obj["x"] = 0.0f;
|
||||
obj["y"] = 0.0f;
|
||||
}
|
||||
|
||||
obj["rotation"] = a.headingDegrees.value_or(0.0f);
|
||||
obj["distance"] = -1;
|
||||
obj["visibleOnMap"] = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static json VehicleEntityJson(const DayZCarAndBoatEntry& v, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(v.address);
|
||||
obj["kind"] = "vehicle";
|
||||
obj["label"] = v.entityName.empty() ? v.typeName : v.entityName;
|
||||
|
||||
if (v.position.has_value()) {
|
||||
auto [mx, my] = WorldToMap(map, v.position->x, v.position->z);
|
||||
obj["x"] = mx;
|
||||
obj["y"] = my;
|
||||
} else {
|
||||
obj["x"] = 0.0f;
|
||||
obj["y"] = 0.0f;
|
||||
}
|
||||
|
||||
obj["rotation"] = v.headingDegrees.value_or(0.0f);
|
||||
obj["distance"] = -1;
|
||||
obj["visibleOnMap"] = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static json OtherEntityJson(const DayZOtherEntityEntry& e, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(e.address);
|
||||
obj["kind"] = "other-entity";
|
||||
obj["label"] = e.entityName.empty() ? e.typeName : e.entityName;
|
||||
|
||||
if (e.position.has_value()) {
|
||||
auto [mx, my] = WorldToMap(map, e.position->x, e.position->z);
|
||||
obj["x"] = mx;
|
||||
obj["y"] = my;
|
||||
} else {
|
||||
obj["x"] = 0.0f;
|
||||
obj["y"] = 0.0f;
|
||||
}
|
||||
|
||||
obj["rotation"] = e.headingDegrees.value_or(0.0f);
|
||||
obj["distance"] = -1;
|
||||
obj["visibleOnMap"] = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static json LootEntityJson(const DayZItemListEntry& item, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(item.address);
|
||||
obj["kind"] = "loot";
|
||||
obj["label"] = item.cleanName.empty() ? item.entityName : item.cleanName;
|
||||
|
||||
if (item.position.has_value()) {
|
||||
auto [mx, my] = WorldToMap(map, item.position->x, item.position->z);
|
||||
obj["x"] = mx;
|
||||
obj["y"] = my;
|
||||
} else {
|
||||
obj["x"] = 0.0f;
|
||||
obj["y"] = 0.0f;
|
||||
}
|
||||
|
||||
obj["rotation"] = 0.0f;
|
||||
obj["distance"] = -1;
|
||||
obj["visibleOnMap"] = true;
|
||||
obj["lootCategory"] = item.filterKey; // filterKey = lowercase composite from item_filters.json
|
||||
return obj;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bullet JSON builder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static json BulletJson(const BulletTrack& t, const MapInfo* map) {
|
||||
json obj;
|
||||
obj["id"] = AddrId(t.address);
|
||||
obj["label"] = "Bullet";
|
||||
obj["kind"] = "bullet";
|
||||
|
||||
auto [cx, cy] = WorldToMap(map, t.currentPos.x, t.currentPos.z);
|
||||
obj["x"] = cx;
|
||||
obj["y"] = cy;
|
||||
|
||||
// Rotation from lastDir: atan2(x, z) gives bearing in radians; convert to degrees.
|
||||
float bearing = std::atan2(t.lastDir.x, t.lastDir.z) * (180.0f / kPi);
|
||||
obj["rotation"] = bearing;
|
||||
obj["distance"] = -1;
|
||||
obj["visibleOnMap"] = true;
|
||||
|
||||
obj["isActive"] = t.isActive;
|
||||
obj["isPhantom"] = t.isPhantom;
|
||||
obj["isCompleted"] = t.isCompleted;
|
||||
|
||||
obj["firstSeenAtUtcMs"] = t.firstSeenMs;
|
||||
obj["lastSeenAtUtcMs"] = t.lastSeenMs;
|
||||
|
||||
if (t.isCompleted)
|
||||
obj["finalizedAtUtcMs"] = t.finalizedMs;
|
||||
else
|
||||
obj["finalizedAtUtcMs"] = nullptr;
|
||||
|
||||
obj["predictionSpeed"] = t.speedMps;
|
||||
obj["directionSource"] = "velocity";
|
||||
|
||||
// initialPosition
|
||||
if (t.firstSeenMs > 0) {
|
||||
auto [ix, iy] = WorldToMap(map, t.initialPos.x, t.initialPos.z);
|
||||
obj["initialPosition"] = { {"x", ix}, {"y", iy} };
|
||||
} else {
|
||||
obj["initialPosition"] = nullptr;
|
||||
}
|
||||
|
||||
// finalPosition
|
||||
if (t.isCompleted) {
|
||||
auto [fx, fy] = WorldToMap(map, t.finalPos.x, t.finalPos.z);
|
||||
obj["finalPosition"] = { {"x", fx}, {"y", fy} };
|
||||
} else {
|
||||
obj["finalPosition"] = nullptr;
|
||||
}
|
||||
|
||||
// initialDirection (3-D, world space)
|
||||
obj["initialDirection"] = {
|
||||
{"x", t.initialDir.x},
|
||||
{"y", t.initialDir.y},
|
||||
{"z", t.initialDir.z}
|
||||
};
|
||||
|
||||
// lastDirection
|
||||
obj["lastDirection"] = {
|
||||
{"x", t.lastDir.x},
|
||||
{"y", t.lastDir.y},
|
||||
{"z", t.lastDir.z}
|
||||
};
|
||||
|
||||
// Path (map-projected 2-D)
|
||||
json path = json::array();
|
||||
for (const auto& [ts, pos] : t.points) {
|
||||
auto [px, py] = WorldToMap(map, pos.x, pos.z);
|
||||
path.push_back({ {"x", px}, {"y", py} });
|
||||
}
|
||||
obj["path"] = std::move(path);
|
||||
|
||||
// Predicted path: 20 points at 15 m intervals, up to 300 m.
|
||||
json predictedPath = json::array();
|
||||
bool doPredict = !t.isCompleted && t.speedMps >= 1.0f;
|
||||
if (doPredict) {
|
||||
Vector3 cur = t.currentPos;
|
||||
float step = 15.0f;
|
||||
int nPts = 20;
|
||||
for (int i = 1; i <= nPts; ++i) {
|
||||
float dist = step * static_cast<float>(i);
|
||||
if (dist > 300.0f) break;
|
||||
float px = cur.x + t.lastDir.x * dist;
|
||||
float py_val = cur.y + t.lastDir.y * dist;
|
||||
float pz = cur.z + t.lastDir.z * dist;
|
||||
(void)py_val;
|
||||
auto [mx, my] = WorldToMap(map, px, pz);
|
||||
predictedPath.push_back({ {"x", mx}, {"y", my} });
|
||||
}
|
||||
}
|
||||
obj["predictedPath"] = std::move(predictedPath);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSnapshotService::BuildStateJson
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string WebSnapshotService::BuildStateJson(const RuntimeUpdate& update,
|
||||
const std::vector<BulletTrack>& bullets,
|
||||
const MapInfo* map,
|
||||
int64_t nowMs)
|
||||
{
|
||||
(void)nowMs;
|
||||
int64_t seq = m_seq.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
int mapSize = map ? map->worldSize : 15360;
|
||||
int tileCount = (mapSize + 511) / 512;
|
||||
|
||||
json j;
|
||||
j["cacheRefreshSequence"] = seq;
|
||||
j["fastCacheRefreshSequence"] = seq;
|
||||
j["slowCacheRefreshSequence"] = seq;
|
||||
|
||||
j["mapId"] = map ? map->id : "";
|
||||
j["mapName"] = map ? map->name : "";
|
||||
j["mapSize"] = mapSize;
|
||||
j["tileSize"] = 512;
|
||||
j["tileCountX"] = tileCount;
|
||||
j["tileCountY"] = tileCount;
|
||||
|
||||
j["serverMapName"] = update.serverMapName.value_or("");
|
||||
j["serverName"] = update.serverName.value_or("");
|
||||
j["gameVersion"] = update.gameVersion.value_or("");
|
||||
|
||||
j["isAttached"] = update.areBaseObjectsReady;
|
||||
j["isUsingMockData"] = false;
|
||||
j["status"] = update.status;
|
||||
|
||||
bool hasLocal = update.localPlayerPosition.has_value();
|
||||
j["hasLocalPlayer"] = hasLocal;
|
||||
|
||||
if (hasLocal) {
|
||||
const Vector3& lp = *update.localPlayerPosition;
|
||||
auto [lx, ly] = WorldToMap(map, lp.x, lp.z);
|
||||
j["localPlayer"] = {
|
||||
{ "x", lx },
|
||||
{ "y", ly },
|
||||
{ "rotation", update.localPlayerLookDirection.value_or(0.0f) },
|
||||
{ "cameraRotation", 0.0 }
|
||||
};
|
||||
} else {
|
||||
j["localPlayer"] = nullptr;
|
||||
}
|
||||
|
||||
// playerList
|
||||
json playerList = json::array();
|
||||
for (const auto& p : update.players)
|
||||
playerList.push_back(PlayerListEntryJson(p));
|
||||
j["playerList"] = std::move(playerList);
|
||||
|
||||
// players
|
||||
json players = json::array();
|
||||
for (const auto& p : update.players)
|
||||
players.push_back(PlayerEntityJson(p, map));
|
||||
j["players"] = std::move(players);
|
||||
|
||||
// zombies
|
||||
json zombies = json::array();
|
||||
for (const auto& z : update.zombies)
|
||||
zombies.push_back(ZombieEntityJson(z, map));
|
||||
j["zombies"] = std::move(zombies);
|
||||
|
||||
// animals
|
||||
json animals = json::array();
|
||||
for (const auto& a : update.animals)
|
||||
animals.push_back(AnimalEntityJson(a, map));
|
||||
j["animals"] = std::move(animals);
|
||||
|
||||
// vehicles
|
||||
json vehicles = json::array();
|
||||
for (const auto& v : update.carsAndBoats)
|
||||
vehicles.push_back(VehicleEntityJson(v, map));
|
||||
j["vehicles"] = std::move(vehicles);
|
||||
|
||||
// otherEntities
|
||||
json others = json::array();
|
||||
for (const auto& e : update.otherEntities)
|
||||
others.push_back(OtherEntityJson(e, map));
|
||||
j["otherEntities"] = std::move(others);
|
||||
|
||||
// loot
|
||||
json loot = json::array();
|
||||
for (const auto& item : update.items)
|
||||
loot.push_back(LootEntityJson(item, map));
|
||||
j["loot"] = std::move(loot);
|
||||
|
||||
// bullets
|
||||
json bulletArr = json::array();
|
||||
for (const auto& t : bullets)
|
||||
bulletArr.push_back(BulletJson(t, map));
|
||||
j["bullets"] = std::move(bulletArr);
|
||||
|
||||
return j.dump();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSnapshotService::BuildBootstrapJson
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::string WebSnapshotService::BuildBootstrapJson(const RuntimeUpdate& update,
|
||||
const MapInfo* map,
|
||||
int port)
|
||||
{
|
||||
int mapSize = map ? map->worldSize : 15360;
|
||||
int tileCount = (mapSize + 511) / 512;
|
||||
|
||||
json j;
|
||||
j["mapId"] = map ? map->id : "";
|
||||
j["mapName"] = map ? map->name : "";
|
||||
j["serverMapName"] = update.serverMapName.value_or("");
|
||||
j["mapSize"] = mapSize;
|
||||
j["tileSize"] = 512;
|
||||
j["tileCountX"] = tileCount;
|
||||
j["tileCountY"] = tileCount;
|
||||
|
||||
j["server"] = { { "port", port } };
|
||||
|
||||
// All known maps.
|
||||
int mapCount = 0;
|
||||
const MapInfo* allMaps = MapRegistry::AllMaps(mapCount);
|
||||
|
||||
json mapsArr = json::array();
|
||||
for (int i = 0; i < mapCount; ++i) {
|
||||
const MapInfo& m = allMaps[i];
|
||||
mapsArr.push_back({
|
||||
{ "id", m.id },
|
||||
{ "name", m.name },
|
||||
{ "worldSize", m.worldSize },
|
||||
{ "index", m.index },
|
||||
{ "hasImage", MapRegistry::HasMapImage(m) }
|
||||
});
|
||||
}
|
||||
j["maps"] = std::move(mapsArr);
|
||||
|
||||
// Entity filter presets (fixed).
|
||||
json filters = json::array({
|
||||
{ {"key","player"}, {"label","Players"}, {"kind","player"}, {"color","#ff8800"} },
|
||||
{ {"key","zombie"}, {"label","Zombies"}, {"kind","zombie"}, {"color","#00cc44"} },
|
||||
{ {"key","animal"}, {"label","Animals"}, {"kind","animal"}, {"color","#44aaff"} },
|
||||
{ {"key","vehicle"}, {"label","Vehicles"}, {"kind","vehicle"}, {"color","#aa44ff"} },
|
||||
{ {"key","bullet"}, {"label","Bullets"}, {"kind","bullet"}, {"color","#ffff00"} },
|
||||
});
|
||||
|
||||
// Per-category loot filters — built from item_filters.json so that
|
||||
// app.js can map item.lootCategory → the correct colour/label.
|
||||
for (const auto& def : m_catalog.GetFilters()) {
|
||||
json f;
|
||||
f["key"] = "loot_" + def.key; // unique filter key
|
||||
f["label"] = def.description.empty() ? def.key : def.description;
|
||||
f["kind"] = "loot";
|
||||
f["category"] = def.key; // must match item.lootCategory
|
||||
f["color"] = def.color.value_or("#ffffff");
|
||||
filters.push_back(std::move(f));
|
||||
}
|
||||
|
||||
// Fallback "other" loot bucket for items that don't match any filter.
|
||||
filters.push_back({
|
||||
{"key", "loot_other"},
|
||||
{"label", "Other"},
|
||||
{"kind", "loot"},
|
||||
{"category", ItemFilterCatalog::kFallbackKey},
|
||||
{"color", "#888888"}
|
||||
});
|
||||
|
||||
j["filters"] = std::move(filters);
|
||||
|
||||
return j.dump();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Web/BulletTrackCache.h"
|
||||
#include "Web/MapRegistry.h"
|
||||
#include "Readers/ItemFilterCatalog.h"
|
||||
#include "Runtime/DayZRuntimeService.h"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSnapshotService
|
||||
// Converts RuntimeUpdate snapshots into JSON strings consumed by the
|
||||
// browser radar client.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
class WebSnapshotService {
|
||||
public:
|
||||
/// Build the main state JSON sent on every SSE push / polling request.
|
||||
std::string BuildStateJson(const RuntimeUpdate& update,
|
||||
const std::vector<BulletTrack>& bullets,
|
||||
const MapInfo* map,
|
||||
int64_t nowMs);
|
||||
|
||||
/// Build the one-time bootstrap JSON (map list, filter presets, server info).
|
||||
std::string BuildBootstrapJson(const RuntimeUpdate& update,
|
||||
const MapInfo* map,
|
||||
int port);
|
||||
|
||||
private:
|
||||
std::atomic<int64_t> m_seq{0};
|
||||
ItemFilterCatalog m_catalog; // loaded from config/item_filters.json
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// Single-translation-unit stb implementation.
|
||||
// All other TUs must include stb_image / stb_image_write WITHOUT defining
|
||||
// the implementation macros.
|
||||
|
||||
#ifdef _MSC_VER
|
||||
# pragma warning(push)
|
||||
# pragma warning(disable: 4244 4996 4100)
|
||||
#endif
|
||||
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#define STBI_ONLY_PNG
|
||||
#define STBI_ONLY_JPEG
|
||||
|
||||
#include <stb_image.h>
|
||||
#include <stb_image_write.h>
|
||||
|
||||
#ifdef _MSC_VER
|
||||
# pragma warning(pop)
|
||||
#endif
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
|
||||
#include <imgui.h>
|
||||
#include "Config.h"
|
||||
#include "Logger.h"
|
||||
#include "Overlay/GameOverlay.h"
|
||||
#include "Overlay/OverlayWindow.h"
|
||||
#include "Runtime/DayZRuntimeService.h"
|
||||
#include "Web/WebRadarServer.h"
|
||||
|
||||
// Shared stop flag so the console Ctrl+C / close handler can reach it.
|
||||
static std::atomic<bool>* g_stopFlag = nullptr;
|
||||
|
||||
static BOOL WINAPI ConsoleCtrlHandler(DWORD signal) {
|
||||
if (signal == CTRL_C_EVENT || signal == CTRL_CLOSE_EVENT ||
|
||||
signal == CTRL_BREAK_EVENT)
|
||||
{
|
||||
if (g_stopFlag) g_stopFlag->store(true);
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::filesystem::create_directories("logs");
|
||||
AppLogger::Init();
|
||||
auto log = AppLogger::Get();
|
||||
log->info("DayZ Memory Reader - Starting");
|
||||
|
||||
RuntimeConfig rtCfg = RuntimeConfig::Load("config/config.cfg");
|
||||
DayZRuntimeService service(rtCfg);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Config — loaded from config/overlay.json (defaults if missing).
|
||||
// -------------------------------------------------------------------------
|
||||
static const std::string kCfgPath = "config/overlay.json";
|
||||
OverlayConfig cfg = OverlayConfig::Load(kCfgPath);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Web radar server — settings come from config.
|
||||
// -------------------------------------------------------------------------
|
||||
WebRadarConfig webCfg;
|
||||
webCfg.bindAddress = cfg.webBindAddress;
|
||||
webCfg.port = cfg.webPort;
|
||||
webCfg.password = cfg.webPassword;
|
||||
|
||||
WebRadarServer webRadar(webCfg);
|
||||
webRadar.Start();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime service callback: log stats + push snapshot to web radar.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
service.SetUpdateCallback([&log, &webRadar](const RuntimeUpdate& update) {
|
||||
// Always push to the web radar (even when not yet attached) so the
|
||||
// browser client sees connection/status changes immediately.
|
||||
webRadar.PushSnapshot(update);
|
||||
|
||||
if (!update.areBaseObjectsReady) {
|
||||
// Status messages are already de-duplicated inside WaitForSession.
|
||||
return;
|
||||
}
|
||||
|
||||
// Periodic heartbeat at most once every 10 seconds. Kept terse — the
|
||||
// interesting per-event detail (admins detected, skeletons lost) is logged
|
||||
// as it happens by the runtime service, not polled here.
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static auto nextLog = Clock::time_point{};
|
||||
auto now = Clock::now();
|
||||
if (now < nextLog) return;
|
||||
nextLog = now + std::chrono::seconds(10);
|
||||
|
||||
// Skeleton coverage: how many in-range players currently have valid bones.
|
||||
size_t skelOk = 0, admins = 0;
|
||||
for (const auto& p : update.players) {
|
||||
if (p.skeleton.valid) ++skelOk;
|
||||
if (p.isAdmin) ++admins;
|
||||
}
|
||||
|
||||
log->info("[Live] Players={} (bones {}/{}, admins {}) Zombies={} Vehicles={} Server='{}'",
|
||||
update.players.size(),
|
||||
skelOk, update.players.size(), admins,
|
||||
update.zombies.size(),
|
||||
update.carsAndBoats.size(),
|
||||
update.serverName.value_or("?"));
|
||||
});
|
||||
|
||||
service.Start();
|
||||
|
||||
// Overlay polls GetLatestUpdate() each frame; no extra callback needed.
|
||||
std::atomic<bool> stopFlag{false};
|
||||
g_stopFlag = &stopFlag;
|
||||
|
||||
GameOverlay overlay(service, cfg, kCfgPath);
|
||||
overlay.SetWebRadarPort(webCfg.port);
|
||||
overlay.SetExitCallback([&stopFlag]() { stopFlag.store(true); });
|
||||
OverlayWindow window;
|
||||
window.SetResolutionOverride(cfg.overlayWidth, cfg.overlayHeight);
|
||||
overlay.SetResizeCallback([&window](int w, int h) { window.ResizeTo(w, h); });
|
||||
|
||||
window.SetDrawCallback([&overlay](float w, float h) {
|
||||
overlay.Draw(w, h);
|
||||
});
|
||||
window.SetInputQueryCallback([&overlay]() {
|
||||
return overlay.IsMenuOpen();
|
||||
});
|
||||
window.SetFontReadyCallback([&overlay](ImFont* close, ImFont* lootFar) {
|
||||
overlay.SetLootFonts(close, lootFar);
|
||||
});
|
||||
|
||||
SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE);
|
||||
|
||||
// Blocks until the overlay window is closed or stopFlag is set externally.
|
||||
window.Run(stopFlag);
|
||||
|
||||
webRadar.Stop();
|
||||
service.Stop();
|
||||
log->info("Stopped.");
|
||||
spdlog::shutdown();
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user