mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-30 10:23:51 +00:00
Replace ~300 C-style casts ((int), (float), (uint32_t), etc.) with static_cast across 15 source files. Extract toLowerInPlace() helper in lua_engine.cpp to replace 72 identical tolower loop patterns.
26277 lines
1.2 MiB
26277 lines
1.2 MiB
#include "game/game_handler.hpp"
|
||
#include "game/packet_parsers.hpp"
|
||
#include "game/transport_manager.hpp"
|
||
#include "game/warden_crypto.hpp"
|
||
#include "game/warden_memory.hpp"
|
||
#include "game/warden_module.hpp"
|
||
#include "game/opcodes.hpp"
|
||
#include "game/update_field_table.hpp"
|
||
#include "game/expansion_profile.hpp"
|
||
#include "rendering/renderer.hpp"
|
||
#include "audio/activity_sound_manager.hpp"
|
||
#include "audio/combat_sound_manager.hpp"
|
||
#include "audio/spell_sound_manager.hpp"
|
||
#include "audio/ui_sound_manager.hpp"
|
||
#include "pipeline/dbc_layout.hpp"
|
||
#include "network/world_socket.hpp"
|
||
#include "network/packet.hpp"
|
||
#include "auth/crypto.hpp"
|
||
#include "core/coordinates.hpp"
|
||
#include "core/application.hpp"
|
||
#include "pipeline/asset_manager.hpp"
|
||
#include "pipeline/dbc_loader.hpp"
|
||
#include "core/logger.hpp"
|
||
#include <glm/gtx/quaternion.hpp>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cctype>
|
||
#include <ctime>
|
||
#include <random>
|
||
#include <zlib.h>
|
||
#include <chrono>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <sstream>
|
||
#include <unordered_map>
|
||
#include <unordered_set>
|
||
#include <functional>
|
||
#include <array>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <limits>
|
||
#include <openssl/sha.h>
|
||
#include <openssl/hmac.h>
|
||
|
||
namespace wowee {
|
||
namespace game {
|
||
|
||
namespace {
|
||
const char* worldStateName(WorldState state) {
|
||
switch (state) {
|
||
case WorldState::DISCONNECTED: return "DISCONNECTED";
|
||
case WorldState::CONNECTING: return "CONNECTING";
|
||
case WorldState::CONNECTED: return "CONNECTED";
|
||
case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED";
|
||
case WorldState::AUTH_SENT: return "AUTH_SENT";
|
||
case WorldState::AUTHENTICATED: return "AUTHENTICATED";
|
||
case WorldState::READY: return "READY";
|
||
case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED";
|
||
case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED";
|
||
case WorldState::ENTERING_WORLD: return "ENTERING_WORLD";
|
||
case WorldState::IN_WORLD: return "IN_WORLD";
|
||
case WorldState::FAILED: return "FAILED";
|
||
}
|
||
return "UNKNOWN";
|
||
}
|
||
|
||
bool isAuthCharPipelineOpcode(LogicalOpcode op) {
|
||
switch (op) {
|
||
case Opcode::SMSG_AUTH_CHALLENGE:
|
||
case Opcode::SMSG_AUTH_RESPONSE:
|
||
case Opcode::SMSG_CLIENTCACHE_VERSION:
|
||
case Opcode::SMSG_TUTORIAL_FLAGS:
|
||
case Opcode::SMSG_WARDEN_DATA:
|
||
case Opcode::SMSG_CHAR_ENUM:
|
||
case Opcode::SMSG_CHAR_CREATE:
|
||
case Opcode::SMSG_CHAR_DELETE:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 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<rrggbb>|Hitem:<id>:0:0:0:0:0:0:0:0|h[<name>]|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<int>(std::clamp<long>(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<float>(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<float>(thresholdMs);
|
||
}
|
||
|
||
float slowUpdateObjectBlockLogThresholdMs() {
|
||
static const int thresholdMs =
|
||
parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000);
|
||
return static_cast<float>(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<uint32_t>& 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<uint8_t>& data, size_t start, std::string& out, size_t& nextPos) {
|
||
out.clear();
|
||
if (start >= data.size()) return false;
|
||
size_t i = start;
|
||
while (i < data.size()) {
|
||
uint8_t b = data[i++];
|
||
if (b == 0) {
|
||
nextPos = i;
|
||
return true;
|
||
}
|
||
out.push_back(static_cast<char>(b));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
std::string asciiLower(std::string s) {
|
||
std::transform(s.begin(), s.end(), s.begin(),
|
||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||
return s;
|
||
}
|
||
|
||
std::vector<std::string> splitWowPath(const std::string& wowPath) {
|
||
std::vector<std::string> out;
|
||
std::string cur;
|
||
for (char c : wowPath) {
|
||
if (c == '\\' || c == '/') {
|
||
if (!cur.empty()) {
|
||
out.push_back(cur);
|
||
cur.clear();
|
||
}
|
||
continue;
|
||
}
|
||
cur.push_back(c);
|
||
}
|
||
if (!cur.empty()) out.push_back(cur);
|
||
return out;
|
||
}
|
||
|
||
int pathCaseScore(const std::string& name) {
|
||
int score = 0;
|
||
for (unsigned char c : name) {
|
||
if (std::islower(c)) score += 2;
|
||
else if (std::isupper(c)) score -= 1;
|
||
}
|
||
return score;
|
||
}
|
||
|
||
std::string resolveCaseInsensitiveDataPath(const std::string& dataRoot, const std::string& wowPath) {
|
||
if (dataRoot.empty() || wowPath.empty()) return std::string();
|
||
std::filesystem::path cur(dataRoot);
|
||
std::error_code ec;
|
||
if (!std::filesystem::exists(cur, ec) || !std::filesystem::is_directory(cur, ec)) {
|
||
return std::string();
|
||
}
|
||
|
||
for (const std::string& segment : splitWowPath(wowPath)) {
|
||
std::string wanted = asciiLower(segment);
|
||
std::filesystem::path bestPath;
|
||
int bestScore = std::numeric_limits<int>::min();
|
||
bool found = false;
|
||
|
||
for (const auto& entry : std::filesystem::directory_iterator(cur, ec)) {
|
||
if (ec) break;
|
||
std::string name = entry.path().filename().string();
|
||
if (asciiLower(name) != wanted) continue;
|
||
int score = pathCaseScore(name);
|
||
if (!found || score > bestScore) {
|
||
found = true;
|
||
bestScore = score;
|
||
bestPath = entry.path();
|
||
}
|
||
}
|
||
if (!found) return std::string();
|
||
cur = bestPath;
|
||
}
|
||
|
||
if (!std::filesystem::exists(cur, ec) || std::filesystem::is_directory(cur, ec)) {
|
||
return std::string();
|
||
}
|
||
return cur.string();
|
||
}
|
||
|
||
std::vector<uint8_t> readFileBinary(const std::string& fsPath) {
|
||
std::ifstream in(fsPath, std::ios::binary);
|
||
if (!in) return {};
|
||
in.seekg(0, std::ios::end);
|
||
std::streamoff size = in.tellg();
|
||
if (size <= 0) return {};
|
||
in.seekg(0, std::ios::beg);
|
||
std::vector<uint8_t> data(static_cast<size_t>(size));
|
||
in.read(reinterpret_cast<char*>(data.data()), size);
|
||
if (!in) return {};
|
||
return data;
|
||
}
|
||
|
||
bool hmacSha1Matches(const uint8_t seedBytes[4], const std::string& text, const uint8_t expected[20]) {
|
||
uint8_t out[SHA_DIGEST_LENGTH];
|
||
unsigned int outLen = 0;
|
||
HMAC(EVP_sha1(),
|
||
seedBytes, 4,
|
||
reinterpret_cast<const uint8_t*>(text.data()),
|
||
static_cast<int>(text.size()),
|
||
out, &outLen);
|
||
return outLen == SHA_DIGEST_LENGTH && std::memcmp(out, expected, SHA_DIGEST_LENGTH) == 0;
|
||
}
|
||
|
||
const std::unordered_map<std::string, std::array<uint8_t, 20>>& knownDoorHashes() {
|
||
static const std::unordered_map<std::string, std::array<uint8_t, 20>> k = {
|
||
{"world\\lordaeron\\stratholme\\activedoodads\\doors\\nox_door_plague.m2",
|
||
{0xB4,0x45,0x2B,0x6D,0x95,0xC9,0x8B,0x18,0x6A,0x70,0xB0,0x08,0xFA,0x07,0xBB,0xAE,0xF3,0x0D,0xF7,0xA2}},
|
||
{"world\\kalimdor\\onyxiaslair\\doors\\onyxiasgate01.m2",
|
||
{0x75,0x19,0x5E,0x4A,0xED,0xA0,0xBC,0xAF,0x04,0x8C,0xA0,0xE3,0x4D,0x95,0xA7,0x0D,0x4F,0x53,0xC7,0x46}},
|
||
{"world\\generic\\human\\activedoodads\\doors\\deadminedoor02.m2",
|
||
{0x3D,0xFF,0x01,0x1B,0x9A,0xB1,0x34,0xF3,0x7F,0x88,0x50,0x97,0xE6,0x95,0x35,0x1B,0x91,0x95,0x35,0x64}},
|
||
{"world\\kalimdor\\silithus\\activedoodads\\ahnqirajdoor\\ahnqirajdoor02.m2",
|
||
{0xDB,0xD4,0xF4,0x07,0xC4,0x68,0xCC,0x36,0x13,0x4E,0x62,0x1D,0x16,0x01,0x78,0xFD,0xA4,0xD0,0xD2,0x49}},
|
||
{"world\\kalimdor\\diremaul\\activedoodads\\doors\\diremaulsmallinstancedoor.m2",
|
||
{0x0D,0xC8,0xDB,0x46,0xC8,0x55,0x49,0xC0,0xFF,0x1A,0x60,0x0F,0x6C,0x23,0x63,0x57,0xC3,0x05,0x78,0x1A}},
|
||
};
|
||
return k;
|
||
}
|
||
|
||
bool isReadableQuestText(const std::string& s, size_t minLen, size_t maxLen) {
|
||
if (s.size() < minLen || s.size() > maxLen) return false;
|
||
bool hasAlpha = false;
|
||
for (unsigned char c : s) {
|
||
if (c < 0x20 || c > 0x7E) return false;
|
||
if (std::isalpha(c)) hasAlpha = true;
|
||
}
|
||
return hasAlpha;
|
||
}
|
||
|
||
bool isPlaceholderQuestTitle(const std::string& s) {
|
||
return s.rfind("Quest #", 0) == 0;
|
||
}
|
||
|
||
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<unsigned char>(s.front());
|
||
return std::isupper(first) != 0;
|
||
}
|
||
|
||
int scoreQuestTitle(const std::string& s) {
|
||
if (!isReadableQuestText(s, 4, 72)) return -1000;
|
||
if (looksLikeQuestDescriptionText(s)) return -1000;
|
||
int score = 0;
|
||
score += static_cast<int>(std::min<size_t>(s.size(), 32));
|
||
unsigned char first = static_cast<unsigned char>(s.front());
|
||
if (std::isupper(first)) score += 20;
|
||
if (std::islower(first)) score -= 20;
|
||
if (s.find(' ') != std::string::npos) score += 8;
|
||
if (s.find('.') != std::string::npos) score -= 18;
|
||
if (s.find('!') != std::string::npos || s.find('?') != std::string::npos) score -= 6;
|
||
return score;
|
||
}
|
||
|
||
struct QuestQueryTextCandidate {
|
||
std::string title;
|
||
std::string objectives;
|
||
int score = -1000;
|
||
};
|
||
|
||
QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector<uint8_t>& data, bool classicHint) {
|
||
QuestQueryTextCandidate best;
|
||
if (data.size() <= 9) return best;
|
||
|
||
std::vector<size_t> seedOffsets;
|
||
const size_t base = 8;
|
||
const size_t classicOffset = base + 40u * 4u;
|
||
const size_t wotlkOffset = base + 55u * 4u;
|
||
if (classicHint) {
|
||
seedOffsets.push_back(classicOffset);
|
||
seedOffsets.push_back(wotlkOffset);
|
||
} else {
|
||
seedOffsets.push_back(wotlkOffset);
|
||
seedOffsets.push_back(classicOffset);
|
||
}
|
||
for (size_t off : seedOffsets) {
|
||
if (off < data.size()) {
|
||
std::string title;
|
||
size_t next = off;
|
||
if (readCStringAt(data, off, title, next)) {
|
||
QuestQueryTextCandidate c;
|
||
c.title = title;
|
||
c.score = scoreQuestTitle(title) + 20; // Prefer expected struct offsets
|
||
|
||
std::string s2;
|
||
size_t n2 = next;
|
||
if (readCStringAt(data, next, s2, n2) && isReadableQuestText(s2, 8, 600)) {
|
||
c.objectives = s2;
|
||
}
|
||
if (c.score > best.score) best = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: scan packet for best printable C-string title candidate.
|
||
for (size_t start = 8; start < data.size(); ++start) {
|
||
std::string title;
|
||
size_t next = start;
|
||
if (!readCStringAt(data, start, title, next)) continue;
|
||
|
||
QuestQueryTextCandidate c;
|
||
c.title = title;
|
||
c.score = scoreQuestTitle(title);
|
||
if (c.score < 0) continue;
|
||
|
||
std::string s2, s3;
|
||
size_t n2 = next, n3 = next;
|
||
if (readCStringAt(data, next, s2, n2)) {
|
||
if (isReadableQuestText(s2, 8, 600)) c.objectives = s2;
|
||
else if (readCStringAt(data, n2, s3, n3) && isReadableQuestText(s3, 8, 600)) c.objectives = s3;
|
||
}
|
||
if (c.score > best.score) best = c;
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
// 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<Kill, 4> kills{};
|
||
std::array<Item, 6> items{};
|
||
bool valid = false;
|
||
};
|
||
|
||
static uint32_t readU32At(const std::vector<uint8_t>& d, size_t pos) {
|
||
return static_cast<uint32_t>(d[pos])
|
||
| (static_cast<uint32_t>(d[pos + 1]) << 8)
|
||
| (static_cast<uint32_t>(d[pos + 2]) << 16)
|
||
| (static_cast<uint32_t>(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<uint8_t>& 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<int32_t>(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<uint8_t>& 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<uint32_t, 4> itemId{};
|
||
std::array<uint32_t, 4> itemCount{};
|
||
std::array<uint32_t, 6> choiceItemId{};
|
||
std::array<uint32_t, 6> choiceItemCount{};
|
||
bool valid = false;
|
||
};
|
||
|
||
static QuestQueryRewards tryParseQuestRewards(const std::vector<uint8_t>& 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<int32_t>(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<WotlkPacketParsers>();
|
||
|
||
// Initialize transport manager
|
||
transportManager_ = std::make_unique<TransportManager>();
|
||
|
||
// Initialize Warden module manager
|
||
wardenModuleManager_ = std::make_unique<WardenModuleManager>();
|
||
|
||
// Default spells always available
|
||
knownSpells.insert(6603); // Attack
|
||
knownSpells.insert(8690); // Hearthstone
|
||
|
||
// Default action bar layout
|
||
actionBar[0].type = ActionBarSlot::SPELL;
|
||
actionBar[0].id = 6603; // Attack in slot 1
|
||
actionBar[11].type = ActionBarSlot::SPELL;
|
||
actionBar[11].id = 8690; // Hearthstone in slot 12
|
||
|
||
// Build the opcode dispatch table (replaces switch(*logicalOp) in handlePacket)
|
||
registerOpcodeHandlers();
|
||
}
|
||
|
||
GameHandler::~GameHandler() {
|
||
disconnect();
|
||
}
|
||
|
||
void GameHandler::setPacketParsers(std::unique_ptr<PacketParsers> parsers) {
|
||
packetParsers_ = std::move(parsers);
|
||
}
|
||
|
||
bool GameHandler::connect(const std::string& host,
|
||
uint16_t port,
|
||
const std::vector<uint8_t>& sessionKey,
|
||
const std::string& accountName,
|
||
uint32_t build,
|
||
uint32_t realmId) {
|
||
|
||
if (sessionKey.size() != 40) {
|
||
LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)");
|
||
fail("Invalid session key");
|
||
return false;
|
||
}
|
||
|
||
LOG_INFO("========================================");
|
||
LOG_INFO(" CONNECTING TO WORLD SERVER");
|
||
LOG_INFO("========================================");
|
||
LOG_INFO("Host: ", host);
|
||
LOG_INFO("Port: ", port);
|
||
LOG_INFO("Account: ", accountName);
|
||
LOG_INFO("Build: ", build);
|
||
|
||
// Store authentication data
|
||
this->sessionKey = sessionKey;
|
||
this->accountName = accountName;
|
||
this->build = build;
|
||
this->realmId_ = realmId;
|
||
|
||
// Diagnostic: dump session key for AUTH_REJECT debugging
|
||
{
|
||
std::string hex;
|
||
for (uint8_t b : sessionKey) { char buf[4]; snprintf(buf, sizeof(buf), "%02x", b); hex += buf; }
|
||
LOG_INFO("GameHandler session key (", sessionKey.size(), "): ", hex);
|
||
}
|
||
requiresWarden_ = false;
|
||
wardenGateSeen_ = false;
|
||
wardenGateElapsed_ = 0.0f;
|
||
wardenGateNextStatusLog_ = 2.0f;
|
||
wardenPacketsAfterGate_ = 0;
|
||
wardenCharEnumBlockedLogged_ = false;
|
||
wardenCrypto_.reset();
|
||
wardenState_ = WardenState::WAIT_MODULE_USE;
|
||
wardenModuleHash_.clear();
|
||
wardenModuleKey_.clear();
|
||
wardenModuleSize_ = 0;
|
||
wardenModuleData_.clear();
|
||
wardenLoadedModule_.reset();
|
||
|
||
// Generate random client seed
|
||
this->clientSeed = generateClientSeed();
|
||
LOG_DEBUG("Generated client seed: 0x", std::hex, clientSeed, std::dec);
|
||
|
||
// Create world socket
|
||
socket = std::make_unique<network::WorldSocket>();
|
||
|
||
// Set up packet callback
|
||
socket->setPacketCallback([this](const network::Packet& packet) {
|
||
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<float, std::milli>(
|
||
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<float, std::milli>(
|
||
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<uint8_t> encrypted = wardenCrypto_->encrypt(plaintext);
|
||
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
|
||
for (uint8_t byte : encrypted) {
|
||
response.writeUInt8(byte);
|
||
}
|
||
if (socket && socket->isConnected()) {
|
||
socket->send(response);
|
||
LOG_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::milliseconds>(
|
||
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_) {
|
||
fireAddonEvent(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<Player>(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<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::ASCENDING) |
|
||
static_cast<uint32_t>(MovementFlags::FALLING) |
|
||
static_cast<uint32_t>(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<Unit>(playerEntity);
|
||
if (unit &&
|
||
(unit->getUnitFlags() & 0x00000100) == 0 &&
|
||
!taxiClientActive_ &&
|
||
!taxiActivatePending_ &&
|
||
taxiStartGrace_ <= 0.0f) {
|
||
onTaxiFlight_ = false;
|
||
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
|
||
if (taxiMountActive_ && mountCallback_) {
|
||
mountCallback_(0);
|
||
}
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
currentMountDisplayId_ = 0;
|
||
taxiClientActive_ = false;
|
||
taxiClientPath_.clear();
|
||
taxiRecoverPending_ = false;
|
||
movementInfo.flags = 0;
|
||
movementInfo.flags2 = 0;
|
||
if (socket) {
|
||
sendMovement(Opcode::MSG_MOVE_STOP);
|
||
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
||
}
|
||
LOG_INFO("Taxi flight landed");
|
||
}
|
||
}
|
||
|
||
// Safety: if taxi flight ended but mount is still active, force dismount.
|
||
// Guard against transient taxi-state flicker.
|
||
if (!onTaxiFlight_ && taxiMountActive_) {
|
||
bool serverStillTaxi = false;
|
||
auto playerEntity = entityManager.getEntity(playerGuid);
|
||
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
||
if (playerUnit) {
|
||
serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0;
|
||
}
|
||
|
||
if (taxiStartGrace_ > 0.0f || serverStillTaxi || taxiClientActive_ || taxiActivatePending_) {
|
||
onTaxiFlight_ = true;
|
||
} else {
|
||
if (mountCallback_) mountCallback_(0);
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
currentMountDisplayId_ = 0;
|
||
movementInfo.flags = 0;
|
||
movementInfo.flags2 = 0;
|
||
if (socket) {
|
||
sendMovement(Opcode::MSG_MOVE_STOP);
|
||
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
||
}
|
||
LOG_INFO("Taxi dismount cleanup");
|
||
}
|
||
}
|
||
|
||
// Keep non-taxi mount state server-authoritative.
|
||
// Some server paths don't emit explicit mount field updates in lockstep
|
||
// with local visual state changes, so reconcile continuously.
|
||
if (!onTaxiFlight_ && !taxiMountActive_) {
|
||
auto playerEntity = entityManager.getEntity(playerGuid);
|
||
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
||
if (playerUnit) {
|
||
uint32_t serverMountDisplayId = playerUnit->getMountDisplayId();
|
||
if (serverMountDisplayId != currentMountDisplayId_) {
|
||
LOG_INFO("Mount reconcile: server=", serverMountDisplayId,
|
||
" local=", currentMountDisplayId_);
|
||
currentMountDisplayId_ = serverMountDisplayId;
|
||
if (mountCallback_) {
|
||
mountCallback_(serverMountDisplayId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (taxiRecoverPending_ && state == WorldState::IN_WORLD) {
|
||
auto playerEntity = entityManager.getEntity(playerGuid);
|
||
if (playerEntity) {
|
||
playerEntity->setPosition(taxiRecoverPos_.x, taxiRecoverPos_.y,
|
||
taxiRecoverPos_.z, movementInfo.orientation);
|
||
movementInfo.x = taxiRecoverPos_.x;
|
||
movementInfo.y = taxiRecoverPos_.y;
|
||
movementInfo.z = taxiRecoverPos_.z;
|
||
if (socket) {
|
||
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
||
}
|
||
taxiRecoverPending_ = false;
|
||
LOG_INFO("Taxi recovery applied");
|
||
}
|
||
}
|
||
|
||
if (taxiActivatePending_) {
|
||
taxiActivateTimer_ += deltaTime;
|
||
if (taxiActivateTimer_ > 5.0f) {
|
||
// If client taxi simulation is already active, server reply may be missing/late.
|
||
// Do not cancel the flight in that case; clear pending state and continue.
|
||
if (onTaxiFlight_ || taxiClientActive_ || taxiMountActive_) {
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
} else {
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
if (taxiMountActive_ && mountCallback_) {
|
||
mountCallback_(0);
|
||
}
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
taxiClientActive_ = false;
|
||
taxiClientPath_.clear();
|
||
onTaxiFlight_ = false;
|
||
LOG_WARNING("Taxi activation timed out");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update transport manager
|
||
if (transportManager_) {
|
||
transportManager_->update(deltaTime);
|
||
updateAttachedTransportChildren(deltaTime);
|
||
}
|
||
|
||
// Leave combat if auto-attack target is too far away (leash range)
|
||
// and keep melee intent tightly synced while stationary.
|
||
if (autoAttackRequested_ && autoAttackTarget != 0) {
|
||
auto targetEntity = entityManager.getEntity(autoAttackTarget);
|
||
if (targetEntity) {
|
||
// Use latest server-authoritative target position to avoid stale
|
||
// interpolation snapshots masking out-of-range states.
|
||
const float targetX = targetEntity->getLatestX();
|
||
const float targetY = targetEntity->getLatestY();
|
||
const float targetZ = targetEntity->getLatestZ();
|
||
float dx = movementInfo.x - targetX;
|
||
float dy = movementInfo.y - targetY;
|
||
float dz = movementInfo.z - targetZ;
|
||
float dist = std::sqrt(dx * dx + dy * dy);
|
||
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||
const bool classicLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
||
if (dist > 40.0f) {
|
||
stopAutoAttack();
|
||
LOG_INFO("Left combat: target too far (", dist, " yards)");
|
||
} else if (state == WorldState::IN_WORLD && socket) {
|
||
bool allowResync = true;
|
||
const float meleeRange = classicLike ? 5.25f : 5.75f;
|
||
if (dist3d > meleeRange) {
|
||
autoAttackOutOfRange_ = true;
|
||
autoAttackOutOfRangeTime_ += deltaTime;
|
||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||
addSystemChatMessage("Target is too far away.");
|
||
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<float>(M_PI)) diff -= 2.0f * static_cast<float>(M_PI);
|
||
while (diff < -static_cast<float>(M_PI)) diff += 2.0f * static_cast<float>(M_PI);
|
||
const float facingThreshold = classicLike ? 0.035f : 0.12f; // ~2deg / ~7deg
|
||
if (std::abs(diff) > facingThreshold) {
|
||
movementInfo.orientation = desired;
|
||
sendMovement(Opcode::MSG_MOVE_SET_FACING);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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::registerOpcodeHandlers() {
|
||
// -----------------------------------------------------------------------
|
||
// Auth / session / pre-world handshake
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_AUTH_CHALLENGE] = [this](network::Packet& packet) {
|
||
if (state == WorldState::CONNECTED)
|
||
handleAuthChallenge(packet);
|
||
else
|
||
LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state));
|
||
};
|
||
dispatchTable_[Opcode::SMSG_AUTH_RESPONSE] = [this](network::Packet& packet) {
|
||
if (state == WorldState::AUTH_SENT)
|
||
handleAuthResponse(packet);
|
||
else
|
||
LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state));
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAR_CREATE] = [this](network::Packet& packet) {
|
||
handleCharCreateResponse(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) {
|
||
uint8_t result = packet.readUInt8();
|
||
lastCharDeleteResult_ = result;
|
||
bool success = (result == 0x00 || result == 0x47);
|
||
LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast<int>(result), success ? " (success)" : " (failed)");
|
||
requestCharacterList();
|
||
if (charDeleteCallback_) charDeleteCallback_(success);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) {
|
||
if (state == WorldState::CHAR_LIST_REQUESTED)
|
||
handleCharEnum(packet);
|
||
else
|
||
LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state));
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHARACTER_LOGIN_FAILED] = [this](network::Packet& packet) { handleCharLoginFailed(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOGIN_VERIFY_WORLD] = [this](network::Packet& packet) {
|
||
if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD)
|
||
handleLoginVerifyWorld(packet);
|
||
else
|
||
LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state));
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LOGIN_SETTIMESPEED] = [this](network::Packet& packet) { handleLoginSetTimeSpeed(packet); };
|
||
dispatchTable_[Opcode::SMSG_CLIENTCACHE_VERSION] = [this](network::Packet& packet) { handleClientCacheVersion(packet); };
|
||
dispatchTable_[Opcode::SMSG_TUTORIAL_FLAGS] = [this](network::Packet& packet) { handleTutorialFlags(packet); };
|
||
dispatchTable_[Opcode::SMSG_WARDEN_DATA] = [this](network::Packet& packet) { handleWardenData(packet); };
|
||
dispatchTable_[Opcode::SMSG_ACCOUNT_DATA_TIMES] = [this](network::Packet& packet) { handleAccountDataTimes(packet); };
|
||
dispatchTable_[Opcode::SMSG_MOTD] = [this](network::Packet& packet) { handleMotd(packet); };
|
||
dispatchTable_[Opcode::SMSG_NOTIFICATION] = [this](network::Packet& packet) { handleNotification(packet); };
|
||
dispatchTable_[Opcode::SMSG_PONG] = [this](network::Packet& packet) { handlePong(packet); };
|
||
|
||
// -----------------------------------------------------------------------
|
||
// World object updates
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) {
|
||
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
|
||
if (state == WorldState::IN_WORLD) handleUpdateObject(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) {
|
||
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
|
||
if (state == WorldState::IN_WORLD) handleCompressedUpdateObject(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) {
|
||
if (state == WorldState::IN_WORLD) handleDestroyObject(packet);
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Chat
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_MESSAGECHAT] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleMessageChat(packet); };
|
||
dispatchTable_[Opcode::SMSG_GM_MESSAGECHAT] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleMessageChat(packet); };
|
||
dispatchTable_[Opcode::SMSG_TEXT_EMOTE] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleTextEmote(packet); };
|
||
dispatchTable_[Opcode::SMSG_EMOTE] = [this](network::Packet& packet) {
|
||
if (state != WorldState::IN_WORLD) return;
|
||
if (packet.getSize() - packet.getReadPos() < 12) return;
|
||
uint32_t emoteAnim = packet.readUInt32();
|
||
uint64_t sourceGuid = packet.readUInt64();
|
||
if (emoteAnimCallback_ && sourceGuid != 0) emoteAnimCallback_(sourceGuid, emoteAnim);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHANNEL_NOTIFY] = [this](network::Packet& packet) {
|
||
if (state == WorldState::IN_WORLD || state == WorldState::ENTERING_WORLD)
|
||
handleChannelNotify(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAT_PLAYER_NOT_FOUND] = [this](network::Packet& packet) {
|
||
std::string name = packet.readString();
|
||
if (!name.empty()) addSystemChatMessage("No player named '" + name + "' is currently playing.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAT_PLAYER_AMBIGUOUS] = [this](network::Packet& packet) {
|
||
std::string name = packet.readString();
|
||
if (!name.empty()) addSystemChatMessage("Player name '" + name + "' is ambiguous.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAT_WRONG_FACTION] = [this](network::Packet& /*packet*/) {
|
||
addUIError("You cannot send messages to members of that faction.");
|
||
addSystemChatMessage("You cannot send messages to members of that faction.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAT_NOT_IN_PARTY] = [this](network::Packet& /*packet*/) {
|
||
addUIError("You are not in a party.");
|
||
addSystemChatMessage("You are not in a party.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAT_RESTRICTED] = [this](network::Packet& /*packet*/) {
|
||
addUIError("You cannot send chat messages in this area.");
|
||
addSystemChatMessage("You cannot send chat messages in this area.");
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Player info queries / social
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_QUERY_TIME_RESPONSE] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleQueryTimeResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_PLAYED_TIME] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handlePlayedTime(packet); };
|
||
dispatchTable_[Opcode::SMSG_WHO] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleWho(packet); };
|
||
dispatchTable_[Opcode::SMSG_WHOIS] = [this](network::Packet& packet) {
|
||
if (packet.getReadPos() < packet.getSize()) {
|
||
std::string whoisText = packet.readString();
|
||
if (!whoisText.empty()) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FRIEND_STATUS] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleFriendStatus(packet); };
|
||
dispatchTable_[Opcode::SMSG_CONTACT_LIST] = [this](network::Packet& packet) { handleContactList(packet); };
|
||
dispatchTable_[Opcode::SMSG_FRIEND_LIST] = [this](network::Packet& packet) { handleFriendList(packet); };
|
||
dispatchTable_[Opcode::SMSG_IGNORE_LIST] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
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 ", static_cast<int>(ignCount), " ignored players");
|
||
};
|
||
dispatchTable_[Opcode::MSG_RANDOM_ROLL] = [this](network::Packet& packet) { if (state == WorldState::IN_WORLD) handleRandomRoll(packet); };
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Item push / logout / entity queries
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_ITEM_PUSH_RESULT] = [this](network::Packet& packet) {
|
||
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();
|
||
/*uint8_t created =*/ packet.readUInt8();
|
||
uint8_t showInChat = packet.readUInt8();
|
||
/*uint8_t bagSlot =*/ packet.readUInt8();
|
||
/*uint32_t itemSlot =*/ packet.readUInt32();
|
||
uint32_t itemId = packet.readUInt32();
|
||
/*uint32_t suffixFactor =*/ packet.readUInt32();
|
||
int32_t randomProp = static_cast<int32_t>(packet.readUInt32());
|
||
uint32_t count = packet.readUInt32();
|
||
/*uint32_t totalCount =*/ packet.readUInt32();
|
||
queryItemInfo(itemId, 0);
|
||
if (showInChat) {
|
||
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
|
||
std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name;
|
||
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);
|
||
fireAddonEvent("CHAT_MSG_LOOT", {msg, "", std::to_string(itemId), std::to_string(count)});
|
||
} else {
|
||
pendingItemPushNotifs_.push_back({itemId, count});
|
||
}
|
||
}
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("BAG_UPDATE", {});
|
||
fireAddonEvent("UNIT_INVENTORY_CHANGED", {"player"});
|
||
}
|
||
LOG_INFO("Item push: itemId=", itemId, " count=", count, " showInChat=", static_cast<int>(showInChat));
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LOGOUT_RESPONSE] = [this](network::Packet& packet) { handleLogoutResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOGOUT_COMPLETE] = [this](network::Packet& packet) { handleLogoutComplete(packet); };
|
||
dispatchTable_[Opcode::SMSG_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) { handleNameQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_CREATURE_QUERY_RESPONSE] = [this](network::Packet& packet) { handleCreatureQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE] = [this](network::Packet& packet) { handleItemQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_INSPECT_TALENT] = [this](network::Packet& packet) { handleInspectResults(packet); };
|
||
dispatchTable_[Opcode::SMSG_ADDON_INFO] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
dispatchTable_[Opcode::SMSG_EXPECTED_SPAM_RECORDS] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
|
||
// -----------------------------------------------------------------------
|
||
// XP / exploration
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_LOG_XPGAIN] = [this](network::Packet& packet) { handleXpGain(packet); };
|
||
dispatchTable_[Opcode::SMSG_EXPLORATION_EXPERIENCE] = [this](network::Packet& packet) {
|
||
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<int32_t>(xpGained), 0, true);
|
||
if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained);
|
||
fireAddonEvent("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)});
|
||
}
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Pet feedback (pre-main pet block)
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_PET_TAME_FAILURE] = [this](network::Packet& packet) {
|
||
static 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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PET_ACTION_FEEDBACK] = [this](network::Packet& packet) {
|
||
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 (packet.getSize() - packet.getReadPos() < 1) return;
|
||
uint8_t msg = packet.readUInt8();
|
||
if (msg > 0 && msg < 7 && kPetFeedback[msg]) addSystemChatMessage(kPetFeedback[msg]);
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PET_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Quest failures
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_QUESTUPDATE_FAILED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t questId = packet.readUInt32();
|
||
auto questTitle = getQuestTitle(questId);
|
||
addSystemChatMessage(questTitle.empty() ? std::string("Quest failed!")
|
||
: ('"' + questTitle + "\" failed!"));
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_QUESTUPDATE_FAILEDTIMER] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t questId = packet.readUInt32();
|
||
auto questTitle = getQuestTitle(questId);
|
||
addSystemChatMessage(questTitle.empty() ? std::string("Quest timed out!")
|
||
: ('"' + questTitle + "\" has timed out."));
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Entity delta updates: health / power / world state / combo / timers / PvP
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_HEALTH_UPDATE] = [this](network::Packet& packet) {
|
||
const bool huTbc = isActiveExpansion("tbc");
|
||
if (packet.getSize() - packet.getReadPos() < (huTbc ? 8u : 2u)) return;
|
||
uint64_t guid = huTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t hp = packet.readUInt32();
|
||
auto entity = entityManager.getEntity(guid);
|
||
if (auto* unit = dynamic_cast<Unit*>(entity.get())) unit->setHealth(hp);
|
||
if (guid != 0) {
|
||
auto unitId = guidToUnitId(guid);
|
||
if (!unitId.empty()) fireAddonEvent("UNIT_HEALTH", {unitId});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_POWER_UPDATE] = [this](network::Packet& packet) {
|
||
const bool puTbc = isActiveExpansion("tbc");
|
||
if (packet.getSize() - packet.getReadPos() < (puTbc ? 8u : 2u)) return;
|
||
uint64_t guid = puTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint8_t powerType = packet.readUInt8();
|
||
uint32_t value = packet.readUInt32();
|
||
auto entity = entityManager.getEntity(guid);
|
||
if (auto* unit = dynamic_cast<Unit*>(entity.get())) unit->setPowerByType(powerType, value);
|
||
if (guid != 0) {
|
||
auto unitId = guidToUnitId(guid);
|
||
if (!unitId.empty()) {
|
||
fireAddonEvent("UNIT_POWER", {unitId});
|
||
if (guid == playerGuid) {
|
||
fireAddonEvent("ACTIONBAR_UPDATE_USABLE", {});
|
||
fireAddonEvent("SPELL_UPDATE_USABLE", {});
|
||
}
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_UPDATE_WORLD_STATE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
uint32_t field = packet.readUInt32();
|
||
uint32_t value = packet.readUInt32();
|
||
worldStates_[field] = value;
|
||
LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value);
|
||
fireAddonEvent("UPDATE_WORLD_STATES", {});
|
||
};
|
||
dispatchTable_[Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t serverTime = packet.readUInt32();
|
||
LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PVP_CREDIT] = [this](network::Packet& packet) {
|
||
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<int32_t>(honor), 0, true);
|
||
if (pvpHonorCallback_) pvpHonorCallback_(honor, victimGuid, rank);
|
||
fireAddonEvent("CHAT_MSG_COMBAT_HONOR_GAIN", {msg});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_UPDATE_COMBO_POINTS] = [this](network::Packet& packet) {
|
||
const bool cpTbc = isActiveExpansion("tbc");
|
||
if (packet.getSize() - packet.getReadPos() < (cpTbc ? 8u : 2u)) return;
|
||
uint64_t target = cpTbc ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
comboPoints_ = packet.readUInt8();
|
||
comboTarget_ = target;
|
||
LOG_DEBUG("SMSG_UPDATE_COMBO_POINTS: target=0x", std::hex, target,
|
||
std::dec, " points=", static_cast<int>(comboPoints_));
|
||
fireAddonEvent("PLAYER_COMBO_POINTS", {});
|
||
};
|
||
dispatchTable_[Opcode::SMSG_START_MIRROR_TIMER] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 21) return;
|
||
uint32_t type = packet.readUInt32();
|
||
int32_t value = static_cast<int32_t>(packet.readUInt32());
|
||
int32_t maxV = static_cast<int32_t>(packet.readUInt32());
|
||
int32_t scale = static_cast<int32_t>(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;
|
||
fireAddonEvent("MIRROR_TIMER_START", {
|
||
std::to_string(type), std::to_string(value),
|
||
std::to_string(maxV), std::to_string(scale),
|
||
paused ? "1" : "0"});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_STOP_MIRROR_TIMER] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t type = packet.readUInt32();
|
||
if (type < 3) {
|
||
mirrorTimers_[type].active = false;
|
||
mirrorTimers_[type].value = 0;
|
||
fireAddonEvent("MIRROR_TIMER_STOP", {std::to_string(type)});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PAUSE_MIRROR_TIMER] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint32_t type = packet.readUInt32();
|
||
uint8_t paused = packet.readUInt8();
|
||
if (type < 3) {
|
||
mirrorTimers_[type].paused = (paused != 0);
|
||
fireAddonEvent("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"});
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Cast result / spell proc
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_CAST_RESULT] = [this](network::Packet& packet) {
|
||
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;
|
||
craftQueueSpellId_ = 0; craftQueueRemaining_ = 0;
|
||
queuedSpellId_ = 0; queuedSpellTarget_ = 0;
|
||
int playerPowerType = -1;
|
||
if (auto pe = entityManager.getEntity(playerGuid)) {
|
||
if (auto pu = std::dynamic_pointer_cast<Unit>(pe))
|
||
playerPowerType = static_cast<int>(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_) {
|
||
fireAddonEvent("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)});
|
||
fireAddonEvent("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)});
|
||
}
|
||
MessageChatData msg;
|
||
msg.type = ChatType::SYSTEM;
|
||
msg.language = ChatLanguage::UNIVERSAL;
|
||
msg.message = errMsg;
|
||
addLocalChatMessage(msg);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPELL_FAILED_OTHER] = [this](network::Packet& packet) {
|
||
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);
|
||
if (addonEventCallback_) {
|
||
std::string unitId;
|
||
if (failOtherGuid == targetGuid) unitId = "target";
|
||
else if (failOtherGuid == focusGuid) unitId = "focus";
|
||
if (!unitId.empty()) {
|
||
fireAddonEvent("UNIT_SPELLCAST_FAILED", {unitId});
|
||
fireAddonEvent("UNIT_SPELLCAST_STOP", {unitId});
|
||
}
|
||
}
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PROCRESIST] = [this](network::Packet& packet) {
|
||
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()); return; }
|
||
uint64_t caster = readPrGuid();
|
||
if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)
|
||
|| (!prUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); return; }
|
||
uint64_t victim = readPrGuid();
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
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());
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Loot roll
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_LOOT_START_ROLL] = [this](network::Packet& packet) {
|
||
const bool isWotLK = isActiveExpansion("wotlk");
|
||
const size_t minSize = isWotLK ? 33u : 25u;
|
||
if (packet.getSize() - packet.getReadPos() < minSize) return;
|
||
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<int32_t>(packet.readUInt32());
|
||
}
|
||
uint32_t countdown = packet.readUInt32();
|
||
uint8_t voteMask = packet.readUInt8();
|
||
pendingLootRollActive_ = true;
|
||
pendingLootRoll_.objectGuid = objectGuid;
|
||
pendingLootRoll_.slot = slot;
|
||
pendingLootRoll_.itemId = itemId;
|
||
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<uint8_t>(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, static_cast<int>(voteMask), std::dec);
|
||
fireAddonEvent("START_LOOT_ROLL", {std::to_string(slot), std::to_string(countdown)});
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Pet stable
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::MSG_LIST_STABLED_PETS] = [this](network::Packet& packet) {
|
||
if (state == WorldState::IN_WORLD) handleListStabledPets(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_STABLE_RESULT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
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<int>(result));
|
||
if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) {
|
||
auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_);
|
||
socket->send(refreshPkt);
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Titles / achievements / character services
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_TITLE_EARNED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
uint32_t titleBit = packet.readUInt32();
|
||
uint32_t isLost = packet.readUInt32();
|
||
loadTitleNameCache();
|
||
std::string titleStr;
|
||
auto tit = titleNameCache_.find(titleBit);
|
||
if (tit != titleNameCache_.end() && !tit->second.empty()) {
|
||
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;
|
||
}
|
||
if (isLost) knownTitleBits_.erase(titleBit);
|
||
else knownTitleBits_.insert(titleBit);
|
||
addSystemChatMessage(msg);
|
||
LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, " title='", titleStr, "'");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LEARNED_DANCE_MOVES] = [this](network::Packet& packet) {
|
||
LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAR_RENAME] = [this](network::Packet& packet) {
|
||
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 {
|
||
static const char* kRenameErrors[] = {
|
||
nullptr, "Name already in use.", "Name too short.", "Name too long.",
|
||
"Name contains invalid characters.", "Name contains a profanity.",
|
||
"Name is reserved.", "Character name does not meet requirements.",
|
||
};
|
||
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);
|
||
}
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Bind / heartstone / phase / barber / corpse
|
||
// -----------------------------------------------------------------------
|
||
dispatchTable_[Opcode::SMSG_PLAYERBOUND] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 16) return;
|
||
/*uint64_t binderGuid =*/ packet.readUInt64();
|
||
uint32_t mapId = packet.readUInt32();
|
||
uint32_t zoneId = packet.readUInt32();
|
||
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);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_BINDER_CONFIRM] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
dispatchTable_[Opcode::SMSG_SET_PHASE_SHIFT] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
dispatchTable_[Opcode::SMSG_TOGGLE_XP_GAIN] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
uint8_t enabled = packet.readUInt8();
|
||
addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GOSSIP_POI] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 20) return;
|
||
/*uint32_t flags =*/ packet.readUInt32();
|
||
float poiX = packet.readFloat();
|
||
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);
|
||
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);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_BINDZONEREPLY] = [this](network::Packet& packet) {
|
||
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."); }
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CORPSE_NOT_IN_INSTANCE] = [this](network::Packet& /*packet*/) {
|
||
addUIError("Your corpse is outside this instance.");
|
||
addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE] = [this](network::Packet& /*packet*/) {
|
||
LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_COMBAT_EVENT_FAILED] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
dispatchTable_[Opcode::SMSG_FORCE_ANIM] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
// Multi-case group: consume silently
|
||
for (auto op : {
|
||
Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM, Opcode::SMSG_GAMEOBJECT_RESET_STATE,
|
||
Opcode::SMSG_FLIGHT_SPLINE_SYNC, Opcode::SMSG_FORCE_DISPLAY_UPDATE,
|
||
Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS, Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID,
|
||
Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE, Opcode::SMSG_DAMAGE_CALC_LOG,
|
||
Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT, Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE,
|
||
}) { dispatchTable_[op] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); }; }
|
||
dispatchTable_[Opcode::SMSG_FORCED_DEATH_UPDATE] = [this](network::Packet& packet) {
|
||
playerDead_ = true;
|
||
if (ghostStateCallback_) ghostStateCallback_(false);
|
||
fireAddonEvent("PLAYER_DEAD", {});
|
||
addSystemChatMessage("You have been killed.");
|
||
LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DEFENSE_MESSAGE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 5) {
|
||
/*uint32_t zoneId =*/ packet.readUInt32();
|
||
std::string defMsg = packet.readString();
|
||
if (!defMsg.empty()) addSystemChatMessage("[Defense] " + defMsg);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CORPSE_RECLAIM_DELAY] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t delayMs = packet.readUInt32();
|
||
auto nowMs = static_cast<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
corpseReclaimAvailableMs_ = nowMs + delayMs;
|
||
LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DEATH_RELEASE_LOC] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 16) {
|
||
uint32_t relMapId = packet.readUInt32();
|
||
float relX = packet.readFloat(), relY = packet.readFloat(), relZ = packet.readFloat();
|
||
LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, " x=", relX, " y=", relY, " z=", relZ);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ENABLE_BARBER_SHOP] = [this](network::Packet& /*packet*/) {
|
||
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
|
||
barberShopOpen_ = true;
|
||
fireAddonEvent("BARBER_SHOP_OPEN", {});
|
||
};
|
||
|
||
// ---- Batch 3: Corpse/gametime, combat clearing, mount, loot notify,
|
||
// movement/speed/flags, attack, spells, group ----
|
||
|
||
dispatchTable_[Opcode::MSG_CORPSE_QUERY] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
uint8_t found = packet.readUInt8();
|
||
if (found && packet.getSize() - packet.getReadPos() >= 20) {
|
||
/*uint32_t mapId =*/ packet.readUInt32();
|
||
float cx = packet.readFloat();
|
||
float cy = packet.readFloat();
|
||
float cz = packet.readFloat();
|
||
uint32_t corpseMapId = packet.readUInt32();
|
||
corpseX_ = cx;
|
||
corpseY_ = cy;
|
||
corpseZ_ = cz;
|
||
corpseMapId_ = corpseMapId;
|
||
LOG_INFO("MSG_CORPSE_QUERY: corpse at (", cx, ",", cy, ",", cz, ") map=", corpseMapId);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FEIGN_DEATH_RESISTED] = [this](network::Packet& /*packet*/) {
|
||
addUIError("Your Feign Death was resisted.");
|
||
addSystemChatMessage("Your Feign Death attempt was resisted.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHANNEL_MEMBER_COUNT] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
for (auto op : { Opcode::SMSG_GAMETIME_SET, Opcode::SMSG_GAMETIME_UPDATE }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t gameTimePacked = packet.readUInt32();
|
||
gameTime_ = static_cast<float>(gameTimePacked);
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
}
|
||
dispatchTable_[Opcode::SMSG_GAMESPEED_SET] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint32_t gameTimePacked = packet.readUInt32();
|
||
float timeSpeed = packet.readFloat();
|
||
gameTime_ = static_cast<float>(gameTimePacked);
|
||
timeSpeed_ = timeSpeed;
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GAMETIMEBIAS_SET] = [this](network::Packet& packet) {
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ACHIEVEMENT_DELETED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t achId = packet.readUInt32();
|
||
earnedAchievements_.erase(achId);
|
||
achievementDates_.erase(achId);
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CRITERIA_DELETED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t critId = packet.readUInt32();
|
||
criteriaProgress_.erase(critId);
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
|
||
// Combat clearing
|
||
dispatchTable_[Opcode::SMSG_ATTACKSWING_DEADTARGET] = [this](network::Packet& /*packet*/) {
|
||
autoAttacking = false;
|
||
autoAttackTarget = 0;
|
||
};
|
||
dispatchTable_[Opcode::SMSG_THREAT_CLEAR] = [this](network::Packet& /*packet*/) {
|
||
threatLists_.clear();
|
||
fireAddonEvent("UNIT_THREAT_LIST_UPDATE", {});
|
||
};
|
||
dispatchTable_[Opcode::SMSG_THREAT_REMOVE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CANCEL_COMBAT] = [this](network::Packet& /*packet*/) {
|
||
autoAttacking = false;
|
||
autoAttackTarget = 0;
|
||
autoAttackRequested_ = false;
|
||
};
|
||
dispatchTable_[Opcode::SMSG_BREAK_TARGET] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint64_t bGuid = packet.readUInt64();
|
||
if (bGuid == targetGuid) targetGuid = 0;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CLEAR_TARGET] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint64_t cGuid = packet.readUInt64();
|
||
if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0;
|
||
}
|
||
};
|
||
|
||
// Mount/dismount
|
||
dispatchTable_[Opcode::SMSG_DISMOUNT] = [this](network::Packet& /*packet*/) {
|
||
currentMountDisplayId_ = 0;
|
||
if (mountCallback_) mountCallback_(0);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOUNTRESULT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DISMOUNTRESULT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t result = packet.readUInt32();
|
||
if (result != 0) {
|
||
addUIError("Cannot dismount here.");
|
||
addSystemChatMessage("Cannot dismount here.");
|
||
}
|
||
};
|
||
|
||
// Loot notifications
|
||
dispatchTable_[Opcode::SMSG_LOOT_ALL_PASSED] = [this](network::Packet& packet) {
|
||
const bool isWotLK = isActiveExpansion("wotlk");
|
||
const size_t minSize = isWotLK ? 24u : 16u;
|
||
if (packet.getSize() - packet.getReadPos() < minSize) return;
|
||
/*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;
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LOOT_ITEM_NOTIFY] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 24) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t looterGuid = packet.readUInt64();
|
||
/*uint64_t lootGuid =*/ packet.readUInt64();
|
||
uint32_t itemId = packet.readUInt32();
|
||
uint32_t count = packet.readUInt32();
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LOOT_SLOT_CHANGED] = [this](network::Packet& packet) {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Creature movement
|
||
dispatchTable_[Opcode::SMSG_MONSTER_MOVE] = [this](network::Packet& packet) { handleMonsterMove(packet); };
|
||
dispatchTable_[Opcode::SMSG_COMPRESSED_MOVES] = [this](network::Packet& packet) { handleCompressedMoves(packet); };
|
||
dispatchTable_[Opcode::SMSG_MONSTER_MOVE_TRANSPORT] = [this](network::Packet& packet) { handleMonsterMoveTransport(packet); };
|
||
|
||
// Spline move: consume-only (no state change)
|
||
for (auto op : { Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL,
|
||
Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE,
|
||
Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE,
|
||
Opcode::SMSG_SPLINE_MOVE_LAND_WALK,
|
||
Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL,
|
||
Opcode::SMSG_SPLINE_MOVE_ROOT,
|
||
Opcode::SMSG_SPLINE_MOVE_SET_HOVER }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1)
|
||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||
};
|
||
}
|
||
|
||
// Spline move: synth flags (each opcode produces different flags)
|
||
{
|
||
auto makeSynthHandler = [this](uint32_t synthFlags) {
|
||
return [this, synthFlags](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) return;
|
||
unitMoveFlagsCallback_(guid, synthFlags);
|
||
};
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE] = makeSynthHandler(0x00000100u);
|
||
dispatchTable_[Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE] = makeSynthHandler(0u);
|
||
dispatchTable_[Opcode::SMSG_SPLINE_MOVE_SET_FLYING] = makeSynthHandler(0x01000000u | 0x00800000u);
|
||
dispatchTable_[Opcode::SMSG_SPLINE_MOVE_START_SWIM] = makeSynthHandler(0x00200000u);
|
||
dispatchTable_[Opcode::SMSG_SPLINE_MOVE_STOP_SWIM] = makeSynthHandler(0u);
|
||
}
|
||
|
||
// Spline speed: each opcode updates a different speed member
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_RUN_SPEED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float speed = packet.readFloat();
|
||
if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f)
|
||
serverRunSpeed_ = speed;
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float speed = packet.readFloat();
|
||
if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f)
|
||
serverRunBackSpeed_ = speed;
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_SWIM_SPEED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float speed = packet.readFloat();
|
||
if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f)
|
||
serverSwimSpeed_ = speed;
|
||
};
|
||
|
||
// Force speed changes
|
||
dispatchTable_[Opcode::SMSG_FORCE_RUN_SPEED_CHANGE] = [this](network::Packet& packet) { handleForceRunSpeedChange(packet); };
|
||
dispatchTable_[Opcode::SMSG_FORCE_MOVE_ROOT] = [this](network::Packet& packet) { handleForceMoveRootState(packet, true); };
|
||
dispatchTable_[Opcode::SMSG_FORCE_MOVE_UNROOT] = [this](network::Packet& packet) { handleForceMoveRootState(packet, false); };
|
||
dispatchTable_[Opcode::SMSG_FORCE_WALK_SPEED_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "WALK_SPEED", Opcode::CMSG_FORCE_WALK_SPEED_CHANGE_ACK, &serverWalkSpeed_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_RUN_BACK_SPEED_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "RUN_BACK_SPEED", Opcode::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK, &serverRunBackSpeed_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_SWIM_SPEED_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "SWIM_SPEED", Opcode::CMSG_FORCE_SWIM_SPEED_CHANGE_ACK, &serverSwimSpeed_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_SWIM_BACK_SPEED_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "SWIM_BACK_SPEED", Opcode::CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK, &serverSwimBackSpeed_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_FLIGHT_SPEED_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "FLIGHT_SPEED", Opcode::CMSG_FORCE_FLIGHT_SPEED_CHANGE_ACK, &serverFlightSpeed_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "FLIGHT_BACK_SPEED", Opcode::CMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE_ACK, &serverFlightBackSpeed_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_TURN_RATE_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "TURN_RATE", Opcode::CMSG_FORCE_TURN_RATE_CHANGE_ACK, &serverTurnRate_);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FORCE_PITCH_RATE_CHANGE] = [this](network::Packet& packet) {
|
||
handleForceSpeedChange(packet, "PITCH_RATE", Opcode::CMSG_FORCE_PITCH_RATE_CHANGE_ACK, &serverPitchRate_);
|
||
};
|
||
|
||
// Movement flag toggles
|
||
dispatchTable_[Opcode::SMSG_MOVE_SET_CAN_FLY] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "SET_CAN_FLY", Opcode::CMSG_MOVE_SET_CAN_FLY_ACK,
|
||
static_cast<uint32_t>(MovementFlags::CAN_FLY), true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_UNSET_CAN_FLY] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "UNSET_CAN_FLY", Opcode::CMSG_MOVE_SET_CAN_FLY_ACK,
|
||
static_cast<uint32_t>(MovementFlags::CAN_FLY), false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_FEATHER_FALL] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK,
|
||
static_cast<uint32_t>(MovementFlags::FEATHER_FALL), true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_WATER_WALK] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK,
|
||
static_cast<uint32_t>(MovementFlags::WATER_WALK), true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_SET_HOVER] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
|
||
static_cast<uint32_t>(MovementFlags::HOVER), true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_UNSET_HOVER] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "UNSET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
|
||
static_cast<uint32_t>(MovementFlags::HOVER), false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_KNOCK_BACK] = [this](network::Packet& packet) { handleMoveKnockBack(packet); };
|
||
|
||
// Camera shake
|
||
dispatchTable_[Opcode::SMSG_CAMERA_SHAKE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint32_t shakeId = packet.readUInt32();
|
||
uint32_t shakeType = packet.readUInt32();
|
||
(void)shakeType;
|
||
float magnitude = (shakeId < 50) ? 0.04f : 0.08f;
|
||
if (cameraShakeCallback_)
|
||
cameraShakeCallback_(magnitude, 18.0f, 0.5f);
|
||
}
|
||
};
|
||
|
||
// Attack/combat delegates
|
||
dispatchTable_[Opcode::SMSG_ATTACKSTART] = [this](network::Packet& packet) { handleAttackStart(packet); };
|
||
dispatchTable_[Opcode::SMSG_ATTACKSTOP] = [this](network::Packet& packet) { handleAttackStop(packet); };
|
||
dispatchTable_[Opcode::SMSG_ATTACKSWING_NOTINRANGE] = [this](network::Packet& /*packet*/) {
|
||
autoAttackOutOfRange_ = true;
|
||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||
addSystemChatMessage("Target is too far away.");
|
||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ATTACKSWING_BADFACING] = [this](network::Packet& /*packet*/) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ATTACKSWING_NOTSTANDING] = [this](network::Packet& /*packet*/) {
|
||
autoAttackOutOfRange_ = false;
|
||
autoAttackOutOfRangeTime_ = 0.0f;
|
||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||
addSystemChatMessage("You need to stand up to fight.");
|
||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ATTACKSWING_CANT_ATTACK] = [this](network::Packet& /*packet*/) {
|
||
stopAutoAttack();
|
||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||
addSystemChatMessage("You can't attack that.");
|
||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ATTACKERSTATEUPDATE] = [this](network::Packet& packet) { handleAttackerStateUpdate(packet); };
|
||
dispatchTable_[Opcode::SMSG_AI_REACTION] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 12) return;
|
||
uint64_t guid = packet.readUInt64();
|
||
uint32_t reaction = packet.readUInt32();
|
||
if (reaction == 2 && npcAggroCallback_) {
|
||
auto entity = entityManager.getEntity(guid);
|
||
if (entity)
|
||
npcAggroCallback_(guid, glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPELLNONMELEEDAMAGELOG] = [this](network::Packet& packet) { handleSpellDamageLog(packet); };
|
||
dispatchTable_[Opcode::SMSG_PLAY_SPELL_VISUAL] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 12) return;
|
||
uint64_t casterGuid = packet.readUInt64();
|
||
uint32_t visualId = packet.readUInt32();
|
||
if (visualId == 0) return;
|
||
auto* renderer = core::Application::getInstance().getRenderer();
|
||
if (!renderer) return;
|
||
glm::vec3 spawnPos;
|
||
if (casterGuid == playerGuid) {
|
||
spawnPos = renderer->getCharacterPosition();
|
||
} else {
|
||
auto entity = entityManager.getEntity(casterGuid);
|
||
if (!entity) return;
|
||
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
||
spawnPos = core::coords::canonicalToRender(canonical);
|
||
}
|
||
renderer->playSpellVisual(visualId, spawnPos);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPELLHEALLOG] = [this](network::Packet& packet) { handleSpellHealLog(packet); };
|
||
|
||
// Spell delegates
|
||
dispatchTable_[Opcode::SMSG_INITIAL_SPELLS] = [this](network::Packet& packet) { handleInitialSpells(packet); };
|
||
dispatchTable_[Opcode::SMSG_CAST_FAILED] = [this](network::Packet& packet) { handleCastFailed(packet); };
|
||
dispatchTable_[Opcode::SMSG_SPELL_START] = [this](network::Packet& packet) { handleSpellStart(packet); };
|
||
dispatchTable_[Opcode::SMSG_SPELL_GO] = [this](network::Packet& packet) { handleSpellGo(packet); };
|
||
dispatchTable_[Opcode::SMSG_SPELL_COOLDOWN] = [this](network::Packet& packet) { handleSpellCooldown(packet); };
|
||
dispatchTable_[Opcode::SMSG_COOLDOWN_EVENT] = [this](network::Packet& packet) { handleCooldownEvent(packet); };
|
||
dispatchTable_[Opcode::SMSG_CLEAR_COOLDOWN] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t spellId = packet.readUInt32();
|
||
spellCooldowns.erase(spellId);
|
||
for (auto& slot : actionBar) {
|
||
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|
||
slot.cooldownRemaining = 0.0f;
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MODIFY_COOLDOWN] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint32_t spellId = packet.readUInt32();
|
||
int32_t diffMs = static_cast<int32_t>(packet.readUInt32());
|
||
float diffSec = diffMs / 1000.0f;
|
||
auto it = spellCooldowns.find(spellId);
|
||
if (it != spellCooldowns.end()) {
|
||
it->second = std::max(0.0f, it->second + diffSec);
|
||
for (auto& slot : actionBar) {
|
||
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|
||
slot.cooldownRemaining = std::max(0.0f, slot.cooldownRemaining + diffSec);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LEARNED_SPELL] = [this](network::Packet& packet) { handleLearnedSpell(packet); };
|
||
dispatchTable_[Opcode::SMSG_SUPERCEDED_SPELL] = [this](network::Packet& packet) { handleSupercededSpell(packet); };
|
||
dispatchTable_[Opcode::SMSG_REMOVED_SPELL] = [this](network::Packet& packet) { handleRemovedSpell(packet); };
|
||
dispatchTable_[Opcode::SMSG_SEND_UNLEARN_SPELLS] = [this](network::Packet& packet) { handleUnlearnSpells(packet); };
|
||
dispatchTable_[Opcode::SMSG_TALENTS_INFO] = [this](network::Packet& packet) { handleTalentsInfo(packet); };
|
||
|
||
// Group
|
||
dispatchTable_[Opcode::SMSG_GROUP_INVITE] = [this](network::Packet& packet) { handleGroupInvite(packet); };
|
||
dispatchTable_[Opcode::SMSG_GROUP_DECLINE] = [this](network::Packet& packet) { handleGroupDecline(packet); };
|
||
dispatchTable_[Opcode::SMSG_GROUP_LIST] = [this](network::Packet& packet) { handleGroupList(packet); };
|
||
dispatchTable_[Opcode::SMSG_GROUP_DESTROYED] = [this](network::Packet& /*packet*/) {
|
||
partyData.members.clear();
|
||
partyData.memberCount = 0;
|
||
partyData.leaderGuid = 0;
|
||
addUIError("Your party has been disbanded.");
|
||
addSystemChatMessage("Your party has been disbanded.");
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
|
||
fireAddonEvent("PARTY_MEMBERS_CHANGED", {});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GROUP_CANCEL] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("Group invite cancelled.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GROUP_UNINVITE] = [this](network::Packet& packet) { handleGroupUninvite(packet); };
|
||
dispatchTable_[Opcode::SMSG_PARTY_COMMAND_RESULT] = [this](network::Packet& packet) { handlePartyCommandResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_PARTY_MEMBER_STATS] = [this](network::Packet& packet) { handlePartyMemberStats(packet, false); };
|
||
dispatchTable_[Opcode::SMSG_PARTY_MEMBER_STATS_FULL] = [this](network::Packet& packet) { handlePartyMemberStats(packet, true); };
|
||
|
||
// ---- Batch 4: Ready check, duels, guild, loot/gossip/vendor, factions, spell mods ----
|
||
|
||
// Ready check
|
||
dispatchTable_[Opcode::MSG_RAID_READY_CHECK] = [this](network::Packet& packet) {
|
||
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<Unit*>(entity.get()))
|
||
readyCheckInitiator_ = unit->getName();
|
||
}
|
||
if (readyCheckInitiator_.empty() && partyData.leaderGuid != 0) {
|
||
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!");
|
||
fireAddonEvent("READY_CHECK", {readyCheckInitiator_});
|
||
};
|
||
dispatchTable_[Opcode::MSG_RAID_READY_CHECK_CONFIRM] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); return; }
|
||
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<game::Unit>(ent)->getName();
|
||
}
|
||
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);
|
||
fireAddonEvent("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::MSG_RAID_READY_CHECK_FINISHED] = [this](network::Packet& /*packet*/) {
|
||
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();
|
||
fireAddonEvent("READY_CHECK_FINISHED", {});
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RAID_INSTANCE_INFO] = [this](network::Packet& packet) { handleRaidInstanceInfo(packet); };
|
||
|
||
// Duels
|
||
dispatchTable_[Opcode::SMSG_DUEL_REQUESTED] = [this](network::Packet& packet) { handleDuelRequested(packet); };
|
||
dispatchTable_[Opcode::SMSG_DUEL_COMPLETE] = [this](network::Packet& packet) { handleDuelComplete(packet); };
|
||
dispatchTable_[Opcode::SMSG_DUEL_WINNER] = [this](network::Packet& packet) { handleDuelWinner(packet); };
|
||
dispatchTable_[Opcode::SMSG_DUEL_OUTOFBOUNDS] = [this](network::Packet& /*packet*/) {
|
||
addUIError("You are out of the duel area!");
|
||
addSystemChatMessage("You are out of the duel area!");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DUEL_INBOUNDS] = [this](network::Packet& /*packet*/) {};
|
||
dispatchTable_[Opcode::SMSG_DUEL_COUNTDOWN] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t ms = packet.readUInt32();
|
||
duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000;
|
||
duelCountdownStartedAt_ = std::chrono::steady_clock::now();
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PARTYKILLLOG] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 16) return;
|
||
uint64_t killerGuid = packet.readUInt64();
|
||
uint64_t victimGuid = packet.readUInt64();
|
||
auto nameFor = [this](uint64_t g) -> std::string {
|
||
auto nit = playerNameCache.find(g);
|
||
if (nit != playerNameCache.end()) return nit->second;
|
||
auto ent = entityManager.getEntity(g);
|
||
if (ent && (ent->getType() == game::ObjectType::UNIT ||
|
||
ent->getType() == game::ObjectType::PLAYER))
|
||
return std::static_pointer_cast<game::Unit>(ent)->getName();
|
||
return {};
|
||
};
|
||
std::string killerName = nameFor(killerGuid);
|
||
std::string victimName = nameFor(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);
|
||
}
|
||
};
|
||
|
||
// Guild
|
||
dispatchTable_[Opcode::SMSG_GUILD_INFO] = [this](network::Packet& packet) { handleGuildInfo(packet); };
|
||
dispatchTable_[Opcode::SMSG_GUILD_ROSTER] = [this](network::Packet& packet) { handleGuildRoster(packet); };
|
||
dispatchTable_[Opcode::SMSG_GUILD_QUERY_RESPONSE] = [this](network::Packet& packet) { handleGuildQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_GUILD_EVENT] = [this](network::Packet& packet) { handleGuildEvent(packet); };
|
||
dispatchTable_[Opcode::SMSG_GUILD_INVITE] = [this](network::Packet& packet) { handleGuildInvite(packet); };
|
||
dispatchTable_[Opcode::SMSG_GUILD_COMMAND_RESULT] = [this](network::Packet& packet) { handleGuildCommandResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_PET_SPELLS] = [this](network::Packet& packet) { handlePetSpells(packet); };
|
||
dispatchTable_[Opcode::SMSG_PETITION_SHOWLIST] = [this](network::Packet& packet) { handlePetitionShowlist(packet); };
|
||
dispatchTable_[Opcode::SMSG_TURN_IN_PETITION_RESULTS] = [this](network::Packet& packet) { handleTurnInPetitionResults(packet); };
|
||
|
||
// Loot/gossip/vendor delegates
|
||
dispatchTable_[Opcode::SMSG_LOOT_RESPONSE] = [this](network::Packet& packet) { handleLootResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOOT_RELEASE_RESPONSE] = [this](network::Packet& packet) { handleLootReleaseResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOOT_REMOVED] = [this](network::Packet& packet) { handleLootRemoved(packet); };
|
||
dispatchTable_[Opcode::SMSG_QUEST_CONFIRM_ACCEPT] = [this](network::Packet& packet) { handleQuestConfirmAccept(packet); };
|
||
dispatchTable_[Opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE] = [this](network::Packet& packet) { handleItemTextQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_SUMMON_REQUEST] = [this](network::Packet& packet) { handleSummonRequest(packet); };
|
||
dispatchTable_[Opcode::SMSG_SUMMON_CANCEL] = [this](network::Packet& /*packet*/) {
|
||
pendingSummonRequest_ = false;
|
||
addSystemChatMessage("Summon cancelled.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_TRADE_STATUS] = [this](network::Packet& packet) { handleTradeStatus(packet); };
|
||
dispatchTable_[Opcode::SMSG_TRADE_STATUS_EXTENDED] = [this](network::Packet& packet) { handleTradeStatusExtended(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOOT_ROLL] = [this](network::Packet& packet) { handleLootRoll(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOOT_ROLL_WON] = [this](network::Packet& packet) { handleLootRollWon(packet); };
|
||
dispatchTable_[Opcode::SMSG_LOOT_MASTER_LIST] = [this](network::Packet& packet) {
|
||
masterLootCandidates_.clear();
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
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());
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GOSSIP_MESSAGE] = [this](network::Packet& packet) { handleGossipMessage(packet); };
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_QUEST_LIST] = [this](network::Packet& packet) { handleQuestgiverQuestList(packet); };
|
||
dispatchTable_[Opcode::SMSG_GOSSIP_COMPLETE] = [this](network::Packet& packet) { handleGossipComplete(packet); };
|
||
|
||
// Bind point
|
||
dispatchTable_[Opcode::SMSG_BINDPOINTUPDATE] = [this](network::Packet& packet) {
|
||
BindPointUpdateData data;
|
||
if (BindPointUpdateParser::parse(packet, data)) {
|
||
glm::vec3 canonical = core::coords::serverToCanonical(
|
||
glm::vec3(data.x, data.y, data.z));
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Spirit healer / resurrect
|
||
dispatchTable_[Opcode::SMSG_SPIRIT_HEALER_CONFIRM] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
uint64_t npcGuid = packet.readUInt64();
|
||
if (npcGuid) {
|
||
resurrectCasterGuid_ = npcGuid;
|
||
resurrectCasterName_ = "";
|
||
resurrectIsSpiritHealer_ = true;
|
||
resurrectRequestPending_ = true;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RESURRECT_REQUEST] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
uint64_t casterGuid = packet.readUInt64();
|
||
std::string casterName;
|
||
if (packet.getReadPos() < packet.getSize())
|
||
casterName = packet.readString();
|
||
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;
|
||
fireAddonEvent("RESURRECT_REQUEST", {resurrectCasterName_});
|
||
}
|
||
};
|
||
|
||
// Time sync
|
||
dispatchTable_[Opcode::SMSG_TIME_SYNC_REQ] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t counter = packet.readUInt32();
|
||
if (socket) {
|
||
network::Packet resp(wireOpcode(Opcode::CMSG_TIME_SYNC_RESP));
|
||
resp.writeUInt32(counter);
|
||
resp.writeUInt32(nextMovementTimestampMs());
|
||
socket->send(resp);
|
||
}
|
||
};
|
||
|
||
// Vendor/trainer
|
||
dispatchTable_[Opcode::SMSG_LIST_INVENTORY] = [this](network::Packet& packet) { handleListInventory(packet); };
|
||
dispatchTable_[Opcode::SMSG_TRAINER_LIST] = [this](network::Packet& packet) { handleTrainerList(packet); };
|
||
dispatchTable_[Opcode::SMSG_TRAINER_BUY_SUCCEEDED] = [this](network::Packet& packet) {
|
||
/*uint64_t guid =*/ packet.readUInt64();
|
||
uint32_t spellId = packet.readUInt32();
|
||
if (!knownSpells.count(spellId)) {
|
||
knownSpells.insert(spellId);
|
||
}
|
||
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();
|
||
}
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("TRAINER_UPDATE", {});
|
||
fireAddonEvent("SPELLS_CHANGED", {});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_TRAINER_BUY_FAILED] = [this](network::Packet& packet) {
|
||
/*uint64_t trainerGuid =*/ packet.readUInt64();
|
||
uint32_t spellId = packet.readUInt32();
|
||
uint32_t errorCode = 0;
|
||
if (packet.getSize() - packet.getReadPos() >= 4)
|
||
errorCode = packet.readUInt32();
|
||
const std::string& spellName = getSpellName(spellId);
|
||
std::string msg = "Cannot learn ";
|
||
if (!spellName.empty()) msg += spellName;
|
||
else msg += "spell #" + std::to_string(spellId);
|
||
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);
|
||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||
if (auto* sfx = renderer->getUiSoundManager()) sfx->playError();
|
||
}
|
||
};
|
||
|
||
// Minimap ping
|
||
dispatchTable_[Opcode::MSG_MINIMAP_PING] = [this](network::Packet& packet) {
|
||
const bool mmTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
||
if (packet.getSize() - packet.getReadPos() < (mmTbcLike ? 8u : 1u)) return;
|
||
uint64_t senderGuid = mmTbcLike
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
float pingX = packet.readFloat();
|
||
float pingY = packet.readFloat();
|
||
MinimapPing ping;
|
||
ping.senderGuid = senderGuid;
|
||
ping.wowX = pingY;
|
||
ping.wowY = pingX;
|
||
ping.age = 0.0f;
|
||
minimapPings_.push_back(ping);
|
||
if (senderGuid != playerGuid) {
|
||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||
if (auto* sfx = renderer->getUiSoundManager()) sfx->playMinimapPing();
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ZONE_UNDER_ATTACK] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Spirit healer time / durability
|
||
dispatchTable_[Opcode::SMSG_AREA_SPIRIT_HEALER_TIME] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DURABILITY_DAMAGE_DEATH] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Factions
|
||
dispatchTable_[Opcode::SMSG_INITIALIZE_FACTIONS] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t count = packet.readUInt32();
|
||
size_t needed = static_cast<size_t>(count) * 5;
|
||
if (packet.getSize() - packet.getReadPos() < needed) { packet.setReadPos(packet.getSize()); return; }
|
||
initialFactions_.clear();
|
||
initialFactions_.reserve(count);
|
||
for (uint32_t i = 0; i < count; ++i) {
|
||
FactionStandingInit fs{};
|
||
fs.flags = packet.readUInt8();
|
||
fs.standing = static_cast<int32_t>(packet.readUInt32());
|
||
initialFactions_.push_back(fs);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SET_FACTION_STANDING] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
/*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<int32_t>(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_) {
|
||
fireAddonEvent("UPDATE_FACTION", {});
|
||
fireAddonEvent("CHAT_MSG_COMBAT_FACTION_CHANGE", {std::string(buf)});
|
||
}
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SET_FACTION_ATWAR] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) { packet.setReadPos(packet.getSize()); return; }
|
||
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;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SET_FACTION_VISIBLE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) { packet.setReadPos(packet.getSize()); return; }
|
||
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;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FEATURE_SYSTEM_STATUS] = [this](network::Packet& packet) {
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
|
||
// Spell modifiers (separate lambdas: *logicalOp was used to determine isFlat)
|
||
{
|
||
auto makeSpellModHandler = [this](bool isFlat) {
|
||
return [this, isFlat](network::Packet& packet) {
|
||
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<int32_t>(packet.readUInt32());
|
||
if (groupIndex > 5 || modOpRaw >= SPELL_MOD_OP_COUNT) continue;
|
||
SpellModKey key{ static_cast<SpellModOp>(modOpRaw), groupIndex };
|
||
modMap[key] = value;
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SET_FLAT_SPELL_MODIFIER] = makeSpellModHandler(true);
|
||
dispatchTable_[Opcode::SMSG_SET_PCT_SPELL_MODIFIER] = makeSpellModHandler(false);
|
||
}
|
||
|
||
// Spell delayed
|
||
dispatchTable_[Opcode::SMSG_SPELL_DELAYED] = [this](network::Packet& packet) {
|
||
const bool spellDelayTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
||
if (packet.getSize() - packet.getReadPos() < (spellDelayTbcLike ? 8u : 1u)) return;
|
||
uint64_t caster = spellDelayTbcLike
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t delayMs = packet.readUInt32();
|
||
if (delayMs == 0) return;
|
||
float delaySec = delayMs / 1000.0f;
|
||
if (caster == playerGuid) {
|
||
if (casting) {
|
||
castTimeRemaining += delaySec;
|
||
castTimeTotal += delaySec;
|
||
}
|
||
} else {
|
||
auto it = unitCastStates_.find(caster);
|
||
if (it != unitCastStates_.end() && it->second.casting) {
|
||
it->second.timeRemaining += delaySec;
|
||
it->second.timeTotal += delaySec;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Proficiency
|
||
dispatchTable_[Opcode::SMSG_SET_PROFICIENCY] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint8_t itemClass = packet.readUInt8();
|
||
uint32_t mask = packet.readUInt32();
|
||
if (itemClass == 2) weaponProficiency_ = mask;
|
||
else if (itemClass == 4) armorProficiency_ = mask;
|
||
};
|
||
|
||
// Loot money / misc consume
|
||
dispatchTable_[Opcode::SMSG_LOOT_MONEY_NOTIFY] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t amount = packet.readUInt32();
|
||
if (packet.getSize() - packet.getReadPos() >= 1)
|
||
/*uint8_t soleLooter =*/ packet.readUInt8();
|
||
playerMoneyCopper_ += amount;
|
||
pendingMoneyDelta_ = amount;
|
||
pendingMoneyDeltaTimer_ = 2.0f;
|
||
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;
|
||
}
|
||
fireAddonEvent("PLAYER_MONEY", {});
|
||
};
|
||
for (auto op : { Opcode::SMSG_LOOT_CLEAR_MONEY, Opcode::SMSG_NPC_TEXT_UPDATE }) {
|
||
dispatchTable_[op] = [](network::Packet& /*packet*/) {};
|
||
}
|
||
|
||
// Play sound
|
||
dispatchTable_[Opcode::SMSG_PLAY_SOUND] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t soundId = packet.readUInt32();
|
||
if (playSoundCallback_) playSoundCallback_(soundId);
|
||
}
|
||
};
|
||
|
||
// Server messages
|
||
dispatchTable_[Opcode::SMSG_SERVER_MESSAGE] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAT_SERVER_MESSAGE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
/*uint32_t msgType =*/ packet.readUInt32();
|
||
std::string msg = packet.readString();
|
||
if (!msg.empty()) addSystemChatMessage("[Announcement] " + msg);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_AREA_TRIGGER_MESSAGE] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_TRIGGER_CINEMATIC] = [this](network::Packet& packet) {
|
||
packet.setReadPos(packet.getSize());
|
||
network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA));
|
||
socket->send(ack);
|
||
};
|
||
|
||
// ---- Batch 5: Teleport, taxi, BG, LFG, arena, movement relay, mail, bank, auction, quests ----
|
||
|
||
// Teleport
|
||
for (auto op : { Opcode::MSG_MOVE_TELEPORT, Opcode::MSG_MOVE_TELEPORT_ACK }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) { handleTeleportAck(packet); };
|
||
}
|
||
dispatchTable_[Opcode::SMSG_TRANSFER_PENDING] = [this](network::Packet& packet) {
|
||
uint32_t pendingMapId = packet.readUInt32();
|
||
if (packet.getReadPos() + 8 <= packet.getSize()) {
|
||
packet.readUInt32(); // transportEntry
|
||
packet.readUInt32(); // transportMapId
|
||
}
|
||
(void)pendingMapId;
|
||
};
|
||
dispatchTable_[Opcode::SMSG_NEW_WORLD] = [this](network::Packet& packet) { handleNewWorld(packet); };
|
||
dispatchTable_[Opcode::SMSG_TRANSFER_ABORTED] = [this](network::Packet& packet) {
|
||
uint32_t mapId = packet.readUInt32();
|
||
uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0;
|
||
(void)mapId;
|
||
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;
|
||
default: abortMsg = "Transfer aborted."; break;
|
||
}
|
||
addUIError(abortMsg);
|
||
addSystemChatMessage(abortMsg);
|
||
};
|
||
|
||
// Taxi
|
||
dispatchTable_[Opcode::SMSG_SHOWTAXINODES] = [this](network::Packet& packet) { handleShowTaxiNodes(packet); };
|
||
dispatchTable_[Opcode::SMSG_ACTIVATETAXIREPLY] = [this](network::Packet& packet) { handleActivateTaxiReply(packet); };
|
||
dispatchTable_[Opcode::SMSG_STANDSTATE_UPDATE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
standState_ = packet.readUInt8();
|
||
if (standStateCallback_) standStateCallback_(standState_);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_NEW_TAXI_PATH] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("New flight path discovered!");
|
||
};
|
||
|
||
// Battlefield / BG
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_STATUS] = [this](network::Packet& packet) { handleBattlefieldStatus(packet); };
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_LIST] = [this](network::Packet& packet) { handleBattlefieldList(packet); };
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_PORT_DENIED] = [this](network::Packet& /*packet*/) {
|
||
addUIError("Battlefield port denied.");
|
||
addSystemChatMessage("Battlefield port denied.");
|
||
};
|
||
dispatchTable_[Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_REMOVED_FROM_PVP_QUEUE] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("You have been removed from the PvP queue.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GROUP_JOINED_BATTLEGROUND] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("Your group has joined the battleground.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("You have joined the battleground queue.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint64_t guid = packet.readUInt64();
|
||
auto it = playerNameCache.find(guid);
|
||
if (it != playerNameCache.end() && !it->second.empty())
|
||
addSystemChatMessage(it->second + " has entered the battleground.");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint64_t guid = packet.readUInt64();
|
||
auto it = playerNameCache.find(guid);
|
||
if (it != playerNameCache.end() && !it->second.empty())
|
||
addSystemChatMessage(it->second + " has left the battleground.");
|
||
}
|
||
};
|
||
|
||
// Instance
|
||
for (auto op : { Opcode::SMSG_INSTANCE_DIFFICULTY, Opcode::MSG_SET_DUNGEON_DIFFICULTY }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) { handleInstanceDifficulty(packet); };
|
||
}
|
||
dispatchTable_[Opcode::SMSG_INSTANCE_SAVE_CREATED] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("You are now saved to this instance.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RAID_INSTANCE_MESSAGE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 12) return;
|
||
uint32_t msgType = packet.readUInt32();
|
||
uint32_t mapId = packet.readUInt32();
|
||
packet.readUInt32(); // diff
|
||
std::string mapLabel = getMapName(mapId);
|
||
if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId);
|
||
if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t timeLeft = packet.readUInt32();
|
||
addSystemChatMessage(mapLabel + " will reset in " + std::to_string(timeLeft / 60) + " minute(s).");
|
||
} else if (msgType == 2) {
|
||
addSystemChatMessage("You have been saved to " + mapLabel + ".");
|
||
} else if (msgType == 3) {
|
||
addSystemChatMessage("Welcome to " + mapLabel + ".");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_INSTANCE_RESET] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t mapId = packet.readUInt32();
|
||
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.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_INSTANCE_RESET_FAILED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
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);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_INSTANCE_LOCK_WARNING_QUERY] = [this](network::Packet& packet) {
|
||
if (!socket || packet.getSize() - packet.getReadPos() < 17) return;
|
||
uint32_t ilMapId = packet.readUInt32();
|
||
uint32_t ilDiff = packet.readUInt32();
|
||
uint32_t ilTimeLeft = packet.readUInt32();
|
||
packet.readUInt32(); // unk
|
||
uint8_t ilLocked = packet.readUInt8();
|
||
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)
|
||
ilMsg += " — " + std::to_string(ilTimeLeft / 60) + " min remaining.";
|
||
else
|
||
ilMsg += ".";
|
||
addSystemChatMessage(ilMsg);
|
||
network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE));
|
||
resp.writeUInt8(1);
|
||
socket->send(resp);
|
||
};
|
||
|
||
// LFG
|
||
dispatchTable_[Opcode::SMSG_LFG_JOIN_RESULT] = [this](network::Packet& packet) { handleLfgJoinResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_LFG_QUEUE_STATUS] = [this](network::Packet& packet) { handleLfgQueueStatus(packet); };
|
||
dispatchTable_[Opcode::SMSG_LFG_PROPOSAL_UPDATE] = [this](network::Packet& packet) { handleLfgProposalUpdate(packet); };
|
||
dispatchTable_[Opcode::SMSG_LFG_ROLE_CHECK_UPDATE] = [this](network::Packet& packet) { handleLfgRoleCheckUpdate(packet); };
|
||
for (auto op : { Opcode::SMSG_LFG_UPDATE_PLAYER, Opcode::SMSG_LFG_UPDATE_PARTY }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) { handleLfgUpdatePlayer(packet); };
|
||
}
|
||
dispatchTable_[Opcode::SMSG_LFG_PLAYER_REWARD] = [this](network::Packet& packet) { handleLfgPlayerReward(packet); };
|
||
dispatchTable_[Opcode::SMSG_LFG_BOOT_PROPOSAL_UPDATE] = [this](network::Packet& packet) { handleLfgBootProposalUpdate(packet); };
|
||
dispatchTable_[Opcode::SMSG_LFG_TELEPORT_DENIED] = [this](network::Packet& packet) { handleLfgTeleportDenied(packet); };
|
||
dispatchTable_[Opcode::SMSG_LFG_DISABLED] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("The Dungeon Finder is currently disabled.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LFG_OFFER_CONTINUE] = [this](network::Packet& /*packet*/) {
|
||
addSystemChatMessage("Dungeon Finder: You may continue your dungeon.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_LFG_ROLE_CHOSEN] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 13) { packet.setReadPos(packet.getSize()); return; }
|
||
uint64_t roleGuid = packet.readUInt64();
|
||
uint8_t ready = packet.readUInt8();
|
||
uint32_t roles = packet.readUInt32();
|
||
std::string roleName;
|
||
if (roles & 0x02) roleName += "Tank ";
|
||
if (roles & 0x04) roleName += "Healer ";
|
||
if (roles & 0x08) roleName += "DPS ";
|
||
if (roleName.empty()) roleName = "None";
|
||
std::string pName = "A player";
|
||
if (auto e = entityManager.getEntity(roleGuid))
|
||
if (auto u = std::dynamic_pointer_cast<Unit>(e))
|
||
pName = u->getName();
|
||
if (ready) addSystemChatMessage(pName + " has chosen: " + roleName);
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
for (auto op : { Opcode::SMSG_LFG_UPDATE_SEARCH, Opcode::SMSG_UPDATE_LFG_LIST,
|
||
Opcode::SMSG_LFG_PLAYER_INFO, Opcode::SMSG_LFG_PARTY_INFO }) {
|
||
dispatchTable_[op] = [](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
}
|
||
dispatchTable_[Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER] = [this](network::Packet& packet) {
|
||
packet.setReadPos(packet.getSize());
|
||
if (openLfgCallback_) openLfgCallback_();
|
||
};
|
||
|
||
// Arena
|
||
dispatchTable_[Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT] = [this](network::Packet& packet) { handleArenaTeamCommandResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_ARENA_TEAM_QUERY_RESPONSE] = [this](network::Packet& packet) { handleArenaTeamQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_ARENA_TEAM_ROSTER] = [this](network::Packet& packet) { handleArenaTeamRoster(packet); };
|
||
dispatchTable_[Opcode::SMSG_ARENA_TEAM_INVITE] = [this](network::Packet& packet) { handleArenaTeamInvite(packet); };
|
||
dispatchTable_[Opcode::SMSG_ARENA_TEAM_EVENT] = [this](network::Packet& packet) { handleArenaTeamEvent(packet); };
|
||
dispatchTable_[Opcode::SMSG_ARENA_TEAM_STATS] = [this](network::Packet& packet) { handleArenaTeamStats(packet); };
|
||
dispatchTable_[Opcode::SMSG_ARENA_ERROR] = [this](network::Packet& packet) { handleArenaError(packet); };
|
||
dispatchTable_[Opcode::MSG_PVP_LOG_DATA] = [this](network::Packet& packet) { handlePvpLogData(packet); };
|
||
dispatchTable_[Opcode::MSG_TALENT_WIPE_CONFIRM] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); return; }
|
||
talentWipeNpcGuid_ = packet.readUInt64();
|
||
talentWipeCost_ = packet.readUInt32();
|
||
talentWipePending_ = true;
|
||
fireAddonEvent("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)});
|
||
};
|
||
|
||
// MSG_MOVE_* relay (26 opcodes → handleOtherPlayerMovement)
|
||
for (auto op : { Opcode::MSG_MOVE_START_FORWARD, Opcode::MSG_MOVE_START_BACKWARD,
|
||
Opcode::MSG_MOVE_STOP, Opcode::MSG_MOVE_START_STRAFE_LEFT,
|
||
Opcode::MSG_MOVE_START_STRAFE_RIGHT, Opcode::MSG_MOVE_STOP_STRAFE,
|
||
Opcode::MSG_MOVE_JUMP, Opcode::MSG_MOVE_START_TURN_LEFT,
|
||
Opcode::MSG_MOVE_START_TURN_RIGHT, Opcode::MSG_MOVE_STOP_TURN,
|
||
Opcode::MSG_MOVE_SET_FACING, Opcode::MSG_MOVE_FALL_LAND,
|
||
Opcode::MSG_MOVE_HEARTBEAT, Opcode::MSG_MOVE_START_SWIM,
|
||
Opcode::MSG_MOVE_STOP_SWIM, Opcode::MSG_MOVE_SET_WALK_MODE,
|
||
Opcode::MSG_MOVE_SET_RUN_MODE, Opcode::MSG_MOVE_START_PITCH_UP,
|
||
Opcode::MSG_MOVE_START_PITCH_DOWN, Opcode::MSG_MOVE_STOP_PITCH,
|
||
Opcode::MSG_MOVE_START_ASCEND, Opcode::MSG_MOVE_STOP_ASCEND,
|
||
Opcode::MSG_MOVE_START_DESCEND, Opcode::MSG_MOVE_SET_PITCH,
|
||
Opcode::MSG_MOVE_GRAVITY_CHNG, Opcode::MSG_MOVE_UPDATE_CAN_FLY,
|
||
Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY,
|
||
Opcode::MSG_MOVE_ROOT, Opcode::MSG_MOVE_UNROOT }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
if (state == WorldState::IN_WORLD) handleOtherPlayerMovement(packet);
|
||
};
|
||
}
|
||
|
||
// MSG_MOVE_SET_*_SPEED relay (7 opcodes → handleMoveSetSpeed)
|
||
for (auto op : { Opcode::MSG_MOVE_SET_RUN_SPEED, Opcode::MSG_MOVE_SET_RUN_BACK_SPEED,
|
||
Opcode::MSG_MOVE_SET_WALK_SPEED, Opcode::MSG_MOVE_SET_SWIM_SPEED,
|
||
Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED, Opcode::MSG_MOVE_SET_FLIGHT_SPEED,
|
||
Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
if (state == WorldState::IN_WORLD) handleMoveSetSpeed(packet);
|
||
};
|
||
}
|
||
|
||
// Mail
|
||
dispatchTable_[Opcode::SMSG_SHOW_MAILBOX] = [this](network::Packet& packet) { handleShowMailbox(packet); };
|
||
dispatchTable_[Opcode::SMSG_MAIL_LIST_RESULT] = [this](network::Packet& packet) { handleMailListResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_SEND_MAIL_RESULT] = [this](network::Packet& packet) { handleSendMailResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_RECEIVED_MAIL] = [this](network::Packet& packet) { handleReceivedMail(packet); };
|
||
dispatchTable_[Opcode::MSG_QUERY_NEXT_MAIL_TIME] = [this](network::Packet& packet) { handleQueryNextMailTime(packet); };
|
||
|
||
// Inspect / channel list
|
||
dispatchTable_[Opcode::SMSG_INSPECT_RESULTS_UPDATE] = [this](network::Packet& packet) { handleInspectResults(packet); };
|
||
dispatchTable_[Opcode::SMSG_CHANNEL_LIST] = [this](network::Packet& packet) {
|
||
std::string chanName = packet.readString();
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
/*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();
|
||
std::string name;
|
||
auto entity = entityManager.getEntity(memberGuid);
|
||
if (entity) {
|
||
auto player = std::dynamic_pointer_cast<Player>(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);
|
||
}
|
||
};
|
||
|
||
// Bank
|
||
dispatchTable_[Opcode::SMSG_SHOW_BANK] = [this](network::Packet& packet) { handleShowBank(packet); };
|
||
dispatchTable_[Opcode::SMSG_BUY_BANK_SLOT_RESULT] = [this](network::Packet& packet) { handleBuyBankSlotResult(packet); };
|
||
|
||
// Guild bank
|
||
dispatchTable_[Opcode::SMSG_GUILD_BANK_LIST] = [this](network::Packet& packet) { handleGuildBankList(packet); };
|
||
|
||
// Auction house
|
||
dispatchTable_[Opcode::MSG_AUCTION_HELLO] = [this](network::Packet& packet) { handleAuctionHello(packet); };
|
||
dispatchTable_[Opcode::SMSG_AUCTION_LIST_RESULT] = [this](network::Packet& packet) { handleAuctionListResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_AUCTION_OWNER_LIST_RESULT] = [this](network::Packet& packet) { handleAuctionOwnerListResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_AUCTION_BIDDER_LIST_RESULT] = [this](network::Packet& packet) { handleAuctionBidderListResult(packet); };
|
||
dispatchTable_[Opcode::SMSG_AUCTION_COMMAND_RESULT] = [this](network::Packet& packet) { handleAuctionCommandResult(packet); };
|
||
|
||
// Questgiver status
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_STATUS] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 9) {
|
||
uint64_t npcGuid = packet.readUInt64();
|
||
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
|
||
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t count = packet.readUInt32();
|
||
for (uint32_t i = 0; i < count; ++i) {
|
||
if (packet.getSize() - packet.getReadPos() < 9) break;
|
||
uint64_t npcGuid = packet.readUInt64();
|
||
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
|
||
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_QUEST_DETAILS] = [this](network::Packet& packet) { handleQuestDetails(packet); };
|
||
dispatchTable_[Opcode::SMSG_QUESTLOG_FULL] = [this](network::Packet& /*packet*/) {
|
||
addUIError("Your quest log is full.");
|
||
addSystemChatMessage("Your quest log is full.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS] = [this](network::Packet& packet) { handleQuestRequestItems(packet); };
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_OFFER_REWARD] = [this](network::Packet& packet) { handleQuestOfferReward(packet); };
|
||
|
||
// Group set leader
|
||
dispatchTable_[Opcode::SMSG_GROUP_SET_LEADER] = [this](network::Packet& packet) {
|
||
if (packet.getSize() <= packet.getReadPos()) return;
|
||
std::string leaderName = packet.readString();
|
||
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.");
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("PARTY_LEADER_CHANGED", {});
|
||
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
|
||
}
|
||
};
|
||
|
||
// Gameobject / page text
|
||
dispatchTable_[Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE] = [this](network::Packet& packet) { handleGameObjectQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_GAMEOBJECT_PAGETEXT] = [this](network::Packet& packet) { handleGameObjectPageText(packet); };
|
||
dispatchTable_[Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE] = [this](network::Packet& packet) { handlePageTextQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM] = [this](network::Packet& packet) {
|
||
if (packet.getSize() < 12) return;
|
||
uint64_t guid = packet.readUInt64();
|
||
uint32_t animId = packet.readUInt32();
|
||
if (gameObjectCustomAnimCallback_)
|
||
gameObjectCustomAnimCallback_(guid, animId);
|
||
if (animId == 0) {
|
||
auto goEnt = entityManager.getEntity(guid);
|
||
if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) {
|
||
auto go = std::static_pointer_cast<GameObject>(goEnt);
|
||
auto* info = getCachedGameObjectInfo(go->getEntry());
|
||
if (info && info->type == 17) {
|
||
addUIError("A fish is on your line!");
|
||
addSystemChatMessage("A fish is on your line!");
|
||
if (auto* renderer = core::Application::getInstance().getRenderer())
|
||
if (auto* sfx = renderer->getUiSoundManager())
|
||
sfx->playQuestUpdate();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Resurrect failed / item refund / socket gems / item time
|
||
dispatchTable_[Opcode::SMSG_RESURRECT_FAILED] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ITEM_REFUND_RESULT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 12) {
|
||
packet.readUInt64(); // itemGuid
|
||
uint32_t result = packet.readUInt32();
|
||
addSystemChatMessage(result == 0 ? "Item returned. Refund processed."
|
||
: "Could not return item for refund.");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SOCKET_GEMS_RESULT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t result = packet.readUInt32();
|
||
if (result == 0) addSystemChatMessage("Gems socketed successfully.");
|
||
else addSystemChatMessage("Failed to socket gems.");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ITEM_TIME_UPDATE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 12) {
|
||
packet.readUInt64(); // itemGuid
|
||
packet.readUInt32(); // durationMs
|
||
}
|
||
};
|
||
|
||
// ---- Batch 6: Spell miss / env damage / control / spell failure ----
|
||
|
||
// ---- SMSG_SPELLLOGMISS ----
|
||
dispatchTable_[Opcode::SMSG_SPELLLOGMISS] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
uint32_t spellId = packet.readUInt32();
|
||
if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u)
|
||
|| (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t casterGuid = readSpellMissGuid();
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
/*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<uint32_t>(rawCount, 128u);
|
||
|
||
struct SpellMissLogEntry {
|
||
uint64_t victimGuid = 0;
|
||
uint8_t missInfo = 0;
|
||
uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT)
|
||
};
|
||
std::vector<SpellMissLogEntry> 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;
|
||
return;
|
||
}
|
||
const uint64_t victimGuid = readSpellMissGuid();
|
||
if (packet.getSize() - packet.getReadPos() < 1) {
|
||
truncated = true;
|
||
return;
|
||
}
|
||
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;
|
||
return;
|
||
}
|
||
}
|
||
if (i < storedLimit) {
|
||
parsedMisses.push_back({victimGuid, missInfo, reflectSpellId});
|
||
}
|
||
}
|
||
|
||
if (truncated) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- Environmental damage log ----
|
||
dispatchTable_[Opcode::SMSG_ENVIRONMENTALDAMAGELOG] = [this](network::Packet& packet) {
|
||
// uint64 victimGuid + uint8 envDamageType + uint32 damage + uint32 absorb + uint32 resist
|
||
if (packet.getSize() - packet.getReadPos() < 21) return;
|
||
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<int32_t>(damage), 0, false, 0, 0, victimGuid);
|
||
if (absorb > 0)
|
||
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(absorb), 0, false, 0, 0, victimGuid);
|
||
if (resist > 0)
|
||
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(resist), 0, false, 0, 0, victimGuid);
|
||
}
|
||
};
|
||
|
||
// ---- Client control update ----
|
||
dispatchTable_[Opcode::SMSG_CLIENT_CONTROL_UPDATE] = [this](network::Packet& packet) {
|
||
// Minimal parse: PackedGuid + uint8 allowMovement.
|
||
if (packet.getSize() - packet.getReadPos() < 2) {
|
||
LOG_WARNING("SMSG_CLIENT_CONTROL_UPDATE too short: ", packet.getSize(), " bytes");
|
||
return;
|
||
}
|
||
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());
|
||
return;
|
||
}
|
||
for (int i = 0; i < 8; ++i) {
|
||
if (guidMask & (1u << i)) {
|
||
uint8_t b = packet.readUInt8();
|
||
controlGuid |= (static_cast<uint64_t>(b) << (i * 8));
|
||
}
|
||
}
|
||
bool allowMovement = (packet.readUInt8() != 0);
|
||
if (controlGuid == 0 || controlGuid == playerGuid) {
|
||
bool changed = (serverMovementAllowed_ != allowMovement);
|
||
serverMovementAllowed_ = allowMovement;
|
||
if (changed && !allowMovement) {
|
||
// Force-stop local movement immediately when server revokes control.
|
||
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_RIGHT));
|
||
sendMovement(Opcode::MSG_MOVE_STOP);
|
||
sendMovement(Opcode::MSG_MOVE_STOP_STRAFE);
|
||
sendMovement(Opcode::MSG_MOVE_STOP_TURN);
|
||
sendMovement(Opcode::MSG_MOVE_STOP_SWIM);
|
||
addSystemChatMessage("Movement disabled by server.");
|
||
fireAddonEvent("PLAYER_CONTROL_LOST", {});
|
||
} else if (changed && allowMovement) {
|
||
addSystemChatMessage("Movement re-enabled.");
|
||
fireAddonEvent("PLAYER_CONTROL_GAINED", {});
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- Spell failure ----
|
||
dispatchTable_[Opcode::SMSG_SPELL_FAILURE] = [this](network::Packet& packet) {
|
||
// 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<uint8_t>(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<Unit>(pe))
|
||
pt = static_cast<int>(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_) {
|
||
auto unitId = (failGuid == 0) ? std::string("player") : guidToUnitId(failGuid);
|
||
if (!unitId.empty()) {
|
||
fireAddonEvent("UNIT_SPELLCAST_INTERRUPTED", {unitId});
|
||
fireAddonEvent("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);
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- Achievement / fishing delegates ----
|
||
dispatchTable_[Opcode::SMSG_ACHIEVEMENT_EARNED] = [this](network::Packet& packet) {
|
||
handleAchievementEarned(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ALL_ACHIEVEMENT_DATA] = [this](network::Packet& packet) {
|
||
handleAllAchievementData(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ITEM_COOLDOWN] = [this](network::Packet& packet) {
|
||
// 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");
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FISH_NOT_HOOKED] = [this](network::Packet& packet) {
|
||
addSystemChatMessage("Your fish got away.");
|
||
};
|
||
dispatchTable_[Opcode::SMSG_FISH_ESCAPED] = [this](network::Packet& packet) {
|
||
addSystemChatMessage("Your fish escaped!");
|
||
};
|
||
|
||
// ---- Auto-repeat / auras / dispel / totem ----
|
||
dispatchTable_[Opcode::SMSG_CANCEL_AUTO_REPEAT] = [this](network::Packet& packet) {
|
||
// Server signals to stop a repeating spell (wand/shoot); no client action needed
|
||
};
|
||
dispatchTable_[Opcode::SMSG_AURA_UPDATE] = [this](network::Packet& packet) {
|
||
handleAuraUpdate(packet, false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_AURA_UPDATE_ALL] = [this](network::Packet& packet) {
|
||
handleAuraUpdate(packet, true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_DISPEL_FAILED] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
dispelCasterGuid = packet.readUInt64();
|
||
/*uint64_t victim =*/ packet.readUInt64();
|
||
dispelSpellId = packet.readUInt32();
|
||
} else {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
dispelSpellId = packet.readUInt32();
|
||
if (!hasFullPackedGuid(packet)) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (!hasFullPackedGuid(packet)) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
/*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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_TOTEM_CREATED] = [this](network::Packet& packet) {
|
||
// 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)) return;
|
||
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) return;
|
||
uint32_t duration = packet.readUInt32();
|
||
uint32_t spellId = packet.readUInt32();
|
||
LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", static_cast<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();
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_ENVIRONMENTAL_DAMAGE_LOG (distinct from SMSG_ENVIRONMENTALDAMAGELOG) ----
|
||
dispatchTable_[Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG] = [this](network::Packet& packet) {
|
||
// 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()); return; }
|
||
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<int32_t>(dmg), 0, false, envType, 0, victimGuid);
|
||
if (envAbs > 0)
|
||
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(envAbs), 0, false, 0, 0, victimGuid);
|
||
if (envRes > 0)
|
||
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(envRes), 0, false, 0, 0, victimGuid);
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
|
||
// ---- Spline move flag changes for other units (unroot/unset_hover/water_walk) ----
|
||
for (auto op : {Opcode::SMSG_SPLINE_MOVE_UNROOT,
|
||
Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER,
|
||
Opcode::SMSG_SPLINE_MOVE_WATER_WALK}) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
// Minimal parse: PackedGuid only — no animation-relevant state change.
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||
}
|
||
};
|
||
}
|
||
|
||
dispatchTable_[Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING] = [this](network::Packet& packet) {
|
||
// PackedGuid + synthesised move-flags=0 → clears flying animation.
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) return;
|
||
unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY
|
||
};
|
||
|
||
// ---- Spline speed changes for other units ----
|
||
// These use *logicalOp to distinguish which speed to set, so each gets a separate lambda.
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED] = [this](network::Packet& packet) {
|
||
// Minimal parse: PackedGuid + float speed
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float sSpeed = packet.readFloat();
|
||
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
|
||
serverFlightSpeed_ = sSpeed;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float sSpeed = packet.readFloat();
|
||
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
|
||
serverFlightBackSpeed_ = sSpeed;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float sSpeed = packet.readFloat();
|
||
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
|
||
serverSwimBackSpeed_ = sSpeed;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_WALK_SPEED] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float sSpeed = packet.readFloat();
|
||
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
|
||
serverWalkSpeed_ = sSpeed;
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_TURN_RATE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
float sSpeed = packet.readFloat();
|
||
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
|
||
serverTurnRate_ = sSpeed; // rad/s
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SPLINE_SET_PITCH_RATE] = [this](network::Packet& packet) {
|
||
// Minimal parse: PackedGuid + float speed — pitch rate not stored locally
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
(void)packet.readFloat();
|
||
};
|
||
|
||
// ---- Threat updates ----
|
||
for (auto op : {Opcode::SMSG_HIGHEST_THREAT_UPDATE,
|
||
Opcode::SMSG_THREAT_UPDATE}) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
(void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t cnt = packet.readUInt32();
|
||
if (cnt > 100) { packet.setReadPos(packet.getSize()); return; } // sanity
|
||
std::vector<ThreatEntry> list;
|
||
list.reserve(cnt);
|
||
for (uint32_t i = 0; i < cnt; ++i) {
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
ThreatEntry entry;
|
||
entry.victimGuid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
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);
|
||
fireAddonEvent("UNIT_THREAT_LIST_UPDATE", {});
|
||
};
|
||
}
|
||
|
||
// ---- Player movement flag changes (server-pushed) ----
|
||
dispatchTable_[Opcode::SMSG_MOVE_GRAVITY_DISABLE] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK,
|
||
static_cast<uint32_t>(MovementFlags::LEVITATING), true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_GRAVITY_ENABLE] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK,
|
||
static_cast<uint32_t>(MovementFlags::LEVITATING), false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_LAND_WALK] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK,
|
||
static_cast<uint32_t>(MovementFlags::WATER_WALK), false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_NORMAL_FALL] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK,
|
||
static_cast<uint32_t>(MovementFlags::FEATHER_FALL), false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY",
|
||
Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY",
|
||
Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_SET_COLLISION_HGT] = [this](network::Packet& packet) {
|
||
handleMoveSetCollisionHeight(packet);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_SET_FLIGHT] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK,
|
||
static_cast<uint32_t>(MovementFlags::FLYING), true);
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOVE_UNSET_FLIGHT] = [this](network::Packet& packet) {
|
||
handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK,
|
||
static_cast<uint32_t>(MovementFlags::FLYING), false);
|
||
};
|
||
|
||
// ---- Batch 7: World states, action buttons, level-up, vendor, inventory ----
|
||
|
||
// ---- SMSG_INIT_WORLD_STATES ----
|
||
dispatchTable_[Opcode::SMSG_INIT_WORLD_STATES] = [this](network::Packet& packet) {
|
||
// 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");
|
||
return;
|
||
}
|
||
worldStateMapId_ = packet.readUInt32();
|
||
{
|
||
uint32_t newZoneId = packet.readUInt32();
|
||
if (newZoneId != worldStateZoneId_ && newZoneId != 0) {
|
||
worldStateZoneId_ = newZoneId;
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("ZONE_CHANGED_NEW_AREA", {});
|
||
fireAddonEvent("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<size_t>(count) * 8;
|
||
size_t available = packet.getSize() - packet.getReadPos();
|
||
if (available < needed) {
|
||
// Be tolerant across expansion/private-core variants: if packet shape
|
||
// still looks like N*(key,val) dwords, parse what is present.
|
||
if ((available % 8) == 0) {
|
||
uint16_t adjustedCount = static_cast<uint16_t>(available / 8);
|
||
LOG_WARNING("SMSG_INIT_WORLD_STATES count mismatch: header=", count,
|
||
" adjusted=", adjustedCount, " (available=", available, ")");
|
||
count = adjustedCount;
|
||
needed = available;
|
||
} else {
|
||
LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed,
|
||
" bytes of state pairs, got ", available);
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_ACTION_BUTTONS ----
|
||
dispatchTable_[Opcode::SMSG_ACTION_BUTTONS] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
/*uint8_t mode =*/ packet.readUInt8();
|
||
rem--;
|
||
}
|
||
for (int i = 0; i < serverBarSlots; ++i) {
|
||
if (rem < 4) return;
|
||
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<uint8_t>((packed >> 16) & 0xFF);
|
||
} else {
|
||
type = static_cast<uint8_t>((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");
|
||
fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {});
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
|
||
// ---- SMSG_LEVELUP_INFO / SMSG_LEVELUP_INFO_ALT (shared body) ----
|
||
for (auto op : {Opcode::SMSG_LEVELUP_INFO, Opcode::SMSG_LEVELUP_INFO_ALT}) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
// 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_;
|
||
return;
|
||
}
|
||
}
|
||
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);
|
||
fireAddonEvent("PLAYER_LEVEL_UP", {std::to_string(newLevel)});
|
||
}
|
||
}
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
}
|
||
|
||
// ---- SMSG_SELL_ITEM ----
|
||
dispatchTable_[Opcode::SMSG_SELL_ITEM] = [this](network::Packet& packet) {
|
||
// uint64 vendorGuid, uint64 itemGuid, uint8 result
|
||
if ((packet.getSize() - packet.getReadPos()) >= 17) {
|
||
uint64_t vendorGuid = packet.readUInt64();
|
||
uint64_t itemGuid = packet.readUInt64();
|
||
uint8_t result = packet.readUInt8();
|
||
LOG_INFO("SMSG_SELL_ITEM: vendorGuid=0x", std::hex, vendorGuid,
|
||
" itemGuid=0x", itemGuid, std::dec,
|
||
" result=", static_cast<int>(result));
|
||
if (result == 0) {
|
||
pendingSellToBuyback_.erase(itemGuid);
|
||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||
if (auto* sfx = renderer->getUiSoundManager())
|
||
sfx->playDropOnGround();
|
||
}
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("BAG_UPDATE", {});
|
||
fireAddonEvent("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);
|
||
return;
|
||
}
|
||
}
|
||
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: ", static_cast<int>(result), " (", msg, ")");
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_INVENTORY_CHANGE_FAILURE ----
|
||
dispatchTable_[Opcode::SMSG_INVENTORY_CHANGE_FAILURE] = [this](network::Packet& packet) {
|
||
if ((packet.getSize() - packet.getReadPos()) >= 1) {
|
||
uint8_t error = packet.readUInt8();
|
||
if (error != 0) {
|
||
LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", static_cast<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.");
|
||
}
|
||
return;
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_BUY_FAILED ----
|
||
dispatchTable_[Opcode::SMSG_BUY_FAILED] = [this](network::Packet& packet) {
|
||
// vendorGuid(8) + itemId(4) + errorCode(1)
|
||
if (packet.getSize() - packet.getReadPos() >= 13) {
|
||
uint64_t vendorGuid = packet.readUInt64();
|
||
uint32_t itemIdOrSlot = packet.readUInt32();
|
||
uint8_t errCode = packet.readUInt8();
|
||
LOG_INFO("SMSG_BUY_FAILED: vendorGuid=0x", std::hex, vendorGuid, std::dec,
|
||
" item/slot=", itemIdOrSlot,
|
||
" err=", static_cast<int>(errCode),
|
||
" pendingBuybackSlot=", pendingBuybackSlot_,
|
||
" pendingBuybackWireSlot=", pendingBuybackWireSlot_,
|
||
" pendingBuyItemId=", pendingBuyItemId_,
|
||
" pendingBuyItemSlot=", pendingBuyItemSlot_);
|
||
if (pendingBuybackSlot_ >= 0) {
|
||
// Some cores require probing absolute buyback slots until a live entry is found.
|
||
if (errCode == 0) {
|
||
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
|
||
constexpr uint32_t kBuybackSlotEnd = 85;
|
||
if (pendingBuybackWireSlot_ >= 74 && pendingBuybackWireSlot_ < kBuybackSlotEnd &&
|
||
socket && state == WorldState::IN_WORLD && currentVendorItems.vendorGuid != 0) {
|
||
++pendingBuybackWireSlot_;
|
||
LOG_INFO("Buyback retry: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
|
||
std::dec, " uiSlot=", pendingBuybackSlot_,
|
||
" wireSlot=", pendingBuybackWireSlot_);
|
||
network::Packet retry(kWotlkCmsgBuybackItemOpcode);
|
||
retry.writeUInt64(currentVendorItems.vendorGuid);
|
||
retry.writeUInt32(pendingBuybackWireSlot_);
|
||
socket->send(retry);
|
||
return;
|
||
}
|
||
// Exhausted slot probe: drop stale local row and advance.
|
||
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
|
||
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
|
||
}
|
||
pendingBuybackSlot_ = -1;
|
||
pendingBuybackWireSlot_ = 0;
|
||
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
|
||
auto pkt = ListInventoryPacket::build(currentVendorItems.vendorGuid);
|
||
socket->send(pkt);
|
||
}
|
||
return;
|
||
}
|
||
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();
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_BUY_ITEM ----
|
||
dispatchTable_[Opcode::SMSG_BUY_ITEM] = [this](network::Packet& packet) {
|
||
// 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<int32_t>(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_) {
|
||
fireAddonEvent("MERCHANT_UPDATE", {});
|
||
fireAddonEvent("BAG_UPDATE", {});
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---- MSG_RAID_TARGET_UPDATE ----
|
||
dispatchTable_[Opcode::MSG_RAID_TARGET_UPDATE] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
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) return;
|
||
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<int>(rtuType));
|
||
fireAddonEvent("RAID_TARGET_UPDATE", {});
|
||
};
|
||
|
||
// ---- SMSG_CRITERIA_UPDATE ----
|
||
dispatchTable_[Opcode::SMSG_CRITERIA_UPDATE] = [this](network::Packet& packet) {
|
||
// 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 (progress != oldProgress)
|
||
fireAddonEvent("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)});
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_BARBER_SHOP_RESULT ----
|
||
dispatchTable_[Opcode::SMSG_BARBER_SHOP_RESULT] = [this](network::Packet& packet) {
|
||
// 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;
|
||
fireAddonEvent("BARBER_SHOP_CLOSE", {});
|
||
} 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);
|
||
}
|
||
};
|
||
|
||
// ---- SMSG_QUESTGIVER_QUEST_FAILED ----
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_QUEST_FAILED] = [this](network::Packet& packet) {
|
||
// uint32 questId + uint32 reason
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint32_t questId = packet.readUInt32();
|
||
uint32_t reason = packet.readUInt32();
|
||
auto questTitle = getQuestTitle(questId);
|
||
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);
|
||
}
|
||
};
|
||
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Batch 8-12: Remaining opcodes (inspects, quests, auctions, spells,
|
||
// calendars, battlefields, voice, misc consume-only)
|
||
// -----------------------------------------------------------------------
|
||
// uint32 setIndex + uint64 guid — equipment set was successfully saved
|
||
dispatchTable_[Opcode::SMSG_EQUIPMENT_SET_SAVED] = [this](network::Packet& packet) {
|
||
// 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.");
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_PERIODICAURALOG] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
uint64_t victimGuid = periodicTbc
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < guidMinSz) return;
|
||
uint64_t casterGuid = periodicTbc
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
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());
|
||
return;
|
||
}
|
||
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<int32_t>(dmg),
|
||
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
||
if (abs > 0)
|
||
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(abs),
|
||
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
||
if (res > 0)
|
||
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(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<int32_t>(heal),
|
||
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
||
if (hotAbs > 0)
|
||
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(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<uint8_t>(packet.readUInt32());
|
||
uint32_t amount = packet.readUInt32();
|
||
if ((isPlayerVictim || isPlayerCaster) && amount > 0)
|
||
addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(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<uint8_t>(packet.readUInt32());
|
||
uint32_t amount = packet.readUInt32();
|
||
float multiplier = packet.readFloat();
|
||
if (isPlayerVictim && amount > 0)
|
||
addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(amount),
|
||
spellId, false, powerType, casterGuid, victimGuid);
|
||
if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) {
|
||
const uint32_t gainedAmount = static_cast<uint32_t>(
|
||
std::lround(static_cast<double>(amount) * static_cast<double>(multiplier)));
|
||
if (gainedAmount > 0) {
|
||
addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(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());
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_SPELLENERGIZELOG] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
uint64_t victimGuid = readEnergizeGuid();
|
||
if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)
|
||
|| (!energizeTbc && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t casterGuid = readEnergizeGuid();
|
||
if (packet.getSize() - packet.getReadPos() < 9) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint32_t spellId = packet.readUInt32();
|
||
uint8_t energizePowerType = packet.readUInt8();
|
||
int32_t amount = static_cast<int32_t>(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());
|
||
};
|
||
// uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs
|
||
dispatchTable_[Opcode::SMSG_OVERRIDE_LIGHT] = [this](network::Packet& packet) {
|
||
// 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");
|
||
}
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_WEATHER] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
// Notify addons of weather change
|
||
fireAddonEvent("WEATHER_CHANGED", {std::to_string(wType), std::to_string(wIntensity)});
|
||
// 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);
|
||
}
|
||
}
|
||
};
|
||
// Server-script text message — display in system chat
|
||
dispatchTable_[Opcode::SMSG_SCRIPT_MESSAGE] = [this](network::Packet& packet) {
|
||
// Server-script text message — display in system chat
|
||
std::string msg = packet.readString();
|
||
if (!msg.empty()) {
|
||
addSystemChatMessage(msg);
|
||
LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg);
|
||
}
|
||
};
|
||
// uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType
|
||
dispatchTable_[Opcode::SMSG_ENCHANTMENTLOG] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
// Quest query failed - parse failure reason
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_QUEST_INVALID] = [this](network::Packet& packet) {
|
||
// Quest query failed - parse failure reason
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t failReason = packet.readUInt32();
|
||
pendingTurnInRewardRequest_ = false;
|
||
const char* reasonStr = "Unknown";
|
||
switch (failReason) {
|
||
case 0: reasonStr = "Don't have quest"; break;
|
||
case 1: reasonStr = "Quest level too low"; break;
|
||
case 4: reasonStr = "Insufficient money"; break;
|
||
case 5: reasonStr = "Inventory full"; break;
|
||
case 13: reasonStr = "Already on that quest"; break;
|
||
case 18: reasonStr = "Already completed quest"; break;
|
||
case 19: reasonStr = "Can't take any more quests"; break;
|
||
}
|
||
LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")");
|
||
if (!pendingQuestAcceptTimeouts_.empty()) {
|
||
std::vector<uint32_t> pendingQuestIds;
|
||
pendingQuestIds.reserve(pendingQuestAcceptTimeouts_.size());
|
||
for (const auto& pending : pendingQuestAcceptTimeouts_) {
|
||
pendingQuestIds.push_back(pending.first);
|
||
}
|
||
for (uint32_t questId : pendingQuestIds) {
|
||
const uint64_t npcGuid = pendingQuestAcceptNpcGuids_.count(questId) != 0
|
||
? pendingQuestAcceptNpcGuids_[questId] : 0;
|
||
if (failReason == 13) {
|
||
std::string fallbackTitle = "Quest #" + std::to_string(questId);
|
||
std::string fallbackObjectives;
|
||
if (currentQuestDetails.questId == questId) {
|
||
if (!currentQuestDetails.title.empty()) fallbackTitle = currentQuestDetails.title;
|
||
fallbackObjectives = currentQuestDetails.objectives;
|
||
}
|
||
addQuestToLocalLogIfMissing(questId, fallbackTitle, fallbackObjectives);
|
||
triggerQuestAcceptResync(questId, npcGuid, "already-on-quest");
|
||
} else if (failReason == 18) {
|
||
triggerQuestAcceptResync(questId, npcGuid, "already-completed");
|
||
}
|
||
clearPendingQuestAccept(questId);
|
||
}
|
||
}
|
||
// Only show error to user for real errors (not informational messages)
|
||
if (failReason != 13 && failReason != 18) { // Don't spam "already on/completed"
|
||
addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr);
|
||
}
|
||
}
|
||
};
|
||
// Mark quest as complete in local log
|
||
dispatchTable_[Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE] = [this](network::Packet& packet) {
|
||
// 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");
|
||
fireAddonEvent("QUEST_TURNED_IN", {std::to_string(questId)});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("QUEST_LOG_UPDATE", {});
|
||
fireAddonEvent("UNIT_QUEST_LOG_CHANGED", {"player"});
|
||
}
|
||
// Re-query all nearby quest giver NPCs so markers refresh
|
||
if (socket) {
|
||
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
||
if (entity->getType() != ObjectType::UNIT) continue;
|
||
auto unit = std::static_pointer_cast<Unit>(entity);
|
||
if (unit->getNpcFlags() & 0x02) {
|
||
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
||
qsPkt.writeUInt64(guid);
|
||
socket->send(qsPkt);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
// Quest kill count update
|
||
// Compatibility: some classic-family opcode tables swap ADD_KILL and COMPLETE.
|
||
dispatchTable_[Opcode::SMSG_QUESTUPDATE_ADD_KILL] = [this](network::Packet& packet) {
|
||
// 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<uint32_t>(
|
||
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_) {
|
||
fireAddonEvent("QUEST_WATCH_UPDATE", {std::to_string(questId)});
|
||
fireAddonEvent("QUEST_LOG_UPDATE", {});
|
||
fireAddonEvent("UNIT_QUEST_LOG_CHANGED", {"player"});
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
// Quest item count update: itemId + count
|
||
dispatchTable_[Opcode::SMSG_QUESTUPDATE_ADD_ITEM] = [this](network::Packet& packet) {
|
||
// 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 (updatedAny) {
|
||
fireAddonEvent("QUEST_WATCH_UPDATE", {});
|
||
fireAddonEvent("QUEST_LOG_UPDATE", {});
|
||
fireAddonEvent("UNIT_QUEST_LOG_CHANGED", {"player"});
|
||
}
|
||
LOG_INFO("Quest item update: itemId=", itemId, " count=", count,
|
||
" trackedQuestsUpdated=", updatedAny);
|
||
}
|
||
};
|
||
// Quest objectives completed - mark as ready to turn in.
|
||
// Compatibility: some classic-family opcode tables swap COMPLETE and ADD_KILL.
|
||
dispatchTable_[Opcode::SMSG_QUESTUPDATE_COMPLETE] = [this](network::Packet& packet) {
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
// 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).
|
||
dispatchTable_[Opcode::SMSG_QUEST_FORCE_REMOVE] = [this](network::Packet& packet) {
|
||
// 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");
|
||
return;
|
||
}
|
||
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.");
|
||
fireAddonEvent("PLAYER_UPDATE_RESTING", {});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 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.
|
||
return;
|
||
}
|
||
|
||
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) + ").");
|
||
}
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("QUEST_LOG_UPDATE", {});
|
||
fireAddonEvent("UNIT_QUEST_LOG_CHANGED", {"player"});
|
||
fireAddonEvent("QUEST_REMOVED", {std::to_string(questId)});
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_QUEST_QUERY_RESPONSE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() < 8) {
|
||
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
||
return;
|
||
}
|
||
|
||
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<uint32_t>(id), 0);
|
||
else queryGameObjectInfo(static_cast<uint32_t>(-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);
|
||
};
|
||
// WotLK: uint64 playerGuid + uint8 teamCount + per-team fields
|
||
dispatchTable_[Opcode::MSG_INSPECT_ARENA_TEAMS] = [this](network::Packet& packet) {
|
||
// WotLK: uint64 playerGuid + uint8 teamCount + per-team fields
|
||
if (packet.getSize() - packet.getReadPos() < 9) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
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=", static_cast<int>(teamCount));
|
||
};
|
||
// auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ...
|
||
// action: 0=sold/won, 1=expired, 2=bid placed on your auction
|
||
dispatchTable_[Opcode::SMSG_AUCTION_OWNER_NOTIFICATION] = [this](network::Packet& packet) {
|
||
// 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<int32_t>(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());
|
||
};
|
||
// auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32)
|
||
dispatchTable_[Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION] = [this](network::Packet& packet) {
|
||
// 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<int32_t>(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());
|
||
};
|
||
// uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled
|
||
dispatchTable_[Opcode::SMSG_AUCTION_REMOVED_NOTIFICATION] = [this](network::Packet& packet) {
|
||
// 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<int32_t>(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());
|
||
};
|
||
// uint64 containerGuid — tells client to open this container
|
||
// The actual items come via update packets; we just log this.
|
||
dispatchTable_[Opcode::SMSG_OPEN_CONTAINER] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
};
|
||
// PackedGuid (player guid) + uint32 vehicleId
|
||
// vehicleId == 0 means the player left the vehicle
|
||
dispatchTable_[Opcode::SMSG_PLAYER_VEHICLE_DATA] = [this](network::Packet& packet) {
|
||
// 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;
|
||
}
|
||
};
|
||
// guid(8) + status(1): status 1 = NPC has available/new routes for this player
|
||
dispatchTable_[Opcode::SMSG_TAXINODE_STATUS] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
};
|
||
// 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}
|
||
dispatchTable_[Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE] = [this](network::Packet& packet) {
|
||
// 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 = true;
|
||
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
|
||
if (remaining() < 9) { packet.setReadPos(packet.getSize()); return; }
|
||
uint64_t auraTargetGuid = packet.readUInt64();
|
||
uint8_t count = packet.readUInt8();
|
||
|
||
std::vector<AuraSlot>* 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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<int32_t>(durationMs);
|
||
a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast<int32_t>(maxDurMs);
|
||
a.receivedAtMs = nowMs;
|
||
}
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// 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}
|
||
dispatchTable_[Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE] = [this](network::Packet& packet) {
|
||
// 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 = false;
|
||
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
|
||
if (remaining() < 9) { packet.setReadPos(packet.getSize()); return; }
|
||
uint64_t auraTargetGuid = packet.readUInt64();
|
||
uint8_t count = packet.readUInt8();
|
||
|
||
std::vector<AuraSlot>* 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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<int32_t>(durationMs);
|
||
a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast<int32_t>(maxDurMs);
|
||
a.receivedAtMs = nowMs;
|
||
}
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GUILD_DECLINE] = [this](network::Packet& packet) {
|
||
if (packet.getReadPos() < packet.getSize()) {
|
||
std::string name = packet.readString();
|
||
addSystemChatMessage(name + " declined your guild invitation.");
|
||
}
|
||
};
|
||
// Clear cached talent data so the talent screen reflects the reset.
|
||
dispatchTable_[Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SET_REST_START] = [this](network::Packet& packet) {
|
||
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.");
|
||
fireAddonEvent("PLAYER_UPDATE_RESTING", {});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_UPDATE_AURA_DURATION] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 5) {
|
||
uint8_t slot = packet.readUInt8();
|
||
uint32_t durationMs = packet.readUInt32();
|
||
handleUpdateAuraDuration(slot, durationMs);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_MOUNTSPECIAL_ANIM] = [this](network::Packet& packet) { (void)UpdateObjectParser::readPackedGuid(packet); };
|
||
dispatchTable_[Opcode::SMSG_CHAR_CUSTOMIZE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
uint8_t result = packet.readUInt8();
|
||
addSystemChatMessage(result == 0 ? "Character customization complete."
|
||
: "Character customization failed.");
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_CHAR_FACTION_CHANGE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
uint8_t result = packet.readUInt8();
|
||
addSystemChatMessage(result == 0 ? "Faction change complete."
|
||
: "Faction change failed.");
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_INVALIDATE_PLAYER] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
uint64_t guid = packet.readUInt64();
|
||
playerNameCache.erase(guid);
|
||
}
|
||
};
|
||
// uint32 movieId — we don't play movies; acknowledge immediately.
|
||
dispatchTable_[Opcode::SMSG_TRIGGER_MOVIE] = [this](network::Packet& packet) {
|
||
// 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");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_EQUIPMENT_SET_LIST] = [this](network::Packet& packet) { handleEquipmentSetList(packet); };
|
||
dispatchTable_[Opcode::SMSG_EQUIPMENT_SET_USE_RESULT] = [this](network::Packet& packet) {
|
||
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."); }
|
||
}
|
||
};
|
||
// Server-side LFG invite timed out (no response within time limit)
|
||
dispatchTable_[Opcode::SMSG_LFG_TIMEDOUT] = [this](network::Packet& packet) {
|
||
// Server-side LFG invite timed out (no response within time limit)
|
||
addSystemChatMessage("Dungeon Finder: Invite timed out.");
|
||
if (openLfgCallback_) openLfgCallback_();
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// Another party member failed to respond to a LFG role-check in time
|
||
dispatchTable_[Opcode::SMSG_LFG_OTHER_TIMEDOUT] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time)
|
||
dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// No eligible players found for auto-join
|
||
dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// Party leader is currently set to Looking for More (LFM) mode
|
||
dispatchTable_[Opcode::SMSG_LFG_LEADER_IS_LFM] = [this](network::Packet& packet) {
|
||
// Party leader is currently set to Looking for More (LFM) mode
|
||
addSystemChatMessage("Your party leader is currently Looking for More.");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone
|
||
dispatchTable_[Opcode::SMSG_MEETINGSTONE_SETQUEUE] = [this](network::Packet& packet) {
|
||
// 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=", static_cast<int>(levelMin), "-", static_cast<int>(levelMax));
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// Server confirms group found and teleport summon is ready
|
||
dispatchTable_[Opcode::SMSG_MEETINGSTONE_COMPLETE] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// Meeting stone search is still ongoing
|
||
dispatchTable_[Opcode::SMSG_MEETINGSTONE_IN_PROGRESS] = [this](network::Packet& packet) {
|
||
// Meeting stone search is still ongoing
|
||
addSystemChatMessage("Meeting Stone: Searching for group members...");
|
||
LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint64 memberGuid — a player was added to your group via meeting stone
|
||
dispatchTable_[Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
};
|
||
// 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
|
||
dispatchTable_[Opcode::SMSG_MEETINGSTONE_JOINFAILED] = [this](network::Packet& packet) {
|
||
// 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=", static_cast<int>(reason));
|
||
}
|
||
};
|
||
// Player was removed from the meeting stone queue (left, or group disbanded)
|
||
dispatchTable_[Opcode::SMSG_MEETINGSTONE_LEAVE] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GMTICKET_CREATE] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
uint8_t res = packet.readUInt8();
|
||
addSystemChatMessage(res == 1 ? "GM ticket submitted."
|
||
: "Failed to submit GM ticket.");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GMTICKET_UPDATETEXT] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
uint8_t res = packet.readUInt8();
|
||
addSystemChatMessage(res == 1 ? "GM ticket updated."
|
||
: "Failed to update GM ticket.");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_GMTICKET_DELETETICKET] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
uint8_t res = packet.readUInt8();
|
||
addSystemChatMessage(res == 9 ? "GM ticket deleted."
|
||
: "No ticket to delete.");
|
||
}
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_GMTICKET_GETTICKET] = [this](network::Packet& packet) {
|
||
// 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()); return; }
|
||
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=", static_cast<int>(gmStatus), ")");
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint32 status: 1 = GM support available, 0 = offline/unavailable
|
||
dispatchTable_[Opcode::SMSG_GMTICKET_SYSTEMSTATUS] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death)
|
||
dispatchTable_[Opcode::SMSG_CONVERT_RUNE] = [this](network::Packet& packet) {
|
||
// uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death)
|
||
if (packet.getSize() - packet.getReadPos() < 2) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
uint8_t idx = packet.readUInt8();
|
||
uint8_t type = packet.readUInt8();
|
||
if (idx < 6) playerRunes_[idx].type = static_cast<RuneType>(type & 0x3);
|
||
};
|
||
// uint8 runeReadyMask (bit i=1 → rune i is ready)
|
||
// uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255)
|
||
dispatchTable_[Opcode::SMSG_RESYNC_RUNES] = [this](network::Packet& packet) {
|
||
// 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());
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
};
|
||
// uint32 runeMask (bit i=1 → rune i just became ready)
|
||
dispatchTable_[Opcode::SMSG_ADD_RUNE_POWER] = [this](network::Packet& packet) {
|
||
// uint32 runeMask (bit i=1 → rune i just became ready)
|
||
if (packet.getSize() - packet.getReadPos() < 4) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_SPELLDAMAGESHIELD] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
if (!shieldTbc && (!hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t victimGuid = shieldTbc
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < (shieldTbc ? 8u : 1u)
|
||
|| (!shieldTbc && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t casterGuid = shieldTbc
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
const size_t shieldTailSize = shieldWotlkLike ? 16u : 12u;
|
||
if (shieldRem() < shieldTailSize) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
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<int32_t>(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<int32_t>(damage), shieldSpellId, false, 0, casterGuid, victimGuid);
|
||
}
|
||
};
|
||
// WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType
|
||
// TBC: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8
|
||
dispatchTable_[Opcode::SMSG_SPELLORDAMAGE_IMMUNE] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
if (!immuneUsesFullGuid && !hasFullPackedGuid(packet)) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t casterGuid = immuneUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < (immuneUsesFullGuid ? 8u : 2u)
|
||
|| (!immuneUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t victimGuid = immuneUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 5) return;
|
||
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);
|
||
}
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_SPELLDISPELLOG] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
uint64_t casterGuid = dispelUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)
|
||
|| (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t victimGuid = dispelUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 9) return;
|
||
/*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<uint32_t> 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<uint32_t> 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());
|
||
};
|
||
// 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
|
||
dispatchTable_[Opcode::SMSG_SPELLSTEALLOG] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
uint64_t stealVictim = stealUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)
|
||
|| (!stealUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t stealCaster = stealUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 9) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
/*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<uint32_t> 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<uint32_t> 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());
|
||
};
|
||
// WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ...
|
||
// TBC: uint64 target + uint64 caster + uint32 spellId + ...
|
||
dispatchTable_[Opcode::SMSG_SPELL_CHANCE_PROC_LOG] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
uint64_t procTargetGuid = readProcChanceGuid();
|
||
if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)
|
||
|| (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t procCasterGuid = readProcChanceGuid();
|
||
if (packet.getSize() - packet.getReadPos() < 4) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
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());
|
||
};
|
||
// 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
|
||
dispatchTable_[Opcode::SMSG_SPELLINSTAKILLLOG] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
uint64_t ikCaster = ikUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)
|
||
|| (!ikUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t ikVictim = ikUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (ik_rem() < 4) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
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());
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_SPELLLOGEXECUTE] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
if (!exeUsesFullGuid && !hasFullPackedGuid(packet)) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t exeCaster = exeUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (packet.getSize() - packet.getReadPos() < 8) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
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<int32_t>(drainAmount), exeSpellId, false,
|
||
static_cast<uint8_t>(drainPower),
|
||
exeCaster, drainTarget);
|
||
if (isPlayerCaster) {
|
||
if (drainTarget != playerGuid) {
|
||
addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(drainAmount), exeSpellId, true,
|
||
static_cast<uint8_t>(drainPower), exeCaster, drainTarget);
|
||
}
|
||
if (drainMult > 0.0f && std::isfinite(drainMult)) {
|
||
const uint32_t gainedAmount = static_cast<uint32_t>(
|
||
std::lround(static_cast<double>(drainAmount) * static_cast<double>(drainMult)));
|
||
if (gainedAmount > 0) {
|
||
addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(gainedAmount), exeSpellId, true,
|
||
static_cast<uint8_t>(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<int32_t>(leechAmount), exeSpellId, false, 0,
|
||
exeCaster, leechTarget);
|
||
} else if (isPlayerCaster) {
|
||
addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast<int32_t>(leechAmount), exeSpellId, true, 0,
|
||
exeCaster, leechTarget);
|
||
}
|
||
if (isPlayerCaster && leechMult > 0.0f && std::isfinite(leechMult)) {
|
||
const uint32_t gainedAmount = static_cast<uint32_t>(
|
||
std::lround(static_cast<double>(leechAmount) * static_cast<double>(leechMult)));
|
||
if (gainedAmount > 0) {
|
||
addCombatText(CombatTextEntry::HEAL, static_cast<int32_t>(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());
|
||
};
|
||
// TBC 2.4.3: clear a single aura slot for a unit
|
||
// Format: uint64 targetGuid + uint8 slot
|
||
dispatchTable_[Opcode::SMSG_CLEAR_EXTRA_AURA_INFO] = [this](network::Packet& packet) {
|
||
// 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<AuraSlot>* 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());
|
||
};
|
||
// Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid
|
||
// slot: 0=main-hand, 1=off-hand, 2=ranged
|
||
dispatchTable_[Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
/*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) { return; }
|
||
|
||
uint64_t nowMs = static_cast<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<uint64_t>(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");
|
||
};
|
||
// uint8 result: 0=success, 1=failed, 2=disabled
|
||
dispatchTable_[Opcode::SMSG_COMPLAIN_RESULT] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// WotLK: packed_guid caster + packed_guid target + uint32 spellId + uint32 remainingMs + uint32 totalMs + uint8 schoolMask
|
||
// TBC/Classic: uint64 caster + uint64 target + ...
|
||
dispatchTable_[Opcode::SMSG_RESUME_CAST_BAR] = [this](network::Packet& packet) {
|
||
// 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)) return;
|
||
uint64_t caster = rcbTbc
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (remaining() < (rcbTbc ? 8u : 1u)) return;
|
||
if (rcbTbc) packet.readUInt64(); // target (discard)
|
||
else (void)UpdateObjectParser::readPackedGuid(packet); // target
|
||
if (remaining() < 12) return;
|
||
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");
|
||
}
|
||
};
|
||
// casterGuid + uint32 spellId + uint32 totalDurationMs
|
||
dispatchTable_[Opcode::MSG_CHANNEL_START] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
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.isChannel = 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_) {
|
||
auto unitId = guidToUnitId(chanCaster);
|
||
if (!unitId.empty())
|
||
fireAddonEvent("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)});
|
||
}
|
||
}
|
||
};
|
||
// casterGuid + uint32 remainingMs
|
||
dispatchTable_[Opcode::MSG_CHANNEL_UPDATE] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
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) {
|
||
auto unitId = guidToUnitId(chanCaster2);
|
||
if (!unitId.empty())
|
||
fireAddonEvent("UNIT_SPELLCAST_CHANNEL_STOP", {unitId});
|
||
}
|
||
};
|
||
// uint32 slot + packed_guid unit (0 packed = clear slot)
|
||
dispatchTable_[Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT] = [this](network::Packet& packet) {
|
||
// uint32 slot + packed_guid unit (0 packed = clear slot)
|
||
if (packet.getSize() - packet.getReadPos() < 5) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
};
|
||
// charName (cstring) + guid (uint64) + achievementId (uint32) + ...
|
||
dispatchTable_[Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_SET_FORCED_REACTIONS] = [this](network::Packet& packet) { handleSetForcedReactions(packet); };
|
||
dispatchTable_[Opcode::SMSG_SUSPEND_COMMS] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
}
|
||
};
|
||
// 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.
|
||
dispatchTable_[Opcode::SMSG_PRE_RESURRECT] = [this](network::Packet& packet) {
|
||
// 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, ")");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PLAYERBINDERROR] = [this](network::Packet& packet) {
|
||
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.");
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RAID_GROUP_ONLY] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RAID_READY_CHECK_ERROR] = [this](network::Packet& packet) {
|
||
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."); }
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RESET_FAILED_NOTIFY] = [this](network::Packet& packet) {
|
||
addUIError("Cannot reset instance: another player is still inside.");
|
||
addSystemChatMessage("Cannot reset instance: another player is still inside.");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint32 splitType + uint32 deferTime + string realmName
|
||
// Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers.
|
||
dispatchTable_[Opcode::SMSG_REALM_SPLIT] = [this](network::Packet& packet) {
|
||
// 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");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_REAL_GROUP_UPDATE] = [this](network::Packet& packet) {
|
||
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
||
if (rem() < 1) return;
|
||
uint8_t newGroupType = packet.readUInt8();
|
||
if (rem() < 4) return;
|
||
uint32_t newMemberFlags = packet.readUInt32();
|
||
if (rem() < 8) return;
|
||
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<uint8_t>(newMemberFlags & 0xFF);
|
||
break;
|
||
}
|
||
}
|
||
LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast<int>(newGroupType),
|
||
" memberFlags=0x", std::hex, newMemberFlags, std::dec,
|
||
" leaderGuid=", newLeaderGuid);
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("PARTY_LEADER_CHANGED", {});
|
||
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PLAY_MUSIC] = [this](network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||
uint32_t soundId = packet.readUInt32();
|
||
if (playMusicCallback_) playMusicCallback_(soundId);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PLAY_OBJECT_SOUND] = [this](network::Packet& packet) {
|
||
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);
|
||
}
|
||
};
|
||
// uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL)
|
||
dispatchTable_[Opcode::SMSG_PLAY_SPELL_IMPACT] = [this](network::Packet& packet) {
|
||
// uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL)
|
||
if (packet.getSize() - packet.getReadPos() < 12) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t impTargetGuid = packet.readUInt64();
|
||
uint32_t impVisualId = packet.readUInt32();
|
||
if (impVisualId == 0) return;
|
||
auto* renderer = core::Application::getInstance().getRenderer();
|
||
if (!renderer) return;
|
||
glm::vec3 spawnPos;
|
||
if (impTargetGuid == playerGuid) {
|
||
spawnPos = renderer->getCharacterPosition();
|
||
} else {
|
||
auto entity = entityManager.getEntity(impTargetGuid);
|
||
if (!entity) return;
|
||
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
||
spawnPos = core::coords::canonicalToRender(canonical);
|
||
}
|
||
renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true);
|
||
};
|
||
// 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.
|
||
dispatchTable_[Opcode::SMSG_RESISTLOG] = [this](network::Packet& packet) {
|
||
// 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()); return; }
|
||
/*uint32_t hitInfo =*/ packet.readUInt32();
|
||
if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)
|
||
|| (!rlUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t attackerGuid = rlUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)
|
||
|| (!rlUsesFullGuid && !hasFullPackedGuid(packet))) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
uint64_t victimGuid = rlUsesFullGuid
|
||
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
||
if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); return; }
|
||
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()); return; }
|
||
/*float resistFactor =*/ packet.readFloat();
|
||
/*uint32_t targetRes =*/ packet.readUInt32();
|
||
int32_t resistedAmount = static_cast<int32_t>(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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_READ_ITEM_OK] = [this](network::Packet& packet) {
|
||
bookPages_.clear(); // fresh book for this item read
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_READ_ITEM_FAILED] = [this](network::Packet& packet) {
|
||
addUIError("You cannot read this item.");
|
||
addSystemChatMessage("You cannot read this item.");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_QUERY_QUESTS_COMPLETED_RESPONSE] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
// WotLK 3.3.5a format: uint64 guid + uint32 questId + uint32 count + uint32 reqCount
|
||
// Classic format: uint64 guid + uint32 questId + uint32 count (no reqCount)
|
||
dispatchTable_[Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL] = [this](network::Packet& packet) {
|
||
// 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;
|
||
}
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_NPC_WONT_TALK] = [this](network::Packet& packet) {
|
||
addUIError("That creature can't talk to you right now.");
|
||
addSystemChatMessage("That creature can't talk to you right now.");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_OFFER_PETITION_ERROR] = [this](network::Packet& packet) {
|
||
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.");
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PETITION_QUERY_RESPONSE] = [this](network::Packet& packet) { handlePetitionQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_PETITION_SHOW_SIGNATURES] = [this](network::Packet& packet) { handlePetitionShowSignatures(packet); };
|
||
dispatchTable_[Opcode::SMSG_PETITION_SIGN_RESULTS] = [this](network::Packet& packet) { handlePetitionSignResults(packet); };
|
||
// uint64 petGuid, uint32 mode
|
||
// mode bits: low byte = command state, next byte = react state
|
||
dispatchTable_[Opcode::SMSG_PET_MODE] = [this](network::Packet& packet) {
|
||
// 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<uint8_t>(mode & 0xFF);
|
||
petReact_ = static_cast<uint8_t>((mode >> 8) & 0xFF);
|
||
LOG_DEBUG("SMSG_PET_MODE: command=", static_cast<int>(petCommand_),
|
||
" react=", static_cast<int>(petReact_));
|
||
}
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// Pet bond broken (died or forcibly dismissed) — clear pet state
|
||
dispatchTable_[Opcode::SMSG_PET_BROKEN] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PET_LEARNED_SPELL] = [this](network::Packet& packet) {
|
||
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);
|
||
fireAddonEvent("PET_BAR_UPDATE", {});
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PET_UNLEARNED_SPELL] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
// WotLK: castCount(1) + spellId(4) + reason(1)
|
||
// Classic/TBC: spellId(4) + reason(1) (no castCount)
|
||
dispatchTable_[Opcode::SMSG_PET_CAST_FAILED] = [this](network::Packet& packet) {
|
||
// 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=", static_cast<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());
|
||
};
|
||
// uint64 petGuid + uint32 cost (copper)
|
||
for (auto op : { Opcode::SMSG_PET_GUIDS, Opcode::SMSG_PET_DISMISS_SOUND, Opcode::SMSG_PET_ACTION_SOUND, Opcode::SMSG_PET_UNLEARN_CONFIRM }) {
|
||
dispatchTable_[op] = [this](network::Packet& packet) {
|
||
// uint64 petGuid + uint32 cost (copper)
|
||
if (packet.getSize() - packet.getReadPos() >= 12) {
|
||
petUnlearnGuid_ = packet.readUInt64();
|
||
petUnlearnCost_ = packet.readUInt32();
|
||
petUnlearnPending_ = true;
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
}
|
||
// Server signals that the pet can now be named (first tame)
|
||
dispatchTable_[Opcode::SMSG_PET_RENAMEABLE] = [this](network::Packet& packet) {
|
||
// Server signals that the pet can now be named (first tame)
|
||
petRenameablePending_ = true;
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_PET_NAME_INVALID] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
// 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.
|
||
dispatchTable_[Opcode::SMSG_INSPECT] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||
if (guid == 0) { packet.setReadPos(packet.getSize()); return; }
|
||
|
||
constexpr int kGearSlots = 19;
|
||
size_t needed = kGearSlots * sizeof(uint32_t);
|
||
if (packet.getSize() - packet.getReadPos() < needed) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
|
||
std::array<uint32_t, 19> 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<Player>(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);
|
||
fireAddonEvent("INSPECT_READY", {guidBuf});
|
||
}
|
||
};
|
||
// Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[]
|
||
dispatchTable_[Opcode::SMSG_MULTIPLE_MOVES] = [this](network::Packet& packet) {
|
||
// Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[]
|
||
handleCompressedMoves(packet);
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_MULTIPLE_PACKETS] = [this](network::Packet& packet) {
|
||
// 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<network::Packet> subPackets;
|
||
while (pos + 4 <= dataLen) {
|
||
uint16_t subSize = static_cast<uint16_t>(
|
||
(static_cast<uint16_t>(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<uint16_t>(pdata[pos + 2]) |
|
||
(static_cast<uint16_t>(pdata[pos + 3]) << 8);
|
||
std::vector<uint8_t> 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());
|
||
};
|
||
// Recruit-A-Friend: a mentor is offering to grant you a level
|
||
dispatchTable_[Opcode::SMSG_PROPOSE_LEVEL_GRANT] = [this](network::Packet& packet) {
|
||
// 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<Unit*>(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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_REFER_A_FRIEND_EXPIRED] = [this](network::Packet& packet) {
|
||
addSystemChatMessage("Your Recruit-A-Friend link has expired.");
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_REFER_A_FRIEND_FAILURE] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_REPORT_PVP_AFK_RESULT] = [this](network::Packet& packet) {
|
||
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());
|
||
};
|
||
dispatchTable_[Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS] = [this](network::Packet& packet) { handleRespondInspectAchievements(packet); };
|
||
dispatchTable_[Opcode::SMSG_QUEST_POI_QUERY_RESPONSE] = [this](network::Packet& packet) { handleQuestPoiQueryResponse(packet); };
|
||
dispatchTable_[Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA] = [this](network::Packet& packet) {
|
||
vehicleId_ = 0; // Vehicle ride cancelled; clear UI
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played
|
||
dispatchTable_[Opcode::SMSG_PLAY_TIME_WARNING] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
};
|
||
dispatchTable_[Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE] = [this](network::Packet& packet) { handleItemQueryResponse(packet); };
|
||
// 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.
|
||
dispatchTable_[Opcode::SMSG_MIRRORIMAGE_DATA] = [this](network::Packet& packet) {
|
||
// 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) return;
|
||
uint64_t mirrorGuid = packet.readUInt64();
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t displayId = packet.readUInt32();
|
||
if (packet.getSize() - packet.getReadPos() < 3) return;
|
||
/*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<game::Unit>(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());
|
||
};
|
||
// uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds)
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE] = [this](network::Packet& packet) {
|
||
// uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds)
|
||
if (packet.getSize() - packet.getReadPos() < 20) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
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);
|
||
};
|
||
// uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTERED] = [this](network::Packet& packet) {
|
||
// 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=", static_cast<int>(isSafe), " onQueue=", static_cast<int>(onQueue));
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE] = [this](network::Packet& packet) {
|
||
// uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime
|
||
if (packet.getSize() - packet.getReadPos() < 20) {
|
||
packet.setReadPos(packet.getSize()); return;
|
||
}
|
||
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);
|
||
};
|
||
// 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
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
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=", static_cast<int>(accepted),
|
||
" result=", static_cast<int>(result));
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint64 battlefieldGuid + uint8 remove
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING] = [this](network::Packet& packet) {
|
||
// 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=", static_cast<int>(remove));
|
||
}
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECTED] = [this](network::Packet& packet) {
|
||
// 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=", static_cast<int>(relocated));
|
||
}
|
||
bfMgrActive_ = false;
|
||
bfMgrInvitePending_ = false;
|
||
packet.setReadPos(packet.getSize());
|
||
};
|
||
// uint32 oldState + uint32 newState
|
||
// States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown
|
||
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// uint32 numPending — number of unacknowledged calendar invites
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_SEND_NUM_PENDING] = [this](network::Packet& packet) {
|
||
// 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");
|
||
}
|
||
};
|
||
// 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
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_COMMAND_RESULT] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
/*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());
|
||
};
|
||
// Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) +
|
||
// eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) +
|
||
// isGuildEvent(1) + inviterGuid(8)
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
/*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, "'");
|
||
};
|
||
// 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)
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_STATUS] = [this](network::Packet& packet) {
|
||
// 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()); return;
|
||
}
|
||
/*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());
|
||
};
|
||
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED] = [this](network::Packet& packet) {
|
||
// 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());
|
||
};
|
||
// uint32 unixTime — server's current unix timestamp; use to sync gameTime_
|
||
dispatchTable_[Opcode::SMSG_SERVERTIME] = [this](network::Packet& packet) {
|
||
// 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<float>(srvTime);
|
||
LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime);
|
||
}
|
||
}
|
||
};
|
||
// uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string
|
||
// kickReasonType: 0=other, 1=afk, 2=vote kick
|
||
dispatchTable_[Opcode::SMSG_KICK_REASON] = [this](network::Packet& packet) {
|
||
// uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string
|
||
// kickReasonType: 0=other, 1=afk, 2=vote kick
|
||
if (!packetHasRemaining(packet, 12)) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
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, "'");
|
||
};
|
||
// uint32 throttleMs — rate-limited group action; notify the player
|
||
dispatchTable_[Opcode::SMSG_GROUPACTION_THROTTLED] = [this](network::Packet& packet) {
|
||
// 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);
|
||
}
|
||
};
|
||
// WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count
|
||
// per count: string responseText
|
||
dispatchTable_[Opcode::SMSG_GMRESPONSE_RECEIVED] = [this](network::Packet& packet) {
|
||
// WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count
|
||
// per count: string responseText
|
||
if (!packetHasRemaining(packet, 4)) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
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, "'");
|
||
};
|
||
// uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help)
|
||
dispatchTable_[Opcode::SMSG_GMRESPONSE_STATUS_UPDATE] = [this](network::Packet& packet) {
|
||
// 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<int>(status));
|
||
}
|
||
};
|
||
// GM ticket status (new/updated); no ticket UI yet
|
||
dispatchTable_[Opcode::SMSG_GM_TICKET_STATUS_UPDATE] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// Client uses this outbound; treat inbound variant as no-op for robustness.
|
||
dispatchTable_[Opcode::MSG_MOVE_WORLDPORT_ACK] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// Observed custom server packet (8 bytes). Safe-consume for now.
|
||
dispatchTable_[Opcode::MSG_MOVE_TIME_SKIPPED] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// loggingOut_ already cleared by cancelLogout(); this is server's confirmation
|
||
dispatchTable_[Opcode::SMSG_LOGOUT_CANCEL_ACK] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// These packets are not damage-shield events. Consume them without
|
||
// synthesizing reflected damage entries or misattributing GUIDs.
|
||
dispatchTable_[Opcode::SMSG_AURACASTLOG] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// These packets are not damage-shield events. Consume them without
|
||
// synthesizing reflected damage entries or misattributing GUIDs.
|
||
dispatchTable_[Opcode::SMSG_SPELLBREAKLOG] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// Consume silently — informational, no UI action needed
|
||
dispatchTable_[Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// Consume silently — informational, no UI action needed
|
||
dispatchTable_[Opcode::SMSG_LOOT_LIST] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// Same format as LOCKOUT_ADDED; consume
|
||
dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); };
|
||
// Consume — remaining server notifications not yet parsed
|
||
for (auto op : {
|
||
Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE,
|
||
Opcode::SMSG_AUCTION_LIST_PENDING_SALES,
|
||
Opcode::SMSG_AVAILABLE_VOICE_CHANNEL,
|
||
Opcode::SMSG_CALENDAR_ARENA_TEAM,
|
||
Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION,
|
||
Opcode::SMSG_CALENDAR_EVENT_INVITE,
|
||
Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES,
|
||
Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT,
|
||
Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED,
|
||
Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT,
|
||
Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT,
|
||
Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT,
|
||
Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT,
|
||
Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT,
|
||
Opcode::SMSG_CALENDAR_FILTER_GUILD,
|
||
Opcode::SMSG_CALENDAR_SEND_CALENDAR,
|
||
Opcode::SMSG_CALENDAR_SEND_EVENT,
|
||
Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE,
|
||
Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE,
|
||
Opcode::SMSG_CHEAT_PLAYER_LOOKUP,
|
||
Opcode::SMSG_CHECK_FOR_BOTS,
|
||
Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO,
|
||
Opcode::SMSG_COMMENTATOR_MAP_INFO,
|
||
Opcode::SMSG_COMMENTATOR_PLAYER_INFO,
|
||
Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1,
|
||
Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2,
|
||
Opcode::SMSG_COMMENTATOR_STATE_CHANGED,
|
||
Opcode::SMSG_COOLDOWN_CHEAT,
|
||
Opcode::SMSG_DANCE_QUERY_RESPONSE,
|
||
Opcode::SMSG_DBLOOKUP,
|
||
Opcode::SMSG_DEBUGAURAPROC,
|
||
Opcode::SMSG_DEBUG_AISTATE,
|
||
Opcode::SMSG_DEBUG_LIST_TARGETS,
|
||
Opcode::SMSG_DEBUG_SERVER_GEO,
|
||
Opcode::SMSG_DUMP_OBJECTS_DATA,
|
||
Opcode::SMSG_FORCEACTIONSHOW,
|
||
Opcode::SMSG_GM_PLAYER_INFO,
|
||
Opcode::SMSG_GODMODE,
|
||
Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT,
|
||
Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT,
|
||
Opcode::SMSG_INVALIDATE_DANCE,
|
||
Opcode::SMSG_LFG_PENDING_INVITE,
|
||
Opcode::SMSG_LFG_PENDING_MATCH,
|
||
Opcode::SMSG_LFG_PENDING_MATCH_DONE,
|
||
Opcode::SMSG_LFG_UPDATE,
|
||
Opcode::SMSG_LFG_UPDATE_LFG,
|
||
Opcode::SMSG_LFG_UPDATE_LFM,
|
||
Opcode::SMSG_LFG_UPDATE_QUEUED,
|
||
Opcode::SMSG_MOVE_CHARACTER_CHEAT,
|
||
Opcode::SMSG_NOTIFY_DANCE,
|
||
Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST,
|
||
Opcode::SMSG_PETGODMODE,
|
||
Opcode::SMSG_PET_UPDATE_COMBO_POINTS,
|
||
Opcode::SMSG_PLAYER_SKINNED,
|
||
Opcode::SMSG_PLAY_DANCE,
|
||
Opcode::SMSG_PROFILEDATA_RESPONSE,
|
||
Opcode::SMSG_PVP_QUEUE_STATS,
|
||
Opcode::SMSG_QUERY_OBJECT_POSITION,
|
||
Opcode::SMSG_QUERY_OBJECT_ROTATION,
|
||
Opcode::SMSG_REDIRECT_CLIENT,
|
||
Opcode::SMSG_RESET_RANGED_COMBAT_TIMER,
|
||
Opcode::SMSG_SEND_ALL_COMBAT_LOG,
|
||
Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE,
|
||
Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT,
|
||
Opcode::SMSG_SET_PROJECTILE_POSITION,
|
||
Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK,
|
||
Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS,
|
||
Opcode::SMSG_STOP_DANCE,
|
||
Opcode::SMSG_TEST_DROP_RATE_RESULT,
|
||
Opcode::SMSG_UPDATE_ACCOUNT_DATA,
|
||
Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE,
|
||
Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP,
|
||
Opcode::SMSG_UPDATE_LAST_INSTANCE,
|
||
Opcode::SMSG_VOICESESSION_FULL,
|
||
Opcode::SMSG_VOICE_CHAT_STATUS,
|
||
Opcode::SMSG_VOICE_PARENTAL_CONTROLS,
|
||
Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY,
|
||
Opcode::SMSG_VOICE_SESSION_ENABLE,
|
||
Opcode::SMSG_VOICE_SESSION_LEAVE,
|
||
Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE,
|
||
Opcode::SMSG_VOICE_SET_TALKER_MUTED
|
||
}) { dispatchTable_[op] = [this](network::Packet& packet) { packet.setReadPos(packet.getSize()); }; }
|
||
}
|
||
|
||
void GameHandler::handlePacket(network::Packet& packet) {
|
||
if (packet.getSize() < 1) {
|
||
LOG_DEBUG("Received empty world packet (ignored)");
|
||
return;
|
||
}
|
||
|
||
uint16_t opcode = packet.getOpcode();
|
||
|
||
try {
|
||
|
||
const bool allowVanillaAliases = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
||
|
||
// Vanilla compatibility aliases:
|
||
// - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers
|
||
// and SMSG_WEATHER on others
|
||
// - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers)
|
||
//
|
||
// We gate these by payload shape so expansion-native mappings remain intact.
|
||
if (allowVanillaAliases && opcode == 0x006B) {
|
||
// Try compressed movement batch first:
|
||
// [u8 subSize][u16 subOpcode][subPayload...] ...
|
||
// where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT.
|
||
const auto& data = packet.getData();
|
||
if (packet.getReadPos() + 3 <= data.size()) {
|
||
size_t pos = packet.getReadPos();
|
||
uint8_t subSize = data[pos];
|
||
if (subSize >= 2 && pos + 1 + subSize <= data.size()) {
|
||
uint16_t subOpcode = static_cast<uint16_t>(data[pos + 1]) |
|
||
(static_cast<uint16_t>(data[pos + 2]) << 8);
|
||
uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE);
|
||
uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT);
|
||
if ((monsterMoveWire != 0xFFFF && subOpcode == monsterMoveWire) ||
|
||
(monsterMoveTransportWire != 0xFFFF && subOpcode == monsterMoveTransportWire)) {
|
||
LOG_INFO("Opcode 0x006B interpreted as SMSG_COMPRESSED_MOVES (subOpcode=0x",
|
||
std::hex, subOpcode, std::dec, ")");
|
||
handleCompressedMoves(packet);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Expected weather payload: uint32 weatherType, float intensity, uint8 abrupt
|
||
if (packet.getSize() - packet.getReadPos() >= 9) {
|
||
uint32_t wType = packet.readUInt32();
|
||
float wIntensity = packet.readFloat();
|
||
uint8_t abrupt = packet.readUInt8();
|
||
bool plausibleWeather =
|
||
(wType <= 3) &&
|
||
std::isfinite(wIntensity) &&
|
||
(wIntensity >= 0.0f && wIntensity <= 1.5f) &&
|
||
(abrupt <= 1);
|
||
if (plausibleWeather) {
|
||
weatherType_ = wType;
|
||
weatherIntensity_ = wIntensity;
|
||
const char* typeName =
|
||
(wType == 1) ? "Rain" :
|
||
(wType == 2) ? "Snow" :
|
||
(wType == 3) ? "Storm" : "Clear";
|
||
LOG_INFO("Weather changed (0x006B alias): type=", wType,
|
||
" (", typeName, "), intensity=", wIntensity,
|
||
", abrupt=", static_cast<int>(abrupt));
|
||
return;
|
||
}
|
||
// Not weather-shaped: rewind and fall through to normal opcode table handling.
|
||
packet.setReadPos(0);
|
||
}
|
||
} else if (allowVanillaAliases && opcode == 0x0103) {
|
||
// Expected play-music payload: uint32 sound/music id
|
||
if (packet.getSize() - packet.getReadPos() == 4) {
|
||
uint32_t soundId = packet.readUInt32();
|
||
LOG_INFO("SMSG_PLAY_MUSIC (0x0103 alias): soundId=", soundId);
|
||
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<int>(opType),
|
||
" result=", static_cast<int>(resultCode),
|
||
" slot/count=", static_cast<int>(slotOrCount),
|
||
" itemId=", itemId,
|
||
" pendingBuybackSlot=", pendingBuybackSlot_,
|
||
" pendingBuyItemId=", pendingBuyItemId_,
|
||
" pendingBuyItemSlot=", pendingBuyItemSlot_);
|
||
|
||
if (pendingBuybackSlot_ >= 0) {
|
||
if (resultCode == 0) {
|
||
// Success: remove the bought-back slot from our local UI cache.
|
||
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
|
||
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
|
||
}
|
||
} else {
|
||
const char* msg = "Buyback failed.";
|
||
// Best-effort mapping; keep raw code visible for unknowns.
|
||
switch (resultCode) {
|
||
case 2: msg = "Buyback failed: not enough money."; break;
|
||
case 4: msg = "Buyback failed: vendor too far away."; break;
|
||
case 5: msg = "Buyback failed: item unavailable."; break;
|
||
case 6: msg = "Buyback failed: inventory full."; break;
|
||
case 8: msg = "Buyback failed: requirements not met."; break;
|
||
default: break;
|
||
}
|
||
addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")");
|
||
}
|
||
pendingBuybackSlot_ = -1;
|
||
pendingBuybackWireSlot_ = 0;
|
||
|
||
// Refresh vendor list so UI state stays in sync after buyback result.
|
||
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
|
||
auto pkt = ListInventoryPacket::build(currentVendorItems.vendorGuid);
|
||
socket->send(pkt);
|
||
}
|
||
} else if (pendingBuyItemId_ != 0) {
|
||
if (resultCode != 0) {
|
||
const char* msg = "Purchase failed.";
|
||
switch (resultCode) {
|
||
case 2: msg = "Purchase failed: not enough money."; break;
|
||
case 4: msg = "Purchase failed: vendor too far away."; break;
|
||
case 5: msg = "Purchase failed: item sold out."; break;
|
||
case 6: msg = "Purchase failed: inventory full."; break;
|
||
case 8: msg = "Purchase failed: requirements not met."; break;
|
||
default: break;
|
||
}
|
||
addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")");
|
||
}
|
||
pendingBuyItemId_ = 0;
|
||
pendingBuyItemSlot_ = 0;
|
||
}
|
||
return;
|
||
}
|
||
} else if (opcode == 0x046A) {
|
||
// Server-specific vendor/buyback state packet (observed 25-byte records).
|
||
// Consume to keep stream aligned; currently not used for gameplay logic.
|
||
if (packet.getSize() - packet.getReadPos() >= 25) {
|
||
packet.setReadPos(packet.getReadPos() + 25);
|
||
return;
|
||
}
|
||
}
|
||
|
||
auto preLogicalOp = opcodeTable_.fromWire(opcode);
|
||
if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) {
|
||
++wardenPacketsAfterGate_;
|
||
}
|
||
if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) {
|
||
LOG_DEBUG("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec,
|
||
" state=", worldStateName(state),
|
||
" size=", packet.getSize());
|
||
}
|
||
|
||
LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec,
|
||
" size=", packet.getSize(), " bytes");
|
||
|
||
// Translate wire opcode to logical opcode via expansion table
|
||
auto logicalOp = opcodeTable_.fromWire(opcode);
|
||
|
||
if (!logicalOp) {
|
||
static std::unordered_set<uint16_t> loggedUnknownWireOpcodes;
|
||
if (loggedUnknownWireOpcodes.insert(opcode).second) {
|
||
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
|
||
" state=", static_cast<int>(state),
|
||
" size=", packet.getSize());
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Dispatch via the opcode handler table
|
||
auto it = dispatchTable_.find(*logicalOp);
|
||
if (it != dispatchTable_.end()) {
|
||
it->second(packet);
|
||
} else {
|
||
// In pre-world states we need full visibility (char create/login handshakes).
|
||
// In-world we keep de-duplication to avoid heavy log I/O in busy areas.
|
||
if (state != WorldState::IN_WORLD) {
|
||
static std::unordered_set<uint32_t> loggedUnhandledByState;
|
||
const uint32_t key = (static_cast<uint32_t>(static_cast<uint8_t>(state)) << 16) |
|
||
static_cast<uint32_t>(opcode);
|
||
if (loggedUnhandledByState.insert(key).second) {
|
||
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
|
||
" state=", static_cast<int>(state),
|
||
" size=", packet.getSize());
|
||
const auto& data = packet.getData();
|
||
std::string hex;
|
||
size_t limit = std::min<size_t>(data.size(), 48);
|
||
hex.reserve(limit * 3);
|
||
for (size_t i = 0; i < limit; ++i) {
|
||
char b[4];
|
||
snprintf(b, sizeof(b), "%02x ", data[i]);
|
||
hex += b;
|
||
}
|
||
LOG_INFO("Unhandled opcode payload hex (first ", limit, " bytes): ", hex);
|
||
}
|
||
} else {
|
||
static std::unordered_set<uint16_t> loggedUnhandledOpcodes;
|
||
if (loggedUnhandledOpcodes.insert(static_cast<uint16_t>(opcode)).second) {
|
||
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec);
|
||
}
|
||
}
|
||
}
|
||
} 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<float, std::milli>(
|
||
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<float, std::milli>(
|
||
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<float, std::milli>(
|
||
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<float, std::milli>(
|
||
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<int>(block.updateType),
|
||
" guid=0x", std::hex, block.guid, std::dec,
|
||
" objectType=", static_cast<int>(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<float, std::milli>(
|
||
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<float, std::milli>(
|
||
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 ", static_cast<int>(character.level));
|
||
}
|
||
}
|
||
|
||
LOG_INFO("Ready to select character");
|
||
}
|
||
|
||
void GameHandler::createCharacter(const CharCreateData& data) {
|
||
|
||
// Online mode: send packet to server
|
||
if (!socket) {
|
||
LOG_WARNING("Cannot create character: not connected");
|
||
if (charCreateCallback_) {
|
||
charCreateCallback_(false, "Not connected to server");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (requiresWarden_) {
|
||
std::string msg = "Server requires anti-cheat/Warden; character creation blocked.";
|
||
LOG_WARNING("Blocking CMSG_CHAR_CREATE while Warden gate is active");
|
||
if (charCreateCallback_) {
|
||
charCreateCallback_(false, msg);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (state != WorldState::CHAR_LIST_RECEIVED) {
|
||
std::string msg = "Character list not ready yet. Wait for SMSG_CHAR_ENUM.";
|
||
LOG_WARNING("Blocking CMSG_CHAR_CREATE in state=", worldStateName(state),
|
||
" (awaiting CHAR_LIST_RECEIVED)");
|
||
if (charCreateCallback_) {
|
||
charCreateCallback_(false, msg);
|
||
}
|
||
return;
|
||
}
|
||
|
||
auto packet = CharCreatePacket::build(data);
|
||
socket->send(packet);
|
||
LOG_INFO("CMSG_CHAR_CREATE sent for: ", data.name);
|
||
}
|
||
|
||
void GameHandler::handleCharCreateResponse(network::Packet& packet) {
|
||
CharCreateResponseData data;
|
||
if (!CharCreateResponseParser::parse(packet, data)) {
|
||
LOG_ERROR("Failed to parse SMSG_CHAR_CREATE");
|
||
return;
|
||
}
|
||
|
||
if (data.result == CharCreateResult::SUCCESS || data.result == CharCreateResult::IN_PROGRESS) {
|
||
LOG_INFO("Character created successfully (code=", static_cast<int>(data.result), ")");
|
||
requestCharacterList();
|
||
if (charCreateCallback_) {
|
||
charCreateCallback_(true, "Character created!");
|
||
}
|
||
} else {
|
||
std::string msg;
|
||
switch (data.result) {
|
||
case CharCreateResult::CHAR_ERROR: msg = "Server error"; break;
|
||
case CharCreateResult::FAILED: msg = "Creation failed"; break;
|
||
case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break;
|
||
case CharCreateResult::DISABLED: msg = "Character creation disabled"; break;
|
||
case CharCreateResult::PVP_TEAMS_VIOLATION: msg = "PvP faction violation"; break;
|
||
case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break;
|
||
case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break;
|
||
case CharCreateResult::SERVER_QUEUE: msg = "Server is queued"; break;
|
||
case CharCreateResult::ONLY_EXISTING: msg = "Only existing characters allowed"; break;
|
||
case CharCreateResult::EXPANSION: msg = "Expansion required"; break;
|
||
case CharCreateResult::EXPANSION_CLASS: msg = "Expansion required for this class"; break;
|
||
case CharCreateResult::LEVEL_REQUIREMENT: msg = "Level requirement not met"; break;
|
||
case CharCreateResult::UNIQUE_CLASS_LIMIT: msg = "Unique class limit reached"; break;
|
||
case CharCreateResult::RESTRICTED_RACECLASS: msg = "Race/class combination not allowed"; break;
|
||
case CharCreateResult::IN_PROGRESS: msg = "Character creation in progress..."; break;
|
||
case CharCreateResult::CHARACTER_CHOOSE_RACE: msg = "Please choose a different race"; break;
|
||
case CharCreateResult::CHARACTER_ARENA_LEADER: msg = "Arena team leader restriction"; break;
|
||
case CharCreateResult::CHARACTER_DELETE_MAIL: msg = "Character has mail"; break;
|
||
case CharCreateResult::CHARACTER_SWAP_FACTION: msg = "Faction swap restriction"; break;
|
||
case CharCreateResult::CHARACTER_RACE_ONLY: msg = "Race-only restriction"; break;
|
||
case CharCreateResult::CHARACTER_GOLD_LIMIT: msg = "Gold limit reached"; break;
|
||
case CharCreateResult::FORCE_LOGIN: msg = "Force login required"; break;
|
||
case CharCreateResult::CHARACTER_IN_GUILD: msg = "Character is in a guild"; break;
|
||
// Name validation errors
|
||
case CharCreateResult::NAME_FAILURE: msg = "Invalid name"; break;
|
||
case CharCreateResult::NAME_NO_NAME: msg = "Please enter a name"; break;
|
||
case CharCreateResult::NAME_TOO_SHORT: msg = "Name is too short"; break;
|
||
case CharCreateResult::NAME_TOO_LONG: msg = "Name is too long"; break;
|
||
case CharCreateResult::NAME_INVALID_CHARACTER: msg = "Name contains invalid characters"; break;
|
||
case CharCreateResult::NAME_MIXED_LANGUAGES: msg = "Name mixes languages"; break;
|
||
case CharCreateResult::NAME_PROFANE: msg = "Name contains profanity"; break;
|
||
case CharCreateResult::NAME_RESERVED: msg = "Name is reserved"; break;
|
||
case CharCreateResult::NAME_INVALID_APOSTROPHE: msg = "Invalid apostrophe in name"; break;
|
||
case CharCreateResult::NAME_MULTIPLE_APOSTROPHES: msg = "Name has multiple apostrophes"; break;
|
||
case CharCreateResult::NAME_THREE_CONSECUTIVE: msg = "Name has 3+ consecutive same letters"; break;
|
||
case CharCreateResult::NAME_INVALID_SPACE: msg = "Invalid space in name"; break;
|
||
case CharCreateResult::NAME_CONSECUTIVE_SPACES: msg = "Name has consecutive spaces"; break;
|
||
default: msg = "Unknown error (code " + std::to_string(static_cast<int>(data.result)) + ")"; break;
|
||
}
|
||
LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast<int>(data.result), ")");
|
||
if (charCreateCallback_) {
|
||
charCreateCallback_(false, msg);
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameHandler::deleteCharacter(uint64_t characterGuid) {
|
||
if (!socket) {
|
||
if (charDeleteCallback_) charDeleteCallback_(false);
|
||
return;
|
||
}
|
||
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE));
|
||
packet.writeUInt64(characterGuid);
|
||
socket->send(packet);
|
||
LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec);
|
||
}
|
||
|
||
const Character* GameHandler::getActiveCharacter() const {
|
||
if (activeCharacterGuid_ == 0) return nullptr;
|
||
for (const auto& ch : characters) {
|
||
if (ch.guid == activeCharacterGuid_) return &ch;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const Character* GameHandler::getFirstCharacter() const {
|
||
if (characters.empty()) return nullptr;
|
||
return &characters.front();
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
void GameHandler::handleCharLoginFailed(network::Packet& packet) {
|
||
uint8_t reason = packet.readUInt8();
|
||
|
||
static const char* reasonNames[] = {
|
||
"Login failed", // 0
|
||
"World server is down", // 1
|
||
"Duplicate character", // 2 (session still active)
|
||
"No instance servers", // 3
|
||
"Login disabled", // 4
|
||
"Character not found", // 5
|
||
"Locked for transfer", // 6
|
||
"Locked by billing", // 7
|
||
"Using remote", // 8
|
||
};
|
||
const char* msg = (reason < 9) ? reasonNames[reason] : "Unknown reason";
|
||
|
||
LOG_ERROR("SMSG_CHARACTER_LOGIN_FAILED: reason=", static_cast<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: ", static_cast<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 ", static_cast<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<float>(gameTimePacked);
|
||
timeSpeed_ = timeScale;
|
||
|
||
LOG_INFO("Server time: gameTime=", gameTime_, "s, timeSpeed=", timeSpeed_);
|
||
LOG_INFO(" (1 game day = ", (1.0f / timeSpeed_) / 60.0f, " real minutes)");
|
||
}
|
||
|
||
void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
||
LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD");
|
||
const bool initialWorldEntry = (state == WorldState::ENTERING_WORLD);
|
||
|
||
LoginVerifyWorldData data;
|
||
if (!LoginVerifyWorldParser::parse(packet, data)) {
|
||
fail("Failed to parse SMSG_LOGIN_VERIFY_WORLD");
|
||
return;
|
||
}
|
||
|
||
if (!data.isValid()) {
|
||
fail("Invalid world entry data");
|
||
return;
|
||
}
|
||
|
||
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");
|
||
}
|
||
}
|
||
|
||
// Fire PLAYER_ENTERING_WORLD — THE most important event for addon initialization.
|
||
// Fires on initial login, teleports, instance transitions, and zone changes.
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("PLAYER_ENTERING_WORLD", {initialWorldEntry ? "1" : "0"});
|
||
// Also fire ZONE_CHANGED_NEW_AREA and UPDATE_WORLD_STATES so map/BG addons refresh
|
||
fireAddonEvent("ZONE_CHANGED_NEW_AREA", {});
|
||
fireAddonEvent("UPDATE_WORLD_STATES", {});
|
||
// PLAYER_LOGIN fires only on initial login (not teleports)
|
||
if (initialWorldEntry) {
|
||
fireAddonEvent("PLAYER_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<uint32_t, 8> flags{};
|
||
for (uint32_t& v : flags) {
|
||
v = packet.readUInt32();
|
||
}
|
||
|
||
LOG_INFO("SMSG_TUTORIAL_FLAGS: [",
|
||
flags[0], ", ", flags[1], ", ", flags[2], ", ", flags[3], ", ",
|
||
flags[4], ", ", flags[5], ", ", flags[6], ", ", flags[7], "]");
|
||
}
|
||
|
||
bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) {
|
||
wardenCREntries_.clear();
|
||
|
||
// Look for .cr file in warden cache
|
||
std::string cacheBase;
|
||
#ifdef _WIN32
|
||
if (const char* h = std::getenv("APPDATA")) cacheBase = std::string(h) + "\\wowee\\warden_cache";
|
||
else cacheBase = ".\\warden_cache";
|
||
#else
|
||
if (const char* h = std::getenv("HOME")) cacheBase = std::string(h) + "/.local/share/wowee/warden_cache";
|
||
else cacheBase = "./warden_cache";
|
||
#endif
|
||
std::string crPath = cacheBase + "/" + moduleHashHex + ".cr";
|
||
|
||
std::ifstream crFile(crPath, std::ios::binary);
|
||
if (!crFile) {
|
||
LOG_WARNING("Warden: No .cr file found at ", crPath);
|
||
return false;
|
||
}
|
||
|
||
// Get file size
|
||
crFile.seekg(0, std::ios::end);
|
||
auto fileSize = crFile.tellg();
|
||
crFile.seekg(0, std::ios::beg);
|
||
|
||
// Header: [4 memoryRead][4 pageScanCheck][9 opcodes] = 17 bytes
|
||
constexpr size_t CR_HEADER_SIZE = 17;
|
||
constexpr size_t CR_ENTRY_SIZE = 68; // seed[16]+reply[20]+clientKey[16]+serverKey[16]
|
||
|
||
if (static_cast<size_t>(fileSize) < CR_HEADER_SIZE) {
|
||
LOG_ERROR("Warden: .cr file too small (", fileSize, " bytes)");
|
||
return false;
|
||
}
|
||
|
||
// Read header: [4 memoryRead][4 pageScanCheck][9 opcodes]
|
||
crFile.seekg(8); // skip memoryRead + pageScanCheck
|
||
crFile.read(reinterpret_cast<char*>(wardenCheckOpcodes_), 9);
|
||
{
|
||
std::string opcHex;
|
||
// CMaNGOS WindowsScanType order:
|
||
// 0 READ_MEMORY, 1 FIND_MODULE_BY_NAME, 2 FIND_MEM_IMAGE_CODE_BY_HASH,
|
||
// 3 FIND_CODE_BY_HASH, 4 HASH_CLIENT_FILE, 5 GET_LUA_VARIABLE,
|
||
// 6 API_CHECK, 7 FIND_DRIVER_BY_NAME, 8 CHECK_TIMING_VALUES
|
||
const char* names[] = {"MEM","MODULE","PAGE_A","PAGE_B","MPQ","LUA","PROC","DRIVER","TIMING"};
|
||
for (int i = 0; i < 9; i++) {
|
||
char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s;
|
||
}
|
||
LOG_WARNING("Warden: Check opcodes: ", opcHex);
|
||
}
|
||
|
||
size_t entryCount = (static_cast<size_t>(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE;
|
||
if (entryCount == 0) {
|
||
LOG_ERROR("Warden: .cr file has no entries");
|
||
return false;
|
||
}
|
||
|
||
wardenCREntries_.resize(entryCount);
|
||
for (size_t i = 0; i < entryCount; i++) {
|
||
auto& e = wardenCREntries_[i];
|
||
crFile.read(reinterpret_cast<char*>(e.seed), 16);
|
||
crFile.read(reinterpret_cast<char*>(e.reply), 20);
|
||
crFile.read(reinterpret_cast<char*>(e.clientKey), 16);
|
||
crFile.read(reinterpret_cast<char*>(e.serverKey), 16);
|
||
}
|
||
|
||
LOG_INFO("Warden: Loaded ", entryCount, " CR entries from ", crPath);
|
||
return true;
|
||
}
|
||
|
||
void GameHandler::handleWardenData(network::Packet& packet) {
|
||
const auto& data = packet.getData();
|
||
if (!wardenGateSeen_) {
|
||
wardenGateSeen_ = true;
|
||
wardenGateElapsed_ = 0.0f;
|
||
wardenGateNextStatusLog_ = 2.0f;
|
||
wardenPacketsAfterGate_ = 0;
|
||
}
|
||
|
||
// Initialize Warden crypto from session key on first packet
|
||
if (!wardenCrypto_) {
|
||
wardenCrypto_ = std::make_unique<WardenCrypto>();
|
||
if (sessionKey.size() != 40) {
|
||
LOG_ERROR("Warden: No valid session key (size=", sessionKey.size(), "), cannot init crypto");
|
||
wardenCrypto_.reset();
|
||
return;
|
||
}
|
||
if (!wardenCrypto_->initFromSessionKey(sessionKey)) {
|
||
LOG_ERROR("Warden: Failed to initialize crypto from session key");
|
||
wardenCrypto_.reset();
|
||
return;
|
||
}
|
||
wardenState_ = WardenState::WAIT_MODULE_USE;
|
||
}
|
||
|
||
// Decrypt the payload
|
||
std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data);
|
||
|
||
// Avoid expensive hex formatting when DEBUG logs are disabled.
|
||
if (core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG)) {
|
||
std::string hex;
|
||
size_t logSize = std::min(decrypted.size(), size_t(256));
|
||
hex.reserve(logSize * 3);
|
||
for (size_t i = 0; i < logSize; ++i) {
|
||
char b[4];
|
||
snprintf(b, sizeof(b), "%02x ", decrypted[i]);
|
||
hex += b;
|
||
}
|
||
if (decrypted.size() > 64) {
|
||
hex += "... (" + std::to_string(decrypted.size() - 64) + " more)";
|
||
}
|
||
LOG_DEBUG("Warden: Decrypted (", decrypted.size(), " bytes): ", hex);
|
||
}
|
||
|
||
if (decrypted.empty()) {
|
||
LOG_WARNING("Warden: Empty decrypted payload");
|
||
return;
|
||
}
|
||
|
||
uint8_t wardenOpcode = decrypted[0];
|
||
|
||
// Helper to send an encrypted Warden response
|
||
auto sendWardenResponse = [&](const std::vector<uint8_t>& plaintext) {
|
||
std::vector<uint8_t> encrypted = wardenCrypto_->encrypt(plaintext);
|
||
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
|
||
for (uint8_t byte : encrypted) {
|
||
response.writeUInt8(byte);
|
||
}
|
||
if (socket && socket->isConnected()) {
|
||
socket->send(response);
|
||
LOG_DEBUG("Warden: Sent response (", plaintext.size(), " bytes plaintext)");
|
||
}
|
||
};
|
||
|
||
switch (wardenOpcode) {
|
||
case 0x00: { // WARDEN_SMSG_MODULE_USE
|
||
// Format: [1 opcode][16 moduleHash][16 moduleKey][4 moduleSize]
|
||
if (decrypted.size() < 37) {
|
||
LOG_ERROR("Warden: MODULE_USE too short (", decrypted.size(), " bytes, need 37)");
|
||
return;
|
||
}
|
||
|
||
wardenModuleHash_.assign(decrypted.begin() + 1, decrypted.begin() + 17);
|
||
wardenModuleKey_.assign(decrypted.begin() + 17, decrypted.begin() + 33);
|
||
wardenModuleSize_ = static_cast<uint32_t>(decrypted[33])
|
||
| (static_cast<uint32_t>(decrypted[34]) << 8)
|
||
| (static_cast<uint32_t>(decrypted[35]) << 16)
|
||
| (static_cast<uint32_t>(decrypted[36]) << 24);
|
||
wardenModuleData_.clear();
|
||
|
||
{
|
||
std::string hashHex;
|
||
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
|
||
LOG_DEBUG("Warden: MODULE_USE hash=", hashHex, " size=", wardenModuleSize_);
|
||
|
||
// Try to load pre-computed challenge/response entries
|
||
loadWardenCRFile(hashHex);
|
||
}
|
||
|
||
// Respond with MODULE_MISSING (opcode 0x00) to request the module data
|
||
std::vector<uint8_t> resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING
|
||
sendWardenResponse(resp);
|
||
wardenState_ = WardenState::WAIT_MODULE_CACHE;
|
||
LOG_DEBUG("Warden: Sent MODULE_MISSING, waiting for module data chunks");
|
||
break;
|
||
}
|
||
|
||
case 0x01: { // WARDEN_SMSG_MODULE_CACHE (module data chunk)
|
||
// Format: [1 opcode][2 chunkSize LE][chunkSize bytes data]
|
||
if (decrypted.size() < 3) {
|
||
LOG_ERROR("Warden: MODULE_CACHE too short");
|
||
return;
|
||
}
|
||
|
||
uint16_t chunkSize = static_cast<uint16_t>(decrypted[1])
|
||
| (static_cast<uint16_t>(decrypted[2]) << 8);
|
||
|
||
if (decrypted.size() < 3u + chunkSize) {
|
||
LOG_ERROR("Warden: MODULE_CACHE chunk truncated (claimed ", chunkSize,
|
||
", have ", decrypted.size() - 3, ")");
|
||
return;
|
||
}
|
||
|
||
wardenModuleData_.insert(wardenModuleData_.end(),
|
||
decrypted.begin() + 3,
|
||
decrypted.begin() + 3 + chunkSize);
|
||
|
||
LOG_DEBUG("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ",
|
||
wardenModuleData_.size(), "/", wardenModuleSize_);
|
||
|
||
// Check if module download is complete
|
||
if (wardenModuleData_.size() >= wardenModuleSize_) {
|
||
LOG_INFO("Warden: Module download complete (",
|
||
wardenModuleData_.size(), " bytes)");
|
||
wardenState_ = WardenState::WAIT_HASH_REQUEST;
|
||
|
||
// Cache raw module to disk
|
||
{
|
||
#ifdef _WIN32
|
||
std::string cacheDir;
|
||
if (const char* h = std::getenv("APPDATA")) cacheDir = std::string(h) + "\\wowee\\warden_cache";
|
||
else cacheDir = ".\\warden_cache";
|
||
#else
|
||
std::string cacheDir;
|
||
if (const char* h = std::getenv("HOME")) cacheDir = std::string(h) + "/.local/share/wowee/warden_cache";
|
||
else cacheDir = "./warden_cache";
|
||
#endif
|
||
std::filesystem::create_directories(cacheDir);
|
||
|
||
std::string hashHex;
|
||
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
|
||
std::string cachePath = cacheDir + "/" + hashHex + ".wdn";
|
||
|
||
std::ofstream wf(cachePath, std::ios::binary);
|
||
if (wf) {
|
||
wf.write(reinterpret_cast<const char*>(wardenModuleData_.data()), wardenModuleData_.size());
|
||
LOG_DEBUG("Warden: Cached module to ", cachePath);
|
||
}
|
||
}
|
||
|
||
// Load the module (decrypt, decompress, parse, relocate)
|
||
wardenLoadedModule_ = std::make_shared<WardenModule>();
|
||
if (wardenLoadedModule_->load(wardenModuleData_, wardenModuleHash_, wardenModuleKey_)) { // codeql[cpp/weak-cryptographic-algorithm]
|
||
LOG_INFO("Warden: Module loaded successfully (image size=",
|
||
wardenLoadedModule_->getModuleSize(), " bytes)");
|
||
} else {
|
||
LOG_ERROR("Warden: Module loading FAILED");
|
||
wardenLoadedModule_.reset();
|
||
}
|
||
|
||
// Send MODULE_OK (opcode 0x01)
|
||
std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK
|
||
sendWardenResponse(resp);
|
||
LOG_DEBUG("Warden: Sent MODULE_OK");
|
||
}
|
||
// No response for intermediate chunks
|
||
break;
|
||
}
|
||
|
||
case 0x05: { // WARDEN_SMSG_HASH_REQUEST
|
||
// Format: [1 opcode][16 seed]
|
||
if (decrypted.size() < 17) {
|
||
LOG_ERROR("Warden: HASH_REQUEST too short (", decrypted.size(), " bytes, need 17)");
|
||
return;
|
||
}
|
||
|
||
std::vector<uint8_t> seed(decrypted.begin() + 1, decrypted.begin() + 17);
|
||
auto applyWardenSeedRekey = [&](const std::vector<uint8_t>& rekeySeed) {
|
||
// Derive new RC4 keys from the seed using SHA1Randx.
|
||
uint8_t newEncryptKey[16], newDecryptKey[16];
|
||
WardenCrypto::sha1RandxGenerate(rekeySeed, newEncryptKey, newDecryptKey);
|
||
|
||
std::vector<uint8_t> ek(newEncryptKey, newEncryptKey + 16);
|
||
std::vector<uint8_t> dk(newDecryptKey, newDecryptKey + 16);
|
||
wardenCrypto_->replaceKeys(ek, dk);
|
||
for (auto& b : newEncryptKey) b = 0;
|
||
for (auto& b : newDecryptKey) b = 0;
|
||
LOG_DEBUG("Warden: Derived and applied key update from seed");
|
||
};
|
||
|
||
// --- 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<uint8_t> resp;
|
||
resp.push_back(0x04);
|
||
resp.insert(resp.end(), match->reply, match->reply + 20);
|
||
sendWardenResponse(resp);
|
||
|
||
// Switch to new RC4 keys from the CR entry
|
||
// clientKey = encrypt (client→server), serverKey = decrypt (server→client)
|
||
std::vector<uint8_t> newEncryptKey(match->clientKey, match->clientKey + 16);
|
||
std::vector<uint8_t> newDecryptKey(match->serverKey, match->serverKey + 16);
|
||
wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey);
|
||
|
||
LOG_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<uint8_t> fallbackReply;
|
||
if (wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) {
|
||
const uint8_t* moduleImage = static_cast<const uint8_t*>(wardenLoadedModule_->getModuleMemory());
|
||
size_t moduleImageSize = wardenLoadedModule_->getModuleSize();
|
||
if (moduleImage && moduleImageSize > 0) {
|
||
std::vector<uint8_t> 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<uint8_t> 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<std::string> strings;
|
||
while (pos < decrypted.size()) {
|
||
uint8_t slen = decrypted[pos++];
|
||
if (slen == 0) break; // end of string table
|
||
if (pos + slen > decrypted.size()) break;
|
||
strings.emplace_back(reinterpret_cast<const char*>(decrypted.data() + pos), slen);
|
||
pos += slen;
|
||
}
|
||
LOG_DEBUG("Warden: String table: ", strings.size(), " entries");
|
||
for (size_t i = 0; i < strings.size(); i++) {
|
||
LOG_DEBUG("Warden: [", i, "] = \"", strings[i], "\"");
|
||
}
|
||
|
||
// XOR byte is the last byte of the packet
|
||
uint8_t xorByte = decrypted.back();
|
||
LOG_DEBUG("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }());
|
||
|
||
// 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<WardenMemory>();
|
||
if (!wardenMemory_->load(static_cast<uint16_t>(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<uint8_t> {
|
||
// 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<uint8_t> 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<uint32_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
resultData.push_back(ticks & 0xFF);
|
||
resultData.push_back((ticks >> 8) & 0xFF);
|
||
resultData.push_back((ticks >> 16) & 0xFF);
|
||
resultData.push_back((ticks >> 24) & 0xFF);
|
||
break;
|
||
}
|
||
case CT_MEM: {
|
||
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=", static_cast<int>(readLen),
|
||
(strIdx ? " module=\"" + moduleName + "\"" : ""));
|
||
if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) {
|
||
uint32_t now = static_cast<uint32_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
wardenMemory_->writeLE32(0xCF0BC8, now - 2000);
|
||
}
|
||
std::vector<uint8_t> 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 < static_cast<int>(readLen); i++) { if (memBuf[i] != 0) { allZero = false; break; } }
|
||
std::string hexDump;
|
||
for (int i = 0; i < static_cast<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<const uint8_t*>(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=", static_cast<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<uint8_t> 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<uint8_t> 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<uint16_t>(resultData.size());
|
||
std::vector<uint8_t> 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<size_t>(oneBasedIndex - 1);
|
||
if (idx >= strings.size()) return std::string();
|
||
return strings[idx];
|
||
};
|
||
auto requestSizes = [&](CheckType ct) {
|
||
switch (ct) {
|
||
case CT_TIMING: return std::vector<size_t>{0};
|
||
case CT_MEM: return std::vector<size_t>{6};
|
||
case CT_PAGE_A: return std::vector<size_t>{24, 29};
|
||
case CT_PAGE_B: return std::vector<size_t>{24, 29};
|
||
case CT_MPQ: return std::vector<size_t>{1};
|
||
case CT_LUA: return std::vector<size_t>{1};
|
||
case CT_DRIVER: return std::vector<size_t>{25};
|
||
case CT_PROC: return std::vector<size_t>{30};
|
||
case CT_MODULE: return std::vector<size_t>{24};
|
||
default: return std::vector<size_t>{};
|
||
}
|
||
};
|
||
std::unordered_map<size_t, bool> parseMemo;
|
||
std::function<bool(size_t)> canParseFrom = [&](size_t checkPos) -> bool {
|
||
if (checkPos == checkEnd) return true;
|
||
if (checkPos > checkEnd) return false;
|
||
auto it = parseMemo.find(checkPos);
|
||
if (it != parseMemo.end()) return it->second;
|
||
|
||
CheckType ct = decodeCheckType(decrypted[checkPos]);
|
||
if (ct == CT_UNKNOWN) {
|
||
parseMemo[checkPos] = false;
|
||
return false;
|
||
}
|
||
|
||
size_t payloadPos = checkPos + 1;
|
||
for (size_t reqSize : requestSizes(ct)) {
|
||
if (payloadPos + reqSize > checkEnd) continue;
|
||
if (canParseFrom(payloadPos + reqSize)) {
|
||
parseMemo[checkPos] = true;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
parseMemo[checkPos] = false;
|
||
return false;
|
||
};
|
||
auto isBoundaryAfter = [&](size_t start, size_t consume) -> bool {
|
||
size_t next = start + consume;
|
||
if (next == checkEnd) return true;
|
||
if (next > checkEnd) return false;
|
||
return decodeCheckType(decrypted[next]) != CT_UNKNOWN;
|
||
};
|
||
|
||
// --- Parse check entries and build response ---
|
||
std::vector<uint8_t> resultData;
|
||
int checkCount = 0;
|
||
|
||
while (pos < checkEnd) {
|
||
CheckType ct = decodeCheckType(decrypted[pos]);
|
||
pos++;
|
||
checkCount++;
|
||
|
||
LOG_DEBUG("Warden: Check #", checkCount, " type=", checkTypeNames[ct],
|
||
" at offset ", pos - 1);
|
||
|
||
switch (ct) {
|
||
case CT_TIMING: {
|
||
// No additional request data
|
||
// Response: [uint8 result][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<uint32_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
resultData.push_back(ticks & 0xFF);
|
||
resultData.push_back((ticks >> 8) & 0xFF);
|
||
resultData.push_back((ticks >> 16) & 0xFF);
|
||
resultData.push_back((ticks >> 24) & 0xFF);
|
||
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=", static_cast<int>(readLen),
|
||
moduleName.empty() ? "" : (" module=\"" + moduleName + "\""));
|
||
|
||
// Lazy-load WoW.exe PE image on first MEM_CHECK
|
||
if (!wardenMemory_) {
|
||
wardenMemory_ = std::make_unique<WardenMemory>();
|
||
if (!wardenMemory_->load(static_cast<uint16_t>(build), 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<uint32_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<uint8_t> 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=", static_cast<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<uint8_t> hash(20, 0);
|
||
if (!filePath.empty()) {
|
||
std::string normalizedPath = asciiLower(filePath);
|
||
std::replace(normalizedPath.begin(), normalizedPath.end(), '/', '\\');
|
||
auto knownIt = knownDoorHashes().find(normalizedPath);
|
||
if (knownIt != knownDoorHashes().end()) {
|
||
found = true;
|
||
hash.assign(knownIt->second.begin(), knownIt->second.end());
|
||
}
|
||
|
||
auto* am = core::Application::getInstance().getAssetManager();
|
||
if (am && am->isInitialized() && !found) {
|
||
// Use a case-insensitive direct filesystem resolution first.
|
||
// Manifest entries may point at uppercase duplicate trees with
|
||
// different content/hashes than canonical client files.
|
||
std::vector<uint8_t> fileData;
|
||
std::string resolvedFsPath =
|
||
resolveCaseInsensitiveDataPath(am->getDataPath(), filePath);
|
||
if (!resolvedFsPath.empty()) {
|
||
fileData = readFileBinary(resolvedFsPath);
|
||
}
|
||
if (fileData.empty()) {
|
||
fileData = am->readFile(filePath);
|
||
}
|
||
|
||
if (!fileData.empty()) {
|
||
found = true;
|
||
hash = auth::Crypto::sha1(fileData);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Response: 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<uint16_t>(resultData.size());
|
||
std::vector<uint8_t> resp;
|
||
resp.push_back(0x02);
|
||
resp.push_back(resultLen & 0xFF);
|
||
resp.push_back((resultLen >> 8) & 0xFF);
|
||
resp.push_back(checksum & 0xFF);
|
||
resp.push_back((checksum >> 8) & 0xFF);
|
||
resp.push_back((checksum >> 16) & 0xFF);
|
||
resp.push_back((checksum >> 24) & 0xFF);
|
||
resp.insert(resp.end(), resultData.begin(), resultData.end());
|
||
sendWardenResponse(resp);
|
||
LOG_DEBUG("Warden: Sent CHEAT_CHECKS_RESULT (", resp.size(), " bytes, ",
|
||
checkCount, " checks, checksum=0x",
|
||
[&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")");
|
||
break;
|
||
}
|
||
|
||
case 0x03: // WARDEN_SMSG_MODULE_INITIALIZE
|
||
LOG_DEBUG("Warden: MODULE_INITIALIZE (", decrypted.size(), " bytes, no response needed)");
|
||
break;
|
||
|
||
default:
|
||
LOG_DEBUG("Warden: Unknown opcode 0x", std::hex, static_cast<int>(wardenOpcode), std::dec,
|
||
" (state=", static_cast<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<uint8_t>(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<uint8_t>(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<uint8_t>(19 + bag);
|
||
srcSlot = static_cast<uint8_t>(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<uint32_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(now - movementClockStart_).count()) + 1ULL;
|
||
if (elapsed > std::numeric_limits<uint32_t>::max()) {
|
||
movementClockStart_ = now;
|
||
elapsed = 1ULL;
|
||
}
|
||
|
||
uint32_t candidate = static_cast<uint32_t>(elapsed);
|
||
if (candidate <= lastMovementTimestampMs_) {
|
||
candidate = lastMovementTimestampMs_ + 1U;
|
||
if (candidate == 0) {
|
||
movementClockStart_ = now;
|
||
candidate = 1U;
|
||
}
|
||
}
|
||
|
||
lastMovementTimestampMs_ = candidate;
|
||
return candidate;
|
||
}
|
||
|
||
void GameHandler::sendMovement(Opcode opcode) {
|
||
if (state != WorldState::IN_WORLD) {
|
||
LOG_WARNING("Cannot send movement in state: ", static_cast<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<uint32_t>::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<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(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<uint32_t>(MovementFlags::FORWARD);
|
||
break;
|
||
case Opcode::MSG_MOVE_START_BACKWARD:
|
||
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::BACKWARD);
|
||
break;
|
||
case Opcode::MSG_MOVE_STOP:
|
||
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD));
|
||
break;
|
||
case Opcode::MSG_MOVE_START_STRAFE_LEFT:
|
||
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_LEFT);
|
||
break;
|
||
case Opcode::MSG_MOVE_START_STRAFE_RIGHT:
|
||
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
|
||
break;
|
||
case Opcode::MSG_MOVE_STOP_STRAFE:
|
||
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT));
|
||
break;
|
||
case Opcode::MSG_MOVE_JUMP:
|
||
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FALLING);
|
||
// 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<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
|
||
const bool movingHoriz = (movementInfo.flags & horizFlags) != 0;
|
||
if (movingHoriz) {
|
||
const bool isWalking = (movementInfo.flags & static_cast<uint32_t>(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<uint32_t>(MovementFlags::TURN_LEFT);
|
||
break;
|
||
case Opcode::MSG_MOVE_START_TURN_RIGHT:
|
||
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_RIGHT);
|
||
break;
|
||
case Opcode::MSG_MOVE_STOP_TURN:
|
||
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_RIGHT));
|
||
break;
|
||
case Opcode::MSG_MOVE_FALL_LAND:
|
||
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::FALLING);
|
||
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<uint32_t>(MovementFlags::ASCENDING);
|
||
break;
|
||
case Opcode::MSG_MOVE_STOP_ASCEND:
|
||
// Clears ascending (and descending) — one stop opcode for both directions
|
||
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ASCENDING);
|
||
break;
|
||
case Opcode::MSG_MOVE_START_DESCEND:
|
||
// Descending: no separate flag; clear ASCENDING so they don't conflict
|
||
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ASCENDING);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
// Fire PLAYER_STARTED/STOPPED_MOVING on movement state transitions
|
||
{
|
||
const bool isMoving = (movementInfo.flags & kMoveMask) != 0;
|
||
if (isMoving && !wasMoving)
|
||
fireAddonEvent("PLAYER_STARTED_MOVING", {});
|
||
else if (!isMoving && wasMoving)
|
||
fireAddonEvent("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<uint32_t>(MovementFlags::ONTRANSPORT);
|
||
movementInfo.transportGuid = playerTransportGuid_;
|
||
movementInfo.transportX = playerTransportOffset_.x;
|
||
movementInfo.transportY = playerTransportOffset_.y;
|
||
movementInfo.transportZ = playerTransportOffset_.z;
|
||
movementInfo.transportTime = movementInfo.time;
|
||
movementInfo.transportSeat = -1;
|
||
movementInfo.transportTime2 = movementInfo.time;
|
||
|
||
// ONTRANSPORT expects local orientation (player yaw relative to transport yaw).
|
||
// Keep internal yaw canonical; convert to server yaw on the wire.
|
||
float transportYawCanonical = 0.0f;
|
||
if (transportManager_) {
|
||
if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr) {
|
||
if (tr->hasServerYaw) {
|
||
transportYawCanonical = tr->serverYaw;
|
||
} else {
|
||
transportYawCanonical = glm::eulerAngles(tr->rotation).z;
|
||
}
|
||
}
|
||
}
|
||
|
||
movementInfo.transportO =
|
||
core::coords::normalizeAngleRad(movementInfo.orientation - transportYawCanonical);
|
||
} else {
|
||
// Clear transport flag if not on transport
|
||
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
|
||
movementInfo.transportGuid = 0;
|
||
movementInfo.transportSeat = -1;
|
||
}
|
||
|
||
if (opcode == Opcode::MSG_MOVE_HEARTBEAT && isClassicLikeExpansion()) {
|
||
const uint32_t locomotionFlags =
|
||
static_cast<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::ASCENDING) |
|
||
static_cast<uint32_t>(MovementFlags::FALLING) |
|
||
static_cast<uint32_t>(MovementFlags::FALLINGFAR) |
|
||
static_cast<uint32_t>(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<uint32_t>::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<uint32_t>::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<uint32_t>(MovementFlags::FORWARD) |
|
||
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
||
static_cast<uint32_t>(MovementFlags::TURN_RIGHT) |
|
||
static_cast<uint32_t>(MovementFlags::PITCH_UP) |
|
||
static_cast<uint32_t>(MovementFlags::PITCH_DOWN) |
|
||
static_cast<uint32_t>(MovementFlags::FALLING) |
|
||
static_cast<uint32_t>(MovementFlags::FALLINGFAR) |
|
||
static_cast<uint32_t>(MovementFlags::SWIMMING);
|
||
|
||
movementInfo.flags &= ~kClearTaxiFlags;
|
||
movementInfo.fallTime = 0;
|
||
movementInfo.jumpVelocity = 0.0f;
|
||
movementInfo.jumpSinAngle = 0.0f;
|
||
movementInfo.jumpCosAngle = 0.0f;
|
||
movementInfo.jumpXYSpeed = 0.0f;
|
||
movementInfo.pitch = 0.0f;
|
||
}
|
||
|
||
void GameHandler::forceClearTaxiAndMovementState() {
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
taxiClientActive_ = false;
|
||
taxiClientPath_.clear();
|
||
taxiRecoverPending_ = false;
|
||
taxiStartGrace_ = 0.0f;
|
||
onTaxiFlight_ = false;
|
||
|
||
if (taxiMountActive_ && mountCallback_) {
|
||
mountCallback_(0);
|
||
}
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
currentMountDisplayId_ = 0;
|
||
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<uint64_t>& 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<uint16_t, uint32_t>& fields,
|
||
uint8_t& outRace,
|
||
uint8_t& outGender,
|
||
uint32_t& outAppearanceBytes,
|
||
uint8_t& outFacial) -> bool {
|
||
outRace = 0;
|
||
outGender = 0;
|
||
outAppearanceBytes = 0;
|
||
outFacial = 0;
|
||
|
||
auto readField = [&](uint16_t idx, uint32_t& out) -> bool {
|
||
if (idx == 0xFFFF) return false;
|
||
auto it = fields.find(idx);
|
||
if (it == fields.end()) return false;
|
||
out = it->second;
|
||
return true;
|
||
};
|
||
|
||
uint32_t bytes0 = 0;
|
||
uint32_t pbytes = 0;
|
||
uint32_t pbytes2 = 0;
|
||
|
||
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
|
||
const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES);
|
||
const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2);
|
||
|
||
bool haveBytes0 = readField(ufBytes0, bytes0);
|
||
bool havePbytes = readField(ufPbytes, pbytes);
|
||
bool havePbytes2 = readField(ufPbytes2, pbytes2);
|
||
|
||
// Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing,
|
||
// try to locate plausible packed fields by scanning.
|
||
if (!haveBytes0) {
|
||
for (const auto& [idx, v] : fields) {
|
||
uint8_t race = static_cast<uint8_t>(v & 0xFF);
|
||
uint8_t cls = static_cast<uint8_t>((v >> 8) & 0xFF);
|
||
uint8_t gender = static_cast<uint8_t>((v >> 16) & 0xFF);
|
||
uint8_t power = static_cast<uint8_t>((v >> 24) & 0xFF);
|
||
if (race >= 1 && race <= 20 &&
|
||
cls >= 1 && cls <= 20 &&
|
||
gender <= 1 &&
|
||
power <= 10) {
|
||
bytes0 = v;
|
||
haveBytes0 = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!havePbytes) {
|
||
for (const auto& [idx, v] : fields) {
|
||
uint8_t skin = static_cast<uint8_t>(v & 0xFF);
|
||
uint8_t face = static_cast<uint8_t>((v >> 8) & 0xFF);
|
||
uint8_t hair = static_cast<uint8_t>((v >> 16) & 0xFF);
|
||
uint8_t color = static_cast<uint8_t>((v >> 24) & 0xFF);
|
||
if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) {
|
||
pbytes = v;
|
||
havePbytes = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!havePbytes2) {
|
||
for (const auto& [idx, v] : fields) {
|
||
uint8_t facial = static_cast<uint8_t>(v & 0xFF);
|
||
if (facial <= 100) {
|
||
pbytes2 = v;
|
||
havePbytes2 = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!haveBytes0 || !havePbytes) return false;
|
||
|
||
outRace = static_cast<uint8_t>(bytes0 & 0xFF);
|
||
outGender = static_cast<uint8_t>((bytes0 >> 16) & 0xFF);
|
||
outAppearanceBytes = pbytes;
|
||
outFacial = havePbytes2 ? static_cast<uint8_t>(pbytes2 & 0xFF) : 0;
|
||
return true;
|
||
};
|
||
|
||
auto maybeDetectCoinageIndex = [&](const std::map<uint16_t, uint32_t>& oldFields,
|
||
const std::map<uint16_t, uint32_t>& newFields) {
|
||
if (pendingMoneyDelta_ == 0 || pendingMoneyDeltaTimer_ <= 0.0f) return;
|
||
if (oldFields.empty() || newFields.empty()) return;
|
||
|
||
constexpr uint32_t kMaxPlausibleCoinage = 2147483647u;
|
||
std::vector<uint16_t> candidates;
|
||
candidates.reserve(8);
|
||
|
||
for (const auto& [idx, newVal] : newFields) {
|
||
auto itOld = oldFields.find(idx);
|
||
if (itOld == oldFields.end()) continue;
|
||
uint32_t oldVal = itOld->second;
|
||
if (newVal < oldVal) continue;
|
||
uint32_t delta = newVal - oldVal;
|
||
if (delta != pendingMoneyDelta_) continue;
|
||
if (newVal > kMaxPlausibleCoinage) continue;
|
||
candidates.push_back(idx);
|
||
}
|
||
|
||
if (candidates.empty()) return;
|
||
|
||
uint16_t current = fieldIndex(UF::PLAYER_FIELD_COINAGE);
|
||
uint16_t chosen = candidates[0];
|
||
if (std::find(candidates.begin(), candidates.end(), current) != candidates.end()) {
|
||
chosen = current;
|
||
} else {
|
||
std::sort(candidates.begin(), candidates.end());
|
||
chosen = candidates[0];
|
||
}
|
||
|
||
if (chosen != current && current != 0xFFFF) {
|
||
updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen);
|
||
LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")");
|
||
}
|
||
|
||
pendingMoneyDelta_ = 0;
|
||
pendingMoneyDeltaTimer_ = 0.0f;
|
||
};
|
||
|
||
switch (block.updateType) {
|
||
case UpdateType::CREATE_OBJECT:
|
||
case UpdateType::CREATE_OBJECT2: {
|
||
// Create new entity
|
||
std::shared_ptr<Entity> entity;
|
||
|
||
switch (block.objectType) {
|
||
case ObjectType::PLAYER:
|
||
entity = std::make_shared<Player>(block.guid);
|
||
break;
|
||
|
||
case ObjectType::UNIT:
|
||
entity = std::make_shared<Unit>(block.guid);
|
||
break;
|
||
|
||
case ObjectType::GAMEOBJECT:
|
||
entity = std::make_shared<GameObject>(block.guid);
|
||
break;
|
||
|
||
default:
|
||
entity = std::make_shared<Entity>(block.guid);
|
||
entity->setType(block.objectType);
|
||
break;
|
||
}
|
||
|
||
// Set position from movement block (server → canonical)
|
||
if (block.hasMovement) {
|
||
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
|
||
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
|
||
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
|
||
LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")");
|
||
if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
|
||
serverRunSpeed_ = block.runSpeed;
|
||
}
|
||
// Track player-on-transport state
|
||
if (block.guid == playerGuid) {
|
||
if (block.onTransport) {
|
||
// 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<Unit>(entity);
|
||
unit->setEntry(it->second);
|
||
// Set name from cache immediately if available
|
||
std::string cached = getCachedCreatureName(it->second);
|
||
if (!cached.empty()) {
|
||
unit->setName(cached);
|
||
}
|
||
queryCreatureInfo(it->second, block.guid);
|
||
}
|
||
}
|
||
|
||
// Extract health/mana/power from fields (Phase 2) — single pass
|
||
if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) {
|
||
auto unit = std::static_pointer_cast<Unit>(entity);
|
||
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
|
||
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
|
||
bool unitInitiallyDead = false;
|
||
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
|
||
const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1);
|
||
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
|
||
const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
|
||
const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
||
const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE);
|
||
const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS);
|
||
const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS);
|
||
const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID);
|
||
const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID);
|
||
const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS);
|
||
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
|
||
for (const auto& [key, val] : block.fields) {
|
||
// Check all specific fields BEFORE power/maxpower range checks.
|
||
// In Classic, power indices (23-27) are adjacent to maxHealth (28),
|
||
// and maxPower indices (29-33) are adjacent to level (34) and faction (35).
|
||
// A range check like "key >= powerBase && key < powerBase+7" would
|
||
// incorrectly capture maxHealth/level/faction in Classic's tight layout.
|
||
if (key == ufHealth) {
|
||
unit->setHealth(val);
|
||
if (block.objectType == ObjectType::UNIT && val == 0) {
|
||
unitInitiallyDead = true;
|
||
}
|
||
if (block.guid == playerGuid && val == 0) {
|
||
playerDead_ = true;
|
||
LOG_INFO("Player logged in dead");
|
||
}
|
||
} else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
|
||
else if (key == ufLevel) {
|
||
unit->setLevel(val);
|
||
} else if (key == ufFaction) {
|
||
unit->setFactionTemplate(val);
|
||
if (addonEventCallback_) {
|
||
auto uid = guidToUnitId(block.guid);
|
||
if (!uid.empty())
|
||
fireAddonEvent("UNIT_FACTION", {uid});
|
||
}
|
||
}
|
||
else if (key == ufFlags) {
|
||
unit->setUnitFlags(val);
|
||
if (addonEventCallback_) {
|
||
auto uid = guidToUnitId(block.guid);
|
||
if (!uid.empty())
|
||
fireAddonEvent("UNIT_FLAGS", {uid});
|
||
}
|
||
}
|
||
else if (key == ufBytes0) {
|
||
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
|
||
} else if (key == ufDisplayId) {
|
||
unit->setDisplayId(val);
|
||
if (addonEventCallback_) {
|
||
auto uid = guidToUnitId(block.guid);
|
||
if (!uid.empty())
|
||
fireAddonEvent("UNIT_MODEL_CHANGED", {uid});
|
||
}
|
||
}
|
||
else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
||
else if (key == ufDynFlags) {
|
||
unit->setDynamicFlags(val);
|
||
if (block.objectType == ObjectType::UNIT &&
|
||
((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) {
|
||
unitInitiallyDead = true;
|
||
}
|
||
}
|
||
// Power/maxpower range checks AFTER all specific fields
|
||
else if (key >= ufPowerBase && key < ufPowerBase + 7) {
|
||
unit->setPowerByType(static_cast<uint8_t>(key - ufPowerBase), val);
|
||
} else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) {
|
||
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufMaxPowerBase), val);
|
||
}
|
||
else if (key == ufMountDisplayId) {
|
||
if (block.guid == playerGuid) {
|
||
uint32_t old = currentMountDisplayId_;
|
||
currentMountDisplayId_ = val;
|
||
if (val != old && mountCallback_) mountCallback_(val);
|
||
if (val != old)
|
||
fireAddonEvent("UNIT_MODEL_CHANGED", {"player"});
|
||
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);
|
||
}
|
||
}
|
||
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);
|
||
// Query corpse position so minimap marker is accurate on reconnect
|
||
if (socket) {
|
||
network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY));
|
||
socket->send(cq);
|
||
}
|
||
}
|
||
}
|
||
// 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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<uint16_t>(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<uint16_t>(ufAuraFlags + slot / 4));
|
||
if (fit != allFields.end())
|
||
classicFlag = static_cast<uint8_t>((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)");
|
||
fireAddonEvent("UNIT_AURA", {"player"});
|
||
}
|
||
}
|
||
}
|
||
// 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<GameObject>(entity);
|
||
auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID));
|
||
if (itDisp != block.fields.end()) {
|
||
go->setDisplayId(itDisp->second);
|
||
}
|
||
auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
|
||
if (itEntry != block.fields.end() && itEntry->second != 0) {
|
||
go->setEntry(itEntry->second);
|
||
auto cacheIt = gameObjectInfoCache_.find(itEntry->second);
|
||
if (cacheIt != gameObjectInfoCache_.end()) {
|
||
go->setName(cacheIt->second.name);
|
||
}
|
||
queryGameObjectInfo(itEntry->second, block.guid);
|
||
}
|
||
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
|
||
LOG_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<uint64_t>(ownerHigh) << 32) | ownerLow;
|
||
if (ownerGuid == playerGuid || ownerLow == static_cast<uint32_t>(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<uint16_t>(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)
|
||
fireAddonEvent("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<int32_t>(val);
|
||
LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_);
|
||
}
|
||
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
|
||
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(val);
|
||
}
|
||
else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) {
|
||
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
|
||
LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec,
|
||
" bankBagSlots=", static_cast<int>(bankBagSlots));
|
||
inventory.setPurchasedBankBagSlots(bankBagSlots);
|
||
// 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<uint8_t>((val >> 24) & 0xFF);
|
||
bool wasResting = isResting_;
|
||
isResting_ = (restStateByte != 0);
|
||
if (isResting_ != wasResting) {
|
||
fireAddonEvent("UPDATE_EXHAUSTION", {});
|
||
fireAddonEvent("PLAYER_UPDATE_RESTING", {});
|
||
}
|
||
}
|
||
else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) {
|
||
chosenTitleBit_ = static_cast<int32_t>(val);
|
||
LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_);
|
||
}
|
||
else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast<int32_t>(val); }
|
||
else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast<int32_t>(val); }
|
||
else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) {
|
||
playerSpellDmgBonus_[key - ufSpDmg1] = static_cast<int32_t>(val);
|
||
}
|
||
else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast<int32_t>(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<int32_t>(val);
|
||
}
|
||
else {
|
||
for (int si = 0; si < 5; ++si) {
|
||
if (ufStats[si] != 0xFFFF && key == ufStats[si]) {
|
||
playerStats_[si] = static_cast<int32_t>(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<Unit>(entity);
|
||
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
|
||
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
|
||
uint32_t oldDisplayId = unit->getDisplayId();
|
||
bool displayIdChanged = false;
|
||
bool npcDeathNotified = false;
|
||
bool npcRespawnNotified = false;
|
||
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);
|
||
const uint16_t ufBytes1 = fieldIndex(UF::UNIT_FIELD_BYTES_1);
|
||
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_);
|
||
fireAddonEvent("PLAYER_DEAD", {});
|
||
}
|
||
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) {
|
||
bool wasGhost = releasedSpirit_;
|
||
playerDead_ = false;
|
||
if (!wasGhost) {
|
||
LOG_INFO("Player resurrected!");
|
||
fireAddonEvent("PLAYER_ALIVE", {});
|
||
} else {
|
||
LOG_INFO("Player entered ghost form");
|
||
releasedSpirit_ = false;
|
||
fireAddonEvent("PLAYER_UNGHOST", {});
|
||
}
|
||
}
|
||
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) {
|
||
uint8_t oldPT = unit->getPowerType();
|
||
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
|
||
if (unit->getPowerType() != oldPT) {
|
||
auto uid = guidToUnitId(block.guid);
|
||
if (!uid.empty())
|
||
fireAddonEvent("UNIT_DISPLAYPOWER", {uid});
|
||
}
|
||
} else if (key == ufFlags) { unit->setUnitFlags(val); }
|
||
else if (ufBytes1 != 0xFFFF && key == ufBytes1 && block.guid == playerGuid) {
|
||
uint8_t newForm = static_cast<uint8_t>((val >> 24) & 0xFF);
|
||
if (newForm != shapeshiftFormId_) {
|
||
shapeshiftFormId_ = newForm;
|
||
LOG_INFO("Shapeshift form changed: ", static_cast<int>(newForm));
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("UPDATE_SHAPESHIFT_FORM", {});
|
||
fireAddonEvent("UPDATE_SHAPESHIFT_FORMS", {});
|
||
}
|
||
}
|
||
}
|
||
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 (val != oldLvl) {
|
||
auto uid = guidToUnitId(block.guid);
|
||
if (!uid.empty())
|
||
fireAddonEvent("UNIT_LEVEL", {uid});
|
||
}
|
||
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 (val != old)
|
||
fireAddonEvent("UNIT_MODEL_CHANGED", {"player"});
|
||
if (old == 0 && val != 0) {
|
||
mountAuraSpellId_ = 0;
|
||
for (const auto& a : playerAuras) {
|
||
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
|
||
mountAuraSpellId_ = a.spellId;
|
||
}
|
||
}
|
||
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
|
||
if (mountAuraSpellId_ == 0) {
|
||
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
|
||
if (ufAuras != 0xFFFF) {
|
||
for (const auto& [fk, fv] : block.fields) {
|
||
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
|
||
mountAuraSpellId_ = fv;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_);
|
||
}
|
||
if (old != 0 && val == 0) {
|
||
mountAuraSpellId_ = 0;
|
||
for (auto& a : playerAuras)
|
||
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
|
||
}
|
||
}
|
||
unit->setMountDisplayId(val);
|
||
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
||
// Power/maxpower range checks AFTER all specific fields
|
||
else if (key >= ufPowerBase && key < ufPowerBase + 7) {
|
||
unit->setPowerByType(static_cast<uint8_t>(key - ufPowerBase), val);
|
||
powerChanged = true;
|
||
} else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) {
|
||
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufMaxPowerBase), val);
|
||
powerChanged = true;
|
||
}
|
||
}
|
||
|
||
// Fire UNIT_HEALTH / UNIT_POWER events for Lua addons
|
||
if ((healthChanged || powerChanged)) {
|
||
auto unitId = guidToUnitId(block.guid);
|
||
if (!unitId.empty()) {
|
||
if (healthChanged) fireAddonEvent("UNIT_HEALTH", {unitId});
|
||
if (powerChanged) {
|
||
fireAddonEvent("UNIT_POWER", {unitId});
|
||
// When player power changes, action bar usability may change
|
||
if (block.guid == playerGuid) {
|
||
fireAddonEvent("ACTIONBAR_UPDATE_USABLE", {});
|
||
fireAddonEvent("SPELL_UPDATE_USABLE", {});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<uint16_t>(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<uint16_t>(ufAuraFlags + slot / 4));
|
||
if (fit != allFields.end())
|
||
aFlag = static_cast<uint8_t>((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)");
|
||
fireAddonEvent("UNIT_AURA", {"player"});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
// Fire UNIT_MODEL_CHANGED for addons that track model swaps
|
||
if (addonEventCallback_) {
|
||
std::string uid;
|
||
if (block.guid == targetGuid) uid = "target";
|
||
else if (block.guid == focusGuid) uid = "focus";
|
||
else if (block.guid == petGuid_) uid = "pet";
|
||
if (!uid.empty())
|
||
fireAddonEvent("UNIT_MODEL_CHANGED", {uid});
|
||
}
|
||
}
|
||
}
|
||
// Update XP / inventory slot / skill fields for player entity
|
||
if (block.guid == playerGuid) {
|
||
const bool needCoinageDetectSnapshot =
|
||
(pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f);
|
||
std::map<uint16_t, uint32_t> oldFieldsSnapshot;
|
||
if (needCoinageDetectSnapshot) {
|
||
oldFieldsSnapshot = lastPlayerFields_;
|
||
}
|
||
if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
|
||
serverRunSpeed_ = block.runSpeed;
|
||
// Some server dismount paths update run speed without updating mount display field.
|
||
if (!onTaxiFlight_ && !taxiMountActive_ &&
|
||
currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) {
|
||
LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed,
|
||
" displayId=", currentMountDisplayId_);
|
||
currentMountDisplayId_ = 0;
|
||
if (mountCallback_) {
|
||
mountCallback_(0);
|
||
}
|
||
}
|
||
}
|
||
auto mergeHint = lastPlayerFields_.end();
|
||
for (const auto& [key, val] : block.fields) {
|
||
mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val);
|
||
}
|
||
if (needCoinageDetectSnapshot) {
|
||
maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_);
|
||
}
|
||
maybeDetectVisibleItemLayout();
|
||
detectInventorySlotBases(block.fields);
|
||
bool slotsChanged = false;
|
||
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
|
||
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
|
||
const uint16_t 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);
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("UPDATE_EXHAUSTION", {});
|
||
}
|
||
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)
|
||
fireAddonEvent("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<int32_t>(val);
|
||
}
|
||
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
|
||
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(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<uint8_t>(val & 0xFF);
|
||
for (auto& ch : characters) {
|
||
if (ch.guid == playerGuid) {
|
||
ch.facialFeatures = facialHair;
|
||
break;
|
||
}
|
||
}
|
||
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
|
||
LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
|
||
" bankBagSlots=", static_cast<int>(bankBagSlots),
|
||
" facial=", static_cast<int>(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<uint8_t>((val >> 24) & 0xFF);
|
||
isResting_ = (restStateByte != 0);
|
||
if (appearanceChangedCallback_)
|
||
appearanceChangedCallback_();
|
||
}
|
||
else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) {
|
||
chosenTitleBit_ = static_cast<int32_t>(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)");
|
||
fireAddonEvent("PLAYER_ALIVE", {});
|
||
if (ghostStateCallback_) ghostStateCallback_(false);
|
||
}
|
||
fireAddonEvent("PLAYER_FLAGS_CHANGED", {});
|
||
}
|
||
else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast<int32_t>(val); }
|
||
else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast<int32_t>(val); }
|
||
else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) {
|
||
playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast<int32_t>(val);
|
||
}
|
||
else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast<int32_t>(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<int32_t>(val);
|
||
}
|
||
else {
|
||
for (int si = 0; si < 5; ++si) {
|
||
if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) {
|
||
playerStats_[si] = static_cast<int32_t>(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();
|
||
fireAddonEvent("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_) {
|
||
fireAddonEvent("BAG_UPDATE", {});
|
||
fireAddonEvent("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<uint8_t> decompressed(decompressedSize);
|
||
uLongf destLen = decompressedSize;
|
||
int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize);
|
||
|
||
if (ret != Z_OK) {
|
||
LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret);
|
||
return;
|
||
}
|
||
|
||
// Create packet from decompressed data and parse it
|
||
network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed);
|
||
handleUpdateObject(decompressedPacket);
|
||
}
|
||
|
||
void GameHandler::handleDestroyObject(network::Packet& packet) {
|
||
LOG_DEBUG("Handling SMSG_DESTROY_OBJECT");
|
||
|
||
DestroyObjectData data;
|
||
if (!DestroyObjectParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT");
|
||
return;
|
||
}
|
||
|
||
// Remove entity
|
||
if (entityManager.hasEntity(data.guid)) {
|
||
if (transportGuids_.count(data.guid) > 0) {
|
||
const bool playerAboardNow = (playerTransportGuid_ == data.guid);
|
||
const bool stickyAboard = (playerTransportStickyGuid_ == data.guid && playerTransportStickyTimer_ > 0.0f);
|
||
const bool movementSaysAboard = (movementInfo.transportGuid == data.guid);
|
||
if (playerAboardNow || stickyAboard || movementSaysAboard) {
|
||
serverUpdatedTransportGuids_.erase(data.guid);
|
||
LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec,
|
||
" now=", playerAboardNow,
|
||
" sticky=", stickyAboard,
|
||
" movement=", movementSaysAboard);
|
||
return;
|
||
}
|
||
}
|
||
// Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal.
|
||
auto entity = entityManager.getEntity(data.guid);
|
||
if (entity) {
|
||
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
|
||
creatureDespawnCallback_(data.guid);
|
||
} else if (entity->getType() == ObjectType::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: ", static_cast<int>(state));
|
||
return;
|
||
}
|
||
|
||
if (message.empty()) {
|
||
LOG_WARNING("Cannot send empty chat message");
|
||
return;
|
||
}
|
||
|
||
LOG_INFO("Sending chat message: [", getChatTypeString(type), "] ", message);
|
||
|
||
// Determine language based on character (for now, use COMMON)
|
||
ChatLanguage language = ChatLanguage::COMMON;
|
||
|
||
// Build and send packet
|
||
auto packet = MessageChatPacket::build(type, language, message, target);
|
||
socket->send(packet);
|
||
|
||
// Add local echo so the player sees their own message immediately
|
||
MessageChatData echo;
|
||
echo.senderGuid = playerGuid;
|
||
echo.language = language;
|
||
echo.message = message;
|
||
|
||
// Look up player name
|
||
auto nameIt = playerNameCache.find(playerGuid);
|
||
if (nameIt != playerNameCache.end()) {
|
||
echo.senderName = nameIt->second;
|
||
}
|
||
|
||
if (type == ChatType::WHISPER) {
|
||
echo.type = ChatType::WHISPER_INFORM;
|
||
echo.senderName = target; // "To [target]: message"
|
||
} else {
|
||
echo.type = type;
|
||
}
|
||
|
||
if (type == ChatType::CHANNEL) {
|
||
echo.channelName = target;
|
||
}
|
||
|
||
addLocalChatMessage(echo);
|
||
}
|
||
|
||
void GameHandler::handleMessageChat(network::Packet& packet) {
|
||
LOG_DEBUG("Handling SMSG_MESSAGECHAT");
|
||
|
||
MessageChatData data;
|
||
if (!packetParsers_->parseMessageChat(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_MESSAGECHAT");
|
||
return;
|
||
}
|
||
|
||
// Skip server echo of our own messages (we already added a local echo)
|
||
if (data.senderGuid == playerGuid && data.senderGuid != 0) {
|
||
// Still track whisper sender for /r even if it's our own whisper-inform
|
||
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
|
||
lastWhisperSender_ = data.senderName;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Resolve sender name from entity/cache if not already set by parser
|
||
if (data.senderName.empty() && data.senderGuid != 0) {
|
||
// Check player name cache first
|
||
auto nameIt = playerNameCache.find(data.senderGuid);
|
||
if (nameIt != playerNameCache.end()) {
|
||
data.senderName = nameIt->second;
|
||
} else {
|
||
// Try entity name
|
||
auto entity = entityManager.getEntity(data.senderGuid);
|
||
if (entity) {
|
||
if (entity->getType() == ObjectType::PLAYER) {
|
||
auto player = std::dynamic_pointer_cast<Player>(entity);
|
||
if (player && !player->getName().empty()) {
|
||
data.senderName = player->getName();
|
||
}
|
||
} else if (entity->getType() == ObjectType::UNIT) {
|
||
auto unit = std::dynamic_pointer_cast<Unit>(entity);
|
||
if (unit && !unit->getName().empty()) {
|
||
data.senderName = unit->getName();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If still unknown, proactively query the server so the UI can show names soon after.
|
||
if (data.senderName.empty()) {
|
||
queryPlayerName(data.senderGuid);
|
||
}
|
||
}
|
||
|
||
// Add to chat history
|
||
chatHistory.push_back(data);
|
||
|
||
// Limit chat history size
|
||
if (chatHistory.size() > maxChatHistory) {
|
||
chatHistory.erase(chatHistory.begin());
|
||
}
|
||
|
||
// Track whisper sender for /r command
|
||
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
|
||
lastWhisperSender_ = data.senderName;
|
||
|
||
// Auto-reply if AFK or DND
|
||
if (afkStatus_ && !data.senderName.empty()) {
|
||
std::string reply = afkMessage_.empty() ? "Away from Keyboard" : afkMessage_;
|
||
sendChatMessage(ChatType::WHISPER, "<AFK> " + reply, data.senderName);
|
||
} else if (dndStatus_ && !data.senderName.empty()) {
|
||
std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_;
|
||
sendChatMessage(ChatType::WHISPER, "<DND> " + 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);
|
||
|
||
// Detect addon messages: format is "prefix\ttext" in the message body.
|
||
// Only treat as addon message if prefix is short (<=16 chars, WoW limit),
|
||
// contains no spaces (real prefixes are identifiers like "DBM4" or "BigWigs"),
|
||
// and the message isn't a SAY/YELL/EMOTE (those are always player chat).
|
||
if (addonEventCallback_ &&
|
||
data.type != ChatType::SAY && data.type != ChatType::YELL &&
|
||
data.type != ChatType::EMOTE && data.type != ChatType::TEXT_EMOTE &&
|
||
data.type != ChatType::MONSTER_SAY && data.type != ChatType::MONSTER_YELL) {
|
||
auto tabPos = data.message.find('\t');
|
||
if (tabPos != std::string::npos && tabPos > 0 && tabPos <= 16 &&
|
||
tabPos < data.message.size() - 1) {
|
||
std::string prefix = data.message.substr(0, tabPos);
|
||
// Addon prefixes are identifier-like: no spaces
|
||
if (prefix.find(' ') == std::string::npos) {
|
||
std::string body = data.message.substr(tabPos + 1);
|
||
std::string channel = getChatTypeString(data.type);
|
||
fireAddonEvent("CHAT_MSG_ADDON", {prefix, body, channel, data.senderName});
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fire CHAT_MSG_* addon events so Lua chat frames and addons receive messages.
|
||
// WoW event args: message, senderName, language, channelName
|
||
if (addonEventCallback_) {
|
||
std::string eventName = "CHAT_MSG_";
|
||
eventName += getChatTypeString(data.type);
|
||
std::string lang = std::to_string(static_cast<int>(data.language));
|
||
// Format sender GUID as hex string for addons that need it
|
||
char guidBuf[32];
|
||
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)data.senderGuid);
|
||
fireAddonEvent(eventName, {
|
||
data.message,
|
||
data.senderName,
|
||
lang,
|
||
data.channelName,
|
||
senderInfo, // arg5: displayName
|
||
"", // arg6: specialFlags
|
||
"0", // arg7: zoneChannelID
|
||
"0", // arg8: channelIndex
|
||
"", // arg9: channelBaseName
|
||
"0", // arg10: unused
|
||
"0", // arg11: lineID
|
||
guidBuf // arg12: senderGUID
|
||
});
|
||
}
|
||
}
|
||
|
||
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<Unit>(entity);
|
||
if (unit) senderName = unit->getName();
|
||
}
|
||
}
|
||
if (senderName.empty()) {
|
||
senderName = "Unknown";
|
||
queryPlayerName(data.senderGuid);
|
||
}
|
||
|
||
// Resolve emote text from DBC using third-person "others see" templates
|
||
const std::string* targetPtr = data.targetName.empty() ? nullptr : &data.targetName;
|
||
std::string emoteText = rendering::Renderer::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr);
|
||
if (emoteText.empty()) {
|
||
// Fallback if DBC lookup fails
|
||
emoteText = data.targetName.empty()
|
||
? senderName + " performs an emote."
|
||
: senderName + " performs an emote at " + data.targetName + ".";
|
||
}
|
||
|
||
MessageChatData chatMsg;
|
||
chatMsg.type = ChatType::TEXT_EMOTE;
|
||
chatMsg.language = ChatLanguage::COMMON;
|
||
chatMsg.senderGuid = data.senderGuid;
|
||
chatMsg.senderName = senderName;
|
||
chatMsg.message = emoteText;
|
||
|
||
addLocalChatMessage(chatMsg);
|
||
|
||
// Trigger emote animation on sender's entity via callback
|
||
uint32_t animId = rendering::Renderer::getEmoteAnimByDbcId(data.textEmoteId);
|
||
if (animId != 0 && emoteAnimCallback_) {
|
||
emoteAnimCallback_(data.senderGuid, animId);
|
||
}
|
||
|
||
LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ", anim=", animId, ")");
|
||
}
|
||
|
||
void GameHandler::joinChannel(const std::string& channelName, const std::string& password) {
|
||
if (state != WorldState::IN_WORLD || !socket) return;
|
||
auto packet = packetParsers_ ? packetParsers_->buildJoinChannel(channelName, password)
|
||
: JoinChannelPacket::build(channelName, password);
|
||
socket->send(packet);
|
||
LOG_INFO("Requesting to join channel: ", channelName);
|
||
}
|
||
|
||
void GameHandler::leaveChannel(const std::string& channelName) {
|
||
if (state != WorldState::IN_WORLD || !socket) return;
|
||
auto packet = packetParsers_ ? packetParsers_->buildLeaveChannel(channelName)
|
||
: LeaveChannelPacket::build(channelName);
|
||
socket->send(packet);
|
||
LOG_INFO("Requesting to leave channel: ", channelName);
|
||
}
|
||
|
||
std::string GameHandler::getChannelByIndex(int index) const {
|
||
if (index < 1 || index > static_cast<int>(joinedChannels_.size())) return "";
|
||
return joinedChannels_[index - 1];
|
||
}
|
||
|
||
int GameHandler::getChannelIndex(const std::string& channelName) const {
|
||
for (int i = 0; i < static_cast<int>(joinedChannels_.size()); ++i) {
|
||
if (joinedChannels_[i] == channelName) return i + 1; // 1-based
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
void GameHandler::handleChannelNotify(network::Packet& packet) {
|
||
ChannelNotifyData data;
|
||
if (!ChannelNotifyParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_CHANNEL_NOTIFY");
|
||
return;
|
||
}
|
||
|
||
switch (data.notifyType) {
|
||
case ChannelNotifyType::YOU_JOINED: {
|
||
// Add to active channels if not already present
|
||
bool found = false;
|
||
for (const auto& ch : joinedChannels_) {
|
||
if (ch == data.channelName) { found = true; break; }
|
||
}
|
||
if (!found) {
|
||
joinedChannels_.push_back(data.channelName);
|
||
}
|
||
MessageChatData msg;
|
||
msg.type = ChatType::SYSTEM;
|
||
msg.message = "Joined channel: " + data.channelName;
|
||
addLocalChatMessage(msg);
|
||
LOG_INFO("Joined channel: ", data.channelName);
|
||
break;
|
||
}
|
||
case ChannelNotifyType::YOU_LEFT: {
|
||
joinedChannels_.erase(
|
||
std::remove(joinedChannels_.begin(), joinedChannels_.end(), data.channelName),
|
||
joinedChannels_.end());
|
||
MessageChatData msg;
|
||
msg.type = ChatType::SYSTEM;
|
||
msg.message = "Left channel: " + data.channelName;
|
||
addLocalChatMessage(msg);
|
||
LOG_INFO("Left channel: ", data.channelName);
|
||
break;
|
||
}
|
||
case ChannelNotifyType::PLAYER_ALREADY_MEMBER: {
|
||
// Server says we're already in this channel (e.g. server auto-joined us)
|
||
// Still track it in our channel list
|
||
bool found = false;
|
||
for (const auto& ch : joinedChannels_) {
|
||
if (ch == data.channelName) { found = true; break; }
|
||
}
|
||
if (!found) {
|
||
joinedChannels_.push_back(data.channelName);
|
||
LOG_INFO("Already in channel: ", data.channelName);
|
||
}
|
||
break;
|
||
}
|
||
case ChannelNotifyType::NOT_IN_AREA:
|
||
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<int>(data.notifyType),
|
||
" for channel ", data.channelName);
|
||
break;
|
||
}
|
||
}
|
||
|
||
void GameHandler::autoJoinDefaultChannels() {
|
||
LOG_INFO("autoJoinDefaultChannels: general=", chatAutoJoin.general,
|
||
" trade=", chatAutoJoin.trade, " localDefense=", chatAutoJoin.localDefense,
|
||
" lfg=", chatAutoJoin.lfg, " local=", chatAutoJoin.local);
|
||
if (chatAutoJoin.general) joinChannel("General");
|
||
if (chatAutoJoin.trade) joinChannel("Trade");
|
||
if (chatAutoJoin.localDefense) joinChannel("LocalDefense");
|
||
if (chatAutoJoin.lfg) joinChannel("LookingForGroup");
|
||
if (chatAutoJoin.local) joinChannel("Local");
|
||
}
|
||
|
||
void GameHandler::setTarget(uint64_t guid) {
|
||
if (guid == targetGuid) return;
|
||
|
||
// Save previous target
|
||
if (targetGuid != 0) {
|
||
lastTargetGuid = targetGuid;
|
||
}
|
||
|
||
targetGuid = guid;
|
||
|
||
// 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);
|
||
}
|
||
fireAddonEvent("PLAYER_TARGET_CHANGED", {});
|
||
}
|
||
|
||
void GameHandler::clearTarget() {
|
||
if (targetGuid != 0) {
|
||
LOG_INFO("Target cleared");
|
||
fireAddonEvent("PLAYER_TARGET_CHANGED", {});
|
||
}
|
||
targetGuid = 0;
|
||
tabCycleIndex = -1;
|
||
tabCycleStale = true;
|
||
}
|
||
|
||
std::shared_ptr<Entity> GameHandler::getTarget() const {
|
||
if (targetGuid == 0) return nullptr;
|
||
return entityManager.getEntity(targetGuid);
|
||
}
|
||
|
||
void GameHandler::setFocus(uint64_t guid) {
|
||
focusGuid = guid;
|
||
fireAddonEvent("PLAYER_FOCUS_CHANGED", {});
|
||
if (guid != 0) {
|
||
auto entity = entityManager.getEntity(guid);
|
||
if (entity) {
|
||
std::string name;
|
||
auto unit = std::dynamic_pointer_cast<Unit>(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;
|
||
fireAddonEvent("PLAYER_FOCUS_CHANGED", {});
|
||
}
|
||
|
||
void GameHandler::setMouseoverGuid(uint64_t guid) {
|
||
if (mouseoverGuid_ != guid) {
|
||
mouseoverGuid_ = guid;
|
||
fireAddonEvent("UPDATE_MOUSEOVER_UNIT", {});
|
||
}
|
||
}
|
||
|
||
std::shared_ptr<Entity> GameHandler::getFocus() const {
|
||
if (focusGuid == 0) return nullptr;
|
||
return entityManager.getEntity(focusGuid);
|
||
}
|
||
|
||
void GameHandler::targetLastTarget() {
|
||
if (lastTargetGuid == 0) {
|
||
addSystemChatMessage("No previous target.");
|
||
return;
|
||
}
|
||
|
||
// Swap current and last target
|
||
uint64_t temp = targetGuid;
|
||
setTarget(lastTargetGuid);
|
||
lastTargetGuid = temp;
|
||
}
|
||
|
||
void GameHandler::targetEnemy(bool reverse) {
|
||
// Get list of hostile entities
|
||
std::vector<uint64_t> hostiles;
|
||
auto& entities = entityManager.getEntities();
|
||
|
||
for (const auto& [guid, entity] : entities) {
|
||
if (entity->getType() == ObjectType::UNIT) {
|
||
auto unit = std::dynamic_pointer_cast<Unit>(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<uint64_t> friendlies;
|
||
auto& entities = entityManager.getEntities();
|
||
|
||
for (const auto& [guid, entity] : entities) {
|
||
if (entity->getType() == ObjectType::PLAYER && guid != playerGuid) {
|
||
friendlies.push_back(guid);
|
||
}
|
||
}
|
||
|
||
if (friendlies.empty()) {
|
||
addSystemChatMessage("No friendly targets in range.");
|
||
return;
|
||
}
|
||
|
||
// Find current target in list
|
||
auto it = std::find(friendlies.begin(), friendlies.end(), targetGuid);
|
||
|
||
if (it == friendlies.end()) {
|
||
// Not currently targeting a friend, target first one
|
||
setTarget(reverse ? friendlies.back() : friendlies.front());
|
||
} else {
|
||
// Cycle to next/previous
|
||
if (reverse) {
|
||
if (it == friendlies.begin()) {
|
||
setTarget(friendlies.back());
|
||
} else {
|
||
setTarget(*(--it));
|
||
}
|
||
} else {
|
||
++it;
|
||
if (it == friendlies.end()) {
|
||
setTarget(friendlies.front());
|
||
} else {
|
||
setTarget(*it);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameHandler::inspectTarget() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot inspect: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (targetGuid == 0) {
|
||
addSystemChatMessage("You must target a player to inspect.");
|
||
return;
|
||
}
|
||
|
||
auto target = getTarget();
|
||
if (!target || target->getType() != ObjectType::PLAYER) {
|
||
addSystemChatMessage("You can only inspect players.");
|
||
return;
|
||
}
|
||
|
||
auto packet = InspectPacket::build(targetGuid);
|
||
socket->send(packet);
|
||
|
||
// 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<Player>(target);
|
||
std::string name = player->getName().empty() ? "Target" : player->getName();
|
||
addSystemChatMessage("Inspecting " + name + "...");
|
||
LOG_INFO("Sent inspect request for player: ", name, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
|
||
}
|
||
|
||
void GameHandler::queryServerTime() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot query time: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = QueryTimePacket::build();
|
||
socket->send(packet);
|
||
LOG_INFO("Requested server time");
|
||
}
|
||
|
||
void GameHandler::requestPlayedTime() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot request played time: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = RequestPlayedTimePacket::build(true);
|
||
socket->send(packet);
|
||
LOG_INFO("Requested played time");
|
||
}
|
||
|
||
void GameHandler::queryWho(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot query who: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = WhoPacket::build(0, 0, playerName);
|
||
socket->send(packet);
|
||
LOG_INFO("Sent WHO query", playerName.empty() ? "" : " for: " + playerName);
|
||
}
|
||
|
||
void GameHandler::addFriend(const std::string& playerName, const std::string& note) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot add friend: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
auto packet = AddFriendPacket::build(playerName, note);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Sending friend request to " + playerName + "...");
|
||
LOG_INFO("Sent friend request to: ", playerName);
|
||
}
|
||
|
||
void GameHandler::removeFriend(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot remove friend: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
// Look up GUID from cache
|
||
auto it = friendsCache.find(playerName);
|
||
if (it == friendsCache.end()) {
|
||
addSystemChatMessage(playerName + " is not in your friends list.");
|
||
LOG_WARNING("Friend not found in cache: ", playerName);
|
||
return;
|
||
}
|
||
|
||
auto packet = DelFriendPacket::build(it->second);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Removing " + playerName + " from friends list...");
|
||
LOG_INFO("Sent remove friend request for: ", playerName, " (GUID: 0x", std::hex, it->second, std::dec, ")");
|
||
}
|
||
|
||
void GameHandler::setFriendNote(const std::string& playerName, const std::string& note) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot set friend note: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
// Look up GUID from cache
|
||
auto it = friendsCache.find(playerName);
|
||
if (it == friendsCache.end()) {
|
||
addSystemChatMessage(playerName + " is not in your friends list.");
|
||
return;
|
||
}
|
||
|
||
auto packet = SetContactNotesPacket::build(it->second, note);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Updated note for " + playerName);
|
||
LOG_INFO("Set friend note for: ", playerName);
|
||
}
|
||
|
||
void GameHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot roll: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (minRoll > maxRoll) {
|
||
std::swap(minRoll, maxRoll);
|
||
}
|
||
|
||
if (maxRoll > 10000) {
|
||
maxRoll = 10000; // Cap at reasonable value
|
||
}
|
||
|
||
auto packet = RandomRollPacket::build(minRoll, maxRoll);
|
||
socket->send(packet);
|
||
LOG_INFO("Rolled ", minRoll, "-", maxRoll);
|
||
}
|
||
|
||
void GameHandler::addIgnore(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot add ignore: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
auto packet = AddIgnorePacket::build(playerName);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Adding " + playerName + " to ignore list...");
|
||
LOG_INFO("Sent ignore request for: ", playerName);
|
||
}
|
||
|
||
void GameHandler::removeIgnore(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot remove ignore: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
// Look up GUID from cache
|
||
auto it = ignoreCache.find(playerName);
|
||
if (it == ignoreCache.end()) {
|
||
addSystemChatMessage(playerName + " is not in your ignore list.");
|
||
LOG_WARNING("Ignored player not found in cache: ", playerName);
|
||
return;
|
||
}
|
||
|
||
auto packet = DelIgnorePacket::build(it->second);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Removing " + playerName + " from ignore list...");
|
||
ignoreCache.erase(it);
|
||
LOG_INFO("Sent remove ignore request for: ", playerName, " (GUID: 0x", std::hex, it->second, std::dec, ")");
|
||
}
|
||
|
||
void GameHandler::requestLogout() {
|
||
if (!socket) {
|
||
LOG_WARNING("Cannot logout: not connected");
|
||
return;
|
||
}
|
||
|
||
if (loggingOut_) {
|
||
addSystemChatMessage("Already logging out.");
|
||
return;
|
||
}
|
||
|
||
auto packet = LogoutRequestPacket::build();
|
||
socket->send(packet);
|
||
loggingOut_ = true;
|
||
LOG_INFO("Sent logout request");
|
||
}
|
||
|
||
void GameHandler::cancelLogout() {
|
||
if (!socket) {
|
||
LOG_WARNING("Cannot cancel logout: not connected");
|
||
return;
|
||
}
|
||
|
||
if (!loggingOut_) {
|
||
addSystemChatMessage("Not currently logging out.");
|
||
return;
|
||
}
|
||
|
||
auto packet = LogoutCancelPacket::build();
|
||
socket->send(packet);
|
||
loggingOut_ = false;
|
||
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: ", static_cast<int>(standState));
|
||
}
|
||
|
||
void GameHandler::toggleHelm() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot toggle helm: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
helmVisible_ = !helmVisible_;
|
||
auto packet = ShowingHelmPacket::build(helmVisible_);
|
||
socket->send(packet);
|
||
addSystemChatMessage(helmVisible_ ? "Helm is now visible." : "Helm is now hidden.");
|
||
LOG_INFO("Helm visibility toggled: ", helmVisible_);
|
||
}
|
||
|
||
void GameHandler::toggleCloak() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot toggle cloak: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
cloakVisible_ = !cloakVisible_;
|
||
auto packet = ShowingCloakPacket::build(cloakVisible_);
|
||
socket->send(packet);
|
||
addSystemChatMessage(cloakVisible_ ? "Cloak is now visible." : "Cloak is now hidden.");
|
||
LOG_INFO("Cloak visibility toggled: ", cloakVisible_);
|
||
}
|
||
|
||
void GameHandler::followTarget() {
|
||
if (state != WorldState::IN_WORLD) {
|
||
LOG_WARNING("Cannot follow: not in world");
|
||
return;
|
||
}
|
||
|
||
if (targetGuid == 0) {
|
||
addSystemChatMessage("You must target someone to follow.");
|
||
return;
|
||
}
|
||
|
||
auto target = getTarget();
|
||
if (!target) {
|
||
addSystemChatMessage("Invalid target.");
|
||
return;
|
||
}
|
||
|
||
// Set follow target
|
||
followTargetGuid_ = targetGuid;
|
||
|
||
// Get target name
|
||
std::string targetName = "Target";
|
||
if (target->getType() == ObjectType::PLAYER) {
|
||
auto player = std::static_pointer_cast<Player>(target);
|
||
if (!player->getName().empty()) {
|
||
targetName = player->getName();
|
||
}
|
||
} else if (target->getType() == ObjectType::UNIT) {
|
||
auto unit = std::static_pointer_cast<Unit>(target);
|
||
targetName = unit->getName();
|
||
}
|
||
|
||
addSystemChatMessage("Now following " + targetName + ".");
|
||
LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
|
||
fireAddonEvent("AUTOFOLLOW_BEGIN", {});
|
||
}
|
||
|
||
void GameHandler::cancelFollow() {
|
||
if (followTargetGuid_ == 0) {
|
||
addSystemChatMessage("You are not following anyone.");
|
||
return;
|
||
}
|
||
followTargetGuid_ = 0;
|
||
addSystemChatMessage("You stop following.");
|
||
fireAddonEvent("AUTOFOLLOW_END", {});
|
||
}
|
||
|
||
void GameHandler::assistTarget() {
|
||
if (state != WorldState::IN_WORLD) {
|
||
LOG_WARNING("Cannot assist: not in world");
|
||
return;
|
||
}
|
||
|
||
if (targetGuid == 0) {
|
||
addSystemChatMessage("You must target someone to assist.");
|
||
return;
|
||
}
|
||
|
||
auto target = getTarget();
|
||
if (!target) {
|
||
addSystemChatMessage("Invalid target.");
|
||
return;
|
||
}
|
||
|
||
// Get target name
|
||
std::string targetName = "Target";
|
||
if (target->getType() == ObjectType::PLAYER) {
|
||
auto player = std::static_pointer_cast<Player>(target);
|
||
if (!player->getName().empty()) {
|
||
targetName = player->getName();
|
||
}
|
||
} else if (target->getType() == ObjectType::UNIT) {
|
||
auto unit = std::static_pointer_cast<Unit>(target);
|
||
targetName = unit->getName();
|
||
}
|
||
|
||
// Try to read target GUID from update fields (UNIT_FIELD_TARGET)
|
||
uint64_t assistTargetGuid = 0;
|
||
const auto& fields = target->getFields();
|
||
auto it = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_LO));
|
||
if (it != fields.end()) {
|
||
assistTargetGuid = it->second;
|
||
auto it2 = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_HI));
|
||
if (it2 != fields.end()) {
|
||
assistTargetGuid |= (static_cast<uint64_t>(it2->second) << 32);
|
||
}
|
||
}
|
||
|
||
if (assistTargetGuid == 0) {
|
||
addSystemChatMessage(targetName + " has no target.");
|
||
LOG_INFO("Assist: ", targetName, " has no target");
|
||
return;
|
||
}
|
||
|
||
// Set our target to their target
|
||
setTarget(assistTargetGuid);
|
||
LOG_INFO("Assisting ", targetName, ", now targeting GUID: 0x", std::hex, assistTargetGuid, std::dec);
|
||
}
|
||
|
||
void GameHandler::togglePvp() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot toggle PvP: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = TogglePvpPacket::build();
|
||
socket->send(packet);
|
||
// Check current PVP state from player's UNIT_FIELD_FLAGS (index 59)
|
||
// UNIT_FLAG_PVP = 0x00001000
|
||
auto entity = entityManager.getEntity(playerGuid);
|
||
bool currentlyPvp = false;
|
||
if (entity) {
|
||
currentlyPvp = (entity->getField(59) & 0x00001000) != 0;
|
||
}
|
||
// We're toggling, so report the NEW state
|
||
if (currentlyPvp) {
|
||
addSystemChatMessage("PvP flag disabled.");
|
||
} else {
|
||
addSystemChatMessage("PvP flag enabled.");
|
||
}
|
||
LOG_INFO("Toggled PvP flag");
|
||
}
|
||
|
||
void GameHandler::requestGuildInfo() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot request guild info: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildInfoPacket::build();
|
||
socket->send(packet);
|
||
LOG_INFO("Requested guild info");
|
||
}
|
||
|
||
void GameHandler::requestGuildRoster() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot request guild roster: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildRosterPacket::build();
|
||
socket->send(packet);
|
||
addSystemChatMessage("Requesting guild roster...");
|
||
LOG_INFO("Requested guild roster");
|
||
}
|
||
|
||
void GameHandler::setGuildMotd(const std::string& motd) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot set guild MOTD: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildMotdPacket::build(motd);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Guild MOTD updated.");
|
||
LOG_INFO("Set guild MOTD: ", motd);
|
||
}
|
||
|
||
void GameHandler::promoteGuildMember(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot promote guild member: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildPromotePacket::build(playerName);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Promoting " + playerName + "...");
|
||
LOG_INFO("Promoting guild member: ", playerName);
|
||
}
|
||
|
||
void GameHandler::demoteGuildMember(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot demote guild member: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildDemotePacket::build(playerName);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Demoting " + playerName + "...");
|
||
LOG_INFO("Demoting guild member: ", playerName);
|
||
}
|
||
|
||
void GameHandler::leaveGuild() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot leave guild: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildLeavePacket::build();
|
||
socket->send(packet);
|
||
addSystemChatMessage("Leaving guild...");
|
||
LOG_INFO("Leaving guild");
|
||
}
|
||
|
||
void GameHandler::inviteToGuild(const std::string& playerName) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot invite to guild: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (playerName.empty()) {
|
||
addSystemChatMessage("You must specify a player name.");
|
||
return;
|
||
}
|
||
|
||
auto packet = GuildInvitePacket::build(playerName);
|
||
socket->send(packet);
|
||
addSystemChatMessage("Inviting " + playerName + " to guild...");
|
||
LOG_INFO("Inviting to guild: ", playerName);
|
||
}
|
||
|
||
void GameHandler::initiateReadyCheck() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot initiate ready check: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
if (!isInGroup()) {
|
||
addSystemChatMessage("You must be in a group to initiate a ready check.");
|
||
return;
|
||
}
|
||
|
||
auto packet = ReadyCheckPacket::build();
|
||
socket->send(packet);
|
||
addSystemChatMessage("Ready check initiated.");
|
||
LOG_INFO("Initiated ready check");
|
||
}
|
||
|
||
void GameHandler::respondToReadyCheck(bool ready) {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot respond to ready check: not in world or not connected");
|
||
return;
|
||
}
|
||
|
||
auto packet = ReadyCheckConfirmPacket::build(ready);
|
||
socket->send(packet);
|
||
addSystemChatMessage(ready ? "You are ready." : "You are not ready.");
|
||
LOG_INFO("Responded to ready check: ", ready ? "ready" : "not ready");
|
||
}
|
||
|
||
void GameHandler::acceptDuel() {
|
||
if (!pendingDuelRequest_ || state != WorldState::IN_WORLD || !socket) return;
|
||
pendingDuelRequest_ = false;
|
||
auto pkt = DuelAcceptPacket::build();
|
||
socket->send(pkt);
|
||
addSystemChatMessage("You accept the duel.");
|
||
LOG_INFO("Accepted duel from guid=0x", std::hex, duelChallengerGuid_, std::dec);
|
||
}
|
||
|
||
void GameHandler::forfeitDuel() {
|
||
if (state != WorldState::IN_WORLD || !socket) {
|
||
LOG_WARNING("Cannot forfeit duel: not in world or not connected");
|
||
return;
|
||
}
|
||
pendingDuelRequest_ = false; // cancel request if still pending
|
||
auto packet = DuelCancelPacket::build();
|
||
socket->send(packet);
|
||
addSystemChatMessage("You have forfeited the duel.");
|
||
LOG_INFO("Forfeited duel");
|
||
}
|
||
|
||
void GameHandler::handleDuelRequested(network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 16) {
|
||
packet.setReadPos(packet.getSize());
|
||
return;
|
||
}
|
||
duelChallengerGuid_ = packet.readUInt64();
|
||
duelFlagGuid_ = packet.readUInt64();
|
||
|
||
// Resolve challenger name from entity list
|
||
duelChallengerName_.clear();
|
||
auto entity = entityManager.getEntity(duelChallengerGuid_);
|
||
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
||
duelChallengerName_ = unit->getName();
|
||
}
|
||
if (duelChallengerName_.empty()) {
|
||
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<unsigned long long>(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_);
|
||
fireAddonEvent("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<int>(started));
|
||
fireAddonEvent("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<int>(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<uint8_t>(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::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||
if (repopPending_ && now - static_cast<int64_t>(lastRepopRequestMs_) < 1000) {
|
||
return;
|
||
}
|
||
auto packet = RepopRequestPacket::build();
|
||
socket->send(packet);
|
||
// 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<uint64_t>(now);
|
||
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)");
|
||
// Query server for authoritative corpse position (response updates corpseX_/Y_/Z_)
|
||
network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY));
|
||
socket->send(cq);
|
||
}
|
||
}
|
||
|
||
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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
if (nowMs >= corpseReclaimAvailableMs_) return 0.0f;
|
||
return static_cast<float>(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<Entity>& e) -> bool {
|
||
if (!e) return false;
|
||
const uint64_t guid = e->getGuid();
|
||
auto* unit = dynamic_cast<Unit*>(e.get());
|
||
if (!unit) return false; // Not a unit (shouldn't happen after type filter)
|
||
if (unit->getHealth() == 0) {
|
||
// 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<EntityDist> sortable;
|
||
|
||
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
||
auto t = entity->getType();
|
||
if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue;
|
||
if (guid == playerGuid) continue;
|
||
if (!isValidTabTarget(entity)) continue; // Skip dead / non-hostile
|
||
float dx = entity->getX() - playerX;
|
||
float dy = entity->getY() - playerY;
|
||
float dz = entity->getZ() - playerZ;
|
||
sortable.push_back({guid, std::sqrt(dx*dx + dy*dy + dz*dz)});
|
||
}
|
||
|
||
std::sort(sortable.begin(), sortable.end(),
|
||
[](const EntityDist& a, const EntityDist& b) { return a.distance < b.distance; });
|
||
|
||
for (const auto& ed : sortable) {
|
||
tabCycleList.push_back(ed.guid);
|
||
}
|
||
tabCycleStale = false;
|
||
}
|
||
|
||
if (tabCycleList.empty()) {
|
||
clearTarget();
|
||
return;
|
||
}
|
||
|
||
// Advance through the cycle, skipping any entry that has since died or
|
||
// turned friendly (e.g. NPC killed between two tab presses).
|
||
int tries = static_cast<int>(tabCycleList.size());
|
||
while (tries-- > 0) {
|
||
tabCycleIndex = (tabCycleIndex + 1) % static_cast<int>(tabCycleList.size());
|
||
uint64_t guid = tabCycleList[tabCycleIndex];
|
||
auto entity = entityManager.getEntity(guid);
|
||
if (isValidTabTarget(entity)) {
|
||
setTarget(guid);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// All cached entries are stale — clear target and force a fresh rebuild next time.
|
||
tabCycleStale = true;
|
||
clearTarget();
|
||
}
|
||
|
||
void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
|
||
chatHistory.push_back(msg);
|
||
if (chatHistory.size() > maxChatHistory) {
|
||
chatHistory.pop_front();
|
||
}
|
||
if (addonChatCallback_) addonChatCallback_(msg);
|
||
|
||
// Fire CHAT_MSG_* for local echoes (player's own messages, system messages)
|
||
// so Lua chat frame addons display them.
|
||
if (addonEventCallback_) {
|
||
std::string eventName = "CHAT_MSG_";
|
||
eventName += getChatTypeString(msg.type);
|
||
const Character* ac = getActiveCharacter();
|
||
std::string senderName = msg.senderName.empty()
|
||
? (ac ? ac->name : std::string{}) : msg.senderName;
|
||
char guidBuf[32];
|
||
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX",
|
||
(unsigned long long)(msg.senderGuid != 0 ? msg.senderGuid : playerGuid));
|
||
fireAddonEvent(eventName, {
|
||
msg.message, senderName,
|
||
std::to_string(static_cast<int>(msg.language)),
|
||
msg.channelName, senderName, "", "0", "0", "", "0", "0", guidBuf
|
||
});
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 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<Player>(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=", static_cast<int>(data.found), " name='", data.name, "'",
|
||
" race=", static_cast<int>(data.race), " class=", static_cast<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<Player>(entity);
|
||
player->setName(data.name);
|
||
}
|
||
|
||
// Backfill chat history entries that arrived before we knew the name.
|
||
for (auto& msg : chatHistory) {
|
||
if (msg.senderGuid == data.guid && msg.senderName.empty()) {
|
||
msg.senderName = data.name;
|
||
}
|
||
}
|
||
|
||
// Backfill mail inbox sender names
|
||
for (auto& mail : mailInbox_) {
|
||
if (mail.messageType == 0 && mail.senderGuid == data.guid) {
|
||
mail.senderName = data.name;
|
||
}
|
||
}
|
||
|
||
// 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())
|
||
fireAddonEvent("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<Unit>(entity);
|
||
if (unit->getEntry() == data.entry) {
|
||
unit->setName(data.name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// GameObject Query
|
||
// ============================================================
|
||
|
||
void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) {
|
||
GameObjectQueryResponseData data;
|
||
bool ok = packetParsers_ ? packetParsers_->parseGameObjectQueryResponse(packet, data)
|
||
: GameObjectQueryResponseParser::parse(packet, data);
|
||
if (!ok) return;
|
||
|
||
pendingGameObjectQueries_.erase(data.entry);
|
||
|
||
if (data.isValid()) {
|
||
gameObjectInfoCache_[data.entry] = data;
|
||
// Update all gameobject entities with this entry
|
||
for (auto& [guid, entity] : entityManager.getEntities()) {
|
||
if (entity->getType() == ObjectType::GAMEOBJECT) {
|
||
auto go = std::static_pointer_cast<GameObject>(entity);
|
||
if (go->getEntry() == data.entry) {
|
||
go->setName(data.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
// MO_TRANSPORT (type 15): assign TaxiPathNode path if available
|
||
if (data.type == 15 && data.hasData && data.data[0] != 0 && transportManager_) {
|
||
uint32_t taxiPathId = data.data[0];
|
||
if (transportManager_->hasTaxiPath(taxiPathId)) {
|
||
if (transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) {
|
||
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId);
|
||
}
|
||
} else {
|
||
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId,
|
||
" not found in TaxiPathNode.dbc");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameHandler::handleGameObjectPageText(network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
uint64_t guid = packet.readUInt64();
|
||
auto entity = entityManager.getEntity(guid);
|
||
if (!entity || entity->getType() != ObjectType::GAMEOBJECT) return;
|
||
|
||
auto go = std::static_pointer_cast<GameObject>(entity);
|
||
uint32_t entry = go->getEntry();
|
||
if (entry == 0) return;
|
||
|
||
auto cacheIt = gameObjectInfoCache_.find(entry);
|
||
if (cacheIt == gameObjectInfoCache_.end()) {
|
||
queryGameObjectInfo(entry, guid);
|
||
return;
|
||
}
|
||
|
||
const GameObjectQueryResponseData& info = cacheIt->second;
|
||
uint32_t pageId = 0;
|
||
// AzerothCore layout:
|
||
// type 9 (TEXT): data[0]=pageID
|
||
// type 10 (GOOBER): data[7]=pageId
|
||
if (info.type == 9) pageId = info.data[0];
|
||
else if (info.type == 10) pageId = info.data[7];
|
||
|
||
if (pageId != 0 && socket && state == WorldState::IN_WORLD) {
|
||
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<uint32_t, 19> displayIds{};
|
||
std::array<uint8_t, 19> invTypes{};
|
||
for (int s = 0; s < 19; s++) {
|
||
uint32_t entry = entries[s];
|
||
if (entry == 0) continue;
|
||
auto infoIt = itemInfoCache_.find(entry);
|
||
if (infoIt == itemInfoCache_.end()) continue;
|
||
displayIds[s] = infoIt->second.displayInfoId;
|
||
invTypes[s] = static_cast<uint8_t>(infoIt->second.inventoryType);
|
||
}
|
||
playerEquipmentCallback_(guid, displayIds, invTypes);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameHandler::handleInspectResults(network::Packet& packet) {
|
||
// SMSG_TALENTS_INFO (0x3F4) format:
|
||
// uint8 talentType: 0 = own talents (sent on login/respec), 1 = inspect result
|
||
// If type==1: PackedGUID of inspected player
|
||
// Then: uint32 unspentTalents, uint8 talentGroupCount, uint8 activeTalentGroup
|
||
// Per talent group: uint8 talentCount, [talentId(u32) + rank(u8)]..., uint8 glyphCount, [glyphId(u16)]...
|
||
if (packet.getSize() - packet.getReadPos() < 1) return;
|
||
|
||
uint8_t talentType = packet.readUInt8();
|
||
|
||
if (talentType == 0) {
|
||
// Own talent info (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<uint8_t>(
|
||
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=", static_cast<int>(talentGroupCount), " active=", static_cast<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<Player>(entity);
|
||
if (player && !player->getName().empty()) name = player->getName();
|
||
}
|
||
addSystemChatMessage("Inspecting " + name + " (no talent data available).");
|
||
return;
|
||
}
|
||
|
||
uint32_t unspentTalents = packet.readUInt32();
|
||
uint8_t talentGroupCount = packet.readUInt8();
|
||
uint8_t activeTalentGroup = packet.readUInt8();
|
||
|
||
// Resolve player name
|
||
auto entity = entityManager.getEntity(guid);
|
||
std::string playerName = "Target";
|
||
if (entity) {
|
||
auto player = std::dynamic_pointer_cast<Player>(entity);
|
||
if (player && !player->getName().empty()) playerName = player->getName();
|
||
}
|
||
|
||
// Parse talent groups
|
||
uint32_t totalTalents = 0;
|
||
for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) {
|
||
bytesLeft = packet.getSize() - packet.getReadPos();
|
||
if (bytesLeft < 1) break;
|
||
|
||
uint8_t talentCount = packet.readUInt8();
|
||
for (uint8_t t = 0; t < talentCount; ++t) {
|
||
bytesLeft = packet.getSize() - packet.getReadPos();
|
||
if (bytesLeft < 5) break;
|
||
packet.readUInt32(); // talentId
|
||
packet.readUInt8(); // rank
|
||
totalTalents++;
|
||
}
|
||
|
||
bytesLeft = packet.getSize() - packet.getReadPos();
|
||
if (bytesLeft < 1) break;
|
||
uint8_t glyphCount = packet.readUInt8();
|
||
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
|
||
bytesLeft = packet.getSize() - packet.getReadPos();
|
||
if (bytesLeft < 2) break;
|
||
packet.readUInt16(); // glyphId
|
||
}
|
||
}
|
||
|
||
// Parse enchantment slot mask + enchant IDs
|
||
std::array<uint16_t, 19> 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, ", static_cast<int>(talentGroupCount), " specs");
|
||
if (addonEventCallback_) {
|
||
char guidBuf[32];
|
||
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid);
|
||
fireAddonEvent("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<uint16_t, uint32_t>& fields) {
|
||
if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return;
|
||
if (fields.empty()) return;
|
||
|
||
std::vector<uint16_t> matchingPairs;
|
||
matchingPairs.reserve(32);
|
||
|
||
for (const auto& [idx, low] : fields) {
|
||
if ((idx % 2) != 0) continue;
|
||
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
|
||
if (itHigh == fields.end()) continue;
|
||
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
|
||
if (guid == 0) continue;
|
||
// Primary signal: GUID pairs that match spawned ITEM objects.
|
||
if (!onlineItems_.empty() && onlineItems_.count(guid)) {
|
||
matchingPairs.push_back(idx);
|
||
}
|
||
}
|
||
|
||
// Fallback signal (when ITEM objects haven't been seen yet):
|
||
// collect any plausible non-zero GUID pairs and derive a base by density.
|
||
if (matchingPairs.empty()) {
|
||
for (const auto& [idx, low] : fields) {
|
||
if ((idx % 2) != 0) continue;
|
||
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
|
||
if (itHigh == fields.end()) continue;
|
||
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
|
||
if (guid == 0) continue;
|
||
// Heuristic: item GUIDs tend to be non-trivial and change often; ignore tiny values.
|
||
if (guid < 0x10000ull) continue;
|
||
matchingPairs.push_back(idx);
|
||
}
|
||
}
|
||
|
||
if (matchingPairs.empty()) return;
|
||
std::sort(matchingPairs.begin(), matchingPairs.end());
|
||
|
||
if (invSlotBase_ < 0) {
|
||
// The lowest matching field is the first EQUIPPED slot (not necessarily HEAD).
|
||
// With 2+ matches we can derive the true base: all matches must be at
|
||
// even offsets from the base, spaced 2 fields per slot.
|
||
const int knownBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
|
||
constexpr int slotStride = 2;
|
||
bool allAlign = true;
|
||
for (uint16_t p : matchingPairs) {
|
||
if (p < knownBase || (p - knownBase) % slotStride != 0) {
|
||
allAlign = false;
|
||
break;
|
||
}
|
||
}
|
||
if (allAlign) {
|
||
invSlotBase_ = knownBase;
|
||
} else {
|
||
// Fallback: if we have 2+ matches, derive base from their spacing
|
||
if (matchingPairs.size() >= 2) {
|
||
uint16_t lo = matchingPairs[0];
|
||
// lo must be base + 2*slotN, and slotN is 0..22
|
||
// Try each possible slot for 'lo' and see if all others also land on valid slots
|
||
for (int s = 0; s <= 22; s++) {
|
||
int candidate = lo - s * slotStride;
|
||
if (candidate < 0) break;
|
||
bool ok = true;
|
||
for (uint16_t p : matchingPairs) {
|
||
int off = p - candidate;
|
||
if (off < 0 || off % slotStride != 0 || off / slotStride > 22) {
|
||
ok = false;
|
||
break;
|
||
}
|
||
}
|
||
if (ok) {
|
||
invSlotBase_ = candidate;
|
||
break;
|
||
}
|
||
}
|
||
if (invSlotBase_ < 0) invSlotBase_ = knownBase;
|
||
} else {
|
||
invSlotBase_ = knownBase;
|
||
}
|
||
}
|
||
packSlotBase_ = invSlotBase_ + (game::Inventory::NUM_EQUIP_SLOTS * 2);
|
||
LOG_INFO("Detected inventory field base: equip=", invSlotBase_,
|
||
" pack=", packSlotBase_);
|
||
}
|
||
}
|
||
|
||
bool GameHandler::applyInventoryFields(const std::map<uint16_t, uint32_t>& fields) {
|
||
bool slotsChanged = false;
|
||
int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
|
||
int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_PACK_SLOT_1));
|
||
int bankBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1));
|
||
int bankBagBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1));
|
||
|
||
// Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7).
|
||
if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) {
|
||
effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28);
|
||
effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7;
|
||
}
|
||
|
||
int keyringBase = static_cast<int>(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<int>(equipSlotGuids_.size())) {
|
||
uint64_t& guid = equipSlotGuids_[slotIndex];
|
||
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
||
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
||
slotsChanged = true;
|
||
}
|
||
} else if (key >= packBase && key <= packBase + (game::Inventory::BACKPACK_SLOTS * 2 - 1)) {
|
||
int slotIndex = (key - packBase) / 2;
|
||
bool isLow = ((key - packBase) % 2 == 0);
|
||
if (slotIndex < static_cast<int>(backpackSlotGuids_.size())) {
|
||
uint64_t& guid = backpackSlotGuids_[slotIndex];
|
||
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
||
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
||
slotsChanged = true;
|
||
}
|
||
} 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<int>(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<uint16_t>(bankBase) &&
|
||
key <= static_cast<uint16_t>(bankBase) + (effectiveBankSlots_ * 2 - 1)) {
|
||
int slotIndex = (key - bankBase) / 2;
|
||
bool isLow = ((key - bankBase) % 2 == 0);
|
||
if (slotIndex < static_cast<int>(bankSlotGuids_.size())) {
|
||
uint64_t& guid = bankSlotGuids_[slotIndex];
|
||
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
||
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
||
slotsChanged = true;
|
||
}
|
||
}
|
||
|
||
// Bank bag slots starting at PLAYER_FIELD_BANKBAG_SLOT_1
|
||
if (bankBagBase != 0xFFFF && key >= static_cast<uint16_t>(bankBagBase) &&
|
||
key <= static_cast<uint16_t>(bankBagBase) + (effectiveBankBagSlots_ * 2 - 1)) {
|
||
int slotIndex = (key - bankBagBase) / 2;
|
||
bool isLow = ((key - bankBagBase) % 2 == 0);
|
||
if (slotIndex < static_cast<int>(bankBagSlotGuids_.size())) {
|
||
uint64_t& guid = bankBagSlotGuids_[slotIndex];
|
||
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
||
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
||
slotsChanged = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return slotsChanged;
|
||
}
|
||
|
||
void GameHandler::extractContainerFields(uint64_t containerGuid, const std::map<uint16_t, uint32_t>& fields) {
|
||
const uint16_t numSlotsIdx = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
|
||
const uint16_t slot1Idx = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
|
||
if (numSlotsIdx == 0xFFFF || slot1Idx == 0xFFFF) return;
|
||
|
||
auto& info = containerContents_[containerGuid];
|
||
|
||
// Read number of slots
|
||
auto numIt = fields.find(numSlotsIdx);
|
||
if (numIt != fields.end()) {
|
||
info.numSlots = std::min(numIt->second, 36u);
|
||
}
|
||
|
||
// Read slot GUIDs (each is 2 uint32 fields: lo + hi)
|
||
for (const auto& [key, val] : fields) {
|
||
if (key < slot1Idx) continue;
|
||
int offset = key - slot1Idx;
|
||
int slotIndex = offset / 2;
|
||
if (slotIndex >= 36) continue;
|
||
bool isLow = (offset % 2 == 0);
|
||
uint64_t& guid = info.slotGuids[slotIndex];
|
||
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
||
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
||
}
|
||
}
|
||
|
||
void GameHandler::rebuildOnlineInventory() {
|
||
|
||
uint8_t savedBankBagSlots = inventory.getPurchasedBankBagSlots();
|
||
inventory = Inventory();
|
||
inventory.setPurchasedBankBagSlots(savedBankBagSlots);
|
||
|
||
// Equipment slots
|
||
for (int i = 0; i < 23; i++) {
|
||
uint64_t guid = equipSlotGuids_[i];
|
||
if (guid == 0) continue;
|
||
|
||
auto itemIt = onlineItems_.find(guid);
|
||
if (itemIt == onlineItems_.end()) continue;
|
||
|
||
ItemDef def;
|
||
def.itemId = itemIt->second.entry;
|
||
def.stackCount = itemIt->second.stackCount;
|
||
def.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<ItemQuality>(infoIt->second.quality);
|
||
def.inventoryType = infoIt->second.inventoryType;
|
||
def.maxStack = std::max(1, infoIt->second.maxStack);
|
||
def.displayInfoId = infoIt->second.displayInfoId;
|
||
def.subclassName = infoIt->second.subclassName;
|
||
def.damageMin = infoIt->second.damageMin;
|
||
def.damageMax = infoIt->second.damageMax;
|
||
def.delayMs = infoIt->second.delayMs;
|
||
def.armor = infoIt->second.armor;
|
||
def.stamina = infoIt->second.stamina;
|
||
def.strength = infoIt->second.strength;
|
||
def.agility = infoIt->second.agility;
|
||
def.intellect = infoIt->second.intellect;
|
||
def.spirit = infoIt->second.spirit;
|
||
def.sellPrice = infoIt->second.sellPrice;
|
||
def.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<EquipSlot>(i), def);
|
||
}
|
||
|
||
// Backpack slots
|
||
for (int i = 0; i < 16; i++) {
|
||
uint64_t guid = backpackSlotGuids_[i];
|
||
if (guid == 0) continue;
|
||
|
||
auto itemIt = onlineItems_.find(guid);
|
||
if (itemIt == onlineItems_.end()) continue;
|
||
|
||
ItemDef def;
|
||
def.itemId = itemIt->second.entry;
|
||
def.stackCount = itemIt->second.stackCount;
|
||
def.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<ItemQuality>(infoIt->second.quality);
|
||
def.inventoryType = infoIt->second.inventoryType;
|
||
def.maxStack = std::max(1, infoIt->second.maxStack);
|
||
def.displayInfoId = infoIt->second.displayInfoId;
|
||
def.subclassName = infoIt->second.subclassName;
|
||
def.damageMin = infoIt->second.damageMin;
|
||
def.damageMax = infoIt->second.damageMax;
|
||
def.delayMs = infoIt->second.delayMs;
|
||
def.armor = infoIt->second.armor;
|
||
def.stamina = infoIt->second.stamina;
|
||
def.strength = infoIt->second.strength;
|
||
def.agility = infoIt->second.agility;
|
||
def.intellect = infoIt->second.intellect;
|
||
def.spirit = infoIt->second.spirit;
|
||
def.sellPrice = infoIt->second.sellPrice;
|
||
def.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<ItemQuality>(infoIt->second.quality);
|
||
def.inventoryType = infoIt->second.inventoryType;
|
||
def.maxStack = std::max(1, infoIt->second.maxStack);
|
||
def.displayInfoId = infoIt->second.displayInfoId;
|
||
def.subclassName = infoIt->second.subclassName;
|
||
def.damageMin = infoIt->second.damageMin;
|
||
def.damageMax = infoIt->second.damageMax;
|
||
def.delayMs = infoIt->second.delayMs;
|
||
def.armor = infoIt->second.armor;
|
||
def.stamina = infoIt->second.stamina;
|
||
def.strength = infoIt->second.strength;
|
||
def.agility = infoIt->second.agility;
|
||
def.intellect = infoIt->second.intellect;
|
||
def.spirit = infoIt->second.spirit;
|
||
def.sellPrice = infoIt->second.sellPrice;
|
||
def.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<int>(contIt->second.numSlots);
|
||
}
|
||
if (numSlots <= 0) {
|
||
auto bagItemIt = onlineItems_.find(bagGuid);
|
||
if (bagItemIt != onlineItems_.end()) {
|
||
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
|
||
if (bagInfoIt != itemInfoCache_.end()) {
|
||
numSlots = bagInfoIt->second.containerSlots;
|
||
}
|
||
}
|
||
}
|
||
if (numSlots <= 0) continue;
|
||
|
||
// Set the bag size in the inventory bag data
|
||
inventory.setBagSize(bagIdx, numSlots);
|
||
|
||
// Also set bagSlots on the equipped bag item (for UI display)
|
||
auto& bagEquipSlot = inventory.getEquipSlot(static_cast<EquipSlot>(19 + bagIdx));
|
||
if (!bagEquipSlot.empty()) {
|
||
ItemDef bagDef = bagEquipSlot.item;
|
||
bagDef.bagSlots = numSlots;
|
||
inventory.setEquipSlot(static_cast<EquipSlot>(19 + bagIdx), bagDef);
|
||
}
|
||
|
||
// Populate bag slot items
|
||
if (contIt == containerContents_.end()) continue;
|
||
const auto& container = contIt->second;
|
||
for (int s = 0; s < numSlots && s < 36; s++) {
|
||
uint64_t itemGuid = container.slotGuids[s];
|
||
if (itemGuid == 0) continue;
|
||
|
||
auto itemIt = onlineItems_.find(itemGuid);
|
||
if (itemIt == onlineItems_.end()) continue;
|
||
|
||
ItemDef def;
|
||
def.itemId = itemIt->second.entry;
|
||
def.stackCount = itemIt->second.stackCount;
|
||
def.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<ItemQuality>(infoIt->second.quality);
|
||
def.inventoryType = infoIt->second.inventoryType;
|
||
def.maxStack = std::max(1, infoIt->second.maxStack);
|
||
def.displayInfoId = infoIt->second.displayInfoId;
|
||
def.subclassName = infoIt->second.subclassName;
|
||
def.damageMin = infoIt->second.damageMin;
|
||
def.damageMax = infoIt->second.damageMax;
|
||
def.delayMs = infoIt->second.delayMs;
|
||
def.armor = infoIt->second.armor;
|
||
def.stamina = infoIt->second.stamina;
|
||
def.strength = infoIt->second.strength;
|
||
def.agility = infoIt->second.agility;
|
||
def.intellect = infoIt->second.intellect;
|
||
def.spirit = infoIt->second.spirit;
|
||
def.sellPrice = infoIt->second.sellPrice;
|
||
def.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<ItemQuality>(infoIt->second.quality);
|
||
def.inventoryType = infoIt->second.inventoryType;
|
||
def.maxStack = std::max(1, infoIt->second.maxStack);
|
||
def.displayInfoId = infoIt->second.displayInfoId;
|
||
def.subclassName = infoIt->second.subclassName;
|
||
def.damageMin = infoIt->second.damageMin;
|
||
def.damageMax = infoIt->second.damageMax;
|
||
def.delayMs = infoIt->second.delayMs;
|
||
def.armor = infoIt->second.armor;
|
||
def.stamina = infoIt->second.stamina;
|
||
def.strength = infoIt->second.strength;
|
||
def.agility = infoIt->second.agility;
|
||
def.intellect = infoIt->second.intellect;
|
||
def.spirit = infoIt->second.spirit;
|
||
def.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<int>(contIt->second.numSlots);
|
||
}
|
||
|
||
// Populate the bag item itself (for icon/name in the bank bag equip slot)
|
||
auto bagItemIt = onlineItems_.find(bagGuid);
|
||
if (bagItemIt != onlineItems_.end()) {
|
||
if (numSlots <= 0) {
|
||
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
|
||
if (bagInfoIt != itemInfoCache_.end()) {
|
||
numSlots = bagInfoIt->second.containerSlots;
|
||
}
|
||
}
|
||
ItemDef bagDef;
|
||
bagDef.itemId = bagItemIt->second.entry;
|
||
bagDef.stackCount = 1;
|
||
bagDef.inventoryType = 18; // bag
|
||
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
|
||
if (bagInfoIt != itemInfoCache_.end()) {
|
||
bagDef.name = bagInfoIt->second.name;
|
||
bagDef.quality = static_cast<ItemQuality>(bagInfoIt->second.quality);
|
||
bagDef.displayInfoId = bagInfoIt->second.displayInfoId;
|
||
bagDef.bagSlots = bagInfoIt->second.containerSlots;
|
||
} else {
|
||
bagDef.name = "Bag";
|
||
queryItemInfo(bagDef.itemId, bagGuid);
|
||
}
|
||
inventory.setBankBagItem(bagIdx, bagDef);
|
||
}
|
||
if (numSlots <= 0) continue;
|
||
|
||
inventory.setBankBagSize(bagIdx, numSlots);
|
||
|
||
if (contIt == containerContents_.end()) continue;
|
||
const auto& container = contIt->second;
|
||
for (int s = 0; s < numSlots && s < 36; s++) {
|
||
uint64_t itemGuid = container.slotGuids[s];
|
||
if (itemGuid == 0) continue;
|
||
|
||
auto itemIt = onlineItems_.find(itemGuid);
|
||
if (itemIt == onlineItems_.end()) continue;
|
||
|
||
ItemDef def;
|
||
def.itemId = itemIt->second.entry;
|
||
def.stackCount = itemIt->second.stackCount;
|
||
def.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<ItemQuality>(infoIt->second.quality);
|
||
def.inventoryType = infoIt->second.inventoryType;
|
||
def.maxStack = std::max(1, infoIt->second.maxStack);
|
||
def.displayInfoId = infoIt->second.displayInfoId;
|
||
def.subclassName = infoIt->second.subclassName;
|
||
def.damageMin = infoIt->second.damageMin;
|
||
def.damageMax = infoIt->second.damageMax;
|
||
def.delayMs = infoIt->second.delayMs;
|
||
def.armor = infoIt->second.armor;
|
||
def.stamina = infoIt->second.stamina;
|
||
def.strength = infoIt->second.strength;
|
||
def.agility = infoIt->second.agility;
|
||
def.intellect = infoIt->second.intellect;
|
||
def.spirit = infoIt->second.spirit;
|
||
def.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<uint32_t, 19> currentEquipDisplayIds{};
|
||
for (int i = 0; i < 19; i++) {
|
||
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
|
||
if (!slot.empty()) currentEquipDisplayIds[i] = slot.item.displayInfoId;
|
||
}
|
||
if (currentEquipDisplayIds != lastEquipDisplayIds_) {
|
||
lastEquipDisplayIds_ = currentEquipDisplayIds;
|
||
onlineEquipDirty_ = true;
|
||
}
|
||
|
||
LOG_DEBUG("Rebuilt online inventory: equip=", [&](){
|
||
int c = 0; for (auto g : equipSlotGuids_) if (g) c++; return c;
|
||
}(), " backpack=", [&](){
|
||
int c = 0; for (auto g : backpackSlotGuids_) if (g) c++; return c;
|
||
}(), " 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<uint32_t, 19> equipEntries{};
|
||
int nonZero = 0;
|
||
// Prefer authoritative equipped item entry IDs derived from item objects (onlineItems_),
|
||
// because Inventory::ItemDef may not be populated yet if templates haven't been queried.
|
||
for (int i = 0; i < 19; i++) {
|
||
uint64_t itemGuid = equipSlotGuids_[i];
|
||
if (itemGuid != 0) {
|
||
auto it = onlineItems_.find(itemGuid);
|
||
if (it != onlineItems_.end() && it->second.entry != 0) {
|
||
equipEntries[i] = it->second.entry;
|
||
}
|
||
}
|
||
if (equipEntries[i] == 0) {
|
||
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
|
||
equipEntries[i] = slot.empty() ? 0u : slot.item.itemId;
|
||
}
|
||
if (equipEntries[i] != 0) nonZero++;
|
||
}
|
||
if (nonZero < 2) return;
|
||
|
||
const uint16_t maxKey = lastPlayerFields_.rbegin()->first;
|
||
int bestBase = -1;
|
||
int bestStride = 0;
|
||
int bestMatches = 0;
|
||
int bestMismatches = 9999;
|
||
int bestScore = -999999;
|
||
|
||
const int strides[] = {2, 3, 4, 1};
|
||
for (int stride : strides) {
|
||
for (const auto& [baseIdxU16, _v] : lastPlayerFields_) {
|
||
const int base = static_cast<int>(baseIdxU16);
|
||
if (base + 18 * stride > static_cast<int>(maxKey)) continue;
|
||
|
||
int matches = 0;
|
||
int mismatches = 0;
|
||
for (int s = 0; s < 19; s++) {
|
||
uint32_t want = equipEntries[s];
|
||
if (want == 0) continue;
|
||
const uint16_t idx = static_cast<uint16_t>(base + s * stride);
|
||
auto it = lastPlayerFields_.find(idx);
|
||
if (it == lastPlayerFields_.end()) continue;
|
||
if (it->second == want) {
|
||
matches++;
|
||
} else if (it->second != 0) {
|
||
mismatches++;
|
||
}
|
||
}
|
||
|
||
int score = matches * 2 - mismatches * 3;
|
||
if (score > bestScore ||
|
||
(score == bestScore && matches > bestMatches) ||
|
||
(score == bestScore && matches == bestMatches && mismatches < bestMismatches) ||
|
||
(score == bestScore && matches == bestMatches && mismatches == bestMismatches && base < bestBase)) {
|
||
bestScore = score;
|
||
bestMatches = matches;
|
||
bestMismatches = mismatches;
|
||
bestBase = base;
|
||
bestStride = stride;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bestMatches >= 2 && bestBase >= 0 && bestStride > 0 && bestMismatches <= 1) {
|
||
visibleItemEntryBase_ = bestBase;
|
||
visibleItemStride_ = bestStride;
|
||
visibleItemLayoutVerified_ = true;
|
||
LOG_INFO("Detected PLAYER_VISIBLE_ITEM entry layout: base=", visibleItemEntryBase_,
|
||
" stride=", visibleItemStride_, " (matches=", bestMatches,
|
||
" mismatches=", bestMismatches, " score=", bestScore, ")");
|
||
|
||
// Backfill existing player entities already in view.
|
||
for (const auto& [guid, ent] : entityManager.getEntities()) {
|
||
if (!ent || ent->getType() != ObjectType::PLAYER) continue;
|
||
if (guid == playerGuid) continue;
|
||
updateOtherPlayerVisibleItems(guid, ent->getFields());
|
||
}
|
||
}
|
||
// If heuristic didn't find a match, keep using the default WotLK layout (base=284, stride=2).
|
||
}
|
||
|
||
void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map<uint16_t, uint32_t>& fields) {
|
||
if (guid == 0 || guid == playerGuid) return;
|
||
if (visibleItemEntryBase_ < 0 || visibleItemStride_ <= 0) {
|
||
// Layout not detected yet — queue this player for inspect as fallback.
|
||
if (socket && state == WorldState::IN_WORLD) {
|
||
pendingAutoInspect_.insert(guid);
|
||
LOG_DEBUG("Queued player 0x", std::hex, guid, std::dec, " for auto-inspect (layout not detected)");
|
||
}
|
||
return;
|
||
}
|
||
|
||
std::array<uint32_t, 19> newEntries{};
|
||
for (int s = 0; s < 19; s++) {
|
||
uint16_t idx = static_cast<uint16_t>(visibleItemEntryBase_ + s * visibleItemStride_);
|
||
auto it = fields.find(idx);
|
||
if (it != fields.end()) newEntries[s] = it->second;
|
||
}
|
||
|
||
bool changed = false;
|
||
auto& old = otherPlayerVisibleItemEntries_[guid];
|
||
if (old != newEntries) {
|
||
old = newEntries;
|
||
changed = true;
|
||
}
|
||
|
||
// Request item templates for any new visible entries.
|
||
for (uint32_t entry : newEntries) {
|
||
if (entry == 0) continue;
|
||
if (!itemInfoCache_.count(entry) && !pendingItemQueries_.count(entry)) {
|
||
queryItemInfo(entry, 0);
|
||
}
|
||
}
|
||
|
||
// If the server isn't sending visible item fields (all zeros), fall back to inspect.
|
||
bool any = false;
|
||
for (uint32_t e : newEntries) { if (e != 0) { any = true; break; } }
|
||
if (!any && socket && state == WorldState::IN_WORLD) {
|
||
pendingAutoInspect_.insert(guid);
|
||
}
|
||
|
||
if (changed) {
|
||
otherPlayerVisibleDirty_.insert(guid);
|
||
emitOtherPlayerEquipment(guid);
|
||
}
|
||
}
|
||
|
||
void GameHandler::emitOtherPlayerEquipment(uint64_t guid) {
|
||
if (!playerEquipmentCallback_) return;
|
||
auto it = otherPlayerVisibleItemEntries_.find(guid);
|
||
if (it == otherPlayerVisibleItemEntries_.end()) return;
|
||
|
||
std::array<uint32_t, 19> displayIds{};
|
||
std::array<uint8_t, 19> invTypes{};
|
||
bool anyEntry = false;
|
||
|
||
for (int s = 0; s < 19; s++) {
|
||
uint32_t entry = it->second[s];
|
||
if (entry == 0) continue;
|
||
anyEntry = true;
|
||
auto infoIt = itemInfoCache_.find(entry);
|
||
if (infoIt == itemInfoCache_.end()) continue;
|
||
displayIds[s] = infoIt->second.displayInfoId;
|
||
invTypes[s] = static_cast<uint8_t>(infoIt->second.inventoryType);
|
||
}
|
||
|
||
playerEquipmentCallback_(guid, displayIds, invTypes);
|
||
otherPlayerVisibleDirty_.erase(guid);
|
||
|
||
// If we had entries but couldn't resolve any templates, also try inspect as a fallback.
|
||
bool anyResolved = false;
|
||
for (uint32_t did : displayIds) { if (did != 0) { anyResolved = true; break; } }
|
||
if (anyEntry && !anyResolved) {
|
||
pendingAutoInspect_.insert(guid);
|
||
}
|
||
}
|
||
|
||
void GameHandler::emitAllOtherPlayerEquipment() {
|
||
if (!playerEquipmentCallback_) return;
|
||
for (const auto& [guid, _] : otherPlayerVisibleItemEntries_) {
|
||
emitOtherPlayerEquipment(guid);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Phase 2: Combat
|
||
// ============================================================
|
||
|
||
void GameHandler::startAutoAttack(uint64_t targetGuid) {
|
||
// Can't attack yourself
|
||
if (targetGuid == playerGuid) return;
|
||
if (targetGuid == 0) return;
|
||
|
||
// Dismount when entering combat
|
||
if (isMounted()) {
|
||
dismount();
|
||
}
|
||
|
||
// Client-side melee range gate to avoid starting "swing forever" loops when
|
||
// target is already clearly out of range.
|
||
if (auto target = entityManager.getEntity(targetGuid)) {
|
||
float dx = movementInfo.x - target->getLatestX();
|
||
float dy = movementInfo.y - target->getLatestY();
|
||
float dz = movementInfo.z - target->getLatestZ();
|
||
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||
if (dist3d > 8.0f) {
|
||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||
addSystemChatMessage("Target is too far away.");
|
||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
autoAttackRequested_ = true;
|
||
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");
|
||
fireAddonEvent("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<float> 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<double>(std::time(nullptr)));
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("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<uint32_t>(MovementFlags::ROOT);
|
||
} else {
|
||
movementInfo.flags &= ~static_cast<uint32_t>(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<uint32_t, const char*> 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;
|
||
}
|
||
fireAddonEvent("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<uint32_t, const char*> 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<LfgState>(state);
|
||
LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast<int>(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<int>(result),
|
||
" state=", static_cast<int>(state));
|
||
}
|
||
}
|
||
|
||
void GameHandler::handleLfgQueueStatus(network::Packet& packet) {
|
||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||
if (remaining < 4 + 6 * 4 + 1 + 4) return; // dungeonId + 6 int32 + uint8 + uint32
|
||
|
||
lfgDungeonId_ = packet.readUInt32();
|
||
int32_t avgWait = static_cast<int32_t>(packet.readUInt32());
|
||
int32_t waitTime = static_cast<int32_t>(packet.readUInt32());
|
||
/*int32_t waitTimeTank =*/ static_cast<int32_t>(packet.readUInt32());
|
||
/*int32_t waitTimeHealer =*/ static_cast<int32_t>(packet.readUInt32());
|
||
/*int32_t waitTimeDps =*/ static_cast<int32_t>(packet.readUInt32());
|
||
/*uint8_t queuedByNeeded=*/ packet.readUInt8();
|
||
lfgTimeInQueueMs_ = packet.readUInt32();
|
||
|
||
lfgAvgWaitSec_ = (waitTime >= 0) ? (waitTime / 1000) : (avgWait / 1000);
|
||
lfgState_ = LfgState::Queued;
|
||
|
||
LOG_INFO("SMSG_LFG_QUEUE_STATUS: dungeonId=", lfgDungeonId_,
|
||
" avgWait=", avgWait, "ms waitTime=", waitTime, "ms");
|
||
}
|
||
|
||
void GameHandler::handleLfgProposalUpdate(network::Packet& packet) {
|
||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||
if (remaining < 16) return;
|
||
|
||
uint32_t dungeonId = packet.readUInt32();
|
||
uint32_t proposalId = packet.readUInt32();
|
||
uint32_t proposalState = packet.readUInt32();
|
||
/*uint32_t encounterMask =*/ packet.readUInt32();
|
||
|
||
if (remaining < 17) return;
|
||
/*bool canOverride =*/ packet.readUInt8();
|
||
|
||
lfgDungeonId_ = dungeonId;
|
||
lfgProposalId_ = proposalId;
|
||
|
||
switch (proposalState) {
|
||
case 0:
|
||
lfgState_ = LfgState::Queued;
|
||
lfgProposalId_ = 0;
|
||
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<int>(roleCheckState));
|
||
}
|
||
|
||
void GameHandler::handleLfgUpdatePlayer(network::Packet& packet) {
|
||
// SMSG_LFG_UPDATE_PLAYER and SMSG_LFG_UPDATE_PARTY share the same layout.
|
||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||
if (remaining < 1) return;
|
||
|
||
uint8_t updateType = packet.readUInt8();
|
||
|
||
// LFGUpdateType values that carry no extra payload
|
||
// 0=default, 1=leader_unk1, 4=rolecheck_aborted, 8=removed_from_queue,
|
||
// 9=proposal_failed, 10=proposal_declined, 15=leave_queue, 17=member_offline, 18=group_disband
|
||
bool hasExtra = (updateType != 0 && updateType != 1 && updateType != 15 &&
|
||
updateType != 17 && updateType != 18);
|
||
if (!hasExtra || packet.getSize() - packet.getReadPos() < 3) {
|
||
switch (updateType) {
|
||
case 8: lfgState_ = LfgState::None;
|
||
addSystemChatMessage("Dungeon Finder: Removed from queue."); break;
|
||
case 9: lfgState_ = LfgState::Queued;
|
||
addSystemChatMessage("Dungeon Finder: Proposal failed — re-queuing."); break;
|
||
case 10: lfgState_ = LfgState::Queued;
|
||
addSystemChatMessage("Dungeon Finder: A member declined the proposal."); break;
|
||
case 15: lfgState_ = LfgState::None;
|
||
addSystemChatMessage("Dungeon Finder: Left the queue."); break;
|
||
case 18: lfgState_ = LfgState::None;
|
||
addSystemChatMessage("Dungeon Finder: Your group disbanded."); break;
|
||
default: break;
|
||
}
|
||
LOG_INFO("SMSG_LFG_UPDATE_PLAYER/PARTY: updateType=", static_cast<int>(updateType));
|
||
return;
|
||
}
|
||
|
||
/*bool queued =*/ packet.readUInt8();
|
||
packet.readUInt8(); // unk1
|
||
packet.readUInt8(); // unk2
|
||
|
||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||
uint8_t count = packet.readUInt8();
|
||
for (uint8_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 4; ++i) {
|
||
uint32_t dungeonEntry = packet.readUInt32();
|
||
if (i == 0) lfgDungeonId_ = dungeonEntry;
|
||
}
|
||
}
|
||
|
||
switch (updateType) {
|
||
case 6: lfgState_ = LfgState::Queued;
|
||
addSystemChatMessage("Dungeon Finder: You have joined the queue."); break;
|
||
case 11: lfgState_ = LfgState::Proposal;
|
||
addSystemChatMessage("Dungeon Finder: A group has been found!"); break;
|
||
case 12: lfgState_ = LfgState::Queued;
|
||
addSystemChatMessage("Dungeon Finder: Added to queue."); break;
|
||
case 13: lfgState_ = LfgState::Proposal;
|
||
addSystemChatMessage("Dungeon Finder: Proposal started."); break;
|
||
case 14: lfgState_ = LfgState::InDungeon; break;
|
||
case 16: addSystemChatMessage("Dungeon Finder: Two members are ready."); break;
|
||
default: break;
|
||
}
|
||
LOG_INFO("SMSG_LFG_UPDATE_PLAYER/PARTY: updateType=", static_cast<int>(updateType));
|
||
}
|
||
|
||
void GameHandler::handleLfgPlayerReward(network::Packet& packet) {
|
||
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<int>(reason));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// LFG outgoing packets
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameHandler::lfgJoin(uint32_t dungeonId, uint8_t roles) {
|
||
if (state != WorldState::IN_WORLD || !socket) return;
|
||
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_JOIN));
|
||
pkt.writeUInt8(roles);
|
||
pkt.writeUInt8(0); // needed
|
||
pkt.writeUInt8(0); // unk
|
||
pkt.writeUInt8(1); // 1 dungeon in list
|
||
pkt.writeUInt32(dungeonId);
|
||
pkt.writeString(""); // comment
|
||
|
||
socket->send(pkt);
|
||
LOG_INFO("Sent CMSG_LFG_JOIN: dungeonId=", dungeonId, " roles=", static_cast<int>(roles));
|
||
}
|
||
|
||
void GameHandler::lfgLeave() {
|
||
if (!socket) return;
|
||
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_LEAVE));
|
||
// CMSG_LFG_LEAVE has an LFG identifier block; send zeroes to leave any active queue.
|
||
pkt.writeUInt32(0); // slot
|
||
pkt.writeUInt32(0); // unk
|
||
pkt.writeUInt32(0); // dungeonId
|
||
|
||
socket->send(pkt);
|
||
lfgState_ = LfgState::None;
|
||
LOG_INFO("Sent CMSG_LFG_LEAVE");
|
||
}
|
||
|
||
void GameHandler::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<uint16_t>(wire));
|
||
pkt.writeUInt8(roles);
|
||
socket->send(pkt);
|
||
LOG_INFO("Sent CMSG_LFG_SET_ROLES: roles=", static_cast<int>(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: ", static_cast<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<game::Unit>(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<char>(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=", static_cast<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=", static_cast<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<float>(rawDt);
|
||
// EMA: smooth the interval so single spike packets don't stutter playback.
|
||
auto& smoothed = otherPlayerSmoothedIntervalMs_[moverGuid];
|
||
if (smoothed < 1.0f) smoothed = fDt; // first observation — seed directly
|
||
smoothed = 0.7f * smoothed + 0.3f * fDt;
|
||
// Clamp to sane WoW movement rates: ~10 Hz (100ms) normal, up to 2Hz (500ms) slow
|
||
float clamped = std::max(60.0f, std::min(500.0f, smoothed));
|
||
durationMs = static_cast<uint32_t>(clamped);
|
||
}
|
||
}
|
||
otherPlayerMoveTimeMs_[moverGuid] = info.time;
|
||
|
||
// 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<uint8_t> decompressedStorage;
|
||
const std::vector<uint8_t>* 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<uint32_t>(rawData[0]) |
|
||
(static_cast<uint32_t>(rawData[1]) << 8) |
|
||
(static_cast<uint32_t>(rawData[2]) << 16) |
|
||
(static_cast<uint32_t>(rawData[3]) << 24);
|
||
if (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<uint16_t, 29> 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<uint8_t> 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<CompressedMoveSubPacket> 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<size_t>(subSize)
|
||
: (subSize >= 2 ? static_cast<size_t>(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<uint16_t>(data[pos + 1]) |
|
||
(static_cast<uint16_t>(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<uint16_t> 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<uint8_t>& bytes, std::vector<uint8_t>& stripped) -> bool {
|
||
if (bytes.size() < 3) return false;
|
||
uint8_t subSize = bytes[0];
|
||
if (subSize < 2) return false;
|
||
size_t wrappedLen = static_cast<size_t>(subSize) + 1; // size byte + body
|
||
if (wrappedLen != bytes.size()) return false;
|
||
size_t payloadLen = static_cast<size_t>(subSize) - 2; // opcode(2) stripped
|
||
if (3 + payloadLen > bytes.size()) return false;
|
||
stripped.assign(bytes.begin() + 3, bytes.begin() + 3 + payloadLen);
|
||
return true;
|
||
};
|
||
// Turtle WoW (1.17+) compresses each SMSG_MONSTER_MOVE individually:
|
||
// format: uint32 decompressedSize + zlib data (zlib magic = 0x78 ??)
|
||
const auto& rawData = packet.getData();
|
||
const bool allowTurtleMoveCompression = isActiveExpansion("turtle");
|
||
bool isCompressed = allowTurtleMoveCompression &&
|
||
rawData.size() >= 6 &&
|
||
rawData[4] == 0x78 &&
|
||
(rawData[5] == 0x01 || rawData[5] == 0x9C ||
|
||
rawData[5] == 0xDA || rawData[5] == 0x5E);
|
||
if (isCompressed) {
|
||
uint32_t decompSize = static_cast<uint32_t>(rawData[0]) |
|
||
(static_cast<uint32_t>(rawData[1]) << 8) |
|
||
(static_cast<uint32_t>(rawData[2]) << 16) |
|
||
(static_cast<uint32_t>(rawData[3]) << 24);
|
||
if (decompSize == 0 || decompSize > 65536) {
|
||
LOG_WARNING("SMSG_MONSTER_MOVE: bad decompSize=", decompSize);
|
||
return;
|
||
}
|
||
std::vector<uint8_t> decompressed(decompSize);
|
||
uLongf destLen = decompSize;
|
||
int ret = uncompress(decompressed.data(), &destLen,
|
||
rawData.data() + 4, rawData.size() - 4);
|
||
if (ret != Z_OK) {
|
||
LOG_WARNING("SMSG_MONSTER_MOVE: zlib error ", ret);
|
||
return;
|
||
}
|
||
decompressed.resize(destLen);
|
||
std::vector<uint8_t> 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<uint8_t> 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<float>(M_PI)) diff -= 2.0f * static_cast<float>(M_PI);
|
||
while (diff < -static_cast<float>(M_PI)) diff += 2.0f * static_cast<float>(M_PI);
|
||
if (std::abs(diff) > static_cast<float>(M_PI) * 0.5f) {
|
||
orientation = travelAngle;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Interpolate entity position alongside renderer (so targeting matches visual)
|
||
entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z,
|
||
orientation, data.duration / 1000.0f);
|
||
|
||
// Notify renderer to smoothly move the creature
|
||
if (creatureMoveCallback_) {
|
||
creatureMoveCallback_(data.guid,
|
||
destCanonical.x, destCanonical.y, destCanonical.z,
|
||
data.duration);
|
||
}
|
||
} else if (data.moveType == 1) {
|
||
// Stop at current position
|
||
glm::vec3 posCanonical = core::coords::serverToCanonical(
|
||
glm::vec3(data.x, data.y, data.z));
|
||
entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z,
|
||
entity->getOrientation());
|
||
|
||
if (creatureMoveCallback_) {
|
||
creatureMoveCallback_(data.guid,
|
||
posCanonical.x, posCanonical.y, posCanonical.z, 0);
|
||
}
|
||
} 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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<int32_t>(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<int32_t>(totalAbsorbed), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
||
if (totalResisted > 0)
|
||
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(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<int32_t>(data.damage), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid);
|
||
if (data.absorbed > 0)
|
||
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(data.absorbed), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid);
|
||
if (data.resisted > 0)
|
||
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(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<int32_t>(data.heal), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid);
|
||
if (data.absorbed > 0)
|
||
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(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);
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
for (const auto& t : tempEnchantTimers_) {
|
||
if (t.slot == slot) {
|
||
return (t.expireMs > nowMs)
|
||
? static_cast<uint32_t>(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");
|
||
fireAddonEvent("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)");
|
||
fireAddonEvent("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=", static_cast<int>(petReact_), " command=", static_cast<int>(petCommand_),
|
||
" spells=", petSpellList_.size());
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("UNIT_PET", {"player"});
|
||
fireAddonEvent("PET_BAR_UPDATE", {});
|
||
}
|
||
}
|
||
|
||
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=", static_cast<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<int>(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=", static_cast<int>(petCount), " numSlots=", static_cast<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 Lua addons that the action bar changed
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {std::to_string(slot + 1)});
|
||
fireAddonEvent("ACTIONBAR_UPDATE_STATE", {});
|
||
}
|
||
// 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<uint8_t>(slot),
|
||
static_cast<uint8_t>(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");
|
||
|
||
// Notify addons that the full spell list is now available
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("SPELLS_CHANGED", {});
|
||
fireAddonEvent("LEARNED_SPELL_IN_TAB", {});
|
||
}
|
||
}
|
||
|
||
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<Unit>(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();
|
||
}
|
||
|
||
// Fire UNIT_SPELLCAST_FAILED + UNIT_SPELLCAST_STOP so Lua addons can react
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("UNIT_SPELLCAST_FAILED", {"player", std::to_string(data.spellId)});
|
||
fireAddonEvent("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)});
|
||
}
|
||
if (spellCastFailedCallback_) spellCastFailedCallback_(data.spellId);
|
||
}
|
||
|
||
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.isChannel = false;
|
||
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;
|
||
fireAddonEvent("CURRENT_SPELL_CAST_CHANGED", {});
|
||
|
||
// Play precast sound — skip profession/tradeskill spells (they use crafting
|
||
// animations/sounds, not magic spell audio).
|
||
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_) {
|
||
auto unitId = guidToUnitId(data.casterUnit);
|
||
if (!unitId.empty())
|
||
fireAddonEvent("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 — skip profession spells (no magic sound for crafting)
|
||
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.
|
||
// Skip profession spells — crafting should not swing weapons.
|
||
// 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;
|
||
if (!isProfessionSpell(sid)) {
|
||
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
|
||
fireAddonEvent("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_) {
|
||
auto unitId = guidToUnitId(data.casterUnit);
|
||
if (!unitId.empty())
|
||
fireAddonEvent("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_) {
|
||
fireAddonEvent("SPELL_UPDATE_COOLDOWN", {});
|
||
fireAddonEvent("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_) {
|
||
fireAddonEvent("SPELL_UPDATE_COOLDOWN", {});
|
||
fireAddonEvent("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<AuraSlot>* 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<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||
for (auto [slot, aura] : data.updates) {
|
||
// Stamp client timestamp so the UI can count down duration locally
|
||
if (aura.durationMs >= 0) {
|
||
aura.receivedAtMs = nowMs;
|
||
}
|
||
// Ensure vector is large enough
|
||
while (auraList->size() <= slot) {
|
||
auraList->push_back(AuraSlot{});
|
||
}
|
||
(*auraList)[slot] = aura;
|
||
}
|
||
|
||
// Fire UNIT_AURA event for Lua addons
|
||
if (addonEventCallback_) {
|
||
auto unitId = guidToUnitId(data.guid);
|
||
if (!unitId.empty())
|
||
fireAddonEvent("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=", static_cast<int>(newRank),
|
||
" (spell ", spellId, ") in spec ", static_cast<int>(activeTalentSpec_));
|
||
isTalentSpell = true;
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("CHARACTER_POINTS_CHANGED", {});
|
||
fireAddonEvent("PLAYER_TALENT_UPDATE", {});
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (isTalentSpell) break;
|
||
}
|
||
|
||
// Fire LEARNED_SPELL_IN_TAB / SPELLS_CHANGED for Lua addons
|
||
if (!alreadyKnown) {
|
||
fireAddonEvent("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)});
|
||
fireAddonEvent("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);
|
||
fireAddonEvent("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();
|
||
fireAddonEvent("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<uint8_t>(unspentTalents > 255 ? 255 : unspentTalents);
|
||
|
||
LOG_INFO("handleTalentsInfo: unspent=", unspentTalents,
|
||
" groups=", static_cast<int>(talentGroupCount), " active=", static_cast<int>(activeTalentGroup),
|
||
" learned=", learnedTalents_[activeTalentGroup].size());
|
||
|
||
// Fire talent-related events for addons
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("CHARACTER_POINTS_CHANGED", {});
|
||
fireAddonEvent("ACTIVE_TALENT_GROUP_CHANGED", {});
|
||
fireAddonEvent("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: ", static_cast<int>(newSpec));
|
||
return;
|
||
}
|
||
|
||
if (newSpec == activeTalentSpec_) {
|
||
LOG_INFO("Already on spec ", static_cast<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<uint32_t>(newSpec));
|
||
socket->send(pkt);
|
||
LOG_INFO("Sent CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE: group=", static_cast<int>(newSpec));
|
||
}
|
||
activeTalentSpec_ = newSpec;
|
||
|
||
LOG_INFO("Switched to talent spec ", static_cast<int>(newSpec),
|
||
" (unspent=", static_cast<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_) {
|
||
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
|
||
fireAddonEvent("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();
|
||
}
|
||
fireAddonEvent("PARTY_INVITE_REQUEST", {data.inviterName});
|
||
}
|
||
|
||
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 / RAID_ROSTER_UPDATE for Lua addons
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
|
||
fireAddonEvent("PARTY_MEMBERS_CHANGED", {});
|
||
if (partyData.groupType == 1)
|
||
fireAddonEvent("RAID_ROSTER_UPDATE", {});
|
||
}
|
||
}
|
||
|
||
void GameHandler::handleGroupUninvite(network::Packet& packet) {
|
||
(void)packet;
|
||
partyData = GroupListData{};
|
||
LOG_INFO("Removed from group");
|
||
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
|
||
fireAddonEvent("PARTY_MEMBERS_CHANGED", {});
|
||
fireAddonEvent("RAID_ROSTER_UPDATE", {});
|
||
}
|
||
|
||
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<uint32_t>(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<int16_t>(packet.readUInt16());
|
||
member->posY = static_cast<int16_t>(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<AuraSlot> newAuras;
|
||
for (int i = 0; i < 64; ++i) {
|
||
if (auraMask & (uint64_t(1) << i)) {
|
||
AuraSlot a;
|
||
a.level = static_cast<uint8_t>(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
|
||
fireAddonEvent("UNIT_HEALTH", {unitId});
|
||
if (updateFlags & (0x0010 | 0x0020)) // CUR_POWER or MAX_POWER
|
||
fireAddonEvent("UNIT_POWER", {unitId});
|
||
if (updateFlags & 0x0200) // AURAS
|
||
fireAddonEvent("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");
|
||
fireAddonEvent("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_ + ">");
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("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:
|
||
fireAddonEvent("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:
|
||
fireAddonEvent("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 + ".");
|
||
fireAddonEvent("GUILD_INVITE_REQUEST", {data.inviterName, 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;
|
||
fireAddonEvent("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<std::chrono::milliseconds>(now - lastInteractTime).count() < minRepeatMs) {
|
||
return;
|
||
}
|
||
lastInteractGuid = guid;
|
||
lastInteractTime = now;
|
||
|
||
// Ensure GO interaction isn't blocked by stale or active melee state.
|
||
stopAutoAttack();
|
||
auto entity = entityManager.getEntity(guid);
|
||
uint32_t goEntry = 0;
|
||
uint32_t goType = 0;
|
||
std::string goName;
|
||
|
||
if (entity) {
|
||
if (entity->getType() == ObjectType::GAMEOBJECT) {
|
||
auto go = std::static_pointer_cast<GameObject>(entity);
|
||
goEntry = go->getEntry();
|
||
goName = go->getName();
|
||
if (auto* info = getCachedGameObjectInfo(goEntry)) goType = info->type;
|
||
if (goType == 5 && !goName.empty()) {
|
||
std::string lower = goName;
|
||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||
if (lower.rfind("doodad_", 0) != 0) {
|
||
addSystemChatMessage(goName);
|
||
}
|
||
}
|
||
}
|
||
// Face object and send heartbeat before use so strict servers don't require
|
||
// a nudge movement to accept interaction.
|
||
float dx = entity->getX() - movementInfo.x;
|
||
float dy = entity->getY() - movementInfo.y;
|
||
float dz = entity->getZ() - movementInfo.z;
|
||
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||
if (dist3d > 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);
|
||
}
|
||
|
||
// Determine GO type for interaction strategy
|
||
bool isMailbox = false;
|
||
bool chestLike = false;
|
||
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
|
||
auto go = std::static_pointer_cast<GameObject>(entity);
|
||
auto* info = getCachedGameObjectInfo(go->getEntry());
|
||
if (info && info->type == 19) {
|
||
isMailbox = true;
|
||
} 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<char>(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 ||
|
||
lower.find("bundle") != std::string::npos);
|
||
}
|
||
|
||
LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec,
|
||
" entry=", goEntry, " type=", goType,
|
||
" name='", goName, "' chestLike=", chestLike, " isMailbox=", isMailbox);
|
||
|
||
if (chestLike) {
|
||
// For chest-like GOs: send CMSG_GAMEOBJ_USE (opens the chest) followed
|
||
// immediately by CMSG_LOOT (requests loot contents). Both sent in the
|
||
// same frame so the server processes them sequentially: USE transitions
|
||
// the GO to lootable state, then LOOT reads the contents.
|
||
auto usePacket = GameObjectUsePacket::build(guid);
|
||
socket->send(usePacket);
|
||
lootTarget(guid);
|
||
lastInteractedGoGuid_ = guid;
|
||
} else {
|
||
// Non-chest GOs (doors, buttons, quest givers, etc.): use CMSG_GAMEOBJ_USE
|
||
auto packet = GameObjectUsePacket::build(guid);
|
||
socket->send(packet);
|
||
lastInteractedGoGuid_ = guid;
|
||
|
||
if (isMailbox) {
|
||
LOG_INFO("Mailbox interaction: opening mail UI and requesting mail list");
|
||
mailboxGuid_ = guid;
|
||
mailboxOpen_ = true;
|
||
hasNewMail_ = false;
|
||
selectedMailIndex_ = -1;
|
||
showMailCompose_ = false;
|
||
refreshMailList();
|
||
}
|
||
|
||
// CMSG_GAMEOBJ_REPORT_USE for GO AI scripts (quest givers, etc.)
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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=", static_cast<int>(opt.icon), " text='", opt.text, "'");
|
||
|
||
// Icon-based NPC interaction fallbacks
|
||
// Some servers need the specific activate packet in addition to gossip select
|
||
if (opt.icon == 6) {
|
||
// GOSSIP_ICON_MONEY_BAG = banker
|
||
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
|
||
socket->send(pkt);
|
||
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
||
}
|
||
|
||
// Text-based NPC type detection for servers using placeholder strings
|
||
std::string text = opt.text;
|
||
std::string textLower = text;
|
||
std::transform(textLower.begin(), textLower.end(), textLower.begin(),
|
||
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
||
|
||
if (text == "GOSSIP_OPTION_AUCTIONEER" || textLower.find("auction") != std::string::npos) {
|
||
auto pkt = AuctionHelloPacket::build(currentGossip.npcGuid);
|
||
socket->send(pkt);
|
||
LOG_INFO("Sent MSG_AUCTION_HELLO for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
||
}
|
||
|
||
if (text == "GOSSIP_OPTION_BANKER" || textLower.find("deposit box") != std::string::npos) {
|
||
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
|
||
socket->send(pkt);
|
||
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
||
}
|
||
|
||
// 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=", static_cast<int>(isVendor), " repair=", static_cast<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 = findQuestLogEntry(questId);
|
||
|
||
// 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);
|
||
activeQuest = findQuestLogEntry(questId);
|
||
}
|
||
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<uint16_t>(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.
|
||
auto questTitle = getQuestTitle(questId);
|
||
|
||
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<int32_t>(packet.readUInt32());
|
||
const int32_t py = static_cast<int32_t>(packet.readUInt32());
|
||
sumX += static_cast<float>(px);
|
||
sumY += static_cast<float>(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<float>(pointCount); // WoW canonical X
|
||
poi.y = sumY / static_cast<float>(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;
|
||
fireAddonEvent("QUEST_DETAIL", {});
|
||
}
|
||
|
||
bool GameHandler::hasQuestInLog(uint32_t questId) const {
|
||
for (const auto& q : questLog_) {
|
||
if (q.questId == questId) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
std::string GameHandler::guidToUnitId(uint64_t guid) const {
|
||
if (guid == playerGuid) return "player";
|
||
if (guid == targetGuid) return "target";
|
||
if (guid == focusGuid) return "focus";
|
||
if (guid == petGuid_) return "pet";
|
||
return {};
|
||
}
|
||
|
||
std::string GameHandler::getQuestTitle(uint32_t questId) const {
|
||
for (const auto& q : questLog_)
|
||
if (q.questId == questId && !q.title.empty()) return q.title;
|
||
return {};
|
||
}
|
||
|
||
const GameHandler::QuestLogEntry* GameHandler::findQuestLogEntry(uint32_t questId) const {
|
||
for (const auto& q : questLog_)
|
||
if (q.questId == questId) return &q;
|
||
return nullptr;
|
||
}
|
||
|
||
int GameHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const {
|
||
if (questId == 0 || lastPlayerFields_.empty()) return -1;
|
||
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
||
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
|
||
for (uint16_t slot = 0; slot < 25; ++slot) {
|
||
const uint16_t idField = ufQuestStart + slot * qStride;
|
||
auto it = lastPlayerFields_.find(idField);
|
||
if (it != lastPlayerFields_.end() && it->second == questId) {
|
||
return static_cast<int>(slot);
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives) {
|
||
if (questId == 0 || hasQuestInLog(questId)) return;
|
||
QuestLogEntry entry;
|
||
entry.questId = questId;
|
||
entry.title = title.empty() ? ("Quest #" + std::to_string(questId)) : title;
|
||
entry.objectives = objectives;
|
||
questLog_.push_back(std::move(entry));
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("QUEST_ACCEPTED", {std::to_string(questId)});
|
||
fireAddonEvent("QUEST_LOG_UPDATE", {});
|
||
fireAddonEvent("UNIT_QUEST_LOG_CHANGED", {"player"});
|
||
}
|
||
}
|
||
|
||
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<uint32_t, bool> 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<uint32_t> 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<uint16_t, uint32_t>& 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<uint16_t>(slot) * qStride + 2;
|
||
const uint16_t countField2 = (qStride >= 5)
|
||
? static_cast<uint16_t>(countField1 + 1)
|
||
: static_cast<uint16_t>(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<uint8_t>((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<uint32_t>(
|
||
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=", static_cast<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<uint32_t>(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<int>(i);
|
||
break;
|
||
}
|
||
}
|
||
|
||
int slotIndex = findQuestLogSlotIndexFromServer(questId);
|
||
if (slotIndex < 0 && localIndex >= 0) {
|
||
// Best-effort fallback if update fields are stale/missing.
|
||
slotIndex = localIndex;
|
||
LOG_WARNING("Abandon quest using local slot fallback: questId=", questId, " slot=", slotIndex);
|
||
}
|
||
|
||
if (slotIndex >= 0 && slotIndex < 25) {
|
||
if (state == WorldState::IN_WORLD && socket) {
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
|
||
pkt.writeUInt8(static_cast<uint8_t>(slotIndex));
|
||
socket->send(pkt);
|
||
}
|
||
} else {
|
||
LOG_WARNING("Abandon quest failed: no quest-log slot found for questId=", questId);
|
||
}
|
||
|
||
if (localIndex >= 0) {
|
||
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(localIndex));
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("QUEST_LOG_UPDATE", {});
|
||
fireAddonEvent("UNIT_QUEST_LOG_CHANGED", {"player"});
|
||
fireAddonEvent("QUEST_REMOVED", {std::to_string(questId)});
|
||
}
|
||
}
|
||
|
||
// 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
|
||
auto questTitle = getQuestTitle(questId);
|
||
addSystemChatMessage(questTitle.empty() ? std::string("Quest shared.")
|
||
: ("Sharing quest: " + questTitle));
|
||
}
|
||
|
||
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{};
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("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<int>(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) fireAddonEvent("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<int>(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<uint8_t>(23 + backpackIndex));
|
||
socket->send(packet);
|
||
}
|
||
}
|
||
|
||
void GameHandler::autoEquipItemInBag(int bagIndex, int slotIndex) {
|
||
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
||
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
||
|
||
if (state == WorldState::IN_WORLD && socket) {
|
||
// Bag items: bag = equip slot 19+bagIndex, slot = index within bag
|
||
auto packet = AutoEquipItemPacket::build(
|
||
static_cast<uint8_t>(19 + bagIndex), static_cast<uint8_t>(slotIndex));
|
||
socket->send(packet);
|
||
}
|
||
}
|
||
|
||
void GameHandler::sellItemInBag(int bagIndex, int slotIndex) {
|
||
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
||
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
||
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
|
||
if (slot.empty()) return;
|
||
|
||
uint32_t sellPrice = slot.item.sellPrice;
|
||
if (sellPrice == 0) {
|
||
if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) {
|
||
sellPrice = info->sellPrice;
|
||
}
|
||
}
|
||
if (sellPrice == 0) {
|
||
addSystemChatMessage("Cannot sell: this item has no vendor value.");
|
||
return;
|
||
}
|
||
|
||
// Resolve item GUID from container contents
|
||
uint64_t itemGuid = 0;
|
||
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
|
||
if (bagGuid != 0) {
|
||
auto it = containerContents_.find(bagGuid);
|
||
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
|
||
itemGuid = it->second.slotGuids[slotIndex];
|
||
}
|
||
}
|
||
if (itemGuid == 0) {
|
||
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
||
}
|
||
|
||
if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) {
|
||
BuybackItem sold;
|
||
sold.itemGuid = itemGuid;
|
||
sold.item = slot.item;
|
||
sold.count = 1;
|
||
buybackItems_.push_front(sold);
|
||
if (buybackItems_.size() > 12) buybackItems_.pop_back();
|
||
pendingSellToBuyback_[itemGuid] = sold;
|
||
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
|
||
} else if (itemGuid == 0) {
|
||
addSystemChatMessage("Cannot sell: item not found.");
|
||
} else {
|
||
addSystemChatMessage("Cannot sell: no vendor.");
|
||
}
|
||
}
|
||
|
||
void GameHandler::unequipToBackpack(EquipSlot equipSlot) {
|
||
if (state != WorldState::IN_WORLD || !socket) return;
|
||
|
||
int freeSlot = inventory.findFreeBackpackSlot();
|
||
if (freeSlot < 0) {
|
||
addSystemChatMessage("Cannot unequip: no free backpack slots.");
|
||
return;
|
||
}
|
||
|
||
// Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF.
|
||
uint8_t srcBag = 0xFF;
|
||
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
|
||
uint8_t dstBag = 0xFF;
|
||
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
|
||
|
||
LOG_INFO("UnequipToBackpack: equipSlot=", static_cast<int>(srcSlot),
|
||
" -> backpackIndex=", freeSlot, " (dstSlot=", static_cast<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=", static_cast<int>(srcBag), " slot=", static_cast<int>(srcSlot),
|
||
") -> dst(bag=", static_cast<int>(dstBag), " slot=", static_cast<int>(dstSlot), ")");
|
||
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
|
||
socket->send(packet);
|
||
}
|
||
|
||
void GameHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) {
|
||
if (srcBagIndex < 0 || srcBagIndex > 3 || dstBagIndex < 0 || dstBagIndex > 3) return;
|
||
if (srcBagIndex == dstBagIndex) return;
|
||
|
||
// Local swap for immediate visual feedback
|
||
auto srcEquip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + srcBagIndex);
|
||
auto dstEquip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + dstBagIndex);
|
||
auto srcItem = inventory.getEquipSlot(srcEquip).item;
|
||
auto dstItem = inventory.getEquipSlot(dstEquip).item;
|
||
inventory.setEquipSlot(srcEquip, dstItem);
|
||
inventory.setEquipSlot(dstEquip, srcItem);
|
||
|
||
// Also swap bag contents locally
|
||
inventory.swapBagContents(srcBagIndex, dstBagIndex);
|
||
|
||
// Send to server using CMSG_SWAP_ITEM with INVENTORY_SLOT_BAG_0 (255)
|
||
// CMSG_SWAP_INV_ITEM doesn't support bag equip slots (19-22)
|
||
if (socket && socket->isConnected()) {
|
||
uint8_t srcSlot = static_cast<uint8_t>(19 + srcBagIndex);
|
||
uint8_t dstSlot = static_cast<uint8_t>(19 + dstBagIndex);
|
||
LOG_INFO("swapBagSlots: bag ", srcBagIndex, " (slot ", static_cast<int>(srcSlot),
|
||
") <-> bag ", dstBagIndex, " (slot ", static_cast<int>(dstSlot), ")");
|
||
auto packet = SwapItemPacket::build(255, dstSlot, 255, srcSlot);
|
||
socket->send(packet);
|
||
}
|
||
}
|
||
|
||
void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
|
||
if (state != WorldState::IN_WORLD || !socket) return;
|
||
if (count == 0) count = 1;
|
||
|
||
// AzerothCore WotLK expects CMSG_DESTROYITEM(bag:u8, slot:u8, count:u32).
|
||
// This opcode is currently not modeled as a logical opcode in our table.
|
||
constexpr uint16_t kCmsgDestroyItem = 0x111;
|
||
network::Packet packet(kCmsgDestroyItem);
|
||
packet.writeUInt8(bag);
|
||
packet.writeUInt8(slot);
|
||
packet.writeUInt32(static_cast<uint32_t>(count));
|
||
LOG_DEBUG("Destroy item request: bag=", static_cast<int>(bag), " slot=", static_cast<int>(slot),
|
||
" count=", static_cast<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<uint8_t>(23 + freeBp);
|
||
LOG_INFO("splitItem: src(bag=", static_cast<int>(srcBag), " slot=", static_cast<int>(srcSlot),
|
||
") count=", static_cast<int>(count), " -> dst(bag=0xFF slot=", static_cast<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<uint8_t>(19 + b);
|
||
uint8_t dstSlot = static_cast<uint8_t>(s);
|
||
LOG_INFO("splitItem: src(bag=", static_cast<int>(srcBag), " slot=", static_cast<int>(srcSlot),
|
||
") count=", static_cast<int>(count), " -> dst(bag=", static_cast<int>(dstBag),
|
||
" slot=", static_cast<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<uint8_t>(23 + backpackIndex), itemGuid, useSpellId)
|
||
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId);
|
||
socket->send(packet);
|
||
} else if (itemGuid == 0) {
|
||
addSystemChatMessage("Cannot use that item right now.");
|
||
}
|
||
}
|
||
|
||
void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
|
||
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
||
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
||
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
|
||
if (slot.empty()) return;
|
||
|
||
// Resolve item GUID from container contents
|
||
uint64_t itemGuid = 0;
|
||
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
|
||
if (bagGuid != 0) {
|
||
auto it = containerContents_.find(bagGuid);
|
||
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
|
||
itemGuid = it->second.slotGuids[slotIndex];
|
||
}
|
||
}
|
||
if (itemGuid == 0) {
|
||
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
||
}
|
||
|
||
LOG_INFO("useItemInBag: bag=", bagIndex, " slot=", slotIndex, " itemId=", slot.item.itemId,
|
||
" itemGuid=0x", std::hex, itemGuid, std::dec);
|
||
|
||
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
|
||
// Find the item's on-use spell ID
|
||
uint32_t useSpellId = 0;
|
||
if (auto* info = getItemInfo(slot.item.itemId)) {
|
||
for (const auto& sp : info->spells) {
|
||
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
|
||
useSpellId = sp.spellId;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// WoW bag addressing: bagIndex = equip slot of bag container (19-22)
|
||
// For CMSG_USE_ITEM: bag = 19+bagIndex, slot = slot within bag
|
||
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
|
||
auto packet = packetParsers_
|
||
? packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
|
||
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
|
||
LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", static_cast<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<uint8_t>(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<uint8_t>(19 + bagIndex);
|
||
auto packet = OpenItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex));
|
||
LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", static_cast<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_) {
|
||
fireAddonEvent("LOOT_OPENED", {});
|
||
fireAddonEvent("LOOT_READY", {});
|
||
}
|
||
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;
|
||
fireAddonEvent("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);
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("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<int32_t>(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;
|
||
fireAddonEvent("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;
|
||
fireAddonEvent("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<Unit>(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
|
||
fireAddonEvent("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<int>(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<EquipSlot>(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;
|
||
fireAddonEvent("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=", static_cast<int>(s.state),
|
||
" cost=", s.spellCost, " reqLvl=", static_cast<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=", static_cast<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;
|
||
fireAddonEvent("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<uint8_t>(dbc->getUInt32(i, dispelField));
|
||
}
|
||
if (hasAttrExField) {
|
||
entry.attrEx = dbc->getUInt32(i, attrExField);
|
||
}
|
||
// Load effect base points for $s1/$s2/$s3 tooltip substitution
|
||
if (spellL) {
|
||
uint32_t f0 = spellL->field("EffectBasePoints0");
|
||
uint32_t f1 = spellL->field("EffectBasePoints1");
|
||
uint32_t f2 = spellL->field("EffectBasePoints2");
|
||
if (f0 != 0xFFFFFFFF) entry.effectBasePoints[0] = static_cast<int32_t>(dbc->getUInt32(i, f0));
|
||
if (f1 != 0xFFFFFFFF) entry.effectBasePoints[1] = static_cast<int32_t>(dbc->getUInt32(i, f1));
|
||
if (f2 != 0xFFFFFFFF) entry.effectBasePoints[2] = static_cast<int32_t>(dbc->getUInt32(i, f2));
|
||
}
|
||
// Duration: read DurationIndex and resolve via SpellDuration.dbc later
|
||
if (spellL) {
|
||
uint32_t durF = spellL->field("DurationIndex");
|
||
if (durF != 0xFFFFFFFF)
|
||
entry.durationSec = static_cast<float>(dbc->getUInt32(i, durF)); // store index temporarily
|
||
}
|
||
spellNameCache_[id] = std::move(entry);
|
||
}
|
||
}
|
||
// Resolve DurationIndex → seconds via SpellDuration.dbc
|
||
auto durDbc = am->loadDBC("SpellDuration.dbc");
|
||
if (durDbc && durDbc->isLoaded()) {
|
||
std::unordered_map<uint32_t, float> durMap;
|
||
for (uint32_t di = 0; di < durDbc->getRecordCount(); ++di) {
|
||
uint32_t durId = durDbc->getUInt32(di, 0);
|
||
int32_t baseMs = static_cast<int32_t>(durDbc->getUInt32(di, 1));
|
||
if (baseMs > 0 && baseMs < 100000000) // filter out absurd values
|
||
durMap[durId] = baseMs / 1000.0f;
|
||
}
|
||
for (auto& [sid, entry] : spellNameCache_) {
|
||
uint32_t durIdx = static_cast<uint32_t>(entry.durationSec);
|
||
if (durIdx > 0) {
|
||
auto it = durMap.find(durIdx);
|
||
entry.durationSec = (it != durMap.end()) ? it->second : 0.0f;
|
||
}
|
||
}
|
||
}
|
||
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");
|
||
}
|
||
}
|
||
|
||
const std::vector<GameHandler::SpellBookTab>& GameHandler::getSpellBookTabs() {
|
||
// Rebuild when spell count changes (learns/unlearns)
|
||
static size_t lastSpellCount = 0;
|
||
if (lastSpellCount == knownSpells.size() && !spellBookTabsDirty_)
|
||
return spellBookTabs_;
|
||
lastSpellCount = knownSpells.size();
|
||
spellBookTabsDirty_ = false;
|
||
spellBookTabs_.clear();
|
||
|
||
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
|
||
|
||
// Group known spells by class skill line
|
||
std::map<uint32_t, std::vector<uint32_t>> bySkillLine;
|
||
std::vector<uint32_t> general;
|
||
|
||
for (uint32_t spellId : knownSpells) {
|
||
auto slIt = spellToSkillLine_.find(spellId);
|
||
if (slIt != spellToSkillLine_.end()) {
|
||
uint32_t skillLineId = slIt->second;
|
||
auto catIt = skillLineCategories_.find(skillLineId);
|
||
if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
|
||
bySkillLine[skillLineId].push_back(spellId);
|
||
continue;
|
||
}
|
||
}
|
||
general.push_back(spellId);
|
||
}
|
||
|
||
// Sort spells within each group by name
|
||
auto byName = [this](uint32_t a, uint32_t b) {
|
||
return getSpellName(a) < getSpellName(b);
|
||
};
|
||
|
||
// "General" tab first (spells not in a class skill line)
|
||
if (!general.empty()) {
|
||
std::sort(general.begin(), general.end(), byName);
|
||
spellBookTabs_.push_back({"General", "Interface\\Icons\\INV_Misc_Book_09", std::move(general)});
|
||
}
|
||
|
||
// Class skill line tabs, sorted by name
|
||
std::vector<std::pair<std::string, std::vector<uint32_t>>> named;
|
||
for (auto& [skillLineId, spells] : bySkillLine) {
|
||
auto nameIt = skillLineNames_.find(skillLineId);
|
||
std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Unknown";
|
||
std::sort(spells.begin(), spells.end(), byName);
|
||
named.emplace_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) {
|
||
spellBookTabs_.push_back({std::move(name), "Interface\\Icons\\INV_Misc_Book_09", std::move(spells)});
|
||
}
|
||
|
||
return spellBookTabs_;
|
||
}
|
||
|
||
void GameHandler::categorizeTrainerSpells() {
|
||
trainerTabs_.clear();
|
||
|
||
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
|
||
|
||
// Group spells by skill line (category 7 = class spec tabs)
|
||
std::map<uint32_t, std::vector<const TrainerSpell*>> specialtySpells;
|
||
std::vector<const TrainerSpell*> generalSpells;
|
||
|
||
for (const auto& spell : currentTrainerList_.spells) {
|
||
auto slIt = spellToSkillLine_.find(spell.spellId);
|
||
if (slIt != spellToSkillLine_.end()) {
|
||
uint32_t skillLineId = slIt->second;
|
||
auto catIt = skillLineCategories_.find(skillLineId);
|
||
if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
|
||
specialtySpells[skillLineId].push_back(&spell);
|
||
continue;
|
||
}
|
||
}
|
||
generalSpells.push_back(&spell);
|
||
}
|
||
|
||
// Sort by spell name within each group
|
||
auto byName = [this](const TrainerSpell* a, const TrainerSpell* b) {
|
||
return getSpellName(a->spellId) < getSpellName(b->spellId);
|
||
};
|
||
|
||
// Build named tabs sorted alphabetically
|
||
std::vector<std::pair<std::string, std::vector<const TrainerSpell*>>> named;
|
||
for (auto& [skillLineId, spells] : specialtySpells) {
|
||
auto nameIt = skillLineNames_.find(skillLineId);
|
||
std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Specialty";
|
||
std::sort(spells.begin(), spells.end(), byName);
|
||
named.push_back({std::move(tabName), std::move(spells)});
|
||
}
|
||
std::sort(named.begin(), named.end(),
|
||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||
|
||
for (auto& [name, spells] : named) {
|
||
trainerTabs_.push_back({std::move(name), std::move(spells)});
|
||
}
|
||
|
||
// General tab last
|
||
if (!generalSpells.empty()) {
|
||
std::sort(generalSpells.begin(), generalSpells.end(), byName);
|
||
trainerTabs_.push_back({"General", std::move(generalSpells)});
|
||
}
|
||
|
||
LOG_INFO("Trainer: Categorized into ", trainerTabs_.size(), " tabs");
|
||
}
|
||
|
||
void GameHandler::loadTalentDbc() {
|
||
if (talentDbcLoaded_) return;
|
||
talentDbcLoaded_ = true;
|
||
|
||
auto* am = core::Application::getInstance().getAssetManager();
|
||
if (!am || !am->isInitialized()) return;
|
||
|
||
// Load Talent.dbc
|
||
auto talentDbc = am->loadDBC("Talent.dbc");
|
||
if (talentDbc && talentDbc->isLoaded()) {
|
||
// Talent.dbc structure (WoW 3.3.5a):
|
||
// 0: TalentID
|
||
// 1: TalentTabID
|
||
// 2: Row (tier)
|
||
// 3: Column
|
||
// 4-8: RankID[0-4] (spell IDs for ranks 1-5)
|
||
// 9-11: PrereqTalent[0-2]
|
||
// 12-14: PrereqRank[0-2]
|
||
// (other fields less relevant for basic functionality)
|
||
|
||
const auto* talL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Talent") : nullptr;
|
||
const uint32_t tID = talL ? (*talL)["ID"] : 0;
|
||
const uint32_t tTabID = talL ? (*talL)["TabID"] : 1;
|
||
const uint32_t tRow = talL ? (*talL)["Row"] : 2;
|
||
const uint32_t tCol = talL ? (*talL)["Column"] : 3;
|
||
const uint32_t tRank0 = talL ? (*talL)["RankSpell0"] : 4;
|
||
const uint32_t tPrereq0 = talL ? (*talL)["PrereqTalent0"] : 9;
|
||
const uint32_t tPrereqR0 = talL ? (*talL)["PrereqRank0"] : 12;
|
||
|
||
uint32_t count = talentDbc->getRecordCount();
|
||
for (uint32_t i = 0; i < count; ++i) {
|
||
TalentEntry entry;
|
||
entry.talentId = talentDbc->getUInt32(i, tID);
|
||
if (entry.talentId == 0) continue;
|
||
|
||
entry.tabId = talentDbc->getUInt32(i, tTabID);
|
||
entry.row = static_cast<uint8_t>(talentDbc->getUInt32(i, tRow));
|
||
entry.column = static_cast<uint8_t>(talentDbc->getUInt32(i, tCol));
|
||
|
||
// Rank spells (1-5 ranks)
|
||
for (int r = 0; r < 5; ++r) {
|
||
entry.rankSpells[r] = talentDbc->getUInt32(i, tRank0 + r);
|
||
}
|
||
|
||
// Prerequisites
|
||
for (int p = 0; p < 3; ++p) {
|
||
entry.prereqTalent[p] = talentDbc->getUInt32(i, tPrereq0 + p);
|
||
entry.prereqRank[p] = static_cast<uint8_t>(talentDbc->getUInt32(i, tPrereqR0 + p));
|
||
}
|
||
|
||
// Calculate max rank
|
||
entry.maxRank = 0;
|
||
for (int r = 0; r < 5; ++r) {
|
||
if (entry.rankSpells[r] != 0) {
|
||
entry.maxRank = r + 1;
|
||
}
|
||
}
|
||
|
||
talentCache_[entry.talentId] = entry;
|
||
}
|
||
LOG_INFO("Loaded ", talentCache_.size(), " talents from Talent.dbc");
|
||
} else {
|
||
LOG_WARNING("Could not load Talent.dbc");
|
||
}
|
||
|
||
// Load TalentTab.dbc
|
||
auto tabDbc = am->loadDBC("TalentTab.dbc");
|
||
if (tabDbc && tabDbc->isLoaded()) {
|
||
// TalentTab.dbc structure (WoW 3.3.5a):
|
||
// 0: TalentTabID
|
||
// 1-17: Name (16 localized strings + flags = 17 fields)
|
||
// 18: SpellIconID
|
||
// 19: RaceMask
|
||
// 20: ClassMask
|
||
// 21: PetTalentMask
|
||
// 22: OrderIndex
|
||
// 23-39: BackgroundFile (16 localized strings + flags = 17 fields)
|
||
|
||
const auto* ttL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TalentTab") : nullptr;
|
||
uint32_t count = tabDbc->getRecordCount();
|
||
for (uint32_t i = 0; i < count; ++i) {
|
||
TalentTabEntry entry;
|
||
entry.tabId = tabDbc->getUInt32(i, ttL ? (*ttL)["ID"] : 0);
|
||
if (entry.tabId == 0) continue;
|
||
|
||
entry.name = tabDbc->getString(i, ttL ? (*ttL)["Name"] : 1);
|
||
entry.classMask = tabDbc->getUInt32(i, ttL ? (*ttL)["ClassMask"] : 20);
|
||
entry.orderIndex = static_cast<uint8_t>(tabDbc->getUInt32(i, ttL ? (*ttL)["OrderIndex"] : 22));
|
||
entry.backgroundFile = tabDbc->getString(i, ttL ? (*ttL)["BackgroundFile"] : 23);
|
||
|
||
talentTabCache_[entry.tabId] = entry;
|
||
|
||
// Log first few tabs to debug class mask issue
|
||
if (talentTabCache_.size() <= 10) {
|
||
LOG_INFO(" Tab ", entry.tabId, ": ", entry.name, " (classMask=0x", std::hex, entry.classMask, std::dec, ")");
|
||
}
|
||
}
|
||
LOG_INFO("Loaded ", talentTabCache_.size(), " talent tabs from TalentTab.dbc");
|
||
} else {
|
||
LOG_WARNING("Could not load TalentTab.dbc");
|
||
}
|
||
}
|
||
|
||
static const std::string EMPTY_STRING;
|
||
|
||
const int32_t* GameHandler::getSpellEffectBasePoints(uint32_t spellId) const {
|
||
const_cast<GameHandler*>(this)->loadSpellNameCache();
|
||
auto it = spellNameCache_.find(spellId);
|
||
return (it != spellNameCache_.end()) ? it->second.effectBasePoints : nullptr;
|
||
}
|
||
|
||
float GameHandler::getSpellDuration(uint32_t spellId) const {
|
||
const_cast<GameHandler*>(this)->loadSpellNameCache();
|
||
auto it = spellNameCache_.find(spellId);
|
||
return (it != spellNameCache_.end()) ? it->second.durationSec : 0.0f;
|
||
}
|
||
|
||
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<GameHandler*>(this)->loadSpellNameCache();
|
||
auto it = spellNameCache_.find(spellId);
|
||
return (it != spellNameCache_.end()) ? it->second.description : EMPTY_STRING;
|
||
}
|
||
|
||
std::string GameHandler::getEnchantName(uint32_t enchantId) const {
|
||
if (enchantId == 0) return {};
|
||
auto* am = core::Application::getInstance().getAssetManager();
|
||
if (!am || !am->isInitialized()) return {};
|
||
auto dbc = am->loadDBC("SpellItemEnchantment.dbc");
|
||
if (!dbc || !dbc->isLoaded()) return {};
|
||
// Name is at field 14 (consistent across Classic/TBC/WotLK)
|
||
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
|
||
if (dbc->getUInt32(i, 0) == enchantId) {
|
||
return dbc->getString(i, 14);
|
||
}
|
||
}
|
||
return {};
|
||
}
|
||
|
||
uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const {
|
||
const_cast<GameHandler*>(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<GameHandler*>(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<GameHandler*>(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<int32_t>(playerLevel) - 5 - static_cast<int32_t>(playerLevel) / 10;
|
||
else if (playerLevel <= 59) grayLevel = static_cast<int32_t>(playerLevel) - 1 - static_cast<int32_t>(playerLevel) / 5;
|
||
else grayLevel = static_cast<int32_t>(playerLevel) - 9;
|
||
|
||
if (static_cast<int32_t>(victimLevel) <= grayLevel) return 0;
|
||
|
||
// Base XP = 45 + 5 * victimLevel (WoW-like ZeroDifference formula)
|
||
uint32_t baseXp = 45 + 5 * victimLevel;
|
||
|
||
// Level difference multiplier
|
||
int32_t diff = static_cast<int32_t>(victimLevel) - static_cast<int32_t>(playerLevel);
|
||
float multiplier = 1.0f + diff * 0.05f;
|
||
if (multiplier < 0.1f) multiplier = 0.1f;
|
||
if (multiplier > 2.0f) multiplier = 2.0f;
|
||
|
||
return static_cast<uint32_t>(baseXp * multiplier);
|
||
}
|
||
|
||
|
||
|
||
void GameHandler::handleXpGain(network::Packet& packet) {
|
||
XpGainData data;
|
||
if (!XpGainParser::parse(packet, data)) return;
|
||
|
||
// Server already updates PLAYER_XP via update fields,
|
||
// but we can show combat text for XP gains
|
||
addCombatText(CombatTextEntry::XP_GAIN, static_cast<int32_t>(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);
|
||
fireAddonEvent("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);
|
||
fireAddonEvent("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);
|
||
}
|
||
|
||
// Fire PLAYER_ENTERING_WORLD for teleports / zone transitions
|
||
if (addonEventCallback_) {
|
||
fireAddonEvent("PLAYER_ENTERING_WORLD", {"0"});
|
||
fireAddonEvent("ZONE_CHANGED_NEW_AREA", {});
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Taxi / Flight Path Handlers
|
||
// ============================================================
|
||
|
||
void GameHandler::loadTaxiDbc() {
|
||
if (taxiDbcLoaded_) return;
|
||
taxiDbcLoaded_ = true;
|
||
|
||
auto* am = core::Application::getInstance().getAssetManager();
|
||
if (!am || !am->isInitialized()) return;
|
||
|
||
auto nodesDbc = am->loadDBC("TaxiNodes.dbc");
|
||
if (nodesDbc && nodesDbc->isLoaded()) {
|
||
const auto* tnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiNodes") : nullptr;
|
||
uint32_t fieldCount = nodesDbc->getFieldCount();
|
||
for (uint32_t i = 0; i < nodesDbc->getRecordCount(); i++) {
|
||
TaxiNode node;
|
||
node.id = nodesDbc->getUInt32(i, tnL ? (*tnL)["ID"] : 0);
|
||
node.mapId = nodesDbc->getUInt32(i, tnL ? (*tnL)["MapID"] : 1);
|
||
node.x = nodesDbc->getFloat(i, tnL ? (*tnL)["X"] : 2);
|
||
node.y = nodesDbc->getFloat(i, tnL ? (*tnL)["Y"] : 3);
|
||
node.z = nodesDbc->getFloat(i, tnL ? (*tnL)["Z"] : 4);
|
||
node.name = nodesDbc->getString(i, tnL ? (*tnL)["Name"] : 5);
|
||
const uint32_t mountAllianceField = tnL ? (*tnL)["MountDisplayIdAlliance"] : 22;
|
||
const uint32_t mountHordeField = tnL ? (*tnL)["MountDisplayIdHorde"] : 23;
|
||
const uint32_t mountAllianceFB = tnL ? (*tnL)["MountDisplayIdAllianceFallback"] : 20;
|
||
const uint32_t mountHordeFB = tnL ? (*tnL)["MountDisplayIdHordeFallback"] : 21;
|
||
if (fieldCount > mountHordeField) {
|
||
node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, mountAllianceField);
|
||
node.mountDisplayIdHorde = nodesDbc->getUInt32(i, mountHordeField);
|
||
if (node.mountDisplayIdAlliance == 0 && node.mountDisplayIdHorde == 0 && fieldCount > mountHordeFB) {
|
||
node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, mountAllianceFB);
|
||
node.mountDisplayIdHorde = nodesDbc->getUInt32(i, mountHordeFB);
|
||
}
|
||
}
|
||
uint32_t nodeId = node.id;
|
||
if (nodeId > 0) {
|
||
taxiNodes_[nodeId] = std::move(node);
|
||
}
|
||
if (nodeId == 195) {
|
||
std::string fields;
|
||
for (uint32_t f = 0; f < fieldCount; f++) {
|
||
fields += std::to_string(f) + ":" + std::to_string(nodesDbc->getUInt32(i, f)) + " ";
|
||
}
|
||
LOG_INFO("TaxiNodes[195] fields: ", fields);
|
||
}
|
||
}
|
||
LOG_INFO("Loaded ", taxiNodes_.size(), " taxi nodes from TaxiNodes.dbc");
|
||
} else {
|
||
LOG_WARNING("Could not load TaxiNodes.dbc");
|
||
}
|
||
|
||
auto pathDbc = am->loadDBC("TaxiPath.dbc");
|
||
if (pathDbc && pathDbc->isLoaded()) {
|
||
const auto* tpL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPath") : nullptr;
|
||
for (uint32_t i = 0; i < pathDbc->getRecordCount(); i++) {
|
||
TaxiPathEdge edge;
|
||
edge.pathId = pathDbc->getUInt32(i, tpL ? (*tpL)["ID"] : 0);
|
||
edge.fromNode = pathDbc->getUInt32(i, tpL ? (*tpL)["FromNode"] : 1);
|
||
edge.toNode = pathDbc->getUInt32(i, tpL ? (*tpL)["ToNode"] : 2);
|
||
edge.cost = pathDbc->getUInt32(i, tpL ? (*tpL)["Cost"] : 3);
|
||
taxiPathEdges_.push_back(edge);
|
||
}
|
||
LOG_INFO("Loaded ", taxiPathEdges_.size(), " taxi path edges from TaxiPath.dbc");
|
||
} else {
|
||
LOG_WARNING("Could not load TaxiPath.dbc");
|
||
}
|
||
|
||
auto pathNodeDbc = am->loadDBC("TaxiPathNode.dbc");
|
||
if (pathNodeDbc && pathNodeDbc->isLoaded()) {
|
||
const auto* tpnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPathNode") : nullptr;
|
||
for (uint32_t i = 0; i < pathNodeDbc->getRecordCount(); i++) {
|
||
TaxiPathNode node;
|
||
node.id = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["ID"] : 0);
|
||
node.pathId = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["PathID"] : 1);
|
||
node.nodeIndex = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["NodeIndex"] : 2);
|
||
node.mapId = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["MapID"] : 3);
|
||
node.x = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["X"] : 4);
|
||
node.y = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["Y"] : 5);
|
||
node.z = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["Z"] : 6);
|
||
taxiPathNodes_[node.pathId].push_back(node);
|
||
}
|
||
// Sort waypoints by nodeIndex for each path
|
||
for (auto& [pathId, nodes] : taxiPathNodes_) {
|
||
std::sort(nodes.begin(), nodes.end(),
|
||
[](const TaxiPathNode& a, const TaxiPathNode& b) {
|
||
return a.nodeIndex < b.nodeIndex;
|
||
});
|
||
}
|
||
LOG_INFO("Loaded ", pathNodeDbc->getRecordCount(), " taxi path waypoints from TaxiPathNode.dbc");
|
||
} else {
|
||
LOG_WARNING("Could not load TaxiPathNode.dbc");
|
||
}
|
||
}
|
||
|
||
void GameHandler::handleShowTaxiNodes(network::Packet& packet) {
|
||
ShowTaxiNodesData data;
|
||
if (!ShowTaxiNodesParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_SHOWTAXINODES");
|
||
return;
|
||
}
|
||
|
||
loadTaxiDbc();
|
||
|
||
// Detect newly discovered flight paths by comparing with stored mask
|
||
if (taxiMaskInitialized_) {
|
||
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
|
||
uint32_t newBits = data.nodeMask[i] & ~knownTaxiMask_[i];
|
||
if (newBits == 0) continue;
|
||
for (uint32_t bit = 0; bit < 32; ++bit) {
|
||
if (newBits & (1u << bit)) {
|
||
uint32_t nodeId = i * 32 + bit + 1;
|
||
auto it = taxiNodes_.find(nodeId);
|
||
if (it != taxiNodes_.end()) {
|
||
addSystemChatMessage("Discovered flight path: " + it->second.name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update stored mask
|
||
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
|
||
knownTaxiMask_[i] = data.nodeMask[i];
|
||
}
|
||
taxiMaskInitialized_ = true;
|
||
|
||
currentTaxiData_ = data;
|
||
taxiNpcGuid_ = data.npcGuid;
|
||
taxiWindowOpen_ = true;
|
||
gossipWindowOpen = false;
|
||
buildTaxiCostMap();
|
||
auto it = taxiNodes_.find(data.nearestNode);
|
||
if (it != taxiNodes_.end()) {
|
||
LOG_INFO("Taxi node ", data.nearestNode, " mounts: A=", it->second.mountDisplayIdAlliance,
|
||
" H=", it->second.mountDisplayIdHorde);
|
||
}
|
||
LOG_INFO("Taxi window opened, nearest node=", data.nearestNode);
|
||
}
|
||
|
||
void GameHandler::applyTaxiMountForCurrentNode() {
|
||
if (taxiMountActive_ || !mountCallback_) return;
|
||
auto it = taxiNodes_.find(currentTaxiData_.nearestNode);
|
||
if (it == taxiNodes_.end()) {
|
||
// Node not in DBC (custom server nodes, missing data) — use hardcoded fallback.
|
||
bool isAlliance = true;
|
||
switch (playerRace_) {
|
||
case Race::ORC: case Race::UNDEAD: case Race::TAUREN: case Race::TROLL:
|
||
case Race::GOBLIN: case Race::BLOOD_ELF:
|
||
isAlliance = false; break;
|
||
default: break;
|
||
}
|
||
uint32_t mountId = isAlliance ? 1210u : 1310u;
|
||
taxiMountDisplayId_ = mountId;
|
||
taxiMountActive_ = true;
|
||
LOG_INFO("Taxi mount fallback (node ", currentTaxiData_.nearestNode, " not in DBC): displayId=", mountId);
|
||
mountCallback_(mountId);
|
||
return;
|
||
}
|
||
|
||
bool isAlliance = true;
|
||
switch (playerRace_) {
|
||
case Race::ORC:
|
||
case Race::UNDEAD:
|
||
case Race::TAUREN:
|
||
case Race::TROLL:
|
||
case Race::GOBLIN:
|
||
case Race::BLOOD_ELF:
|
||
isAlliance = false;
|
||
break;
|
||
default:
|
||
isAlliance = true;
|
||
break;
|
||
}
|
||
uint32_t mountId = isAlliance ? it->second.mountDisplayIdAlliance
|
||
: it->second.mountDisplayIdHorde;
|
||
if (mountId == 541) mountId = 0; // Placeholder/invalid in some DBC sets
|
||
if (mountId == 0) {
|
||
mountId = isAlliance ? it->second.mountDisplayIdHorde
|
||
: it->second.mountDisplayIdAlliance;
|
||
if (mountId == 541) mountId = 0;
|
||
}
|
||
if (mountId == 0) {
|
||
auto& app = core::Application::getInstance();
|
||
uint32_t gryphonId = app.getGryphonDisplayId();
|
||
uint32_t wyvernId = app.getWyvernDisplayId();
|
||
if (isAlliance && gryphonId != 0) mountId = gryphonId;
|
||
if (!isAlliance && wyvernId != 0) mountId = wyvernId;
|
||
if (mountId == 0) {
|
||
mountId = (isAlliance ? wyvernId : gryphonId);
|
||
}
|
||
}
|
||
if (mountId == 0) {
|
||
// Fallback: any non-zero mount display from the node.
|
||
if (it->second.mountDisplayIdAlliance != 0) mountId = it->second.mountDisplayIdAlliance;
|
||
else if (it->second.mountDisplayIdHorde != 0) mountId = it->second.mountDisplayIdHorde;
|
||
}
|
||
if (mountId == 0) {
|
||
// 3.3.5a fallback display IDs (real CreatureDisplayInfo entries).
|
||
// Alliance taxi gryphons commonly use 1210-1213.
|
||
// Horde taxi wyverns commonly use 1310-1312.
|
||
static const uint32_t kAllianceTaxiDisplays[] = {1210u, 1211u, 1212u, 1213u};
|
||
static const uint32_t kHordeTaxiDisplays[] = {1310u, 1311u, 1312u};
|
||
mountId = isAlliance ? kAllianceTaxiDisplays[0] : kHordeTaxiDisplays[0];
|
||
}
|
||
|
||
// Last resort legacy fallback.
|
||
if (mountId == 0) {
|
||
mountId = isAlliance ? 30412u : 30413u;
|
||
}
|
||
if (mountId != 0) {
|
||
taxiMountDisplayId_ = mountId;
|
||
taxiMountActive_ = true;
|
||
LOG_INFO("Taxi mount apply: displayId=", mountId);
|
||
mountCallback_(mountId);
|
||
}
|
||
}
|
||
|
||
void GameHandler::startClientTaxiPath(const std::vector<uint32_t>& pathNodes) {
|
||
taxiClientPath_.clear();
|
||
taxiClientIndex_ = 0;
|
||
taxiClientActive_ = false;
|
||
taxiClientSegmentProgress_ = 0.0f;
|
||
|
||
// Build full spline path using TaxiPathNode waypoints (not just node positions)
|
||
for (size_t i = 0; i + 1 < pathNodes.size(); i++) {
|
||
uint32_t fromNode = pathNodes[i];
|
||
uint32_t toNode = pathNodes[i + 1];
|
||
// Find the pathId connecting these nodes
|
||
uint32_t pathId = 0;
|
||
for (const auto& edge : taxiPathEdges_) {
|
||
if (edge.fromNode == fromNode && edge.toNode == toNode) {
|
||
pathId = edge.pathId;
|
||
break;
|
||
}
|
||
}
|
||
if (pathId == 0) {
|
||
LOG_WARNING("No taxi path found from node ", fromNode, " to ", toNode);
|
||
continue;
|
||
}
|
||
// Get spline waypoints for this path segment
|
||
auto pathIt = taxiPathNodes_.find(pathId);
|
||
if (pathIt != taxiPathNodes_.end()) {
|
||
for (const auto& wpNode : pathIt->second) {
|
||
glm::vec3 serverPos(wpNode.x, wpNode.y, wpNode.z);
|
||
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
|
||
taxiClientPath_.push_back(canonical);
|
||
}
|
||
} else {
|
||
LOG_WARNING("No spline waypoints found for taxi pathId ", pathId);
|
||
}
|
||
}
|
||
|
||
if (taxiClientPath_.size() < 2) {
|
||
// Fallback: use TaxiNodes directly when TaxiPathNode spline data is missing.
|
||
taxiClientPath_.clear();
|
||
for (uint32_t nodeId : pathNodes) {
|
||
auto nodeIt = taxiNodes_.find(nodeId);
|
||
if (nodeIt == taxiNodes_.end()) continue;
|
||
glm::vec3 serverPos(nodeIt->second.x, nodeIt->second.y, nodeIt->second.z);
|
||
taxiClientPath_.push_back(core::coords::serverToCanonical(serverPos));
|
||
}
|
||
}
|
||
|
||
if (taxiClientPath_.size() < 2) {
|
||
LOG_WARNING("Taxi path too short: ", taxiClientPath_.size(), " waypoints");
|
||
return;
|
||
}
|
||
|
||
// Set initial orientation to face the first non-degenerate flight segment.
|
||
glm::vec3 start = taxiClientPath_[0];
|
||
glm::vec3 dir(0.0f);
|
||
float dirLen = 0.0f;
|
||
for (size_t i = 1; i < taxiClientPath_.size(); i++) {
|
||
dir = taxiClientPath_[i] - start;
|
||
dirLen = glm::length(dir);
|
||
if (dirLen >= 0.001f) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
float initialOrientation = movementInfo.orientation;
|
||
float initialRenderYaw = movementInfo.orientation;
|
||
float initialPitch = 0.0f;
|
||
float initialRoll = 0.0f;
|
||
if (dirLen >= 0.001f) {
|
||
initialOrientation = std::atan2(dir.y, dir.x);
|
||
glm::vec3 renderDir = core::coords::canonicalToRender(dir);
|
||
initialRenderYaw = std::atan2(renderDir.y, renderDir.x);
|
||
glm::vec3 dirNorm = dir / dirLen;
|
||
initialPitch = std::asin(std::clamp(dirNorm.z, -1.0f, 1.0f));
|
||
}
|
||
|
||
movementInfo.x = start.x;
|
||
movementInfo.y = start.y;
|
||
movementInfo.z = start.z;
|
||
movementInfo.orientation = initialOrientation;
|
||
sanitizeMovementForTaxi();
|
||
|
||
auto playerEntity = entityManager.getEntity(playerGuid);
|
||
if (playerEntity) {
|
||
playerEntity->setPosition(start.x, start.y, start.z, initialOrientation);
|
||
}
|
||
|
||
if (taxiOrientationCallback_) {
|
||
taxiOrientationCallback_(initialRenderYaw, initialPitch, initialRoll);
|
||
}
|
||
|
||
LOG_INFO("Taxi flight started with ", taxiClientPath_.size(), " spline waypoints");
|
||
taxiClientActive_ = true;
|
||
}
|
||
|
||
void GameHandler::updateClientTaxi(float deltaTime) {
|
||
if (!taxiClientActive_ || taxiClientPath_.size() < 2) return;
|
||
auto playerEntity = entityManager.getEntity(playerGuid);
|
||
|
||
auto finishTaxiFlight = [&]() {
|
||
// Snap player to the last waypoint (landing position) before clearing state.
|
||
// Without this, the player would be left at whatever mid-flight position
|
||
// they were at when the path completion was detected.
|
||
if (!taxiClientPath_.empty()) {
|
||
const auto& landingPos = taxiClientPath_.back();
|
||
if (playerEntity) {
|
||
playerEntity->setPosition(landingPos.x, landingPos.y, landingPos.z,
|
||
movementInfo.orientation);
|
||
}
|
||
movementInfo.x = landingPos.x;
|
||
movementInfo.y = landingPos.y;
|
||
movementInfo.z = landingPos.z;
|
||
LOG_INFO("Taxi landing: snapped to final waypoint (",
|
||
landingPos.x, ", ", landingPos.y, ", ", landingPos.z, ")");
|
||
}
|
||
taxiClientActive_ = false;
|
||
onTaxiFlight_ = false;
|
||
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
|
||
if (taxiMountActive_ && mountCallback_) {
|
||
mountCallback_(0);
|
||
}
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
currentMountDisplayId_ = 0;
|
||
taxiClientPath_.clear();
|
||
taxiRecoverPending_ = false;
|
||
movementInfo.flags = 0;
|
||
movementInfo.flags2 = 0;
|
||
if (socket) {
|
||
sendMovement(Opcode::MSG_MOVE_STOP);
|
||
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
||
}
|
||
LOG_INFO("Taxi flight landed (client path)");
|
||
};
|
||
|
||
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
|
||
finishTaxiFlight();
|
||
return;
|
||
}
|
||
|
||
float remainingDistance = taxiClientSegmentProgress_ + (taxiClientSpeed_ * deltaTime);
|
||
glm::vec3 start(0.0f);
|
||
glm::vec3 end(0.0f);
|
||
glm::vec3 dir(0.0f);
|
||
float segmentLen = 0.0f;
|
||
float t = 0.0f;
|
||
|
||
// Consume as many tiny/finished segments as needed this frame so taxi doesn't stall
|
||
// on dense/degenerate node clusters near takeoff/landing.
|
||
while (true) {
|
||
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
|
||
finishTaxiFlight();
|
||
return;
|
||
}
|
||
|
||
start = taxiClientPath_[taxiClientIndex_];
|
||
end = taxiClientPath_[taxiClientIndex_ + 1];
|
||
dir = end - start;
|
||
segmentLen = glm::length(dir);
|
||
|
||
if (segmentLen < 0.01f) {
|
||
taxiClientIndex_++;
|
||
continue;
|
||
}
|
||
|
||
if (remainingDistance >= segmentLen) {
|
||
remainingDistance -= segmentLen;
|
||
taxiClientIndex_++;
|
||
taxiClientSegmentProgress_ = 0.0f;
|
||
continue;
|
||
}
|
||
|
||
taxiClientSegmentProgress_ = remainingDistance;
|
||
t = taxiClientSegmentProgress_ / segmentLen;
|
||
break;
|
||
}
|
||
|
||
// Use Catmull-Rom spline for smooth interpolation between waypoints
|
||
// Get surrounding points for spline curve
|
||
glm::vec3 p0 = (taxiClientIndex_ > 0) ? taxiClientPath_[taxiClientIndex_ - 1] : start;
|
||
glm::vec3 p1 = start;
|
||
glm::vec3 p2 = end;
|
||
glm::vec3 p3 = (taxiClientIndex_ + 2 < taxiClientPath_.size()) ?
|
||
taxiClientPath_[taxiClientIndex_ + 2] : end;
|
||
|
||
// Catmull-Rom spline formula for smooth curves
|
||
float t2 = t * t;
|
||
float t3 = t2 * t;
|
||
glm::vec3 nextPos = 0.5f * (
|
||
(2.0f * p1) +
|
||
(-p0 + p2) * t +
|
||
(2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 +
|
||
(-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3
|
||
);
|
||
|
||
// Calculate smooth direction for orientation (tangent to spline)
|
||
glm::vec3 tangent = 0.5f * (
|
||
(-p0 + p2) +
|
||
2.0f * (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t +
|
||
3.0f * (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t2
|
||
);
|
||
float tangentLen = glm::length(tangent);
|
||
if (tangentLen < 0.0001f) {
|
||
tangent = dir;
|
||
tangentLen = glm::length(tangent);
|
||
if (tangentLen < 0.0001f) {
|
||
tangent = glm::vec3(std::cos(movementInfo.orientation), std::sin(movementInfo.orientation), 0.0f);
|
||
tangentLen = glm::length(tangent);
|
||
}
|
||
}
|
||
|
||
// Calculate yaw from horizontal direction
|
||
float targetOrientation = std::atan2(tangent.y, tangent.x);
|
||
|
||
// Calculate pitch from vertical component (altitude change)
|
||
glm::vec3 tangentNorm = tangent / std::max(tangentLen, 0.0001f);
|
||
float pitch = std::asin(std::clamp(tangentNorm.z, -1.0f, 1.0f));
|
||
|
||
// Calculate roll (banking) from rate of yaw change
|
||
float currentOrientation = movementInfo.orientation;
|
||
float orientDiff = targetOrientation - currentOrientation;
|
||
// Normalize angle difference to [-PI, PI]
|
||
while (orientDiff > 3.14159265f) orientDiff -= 6.28318530f;
|
||
while (orientDiff < -3.14159265f) orientDiff += 6.28318530f;
|
||
// Bank proportional to turn rate (scaled for visual effect)
|
||
float roll = -orientDiff * 2.5f;
|
||
roll = std::clamp(roll, -0.7f, 0.7f); // Limit to ~40 degrees
|
||
|
||
// Smooth rotation transition (lerp towards target)
|
||
float smoothOrientation = currentOrientation + orientDiff * std::min(1.0f, deltaTime * 3.0f);
|
||
|
||
if (playerEntity) {
|
||
playerEntity->setPosition(nextPos.x, nextPos.y, nextPos.z, smoothOrientation);
|
||
}
|
||
movementInfo.x = nextPos.x;
|
||
movementInfo.y = nextPos.y;
|
||
movementInfo.z = nextPos.z;
|
||
movementInfo.orientation = smoothOrientation;
|
||
|
||
// Update mount rotation with yaw/pitch/roll. Use render-space tangent yaw to
|
||
// avoid canonical<->render convention mismatches.
|
||
if (taxiOrientationCallback_) {
|
||
glm::vec3 renderTangent = core::coords::canonicalToRender(tangent);
|
||
float renderYaw = std::atan2(renderTangent.y, renderTangent.x);
|
||
taxiOrientationCallback_(renderYaw, pitch, roll);
|
||
}
|
||
}
|
||
|
||
void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
|
||
ActivateTaxiReplyData data;
|
||
if (!ActivateTaxiReplyParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_ACTIVATETAXIREPLY");
|
||
return;
|
||
}
|
||
|
||
// Guard against stray/mis-mapped packets being treated as taxi replies.
|
||
// We only consume a reply while an activation request is pending.
|
||
if (!taxiActivatePending_) {
|
||
LOG_DEBUG("Ignoring stray taxi reply: result=", data.result);
|
||
return;
|
||
}
|
||
|
||
if (data.result == 0) {
|
||
// Some cores can emit duplicate success replies (e.g. basic + express activate).
|
||
// Ignore repeats once taxi is already active and no activation is pending.
|
||
if (onTaxiFlight_ && !taxiActivatePending_) {
|
||
return;
|
||
}
|
||
onTaxiFlight_ = true;
|
||
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
|
||
sanitizeMovementForTaxi();
|
||
taxiWindowOpen_ = false;
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
applyTaxiMountForCurrentNode();
|
||
if (socket) {
|
||
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
||
}
|
||
LOG_INFO("Taxi flight started!");
|
||
} else {
|
||
// If local taxi motion already started, treat late failure as stale and ignore.
|
||
if (onTaxiFlight_ || taxiClientActive_) {
|
||
LOG_WARNING("Ignoring stale taxi failure reply while flight is active: result=", data.result);
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
return;
|
||
}
|
||
LOG_WARNING("Taxi activation failed, result=", data.result);
|
||
addSystemChatMessage("Cannot take that flight path.");
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
if (taxiMountActive_ && mountCallback_) {
|
||
mountCallback_(0);
|
||
}
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
onTaxiFlight_ = false;
|
||
}
|
||
}
|
||
|
||
void GameHandler::closeTaxi() {
|
||
taxiWindowOpen_ = false;
|
||
|
||
// Closing the taxi UI must not cancel an active/pending flight.
|
||
// The window can auto-close due distance checks while takeoff begins.
|
||
if (taxiActivatePending_ || onTaxiFlight_ || taxiClientActive_) {
|
||
return;
|
||
}
|
||
|
||
// If we optimistically mounted during node selection, dismount now
|
||
if (taxiMountActive_ && mountCallback_) {
|
||
mountCallback_(0); // Dismount
|
||
}
|
||
taxiMountActive_ = false;
|
||
taxiMountDisplayId_ = 0;
|
||
|
||
// Clear any pending activation
|
||
taxiActivatePending_ = false;
|
||
onTaxiFlight_ = false;
|
||
|
||
// Set cooldown to prevent auto-mount trigger from re-applying taxi mount
|
||
// (The UNIT_FLAG_TAXI_FLIGHT check in handleUpdateObject won't re-trigger during cooldown)
|
||
taxiLandingCooldown_ = 2.0f;
|
||
}
|
||
|
||
void GameHandler::buildTaxiCostMap() {
|
||
taxiCostMap_.clear();
|
||
uint32_t startNode = currentTaxiData_.nearestNode;
|
||
if (startNode == 0) return;
|
||
|
||
// Build adjacency list with costs from all edges (path may traverse unknown nodes)
|
||
struct AdjEntry { uint32_t node; uint32_t cost; };
|
||
std::unordered_map<uint32_t, std::vector<AdjEntry>> adj;
|
||
for (const auto& edge : taxiPathEdges_) {
|
||
adj[edge.fromNode].push_back({edge.toNode, edge.cost});
|
||
}
|
||
|
||
// BFS from startNode, accumulating costs along the path
|
||
std::deque<uint32_t> queue;
|
||
queue.push_back(startNode);
|
||
taxiCostMap_[startNode] = 0;
|
||
|
||
while (!queue.empty()) {
|
||
uint32_t cur = queue.front();
|
||
queue.pop_front();
|
||
for (const auto& next : adj[cur]) {
|
||
if (taxiCostMap_.find(next.node) == taxiCostMap_.end()) {
|
||
taxiCostMap_[next.node] = taxiCostMap_[cur] + next.cost;
|
||
queue.push_back(next.node);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
uint32_t GameHandler::getTaxiCostTo(uint32_t destNodeId) const {
|
||
auto it = taxiCostMap_.find(destNodeId);
|
||
return (it != taxiCostMap_.end()) ? it->second : 0;
|
||
}
|
||
|
||
void GameHandler::activateTaxi(uint32_t destNodeId) {
|
||
if (!socket || state != WorldState::IN_WORLD) return;
|
||
|
||
// One-shot taxi activation until server replies or timeout.
|
||
if (taxiActivatePending_ || onTaxiFlight_) {
|
||
return;
|
||
}
|
||
|
||
uint32_t startNode = currentTaxiData_.nearestNode;
|
||
if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return;
|
||
|
||
// If already mounted, dismount before starting a taxi flight.
|
||
if (isMounted()) {
|
||
LOG_INFO("Taxi activate: dismounting current mount");
|
||
if (mountCallback_) mountCallback_(0);
|
||
currentMountDisplayId_ = 0;
|
||
dismount();
|
||
}
|
||
|
||
{
|
||
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<uint32_t, std::vector<uint32_t>> adj;
|
||
for (const auto& edge : taxiPathEdges_) {
|
||
adj[edge.fromNode].push_back(edge.toNode);
|
||
}
|
||
|
||
std::unordered_map<uint32_t, uint32_t> parent;
|
||
std::deque<uint32_t> queue;
|
||
queue.push_back(startNode);
|
||
parent[startNode] = startNode;
|
||
|
||
bool found = false;
|
||
while (!queue.empty()) {
|
||
uint32_t cur = queue.front();
|
||
queue.pop_front();
|
||
if (cur == destNodeId) { found = true; break; }
|
||
for (uint32_t next : adj[cur]) {
|
||
if (parent.find(next) == parent.end()) {
|
||
parent[next] = cur;
|
||
queue.push_back(next);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!found) {
|
||
LOG_WARNING("No taxi path found from node ", startNode, " to ", destNodeId);
|
||
addSystemChatMessage("No flight path available to that destination.");
|
||
return;
|
||
}
|
||
|
||
std::vector<uint32_t> path;
|
||
for (uint32_t n = destNodeId; n != startNode; n = parent[n]) {
|
||
path.push_back(n);
|
||
}
|
||
path.push_back(startNode);
|
||
std::reverse(path.begin(), path.end());
|
||
|
||
LOG_INFO("Taxi path: ", path.size(), " nodes, from ", startNode, " to ", destNodeId);
|
||
|
||
LOG_INFO("Taxi activate: npc=0x", std::hex, taxiNpcGuid_, std::dec,
|
||
" start=", startNode, " dest=", destNodeId, " pathLen=", path.size());
|
||
if (!path.empty()) {
|
||
std::string pathStr;
|
||
for (size_t i = 0; i < path.size(); i++) {
|
||
pathStr += std::to_string(path[i]);
|
||
if (i + 1 < path.size()) pathStr += "->";
|
||
}
|
||
LOG_INFO("Taxi path nodes: ", pathStr);
|
||
}
|
||
|
||
uint32_t totalCost = getTaxiCostTo(destNodeId);
|
||
LOG_INFO("Taxi activate: start=", startNode, " dest=", destNodeId, " cost=", totalCost);
|
||
|
||
// Some servers only accept basic CMSG_ACTIVATETAXI.
|
||
auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId);
|
||
socket->send(basicPkt);
|
||
|
||
// AzerothCore in this setup rejects/misparses CMSG_ACTIVATETAXIEXPRESS (0x312),
|
||
// so keep taxi activation on the basic packet only.
|
||
|
||
// Optimistically start taxi visuals; server will correct if it denies.
|
||
taxiWindowOpen_ = false;
|
||
taxiActivatePending_ = true;
|
||
taxiActivateTimer_ = 0.0f;
|
||
taxiStartGrace_ = 2.0f;
|
||
if (!onTaxiFlight_) {
|
||
onTaxiFlight_ = true;
|
||
sanitizeMovementForTaxi();
|
||
applyTaxiMountForCurrentNode();
|
||
}
|
||
if (socket) {
|
||
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
||
}
|
||
|
||
// Trigger terrain precache immediately (non-blocking).
|
||
if (taxiPrecacheCallback_) {
|
||
std::vector<glm::vec3> previewPath;
|
||
// Build full spline path using TaxiPathNode waypoints
|
||
for (size_t i = 0; i + 1 < path.size(); i++) {
|
||
uint32_t fromNode = path[i];
|
||
uint32_t toNode = path[i + 1];
|
||
// Find the pathId connecting these nodes
|
||
uint32_t pathId = 0;
|
||
for (const auto& edge : taxiPathEdges_) {
|
||
if (edge.fromNode == fromNode && edge.toNode == toNode) {
|
||
pathId = edge.pathId;
|
||
break;
|
||
}
|
||
}
|
||
if (pathId == 0) continue;
|
||
// Get spline waypoints for this path segment
|
||
auto pathIt = taxiPathNodes_.find(pathId);
|
||
if (pathIt != taxiPathNodes_.end()) {
|
||
for (const auto& wpNode : pathIt->second) {
|
||
glm::vec3 serverPos(wpNode.x, wpNode.y, wpNode.z);
|
||
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
|
||
previewPath.push_back(canonical);
|
||
}
|
||
}
|
||
}
|
||
if (previewPath.size() >= 2) {
|
||
taxiPrecacheCallback_(previewPath);
|
||
}
|
||
}
|
||
|
||
// Flight starts immediately; upload callback stays opportunistic/non-blocking.
|
||
if (taxiFlightStartCallback_) {
|
||
taxiFlightStartCallback_();
|
||
}
|
||
startClientTaxiPath(path);
|
||
// We run taxi movement locally immediately; don't keep a long-lived pending state.
|
||
if (taxiClientActive_) {
|
||
taxiActivatePending_ = false;
|
||
taxiActivateTimer_ = 0.0f;
|
||
}
|
||
|
||
// 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<time_t>(data.serverTime);
|
||
struct tm* timeInfo = localtime(&serverTime);
|
||
char timeStr[64];
|
||
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", timeInfo);
|
||
|
||
std::string msg = "Server time: " + std::string(timeStr);
|
||
addSystemChatMessage(msg);
|
||
LOG_INFO("Server time: ", data.serverTime, " (", timeStr, ")");
|
||
}
|
||
|
||
void GameHandler::handlePlayedTime(network::Packet& packet) {
|
||
PlayedTimeData data;
|
||
if (!PlayedTimeParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_PLAYED_TIME");
|
||
return;
|
||
}
|
||
|
||
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: ", static_cast<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=", static_cast<int>(status));
|
||
} else {
|
||
LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec,
|
||
" status=", static_cast<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));
|
||
}
|
||
fireAddonEvent("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_) {
|
||
fireAddonEvent("FRIENDLIST_UPDATE", {});
|
||
if (lastContactListMask_ & 0x2) // ignore list
|
||
fireAddonEvent("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: ", static_cast<int>(data.status), " for ", playerName);
|
||
break;
|
||
}
|
||
|
||
LOG_INFO("Friend status update: ", playerName, " status=", static_cast<int>(data.status));
|
||
fireAddonEvent("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=", static_cast<int>(data.instant));
|
||
fireAddonEvent("PLAYER_LOGOUT", {});
|
||
} 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<uint32_t> dis(1, 0xFFFFFFFF);
|
||
return dis(gen);
|
||
}
|
||
|
||
void GameHandler::setState(WorldState newState) {
|
||
if (state != newState) {
|
||
LOG_DEBUG("World state: ", static_cast<int>(state), " -> ", static_cast<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<uint16_t, uint32_t>& fields) {
|
||
loadSkillLineDbc();
|
||
|
||
const uint16_t PLAYER_SKILL_INFO_START = fieldIndex(UF::PLAYER_SKILL_INFO_START);
|
||
static constexpr int MAX_SKILL_SLOTS = 128;
|
||
|
||
std::map<uint32_t, PlayerSkill> newSkills;
|
||
|
||
for (int slot = 0; slot < MAX_SKILL_SLOTS; slot++) {
|
||
uint16_t baseField = PLAYER_SKILL_INFO_START + slot * 3;
|
||
|
||
auto idIt = fields.find(baseField);
|
||
if (idIt == fields.end()) continue;
|
||
|
||
uint32_t raw0 = idIt->second;
|
||
uint16_t skillId = raw0 & 0xFFFF;
|
||
if (skillId == 0) continue;
|
||
|
||
auto valIt = fields.find(baseField + 1);
|
||
if (valIt == fields.end()) continue;
|
||
|
||
uint32_t raw1 = valIt->second;
|
||
uint16_t value = raw1 & 0xFFFF;
|
||
uint16_t maxValue = (raw1 >> 16) & 0xFFFF;
|
||
|
||
uint16_t bonusTemp = 0;
|
||
uint16_t bonusPerm = 0;
|
||
auto bonusIt = fields.find(static_cast<uint16_t>(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)
|
||
fireAddonEvent("SKILL_LINES_CHANGED", {});
|
||
}
|
||
|
||
void GameHandler::extractExploredZoneFields(const std::map<uint16_t, uint32_t>& 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<size_t>(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<uint16_t>(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i);
|
||
auto it = fields.find(fieldIdx);
|
||
if (it == fields.end()) continue;
|
||
playerExploredZones_[i] = it->second;
|
||
foundAny = true;
|
||
}
|
||
// 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<int>(ch->gender) << "\n";
|
||
// For male/female, derive from gender; only nonbinary has a meaningful separate choice
|
||
bool saveUseFemaleModel = (ch->gender == Gender::NONBINARY) ? ch->useFemaleModel
|
||
: (ch->gender == Gender::FEMALE);
|
||
out << "use_female_model=" << (saveUseFemaleModel ? 1 : 0) << "\n";
|
||
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
|
||
out << "action_bar_" << i << "_type=" << static_cast<int>(actionBar[i].type) << "\n";
|
||
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
|
||
}
|
||
|
||
// Save 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<int, ACTION_BAR_SLOTS> types{};
|
||
std::array<uint32_t, ACTION_BAR_SLOTS> ids{};
|
||
bool hasSlots = false;
|
||
int savedGender = -1;
|
||
int savedUseFemaleModel = -1;
|
||
|
||
std::string line;
|
||
while (std::getline(in, line)) {
|
||
size_t eq = line.find('=');
|
||
if (eq == std::string::npos) continue;
|
||
std::string key = line.substr(0, eq);
|
||
std::string val = line.substr(eq + 1);
|
||
|
||
if (key == "character_guid") {
|
||
try { savedGuid = std::stoull(val); } catch (...) {}
|
||
} else if (key == "gender") {
|
||
try { savedGender = std::stoi(val); } catch (...) {}
|
||
} else if (key == "use_female_model") {
|
||
try { savedUseFemaleModel = std::stoi(val); } catch (...) {}
|
||
} else if (key.rfind("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<uint32_t>(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<uint32_t>(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<uint32_t>(std::stoul(val));
|
||
hasSlots = true;
|
||
}
|
||
} catch (...) {}
|
||
}
|
||
}
|
||
|
||
// Validate guid matches current character
|
||
if (savedGuid != 0 && savedGuid != playerGuid) {
|
||
LOG_WARNING("Character config guid mismatch for ", ch->name, ", using defaults");
|
||
return;
|
||
}
|
||
|
||
// Apply saved gender and body type (allows nonbinary to persist even though server only stores male/female)
|
||
if (savedGender >= 0 && savedGender <= 2) {
|
||
for (auto& character : characters) {
|
||
if (character.guid == playerGuid) {
|
||
character.gender = static_cast<Gender>(savedGender);
|
||
if (character.gender == Gender::NONBINARY) {
|
||
// Only nonbinary characters have a meaningful body type choice
|
||
if (savedUseFemaleModel >= 0) {
|
||
character.useFemaleModel = (savedUseFemaleModel != 0);
|
||
}
|
||
} else {
|
||
// Male/female always use the model matching their gender
|
||
character.useFemaleModel = (character.gender == Gender::FEMALE);
|
||
}
|
||
LOG_INFO("Applied saved gender: ", getGenderName(character.gender),
|
||
", body type: ", (character.useFemaleModel ? "feminine" : "masculine"));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasSlots) {
|
||
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
|
||
actionBar[i].type = static_cast<ActionBarSlot::Type>(types[i]);
|
||
actionBar[i].id = ids[i];
|
||
}
|
||
LOG_INFO("Character config loaded from ", path);
|
||
}
|
||
}
|
||
|
||
void GameHandler::setTransportAttachment(uint64_t childGuid, ObjectType type, uint64_t transportGuid,
|
||
const glm::vec3& localOffset, bool hasLocalOrientation,
|
||
float localOrientation) {
|
||
if (childGuid == 0 || transportGuid == 0) {
|
||
return;
|
||
}
|
||
|
||
TransportAttachment& attachment = transportAttachments_[childGuid];
|
||
attachment.type = type;
|
||
attachment.transportGuid = transportGuid;
|
||
attachment.localOffset = localOffset;
|
||
attachment.hasLocalOrientation = hasLocalOrientation;
|
||
attachment.localOrientation = localOrientation;
|
||
}
|
||
|
||
void GameHandler::clearTransportAttachment(uint64_t childGuid) {
|
||
if (childGuid == 0) {
|
||
return;
|
||
}
|
||
transportAttachments_.erase(childGuid);
|
||
}
|
||
|
||
void GameHandler::updateAttachedTransportChildren(float /*deltaTime*/) {
|
||
if (!transportManager_ || transportAttachments_.empty()) {
|
||
return;
|
||
}
|
||
|
||
constexpr float kPosEpsilonSq = 0.0001f;
|
||
constexpr float kOriEpsilon = 0.001f;
|
||
std::vector<uint64_t> stale;
|
||
stale.reserve(8);
|
||
|
||
for (const auto& [childGuid, attachment] : transportAttachments_) {
|
||
auto entity = entityManager.getEntity(childGuid);
|
||
if (!entity) {
|
||
stale.push_back(childGuid);
|
||
continue;
|
||
}
|
||
|
||
ActiveTransport* transport = transportManager_->getTransport(attachment.transportGuid);
|
||
if (!transport) {
|
||
continue;
|
||
}
|
||
|
||
glm::vec3 composed = transportManager_->getPlayerWorldPosition(
|
||
attachment.transportGuid, attachment.localOffset);
|
||
|
||
float composedOrientation = entity->getOrientation();
|
||
if (attachment.hasLocalOrientation) {
|
||
float baseYaw = transport->hasServerYaw ? transport->serverYaw : 0.0f;
|
||
composedOrientation = baseYaw + attachment.localOrientation;
|
||
}
|
||
|
||
glm::vec3 oldPos(entity->getX(), entity->getY(), entity->getZ());
|
||
float oldOrientation = entity->getOrientation();
|
||
glm::vec3 delta = composed - oldPos;
|
||
const bool positionChanged = glm::dot(delta, delta) > kPosEpsilonSq;
|
||
const bool orientationChanged = std::abs(composedOrientation - oldOrientation) > kOriEpsilon;
|
||
if (!positionChanged && !orientationChanged) {
|
||
continue;
|
||
}
|
||
|
||
entity->setPosition(composed.x, composed.y, composed.z, composedOrientation);
|
||
|
||
if (attachment.type == ObjectType::UNIT) {
|
||
if (creatureMoveCallback_) {
|
||
creatureMoveCallback_(childGuid, composed.x, composed.y, composed.z, 0);
|
||
}
|
||
} else if (attachment.type == ObjectType::GAMEOBJECT) {
|
||
if (gameObjectMoveCallback_) {
|
||
gameObjectMoveCallback_(childGuid, composed.x, composed.y, composed.z, composedOrientation);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (uint64_t guid : stale) {
|
||
transportAttachments_.erase(guid);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Mail System
|
||
// ============================================================
|
||
|
||
void GameHandler::closeMailbox() {
|
||
bool wasOpen = mailboxOpen_;
|
||
mailboxOpen_ = false;
|
||
mailboxGuid_ = 0;
|
||
mailInbox_.clear();
|
||
selectedMailIndex_ = -1;
|
||
showMailCompose_ = false;
|
||
if (wasOpen) fireAddonEvent("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<uint64_t> itemGuids;
|
||
for (const auto& att : mailAttachments_) {
|
||
if (att.occupied()) {
|
||
itemGuids.push_back(att.itemGuid);
|
||
}
|
||
}
|
||
auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod, itemGuids);
|
||
LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money,
|
||
" attachments=", itemGuids.size(), " mailboxGuid=", mailboxGuid_);
|
||
socket->send(packet);
|
||
clearMailAttachments();
|
||
}
|
||
|
||
bool GameHandler::attachItemFromBackpack(int backpackIndex) {
|
||
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return false;
|
||
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
||
if (slot.empty()) return false;
|
||
|
||
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
|
||
if (itemGuid == 0) {
|
||
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
||
}
|
||
if (itemGuid == 0) {
|
||
addSystemChatMessage("Cannot attach: item not found.");
|
||
return false;
|
||
}
|
||
|
||
// Check not already attached
|
||
for (const auto& att : mailAttachments_) {
|
||
if (att.occupied() && att.itemGuid == itemGuid) return false;
|
||
}
|
||
|
||
// Find free attachment slot
|
||
for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) {
|
||
if (!mailAttachments_[i].occupied()) {
|
||
mailAttachments_[i].itemGuid = itemGuid;
|
||
mailAttachments_[i].item = slot.item;
|
||
mailAttachments_[i].srcBag = 0xFF;
|
||
mailAttachments_[i].srcSlot = static_cast<uint8_t>(23 + backpackIndex);
|
||
LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x",
|
||
std::hex, itemGuid, std::dec, " from backpack[", backpackIndex, "]");
|
||
return true;
|
||
}
|
||
}
|
||
addSystemChatMessage("Cannot attach: all attachment slots full.");
|
||
return false;
|
||
}
|
||
|
||
bool GameHandler::attachItemFromBag(int bagIndex, int slotIndex) {
|
||
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return false;
|
||
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return false;
|
||
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
|
||
if (slot.empty()) return false;
|
||
|
||
uint64_t itemGuid = 0;
|
||
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
|
||
if (bagGuid != 0) {
|
||
auto it = containerContents_.find(bagGuid);
|
||
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
|
||
itemGuid = it->second.slotGuids[slotIndex];
|
||
}
|
||
}
|
||
if (itemGuid == 0) {
|
||
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
||
}
|
||
if (itemGuid == 0) {
|
||
addSystemChatMessage("Cannot attach: item not found.");
|
||
return false;
|
||
}
|
||
|
||
for (const auto& att : mailAttachments_) {
|
||
if (att.occupied() && att.itemGuid == itemGuid) return false;
|
||
}
|
||
|
||
for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) {
|
||
if (!mailAttachments_[i].occupied()) {
|
||
mailAttachments_[i].itemGuid = itemGuid;
|
||
mailAttachments_[i].item = slot.item;
|
||
mailAttachments_[i].srcBag = static_cast<uint8_t>(19 + bagIndex);
|
||
mailAttachments_[i].srcSlot = static_cast<uint8_t>(slotIndex);
|
||
LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x",
|
||
std::hex, itemGuid, std::dec, " from bag[", bagIndex, "][", slotIndex, "]");
|
||
return true;
|
||
}
|
||
}
|
||
addSystemChatMessage("Cannot attach: all attachment slots full.");
|
||
return false;
|
||
}
|
||
|
||
bool GameHandler::detachMailAttachment(int attachIndex) {
|
||
if (attachIndex < 0 || attachIndex >= MAIL_MAX_ATTACHMENTS) return false;
|
||
if (!mailAttachments_[attachIndex].occupied()) return false;
|
||
LOG_INFO("Mail detach: slot=", attachIndex, " item='", mailAttachments_[attachIndex].item.name, "'");
|
||
mailAttachments_[attachIndex] = MailAttachSlot{};
|
||
return true;
|
||
}
|
||
|
||
void GameHandler::clearMailAttachments() {
|
||
for (auto& att : mailAttachments_) att = MailAttachSlot{};
|
||
}
|
||
|
||
int GameHandler::getMailAttachmentCount() const {
|
||
int count = 0;
|
||
for (const auto& att : mailAttachments_) {
|
||
if (att.occupied()) ++count;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
void GameHandler::mailTakeMoney(uint32_t mailId) {
|
||
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
||
auto packet = MailTakeMoneyPacket::build(mailboxGuid_, mailId);
|
||
socket->send(packet);
|
||
}
|
||
|
||
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t 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;
|
||
fireAddonEvent("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;
|
||
}
|
||
fireAddonEvent("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.");
|
||
fireAddonEvent("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) fireAddonEvent("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<int>(inventory.getPurchasedBankBagSlots()));
|
||
auto pkt = BuyBankSlotPacket::build(bankerGuid_);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::depositItem(uint8_t srcBag, uint8_t srcSlot) {
|
||
if (!isConnected() || !bankOpen_) return;
|
||
auto pkt = AutoBankItemPacket::build(srcBag, srcSlot);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::withdrawItem(uint8_t srcBag, uint8_t srcSlot) {
|
||
if (!isConnected() || !bankOpen_) return;
|
||
auto pkt = AutoStoreBankItemPacket::build(srcBag, srcSlot);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::handleShowBank(network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 8) return;
|
||
bankerGuid_ = packet.readUInt64();
|
||
bankOpen_ = true;
|
||
gossipWindowOpen = false; // Close gossip when bank opens
|
||
fireAddonEvent("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<int>(inventory.getPurchasedBankBagSlots()),
|
||
" filledBags=", filledBags,
|
||
" effectiveBankBagSlots=", effectiveBankBagSlots_);
|
||
}
|
||
|
||
void GameHandler::handleBuyBankSlotResult(network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||
uint32_t result = packet.readUInt32();
|
||
LOG_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<uint8_t>(guildBankData_.tabs.size());
|
||
auto pkt = GuildBankBuyTabPacket::build(guildBankerGuid_, nextTab);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::depositGuildBankMoney(uint32_t amount) {
|
||
if (!isConnected() || !guildBankOpen_) return;
|
||
auto pkt = GuildBankDepositMoneyPacket::build(guildBankerGuid_, amount);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::withdrawGuildBankMoney(uint32_t amount) {
|
||
if (!isConnected() || !guildBankOpen_) return;
|
||
auto pkt = GuildBankWithdrawMoneyPacket::build(guildBankerGuid_, amount);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::guildBankWithdrawItem(uint8_t tabId, uint8_t bankSlot, uint8_t destBag, uint8_t destSlot) {
|
||
if (!isConnected() || !guildBankOpen_) return;
|
||
auto pkt = GuildBankSwapItemsPacket::buildBankToInventory(guildBankerGuid_, tabId, bankSlot, destBag, destSlot);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::guildBankDepositItem(uint8_t tabId, uint8_t bankSlot, uint8_t srcBag, uint8_t srcSlot) {
|
||
if (!isConnected() || !guildBankOpen_) return;
|
||
auto pkt = GuildBankSwapItemsPacket::buildInventoryToBank(guildBankerGuid_, tabId, bankSlot, srcBag, srcSlot);
|
||
socket->send(pkt);
|
||
}
|
||
|
||
void GameHandler::handleGuildBankList(network::Packet& packet) {
|
||
GuildBankData data;
|
||
if (!GuildBankListParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse SMSG_GUILD_BANK_LIST");
|
||
return;
|
||
}
|
||
guildBankData_ = data;
|
||
guildBankOpen_ = true;
|
||
guildBankActiveTab_ = data.tabId;
|
||
|
||
// Ensure item info for all guild bank items
|
||
for (const auto& item : data.tabItems) {
|
||
if (item.itemEntry != 0) ensureItemInfo(item.itemEntry);
|
||
}
|
||
|
||
LOG_INFO("SMSG_GUILD_BANK_LIST: tab=", static_cast<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) fireAddonEvent("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<size_t>(rawData.size(), 20);
|
||
for (size_t i = 0; i < dumpLen; ++i) {
|
||
char b[4]; snprintf(b, sizeof(b), "%02x ", rawData[i]);
|
||
hex += b;
|
||
}
|
||
LOG_INFO(" hex dump: ", hex);
|
||
AuctionHelloData data;
|
||
if (!AuctionHelloParser::parse(packet, data)) {
|
||
LOG_WARNING("Failed to parse MSG_AUCTION_HELLO response, size=", pktSize, " readPos=", readPos);
|
||
return;
|
||
}
|
||
auctioneerGuid_ = data.auctioneerGuid;
|
||
auctionHouseId_ = data.auctionHouseId;
|
||
auctionOpen_ = true;
|
||
gossipWindowOpen = false; // Close gossip when auction house opens
|
||
fireAddonEvent("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=", static_cast<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=", static_cast<int>(isEmpty),
|
||
" len=", itemText_.size());
|
||
}
|
||
|
||
void GameHandler::queryItemText(uint64_t itemGuid) {
|
||
if (state != WorldState::IN_WORLD || !socket) return;
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_ITEM_TEXT_QUERY));
|
||
pkt.writeUInt64(itemGuid);
|
||
socket->send(pkt);
|
||
LOG_DEBUG("CMSG_ITEM_TEXT_QUERY: guid=0x", std::hex, itemGuid, std::dec);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SMSG_QUEST_CONFIRM_ACCEPT (shared quest from group member)
|
||
// uint32 questId + string questTitle + uint64 sharerGuid
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameHandler::handleQuestConfirmAccept(network::Packet& packet) {
|
||
size_t rem = packet.getSize() - packet.getReadPos();
|
||
if (rem < 4) return;
|
||
|
||
sharedQuestId_ = packet.readUInt32();
|
||
sharedQuestTitle_ = packet.readString();
|
||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||
sharedQuestSharerGuid_ = packet.readUInt64();
|
||
}
|
||
|
||
sharedQuestSharerName_.clear();
|
||
auto entity = entityManager.getEntity(sharedQuestSharerGuid_);
|
||
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
||
sharedQuestSharerName_ = unit->getName();
|
||
}
|
||
if (sharedQuestSharerName_.empty()) {
|
||
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<unsigned long long>(sharedQuestSharerGuid_));
|
||
sharedQuestSharerName_ = tmp;
|
||
}
|
||
|
||
pendingSharedQuest_ = true;
|
||
addSystemChatMessage(sharedQuestSharerName_ + " has shared the quest \"" +
|
||
sharedQuestTitle_ + "\" with you.");
|
||
LOG_INFO("SMSG_QUEST_CONFIRM_ACCEPT: questId=", sharedQuestId_,
|
||
" title=", sharedQuestTitle_, " sharer=", sharedQuestSharerName_);
|
||
}
|
||
|
||
void GameHandler::acceptSharedQuest() {
|
||
if (!pendingSharedQuest_ || !socket) return;
|
||
pendingSharedQuest_ = false;
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_CONFIRM_ACCEPT));
|
||
pkt.writeUInt32(sharedQuestId_);
|
||
socket->send(pkt);
|
||
addSystemChatMessage("Accepted: " + sharedQuestTitle_);
|
||
}
|
||
|
||
void GameHandler::declineSharedQuest() {
|
||
pendingSharedQuest_ = false;
|
||
// No response packet needed — just dismiss the UI
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SMSG_SUMMON_REQUEST
|
||
// uint64 summonerGuid + uint32 zoneId + uint32 timeoutMs
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameHandler::handleSummonRequest(network::Packet& packet) {
|
||
if (packet.getSize() - packet.getReadPos() < 16) return;
|
||
|
||
summonerGuid_ = packet.readUInt64();
|
||
uint32_t zoneId = packet.readUInt32();
|
||
uint32_t timeoutMs = packet.readUInt32();
|
||
summonTimeoutSec_ = timeoutMs / 1000.0f;
|
||
pendingSummonRequest_= true;
|
||
|
||
summonerName_.clear();
|
||
auto entity = entityManager.getEntity(summonerGuid_);
|
||
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
||
summonerName_ = unit->getName();
|
||
}
|
||
if (summonerName_.empty()) {
|
||
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<unsigned long long>(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");
|
||
fireAddonEvent("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<Unit*>(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<unsigned long long>(tradePeerGuid_));
|
||
tradePeerName_ = tmp;
|
||
}
|
||
tradeStatus_ = TradeStatus::PendingIncoming;
|
||
addSystemChatMessage(tradePeerName_ + " wants to trade with you.");
|
||
fireAddonEvent("TRADE_REQUEST", {});
|
||
break;
|
||
}
|
||
case 2: // OPEN_WINDOW
|
||
myTradeSlots_.fill(TradeSlot{});
|
||
peerTradeSlots_.fill(TradeSlot{});
|
||
myTradeGold_ = 0;
|
||
peerTradeGold_ = 0;
|
||
tradeStatus_ = TradeStatus::Open;
|
||
addSystemChatMessage("Trade window opened.");
|
||
fireAddonEvent("TRADE_SHOW", {});
|
||
break;
|
||
case 3: // CANCELLED
|
||
case 12: // CLOSE_WINDOW
|
||
resetTradeState();
|
||
addSystemChatMessage("Trade cancelled.");
|
||
fireAddonEvent("TRADE_CLOSED", {});
|
||
break;
|
||
case 9: // REJECTED — other player clicked Decline
|
||
resetTradeState();
|
||
addSystemChatMessage("Trade declined.");
|
||
fireAddonEvent("TRADE_CLOSED", {});
|
||
break;
|
||
case 4: // ACCEPTED (partner accepted)
|
||
tradeStatus_ = TradeStatus::Accepted;
|
||
addSystemChatMessage("Trade accepted. Awaiting other player...");
|
||
fireAddonEvent("TRADE_ACCEPT_UPDATE", {});
|
||
break;
|
||
case 8: // COMPLETE
|
||
addSystemChatMessage("Trade complete!");
|
||
fireAddonEvent("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=", static_cast<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<uint8_t>(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<Unit*>(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<int32_t>(packet.readUInt32());
|
||
}
|
||
uint8_t rollNum = packet.readUInt8();
|
||
uint8_t rollType = packet.readUInt8();
|
||
|
||
const char* rollNames[] = {"Need", "Greed", "Disenchant"};
|
||
const char* rollName = (rollType < 3) ? rollNames[rollType] : "Roll";
|
||
|
||
std::string winnerName;
|
||
auto entity = entityManager.getEntity(winnerGuid);
|
||
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
||
winnerName = unit->getName();
|
||
}
|
||
if (winnerName.empty()) {
|
||
winnerName = (winnerGuid == playerGuid) ? "You" : "Someone";
|
||
}
|
||
|
||
auto* info = getItemInfo(itemId);
|
||
std::string iName = info && !info->name.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<uint32_t>(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<GameHandler*>(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<Unit*>(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<unsigned long long>(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);
|
||
fireAddonEvent("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<uint32_t> 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<GameHandler*>(this)->loadFactionNameCache();
|
||
auto it = factionRepListToId_.find(repListId);
|
||
return (it != factionRepListToId_.end()) ? it->second : 0u;
|
||
}
|
||
|
||
uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const {
|
||
const_cast<GameHandler*>(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<int32_t>(rl);
|
||
}
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_SET_WATCHED_FACTION));
|
||
pkt.writeUInt32(static_cast<uint32_t>(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<GameHandler*>(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<GameHandler*>(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<GameHandler*>(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<GameHandler*>(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<int32_t>(durationMs);
|
||
playerAuras[slot].receivedAtMs = static_cast<uint64_t>(
|
||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
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<uint8_t>(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
|