Skip to content
Open
3 changes: 3 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ on:
pull_request_target:
types: [opened]

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
label-ai-pr:
# Claude doesn't open PRs itself — it commits to a claude/* branch and
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/cmake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ on:
# The workflow itself
- '.github/workflows/cmake.yml'

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
build:
runs-on: windows-2025-vs2026
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pending-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
pull_request:
types: [closed]

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
label-linked-issues:
if: github.event.pull_request.merged == true
Expand Down
118 changes: 59 additions & 59 deletions .github/workflows/site-deploy.yml
Original file line number Diff line number Diff line change
@@ -1,59 +1,59 @@
name: Deploy site to GitHub Pages
on:
push:
branches:
- master
paths:
- 'site/**'
- '.github/workflows/site-deploy.yml'
# Rebuild after a release publishes so the download button picks up the new
# exe; the release-creating DLL build outlives this job, so the push above
# always captures the previous release.
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment, but cancel any in-flight run when a new push
# arrives so we don't ship stale content if two pushes land back-to-back.
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: site/package-lock.json
- run: npm ci
- run: npm run build
env:
# Higher rate limit when querying the Releases API at build time.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
name: Deploy site to GitHub Pages

on:
push:
branches:
- master
paths:
- 'site/**'
- '.github/workflows/site-deploy.yml'
# Rebuild after a release publishes so the download button picks up the new
# exe; the release-creating DLL build outlives this job, so the push above
# always captures the previous release.
release:
types: [published]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Allow one concurrent deployment, but cancel any in-flight run when a new push
# arrives so we don't ship stale content if two pushes land back-to-back.
concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: site/package-lock.json
- run: npm ci
- run: npm run build
env:
# Higher rate limit when querying the Releases API at build time.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ Gemfile.lock
GWToolbox/~*.tmp
GWToolbox.aps

# Local archive folders (deferred/future code, kept off remote)
**/_archive/

# Sublime Text stuff
*.sublime-project*
*.sublime-workspace*
Expand Down
2 changes: 2 additions & 0 deletions GWToolboxdll/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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
229 changes: 229 additions & 0 deletions GWToolboxdll/Modules/GWEventBus.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#include "stdafx.h"

#include <Modules/GWEventBus.h>

#include <GWCA/Context/GameContext.h>
#include <GWCA/Context/WorldContext.h>
#include <GWCA/GameContainers/Array.h>
#include <GWCA/GameEntities/Agent.h>
#include <GWCA/Managers/AgentMgr.h>
#include <GWCA/Managers/MapMgr.h>
#include <GWCA/Managers/StoCMgr.h>
#include <GWCA/Managers/UIMgr.h>
#include <GWCA/Packets/Opcodes.h>
#include <GWCA/Packets/StoC.h>

void GWEventBus::Subscribe(const void* owner, GWBusCallback cb)
{
for (auto& [o, fn] : subscribers_) {
if (o == owner) { fn = std::move(cb); return; }
}
subscribers_.emplace_back(owner, std::move(cb));
}

void GWEventBus::Unsubscribe(const void* owner)
{
auto it = std::find_if(subscribers_.begin(), subscribers_.end(),
[owner](const auto& p) { return p.first == owner; });
if (it != subscribers_.end()) subscribers_.erase(it);
}

void GWEventBus::Emit(const GWEvent& e) const
{
for (const auto& [_, cb] : subscribers_)
cb(e);
}

// ---------------------------------------------------------------------------
// Initialize
// ---------------------------------------------------------------------------
void GWEventBus::Initialize()
{
ToolboxModule::Initialize();

// --- UIMessage-sourced events ---

GW::UI::RegisterUIMessageCallback(
&on_mission_complete_,
GW::UI::UIMessage::kMissionComplete,
[this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) {
Emit({GWEvent::Type::MissionComplete,
static_cast<uint32_t>(GW::Map::GetMapID())});
});

// ObjectiveAdd: fires at mission start for each objective; type_flags 0x1 = bullet/sub-objective,
// 0x0 = base/primary objective. GoalEngine uses the base objective's completion to
// synthesize both MissionComplete and MissionBonus with a reliable map_id.
GW::StoC::RegisterPacketCallback<GW::Packet::StoC::ObjectiveAdd>(
&on_objective_add_,
[this](GW::HookStatus*, const GW::Packet::StoC::ObjectiveAdd* p) {
Emit({GWEvent::Type::ObjectiveAdd, p->objective_id, p->type});
});

GW::UI::RegisterUIMessageCallback(
&on_vanquish_complete_,
GW::UI::UIMessage::kVanquishComplete,
[this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) {
Emit({GWEvent::Type::VanquishComplete,
static_cast<uint32_t>(GW::Map::GetMapID())});
});

GW::UI::RegisterUIMessageCallback(
&on_party_defeated_,
GW::UI::UIMessage::kPartyDefeated,
[this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) {
Emit({GWEvent::Type::PartyDefeated});
});

// --- StoC-sourced events ---

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::ObjectiveDone>(
&on_objective_done_,
[this](GW::HookStatus*, const GW::Packet::StoC::ObjectiveDone* p) {
Emit({GWEvent::Type::ObjectiveDone, p->objective_id,
static_cast<uint32_t>(GW::Map::GetMapID())});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::ObjectiveUpdateName>(
&on_objective_started_,
[this](GW::HookStatus*, const GW::Packet::StoC::ObjectiveUpdateName* p) {
Emit({GWEvent::Type::ObjectiveStarted, p->objective_id});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::ManipulateMapObject>(
&on_door_,
[this](GW::HookStatus*, const GW::Packet::StoC::ManipulateMapObject* p) {
if (GW::Map::GetInstanceType() != GW::Constants::InstanceType::Explorable) return;
if (p->animation_type == 16 && p->animation_stage == 2)
Emit({GWEvent::Type::DoorOpen, p->object_id});
else if (p->animation_type == 3 && p->animation_stage == 2)
Emit({GWEvent::Type::DoorClose, p->object_id});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::AgentUpdateAllegiance>(
&on_agent_allegiance_,
[this](GW::HookStatus*, const GW::Packet::StoC::AgentUpdateAllegiance* p) {
const auto* agent = GW::Agents::GetAgentByID(p->agent_id);
if (!agent) return;
const auto* living = agent->GetAsAgentLiving();
if (!living) return;
Emit({GWEvent::Type::AgentUpdateAllegiance,
living->player_number, p->allegiance_bits});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::DoACompleteZone>(
&on_doa_zone_,
[this](GW::HookStatus*, const GW::Packet::StoC::DoACompleteZone* p) {
if (p->message[0] == 0x8101)
Emit({GWEvent::Type::DoACompleteZone, p->message[1]});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::DungeonReward>(
&on_dungeon_reward_,
[this](GW::HookStatus*, GW::Packet::StoC::DungeonReward*) {
Emit({GWEvent::Type::DungeonReward});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::MessageServer>(
&on_server_message_,
[this](GW::HookStatus*, GW::Packet::StoC::MessageServer*) {
const auto* buff = &GW::GetGameContext()->world->message_buff;
if (!buff || !buff->valid() || !buff->size()) return;
const wchar_t* msg = buff->begin();
GWEvent e;
e.type = GWEvent::Type::ServerMessage;
e.str = msg;
e.str_len = wcslen(msg);
Emit(e);
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::DisplayDialogue>(
&on_display_dialogue_,
[this](GW::HookStatus*, const GW::Packet::StoC::DisplayDialogue* p) {
GWEvent e;
e.type = GWEvent::Type::DisplayDialogue;
e.str = p->message;
e.str_len = wcslen(p->message);
Emit(e);
});

GW::StoC::RegisterPacketCallback(
&on_countdown_start_, GAME_SMSG_INSTANCE_COUNTDOWN,
[this](GW::HookStatus*, GW::Packet::StoC::PacketBase*) {
Emit({GWEvent::Type::CountdownStart,
static_cast<uint32_t>(GW::Map::GetMapID())});
});

// Fires for all players — subscribers filter by controlled character as needed.
GW::StoC::RegisterPacketCallback<GW::Packet::StoC::SkillActivate>(
&on_skill_activate_,
[this](GW::HookStatus*, const GW::Packet::StoC::SkillActivate* p) {
Emit({GWEvent::Type::SkillActivate, p->agent_id, p->skill_id});
});

// Fires on party lock/unlock — subscribers gate to relevant maps as needed.
GW::StoC::RegisterPacketCallback<GW::Packet::StoC::PartyLock>(
&on_party_lock_,
[this](GW::HookStatus*, const GW::Packet::StoC::PartyLock* p) {
Emit({GWEvent::Type::PartyLock, p->unk2});
});

// --- Instance lifecycle events ---

GW::StoC::RegisterPostPacketCallback<GW::Packet::StoC::InstanceLoadInfo>(
&on_instance_load_info_,
[this](GW::HookStatus*, const GW::Packet::StoC::InstanceLoadInfo* p) {
Emit({GWEvent::Type::InstanceLoadInfo, p->map_id,
static_cast<uint32_t>(p->is_explorable)});
});

GW::StoC::RegisterPostPacketCallback<GW::Packet::StoC::InstanceLoadFile>(
&on_instance_load_file_,
[this](GW::HookStatus*, const GW::Packet::StoC::InstanceLoadFile* p) {
GWEvent e;
e.type = GWEvent::Type::InstanceLoadFile;
e.id1 = p->map_fileID;
e.spawn = p->spawn_point;
Emit(e);
});

GW::StoC::RegisterPostPacketCallback<GW::Packet::StoC::InstanceTimer>(
&on_instance_timer_,
[this](GW::HookStatus*, GW::Packet::StoC::InstanceTimer*) {
Emit({GWEvent::Type::InstanceTimer});
});

GW::StoC::RegisterPacketCallback<GW::Packet::StoC::GameSrvTransfer>(
&on_game_srv_transfer_,
[this](GW::HookStatus*, const GW::Packet::StoC::GameSrvTransfer* p) {
Emit({GWEvent::Type::GameSrvTransfer, p->map_id,
static_cast<uint32_t>(p->is_explorable)});
});
}

// ---------------------------------------------------------------------------
// SignalTerminate
// ---------------------------------------------------------------------------
void GWEventBus::SignalTerminate()
{
GW::UI::RemoveUIMessageCallback(&on_mission_complete_);
GW::StoC::RemoveCallback<GW::Packet::StoC::ObjectiveAdd>(&on_objective_add_);
GW::UI::RemoveUIMessageCallback(&on_vanquish_complete_);
GW::UI::RemoveUIMessageCallback(&on_party_defeated_);
GW::StoC::RemoveCallback<GW::Packet::StoC::ObjectiveDone>(&on_objective_done_);
GW::StoC::RemoveCallback<GW::Packet::StoC::ObjectiveUpdateName>(&on_objective_started_);
GW::StoC::RemoveCallback<GW::Packet::StoC::ManipulateMapObject>(&on_door_);
GW::StoC::RemoveCallback<GW::Packet::StoC::AgentUpdateAllegiance>(&on_agent_allegiance_);
GW::StoC::RemoveCallback<GW::Packet::StoC::DoACompleteZone>(&on_doa_zone_);
GW::StoC::RemoveCallback<GW::Packet::StoC::DungeonReward>(&on_dungeon_reward_);
GW::StoC::RemoveCallback<GW::Packet::StoC::MessageServer>(&on_server_message_);
GW::StoC::RemoveCallback<GW::Packet::StoC::DisplayDialogue>(&on_display_dialogue_);
GW::StoC::RemoveCallback(GAME_SMSG_INSTANCE_COUNTDOWN, &on_countdown_start_);
GW::StoC::RemoveCallback<GW::Packet::StoC::SkillActivate>(&on_skill_activate_);
GW::StoC::RemoveCallback<GW::Packet::StoC::PartyLock>(&on_party_lock_);
GW::StoC::RemoveCallback<GW::Packet::StoC::InstanceLoadInfo>(&on_instance_load_info_);
GW::StoC::RemoveCallback<GW::Packet::StoC::InstanceLoadFile>(&on_instance_load_file_);
GW::StoC::RemoveCallback<GW::Packet::StoC::InstanceTimer>(&on_instance_timer_);
GW::StoC::RemoveCallback<GW::Packet::StoC::GameSrvTransfer>(&on_game_srv_transfer_);
ToolboxModule::SignalTerminate();
}
Loading