#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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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& 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(b)); } return false; } std::string asciiLower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; } std::vector splitWowPath(const std::string& wowPath) { std::vector 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::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 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 data(static_cast(size)); in.read(reinterpret_cast(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(text.data()), static_cast(text.size()), out, &outLen); return outLen == SHA_DIGEST_LENGTH && std::memcmp(out, expected, SHA_DIGEST_LENGTH) == 0; } const std::unordered_map>& knownDoorHashes() { static const std::unordered_map> 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(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(std::min(s.size(), 32)); unsigned char first = static_cast(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& data, bool classicHint) { QuestQueryTextCandidate best; if (data.size() <= 9) return best; std::vector 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; } // Parse kill/item objectives from SMSG_QUEST_QUERY_RESPONSE raw data. // Returns true if the objective block was found and at least one entry read. // // Format after the fixed integer header (40*4 Classic or 55*4 WotLK bytes post questId+questMethod): // N strings (title, objectives, details, endText; + completedText for WotLK) // 4x { int32 npcOrGoId, uint32 count } -- entity (kill/interact) objectives // 6x { uint32 itemId, uint32 count } -- item collect objectives // 4x cstring -- per-objective display text // // We use the same fixed-offset heuristic as pickBestQuestQueryTexts and then scan past // the string section to reach the objective data. struct QuestQueryObjectives { struct Kill { int32_t npcOrGoId; uint32_t required; }; struct Item { uint32_t itemId; uint32_t required; }; std::array kills{}; std::array items{}; bool valid = false; }; static uint32_t readU32At(const std::vector& d, size_t pos) { return static_cast(d[pos]) | (static_cast(d[pos + 1]) << 8) | (static_cast(d[pos + 2]) << 16) | (static_cast(d[pos + 3]) << 24); } // Try to parse objective block starting at `startPos` with `nStrings` strings before it. // Returns a valid QuestQueryObjectives if the data looks plausible, otherwise invalid. static QuestQueryObjectives tryParseQuestObjectivesAt(const std::vector& data, size_t startPos, int nStrings) { QuestQueryObjectives out; size_t pos = startPos; // Scan past each string (null-terminated). for (int si = 0; si < nStrings; ++si) { while (pos < data.size() && data[pos] != 0) ++pos; if (pos >= data.size()) return out; // truncated ++pos; // consume null terminator } // Read 4 entity objectives: int32 npcOrGoId + uint32 count each. for (int i = 0; i < 4; ++i) { if (pos + 8 > data.size()) return out; out.kills[i].npcOrGoId = static_cast(readU32At(data, pos)); pos += 4; out.kills[i].required = readU32At(data, pos); pos += 4; } // Read 6 item objectives: uint32 itemId + uint32 count each. for (int i = 0; i < 6; ++i) { if (pos + 8 > data.size()) break; out.items[i].itemId = readU32At(data, pos); pos += 4; out.items[i].required = readU32At(data, pos); pos += 4; } out.valid = true; return out; } QuestQueryObjectives extractQuestQueryObjectives(const std::vector& data, bool classicHint) { if (data.size() < 16) return {}; // questId(4) + questMethod(4) prefix before the fixed integer header. const size_t base = 8; // Classic/TBC: 40 fixed uint32 fields + 4 strings before objectives. // WotLK: 55 fixed uint32 fields + 5 strings before objectives. const size_t classicStart = base + 40u * 4u; const size_t wotlkStart = base + 55u * 4u; // Try the expected layout first, then fall back to the other. if (classicHint) { auto r = tryParseQuestObjectivesAt(data, classicStart, 4); if (r.valid) return r; return tryParseQuestObjectivesAt(data, wotlkStart, 5); } else { auto r = tryParseQuestObjectivesAt(data, wotlkStart, 5); if (r.valid) return r; return tryParseQuestObjectivesAt(data, classicStart, 4); } } } // 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(); // Initialize transport manager transportManager_ = std::make_unique(); // Initialize Warden module manager wardenModuleManager_ = std::make_unique(); // 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 parsers) { packetParsers_ = std::move(parsers); } bool GameHandler::connect(const std::string& host, uint16_t port, const std::vector& 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(); // 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(); friendGuids_.clear(); contacts_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); // Clear in-flight query sets so reconnect can re-issue queries for any // entries whose responses were lost during the disconnect. pendingCreatureQueries.clear(); pendingGameObjectQueries_.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(); // Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects. entityManager.clear(); 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(); // Clear the AssetManager DBC file cache so that expansion-specific DBCs // (CharSections, ItemDisplayInfo, etc.) are reloaded from the new expansion's // MPQ files instead of returning stale data from a previous session/expansion. auto* am = core::Application::getInstance().getAssetManager(); if (am) { am->clearDBCCache(); } 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( 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; } } // Periodically re-query names for players whose initial CMSG_NAME_QUERY was // lost (server didn't respond) or whose entity was recreated while the query // was still pending. Runs every 5 seconds to keep overhead minimal. if (state == WorldState::IN_WORLD && socket) { static float nameResyncTimer = 0.0f; nameResyncTimer += deltaTime; if (nameResyncTimer >= 5.0f) { nameResyncTimer = 0.0f; for (const auto& [guid, entity] : entityManager.getEntities()) { if (!entity || entity->getType() != ObjectType::PLAYER) continue; if (guid == playerGuid) continue; auto player = std::static_pointer_cast(entity); if (!player->getName().empty()) continue; if (playerNameCache.count(guid)) continue; if (pendingNameQueries.count(guid)) continue; // Player entity exists with empty name and no pending query — resend. LOG_DEBUG("Name resync: re-querying guid=0x", std::hex, guid, std::dec); pendingNameQueries.insert(guid); auto pkt = NameQueryPacket::build(guid); socket->send(pkt); } } } 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; } } // Tick down all tracked unit cast bars for (auto it = unitCastStates_.begin(); it != unitCastStates_.end(); ) { auto& s = it->second; if (s.casting && s.timeRemaining > 0.0f) { s.timeRemaining -= deltaTime; if (s.timeRemaining <= 0.0f) { it = unitCastStates_.erase(it); continue; } } ++it; } // 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); tickMinimapPings(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(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(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(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(M_PI)) diff -= 2.0f * static_cast(M_PI); while (diff < -static_cast(M_PI)) diff += 2.0f * static_cast(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(data[pos + 1]) | (static_cast(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(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); if (playMusicCallback_) playMusicCallback_(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(opType), " result=", static_cast(resultCode), " slot/count=", static_cast(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(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 loggedUnknownWireOpcodes; if (loggedUnknownWireOpcodes.insert(opcode).second) { LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec, " state=", static_cast(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(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(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_GM_MESSAGECHAT: // GM → player message: same wire format as SMSG_MESSAGECHAT 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_CHAT_PLAYER_NOT_FOUND: { // string: name of the player not found (for failed whispers) std::string name = packet.readString(); if (!name.empty()) { addSystemChatMessage("No player named '" + name + "' is currently playing."); } break; } case Opcode::SMSG_CHAT_PLAYER_AMBIGUOUS: { // string: ambiguous player name (multiple matches) std::string name = packet.readString(); if (!name.empty()) { addSystemChatMessage("Player name '" + name + "' is ambiguous."); } break; } case Opcode::SMSG_CHAT_WRONG_FACTION: addSystemChatMessage("You cannot send messages to members of that faction."); break; case Opcode::SMSG_CHAT_NOT_IN_PARTY: addSystemChatMessage("You are not in a party."); break; case Opcode::SMSG_CHAT_RESTRICTED: addSystemChatMessage("You cannot send chat messages in this area."); 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: handleContactList(packet); break; case Opcode::SMSG_FRIEND_LIST: // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) handleFriendList(packet); break; case Opcode::SMSG_IGNORE_LIST: { // uint8 count + count × (uint64 guid + string name) // Populate ignoreCache so /unignore works for pre-existing ignores. if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t ignCount = packet.readUInt8(); for (uint8_t i = 0; i < ignCount; ++i) { if (packet.getSize() - packet.getReadPos() < 8) break; uint64_t ignGuid = packet.readUInt64(); std::string ignName = packet.readString(); if (!ignName.empty() && ignGuid != 0) { ignoreCache[ignName] = ignGuid; } } LOG_DEBUG("SMSG_IGNORE_LIST: loaded ", (int)ignCount, " ignored players"); 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(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(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; case Opcode::SMSG_EXPLORATION_EXPERIENCE: { // uint32 areaId + uint32 xpGained if (packet.getSize() - packet.getReadPos() >= 8) { uint32_t areaId = packet.readUInt32(); uint32_t xpGained = packet.readUInt32(); if (xpGained > 0) { std::string areaName = getAreaName(areaId); std::string msg; if (!areaName.empty()) { msg = "Discovered " + areaName + "! Gained " + std::to_string(xpGained) + " experience."; } else { char buf[128]; std::snprintf(buf, sizeof(buf), "Discovered new area! Gained %u experience.", xpGained); msg = buf; } addSystemChatMessage(msg); // XP is updated via PLAYER_XP update fields from the server. } } break; } case Opcode::SMSG_PET_TAME_FAILURE: { // uint8 reason: 0=invalid_creature, 1=too_many_pets, 2=already_tamed, etc. const char* reasons[] = { "Invalid creature", "Too many pets", "Already tamed", "Wrong faction", "Level too low", "Creature not tameable", "Can't control", "Can't command" }; if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t reason = packet.readUInt8(); const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason"; std::string s = std::string("Failed to tame: ") + msg; addSystemChatMessage(s); } break; } case Opcode::SMSG_PET_ACTION_FEEDBACK: { // uint8 action + uint8 flags packet.setReadPos(packet.getSize()); // Consume; no UI for pet feedback yet. break; } case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: { // uint32 petNumber + string name + uint32 timestamp + bool declined packet.setReadPos(packet.getSize()); // Consume; pet names shown via unit objects. break; } case Opcode::SMSG_QUESTUPDATE_FAILED: { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); char buf[128]; std::snprintf(buf, sizeof(buf), "Quest %u failed!", questId); addSystemChatMessage(buf); } break; } case Opcode::SMSG_QUESTUPDATE_FAILEDTIMER: { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); char buf[128]; std::snprintf(buf, sizeof(buf), "Quest %u timed out!", questId); addSystemChatMessage(buf); } break; } // ---- Entity health/power delta updates ---- case Opcode::SMSG_HEALTH_UPDATE: { // WotLK: packed_guid + uint32 health // TBC: full uint64 + uint32 health // Classic/Vanilla: packed_guid + uint32 health (same as WotLK) const bool huTbc = isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (huTbc ? 8u : 2u)) break; uint64_t guid = huTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t hp = packet.readUInt32(); auto entity = entityManager.getEntity(guid); if (auto* unit = dynamic_cast(entity.get())) { unit->setHealth(hp); } break; } case Opcode::SMSG_POWER_UPDATE: { // WotLK: packed_guid + uint8 powerType + uint32 value // TBC: full uint64 + uint8 powerType + uint32 value // Classic/Vanilla: packed_guid + uint8 powerType + uint32 value (same as WotLK) const bool puTbc = isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (puTbc ? 8u : 2u)) break; uint64_t guid = puTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint8_t powerType = packet.readUInt8(); uint32_t value = packet.readUInt32(); auto entity = entityManager.getEntity(guid); if (auto* unit = dynamic_cast(entity.get())) { unit->setPowerByType(powerType, value); } break; } // ---- World state single update ---- case Opcode::SMSG_UPDATE_WORLD_STATE: { // uint32 field + uint32 value if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t field = packet.readUInt32(); uint32_t value = packet.readUInt32(); worldStates_[field] = value; LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); break; } case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: { // uint32 time (server unix timestamp) — used to sync UI timers (arena, BG) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t serverTime = packet.readUInt32(); LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime); } break; } case Opcode::SMSG_PVP_CREDIT: { // uint32 honorPoints + uint64 victimGuid + uint32 victimRank if (packet.getSize() - packet.getReadPos() >= 16) { uint32_t honor = packet.readUInt32(); uint64_t victimGuid = packet.readUInt64(); uint32_t rank = packet.readUInt32(); LOG_INFO("SMSG_PVP_CREDIT: honor=", honor, " victim=0x", std::hex, victimGuid, std::dec, " rank=", rank); std::string msg = "You gain " + std::to_string(honor) + " honor points."; addSystemChatMessage(msg); } break; } // ---- Combo points ---- case Opcode::SMSG_UPDATE_COMBO_POINTS: { // WotLK: packed_guid (target) + uint8 points // TBC: full uint64 (target) + uint8 points // Classic/Vanilla: packed_guid (target) + uint8 points (same as WotLK) const bool cpTbc = isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (cpTbc ? 8u : 2u)) break; uint64_t target = cpTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; comboPoints_ = packet.readUInt8(); comboTarget_ = target; LOG_DEBUG("SMSG_UPDATE_COMBO_POINTS: target=0x", std::hex, target, std::dec, " points=", static_cast(comboPoints_)); break; } // ---- Mirror timers (breath/fatigue/feign death) ---- case Opcode::SMSG_START_MIRROR_TIMER: { // uint32 type + int32 value + int32 maxValue + int32 scale + uint32 tracker + uint8 paused if (packet.getSize() - packet.getReadPos() < 21) break; uint32_t type = packet.readUInt32(); int32_t value = static_cast(packet.readUInt32()); int32_t maxV = static_cast(packet.readUInt32()); int32_t scale = static_cast(packet.readUInt32()); /*uint32_t tracker =*/ packet.readUInt32(); uint8_t paused = packet.readUInt8(); if (type < 3) { mirrorTimers_[type].value = value; mirrorTimers_[type].maxValue = maxV; mirrorTimers_[type].scale = scale; mirrorTimers_[type].paused = (paused != 0); mirrorTimers_[type].active = true; } break; } case Opcode::SMSG_STOP_MIRROR_TIMER: { // uint32 type if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t type = packet.readUInt32(); if (type < 3) { mirrorTimers_[type].active = false; mirrorTimers_[type].value = 0; } break; } case Opcode::SMSG_PAUSE_MIRROR_TIMER: { // uint32 type + uint8 paused if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t type = packet.readUInt32(); uint8_t paused = packet.readUInt8(); if (type < 3) { mirrorTimers_[type].paused = (paused != 0); } break; } // ---- Cast result (WotLK extended cast failed) ---- case Opcode::SMSG_CAST_RESULT: { // WotLK: castCount(u8) + spellId(u32) + result(u8) // TBC/Classic: spellId(u32) + result(u8) (no castCount prefix) // If result == 0, the spell successfully began; otherwise treat like SMSG_CAST_FAILED. uint32_t castResultSpellId = 0; uint8_t castResult = 0; if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { if (castResult != 0) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; // Pass player's power type so result 85 says "Not enough rage/energy/etc." int playerPowerType = -1; if (auto pe = entityManager.getEntity(playerGuid)) { if (auto pu = std::dynamic_pointer_cast(pe)) playerPowerType = static_cast(pu->getPowerType()); } const char* reason = getSpellCastResultString(castResult, playerPowerType); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = reason ? reason : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addLocalChatMessage(msg); } } break; } // ---- Spell failed on another unit ---- case Opcode::SMSG_SPELL_FAILED_OTHER: { // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 reason // TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 reason const bool tbcLike2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t failOtherGuid = tbcLike2 ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); if (failOtherGuid != 0 && failOtherGuid != playerGuid) { unitCastStates_.erase(failOtherGuid); } packet.setReadPos(packet.getSize()); break; } // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + ... // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + ... const bool prTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto readPrGuid = [&]() -> uint64_t { if (prTbcLike) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; /*uint64_t caster =*/ readPrGuid(); if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; uint64_t victim = readPrGuid(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); if (victim == playerGuid) addCombatText(CombatTextEntry::RESIST, 0, spellId, false); packet.setReadPos(packet.getSize()); break; } // ---- Loot start roll (Need/Greed popup trigger) ---- case Opcode::SMSG_LOOT_START_ROLL: { // WotLK 3.3.5a: uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId // + uint32 randomSuffix + uint32 randomPropId + uint32 countdown + uint8 voteMask (33 bytes) // Classic/TBC: uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId // + uint32 countdown + uint8 voteMask (25 bytes) const bool isWotLK = isActiveExpansion("wotlk"); const size_t minSize = isWotLK ? 33u : 25u; if (packet.getSize() - packet.getReadPos() < minSize) break; uint64_t objectGuid = packet.readUInt64(); /*uint32_t mapId =*/ packet.readUInt32(); uint32_t slot = packet.readUInt32(); uint32_t itemId = packet.readUInt32(); if (isWotLK) { /*uint32_t randSuffix =*/ packet.readUInt32(); /*uint32_t randProp =*/ packet.readUInt32(); } /*uint32_t countdown =*/ packet.readUInt32(); /*uint8_t voteMask =*/ packet.readUInt8(); // Trigger the roll popup for local player pendingLootRollActive_ = true; pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot); break; } // ---- Pet stable result ---- case Opcode::SMSG_STABLE_RESULT: { // uint8 result if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t result = packet.readUInt8(); const char* msg = nullptr; switch (result) { case 0x01: msg = "Pet stored in stable."; break; case 0x06: msg = "Pet retrieved from stable."; break; case 0x07: msg = "Stable slot purchased."; break; case 0x08: msg = "Stable list updated."; break; case 0x09: msg = "Stable failed: not enough money or other error."; break; default: break; } if (msg) addSystemChatMessage(msg); LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); break; } // ---- Title earned ---- case Opcode::SMSG_TITLE_EARNED: { // uint32 titleBitIndex + uint32 isLost if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t titleBit = packet.readUInt32(); uint32_t isLost = packet.readUInt32(); char buf[128]; std::snprintf(buf, sizeof(buf), isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!", titleBit); addSystemChatMessage(buf); LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost); break; } case Opcode::SMSG_LEARNED_DANCE_MOVES: // Contains bitmask of learned dance moves — cosmetic only, no gameplay effect. LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")"); break; // ---- Hearthstone binding ---- case Opcode::SMSG_PLAYERBOUND: { // uint64 binderGuid + uint32 mapId + uint32 zoneId if (packet.getSize() - packet.getReadPos() < 16) break; /*uint64_t binderGuid =*/ packet.readUInt64(); uint32_t mapId = packet.readUInt32(); uint32_t zoneId = packet.readUInt32(); char buf[128]; std::snprintf(buf, sizeof(buf), "Your home location has been set (map %u, zone %u).", mapId, zoneId); addSystemChatMessage(buf); break; } case Opcode::SMSG_BINDER_CONFIRM: { // uint64 npcGuid — server confirming bind point has been set addSystemChatMessage("This innkeeper is now your home location."); packet.setReadPos(packet.getSize()); break; } // ---- Phase shift (WotLK phasing) ---- case Opcode::SMSG_SET_PHASE_SHIFT: { // uint32 phaseFlags [+ packed guid + uint16 count + repeated uint16 phaseIds] // Just consume; phasing doesn't require action from client in WotLK packet.setReadPos(packet.getSize()); break; } // ---- XP gain toggle ---- case Opcode::SMSG_TOGGLE_XP_GAIN: { // uint8 enabled if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t enabled = packet.readUInt8(); addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled."); break; } // ---- Gossip POI (quest map markers) ---- case Opcode::SMSG_GOSSIP_POI: { // uint32 flags + float x + float y + uint32 icon + uint32 data + string name if (packet.getSize() - packet.getReadPos() < 20) break; /*uint32_t flags =*/ packet.readUInt32(); float poiX = packet.readFloat(); // WoW canonical coords float poiY = packet.readFloat(); uint32_t icon = packet.readUInt32(); uint32_t data = packet.readUInt32(); std::string name = packet.readString(); GossipPoi poi; poi.x = poiX; poi.y = poiY; poi.icon = icon; poi.data = data; poi.name = std::move(name); gossipPois_.push_back(std::move(poi)); LOG_DEBUG("SMSG_GOSSIP_POI: x=", poiX, " y=", poiY, " icon=", icon); break; } // ---- Character service results ---- case Opcode::SMSG_CHAR_RENAME: { // uint32 result (0=success) + uint64 guid + string newName if (packet.getSize() - packet.getReadPos() >= 13) { uint32_t result = packet.readUInt32(); /*uint64_t guid =*/ packet.readUInt64(); std::string newName = packet.readString(); if (result == 0) { addSystemChatMessage("Character name changed to: " + newName); } else { addSystemChatMessage("Character rename failed (error " + std::to_string(result) + ")."); } LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); } break; } case Opcode::SMSG_BINDZONEREPLY: { // uint32 result (0=success, 1=too far) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Your home is now set to this location."); } else { addSystemChatMessage("You are too far from the innkeeper."); } } break; } case Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT: { // uint32 result if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Difficulty changed."); } else { static const char* reasons[] = { "", "Error", "Too many members", "Already in dungeon", "You are in a battleground", "Raid not allowed in heroic", "You must be in a raid group", "Player not in group" }; const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); } } break; } case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE: addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); break; case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: { // uint64 playerGuid + uint32 threshold if (packet.getSize() - packet.getReadPos() >= 12) { uint64_t guid = packet.readUInt64(); uint32_t threshold = packet.readUInt32(); if (guid == playerGuid && threshold > 0) { addSystemChatMessage("You feel rather drunk."); } LOG_DEBUG("SMSG_CROSSED_INEBRIATION_THRESHOLD: guid=0x", std::hex, guid, std::dec, " threshold=", threshold); } break; } case Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE: // Far sight cancelled; viewport returns to player camera LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE"); break; case Opcode::SMSG_COMBAT_EVENT_FAILED: // Combat event could not be executed (e.g. invalid target for special ability) packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_FORCE_ANIM: { // packed_guid + uint32 animId — force entity to play animation if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 4) { /*uint32_t animId =*/ packet.readUInt32(); } } break; } case Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM: case Opcode::SMSG_GAMEOBJECT_RESET_STATE: case Opcode::SMSG_FLIGHT_SPLINE_SYNC: case Opcode::SMSG_FORCE_DISPLAY_UPDATE: case Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS: case Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID: case Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE: case Opcode::SMSG_DAMAGE_CALC_LOG: case Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT: case Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE: case Opcode::SMSG_FORCED_DEATH_UPDATE: // Consume — handled by broader object update or not yet implemented packet.setReadPos(packet.getSize()); break; // ---- Zone defense messages ---- case Opcode::SMSG_DEFENSE_MESSAGE: { // uint32 zoneId + string message — used for PvP zone attack alerts if (packet.getSize() - packet.getReadPos() >= 5) { /*uint32_t zoneId =*/ packet.readUInt32(); std::string defMsg = packet.readString(); if (!defMsg.empty()) { addSystemChatMessage("[Defense] " + defMsg); } } break; } case Opcode::SMSG_CORPSE_RECLAIM_DELAY: { // uint32 delayMs before player can reclaim corpse if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t delayMs = packet.readUInt32(); uint32_t delaySec = (delayMs + 999) / 1000; addSystemChatMessage("You can reclaim your corpse in " + std::to_string(delaySec) + " seconds."); LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); } break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { // uint32 mapId + float x + float y + float z — corpse/spirit healer position if (packet.getSize() - packet.getReadPos() >= 16) { corpseMapId_ = packet.readUInt32(); corpseX_ = packet.readFloat(); corpseY_ = packet.readFloat(); corpseZ_ = packet.readFloat(); LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_, " x=", corpseX_, " y=", corpseY_, " z=", corpseZ_); } break; } case Opcode::SMSG_ENABLE_BARBER_SHOP: // Sent by server when player sits in barber chair — triggers barber shop UI // No payload; we don't have barber shop UI yet, so just log LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); break; case Opcode::SMSG_FEIGN_DEATH_RESISTED: addSystemChatMessage("Your Feign Death attempt was resisted."); LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED"); break; case Opcode::SMSG_CHANNEL_MEMBER_COUNT: { // string channelName + uint8 flags + uint32 memberCount std::string chanName = packet.readString(); if (packet.getSize() - packet.getReadPos() >= 5) { /*uint8_t flags =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); LOG_DEBUG("SMSG_CHANNEL_MEMBER_COUNT: channel=", chanName, " members=", count); } break; } case Opcode::SMSG_GAMETIME_SET: case Opcode::SMSG_GAMETIME_UPDATE: // Server time correction: uint32 gameTimePacked (seconds since epoch) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t gameTimePacked = packet.readUInt32(); gameTime_ = static_cast(gameTimePacked); LOG_DEBUG("Server game time update: ", gameTime_, "s"); } packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_GAMESPEED_SET: // Server speed correction: uint32 gameTimePacked + float timeSpeed if (packet.getSize() - packet.getReadPos() >= 8) { uint32_t gameTimePacked = packet.readUInt32(); float timeSpeed = packet.readFloat(); gameTime_ = static_cast(gameTimePacked); timeSpeed_ = timeSpeed; LOG_DEBUG("Server game speed update: time=", gameTime_, " speed=", timeSpeed_); } packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_GAMETIMEBIAS_SET: // Time bias — consume without processing packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_ACHIEVEMENT_DELETED: case Opcode::SMSG_CRITERIA_DELETED: // Consume achievement/criteria removal notifications packet.setReadPos(packet.getSize()); break; // ---- Combat clearing ---- case Opcode::SMSG_ATTACKSWING_DEADTARGET: // Target died mid-swing: clear auto-attack autoAttacking = false; autoAttackTarget = 0; break; case Opcode::SMSG_THREAT_CLEAR: // All threat dropped on the local player (e.g. Vanish, Feign Death) // No local state to clear — informational LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); break; case Opcode::SMSG_THREAT_REMOVE: { // packed_guid (unit) + packed_guid (victim whose threat was removed) if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); } } break; } case Opcode::SMSG_HIGHEST_THREAT_UPDATE: { // packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count // + count × (packed_guid victim + uint32 threat) // Informational — no threat UI yet; consume to suppress warnings packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CANCEL_COMBAT: // Server-side combat state reset autoAttacking = false; autoAttackTarget = 0; autoAttackRequested_ = false; break; case Opcode::SMSG_BREAK_TARGET: // Server breaking our targeting (PvP flag, etc.) // uint64 guid — consume; target cleared if it matches if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t bGuid = packet.readUInt64(); if (bGuid == targetGuid) targetGuid = 0; } break; case Opcode::SMSG_CLEAR_TARGET: // uint64 guid — server cleared targeting on a unit (or 0 = clear all) if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t cGuid = packet.readUInt64(); if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0; } break; // ---- Server-forced dismount ---- case Opcode::SMSG_DISMOUNT: // No payload — server forcing dismount currentMountDisplayId_ = 0; if (mountCallback_) mountCallback_(0); break; case Opcode::SMSG_MOUNTRESULT: { // uint32 result: 0=error, 1=invalid, 2=not in range, 3=already mounted, 4=ok if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t result = packet.readUInt32(); if (result != 4) { const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." }; addSystemChatMessage(result < 4 ? msgs[result] : "Cannot mount."); } break; } case Opcode::SMSG_DISMOUNTRESULT: { // uint32 result: 0=ok, others=error if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t result = packet.readUInt32(); if (result != 0) addSystemChatMessage("Cannot dismount here."); break; } // ---- Loot notifications ---- case Opcode::SMSG_LOOT_ALL_PASSED: { // WotLK 3.3.5a: uint64 objectGuid + uint32 slot + uint32 itemId + uint32 randSuffix + uint32 randPropId (24 bytes) // Classic/TBC: uint64 objectGuid + uint32 slot + uint32 itemId (16 bytes) const bool isWotLK = isActiveExpansion("wotlk"); const size_t minSize = isWotLK ? 24u : 16u; if (packet.getSize() - packet.getReadPos() < minSize) break; /*uint64_t objGuid =*/ packet.readUInt64(); /*uint32_t slot =*/ packet.readUInt32(); uint32_t itemId = packet.readUInt32(); if (isWotLK) { /*uint32_t randSuffix =*/ packet.readUInt32(); /*uint32_t randProp =*/ packet.readUInt32(); } auto* info = getItemInfo(itemId); char buf[256]; std::snprintf(buf, sizeof(buf), "Everyone passed on [%s].", info ? info->name.c_str() : std::to_string(itemId).c_str()); addSystemChatMessage(buf); pendingLootRollActive_ = false; break; } case Opcode::SMSG_LOOT_ITEM_NOTIFY: { // uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count if (packet.getSize() - packet.getReadPos() < 24) { packet.setReadPos(packet.getSize()); break; } uint64_t looterGuid = packet.readUInt64(); /*uint64_t lootGuid =*/ packet.readUInt64(); uint32_t itemId = packet.readUInt32(); uint32_t count = packet.readUInt32(); // Show loot message for party members (not the player — SMSG_ITEM_PUSH_RESULT covers that) if (isInGroup() && looterGuid != playerGuid) { auto nit = playerNameCache.find(looterGuid); std::string looterName = (nit != playerNameCache.end()) ? nit->second : ""; if (!looterName.empty()) { queryItemInfo(itemId, 0); std::string itemName = "item #" + std::to_string(itemId); if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemName = info->name; } char buf[256]; if (count > 1) std::snprintf(buf, sizeof(buf), "%s loots %s x%u.", looterName.c_str(), itemName.c_str(), count); else std::snprintf(buf, sizeof(buf), "%s loots %s.", looterName.c_str(), itemName.c_str()); addSystemChatMessage(buf); } } break; } case Opcode::SMSG_LOOT_SLOT_CHANGED: // uint64 objectGuid + uint32 slot + ... — consume packet.setReadPos(packet.getSize()); break; // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { // All expansions: uint32 spellId first. // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count // + count × (packed_guid victim + uint8 missInfo) // [missInfo==11(REFLECT): + uint32 reflectSpellId + uint8 reflectResult] // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count // + count × (uint64 victim + uint8 missInfo) const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { if (spellMissTbcLike) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; // spellId prefix present in all expansions if (packet.getSize() - packet.getReadPos() < 4) break; /*uint32_t spellId =*/ packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 32u); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; /*uint64_t victimGuid =*/ readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult if (missInfo == 11 && !spellMissTbcLike) { if (packet.getSize() - packet.getReadPos() >= 5) { /*uint32_t reflectSpellId =*/ packet.readUInt32(); /*uint8_t reflectResult =*/ packet.readUInt8(); } else { packet.setReadPos(packet.getSize()); break; } } // Show combat text only for local player's spell misses if (casterGuid == playerGuid) { static const CombatTextEntry::Type missTypes[] = { CombatTextEntry::MISS, // 0=MISS CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK CombatTextEntry::MISS, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; addCombatText(ct, 0, 0, true); } } break; } // ---- Environmental damage log ---- case Opcode::SMSG_ENVIRONMENTALDAMAGELOG: { // uint64 victimGuid + uint8 envDamageType + uint32 damage + uint32 absorb + uint32 resist if (packet.getSize() - packet.getReadPos() < 21) break; uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t damage = packet.readUInt32(); uint32_t absorb = packet.readUInt32(); uint32_t resist = packet.readUInt32(); if (victimGuid == playerGuid) { if (damage > 0) addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); if (absorb > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false); if (resist > 0) addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false); } 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_FEATHER_FALL: case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE: case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE: case Opcode::SMSG_SPLINE_MOVE_LAND_WALK: case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL: case Opcode::SMSG_SPLINE_MOVE_ROOT: case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: { // Minimal parse: PackedGuid only — no animation-relevant state change. if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); } break; } case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE: case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: case Opcode::SMSG_SPLINE_MOVE_SET_FLYING: case Opcode::SMSG_SPLINE_MOVE_START_SWIM: case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: { // PackedGuid + synthesised move-flags → drives animation state in application layer. // SWIMMING=0x00200000, WALKING=0x00000100, CAN_FLY=0x00800000, FLYING=0x01000000 if (packet.getSize() - packet.getReadPos() < 1) break; uint64_t guid = UpdateObjectParser::readPackedGuid(packet); if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; uint32_t synthFlags = 0; if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_START_SWIM) synthFlags = 0x00200000u; // SWIMMING else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE) synthFlags = 0x00000100u; // WALKING else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_FLYING) synthFlags = 0x01000000u | 0x00800000u; // FLYING | CAN_FLY // STOP_SWIM and SET_RUN_MODE: synthFlags stays 0 → clears swim/walk unitMoveFlagsCallback_(guid, synthFlags); 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.01f && speed < 200.0f) { if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) serverRunSpeed_ = speed; else if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED) serverRunBackSpeed_ = speed; else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_SPEED) serverSwimSpeed_ = 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(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(MovementFlags::CAN_FLY), false); break; case Opcode::SMSG_MOVE_FEATHER_FALL: handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, static_cast(MovementFlags::FEATHER_FALL), true); break; case Opcode::SMSG_MOVE_WATER_WALK: handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, static_cast(MovementFlags::WATER_WALK), true); break; case Opcode::SMSG_MOVE_SET_HOVER: handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK, static_cast(MovementFlags::HOVER), true); break; case Opcode::SMSG_MOVE_UNSET_HOVER: handleForceMoveFlagChange(packet, "UNSET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK, static_cast(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(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(MovementFlags::FORWARD) | static_cast(MovementFlags::BACKWARD) | static_cast(MovementFlags::STRAFE_LEFT) | static_cast(MovementFlags::STRAFE_RIGHT) | static_cast(MovementFlags::TURN_LEFT) | static_cast(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: { // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 failReason // TBC: full uint64 + uint8 castCount + uint32 spellId + uint8 failReason // Classic: full uint64 + uint32 spellId + uint8 failReason (NO castCount) const bool isClassic = isClassicLikeExpansion(); const bool isTbc = isActiveExpansion("tbc"); uint64_t failGuid = (isClassic || isTbc) ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); // Classic omits the castCount byte; TBC and WotLK include it const size_t remainingFields = isClassic ? 5u : 6u; // spellId(4)+reason(1) [+castCount(1)] if (packet.getSize() - packet.getReadPos() >= remainingFields) { if (!isClassic) /*uint8_t castCount =*/ packet.readUInt8(); /*uint32_t spellId =*/ packet.readUInt32(); uint8_t rawFailReason = packet.readUInt8(); // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; if (failGuid == playerGuid && failReason != 0) { // Show interruption/failure reason in chat for player int pt = -1; if (auto pe = entityManager.getEntity(playerGuid)) if (auto pu = std::dynamic_pointer_cast(pe)) pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); if (reason) { MessageChatData emsg; emsg.type = ChatType::SYSTEM; emsg.language = ChatLanguage::UNIVERSAL; emsg.message = reason; addLocalChatMessage(emsg); } } } if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed casting = false; currentCastSpellId = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); } } if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); } } else { // Another unit's cast failed — clear their tracked cast bar unitCastStates_.erase(failGuid); if (spellCastAnimCallback_) { spellCastAnimCallback_(failGuid, false, false); } } 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(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: handleAllAchievementData(packet); 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: { // WotLK: packed_guid + float posX + float posY // TBC/Classic: uint64 + float posX + float posY const bool mmTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (mmTbcLike ? 8u : 1u)) break; uint64_t senderGuid = mmTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; float pingX = packet.readFloat(); // server sends map-coord X (east-west) float pingY = packet.readFloat(); // server sends map-coord Y (north-south) MinimapPing ping; ping.senderGuid = senderGuid; ping.wowX = pingY; // canonical WoW X = north = server's posY ping.wowY = pingX; // canonical WoW Y = west = server's posX ping.age = 0.0f; minimapPings_.push_back(ping); break; } case Opcode::SMSG_ZONE_UNDER_ATTACK: { // uint32 areaId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t areaId = packet.readUInt32(); std::string areaName = getAreaName(areaId); std::string msg = areaName.empty() ? std::string("A zone is under attack!") : (areaName + " is under attack!"); addSystemChatMessage(msg); } 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: { // WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim // [+ count × uint32 failedSpellId] // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId // [+ count × uint32 failedSpellId] const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint32_t dispelSpellId = 0; if (dispelTbcLike) { if (packet.getSize() - packet.getReadPos() < 20) break; /*uint64_t caster =*/ packet.readUInt64(); /*uint64_t victim =*/ packet.readUInt64(); dispelSpellId = packet.readUInt32(); } else { if (packet.getSize() - packet.getReadPos() < 4) break; dispelSpellId = packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < 1) break; /*uint64_t caster =*/ UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); } { loadSpellNameCache(); auto it = spellNameCache_.find(dispelSpellId); char buf[128]; if (it != spellNameCache_.end() && !it->second.name.empty()) std::snprintf(buf, sizeof(buf), "%s failed to dispel.", it->second.name.c_str()); else std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", dispelSpellId); addSystemChatMessage(buf); } break; } case Opcode::SMSG_TOTEM_CREATED: { // WotLK: uint8 slot + packed_guid + uint32 duration + uint32 spellId // TBC/Classic: uint8 slot + uint64 guid + uint32 duration + uint32 spellId const bool totemTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (totemTbcLike ? 17u : 9u)) break; uint8_t slot = packet.readUInt8(); if (totemTbcLike) /*uint64_t guid =*/ packet.readUInt64(); else /*uint64_t guid =*/ UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; 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_DESTROYED: // The group was disbanded; clear all party state. partyData.members.clear(); partyData.memberCount = 0; partyData.leaderGuid = 0; addSystemChatMessage("Your party has been disbanded."); LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); break; case Opcode::SMSG_GROUP_CANCEL: // Group invite was cancelled before being accepted. addSystemChatMessage("Group invite cancelled."); LOG_DEBUG("SMSG_GROUP_CANCEL"); 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 is broadcasting a ready check (someone in the raid initiated it). // Payload: empty body, or optional uint64 initiator GUID in some builds. pendingReadyCheck_ = true; readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; readyCheckInitiator_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); auto entity = entityManager.getEntity(initiatorGuid); if (auto* unit = dynamic_cast(entity.get())) { readyCheckInitiator_ = unit->getName(); } } if (readyCheckInitiator_.empty() && partyData.leaderGuid != 0) { // Identify initiator from party leader for (const auto& member : partyData.members) { if (member.guid == partyData.leaderGuid) { readyCheckInitiator_ = member.name; break; } } } addSystemChatMessage(readyCheckInitiator_.empty() ? "Ready check initiated!" : readyCheckInitiator_ + " initiated a ready check!"); LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_); break; } case Opcode::MSG_RAID_READY_CHECK_CONFIRM: { // guid (8) + uint8 isReady (0=not ready, 1=ready) if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } uint64_t respGuid = packet.readUInt64(); uint8_t isReady = packet.readUInt8(); if (isReady) ++readyCheckReadyCount_; else ++readyCheckNotReadyCount_; auto nit = playerNameCache.find(respGuid); std::string rname; if (nit != playerNameCache.end()) rname = nit->second; else { auto ent = entityManager.getEntity(respGuid); if (ent) rname = std::static_pointer_cast(ent)->getName(); } if (!rname.empty()) { char rbuf[128]; std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); addSystemChatMessage(rbuf); } break; } case Opcode::MSG_RAID_READY_CHECK_FINISHED: { // Ready check complete — summarize results char fbuf[128]; std::snprintf(fbuf, sizeof(fbuf), "Ready check complete: %u ready, %u not ready.", readyCheckReadyCount_, readyCheckNotReadyCount_); addSystemChatMessage(fbuf); pendingReadyCheck_ = false; readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; 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: { // uint64 killerGuid + uint64 victimGuid if (packet.getSize() - packet.getReadPos() < 16) break; uint64_t killerGuid = packet.readUInt64(); uint64_t victimGuid = packet.readUInt64(); // Show kill message in party chat style auto nameForGuid = [&](uint64_t g) -> std::string { // Check player name cache first auto nit = playerNameCache.find(g); if (nit != playerNameCache.end()) return nit->second; // Fall back to entity name (NPCs) auto ent = entityManager.getEntity(g); if (ent && (ent->getType() == game::ObjectType::UNIT || ent->getType() == game::ObjectType::PLAYER)) { auto unit = std::static_pointer_cast(ent); return unit->getName(); } return {}; }; std::string killerName = nameForGuid(killerGuid); std::string victimName = nameForGuid(victimGuid); if (!killerName.empty() && !victimName.empty()) { char buf[256]; std::snprintf(buf, sizeof(buf), "%s killed %s.", killerName.c_str(), victimName.c_str()); addSystemChatMessage(buf); } 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: handleTradeStatus(packet); break; case Opcode::SMSG_TRADE_STATUS_EXTENDED: handleTradeStatusExtended(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; resurrectCasterName_ = ""; resurrectIsSpiritHealer_ = true; 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(); // Optional caster name (CString, may be absent on some server builds) std::string casterName; if (packet.getReadPos() < packet.getSize()) { casterName = packet.readString(); } LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec, " name='", casterName, "'"); if (casterGuid) { resurrectCasterGuid_ = casterGuid; resurrectIsSpiritHealer_ = false; if (!casterName.empty()) { resurrectCasterName_ = casterName; } else { auto nit = playerNameCache.find(casterGuid); resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : ""; } 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: { // WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val) // Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(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(); // WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent size_t remaining = packet.getSize() - packet.getReadPos(); bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle"); if (isWotLKFormat && remaining >= 6) { packet.readUInt32(); // areaId (WotLK only) } uint16_t count = packet.readUInt16(); size_t needed = static_cast(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(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(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(packet.readUInt32()); initialFactions_.push_back(fs); } break; } case Opcode::SMSG_SET_FACTION_STANDING: { // uint8 showVisualEffect + uint32 count + count × (uint32 factionId + int32 standing) if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t showVisual =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 128u); loadFactionNameCache(); for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { uint32_t factionId = packet.readUInt32(); int32_t standing = static_cast(packet.readUInt32()); int32_t oldStanding = 0; auto it = factionStandings_.find(factionId); if (it != factionStandings_.end()) oldStanding = it->second; factionStandings_[factionId] = standing; int32_t delta = standing - oldStanding; if (delta != 0) { std::string name = getFactionName(factionId); char buf[256]; std::snprintf(buf, sizeof(buf), "Reputation with %s %s by %d.", name.c_str(), delta > 0 ? "increased" : "decreased", std::abs(delta)); addSystemChatMessage(buf); } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } break; } case Opcode::SMSG_SET_FACTION_ATWAR: case Opcode::SMSG_SET_FACTION_VISIBLE: // uint32 factionId [+ uint8 flags for ATWAR] — consume; hostility is tracked via update fields packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: // Different formats than SMSG_SPELL_DELAYED — consume and ignore packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_SPELL_DELAYED: { // WotLK: packed_guid (caster) + uint32 delayMs // TBC/Classic: uint64 (caster) + uint32 delayMs const bool spellDelayTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (spellDelayTbcLike ? 8u : 1u)) break; uint64_t caster = spellDelayTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t delayMs = packet.readUInt32(); if (delayMs == 0) break; float delaySec = delayMs / 1000.0f; if (caster == playerGuid) { if (casting) castTimeRemaining += delaySec; } else { auto it = unitCastStates_.find(caster); if (it != unitCastStates_.end() && it->second.casting) { it->second.timeRemaining += delaySec; it->second.timeTotal += delaySec; } } break; } case Opcode::SMSG_EQUIPMENT_SET_SAVED: // uint32 setIndex + uint64 guid — equipment set was successfully saved LOG_DEBUG("Equipment set saved"); break; case Opcode::SMSG_PERIODICAURALOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects // Classic/Vanilla: packed_guid (same as WotLK) const bool periodicTbc = isActiveExpansion("tbc"); const size_t guidMinSz = periodicTbc ? 8u : 2u; if (packet.getSize() - packet.getReadPos() < guidMinSz) break; uint64_t victimGuid = periodicTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < guidMinSz) break; uint64_t casterGuid = periodicTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t spellId = packet.readUInt32(); uint32_t count = packet.readUInt32(); bool isPlayerVictim = (victimGuid == playerGuid); bool isPlayerCaster = (casterGuid == playerGuid); if (!isPlayerVictim && !isPlayerCaster) { packet.setReadPos(packet.getSize()); break; } for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 1; ++i) { uint8_t auraType = packet.readUInt8(); if (auraType == 3 || auraType == 89) { // Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes // WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4) = 20 bytes const bool periodicWotlk = isActiveExpansion("wotlk"); const size_t dotSz = periodicWotlk ? 20u : 16u; if (packet.getSize() - packet.getReadPos() < dotSz) break; uint32_t dmg = packet.readUInt32(); if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32(); /*uint32_t school=*/ packet.readUInt32(); uint32_t abs = packet.readUInt32(); uint32_t res = packet.readUInt32(); if (dmg > 0) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), spellId, isPlayerCaster); if (abs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(abs), spellId, isPlayerCaster); if (res > 0) addCombatText(CombatTextEntry::RESIST, static_cast(res), spellId, isPlayerCaster); } else if (auraType == 8 || auraType == 124 || auraType == 45) { // Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes // WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes const bool healWotlk = isActiveExpansion("wotlk"); const size_t hotSz = healWotlk ? 17u : 12u; if (packet.getSize() - packet.getReadPos() < hotSz) break; uint32_t heal = packet.readUInt32(); /*uint32_t max=*/ packet.readUInt32(); /*uint32_t over=*/ packet.readUInt32(); uint32_t hotAbs = 0; if (healWotlk) { hotAbs = packet.readUInt32(); /*uint8_t isCrit=*/ packet.readUInt8(); } addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), spellId, isPlayerCaster); if (hotAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), spellId, isPlayerCaster); } else if (auraType == 46 || auraType == 91) { // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. if (packet.getSize() - packet.getReadPos() < 8) break; /*uint32_t powerType =*/ packet.readUInt32(); uint32_t amount = packet.readUInt32(); if ((isPlayerVictim || isPlayerCaster) && amount > 0) addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), spellId, isPlayerCaster); } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; /*uint32_t powerType =*/ packet.readUInt32(); uint32_t amount = packet.readUInt32(); /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) // Show as periodic damage from victim's perspective (mana drained) if (isPlayerVictim && amount > 0) addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), spellId, false); } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); break; } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLENERGIZELOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint8 powerType + int32 amount // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount // Classic/Vanilla: packed_guid (same as WotLK) const bool energizeTbc = isActiveExpansion("tbc"); size_t rem = packet.getSize() - packet.getReadPos(); if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = energizeTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); uint64_t casterGuid = energizeTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); rem = packet.getSize() - packet.getReadPos(); if (rem < 6) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); /*uint8_t powerType =*/ packet.readUInt8(); int32_t amount = static_cast(packet.readUInt32()); bool isPlayerVictim = (victimGuid == playerGuid); bool isPlayerCaster = (casterGuid == playerGuid); if ((isPlayerVictim || isPlayerCaster) && amount > 0) addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG: { // uint64 victimGuid + uint8 envDmgType + uint32 damage + uint32 absorbed + uint32 resisted // envDmgType: 1=Exhausted(fatigue), 2=Drowning, 3=Fall, 4=Lava, 5=Slime, 6=Fire if (packet.getSize() - packet.getReadPos() < 21) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t dmg = packet.readUInt32(); uint32_t envAbs = packet.readUInt32(); uint32_t envRes = packet.readUInt32(); if (victimGuid == playerGuid) { if (dmg > 0) addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); if (envAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false); if (envRes > 0) addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SET_PROFICIENCY: { // uint8 itemClass + uint32 itemSubClassMask if (packet.getSize() - packet.getReadPos() < 5) break; uint8_t itemClass = packet.readUInt8(); uint32_t mask = packet.readUInt32(); if (itemClass == 2) { // Weapon weaponProficiency_ = mask; LOG_DEBUG("SMSG_SET_PROFICIENCY: weapon mask=0x", std::hex, mask, std::dec); } else if (itemClass == 4) { // Armor armorProficiency_ = mask; LOG_DEBUG("SMSG_SET_PROFICIENCY: armor mask=0x", std::hex, mask, std::dec); } break; } case Opcode::SMSG_ACTION_BUTTONS: { // packed: bits 0-23 = actionId, bits 24-31 = type // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) // Format differences: // Classic 1.12: no mode byte, 120 slots (480 bytes) // TBC 2.4.3: no mode byte, 132 slots (528 bytes) // WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes) size_t rem = packet.getSize() - packet.getReadPos(); const bool hasModeByteExp = isActiveExpansion("wotlk"); int serverBarSlots; if (isClassicLikeExpansion()) { serverBarSlots = 120; } else if (isActiveExpansion("tbc")) { serverBarSlots = 132; } else { serverBarSlots = 144; } if (hasModeByteExp) { if (rem < 1) break; /*uint8_t mode =*/ packet.readUInt8(); rem--; } for (int i = 0; i < serverBarSlots; ++i) { if (rem < 4) break; uint32_t packed = packet.readUInt32(); rem -= 4; if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2 if (packed == 0) { // Empty slot — only clear if not already set to Attack/Hearthstone defaults // so we don't wipe hardcoded fallbacks when the server sends zeros. continue; } uint8_t type = static_cast((packed >> 24) & 0xFF); uint32_t id = packed & 0x00FFFFFFu; if (id == 0) continue; ActionBarSlot slot; switch (type) { case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; default: continue; // macro or unknown — leave as-is } actionBar[i] = slot; } LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); packet.setReadPos(packet.getSize()); 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); if (playSoundCallback_) playSoundCallback_(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(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); // After error byte: item_guid1(8) + item_guid2(8) + bag_slot(1) = 17 bytes uint32_t requiredLevel = 0; if (packet.getSize() - packet.getReadPos() >= 17) { packet.readUInt64(); // item_guid1 packet.readUInt64(); // item_guid2 packet.readUInt8(); // bag_slot // Error 1 = EQUIP_ERR_LEVEL_REQ: server appends required level as uint32 if (error == 1 && packet.getSize() - packet.getReadPos() >= 4) requiredLevel = packet.readUInt32(); } // InventoryResult enum (AzerothCore 3.3.5a) const char* errMsg = nullptr; char levelBuf[64]; switch (error) { case 1: if (requiredLevel > 0) { std::snprintf(levelBuf, sizeof(levelBuf), "You must reach level %u to use that item.", requiredLevel); addSystemChatMessage(levelBuf); } else { addSystemChatMessage("You must reach a higher level 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(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(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: { // uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)), // 1 = single update (uint8 icon + uint64 guid) size_t remRTU = packet.getSize() - packet.getReadPos(); if (remRTU < 1) break; uint8_t rtuType = packet.readUInt8(); if (rtuType == 0) { // Full update: always 8 entries for (uint32_t i = 0; i < kRaidMarkCount; ++i) { if (packet.getSize() - packet.getReadPos() < 9) break; uint8_t icon = packet.readUInt8(); uint64_t guid = packet.readUInt64(); if (icon < kRaidMarkCount) raidTargetGuids_[icon] = guid; } } else { // Single update if (packet.getSize() - packet.getReadPos() >= 9) { uint8_t icon = packet.readUInt8(); uint64_t guid = packet.readUInt64(); if (icon < kRaidMarkCount) raidTargetGuids_[icon] = guid; } } LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); break; } case Opcode::SMSG_BUY_ITEM: { // uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount // Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT. if (packet.getSize() - packet.getReadPos() >= 20) { uint64_t vendorGuid = packet.readUInt64(); uint32_t vendorSlot = packet.readUInt32(); int32_t newCount = static_cast(packet.readUInt32()); uint32_t itemCount = packet.readUInt32(); LOG_DEBUG("SMSG_BUY_ITEM: vendorGuid=0x", std::hex, vendorGuid, std::dec, " slot=", vendorSlot, " newCount=", newCount, " bought=", itemCount); pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; } break; } case Opcode::SMSG_CRITERIA_UPDATE: { // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime // Achievement criteria progress (informational — no criteria UI yet). if (packet.getSize() - packet.getReadPos() >= 20) { uint32_t criteriaId = packet.readUInt32(); uint64_t progress = packet.readUInt64(); /*uint32_t elapsedTime =*/ packet.readUInt32(); /*uint32_t createTime =*/ packet.readUInt32(); LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); } break; } case Opcode::SMSG_BARBER_SHOP_RESULT: { // uint32 result (0 = success, 1 = no money, 2 = not barber, 3 = sitting) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Hairstyle changed."); } else { const char* msg = (result == 1) ? "Not enough money for new hairstyle." : (result == 2) ? "You are not at a barber shop." : (result == 3) ? "You must stand up to use the barber shop." : "Barber shop unavailable."; addSystemChatMessage(msg); } LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); } break; } case Opcode::SMSG_OVERRIDE_LIGHT: { // uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs if (packet.getSize() - packet.getReadPos() >= 12) { uint32_t zoneLightId = packet.readUInt32(); uint32_t overrideLightId = packet.readUInt32(); uint32_t transitionMs = packet.readUInt32(); overrideLightId_ = overrideLightId; overrideLightTransMs_ = transitionMs; LOG_DEBUG("SMSG_OVERRIDE_LIGHT: zone=", zoneLightId, " override=", overrideLightId, " transition=", transitionMs, "ms"); } break; } case Opcode::SMSG_WEATHER: { // Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt) // TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes) if (packet.getSize() - packet.getReadPos() >= 8) { uint32_t wType = packet.readUInt32(); float wIntensity = packet.readFloat(); if (packet.getSize() - packet.getReadPos() >= 1) /*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_SCRIPT_MESSAGE: { // Server-script text message — display in system chat std::string msg = packet.readString(); if (!msg.empty()) { addSystemChatMessage(msg); LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg); } break; } case Opcode::SMSG_ENCHANTMENTLOG: { // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType if (packet.getSize() - packet.getReadPos() >= 28) { /*uint64_t targetGuid =*/ packet.readUInt64(); /*uint64_t casterGuid =*/ packet.readUInt64(); uint32_t spellId = packet.readUInt32(); /*uint32_t displayId =*/ packet.readUInt32(); /*uint32_t animType =*/ packet.readUInt32(); LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", spellId); } break; } case Opcode::SMSG_SOCKET_GEMS_RESULT: { // uint64 itemGuid + uint32 result (0 = success) if (packet.getSize() - packet.getReadPos() >= 12) { /*uint64_t itemGuid =*/ packet.readUInt64(); uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Gems socketed successfully."); } else { addSystemChatMessage("Failed to socket gems."); } LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result); } break; } case Opcode::SMSG_ITEM_REFUND_RESULT: { // uint64 itemGuid + uint32 result (0=success) if (packet.getSize() - packet.getReadPos() >= 12) { /*uint64_t itemGuid =*/ packet.readUInt64(); uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Item returned. Refund processed."); } else { addSystemChatMessage("Could not return item for refund."); } LOG_DEBUG("SMSG_ITEM_REFUND_RESULT: result=", result); } break; } case Opcode::SMSG_ITEM_TIME_UPDATE: { // uint64 itemGuid + uint32 durationMs — item duration ticking down if (packet.getSize() - packet.getReadPos() >= 12) { /*uint64_t itemGuid =*/ packet.readUInt64(); uint32_t durationMs = packet.readUInt32(); LOG_DEBUG("SMSG_ITEM_TIME_UPDATE: remainingMs=", durationMs); } break; } case Opcode::SMSG_RESURRECT_FAILED: { // uint32 reason — various resurrection failures if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t reason = packet.readUInt32(); const char* msg = (reason == 1) ? "The target cannot be resurrected right now." : (reason == 2) ? "Cannot resurrect in this area." : "Resurrection failed."; addSystemChatMessage(msg); LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason); } 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(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(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 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(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; } // Fall back to killObjectives (parsed from SMSG_QUEST_QUERY_RESPONSE). // Note: npcOrGoId < 0 means game object; server always sends entry as uint32 // in QUESTUPDATE_ADD_KILL regardless of type, so match by absolute value. if (reqCount == 0) { for (const auto& obj : quest.killObjectives) { if (obj.npcOrGoId == 0 || obj.required == 0) continue; uint32_t objEntry = static_cast( obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); if (objEntry == entry) { reqCount = obj.required; break; } } } if (reqCount == 0) reqCount = count; // last-resort: avoid 0/0 display quest.killCounts[entry] = {count, reqCount}; std::string creatureName = getCachedCreatureName(entry); std::string progressMsg = quest.title + ": "; if (!creatureName.empty()) { progressMsg += creatureName + " "; } progressMsg += 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; bool tracksItem = quest.requiredItemCounts.count(itemId) > 0 || quest.itemCounts.count(itemId) > 0; // Also check itemObjectives parsed from SMSG_QUEST_QUERY_RESPONSE in case // requiredItemCounts hasn't been populated yet (race during quest accept). if (!tracksItem) { for (const auto& obj : quest.itemObjectives) { if (obj.itemId == itemId && obj.required > 0) { quest.requiredItemCounts.emplace(itemId, obj.required); tracksItem = true; break; } } } 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: { // This opcode is aliased to SMSG_SET_REST_START in the opcode table // because both share opcode 0x21E in WotLK 3.3.5a. // In WotLK: payload = uint32 areaId (entering rest) or 0 (leaving rest). // In Classic/TBC: payload = uint32 questId (force-remove a quest). if (packet.getSize() - packet.getReadPos() < 4) { LOG_WARNING("SMSG_QUEST_FORCE_REMOVE/SET_REST_START too short"); break; } uint32_t value = packet.readUInt32(); // WotLK uses this opcode as SMSG_SET_REST_START: non-zero = entering // a rest area (inn/city), zero = leaving. Classic/TBC use it for quest removal. if (!isClassicLikeExpansion() && !isActiveExpansion("tbc")) { // WotLK: treat as SET_REST_START bool nowResting = (value != 0); if (nowResting != isResting_) { isResting_ = nowResting; addSystemChatMessage(isResting_ ? "You are now resting." : "You are no longer resting."); } break; } // Classic/TBC: treat as QUEST_FORCE_REMOVE (uint32 questId) uint32_t questId = value; 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; 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 // Classic/Turtle = stride 3, TBC = stride 4 — all use 40 fixed fields + 4 strings. // WotLK = stride 5, uses 55 fixed fields + 5 strings. const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); const QuestQueryObjectives objs = extractQuestQueryObjectives(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; } // Store structured kill/item objectives for later kill-count restoration. if (objs.valid) { for (int i = 0; i < 4; ++i) { q.killObjectives[i].npcOrGoId = objs.kills[i].npcOrGoId; q.killObjectives[i].required = objs.kills[i].required; } for (int i = 0; i < 6; ++i) { q.itemObjectives[i].itemId = objs.items[i].itemId; q.itemObjectives[i].required = objs.items[i].required; } // Now that we have the objective creature IDs, apply any packed kill // counts from the player update fields that arrived at login. applyPackedKillCountsFromFields(q); // Pre-fetch creature/GO names and item info so objective display is // populated by the time the player opens the quest log. for (int i = 0; i < 4; ++i) { int32_t id = objs.kills[i].npcOrGoId; if (id == 0 || objs.kills[i].required == 0) continue; if (id > 0) queryCreatureInfo(static_cast(id), 0); else queryGameObjectInfo(static_cast(-id), 0); } for (int i = 0; i < 6; ++i) { if (objs.items[i].itemId != 0 && objs.items[i].required != 0) queryItemInfo(objs.items[i].itemId, 0); } LOG_DEBUG("Quest ", questId, " objectives parsed: kills=[", objs.kills[0].npcOrGoId, "/", objs.kills[0].required, ", ", objs.kills[1].npcOrGoId, "/", objs.kills[1].required, ", ", objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ", objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]"); } 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_INFO("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); // Provide reason-specific feedback (WotLK TRANSFER_ABORT_* codes) const char* abortMsg = nullptr; switch (reason) { case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break; case 0x02: abortMsg = "Transfer aborted: expansion required."; break; case 0x03: abortMsg = "Transfer aborted: instance not found."; break; case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break; case 0x06: abortMsg = "Transfer aborted: instance is full."; break; case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break; case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break; case 0x09: abortMsg = "Transfer aborted: not enough players."; break; case 0x0C: abortMsg = "Transfer aborted."; break; default: abortMsg = "Transfer aborted."; break; } addSystemChatMessage(abortMsg); 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(standState_), " (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit" : standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")"); if (standStateCallback_) { standStateCallback_(standState_); } } 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: case Opcode::MSG_SET_DUNGEON_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: { // Server sends: uint64 npcGuid + uint32 cost // Client must respond with the same opcode containing uint64 npcGuid to confirm. if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } talentWipeNpcGuid_ = packet.readUInt64(); talentWipeCost_ = packet.readUInt32(); talentWipePending_ = true; LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_, std::dec, " cost=", talentWipeCost_); 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: case Opcode::MSG_MOVE_SET_WALK_MODE: case Opcode::MSG_MOVE_SET_RUN_MODE: case Opcode::MSG_MOVE_START_PITCH_UP: case Opcode::MSG_MOVE_START_PITCH_DOWN: case Opcode::MSG_MOVE_STOP_PITCH: case Opcode::MSG_MOVE_START_ASCEND: case Opcode::MSG_MOVE_STOP_ASCEND: case Opcode::MSG_MOVE_START_DESCEND: case Opcode::MSG_MOVE_SET_PITCH: case Opcode::MSG_MOVE_GRAVITY_CHNG: case Opcode::MSG_MOVE_UPDATE_CAN_FLY: case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::MSG_MOVE_ROOT: case Opcode::MSG_MOVE_UNROOT: 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_AUCTION_REMOVED_NOTIFICATION: { // uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled if (packet.getSize() - packet.getReadPos() >= 12) { /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); /*uint32_t itemRandom =*/ packet.readUInt32(); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); addSystemChatMessage("Your auction of " + itemName + " has expired."); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_OPEN_CONTAINER: { // uint64 containerGuid — tells client to open this container // The actual items come via update packets; we just log this. if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t containerGuid = packet.readUInt64(); LOG_DEBUG("SMSG_OPEN_CONTAINER: guid=0x", std::hex, containerGuid, std::dec); } break; } case Opcode::SMSG_GM_TICKET_STATUS_UPDATE: // GM ticket status (new/updated); no ticket UI yet packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PLAYER_VEHICLE_DATA: // Vehicle data update for player in vehicle; no vehicle UI yet packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_TAXINODE_STATUS: { // guid(8) + status(1): status 1 = NPC has available/new routes for this player if (packet.getSize() - packet.getReadPos() >= 9) { uint64_t npcGuid = packet.readUInt64(); uint8_t status = packet.readUInt8(); taxiNpcHasRoutes_[npcGuid] = (status != 0); } break; } case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE: case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: { // TBC 2.4.3 aura tracking: replaces SMSG_AURA_UPDATE which doesn't exist in TBC. // Format: uint64 targetGuid + uint8 count + N×{uint8 slot, uint32 spellId, // uint8 effectIndex, uint8 flags, uint32 durationMs, uint32 maxDurationMs} const bool isInit = (*logicalOp == Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE); auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; if (remaining() < 9) { packet.setReadPos(packet.getSize()); break; } uint64_t auraTargetGuid = packet.readUInt64(); uint8_t count = packet.readUInt8(); std::vector* auraList = nullptr; if (auraTargetGuid == playerGuid) auraList = &playerAuras; else if (auraTargetGuid == targetGuid) auraList = &targetAuras; if (auraList && isInit) auraList->clear(); uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); for (uint8_t i = 0; i < count && remaining() >= 15; i++) { uint8_t slot = packet.readUInt8(); // 1 byte uint32_t spellId = packet.readUInt32(); // 4 bytes (void) packet.readUInt8(); // effectIndex: 1 byte (unused for slot display) uint8_t flags = packet.readUInt8(); // 1 byte uint32_t durationMs = packet.readUInt32(); // 4 bytes uint32_t maxDurMs = packet.readUInt32(); // 4 bytes — total 15 bytes per entry if (auraList) { while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); AuraSlot& a = (*auraList)[slot]; a.spellId = spellId; a.flags = flags; a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast(durationMs); a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast(maxDurMs); a.receivedAtMs = nowMs; } } 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; // ---- Logout cancel ACK ---- case Opcode::SMSG_LOGOUT_CANCEL_ACK: // loggingOut_ already cleared by cancelLogout(); this is server's confirmation packet.setReadPos(packet.getSize()); break; // ---- Guild decline ---- case Opcode::SMSG_GUILD_DECLINE: { if (packet.getReadPos() < packet.getSize()) { std::string name = packet.readString(); addSystemChatMessage(name + " declined your guild invitation."); } break; } // ---- Talents involuntarily reset ---- case Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET: addSystemChatMessage("Your talents have been reset by the server."); packet.setReadPos(packet.getSize()); break; // ---- Account data sync ---- case Opcode::SMSG_UPDATE_ACCOUNT_DATA: case Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE: packet.setReadPos(packet.getSize()); break; // ---- Rest state ---- case Opcode::SMSG_SET_REST_START: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t restTrigger = packet.readUInt32(); isResting_ = (restTrigger > 0); addSystemChatMessage(isResting_ ? "You are now resting." : "You are no longer resting."); } break; } // ---- Aura duration update ---- case Opcode::SMSG_UPDATE_AURA_DURATION: { if (packet.getSize() - packet.getReadPos() >= 5) { uint8_t slot = packet.readUInt8(); uint32_t durationMs = packet.readUInt32(); handleUpdateAuraDuration(slot, durationMs); } break; } // ---- Item name query response ---- case Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t itemId = packet.readUInt32(); std::string name = packet.readString(); if (!itemInfoCache_.count(itemId) && !name.empty()) { ItemQueryResponseData stub; stub.entry = itemId; stub.name = std::move(name); stub.valid = true; itemInfoCache_[itemId] = std::move(stub); } } packet.setReadPos(packet.getSize()); break; } // ---- Mount special animation ---- case Opcode::SMSG_MOUNTSPECIAL_ANIM: (void)UpdateObjectParser::readPackedGuid(packet); break; // ---- Character customisation / faction change results ---- case Opcode::SMSG_CHAR_CUSTOMIZE: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); addSystemChatMessage(result == 0 ? "Character customization complete." : "Character customization failed."); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CHAR_FACTION_CHANGE: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); addSystemChatMessage(result == 0 ? "Faction change complete." : "Faction change failed."); } packet.setReadPos(packet.getSize()); break; } // ---- Invalidate cached player data ---- case Opcode::SMSG_INVALIDATE_PLAYER: { if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t guid = packet.readUInt64(); playerNameCache.erase(guid); } break; } // ---- Movie trigger ---- case Opcode::SMSG_TRIGGER_MOVIE: packet.setReadPos(packet.getSize()); break; // ---- Equipment sets ---- case Opcode::SMSG_EQUIPMENT_SET_LIST: handleEquipmentSetList(packet); break; case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); if (result != 0) addSystemChatMessage("Failed to equip item set."); } break; } // ---- LFG informational (not yet surfaced in UI) ---- case Opcode::SMSG_LFG_UPDATE: case Opcode::SMSG_LFG_UPDATE_LFG: case Opcode::SMSG_LFG_UPDATE_LFM: case Opcode::SMSG_LFG_UPDATE_QUEUED: case Opcode::SMSG_LFG_PENDING_INVITE: case Opcode::SMSG_LFG_PENDING_MATCH: case Opcode::SMSG_LFG_PENDING_MATCH_DONE: packet.setReadPos(packet.getSize()); break; // ---- GM Ticket responses ---- case Opcode::SMSG_GMTICKET_CREATE: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t res = packet.readUInt8(); addSystemChatMessage(res == 1 ? "GM ticket submitted." : "Failed to submit GM ticket."); } break; } case Opcode::SMSG_GMTICKET_UPDATETEXT: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t res = packet.readUInt8(); addSystemChatMessage(res == 1 ? "GM ticket updated." : "Failed to update GM ticket."); } break; } case Opcode::SMSG_GMTICKET_DELETETICKET: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t res = packet.readUInt8(); addSystemChatMessage(res == 9 ? "GM ticket deleted." : "No ticket to delete."); } break; } case Opcode::SMSG_GMTICKET_GETTICKET: case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: packet.setReadPos(packet.getSize()); break; // ---- DK rune tracking ---- case Opcode::SMSG_CONVERT_RUNE: { // uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death) if (packet.getSize() - packet.getReadPos() < 2) { packet.setReadPos(packet.getSize()); break; } uint8_t idx = packet.readUInt8(); uint8_t type = packet.readUInt8(); if (idx < 6) playerRunes_[idx].type = static_cast(type & 0x3); break; } case Opcode::SMSG_RESYNC_RUNES: { // uint8 runeReadyMask (bit i=1 → rune i is ready) // uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255) if (packet.getSize() - packet.getReadPos() < 7) { packet.setReadPos(packet.getSize()); break; } uint8_t readyMask = packet.readUInt8(); for (int i = 0; i < 6; i++) { uint8_t cd = packet.readUInt8(); playerRunes_[i].ready = (readyMask & (1u << i)) != 0; playerRunes_[i].readyFraction = 1.0f - cd / 255.0f; if (playerRunes_[i].ready) playerRunes_[i].readyFraction = 1.0f; } break; } case Opcode::SMSG_ADD_RUNE_POWER: { // uint32 runeMask (bit i=1 → rune i just became ready) if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t runeMask = packet.readUInt32(); for (int i = 0; i < 6; i++) { if (runeMask & (1u << i)) { playerRunes_[i].ready = true; playerRunes_[i].readyFraction = 1.0f; } } break; } // ---- Spell combat logs (consume) ---- case Opcode::SMSG_AURACASTLOG: case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { // Classic/TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) const bool shieldClassicLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); const size_t shieldMinSz = shieldClassicLike ? 24u : 2u; if (packet.getSize() - packet.getReadPos() < shieldMinSz) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = shieldClassicLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); uint64_t casterGuid = shieldClassicLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } /*uint32_t spellId =*/ packet.readUInt32(); uint32_t damage = packet.readUInt32(); if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect if (casterGuid == playerGuid) { // We have a damage shield that reflected damage addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, true); } else if (victimGuid == playerGuid) { // A damage shield hit us (e.g. target's Thorns) addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, false); } break; } case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 const bool immuneTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); const size_t minSz = immuneTbcLike ? 21u : 2u; if (packet.getSize() - packet.getReadPos() < minSz) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = immuneTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (immuneTbcLike ? 8u : 2u)) break; uint64_t victimGuid = immuneTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint32_t spellId =*/ packet.readUInt32(); /*uint8_t saveType =*/ packet.readUInt8(); // Show IMMUNE text when the player is the caster (we hit an immune target) // or the victim (we are immune) if (casterGuid == playerGuid || victimGuid == playerGuid) { addCombatText(CombatTextEntry::IMMUNE, 0, 0, casterGuid == playerGuid); } break; } case Opcode::SMSG_SPELLDISPELLOG: { // WotLK: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = dispelTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) break; uint64_t victimGuid = dispelTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) break; /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); uint32_t count = packet.readUInt32(); // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { const char* verb = isStolen ? "stolen" : "dispelled"; // Collect first dispelled spell name for the message // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) std::string firstSpellName; for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { uint32_t dispelledId = packet.readUInt32(); /*uint8_t isPositive =*/ packet.readUInt8(); if (i == 0) { const std::string& nm = getSpellName(dispelledId); firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; } } if (!firstSpellName.empty()) { char buf[256]; if (victimGuid == playerGuid && casterGuid != playerGuid) std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); else if (casterGuid == playerGuid) std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); addSystemChatMessage(buf); } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLSTEALLOG: { // Sent to the CASTER (Mage) when Spellsteal succeeds. // Wire format mirrors SPELLDISPELLOG: // WotLK: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count // + count × (uint32 stolenSpellId + uint8 isPositive) // TBC/Classic: full uint64 victim + full uint64 caster + same tail const bool stealTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } /*uint64_t stealVictim =*/ stealTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } uint64_t stealCaster = stealTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } /*uint32_t stealSpellId =*/ packet.readUInt32(); /*uint8_t isStolen =*/ packet.readUInt8(); uint32_t stealCount = packet.readUInt32(); // Show feedback only when we are the caster (we stole something) if (stealCaster == playerGuid) { std::string stolenName; for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { uint32_t stolenId = packet.readUInt32(); /*uint8_t isPos =*/ packet.readUInt8(); if (i == 0) { const std::string& nm = getSpellName(stolenId); stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; } } if (!stolenName.empty()) { char buf[256]; std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); addSystemChatMessage(buf); } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLLOGEXECUTE: case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: { // TBC 2.4.3: clear a single aura slot for a unit // Format: uint64 targetGuid + uint8 slot if (packet.getSize() - packet.getReadPos() >= 9) { uint64_t clearGuid = packet.readUInt64(); uint8_t slot = packet.readUInt8(); std::vector* auraList = nullptr; if (clearGuid == playerGuid) auraList = &playerAuras; else if (clearGuid == targetGuid) auraList = &targetAuras; if (auraList && slot < auraList->size()) { (*auraList)[slot] = AuraSlot{}; } } packet.setReadPos(packet.getSize()); break; } // ---- Misc consume ---- case Opcode::SMSG_COMPLAIN_RESULT: case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: // Consume — not yet processed packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_RESUME_CAST_BAR: { // WotLK: packed_guid caster + packed_guid target + uint32 spellId + uint32 remainingMs + uint32 totalMs + uint8 schoolMask // TBC/Classic: uint64 caster + uint64 target + ... const bool rcbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; if (remaining() < (rcbTbc ? 8u : 1u)) break; uint64_t caster = rcbTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (remaining() < (rcbTbc ? 8u : 1u)) break; if (rcbTbc) packet.readUInt64(); // target (discard) else (void)UpdateObjectParser::readPackedGuid(packet); // target if (remaining() < 12) break; uint32_t spellId = packet.readUInt32(); uint32_t remainMs = packet.readUInt32(); uint32_t totalMs = packet.readUInt32(); if (totalMs > 0) { if (caster == playerGuid) { casting = true; currentCastSpellId = spellId; castTimeTotal = totalMs / 1000.0f; castTimeRemaining = remainMs / 1000.0f; } else { auto& s = unitCastStates_[caster]; s.casting = true; s.spellId = spellId; s.timeTotal = totalMs / 1000.0f; s.timeRemaining = remainMs / 1000.0f; } LOG_DEBUG("SMSG_RESUME_CAST_BAR: caster=0x", std::hex, caster, std::dec, " spell=", spellId, " remaining=", remainMs, "ms total=", totalMs, "ms"); } break; } // ---- Channeled spell start/tick (WotLK: packed GUIDs; TBC/Classic: full uint64) ---- case Opcode::MSG_CHANNEL_START: { // casterGuid + uint32 spellId + uint32 totalDurationMs const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t chanCaster = tbcOrClassic ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t chanSpellId = packet.readUInt32(); uint32_t chanTotalMs = packet.readUInt32(); if (chanTotalMs > 0 && chanCaster != 0) { if (chanCaster == playerGuid) { casting = true; currentCastSpellId = chanSpellId; castTimeTotal = chanTotalMs / 1000.0f; castTimeRemaining = castTimeTotal; } else { auto& s = unitCastStates_[chanCaster]; s.casting = true; s.spellId = chanSpellId; s.timeTotal = chanTotalMs / 1000.0f; s.timeRemaining = s.timeTotal; } LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, " spell=", chanSpellId, " total=", chanTotalMs, "ms"); } break; } case Opcode::MSG_CHANNEL_UPDATE: { // casterGuid + uint32 remainingMs const bool tbcOrClassic2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t chanCaster2 = tbcOrClassic2 ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t chanRemainMs = packet.readUInt32(); if (chanCaster2 == playerGuid) { castTimeRemaining = chanRemainMs / 1000.0f; if (chanRemainMs == 0) { casting = false; currentCastSpellId = 0; } } else if (chanCaster2 != 0) { auto it = unitCastStates_.find(chanCaster2); if (it != unitCastStates_.end()) { it->second.timeRemaining = chanRemainMs / 1000.0f; if (chanRemainMs == 0) unitCastStates_.erase(it); } } LOG_DEBUG("MSG_CHANNEL_UPDATE: caster=0x", std::hex, chanCaster2, std::dec, " remaining=", chanRemainMs, "ms"); break; } case Opcode::SMSG_THREAT_UPDATE: { // packed_guid (unit) + packed_guid (target) + uint32 count // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings if (packet.getSize() - packet.getReadPos() < 1) break; (void)UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; (void)UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t cnt = packet.readUInt32(); for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) { (void)UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 4) packet.readUInt32(); } break; } case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: { // uint32 slot + packed_guid unit (0 packed = clear slot) if (packet.getSize() - packet.getReadPos() < 5) { packet.setReadPos(packet.getSize()); break; } uint32_t slot = packet.readUInt32(); uint64_t unit = UpdateObjectParser::readPackedGuid(packet); if (slot < kMaxEncounterSlots) { encounterUnitGuids_[slot] = unit; LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot, " guid=0x", std::hex, unit, std::dec); } break; } case Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP: case Opcode::SMSG_UPDATE_LAST_INSTANCE: case Opcode::SMSG_SEND_ALL_COMBAT_LOG: case Opcode::SMSG_SET_PROJECTILE_POSITION: case Opcode::SMSG_AUCTION_LIST_PENDING_SALES: packet.setReadPos(packet.getSize()); break; // ---- Server-first achievement broadcast ---- case Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT: { // charName (cstring) + guid (uint64) + achievementId (uint32) + ... if (packet.getReadPos() < packet.getSize()) { std::string charName = packet.readString(); if (packet.getSize() - packet.getReadPos() >= 12) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t achievementId = packet.readUInt32(); loadAchievementNameCache(); auto nit = achievementNameCache_.find(achievementId); char buf[256]; if (nit != achievementNameCache_.end() && !nit->second.empty()) { std::snprintf(buf, sizeof(buf), "%s is the first on the realm to earn: %s!", charName.c_str(), nit->second.c_str()); } else { std::snprintf(buf, sizeof(buf), "%s is the first on the realm to earn achievement #%u!", charName.c_str(), achievementId); } addSystemChatMessage(buf); } } packet.setReadPos(packet.getSize()); break; } // ---- Forced faction reactions ---- case Opcode::SMSG_SET_FORCED_REACTIONS: handleSetForcedReactions(packet); break; // ---- Spline speed changes for other units ---- case Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED: case Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_WALK_SPEED: case Opcode::SMSG_SPLINE_SET_TURN_RATE: case Opcode::SMSG_SPLINE_SET_PITCH_RATE: { // Minimal parse: PackedGuid + float speed if (packet.getSize() - packet.getReadPos() < 5) break; uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; float sSpeed = packet.readFloat(); if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) { if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED) serverFlightSpeed_ = sSpeed; else if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED) serverFlightBackSpeed_ = sSpeed; else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED) serverSwimBackSpeed_ = sSpeed; else if (*logicalOp == Opcode::SMSG_SPLINE_SET_WALK_SPEED) serverWalkSpeed_ = sSpeed; else if (*logicalOp == Opcode::SMSG_SPLINE_SET_TURN_RATE) serverTurnRate_ = sSpeed; // rad/s } break; } // ---- Spline move flag changes for other units ---- case Opcode::SMSG_SPLINE_MOVE_UNROOT: case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER: case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: { // Minimal parse: PackedGuid only — no animation-relevant state change. if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); } break; } case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: { // PackedGuid + synthesised move-flags=0 → clears flying animation. if (packet.getSize() - packet.getReadPos() < 1) break; uint64_t guid = UpdateObjectParser::readPackedGuid(packet); if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY break; } // ---- Quest failure notification ---- case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { // uint32 questId + uint32 reason if (packet.getSize() - packet.getReadPos() >= 8) { /*uint32_t questId =*/ packet.readUInt32(); uint32_t reason = packet.readUInt32(); const char* reasonStr = "Unknown reason"; switch (reason) { case 1: reasonStr = "Quest failed: failed conditions"; break; case 2: reasonStr = "Quest failed: inventory full"; break; case 3: reasonStr = "Quest failed: too far away"; break; case 4: reasonStr = "Quest failed: another quest is blocking"; break; case 5: reasonStr = "Quest failed: wrong time of day"; break; case 6: reasonStr = "Quest failed: wrong race"; break; case 7: reasonStr = "Quest failed: wrong class"; break; } addSystemChatMessage(reasonStr); } break; } // ---- Suspend comms (requires ACK) ---- case Opcode::SMSG_SUSPEND_COMMS: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t seqIdx = packet.readUInt32(); if (socket) { network::Packet ack(wireOpcode(Opcode::CMSG_SUSPEND_COMMS_ACK)); ack.writeUInt32(seqIdx); socket->send(ack); } } break; } // ---- Pre-resurrect state ---- case Opcode::SMSG_PRE_RESURRECT: { // packed GUID of the player to enter pre-resurrect (void)UpdateObjectParser::readPackedGuid(packet); break; } // ---- Hearthstone bind error ---- case Opcode::SMSG_PLAYERBINDERROR: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t error = packet.readUInt32(); if (error == 0) addSystemChatMessage("Your hearthstone is not bound."); else addSystemChatMessage("Hearthstone bind failed."); } break; } // ---- Instance/raid errors ---- case Opcode::SMSG_RAID_GROUP_ONLY: { addSystemChatMessage("You must be in a raid group to enter this instance."); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_RAID_READY_CHECK_ERROR: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t err = packet.readUInt8(); if (err == 0) addSystemChatMessage("Ready check failed: not in a group."); else if (err == 1) addSystemChatMessage("Ready check failed: in instance."); else addSystemChatMessage("Ready check failed."); } break; } case Opcode::SMSG_RESET_FAILED_NOTIFY: { addSystemChatMessage("Cannot reset instance: another player is still inside."); packet.setReadPos(packet.getSize()); break; } // ---- Realm split ---- case Opcode::SMSG_REALM_SPLIT: { // uint32 splitType + uint32 deferTime + string realmName // Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers. uint32_t splitType = 0; if (packet.getSize() - packet.getReadPos() >= 4) splitType = packet.readUInt32(); packet.setReadPos(packet.getSize()); if (socket) { network::Packet resp(wireOpcode(Opcode::CMSG_REALM_SPLIT)); resp.writeUInt32(splitType); resp.writeString("3.3.5"); socket->send(resp); LOG_DEBUG("SMSG_REALM_SPLIT splitType=", splitType, " — sent CMSG_REALM_SPLIT ack"); } break; } // ---- Real group update (status flags) ---- case Opcode::SMSG_REAL_GROUP_UPDATE: packet.setReadPos(packet.getSize()); break; // ---- Play music (WotLK standard opcode) ---- case Opcode::SMSG_PLAY_MUSIC: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t soundId = packet.readUInt32(); if (playMusicCallback_) playMusicCallback_(soundId); } break; } // ---- Play object/spell sounds ---- case Opcode::SMSG_PLAY_OBJECT_SOUND: case Opcode::SMSG_PLAY_SPELL_IMPACT: if (packet.getSize() - packet.getReadPos() >= 12) { // uint32 soundId + uint64 sourceGuid uint32_t soundId = packet.readUInt32(); uint64_t srcGuid = packet.readUInt64(); LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND/SPELL_IMPACT id=", soundId, " src=0x", std::hex, srcGuid, std::dec); if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); else if (playSoundCallback_) playSoundCallback_(soundId); } else if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t soundId = packet.readUInt32(); if (playSoundCallback_) playSoundCallback_(soundId); } packet.setReadPos(packet.getSize()); break; // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { // WotLK: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... // TBC/Classic: same but full uint64 GUIDs // Show RESIST combat text when player resists an incoming spell. const bool rlTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } /*uint32_t hitInfo =*/ packet.readUInt32(); if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } uint64_t attackerGuid = rlTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = rlTbcLike ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); (void)attackerGuid; // Show RESIST when player is the victim; show as caster-side MISS when player is attacker if (victimGuid == playerGuid) { addCombatText(CombatTextEntry::MISS, 0, spellId, false); } else if (attackerGuid == playerGuid) { addCombatText(CombatTextEntry::MISS, 0, spellId, true); } packet.setReadPos(packet.getSize()); break; } // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: addSystemChatMessage("You read the item."); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: addSystemChatMessage("You cannot read this item."); packet.setReadPos(packet.getSize()); break; // ---- Completed quests query ---- case Opcode::SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t count = packet.readUInt32(); if (count <= 4096) { for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t questId = packet.readUInt32(); completedQuests_.insert(questId); } LOG_DEBUG("SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: ", count, " completed quests"); } } packet.setReadPos(packet.getSize()); break; } // ---- PVP quest kill update ---- case Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL: { // WotLK 3.3.5a format: uint64 guid + uint32 questId + uint32 count + uint32 reqCount // Classic format: uint64 guid + uint32 questId + uint32 count (no reqCount) if (packet.getSize() - packet.getReadPos() >= 16) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t questId = packet.readUInt32(); uint32_t count = packet.readUInt32(); uint32_t reqCount = 0; if (packet.getSize() - packet.getReadPos() >= 4) { reqCount = packet.readUInt32(); } // Update quest log kill counts (PvP kills use entry=0 as the key // since there's no specific creature entry — one slot per quest). constexpr uint32_t PVP_KILL_ENTRY = 0u; for (auto& quest : questLog_) { if (quest.questId != questId) continue; if (reqCount == 0) { auto it = quest.killCounts.find(PVP_KILL_ENTRY); if (it != quest.killCounts.end()) reqCount = it->second.second; } if (reqCount == 0) { // Pull required count from kill objectives (npcOrGoId == 0 slot, if any) for (const auto& obj : quest.killObjectives) { if (obj.npcOrGoId == 0 && obj.required > 0) { reqCount = obj.required; break; } } } if (reqCount == 0) reqCount = count; quest.killCounts[PVP_KILL_ENTRY] = {count, reqCount}; std::string progressMsg = quest.title + ": PvP kills " + std::to_string(count) + "/" + std::to_string(reqCount); addSystemChatMessage(progressMsg); break; } } break; } // ---- NPC not responding ---- case Opcode::SMSG_NPC_WONT_TALK: addSystemChatMessage("That creature can't talk to you right now."); packet.setReadPos(packet.getSize()); break; // ---- Petition ---- case Opcode::SMSG_OFFER_PETITION_ERROR: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t err = packet.readUInt32(); if (err == 1) addSystemChatMessage("Player is already in a guild."); else if (err == 2) addSystemChatMessage("Player already has a petition."); else addSystemChatMessage("Cannot offer petition to that player."); } break; } case Opcode::SMSG_PETITION_QUERY_RESPONSE: case Opcode::SMSG_PETITION_SHOW_SIGNATURES: case Opcode::SMSG_PETITION_SIGN_RESULTS: packet.setReadPos(packet.getSize()); break; // ---- Pet system ---- case Opcode::SMSG_PET_MODE: { // uint64 petGuid, uint32 mode // mode bits: low byte = command state, next byte = react state if (packet.getSize() - packet.getReadPos() >= 12) { uint64_t modeGuid = packet.readUInt64(); uint32_t mode = packet.readUInt32(); if (modeGuid == petGuid_) { petCommand_ = static_cast(mode & 0xFF); petReact_ = static_cast((mode >> 8) & 0xFF); LOG_DEBUG("SMSG_PET_MODE: command=", (int)petCommand_, " react=", (int)petReact_); } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_BROKEN: // Pet bond broken (died or forcibly dismissed) — clear pet state petGuid_ = 0; petSpellList_.clear(); petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); addSystemChatMessage("Your pet has died."); LOG_INFO("SMSG_PET_BROKEN: pet bond broken"); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PET_LEARNED_SPELL: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t spellId = packet.readUInt32(); petSpellList_.push_back(spellId); LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_UNLEARNED_SPELL: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t spellId = packet.readUInt32(); petSpellList_.erase( std::remove(petSpellList_.begin(), petSpellList_.end(), spellId), petSpellList_.end()); petAutocastSpells_.erase(spellId); LOG_DEBUG("SMSG_PET_UNLEARNED_SPELL: spellId=", spellId); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_CAST_FAILED: { if (packet.getSize() - packet.getReadPos() >= 5) { uint8_t castCount = packet.readUInt8(); uint32_t spellId = packet.readUInt32(); uint32_t reason = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, " reason=", reason, " castCount=", (int)castCount); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_GUIDS: case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: case Opcode::SMSG_PET_UNLEARN_CONFIRM: case Opcode::SMSG_PET_NAME_INVALID: case Opcode::SMSG_PET_RENAMEABLE: case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; // ---- Inspect (full character inspection) ---- case Opcode::SMSG_INSPECT: packet.setReadPos(packet.getSize()); break; // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] handleCompressedMoves(packet); break; case Opcode::SMSG_MULTIPLE_PACKETS: { // Each sub-packet uses the standard WotLK server wire format: // uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2) // uint16_le subOpcode // payload (subSize - 2 bytes) const auto& pdata = packet.getData(); size_t dataLen = pdata.size(); size_t pos = packet.getReadPos(); static uint32_t multiPktWarnCount = 0; while (pos + 4 <= dataLen) { uint16_t subSize = static_cast( (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); if (subSize < 2) break; size_t payloadLen = subSize - 2; if (pos + 4 + payloadLen > dataLen) { if (++multiPktWarnCount <= 10) { LOG_WARNING("SMSG_MULTIPLE_PACKETS: sub-packet overruns buffer at pos=", pos, " subSize=", subSize, " dataLen=", dataLen); } break; } uint16_t subOpcode = static_cast(pdata[pos + 2]) | (static_cast(pdata[pos + 3]) << 8); std::vector subPayload(pdata.begin() + pos + 4, pdata.begin() + pos + 4 + payloadLen); network::Packet subPacket(subOpcode, std::move(subPayload)); handlePacket(subPacket); pos += 4 + payloadLen; } packet.setReadPos(packet.getSize()); break; } // ---- Misc consume ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: case Opcode::SMSG_PROPOSE_LEVEL_GRANT: case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: case Opcode::SMSG_REFER_A_FRIEND_FAILURE: case Opcode::SMSG_REPORT_PVP_AFK_RESULT: case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: handleQuestPoiQueryResponse(packet); break; case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: case Opcode::SMSG_PLAY_TIME_WARNING: packet.setReadPos(packet.getSize()); break; // ---- Item query multiple (same format as single, re-use handler) ---- case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: handleItemQueryResponse(packet); break; // ---- Object position/rotation queries ---- case Opcode::SMSG_QUERY_OBJECT_POSITION: case Opcode::SMSG_QUERY_OBJECT_ROTATION: case Opcode::SMSG_VOICESESSION_FULL: packet.setReadPos(packet.getSize()); break; // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, static_cast(MovementFlags::LEVITATING), true); break; case Opcode::SMSG_MOVE_GRAVITY_ENABLE: handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK, static_cast(MovementFlags::LEVITATING), false); break; case Opcode::SMSG_MOVE_LAND_WALK: handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, static_cast(MovementFlags::WATER_WALK), false); break; case Opcode::SMSG_MOVE_NORMAL_FALL: handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, static_cast(MovementFlags::FEATHER_FALL), false); break; case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY", Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true); break; case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY", Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false); break; case Opcode::SMSG_MOVE_SET_COLLISION_HGT: handleMoveSetCollisionHeight(packet); break; case Opcode::SMSG_MOVE_SET_FLIGHT: handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, static_cast(MovementFlags::FLYING), true); break; case Opcode::SMSG_MOVE_UNSET_FLIGHT: handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, static_cast(MovementFlags::FLYING), false); 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 loggedUnhandledByState; const uint32_t key = (static_cast(static_cast(state)) << 16) | static_cast(opcode); if (loggedUnhandledByState.insert(key).second) { LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec, " state=", static_cast(state), " size=", packet.getSize()); const auto& data = packet.getData(); std::string hex; size_t limit = std::min(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 loggedUnhandledOpcodes; if (loggedUnhandledOpcodes.insert(static_cast(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(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(data.result)) + ")"; break; } LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast(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; std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); actionBar = {}; playerAuras.clear(); targetAuras.clear(); unitCastStates_.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(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_DEBUG("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(); isFalling_ = false; fallStartMs_ = 0; movementInfo.fallTime = 0; movementInfo.jumpVelocity = 0.0f; movementInfo.jumpSinAngle = 0.0f; movementInfo.jumpCosAngle = 0.0f; movementInfo.jumpXYSpeed = 0.0f; 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 boss encounter unit slots and raid marks on world transfer encounterUnitGuids_.fill(0); raidTargetGuids_.fill(0); // Reset talent initialization so the first SMSG_TALENTS_INFO after login // correctly sets the active spec (static locals don't reset across logins) talentsInitialized_ = false; learnedTalents_[0].clear(); learnedTalents_[1].clear(); unspentTalentPoints_[0] = 0; unspentTalentPoints_[1] = 0; activeTalentSpec_ = 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, initialWorldEntry); } // 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; completedQuests_.clear(); LOG_INFO("Queued quest log resync for login (from server quest slots)"); // Request completed quest IDs from server (populates completedQuests_ when response arrives) if (socket) { network::Packet cqcPkt(wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED)); socket->send(cqcPkt); LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); } } } 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 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(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(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(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(e.seed), 16); crFile.read(reinterpret_cast(e.reply), 20); crFile.read(reinterpret_cast(e.clientKey), 16); crFile.read(reinterpret_cast(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(); 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 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& plaintext) { std::vector 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(decrypted[33]) | (static_cast(decrypted[34]) << 8) | (static_cast(decrypted[35]) << 16) | (static_cast(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 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(decrypted[1]) | (static_cast(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(wardenModuleData_.data()), wardenModuleData_.size()); LOG_DEBUG("Warden: Cached module to ", cachePath); } } // Load the module (decrypt, decompress, parse, relocate) wardenLoadedModule_ = std::make_shared(); 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 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 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 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 newEncryptKey(match->clientKey, match->clientKey + 16); std::vector 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(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 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 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 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 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 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 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 ek(newEncryptKey, newEncryptKey + 16); std::vector 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 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(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(oneBasedIndex - 1); if (idx >= strings.size()) return std::string(); return strings[idx]; }; auto requestSizes = [&](CheckType ct) { switch (ct) { case CT_TIMING: return std::vector{0}; case CT_MEM: return std::vector{6}; case CT_PAGE_A: return std::vector{24, 29}; case CT_PAGE_B: return std::vector{24, 29}; case CT_MPQ: return std::vector{1}; case CT_LUA: return std::vector{1}; case CT_DRIVER: return std::vector{25}; case CT_PROC: return std::vector{30}; case CT_MODULE: return std::vector{24}; default: return std::vector{}; } }; std::unordered_map parseMemo; std::function 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 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( std::chrono::duration_cast( 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(); if (!wardenMemory_->load(static_cast(build))) { LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); } } // Read bytes from PE image (includes patched runtime globals) std::vector 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 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 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(resultData.size()); std::vector 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); // Record send time for RTT measurement pingTimestamp_ = std::chrono::steady_clock::now(); // 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; } // Measure round-trip time auto rtt = std::chrono::steady_clock::now() - pingTimestamp_; lastLatency = static_cast( std::chrono::duration_cast(rtt).count()); LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ", latency: ", lastLatency, "ms)"); } uint32_t GameHandler::nextMovementTimestampMs() { auto now = std::chrono::steady_clock::now(); uint64_t elapsed = static_cast( std::chrono::duration_cast(now - movementClockStart_).count()) + 1ULL; if (elapsed > std::numeric_limits::max()) { movementClockStart_ = now; elapsed = 1ULL; } uint32_t candidate = static_cast(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(MovementFlags::FORWARD); break; case Opcode::MSG_MOVE_START_BACKWARD: movementInfo.flags |= static_cast(MovementFlags::BACKWARD); break; case Opcode::MSG_MOVE_STOP: movementInfo.flags &= ~(static_cast(MovementFlags::FORWARD) | static_cast(MovementFlags::BACKWARD)); break; case Opcode::MSG_MOVE_START_STRAFE_LEFT: movementInfo.flags |= static_cast(MovementFlags::STRAFE_LEFT); break; case Opcode::MSG_MOVE_START_STRAFE_RIGHT: movementInfo.flags |= static_cast(MovementFlags::STRAFE_RIGHT); break; case Opcode::MSG_MOVE_STOP_STRAFE: movementInfo.flags &= ~(static_cast(MovementFlags::STRAFE_LEFT) | static_cast(MovementFlags::STRAFE_RIGHT)); break; case Opcode::MSG_MOVE_JUMP: movementInfo.flags |= static_cast(MovementFlags::FALLING); // Record fall start and capture horizontal velocity for jump fields. isFalling_ = true; fallStartMs_ = movementInfo.time; movementInfo.fallTime = 0; // jumpVelocity: WoW convention is the upward speed at launch. movementInfo.jumpVelocity = 7.96f; // WOW_JUMP_VELOCITY from CameraController { // Facing direction encodes the horizontal movement direction at launch. const float facingRad = movementInfo.orientation; movementInfo.jumpCosAngle = std::cos(facingRad); movementInfo.jumpSinAngle = std::sin(facingRad); // Horizontal speed: only non-zero when actually moving at jump time. const uint32_t horizFlags = static_cast(MovementFlags::FORWARD) | static_cast(MovementFlags::BACKWARD) | static_cast(MovementFlags::STRAFE_LEFT) | static_cast(MovementFlags::STRAFE_RIGHT); const bool movingHoriz = (movementInfo.flags & horizFlags) != 0; if (movingHoriz) { const bool isWalking = (movementInfo.flags & static_cast(MovementFlags::WALKING)) != 0; movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f); } else { movementInfo.jumpXYSpeed = 0.0f; } } break; case Opcode::MSG_MOVE_START_TURN_LEFT: movementInfo.flags |= static_cast(MovementFlags::TURN_LEFT); break; case Opcode::MSG_MOVE_START_TURN_RIGHT: movementInfo.flags |= static_cast(MovementFlags::TURN_RIGHT); break; case Opcode::MSG_MOVE_STOP_TURN: movementInfo.flags &= ~(static_cast(MovementFlags::TURN_LEFT) | static_cast(MovementFlags::TURN_RIGHT)); break; case Opcode::MSG_MOVE_FALL_LAND: movementInfo.flags &= ~static_cast(MovementFlags::FALLING); isFalling_ = false; fallStartMs_ = 0; movementInfo.fallTime = 0; movementInfo.jumpVelocity = 0.0f; movementInfo.jumpSinAngle = 0.0f; movementInfo.jumpCosAngle = 0.0f; movementInfo.jumpXYSpeed = 0.0f; break; case Opcode::MSG_MOVE_HEARTBEAT: // No flag changes — just sends current position break; case Opcode::MSG_MOVE_START_ASCEND: movementInfo.flags |= static_cast(MovementFlags::ASCENDING); break; case Opcode::MSG_MOVE_STOP_ASCEND: // Clears ascending (and descending) — one stop opcode for both directions movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); break; case Opcode::MSG_MOVE_START_DESCEND: // Descending: no separate flag; clear ASCENDING so they don't conflict movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); break; default: break; } // Keep fallTime current: it must equal the elapsed milliseconds since FALLING // was set, so the server can compute fall damage correctly. if (isFalling_ && movementInfo.hasFlag(MovementFlags::FALLING)) { // movementInfo.time is the strictly-increasing client clock (ms). // Subtract fallStartMs_ to get elapsed fall time; clamp to non-negative. uint32_t elapsed = (movementInfo.time >= fallStartMs_) ? (movementInfo.time - fallStartMs_) : 0u; movementInfo.fallTime = elapsed; } else if (!movementInfo.hasFlag(MovementFlags::FALLING)) { // Ensure fallTime is zeroed whenever we're not falling. if (isFalling_) { isFalling_ = false; fallStartMs_ = 0; } movementInfo.fallTime = 0; } 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(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(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(MovementFlags::FORWARD) | static_cast(MovementFlags::BACKWARD) | static_cast(MovementFlags::STRAFE_LEFT) | static_cast(MovementFlags::STRAFE_RIGHT) | static_cast(MovementFlags::TURN_LEFT) | static_cast(MovementFlags::TURN_RIGHT) | static_cast(MovementFlags::PITCH_UP) | static_cast(MovementFlags::PITCH_DOWN) | static_cast(MovementFlags::FALLING) | static_cast(MovementFlags::FALLINGFAR) | static_cast(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"); if (data.blocks.empty()) return; // Fall through: process any blocks that were successfully parsed before the failure. } auto extractPlayerAppearance = [&](const std::map& 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(v & 0xFF); uint8_t cls = static_cast((v >> 8) & 0xFF); uint8_t gender = static_cast((v >> 16) & 0xFF); uint8_t power = static_cast((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(v & 0xFF); uint8_t face = static_cast((v >> 8) & 0xFF); uint8_t hair = static_cast((v >> 16) & 0xFF); uint8_t color = static_cast((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(v & 0xFF); if (facial <= 100) { pbytes2 = v; havePbytes2 = true; break; } } } if (!haveBytes0 || !havePbytes) return false; outRace = static_cast(bytes0 & 0xFF); outGender = static_cast((bytes0 >> 16) & 0xFF); outAppearanceBytes = pbytes; outFacial = havePbytes2 ? static_cast(pbytes2 & 0xFF) : 0; return true; }; auto maybeDetectCoinageIndex = [&](const std::map& oldFields, const std::map& newFields) { if (pendingMoneyDelta_ == 0 || pendingMoneyDeltaTimer_ <= 0.0f) return; if (oldFields.empty() || newFields.empty()) return; constexpr uint32_t kMaxPlausibleCoinage = 2147483647u; std::vector 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); // Clear pending name query so the query is re-sent when this player // comes back into range (entity is recreated as a new object). pendingNameQueries.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; switch (block.objectType) { case ObjectType::PLAYER: entity = std::make_shared(block.guid); break; case ObjectType::UNIT: entity = std::make_shared(block.guid); break; case ObjectType::GAMEOBJECT: entity = std::make_shared(block.guid); break; default: entity = std::make_shared(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(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(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((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(key - ufPowerBase), val); } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { unit->setMaxPowerByType(static_cast(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)"); if (ghostStateCallback_) ghostStateCallback_(true); } } // Determine hostility from faction template for online creatures. // Always call isHostileFaction — factionTemplate=0 defaults to hostile // in the lookup rather than silently staying at the struct default (false). 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 { LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); } } } 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(), ")"); float unitScale = 1.0f; { uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); if (scaleIdx != 0xFFFF) { uint32_t raw = entity->getField(scaleIdx); if (raw != 0) { std::memcpy(&unitScale, &raw, sizeof(float)); if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; } } } creatureSpawnCallback_(block.guid, unit->getDisplayId(), unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); if (unitInitiallyDead && npcDeathCallback_) { npcDeathCallback_(block.guid); } } // Initialise swim/walk state from spawn-time movement flags (cold-join fix). // Without this, an entity already swimming/walking when the client joins // won't get its animation state set until the next MSG_MOVE_* heartbeat. if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && block.guid != playerGuid) { unitMoveFlagsCallback_(block.guid, block.moveFlags); } // 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(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_DEBUG("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_INFO("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_) { float goScale = 1.0f; { uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); if (scaleIdx != 0xFFFF) { uint32_t raw = entity->getField(scaleIdx); if (raw != 0) { std::memcpy(&goScale, &raw, sizeof(float)); if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; } } } gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); } // 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)); auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); if (entryIt != block.fields.end() && entryIt->second != 0) { // Preserve existing info when doing partial updates OnlineItemInfo info = onlineItems_.count(block.guid) ? onlineItems_[block.guid] : OnlineItemInfo{}; info.entry = entryIt->second; if (stackIt != block.fields.end()) info.stackCount = stackIt->second; if (durIt != block.fields.end()) info.curDurability = durIt->second; if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; 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 ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); 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); const uint16_t ufStats[5] = { fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), fieldIndex(UF::UNIT_FIELD_STAT4) }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = 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(val); LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); } else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); // Byte 3 (bits 24-31): REST_STATE // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } else { for (int si = 0; si < 5; ++si) { if (ufStats[si] != 0xFFFF && key == ufStats[si]) { playerStats_[si] = static_cast(val); break; } } } // 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_); applyQuestStateFromFields(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(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((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(key - ufPowerBase), val); } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { unit->setMaxPowerByType(static_cast(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 { LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); } } } else if (creatureSpawnCallback_) { float unitScale2 = 1.0f; { uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); if (scaleIdx != 0xFFFF) { uint32_t raw = entity->getField(scaleIdx); if (raw != 0) { std::memcpy(&unitScale2, &raw, sizeof(float)); if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; } } } creatureSpawnCallback_(block.guid, unit->getDisplayId(), unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); 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 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 ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); 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); const uint16_t ufStatsV[5] = { fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), fieldIndex(UF::UNIT_FIELD_STAT4) }; 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 (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { playerRestedXp_ = 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(val); } else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); // Byte 3 (bits 24-31): REST_STATE // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } 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)"); if (ghostStateCallback_) ghostStateCallback_(true); } else if (wasGhost && !nowGhost) { releasedSpirit_ = false; playerDead_ = false; repopPending_ = false; resurrectPending_ = false; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (ghostStateCallback_) ghostStateCallback_(false); } } else { for (int si = 0; si < 5; ++si) { if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { playerStats_[si] = static_cast(val); break; } } } } // 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_); applyQuestStateFromFields(lastPlayerFields_); } // Update item stack count / durability 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 itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); auto it = onlineItems_.find(block.guid); bool isItemInInventory = (it != onlineItems_.end()); for (const auto& [key, val] : block.fields) { if (key == itemStackField && isItemInInventory) { if (it->second.stackCount != val) { it->second.stackCount = val; inventoryChanged = true; } } else if (key == itemDurField && isItemInInventory) { if (it->second.curDurability != val) { it->second.curDurability = val; inventoryChanged = true; } } else if (key == itemMaxDurField && isItemInInventory) { if (it->second.maxDurability != val) { it->second.maxDurability = 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 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::PLAYER && playerDespawnCallback_) { // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. playerDespawnCallback_(data.guid); otherPlayerVisibleItemEntries_.erase(data.guid); otherPlayerVisibleDirty_.erase(data.guid); otherPlayerMoveTimeMs_.erase(data.guid); inspectedPlayerItemEntries_.erase(data.guid); pendingAutoInspect_.erase(data.guid); pendingNameQueries.erase(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(entity); if (player && !player->getName().empty()) { data.senderName = player->getName(); } } else if (entity->getType() == ObjectType::UNIT) { auto unit = std::dynamic_pointer_cast(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) { // Classic 1.12 and TBC 2.4.3 send: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + nameLen(u32) + name // WotLK 3.3.5a reversed this to: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + nameLen(u32) + name const bool legacyFormat = isClassicLikeExpansion() || isActiveExpansion("tbc"); TextEmoteData data; if (!TextEmoteParser::parse(packet, data, legacyFormat)) { 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(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(joinedChannels_.size())) return ""; return joinedChannels_[index - 1]; } int GameHandler::getChannelIndex(const std::string& channelName) const { for (int i = 0; i < static_cast(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(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; // Clear stale aura data from the previous target so the buff bar shows // an empty state until the server sends SMSG_AURA_UPDATE_ALL for the new target. for (auto& slot : targetAuras) slot = AuraSlot{}; // Clear previous target's cast bar on target change // (the new target's cast state is naturally fetched from unitCastStates_ by 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 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(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 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 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(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 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(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(target); if (!player->getName().empty()) { targetName = player->getName(); } } else if (target->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(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(target); if (!player->getName().empty()) { targetName = player->getName(); } } else if (target->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(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(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(entity.get())) { duelChallengerName_ = unit->getName(); } if (duelChallengerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", static_cast(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(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::steady_clock::now().time_since_epoch()).count(); if (repopPending_ && now - static_cast(lastRepopRequestMs_) < 1000) { return; } auto packet = RepopRequestPacket::build(); socket->send(packet); releasedSpirit_ = true; repopPending_ = true; lastRepopRequestMs_ = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); } } bool GameHandler::canReclaimCorpse() const { if (!releasedSpirit_ || corpseMapId_ == 0) return false; // Only if ghost is on the same map as their corpse if (currentMapId_ != corpseMapId_) return false; // Must be within 40 yards (server also validates proximity) float dx = movementInfo.x - corpseX_; float dy = movementInfo.y - corpseY_; float dz = movementInfo.z - corpseZ_; return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); socket->send(packet); LOG_INFO("Sent CMSG_RECLAIM_CORPSE"); } 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; if (resurrectIsSpiritHealer_) { // Spirit healer resurrection — SMSG_SPIRIT_HEALER_CONFIRM → CMSG_SPIRIT_HEALER_ACTIVATE auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_); socket->send(activate); LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", std::hex, resurrectCasterGuid_, std::dec); } else { // Player-cast resurrection — SMSG_RESURRECT_REQUEST → CMSG_RESURRECT_RESPONSE (accept=1) auto resp = ResurrectResponsePacket::build(resurrectCasterGuid_, true); socket->send(resp); LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (accept) 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& e) -> bool { if (!e) return false; const uint64_t guid = e->getGuid(); auto* unit = dynamic_cast(e.get()); if (!unit) return false; // Not a unit (shouldn't happen after type filter) if (unit->getHealth() == 0) { // Dead corpse: only targetable if it has loot or is skinnableable // If corpse was looted and is now empty, skip it (except for skinning) auto lootIt = localLootState_.find(guid); if (lootIt == localLootState_.end() || lootIt->second.data.items.empty()) { // No loot data or all items taken; check if skinnableable // For now, skip empty looted corpses (proper skinning check requires // creature type data that may not be immediately available) return false; } // Has unlooted items available return true; } 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 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(tabCycleList.size()); while (tries-- > 0) { tabCycleIndex = (tabCycleIndex + 1) % static_cast(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 already cached, apply the name to the entity (handles entity recreation after // moving out/in range — the entity object is new but the cached name is valid). auto cacheIt = playerNameCache.find(guid); if (cacheIt != playerNameCache.end()) { auto entity = entityManager.getEntity(guid); if (entity && entity->getType() == ObjectType::PLAYER) { auto player = std::static_pointer_cast(entity); if (player->getName().empty()) { player->setName(cacheIt->second); } } return; } if (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(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; } } // Backfill friend list: if this GUID came from a friend list packet, // register the name in friendsCache now that we know it. if (friendGuids_.count(data.guid)) { friendsCache[data.name] = data.guid; } } } void GameHandler::handleCreatureQueryResponse(network::Packet& packet) { CreatureQueryResponseData data; if (!packetParsers_->parseCreatureQueryResponse(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(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(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(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_DEBUG("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_DEBUG("handleItemQueryResponse: entry=", data.entry, " 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 displayIds{}; std::array 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(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 // WotLK: packed GUID; TBC: full uint64 const bool talentTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (talentTbc ? 8u : 2u)) return; uint64_t guid = talentTbc ? packet.readUInt64() : 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(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(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& fields) { if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return; if (fields.empty()) return; std::vector matchingPairs; matchingPairs.reserve(32); for (const auto& [idx, low] : fields) { if ((idx % 2) != 0) continue; auto itHigh = fields.find(static_cast(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(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(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& fields) { bool slotsChanged = false; int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD)); int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast(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(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(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(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1)); int bankBagBase = static_cast(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(bankBase) && key <= static_cast(bankBase) + (effectiveBankSlots_ * 2 - 1)) { int slotIndex = (key - bankBase) / 2; bool isLow = ((key - bankBase) % 2 == 0); if (slotIndex < static_cast(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(bankBagBase) && key <= static_cast(bankBagBase) + (effectiveBankBagSlots_ * 2 - 1)) { int slotIndex = (key - bankBagBase) / 2; bool isLow = ((key - bankBagBase) % 2 == 0); if (slotIndex < static_cast(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& 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.curDurability = itemIt->second.curDurability; def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); if (infoIt != itemInfoCache_.end()) { def.name = infoIt->second.name; def.quality = static_cast(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.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); } inventory.setEquipSlot(static_cast(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.curDurability = itemIt->second.curDurability; def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); if (infoIt != itemInfoCache_.end()) { def.name = infoIt->second.name; def.quality = static_cast(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.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); } 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(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(19 + bagIdx)); if (!bagEquipSlot.empty()) { ItemDef bagDef = bagEquipSlot.item; bagDef.bagSlots = numSlots; inventory.setEquipSlot(static_cast(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.curDurability = itemIt->second.curDurability; def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); if (infoIt != itemInfoCache_.end()) { def.name = infoIt->second.name; def.quality = static_cast(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.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); 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.curDurability = itemIt->second.curDurability; def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); if (infoIt != itemInfoCache_.end()) { def.name = infoIt->second.name; def.quality = static_cast(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.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); 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(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(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.curDurability = itemIt->second.curDurability; def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); if (infoIt != itemInfoCache_.end()) { def.name = infoIt->second.name; def.quality = static_cast(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.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.sellPrice = infoIt->second.sellPrice; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); 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 currentEquipDisplayIds{}; for (int i = 0; i < 19; i++) { const auto& slot = inventory.getEquipSlot(static_cast(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 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(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(baseIdxU16); if (base + 18 * stride > static_cast(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(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& 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 newEntries{}; for (int s = 0; s < 19; s++) { uint16_t idx = static_cast(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 displayIds{}; std::array 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(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) { // WotLK: packed GUID; TBC/Classic: full uint64 const bool fscTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t guid = fscTbcLike ? packet.readUInt64() : 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. // Classic/TBC use full uint64 GUID; WotLK uses packed GUID. if (socket) { 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: // WotLK: packed GUID + uint32 counter + [optional unknown field(s)] // TBC/Classic: full uint64 + uint32 counter // We always ACK with current movement state, same pattern as speed-change ACKs. const bool rootTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (rootTbc ? 8u : 2u)) return; uint64_t guid = rootTbc ? packet.readUInt64() : 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(MovementFlags::ROOT); } else { movementInfo.flags &= ~static_cast(MovementFlags::ROOT); } if (!socket) 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) { // WotLK: packed GUID; TBC/Classic: full uint64 const bool fmfTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (fmfTbcLike ? 8u : 2u)) return; uint64_t guid = fmfTbcLike ? packet.readUInt64() : 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) 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::handleMoveSetCollisionHeight(network::Packet& packet) { // SMSG_MOVE_SET_COLLISION_HGT: packed guid + counter + float (height) // ACK: CMSG_MOVE_SET_COLLISION_HGT_ACK = packed guid + counter + movement block + float (height) const bool legacyGuid = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (legacyGuid ? 8u : 2u)) return; uint64_t guid = legacyGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) return; // counter(4) + height(4) uint32_t counter = packet.readUInt32(); float height = packet.readFloat(); LOG_INFO("SMSG_MOVE_SET_COLLISION_HGT: guid=0x", std::hex, guid, std::dec, " counter=", counter, " height=", height); if (guid != playerGuid) return; if (!socket) return; uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_SET_COLLISION_HGT_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(); 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 (packetParsers_) packetParsers_->writeMovementPayload(ack, wire); else MovementPacket::writeMovementPayload(ack, wire); ack.writeFloat(height); socket->send(ack); } void GameHandler::handleMoveKnockBack(network::Packet& packet) { // WotLK: packed GUID; TBC/Classic: full uint64 const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (mkbTbc ? 8u : 2u)) return; uint64_t guid = mkbTbc ? packet.readUInt64() : 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(); float vcos = packet.readFloat(); float vsin = packet.readFloat(); float hspeed = packet.readFloat(); float vspeed = packet.readFloat(); LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec, " counter=", counter, " vcos=", vcos, " vsin=", vsin, " hspeed=", hspeed, " vspeed=", vspeed); if (guid != playerGuid) return; // Apply knockback physics locally so the player visually flies through the air. // The callback forwards to CameraController::applyKnockBack(). if (knockBackCallback_) { knockBackCallback_(vcos, vsin, hspeed, vspeed); } if (!socket) 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) { // SMSG_BATTLEFIELD_STATUS wire format differs by expansion: // // Classic 1.12 (vmangos/cmangos): // queueSlot(4) bgTypeId(4) unk(2) instanceId(4) isRegistered(1) statusId(4) [status fields...] // STATUS_NONE sends only: queueSlot(4) bgTypeId(4) // // TBC 2.4.3 / WotLK 3.3.5a: // queueSlot(4) arenaType(1) unk(1) bgTypeId(4) unk2(2) instanceId(4) isRated(1) statusId(4) [status fields...] // STATUS_NONE sends only: queueSlot(4) arenaType(1) if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t queueSlot = packet.readUInt32(); const bool classicFormat = isClassicLikeExpansion(); uint8_t arenaType = 0; if (!classicFormat) { // TBC/WotLK: arenaType(1) + unk(1) before bgTypeId // STATUS_NONE sends only queueSlot + arenaType if (packet.getSize() - packet.getReadPos() < 1) { LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared"); return; } arenaType = packet.readUInt8(); if (packet.getSize() - packet.getReadPos() < 1) return; packet.readUInt8(); // unk } else { // Classic STATUS_NONE sends only queueSlot + bgTypeId (4 bytes) if (packet.getSize() - packet.getReadPos() < 4) { LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared"); return; } } 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"; } // Parse status-specific fields uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) if (statusId == 1) { // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) if (packet.getSize() - packet.getReadPos() >= 8) { /*uint32_t avgWait =*/ packet.readUInt32(); /*uint32_t inQueue =*/ packet.readUInt32(); } } else if (statusId == 2) { // STATUS_WAIT_JOIN: timeout(4) + mapId(4) if (packet.getSize() - packet.getReadPos() >= 4) { inviteTimeout = packet.readUInt32(); } if (packet.getSize() - packet.getReadPos() >= 4) { /*uint32_t mapId =*/ packet.readUInt32(); } } else if (statusId == 3) { // STATUS_IN_PROGRESS: mapId(4) + timeSinceStart(4) if (packet.getSize() - packet.getReadPos() >= 8) { /*uint32_t mapId =*/ packet.readUInt32(); /*uint32_t elapsed =*/ packet.readUInt32(); } } // Store queue state if (queueSlot < bgQueues_.size()) { bool wasInvite = (bgQueues_[queueSlot].statusId == 2); bgQueues_[queueSlot].queueSlot = queueSlot; bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; if (statusId == 2 && !wasInvite) { bgQueues_[queueSlot].inviteTimeout = inviteTimeout; bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now(); } } 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 // Popup shown by the UI; add chat notification too. addSystemChatMessage(bgName + " is ready!"); LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName, " timeout=", inviteTimeout, "s"); 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; } } void GameHandler::declineBattlefield(uint32_t queueSlot) { if (state != WorldState::IN_WORLD) return; if (!socket) return; 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 with action=0 (decline) network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); pkt.writeUInt8(slot->arenaType); pkt.writeUInt8(0x00); pkt.writeUInt32(slot->bgTypeId); pkt.writeUInt16(0x0000); pkt.writeUInt8(0); // 0 = decline socket->send(pkt); // Clear queue slot uint32_t clearSlot = slot->queueSlot; if (clearSlot < bgQueues_.size()) { bgQueues_[clearSlot] = BgQueueSlot{}; } addSystemChatMessage("Battleground invitation declined."); LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: decline"); } 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); // Optimistically clear the invite so the popup disappears immediately. uint32_t clearSlot = slot->queueSlot; if (clearSlot < bgQueues_.size()) { bgQueues_[clearSlot].statusId = 3; // STATUS_IN_PROGRESS (server will confirm) } addSystemChatMessage("Accepting battleground invitation..."); LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); } void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { // TBC 2.4.3 format: mapId(4) + difficulty(4) + resetTime(4 — uint32 seconds) + locked(1) // WotLK 3.3.5a format: mapId(4) + difficulty(4) + resetTime(8 — uint64 timestamp) + locked(1) + extended(1) const bool isTbc = isActiveExpansion("tbc"); const bool isClassic = isClassicLikeExpansion(); const bool useTbcFormat = isTbc || isClassic; if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t count = packet.readUInt32(); instanceLockouts_.clear(); instanceLockouts_.reserve(count); const size_t kEntrySize = useTbcFormat ? (4 + 4 + 4 + 1) : (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(); if (useTbcFormat) { lo.resetTime = packet.readUInt32(); // TBC/Classic: 4-byte seconds lo.locked = packet.readUInt8() != 0; lo.extended = false; } else { lo.resetTime = packet.readUInt64(); // WotLK: 8-byte timestamp 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) { // SMSG_INSTANCE_DIFFICULTY: uint32 difficulty, uint32 heroic (8 bytes) // MSG_SET_DUNGEON_DIFFICULTY: uint32 difficulty[, uint32 isInGroup, uint32 savedBool] (4 or 12 bytes) auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 4) return; instanceDifficulty_ = packet.readUInt32(); if (rem() >= 4) { uint32_t secondField = packet.readUInt32(); // SMSG_INSTANCE_DIFFICULTY: second field is heroic flag (0 or 1) // MSG_SET_DUNGEON_DIFFICULTY: second field is isInGroup (not heroic) // Heroic = difficulty value 1 for 5-man, so use the field value for SMSG and // infer from difficulty for MSG variant (which has larger payloads). if (rem() >= 4) { // Three+ fields: this is MSG_SET_DUNGEON_DIFFICULTY; heroic = (difficulty == 1) instanceIsHeroic_ = (instanceDifficulty_ == 1); } else { // Two fields: SMSG_INSTANCE_DIFFICULTY format instanceIsHeroic_ = (secondField != 0); } } else { instanceIsHeroic_ = (instanceDifficulty_ == 1); } 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(state); LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast(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(result), " state=", static_cast(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(packet.readUInt32()); int32_t waitTime = static_cast(packet.readUInt32()); /*int32_t waitTimeTank =*/ static_cast(packet.readUInt32()); /*int32_t waitTimeHealer =*/ static_cast(packet.readUInt32()); /*int32_t waitTimeDps =*/ static_cast(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(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(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(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(); // Convert copper to gold/silver/copper uint32_t gold = money / 10000; uint32_t silver = (money % 10000) / 100; uint32_t copper = money % 100; char moneyBuf[64]; if (gold > 0) snprintf(moneyBuf, sizeof(moneyBuf), "%ug %us %uc", gold, silver, copper); else if (silver > 0) snprintf(moneyBuf, sizeof(moneyBuf), "%us %uc", silver, copper); else snprintf(moneyBuf, sizeof(moneyBuf), "%uc", copper); std::string rewardMsg = std::string("Dungeon Finder reward: ") + moneyBuf + ", " + 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) { lfgState_ = LfgState::Boot; addSystemChatMessage( std::string("Dungeon Finder: Vote to kick in progress (") + std::to_string(timeLeft) + "s remaining)."); } else { // Boot vote ended — return to InDungeon state regardless of outcome lfgState_ = LfgState::InDungeon; 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(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(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::lfgSetBootVote(bool vote) { if (!socket) return; uint16_t wireOp = wireOpcode(Opcode::CMSG_LFG_SET_BOOT_VOTE); if (wireOp == 0xFFFF) return; network::Packet pkt(wireOp); pkt.writeUInt8(vote ? 1 : 0); socket->send(pkt); LOG_INFO("Sent CMSG_LFG_SET_BOOT_VOTE: vote=", vote); } 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: packed GUID (WotLK) or full uint64 (TBC/Classic) const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t moverGuid = otherMoveTbc ? packet.readUInt64() : 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(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(clamped); } } otherPlayerMoveTimeMs_[moverGuid] = info.time; // Classify the opcode so we can drive the correct entity update and animation. const uint16_t wireOp = packet.getOpcode(); const bool isStopOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP)) || (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE)) || (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_TURN)) || (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)) || (wireOp == wireOpcode(Opcode::MSG_MOVE_FALL_LAND)); const bool isJumpOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_JUMP)); // For stop opcodes snap the entity position (duration=0) so it doesn't keep interpolating, // and pass durationMs=0 to the renderer so the Run-anim flash is suppressed. // The per-frame sync will detect no movement and play Stand on the next frame. const float entityDuration = isStopOpcode ? 0.0f : (durationMs / 1000.0f); entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, entityDuration); // Notify renderer of position change if (creatureMoveCallback_) { const uint32_t notifyDuration = isStopOpcode ? 0u : durationMs; creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration); } // Signal specific animation transitions that the per-frame sync can't detect reliably. // WoW M2 animation ID 38=JumpMid (loops during airborne). // Swim/walking state is now authoritative from the movement flags field via unitMoveFlagsCallback_. if (unitAnimHintCallback_ && isJumpOpcode) { unitAnimHintCallback_(moverGuid, 38u); } // Fire move-flags callback so application.cpp can update swimming/walking state // from the flags field embedded in every movement packet (covers heartbeats and cold joins). if (unitMoveFlagsCallback_) { unitMoveFlagsCallback_(moverGuid, info.flags); } } 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); // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), wireOpcode(Opcode::MSG_MOVE_START_STRAFE_LEFT), wireOpcode(Opcode::MSG_MOVE_START_STRAFE_RIGHT), wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE), wireOpcode(Opcode::MSG_MOVE_JUMP), wireOpcode(Opcode::MSG_MOVE_START_TURN_LEFT), wireOpcode(Opcode::MSG_MOVE_START_TURN_RIGHT), wireOpcode(Opcode::MSG_MOVE_STOP_TURN), wireOpcode(Opcode::MSG_MOVE_SET_FACING), wireOpcode(Opcode::MSG_MOVE_FALL_LAND), wireOpcode(Opcode::MSG_MOVE_HEARTBEAT), wireOpcode(Opcode::MSG_MOVE_START_SWIM), wireOpcode(Opcode::MSG_MOVE_STOP_SWIM), wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE), wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE), wireOpcode(Opcode::MSG_MOVE_START_PITCH_UP), wireOpcode(Opcode::MSG_MOVE_START_PITCH_DOWN), wireOpcode(Opcode::MSG_MOVE_STOP_PITCH), wireOpcode(Opcode::MSG_MOVE_START_ASCEND), wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), wireOpcode(Opcode::MSG_MOVE_START_DESCEND), wireOpcode(Opcode::MSG_MOVE_SET_PITCH), wireOpcode(Opcode::MSG_MOVE_GRAVITY_CHNG), wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_FLY), wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY), wireOpcode(Opcode::MSG_MOVE_ROOT), wireOpcode(Opcode::MSG_MOVE_UNROOT), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) std::unordered_set 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(data[pos + 1]) | (static_cast(data[pos + 2]) << 8); size_t payloadLen = subSize - 2; size_t payloadStart = pos + 3; std::vector 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 (state == WorldState::IN_WORLD && std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end()) { // Player/NPC movement update packed in SMSG_MULTIPLE_MOVES handleOtherPlayerMovement(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& bytes, std::vector& stripped) -> bool { if (bytes.size() < 3) return false; uint8_t subSize = bytes[0]; if (subSize < 2) return false; size_t wrappedLen = static_cast(subSize) + 1; // size byte + body if (wrappedLen != bytes.size()) return false; size_t payloadLen = static_cast(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(rawData[0]) | (static_cast(rawData[1]) << 8) | (static_cast(rawData[2]) << 16) | (static_cast(rawData[3]) << 24); if (decompSize == 0 || decompSize > 65536) { LOG_WARNING("SMSG_MONSTER_MOVE: bad decompSize=", decompSize); return; } std::vector 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); std::vector 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 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(M_PI)) diff -= 2.0f * static_cast(M_PI); while (diff < -static_cast(M_PI)) diff += 2.0f * static_cast(M_PI); if (std::abs(diff) > static_cast(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 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 (!packetParsers_->parseAttackerStateUpdate(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 if (data.victimState == 4) { // VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount if (data.totalDamage > 0) addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker); addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker); } else if (data.victimState == 5) { // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); } else if (data.victimState == 6) { // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker); } else if (data.victimState == 7) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker); // Show partial absorb/resist from sub-damage entries uint32_t totalAbsorbed = 0, totalResisted = 0; for (const auto& sub : data.subDamages) { totalAbsorbed += sub.absorbed; totalResisted += sub.resisted; } if (totalAbsorbed > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker); if (totalResisted > 0) addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker); } (void)isPlayerTarget; } void GameHandler::handleSpellDamageLog(network::Packet& packet) { SpellDamageLogData data; if (!packetParsers_->parseSpellDamageLog(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; if (data.damage > 0) addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); if (data.absorbed > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); if (data.resisted > 0) addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource); } void GameHandler::handleSpellHealLog(network::Packet& packet) { SpellHealLogData data; if (!packetParsers_->parseSpellHealLog(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(data.heal), data.spellId, isPlayerSource); if (data.absorbed > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), 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 // Detected via physical school mask (1) from DBC cache — covers warrior, rogue, DK, paladin, // feral druid, and hunter melee abilities generically. { loadSpellNameCache(); bool isMeleeAbility = false; auto cacheIt = spellNameCache_.find(spellId); if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { // Physical school and no cast time (instant) — treat as melee ability isMeleeAbility = true; } 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) { const size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 8) { // Empty or undersized → pet cleared (dismissed / died) petGuid_ = 0; petSpellList_.clear(); petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); LOG_INFO("SMSG_PET_SPELLS: pet cleared"); return; } petGuid_ = packet.readUInt64(); if (petGuid_ == 0) { petSpellList_.clear(); petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)"); return; } // uint16 duration (ms, 0 = permanent), uint16 timer (ms) if (packet.getSize() - packet.getReadPos() < 4) goto done; /*uint16_t dur =*/ packet.readUInt16(); /*uint16_t timer =*/ packet.readUInt16(); // uint8 reactState, uint8 commandState (packed order varies; WotLK: react first) if (packet.getSize() - packet.getReadPos() < 2) goto done; petReact_ = packet.readUInt8(); // 0=passive, 1=defensive, 2=aggressive petCommand_ = packet.readUInt8(); // 0=stay, 1=follow, 2=attack, 3=dismiss // 10 × uint32 action bar slots if (packet.getSize() - packet.getReadPos() < PET_ACTION_BAR_SLOTS * 4u) goto done; for (int i = 0; i < PET_ACTION_BAR_SLOTS; ++i) { petActionSlots_[i] = packet.readUInt32(); } // uint8 spell count, then per-spell: uint32 spellId, uint16 active flags if (packet.getSize() - packet.getReadPos() < 1) goto done; { uint8_t spellCount = packet.readUInt8(); petSpellList_.clear(); petAutocastSpells_.clear(); for (uint8_t i = 0; i < spellCount; ++i) { if (packet.getSize() - packet.getReadPos() < 6) break; uint32_t spellId = packet.readUInt32(); uint16_t activeFlags = packet.readUInt16(); petSpellList_.push_back(spellId); // activeFlags bit 0 = autocast on if (activeFlags & 0x0001) { petAutocastSpells_.insert(spellId); } } } done: LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec, " react=", (int)petReact_, " command=", (int)petCommand_, " spells=", petSpellList_.size()); } void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { if (!hasPet() || state != WorldState::IN_WORLD || !socket) return; auto pkt = PetActionPacket::build(petGuid_, action, targetGuid); socket->send(pkt); LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, petGuid_, " action=0x", action, " target=0x", targetGuid, 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; // Pre-query item information so action bar displays item name instead of "Item" placeholder if (type == ActionBarSlot::ITEM && id != 0) { queryItemInfo(id, 0); } 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 (!packetParsers_->parseInitialSpells(packet, data)) return; knownSpells = {data.spellIds.begin(), data.spellIds.end()}; LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u), " 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u)); // 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; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); } } // Add system message about failed cast with readable reason int powerType = -1; auto playerEntity = entityManager.getEntity(playerGuid); if (auto playerUnit = std::dynamic_pointer_cast(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); } static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) { if (mask & 0x04) return audio::SpellSoundManager::MagicSchool::FIRE; if (mask & 0x10) return audio::SpellSoundManager::MagicSchool::FROST; if (mask & 0x02) return audio::SpellSoundManager::MagicSchool::HOLY; if (mask & 0x08) return audio::SpellSoundManager::MagicSchool::NATURE; if (mask & 0x20) return audio::SpellSoundManager::MagicSchool::SHADOW; if (mask & 0x40) return audio::SpellSoundManager::MagicSchool::ARCANE; return audio::SpellSoundManager::MagicSchool::ARCANE; } void GameHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; if (!packetParsers_->parseSpellStart(packet, data)) return; // Track cast bar for any non-player caster (target frame + boss frames) if (data.casterUnit != playerGuid && data.castTime > 0) { auto& s = unitCastStates_[data.casterUnit]; s.casting = true; s.spellId = data.spellId; s.timeTotal = data.castTime / 1000.0f; s.timeRemaining = s.timeTotal; // Trigger cast animation on the casting unit if (spellCastAnimCallback_) { spellCastAnimCallback_(data.casterUnit, true, false); } } // 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 with correct magic school if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { loadSpellNameCache(); auto it = spellNameCache_.find(data.spellId); auto school = (it != spellNameCache_.end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); } } // Trigger cast animation on player character if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, true, false); } // Hearthstone cast: begin pre-loading terrain at bind point during cast time // so tiles are ready when the teleport fires (avoids falling through un-loaded terrain). // Spell IDs: 6948 = Vanilla Hearthstone (rank 1), 8690 = TBC/WotLK Hearthstone const bool isHearthstone = (data.spellId == 6948 || data.spellId == 8690); if (isHearthstone && hasHomeBind_ && hearthstonePreloadCallback_) { hearthstonePreloadCallback_(homeBindMapId_, homeBindPos_.x, homeBindPos_.y, homeBindPos_.z); } } } void GameHandler::handleSpellGo(network::Packet& packet) { SpellGoData data; if (!packetParsers_->parseSpellGo(packet, data)) return; // Cast completed if (data.casterUnit == playerGuid) { // Play cast-complete sound with correct magic school if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { loadSpellNameCache(); auto it = spellNameCache_.find(data.spellId); auto school = (it != spellNameCache_.end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playCast(school); } } // Instant melee abilities → trigger attack animation // Detect via physical school mask (1 = Physical) from the spell DBC cache. // This covers warrior, rogue, DK, paladin, feral druid, and hunter melee // abilities generically instead of maintaining a brittle per-spell-ID list. uint32_t sid = data.spellId; bool isMeleeAbility = false; { loadSpellNameCache(); auto cacheIt = spellNameCache_.find(sid); if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { // Physical school — treat as instant melee ability if cast time is zero. // We don't store cast time in the cache; use the fact that if we were not // in a cast (casting == true with this spellId) then it was instant. isMeleeAbility = (currentCastSpellId != sid); } } if (isMeleeAbility && meleeSwingCallback_) { meleeSwingCallback_(); } casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; // End cast animation on player character if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); } } else if (spellCastAnimCallback_) { // End cast animation on other unit spellCastAnimCallback_(data.casterUnit, false, false); } // Clear unit cast bar when the spell lands (for any tracked unit) unitCastStates_.erase(data.casterUnit); // Show miss/dodge/parry/etc combat text when player's spells miss targets if (data.casterUnit == playerGuid && !data.missTargets.empty()) { static const CombatTextEntry::Type missTypes[] = { CombatTextEntry::MISS, // 0=MISS CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK CombatTextEntry::MISS, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT CombatTextEntry::ABSORB, // 7=ABSORB CombatTextEntry::RESIST, // 8=RESIST }; // Show text for each miss (usually just 1 target per spell go) for (const auto& m : data.missTargets) { CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; addCombatText(ct, 0, 0, true); } } // Play impact sound when player is hit by any spell (from self or others) bool playerIsHit = false; for (const auto& tgt : data.hitTargets) { if (tgt == playerGuid) { playerIsHit = true; break; } } if (playerIsHit && data.casterUnit != playerGuid) { if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { loadSpellNameCache(); auto it = spellNameCache_.find(data.spellId); auto school = (it != spellNameCache_.end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playImpact(school, audio::SpellSoundManager::SpellPower::MEDIUM); } } } } void GameHandler::handleSpellCooldown(network::Packet& packet) { // Classic 1.12: guid(8) + N×[spellId(4) + itemId(4) + cooldown(4)] — no flags byte, 12 bytes/entry // TBC 2.4.3 / WotLK 3.3.5a: guid(8) + flags(1) + N×[spellId(4) + cooldown(4)] — 8 bytes/entry const bool isClassicFormat = isClassicLikeExpansion(); if (packet.getSize() - packet.getReadPos() < 8) return; /*data.guid =*/ packet.readUInt64(); // guid (not used further) if (!isClassicFormat) { if (packet.getSize() - packet.getReadPos() < 1) return; /*data.flags =*/ packet.readUInt8(); // flags (consumed but not stored) } const size_t entrySize = isClassicFormat ? 12u : 8u; while (packet.getSize() - packet.getReadPos() >= entrySize) { uint32_t spellId = packet.readUInt32(); if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used uint32_t cooldownMs = packet.readUInt32(); float seconds = cooldownMs / 1000.0f; spellCooldowns[spellId] = seconds; for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { slot.cooldownTotal = seconds; slot.cooldownRemaining = seconds; } } } LOG_DEBUG("handleSpellCooldown: parsed for ", isClassicFormat ? "Classic" : "TBC/WotLK", " format"); } void GameHandler::handleCooldownEvent(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); // WotLK appends the target unit guid (8 bytes) — skip it if (packet.getSize() - packet.getReadPos() >= 8) packet.readUInt64(); // 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 (!packetParsers_->parseAuraUpdate(packet, data, isAll)) return; // Determine which aura list to update std::vector* 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( std::chrono::duration_cast( 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) { // Classic 1.12: uint16 spellId; TBC 2.4.3 / WotLK 3.3.5a: uint32 spellId const bool classicSpellId = isClassicLikeExpansion(); const size_t minSz = classicSpellId ? 2u : 4u; if (packet.getSize() - packet.getReadPos() < minSz) return; uint32_t spellId = classicSpellId ? packet.readUInt16() : 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) { // Classic 1.12: uint16 spellId; TBC 2.4.3 / WotLK 3.3.5a: uint32 spellId const bool classicSpellId = isClassicLikeExpansion(); const size_t minSz = classicSpellId ? 2u : 4u; if (packet.getSize() - packet.getReadPos() < minSz) return; uint32_t spellId = classicSpellId ? packet.readUInt16() : 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) // Classic 1.12: uint16 oldSpellId + uint16 newSpellId (4 bytes total) // TBC 2.4.3 / WotLK 3.3.5a: uint32 oldSpellId + uint32 newSpellId (8 bytes total) const bool classicSpellId = isClassicLikeExpansion(); const size_t minSz = classicSpellId ? 4u : 8u; if (packet.getSize() - packet.getReadPos() < minSz) return; uint32_t oldSpellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); uint32_t newSpellId = classicSpellId ? packet.readUInt16() : 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) if (packet.getSize() - packet.getReadPos() < 4) return; 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 after login, set it as the active spec if (!talentsInitialized_) { talentsInitialized_ = 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; } // Send CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3) to the server. // The server will validate the swap, apply the new spec's spells/auras, // and respond with SMSG_TALENTS_INFO for the newly active group. // We optimistically update the local state so the UI reflects the change // immediately; the server response will correct us if needed. if (state == WorldState::IN_WORLD && socket) { auto pkt = ActivateTalentGroupPacket::build(static_cast(newSpec)); socket->send(pkt); LOG_INFO("Sent CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE: group=", (int)newSpec); } 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); } void GameHandler::confirmTalentWipe() { if (!talentWipePending_) return; talentWipePending_ = false; if (state != WorldState::IN_WORLD || !socket) return; // Respond to MSG_TALENT_WIPE_CONFIRM with the trainer GUID to trigger the reset. // Packet: opcode(2) + uint64 npcGuid = 10 bytes. network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); pkt.writeUInt64(talentWipeNpcGuid_); socket->send(pkt); LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec); addSystemChatMessage("Talent reset confirmed. The server will update your talents."); talentWipeNpcGuid_ = 0; talentWipeCost_ = 0; } // ============================================================ // 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) { // WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder. // Classic 1.12 and TBC 2.4.3 do not send the roles byte. const bool hasRoles = isActiveExpansion("wotlk"); // Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta. // Without this, repeated GROUP_LIST packets push duplicate members. partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles)) 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(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(); } // WotLK and Classic/Vanilla use packed GUID; TBC uses full uint64 // (Classic uses ObjectGuid::WriteAsPacked() = packed format, same as WotLK) const bool pmsTbc = isActiveExpansion("tbc"); if (remaining() < (pmsTbc ? 8u : 1u)) return; uint64_t memberGuid = pmsTbc ? packet.readUInt64() : 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(packet.readUInt16()); member->posY = static_cast(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(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(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(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; // Always send CMSG_LOOT after CMSG_GAMEOBJ_USE for any gameobject that could be // lootable. The server silently ignores CMSG_LOOT for non-lootable objects // (doors, buttons, etc.), so this is safe. Not sending it is the main reason // chests fail to open when their GO type is not yet cached or their name doesn't // contain the word "chest" (e.g. lockboxes, coffers, strongboxes, caches). bool shouldSendLoot = true; if (entity && entity->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(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; } } if (!chestLike && !goName.empty()) { std::string lower = goName; std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); chestLike = (lower.find("chest") != std::string::npos || lower.find("lockbox") != std::string::npos || lower.find("strongbox") != std::string::npos || lower.find("coffer") != std::string::npos || lower.find("cache") != std::string::npos); } // For WotLK, CMSG_GAMEOBJ_REPORT_USE is required for chests (and is harmless for others). if (!isMailbox && 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. const bool retryLoot = shouldSendLoot; 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(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); // WotLK supports CMSG_QUEST_POI_QUERY to get objective map locations. // Only send if the opcode is mapped (stride==5 means WotLK). if (packetParsers_ && packetParsers_->questLogStride() == 5) { const uint32_t wirePoiQuery = wireOpcode(Opcode::CMSG_QUEST_POI_QUERY); if (wirePoiQuery != 0xFFFF) { network::Packet poiPkt(static_cast(wirePoiQuery)); poiPkt.writeUInt32(1); // count = 1 poiPkt.writeUInt32(questId); socket->send(poiPkt); } } return true; } void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { // WotLK 3.3.5a SMSG_QUEST_POI_QUERY_RESPONSE format: // uint32 questCount // per quest: // uint32 questId // uint32 poiCount // per poi: // uint32 poiId // int32 objIndex (-1 = no specific objective) // uint32 mapId // uint32 areaId // uint32 floorId // uint32 unk1 // uint32 unk2 // uint32 pointCount // per point: int32 x, int32 y if (packet.getSize() - packet.getReadPos() < 4) return; const uint32_t questCount = packet.readUInt32(); for (uint32_t qi = 0; qi < questCount; ++qi) { if (packet.getSize() - packet.getReadPos() < 8) return; const uint32_t questId = packet.readUInt32(); const uint32_t poiCount = packet.readUInt32(); // Remove any previously added POI markers for this quest to avoid duplicates // on repeated queries (e.g. zone change or force-refresh). gossipPois_.erase( std::remove_if(gossipPois_.begin(), gossipPois_.end(), [questId, this](const GossipPoi& p) { // Match by questId embedded in data field (set below). return p.data == questId; }), gossipPois_.end()); // Find the quest title for the marker label. std::string questTitle; for (const auto& q : questLog_) { if (q.questId == questId) { questTitle = q.title; break; } } for (uint32_t pi = 0; pi < poiCount; ++pi) { if (packet.getSize() - packet.getReadPos() < 28) return; packet.readUInt32(); // poiId packet.readUInt32(); // objIndex (int32) const uint32_t mapId = packet.readUInt32(); packet.readUInt32(); // areaId packet.readUInt32(); // floorId packet.readUInt32(); // unk1 packet.readUInt32(); // unk2 const uint32_t pointCount = packet.readUInt32(); if (pointCount == 0) continue; if (packet.getSize() - packet.getReadPos() < pointCount * 8) return; // Compute centroid of the poi region to place a minimap marker. float sumX = 0.0f, sumY = 0.0f; for (uint32_t pt = 0; pt < pointCount; ++pt) { const int32_t px = static_cast(packet.readUInt32()); const int32_t py = static_cast(packet.readUInt32()); sumX += static_cast(px); sumY += static_cast(py); } // Skip POIs for maps other than the player's current map. if (mapId != currentMapId_) continue; // Add as a GossipPoi; use data field to carry questId for later dedup. GossipPoi poi; poi.x = sumX / static_cast(pointCount); // WoW canonical X poi.y = sumY / static_cast(pointCount); // WoW canonical Y poi.icon = 6; // generic quest POI icon poi.data = questId; // used for dedup on subsequent queries poi.name = questTitle.empty() ? "Quest objective" : questTitle; LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId, " centroid=(", poi.x, ",", poi.y, ") title=", poi.name); gossipPois_.push_back(std::move(poi)); } } } 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; } // Pre-fetch item info for all reward items so icons and names are ready // both in this details window and later in the offer-reward dialog (after the player turns in). for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0); for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0); // Delay opening the window slightly to allow item queries to complete questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); 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(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; // Collect quest IDs and their completion state from update fields. // State field (slot*stride+1) uses the same QuestStatus enum across all expansions: // 0 = none, 1 = complete (ready to turn in), 3 = incomplete/active, etc. static constexpr uint32_t kQuestStatusComplete = 1; std::unordered_map serverQuestComplete; // questId → complete serverQuestComplete.reserve(25); for (uint16_t slot = 0; slot < 25; ++slot) { const uint16_t idField = ufQuestStart + slot * qStride; const uint16_t stateField = ufQuestStart + slot * qStride + 1; auto it = lastPlayerFields_.find(idField); if (it == lastPlayerFields_.end()) continue; uint32_t questId = it->second; if (questId == 0) continue; bool complete = false; if (qStride >= 2) { auto stateIt = lastPlayerFields_.find(stateField); if (stateIt != lastPlayerFields_.end()) { // Lower byte is the quest state; treat any variant of "complete" as done. uint32_t state = stateIt->second & 0xFF; complete = (state == kQuestStatusComplete); } } serverQuestComplete[questId] = complete; } std::unordered_set serverQuestIds; serverQuestIds.reserve(serverQuestComplete.size()); for (const auto& [qid, _] : serverQuestComplete) serverQuestIds.insert(qid); 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; } // Apply server-authoritative completion state to all tracked quests. // This initialises quest.complete correctly on login for quests that were // already complete before the current session started. size_t marked = 0; for (auto& quest : questLog_) { auto it = serverQuestComplete.find(quest.questId); if (it == serverQuestComplete.end()) continue; if (it->second && !quest.complete) { quest.complete = true; ++marked; LOG_DEBUG("Quest ", quest.questId, " marked complete from update fields"); } } 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, " markedComplete=", marked); return true; } // Apply quest completion state from player update fields to already-tracked local quests. // Called from VALUES update handler so quests that complete mid-session (or that were // complete on login) get quest.complete=true without waiting for SMSG_QUESTUPDATE_COMPLETE. void GameHandler::applyQuestStateFromFields(const std::map& fields) { const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); if (ufQuestStart == 0xFFFF || questLog_.empty()) return; const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; if (qStride < 2) return; // Need at least 2 fields per slot (id + state) static constexpr uint32_t kQuestStatusComplete = 1; for (uint16_t slot = 0; slot < 25; ++slot) { const uint16_t idField = ufQuestStart + slot * qStride; const uint16_t stateField = idField + 1; auto idIt = fields.find(idField); if (idIt == fields.end()) continue; uint32_t questId = idIt->second; if (questId == 0) continue; auto stateIt = fields.find(stateField); if (stateIt == fields.end()) continue; bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete); if (!serverComplete) continue; for (auto& quest : questLog_) { if (quest.questId == questId && !quest.complete) { quest.complete = true; LOG_INFO("Quest ", questId, " marked complete from VALUES update field state"); break; } } } } // Extract packed 6-bit kill/objective counts from WotLK/TBC/Classic quest-log update fields // and populate quest.killCounts + quest.itemCounts using the structured objectives obtained // from a prior SMSG_QUEST_QUERY_RESPONSE. Silently does nothing if objectives are absent. void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { if (lastPlayerFields_.empty()) return; const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); if (ufQuestStart == 0xFFFF) return; const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; if (qStride < 3) return; // Need at least id + state + packed-counts field // Find which server slot this quest occupies. int slot = findQuestLogSlotIndexFromServer(quest.questId); if (slot < 0) return; // Packed count fields: stride+2 (all expansions), stride+3 (WotLK only, stride==5) const uint16_t countField1 = ufQuestStart + static_cast(slot) * qStride + 2; const uint16_t countField2 = (qStride >= 5) ? static_cast(countField1 + 1) : static_cast(0xFFFF); auto f1It = lastPlayerFields_.find(countField1); if (f1It == lastPlayerFields_.end()) return; const uint32_t packed1 = f1It->second; uint32_t packed2 = 0; if (countField2 != 0xFFFF) { auto f2It = lastPlayerFields_.find(countField2); if (f2It != lastPlayerFields_.end()) packed2 = f2It->second; } // Unpack six 6-bit counts (bit fields 0-5, 6-11, 12-17, 18-23 in packed1; // bits 0-5, 6-11 in packed2 for objectives 4 and 5). auto unpack6 = [](uint32_t word, int idx) -> uint8_t { return static_cast((word >> (idx * 6)) & 0x3F); }; const uint8_t counts[6] = { unpack6(packed1, 0), unpack6(packed1, 1), unpack6(packed1, 2), unpack6(packed1, 3), unpack6(packed2, 0), unpack6(packed2, 1), }; // Apply kill objective counts (indices 0-3). for (int i = 0; i < 4; ++i) { const auto& obj = quest.killObjectives[i]; if (obj.npcOrGoId == 0 || obj.required == 0) continue; // Negative npcOrGoId means game object; use absolute value as the map key // (SMSG_QUESTUPDATE_ADD_KILL always sends a positive entry regardless of type). const uint32_t entryKey = static_cast( obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); // Don't overwrite live kill count with stale packed data if already non-zero. if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue; quest.killCounts[entryKey] = {counts[i], obj.required}; LOG_DEBUG("Quest ", quest.questId, " objective[", i, "]: npcOrGo=", obj.npcOrGoId, " count=", (int)counts[i], "/", obj.required); } // Apply item objective counts (only available in WotLK stride+3 positions 4-5). // Item counts also arrive live via SMSG_QUESTUPDATE_ADD_ITEM; just initialise here. for (int i = 0; i < 6; ++i) { const auto& obj = quest.itemObjectives[i]; if (obj.itemId == 0 || obj.required == 0) continue; if (i < 2 && qStride >= 5) { uint8_t cnt = counts[4 + i]; if (cnt > 0) { quest.itemCounts[obj.itemId] = std::max(quest.itemCounts[obj.itemId], static_cast(cnt)); } } quest.requiredItemCounts.emplace(obj.itemId, obj.required); } } 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; 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(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(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(localIndex)); } // Remove any quest POI minimap markers for this quest. gossipPois_.erase( std::remove_if(gossipPois_.begin(), gossipPois_.end(), [questId](const GossipPoi& p) { return p.data == questId; }), gossipPois_.end()); } 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; // 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; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; // 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; Classic/TBC do not const bool isWotLk = isActiveExpansion("wotlk"); if (isWotLk) { 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(buybackSlot); pendingBuybackWireSlot_ = wireSlot; network::Packet packet(kWotlkCmsgBuybackItemOpcode); packet.writeUInt64(currentVendorItems.vendorGuid); packet.writeUInt32(wireSlot); socket->send(packet); } void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { if (state != WorldState::IN_WORLD || !socket) return; // CMSG_REPAIR_ITEM: npcGuid(8) + itemGuid(8) + useGuildBank(uint8) network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); packet.writeUInt64(vendorGuid); packet.writeUInt64(itemGuid); packet.writeUInt8(0); // do not use guild bank socket->send(packet); } void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { if (state != WorldState::IN_WORLD || !socket) return; // itemGuid = 0 signals "repair all equipped" to the server network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); packet.writeUInt64(vendorGuid); packet.writeUInt64(0); packet.writeUInt8(useGuildBank ? 1 : 0); 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(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(19 + bagIndex), static_cast(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(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(equipSlot); uint8_t dstBag = 0xFF; uint8_t dstSlot = static_cast(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(static_cast(game::EquipSlot::BAG1) + srcBagIndex); auto dstEquip = static_cast(static_cast(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(19 + srcBagIndex); uint8_t dstSlot = static_cast(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(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(23 + backpackIndex), itemGuid, useSpellId) : UseItemPacket::build(0xFF, static_cast(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(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(19 + bagIndex); auto packet = packetParsers_ ? packetParsers_->buildUseItem(wowBag, static_cast(slotIndex), itemGuid, useSpellId) : UseItemPacket::build(wowBag, static_cast(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_DEBUG("useItemById: searching for itemId=", itemId); // 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_DEBUG("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_DEBUG("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) { // Classic 1.12 and TBC 2.4.3 use 14 bytes/item (no randomSuffix/randomProp fields); // WotLK 3.3.5a uses 22 bytes/item. const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) 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; // questCount is uint8 in all WoW versions for SMSG_QUESTGIVER_QUEST_LIST. uint32_t questCount = 0; if (packet.getSize() - packet.getReadPos() >= 1) { questCount = packet.readUInt8(); } // Classic 1.12 and TBC 2.4.3 don't include questFlags(u32) + isRepeatable(u8) // before the quest title. WotLK 3.3.5a added those 5 bytes. const bool hasQuestFlagsField = !isClassicLikeExpansion() && !isActiveExpansion("tbc"); 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(packet.readUInt32()); if (hasQuestFlagsField && packet.getSize() - packet.getReadPos() >= 5) { q.questFlags = packet.readUInt32(); q.isRepeatable = packet.readUInt8(); } 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) { bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set before openVendor() if (!ListInventoryParser::parse(packet, currentVendorItems)) return; currentVendorItems.canRepair = savedCanRepair; 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) { const bool isClassic = isClassicLikeExpansion(); if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return; trainerWindowOpen_ = true; gossipWindowOpen = false; LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells"); LOG_DEBUG("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_DEBUG("Known spells: ", spellList); } LOG_DEBUG("Prerequisite check: 527=", knownSpells.count(527u), " 25312=", knownSpells.count(25312u)); for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) { const auto& s = currentTrainerList_.spells[i]; LOG_DEBUG(" 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; } // Classic 1.12 Spell.dbc has 148 fields; TBC/WotLK have more. // Require at least 148 so Classic trainers can resolve spell names. if (dbc->getFieldCount() < 148) { LOG_WARNING("Trainer: Spell.dbc has too few fields (", dbc->getFieldCount(), ")"); return; } const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; // Determine school field (bitmask for TBC/WotLK, enum for Classic/Vanilla) uint32_t schoolMaskField = 0, schoolEnumField = 0; bool hasSchoolMask = false, hasSchoolEnum = false; if (spellL) { uint32_t f = spellL->field("SchoolMask"); if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolMaskField = f; hasSchoolMask = true; } f = spellL->field("SchoolEnum"); if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; } } 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()) { SpellNameEntry entry{std::move(name), std::move(rank), 0}; if (hasSchoolMask) { entry.schoolMask = dbc->getUInt32(i, schoolMaskField); } else if (hasSchoolEnum) { // Classic/Vanilla enum: 0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane static const uint32_t enumToBitmask[] = {0x01,0x02,0x04,0x08,0x10,0x20,0x40}; uint32_t e = dbc->getUInt32(i, schoolEnumField); entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0; } spellNameCache_[id] = std::move(entry); } } 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> specialtySpells; std::vector 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>> 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(talentDbc->getUInt32(i, tRow)); entry.column = static_cast(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(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(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(playerLevel) - 5 - static_cast(playerLevel) / 10; else if (playerLevel <= 59) grayLevel = static_cast(playerLevel) - 1 - static_cast(playerLevel) / 5; else grayLevel = static_cast(playerLevel) - 9; if (static_cast(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(victimLevel) - static_cast(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(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::XP_GAIN, static_cast(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): // WotLK: packed GUID + u32 counter + u32 time + movement info with new position // TBC/Classic: uint64 + u32 counter + u32 time + movement info const bool taTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (taTbc ? 8u : 4u)) { LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short"); return; } uint64_t guid = taTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); // Read the movement info embedded in the teleport. // WotLK: moveFlags(4) + moveFlags2(2) + time(4) + x(4) + y(4) + z(4) + o(4) = 26 bytes // Classic 1.12 / TBC 2.4.3: moveFlags(4) + time(4) + x(4) + y(4) + z(4) + o(4) = 24 bytes // (Classic and TBC have no moveFlags2 field in movement packets) const bool taNoFlags2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); const size_t minMoveSz = taNoFlags2 ? (4 + 4 + 4 * 4) : (4 + 2 + 4 + 4 * 4); if (packet.getSize() - packet.getReadPos() < minMoveSz) { LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info"); return; } packet.readUInt32(); // moveFlags if (!taNoFlags2) packet.readUInt16(); // moveFlags2 (WotLK only) 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 // Classic/TBC use full uint64 GUID; WotLK uses packed GUID. if (socket) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK)); const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); if (legacyGuidAck) { ack.writeUInt64(playerGuid); // CMaNGOS/VMaNGOS expects full GUID for Classic/TBC } 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, false); } } 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_INFO("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(); // Quest POI markers are map-specific; remove those that don't apply to the new map. // Markers without a questId tag (data==0) are gossip-window POIs — keep them cleared // here since gossipWindowOpen is reset on teleport anyway. gossipPois_.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, false); } } // ============================================================ // 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& 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> 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 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> adj; for (const auto& edge : taxiPathEdges_) { adj[edge.fromNode].push_back(edge.toNode); } std::unordered_map parent; std::deque 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 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 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(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) { // Classic 1.12 / TBC 2.4.3 per-player: name + guild + level(u32) + class(u32) + race(u32) + zone(u32) // WotLK 3.3.5a added a gender(u8) field between race and zone. const bool hasGender = isActiveExpansion("wotlk"); 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) { if (packet.getReadPos() >= packet.getSize()) break; std::string playerName = packet.readString(); std::string guildName = packet.readString(); if (packet.getSize() - packet.getReadPos() < 12) break; uint32_t level = packet.readUInt32(); uint32_t classId = packet.readUInt32(); uint32_t raceId = packet.readUInt32(); if (hasGender && packet.getSize() - packet.getReadPos() >= 1) packet.readUInt8(); // gender (WotLK only, unused) uint32_t zoneId = 0; if (packet.getSize() - packet.getReadPos() >= 4) zoneId = packet.readUInt32(); std::string msg = " " + playerName; if (!guildName.empty()) msg += " <" + guildName + ">"; msg += " - Level " + std::to_string(level); if (zoneId != 0) { std::string zoneName = getAreaName(zoneId); if (!zoneName.empty()) msg += " [" + zoneName + "]"; } addSystemChatMessage(msg); LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId, " Zone:", zoneId); } } void GameHandler::handleFriendList(network::Packet& packet) { // Classic 1.12 / TBC 2.4.3 SMSG_FRIEND_LIST format: // uint8 count // for each entry: // uint64 guid (full) // uint8 status (0=offline, 1=online, 2=AFK, 3=DND) // if status != 0: // uint32 area // uint32 level // uint32 class auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 1) return; uint8_t count = packet.readUInt8(); LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries"); // Rebuild friend contacts (keep ignores from previous contact_ entries) contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), [](const ContactEntry& e){ return e.isFriend(); }), contacts_.end()); for (uint8_t i = 0; i < count && rem() >= 9; ++i) { uint64_t guid = packet.readUInt64(); uint8_t status = packet.readUInt8(); uint32_t area = 0, level = 0, classId = 0; if (status != 0 && rem() >= 12) { area = packet.readUInt32(); level = packet.readUInt32(); classId = packet.readUInt32(); } // Track as a friend GUID; resolve name via name query friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); std::string name; if (nit != playerNameCache.end()) { name = nit->second; friendsCache[name] = guid; LOG_INFO(" Friend: ", name, " status=", (int)status); } else { LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec, " status=", (int)status, " (name pending)"); queryPlayerName(guid); } ContactEntry entry; entry.guid = guid; entry.name = name; entry.flags = 0x1; // friend entry.status = status; entry.areaId = area; entry.level = level; entry.classId = classId; contacts_.push_back(std::move(entry)); } } void GameHandler::handleContactList(network::Packet& packet) { // WotLK SMSG_CONTACT_LIST format: // uint32 listMask (1=friend, 2=ignore, 4=mute) // uint32 count // for each entry: // uint64 guid (full) // uint32 flags // string note (null-terminated) // if flags & 0x1 (friend): // uint8 status (0=offline, 1=online, 2=AFK, 3=DND) // if status != 0: // uint32 area, uint32 level, uint32 class // Short/keepalive variant (1-7 bytes): consume silently. auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 8) { packet.setReadPos(packet.getSize()); return; } lastContactListMask_ = packet.readUInt32(); lastContactListCount_ = packet.readUInt32(); contacts_.clear(); for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) { uint64_t guid = packet.readUInt64(); if (rem() < 4) break; uint32_t flags = packet.readUInt32(); std::string note = packet.readString(); // may be empty uint8_t status = 0; uint32_t areaId = 0; uint32_t level = 0; uint32_t classId = 0; if (flags & 0x1) { // SOCIAL_FLAG_FRIEND if (rem() < 1) break; status = packet.readUInt8(); if (status != 0 && rem() >= 12) { areaId = packet.readUInt32(); level = packet.readUInt32(); classId = packet.readUInt32(); } friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); if (nit != playerNameCache.end()) { friendsCache[nit->second] = guid; } else { queryPlayerName(guid); } } // ignore / mute entries: no additional fields beyond guid+flags+note ContactEntry entry; entry.guid = guid; entry.flags = flags; entry.note = std::move(note); entry.status = status; entry.areaId = areaId; entry.level = level; entry.classId = classId; auto nit = playerNameCache.find(guid); if (nit != playerNameCache.end()) entry.name = nit->second; contacts_.push_back(std::move(entry)); } LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, " count=", lastContactListCount_); } 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); } // Mirror into contacts_: update existing entry or add/remove as needed if (data.status == 0) { // Removed from friends list contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), [&](const ContactEntry& e){ return e.guid == data.guid; }), contacts_.end()); } else { auto cit = std::find_if(contacts_.begin(), contacts_.end(), [&](const ContactEntry& e){ return e.guid == data.guid; }); if (cit != contacts_.end()) { if (!playerName.empty() && playerName != "Unknown") cit->name = playerName; // status: 2=online→1, 3=offline→0, 1=added→1 (online on add) if (data.status == 2) cit->status = 1; else if (data.status == 3) cit->status = 0; } else { ContactEntry entry; entry.guid = data.guid; entry.name = playerName; entry.flags = 0x1; // friend entry.status = (data.status == 2) ? 1 : 0; contacts_.push_back(std::move(entry)); } } // 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 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& fields) { loadSkillLineDbc(); const uint16_t PLAYER_SKILL_INFO_START = fieldIndex(UF::PLAYER_SKILL_INFO_START); static constexpr int MAX_SKILL_SLOTS = 128; std::map 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& fields) { // Number of explored-zone uint32 fields varies by expansion: // Classic/Turtle = 64, TBC/WotLK = 128. Always allocate 128 for world-map // bit lookups, but only read the expansion-specific count to avoid reading // player money or rest-XP fields as zone flags. const size_t zoneCount = packetParsers_ ? static_cast(packetParsers_->exploredZonesCount()) : PLAYER_EXPLORED_ZONES_COUNT; if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) { playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u); } bool foundAny = false; for (size_t i = 0; i < zoneCount; i++) { const uint16_t fieldIdx = static_cast(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i); auto it = fields.find(fieldIdx); if (it == fields.end()) continue; playerExploredZones_[i] = it->second; foundAny = true; } // Zero out slots beyond the expansion's zone count to prevent stale data // from polluting the fog-of-war display. for (size_t i = zoneCount; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { playerExploredZones_[i] = 0u; } 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(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(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 types{}; std::array 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(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(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(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 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 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(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(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(19 + bagIndex); mailAttachments_[i].srcSlot = static_cast(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(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_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec, " purchased=", static_cast(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_INFO("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(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(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) { // Classic 1.12 has 1 enchant slot per auction entry; TBC/WotLK have 3. const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; if (!AuctionListResultParser::parse(packet, result, enchSlots)) { 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) { const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; if (!AuctionListResultParser::parse(packet, result, enchSlots)) { 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) { const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; if (!AuctionListResultParser::parse(packet, result, enchSlots)) { 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(entity.get())) { sharedQuestSharerName_ = unit->getName(); } if (sharedQuestSharerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", static_cast(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(entity.get())) { summonerName_ = unit->getName(); } if (summonerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", static_cast(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(entity.get())) { tradePeerName_ = unit->getName(); } if (tradePeerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", static_cast(tradePeerGuid_)); tradePeerName_ = tmp; } tradeStatus_ = TradeStatus::PendingIncoming; addSystemChatMessage(tradePeerName_ + " wants to trade with you."); break; } case 2: // OPEN_WINDOW myTradeSlots_.fill(TradeSlot{}); peerTradeSlots_.fill(TradeSlot{}); myTradeGold_ = 0; peerTradeGold_ = 0; tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); break; case 3: // CANCELLED case 9: // REJECTED case 12: // CLOSE_WINDOW resetTradeState(); addSystemChatMessage("Trade cancelled."); break; case 4: // ACCEPTED (partner accepted) tradeStatus_ = TradeStatus::Accepted; addSystemChatMessage("Trade accepted. Awaiting other player..."); break; case 8: // COMPLETE addSystemChatMessage("Trade complete!"); resetTradeState(); 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; resetTradeState(); socket->send(CancelTradePacket::build()); } void GameHandler::setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; socket->send(SetTradeItemPacket::build(tradeSlot, bag, bagSlot)); } void GameHandler::clearTradeItem(uint8_t tradeSlot) { if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; myTradeSlots_[tradeSlot] = TradeSlot{}; socket->send(ClearTradeItemPacket::build(tradeSlot)); } void GameHandler::setTradeGold(uint64_t copper) { if (!isTradeOpen() || !socket) return; myTradeGold_ = copper; socket->send(SetTradeGoldPacket::build(copper)); } void GameHandler::resetTradeState() { tradeStatus_ = TradeStatus::None; myTradeGold_ = 0; peerTradeGold_ = 0; myTradeSlots_.fill(TradeSlot{}); peerTradeSlots_.fill(TradeSlot{}); } void GameHandler::handleTradeStatusExtended(network::Packet& packet) { // WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format: // uint8 isSelfState (1 = my trade window, 0 = peer's) // uint32 tradeId // uint32 slotCount (7: 6 normal + 1 extra for enchanting) // Per slot (up to slotCount): // uint8 slotIndex // uint32 itemId // uint32 displayId // uint32 stackCount // uint8 isWrapped // uint64 giftCreatorGuid // uint32 enchantId (and several more enchant/stat fields) // ... (complex; we parse only the essential fields) // uint64 coins (gold offered by the sender of this message) size_t rem = packet.getSize() - packet.getReadPos(); if (rem < 9) return; uint8_t isSelf = packet.readUInt8(); uint32_t tradeId = packet.readUInt32(); (void)tradeId; uint32_t slotCount= packet.readUInt32(); auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_; for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) { uint8_t slotIdx = packet.readUInt8(); uint32_t itemId = packet.readUInt32(); uint32_t displayId = packet.readUInt32(); uint32_t stackCount = packet.readUInt32(); bool isWrapped = false; if (packet.getSize() - packet.getReadPos() >= 1) { isWrapped = (packet.readUInt8() != 0); } // AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped: // giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12) // + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24] // + randomPropertyId (4) + suffixFactor (4) // + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes constexpr size_t SLOT_TRAIL = 52; if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); } else { packet.setReadPos(packet.getSize()); return; } (void)isWrapped; if (slotIdx < TRADE_SLOT_COUNT) { TradeSlot& s = slots[slotIdx]; s.itemId = itemId; s.displayId = displayId; s.stackCount = stackCount; s.occupied = (itemId != 0); } } // Gold offered (uint64 copper) if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t coins = packet.readUInt64(); if (isSelf) myTradeGold_ = coins; else peerTradeGold_ = coins; } // Prefetch item info for all occupied trade slots so names show immediately for (const auto& s : slots) { if (s.occupied && s.itemId != 0) queryItemInfo(s.itemId, 0); } LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf, " myGold=", myTradeGold_, " peerGold=", peerTradeGold_); } // --------------------------------------------------------------------------- // Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL) // --------------------------------------------------------------------------- void GameHandler::handleLootRoll(network::Packet& packet) { // WotLK 3.3.5a: uint64 objectGuid, uint32 slot, uint64 playerGuid, // uint32 itemId, uint32 randomSuffix, uint32 randomPropId, uint8 rollNumber, uint8 rollType (34 bytes) // Classic/TBC: uint64 objectGuid, uint32 slot, uint64 playerGuid, // uint32 itemId, uint8 rollNumber, uint8 rollType (26 bytes) const bool isWotLK = isActiveExpansion("wotlk"); const size_t minSize = isWotLK ? 34u : 26u; size_t rem = packet.getSize() - packet.getReadPos(); if (rem < minSize) return; uint64_t objectGuid = packet.readUInt64(); uint32_t slot = packet.readUInt32(); uint64_t rollerGuid = packet.readUInt64(); uint32_t itemId = packet.readUInt32(); if (isWotLK) { /*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; // Ensure item info is in cache; query if not queryItemInfo(itemId, 0); // Look up item name from cache auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(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(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(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) { // WotLK 3.3.5a: uint64 objectGuid, uint32 slot, uint64 winnerGuid, // uint32 itemId, uint32 randomSuffix, uint32 randomPropId, uint8 rollNumber, uint8 rollType (34 bytes) // Classic/TBC: uint64 objectGuid, uint32 slot, uint64 winnerGuid, // uint32 itemId, uint8 rollNumber, uint8 rollType (26 bytes) const bool isWotLK = isActiveExpansion("wotlk"); const size_t minSize = isWotLK ? 34u : 26u; size_t rem = packet.getSize() - packet.getReadPos(); if (rem < minSize) return; /*uint64_t objectGuid =*/ packet.readUInt64(); /*uint32_t slot =*/ packet.readUInt32(); uint64_t winnerGuid = packet.readUInt64(); uint32_t itemId = packet.readUInt32(); if (isWotLK) { /*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(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(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::loadAchievementNameCache() { if (achievementNameCacheLoaded_) return; achievementNameCacheLoaded_ = true; auto* am = core::Application::getInstance().getAssetManager(); if (!am || !am->isInitialized()) return; auto dbc = am->loadDBC("Achievement.dbc"); if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 22) return; const auto* achL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr; uint32_t titleField = achL ? achL->field("Title") : 4; if (titleField == 0xFFFFFFFF) titleField = 4; for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, 0); if (id == 0) continue; std::string title = dbc->getString(i, titleField); if (!title.empty()) achievementNameCache_[id] = std::move(title); } LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc"); } 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 loadAchievementNameCache(); auto nameIt = achievementNameCache_.find(achievementId); const std::string& achName = (nameIt != achievementNameCache_.end()) ? nameIt->second : std::string(); // Show chat notification bool isSelf = (guid == playerGuid); if (isSelf) { char buf[256]; if (!achName.empty()) { std::snprintf(buf, sizeof(buf), "Achievement earned: %s", achName.c_str()); } else { std::snprintf(buf, sizeof(buf), "Achievement earned! (ID %u)", achievementId); } addSystemChatMessage(buf); earnedAchievements_.insert(achievementId); if (achievementEarnedCallback_) { achievementEarnedCallback_(achievementId, achName); } } else { // Another player in the zone earned an achievement std::string senderName; auto entity = entityManager.getEntity(guid); if (auto* unit = dynamic_cast(entity.get())) { senderName = unit->getName(); } if (senderName.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", static_cast(guid)); senderName = tmp; } char buf[256]; if (!achName.empty()) { std::snprintf(buf, sizeof(buf), "%s has earned the achievement: %s", senderName.c_str(), achName.c_str()); } else { 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, achName.empty() ? "" : " name=", achName); } // --------------------------------------------------------------------------- // SMSG_ALL_ACHIEVEMENT_DATA (WotLK 3.3.5a) // Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel // Criteria records: repeated { uint32 id, uint64 counter, uint32 packedDate, ... } until 0xFFFFFFFF // --------------------------------------------------------------------------- void GameHandler::handleAllAchievementData(network::Packet& packet) { loadAchievementNameCache(); earnedAchievements_.clear(); // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; if (packet.getSize() - packet.getReadPos() < 4) break; /*uint32_t date =*/ packet.readUInt32(); earnedAchievements_.insert(id); } // Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF) while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; // counter(8) + date(4) + unknown(4) = 16 bytes if (packet.getSize() - packet.getReadPos() < 16) break; packet.readUInt64(); // counter packet.readUInt32(); // date packet.readUInt32(); // unknown / flags } LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements"); } // --------------------------------------------------------------------------- // Faction name cache (lazily loaded from Faction.dbc) // --------------------------------------------------------------------------- void GameHandler::loadFactionNameCache() { if (factionNameCacheLoaded_) return; factionNameCacheLoaded_ = true; auto* am = core::Application::getInstance().getAssetManager(); if (!am || !am->isInitialized()) return; auto dbc = am->loadDBC("Faction.dbc"); if (!dbc || !dbc->isLoaded()) return; // Faction.dbc WotLK 3.3.5a field layout: // 0: ID // 1-4: ReputationRaceMask[4] // 5-8: ReputationClassMask[4] // 9-12: ReputationBase[4] // 13-16: ReputationFlags[4] // 17: ParentFactionID // 18-19: Spillover rates (floats) // 20-21: MaxRank // 22: Name (English locale, string ref) constexpr uint32_t ID_FIELD = 0; constexpr uint32_t NAME_FIELD = 22; // enUS name string if (dbc->getFieldCount() <= NAME_FIELD) { LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount()); return; } uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t factionId = dbc->getUInt32(i, ID_FIELD); if (factionId == 0) continue; std::string name = dbc->getString(i, NAME_FIELD); if (!name.empty()) { factionNameCache_[factionId] = std::move(name); } } LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names"); } std::string GameHandler::getFactionName(uint32_t factionId) const { auto it = factionNameCache_.find(factionId); if (it != factionNameCache_.end()) return it->second; return "faction #" + std::to_string(factionId); } const std::string& GameHandler::getFactionNamePublic(uint32_t factionId) const { const_cast(this)->loadFactionNameCache(); auto it = factionNameCache_.find(factionId); if (it != factionNameCache_.end()) return it->second; static const std::string empty; return empty; } // --------------------------------------------------------------------------- // Area name cache (lazy-loaded from WorldMapArea.dbc) // --------------------------------------------------------------------------- void GameHandler::loadAreaNameCache() { if (areaNameCacheLoaded_) return; areaNameCacheLoaded_ = true; auto* am = core::Application::getInstance().getAssetManager(); if (!am || !am->isInitialized()) return; auto dbc = am->loadDBC("WorldMapArea.dbc"); if (!dbc || !dbc->isLoaded()) return; const auto* layout = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("WorldMapArea") : nullptr; const uint32_t areaIdField = layout ? (*layout)["AreaID"] : 2; const uint32_t areaNameField = layout ? (*layout)["AreaName"] : 3; if (dbc->getFieldCount() <= areaNameField) return; for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t areaId = dbc->getUInt32(i, areaIdField); if (areaId == 0) continue; std::string name = dbc->getString(i, areaNameField); if (!name.empty() && !areaNameCache_.count(areaId)) { areaNameCache_[areaId] = std::move(name); } } LOG_INFO("WorldMapArea.dbc: loaded ", areaNameCache_.size(), " area names"); } std::string GameHandler::getAreaName(uint32_t areaId) const { if (areaId == 0) return {}; const_cast(this)->loadAreaNameCache(); auto it = areaNameCache_.find(areaId); return (it != areaNameCache_.end()) ? it->second : std::string{}; } // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- void GameHandler::handleUpdateAuraDuration(uint8_t slot, uint32_t durationMs) { if (slot >= playerAuras.size()) return; if (playerAuras[slot].isEmpty()) return; playerAuras[slot].durationMs = static_cast(durationMs); playerAuras[slot].receivedAtMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); } // --------------------------------------------------------------------------- // Equipment set list // --------------------------------------------------------------------------- void GameHandler::handleEquipmentSetList(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t count = packet.readUInt32(); if (count > 10) { LOG_WARNING("SMSG_EQUIPMENT_SET_LIST: unexpected count ", count, ", ignoring"); packet.setReadPos(packet.getSize()); return; } equipmentSets_.clear(); equipmentSets_.reserve(count); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < 16) break; EquipmentSet es; es.setGuid = packet.readUInt64(); es.setId = packet.readUInt32(); es.name = packet.readString(); es.iconName = packet.readString(); es.ignoreSlotMask = packet.readUInt32(); for (int slot = 0; slot < 19; ++slot) { if (packet.getSize() - packet.getReadPos() < 8) break; es.itemGuids[slot] = packet.readUInt64(); } equipmentSets_.push_back(std::move(es)); } LOG_INFO("SMSG_EQUIPMENT_SET_LIST: ", equipmentSets_.size(), " equipment sets received"); } // --------------------------------------------------------------------------- // Forced faction reactions // --------------------------------------------------------------------------- void GameHandler::handleSetForcedReactions(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t count = packet.readUInt32(); if (count > 64) { LOG_WARNING("SMSG_SET_FORCED_REACTIONS: suspicious count ", count, ", ignoring"); packet.setReadPos(packet.getSize()); return; } forcedReactions_.clear(); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t factionId = packet.readUInt32(); uint32_t reaction = packet.readUInt32(); forcedReactions_[factionId] = static_cast(reaction); } LOG_INFO("SMSG_SET_FORCED_REACTIONS: ", forcedReactions_.size(), " faction overrides"); } } // namespace game } // namespace wowee