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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ d2bsng/
│ │ │ ├── dde/ DDE service
│ │ │ ├── exits/ Level-exit finder
│ │ │ ├── profile/ Profile DTOs
│ │ │ ├── update/ GitHub-release update checker (6h poll -> in-game notice)
│ │ │ └── Framework.h/.cpp DLL lifecycle orchestrator
│ │ └── game/ Game interface headers (NO .cpp files)
│ └── lod114d/ d2bs.dll - 1.14d game implementation
Expand Down Expand Up @@ -219,7 +220,7 @@ These are the intended dependencies. A few deliberate exceptions are noted inlin
- **utils/** depends on: standard library, Windows headers, third-party libs (spdlog, stackwalker).
- **framework/game/** (interface) depends on: standard library only. NEVER on V8 or api/. NEVER on components/, with one exception: `game/Menu.h` includes `components/profile/ProfileData.h` so `Login()` can take the profile struct by const-ref rather than duplicating that DTO into the game layer.
- **framework/api/** depends on: game/ interface, components/, utils/, V8.
- **framework/components/** depends on: game/ interface, components/config/, utils/, V8. Exception: `components/script/` includes `api/` - the script engine is the JS-API composition root (it owns V8 isolate setup and registers the `api/` ClassRegistry + globals), and a few components reuse `api::v8_convert` instead of duplicating the V8 conversion helpers.
- **framework/components/** depends on: game/ interface, components/config/, utils/, V8. Exception: `components/script/` includes `api/` - the script engine is the JS-API composition root (it owns V8 isolate setup and registers the `api/` ClassRegistry + globals), and a few components reuse `api::v8_convert` instead of duplicating the V8 conversion helpers. `components/update/` similarly reuses the V8-free `api::classes::PerformHttpRequest` (`api/classes/io/HttpEngine.h`) rather than re-implementing the WinHTTP plumbing.
- **lod114d/game/** (implementation) depends on: game/ interface, utils/, and sibling port headers (imports/, hooks/, asm_thunks/). NEVER on api/ or V8. NEVER on components/, except config reads (`components/config/AppConfig.h`) and forwarding to the port-chosen console sink (`components/console/`, see "Port-chosen message sink" below).

### Key Design Decisions
Expand Down
10 changes: 10 additions & 0 deletions src/framework/components/Framework.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include "components/profile/ProfileService.h"
#include "components/script/Commands.h"
#include "components/script/ScriptEngine.h"
#include "components/update/UpdateChecker.h"
#include "game/Bridge.h"
#include "game/Console.h"
#include "game/GameCallbacks.h"
Expand Down Expand Up @@ -108,6 +109,11 @@ void Framework::DoInitialize(HMODULE hModule) {
}
});

// Best-effort background update check (polls GitHub releases every 6h;
// the game loop surfaces a notice on game entry). Independent of game
// readiness, so it can start as soon as the framework is up.
framework::update::UpdateChecker::Instance().Start();

logger_->info("d2bsng initialized");
} catch (const std::exception& ex) {
logger_->error("Framework::Initialize failed: {}", ex.what());
Expand Down Expand Up @@ -141,6 +147,10 @@ void Framework::Shutdown() {
// in-flight DDE handler call finishes against a still-valid framework.
dde::DdeService::Instance().Stop();

// Halt the background update poller (joins its thread) before the rest
// of teardown so no network work outlives the framework.
framework::update::UpdateChecker::Instance().Stop();

ScriptEngine::Instance().Shutdown();
game::RemoveHooks();
game::Bridge::Shutdown();
Expand Down
13 changes: 13 additions & 0 deletions src/framework/components/gameloop/GameLoop.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "components/script/ScriptEngine.h"
#include "components/script/ScriptTypes.h"
#include "components/speedhack/Speedhack.h"
#include "components/update/UpdateChecker.h"
#include "game/GameHelpers.h"
#include "game/GameLock.h"
#include "game/GameThread.h"
Expand Down Expand Up @@ -106,6 +107,18 @@ void GameLoop::OnSleep(std::chrono::milliseconds duration) {
characterstate::CharacterState::Instance().OnTick(cur.state, !previous_.inSession && cur.inSession);
DriveScriptLifecycle(previous_, cur);

// Surface an available update once per game entry. The checker polls GitHub
// releases on its own thread; here we only read the latched flag (lock-free)
// and print a one-line ASCII notice on the first frame of each game. No URL -
// the notice just tells the user an update exists.
if (!previous_.inSession && cur.inSession && update::UpdateChecker::Instance().UpdateAvailable()) {
// D2 in-game message color palette index (same palette as the \xFFc
// escapes; see game/Console.h). 8 = orange, an attention color distinct
// from the usual white system text.
constexpr int32_t UPDATE_NOTICE_COLOR = 8;
d2bs::game::PrintGameString(update::UpdateChecker::Instance().Message(), UPDATE_NOTICE_COLOR);
}

d2bs::game::GameThread::Drain();

previous_ = cur;
Expand Down
257 changes: 257 additions & 0 deletions src/framework/components/update/UpdateChecker.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
#include "components/update/UpdateChecker.h"

#include <charconv>
#include <chrono>
#include <compare>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>

#include <spdlog/spdlog.h>
#include <nlohmann/json.hpp>

// The V8-free HTTP engine (WinHTTP; bypasses the game's SOCKS5 detour). Reused
// here rather than duplicating the WinHTTP plumbing - this is a documented
// components -> api exception (HttpEngine.h pulls in no V8 / JS headers).
#include "api/classes/io/HttpEngine.h"
#include "components/config/Version.h"
#include "utils/threadutils.h"
#include "utils/utils.h"

namespace d2bs::framework::update {

namespace {

using json = nlohmann::json; // NOLINT(readability-identifier-naming) - nlohmann's conventional alias spelling

constexpr auto CHECK_INTERVAL = std::chrono::hours{6};
// Short settle delay before the first check so it doesn't pile onto the heavy
// DLL-load / framework-init work.
constexpr auto INITIAL_DELAY = std::chrono::seconds{30};

// Hardcoded: the canonical d2bsng releases endpoint. `releases/latest` returns
// the newest non-draft, non-prerelease release (404 when none exist yet).
constexpr std::string_view RELEASES_API_URL = "https://api.github.com/repos/ResurrectedTrader/d2bsng/releases/latest";

// A comparable major.minor.patch triple. The defaulted operator<=> gives the
// natural precedence (major, then minor, then patch).
struct SemVer {
uint32_t major = 0;
uint32_t minor = 0;
uint32_t patch = 0;
auto operator<=>(const SemVer&) const = default;
};

// Drop a single leading 'v' / 'V' version-tag prefix (GitHub tags read "v2.1.0";
// the value we compare and display is "2.1.0"). Returns a view into `tag`.
std::string_view StripTagPrefix(std::string_view tag) {
if (!tag.empty() && (tag.front() == 'v' || tag.front() == 'V')) {
tag.remove_prefix(1);
}
return tag;
}

// Parse the leading "major.minor.patch" of a version string. Tolerates a
// leading 'v' and ignores any pre-release / build suffix ("-dev", "+meta"), so
// "v2.1.0" and "2.1.0-dev" both parse. Missing trailing components default to
// 0. Returns nullopt only when there is no leading numeric component at all.
std::optional<SemVer> ParseSemVer(std::string_view s) {
s = StripTagPrefix(s);
// Keep only the leading run of digits and dots ("2.1.0-dev" -> "2.1.0").
size_t end = 0;
while (end < s.size() && ((s[end] >= '0' && s[end] <= '9') || s[end] == '.')) {
++end;
}
s = s.substr(0, end);
if (s.empty() || s.front() < '0' || s.front() > '9') {
return std::nullopt;
}

SemVer out;
size_t field = 0;
size_t start = 0;
while (start <= s.size() && field < 3) {
const size_t dot = s.find('.', start);
const size_t len = (dot == std::string_view::npos) ? std::string_view::npos : dot - start;
const std::string_view part = s.substr(start, len);
uint32_t value = 0;
if (!part.empty()) {
const auto* first = part.data();
const auto* last = part.data() + part.size();
if (std::from_chars(first, last, value).ec != std::errc{}) {
return std::nullopt;
}
}
switch (field) {
case 0:
out.major = value;
break;
case 1:
out.minor = value;
break;
default:
out.patch = value;
break;
}
++field;
if (dot == std::string_view::npos) {
break;
}
start = dot + 1;
}
return out;
}

// True if `version` carries anything beyond a numeric "major[.minor[.patch]]"
// core - a pre-release ("-dev") or build-metadata ("+sha") suffix. Such builds
// are not released versions, so they opt out of update checking entirely.
bool HasVersionSuffix(std::string_view version) {
version = StripTagPrefix(version);
for (char c : version) {
if ((c < '0' || c > '9') && c != '.') {
return true;
}
}
return false;
}

} // namespace

UpdateChecker& UpdateChecker::Instance() {
static UpdateChecker instance;
return instance;
}

UpdateChecker::UpdateChecker() {
logger_ = d2bs::utils::GetLogger("update");
}

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

void UpdateChecker::Start() {
// Pre-release / dev builds (D2BS_VERSION carries a -suffix or +metadata, e.g.
// the "2.0.0-dev" fallback) are not released versions, so they opt out of
// update checking entirely - no poll thread, no network traffic.
if (HasVersionSuffix(D2BS_VERSION)) {
logger_->debug("update check disabled for non-release version {}", D2BS_VERSION);
return;
}
bool expected = false;
if (!started_.compare_exchange_strong(expected, true)) {
return; // already running
}
thread_ = std::jthread([this](const std::stop_token& stopToken) { Run(stopToken); });
}

void UpdateChecker::Stop() {
if (!started_.exchange(false)) {
return; // not running
}
if (thread_.joinable()) {
thread_.request_stop();
// request_stop() already wakes the interruptible wait_for via its
// registered stop_token; this notify is a harmless backstop.
cv_.notify_all();
thread_.join();
}
}

std::string UpdateChecker::Message() const {
if (!updateAvailable_.load(std::memory_order_acquire)) {
return {};
}
std::lock_guard lock(mutex_);
// ASCII only (renders both in-game and in the dev console). No URL by design.
return "d2bsng " + latestVersion_ + " is available (running " D2BS_VERSION ")";
}

void UpdateChecker::Run(const std::stop_token& stopToken) {
d2bs::thread_utils::SetThreadDescription("d2bs update checker");

std::unique_lock lock(mutex_);
// Interruptible settle delay before the first check. wait_for returns the
// predicate result, so a true return means Stop() fired during the delay.
if (cv_.wait_for(lock, stopToken, INITIAL_DELAY, [&stopToken] { return stopToken.stop_requested(); })) {
return;
}

while (!stopToken.stop_requested()) {
lock.unlock();
CheckOnce();
lock.lock();
// Sleep the interval; wakes early when Stop() requests it.
cv_.wait_for(lock, stopToken, CHECK_INTERVAL, [&stopToken] { return stopToken.stop_requested(); });
}
}

bool UpdateChecker::CheckOnce() {
api::classes::HttpRequest request;
request.method = "GET";
request.url = std::string(RELEASES_API_URL);
request.headers = {
// GitHub rejects API requests without a User-Agent.
{"User-Agent", "d2bsng-update-checker"},
{"Accept", "application/vnd.github+json"},
{"X-GitHub-Api-Version", "2022-11-28"},
};
request.timeoutMs = 10000;
request.totalTimeoutMs = 15000;

api::classes::HttpResponse response;
const std::string error = api::classes::PerformHttpRequest(request, response);
if (!error.empty()) {
logger_->debug("update check: request failed ({})", error);
return false;
}
if (response.status != 200) {
// 404 = no releases published yet; anything else = transient. Either
// way there's nothing to flag.
logger_->debug("update check: HTTP {}", response.status);
return false;
}

const json doc =
json::parse(response.body.begin(), response.body.end(), /*cb=*/nullptr, /*allow_exceptions=*/false);
if (!doc.is_object()) {
logger_->debug("update check: response was not a JSON object");
return false;
}
const auto it = doc.find("tag_name");
if (it == doc.end() || !it->is_string()) {
logger_->debug("update check: no tag_name in response");
return false;
}
const auto tag = it->get<std::string>();

const auto latest = ParseSemVer(tag);
const auto current = ParseSemVer(D2BS_VERSION);
if (!latest) {
logger_->debug("update check: unparseable release tag '{}'", tag);
return false;
}
if (!current) {
// D2BS_VERSION is a build-baked literal; this should never happen.
logger_->debug("update check: unparseable running version '{}'", D2BS_VERSION);
return false;
}

const bool newer = *latest > *current;
if (newer) {
{
std::lock_guard lock(mutex_);
latestVersion_ = StripTagPrefix(tag);
}
logger_->info("update available: {} (running {})", tag, D2BS_VERSION);
} else {
logger_->debug("up to date: latest {} vs running {}", tag, D2BS_VERSION);
}
updateAvailable_.store(newer, std::memory_order_release);
return true;
}

} // namespace d2bs::framework::update
Loading
Loading