diff --git a/CLAUDE.md b/CLAUDE.md index e592f86..bfb8d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/src/framework/components/Framework.cpp b/src/framework/components/Framework.cpp index 6fb9440..3ead6cd 100644 --- a/src/framework/components/Framework.cpp +++ b/src/framework/components/Framework.cpp @@ -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" @@ -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()); @@ -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(); diff --git a/src/framework/components/gameloop/GameLoop.cpp b/src/framework/components/gameloop/GameLoop.cpp index dad0c91..d1b9d45 100644 --- a/src/framework/components/gameloop/GameLoop.cpp +++ b/src/framework/components/gameloop/GameLoop.cpp @@ -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" @@ -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; diff --git a/src/framework/components/update/UpdateChecker.cpp b/src/framework/components/update/UpdateChecker.cpp new file mode 100644 index 0000000..ac02b3b --- /dev/null +++ b/src/framework/components/update/UpdateChecker.cpp @@ -0,0 +1,257 @@ +#include "components/update/UpdateChecker.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// 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 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(); + + 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 diff --git a/src/framework/components/update/UpdateChecker.h b/src/framework/components/update/UpdateChecker.h new file mode 100644 index 0000000..d2a19e2 --- /dev/null +++ b/src/framework/components/update/UpdateChecker.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// NOLINTBEGIN(readability-identifier-naming) - spdlog::logger is upstream API naming +namespace spdlog { +class logger; +} // namespace spdlog +// NOLINTEND(readability-identifier-naming) + +namespace d2bs::framework::update { + +// Background poller that checks the project's GitHub releases on a fixed +// interval and flags when a published release is newer than the running build +// (D2BS_VERSION). The game loop reads UpdateAvailable() on game entry to surface +// a one-line notice in-game. Pre-release / dev builds (a D2BS_VERSION carrying a +// -suffix) are not released versions and opt out entirely - they never start the +// poll. The HTTP request + JSON parse run on a dedicated jthread (never the game +// thread or a V8 isolate thread); UpdateAvailable() is a lock-free atomic read +// safe to call from any thread. +class UpdateChecker { + public: + static UpdateChecker& Instance(); + + // Start the polling thread. Idempotent - a second call while running is a + // no-op, as is a call on a pre-release / dev build (D2BS_VERSION with a + // suffix), which opts out of checking. The first check runs after a short + // settle delay, then every 6h. + void Start(); + + // Request the polling thread to stop and join it. Idempotent. May block + // briefly (up to the in-flight request's timeout) if a check is mid-flight. + void Stop(); + + // Whether the most recent successful check found a release newer than this + // build. Recomputed every poll (not a permanent latch), so it can clear + // again if a later poll sees an equal/older release. Lock-free; safe to call + // from the game thread. + bool UpdateAvailable() const { return updateAvailable_.load(std::memory_order_acquire); } + + // The one-line ASCII notice to show in-game (e.g. "d2bsng 2.1.0 is + // available (running 2.0.0)"). Empty when no update has been found. No URL + // by design - the notice only signals that an update exists. + std::string Message() const; + + UpdateChecker(const UpdateChecker&) = delete; + UpdateChecker& operator=(const UpdateChecker&) = delete; + UpdateChecker(UpdateChecker&&) = delete; + UpdateChecker& operator=(UpdateChecker&&) = delete; + + private: + UpdateChecker(); + ~UpdateChecker(); + + // Polling loop body: an initial settle delay, then CheckOnce() every + // interval. Both waits are interruptible via the stop_token. + void Run(const std::stop_token& stopToken); + + // Perform one poll: GET the releases API, parse the latest tag, compare it + // against D2BS_VERSION, and update updateAvailable_ / latestVersion_. + // Returns true if the check completed (whether or not an update was found), + // false on any network / parse failure. + bool CheckOnce(); + + inline static std::shared_ptr logger_; + + mutable std::mutex mutex_; // guards latestVersion_ + the cv wait + std::condition_variable_any cv_; // woken by the stop_token on Stop() + std::atomic started_{false}; // Start() latch (idempotency) + std::atomic updateAvailable_{false}; + std::string latestVersion_; // newest tag observed (no leading 'v'); guarded by mutex_ + // Declared last so it is destroyed first - ~jthread requests stop + joins + // while mutex_/cv_ are still alive for the loop's final wakeup. + std::jthread thread_; +}; + +} // namespace d2bs::framework::update diff --git a/src/framework/framework.vcxproj b/src/framework/framework.vcxproj index 8b45e3c..10387ba 100644 --- a/src/framework/framework.vcxproj +++ b/src/framework/framework.vcxproj @@ -160,6 +160,8 @@ + + @@ -278,6 +280,8 @@ + + diff --git a/tests/framework/fakes/GameLoopCollaborators.cpp b/tests/framework/fakes/GameLoopCollaborators.cpp index eb29257..12c63ed 100644 --- a/tests/framework/fakes/GameLoopCollaborators.cpp +++ b/tests/framework/fakes/GameLoopCollaborators.cpp @@ -5,6 +5,7 @@ #include "components/drawing/Drawable.h" #include "components/events/EventDispatch.h" #include "components/script/ScriptEngine.h" +#include "components/update/UpdateChecker.h" #include "game/GameHelpers.h" #include "game/Unit.h" @@ -104,5 +105,28 @@ void CharacterState::OnTick(d2bs::game::GameState /*state*/, bool /*sessionEnter } // namespace d2bs::framework::characterstate +// === UpdateChecker (shim) === +// The real component pulls in nlohmann-json + the HTTP engine (not in the +// test's dependency set). GameLoop only reads Instance()/UpdateAvailable()/ +// Message(); UpdateAvailable() is inline in the header and a default-constructed +// instance reports no update, so the hook never fires and Message() returns +// empty. No polling thread is ever started in tests. +namespace d2bs::framework::update { + +UpdateChecker::UpdateChecker() = default; +UpdateChecker::~UpdateChecker() = default; + +UpdateChecker& UpdateChecker::Instance() { + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - matches real singleton shape + static UpdateChecker instance; + return instance; +} + +std::string UpdateChecker::Message() const { + return {}; +} + +} // namespace d2bs::framework::update + // GetGameState / IsTownByLevelNo / ExitGame are defined in // fakes/GameHelpers.cpp and route through d2bs::test::State().