Kelsidavis-WoWee/src/game/game_handler.cpp
Kelsi 8e4a0053c4 Handle SMSG_DISPEL_FAILED, SMSG_TOTEM_CREATED, SMSG_AREA_SPIRIT_HEALER_TIME, SMSG_DURABILITY_DAMAGE_DEATH
- SMSG_DISPEL_FAILED: parse caster/victim/spellId, show "Dispel failed!" chat
- SMSG_TOTEM_CREATED: parse slot/guid/duration/spellId, log (no totem bar yet)
- SMSG_AREA_SPIRIT_HEALER_TIME: show "resurrect in N seconds" message
- SMSG_DURABILITY_DAMAGE_DEATH: show durability loss notification on death
2026-03-09 14:18:36 -07:00

15482 lines
634 KiB
C++

#include "game/game_handler.hpp"
#include "game/packet_parsers.hpp"
#include "game/transport_manager.hpp"
#include "game/warden_crypto.hpp"
#include "game/warden_memory.hpp"
#include "game/warden_module.hpp"
#include "game/opcodes.hpp"
#include "game/update_field_table.hpp"
#include "game/expansion_profile.hpp"
#include "rendering/renderer.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/combat_sound_manager.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/ui_sound_manager.hpp"
#include "pipeline/dbc_layout.hpp"
#include "network/world_socket.hpp"
#include "network/packet.hpp"
#include "auth/crypto.hpp"
#include "core/coordinates.hpp"
#include "core/application.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "core/logger.hpp"
#include <glm/gtx/quaternion.hpp>
#include <algorithm>
#include <cmath>
#include <cctype>
#include <ctime>
#include <random>
#include <zlib.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <unordered_map>
#include <unordered_set>
#include <functional>
#include <array>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <openssl/sha.h>
#include <openssl/hmac.h>
namespace wowee {
namespace game {
namespace {
const char* worldStateName(WorldState state) {
switch (state) {
case WorldState::DISCONNECTED: return "DISCONNECTED";
case WorldState::CONNECTING: return "CONNECTING";
case WorldState::CONNECTED: return "CONNECTED";
case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED";
case WorldState::AUTH_SENT: return "AUTH_SENT";
case WorldState::AUTHENTICATED: return "AUTHENTICATED";
case WorldState::READY: return "READY";
case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED";
case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED";
case WorldState::ENTERING_WORLD: return "ENTERING_WORLD";
case WorldState::IN_WORLD: return "IN_WORLD";
case WorldState::FAILED: return "FAILED";
}
return "UNKNOWN";
}
bool isAuthCharPipelineOpcode(LogicalOpcode op) {
switch (op) {
case Opcode::SMSG_AUTH_CHALLENGE:
case Opcode::SMSG_AUTH_RESPONSE:
case Opcode::SMSG_CLIENTCACHE_VERSION:
case Opcode::SMSG_TUTORIAL_FLAGS:
case Opcode::SMSG_WARDEN_DATA:
case Opcode::SMSG_CHAR_ENUM:
case Opcode::SMSG_CHAR_CREATE:
case Opcode::SMSG_CHAR_DELETE:
return true;
default:
return false;
}
}
bool isActiveExpansion(const char* expansionId) {
auto& app = core::Application::getInstance();
auto* registry = app.getExpansionRegistry();
if (!registry) return false;
auto* profile = registry->getActive();
if (!profile) return false;
return profile->id == expansionId;
}
bool isClassicLikeExpansion() {
return isActiveExpansion("classic") || isActiveExpansion("turtle");
}
bool envFlagEnabled(const char* key, bool defaultValue = false) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' ||
raw[0] == 'n' || raw[0] == 'N');
}
std::string formatCopperAmount(uint32_t amount) {
uint32_t gold = amount / 10000;
uint32_t silver = (amount / 100) % 100;
uint32_t copper = amount % 100;
std::ostringstream oss;
bool wrote = false;
if (gold > 0) {
oss << gold << "g";
wrote = true;
}
if (silver > 0) {
if (wrote) oss << " ";
oss << silver << "s";
wrote = true;
}
if (copper > 0 || !wrote) {
if (wrote) oss << " ";
oss << copper << "c";
}
return oss.str();
}
bool readCStringAt(const std::vector<uint8_t>& data, size_t start, std::string& out, size_t& nextPos) {
out.clear();
if (start >= data.size()) return false;
size_t i = start;
while (i < data.size()) {
uint8_t b = data[i++];
if (b == 0) {
nextPos = i;
return true;
}
out.push_back(static_cast<char>(b));
}
return false;
}
std::string asciiLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
std::vector<std::string> splitWowPath(const std::string& wowPath) {
std::vector<std::string> out;
std::string cur;
for (char c : wowPath) {
if (c == '\\' || c == '/') {
if (!cur.empty()) {
out.push_back(cur);
cur.clear();
}
continue;
}
cur.push_back(c);
}
if (!cur.empty()) out.push_back(cur);
return out;
}
int pathCaseScore(const std::string& name) {
int score = 0;
for (unsigned char c : name) {
if (std::islower(c)) score += 2;
else if (std::isupper(c)) score -= 1;
}
return score;
}
std::string resolveCaseInsensitiveDataPath(const std::string& dataRoot, const std::string& wowPath) {
if (dataRoot.empty() || wowPath.empty()) return std::string();
std::filesystem::path cur(dataRoot);
std::error_code ec;
if (!std::filesystem::exists(cur, ec) || !std::filesystem::is_directory(cur, ec)) {
return std::string();
}
for (const std::string& segment : splitWowPath(wowPath)) {
std::string wanted = asciiLower(segment);
std::filesystem::path bestPath;
int bestScore = std::numeric_limits<int>::min();
bool found = false;
for (const auto& entry : std::filesystem::directory_iterator(cur, ec)) {
if (ec) break;
std::string name = entry.path().filename().string();
if (asciiLower(name) != wanted) continue;
int score = pathCaseScore(name);
if (!found || score > bestScore) {
found = true;
bestScore = score;
bestPath = entry.path();
}
}
if (!found) return std::string();
cur = bestPath;
}
if (!std::filesystem::exists(cur, ec) || std::filesystem::is_directory(cur, ec)) {
return std::string();
}
return cur.string();
}
std::vector<uint8_t> readFileBinary(const std::string& fsPath) {
std::ifstream in(fsPath, std::ios::binary);
if (!in) return {};
in.seekg(0, std::ios::end);
std::streamoff size = in.tellg();
if (size <= 0) return {};
in.seekg(0, std::ios::beg);
std::vector<uint8_t> data(static_cast<size_t>(size));
in.read(reinterpret_cast<char*>(data.data()), size);
if (!in) return {};
return data;
}
bool hmacSha1Matches(const uint8_t seedBytes[4], const std::string& text, const uint8_t expected[20]) {
uint8_t out[SHA_DIGEST_LENGTH];
unsigned int outLen = 0;
HMAC(EVP_sha1(),
seedBytes, 4,
reinterpret_cast<const uint8_t*>(text.data()),
static_cast<int>(text.size()),
out, &outLen);
return outLen == SHA_DIGEST_LENGTH && std::memcmp(out, expected, SHA_DIGEST_LENGTH) == 0;
}
const std::unordered_map<std::string, std::array<uint8_t, 20>>& knownDoorHashes() {
static const std::unordered_map<std::string, std::array<uint8_t, 20>> k = {
{"world\\lordaeron\\stratholme\\activedoodads\\doors\\nox_door_plague.m2",
{0xB4,0x45,0x2B,0x6D,0x95,0xC9,0x8B,0x18,0x6A,0x70,0xB0,0x08,0xFA,0x07,0xBB,0xAE,0xF3,0x0D,0xF7,0xA2}},
{"world\\kalimdor\\onyxiaslair\\doors\\onyxiasgate01.m2",
{0x75,0x19,0x5E,0x4A,0xED,0xA0,0xBC,0xAF,0x04,0x8C,0xA0,0xE3,0x4D,0x95,0xA7,0x0D,0x4F,0x53,0xC7,0x46}},
{"world\\generic\\human\\activedoodads\\doors\\deadminedoor02.m2",
{0x3D,0xFF,0x01,0x1B,0x9A,0xB1,0x34,0xF3,0x7F,0x88,0x50,0x97,0xE6,0x95,0x35,0x1B,0x91,0x95,0x35,0x64}},
{"world\\kalimdor\\silithus\\activedoodads\\ahnqirajdoor\\ahnqirajdoor02.m2",
{0xDB,0xD4,0xF4,0x07,0xC4,0x68,0xCC,0x36,0x13,0x4E,0x62,0x1D,0x16,0x01,0x78,0xFD,0xA4,0xD0,0xD2,0x49}},
{"world\\kalimdor\\diremaul\\activedoodads\\doors\\diremaulsmallinstancedoor.m2",
{0x0D,0xC8,0xDB,0x46,0xC8,0x55,0x49,0xC0,0xFF,0x1A,0x60,0x0F,0x6C,0x23,0x63,0x57,0xC3,0x05,0x78,0x1A}},
};
return k;
}
bool isReadableQuestText(const std::string& s, size_t minLen, size_t maxLen) {
if (s.size() < minLen || s.size() > maxLen) return false;
bool hasAlpha = false;
for (unsigned char c : s) {
if (c < 0x20 || c > 0x7E) return false;
if (std::isalpha(c)) hasAlpha = true;
}
return hasAlpha;
}
bool isPlaceholderQuestTitle(const std::string& s) {
return s.rfind("Quest #", 0) == 0;
}
bool looksLikeQuestDescriptionText(const std::string& s) {
int spaces = 0;
int commas = 0;
for (unsigned char c : s) {
if (c == ' ') spaces++;
if (c == ',') commas++;
}
const int words = spaces + 1;
if (words > 8) return true;
if (commas > 0 && words > 5) return true;
if (s.find(". ") != std::string::npos) return true;
if (s.find(':') != std::string::npos && words > 5) return true;
return false;
}
bool isStrongQuestTitle(const std::string& s) {
if (!isReadableQuestText(s, 6, 72)) return false;
if (looksLikeQuestDescriptionText(s)) return false;
unsigned char first = static_cast<unsigned char>(s.front());
return std::isupper(first) != 0;
}
int scoreQuestTitle(const std::string& s) {
if (!isReadableQuestText(s, 4, 72)) return -1000;
if (looksLikeQuestDescriptionText(s)) return -1000;
int score = 0;
score += static_cast<int>(std::min<size_t>(s.size(), 32));
unsigned char first = static_cast<unsigned char>(s.front());
if (std::isupper(first)) score += 20;
if (std::islower(first)) score -= 20;
if (s.find(' ') != std::string::npos) score += 8;
if (s.find('.') != std::string::npos) score -= 18;
if (s.find('!') != std::string::npos || s.find('?') != std::string::npos) score -= 6;
return score;
}
struct QuestQueryTextCandidate {
std::string title;
std::string objectives;
int score = -1000;
};
QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector<uint8_t>& data, bool classicHint) {
QuestQueryTextCandidate best;
if (data.size() <= 9) return best;
std::vector<size_t> seedOffsets;
const size_t base = 8;
const size_t classicOffset = base + 40u * 4u;
const size_t wotlkOffset = base + 55u * 4u;
if (classicHint) {
seedOffsets.push_back(classicOffset);
seedOffsets.push_back(wotlkOffset);
} else {
seedOffsets.push_back(wotlkOffset);
seedOffsets.push_back(classicOffset);
}
for (size_t off : seedOffsets) {
if (off < data.size()) {
std::string title;
size_t next = off;
if (readCStringAt(data, off, title, next)) {
QuestQueryTextCandidate c;
c.title = title;
c.score = scoreQuestTitle(title) + 20; // Prefer expected struct offsets
std::string s2;
size_t n2 = next;
if (readCStringAt(data, next, s2, n2) && isReadableQuestText(s2, 8, 600)) {
c.objectives = s2;
}
if (c.score > best.score) best = c;
}
}
}
// Fallback: scan packet for best printable C-string title candidate.
for (size_t start = 8; start < data.size(); ++start) {
std::string title;
size_t next = start;
if (!readCStringAt(data, start, title, next)) continue;
QuestQueryTextCandidate c;
c.title = title;
c.score = scoreQuestTitle(title);
if (c.score < 0) continue;
std::string s2, s3;
size_t n2 = next, n3 = next;
if (readCStringAt(data, next, s2, n2)) {
if (isReadableQuestText(s2, 8, 600)) c.objectives = s2;
else if (readCStringAt(data, n2, s3, n3) && isReadableQuestText(s3, 8, 600)) c.objectives = s3;
}
if (c.score > best.score) best = c;
}
return best;
}
} // namespace
GameHandler::GameHandler() {
LOG_DEBUG("GameHandler created");
setActiveOpcodeTable(&opcodeTable_);
setActiveUpdateFieldTable(&updateFieldTable_);
// Initialize packet parsers (WotLK default, may be replaced for other expansions)
packetParsers_ = std::make_unique<WotlkPacketParsers>();
// Initialize transport manager
transportManager_ = std::make_unique<TransportManager>();
// Initialize Warden module manager
wardenModuleManager_ = std::make_unique<WardenModuleManager>();
// Default spells always available
knownSpells.insert(6603); // Attack
knownSpells.insert(8690); // Hearthstone
// Default action bar layout
actionBar[0].type = ActionBarSlot::SPELL;
actionBar[0].id = 6603; // Attack in slot 1
actionBar[11].type = ActionBarSlot::SPELL;
actionBar[11].id = 8690; // Hearthstone in slot 12
}
GameHandler::~GameHandler() {
disconnect();
}
void GameHandler::setPacketParsers(std::unique_ptr<PacketParsers> parsers) {
packetParsers_ = std::move(parsers);
}
bool GameHandler::connect(const std::string& host,
uint16_t port,
const std::vector<uint8_t>& sessionKey,
const std::string& accountName,
uint32_t build,
uint32_t realmId) {
if (sessionKey.size() != 40) {
LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)");
fail("Invalid session key");
return false;
}
LOG_INFO("========================================");
LOG_INFO(" CONNECTING TO WORLD SERVER");
LOG_INFO("========================================");
LOG_INFO("Host: ", host);
LOG_INFO("Port: ", port);
LOG_INFO("Account: ", accountName);
LOG_INFO("Build: ", build);
// Store authentication data
this->sessionKey = sessionKey;
this->accountName = accountName;
this->build = build;
this->realmId_ = realmId;
// Diagnostic: dump session key for AUTH_REJECT debugging
{
std::string hex;
for (uint8_t b : sessionKey) { char buf[4]; snprintf(buf, sizeof(buf), "%02x", b); hex += buf; }
LOG_INFO("GameHandler session key (", sessionKey.size(), "): ", hex);
}
requiresWarden_ = false;
wardenGateSeen_ = false;
wardenGateElapsed_ = 0.0f;
wardenGateNextStatusLog_ = 2.0f;
wardenPacketsAfterGate_ = 0;
wardenCharEnumBlockedLogged_ = false;
wardenCrypto_.reset();
wardenState_ = WardenState::WAIT_MODULE_USE;
wardenModuleHash_.clear();
wardenModuleKey_.clear();
wardenModuleSize_ = 0;
wardenModuleData_.clear();
wardenLoadedModule_.reset();
// Generate random client seed
this->clientSeed = generateClientSeed();
LOG_DEBUG("Generated client seed: 0x", std::hex, clientSeed, std::dec);
// Create world socket
socket = std::make_unique<network::WorldSocket>();
// Set up packet callback
socket->setPacketCallback([this](const network::Packet& packet) {
network::Packet mutablePacket = packet;
handlePacket(mutablePacket);
});
// Connect to world server
setState(WorldState::CONNECTING);
if (!socket->connect(host, port)) {
LOG_ERROR("Failed to connect to world server");
fail("Connection failed");
return false;
}
setState(WorldState::CONNECTED);
LOG_INFO("Connected to world server, waiting for SMSG_AUTH_CHALLENGE...");
return true;
}
void GameHandler::disconnect() {
if (onTaxiFlight_) {
taxiRecoverPending_ = true;
} else {
taxiRecoverPending_ = false;
}
if (socket) {
socket->disconnect();
socket.reset();
}
activeCharacterGuid_ = 0;
playerNameCache.clear();
pendingNameQueries.clear();
transportAttachments_.clear();
serverUpdatedTransportGuids_.clear();
requiresWarden_ = false;
wardenGateSeen_ = false;
wardenGateElapsed_ = 0.0f;
wardenGateNextStatusLog_ = 2.0f;
wardenPacketsAfterGate_ = 0;
wardenCharEnumBlockedLogged_ = false;
wardenCrypto_.reset();
wardenState_ = WardenState::WAIT_MODULE_USE;
wardenModuleHash_.clear();
wardenModuleKey_.clear();
wardenModuleSize_ = 0;
wardenModuleData_.clear();
wardenLoadedModule_.reset();
setState(WorldState::DISCONNECTED);
LOG_INFO("Disconnected from world server");
}
void GameHandler::resetDbcCaches() {
spellNameCacheLoaded_ = false;
spellNameCache_.clear();
skillLineDbcLoaded_ = false;
skillLineNames_.clear();
skillLineCategories_.clear();
skillLineAbilityLoaded_ = false;
spellToSkillLine_.clear();
taxiDbcLoaded_ = false;
taxiNodes_.clear();
taxiPathEdges_.clear();
taxiPathNodes_.clear();
areaTriggerDbcLoaded_ = false;
areaTriggers_.clear();
activeAreaTriggers_.clear();
talentDbcLoaded_ = false;
talentCache_.clear();
talentTabCache_.clear();
LOG_INFO("GameHandler: DBC caches cleared for expansion switch");
}
bool GameHandler::isConnected() const {
return socket && socket->isConnected();
}
void GameHandler::update(float deltaTime) {
// Fire deferred char-create callback (outside ImGui render)
if (pendingCharCreateResult_) {
pendingCharCreateResult_ = false;
if (charCreateCallback_) {
charCreateCallback_(pendingCharCreateSuccess_, pendingCharCreateMsg_);
}
}
if (!socket) {
return;
}
// Update socket (processes incoming data and triggers callbacks)
if (socket) {
auto socketStart = std::chrono::steady_clock::now();
socket->update();
float socketMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - socketStart).count();
if (socketMs > 3.0f) {
LOG_WARNING("SLOW socket->update: ", socketMs, "ms");
}
}
// Detect server-side disconnect (socket closed during update)
if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) {
LOG_WARNING("Server closed connection in state: ", worldStateName(state));
disconnect();
return;
}
// Post-gate visibility: determine whether server goes silent or closes after Warden requirement.
if (wardenGateSeen_ && socket && socket->isConnected()) {
wardenGateElapsed_ += deltaTime;
if (wardenGateElapsed_ >= wardenGateNextStatusLog_) {
LOG_DEBUG("Warden gate status: elapsed=", wardenGateElapsed_,
"s connected=", socket->isConnected() ? "yes" : "no",
" packetsAfterGate=", wardenPacketsAfterGate_);
wardenGateNextStatusLog_ += 30.0f;
}
}
// Validate target still exists
if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) {
clearTarget();
}
if (auctionSearchDelayTimer_ > 0.0f) {
auctionSearchDelayTimer_ -= deltaTime;
if (auctionSearchDelayTimer_ < 0.0f) auctionSearchDelayTimer_ = 0.0f;
}
for (auto it = pendingQuestAcceptTimeouts_.begin(); it != pendingQuestAcceptTimeouts_.end();) {
it->second -= deltaTime;
if (it->second <= 0.0f) {
const uint32_t questId = it->first;
const uint64_t npcGuid = pendingQuestAcceptNpcGuids_.count(questId) != 0
? pendingQuestAcceptNpcGuids_[questId] : 0;
triggerQuestAcceptResync(questId, npcGuid, "timeout");
it = pendingQuestAcceptTimeouts_.erase(it);
pendingQuestAcceptNpcGuids_.erase(questId);
} else {
++it;
}
}
if (pendingMoneyDeltaTimer_ > 0.0f) {
pendingMoneyDeltaTimer_ -= deltaTime;
if (pendingMoneyDeltaTimer_ <= 0.0f) {
pendingMoneyDeltaTimer_ = 0.0f;
pendingMoneyDelta_ = 0;
}
}
if (autoAttackRangeWarnCooldown_ > 0.0f) {
autoAttackRangeWarnCooldown_ = std::max(0.0f, autoAttackRangeWarnCooldown_ - deltaTime);
}
if (pendingLoginQuestResync_) {
pendingLoginQuestResyncTimeout_ -= deltaTime;
if (resyncQuestLogFromServerSlots(true)) {
pendingLoginQuestResync_ = false;
pendingLoginQuestResyncTimeout_ = 0.0f;
} else if (pendingLoginQuestResyncTimeout_ <= 0.0f) {
pendingLoginQuestResync_ = false;
pendingLoginQuestResyncTimeout_ = 0.0f;
LOG_WARNING("Quest login resync timed out waiting for player quest slot fields");
}
}
for (auto it = pendingGameObjectLootRetries_.begin(); it != pendingGameObjectLootRetries_.end();) {
it->timer -= deltaTime;
if (it->timer <= 0.0f) {
if (it->remainingRetries > 0 && state == WorldState::IN_WORLD && socket) {
// Keep server-side position/facing fresh before retrying GO use.
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
auto usePacket = GameObjectUsePacket::build(it->guid);
socket->send(usePacket);
if (it->sendLoot) {
auto lootPacket = LootPacket::build(it->guid);
socket->send(lootPacket);
}
--it->remainingRetries;
it->timer = 0.20f;
}
}
if (it->remainingRetries == 0) {
it = pendingGameObjectLootRetries_.erase(it);
} else {
++it;
}
}
for (auto it = pendingGameObjectLootOpens_.begin(); it != pendingGameObjectLootOpens_.end();) {
it->timer -= deltaTime;
if (it->timer <= 0.0f) {
if (state == WorldState::IN_WORLD && socket) {
lootTarget(it->guid);
}
it = pendingGameObjectLootOpens_.erase(it);
} else {
++it;
}
}
if (pendingLootMoneyNotifyTimer_ > 0.0f) {
pendingLootMoneyNotifyTimer_ -= deltaTime;
if (pendingLootMoneyNotifyTimer_ <= 0.0f) {
pendingLootMoneyNotifyTimer_ = 0.0f;
bool alreadyAnnounced = false;
if (pendingLootMoneyGuid_ != 0) {
auto it = localLootState_.find(pendingLootMoneyGuid_);
if (it != localLootState_.end()) {
alreadyAnnounced = it->second.moneyTaken;
it->second.moneyTaken = true;
}
}
if (!alreadyAnnounced && pendingLootMoneyAmount_ > 0) {
addSystemChatMessage("Looted: " + formatCopperAmount(pendingLootMoneyAmount_));
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
if (pendingLootMoneyAmount_ >= 10000) {
sfx->playLootCoinLarge();
} else {
sfx->playLootCoinSmall();
}
}
}
if (pendingLootMoneyGuid_ != 0) {
recentLootMoneyAnnounceCooldowns_[pendingLootMoneyGuid_] = 1.5f;
}
}
pendingLootMoneyGuid_ = 0;
pendingLootMoneyAmount_ = 0;
}
}
for (auto it = recentLootMoneyAnnounceCooldowns_.begin(); it != recentLootMoneyAnnounceCooldowns_.end();) {
it->second -= deltaTime;
if (it->second <= 0.0f) {
it = recentLootMoneyAnnounceCooldowns_.erase(it);
} else {
++it;
}
}
// Auto-inspect throttling (fallback for player equipment visuals).
if (inspectRateLimit_ > 0.0f) {
inspectRateLimit_ = std::max(0.0f, inspectRateLimit_ - deltaTime);
}
if (state == WorldState::IN_WORLD && socket && inspectRateLimit_ <= 0.0f && !pendingAutoInspect_.empty()) {
uint64_t guid = *pendingAutoInspect_.begin();
pendingAutoInspect_.erase(pendingAutoInspect_.begin());
if (guid != 0 && guid != playerGuid && entityManager.hasEntity(guid)) {
auto pkt = InspectPacket::build(guid);
socket->send(pkt);
inspectRateLimit_ = 2.0f; // throttle to avoid compositing stutter
LOG_DEBUG("Sent CMSG_INSPECT for player 0x", std::hex, guid, std::dec);
}
}
// Send periodic heartbeat if in world
if (state == WorldState::IN_WORLD) {
timeSinceLastPing += deltaTime;
timeSinceLastMoveHeartbeat_ += deltaTime;
if (timeSinceLastPing >= pingInterval) {
if (socket) {
sendPing();
}
timeSinceLastPing = 0.0f;
}
const bool classicLikeCombatSync =
autoAttackRequested_ && (isClassicLikeExpansion() || isActiveExpansion("tbc"));
float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_)
? 0.25f
: (classicLikeCombatSync ? 0.05f : moveHeartbeatInterval_);
if (timeSinceLastMoveHeartbeat_ >= heartbeatInterval) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
timeSinceLastMoveHeartbeat_ = 0.0f;
}
// Check area triggers (instance portals, tavern rests, etc.)
areaTriggerCheckTimer_ += deltaTime;
if (areaTriggerCheckTimer_ >= 0.25f) {
areaTriggerCheckTimer_ = 0.0f;
checkAreaTriggers();
}
// Update cast timer (Phase 3)
if (pendingGameObjectInteractGuid_ != 0 &&
(autoAttacking || autoAttackRequested_)) {
pendingGameObjectInteractGuid_ = 0;
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
addSystemChatMessage("Interrupted.");
}
if (casting && castTimeRemaining > 0.0f) {
castTimeRemaining -= deltaTime;
if (castTimeRemaining <= 0.0f) {
if (pendingGameObjectInteractGuid_ != 0) {
uint64_t interactGuid = pendingGameObjectInteractGuid_;
pendingGameObjectInteractGuid_ = 0;
performGameObjectInteractionNow(interactGuid);
}
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
}
// Update spell cooldowns (Phase 3)
for (auto it = spellCooldowns.begin(); it != spellCooldowns.end(); ) {
it->second -= deltaTime;
if (it->second <= 0.0f) {
it = spellCooldowns.erase(it);
} else {
++it;
}
}
// Update action bar cooldowns
for (auto& slot : actionBar) {
if (slot.cooldownRemaining > 0.0f) {
slot.cooldownRemaining -= deltaTime;
if (slot.cooldownRemaining < 0.0f) slot.cooldownRemaining = 0.0f;
}
}
// Update combat text (Phase 2)
updateCombatText(deltaTime);
// Update taxi landing cooldown
if (taxiLandingCooldown_ > 0.0f) {
taxiLandingCooldown_ -= deltaTime;
}
if (taxiStartGrace_ > 0.0f) {
taxiStartGrace_ -= deltaTime;
}
if (playerTransportStickyTimer_ > 0.0f) {
playerTransportStickyTimer_ -= deltaTime;
if (playerTransportStickyTimer_ <= 0.0f) {
playerTransportStickyTimer_ = 0.0f;
playerTransportStickyGuid_ = 0;
}
}
// Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared
if (onTaxiFlight_) {
updateClientTaxi(deltaTime);
auto playerEntity = entityManager.getEntity(playerGuid);
auto unit = std::dynamic_pointer_cast<Unit>(playerEntity);
if (unit &&
(unit->getUnitFlags() & 0x00000100) == 0 &&
!taxiClientActive_ &&
!taxiActivatePending_ &&
taxiStartGrace_ <= 0.0f) {
onTaxiFlight_ = false;
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0);
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
currentMountDisplayId_ = 0;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
movementInfo.flags = 0;
movementInfo.flags2 = 0;
if (socket) {
sendMovement(Opcode::MSG_MOVE_STOP);
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("Taxi flight landed");
}
}
// Safety: if taxi flight ended but mount is still active, force dismount.
// Guard against transient taxi-state flicker.
if (!onTaxiFlight_ && taxiMountActive_) {
bool serverStillTaxi = false;
auto playerEntity = entityManager.getEntity(playerGuid);
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
if (playerUnit) {
serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0;
}
if (taxiStartGrace_ > 0.0f || serverStillTaxi || taxiClientActive_ || taxiActivatePending_) {
onTaxiFlight_ = true;
} else {
if (mountCallback_) mountCallback_(0);
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
currentMountDisplayId_ = 0;
movementInfo.flags = 0;
movementInfo.flags2 = 0;
if (socket) {
sendMovement(Opcode::MSG_MOVE_STOP);
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("Taxi dismount cleanup");
}
}
// Keep non-taxi mount state server-authoritative.
// Some server paths don't emit explicit mount field updates in lockstep
// with local visual state changes, so reconcile continuously.
if (!onTaxiFlight_ && !taxiMountActive_) {
auto playerEntity = entityManager.getEntity(playerGuid);
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
if (playerUnit) {
uint32_t serverMountDisplayId = playerUnit->getMountDisplayId();
if (serverMountDisplayId != currentMountDisplayId_) {
LOG_INFO("Mount reconcile: server=", serverMountDisplayId,
" local=", currentMountDisplayId_);
currentMountDisplayId_ = serverMountDisplayId;
if (mountCallback_) {
mountCallback_(serverMountDisplayId);
}
}
}
}
if (taxiRecoverPending_ && state == WorldState::IN_WORLD) {
auto playerEntity = entityManager.getEntity(playerGuid);
if (playerEntity) {
playerEntity->setPosition(taxiRecoverPos_.x, taxiRecoverPos_.y,
taxiRecoverPos_.z, movementInfo.orientation);
movementInfo.x = taxiRecoverPos_.x;
movementInfo.y = taxiRecoverPos_.y;
movementInfo.z = taxiRecoverPos_.z;
if (socket) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
taxiRecoverPending_ = false;
LOG_INFO("Taxi recovery applied");
}
}
if (taxiActivatePending_) {
taxiActivateTimer_ += deltaTime;
if (taxiActivateTimer_ > 5.0f) {
// If client taxi simulation is already active, server reply may be missing/late.
// Do not cancel the flight in that case; clear pending state and continue.
if (onTaxiFlight_ || taxiClientActive_ || taxiMountActive_) {
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
} else {
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0);
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
taxiClientActive_ = false;
taxiClientPath_.clear();
onTaxiFlight_ = false;
LOG_WARNING("Taxi activation timed out");
}
}
}
// Update transport manager
if (transportManager_) {
transportManager_->update(deltaTime);
updateAttachedTransportChildren(deltaTime);
}
// Leave combat if auto-attack target is too far away (leash range)
// and keep melee intent tightly synced while stationary.
if (autoAttackRequested_ && autoAttackTarget != 0) {
auto targetEntity = entityManager.getEntity(autoAttackTarget);
if (targetEntity) {
// Use latest server-authoritative target position to avoid stale
// interpolation snapshots masking out-of-range states.
const float targetX = targetEntity->getLatestX();
const float targetY = targetEntity->getLatestY();
const float targetZ = targetEntity->getLatestZ();
float dx = movementInfo.x - targetX;
float dy = movementInfo.y - targetY;
float dz = movementInfo.z - targetZ;
float dist = std::sqrt(dx * dx + dy * dy);
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
const bool classicLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
if (dist > 40.0f) {
stopAutoAttack();
LOG_INFO("Left combat: target too far (", dist, " yards)");
} else if (state == WorldState::IN_WORLD && socket) {
bool allowResync = true;
const float meleeRange = classicLike ? 5.25f : 5.75f;
if (dist3d > meleeRange) {
autoAttackOutOfRange_ = true;
autoAttackOutOfRangeTime_ += deltaTime;
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
addSystemChatMessage("Target is too far away.");
autoAttackRangeWarnCooldown_ = 1.25f;
}
// Stop chasing stale swings when the target remains out of range.
if (autoAttackOutOfRangeTime_ > 2.0f && dist3d > 9.0f) {
stopAutoAttack();
addSystemChatMessage("Auto-attack stopped: target out of range.");
allowResync = false;
}
} else {
autoAttackOutOfRange_ = false;
autoAttackOutOfRangeTime_ = 0.0f;
}
if (allowResync) {
autoAttackResendTimer_ += deltaTime;
autoAttackFacingSyncTimer_ += deltaTime;
// Re-request swing more aggressively until server confirms active loop.
float resendInterval = 1.0f;
if (!autoAttacking || autoAttackOutOfRange_) {
resendInterval = classicLike ? 0.25f : 0.50f;
}
if (autoAttackResendTimer_ >= resendInterval) {
autoAttackResendTimer_ = 0.0f;
auto pkt = AttackSwingPacket::build(autoAttackTarget);
socket->send(pkt);
}
// Keep server-facing aligned with our current melee target.
// Some vanilla-family realms become strict about front-arc checks unless
// the client sends explicit facing updates while stationary.
const float facingSyncInterval = classicLike ? 0.10f : 0.20f;
if (autoAttackFacingSyncTimer_ >= facingSyncInterval) {
autoAttackFacingSyncTimer_ = 0.0f;
float toTargetX = targetX - movementInfo.x;
float toTargetY = targetY - movementInfo.y;
bool sentMovement = false;
if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) {
float desired = std::atan2(-toTargetY, toTargetX);
float diff = desired - movementInfo.orientation;
while (diff > static_cast<float>(M_PI)) diff -= 2.0f * static_cast<float>(M_PI);
while (diff < -static_cast<float>(M_PI)) diff += 2.0f * static_cast<float>(M_PI);
const float facingThreshold = classicLike ? 0.035f : 0.12f; // ~2deg / ~7deg
if (std::abs(diff) > facingThreshold) {
movementInfo.orientation = desired;
sendMovement(Opcode::MSG_MOVE_SET_FACING);
// Follow facing update with a heartbeat to tighten server range/facing checks.
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
sentMovement = true;
}
} else if (classicLike) {
// Keep stationary melee position/facing fresh for strict vanilla-family checks.
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
sentMovement = true;
}
// Even when facing is already correct, keep position fresh while
// trying to connect melee hits so servers don't require a step.
if (!sentMovement && (!autoAttacking || autoAttackOutOfRange_)) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
}
}
}
}
}
// Keep active melee attackers visually facing the player as positions change.
// Some servers don't stream frequent orientation updates during combat.
if (!hostileAttackers_.empty()) {
for (uint64_t attackerGuid : hostileAttackers_) {
auto attacker = entityManager.getEntity(attackerGuid);
if (!attacker) continue;
float dx = movementInfo.x - attacker->getX();
float dy = movementInfo.y - attacker->getY();
if (std::abs(dx) < 0.01f && std::abs(dy) < 0.01f) continue;
attacker->setOrientation(std::atan2(-dy, dx));
}
}
// Close vendor/gossip/taxi window if player walks too far from NPC
if (vendorWindowOpen && currentVendorItems.vendorGuid != 0) {
auto npc = entityManager.getEntity(currentVendorItems.vendorGuid);
if (npc) {
float dx = movementInfo.x - npc->getX();
float dy = movementInfo.y - npc->getY();
float dist = std::sqrt(dx * dx + dy * dy);
if (dist > 15.0f) {
closeVendor();
LOG_INFO("Vendor closed: walked too far from NPC");
}
}
}
if (gossipWindowOpen && currentGossip.npcGuid != 0) {
auto npc = entityManager.getEntity(currentGossip.npcGuid);
if (npc) {
float dx = movementInfo.x - npc->getX();
float dy = movementInfo.y - npc->getY();
float dist = std::sqrt(dx * dx + dy * dy);
if (dist > 15.0f) {
closeGossip();
LOG_INFO("Gossip closed: walked too far from NPC");
}
}
}
if (taxiWindowOpen_ && taxiNpcGuid_ != 0) {
auto npc = entityManager.getEntity(taxiNpcGuid_);
if (npc) {
float dx = movementInfo.x - npc->getX();
float dy = movementInfo.y - npc->getY();
float dist = std::sqrt(dx * dx + dy * dy);
if (dist > 15.0f) {
closeTaxi();
LOG_INFO("Taxi window closed: walked too far from NPC");
}
}
}
if (trainerWindowOpen_ && currentTrainerList_.trainerGuid != 0) {
auto npc = entityManager.getEntity(currentTrainerList_.trainerGuid);
if (npc) {
float dx = movementInfo.x - npc->getX();
float dy = movementInfo.y - npc->getY();
float dist = std::sqrt(dx * dx + dy * dy);
if (dist > 15.0f) {
closeTrainer();
LOG_INFO("Trainer closed: walked too far from NPC");
}
}
}
// Update entity movement interpolation (keeps targeting in sync with visuals)
// Only update entities within reasonable distance for performance
const float updateRadiusSq = 150.0f * 150.0f; // 150 unit radius
auto playerEntity = entityManager.getEntity(playerGuid);
glm::vec3 playerPos = playerEntity ? glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()) : glm::vec3(0.0f);
for (auto& [guid, entity] : entityManager.getEntities()) {
// Always update player
if (guid == playerGuid) {
entity->updateMovement(deltaTime);
continue;
}
// Keep selected/engaged target interpolation exact for UI targeting circle.
if (guid == targetGuid || guid == autoAttackTarget) {
entity->updateMovement(deltaTime);
continue;
}
// Distance cull other entities (use latest position to avoid culling by stale origin)
glm::vec3 entityPos(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
float distSq = glm::dot(entityPos - playerPos, entityPos - playerPos);
if (distSq < updateRadiusSq) {
entity->updateMovement(deltaTime);
}
}
}
}
void GameHandler::handlePacket(network::Packet& packet) {
if (packet.getSize() < 1) {
LOG_DEBUG("Received empty world packet (ignored)");
return;
}
uint16_t opcode = packet.getOpcode();
try {
const bool allowVanillaAliases = isClassicLikeExpansion() || isActiveExpansion("tbc");
// Vanilla compatibility aliases:
// - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers
// and SMSG_WEATHER on others
// - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers)
//
// We gate these by payload shape so expansion-native mappings remain intact.
if (allowVanillaAliases && opcode == 0x006B) {
// Try compressed movement batch first:
// [u8 subSize][u16 subOpcode][subPayload...] ...
// where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT.
const auto& data = packet.getData();
if (packet.getReadPos() + 3 <= data.size()) {
size_t pos = packet.getReadPos();
uint8_t subSize = data[pos];
if (subSize >= 2 && pos + 1 + subSize <= data.size()) {
uint16_t subOpcode = static_cast<uint16_t>(data[pos + 1]) |
(static_cast<uint16_t>(data[pos + 2]) << 8);
uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE);
uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT);
if ((monsterMoveWire != 0xFFFF && subOpcode == monsterMoveWire) ||
(monsterMoveTransportWire != 0xFFFF && subOpcode == monsterMoveTransportWire)) {
LOG_INFO("Opcode 0x006B interpreted as SMSG_COMPRESSED_MOVES (subOpcode=0x",
std::hex, subOpcode, std::dec, ")");
handleCompressedMoves(packet);
return;
}
}
}
// Expected weather payload: uint32 weatherType, float intensity, uint8 abrupt
if (packet.getSize() - packet.getReadPos() >= 9) {
uint32_t wType = packet.readUInt32();
float wIntensity = packet.readFloat();
uint8_t abrupt = packet.readUInt8();
bool plausibleWeather =
(wType <= 3) &&
std::isfinite(wIntensity) &&
(wIntensity >= 0.0f && wIntensity <= 1.5f) &&
(abrupt <= 1);
if (plausibleWeather) {
weatherType_ = wType;
weatherIntensity_ = wIntensity;
const char* typeName =
(wType == 1) ? "Rain" :
(wType == 2) ? "Snow" :
(wType == 3) ? "Storm" : "Clear";
LOG_INFO("Weather changed (0x006B alias): type=", wType,
" (", typeName, "), intensity=", wIntensity,
", abrupt=", static_cast<int>(abrupt));
return;
}
// Not weather-shaped: rewind and fall through to normal opcode table handling.
packet.setReadPos(0);
}
} else if (allowVanillaAliases && opcode == 0x0103) {
// Expected play-music payload: uint32 sound/music id
if (packet.getSize() - packet.getReadPos() == 4) {
uint32_t soundId = packet.readUInt32();
LOG_INFO("SMSG_PLAY_MUSIC (0x0103 alias): soundId=", soundId);
return;
}
} else if (opcode == 0x0480) {
// Observed on this WotLK profile immediately after CMSG_BUYBACK_ITEM.
// Treat as vendor/buyback transaction result (7-byte payload on this core).
if (packet.getSize() - packet.getReadPos() >= 7) {
uint8_t opType = packet.readUInt8();
uint8_t resultCode = packet.readUInt8();
uint8_t slotOrCount = packet.readUInt8();
uint32_t itemId = packet.readUInt32();
LOG_INFO("Vendor txn result (0x480): opType=", static_cast<int>(opType),
" result=", static_cast<int>(resultCode),
" slot/count=", static_cast<int>(slotOrCount),
" itemId=", itemId,
" pendingBuybackSlot=", pendingBuybackSlot_,
" pendingBuyItemId=", pendingBuyItemId_,
" pendingBuyItemSlot=", pendingBuyItemSlot_);
if (pendingBuybackSlot_ >= 0) {
if (resultCode == 0) {
// Success: remove the bought-back slot from our local UI cache.
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
}
} else {
const char* msg = "Buyback failed.";
// Best-effort mapping; keep raw code visible for unknowns.
switch (resultCode) {
case 2: msg = "Buyback failed: not enough money."; break;
case 4: msg = "Buyback failed: vendor too far away."; break;
case 5: msg = "Buyback failed: item unavailable."; break;
case 6: msg = "Buyback failed: inventory full."; break;
case 8: msg = "Buyback failed: requirements not met."; break;
default: break;
}
addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")");
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
// Refresh vendor list so UI state stays in sync after buyback result.
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
auto pkt = ListInventoryPacket::build(currentVendorItems.vendorGuid);
socket->send(pkt);
}
} else if (pendingBuyItemId_ != 0) {
if (resultCode != 0) {
const char* msg = "Purchase failed.";
switch (resultCode) {
case 2: msg = "Purchase failed: not enough money."; break;
case 4: msg = "Purchase failed: vendor too far away."; break;
case 5: msg = "Purchase failed: item sold out."; break;
case 6: msg = "Purchase failed: inventory full."; break;
case 8: msg = "Purchase failed: requirements not met."; break;
default: break;
}
addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")");
}
pendingBuyItemId_ = 0;
pendingBuyItemSlot_ = 0;
}
return;
}
} else if (opcode == 0x046A) {
// Server-specific vendor/buyback state packet (observed 25-byte records).
// Consume to keep stream aligned; currently not used for gameplay logic.
if (packet.getSize() - packet.getReadPos() >= 25) {
packet.setReadPos(packet.getReadPos() + 25);
return;
}
}
auto preLogicalOp = opcodeTable_.fromWire(opcode);
if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) {
++wardenPacketsAfterGate_;
}
if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) {
LOG_DEBUG("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec,
" state=", worldStateName(state),
" size=", packet.getSize());
}
LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec,
" size=", packet.getSize(), " bytes");
// Translate wire opcode to logical opcode via expansion table
auto logicalOp = opcodeTable_.fromWire(opcode);
if (!logicalOp) {
static std::unordered_set<uint16_t> loggedUnknownWireOpcodes;
if (loggedUnknownWireOpcodes.insert(opcode).second) {
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
" state=", static_cast<int>(state),
" size=", packet.getSize());
}
return;
}
switch (*logicalOp) {
case Opcode::SMSG_AUTH_CHALLENGE:
if (state == WorldState::CONNECTED) {
handleAuthChallenge(packet);
} else {
LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state));
}
break;
case Opcode::SMSG_AUTH_RESPONSE:
if (state == WorldState::AUTH_SENT) {
handleAuthResponse(packet);
} else {
LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state));
}
break;
case Opcode::SMSG_CHAR_CREATE:
handleCharCreateResponse(packet);
break;
case Opcode::SMSG_CHAR_DELETE: {
uint8_t result = packet.readUInt8();
lastCharDeleteResult_ = result;
bool success = (result == 0x00 || result == 0x47); // Common success codes
LOG_INFO("SMSG_CHAR_DELETE result: ", (int)result, success ? " (success)" : " (failed)");
requestCharacterList();
if (charDeleteCallback_) charDeleteCallback_(success);
break;
}
case Opcode::SMSG_CHAR_ENUM:
if (state == WorldState::CHAR_LIST_REQUESTED) {
handleCharEnum(packet);
} else {
LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state));
}
break;
case Opcode::SMSG_CHARACTER_LOGIN_FAILED:
handleCharLoginFailed(packet);
break;
case Opcode::SMSG_LOGIN_VERIFY_WORLD:
if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD) {
handleLoginVerifyWorld(packet);
} else {
LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state));
}
break;
case Opcode::SMSG_LOGIN_SETTIMESPEED:
// Can be received during login or at any time after
handleLoginSetTimeSpeed(packet);
break;
case Opcode::SMSG_CLIENTCACHE_VERSION:
// Early pre-world packet in some realms (e.g. Warmane profile)
handleClientCacheVersion(packet);
break;
case Opcode::SMSG_TUTORIAL_FLAGS:
// Often sent during char-list stage (8x uint32 tutorial flags)
handleTutorialFlags(packet);
break;
case Opcode::SMSG_WARDEN_DATA:
handleWardenData(packet);
break;
case Opcode::SMSG_ACCOUNT_DATA_TIMES:
// Can be received at any time after authentication
handleAccountDataTimes(packet);
break;
case Opcode::SMSG_MOTD:
// Can be received at any time after entering world
handleMotd(packet);
break;
case Opcode::SMSG_NOTIFICATION:
// Vanilla/Classic server notification (single string)
handleNotification(packet);
break;
case Opcode::SMSG_PONG:
// Can be received at any time after entering world
handlePong(packet);
break;
case Opcode::SMSG_UPDATE_OBJECT:
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
// Can be received after entering world
if (state == WorldState::IN_WORLD) {
handleUpdateObject(packet);
}
break;
case Opcode::SMSG_COMPRESSED_UPDATE_OBJECT:
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
// Compressed version of UPDATE_OBJECT
if (state == WorldState::IN_WORLD) {
handleCompressedUpdateObject(packet);
}
break;
case Opcode::SMSG_DESTROY_OBJECT:
// Can be received after entering world
if (state == WorldState::IN_WORLD) {
handleDestroyObject(packet);
}
break;
case Opcode::SMSG_MESSAGECHAT:
// Can be received after entering world
if (state == WorldState::IN_WORLD) {
handleMessageChat(packet);
}
break;
case Opcode::SMSG_TEXT_EMOTE:
if (state == WorldState::IN_WORLD) {
handleTextEmote(packet);
}
break;
case Opcode::SMSG_EMOTE: {
if (state != WorldState::IN_WORLD) break;
// SMSG_EMOTE: uint32 emoteAnim, uint64 sourceGuid
if (packet.getSize() - packet.getReadPos() < 12) break;
uint32_t emoteAnim = packet.readUInt32();
uint64_t sourceGuid = packet.readUInt64();
if (emoteAnimCallback_ && sourceGuid != 0) {
emoteAnimCallback_(sourceGuid, emoteAnim);
}
break;
}
case Opcode::SMSG_CHANNEL_NOTIFY:
// Accept during ENTERING_WORLD too — server auto-joins channels before VERIFY_WORLD
if (state == WorldState::IN_WORLD || state == WorldState::ENTERING_WORLD) {
handleChannelNotify(packet);
}
break;
case Opcode::SMSG_QUERY_TIME_RESPONSE:
if (state == WorldState::IN_WORLD) {
handleQueryTimeResponse(packet);
}
break;
case Opcode::SMSG_PLAYED_TIME:
if (state == WorldState::IN_WORLD) {
handlePlayedTime(packet);
}
break;
case Opcode::SMSG_WHO:
if (state == WorldState::IN_WORLD) {
handleWho(packet);
}
break;
case Opcode::SMSG_FRIEND_STATUS:
if (state == WorldState::IN_WORLD) {
handleFriendStatus(packet);
}
break;
case Opcode::SMSG_CONTACT_LIST: {
// Known variants:
// - Full form: uint32 listMask, uint32 count, then variable-size entries.
// - Minimal/legacy keepalive-ish form observed on some servers: 1 byte.
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining >= 8) {
lastContactListMask_ = packet.readUInt32();
lastContactListCount_ = packet.readUInt32();
} else if (remaining == 1) {
/*uint8_t marker =*/ packet.readUInt8();
lastContactListMask_ = 0;
lastContactListCount_ = 0;
} else if (remaining > 0) {
// Unknown short variant: consume to keep stream aligned, no warning spam.
packet.setReadPos(packet.getSize());
}
break;
}
case Opcode::SMSG_FRIEND_LIST:
case Opcode::SMSG_IGNORE_LIST:
// Legacy social list variants; CONTACT_LIST is primary in modern flow.
packet.setReadPos(packet.getSize());
break;
case Opcode::MSG_RANDOM_ROLL:
if (state == WorldState::IN_WORLD) {
handleRandomRoll(packet);
}
break;
case Opcode::SMSG_ITEM_PUSH_RESULT: {
// Item received notification (loot, quest reward, trade, etc.)
// guid(8) + received(1) + created(1) + showInChat(1) + bagSlot(1) + itemSlot(4)
// + itemId(4) + itemSuffixFactor(4) + randomPropertyId(4) + count(4) + totalCount(4)
constexpr size_t kMinSize = 8 + 1 + 1 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + 4;
if (packet.getSize() - packet.getReadPos() >= kMinSize) {
/*uint64_t recipientGuid =*/ packet.readUInt64();
/*uint8_t received =*/ packet.readUInt8(); // 0=looted/generated, 1=received from trade
/*uint8_t created =*/ packet.readUInt8(); // 0=stack added, 1=new item slot
uint8_t showInChat = packet.readUInt8();
/*uint8_t bagSlot =*/ packet.readUInt8();
/*uint32_t itemSlot =*/ packet.readUInt32();
uint32_t itemId = packet.readUInt32();
/*uint32_t suffixFactor =*/ packet.readUInt32();
/*int32_t randomProp =*/ static_cast<int32_t>(packet.readUInt32());
uint32_t count = packet.readUInt32();
/*uint32_t totalCount =*/ packet.readUInt32();
queryItemInfo(itemId, 0);
if (showInChat) {
std::string itemName = "item #" + std::to_string(itemId);
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
if (!info->name.empty()) itemName = info->name;
}
std::string msg = "Received: " + itemName;
if (count > 1) msg += " x" + std::to_string(count);
addSystemChatMessage(msg);
}
LOG_INFO("Item push: itemId=", itemId, " count=", count,
" showInChat=", static_cast<int>(showInChat));
}
break;
}
case Opcode::SMSG_LOGOUT_RESPONSE:
handleLogoutResponse(packet);
break;
case Opcode::SMSG_LOGOUT_COMPLETE:
handleLogoutComplete(packet);
break;
// ---- Phase 1: Foundation ----
case Opcode::SMSG_NAME_QUERY_RESPONSE:
handleNameQueryResponse(packet);
break;
case Opcode::SMSG_CREATURE_QUERY_RESPONSE:
handleCreatureQueryResponse(packet);
break;
case Opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE:
handleItemQueryResponse(packet);
break;
case Opcode::SMSG_INSPECT_TALENT:
handleInspectResults(packet);
break;
case Opcode::SMSG_ADDON_INFO:
case Opcode::SMSG_EXPECTED_SPAM_RECORDS:
// Optional system payloads that are safe to consume.
packet.setReadPos(packet.getSize());
break;
// ---- XP ----
case Opcode::SMSG_LOG_XPGAIN:
handleXpGain(packet);
break;
// ---- Creature Movement ----
case Opcode::SMSG_MONSTER_MOVE:
handleMonsterMove(packet);
break;
case Opcode::SMSG_COMPRESSED_MOVES:
handleCompressedMoves(packet);
break;
case Opcode::SMSG_MONSTER_MOVE_TRANSPORT:
handleMonsterMoveTransport(packet);
break;
case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE:
case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: {
// Minimal parse: PackedGuid
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
}
break;
}
case Opcode::SMSG_SPLINE_SET_RUN_SPEED:
case Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED:
case Opcode::SMSG_SPLINE_SET_SWIM_SPEED: {
// Minimal parse: PackedGuid + float speed
if (packet.getSize() - packet.getReadPos() < 5) break;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break;
float speed = packet.readFloat();
if (guid == playerGuid && std::isfinite(speed) && speed > 0.1f && speed < 100.0f &&
*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) {
serverRunSpeed_ = speed;
}
break;
}
// ---- Speed Changes ----
case Opcode::SMSG_FORCE_RUN_SPEED_CHANGE:
handleForceRunSpeedChange(packet);
break;
case Opcode::SMSG_FORCE_MOVE_ROOT:
handleForceMoveRootState(packet, true);
break;
case Opcode::SMSG_FORCE_MOVE_UNROOT:
handleForceMoveRootState(packet, false);
break;
// ---- Other force speed changes ----
case Opcode::SMSG_FORCE_WALK_SPEED_CHANGE:
handleForceSpeedChange(packet, "WALK_SPEED", Opcode::CMSG_FORCE_WALK_SPEED_CHANGE_ACK, &serverWalkSpeed_);
break;
case Opcode::SMSG_FORCE_RUN_BACK_SPEED_CHANGE:
handleForceSpeedChange(packet, "RUN_BACK_SPEED", Opcode::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK, &serverRunBackSpeed_);
break;
case Opcode::SMSG_FORCE_SWIM_SPEED_CHANGE:
handleForceSpeedChange(packet, "SWIM_SPEED", Opcode::CMSG_FORCE_SWIM_SPEED_CHANGE_ACK, &serverSwimSpeed_);
break;
case Opcode::SMSG_FORCE_SWIM_BACK_SPEED_CHANGE:
handleForceSpeedChange(packet, "SWIM_BACK_SPEED", Opcode::CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK, &serverSwimBackSpeed_);
break;
case Opcode::SMSG_FORCE_FLIGHT_SPEED_CHANGE:
handleForceSpeedChange(packet, "FLIGHT_SPEED", Opcode::CMSG_FORCE_FLIGHT_SPEED_CHANGE_ACK, &serverFlightSpeed_);
break;
case Opcode::SMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE:
handleForceSpeedChange(packet, "FLIGHT_BACK_SPEED", Opcode::CMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE_ACK, &serverFlightBackSpeed_);
break;
case Opcode::SMSG_FORCE_TURN_RATE_CHANGE:
handleForceSpeedChange(packet, "TURN_RATE", Opcode::CMSG_FORCE_TURN_RATE_CHANGE_ACK, &serverTurnRate_);
break;
case Opcode::SMSG_FORCE_PITCH_RATE_CHANGE:
handleForceSpeedChange(packet, "PITCH_RATE", Opcode::CMSG_FORCE_PITCH_RATE_CHANGE_ACK, &serverPitchRate_);
break;
// ---- Movement flag toggle ACKs ----
case Opcode::SMSG_MOVE_SET_CAN_FLY:
handleForceMoveFlagChange(packet, "SET_CAN_FLY", Opcode::CMSG_MOVE_SET_CAN_FLY_ACK,
static_cast<uint32_t>(MovementFlags::CAN_FLY), true);
break;
case Opcode::SMSG_MOVE_UNSET_CAN_FLY:
handleForceMoveFlagChange(packet, "UNSET_CAN_FLY", Opcode::CMSG_MOVE_SET_CAN_FLY_ACK,
static_cast<uint32_t>(MovementFlags::CAN_FLY), false);
break;
case Opcode::SMSG_MOVE_FEATHER_FALL:
handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, true);
break;
case Opcode::SMSG_MOVE_WATER_WALK:
handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, true);
break;
case Opcode::SMSG_MOVE_SET_HOVER:
handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
static_cast<uint32_t>(MovementFlags::HOVER), true);
break;
case Opcode::SMSG_MOVE_UNSET_HOVER:
handleForceMoveFlagChange(packet, "UNSET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
static_cast<uint32_t>(MovementFlags::HOVER), false);
break;
// ---- Knockback ----
case Opcode::SMSG_MOVE_KNOCK_BACK:
handleMoveKnockBack(packet);
break;
case Opcode::SMSG_CLIENT_CONTROL_UPDATE: {
// Minimal parse: PackedGuid + uint8 allowMovement.
if (packet.getSize() - packet.getReadPos() < 2) {
LOG_WARNING("SMSG_CLIENT_CONTROL_UPDATE too short: ", packet.getSize(), " bytes");
break;
}
uint8_t guidMask = packet.readUInt8();
size_t guidBytes = 0;
uint64_t controlGuid = 0;
for (int i = 0; i < 8; ++i) {
if (guidMask & (1u << i)) ++guidBytes;
}
if (packet.getSize() - packet.getReadPos() < guidBytes + 1) {
LOG_WARNING("SMSG_CLIENT_CONTROL_UPDATE malformed (truncated packed guid)");
packet.setReadPos(packet.getSize());
break;
}
for (int i = 0; i < 8; ++i) {
if (guidMask & (1u << i)) {
uint8_t b = packet.readUInt8();
controlGuid |= (static_cast<uint64_t>(b) << (i * 8));
}
}
bool allowMovement = (packet.readUInt8() != 0);
if (controlGuid == 0 || controlGuid == playerGuid) {
bool changed = (serverMovementAllowed_ != allowMovement);
serverMovementAllowed_ = allowMovement;
if (changed && !allowMovement) {
// Force-stop local movement immediately when server revokes control.
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::FORWARD) |
static_cast<uint32_t>(MovementFlags::BACKWARD) |
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
static_cast<uint32_t>(MovementFlags::TURN_RIGHT));
sendMovement(Opcode::MSG_MOVE_STOP);
sendMovement(Opcode::MSG_MOVE_STOP_STRAFE);
sendMovement(Opcode::MSG_MOVE_STOP_TURN);
sendMovement(Opcode::MSG_MOVE_STOP_SWIM);
addSystemChatMessage("Movement disabled by server.");
} else if (changed && allowMovement) {
addSystemChatMessage("Movement re-enabled.");
}
}
break;
}
// ---- Phase 2: Combat ----
case Opcode::SMSG_ATTACKSTART:
handleAttackStart(packet);
break;
case Opcode::SMSG_ATTACKSTOP:
handleAttackStop(packet);
break;
case Opcode::SMSG_ATTACKSWING_NOTINRANGE:
autoAttackOutOfRange_ = true;
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
addSystemChatMessage("Target is too far away.");
autoAttackRangeWarnCooldown_ = 1.25f;
}
if (autoAttackRequested_ && autoAttackTarget != 0 && socket) {
// Avoid blind immediate resend loops when target is clearly out of melee range.
bool likelyInRange = true;
if (auto target = entityManager.getEntity(autoAttackTarget)) {
float dx = movementInfo.x - target->getLatestX();
float dy = movementInfo.y - target->getLatestY();
float dz = movementInfo.z - target->getLatestZ();
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
likelyInRange = (dist3d <= 7.5f);
}
if (likelyInRange) {
auto pkt = AttackSwingPacket::build(autoAttackTarget);
socket->send(pkt);
}
}
break;
case Opcode::SMSG_ATTACKSWING_BADFACING:
if (autoAttackRequested_ && autoAttackTarget != 0) {
auto targetEntity = entityManager.getEntity(autoAttackTarget);
if (targetEntity) {
float toTargetX = targetEntity->getX() - movementInfo.x;
float toTargetY = targetEntity->getY() - movementInfo.y;
if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) {
movementInfo.orientation = std::atan2(-toTargetY, toTargetX);
sendMovement(Opcode::MSG_MOVE_SET_FACING);
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
}
if (socket) {
auto pkt = AttackSwingPacket::build(autoAttackTarget);
socket->send(pkt);
}
}
break;
case Opcode::SMSG_ATTACKSWING_NOTSTANDING:
case Opcode::SMSG_ATTACKSWING_CANT_ATTACK:
autoAttackOutOfRange_ = false;
autoAttackOutOfRangeTime_ = 0.0f;
break;
case Opcode::SMSG_ATTACKERSTATEUPDATE:
handleAttackerStateUpdate(packet);
break;
case Opcode::SMSG_AI_REACTION: {
// SMSG_AI_REACTION: uint64 guid, uint32 reaction
if (packet.getSize() - packet.getReadPos() < 12) break;
uint64_t guid = packet.readUInt64();
uint32_t reaction = packet.readUInt32();
// Reaction 2 commonly indicates aggro.
if (reaction == 2 && npcAggroCallback_) {
auto entity = entityManager.getEntity(guid);
if (entity) {
npcAggroCallback_(guid, glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
}
}
break;
}
case Opcode::SMSG_SPELLNONMELEEDAMAGELOG:
handleSpellDamageLog(packet);
break;
case Opcode::SMSG_PLAY_SPELL_VISUAL: {
// Minimal parse: uint64 casterGuid, uint32 visualId
if (packet.getSize() - packet.getReadPos() < 12) break;
packet.readUInt64();
packet.readUInt32();
break;
}
case Opcode::SMSG_SPELLHEALLOG:
handleSpellHealLog(packet);
break;
// ---- Phase 3: Spells ----
case Opcode::SMSG_INITIAL_SPELLS:
handleInitialSpells(packet);
break;
case Opcode::SMSG_CAST_FAILED:
handleCastFailed(packet);
break;
case Opcode::SMSG_SPELL_START:
handleSpellStart(packet);
break;
case Opcode::SMSG_SPELL_GO:
handleSpellGo(packet);
break;
case Opcode::SMSG_SPELL_FAILURE:
// Spell failed mid-cast
casting = false;
currentCastSpellId = 0;
break;
case Opcode::SMSG_SPELL_COOLDOWN:
handleSpellCooldown(packet);
break;
case Opcode::SMSG_COOLDOWN_EVENT:
handleCooldownEvent(packet);
break;
case Opcode::SMSG_CLEAR_COOLDOWN: {
// spellId(u32) + guid(u64): clear cooldown for the given spell/guid
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t spellId = packet.readUInt32();
// guid is present but we only track per-spell for the local player
spellCooldowns.erase(spellId);
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownRemaining = 0.0f;
}
}
LOG_DEBUG("SMSG_CLEAR_COOLDOWN: spellId=", spellId);
}
break;
}
case Opcode::SMSG_MODIFY_COOLDOWN: {
// spellId(u32) + diffMs(i32): adjust cooldown remaining by diffMs
if (packet.getSize() - packet.getReadPos() >= 8) {
uint32_t spellId = packet.readUInt32();
int32_t diffMs = static_cast<int32_t>(packet.readUInt32());
float diffSec = diffMs / 1000.0f;
auto it = spellCooldowns.find(spellId);
if (it != spellCooldowns.end()) {
it->second = std::max(0.0f, it->second + diffSec);
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownRemaining = std::max(0.0f, slot.cooldownRemaining + diffSec);
}
}
}
LOG_DEBUG("SMSG_MODIFY_COOLDOWN: spellId=", spellId, " diff=", diffMs, "ms");
}
break;
}
case Opcode::SMSG_ACHIEVEMENT_EARNED:
handleAchievementEarned(packet);
break;
case Opcode::SMSG_ALL_ACHIEVEMENT_DATA:
// Initial data burst on login — ignored for now (no achievement tracker UI).
break;
case Opcode::SMSG_ITEM_COOLDOWN: {
// uint64 itemGuid + uint32 spellId + uint32 cooldownMs
size_t rem = packet.getSize() - packet.getReadPos();
if (rem >= 16) {
/*uint64_t itemGuid =*/ packet.readUInt64();
uint32_t spellId = packet.readUInt32();
uint32_t cdMs = packet.readUInt32();
float cdSec = cdMs / 1000.0f;
if (spellId != 0 && cdSec > 0.0f) {
spellCooldowns[spellId] = cdSec;
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownRemaining = cdSec;
}
}
LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s");
}
}
break;
}
case Opcode::SMSG_FISH_NOT_HOOKED:
addSystemChatMessage("Your fish got away.");
break;
case Opcode::SMSG_FISH_ESCAPED:
addSystemChatMessage("Your fish escaped!");
break;
case Opcode::MSG_MINIMAP_PING:
// Minimap ping from a party member — consume; no visual support yet.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_ZONE_UNDER_ATTACK: {
// uint32 areaId
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t areaId = packet.readUInt32();
char buf[128];
std::snprintf(buf, sizeof(buf),
"A zone is under attack! (area %u)", areaId);
addSystemChatMessage(buf);
}
break;
}
case Opcode::SMSG_CANCEL_AUTO_REPEAT:
break; // Server signals to stop a repeating spell (wand/shoot); no client action needed
case Opcode::SMSG_AURA_UPDATE:
handleAuraUpdate(packet, false);
break;
case Opcode::SMSG_AURA_UPDATE_ALL:
handleAuraUpdate(packet, true);
break;
case Opcode::SMSG_DISPEL_FAILED: {
// casterGuid(8) + victimGuid(8) + spellId(4) [+ failing spellId(4)...]
if (packet.getSize() - packet.getReadPos() >= 20) {
/*uint64_t casterGuid =*/ packet.readUInt64();
/*uint64_t victimGuid =*/ packet.readUInt64();
uint32_t spellId = packet.readUInt32();
char buf[128];
std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", spellId);
addSystemChatMessage(buf);
}
break;
}
case Opcode::SMSG_TOTEM_CREATED: {
// uint8 slot + uint64 guid + uint32 duration + uint32 spellId
if (packet.getSize() - packet.getReadPos() >= 17) {
uint8_t slot = packet.readUInt8();
/*uint64_t guid =*/ packet.readUInt64();
uint32_t duration = packet.readUInt32();
uint32_t spellId = packet.readUInt32();
LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot,
" spellId=", spellId, " duration=", duration, "ms");
}
break;
}
case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: {
// uint64 guid + uint32 timeLeftMs
if (packet.getSize() - packet.getReadPos() >= 12) {
/*uint64_t guid =*/ packet.readUInt64();
uint32_t timeMs = packet.readUInt32();
uint32_t secs = timeMs / 1000;
char buf[128];
std::snprintf(buf, sizeof(buf),
"You will be able to resurrect in %u seconds.", secs);
addSystemChatMessage(buf);
}
break;
}
case Opcode::SMSG_DURABILITY_DAMAGE_DEATH: {
// uint32 percent (how much durability was lost due to death)
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t pct =*/ packet.readUInt32();
addSystemChatMessage("You have lost 10% of your gear's durability due to death.");
}
break;
}
case Opcode::SMSG_LEARNED_SPELL:
handleLearnedSpell(packet);
break;
case Opcode::SMSG_SUPERCEDED_SPELL:
handleSupercededSpell(packet);
break;
case Opcode::SMSG_REMOVED_SPELL:
handleRemovedSpell(packet);
break;
case Opcode::SMSG_SEND_UNLEARN_SPELLS:
handleUnlearnSpells(packet);
break;
// ---- Talents ----
case Opcode::SMSG_TALENTS_INFO:
handleTalentsInfo(packet);
break;
// ---- Phase 4: Group ----
case Opcode::SMSG_GROUP_INVITE:
handleGroupInvite(packet);
break;
case Opcode::SMSG_GROUP_DECLINE:
handleGroupDecline(packet);
break;
case Opcode::SMSG_GROUP_LIST:
handleGroupList(packet);
break;
case Opcode::SMSG_GROUP_UNINVITE:
handleGroupUninvite(packet);
break;
case Opcode::SMSG_PARTY_COMMAND_RESULT:
handlePartyCommandResult(packet);
break;
case Opcode::SMSG_PARTY_MEMBER_STATS:
handlePartyMemberStats(packet, false);
break;
case Opcode::SMSG_PARTY_MEMBER_STATS_FULL:
handlePartyMemberStats(packet, true);
break;
case Opcode::MSG_RAID_READY_CHECK:
// Server ready-check prompt (minimal handling for now).
packet.setReadPos(packet.getSize());
break;
case Opcode::MSG_RAID_READY_CHECK_CONFIRM:
// Ready-check responses from members.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_RAID_INSTANCE_INFO:
handleRaidInstanceInfo(packet);
break;
case Opcode::SMSG_DUEL_REQUESTED:
handleDuelRequested(packet);
break;
case Opcode::SMSG_DUEL_COMPLETE:
handleDuelComplete(packet);
break;
case Opcode::SMSG_DUEL_WINNER:
handleDuelWinner(packet);
break;
case Opcode::SMSG_DUEL_OUTOFBOUNDS:
addSystemChatMessage("You are out of the duel area!");
break;
case Opcode::SMSG_DUEL_INBOUNDS:
// Re-entered the duel area; no special action needed.
break;
case Opcode::SMSG_DUEL_COUNTDOWN:
// Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update.
break;
case Opcode::SMSG_PARTYKILLLOG:
// Classic-era packet: killer GUID + victim GUID.
// XP and combat state are handled by other packets; consume to avoid warning spam.
packet.setReadPos(packet.getSize());
break;
// ---- Guild ----
case Opcode::SMSG_GUILD_INFO:
handleGuildInfo(packet);
break;
case Opcode::SMSG_GUILD_ROSTER:
handleGuildRoster(packet);
break;
case Opcode::SMSG_GUILD_QUERY_RESPONSE:
handleGuildQueryResponse(packet);
break;
case Opcode::SMSG_GUILD_EVENT:
handleGuildEvent(packet);
break;
case Opcode::SMSG_GUILD_INVITE:
handleGuildInvite(packet);
break;
case Opcode::SMSG_GUILD_COMMAND_RESULT:
handleGuildCommandResult(packet);
break;
case Opcode::SMSG_PET_SPELLS:
handlePetSpells(packet);
break;
case Opcode::SMSG_PETITION_SHOWLIST:
handlePetitionShowlist(packet);
break;
case Opcode::SMSG_TURN_IN_PETITION_RESULTS:
handleTurnInPetitionResults(packet);
break;
// ---- Phase 5: Loot/Gossip/Vendor ----
case Opcode::SMSG_LOOT_RESPONSE:
handleLootResponse(packet);
break;
case Opcode::SMSG_LOOT_RELEASE_RESPONSE:
handleLootReleaseResponse(packet);
break;
case Opcode::SMSG_LOOT_REMOVED:
handleLootRemoved(packet);
break;
case Opcode::SMSG_QUEST_CONFIRM_ACCEPT:
handleQuestConfirmAccept(packet);
break;
case Opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE:
handleItemTextQueryResponse(packet);
break;
case Opcode::SMSG_SUMMON_REQUEST:
handleSummonRequest(packet);
break;
case Opcode::SMSG_SUMMON_CANCEL:
pendingSummonRequest_ = false;
addSystemChatMessage("Summon cancelled.");
break;
case Opcode::SMSG_TRADE_STATUS:
case Opcode::SMSG_TRADE_STATUS_EXTENDED:
handleTradeStatus(packet);
break;
case Opcode::SMSG_LOOT_ROLL:
handleLootRoll(packet);
break;
case Opcode::SMSG_LOOT_ROLL_WON:
handleLootRollWon(packet);
break;
case Opcode::SMSG_LOOT_MASTER_LIST:
// Master looter list — no UI yet; consume to avoid unhandled warning.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_GOSSIP_MESSAGE:
handleGossipMessage(packet);
break;
case Opcode::SMSG_QUESTGIVER_QUEST_LIST:
handleQuestgiverQuestList(packet);
break;
case Opcode::SMSG_BINDPOINTUPDATE: {
BindPointUpdateData data;
if (BindPointUpdateParser::parse(packet, data)) {
LOG_INFO("Bindpoint updated: mapId=", data.mapId,
" pos=(", data.x, ", ", data.y, ", ", data.z, ")");
glm::vec3 canonical = core::coords::serverToCanonical(
glm::vec3(data.x, data.y, data.z));
// Only show message if bind point was already set (not initial login sync)
bool wasSet = hasHomeBind_;
hasHomeBind_ = true;
homeBindMapId_ = data.mapId;
homeBindPos_ = canonical;
if (bindPointCallback_) {
bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z);
}
if (wasSet) {
addSystemChatMessage("Your home has been set.");
}
} else {
LOG_WARNING("Failed to parse SMSG_BINDPOINTUPDATE");
}
break;
}
case Opcode::SMSG_GOSSIP_COMPLETE:
handleGossipComplete(packet);
break;
case Opcode::SMSG_SPIRIT_HEALER_CONFIRM: {
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("SMSG_SPIRIT_HEALER_CONFIRM too short");
break;
}
uint64_t npcGuid = packet.readUInt64();
LOG_INFO("Spirit healer confirm from 0x", std::hex, npcGuid, std::dec);
if (npcGuid) {
resurrectCasterGuid_ = npcGuid;
resurrectRequestPending_ = true;
}
break;
}
case Opcode::SMSG_RESURRECT_REQUEST: {
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("SMSG_RESURRECT_REQUEST too short");
break;
}
uint64_t casterGuid = packet.readUInt64();
LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec);
if (casterGuid) {
resurrectCasterGuid_ = casterGuid;
resurrectRequestPending_ = true;
}
break;
}
case Opcode::SMSG_TIME_SYNC_REQ: {
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("SMSG_TIME_SYNC_REQ too short");
break;
}
uint32_t counter = packet.readUInt32();
LOG_DEBUG("Time sync request counter: ", counter);
if (socket) {
network::Packet resp(wireOpcode(Opcode::CMSG_TIME_SYNC_RESP));
resp.writeUInt32(counter);
resp.writeUInt32(nextMovementTimestampMs());
socket->send(resp);
}
break;
}
case Opcode::SMSG_LIST_INVENTORY:
handleListInventory(packet);
break;
case Opcode::SMSG_TRAINER_LIST:
handleTrainerList(packet);
break;
case Opcode::SMSG_TRAINER_BUY_SUCCEEDED: {
uint64_t guid = packet.readUInt64();
uint32_t spellId = packet.readUInt32();
(void)guid;
// Add to known spells immediately for prerequisite re-evaluation
// (SMSG_LEARNED_SPELL may come separately, but we need immediate update)
if (!knownSpells.count(spellId)) {
knownSpells.insert(spellId);
LOG_INFO("Added spell ", spellId, " to known spells (trainer purchase)");
}
const std::string& name = getSpellName(spellId);
if (!name.empty())
addSystemChatMessage("You have learned " + name + ".");
else
addSystemChatMessage("Spell learned.");
break;
}
case Opcode::SMSG_TRAINER_BUY_FAILED: {
// Server rejected the spell purchase
// Packet format: uint64 trainerGuid, uint32 spellId, uint32 errorCode
uint64_t trainerGuid = packet.readUInt64();
uint32_t spellId = packet.readUInt32();
uint32_t errorCode = 0;
if (packet.getSize() - packet.getReadPos() >= 4) {
errorCode = packet.readUInt32();
}
LOG_WARNING("Trainer buy spell failed: guid=", trainerGuid,
" spellId=", spellId, " error=", errorCode);
const std::string& spellName = getSpellName(spellId);
std::string msg = "Cannot learn ";
if (!spellName.empty()) msg += spellName;
else msg += "spell #" + std::to_string(spellId);
// Common error reasons
if (errorCode == 0) msg += " (not enough money)";
else if (errorCode == 1) msg += " (not enough skill)";
else if (errorCode == 2) msg += " (already known)";
else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")";
addSystemChatMessage(msg);
break;
}
// Silently ignore common packets we don't handle yet
case Opcode::SMSG_INIT_WORLD_STATES: {
// Minimal parse: uint32 mapId, uint32 zoneId, uint16 count, repeated (uint32 key, uint32 val)
if (packet.getSize() - packet.getReadPos() < 10) {
LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes");
break;
}
worldStateMapId_ = packet.readUInt32();
worldStateZoneId_ = packet.readUInt32();
uint16_t count = packet.readUInt16();
size_t needed = static_cast<size_t>(count) * 8;
size_t available = packet.getSize() - packet.getReadPos();
if (available < needed) {
// Be tolerant across expansion/private-core variants: if packet shape
// still looks like N*(key,val) dwords, parse what is present.
if ((available % 8) == 0) {
uint16_t adjustedCount = static_cast<uint16_t>(available / 8);
LOG_WARNING("SMSG_INIT_WORLD_STATES count mismatch: header=", count,
" adjusted=", adjustedCount, " (available=", available, ")");
count = adjustedCount;
needed = available;
} else {
LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed,
" bytes of state pairs, got ", available);
packet.setReadPos(packet.getSize());
break;
}
}
worldStates_.clear();
worldStates_.reserve(count);
for (uint16_t i = 0; i < count; ++i) {
uint32_t key = packet.readUInt32();
uint32_t val = packet.readUInt32();
worldStates_[key] = val;
}
break;
}
case Opcode::SMSG_INITIALIZE_FACTIONS: {
// Minimal parse: uint32 count, repeated (uint8 flags, int32 standing)
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("SMSG_INITIALIZE_FACTIONS too short: ", packet.getSize(), " bytes");
break;
}
uint32_t count = packet.readUInt32();
size_t needed = static_cast<size_t>(count) * 5;
if (packet.getSize() - packet.getReadPos() < needed) {
LOG_WARNING("SMSG_INITIALIZE_FACTIONS truncated: expected ", needed,
" bytes of faction data, got ", packet.getSize() - packet.getReadPos());
packet.setReadPos(packet.getSize());
break;
}
initialFactions_.clear();
initialFactions_.reserve(count);
for (uint32_t i = 0; i < count; ++i) {
FactionStandingInit fs{};
fs.flags = packet.readUInt8();
fs.standing = static_cast<int32_t>(packet.readUInt32());
initialFactions_.push_back(fs);
}
break;
}
case Opcode::SMSG_FEATURE_SYSTEM_STATUS:
case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER:
case Opcode::SMSG_SET_PCT_SPELL_MODIFIER:
case Opcode::SMSG_SPELL_DELAYED:
case Opcode::SMSG_EQUIPMENT_SET_SAVED:
case Opcode::SMSG_PERIODICAURALOG:
case Opcode::SMSG_SPELLENERGIZELOG:
case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG:
case Opcode::SMSG_SET_PROFICIENCY:
case Opcode::SMSG_ACTION_BUTTONS:
break;
case Opcode::SMSG_LEVELUP_INFO:
case Opcode::SMSG_LEVELUP_INFO_ALT: {
// Server-authoritative level-up event.
// First field is always the new level in Classic/TBC/WotLK-era layouts.
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t newLevel = packet.readUInt32();
if (newLevel > 0) {
uint32_t oldLevel = serverPlayerLevel_;
serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel);
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.level = serverPlayerLevel_;
break;
}
}
if (newLevel > oldLevel && levelUpCallback_) {
levelUpCallback_(newLevel);
}
}
}
// Remaining payload (hp/mana/stat deltas) is optional for our client.
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_PLAY_SOUND:
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t soundId = packet.readUInt32();
LOG_DEBUG("SMSG_PLAY_SOUND id=", soundId);
}
break;
case Opcode::SMSG_SERVER_MESSAGE: {
// uint32 type, string message
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t msgType =*/ packet.readUInt32();
std::string msg = packet.readString();
if (!msg.empty()) addSystemChatMessage("[Server] " + msg);
}
break;
}
case Opcode::SMSG_CHAT_SERVER_MESSAGE: {
// uint32 type + string text
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t msgType =*/ packet.readUInt32();
std::string msg = packet.readString();
if (!msg.empty()) addSystemChatMessage("[Announcement] " + msg);
}
break;
}
case Opcode::SMSG_AREA_TRIGGER_MESSAGE: {
// uint32 size, then string
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t len =*/ packet.readUInt32();
std::string msg = packet.readString();
if (!msg.empty()) addSystemChatMessage(msg);
}
break;
}
case Opcode::SMSG_TRIGGER_CINEMATIC:
// uint32 cinematicId — we don't play cinematics; consume and skip.
packet.setReadPos(packet.getSize());
LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped");
break;
case Opcode::SMSG_LOOT_MONEY_NOTIFY: {
// Format: uint32 money + uint8 soleLooter
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t amount = packet.readUInt32();
if (packet.getSize() - packet.getReadPos() >= 1) {
/*uint8_t soleLooter =*/ packet.readUInt8();
}
playerMoneyCopper_ += amount;
pendingMoneyDelta_ = amount;
pendingMoneyDeltaTimer_ = 2.0f;
LOG_INFO("Looted ", amount, " copper (total: ", playerMoneyCopper_, ")");
uint64_t notifyGuid = pendingLootMoneyGuid_ != 0 ? pendingLootMoneyGuid_ : currentLoot.lootGuid;
pendingLootMoneyGuid_ = 0;
pendingLootMoneyAmount_ = 0;
pendingLootMoneyNotifyTimer_ = 0.0f;
bool alreadyAnnounced = false;
auto it = localLootState_.find(notifyGuid);
if (it != localLootState_.end()) {
alreadyAnnounced = it->second.moneyTaken;
it->second.moneyTaken = true;
}
if (!alreadyAnnounced) {
addSystemChatMessage("Looted: " + formatCopperAmount(amount));
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
if (amount >= 10000) {
sfx->playLootCoinLarge();
} else {
sfx->playLootCoinSmall();
}
}
}
if (notifyGuid != 0) {
recentLootMoneyAnnounceCooldowns_[notifyGuid] = 1.5f;
}
}
}
break;
}
case Opcode::SMSG_LOOT_CLEAR_MONEY:
case Opcode::SMSG_NPC_TEXT_UPDATE:
break;
case Opcode::SMSG_SELL_ITEM: {
// uint64 vendorGuid, uint64 itemGuid, uint8 result
if ((packet.getSize() - packet.getReadPos()) >= 17) {
uint64_t vendorGuid = packet.readUInt64();
uint64_t itemGuid = packet.readUInt64(); // itemGuid
uint8_t result = packet.readUInt8();
LOG_INFO("SMSG_SELL_ITEM: vendorGuid=0x", std::hex, vendorGuid,
" itemGuid=0x", itemGuid, std::dec,
" result=", static_cast<int>(result));
if (result == 0) {
pendingSellToBuyback_.erase(itemGuid);
} else {
bool removedPending = false;
auto it = pendingSellToBuyback_.find(itemGuid);
if (it != pendingSellToBuyback_.end()) {
for (auto bit = buybackItems_.begin(); bit != buybackItems_.end(); ++bit) {
if (bit->itemGuid == itemGuid) {
buybackItems_.erase(bit);
break;
}
}
pendingSellToBuyback_.erase(it);
removedPending = true;
}
if (!removedPending) {
// Some cores return a non-item GUID on sell failure; drop the newest
// optimistic entry if it is still pending so stale rows don't block buyback.
if (!buybackItems_.empty()) {
uint64_t frontGuid = buybackItems_.front().itemGuid;
if (pendingSellToBuyback_.erase(frontGuid) > 0) {
buybackItems_.pop_front();
removedPending = true;
}
}
}
if (!removedPending && !pendingSellToBuyback_.empty()) {
// Last-resort desync recovery.
pendingSellToBuyback_.clear();
buybackItems_.clear();
}
static const char* sellErrors[] = {
"OK", "Can't find item", "Can't sell item",
"Can't find vendor", "You don't own that item",
"Unknown error", "Only empty bag"
};
const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error";
addSystemChatMessage(std::string("Sell failed: ") + msg);
LOG_WARNING("SMSG_SELL_ITEM error: ", (int)result, " (", msg, ")");
}
}
break;
}
case Opcode::SMSG_INVENTORY_CHANGE_FAILURE: {
if ((packet.getSize() - packet.getReadPos()) >= 1) {
uint8_t error = packet.readUInt8();
if (error != 0) {
LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error);
// InventoryResult enum (AzerothCore 3.3.5a)
const char* errMsg = nullptr;
switch (error) {
case 1: errMsg = "You must reach level %d to use that item."; break;
case 2: errMsg = "You don't have the required skill."; break;
case 3: errMsg = "That item doesn't go in that slot."; break;
case 4: errMsg = "That bag is full."; break;
case 5: errMsg = "Can't put bags in bags."; break;
case 6: errMsg = "Can't trade equipped bags."; break;
case 7: errMsg = "That slot only holds ammo."; break;
case 8: errMsg = "You can't use that item."; break;
case 9: errMsg = "No equipment slot available."; break;
case 10: errMsg = "You can never use that item."; break;
case 11: errMsg = "You can never use that item."; break;
case 12: errMsg = "No equipment slot available."; break;
case 13: errMsg = "Can't equip with a two-handed weapon."; break;
case 14: errMsg = "Can't dual-wield."; break;
case 15: errMsg = "That item doesn't go in that bag."; break;
case 16: errMsg = "That item doesn't go in that bag."; break;
case 17: errMsg = "You can't carry any more of those."; break;
case 18: errMsg = "No equipment slot available."; break;
case 19: errMsg = "Can't stack those items."; break;
case 20: errMsg = "That item can't be equipped."; break;
case 21: errMsg = "Can't swap items."; break;
case 22: errMsg = "That slot is empty."; break;
case 23: errMsg = "Item not found."; break;
case 24: errMsg = "Can't drop soulbound items."; break;
case 25: errMsg = "Out of range."; break;
case 26: errMsg = "Need to split more than 1."; break;
case 27: errMsg = "Split failed."; break;
case 28: errMsg = "Not enough reagents."; break;
case 29: errMsg = "Not enough money."; break;
case 30: errMsg = "Not a bag."; break;
case 31: errMsg = "Can't destroy non-empty bag."; break;
case 32: errMsg = "You don't own that item."; break;
case 33: errMsg = "You can only have one quiver."; break;
case 34: errMsg = "No free bank slots."; break;
case 35: errMsg = "No bank here."; break;
case 36: errMsg = "Item is locked."; break;
case 37: errMsg = "You are stunned."; break;
case 38: errMsg = "You are dead."; break;
case 39: errMsg = "Can't do that right now."; break;
case 40: errMsg = "Internal bag error."; break;
case 49: errMsg = "Loot is gone."; break;
case 50: errMsg = "Inventory is full."; break;
case 51: errMsg = "Bank is full."; break;
case 52: errMsg = "That item is sold out."; break;
case 58: errMsg = "That object is busy."; break;
case 60: errMsg = "Can't do that in combat."; break;
case 61: errMsg = "Can't do that while disarmed."; break;
case 63: errMsg = "Requires a higher rank."; break;
case 64: errMsg = "Requires higher reputation."; break;
case 67: errMsg = "That item is unique-equipped."; break;
case 69: errMsg = "Not enough honor points."; break;
case 70: errMsg = "Not enough arena points."; break;
case 77: errMsg = "Too much gold."; break;
case 78: errMsg = "Can't do that during arena match."; break;
case 80: errMsg = "Requires a personal arena rating."; break;
case 87: errMsg = "Requires a higher level."; break;
case 88: errMsg = "Requires the right talent."; break;
default: break;
}
std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ").";
addSystemChatMessage(msg);
}
}
break;
}
case Opcode::SMSG_BUY_FAILED: {
// vendorGuid(8) + itemId(4) + errorCode(1)
if (packet.getSize() - packet.getReadPos() >= 13) {
uint64_t vendorGuid = packet.readUInt64();
uint32_t itemIdOrSlot = packet.readUInt32();
uint8_t errCode = packet.readUInt8();
LOG_INFO("SMSG_BUY_FAILED: vendorGuid=0x", std::hex, vendorGuid, std::dec,
" item/slot=", itemIdOrSlot,
" err=", static_cast<int>(errCode),
" pendingBuybackSlot=", pendingBuybackSlot_,
" pendingBuybackWireSlot=", pendingBuybackWireSlot_,
" pendingBuyItemId=", pendingBuyItemId_,
" pendingBuyItemSlot=", pendingBuyItemSlot_);
if (pendingBuybackSlot_ >= 0) {
// Some cores require probing absolute buyback slots until a live entry is found.
if (errCode == 0) {
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
constexpr uint32_t kBuybackSlotEnd = 85;
if (pendingBuybackWireSlot_ >= 74 && pendingBuybackWireSlot_ < kBuybackSlotEnd &&
socket && state == WorldState::IN_WORLD && currentVendorItems.vendorGuid != 0) {
++pendingBuybackWireSlot_;
LOG_INFO("Buyback retry: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
std::dec, " uiSlot=", pendingBuybackSlot_,
" wireSlot=", pendingBuybackWireSlot_);
network::Packet retry(kWotlkCmsgBuybackItemOpcode);
retry.writeUInt64(currentVendorItems.vendorGuid);
retry.writeUInt32(pendingBuybackWireSlot_);
socket->send(retry);
break;
}
// Exhausted slot probe: drop stale local row and advance.
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
auto pkt = ListInventoryPacket::build(currentVendorItems.vendorGuid);
socket->send(pkt);
}
break;
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
}
const char* msg = "Purchase failed.";
switch (errCode) {
case 0: msg = "Purchase failed: item not found."; break;
case 2: msg = "You don't have enough money."; break;
case 4: msg = "Seller is too far away."; break;
case 5: msg = "That item is sold out."; break;
case 6: msg = "You can't carry any more items."; break;
default: break;
}
addSystemChatMessage(msg);
}
break;
}
case Opcode::MSG_RAID_TARGET_UPDATE:
break;
case Opcode::SMSG_WEATHER: {
// Format: uint32 weatherType, float intensity, uint8 isAbrupt
if (packet.getSize() - packet.getReadPos() >= 9) {
uint32_t wType = packet.readUInt32();
float wIntensity = packet.readFloat();
/*uint8_t isAbrupt =*/ packet.readUInt8();
weatherType_ = wType;
weatherIntensity_ = wIntensity;
const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear";
LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity);
}
break;
}
case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE:
handleGameObjectQueryResponse(packet);
break;
case Opcode::SMSG_GAMEOBJECT_PAGETEXT:
handleGameObjectPageText(packet);
break;
case Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM: {
if (packet.getSize() >= 12) {
uint64_t guid = packet.readUInt64();
uint32_t animId = packet.readUInt32();
if (gameObjectCustomAnimCallback_) {
gameObjectCustomAnimCallback_(guid, animId);
}
}
break;
}
case Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE:
handlePageTextQueryResponse(packet);
break;
case Opcode::SMSG_QUESTGIVER_STATUS: {
if (packet.getSize() - packet.getReadPos() >= 9) {
uint64_t npcGuid = packet.readUInt64();
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
LOG_DEBUG("SMSG_QUESTGIVER_STATUS: guid=0x", std::hex, npcGuid, std::dec, " status=", (int)status);
}
break;
}
case Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE: {
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t count = packet.readUInt32();
for (uint32_t i = 0; i < count; ++i) {
if (packet.getSize() - packet.getReadPos() < 9) break;
uint64_t npcGuid = packet.readUInt64();
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
}
LOG_DEBUG("SMSG_QUESTGIVER_STATUS_MULTIPLE: ", count, " entries");
}
break;
}
case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS:
handleQuestDetails(packet);
break;
case Opcode::SMSG_QUESTGIVER_QUEST_INVALID: {
// Quest query failed - parse failure reason
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t failReason = packet.readUInt32();
pendingTurnInRewardRequest_ = false;
const char* reasonStr = "Unknown";
switch (failReason) {
case 0: reasonStr = "Don't have quest"; break;
case 1: reasonStr = "Quest level too low"; break;
case 4: reasonStr = "Insufficient money"; break;
case 5: reasonStr = "Inventory full"; break;
case 13: reasonStr = "Already on that quest"; break;
case 18: reasonStr = "Already completed quest"; break;
case 19: reasonStr = "Can't take any more quests"; break;
}
LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")");
if (!pendingQuestAcceptTimeouts_.empty()) {
std::vector<uint32_t> pendingQuestIds;
pendingQuestIds.reserve(pendingQuestAcceptTimeouts_.size());
for (const auto& pending : pendingQuestAcceptTimeouts_) {
pendingQuestIds.push_back(pending.first);
}
for (uint32_t questId : pendingQuestIds) {
const uint64_t npcGuid = pendingQuestAcceptNpcGuids_.count(questId) != 0
? pendingQuestAcceptNpcGuids_[questId] : 0;
if (failReason == 13) {
std::string fallbackTitle = "Quest #" + std::to_string(questId);
std::string fallbackObjectives;
if (currentQuestDetails.questId == questId) {
if (!currentQuestDetails.title.empty()) fallbackTitle = currentQuestDetails.title;
fallbackObjectives = currentQuestDetails.objectives;
}
addQuestToLocalLogIfMissing(questId, fallbackTitle, fallbackObjectives);
triggerQuestAcceptResync(questId, npcGuid, "already-on-quest");
} else if (failReason == 18) {
triggerQuestAcceptResync(questId, npcGuid, "already-completed");
}
clearPendingQuestAccept(questId);
}
}
// Only show error to user for real errors (not informational messages)
if (failReason != 13 && failReason != 18) { // Don't spam "already on/completed"
addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr);
}
}
break;
}
case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: {
// Mark quest as complete in local log
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t questId = packet.readUInt32();
LOG_INFO("Quest completed: questId=", questId);
if (pendingTurnInQuestId_ == questId) {
pendingTurnInQuestId_ = 0;
pendingTurnInNpcGuid_ = 0;
pendingTurnInRewardRequest_ = false;
}
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
if (it->questId == questId) {
questLog_.erase(it);
LOG_INFO(" Removed quest ", questId, " from quest log");
break;
}
}
}
// Re-query all nearby quest giver NPCs so markers refresh
if (socket) {
for (const auto& [guid, entity] : entityManager.getEntities()) {
if (entity->getType() != ObjectType::UNIT) continue;
auto unit = std::static_pointer_cast<Unit>(entity);
if (unit->getNpcFlags() & 0x02) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(guid);
socket->send(qsPkt);
}
}
}
break;
}
case Opcode::SMSG_QUESTUPDATE_ADD_KILL: {
// Quest kill count update
// Compatibility: some classic-family opcode tables swap ADD_KILL and COMPLETE.
size_t rem = packet.getSize() - packet.getReadPos();
if (rem >= 12) {
uint32_t questId = packet.readUInt32();
clearPendingQuestAccept(questId);
uint32_t entry = packet.readUInt32(); // Creature entry
uint32_t count = packet.readUInt32(); // Current kills
uint32_t reqCount = 0;
if (packet.getSize() - packet.getReadPos() >= 4) {
reqCount = packet.readUInt32(); // Required kills (if present)
}
LOG_INFO("Quest kill update: questId=", questId, " entry=", entry,
" count=", count, "/", reqCount);
// Update quest log with kill count
for (auto& quest : questLog_) {
if (quest.questId == questId) {
// Preserve prior required count if this packet variant omits it.
if (reqCount == 0) {
auto it = quest.killCounts.find(entry);
if (it != quest.killCounts.end()) reqCount = it->second.second;
if (reqCount == 0) reqCount = count;
}
quest.killCounts[entry] = {count, reqCount};
std::string progressMsg = quest.title + ": " +
std::to_string(count) + "/" +
std::to_string(reqCount);
addSystemChatMessage(progressMsg);
LOG_INFO("Updated kill count for quest ", questId, ": ",
count, "/", reqCount);
break;
}
}
} else if (rem >= 4) {
// Swapped mapping fallback: treat as QUESTUPDATE_COMPLETE packet.
uint32_t questId = packet.readUInt32();
clearPendingQuestAccept(questId);
LOG_INFO("Quest objectives completed (compat via ADD_KILL): questId=", questId);
for (auto& quest : questLog_) {
if (quest.questId == questId) {
quest.complete = true;
addSystemChatMessage("Quest Complete: " + quest.title);
break;
}
}
}
break;
}
case Opcode::SMSG_QUESTUPDATE_ADD_ITEM: {
// Quest item count update: itemId + count
if (packet.getSize() - packet.getReadPos() >= 8) {
uint32_t itemId = packet.readUInt32();
uint32_t count = packet.readUInt32();
queryItemInfo(itemId, 0);
std::string itemLabel = "item #" + std::to_string(itemId);
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
if (!info->name.empty()) itemLabel = info->name;
}
bool updatedAny = false;
for (auto& quest : questLog_) {
if (quest.complete) continue;
const bool tracksItem =
quest.requiredItemCounts.count(itemId) > 0 ||
quest.itemCounts.count(itemId) > 0;
if (!tracksItem) continue;
quest.itemCounts[itemId] = count;
updatedAny = true;
}
addSystemChatMessage("Quest item: " + itemLabel + " (" + std::to_string(count) + ")");
LOG_INFO("Quest item update: itemId=", itemId, " count=", count,
" trackedQuestsUpdated=", updatedAny);
}
break;
}
case Opcode::SMSG_QUESTUPDATE_COMPLETE: {
// Quest objectives completed - mark as ready to turn in.
// Compatibility: some classic-family opcode tables swap COMPLETE and ADD_KILL.
size_t rem = packet.getSize() - packet.getReadPos();
if (rem >= 12) {
uint32_t questId = packet.readUInt32();
clearPendingQuestAccept(questId);
uint32_t entry = packet.readUInt32();
uint32_t count = packet.readUInt32();
uint32_t reqCount = 0;
if (packet.getSize() - packet.getReadPos() >= 4) reqCount = packet.readUInt32();
if (reqCount == 0) reqCount = count;
LOG_INFO("Quest kill update (compat via COMPLETE): questId=", questId,
" entry=", entry, " count=", count, "/", reqCount);
for (auto& quest : questLog_) {
if (quest.questId == questId) {
quest.killCounts[entry] = {count, reqCount};
addSystemChatMessage(quest.title + ": " + std::to_string(count) +
"/" + std::to_string(reqCount));
break;
}
}
} else if (rem >= 4) {
uint32_t questId = packet.readUInt32();
clearPendingQuestAccept(questId);
LOG_INFO("Quest objectives completed: questId=", questId);
for (auto& quest : questLog_) {
if (quest.questId == questId) {
quest.complete = true;
addSystemChatMessage("Quest Complete: " + quest.title);
LOG_INFO("Marked quest ", questId, " as complete");
break;
}
}
}
break;
}
case Opcode::SMSG_QUEST_FORCE_REMOVE: {
// Minimal parse: uint32 questId
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("SMSG_QUEST_FORCE_REMOVE too short");
break;
}
uint32_t questId = packet.readUInt32();
clearPendingQuestAccept(questId);
pendingQuestQueryIds_.erase(questId);
if (questId == 0) {
// Some servers emit a zero-id variant during world bootstrap.
// Treat as no-op to avoid false "Quest removed" spam.
break;
}
bool removed = false;
std::string removedTitle;
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
if (it->questId == questId) {
removedTitle = it->title;
questLog_.erase(it);
removed = true;
break;
}
}
if (currentQuestDetails.questId == questId) {
questDetailsOpen = false;
currentQuestDetails = QuestDetailsData{};
removed = true;
}
if (currentQuestRequestItems_.questId == questId) {
questRequestItemsOpen_ = false;
currentQuestRequestItems_ = QuestRequestItemsData{};
removed = true;
}
if (currentQuestOfferReward_.questId == questId) {
questOfferRewardOpen_ = false;
currentQuestOfferReward_ = QuestOfferRewardData{};
removed = true;
}
if (removed) {
if (!removedTitle.empty()) {
addSystemChatMessage("Quest removed: " + removedTitle);
} else {
addSystemChatMessage("Quest removed (ID " + std::to_string(questId) + ").");
}
}
break;
}
case Opcode::SMSG_QUEST_QUERY_RESPONSE: {
if (packet.getSize() < 8) {
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
break;
}
uint32_t questId = packet.readUInt32();
packet.readUInt32(); // questMethod
const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3;
const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout);
for (auto& q : questLog_) {
if (q.questId != questId) continue;
const int existingScore = scoreQuestTitle(q.title);
const bool parsedStrong = isStrongQuestTitle(parsed.title);
const bool parsedLongEnough = parsed.title.size() >= 6;
const bool notShorterThanExisting =
isPlaceholderQuestTitle(q.title) || q.title.empty() || parsed.title.size() + 2 >= q.title.size();
const bool shouldReplaceTitle =
parsed.score > -1000 &&
parsedStrong &&
parsedLongEnough &&
notShorterThanExisting &&
(isPlaceholderQuestTitle(q.title) || q.title.empty() || parsed.score >= existingScore + 12);
if (shouldReplaceTitle && !parsed.title.empty()) {
q.title = parsed.title;
}
if (!parsed.objectives.empty() &&
(q.objectives.empty() || q.objectives.size() < 16)) {
q.objectives = parsed.objectives;
}
break;
}
pendingQuestQueryIds_.erase(questId);
break;
}
case Opcode::SMSG_QUESTLOG_FULL:
// Zero-payload notification: the player's quest log is full (25 quests).
addSystemChatMessage("Your quest log is full.");
LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity");
break;
case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS:
handleQuestRequestItems(packet);
break;
case Opcode::SMSG_QUESTGIVER_OFFER_REWARD:
handleQuestOfferReward(packet);
break;
case Opcode::SMSG_GROUP_SET_LEADER:
LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec);
break;
// ---- Teleport / Transfer ----
case Opcode::MSG_MOVE_TELEPORT_ACK:
handleTeleportAck(packet);
break;
case Opcode::SMSG_TRANSFER_PENDING: {
// SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data
uint32_t pendingMapId = packet.readUInt32();
LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
// Optional: if remaining data, there's a transport entry + mapId
if (packet.getReadPos() + 8 <= packet.getSize()) {
uint32_t transportEntry = packet.readUInt32();
uint32_t transportMapId = packet.readUInt32();
LOG_INFO(" Transport entry=", transportEntry, " transportMapId=", transportMapId);
}
break;
}
case Opcode::SMSG_NEW_WORLD:
handleNewWorld(packet);
break;
case Opcode::SMSG_TRANSFER_ABORTED: {
uint32_t mapId = packet.readUInt32();
uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0;
LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason);
addSystemChatMessage("Transfer aborted.");
break;
}
// ---- Taxi / Flight Paths ----
case Opcode::SMSG_SHOWTAXINODES:
handleShowTaxiNodes(packet);
break;
case Opcode::SMSG_ACTIVATETAXIREPLY:
handleActivateTaxiReply(packet);
break;
case Opcode::SMSG_STANDSTATE_UPDATE:
// Server confirms stand state change (sit/stand/sleep/kneel)
if (packet.getSize() - packet.getReadPos() >= 1) {
standState_ = packet.readUInt8();
LOG_INFO("Stand state updated: ", static_cast<int>(standState_),
" (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit"
: standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")");
}
break;
case Opcode::SMSG_NEW_TAXI_PATH:
// Empty packet - server signals a new flight path was learned
// The actual node details come in the next SMSG_SHOWTAXINODES
addSystemChatMessage("New flight path discovered!");
break;
// ---- Arena / Battleground ----
case Opcode::SMSG_BATTLEFIELD_STATUS:
handleBattlefieldStatus(packet);
break;
case Opcode::SMSG_BATTLEFIELD_LIST:
LOG_INFO("Received SMSG_BATTLEFIELD_LIST");
break;
case Opcode::SMSG_BATTLEFIELD_PORT_DENIED:
addSystemChatMessage("Battlefield port denied.");
break;
case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS:
// Optional map position updates for BG objectives/players.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE:
addSystemChatMessage("You have been removed from the PvP queue.");
break;
case Opcode::SMSG_GROUP_JOINED_BATTLEGROUND:
addSystemChatMessage("Your group has joined the battleground.");
break;
case Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE:
addSystemChatMessage("You have joined the battleground queue.");
break;
case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED:
LOG_INFO("Battleground player joined");
break;
case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT:
LOG_INFO("Battleground player left");
break;
case Opcode::SMSG_INSTANCE_DIFFICULTY:
handleInstanceDifficulty(packet);
break;
case Opcode::SMSG_INSTANCE_SAVE_CREATED:
// Zero-payload: your instance save was just created on the server.
addSystemChatMessage("You are now saved to this instance.");
LOG_INFO("SMSG_INSTANCE_SAVE_CREATED");
break;
case Opcode::SMSG_RAID_INSTANCE_MESSAGE: {
if (packet.getSize() - packet.getReadPos() >= 12) {
uint32_t msgType = packet.readUInt32();
uint32_t mapId = packet.readUInt32();
/*uint32_t diff =*/ packet.readUInt32();
// type: 1=warning(time left), 2=saved, 3=welcome
if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) {
uint32_t timeLeft = packet.readUInt32();
uint32_t minutes = timeLeft / 60;
std::string msg = "Instance " + std::to_string(mapId) +
" will reset in " + std::to_string(minutes) + " minute(s).";
addSystemChatMessage(msg);
} else if (msgType == 2) {
addSystemChatMessage("You have been saved to instance " + std::to_string(mapId) + ".");
} else if (msgType == 3) {
addSystemChatMessage("Welcome to instance " + std::to_string(mapId) + ".");
}
LOG_INFO("SMSG_RAID_INSTANCE_MESSAGE: type=", msgType, " map=", mapId);
}
break;
}
case Opcode::SMSG_INSTANCE_RESET: {
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t mapId = packet.readUInt32();
// Remove matching lockout from local cache
auto it = std::remove_if(instanceLockouts_.begin(), instanceLockouts_.end(),
[mapId](const InstanceLockout& lo){ return lo.mapId == mapId; });
instanceLockouts_.erase(it, instanceLockouts_.end());
addSystemChatMessage("Instance " + std::to_string(mapId) + " has been reset.");
LOG_INFO("SMSG_INSTANCE_RESET: mapId=", mapId);
}
break;
}
case Opcode::SMSG_INSTANCE_RESET_FAILED: {
if (packet.getSize() - packet.getReadPos() >= 8) {
uint32_t mapId = packet.readUInt32();
uint32_t reason = packet.readUInt32();
static const char* resetFailReasons[] = {
"Not max level.", "Offline party members.", "Party members inside.",
"Party members changing zone.", "Heroic difficulty only."
};
const char* msg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason.";
addSystemChatMessage("Cannot reset instance " + std::to_string(mapId) + ": " + msg);
LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason);
}
break;
}
case Opcode::SMSG_INSTANCE_LOCK_WARNING_QUERY: {
// Server asks player to confirm entering a saved instance.
// We auto-confirm with CMSG_INSTANCE_LOCK_RESPONSE.
if (socket && packet.getSize() - packet.getReadPos() >= 17) {
/*uint32_t mapId =*/ packet.readUInt32();
/*uint32_t diff =*/ packet.readUInt32();
/*uint32_t timeLeft =*/ packet.readUInt32();
packet.readUInt32(); // unk
/*uint8_t locked =*/ packet.readUInt8();
// Send acceptance
network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE));
resp.writeUInt8(1); // 1=accept
socket->send(resp);
LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted");
}
break;
}
// ---- LFG / Dungeon Finder ----
case Opcode::SMSG_LFG_JOIN_RESULT:
handleLfgJoinResult(packet);
break;
case Opcode::SMSG_LFG_QUEUE_STATUS:
handleLfgQueueStatus(packet);
break;
case Opcode::SMSG_LFG_PROPOSAL_UPDATE:
handleLfgProposalUpdate(packet);
break;
case Opcode::SMSG_LFG_ROLE_CHECK_UPDATE:
handleLfgRoleCheckUpdate(packet);
break;
case Opcode::SMSG_LFG_UPDATE_PLAYER:
case Opcode::SMSG_LFG_UPDATE_PARTY:
handleLfgUpdatePlayer(packet);
break;
case Opcode::SMSG_LFG_PLAYER_REWARD:
handleLfgPlayerReward(packet);
break;
case Opcode::SMSG_LFG_BOOT_PROPOSAL_UPDATE:
handleLfgBootProposalUpdate(packet);
break;
case Opcode::SMSG_LFG_TELEPORT_DENIED:
handleLfgTeleportDenied(packet);
break;
case Opcode::SMSG_LFG_DISABLED:
addSystemChatMessage("The Dungeon Finder is currently disabled.");
LOG_INFO("SMSG_LFG_DISABLED received");
break;
case Opcode::SMSG_LFG_OFFER_CONTINUE:
addSystemChatMessage("Dungeon Finder: You may continue your dungeon.");
break;
case Opcode::SMSG_LFG_ROLE_CHOSEN:
case Opcode::SMSG_LFG_UPDATE_SEARCH:
case Opcode::SMSG_UPDATE_LFG_LIST:
case Opcode::SMSG_LFG_PLAYER_INFO:
case Opcode::SMSG_LFG_PARTY_INFO:
case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER:
// Informational LFG packets not yet surfaced in UI — consume silently.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT:
handleArenaTeamCommandResult(packet);
break;
case Opcode::SMSG_ARENA_TEAM_QUERY_RESPONSE:
handleArenaTeamQueryResponse(packet);
break;
case Opcode::SMSG_ARENA_TEAM_ROSTER:
LOG_INFO("Received SMSG_ARENA_TEAM_ROSTER");
break;
case Opcode::SMSG_ARENA_TEAM_INVITE:
handleArenaTeamInvite(packet);
break;
case Opcode::SMSG_ARENA_TEAM_EVENT:
handleArenaTeamEvent(packet);
break;
case Opcode::SMSG_ARENA_TEAM_STATS:
LOG_INFO("Received SMSG_ARENA_TEAM_STATS");
break;
case Opcode::SMSG_ARENA_ERROR:
handleArenaError(packet);
break;
case Opcode::MSG_PVP_LOG_DATA:
LOG_INFO("Received MSG_PVP_LOG_DATA");
break;
case Opcode::MSG_INSPECT_ARENA_TEAMS:
LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS");
break;
case Opcode::MSG_TALENT_WIPE_CONFIRM:
// Talent reset confirmation payload is not needed client-side right now.
packet.setReadPos(packet.getSize());
break;
// ---- MSG_MOVE_* opcodes (server relays other players' movement) ----
case Opcode::MSG_MOVE_START_FORWARD:
case Opcode::MSG_MOVE_START_BACKWARD:
case Opcode::MSG_MOVE_STOP:
case Opcode::MSG_MOVE_START_STRAFE_LEFT:
case Opcode::MSG_MOVE_START_STRAFE_RIGHT:
case Opcode::MSG_MOVE_STOP_STRAFE:
case Opcode::MSG_MOVE_JUMP:
case Opcode::MSG_MOVE_START_TURN_LEFT:
case Opcode::MSG_MOVE_START_TURN_RIGHT:
case Opcode::MSG_MOVE_STOP_TURN:
case Opcode::MSG_MOVE_SET_FACING:
case Opcode::MSG_MOVE_FALL_LAND:
case Opcode::MSG_MOVE_HEARTBEAT:
case Opcode::MSG_MOVE_START_SWIM:
case Opcode::MSG_MOVE_STOP_SWIM:
if (state == WorldState::IN_WORLD) {
handleOtherPlayerMovement(packet);
}
break;
// ---- Mail ----
case Opcode::SMSG_SHOW_MAILBOX:
handleShowMailbox(packet);
break;
case Opcode::SMSG_MAIL_LIST_RESULT:
handleMailListResult(packet);
break;
case Opcode::SMSG_SEND_MAIL_RESULT:
handleSendMailResult(packet);
break;
case Opcode::SMSG_RECEIVED_MAIL:
handleReceivedMail(packet);
break;
case Opcode::MSG_QUERY_NEXT_MAIL_TIME:
handleQueryNextMailTime(packet);
break;
case Opcode::SMSG_CHANNEL_LIST:
// Channel member listing currently not rendered in UI.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_INSPECT_RESULTS_UPDATE:
handleInspectResults(packet);
break;
// ---- Bank ----
case Opcode::SMSG_SHOW_BANK:
handleShowBank(packet);
break;
case Opcode::SMSG_BUY_BANK_SLOT_RESULT:
handleBuyBankSlotResult(packet);
break;
// ---- Guild Bank ----
case Opcode::SMSG_GUILD_BANK_LIST:
handleGuildBankList(packet);
break;
// ---- Auction House ----
case Opcode::MSG_AUCTION_HELLO:
handleAuctionHello(packet);
break;
case Opcode::SMSG_AUCTION_LIST_RESULT:
handleAuctionListResult(packet);
break;
case Opcode::SMSG_AUCTION_OWNER_LIST_RESULT:
handleAuctionOwnerListResult(packet);
break;
case Opcode::SMSG_AUCTION_BIDDER_LIST_RESULT:
handleAuctionBidderListResult(packet);
break;
case Opcode::SMSG_AUCTION_COMMAND_RESULT:
handleAuctionCommandResult(packet);
break;
case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: {
// auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ...
if (packet.getSize() - packet.getReadPos() >= 16) {
uint32_t auctionId = packet.readUInt32();
uint32_t action = packet.readUInt32();
uint32_t error = packet.readUInt32();
uint32_t itemEntry = packet.readUInt32();
(void)auctionId; (void)action; (void)error;
ensureItemInfo(itemEntry);
auto* info = getItemInfo(itemEntry);
std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry));
addSystemChatMessage("Your auction of " + itemName + " has sold!");
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: {
// auctionId(u32) + itemEntry(u32) + ...
if (packet.getSize() - packet.getReadPos() >= 8) {
uint32_t auctionId = packet.readUInt32();
uint32_t itemEntry = packet.readUInt32();
(void)auctionId;
ensureItemInfo(itemEntry);
auto* info = getItemInfo(itemEntry);
std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry));
addSystemChatMessage("You have been outbid on " + itemName + ".");
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_TAXINODE_STATUS:
// Node status cache not implemented yet.
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE:
case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE:
// Extra aura metadata (icons/durations) not yet consumed by aura UI.
packet.setReadPos(packet.getSize());
break;
case Opcode::MSG_MOVE_WORLDPORT_ACK:
// Client uses this outbound; treat inbound variant as no-op for robustness.
packet.setReadPos(packet.getSize());
break;
case Opcode::MSG_MOVE_TIME_SKIPPED:
// Observed custom server packet (8 bytes). Safe-consume for now.
packet.setReadPos(packet.getSize());
break;
default:
// In pre-world states we need full visibility (char create/login handshakes).
// In-world we keep de-duplication to avoid heavy log I/O in busy areas.
if (state != WorldState::IN_WORLD) {
static std::unordered_set<uint32_t> loggedUnhandledByState;
const uint32_t key = (static_cast<uint32_t>(static_cast<uint8_t>(state)) << 16) |
static_cast<uint32_t>(opcode);
if (loggedUnhandledByState.insert(key).second) {
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
" state=", static_cast<int>(state),
" size=", packet.getSize());
const auto& data = packet.getData();
std::string hex;
size_t limit = std::min<size_t>(data.size(), 48);
hex.reserve(limit * 3);
for (size_t i = 0; i < limit; ++i) {
char b[4];
snprintf(b, sizeof(b), "%02x ", data[i]);
hex += b;
}
LOG_INFO("Unhandled opcode payload hex (first ", limit, " bytes): ", hex);
}
} else {
static std::unordered_set<uint16_t> loggedUnhandledOpcodes;
if (loggedUnhandledOpcodes.insert(static_cast<uint16_t>(opcode)).second) {
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec);
}
}
break;
}
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM while handling world opcode=0x", std::hex, opcode, std::dec,
" state=", worldStateName(state),
" size=", packet.getSize(),
" readPos=", packet.getReadPos(),
" what=", e.what());
if (socket && state == WorldState::IN_WORLD) {
disconnect();
fail("Out of memory while parsing world packet");
}
} catch (const std::exception& e) {
LOG_ERROR("Exception while handling world opcode=0x", std::hex, opcode, std::dec,
" state=", worldStateName(state),
" size=", packet.getSize(),
" readPos=", packet.getReadPos(),
" what=", e.what());
}
}
void GameHandler::handleAuthChallenge(network::Packet& packet) {
LOG_INFO("Handling SMSG_AUTH_CHALLENGE");
AuthChallengeData challenge;
if (!AuthChallengeParser::parse(packet, challenge)) {
fail("Failed to parse SMSG_AUTH_CHALLENGE");
return;
}
if (!challenge.isValid()) {
fail("Invalid auth challenge data");
return;
}
// Store server seed
serverSeed = challenge.serverSeed;
LOG_DEBUG("Server seed: 0x", std::hex, serverSeed, std::dec);
setState(WorldState::CHALLENGE_RECEIVED);
// Send authentication session
sendAuthSession();
}
void GameHandler::sendAuthSession() {
LOG_INFO("Sending CMSG_AUTH_SESSION");
// Build authentication packet
auto packet = AuthSessionPacket::build(
build,
accountName,
clientSeed,
sessionKey,
serverSeed,
realmId_
);
LOG_DEBUG("CMSG_AUTH_SESSION packet size: ", packet.getSize(), " bytes");
// Send packet (unencrypted - this is the last unencrypted packet)
socket->send(packet);
// Enable encryption IMMEDIATELY after sending AUTH_SESSION
// AzerothCore enables encryption before sending AUTH_RESPONSE,
// so we need to be ready to decrypt the response
LOG_INFO("Enabling encryption immediately after AUTH_SESSION");
socket->initEncryption(sessionKey, build);
setState(WorldState::AUTH_SENT);
LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE...");
}
void GameHandler::handleAuthResponse(network::Packet& packet) {
LOG_INFO("Handling SMSG_AUTH_RESPONSE");
AuthResponseData response;
if (!AuthResponseParser::parse(packet, response)) {
fail("Failed to parse SMSG_AUTH_RESPONSE");
return;
}
if (!response.isSuccess()) {
std::string reason = std::string("Authentication failed: ") +
getAuthResultString(response.result);
fail(reason);
return;
}
// Encryption was already enabled after sending AUTH_SESSION
LOG_INFO("AUTH_RESPONSE OK - world authentication successful");
setState(WorldState::AUTHENTICATED);
LOG_INFO("========================================");
LOG_INFO(" WORLD AUTHENTICATION SUCCESSFUL!");
LOG_INFO("========================================");
LOG_INFO("Connected to world server");
LOG_INFO("Ready for character operations");
setState(WorldState::READY);
// Request character list automatically
requestCharacterList();
// Call success callback
if (onSuccess) {
onSuccess();
}
}
void GameHandler::requestCharacterList() {
if (requiresWarden_) {
// Gate already surfaced via failure callback/chat; avoid per-frame warning spam.
wardenCharEnumBlockedLogged_ = true;
return;
}
if (state == WorldState::FAILED || !socket || !socket->isConnected()) {
return;
}
if (state != WorldState::READY && state != WorldState::AUTHENTICATED &&
state != WorldState::CHAR_LIST_RECEIVED) {
LOG_WARNING("Cannot request character list in state: ", worldStateName(state));
return;
}
LOG_INFO("Requesting character list from server...");
// Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM.
// This matters after character create/delete where the old list can linger for a few frames.
characters.clear();
// Build CMSG_CHAR_ENUM packet (no body, just opcode)
auto packet = CharEnumPacket::build();
// Send packet
socket->send(packet);
setState(WorldState::CHAR_LIST_REQUESTED);
LOG_INFO("CMSG_CHAR_ENUM sent, waiting for character list...");
}
void GameHandler::handleCharEnum(network::Packet& packet) {
LOG_INFO("Handling SMSG_CHAR_ENUM");
CharEnumResponse response;
// IMPORTANT: Do not infer packet formats from numeric build alone.
// Turtle WoW uses a "high" build but classic-era world packet formats.
bool parsed = packetParsers_ ? packetParsers_->parseCharEnum(packet, response)
: CharEnumParser::parse(packet, response);
if (!parsed) {
fail("Failed to parse SMSG_CHAR_ENUM");
return;
}
// Store characters
characters = response.characters;
setState(WorldState::CHAR_LIST_RECEIVED);
LOG_INFO("========================================");
LOG_INFO(" CHARACTER LIST RECEIVED");
LOG_INFO("========================================");
LOG_INFO("Found ", characters.size(), " character(s)");
if (characters.empty()) {
LOG_INFO("No characters on this account");
} else {
LOG_INFO("Characters:");
for (size_t i = 0; i < characters.size(); ++i) {
const auto& character = characters[i];
LOG_INFO(" [", i + 1, "] ", character.name);
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
LOG_INFO(" ", getRaceName(character.race), " ",
getClassName(character.characterClass));
LOG_INFO(" Level ", (int)character.level);
}
}
LOG_INFO("Ready to select character");
}
void GameHandler::createCharacter(const CharCreateData& data) {
// Online mode: send packet to server
if (!socket) {
LOG_WARNING("Cannot create character: not connected");
if (charCreateCallback_) {
charCreateCallback_(false, "Not connected to server");
}
return;
}
if (requiresWarden_) {
std::string msg = "Server requires anti-cheat/Warden; character creation blocked.";
LOG_WARNING("Blocking CMSG_CHAR_CREATE while Warden gate is active");
if (charCreateCallback_) {
charCreateCallback_(false, msg);
}
return;
}
if (state != WorldState::CHAR_LIST_RECEIVED) {
std::string msg = "Character list not ready yet. Wait for SMSG_CHAR_ENUM.";
LOG_WARNING("Blocking CMSG_CHAR_CREATE in state=", worldStateName(state),
" (awaiting CHAR_LIST_RECEIVED)");
if (charCreateCallback_) {
charCreateCallback_(false, msg);
}
return;
}
auto packet = CharCreatePacket::build(data);
socket->send(packet);
LOG_INFO("CMSG_CHAR_CREATE sent for: ", data.name);
}
void GameHandler::handleCharCreateResponse(network::Packet& packet) {
CharCreateResponseData data;
if (!CharCreateResponseParser::parse(packet, data)) {
LOG_ERROR("Failed to parse SMSG_CHAR_CREATE");
return;
}
if (data.result == CharCreateResult::SUCCESS || data.result == CharCreateResult::IN_PROGRESS) {
LOG_INFO("Character created successfully (code=", static_cast<int>(data.result), ")");
requestCharacterList();
if (charCreateCallback_) {
charCreateCallback_(true, "Character created!");
}
} else {
std::string msg;
switch (data.result) {
case CharCreateResult::CHAR_ERROR: msg = "Server error"; break;
case CharCreateResult::FAILED: msg = "Creation failed"; break;
case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break;
case CharCreateResult::DISABLED: msg = "Character creation disabled"; break;
case CharCreateResult::PVP_TEAMS_VIOLATION: msg = "PvP faction violation"; break;
case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break;
case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break;
case CharCreateResult::SERVER_QUEUE: msg = "Server is queued"; break;
case CharCreateResult::ONLY_EXISTING: msg = "Only existing characters allowed"; break;
case CharCreateResult::EXPANSION: msg = "Expansion required"; break;
case CharCreateResult::EXPANSION_CLASS: msg = "Expansion required for this class"; break;
case CharCreateResult::LEVEL_REQUIREMENT: msg = "Level requirement not met"; break;
case CharCreateResult::UNIQUE_CLASS_LIMIT: msg = "Unique class limit reached"; break;
case CharCreateResult::RESTRICTED_RACECLASS: msg = "Race/class combination not allowed"; break;
case CharCreateResult::IN_PROGRESS: msg = "Character creation in progress..."; break;
case CharCreateResult::CHARACTER_CHOOSE_RACE: msg = "Please choose a different race"; break;
case CharCreateResult::CHARACTER_ARENA_LEADER: msg = "Arena team leader restriction"; break;
case CharCreateResult::CHARACTER_DELETE_MAIL: msg = "Character has mail"; break;
case CharCreateResult::CHARACTER_SWAP_FACTION: msg = "Faction swap restriction"; break;
case CharCreateResult::CHARACTER_RACE_ONLY: msg = "Race-only restriction"; break;
case CharCreateResult::CHARACTER_GOLD_LIMIT: msg = "Gold limit reached"; break;
case CharCreateResult::FORCE_LOGIN: msg = "Force login required"; break;
case CharCreateResult::CHARACTER_IN_GUILD: msg = "Character is in a guild"; break;
// Name validation errors
case CharCreateResult::NAME_FAILURE: msg = "Invalid name"; break;
case CharCreateResult::NAME_NO_NAME: msg = "Please enter a name"; break;
case CharCreateResult::NAME_TOO_SHORT: msg = "Name is too short"; break;
case CharCreateResult::NAME_TOO_LONG: msg = "Name is too long"; break;
case CharCreateResult::NAME_INVALID_CHARACTER: msg = "Name contains invalid characters"; break;
case CharCreateResult::NAME_MIXED_LANGUAGES: msg = "Name mixes languages"; break;
case CharCreateResult::NAME_PROFANE: msg = "Name contains profanity"; break;
case CharCreateResult::NAME_RESERVED: msg = "Name is reserved"; break;
case CharCreateResult::NAME_INVALID_APOSTROPHE: msg = "Invalid apostrophe in name"; break;
case CharCreateResult::NAME_MULTIPLE_APOSTROPHES: msg = "Name has multiple apostrophes"; break;
case CharCreateResult::NAME_THREE_CONSECUTIVE: msg = "Name has 3+ consecutive same letters"; break;
case CharCreateResult::NAME_INVALID_SPACE: msg = "Invalid space in name"; break;
case CharCreateResult::NAME_CONSECUTIVE_SPACES: msg = "Name has consecutive spaces"; break;
default: msg = "Unknown error (code " + std::to_string(static_cast<int>(data.result)) + ")"; break;
}
LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast<int>(data.result), ")");
if (charCreateCallback_) {
charCreateCallback_(false, msg);
}
}
}
void GameHandler::deleteCharacter(uint64_t characterGuid) {
if (!socket) {
if (charDeleteCallback_) charDeleteCallback_(false);
return;
}
network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE));
packet.writeUInt64(characterGuid);
socket->send(packet);
LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec);
}
const Character* GameHandler::getActiveCharacter() const {
if (activeCharacterGuid_ == 0) return nullptr;
for (const auto& ch : characters) {
if (ch.guid == activeCharacterGuid_) return &ch;
}
return nullptr;
}
const Character* GameHandler::getFirstCharacter() const {
if (characters.empty()) return nullptr;
return &characters.front();
}
void GameHandler::handleCharLoginFailed(network::Packet& packet) {
uint8_t reason = packet.readUInt8();
static const char* reasonNames[] = {
"Login failed", // 0
"World server is down", // 1
"Duplicate character", // 2 (session still active)
"No instance servers", // 3
"Login disabled", // 4
"Character not found", // 5
"Locked for transfer", // 6
"Locked by billing", // 7
"Using remote", // 8
};
const char* msg = (reason < 9) ? reasonNames[reason] : "Unknown reason";
LOG_ERROR("SMSG_CHARACTER_LOGIN_FAILED: reason=", (int)reason, " (", msg, ")");
// Allow the player to re-select a character
setState(WorldState::CHAR_LIST_RECEIVED);
if (charLoginFailCallback_) {
charLoginFailCallback_(msg);
}
}
void GameHandler::selectCharacter(uint64_t characterGuid) {
if (state != WorldState::CHAR_LIST_RECEIVED) {
LOG_WARNING("Cannot select character in state: ", (int)state);
return;
}
// Make the selected character authoritative in GameHandler.
// This avoids relying on UI/Application ordering for appearance-dependent logic.
activeCharacterGuid_ = characterGuid;
LOG_INFO("========================================");
LOG_INFO(" ENTERING WORLD");
LOG_INFO("========================================");
LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec);
// Find character name for logging
for (const auto& character : characters) {
if (character.guid == characterGuid) {
LOG_INFO("Character: ", character.name);
LOG_INFO("Level ", (int)character.level, " ",
getRaceName(character.race), " ",
getClassName(character.characterClass));
playerRace_ = character.race;
break;
}
}
// Store player GUID
playerGuid = characterGuid;
// Reset per-character state so previous character data doesn't bleed through
inventory = Inventory();
onlineItems_.clear();
itemInfoCache_.clear();
pendingItemQueries_.clear();
equipSlotGuids_ = {};
backpackSlotGuids_ = {};
invSlotBase_ = -1;
packSlotBase_ = -1;
lastPlayerFields_.clear();
onlineEquipDirty_ = false;
playerMoneyCopper_ = 0;
playerArmorRating_ = 0;
knownSpells.clear();
spellCooldowns.clear();
actionBar = {};
playerAuras.clear();
targetAuras.clear();
petGuid_ = 0;
playerXp_ = 0;
playerNextLevelXp_ = 0;
serverPlayerLevel_ = 1;
std::fill(playerExploredZones_.begin(), playerExploredZones_.end(), 0u);
hasPlayerExploredZones_ = false;
playerSkills_.clear();
questLog_.clear();
pendingQuestQueryIds_.clear();
pendingLoginQuestResync_ = false;
pendingLoginQuestResyncTimeout_ = 0.0f;
pendingQuestAcceptTimeouts_.clear();
pendingQuestAcceptNpcGuids_.clear();
npcQuestStatus_.clear();
hostileAttackers_.clear();
combatText.clear();
autoAttacking = false;
autoAttackTarget = 0;
casting = false;
currentCastSpellId = 0;
pendingGameObjectInteractGuid_ = 0;
castTimeRemaining = 0.0f;
castTimeTotal = 0.0f;
playerDead_ = false;
releasedSpirit_ = false;
targetGuid = 0;
focusGuid = 0;
lastTargetGuid = 0;
tabCycleStale = true;
entityManager = EntityManager();
// Build CMSG_PLAYER_LOGIN packet
auto packet = PlayerLoginPacket::build(characterGuid);
// Send packet
socket->send(packet);
setState(WorldState::ENTERING_WORLD);
LOG_INFO("CMSG_PLAYER_LOGIN sent, entering world...");
}
void GameHandler::handleLoginSetTimeSpeed(network::Packet& packet) {
// SMSG_LOGIN_SETTIMESPEED (0x042)
// Structure: uint32 gameTime, float timeScale
// gameTime: Game time in seconds since epoch
// timeScale: Time speed multiplier (typically 0.0166 for 1 day = 1 hour)
if (packet.getSize() < 8) {
LOG_WARNING("SMSG_LOGIN_SETTIMESPEED: packet too small (", packet.getSize(), " bytes)");
return;
}
uint32_t gameTimePacked = packet.readUInt32();
float timeScale = packet.readFloat();
// Store for celestial/sky system use
gameTime_ = static_cast<float>(gameTimePacked);
timeSpeed_ = timeScale;
LOG_INFO("Server time: gameTime=", gameTime_, "s, timeSpeed=", timeSpeed_);
LOG_INFO(" (1 game day = ", (1.0f / timeSpeed_) / 60.0f, " real minutes)");
}
void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD");
const bool initialWorldEntry = (state == WorldState::ENTERING_WORLD);
LoginVerifyWorldData data;
if (!LoginVerifyWorldParser::parse(packet, data)) {
fail("Failed to parse SMSG_LOGIN_VERIFY_WORLD");
return;
}
if (!data.isValid()) {
fail("Invalid world entry data");
return;
}
// Successfully entered the world (or teleported)
currentMapId_ = data.mapId;
setState(WorldState::IN_WORLD);
LOG_INFO("========================================");
LOG_INFO(" SUCCESSFULLY ENTERED WORLD!");
LOG_INFO("========================================");
LOG_INFO("Map ID: ", data.mapId);
LOG_INFO("Position: (", data.x, ", ", data.y, ", ", data.z, ")");
LOG_INFO("Orientation: ", data.orientation, " radians");
LOG_INFO("Player is now in the game world");
// Initialize movement info with world entry position (server → canonical)
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z));
LOG_WARNING("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId);
movementInfo.x = canonical.x;
movementInfo.y = canonical.y;
movementInfo.z = canonical.z;
movementInfo.orientation = core::coords::serverToCanonicalYaw(data.orientation);
movementInfo.flags = 0;
movementInfo.flags2 = 0;
movementClockStart_ = std::chrono::steady_clock::now();
lastMovementTimestampMs_ = 0;
movementInfo.time = nextMovementTimestampMs();
resurrectPending_ = false;
resurrectRequestPending_ = false;
onTaxiFlight_ = false;
taxiMountActive_ = false;
taxiActivatePending_ = false;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
taxiStartGrace_ = 0.0f;
currentMountDisplayId_ = 0;
taxiMountDisplayId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
// Suppress area triggers on initial login — prevents exit portals from
// immediately firing when spawning inside a dungeon/instance.
activeAreaTriggers_.clear();
areaTriggerCheckTimer_ = -5.0f;
areaTriggerSuppressFirst_ = true;
// Send CMSG_SET_ACTIVE_MOVER (required by some servers)
if (playerGuid != 0 && socket) {
auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid);
socket->send(activeMoverPacket);
LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec);
}
// Notify application to load terrain for this map/position (online mode)
if (worldEntryCallback_) {
worldEntryCallback_(data.mapId, data.x, data.y, data.z);
}
// Auto-join default chat channels
autoJoinDefaultChannels();
// Auto-query guild info on login
const Character* activeChar = getActiveCharacter();
if (activeChar && activeChar->hasGuild() && socket) {
auto gqPacket = GuildQueryPacket::build(activeChar->guildId);
socket->send(gqPacket);
auto grPacket = GuildRosterPacket::build();
socket->send(grPacket);
LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")");
}
// If we disconnected mid-taxi, attempt to recover to destination after login.
if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) {
float dx = movementInfo.x - taxiRecoverPos_.x;
float dy = movementInfo.y - taxiRecoverPos_.y;
float dz = movementInfo.z - taxiRecoverPos_.z;
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist > 5.0f) {
// Keep pending until player entity exists; update() will apply.
LOG_INFO("Taxi recovery pending: dist=", dist);
} else {
taxiRecoverPending_ = false;
}
}
if (initialWorldEntry) {
pendingQuestAcceptTimeouts_.clear();
pendingQuestAcceptNpcGuids_.clear();
pendingQuestQueryIds_.clear();
pendingLoginQuestResync_ = true;
pendingLoginQuestResyncTimeout_ = 10.0f;
LOG_INFO("Queued quest log resync for login (from server quest slots)");
}
}
void GameHandler::handleClientCacheVersion(network::Packet& packet) {
if (packet.getSize() < 4) {
LOG_WARNING("SMSG_CLIENTCACHE_VERSION too short: ", packet.getSize(), " bytes");
return;
}
uint32_t version = packet.readUInt32();
LOG_INFO("SMSG_CLIENTCACHE_VERSION: ", version);
}
void GameHandler::handleTutorialFlags(network::Packet& packet) {
if (packet.getSize() < 32) {
LOG_WARNING("SMSG_TUTORIAL_FLAGS too short: ", packet.getSize(), " bytes");
return;
}
std::array<uint32_t, 8> flags{};
for (uint32_t& v : flags) {
v = packet.readUInt32();
}
LOG_INFO("SMSG_TUTORIAL_FLAGS: [",
flags[0], ", ", flags[1], ", ", flags[2], ", ", flags[3], ", ",
flags[4], ", ", flags[5], ", ", flags[6], ", ", flags[7], "]");
}
bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) {
wardenCREntries_.clear();
// Look for .cr file in warden cache
std::string cacheBase;
#ifdef _WIN32
if (const char* h = std::getenv("APPDATA")) cacheBase = std::string(h) + "\\wowee\\warden_cache";
else cacheBase = ".\\warden_cache";
#else
if (const char* h = std::getenv("HOME")) cacheBase = std::string(h) + "/.local/share/wowee/warden_cache";
else cacheBase = "./warden_cache";
#endif
std::string crPath = cacheBase + "/" + moduleHashHex + ".cr";
std::ifstream crFile(crPath, std::ios::binary);
if (!crFile) {
LOG_WARNING("Warden: No .cr file found at ", crPath);
return false;
}
// Get file size
crFile.seekg(0, std::ios::end);
auto fileSize = crFile.tellg();
crFile.seekg(0, std::ios::beg);
// Header: [4 memoryRead][4 pageScanCheck][9 opcodes] = 17 bytes
constexpr size_t CR_HEADER_SIZE = 17;
constexpr size_t CR_ENTRY_SIZE = 68; // seed[16]+reply[20]+clientKey[16]+serverKey[16]
if (static_cast<size_t>(fileSize) < CR_HEADER_SIZE) {
LOG_ERROR("Warden: .cr file too small (", fileSize, " bytes)");
return false;
}
// Read header: [4 memoryRead][4 pageScanCheck][9 opcodes]
crFile.seekg(8); // skip memoryRead + pageScanCheck
crFile.read(reinterpret_cast<char*>(wardenCheckOpcodes_), 9);
{
std::string opcHex;
// CMaNGOS WindowsScanType order:
// 0 READ_MEMORY, 1 FIND_MODULE_BY_NAME, 2 FIND_MEM_IMAGE_CODE_BY_HASH,
// 3 FIND_CODE_BY_HASH, 4 HASH_CLIENT_FILE, 5 GET_LUA_VARIABLE,
// 6 API_CHECK, 7 FIND_DRIVER_BY_NAME, 8 CHECK_TIMING_VALUES
const char* names[] = {"MEM","MODULE","PAGE_A","PAGE_B","MPQ","LUA","PROC","DRIVER","TIMING"};
for (int i = 0; i < 9; i++) {
char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s;
}
LOG_DEBUG("Warden: Check opcodes: ", opcHex);
}
size_t entryCount = (static_cast<size_t>(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE;
if (entryCount == 0) {
LOG_ERROR("Warden: .cr file has no entries");
return false;
}
wardenCREntries_.resize(entryCount);
for (size_t i = 0; i < entryCount; i++) {
auto& e = wardenCREntries_[i];
crFile.read(reinterpret_cast<char*>(e.seed), 16);
crFile.read(reinterpret_cast<char*>(e.reply), 20);
crFile.read(reinterpret_cast<char*>(e.clientKey), 16);
crFile.read(reinterpret_cast<char*>(e.serverKey), 16);
}
LOG_INFO("Warden: Loaded ", entryCount, " CR entries from ", crPath);
return true;
}
void GameHandler::handleWardenData(network::Packet& packet) {
const auto& data = packet.getData();
if (!wardenGateSeen_) {
wardenGateSeen_ = true;
wardenGateElapsed_ = 0.0f;
wardenGateNextStatusLog_ = 2.0f;
wardenPacketsAfterGate_ = 0;
}
// Initialize Warden crypto from session key on first packet
if (!wardenCrypto_) {
wardenCrypto_ = std::make_unique<WardenCrypto>();
if (sessionKey.size() != 40) {
LOG_ERROR("Warden: No valid session key (size=", sessionKey.size(), "), cannot init crypto");
wardenCrypto_.reset();
return;
}
if (!wardenCrypto_->initFromSessionKey(sessionKey)) {
LOG_ERROR("Warden: Failed to initialize crypto from session key");
wardenCrypto_.reset();
return;
}
wardenState_ = WardenState::WAIT_MODULE_USE;
}
// Decrypt the payload
std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data);
// Avoid expensive hex formatting when DEBUG logs are disabled.
if (core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG)) {
std::string hex;
size_t logSize = std::min(decrypted.size(), size_t(256));
hex.reserve(logSize * 3);
for (size_t i = 0; i < logSize; ++i) {
char b[4];
snprintf(b, sizeof(b), "%02x ", decrypted[i]);
hex += b;
}
if (decrypted.size() > 64) {
hex += "... (" + std::to_string(decrypted.size() - 64) + " more)";
}
LOG_DEBUG("Warden: Decrypted (", decrypted.size(), " bytes): ", hex);
}
if (decrypted.empty()) {
LOG_WARNING("Warden: Empty decrypted payload");
return;
}
uint8_t wardenOpcode = decrypted[0];
// Helper to send an encrypted Warden response
auto sendWardenResponse = [&](const std::vector<uint8_t>& plaintext) {
std::vector<uint8_t> encrypted = wardenCrypto_->encrypt(plaintext);
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
for (uint8_t byte : encrypted) {
response.writeUInt8(byte);
}
if (socket && socket->isConnected()) {
socket->send(response);
LOG_DEBUG("Warden: Sent response (", plaintext.size(), " bytes plaintext)");
}
};
switch (wardenOpcode) {
case 0x00: { // WARDEN_SMSG_MODULE_USE
// Format: [1 opcode][16 moduleHash][16 moduleKey][4 moduleSize]
if (decrypted.size() < 37) {
LOG_ERROR("Warden: MODULE_USE too short (", decrypted.size(), " bytes, need 37)");
return;
}
wardenModuleHash_.assign(decrypted.begin() + 1, decrypted.begin() + 17);
wardenModuleKey_.assign(decrypted.begin() + 17, decrypted.begin() + 33);
wardenModuleSize_ = static_cast<uint32_t>(decrypted[33])
| (static_cast<uint32_t>(decrypted[34]) << 8)
| (static_cast<uint32_t>(decrypted[35]) << 16)
| (static_cast<uint32_t>(decrypted[36]) << 24);
wardenModuleData_.clear();
{
std::string hashHex;
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
LOG_DEBUG("Warden: MODULE_USE hash=", hashHex, " size=", wardenModuleSize_);
// Try to load pre-computed challenge/response entries
loadWardenCRFile(hashHex);
}
// Respond with MODULE_MISSING (opcode 0x00) to request the module data
std::vector<uint8_t> resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING
sendWardenResponse(resp);
wardenState_ = WardenState::WAIT_MODULE_CACHE;
LOG_DEBUG("Warden: Sent MODULE_MISSING, waiting for module data chunks");
break;
}
case 0x01: { // WARDEN_SMSG_MODULE_CACHE (module data chunk)
// Format: [1 opcode][2 chunkSize LE][chunkSize bytes data]
if (decrypted.size() < 3) {
LOG_ERROR("Warden: MODULE_CACHE too short");
return;
}
uint16_t chunkSize = static_cast<uint16_t>(decrypted[1])
| (static_cast<uint16_t>(decrypted[2]) << 8);
if (decrypted.size() < 3u + chunkSize) {
LOG_ERROR("Warden: MODULE_CACHE chunk truncated (claimed ", chunkSize,
", have ", decrypted.size() - 3, ")");
return;
}
wardenModuleData_.insert(wardenModuleData_.end(),
decrypted.begin() + 3,
decrypted.begin() + 3 + chunkSize);
LOG_DEBUG("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ",
wardenModuleData_.size(), "/", wardenModuleSize_);
// Check if module download is complete
if (wardenModuleData_.size() >= wardenModuleSize_) {
LOG_INFO("Warden: Module download complete (",
wardenModuleData_.size(), " bytes)");
wardenState_ = WardenState::WAIT_HASH_REQUEST;
// Cache raw module to disk
{
#ifdef _WIN32
std::string cacheDir;
if (const char* h = std::getenv("APPDATA")) cacheDir = std::string(h) + "\\wowee\\warden_cache";
else cacheDir = ".\\warden_cache";
#else
std::string cacheDir;
if (const char* h = std::getenv("HOME")) cacheDir = std::string(h) + "/.local/share/wowee/warden_cache";
else cacheDir = "./warden_cache";
#endif
std::filesystem::create_directories(cacheDir);
std::string hashHex;
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
std::string cachePath = cacheDir + "/" + hashHex + ".wdn";
std::ofstream wf(cachePath, std::ios::binary);
if (wf) {
wf.write(reinterpret_cast<const char*>(wardenModuleData_.data()), wardenModuleData_.size());
LOG_DEBUG("Warden: Cached module to ", cachePath);
}
}
// Load the module (decrypt, decompress, parse, relocate)
wardenLoadedModule_ = std::make_shared<WardenModule>();
if (wardenLoadedModule_->load(wardenModuleData_, wardenModuleHash_, wardenModuleKey_)) { // codeql[cpp/weak-cryptographic-algorithm]
LOG_INFO("Warden: Module loaded successfully (image size=",
wardenLoadedModule_->getModuleSize(), " bytes)");
} else {
LOG_ERROR("Warden: Module loading FAILED");
wardenLoadedModule_.reset();
}
// Send MODULE_OK (opcode 0x01)
std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK
sendWardenResponse(resp);
LOG_DEBUG("Warden: Sent MODULE_OK");
}
// No response for intermediate chunks
break;
}
case 0x05: { // WARDEN_SMSG_HASH_REQUEST
// Format: [1 opcode][16 seed]
if (decrypted.size() < 17) {
LOG_ERROR("Warden: HASH_REQUEST too short (", decrypted.size(), " bytes, need 17)");
return;
}
std::vector<uint8_t> seed(decrypted.begin() + 1, decrypted.begin() + 17);
// --- Try CR lookup (pre-computed challenge/response entries) ---
if (!wardenCREntries_.empty()) {
const WardenCREntry* match = nullptr;
for (const auto& entry : wardenCREntries_) {
if (std::memcmp(entry.seed, seed.data(), 16) == 0) {
match = &entry;
break;
}
}
if (match) {
LOG_DEBUG("Warden: Found matching CR entry for seed");
// Log the reply we're sending
{
std::string replyHex;
for (int i = 0; i < 20; i++) {
char s[4]; snprintf(s, 4, "%02x", match->reply[i]); replyHex += s;
}
LOG_DEBUG("Warden: Sending pre-computed reply=", replyHex);
}
// Send HASH_RESULT (opcode 0x04 + 20-byte reply)
std::vector<uint8_t> resp;
resp.push_back(0x04);
resp.insert(resp.end(), match->reply, match->reply + 20);
sendWardenResponse(resp);
// Switch to new RC4 keys from the CR entry
// clientKey = encrypt (client→server), serverKey = decrypt (server→client)
std::vector<uint8_t> newEncryptKey(match->clientKey, match->clientKey + 16);
std::vector<uint8_t> newDecryptKey(match->serverKey, match->serverKey + 16);
wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey);
LOG_DEBUG("Warden: Switched to CR key set");
wardenState_ = WardenState::WAIT_CHECKS;
break;
} else {
LOG_WARNING("Warden: Seed not found in ", wardenCREntries_.size(), " CR entries");
}
}
// --- Fallback: compute hash from loaded module ---
LOG_WARNING("Warden: No CR match, computing hash from loaded module");
if (!wardenLoadedModule_ || !wardenLoadedModule_->isLoaded()) {
LOG_ERROR("Warden: No loaded module and no CR match — cannot compute hash");
wardenState_ = WardenState::WAIT_CHECKS;
break;
}
{
const uint8_t* moduleImage = static_cast<const uint8_t*>(wardenLoadedModule_->getModuleMemory());
size_t moduleImageSize = wardenLoadedModule_->getModuleSize();
const auto& decompressedData = wardenLoadedModule_->getDecompressedData();
// --- Empirical test: try multiple SHA1 computations and check against first CR entry ---
if (!wardenCREntries_.empty()) {
const auto& firstCR = wardenCREntries_[0];
std::string expectedHex;
for (int i = 0; i < 20; i++) { char s[4]; snprintf(s, 4, "%02x", firstCR.reply[i]); expectedHex += s; }
LOG_DEBUG("Warden: Empirical test — expected reply from CR[0]=", expectedHex);
// Test 1: SHA1(moduleImage)
{
std::vector<uint8_t> data(moduleImage, moduleImage + moduleImageSize);
auto h = auth::Crypto::sha1(data);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
LOG_DEBUG("Warden: SHA1(moduleImage)=", hex, match ? " MATCH!" : "");
}
// Test 2: SHA1(seed || moduleImage)
{
std::vector<uint8_t> data;
data.insert(data.end(), seed.begin(), seed.end());
data.insert(data.end(), moduleImage, moduleImage + moduleImageSize);
auto h = auth::Crypto::sha1(data);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
LOG_DEBUG("Warden: SHA1(seed||image)=", hex, match ? " MATCH!" : "");
}
// Test 3: SHA1(moduleImage || seed)
{
std::vector<uint8_t> data(moduleImage, moduleImage + moduleImageSize);
data.insert(data.end(), seed.begin(), seed.end());
auto h = auth::Crypto::sha1(data);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
LOG_DEBUG("Warden: SHA1(image||seed)=", hex, match ? " MATCH!" : "");
}
// Test 4: SHA1(decompressedData)
{
auto h = auth::Crypto::sha1(decompressedData);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
LOG_DEBUG("Warden: SHA1(decompressed)=", hex, match ? " MATCH!" : "");
}
// Test 5: SHA1(rawModuleData)
{
auto h = auth::Crypto::sha1(wardenModuleData_);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
LOG_DEBUG("Warden: SHA1(rawModule)=", hex, match ? " MATCH!" : "");
}
// Test 6: Check if all CR replies are the same (constant hash)
{
bool allSame = true;
for (size_t i = 1; i < wardenCREntries_.size(); i++) {
if (std::memcmp(wardenCREntries_[i].reply, firstCR.reply, 20) != 0) {
allSame = false;
break;
}
}
LOG_DEBUG("Warden: All ", wardenCREntries_.size(), " CR replies identical? ", allSame ? "YES" : "NO");
}
}
// --- Compute the hash: SHA1(moduleImage) is the most likely candidate ---
// The module's hash response is typically SHA1 of the loaded module image.
// This is a constant per module (seed is not used in the hash, only for key derivation).
std::vector<uint8_t> imageData(moduleImage, moduleImage + moduleImageSize);
auto reply = auth::Crypto::sha1(imageData);
{
std::string hex;
for (auto b : reply) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
LOG_DEBUG("Warden: Sending SHA1(moduleImage)=", hex);
}
// Send HASH_RESULT (opcode 0x04 + 20-byte hash)
std::vector<uint8_t> resp;
resp.push_back(0x04);
resp.insert(resp.end(), reply.begin(), reply.end());
sendWardenResponse(resp);
// Derive new RC4 keys from the seed using SHA1Randx
std::vector<uint8_t> seedVec(seed.begin(), seed.end());
// Pad seed to at least 2 bytes for SHA1Randx split
// SHA1Randx splits input in half: first_half and second_half
uint8_t newEncryptKey[16], newDecryptKey[16];
WardenCrypto::sha1RandxGenerate(seedVec, newEncryptKey, newDecryptKey);
std::vector<uint8_t> ek(newEncryptKey, newEncryptKey + 16);
std::vector<uint8_t> dk(newDecryptKey, newDecryptKey + 16);
wardenCrypto_->replaceKeys(ek, dk);
for (auto& b : newEncryptKey) b = 0;
for (auto& b : newDecryptKey) b = 0;
LOG_DEBUG("Warden: Derived and applied key update from seed");
}
wardenState_ = WardenState::WAIT_CHECKS;
break;
}
case 0x02: { // WARDEN_SMSG_CHEAT_CHECKS_REQUEST
LOG_DEBUG("Warden: CHEAT_CHECKS_REQUEST (", decrypted.size(), " bytes)");
if (decrypted.size() < 3) {
LOG_ERROR("Warden: CHEAT_CHECKS_REQUEST too short");
break;
}
// --- Parse string table ---
// Format: [1 opcode][string table: (len+data)*][0x00 end][check data][xorByte]
size_t pos = 1;
std::vector<std::string> strings;
while (pos < decrypted.size()) {
uint8_t slen = decrypted[pos++];
if (slen == 0) break; // end of string table
if (pos + slen > decrypted.size()) break;
strings.emplace_back(reinterpret_cast<const char*>(decrypted.data() + pos), slen);
pos += slen;
}
LOG_DEBUG("Warden: String table: ", strings.size(), " entries");
for (size_t i = 0; i < strings.size(); i++) {
LOG_DEBUG("Warden: [", i, "] = \"", strings[i], "\"");
}
// XOR byte is the last byte of the packet
uint8_t xorByte = decrypted.back();
LOG_DEBUG("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }());
// Check type enum indices
enum CheckType { CT_MEM=0, CT_PAGE_A=1, CT_PAGE_B=2, CT_MPQ=3, CT_LUA=4,
CT_DRIVER=5, CT_TIMING=6, CT_PROC=7, CT_MODULE=8, CT_UNKNOWN=9 };
const char* checkTypeNames[] = {"MEM","PAGE_A","PAGE_B","MPQ","LUA","DRIVER","TIMING","PROC","MODULE","UNKNOWN"};
size_t checkEnd = decrypted.size() - 1; // exclude xorByte
auto decodeCheckType = [&](uint8_t raw) -> CheckType {
uint8_t decoded = raw ^ xorByte;
if (decoded == wardenCheckOpcodes_[0]) return CT_MEM; // READ_MEMORY
if (decoded == wardenCheckOpcodes_[1]) return CT_MODULE; // FIND_MODULE_BY_NAME
if (decoded == wardenCheckOpcodes_[2]) return CT_PAGE_A; // FIND_MEM_IMAGE_CODE_BY_HASH
if (decoded == wardenCheckOpcodes_[3]) return CT_PAGE_B; // FIND_CODE_BY_HASH
if (decoded == wardenCheckOpcodes_[4]) return CT_MPQ; // HASH_CLIENT_FILE
if (decoded == wardenCheckOpcodes_[5]) return CT_LUA; // GET_LUA_VARIABLE
if (decoded == wardenCheckOpcodes_[6]) return CT_PROC; // API_CHECK
if (decoded == wardenCheckOpcodes_[7]) return CT_DRIVER; // FIND_DRIVER_BY_NAME
if (decoded == wardenCheckOpcodes_[8]) return CT_TIMING; // CHECK_TIMING_VALUES
return CT_UNKNOWN;
};
auto isKnownWantedCodeScan = [&](const uint8_t seedBytes[4], const uint8_t reqHash[20],
uint32_t offset, uint8_t length) -> bool {
auto hashPattern = [&](const uint8_t* pattern, size_t patternLen) {
uint8_t out[SHA_DIGEST_LENGTH];
unsigned int outLen = 0;
HMAC(EVP_sha1(),
seedBytes, 4,
pattern, patternLen,
out, &outLen);
return outLen == SHA_DIGEST_LENGTH && std::memcmp(out, reqHash, SHA_DIGEST_LENGTH) == 0;
};
// DB sanity check: "Warden packet process code search sanity check" (id=85)
static const uint8_t kPacketProcessSanityPattern[] = {
0x33, 0xD2, 0x33, 0xC9, 0xE8, 0x87, 0x07, 0x1B, 0x00, 0xE8
};
if (offset == 13856 && length == sizeof(kPacketProcessSanityPattern) &&
hashPattern(kPacketProcessSanityPattern, sizeof(kPacketProcessSanityPattern))) {
return true;
}
// Scripted sanity check: "Warden Memory Read check" in wardenwin.cpp
static const uint8_t kWardenMemoryReadPattern[] = {
0x56, 0x57, 0xFC, 0x8B, 0x54, 0x24, 0x14, 0x8B,
0x74, 0x24, 0x10, 0x8B, 0x44, 0x24, 0x0C, 0x8B,
0xCA, 0x8B, 0xF8, 0xC1, 0xE9, 0x02, 0x74, 0x02,
0xF3, 0xA5, 0xB1, 0x03, 0x23, 0xCA, 0x74, 0x02,
0xF3, 0xA4, 0x5F, 0x5E, 0xC3
};
if (length == sizeof(kWardenMemoryReadPattern) &&
hashPattern(kWardenMemoryReadPattern, sizeof(kWardenMemoryReadPattern))) {
return true;
}
return false;
};
auto resolveWardenString = [&](uint8_t oneBasedIndex) -> std::string {
if (oneBasedIndex == 0) return std::string();
size_t idx = static_cast<size_t>(oneBasedIndex - 1);
if (idx >= strings.size()) return std::string();
return strings[idx];
};
auto requestSizes = [&](CheckType ct) {
switch (ct) {
case CT_TIMING: return std::vector<size_t>{0};
case CT_MEM: return std::vector<size_t>{6};
case CT_PAGE_A: return std::vector<size_t>{24, 29};
case CT_PAGE_B: return std::vector<size_t>{24, 29};
case CT_MPQ: return std::vector<size_t>{1};
case CT_LUA: return std::vector<size_t>{1};
case CT_DRIVER: return std::vector<size_t>{25};
case CT_PROC: return std::vector<size_t>{30};
case CT_MODULE: return std::vector<size_t>{24};
default: return std::vector<size_t>{};
}
};
std::unordered_map<size_t, bool> parseMemo;
std::function<bool(size_t)> canParseFrom = [&](size_t checkPos) -> bool {
if (checkPos == checkEnd) return true;
if (checkPos > checkEnd) return false;
auto it = parseMemo.find(checkPos);
if (it != parseMemo.end()) return it->second;
CheckType ct = decodeCheckType(decrypted[checkPos]);
if (ct == CT_UNKNOWN) {
parseMemo[checkPos] = false;
return false;
}
size_t payloadPos = checkPos + 1;
for (size_t reqSize : requestSizes(ct)) {
if (payloadPos + reqSize > checkEnd) continue;
if (canParseFrom(payloadPos + reqSize)) {
parseMemo[checkPos] = true;
return true;
}
}
parseMemo[checkPos] = false;
return false;
};
auto isBoundaryAfter = [&](size_t start, size_t consume) -> bool {
size_t next = start + consume;
if (next == checkEnd) return true;
if (next > checkEnd) return false;
return decodeCheckType(decrypted[next]) != CT_UNKNOWN;
};
// --- Parse check entries and build response ---
std::vector<uint8_t> resultData;
int checkCount = 0;
while (pos < checkEnd) {
CheckType ct = decodeCheckType(decrypted[pos]);
pos++;
checkCount++;
LOG_DEBUG("Warden: Check #", checkCount, " type=", checkTypeNames[ct],
" at offset ", pos - 1);
switch (ct) {
case CT_TIMING: {
// No additional request data
// Response: [uint8 result=1][uint32 ticks]
resultData.push_back(0x01);
uint32_t ticks = static_cast<uint32_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
resultData.push_back(ticks & 0xFF);
resultData.push_back((ticks >> 8) & 0xFF);
resultData.push_back((ticks >> 16) & 0xFF);
resultData.push_back((ticks >> 24) & 0xFF);
break;
}
case CT_MEM: {
// Request: [1 stringIdx][4 offset][1 length]
if (pos + 6 > checkEnd) { pos = checkEnd; break; }
uint8_t strIdx = decrypted[pos++];
std::string moduleName = resolveWardenString(strIdx);
uint32_t offset = decrypted[pos] | (uint32_t(decrypted[pos+1])<<8)
| (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24);
pos += 4;
uint8_t readLen = decrypted[pos++];
LOG_DEBUG("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(),
" len=", (int)readLen);
if (!moduleName.empty()) {
LOG_DEBUG("Warden: MEM module=\"", moduleName, "\"");
}
// Lazy-load WoW.exe PE image on first MEM_CHECK
if (!wardenMemory_) {
wardenMemory_ = std::make_unique<WardenMemory>();
if (!wardenMemory_->load(static_cast<uint16_t>(build))) {
LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK");
}
}
// Read bytes from PE image (includes patched runtime globals)
std::vector<uint8_t> memBuf(readLen, 0);
if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) {
LOG_DEBUG("Warden: MEM_CHECK served from PE image");
} else {
LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x",
[&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}());
}
resultData.push_back(0x00);
resultData.insert(resultData.end(), memBuf.begin(), memBuf.end());
break;
}
case CT_PAGE_A: {
// Classic has seen two PAGE_A layouts in the wild:
// short: [4 seed][20 sha1] = 24 bytes
// long: [4 seed][20 sha1][4 addr][1 len] = 29 bytes
// Prefer the variant that allows the full remaining stream to parse.
constexpr size_t kPageAShort = 24;
constexpr size_t kPageALong = 29;
size_t consume = 0;
if (pos + kPageAShort <= checkEnd && canParseFrom(pos + kPageAShort)) {
consume = kPageAShort;
}
if (pos + kPageALong <= checkEnd && canParseFrom(pos + kPageALong) && consume == 0) {
consume = kPageALong;
}
if (consume == 0 && isBoundaryAfter(pos, kPageAShort)) consume = kPageAShort;
if (consume == 0 && isBoundaryAfter(pos, kPageALong)) consume = kPageALong;
if (consume == 0) {
size_t remaining = checkEnd - pos;
if (remaining >= kPageAShort && remaining < kPageALong) consume = kPageAShort;
else if (remaining >= kPageALong) consume = kPageALong;
else {
LOG_WARNING("Warden: PAGE_A check truncated (remaining=", remaining,
"), consuming remainder");
pos = checkEnd;
resultData.push_back(0x00);
break;
}
}
uint8_t pageResult = 0x00;
if (consume >= 29) {
const uint8_t* p = decrypted.data() + pos;
uint8_t seedBytes[4] = { p[0], p[1], p[2], p[3] };
uint8_t reqHash[20];
std::memcpy(reqHash, p + 4, 20);
uint32_t off = uint32_t(p[24]) | (uint32_t(p[25]) << 8) |
(uint32_t(p[26]) << 16) | (uint32_t(p[27]) << 24);
uint8_t len = p[28];
if (isKnownWantedCodeScan(seedBytes, reqHash, off, len)) {
pageResult = 0x4A; // PatternFound
}
}
LOG_DEBUG("Warden: PAGE_A request bytes=", consume,
" result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
pos += consume;
resultData.push_back(pageResult);
break;
}
case CT_PAGE_B: {
constexpr size_t kPageBShort = 24;
constexpr size_t kPageBLong = 29;
size_t consume = 0;
if (pos + kPageBShort <= checkEnd && canParseFrom(pos + kPageBShort)) {
consume = kPageBShort;
}
if (pos + kPageBLong <= checkEnd && canParseFrom(pos + kPageBLong) && consume == 0) {
consume = kPageBLong;
}
if (consume == 0 && isBoundaryAfter(pos, kPageBShort)) consume = kPageBShort;
if (consume == 0 && isBoundaryAfter(pos, kPageBLong)) consume = kPageBLong;
if (consume == 0) {
size_t remaining = checkEnd - pos;
if (remaining >= kPageBShort && remaining < kPageBLong) consume = kPageBShort;
else if (remaining >= kPageBLong) consume = kPageBLong;
else { pos = checkEnd; break; }
}
uint8_t pageResult = 0x00;
if (consume >= 29) {
const uint8_t* p = decrypted.data() + pos;
uint8_t seedBytes[4] = { p[0], p[1], p[2], p[3] };
uint8_t reqHash[20];
std::memcpy(reqHash, p + 4, 20);
uint32_t off = uint32_t(p[24]) | (uint32_t(p[25]) << 8) |
(uint32_t(p[26]) << 16) | (uint32_t(p[27]) << 24);
uint8_t len = p[28];
if (isKnownWantedCodeScan(seedBytes, reqHash, off, len)) {
pageResult = 0x4A; // PatternFound
}
}
LOG_DEBUG("Warden: PAGE_B request bytes=", consume,
" result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
pos += consume;
resultData.push_back(pageResult);
break;
}
case CT_MPQ: {
// HASH_CLIENT_FILE request: [1 stringIdx]
if (pos + 1 > checkEnd) { pos = checkEnd; break; }
uint8_t strIdx = decrypted[pos++];
std::string filePath = resolveWardenString(strIdx);
LOG_DEBUG("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\"");
bool found = false;
std::vector<uint8_t> hash(20, 0);
if (!filePath.empty()) {
std::string normalizedPath = asciiLower(filePath);
std::replace(normalizedPath.begin(), normalizedPath.end(), '/', '\\');
auto knownIt = knownDoorHashes().find(normalizedPath);
if (knownIt != knownDoorHashes().end()) {
found = true;
hash.assign(knownIt->second.begin(), knownIt->second.end());
}
auto* am = core::Application::getInstance().getAssetManager();
if (am && am->isInitialized() && !found) {
// Use a case-insensitive direct filesystem resolution first.
// Manifest entries may point at uppercase duplicate trees with
// different content/hashes than canonical client files.
std::vector<uint8_t> fileData;
std::string resolvedFsPath =
resolveCaseInsensitiveDataPath(am->getDataPath(), filePath);
if (!resolvedFsPath.empty()) {
fileData = readFileBinary(resolvedFsPath);
}
if (fileData.empty()) {
fileData = am->readFile(filePath);
}
if (!fileData.empty()) {
found = true;
hash = auth::Crypto::sha1(fileData);
}
}
}
// Response: [uint8 result][20 sha1]
// result=0 => found/success, result=1 => not found/failure
resultData.push_back(found ? 0x00 : 0x01);
resultData.insert(resultData.end(), hash.begin(), hash.end());
break;
}
case CT_LUA: {
// Request: [1 stringIdx]
if (pos + 1 > checkEnd) { pos = checkEnd; break; }
uint8_t strIdx = decrypted[pos++];
std::string luaVar = resolveWardenString(strIdx);
LOG_DEBUG("Warden: LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\"");
// Response: [uint8 result=0][uint16 len=0]
// Lua string doesn't exist
resultData.push_back(0x01); // not found
break;
}
case CT_DRIVER: {
// Request: [4 seed][20 sha1][1 stringIdx]
if (pos + 25 > checkEnd) { pos = checkEnd; break; }
pos += 24; // skip seed + sha1
uint8_t strIdx = decrypted[pos++];
std::string driverName = resolveWardenString(strIdx);
LOG_DEBUG("Warden: DRIVER=\"", (driverName.empty() ? "?" : driverName), "\"");
// Response: [uint8 result=1] (driver NOT found = clean)
resultData.push_back(0x01);
break;
}
case CT_MODULE: {
// FIND_MODULE_BY_NAME request: [4 seed][20 sha1] = 24 bytes
int moduleSize = 24;
if (pos + moduleSize > checkEnd) {
size_t remaining = checkEnd - pos;
LOG_WARNING("Warden: MODULE check truncated (remaining=", remaining,
", expected=", moduleSize, "), consuming remainder");
pos = checkEnd;
} else {
const uint8_t* p = decrypted.data() + pos;
uint8_t seedBytes[4] = { p[0], p[1], p[2], p[3] };
uint8_t reqHash[20];
std::memcpy(reqHash, p + 4, 20);
pos += moduleSize;
// CMaNGOS uppercases module names before hashing.
// DB module scans:
// KERNEL32.DLL (wanted=true)
// WPESPY.DLL / SPEEDHACK-I386.DLL / TAMIA.DLL (wanted=false)
bool shouldReportFound = false;
if (hmacSha1Matches(seedBytes, "KERNEL32.DLL", reqHash)) {
shouldReportFound = true;
} else if (hmacSha1Matches(seedBytes, "WPESPY.DLL", reqHash) ||
hmacSha1Matches(seedBytes, "SPEEDHACK-I386.DLL", reqHash) ||
hmacSha1Matches(seedBytes, "TAMIA.DLL", reqHash)) {
shouldReportFound = false;
}
resultData.push_back(shouldReportFound ? 0x4A : 0x01);
break;
}
// Truncated module request fallback: module NOT loaded = clean
resultData.push_back(0x01);
break;
}
case CT_PROC: {
// API_CHECK request:
// [4 seed][20 sha1][1 stringIdx][1 stringIdx2][4 offset] = 30 bytes
int procSize = 30;
if (pos + procSize > checkEnd) { pos = checkEnd; break; }
pos += procSize;
// Response: [uint8 result=1] (proc NOT found = clean)
resultData.push_back(0x01);
break;
}
default: {
LOG_WARNING("Warden: Unknown check type, cannot parse remaining");
pos = checkEnd; // stop parsing
break;
}
}
}
LOG_DEBUG("Warden: Parsed ", checkCount, " checks, result data size=", resultData.size());
// --- Compute checksum: XOR of 5 uint32s from SHA1(resultData) ---
auto resultHash = auth::Crypto::sha1(resultData);
uint32_t checksum = 0;
for (int i = 0; i < 5; i++) {
uint32_t word = resultHash[i*4]
| (uint32_t(resultHash[i*4+1]) << 8)
| (uint32_t(resultHash[i*4+2]) << 16)
| (uint32_t(resultHash[i*4+3]) << 24);
checksum ^= word;
}
// --- Build response: [0x02][uint16 length][uint32 checksum][resultData] ---
uint16_t resultLen = static_cast<uint16_t>(resultData.size());
std::vector<uint8_t> resp;
resp.push_back(0x02);
resp.push_back(resultLen & 0xFF);
resp.push_back((resultLen >> 8) & 0xFF);
resp.push_back(checksum & 0xFF);
resp.push_back((checksum >> 8) & 0xFF);
resp.push_back((checksum >> 16) & 0xFF);
resp.push_back((checksum >> 24) & 0xFF);
resp.insert(resp.end(), resultData.begin(), resultData.end());
sendWardenResponse(resp);
LOG_DEBUG("Warden: Sent CHEAT_CHECKS_RESULT (", resp.size(), " bytes, ",
checkCount, " checks, checksum=0x",
[&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")");
break;
}
case 0x03: // WARDEN_SMSG_MODULE_INITIALIZE
LOG_DEBUG("Warden: MODULE_INITIALIZE (", decrypted.size(), " bytes, no response needed)");
break;
default:
LOG_DEBUG("Warden: Unknown opcode 0x", std::hex, (int)wardenOpcode, std::dec,
" (state=", (int)wardenState_, ", size=", decrypted.size(), ")");
break;
}
}
void GameHandler::handleAccountDataTimes(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_ACCOUNT_DATA_TIMES");
AccountDataTimesData data;
if (!AccountDataTimesParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_ACCOUNT_DATA_TIMES");
return;
}
LOG_DEBUG("Account data times received (server time: ", data.serverTime, ")");
}
void GameHandler::handleMotd(network::Packet& packet) {
LOG_INFO("Handling SMSG_MOTD");
MotdData data;
if (!MotdParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_MOTD");
return;
}
if (!data.isEmpty()) {
LOG_INFO("========================================");
LOG_INFO(" MESSAGE OF THE DAY");
LOG_INFO("========================================");
for (const auto& line : data.lines) {
LOG_INFO(line);
addSystemChatMessage(std::string("MOTD: ") + line);
}
// Add a visual separator after MOTD block so subsequent messages don't
// appear glued to the last MOTD line.
MessageChatData spacer;
spacer.type = ChatType::SYSTEM;
spacer.language = ChatLanguage::UNIVERSAL;
spacer.message = "";
addLocalChatMessage(spacer);
LOG_INFO("========================================");
}
}
void GameHandler::handleNotification(network::Packet& packet) {
// SMSG_NOTIFICATION: single null-terminated string
std::string message = packet.readString();
if (!message.empty()) {
LOG_INFO("Server notification: ", message);
addSystemChatMessage(message);
}
}
void GameHandler::sendPing() {
if (state != WorldState::IN_WORLD) {
return;
}
// Increment sequence number
pingSequence++;
LOG_DEBUG("Sending CMSG_PING (heartbeat)");
LOG_DEBUG(" Sequence: ", pingSequence);
// Build and send ping packet
auto packet = PingPacket::build(pingSequence, lastLatency);
socket->send(packet);
}
void GameHandler::handlePong(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_PONG");
PongData data;
if (!PongParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_PONG");
return;
}
// Verify sequence matches
if (data.sequence != pingSequence) {
LOG_WARNING("SMSG_PONG sequence mismatch: expected ", pingSequence,
", got ", data.sequence);
return;
}
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ")");
}
uint32_t GameHandler::nextMovementTimestampMs() {
auto now = std::chrono::steady_clock::now();
uint64_t elapsed = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(now - movementClockStart_).count()) + 1ULL;
if (elapsed > std::numeric_limits<uint32_t>::max()) {
movementClockStart_ = now;
elapsed = 1ULL;
}
uint32_t candidate = static_cast<uint32_t>(elapsed);
if (candidate <= lastMovementTimestampMs_) {
candidate = lastMovementTimestampMs_ + 1U;
if (candidate == 0) {
movementClockStart_ = now;
candidate = 1U;
}
}
lastMovementTimestampMs_ = candidate;
return candidate;
}
void GameHandler::sendMovement(Opcode opcode) {
if (state != WorldState::IN_WORLD) {
LOG_WARNING("Cannot send movement in state: ", (int)state);
return;
}
// Block manual movement while taxi is active/mounted, but always allow
// stop/heartbeat opcodes so stuck states can be recovered.
bool taxiAllowed =
(opcode == Opcode::MSG_MOVE_HEARTBEAT) ||
(opcode == Opcode::MSG_MOVE_STOP) ||
(opcode == Opcode::MSG_MOVE_STOP_STRAFE) ||
(opcode == Opcode::MSG_MOVE_STOP_TURN) ||
(opcode == Opcode::MSG_MOVE_STOP_SWIM);
if (!serverMovementAllowed_ && !taxiAllowed) return;
if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return;
if (resurrectPending_ && !taxiAllowed) return;
// Always send a strictly increasing non-zero client movement clock value.
movementInfo.time = nextMovementTimestampMs();
// Update movement flags based on opcode
switch (opcode) {
case Opcode::MSG_MOVE_START_FORWARD:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FORWARD);
break;
case Opcode::MSG_MOVE_START_BACKWARD:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::BACKWARD);
break;
case Opcode::MSG_MOVE_STOP:
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::FORWARD) |
static_cast<uint32_t>(MovementFlags::BACKWARD));
break;
case Opcode::MSG_MOVE_START_STRAFE_LEFT:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_LEFT);
break;
case Opcode::MSG_MOVE_START_STRAFE_RIGHT:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
break;
case Opcode::MSG_MOVE_STOP_STRAFE:
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT));
break;
case Opcode::MSG_MOVE_JUMP:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FALLING);
break;
case Opcode::MSG_MOVE_START_TURN_LEFT:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_LEFT);
break;
case Opcode::MSG_MOVE_START_TURN_RIGHT:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_RIGHT);
break;
case Opcode::MSG_MOVE_STOP_TURN:
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
static_cast<uint32_t>(MovementFlags::TURN_RIGHT));
break;
case Opcode::MSG_MOVE_FALL_LAND:
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::FALLING);
break;
case Opcode::MSG_MOVE_HEARTBEAT:
// No flag changes — just sends current position
break;
default:
break;
}
if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) {
sanitizeMovementForTaxi();
}
// Add transport data if player is on a transport
if (isOnTransport()) {
// Keep authoritative world position synchronized to parent transport transform
// so heartbeats/corrections don't drag the passenger through geometry.
if (transportManager_) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
movementInfo.x = composed.x;
movementInfo.y = composed.y;
movementInfo.z = composed.z;
}
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
movementInfo.transportGuid = playerTransportGuid_;
movementInfo.transportX = playerTransportOffset_.x;
movementInfo.transportY = playerTransportOffset_.y;
movementInfo.transportZ = playerTransportOffset_.z;
movementInfo.transportTime = movementInfo.time;
movementInfo.transportSeat = -1;
movementInfo.transportTime2 = movementInfo.time;
// ONTRANSPORT expects local orientation (player yaw relative to transport yaw).
// Keep internal yaw canonical; convert to server yaw on the wire.
float transportYawCanonical = 0.0f;
if (transportManager_) {
if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr) {
if (tr->hasServerYaw) {
transportYawCanonical = tr->serverYaw;
} else {
transportYawCanonical = glm::eulerAngles(tr->rotation).z;
}
}
}
movementInfo.transportO =
core::coords::normalizeAngleRad(movementInfo.orientation - transportYawCanonical);
} else {
// Clear transport flag if not on transport
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
movementInfo.transportGuid = 0;
movementInfo.transportSeat = -1;
}
LOG_DEBUG("Sending movement packet: opcode=0x", std::hex,
wireOpcode(opcode), std::dec,
(isOnTransport() ? " ONTRANSPORT" : ""));
// Convert canonical → server coordinates for the wire
MovementInfo wireInfo = movementInfo;
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wireInfo.x, wireInfo.y, wireInfo.z));
wireInfo.x = serverPos.x;
wireInfo.y = serverPos.y;
wireInfo.z = serverPos.z;
// Convert canonical → server yaw for the wire
wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation);
// Also convert transport local position to server coordinates if on transport
if (isOnTransport()) {
glm::vec3 serverTransportPos = core::coords::canonicalToServer(
glm::vec3(wireInfo.transportX, wireInfo.transportY, wireInfo.transportZ));
wireInfo.transportX = serverTransportPos.x;
wireInfo.transportY = serverTransportPos.y;
wireInfo.transportZ = serverTransportPos.z;
// transportO is a local delta; server<->canonical swap negates delta yaw.
wireInfo.transportO = core::coords::normalizeAngleRad(-wireInfo.transportO);
}
// Build and send movement packet (expansion-specific format)
auto packet = packetParsers_
? packetParsers_->buildMovementPacket(opcode, wireInfo, playerGuid)
: MovementPacket::build(opcode, wireInfo, playerGuid);
socket->send(packet);
}
void GameHandler::sanitizeMovementForTaxi() {
constexpr uint32_t kClearTaxiFlags =
static_cast<uint32_t>(MovementFlags::FORWARD) |
static_cast<uint32_t>(MovementFlags::BACKWARD) |
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
static_cast<uint32_t>(MovementFlags::TURN_RIGHT) |
static_cast<uint32_t>(MovementFlags::PITCH_UP) |
static_cast<uint32_t>(MovementFlags::PITCH_DOWN) |
static_cast<uint32_t>(MovementFlags::FALLING) |
static_cast<uint32_t>(MovementFlags::FALLINGFAR) |
static_cast<uint32_t>(MovementFlags::SWIMMING);
movementInfo.flags &= ~kClearTaxiFlags;
movementInfo.fallTime = 0;
movementInfo.jumpVelocity = 0.0f;
movementInfo.jumpSinAngle = 0.0f;
movementInfo.jumpCosAngle = 0.0f;
movementInfo.jumpXYSpeed = 0.0f;
movementInfo.pitch = 0.0f;
}
void GameHandler::forceClearTaxiAndMovementState() {
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
taxiStartGrace_ = 0.0f;
onTaxiFlight_ = false;
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0);
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
currentMountDisplayId_ = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
playerDead_ = false;
releasedSpirit_ = false;
repopPending_ = false;
pendingSpiritHealerGuid_ = 0;
resurrectCasterGuid_ = 0;
movementInfo.flags = 0;
movementInfo.flags2 = 0;
movementInfo.transportGuid = 0;
clearPlayerTransport();
if (socket && state == WorldState::IN_WORLD) {
sendMovement(Opcode::MSG_MOVE_STOP);
sendMovement(Opcode::MSG_MOVE_STOP_STRAFE);
sendMovement(Opcode::MSG_MOVE_STOP_TURN);
sendMovement(Opcode::MSG_MOVE_STOP_SWIM);
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("Force-cleared taxi/movement state");
}
void GameHandler::setPosition(float x, float y, float z) {
movementInfo.x = x;
movementInfo.y = y;
movementInfo.z = z;
}
void GameHandler::setOrientation(float orientation) {
movementInfo.orientation = orientation;
}
void GameHandler::handleUpdateObject(network::Packet& packet) {
static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false);
UpdateObjectData data;
if (!packetParsers_->parseUpdateObject(packet, data)) {
static int updateObjErrors = 0;
if (++updateObjErrors <= 5)
LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT");
return;
}
auto extractPlayerAppearance = [&](const std::map<uint16_t, uint32_t>& fields,
uint8_t& outRace,
uint8_t& outGender,
uint32_t& outAppearanceBytes,
uint8_t& outFacial) -> bool {
outRace = 0;
outGender = 0;
outAppearanceBytes = 0;
outFacial = 0;
auto readField = [&](uint16_t idx, uint32_t& out) -> bool {
if (idx == 0xFFFF) return false;
auto it = fields.find(idx);
if (it == fields.end()) return false;
out = it->second;
return true;
};
uint32_t bytes0 = 0;
uint32_t pbytes = 0;
uint32_t pbytes2 = 0;
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES);
const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2);
bool haveBytes0 = readField(ufBytes0, bytes0);
bool havePbytes = readField(ufPbytes, pbytes);
bool havePbytes2 = readField(ufPbytes2, pbytes2);
// Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing,
// try to locate plausible packed fields by scanning.
if (!haveBytes0) {
for (const auto& [idx, v] : fields) {
uint8_t race = static_cast<uint8_t>(v & 0xFF);
uint8_t cls = static_cast<uint8_t>((v >> 8) & 0xFF);
uint8_t gender = static_cast<uint8_t>((v >> 16) & 0xFF);
uint8_t power = static_cast<uint8_t>((v >> 24) & 0xFF);
if (race >= 1 && race <= 20 &&
cls >= 1 && cls <= 20 &&
gender <= 1 &&
power <= 10) {
bytes0 = v;
haveBytes0 = true;
break;
}
}
}
if (!havePbytes) {
for (const auto& [idx, v] : fields) {
uint8_t skin = static_cast<uint8_t>(v & 0xFF);
uint8_t face = static_cast<uint8_t>((v >> 8) & 0xFF);
uint8_t hair = static_cast<uint8_t>((v >> 16) & 0xFF);
uint8_t color = static_cast<uint8_t>((v >> 24) & 0xFF);
if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) {
pbytes = v;
havePbytes = true;
break;
}
}
}
if (!havePbytes2) {
for (const auto& [idx, v] : fields) {
uint8_t facial = static_cast<uint8_t>(v & 0xFF);
if (facial <= 100) {
pbytes2 = v;
havePbytes2 = true;
break;
}
}
}
if (!haveBytes0 || !havePbytes) return false;
outRace = static_cast<uint8_t>(bytes0 & 0xFF);
outGender = static_cast<uint8_t>((bytes0 >> 16) & 0xFF);
outAppearanceBytes = pbytes;
outFacial = havePbytes2 ? static_cast<uint8_t>(pbytes2 & 0xFF) : 0;
return true;
};
auto maybeDetectCoinageIndex = [&](const std::map<uint16_t, uint32_t>& oldFields,
const std::map<uint16_t, uint32_t>& newFields) {
if (pendingMoneyDelta_ == 0 || pendingMoneyDeltaTimer_ <= 0.0f) return;
if (oldFields.empty() || newFields.empty()) return;
constexpr uint32_t kMaxPlausibleCoinage = 2147483647u;
std::vector<uint16_t> candidates;
candidates.reserve(8);
for (const auto& [idx, newVal] : newFields) {
auto itOld = oldFields.find(idx);
if (itOld == oldFields.end()) continue;
uint32_t oldVal = itOld->second;
if (newVal < oldVal) continue;
uint32_t delta = newVal - oldVal;
if (delta != pendingMoneyDelta_) continue;
if (newVal > kMaxPlausibleCoinage) continue;
candidates.push_back(idx);
}
if (candidates.empty()) return;
uint16_t current = fieldIndex(UF::PLAYER_FIELD_COINAGE);
uint16_t chosen = candidates[0];
if (std::find(candidates.begin(), candidates.end(), current) != candidates.end()) {
chosen = current;
} else {
std::sort(candidates.begin(), candidates.end());
chosen = candidates[0];
}
if (chosen != current && current != 0xFFFF) {
updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen);
LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")");
}
pendingMoneyDelta_ = 0;
pendingMoneyDeltaTimer_ = 0.0f;
};
// Process out-of-range objects first
for (uint64_t guid : data.outOfRangeGuids) {
auto entity = entityManager.getEntity(guid);
if (!entity) continue;
const bool isKnownTransport = transportGuids_.count(guid) > 0;
if (isKnownTransport) {
// Keep transports alive across out-of-range flapping.
// Boats/zeppelins are global movers and removing them here can make
// them disappear until a later movement snapshot happens to recreate them.
const bool playerAboardNow = (playerTransportGuid_ == guid);
const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f);
const bool movementSaysAboard = (movementInfo.transportGuid == guid);
LOG_INFO("Preserving transport on out-of-range: 0x",
std::hex, guid, std::dec,
" now=", playerAboardNow,
" sticky=", stickyAboard,
" movement=", movementSaysAboard);
continue;
}
LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec);
// Trigger despawn callbacks before removing entity
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(guid);
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
playerDespawnCallback_(guid);
otherPlayerVisibleItemEntries_.erase(guid);
otherPlayerVisibleDirty_.erase(guid);
otherPlayerMoveTimeMs_.erase(guid);
inspectedPlayerItemEntries_.erase(guid);
pendingAutoInspect_.erase(guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(guid);
}
transportGuids_.erase(guid);
serverUpdatedTransportGuids_.erase(guid);
clearTransportAttachment(guid);
if (playerTransportGuid_ == guid) {
clearPlayerTransport();
}
entityManager.removeEntity(guid);
}
// Process update blocks
bool newItemCreated = false;
for (const auto& block : data.blocks) {
switch (block.updateType) {
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2: {
// Create new entity
std::shared_ptr<Entity> entity;
switch (block.objectType) {
case ObjectType::PLAYER:
entity = std::make_shared<Player>(block.guid);
break;
case ObjectType::UNIT:
entity = std::make_shared<Unit>(block.guid);
break;
case ObjectType::GAMEOBJECT:
entity = std::make_shared<GameObject>(block.guid);
break;
default:
entity = std::make_shared<Entity>(block.guid);
entity->setType(block.objectType);
break;
}
// Set position from movement block (server → canonical)
if (block.hasMovement) {
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")");
if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
serverRunSpeed_ = block.runSpeed;
}
// Track player-on-transport state
if (block.guid == playerGuid) {
if (block.onTransport) {
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
movementInfo.x = composed.x;
movementInfo.y = composed.y;
movementInfo.z = composed.z;
}
LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec,
" offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")");
} else {
// Don't clear client-side M2 transport boarding (trams) —
// the server doesn't know about client-detected transport attachment.
bool isClientM2Transport = false;
if (playerTransportGuid_ != 0 && transportManager_) {
auto* tr = transportManager_->getTransport(playerTransportGuid_);
isClientM2Transport = (tr && tr->isM2);
}
if (playerTransportGuid_ != 0 && !isClientM2Transport) {
LOG_INFO("Player left transport");
clearPlayerTransport();
}
}
}
// Track transport-relative children so they follow parent transport motion.
if (block.guid != playerGuid &&
(block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) {
if (block.onTransport && block.transportGuid != 0) {
glm::vec3 localOffset = core::coords::serverToCanonical(
glm::vec3(block.transportX, block.transportY, block.transportZ));
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
setTransportAttachment(block.guid, block.objectType, block.transportGuid,
localOffset, hasLocalOrientation, localOriCanonical);
if (transportManager_ && transportManager_->getTransport(block.transportGuid)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
}
} else {
clearTransportAttachment(block.guid);
}
}
}
// Set fields
for (const auto& field : block.fields) {
entity->setField(field.first, field.second);
}
// Add to manager
entityManager.addEntity(block.guid, entity);
// For the local player, capture the full initial field state (CREATE_OBJECT carries the
// large baseline update-field set, including visible item fields on many cores).
// Later VALUES updates often only include deltas and may never touch visible item fields.
if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) {
lastPlayerFields_ = entity->getFields();
maybeDetectVisibleItemLayout();
}
// Auto-query names (Phase 1)
if (block.objectType == ObjectType::PLAYER) {
queryPlayerName(block.guid);
if (block.guid != playerGuid) {
updateOtherPlayerVisibleItems(block.guid, entity->getFields());
}
} else if (block.objectType == ObjectType::UNIT) {
auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
if (it != block.fields.end() && it->second != 0) {
auto unit = std::static_pointer_cast<Unit>(entity);
unit->setEntry(it->second);
// Set name from cache immediately if available
std::string cached = getCachedCreatureName(it->second);
if (!cached.empty()) {
unit->setName(cached);
}
queryCreatureInfo(it->second, block.guid);
}
}
// Extract health/mana/power from fields (Phase 2) — single pass
if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<Unit>(entity);
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
bool unitInitiallyDead = false;
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1);
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE);
const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS);
const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS);
const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID);
const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID);
const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS);
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
for (const auto& [key, val] : block.fields) {
// Check all specific fields BEFORE power/maxpower range checks.
// In Classic, power indices (23-27) are adjacent to maxHealth (28),
// and maxPower indices (29-33) are adjacent to level (34) and faction (35).
// A range check like "key >= powerBase && key < powerBase+7" would
// incorrectly capture maxHealth/level/faction in Classic's tight layout.
if (key == ufHealth) {
unit->setHealth(val);
if (block.objectType == ObjectType::UNIT && val == 0) {
unitInitiallyDead = true;
}
if (block.guid == playerGuid && val == 0) {
playerDead_ = true;
LOG_INFO("Player logged in dead");
}
} else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
else if (key == ufLevel) {
unit->setLevel(val);
} else if (key == ufFaction) { unit->setFactionTemplate(val); }
else if (key == ufFlags) { unit->setUnitFlags(val); }
else if (key == ufBytes0) {
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
} else if (key == ufDisplayId) { unit->setDisplayId(val); }
else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
else if (key == ufDynFlags) {
unit->setDynamicFlags(val);
if (block.objectType == ObjectType::UNIT &&
((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) {
unitInitiallyDead = true;
}
}
// Power/maxpower range checks AFTER all specific fields
else if (key >= ufPowerBase && key < ufPowerBase + 7) {
unit->setPowerByType(static_cast<uint8_t>(key - ufPowerBase), val);
} else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) {
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufMaxPowerBase), val);
}
else if (key == ufMountDisplayId) {
if (block.guid == playerGuid) {
uint32_t old = currentMountDisplayId_;
currentMountDisplayId_ = val;
if (val != old && mountCallback_) mountCallback_(val);
if (old == 0 && val != 0) {
// Just mounted — find the mount aura (indefinite duration, self-cast)
mountAuraSpellId_ = 0;
for (const auto& a : playerAuras) {
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
mountAuraSpellId_ = a.spellId;
}
}
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
if (mountAuraSpellId_ == 0) {
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
if (ufAuras != 0xFFFF) {
for (const auto& [fk, fv] : block.fields) {
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
mountAuraSpellId_ = fv;
break;
}
}
}
}
LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_);
}
if (old != 0 && val == 0) {
mountAuraSpellId_ = 0;
for (auto& a : playerAuras)
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
}
}
unit->setMountDisplayId(val);
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
}
if (block.guid == playerGuid) {
constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100;
if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) {
onTaxiFlight_ = true;
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
sanitizeMovementForTaxi();
applyTaxiMountForCurrentNode();
}
}
if (block.guid == playerGuid &&
(unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) {
playerDead_ = true;
LOG_INFO("Player logged in dead (dynamic flags)");
}
// Detect ghost state on login via PLAYER_FLAGS
if (block.guid == playerGuid) {
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS));
if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) {
releasedSpirit_ = true;
playerDead_ = true;
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
}
}
// Determine hostility from faction template for online creatures
if (unit->getFactionTemplate() != 0) {
unit->setHostile(isHostileFaction(unit->getFactionTemplate()));
}
// Trigger creature spawn callback for units/players with displayId
if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) {
LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec,
" has displayId=0 — no spawn (entry=", unit->getEntry(),
" at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")");
}
if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) {
if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) {
// Skip local player — spawned separately via spawnPlayerCharacter()
} else if (block.objectType == ObjectType::PLAYER) {
if (playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0;
// Use the entity's accumulated field state, not just this block's changed fields.
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
}
}
} else if (creatureSpawnCallback_) {
LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec,
" displayId=", unit->getDisplayId(), " at (",
unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")");
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
if (unitInitiallyDead && npcDeathCallback_) {
npcDeathCallback_(block.guid);
}
}
// Query quest giver status for NPCs with questgiver flag (0x02)
if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(block.guid);
socket->send(qsPkt);
}
}
}
// Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8)
if (block.objectType == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID));
if (itDisp != block.fields.end()) {
go->setDisplayId(itDisp->second);
}
auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
if (itEntry != block.fields.end() && itEntry->second != 0) {
go->setEntry(itEntry->second);
auto cacheIt = gameObjectInfoCache_.find(itEntry->second);
if (cacheIt != gameObjectInfoCache_.end()) {
go->setName(cacheIt->second.name);
}
queryGameObjectInfo(itEntry->second, block.guid);
}
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(), " displayId=", go->getDisplayId(),
" updateFlags=0x", std::hex, block.updateFlags, std::dec,
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
if (block.updateFlags & 0x0002) {
transportGuids_.insert(block.guid);
LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(),
" displayId=", go->getDisplayId(),
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
// Note: TransportSpawnCallback will be invoked from Application after WMO instance is created
}
if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) {
gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(),
go->getX(), go->getY(), go->getZ(), go->getOrientation());
}
// Fire transport move callback for transports (position update on re-creation)
if (transportGuids_.count(block.guid) && transportMoveCallback_) {
serverUpdatedTransportGuids_.insert(block.guid);
transportMoveCallback_(block.guid,
go->getX(), go->getY(), go->getZ(), go->getOrientation());
}
}
// Track online item objects (CONTAINER = bags, also tracked as items)
if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) {
auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT));
if (entryIt != block.fields.end() && entryIt->second != 0) {
OnlineItemInfo info;
info.entry = entryIt->second;
info.stackCount = (stackIt != block.fields.end()) ? stackIt->second : 1;
bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end());
onlineItems_[block.guid] = info;
if (isNew) newItemCreated = true;
queryItemInfo(info.entry, block.guid);
}
// Extract container slot GUIDs for bags
if (block.objectType == ObjectType::CONTAINER) {
extractContainerFields(block.guid, block.fields);
}
}
// Extract XP / inventory slot / skill fields for player entity
if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) {
// Auto-detect coinage index using the previous snapshot vs this full snapshot.
maybeDetectCoinageIndex(lastPlayerFields_, block.fields);
lastPlayerFields_ = block.fields;
detectInventorySlotBases(block.fields);
if (kVerboseUpdateObject) {
uint16_t maxField = 0;
for (const auto& [key, _val] : block.fields) {
if (key > maxField) maxField = key;
}
LOG_INFO("Player update with ", block.fields.size(),
" fields (max index=", maxField, ")");
}
bool slotsChanged = false;
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2);
for (const auto& [key, val] : block.fields) {
if (key == ufPlayerXp) { playerXp_ = val; }
else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; }
else if (key == ufPlayerLevel) {
serverPlayerLevel_ = val;
for (auto& ch : characters) {
if (ch.guid == playerGuid) { ch.level = val; break; }
}
}
else if (key == ufCoinage) {
playerMoneyCopper_ = val;
LOG_DEBUG("Money set from update fields: ", val, " copper");
}
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_);
}
else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) {
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots));
inventory.setPurchasedBankBagSlots(bankBagSlots);
}
// Do not synthesize quest-log entries from raw update-field slots.
// Slot layouts differ on some classic-family realms and can produce
// phantom "already accepted" quests that block quest acceptance.
}
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
maybeDetectVisibleItemLayout();
extractSkillFields(lastPlayerFields_);
extractExploredZoneFields(lastPlayerFields_);
}
break;
}
case UpdateType::VALUES: {
// Update existing entity fields
auto entity = entityManager.getEntity(block.guid);
if (entity) {
if (block.hasMovement) {
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
if (block.guid != playerGuid &&
(entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) {
if (block.onTransport && block.transportGuid != 0) {
glm::vec3 localOffset = core::coords::serverToCanonical(
glm::vec3(block.transportX, block.transportY, block.transportZ));
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
setTransportAttachment(block.guid, entity->getType(), block.transportGuid,
localOffset, hasLocalOrientation, localOriCanonical);
if (transportManager_ && transportManager_->getTransport(block.transportGuid)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
}
} else {
clearTransportAttachment(block.guid);
}
}
}
for (const auto& field : block.fields) {
entity->setField(field.first, field.second);
}
if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) {
updateOtherPlayerVisibleItems(block.guid, entity->getFields());
}
// Update cached health/mana/power values (Phase 2) — single pass
if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<Unit>(entity);
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
uint32_t oldDisplayId = unit->getDisplayId();
bool displayIdChanged = false;
bool npcDeathNotified = false;
bool npcRespawnNotified = false;
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1);
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE);
const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS);
const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS);
const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID);
const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID);
const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS);
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
for (const auto& [key, val] : block.fields) {
if (key == ufHealth) {
uint32_t oldHealth = unit->getHealth();
unit->setHealth(val);
if (val == 0) {
if (block.guid == autoAttackTarget) {
stopAutoAttack();
}
hostileAttackers_.erase(block.guid);
if (block.guid == playerGuid) {
playerDead_ = true;
releasedSpirit_ = false;
stopAutoAttack();
LOG_INFO("Player died!");
}
if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
} else if (oldHealth == 0 && val > 0) {
if (block.guid == playerGuid) {
playerDead_ = false;
if (!releasedSpirit_) {
LOG_INFO("Player resurrected!");
} else {
LOG_INFO("Player entered ghost form");
}
}
if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) {
npcRespawnCallback_(block.guid);
npcRespawnNotified = true;
}
}
// Specific fields checked BEFORE power/maxpower range checks
// (Classic packs maxHealth/level/faction adjacent to power indices)
} else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
else if (key == ufBytes0) {
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
} else if (key == ufFlags) { unit->setUnitFlags(val); }
else if (key == ufDynFlags) {
uint32_t oldDyn = unit->getDynamicFlags();
unit->setDynamicFlags(val);
if (block.guid == playerGuid) {
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
if (!wasDead && nowDead) {
playerDead_ = true;
releasedSpirit_ = false;
LOG_INFO("Player died (dynamic flags)");
} else if (wasDead && !nowDead) {
playerDead_ = false;
releasedSpirit_ = false;
LOG_INFO("Player resurrected (dynamic flags)");
}
} else if (entity->getType() == ObjectType::UNIT) {
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
if (!wasDead && nowDead) {
if (!npcDeathNotified && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
} else if (wasDead && !nowDead) {
if (!npcRespawnNotified && npcRespawnCallback_) {
npcRespawnCallback_(block.guid);
npcRespawnNotified = true;
}
}
}
} else if (key == ufLevel) {
uint32_t oldLvl = unit->getLevel();
unit->setLevel(val);
if (block.guid != playerGuid &&
entity->getType() == ObjectType::PLAYER &&
val > oldLvl && oldLvl > 0 &&
otherPlayerLevelUpCallback_) {
otherPlayerLevelUpCallback_(block.guid, val);
}
}
else if (key == ufFaction) {
unit->setFactionTemplate(val);
unit->setHostile(isHostileFaction(val));
} else if (key == ufDisplayId) {
if (val != unit->getDisplayId()) {
unit->setDisplayId(val);
displayIdChanged = true;
}
} else if (key == ufMountDisplayId) {
if (block.guid == playerGuid) {
uint32_t old = currentMountDisplayId_;
currentMountDisplayId_ = val;
if (val != old && mountCallback_) mountCallback_(val);
if (old == 0 && val != 0) {
mountAuraSpellId_ = 0;
for (const auto& a : playerAuras) {
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
mountAuraSpellId_ = a.spellId;
}
}
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
if (mountAuraSpellId_ == 0) {
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
if (ufAuras != 0xFFFF) {
for (const auto& [fk, fv] : block.fields) {
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
mountAuraSpellId_ = fv;
break;
}
}
}
}
LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_);
}
if (old != 0 && val == 0) {
mountAuraSpellId_ = 0;
for (auto& a : playerAuras)
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
}
}
unit->setMountDisplayId(val);
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
// Power/maxpower range checks AFTER all specific fields
else if (key >= ufPowerBase && key < ufPowerBase + 7) {
unit->setPowerByType(static_cast<uint8_t>(key - ufPowerBase), val);
} else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) {
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufMaxPowerBase), val);
}
}
// Some units/players are created without displayId and get it later via VALUES.
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) &&
displayIdChanged &&
unit->getDisplayId() != 0 &&
unit->getDisplayId() != oldDisplayId) {
if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) {
// Skip local player — spawned separately
} else if (entity->getType() == ObjectType::PLAYER) {
if (playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0;
// Use the entity's accumulated field state, not just this block's changed fields.
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
}
}
} else if (creatureSpawnCallback_) {
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
bool isDeadNow = (unit->getHealth() == 0) ||
((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0);
if (isDeadNow && !npcDeathNotified && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
}
if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(block.guid);
socket->send(qsPkt);
}
}
}
// Update XP / inventory slot / skill fields for player entity
if (block.guid == playerGuid) {
const bool needCoinageDetectSnapshot =
(pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f);
std::map<uint16_t, uint32_t> oldFieldsSnapshot;
if (needCoinageDetectSnapshot) {
oldFieldsSnapshot = lastPlayerFields_;
}
if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
serverRunSpeed_ = block.runSpeed;
// Some server dismount paths update run speed without updating mount display field.
if (!onTaxiFlight_ && !taxiMountActive_ &&
currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) {
LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed,
" displayId=", currentMountDisplayId_);
currentMountDisplayId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
}
}
auto mergeHint = lastPlayerFields_.end();
for (const auto& [key, val] : block.fields) {
mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val);
}
if (needCoinageDetectSnapshot) {
maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_);
}
maybeDetectVisibleItemLayout();
detectInventorySlotBases(block.fields);
bool slotsChanged = false;
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2);
for (const auto& [key, val] : block.fields) {
if (key == ufPlayerXp) {
playerXp_ = val;
LOG_DEBUG("XP updated: ", val);
}
else if (key == ufPlayerNextXp) {
playerNextLevelXp_ = val;
LOG_DEBUG("Next level XP updated: ", val);
}
else if (key == ufPlayerLevel) {
serverPlayerLevel_ = val;
LOG_DEBUG("Level updated: ", val);
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.level = val;
break;
}
}
}
else if (key == ufCoinage) {
playerMoneyCopper_ = val;
LOG_DEBUG("Money updated via VALUES: ", val, " copper");
}
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
}
else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) {
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots));
inventory.setPurchasedBankBagSlots(bankBagSlots);
}
else if (key == ufPlayerFlags) {
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
bool wasGhost = releasedSpirit_;
bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0;
if (!wasGhost && nowGhost) {
releasedSpirit_ = true;
LOG_INFO("Player entered ghost form (PLAYER_FLAGS)");
} else if (wasGhost && !nowGhost) {
releasedSpirit_ = false;
playerDead_ = false;
repopPending_ = false;
resurrectPending_ = false;
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
}
}
}
// Do not auto-create quests from VALUES quest-log slot fields for the
// same reason as CREATE_OBJECT2 above (can be misaligned per realm).
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
extractSkillFields(lastPlayerFields_);
extractExploredZoneFields(lastPlayerFields_);
}
// Update item stack count for online items
if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) {
bool inventoryChanged = false;
const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT);
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
for (const auto& [key, val] : block.fields) {
if (key == itemStackField) {
auto it = onlineItems_.find(block.guid);
if (it != onlineItems_.end() && it->second.stackCount != val) {
it->second.stackCount = val;
inventoryChanged = true;
}
}
}
// Update container slot GUIDs on bag content changes
if (entity->getType() == ObjectType::CONTAINER) {
for (const auto& [key, _] : block.fields) {
if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) ||
(containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) {
inventoryChanged = true;
break;
}
}
extractContainerFields(block.guid, block.fields);
}
if (inventoryChanged) {
rebuildOnlineInventory();
}
}
if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) {
if (transportGuids_.count(block.guid) && transportMoveCallback_) {
serverUpdatedTransportGuids_.insert(block.guid);
transportMoveCallback_(block.guid, entity->getX(), entity->getY(),
entity->getZ(), entity->getOrientation());
} else if (gameObjectMoveCallback_) {
gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(),
entity->getZ(), entity->getOrientation());
}
}
LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec);
} else {
}
break;
}
case UpdateType::MOVEMENT: {
// Diagnostic: Log if we receive MOVEMENT blocks for transports
if (transportGuids_.count(block.guid)) {
LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec,
" pos=(", block.x, ", ", block.y, ", ", block.z, ")");
}
// Update entity position (server → canonical)
auto entity = entityManager.getEntity(block.guid);
if (entity) {
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec);
if (block.guid != playerGuid &&
(entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) {
if (block.onTransport && block.transportGuid != 0) {
glm::vec3 localOffset = core::coords::serverToCanonical(
glm::vec3(block.transportX, block.transportY, block.transportZ));
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
setTransportAttachment(block.guid, entity->getType(), block.transportGuid,
localOffset, hasLocalOrientation, localOriCanonical);
if (transportManager_ && transportManager_->getTransport(block.transportGuid)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
}
} else {
clearTransportAttachment(block.guid);
}
}
if (block.guid == playerGuid) {
movementInfo.orientation = oCanonical;
// Track player-on-transport state from MOVEMENT updates
if (block.onTransport) {
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
movementInfo.x = composed.x;
movementInfo.y = composed.y;
movementInfo.z = composed.z;
} else {
movementInfo.x = pos.x;
movementInfo.y = pos.y;
movementInfo.z = pos.z;
}
LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec);
} else {
movementInfo.x = pos.x;
movementInfo.y = pos.y;
movementInfo.z = pos.z;
// Don't clear client-side M2 transport boarding
bool isClientM2Transport = false;
if (playerTransportGuid_ != 0 && transportManager_) {
auto* tr = transportManager_->getTransport(playerTransportGuid_);
isClientM2Transport = (tr && tr->isM2);
}
if (playerTransportGuid_ != 0 && !isClientM2Transport) {
LOG_INFO("Player left transport (MOVEMENT)");
clearPlayerTransport();
}
}
}
// Fire transport move callback if this is a known transport
if (transportGuids_.count(block.guid) && transportMoveCallback_) {
serverUpdatedTransportGuids_.insert(block.guid);
transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical);
}
// Fire move callback for non-transport gameobjects.
if (entity->getType() == ObjectType::GAMEOBJECT &&
transportGuids_.count(block.guid) == 0 &&
gameObjectMoveCallback_) {
gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(),
entity->getZ(), entity->getOrientation());
}
// Fire move callback for non-player units (creatures).
// SMSG_MONSTER_MOVE handles smooth interpolated movement, but many
// servers (especially vanilla/Turtle WoW) communicate NPC positions
// via MOVEMENT blocks instead. Use duration=0 for an instant snap.
if (block.guid != playerGuid &&
entity->getType() == ObjectType::UNIT &&
transportGuids_.count(block.guid) == 0 &&
creatureMoveCallback_) {
creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0);
}
} else {
LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec);
}
break;
}
default:
break;
}
}
tabCycleStale = true;
// Entity count logging disabled
// Deferred rebuild: if new item objects were created in this packet, rebuild
// inventory so that slot GUIDs updated earlier in the same packet can resolve.
if (newItemCreated) {
rebuildOnlineInventory();
}
// Late inventory base detection once items are known
if (playerGuid != 0 && invSlotBase_ < 0 && !lastPlayerFields_.empty() && !onlineItems_.empty()) {
detectInventorySlotBases(lastPlayerFields_);
if (invSlotBase_ >= 0) {
if (applyInventoryFields(lastPlayerFields_)) {
rebuildOnlineInventory();
}
}
}
}
void GameHandler::handleCompressedUpdateObject(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize());
// First 4 bytes = decompressed size
if (packet.getSize() < 4) {
LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small");
return;
}
uint32_t decompressedSize = packet.readUInt32();
LOG_DEBUG(" Decompressed size: ", decompressedSize);
if (decompressedSize == 0 || decompressedSize > 1024 * 1024) {
LOG_WARNING("Invalid decompressed size: ", decompressedSize);
return;
}
// Remaining data is zlib compressed
size_t compressedSize = packet.getSize() - packet.getReadPos();
const uint8_t* compressedData = packet.getData().data() + packet.getReadPos();
// Decompress
std::vector<uint8_t> decompressed(decompressedSize);
uLongf destLen = decompressedSize;
int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize);
if (ret != Z_OK) {
LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret);
return;
}
// Create packet from decompressed data and parse it
network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed);
handleUpdateObject(decompressedPacket);
}
void GameHandler::handleDestroyObject(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_DESTROY_OBJECT");
DestroyObjectData data;
if (!DestroyObjectParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT");
return;
}
// Remove entity
if (entityManager.hasEntity(data.guid)) {
if (transportGuids_.count(data.guid) > 0) {
const bool playerAboardNow = (playerTransportGuid_ == data.guid);
const bool stickyAboard = (playerTransportStickyGuid_ == data.guid && playerTransportStickyTimer_ > 0.0f);
const bool movementSaysAboard = (movementInfo.transportGuid == data.guid);
if (playerAboardNow || stickyAboard || movementSaysAboard) {
serverUpdatedTransportGuids_.erase(data.guid);
LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec,
" now=", playerAboardNow,
" sticky=", stickyAboard,
" movement=", movementSaysAboard);
return;
}
}
// Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal.
auto entity = entityManager.getEntity(data.guid);
if (entity) {
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(data.guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(data.guid);
}
}
if (transportGuids_.count(data.guid) > 0) {
transportGuids_.erase(data.guid);
serverUpdatedTransportGuids_.erase(data.guid);
if (playerTransportGuid_ == data.guid) {
clearPlayerTransport();
}
}
clearTransportAttachment(data.guid);
entityManager.removeEntity(data.guid);
LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec,
" (", (data.isDeath ? "death" : "despawn"), ")");
} else {
LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec);
}
// Clean up auto-attack and target if destroyed entity was our target
if (data.guid == autoAttackTarget) {
stopAutoAttack();
}
if (data.guid == targetGuid) {
targetGuid = 0;
}
hostileAttackers_.erase(data.guid);
// Remove online item/container tracking
containerContents_.erase(data.guid);
if (onlineItems_.erase(data.guid)) {
rebuildOnlineInventory();
}
// Clean up quest giver status
npcQuestStatus_.erase(data.guid);
tabCycleStale = true;
// Entity count logging disabled
}
void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) {
if (state != WorldState::IN_WORLD) {
LOG_WARNING("Cannot send chat in state: ", (int)state);
return;
}
if (message.empty()) {
LOG_WARNING("Cannot send empty chat message");
return;
}
LOG_INFO("Sending chat message: [", getChatTypeString(type), "] ", message);
// Determine language based on character (for now, use COMMON)
ChatLanguage language = ChatLanguage::COMMON;
// Build and send packet
auto packet = MessageChatPacket::build(type, language, message, target);
socket->send(packet);
// Add local echo so the player sees their own message immediately
MessageChatData echo;
echo.senderGuid = playerGuid;
echo.language = language;
echo.message = message;
// Look up player name
auto nameIt = playerNameCache.find(playerGuid);
if (nameIt != playerNameCache.end()) {
echo.senderName = nameIt->second;
}
if (type == ChatType::WHISPER) {
echo.type = ChatType::WHISPER_INFORM;
echo.senderName = target; // "To [target]: message"
} else {
echo.type = type;
}
if (type == ChatType::CHANNEL) {
echo.channelName = target;
}
addLocalChatMessage(echo);
}
void GameHandler::handleMessageChat(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_MESSAGECHAT");
MessageChatData data;
if (!packetParsers_->parseMessageChat(packet, data)) {
LOG_WARNING("Failed to parse SMSG_MESSAGECHAT");
return;
}
// Skip server echo of our own messages (we already added a local echo)
if (data.senderGuid == playerGuid && data.senderGuid != 0) {
// Still track whisper sender for /r even if it's our own whisper-inform
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
lastWhisperSender_ = data.senderName;
}
return;
}
// Resolve sender name from entity/cache if not already set by parser
if (data.senderName.empty() && data.senderGuid != 0) {
// Check player name cache first
auto nameIt = playerNameCache.find(data.senderGuid);
if (nameIt != playerNameCache.end()) {
data.senderName = nameIt->second;
} else {
// Try entity name
auto entity = entityManager.getEntity(data.senderGuid);
if (entity) {
if (entity->getType() == ObjectType::PLAYER) {
auto player = std::dynamic_pointer_cast<Player>(entity);
if (player && !player->getName().empty()) {
data.senderName = player->getName();
}
} else if (entity->getType() == ObjectType::UNIT) {
auto unit = std::dynamic_pointer_cast<Unit>(entity);
if (unit && !unit->getName().empty()) {
data.senderName = unit->getName();
}
}
}
}
// If still unknown, proactively query the server so the UI can show names soon after.
if (data.senderName.empty()) {
queryPlayerName(data.senderGuid);
}
}
// Add to chat history
chatHistory.push_back(data);
// Limit chat history size
if (chatHistory.size() > maxChatHistory) {
chatHistory.erase(chatHistory.begin());
}
// Track whisper sender for /r command
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
lastWhisperSender_ = data.senderName;
}
// Trigger chat bubble for SAY/YELL messages from others
if (chatBubbleCallback_ && data.senderGuid != 0) {
if (data.type == ChatType::SAY || data.type == ChatType::YELL ||
data.type == ChatType::MONSTER_SAY || data.type == ChatType::MONSTER_YELL ||
data.type == ChatType::MONSTER_PARTY) {
bool isYell = (data.type == ChatType::YELL || data.type == ChatType::MONSTER_YELL);
chatBubbleCallback_(data.senderGuid, data.message, isYell);
}
}
// Log the message
std::string senderInfo;
if (!data.senderName.empty()) {
senderInfo = data.senderName;
} else if (data.senderGuid != 0) {
senderInfo = "Unknown-" + std::to_string(data.senderGuid);
} else {
senderInfo = "System";
}
std::string channelInfo;
if (!data.channelName.empty()) {
channelInfo = "[" + data.channelName + "] ";
}
LOG_DEBUG("[", getChatTypeString(data.type), "] ", channelInfo, senderInfo, ": ", data.message);
}
void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = TextEmotePacket::build(textEmoteId, targetGuid);
socket->send(packet);
}
void GameHandler::handleTextEmote(network::Packet& packet) {
TextEmoteData data;
if (!TextEmoteParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE");
return;
}
// Skip our own text emotes (we already have local echo)
if (data.senderGuid == playerGuid && data.senderGuid != 0) {
return;
}
// Resolve sender name
std::string senderName;
auto nameIt = playerNameCache.find(data.senderGuid);
if (nameIt != playerNameCache.end()) {
senderName = nameIt->second;
} else {
auto entity = entityManager.getEntity(data.senderGuid);
if (entity) {
auto unit = std::dynamic_pointer_cast<Unit>(entity);
if (unit) senderName = unit->getName();
}
}
if (senderName.empty()) {
senderName = "Unknown";
queryPlayerName(data.senderGuid);
}
// Resolve emote text from DBC using third-person "others see" templates
const std::string* targetPtr = data.targetName.empty() ? nullptr : &data.targetName;
std::string emoteText = rendering::Renderer::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr);
if (emoteText.empty()) {
// Fallback if DBC lookup fails
emoteText = data.targetName.empty()
? senderName + " performs an emote."
: senderName + " performs an emote at " + data.targetName + ".";
}
MessageChatData chatMsg;
chatMsg.type = ChatType::TEXT_EMOTE;
chatMsg.language = ChatLanguage::COMMON;
chatMsg.senderGuid = data.senderGuid;
chatMsg.senderName = senderName;
chatMsg.message = emoteText;
chatHistory.push_back(chatMsg);
if (chatHistory.size() > maxChatHistory) {
chatHistory.erase(chatHistory.begin());
}
// Trigger emote animation on sender's entity via callback
uint32_t animId = rendering::Renderer::getEmoteAnimByDbcId(data.textEmoteId);
if (animId != 0 && emoteAnimCallback_) {
emoteAnimCallback_(data.senderGuid, animId);
}
LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ", anim=", animId, ")");
}
void GameHandler::joinChannel(const std::string& channelName, const std::string& password) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = packetParsers_ ? packetParsers_->buildJoinChannel(channelName, password)
: JoinChannelPacket::build(channelName, password);
socket->send(packet);
LOG_INFO("Requesting to join channel: ", channelName);
}
void GameHandler::leaveChannel(const std::string& channelName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = packetParsers_ ? packetParsers_->buildLeaveChannel(channelName)
: LeaveChannelPacket::build(channelName);
socket->send(packet);
LOG_INFO("Requesting to leave channel: ", channelName);
}
std::string GameHandler::getChannelByIndex(int index) const {
if (index < 1 || index > static_cast<int>(joinedChannels_.size())) return "";
return joinedChannels_[index - 1];
}
int GameHandler::getChannelIndex(const std::string& channelName) const {
for (int i = 0; i < static_cast<int>(joinedChannels_.size()); ++i) {
if (joinedChannels_[i] == channelName) return i + 1; // 1-based
}
return 0;
}
void GameHandler::handleChannelNotify(network::Packet& packet) {
ChannelNotifyData data;
if (!ChannelNotifyParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_CHANNEL_NOTIFY");
return;
}
switch (data.notifyType) {
case ChannelNotifyType::YOU_JOINED: {
// Add to active channels if not already present
bool found = false;
for (const auto& ch : joinedChannels_) {
if (ch == data.channelName) { found = true; break; }
}
if (!found) {
joinedChannels_.push_back(data.channelName);
}
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.message = "Joined channel: " + data.channelName;
addLocalChatMessage(msg);
LOG_INFO("Joined channel: ", data.channelName);
break;
}
case ChannelNotifyType::YOU_LEFT: {
joinedChannels_.erase(
std::remove(joinedChannels_.begin(), joinedChannels_.end(), data.channelName),
joinedChannels_.end());
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.message = "Left channel: " + data.channelName;
addLocalChatMessage(msg);
LOG_INFO("Left channel: ", data.channelName);
break;
}
case ChannelNotifyType::PLAYER_ALREADY_MEMBER: {
// Server says we're already in this channel (e.g. server auto-joined us)
// Still track it in our channel list
bool found = false;
for (const auto& ch : joinedChannels_) {
if (ch == data.channelName) { found = true; break; }
}
if (!found) {
joinedChannels_.push_back(data.channelName);
LOG_INFO("Already in channel: ", data.channelName);
}
break;
}
case ChannelNotifyType::NOT_IN_AREA: {
LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)");
break;
}
default:
LOG_DEBUG("Channel notify type ", static_cast<int>(data.notifyType),
" for channel ", data.channelName);
break;
}
}
void GameHandler::autoJoinDefaultChannels() {
LOG_INFO("autoJoinDefaultChannels: general=", chatAutoJoin.general,
" trade=", chatAutoJoin.trade, " localDefense=", chatAutoJoin.localDefense,
" lfg=", chatAutoJoin.lfg, " local=", chatAutoJoin.local);
if (chatAutoJoin.general) joinChannel("General");
if (chatAutoJoin.trade) joinChannel("Trade");
if (chatAutoJoin.localDefense) joinChannel("LocalDefense");
if (chatAutoJoin.lfg) joinChannel("LookingForGroup");
if (chatAutoJoin.local) joinChannel("Local");
}
void GameHandler::setTarget(uint64_t guid) {
if (guid == targetGuid) return;
// Save previous target
if (targetGuid != 0) {
lastTargetGuid = targetGuid;
}
targetGuid = guid;
// Inform server of target selection (Phase 1)
if (state == WorldState::IN_WORLD && socket) {
auto packet = SetSelectionPacket::build(guid);
socket->send(packet);
}
if (guid != 0) {
LOG_INFO("Target set: 0x", std::hex, guid, std::dec);
}
}
void GameHandler::clearTarget() {
if (targetGuid != 0) {
LOG_INFO("Target cleared");
}
targetGuid = 0;
tabCycleIndex = -1;
tabCycleStale = true;
}
std::shared_ptr<Entity> GameHandler::getTarget() const {
if (targetGuid == 0) return nullptr;
return entityManager.getEntity(targetGuid);
}
void GameHandler::setFocus(uint64_t guid) {
focusGuid = guid;
if (guid != 0) {
auto entity = entityManager.getEntity(guid);
if (entity) {
std::string name = "Unknown";
if (entity->getType() == ObjectType::PLAYER) {
auto player = std::dynamic_pointer_cast<Player>(entity);
if (player && !player->getName().empty()) {
name = player->getName();
}
}
addSystemChatMessage("Focus set: " + name);
LOG_INFO("Focus set: 0x", std::hex, guid, std::dec);
}
}
}
void GameHandler::clearFocus() {
if (focusGuid != 0) {
addSystemChatMessage("Focus cleared.");
LOG_INFO("Focus cleared");
}
focusGuid = 0;
}
std::shared_ptr<Entity> GameHandler::getFocus() const {
if (focusGuid == 0) return nullptr;
return entityManager.getEntity(focusGuid);
}
void GameHandler::targetLastTarget() {
if (lastTargetGuid == 0) {
addSystemChatMessage("No previous target.");
return;
}
// Swap current and last target
uint64_t temp = targetGuid;
setTarget(lastTargetGuid);
lastTargetGuid = temp;
}
void GameHandler::targetEnemy(bool reverse) {
// Get list of hostile entities
std::vector<uint64_t> hostiles;
auto& entities = entityManager.getEntities();
for (const auto& [guid, entity] : entities) {
if (entity->getType() == ObjectType::UNIT) {
// Check if hostile (this is simplified - would need faction checking)
auto unit = std::dynamic_pointer_cast<Unit>(entity);
if (unit && guid != playerGuid) {
hostiles.push_back(guid);
}
}
}
if (hostiles.empty()) {
addSystemChatMessage("No enemies in range.");
return;
}
// Find current target in list
auto it = std::find(hostiles.begin(), hostiles.end(), targetGuid);
if (it == hostiles.end()) {
// Not currently targeting a hostile, target first one
setTarget(reverse ? hostiles.back() : hostiles.front());
} else {
// Cycle to next/previous
if (reverse) {
if (it == hostiles.begin()) {
setTarget(hostiles.back());
} else {
setTarget(*(--it));
}
} else {
++it;
if (it == hostiles.end()) {
setTarget(hostiles.front());
} else {
setTarget(*it);
}
}
}
}
void GameHandler::targetFriend(bool reverse) {
// Get list of friendly entities (players)
std::vector<uint64_t> friendlies;
auto& entities = entityManager.getEntities();
for (const auto& [guid, entity] : entities) {
if (entity->getType() == ObjectType::PLAYER && guid != playerGuid) {
friendlies.push_back(guid);
}
}
if (friendlies.empty()) {
addSystemChatMessage("No friendly targets in range.");
return;
}
// Find current target in list
auto it = std::find(friendlies.begin(), friendlies.end(), targetGuid);
if (it == friendlies.end()) {
// Not currently targeting a friend, target first one
setTarget(reverse ? friendlies.back() : friendlies.front());
} else {
// Cycle to next/previous
if (reverse) {
if (it == friendlies.begin()) {
setTarget(friendlies.back());
} else {
setTarget(*(--it));
}
} else {
++it;
if (it == friendlies.end()) {
setTarget(friendlies.front());
} else {
setTarget(*it);
}
}
}
}
void GameHandler::inspectTarget() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot inspect: not in world or not connected");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must target a player to inspect.");
return;
}
auto target = getTarget();
if (!target || target->getType() != ObjectType::PLAYER) {
addSystemChatMessage("You can only inspect players.");
return;
}
auto packet = InspectPacket::build(targetGuid);
socket->send(packet);
auto player = std::static_pointer_cast<Player>(target);
std::string name = player->getName().empty() ? "Target" : player->getName();
addSystemChatMessage("Inspecting " + name + "...");
LOG_INFO("Sent inspect request for player: ", name, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
}
void GameHandler::queryServerTime() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot query time: not in world or not connected");
return;
}
auto packet = QueryTimePacket::build();
socket->send(packet);
LOG_INFO("Requested server time");
}
void GameHandler::requestPlayedTime() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot request played time: not in world or not connected");
return;
}
auto packet = RequestPlayedTimePacket::build(true);
socket->send(packet);
LOG_INFO("Requested played time");
}
void GameHandler::queryWho(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot query who: not in world or not connected");
return;
}
auto packet = WhoPacket::build(0, 0, playerName);
socket->send(packet);
LOG_INFO("Sent WHO query", playerName.empty() ? "" : " for: " + playerName);
}
void GameHandler::addFriend(const std::string& playerName, const std::string& note) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot add friend: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
auto packet = AddFriendPacket::build(playerName, note);
socket->send(packet);
addSystemChatMessage("Sending friend request to " + playerName + "...");
LOG_INFO("Sent friend request to: ", playerName);
}
void GameHandler::removeFriend(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot remove friend: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
// Look up GUID from cache
auto it = friendsCache.find(playerName);
if (it == friendsCache.end()) {
addSystemChatMessage(playerName + " is not in your friends list.");
LOG_WARNING("Friend not found in cache: ", playerName);
return;
}
auto packet = DelFriendPacket::build(it->second);
socket->send(packet);
addSystemChatMessage("Removing " + playerName + " from friends list...");
LOG_INFO("Sent remove friend request for: ", playerName, " (GUID: 0x", std::hex, it->second, std::dec, ")");
}
void GameHandler::setFriendNote(const std::string& playerName, const std::string& note) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot set friend note: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
// Look up GUID from cache
auto it = friendsCache.find(playerName);
if (it == friendsCache.end()) {
addSystemChatMessage(playerName + " is not in your friends list.");
return;
}
auto packet = SetContactNotesPacket::build(it->second, note);
socket->send(packet);
addSystemChatMessage("Updated note for " + playerName);
LOG_INFO("Set friend note for: ", playerName);
}
void GameHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot roll: not in world or not connected");
return;
}
if (minRoll > maxRoll) {
std::swap(minRoll, maxRoll);
}
if (maxRoll > 10000) {
maxRoll = 10000; // Cap at reasonable value
}
auto packet = RandomRollPacket::build(minRoll, maxRoll);
socket->send(packet);
LOG_INFO("Rolled ", minRoll, "-", maxRoll);
}
void GameHandler::addIgnore(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot add ignore: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
auto packet = AddIgnorePacket::build(playerName);
socket->send(packet);
addSystemChatMessage("Adding " + playerName + " to ignore list...");
LOG_INFO("Sent ignore request for: ", playerName);
}
void GameHandler::removeIgnore(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot remove ignore: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
// Look up GUID from cache
auto it = ignoreCache.find(playerName);
if (it == ignoreCache.end()) {
addSystemChatMessage(playerName + " is not in your ignore list.");
LOG_WARNING("Ignored player not found in cache: ", playerName);
return;
}
auto packet = DelIgnorePacket::build(it->second);
socket->send(packet);
addSystemChatMessage("Removing " + playerName + " from ignore list...");
ignoreCache.erase(it);
LOG_INFO("Sent remove ignore request for: ", playerName, " (GUID: 0x", std::hex, it->second, std::dec, ")");
}
void GameHandler::requestLogout() {
if (!socket) {
LOG_WARNING("Cannot logout: not connected");
return;
}
if (loggingOut_) {
addSystemChatMessage("Already logging out.");
return;
}
auto packet = LogoutRequestPacket::build();
socket->send(packet);
loggingOut_ = true;
LOG_INFO("Sent logout request");
}
void GameHandler::cancelLogout() {
if (!socket) {
LOG_WARNING("Cannot cancel logout: not connected");
return;
}
if (!loggingOut_) {
addSystemChatMessage("Not currently logging out.");
return;
}
auto packet = LogoutCancelPacket::build();
socket->send(packet);
loggingOut_ = false;
addSystemChatMessage("Logout cancelled.");
LOG_INFO("Cancelled logout");
}
void GameHandler::setStandState(uint8_t standState) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot change stand state: not in world or not connected");
return;
}
auto packet = StandStateChangePacket::build(standState);
socket->send(packet);
LOG_INFO("Changed stand state to: ", (int)standState);
}
void GameHandler::toggleHelm() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot toggle helm: not in world or not connected");
return;
}
helmVisible_ = !helmVisible_;
auto packet = ShowingHelmPacket::build(helmVisible_);
socket->send(packet);
addSystemChatMessage(helmVisible_ ? "Helm is now visible." : "Helm is now hidden.");
LOG_INFO("Helm visibility toggled: ", helmVisible_);
}
void GameHandler::toggleCloak() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot toggle cloak: not in world or not connected");
return;
}
cloakVisible_ = !cloakVisible_;
auto packet = ShowingCloakPacket::build(cloakVisible_);
socket->send(packet);
addSystemChatMessage(cloakVisible_ ? "Cloak is now visible." : "Cloak is now hidden.");
LOG_INFO("Cloak visibility toggled: ", cloakVisible_);
}
void GameHandler::followTarget() {
if (state != WorldState::IN_WORLD) {
LOG_WARNING("Cannot follow: not in world");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must target someone to follow.");
return;
}
auto target = getTarget();
if (!target) {
addSystemChatMessage("Invalid target.");
return;
}
// Set follow target
followTargetGuid_ = targetGuid;
// Get target name
std::string targetName = "Target";
if (target->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(target);
if (!player->getName().empty()) {
targetName = player->getName();
}
} else if (target->getType() == ObjectType::UNIT) {
auto unit = std::static_pointer_cast<Unit>(target);
targetName = unit->getName();
}
addSystemChatMessage("Now following " + targetName + ".");
LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
}
void GameHandler::assistTarget() {
if (state != WorldState::IN_WORLD) {
LOG_WARNING("Cannot assist: not in world");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must target someone to assist.");
return;
}
auto target = getTarget();
if (!target) {
addSystemChatMessage("Invalid target.");
return;
}
// Get target name
std::string targetName = "Target";
if (target->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(target);
if (!player->getName().empty()) {
targetName = player->getName();
}
} else if (target->getType() == ObjectType::UNIT) {
auto unit = std::static_pointer_cast<Unit>(target);
targetName = unit->getName();
}
// Try to read target GUID from update fields (UNIT_FIELD_TARGET)
uint64_t assistTargetGuid = 0;
const auto& fields = target->getFields();
auto it = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_LO));
if (it != fields.end()) {
assistTargetGuid = it->second;
auto it2 = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_HI));
if (it2 != fields.end()) {
assistTargetGuid |= (static_cast<uint64_t>(it2->second) << 32);
}
}
if (assistTargetGuid == 0) {
addSystemChatMessage(targetName + " has no target.");
LOG_INFO("Assist: ", targetName, " has no target");
return;
}
// Set our target to their target
setTarget(assistTargetGuid);
LOG_INFO("Assisting ", targetName, ", now targeting GUID: 0x", std::hex, assistTargetGuid, std::dec);
}
void GameHandler::togglePvp() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot toggle PvP: not in world or not connected");
return;
}
auto packet = TogglePvpPacket::build();
socket->send(packet);
// Check current PVP state from player's UNIT_FIELD_FLAGS (index 59)
// UNIT_FLAG_PVP = 0x00001000
auto entity = entityManager.getEntity(playerGuid);
bool currentlyPvp = false;
if (entity) {
currentlyPvp = (entity->getField(59) & 0x00001000) != 0;
}
// We're toggling, so report the NEW state
if (currentlyPvp) {
addSystemChatMessage("PvP flag disabled.");
} else {
addSystemChatMessage("PvP flag enabled.");
}
LOG_INFO("Toggled PvP flag");
}
void GameHandler::requestGuildInfo() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot request guild info: not in world or not connected");
return;
}
auto packet = GuildInfoPacket::build();
socket->send(packet);
LOG_INFO("Requested guild info");
}
void GameHandler::requestGuildRoster() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot request guild roster: not in world or not connected");
return;
}
auto packet = GuildRosterPacket::build();
socket->send(packet);
addSystemChatMessage("Requesting guild roster...");
LOG_INFO("Requested guild roster");
}
void GameHandler::setGuildMotd(const std::string& motd) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot set guild MOTD: not in world or not connected");
return;
}
auto packet = GuildMotdPacket::build(motd);
socket->send(packet);
addSystemChatMessage("Guild MOTD updated.");
LOG_INFO("Set guild MOTD: ", motd);
}
void GameHandler::promoteGuildMember(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot promote guild member: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
auto packet = GuildPromotePacket::build(playerName);
socket->send(packet);
addSystemChatMessage("Promoting " + playerName + "...");
LOG_INFO("Promoting guild member: ", playerName);
}
void GameHandler::demoteGuildMember(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot demote guild member: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
auto packet = GuildDemotePacket::build(playerName);
socket->send(packet);
addSystemChatMessage("Demoting " + playerName + "...");
LOG_INFO("Demoting guild member: ", playerName);
}
void GameHandler::leaveGuild() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot leave guild: not in world or not connected");
return;
}
auto packet = GuildLeavePacket::build();
socket->send(packet);
addSystemChatMessage("Leaving guild...");
LOG_INFO("Leaving guild");
}
void GameHandler::inviteToGuild(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot invite to guild: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name.");
return;
}
auto packet = GuildInvitePacket::build(playerName);
socket->send(packet);
addSystemChatMessage("Inviting " + playerName + " to guild...");
LOG_INFO("Inviting to guild: ", playerName);
}
void GameHandler::initiateReadyCheck() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot initiate ready check: not in world or not connected");
return;
}
if (!isInGroup()) {
addSystemChatMessage("You must be in a group to initiate a ready check.");
return;
}
auto packet = ReadyCheckPacket::build();
socket->send(packet);
addSystemChatMessage("Ready check initiated.");
LOG_INFO("Initiated ready check");
}
void GameHandler::respondToReadyCheck(bool ready) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot respond to ready check: not in world or not connected");
return;
}
auto packet = ReadyCheckConfirmPacket::build(ready);
socket->send(packet);
addSystemChatMessage(ready ? "You are ready." : "You are not ready.");
LOG_INFO("Responded to ready check: ", ready ? "ready" : "not ready");
}
void GameHandler::acceptDuel() {
if (!pendingDuelRequest_ || state != WorldState::IN_WORLD || !socket) return;
pendingDuelRequest_ = false;
auto pkt = DuelAcceptPacket::build();
socket->send(pkt);
addSystemChatMessage("You accept the duel.");
LOG_INFO("Accepted duel from guid=0x", std::hex, duelChallengerGuid_, std::dec);
}
void GameHandler::forfeitDuel() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot forfeit duel: not in world or not connected");
return;
}
pendingDuelRequest_ = false; // cancel request if still pending
auto packet = DuelCancelPacket::build();
socket->send(packet);
addSystemChatMessage("You have forfeited the duel.");
LOG_INFO("Forfeited duel");
}
void GameHandler::handleDuelRequested(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 16) {
packet.setReadPos(packet.getSize());
return;
}
duelChallengerGuid_ = packet.readUInt64();
duelFlagGuid_ = packet.readUInt64();
// Resolve challenger name from entity list
duelChallengerName_.clear();
auto entity = entityManager.getEntity(duelChallengerGuid_);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
duelChallengerName_ = unit->getName();
}
if (duelChallengerName_.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(duelChallengerGuid_));
duelChallengerName_ = tmp;
}
pendingDuelRequest_ = true;
addSystemChatMessage(duelChallengerName_ + " challenges you to a duel!");
LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_,
" flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_);
}
void GameHandler::handleDuelComplete(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 1) return;
uint8_t started = packet.readUInt8();
// started=1: duel began, started=0: duel was cancelled before starting
pendingDuelRequest_ = false;
if (!started) {
addSystemChatMessage("The duel was cancelled.");
}
LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast<int>(started));
}
void GameHandler::handleDuelWinner(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 3) return;
/*uint8_t type =*/ packet.readUInt8(); // 0=normal, 1=flee
std::string winner = packet.readString();
std::string loser = packet.readString();
std::string msg = winner + " has defeated " + loser + " in a duel!";
addSystemChatMessage(msg);
LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser);
}
void GameHandler::toggleAfk(const std::string& message) {
afkStatus_ = !afkStatus_;
afkMessage_ = message;
if (afkStatus_) {
if (message.empty()) {
addSystemChatMessage("You are now AFK.");
} else {
addSystemChatMessage("You are now AFK: " + message);
}
// If DND was active, turn it off
if (dndStatus_) {
dndStatus_ = false;
dndMessage_.clear();
}
} else {
addSystemChatMessage("You are no longer AFK.");
afkMessage_.clear();
}
LOG_INFO("AFK status: ", afkStatus_, ", message: ", message);
}
void GameHandler::toggleDnd(const std::string& message) {
dndStatus_ = !dndStatus_;
dndMessage_ = message;
if (dndStatus_) {
if (message.empty()) {
addSystemChatMessage("You are now DND (Do Not Disturb).");
} else {
addSystemChatMessage("You are now DND: " + message);
}
// If AFK was active, turn it off
if (afkStatus_) {
afkStatus_ = false;
afkMessage_.clear();
}
} else {
addSystemChatMessage("You are no longer DND.");
dndMessage_.clear();
}
LOG_INFO("DND status: ", dndStatus_, ", message: ", message);
}
void GameHandler::replyToLastWhisper(const std::string& message) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot send whisper: not in world or not connected");
return;
}
if (lastWhisperSender_.empty()) {
addSystemChatMessage("No one has whispered you yet.");
return;
}
if (message.empty()) {
addSystemChatMessage("You must specify a message to send.");
return;
}
// Send whisper using the standard message chat function
sendChatMessage(ChatType::WHISPER, message, lastWhisperSender_);
LOG_INFO("Replied to ", lastWhisperSender_, ": ", message);
}
void GameHandler::uninvitePlayer(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot uninvite player: not in world or not connected");
return;
}
if (playerName.empty()) {
addSystemChatMessage("You must specify a player name to uninvite.");
return;
}
auto packet = GroupUninvitePacket::build(playerName);
socket->send(packet);
addSystemChatMessage("Removed " + playerName + " from the group.");
LOG_INFO("Uninvited player: ", playerName);
}
void GameHandler::leaveParty() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot leave party: not in world or not connected");
return;
}
auto packet = GroupDisbandPacket::build();
socket->send(packet);
addSystemChatMessage("You have left the group.");
LOG_INFO("Left party/raid");
}
void GameHandler::setMainTank(uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot set main tank: not in world or not connected");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must have a target selected.");
return;
}
// Main tank uses index 0
auto packet = RaidTargetUpdatePacket::build(0, targetGuid);
socket->send(packet);
addSystemChatMessage("Main tank set.");
LOG_INFO("Set main tank: 0x", std::hex, targetGuid, std::dec);
}
void GameHandler::setMainAssist(uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot set main assist: not in world or not connected");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must have a target selected.");
return;
}
// Main assist uses index 1
auto packet = RaidTargetUpdatePacket::build(1, targetGuid);
socket->send(packet);
addSystemChatMessage("Main assist set.");
LOG_INFO("Set main assist: 0x", std::hex, targetGuid, std::dec);
}
void GameHandler::clearMainTank() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot clear main tank: not in world or not connected");
return;
}
// Clear main tank by setting GUID to 0
auto packet = RaidTargetUpdatePacket::build(0, 0);
socket->send(packet);
addSystemChatMessage("Main tank cleared.");
LOG_INFO("Cleared main tank");
}
void GameHandler::clearMainAssist() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot clear main assist: not in world or not connected");
return;
}
// Clear main assist by setting GUID to 0
auto packet = RaidTargetUpdatePacket::build(1, 0);
socket->send(packet);
addSystemChatMessage("Main assist cleared.");
LOG_INFO("Cleared main assist");
}
void GameHandler::requestRaidInfo() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot request raid info: not in world or not connected");
return;
}
auto packet = RequestRaidInfoPacket::build();
socket->send(packet);
addSystemChatMessage("Requesting raid lockout information...");
LOG_INFO("Requested raid info");
}
void GameHandler::proposeDuel(uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot propose duel: not in world or not connected");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must target a player to challenge to a duel.");
return;
}
auto packet = DuelProposedPacket::build(targetGuid);
socket->send(packet);
addSystemChatMessage("You have challenged your target to a duel.");
LOG_INFO("Proposed duel to target: 0x", std::hex, targetGuid, std::dec);
}
void GameHandler::initiateTrade(uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot initiate trade: not in world or not connected");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must target a player to trade with.");
return;
}
auto packet = InitiateTradePacket::build(targetGuid);
socket->send(packet);
addSystemChatMessage("Requesting trade with target.");
LOG_INFO("Initiated trade with target: 0x", std::hex, targetGuid, std::dec);
}
void GameHandler::stopCasting() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot stop casting: not in world or not connected");
return;
}
if (!casting) {
return; // Not casting anything
}
// Send cancel cast packet only for real spell casts.
if (pendingGameObjectInteractGuid_ == 0 && currentCastSpellId != 0) {
auto packet = CancelCastPacket::build(currentCastSpellId);
socket->send(packet);
}
// Reset casting state
casting = false;
currentCastSpellId = 0;
pendingGameObjectInteractGuid_ = 0;
castTimeRemaining = 0.0f;
castTimeTotal = 0.0f;
LOG_INFO("Cancelled spell cast");
}
void GameHandler::releaseSpirit() {
if (socket && state == WorldState::IN_WORLD) {
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
if (repopPending_ && now - static_cast<int64_t>(lastRepopRequestMs_) < 1000) {
return;
}
auto packet = RepopRequestPacket::build();
socket->send(packet);
releasedSpirit_ = true;
repopPending_ = true;
lastRepopRequestMs_ = static_cast<uint64_t>(now);
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)");
}
}
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
pendingSpiritHealerGuid_ = npcGuid;
auto packet = SpiritHealerActivatePacket::build(npcGuid);
socket->send(packet);
resurrectPending_ = true;
LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", std::hex, npcGuid, std::dec);
}
void GameHandler::acceptResurrect() {
if (state != WorldState::IN_WORLD || !socket || !resurrectRequestPending_) return;
// Send spirit healer activate (correct response to SMSG_SPIRIT_HEALER_CONFIRM)
auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_);
socket->send(activate);
LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE (0x21C) for 0x",
std::hex, resurrectCasterGuid_, std::dec);
resurrectRequestPending_ = false;
resurrectPending_ = true;
}
void GameHandler::declineResurrect() {
if (state != WorldState::IN_WORLD || !socket || !resurrectRequestPending_) return;
auto resp = ResurrectResponsePacket::build(resurrectCasterGuid_, false);
socket->send(resp);
LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (decline) for 0x",
std::hex, resurrectCasterGuid_, std::dec);
resurrectRequestPending_ = false;
}
void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
// Helper: returns true if the entity is a living hostile that can be tab-targeted.
auto isValidTabTarget = [&](const std::shared_ptr<Entity>& e) -> bool {
if (!e) return false;
const uint64_t guid = e->getGuid();
auto* unit = dynamic_cast<Unit*>(e.get());
if (!unit) return false; // Not a unit (shouldn't happen after type filter)
if (unit->getHealth() == 0) return false; // Dead / corpse
const bool hostileByFaction = unit->isHostile();
const bool hostileByCombat = isAggressiveTowardPlayer(guid);
if (!hostileByFaction && !hostileByCombat) return false;
return true;
};
// Rebuild cycle list if stale (entity added/removed since last tab press).
if (tabCycleStale) {
tabCycleList.clear();
tabCycleIndex = -1;
struct EntityDist { uint64_t guid; float distance; };
std::vector<EntityDist> sortable;
for (const auto& [guid, entity] : entityManager.getEntities()) {
auto t = entity->getType();
if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue;
if (guid == playerGuid) continue;
if (!isValidTabTarget(entity)) continue; // Skip dead / non-hostile
float dx = entity->getX() - playerX;
float dy = entity->getY() - playerY;
float dz = entity->getZ() - playerZ;
sortable.push_back({guid, std::sqrt(dx*dx + dy*dy + dz*dz)});
}
std::sort(sortable.begin(), sortable.end(),
[](const EntityDist& a, const EntityDist& b) { return a.distance < b.distance; });
for (const auto& ed : sortable) {
tabCycleList.push_back(ed.guid);
}
tabCycleStale = false;
}
if (tabCycleList.empty()) {
clearTarget();
return;
}
// Advance through the cycle, skipping any entry that has since died or
// turned friendly (e.g. NPC killed between two tab presses).
int tries = static_cast<int>(tabCycleList.size());
while (tries-- > 0) {
tabCycleIndex = (tabCycleIndex + 1) % static_cast<int>(tabCycleList.size());
uint64_t guid = tabCycleList[tabCycleIndex];
auto entity = entityManager.getEntity(guid);
if (isValidTabTarget(entity)) {
setTarget(guid);
return;
}
}
// All cached entries are stale — clear target and force a fresh rebuild next time.
tabCycleStale = true;
clearTarget();
}
void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
chatHistory.push_back(msg);
if (chatHistory.size() > maxChatHistory) {
chatHistory.pop_front();
}
}
// ============================================================
// Phase 1: Name Queries
// ============================================================
void GameHandler::queryPlayerName(uint64_t guid) {
if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return;
if (state != WorldState::IN_WORLD || !socket) {
LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec,
" state=", worldStateName(state), " socket=", (socket ? "yes" : "no"));
return;
}
LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec);
pendingNameQueries.insert(guid);
auto packet = NameQueryPacket::build(guid);
socket->send(packet);
}
void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) {
if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return;
if (state != WorldState::IN_WORLD || !socket) return;
pendingCreatureQueries.insert(entry);
auto packet = CreatureQueryPacket::build(entry, guid);
socket->send(packet);
}
void GameHandler::queryGameObjectInfo(uint32_t entry, uint64_t guid) {
if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return;
if (state != WorldState::IN_WORLD || !socket) return;
pendingGameObjectQueries_.insert(entry);
auto packet = GameObjectQueryPacket::build(entry, guid);
socket->send(packet);
}
std::string GameHandler::getCachedPlayerName(uint64_t guid) const {
auto it = playerNameCache.find(guid);
return (it != playerNameCache.end()) ? it->second : "";
}
std::string GameHandler::getCachedCreatureName(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.name : "";
}
void GameHandler::handleNameQueryResponse(network::Packet& packet) {
NameQueryResponseData data;
if (!packetParsers_ || !packetParsers_->parseNameQueryResponse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")");
return;
}
pendingNameQueries.erase(data.guid);
LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec,
" found=", (int)data.found, " name='", data.name, "'",
" race=", (int)data.race, " class=", (int)data.classId);
if (data.isValid()) {
playerNameCache[data.guid] = data.name;
// Update entity name
auto entity = entityManager.getEntity(data.guid);
if (entity && entity->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(entity);
player->setName(data.name);
}
// Backfill chat history entries that arrived before we knew the name.
for (auto& msg : chatHistory) {
if (msg.senderGuid == data.guid && msg.senderName.empty()) {
msg.senderName = data.name;
}
}
// Backfill mail inbox sender names
for (auto& mail : mailInbox_) {
if (mail.messageType == 0 && mail.senderGuid == data.guid) {
mail.senderName = data.name;
}
}
}
}
void GameHandler::handleCreatureQueryResponse(network::Packet& packet) {
CreatureQueryResponseData data;
if (!CreatureQueryResponseParser::parse(packet, data)) return;
pendingCreatureQueries.erase(data.entry);
if (data.isValid()) {
creatureInfoCache[data.entry] = data;
// Update all unit entities with this entry
for (auto& [guid, entity] : entityManager.getEntities()) {
if (entity->getType() == ObjectType::UNIT) {
auto unit = std::static_pointer_cast<Unit>(entity);
if (unit->getEntry() == data.entry) {
unit->setName(data.name);
}
}
}
}
}
// ============================================================
// GameObject Query
// ============================================================
void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) {
GameObjectQueryResponseData data;
bool ok = packetParsers_ ? packetParsers_->parseGameObjectQueryResponse(packet, data)
: GameObjectQueryResponseParser::parse(packet, data);
if (!ok) return;
pendingGameObjectQueries_.erase(data.entry);
if (data.isValid()) {
gameObjectInfoCache_[data.entry] = data;
// Update all gameobject entities with this entry
for (auto& [guid, entity] : entityManager.getEntities()) {
if (entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
if (go->getEntry() == data.entry) {
go->setName(data.name);
}
}
}
// MO_TRANSPORT (type 15): assign TaxiPathNode path if available
if (data.type == 15 && data.hasData && data.data[0] != 0 && transportManager_) {
uint32_t taxiPathId = data.data[0];
if (transportManager_->hasTaxiPath(taxiPathId)) {
if (transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) {
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId);
}
} else {
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId,
" not found in TaxiPathNode.dbc");
}
}
}
}
void GameHandler::handleGameObjectPageText(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) return;
uint64_t guid = packet.readUInt64();
auto entity = entityManager.getEntity(guid);
if (!entity || entity->getType() != ObjectType::GAMEOBJECT) return;
auto go = std::static_pointer_cast<GameObject>(entity);
uint32_t entry = go->getEntry();
if (entry == 0) return;
auto cacheIt = gameObjectInfoCache_.find(entry);
if (cacheIt == gameObjectInfoCache_.end()) {
queryGameObjectInfo(entry, guid);
return;
}
const GameObjectQueryResponseData& info = cacheIt->second;
uint32_t pageId = 0;
// AzerothCore layout:
// type 9 (TEXT): data[0]=pageID
// type 10 (GOOBER): data[7]=pageId
if (info.type == 9) pageId = info.data[0];
else if (info.type == 10) pageId = info.data[7];
if (pageId != 0 && socket && state == WorldState::IN_WORLD) {
auto req = PageTextQueryPacket::build(pageId, guid);
socket->send(req);
return;
}
if (!info.name.empty()) {
addSystemChatMessage(info.name);
}
}
void GameHandler::handlePageTextQueryResponse(network::Packet& packet) {
PageTextQueryResponseData data;
if (!PageTextQueryResponseParser::parse(packet, data)) return;
if (!data.text.empty()) {
std::istringstream iss(data.text);
std::string line;
bool wrote = false;
while (std::getline(iss, line)) {
if (line.empty()) continue;
addSystemChatMessage(line);
wrote = true;
}
if (!wrote) {
addSystemChatMessage(data.text);
}
}
}
// ============================================================
// Item Query
// ============================================================
void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) {
if (itemInfoCache_.count(entry) || pendingItemQueries_.count(entry)) return;
if (state != WorldState::IN_WORLD || !socket) return;
pendingItemQueries_.insert(entry);
// Some cores reject CMSG_ITEM_QUERY_SINGLE when the GUID is 0.
// If we don't have the item object's GUID (e.g. visible equipment decoding),
// fall back to the player's GUID to keep the request non-zero.
uint64_t queryGuid = (guid != 0) ? guid : playerGuid;
auto packet = packetParsers_
? packetParsers_->buildItemQuery(entry, queryGuid)
: ItemQueryPacket::build(entry, queryGuid);
socket->send(packet);
LOG_INFO("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
" pending=", pendingItemQueries_.size());
}
void GameHandler::handleItemQueryResponse(network::Packet& packet) {
ItemQueryResponseData data;
bool parsed = packetParsers_
? packetParsers_->parseItemQueryResponse(packet, data)
: ItemQueryResponseParser::parse(packet, data);
if (!parsed) {
LOG_WARNING("handleItemQueryResponse: parse failed, size=", packet.getSize());
return;
}
pendingItemQueries_.erase(data.entry);
LOG_INFO("handleItemQueryResponse: entry=", data.entry, " valid=", data.valid,
" name='", data.name, "' displayInfoId=", data.displayInfoId,
" pending=", pendingItemQueries_.size());
if (data.valid) {
itemInfoCache_[data.entry] = data;
rebuildOnlineInventory();
maybeDetectVisibleItemLayout();
// Selectively re-emit only players whose equipment references this item entry
const uint32_t resolvedEntry = data.entry;
for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) {
for (uint32_t e : entries) {
if (e == resolvedEntry) {
emitOtherPlayerEquipment(guid);
break;
}
}
}
// Same for inspect-based entries
if (playerEquipmentCallback_) {
for (const auto& [guid, entries] : inspectedPlayerItemEntries_) {
bool relevant = false;
for (uint32_t e : entries) {
if (e == resolvedEntry) { relevant = true; break; }
}
if (!relevant) continue;
std::array<uint32_t, 19> displayIds{};
std::array<uint8_t, 19> invTypes{};
for (int s = 0; s < 19; s++) {
uint32_t entry = entries[s];
if (entry == 0) continue;
auto infoIt = itemInfoCache_.find(entry);
if (infoIt == itemInfoCache_.end()) continue;
displayIds[s] = infoIt->second.displayInfoId;
invTypes[s] = static_cast<uint8_t>(infoIt->second.inventoryType);
}
playerEquipmentCallback_(guid, displayIds, invTypes);
}
}
}
}
void GameHandler::handleInspectResults(network::Packet& packet) {
// SMSG_TALENTS_INFO (0x3F4) format:
// uint8 talentType: 0 = own talents (sent on login/respec), 1 = inspect result
// If type==1: PackedGUID of inspected player
// Then: uint32 unspentTalents, uint8 talentGroupCount, uint8 activeTalentGroup
// Per talent group: uint8 talentCount, [talentId(u32) + rank(u8)]..., uint8 glyphCount, [glyphId(u16)]...
if (packet.getSize() - packet.getReadPos() < 1) return;
uint8_t talentType = packet.readUInt8();
if (talentType == 0) {
// Own talent info — silently consume (sent on login, talent changes, respecs)
LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring");
return;
}
// talentType == 1: inspect result
if (packet.getSize() - packet.getReadPos() < 2) return;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (guid == 0) return;
size_t bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 6) {
LOG_WARNING("SMSG_TALENTS_INFO: too short after guid, ", bytesLeft, " bytes");
auto entity = entityManager.getEntity(guid);
std::string name = "Target";
if (entity) {
auto player = std::dynamic_pointer_cast<Player>(entity);
if (player && !player->getName().empty()) name = player->getName();
}
addSystemChatMessage("Inspecting " + name + " (no talent data available).");
return;
}
uint32_t unspentTalents = packet.readUInt32();
uint8_t talentGroupCount = packet.readUInt8();
uint8_t activeTalentGroup = packet.readUInt8();
// Resolve player name
auto entity = entityManager.getEntity(guid);
std::string playerName = "Target";
if (entity) {
auto player = std::dynamic_pointer_cast<Player>(entity);
if (player && !player->getName().empty()) playerName = player->getName();
}
// Parse talent groups
uint32_t totalTalents = 0;
for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) {
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 1) break;
uint8_t talentCount = packet.readUInt8();
for (uint8_t t = 0; t < talentCount; ++t) {
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 5) break;
packet.readUInt32(); // talentId
packet.readUInt8(); // rank
totalTalents++;
}
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 1) break;
uint8_t glyphCount = packet.readUInt8();
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 2) break;
packet.readUInt16(); // glyphId
}
}
// Parse enchantment slot mask + enchant IDs
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft >= 4) {
uint32_t slotMask = packet.readUInt32();
for (int slot = 0; slot < 19; ++slot) {
if (slotMask & (1u << slot)) {
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 2) break;
packet.readUInt16(); // enchantId
}
}
}
// Display inspect results
std::string msg = "Inspect: " + playerName;
msg += " - " + std::to_string(totalTalents) + " talent points spent";
if (unspentTalents > 0) {
msg += ", " + std::to_string(unspentTalents) + " unspent";
}
if (talentGroupCount > 1) {
msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")";
}
addSystemChatMessage(msg);
LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ",
unspentTalents, " unspent, ", (int)talentGroupCount, " specs");
}
uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const {
if (itemId == 0) return 0;
for (const auto& [guid, info] : onlineItems_) {
if (info.entry == itemId) return guid;
}
return 0;
}
void GameHandler::detectInventorySlotBases(const std::map<uint16_t, uint32_t>& fields) {
if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return;
if (fields.empty()) return;
std::vector<uint16_t> matchingPairs;
matchingPairs.reserve(32);
for (const auto& [idx, low] : fields) {
if ((idx % 2) != 0) continue;
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
if (itHigh == fields.end()) continue;
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
if (guid == 0) continue;
// Primary signal: GUID pairs that match spawned ITEM objects.
if (!onlineItems_.empty() && onlineItems_.count(guid)) {
matchingPairs.push_back(idx);
}
}
// Fallback signal (when ITEM objects haven't been seen yet):
// collect any plausible non-zero GUID pairs and derive a base by density.
if (matchingPairs.empty()) {
for (const auto& [idx, low] : fields) {
if ((idx % 2) != 0) continue;
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
if (itHigh == fields.end()) continue;
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
if (guid == 0) continue;
// Heuristic: item GUIDs tend to be non-trivial and change often; ignore tiny values.
if (guid < 0x10000ull) continue;
matchingPairs.push_back(idx);
}
}
if (matchingPairs.empty()) return;
std::sort(matchingPairs.begin(), matchingPairs.end());
if (invSlotBase_ < 0) {
// The lowest matching field is the first EQUIPPED slot (not necessarily HEAD).
// With 2+ matches we can derive the true base: all matches must be at
// even offsets from the base, spaced 2 fields per slot.
const int knownBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
constexpr int slotStride = 2;
bool allAlign = true;
for (uint16_t p : matchingPairs) {
if (p < knownBase || (p - knownBase) % slotStride != 0) {
allAlign = false;
break;
}
}
if (allAlign) {
invSlotBase_ = knownBase;
} else {
// Fallback: if we have 2+ matches, derive base from their spacing
if (matchingPairs.size() >= 2) {
uint16_t lo = matchingPairs[0];
// lo must be base + 2*slotN, and slotN is 0..22
// Try each possible slot for 'lo' and see if all others also land on valid slots
for (int s = 0; s <= 22; s++) {
int candidate = lo - s * slotStride;
if (candidate < 0) break;
bool ok = true;
for (uint16_t p : matchingPairs) {
int off = p - candidate;
if (off < 0 || off % slotStride != 0 || off / slotStride > 22) {
ok = false;
break;
}
}
if (ok) {
invSlotBase_ = candidate;
break;
}
}
if (invSlotBase_ < 0) invSlotBase_ = knownBase;
} else {
invSlotBase_ = knownBase;
}
}
packSlotBase_ = invSlotBase_ + (game::Inventory::NUM_EQUIP_SLOTS * 2);
LOG_INFO("Detected inventory field base: equip=", invSlotBase_,
" pack=", packSlotBase_);
}
}
bool GameHandler::applyInventoryFields(const std::map<uint16_t, uint32_t>& fields) {
bool slotsChanged = false;
int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_PACK_SLOT_1));
for (const auto& [key, val] : fields) {
if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) {
int slotIndex = (key - equipBase) / 2;
bool isLow = ((key - equipBase) % 2 == 0);
if (slotIndex < static_cast<int>(equipSlotGuids_.size())) {
uint64_t& guid = equipSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
} else if (key >= packBase && key <= packBase + (game::Inventory::BACKPACK_SLOTS * 2 - 1)) {
int slotIndex = (key - packBase) / 2;
bool isLow = ((key - packBase) % 2 == 0);
if (slotIndex < static_cast<int>(backpackSlotGuids_.size())) {
uint64_t& guid = backpackSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
// Bank slots starting at PLAYER_FIELD_BANK_SLOT_1
int bankBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1));
int bankBagBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1));
// Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7)
if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) {
effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28);
effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7;
}
if (bankBase != 0xFFFF && key >= static_cast<uint16_t>(bankBase) &&
key <= static_cast<uint16_t>(bankBase) + (effectiveBankSlots_ * 2 - 1)) {
int slotIndex = (key - bankBase) / 2;
bool isLow = ((key - bankBase) % 2 == 0);
if (slotIndex < static_cast<int>(bankSlotGuids_.size())) {
uint64_t& guid = bankSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
// Bank bag slots starting at PLAYER_FIELD_BANKBAG_SLOT_1
if (bankBagBase != 0xFFFF && key >= static_cast<uint16_t>(bankBagBase) &&
key <= static_cast<uint16_t>(bankBagBase) + (effectiveBankBagSlots_ * 2 - 1)) {
int slotIndex = (key - bankBagBase) / 2;
bool isLow = ((key - bankBagBase) % 2 == 0);
if (slotIndex < static_cast<int>(bankBagSlotGuids_.size())) {
uint64_t& guid = bankBagSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
}
return slotsChanged;
}
void GameHandler::extractContainerFields(uint64_t containerGuid, const std::map<uint16_t, uint32_t>& fields) {
const uint16_t numSlotsIdx = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
const uint16_t slot1Idx = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
if (numSlotsIdx == 0xFFFF || slot1Idx == 0xFFFF) return;
auto& info = containerContents_[containerGuid];
// Read number of slots
auto numIt = fields.find(numSlotsIdx);
if (numIt != fields.end()) {
info.numSlots = std::min(numIt->second, 36u);
}
// Read slot GUIDs (each is 2 uint32 fields: lo + hi)
for (const auto& [key, val] : fields) {
if (key < slot1Idx) continue;
int offset = key - slot1Idx;
int slotIndex = offset / 2;
if (slotIndex >= 36) continue;
bool isLow = (offset % 2 == 0);
uint64_t& guid = info.slotGuids[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
}
}
void GameHandler::rebuildOnlineInventory() {
uint8_t savedBankBagSlots = inventory.getPurchasedBankBagSlots();
inventory = Inventory();
inventory.setPurchasedBankBagSlots(savedBankBagSlots);
// Equipment slots
for (int i = 0; i < 23; i++) {
uint64_t guid = equipSlotGuids_[i];
if (guid == 0) continue;
auto itemIt = onlineItems_.find(guid);
if (itemIt == onlineItems_.end()) continue;
ItemDef def;
def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount;
def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
if (infoIt != itemInfoCache_.end()) {
def.name = infoIt->second.name;
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
def.inventoryType = infoIt->second.inventoryType;
def.maxStack = std::max(1, infoIt->second.maxStack);
def.displayInfoId = infoIt->second.displayInfoId;
def.subclassName = infoIt->second.subclassName;
def.damageMin = infoIt->second.damageMin;
def.damageMax = infoIt->second.damageMax;
def.delayMs = infoIt->second.delayMs;
def.armor = infoIt->second.armor;
def.stamina = infoIt->second.stamina;
def.strength = infoIt->second.strength;
def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid);
}
inventory.setEquipSlot(static_cast<EquipSlot>(i), def);
}
// Backpack slots
for (int i = 0; i < 16; i++) {
uint64_t guid = backpackSlotGuids_[i];
if (guid == 0) continue;
auto itemIt = onlineItems_.find(guid);
if (itemIt == onlineItems_.end()) continue;
ItemDef def;
def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount;
def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
if (infoIt != itemInfoCache_.end()) {
def.name = infoIt->second.name;
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
def.inventoryType = infoIt->second.inventoryType;
def.maxStack = std::max(1, infoIt->second.maxStack);
def.displayInfoId = infoIt->second.displayInfoId;
def.subclassName = infoIt->second.subclassName;
def.damageMin = infoIt->second.damageMin;
def.damageMax = infoIt->second.damageMax;
def.delayMs = infoIt->second.delayMs;
def.armor = infoIt->second.armor;
def.stamina = infoIt->second.stamina;
def.strength = infoIt->second.strength;
def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid);
}
inventory.setBackpackSlot(i, def);
}
// Bag contents (BAG1-BAG4 are equip slots 19-22)
for (int bagIdx = 0; bagIdx < 4; bagIdx++) {
uint64_t bagGuid = equipSlotGuids_[19 + bagIdx];
if (bagGuid == 0) continue;
// Determine bag size from container fields or item template
int numSlots = 0;
auto contIt = containerContents_.find(bagGuid);
if (contIt != containerContents_.end()) {
numSlots = static_cast<int>(contIt->second.numSlots);
}
if (numSlots <= 0) {
auto bagItemIt = onlineItems_.find(bagGuid);
if (bagItemIt != onlineItems_.end()) {
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
if (bagInfoIt != itemInfoCache_.end()) {
numSlots = bagInfoIt->second.containerSlots;
}
}
}
if (numSlots <= 0) continue;
// Set the bag size in the inventory bag data
inventory.setBagSize(bagIdx, numSlots);
// Also set bagSlots on the equipped bag item (for UI display)
auto& bagEquipSlot = inventory.getEquipSlot(static_cast<EquipSlot>(19 + bagIdx));
if (!bagEquipSlot.empty()) {
ItemDef bagDef = bagEquipSlot.item;
bagDef.bagSlots = numSlots;
inventory.setEquipSlot(static_cast<EquipSlot>(19 + bagIdx), bagDef);
}
// Populate bag slot items
if (contIt == containerContents_.end()) continue;
const auto& container = contIt->second;
for (int s = 0; s < numSlots && s < 36; s++) {
uint64_t itemGuid = container.slotGuids[s];
if (itemGuid == 0) continue;
auto itemIt = onlineItems_.find(itemGuid);
if (itemIt == onlineItems_.end()) continue;
ItemDef def;
def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount;
def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
if (infoIt != itemInfoCache_.end()) {
def.name = infoIt->second.name;
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
def.inventoryType = infoIt->second.inventoryType;
def.maxStack = std::max(1, infoIt->second.maxStack);
def.displayInfoId = infoIt->second.displayInfoId;
def.subclassName = infoIt->second.subclassName;
def.damageMin = infoIt->second.damageMin;
def.damageMax = infoIt->second.damageMax;
def.delayMs = infoIt->second.delayMs;
def.armor = infoIt->second.armor;
def.stamina = infoIt->second.stamina;
def.strength = infoIt->second.strength;
def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit;
def.bagSlots = infoIt->second.containerSlots;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, itemGuid);
}
inventory.setBagSlot(bagIdx, s, def);
}
}
// Bank slots (24 for Classic, 28 for TBC/WotLK)
for (int i = 0; i < effectiveBankSlots_; i++) {
uint64_t guid = bankSlotGuids_[i];
if (guid == 0) { inventory.clearBankSlot(i); continue; }
auto itemIt = onlineItems_.find(guid);
if (itemIt == onlineItems_.end()) { inventory.clearBankSlot(i); continue; }
ItemDef def;
def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount;
def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
if (infoIt != itemInfoCache_.end()) {
def.name = infoIt->second.name;
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
def.inventoryType = infoIt->second.inventoryType;
def.maxStack = std::max(1, infoIt->second.maxStack);
def.displayInfoId = infoIt->second.displayInfoId;
def.subclassName = infoIt->second.subclassName;
def.damageMin = infoIt->second.damageMin;
def.damageMax = infoIt->second.damageMax;
def.delayMs = infoIt->second.delayMs;
def.armor = infoIt->second.armor;
def.stamina = infoIt->second.stamina;
def.strength = infoIt->second.strength;
def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit;
def.sellPrice = infoIt->second.sellPrice;
def.bagSlots = infoIt->second.containerSlots;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid);
}
inventory.setBankSlot(i, def);
}
// Bank bag contents (6 for Classic, 7 for TBC/WotLK)
for (int bagIdx = 0; bagIdx < effectiveBankBagSlots_; bagIdx++) {
uint64_t bagGuid = bankBagSlotGuids_[bagIdx];
if (bagGuid == 0) { inventory.setBankBagSize(bagIdx, 0); continue; }
int numSlots = 0;
auto contIt = containerContents_.find(bagGuid);
if (contIt != containerContents_.end()) {
numSlots = static_cast<int>(contIt->second.numSlots);
}
// Populate the bag item itself (for icon/name in the bank bag equip slot)
auto bagItemIt = onlineItems_.find(bagGuid);
if (bagItemIt != onlineItems_.end()) {
if (numSlots <= 0) {
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
if (bagInfoIt != itemInfoCache_.end()) {
numSlots = bagInfoIt->second.containerSlots;
}
}
ItemDef bagDef;
bagDef.itemId = bagItemIt->second.entry;
bagDef.stackCount = 1;
bagDef.inventoryType = 18; // bag
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
if (bagInfoIt != itemInfoCache_.end()) {
bagDef.name = bagInfoIt->second.name;
bagDef.quality = static_cast<ItemQuality>(bagInfoIt->second.quality);
bagDef.displayInfoId = bagInfoIt->second.displayInfoId;
bagDef.bagSlots = bagInfoIt->second.containerSlots;
} else {
bagDef.name = "Bag";
queryItemInfo(bagDef.itemId, bagGuid);
}
inventory.setBankBagItem(bagIdx, bagDef);
}
if (numSlots <= 0) continue;
inventory.setBankBagSize(bagIdx, numSlots);
if (contIt == containerContents_.end()) continue;
const auto& container = contIt->second;
for (int s = 0; s < numSlots && s < 36; s++) {
uint64_t itemGuid = container.slotGuids[s];
if (itemGuid == 0) continue;
auto itemIt = onlineItems_.find(itemGuid);
if (itemIt == onlineItems_.end()) continue;
ItemDef def;
def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount;
def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
if (infoIt != itemInfoCache_.end()) {
def.name = infoIt->second.name;
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
def.inventoryType = infoIt->second.inventoryType;
def.maxStack = std::max(1, infoIt->second.maxStack);
def.displayInfoId = infoIt->second.displayInfoId;
def.subclassName = infoIt->second.subclassName;
def.damageMin = infoIt->second.damageMin;
def.damageMax = infoIt->second.damageMax;
def.delayMs = infoIt->second.delayMs;
def.armor = infoIt->second.armor;
def.stamina = infoIt->second.stamina;
def.strength = infoIt->second.strength;
def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit;
def.sellPrice = infoIt->second.sellPrice;
def.bagSlots = infoIt->second.containerSlots;
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, itemGuid);
}
inventory.setBankBagSlot(bagIdx, s, def);
}
}
// Only mark equipment dirty if equipped item displayInfoIds actually changed
std::array<uint32_t, 19> currentEquipDisplayIds{};
for (int i = 0; i < 19; i++) {
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
if (!slot.empty()) currentEquipDisplayIds[i] = slot.item.displayInfoId;
}
if (currentEquipDisplayIds != lastEquipDisplayIds_) {
lastEquipDisplayIds_ = currentEquipDisplayIds;
onlineEquipDirty_ = true;
}
LOG_DEBUG("Rebuilt online inventory: equip=", [&](){
int c = 0; for (auto g : equipSlotGuids_) if (g) c++; return c;
}(), " backpack=", [&](){
int c = 0; for (auto g : backpackSlotGuids_) if (g) c++; return c;
}());
}
void GameHandler::maybeDetectVisibleItemLayout() {
if (visibleItemLayoutVerified_) return;
if (lastPlayerFields_.empty()) return;
std::array<uint32_t, 19> equipEntries{};
int nonZero = 0;
// Prefer authoritative equipped item entry IDs derived from item objects (onlineItems_),
// because Inventory::ItemDef may not be populated yet if templates haven't been queried.
for (int i = 0; i < 19; i++) {
uint64_t itemGuid = equipSlotGuids_[i];
if (itemGuid != 0) {
auto it = onlineItems_.find(itemGuid);
if (it != onlineItems_.end() && it->second.entry != 0) {
equipEntries[i] = it->second.entry;
}
}
if (equipEntries[i] == 0) {
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
equipEntries[i] = slot.empty() ? 0u : slot.item.itemId;
}
if (equipEntries[i] != 0) nonZero++;
}
if (nonZero < 2) return;
const uint16_t maxKey = lastPlayerFields_.rbegin()->first;
int bestBase = -1;
int bestStride = 0;
int bestMatches = 0;
int bestMismatches = 9999;
int bestScore = -999999;
const int strides[] = {2, 3, 4, 1};
for (int stride : strides) {
for (const auto& [baseIdxU16, _v] : lastPlayerFields_) {
const int base = static_cast<int>(baseIdxU16);
if (base + 18 * stride > static_cast<int>(maxKey)) continue;
int matches = 0;
int mismatches = 0;
for (int s = 0; s < 19; s++) {
uint32_t want = equipEntries[s];
if (want == 0) continue;
const uint16_t idx = static_cast<uint16_t>(base + s * stride);
auto it = lastPlayerFields_.find(idx);
if (it == lastPlayerFields_.end()) continue;
if (it->second == want) {
matches++;
} else if (it->second != 0) {
mismatches++;
}
}
int score = matches * 2 - mismatches * 3;
if (score > bestScore ||
(score == bestScore && matches > bestMatches) ||
(score == bestScore && matches == bestMatches && mismatches < bestMismatches) ||
(score == bestScore && matches == bestMatches && mismatches == bestMismatches && base < bestBase)) {
bestScore = score;
bestMatches = matches;
bestMismatches = mismatches;
bestBase = base;
bestStride = stride;
}
}
}
if (bestMatches >= 2 && bestBase >= 0 && bestStride > 0 && bestMismatches <= 1) {
visibleItemEntryBase_ = bestBase;
visibleItemStride_ = bestStride;
visibleItemLayoutVerified_ = true;
LOG_INFO("Detected PLAYER_VISIBLE_ITEM entry layout: base=", visibleItemEntryBase_,
" stride=", visibleItemStride_, " (matches=", bestMatches,
" mismatches=", bestMismatches, " score=", bestScore, ")");
// Backfill existing player entities already in view.
for (const auto& [guid, ent] : entityManager.getEntities()) {
if (!ent || ent->getType() != ObjectType::PLAYER) continue;
if (guid == playerGuid) continue;
updateOtherPlayerVisibleItems(guid, ent->getFields());
}
}
// If heuristic didn't find a match, keep using the default WotLK layout (base=284, stride=2).
}
void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map<uint16_t, uint32_t>& fields) {
if (guid == 0 || guid == playerGuid) return;
if (visibleItemEntryBase_ < 0 || visibleItemStride_ <= 0) {
// Layout not detected yet — queue this player for inspect as fallback.
if (socket && state == WorldState::IN_WORLD) {
pendingAutoInspect_.insert(guid);
LOG_DEBUG("Queued player 0x", std::hex, guid, std::dec, " for auto-inspect (layout not detected)");
}
return;
}
std::array<uint32_t, 19> newEntries{};
for (int s = 0; s < 19; s++) {
uint16_t idx = static_cast<uint16_t>(visibleItemEntryBase_ + s * visibleItemStride_);
auto it = fields.find(idx);
if (it != fields.end()) newEntries[s] = it->second;
}
bool changed = false;
auto& old = otherPlayerVisibleItemEntries_[guid];
if (old != newEntries) {
old = newEntries;
changed = true;
}
// Request item templates for any new visible entries.
for (uint32_t entry : newEntries) {
if (entry == 0) continue;
if (!itemInfoCache_.count(entry) && !pendingItemQueries_.count(entry)) {
queryItemInfo(entry, 0);
}
}
// If the server isn't sending visible item fields (all zeros), fall back to inspect.
bool any = false;
for (uint32_t e : newEntries) { if (e != 0) { any = true; break; } }
if (!any && socket && state == WorldState::IN_WORLD) {
pendingAutoInspect_.insert(guid);
}
if (changed) {
otherPlayerVisibleDirty_.insert(guid);
emitOtherPlayerEquipment(guid);
}
}
void GameHandler::emitOtherPlayerEquipment(uint64_t guid) {
if (!playerEquipmentCallback_) return;
auto it = otherPlayerVisibleItemEntries_.find(guid);
if (it == otherPlayerVisibleItemEntries_.end()) return;
std::array<uint32_t, 19> displayIds{};
std::array<uint8_t, 19> invTypes{};
bool anyEntry = false;
for (int s = 0; s < 19; s++) {
uint32_t entry = it->second[s];
if (entry == 0) continue;
anyEntry = true;
auto infoIt = itemInfoCache_.find(entry);
if (infoIt == itemInfoCache_.end()) continue;
displayIds[s] = infoIt->second.displayInfoId;
invTypes[s] = static_cast<uint8_t>(infoIt->second.inventoryType);
}
playerEquipmentCallback_(guid, displayIds, invTypes);
otherPlayerVisibleDirty_.erase(guid);
// If we had entries but couldn't resolve any templates, also try inspect as a fallback.
bool anyResolved = false;
for (uint32_t did : displayIds) { if (did != 0) { anyResolved = true; break; } }
if (anyEntry && !anyResolved) {
pendingAutoInspect_.insert(guid);
}
}
void GameHandler::emitAllOtherPlayerEquipment() {
if (!playerEquipmentCallback_) return;
for (const auto& [guid, _] : otherPlayerVisibleItemEntries_) {
emitOtherPlayerEquipment(guid);
}
}
// ============================================================
// Phase 2: Combat
// ============================================================
void GameHandler::startAutoAttack(uint64_t targetGuid) {
// Can't attack yourself
if (targetGuid == playerGuid) return;
if (targetGuid == 0) return;
// Dismount when entering combat
if (isMounted()) {
dismount();
}
// Client-side melee range gate to avoid starting "swing forever" loops when
// target is already clearly out of range.
if (auto target = entityManager.getEntity(targetGuid)) {
float dx = movementInfo.x - target->getLatestX();
float dy = movementInfo.y - target->getLatestY();
float dz = movementInfo.z - target->getLatestZ();
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist3d > 8.0f) {
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
addSystemChatMessage("Target is too far away.");
autoAttackRangeWarnCooldown_ = 1.25f;
}
return;
}
}
autoAttackRequested_ = true;
// Keep combat animation/state server-authoritative. We only flip autoAttacking
// on SMSG_ATTACKSTART where attackerGuid == playerGuid.
autoAttacking = false;
autoAttackTarget = targetGuid;
autoAttackOutOfRange_ = false;
autoAttackOutOfRangeTime_ = 0.0f;
autoAttackResendTimer_ = 0.0f;
autoAttackFacingSyncTimer_ = 0.0f;
if (state == WorldState::IN_WORLD && socket) {
auto packet = AttackSwingPacket::build(targetGuid);
socket->send(packet);
}
LOG_INFO("Starting auto-attack on 0x", std::hex, targetGuid, std::dec);
}
void GameHandler::stopAutoAttack() {
if (!autoAttacking && !autoAttackRequested_) return;
autoAttackRequested_ = false;
autoAttacking = false;
autoAttackTarget = 0;
autoAttackOutOfRange_ = false;
autoAttackOutOfRangeTime_ = 0.0f;
autoAttackResendTimer_ = 0.0f;
autoAttackFacingSyncTimer_ = 0.0f;
if (state == WorldState::IN_WORLD && socket) {
auto packet = AttackStopPacket::build();
socket->send(packet);
}
LOG_INFO("Stopping auto-attack");
}
void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) {
CombatTextEntry entry;
entry.type = type;
entry.amount = amount;
entry.spellId = spellId;
entry.age = 0.0f;
entry.isPlayerSource = isPlayerSource;
combatText.push_back(entry);
}
void GameHandler::updateCombatText(float deltaTime) {
for (auto& entry : combatText) {
entry.age += deltaTime;
}
combatText.erase(
std::remove_if(combatText.begin(), combatText.end(),
[](const CombatTextEntry& e) { return e.isExpired(); }),
combatText.end());
}
void GameHandler::autoTargetAttacker(uint64_t attackerGuid) {
if (attackerGuid == 0 || attackerGuid == playerGuid) return;
if (targetGuid != 0) return;
if (!entityManager.hasEntity(attackerGuid)) return;
setTarget(attackerGuid);
}
void GameHandler::handleAttackStart(network::Packet& packet) {
AttackStartData data;
if (!AttackStartParser::parse(packet, data)) return;
if (data.attackerGuid == playerGuid) {
autoAttackRequested_ = true;
autoAttacking = true;
autoAttackTarget = data.victimGuid;
} else if (data.victimGuid == playerGuid && data.attackerGuid != 0) {
hostileAttackers_.insert(data.attackerGuid);
autoTargetAttacker(data.attackerGuid);
// Play aggro sound when NPC attacks player
if (npcAggroCallback_) {
auto entity = entityManager.getEntity(data.attackerGuid);
if (entity && entity->getType() == ObjectType::UNIT) {
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
npcAggroCallback_(data.attackerGuid, pos);
}
}
}
// Force both participants to face each other at combat start.
// Uses atan2(-dy, dx): canonical orientation convention where the West/Y
// component is negated (renderYaw = orientation + 90°, model-forward = render+X).
auto attackerEnt = entityManager.getEntity(data.attackerGuid);
auto victimEnt = entityManager.getEntity(data.victimGuid);
if (attackerEnt && victimEnt) {
float dx = victimEnt->getX() - attackerEnt->getX();
float dy = victimEnt->getY() - attackerEnt->getY();
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
attackerEnt->setOrientation(std::atan2(-dy, dx)); // attacker → victim
victimEnt->setOrientation (std::atan2( dy, -dx)); // victim → attacker
}
}
}
void GameHandler::handleAttackStop(network::Packet& packet) {
AttackStopData data;
if (!AttackStopParser::parse(packet, data)) return;
// Keep intent, but clear server-confirmed active state until ATTACKSTART resumes.
if (data.attackerGuid == playerGuid) {
autoAttacking = false;
LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)");
if (autoAttackRequested_ && autoAttackTarget != 0 && socket) {
// Classic-family servers may emit transient ATTACKSTOP when range/facing jitters.
// Reassert melee intent immediately instead of waiting for periodic resend.
auto pkt = AttackSwingPacket::build(autoAttackTarget);
socket->send(pkt);
}
} else if (data.victimGuid == playerGuid) {
hostileAttackers_.erase(data.attackerGuid);
}
}
void GameHandler::dismount() {
if (!socket) return;
// Clear local mount state immediately (optimistic dismount).
// Server will confirm via SMSG_UPDATE_OBJECT with mountDisplayId=0.
uint32_t savedMountAura = mountAuraSpellId_;
if (currentMountDisplayId_ != 0 || taxiMountActive_) {
if (mountCallback_) {
mountCallback_(0);
}
currentMountDisplayId_ = 0;
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
mountAuraSpellId_ = 0;
LOG_INFO("Dismount: cleared local mount state");
}
// CMSG_CANCEL_MOUNT_AURA exists in TBC+ (0x0375). Classic/Vanilla doesn't have it.
uint16_t cancelMountWire = wireOpcode(Opcode::CMSG_CANCEL_MOUNT_AURA);
if (cancelMountWire != 0xFFFF) {
network::Packet pkt(cancelMountWire);
socket->send(pkt);
LOG_INFO("Sent CMSG_CANCEL_MOUNT_AURA");
} else if (savedMountAura != 0) {
// Fallback for Classic/Vanilla: cancel the mount aura by spell ID
auto pkt = CancelAuraPacket::build(savedMountAura);
socket->send(pkt);
LOG_INFO("Sent CMSG_CANCEL_AURA (mount spell ", savedMountAura, ") — Classic fallback");
} else {
// No tracked mount aura — try cancelling all indefinite self-cast auras
// (mount aura detection may have missed if aura arrived after mount field)
for (const auto& a : playerAuras) {
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
auto pkt = CancelAuraPacket::build(a.spellId);
socket->send(pkt);
LOG_INFO("Sent CMSG_CANCEL_AURA (spell ", a.spellId, ") — brute force dismount");
}
}
}
}
void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* name,
Opcode ackOpcode, float* speedStorage) {
// Packed GUID
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
// uint32 counter
uint32_t counter = packet.readUInt32();
// Determine format from remaining bytes:
// 5 bytes remaining = uint8(1) + float(4) — standard 3.3.5a
// 8 bytes remaining = uint32(4) + float(4) — some forks
// 4 bytes remaining = float(4) — no unknown field
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining >= 8) {
packet.readUInt32(); // unknown (extended format)
} else if (remaining >= 5) {
packet.readUInt8(); // unknown (standard 3.3.5a)
}
// float newSpeed
float newSpeed = packet.readFloat();
LOG_INFO("SMSG_FORCE_", name, "_CHANGE: guid=0x", std::hex, guid, std::dec,
" counter=", counter, " speed=", newSpeed);
if (guid != playerGuid) return;
// Always ACK the speed change to prevent server stall.
if (socket && !isClassicLikeExpansion()) {
network::Packet ack(wireOpcode(ackOpcode));
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid);
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
ack.writeUInt32(counter);
MovementInfo wire = movementInfo;
wire.time = nextMovementTimestampMs();
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
wire.transportTime = wire.time;
wire.transportTime2 = wire.time;
}
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
wire.x = serverPos.x;
wire.y = serverPos.y;
wire.z = serverPos.z;
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
glm::vec3 serverTransport =
core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ));
wire.transportX = serverTransport.x;
wire.transportY = serverTransport.y;
wire.transportZ = serverTransport.z;
}
if (packetParsers_) {
packetParsers_->writeMovementPayload(ack, wire);
} else {
MovementPacket::writeMovementPayload(ack, wire);
}
ack.writeFloat(newSpeed);
socket->send(ack);
}
// Validate speed - reject garbage/NaN values but still ACK
if (std::isnan(newSpeed) || newSpeed < 0.1f || newSpeed > 100.0f) {
LOG_WARNING("Ignoring invalid ", name, " speed: ", newSpeed);
return;
}
if (speedStorage) *speedStorage = newSpeed;
}
void GameHandler::handleForceRunSpeedChange(network::Packet& packet) {
handleForceSpeedChange(packet, "RUN_SPEED", Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK, &serverRunSpeed_);
// Server can auto-dismount (e.g. entering no-mount areas) and only send a speed change.
// Keep client mount visuals in sync with server-authoritative movement speed.
if (!onTaxiFlight_ && !taxiMountActive_ && currentMountDisplayId_ != 0 && serverRunSpeed_ <= 8.5f) {
LOG_INFO("Auto-clearing mount from speed change: speed=", serverRunSpeed_,
" displayId=", currentMountDisplayId_);
currentMountDisplayId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
}
}
void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) {
// Packet is server movement control update:
// packedGuid + uint32 counter + [optional unknown field(s)].
// We always ACK with current movement state, same pattern as speed-change ACKs.
if (packet.getSize() - packet.getReadPos() < 2) return;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t counter = packet.readUInt32();
LOG_INFO(rooted ? "SMSG_FORCE_MOVE_ROOT" : "SMSG_FORCE_MOVE_UNROOT",
": guid=0x", std::hex, guid, std::dec, " counter=", counter);
if (guid != playerGuid) return;
// Keep local movement flags aligned with server authoritative root state.
if (rooted) {
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::ROOT);
} else {
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ROOT);
}
if (!socket || isClassicLikeExpansion()) return;
uint16_t ackWire = wireOpcode(rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK
: Opcode::CMSG_FORCE_MOVE_UNROOT_ACK);
if (ackWire == 0xFFFF) return;
network::Packet ack(ackWire);
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for root/unroot ACKs
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
ack.writeUInt32(counter);
MovementInfo wire = movementInfo;
wire.time = nextMovementTimestampMs();
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
wire.transportTime = wire.time;
wire.transportTime2 = wire.time;
}
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
wire.x = serverPos.x;
wire.y = serverPos.y;
wire.z = serverPos.z;
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
glm::vec3 serverTransport =
core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ));
wire.transportX = serverTransport.x;
wire.transportY = serverTransport.y;
wire.transportZ = serverTransport.z;
}
if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire);
else MovementPacket::writeMovementPayload(ack, wire);
socket->send(ack);
}
void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* name,
Opcode ackOpcode, uint32_t flag, bool set) {
if (packet.getSize() - packet.getReadPos() < 2) return;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t counter = packet.readUInt32();
LOG_INFO("SMSG_FORCE_", name, ": guid=0x", std::hex, guid, std::dec, " counter=", counter);
if (guid != playerGuid) return;
// Update local movement flags if a flag was specified
if (flag != 0) {
if (set) {
movementInfo.flags |= flag;
} else {
movementInfo.flags &= ~flag;
}
}
if (!socket || isClassicLikeExpansion()) return;
uint16_t ackWire = wireOpcode(ackOpcode);
if (ackWire == 0xFFFF) return;
network::Packet ack(ackWire);
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid);
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
ack.writeUInt32(counter);
MovementInfo wire = movementInfo;
wire.time = nextMovementTimestampMs();
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
wire.transportTime = wire.time;
wire.transportTime2 = wire.time;
}
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
wire.x = serverPos.x;
wire.y = serverPos.y;
wire.z = serverPos.z;
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
glm::vec3 serverTransport =
core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ));
wire.transportX = serverTransport.x;
wire.transportY = serverTransport.y;
wire.transportZ = serverTransport.z;
}
if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire);
else MovementPacket::writeMovementPayload(ack, wire);
socket->send(ack);
}
void GameHandler::handleMoveKnockBack(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 2) return;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4)
uint32_t counter = packet.readUInt32();
[[maybe_unused]] float vcos = packet.readFloat();
[[maybe_unused]] float vsin = packet.readFloat();
[[maybe_unused]] float hspeed = packet.readFloat();
[[maybe_unused]] float vspeed = packet.readFloat();
LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec,
" counter=", counter, " hspeed=", hspeed, " vspeed=", vspeed);
if (guid != playerGuid) return;
if (!socket || isClassicLikeExpansion()) return;
uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK);
if (ackWire == 0xFFFF) return;
network::Packet ack(ackWire);
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid);
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
ack.writeUInt32(counter);
MovementInfo wire = movementInfo;
wire.time = nextMovementTimestampMs();
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
wire.transportTime = wire.time;
wire.transportTime2 = wire.time;
}
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
wire.x = serverPos.x;
wire.y = serverPos.y;
wire.z = serverPos.z;
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
glm::vec3 serverTransport =
core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ));
wire.transportX = serverTransport.x;
wire.transportY = serverTransport.y;
wire.transportZ = serverTransport.z;
}
if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire);
else MovementPacket::writeMovementPayload(ack, wire);
socket->send(ack);
}
// ============================================================
// Arena / Battleground Handlers
// ============================================================
void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t queueSlot = packet.readUInt32();
// Minimal packet = just queueSlot + arenaType(1) when status is NONE
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared");
return;
}
uint8_t arenaType = packet.readUInt8();
if (packet.getSize() - packet.getReadPos() < 1) return;
// Unknown byte
packet.readUInt8();
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t bgTypeId = packet.readUInt32();
if (packet.getSize() - packet.getReadPos() < 2) return;
uint16_t unk2 = packet.readUInt16();
(void)unk2;
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t clientInstanceId = packet.readUInt32();
(void)clientInstanceId;
if (packet.getSize() - packet.getReadPos() < 1) return;
uint8_t isRatedArena = packet.readUInt8();
(void)isRatedArena;
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t statusId = packet.readUInt32();
std::string bgName = "Battleground #" + std::to_string(bgTypeId);
if (arenaType > 0) {
bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena";
}
// Store queue state
if (queueSlot < bgQueues_.size()) {
bgQueues_[queueSlot].queueSlot = queueSlot;
bgQueues_[queueSlot].bgTypeId = bgTypeId;
bgQueues_[queueSlot].arenaType = arenaType;
bgQueues_[queueSlot].statusId = statusId;
}
switch (statusId) {
case 0: // STATUS_NONE
LOG_INFO("Battlefield status: NONE for ", bgName);
break;
case 1: // STATUS_WAIT_QUEUE
addSystemChatMessage("Queued for " + bgName + ".");
LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName);
break;
case 2: // STATUS_WAIT_JOIN
addSystemChatMessage(bgName + " is ready! Type /join to enter.");
LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName);
break;
case 3: // STATUS_IN_PROGRESS
addSystemChatMessage("Entered " + bgName + ".");
LOG_INFO("Battlefield status: IN_PROGRESS for ", bgName);
break;
case 4: // STATUS_WAIT_LEAVE
LOG_INFO("Battlefield status: WAIT_LEAVE for ", bgName);
break;
default:
LOG_INFO("Battlefield status: unknown (", statusId, ") for ", bgName);
break;
}
}
bool GameHandler::hasPendingBgInvite() const {
for (const auto& slot : bgQueues_) {
if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN
}
return false;
}
void GameHandler::acceptBattlefield(uint32_t queueSlot) {
if (state != WorldState::IN_WORLD) return;
if (!socket) return;
// Find first WAIT_JOIN slot if no specific slot given
const BgQueueSlot* slot = nullptr;
if (queueSlot == 0xFFFFFFFF) {
for (const auto& s : bgQueues_) {
if (s.statusId == 2) { slot = &s; break; }
}
} else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) {
slot = &bgQueues_[queueSlot];
}
if (!slot) {
addSystemChatMessage("No battleground invitation pending.");
return;
}
// CMSG_BATTLEFIELD_PORT: arenaType(1) + unk(1) + bgTypeId(4) + unk(2) + action(1) = 9 bytes
network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT));
pkt.writeUInt8(slot->arenaType);
pkt.writeUInt8(0x00);
pkt.writeUInt32(slot->bgTypeId);
pkt.writeUInt16(0x0000);
pkt.writeUInt8(1); // 1 = accept, 0 = decline
socket->send(pkt);
addSystemChatMessage("Accepting battleground invitation...");
LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId);
}
void GameHandler::handleRaidInstanceInfo(network::Packet& packet) {
// SMSG_RAID_INSTANCE_INFO: uint32 count, then for each:
// mapId(u32) + difficulty(u32) + resetTime(u64) + locked(u8) + extended(u8)
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t count = packet.readUInt32();
instanceLockouts_.clear();
instanceLockouts_.reserve(count);
constexpr size_t kEntrySize = 4 + 4 + 8 + 1 + 1;
for (uint32_t i = 0; i < count; ++i) {
if (packet.getSize() - packet.getReadPos() < kEntrySize) break;
InstanceLockout lo;
lo.mapId = packet.readUInt32();
lo.difficulty = packet.readUInt32();
lo.resetTime = packet.readUInt64();
lo.locked = packet.readUInt8() != 0;
lo.extended = packet.readUInt8() != 0;
instanceLockouts_.push_back(lo);
LOG_INFO("Instance lockout: mapId=", lo.mapId, " diff=", lo.difficulty,
" reset=", lo.resetTime, " locked=", lo.locked, " extended=", lo.extended);
}
LOG_INFO("SMSG_RAID_INSTANCE_INFO: ", instanceLockouts_.size(), " lockout(s)");
}
void GameHandler::handleInstanceDifficulty(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) return;
instanceDifficulty_ = packet.readUInt32();
uint32_t isHeroic = packet.readUInt32();
instanceIsHeroic_ = (isHeroic != 0);
LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_);
}
// ---------------------------------------------------------------------------
// LFG / Dungeon Finder handlers (WotLK 3.3.5a)
// ---------------------------------------------------------------------------
static const char* lfgJoinResultString(uint8_t result) {
switch (result) {
case 0: return nullptr; // success
case 1: return "Role check failed.";
case 2: return "No LFG slots available for your group.";
case 3: return "No LFG object found.";
case 4: return "No slots available (player).";
case 5: return "No slots available (party).";
case 6: return "Dungeon requirements not met by all members.";
case 7: return "Party members are from different realms.";
case 8: return "Not all members are present.";
case 9: return "Get info timeout.";
case 10: return "Invalid dungeon slot.";
case 11: return "You are marked as a deserter.";
case 12: return "A party member is marked as a deserter.";
case 13: return "You are on a random dungeon cooldown.";
case 14: return "A party member is on a random dungeon cooldown.";
case 16: return "No spec/role available.";
default: return "Cannot join dungeon finder.";
}
}
static const char* lfgTeleportDeniedString(uint8_t reason) {
switch (reason) {
case 0: return "You are not in a LFG group.";
case 1: return "You are not in the dungeon.";
case 2: return "You have a summon pending.";
case 3: return "You are dead.";
case 4: return "You have Deserter.";
case 5: return "You do not meet the requirements.";
default: return "Teleport to dungeon denied.";
}
}
void GameHandler::handleLfgJoinResult(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 2) return;
uint8_t result = packet.readUInt8();
uint8_t state = packet.readUInt8();
if (result == 0) {
// Success — state tells us what phase we're entering
lfgState_ = static_cast<LfgState>(state);
LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast<int>(state));
addSystemChatMessage("Dungeon Finder: Joined the queue.");
} else {
const char* msg = lfgJoinResultString(result);
std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed.");
addSystemChatMessage(errMsg);
LOG_INFO("SMSG_LFG_JOIN_RESULT: result=", static_cast<int>(result),
" state=", static_cast<int>(state));
}
}
void GameHandler::handleLfgQueueStatus(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 4 + 6 * 4 + 1 + 4) return; // dungeonId + 6 int32 + uint8 + uint32
lfgDungeonId_ = packet.readUInt32();
int32_t avgWait = static_cast<int32_t>(packet.readUInt32());
int32_t waitTime = static_cast<int32_t>(packet.readUInt32());
/*int32_t waitTimeTank =*/ static_cast<int32_t>(packet.readUInt32());
/*int32_t waitTimeHealer =*/ static_cast<int32_t>(packet.readUInt32());
/*int32_t waitTimeDps =*/ static_cast<int32_t>(packet.readUInt32());
/*uint8_t queuedByNeeded=*/ packet.readUInt8();
lfgTimeInQueueMs_ = packet.readUInt32();
lfgAvgWaitSec_ = (waitTime >= 0) ? (waitTime / 1000) : (avgWait / 1000);
lfgState_ = LfgState::Queued;
LOG_INFO("SMSG_LFG_QUEUE_STATUS: dungeonId=", lfgDungeonId_,
" avgWait=", avgWait, "ms waitTime=", waitTime, "ms");
}
void GameHandler::handleLfgProposalUpdate(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 16) return;
uint32_t dungeonId = packet.readUInt32();
uint32_t proposalId = packet.readUInt32();
uint32_t proposalState = packet.readUInt32();
/*uint32_t encounterMask =*/ packet.readUInt32();
if (remaining < 17) return;
/*bool canOverride =*/ packet.readUInt8();
lfgDungeonId_ = dungeonId;
lfgProposalId_ = proposalId;
switch (proposalState) {
case 0:
lfgState_ = LfgState::Queued;
lfgProposalId_ = 0;
addSystemChatMessage("Dungeon Finder: Group proposal failed.");
break;
case 1:
lfgState_ = LfgState::InDungeon;
lfgProposalId_ = 0;
addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon...");
break;
case 2:
lfgState_ = LfgState::Proposal;
addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline.");
break;
default:
break;
}
LOG_INFO("SMSG_LFG_PROPOSAL_UPDATE: dungeonId=", dungeonId,
" proposalId=", proposalId, " state=", proposalState);
}
void GameHandler::handleLfgRoleCheckUpdate(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 6) return;
/*uint32_t dungeonId =*/ packet.readUInt32();
uint8_t roleCheckState = packet.readUInt8();
/*bool isBeginning =*/ packet.readUInt8();
// roleCheckState: 0=default, 1=finished, 2=initializing, 3=missing_role, 4=wrong_dungeons
if (roleCheckState == 1) {
lfgState_ = LfgState::Queued;
LOG_INFO("LFG role check finished");
} else if (roleCheckState == 3) {
lfgState_ = LfgState::None;
addSystemChatMessage("Dungeon Finder: Role check failed — missing required role.");
} else if (roleCheckState == 2) {
lfgState_ = LfgState::RoleCheck;
addSystemChatMessage("Dungeon Finder: Performing role check...");
}
LOG_INFO("SMSG_LFG_ROLE_CHECK_UPDATE: roleCheckState=", static_cast<int>(roleCheckState));
}
void GameHandler::handleLfgUpdatePlayer(network::Packet& packet) {
// SMSG_LFG_UPDATE_PLAYER and SMSG_LFG_UPDATE_PARTY share the same layout.
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 1) return;
uint8_t updateType = packet.readUInt8();
// LFGUpdateType values that carry no extra payload
// 0=default, 1=leader_unk1, 4=rolecheck_aborted, 8=removed_from_queue,
// 9=proposal_failed, 10=proposal_declined, 15=leave_queue, 17=member_offline, 18=group_disband
bool hasExtra = (updateType != 0 && updateType != 1 && updateType != 15 &&
updateType != 17 && updateType != 18);
if (!hasExtra || packet.getSize() - packet.getReadPos() < 3) {
switch (updateType) {
case 8: lfgState_ = LfgState::None;
addSystemChatMessage("Dungeon Finder: Removed from queue."); break;
case 9: lfgState_ = LfgState::Queued;
addSystemChatMessage("Dungeon Finder: Proposal failed — re-queuing."); break;
case 10: lfgState_ = LfgState::Queued;
addSystemChatMessage("Dungeon Finder: A member declined the proposal."); break;
case 15: lfgState_ = LfgState::None;
addSystemChatMessage("Dungeon Finder: Left the queue."); break;
case 18: lfgState_ = LfgState::None;
addSystemChatMessage("Dungeon Finder: Your group disbanded."); break;
default: break;
}
LOG_INFO("SMSG_LFG_UPDATE_PLAYER/PARTY: updateType=", static_cast<int>(updateType));
return;
}
/*bool queued =*/ packet.readUInt8();
packet.readUInt8(); // unk1
packet.readUInt8(); // unk2
if (packet.getSize() - packet.getReadPos() >= 1) {
uint8_t count = packet.readUInt8();
for (uint8_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 4; ++i) {
uint32_t dungeonEntry = packet.readUInt32();
if (i == 0) lfgDungeonId_ = dungeonEntry;
}
}
switch (updateType) {
case 6: lfgState_ = LfgState::Queued;
addSystemChatMessage("Dungeon Finder: You have joined the queue."); break;
case 11: lfgState_ = LfgState::Proposal;
addSystemChatMessage("Dungeon Finder: A group has been found!"); break;
case 12: lfgState_ = LfgState::Queued;
addSystemChatMessage("Dungeon Finder: Added to queue."); break;
case 13: lfgState_ = LfgState::Proposal;
addSystemChatMessage("Dungeon Finder: Proposal started."); break;
case 14: lfgState_ = LfgState::InDungeon; break;
case 16: addSystemChatMessage("Dungeon Finder: Two members are ready."); break;
default: break;
}
LOG_INFO("SMSG_LFG_UPDATE_PLAYER/PARTY: updateType=", static_cast<int>(updateType));
}
void GameHandler::handleLfgPlayerReward(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 4 + 4 + 1 + 4 + 4 + 4) return;
/*uint32_t randomDungeonEntry =*/ packet.readUInt32();
/*uint32_t dungeonEntry =*/ packet.readUInt32();
packet.readUInt8(); // unk
uint32_t money = packet.readUInt32();
uint32_t xp = packet.readUInt32();
std::string rewardMsg = "Dungeon Finder reward: " + std::to_string(money) + "g " +
std::to_string(xp) + " XP";
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t rewardCount = packet.readUInt32();
for (uint32_t i = 0; i < rewardCount && packet.getSize() - packet.getReadPos() >= 9; ++i) {
uint32_t itemId = packet.readUInt32();
uint32_t itemCount = packet.readUInt32();
packet.readUInt8(); // unk
if (i == 0) {
rewardMsg += ", item #" + std::to_string(itemId);
if (itemCount > 1) rewardMsg += " x" + std::to_string(itemCount);
}
}
}
addSystemChatMessage(rewardMsg);
lfgState_ = LfgState::FinishedDungeon;
LOG_INFO("SMSG_LFG_PLAYER_REWARD: money=", money, " xp=", xp);
}
void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 7 + 4 + 4 + 4 + 4) return;
bool inProgress = packet.readUInt8() != 0;
bool myVote = packet.readUInt8() != 0;
bool myAnswer = packet.readUInt8() != 0;
uint32_t totalVotes = packet.readUInt32();
uint32_t bootVotes = packet.readUInt32();
uint32_t timeLeft = packet.readUInt32();
uint32_t votesNeeded = packet.readUInt32();
(void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded;
if (inProgress) {
addSystemChatMessage(
std::string("Dungeon Finder: Vote to kick in progress (") +
std::to_string(timeLeft) + "s remaining).");
} else if (myAnswer) {
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
} else {
addSystemChatMessage("Dungeon Finder: Vote kick failed.");
}
LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress,
" bootVotes=", bootVotes, "/", totalVotes);
}
void GameHandler::handleLfgTeleportDenied(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 1) return;
uint8_t reason = packet.readUInt8();
const char* msg = lfgTeleportDeniedString(reason);
addSystemChatMessage(std::string("Dungeon Finder: ") + msg);
LOG_INFO("SMSG_LFG_TELEPORT_DENIED: reason=", static_cast<int>(reason));
}
// ---------------------------------------------------------------------------
// LFG outgoing packets
// ---------------------------------------------------------------------------
void GameHandler::lfgJoin(uint32_t dungeonId, uint8_t roles) {
if (state != WorldState::IN_WORLD || !socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_JOIN));
pkt.writeUInt8(roles);
pkt.writeUInt8(0); // needed
pkt.writeUInt8(0); // unk
pkt.writeUInt8(1); // 1 dungeon in list
pkt.writeUInt32(dungeonId);
pkt.writeString(""); // comment
socket->send(pkt);
LOG_INFO("Sent CMSG_LFG_JOIN: dungeonId=", dungeonId, " roles=", static_cast<int>(roles));
}
void GameHandler::lfgLeave() {
if (!socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_LEAVE));
// CMSG_LFG_LEAVE has an LFG identifier block; send zeroes to leave any active queue.
pkt.writeUInt32(0); // slot
pkt.writeUInt32(0); // unk
pkt.writeUInt32(0); // dungeonId
socket->send(pkt);
lfgState_ = LfgState::None;
LOG_INFO("Sent CMSG_LFG_LEAVE");
}
void GameHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) {
if (!socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_PROPOSAL_RESULT));
pkt.writeUInt32(proposalId);
pkt.writeUInt8(accept ? 1 : 0);
socket->send(pkt);
LOG_INFO("Sent CMSG_LFG_PROPOSAL_RESULT: proposalId=", proposalId, " accept=", accept);
}
void GameHandler::lfgTeleport(bool toLfgDungeon) {
if (!socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_TELEPORT));
pkt.writeUInt8(toLfgDungeon ? 0 : 1); // 0=teleport in, 1=teleport out
socket->send(pkt);
LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon);
}
void GameHandler::loadAreaTriggerDbc() {
if (areaTriggerDbcLoaded_) return;
areaTriggerDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("AreaTrigger.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Failed to load AreaTrigger.dbc");
return;
}
areaTriggers_.reserve(dbc->getRecordCount());
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
AreaTriggerEntry at;
at.id = dbc->getUInt32(i, 0);
at.mapId = dbc->getUInt32(i, 1);
// DBC stores positions in server/wire format (X=west, Y=north) — swap to canonical
at.x = dbc->getFloat(i, 3); // canonical X (north) = DBC field 3 (Y_wire)
at.y = dbc->getFloat(i, 2); // canonical Y (west) = DBC field 2 (X_wire)
at.z = dbc->getFloat(i, 4);
at.radius = dbc->getFloat(i, 5);
at.boxLength = dbc->getFloat(i, 6);
at.boxWidth = dbc->getFloat(i, 7);
at.boxHeight = dbc->getFloat(i, 8);
at.boxYaw = dbc->getFloat(i, 9);
areaTriggers_.push_back(at);
}
LOG_WARNING("Loaded ", areaTriggers_.size(), " area triggers from AreaTrigger.dbc");
}
void GameHandler::checkAreaTriggers() {
if (state != WorldState::IN_WORLD || !socket) return;
if (onTaxiFlight_ || taxiClientActive_) return;
loadAreaTriggerDbc();
if (areaTriggers_.empty()) return;
const float px = movementInfo.x;
const float py = movementInfo.y;
const float pz = movementInfo.z;
// On first check after map transfer, just mark which triggers we're inside
// without firing them — prevents exit portal from immediately sending us back
bool suppressFirst = areaTriggerSuppressFirst_;
if (suppressFirst) {
areaTriggerSuppressFirst_ = false;
}
for (const auto& at : areaTriggers_) {
if (at.mapId != currentMapId_) continue;
bool inside = false;
if (at.radius > 0.0f) {
// Sphere trigger — use actual radius, with small floor for very tiny triggers
float effectiveRadius = std::max(at.radius, 3.0f);
float dx = px - at.x;
float dy = py - at.y;
float dz = pz - at.z;
float distSq = dx * dx + dy * dy + dz * dz;
inside = (distSq <= effectiveRadius * effectiveRadius);
} else if (at.boxLength > 0.0f || at.boxWidth > 0.0f || at.boxHeight > 0.0f) {
// Box trigger — use actual size, with small floor for tiny triggers
float boxMin = 4.0f;
float effLength = std::max(at.boxLength, boxMin);
float effWidth = std::max(at.boxWidth, boxMin);
float effHeight = std::max(at.boxHeight, boxMin);
float dx = px - at.x;
float dy = py - at.y;
float dz = pz - at.z;
// Rotate into box-local space
float cosYaw = std::cos(-at.boxYaw);
float sinYaw = std::sin(-at.boxYaw);
float localX = dx * cosYaw - dy * sinYaw;
float localY = dx * sinYaw + dy * cosYaw;
inside = (std::abs(localX) <= effLength * 0.5f &&
std::abs(localY) <= effWidth * 0.5f &&
std::abs(dz) <= effHeight * 0.5f);
}
if (inside) {
if (activeAreaTriggers_.count(at.id) == 0) {
activeAreaTriggers_.insert(at.id);
if (suppressFirst) {
// After map transfer: mark triggers we're inside of, but don't fire them.
// This prevents the exit portal from immediately sending us back.
LOG_WARNING("AreaTrigger suppressed (post-transfer): AT", at.id);
} else {
// Temporarily move player to trigger center so the server's distance
// check passes, then restore to actual position so the server doesn't
// persist the fake position on disconnect.
float savedX = movementInfo.x, savedY = movementInfo.y, savedZ = movementInfo.z;
movementInfo.x = at.x;
movementInfo.y = at.y;
movementInfo.z = at.z;
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
network::Packet pkt(wireOpcode(Opcode::CMSG_AREATRIGGER));
pkt.writeUInt32(at.id);
socket->send(pkt);
LOG_WARNING("Fired CMSG_AREATRIGGER: id=", at.id,
" at (", at.x, ", ", at.y, ", ", at.z, ")");
// Restore actual player position
movementInfo.x = savedX;
movementInfo.y = savedY;
movementInfo.z = savedZ;
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
}
} else {
// Player left the trigger — allow re-fire on re-entry
activeAreaTriggers_.erase(at.id);
}
}
}
void GameHandler::handleArenaTeamCommandResult(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) return;
uint32_t command = packet.readUInt32();
std::string name = packet.readString();
uint32_t error = packet.readUInt32();
static const char* commands[] = { "create", "invite", "leave", "remove", "disband", "leader" };
std::string cmdName = (command < 6) ? commands[command] : "unknown";
if (error == 0) {
addSystemChatMessage("Arena team " + cmdName + " successful" +
(name.empty() ? "." : ": " + name));
} else {
addSystemChatMessage("Arena team " + cmdName + " failed" +
(name.empty() ? "." : " for " + name + "."));
}
LOG_INFO("Arena team command: ", cmdName, " name=", name, " error=", error);
}
void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t teamId = packet.readUInt32();
std::string teamName = packet.readString();
LOG_INFO("Arena team query response: id=", teamId, " name=", teamName);
}
void GameHandler::handleArenaTeamInvite(network::Packet& packet) {
std::string playerName = packet.readString();
std::string teamName = packet.readString();
addSystemChatMessage(playerName + " has invited you to join " + teamName + ".");
LOG_INFO("Arena team invite from ", playerName, " to ", teamName);
}
void GameHandler::handleArenaTeamEvent(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 1) return;
uint8_t event = packet.readUInt8();
static const char* events[] = {
"joined", "left", "removed", "leader changed",
"disbanded", "created"
};
std::string eventName = (event < 6) ? events[event] : "unknown event";
// Read string params (up to 3)
uint8_t strCount = 0;
if (packet.getSize() - packet.getReadPos() >= 1) {
strCount = packet.readUInt8();
}
std::string param1, param2;
if (strCount >= 1 && packet.getSize() > packet.getReadPos()) param1 = packet.readString();
if (strCount >= 2 && packet.getSize() > packet.getReadPos()) param2 = packet.readString();
std::string msg = "Arena team " + eventName;
if (!param1.empty()) msg += ": " + param1;
if (!param2.empty()) msg += " (" + param2 + ")";
addSystemChatMessage(msg);
LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2);
}
void GameHandler::handleArenaError(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t error = packet.readUInt32();
std::string msg;
switch (error) {
case 1: msg = "The other team is not big enough."; break;
case 2: msg = "That team is full."; break;
case 3: msg = "Not enough members to start."; break;
case 4: msg = "Too many members."; break;
default: msg = "Arena error (code " + std::to_string(error) + ")"; break;
}
addSystemChatMessage(msg);
LOG_INFO("Arena error: ", error, " - ", msg);
}
void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
// Server relays MSG_MOVE_* for other players: PackedGuid + MovementInfo
uint64_t moverGuid = UpdateObjectParser::readPackedGuid(packet);
if (moverGuid == playerGuid || moverGuid == 0) {
return; // Skip our own echoes
}
// Read movement info (expansion-specific format)
// For classic: moveFlags(u32) + time(u32) + pos(4xf32) + [transport] + [pitch] + fallTime(u32) + [jump] + [splineElev]
MovementInfo info = {};
info.flags = packet.readUInt32();
// WotLK has u16 flags2, TBC has u8, Classic has none.
// Do NOT use build-number thresholds here (Turtle uses classic formats with a high build).
uint8_t flags2Size = packetParsers_ ? packetParsers_->movementFlags2Size() : 2;
if (flags2Size == 2) info.flags2 = packet.readUInt16();
else if (flags2Size == 1) info.flags2 = packet.readUInt8();
info.time = packet.readUInt32();
info.x = packet.readFloat();
info.y = packet.readFloat();
info.z = packet.readFloat();
info.orientation = packet.readFloat();
// Update entity position in entity manager
auto entity = entityManager.getEntity(moverGuid);
if (!entity) {
return;
}
// Convert server coords to canonical
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(info.x, info.y, info.z));
float canYaw = core::coords::serverToCanonicalYaw(info.orientation);
// Compute a smoothed interpolation window for this player.
// Using a raw packet delta causes jitter when timing spikes (e.g. 50ms then 300ms).
// An exponential moving average of intervals gives a stable playback speed that
// dead-reckoning in Entity::updateMovement() can bridge without a visible freeze.
uint32_t durationMs = 120;
auto itPrev = otherPlayerMoveTimeMs_.find(moverGuid);
if (itPrev != otherPlayerMoveTimeMs_.end()) {
uint32_t rawDt = info.time - itPrev->second; // wraps naturally on uint32_t
if (rawDt >= 20 && rawDt <= 2000) {
float fDt = static_cast<float>(rawDt);
// EMA: smooth the interval so single spike packets don't stutter playback.
auto& smoothed = otherPlayerSmoothedIntervalMs_[moverGuid];
if (smoothed < 1.0f) smoothed = fDt; // first observation — seed directly
smoothed = 0.7f * smoothed + 0.3f * fDt;
// Clamp to sane WoW movement rates: ~10 Hz (100ms) normal, up to 2Hz (500ms) slow
float clamped = std::max(60.0f, std::min(500.0f, smoothed));
durationMs = static_cast<uint32_t>(clamped);
}
}
otherPlayerMoveTimeMs_[moverGuid] = info.time;
entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, durationMs / 1000.0f);
// Notify renderer
if (creatureMoveCallback_) {
creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, durationMs);
}
}
void GameHandler::handleCompressedMoves(network::Packet& packet) {
// Vanilla/Classic SMSG_COMPRESSED_MOVES: raw concatenated sub-packets, NOT zlib.
// Evidence: observed 1-byte "00" packets which are not valid zlib streams.
// Each sub-packet: uint8 size (of opcode[2]+payload), uint16 opcode, uint8[] payload.
// size=0 → invalid/empty, signals end of batch.
const auto& data = packet.getData();
size_t dataLen = data.size();
// Wire opcodes for sub-packet routing
uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE);
uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT);
// Track unhandled sub-opcodes once per compressed packet (avoid log spam)
std::unordered_set<uint16_t> unhandledSeen;
size_t pos = 0;
while (pos < dataLen) {
if (pos + 1 > dataLen) break;
uint8_t subSize = data[pos];
if (subSize < 2) break; // size=0 or 1 → empty/end-of-batch sentinel
if (pos + 1 + subSize > dataLen) {
LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", pos);
break;
}
uint16_t subOpcode = static_cast<uint16_t>(data[pos + 1]) |
(static_cast<uint16_t>(data[pos + 2]) << 8);
size_t payloadLen = subSize - 2;
size_t payloadStart = pos + 3;
std::vector<uint8_t> subPayload(data.begin() + payloadStart,
data.begin() + payloadStart + payloadLen);
network::Packet subPacket(subOpcode, subPayload);
if (subOpcode == monsterMoveWire) {
handleMonsterMove(subPacket);
} else if (subOpcode == monsterMoveTransportWire) {
handleMonsterMoveTransport(subPacket);
} else {
if (unhandledSeen.insert(subOpcode).second) {
LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x",
std::hex, subOpcode, std::dec, " payloadLen=", payloadLen);
}
}
pos = payloadStart + payloadLen;
}
}
void GameHandler::handleMonsterMove(network::Packet& packet) {
MonsterMoveData data;
auto logMonsterMoveParseFailure = [&](const std::string& msg) {
static uint32_t failCount = 0;
++failCount;
if (failCount <= 10 || (failCount % 100) == 0) {
LOG_WARNING(msg, " (occurrence=", failCount, ")");
}
};
auto stripWrappedSubpacket = [&](const std::vector<uint8_t>& bytes, std::vector<uint8_t>& stripped) -> bool {
if (bytes.size() < 3) return false;
uint8_t subSize = bytes[0];
if (subSize < 2) return false;
size_t wrappedLen = static_cast<size_t>(subSize) + 1; // size byte + body
if (wrappedLen != bytes.size()) return false;
size_t payloadLen = static_cast<size_t>(subSize) - 2; // opcode(2) stripped
if (3 + payloadLen > bytes.size()) return false;
stripped.assign(bytes.begin() + 3, bytes.begin() + 3 + payloadLen);
return true;
};
// Turtle WoW (1.17+) compresses each SMSG_MONSTER_MOVE individually:
// format: uint32 decompressedSize + zlib data (zlib magic = 0x78 ??)
const auto& rawData = packet.getData();
const bool allowTurtleMoveCompression = isActiveExpansion("turtle");
bool isCompressed = allowTurtleMoveCompression &&
rawData.size() >= 6 &&
rawData[4] == 0x78 &&
(rawData[5] == 0x01 || rawData[5] == 0x9C ||
rawData[5] == 0xDA || rawData[5] == 0x5E);
if (isCompressed) {
uint32_t decompSize = static_cast<uint32_t>(rawData[0]) |
(static_cast<uint32_t>(rawData[1]) << 8) |
(static_cast<uint32_t>(rawData[2]) << 16) |
(static_cast<uint32_t>(rawData[3]) << 24);
if (decompSize == 0 || decompSize > 65536) {
LOG_WARNING("SMSG_MONSTER_MOVE: bad decompSize=", decompSize);
return;
}
std::vector<uint8_t> decompressed(decompSize);
uLongf destLen = decompSize;
int ret = uncompress(decompressed.data(), &destLen,
rawData.data() + 4, rawData.size() - 4);
if (ret != Z_OK) {
LOG_WARNING("SMSG_MONSTER_MOVE: zlib error ", ret);
return;
}
decompressed.resize(destLen);
// Dump ALL bytes for format diagnosis (remove once confirmed)
static int dumpCount = 0;
if (dumpCount < 10) {
++dumpCount;
std::string hex;
for (size_t i = 0; i < destLen; ++i) {
char buf[4]; snprintf(buf, sizeof(buf), "%02X ", decompressed[i]); hex += buf;
}
LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex);
}
std::vector<uint8_t> stripped;
bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped);
// Try unwrapped payload first (common form), then wrapped-subpacket fallback.
network::Packet decompPacket(packet.getOpcode(), decompressed);
if (!packetParsers_->parseMonsterMove(decompPacket, data)) {
if (!hasWrappedForm) {
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " +
std::to_string(destLen) + " bytes)");
return;
}
network::Packet wrappedPacket(packet.getOpcode(), stripped);
if (!packetParsers_->parseMonsterMove(wrappedPacket, data)) {
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " +
std::to_string(destLen) + " bytes, wrapped payload " +
std::to_string(stripped.size()) + " bytes)");
return;
}
LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback");
}
} else if (!packetParsers_->parseMonsterMove(packet, data)) {
// Some realms occasionally embed an extra [size|opcode] wrapper even when the
// outer packet wasn't zlib-compressed. Retry with wrapper stripped by structure.
std::vector<uint8_t> stripped;
if (stripWrappedSubpacket(rawData, stripped)) {
network::Packet wrappedPacket(packet.getOpcode(), stripped);
if (packetParsers_->parseMonsterMove(wrappedPacket, data)) {
LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback");
} else {
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE");
return;
}
} else {
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE");
return;
}
}
// Update entity position in entity manager
auto entity = entityManager.getEntity(data.guid);
if (!entity) {
return;
}
if (data.hasDest) {
// Convert destination from server to canonical coords
glm::vec3 destCanonical = core::coords::serverToCanonical(
glm::vec3(data.destX, data.destY, data.destZ));
// Calculate facing angle
float orientation = entity->getOrientation();
if (data.moveType == 4) {
// FacingAngle - server specifies exact angle
orientation = core::coords::serverToCanonicalYaw(data.facingAngle);
} else if (data.moveType == 3) {
// FacingTarget - face toward the target entity.
// Canonical orientation uses atan2(-dy, dx): the West/Y component
// must be negated because renderYaw = orientation + 90° and
// model-forward = render +X, so the sign convention flips.
auto target = entityManager.getEntity(data.facingTarget);
if (target) {
float dx = target->getX() - entity->getX();
float dy = target->getY() - entity->getY();
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
orientation = std::atan2(-dy, dx);
}
}
} else {
// Normal move - face toward destination.
float dx = destCanonical.x - entity->getX();
float dy = destCanonical.y - entity->getY();
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
orientation = std::atan2(-dy, dx);
}
}
// Anti-backward-glide: if the computed orientation is more than 90° away from
// the actual travel direction, snap to the travel direction. FacingTarget
// (moveType 3) is deliberately different from travel dir, so skip it there.
if (data.moveType != 3) {
glm::vec3 startCanonical = core::coords::serverToCanonical(
glm::vec3(data.x, data.y, data.z));
float travelDx = destCanonical.x - startCanonical.x;
float travelDy = destCanonical.y - startCanonical.y;
float travelLen = std::sqrt(travelDx * travelDx + travelDy * travelDy);
if (travelLen > 0.5f) {
float travelAngle = std::atan2(-travelDy, travelDx);
float diff = orientation - travelAngle;
// Normalise diff to [-π, π]
while (diff > static_cast<float>(M_PI)) diff -= 2.0f * static_cast<float>(M_PI);
while (diff < -static_cast<float>(M_PI)) diff += 2.0f * static_cast<float>(M_PI);
if (std::abs(diff) > static_cast<float>(M_PI) * 0.5f) {
orientation = travelAngle;
}
}
}
// Interpolate entity position alongside renderer (so targeting matches visual)
entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z,
orientation, data.duration / 1000.0f);
// Notify renderer to smoothly move the creature
if (creatureMoveCallback_) {
creatureMoveCallback_(data.guid,
destCanonical.x, destCanonical.y, destCanonical.z,
data.duration);
}
} else if (data.moveType == 1) {
// Stop at current position
glm::vec3 posCanonical = core::coords::serverToCanonical(
glm::vec3(data.x, data.y, data.z));
entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z,
entity->getOrientation());
if (creatureMoveCallback_) {
creatureMoveCallback_(data.guid,
posCanonical.x, posCanonical.y, posCanonical.z, 0);
}
}
}
void GameHandler::handleMonsterMoveTransport(network::Packet& packet) {
// Parse transport-relative creature movement (NPCs on boats/zeppelins)
// Packet: moverGuid(8) + unk(1) + transportGuid(8) + localX/Y/Z(12) + spline data
if (packet.getSize() - packet.getReadPos() < 8 + 1 + 8 + 12) return;
uint64_t moverGuid = packet.readUInt64();
/*uint8_t unk =*/ packet.readUInt8();
uint64_t transportGuid = packet.readUInt64();
// Transport-local start position (server coords: x=east/west, y=north/south, z=up)
float localX = packet.readFloat();
float localY = packet.readFloat();
float localZ = packet.readFloat();
auto entity = entityManager.getEntity(moverGuid);
if (!entity) return;
// ---- Spline data (same format as SMSG_MONSTER_MOVE, transport-local coords) ----
if (packet.getReadPos() + 5 > packet.getSize()) {
// No spline data — snap to start position
if (transportManager_) {
glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ));
setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, false, 0.0f);
glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical);
entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation());
if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_)
creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0);
}
return;
}
/*uint32_t splineId =*/ packet.readUInt32();
uint8_t moveType = packet.readUInt8();
if (moveType == 1) {
// Stop — snap to start position
if (transportManager_) {
glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ));
setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, false, 0.0f);
glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical);
entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation());
if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_)
creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0);
}
return;
}
// Facing data based on moveType
float facingAngle = entity->getOrientation();
if (moveType == 2) { // FacingSpot
if (packet.getReadPos() + 12 > packet.getSize()) return;
float sx = packet.readFloat(), sy = packet.readFloat(), sz = packet.readFloat();
facingAngle = std::atan2(-(sy - localY), sx - localX);
(void)sz;
} else if (moveType == 3) { // FacingTarget
if (packet.getReadPos() + 8 > packet.getSize()) return;
uint64_t tgtGuid = packet.readUInt64();
if (auto tgt = entityManager.getEntity(tgtGuid)) {
float dx = tgt->getX() - entity->getX();
float dy = tgt->getY() - entity->getY();
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f)
facingAngle = std::atan2(-dy, dx);
}
} else if (moveType == 4) { // FacingAngle
if (packet.getReadPos() + 4 > packet.getSize()) return;
facingAngle = core::coords::serverToCanonicalYaw(packet.readFloat());
}
if (packet.getReadPos() + 4 > packet.getSize()) return;
uint32_t splineFlags = packet.readUInt32();
if (splineFlags & 0x00400000) { // Animation
if (packet.getReadPos() + 5 > packet.getSize()) return;
packet.readUInt8(); packet.readUInt32();
}
if (packet.getReadPos() + 4 > packet.getSize()) return;
uint32_t duration = packet.readUInt32();
if (splineFlags & 0x00000800) { // Parabolic
if (packet.getReadPos() + 8 > packet.getSize()) return;
packet.readFloat(); packet.readUInt32();
}
if (packet.getReadPos() + 4 > packet.getSize()) return;
uint32_t pointCount = packet.readUInt32();
// Read destination point (transport-local server coords)
float destLocalX = localX, destLocalY = localY, destLocalZ = localZ;
bool hasDest = false;
if (pointCount > 0) {
const bool uncompressed = (splineFlags & (0x00080000 | 0x00002000)) != 0;
if (uncompressed) {
for (uint32_t i = 0; i < pointCount - 1; ++i) {
if (packet.getReadPos() + 12 > packet.getSize()) break;
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
if (packet.getReadPos() + 12 <= packet.getSize()) {
destLocalX = packet.readFloat();
destLocalY = packet.readFloat();
destLocalZ = packet.readFloat();
hasDest = true;
}
} else {
if (packet.getReadPos() + 12 <= packet.getSize()) {
destLocalX = packet.readFloat();
destLocalY = packet.readFloat();
destLocalZ = packet.readFloat();
hasDest = true;
}
}
}
if (!transportManager_) {
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x",
std::hex, moverGuid, std::dec);
return;
}
glm::vec3 startLocalCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ));
if (hasDest && duration > 0) {
glm::vec3 destLocalCanonical = core::coords::serverToCanonical(glm::vec3(destLocalX, destLocalY, destLocalZ));
glm::vec3 startWorld = transportManager_->getPlayerWorldPosition(transportGuid, startLocalCanonical);
glm::vec3 destWorld = transportManager_->getPlayerWorldPosition(transportGuid, destLocalCanonical);
// Face toward destination unless an explicit facing was given
if (moveType == 0) {
float dx = destLocalCanonical.x - startLocalCanonical.x;
float dy = destLocalCanonical.y - startLocalCanonical.y;
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f)
facingAngle = std::atan2(-dy, dx);
}
setTransportAttachment(moverGuid, entity->getType(), transportGuid, destLocalCanonical, false, 0.0f);
entity->startMoveTo(destWorld.x, destWorld.y, destWorld.z, facingAngle, duration / 1000.0f);
if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_)
creatureMoveCallback_(moverGuid, destWorld.x, destWorld.y, destWorld.z, duration);
LOG_DEBUG("SMSG_MONSTER_MOVE_TRANSPORT: mover=0x", std::hex, moverGuid,
" transport=0x", transportGuid, std::dec,
" dur=", duration, "ms dest=(", destWorld.x, ",", destWorld.y, ",", destWorld.z, ")");
} else {
glm::vec3 startWorld = transportManager_->getPlayerWorldPosition(transportGuid, startLocalCanonical);
setTransportAttachment(moverGuid, entity->getType(), transportGuid, startLocalCanonical, false, 0.0f);
entity->setPosition(startWorld.x, startWorld.y, startWorld.z, facingAngle);
if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_)
creatureMoveCallback_(moverGuid, startWorld.x, startWorld.y, startWorld.z, 0);
}
}
void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
AttackerStateUpdateData data;
if (!AttackerStateUpdateParser::parse(packet, data)) return;
bool isPlayerAttacker = (data.attackerGuid == playerGuid);
bool isPlayerTarget = (data.targetGuid == playerGuid);
if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat
if (isPlayerAttacker && meleeSwingCallback_) {
meleeSwingCallback_();
}
if (!isPlayerAttacker && npcSwingCallback_) {
npcSwingCallback_(data.attackerGuid);
}
if (isPlayerTarget && data.attackerGuid != 0) {
hostileAttackers_.insert(data.attackerGuid);
autoTargetAttacker(data.attackerGuid);
}
// Play combat sounds via CombatSoundManager + character vocalizations
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* csm = renderer->getCombatSoundManager()) {
auto weaponSize = audio::CombatSoundManager::WeaponSize::MEDIUM;
if (data.isMiss()) {
csm->playWeaponMiss(false);
} else if (data.victimState == 1 || data.victimState == 2) {
// Dodge/parry — swing whoosh but no impact
csm->playWeaponSwing(weaponSize, false);
} else {
// Hit — swing + flesh impact
csm->playWeaponSwing(weaponSize, data.isCrit());
csm->playImpact(weaponSize, audio::CombatSoundManager::ImpactType::FLESH, data.isCrit());
}
}
// Character vocalizations
if (auto* asm_ = renderer->getActivitySoundManager()) {
if (isPlayerAttacker && !data.isMiss() && data.victimState != 1 && data.victimState != 2) {
asm_->playAttackGrunt();
}
if (isPlayerTarget && !data.isMiss() && data.victimState != 1 && data.victimState != 2) {
asm_->playWound(data.isCrit());
}
}
}
if (data.isMiss()) {
addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker);
} else if (data.victimState == 1) {
addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker);
} else if (data.victimState == 2) {
addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker);
} else {
auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE;
addCombatText(type, data.totalDamage, 0, isPlayerAttacker);
}
(void)isPlayerTarget; // Used for future incoming damage display
}
void GameHandler::handleSpellDamageLog(network::Packet& packet) {
SpellDamageLogData data;
if (!SpellDamageLogParser::parse(packet, data)) return;
bool isPlayerSource = (data.attackerGuid == playerGuid);
bool isPlayerTarget = (data.targetGuid == playerGuid);
if (!isPlayerSource && !isPlayerTarget) return; // Not our combat
if (isPlayerTarget && data.attackerGuid != 0) {
hostileAttackers_.insert(data.attackerGuid);
autoTargetAttacker(data.attackerGuid);
}
auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE;
addCombatText(type, static_cast<int32_t>(data.damage), data.spellId, isPlayerSource);
}
void GameHandler::handleSpellHealLog(network::Packet& packet) {
SpellHealLogData data;
if (!SpellHealLogParser::parse(packet, data)) return;
bool isPlayerSource = (data.casterGuid == playerGuid);
bool isPlayerTarget = (data.targetGuid == playerGuid);
if (!isPlayerSource && !isPlayerTarget) return; // Not our combat
auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL;
addCombatText(type, static_cast<int32_t>(data.heal), data.spellId, isPlayerSource);
}
// ============================================================
// Phase 3: Spells
// ============================================================
void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
// Attack (6603) routes to auto-attack instead of cast
if (spellId == 6603) {
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
if (target != 0) {
if (autoAttacking) {
stopAutoAttack();
} else {
startAutoAttack(target);
}
}
return;
}
if (state != WorldState::IN_WORLD || !socket) return;
// Casting any spell while mounted → dismount instead
if (isMounted()) {
dismount();
return;
}
if (casting) return; // Already casting
// Hearthstone: cast spell directly (server checks item in inventory)
// Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which
// depends on slot indices matching between client and server.
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
// Self-targeted spells like hearthstone should not send a target
if (spellId == 8690) target = 0;
// Warrior Charge (ranks 1-3): client-side range check + charge callback
// Must face target and validate range BEFORE sending packet to server
if (spellId == 100 || spellId == 6178 || spellId == 11578) {
if (target == 0) {
addSystemChatMessage("You have no target.");
return;
}
auto entity = entityManager.getEntity(target);
if (!entity) {
addSystemChatMessage("You have no target.");
return;
}
float tx = entity->getX(), ty = entity->getY(), tz = entity->getZ();
float dx = tx - movementInfo.x;
float dy = ty - movementInfo.y;
float dz = tz - movementInfo.z;
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist < 8.0f) {
addSystemChatMessage("Target is too close.");
return;
}
if (dist > 25.0f) {
addSystemChatMessage("Out of range.");
return;
}
// Face the target before sending the cast packet to avoid "not in front" rejection
float yaw = std::atan2(dy, dx);
movementInfo.orientation = yaw;
sendMovement(Opcode::MSG_MOVE_SET_FACING);
if (chargeCallback_) {
chargeCallback_(target, tx, ty, tz);
}
}
// Instant melee abilities: client-side range + facing check to avoid server "not in front" errors
{
uint32_t sid = spellId;
bool isMeleeAbility =
sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike
sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 ||
sid == 25286 || sid == 29707 || sid == 30324 ||
sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend
sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 ||
sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge
sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 ||
sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave
sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 ||
sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike
sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 ||
sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam
sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488;
if (isMeleeAbility && target != 0) {
auto entity = entityManager.getEntity(target);
if (entity) {
float dx = entity->getX() - movementInfo.x;
float dy = entity->getY() - movementInfo.y;
float dz = entity->getZ() - movementInfo.z;
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist > 8.0f) {
addSystemChatMessage("Out of range.");
return;
}
// Face the target to prevent "not in front" rejection
float yaw = std::atan2(dy, dx);
movementInfo.orientation = yaw;
sendMovement(Opcode::MSG_MOVE_SET_FACING);
}
}
}
auto packet = packetParsers_
? packetParsers_->buildCastSpell(spellId, target, ++castCount)
: CastSpellPacket::build(spellId, target, ++castCount);
socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
}
void GameHandler::cancelCast() {
if (!casting) return;
// GameObject interaction cast is client-side timing only.
if (pendingGameObjectInteractGuid_ == 0 &&
state == WorldState::IN_WORLD && socket &&
currentCastSpellId != 0) {
auto packet = CancelCastPacket::build(currentCastSpellId);
socket->send(packet);
}
pendingGameObjectInteractGuid_ = 0;
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
void GameHandler::cancelAura(uint32_t spellId) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = CancelAuraPacket::build(spellId);
socket->send(packet);
}
void GameHandler::handlePetSpells(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) {
// Empty packet = pet dismissed/died
petGuid_ = 0;
LOG_INFO("SMSG_PET_SPELLS: pet cleared (empty packet)");
return;
}
petGuid_ = packet.readUInt64();
LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec);
}
void GameHandler::dismissPet() {
if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return;
auto packet = PetActionPacket::build(petGuid_, 0x07000000);
socket->send(packet);
}
void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) {
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
actionBar[slot].type = type;
actionBar[slot].id = id;
saveCharacterConfig();
}
float GameHandler::getSpellCooldown(uint32_t spellId) const {
auto it = spellCooldowns.find(spellId);
return (it != spellCooldowns.end()) ? it->second : 0.0f;
}
void GameHandler::handleInitialSpells(network::Packet& packet) {
InitialSpellsData data;
if (!InitialSpellsParser::parse(packet, data)) return;
knownSpells = {data.spellIds.begin(), data.spellIds.end()};
// Debug: check if specific spells are in initial spells
bool has527 = knownSpells.count(527u);
bool has988 = knownSpells.count(988u);
bool has1180 = knownSpells.count(1180u);
LOG_INFO("Initial spells include: 527=", has527, " 988=", has988, " 1180=", has1180);
// Ensure Attack (6603) and Hearthstone (8690) are always present
knownSpells.insert(6603u);
knownSpells.insert(8690u);
// Set initial cooldowns
for (const auto& cd : data.cooldowns) {
if (cd.cooldownMs > 0) {
spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f;
}
}
// Load saved action bar or use defaults (Attack slot 1, Hearthstone slot 12)
actionBar[0].type = ActionBarSlot::SPELL;
actionBar[0].id = 6603; // Attack
actionBar[11].type = ActionBarSlot::SPELL;
actionBar[11].id = 8690; // Hearthstone
loadCharacterConfig();
LOG_INFO("Learned ", knownSpells.size(), " spells");
}
void GameHandler::handleCastFailed(network::Packet& packet) {
CastFailedData data;
bool ok = packetParsers_ ? packetParsers_->parseCastFailed(packet, data)
: CastFailedParser::parse(packet, data);
if (!ok) return;
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Add system message about failed cast with readable reason
int powerType = -1;
auto playerEntity = entityManager.getEntity(playerGuid);
if (auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity)) {
powerType = playerUnit->getPowerType();
}
const char* reason = getSpellCastResultString(data.result, powerType);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
if (reason) {
msg.message = reason;
} else {
msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")";
}
addLocalChatMessage(msg);
}
void GameHandler::handleSpellStart(network::Packet& packet) {
SpellStartData data;
if (!SpellStartParser::parse(packet, data)) return;
// If this is the player's own cast, start cast bar
if (data.casterUnit == playerGuid && data.castTime > 0) {
casting = true;
currentCastSpellId = data.spellId;
castTimeTotal = data.castTime / 1000.0f;
castTimeRemaining = castTimeTotal;
// Play precast (channeling) sound
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
ssm->playPrecast(audio::SpellSoundManager::MagicSchool::ARCANE, audio::SpellSoundManager::SpellPower::MEDIUM);
}
}
}
}
void GameHandler::handleSpellGo(network::Packet& packet) {
SpellGoData data;
if (!SpellGoParser::parse(packet, data)) return;
// Cast completed
if (data.casterUnit == playerGuid) {
// Play cast-complete sound before clearing state
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
ssm->playCast(audio::SpellSoundManager::MagicSchool::ARCANE);
}
}
// Instant melee abilities → trigger attack animation
uint32_t sid = data.spellId;
bool isMeleeAbility =
sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike ranks
sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 ||
sid == 25286 || sid == 29707 || sid == 30324 ||
sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend ranks
sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 ||
sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge ranks
sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 ||
sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave ranks
sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 ||
sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike ranks
sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 ||
sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam ranks
sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488;
if (isMeleeAbility && meleeSwingCallback_) {
meleeSwingCallback_();
}
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
}
void GameHandler::handleSpellCooldown(network::Packet& packet) {
SpellCooldownData data;
if (!SpellCooldownParser::parse(packet, data)) return;
for (const auto& [spellId, cooldownMs] : data.cooldowns) {
float seconds = cooldownMs / 1000.0f;
spellCooldowns[spellId] = seconds;
// Update action bar cooldowns
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownTotal = seconds;
slot.cooldownRemaining = seconds;
}
}
}
}
void GameHandler::handleCooldownEvent(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
// Cooldown finished
spellCooldowns.erase(spellId);
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownRemaining = 0.0f;
}
}
}
void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
AuraUpdateData data;
if (!AuraUpdateParser::parse(packet, data, isAll)) return;
// Determine which aura list to update
std::vector<AuraSlot>* auraList = nullptr;
if (data.guid == playerGuid) {
auraList = &playerAuras;
} else if (data.guid == targetGuid) {
auraList = &targetAuras;
}
if (auraList) {
if (isAll) {
auraList->clear();
}
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (auto [slot, aura] : data.updates) {
// Stamp client timestamp so the UI can count down duration locally
if (aura.durationMs >= 0) {
aura.receivedAtMs = nowMs;
}
// Ensure vector is large enough
while (auraList->size() <= slot) {
auraList->push_back(AuraSlot{});
}
(*auraList)[slot] = aura;
}
// If player is mounted but we haven't identified the mount aura yet,
// check newly added auras (aura update may arrive after mountDisplayId)
if (data.guid == playerGuid && currentMountDisplayId_ != 0 && mountAuraSpellId_ == 0) {
for (const auto& [slot, aura] : data.updates) {
if (!aura.isEmpty() && aura.maxDurationMs < 0 && aura.casterGuid == playerGuid) {
mountAuraSpellId_ = aura.spellId;
LOG_INFO("Mount aura detected from aura update: spellId=", aura.spellId);
}
}
}
}
}
void GameHandler::handleLearnedSpell(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
knownSpells.insert(spellId);
LOG_INFO("Learned spell: ", spellId);
// Check if this spell corresponds to a talent rank
for (const auto& [talentId, talent] : talentCache_) {
for (int rank = 0; rank < 5; ++rank) {
if (talent.rankSpells[rank] == spellId) {
// Found the talent! Update the rank for the active spec
uint8_t newRank = rank + 1; // rank is 0-indexed in array, but stored as 1-indexed
learnedTalents_[activeTalentSpec_][talentId] = newRank;
LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank,
" (spell ", spellId, ") in spec ", (int)activeTalentSpec_);
return;
}
}
}
// Show chat message for non-talent spells
const std::string& name = getSpellName(spellId);
if (!name.empty()) {
addSystemChatMessage("You have learned a new spell: " + name + ".");
} else {
addSystemChatMessage("You have learned a new spell.");
}
}
void GameHandler::handleRemovedSpell(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
knownSpells.erase(spellId);
LOG_INFO("Removed spell: ", spellId);
}
void GameHandler::handleSupercededSpell(network::Packet& packet) {
// Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2)
uint32_t oldSpellId = packet.readUInt32();
uint32_t newSpellId = packet.readUInt32();
// Remove old spell
knownSpells.erase(oldSpellId);
// Add new spell
knownSpells.insert(newSpellId);
LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId);
const std::string& newName = getSpellName(newSpellId);
if (!newName.empty()) {
addSystemChatMessage("Upgraded to " + newName);
}
}
void GameHandler::handleUnlearnSpells(network::Packet& packet) {
// Sent when unlearning multiple spells (e.g., spec change, respec)
uint32_t spellCount = packet.readUInt32();
LOG_INFO("Unlearning ", spellCount, " spells");
for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) {
uint32_t spellId = packet.readUInt32();
knownSpells.erase(spellId);
LOG_INFO(" Unlearned spell: ", spellId);
}
if (spellCount > 0) {
addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells");
}
}
// ============================================================
// Talents
// ============================================================
void GameHandler::handleTalentsInfo(network::Packet& packet) {
TalentsInfoData data;
if (!TalentsInfoParser::parse(packet, data)) return;
// Ensure talent DBCs are loaded
loadTalentDbc();
// Validate spec number
if (data.talentSpec > 1) {
LOG_WARNING("Invalid talent spec: ", (int)data.talentSpec);
return;
}
// Store talents for this spec
unspentTalentPoints_[data.talentSpec] = data.unspentPoints;
// Clear and rebuild learned talents map for this spec
// Note: If a talent appears in the packet, it's learned (ranks are 0-indexed)
learnedTalents_[data.talentSpec].clear();
for (const auto& talent : data.talents) {
learnedTalents_[data.talentSpec][talent.talentId] = talent.currentRank;
}
LOG_INFO("Talents loaded: spec=", (int)data.talentSpec,
" unspent=", (int)unspentTalentPoints_[data.talentSpec],
" learned=", learnedTalents_[data.talentSpec].size());
// If this is the first spec received, set it as active
static bool firstSpecReceived = false;
if (!firstSpecReceived) {
firstSpecReceived = true;
activeTalentSpec_ = data.talentSpec;
// Show message to player about active spec
if (unspentTalentPoints_[data.talentSpec] > 0) {
std::string msg = "You have " + std::to_string(unspentTalentPoints_[data.talentSpec]) +
" unspent talent point";
if (unspentTalentPoints_[data.talentSpec] > 1) msg += "s";
msg += " in spec " + std::to_string(data.talentSpec + 1);
addSystemChatMessage(msg);
}
}
}
void GameHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("learnTalent: Not in world or no socket connection");
return;
}
LOG_INFO("Requesting to learn talent: id=", talentId, " rank=", requestedRank);
auto packet = LearnTalentPacket::build(talentId, requestedRank);
socket->send(packet);
}
void GameHandler::switchTalentSpec(uint8_t newSpec) {
if (newSpec > 1) {
LOG_WARNING("Invalid talent spec: ", (int)newSpec);
return;
}
if (newSpec == activeTalentSpec_) {
LOG_INFO("Already on spec ", (int)newSpec);
return;
}
// For now, just switch locally. In a real implementation, we'd send
// MSG_TALENT_WIPE_CONFIRM to the server to trigger a spec switch.
// The server would respond with new SMSG_TALENTS_INFO for the new spec.
activeTalentSpec_ = newSpec;
LOG_INFO("Switched to talent spec ", (int)newSpec,
" (unspent=", (int)unspentTalentPoints_[newSpec],
", learned=", learnedTalents_[newSpec].size(), ")");
std::string msg = "Switched to spec " + std::to_string(newSpec + 1);
if (unspentTalentPoints_[newSpec] > 0) {
msg += " (" + std::to_string(unspentTalentPoints_[newSpec]) + " unspent point";
if (unspentTalentPoints_[newSpec] > 1) msg += "s";
msg += ")";
}
addSystemChatMessage(msg);
}
// ============================================================
// Phase 4: Group/Party
// ============================================================
void GameHandler::inviteToGroup(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GroupInvitePacket::build(playerName);
socket->send(packet);
LOG_INFO("Inviting ", playerName, " to group");
}
void GameHandler::acceptGroupInvite() {
if (state != WorldState::IN_WORLD || !socket) return;
pendingGroupInvite = false;
auto packet = GroupAcceptPacket::build();
socket->send(packet);
LOG_INFO("Accepted group invite");
}
void GameHandler::declineGroupInvite() {
if (state != WorldState::IN_WORLD || !socket) return;
pendingGroupInvite = false;
auto packet = GroupDeclinePacket::build();
socket->send(packet);
LOG_INFO("Declined group invite");
}
void GameHandler::leaveGroup() {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GroupDisbandPacket::build();
socket->send(packet);
partyData = GroupListData{};
LOG_INFO("Left group");
}
void GameHandler::handleGroupInvite(network::Packet& packet) {
GroupInviteResponseData data;
if (!GroupInviteResponseParser::parse(packet, data)) return;
pendingGroupInvite = true;
pendingInviterName = data.inviterName;
LOG_INFO("Group invite from: ", data.inviterName);
if (!data.inviterName.empty()) {
addSystemChatMessage(data.inviterName + " has invited you to a group.");
}
}
void GameHandler::handleGroupDecline(network::Packet& packet) {
GroupDeclineData data;
if (!GroupDeclineResponseParser::parse(packet, data)) return;
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = data.playerName + " has declined your group invitation.";
addLocalChatMessage(msg);
}
void GameHandler::handleGroupList(network::Packet& packet) {
if (!GroupListParser::parse(packet, partyData)) return;
if (partyData.isEmpty()) {
LOG_INFO("No longer in a group");
addSystemChatMessage("You are no longer in a group.");
} else {
LOG_INFO("In group with ", partyData.memberCount, " members");
addSystemChatMessage("You are now in a group with " + std::to_string(partyData.memberCount) + " members.");
}
}
void GameHandler::handleGroupUninvite(network::Packet& packet) {
(void)packet;
partyData = GroupListData{};
LOG_INFO("Removed from group");
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "You have been removed from the group.";
addLocalChatMessage(msg);
}
void GameHandler::handlePartyCommandResult(network::Packet& packet) {
PartyCommandResultData data;
if (!PartyCommandResultParser::parse(packet, data)) return;
if (data.result != PartyResult::OK) {
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "Party command failed (error " + std::to_string(static_cast<uint32_t>(data.result)) + ")";
if (!data.name.empty()) msg.message += " for " + data.name;
addLocalChatMessage(msg);
}
}
void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) {
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
// Classic/TBC use uint16 for health fields and simpler aura format;
// WotLK uses uint32 health and uint32+uint8 per aura.
const bool isWotLK = isActiveExpansion("wotlk");
// SMSG_PARTY_MEMBER_STATS_FULL has a leading padding byte
if (isFull) {
if (remaining() < 1) return;
packet.readUInt8();
}
uint64_t memberGuid = UpdateObjectParser::readPackedGuid(packet);
if (remaining() < 4) return;
uint32_t updateFlags = packet.readUInt32();
// Find matching group member
game::GroupMember* member = nullptr;
for (auto& m : partyData.members) {
if (m.guid == memberGuid) {
member = &m;
break;
}
}
if (!member) {
packet.setReadPos(packet.getSize());
return;
}
// Parse each flag field in order
if (updateFlags & 0x0001) { // STATUS
if (remaining() >= 2)
member->onlineStatus = packet.readUInt16();
}
if (updateFlags & 0x0002) { // CUR_HP
if (isWotLK) {
if (remaining() >= 4)
member->curHealth = packet.readUInt32();
} else {
if (remaining() >= 2)
member->curHealth = packet.readUInt16();
}
}
if (updateFlags & 0x0004) { // MAX_HP
if (isWotLK) {
if (remaining() >= 4)
member->maxHealth = packet.readUInt32();
} else {
if (remaining() >= 2)
member->maxHealth = packet.readUInt16();
}
}
if (updateFlags & 0x0008) { // POWER_TYPE
if (remaining() >= 1)
member->powerType = packet.readUInt8();
}
if (updateFlags & 0x0010) { // CUR_POWER
if (remaining() >= 2)
member->curPower = packet.readUInt16();
}
if (updateFlags & 0x0020) { // MAX_POWER
if (remaining() >= 2)
member->maxPower = packet.readUInt16();
}
if (updateFlags & 0x0040) { // LEVEL
if (remaining() >= 2)
member->level = packet.readUInt16();
}
if (updateFlags & 0x0080) { // ZONE
if (remaining() >= 2)
member->zoneId = packet.readUInt16();
}
if (updateFlags & 0x0100) { // POSITION
if (remaining() >= 4) {
member->posX = static_cast<int16_t>(packet.readUInt16());
member->posY = static_cast<int16_t>(packet.readUInt16());
}
}
if (updateFlags & 0x0200) { // AURAS
if (remaining() >= 8) {
uint64_t auraMask = packet.readUInt64();
for (int i = 0; i < 64; ++i) {
if (auraMask & (uint64_t(1) << i)) {
if (isWotLK) {
// WotLK: uint32 spellId + uint8 auraFlags
if (remaining() < 5) break;
packet.readUInt32();
packet.readUInt8();
} else {
// Classic/TBC: uint16 spellId only
if (remaining() < 2) break;
packet.readUInt16();
}
}
}
}
}
if (updateFlags & 0x0400) { // PET_GUID
if (remaining() >= 8)
packet.readUInt64();
}
if (updateFlags & 0x0800) { // PET_NAME
if (remaining() > 0)
packet.readString();
}
if (updateFlags & 0x1000) { // PET_MODEL_ID
if (remaining() >= 2)
packet.readUInt16();
}
if (updateFlags & 0x2000) { // PET_CUR_HP
if (isWotLK) {
if (remaining() >= 4)
packet.readUInt32();
} else {
if (remaining() >= 2)
packet.readUInt16();
}
}
if (updateFlags & 0x4000) { // PET_MAX_HP
if (isWotLK) {
if (remaining() >= 4)
packet.readUInt32();
} else {
if (remaining() >= 2)
packet.readUInt16();
}
}
if (updateFlags & 0x8000) { // PET_POWER_TYPE
if (remaining() >= 1)
packet.readUInt8();
}
if (updateFlags & 0x10000) { // PET_CUR_POWER
if (remaining() >= 2)
packet.readUInt16();
}
if (updateFlags & 0x20000) { // PET_MAX_POWER
if (remaining() >= 2)
packet.readUInt16();
}
if (updateFlags & 0x40000) { // PET_AURAS
if (remaining() >= 8) {
uint64_t petAuraMask = packet.readUInt64();
for (int i = 0; i < 64; ++i) {
if (petAuraMask & (uint64_t(1) << i)) {
if (isWotLK) {
if (remaining() < 5) break;
packet.readUInt32();
packet.readUInt8();
} else {
if (remaining() < 2) break;
packet.readUInt16();
}
}
}
}
}
if (isWotLK && (updateFlags & 0x80000)) { // VEHICLE_SEAT (WotLK only)
if (remaining() >= 4)
packet.readUInt32();
}
member->hasPartyStats = true;
LOG_DEBUG("Party member stats for ", member->name,
": HP=", member->curHealth, "/", member->maxHealth,
" Level=", member->level);
}
// ============================================================
// Guild Handlers
// ============================================================
void GameHandler::kickGuildMember(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildRemovePacket::build(playerName);
socket->send(packet);
LOG_INFO("Kicking guild member: ", playerName);
}
void GameHandler::disbandGuild() {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildDisbandPacket::build();
socket->send(packet);
LOG_INFO("Disbanding guild");
}
void GameHandler::setGuildLeader(const std::string& name) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildLeaderPacket::build(name);
socket->send(packet);
LOG_INFO("Setting guild leader: ", name);
}
void GameHandler::setGuildPublicNote(const std::string& name, const std::string& note) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildSetPublicNotePacket::build(name, note);
socket->send(packet);
LOG_INFO("Setting public note for ", name, ": ", note);
}
void GameHandler::setGuildOfficerNote(const std::string& name, const std::string& note) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildSetOfficerNotePacket::build(name, note);
socket->send(packet);
LOG_INFO("Setting officer note for ", name, ": ", note);
}
void GameHandler::acceptGuildInvite() {
if (state != WorldState::IN_WORLD || !socket) return;
pendingGuildInvite_ = false;
auto packet = GuildAcceptPacket::build();
socket->send(packet);
LOG_INFO("Accepted guild invite");
}
void GameHandler::declineGuildInvite() {
if (state != WorldState::IN_WORLD || !socket) return;
pendingGuildInvite_ = false;
auto packet = GuildDeclineInvitationPacket::build();
socket->send(packet);
LOG_INFO("Declined guild invite");
}
void GameHandler::queryGuildInfo(uint32_t guildId) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildQueryPacket::build(guildId);
socket->send(packet);
LOG_INFO("Querying guild info: guildId=", guildId);
}
void GameHandler::createGuild(const std::string& guildName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildCreatePacket::build(guildName);
socket->send(packet);
LOG_INFO("Creating guild: ", guildName);
}
void GameHandler::addGuildRank(const std::string& rankName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildAddRankPacket::build(rankName);
socket->send(packet);
LOG_INFO("Adding guild rank: ", rankName);
// Refresh roster to update rank list
requestGuildRoster();
}
void GameHandler::deleteGuildRank() {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildDelRankPacket::build();
socket->send(packet);
LOG_INFO("Deleting last guild rank");
// Refresh roster to update rank list
requestGuildRoster();
}
void GameHandler::requestPetitionShowlist(uint64_t npcGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = PetitionShowlistPacket::build(npcGuid);
socket->send(packet);
}
void GameHandler::buyPetition(uint64_t npcGuid, const std::string& guildName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = PetitionBuyPacket::build(npcGuid, guildName);
socket->send(packet);
LOG_INFO("Buying guild petition: ", guildName);
}
void GameHandler::handlePetitionShowlist(network::Packet& packet) {
PetitionShowlistData data;
if (!PetitionShowlistParser::parse(packet, data)) return;
petitionNpcGuid_ = data.npcGuid;
petitionCost_ = data.cost;
showPetitionDialog_ = true;
LOG_INFO("Petition showlist: cost=", data.cost);
}
void GameHandler::handleTurnInPetitionResults(network::Packet& packet) {
uint32_t result = 0;
if (!TurnInPetitionResultsParser::parse(packet, result)) return;
switch (result) {
case 0: addSystemChatMessage("Guild created successfully!"); break;
case 1: addSystemChatMessage("Guild creation failed: already in a guild."); break;
case 2: addSystemChatMessage("Guild creation failed: not enough signatures."); break;
case 3: addSystemChatMessage("Guild creation failed: name already taken."); break;
default: addSystemChatMessage("Guild creation failed (error " + std::to_string(result) + ")."); break;
}
}
void GameHandler::handleGuildInfo(network::Packet& packet) {
GuildInfoData data;
if (!GuildInfoParser::parse(packet, data)) return;
guildInfoData_ = data;
addSystemChatMessage("Guild: " + data.guildName + " (" +
std::to_string(data.numMembers) + " members, " +
std::to_string(data.numAccounts) + " accounts)");
}
void GameHandler::handleGuildRoster(network::Packet& packet) {
GuildRosterData data;
if (!packetParsers_->parseGuildRoster(packet, data)) return;
guildRoster_ = std::move(data);
hasGuildRoster_ = true;
LOG_INFO("Guild roster received: ", guildRoster_.members.size(), " members");
}
void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
GuildQueryResponseData data;
if (!packetParsers_->parseGuildQueryResponse(packet, data)) return;
guildName_ = data.guildName;
guildQueryData_ = data;
guildRankNames_.clear();
for (uint32_t i = 0; i < 10; ++i) {
guildRankNames_.push_back(data.rankNames[i]);
}
LOG_INFO("Guild name set to: ", guildName_);
addSystemChatMessage("Guild: <" + guildName_ + ">");
}
void GameHandler::handleGuildEvent(network::Packet& packet) {
GuildEventData data;
if (!GuildEventParser::parse(packet, data)) return;
std::string msg;
switch (data.eventType) {
case GuildEvent::PROMOTION:
if (data.numStrings >= 3)
msg = data.strings[0] + " has promoted " + data.strings[1] + " to " + data.strings[2] + ".";
break;
case GuildEvent::DEMOTION:
if (data.numStrings >= 3)
msg = data.strings[0] + " has demoted " + data.strings[1] + " to " + data.strings[2] + ".";
break;
case GuildEvent::MOTD:
if (data.numStrings >= 1)
msg = "Guild MOTD: " + data.strings[0];
break;
case GuildEvent::JOINED:
if (data.numStrings >= 1)
msg = data.strings[0] + " has joined the guild.";
break;
case GuildEvent::LEFT:
if (data.numStrings >= 1)
msg = data.strings[0] + " has left the guild.";
break;
case GuildEvent::REMOVED:
if (data.numStrings >= 2)
msg = data.strings[1] + " has been kicked from the guild by " + data.strings[0] + ".";
break;
case GuildEvent::LEADER_IS:
if (data.numStrings >= 1)
msg = data.strings[0] + " is the guild leader.";
break;
case GuildEvent::LEADER_CHANGED:
if (data.numStrings >= 2)
msg = data.strings[0] + " has made " + data.strings[1] + " the new guild leader.";
break;
case GuildEvent::DISBANDED:
msg = "Guild has been disbanded.";
guildName_.clear();
guildRankNames_.clear();
guildRoster_ = GuildRosterData{};
hasGuildRoster_ = false;
break;
case GuildEvent::SIGNED_ON:
if (data.numStrings >= 1)
msg = "[Guild] " + data.strings[0] + " has come online.";
break;
case GuildEvent::SIGNED_OFF:
if (data.numStrings >= 1)
msg = "[Guild] " + data.strings[0] + " has gone offline.";
break;
default:
msg = "Guild event " + std::to_string(data.eventType);
break;
}
if (!msg.empty()) {
MessageChatData chatMsg;
chatMsg.type = ChatType::GUILD;
chatMsg.language = ChatLanguage::UNIVERSAL;
chatMsg.message = msg;
addLocalChatMessage(chatMsg);
}
// Auto-refresh roster after membership/rank changes
switch (data.eventType) {
case GuildEvent::PROMOTION:
case GuildEvent::DEMOTION:
case GuildEvent::JOINED:
case GuildEvent::LEFT:
case GuildEvent::REMOVED:
case GuildEvent::LEADER_CHANGED:
if (hasGuildRoster_) requestGuildRoster();
break;
default:
break;
}
}
void GameHandler::handleGuildInvite(network::Packet& packet) {
GuildInviteResponseData data;
if (!GuildInviteResponseParser::parse(packet, data)) return;
pendingGuildInvite_ = true;
pendingGuildInviterName_ = data.inviterName;
pendingGuildInviteGuildName_ = data.guildName;
LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName);
addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + ".");
}
void GameHandler::handleGuildCommandResult(network::Packet& packet) {
GuildCommandResultData data;
if (!GuildCommandResultParser::parse(packet, data)) return;
if (data.errorCode != 0) {
std::string msg = "Guild command failed";
if (!data.name.empty()) msg += " for " + data.name;
msg += " (error " + std::to_string(data.errorCode) + ")";
addSystemChatMessage(msg);
}
}
// ============================================================
// Phase 5: Loot, Gossip, Vendor
// ============================================================
void GameHandler::lootTarget(uint64_t guid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = LootPacket::build(guid);
socket->send(packet);
}
void GameHandler::lootItem(uint8_t slotIndex) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = AutostoreLootItemPacket::build(slotIndex);
socket->send(packet);
}
void GameHandler::closeLoot() {
if (!lootWindowOpen) return;
lootWindowOpen = false;
if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) {
clearTarget();
}
if (state == WorldState::IN_WORLD && socket) {
auto packet = LootReleasePacket::build(currentLoot.lootGuid);
socket->send(packet);
}
currentLoot = LootResponseData{};
}
void GameHandler::interactWithNpc(uint64_t guid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GossipHelloPacket::build(guid);
socket->send(packet);
}
void GameHandler::interactWithGameObject(uint64_t guid) {
if (guid == 0) return;
if (state != WorldState::IN_WORLD || !socket) return;
// Do not overlap an actual spell cast.
if (casting && currentCastSpellId != 0) return;
// Always clear melee intent before GO interactions.
stopAutoAttack();
// Interact immediately; server drives any real cast/channel feedback.
pendingGameObjectInteractGuid_ = 0;
performGameObjectInteractionNow(guid);
}
void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
if (guid == 0) return;
if (state != WorldState::IN_WORLD || !socket) return;
bool turtleMode = isActiveExpansion("turtle");
// Rate-limit to prevent spamming the server
static uint64_t lastInteractGuid = 0;
static std::chrono::steady_clock::time_point lastInteractTime{};
auto now = std::chrono::steady_clock::now();
// Keep duplicate suppression, but allow quick retry clicks.
int64_t minRepeatMs = turtleMode ? 150 : 150;
if (guid == lastInteractGuid &&
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastInteractTime).count() < minRepeatMs) {
return;
}
lastInteractGuid = guid;
lastInteractTime = now;
// Ensure GO interaction isn't blocked by stale or active melee state.
stopAutoAttack();
auto entity = entityManager.getEntity(guid);
uint32_t goEntry = 0;
uint32_t goType = 0;
std::string goName;
if (entity) {
if (entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
goEntry = go->getEntry();
goName = go->getName();
if (auto* info = getCachedGameObjectInfo(goEntry)) goType = info->type;
if (goType == 5 && !goName.empty()) {
std::string lower = goName;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lower.rfind("doodad_", 0) != 0) {
addSystemChatMessage(goName);
}
}
}
// Face object and send heartbeat before use so strict servers don't require
// a nudge movement to accept interaction.
float dx = entity->getX() - movementInfo.x;
float dy = entity->getY() - movementInfo.y;
float dz = entity->getZ() - movementInfo.z;
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist3d > 6.0f) {
addSystemChatMessage("Too far away.");
return;
}
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
movementInfo.orientation = std::atan2(-dy, dx);
sendMovement(Opcode::MSG_MOVE_SET_FACING);
}
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
auto packet = GameObjectUsePacket::build(guid);
socket->send(packet);
// For mailbox GameObjects (type 19), open mail UI and request mail list.
// In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends
// animation/sound and expects the client to request the mail list.
bool isMailbox = false;
bool chestLike = false;
// Stock-like behavior: GO use opens GO loot context. Keep eager CMSG_LOOT only
// as Classic/Turtle fallback behavior.
bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle");
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
auto* info = getCachedGameObjectInfo(go->getEntry());
if (info && info->type == 19) {
isMailbox = true;
shouldSendLoot = false;
LOG_INFO("Mailbox interaction: opening mail UI and requesting mail list");
mailboxGuid_ = guid;
mailboxOpen_ = true;
hasNewMail_ = false;
selectedMailIndex_ = -1;
showMailCompose_ = false;
refreshMailList();
} else if (info && info->type == 3) {
chestLike = true;
} else if (turtleMode) {
// Turtle compatibility: keep eager loot open behavior.
shouldSendLoot = true;
}
}
if (!chestLike && !goName.empty()) {
std::string lower = goName;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
chestLike = (lower.find("chest") != std::string::npos);
}
// For WotLK chest-like gameobjects, report use but let server open loot.
if (!isMailbox && chestLike) {
if (isActiveExpansion("wotlk")) {
network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE));
reportUse.writeUInt64(guid);
socket->send(reportUse);
}
}
if (shouldSendLoot) {
lootTarget(guid);
}
// Retry use briefly to survive packet loss/order races. Keep loot retries only
// when we intentionally use eager loot-open mode.
const bool retryLoot = shouldSendLoot && (turtleMode || isActiveExpansion("classic"));
const bool retryUse = turtleMode || isActiveExpansion("classic");
if (retryUse || retryLoot) {
pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot});
}
}
void GameHandler::selectGossipOption(uint32_t optionId) {
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
LOG_INFO("selectGossipOption: optionId=", optionId,
" npcGuid=0x", std::hex, currentGossip.npcGuid, std::dec,
" menuId=", currentGossip.menuId,
" numOptions=", currentGossip.options.size());
auto packet = GossipSelectOptionPacket::build(currentGossip.npcGuid, currentGossip.menuId, optionId);
socket->send(packet);
for (const auto& opt : currentGossip.options) {
if (opt.id != optionId) continue;
LOG_INFO(" matched option: id=", opt.id, " icon=", (int)opt.icon, " text='", opt.text, "'");
// Icon-based NPC interaction fallbacks
// Some servers need the specific activate packet in addition to gossip select
if (opt.icon == 6) {
// GOSSIP_ICON_MONEY_BAG = banker
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
socket->send(pkt);
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
}
// Text-based NPC type detection for servers using placeholder strings
std::string text = opt.text;
std::string textLower = text;
std::transform(textLower.begin(), textLower.end(), textLower.begin(),
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
if (text == "GOSSIP_OPTION_AUCTIONEER" || textLower.find("auction") != std::string::npos) {
auto pkt = AuctionHelloPacket::build(currentGossip.npcGuid);
socket->send(pkt);
LOG_INFO("Sent MSG_AUCTION_HELLO for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
}
if (text == "GOSSIP_OPTION_BANKER" || textLower.find("deposit box") != std::string::npos) {
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
socket->send(pkt);
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
}
if (textLower.find("make this inn your home") != std::string::npos ||
textLower.find("set your home") != std::string::npos) {
auto bindPkt = BinderActivatePacket::build(currentGossip.npcGuid);
socket->send(bindPkt);
LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
}
break;
}
}
void GameHandler::selectGossipQuest(uint32_t questId) {
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
// Keep quest-log fallback for servers that don't provide stable icon semantics.
const QuestLogEntry* activeQuest = nullptr;
for (const auto& q : questLog_) {
if (q.questId == questId) {
activeQuest = &q;
break;
}
}
// Validate against server-auth quest slot fields to avoid stale local entries
// forcing turn-in flow for quests that are not actually accepted.
auto questInServerLogSlots = [&](uint32_t qid) -> bool {
if (qid == 0 || lastPlayerFields_.empty()) return false;
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride;
for (const auto& [key, val] : lastPlayerFields_) {
if (key < ufQuestStart || key >= ufQuestEnd) continue;
if ((key - ufQuestStart) % qStride != 0) continue;
if (val == qid) return true;
}
return false;
};
const bool questInServerLog = questInServerLogSlots(questId);
if (questInServerLog && !activeQuest) {
addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), "");
requestQuestQuery(questId, false);
for (const auto& q : questLog_) {
if (q.questId == questId) {
activeQuest = &q;
break;
}
}
}
const bool activeQuestConfirmedByServer = questInServerLog;
// Only trust server quest-log slots for deciding "already accepted" flow.
// Gossip icon values can differ across cores/expansions and misclassify
// available quests as active, which blocks acceptance.
const bool shouldStartProgressFlow = activeQuestConfirmedByServer;
if (shouldStartProgressFlow) {
pendingTurnInQuestId_ = questId;
pendingTurnInNpcGuid_ = currentGossip.npcGuid;
pendingTurnInRewardRequest_ = activeQuest ? activeQuest->complete : false;
auto packet = QuestgiverCompleteQuestPacket::build(currentGossip.npcGuid, questId);
socket->send(packet);
} else {
pendingTurnInQuestId_ = 0;
pendingTurnInNpcGuid_ = 0;
pendingTurnInRewardRequest_ = false;
auto packet = packetParsers_
? packetParsers_->buildQueryQuestPacket(currentGossip.npcGuid, questId)
: QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId);
socket->send(packet);
}
gossipWindowOpen = false;
}
bool GameHandler::requestQuestQuery(uint32_t questId, bool force) {
if (questId == 0 || state != WorldState::IN_WORLD || !socket) return false;
if (!force && pendingQuestQueryIds_.count(questId)) return false;
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
pkt.writeUInt32(questId);
socket->send(pkt);
pendingQuestQueryIds_.insert(questId);
return true;
}
void GameHandler::handleQuestDetails(network::Packet& packet) {
QuestDetailsData data;
bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data)
: QuestDetailsParser::parse(packet, data);
if (!ok) {
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_QUEST_DETAILS");
return;
}
currentQuestDetails = data;
for (auto& q : questLog_) {
if (q.questId != data.questId) continue;
if (!data.title.empty() && (isPlaceholderQuestTitle(q.title) || data.title.size() >= q.title.size())) {
q.title = data.title;
}
if (!data.objectives.empty() && (q.objectives.empty() || data.objectives.size() > q.objectives.size())) {
q.objectives = data.objectives;
}
break;
}
questDetailsOpen = true;
gossipWindowOpen = false;
}
bool GameHandler::hasQuestInLog(uint32_t questId) const {
for (const auto& q : questLog_) {
if (q.questId == questId) return true;
}
return false;
}
int GameHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const {
if (questId == 0 || lastPlayerFields_.empty()) return -1;
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
for (uint16_t slot = 0; slot < 25; ++slot) {
const uint16_t idField = ufQuestStart + slot * qStride;
auto it = lastPlayerFields_.find(idField);
if (it != lastPlayerFields_.end() && it->second == questId) {
return static_cast<int>(slot);
}
}
return -1;
}
void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives) {
if (questId == 0 || hasQuestInLog(questId)) return;
QuestLogEntry entry;
entry.questId = questId;
entry.title = title.empty() ? ("Quest #" + std::to_string(questId)) : title;
entry.objectives = objectives;
questLog_.push_back(std::move(entry));
}
bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) {
if (lastPlayerFields_.empty()) return false;
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
std::unordered_set<uint32_t> serverQuestIds;
serverQuestIds.reserve(25);
for (uint16_t slot = 0; slot < 25; ++slot) {
const uint16_t idField = ufQuestStart + slot * qStride;
auto it = lastPlayerFields_.find(idField);
if (it == lastPlayerFields_.end()) continue;
if (it->second != 0) serverQuestIds.insert(it->second);
}
const size_t localBefore = questLog_.size();
std::erase_if(questLog_, [&](const QuestLogEntry& q) {
return q.questId == 0 || serverQuestIds.count(q.questId) == 0;
});
const size_t removed = localBefore - questLog_.size();
size_t added = 0;
for (uint32_t questId : serverQuestIds) {
if (hasQuestInLog(questId)) continue;
addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), "");
++added;
}
if (forceQueryMetadata) {
for (uint32_t questId : serverQuestIds) {
requestQuestQuery(questId, false);
}
}
LOG_INFO("Quest log resync from server slots: server=", serverQuestIds.size(),
" localBefore=", localBefore, " removed=", removed, " added=", added);
return true;
}
void GameHandler::clearPendingQuestAccept(uint32_t questId) {
pendingQuestAcceptTimeouts_.erase(questId);
pendingQuestAcceptNpcGuids_.erase(questId);
}
void GameHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason) {
if (questId == 0 || !socket || state != WorldState::IN_WORLD) return;
LOG_INFO("Quest accept resync: questId=", questId, " reason=", reason ? reason : "unknown");
requestQuestQuery(questId, true);
if (npcGuid != 0) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(npcGuid);
socket->send(qsPkt);
auto queryPkt = packetParsers_
? packetParsers_->buildQueryQuestPacket(npcGuid, questId)
: QuestgiverQueryQuestPacket::build(npcGuid, questId);
socket->send(queryPkt);
}
}
void GameHandler::acceptQuest() {
if (!questDetailsOpen || state != WorldState::IN_WORLD || !socket) return;
const uint32_t questId = currentQuestDetails.questId;
if (questId == 0) return;
uint64_t npcGuid = currentQuestDetails.npcGuid;
if (pendingQuestAcceptTimeouts_.count(questId) != 0) {
LOG_DEBUG("Ignoring duplicate quest accept while pending: questId=", questId);
triggerQuestAcceptResync(questId, npcGuid, "duplicate-accept");
questDetailsOpen = false;
currentQuestDetails = QuestDetailsData{};
return;
}
const bool inLocalLog = hasQuestInLog(questId);
const int serverSlot = findQuestLogSlotIndexFromServer(questId);
if (serverSlot >= 0) {
LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId,
" slot=", serverSlot);
questDetailsOpen = false;
currentQuestDetails = QuestDetailsData{};
return;
}
if (inLocalLog) {
LOG_WARNING("Quest accept local/server mismatch, allowing re-accept: questId=", questId);
std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == questId; });
}
network::Packet packet = packetParsers_
? packetParsers_->buildAcceptQuestPacket(npcGuid, questId)
: QuestgiverAcceptQuestPacket::build(npcGuid, questId);
socket->send(packet);
pendingQuestAcceptTimeouts_[questId] = 5.0f;
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
questDetailsOpen = false;
currentQuestDetails = QuestDetailsData{};
// Re-query quest giver status so marker updates (! → ?)
if (npcGuid) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(npcGuid);
socket->send(qsPkt);
}
}
void GameHandler::declineQuest() {
questDetailsOpen = false;
currentQuestDetails = QuestDetailsData{};
}
void GameHandler::abandonQuest(uint32_t questId) {
clearPendingQuestAccept(questId);
int localIndex = -1;
for (size_t i = 0; i < questLog_.size(); ++i) {
if (questLog_[i].questId == questId) {
localIndex = static_cast<int>(i);
break;
}
}
int slotIndex = findQuestLogSlotIndexFromServer(questId);
if (slotIndex < 0 && localIndex >= 0) {
// Best-effort fallback if update fields are stale/missing.
slotIndex = localIndex;
LOG_WARNING("Abandon quest using local slot fallback: questId=", questId, " slot=", slotIndex);
}
if (slotIndex >= 0 && slotIndex < 25) {
if (state == WorldState::IN_WORLD && socket) {
network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
pkt.writeUInt8(static_cast<uint8_t>(slotIndex));
socket->send(pkt);
}
} else {
LOG_WARNING("Abandon quest failed: no quest-log slot found for questId=", questId);
}
if (localIndex >= 0) {
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(localIndex));
}
}
void GameHandler::handleQuestRequestItems(network::Packet& packet) {
QuestRequestItemsData data;
if (!QuestRequestItemsParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_REQUEST_ITEMS");
return;
}
clearPendingQuestAccept(data.questId);
// Expansion-safe fallback: COMPLETE_QUEST is the default flow.
// If a server echoes REQUEST_ITEMS again while still completable,
// request the reward explicitly once.
if (pendingTurnInRewardRequest_ &&
data.questId == pendingTurnInQuestId_ &&
data.npcGuid == pendingTurnInNpcGuid_ &&
data.isCompletable() &&
socket) {
auto rewardReq = QuestgiverRequestRewardPacket::build(data.npcGuid, data.questId);
socket->send(rewardReq);
pendingTurnInRewardRequest_ = false;
}
currentQuestRequestItems_ = data;
questRequestItemsOpen_ = true;
gossipWindowOpen = false;
questDetailsOpen = false;
// Query item names for required items
for (const auto& item : data.requiredItems) {
queryItemInfo(item.itemId, 0);
}
// Server-authoritative turn-in requirements: sync quest-log summary so
// UI doesn't show stale/inferred objective numbers.
for (auto& q : questLog_) {
if (q.questId != data.questId) continue;
q.complete = data.isCompletable();
q.requiredItemCounts.clear();
std::ostringstream oss;
if (!data.completionText.empty()) {
oss << data.completionText;
if (!data.requiredItems.empty() || data.requiredMoney > 0) oss << "\n\n";
}
if (!data.requiredItems.empty()) {
oss << "Required items:";
for (const auto& item : data.requiredItems) {
std::string itemLabel = "Item " + std::to_string(item.itemId);
if (const auto* info = getItemInfo(item.itemId)) {
if (!info->name.empty()) itemLabel = info->name;
}
q.requiredItemCounts[item.itemId] = item.count;
oss << "\n- " << itemLabel << " x" << item.count;
}
}
if (data.requiredMoney > 0) {
if (!data.requiredItems.empty()) oss << "\n";
oss << "\nRequired money: " << formatCopperAmount(data.requiredMoney);
}
q.objectives = oss.str();
break;
}
}
void GameHandler::handleQuestOfferReward(network::Packet& packet) {
QuestOfferRewardData data;
if (!QuestOfferRewardParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_OFFER_REWARD");
return;
}
clearPendingQuestAccept(data.questId);
LOG_INFO("Quest offer reward: questId=", data.questId, " title=\"", data.title, "\"");
if (pendingTurnInQuestId_ == data.questId) {
pendingTurnInQuestId_ = 0;
pendingTurnInNpcGuid_ = 0;
pendingTurnInRewardRequest_ = false;
}
currentQuestOfferReward_ = data;
questOfferRewardOpen_ = true;
questRequestItemsOpen_ = false;
gossipWindowOpen = false;
questDetailsOpen = false;
// Query item names for reward items
for (const auto& item : data.choiceRewards)
queryItemInfo(item.itemId, 0);
for (const auto& item : data.fixedRewards)
queryItemInfo(item.itemId, 0);
}
void GameHandler::completeQuest() {
if (!questRequestItemsOpen_ || state != WorldState::IN_WORLD || !socket) return;
pendingTurnInQuestId_ = currentQuestRequestItems_.questId;
pendingTurnInNpcGuid_ = currentQuestRequestItems_.npcGuid;
pendingTurnInRewardRequest_ = currentQuestRequestItems_.isCompletable();
// Default quest turn-in flow used by all branches.
auto packet = QuestgiverCompleteQuestPacket::build(
currentQuestRequestItems_.npcGuid, currentQuestRequestItems_.questId);
socket->send(packet);
questRequestItemsOpen_ = false;
currentQuestRequestItems_ = QuestRequestItemsData{};
}
void GameHandler::closeQuestRequestItems() {
pendingTurnInRewardRequest_ = false;
questRequestItemsOpen_ = false;
currentQuestRequestItems_ = QuestRequestItemsData{};
}
void GameHandler::chooseQuestReward(uint32_t rewardIndex) {
if (!questOfferRewardOpen_ || state != WorldState::IN_WORLD || !socket) return;
uint64_t npcGuid = currentQuestOfferReward_.npcGuid;
LOG_INFO("Completing quest: questId=", currentQuestOfferReward_.questId,
" npcGuid=", npcGuid, " rewardIndex=", rewardIndex);
auto packet = QuestgiverChooseRewardPacket::build(
npcGuid, currentQuestOfferReward_.questId, rewardIndex);
socket->send(packet);
pendingTurnInQuestId_ = 0;
pendingTurnInNpcGuid_ = 0;
pendingTurnInRewardRequest_ = false;
questOfferRewardOpen_ = false;
currentQuestOfferReward_ = QuestOfferRewardData{};
// Re-query quest giver status so markers update
if (npcGuid) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(npcGuid);
socket->send(qsPkt);
}
}
void GameHandler::closeQuestOfferReward() {
pendingTurnInRewardRequest_ = false;
questOfferRewardOpen_ = false;
currentQuestOfferReward_ = QuestOfferRewardData{};
}
void GameHandler::closeGossip() {
gossipWindowOpen = false;
currentGossip = GossipMessageData{};
}
void GameHandler::openVendor(uint64_t npcGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
buybackItems_.clear();
auto packet = ListInventoryPacket::build(npcGuid);
socket->send(packet);
}
void GameHandler::closeVendor() {
vendorWindowOpen = false;
currentVendorItems = ListInventoryData{};
buybackItems_.clear();
pendingSellToBuyback_.clear();
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
pendingBuyItemId_ = 0;
pendingBuyItemSlot_ = 0;
}
void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
LOG_INFO("Buy request: vendorGuid=0x", std::hex, vendorGuid, std::dec,
" itemId=", itemId, " slot=", slot, " count=", count,
" wire=0x", std::hex, wireOpcode(Opcode::CMSG_BUY_ITEM), std::dec);
pendingBuyItemId_ = itemId;
pendingBuyItemSlot_ = slot;
// Build directly to avoid helper-signature drift across branches (3-arg vs 4-arg helper).
network::Packet packet(wireOpcode(Opcode::CMSG_BUY_ITEM));
packet.writeUInt64(vendorGuid);
packet.writeUInt32(itemId); // item entry
packet.writeUInt32(slot); // vendor slot index
packet.writeUInt32(count);
// WotLK/AzerothCore expects a trailing byte here.
packet.writeUInt8(0);
socket->send(packet);
}
void GameHandler::buyBackItem(uint32_t buybackSlot) {
if (state != WorldState::IN_WORLD || !socket || currentVendorItems.vendorGuid == 0) return;
// AzerothCore/WotLK expects absolute buyback inventory slot IDs, not 0-based UI row index.
// BUYBACK_SLOT_START is 74 in this protocol family.
constexpr uint32_t kBuybackSlotStart = 74;
uint32_t wireSlot = kBuybackSlotStart + buybackSlot;
// This request is independent from normal buy path; avoid stale pending buy context in logs.
pendingBuyItemId_ = 0;
pendingBuyItemSlot_ = 0;
// Build directly so this compiles even when Opcode::CMSG_BUYBACK_ITEM / BuybackItemPacket
// are not available in some branches.
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
LOG_INFO("Buyback request: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
std::dec, " uiSlot=", buybackSlot, " wireSlot=", wireSlot,
" source=absolute-buyback-slot",
" wire=0x", std::hex, kWotlkCmsgBuybackItemOpcode, std::dec);
pendingBuybackSlot_ = static_cast<int>(buybackSlot);
pendingBuybackWireSlot_ = wireSlot;
network::Packet packet(kWotlkCmsgBuybackItemOpcode);
packet.writeUInt64(currentVendorItems.vendorGuid);
packet.writeUInt32(wireSlot);
socket->send(packet);
}
void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid,
" itemGuid=0x", itemGuid, std::dec,
" count=", count, " wire=0x", std::hex, wireOpcode(Opcode::CMSG_SELL_ITEM), std::dec);
auto packet = SellItemPacket::build(vendorGuid, itemGuid, count);
socket->send(packet);
}
void GameHandler::sellItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return;
uint32_t sellPrice = slot.item.sellPrice;
if (sellPrice == 0) {
if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) {
sellPrice = info->sellPrice;
}
}
if (sellPrice == 0) {
addSystemChatMessage("Cannot sell: this item has no vendor value.");
return;
}
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
LOG_DEBUG("sellItemBySlot: slot=", backpackIndex,
" item=", slot.item.name,
" itemGuid=0x", std::hex, itemGuid, std::dec,
" vendorGuid=0x", std::hex, currentVendorItems.vendorGuid, std::dec);
if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) {
BuybackItem sold;
sold.itemGuid = itemGuid;
sold.item = slot.item;
sold.count = 1;
buybackItems_.push_front(sold);
if (buybackItems_.size() > 12) buybackItems_.pop_back();
pendingSellToBuyback_[itemGuid] = sold;
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
} else if (itemGuid == 0) {
addSystemChatMessage("Cannot sell: item not found in inventory.");
LOG_WARNING("Sell failed: missing item GUID for slot ", backpackIndex);
} else {
addSystemChatMessage("Cannot sell: no vendor.");
}
}
void GameHandler::autoEquipItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return;
if (state == WorldState::IN_WORLD && socket) {
// WoW inventory: equipment 0-18, bags 19-22, backpack 23-38
auto packet = AutoEquipItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex));
socket->send(packet);
}
}
void GameHandler::autoEquipItemInBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
if (state == WorldState::IN_WORLD && socket) {
// Bag items: bag = equip slot 19+bagIndex, slot = index within bag
auto packet = AutoEquipItemPacket::build(
static_cast<uint8_t>(19 + bagIndex), static_cast<uint8_t>(slotIndex));
socket->send(packet);
}
}
void GameHandler::sellItemInBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
if (slot.empty()) return;
uint32_t sellPrice = slot.item.sellPrice;
if (sellPrice == 0) {
if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) {
sellPrice = info->sellPrice;
}
}
if (sellPrice == 0) {
addSystemChatMessage("Cannot sell: this item has no vendor value.");
return;
}
// Resolve item GUID from container contents
uint64_t itemGuid = 0;
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
if (bagGuid != 0) {
auto it = containerContents_.find(bagGuid);
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
itemGuid = it->second.slotGuids[slotIndex];
}
}
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) {
BuybackItem sold;
sold.itemGuid = itemGuid;
sold.item = slot.item;
sold.count = 1;
buybackItems_.push_front(sold);
if (buybackItems_.size() > 12) buybackItems_.pop_back();
pendingSellToBuyback_[itemGuid] = sold;
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
} else if (itemGuid == 0) {
addSystemChatMessage("Cannot sell: item not found.");
} else {
addSystemChatMessage("Cannot sell: no vendor.");
}
}
void GameHandler::unequipToBackpack(EquipSlot equipSlot) {
if (state != WorldState::IN_WORLD || !socket) return;
int freeSlot = inventory.findFreeBackpackSlot();
if (freeSlot < 0) {
addSystemChatMessage("Cannot unequip: no free backpack slots.");
return;
}
// Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF.
uint8_t srcBag = 0xFF;
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot,
" -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")");
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
socket->send(packet);
}
void GameHandler::swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot) {
if (!socket || !socket->isConnected()) return;
LOG_INFO("swapContainerItems: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") -> dst(bag=", (int)dstBag, " slot=", (int)dstSlot, ")");
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
socket->send(packet);
}
void GameHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) {
if (srcBagIndex < 0 || srcBagIndex > 3 || dstBagIndex < 0 || dstBagIndex > 3) return;
if (srcBagIndex == dstBagIndex) return;
// Local swap for immediate visual feedback
auto srcEquip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + srcBagIndex);
auto dstEquip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + dstBagIndex);
auto srcItem = inventory.getEquipSlot(srcEquip).item;
auto dstItem = inventory.getEquipSlot(dstEquip).item;
inventory.setEquipSlot(srcEquip, dstItem);
inventory.setEquipSlot(dstEquip, srcItem);
// Also swap bag contents locally
inventory.swapBagContents(srcBagIndex, dstBagIndex);
// Send to server using CMSG_SWAP_ITEM with INVENTORY_SLOT_BAG_0 (255)
// CMSG_SWAP_INV_ITEM doesn't support bag equip slots (19-22)
if (socket && socket->isConnected()) {
uint8_t srcSlot = static_cast<uint8_t>(19 + srcBagIndex);
uint8_t dstSlot = static_cast<uint8_t>(19 + dstBagIndex);
LOG_INFO("swapBagSlots: bag ", srcBagIndex, " (slot ", (int)srcSlot,
") <-> bag ", dstBagIndex, " (slot ", (int)dstSlot, ")");
auto packet = SwapItemPacket::build(255, dstSlot, 255, srcSlot);
socket->send(packet);
}
}
void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
if (count == 0) count = 1;
// AzerothCore WotLK expects CMSG_DESTROYITEM(bag:u8, slot:u8, count:u32).
// This opcode is currently not modeled as a logical opcode in our table.
constexpr uint16_t kCmsgDestroyItem = 0x111;
network::Packet packet(kCmsgDestroyItem);
packet.writeUInt8(bag);
packet.writeUInt8(slot);
packet.writeUInt32(static_cast<uint32_t>(count));
LOG_DEBUG("Destroy item request: bag=", (int)bag, " slot=", (int)slot,
" count=", (int)count, " wire=0x", std::hex, kCmsgDestroyItem, std::dec);
socket->send(packet);
}
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return;
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
// Find the item's on-use spell ID from cached item info
uint32_t useSpellId = 0;
if (auto* info = getItemInfo(slot.item.itemId)) {
for (const auto& sp : info->spells) {
// SpellTrigger: 0=Use, 5=Learn
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
useSpellId = sp.spellId;
break;
}
}
}
// WoW inventory: equipment 0-18, bags 19-22, backpack 23-38
auto packet = packetParsers_
? packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId);
socket->send(packet);
} else if (itemGuid == 0) {
addSystemChatMessage("Cannot use that item right now.");
}
}
void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
if (slot.empty()) return;
// Resolve item GUID from container contents
uint64_t itemGuid = 0;
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
if (bagGuid != 0) {
auto it = containerContents_.find(bagGuid);
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
itemGuid = it->second.slotGuids[slotIndex];
}
}
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
LOG_INFO("useItemInBag: bag=", bagIndex, " slot=", slotIndex, " itemId=", slot.item.itemId,
" itemGuid=0x", std::hex, itemGuid, std::dec);
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
// Find the item's on-use spell ID
uint32_t useSpellId = 0;
if (auto* info = getItemInfo(slot.item.itemId)) {
for (const auto& sp : info->spells) {
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
useSpellId = sp.spellId;
break;
}
}
}
// WoW bag addressing: bagIndex = equip slot of bag container (19-22)
// For CMSG_USE_ITEM: bag = 19+bagIndex, slot = slot within bag
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
auto packet = packetParsers_
? packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex,
" packetSize=", packet.getSize());
socket->send(packet);
} else if (itemGuid == 0) {
LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex);
addSystemChatMessage("Cannot use that item right now.");
}
}
void GameHandler::useItemById(uint32_t itemId) {
if (itemId == 0) return;
LOG_INFO("useItemById: searching for itemId=", itemId, " in backpack (", inventory.getBackpackSize(), " slots)");
// Search backpack first
for (int i = 0; i < inventory.getBackpackSize(); i++) {
const auto& slot = inventory.getBackpackSlot(i);
if (!slot.empty() && slot.item.itemId == itemId) {
LOG_INFO("useItemById: found itemId=", itemId, " at backpack slot ", i);
useItemBySlot(i);
return;
}
}
// Search bags
for (int bag = 0; bag < inventory.NUM_BAG_SLOTS; bag++) {
int bagSize = inventory.getBagSize(bag);
for (int slot = 0; slot < bagSize; slot++) {
const auto& bagSlot = inventory.getBagSlot(bag, slot);
if (!bagSlot.empty() && bagSlot.item.itemId == itemId) {
LOG_INFO("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
useItemInBag(bag, slot);
return;
}
}
}
LOG_WARNING("useItemById: itemId=", itemId, " not found in inventory");
}
void GameHandler::unstuck() {
if (unstuckCallback_) {
unstuckCallback_();
addSystemChatMessage("Unstuck: snapped upward. Use /unstuckgy for full teleport.");
}
}
void GameHandler::unstuckGy() {
if (unstuckGyCallback_) {
unstuckGyCallback_();
addSystemChatMessage("Unstuck: teleported to safe location.");
}
}
void GameHandler::unstuckHearth() {
if (unstuckHearthCallback_) {
unstuckHearthCallback_();
addSystemChatMessage("Unstuck: teleported to hearthstone location.");
} else {
addSystemChatMessage("No hearthstone bind point set.");
}
}
void GameHandler::handleLootResponse(network::Packet& packet) {
if (!LootResponseParser::parse(packet, currentLoot)) return;
lootWindowOpen = true;
localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false};
// Query item info so loot window can show names instead of IDs
for (const auto& item : currentLoot.items) {
queryItemInfo(item.itemId, 0);
}
if (currentLoot.gold > 0) {
if (state == WorldState::IN_WORLD && socket) {
// Auto-loot gold by sending CMSG_LOOT_MONEY (server handles the rest)
bool suppressFallback = false;
auto cooldownIt = recentLootMoneyAnnounceCooldowns_.find(currentLoot.lootGuid);
if (cooldownIt != recentLootMoneyAnnounceCooldowns_.end() && cooldownIt->second > 0.0f) {
suppressFallback = true;
}
pendingLootMoneyGuid_ = suppressFallback ? 0 : currentLoot.lootGuid;
pendingLootMoneyAmount_ = suppressFallback ? 0 : currentLoot.gold;
pendingLootMoneyNotifyTimer_ = suppressFallback ? 0.0f : 0.4f;
auto pkt = LootMoneyPacket::build();
socket->send(pkt);
currentLoot.gold = 0;
}
}
// Auto-loot items when enabled
if (autoLoot_ && state == WorldState::IN_WORLD && socket) {
for (const auto& item : currentLoot.items) {
auto pkt = AutostoreLootItemPacket::build(item.slotIndex);
socket->send(pkt);
}
}
}
void GameHandler::handleLootReleaseResponse(network::Packet& packet) {
(void)packet;
localLootState_.erase(currentLoot.lootGuid);
lootWindowOpen = false;
currentLoot = LootResponseData{};
}
void GameHandler::handleLootRemoved(network::Packet& packet) {
uint8_t slotIndex = packet.readUInt8();
for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) {
if (it->slotIndex == slotIndex) {
std::string itemName = "item #" + std::to_string(it->itemId);
if (const ItemQueryResponseData* info = getItemInfo(it->itemId)) {
if (!info->name.empty()) {
itemName = info->name;
}
}
std::ostringstream msg;
msg << "Looted: " << itemName;
if (it->count > 1) {
msg << " x" << it->count;
}
addSystemChatMessage(msg.str());
currentLoot.items.erase(it);
break;
}
}
}
void GameHandler::handleGossipMessage(network::Packet& packet) {
bool ok = packetParsers_ ? packetParsers_->parseGossipMessage(packet, currentGossip)
: GossipMessageParser::parse(packet, currentGossip);
if (!ok) return;
if (questDetailsOpen) return; // Don't reopen gossip while viewing quest
gossipWindowOpen = true;
vendorWindowOpen = false; // Close vendor if gossip opens
// Update known quest-log entries based on gossip quests.
// Do not synthesize new "active quest" entries from gossip alone.
bool hasAvailableQuest = false;
bool hasRewardQuest = false;
bool hasIncompleteQuest = false;
auto questIconIsCompletable = [](uint32_t icon) {
return icon == 5 || icon == 6 || icon == 10;
};
auto questIconIsIncomplete = [](uint32_t icon) {
return icon == 3 || icon == 4;
};
auto questIconIsAvailable = [](uint32_t icon) {
return icon == 2 || icon == 7 || icon == 8;
};
for (const auto& questItem : currentGossip.quests) {
// WotLK gossip questIcon is an integer enum, NOT a bitmask:
// 2 = yellow ! (available, not yet accepted)
// 4 = gray ? (active, objectives incomplete)
// 5 = gold ? (complete, ready to turn in)
// Bit-masking these values is wrong: 4 & 0x04 = true, treating incomplete
// quests as completable and causing the server to reject the turn-in request.
bool isCompletable = questIconIsCompletable(questItem.questIcon);
bool isIncomplete = questIconIsIncomplete(questItem.questIcon);
bool isAvailable = questIconIsAvailable(questItem.questIcon);
hasAvailableQuest |= isAvailable;
hasRewardQuest |= isCompletable;
hasIncompleteQuest |= isIncomplete;
// Update existing quest entry if present
for (auto& quest : questLog_) {
if (quest.questId == questItem.questId) {
quest.complete = isCompletable;
quest.title = questItem.title;
LOG_INFO("Updated quest ", questItem.questId, " in log: complete=", isCompletable);
break;
}
}
}
// Keep overhead marker aligned with what this gossip actually offers.
if (currentGossip.npcGuid != 0) {
QuestGiverStatus derivedStatus = QuestGiverStatus::NONE;
if (hasRewardQuest) derivedStatus = QuestGiverStatus::REWARD;
else if (hasAvailableQuest) derivedStatus = QuestGiverStatus::AVAILABLE;
else if (hasIncompleteQuest) derivedStatus = QuestGiverStatus::INCOMPLETE;
if (derivedStatus != QuestGiverStatus::NONE) {
npcQuestStatus_[currentGossip.npcGuid] = derivedStatus;
}
}
// Play NPC greeting voice
if (npcGreetingCallback_ && currentGossip.npcGuid != 0) {
auto entity = entityManager.getEntity(currentGossip.npcGuid);
if (entity) {
glm::vec3 npcPos(entity->getX(), entity->getY(), entity->getZ());
npcGreetingCallback_(currentGossip.npcGuid, npcPos);
}
}
}
void GameHandler::handleQuestgiverQuestList(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) return;
GossipMessageData data;
data.npcGuid = packet.readUInt64();
data.menuId = 0;
data.titleTextId = 0;
// Server text (header/greeting) and optional emote fields.
std::string header = packet.readString();
if (packet.getSize() - packet.getReadPos() >= 8) {
(void)packet.readUInt32(); // emoteDelay / unk
(void)packet.readUInt32(); // emote / unk
}
(void)header;
auto readQuestCount = [&](network::Packet& pkt) -> uint32_t {
size_t rem = pkt.getSize() - pkt.getReadPos();
if (rem >= 4) {
size_t p = pkt.getReadPos();
uint32_t c = pkt.readUInt32();
if (c <= 64) return c;
pkt.setReadPos(p);
}
if (rem >= 1) {
return static_cast<uint32_t>(pkt.readUInt8());
}
return 0;
};
uint32_t questCount = readQuestCount(packet);
data.quests.reserve(questCount);
for (uint32_t i = 0; i < questCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 12) break;
GossipQuestItem q;
q.questId = packet.readUInt32();
q.questIcon = packet.readUInt32();
q.questLevel = static_cast<int32_t>(packet.readUInt32());
// WotLK includes questFlags + isRepeatable; Classic variants may omit.
size_t titlePos = packet.getReadPos();
if (packet.getSize() - packet.getReadPos() >= 5) {
q.questFlags = packet.readUInt32();
q.isRepeatable = packet.readUInt8();
q.title = normalizeWowTextTokens(packet.readString());
if (q.title.empty()) {
packet.setReadPos(titlePos);
q.questFlags = 0;
q.isRepeatable = 0;
q.title = normalizeWowTextTokens(packet.readString());
}
} else {
q.questFlags = 0;
q.isRepeatable = 0;
q.title = normalizeWowTextTokens(packet.readString());
}
if (q.questId != 0) {
data.quests.push_back(std::move(q));
}
}
currentGossip = std::move(data);
gossipWindowOpen = true;
vendorWindowOpen = false;
bool hasAvailableQuest = false;
bool hasRewardQuest = false;
bool hasIncompleteQuest = false;
auto questIconIsCompletable = [](uint32_t icon) {
return icon == 5 || icon == 6 || icon == 10;
};
auto questIconIsIncomplete = [](uint32_t icon) {
return icon == 3 || icon == 4;
};
auto questIconIsAvailable = [](uint32_t icon) {
return icon == 2 || icon == 7 || icon == 8;
};
for (const auto& questItem : currentGossip.quests) {
bool isCompletable = questIconIsCompletable(questItem.questIcon);
bool isIncomplete = questIconIsIncomplete(questItem.questIcon);
bool isAvailable = questIconIsAvailable(questItem.questIcon);
hasAvailableQuest |= isAvailable;
hasRewardQuest |= isCompletable;
hasIncompleteQuest |= isIncomplete;
}
if (currentGossip.npcGuid != 0) {
QuestGiverStatus derivedStatus = QuestGiverStatus::NONE;
if (hasRewardQuest) derivedStatus = QuestGiverStatus::REWARD;
else if (hasAvailableQuest) derivedStatus = QuestGiverStatus::AVAILABLE;
else if (hasIncompleteQuest) derivedStatus = QuestGiverStatus::INCOMPLETE;
if (derivedStatus != QuestGiverStatus::NONE) {
npcQuestStatus_[currentGossip.npcGuid] = derivedStatus;
}
}
LOG_INFO("Questgiver quest list: npc=0x", std::hex, currentGossip.npcGuid, std::dec,
" quests=", currentGossip.quests.size());
}
void GameHandler::handleGossipComplete(network::Packet& packet) {
(void)packet;
// Play farewell sound before closing
if (npcFarewellCallback_ && currentGossip.npcGuid != 0) {
auto entity = entityManager.getEntity(currentGossip.npcGuid);
if (entity && entity->getType() == ObjectType::UNIT) {
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
npcFarewellCallback_(currentGossip.npcGuid, pos);
}
}
gossipWindowOpen = false;
currentGossip = GossipMessageData{};
}
void GameHandler::handleListInventory(network::Packet& packet) {
if (!ListInventoryParser::parse(packet, currentVendorItems)) return;
vendorWindowOpen = true;
gossipWindowOpen = false; // Close gossip if vendor opens
// Play vendor sound
if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) {
auto entity = entityManager.getEntity(currentVendorItems.vendorGuid);
if (entity && entity->getType() == ObjectType::UNIT) {
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
npcVendorCallback_(currentVendorItems.vendorGuid, pos);
}
}
// Query item info for all vendor items so we can show names
for (const auto& item : currentVendorItems.items) {
queryItemInfo(item.itemId, 0);
}
}
// ============================================================
// Trainer
// ============================================================
void GameHandler::handleTrainerList(network::Packet& packet) {
if (!TrainerListParser::parse(packet, currentTrainerList_)) return;
trainerWindowOpen_ = true;
gossipWindowOpen = false;
// Debug: log known spells
LOG_INFO("Known spells count: ", knownSpells.size());
if (knownSpells.size() <= 50) {
std::string spellList;
for (uint32_t id : knownSpells) {
if (!spellList.empty()) spellList += ", ";
spellList += std::to_string(id);
}
LOG_INFO("Known spells: ", spellList);
}
// Check if specific prerequisite spells are known
bool has527 = knownSpells.count(527u);
bool has25312 = knownSpells.count(25312u);
LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312);
// Debug: log first few trainer spells to see their state
LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells");
for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) {
const auto& s = currentTrainerList_.spells[i];
LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
}
// Ensure caches are populated
loadSpellNameCache();
loadSkillLineDbc();
loadSkillLineAbilityDbc();
categorizeTrainerSpells();
}
void GameHandler::trainSpell(uint32_t spellId) {
LOG_INFO("trainSpell called: spellId=", spellId, " state=", (int)state, " socket=", (socket ? "yes" : "no"));
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("trainSpell: Not in world or no socket connection");
return;
}
// Find spell cost in trainer list
uint32_t spellCost = 0;
for (const auto& spell : currentTrainerList_.spells) {
if (spell.spellId == spellId) {
spellCost = spell.spellCost;
break;
}
}
LOG_INFO("Player money: ", playerMoneyCopper_, " copper, spell cost: ", spellCost, " copper");
LOG_INFO("Sending CMSG_TRAINER_BUY_SPELL: guid=", currentTrainerList_.trainerGuid,
" spellId=", spellId);
auto packet = TrainerBuySpellPacket::build(
currentTrainerList_.trainerGuid,
spellId);
socket->send(packet);
LOG_INFO("CMSG_TRAINER_BUY_SPELL sent");
}
void GameHandler::closeTrainer() {
trainerWindowOpen_ = false;
currentTrainerList_ = TrainerListData{};
trainerTabs_.clear();
}
void GameHandler::loadSpellNameCache() {
if (spellNameCacheLoaded_) return;
spellNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("Spell.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Trainer: Could not load Spell.dbc for spell names");
return;
}
if (dbc->getFieldCount() < 154) {
LOG_WARNING("Trainer: Spell.dbc has too few fields");
return;
}
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
if (id == 0) continue;
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
if (!name.empty()) {
spellNameCache_[id] = {std::move(name), std::move(rank)};
}
}
LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc");
}
void GameHandler::loadSkillLineAbilityDbc() {
if (skillLineAbilityLoaded_) return;
skillLineAbilityLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto slaDbc = am->loadDBC("SkillLineAbility.dbc");
if (slaDbc && slaDbc->isLoaded()) {
const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr;
for (uint32_t i = 0; i < slaDbc->getRecordCount(); i++) {
uint32_t skillLineId = slaDbc->getUInt32(i, slaL ? (*slaL)["SkillLineID"] : 1);
uint32_t spellId = slaDbc->getUInt32(i, slaL ? (*slaL)["SpellID"] : 2);
if (spellId > 0 && skillLineId > 0) {
spellToSkillLine_[spellId] = skillLineId;
}
}
LOG_INFO("Trainer: Loaded ", spellToSkillLine_.size(), " skill line abilities");
}
}
void GameHandler::categorizeTrainerSpells() {
trainerTabs_.clear();
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
// Group spells by skill line (category 7 = class spec tabs)
std::map<uint32_t, std::vector<const TrainerSpell*>> specialtySpells;
std::vector<const TrainerSpell*> generalSpells;
for (const auto& spell : currentTrainerList_.spells) {
auto slIt = spellToSkillLine_.find(spell.spellId);
if (slIt != spellToSkillLine_.end()) {
uint32_t skillLineId = slIt->second;
auto catIt = skillLineCategories_.find(skillLineId);
if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
specialtySpells[skillLineId].push_back(&spell);
continue;
}
}
generalSpells.push_back(&spell);
}
// Sort by spell name within each group
auto byName = [this](const TrainerSpell* a, const TrainerSpell* b) {
return getSpellName(a->spellId) < getSpellName(b->spellId);
};
// Build named tabs sorted alphabetically
std::vector<std::pair<std::string, std::vector<const TrainerSpell*>>> named;
for (auto& [skillLineId, spells] : specialtySpells) {
auto nameIt = skillLineNames_.find(skillLineId);
std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Specialty";
std::sort(spells.begin(), spells.end(), byName);
named.push_back({std::move(tabName), std::move(spells)});
}
std::sort(named.begin(), named.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& [name, spells] : named) {
trainerTabs_.push_back({std::move(name), std::move(spells)});
}
// General tab last
if (!generalSpells.empty()) {
std::sort(generalSpells.begin(), generalSpells.end(), byName);
trainerTabs_.push_back({"General", std::move(generalSpells)});
}
LOG_INFO("Trainer: Categorized into ", trainerTabs_.size(), " tabs");
}
void GameHandler::loadTalentDbc() {
if (talentDbcLoaded_) return;
talentDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
// Load Talent.dbc
auto talentDbc = am->loadDBC("Talent.dbc");
if (talentDbc && talentDbc->isLoaded()) {
// Talent.dbc structure (WoW 3.3.5a):
// 0: TalentID
// 1: TalentTabID
// 2: Row (tier)
// 3: Column
// 4-8: RankID[0-4] (spell IDs for ranks 1-5)
// 9-11: PrereqTalent[0-2]
// 12-14: PrereqRank[0-2]
// (other fields less relevant for basic functionality)
const auto* talL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Talent") : nullptr;
const uint32_t tID = talL ? (*talL)["ID"] : 0;
const uint32_t tTabID = talL ? (*talL)["TabID"] : 1;
const uint32_t tRow = talL ? (*talL)["Row"] : 2;
const uint32_t tCol = talL ? (*talL)["Column"] : 3;
const uint32_t tRank0 = talL ? (*talL)["RankSpell0"] : 4;
const uint32_t tPrereq0 = talL ? (*talL)["PrereqTalent0"] : 9;
const uint32_t tPrereqR0 = talL ? (*talL)["PrereqRank0"] : 12;
uint32_t count = talentDbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
TalentEntry entry;
entry.talentId = talentDbc->getUInt32(i, tID);
if (entry.talentId == 0) continue;
entry.tabId = talentDbc->getUInt32(i, tTabID);
entry.row = static_cast<uint8_t>(talentDbc->getUInt32(i, tRow));
entry.column = static_cast<uint8_t>(talentDbc->getUInt32(i, tCol));
// Rank spells (1-5 ranks)
for (int r = 0; r < 5; ++r) {
entry.rankSpells[r] = talentDbc->getUInt32(i, tRank0 + r);
}
// Prerequisites
for (int p = 0; p < 3; ++p) {
entry.prereqTalent[p] = talentDbc->getUInt32(i, tPrereq0 + p);
entry.prereqRank[p] = static_cast<uint8_t>(talentDbc->getUInt32(i, tPrereqR0 + p));
}
// Calculate max rank
entry.maxRank = 0;
for (int r = 0; r < 5; ++r) {
if (entry.rankSpells[r] != 0) {
entry.maxRank = r + 1;
}
}
talentCache_[entry.talentId] = entry;
}
LOG_INFO("Loaded ", talentCache_.size(), " talents from Talent.dbc");
} else {
LOG_WARNING("Could not load Talent.dbc");
}
// Load TalentTab.dbc
auto tabDbc = am->loadDBC("TalentTab.dbc");
if (tabDbc && tabDbc->isLoaded()) {
// TalentTab.dbc structure (WoW 3.3.5a):
// 0: TalentTabID
// 1-17: Name (16 localized strings + flags = 17 fields)
// 18: SpellIconID
// 19: RaceMask
// 20: ClassMask
// 21: PetTalentMask
// 22: OrderIndex
// 23-39: BackgroundFile (16 localized strings + flags = 17 fields)
const auto* ttL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TalentTab") : nullptr;
uint32_t count = tabDbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
TalentTabEntry entry;
entry.tabId = tabDbc->getUInt32(i, ttL ? (*ttL)["ID"] : 0);
if (entry.tabId == 0) continue;
entry.name = tabDbc->getString(i, ttL ? (*ttL)["Name"] : 1);
entry.classMask = tabDbc->getUInt32(i, ttL ? (*ttL)["ClassMask"] : 20);
entry.orderIndex = static_cast<uint8_t>(tabDbc->getUInt32(i, ttL ? (*ttL)["OrderIndex"] : 22));
entry.backgroundFile = tabDbc->getString(i, ttL ? (*ttL)["BackgroundFile"] : 23);
talentTabCache_[entry.tabId] = entry;
// Log first few tabs to debug class mask issue
if (talentTabCache_.size() <= 10) {
LOG_INFO(" Tab ", entry.tabId, ": ", entry.name, " (classMask=0x", std::hex, entry.classMask, std::dec, ")");
}
}
LOG_INFO("Loaded ", talentTabCache_.size(), " talent tabs from TalentTab.dbc");
} else {
LOG_WARNING("Could not load TalentTab.dbc");
}
}
static const std::string EMPTY_STRING;
const std::string& GameHandler::getSpellName(uint32_t spellId) const {
auto it = spellNameCache_.find(spellId);
return (it != spellNameCache_.end()) ? it->second.name : EMPTY_STRING;
}
const std::string& GameHandler::getSpellRank(uint32_t spellId) const {
auto it = spellNameCache_.find(spellId);
return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING;
}
const std::string& GameHandler::getSkillLineName(uint32_t spellId) const {
auto slIt = spellToSkillLine_.find(spellId);
if (slIt == spellToSkillLine_.end()) return EMPTY_STRING;
auto nameIt = skillLineNames_.find(slIt->second);
return (nameIt != skillLineNames_.end()) ? nameIt->second : EMPTY_STRING;
}
// ============================================================
// Single-player local combat
// ============================================================
// ============================================================
// XP tracking
// ============================================================
// WotLK 3.3.5a XP-to-next-level table (from player_xp_for_level)
static const uint32_t XP_TABLE[] = {
0, // level 0 (unused)
400, 900, 1400, 2100, 2800, 3600, 4500, 5400, 6500, 7600, // 1-10
8700, 9800, 11000, 12300, 13600, 15000, 16400, 17800, 19300, 20800, // 11-20
22400, 24000, 25500, 27200, 28900, 30500, 32200, 33900, 36300, 38800, // 21-30
41600, 44600, 48000, 51400, 55000, 58700, 62400, 66200, 70200, 74300, // 31-40
78500, 82800, 87100, 91600, 96300, 101000, 105800, 110700, 115700, 120900, // 41-50
126100, 131500, 137000, 142500, 148200, 154000, 159900, 165800, 172000, 290000, // 51-60
317000, 349000, 386000, 428000, 475000, 527000, 585000, 648000, 717000, 1523800, // 61-70
1539600, 1555700, 1571800, 1587900, 1604200, 1620700, 1637400, 1653900, 1670800 // 71-79
};
static constexpr uint32_t XP_TABLE_SIZE = sizeof(XP_TABLE) / sizeof(XP_TABLE[0]);
uint32_t GameHandler::xpForLevel(uint32_t level) {
if (level == 0 || level >= XP_TABLE_SIZE) return 0;
return XP_TABLE[level];
}
uint32_t GameHandler::killXp(uint32_t playerLevel, uint32_t victimLevel) {
if (playerLevel == 0 || victimLevel == 0) return 0;
// Gray level check (too low = 0 XP)
int32_t grayLevel;
if (playerLevel <= 5) grayLevel = 0;
else if (playerLevel <= 39) grayLevel = static_cast<int32_t>(playerLevel) - 5 - static_cast<int32_t>(playerLevel) / 10;
else if (playerLevel <= 59) grayLevel = static_cast<int32_t>(playerLevel) - 1 - static_cast<int32_t>(playerLevel) / 5;
else grayLevel = static_cast<int32_t>(playerLevel) - 9;
if (static_cast<int32_t>(victimLevel) <= grayLevel) return 0;
// Base XP = 45 + 5 * victimLevel (WoW-like ZeroDifference formula)
uint32_t baseXp = 45 + 5 * victimLevel;
// Level difference multiplier
int32_t diff = static_cast<int32_t>(victimLevel) - static_cast<int32_t>(playerLevel);
float multiplier = 1.0f + diff * 0.05f;
if (multiplier < 0.1f) multiplier = 0.1f;
if (multiplier > 2.0f) multiplier = 2.0f;
return static_cast<uint32_t>(baseXp * multiplier);
}
void GameHandler::handleXpGain(network::Packet& packet) {
XpGainData data;
if (!XpGainParser::parse(packet, data)) return;
// Server already updates PLAYER_XP via update fields,
// but we can show combat text for XP gains
addCombatText(CombatTextEntry::HEAL, static_cast<int32_t>(data.totalXp), 0, true);
std::string msg = "You gain " + std::to_string(data.totalXp) + " experience.";
if (data.groupBonus > 0) {
msg += " (+" + std::to_string(data.groupBonus) + " group bonus)";
}
addSystemChatMessage(msg);
}
void GameHandler::addMoneyCopper(uint32_t amount) {
if (amount == 0) return;
playerMoneyCopper_ += amount;
uint32_t gold = amount / 10000;
uint32_t silver = (amount / 100) % 100;
uint32_t copper = amount % 100;
std::string msg = "You receive ";
msg += std::to_string(gold) + "g ";
msg += std::to_string(silver) + "s ";
msg += std::to_string(copper) + "c.";
addSystemChatMessage(msg);
}
void GameHandler::addSystemChatMessage(const std::string& message) {
if (message.empty()) return;
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = message;
addLocalChatMessage(msg);
}
// ============================================================
// Teleport Handler
// ============================================================
void GameHandler::handleTeleportAck(network::Packet& packet) {
// MSG_MOVE_TELEPORT_ACK (server→client): packedGuid + u32 counter + u32 time
// followed by movement info with the new position
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short");
return;
}
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t counter = packet.readUInt32();
// Read the movement info embedded in the teleport
// Format: u32 flags, u16 flags2, u32 time, float x, float y, float z, float o
if (packet.getSize() - packet.getReadPos() < 4 + 2 + 4 + 4 * 4) {
LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info");
return;
}
packet.readUInt32(); // moveFlags
packet.readUInt16(); // moveFlags2
uint32_t moveTime = packet.readUInt32();
float serverX = packet.readFloat();
float serverY = packet.readFloat();
float serverZ = packet.readFloat();
float orientation = packet.readFloat();
LOG_INFO("MSG_MOVE_TELEPORT_ACK: guid=0x", std::hex, guid, std::dec,
" counter=", counter,
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")");
// Update our position
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ));
movementInfo.x = canonical.x;
movementInfo.y = canonical.y;
movementInfo.z = canonical.z;
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
movementInfo.flags = 0;
// Send the ack back to the server
// Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time
if (socket && !isClassicLikeExpansion()) {
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK));
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for teleport ACK
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
ack.writeUInt32(counter);
ack.writeUInt32(moveTime);
socket->send(ack);
LOG_INFO("Sent MSG_MOVE_TELEPORT_ACK response");
}
// Notify application of teleport — the callback decides whether to do
// a full world reload (map change) or just update position (same map).
if (worldEntryCallback_) {
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ);
}
}
void GameHandler::handleNewWorld(network::Packet& packet) {
// SMSG_NEW_WORLD: uint32 mapId, float x, y, z, orientation
if (packet.getSize() - packet.getReadPos() < 20) {
LOG_WARNING("SMSG_NEW_WORLD too short");
return;
}
uint32_t mapId = packet.readUInt32();
float serverX = packet.readFloat();
float serverY = packet.readFloat();
float serverZ = packet.readFloat();
float orientation = packet.readFloat();
LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId,
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")",
" orient=", orientation);
// Detect same-map spirit healer resurrection: the server uses SMSG_NEW_WORLD
// to reposition the player at the graveyard on the same map. A full world
// reload is not needed and causes terrain to vanish, making the player fall
// forever. Just reposition and send the ack.
const bool isSameMap = (mapId == currentMapId_);
const bool isResurrection = resurrectPending_;
if (isSameMap && isResurrection) {
LOG_INFO("SMSG_NEW_WORLD same-map resurrection — skipping world reload");
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ));
movementInfo.x = canonical.x;
movementInfo.y = canonical.y;
movementInfo.z = canonical.z;
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
movementInfo.flags = 0;
movementInfo.flags2 = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
releasedSpirit_ = false;
playerDead_ = false;
repopPending_ = false;
pendingSpiritHealerGuid_ = 0;
resurrectCasterGuid_ = 0;
hostileAttackers_.clear();
stopAutoAttack();
tabCycleStale = true;
if (socket) {
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
socket->send(ack);
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK (resurrection)");
}
return;
}
currentMapId_ = mapId;
// Update player position
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ));
movementInfo.x = canonical.x;
movementInfo.y = canonical.y;
movementInfo.z = canonical.z;
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
movementInfo.flags = 0;
movementInfo.flags2 = 0;
serverMovementAllowed_ = true;
resurrectPending_ = false;
resurrectRequestPending_ = false;
onTaxiFlight_ = false;
taxiMountActive_ = false;
taxiActivatePending_ = false;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
taxiStartGrace_ = 0.0f;
currentMountDisplayId_ = 0;
taxiMountDisplayId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
// Clear world state for the new map
entityManager.clear();
hostileAttackers_.clear();
worldStates_.clear();
worldStateMapId_ = mapId;
worldStateZoneId_ = 0;
activeAreaTriggers_.clear();
areaTriggerCheckTimer_ = -5.0f; // 5-second cooldown after map transfer
areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire
stopAutoAttack();
casting = false;
currentCastSpellId = 0;
pendingGameObjectInteractGuid_ = 0;
castTimeRemaining = 0.0f;
// Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready
if (socket) {
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
socket->send(ack);
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK");
}
// Reload terrain at new position
if (worldEntryCallback_) {
worldEntryCallback_(mapId, serverX, serverY, serverZ);
}
}
// ============================================================
// Taxi / Flight Path Handlers
// ============================================================
void GameHandler::loadTaxiDbc() {
if (taxiDbcLoaded_) return;
taxiDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto nodesDbc = am->loadDBC("TaxiNodes.dbc");
if (nodesDbc && nodesDbc->isLoaded()) {
const auto* tnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiNodes") : nullptr;
uint32_t fieldCount = nodesDbc->getFieldCount();
for (uint32_t i = 0; i < nodesDbc->getRecordCount(); i++) {
TaxiNode node;
node.id = nodesDbc->getUInt32(i, tnL ? (*tnL)["ID"] : 0);
node.mapId = nodesDbc->getUInt32(i, tnL ? (*tnL)["MapID"] : 1);
node.x = nodesDbc->getFloat(i, tnL ? (*tnL)["X"] : 2);
node.y = nodesDbc->getFloat(i, tnL ? (*tnL)["Y"] : 3);
node.z = nodesDbc->getFloat(i, tnL ? (*tnL)["Z"] : 4);
node.name = nodesDbc->getString(i, tnL ? (*tnL)["Name"] : 5);
const uint32_t mountAllianceField = tnL ? (*tnL)["MountDisplayIdAlliance"] : 22;
const uint32_t mountHordeField = tnL ? (*tnL)["MountDisplayIdHorde"] : 23;
const uint32_t mountAllianceFB = tnL ? (*tnL)["MountDisplayIdAllianceFallback"] : 20;
const uint32_t mountHordeFB = tnL ? (*tnL)["MountDisplayIdHordeFallback"] : 21;
if (fieldCount > mountHordeField) {
node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, mountAllianceField);
node.mountDisplayIdHorde = nodesDbc->getUInt32(i, mountHordeField);
if (node.mountDisplayIdAlliance == 0 && node.mountDisplayIdHorde == 0 && fieldCount > mountHordeFB) {
node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, mountAllianceFB);
node.mountDisplayIdHorde = nodesDbc->getUInt32(i, mountHordeFB);
}
}
uint32_t nodeId = node.id;
if (nodeId > 0) {
taxiNodes_[nodeId] = std::move(node);
}
if (nodeId == 195) {
std::string fields;
for (uint32_t f = 0; f < fieldCount; f++) {
fields += std::to_string(f) + ":" + std::to_string(nodesDbc->getUInt32(i, f)) + " ";
}
LOG_INFO("TaxiNodes[195] fields: ", fields);
}
}
LOG_INFO("Loaded ", taxiNodes_.size(), " taxi nodes from TaxiNodes.dbc");
} else {
LOG_WARNING("Could not load TaxiNodes.dbc");
}
auto pathDbc = am->loadDBC("TaxiPath.dbc");
if (pathDbc && pathDbc->isLoaded()) {
const auto* tpL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPath") : nullptr;
for (uint32_t i = 0; i < pathDbc->getRecordCount(); i++) {
TaxiPathEdge edge;
edge.pathId = pathDbc->getUInt32(i, tpL ? (*tpL)["ID"] : 0);
edge.fromNode = pathDbc->getUInt32(i, tpL ? (*tpL)["FromNode"] : 1);
edge.toNode = pathDbc->getUInt32(i, tpL ? (*tpL)["ToNode"] : 2);
edge.cost = pathDbc->getUInt32(i, tpL ? (*tpL)["Cost"] : 3);
taxiPathEdges_.push_back(edge);
}
LOG_INFO("Loaded ", taxiPathEdges_.size(), " taxi path edges from TaxiPath.dbc");
} else {
LOG_WARNING("Could not load TaxiPath.dbc");
}
auto pathNodeDbc = am->loadDBC("TaxiPathNode.dbc");
if (pathNodeDbc && pathNodeDbc->isLoaded()) {
const auto* tpnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPathNode") : nullptr;
for (uint32_t i = 0; i < pathNodeDbc->getRecordCount(); i++) {
TaxiPathNode node;
node.id = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["ID"] : 0);
node.pathId = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["PathID"] : 1);
node.nodeIndex = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["NodeIndex"] : 2);
node.mapId = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["MapID"] : 3);
node.x = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["X"] : 4);
node.y = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["Y"] : 5);
node.z = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["Z"] : 6);
taxiPathNodes_[node.pathId].push_back(node);
}
// Sort waypoints by nodeIndex for each path
for (auto& [pathId, nodes] : taxiPathNodes_) {
std::sort(nodes.begin(), nodes.end(),
[](const TaxiPathNode& a, const TaxiPathNode& b) {
return a.nodeIndex < b.nodeIndex;
});
}
LOG_INFO("Loaded ", pathNodeDbc->getRecordCount(), " taxi path waypoints from TaxiPathNode.dbc");
} else {
LOG_WARNING("Could not load TaxiPathNode.dbc");
}
}
void GameHandler::handleShowTaxiNodes(network::Packet& packet) {
ShowTaxiNodesData data;
if (!ShowTaxiNodesParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_SHOWTAXINODES");
return;
}
loadTaxiDbc();
// Detect newly discovered flight paths by comparing with stored mask
if (taxiMaskInitialized_) {
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
uint32_t newBits = data.nodeMask[i] & ~knownTaxiMask_[i];
if (newBits == 0) continue;
for (uint32_t bit = 0; bit < 32; ++bit) {
if (newBits & (1u << bit)) {
uint32_t nodeId = i * 32 + bit + 1;
auto it = taxiNodes_.find(nodeId);
if (it != taxiNodes_.end()) {
addSystemChatMessage("Discovered flight path: " + it->second.name);
}
}
}
}
}
// Update stored mask
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
knownTaxiMask_[i] = data.nodeMask[i];
}
taxiMaskInitialized_ = true;
currentTaxiData_ = data;
taxiNpcGuid_ = data.npcGuid;
taxiWindowOpen_ = true;
gossipWindowOpen = false;
buildTaxiCostMap();
auto it = taxiNodes_.find(data.nearestNode);
if (it != taxiNodes_.end()) {
LOG_INFO("Taxi node ", data.nearestNode, " mounts: A=", it->second.mountDisplayIdAlliance,
" H=", it->second.mountDisplayIdHorde);
}
LOG_INFO("Taxi window opened, nearest node=", data.nearestNode);
}
void GameHandler::applyTaxiMountForCurrentNode() {
if (taxiMountActive_ || !mountCallback_) return;
auto it = taxiNodes_.find(currentTaxiData_.nearestNode);
if (it == taxiNodes_.end()) {
// Node not in DBC (custom server nodes, missing data) — use hardcoded fallback.
bool isAlliance = true;
switch (playerRace_) {
case Race::ORC: case Race::UNDEAD: case Race::TAUREN: case Race::TROLL:
case Race::GOBLIN: case Race::BLOOD_ELF:
isAlliance = false; break;
default: break;
}
uint32_t mountId = isAlliance ? 1210u : 1310u;
taxiMountDisplayId_ = mountId;
taxiMountActive_ = true;
LOG_INFO("Taxi mount fallback (node ", currentTaxiData_.nearestNode, " not in DBC): displayId=", mountId);
mountCallback_(mountId);
return;
}
bool isAlliance = true;
switch (playerRace_) {
case Race::ORC:
case Race::UNDEAD:
case Race::TAUREN:
case Race::TROLL:
case Race::GOBLIN:
case Race::BLOOD_ELF:
isAlliance = false;
break;
default:
isAlliance = true;
break;
}
uint32_t mountId = isAlliance ? it->second.mountDisplayIdAlliance
: it->second.mountDisplayIdHorde;
if (mountId == 541) mountId = 0; // Placeholder/invalid in some DBC sets
if (mountId == 0) {
mountId = isAlliance ? it->second.mountDisplayIdHorde
: it->second.mountDisplayIdAlliance;
if (mountId == 541) mountId = 0;
}
if (mountId == 0) {
auto& app = core::Application::getInstance();
uint32_t gryphonId = app.getGryphonDisplayId();
uint32_t wyvernId = app.getWyvernDisplayId();
if (isAlliance && gryphonId != 0) mountId = gryphonId;
if (!isAlliance && wyvernId != 0) mountId = wyvernId;
if (mountId == 0) {
mountId = (isAlliance ? wyvernId : gryphonId);
}
}
if (mountId == 0) {
// Fallback: any non-zero mount display from the node.
if (it->second.mountDisplayIdAlliance != 0) mountId = it->second.mountDisplayIdAlliance;
else if (it->second.mountDisplayIdHorde != 0) mountId = it->second.mountDisplayIdHorde;
}
if (mountId == 0) {
// 3.3.5a fallback display IDs (real CreatureDisplayInfo entries).
// Alliance taxi gryphons commonly use 1210-1213.
// Horde taxi wyverns commonly use 1310-1312.
static const uint32_t kAllianceTaxiDisplays[] = {1210u, 1211u, 1212u, 1213u};
static const uint32_t kHordeTaxiDisplays[] = {1310u, 1311u, 1312u};
mountId = isAlliance ? kAllianceTaxiDisplays[0] : kHordeTaxiDisplays[0];
}
// Last resort legacy fallback.
if (mountId == 0) {
mountId = isAlliance ? 30412u : 30413u;
}
if (mountId != 0) {
taxiMountDisplayId_ = mountId;
taxiMountActive_ = true;
LOG_INFO("Taxi mount apply: displayId=", mountId);
mountCallback_(mountId);
}
}
void GameHandler::startClientTaxiPath(const std::vector<uint32_t>& pathNodes) {
taxiClientPath_.clear();
taxiClientIndex_ = 0;
taxiClientActive_ = false;
taxiClientSegmentProgress_ = 0.0f;
// Build full spline path using TaxiPathNode waypoints (not just node positions)
for (size_t i = 0; i + 1 < pathNodes.size(); i++) {
uint32_t fromNode = pathNodes[i];
uint32_t toNode = pathNodes[i + 1];
// Find the pathId connecting these nodes
uint32_t pathId = 0;
for (const auto& edge : taxiPathEdges_) {
if (edge.fromNode == fromNode && edge.toNode == toNode) {
pathId = edge.pathId;
break;
}
}
if (pathId == 0) {
LOG_WARNING("No taxi path found from node ", fromNode, " to ", toNode);
continue;
}
// Get spline waypoints for this path segment
auto pathIt = taxiPathNodes_.find(pathId);
if (pathIt != taxiPathNodes_.end()) {
for (const auto& wpNode : pathIt->second) {
glm::vec3 serverPos(wpNode.x, wpNode.y, wpNode.z);
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
taxiClientPath_.push_back(canonical);
}
} else {
LOG_WARNING("No spline waypoints found for taxi pathId ", pathId);
}
}
if (taxiClientPath_.size() < 2) {
// Fallback: use TaxiNodes directly when TaxiPathNode spline data is missing.
taxiClientPath_.clear();
for (uint32_t nodeId : pathNodes) {
auto nodeIt = taxiNodes_.find(nodeId);
if (nodeIt == taxiNodes_.end()) continue;
glm::vec3 serverPos(nodeIt->second.x, nodeIt->second.y, nodeIt->second.z);
taxiClientPath_.push_back(core::coords::serverToCanonical(serverPos));
}
}
if (taxiClientPath_.size() < 2) {
LOG_WARNING("Taxi path too short: ", taxiClientPath_.size(), " waypoints");
return;
}
// Set initial orientation to face the first non-degenerate flight segment.
glm::vec3 start = taxiClientPath_[0];
glm::vec3 dir(0.0f);
float dirLen = 0.0f;
for (size_t i = 1; i < taxiClientPath_.size(); i++) {
dir = taxiClientPath_[i] - start;
dirLen = glm::length(dir);
if (dirLen >= 0.001f) {
break;
}
}
float initialOrientation = movementInfo.orientation;
float initialRenderYaw = movementInfo.orientation;
float initialPitch = 0.0f;
float initialRoll = 0.0f;
if (dirLen >= 0.001f) {
initialOrientation = std::atan2(dir.y, dir.x);
glm::vec3 renderDir = core::coords::canonicalToRender(dir);
initialRenderYaw = std::atan2(renderDir.y, renderDir.x);
glm::vec3 dirNorm = dir / dirLen;
initialPitch = std::asin(std::clamp(dirNorm.z, -1.0f, 1.0f));
}
movementInfo.x = start.x;
movementInfo.y = start.y;
movementInfo.z = start.z;
movementInfo.orientation = initialOrientation;
sanitizeMovementForTaxi();
auto playerEntity = entityManager.getEntity(playerGuid);
if (playerEntity) {
playerEntity->setPosition(start.x, start.y, start.z, initialOrientation);
}
if (taxiOrientationCallback_) {
taxiOrientationCallback_(initialRenderYaw, initialPitch, initialRoll);
}
LOG_INFO("Taxi flight started with ", taxiClientPath_.size(), " spline waypoints");
taxiClientActive_ = true;
}
void GameHandler::updateClientTaxi(float deltaTime) {
if (!taxiClientActive_ || taxiClientPath_.size() < 2) return;
auto playerEntity = entityManager.getEntity(playerGuid);
auto finishTaxiFlight = [&]() {
// Snap player to the last waypoint (landing position) before clearing state.
// Without this, the player would be left at whatever mid-flight position
// they were at when the path completion was detected.
if (!taxiClientPath_.empty()) {
const auto& landingPos = taxiClientPath_.back();
if (playerEntity) {
playerEntity->setPosition(landingPos.x, landingPos.y, landingPos.z,
movementInfo.orientation);
}
movementInfo.x = landingPos.x;
movementInfo.y = landingPos.y;
movementInfo.z = landingPos.z;
LOG_INFO("Taxi landing: snapped to final waypoint (",
landingPos.x, ", ", landingPos.y, ", ", landingPos.z, ")");
}
taxiClientActive_ = false;
onTaxiFlight_ = false;
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0);
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
currentMountDisplayId_ = 0;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
movementInfo.flags = 0;
movementInfo.flags2 = 0;
if (socket) {
sendMovement(Opcode::MSG_MOVE_STOP);
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("Taxi flight landed (client path)");
};
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
finishTaxiFlight();
return;
}
float remainingDistance = taxiClientSegmentProgress_ + (taxiClientSpeed_ * deltaTime);
glm::vec3 start(0.0f);
glm::vec3 end(0.0f);
glm::vec3 dir(0.0f);
float segmentLen = 0.0f;
float t = 0.0f;
// Consume as many tiny/finished segments as needed this frame so taxi doesn't stall
// on dense/degenerate node clusters near takeoff/landing.
while (true) {
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
finishTaxiFlight();
return;
}
start = taxiClientPath_[taxiClientIndex_];
end = taxiClientPath_[taxiClientIndex_ + 1];
dir = end - start;
segmentLen = glm::length(dir);
if (segmentLen < 0.01f) {
taxiClientIndex_++;
continue;
}
if (remainingDistance >= segmentLen) {
remainingDistance -= segmentLen;
taxiClientIndex_++;
taxiClientSegmentProgress_ = 0.0f;
continue;
}
taxiClientSegmentProgress_ = remainingDistance;
t = taxiClientSegmentProgress_ / segmentLen;
break;
}
// Use Catmull-Rom spline for smooth interpolation between waypoints
// Get surrounding points for spline curve
glm::vec3 p0 = (taxiClientIndex_ > 0) ? taxiClientPath_[taxiClientIndex_ - 1] : start;
glm::vec3 p1 = start;
glm::vec3 p2 = end;
glm::vec3 p3 = (taxiClientIndex_ + 2 < taxiClientPath_.size()) ?
taxiClientPath_[taxiClientIndex_ + 2] : end;
// Catmull-Rom spline formula for smooth curves
float t2 = t * t;
float t3 = t2 * t;
glm::vec3 nextPos = 0.5f * (
(2.0f * p1) +
(-p0 + p2) * t +
(2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 +
(-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3
);
// Calculate smooth direction for orientation (tangent to spline)
glm::vec3 tangent = 0.5f * (
(-p0 + p2) +
2.0f * (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t +
3.0f * (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t2
);
float tangentLen = glm::length(tangent);
if (tangentLen < 0.0001f) {
tangent = dir;
tangentLen = glm::length(tangent);
if (tangentLen < 0.0001f) {
tangent = glm::vec3(std::cos(movementInfo.orientation), std::sin(movementInfo.orientation), 0.0f);
tangentLen = glm::length(tangent);
}
}
// Calculate yaw from horizontal direction
float targetOrientation = std::atan2(tangent.y, tangent.x);
// Calculate pitch from vertical component (altitude change)
glm::vec3 tangentNorm = tangent / std::max(tangentLen, 0.0001f);
float pitch = std::asin(std::clamp(tangentNorm.z, -1.0f, 1.0f));
// Calculate roll (banking) from rate of yaw change
float currentOrientation = movementInfo.orientation;
float orientDiff = targetOrientation - currentOrientation;
// Normalize angle difference to [-PI, PI]
while (orientDiff > 3.14159265f) orientDiff -= 6.28318530f;
while (orientDiff < -3.14159265f) orientDiff += 6.28318530f;
// Bank proportional to turn rate (scaled for visual effect)
float roll = -orientDiff * 2.5f;
roll = std::clamp(roll, -0.7f, 0.7f); // Limit to ~40 degrees
// Smooth rotation transition (lerp towards target)
float smoothOrientation = currentOrientation + orientDiff * std::min(1.0f, deltaTime * 3.0f);
if (playerEntity) {
playerEntity->setPosition(nextPos.x, nextPos.y, nextPos.z, smoothOrientation);
}
movementInfo.x = nextPos.x;
movementInfo.y = nextPos.y;
movementInfo.z = nextPos.z;
movementInfo.orientation = smoothOrientation;
// Update mount rotation with yaw/pitch/roll. Use render-space tangent yaw to
// avoid canonical<->render convention mismatches.
if (taxiOrientationCallback_) {
glm::vec3 renderTangent = core::coords::canonicalToRender(tangent);
float renderYaw = std::atan2(renderTangent.y, renderTangent.x);
taxiOrientationCallback_(renderYaw, pitch, roll);
}
}
void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
ActivateTaxiReplyData data;
if (!ActivateTaxiReplyParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_ACTIVATETAXIREPLY");
return;
}
// Guard against stray/mis-mapped packets being treated as taxi replies.
// We only consume a reply while an activation request is pending.
if (!taxiActivatePending_) {
LOG_DEBUG("Ignoring stray taxi reply: result=", data.result);
return;
}
if (data.result == 0) {
// Some cores can emit duplicate success replies (e.g. basic + express activate).
// Ignore repeats once taxi is already active and no activation is pending.
if (onTaxiFlight_ && !taxiActivatePending_) {
return;
}
onTaxiFlight_ = true;
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
sanitizeMovementForTaxi();
taxiWindowOpen_ = false;
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
applyTaxiMountForCurrentNode();
if (socket) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("Taxi flight started!");
} else {
// If local taxi motion already started, treat late failure as stale and ignore.
if (onTaxiFlight_ || taxiClientActive_) {
LOG_WARNING("Ignoring stale taxi failure reply while flight is active: result=", data.result);
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
return;
}
LOG_WARNING("Taxi activation failed, result=", data.result);
addSystemChatMessage("Cannot take that flight path.");
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0);
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
onTaxiFlight_ = false;
}
}
void GameHandler::closeTaxi() {
taxiWindowOpen_ = false;
// Closing the taxi UI must not cancel an active/pending flight.
// The window can auto-close due distance checks while takeoff begins.
if (taxiActivatePending_ || onTaxiFlight_ || taxiClientActive_) {
return;
}
// If we optimistically mounted during node selection, dismount now
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0); // Dismount
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
// Clear any pending activation
taxiActivatePending_ = false;
onTaxiFlight_ = false;
// Set cooldown to prevent auto-mount trigger from re-applying taxi mount
// (The UNIT_FLAG_TAXI_FLIGHT check in handleUpdateObject won't re-trigger during cooldown)
taxiLandingCooldown_ = 2.0f;
}
void GameHandler::buildTaxiCostMap() {
taxiCostMap_.clear();
uint32_t startNode = currentTaxiData_.nearestNode;
if (startNode == 0) return;
// Build adjacency list with costs from all edges (path may traverse unknown nodes)
struct AdjEntry { uint32_t node; uint32_t cost; };
std::unordered_map<uint32_t, std::vector<AdjEntry>> adj;
for (const auto& edge : taxiPathEdges_) {
adj[edge.fromNode].push_back({edge.toNode, edge.cost});
}
// BFS from startNode, accumulating costs along the path
std::deque<uint32_t> queue;
queue.push_back(startNode);
taxiCostMap_[startNode] = 0;
while (!queue.empty()) {
uint32_t cur = queue.front();
queue.pop_front();
for (const auto& next : adj[cur]) {
if (taxiCostMap_.find(next.node) == taxiCostMap_.end()) {
taxiCostMap_[next.node] = taxiCostMap_[cur] + next.cost;
queue.push_back(next.node);
}
}
}
}
uint32_t GameHandler::getTaxiCostTo(uint32_t destNodeId) const {
auto it = taxiCostMap_.find(destNodeId);
return (it != taxiCostMap_.end()) ? it->second : 0;
}
void GameHandler::activateTaxi(uint32_t destNodeId) {
if (!socket || state != WorldState::IN_WORLD) return;
// One-shot taxi activation until server replies or timeout.
if (taxiActivatePending_ || onTaxiFlight_) {
return;
}
uint32_t startNode = currentTaxiData_.nearestNode;
if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return;
// If already mounted, dismount before starting a taxi flight.
if (isMounted()) {
LOG_INFO("Taxi activate: dismounting current mount");
if (mountCallback_) mountCallback_(0);
currentMountDisplayId_ = 0;
dismount();
}
addSystemChatMessage("Taxi: requesting flight...");
// BFS to find path from startNode to destNodeId
std::unordered_map<uint32_t, std::vector<uint32_t>> adj;
for (const auto& edge : taxiPathEdges_) {
adj[edge.fromNode].push_back(edge.toNode);
}
std::unordered_map<uint32_t, uint32_t> parent;
std::deque<uint32_t> queue;
queue.push_back(startNode);
parent[startNode] = startNode;
bool found = false;
while (!queue.empty()) {
uint32_t cur = queue.front();
queue.pop_front();
if (cur == destNodeId) { found = true; break; }
for (uint32_t next : adj[cur]) {
if (parent.find(next) == parent.end()) {
parent[next] = cur;
queue.push_back(next);
}
}
}
if (!found) {
LOG_WARNING("No taxi path found from node ", startNode, " to ", destNodeId);
addSystemChatMessage("No flight path available to that destination.");
return;
}
std::vector<uint32_t> path;
for (uint32_t n = destNodeId; n != startNode; n = parent[n]) {
path.push_back(n);
}
path.push_back(startNode);
std::reverse(path.begin(), path.end());
LOG_INFO("Taxi path: ", path.size(), " nodes, from ", startNode, " to ", destNodeId);
LOG_INFO("Taxi activate: npc=0x", std::hex, taxiNpcGuid_, std::dec,
" start=", startNode, " dest=", destNodeId, " pathLen=", path.size());
if (!path.empty()) {
std::string pathStr;
for (size_t i = 0; i < path.size(); i++) {
pathStr += std::to_string(path[i]);
if (i + 1 < path.size()) pathStr += "->";
}
LOG_INFO("Taxi path nodes: ", pathStr);
}
uint32_t totalCost = getTaxiCostTo(destNodeId);
LOG_INFO("Taxi activate: start=", startNode, " dest=", destNodeId, " cost=", totalCost);
// Some servers only accept basic CMSG_ACTIVATETAXI.
auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId);
socket->send(basicPkt);
// AzerothCore in this setup rejects/misparses CMSG_ACTIVATETAXIEXPRESS (0x312),
// so keep taxi activation on the basic packet only.
// Optimistically start taxi visuals; server will correct if it denies.
taxiWindowOpen_ = false;
taxiActivatePending_ = true;
taxiActivateTimer_ = 0.0f;
taxiStartGrace_ = 2.0f;
if (!onTaxiFlight_) {
onTaxiFlight_ = true;
sanitizeMovementForTaxi();
applyTaxiMountForCurrentNode();
}
if (socket) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
// Trigger terrain precache immediately (non-blocking).
if (taxiPrecacheCallback_) {
std::vector<glm::vec3> previewPath;
// Build full spline path using TaxiPathNode waypoints
for (size_t i = 0; i + 1 < path.size(); i++) {
uint32_t fromNode = path[i];
uint32_t toNode = path[i + 1];
// Find the pathId connecting these nodes
uint32_t pathId = 0;
for (const auto& edge : taxiPathEdges_) {
if (edge.fromNode == fromNode && edge.toNode == toNode) {
pathId = edge.pathId;
break;
}
}
if (pathId == 0) continue;
// Get spline waypoints for this path segment
auto pathIt = taxiPathNodes_.find(pathId);
if (pathIt != taxiPathNodes_.end()) {
for (const auto& wpNode : pathIt->second) {
glm::vec3 serverPos(wpNode.x, wpNode.y, wpNode.z);
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
previewPath.push_back(canonical);
}
}
}
if (previewPath.size() >= 2) {
taxiPrecacheCallback_(previewPath);
}
}
// Flight starts immediately; upload callback stays opportunistic/non-blocking.
if (taxiFlightStartCallback_) {
taxiFlightStartCallback_();
}
startClientTaxiPath(path);
// We run taxi movement locally immediately; don't keep a long-lived pending state.
if (taxiClientActive_) {
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
}
addSystemChatMessage("Flight started.");
// Save recovery target in case of disconnect during taxi.
auto destIt = taxiNodes_.find(destNodeId);
if (destIt != taxiNodes_.end()) {
taxiRecoverMapId_ = destIt->second.mapId;
taxiRecoverPos_ = core::coords::serverToCanonical(
glm::vec3(destIt->second.x, destIt->second.y, destIt->second.z));
taxiRecoverPending_ = false;
}
}
// ============================================================
// Server Info Command Handlers
// ============================================================
void GameHandler::handleQueryTimeResponse(network::Packet& packet) {
QueryTimeResponseData data;
if (!QueryTimeResponseParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_QUERY_TIME_RESPONSE");
return;
}
// Convert Unix timestamp to readable format
time_t serverTime = static_cast<time_t>(data.serverTime);
struct tm* timeInfo = localtime(&serverTime);
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", timeInfo);
std::string msg = "Server time: " + std::string(timeStr);
addSystemChatMessage(msg);
LOG_INFO("Server time: ", data.serverTime, " (", timeStr, ")");
}
void GameHandler::handlePlayedTime(network::Packet& packet) {
PlayedTimeData data;
if (!PlayedTimeParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_PLAYED_TIME");
return;
}
if (data.triggerMessage) {
// Format total time played
uint32_t totalDays = data.totalTimePlayed / 86400;
uint32_t totalHours = (data.totalTimePlayed % 86400) / 3600;
uint32_t totalMinutes = (data.totalTimePlayed % 3600) / 60;
// Format level time played
uint32_t levelDays = data.levelTimePlayed / 86400;
uint32_t levelHours = (data.levelTimePlayed % 86400) / 3600;
uint32_t levelMinutes = (data.levelTimePlayed % 3600) / 60;
std::string totalMsg = "Total time played: ";
if (totalDays > 0) totalMsg += std::to_string(totalDays) + " days, ";
if (totalHours > 0 || totalDays > 0) totalMsg += std::to_string(totalHours) + " hours, ";
totalMsg += std::to_string(totalMinutes) + " minutes";
std::string levelMsg = "Time played this level: ";
if (levelDays > 0) levelMsg += std::to_string(levelDays) + " days, ";
if (levelHours > 0 || levelDays > 0) levelMsg += std::to_string(levelHours) + " hours, ";
levelMsg += std::to_string(levelMinutes) + " minutes";
addSystemChatMessage(totalMsg);
addSystemChatMessage(levelMsg);
}
LOG_INFO("Played time: total=", data.totalTimePlayed, "s, level=", data.levelTimePlayed, "s");
}
void GameHandler::handleWho(network::Packet& packet) {
// Parse WHO response
uint32_t displayCount = packet.readUInt32();
uint32_t onlineCount = packet.readUInt32();
LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online");
if (displayCount == 0) {
addSystemChatMessage("No players found.");
return;
}
addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:");
for (uint32_t i = 0; i < displayCount; ++i) {
std::string playerName = packet.readString();
std::string guildName = packet.readString();
uint32_t level = packet.readUInt32();
uint32_t classId = packet.readUInt32();
uint32_t raceId = packet.readUInt32();
packet.readUInt8(); // gender (unused)
packet.readUInt32(); // zoneId (unused)
std::string msg = " " + playerName;
if (!guildName.empty()) {
msg += " <" + guildName + ">";
}
msg += " - Level " + std::to_string(level);
addSystemChatMessage(msg);
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId);
}
}
void GameHandler::handleFriendStatus(network::Packet& packet) {
FriendStatusData data;
if (!FriendStatusParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_FRIEND_STATUS");
return;
}
// Look up player name from GUID
std::string playerName;
auto it = playerNameCache.find(data.guid);
if (it != playerNameCache.end()) {
playerName = it->second;
} else {
playerName = "Unknown";
}
// Update friends cache
if (data.status == 1 || data.status == 2) { // Added or online
friendsCache[playerName] = data.guid;
} else if (data.status == 0) { // Removed
friendsCache.erase(playerName);
}
// Status messages
switch (data.status) {
case 0:
addSystemChatMessage(playerName + " has been removed from your friends list.");
break;
case 1:
addSystemChatMessage(playerName + " has been added to your friends list.");
break;
case 2:
addSystemChatMessage(playerName + " is now online.");
break;
case 3:
addSystemChatMessage(playerName + " is now offline.");
break;
case 4:
addSystemChatMessage("Player not found.");
break;
case 5:
addSystemChatMessage(playerName + " is already in your friends list.");
break;
case 6:
addSystemChatMessage("Your friends list is full.");
break;
case 7:
addSystemChatMessage(playerName + " is ignoring you.");
break;
default:
LOG_INFO("Friend status: ", (int)data.status, " for ", playerName);
break;
}
LOG_INFO("Friend status update: ", playerName, " status=", (int)data.status);
}
void GameHandler::handleRandomRoll(network::Packet& packet) {
RandomRollData data;
if (!RandomRollParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_RANDOM_ROLL");
return;
}
// Get roller name
std::string rollerName;
if (data.rollerGuid == playerGuid) {
rollerName = "You";
} else {
auto it = playerNameCache.find(data.rollerGuid);
if (it != playerNameCache.end()) {
rollerName = it->second;
} else {
rollerName = "Someone";
}
}
// Build message
std::string msg = rollerName;
if (data.rollerGuid == playerGuid) {
msg += " roll ";
} else {
msg += " rolls ";
}
msg += std::to_string(data.result);
msg += " (" + std::to_string(data.minRoll) + "-" + std::to_string(data.maxRoll) + ")";
addSystemChatMessage(msg);
LOG_INFO("Random roll: ", rollerName, " rolled ", data.result, " (", data.minRoll, "-", data.maxRoll, ")");
}
void GameHandler::handleLogoutResponse(network::Packet& packet) {
LogoutResponseData data;
if (!LogoutResponseParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_LOGOUT_RESPONSE");
return;
}
if (data.result == 0) {
// Success - logout initiated
if (data.instant) {
addSystemChatMessage("Logging out...");
} else {
addSystemChatMessage("Logging out in 20 seconds...");
}
LOG_INFO("Logout response: success, instant=", (int)data.instant);
} else {
// Failure
addSystemChatMessage("Cannot logout right now.");
loggingOut_ = false;
LOG_WARNING("Logout failed, result=", data.result);
}
}
void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) {
addSystemChatMessage("Logout complete.");
loggingOut_ = false;
LOG_INFO("Logout complete");
// Server will disconnect us
}
uint32_t GameHandler::generateClientSeed() {
// Generate cryptographically random seed
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<uint32_t> dis(1, 0xFFFFFFFF);
return dis(gen);
}
void GameHandler::setState(WorldState newState) {
if (state != newState) {
LOG_DEBUG("World state: ", (int)state, " -> ", (int)newState);
state = newState;
}
}
void GameHandler::fail(const std::string& reason) {
LOG_ERROR("World connection failed: ", reason);
setState(WorldState::FAILED);
if (onFailure) {
onFailure(reason);
}
}
// ============================================================
// Player Skills
// ============================================================
static const std::string kEmptySkillName;
const std::string& GameHandler::getSkillName(uint32_t skillId) const {
auto it = skillLineNames_.find(skillId);
return (it != skillLineNames_.end()) ? it->second : kEmptySkillName;
}
uint32_t GameHandler::getSkillCategory(uint32_t skillId) const {
auto it = skillLineCategories_.find(skillId);
return (it != skillLineCategories_.end()) ? it->second : 0;
}
void GameHandler::loadSkillLineDbc() {
if (skillLineDbcLoaded_) return;
skillLineDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("SkillLine.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("GameHandler: Could not load SkillLine.dbc");
return;
}
const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
uint32_t id = dbc->getUInt32(i, slL ? (*slL)["ID"] : 0);
uint32_t category = dbc->getUInt32(i, slL ? (*slL)["Category"] : 1);
std::string name = dbc->getString(i, slL ? (*slL)["Name"] : 3);
if (id > 0 && !name.empty()) {
skillLineNames_[id] = name;
skillLineCategories_[id] = category;
}
}
LOG_INFO("GameHandler: Loaded ", skillLineNames_.size(), " skill line names");
}
void GameHandler::extractSkillFields(const std::map<uint16_t, uint32_t>& fields) {
loadSkillLineDbc();
const uint16_t PLAYER_SKILL_INFO_START = fieldIndex(UF::PLAYER_SKILL_INFO_START);
static constexpr int MAX_SKILL_SLOTS = 128;
std::map<uint32_t, PlayerSkill> newSkills;
for (int slot = 0; slot < MAX_SKILL_SLOTS; slot++) {
uint16_t baseField = PLAYER_SKILL_INFO_START + slot * 3;
auto idIt = fields.find(baseField);
if (idIt == fields.end()) continue;
uint32_t raw0 = idIt->second;
uint16_t skillId = raw0 & 0xFFFF;
if (skillId == 0) continue;
auto valIt = fields.find(baseField + 1);
if (valIt == fields.end()) continue;
uint32_t raw1 = valIt->second;
uint16_t value = raw1 & 0xFFFF;
uint16_t maxValue = (raw1 >> 16) & 0xFFFF;
PlayerSkill skill;
skill.skillId = skillId;
skill.value = value;
skill.maxValue = maxValue;
newSkills[skillId] = skill;
}
// Detect increases and emit chat messages
for (const auto& [skillId, skill] : newSkills) {
if (skill.value == 0) continue;
auto oldIt = playerSkills_.find(skillId);
if (oldIt != playerSkills_.end() && skill.value > oldIt->second.value) {
// Filter out racial, generic, and hidden skills from announcements
// Category 5 = Attributes (Defense, etc.)
// Category 10 = Languages (Orcish, Common, etc.)
// Category 12 = Not Displayed (generic/hidden)
auto catIt = skillLineCategories_.find(skillId);
if (catIt != skillLineCategories_.end()) {
uint32_t category = catIt->second;
if (category == 5 || category == 10 || category == 12) {
continue; // Skip announcement for racial/generic skills
}
}
const std::string& name = getSkillName(skillId);
std::string skillName = name.empty() ? ("Skill #" + std::to_string(skillId)) : name;
addSystemChatMessage("Your skill in " + skillName + " has increased to " + std::to_string(skill.value) + ".");
}
}
playerSkills_ = std::move(newSkills);
}
void GameHandler::extractExploredZoneFields(const std::map<uint16_t, uint32_t>& fields) {
if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) {
playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u);
}
bool foundAny = false;
for (size_t i = 0; i < PLAYER_EXPLORED_ZONES_COUNT; i++) {
const uint16_t fieldIdx = static_cast<uint16_t>(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i);
auto it = fields.find(fieldIdx);
if (it == fields.end()) continue;
playerExploredZones_[i] = it->second;
foundAny = true;
}
if (foundAny) {
hasPlayerExploredZones_ = true;
}
}
std::string GameHandler::getCharacterConfigDir() {
std::string dir;
#ifdef _WIN32
const char* appdata = std::getenv("APPDATA");
dir = appdata ? std::string(appdata) + "\\wowee\\characters" : "characters";
#else
const char* home = std::getenv("HOME");
dir = home ? std::string(home) + "/.wowee/characters" : "characters";
#endif
return dir;
}
void GameHandler::saveCharacterConfig() {
const Character* ch = getActiveCharacter();
if (!ch || ch->name.empty()) return;
std::string dir = getCharacterConfigDir();
std::error_code ec;
std::filesystem::create_directories(dir, ec);
std::string path = dir + "/" + ch->name + ".cfg";
std::ofstream out(path);
if (!out.is_open()) {
LOG_WARNING("Could not save character config to ", path);
return;
}
out << "character_guid=" << playerGuid << "\n";
out << "gender=" << static_cast<int>(ch->gender) << "\n";
// For male/female, derive from gender; only nonbinary has a meaningful separate choice
bool saveUseFemaleModel = (ch->gender == Gender::NONBINARY) ? ch->useFemaleModel
: (ch->gender == Gender::FEMALE);
out << "use_female_model=" << (saveUseFemaleModel ? 1 : 0) << "\n";
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
out << "action_bar_" << i << "_type=" << static_cast<int>(actionBar[i].type) << "\n";
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
}
// Save quest log
out << "quest_log_count=" << questLog_.size() << "\n";
for (size_t i = 0; i < questLog_.size(); i++) {
const auto& quest = questLog_[i];
out << "quest_" << i << "_id=" << quest.questId << "\n";
out << "quest_" << i << "_title=" << quest.title << "\n";
out << "quest_" << i << "_complete=" << (quest.complete ? 1 : 0) << "\n";
}
LOG_INFO("Character config saved to ", path);
}
void GameHandler::loadCharacterConfig() {
const Character* ch = getActiveCharacter();
if (!ch || ch->name.empty()) return;
std::string path = getCharacterConfigDir() + "/" + ch->name + ".cfg";
std::ifstream in(path);
if (!in.is_open()) return;
uint64_t savedGuid = 0;
std::array<int, ACTION_BAR_SLOTS> types{};
std::array<uint32_t, ACTION_BAR_SLOTS> ids{};
bool hasSlots = false;
int savedGender = -1;
int savedUseFemaleModel = -1;
std::string line;
while (std::getline(in, line)) {
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
if (key == "character_guid") {
try { savedGuid = std::stoull(val); } catch (...) {}
} else if (key == "gender") {
try { savedGender = std::stoi(val); } catch (...) {}
} else if (key == "use_female_model") {
try { savedUseFemaleModel = std::stoi(val); } catch (...) {}
} else if (key.rfind("action_bar_", 0) == 0) {
// Parse action_bar_N_type or action_bar_N_id
size_t firstUnderscore = 11; // length of "action_bar_"
size_t secondUnderscore = key.find('_', firstUnderscore);
if (secondUnderscore == std::string::npos) continue;
int slot = -1;
try { slot = std::stoi(key.substr(firstUnderscore, secondUnderscore - firstUnderscore)); } catch (...) { continue; }
if (slot < 0 || slot >= ACTION_BAR_SLOTS) continue;
std::string suffix = key.substr(secondUnderscore + 1);
try {
if (suffix == "type") {
types[slot] = std::stoi(val);
hasSlots = true;
} else if (suffix == "id") {
ids[slot] = static_cast<uint32_t>(std::stoul(val));
hasSlots = true;
}
} catch (...) {}
}
}
// Validate guid matches current character
if (savedGuid != 0 && savedGuid != playerGuid) {
LOG_WARNING("Character config guid mismatch for ", ch->name, ", using defaults");
return;
}
// Apply saved gender and body type (allows nonbinary to persist even though server only stores male/female)
if (savedGender >= 0 && savedGender <= 2) {
for (auto& character : characters) {
if (character.guid == playerGuid) {
character.gender = static_cast<Gender>(savedGender);
if (character.gender == Gender::NONBINARY) {
// Only nonbinary characters have a meaningful body type choice
if (savedUseFemaleModel >= 0) {
character.useFemaleModel = (savedUseFemaleModel != 0);
}
} else {
// Male/female always use the model matching their gender
character.useFemaleModel = (character.gender == Gender::FEMALE);
}
LOG_INFO("Applied saved gender: ", getGenderName(character.gender),
", body type: ", (character.useFemaleModel ? "feminine" : "masculine"));
break;
}
}
}
if (hasSlots) {
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
actionBar[i].type = static_cast<ActionBarSlot::Type>(types[i]);
actionBar[i].id = ids[i];
}
LOG_INFO("Character config loaded from ", path);
}
}
void GameHandler::setTransportAttachment(uint64_t childGuid, ObjectType type, uint64_t transportGuid,
const glm::vec3& localOffset, bool hasLocalOrientation,
float localOrientation) {
if (childGuid == 0 || transportGuid == 0) {
return;
}
TransportAttachment& attachment = transportAttachments_[childGuid];
attachment.type = type;
attachment.transportGuid = transportGuid;
attachment.localOffset = localOffset;
attachment.hasLocalOrientation = hasLocalOrientation;
attachment.localOrientation = localOrientation;
}
void GameHandler::clearTransportAttachment(uint64_t childGuid) {
if (childGuid == 0) {
return;
}
transportAttachments_.erase(childGuid);
}
void GameHandler::updateAttachedTransportChildren(float /*deltaTime*/) {
if (!transportManager_ || transportAttachments_.empty()) {
return;
}
constexpr float kPosEpsilonSq = 0.0001f;
constexpr float kOriEpsilon = 0.001f;
std::vector<uint64_t> stale;
stale.reserve(8);
for (const auto& [childGuid, attachment] : transportAttachments_) {
auto entity = entityManager.getEntity(childGuid);
if (!entity) {
stale.push_back(childGuid);
continue;
}
ActiveTransport* transport = transportManager_->getTransport(attachment.transportGuid);
if (!transport) {
continue;
}
glm::vec3 composed = transportManager_->getPlayerWorldPosition(
attachment.transportGuid, attachment.localOffset);
float composedOrientation = entity->getOrientation();
if (attachment.hasLocalOrientation) {
float baseYaw = transport->hasServerYaw ? transport->serverYaw : 0.0f;
composedOrientation = baseYaw + attachment.localOrientation;
}
glm::vec3 oldPos(entity->getX(), entity->getY(), entity->getZ());
float oldOrientation = entity->getOrientation();
glm::vec3 delta = composed - oldPos;
const bool positionChanged = glm::dot(delta, delta) > kPosEpsilonSq;
const bool orientationChanged = std::abs(composedOrientation - oldOrientation) > kOriEpsilon;
if (!positionChanged && !orientationChanged) {
continue;
}
entity->setPosition(composed.x, composed.y, composed.z, composedOrientation);
if (attachment.type == ObjectType::UNIT) {
if (creatureMoveCallback_) {
creatureMoveCallback_(childGuid, composed.x, composed.y, composed.z, 0);
}
} else if (attachment.type == ObjectType::GAMEOBJECT) {
if (gameObjectMoveCallback_) {
gameObjectMoveCallback_(childGuid, composed.x, composed.y, composed.z, composedOrientation);
}
}
}
for (uint64_t guid : stale) {
transportAttachments_.erase(guid);
}
}
// ============================================================
// Mail System
// ============================================================
void GameHandler::closeMailbox() {
mailboxOpen_ = false;
mailboxGuid_ = 0;
mailInbox_.clear();
selectedMailIndex_ = -1;
showMailCompose_ = false;
}
void GameHandler::refreshMailList() {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = GetMailListPacket::build(mailboxGuid_);
socket->send(packet);
}
void GameHandler::sendMail(const std::string& recipient, const std::string& subject,
const std::string& body, uint32_t money, uint32_t cod) {
if (state != WorldState::IN_WORLD) {
LOG_WARNING("sendMail: not in world");
return;
}
if (!socket) {
LOG_WARNING("sendMail: no socket");
return;
}
if (mailboxGuid_ == 0) {
LOG_WARNING("sendMail: mailboxGuid_ is 0 (mailbox closed?)");
return;
}
// Collect attached item GUIDs
std::vector<uint64_t> itemGuids;
for (const auto& att : mailAttachments_) {
if (att.occupied()) {
itemGuids.push_back(att.itemGuid);
}
}
auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod, itemGuids);
LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money,
" attachments=", itemGuids.size(), " mailboxGuid=", mailboxGuid_);
socket->send(packet);
clearMailAttachments();
}
bool GameHandler::attachItemFromBackpack(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return false;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return false;
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid == 0) {
addSystemChatMessage("Cannot attach: item not found.");
return false;
}
// Check not already attached
for (const auto& att : mailAttachments_) {
if (att.occupied() && att.itemGuid == itemGuid) return false;
}
// Find free attachment slot
for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) {
if (!mailAttachments_[i].occupied()) {
mailAttachments_[i].itemGuid = itemGuid;
mailAttachments_[i].item = slot.item;
mailAttachments_[i].srcBag = 0xFF;
mailAttachments_[i].srcSlot = static_cast<uint8_t>(23 + backpackIndex);
LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x",
std::hex, itemGuid, std::dec, " from backpack[", backpackIndex, "]");
return true;
}
}
addSystemChatMessage("Cannot attach: all attachment slots full.");
return false;
}
bool GameHandler::attachItemFromBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return false;
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return false;
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
if (slot.empty()) return false;
uint64_t itemGuid = 0;
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
if (bagGuid != 0) {
auto it = containerContents_.find(bagGuid);
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
itemGuid = it->second.slotGuids[slotIndex];
}
}
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid == 0) {
addSystemChatMessage("Cannot attach: item not found.");
return false;
}
for (const auto& att : mailAttachments_) {
if (att.occupied() && att.itemGuid == itemGuid) return false;
}
for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) {
if (!mailAttachments_[i].occupied()) {
mailAttachments_[i].itemGuid = itemGuid;
mailAttachments_[i].item = slot.item;
mailAttachments_[i].srcBag = static_cast<uint8_t>(19 + bagIndex);
mailAttachments_[i].srcSlot = static_cast<uint8_t>(slotIndex);
LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x",
std::hex, itemGuid, std::dec, " from bag[", bagIndex, "][", slotIndex, "]");
return true;
}
}
addSystemChatMessage("Cannot attach: all attachment slots full.");
return false;
}
bool GameHandler::detachMailAttachment(int attachIndex) {
if (attachIndex < 0 || attachIndex >= MAIL_MAX_ATTACHMENTS) return false;
if (!mailAttachments_[attachIndex].occupied()) return false;
LOG_INFO("Mail detach: slot=", attachIndex, " item='", mailAttachments_[attachIndex].item.name, "'");
mailAttachments_[attachIndex] = MailAttachSlot{};
return true;
}
void GameHandler::clearMailAttachments() {
for (auto& att : mailAttachments_) att = MailAttachSlot{};
}
int GameHandler::getMailAttachmentCount() const {
int count = 0;
for (const auto& att : mailAttachments_) {
if (att.occupied()) ++count;
}
return count;
}
void GameHandler::mailTakeMoney(uint32_t mailId) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = MailTakeMoneyPacket::build(mailboxGuid_, mailId);
socket->send(packet);
}
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemIndex) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemIndex);
socket->send(packet);
}
void GameHandler::mailDelete(uint32_t mailId) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
// Find mail template ID for this mail
uint32_t templateId = 0;
for (const auto& m : mailInbox_) {
if (m.messageId == mailId) {
templateId = m.mailTemplateId;
break;
}
}
auto packet = packetParsers_->buildMailDelete(mailboxGuid_, mailId, templateId);
socket->send(packet);
}
void GameHandler::mailMarkAsRead(uint32_t mailId) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = MailMarkAsReadPacket::build(mailboxGuid_, mailId);
socket->send(packet);
}
void GameHandler::handleShowMailbox(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("SMSG_SHOW_MAILBOX too short");
return;
}
uint64_t guid = packet.readUInt64();
LOG_INFO("SMSG_SHOW_MAILBOX: guid=0x", std::hex, guid, std::dec);
mailboxGuid_ = guid;
mailboxOpen_ = true;
hasNewMail_ = false;
selectedMailIndex_ = -1;
showMailCompose_ = false;
// Request inbox contents
refreshMailList();
}
void GameHandler::handleMailListResult(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 1) {
LOG_WARNING("SMSG_MAIL_LIST_RESULT too short (", remaining, " bytes)");
return;
}
// Delegate parsing to expansion-aware packet parser
packetParsers_->parseMailList(packet, mailInbox_);
// Resolve sender names (needs GameHandler context, so done here)
for (auto& msg : mailInbox_) {
if (msg.messageType == 0 && msg.senderGuid != 0) {
msg.senderName = getCachedPlayerName(msg.senderGuid);
if (msg.senderName.empty()) {
queryPlayerName(msg.senderGuid);
msg.senderName = "Unknown";
}
} else if (msg.messageType == 2) {
msg.senderName = "Auction House";
} else if (msg.messageType == 3) {
msg.senderName = getCachedCreatureName(msg.senderEntry);
if (msg.senderName.empty()) msg.senderName = "NPC";
} else {
msg.senderName = "System";
}
}
// Open the mailbox UI if it isn't already open (Vanilla has no SMSG_SHOW_MAILBOX).
if (!mailboxOpen_) {
LOG_INFO("Opening mailbox UI (triggered by SMSG_MAIL_LIST_RESULT)");
mailboxOpen_ = true;
hasNewMail_ = false;
selectedMailIndex_ = -1;
showMailCompose_ = false;
}
}
void GameHandler::handleSendMailResult(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 12) {
LOG_WARNING("SMSG_SEND_MAIL_RESULT too short");
return;
}
uint32_t mailId = packet.readUInt32();
uint32_t command = packet.readUInt32();
uint32_t error = packet.readUInt32();
// Commands: 0=send, 1=moneyTaken, 2=itemTaken, 3=returnedToSender, 4=deleted, 5=madePermanent
// Vanilla errors: 0=OK, 1=equipError, 2=cannotSendToSelf, 3=notEnoughMoney, 4=recipientNotFound, 5=notYourTeam, 6=internalError
static const char* cmdNames[] = {"Send", "TakeMoney", "TakeItem", "Return", "Delete", "MadePermanent"};
const char* cmdName = (command < 6) ? cmdNames[command] : "Unknown";
LOG_INFO("SMSG_SEND_MAIL_RESULT: mailId=", mailId, " cmd=", cmdName, " error=", error);
if (error == 0) {
// Success
switch (command) {
case 0: // Send
addSystemChatMessage("Mail sent successfully.");
showMailCompose_ = false;
refreshMailList();
break;
case 1: // Money taken
addSystemChatMessage("Money received from mail.");
refreshMailList();
break;
case 2: // Item taken
addSystemChatMessage("Item received from mail.");
refreshMailList();
break;
case 4: // Deleted
selectedMailIndex_ = -1;
refreshMailList();
break;
default:
refreshMailList();
break;
}
} else {
// Error
std::string errMsg = "Mail error: ";
switch (error) {
case 1: errMsg += "Equipment error."; break;
case 2: errMsg += "You cannot send mail to yourself."; break;
case 3: errMsg += "Not enough money."; break;
case 4: errMsg += "Recipient not found."; break;
case 5: errMsg += "Cannot send to the opposing faction."; break;
case 6: errMsg += "Internal mail error."; break;
case 14: errMsg += "Disabled for trial accounts."; break;
case 15: errMsg += "Recipient's mailbox is full."; break;
case 16: errMsg += "Cannot send wrapped items COD."; break;
case 17: errMsg += "Mail and chat suspended."; break;
case 18: errMsg += "Too many attachments."; break;
case 19: errMsg += "Invalid attachment."; break;
default: errMsg += "Unknown error (" + std::to_string(error) + ")."; break;
}
addSystemChatMessage(errMsg);
}
}
void GameHandler::handleReceivedMail(network::Packet& packet) {
// Server notifies us that new mail arrived
if (packet.getSize() - packet.getReadPos() >= 4) {
float nextMailTime = packet.readFloat();
(void)nextMailTime;
}
LOG_INFO("SMSG_RECEIVED_MAIL: New mail arrived!");
hasNewMail_ = true;
addSystemChatMessage("New mail has arrived.");
// If mailbox is open, refresh
if (mailboxOpen_) {
refreshMailList();
}
}
void GameHandler::handleQueryNextMailTime(network::Packet& packet) {
// Server response to MSG_QUERY_NEXT_MAIL_TIME
// If there's pending mail, the packet contains a float with time until next mail delivery
// A value of 0.0 or the presence of mail entries means there IS mail waiting
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining >= 4) {
float nextMailTime = packet.readFloat();
// In Vanilla: 0x00000000 = has mail, 0xC7A8C000 (big negative) = no mail
uint32_t rawValue;
std::memcpy(&rawValue, &nextMailTime, sizeof(uint32_t));
if (rawValue == 0 || nextMailTime >= 0.0f) {
hasNewMail_ = true;
LOG_INFO("MSG_QUERY_NEXT_MAIL_TIME: Player has pending mail");
} else {
LOG_INFO("MSG_QUERY_NEXT_MAIL_TIME: No pending mail (value=", nextMailTime, ")");
}
}
}
glm::vec3 GameHandler::getComposedWorldPosition() {
if (playerTransportGuid_ != 0 && transportManager_) {
return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
}
// Not on transport, return normal movement position
return glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z);
}
// ============================================================
// Bank System
// ============================================================
void GameHandler::openBank(uint64_t guid) {
if (!isConnected()) return;
auto pkt = BankerActivatePacket::build(guid);
socket->send(pkt);
}
void GameHandler::closeBank() {
bankOpen_ = false;
bankerGuid_ = 0;
}
void GameHandler::buyBankSlot() {
if (!isConnected() || !bankOpen_) {
LOG_WARNING("buyBankSlot: not connected or bank not open");
return;
}
LOG_WARNING("buyBankSlot: sending CMSG_BUY_BANK_SLOT banker=0x", std::hex, bankerGuid_, std::dec,
" purchased=", static_cast<int>(inventory.getPurchasedBankBagSlots()));
auto pkt = BuyBankSlotPacket::build(bankerGuid_);
socket->send(pkt);
}
void GameHandler::depositItem(uint8_t srcBag, uint8_t srcSlot) {
if (!isConnected() || !bankOpen_) return;
auto pkt = AutoBankItemPacket::build(srcBag, srcSlot);
socket->send(pkt);
}
void GameHandler::withdrawItem(uint8_t srcBag, uint8_t srcSlot) {
if (!isConnected() || !bankOpen_) return;
auto pkt = AutoStoreBankItemPacket::build(srcBag, srcSlot);
socket->send(pkt);
}
void GameHandler::handleShowBank(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) return;
bankerGuid_ = packet.readUInt64();
bankOpen_ = true;
gossipWindowOpen = false; // Close gossip when bank opens
// Bank items are already tracked via update fields (bank slot GUIDs)
// Trigger rebuild to populate bank slots in inventory
rebuildOnlineInventory();
// Count bank bags that actually have items/containers
int filledBags = 0;
for (int i = 0; i < effectiveBankBagSlots_; i++) {
if (inventory.getBankBagSize(i) > 0) filledBags++;
}
LOG_WARNING("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec,
" purchased=", static_cast<int>(inventory.getPurchasedBankBagSlots()),
" filledBags=", filledBags,
" effectiveBankBagSlots=", effectiveBankBagSlots_);
}
void GameHandler::handleBuyBankSlotResult(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t result = packet.readUInt32();
LOG_WARNING("SMSG_BUY_BANK_SLOT_RESULT: result=", result);
// AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK
if (result == 3) {
addSystemChatMessage("Bank slot purchased.");
inventory.setPurchasedBankBagSlots(inventory.getPurchasedBankBagSlots() + 1);
} else if (result == 1) {
addSystemChatMessage("Not enough gold to purchase bank slot.");
} else if (result == 0) {
addSystemChatMessage("No more bank slots available.");
} else if (result == 2) {
addSystemChatMessage("You must be at a banker to purchase bank slots.");
} else {
addSystemChatMessage("Cannot purchase bank slot (error " + std::to_string(result) + ").");
}
}
// ============================================================
// Guild Bank System
// ============================================================
void GameHandler::openGuildBank(uint64_t guid) {
if (!isConnected()) return;
auto pkt = GuildBankerActivatePacket::build(guid);
socket->send(pkt);
}
void GameHandler::closeGuildBank() {
guildBankOpen_ = false;
guildBankerGuid_ = 0;
}
void GameHandler::queryGuildBankTab(uint8_t tabId) {
if (!isConnected() || !guildBankOpen_) return;
guildBankActiveTab_ = tabId;
auto pkt = GuildBankQueryTabPacket::build(guildBankerGuid_, tabId, true);
socket->send(pkt);
}
void GameHandler::buyGuildBankTab() {
if (!isConnected() || !guildBankOpen_) return;
uint8_t nextTab = static_cast<uint8_t>(guildBankData_.tabs.size());
auto pkt = GuildBankBuyTabPacket::build(guildBankerGuid_, nextTab);
socket->send(pkt);
}
void GameHandler::depositGuildBankMoney(uint32_t amount) {
if (!isConnected() || !guildBankOpen_) return;
auto pkt = GuildBankDepositMoneyPacket::build(guildBankerGuid_, amount);
socket->send(pkt);
}
void GameHandler::withdrawGuildBankMoney(uint32_t amount) {
if (!isConnected() || !guildBankOpen_) return;
auto pkt = GuildBankWithdrawMoneyPacket::build(guildBankerGuid_, amount);
socket->send(pkt);
}
void GameHandler::guildBankWithdrawItem(uint8_t tabId, uint8_t bankSlot, uint8_t destBag, uint8_t destSlot) {
if (!isConnected() || !guildBankOpen_) return;
auto pkt = GuildBankSwapItemsPacket::buildBankToInventory(guildBankerGuid_, tabId, bankSlot, destBag, destSlot);
socket->send(pkt);
}
void GameHandler::guildBankDepositItem(uint8_t tabId, uint8_t bankSlot, uint8_t srcBag, uint8_t srcSlot) {
if (!isConnected() || !guildBankOpen_) return;
auto pkt = GuildBankSwapItemsPacket::buildInventoryToBank(guildBankerGuid_, tabId, bankSlot, srcBag, srcSlot);
socket->send(pkt);
}
void GameHandler::handleGuildBankList(network::Packet& packet) {
GuildBankData data;
if (!GuildBankListParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_GUILD_BANK_LIST");
return;
}
guildBankData_ = data;
guildBankOpen_ = true;
guildBankActiveTab_ = data.tabId;
// Ensure item info for all guild bank items
for (const auto& item : data.tabItems) {
if (item.itemEntry != 0) ensureItemInfo(item.itemEntry);
}
LOG_INFO("SMSG_GUILD_BANK_LIST: tab=", (int)data.tabId,
" items=", data.tabItems.size(),
" tabs=", data.tabs.size(),
" money=", data.money);
}
// ============================================================
// Auction House System
// ============================================================
void GameHandler::openAuctionHouse(uint64_t guid) {
if (!isConnected()) return;
auto pkt = AuctionHelloPacket::build(guid);
socket->send(pkt);
}
void GameHandler::closeAuctionHouse() {
auctionOpen_ = false;
auctioneerGuid_ = 0;
}
void GameHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8_t levelMax,
uint32_t quality, uint32_t itemClass, uint32_t itemSubClass,
uint32_t invTypeMask, uint8_t usableOnly, uint32_t offset)
{
if (!isConnected() || !auctionOpen_) return;
if (auctionSearchDelayTimer_ > 0.0f) {
addSystemChatMessage("Please wait before searching again.");
return;
}
// Save search params for pagination and auto-refresh
lastAuctionSearch_ = {name, levelMin, levelMax, quality, itemClass, itemSubClass, invTypeMask, usableOnly, offset};
pendingAuctionTarget_ = AuctionResultTarget::BROWSE;
auto pkt = AuctionListItemsPacket::build(auctioneerGuid_, offset, name,
levelMin, levelMax, invTypeMask,
itemClass, itemSubClass, quality, usableOnly, 0);
socket->send(pkt);
}
void GameHandler::auctionSellItem(uint64_t itemGuid, uint32_t stackCount,
uint32_t bid, uint32_t buyout, uint32_t duration)
{
if (!isConnected() || !auctionOpen_) return;
auto pkt = AuctionSellItemPacket::build(auctioneerGuid_, itemGuid, stackCount, bid, buyout, duration);
socket->send(pkt);
}
void GameHandler::auctionPlaceBid(uint32_t auctionId, uint32_t amount) {
if (!isConnected() || !auctionOpen_) return;
auto pkt = AuctionPlaceBidPacket::build(auctioneerGuid_, auctionId, amount);
socket->send(pkt);
}
void GameHandler::auctionBuyout(uint32_t auctionId, uint32_t buyoutPrice) {
auctionPlaceBid(auctionId, buyoutPrice);
}
void GameHandler::auctionCancelItem(uint32_t auctionId) {
if (!isConnected() || !auctionOpen_) return;
auto pkt = AuctionRemoveItemPacket::build(auctioneerGuid_, auctionId);
socket->send(pkt);
}
void GameHandler::auctionListOwnerItems(uint32_t offset) {
if (!isConnected() || !auctionOpen_) return;
pendingAuctionTarget_ = AuctionResultTarget::OWNER;
auto pkt = AuctionListOwnerItemsPacket::build(auctioneerGuid_, offset);
socket->send(pkt);
}
void GameHandler::auctionListBidderItems(uint32_t offset) {
if (!isConnected() || !auctionOpen_) return;
pendingAuctionTarget_ = AuctionResultTarget::BIDDER;
auto pkt = AuctionListBidderItemsPacket::build(auctioneerGuid_, offset);
socket->send(pkt);
}
void GameHandler::handleAuctionHello(network::Packet& packet) {
size_t pktSize = packet.getSize();
size_t readPos = packet.getReadPos();
LOG_INFO("handleAuctionHello: packetSize=", pktSize, " readPos=", readPos);
// Hex dump first 20 bytes for debugging
const auto& rawData = packet.getData();
std::string hex;
size_t dumpLen = std::min<size_t>(rawData.size(), 20);
for (size_t i = 0; i < dumpLen; ++i) {
char b[4]; snprintf(b, sizeof(b), "%02x ", rawData[i]);
hex += b;
}
LOG_INFO(" hex dump: ", hex);
AuctionHelloData data;
if (!AuctionHelloParser::parse(packet, data)) {
LOG_WARNING("Failed to parse MSG_AUCTION_HELLO response, size=", pktSize, " readPos=", readPos);
return;
}
auctioneerGuid_ = data.auctioneerGuid;
auctionHouseId_ = data.auctionHouseId;
auctionOpen_ = true;
gossipWindowOpen = false; // Close gossip when auction house opens
auctionActiveTab_ = 0;
auctionBrowseResults_ = AuctionListResult{};
auctionOwnerResults_ = AuctionListResult{};
auctionBidderResults_ = AuctionListResult{};
LOG_INFO("MSG_AUCTION_HELLO: auctioneer=0x", std::hex, data.auctioneerGuid, std::dec,
" house=", data.auctionHouseId, " enabled=", (int)data.enabled);
}
void GameHandler::handleAuctionListResult(network::Packet& packet) {
AuctionListResult result;
if (!AuctionListResultParser::parse(packet, result)) {
LOG_WARNING("Failed to parse SMSG_AUCTION_LIST_RESULT");
return;
}
auctionBrowseResults_ = result;
auctionSearchDelayTimer_ = result.searchDelay / 1000.0f;
// Ensure item info for all auction items
for (const auto& entry : result.auctions) {
if (entry.itemEntry != 0) ensureItemInfo(entry.itemEntry);
}
LOG_INFO("SMSG_AUCTION_LIST_RESULT: ", result.auctions.size(), " items, total=", result.totalCount);
}
void GameHandler::handleAuctionOwnerListResult(network::Packet& packet) {
AuctionListResult result;
if (!AuctionListResultParser::parse(packet, result)) {
LOG_WARNING("Failed to parse SMSG_AUCTION_OWNER_LIST_RESULT");
return;
}
auctionOwnerResults_ = result;
for (const auto& entry : result.auctions) {
if (entry.itemEntry != 0) ensureItemInfo(entry.itemEntry);
}
LOG_INFO("SMSG_AUCTION_OWNER_LIST_RESULT: ", result.auctions.size(), " items");
}
void GameHandler::handleAuctionBidderListResult(network::Packet& packet) {
AuctionListResult result;
if (!AuctionListResultParser::parse(packet, result)) {
LOG_WARNING("Failed to parse SMSG_AUCTION_BIDDER_LIST_RESULT");
return;
}
auctionBidderResults_ = result;
for (const auto& entry : result.auctions) {
if (entry.itemEntry != 0) ensureItemInfo(entry.itemEntry);
}
LOG_INFO("SMSG_AUCTION_BIDDER_LIST_RESULT: ", result.auctions.size(), " items");
}
void GameHandler::handleAuctionCommandResult(network::Packet& packet) {
AuctionCommandResult result;
if (!AuctionCommandResultParser::parse(packet, result)) {
LOG_WARNING("Failed to parse SMSG_AUCTION_COMMAND_RESULT");
return;
}
const char* actions[] = {"Create", "Cancel", "Bid", "Buyout"};
const char* actionName = (result.action < 4) ? actions[result.action] : "Unknown";
if (result.errorCode == 0) {
std::string msg = std::string("Auction ") + actionName + " successful.";
addSystemChatMessage(msg);
// Refresh appropriate lists
if (result.action == 0) auctionListOwnerItems(); // create
else if (result.action == 1) auctionListOwnerItems(); // cancel
else if (result.action == 2 || result.action == 3) { // bid or buyout
auctionListBidderItems();
// Re-query browse results with the same filters the user last searched with
const auto& s = lastAuctionSearch_;
auctionSearch(s.name, s.levelMin, s.levelMax, s.quality,
s.itemClass, s.itemSubClass, s.invTypeMask, s.usableOnly, s.offset);
}
} else {
const char* errors[] = {"OK", "Inventory", "Not enough money", "Item not found",
"Higher bid", "Increment", "Not enough items",
"DB error", "Restricted account"};
const char* errName = (result.errorCode < 9) ? errors[result.errorCode] : "Unknown";
std::string msg = std::string("Auction ") + actionName + " failed: " + errName;
addSystemChatMessage(msg);
}
LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName,
" error=", result.errorCode);
}
// ---------------------------------------------------------------------------
// Item text (SMSG_ITEM_TEXT_QUERY_RESPONSE)
// uint64 itemGuid + uint8 isEmpty + string text (when !isEmpty)
// ---------------------------------------------------------------------------
void GameHandler::handleItemTextQueryResponse(network::Packet& packet) {
size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 9) return; // guid(8) + isEmpty(1)
/*uint64_t guid =*/ packet.readUInt64();
uint8_t isEmpty = packet.readUInt8();
if (!isEmpty) {
itemText_ = packet.readString();
itemTextOpen_= !itemText_.empty();
}
LOG_DEBUG("SMSG_ITEM_TEXT_QUERY_RESPONSE: isEmpty=", (int)isEmpty,
" len=", itemText_.size());
}
void GameHandler::queryItemText(uint64_t itemGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_ITEM_TEXT_QUERY));
pkt.writeUInt64(itemGuid);
socket->send(pkt);
LOG_DEBUG("CMSG_ITEM_TEXT_QUERY: guid=0x", std::hex, itemGuid, std::dec);
}
// ---------------------------------------------------------------------------
// SMSG_QUEST_CONFIRM_ACCEPT (shared quest from group member)
// uint32 questId + string questTitle + uint64 sharerGuid
// ---------------------------------------------------------------------------
void GameHandler::handleQuestConfirmAccept(network::Packet& packet) {
size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 4) return;
sharedQuestId_ = packet.readUInt32();
sharedQuestTitle_ = packet.readString();
if (packet.getSize() - packet.getReadPos() >= 8) {
sharedQuestSharerGuid_ = packet.readUInt64();
}
sharedQuestSharerName_.clear();
auto entity = entityManager.getEntity(sharedQuestSharerGuid_);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
sharedQuestSharerName_ = unit->getName();
}
if (sharedQuestSharerName_.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(sharedQuestSharerGuid_));
sharedQuestSharerName_ = tmp;
}
pendingSharedQuest_ = true;
addSystemChatMessage(sharedQuestSharerName_ + " has shared the quest \"" +
sharedQuestTitle_ + "\" with you.");
LOG_INFO("SMSG_QUEST_CONFIRM_ACCEPT: questId=", sharedQuestId_,
" title=", sharedQuestTitle_, " sharer=", sharedQuestSharerName_);
}
void GameHandler::acceptSharedQuest() {
if (!pendingSharedQuest_ || !socket) return;
pendingSharedQuest_ = false;
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_CONFIRM_ACCEPT));
pkt.writeUInt32(sharedQuestId_);
socket->send(pkt);
addSystemChatMessage("Accepted: " + sharedQuestTitle_);
}
void GameHandler::declineSharedQuest() {
pendingSharedQuest_ = false;
// No response packet needed — just dismiss the UI
}
// ---------------------------------------------------------------------------
// SMSG_SUMMON_REQUEST
// uint64 summonerGuid + uint32 zoneId + uint32 timeoutMs
// ---------------------------------------------------------------------------
void GameHandler::handleSummonRequest(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 16) return;
summonerGuid_ = packet.readUInt64();
/*uint32_t zoneId =*/ packet.readUInt32();
uint32_t timeoutMs = packet.readUInt32();
summonTimeoutSec_ = timeoutMs / 1000.0f;
pendingSummonRequest_= true;
summonerName_.clear();
auto entity = entityManager.getEntity(summonerGuid_);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
summonerName_ = unit->getName();
}
if (summonerName_.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(summonerGuid_));
summonerName_ = tmp;
}
addSystemChatMessage(summonerName_ + " is summoning you.");
LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_,
" timeout=", summonTimeoutSec_, "s");
}
void GameHandler::acceptSummon() {
if (!pendingSummonRequest_ || !socket) return;
pendingSummonRequest_ = false;
network::Packet pkt(wireOpcode(Opcode::CMSG_SUMMON_RESPONSE));
pkt.writeUInt8(1); // 1 = accept
socket->send(pkt);
addSystemChatMessage("Accepting summon...");
LOG_INFO("Accepted summon from ", summonerName_);
}
void GameHandler::declineSummon() {
if (!socket) return;
pendingSummonRequest_ = false;
network::Packet pkt(wireOpcode(Opcode::CMSG_SUMMON_RESPONSE));
pkt.writeUInt8(0); // 0 = decline
socket->send(pkt);
addSystemChatMessage("Summon declined.");
}
// ---------------------------------------------------------------------------
// Trade (SMSG_TRADE_STATUS / SMSG_TRADE_STATUS_EXTENDED)
// WotLK 3.3.5a status values:
// 0=busy, 1=begin_trade(+guid), 2=open_window, 3=cancelled, 4=accepted,
// 5=busy2, 6=no_target, 7=back_to_trade, 8=complete, 9=rejected,
// 10=too_far, 11=wrong_faction, 12=close_window, 13=ignore,
// 14-19=stun/dead/logout, 20=trial, 21=conjured_only
// ---------------------------------------------------------------------------
void GameHandler::handleTradeStatus(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t status = packet.readUInt32();
switch (status) {
case 1: { // BEGIN_TRADE — incoming request; read initiator GUID
if (packet.getSize() - packet.getReadPos() >= 8) {
tradePeerGuid_ = packet.readUInt64();
}
// Resolve name from entity list
tradePeerName_.clear();
auto entity = entityManager.getEntity(tradePeerGuid_);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
tradePeerName_ = unit->getName();
}
if (tradePeerName_.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(tradePeerGuid_));
tradePeerName_ = tmp;
}
tradeStatus_ = TradeStatus::PendingIncoming;
addSystemChatMessage(tradePeerName_ + " wants to trade with you.");
break;
}
case 2: // OPEN_WINDOW
tradeStatus_ = TradeStatus::Open;
addSystemChatMessage("Trade window opened.");
break;
case 3: // CANCELLED
case 9: // REJECTED
case 12: // CLOSE_WINDOW
tradeStatus_ = TradeStatus::None;
addSystemChatMessage("Trade cancelled.");
break;
case 4: // ACCEPTED (partner accepted)
tradeStatus_ = TradeStatus::Accepted;
addSystemChatMessage("Trade accepted. Awaiting other player...");
break;
case 8: // COMPLETE
tradeStatus_ = TradeStatus::Complete;
addSystemChatMessage("Trade complete!");
tradeStatus_ = TradeStatus::None; // reset after notification
break;
case 7: // BACK_TO_TRADE (unaccepted after a change)
tradeStatus_ = TradeStatus::Open;
addSystemChatMessage("Trade offer changed.");
break;
case 10: addSystemChatMessage("Trade target is too far away."); break;
case 11: addSystemChatMessage("Trade failed: wrong faction."); break;
case 13: addSystemChatMessage("Trade failed: player ignores you."); break;
case 14: addSystemChatMessage("Trade failed: you are stunned."); break;
case 15: addSystemChatMessage("Trade failed: target is stunned."); break;
case 16: addSystemChatMessage("Trade failed: you are dead."); break;
case 17: addSystemChatMessage("Trade failed: target is dead."); break;
case 20: addSystemChatMessage("Trial accounts cannot trade."); break;
default: break;
}
LOG_DEBUG("SMSG_TRADE_STATUS: status=", status);
}
void GameHandler::acceptTradeRequest() {
if (tradeStatus_ != TradeStatus::PendingIncoming || !socket) return;
tradeStatus_ = TradeStatus::Open;
socket->send(BeginTradePacket::build());
}
void GameHandler::declineTradeRequest() {
if (!socket) return;
tradeStatus_ = TradeStatus::None;
socket->send(CancelTradePacket::build());
}
void GameHandler::acceptTrade() {
if (tradeStatus_ != TradeStatus::Open || !socket) return;
tradeStatus_ = TradeStatus::Accepted;
socket->send(AcceptTradePacket::build());
}
void GameHandler::cancelTrade() {
if (!socket) return;
tradeStatus_ = TradeStatus::None;
socket->send(CancelTradePacket::build());
}
// ---------------------------------------------------------------------------
// Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL)
// ---------------------------------------------------------------------------
void GameHandler::handleLootRoll(network::Packet& packet) {
// uint64 objectGuid, uint32 slot, uint64 playerGuid,
// uint32 itemId, uint32 randomSuffix, uint32 randomPropId,
// uint8 rollNumber, uint8 rollType
size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 26) return; // minimum: 8+4+8+4+4+4+1+1 = 34, be lenient
uint64_t objectGuid = packet.readUInt64();
uint32_t slot = packet.readUInt32();
uint64_t rollerGuid = packet.readUInt64();
uint32_t itemId = packet.readUInt32();
/*uint32_t randSuffix =*/ packet.readUInt32();
/*uint32_t randProp =*/ packet.readUInt32();
uint8_t rollNum = packet.readUInt8();
uint8_t rollType = packet.readUInt8();
// rollType 128 = "waiting for this player to roll"
if (rollType == 128 && rollerGuid == playerGuid) {
// Server is asking us to roll; present the roll UI.
pendingLootRollActive_ = true;
pendingLootRoll_.objectGuid = objectGuid;
pendingLootRoll_.slot = slot;
pendingLootRoll_.itemId = itemId;
// Look up item name from cache
auto* info = getItemInfo(itemId);
pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId);
pendingLootRoll_.itemQuality = info ? static_cast<uint8_t>(info->quality) : 0;
LOG_INFO("SMSG_LOOT_ROLL: need/greed prompt for item=", itemId,
" (", pendingLootRoll_.itemName, ") slot=", slot);
return;
}
// Otherwise it's reporting another player's roll result
const char* rollNames[] = {"Need", "Greed", "Disenchant", "Pass"};
const char* rollName = (rollType < 4) ? rollNames[rollType] : "Pass";
std::string rollerName;
auto entity = entityManager.getEntity(rollerGuid);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
rollerName = unit->getName();
}
if (rollerName.empty()) rollerName = "Someone";
auto* info = getItemInfo(itemId);
std::string iName = info ? info->name : std::to_string(itemId);
char buf[256];
std::snprintf(buf, sizeof(buf), "%s rolls %s (%d) on [%s]",
rollerName.c_str(), rollName, static_cast<int>(rollNum), iName.c_str());
addSystemChatMessage(buf);
LOG_DEBUG("SMSG_LOOT_ROLL: ", rollerName, " rolled ", rollName,
" (", rollNum, ") on item ", itemId);
(void)objectGuid; (void)slot;
}
void GameHandler::handleLootRollWon(network::Packet& packet) {
size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 26) return;
/*uint64_t objectGuid =*/ packet.readUInt64();
/*uint32_t slot =*/ packet.readUInt32();
uint64_t winnerGuid = packet.readUInt64();
uint32_t itemId = packet.readUInt32();
/*uint32_t randSuffix =*/ packet.readUInt32();
/*uint32_t randProp =*/ packet.readUInt32();
uint8_t rollNum = packet.readUInt8();
uint8_t rollType = packet.readUInt8();
const char* rollNames[] = {"Need", "Greed", "Disenchant"};
const char* rollName = (rollType < 3) ? rollNames[rollType] : "Roll";
std::string winnerName;
auto entity = entityManager.getEntity(winnerGuid);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
winnerName = unit->getName();
}
if (winnerName.empty()) {
winnerName = (winnerGuid == playerGuid) ? "You" : "Someone";
}
auto* info = getItemInfo(itemId);
std::string iName = info ? info->name : std::to_string(itemId);
char buf[256];
std::snprintf(buf, sizeof(buf), "%s wins [%s] (%s %d)!",
winnerName.c_str(), iName.c_str(), rollName, static_cast<int>(rollNum));
addSystemChatMessage(buf);
// Clear pending roll if it was ours
if (pendingLootRollActive_ && winnerGuid == playerGuid) {
pendingLootRollActive_ = false;
}
LOG_INFO("SMSG_LOOT_ROLL_WON: winner=", winnerName, " item=", itemId,
" roll=", rollName, "(", rollNum, ")");
}
void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType) {
if (state != WorldState::IN_WORLD || !socket) return;
pendingLootRollActive_ = false;
network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_ROLL));
pkt.writeUInt64(objectGuid);
pkt.writeUInt32(slot);
pkt.writeUInt8(rollType);
socket->send(pkt);
const char* rollNames[] = {"Need", "Greed", "Disenchant", "Pass"};
const char* rName = (rollType < 3) ? rollNames[rollType] : "Pass";
LOG_INFO("CMSG_LOOT_ROLL: type=", rName, " item=", pendingLootRoll_.itemName);
}
// ---------------------------------------------------------------------------
// SMSG_ACHIEVEMENT_EARNED (WotLK 3.3.5a wire 0x4AB)
// uint64 guid — player who earned it (may be another player)
// uint32 achievementId — Achievement.dbc ID
// PackedTime date — uint32 bitfield (seconds since epoch)
// uint32 realmFirst — how many on realm also got it (0 = realm first)
// ---------------------------------------------------------------------------
void GameHandler::handleAchievementEarned(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 16) return; // guid(8) + id(4) + date(4)
uint64_t guid = packet.readUInt64();
uint32_t achievementId = packet.readUInt32();
/*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed
// Show chat notification
bool isSelf = (guid == playerGuid);
if (isSelf) {
char buf[128];
std::snprintf(buf, sizeof(buf),
"Achievement earned! (ID %u)", achievementId);
addSystemChatMessage(buf);
if (achievementEarnedCallback_) {
achievementEarnedCallback_(achievementId);
}
} else {
// Another player in the zone earned an achievement
std::string senderName;
auto entity = entityManager.getEntity(guid);
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
senderName = unit->getName();
}
if (senderName.empty()) {
char tmp[32];
std::snprintf(tmp, sizeof(tmp), "0x%llX",
static_cast<unsigned long long>(guid));
senderName = tmp;
}
char buf[256];
std::snprintf(buf, sizeof(buf),
"%s has earned an achievement! (ID %u)", senderName.c_str(), achievementId);
addSystemChatMessage(buf);
}
LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec,
" achievementId=", achievementId, " self=", isSelf);
}
} // namespace game
} // namespace wowee