Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ per install; all events from one launch share a `sessionId`.
| Field | Example | Why |
|-------|---------|-----|
| `installId` | salted hash (see below) | count unique installs across launches |
| `backendVersion` | `1.14d` | which game backend / DLL variant is loaded |
| `backendVersion` | `LOD/1.14d` | which game backend and game build is running (read from the game executable's version resource) |
| `arch` | `x86` | future-proofing (x64 D2R backends) |
| `managerVersion` | `1.4.2` | *only when the manager sets `D2BOTNG_VERSION`* - which manager versions are in the field, and whether a d2bsng change can rely on a newer one |

Expand Down
24 changes: 24 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ are documentation-only - see below).
`Register("name")` calls (below).
- `Register(name)` / `Register(game::CompatibilityFlag)` add one flag; idempotent by name.
- `IsEnabled(name)` / `Has(name)` / `SetEnabled(name, on)` / `Reset()` / `All()`.
- `BypassScope` turns every flag off for the current thread while it lives (see
"Raw game data").

It lives under `core/config/` because it is configuration-shaped and
`config/` is the one component the game implementation (`lod114d`) is allowed to
Expand Down Expand Up @@ -60,6 +62,28 @@ the port queries `CompatibilityFlags::Instance().IsEnabled(...)` to act on them.
Game-version flags are registered at runtime; the statically-generated API docs
cover the framework catalog only.

## Raw game data

A flag's off state is always the game's own data: a flag only ever reshapes what
the game reports into what 1.14d scripts expect, never the other way round. Code
that needs the game's own data regardless of the script toggles reads under
`CompatibilityFlags::BypassScope`: while one is alive, `IsEnabled()` answers
false for every flag **on the thread that created it**. The stored states,
`Has()`, `All()` and `SetEnabled()` are untouched, and every other thread - the
script threads - keeps seeing the flags as set. Scopes nest (a thread-local
depth count), and are neither copyable nor movable.

The character-state capture (`components/characterstate`) reads past all flags
this way: `CharacterState::OnTick` and `RecordKill` open a scope on the game
thread, so the dump sent to the D2BotNG manager carries the game's own values;
its `backend` field tells the manager which tables the ids index. Nothing in the
capture waits on another thread, which would not inherit the scope.

The scope covers a gating site only if it reads the flag through `IsEnabled()`
while the scope is alive. A walk that reads a flag once and carries the value
through its steps reads it inside the scope when the walk itself is; a value a
backend latches across calls is not reached.

## The `Compatibility` JS object

A non-constructable namespace object (`src/runtime/api/classes/scripting/JSCompatibility.h`),
Expand Down
10 changes: 7 additions & 3 deletions docs/stash_tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,13 @@ The character-state producer (`components/characterstate`) sends the stash as
the tab's `kind`, `index`, `type`, `name` and `gold` plus the shared grid
`width` / `height` and its `items` (from `StashTab::GetItems()`, so tabs that
are not shown are included). 1.14d produces a single personal page; `name` is
the tab's name, empty when unnamed. The stash hash folds every page's identity
and contents, so a rename, a gold move or a page appearing re-sends the
container.
the tab's name, empty when unnamed. Every item on an advanced page (`type` 1)
is a slot, so its document carries its stack size as `stackCount` (its
`ItemCount()`, which for any other item counts its socket fillers); no other
item has the key, so it never appears on 1.14d. The stash hash folds every
page's identity and contents, so a rename, a gold move, a page appearing or a
stack growing or shrinking re-sends the container. The capture reads past the
compatibility flags (`docs/compatibility.md`, "Raw game data").

## Generated docs

Expand Down
13 changes: 12 additions & 1 deletion src/backends/lod114d/game/GameHelpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <chrono>
#include <cstddef>
#include <cstring>
#include <format>
#include <limits>
#include <optional>
#include <span>
Expand Down Expand Up @@ -1933,8 +1934,18 @@ std::optional<std::string> GetLaunchProfile() {
return GetLaunchOptions().profile;
}

// The running Game.exe's version, its patch letter from the build part
// ("1.14.3.71" -> "LOD/1.14d").
std::string GetBackendVersion() {
return "1.14d";
static const std::string version = [] {
constexpr uint32_t PATCH_LETTERS = 26;
const auto file = utils::GetModuleVersion(GetModuleHandleW(nullptr));
if (!file || file->build >= PATCH_LETTERS) {
return std::string{"LOD/1.14d"};
}
return std::format("LOD/{}.{}{}", file->major, file->minor, static_cast<char>('a' + file->build));
}();
return version;
}

AnalyticsLaunchOptions GetAnalyticsLaunchOptions() {
Expand Down
9 changes: 9 additions & 0 deletions src/backends/lod114d/game/Unit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,15 @@ std::vector<Unit::SkillInfo> Unit::GetAllSkills() const {
return out;
}

std::optional<Skill> Unit::LastUsedSkill() const {
const auto u = Resolve<D2UnitStrc>();
if (!u || u->pSkills == nullptr || u->pSkills->pUsedSkill == nullptr ||
u->pSkills->pUsedSkill->pSkillsTxt == nullptr) {
return std::nullopt;
}
return SkillOf(u->pSkills->pUsedSkill);
}

std::optional<uint32_t> Unit::GetSkillLevel(Skill skill, bool includeExtraLevels, std::optional<bool> charge) const {
const auto u = Resolve<D2UnitStrc>();
if (!u || u->pSkills == nullptr) {
Expand Down
8 changes: 4 additions & 4 deletions src/contract/game/GameHelpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,10 @@ void LoadMpq(const std::string& path);
// registry, or IPC. Returns nullopt when no launch profile is requested.
std::optional<std::string> GetLaunchProfile();

// Version string of the game backend this build is compiled against, e.g.
// "1.14d" for the lod114d backend. Fixed at compile time - the injected
// DLL links exactly one backend - so it identifies "which build" for
// diagnostics and analytics.
// "<backend>/<game version>" of the running game, read once from the game
// executable's version resource: e.g. "LOD/1.14d". It identifies
// "which build" for diagnostics, analytics and the character-state snapshot's
// `backend`, which the D2BotNG manager keys on: keep the format stable.
std::string GetBackendVersion();

// === Analytics ===
Expand Down
3 changes: 3 additions & 0 deletions src/contract/game/Unit.h
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ class Unit {
std::string GetSkillName(Hand hand) const;
Skill GetSkillId(Hand hand) const;
std::vector<SkillInfo> GetAllSkills() const;
// The skill the unit last used (its skill list's used-skill entry); nullopt
// when there is none. Skill::Attack is a real answer.
std::optional<Skill> LastUsedSkill() const;
// Charge filter:
// nullopt -> non-charge skills only (IsCharge == 0).
// false -> no filter (match any skill).
Expand Down
19 changes: 19 additions & 0 deletions src/core/config/CompatibilityFlags.cpp
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
#include "config/CompatibilityFlags.h"

#include <algorithm>
#include <cstdint>

namespace d2bs::core::config {

namespace {

// Live BypassScopes on this thread.
thread_local uint32_t bypassDepth = 0;

} // namespace

CompatibilityFlags::BypassScope::BypassScope() {
++bypassDepth;
}

CompatibilityFlags::BypassScope::~BypassScope() {
--bypassDepth;
}

CompatibilityFlags& CompatibilityFlags::Instance() {
static CompatibilityFlags instance;
return instance;
Expand All @@ -24,6 +40,9 @@ void CompatibilityFlags::Register(std::string_view name, bool defaultEnabled) {
}

bool CompatibilityFlags::IsEnabled(std::string_view name) const {
if (bypassDepth != 0) {
return false;
}
std::scoped_lock lock(mutex_);
for (const auto& flag : flags_) {
if (flag.name == name) {
Expand Down
19 changes: 18 additions & 1 deletion src/core/config/CompatibilityFlags.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ namespace d2bs::core::config {
// from script threads.
class CompatibilityFlags {
public:
// While one is alive, IsEnabled() answers false for every flag on the thread
// that created it, so the code it wraps reads what each flag's off state
// reads: the game's own data. Nothing else changes - the stored states,
// Has(), All() and SetEnabled() are untouched, and every other thread (the
// script threads) keeps seeing the toggles as set. Scopes nest; keep one on
// the stack so it ends on the thread that began it.
class BypassScope {
public:
BypassScope();
~BypassScope();
BypassScope(const BypassScope&) = delete;
BypassScope& operator=(const BypassScope&) = delete;
BypassScope(BypassScope&&) = delete;
BypassScope& operator=(BypassScope&&) = delete;
};

struct Flag {
std::string name;
bool defaultEnabled;
Expand All @@ -38,7 +54,8 @@ class CompatibilityFlags {
void Register(const game::CompatibilityFlag& flag);
void Register(std::string_view name, bool defaultEnabled = true);

// Whether the named flag is enabled. Unknown names return false.
// Whether the named flag is enabled. Unknown names return false, and so does
// every name while a BypassScope is alive on the calling thread.
[[nodiscard]] bool IsEnabled(std::string_view name) const;

// Whether the named flag exists in the registry.
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ void Host::DoInitialize(HMODULE hModule) {

LoadConfig();

logger_->info("d2bsng v{} initializing", D2BS_VERSION);
logger_->info("d2bsng v{} initializing ({})", D2BS_VERSION, game::GetBackendVersion());

// Bridge::Init is idempotent - the DLL entry path already invoked it
// synchronously, so this call just returns the cached success value.
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/components/characterstate/CharacterState.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "components/characterstate/Fingerprint.h"
#include "components/characterstate/UnitJson.h"
#include "config/AppConfig.h"
#include "config/CompatibilityFlags.h"
#include "game/Finders.h"
#include "game/GameHelpers.h"
#include "game/StashTab.h"
Expand Down Expand Up @@ -117,6 +118,8 @@ json BuildIdentity() {
identity["realm"] = game::GetRealmShort();
identity["difficulty"] = game::GetDifficulty();
identity["charFlags"] = std::to_underlying(game::GetCharFlags());
// The game's own type, read past the compatibility flags like the rest of the capture.
identity["gameType"] = std::to_underlying(game::GetGameType());
// hardcore/expansion are derivable from charFlags; ladder is a separate BnetData
// flag, so it stays here.
identity["ladder"] = game::IsLadder().value_or(0) != 0;
Expand Down Expand Up @@ -234,6 +237,10 @@ CharacterState& CharacterState::Instance() {
void CharacterState::OnTick(game::GameState state, bool sessionEntered) {
static_assert(BUCKET_COUNT == CONTAINER_COUNT);

// Every flag-gated read below answers the game's own data. Nothing in here waits on
// another thread, which would not inherit the per-thread scope.
const core::config::CompatibilityFlags::BypassScope rawGameData;

if (state != game::GameState::InGame) {
wasInGame_ = false;
return;
Expand Down Expand Up @@ -466,6 +473,9 @@ void CharacterState::OnTick(game::GameState state, bool sessionEntered) {
}

snapshot["schemaVersion"] = SCHEMA_VERSION;
// gameType alone does not say which data the ids index: the same game type has different
// stat ids and tables on different backends.
snapshot["backend"] = game::GetBackendVersion();
snapshot["gameId"] = gameId_;
snapshot["keyframe"] = keyframe;
// Wall-clock time the snapshot was assembled (Unix epoch ms) so the manager
Expand All @@ -484,6 +494,7 @@ void CharacterState::RecordKill(uint32_t unitId) {
// Game thread (death packet hook): the per-resolve GameReadLock no-ops because
// the frame write lock is held (see the class doc + game/GameLock.h), so this
// resolve is lock-free and consistent.
const core::config::CompatibilityFlags::BypassScope rawGameData;
const auto monster = game::Unit::Find(unitId, game::UnitType::Monster);
if (!monster) {
return; // already despawned, or not a resolvable monster
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/components/characterstate/CharacterState.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ namespace d2bs::runtime::characterstate {
// no-ops (see game/GameLock.h). No threads of its own. Self-throttles to ~1s;
// emits immediately on game entry. Sends through game::SendIPC to the manager
// handle stored on AppConfig (seeded from the "Handle" WM_COPYDATA).
//
// The manager renders with the game's own data, so every read runs under a
// CompatibilityFlags::BypassScope: no compatibility flag reshapes the dump the
// way it reshapes what scripts see (docs/compatibility.md).
class CharacterState {
public:
static CharacterState& Instance();
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/components/characterstate/UnitJson.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ void VisitItem(UnitVisitor& visitor, const game::Unit& item, Detail detail) {
// varinvgfx (rings, amulets, jewels, charms). Nothing else in the document implies
// it, so a consumer resolving the graphic itself needs it.
visitor.Int("gfxIndex", item.GfxIndex());
// Every item on an advanced stash tab is a slot holding one class's stack, and only there
// does ItemCount() read as the stack size (elsewhere it counts socket fillers). The stack
// moves only on a deposit or withdrawal, so it is structural.
if (const auto tab = item.StashTab(); tab && tab->Type() == game::StashTabType::AdvancedStash) {
visitor.Int("stackCount", item.ItemCount());
}

if (detail == Detail::Full) {
visitor.Str("title", item.Name());
Expand Down Expand Up @@ -182,6 +188,11 @@ void VisitWearer(UnitVisitor& visitor, const game::Unit& wearer) {
visitor.EndElement();
}
visitor.EndArray();

// The tooltip's throwing-mastery term keys off it. Left out when there is none; 0 is Attack.
if (const auto lastUsed = wearer.LastUsedSkill()) {
visitor.Int("lastUsedSkill", std::to_underlying(*lastUsed));
}
}

// Builds the nlohmann json document from the visit events, tracking the current insertion
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/components/characterstate/UnitJson.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ void VisitUnit(UnitVisitor& visitor, const game::Unit& unit, Detail detail);
// Every unit carries unitType and classId; the rest is chosen by type. An item adds its
// capture fields (code, quality, itemFlags, affixes, statsLists, ...) plus the fields the
// manager renders with, and recurses into its socket fillers under `sockets`, positional by
// socket index. A player or merc adds identity, skills and - for the player - area and the
// socket index. A player or merc adds identity, skills, the last used skill (lastUsedSkill,
// when there is one) and - for the player - area and the
// active weapon set; its numbers come from WearerStats instead, which carries the merged
// values the requirement checks actually compare against.
nlohmann::json UnitToJson(const game::Unit& unit, Detail detail = Detail::Full);
Expand Down
58 changes: 58 additions & 0 deletions src/utils/utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

#include <spdlog/sinks/dist_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <algorithm>
#include <array>
#include <format>
#include <mutex>
#include <string_view>
#include <vector>

#pragma comment(lib, "version.lib")
Expand All @@ -29,6 +32,58 @@ std::mutex &LoggerMutex() {
return mutex;
}

// The first language's FileVersion string ("1.14.3.71") as up to
// four numbers separated by dots or commas; anything after the last number
// (" (79b7ae8)") is ignored. nullopt when there is no such string or it holds
// fewer than two numbers.
std::optional<ModuleVersion> ParseFileVersionString(std::vector<uint8_t> &versionInfo) {
struct Translation {
WORD language;
WORD codePage;
};
Translation *translation = nullptr;
UINT translationLength = 0;
if (VerQueryValueW(versionInfo.data(), L"\\VarFileInfo\\Translation", reinterpret_cast<void **>(&translation),
&translationLength) == 0 ||
translation == nullptr || translationLength < sizeof(Translation)) {
return std::nullopt;
}
const auto key =
std::format(L"\\StringFileInfo\\{:04x}{:04x}\\FileVersion", translation->language, translation->codePage);
wchar_t *text = nullptr;
UINT textLength = 0;
if (VerQueryValueW(versionInfo.data(), key.c_str(), reinterpret_cast<void **>(&text), &textLength) == 0 ||
text == nullptr) {
return std::nullopt;
}

std::array<uint32_t, 4> parts{};
size_t count = 0;
std::wstring_view rest{text};
while (count < parts.size()) {
rest.remove_prefix(std::min(rest.find_first_not_of(L' '), rest.size()));
const auto digits = std::min(rest.find_first_not_of(L"0123456789"), rest.size());
if (digits == 0) {
break;
}
uint32_t value = 0;
for (const wchar_t digit : rest.substr(0, digits)) {
value = (value * 10) + static_cast<uint32_t>(digit - L'0');
}
parts.at(count++) = value;
rest.remove_prefix(digits);
rest.remove_prefix(std::min(rest.find_first_not_of(L' '), rest.size()));
if (rest.empty() || (rest.front() != L'.' && rest.front() != L',')) {
break;
}
rest.remove_prefix(1);
}
if (count < 2) {
return std::nullopt;
}
return ModuleVersion{.major = parts[0], .minor = parts[1], .build = parts[2], .revision = parts[3]};
}

} // namespace

std::shared_ptr<spdlog::logger> GetLogger(const std::string &name) {
Expand Down Expand Up @@ -68,6 +123,9 @@ std::optional<ModuleVersion> GetModuleVersion(HMODULE module) {
if (GetFileVersionInfoW(path.data(), 0, size, buffer.data()) == 0) {
return std::nullopt;
}
if (const auto parsed = ParseFileVersionString(buffer)) {
return parsed;
}
VS_FIXEDFILEINFO *info = nullptr;
UINT infoLength = 0;
if (VerQueryValueW(buffer.data(), L"\\", reinterpret_cast<void **>(&info), &infoLength) == 0 || info == nullptr ||
Expand Down
Loading
Loading