#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; } } // Build a WoW-format item link for use in system chat messages. // The chat renderer in game_screen.cpp parses this format and draws the // item name in its quality colour with a small icon and tooltip. // Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r std::string buildItemLink(uint32_t itemId, uint32_t quality, const std::string& name) { static const char* kQualHex[] = { "9d9d9d", // 0 Poor "ffffff", // 1 Common "1eff00", // 2 Uncommon "0070dd", // 3 Rare "a335ee", // 4 Epic "ff8000", // 5 Legendary "e6cc80", // 6 Artifact "e6cc80", // 7 Heirloom }; uint32_t qi = quality < 8 ? quality : 1u; char buf[512]; snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", kQualHex[qi], itemId, name.c_str()); return buf; } 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'); } int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; char* end = nullptr; long parsed = std::strtol(raw, &end, 10); if (end == raw) return defaultValue; return static_cast(std::clamp(parsed, minValue, maxValue)); } int incomingPacketsBudgetPerUpdate(WorldState state) { static const int inWorldBudget = parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512); static const int loginBudget = parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512); return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; } float incomingPacketBudgetMs(WorldState state) { static const int inWorldBudgetMs = parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50); static const int loginBudgetMs = parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50); return static_cast(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs); } int updateObjectBlocksBudgetPerUpdate(WorldState state) { static const int inWorldBudget = parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048); static const int loginBudget = parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096); return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; } float slowPacketLogThresholdMs() { static const int thresholdMs = parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000); return static_cast(thresholdMs); } float slowUpdateObjectBlockLogThresholdMs() { static const int thresholdMs = parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000); return static_cast(thresholdMs); } constexpr size_t kMaxQueuedInboundPackets = 4096; bool hasFullPackedGuid(const network::Packet& packet) { if (packet.getReadPos() >= packet.getSize()) { return false; } const auto& rawData = packet.getData(); const uint8_t mask = rawData[packet.getReadPos()]; size_t guidBytes = 1; for (int bit = 0; bit < 8; ++bit) { if ((mask & (1u << bit)) != 0) { ++guidBytes; } } return packet.getSize() - packet.getReadPos() >= guidBytes; } bool packetHasRemaining(const network::Packet& packet, size_t need) { const size_t size = packet.getSize(); const size_t pos = packet.getReadPos(); return pos <= size && need <= (size - pos); } CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) { switch (missInfo) { case 0: return CombatTextEntry::MISS; case 1: return CombatTextEntry::DODGE; case 2: return CombatTextEntry::PARRY; case 3: return CombatTextEntry::BLOCK; case 4: return CombatTextEntry::EVADE; case 5: return CombatTextEntry::IMMUNE; case 6: return CombatTextEntry::DEFLECT; case 7: return CombatTextEntry::ABSORB; case 8: return CombatTextEntry::RESIST; case 9: // Some cores encode SPELL_MISS_IMMUNE2 as 9. case 10: // Others encode SPELL_MISS_IMMUNE2 as 10. return CombatTextEntry::IMMUNE; case 11: return CombatTextEntry::REFLECT; default: return CombatTextEntry::MISS; } } 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(); } std::string displaySpellName(GameHandler& handler, uint32_t spellId) { if (spellId == 0) return {}; const std::string& name = handler.getSpellName(spellId); if (!name.empty()) return name; return "spell " + std::to_string(spellId); } std::string formatSpellNameList(GameHandler& handler, const std::vector& spellIds, size_t maxShown = 3) { if (spellIds.empty()) return {}; const size_t shownCount = std::min(spellIds.size(), maxShown); std::ostringstream oss; for (size_t i = 0; i < shownCount; ++i) { if (i > 0) { if (shownCount == 2) { oss << " and "; } else if (i == shownCount - 1) { oss << ", and "; } else { oss << ", "; } } oss << displaySpellName(handler, spellIds[i]); } if (spellIds.size() > shownCount) { oss << ", and " << (spellIds.size() - shownCount) << " more"; } 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; } float mergeCooldownSeconds(float current, float incoming) { constexpr float kEpsilon = 0.05f; if (incoming <= 0.0f) return 0.0f; if (current <= 0.0f) return incoming; // Cooldowns should normally tick down. If a duplicate/late packet reports a // larger value, keep the local remaining time to avoid visible timer resets. if (incoming > current + kEpsilon) return current; return incoming; } 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); } } // Parse quest reward fields from SMSG_QUEST_QUERY_RESPONSE fixed header. // Classic/TBC: 40 fixed fields; WotLK: 55 fixed fields. struct QuestQueryRewards { int32_t rewardMoney = 0; std::array itemId{}; std::array itemCount{}; std::array choiceItemId{}; std::array choiceItemCount{}; bool valid = false; }; static QuestQueryRewards tryParseQuestRewards(const std::vector& data, bool classicLayout) { const size_t base = 8; // after questId(4) + questMethod(4) const size_t fieldCount = classicLayout ? 40u : 55u; const size_t headerEnd = base + fieldCount * 4u; if (data.size() < headerEnd) return {}; // Field indices (0-based) for each expansion: // Classic/TBC: rewardMoney=[14], rewardItemId[4]=[20..23], rewardItemCount[4]=[24..27], // rewardChoiceItemId[6]=[28..33], rewardChoiceItemCount[6]=[34..39] // WotLK: rewardMoney=[17], rewardItemId[4]=[30..33], rewardItemCount[4]=[34..37], // rewardChoiceItemId[6]=[38..43], rewardChoiceItemCount[6]=[44..49] const size_t moneyField = classicLayout ? 14u : 17u; const size_t itemIdField = classicLayout ? 20u : 30u; const size_t itemCountField = classicLayout ? 24u : 34u; const size_t choiceIdField = classicLayout ? 28u : 38u; const size_t choiceCntField = classicLayout ? 34u : 44u; QuestQueryRewards out; out.rewardMoney = static_cast(readU32At(data, base + moneyField * 4u)); for (size_t i = 0; i < 4; ++i) { out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u); out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u); } for (size_t i = 0; i < 6; ++i) { out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u); out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u); } out.valid = true; return out; } } // 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) { enqueueIncomingPacket(packet); }); // 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(); guildNameCache_.clear(); pendingGuildNameQueries_.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(); pendingIncomingPackets_.clear(); pendingUpdateObjectWork_.clear(); // Fire despawn callbacks so the renderer releases M2/character model resources. for (const auto& [guid, entity] : entityManager.getEntities()) { if (guid == playerGuid) continue; if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) creatureDespawnCallback_(guid); else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) playerDespawnCallback_(guid); else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) gameObjectDespawnCallback_(guid); } otherPlayerVisibleItemEntries_.clear(); otherPlayerVisibleDirty_.clear(); otherPlayerMoveTimeMs_.clear(); unitCastStates_.clear(); unitAurasCache_.clear(); combatText.clear(); 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; } // Reset per-tick monster-move budget tracking (Classic/Turtle flood protection). monsterMovePacketsThisTick_ = 0; monsterMovePacketsDroppedThisTick_ = 0; // 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"); } } { auto packetStart = std::chrono::steady_clock::now(); processQueuedIncomingPackets(); float packetMs = std::chrono::duration( std::chrono::steady_clock::now() - packetStart).count(); if (packetMs > 3.0f) { LOG_WARNING("SLOW queued packet handling: ", packetMs, "ms"); } } // Drain pending async Warden response (built on background thread to avoid 5s stalls) if (wardenResponsePending_) { auto status = wardenPendingEncrypted_.wait_for(std::chrono::milliseconds(0)); if (status == std::future_status::ready) { auto plaintext = wardenPendingEncrypted_.get(); wardenResponsePending_ = false; if (!plaintext.empty() && wardenCrypto_) { 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_WARNING("Warden: Sent async CHEAT_CHECKS_RESULT (", plaintext.size(), " bytes plaintext)"); } } } } // Detect RX silence (server stopped sending packets but TCP still open) if (state == WorldState::IN_WORLD && socket && socket->isConnected() && lastRxTime_.time_since_epoch().count() > 0) { auto silenceMs = std::chrono::duration_cast( std::chrono::steady_clock::now() - lastRxTime_).count(); if (silenceMs > 10000 && !rxSilenceLogged_) { rxSilenceLogged_ = true; LOG_WARNING("RX SILENCE: No packets from server for ", silenceMs, "ms — possible soft disconnect"); } if (silenceMs > 15000 && silenceMs < 15500) { LOG_WARNING("RX SILENCE: 15s — server appears to have stopped sending"); } } // Detect server-side disconnect (socket closed during update) if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) { if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { LOG_WARNING("Server closed connection in state: ", worldStateName(state)); disconnect(); return; } LOG_DEBUG("World socket closed with ", pendingIncomingPackets_.size(), " queued packet(s) and ", pendingUpdateObjectWork_.size(), " update-object batch(es) pending dispatch"); } // 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(); } // Detect combat state transitions → fire PLAYER_REGEN_DISABLED / PLAYER_REGEN_ENABLED { bool combatNow = isInCombat(); if (combatNow != wasCombat_) { wasCombat_ = combatNow; if (addonEventCallback_) { addonEventCallback_(combatNow ? "PLAYER_REGEN_DISABLED" : "PLAYER_REGEN_ENABLED", {}); } } } 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) { // Avoid sending CMSG_LOOT while a timed cast is active (e.g. gathering). // handleSpellGo will trigger loot after the cast completes. if (casting && currentCastSpellId != 0) { it->timer = 0.20f; ++it; continue; } 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; const float currentPingInterval = (isClassicLikeExpansion() || isActiveExpansion("tbc")) ? 10.0f : pingInterval; if (timeSinceLastPing >= currentPingInterval) { if (socket) { sendPing(); } timeSinceLastPing = 0.0f; } const bool classicLikeCombatSync = autoAttackRequested_ && (isClassicLikeExpansion() || isActiveExpansion("tbc")); const uint32_t locomotionFlags = 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::ASCENDING) | static_cast(MovementFlags::FALLING) | static_cast(MovementFlags::FALLINGFAR); const bool classicLikeStationaryCombatSync = classicLikeCombatSync && !onTaxiFlight_ && !taxiActivatePending_ && !taxiClientActive_ && (movementInfo.flags & locomotionFlags) == 0; float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_) ? 0.25f : (classicLikeStationaryCombatSync ? 0.75f : (classicLikeCombatSync ? 0.20f : 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; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; addUIError("Interrupted."); 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; castIsChannel = 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); // Tick logout countdown if (loggingOut_ && logoutCountdown_ > 0.0f) { logoutCountdown_ -= deltaTime; if (logoutCountdown_ < 0.0f) logoutCountdown_ = 0.0f; } // 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."); addUIError("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; // Classic/Turtle servers do not tolerate steady attack-start // reissues well. Only retry once after local start or an // explicit server-side attack stop while intent is still set. const float resendInterval = classicLike ? 1.0f : 0.50f; if (!autoAttacking && !autoAttackOutOfRange_ && autoAttackRetryPending_ && autoAttackResendTimer_ >= resendInterval) { autoAttackResendTimer_ = 0.0f; autoAttackRetryPending_ = false; auto pkt = AttackSwingPacket::build(autoAttackTarget); socket->send(pkt); } // Keep server-facing aligned while trying to acquire melee. // Once the server confirms auto-attack, rely on explicit // bad-facing feedback instead of periodic steady-state facing spam. const float facingSyncInterval = classicLike ? 0.25f : 0.20f; const bool allowPeriodicFacingSync = !classicLike || !autoAttacking; if (allowPeriodicFacingSync && autoAttackFacingSyncTimer_ >= facingSyncInterval) { autoAttackFacingSyncTimer_ = 0.0f; float toTargetX = targetX - movementInfo.x; float toTargetY = targetY - movementInfo.y; 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); } } } } } } } // 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: addUIError("You cannot send messages to members of that faction."); addSystemChatMessage("You cannot send messages to members of that faction."); break; case Opcode::SMSG_CHAT_NOT_IN_PARTY: addUIError("You are not in a party."); addSystemChatMessage("You are not in a party."); break; case Opcode::SMSG_CHAT_RESTRICTED: addUIError("You cannot send chat messages in this area."); 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_WHOIS: { // GM/admin response to /whois command: cstring with account/IP info // Format: string (the whois result text, typically "Name: ...\nAccount: ...\nIP: ...") if (packet.getReadPos() < packet.getSize()) { std::string whoisText = packet.readString(); if (!whoisText.empty()) { // Display each line of the whois response in system chat std::string line; for (char c : whoisText) { if (c == '\n') { if (!line.empty()) addSystemChatMessage("[Whois] " + line); line.clear(); } else { line += c; } } if (!line.empty()) addSystemChatMessage("[Whois] " + line); LOG_INFO("SMSG_WHOIS: ", whoisText); } } 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) { if (const ItemQueryResponseData* info = getItemInfo(itemId)) { // Item info already cached — emit immediately. std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name; // Append random suffix name (e.g., "of the Eagle") if present if (randomProp != 0) { std::string suffix = getRandomPropertyName(randomProp); if (!suffix.empty()) itemName += " " + suffix; } uint32_t quality = info->quality; std::string link = buildItemLink(itemId, quality, itemName); std::string msg = "Received: " + link; if (count > 1) msg += " x" + std::to_string(count); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playLootItem(); } if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName); // Fire CHAT_MSG_LOOT for loot tracking addons if (addonEventCallback_) addonEventCallback_("CHAT_MSG_LOOT", {msg, "", std::to_string(itemId), std::to_string(count)}); } else { // Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE. pendingItemPushNotifs_.push_back({itemId, count}); } } 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); addCombatText(CombatTextEntry::XP_GAIN, static_cast(xpGained), 0, true); // XP is updated via PLAYER_XP update fields from the server. if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained); } } 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; addUIError(s); addSystemChatMessage(s); } break; } case Opcode::SMSG_PET_ACTION_FEEDBACK: { // uint8 msg: 1=dead, 2=nothing_to_attack, 3=cant_attack_target, // 4=target_too_far, 5=no_path, 6=cant_attack_immune if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t msg = packet.readUInt8(); static const char* kPetFeedback[] = { nullptr, "Your pet is dead.", "Your pet has nothing to attack.", "Your pet cannot attack that target.", "That target is too far away.", "Your pet cannot find a path to the target.", "Your pet cannot attack an immune target.", }; if (msg > 0 && msg < 7 && kPetFeedback[msg]) { addSystemChatMessage(kPetFeedback[msg]); } packet.setReadPos(packet.getSize()); 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(); std::string questTitle; for (const auto& q : questLog_) if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } addSystemChatMessage(questTitle.empty() ? std::string("Quest failed!") : ('"' + questTitle + "\" failed!")); } break; } case Opcode::SMSG_QUESTUPDATE_FAILEDTIMER: { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); std::string questTitle; for (const auto& q : questLog_) if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } addSystemChatMessage(questTitle.empty() ? std::string("Quest timed out!") : ('"' + questTitle + "\" has timed out.")); } 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); } if (addonEventCallback_ && guid != 0) { std::string unitId; if (guid == playerGuid) unitId = "player"; else if (guid == targetGuid) unitId = "target"; else if (guid == focusGuid) unitId = "focus"; else if (guid == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_HEALTH", {unitId}); } 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); } if (addonEventCallback_ && guid != 0) { std::string unitId; if (guid == playerGuid) unitId = "player"; else if (guid == targetGuid) unitId = "target"; else if (guid == focusGuid) unitId = "focus"; else if (guid == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_POWER", {unitId}); } 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); if (honor > 0) addCombatText(CombatTextEntry::HONOR_GAIN, static_cast(honor), 0, true); if (pvpHonorCallback_) { pvpHonorCallback_(honor, victimGuid, rank); } if (addonEventCallback_) addonEventCallback_("CHAT_MSG_COMBAT_HONOR_GAIN", {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; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; // Cancel craft queue and spell queue on cast failure craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; // 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); std::string errMsg = reason ? reason : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addUIError(errMsg); if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId); if (addonEventCallback_) { addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)}); addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)}); } MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = errMsg; 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); // Fire cast failure events so cast bar addons clear the bar if (addonEventCallback_) { std::string unitId; if (failOtherGuid == targetGuid) unitId = "target"; else if (failOtherGuid == focusGuid) unitId = "focus"; if (!unitId.empty()) { addonEventCallback_("UNIT_SPELLCAST_FAILED", {unitId}); addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); } } } packet.setReadPos(packet.getSize()); break; } // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + ... // TBC: uint64 caster + uint64 victim + uint32 spellId + ... const bool prUsesFullGuid = isActiveExpansion("tbc"); auto readPrGuid = [&]() -> uint64_t { if (prUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t caster = readPrGuid(); if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); 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, 0, caster, victim); } else if (caster == playerGuid) { addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); } 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(); int32_t rollRandProp = 0; if (isWotLK) { /*uint32_t randSuffix =*/ packet.readUInt32(); rollRandProp = static_cast(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; // Ensure item info is queried so the roll popup can show the name/icon. queryItemInfo(itemId, 0); auto* info = getItemInfo(itemId); std::string rollItemName = info ? info->name : std::to_string(itemId); if (rollRandProp != 0) { std::string suffix = getRandomPropertyName(rollRandProp); if (!suffix.empty()) rollItemName += " " + suffix; } pendingLootRoll_.itemName = rollItemName; pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000; pendingLootRoll_.voteMask = voteMask; pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec); if (addonEventCallback_) addonEventCallback_("START_LOOT_ROLL", {std::to_string(slot), std::to_string(countdown)}); break; } // ---- Pet stable list ---- case Opcode::MSG_LIST_STABLED_PETS: if (state == WorldState::IN_WORLD) handleListStabledPets(packet); 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."; addUIError(msg); break; default: break; } if (msg) addSystemChatMessage(msg); LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); // Refresh the stable list after a result to reflect the new state if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) { auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_); socket->send(refreshPkt); } 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(); loadTitleNameCache(); // Format the title string using the player's own name std::string titleStr; auto tit = titleNameCache_.find(titleBit); if (tit != titleNameCache_.end() && !tit->second.empty()) { // Title strings contain "%s" as a player-name placeholder. // Replace it with the local player's name if known. auto nameIt = playerNameCache.find(playerGuid); const std::string& pName = (nameIt != playerNameCache.end()) ? nameIt->second : "you"; const std::string& fmt = tit->second; size_t pos = fmt.find("%s"); if (pos != std::string::npos) { titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2); } else { titleStr = fmt; } } std::string msg; if (!titleStr.empty()) { msg = isLost ? ("Title removed: " + titleStr + ".") : ("Title earned: " + titleStr + "!"); } else { char buf[64]; std::snprintf(buf, sizeof(buf), isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", titleBit); msg = buf; } // Track in known title set if (isLost) { knownTitleBits_.erase(titleBit); } else { knownTitleBits_.insert(titleBit); } // Only post chat message for actual earned/lost events (isLost and new earn) // Server sends isLost=0 for all known titles during login — suppress the chat spam // by only notifying when we already had some titles (after login sequence) addSystemChatMessage(msg); LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, " title='", titleStr, "' known=", knownTitleBits_.size()); 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(); // Update home bind location so hearthstone tooltip reflects the new zone homeBindMapId_ = mapId; homeBindZoneId_ = zoneId; std::string pbMsg = "Your home location has been set"; std::string zoneName = getAreaName(zoneId); if (!zoneName.empty()) pbMsg += " to " + zoneName; pbMsg += '.'; addSystemChatMessage(pbMsg); break; } case Opcode::SMSG_BINDER_CONFIRM: { // uint64 npcGuid — fires just before SMSG_PLAYERBOUND; PLAYERBOUND shows // the zone name so this confirm is redundant. Consume silently. 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); // Cap POI count to prevent unbounded growth from rapid gossip queries if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin()); 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 { // ResponseCodes for name changes (shared with char create) static const char* kRenameErrors[] = { nullptr, // 0 = success "Name already in use.", // 1 "Name too short.", // 2 "Name too long.", // 3 "Name contains invalid characters.", // 4 "Name contains a profanity.", // 5 "Name is reserved.", // 6 "Character name does not meet requirements.", // 7 }; const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg : "Character rename failed."; addUIError(renameErr); addSystemChatMessage(renameErr); } 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 { addUIError("You are too far from the innkeeper."); 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."; addUIError(std::string("Cannot change difficulty: ") + msg); addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); } } break; } case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE: addUIError("Your corpse is outside this 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) { uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t animId = packet.readUInt32(); if (emoteAnimCallback_) emoteAnimCallback_(animGuid, animId); } } 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: // Consume — handled by broader object update or not yet implemented packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_FORCED_DEATH_UPDATE: // Server forces player into dead state (GM command, scripted event, etc.) playerDead_ = true; if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet if (addonEventCallback_) addonEventCallback_("PLAYER_DEAD", {}); addSystemChatMessage("You have been killed."); LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); 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 (PvP deaths) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t delayMs = packet.readUInt32(); auto nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); corpseReclaimAvailableMs_ = nowMs + delayMs; LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); } break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { // uint32 mapId + float x + float y + float z // This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location. // The corpse remains at the death position (already cached when health dropped to 0, // and updated when the corpse object arrives via SMSG_UPDATE_OBJECT). // Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse() // by making it check distance to the graveyard instead of the real corpse. if (packet.getSize() - packet.getReadPos() >= 16) { uint32_t relMapId = packet.readUInt32(); float relX = packet.readFloat(); float relY = packet.readFloat(); float relZ = packet.readFloat(); LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, " x=", relX, " y=", relY, " z=", relZ); } break; } case Opcode::SMSG_ENABLE_BARBER_SHOP: // Sent by server when player sits in barber chair — triggers barber shop UI LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); barberShopOpen_ = true; break; case Opcode::SMSG_FEIGN_DEATH_RESISTED: addUIError("Your Feign Death was 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: { // uint32 achievementId — remove from local earned set if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t achId = packet.readUInt32(); earnedAchievements_.erase(achId); achievementDates_.erase(achId); LOG_DEBUG("SMSG_ACHIEVEMENT_DELETED: id=", achId); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CRITERIA_DELETED: { // uint32 criteriaId — remove from local criteria progress if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t critId = packet.readUInt32(); criteriaProgress_.erase(critId); LOG_DEBUG("SMSG_CRITERIA_DELETED: id=", critId); } 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) threatLists_.clear(); 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) break; uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); auto it = threatLists_.find(unitGuid); if (it != threatLists_.end()) { auto& list = it->second; list.erase(std::remove_if(list.begin(), list.end(), [victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }), list.end()); if (list.empty()) threatLists_.erase(it); } break; } case Opcode::SMSG_HIGHEST_THREAT_UPDATE: case Opcode::SMSG_THREAT_UPDATE: { // Both packets share the same format: // packed_guid (unit) + packed_guid (highest-threat target or target, unused here) // + uint32 count + count × (packed_guid victim + uint32 threat) if (packet.getSize() - packet.getReadPos() < 1) break; uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; (void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t cnt = packet.readUInt32(); if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity std::vector list; list.reserve(cnt); for (uint32_t i = 0; i < cnt; ++i) { if (packet.getSize() - packet.getReadPos() < 1) break; ThreatEntry entry; entry.victimGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; entry.threat = packet.readUInt32(); list.push_back(entry); } // Sort descending by threat so highest is first std::sort(list.begin(), list.end(), [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); threatLists_[unitGuid] = std::move(list); 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." }; std::string mountErr = result < 4 ? msgs[result] : "Cannot mount."; addUIError(mountErr); addSystemChatMessage(mountErr); } 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) { addUIError("Cannot dismount here."); 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); std::string allPassName = info && !info->name.empty() ? info->name : std::to_string(itemId); uint32_t allPassQuality = info ? info->quality : 1u; addSystemChatMessage("Everyone passed on " + buildItemLink(itemId, allPassQuality, allPassName) + "."); 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); uint32_t notifyQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemName = info->name; notifyQuality = info->quality; } std::string itemLink2 = buildItemLink(itemId, notifyQuality, itemName); std::string lootMsg = looterName + " loots " + itemLink2; if (count > 1) lootMsg += " x" + std::to_string(count); lootMsg += "."; addSystemChatMessage(lootMsg); } } break; } case Opcode::SMSG_LOOT_SLOT_CHANGED: { // uint8 slotIndex — another player took the item from this slot in group loot if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t slotIndex = packet.readUInt8(); for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) { if (it->slotIndex == slotIndex) { currentLoot.items.erase(it); break; } } } break; } // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { // All expansions: uint32 spellId first. // WotLK/Classic: spellId(4) + packed_guid caster + uint8 unk + uint32 count // + count × (packed_guid victim + uint8 missInfo) // TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count // + count × (uint64 victim + uint8 missInfo) // All expansions append uint32 reflectSpellId + uint8 reflectResult when // missInfo==11 (REFLECT). const bool spellMissUsesFullGuid = isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { if (spellMissUsesFullGuid) 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() < (spellMissUsesFullGuid ? 8u : 1u) || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t unk =*/ packet.readUInt8(); const uint32_t rawCount = packet.readUInt32(); if (rawCount > 128) { LOG_WARNING("SMSG_SPELLLOGMISS: miss count capped (requested=", rawCount, ")"); } const uint32_t storedLimit = std::min(rawCount, 128u); struct SpellMissLogEntry { uint64_t victimGuid = 0; uint8_t missInfo = 0; uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT) }; std::vector parsedMisses; parsedMisses.reserve(storedLimit); bool truncated = false; for (uint32_t i = 0; i < rawCount; ++i) { if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u) || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { truncated = true; break; } const uint64_t victimGuid = readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 1) { truncated = true; break; } const uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult uint32_t reflectSpellId = 0; if (missInfo == 11) { if (packet.getSize() - packet.getReadPos() >= 5) { reflectSpellId = packet.readUInt32(); /*uint8_t reflectResult =*/ packet.readUInt8(); } else { truncated = true; break; } } if (i < storedLimit) { parsedMisses.push_back({victimGuid, missInfo, reflectSpellId}); } } if (truncated) { packet.setReadPos(packet.getSize()); break; } for (const auto& miss : parsedMisses) { const uint64_t victimGuid = miss.victimGuid; const uint8_t missInfo = miss.missInfo; CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo); // For REFLECT, use the reflected spell ID so combat text shows the spell name uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0) ? miss.reflectSpellId : spellId; if (casterGuid == playerGuid) { // We cast a spell and it missed the target addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid); } } 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) { // Environmental damage: no caster GUID, victim = player if (damage > 0) addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false, 0, 0, victimGuid); if (absorb > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false, 0, 0, victimGuid); if (resist > 0) addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false, 0, 0, victimGuid); } 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_CAMERA_SHAKE: { // uint32 shakeID (CameraShakes.dbc), uint32 shakeType // We don't parse CameraShakes.dbc; apply a hardcoded moderate shake. if (packet.getSize() - packet.getReadPos() >= 8) { uint32_t shakeId = packet.readUInt32(); uint32_t shakeType = packet.readUInt32(); (void)shakeType; // Map shakeId ranges to approximate magnitudes: // IDs < 50: minor environmental (0.04), others: larger boss effects (0.08) float magnitude = (shakeId < 50) ? 0.04f : 0.08f; if (cameraShakeCallback_) { cameraShakeCallback_(magnitude, 18.0f, 0.5f); } LOG_DEBUG("SMSG_CAMERA_SHAKE: id=", shakeId, " type=", shakeType, " magnitude=", magnitude); } 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."); if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_LOST", {}); } else if (changed && allowMovement) { addSystemChatMessage("Movement re-enabled."); if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_GAINED", {}); } } 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; } 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); } } } break; case Opcode::SMSG_ATTACKSWING_NOTSTANDING: autoAttackOutOfRange_ = false; autoAttackOutOfRangeTime_ = 0.0f; if (autoAttackRangeWarnCooldown_ <= 0.0f) { addSystemChatMessage("You need to stand up to fight."); autoAttackRangeWarnCooldown_ = 1.25f; } break; case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: // Target is permanently non-attackable (critter, civilian, already dead, etc.). // Stop the auto-attack loop so the client doesn't spam the server. stopAutoAttack(); if (autoAttackRangeWarnCooldown_ <= 0.0f) { addSystemChatMessage("You can't attack that."); autoAttackRangeWarnCooldown_ = 1.25f; } 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: { // uint64 casterGuid + uint32 visualId if (packet.getSize() - packet.getReadPos() < 12) break; uint64_t casterGuid = packet.readUInt64(); uint32_t visualId = packet.readUInt32(); if (visualId == 0) break; // Resolve caster world position and spawn the effect auto* renderer = core::Application::getInstance().getRenderer(); if (!renderer) break; glm::vec3 spawnPos; if (casterGuid == playerGuid) { spawnPos = renderer->getCharacterPosition(); } else { auto entity = entityManager.getEntity(casterGuid); if (!entity) break; glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); spawnPos = core::coords::canonicalToRender(canonical); } renderer->playSpellVisual(visualId, spawnPos); 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 failSpellId = 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 and error overlay 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) { // Prefix with spell name for context, e.g. "Fireball: Not in range" const std::string& sName = getSpellName(failSpellId); std::string fullMsg = sName.empty() ? reason : sName + ": " + reason; addUIError(fullMsg); MessageChatData emsg; emsg.type = ChatType::SYSTEM; emsg.language = ChatLanguage::UNIVERSAL; emsg.message = std::move(fullMsg); addLocalChatMessage(emsg); } } } // Fire UNIT_SPELLCAST_INTERRUPTED for Lua addons if (addonEventCallback_) { std::string unitId; if (failGuid == playerGuid || failGuid == 0) unitId = "player"; else if (failGuid == targetGuid) unitId = "target"; else if (failGuid == focusGuid) unitId = "focus"; else if (failGuid == petGuid_) unitId = "pet"; if (!unitId.empty()) { addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId}); addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); } } if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed — clear gather-node loot target so the // next timed cast doesn't try to loot a stale interrupted gather node. casting = false; castIsChannel = false; currentCastSpellId = 0; lastInteractedGoGuid_ = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 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 (cdSec > 0.0f) { if (spellId != 0) { auto it = spellCooldowns.find(spellId); if (it == spellCooldowns.end()) { spellCooldowns[spellId] = cdSec; } else { it->second = mergeCooldownSeconds(it->second, cdSec); } } // Resolve itemId from the GUID so item-type slots are also updated uint32_t itemId = 0; auto iit = onlineItems_.find(itemGuid); if (iit != onlineItems_.end()) itemId = iit->second.entry; for (auto& slot : actionBar) { bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId) || (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId); if (match) { float prevRemaining = slot.cooldownRemaining; float merged = mergeCooldownSeconds(slot.cooldownRemaining, cdSec); slot.cooldownRemaining = merged; if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) { slot.cooldownTotal = cdSec; } else { slot.cooldownTotal = std::max(slot.cooldownTotal, merged); } } } LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec, " spellId=", spellId, " itemId=", itemId, " 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); // Play ping sound for other players' pings (not our own) if (senderGuid != playerGuid) { if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playMinimapPing(); } } 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!"); addUIError(msg); 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] // Classic: uint32 dispelSpellId + packed_guid caster + packed_guid victim // [+ count × uint32 failedSpellId] // TBC: uint64 caster + uint64 victim + uint32 spellId // [+ count × uint32 failedSpellId] const bool dispelUsesFullGuid = isActiveExpansion("tbc"); uint32_t dispelSpellId = 0; uint64_t dispelCasterGuid = 0; if (dispelUsesFullGuid) { if (packet.getSize() - packet.getReadPos() < 20) break; dispelCasterGuid = packet.readUInt64(); /*uint64_t victim =*/ packet.readUInt64(); dispelSpellId = packet.readUInt32(); } else { if (packet.getSize() - packet.getReadPos() < 4) break; dispelSpellId = packet.readUInt32(); if (!hasFullPackedGuid(packet)) { packet.setReadPos(packet.getSize()); break; } dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet); if (!hasFullPackedGuid(packet)) { packet.setReadPos(packet.getSize()); break; } /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); } // Only show failure to the player who attempted the dispel if (dispelCasterGuid == playerGuid) { 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"); if (slot < NUM_TOTEM_SLOTS) { activeTotemSlots_[slot].spellId = spellId; activeTotemSlots_[slot].durationMs = duration; activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now(); } 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(); char buf[80]; std::snprintf(buf, sizeof(buf), "You have lost %u%% of your gear's durability due to death.", pct); addUIError(buf); addSystemChatMessage(buf); } 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; addUIError("Your party has been disbanded."); addSystemChatMessage("Your party has been disbanded."); LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); if (addonEventCallback_) { addonEventCallback_("GROUP_ROSTER_UPDATE", {}); addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); } 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(); readyCheckResults_.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_); if (addonEventCallback_) addonEventCallback_("READY_CHECK", {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(); } // Track per-player result for live popup display if (!rname.empty()) { bool found = false; for (auto& r : readyCheckResults_) { if (r.name == rname) { r.ready = (isReady != 0); found = true; break; } } if (!found) readyCheckResults_.push_back({ rname, isReady != 0 }); char rbuf[128]; std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); addSystemChatMessage(rbuf); } if (addonEventCallback_) { char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)respGuid); addonEventCallback_("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"}); } 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; readyCheckResults_.clear(); if (addonEventCallback_) addonEventCallback_("READY_CHECK_FINISHED", {}); 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: addUIError("You are out of the duel area!"); 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: { // uint32 countdown in milliseconds (typically 3000 ms) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t ms = packet.readUInt32(); duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000; duelCountdownStartedAt_ = std::chrono::steady_clock::now(); LOG_INFO("SMSG_DUEL_COUNTDOWN: ", duelCountdownMs_, " ms"); } 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: { // uint8 count + count * uint64 guid — eligible recipients for master looter masterLootCandidates_.clear(); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t mlCount = packet.readUInt8(); masterLootCandidates_.reserve(mlCount); for (uint8_t i = 0; i < mlCount; ++i) { if (packet.getSize() - packet.getReadPos() < 8) break; masterLootCandidates_.push_back(packet.readUInt64()); } LOG_INFO("SMSG_LOOT_MASTER_LIST: ", (int)masterLootCandidates_.size(), " candidates"); 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; homeBindZoneId_ = data.zoneId; homeBindPos_ = canonical; if (bindPointCallback_) { bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); } if (wasSet) { std::string bindMsg = "Your home has been set"; std::string zoneName = getAreaName(data.zoneId); if (!zoneName.empty()) bindMsg += " to " + zoneName; bindMsg += '.'; addSystemChatMessage(bindMsg); } } 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; if (addonEventCallback_) addonEventCallback_("RESURRECT_REQUEST", {resurrectCasterName_}); } 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."); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playQuestActivate(); } 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) + ")"; addUIError(msg); addSystemChatMessage(msg); // Play error sound so the player notices the failure if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playError(); } 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(); { uint32_t newZoneId = packet.readUInt32(); if (newZoneId != worldStateZoneId_ && newZoneId != 0) { worldStateZoneId_ = newZoneId; if (addonEventCallback_) { addonEventCallback_("ZONE_CHANGED_NEW_AREA", {}); addonEventCallback_("ZONE_CHANGED", {}); } } else { worldStateZoneId_ = newZoneId; } } // WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format size_t remaining = packet.getSize() - packet.getReadPos(); bool isWotLKFormat = isActiveExpansion("wotlk"); 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); watchedFactionId_ = factionId; if (repChangeCallback_) repChangeCallback_(name, delta, standing); if (addonEventCallback_) addonEventCallback_("UPDATE_FACTION", {}); } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } break; } case Opcode::SMSG_SET_FACTION_ATWAR: { // uint32 repListId + uint8 set (1=set at-war, 0=clear at-war) if (packet.getSize() - packet.getReadPos() < 5) { packet.setReadPos(packet.getSize()); break; } uint32_t repListId = packet.readUInt32(); uint8_t setAtWar = packet.readUInt8(); if (repListId < initialFactions_.size()) { if (setAtWar) initialFactions_[repListId].flags |= FACTION_FLAG_AT_WAR; else initialFactions_[repListId].flags &= ~FACTION_FLAG_AT_WAR; LOG_DEBUG("SMSG_SET_FACTION_ATWAR: repListId=", repListId, " atWar=", (int)setAtWar); } break; } case Opcode::SMSG_SET_FACTION_VISIBLE: { // uint32 repListId + uint8 visible (1=show, 0=hide) if (packet.getSize() - packet.getReadPos() < 5) { packet.setReadPos(packet.getSize()); break; } uint32_t repListId = packet.readUInt32(); uint8_t visible = packet.readUInt8(); if (repListId < initialFactions_.size()) { if (visible) initialFactions_[repListId].flags |= FACTION_FLAG_VISIBLE; else initialFactions_[repListId].flags &= ~FACTION_FLAG_VISIBLE; LOG_DEBUG("SMSG_SET_FACTION_VISIBLE: repListId=", repListId, " visible=", (int)visible); } break; } case Opcode::SMSG_FEATURE_SYSTEM_STATUS: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: { // WotLK format: one or more (uint8 groupIndex, uint8 modOp, int32 value) tuples // Each tuple is 6 bytes; iterate until packet is consumed. const bool isFlat = (*logicalOp == Opcode::SMSG_SET_FLAT_SPELL_MODIFIER); auto& modMap = isFlat ? spellFlatMods_ : spellPctMods_; while (packet.getSize() - packet.getReadPos() >= 6) { uint8_t groupIndex = packet.readUInt8(); uint8_t modOpRaw = packet.readUInt8(); int32_t value = static_cast(packet.readUInt32()); if (groupIndex > 5 || modOpRaw >= SPELL_MOD_OP_COUNT) continue; SpellModKey key{ static_cast(modOpRaw), groupIndex }; modMap[key] = value; LOG_DEBUG(isFlat ? "SMSG_SET_FLAT_SPELL_MODIFIER" : "SMSG_SET_PCT_SPELL_MODIFIER", ": group=", (int)groupIndex, " op=", (int)modOpRaw, " value=", value); } 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; castTimeTotal += delaySec; // keep progress percentage correct } } 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 std::string setName; if (packet.getSize() - packet.getReadPos() >= 12) { uint32_t setIndex = packet.readUInt32(); uint64_t setGuid = packet.readUInt64(); // Update the local set's GUID so subsequent "Update" calls // use the server-assigned GUID instead of 0 (which would // create a duplicate instead of updating). bool found = false; for (auto& es : equipmentSets_) { if (es.setGuid == setGuid || es.setId == setIndex) { es.setGuid = setGuid; setName = es.name; found = true; break; } } // Also update public-facing info for (auto& info : equipmentSetInfo_) { if (info.setGuid == setGuid || info.setId == setIndex) { info.setGuid = setGuid; break; } } // If the set doesn't exist locally yet (new save), add a // placeholder entry so it shows up in the UI immediately. if (!found && setGuid != 0) { EquipmentSet newEs; newEs.setGuid = setGuid; newEs.setId = setIndex; newEs.name = pendingSaveSetName_; newEs.iconName = pendingSaveSetIcon_; for (int s = 0; s < 19; ++s) newEs.itemGuids[s] = getEquipSlotGuid(s); equipmentSets_.push_back(std::move(newEs)); EquipmentSetInfo newInfo; newInfo.setGuid = setGuid; newInfo.setId = setIndex; newInfo.name = pendingSaveSetName_; newInfo.iconName = pendingSaveSetIcon_; equipmentSetInfo_.push_back(std::move(newInfo)); setName = pendingSaveSetName_; } pendingSaveSetName_.clear(); pendingSaveSetIcon_.clear(); LOG_INFO("SMSG_EQUIPMENT_SET_SAVED: index=", setIndex, " guid=", setGuid, " name=", setName); } addSystemChatMessage(setName.empty() ? std::string("Equipment set saved.") : "Equipment set \"" + setName + "\" 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)+isCrit(1) = 21 bytes const bool periodicWotlk = isActiveExpansion("wotlk"); const size_t dotSz = periodicWotlk ? 21u : 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(); bool dotCrit = false; if (periodicWotlk) dotCrit = (packet.readUInt8() != 0); if (dmg > 0) addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (abs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(abs), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (res > 0) addCombatText(CombatTextEntry::RESIST, static_cast(res), spellId, isPlayerCaster, 0, casterGuid, victimGuid); } 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; bool hotCrit = false; if (healWotlk) { hotAbs = packet.readUInt32(); hotCrit = (packet.readUInt8() != 0); } addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL, static_cast(heal), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (hotAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), spellId, isPlayerCaster, 0, casterGuid, victimGuid); } 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; uint8_t periodicPowerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); if ((isPlayerVictim || isPlayerCaster) && amount > 0) addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), spellId, isPlayerCaster, periodicPowerType, casterGuid, victimGuid); } else if (auraType == 98) { // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier if (packet.getSize() - packet.getReadPos() < 12) break; uint8_t powerType = static_cast(packet.readUInt32()); uint32_t amount = packet.readUInt32(); float multiplier = packet.readFloat(); if (isPlayerVictim && amount > 0) addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(amount), spellId, false, powerType, casterGuid, victimGuid); if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) { const uint32_t gainedAmount = static_cast( std::lround(static_cast(amount) * static_cast(multiplier))); if (gainedAmount > 0) { addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), spellId, true, powerType, casterGuid, casterGuid); } } } 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"); auto readEnergizeGuid = [&]() -> uint64_t { if (energizeTbc) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u) || (!energizeTbc && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = readEnergizeGuid(); if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u) || (!energizeTbc && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = readEnergizeGuid(); if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); uint8_t energizePowerType = 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, energizePowerType, casterGuid, victimGuid); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG: { // uint64 victimGuid + uint8 envDmgType + uint32 damage + uint32 absorbed + uint32 resisted // envDmgType: 0=Exhausted(fatigue), 1=Drowning, 2=Fall, 3=Lava, 4=Slime, 5=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) { // Environmental damage: pass envType via powerType field for display differentiation if (dmg > 0) addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, envType, 0, victimGuid); if (envAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false, 0, 0, victimGuid); if (envRes > 0) addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false, 0, 0, victimGuid); } 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: { // Slot encoding differs by expansion: // Classic/Turtle: uint16 actionId + uint8 type + uint8 misc // type: 0=spell, 1=item, 64=macro // TBC/WotLK: uint32 packed = actionId | (type << 24) // type: 0x00=spell, 0x80=item, 0x40=macro // 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 = 0; uint32_t id = 0; if (isClassicLikeExpansion()) { id = packed & 0x0000FFFFu; type = static_cast((packed >> 16) & 0xFF); } else { type = static_cast((packed >> 24) & 0xFF); id = packed & 0x00FFFFFFu; } if (id == 0) continue; ActionBarSlot slot; switch (type) { case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions) default: continue; // unknown — leave as-is } actionBar[i] = slot; } // Apply any pending cooldowns from spellCooldowns to newly populated slots. // SMSG_SPELL_COOLDOWN often arrives before SMSG_ACTION_BUTTONS during login, // so the per-slot cooldownRemaining would be 0 without this sync. for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { auto cdIt = spellCooldowns.find(slot.id); if (cdIt != spellCooldowns.end() && cdIt->second > 0.0f) { slot.cooldownRemaining = cdIt->second; slot.cooldownTotal = cdIt->second; } } else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) { // Items (potions, trinkets): look up the item's on-use spell // and check if that spell has a pending cooldown. const auto* qi = getItemInfo(slot.id); if (qi && qi->valid) { for (const auto& sp : qi->spells) { if (sp.spellId == 0) continue; auto cdIt = spellCooldowns.find(sp.spellId); if (cdIt != spellCooldowns.end() && cdIt->second > 0.0f) { slot.cooldownRemaining = cdIt->second; slot.cooldownTotal = cdIt->second; break; } } } } } LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_LEVELUP_INFO: case Opcode::SMSG_LEVELUP_INFO_ALT: { // Server-authoritative level-up event. // WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t newLevel = packet.readUInt32(); if (newLevel > 0) { // Parse stat deltas (WotLK layout has 7 more uint32s) lastLevelUpDeltas_ = {}; if (packet.getSize() - packet.getReadPos() >= 28) { lastLevelUpDeltas_.hp = packet.readUInt32(); lastLevelUpDeltas_.mana = packet.readUInt32(); lastLevelUpDeltas_.str = packet.readUInt32(); lastLevelUpDeltas_.agi = packet.readUInt32(); lastLevelUpDeltas_.sta = packet.readUInt32(); lastLevelUpDeltas_.intel = packet.readUInt32(); lastLevelUpDeltas_.spi = packet.readUInt32(); } uint32_t oldLevel = serverPlayerLevel_; serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel); for (auto& ch : characters) { if (ch.guid == playerGuid) { ch.level = serverPlayerLevel_; break; } } if (newLevel > oldLevel) { addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playLevelUp(); } if (levelUpCallback_) levelUpCallback_(newLevel); if (addonEventCallback_) addonEventCallback_("PLAYER_LEVEL_UP", {std::to_string(newLevel)}); } } } 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 // Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t msgType = packet.readUInt32(); std::string msg = packet.readString(); if (!msg.empty()) { std::string prefix; switch (msgType) { case 1: prefix = "[Shutdown] "; addUIError("Server shutdown: " + msg); break; case 2: prefix = "[Restart] "; addUIError("Server restart: " + msg); break; case 4: prefix = "[Shutdown cancelled] "; break; case 5: prefix = "[Restart cancelled] "; break; default: prefix = "[Server] "; break; } addSystemChatMessage(prefix + 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()) { addUIError(msg); addSystemChatMessage(msg); areaTriggerMsgs_.push_back(msg); } } break; } case Opcode::SMSG_TRIGGER_CINEMATIC: { // uint32 cinematicId — we don't play cinematics; acknowledge immediately. packet.setReadPos(packet.getSize()); // Send CMSG_NEXT_CINEMATIC_CAMERA to signal cinematic completion; // servers may block further packets until this is received. network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA)); socket->send(ack); LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped, sent CMSG_NEXT_CINEMATIC_CAMERA"); 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; } } if (addonEventCallback_) addonEventCallback_("PLAYER_MONEY", {}); } 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); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playDropOnGround(); } if (addonEventCallback_) { addonEventCallback_("BAG_UPDATE", {}); addonEventCallback_("PLAYER_MONEY", {}); } } 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"; addUIError(std::string("Sell failed: ") + msg); addSystemChatMessage(std::string("Sell failed: ") + msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playError(); } 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); addUIError(levelBuf); addSystemChatMessage(levelBuf); } else { addUIError("You must reach a higher level to use that item."); 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) + ")."; addUIError(msg); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playError(); } } } 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; } addUIError(msg); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playError(); } } 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)); if (addonEventCallback_) addonEventCallback_("RAID_TARGET_UPDATE", {}); 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(); // Show purchase confirmation with item name if available if (pendingBuyItemId_ != 0) { std::string itemLabel; uint32_t buyQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) { if (!info->name.empty()) itemLabel = info->name; buyQuality = info->quality; } if (itemLabel.empty()) itemLabel = "item #" + std::to_string(pendingBuyItemId_); std::string msg = "Purchased: " + buildItemLink(pendingBuyItemId_, buyQuality, itemLabel); if (itemCount > 1) msg += " x" + std::to_string(itemCount); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playPickupBag(); } } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; if (addonEventCallback_) { addonEventCallback_("MERCHANT_UPDATE", {}); addonEventCallback_("BAG_UPDATE", {}); } } break; } case Opcode::SMSG_CRITERIA_UPDATE: { // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime if (packet.getSize() - packet.getReadPos() >= 20) { uint32_t criteriaId = packet.readUInt32(); uint64_t progress = packet.readUInt64(); packet.readUInt32(); // elapsedTime packet.readUInt32(); // creationTime uint64_t oldProgress = 0; auto cpit = criteriaProgress_.find(criteriaId); if (cpit != criteriaProgress_.end()) oldProgress = cpit->second; criteriaProgress_[criteriaId] = progress; LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); // Fire addon event for achievement tracking addons if (addonEventCallback_ && progress != oldProgress) addonEventCallback_("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(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."); barberShopOpen_ = false; } 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."; addUIError(msg); 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(); uint32_t prevWeatherType = weatherType_; 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); // Announce weather changes (including initial zone weather) if (wType != prevWeatherType) { const char* weatherMsg = nullptr; if (wIntensity < 0.05f || wType == 0) { if (prevWeatherType != 0) weatherMsg = "The weather clears."; } else if (wType == 1) { weatherMsg = "It begins to rain."; } else if (wType == 2) { weatherMsg = "It begins to snow."; } else if (wType == 3) { weatherMsg = "A storm rolls in."; } if (weatherMsg) addSystemChatMessage(weatherMsg); } // Storm transition: trigger a low-frequency thunder rumble shake if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units cameraShakeCallback_(mag, 6.0f, 0.6f); } } 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 enchTargetGuid = packet.readUInt64(); uint64_t enchCasterGuid = packet.readUInt64(); uint32_t enchSpellId = packet.readUInt32(); /*uint32_t displayId =*/ packet.readUInt32(); /*uint32_t animType =*/ packet.readUInt32(); LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", enchSpellId); // Show enchant message if the player is involved if (enchTargetGuid == playerGuid || enchCasterGuid == playerGuid) { const std::string& enchName = getSpellName(enchSpellId); std::string casterName = lookupName(enchCasterGuid); if (!enchName.empty()) { std::string msg; if (enchCasterGuid == playerGuid) msg = "You enchant with " + enchName + "."; else if (!casterName.empty()) msg = casterName + " enchants your item with " + enchName + "."; else msg = "Your item has been enchanted with " + enchName + "."; addSystemChatMessage(msg); } } } 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 { addUIError("Failed to socket gems."); 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."; addUIError(msg); 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); } // animId == 0 is the fishing bobber splash ("fish hooked"). // Detect by GO type 17 (FISHINGNODE) and notify the player so they // know to click the bobber before the fish escapes. if (animId == 0) { auto goEnt = entityManager.getEntity(guid); if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(goEnt); auto* info = getCachedGameObjectInfo(go->getEntry()); if (info && info->type == 17) { // GO_TYPE_FISHINGNODE addUIError("A fish is on your line!"); addSystemChatMessage("A fish is on your line!"); // Play a distinctive UI sound to alert the player if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) { sfx->playQuestUpdate(); // Distinct "ping" sound } } } } } } 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) { // Fire toast callback before erasing if (questCompleteCallback_) { questCompleteCallback_(questId, it->title); } // Play quest-complete sound if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playQuestComplete(); } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); break; } } } if (addonEventCallback_) addonEventCallback_("QUEST_LOG_UPDATE", {}); // 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); if (questProgressCallback_) { questProgressCallback_(quest.title, creatureName, count, reqCount); } if (addonEventCallback_) { addonEventCallback_("QUEST_WATCH_UPDATE", {std::to_string(questId)}); addonEventCallback_("QUEST_LOG_UPDATE", {}); } 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); uint32_t questItemQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; questItemQuality = info->quality; } 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: " + buildItemLink(itemId, questItemQuality, itemLabel) + " (" + std::to_string(count) + ")"); if (questProgressCallback_ && updatedAny) { // Find the quest that tracks this item to get title and required count for (const auto& quest : questLog_) { if (quest.complete) continue; if (quest.itemCounts.count(itemId) == 0) continue; uint32_t required = 0; auto rIt = quest.requiredItemCounts.find(itemId); if (rIt != quest.requiredItemCounts.end()) required = rIt->second; if (required == 0) { for (const auto& obj : quest.itemObjectives) { if (obj.itemId == itemId) { required = obj.required; break; } } } if (required == 0) required = count; questProgressCallback_(quest.title, itemLabel, count, required); break; } } if (addonEventCallback_ && updatedAny) { addonEventCallback_("QUEST_WATCH_UPDATE", {}); addonEventCallback_("QUEST_LOG_UPDATE", {}); } 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."); if (addonEventCallback_) addonEventCallback_("PLAYER_UPDATE_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); const QuestQueryRewards rwds = tryParseQuestRewards(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, "]"); } // Store reward data and pre-fetch item info for icons. if (rwds.valid) { q.rewardMoney = rwds.rewardMoney; for (int i = 0; i < 4; ++i) { q.rewardItems[i].itemId = rwds.itemId[i]; q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0; if (rwds.itemId[i] != 0) queryItemInfo(rwds.itemId[i], 0); } for (int i = 0; i < 6; ++i) { q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i]; q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0; if (rwds.choiceItemId[i] != 0) queryItemInfo(rwds.choiceItemId[i], 0); } } break; } pendingQuestQueryIds_.erase(questId); break; } case Opcode::SMSG_QUESTLOG_FULL: // Zero-payload notification: the player's quest log is full (25 quests). addUIError("Your quest log is full."); 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: { // SMSG_GROUP_SET_LEADER: string leaderName (null-terminated) if (packet.getSize() > packet.getReadPos()) { std::string leaderName = packet.readString(); // Update leaderGuid by name lookup in party members for (const auto& m : partyData.members) { if (m.name == leaderName) { partyData.leaderGuid = m.guid; break; } } if (!leaderName.empty()) addSystemChatMessage(leaderName + " is now the group leader."); LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName); if (addonEventCallback_) { addonEventCallback_("PARTY_LEADER_CHANGED", {}); addonEventCallback_("GROUP_ROSTER_UPDATE", {}); } } break; } // ---- Teleport / Transfer ---- case Opcode::MSG_MOVE_TELEPORT: 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; } addUIError(abortMsg); 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: handleBattlefieldList(packet); break; case Opcode::SMSG_BATTLEFIELD_PORT_DENIED: addUIError("Battlefield port denied."); addSystemChatMessage("Battlefield port denied."); break; case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: { bgPlayerPositions_.clear(); for (int grp = 0; grp < 2; ++grp) { if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t count = packet.readUInt32(); for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 16; ++i) { BgPlayerPosition pos; pos.guid = packet.readUInt64(); pos.wowX = packet.readFloat(); pos.wowY = packet.readFloat(); pos.group = grp; bgPlayerPositions_.push_back(pos); } } 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: { // SMSG_BATTLEGROUND_PLAYER_JOINED: uint64 guid if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t guid = packet.readUInt64(); auto it = playerNameCache.find(guid); std::string name = (it != playerNameCache.end()) ? it->second : ""; if (!name.empty()) addSystemChatMessage(name + " has entered the battleground."); LOG_INFO("SMSG_BATTLEGROUND_PLAYER_JOINED: guid=0x", std::hex, guid, std::dec); } break; } case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: { // SMSG_BATTLEGROUND_PLAYER_LEFT: uint64 guid if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t guid = packet.readUInt64(); auto it = playerNameCache.find(guid); std::string name = (it != playerNameCache.end()) ? it->second : ""; if (!name.empty()) addSystemChatMessage(name + " has left the battleground."); LOG_INFO("SMSG_BATTLEGROUND_PLAYER_LEFT: guid=0x", std::hex, guid, std::dec); } 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(); std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); // 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; addSystemChatMessage(mapLabel + " will reset in " + std::to_string(minutes) + " minute(s)."); } else if (msgType == 2) { addSystemChatMessage("You have been saved to " + mapLabel + "."); } else if (msgType == 3) { addSystemChatMessage("Welcome to " + mapLabel + "."); } 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()); std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); addSystemChatMessage(mapLabel + " 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* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); addUIError("Cannot reset " + mapLabel + ": " + reasonMsg); addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); 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 ilMapId = packet.readUInt32(); uint32_t ilDiff = packet.readUInt32(); uint32_t ilTimeLeft = packet.readUInt32(); packet.readUInt32(); // unk uint8_t ilLocked = packet.readUInt8(); // Notify player which instance is being entered/resumed std::string ilName = getMapName(ilMapId); if (ilName.empty()) ilName = "instance #" + std::to_string(ilMapId); static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; std::string ilMsg = "Entering " + ilName; if (ilDiff < 4) ilMsg += std::string(" (") + kDiff[ilDiff] + ")"; if (ilLocked && ilTimeLeft > 0) { uint32_t ilMins = ilTimeLeft / 60; ilMsg += " — " + std::to_string(ilMins) + " min remaining."; } else { ilMsg += "."; } addSystemChatMessage(ilMsg); // 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 mapId=", ilMapId, " diff=", ilDiff, " timeLeft=", ilTimeLeft); } 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: { // uint64 guid + uint8 ready + uint32 roles if (packet.getSize() - packet.getReadPos() >= 13) { uint64_t roleGuid = packet.readUInt64(); uint8_t ready = packet.readUInt8(); uint32_t roles = packet.readUInt32(); // Build a descriptive message for group chat std::string roleName; if (roles & 0x02) roleName += "Tank "; if (roles & 0x04) roleName += "Healer "; if (roles & 0x08) roleName += "DPS "; if (roleName.empty()) roleName = "None"; // Find player name std::string pName = "A player"; if (auto e = entityManager.getEntity(roleGuid)) if (auto u = std::dynamic_pointer_cast(e)) pName = u->getName(); if (ready) addSystemChatMessage(pName + " has chosen: " + roleName); LOG_DEBUG("SMSG_LFG_ROLE_CHOSEN: guid=", roleGuid, " ready=", (int)ready, " roles=", roles); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_LFG_UPDATE_SEARCH: case Opcode::SMSG_UPDATE_LFG_LIST: case Opcode::SMSG_LFG_PLAYER_INFO: case Opcode::SMSG_LFG_PARTY_INFO: // Informational LFG packets not yet surfaced in UI — consume silently. packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: // Server requests client to open the dungeon finder UI packet.setReadPos(packet.getSize()); // consume any payload if (openLfgCallback_) openLfgCallback_(); 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: handleArenaTeamRoster(packet); 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: handleArenaTeamStats(packet); break; case Opcode::SMSG_ARENA_ERROR: handleArenaError(packet); break; case Opcode::MSG_PVP_LOG_DATA: handlePvpLogData(packet); break; case Opcode::MSG_INSPECT_ARENA_TEAMS: { // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } uint64_t inspGuid = packet.readUInt64(); uint8_t teamCount = packet.readUInt8(); if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5 if (inspGuid == inspectResult_.guid || inspectResult_.guid == 0) { inspectResult_.guid = inspGuid; inspectResult_.arenaTeams.clear(); for (uint8_t t = 0; t < teamCount; ++t) { if (packet.getSize() - packet.getReadPos() < 21) break; InspectArenaTeam team; team.teamId = packet.readUInt32(); team.type = packet.readUInt8(); team.weekGames = packet.readUInt32(); team.weekWins = packet.readUInt32(); team.seasonGames = packet.readUInt32(); team.seasonWins = packet.readUInt32(); team.name = packet.readString(); if (packet.getSize() - packet.getReadPos() < 4) break; team.personalRating = packet.readUInt32(); inspectResult_.arenaTeams.push_back(std::move(team)); } } LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec, " teams=", (int)teamCount); 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; // ---- Broadcast speed changes (server→client, no ACK) ---- // Format: PackedGuid (mover) + MovementInfo (variable) + float speed // MovementInfo is complex (optional transport/fall/spline blocks based on flags). // We consume the packet to suppress "Unhandled world opcode" warnings. case Opcode::MSG_MOVE_SET_RUN_SPEED: case Opcode::MSG_MOVE_SET_RUN_BACK_SPEED: case Opcode::MSG_MOVE_SET_WALK_SPEED: case Opcode::MSG_MOVE_SET_SWIM_SPEED: case Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED: case Opcode::MSG_MOVE_SET_FLIGHT_SPEED: case Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED: if (state == WorldState::IN_WORLD) { handleMoveSetSpeed(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: { // string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags) std::string chanName = packet.readString(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t chanFlags =*/ packet.readUInt8(); uint32_t memberCount = packet.readUInt32(); memberCount = std::min(memberCount, 200u); addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):"); for (uint32_t i = 0; i < memberCount; ++i) { if (packet.getSize() - packet.getReadPos() < 9) break; uint64_t memberGuid = packet.readUInt64(); uint8_t memberFlags = packet.readUInt8(); // Look up the name: entity manager > playerNameCache auto entity = entityManager.getEntity(memberGuid); std::string name; if (entity) { auto player = std::dynamic_pointer_cast(entity); if (player && !player->getName().empty()) name = player->getName(); } if (name.empty()) { auto nit = playerNameCache.find(memberGuid); if (nit != playerNameCache.end()) name = nit->second; } if (name.empty()) name = "(unknown)"; std::string entry = " " + name; if (memberFlags & 0x01) entry += " [Moderator]"; if (memberFlags & 0x02) entry += " [Muted]"; addSystemChatMessage(entry); LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec, " flags=", (int)memberFlags, " name=", name); } 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) + randomPropertyId(u32) + ... // action: 0=sold/won, 1=expired, 2=bid placed on your auction 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(); int32_t ownerRandProp = 0; if (packet.getSize() - packet.getReadPos() >= 4) ownerRandProp = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string rawName = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); if (ownerRandProp != 0) { std::string suffix = getRandomPropertyName(ownerRandProp); if (!suffix.empty()) rawName += " " + suffix; } uint32_t aucQuality = info ? info->quality : 1u; std::string itemLink = buildItemLink(itemEntry, aucQuality, rawName); if (action == 1) addSystemChatMessage("Your auction of " + itemLink + " has expired."); else if (action == 2) addSystemChatMessage("A bid has been placed on your auction of " + itemLink + "."); else addSystemChatMessage("Your auction of " + itemLink + " has sold!"); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: { // auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32) if (packet.getSize() - packet.getReadPos() >= 8) { /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); int32_t bidRandProp = 0; // Try to read randomPropertyId if enough data remains if (packet.getSize() - packet.getReadPos() >= 4) bidRandProp = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string rawName2 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); if (bidRandProp != 0) { std::string suffix = getRandomPropertyName(bidRandProp); if (!suffix.empty()) rawName2 += " " + suffix; } uint32_t bidQuality = info ? info->quality : 1u; std::string bidLink = buildItemLink(itemEntry, bidQuality, rawName2); addSystemChatMessage("You have been outbid on " + bidLink + "."); } 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(); int32_t itemRandom = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); std::string rawName3 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); if (itemRandom != 0) { std::string suffix = getRandomPropertyName(itemRandom); if (!suffix.empty()) rawName3 += " " + suffix; } uint32_t remQuality = info ? info->quality : 1u; std::string remLink = buildItemLink(itemEntry, remQuality, rawName3); addSystemChatMessage("Your auction of " + remLink + " 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: { // PackedGuid (player guid) + uint32 vehicleId // vehicleId == 0 means the player left the vehicle if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); // player guid (unused) } if (packet.getSize() - packet.getReadPos() >= 4) { vehicleId_ = packet.readUInt32(); } else { vehicleId_ = 0; } 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; else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid]; 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; // TBC uses same flag convention as Classic: 0x02=harmful, 0x04=beneficial. // Normalize to WotLK SMSG_AURA_UPDATE convention: 0x80=debuff, 0=buff. a.flags = (flags & 0x02) ? 0x80u : 0u; 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: // Clear cached talent data so the talent screen reflects the reset. learnedTalents_[0].clear(); learnedTalents_[1].clear(); addUIError("Your talents have been reset by the server."); 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."); if (addonEventCallback_) addonEventCallback_("PLAYER_UPDATE_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: { // uint32 movieId — we don't play movies; acknowledge immediately. packet.setReadPos(packet.getSize()); // WotLK servers expect CMSG_COMPLETE_MOVIE after the movie finishes; // without it, the server may hang or disconnect the client. uint16_t wire = wireOpcode(Opcode::CMSG_COMPLETE_MOVIE); if (wire != 0xFFFF) { network::Packet ack(wire); socket->send(ack); LOG_DEBUG("SMSG_TRIGGER_MOVIE: skipped, sent CMSG_COMPLETE_MOVIE"); } 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) { addUIError("Failed to equip item set."); 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; // ---- LFG error/timeout states ---- case Opcode::SMSG_LFG_TIMEDOUT: // Server-side LFG invite timed out (no response within time limit) addSystemChatMessage("Dungeon Finder: Invite timed out."); if (openLfgCallback_) openLfgCallback_(); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_LFG_OTHER_TIMEDOUT: // Another party member failed to respond to a LFG role-check in time addSystemChatMessage("Dungeon Finder: Another player's invite timed out."); if (openLfgCallback_) openLfgCallback_(); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_LFG_AUTOJOIN_FAILED: { // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t result = packet.readUInt32(); (void)result; } addUIError("Dungeon Finder: Auto-join failed."); addSystemChatMessage("Dungeon Finder: Auto-join failed."); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: // No eligible players found for auto-join addUIError("Dungeon Finder: No players available for auto-join."); addSystemChatMessage("Dungeon Finder: No players available for auto-join."); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_LFG_LEADER_IS_LFM: // Party leader is currently set to Looking for More (LFM) mode addSystemChatMessage("Your party leader is currently Looking for More."); packet.setReadPos(packet.getSize()); break; // ---- Meeting stone (Classic/TBC group-finding via summon stone) ---- case Opcode::SMSG_MEETINGSTONE_SETQUEUE: { // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone if (packet.getSize() - packet.getReadPos() >= 6) { uint32_t zoneId = packet.readUInt32(); uint8_t levelMin = packet.readUInt8(); uint8_t levelMax = packet.readUInt8(); char buf[128]; std::string zoneName = getAreaName(zoneId); if (!zoneName.empty()) std::snprintf(buf, sizeof(buf), "You are now in the Meeting Stone queue for %s (levels %u-%u).", zoneName.c_str(), levelMin, levelMax); else std::snprintf(buf, sizeof(buf), "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", zoneId, levelMin, levelMax); addSystemChatMessage(buf); LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, " levels=", (int)levelMin, "-", (int)levelMax); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_MEETINGSTONE_COMPLETE: // Server confirms group found and teleport summon is ready addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon."); LOG_INFO("SMSG_MEETINGSTONE_COMPLETE"); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_MEETINGSTONE_IN_PROGRESS: // Meeting stone search is still ongoing addSystemChatMessage("Meeting Stone: Searching for group members..."); LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS"); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED: { // uint64 memberGuid — a player was added to your group via meeting stone if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t memberGuid = packet.readUInt64(); auto nit = playerNameCache.find(memberGuid); if (nit != playerNameCache.end() && !nit->second.empty()) { addSystemChatMessage("Meeting Stone: " + nit->second + " has been added to your group."); } else { addSystemChatMessage("Meeting Stone: A new player has been added to your group."); } LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec); } break; } case Opcode::SMSG_MEETINGSTONE_JOINFAILED: { // uint8 reason — failed to join group via meeting stone // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available static const char* kMeetingstoneErrors[] = { "Target player is not using the Meeting Stone.", "Target player is already in a group.", "You are not in a valid zone for that Meeting Stone.", "Target player is not available.", }; if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t reason = packet.readUInt8(); const char* msg = (reason < 4) ? kMeetingstoneErrors[reason] : "Meeting Stone: Could not join group."; addSystemChatMessage(msg); LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", (int)reason); } break; } case Opcode::SMSG_MEETINGSTONE_LEAVE: // Player was removed from the meeting stone queue (left, or group disbanded) addSystemChatMessage("You have left the Meeting Stone queue."); LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE"); 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: { // WotLK 3.3.5a format: // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended // If status == 6 (GMTICKET_STATUS_HASTEXT): // cstring ticketText // uint32 ticketAge (seconds old) // uint32 daysUntilOld (days remaining before escalation) // float waitTimeHours (estimated GM wait time) if (packet.getSize() - packet.getReadPos() < 1) { packet.setReadPos(packet.getSize()); break; } uint8_t gmStatus = packet.readUInt8(); // Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text if (gmStatus == 6 && packet.getSize() - packet.getReadPos() >= 1) { gmTicketText_ = packet.readString(); uint32_t ageSec = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; /*uint32_t daysLeft =*/ (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; gmTicketWaitHours_ = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readFloat() : 0.0f; gmTicketActive_ = true; char buf[256]; if (ageSec < 60) { std::snprintf(buf, sizeof(buf), "You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.", ageSec, gmTicketWaitHours_); } else { uint32_t ageMin = ageSec / 60; std::snprintf(buf, sizeof(buf), "You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.", ageMin, gmTicketWaitHours_); } addSystemChatMessage(buf); LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec, "s wait=", gmTicketWaitHours_, "h"); } else if (gmStatus == 3) { gmTicketActive_ = false; gmTicketText_.clear(); addSystemChatMessage("Your GM ticket has been closed."); LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed"); } else if (gmStatus == 10) { gmTicketActive_ = false; gmTicketText_.clear(); addSystemChatMessage("Your GM ticket has been suspended."); LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended"); } else { // Status 1 = no open ticket (default/no ticket) gmTicketActive_ = false; gmTicketText_.clear(); LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", (int)gmStatus, ")"); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: { // uint32 status: 1 = GM support available, 0 = offline/unavailable if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t sysStatus = packet.readUInt32(); gmSupportAvailable_ = (sysStatus != 0); addSystemChatMessage(gmSupportAvailable_ ? "GM support is currently available." : "GM support is currently unavailable."); LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_); } 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_SPELLDAMAGESHIELD: { // Classic: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + schoolMask(4) // 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 shieldTbc = isActiveExpansion("tbc"); const bool shieldWotlkLike = !isClassicLikeExpansion() && !shieldTbc; const auto shieldRem = [&]() { return packet.getSize() - packet.getReadPos(); }; const size_t shieldMinSz = shieldTbc ? 24u : 2u; if (packet.getSize() - packet.getReadPos() < shieldMinSz) { packet.setReadPos(packet.getSize()); break; } if (!shieldTbc && (!hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (shieldTbc ? 8u : 1u) || (!shieldTbc && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = shieldTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); const size_t shieldTailSize = shieldWotlkLike ? 16u : 12u; if (shieldRem() < shieldTailSize) { packet.setReadPos(packet.getSize()); break; } uint32_t shieldSpellId = packet.readUInt32(); uint32_t damage = packet.readUInt32(); if (shieldWotlkLike) /*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), shieldSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // A damage shield hit us (e.g. target's Thorns) addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, false, 0, casterGuid, victimGuid); } break; } case Opcode::SMSG_AURACASTLOG: case Opcode::SMSG_SPELLBREAKLOG: // These packets are not damage-shield events. Consume them without // synthesizing reflected damage entries or misattributing GUIDs. packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType // TBC: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 const bool immuneUsesFullGuid = isActiveExpansion("tbc"); const size_t minSz = immuneUsesFullGuid ? 21u : 2u; if (packet.getSize() - packet.getReadPos() < minSz) { packet.setReadPos(packet.getSize()); break; } if (!immuneUsesFullGuid && !hasFullPackedGuid(packet)) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (immuneUsesFullGuid ? 8u : 2u) || (!immuneUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t immuneSpellId = 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, immuneSpellId, casterGuid == playerGuid, 0, casterGuid, victimGuid); } break; } case Opcode::SMSG_SPELLDISPELLOG: { // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen // TBC: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) const bool dispelUsesFullGuid = isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t casterGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = dispelUsesFullGuid ? 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(); // Preserve every dispelled aura in the combat log instead of collapsing // multi-aura packets down to the first entry only. const size_t dispelEntrySize = dispelUsesFullGuid ? 8u : 5u; std::vector dispelledIds; dispelledIds.reserve(count); for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) { uint32_t dispelledId = packet.readUInt32(); if (dispelUsesFullGuid) { /*uint32_t unk =*/ packet.readUInt32(); } else { /*uint8_t isPositive =*/ packet.readUInt8(); } if (dispelledId != 0) { dispelledIds.push_back(dispelledId); } } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { std::vector loggedIds; if (isStolen) { loggedIds.reserve(dispelledIds.size()); for (uint32_t dispelledId : dispelledIds) { if (shouldLogSpellstealAura(casterGuid, victimGuid, dispelledId)) loggedIds.push_back(dispelledId); } } else { loggedIds = dispelledIds; } const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); if (!displaySpellNames.empty()) { char buf[256]; const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were"; if (isStolen) { if (victimGuid == playerGuid && casterGuid != playerGuid) std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), passiveVerb); else if (casterGuid == playerGuid) std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), passiveVerb); } else { if (victimGuid == playerGuid && casterGuid != playerGuid) std::snprintf(buf, sizeof(buf), "%s %s dispelled.", displaySpellNames.c_str(), passiveVerb); else if (casterGuid == playerGuid) std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s dispelled.", displaySpellNames.c_str(), passiveVerb); } addSystemChatMessage(buf); } // Preserve stolen auras as spellsteal events so the log wording stays accurate. if (!loggedIds.empty()) { bool isPlayerCaster = (casterGuid == playerGuid); for (uint32_t dispelledId : loggedIds) { addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, 0, dispelledId, isPlayerCaster, 0, casterGuid, victimGuid); } } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLSTEALLOG: { // Sent to the CASTER (Mage) when Spellsteal succeeds. // Wire format mirrors SPELLDISPELLOG: // WotLK/Classic/Turtle: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count // + count × (uint32 stolenSpellId + uint8 isPositive) // TBC: full uint64 victim + full uint64 caster + same tail const bool stealUsesFullGuid = isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t stealVictim = stealUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t stealCaster = stealUsesFullGuid ? 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(); // Preserve every stolen aura in the combat log instead of only the first. const size_t stealEntrySize = stealUsesFullGuid ? 8u : 5u; std::vector stolenIds; stolenIds.reserve(stealCount); for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) { uint32_t stolenId = packet.readUInt32(); if (stealUsesFullGuid) { /*uint32_t unk =*/ packet.readUInt32(); } else { /*uint8_t isPos =*/ packet.readUInt8(); } if (stolenId != 0) { stolenIds.push_back(stolenId); } } if (stealCaster == playerGuid || stealVictim == playerGuid) { std::vector loggedIds; loggedIds.reserve(stolenIds.size()); for (uint32_t stolenId : stolenIds) { if (shouldLogSpellstealAura(stealCaster, stealVictim, stolenId)) loggedIds.push_back(stolenId); } const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); if (!displaySpellNames.empty()) { char buf[256]; if (stealCaster == playerGuid) std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellNames.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), loggedIds.size() == 1 ? "was" : "were"); addSystemChatMessage(buf); } // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG // for the same aura. Keep the first event and suppress the duplicate. if (!loggedIds.empty()) { bool isPlayerCaster = (stealCaster == playerGuid); for (uint32_t stolenId : loggedIds) { addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, stealCaster, stealVictim); } } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { // WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ... // TBC: uint64 target + uint64 caster + uint32 spellId + ... const bool procChanceUsesFullGuid = isActiveExpansion("tbc"); auto readProcChanceGuid = [&]() -> uint64_t { if (procChanceUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t procTargetGuid = readProcChanceGuid(); if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t procCasterGuid = readProcChanceGuid(); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t procSpellId = packet.readUInt32(); // Show a "PROC!" floating text when the player triggers the proc if (procCasterGuid == playerGuid && procSpellId > 0) addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0, procCasterGuid, procTargetGuid); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLINSTAKILLLOG: { // Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.) // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId // TBC: full uint64 caster + full uint64 victim + uint32 spellId const bool ikUsesFullGuid = isActiveExpansion("tbc"); auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t ikCaster = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t ikVictim = ikUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (ik_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t ikSpell = packet.readUInt32(); // Show kill/death feedback for the local player if (ikCaster == playerGuid) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); } else if (ikVictim == playerGuid) { addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); addUIError("You were killed by an instant-kill effect."); addSystemChatMessage("You were killed by an instant-kill effect."); } LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, " victim=0x", ikVictim, std::dec, " spell=", ikSpell); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLLOGEXECUTE: { // WotLK/Classic/Turtle: packed_guid caster + uint32 spellId + uint32 effectCount // TBC: uint64 caster + uint32 spellId + uint32 effectCount // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data // Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier // Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier // Effect 24 = CREATE_ITEM: uint32 itemEntry // Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id // Effect 49 = FEED_PET: uint32 itemEntry // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) const bool exeUsesFullGuid = isActiveExpansion("tbc"); if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } if (!exeUsesFullGuid && !hasFullPackedGuid(packet)) { packet.setReadPos(packet.getSize()); break; } uint64_t exeCaster = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } uint32_t exeSpellId = packet.readUInt32(); uint32_t exeEffectCount = packet.readUInt32(); exeEffectCount = std::min(exeEffectCount, 32u); // sanity const bool isPlayerCaster = (exeCaster == playerGuid); for (uint32_t ei = 0; ei < exeEffectCount; ++ei) { if (packet.getSize() - packet.getReadPos() < 5) break; uint8_t effectType = packet.readUInt8(); uint32_t effectLogCount = packet.readUInt32(); effectLogCount = std::min(effectLogCount, 64u); // sanity if (effectType == 10) { // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t drainTarget = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint32_t drainAmount = packet.readUInt32(); uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic float drainMult = packet.readFloat(); if (drainAmount > 0) { if (drainTarget == playerGuid) addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, static_cast(drainPower), exeCaster, drainTarget); if (isPlayerCaster) { if (drainTarget != playerGuid) { addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, true, static_cast(drainPower), exeCaster, drainTarget); } if (drainMult > 0.0f && std::isfinite(drainMult)) { const uint32_t gainedAmount = static_cast( std::lround(static_cast(drainAmount) * static_cast(drainMult))); if (gainedAmount > 0) { addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), exeSpellId, true, static_cast(drainPower), exeCaster, exeCaster); } } } } LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, " power=", drainPower, " amount=", drainAmount, " multiplier=", drainMult); } } else if (effectType == 11) { // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t leechTarget = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } uint32_t leechAmount = packet.readUInt32(); float leechMult = packet.readFloat(); if (leechAmount > 0) { if (leechTarget == playerGuid) { addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, exeCaster, leechTarget); } else if (isPlayerCaster) { addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, true, 0, exeCaster, leechTarget); } if (isPlayerCaster && leechMult > 0.0f && std::isfinite(leechMult)) { const uint32_t gainedAmount = static_cast( std::lround(static_cast(leechAmount) * static_cast(leechMult))); if (gainedAmount > 0) { addCombatText(CombatTextEntry::HEAL, static_cast(gainedAmount), exeSpellId, true, 0, exeCaster, exeCaster); } } } LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount, " multiplier=", leechMult); } } else if (effectType == 24 || effectType == 114) { // SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t itemEntry = packet.readUInt32(); if (isPlayerCaster && itemEntry != 0) { ensureItemInfo(itemEntry); const ItemQueryResponseData* info = getItemInfo(itemEntry); std::string itemName = info && !info->name.empty() ? info->name : ("item #" + std::to_string(itemEntry)); loadSpellNameCache(); auto spellIt = spellNameCache_.find(exeSpellId); std::string spellName = (spellIt != spellNameCache_.end() && !spellIt->second.name.empty()) ? spellIt->second.name : ""; std::string msg = spellName.empty() ? ("You create: " + itemName + ".") : ("You create " + itemName + " using " + spellName + "."); addSystemChatMessage(msg); LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId, " item=", itemEntry, " name=", itemName); // Repeat-craft queue: re-cast if more crafts remaining if (craftQueueRemaining_ > 0 && craftQueueSpellId_ == exeSpellId) { --craftQueueRemaining_; if (craftQueueRemaining_ > 0) { castSpell(craftQueueSpellId_, 0); } else { craftQueueSpellId_ = 0; } } } } } else if (effectType == 26) { // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t icTarget = exeUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t icSpellId = packet.readUInt32(); // Clear the interrupted unit's cast bar immediately unitCastStates_.erase(icTarget); // Record interrupt in combat log when player is involved if (isPlayerCaster || icTarget == playerGuid) addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0, exeCaster, icTarget); LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, " interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec); } } else if (effectType == 49) { // SPELL_EFFECT_FEED_PET: uint32 itemEntry per log entry for (uint32_t li = 0; li < effectLogCount; ++li) { if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t feedItem = packet.readUInt32(); if (isPlayerCaster && feedItem != 0) { ensureItemInfo(feedItem); const ItemQueryResponseData* info = getItemInfo(feedItem); std::string itemName = info && !info->name.empty() ? info->name : ("item #" + std::to_string(feedItem)); uint32_t feedQuality = info ? info->quality : 1u; addSystemChatMessage("You feed your pet " + buildItemLink(feedItem, feedQuality, itemName) + "."); LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName); } } } else { // Unknown effect type — stop parsing to avoid misalignment packet.setReadPos(packet.getSize()); break; } } packet.setReadPos(packet.getSize()); break; } 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_ITEM_ENCHANT_TIME_UPDATE: { // Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid // slot: 0=main-hand, 1=off-hand, 2=ranged if (packet.getSize() - packet.getReadPos() < 24) { packet.setReadPos(packet.getSize()); break; } /*uint64_t itemGuid =*/ packet.readUInt64(); uint32_t enchSlot = packet.readUInt32(); uint32_t durationSec = packet.readUInt32(); /*uint64_t playerGuid =*/ packet.readUInt64(); // Clamp to known slots (0-2) if (enchSlot > 2) { break; } uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); if (durationSec == 0) { // Enchant expired / removed — erase the slot entry tempEnchantTimers_.erase( std::remove_if(tempEnchantTimers_.begin(), tempEnchantTimers_.end(), [enchSlot](const TempEnchantTimer& t) { return t.slot == enchSlot; }), tempEnchantTimers_.end()); } else { uint64_t expireMs = nowMs + static_cast(durationSec) * 1000u; bool found = false; for (auto& t : tempEnchantTimers_) { if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; } } if (!found) tempEnchantTimers_.push_back({enchSlot, expireMs}); // Warn at important thresholds if (durationSec <= 60 && durationSec > 55) { const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; char buf[80]; std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName); addSystemChatMessage(buf); } else if (durationSec <= 300 && durationSec > 295) { const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; char buf[80]; std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName); addSystemChatMessage(buf); } } LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); break; } case Opcode::SMSG_COMPLAIN_RESULT: { // uint8 result: 0=success, 1=failed, 2=disabled if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); if (result == 0) addSystemChatMessage("Your complaint has been submitted."); else if (result == 2) addUIError("Report a Player is currently disabled."); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_LOOT_LIST: // Consume silently — informational, no UI action needed 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; castIsChannel = false; 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; castIsChannel = 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; s.interruptible = isSpellInterruptible(chanSpellId); } LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, " spell=", chanSpellId, " total=", chanTotalMs, "ms"); // Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons if (addonEventCallback_) { std::string unitId; if (chanCaster == playerGuid) unitId = "player"; else if (chanCaster == targetGuid) unitId = "target"; else if (chanCaster == focusGuid) unitId = "focus"; else if (chanCaster == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)}); } } 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; castIsChannel = 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"); // Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends if (chanRemainMs == 0 && addonEventCallback_) { std::string unitId; if (chanCaster2 == playerGuid) unitId = "player"; else if (chanCaster2 == targetGuid) unitId = "target"; else if (chanCaster2 == focusGuid) unitId = "focus"; else if (chanCaster2 == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_CHANNEL_STOP", {unitId}); } 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(); std::string questTitle; for (const auto& q : questLog_) if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } const char* reasonStr = nullptr; switch (reason) { case 1: reasonStr = "failed conditions"; break; case 2: reasonStr = "inventory full"; break; case 3: reasonStr = "too far away"; break; case 4: reasonStr = "another quest is blocking"; break; case 5: reasonStr = "wrong time of day"; break; case 6: reasonStr = "wrong race"; break; case 7: reasonStr = "wrong class"; break; } std::string msg = questTitle.empty() ? "Quest" : ('"' + questTitle + '"'); msg += " failed"; if (reasonStr) msg += std::string(": ") + reasonStr; msg += '.'; addSystemChatMessage(msg); } 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: { // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); if (targetGuid == playerGuid || targetGuid == 0) { selfResAvailable_ = true; LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x", std::hex, targetGuid, std::dec, ")"); } break; } // ---- Hearthstone bind error ---- case Opcode::SMSG_PLAYERBINDERROR: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t error = packet.readUInt32(); if (error == 0) { addUIError("Your hearthstone is not bound."); addSystemChatMessage("Your hearthstone is not bound."); } else { addUIError("Hearthstone bind failed."); addSystemChatMessage("Hearthstone bind failed."); } } break; } // ---- Instance/raid errors ---- case Opcode::SMSG_RAID_GROUP_ONLY: { addUIError("You must be in a raid group to enter this instance."); 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) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); } else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); } else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); } } break; } case Opcode::SMSG_RESET_FAILED_NOTIFY: { addUIError("Cannot reset instance: another player is still inside."); 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 (group type, local player flags, leader) ---- // Sent when the player's group configuration changes: group type, // role/flags (assistant/MT/MA), or leader changes. // Format: uint8 groupType | uint32 memberFlags | uint64 leaderGuid case Opcode::SMSG_REAL_GROUP_UPDATE: { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 1) break; uint8_t newGroupType = packet.readUInt8(); if (rem() < 4) break; uint32_t newMemberFlags = packet.readUInt32(); if (rem() < 8) break; uint64_t newLeaderGuid = packet.readUInt64(); partyData.groupType = newGroupType; partyData.leaderGuid = newLeaderGuid; // Update local player's flags in the member list uint64_t localGuid = playerGuid; for (auto& m : partyData.members) { if (m.guid == localGuid) { m.flags = static_cast(newMemberFlags & 0xFF); break; } } LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast(newGroupType), " memberFlags=0x", std::hex, newMemberFlags, std::dec, " leaderGuid=", newLeaderGuid); if (addonEventCallback_) { addonEventCallback_("PARTY_LEADER_CHANGED", {}); addonEventCallback_("GROUP_ROSTER_UPDATE", {}); } 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: 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: 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; case Opcode::SMSG_PLAY_SPELL_IMPACT: { // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } uint64_t impTargetGuid = packet.readUInt64(); uint32_t impVisualId = packet.readUInt32(); if (impVisualId == 0) break; auto* renderer = core::Application::getInstance().getRenderer(); if (!renderer) break; glm::vec3 spawnPos; if (impTargetGuid == playerGuid) { spawnPos = renderer->getCharacterPosition(); } else { auto entity = entityManager.getEntity(impTargetGuid); if (!entity) break; glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); spawnPos = core::coords::canonicalToRender(canonical); } renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true); break; } // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { // WotLK/Classic/Turtle: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... // TBC: same layout but full uint64 GUIDs // Show RESIST combat text when player resists an incoming spell. const bool rlUsesFullGuid = 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() < (rlUsesFullGuid ? 8u : 1u) || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t attackerGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); // Resist payload includes: // float resistFactor + uint32 targetResistance + uint32 resistedValue. // Require the full payload so truncated packets cannot synthesize // zero-value resist events. if (rl_rem() < 12) { packet.setReadPos(packet.getSize()); break; } /*float resistFactor =*/ packet.readFloat(); /*uint32_t targetRes =*/ packet.readUInt32(); int32_t resistedAmount = static_cast(packet.readUInt32()); // Show RESIST when the player is involved on either side. if (resistedAmount > 0 && victimGuid == playerGuid) { addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, false, 0, attackerGuid, victimGuid); } else if (resistedAmount > 0 && attackerGuid == playerGuid) { addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; } // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: bookPages_.clear(); // fresh book for this item read packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: addUIError("You cannot read this item."); 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: addUIError("That creature can't talk to you right now."); 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: handlePetitionQueryResponse(packet); break; case Opcode::SMSG_PETITION_SHOW_SIGNATURES: handlePetitionShowSignatures(packet); break; case Opcode::SMSG_PETITION_SIGN_RESULTS: handlePetitionSignResults(packet); 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); const std::string& sname = getSpellName(spellId); addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + ".")); 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: { // WotLK: castCount(1) + spellId(4) + reason(1) // Classic/TBC: spellId(4) + reason(1) (no castCount) const bool hasCount = isActiveExpansion("wotlk"); const size_t minSize = hasCount ? 6u : 5u; if (packet.getSize() - packet.getReadPos() >= minSize) { if (hasCount) /*uint8_t castCount =*/ packet.readUInt8(); uint32_t spellId = packet.readUInt32(); uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, " reason=", (int)reason); if (reason != 0) { const char* reasonStr = getSpellCastResultString(reason); const std::string& sName = getSpellName(spellId); std::string errMsg; if (reasonStr && *reasonStr) errMsg = sName.empty() ? reasonStr : (sName + ": " + reasonStr); else errMsg = sName.empty() ? "Pet spell failed." : (sName + ": Pet spell failed."); addSystemChatMessage(errMsg); } } 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: { // uint64 petGuid + uint32 cost (copper) if (packet.getSize() - packet.getReadPos() >= 12) { petUnlearnGuid_ = packet.readUInt64(); petUnlearnCost_ = packet.readUInt32(); petUnlearnPending_ = true; } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PET_RENAMEABLE: // Server signals that the pet can now be named (first tame) petRenameablePending_ = true; packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PET_NAME_INVALID: addUIError("That pet name is invalid. Please choose a different name."); addSystemChatMessage("That pet name is invalid. Please choose a different name."); packet.setReadPos(packet.getSize()); break; // ---- Inspect (Classic 1.12 gear inspection) ---- case Opcode::SMSG_INSPECT: { // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. if (packet.getSize() - packet.getReadPos() < 2) { packet.setReadPos(packet.getSize()); break; } uint64_t guid = UpdateObjectParser::readPackedGuid(packet); if (guid == 0) { packet.setReadPos(packet.getSize()); break; } constexpr int kGearSlots = 19; size_t needed = kGearSlots * sizeof(uint32_t); if (packet.getSize() - packet.getReadPos() < needed) { packet.setReadPos(packet.getSize()); break; } std::array items{}; for (int s = 0; s < kGearSlots; ++s) items[s] = packet.readUInt32(); // Resolve player name auto ent = entityManager.getEntity(guid); std::string playerName = "Target"; if (ent) { auto pl = std::dynamic_pointer_cast(ent); if (pl && !pl->getName().empty()) playerName = pl->getName(); } // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) inspectResult_.guid = guid; inspectResult_.playerName = playerName; inspectResult_.totalTalents = 0; inspectResult_.unspentTalents = 0; inspectResult_.talentGroups = 0; inspectResult_.activeTalentGroup = 0; inspectResult_.itemEntries = items; inspectResult_.enchantIds = {}; // Also cache for future talent-inspect cross-reference inspectedPlayerItemEntries_[guid] = items; // Trigger item queries for non-empty slots for (int s = 0; s < kGearSlots; ++s) { if (items[s] != 0) queryItemInfo(items[s], 0); } LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", std::count_if(items.begin(), items.end(), [](uint32_t e) { return e != 0; }), "/19 slots"); if (addonEventCallback_) { char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); addonEventCallback_("INSPECT_READY", {guidBuf}); } 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; std::vector subPackets; 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); subPackets.emplace_back(subOpcode, std::move(subPayload)); pos += 4 + payloadLen; } for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) { enqueueIncomingPacketFront(std::move(*it)); } packet.setReadPos(packet.getSize()); break; } // ---- Misc consume (no state change needed) ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: case Opcode::SMSG_PLAYER_SKINNED: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PROPOSE_LEVEL_GRANT: { // Recruit-A-Friend: a mentor is offering to grant you a level if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t mentorGuid = packet.readUInt64(); std::string mentorName; auto ent = entityManager.getEntity(mentorGuid); if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); if (mentorName.empty()) { auto nit = playerNameCache.find(mentorGuid); if (nit != playerNameCache.end()) mentorName = nit->second; } addSystemChatMessage(mentorName.empty() ? "A player is offering to grant you a level." : (mentorName + " is offering to grant you a level.")); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: addSystemChatMessage("Your Recruit-A-Friend link has expired."); packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_REFER_A_FRIEND_FAILURE: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t reason = packet.readUInt32(); static const char* kRafErrors[] = { "Not eligible", // 0 "Target not eligible", // 1 "Too many referrals", // 2 "Wrong faction", // 3 "Not a recruit", // 4 "Recruit requirements not met", // 5 "Level above requirement", // 6 "Friend needs account upgrade", // 7 }; const char* msg = (reason < 8) ? kRafErrors[reason] : "Recruit-A-Friend failed."; addSystemChatMessage(std::string("Recruit-A-Friend: ") + msg); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_REPORT_PVP_AFK_RESULT: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); if (result == 0) addSystemChatMessage("AFK report submitted."); else addSystemChatMessage("Cannot report that player as AFK right now."); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: handleRespondInspectAchievements(packet); break; case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: handleQuestPoiQueryResponse(packet); break; case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: vehicleId_ = 0; // Vehicle ride cancelled; clear UI packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_PLAY_TIME_WARNING: { // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t warnType = packet.readUInt32(); uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] "; char buf[128]; if (minutesPlayed > 0) { uint32_t h = minutesPlayed / 60; uint32_t m = minutesPlayed % 60; if (h > 0) std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m); else std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m); } else { std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity); } addSystemChatMessage(buf); addUIError(buf); } 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; // ---- Mirror image data (WotLK: Mage ability Mirror Image) ---- case Opcode::SMSG_MIRRORIMAGE_DATA: { // WotLK 3.3.5a format: // uint64 mirrorGuid — GUID of the mirror image unit // uint32 displayId — display ID to render the image with // uint8 raceId — race of caster // uint8 genderFlag — gender of caster // uint8 classId — class of caster // uint64 casterGuid — GUID of the player who cast the spell // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 // Purpose: tells client how to render the image (same appearance as caster). // We parse the GUIDs so units render correctly via their existing display IDs. if (packet.getSize() - packet.getReadPos() < 8) break; uint64_t mirrorGuid = packet.readUInt64(); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t displayId = packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < 3) break; /*uint8_t raceId =*/ packet.readUInt8(); /*uint8_t gender =*/ packet.readUInt8(); /*uint8_t classId =*/ packet.readUInt8(); // Apply display ID to the mirror image unit so it renders correctly if (mirrorGuid != 0 && displayId != 0) { auto entity = entityManager.getEntity(mirrorGuid); if (entity) { auto unit = std::dynamic_pointer_cast(entity); if (unit && unit->getDisplayId() == 0) unit->setDisplayId(displayId); } } LOG_DEBUG("SMSG_MIRRORIMAGE_DATA: mirrorGuid=0x", std::hex, mirrorGuid, " displayId=", std::dec, displayId); 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; // ---- Battlefield Manager (WotLK outdoor battlefields: Wintergrasp, Tol Barad) ---- case Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: { // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) if (packet.getSize() - packet.getReadPos() < 20) { packet.setReadPos(packet.getSize()); break; } uint64_t bfGuid = packet.readUInt64(); uint32_t bfZoneId = packet.readUInt32(); uint64_t expireTime = packet.readUInt64(); (void)bfGuid; (void)expireTime; // Store the invitation so the UI can show a prompt bfMgrInvitePending_ = true; bfMgrZoneId_ = bfZoneId; char buf[128]; std::string bfZoneName = getAreaName(bfZoneId); if (!bfZoneName.empty()) std::snprintf(buf, sizeof(buf), "You are invited to the outdoor battlefield in %s. Click to enter.", bfZoneName.c_str()); else std::snprintf(buf, sizeof(buf), "You are invited to the outdoor battlefield in zone %u. Click to enter.", bfZoneId); addSystemChatMessage(buf); LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); break; } case Opcode::SMSG_BATTLEFIELD_MGR_ENTERED: { // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t bfGuid2 = packet.readUInt64(); (void)bfGuid2; uint8_t isSafe = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; uint8_t onQueue = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; bfMgrInvitePending_ = false; bfMgrActive_ = true; addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)." : "You have entered the battlefield!"); if (onQueue) addSystemChatMessage("You are in the battlefield queue."); LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", (int)isSafe, " onQueue=", (int)onQueue); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: { // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime if (packet.getSize() - packet.getReadPos() < 20) { packet.setReadPos(packet.getSize()); break; } uint64_t bfGuid3 = packet.readUInt64(); uint32_t bfId = packet.readUInt32(); uint64_t expTime = packet.readUInt64(); (void)bfGuid3; (void)expTime; bfMgrInvitePending_ = true; bfMgrZoneId_ = bfId; char buf[128]; std::snprintf(buf, sizeof(buf), "A spot has opened in the battlefield queue (battlefield %u).", bfId); addSystemChatMessage(buf); LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId); break; } case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: { // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, // 4=in_cooldown, 5=queued_other_bf, 6=bf_full if (packet.getSize() - packet.getReadPos() < 11) { packet.setReadPos(packet.getSize()); break; } uint32_t bfId2 = packet.readUInt32(); /*uint32_t teamId =*/ packet.readUInt32(); uint8_t accepted = packet.readUInt8(); /*uint8_t logging =*/ packet.readUInt8(); uint8_t result = packet.readUInt8(); (void)bfId2; if (accepted) { addSystemChatMessage("You have joined the battlefield queue."); } else { static const char* kBfQueueErrors[] = { "Queued for battlefield.", "Not in a group.", "Level too high.", "Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.", "Battlefield is full." }; const char* msg = (result < 7) ? kBfQueueErrors[result] : "Battlefield queue request failed."; addSystemChatMessage(std::string("Battlefield: ") + msg); } LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", (int)accepted, " result=", (int)result); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING: { // uint64 battlefieldGuid + uint8 remove if (packet.getSize() - packet.getReadPos() >= 9) { uint64_t bfGuid4 = packet.readUInt64(); uint8_t remove = packet.readUInt8(); (void)bfGuid4; if (remove) { addSystemChatMessage("You will be removed from the battlefield shortly."); } LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", (int)remove); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_BATTLEFIELD_MGR_EJECTED: { // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated if (packet.getSize() - packet.getReadPos() >= 17) { uint64_t bfGuid5 = packet.readUInt64(); uint32_t reason = packet.readUInt32(); /*uint32_t status =*/ packet.readUInt32(); uint8_t relocated = packet.readUInt8(); (void)bfGuid5; static const char* kEjectReasons[] = { "Removed from battlefield.", "Transported from battlefield.", "Left battlefield voluntarily.", "Offline.", }; const char* msg = (reason < 4) ? kEjectReasons[reason] : "You have been ejected from the battlefield."; addSystemChatMessage(msg); if (relocated) addSystemChatMessage("You have been relocated outside the battlefield."); LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", (int)relocated); } bfMgrActive_ = false; bfMgrInvitePending_ = false; packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE: { // uint32 oldState + uint32 newState // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown if (packet.getSize() - packet.getReadPos() >= 8) { /*uint32_t oldState =*/ packet.readUInt32(); uint32_t newState = packet.readUInt32(); static const char* kBfStates[] = { "waiting", "starting", "in progress", "ending", "in cooldown" }; const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state"; char buf[128]; std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr); addSystemChatMessage(buf); LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState); } packet.setReadPos(packet.getSize()); break; } // ---- WotLK Calendar system (pending invites, event notifications, command results) ---- case Opcode::SMSG_CALENDAR_SEND_NUM_PENDING: { // uint32 numPending — number of unacknowledged calendar invites if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t numPending = packet.readUInt32(); calendarPendingInvites_ = numPending; if (numPending > 0) { char buf[64]; std::snprintf(buf, sizeof(buf), "You have %u pending calendar invite%s.", numPending, numPending == 1 ? "" : "s"); addSystemChatMessage(buf); } LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites"); } break; } case Opcode::SMSG_CALENDAR_COMMAND_RESULT: { // uint32 command + uint8 result + cstring info // result 0 = success; non-zero = error code // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status if (packet.getSize() - packet.getReadPos() < 5) { packet.setReadPos(packet.getSize()); break; } /*uint32_t command =*/ packet.readUInt32(); uint8_t result = packet.readUInt8(); std::string info = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; if (result != 0) { // Map common calendar error codes to friendly strings static const char* kCalendarErrors[] = { "", "Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL "Calendar: Guild event limit reached.",// 2 "Calendar: Event limit reached.", // 3 "Calendar: You cannot invite that player.", // 4 "Calendar: No invites remaining.", // 5 "Calendar: Invalid date.", // 6 "Calendar: Cannot invite yourself.", // 7 "Calendar: Cannot modify this event.", // 8 "Calendar: Not invited.", // 9 "Calendar: Already invited.", // 10 "Calendar: Player not found.", // 11 "Calendar: Not enough focus.", // 12 "Calendar: Event locked.", // 13 "Calendar: Event deleted.", // 14 "Calendar: Not a moderator.", // 15 }; const char* errMsg = (result < 16) ? kCalendarErrors[result] : "Calendar: Command failed."; if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg); else if (!info.empty()) addSystemChatMessage("Calendar: " + info); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT: { // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + // isGuildEvent(1) + inviterGuid(8) if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } /*uint64_t eventId =*/ packet.readUInt64(); std::string title = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; packet.setReadPos(packet.getSize()); // consume remaining fields if (!title.empty()) { addSystemChatMessage("Calendar invite: " + title); } else { addSystemChatMessage("You have a new calendar invite."); } if (calendarPendingInvites_ < 255) ++calendarPendingInvites_; LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'"); break; } // Remaining calendar informational packets — parse title where possible and consume case Opcode::SMSG_CALENDAR_EVENT_STATUS: { // Sent when an event invite's RSVP status changes for the local player // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) if (packet.getSize() - packet.getReadPos() < 31) { packet.setReadPos(packet.getSize()); break; } /*uint64_t inviteId =*/ packet.readUInt64(); /*uint64_t eventId =*/ packet.readUInt64(); /*uint8_t evType =*/ packet.readUInt8(); /*uint32_t flags =*/ packet.readUInt32(); /*uint64_t invTime =*/ packet.readUInt64(); uint8_t status = packet.readUInt8(); /*uint8_t rank =*/ packet.readUInt8(); /*uint8_t isGuild =*/ packet.readUInt8(); std::string evTitle = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; // status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative static const char* kRsvpStatus[] = { "invited", "accepted", "declined", "confirmed", "out", "on standby", "signed up", "not signed up", "tentative" }; const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown"; if (!evTitle.empty()) { char buf[256]; std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.", evTitle.c_str(), statusStr); addSystemChatMessage(buf); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED: { // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime if (packet.getSize() - packet.getReadPos() >= 28) { /*uint64_t inviteId =*/ packet.readUInt64(); /*uint64_t eventId =*/ packet.readUInt64(); uint32_t mapId = packet.readUInt32(); uint32_t difficulty = packet.readUInt32(); /*uint64_t resetTime =*/ packet.readUInt64(); std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; std::string msg = "Calendar: Raid lockout added for " + mapLabel; if (diffStr) msg += std::string(" (") + diffStr + ")"; msg += '.'; addSystemChatMessage(msg); LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: { // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty if (packet.getSize() - packet.getReadPos() >= 20) { /*uint64_t inviteId =*/ packet.readUInt64(); /*uint64_t eventId =*/ packet.readUInt64(); uint32_t mapId = packet.readUInt32(); uint32_t difficulty = packet.readUInt32(); std::string mapLabel = getMapName(mapId); if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; std::string msg = "Calendar: Raid lockout removed for " + mapLabel; if (diffStr) msg += std::string(" (") + diffStr + ")"; msg += '.'; addSystemChatMessage(msg); LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, " difficulty=", difficulty); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED: { // Same format as LOCKOUT_ADDED; consume packet.setReadPos(packet.getSize()); break; } // Remaining calendar opcodes: safe consume — data surfaced via SEND_CALENDAR/SEND_EVENT case Opcode::SMSG_CALENDAR_SEND_CALENDAR: case Opcode::SMSG_CALENDAR_SEND_EVENT: case Opcode::SMSG_CALENDAR_ARENA_TEAM: case Opcode::SMSG_CALENDAR_FILTER_GUILD: case Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION: case Opcode::SMSG_CALENDAR_EVENT_INVITE: case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES: case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT: case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED: case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT: case Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT: case Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT: case Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT: case Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT: packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_SERVERTIME: { // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t srvTime = packet.readUInt32(); if (srvTime > 0) { gameTime_ = static_cast(srvTime); LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime); } } break; } case Opcode::SMSG_KICK_REASON: { // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string // kickReasonType: 0=other, 1=afk, 2=vote kick if (!packetHasRemaining(packet, 12)) { packet.setReadPos(packet.getSize()); break; } uint64_t kickerGuid = packet.readUInt64(); uint32_t reasonType = packet.readUInt32(); std::string reason; if (packet.getReadPos() < packet.getSize()) reason = packet.readString(); (void)kickerGuid; (void)reasonType; std::string msg = "You have been removed from the group."; if (!reason.empty()) msg = "You have been removed from the group: " + reason; else if (reasonType == 1) msg = "You have been removed from the group for being AFK."; else if (reasonType == 2) msg = "You have been removed from the group by vote."; addSystemChatMessage(msg); addUIError(msg); LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, " reason='", reason, "'"); break; } case Opcode::SMSG_GROUPACTION_THROTTLED: { // uint32 throttleMs — rate-limited group action; notify the player if (packetHasRemaining(packet, 4)) { uint32_t throttleMs = packet.readUInt32(); char buf[128]; if (throttleMs > 0) { std::snprintf(buf, sizeof(buf), "Group action throttled. Please wait %.1f seconds.", throttleMs / 1000.0f); } else { std::snprintf(buf, sizeof(buf), "Group action throttled."); } addSystemChatMessage(buf); LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs); } break; } case Opcode::SMSG_GMRESPONSE_RECEIVED: { // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count // per count: string responseText if (!packetHasRemaining(packet, 4)) { packet.setReadPos(packet.getSize()); break; } uint32_t ticketId = packet.readUInt32(); std::string subject; std::string body; if (packet.getReadPos() < packet.getSize()) subject = packet.readString(); if (packet.getReadPos() < packet.getSize()) body = packet.readString(); uint32_t responseCount = 0; if (packetHasRemaining(packet, 4)) responseCount = packet.readUInt32(); std::string responseText; for (uint32_t i = 0; i < responseCount && i < 10; ++i) { if (packet.getReadPos() < packet.getSize()) { std::string t = packet.readString(); if (i == 0) responseText = t; } } (void)ticketId; std::string msg; if (!responseText.empty()) msg = "[GM Response] " + responseText; else if (!body.empty()) msg = "[GM Response] " + body; else if (!subject.empty()) msg = "[GM Response] " + subject; else msg = "[GM Response] Your ticket has been answered."; addSystemChatMessage(msg); addUIError(msg); LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId, " subject='", subject, "'"); break; } case Opcode::SMSG_GMRESPONSE_STATUS_UPDATE: { // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) if (packet.getSize() - packet.getReadPos() >= 5) { uint32_t ticketId = packet.readUInt32(); uint8_t status = packet.readUInt8(); const char* statusStr = (status == 1) ? "open" : (status == 2) ? "answered" : (status == 3) ? "needs more info" : "updated"; char buf[128]; std::snprintf(buf, sizeof(buf), "[GM Ticket #%u] Status: %s.", ticketId, statusStr); addSystemChatMessage(buf); LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId, " status=", static_cast(status)); } break; } // ---- Voice chat (WotLK built-in voice) — consume silently ---- case Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE: case Opcode::SMSG_VOICE_SESSION_LEAVE: case Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY: case Opcode::SMSG_VOICE_SET_TALKER_MUTED: case Opcode::SMSG_VOICE_SESSION_ENABLE: case Opcode::SMSG_VOICE_PARENTAL_CONTROLS: case Opcode::SMSG_AVAILABLE_VOICE_CHANNEL: case Opcode::SMSG_VOICE_CHAT_STATUS: packet.setReadPos(packet.getSize()); break; // ---- Dance / custom emote system (WotLK) — consume silently ---- case Opcode::SMSG_NOTIFY_DANCE: case Opcode::SMSG_PLAY_DANCE: case Opcode::SMSG_STOP_DANCE: case Opcode::SMSG_DANCE_QUERY_RESPONSE: case Opcode::SMSG_INVALIDATE_DANCE: packet.setReadPos(packet.getSize()); break; // ---- Commentator / spectator mode — consume silently ---- case Opcode::SMSG_COMMENTATOR_STATE_CHANGED: case Opcode::SMSG_COMMENTATOR_MAP_INFO: case Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO: case Opcode::SMSG_COMMENTATOR_PLAYER_INFO: case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1: case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2: packet.setReadPos(packet.getSize()); break; // ---- Debug / cheat / GM-only opcodes — consume silently ---- case Opcode::SMSG_DBLOOKUP: case Opcode::SMSG_CHECK_FOR_BOTS: case Opcode::SMSG_GODMODE: case Opcode::SMSG_PETGODMODE: case Opcode::SMSG_DEBUG_AISTATE: case Opcode::SMSG_DEBUGAURAPROC: case Opcode::SMSG_TEST_DROP_RATE_RESULT: case Opcode::SMSG_COOLDOWN_CHEAT: case Opcode::SMSG_GM_PLAYER_INFO: case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE: case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE: case Opcode::SMSG_CHEAT_PLAYER_LOOKUP: case Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT: case Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT: case Opcode::SMSG_DEBUG_LIST_TARGETS: case Opcode::SMSG_DEBUG_SERVER_GEO: case Opcode::SMSG_DUMP_OBJECTS_DATA: case Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE: case Opcode::SMSG_FORCEACTIONSHOW: case Opcode::SMSG_MOVE_CHARACTER_CHEAT: packet.setReadPos(packet.getSize()); break; default: // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. if (state != WorldState::IN_WORLD) { static std::unordered_set 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::enqueueIncomingPacket(const network::Packet& packet) { if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(), " packets); dropping oldest packet to preserve responsiveness"); pendingIncomingPackets_.pop_front(); } pendingIncomingPackets_.push_back(packet); lastRxTime_ = std::chrono::steady_clock::now(); rxSilenceLogged_ = false; } void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(), " packets); dropping newest queued packet to preserve ordering"); pendingIncomingPackets_.pop_back(); } pendingIncomingPackets_.emplace_front(std::move(packet)); } void GameHandler::enqueueUpdateObjectWork(UpdateObjectData&& data) { pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); } void GameHandler::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, float budgetMs) { if (pendingUpdateObjectWork_.empty()) { return; } const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(state); int processedBlocks = 0; while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { float elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - start).count(); if (elapsedMs >= budgetMs) { break; } auto& work = pendingUpdateObjectWork_.front(); if (!work.outOfRangeProcessed) { auto outOfRangeStart = std::chrono::steady_clock::now(); processOutOfRangeObjects(work.data.outOfRangeGuids); float outOfRangeMs = std::chrono::duration( std::chrono::steady_clock::now() - outOfRangeStart).count(); if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, "ms guidCount=", work.data.outOfRangeGuids.size()); } work.outOfRangeProcessed = true; } while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - start).count(); if (elapsedMs >= budgetMs) { break; } const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; auto blockStart = std::chrono::steady_clock::now(); applyUpdateObjectBlock(block, work.newItemCreated); float blockMs = std::chrono::duration( std::chrono::steady_clock::now() - blockStart).count(); if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { LOG_WARNING("SLOW update-object block apply: ", blockMs, "ms index=", work.nextBlockIndex, " type=", static_cast(block.updateType), " guid=0x", std::hex, block.guid, std::dec, " objectType=", static_cast(block.objectType), " fieldCount=", block.fields.size(), " hasMovement=", block.hasMovement ? 1 : 0); } ++work.nextBlockIndex; ++processedBlocks; } if (work.nextBlockIndex >= work.data.blocks.size()) { finalizeUpdateObjectBatch(work.newItemCreated); pendingUpdateObjectWork_.pop_front(); continue; } break; } if (!pendingUpdateObjectWork_.empty()) { const auto& work = pendingUpdateObjectWork_.front(); LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, "/", work.data.blocks.size(), ", state=", worldStateName(state), ")"); } } void GameHandler::processQueuedIncomingPackets() { if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { return; } const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state); const float budgetMs = incomingPacketBudgetMs(state); const auto start = std::chrono::steady_clock::now(); int processed = 0; while (processed < maxPacketsThisUpdate) { float elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - start).count(); if (elapsedMs >= budgetMs) { break; } if (!pendingUpdateObjectWork_.empty()) { processPendingUpdateObjectWork(start, budgetMs); if (!pendingUpdateObjectWork_.empty()) { break; } continue; } if (pendingIncomingPackets_.empty()) { break; } network::Packet packet = std::move(pendingIncomingPackets_.front()); pendingIncomingPackets_.pop_front(); const uint16_t wireOp = packet.getOpcode(); const auto logicalOp = opcodeTable_.fromWire(wireOp); auto packetHandleStart = std::chrono::steady_clock::now(); handlePacket(packet); float packetMs = std::chrono::duration( std::chrono::steady_clock::now() - packetHandleStart).count(); if (packetMs > slowPacketLogThresholdMs()) { const char* logicalName = logicalOp ? OpcodeTable::logicalToName(*logicalOp) : "UNKNOWN"; LOG_WARNING("SLOW packet handler: ", packetMs, "ms wire=0x", std::hex, wireOp, std::dec, " logical=", logicalName, " size=", packet.getSize(), " state=", worldStateName(state)); } ++processed; } if (!pendingUpdateObjectWork_.empty()) { return; } if (!pendingIncomingPackets_.empty()) { LOG_DEBUG("GameHandler packet budget reached (processed=", processed, ", remaining=", pendingIncomingPackets_.size(), ", state=", worldStateName(state), ")"); } } 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_ = {}; keyringSlotGuids_ = {}; invSlotBase_ = -1; packSlotBase_ = -1; lastPlayerFields_.clear(); onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); std::fill(std::begin(playerStats_), std::end(playerStats_), -1); playerMeleeAP_ = -1; playerRangedAP_ = -1; std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1); playerHealBonus_ = -1; playerDodgePct_ = -1.0f; playerParryPct_ = -1.0f; playerBlockPct_ = -1.0f; playerCritPct_ = -1.0f; playerRangedCritPct_ = -1.0f; std::fill(std::begin(playerSpellCritPct_), std::end(playerSpellCritPct_), -1.0f); std::fill(std::begin(playerCombatRatings_), std::end(playerCombatRatings_), -1); knownSpells.clear(); spellCooldowns.clear(); spellFlatMods_.clear(); spellPctMods_.clear(); actionBar = {}; playerAuras.clear(); targetAuras.clear(); unitAurasCache_.clear(); unitCastStates_.clear(); petGuid_ = 0; stableWindowOpen_ = false; stableMasterGuid_ = 0; stableNumSlots_ = 0; stabledPets_.clear(); 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; castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; corpseReclaimAvailableMs_ = 0; 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; } glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); const bool alreadyInWorld = (state == WorldState::IN_WORLD); const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId); const float dxCurrent = movementInfo.x - canonical.x; const float dyCurrent = movementInfo.y - canonical.y; const float dzCurrent = movementInfo.z - canonical.z; const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent; // Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already // in-world. Re-running full world-entry handling here can trigger an expensive // same-map reload/reset path and starve networking for tens of seconds. if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) { LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=", data.mapId, " dist=", std::sqrt(distSqCurrent)); return; } // Successfully entered the world (or teleported) currentMapId_ = data.mapId; setState(WorldState::IN_WORLD); if (socket) { socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_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) 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; selfResAvailable_ = false; onTaxiFlight_ = false; taxiMountActive_ = false; taxiActivatePending_ = false; taxiClientActive_ = false; taxiClientPath_.clear(); taxiRecoverPending_ = false; taxiStartGrace_ = 0.0f; currentMountDisplayId_ = 0; taxiMountDisplayId_ = 0; vehicleId_ = 0; if (mountCallback_) { mountCallback_(0); } // Clear boss encounter unit slots and raid marks on world transfer encounterUnitGuids_.fill(0); raidTargetGuids_.fill(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; // Notify application to load terrain for this map/position (online mode) if (worldEntryCallback_) { worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); } // Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers. 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); } // Kick the first keepalive immediately on world entry. Classic-like realms // can close the session before our default 30s ping cadence fires. timeSinceLastPing = 0.0f; if (socket) { LOG_DEBUG("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD"); sendPing(); } // 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) { // Clear inspect caches on world entry to avoid showing stale data. inspectedPlayerAchievements_.clear(); // 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(); learnedGlyphs_[0].fill(0); learnedGlyphs_[1].fill(0); unspentTalentPoints_[0] = 0; unspentTalentPoints_[1] = 0; activeTalentSpec_ = 0; // Auto-join default chat channels only on first world entry. 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, ")"); } 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 when the expansion supports it. Classic-like // opcode tables do not define this packet, and sending 0xFFFF during world // entry can desync the early session handshake. if (socket) { const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED); if (queryCompletedWire != 0xFFFF) { network::Packet cqcPkt(queryCompletedWire); socket->send(cqcPkt); LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); } else { LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); } } // Auto-request played time on login so the character Stats tab is // populated immediately without requiring /played. if (socket) { auto ptPkt = RequestPlayedTimePacket::build(false); // false = don't show in chat socket->send(ptPkt); LOG_INFO("Auto-requested played time on login"); } } } 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_WARNING("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); auto applyWardenSeedRekey = [&](const std::vector& rekeySeed) { // Derive new RC4 keys from the seed using SHA1Randx. uint8_t newEncryptKey[16], newDecryptKey[16]; WardenCrypto::sha1RandxGenerate(rekeySeed, 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"); }; // --- 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_WARNING("Warden: HASH_REQUEST — CR entry MATCHED, sending pre-computed reply"); // 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_WARNING("Warden: Switched to CR key set"); wardenState_ = WardenState::WAIT_CHECKS; break; } else { LOG_WARNING("Warden: Seed not found in ", wardenCREntries_.size(), " CR entries"); } } // --- No CR match: decide strategy based on server strictness --- { std::string seedHex; for (auto b : seed) { char s[4]; snprintf(s, 4, "%02x", b); seedHex += s; } bool isTurtle = isActiveExpansion("turtle"); bool isClassic = (build <= 6005) && !isTurtle; if (!isTurtle && !isClassic) { // WotLK/TBC (AzerothCore, etc.): strict servers BAN for wrong HASH_RESULT. // Without a matching CR entry we cannot compute the correct hash // (requires executing the module's native init function). // Safest action: don't respond. Server will time-out and kick (not ban). LOG_WARNING("Warden: HASH_REQUEST seed=", seedHex, " — no CR match, SKIPPING response to avoid account ban"); LOG_WARNING("Warden: To fix, provide a .cr file with the correct seed→reply entry for this module"); // Stay in WAIT_HASH_REQUEST — server will eventually kick. break; } // Turtle/Classic: lenient servers (log-only penalties, no bans). // Send a best-effort fallback hash so we can continue the handshake. LOG_WARNING("Warden: No CR match (seed=", seedHex, "), sending fallback hash (lenient server)"); std::vector fallbackReply; if (wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { const uint8_t* moduleImage = static_cast(wardenLoadedModule_->getModuleMemory()); size_t moduleImageSize = wardenLoadedModule_->getModuleSize(); if (moduleImage && moduleImageSize > 0) { std::vector imageData(moduleImage, moduleImage + moduleImageSize); fallbackReply = auth::Crypto::sha1(imageData); } } if (fallbackReply.empty()) { if (!wardenModuleData_.empty()) fallbackReply = auth::Crypto::sha1(wardenModuleData_); else fallbackReply.assign(20, 0); } std::vector resp; resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end()); sendWardenResponse(resp); applyWardenSeedRekey(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); }()); // Quick-scan for PAGE_A/PAGE_B checks (these trigger 5-second brute-force searches) { bool hasSlowChecks = false; for (size_t i = pos; i < decrypted.size() - 1; i++) { uint8_t d = decrypted[i] ^ xorByte; if (d == wardenCheckOpcodes_[2] || d == wardenCheckOpcodes_[3]) { hasSlowChecks = true; break; } } if (hasSlowChecks && !wardenResponsePending_) { LOG_WARNING("Warden: PAGE_A/PAGE_B detected — building response async to avoid main-loop stall"); // Ensure wardenMemory_ is loaded on main thread before launching async task if (!wardenMemory_) { wardenMemory_ = std::make_unique(); if (!wardenMemory_->load(static_cast(build), isActiveExpansion("turtle"))) { LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); } } // Capture state by value (decrypted, strings) and launch async. // The async task returns plaintext response bytes; main thread encrypts+sends in update(). size_t capturedPos = pos; wardenPendingEncrypted_ = std::async(std::launch::async, [this, decrypted, strings, xorByte, capturedPos]() -> std::vector { // This runs on a background thread — same logic as the synchronous path below. // BEGIN: duplicated check processing (kept in sync with synchronous path) 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 }; size_t checkEnd = decrypted.size() - 1; size_t pos = capturedPos; auto decodeCheckType = [&](uint8_t raw) -> CheckType { uint8_t decoded = raw ^ xorByte; if (decoded == wardenCheckOpcodes_[0]) return CT_MEM; if (decoded == wardenCheckOpcodes_[1]) return CT_MODULE; if (decoded == wardenCheckOpcodes_[2]) return CT_PAGE_A; if (decoded == wardenCheckOpcodes_[3]) return CT_PAGE_B; if (decoded == wardenCheckOpcodes_[4]) return CT_MPQ; if (decoded == wardenCheckOpcodes_[5]) return CT_LUA; if (decoded == wardenCheckOpcodes_[6]) return CT_PROC; if (decoded == wardenCheckOpcodes_[7]) return CT_DRIVER; if (decoded == wardenCheckOpcodes_[8]) return CT_TIMING; return CT_UNKNOWN; }; auto resolveString = [&](uint8_t idx) -> std::string { if (idx == 0) return {}; size_t i = idx - 1; return i < strings.size() ? strings[i] : std::string(); }; auto isKnownWantedCodeScan = [&](const uint8_t seed[4], const uint8_t hash[20], uint32_t off, uint8_t len) -> bool { auto tryMatch = [&](const uint8_t* pat, size_t patLen) { uint8_t out[SHA_DIGEST_LENGTH]; unsigned int outLen = 0; HMAC(EVP_sha1(), seed, 4, pat, patLen, out, &outLen); return outLen == SHA_DIGEST_LENGTH && !std::memcmp(out, hash, SHA_DIGEST_LENGTH); }; static const uint8_t p1[] = {0x33,0xD2,0x33,0xC9,0xE8,0x87,0x07,0x1B,0x00,0xE8}; if (off == 13856 && len == sizeof(p1) && tryMatch(p1, sizeof(p1))) return true; static const uint8_t p2[] = {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 (len == sizeof(p2) && tryMatch(p2, sizeof(p2))) return true; return false; }; std::vector resultData; int checkCount = 0; int checkTypeCounts[10] = {}; #define WARDEN_ASYNC_HANDLER 1 // The check processing loop is identical to the synchronous path. // See the synchronous case 0x02 below for the canonical version. while (pos < checkEnd) { CheckType ct = decodeCheckType(decrypted[pos]); pos++; checkCount++; if (ct <= CT_UNKNOWN) checkTypeCounts[ct]++; switch (ct) { case CT_TIMING: { // Result byte: 0x01 = timing check ran successfully, // 0x00 = timing check failed (Wine/VM — server skips anti-AFK). // We return 0x01 so the server validates normally; our // LastHardwareAction (now-2000) ensures a clean 2s delta. 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: { if (pos + 6 > checkEnd) { pos = checkEnd; break; } uint8_t strIdx = decrypted[pos++]; std::string moduleName = resolveString(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_WARNING("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), " len=", (int)readLen, (strIdx ? " module=\"" + moduleName + "\"" : "")); if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) { uint32_t now = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); wardenMemory_->writeLE32(0xCF0BC8, now - 2000); } std::vector memBuf(readLen, 0); bool memOk = wardenMemory_ && wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data()); if (memOk) { const char* region = "?"; if (offset >= 0x7FFE0000 && offset < 0x7FFF0000) region = "KUSER"; else if (offset >= 0x400000 && offset < 0x800000) region = ".text/.code"; else if (offset >= 0x7FF000 && offset < 0x827000) region = ".rdata"; else if (offset >= 0x827000 && offset < 0x883000) region = ".data(raw)"; else if (offset >= 0x883000 && offset < 0xD06000) region = ".data(BSS)"; bool allZero = true; for (int i = 0; i < (int)readLen; i++) { if (memBuf[i] != 0) { allZero = false; break; } } std::string hexDump; for (int i = 0; i < (int)readLen; i++) { char hx[4]; snprintf(hx,4,"%02x ",memBuf[i]); hexDump += hx; } LOG_WARNING("Warden: MEM_CHECK served: [", hexDump, "] region=", region, (allZero && offset >= 0x883000 ? " \xe2\x98\x85""BSS_ZERO\xe2\x98\x85" : "")); if (offset == 0x7FFE026C && readLen == 12) LOG_WARNING("Warden: Applying 4-byte ULONG alignment padding for WinVersionGet"); resultData.push_back(0x00); resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); } else { // Address not in PE/KUSER — return 0xE9 (not readable). // Real 32-bit WoW can't read kernel space (>=0x80000000) // or arbitrary unallocated user-space addresses. LOG_WARNING("Warden: MEM_CHECK -> 0xE9 (unmapped 0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), ")"); resultData.push_back(0xE9); } break; } case CT_PAGE_A: case CT_PAGE_B: { constexpr size_t kPageSize = 29; const char* pageName = (ct == CT_PAGE_A) ? "PAGE_A" : "PAGE_B"; bool isImageOnly = (ct == CT_PAGE_A); if (pos + kPageSize > checkEnd) { pos = checkEnd; resultData.push_back(0x00); break; } const uint8_t* p = decrypted.data() + pos; const uint8_t* seed = p; const uint8_t* sha1 = p + 4; uint32_t off = uint32_t(p[24])|(uint32_t(p[25])<<8)|(uint32_t(p[26])<<16)|(uint32_t(p[27])<<24); uint8_t patLen = p[28]; bool found = false; bool turtleFallback = false; if (isKnownWantedCodeScan(seed, sha1, off, patLen)) { found = true; } else if (wardenMemory_ && wardenMemory_->isLoaded() && patLen > 0) { // Hint + nearby window search (instant). // Skip full brute-force for Turtle PAGE_A to avoid // 25s delay that triggers response timeout. bool hintOnly = (ct == CT_PAGE_A && isActiveExpansion("turtle")); found = wardenMemory_->searchCodePattern(seed, sha1, patLen, isImageOnly, off, hintOnly); if (!found && !hintOnly && wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { const uint8_t* modMem = static_cast(wardenLoadedModule_->getModuleMemory()); size_t modSize = wardenLoadedModule_->getModuleSize(); if (modMem && modSize >= patLen) { for (size_t i = 0; i < modSize - patLen + 1; i++) { uint8_t h[20]; unsigned int hl = 0; HMAC(EVP_sha1(), seed, 4, modMem+i, patLen, h, &hl); if (hl == 20 && !std::memcmp(h, sha1, 20)) { found = true; break; } } } } } // Turtle PAGE_A fallback: patterns at runtime-patched // offsets don't exist in the on-disk PE. The server // expects "found" for these code integrity checks. if (!found && ct == CT_PAGE_A && isActiveExpansion("turtle") && off < 0x600000) { found = true; turtleFallback = true; } uint8_t pageResult = found ? 0x4A : 0x00; LOG_WARNING("Warden: ", pageName, " offset=0x", [&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}(), " patLen=", (int)patLen, " found=", found ? "yes" : "no", turtleFallback ? " (turtle-fallback)" : ""); pos += kPageSize; resultData.push_back(pageResult); break; } case CT_MPQ: { if (pos + 1 > checkEnd) { pos = checkEnd; break; } uint8_t strIdx = decrypted[pos++]; std::string filePath = resolveString(strIdx); LOG_WARNING("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); bool found = false; std::vector hash(20, 0); if (!filePath.empty()) { std::string np = asciiLower(filePath); std::replace(np.begin(), np.end(), '/', '\\'); auto knownIt = knownDoorHashes().find(np); 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) { std::vector fd; std::string rp = resolveCaseInsensitiveDataPath(am->getDataPath(), filePath); if (!rp.empty()) fd = readFileBinary(rp); if (fd.empty()) fd = am->readFile(filePath); if (!fd.empty()) { found = true; hash = auth::Crypto::sha1(fd); } } } LOG_WARNING("Warden: MPQ result=", (found ? "FOUND" : "NOT_FOUND")); if (found) { resultData.push_back(0x00); resultData.insert(resultData.end(), hash.begin(), hash.end()); } else { resultData.push_back(0x01); } break; } case CT_LUA: { if (pos + 1 > checkEnd) { pos = checkEnd; break; } pos++; resultData.push_back(0x01); break; } case CT_DRIVER: { if (pos + 25 > checkEnd) { pos = checkEnd; break; } pos += 24; uint8_t strIdx = decrypted[pos++]; std::string dn = resolveString(strIdx); LOG_WARNING("Warden: DRIVER=\"", (dn.empty() ? "?" : dn), "\" -> 0x00(not found)"); resultData.push_back(0x00); break; } case CT_MODULE: { if (pos + 24 > checkEnd) { pos = checkEnd; resultData.push_back(0x00); break; } const uint8_t* p = decrypted.data() + pos; uint8_t sb[4] = {p[0],p[1],p[2],p[3]}; uint8_t rh[20]; std::memcpy(rh, p+4, 20); pos += 24; bool isWanted = hmacSha1Matches(sb, "KERNEL32.DLL", rh); std::string mn = isWanted ? "KERNEL32.DLL" : "?"; if (!isWanted) { // Cheat modules (unwanted — report not found) if (hmacSha1Matches(sb,"WPESPY.DLL",rh)) mn = "WPESPY.DLL"; else if (hmacSha1Matches(sb,"TAMIA.DLL",rh)) mn = "TAMIA.DLL"; else if (hmacSha1Matches(sb,"PRXDRVPE.DLL",rh)) mn = "PRXDRVPE.DLL"; else if (hmacSha1Matches(sb,"SPEEDHACK-I386.DLL",rh)) mn = "SPEEDHACK-I386.DLL"; else if (hmacSha1Matches(sb,"D3DHOOK.DLL",rh)) mn = "D3DHOOK.DLL"; else if (hmacSha1Matches(sb,"NJUMD.DLL",rh)) mn = "NJUMD.DLL"; // System DLLs (wanted — report found) else if (hmacSha1Matches(sb,"USER32.DLL",rh)) { mn = "USER32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"NTDLL.DLL",rh)) { mn = "NTDLL.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"WS2_32.DLL",rh)) { mn = "WS2_32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"WSOCK32.DLL",rh)) { mn = "WSOCK32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"ADVAPI32.DLL",rh)) { mn = "ADVAPI32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"SHELL32.DLL",rh)) { mn = "SHELL32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"GDI32.DLL",rh)) { mn = "GDI32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"OPENGL32.DLL",rh)) { mn = "OPENGL32.DLL"; isWanted = true; } else if (hmacSha1Matches(sb,"WINMM.DLL",rh)) { mn = "WINMM.DLL"; isWanted = true; } } uint8_t mr = isWanted ? 0x4A : 0x00; LOG_WARNING("Warden: MODULE \"", mn, "\" -> 0x", [&]{char s[4];snprintf(s,4,"%02x",mr);return std::string(s);}(), isWanted ? "(found)" : "(not found)"); resultData.push_back(mr); break; } case CT_PROC: { if (pos + 30 > checkEnd) { pos = checkEnd; break; } pos += 30; resultData.push_back(0x01); break; } default: pos = checkEnd; break; } } #undef WARDEN_ASYNC_HANDLER // Log summary { std::string summary; const char* ctNames[] = {"MEM","PAGE_A","PAGE_B","MPQ","LUA","DRIVER","TIMING","PROC","MODULE","UNK"}; for (int i = 0; i < 10; i++) { if (checkTypeCounts[i] > 0) { if (!summary.empty()) summary += " "; summary += ctNames[i]; summary += "="; summary += std::to_string(checkTypeCounts[i]); } } LOG_WARNING("Warden: (async) Parsed ", checkCount, " checks [", summary, "] resultSize=", resultData.size()); std::string fullHex; for (size_t bi = 0; bi < resultData.size(); bi++) { char hx[4]; snprintf(hx, 4, "%02x ", resultData[bi]); fullHex += hx; if ((bi + 1) % 32 == 0 && bi + 1 < resultData.size()) fullHex += "\n "; } LOG_WARNING("Warden: RESPONSE_HEX [", fullHex, "]"); } // Build plaintext response: [0x02][uint16 len][uint32 checksum][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; } uint16_t rl = static_cast(resultData.size()); std::vector resp; resp.push_back(0x02); resp.push_back(rl & 0xFF); resp.push_back((rl >> 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()); return resp; // plaintext; main thread will encrypt + send }); wardenResponsePending_ = true; break; // exit case 0x02 — response will be sent from update() } } // 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][uint32 ticks] // 0x01 = timing check ran successfully (server validates anti-AFK) // 0x00 = timing failed (Wine/VM — server skips check but flags client) 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); LOG_WARNING("Warden: (sync) TIMING ticks=", ticks); 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_WARNING("Warden: (sync) MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), " len=", (int)readLen, moduleName.empty() ? "" : (" module=\"" + moduleName + "\"")); // Lazy-load WoW.exe PE image on first MEM_CHECK if (!wardenMemory_) { wardenMemory_ = std::make_unique(); if (!wardenMemory_->load(static_cast(build), isActiveExpansion("turtle"))) { LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); } } // Dynamically update LastHardwareAction before reading // (anti-AFK scan compares this timestamp against TIMING ticks) if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) { uint32_t now = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); wardenMemory_->writeLE32(0xCF0BC8, now - 2000); } // 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"); resultData.push_back(0x00); resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); } else { // Address not in PE/KUSER — return 0xE9 (not readable). LOG_WARNING("Warden: (sync) MEM_CHECK -> 0xE9 (unmapped 0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), ")"); resultData.push_back(0xE9); } 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; } else if (wardenMemory_ && wardenMemory_->isLoaded() && len > 0) { if (wardenMemory_->searchCodePattern(seedBytes, reqHash, len, true, off)) pageResult = 0x4A; } // Turtle PAGE_A fallback: runtime-patched offsets aren't in the // on-disk PE. Server expects "found" for code integrity checks. if (pageResult == 0x00 && isActiveExpansion("turtle") && off < 0x600000) { pageResult = 0x4A; LOG_WARNING("Warden: PAGE_A turtle-fallback for offset=0x", [&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}()); } } if (consume >= 29) { uint32_t off2 = uint32_t((decrypted.data()+pos)[24]) | (uint32_t((decrypted.data()+pos)[25])<<8) | (uint32_t((decrypted.data()+pos)[26])<<16) | (uint32_t((decrypted.data()+pos)[27])<<24); uint8_t len2 = (decrypted.data()+pos)[28]; LOG_WARNING("Warden: (sync) PAGE_A offset=0x", [&]{char s[12];snprintf(s,12,"%08x",off2);return std::string(s);}(), " patLen=", (int)len2, " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); } else { LOG_WARNING("Warden: (sync) PAGE_A (short ", consume, "b) 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_WARNING("Warden: (sync) 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: result=0 + 20-byte SHA1 if found; result=1 (no hash) if not found. // Server only reads 20 hash bytes when result==0; extra bytes corrupt parsing. if (found) { resultData.push_back(0x00); resultData.insert(resultData.end(), hash.begin(), hash.end()); } else { resultData.push_back(0x01); } LOG_WARNING("Warden: (sync) MPQ result=", found ? "FOUND" : "NOT_FOUND"); 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_WARNING("Warden: (sync) 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_WARNING("Warden: (sync) DRIVER=\"", (driverName.empty() ? "?" : driverName), "\" -> 0x00(not found)"); // Response: [uint8 result=0] (driver NOT found = clean) // VMaNGOS: result != 0 means "found". 0x01 would mean VM driver detected! resultData.push_back(0x00); 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; bool shouldReportFound = false; std::string modName = "?"; // Wanted system modules if (hmacSha1Matches(seedBytes, "KERNEL32.DLL", reqHash)) { modName = "KERNEL32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "USER32.DLL", reqHash)) { modName = "USER32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "NTDLL.DLL", reqHash)) { modName = "NTDLL.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "WS2_32.DLL", reqHash)) { modName = "WS2_32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "WSOCK32.DLL", reqHash)) { modName = "WSOCK32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "ADVAPI32.DLL", reqHash)) { modName = "ADVAPI32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "SHELL32.DLL", reqHash)) { modName = "SHELL32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "GDI32.DLL", reqHash)) { modName = "GDI32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "OPENGL32.DLL", reqHash)) { modName = "OPENGL32.DLL"; shouldReportFound = true; } else if (hmacSha1Matches(seedBytes, "WINMM.DLL", reqHash)) { modName = "WINMM.DLL"; shouldReportFound = true; } // Unwanted cheat modules else if (hmacSha1Matches(seedBytes, "WPESPY.DLL", reqHash)) modName = "WPESPY.DLL"; else if (hmacSha1Matches(seedBytes, "SPEEDHACK-I386.DLL", reqHash)) modName = "SPEEDHACK-I386.DLL"; else if (hmacSha1Matches(seedBytes, "TAMIA.DLL", reqHash)) modName = "TAMIA.DLL"; else if (hmacSha1Matches(seedBytes, "PRXDRVPE.DLL", reqHash)) modName = "PRXDRVPE.DLL"; else if (hmacSha1Matches(seedBytes, "D3DHOOK.DLL", reqHash)) modName = "D3DHOOK.DLL"; else if (hmacSha1Matches(seedBytes, "NJUMD.DLL", reqHash)) modName = "NJUMD.DLL"; LOG_WARNING("Warden: (sync) MODULE \"", modName, "\" -> 0x", [&]{char s[4];snprintf(s,4,"%02x",shouldReportFound?0x4A:0x00);return std::string(s);}(), "(", shouldReportFound ? "found" : "not found", ")"); resultData.push_back(shouldReportFound ? 0x4A : 0x00); break; } // Truncated module request fallback: module NOT loaded = clean resultData.push_back(0x00); 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; LOG_WARNING("Warden: (sync) PROC check -> 0x01(not found)"); // Response: [uint8 result=1] (proc NOT found = clean) resultData.push_back(0x01); break; } default: { uint8_t rawByte = decrypted[pos - 1]; uint8_t decoded = rawByte ^ xorByte; LOG_WARNING("Warden: Unknown check type raw=0x", [&]{char s[4];snprintf(s,4,"%02x",rawByte);return std::string(s);}(), " decoded=0x", [&]{char s[4];snprintf(s,4,"%02x",decoded);return std::string(s);}(), " xorByte=0x", [&]{char s[4];snprintf(s,4,"%02x",xorByte);return std::string(s);}(), " opcodes=[", [&]{std::string r;for(int i=0;i<9;i++){char s[6];snprintf(s,6,"0x%02x ",wardenCheckOpcodes_[i]);r+=s;}return r;}(), "] pos=", pos, "/", checkEnd); pos = checkEnd; // stop parsing break; } } } // Log synchronous round summary at WARNING level for diagnostics { LOG_WARNING("Warden: (sync) Parsed ", checkCount, " checks, resultSize=", resultData.size()); std::string fullHex; for (size_t bi = 0; bi < resultData.size(); bi++) { char hx[4]; snprintf(hx, 4, "%02x ", resultData[bi]); fullHex += hx; if ((bi + 1) % 32 == 0 && bi + 1 < resultData.size()) fullHex += "\n "; } LOG_WARNING("Warden: (sync) RESPONSE_HEX [", fullHex, "]"); } // --- 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: sequence=", pingSequence, " latencyHintMs=", lastLatency); // 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::sendRequestVehicleExit() { if (state != WorldState::IN_WORLD || vehicleId_ == 0) return; // CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT)); socket->send(pkt); vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) } bool GameHandler::supportsEquipmentSets() const { return wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE) != 0xFFFF; } void GameHandler::useEquipmentSet(uint32_t setId) { if (state != WorldState::IN_WORLD || !socket) return; uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE); if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } // Find the equipment set to get target item GUIDs per slot const EquipmentSet* es = nullptr; for (const auto& s : equipmentSets_) { if (s.setId == setId) { es = &s; break; } } if (!es) { addUIError("Equipment set not found."); return; } // CMSG_EQUIPMENT_SET_USE: 19 × (PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot) network::Packet pkt(wire); for (int slot = 0; slot < 19; ++slot) { uint64_t itemGuid = es->itemGuids[slot]; MovementPacket::writePackedGuid(pkt, itemGuid); uint8_t srcBag = 0xFF; uint8_t srcSlot = 0; if (itemGuid != 0) { bool found = false; // Check if item is already in an equipment slot for (int eq = 0; eq < 19 && !found; ++eq) { if (getEquipSlotGuid(eq) == itemGuid) { srcBag = 0xFF; // INVENTORY_SLOT_BAG_0 srcSlot = static_cast(eq); found = true; } } // Check backpack (slots 23-38 in the body container) for (int bp = 0; bp < 16 && !found; ++bp) { if (getBackpackItemGuid(bp) == itemGuid) { srcBag = 0xFF; srcSlot = static_cast(23 + bp); found = true; } } // Check extra bags (bag indices 19-22) for (int bag = 0; bag < 4 && !found; ++bag) { int bagSize = inventory.getBagSize(bag); for (int s = 0; s < bagSize && !found; ++s) { if (getBagItemGuid(bag, s) == itemGuid) { srcBag = static_cast(19 + bag); srcSlot = static_cast(s); found = true; } } } } pkt.writeUInt8(srcBag); pkt.writeUInt8(srcSlot); } socket->send(pkt); LOG_INFO("CMSG_EQUIPMENT_SET_USE: setId=", setId); } void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, uint64_t existingGuid, uint32_t setIndex) { if (state != WorldState::IN_WORLD) return; uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE); if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } // CMSG_EQUIPMENT_SET_SAVE: uint64 setGuid + uint32 setIndex + string name + string iconName // + 19 × PackedGuid itemGuid (one per equipment slot, 0–18) if (setIndex == 0xFFFFFFFF) { // Auto-assign next free index setIndex = 0; for (const auto& es : equipmentSets_) { if (es.setId >= setIndex) setIndex = es.setId + 1; } } network::Packet pkt(wire); pkt.writeUInt64(existingGuid); // 0 = create new, nonzero = update pkt.writeUInt32(setIndex); pkt.writeString(name); pkt.writeString(iconName); for (int slot = 0; slot < 19; ++slot) { uint64_t guid = getEquipSlotGuid(slot); MovementPacket::writePackedGuid(pkt, guid); } // Track pending save so SMSG_EQUIPMENT_SET_SAVED can add the new set locally pendingSaveSetName_ = name; pendingSaveSetIcon_ = iconName; socket->send(pkt); LOG_INFO("CMSG_EQUIPMENT_SET_SAVE: name=\"", name, "\" guid=", existingGuid, " index=", setIndex); } void GameHandler::deleteEquipmentSet(uint64_t setGuid) { if (state != WorldState::IN_WORLD || setGuid == 0) return; uint16_t wire = wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET); if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } // CMSG_DELETEEQUIPMENT_SET: uint64 setGuid network::Packet pkt(wire); pkt.writeUInt64(setGuid); socket->send(pkt); // Remove locally so UI updates immediately equipmentSets_.erase( std::remove_if(equipmentSets_.begin(), equipmentSets_.end(), [setGuid](const EquipmentSet& es) { return es.setGuid == setGuid; }), equipmentSets_.end()); equipmentSetInfo_.erase( std::remove_if(equipmentSetInfo_.begin(), equipmentSetInfo_.end(), [setGuid](const EquipmentSetInfo& es) { return es.setGuid == setGuid; }), equipmentSetInfo_.end()); LOG_INFO("CMSG_DELETEEQUIPMENT_SET: guid=", setGuid); } void GameHandler::sendMinimapPing(float wowX, float wowY) { if (state != WorldState::IN_WORLD) return; // MSG_MINIMAP_PING (CMSG direction): float posX + float posY // Server convention: posX = east/west axis = canonical Y (west) // posY = north/south axis = canonical X (north) const float serverX = wowY; // canonical Y (west) → server posX const float serverY = wowX; // canonical X (north) → server posY network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING)); pkt.writeFloat(serverX); pkt.writeFloat(serverY); socket->send(pkt); // Add ping locally so the sender sees their own ping immediately MinimapPing localPing; localPing.senderGuid = activeCharacterGuid_; localPing.wowX = wowX; localPing.wowY = wowY; localPing.age = 0.0f; minimapPings_.push_back(localPing); } 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("SMSG_PONG acknowledged: sequence=", data.sequence, " latencyMs=", lastLatency); } 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. const uint32_t movementTime = nextMovementTimestampMs(); movementInfo.time = movementTime; if (opcode == Opcode::MSG_MOVE_SET_FACING && (isClassicLikeExpansion() || isActiveExpansion("tbc"))) { const float facingDelta = core::coords::normalizeAngleRad( movementInfo.orientation - lastFacingSentOrientation_); const uint32_t sinceLastFacingMs = lastFacingSendTimeMs_ != 0 && movementTime >= lastFacingSendTimeMs_ ? (movementTime - lastFacingSendTimeMs_) : std::numeric_limits::max(); if (std::abs(facingDelta) < 0.02f && sinceLastFacingMs < 200U) { return; } } // Track movement state transition for PLAYER_STARTED/STOPPED_MOVING events const uint32_t kMoveMask = static_cast(MovementFlags::FORWARD) | static_cast(MovementFlags::BACKWARD) | static_cast(MovementFlags::STRAFE_LEFT) | static_cast(MovementFlags::STRAFE_RIGHT); const bool wasMoving = (movementInfo.flags & kMoveMask) != 0; // Cancel any timed (non-channeled) cast the moment the player starts moving. // Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server. // Turning (MSG_MOVE_START_TURN_*) is allowed while casting. if (casting && !castIsChannel) { const bool isPositionalMove = opcode == Opcode::MSG_MOVE_START_FORWARD || opcode == Opcode::MSG_MOVE_START_BACKWARD || opcode == Opcode::MSG_MOVE_START_STRAFE_LEFT || opcode == Opcode::MSG_MOVE_START_STRAFE_RIGHT || opcode == Opcode::MSG_MOVE_JUMP; if (isPositionalMove) { cancelCast(); } } // 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 timeSinceLastMoveHeartbeat_ = 0.0f; 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; } // Fire PLAYER_STARTED/STOPPED_MOVING on movement state transitions { const bool isMoving = (movementInfo.flags & kMoveMask) != 0; if (isMoving && !wasMoving && addonEventCallback_) addonEventCallback_("PLAYER_STARTED_MOVING", {}); else if (!isMoving && wasMoving && addonEventCallback_) addonEventCallback_("PLAYER_STOPPED_MOVING", {}); } if (opcode == Opcode::MSG_MOVE_SET_FACING) { lastFacingSendTimeMs_ = movementInfo.time; lastFacingSentOrientation_ = movementInfo.orientation; } // 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(); } bool includeTransportInWire = isOnTransport(); if (includeTransportInWire && transportManager_) { if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr && tr->isM2) { // Client-detected M2 elevators/trams are not always server-recognized transports. // Sending ONTRANSPORT for these can trigger bad fall-state corrections server-side. includeTransportInWire = false; } } // Add transport data if player is on a server-recognized transport if (includeTransportInWire) { // 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; } if (opcode == Opcode::MSG_MOVE_HEARTBEAT && isClassicLikeExpansion()) { const uint32_t locomotionFlags = 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::ASCENDING) | static_cast(MovementFlags::FALLING) | static_cast(MovementFlags::FALLINGFAR) | static_cast(MovementFlags::SWIMMING); const bool stationaryIdle = !onTaxiFlight_ && !taxiMountActive_ && !taxiActivatePending_ && !taxiClientActive_ && !includeTransportInWire && (movementInfo.flags & locomotionFlags) == 0; const uint32_t sinceLastHeartbeatMs = lastHeartbeatSendTimeMs_ != 0 && movementTime >= lastHeartbeatSendTimeMs_ ? (movementTime - lastHeartbeatSendTimeMs_) : std::numeric_limits::max(); const bool unchangedState = std::abs(movementInfo.x - lastHeartbeatX_) < 0.01f && std::abs(movementInfo.y - lastHeartbeatY_) < 0.01f && std::abs(movementInfo.z - lastHeartbeatZ_) < 0.01f && movementInfo.flags == lastHeartbeatFlags_ && movementInfo.transportGuid == lastHeartbeatTransportGuid_; if (stationaryIdle && unchangedState && sinceLastHeartbeatMs < 1500U) { timeSinceLastMoveHeartbeat_ = 0.0f; return; } const uint32_t sinceLastNonHeartbeatMoveMs = lastNonHeartbeatMoveSendTimeMs_ != 0 && movementTime >= lastNonHeartbeatMoveSendTimeMs_ ? (movementTime - lastNonHeartbeatMoveSendTimeMs_) : std::numeric_limits::max(); if (sinceLastNonHeartbeatMoveMs < 350U) { timeSinceLastMoveHeartbeat_ = 0.0f; return; } } LOG_DEBUG("Sending movement packet: opcode=0x", std::hex, wireOpcode(opcode), std::dec, (includeTransportInWire ? " 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 (includeTransportInWire) { 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); if (opcode == Opcode::MSG_MOVE_HEARTBEAT) { lastHeartbeatSendTimeMs_ = movementInfo.time; lastHeartbeatX_ = movementInfo.x; lastHeartbeatY_ = movementInfo.y; lastHeartbeatZ_ = movementInfo.z; lastHeartbeatFlags_ = movementInfo.flags; lastHeartbeatTransportGuid_ = movementInfo.transportGuid; } else { lastNonHeartbeatMoveSendTimeMs_ = movementInfo.time; } } 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; vehicleId_ = 0; resurrectPending_ = false; resurrectRequestPending_ = false; selfResAvailable_ = false; playerDead_ = false; releasedSpirit_ = false; corpseGuid_ = 0; corpseReclaimAvailableMs_ = 0; 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) { 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. } enqueueUpdateObjectWork(std::move(data)); } void GameHandler::processOutOfRangeObjects(const std::vector& guids) { // Process out-of-range objects first for (uint64_t guid : guids) { 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); } } void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); 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; }; 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) { // Convert transport offset from server → canonical coordinates glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); setPlayerOnTransport(block.transportGuid, canonicalOffset); 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); } } // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create if (block.guid == playerGuid && isClassicLikeExpansion()) { const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); if (ufAuras != 0xFFFF) { bool hasAuraField = false; for (const auto& [fk, fv] : block.fields) { if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } } if (hasAuraField) { playerAuras.clear(); playerAuras.resize(48); uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); const auto& allFields = entity->getFields(); for (int slot = 0; slot < 48; ++slot) { auto it = allFields.find(static_cast(ufAuras + slot)); if (it != allFields.end() && it->second != 0) { AuraSlot& a = playerAuras[slot]; a.spellId = it->second; // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful // Normalize to WotLK convention: 0x80 = negative (debuff) uint8_t classicFlag = 0; if (ufAuraFlags != 0xFFFF) { auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); if (fit != allFields.end()) classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); } // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) a.flags = (classicFlag & 0x02) ? 0x80u : 0u; a.durationMs = -1; a.maxDurationMs = -1; a.casterGuid = playerGuid; a.receivedAtMs = nowMs; } } LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); } } } // 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"); } } if (unitInitiallyDead && npcDeathCallback_) { npcDeathCallback_(block.guid); } } 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()); } } // Detect player's own corpse object so we have the position even when // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). if (block.objectType == ObjectType::CORPSE && block.hasMovement) { // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) uint16_t ownerLowIdx = 6; auto ownerLowIt = block.fields.find(ownerLowIdx); uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; auto ownerHighIt = block.fields.find(ownerLowIdx + 1); uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { // Server coords from movement block corpseGuid_ = block.guid; corpseX_ = block.x; corpseY_ = block.y; corpseZ_ = block.z; corpseMapId_ = currentMapId_; LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, " server=(", block.x, ", ", block.y, ", ", block.z, ") map=", corpseMapId_); } } // 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)); const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end(); auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end(); auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); 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; if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->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 ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); 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) }; const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); 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) { uint64_t oldMoney = playerMoneyCopper_; playerMoneyCopper_ = val; LOG_DEBUG("Money set from update fields: ", val, " copper"); if (val != oldMoney && addonEventCallback_) addonEventCallback_("PLAYER_MONEY", {}); } else if (ufHonor != 0xFFFF && key == ufHonor) { playerHonorPoints_ = val; LOG_DEBUG("Honor points from update fields: ", val); } else if (ufArena != 0xFFFF && key == ufArena) { playerArenaPoints_ = val; LOG_DEBUG("Arena points from update fields: ", val); } else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); } else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { playerResistances_[key - ufArmor - 1] = static_cast(val); } 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 if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { chosenTitleBit_ = static_cast(val); LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); } else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); } else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); } else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { playerCombatRatings_[key - ufRating1] = static_cast(val); } 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; bool healthChanged = false; bool powerChanged = 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); healthChanged = true; if (val == 0) { if (block.guid == autoAttackTarget) { stopAutoAttack(); } hostileAttackers_.erase(block.guid); if (block.guid == playerGuid) { playerDead_ = true; releasedSpirit_ = false; stopAutoAttack(); // Cache death position as corpse location. // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so // this is the primary source for canReclaimCorpse(). // movementInfo is canonical (x=north, y=west); corpseX_/Y_ // are raw server coords (x=west, y=north) — swap axes. corpseX_ = movementInfo.y; // canonical west = server X corpseY_ = movementInfo.x; // canonical north = server Y corpseZ_ = movementInfo.z; corpseMapId_ = currentMapId_; LOG_INFO("Player died! Corpse position cached at server=(", corpseX_, ",", corpseY_, ",", corpseZ_, ") map=", corpseMapId_); } if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && 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 || entity->getType() == ObjectType::PLAYER) && 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); healthChanged = true; } 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; corpseX_ = movementInfo.y; corpseY_ = movementInfo.x; corpseZ_ = movementInfo.z; corpseMapId_ = currentMapId_; LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); } else if (wasDead && !nowDead) { playerDead_ = false; releasedSpirit_ = false; selfResAvailable_ = false; LOG_INFO("Player resurrected (dynamic flags)"); } } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { 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); powerChanged = true; } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); powerChanged = true; } } // Fire UNIT_HEALTH / UNIT_POWER events for Lua addons if (addonEventCallback_ && (healthChanged || powerChanged)) { std::string unitId; if (block.guid == playerGuid) unitId = "player"; else if (block.guid == targetGuid) unitId = "target"; else if (block.guid == focusGuid) unitId = "focus"; else if (block.guid == petGuid_) unitId = "pet"; if (!unitId.empty()) { if (healthChanged) addonEventCallback_("UNIT_HEALTH", {unitId}); if (powerChanged) addonEventCallback_("UNIT_POWER", {unitId}); } } // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated if (block.guid == playerGuid && isClassicLikeExpansion()) { const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); if (ufAuras != 0xFFFF) { bool hasAuraUpdate = false; for (const auto& [fk, fv] : block.fields) { if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } } if (hasAuraUpdate) { playerAuras.clear(); playerAuras.resize(48); uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); const auto& allFields = entity->getFields(); for (int slot = 0; slot < 48; ++slot) { auto it = allFields.find(static_cast(ufAuras + slot)); if (it != allFields.end() && it->second != 0) { AuraSlot& a = playerAuras[slot]; a.spellId = it->second; // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags uint8_t aFlag = 0; if (ufAuraFlags != 0xFFFF) { auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); if (fit != allFields.end()) aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); } a.flags = aFlag; a.durationMs = -1; a.maxDurationMs = -1; a.casterGuid = playerGuid; a.receivedAtMs = nowMs; } } LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); } } } // 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"); } } bool isDeadNow = (unit->getHealth() == 0) || ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { npcDeathCallback_(block.guid); npcDeathNotified = true; } } 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 ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES); const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); 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) }; const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; LOG_DEBUG("XP updated: ", val); if (addonEventCallback_) addonEventCallback_("PLAYER_XP_UPDATE", {std::to_string(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) { uint64_t oldM = playerMoneyCopper_; playerMoneyCopper_ = val; LOG_DEBUG("Money updated via VALUES: ", val, " copper"); if (val != oldM && addonEventCallback_) addonEventCallback_("PLAYER_MONEY", {}); } else if (ufHonorV != 0xFFFF && key == ufHonorV) { playerHonorPoints_ = val; LOG_DEBUG("Honor points updated: ", val); } else if (ufArenaV != 0xFFFF && key == ufArenaV) { playerArenaPoints_ = val; LOG_DEBUG("Arena points updated: ", val); } else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); } else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { playerResistances_[key - ufArmor - 1] = static_cast(val); } else if (ufPBytesV != 0xFFFF && key == ufPBytesV) { // PLAYER_BYTES changed (barber shop, polymorph, etc.) // Update the Character struct so inventory preview refreshes for (auto& ch : characters) { if (ch.guid == playerGuid) { ch.appearanceBytes = val; break; } } if (appearanceChangedCallback_) appearanceChangedCallback_(); } else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { // Byte 0 (bits 0-7): facial hair / piercings uint8_t facialHair = static_cast(val & 0xFF); for (auto& ch : characters) { if (ch.guid == playerGuid) { ch.facialFeatures = facialHair; break; } } uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots), " facial=", static_cast(facialHair)); 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); if (appearanceChangedCallback_) appearanceChangedCallback_(); } else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { chosenTitleBit_ = static_cast(val); LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); } 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; selfResAvailable_ = false; corpseMapId_ = 0; // corpse reclaimed corpseGuid_ = 0; corpseReclaimAvailableMs_ = 0; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (addonEventCallback_) addonEventCallback_("PLAYER_ALIVE", {}); if (ghostStateCallback_) ghostStateCallback_(false); } if (addonEventCallback_) addonEventCallback_("PLAYER_FLAGS_CHANGED", {}); } else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); } else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); } else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { playerCombatRatings_[key - ufRating1V] = static_cast(val); } 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(); if (addonEventCallback_) addonEventCallback_("PLAYER_EQUIPMENT_CHANGED", {}); } 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); // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). // Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12). const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; const uint16_t itemPermEnchField = itemEnchBase; const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF; const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; 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) { const uint32_t prevDur = it->second.curDurability; it->second.curDurability = val; inventoryChanged = true; // Warn once when durability drops below 20% for an equipped item. const uint32_t maxDur = it->second.maxDurability; if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { // Check if this item is in an equip slot (not bag inventory). bool isEquipped = false; for (uint64_t slotGuid : equipSlotGuids_) { if (slotGuid == block.guid) { isEquipped = true; break; } } if (isEquipped) { std::string itemName; const auto* info = getItemInfo(it->second.entry); if (info) itemName = info->name; char buf[128]; if (!itemName.empty()) std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str()); else std::snprintf(buf, sizeof(buf), "An equipped item is about to break!"); addUIError(buf); addSystemChatMessage(buf); } } } } else if (key == itemMaxDurField && isItemInInventory) { if (it->second.maxDurability != val) { it->second.maxDurability = val; inventoryChanged = true; } } else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) { if (it->second.permanentEnchantId != val) { it->second.permanentEnchantId = val; inventoryChanged = true; } } else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) { if (it->second.temporaryEnchantId != val) { it->second.temporaryEnchantId = val; inventoryChanged = true; } } else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) { if (it->second.socketEnchantIds[0] != val) { it->second.socketEnchantIds[0] = val; inventoryChanged = true; } } else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) { if (it->second.socketEnchantIds[1] != val) { it->second.socketEnchantIds[1] = val; inventoryChanged = true; } } else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) { if (it->second.socketEnchantIds[2] != val) { it->second.socketEnchantIds[2] = 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 (addonEventCallback_) { addonEventCallback_("BAG_UPDATE", {}); addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"}); } } } 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) { // Convert transport offset from server → canonical coordinates glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); setPlayerOnTransport(block.transportGuid, canonicalOffset); 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; } } void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) { 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); // Capital cities and large raids can produce very large update packets. // The real WoW client handles up to ~10MB; 5MB covers all practical cases. if (decompressedSize == 0 || decompressedSize > 5 * 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); // Remove combat text entries referencing the destroyed entity so floating // damage numbers don't linger after the source/target despawns. combatText.erase( std::remove_if(combatText.begin(), combatText.end(), [&data](const CombatTextEntry& e) { return e.dstGuid == data.guid; }), combatText.end()); // Clean up unit cast state (cast bar) for the destroyed unit unitCastStates_.erase(data.guid); // Clean up cached auras unitAurasCache_.erase(data.guid); tabCycleStale = true; } 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; // Auto-reply if AFK or DND if (afkStatus_ && !data.senderName.empty()) { std::string reply = afkMessage_.empty() ? "Away from Keyboard" : afkMessage_; sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); } else if (dndStatus_ && !data.senderName.empty()) { std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_; sendChatMessage(ChatType::WHISPER, " " + reply, 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: addSystemChatMessage("You must be in the area to join '" + data.channelName + "'."); LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)"); break; case ChannelNotifyType::WRONG_PASSWORD: addSystemChatMessage("Wrong password for channel '" + data.channelName + "'."); break; case ChannelNotifyType::NOT_MEMBER: addSystemChatMessage("You are not in channel '" + data.channelName + "'."); break; case ChannelNotifyType::NOT_MODERATOR: addSystemChatMessage("You are not a moderator of '" + data.channelName + "'."); break; case ChannelNotifyType::MUTED: addSystemChatMessage("You are muted in channel '" + data.channelName + "'."); break; case ChannelNotifyType::BANNED: addSystemChatMessage("You are banned from channel '" + data.channelName + "'."); break; case ChannelNotifyType::THROTTLED: addSystemChatMessage("Channel '" + data.channelName + "' is throttled. Please wait."); break; case ChannelNotifyType::NOT_IN_LFG: addSystemChatMessage("You must be in a LFG queue to join '" + data.channelName + "'."); break; case ChannelNotifyType::PLAYER_KICKED: addSystemChatMessage("A player was kicked from '" + data.channelName + "'."); break; case ChannelNotifyType::PASSWORD_CHANGED: addSystemChatMessage("Password for '" + data.channelName + "' changed."); break; case ChannelNotifyType::OWNER_CHANGED: addSystemChatMessage("Owner of '" + data.channelName + "' changed."); break; case ChannelNotifyType::NOT_OWNER: addSystemChatMessage("You are not the owner of '" + data.channelName + "'."); break; case ChannelNotifyType::INVALID_NAME: addSystemChatMessage("Invalid channel name '" + data.channelName + "'."); break; case ChannelNotifyType::PLAYER_NOT_FOUND: addSystemChatMessage("Player not found."); break; case ChannelNotifyType::ANNOUNCEMENTS_ON: addSystemChatMessage("Channel '" + data.channelName + "': announcements enabled."); break; case ChannelNotifyType::ANNOUNCEMENTS_OFF: addSystemChatMessage("Channel '" + data.channelName + "': announcements disabled."); break; case ChannelNotifyType::MODERATION_ON: addSystemChatMessage("Channel '" + data.channelName + "' is now moderated."); break; case ChannelNotifyType::MODERATION_OFF: addSystemChatMessage("Channel '" + data.channelName + "' is no longer moderated."); break; case ChannelNotifyType::PLAYER_BANNED: addSystemChatMessage("A player was banned from '" + data.channelName + "'."); break; case ChannelNotifyType::PLAYER_UNBANNED: addSystemChatMessage("A player was unbanned from '" + data.channelName + "'."); break; case ChannelNotifyType::PLAYER_NOT_BANNED: addSystemChatMessage("That player is not banned from '" + data.channelName + "'."); break; case ChannelNotifyType::INVITE: addSystemChatMessage("You have been invited to join channel '" + data.channelName + "'."); break; case ChannelNotifyType::INVITE_WRONG_FACTION: case ChannelNotifyType::WRONG_FACTION: addSystemChatMessage("Wrong faction for channel '" + data.channelName + "'."); break; case ChannelNotifyType::NOT_MODERATED: addSystemChatMessage("Channel '" + data.channelName + "' is not moderated."); break; case ChannelNotifyType::PLAYER_INVITED: addSystemChatMessage("Player invited to channel '" + data.channelName + "'."); break; case ChannelNotifyType::PLAYER_INVITE_BANNED: addSystemChatMessage("That player is banned from '" + data.channelName + "'."); 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); } if (addonEventCallback_) addonEventCallback_("PLAYER_TARGET_CHANGED", {}); } void GameHandler::clearTarget() { if (targetGuid != 0) { LOG_INFO("Target cleared"); if (addonEventCallback_) addonEventCallback_("PLAYER_TARGET_CHANGED", {}); } 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 (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {}); if (guid != 0) { auto entity = entityManager.getEntity(guid); if (entity) { std::string name; auto unit = std::dynamic_pointer_cast(entity); if (unit && !unit->getName().empty()) { name = unit->getName(); } if (name.empty()) { auto nit = playerNameCache.find(guid); if (nit != playerNameCache.end()) name = nit->second; } if (name.empty()) name = "Unknown"; 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; if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {}); } void GameHandler::setMouseoverGuid(uint64_t guid) { if (mouseoverGuid_ != guid) { mouseoverGuid_ = guid; if (addonEventCallback_) addonEventCallback_("UPDATE_MOUSEOVER_UNIT", {}); } } 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) { auto unit = std::dynamic_pointer_cast(entity); if (unit && guid != playerGuid && unit->isHostile()) { 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); // WotLK: also query the player's achievement data so the inspect UI can display it if (isActiveExpansion("wotlk")) { auto achPkt = QueryInspectAchievementsPacket::build(targetGuid); socket->send(achPkt); } 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; logoutCountdown_ = 0.0f; 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::cancelFollow() { if (followTargetGuid_ == 0) { addSystemChatMessage("You are not following anyone."); return; } followTargetGuid_ = 0; addSystemChatMessage("You stop following."); } 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()) { auto nit = playerNameCache.find(duelChallengerGuid_); if (nit != playerNameCache.end()) duelChallengerName_ = nit->second; } 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!"); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playTargetSelect(); } LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_, " flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_); if (addonEventCallback_) addonEventCallback_("DUEL_REQUESTED", {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; duelCountdownMs_ = 0; // clear countdown once duel is resolved if (!started) { addSystemChatMessage("The duel was cancelled."); } LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast(started)); if (addonEventCallback_) addonEventCallback_("DUEL_FINISHED", {}); } void GameHandler::handleDuelWinner(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 3) return; uint8_t duelType = packet.readUInt8(); // 0=normal win, 1=opponent fled duel area std::string winner = packet.readString(); std::string loser = packet.readString(); std::string msg; if (duelType == 1) { msg = loser + " has fled from the duel. " + winner + " wins!"; } else { msg = winner + " has defeated " + loser + " in a duel!"; } addSystemChatMessage(msg); LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser, " type=", static_cast(duelType)); } 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::setRaidMark(uint64_t guid, uint8_t icon) { if (state != WorldState::IN_WORLD || !socket) return; static const char* kMarkNames[] = { "Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull" }; if (icon == 0xFF) { // Clear mark: find which slot this guid holds and send 0 GUID for (int i = 0; i < 8; ++i) { if (raidTargetGuids_[i] == guid) { auto packet = RaidTargetUpdatePacket::build(static_cast(i), 0); socket->send(packet); break; } } } else if (icon < 8) { auto packet = RaidTargetUpdatePacket::build(icon, guid); socket->send(packet); LOG_INFO("Set raid mark %s on guid %llu", kMarkNames[icon], (unsigned long long)guid); } } 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 and clear any queued spell so it doesn't fire later casting = false; castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; 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); // Do NOT set releasedSpirit_ = true here. Setting it optimistically races // with PLAYER_FLAGS field updates that arrive before the server processes // CMSG_REPOP_REQUEST: the PLAYER_FLAGS handler sees wasGhost=true/nowGhost=false // and fires the "ghost cleared" path, wiping corpseMapId_/corpseGuid_. // Let the server drive ghost state via PLAYER_FLAGS_GHOST (field update path). selfResAvailable_ = false; // self-res window closes when spirit is released repopPending_ = true; lastRepopRequestMs_ = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); } } bool GameHandler::canReclaimCorpse() const { // Need: ghost state + corpse object GUID (required by CMSG_RECLAIM_CORPSE) + // corpse map known + same map + within 40 yards. if (!releasedSpirit_ || corpseGuid_ == 0 || corpseMapId_ == 0) return false; if (currentMapId_ != corpseMapId_) return false; // movementInfo.x/y are canonical (x=north=server_y, y=west=server_x). // corpseX_/Y_ are raw server coords (x=west, y=north). float dx = movementInfo.x - corpseY_; // canonical north - server.y float dy = movementInfo.y - corpseX_; // canonical west - server.x float dz = movementInfo.z - corpseZ_; return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } float GameHandler::getCorpseReclaimDelaySec() const { if (corpseReclaimAvailableMs_ == 0) return 0.0f; auto nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); if (nowMs >= corpseReclaimAvailableMs_) return 0.0f; return static_cast(corpseReclaimAvailableMs_ - nowMs) / 1000.0f; } void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; // CMSG_RECLAIM_CORPSE requires the corpse object's own GUID. // Servers look up the corpse by this GUID; sending the player GUID silently fails. if (corpseGuid_ == 0) { LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim"); return; } auto packet = ReclaimCorpsePacket::build(corpseGuid_); socket->send(packet); LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec); } void GameHandler::useSelfRes() { if (!selfResAvailable_ || !socket) return; // CMSG_SELF_RES: empty body — server confirms resurrection via SMSG_UPDATE_OBJECT. network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES)); socket->send(pkt); selfResAvailable_ = false; LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)"); } 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(); } if (addonChatCallback_) addonChatCallback_(msg); } // ============================================================ // 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; // Cache class/race from name query for UnitClass/UnitRace fallback if (data.classId != 0 || data.race != 0) { playerClassRaceCache_[data.guid] = {data.classId, data.race}; } // 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; } // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available if (addonEventCallback_) { std::string unitId; if (data.guid == targetGuid) unitId = "target"; else if (data.guid == focusGuid) unitId = "focus"; else if (data.guid == playerGuid) unitId = "player"; if (!unitId.empty()) addonEventCallback_("UNIT_NAME_UPDATE", {unitId}); } } } 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) { bookPages_.clear(); // start a fresh book for this interaction 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.isValid()) return; // Append page if not already collected bool alreadyHave = false; for (const auto& bp : bookPages_) { if (bp.pageId == data.pageId) { alreadyHave = true; break; } } if (!alreadyHave) { bookPages_.push_back({data.pageId, data.text}); } // Follow the chain: if there's a next page we haven't fetched yet, request it if (data.nextPageId != 0) { bool nextHave = false; for (const auto& bp : bookPages_) { if (bp.pageId == data.nextPageId) { nextHave = true; break; } } if (!nextHave && socket && state == WorldState::IN_WORLD) { auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid); socket->send(req); } } LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, " nextPage=", data.nextPageId, " totalPages=", bookPages_.size()); } // ============================================================ // 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(); // Flush any deferred loot notifications waiting on this item's name/quality. for (auto it = pendingItemPushNotifs_.begin(); it != pendingItemPushNotifs_.end(); ) { if (it->itemId == data.entry) { std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name; std::string link = buildItemLink(data.entry, data.quality, itemName); std::string msg = "Received: " + link; if (it->count > 1) msg += " x" + std::to_string(it->count); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playLootItem(); } if (itemLootCallback_) itemLootCallback_(data.entry, it->count, data.quality, itemName); it = pendingItemPushNotifs_.erase(it); } else { ++it; } } // 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 (type 0): uint32 unspentTalents, uint8 groupCount, uint8 activeGroup // Per group: uint8 talentCount, [talentId(4)+rank(1)]..., uint8 glyphCount, [glyphId(2)]... if (packet.getSize() - packet.getReadPos() < 6) { LOG_DEBUG("SMSG_TALENTS_INFO type=0: too short"); return; } uint32_t unspentTalents = packet.readUInt32(); uint8_t talentGroupCount = packet.readUInt8(); uint8_t activeTalentGroup = packet.readUInt8(); if (activeTalentGroup > 1) activeTalentGroup = 0; activeTalentSpec_ = activeTalentGroup; for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t talentCount = packet.readUInt8(); learnedTalents_[g].clear(); for (uint8_t t = 0; t < talentCount; ++t) { if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t talentId = packet.readUInt32(); uint8_t rank = packet.readUInt8(); learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed } if (packet.getSize() - packet.getReadPos() < 1) break; learnedGlyphs_[g].fill(0); uint8_t glyphCount = packet.readUInt8(); for (uint8_t gl = 0; gl < glyphCount; ++gl) { if (packet.getSize() - packet.getReadPos() < 2) break; uint16_t glyphId = packet.readUInt16(); if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; } } unspentTalentPoints_[activeTalentGroup] = static_cast( unspentTalents > 255 ? 255 : unspentTalents); if (!talentsInitialized_) { talentsInitialized_ = true; if (unspentTalents > 0) { addSystemChatMessage("You have " + std::to_string(unspentTalents) + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); } } LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents, " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, " learned=", learnedTalents_[activeTalentGroup].size()); 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 std::array enchantIds{}; 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; enchantIds[slot] = packet.readUInt16(); } } } // Store inspect result for UI display inspectResult_.guid = guid; inspectResult_.playerName = playerName; inspectResult_.totalTalents = totalTalents; inspectResult_.unspentTalents = unspentTalents; inspectResult_.talentGroups = talentGroupCount; inspectResult_.activeTalentGroup = activeTalentGroup; inspectResult_.enchantIds = enchantIds; // Merge any gear we already have from a prior inspect request auto gearIt = inspectedPlayerItemEntries_.find(guid); if (gearIt != inspectedPlayerItemEntries_.end()) { inspectResult_.itemEntries = gearIt->second; } else { inspectResult_.itemEntries = {}; } LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ", unspentTalents, " unspent, ", (int)talentGroupCount, " specs"); if (addonEventCallback_) { char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); addonEventCallback_("INSPECT_READY", {guidBuf}); } } 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)); 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; } int keyringBase = static_cast(fieldIndex(UF::PLAYER_FIELD_KEYRING_SLOT_1)); if (keyringBase == 0xFFFF && bankBagBase != 0xFFFF) { // Layout fallback for profiles that don't define PLAYER_FIELD_KEYRING_SLOT_1. // Bank bag slots are followed by 12 vendor buyback slots (24 fields), then keyring. keyringBase = bankBagBase + (effectiveBankBagSlots_ * 2) + 24; } 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; } } else if (keyringBase != 0xFFFF && key >= keyringBase && key <= keyringBase + (game::Inventory::KEYRING_SLOTS * 2 - 1)) { int slotIndex = (key - keyringBase) / 2; bool isLow = ((key - keyringBase) % 2 == 0); if (slotIndex < static_cast(keyringSlotGuids_.size())) { uint64_t& guid = keyringSlotGuids_[slotIndex]; if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); slotsChanged = true; } } 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); } // Keyring slots for (int i = 0; i < game::Inventory::KEYRING_SLOTS; i++) { uint64_t guid = keyringSlotGuids_[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.setKeyringSlot(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; }(), " keyring=", [&](){ int c = 0; for (auto g : keyringSlotGuids_) 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; autoAttackRetryPending_ = 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; autoAttackRetryPending_ = 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"); if (addonEventCallback_) addonEventCallback_("PLAYER_LEAVE_COMBAT", {}); } void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, uint64_t srcGuid, uint64_t dstGuid) { CombatTextEntry entry; entry.type = type; entry.amount = amount; entry.spellId = spellId; entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; entry.powerType = powerType; entry.srcGuid = srcGuid; entry.dstGuid = dstGuid; // Random horizontal stagger so simultaneous hits don't stack vertically static std::mt19937 rng(std::random_device{}()); std::uniform_real_distribution dist(-1.0f, 1.0f); entry.xSeed = dist(rng); combatText.push_back(entry); // Persistent combat log — use explicit GUIDs if provided, else fall back to // player/current-target (the old behaviour for events without specific participants). CombatLogEntry log; log.type = type; log.amount = amount; log.spellId = spellId; log.isPlayerSource = isPlayerSource; log.powerType = powerType; log.timestamp = std::time(nullptr); // If the caller provided an explicit destination GUID but left source GUID as 0, // preserve "unknown/no source" (e.g. environmental damage) instead of // backfilling from current target. uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid : ((dstGuid != 0) ? 0 : (isPlayerSource ? playerGuid : targetGuid)); uint64_t effectiveDst = (dstGuid != 0) ? dstGuid : (isPlayerSource ? targetGuid : playerGuid); log.sourceName = lookupName(effectiveSrc); log.targetName = (effectiveDst != 0) ? lookupName(effectiveDst) : std::string{}; if (combatLog_.size() >= MAX_COMBAT_LOG) combatLog_.pop_front(); combatLog_.push_back(std::move(log)); // Fire COMBAT_LOG_EVENT_UNFILTERED for Lua addons // Args: subevent, sourceGUID, sourceName, 0 (sourceFlags), destGUID, destName, 0 (destFlags), spellId, spellName, amount if (addonEventCallback_) { static const char* kSubevents[] = { "SWING_DAMAGE", "SPELL_DAMAGE", "SPELL_HEAL", "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SPELL_DAMAGE", "SPELL_HEAL", "SPELL_PERIODIC_DAMAGE", "SPELL_PERIODIC_HEAL", "ENVIRONMENTAL_DAMAGE", "SPELL_ENERGIZE", "SPELL_DRAIN", "PARTY_KILL", "SPELL_MISSED", "SPELL_ABSORBED", "SPELL_MISSED", "SPELL_MISSED", "SPELL_MISSED", "SPELL_AURA_APPLIED", "SPELL_DISPEL", "SPELL_STOLEN", "SPELL_INTERRUPT", "SPELL_INSTAKILL", "PARTY_KILL", "SWING_DAMAGE", "SWING_DAMAGE" }; const char* subevent = (type < sizeof(kSubevents)/sizeof(kSubevents[0])) ? kSubevents[type] : "UNKNOWN"; char srcBuf[32], dstBuf[32]; snprintf(srcBuf, sizeof(srcBuf), "0x%016llX", (unsigned long long)effectiveSrc); snprintf(dstBuf, sizeof(dstBuf), "0x%016llX", (unsigned long long)effectiveDst); std::string spellName = (spellId != 0) ? getSpellName(spellId) : std::string{}; std::string timestamp = std::to_string(static_cast(std::time(nullptr))); addonEventCallback_("COMBAT_LOG_EVENT_UNFILTERED", { timestamp, subevent, srcBuf, log.sourceName, "0", dstBuf, log.targetName, "0", std::to_string(spellId), spellName, std::to_string(amount) }); } } bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { if (spellId == 0) return false; const auto now = std::chrono::steady_clock::now(); constexpr auto kRecentWindow = std::chrono::seconds(1); while (!recentSpellstealLogs_.empty() && now - recentSpellstealLogs_.front().timestamp > kRecentWindow) { recentSpellstealLogs_.pop_front(); } for (auto it = recentSpellstealLogs_.begin(); it != recentSpellstealLogs_.end(); ++it) { if (it->casterGuid == casterGuid && it->victimGuid == victimGuid && it->spellId == spellId) { recentSpellstealLogs_.erase(it); return false; } } if (recentSpellstealLogs_.size() >= MAX_RECENT_SPELLSTEAL_LOGS) recentSpellstealLogs_.pop_front(); recentSpellstealLogs_.push_back({casterGuid, victimGuid, spellId, now}); return true; } 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; autoAttackRetryPending_ = false; autoAttackTarget = data.victimGuid; if (addonEventCallback_) addonEventCallback_("PLAYER_ENTER_COMBAT", {}); } 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; autoAttackRetryPending_ = autoAttackRequested_; autoAttackResendTimer_ = 0.0f; LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)"); } 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(); // Map BG type IDs to their names (stable across all three expansions) // BattlemasterList.dbc IDs (3.3.5a) static const std::pair kBgNames[] = { {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, {4, "Nagrand Arena"}, {5, "Blade's Edge Arena"}, {6, "All Arenas"}, {7, "Eye of the Storm"}, {8, "Ruins of Lordaeron"}, {9, "Strand of the Ancients"}, {10, "Dalaran Sewers"}, {11, "Ring of Valor"}, {30, "Isle of Conquest"}, {32, "Random Battleground"}, }; std::string bgName = "Battleground"; for (const auto& kv : kBgNames) { if (kv.first == bgTypeId) { bgName = kv.second; break; } } if (bgName == "Battleground") bgName = "Battleground #" + std::to_string(bgTypeId); if (arenaType > 0) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; // If bgTypeId matches a named arena, prefer that name for (const auto& kv : kBgNames) { if (kv.first == bgTypeId) { bgName += " (" + std::string(kv.second) + ")"; break; } } } // Parse status-specific fields uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) uint32_t avgWaitSec = 0, timeInQueueSec = 0; if (statusId == 1) { // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) if (packet.getSize() - packet.getReadPos() >= 8) { avgWaitSec = packet.readUInt32() / 1000; // ms → seconds timeInQueueSec = packet.readUInt32() / 1000; } } 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; bgQueues_[queueSlot].bgName = bgName; if (statusId == 1) { bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec; bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec; } 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; } if (addonEventCallback_) addonEventCallback_("UPDATE_BATTLEFIELD_STATUS", {std::to_string(statusId)}); } void GameHandler::handleBattlefieldList(network::Packet& packet) { // SMSG_BATTLEFIELD_LIST wire format by expansion: // // Classic 1.12 (vmangos/cmangos): // bgTypeId(4) isRegistered(1) count(4) [instanceId(4)...] // // TBC 2.4.3: // bgTypeId(4) isRegistered(1) isHoliday(1) count(4) [instanceId(4)...] // // WotLK 3.3.5a: // bgTypeId(4) isRegistered(1) isHoliday(1) minLevel(4) maxLevel(4) count(4) [instanceId(4)...] if (packet.getSize() - packet.getReadPos() < 5) return; AvailableBgInfo info; info.bgTypeId = packet.readUInt32(); info.isRegistered = packet.readUInt8() != 0; const bool isWotlk = isActiveExpansion("wotlk"); const bool isTbc = isActiveExpansion("tbc"); if (isTbc || isWotlk) { if (packet.getSize() - packet.getReadPos() < 1) return; info.isHoliday = packet.readUInt8() != 0; } if (isWotlk) { if (packet.getSize() - packet.getReadPos() < 8) return; info.minLevel = packet.readUInt32(); info.maxLevel = packet.readUInt32(); } if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t count = packet.readUInt32(); // Sanity cap to avoid OOM from malformed packets constexpr uint32_t kMaxInstances = 256; count = std::min(count, kMaxInstances); info.instanceIds.reserve(count); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < 4) break; info.instanceIds.push_back(packet.readUInt32()); } // Update or append the entry for this BG type bool updated = false; for (auto& existing : availableBgs_) { if (existing.bgTypeId == info.bgTypeId) { existing = std::move(info); updated = true; break; } } if (!updated) { availableBgs_.push_back(std::move(info)); } const auto& stored = availableBgs_.back(); static const std::unordered_map kBgNames = { {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, {4, "Nagrand Arena"}, {5, "Blade's Edge Arena"}, {6, "All Arenas"}, {7, "Eye of the Storm"}, {8, "Ruins of Lordaeron"}, {9, "Strand of the Ancients"}, {10, "Dalaran Sewers"}, {11, "The Ring of Valor"}, {30, "Isle of Conquest"}, }; auto nameIt = kBgNames.find(stored.bgTypeId); const char* bgName = (nameIt != kBgNames.end()) ? nameIt->second : "Unknown Battleground"; LOG_INFO("SMSG_BATTLEFIELD_LIST: ", bgName, " bgType=", stored.bgTypeId, " registered=", stored.isRegistered ? "yes" : "no", " instances=", stored.instanceIds.size()); } 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; uint32_t prevDifficulty = instanceDifficulty_; 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); } inInstance_ = true; LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); // Announce difficulty change to the player (only when it actually changes) // difficulty values: 0=Normal, 1=Heroic, 2=25-Man Normal, 3=25-Man Heroic if (instanceDifficulty_ != prevDifficulty) { static const char* kDiffLabels[] = {"Normal", "Heroic", "25-Man Normal", "25-Man Heroic"}; const char* diffLabel = (instanceDifficulty_ < 4) ? kDiffLabels[instanceDifficulty_] : nullptr; if (diffLabel) addSystemChatMessage(std::string("Dungeon difficulty set to ") + diffLabel + "."); } } // --------------------------------------------------------------------------- // 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)); { std::string dName = getLfgDungeonName(lfgDungeonId_); if (!dName.empty()) addSystemChatMessage("Dungeon Finder: Joined the queue for " + dName + "."); else addSystemChatMessage("Dungeon Finder: Joined the queue."); } } else { const char* msg = lfgJoinResultString(result); std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); addUIError(errMsg); 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; addUIError("Dungeon Finder: Group proposal failed."); addSystemChatMessage("Dungeon Finder: Group proposal failed."); break; case 1: { lfgState_ = LfgState::InDungeon; lfgProposalId_ = 0; std::string dName = getLfgDungeonName(dungeonId); if (!dName.empty()) addSystemChatMessage("Dungeon Finder: Group found for " + dName + "! Entering dungeon..."); else addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); break; } case 2: { lfgState_ = LfgState::Proposal; std::string dName = getLfgDungeonName(dungeonId); if (!dName.empty()) addSystemChatMessage("Dungeon Finder: A group has been found for " + dName + ". Accept or decline."); else 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; addUIError("Dungeon Finder: Role check failed — missing required role."); 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) { if (!packetHasRemaining(packet, 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 (packetHasRemaining(packet, 4)) { uint32_t rewardCount = packet.readUInt32(); for (uint32_t i = 0; i < rewardCount && packetHasRemaining(packet, 9); ++i) { uint32_t itemId = packet.readUInt32(); uint32_t itemCount = packet.readUInt32(); packet.readUInt8(); // unk if (i == 0) { std::string itemLabel = "item #" + std::to_string(itemId); uint32_t lfgItemQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; lfgItemQuality = info->quality; } rewardMsg += ", " + buildItemLink(itemId, lfgItemQuality, itemLabel); 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) { if (!packetHasRemaining(packet, 7 + 4 + 4 + 4 + 4)) return; bool inProgress = packet.readUInt8() != 0; /*bool myVote =*/ packet.readUInt8(); // whether local player has voted /*bool myAnswer =*/ packet.readUInt8(); // local player's vote (yes/no) — unused; result derived from counts uint32_t totalVotes = packet.readUInt32(); uint32_t bootVotes = packet.readUInt32(); uint32_t timeLeft = packet.readUInt32(); uint32_t votesNeeded = packet.readUInt32(); lfgBootVotes_ = bootVotes; lfgBootTotal_ = totalVotes; lfgBootTimeLeft_ = timeLeft; lfgBootNeeded_ = votesNeeded; // Optional: reason string and target name (null-terminated) follow the fixed fields if (packet.getReadPos() < packet.getSize()) lfgBootReason_ = packet.readString(); if (packet.getReadPos() < packet.getSize()) lfgBootTargetName_ = packet.readString(); if (inProgress) { lfgState_ = LfgState::Boot; } else { // Boot vote ended — pass/fail determined by whether enough yes votes were cast, // not by the local player's own vote (myAnswer = what *I* voted, not the result). const bool bootPassed = (bootVotes >= votesNeeded); lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; lfgBootTargetName_.clear(); lfgBootReason_.clear(); lfgState_ = LfgState::InDungeon; if (bootPassed) { 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, " target=", lfgBootTargetName_, " reason=", lfgBootReason_); } 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::lfgSetRoles(uint8_t roles) { if (state != WorldState::IN_WORLD || !socket) return; const uint32_t wire = wireOpcode(Opcode::CMSG_LFG_SET_ROLES); if (wire == 0xFFFF) return; network::Packet pkt(static_cast(wire)); pkt.writeUInt8(roles); socket->send(pkt); LOG_INFO("Sent CMSG_LFG_SET_ROLES: roles=", static_cast(roles)); } 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(); uint32_t teamType = 0; if (packet.getSize() - packet.getReadPos() >= 4) teamType = packet.readUInt32(); LOG_INFO("Arena team query response: id=", teamId, " name=", teamName, " type=", teamType); // Store name and type in matching ArenaTeamStats entry for (auto& s : arenaTeamStats_) { if (s.teamId == teamId) { s.teamName = teamName; s.teamType = teamType; return; } } // No stats entry yet — create a placeholder so we can show the name ArenaTeamStats stub; stub.teamId = teamId; stub.teamName = teamName; stub.teamType = teamType; arenaTeamStats_.push_back(std::move(stub)); } void GameHandler::handleArenaTeamRoster(network::Packet& packet) { // SMSG_ARENA_TEAM_ROSTER (WotLK 3.3.5a): // uint32 teamId // uint8 unk (0 = not captainship packet) // uint32 memberCount // For each member: // uint64 guid // uint8 online (1=online, 0=offline) // string name (null-terminated) // uint32 gamesWeek // uint32 winsWeek // uint32 gamesSeason // uint32 winsSeason // uint32 personalRating // float modDay (unused here) // float modWeek (unused here) if (packet.getSize() - packet.getReadPos() < 9) return; uint32_t teamId = packet.readUInt32(); /*uint8_t unk =*/ packet.readUInt8(); uint32_t memberCount = packet.readUInt32(); // Sanity cap to avoid huge allocations from malformed packets if (memberCount > 100) memberCount = 100; ArenaTeamRoster roster; roster.teamId = teamId; roster.members.reserve(memberCount); for (uint32_t i = 0; i < memberCount; ++i) { if (packet.getSize() - packet.getReadPos() < 12) break; ArenaTeamMember m; m.guid = packet.readUInt64(); m.online = (packet.readUInt8() != 0); m.name = packet.readString(); if (packet.getSize() - packet.getReadPos() < 20) break; m.weekGames = packet.readUInt32(); m.weekWins = packet.readUInt32(); m.seasonGames = packet.readUInt32(); m.seasonWins = packet.readUInt32(); m.personalRating = packet.readUInt32(); // skip 2 floats (modDay, modWeek) if (packet.getSize() - packet.getReadPos() >= 8) { packet.readFloat(); packet.readFloat(); } roster.members.push_back(std::move(m)); } // Replace existing roster for this team or append for (auto& r : arenaTeamRosters_) { if (r.teamId == teamId) { r = std::move(roster); LOG_INFO("SMSG_ARENA_TEAM_ROSTER: updated teamId=", teamId, " members=", r.members.size()); return; } } LOG_INFO("SMSG_ARENA_TEAM_ROSTER: new teamId=", teamId, " members=", roster.members.size()); arenaTeamRosters_.push_back(std::move(roster)); } 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(); // 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(); // Build natural-language message based on event type // Event params: 0=joined(name), 1=left(name), 2=removed(name,kicker), // 3=leader_changed(new,old), 4=disbanded, 5=created(name) std::string msg; switch (event) { case 0: // joined msg = param1.empty() ? "A player has joined your arena team." : param1 + " has joined your arena team."; break; case 1: // left msg = param1.empty() ? "A player has left the arena team." : param1 + " has left the arena team."; break; case 2: // removed if (!param1.empty() && !param2.empty()) msg = param1 + " has been removed from the arena team by " + param2 + "."; else if (!param1.empty()) msg = param1 + " has been removed from the arena team."; else msg = "A player has been removed from the arena team."; break; case 3: // leader changed msg = param1.empty() ? "The arena team captain has changed." : param1 + " is now the arena team captain."; break; case 4: // disbanded msg = "Your arena team has been disbanded."; break; case 5: // created msg = param1.empty() ? "Your arena team has been created." : "Arena team \"" + param1 + "\" has been created."; break; default: msg = "Arena team event " + std::to_string(event); if (!param1.empty()) msg += ": " + param1; break; } addSystemChatMessage(msg); LOG_INFO("Arena team event: ", (int)event, " ", param1, " ", param2); } void GameHandler::handleArenaTeamStats(network::Packet& packet) { // SMSG_ARENA_TEAM_STATS (WotLK 3.3.5a): // uint32 teamId, uint32 rating, uint32 weekGames, uint32 weekWins, // uint32 seasonGames, uint32 seasonWins, uint32 rank if (packet.getSize() - packet.getReadPos() < 28) return; ArenaTeamStats stats; stats.teamId = packet.readUInt32(); stats.rating = packet.readUInt32(); stats.weekGames = packet.readUInt32(); stats.weekWins = packet.readUInt32(); stats.seasonGames = packet.readUInt32(); stats.seasonWins = packet.readUInt32(); stats.rank = packet.readUInt32(); // Update or insert for this team (preserve name/type from query response) for (auto& s : arenaTeamStats_) { if (s.teamId == stats.teamId) { stats.teamName = std::move(s.teamName); stats.teamType = s.teamType; s = std::move(stats); LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", s.teamId, " rating=", s.rating, " rank=", s.rank); return; } } arenaTeamStats_.push_back(std::move(stats)); LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", arenaTeamStats_.back().teamId, " rating=", arenaTeamStats_.back().rating, " rank=", arenaTeamStats_.back().rank); } void GameHandler::requestArenaTeamRoster(uint32_t teamId) { if (!socket) return; network::Packet pkt(wireOpcode(Opcode::CMSG_ARENA_TEAM_ROSTER)); pkt.writeUInt32(teamId); socket->send(pkt); LOG_INFO("Requesting arena team roster for teamId=", teamId); } 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::requestPvpLog() { if (state != WorldState::IN_WORLD || !socket) return; // MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA)); socket->send(pkt); LOG_INFO("Requested PvP log data"); } void GameHandler::handlePvpLogData(network::Packet& packet) { auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; if (remaining() < 1) return; bgScoreboard_ = BgScoreboardData{}; bgScoreboard_.isArena = (packet.readUInt8() != 0); if (bgScoreboard_.isArena) { // WotLK 3.3.5a MSG_PVP_LOG_DATA arena header: // two team blocks × (uint32 ratingChange + uint32 newRating + uint32 unk1 + uint32 unk2 + uint32 unk3 + CString teamName) // After both team blocks: same player list and winner fields as battleground. for (int t = 0; t < 2; ++t) { if (remaining() < 20) { packet.setReadPos(packet.getSize()); return; } bgScoreboard_.arenaTeams[t].ratingChange = packet.readUInt32(); bgScoreboard_.arenaTeams[t].newRating = packet.readUInt32(); packet.readUInt32(); // unk1 packet.readUInt32(); // unk2 packet.readUInt32(); // unk3 bgScoreboard_.arenaTeams[t].teamName = remaining() > 0 ? packet.readString() : ""; } // Fall through to parse player list and winner fields below (same layout as BG) } if (remaining() < 4) return; uint32_t playerCount = packet.readUInt32(); bgScoreboard_.players.reserve(playerCount); for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) { BgPlayerScore ps; ps.guid = packet.readUInt64(); ps.team = packet.readUInt8(); ps.killingBlows = packet.readUInt32(); ps.honorableKills = packet.readUInt32(); ps.deaths = packet.readUInt32(); ps.bonusHonor = packet.readUInt32(); // Resolve player name from entity manager { auto ent = entityManager.getEntity(ps.guid); if (ent && (ent->getType() == game::ObjectType::PLAYER || ent->getType() == game::ObjectType::UNIT)) { auto u = std::static_pointer_cast(ent); if (!u->getName().empty()) ps.name = u->getName(); } } // BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value) if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; } uint32_t statCount = packet.readUInt32(); for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) { std::string fieldName; while (remaining() > 0) { char c = static_cast(packet.readUInt8()); if (c == '\0') break; fieldName += c; } uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0; ps.bgStats.emplace_back(std::move(fieldName), val); } bgScoreboard_.players.push_back(std::move(ps)); } if (remaining() >= 1) { bgScoreboard_.hasWinner = (packet.readUInt8() != 0); if (bgScoreboard_.hasWinner && remaining() >= 1) bgScoreboard_.winner = packet.readUInt8(); } if (bgScoreboard_.isArena) { LOG_INFO("Arena log: ", bgScoreboard_.players.size(), " players, hasWinner=", bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner, " team0='", bgScoreboard_.arenaTeams[0].teamName, "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[0].ratingChange, " team1='", bgScoreboard_.arenaTeams[1].teamName, "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[1].ratingChange); } else { LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); } } void GameHandler::handleMoveSetSpeed(network::Packet& packet) { // MSG_MOVE_SET_*_SPEED: PackedGuid (WotLK) / full uint64 (Classic/TBC) + MovementInfo + float speed. // The MovementInfo block is variable-length; rather than fully parsing it, we read the // fixed prefix, skip over optional blocks by consuming remaining bytes until 4 remain, // then read the speed float. This is safe because the speed is always the last field. const bool useFull = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t moverGuid = useFull ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); // Skip to the last 4 bytes — the speed float — by advancing past the MovementInfo. // This avoids duplicating the full variable-length MovementInfo parser here. const size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 4) return; if (remaining > 4) { // Advance past all MovementInfo bytes (flags, time, position, optional blocks). // Speed is always the last 4 bytes in the packet. packet.setReadPos(packet.getSize() - 4); } float speed = packet.readFloat(); if (!std::isfinite(speed) || speed <= 0.01f || speed > 200.0f) return; // Update local player speed state if this broadcast targets us. if (moverGuid != playerGuid) return; const uint16_t wireOp = packet.getOpcode(); if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_SPEED)) serverRunSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_BACK_SPEED)) serverRunBackSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_WALK_SPEED)) serverWalkSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_SPEED)) serverSwimSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED)) serverSwimBackSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_SPEED)) serverFlightSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED))serverFlightBackSpeed_= speed; } 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(); // Read transport data if the on-transport flag is set in wire-format move flags. // The flag bit position differs between expansions (0x200 for WotLK/TBC, 0x02000000 for Classic/Turtle). const uint32_t wireTransportFlag = packetParsers_ ? packetParsers_->wireOnTransportFlag() : 0x00000200; const bool onTransport = (info.flags & wireTransportFlag) != 0; uint64_t transportGuid = 0; float tLocalX = 0, tLocalY = 0, tLocalZ = 0, tLocalO = 0; if (onTransport) { transportGuid = UpdateObjectParser::readPackedGuid(packet); tLocalX = packet.readFloat(); tLocalY = packet.readFloat(); tLocalZ = packet.readFloat(); tLocalO = packet.readFloat(); // TBC and WotLK include a transport timestamp; Classic does not. if (flags2Size >= 1) { /*uint32_t transportTime =*/ packet.readUInt32(); } // WotLK adds a transport seat byte. if (flags2Size >= 2) { /*int8_t transportSeat =*/ packet.readUInt8(); // Optional second transport time for interpolated movement. if (info.flags2 & 0x0200) { /*uint32_t transportTime2 =*/ packet.readUInt32(); } } } // 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); // Handle transport attachment: attach/detach the entity so it follows the transport // smoothly between movement updates via updateAttachedTransportChildren(). if (onTransport && transportGuid != 0 && transportManager_) { glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(tLocalX, tLocalY, tLocalZ)); setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, true, core::coords::serverToCanonicalYaw(tLocalO)); // Derive world position from transport system for best accuracy. glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); canonical = worldPos; } else if (!onTransport) { // Player left transport — clear any stale attachment. clearTransportAttachment(moverGuid); } // 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-family SMSG_COMPRESSED_MOVES carries concatenated movement sub-packets. // Turtle can additionally wrap the batch in the same uint32 decompressedSize + zlib // envelope used by other compressed world packets. // // Within the decompressed stream, some realms encode the leading uint8 size as: // - opcode(2) + payload bytes // - payload bytes only // Try both framing modes and use the one that cleanly consumes the batch. std::vector decompressedStorage; const std::vector* dataPtr = &packet.getData(); const auto& rawData = packet.getData(); const bool hasCompressedWrapper = rawData.size() >= 6 && rawData[4] == 0x78 && (rawData[5] == 0x01 || rawData[5] == 0x9C || rawData[5] == 0xDA || rawData[5] == 0x5E); if (hasCompressedWrapper) { uint32_t decompressedSize = static_cast(rawData[0]) | (static_cast(rawData[1]) << 8) | (static_cast(rawData[2]) << 16) | (static_cast(rawData[3]) << 24); if (decompressedSize == 0 || decompressedSize > 65536) { LOG_WARNING("SMSG_COMPRESSED_MOVES: bad decompressedSize=", decompressedSize); return; } decompressedStorage.resize(decompressedSize); uLongf destLen = decompressedSize; int ret = uncompress(decompressedStorage.data(), &destLen, rawData.data() + 4, rawData.size() - 4); if (ret != Z_OK) { LOG_WARNING("SMSG_COMPRESSED_MOVES: zlib error ", ret); return; } decompressedStorage.resize(destLen); dataPtr = &decompressedStorage; } const auto& data = *dataPtr; const 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), }; struct CompressedMoveSubPacket { uint16_t opcode = 0; std::vector payload; }; struct DecodeResult { bool ok = false; bool overrun = false; bool usedPayloadOnlySize = false; size_t endPos = 0; size_t recognizedCount = 0; size_t subPacketCount = 0; std::vector packets; }; auto isRecognizedSubOpcode = [&](uint16_t subOpcode) { return subOpcode == monsterMoveWire || subOpcode == monsterMoveTransportWire || std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end(); }; auto decodeSubPackets = [&](bool payloadOnlySize) -> DecodeResult { DecodeResult result; result.usedPayloadOnlySize = payloadOnlySize; size_t pos = 0; while (pos < dataLen) { if (pos + 1 > dataLen) break; uint8_t subSize = data[pos]; if (subSize == 0) { result.ok = true; result.endPos = pos + 1; return result; } const size_t payloadLen = payloadOnlySize ? static_cast(subSize) : (subSize >= 2 ? static_cast(subSize) - 2 : 0); if (!payloadOnlySize && subSize < 2) { result.endPos = pos; return result; } const size_t packetLen = 1 + 2 + payloadLen; if (pos + packetLen > dataLen) { result.overrun = true; result.endPos = pos; return result; } uint16_t subOpcode = static_cast(data[pos + 1]) | (static_cast(data[pos + 2]) << 8); size_t payloadStart = pos + 3; CompressedMoveSubPacket subPacket; subPacket.opcode = subOpcode; subPacket.payload.assign(data.begin() + payloadStart, data.begin() + payloadStart + payloadLen); result.packets.push_back(std::move(subPacket)); ++result.subPacketCount; if (isRecognizedSubOpcode(subOpcode)) { ++result.recognizedCount; } pos += packetLen; } result.ok = (result.endPos == 0 || result.endPos == dataLen); result.endPos = dataLen; return result; }; DecodeResult decoded = decodeSubPackets(false); if (!decoded.ok || decoded.overrun) { DecodeResult payloadOnlyDecoded = decodeSubPackets(true); const bool preferPayloadOnly = payloadOnlyDecoded.ok && (!decoded.ok || decoded.overrun || payloadOnlyDecoded.recognizedCount > decoded.recognizedCount); if (preferPayloadOnly) { decoded = std::move(payloadOnlyDecoded); static uint32_t payloadOnlyFallbackCount = 0; ++payloadOnlyFallbackCount; if (payloadOnlyFallbackCount <= 10 || (payloadOnlyFallbackCount % 100) == 0) { LOG_WARNING("SMSG_COMPRESSED_MOVES decoded via payload-only size fallback", " (occurrence=", payloadOnlyFallbackCount, ")"); } } } if (!decoded.ok || decoded.overrun) { LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", decoded.endPos); return; } // Track unhandled sub-opcodes once per compressed packet (avoid log spam) std::unordered_set unhandledSeen; for (const auto& entry : decoded.packets) { network::Packet subPacket(entry.opcode, entry.payload); if (entry.opcode == monsterMoveWire) { handleMonsterMove(subPacket); } else if (entry.opcode == monsterMoveTransportWire) { handleMonsterMoveTransport(subPacket); } else if (state == WorldState::IN_WORLD && std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), entry.opcode) != kMoveOpcodes.end()) { // Player/NPC movement update packed in SMSG_MULTIPLE_MOVES handleOtherPlayerMovement(subPacket); } else { if (unhandledSeen.insert(entry.opcode).second) { LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x", std::hex, entry.opcode, std::dec, " payloadLen=", entry.payload.size()); } } } } void GameHandler::handleMonsterMove(network::Packet& packet) { if (isActiveExpansion("classic") || isActiveExpansion("turtle")) { constexpr uint32_t kMaxMonsterMovesPerTick = 256; ++monsterMovePacketsThisTick_; if (monsterMovePacketsThisTick_ > kMaxMonsterMovesPerTick) { ++monsterMovePacketsDroppedThisTick_; if (monsterMovePacketsDroppedThisTick_ <= 3 || (monsterMovePacketsDroppedThisTick_ % 100) == 0) { LOG_WARNING("SMSG_MONSTER_MOVE: per-tick cap exceeded, dropping packet", " (processed=", monsterMovePacketsThisTick_, " dropped=", monsterMovePacketsDroppedThisTick_, ")"); } return; } } 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 logWrappedUncompressedFallbackUsed = [&]() { static uint32_t wrappedUncompressedFallbackCount = 0; ++wrappedUncompressedFallbackCount; if (wrappedUncompressedFallbackCount <= 10 || (wrappedUncompressedFallbackCount % 100) == 0) { LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback", " (occurrence=", wrappedUncompressedFallbackCount, ")"); } }; 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); bool parsed = false; if (hasWrappedForm) { network::Packet wrappedPacket(packet.getOpcode(), stripped); if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { parsed = true; } } if (!parsed) { network::Packet decompPacket(packet.getOpcode(), decompressed); if (packetParsers_->parseMonsterMove(decompPacket, data)) { parsed = true; } } if (!parsed) { if (hasWrappedForm) { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + std::to_string(destLen) + " bytes, wrapped payload " + std::to_string(stripped.size()) + " bytes)"); } else { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + std::to_string(destLen) + " bytes)"); } return; } } 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)) { logWrappedUncompressedFallbackUsed(); } 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); } } else if (data.moveType == 4) { // FacingAngle without movement — rotate NPC in place float orientation = core::coords::serverToCanonicalYaw(data.facingAngle); glm::vec3 posCanonical = core::coords::serverToCanonical( glm::vec3(data.x, data.y, data.z)); entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, orientation); if (creatureMoveCallback_) { creatureMoveCallback_(data.guid, posCanonical.x, posCanonical.y, posCanonical.z, 0); } } else if (data.moveType == 3 && data.facingTarget != 0) { // FacingTarget without movement — rotate NPC to face a target 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) { float orientation = std::atan2(-dy, dx); entity->setOrientation(orientation); } } } } 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(); constexpr uint32_t kMaxTransportSplinePoints = 1000; if (pointCount > kMaxTransportSplinePoints) { LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount, " clamped to ", kMaxTransportSplinePoints); pointCount = kMaxTransportSplinePoints; } // 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) { lastMeleeSwingMs_ = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); if (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, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 1) { addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 2) { addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } 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, 0, data.attackerGuid, data.targetGuid); addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 5) { // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). addCombatText(CombatTextEntry::EVADE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 6) { // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 7) { // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { CombatTextEntry::Type type; if (data.isCrit()) type = CombatTextEntry::CRIT_DAMAGE; else if (data.isCrushing()) type = CombatTextEntry::CRUSHING; else if (data.isGlancing()) type = CombatTextEntry::GLANCING; else type = CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); // 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, 0, data.attackerGuid, data.targetGuid); if (totalResisted > 0) addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } (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, 0, data.attackerGuid, data.targetGuid); if (data.absorbed > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); if (data.resisted > 0) addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); } 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, 0, data.casterGuid, data.targetGuid); if (data.absorbed > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); } // ============================================================ // 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) { // Spell queue: if we're within 400ms of the cast completing (and not channeling), // store the spell so it fires automatically when the cast finishes. if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) { queuedSpellId_ = spellId; queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid; LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f, "ms remaining)"); } return; } // 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); // Fire UNIT_SPELLCAST_SENT for cast bar addons (fires on client intent, before server confirms) if (addonEventCallback_) { std::string targetName; if (target != 0) targetName = lookupName(target); addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)}); } // Optimistically start GCD immediately on cast, but do not restart it while // already active (prevents timeout animation reset on repeated key presses). if (!isGCDActive()) { gcdTotal_ = 1.5f; gcdStartedAt_ = std::chrono::steady_clock::now(); } } 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; lastInteractedGoGuid_ = 0; casting = false; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; // Cancel craft queue and spell queue when player manually cancels cast craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; if (addonEventCallback_) addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"}); } void GameHandler::startCraftQueue(uint32_t spellId, int count) { craftQueueSpellId_ = spellId; craftQueueRemaining_ = count; // Cast the first one immediately castSpell(spellId, 0); } void GameHandler::cancelCraftQueue() { craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; } void GameHandler::cancelAura(uint32_t spellId) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = CancelAuraPacket::build(spellId); socket->send(packet); } uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); for (const auto& t : tempEnchantTimers_) { if (t.slot == slot) { return (t.expireMs > nowMs) ? static_cast(t.expireMs - nowMs) : 0u; } } return 0u; } 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"); if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); 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)"); if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); 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()); if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); } 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::togglePetSpellAutocast(uint32_t spellId) { if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return; bool currentlyOn = petAutocastSpells_.count(spellId) != 0; uint8_t newState = currentlyOn ? 0 : 1; // CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1) network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST)); pkt.writeUInt64(petGuid_); pkt.writeUInt32(spellId); pkt.writeUInt8(newState); socket->send(pkt); // Optimistically update local state; server will confirm via SMSG_PET_SPELLS if (newState) petAutocastSpells_.insert(spellId); else petAutocastSpells_.erase(spellId); LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState); } void GameHandler::renamePet(const std::string& newName) { if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return; if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars auto packet = PetRenamePacket::build(petGuid_, newName, 0); socket->send(packet); LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, petGuid_, std::dec, " name='", newName, "'"); } void GameHandler::requestStabledPetList() { if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; auto pkt = ListStabledPetsPacket::build(stableMasterGuid_); socket->send(pkt); LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, stableMasterGuid_, std::dec); } void GameHandler::stablePet(uint8_t slot) { if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; if (petGuid_ == 0) { addSystemChatMessage("You do not have an active pet to stable."); return; } auto pkt = StablePetPacket::build(stableMasterGuid_, slot); socket->send(pkt); LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast(slot)); } void GameHandler::unstablePet(uint32_t petNumber) { if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0 || petNumber == 0) return; auto pkt = UnstablePetPacket::build(stableMasterGuid_, petNumber); socket->send(pkt); LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber); } void GameHandler::handleListStabledPets(network::Packet& packet) { // SMSG MSG_LIST_STABLED_PETS: // uint64 stableMasterGuid // uint8 petCount // uint8 numSlots // per pet: // uint32 petNumber // uint32 entry // uint32 level // string name (null-terminated) // uint32 displayId // uint8 isActive (1 = active/summoned, 0 = stabled) constexpr size_t kMinHeader = 8 + 1 + 1; if (packet.getSize() - packet.getReadPos() < kMinHeader) { LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")"); return; } stableMasterGuid_ = packet.readUInt64(); uint8_t petCount = packet.readUInt8(); stableNumSlots_ = packet.readUInt8(); stabledPets_.clear(); stabledPets_.reserve(petCount); for (uint8_t i = 0; i < petCount; ++i) { if (packet.getSize() - packet.getReadPos() < 4 + 4 + 4) break; StabledPet pet; pet.petNumber = packet.readUInt32(); pet.entry = packet.readUInt32(); pet.level = packet.readUInt32(); pet.name = packet.readString(); if (packet.getSize() - packet.getReadPos() < 4 + 1) break; pet.displayId = packet.readUInt32(); pet.isActive = (packet.readUInt8() != 0); stabledPets_.push_back(std::move(pet)); } stableWindowOpen_ = true; LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, stableMasterGuid_, std::dec, " petCount=", (int)petCount, " numSlots=", (int)stableNumSlots_); for (const auto& p : stabledPets_) { LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry, " level=", p.level, " name='", p.name, "' displayId=", p.displayId, " active=", p.isActive); } } 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(); // Notify the server so the action bar persists across relogs. if (state == WorldState::IN_WORLD && socket) { const bool classic = isClassicLikeExpansion(); auto pkt = SetActionButtonPacket::build( static_cast(slot), static_cast(type), id, classic); socket->send(pkt); } } 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 — use the longer of individual vs category cooldown. // Spells like potions have cooldownMs=0 but categoryCooldownMs=120000. for (const auto& cd : data.cooldowns) { uint32_t effectiveMs = std::max(cd.cooldownMs, cd.categoryCooldownMs); if (effectiveMs > 0) { spellCooldowns[cd.spellId] = effectiveMs / 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(); // Sync login-time cooldowns into action bar slot overlays. Without this, spells // that are still on cooldown when the player logs in show no cooldown timer on the // action bar even though spellCooldowns has the right remaining time. for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { auto it = spellCooldowns.find(slot.id); if (it != spellCooldowns.end() && it->second > 0.0f) { slot.cooldownTotal = it->second; slot.cooldownRemaining = it->second; } } else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) { const auto* qi = getItemInfo(slot.id); if (qi && qi->valid) { for (const auto& sp : qi->spells) { if (sp.spellId == 0) continue; auto it = spellCooldowns.find(sp.spellId); if (it != spellCooldowns.end() && it->second > 0.0f) { slot.cooldownTotal = it->second; slot.cooldownRemaining = it->second; break; } } } } } // Pre-load skill line DBCs so isProfessionSpell() works immediately // (not just after opening a trainer window) loadSkillLineDbc(); loadSkillLineAbilityDbc(); 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; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); } } // Show failure reason in the UIError overlay and in chat 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); std::string errMsg = reason ? reason : ("Spell cast failed (error " + std::to_string(data.result) + ")"); addUIError(errMsg); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = errMsg; addLocalChatMessage(msg); // Play error sound for cast failure feedback if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playError(); } } 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; s.interruptible = isSpellInterruptible(data.spellId); // 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) { // CMSG_GAMEOBJ_USE was accepted — cancel pending USE retries so we don't // re-send GAMEOBJ_USE mid-gather-cast and get SPELL_FAILED_BAD_TARGETS. // Keep entries that only have sendLoot (no-cast chests that still need looting). pendingGameObjectLootRetries_.erase( std::remove_if(pendingGameObjectLootRetries_.begin(), pendingGameObjectLootRetries_.end(), [](const PendingLootRetry&) { return true; /* cancel all retries once a gather cast starts */ }), pendingGameObjectLootRetries_.end()); casting = true; castIsChannel = false; currentCastSpellId = data.spellId; castTimeTotal = data.castTime / 1000.0f; castTimeRemaining = castTimeTotal; // Play precast (channeling) sound with correct magic school // Skip sound for profession/tradeskill spells (crafting should be silent) if (!isProfessionSpell(data.spellId)) { 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); } } // Fire UNIT_SPELLCAST_START for Lua addons if (addonEventCallback_) { std::string unitId; if (data.casterUnit == playerGuid) unitId = "player"; else if (data.casterUnit == targetGuid) unitId = "target"; else if (data.casterUnit == focusGuid) unitId = "focus"; else if (data.casterUnit == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)}); } } 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 // Skip sound for profession/tradeskill spells (crafting should be silent) if (!isProfessionSpell(data.spellId)) { 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) { if (meleeSwingCallback_) meleeSwingCallback_(); // Play weapon swing + impact sound for instant melee abilities (Sinister Strike, etc.) if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* csm = renderer->getCombatSoundManager()) { csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false); csm->playImpact(audio::CombatSoundManager::WeaponSize::MEDIUM, audio::CombatSoundManager::ImpactType::FLESH, false); } } } // Capture cast state before clearing. Guard with spellId match so that // proc/triggered spells (which fire SMSG_SPELL_GO while a gather cast is // still active and casting == true) do NOT trigger premature CMSG_LOOT. // Only the spell that originally started the cast bar (currentCastSpellId) // should count as "gather cast completed". const bool wasInTimedCast = casting && (data.spellId == currentCastSpellId); casting = false; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; // If we were gathering a node (mining/herbalism), send CMSG_LOOT now that // the gather cast completed and the server has made the node lootable. // Guard with wasInTimedCast to avoid firing on instant casts / procs. if (wasInTimedCast && lastInteractedGoGuid_ != 0) { lootTarget(lastInteractedGoGuid_); lastInteractedGoGuid_ = 0; } // End cast animation on player character if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); } // Fire UNIT_SPELLCAST_STOP — cast bar should disappear if (addonEventCallback_) addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); // Spell queue: fire the next queued spell now that casting has ended if (queuedSpellId_ != 0) { uint32_t nextSpell = queuedSpellId_; uint64_t nextTarget = queuedSpellTarget_; queuedSpellId_ = 0; queuedSpellTarget_ = 0; LOG_INFO("Spell queue: firing queued spellId=", nextSpell); castSpell(nextSpell, nextTarget); } } else { if (spellCastAnimCallback_) { // End cast animation on other unit spellCastAnimCallback_(data.casterUnit, false, false); } // Play cast-complete sound for enemy spells targeting the player bool targetsPlayer = false; for (const auto& tgt : data.hitTargets) { if (tgt == playerGuid) { targetsPlayer = true; break; } } if (targetsPlayer) { 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); } } } } // Clear unit cast bar when the spell lands (for any tracked unit) unitCastStates_.erase(data.casterUnit); // Preserve spellId and actual participants for spell-go miss results. // This keeps the persistent combat log aligned with the later GUID fixes. if (!data.missTargets.empty()) { const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; const bool playerIsCaster = (spellCasterGuid == playerGuid); for (const auto& m : data.missTargets) { if (!playerIsCaster && m.targetGuid != playerGuid) { continue; } CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(m.missType); addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } // Play impact sound for spell hits involving the player // - When player is hit by an enemy spell // - When player's spell hits an enemy target bool playerIsHit = false; bool playerHitEnemy = false; for (const auto& tgt : data.hitTargets) { if (tgt == playerGuid) { playerIsHit = true; } if (data.casterUnit == playerGuid && tgt != playerGuid && tgt != 0) { playerHitEnemy = true; } } // Fire UNIT_SPELLCAST_SUCCEEDED for Lua addons if (addonEventCallback_) { std::string unitId; if (data.casterUnit == playerGuid) unitId = "player"; else if (data.casterUnit == targetGuid) unitId = "target"; else if (data.casterUnit == focusGuid) unitId = "focus"; else if (data.casterUnit == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)}); } if (playerIsHit || playerHitEnemy) { 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(); uint32_t cdItemId = 0; if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format uint32_t cooldownMs = packet.readUInt32(); float seconds = cooldownMs / 1000.0f; // spellId=0 is the Global Cooldown marker (server sends it for GCD triggers) if (spellId == 0 && cooldownMs > 0 && cooldownMs <= 2000) { gcdTotal_ = seconds; gcdStartedAt_ = std::chrono::steady_clock::now(); continue; } auto it = spellCooldowns.find(spellId); if (it == spellCooldowns.end()) { spellCooldowns[spellId] = seconds; } else { it->second = mergeCooldownSeconds(it->second, seconds); } for (auto& slot : actionBar) { bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) || (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId); if (match) { float prevRemaining = slot.cooldownRemaining; float merged = mergeCooldownSeconds(slot.cooldownRemaining, seconds); slot.cooldownRemaining = merged; if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) { slot.cooldownTotal = seconds; } else { slot.cooldownTotal = std::max(slot.cooldownTotal, merged); } } } } LOG_DEBUG("handleSpellCooldown: parsed for ", isClassicFormat ? "Classic" : "TBC/WotLK", " format"); if (addonEventCallback_) { addonEventCallback_("SPELL_UPDATE_COOLDOWN", {}); addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {}); } } 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; } } if (addonEventCallback_) { addonEventCallback_("SPELL_UPDATE_COOLDOWN", {}); addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {}); } } 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; } // Also maintain a per-unit cache for any unit (party members, etc.) if (data.guid != 0 && data.guid != playerGuid && data.guid != targetGuid) { auraList = &unitAurasCache_[data.guid]; } 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; } // Fire UNIT_AURA event for Lua addons if (addonEventCallback_) { std::string unitId; if (data.guid == playerGuid) unitId = "player"; else if (data.guid == targetGuid) unitId = "target"; else if (data.guid == focusGuid) unitId = "focus"; else if (data.guid == petGuid_) unitId = "pet"; if (!unitId.empty()) addonEventCallback_("UNIT_AURA", {unitId}); } // 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(); // Track whether we already knew this spell before inserting. // SMSG_TRAINER_BUY_SUCCEEDED pre-inserts the spell and shows its own "You have // learned X" message, so when the accompanying SMSG_LEARNED_SPELL arrives we // must not duplicate it. const bool alreadyKnown = knownSpells.count(spellId) > 0; knownSpells.insert(spellId); LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : ""); // Check if this spell corresponds to a talent rank bool isTalentSpell = false; 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_); isTalentSpell = true; if (addonEventCallback_) { addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); addonEventCallback_("PLAYER_TALENT_UPDATE", {}); } break; } } if (isTalentSpell) break; } // Fire LEARNED_SPELL_IN_TAB / SPELLS_CHANGED for Lua addons if (!alreadyKnown && addonEventCallback_) { addonEventCallback_("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)}); addonEventCallback_("SPELLS_CHANGED", {}); } if (isTalentSpell) return; // talent spells don't show chat message // Show chat message for non-talent spells, but only if not already announced by // SMSG_TRAINER_BUY_SUCCEEDED (which pre-inserts into knownSpells). if (!alreadyKnown) { 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); if (addonEventCallback_) addonEventCallback_("SPELLS_CHANGED", {}); const std::string& name = getSpellName(spellId); if (!name.empty()) addSystemChatMessage("You have unlearned: " + name + "."); else addSystemChatMessage("A spell has been removed."); // Clear any action bar slots referencing this spell bool barChanged = false; for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { slot = ActionBarSlot{}; barChanged = true; } } if (barChanged) saveCharacterConfig(); } 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); // Track whether the new spell was already announced via SMSG_TRAINER_BUY_SUCCEEDED. // If it was pre-inserted there, that handler already showed "You have learned X" so // we should skip the "Upgraded to X" message to avoid a duplicate. const bool newSpellAlreadyAnnounced = knownSpells.count(newSpellId) > 0; // Add new spell knownSpells.insert(newSpellId); LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId); // Update all action bar slots that reference the old spell rank to the new rank. // This matches the WoW client behaviour: the action bar automatically upgrades // to the new rank when you train it. bool barChanged = false; for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) { slot.id = newSpellId; slot.cooldownRemaining = 0.0f; slot.cooldownTotal = 0.0f; barChanged = true; LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId); } } if (barChanged) { saveCharacterConfig(); if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); } // Show "Upgraded to X" only when the new spell wasn't already announced by the // trainer-buy handler. For non-trainer supersedes (e.g. quest rewards), the new // spell won't be pre-inserted so we still show the message. if (!newSpellAlreadyAnnounced) { 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"); bool barChanged = false; 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); for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { slot = ActionBarSlot{}; barChanged = true; } } } if (barChanged) saveCharacterConfig(); if (spellCount > 0) { addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells"); } } // ============================================================ // Talents // ============================================================ void GameHandler::handleTalentsInfo(network::Packet& packet) { // SMSG_TALENTS_INFO (WotLK 3.3.5a) correct wire format: // uint8 talentType (0 = own talents, 1 = inspect result — own talent packets always 0) // uint32 unspentTalents // uint8 talentGroupCount // uint8 activeTalentGroup // Per group: uint8 talentCount, [uint32 talentId + uint8 rank] × count, // uint8 glyphCount, [uint16 glyphId] × count if (packet.getSize() - packet.getReadPos() < 1) return; uint8_t talentType = packet.readUInt8(); if (talentType != 0) { // type 1 = inspect result; handled by handleInspectResults — ignore here return; } if (packet.getSize() - packet.getReadPos() < 6) { LOG_WARNING("handleTalentsInfo: packet too short for header"); return; } uint32_t unspentTalents = packet.readUInt32(); uint8_t talentGroupCount = packet.readUInt8(); uint8_t activeTalentGroup = packet.readUInt8(); if (activeTalentGroup > 1) activeTalentGroup = 0; // Ensure talent DBCs are loaded loadTalentDbc(); activeTalentSpec_ = activeTalentGroup; for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t talentCount = packet.readUInt8(); learnedTalents_[g].clear(); for (uint8_t t = 0; t < talentCount; ++t) { if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t talentId = packet.readUInt32(); uint8_t rank = packet.readUInt8(); learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed } learnedGlyphs_[g].fill(0); if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t glyphCount = packet.readUInt8(); for (uint8_t gl = 0; gl < glyphCount; ++gl) { if (packet.getSize() - packet.getReadPos() < 2) break; uint16_t glyphId = packet.readUInt16(); if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; } } unspentTalentPoints_[activeTalentGroup] = static_cast(unspentTalents > 255 ? 255 : unspentTalents); LOG_INFO("handleTalentsInfo: unspent=", unspentTalents, " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, " learned=", learnedTalents_[activeTalentGroup].size()); // Fire talent-related events for addons if (addonEventCallback_) { addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {}); addonEventCallback_("PLAYER_TALENT_UPDATE", {}); } if (!talentsInitialized_) { talentsInitialized_ = true; if (unspentTalents > 0) { addSystemChatMessage("You have " + std::to_string(unspentTalents) + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); } } } 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::confirmPetUnlearn() { if (!petUnlearnPending_) return; petUnlearnPending_ = false; if (state != WorldState::IN_WORLD || !socket) return; // Respond with CMSG_PET_UNLEARN_TALENTS (no payload in 3.3.5a) network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS)); socket->send(pkt); LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS"); addSystemChatMessage("Pet talent reset confirmed."); petUnlearnGuid_ = 0; petUnlearnCost_ = 0; } 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; } void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { if (state != WorldState::IN_WORLD || !socket) return; auto pkt = AlterAppearancePacket::build(hairStyle, hairColor, facialHair); socket->send(pkt); LOG_INFO("sendAlterAppearance: hair=", hairStyle, " color=", hairColor, " facial=", facialHair); } // ============================================================ // 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"); if (addonEventCallback_) { addonEventCallback_("GROUP_ROSTER_UPDATE", {}); addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); } } 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."); } if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playTargetSelect(); } } 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"); // Snapshot state before reset so we can detect transitions. const uint32_t prevCount = partyData.memberCount; const uint8_t prevLootMethod = partyData.lootMethod; const bool wasInGroup = !partyData.isEmpty(); // 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; const bool nowInGroup = !partyData.isEmpty(); if (!nowInGroup && wasInGroup) { LOG_INFO("No longer in a group"); addSystemChatMessage("You are no longer in a group."); } else if (nowInGroup && !wasInGroup) { LOG_INFO("Joined group with ", partyData.memberCount, " members"); addSystemChatMessage("You are now in a group."); } else if (nowInGroup && partyData.memberCount != prevCount) { LOG_INFO("Group updated: ", partyData.memberCount, " members"); } // Loot method change notification if (wasInGroup && nowInGroup && partyData.lootMethod != prevLootMethod) { static const char* kLootMethods[] = { "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" }; const char* methodName = (partyData.lootMethod < 5) ? kLootMethods[partyData.lootMethod] : "Unknown"; addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); } // Fire GROUP_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED for Lua addons if (addonEventCallback_) { addonEventCallback_("GROUP_ROSTER_UPDATE", {}); addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); } } void GameHandler::handleGroupUninvite(network::Packet& packet) { (void)packet; partyData = GroupListData{}; LOG_INFO("Removed from group"); if (addonEventCallback_) { addonEventCallback_("GROUP_ROSTER_UPDATE", {}); addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); } MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = "You have been removed from the group."; addUIError("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) { const char* errText = nullptr; switch (data.result) { case PartyResult::BAD_PLAYER_NAME: errText = "No player named \"%s\" is currently online."; break; case PartyResult::TARGET_NOT_IN_GROUP: errText = "%s is not in your group."; break; case PartyResult::TARGET_NOT_IN_INSTANCE:errText = "%s is not in your instance."; break; case PartyResult::GROUP_FULL: errText = "Your party is full."; break; case PartyResult::ALREADY_IN_GROUP: errText = "%s is already in a group."; break; case PartyResult::NOT_IN_GROUP: errText = "You are not in a group."; break; case PartyResult::NOT_LEADER: errText = "You are not the group leader."; break; case PartyResult::PLAYER_WRONG_FACTION: errText = "%s is the wrong faction for this group."; break; case PartyResult::IGNORING_YOU: errText = "%s is ignoring you."; break; case PartyResult::LFG_PENDING: errText = "You cannot do that while in a LFG queue."; break; case PartyResult::INVITE_RESTRICTED: errText = "Target is not accepting group invites."; break; default: errText = "Party command failed."; break; } char buf[256]; if (!data.name.empty() && errText && std::strstr(errText, "%s")) { std::snprintf(buf, sizeof(buf), errText, data.name.c_str()); } else if (errText) { std::snprintf(buf, sizeof(buf), "%s", errText); } else { std::snprintf(buf, sizeof(buf), "Party command failed (error %u).", static_cast(data.result)); } addUIError(buf); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = buf; 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(); // Collect aura updates for this member and store in unitAurasCache_ // so party frame debuff dots can use them. std::vector newAuras; for (int i = 0; i < 64; ++i) { if (auraMask & (uint64_t(1) << i)) { AuraSlot a; a.level = static_cast(i); // use slot index if (isWotLK) { // WotLK: uint32 spellId + uint8 auraFlags if (remaining() < 5) break; a.spellId = packet.readUInt32(); a.flags = packet.readUInt8(); } else { // Classic/TBC: uint16 spellId only; negative auras not indicated here if (remaining() < 2) break; a.spellId = packet.readUInt16(); // Infer negative/positive from dispel type: non-zero dispel → debuff uint8_t dt = getSpellDispelType(a.spellId); if (dt > 0) a.flags = 0x80; // mark as debuff } if (a.spellId != 0) newAuras.push_back(a); } } // Populate unitAurasCache_ for this member (merge: keep existing per-GUID data // only if we already have a richer source; otherwise replace with stats data) if (memberGuid != 0 && memberGuid != playerGuid && memberGuid != targetGuid) { unitAurasCache_[memberGuid] = std::move(newAuras); } } } 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); // Fire addon events for party/raid member health/power/aura changes if (addonEventCallback_) { // Resolve unit ID for this member (party1..4 or raid1..40) std::string unitId; if (partyData.groupType == 1) { // Raid: find 1-based index for (size_t i = 0; i < partyData.members.size(); ++i) { if (partyData.members[i].guid == memberGuid) { unitId = "raid" + std::to_string(i + 1); break; } } } else { // Party: find 1-based index excluding self int found = 0; for (const auto& m : partyData.members) { if (m.guid == playerGuid) continue; ++found; if (m.guid == memberGuid) { unitId = "party" + std::to_string(found); break; } } } if (!unitId.empty()) { if (updateFlags & (0x0002 | 0x0004)) // CUR_HP or MAX_HP addonEventCallback_("UNIT_HEALTH", {unitId}); if (updateFlags & (0x0010 | 0x0020)) // CUR_POWER or MAX_POWER addonEventCallback_("UNIT_POWER", {unitId}); if (updateFlags & 0x0200) // AURAS addonEventCallback_("UNIT_AURA", {unitId}); } } } // ============================================================ // 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::submitGmTicket(const std::string& text) { if (state != WorldState::IN_WORLD || !socket) return; // CMSG_GMTICKET_CREATE (WotLK 3.3.5a): // string ticket_text // float[3] position (server coords) // float facing // uint32 mapId // uint8 need_response (1 = yes) network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE)); pkt.writeString(text); pkt.writeFloat(movementInfo.x); pkt.writeFloat(movementInfo.y); pkt.writeFloat(movementInfo.z); pkt.writeFloat(movementInfo.orientation); pkt.writeUInt32(currentMapId_); pkt.writeUInt8(1); // need_response = yes socket->send(pkt); LOG_INFO("Submitted GM ticket: '", text, "'"); } void GameHandler::deleteGmTicket() { if (state != WorldState::IN_WORLD || !socket) return; network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); socket->send(pkt); gmTicketActive_ = false; gmTicketText_.clear(); LOG_INFO("Deleting GM ticket"); } void GameHandler::requestGmTicket() { if (state != WorldState::IN_WORLD || !socket) return; // CMSG_GMTICKET_GETTICKET has no payload — server responds with SMSG_GMTICKET_GETTICKET network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_GETTICKET)); socket->send(pkt); LOG_DEBUG("Sent CMSG_GMTICKET_GETTICKET — querying open ticket status"); } 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); } static const std::string kEmptyString; const std::string& GameHandler::lookupGuildName(uint32_t guildId) { if (guildId == 0) return kEmptyString; auto it = guildNameCache_.find(guildId); if (it != guildNameCache_.end()) return it->second; // Query the server if we haven't already if (pendingGuildNameQueries_.insert(guildId).second) { queryGuildInfo(guildId); } return kEmptyString; } uint32_t GameHandler::getEntityGuildId(uint64_t guid) const { auto entity = entityManager.getEntity(guid); if (!entity || entity->getType() != ObjectType::PLAYER) return 0; // PLAYER_GUILDID = UNIT_END + 3 across all expansions const uint16_t ufUnitEnd = fieldIndex(UF::UNIT_END); if (ufUnitEnd == 0xFFFF) return 0; return entity->getField(ufUnitEnd + 3); } 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::handlePetitionQueryResponse(network::Packet& packet) { // SMSG_PETITION_QUERY_RESPONSE (3.3.5a): // uint32 petitionEntry, uint64 petitionGuid, string guildName, // string bodyText (empty), uint32 flags, uint32 minSignatures, // uint32 maxSignatures, ...plus more fields we can skip auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 12) return; /*uint32_t entry =*/ packet.readUInt32(); uint64_t petGuid = packet.readUInt64(); std::string guildName = packet.readString(); /*std::string body =*/ packet.readString(); // Update petition info if it matches our current petition if (petitionInfo_.petitionGuid == petGuid) { petitionInfo_.guildName = guildName; } LOG_INFO("SMSG_PETITION_QUERY_RESPONSE: guid=", petGuid, " name=", guildName); packet.setReadPos(packet.getSize()); // skip remaining fields } void GameHandler::handlePetitionShowSignatures(network::Packet& packet) { // SMSG_PETITION_SHOW_SIGNATURES (3.3.5a): // uint64 itemGuid (petition item in inventory) // uint64 ownerGuid // uint32 petitionGuid (low part / entry) // uint8 signatureCount // For each signature: // uint64 playerGuid // uint32 unk (always 0) auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 21) return; petitionInfo_ = PetitionInfo{}; petitionInfo_.petitionGuid = packet.readUInt64(); petitionInfo_.ownerGuid = packet.readUInt64(); /*uint32_t petEntry =*/ packet.readUInt32(); uint8_t sigCount = packet.readUInt8(); petitionInfo_.signatureCount = sigCount; petitionInfo_.signatures.reserve(sigCount); for (uint8_t i = 0; i < sigCount; ++i) { if (rem() < 12) break; PetitionSignature sig; sig.playerGuid = packet.readUInt64(); /*uint32_t unk =*/ packet.readUInt32(); petitionInfo_.signatures.push_back(sig); } petitionInfo_.showUI = true; LOG_INFO("SMSG_PETITION_SHOW_SIGNATURES: petGuid=", petitionInfo_.petitionGuid, " owner=", petitionInfo_.ownerGuid, " sigs=", sigCount); } void GameHandler::handlePetitionSignResults(network::Packet& packet) { // SMSG_PETITION_SIGN_RESULTS (3.3.5a): // uint64 petitionGuid, uint64 playerGuid, uint32 result auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 20) return; uint64_t petGuid = packet.readUInt64(); uint64_t playerGuid = packet.readUInt64(); uint32_t result = packet.readUInt32(); switch (result) { case 0: // PETITION_SIGN_OK addSystemChatMessage("Petition signed successfully."); // Increment local count if (petitionInfo_.petitionGuid == petGuid) { petitionInfo_.signatureCount++; PetitionSignature sig; sig.playerGuid = playerGuid; petitionInfo_.signatures.push_back(sig); } break; case 1: // PETITION_SIGN_ALREADY_SIGNED addSystemChatMessage("You have already signed that petition."); break; case 2: // PETITION_SIGN_ALREADY_IN_GUILD addSystemChatMessage("You are already in a guild."); break; case 3: // PETITION_SIGN_CANT_SIGN_OWN addSystemChatMessage("You cannot sign your own petition."); break; default: addSystemChatMessage("Cannot sign petition (error " + std::to_string(result) + ")."); break; } LOG_INFO("SMSG_PETITION_SIGN_RESULTS: pet=", petGuid, " player=", playerGuid, " result=", result); } void GameHandler::signPetition(uint64_t petitionGuid) { if (!socket || state != WorldState::IN_WORLD) return; network::Packet pkt(wireOpcode(Opcode::CMSG_PETITION_SIGN)); pkt.writeUInt64(petitionGuid); pkt.writeUInt8(0); // unk socket->send(pkt); LOG_INFO("Signing petition: ", petitionGuid); } void GameHandler::turnInPetition(uint64_t petitionGuid) { if (!socket || state != WorldState::IN_WORLD) return; network::Packet pkt(wireOpcode(Opcode::CMSG_TURN_IN_PETITION)); pkt.writeUInt64(petitionGuid); socket->send(pkt); LOG_INFO("Turning in petition: ", petitionGuid); } 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"); if (addonEventCallback_) addonEventCallback_("GUILD_ROSTER_UPDATE", {}); } void GameHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; // Always cache the guild name for nameplate lookups if (data.guildId != 0 && !data.guildName.empty()) { guildNameCache_[data.guildId] = data.guildName; pendingGuildNameQueries_.erase(data.guildId); } // Check if this is the local player's guild const Character* ch = getActiveCharacter(); bool isLocalGuild = (ch && ch->hasGuild() && ch->guildId == data.guildId); if (isLocalGuild) { const bool wasUnknown = guildName_.empty(); 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_); if (wasUnknown && !guildName_.empty()) { addSystemChatMessage("Guild: <" + guildName_ + ">"); if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {}); } } else { LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.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; if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {}); 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); } // Fire addon events for guild state changes if (addonEventCallback_) { switch (data.eventType) { case GuildEvent::MOTD: addonEventCallback_("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""}); break; case GuildEvent::SIGNED_ON: case GuildEvent::SIGNED_OFF: case GuildEvent::PROMOTION: case GuildEvent::DEMOTION: case GuildEvent::JOINED: case GuildEvent::LEFT: case GuildEvent::REMOVED: case GuildEvent::LEADER_CHANGED: case GuildEvent::DISBANDED: addonEventCallback_("GUILD_ROSTER_UPDATE", {}); break; default: break; } } // 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; // command: 0=CREATE, 1=INVITE, 2=QUIT, 3=FOUNDER if (data.errorCode == 0) { switch (data.command) { case 0: // CREATE addSystemChatMessage("Guild created."); break; case 1: // INVITE — invited another player if (!data.name.empty()) addSystemChatMessage("You have invited " + data.name + " to the guild."); break; case 2: // QUIT — player successfully left addSystemChatMessage("You have left the guild."); guildName_.clear(); guildRankNames_.clear(); guildRoster_ = GuildRosterData{}; hasGuildRoster_ = false; break; default: break; } return; } // Error codes from AzerothCore SharedDefines.h GuildCommandError const char* errStr = nullptr; switch (data.errorCode) { case 2: errStr = "You are not in a guild."; break; case 3: errStr = "That player is not in a guild."; break; case 4: errStr = "No player named \"%s\" is online."; break; case 7: errStr = "You are the guild leader."; break; case 8: errStr = "You must transfer leadership before leaving."; break; case 11: errStr = "\"%s\" is already in a guild."; break; case 13: errStr = "You are already in a guild."; break; case 14: errStr = "\"%s\" has already been invited to a guild."; break; case 15: errStr = "You cannot invite yourself."; break; case 16: case 17: errStr = "You are not the guild leader."; break; case 18: errStr = "That player's rank is too high to remove."; break; case 19: errStr = "You cannot remove someone with a higher rank."; break; case 20: errStr = "Guild ranks are locked."; break; case 21: errStr = "That rank is in use."; break; case 22: errStr = "That player is ignoring you."; break; case 25: errStr = "Insufficient guild bank withdrawal quota."; break; case 26: errStr = "Guild doesn't have enough money."; break; case 28: errStr = "Guild bank is full."; break; case 31: errStr = "Too many guild ranks."; break; case 37: errStr = "That player is the guild leader."; break; case 49: errStr = "Guild reputation is too low."; break; default: break; } std::string msg; if (errStr) { // Substitute %s with player name where applicable std::string fmt = errStr; auto pos = fmt.find("%s"); if (pos != std::string::npos && !data.name.empty()) fmt.replace(pos, 2, data.name); else if (pos != std::string::npos) fmt.replace(pos, 2, "that player"); msg = fmt; } else { 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 (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {}); masterLootCandidates_.clear(); 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::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { if (state != WorldState::IN_WORLD || !socket) return; // CMSG_LOOT_MASTER_GIVE: uint64 lootGuid + uint8 slotIndex + uint64 targetGuid network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_MASTER_GIVE)); pkt.writeUInt64(currentLoot.lootGuid); pkt.writeUInt8(lootSlot); pkt.writeUInt64(targetGuid); socket->send(pkt); } 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; // 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. constexpr int64_t minRepeatMs = 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 > 10.0f) { addSystemChatMessage("Too far away."); return; } // Stop movement before interacting — servers may reject GO use or // immediately cancel the resulting spell cast if the player is moving. const uint32_t moveFlags = movementInfo.flags; const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD (moveFlags & 0x00000002u) || // BACKWARD (moveFlags & 0x00000004u) || // STRAFE_LEFT (moveFlags & 0x00000008u); // STRAFE_RIGHT if (isMoving) { movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags sendMovement(Opcode::MSG_MOVE_STOP); } 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); } LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec, " entry=", goEntry, " type=", goType, " name='", goName, "' dist=", entity ? std::sqrt( (entity->getX() - movementInfo.x) * (entity->getX() - movementInfo.x) + (entity->getY() - movementInfo.y) * (entity->getY() - movementInfo.y) + (entity->getZ() - movementInfo.z) * (entity->getZ() - movementInfo.z)) : -1.0f); auto packet = GameObjectUsePacket::build(guid); socket->send(packet); lastInteractedGoGuid_ = guid; // 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); } // Some servers require CMSG_GAMEOBJ_REPORT_USE for lootable gameobjects. // Only send it when the active opcode table actually supports it. if (!isMailbox) { const auto* table = getActiveOpcodeTable(); if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); reportUse.writeUInt64(guid); socket->send(reportUse); } } if (shouldSendLoot) { // Don't send CMSG_LOOT immediately — give the server time to process // CMSG_GAMEOBJ_USE first (chests need to transition to lootable state, // gathering nodes start a spell cast). A premature CMSG_LOOT can cause // an empty SMSG_LOOT_RESPONSE that clears our gather-cast loot state. pendingGameObjectLootOpens_.erase( std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), [&](const PendingLootOpen& p) { return p.guid == guid; }), pendingGameObjectLootOpens_.end()); // Short delay for chests (server makes them lootable quickly after USE), // plus a longer retry to catch slow state transitions. pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.20f}); pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f}); } else { // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be // sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot // guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT. lastInteractedGoGuid_ = 0; } // Don't retry CMSG_GAMEOBJ_USE — resending can toggle chest state on some // servers (opening→closing the chest). The delayed CMSG_LOOT retries above // handle the case where the first loot attempt arrives too early. } 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); } // Vendor / repair: some servers require an explicit CMSG_LIST_INVENTORY after gossip select. const bool isVendor = (text == "GOSSIP_OPTION_VENDOR" || (textLower.find("browse") != std::string::npos && (textLower.find("goods") != std::string::npos || textLower.find("wares") != std::string::npos))); const bool isArmorer = (text == "GOSSIP_OPTION_ARMORER" || textLower.find("repair") != std::string::npos); if (isVendor || isArmorer) { if (isArmorer) { setVendorCanRepair(true); } auto pkt = ListInventoryPacket::build(currentGossip.npcGuid); socket->send(pkt); LOG_INFO("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip.npcGuid, std::dec, " vendor=", (int)isVendor, " repair=", (int)isArmorer); } 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); } // Stable master detection: GOSSIP_OPTION_STABLE or text keywords if (text == "GOSSIP_OPTION_STABLE" || textLower.find("stable") != std::string::npos || textLower.find("my pet") != std::string::npos) { stableMasterGuid_ = currentGossip.npcGuid; stableWindowOpen_ = false; // will open when list arrives auto listPkt = ListStabledPetsPacket::build(currentGossip.npcGuid); socket->send(listPkt); LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to 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); if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin()); 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; if (addonEventCallback_) addonEventCallback_("QUEST_DETAIL", {}); } 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)); if (addonEventCallback_) { addonEventCallback_("QUEST_ACCEPTED", {std::to_string(questId)}); addonEventCallback_("QUEST_LOG_UPDATE", {}); } } 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; // Play quest-accept sound if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playQuestActivate(); } 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::shareQuestWithParty(uint32_t questId) { if (state != WorldState::IN_WORLD || !socket) { addSystemChatMessage("Cannot share quest: not in world."); return; } if (!isInGroup()) { addSystemChatMessage("You must be in a group to share a quest."); return; } network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY)); pkt.writeUInt32(questId); socket->send(pkt); // Local feedback: find quest title for (const auto& q : questLog_) { if (q.questId == questId && !q.title.empty()) { addSystemChatMessage("Sharing quest: " + q.title); return; } } addSystemChatMessage("Quest shared."); } 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{}; if (addonEventCallback_) addonEventCallback_("QUEST_COMPLETE", {}); // 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; if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {}); currentGossip = GossipMessageData{}; } void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { if (state != WorldState::IN_WORLD || !socket) return; if (itemGuid == 0 || questId == 0) { addSystemChatMessage("Cannot start quest right now."); return; } // Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver." // The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() // picks up and opens the Accept/Decline dialog. auto queryPkt = packetParsers_ ? packetParsers_->buildQueryQuestPacket(itemGuid, questId) : QuestgiverQueryQuestPacket::build(itemGuid, questId); socket->send(queryPkt); LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec, " questId=", questId); } uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const { if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0; if (slotIndex < 0) return 0; uint64_t bagGuid = equipSlotGuids_[19 + bagIndex]; if (bagGuid == 0) return 0; auto it = containerContents_.find(bagGuid); if (it == containerContents_.end()) return 0; if (slotIndex >= static_cast(it->second.numSlots)) return 0; return it->second.slotGuids[slotIndex]; } 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() { bool wasOpen = vendorWindowOpen; vendorWindowOpen = false; currentVendorItems = ListInventoryData{}; buybackItems_.clear(); pendingSellToBuyback_.clear(); pendingBuybackSlot_ = -1; pendingBuybackWireSlot_ = 0; pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; if (wasOpen && addonEventCallback_) addonEventCallback_("MERCHANT_CLOSED", {}); } 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::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) { if (state != WorldState::IN_WORLD || !socket) return; if (count == 0) return; // Find a free slot for the split destination: try backpack first, then bags int freeBp = inventory.findFreeBackpackSlot(); if (freeBp >= 0) { uint8_t dstBag = 0xFF; uint8_t dstSlot = static_cast(23 + freeBp); LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, ") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")"); auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); socket->send(packet); return; } // Try equipped bags for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) { int bagSize = inventory.getBagSize(b); for (int s = 0; s < bagSize; s++) { if (inventory.getBagSlot(b, s).empty()) { uint8_t dstBag = static_cast(19 + b); uint8_t dstSlot = static_cast(s); LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, ") count=", (int)count, " -> dst(bag=", (int)dstBag, " slot=", (int)dstSlot, ")"); auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); socket->send(packet); return; } } } addSystemChatMessage("Cannot split: no free inventory slots."); } 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::openItemBySlot(int backpackIndex) { if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; if (inventory.getBackpackSlot(backpackIndex).empty()) return; if (state != WorldState::IN_WORLD || !socket) return; auto packet = OpenItemPacket::build(0xFF, static_cast(23 + backpackIndex)); LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex)); socket->send(packet); } void GameHandler::openItemInBag(int bagIndex, int slotIndex) { if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return; if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return; if (inventory.getBagSlot(bagIndex, slotIndex).empty()) return; if (state != WorldState::IN_WORLD || !socket) return; uint8_t wowBag = static_cast(19 + bagIndex); auto packet = OpenItemPacket::build(wowBag, static_cast(slotIndex)); LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex); socket->send(packet); } 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) { // All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType). // WotLK adds a quest item list after the regular items. const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0; // If we're mid-gather-cast and got an empty loot response (premature CMSG_LOOT // before the node became lootable), ignore it — don't clear our gather state. if (!hasLoot && casting && currentCastSpellId != 0 && lastInteractedGoGuid_ != 0) { LOG_DEBUG("Ignoring empty SMSG_LOOT_RESPONSE during gather cast"); return; } lootWindowOpen = true; if (addonEventCallback_) addonEventCallback_("LOOT_OPENED", {}); lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo pendingGameObjectLootOpens_.erase( std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), [&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }), pendingGameObjectLootOpens_.end()); auto& localLoot = localLootState_[currentLoot.lootGuid]; localLoot.data = currentLoot; // 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 && !localLoot.itemAutoLootSent) { for (const auto& item : currentLoot.items) { auto pkt = AutostoreLootItemPacket::build(item.slotIndex); socket->send(pkt); } localLoot.itemAutoLootSent = true; } } void GameHandler::handleLootReleaseResponse(network::Packet& packet) { (void)packet; localLootState_.erase(currentLoot.lootGuid); lootWindowOpen = false; if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {}); 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); uint32_t quality = 1; if (const ItemQueryResponseData* info = getItemInfo(it->itemId)) { if (!info->name.empty()) itemName = info->name; quality = info->quality; } std::string link = buildItemLink(it->itemId, quality, itemName); std::string msgStr = "Looted: " + link; if (it->count > 1) msgStr += " x" + std::to_string(it->count); addSystemChatMessage(msgStr); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playLootItem(); } currentLoot.items.erase(it); if (addonEventCallback_) addonEventCallback_("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)}); 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; if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {}); 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; if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {}); 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; if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {}); currentGossip = GossipMessageData{}; } void GameHandler::handleListInventory(network::Packet& packet) { bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path if (!ListInventoryParser::parse(packet, currentVendorItems)) return; // Check NPC_FLAG_REPAIR (0x1000) on the vendor entity — this handles vendors that open // directly without going through the gossip armorer option. if (!savedCanRepair && currentVendorItems.vendorGuid != 0) { auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); if (entity && entity->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); // MaNGOS/Trinity: UNIT_NPC_FLAG_REPAIR = 0x00001000. if (unit->getNpcFlags() & 0x1000) { savedCanRepair = true; } } } currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens if (addonEventCallback_) addonEventCallback_("MERCHANT_SHOW", {}); // Auto-sell grey items if enabled if (autoSellGrey_ && currentVendorItems.vendorGuid != 0) { uint32_t totalSellPrice = 0; int itemsSold = 0; // Helper lambda to attempt selling a poor-quality slot auto tryAutoSell = [&](const ItemSlot& slot, uint64_t itemGuid) { if (slot.empty()) return; if (slot.item.quality != ItemQuality::POOR) return; // Determine sell price (slot cache first, then item info fallback) uint32_t sp = slot.item.sellPrice; if (sp == 0) { if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) sp = info->sellPrice; } if (sp == 0 || itemGuid == 0) return; 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); totalSellPrice += sp; ++itemsSold; }; // Backpack slots for (int i = 0; i < inventory.getBackpackSize(); ++i) { uint64_t guid = backpackSlotGuids_[i]; if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBackpackSlot(i).item.itemId); tryAutoSell(inventory.getBackpackSlot(i), guid); } // Extra bag slots for (int b = 0; b < inventory.NUM_BAG_SLOTS; ++b) { uint64_t bagGuid = equipSlotGuids_[19 + b]; for (int s = 0; s < inventory.getBagSize(b); ++s) { uint64_t guid = 0; if (bagGuid != 0) { auto it = containerContents_.find(bagGuid); if (it != containerContents_.end() && s < static_cast(it->second.numSlots)) guid = it->second.slotGuids[s]; } if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBagSlot(b, s).item.itemId); tryAutoSell(inventory.getBagSlot(b, s), guid); } } if (itemsSold > 0) { uint32_t gold = totalSellPrice / 10000; uint32_t silver = (totalSellPrice % 10000) / 100; uint32_t copper = totalSellPrice % 100; char buf[128]; std::snprintf(buf, sizeof(buf), "|cffaaaaaaAuto-sold %d grey item%s for %ug %us %uc.|r", itemsSold, itemsSold == 1 ? "" : "s", gold, silver, copper); addSystemChatMessage(buf); } } // Auto-repair all items if enabled and vendor can repair if (autoRepair_ && currentVendorItems.canRepair && currentVendorItems.vendorGuid != 0) { // Check that at least one equipped item is actually damaged to avoid no-op bool anyDamaged = false; for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; ++i) { const auto& slot = inventory.getEquipSlot(static_cast(i)); if (!slot.empty() && slot.item.maxDurability > 0 && slot.item.curDurability < slot.item.maxDurability) { anyDamaged = true; break; } } if (anyDamaged) { repairAll(currentVendorItems.vendorGuid, false); addSystemChatMessage("|cffaaaaaaAuto-repair triggered.|r"); } } // 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; if (addonEventCallback_) addonEventCallback_("TRAINER_SHOW", {}); 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; if (addonEventCallback_) addonEventCallback_("TRAINER_CLOSED", {}); 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; } } // DispelType field (0=none,1=magic,2=curse,3=disease,4=poison,5=stealth,…) uint32_t dispelField = 0xFFFFFFFF; bool hasDispelField = false; if (spellL) { uint32_t f = spellL->field("DispelType"); if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } } // AttributesEx field (bit 4 = SPELL_ATTR_EX_NOT_INTERRUPTIBLE) uint32_t attrExField = 0xFFFFFFFF; bool hasAttrExField = false; if (spellL) { uint32_t f = spellL->field("AttributesEx"); if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; } } // Tooltip/description field uint32_t tooltipField = 0xFFFFFFFF; if (spellL) { uint32_t f = spellL->field("Tooltip"); if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f; } 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, 0, 0}; if (tooltipField != 0xFFFFFFFF) { entry.description = dbc->getString(i, tooltipField); } 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; } if (hasDispelField) { entry.dispelType = static_cast(dbc->getUInt32(i, dispelField)); } if (hasAttrExField) { entry.attrEx = dbc->getUInt32(i, attrExField); } 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::getSpellDescription(uint32_t spellId) const { const_cast(this)->loadSpellNameCache(); auto it = spellNameCache_.find(spellId); return (it != spellNameCache_.end()) ? it->second.description : EMPTY_STRING; } uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { const_cast(this)->loadSpellNameCache(); auto it = spellNameCache_.find(spellId); return (it != spellNameCache_.end()) ? it->second.dispelType : 0; } bool GameHandler::isSpellInterruptible(uint32_t spellId) const { if (spellId == 0) return true; const_cast(this)->loadSpellNameCache(); auto it = spellNameCache_.find(spellId); if (it == spellNameCache_.end()) return true; // assume interruptible if unknown // SPELL_ATTR_EX_NOT_INTERRUPTIBLE = bit 4 of AttributesEx (0x00000010) return (it->second.attrEx & 0x00000010u) == 0; } uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const { if (spellId == 0) return 0; const_cast(this)->loadSpellNameCache(); auto it = spellNameCache_.find(spellId); return (it != spellNameCache_.end()) ? it->second.schoolMask : 0; } 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); // Build XP message with source creature name when available std::string msg; if (data.victimGuid != 0 && data.type == 0) { // Kill XP — resolve creature name std::string victimName = lookupName(data.victimGuid); if (!victimName.empty()) msg = victimName + " dies, you gain " + std::to_string(data.totalXp) + " experience."; else msg = "You gain " + std::to_string(data.totalXp) + " experience."; } else { msg = "You gain " + std::to_string(data.totalXp) + " experience."; } if (data.groupBonus > 0) { msg += " (+" + std::to_string(data.groupBonus) + " group bonus)"; } addSystemChatMessage(msg); if (addonEventCallback_) addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(data.totalXp)}); } 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); if (addonEventCallback_) addonEventCallback_("CHAT_MSG_MONEY", {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; corpseMapId_ = 0; corpseGuid_ = 0; hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true; casting = false; castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; 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; inInstance_ = false; // cleared on map change; re-set if SMSG_INSTANCE_DIFFICULTY follows if (socket) { socket->tracePacketsFor(std::chrono::seconds(12), "new_world"); } // 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); } // Invoke despawn callbacks for all entities before clearing, so the renderer // can release M2 instances, character models, and associated resources. for (const auto& [guid, entity] : entityManager.getEntities()) { if (guid == playerGuid) continue; // skip self if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { creatureDespawnCallback_(guid); } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { playerDespawnCallback_(guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(guid); } } otherPlayerVisibleItemEntries_.clear(); otherPlayerVisibleDirty_.clear(); otherPlayerMoveTimeMs_.clear(); unitCastStates_.clear(); unitAurasCache_.clear(); combatText.clear(); 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; castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; // 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"); } timeSinceLastPing = 0.0f; if (socket) { LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK"); sendPing(); } // Reload terrain at new position. // Pass isSameMap as isInitialEntry so the application despawns and // re-registers renderer instances before the server resends CREATE_OBJECTs. // Without this, same-map SMSG_NEW_WORLD (dungeon wing teleporters, etc.) // leaves zombie renderer instances that block fresh entity spawns. if (worldEntryCallback_) { worldEntryCallback_(mapId, serverX, serverY, serverZ, isSameMap); } } // ============================================================ // 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(); } { auto destIt = taxiNodes_.find(destNodeId); if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) { taxiDestName_ = destIt->second.name; addSystemChatMessage("Requesting flight to " + destIt->second.name + "..."); } else { taxiDestName_.clear(); 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; } // Save recovery target in case of disconnect during taxi. auto destIt = taxiNodes_.find(destNodeId); if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) addSystemChatMessage("Flight to " + destIt->second.name + " started."); else addSystemChatMessage("Flight started."); 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; } totalTimePlayed_ = data.totalTimePlayed; levelTimePlayed_ = data.levelTimePlayed; 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"); // Store structured results for the who-results window whoResults_.clear(); whoOnlineCount_ = onlineCount; if (displayCount == 0) { addSystemChatMessage("No players found."); return; } 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(); // Store structured entry WhoEntry entry; entry.name = playerName; entry.guildName = guildName; entry.level = level; entry.classId = classId; entry.raceId = raceId; entry.zoneId = zoneId; whoResults_.push_back(std::move(entry)); 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)); } if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {}); } 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_); if (addonEventCallback_) { addonEventCallback_("FRIENDLIST_UPDATE", {}); if (lastContactListMask_ & 0x2) // ignore list addonEventCallback_("IGNORELIST_UPDATE", {}); } } 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: contacts_ (populated by SMSG_FRIEND_LIST) > playerNameCache std::string playerName; { auto cit2 = std::find_if(contacts_.begin(), contacts_.end(), [&](const ContactEntry& e){ return e.guid == data.guid; }); if (cit2 != contacts_.end() && !cit2->name.empty()) { playerName = cit2->name; } else { auto it = playerNameCache.find(data.guid); if (it != playerNameCache.end()) playerName = it->second; } } // 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); if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {}); } 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..."); logoutCountdown_ = 0.0f; } else { addSystemChatMessage("Logging out in 20 seconds..."); logoutCountdown_ = 20.0f; } LOG_INFO("Logout response: success, instant=", (int)data.instant); } else { // Failure addSystemChatMessage("Cannot logout right now."); loggingOut_ = false; logoutCountdown_ = 0.0f; LOG_WARNING("Logout failed, result=", data.result); } } void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) { addSystemChatMessage("Logout complete."); loggingOut_ = false; logoutCountdown_ = 0.0f; 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; } bool GameHandler::isProfessionSpell(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return false; auto catIt = skillLineCategories_.find(slIt->second); if (catIt == skillLineCategories_.end()) return false; // Category 11 = profession (Blacksmithing, etc.), 9 = secondary (Cooking, First Aid, Fishing) return catIt->second == 11 || catIt->second == 9; } 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; uint16_t bonusTemp = 0; uint16_t bonusPerm = 0; auto bonusIt = fields.find(static_cast(baseField + 2)); if (bonusIt != fields.end()) { bonusTemp = bonusIt->second & 0xFFFF; bonusPerm = (bonusIt->second >> 16) & 0xFFFF; } PlayerSkill skill; skill.skillId = skillId; skill.value = value; skill.maxValue = maxValue; skill.bonusTemp = bonusTemp; skill.bonusPerm = bonusPerm; 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) + "."); } } bool skillsChanged = (newSkills.size() != playerSkills_.size()); if (!skillsChanged) { for (const auto& [id, sk] : newSkills) { auto it = playerSkills_.find(id); if (it == playerSkills_.end() || it->second.value != sk.value) { skillsChanged = true; break; } } } playerSkills_ = std::move(newSkills); if (skillsChanged && addonEventCallback_) addonEventCallback_("SKILL_LINES_CHANGED", {}); } 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; } static const std::string EMPTY_MACRO_TEXT; const std::string& GameHandler::getMacroText(uint32_t macroId) const { auto it = macros_.find(macroId); return (it != macros_.end()) ? it->second : EMPTY_MACRO_TEXT; } void GameHandler::setMacroText(uint32_t macroId, const std::string& text) { if (text.empty()) macros_.erase(macroId); else macros_[macroId] = text; saveCharacterConfig(); } 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 client-side macro text (escape newlines as \n literal) for (const auto& [id, text] : macros_) { if (!text.empty()) { std::string escaped; escaped.reserve(text.size()); for (char c : text) { if (c == '\n') { escaped += "\\n"; } else if (c == '\r') { /* skip CR */ } else if (c == '\\') { escaped += "\\\\"; } else { escaped += c; } } out << "macro_" << id << "_text=" << escaped << "\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"; } // Save tracked quest IDs so the quest tracker restores on login if (!trackedQuestIds_.empty()) { std::string ids; for (uint32_t qid : trackedQuestIds_) { if (!ids.empty()) ids += ','; ids += std::to_string(qid); } out << "tracked_quests=" << ids << "\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("macro_", 0) == 0) { // Parse macro_N_text size_t firstUnder = 6; // length of "macro_" size_t secondUnder = key.find('_', firstUnder); if (secondUnder == std::string::npos) continue; uint32_t macroId = 0; try { macroId = static_cast(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; } if (key.substr(secondUnder + 1) == "text" && !val.empty()) { // Unescape \n and \\ sequences std::string unescaped; unescaped.reserve(val.size()); for (size_t i = 0; i < val.size(); ++i) { if (val[i] == '\\' && i + 1 < val.size()) { if (val[i+1] == 'n') { unescaped += '\n'; ++i; } else if (val[i+1] == '\\') { unescaped += '\\'; ++i; } else { unescaped += val[i]; } } else { unescaped += val[i]; } } macros_[macroId] = std::move(unescaped); } } else if (key == "tracked_quests" && !val.empty()) { // Parse comma-separated quest IDs trackedQuestIds_.clear(); size_t tqPos = 0; while (tqPos <= val.size()) { size_t comma = val.find(',', tqPos); std::string idStr = (comma != std::string::npos) ? val.substr(tqPos, comma - tqPos) : val.substr(tqPos); try { uint32_t qid = static_cast(std::stoul(idStr)); if (qid != 0) trackedQuestIds_.insert(qid); } catch (...) {} if (comma == std::string::npos) break; tqPos = comma + 1; } } 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() { bool wasOpen = mailboxOpen_; mailboxOpen_ = false; mailboxGuid_ = 0; mailInbox_.clear(); selectedMailIndex_ = -1; showMailCompose_ = false; if (wasOpen && addonEventCallback_) addonEventCallback_("MAIL_CLOSED", {}); } 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 itemGuidLow) { if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return; auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemGuidLow); 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; if (addonEventCallback_) addonEventCallback_("MAIL_SHOW", {}); // 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; } if (addonEventCallback_) addonEventCallback_("MAIL_INBOX_UPDATE", {}); } 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 (addonEventCallback_) addonEventCallback_("UPDATE_PENDING_MAIL", {}); // 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() { bool wasOpen = bankOpen_; bankOpen_ = false; bankerGuid_ = 0; if (wasOpen && addonEventCallback_) addonEventCallback_("BANKFRAME_CLOSED", {}); } 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 if (addonEventCallback_) addonEventCallback_("BANKFRAME_OPENED", {}); // 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() { bool wasOpen = auctionOpen_; auctionOpen_ = false; auctioneerGuid_ = 0; if (wasOpen && addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_CLOSED", {}); } 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 if (addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_SHOW", {}); 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; addUIError(msg); 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()) { auto nit = playerNameCache.find(sharedQuestSharerGuid_); if (nit != playerNameCache.end()) sharedQuestSharerName_ = nit->second; } 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()) { auto nit = playerNameCache.find(summonerGuid_); if (nit != playerNameCache.end()) summonerName_ = nit->second; } if (summonerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", static_cast(summonerGuid_)); summonerName_ = tmp; } std::string msg = summonerName_ + " is summoning you"; std::string zoneName = getAreaName(zoneId); if (!zoneName.empty()) msg += " to " + zoneName; msg += '.'; addSystemChatMessage(msg); LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_, " zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s"); if (addonEventCallback_) addonEventCallback_("CONFIRM_SUMMON", {}); } 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()) { auto nit = playerNameCache.find(tradePeerGuid_); if (nit != playerNameCache.end()) tradePeerName_ = nit->second; } 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."); if (addonEventCallback_) addonEventCallback_("TRADE_REQUEST", {}); break; } case 2: // OPEN_WINDOW myTradeSlots_.fill(TradeSlot{}); peerTradeSlots_.fill(TradeSlot{}); myTradeGold_ = 0; peerTradeGold_ = 0; tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); if (addonEventCallback_) addonEventCallback_("TRADE_SHOW", {}); break; case 3: // CANCELLED case 12: // CLOSE_WINDOW resetTradeState(); addSystemChatMessage("Trade cancelled."); if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); break; case 9: // REJECTED — other player clicked Decline resetTradeState(); addSystemChatMessage("Trade declined."); if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); break; case 4: // ACCEPTED (partner accepted) tradeStatus_ = TradeStatus::Accepted; addSystemChatMessage("Trade accepted. Awaiting other player..."); if (addonEventCallback_) addonEventCallback_("TRADE_ACCEPT_UPDATE", {}); break; case 8: // COMPLETE addSystemChatMessage("Trade complete!"); if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); 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 (!isTradeOpen() || !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) { // SMSG_TRADE_STATUS_EXTENDED format differs by expansion: // // Classic/TBC: // uint8 isSelf + uint32 slotCount + [slots] + uint64 coins // Per slot tail (after isWrapped): giftCreatorGuid(8) + enchants(24) + // randomPropertyId(4) + suffixFactor(4) + durability(4) + maxDurability(4) = 48 bytes // // WotLK 3.3.5a adds: // uint32 tradeId (after isSelf, before slotCount) // Per slot: + createPlayedTime(4) at end of trail → trail = 52 bytes // // Minimum: isSelf(1) + [tradeId(4)] + slotCount(4) = 5 or 9 bytes const bool isWotLK = isActiveExpansion("wotlk"); size_t minHdr = isWotLK ? 9u : 5u; if (packet.getSize() - packet.getReadPos() < minHdr) return; uint8_t isSelf = packet.readUInt8(); if (isWotLK) { /*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field } uint32_t slotCount = packet.readUInt32(); // Per-slot tail bytes after isWrapped: // Classic/TBC: giftCreatorGuid(8) + enchants(24) + stats(16) = 48 // WotLK: same + createPlayedTime(4) = 52 const size_t SLOT_TRAIL = isWotLK ? 52u : 48u; 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); } 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; pendingLootRoll_.playerRolls.clear(); // 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; pendingLootRoll_.rollCountdownMs = 60000; pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); 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"; // Track in the live roll list while our popup is open for the same item if (pendingLootRollActive_ && pendingLootRoll_.objectGuid == objectGuid && pendingLootRoll_.slot == slot) { bool found = false; for (auto& r : pendingLootRoll_.playerRolls) { if (r.playerName == rollerName) { r.rollNum = rollNum; r.rollType = rollType; found = true; break; } } if (!found) { LootRollEntry::PlayerRollResult prr; prr.playerName = rollerName; prr.rollNum = rollNum; prr.rollType = rollType; pendingLootRoll_.playerRolls.push_back(std::move(prr)); } } auto* info = getItemInfo(itemId); std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId); uint32_t rollItemQuality = info ? info->quality : 1u; std::string rollItemLink = buildItemLink(itemId, rollItemQuality, iName); addSystemChatMessage(rollerName + " rolls " + rollName + " (" + std::to_string(rollNum) + ") on " + rollItemLink); 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(); int32_t wonRandProp = 0; if (isWotLK) { /*uint32_t randSuffix =*/ packet.readUInt32(); wonRandProp = static_cast(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.empty() ? info->name : std::to_string(itemId); if (wonRandProp != 0) { std::string suffix = getRandomPropertyName(wonRandProp); if (!suffix.empty()) iName += " " + suffix; } uint32_t wonItemQuality = info ? info->quality : 1u; std::string wonItemLink = buildItemLink(itemId, wonItemQuality, iName); addSystemChatMessage(winnerName + " wins " + wonItemLink + " (" + rollName + " " + std::to_string(rollNum) + ")!"); // Dismiss roll popup — roll contest is over regardless of who won 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::loadTitleNameCache() { if (titleNameCacheLoaded_) return; titleNameCacheLoaded_ = true; auto* am = core::Application::getInstance().getAssetManager(); if (!am || !am->isInitialized()) return; auto dbc = am->loadDBC("CharTitles.dbc"); if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 5) return; const auto* layout = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharTitles") : nullptr; uint32_t titleField = layout ? layout->field("Title") : 2; uint32_t bitField = layout ? layout->field("TitleBit") : 36; if (titleField == 0xFFFFFFFF) titleField = 2; if (bitField == 0xFFFFFFFF) bitField = static_cast(dbc->getFieldCount() - 1); for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t bit = dbc->getUInt32(i, bitField); if (bit == 0) continue; std::string name = dbc->getString(i, titleField); if (!name.empty()) titleNameCache_[bit] = std::move(name); } LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC"); } std::string GameHandler::getFormattedTitle(uint32_t bit) const { const_cast(this)->loadTitleNameCache(); auto it = titleNameCache_.find(bit); if (it == titleNameCache_.end() || it->second.empty()) return {}; static const std::string kUnknown = "unknown"; auto nameIt = playerNameCache.find(playerGuid); const std::string& pName = (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown; const std::string& fmt = it->second; size_t pos = fmt.find("%s"); if (pos != std::string::npos) { return fmt.substr(0, pos) + pName + fmt.substr(pos + 2); } return fmt; } void GameHandler::sendSetTitle(int32_t bit) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = SetTitlePacket::build(bit); socket->send(packet); chosenTitleBit_ = bit; LOG_INFO("sendSetTitle: bit=", bit); } 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; uint32_t descField = achL ? achL->field("Description") : 0xFFFFFFFF; uint32_t ptsField = achL ? achL->field("Points") : 0xFFFFFFFF; uint32_t fieldCount = dbc->getFieldCount(); 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); if (descField != 0xFFFFFFFF && descField < fieldCount) { std::string desc = dbc->getString(i, descField); if (!desc.empty()) achievementDescCache_[id] = std::move(desc); } if (ptsField != 0xFFFFFFFF && ptsField < fieldCount) { uint32_t pts = dbc->getUInt32(i, ptsField); if (pts > 0) achievementPointsCache_[id] = pts; } } 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 earnDate = packet.readUInt32(); // WoW PackedTime bitfield 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); achievementDates_[achievementId] = earnDate; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playAchievementAlert(); } 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()) { auto nit = playerNameCache.find(guid); if (nit != playerNameCache.end()) senderName = nit->second; } 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); if (addonEventCallback_) addonEventCallback_("ACHIEVEMENT_EARNED", {std::to_string(achievementId)}); } // --------------------------------------------------------------------------- // 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(); achievementDates_.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); achievementDates_[id] = date; } // Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF criteriaProgress_.clear(); 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; uint64_t counter = packet.readUInt64(); packet.readUInt32(); // date packet.readUInt32(); // unknown / flags criteriaProgress_[id] = counter; } LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " achievements, ", criteriaProgress_.size(), " criteria"); } // --------------------------------------------------------------------------- // SMSG_RESPOND_INSPECT_ACHIEVEMENTS (WotLK 3.3.5a) // Wire format: packed_guid (inspected player) + same achievement/criteria // blocks as SMSG_ALL_ACHIEVEMENT_DATA: // Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel // Criteria records: repeated { uint32 id, uint64 counter, uint32 date, uint32 unk } // until 0xFFFFFFFF sentinel // We store only the earned achievement IDs (not criteria) per inspected player. // --------------------------------------------------------------------------- void GameHandler::handleRespondInspectAchievements(network::Packet& packet) { loadAchievementNameCache(); // Read the inspected player's packed guid if (packet.getSize() - packet.getReadPos() < 1) return; uint64_t inspectedGuid = UpdateObjectParser::readPackedGuid(packet); if (inspectedGuid == 0) { packet.setReadPos(packet.getSize()); return; } std::unordered_set achievements; // Achievement records: { uint32 id, uint32 packedDate } until 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(); achievements.insert(id); } // Criteria records: { uint32 id, uint64 counter, uint32 date, uint32 unk } // until sentinel 0xFFFFFFFF — consume but don't store for inspect use while (packet.getSize() - packet.getReadPos() >= 4) { uint32_t id = packet.readUInt32(); if (id == 0xFFFFFFFF) break; // counter(8) + date(4) + unk(4) = 16 bytes if (packet.getSize() - packet.getReadPos() < 16) break; packet.readUInt64(); // counter packet.readUInt32(); // date packet.readUInt32(); // unk } inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements); LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec, " achievements=", inspectedPlayerAchievements_[inspectedGuid].size()); } // --------------------------------------------------------------------------- // 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: ReputationListID (-1 / 0xFFFFFFFF = no reputation tracking) // 2-5: ReputationRaceMask[4] // 6-9: ReputationClassMask[4] // 10-13: ReputationBase[4] // 14-17: ReputationFlags[4] // 18: ParentFactionID // 19-20: SpilloverRateIn, SpilloverRateOut (floats) // 21-22: SpilloverMaxRankIn, SpilloverMaxRankOut // 23: Name (English locale, string ref) constexpr uint32_t ID_FIELD = 0; constexpr uint32_t REPLIST_FIELD = 1; constexpr uint32_t NAME_FIELD = 23; // enUS name string // Classic/TBC have fewer fields; fall back gracefully const bool hasRepListField = dbc->getFieldCount() > REPLIST_FIELD; if (dbc->getFieldCount() <= NAME_FIELD) { LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount()); // Don't abort — still try to load names from a shorter layout } const uint32_t nameField = (dbc->getFieldCount() > NAME_FIELD) ? NAME_FIELD : 22u; 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; if (dbc->getFieldCount() > nameField) { std::string name = dbc->getString(i, nameField); if (!name.empty()) { factionNameCache_[factionId] = std::move(name); } } // Build repListId ↔ factionId mapping (WotLK field 1) if (hasRepListField) { uint32_t repListId = dbc->getUInt32(i, REPLIST_FIELD); if (repListId != 0xFFFFFFFFu) { factionRepListToId_[repListId] = factionId; factionIdToRepList_[factionId] = repListId; } } } LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names, ", factionRepListToId_.size(), " with reputation tracking"); } uint32_t GameHandler::getFactionIdByRepListId(uint32_t repListId) const { const_cast(this)->loadFactionNameCache(); auto it = factionRepListToId_.find(repListId); return (it != factionRepListToId_.end()) ? it->second : 0u; } uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const { const_cast(this)->loadFactionNameCache(); auto it = factionIdToRepList_.find(factionId); return (it != factionIdToRepList_.end()) ? it->second : 0xFFFFFFFFu; } void GameHandler::setWatchedFactionId(uint32_t factionId) { watchedFactionId_ = factionId; if (state != WorldState::IN_WORLD || !socket) return; // CMSG_SET_WATCHED_FACTION: int32 repListId (-1 = unwatch) int32_t repListId = -1; if (factionId != 0) { uint32_t rl = getRepListIdByFactionId(factionId); if (rl != 0xFFFFFFFFu) repListId = static_cast(rl); } network::Packet pkt(wireOpcode(Opcode::CMSG_SET_WATCHED_FACTION)); pkt.writeUInt32(static_cast(repListId)); socket->send(pkt); LOG_DEBUG("CMSG_SET_WATCHED_FACTION: repListId=", repListId, " (factionId=", factionId, ")"); } 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{}; } void GameHandler::loadMapNameCache() { if (mapNameCacheLoaded_) return; mapNameCacheLoaded_ = true; auto* am = core::Application::getInstance().getAssetManager(); if (!am || !am->isInitialized()) return; auto dbc = am->loadDBC("Map.dbc"); if (!dbc || !dbc->isLoaded()) return; for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, 0); // Field 2 = MapName_enUS (first localized); field 1 = InternalName fallback std::string name = dbc->getString(i, 2); if (name.empty()) name = dbc->getString(i, 1); if (!name.empty() && !mapNameCache_.count(id)) { mapNameCache_[id] = std::move(name); } } LOG_INFO("Map.dbc: loaded ", mapNameCache_.size(), " map names"); } std::string GameHandler::getMapName(uint32_t mapId) const { if (mapId == 0) return {}; const_cast(this)->loadMapNameCache(); auto it = mapNameCache_.find(mapId); return (it != mapNameCache_.end()) ? it->second : std::string{}; } // --------------------------------------------------------------------------- // LFG dungeon name cache (WotLK: LFGDungeons.dbc) // --------------------------------------------------------------------------- void GameHandler::loadLfgDungeonDbc() { if (lfgDungeonNameCacheLoaded_) return; lfgDungeonNameCacheLoaded_ = true; auto* am = core::Application::getInstance().getAssetManager(); if (!am || !am->isInitialized()) return; auto dbc = am->loadDBC("LFGDungeons.dbc"); if (!dbc || !dbc->isLoaded()) return; const auto* layout = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("LFGDungeons") : nullptr; const uint32_t idField = layout ? (*layout)["ID"] : 0; const uint32_t nameField = layout ? (*layout)["Name"] : 1; for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, idField); if (id == 0) continue; std::string name = dbc->getString(i, nameField); if (!name.empty()) lfgDungeonNameCache_[id] = std::move(name); } LOG_INFO("LFGDungeons.dbc: loaded ", lfgDungeonNameCache_.size(), " dungeon names"); } std::string GameHandler::getLfgDungeonName(uint32_t dungeonId) const { if (dungeonId == 0) return {}; const_cast(this)->loadLfgDungeonDbc(); auto it = lfgDungeonNameCache_.find(dungeonId); return (it != lfgDungeonNameCache_.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)); } // Populate public-facing info equipmentSetInfo_.clear(); equipmentSetInfo_.reserve(equipmentSets_.size()); for (const auto& es : equipmentSets_) { EquipmentSetInfo info; info.setGuid = es.setGuid; info.setId = es.setId; info.name = es.name; info.iconName = es.iconName; equipmentSetInfo_.push_back(std::move(info)); } 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"); } // ---- Battlefield Manager (WotLK Wintergrasp / outdoor battlefields) ---- void GameHandler::acceptBfMgrInvite() { if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 1 network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); pkt.writeUInt8(1); // accepted socket->send(pkt); bfMgrInvitePending_ = false; LOG_INFO("acceptBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=1"); } void GameHandler::declineBfMgrInvite() { if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 0 network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); pkt.writeUInt8(0); // declined socket->send(pkt); bfMgrInvitePending_ = false; LOG_INFO("declineBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=0"); } // ---- WotLK Calendar ---- void GameHandler::requestCalendar() { if (state != WorldState::IN_WORLD || !socket) return; // CMSG_CALENDAR_GET_CALENDAR has no payload network::Packet pkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_CALENDAR)); socket->send(pkt); LOG_INFO("requestCalendar: sent CMSG_CALENDAR_GET_CALENDAR"); // Also request pending invite count network::Packet numPkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_NUM_PENDING)); socket->send(numPkt); } } // namespace game } // namespace wowee