Skip to content
Draft
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: 2 additions & 0 deletions GWToolboxdll/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ file(GLOB SOURCES CONFIGURE_DEPENDS
"Windows/*.cpp"
"Windows/Hotkeys/*.h"
"Windows/Hotkeys/*.cpp"
"Windows/Splits/*.h"
"Windows/Splits/*.cpp"
"Windows/Pathfinding/*.h"
"Windows/Pathfinding/*.hpp"
"Windows/Pathfinding/*.cpp"
Expand Down
20 changes: 20 additions & 0 deletions GWToolboxdll/Modules/ChatCommands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,7 @@ void ChatCommands::Initialize()
{L"xunlai", CmdChest},
{L"useskill", CmdUseSkill},
{L"scwiki", CmdSCWiki},
{L"wiki", CmdWiki},
{L"load", CmdLoad},
{L"pingbuild", CmdPingBuild},
{L"quest", CmdPingQuest},
Expand Down Expand Up @@ -2706,6 +2707,25 @@ void CHAT_CMD_FUNC(ChatCommands::CmdSCWiki)
}
}

// Opens a page on the official wiki via the client's own Game_integration link
// handler (kOpenWikiUrl) rather than ShellExecute — same mechanism the skill
// listing's "Wiki" button uses. Multiple args are joined with '_' so a page
// title typed with real spaces (MediaWiki convention) still resolves.
void CHAT_CMD_FUNC(ChatCommands::CmdWiki)
{
std::string page = "Main_Page";
if (argc > 1) {
page.clear();
for (int i = 1; i < argc; i++) {
if (i > 1) page += "_";
page += TextUtils::WStringToString(argv[i]);
}
}
char url[256];
snprintf(url, _countof(url), "https://wiki.guildwars.com/wiki/%s", page.c_str());
GW::UI::SendUIMessage(GW::UI::UIMessage::kOpenWikiUrl, url);
}

void CHAT_CMD_FUNC(ChatCommands::CmdLoad)
{
if (argc == 1) return;
Expand Down
1 change: 1 addition & 0 deletions GWToolboxdll/Modules/ChatCommands.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class ChatCommands : public ToolboxModule {
static void CHAT_CMD_FUNC(CmdToggle);
static void CHAT_CMD_FUNC(CmdCamera);
static void CHAT_CMD_FUNC(CmdSCWiki);
static void CHAT_CMD_FUNC(CmdWiki);
static void CHAT_CMD_FUNC(CmdLoad);
static void CHAT_CMD_FUNC(CmdPingBuild);
static void CHAT_CMD_FUNC(CmdResize);
Expand Down
4 changes: 4 additions & 0 deletions GWToolboxdll/Modules/ToolboxSettings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
#include <Modules/TextToSpeechModule.h>
#include <Modules/ToastNotifications.h>
#include <Modules/VendorFix.h>
#include <Modules/WebSocketModule.h>
#include <Widgets/VanquishMapOverlayWidget.h>
#include <Windows/AccountInventoryWindow.h>
#include <Windows/ArmoryWindow.h>
Expand All @@ -75,6 +76,7 @@
#include <Windows/Pathfinding/PathfindingWindow.h>
#include <Windows/PconsWindow.h>
#include <Windows/RerollWindow.h>
#include <Windows/SplitsWindow.h>
#include <Windows/TradeWindow.h>
#include <Windows/TravelWindow.h>

Expand Down Expand Up @@ -239,7 +241,9 @@ namespace {
MaterialsWindow::Instance(),
TradeWindow::Instance(),
NotePadWindow::Instance(),
WebSocketModule::Instance(),
ObjectiveTimerWindow::Instance(),
SplitsWindow::Instance(),
FactionLeaderboardWindow::Instance(),
DailyQuests::Instance(),
FriendListWindow::Instance(),
Expand Down
157 changes: 157 additions & 0 deletions GWToolboxdll/Modules/WebSocketModule.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#include "stdafx.h"
#include "WebSocketModule.h"

#include <Logger.h>
#include <ToolboxIni.h>
#include <Utils/SettingsDoc.h>

void WebSocketModule::Initialize()
{
ToolboxModule::Initialize();
}

void WebSocketModule::Terminate()
{
EnableServer(false);
ToolboxModule::Terminate();
}

void WebSocketModule::RegisterSettingsContent()
{
// No section of our own — "Splits" already registers its own icon, so pass nullptr rather than risk the icon-mismatch assert.
ToolboxModule::RegisterSettingsContent(
"Splits", nullptr,
[this](const std::string&, const bool is_showing) {
if (is_showing) DrawSettings();
},
0.1f);
}

void WebSocketModule::LoadSettings(SettingsDoc& doc, ToolboxIni*)
{
auto stored_mode = static_cast<int>(mode_);
doc.Get(Name(), "mode", stored_mode);
if (stored_mode < 0 || stored_mode >= static_cast<int>(Mode::Count))
stored_mode = static_cast<int>(Mode::None);
mode_ = static_cast<Mode>(stored_mode);
doc.Get(Name(), "port", port_);
if (port_ <= 0) port_ = 9002;
EnableServer(mode_ != Mode::None);
}

void WebSocketModule::SaveSettings(SettingsDoc& doc)
{
doc.Set(Name(), "mode", static_cast<int>(mode_));
doc.Set(Name(), "port", port_);
}

void WebSocketModule::EnableServer(const bool enable)
{
port_ = std::max(port_, 0);
if (!enable) {
uWS::App* app = app_.exchange(nullptr);
uWS::Loop* loop = loop_.exchange(nullptr);
if (app && loop) {
// close()/delete must run on the loop's own thread — defer() is uWS's documented
// thread-safe handoff. This is what lets run() return so the thread can be joined below.
loop->defer([app]() {
app->close();
delete app;
});
}
if (server_thread_) {
ASSERT(server_thread_->joinable());
server_thread_->join();
delete server_thread_;
server_thread_ = nullptr;
}
return;
}

if (server_thread_) return;
EnableServer(false);
const int port = port_;
server_thread_ = new std::thread([this, port]() {
// The app/loop need to be created in the thread that handles the websocket connections.
uWS::App* app = new uWS::App();
loop_ = uWS::Loop::get();
app_ = app;
app->ws<int>(
"/*",
{/* Settings */
.compression = uWS::SHARED_COMPRESSOR,
.maxPayloadLength = 16 * 1024,
.idleTimeout = 10,
.maxBackpressure = 1 * 1024 * 1024,
.sendPingsAutomatically = true,
/* Handlers */
.upgrade = nullptr,
.open = [](auto ws) { ws->subscribe("objective_events"); }}
)
.listen(port,
[port](auto* listen_socket) {
if (listen_socket) {
Log::Log("WebSocketModule listening on port %d", port);
}
})
.run();
});
}

void WebSocketModule::Send(const std::string_view msg, const std::string_view context)
{
if (!context.empty()) last_command_ = std::string(context);
uWS::App* app = app_;
uWS::Loop* loop = loop_;
if (!app || !loop) return;
// publish() isn't safe to call off the loop's own thread — defer() marshals it over rather
// than calling app->publish() directly here on the main/game thread (this used to race with
// server_thread_; same issue OT's own inline version has always had, now fixed here).
std::string payload = mode_ == Mode::LiveSplitOneJSON
? "{\"command\": \"" + std::string(msg) + "\"}"
: std::string(msg);
loop->defer([app, payload = std::move(payload)]() {
app->publish("objective_events", payload, uWS::OpCode::TEXT);
});
}

void WebSocketModule::DrawSettings()
{
ImGui::Separator();
bool enabled = mode_ != Mode::None;
if (ImGui::Checkbox("Enable LiveSplit websocket server", &enabled)) {
mode_ = enabled ? Mode::LiveSplitOneJSON : Mode::None;
EnableServer(enabled);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Broadcasts Start/Split/Reset commands to a connected LiveSplit instance.\n"
"Independent of ObjectiveTimerWindow's own websocket server (different port) —\n"
"safe to run both, or use this one alone once you no longer need OT.");
}
if (enabled) {
ImGui::Indent();
if (ImGui::InputInt("Websocket server port", &port_)) {
EnableServer(false);
EnableServer(enabled);
}
ImGui::Text("Status: %s", app_ && server_thread_ ? "Running" : "Stopped");
if (app_ && server_thread_) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "(Port %d)", port_);
}
if (ImGui::SmallButton("Restart")) {
EnableServer(false);
EnableServer(enabled);
}
ImGui::RadioButton("LiveSplit One JSON Format", reinterpret_cast<int*>(&mode_), static_cast<int>(Mode::LiveSplitOneJSON));
ImGui::RadioButton("LiveSplit Server Command Format", reinterpret_cast<int*>(&mode_), static_cast<int>(Mode::LiveSplitServerCommand));
// Safe: Mode is explicitly int-backed (see enum declaration), so this isn't punning across sizes/signedness.
ImGui::Spacing();
if (last_command_.empty())
ImGui::TextDisabled("Last command: none");
else
ImGui::Text("Last command: %s", last_command_.c_str());
ImGui::Unindent();
}
}
57 changes: 57 additions & 0 deletions GWToolboxdll/Modules/WebSocketModule.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#pragma once

#include <atomic>
#include <string>
#include <string_view>
#include <thread>

#include <uWebsockets/App.h>

#include <ToolboxModule.h>

// ---------------------------------------------------------------------------
// Standalone LiveSplit-compatible websocket broadcaster.
// ---------------------------------------------------------------------------
// Split out of ObjectiveTimerWindow so Splits doesn't depend on OT; runs on its own port
// (9002, OT stays on 9001) so both can coexist until OT is eventually retired.
//
// No settings/window/widget of its own — enable/port/mode render inside Splits' own settings page instead (see RegisterSettingsContent()).
class WebSocketModule : public ToolboxModule {
WebSocketModule() = default;
~WebSocketModule() override = default;

public:
static WebSocketModule& Instance()
{
static WebSocketModule instance;
return instance;
}

[[nodiscard]] const char* Name() const override { return "WebSocket"; }
[[nodiscard]] bool HasSettings() override { return false; }

void Initialize() override;
void Terminate() override;
void RegisterSettingsContent() override;
void LoadSettings(SettingsDoc& doc, ToolboxIni* legacy) override;
void SaveSettings(SettingsDoc& doc) override;

// Broadcasts a LiveSplit command ("start"/"split"/"reset"); context is an optional reason, unused on the wire but shown in the debug log.
void Send(std::string_view msg, std::string_view context = {});

private:
enum class Mode : int { None, LiveSplitOneJSON, LiveSplitServerCommand, Count }; // int-backed so ImGui::RadioButton can bind an int* without a punning cast

void EnableServer(bool enable);
void DrawSettings(); // rendered inside Splits' settings section, see RegisterSettingsContent()

std::thread* server_thread_ = nullptr;
// app_/loop_ are written on server_thread_ at startup and read from the main thread (Send/DrawSettings/EnableServer)
// while the server thread is alive, so they're atomic; app_ methods themselves aren't thread-safe, so any actual
// call into the app (publish, close) must be marshalled onto loop_ via defer() rather than invoked directly.
std::atomic<uWS::App*> app_ = nullptr;
std::atomic<uWS::Loop*> loop_ = nullptr;
Mode mode_ = Mode::None;
int port_ = 9002;
std::string last_command_; // shown in DrawSettings() so live testing can confirm sends are happening
};
9 changes: 9 additions & 0 deletions GWToolboxdll/Utils/TextUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -808,4 +808,13 @@ namespace TextUtils {
{
return ltrim(rtrim(s, t), t);
}

bool CaseInsensitiveContains(const std::string_view haystack, const std::string_view needle)
{
if (needle.empty()) return true;
const auto it = std::search(haystack.begin(), haystack.end(),
needle.begin(), needle.end(),
[](const char a, const char b) { return tolower(static_cast<unsigned char>(a)) == tolower(static_cast<unsigned char>(b)); });
return it != haystack.end();
}
}
3 changes: 3 additions & 0 deletions GWToolboxdll/Utils/TextUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ namespace TextUtils {
std::wstring Replace(const std::wstring_view subject, const std::wstring& pattern, const std::wstring& replacement);
std::string Replace(const std::string_view subject, const std::string& pattern, const std::string& replacement);

// Case-insensitive substring search (ASCII only); empty needle always matches. Shared so filter boxes don't each reimplement the same std::search + tolower comparator.
bool CaseInsensitiveContains(std::string_view haystack, std::string_view needle);

template<typename CharT>
std::basic_string<CharT> Base64Decode(std::string_view encoded)
{
Expand Down
32 changes: 32 additions & 0 deletions GWToolboxdll/Windows/Splits/GoalClock.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#include "stdafx.h"
#include "GoalClock.h"

void GoalClock::Start() { running_ = true; }
void GoalClock::Pause() { running_ = false; }
void GoalClock::Resume() { running_ = true; }

void GoalClock::Reset()
{
running_ = false;
real_elapsed_ = 0.0;
game_elapsed_ = 0.0;
}

void GoalClock::AddRealTime(double delta)
{
if (running_ && delta > 0.0)
real_elapsed_ += delta;
}

void GoalClock::AddGameTime(double delta)
{
if (running_ && delta > 0.0)
game_elapsed_ += delta;
}

void GoalClock::Restore(double real_elapsed, double game_elapsed)
{
real_elapsed_ = real_elapsed;
game_elapsed_ = game_elapsed;
running_ = true;
}
30 changes: 30 additions & 0 deletions GWToolboxdll/Windows/Splits/GoalClock.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#pragma once

// ---------------------------------------------------------------------------
// GoalClock — two independent timers.
//
// real_time — raw wall-clock; never paused after Start().
// game_time — all pause rules applied (loading, cinematic).
// ---------------------------------------------------------------------------
class GoalClock {
public:
void Start();
void Pause();
void Resume();
void Reset();

void AddRealTime(double delta);
void AddGameTime(double delta);

// Restore clock state (crash-protection resume).
void Restore(double real_elapsed, double game_elapsed);

[[nodiscard]] bool IsRunning() const { return running_; }
[[nodiscard]] double RealTime() const { return real_elapsed_; }
[[nodiscard]] double GameTime() const { return game_elapsed_; }

private:
bool running_ = false;
double real_elapsed_ = 0.0;
double game_elapsed_ = 0.0;
};
Loading