2026-02-02 12:24:50 -08:00
|
|
|
|
#include "game/game_handler.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
|
#include "game/packet_parsers.hpp"
|
2026-02-10 21:29:10 -08:00
|
|
|
|
#include "game/transport_manager.hpp"
|
2026-02-12 02:09:15 -08:00
|
|
|
|
#include "game/warden_crypto.hpp"
|
2026-02-14 02:00:15 -08:00
|
|
|
|
#include "game/warden_memory.hpp"
|
2026-02-12 02:43:20 -08:00
|
|
|
|
#include "game/warden_module.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include "game/opcodes.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
|
#include "game/update_field_table.hpp"
|
2026-02-18 04:18:51 -08:00
|
|
|
|
#include "game/expansion_profile.hpp"
|
2026-02-14 15:11:43 -08:00
|
|
|
|
#include "rendering/renderer.hpp"
|
2026-02-19 21:31:37 -08:00
|
|
|
|
#include "audio/activity_sound_manager.hpp"
|
|
|
|
|
|
#include "audio/combat_sound_manager.hpp"
|
2026-02-17 03:50:36 -08:00
|
|
|
|
#include "audio/spell_sound_manager.hpp"
|
2026-02-18 03:37:03 -08:00
|
|
|
|
#include "audio/ui_sound_manager.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
|
#include "pipeline/dbc_layout.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include "network/world_socket.hpp"
|
|
|
|
|
|
#include "network/packet.hpp"
|
2026-02-12 02:22:04 -08:00
|
|
|
|
#include "auth/crypto.hpp"
|
2026-02-04 18:27:52 -08:00
|
|
|
|
#include "core/coordinates.hpp"
|
2026-02-07 14:21:50 -08:00
|
|
|
|
#include "core/application.hpp"
|
|
|
|
|
|
#include "pipeline/asset_manager.hpp"
|
|
|
|
|
|
#include "pipeline/dbc_loader.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include "core/logger.hpp"
|
2026-02-12 00:04:53 -08:00
|
|
|
|
#include <glm/gtx/quaternion.hpp>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <algorithm>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <cmath>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <cctype>
|
2026-02-07 12:43:32 -08:00
|
|
|
|
#include <ctime>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <random>
|
2026-02-13 18:59:09 -08:00
|
|
|
|
#include <zlib.h>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <chrono>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <filesystem>
|
|
|
|
|
|
#include <fstream>
|
|
|
|
|
|
#include <sstream>
|
|
|
|
|
|
#include <unordered_map>
|
2026-02-11 22:27:02 -08:00
|
|
|
|
#include <unordered_set>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <functional>
|
2026-02-20 01:57:21 -08:00
|
|
|
|
#include <array>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <cstdlib>
|
2026-02-13 18:59:09 -08:00
|
|
|
|
#include <cstring>
|
2026-02-20 01:49:43 -08:00
|
|
|
|
#include <limits>
|
2026-02-12 03:50:28 -08:00
|
|
|
|
#include <openssl/sha.h>
|
2026-02-20 01:53:40 -08:00
|
|
|
|
#include <openssl/hmac.h>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
|
namespace game {
|
|
|
|
|
|
|
2026-02-12 02:27:59 -08:00
|
|
|
|
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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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:
|
2026-02-12 02:27:59 -08:00
|
|
|
|
return true;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-18 03:37:03 -08:00
|
|
|
|
|
2026-03-17 13:25:33 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 04:18:51 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 03:38:12 -08:00
|
|
|
|
bool isClassicLikeExpansion() {
|
|
|
|
|
|
return isActiveExpansion("classic") || isActiveExpansion("turtle");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 08:44:16 -08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-14 01:10:43 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 22:27:42 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 09:44:52 -07:00
|
|
|
|
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;
|
2026-03-14 09:52:33 -07:00
|
|
|
|
case 9: // Some cores encode SPELL_MISS_IMMUNE2 as 9.
|
|
|
|
|
|
case 10: // Others encode SPELL_MISS_IMMUNE2 as 10.
|
|
|
|
|
|
return CombatTextEntry::IMMUNE;
|
2026-03-14 09:44:52 -07:00
|
|
|
|
case 11: return CombatTextEntry::REFLECT;
|
|
|
|
|
|
default: return CombatTextEntry::MISS;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 03:37:03 -08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-02-19 00:30:21 -08:00
|
|
|
|
|
2026-03-13 21:00:34 -07:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 00:30:21 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 01:49:43 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 01:57:21 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 00:30:21 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 09:02:20 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 00:30:21 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-10 23:52:18 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:05:05 -07:00
|
|
|
|
// 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) {
|
2026-03-10 23:52:18 -07:00
|
|
|
|
QuestQueryObjectives out;
|
2026-03-11 00:05:05 -07:00
|
|
|
|
size_t pos = startPos;
|
2026-03-10 23:52:18 -07:00
|
|
|
|
|
|
|
|
|
|
// Scan past each string (null-terminated).
|
|
|
|
|
|
for (int si = 0; si < nStrings; ++si) {
|
|
|
|
|
|
while (pos < data.size() && data[pos] != 0) ++pos;
|
2026-03-11 00:05:05 -07:00
|
|
|
|
if (pos >= data.size()) return out; // truncated
|
2026-03-10 23:52:18 -07:00
|
|
|
|
++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;
|
2026-03-11 00:05:05 -07:00
|
|
|
|
out.kills[i].npcOrGoId = static_cast<int32_t>(readU32At(data, pos)); pos += 4;
|
|
|
|
|
|
out.kills[i].required = readU32At(data, pos); pos += 4;
|
2026-03-10 23:52:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:05:05 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 22:30:16 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 02:27:59 -08:00
|
|
|
|
} // namespace
|
|
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
GameHandler::GameHandler() {
|
|
|
|
|
|
LOG_DEBUG("GameHandler created");
|
2026-02-04 11:31:08 -08:00
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
setActiveOpcodeTable(&opcodeTable_);
|
|
|
|
|
|
setActiveUpdateFieldTable(&updateFieldTable_);
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize packet parsers (WotLK default, may be replaced for other expansions)
|
|
|
|
|
|
packetParsers_ = std::make_unique<WotlkPacketParsers>();
|
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
|
// Initialize transport manager
|
|
|
|
|
|
transportManager_ = std::make_unique<TransportManager>();
|
|
|
|
|
|
|
2026-02-12 02:43:20 -08:00
|
|
|
|
// Initialize Warden module manager
|
|
|
|
|
|
wardenModuleManager_ = std::make_unique<WardenModuleManager>();
|
|
|
|
|
|
|
2026-02-04 11:31:08 -08:00
|
|
|
|
// Default spells always available
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.insert(6603); // Attack
|
|
|
|
|
|
knownSpells.insert(8690); // Hearthstone
|
2026-02-04 11:31:08 -08:00
|
|
|
|
|
|
|
|
|
|
// Default action bar layout
|
|
|
|
|
|
actionBar[0].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[0].id = 6603; // Attack in slot 1
|
2026-02-08 03:05:38 -08:00
|
|
|
|
actionBar[11].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[11].id = 8690; // Hearthstone in slot 12
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
GameHandler::~GameHandler() {
|
|
|
|
|
|
disconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
void GameHandler::setPacketParsers(std::unique_ptr<PacketParsers> parsers) {
|
|
|
|
|
|
packetParsers_ = std::move(parsers);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
bool GameHandler::connect(const std::string& host,
|
|
|
|
|
|
uint16_t port,
|
|
|
|
|
|
const std::vector<uint8_t>& sessionKey,
|
|
|
|
|
|
const std::string& accountName,
|
2026-02-13 01:51:49 -08:00
|
|
|
|
uint32_t build,
|
|
|
|
|
|
uint32_t realmId) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-13 01:51:49 -08:00
|
|
|
|
this->realmId_ = realmId;
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
requiresWarden_ = false;
|
|
|
|
|
|
wardenGateSeen_ = false;
|
|
|
|
|
|
wardenGateElapsed_ = 0.0f;
|
|
|
|
|
|
wardenGateNextStatusLog_ = 2.0f;
|
|
|
|
|
|
wardenPacketsAfterGate_ = 0;
|
|
|
|
|
|
wardenCharEnumBlockedLogged_ = false;
|
2026-02-14 00:57:33 -08:00
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_USE;
|
|
|
|
|
|
wardenModuleHash_.clear();
|
|
|
|
|
|
wardenModuleKey_.clear();
|
|
|
|
|
|
wardenModuleSize_ = 0;
|
|
|
|
|
|
wardenModuleData_.clear();
|
2026-02-14 19:20:32 -08:00
|
|
|
|
wardenLoadedModule_.reset();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
enqueueIncomingPacket(packet);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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() {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (onTaxiFlight_) {
|
|
|
|
|
|
taxiRecoverPending_ = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
socket->disconnect();
|
|
|
|
|
|
socket.reset();
|
|
|
|
|
|
}
|
2026-02-06 20:49:17 -08:00
|
|
|
|
activeCharacterGuid_ = 0;
|
2026-02-06 21:25:35 -08:00
|
|
|
|
playerNameCache.clear();
|
|
|
|
|
|
pendingNameQueries.clear();
|
2026-03-18 09:44:43 -07:00
|
|
|
|
guildNameCache_.clear();
|
|
|
|
|
|
pendingGuildNameQueries_.clear();
|
2026-03-10 01:15:51 -07:00
|
|
|
|
friendGuids_.clear();
|
2026-03-10 05:46:03 -07:00
|
|
|
|
contacts_.clear();
|
2026-02-12 00:04:53 -08:00
|
|
|
|
transportAttachments_.clear();
|
|
|
|
|
|
serverUpdatedTransportGuids_.clear();
|
2026-03-10 09:01:34 -07:00
|
|
|
|
// 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();
|
2026-02-12 01:53:21 -08:00
|
|
|
|
requiresWarden_ = false;
|
|
|
|
|
|
wardenGateSeen_ = false;
|
|
|
|
|
|
wardenGateElapsed_ = 0.0f;
|
|
|
|
|
|
wardenGateNextStatusLog_ = 2.0f;
|
|
|
|
|
|
wardenPacketsAfterGate_ = 0;
|
|
|
|
|
|
wardenCharEnumBlockedLogged_ = false;
|
2026-02-14 00:57:33 -08:00
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_USE;
|
|
|
|
|
|
wardenModuleHash_.clear();
|
|
|
|
|
|
wardenModuleKey_.clear();
|
|
|
|
|
|
wardenModuleSize_ = 0;
|
|
|
|
|
|
wardenModuleData_.clear();
|
2026-02-14 19:20:32 -08:00
|
|
|
|
wardenLoadedModule_.reset();
|
2026-03-15 01:21:23 -07:00
|
|
|
|
pendingIncomingPackets_.clear();
|
|
|
|
|
|
pendingUpdateObjectWork_.clear();
|
2026-03-20 18:07:00 -07:00
|
|
|
|
// 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();
|
2026-03-10 08:35:36 -07:00
|
|
|
|
entityManager.clear();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
setState(WorldState::DISCONNECTED);
|
|
|
|
|
|
LOG_INFO("Disconnected from world server");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 19:27:35 -08:00
|
|
|
|
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();
|
2026-02-26 17:56:11 -08:00
|
|
|
|
areaTriggerDbcLoaded_ = false;
|
|
|
|
|
|
areaTriggers_.clear();
|
|
|
|
|
|
activeAreaTriggers_.clear();
|
2026-02-14 19:27:35 -08:00
|
|
|
|
talentDbcLoaded_ = false;
|
|
|
|
|
|
talentCache_.clear();
|
|
|
|
|
|
talentTabCache_.clear();
|
2026-03-10 03:27:30 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-02-14 19:27:35 -08:00
|
|
|
|
LOG_INFO("GameHandler: DBC caches cleared for expansion switch");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
bool GameHandler::isConnected() const {
|
|
|
|
|
|
return socket && socket->isConnected();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::update(float deltaTime) {
|
2026-02-05 14:18:41 -08:00
|
|
|
|
// Fire deferred char-create callback (outside ImGui render)
|
|
|
|
|
|
if (pendingCharCreateResult_) {
|
|
|
|
|
|
pendingCharCreateResult_ = false;
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(pendingCharCreateSuccess_, pendingCharCreateMsg_);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 23:52:16 -08:00
|
|
|
|
if (!socket) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 22:18:28 -07:00
|
|
|
|
// Reset per-tick monster-move budget tracking (Classic/Turtle flood protection).
|
|
|
|
|
|
monsterMovePacketsThisTick_ = 0;
|
|
|
|
|
|
monsterMovePacketsDroppedThisTick_ = 0;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Update socket (processes incoming data and triggers callbacks)
|
2026-02-05 12:01:03 -08:00
|
|
|
|
if (socket) {
|
2026-03-07 13:44:09 -08:00
|
|
|
|
auto socketStart = std::chrono::steady_clock::now();
|
2026-02-05 12:01:03 -08:00
|
|
|
|
socket->update();
|
2026-03-07 13:44:09 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-02-05 12:01:03 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
{
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 16:46:29 -07:00
|
|
|
|
// 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)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:38:25 -07:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 18:46:54 -08:00
|
|
|
|
// Detect server-side disconnect (socket closed during update)
|
|
|
|
|
|
if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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");
|
2026-02-14 18:46:54 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
// Post-gate visibility: determine whether server goes silent or closes after Warden requirement.
|
2026-02-12 02:27:59 -08:00
|
|
|
|
if (wardenGateSeen_ && socket && socket->isConnected()) {
|
2026-02-12 01:53:21 -08:00
|
|
|
|
wardenGateElapsed_ += deltaTime;
|
|
|
|
|
|
if (wardenGateElapsed_ >= wardenGateNextStatusLog_) {
|
2026-02-16 00:45:47 -08:00
|
|
|
|
LOG_DEBUG("Warden gate status: elapsed=", wardenGateElapsed_,
|
2026-02-12 01:53:21 -08:00
|
|
|
|
"s connected=", socket->isConnected() ? "yes" : "no",
|
|
|
|
|
|
" packetsAfterGate=", wardenPacketsAfterGate_);
|
2026-02-16 00:45:47 -08:00
|
|
|
|
wardenGateNextStatusLog_ += 30.0f;
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Validate target still exists
|
|
|
|
|
|
if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) {
|
|
|
|
|
|
clearTarget();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 14:27:46 -07:00
|
|
|
|
// Detect combat state transitions → fire PLAYER_REGEN_DISABLED / PLAYER_REGEN_ENABLED
|
|
|
|
|
|
{
|
|
|
|
|
|
bool combatNow = isInCombat();
|
|
|
|
|
|
if (combatNow != wasCombat_) {
|
|
|
|
|
|
wasCombat_ = combatNow;
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_(combatNow ? "PLAYER_REGEN_DISABLED" : "PLAYER_REGEN_ENABLED", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
if (auctionSearchDelayTimer_ > 0.0f) {
|
|
|
|
|
|
auctionSearchDelayTimer_ -= deltaTime;
|
|
|
|
|
|
if (auctionSearchDelayTimer_ < 0.0f) auctionSearchDelayTimer_ = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 19:47:49 -08:00
|
|
|
|
if (pendingMoneyDeltaTimer_ > 0.0f) {
|
|
|
|
|
|
pendingMoneyDeltaTimer_ -= deltaTime;
|
|
|
|
|
|
if (pendingMoneyDeltaTimer_ <= 0.0f) {
|
|
|
|
|
|
pendingMoneyDeltaTimer_ = 0.0f;
|
|
|
|
|
|
pendingMoneyDelta_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 17:47:10 -08:00
|
|
|
|
if (autoAttackRangeWarnCooldown_ > 0.0f) {
|
|
|
|
|
|
autoAttackRangeWarnCooldown_ = std::max(0.0f, autoAttackRangeWarnCooldown_ - deltaTime);
|
|
|
|
|
|
}
|
2026-02-13 19:47:49 -08:00
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 04:13:26 -08:00
|
|
|
|
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) {
|
2026-02-20 19:51:04 -08:00
|
|
|
|
// Keep server-side position/facing fresh before retrying GO use.
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-18 04:17:11 -08:00
|
|
|
|
auto usePacket = GameObjectUsePacket::build(it->guid);
|
|
|
|
|
|
socket->send(usePacket);
|
2026-02-20 19:51:04 -08:00
|
|
|
|
if (it->sendLoot) {
|
|
|
|
|
|
auto lootPacket = LootPacket::build(it->guid);
|
|
|
|
|
|
socket->send(lootPacket);
|
|
|
|
|
|
}
|
2026-02-18 04:13:26 -08:00
|
|
|
|
--it->remainingRetries;
|
|
|
|
|
|
it->timer = 0.20f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (it->remainingRetries == 0) {
|
|
|
|
|
|
it = pendingGameObjectLootRetries_.erase(it);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 19:51:04 -08:00
|
|
|
|
for (auto it = pendingGameObjectLootOpens_.begin(); it != pendingGameObjectLootOpens_.end();) {
|
|
|
|
|
|
it->timer -= deltaTime;
|
|
|
|
|
|
if (it->timer <= 0.0f) {
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
2026-03-14 04:41:46 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-20 19:51:04 -08:00
|
|
|
|
lootTarget(it->guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
it = pendingGameObjectLootOpens_.erase(it);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 07:00:43 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 04:11:00 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
// 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);
|
2026-02-16 00:51:59 -08:00
|
|
|
|
inspectRateLimit_ = 2.0f; // throttle to avoid compositing stutter
|
|
|
|
|
|
LOG_DEBUG("Sent CMSG_INSPECT for player 0x", std::hex, guid, std::dec);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Send periodic heartbeat if in world
|
2026-02-06 23:52:16 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
timeSinceLastPing += deltaTime;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
timeSinceLastMoveHeartbeat_ += deltaTime;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
const float currentPingInterval =
|
|
|
|
|
|
(isClassicLikeExpansion() || isActiveExpansion("tbc")) ? 10.0f : pingInterval;
|
|
|
|
|
|
if (timeSinceLastPing >= currentPingInterval) {
|
2026-02-05 12:01:03 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendPing();
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
timeSinceLastPing = 0.0f;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-20 03:38:12 -08:00
|
|
|
|
const bool classicLikeCombatSync =
|
|
|
|
|
|
autoAttackRequested_ && (isClassicLikeExpansion() || isActiveExpansion("tbc"));
|
2026-03-15 06:13:36 -07:00
|
|
|
|
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;
|
2026-02-20 03:38:12 -08:00
|
|
|
|
float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_)
|
|
|
|
|
|
? 0.25f
|
2026-03-15 06:13:36 -07:00
|
|
|
|
: (classicLikeStationaryCombatSync ? 0.75f
|
|
|
|
|
|
: (classicLikeCombatSync ? 0.20f
|
|
|
|
|
|
: moveHeartbeatInterval_));
|
2026-02-11 22:27:02 -08:00
|
|
|
|
if (timeSinceLastMoveHeartbeat_ >= heartbeatInterval) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-11 21:14:35 -08:00
|
|
|
|
timeSinceLastMoveHeartbeat_ = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
// Check area triggers (instance portals, tavern rests, etc.)
|
|
|
|
|
|
areaTriggerCheckTimer_ += deltaTime;
|
|
|
|
|
|
if (areaTriggerCheckTimer_ >= 0.25f) {
|
|
|
|
|
|
areaTriggerCheckTimer_ = 0.0f;
|
|
|
|
|
|
checkAreaTriggers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Update cast timer (Phase 3)
|
2026-02-19 03:31:49 -08:00
|
|
|
|
if (pendingGameObjectInteractGuid_ != 0 &&
|
2026-02-20 19:51:04 -08:00
|
|
|
|
(autoAttacking || autoAttackRequested_)) {
|
2026-02-19 03:31:49 -08:00
|
|
|
|
pendingGameObjectInteractGuid_ = 0;
|
|
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-12 01:22:42 -07:00
|
|
|
|
addUIError("Interrupted.");
|
2026-02-19 03:31:49 -08:00
|
|
|
|
addSystemChatMessage("Interrupted.");
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (casting && castTimeRemaining > 0.0f) {
|
|
|
|
|
|
castTimeRemaining -= deltaTime;
|
|
|
|
|
|
if (castTimeRemaining <= 0.0f) {
|
2026-02-19 03:31:49 -08:00
|
|
|
|
if (pendingGameObjectInteractGuid_ != 0) {
|
|
|
|
|
|
uint64_t interactGuid = pendingGameObjectInteractGuid_;
|
|
|
|
|
|
pendingGameObjectInteractGuid_ = 0;
|
|
|
|
|
|
performGameObjectInteractionNow(interactGuid);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 23:13:30 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-09 23:06:40 -07:00
|
|
|
|
}
|
2026-03-09 23:13:30 -07:00
|
|
|
|
++it;
|
2026-03-09 23:06:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// 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);
|
2026-03-09 20:36:20 -07:00
|
|
|
|
tickMinimapPings(deltaTime);
|
2026-02-05 12:01:03 -08:00
|
|
|
|
|
2026-03-13 10:13:54 -07:00
|
|
|
|
// Tick logout countdown
|
|
|
|
|
|
if (loggingOut_ && logoutCountdown_ > 0.0f) {
|
|
|
|
|
|
logoutCountdown_ -= deltaTime;
|
|
|
|
|
|
if (logoutCountdown_ < 0.0f) logoutCountdown_ = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 23:15:26 -08:00
|
|
|
|
// Update taxi landing cooldown
|
|
|
|
|
|
if (taxiLandingCooldown_ > 0.0f) {
|
|
|
|
|
|
taxiLandingCooldown_ -= deltaTime;
|
|
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (taxiStartGrace_ > 0.0f) {
|
|
|
|
|
|
taxiStartGrace_ -= deltaTime;
|
|
|
|
|
|
}
|
2026-02-12 00:45:24 -08:00
|
|
|
|
if (playerTransportStickyTimer_ > 0.0f) {
|
|
|
|
|
|
playerTransportStickyTimer_ -= deltaTime;
|
|
|
|
|
|
if (playerTransportStickyTimer_ <= 0.0f) {
|
|
|
|
|
|
playerTransportStickyTimer_ = 0.0f;
|
|
|
|
|
|
playerTransportStickyGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 23:15:26 -08:00
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared
|
|
|
|
|
|
if (onTaxiFlight_) {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
updateClientTaxi(deltaTime);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
auto unit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
|
|
|
|
|
if (unit &&
|
|
|
|
|
|
(unit->getUnitFlags() & 0x00000100) == 0 &&
|
|
|
|
|
|
!taxiClientActive_ &&
|
2026-02-11 21:14:35 -08:00
|
|
|
|
!taxiActivatePending_ &&
|
|
|
|
|
|
taxiStartGrace_ <= 0.0f) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
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) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
|
LOG_INFO("Taxi flight landed");
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Safety: if taxi flight ended but mount is still active, force dismount.
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Guard against transient taxi-state flicker.
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (!onTaxiFlight_ && taxiMountActive_) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
bool serverStillTaxi = false;
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
|
|
|
|
|
if (playerUnit) {
|
|
|
|
|
|
serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (taxiStartGrace_ > 0.0f || serverStillTaxi || taxiClientActive_ || taxiActivatePending_) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
onTaxiFlight_ = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (mountCallback_) mountCallback_(0);
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
if (socket) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Taxi dismount cleanup");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
LOG_INFO("Taxi recovery applied");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (taxiActivatePending_) {
|
|
|
|
|
|
taxiActivateTimer_ += deltaTime;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
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 {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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");
|
2026-02-11 22:27:02 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
|
// Update transport manager
|
|
|
|
|
|
if (transportManager_) {
|
|
|
|
|
|
transportManager_->update(deltaTime);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
updateAttachedTransportChildren(deltaTime);
|
2026-02-10 21:29:10 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// Leave combat if auto-attack target is too far away (leash range)
|
2026-02-20 03:38:12 -08:00
|
|
|
|
// and keep melee intent tightly synced while stationary.
|
|
|
|
|
|
if (autoAttackRequested_ && autoAttackTarget != 0) {
|
2026-02-07 18:33:14 -08:00
|
|
|
|
auto targetEntity = entityManager.getEntity(autoAttackTarget);
|
|
|
|
|
|
if (targetEntity) {
|
2026-02-20 17:47:10 -08:00
|
|
|
|
// 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;
|
2026-02-07 18:33:14 -08:00
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy);
|
2026-02-20 17:47:10 -08:00
|
|
|
|
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
|
2026-02-20 03:38:12 -08:00
|
|
|
|
const bool classicLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
2026-02-07 18:33:14 -08:00
|
|
|
|
if (dist > 40.0f) {
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
LOG_INFO("Left combat: target too far (", dist, " yards)");
|
2026-02-17 15:37:02 -08:00
|
|
|
|
} else if (state == WorldState::IN_WORLD && socket) {
|
2026-02-20 17:47:10 -08:00
|
|
|
|
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.");
|
2026-03-12 01:15:11 -07:00
|
|
|
|
addUIError("Target is too far away.");
|
2026-02-20 17:47:10 -08:00
|
|
|
|
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;
|
2026-02-17 15:37:02 -08:00
|
|
|
|
}
|
2026-02-20 03:38:12 -08:00
|
|
|
|
|
2026-02-20 17:47:10 -08:00
|
|
|
|
if (allowResync) {
|
|
|
|
|
|
autoAttackResendTimer_ += deltaTime;
|
|
|
|
|
|
autoAttackFacingSyncTimer_ += deltaTime;
|
|
|
|
|
|
|
2026-03-15 06:13:36 -07:00
|
|
|
|
// 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) {
|
2026-02-20 17:47:10 -08:00
|
|
|
|
autoAttackResendTimer_ = 0.0f;
|
2026-03-15 06:13:36 -07:00
|
|
|
|
autoAttackRetryPending_ = false;
|
2026-02-20 17:47:10 -08:00
|
|
|
|
auto pkt = AttackSwingPacket::build(autoAttackTarget);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 06:13:36 -07:00
|
|
|
|
// 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) {
|
2026-02-20 17:47:10 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-20 03:14:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 01:19:29 -08:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
// Close vendor/gossip/taxi window if player walks too far from NPC
|
2026-02-07 18:33:14 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 19:44:03 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 14:33:39 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
|
|
2026-02-06 14:24:38 -08:00
|
|
|
|
// Update entity movement interpolation (keeps targeting in sync with visuals)
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-02-06 14:24:38 -08:00
|
|
|
|
for (auto& [guid, entity] : entityManager.getEntities()) {
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Always update player
|
|
|
|
|
|
if (guid == playerGuid) {
|
|
|
|
|
|
entity->updateMovement(deltaTime);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-02-18 03:53:53 -08:00
|
|
|
|
// Keep selected/engaged target interpolation exact for UI targeting circle.
|
|
|
|
|
|
if (guid == targetGuid || guid == autoAttackTarget) {
|
|
|
|
|
|
entity->updateMovement(deltaTime);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
|
2026-02-18 03:13:17 -08:00
|
|
|
|
// Distance cull other entities (use latest position to avoid culling by stale origin)
|
|
|
|
|
|
glm::vec3 entityPos(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
2026-02-10 19:30:45 -08:00
|
|
|
|
float distSq = glm::dot(entityPos - playerPos, entityPos - playerPos);
|
|
|
|
|
|
if (distSq < updateRadiusSq) {
|
|
|
|
|
|
entity->updateMovement(deltaTime);
|
|
|
|
|
|
}
|
2026-02-06 14:24:38 -08:00
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
|
2026-02-07 00:12:39 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handlePacket(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() < 1) {
|
2026-02-20 02:19:17 -08:00
|
|
|
|
LOG_DEBUG("Received empty world packet (ignored)");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint16_t opcode = packet.getOpcode();
|
2026-02-22 07:26:54 -08:00
|
|
|
|
try {
|
|
|
|
|
|
|
|
|
|
|
|
const bool allowVanillaAliases = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
2026-02-18 03:23:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Vanilla compatibility aliases:
|
2026-02-20 03:14:48 -08:00
|
|
|
|
// - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers
|
|
|
|
|
|
// and SMSG_WEATHER on others
|
2026-02-18 03:23:37 -08:00
|
|
|
|
// - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers)
|
|
|
|
|
|
//
|
|
|
|
|
|
// We gate these by payload shape so expansion-native mappings remain intact.
|
2026-02-22 07:26:54 -08:00
|
|
|
|
if (allowVanillaAliases && opcode == 0x006B) {
|
2026-02-20 03:14:48 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 03:23:37 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
|
} else if (allowVanillaAliases && opcode == 0x0103) {
|
2026-02-18 03:23:37 -08:00
|
|
|
|
// 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);
|
2026-03-09 15:46:19 -07:00
|
|
|
|
if (playMusicCallback_) playMusicCallback_(soundId);
|
2026-02-18 03:23:37 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-19 05:28:13 -08:00
|
|
|
|
} 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;
|
2026-02-19 05:48:40 -08:00
|
|
|
|
pendingBuybackWireSlot_ = 0;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-18 03:23:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto preLogicalOp = opcodeTable_.fromWire(opcode);
|
|
|
|
|
|
if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) {
|
2026-02-12 01:53:21 -08:00
|
|
|
|
++wardenPacketsAfterGate_;
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) {
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec,
|
2026-02-12 02:27:59 -08:00
|
|
|
|
" state=", worldStateName(state),
|
|
|
|
|
|
" size=", packet.getSize());
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec,
|
|
|
|
|
|
" size=", packet.getSize(), " bytes");
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
// Translate wire opcode to logical opcode via expansion table
|
|
|
|
|
|
auto logicalOp = opcodeTable_.fromWire(opcode);
|
2026-02-19 00:56:24 -08:00
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (!logicalOp) {
|
2026-02-18 23:14:01 -08:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
switch (*logicalOp) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_AUTH_CHALLENGE:
|
|
|
|
|
|
if (state == WorldState::CONNECTED) {
|
|
|
|
|
|
handleAuthChallenge(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_AUTH_RESPONSE:
|
|
|
|
|
|
if (state == WorldState::AUTH_SENT) {
|
|
|
|
|
|
handleAuthResponse(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
case Opcode::SMSG_CHAR_CREATE:
|
|
|
|
|
|
handleCharCreateResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-06 03:24:46 -08:00
|
|
|
|
case Opcode::SMSG_CHAR_DELETE: {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
2026-02-06 18:34:45 -08:00
|
|
|
|
lastCharDeleteResult_ = result;
|
|
|
|
|
|
bool success = (result == 0x00 || result == 0x47); // Common success codes
|
2026-02-06 03:24:46 -08:00
|
|
|
|
LOG_INFO("SMSG_CHAR_DELETE result: ", (int)result, success ? " (success)" : " (failed)");
|
2026-02-06 18:34:45 -08:00
|
|
|
|
requestCharacterList();
|
2026-02-06 03:24:46 -08:00
|
|
|
|
if (charDeleteCallback_) charDeleteCallback_(success);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_CHAR_ENUM:
|
|
|
|
|
|
if (state == WorldState::CHAR_LIST_REQUESTED) {
|
|
|
|
|
|
handleCharEnum(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-17 13:59:29 -08:00
|
|
|
|
case Opcode::SMSG_CHARACTER_LOGIN_FAILED:
|
|
|
|
|
|
handleCharLoginFailed(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_LOGIN_VERIFY_WORLD:
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
handleLoginVerifyWorld(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
case Opcode::SMSG_LOGIN_SETTIMESPEED:
|
|
|
|
|
|
// Can be received during login or at any time after
|
|
|
|
|
|
handleLoginSetTimeSpeed(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
case Opcode::SMSG_CLIENTCACHE_VERSION:
|
|
|
|
|
|
// Early pre-world packet in some realms (e.g. Warmane profile)
|
|
|
|
|
|
handleClientCacheVersion(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_TUTORIAL_FLAGS:
|
|
|
|
|
|
// Often sent during char-list stage (8x uint32 tutorial flags)
|
|
|
|
|
|
handleTutorialFlags(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_WARDEN_DATA:
|
|
|
|
|
|
handleWardenData(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_ACCOUNT_DATA_TIMES:
|
|
|
|
|
|
// Can be received at any time after authentication
|
|
|
|
|
|
handleAccountDataTimes(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_MOTD:
|
|
|
|
|
|
// Can be received at any time after entering world
|
|
|
|
|
|
handleMotd(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-20 00:28:51 -08:00
|
|
|
|
case Opcode::SMSG_NOTIFICATION:
|
|
|
|
|
|
// Vanilla/Classic server notification (single string)
|
|
|
|
|
|
handleNotification(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_PONG:
|
|
|
|
|
|
// Can be received at any time after entering world
|
|
|
|
|
|
handlePong(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_OBJECT:
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Can be received after entering world
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleUpdateObject(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-05 21:55:52 -08:00
|
|
|
|
case Opcode::SMSG_COMPRESSED_UPDATE_OBJECT:
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
|
2026-02-05 21:55:52 -08:00
|
|
|
|
// Compressed version of UPDATE_OBJECT
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleCompressedUpdateObject(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_DESTROY_OBJECT:
|
|
|
|
|
|
// Can be received after entering world
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleDestroyObject(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_MESSAGECHAT:
|
|
|
|
|
|
// Can be received after entering world
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleMessageChat(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-03-09 15:11:21 -07:00
|
|
|
|
case Opcode::SMSG_GM_MESSAGECHAT:
|
|
|
|
|
|
// GM → player message: same wire format as SMSG_MESSAGECHAT
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleMessageChat(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
case Opcode::SMSG_TEXT_EMOTE:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleTextEmote(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-02-18 23:38:34 -08:00
|
|
|
|
case Opcode::SMSG_EMOTE: {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) break;
|
|
|
|
|
|
// SMSG_EMOTE: uint32 emoteAnim, uint64 sourceGuid
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) break;
|
|
|
|
|
|
uint32_t emoteAnim = packet.readUInt32();
|
|
|
|
|
|
uint64_t sourceGuid = packet.readUInt64();
|
|
|
|
|
|
if (emoteAnimCallback_ && sourceGuid != 0) {
|
|
|
|
|
|
emoteAnimCallback_(sourceGuid, emoteAnim);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-14 14:30:09 -08:00
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_CHANNEL_NOTIFY:
|
2026-02-16 20:16:14 -08:00
|
|
|
|
// Accept during ENTERING_WORLD too — server auto-joins channels before VERIFY_WORLD
|
|
|
|
|
|
if (state == WorldState::IN_WORLD || state == WorldState::ENTERING_WORLD) {
|
2026-02-14 14:30:09 -08:00
|
|
|
|
handleChannelNotify(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-03-09 15:02:15 -07:00
|
|
|
|
case Opcode::SMSG_CHAT_PLAYER_NOT_FOUND: {
|
|
|
|
|
|
// string: name of the player not found (for failed whispers)
|
|
|
|
|
|
std::string name = packet.readString();
|
|
|
|
|
|
if (!name.empty()) {
|
|
|
|
|
|
addSystemChatMessage("No player named '" + name + "' is currently playing.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CHAT_PLAYER_AMBIGUOUS: {
|
|
|
|
|
|
// string: ambiguous player name (multiple matches)
|
|
|
|
|
|
std::string name = packet.readString();
|
|
|
|
|
|
if (!name.empty()) {
|
|
|
|
|
|
addSystemChatMessage("Player name '" + name + "' is ambiguous.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CHAT_WRONG_FACTION:
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("You cannot send messages to members of that faction.");
|
2026-03-09 15:02:15 -07:00
|
|
|
|
addSystemChatMessage("You cannot send messages to members of that faction.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_CHAT_NOT_IN_PARTY:
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("You are not in a party.");
|
2026-03-09 15:02:15 -07:00
|
|
|
|
addSystemChatMessage("You are not in a party.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_CHAT_RESTRICTED:
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("You cannot send chat messages in this area.");
|
2026-03-09 15:02:15 -07:00
|
|
|
|
addSystemChatMessage("You cannot send chat messages in this area.");
|
|
|
|
|
|
break;
|
2026-02-14 14:30:09 -08:00
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
case Opcode::SMSG_QUERY_TIME_RESPONSE:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleQueryTimeResponse(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_PLAYED_TIME:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handlePlayedTime(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_WHO:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleWho(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 22:07:03 -07:00
|
|
|
|
case Opcode::SMSG_WHOIS: {
|
|
|
|
|
|
// GM/admin response to /whois command: cstring with account/IP info
|
|
|
|
|
|
// Format: string (the whois result text, typically "Name: ...\nAccount: ...\nIP: ...")
|
|
|
|
|
|
if (packet.getReadPos() < packet.getSize()) {
|
|
|
|
|
|
std::string whoisText = packet.readString();
|
|
|
|
|
|
if (!whoisText.empty()) {
|
|
|
|
|
|
// Display each line of the whois response in system chat
|
|
|
|
|
|
std::string line;
|
|
|
|
|
|
for (char c : whoisText) {
|
|
|
|
|
|
if (c == '\n') {
|
|
|
|
|
|
if (!line.empty()) addSystemChatMessage("[Whois] " + line);
|
|
|
|
|
|
line.clear();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
line += c;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!line.empty()) addSystemChatMessage("[Whois] " + line);
|
|
|
|
|
|
LOG_INFO("SMSG_WHOIS: ", whoisText);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
case Opcode::SMSG_FRIEND_STATUS:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleFriendStatus(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-03-10 01:15:51 -07:00
|
|
|
|
case Opcode::SMSG_CONTACT_LIST:
|
|
|
|
|
|
handleContactList(packet);
|
2026-02-18 23:26:58 -08:00
|
|
|
|
break;
|
2026-02-20 03:14:48 -08:00
|
|
|
|
case Opcode::SMSG_FRIEND_LIST:
|
2026-03-10 01:15:51 -07:00
|
|
|
|
// Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead)
|
|
|
|
|
|
handleFriendList(packet);
|
|
|
|
|
|
break;
|
2026-03-10 12:53:05 -07:00
|
|
|
|
case Opcode::SMSG_IGNORE_LIST: {
|
|
|
|
|
|
// uint8 count + count × (uint64 guid + string name)
|
|
|
|
|
|
// Populate ignoreCache so /unignore works for pre-existing ignores.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint8_t ignCount = packet.readUInt8();
|
|
|
|
|
|
for (uint8_t i = 0; i < ignCount; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint64_t ignGuid = packet.readUInt64();
|
|
|
|
|
|
std::string ignName = packet.readString();
|
|
|
|
|
|
if (!ignName.empty() && ignGuid != 0) {
|
|
|
|
|
|
ignoreCache[ignName] = ignGuid;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_IGNORE_LIST: loaded ", (int)ignCount, " ignored players");
|
2026-02-20 03:14:48 -08:00
|
|
|
|
break;
|
2026-03-10 12:53:05 -07:00
|
|
|
|
}
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
|
|
|
|
|
|
case Opcode::MSG_RANDOM_ROLL:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleRandomRoll(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
Implement SMSG_STANDSTATE_UPDATE and SMSG_ITEM_PUSH_RESULT handlers
SMSG_STANDSTATE_UPDATE:
- Parse uint8 stand state from server confirmation packet
- Store in standState_ member (0=stand, 7=dead, 8=kneel, etc.)
- Expose getStandState(), isSitting(), isDead(), isKneeling() accessors
SMSG_ITEM_PUSH_RESULT:
- Parse full WotLK 3.3.5a payload: guid, received, created, showInChat,
bagSlot, itemSlot, itemId, suffixFactor, randomPropertyId, count, totalCount
- Show "Received: <name> x<count>" chat notification when showInChat=1
- Queue item info lookup via queryItemInfo so name resolves asap
2026-03-09 12:58:52 -07:00
|
|
|
|
case Opcode::SMSG_ITEM_PUSH_RESULT: {
|
|
|
|
|
|
// Item received notification (loot, quest reward, trade, etc.)
|
|
|
|
|
|
// guid(8) + received(1) + created(1) + showInChat(1) + bagSlot(1) + itemSlot(4)
|
|
|
|
|
|
// + itemId(4) + itemSuffixFactor(4) + randomPropertyId(4) + count(4) + totalCount(4)
|
|
|
|
|
|
constexpr size_t kMinSize = 8 + 1 + 1 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + 4;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= kMinSize) {
|
|
|
|
|
|
/*uint64_t recipientGuid =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint8_t received =*/ packet.readUInt8(); // 0=looted/generated, 1=received from trade
|
|
|
|
|
|
/*uint8_t created =*/ packet.readUInt8(); // 0=stack added, 1=new item slot
|
|
|
|
|
|
uint8_t showInChat = packet.readUInt8();
|
|
|
|
|
|
/*uint8_t bagSlot =*/ packet.readUInt8();
|
|
|
|
|
|
/*uint32_t itemSlot =*/ packet.readUInt32();
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t suffixFactor =*/ packet.readUInt32();
|
2026-03-20 19:18:30 -07:00
|
|
|
|
int32_t randomProp = static_cast<int32_t>(packet.readUInt32());
|
Implement SMSG_STANDSTATE_UPDATE and SMSG_ITEM_PUSH_RESULT handlers
SMSG_STANDSTATE_UPDATE:
- Parse uint8 stand state from server confirmation packet
- Store in standState_ member (0=stand, 7=dead, 8=kneel, etc.)
- Expose getStandState(), isSitting(), isDead(), isKneeling() accessors
SMSG_ITEM_PUSH_RESULT:
- Parse full WotLK 3.3.5a payload: guid, received, created, showInChat,
bagSlot, itemSlot, itemId, suffixFactor, randomPropertyId, count, totalCount
- Show "Received: <name> x<count>" chat notification when showInChat=1
- Queue item info lookup via queryItemInfo so name resolves asap
2026-03-09 12:58:52 -07:00
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t totalCount =*/ packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
queryItemInfo(itemId, 0);
|
|
|
|
|
|
if (showInChat) {
|
|
|
|
|
|
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
|
2026-03-18 04:25:37 -07:00
|
|
|
|
// Item info already cached — emit immediately.
|
|
|
|
|
|
std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name;
|
2026-03-20 19:18:30 -07:00
|
|
|
|
// Append random suffix name (e.g., "of the Eagle") if present
|
|
|
|
|
|
if (randomProp != 0) {
|
|
|
|
|
|
std::string suffix = getRandomPropertyName(randomProp);
|
|
|
|
|
|
if (!suffix.empty()) itemName += " " + suffix;
|
|
|
|
|
|
}
|
2026-03-18 04:25:37 -07:00
|
|
|
|
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);
|
2026-03-20 21:27:04 -07:00
|
|
|
|
// Fire CHAT_MSG_LOOT for loot tracking addons
|
|
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("CHAT_MSG_LOOT", {msg, "", std::to_string(itemId), std::to_string(count)});
|
2026-03-18 04:25:37 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
// Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE.
|
|
|
|
|
|
pendingItemPushNotifs_.push_back({itemId, count});
|
2026-03-12 16:24:11 -07:00
|
|
|
|
}
|
Implement SMSG_STANDSTATE_UPDATE and SMSG_ITEM_PUSH_RESULT handlers
SMSG_STANDSTATE_UPDATE:
- Parse uint8 stand state from server confirmation packet
- Store in standState_ member (0=stand, 7=dead, 8=kneel, etc.)
- Expose getStandState(), isSitting(), isDead(), isKneeling() accessors
SMSG_ITEM_PUSH_RESULT:
- Parse full WotLK 3.3.5a payload: guid, received, created, showInChat,
bagSlot, itemSlot, itemId, suffixFactor, randomPropertyId, count, totalCount
- Show "Received: <name> x<count>" chat notification when showInChat=1
- Queue item info lookup via queryItemInfo so name resolves asap
2026-03-09 12:58:52 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Item push: itemId=", itemId, " count=", count,
|
|
|
|
|
|
" showInChat=", static_cast<int>(showInChat));
|
|
|
|
|
|
}
|
2026-02-18 23:42:28 -08:00
|
|
|
|
break;
|
Implement SMSG_STANDSTATE_UPDATE and SMSG_ITEM_PUSH_RESULT handlers
SMSG_STANDSTATE_UPDATE:
- Parse uint8 stand state from server confirmation packet
- Store in standState_ member (0=stand, 7=dead, 8=kneel, etc.)
- Expose getStandState(), isSitting(), isDead(), isKneeling() accessors
SMSG_ITEM_PUSH_RESULT:
- Parse full WotLK 3.3.5a payload: guid, received, created, showInChat,
bagSlot, itemSlot, itemId, suffixFactor, randomPropertyId, count, totalCount
- Show "Received: <name> x<count>" chat notification when showInChat=1
- Queue item info lookup via queryItemInfo so name resolves asap
2026-03-09 12:58:52 -07:00
|
|
|
|
}
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
|
2026-02-07 12:58:11 -08:00
|
|
|
|
case Opcode::SMSG_LOGOUT_RESPONSE:
|
|
|
|
|
|
handleLogoutResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_LOGOUT_COMPLETE:
|
|
|
|
|
|
handleLogoutComplete(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 1: Foundation ----
|
|
|
|
|
|
case Opcode::SMSG_NAME_QUERY_RESPONSE:
|
|
|
|
|
|
handleNameQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_CREATURE_QUERY_RESPONSE:
|
|
|
|
|
|
handleCreatureQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
case Opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE:
|
|
|
|
|
|
handleItemQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
case Opcode::SMSG_INSPECT_TALENT:
|
2026-02-13 20:26:55 -08:00
|
|
|
|
handleInspectResults(packet);
|
|
|
|
|
|
break;
|
2026-02-20 03:14:48 -08:00
|
|
|
|
case Opcode::SMSG_ADDON_INFO:
|
|
|
|
|
|
case Opcode::SMSG_EXPECTED_SPAM_RECORDS:
|
|
|
|
|
|
// Optional system payloads that are safe to consume.
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
2026-02-05 12:07:58 -08:00
|
|
|
|
// ---- XP ----
|
|
|
|
|
|
case Opcode::SMSG_LOG_XPGAIN:
|
|
|
|
|
|
handleXpGain(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:21:17 -07:00
|
|
|
|
case Opcode::SMSG_EXPLORATION_EXPERIENCE: {
|
|
|
|
|
|
// uint32 areaId + uint32 xpGained
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
2026-03-10 08:06:21 -07:00
|
|
|
|
uint32_t areaId = packet.readUInt32();
|
2026-03-09 14:21:17 -07:00
|
|
|
|
uint32_t xpGained = packet.readUInt32();
|
|
|
|
|
|
if (xpGained > 0) {
|
2026-03-10 08:06:21 -07:00
|
|
|
|
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);
|
2026-03-17 12:02:17 -07:00
|
|
|
|
addCombatText(CombatTextEntry::XP_GAIN,
|
|
|
|
|
|
static_cast<int32_t>(xpGained), 0, true);
|
2026-03-09 14:21:17 -07:00
|
|
|
|
// XP is updated via PLAYER_XP update fields from the server.
|
2026-03-12 15:42:55 -07:00
|
|
|
|
if (areaDiscoveryCallback_)
|
|
|
|
|
|
areaDiscoveryCallback_(areaName, xpGained);
|
2026-03-09 14:21:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PET_TAME_FAILURE: {
|
|
|
|
|
|
// uint8 reason: 0=invalid_creature, 1=too_many_pets, 2=already_tamed, etc.
|
|
|
|
|
|
const char* reasons[] = {
|
|
|
|
|
|
"Invalid creature", "Too many pets", "Already tamed",
|
|
|
|
|
|
"Wrong faction", "Level too low", "Creature not tameable",
|
|
|
|
|
|
"Can't control", "Can't command"
|
|
|
|
|
|
};
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t reason = packet.readUInt8();
|
|
|
|
|
|
const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason";
|
|
|
|
|
|
std::string s = std::string("Failed to tame: ") + msg;
|
2026-03-17 17:45:45 -07:00
|
|
|
|
addUIError(s);
|
2026-03-09 14:21:17 -07:00
|
|
|
|
addSystemChatMessage(s);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PET_ACTION_FEEDBACK: {
|
2026-03-12 18:25:02 -07:00
|
|
|
|
// uint8 msg: 1=dead, 2=nothing_to_attack, 3=cant_attack_target,
|
|
|
|
|
|
// 4=target_too_far, 5=no_path, 6=cant_attack_immune
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint8_t msg = packet.readUInt8();
|
|
|
|
|
|
static const char* kPetFeedback[] = {
|
|
|
|
|
|
nullptr,
|
|
|
|
|
|
"Your pet is dead.",
|
|
|
|
|
|
"Your pet has nothing to attack.",
|
|
|
|
|
|
"Your pet cannot attack that target.",
|
|
|
|
|
|
"That target is too far away.",
|
|
|
|
|
|
"Your pet cannot find a path to the target.",
|
|
|
|
|
|
"Your pet cannot attack an immune target.",
|
|
|
|
|
|
};
|
|
|
|
|
|
if (msg > 0 && msg < 7 && kPetFeedback[msg]) {
|
|
|
|
|
|
addSystemChatMessage(kPetFeedback[msg]);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-03-09 14:21:17 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: {
|
|
|
|
|
|
// uint32 petNumber + string name + uint32 timestamp + bool declined
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); // Consume; pet names shown via unit objects.
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_FAILED: {
|
|
|
|
|
|
// uint32 questId
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-03-13 06:24:16 -07:00
|
|
|
|
std::string questTitle;
|
|
|
|
|
|
for (const auto& q : questLog_)
|
|
|
|
|
|
if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; }
|
|
|
|
|
|
addSystemChatMessage(questTitle.empty()
|
|
|
|
|
|
? std::string("Quest failed!")
|
|
|
|
|
|
: ('"' + questTitle + "\" failed!"));
|
2026-03-09 14:21:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_FAILEDTIMER: {
|
|
|
|
|
|
// uint32 questId
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-03-13 06:24:16 -07:00
|
|
|
|
std::string questTitle;
|
|
|
|
|
|
for (const auto& q : questLog_)
|
|
|
|
|
|
if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; }
|
|
|
|
|
|
addSystemChatMessage(questTitle.empty()
|
|
|
|
|
|
? std::string("Quest timed out!")
|
|
|
|
|
|
: ('"' + questTitle + "\" has timed out."));
|
2026-03-09 14:21:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-05 12:07:58 -08:00
|
|
|
|
|
2026-03-09 14:30:48 -07:00
|
|
|
|
// ---- Entity health/power delta updates ----
|
|
|
|
|
|
case Opcode::SMSG_HEALTH_UPDATE: {
|
2026-03-09 23:51:01 -07:00
|
|
|
|
// WotLK: packed_guid + uint32 health
|
2026-03-10 00:38:47 -07:00
|
|
|
|
// TBC: full uint64 + uint32 health
|
|
|
|
|
|
// Classic/Vanilla: packed_guid + uint32 health (same as WotLK)
|
|
|
|
|
|
const bool huTbc = isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (huTbc ? 8u : 2u)) break;
|
|
|
|
|
|
uint64_t guid = huTbc
|
2026-03-09 23:51:01 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 14:30:48 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t hp = packet.readUInt32();
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
|
|
|
|
|
unit->setHealth(hp);
|
|
|
|
|
|
}
|
2026-03-21 02:26:44 -07:00
|
|
|
|
if (addonEventCallback_ && guid != 0) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (guid == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (guid == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (guid == focusGuid) unitId = "focus";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_HEALTH", {unitId});
|
|
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_POWER_UPDATE: {
|
2026-03-09 23:51:01 -07:00
|
|
|
|
// WotLK: packed_guid + uint8 powerType + uint32 value
|
2026-03-10 00:38:47 -07:00
|
|
|
|
// TBC: full uint64 + uint8 powerType + uint32 value
|
|
|
|
|
|
// Classic/Vanilla: packed_guid + uint8 powerType + uint32 value (same as WotLK)
|
|
|
|
|
|
const bool puTbc = isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (puTbc ? 8u : 2u)) break;
|
|
|
|
|
|
uint64_t guid = puTbc
|
2026-03-09 23:51:01 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 14:30:48 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
uint8_t powerType = packet.readUInt8();
|
|
|
|
|
|
uint32_t value = packet.readUInt32();
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
if (auto* unit = dynamic_cast<Unit*>(entity.get())) {
|
|
|
|
|
|
unit->setPowerByType(powerType, value);
|
|
|
|
|
|
}
|
2026-03-21 02:26:44 -07:00
|
|
|
|
if (addonEventCallback_ && guid != 0) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (guid == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (guid == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (guid == focusGuid) unitId = "focus";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_POWER", {unitId});
|
|
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- World state single update ----
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_WORLD_STATE: {
|
|
|
|
|
|
// uint32 field + uint32 value
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint32_t field = packet.readUInt32();
|
|
|
|
|
|
uint32_t value = packet.readUInt32();
|
|
|
|
|
|
worldStates_[field] = value;
|
|
|
|
|
|
LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 14:59:32 -07:00
|
|
|
|
case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: {
|
|
|
|
|
|
// uint32 time (server unix timestamp) — used to sync UI timers (arena, BG)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t serverTime = packet.readUInt32();
|
|
|
|
|
|
LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PVP_CREDIT: {
|
|
|
|
|
|
// uint32 honorPoints + uint64 victimGuid + uint32 victimRank
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 16) {
|
|
|
|
|
|
uint32_t honor = packet.readUInt32();
|
|
|
|
|
|
uint64_t victimGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t rank = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("SMSG_PVP_CREDIT: honor=", honor, " victim=0x", std::hex, victimGuid,
|
|
|
|
|
|
std::dec, " rank=", rank);
|
|
|
|
|
|
std::string msg = "You gain " + std::to_string(honor) + " honor points.";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-17 14:38:57 -07:00
|
|
|
|
if (honor > 0)
|
|
|
|
|
|
addCombatText(CombatTextEntry::HONOR_GAIN, static_cast<int32_t>(honor), 0, true);
|
2026-03-12 16:19:25 -07:00
|
|
|
|
if (pvpHonorCallback_) {
|
|
|
|
|
|
pvpHonorCallback_(honor, victimGuid, rank);
|
|
|
|
|
|
}
|
2026-03-09 14:59:32 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Combo points ----
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_COMBO_POINTS: {
|
2026-03-09 23:51:01 -07:00
|
|
|
|
// WotLK: packed_guid (target) + uint8 points
|
2026-03-10 00:38:47 -07:00
|
|
|
|
// TBC: full uint64 (target) + uint8 points
|
|
|
|
|
|
// Classic/Vanilla: packed_guid (target) + uint8 points (same as WotLK)
|
|
|
|
|
|
const bool cpTbc = isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (cpTbc ? 8u : 2u)) break;
|
|
|
|
|
|
uint64_t target = cpTbc
|
2026-03-09 23:51:01 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 14:30:48 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
comboPoints_ = packet.readUInt8();
|
|
|
|
|
|
comboTarget_ = target;
|
|
|
|
|
|
LOG_DEBUG("SMSG_UPDATE_COMBO_POINTS: target=0x", std::hex, target,
|
|
|
|
|
|
std::dec, " points=", static_cast<int>(comboPoints_));
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Mirror timers (breath/fatigue/feign death) ----
|
|
|
|
|
|
case Opcode::SMSG_START_MIRROR_TIMER: {
|
|
|
|
|
|
// uint32 type + int32 value + int32 maxValue + int32 scale + uint32 tracker + uint8 paused
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 21) break;
|
|
|
|
|
|
uint32_t type = packet.readUInt32();
|
|
|
|
|
|
int32_t value = static_cast<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;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_STOP_MIRROR_TIMER: {
|
|
|
|
|
|
// uint32 type
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t type = packet.readUInt32();
|
|
|
|
|
|
if (type < 3) {
|
|
|
|
|
|
mirrorTimers_[type].active = false;
|
|
|
|
|
|
mirrorTimers_[type].value = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PAUSE_MIRROR_TIMER: {
|
|
|
|
|
|
// uint32 type + uint8 paused
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
uint32_t type = packet.readUInt32();
|
|
|
|
|
|
uint8_t paused = packet.readUInt8();
|
|
|
|
|
|
if (type < 3) {
|
|
|
|
|
|
mirrorTimers_[type].paused = (paused != 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Cast result (WotLK extended cast failed) ----
|
2026-03-09 21:46:18 -07:00
|
|
|
|
case Opcode::SMSG_CAST_RESULT: {
|
|
|
|
|
|
// WotLK: castCount(u8) + spellId(u32) + result(u8)
|
|
|
|
|
|
// TBC/Classic: spellId(u32) + result(u8) (no castCount prefix)
|
2026-03-09 14:30:48 -07:00
|
|
|
|
// If result == 0, the spell successfully began; otherwise treat like SMSG_CAST_FAILED.
|
2026-03-09 21:46:18 -07:00
|
|
|
|
uint32_t castResultSpellId = 0;
|
|
|
|
|
|
uint8_t castResult = 0;
|
|
|
|
|
|
if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) {
|
|
|
|
|
|
if (castResult != 0) {
|
2026-03-09 14:30:48 -07:00
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-03-09 14:30:48 -07:00
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-13 05:02:58 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
// Cancel craft queue and spell queue on cast failure
|
2026-03-17 10:12:49 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
2026-03-11 03:41:49 -07:00
|
|
|
|
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
|
|
|
|
|
|
int playerPowerType = -1;
|
|
|
|
|
|
if (auto pe = entityManager.getEntity(playerGuid)) {
|
|
|
|
|
|
if (auto pu = std::dynamic_pointer_cast<Unit>(pe))
|
|
|
|
|
|
playerPowerType = static_cast<int>(pu->getPowerType());
|
|
|
|
|
|
}
|
|
|
|
|
|
const char* reason = getSpellCastResultString(castResult, playerPowerType);
|
2026-03-12 01:15:11 -07:00
|
|
|
|
std::string errMsg = reason ? reason
|
|
|
|
|
|
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
|
|
|
|
|
|
addUIError(errMsg);
|
2026-03-18 04:30:33 -07:00
|
|
|
|
if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId);
|
2026-03-21 02:10:09 -07:00
|
|
|
|
if (addonEventCallback_) {
|
2026-03-20 15:37:33 -07:00
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)});
|
2026-03-21 02:10:09 -07:00
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)});
|
|
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
2026-03-12 01:15:11 -07:00
|
|
|
|
msg.message = errMsg;
|
2026-03-09 14:30:48 -07:00
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-03-09 21:46:18 -07:00
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Spell failed on another unit ----
|
2026-03-09 23:16:15 -07:00
|
|
|
|
case Opcode::SMSG_SPELL_FAILED_OTHER: {
|
2026-03-09 23:20:15 -07:00
|
|
|
|
// WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 reason
|
|
|
|
|
|
// TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 reason
|
|
|
|
|
|
const bool tbcLike2 = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
uint64_t failOtherGuid = tbcLike2
|
|
|
|
|
|
? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0)
|
|
|
|
|
|
: UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 23:16:15 -07:00
|
|
|
|
if (failOtherGuid != 0 && failOtherGuid != playerGuid) {
|
|
|
|
|
|
unitCastStates_.erase(failOtherGuid);
|
|
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 23:16:15 -07:00
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Spell proc resist log ----
|
2026-03-09 19:42:27 -07:00
|
|
|
|
case Opcode::SMSG_PROCRESIST: {
|
2026-03-14 00:45:50 -07:00
|
|
|
|
// WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + ...
|
|
|
|
|
|
// TBC: uint64 caster + uint64 victim + uint32 spellId + ...
|
|
|
|
|
|
const bool prUsesFullGuid = isActiveExpansion("tbc");
|
2026-03-11 02:50:53 -07:00
|
|
|
|
auto readPrGuid = [&]() -> uint64_t {
|
2026-03-14 00:45:50 -07:00
|
|
|
|
if (prUsesFullGuid)
|
2026-03-11 02:50:53 -07:00
|
|
|
|
return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0;
|
|
|
|
|
|
return UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
};
|
2026-03-14 01:18:28 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!prUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-13 11:50:00 -07:00
|
|
|
|
uint64_t caster = readPrGuid();
|
2026-03-14 01:18:28 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!prUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-11 02:50:53 -07:00
|
|
|
|
uint64_t victim = readPrGuid();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
2026-03-13 20:30:39 -07:00
|
|
|
|
if (victim == playerGuid) {
|
2026-03-13 11:50:00 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim);
|
2026-03-13 20:30:39 -07:00
|
|
|
|
} else if (caster == playerGuid) {
|
|
|
|
|
|
addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim);
|
|
|
|
|
|
}
|
2026-03-11 02:50:53 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
2026-03-09 19:42:27 -07:00
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Loot start roll (Need/Greed popup trigger) ----
|
|
|
|
|
|
case Opcode::SMSG_LOOT_START_ROLL: {
|
2026-03-11 05:09:43 -07:00
|
|
|
|
// WotLK 3.3.5a: uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId
|
|
|
|
|
|
// + uint32 randomSuffix + uint32 randomPropId + uint32 countdown + uint8 voteMask (33 bytes)
|
|
|
|
|
|
// Classic/TBC: uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId
|
|
|
|
|
|
// + uint32 countdown + uint8 voteMask (25 bytes)
|
|
|
|
|
|
const bool isWotLK = isActiveExpansion("wotlk");
|
|
|
|
|
|
const size_t minSize = isWotLK ? 33u : 25u;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < minSize) break;
|
2026-03-09 14:30:48 -07:00
|
|
|
|
uint64_t objectGuid = packet.readUInt64();
|
|
|
|
|
|
/*uint32_t mapId =*/ packet.readUInt32();
|
|
|
|
|
|
uint32_t slot = packet.readUInt32();
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
2026-03-20 19:22:59 -07:00
|
|
|
|
int32_t rollRandProp = 0;
|
2026-03-11 05:09:43 -07:00
|
|
|
|
if (isWotLK) {
|
|
|
|
|
|
/*uint32_t randSuffix =*/ packet.readUInt32();
|
2026-03-20 19:22:59 -07:00
|
|
|
|
rollRandProp = static_cast<int32_t>(packet.readUInt32());
|
2026-03-11 05:09:43 -07:00
|
|
|
|
}
|
2026-03-12 04:57:36 -07:00
|
|
|
|
uint32_t countdown = packet.readUInt32();
|
2026-03-18 10:01:53 -07:00
|
|
|
|
uint8_t voteMask = packet.readUInt8();
|
2026-03-09 14:30:48 -07:00
|
|
|
|
// Trigger the roll popup for local player
|
|
|
|
|
|
pendingLootRollActive_ = true;
|
|
|
|
|
|
pendingLootRoll_.objectGuid = objectGuid;
|
|
|
|
|
|
pendingLootRoll_.slot = slot;
|
|
|
|
|
|
pendingLootRoll_.itemId = itemId;
|
2026-03-13 05:23:31 -07:00
|
|
|
|
// Ensure item info is queried so the roll popup can show the name/icon.
|
|
|
|
|
|
queryItemInfo(itemId, 0);
|
2026-03-09 14:30:48 -07:00
|
|
|
|
auto* info = getItemInfo(itemId);
|
2026-03-20 19:22:59 -07:00
|
|
|
|
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;
|
2026-03-09 14:30:48 -07:00
|
|
|
|
pendingLootRoll_.itemQuality = info ? static_cast<uint8_t>(info->quality) : 0;
|
2026-03-12 04:57:36 -07:00
|
|
|
|
pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000;
|
2026-03-18 10:01:53 -07:00
|
|
|
|
pendingLootRoll_.voteMask = voteMask;
|
2026-03-12 04:57:36 -07:00
|
|
|
|
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
|
2026-03-09 14:30:48 -07:00
|
|
|
|
LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName,
|
2026-03-18 10:01:53 -07:00
|
|
|
|
") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec);
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("START_LOOT_ROLL", {std::to_string(slot), std::to_string(countdown)});
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET)
- Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with
petNumber, entry, level, name, displayId, and active status
- Detect stable master via gossip option text/keyword matching and
auto-send MSG_LIST_STABLED_PETS request to open the stable UI
- Refresh list automatically after SMSG_STABLE_RESULT to reflect state
- New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket
- New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber)
- Stable window UI: shows active/stabled pets with store/retrieve buttons,
slot count, refresh, and close; opens when server sends pet list
- Clear stable state on world logout/disconnect
2026-03-12 19:15:52 -07:00
|
|
|
|
// ---- Pet stable list ----
|
|
|
|
|
|
case Opcode::MSG_LIST_STABLED_PETS:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) handleListStabledPets(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 14:30:48 -07:00
|
|
|
|
// ---- Pet stable result ----
|
|
|
|
|
|
case Opcode::SMSG_STABLE_RESULT: {
|
|
|
|
|
|
// uint8 result
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
const char* msg = nullptr;
|
|
|
|
|
|
switch (result) {
|
|
|
|
|
|
case 0x01: msg = "Pet stored in stable."; break;
|
|
|
|
|
|
case 0x06: msg = "Pet retrieved from stable."; break;
|
|
|
|
|
|
case 0x07: msg = "Stable slot purchased."; break;
|
|
|
|
|
|
case 0x08: msg = "Stable list updated."; break;
|
2026-03-17 17:48:04 -07:00
|
|
|
|
case 0x09: msg = "Stable failed: not enough money or other error.";
|
|
|
|
|
|
addUIError(msg); break;
|
2026-03-09 14:30:48 -07:00
|
|
|
|
default: break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (msg) addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast<int>(result));
|
feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET)
- Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with
petNumber, entry, level, name, displayId, and active status
- Detect stable master via gossip option text/keyword matching and
auto-send MSG_LIST_STABLED_PETS request to open the stable UI
- Refresh list automatically after SMSG_STABLE_RESULT to reflect state
- New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket
- New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber)
- Stable window UI: shows active/stabled pets with store/retrieve buttons,
slot count, refresh, and close; opens when server sends pet list
- Clear stable state on world logout/disconnect
2026-03-12 19:15:52 -07:00
|
|
|
|
// Refresh the stable list after a result to reflect the new state
|
|
|
|
|
|
if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) {
|
|
|
|
|
|
auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_);
|
|
|
|
|
|
socket->send(refreshPkt);
|
|
|
|
|
|
}
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Title earned ----
|
|
|
|
|
|
case Opcode::SMSG_TITLE_EARNED: {
|
|
|
|
|
|
// uint32 titleBitIndex + uint32 isLost
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint32_t titleBit = packet.readUInt32();
|
|
|
|
|
|
uint32_t isLost = packet.readUInt32();
|
2026-03-12 19:05:54 -07:00
|
|
|
|
loadTitleNameCache();
|
|
|
|
|
|
|
|
|
|
|
|
// Format the title string using the player's own name
|
|
|
|
|
|
std::string titleStr;
|
|
|
|
|
|
auto tit = titleNameCache_.find(titleBit);
|
|
|
|
|
|
if (tit != titleNameCache_.end() && !tit->second.empty()) {
|
|
|
|
|
|
// Title strings contain "%s" as a player-name placeholder.
|
|
|
|
|
|
// Replace it with the local player's name if known.
|
|
|
|
|
|
auto nameIt = playerNameCache.find(playerGuid);
|
|
|
|
|
|
const std::string& pName = (nameIt != playerNameCache.end())
|
|
|
|
|
|
? nameIt->second : "you";
|
|
|
|
|
|
const std::string& fmt = tit->second;
|
|
|
|
|
|
size_t pos = fmt.find("%s");
|
|
|
|
|
|
if (pos != std::string::npos) {
|
|
|
|
|
|
titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
titleStr = fmt;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
if (!titleStr.empty()) {
|
|
|
|
|
|
msg = isLost ? ("Title removed: " + titleStr + ".")
|
|
|
|
|
|
: ("Title earned: " + titleStr + "!");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
char buf[64];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!",
|
|
|
|
|
|
titleBit);
|
|
|
|
|
|
msg = buf;
|
|
|
|
|
|
}
|
2026-03-12 20:23:36 -07:00
|
|
|
|
// Track in known title set
|
|
|
|
|
|
if (isLost) {
|
|
|
|
|
|
knownTitleBits_.erase(titleBit);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
knownTitleBits_.insert(titleBit);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only post chat message for actual earned/lost events (isLost and new earn)
|
|
|
|
|
|
// Server sends isLost=0 for all known titles during login — suppress the chat spam
|
|
|
|
|
|
// by only notifying when we already had some titles (after login sequence)
|
2026-03-12 19:05:54 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost,
|
2026-03-12 20:23:36 -07:00
|
|
|
|
" title='", titleStr, "' known=", knownTitleBits_.size());
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 01:48:18 -07:00
|
|
|
|
case Opcode::SMSG_LEARNED_DANCE_MOVES:
|
|
|
|
|
|
// Contains bitmask of learned dance moves — cosmetic only, no gameplay effect.
|
|
|
|
|
|
LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")");
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 14:30:48 -07:00
|
|
|
|
// ---- Hearthstone binding ----
|
|
|
|
|
|
case Opcode::SMSG_PLAYERBOUND: {
|
|
|
|
|
|
// uint64 binderGuid + uint32 mapId + uint32 zoneId
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 16) break;
|
|
|
|
|
|
/*uint64_t binderGuid =*/ packet.readUInt64();
|
2026-03-13 10:33:44 -07:00
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
2026-03-09 14:30:48 -07:00
|
|
|
|
uint32_t zoneId = packet.readUInt32();
|
2026-03-13 10:33:44 -07:00
|
|
|
|
// Update home bind location so hearthstone tooltip reflects the new zone
|
|
|
|
|
|
homeBindMapId_ = mapId;
|
|
|
|
|
|
homeBindZoneId_ = zoneId;
|
2026-03-13 06:46:56 -07:00
|
|
|
|
std::string pbMsg = "Your home location has been set";
|
|
|
|
|
|
std::string zoneName = getAreaName(zoneId);
|
|
|
|
|
|
if (!zoneName.empty())
|
|
|
|
|
|
pbMsg += " to " + zoneName;
|
|
|
|
|
|
pbMsg += '.';
|
|
|
|
|
|
addSystemChatMessage(pbMsg);
|
2026-03-09 14:30:48 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BINDER_CONFIRM: {
|
2026-03-13 06:46:56 -07:00
|
|
|
|
// uint64 npcGuid — fires just before SMSG_PLAYERBOUND; PLAYERBOUND shows
|
|
|
|
|
|
// the zone name so this confirm is redundant. Consume silently.
|
2026-03-09 14:30:48 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Phase shift (WotLK phasing) ----
|
|
|
|
|
|
case Opcode::SMSG_SET_PHASE_SHIFT: {
|
|
|
|
|
|
// uint32 phaseFlags [+ packed guid + uint16 count + repeated uint16 phaseIds]
|
|
|
|
|
|
// Just consume; phasing doesn't require action from client in WotLK
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- XP gain toggle ----
|
|
|
|
|
|
case Opcode::SMSG_TOGGLE_XP_GAIN: {
|
|
|
|
|
|
// uint8 enabled
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint8_t enabled = packet.readUInt8();
|
|
|
|
|
|
addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:38:45 -07:00
|
|
|
|
// ---- Gossip POI (quest map markers) ----
|
|
|
|
|
|
case Opcode::SMSG_GOSSIP_POI: {
|
|
|
|
|
|
// uint32 flags + float x + float y + uint32 icon + uint32 data + string name
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 20) break;
|
|
|
|
|
|
/*uint32_t flags =*/ packet.readUInt32();
|
|
|
|
|
|
float poiX = packet.readFloat(); // WoW canonical coords
|
|
|
|
|
|
float poiY = packet.readFloat();
|
|
|
|
|
|
uint32_t icon = packet.readUInt32();
|
|
|
|
|
|
uint32_t data = packet.readUInt32();
|
|
|
|
|
|
std::string name = packet.readString();
|
|
|
|
|
|
GossipPoi poi;
|
|
|
|
|
|
poi.x = poiX;
|
|
|
|
|
|
poi.y = poiY;
|
|
|
|
|
|
poi.icon = icon;
|
|
|
|
|
|
poi.data = data;
|
|
|
|
|
|
poi.name = std::move(name);
|
2026-03-20 18:51:05 -07:00
|
|
|
|
// Cap POI count to prevent unbounded growth from rapid gossip queries
|
|
|
|
|
|
if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin());
|
2026-03-09 14:38:45 -07:00
|
|
|
|
gossipPois_.push_back(std::move(poi));
|
|
|
|
|
|
LOG_DEBUG("SMSG_GOSSIP_POI: x=", poiX, " y=", poiY, " icon=", icon);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 15:11:21 -07:00
|
|
|
|
// ---- Character service results ----
|
|
|
|
|
|
case Opcode::SMSG_CHAR_RENAME: {
|
|
|
|
|
|
// uint32 result (0=success) + uint64 guid + string newName
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 13) {
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
|
|
|
|
|
std::string newName = packet.readString();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Character name changed to: " + newName);
|
|
|
|
|
|
} else {
|
2026-03-13 07:14:40 -07:00
|
|
|
|
// ResponseCodes for name changes (shared with char create)
|
|
|
|
|
|
static const char* kRenameErrors[] = {
|
|
|
|
|
|
nullptr, // 0 = success
|
|
|
|
|
|
"Name already in use.", // 1
|
|
|
|
|
|
"Name too short.", // 2
|
|
|
|
|
|
"Name too long.", // 3
|
|
|
|
|
|
"Name contains invalid characters.", // 4
|
|
|
|
|
|
"Name contains a profanity.", // 5
|
|
|
|
|
|
"Name is reserved.", // 6
|
|
|
|
|
|
"Character name does not meet requirements.", // 7
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr;
|
2026-03-17 17:48:04 -07:00
|
|
|
|
std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg
|
|
|
|
|
|
: "Character rename failed.";
|
|
|
|
|
|
addUIError(renameErr);
|
|
|
|
|
|
addSystemChatMessage(renameErr);
|
2026-03-09 15:11:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BINDZONEREPLY: {
|
|
|
|
|
|
// uint32 result (0=success, 1=too far)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Your home is now set to this location.");
|
|
|
|
|
|
} else {
|
2026-03-17 17:36:25 -07:00
|
|
|
|
addUIError("You are too far from the innkeeper.");
|
2026-03-09 15:11:21 -07:00
|
|
|
|
addSystemChatMessage("You are too far from the innkeeper.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT: {
|
|
|
|
|
|
// uint32 result
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Difficulty changed.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
static const char* reasons[] = {
|
|
|
|
|
|
"", "Error", "Too many members", "Already in dungeon",
|
|
|
|
|
|
"You are in a battleground", "Raid not allowed in heroic",
|
|
|
|
|
|
"You must be in a raid group", "Player not in group"
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed.";
|
2026-03-17 17:36:25 -07:00
|
|
|
|
addUIError(std::string("Cannot change difficulty: ") + msg);
|
2026-03-09 15:11:21 -07:00
|
|
|
|
addSystemChatMessage(std::string("Cannot change difficulty: ") + msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE:
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError("Your corpse is outside this instance.");
|
2026-03-09 15:11:21 -07:00
|
|
|
|
addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: {
|
|
|
|
|
|
// uint64 playerGuid + uint32 threshold
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
uint32_t threshold = packet.readUInt32();
|
|
|
|
|
|
if (guid == playerGuid && threshold > 0) {
|
|
|
|
|
|
addSystemChatMessage("You feel rather drunk.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_CROSSED_INEBRIATION_THRESHOLD: guid=0x", std::hex, guid,
|
|
|
|
|
|
std::dec, " threshold=", threshold);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE:
|
|
|
|
|
|
// Far sight cancelled; viewport returns to player camera
|
|
|
|
|
|
LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_COMBAT_EVENT_FAILED:
|
|
|
|
|
|
// Combat event could not be executed (e.g. invalid target for special ability)
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_ANIM: {
|
|
|
|
|
|
// packed_guid + uint32 animId — force entity to play animation
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
2026-03-12 01:04:16 -07:00
|
|
|
|
uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 15:11:21 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
2026-03-12 01:04:16 -07:00
|
|
|
|
uint32_t animId = packet.readUInt32();
|
|
|
|
|
|
if (emoteAnimCallback_)
|
|
|
|
|
|
emoteAnimCallback_(animGuid, animId);
|
2026-03-09 15:11:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM:
|
|
|
|
|
|
case Opcode::SMSG_GAMEOBJECT_RESET_STATE:
|
|
|
|
|
|
case Opcode::SMSG_FLIGHT_SPLINE_SYNC:
|
|
|
|
|
|
case Opcode::SMSG_FORCE_DISPLAY_UPDATE:
|
|
|
|
|
|
case Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS:
|
|
|
|
|
|
case Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID:
|
|
|
|
|
|
case Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE:
|
|
|
|
|
|
case Opcode::SMSG_DAMAGE_CALC_LOG:
|
|
|
|
|
|
case Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT:
|
|
|
|
|
|
case Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE:
|
|
|
|
|
|
// Consume — handled by broader object update or not yet implemented
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-12 21:28:24 -07:00
|
|
|
|
case Opcode::SMSG_FORCED_DEATH_UPDATE:
|
|
|
|
|
|
// Server forces player into dead state (GM command, scripted event, etc.)
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet
|
2026-03-20 11:56:59 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_DEAD", {});
|
2026-03-13 07:54:02 -07:00
|
|
|
|
addSystemChatMessage("You have been killed.");
|
2026-03-12 21:28:24 -07:00
|
|
|
|
LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 15:11:21 -07:00
|
|
|
|
|
2026-03-09 15:09:50 -07:00
|
|
|
|
// ---- Zone defense messages ----
|
|
|
|
|
|
case Opcode::SMSG_DEFENSE_MESSAGE: {
|
|
|
|
|
|
// uint32 zoneId + string message — used for PvP zone attack alerts
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 5) {
|
|
|
|
|
|
/*uint32_t zoneId =*/ packet.readUInt32();
|
|
|
|
|
|
std::string defMsg = packet.readString();
|
|
|
|
|
|
if (!defMsg.empty()) {
|
|
|
|
|
|
addSystemChatMessage("[Defense] " + defMsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CORPSE_RECLAIM_DELAY: {
|
2026-03-17 23:52:45 -07:00
|
|
|
|
// uint32 delayMs before player can reclaim corpse (PvP deaths)
|
2026-03-09 15:09:50 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t delayMs = packet.readUInt32();
|
2026-03-17 23:52:45 -07:00
|
|
|
|
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");
|
2026-03-09 15:09:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_DEATH_RELEASE_LOC: {
|
2026-03-17 23:44:55 -07:00
|
|
|
|
// uint32 mapId + float x + float y + float z
|
|
|
|
|
|
// This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location.
|
|
|
|
|
|
// The corpse remains at the death position (already cached when health dropped to 0,
|
|
|
|
|
|
// and updated when the corpse object arrives via SMSG_UPDATE_OBJECT).
|
|
|
|
|
|
// Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse()
|
|
|
|
|
|
// by making it check distance to the graveyard instead of the real corpse.
|
2026-03-09 15:09:50 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 16) {
|
2026-03-17 23:44:55 -07:00
|
|
|
|
uint32_t relMapId = packet.readUInt32();
|
|
|
|
|
|
float relX = packet.readFloat();
|
|
|
|
|
|
float relY = packet.readFloat();
|
|
|
|
|
|
float relZ = packet.readFloat();
|
|
|
|
|
|
LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId,
|
|
|
|
|
|
" x=", relX, " y=", relY, " z=", relZ);
|
2026-03-09 15:09:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_ENABLE_BARBER_SHOP:
|
|
|
|
|
|
// Sent by server when player sits in barber chair — triggers barber shop UI
|
|
|
|
|
|
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
|
2026-03-18 11:58:01 -07:00
|
|
|
|
barberShopOpen_ = true;
|
2026-03-09 15:09:50 -07:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FEIGN_DEATH_RESISTED:
|
2026-03-12 01:15:11 -07:00
|
|
|
|
addUIError("Your Feign Death was resisted.");
|
2026-03-09 15:09:50 -07:00
|
|
|
|
addSystemChatMessage("Your Feign Death attempt was resisted.");
|
|
|
|
|
|
LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_CHANNEL_MEMBER_COUNT: {
|
|
|
|
|
|
// string channelName + uint8 flags + uint32 memberCount
|
|
|
|
|
|
std::string chanName = packet.readString();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 5) {
|
|
|
|
|
|
/*uint8_t flags =*/ packet.readUInt8();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
LOG_DEBUG("SMSG_CHANNEL_MEMBER_COUNT: channel=", chanName, " members=", count);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_GAMETIME_SET:
|
|
|
|
|
|
case Opcode::SMSG_GAMETIME_UPDATE:
|
2026-03-09 16:21:06 -07:00
|
|
|
|
// Server time correction: uint32 gameTimePacked (seconds since epoch)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t gameTimePacked = packet.readUInt32();
|
|
|
|
|
|
gameTime_ = static_cast<float>(gameTimePacked);
|
|
|
|
|
|
LOG_DEBUG("Server game time update: ", gameTime_, "s");
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 15:09:50 -07:00
|
|
|
|
case Opcode::SMSG_GAMESPEED_SET:
|
2026-03-09 16:21:06 -07:00
|
|
|
|
// Server speed correction: uint32 gameTimePacked + float timeSpeed
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint32_t gameTimePacked = packet.readUInt32();
|
|
|
|
|
|
float timeSpeed = packet.readFloat();
|
|
|
|
|
|
gameTime_ = static_cast<float>(gameTimePacked);
|
|
|
|
|
|
timeSpeed_ = timeSpeed;
|
|
|
|
|
|
LOG_DEBUG("Server game speed update: time=", gameTime_, " speed=", timeSpeed_);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GAMETIMEBIAS_SET:
|
|
|
|
|
|
// Time bias — consume without processing
|
2026-03-09 15:09:50 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-12 21:28:24 -07:00
|
|
|
|
case Opcode::SMSG_ACHIEVEMENT_DELETED: {
|
|
|
|
|
|
// uint32 achievementId — remove from local earned set
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t achId = packet.readUInt32();
|
|
|
|
|
|
earnedAchievements_.erase(achId);
|
|
|
|
|
|
achievementDates_.erase(achId);
|
|
|
|
|
|
LOG_DEBUG("SMSG_ACHIEVEMENT_DELETED: id=", achId);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CRITERIA_DELETED: {
|
|
|
|
|
|
// uint32 criteriaId — remove from local criteria progress
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t critId = packet.readUInt32();
|
|
|
|
|
|
criteriaProgress_.erase(critId);
|
|
|
|
|
|
LOG_DEBUG("SMSG_CRITERIA_DELETED: id=", critId);
|
|
|
|
|
|
}
|
2026-03-09 15:09:50 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-12 21:28:24 -07:00
|
|
|
|
}
|
2026-03-09 15:09:50 -07:00
|
|
|
|
|
2026-03-09 14:38:45 -07:00
|
|
|
|
// ---- Combat clearing ----
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSWING_DEADTARGET:
|
|
|
|
|
|
// Target died mid-swing: clear auto-attack
|
|
|
|
|
|
autoAttacking = false;
|
|
|
|
|
|
autoAttackTarget = 0;
|
|
|
|
|
|
break;
|
2026-03-09 15:02:15 -07:00
|
|
|
|
case Opcode::SMSG_THREAT_CLEAR:
|
|
|
|
|
|
// All threat dropped on the local player (e.g. Vanish, Feign Death)
|
2026-03-12 02:59:09 -07:00
|
|
|
|
threatLists_.clear();
|
2026-03-09 15:02:15 -07:00
|
|
|
|
LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_THREAT_REMOVE: {
|
|
|
|
|
|
// packed_guid (unit) + packed_guid (victim whose threat was removed)
|
2026-03-12 02:59:09 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
auto it = threatLists_.find(unitGuid);
|
|
|
|
|
|
if (it != threatLists_.end()) {
|
|
|
|
|
|
auto& list = it->second;
|
|
|
|
|
|
list.erase(std::remove_if(list.begin(), list.end(),
|
|
|
|
|
|
[victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }),
|
|
|
|
|
|
list.end());
|
|
|
|
|
|
if (list.empty()) threatLists_.erase(it);
|
2026-03-09 15:02:15 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-12 02:59:09 -07:00
|
|
|
|
case Opcode::SMSG_HIGHEST_THREAT_UPDATE:
|
|
|
|
|
|
case Opcode::SMSG_THREAT_UPDATE: {
|
|
|
|
|
|
// Both packets share the same format:
|
|
|
|
|
|
// packed_guid (unit) + packed_guid (highest-threat target or target, unused here)
|
|
|
|
|
|
// + uint32 count + count × (packed_guid victim + uint32 threat)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
(void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t cnt = packet.readUInt32();
|
|
|
|
|
|
if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity
|
|
|
|
|
|
std::vector<ThreatEntry> list;
|
|
|
|
|
|
list.reserve(cnt);
|
|
|
|
|
|
for (uint32_t i = 0; i < cnt; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
ThreatEntry entry;
|
|
|
|
|
|
entry.victimGuid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
entry.threat = packet.readUInt32();
|
|
|
|
|
|
list.push_back(entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Sort descending by threat so highest is first
|
|
|
|
|
|
std::sort(list.begin(), list.end(),
|
|
|
|
|
|
[](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; });
|
|
|
|
|
|
threatLists_[unitGuid] = std::move(list);
|
2026-03-09 15:02:15 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 14:38:45 -07:00
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_CANCEL_COMBAT:
|
|
|
|
|
|
// Server-side combat state reset
|
|
|
|
|
|
autoAttacking = false;
|
|
|
|
|
|
autoAttackTarget = 0;
|
|
|
|
|
|
autoAttackRequested_ = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_BREAK_TARGET:
|
|
|
|
|
|
// Server breaking our targeting (PvP flag, etc.)
|
|
|
|
|
|
// uint64 guid — consume; target cleared if it matches
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t bGuid = packet.readUInt64();
|
|
|
|
|
|
if (bGuid == targetGuid) targetGuid = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_CLEAR_TARGET:
|
|
|
|
|
|
// uint64 guid — server cleared targeting on a unit (or 0 = clear all)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t cGuid = packet.readUInt64();
|
|
|
|
|
|
if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Server-forced dismount ----
|
|
|
|
|
|
case Opcode::SMSG_DISMOUNT:
|
|
|
|
|
|
// No payload — server forcing dismount
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
if (mountCallback_) mountCallback_(0);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_MOUNTRESULT: {
|
|
|
|
|
|
// uint32 result: 0=error, 1=invalid, 2=not in range, 3=already mounted, 4=ok
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result != 4) {
|
|
|
|
|
|
const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." };
|
2026-03-17 17:24:23 -07:00
|
|
|
|
std::string mountErr = result < 4 ? msgs[result] : "Cannot mount.";
|
|
|
|
|
|
addUIError(mountErr);
|
|
|
|
|
|
addSystemChatMessage(mountErr);
|
2026-03-09 14:38:45 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_DISMOUNTRESULT: {
|
|
|
|
|
|
// uint32 result: 0=ok, others=error
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
2026-03-17 17:24:23 -07:00
|
|
|
|
if (result != 0) { addUIError("Cannot dismount here."); addSystemChatMessage("Cannot dismount here."); }
|
2026-03-09 14:38:45 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Loot notifications ----
|
|
|
|
|
|
case Opcode::SMSG_LOOT_ALL_PASSED: {
|
2026-03-11 05:09:43 -07:00
|
|
|
|
// WotLK 3.3.5a: uint64 objectGuid + uint32 slot + uint32 itemId + uint32 randSuffix + uint32 randPropId (24 bytes)
|
|
|
|
|
|
// Classic/TBC: uint64 objectGuid + uint32 slot + uint32 itemId (16 bytes)
|
|
|
|
|
|
const bool isWotLK = isActiveExpansion("wotlk");
|
|
|
|
|
|
const size_t minSize = isWotLK ? 24u : 16u;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < minSize) break;
|
2026-03-09 14:38:45 -07:00
|
|
|
|
/*uint64_t objGuid =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint32_t slot =*/ packet.readUInt32();
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
2026-03-11 05:09:43 -07:00
|
|
|
|
if (isWotLK) {
|
|
|
|
|
|
/*uint32_t randSuffix =*/ packet.readUInt32();
|
|
|
|
|
|
/*uint32_t randProp =*/ packet.readUInt32();
|
|
|
|
|
|
}
|
2026-03-09 14:38:45 -07:00
|
|
|
|
auto* info = getItemInfo(itemId);
|
2026-03-17 13:33:07 -07:00
|
|
|
|
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) + ".");
|
2026-03-09 14:38:45 -07:00
|
|
|
|
pendingLootRollActive_ = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 20:31:57 -07:00
|
|
|
|
case Opcode::SMSG_LOOT_ITEM_NOTIFY: {
|
|
|
|
|
|
// uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 24) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t looterGuid = packet.readUInt64();
|
|
|
|
|
|
/*uint64_t lootGuid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
// Show loot message for party members (not the player — SMSG_ITEM_PUSH_RESULT covers that)
|
|
|
|
|
|
if (isInGroup() && looterGuid != playerGuid) {
|
|
|
|
|
|
auto nit = playerNameCache.find(looterGuid);
|
|
|
|
|
|
std::string looterName = (nit != playerNameCache.end()) ? nit->second : "";
|
|
|
|
|
|
if (!looterName.empty()) {
|
|
|
|
|
|
queryItemInfo(itemId, 0);
|
|
|
|
|
|
std::string itemName = "item #" + std::to_string(itemId);
|
2026-03-17 13:33:07 -07:00
|
|
|
|
uint32_t notifyQuality = 1;
|
2026-03-09 20:31:57 -07:00
|
|
|
|
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
|
|
|
|
|
|
if (!info->name.empty()) itemName = info->name;
|
2026-03-17 13:33:07 -07:00
|
|
|
|
notifyQuality = info->quality;
|
2026-03-09 20:31:57 -07:00
|
|
|
|
}
|
2026-03-17 13:33:07 -07:00
|
|
|
|
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);
|
2026-03-09 20:31:57 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 14:38:45 -07:00
|
|
|
|
break;
|
2026-03-09 20:31:57 -07:00
|
|
|
|
}
|
2026-03-17 10:20:29 -07:00
|
|
|
|
case Opcode::SMSG_LOOT_SLOT_CHANGED: {
|
|
|
|
|
|
// uint8 slotIndex — another player took the item from this slot in group loot
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t slotIndex = packet.readUInt8();
|
|
|
|
|
|
for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) {
|
|
|
|
|
|
if (it->slotIndex == slotIndex) {
|
|
|
|
|
|
currentLoot.items.erase(it);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 14:38:45 -07:00
|
|
|
|
break;
|
2026-03-17 10:20:29 -07:00
|
|
|
|
}
|
2026-03-09 14:38:45 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Spell log miss ----
|
|
|
|
|
|
case Opcode::SMSG_SPELLLOGMISS: {
|
2026-03-11 02:47:15 -07:00
|
|
|
|
// All expansions: uint32 spellId first.
|
2026-03-13 22:45:59 -07:00
|
|
|
|
// 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)
|
2026-03-13 22:38:35 -07:00
|
|
|
|
// All expansions append uint32 reflectSpellId + uint8 reflectResult when
|
|
|
|
|
|
// missInfo==11 (REFLECT).
|
2026-03-13 22:45:59 -07:00
|
|
|
|
const bool spellMissUsesFullGuid = isActiveExpansion("tbc");
|
2026-03-09 23:45:10 -07:00
|
|
|
|
auto readSpellMissGuid = [&]() -> uint64_t {
|
2026-03-13 22:45:59 -07:00
|
|
|
|
if (spellMissUsesFullGuid)
|
2026-03-09 23:45:10 -07:00
|
|
|
|
return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0;
|
|
|
|
|
|
return UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
};
|
2026-03-11 02:47:15 -07:00
|
|
|
|
// spellId prefix present in all expansions
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
2026-03-13 19:43:50 -07:00
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
2026-03-14 01:54:01 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-09 23:45:10 -07:00
|
|
|
|
uint64_t casterGuid = readSpellMissGuid();
|
2026-03-09 14:38:45 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
2026-03-11 02:47:15 -07:00
|
|
|
|
/*uint8_t unk =*/ packet.readUInt8();
|
2026-03-14 13:29:55 -07:00
|
|
|
|
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;
|
2026-03-18 09:59:54 -07:00
|
|
|
|
uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT)
|
2026-03-14 13:29:55 -07:00
|
|
|
|
};
|
|
|
|
|
|
std::vector<SpellMissLogEntry> parsedMisses;
|
|
|
|
|
|
parsedMisses.reserve(storedLimit);
|
|
|
|
|
|
|
|
|
|
|
|
bool truncated = false;
|
|
|
|
|
|
for (uint32_t i = 0; i < rawCount; ++i) {
|
2026-03-14 01:54:01 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u)
|
|
|
|
|
|
|| (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-14 13:29:55 -07:00
|
|
|
|
truncated = true;
|
|
|
|
|
|
break;
|
2026-03-14 01:54:01 -07:00
|
|
|
|
}
|
2026-03-14 13:29:55 -07:00
|
|
|
|
const uint64_t victimGuid = readSpellMissGuid();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) {
|
|
|
|
|
|
truncated = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
const uint8_t missInfo = packet.readUInt8();
|
2026-03-11 02:47:15 -07:00
|
|
|
|
// REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult
|
2026-03-18 09:59:54 -07:00
|
|
|
|
uint32_t reflectSpellId = 0;
|
2026-03-13 22:38:35 -07:00
|
|
|
|
if (missInfo == 11) {
|
2026-03-11 02:47:15 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 5) {
|
2026-03-18 09:59:54 -07:00
|
|
|
|
reflectSpellId = packet.readUInt32();
|
|
|
|
|
|
/*uint8_t reflectResult =*/ packet.readUInt8();
|
2026-03-11 02:47:15 -07:00
|
|
|
|
} else {
|
2026-03-14 13:29:55 -07:00
|
|
|
|
truncated = true;
|
2026-03-11 02:47:15 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 13:29:55 -07:00
|
|
|
|
if (i < storedLimit) {
|
2026-03-18 09:59:54 -07:00
|
|
|
|
parsedMisses.push_back({victimGuid, missInfo, reflectSpellId});
|
2026-03-14 13:29:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (truncated) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& miss : parsedMisses) {
|
|
|
|
|
|
const uint64_t victimGuid = miss.victimGuid;
|
|
|
|
|
|
const uint8_t missInfo = miss.missInfo;
|
2026-03-14 09:44:52 -07:00
|
|
|
|
CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo);
|
2026-03-18 09:59:54 -07:00
|
|
|
|
// 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;
|
2026-03-09 14:38:45 -07:00
|
|
|
|
if (casterGuid == playerGuid) {
|
2026-03-13 06:09:42 -07:00
|
|
|
|
// We cast a spell and it missed the target
|
2026-03-18 09:59:54 -07:00
|
|
|
|
addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid);
|
2026-03-13 06:09:42 -07:00
|
|
|
|
} else if (victimGuid == playerGuid) {
|
|
|
|
|
|
// Enemy spell missed us (we dodged/parried/blocked/resisted/etc.)
|
2026-03-18 09:59:54 -07:00
|
|
|
|
addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid);
|
2026-03-09 14:38:45 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Environmental damage log ----
|
|
|
|
|
|
case Opcode::SMSG_ENVIRONMENTALDAMAGELOG: {
|
|
|
|
|
|
// uint64 victimGuid + uint8 envDamageType + uint32 damage + uint32 absorb + uint32 resist
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 21) break;
|
|
|
|
|
|
uint64_t victimGuid = packet.readUInt64();
|
|
|
|
|
|
/*uint8_t envType =*/ packet.readUInt8();
|
|
|
|
|
|
uint32_t damage = packet.readUInt32();
|
2026-03-11 03:31:33 -07:00
|
|
|
|
uint32_t absorb = packet.readUInt32();
|
|
|
|
|
|
uint32_t resist = packet.readUInt32();
|
|
|
|
|
|
if (victimGuid == playerGuid) {
|
2026-03-13 11:52:31 -07:00
|
|
|
|
// Environmental damage: no caster GUID, victim = player
|
2026-03-11 03:31:33 -07:00
|
|
|
|
if (damage > 0)
|
2026-03-13 11:52:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast<int32_t>(damage), 0, false, 0, 0, victimGuid);
|
2026-03-11 03:31:33 -07:00
|
|
|
|
if (absorb > 0)
|
2026-03-13 11:52:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(absorb), 0, false, 0, 0, victimGuid);
|
2026-03-11 03:31:33 -07:00
|
|
|
|
if (resist > 0)
|
2026-03-13 11:52:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(resist), 0, false, 0, 0, victimGuid);
|
2026-03-09 14:38:45 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
// ---- Creature Movement ----
|
|
|
|
|
|
case Opcode::SMSG_MONSTER_MOVE:
|
|
|
|
|
|
handleMonsterMove(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-18 03:13:17 -08:00
|
|
|
|
case Opcode::SMSG_COMPRESSED_MOVES:
|
|
|
|
|
|
handleCompressedMoves(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
|
case Opcode::SMSG_MONSTER_MOVE_TRANSPORT:
|
|
|
|
|
|
handleMonsterMoveTransport(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:59:32 -07:00
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_LAND_WALK:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_ROOT:
|
2026-03-10 11:14:58 -07:00
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: {
|
|
|
|
|
|
// Minimal parse: PackedGuid only — no animation-relevant state change.
|
2026-02-18 23:38:34 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
(void)UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-10 11:14:58 -07:00
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_SET_FLYING:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_START_SWIM:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: {
|
|
|
|
|
|
// PackedGuid + synthesised move-flags → drives animation state in application layer.
|
|
|
|
|
|
// SWIMMING=0x00200000, WALKING=0x00000100, CAN_FLY=0x00800000, FLYING=0x01000000
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break;
|
|
|
|
|
|
uint32_t synthFlags = 0;
|
|
|
|
|
|
if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_START_SWIM)
|
|
|
|
|
|
synthFlags = 0x00200000u; // SWIMMING
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE)
|
|
|
|
|
|
synthFlags = 0x00000100u; // WALKING
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_FLYING)
|
|
|
|
|
|
synthFlags = 0x01000000u | 0x00800000u; // FLYING | CAN_FLY
|
|
|
|
|
|
// STOP_SWIM and SET_RUN_MODE: synthFlags stays 0 → clears swim/walk
|
|
|
|
|
|
unitMoveFlagsCallback_(guid, synthFlags);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::SMSG_SPLINE_SET_RUN_SPEED:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_SWIM_SPEED: {
|
2026-02-18 23:38:34 -08:00
|
|
|
|
// Minimal parse: PackedGuid + float speed
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
float speed = packet.readFloat();
|
2026-03-10 14:18:25 -07:00
|
|
|
|
if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) {
|
|
|
|
|
|
if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED)
|
|
|
|
|
|
serverRunSpeed_ = speed;
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED)
|
|
|
|
|
|
serverRunBackSpeed_ = speed;
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_SPEED)
|
|
|
|
|
|
serverSwimSpeed_ = speed;
|
2026-02-18 23:38:34 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
2026-02-07 17:59:40 -08:00
|
|
|
|
// ---- Speed Changes ----
|
|
|
|
|
|
case Opcode::SMSG_FORCE_RUN_SPEED_CHANGE:
|
|
|
|
|
|
handleForceRunSpeedChange(packet);
|
|
|
|
|
|
break;
|
2026-02-20 03:14:48 -08:00
|
|
|
|
case Opcode::SMSG_FORCE_MOVE_ROOT:
|
|
|
|
|
|
handleForceMoveRootState(packet, true);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_MOVE_UNROOT:
|
|
|
|
|
|
handleForceMoveRootState(packet, false);
|
|
|
|
|
|
break;
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
|
|
|
|
|
|
// ---- Other force speed changes ----
|
|
|
|
|
|
case Opcode::SMSG_FORCE_WALK_SPEED_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "WALK_SPEED", Opcode::CMSG_FORCE_WALK_SPEED_CHANGE_ACK, &serverWalkSpeed_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_RUN_BACK_SPEED_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "RUN_BACK_SPEED", Opcode::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK, &serverRunBackSpeed_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_SWIM_SPEED_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "SWIM_SPEED", Opcode::CMSG_FORCE_SWIM_SPEED_CHANGE_ACK, &serverSwimSpeed_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_SWIM_BACK_SPEED_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "SWIM_BACK_SPEED", Opcode::CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK, &serverSwimBackSpeed_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_FLIGHT_SPEED_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "FLIGHT_SPEED", Opcode::CMSG_FORCE_FLIGHT_SPEED_CHANGE_ACK, &serverFlightSpeed_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "FLIGHT_BACK_SPEED", Opcode::CMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE_ACK, &serverFlightBackSpeed_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_TURN_RATE_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "TURN_RATE", Opcode::CMSG_FORCE_TURN_RATE_CHANGE_ACK, &serverTurnRate_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FORCE_PITCH_RATE_CHANGE:
|
|
|
|
|
|
handleForceSpeedChange(packet, "PITCH_RATE", Opcode::CMSG_FORCE_PITCH_RATE_CHANGE_ACK, &serverPitchRate_);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Movement flag toggle ACKs ----
|
|
|
|
|
|
case Opcode::SMSG_MOVE_SET_CAN_FLY:
|
|
|
|
|
|
handleForceMoveFlagChange(packet, "SET_CAN_FLY", Opcode::CMSG_MOVE_SET_CAN_FLY_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::CAN_FLY), true);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MOVE_UNSET_CAN_FLY:
|
|
|
|
|
|
handleForceMoveFlagChange(packet, "UNSET_CAN_FLY", Opcode::CMSG_MOVE_SET_CAN_FLY_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::CAN_FLY), false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MOVE_FEATHER_FALL:
|
2026-03-10 13:14:52 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FEATHER_FALL), true);
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MOVE_WATER_WALK:
|
2026-03-10 13:18:04 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::WATER_WALK), true);
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MOVE_SET_HOVER:
|
|
|
|
|
|
handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::HOVER), true);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MOVE_UNSET_HOVER:
|
|
|
|
|
|
handleForceMoveFlagChange(packet, "UNSET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::HOVER), false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Knockback ----
|
|
|
|
|
|
case Opcode::SMSG_MOVE_KNOCK_BACK:
|
|
|
|
|
|
handleMoveKnockBack(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 19:37:53 -07:00
|
|
|
|
case Opcode::SMSG_CAMERA_SHAKE: {
|
|
|
|
|
|
// uint32 shakeID (CameraShakes.dbc), uint32 shakeType
|
|
|
|
|
|
// We don't parse CameraShakes.dbc; apply a hardcoded moderate shake.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint32_t shakeId = packet.readUInt32();
|
|
|
|
|
|
uint32_t shakeType = packet.readUInt32();
|
|
|
|
|
|
(void)shakeType;
|
|
|
|
|
|
// Map shakeId ranges to approximate magnitudes:
|
|
|
|
|
|
// IDs < 50: minor environmental (0.04), others: larger boss effects (0.08)
|
|
|
|
|
|
float magnitude = (shakeId < 50) ? 0.04f : 0.08f;
|
|
|
|
|
|
if (cameraShakeCallback_) {
|
|
|
|
|
|
cameraShakeCallback_(magnitude, 18.0f, 0.5f);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_CAMERA_SHAKE: id=", shakeId, " type=", shakeType,
|
|
|
|
|
|
" magnitude=", magnitude);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 23:26:58 -08:00
|
|
|
|
case Opcode::SMSG_CLIENT_CONTROL_UPDATE: {
|
|
|
|
|
|
// Minimal parse: PackedGuid + uint8 allowMovement.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) {
|
|
|
|
|
|
LOG_WARNING("SMSG_CLIENT_CONTROL_UPDATE too short: ", packet.getSize(), " bytes");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint8_t guidMask = packet.readUInt8();
|
|
|
|
|
|
size_t guidBytes = 0;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
uint64_t controlGuid = 0;
|
2026-02-18 23:26:58 -08:00
|
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
|
|
|
|
if (guidMask & (1u << i)) ++guidBytes;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < guidBytes + 1) {
|
|
|
|
|
|
LOG_WARNING("SMSG_CLIENT_CONTROL_UPDATE malformed (truncated packed guid)");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-18 23:30:38 -08:00
|
|
|
|
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));
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP_STRAFE);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP_TURN);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP_SWIM);
|
2026-02-18 23:30:38 -08:00
|
|
|
|
addSystemChatMessage("Movement disabled by server.");
|
|
|
|
|
|
} else if (changed && allowMovement) {
|
|
|
|
|
|
addSystemChatMessage("Movement re-enabled.");
|
|
|
|
|
|
}
|
2026-02-18 23:26:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 2: Combat ----
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSTART:
|
|
|
|
|
|
handleAttackStart(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSTOP:
|
|
|
|
|
|
handleAttackStop(packet);
|
|
|
|
|
|
break;
|
2026-02-20 03:38:12 -08:00
|
|
|
|
case Opcode::SMSG_ATTACKSWING_NOTINRANGE:
|
|
|
|
|
|
autoAttackOutOfRange_ = true;
|
2026-02-20 17:47:10 -08:00
|
|
|
|
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
|
|
|
|
|
addSystemChatMessage("Target is too far away.");
|
|
|
|
|
|
autoAttackRangeWarnCooldown_ = 1.25f;
|
|
|
|
|
|
}
|
2026-02-20 03:38:12 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSWING_BADFACING:
|
|
|
|
|
|
if (autoAttackRequested_ && autoAttackTarget != 0) {
|
|
|
|
|
|
auto targetEntity = entityManager.getEntity(autoAttackTarget);
|
|
|
|
|
|
if (targetEntity) {
|
|
|
|
|
|
float toTargetX = targetEntity->getX() - movementInfo.x;
|
|
|
|
|
|
float toTargetY = targetEntity->getY() - movementInfo.y;
|
|
|
|
|
|
if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) {
|
|
|
|
|
|
movementInfo.orientation = std::atan2(-toTargetY, toTargetX);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_SET_FACING);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSWING_NOTSTANDING:
|
|
|
|
|
|
autoAttackOutOfRange_ = false;
|
2026-02-20 17:47:10 -08:00
|
|
|
|
autoAttackOutOfRangeTime_ = 0.0f;
|
2026-03-18 01:46:19 -07:00
|
|
|
|
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
|
|
|
|
|
addSystemChatMessage("You need to stand up to fight.");
|
|
|
|
|
|
autoAttackRangeWarnCooldown_ = 1.25f;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSWING_CANT_ATTACK:
|
|
|
|
|
|
// Target is permanently non-attackable (critter, civilian, already dead, etc.).
|
|
|
|
|
|
// Stop the auto-attack loop so the client doesn't spam the server.
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
|
|
|
|
|
addSystemChatMessage("You can't attack that.");
|
|
|
|
|
|
autoAttackRangeWarnCooldown_ = 1.25f;
|
|
|
|
|
|
}
|
2026-02-20 03:38:12 -08:00
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_ATTACKERSTATEUPDATE:
|
|
|
|
|
|
handleAttackerStateUpdate(packet);
|
|
|
|
|
|
break;
|
2026-02-18 23:38:34 -08:00
|
|
|
|
case Opcode::SMSG_AI_REACTION: {
|
|
|
|
|
|
// SMSG_AI_REACTION: uint64 guid, uint32 reaction
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) break;
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
uint32_t reaction = packet.readUInt32();
|
|
|
|
|
|
// Reaction 2 commonly indicates aggro.
|
|
|
|
|
|
if (reaction == 2 && npcAggroCallback_) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
npcAggroCallback_(guid, glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_SPELLNONMELEEDAMAGELOG:
|
|
|
|
|
|
handleSpellDamageLog(packet);
|
|
|
|
|
|
break;
|
2026-02-18 23:38:34 -08:00
|
|
|
|
case Opcode::SMSG_PLAY_SPELL_VISUAL: {
|
2026-03-17 18:23:05 -07:00
|
|
|
|
// uint64 casterGuid + uint32 visualId
|
2026-02-18 23:38:34 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) break;
|
2026-03-17 18:23:05 -07:00
|
|
|
|
uint64_t casterGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t visualId = packet.readUInt32();
|
|
|
|
|
|
if (visualId == 0) break;
|
|
|
|
|
|
// Resolve caster world position and spawn the effect
|
|
|
|
|
|
auto* renderer = core::Application::getInstance().getRenderer();
|
|
|
|
|
|
if (!renderer) break;
|
|
|
|
|
|
glm::vec3 spawnPos;
|
|
|
|
|
|
if (casterGuid == playerGuid) {
|
|
|
|
|
|
spawnPos = renderer->getCharacterPosition();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto entity = entityManager.getEntity(casterGuid);
|
|
|
|
|
|
if (!entity) break;
|
|
|
|
|
|
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
|
|
|
|
|
spawnPos = core::coords::canonicalToRender(canonical);
|
|
|
|
|
|
}
|
|
|
|
|
|
renderer->playSpellVisual(visualId, spawnPos);
|
2026-02-18 23:38:34 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_SPELLHEALLOG:
|
|
|
|
|
|
handleSpellHealLog(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Phase 3: Spells ----
|
|
|
|
|
|
case Opcode::SMSG_INITIAL_SPELLS:
|
|
|
|
|
|
handleInitialSpells(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_CAST_FAILED:
|
|
|
|
|
|
handleCastFailed(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELL_START:
|
|
|
|
|
|
handleSpellStart(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELL_GO:
|
|
|
|
|
|
handleSpellGo(packet);
|
|
|
|
|
|
break;
|
2026-03-09 23:16:15 -07:00
|
|
|
|
case Opcode::SMSG_SPELL_FAILURE: {
|
2026-03-09 23:20:15 -07:00
|
|
|
|
// WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 failReason
|
2026-03-11 03:54:33 -07:00
|
|
|
|
// 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)
|
2026-03-09 23:20:15 -07:00
|
|
|
|
? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0)
|
|
|
|
|
|
: UpdateObjectParser::readPackedGuid(packet);
|
2026-03-11 03:54:33 -07:00
|
|
|
|
// 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();
|
2026-03-13 06:14:28 -07:00
|
|
|
|
uint32_t failSpellId = packet.readUInt32();
|
2026-03-11 03:54:33 -07:00
|
|
|
|
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;
|
2026-03-11 03:42:41 -07:00
|
|
|
|
if (failGuid == playerGuid && failReason != 0) {
|
2026-03-12 01:22:42 -07:00
|
|
|
|
// Show interruption/failure reason in chat and error overlay for player
|
2026-03-11 03:42:41 -07:00
|
|
|
|
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) {
|
2026-03-13 06:14:28 -07:00
|
|
|
|
// 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);
|
2026-03-11 03:42:41 -07:00
|
|
|
|
MessageChatData emsg;
|
|
|
|
|
|
emsg.type = ChatType::SYSTEM;
|
|
|
|
|
|
emsg.language = ChatLanguage::UNIVERSAL;
|
2026-03-13 06:14:28 -07:00
|
|
|
|
emsg.message = std::move(fullMsg);
|
2026-03-11 03:42:41 -07:00
|
|
|
|
addLocalChatMessage(emsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 15:37:33 -07:00
|
|
|
|
// Fire UNIT_SPELLCAST_INTERRUPTED for Lua addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (failGuid == playerGuid || failGuid == 0) unitId = "player";
|
|
|
|
|
|
else if (failGuid == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (failGuid == focusGuid) unitId = "focus";
|
2026-03-21 02:10:09 -07:00
|
|
|
|
if (!unitId.empty()) {
|
2026-03-20 15:37:33 -07:00
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId});
|
2026-03-21 02:10:09 -07:00
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId});
|
|
|
|
|
|
}
|
2026-03-20 15:37:33 -07:00
|
|
|
|
}
|
2026-03-09 23:16:15 -07:00
|
|
|
|
if (failGuid == playerGuid || failGuid == 0) {
|
2026-03-13 05:02:58 -07:00
|
|
|
|
// 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.
|
2026-03-09 23:16:15 -07:00
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-03-09 23:16:15 -07:00
|
|
|
|
currentCastSpellId = 0;
|
2026-03-13 05:02:58 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
2026-03-18 01:15:04 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
2026-03-09 23:16:15 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* ssm = renderer->getSpellSoundManager()) {
|
|
|
|
|
|
ssm->stopPrecast();
|
|
|
|
|
|
}
|
2026-03-09 21:04:24 -07:00
|
|
|
|
}
|
2026-03-10 09:42:17 -07:00
|
|
|
|
if (spellCastAnimCallback_) {
|
|
|
|
|
|
spellCastAnimCallback_(playerGuid, false, false);
|
|
|
|
|
|
}
|
2026-03-09 23:16:15 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
// Another unit's cast failed — clear their tracked cast bar
|
|
|
|
|
|
unitCastStates_.erase(failGuid);
|
2026-03-10 09:42:17 -07:00
|
|
|
|
if (spellCastAnimCallback_) {
|
|
|
|
|
|
spellCastAnimCallback_(failGuid, false, false);
|
|
|
|
|
|
}
|
2026-03-09 21:04:24 -07:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
break;
|
2026-03-09 23:16:15 -07:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_SPELL_COOLDOWN:
|
|
|
|
|
|
handleSpellCooldown(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_COOLDOWN_EVENT:
|
|
|
|
|
|
handleCooldownEvent(packet);
|
|
|
|
|
|
break;
|
2026-03-09 13:40:19 -07:00
|
|
|
|
case Opcode::SMSG_CLEAR_COOLDOWN: {
|
|
|
|
|
|
// spellId(u32) + guid(u64): clear cooldown for the given spell/guid
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
// guid is present but we only track per-spell for the local player
|
|
|
|
|
|
spellCooldowns.erase(spellId);
|
|
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
|
|
|
|
|
slot.cooldownRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_CLEAR_COOLDOWN: spellId=", spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_MODIFY_COOLDOWN: {
|
|
|
|
|
|
// spellId(u32) + diffMs(i32): adjust cooldown remaining by diffMs
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
int32_t diffMs = static_cast<int32_t>(packet.readUInt32());
|
|
|
|
|
|
float diffSec = diffMs / 1000.0f;
|
|
|
|
|
|
auto it = spellCooldowns.find(spellId);
|
|
|
|
|
|
if (it != spellCooldowns.end()) {
|
|
|
|
|
|
it->second = std::max(0.0f, it->second + diffSec);
|
|
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
|
|
|
|
|
slot.cooldownRemaining = std::max(0.0f, slot.cooldownRemaining + diffSec);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_MODIFY_COOLDOWN: spellId=", spellId, " diff=", diffMs, "ms");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 13:53:42 -07:00
|
|
|
|
case Opcode::SMSG_ACHIEVEMENT_EARNED:
|
|
|
|
|
|
handleAchievementEarned(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ALL_ACHIEVEMENT_DATA:
|
2026-03-10 20:53:21 -07:00
|
|
|
|
handleAllAchievementData(packet);
|
2026-03-09 13:53:42 -07:00
|
|
|
|
break;
|
2026-03-09 14:12:20 -07:00
|
|
|
|
case Opcode::SMSG_ITEM_COOLDOWN: {
|
|
|
|
|
|
// uint64 itemGuid + uint32 spellId + uint32 cooldownMs
|
|
|
|
|
|
size_t rem = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (rem >= 16) {
|
2026-03-12 00:59:25 -07:00
|
|
|
|
uint64_t itemGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t cdMs = packet.readUInt32();
|
2026-03-09 14:12:20 -07:00
|
|
|
|
float cdSec = cdMs / 1000.0f;
|
2026-03-12 00:59:25 -07:00
|
|
|
|
if (cdSec > 0.0f) {
|
2026-03-14 09:02:20 -07:00
|
|
|
|
if (spellId != 0) {
|
|
|
|
|
|
auto it = spellCooldowns.find(spellId);
|
|
|
|
|
|
if (it == spellCooldowns.end()) {
|
|
|
|
|
|
spellCooldowns[spellId] = cdSec;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
it->second = mergeCooldownSeconds(it->second, cdSec);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 00:59:25 -07:00
|
|
|
|
// 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;
|
2026-03-09 14:12:20 -07:00
|
|
|
|
for (auto& slot : actionBar) {
|
2026-03-12 00:59:25 -07:00
|
|
|
|
bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|
|
|
|
|
|
|| (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId);
|
|
|
|
|
|
if (match) {
|
2026-03-14 09:02:20 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-09 14:12:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 00:59:25 -07:00
|
|
|
|
LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec,
|
|
|
|
|
|
" spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s");
|
2026-03-09 14:12:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_FISH_NOT_HOOKED:
|
|
|
|
|
|
addSystemChatMessage("Your fish got away.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_FISH_ESCAPED:
|
|
|
|
|
|
addSystemChatMessage("Your fish escaped!");
|
|
|
|
|
|
break;
|
2026-03-09 20:36:20 -07:00
|
|
|
|
case Opcode::MSG_MINIMAP_PING: {
|
2026-03-09 23:53:43 -07:00
|
|
|
|
// WotLK: packed_guid + float posX + float posY
|
|
|
|
|
|
// TBC/Classic: uint64 + float posX + float posY
|
|
|
|
|
|
const bool mmTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (mmTbcLike ? 8u : 1u)) break;
|
|
|
|
|
|
uint64_t senderGuid = mmTbcLike
|
|
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 20:36:20 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
float pingX = packet.readFloat(); // server sends map-coord X (east-west)
|
|
|
|
|
|
float pingY = packet.readFloat(); // server sends map-coord Y (north-south)
|
|
|
|
|
|
MinimapPing ping;
|
|
|
|
|
|
ping.senderGuid = senderGuid;
|
|
|
|
|
|
ping.wowX = pingY; // canonical WoW X = north = server's posY
|
|
|
|
|
|
ping.wowY = pingX; // canonical WoW Y = west = server's posX
|
|
|
|
|
|
ping.age = 0.0f;
|
|
|
|
|
|
minimapPings_.push_back(ping);
|
2026-03-20 18:21:34 -07:00
|
|
|
|
// Play ping sound for other players' pings (not our own)
|
|
|
|
|
|
if (senderGuid != playerGuid) {
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playMinimapPing();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 14:12:20 -07:00
|
|
|
|
break;
|
2026-03-09 20:36:20 -07:00
|
|
|
|
}
|
2026-03-09 14:12:20 -07:00
|
|
|
|
case Opcode::SMSG_ZONE_UNDER_ATTACK: {
|
|
|
|
|
|
// uint32 areaId
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t areaId = packet.readUInt32();
|
2026-03-11 00:36:40 -07:00
|
|
|
|
std::string areaName = getAreaName(areaId);
|
|
|
|
|
|
std::string msg = areaName.empty()
|
|
|
|
|
|
? std::string("A zone is under attack!")
|
|
|
|
|
|
: (areaName + " is under attack!");
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError(msg);
|
2026-03-11 00:36:40 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-09 14:12:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-17 15:57:51 -08:00
|
|
|
|
case Opcode::SMSG_CANCEL_AUTO_REPEAT:
|
|
|
|
|
|
break; // Server signals to stop a repeating spell (wand/shoot); no client action needed
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_AURA_UPDATE:
|
|
|
|
|
|
handleAuraUpdate(packet, false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AURA_UPDATE_ALL:
|
|
|
|
|
|
handleAuraUpdate(packet, true);
|
|
|
|
|
|
break;
|
2026-03-09 14:18:36 -07:00
|
|
|
|
case Opcode::SMSG_DISPEL_FAILED: {
|
2026-03-11 02:49:37 -07:00
|
|
|
|
// WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim
|
|
|
|
|
|
// [+ count × uint32 failedSpellId]
|
2026-03-14 01:00:56 -07:00
|
|
|
|
// Classic: uint32 dispelSpellId + packed_guid caster + packed_guid victim
|
2026-03-11 02:49:37 -07:00
|
|
|
|
// [+ count × uint32 failedSpellId]
|
2026-03-14 01:00:56 -07:00
|
|
|
|
// TBC: uint64 caster + uint64 victim + uint32 spellId
|
|
|
|
|
|
// [+ count × uint32 failedSpellId]
|
|
|
|
|
|
const bool dispelUsesFullGuid = isActiveExpansion("tbc");
|
2026-03-11 02:49:37 -07:00
|
|
|
|
uint32_t dispelSpellId = 0;
|
2026-03-13 06:21:33 -07:00
|
|
|
|
uint64_t dispelCasterGuid = 0;
|
2026-03-14 01:00:56 -07:00
|
|
|
|
if (dispelUsesFullGuid) {
|
2026-03-11 02:49:37 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 20) break;
|
2026-03-13 06:21:33 -07:00
|
|
|
|
dispelCasterGuid = packet.readUInt64();
|
2026-03-11 02:49:37 -07:00
|
|
|
|
/*uint64_t victim =*/ packet.readUInt64();
|
|
|
|
|
|
dispelSpellId = packet.readUInt32();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
dispelSpellId = packet.readUInt32();
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (!hasFullPackedGuid(packet)) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-13 06:21:33 -07:00
|
|
|
|
dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (!hasFullPackedGuid(packet)) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-11 02:49:37 -07:00
|
|
|
|
/*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
}
|
2026-03-13 06:21:33 -07:00
|
|
|
|
// Only show failure to the player who attempted the dispel
|
|
|
|
|
|
if (dispelCasterGuid == playerGuid) {
|
2026-03-11 02:49:37 -07:00
|
|
|
|
loadSpellNameCache();
|
|
|
|
|
|
auto it = spellNameCache_.find(dispelSpellId);
|
2026-03-09 14:18:36 -07:00
|
|
|
|
char buf[128];
|
2026-03-11 02:49:37 -07:00
|
|
|
|
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);
|
2026-03-09 14:18:36 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_TOTEM_CREATED: {
|
2026-03-11 02:50:53 -07:00
|
|
|
|
// WotLK: uint8 slot + packed_guid + uint32 duration + uint32 spellId
|
|
|
|
|
|
// TBC/Classic: uint8 slot + uint64 guid + uint32 duration + uint32 spellId
|
|
|
|
|
|
const bool totemTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (totemTbcLike ? 17u : 9u)) break;
|
|
|
|
|
|
uint8_t slot = packet.readUInt8();
|
|
|
|
|
|
if (totemTbcLike)
|
2026-03-09 14:18:36 -07:00
|
|
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
2026-03-11 02:50:53 -07:00
|
|
|
|
else
|
|
|
|
|
|
/*uint64_t guid =*/ UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint32_t duration = packet.readUInt32();
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot,
|
|
|
|
|
|
" spellId=", spellId, " duration=", duration, "ms");
|
2026-03-12 05:16:43 -07:00
|
|
|
|
if (slot < NUM_TOTEM_SLOTS) {
|
|
|
|
|
|
activeTotemSlots_[slot].spellId = spellId;
|
|
|
|
|
|
activeTotemSlots_[slot].durationMs = duration;
|
|
|
|
|
|
activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now();
|
|
|
|
|
|
}
|
2026-03-09 14:18:36 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: {
|
|
|
|
|
|
// uint64 guid + uint32 timeLeftMs
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t timeMs = packet.readUInt32();
|
|
|
|
|
|
uint32_t secs = timeMs / 1000;
|
|
|
|
|
|
char buf[128];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"You will be able to resurrect in %u seconds.", secs);
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_DURABILITY_DAMAGE_DEATH: {
|
|
|
|
|
|
// uint32 percent (how much durability was lost due to death)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
2026-03-13 06:30:30 -07:00
|
|
|
|
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);
|
2026-03-17 17:48:04 -07:00
|
|
|
|
addUIError(buf);
|
2026-03-13 06:30:30 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
2026-03-09 14:18:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_LEARNED_SPELL:
|
|
|
|
|
|
handleLearnedSpell(packet);
|
|
|
|
|
|
break;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_SUPERCEDED_SPELL:
|
|
|
|
|
|
handleSupercededSpell(packet);
|
|
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_REMOVED_SPELL:
|
|
|
|
|
|
handleRemovedSpell(packet);
|
|
|
|
|
|
break;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_SEND_UNLEARN_SPELLS:
|
|
|
|
|
|
handleUnlearnSpells(packet);
|
|
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
// ---- Talents ----
|
|
|
|
|
|
case Opcode::SMSG_TALENTS_INFO:
|
|
|
|
|
|
handleTalentsInfo(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 4: Group ----
|
|
|
|
|
|
case Opcode::SMSG_GROUP_INVITE:
|
|
|
|
|
|
handleGroupInvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_DECLINE:
|
|
|
|
|
|
handleGroupDecline(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_LIST:
|
|
|
|
|
|
handleGroupList(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:59:32 -07:00
|
|
|
|
case Opcode::SMSG_GROUP_DESTROYED:
|
|
|
|
|
|
// The group was disbanded; clear all party state.
|
|
|
|
|
|
partyData.members.clear();
|
|
|
|
|
|
partyData.memberCount = 0;
|
|
|
|
|
|
partyData.leaderGuid = 0;
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError("Your party has been disbanded.");
|
2026-03-09 14:59:32 -07:00
|
|
|
|
addSystemChatMessage("Your party has been disbanded.");
|
|
|
|
|
|
LOG_INFO("SMSG_GROUP_DESTROYED: party cleared");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_CANCEL:
|
|
|
|
|
|
// Group invite was cancelled before being accepted.
|
|
|
|
|
|
addSystemChatMessage("Group invite cancelled.");
|
|
|
|
|
|
LOG_DEBUG("SMSG_GROUP_CANCEL");
|
|
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_GROUP_UNINVITE:
|
|
|
|
|
|
handleGroupUninvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_PARTY_COMMAND_RESULT:
|
|
|
|
|
|
handlePartyCommandResult(packet);
|
|
|
|
|
|
break;
|
2026-02-26 10:25:55 -08:00
|
|
|
|
case Opcode::SMSG_PARTY_MEMBER_STATS:
|
|
|
|
|
|
handlePartyMemberStats(packet, false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_PARTY_MEMBER_STATS_FULL:
|
|
|
|
|
|
handlePartyMemberStats(packet, true);
|
|
|
|
|
|
break;
|
2026-03-09 14:48:30 -07:00
|
|
|
|
case Opcode::MSG_RAID_READY_CHECK: {
|
|
|
|
|
|
// Server is broadcasting a ready check (someone in the raid initiated it).
|
|
|
|
|
|
// Payload: empty body, or optional uint64 initiator GUID in some builds.
|
2026-03-09 20:38:44 -07:00
|
|
|
|
pendingReadyCheck_ = true;
|
|
|
|
|
|
readyCheckReadyCount_ = 0;
|
|
|
|
|
|
readyCheckNotReadyCount_ = 0;
|
2026-03-09 14:48:30 -07:00
|
|
|
|
readyCheckInitiator_.clear();
|
2026-03-12 09:07:37 -07:00
|
|
|
|
readyCheckResults_.clear();
|
2026-03-09 14:48:30 -07:00
|
|
|
|
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) {
|
|
|
|
|
|
// Identify initiator from party leader
|
|
|
|
|
|
for (const auto& member : partyData.members) {
|
|
|
|
|
|
if (member.guid == partyData.leaderGuid) { readyCheckInitiator_ = member.name; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(readyCheckInitiator_.empty()
|
|
|
|
|
|
? "Ready check initiated!"
|
|
|
|
|
|
: readyCheckInitiator_ + " initiated a ready check!");
|
|
|
|
|
|
LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_);
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-09 14:48:30 -07:00
|
|
|
|
}
|
2026-03-09 20:38:44 -07:00
|
|
|
|
case Opcode::MSG_RAID_READY_CHECK_CONFIRM: {
|
|
|
|
|
|
// guid (8) + uint8 isReady (0=not ready, 1=ready)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
uint64_t respGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t isReady = packet.readUInt8();
|
|
|
|
|
|
if (isReady) ++readyCheckReadyCount_;
|
|
|
|
|
|
else ++readyCheckNotReadyCount_;
|
|
|
|
|
|
auto nit = playerNameCache.find(respGuid);
|
|
|
|
|
|
std::string rname;
|
|
|
|
|
|
if (nit != playerNameCache.end()) rname = nit->second;
|
|
|
|
|
|
else {
|
|
|
|
|
|
auto ent = entityManager.getEntity(respGuid);
|
|
|
|
|
|
if (ent) rname = std::static_pointer_cast<game::Unit>(ent)->getName();
|
|
|
|
|
|
}
|
2026-03-12 09:07:37 -07:00
|
|
|
|
// Track per-player result for live popup display
|
2026-03-09 20:38:44 -07:00
|
|
|
|
if (!rname.empty()) {
|
2026-03-12 09:07:37 -07:00
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-03-09 20:38:44 -07:00
|
|
|
|
char rbuf[128];
|
|
|
|
|
|
std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready");
|
|
|
|
|
|
addSystemChatMessage(rbuf);
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-09 20:38:44 -07:00
|
|
|
|
}
|
|
|
|
|
|
case Opcode::MSG_RAID_READY_CHECK_FINISHED: {
|
|
|
|
|
|
// Ready check complete — summarize results
|
|
|
|
|
|
char fbuf[128];
|
|
|
|
|
|
std::snprintf(fbuf, sizeof(fbuf), "Ready check complete: %u ready, %u not ready.",
|
|
|
|
|
|
readyCheckReadyCount_, readyCheckNotReadyCount_);
|
|
|
|
|
|
addSystemChatMessage(fbuf);
|
|
|
|
|
|
pendingReadyCheck_ = false;
|
|
|
|
|
|
readyCheckReadyCount_ = 0;
|
|
|
|
|
|
readyCheckNotReadyCount_ = 0;
|
2026-03-12 09:07:37 -07:00
|
|
|
|
readyCheckResults_.clear();
|
2026-03-09 20:38:44 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::SMSG_RAID_INSTANCE_INFO:
|
2026-03-09 13:36:23 -07:00
|
|
|
|
handleRaidInstanceInfo(packet);
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_DUEL_REQUESTED:
|
2026-03-09 13:58:02 -07:00
|
|
|
|
handleDuelRequested(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_DUEL_COMPLETE:
|
|
|
|
|
|
handleDuelComplete(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_DUEL_WINNER:
|
|
|
|
|
|
handleDuelWinner(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_DUEL_OUTOFBOUNDS:
|
2026-03-12 01:15:11 -07:00
|
|
|
|
addUIError("You are out of the duel area!");
|
2026-03-09 13:58:02 -07:00
|
|
|
|
addSystemChatMessage("You are out of the duel area!");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_DUEL_INBOUNDS:
|
|
|
|
|
|
// Re-entered the duel area; no special action needed.
|
|
|
|
|
|
break;
|
2026-03-12 05:06:14 -07:00
|
|
|
|
case Opcode::SMSG_DUEL_COUNTDOWN: {
|
|
|
|
|
|
// uint32 countdown in milliseconds (typically 3000 ms)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t ms = packet.readUInt32();
|
|
|
|
|
|
duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000;
|
|
|
|
|
|
duelCountdownStartedAt_ = std::chrono::steady_clock::now();
|
|
|
|
|
|
LOG_INFO("SMSG_DUEL_COUNTDOWN: ", duelCountdownMs_, " ms");
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-12 05:06:14 -07:00
|
|
|
|
}
|
2026-03-09 20:27:02 -07:00
|
|
|
|
case Opcode::SMSG_PARTYKILLLOG: {
|
|
|
|
|
|
// uint64 killerGuid + uint64 victimGuid
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 16) break;
|
|
|
|
|
|
uint64_t killerGuid = packet.readUInt64();
|
|
|
|
|
|
uint64_t victimGuid = packet.readUInt64();
|
|
|
|
|
|
// Show kill message in party chat style
|
|
|
|
|
|
auto nameForGuid = [&](uint64_t g) -> std::string {
|
|
|
|
|
|
// Check player name cache first
|
|
|
|
|
|
auto nit = playerNameCache.find(g);
|
|
|
|
|
|
if (nit != playerNameCache.end()) return nit->second;
|
|
|
|
|
|
// Fall back to entity name (NPCs)
|
|
|
|
|
|
auto ent = entityManager.getEntity(g);
|
|
|
|
|
|
if (ent && (ent->getType() == game::ObjectType::UNIT ||
|
|
|
|
|
|
ent->getType() == game::ObjectType::PLAYER)) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<game::Unit>(ent);
|
|
|
|
|
|
return unit->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
return {};
|
|
|
|
|
|
};
|
|
|
|
|
|
std::string killerName = nameForGuid(killerGuid);
|
|
|
|
|
|
std::string victimName = nameForGuid(victimGuid);
|
|
|
|
|
|
if (!killerName.empty() && !victimName.empty()) {
|
|
|
|
|
|
char buf[256];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s killed %s.",
|
|
|
|
|
|
killerName.c_str(), victimName.c_str());
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
2026-02-20 02:19:17 -08:00
|
|
|
|
break;
|
2026-03-09 20:27:02 -07:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
// ---- Guild ----
|
|
|
|
|
|
case Opcode::SMSG_GUILD_INFO:
|
|
|
|
|
|
handleGuildInfo(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_ROSTER:
|
|
|
|
|
|
handleGuildRoster(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_QUERY_RESPONSE:
|
|
|
|
|
|
handleGuildQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_EVENT:
|
|
|
|
|
|
handleGuildEvent(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_INVITE:
|
|
|
|
|
|
handleGuildInvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_COMMAND_RESULT:
|
|
|
|
|
|
handleGuildCommandResult(packet);
|
|
|
|
|
|
break;
|
2026-02-26 10:41:29 -08:00
|
|
|
|
case Opcode::SMSG_PET_SPELLS:
|
|
|
|
|
|
handlePetSpells(packet);
|
|
|
|
|
|
break;
|
2026-02-25 14:44:44 -08:00
|
|
|
|
case Opcode::SMSG_PETITION_SHOWLIST:
|
|
|
|
|
|
handlePetitionShowlist(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_TURN_IN_PETITION_RESULTS:
|
|
|
|
|
|
handleTurnInPetitionResults(packet);
|
|
|
|
|
|
break;
|
2026-02-13 21:39:48 -08:00
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 5: Loot/Gossip/Vendor ----
|
|
|
|
|
|
case Opcode::SMSG_LOOT_RESPONSE:
|
|
|
|
|
|
handleLootResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LOOT_RELEASE_RESPONSE:
|
|
|
|
|
|
handleLootReleaseResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LOOT_REMOVED:
|
|
|
|
|
|
handleLootRemoved(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:14:15 -07:00
|
|
|
|
case Opcode::SMSG_QUEST_CONFIRM_ACCEPT:
|
|
|
|
|
|
handleQuestConfirmAccept(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:15:59 -07:00
|
|
|
|
case Opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE:
|
|
|
|
|
|
handleItemTextQueryResponse(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:07:50 -07:00
|
|
|
|
case Opcode::SMSG_SUMMON_REQUEST:
|
|
|
|
|
|
handleSummonRequest(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SUMMON_CANCEL:
|
|
|
|
|
|
pendingSummonRequest_ = false;
|
|
|
|
|
|
addSystemChatMessage("Summon cancelled.");
|
|
|
|
|
|
break;
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
case Opcode::SMSG_TRADE_STATUS:
|
|
|
|
|
|
handleTradeStatus(packet);
|
|
|
|
|
|
break;
|
2026-03-11 00:44:07 -07:00
|
|
|
|
case Opcode::SMSG_TRADE_STATUS_EXTENDED:
|
|
|
|
|
|
handleTradeStatusExtended(packet);
|
|
|
|
|
|
break;
|
2026-03-09 14:01:27 -07:00
|
|
|
|
case Opcode::SMSG_LOOT_ROLL:
|
|
|
|
|
|
handleLootRoll(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LOOT_ROLL_WON:
|
|
|
|
|
|
handleLootRollWon(packet);
|
|
|
|
|
|
break;
|
2026-03-12 17:58:24 -07:00
|
|
|
|
case Opcode::SMSG_LOOT_MASTER_LIST: {
|
|
|
|
|
|
// uint8 count + count * uint64 guid — eligible recipients for master looter
|
|
|
|
|
|
masterLootCandidates_.clear();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint8_t mlCount = packet.readUInt8();
|
|
|
|
|
|
masterLootCandidates_.reserve(mlCount);
|
|
|
|
|
|
for (uint8_t i = 0; i < mlCount; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
masterLootCandidates_.push_back(packet.readUInt64());
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_LOOT_MASTER_LIST: ", (int)masterLootCandidates_.size(), " candidates");
|
2026-03-09 14:01:27 -07:00
|
|
|
|
break;
|
2026-03-12 17:58:24 -07:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_GOSSIP_MESSAGE:
|
|
|
|
|
|
handleGossipMessage(packet);
|
|
|
|
|
|
break;
|
2026-02-19 02:53:44 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_LIST:
|
|
|
|
|
|
handleQuestgiverQuestList(packet);
|
|
|
|
|
|
break;
|
2026-02-08 03:32:00 -08:00
|
|
|
|
case Opcode::SMSG_BINDPOINTUPDATE: {
|
|
|
|
|
|
BindPointUpdateData data;
|
|
|
|
|
|
if (BindPointUpdateParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_INFO("Bindpoint updated: mapId=", data.mapId,
|
|
|
|
|
|
" pos=(", data.x, ", ", data.y, ", ", data.z, ")");
|
2026-02-08 03:39:02 -08:00
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(data.x, data.y, data.z));
|
2026-02-16 21:16:25 -08:00
|
|
|
|
// Only show message if bind point was already set (not initial login sync)
|
|
|
|
|
|
bool wasSet = hasHomeBind_;
|
2026-02-08 03:39:02 -08:00
|
|
|
|
hasHomeBind_ = true;
|
|
|
|
|
|
homeBindMapId_ = data.mapId;
|
2026-03-13 10:18:31 -07:00
|
|
|
|
homeBindZoneId_ = data.zoneId;
|
2026-02-08 03:39:02 -08:00
|
|
|
|
homeBindPos_ = canonical;
|
2026-02-08 03:32:00 -08:00
|
|
|
|
if (bindPointCallback_) {
|
|
|
|
|
|
bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z);
|
|
|
|
|
|
}
|
2026-02-16 21:16:25 -08:00
|
|
|
|
if (wasSet) {
|
2026-03-13 06:46:56 -07:00
|
|
|
|
std::string bindMsg = "Your home has been set";
|
|
|
|
|
|
std::string zoneName = getAreaName(data.zoneId);
|
|
|
|
|
|
if (!zoneName.empty())
|
|
|
|
|
|
bindMsg += " to " + zoneName;
|
|
|
|
|
|
bindMsg += '.';
|
|
|
|
|
|
addSystemChatMessage(bindMsg);
|
2026-02-16 21:16:25 -08:00
|
|
|
|
}
|
2026-02-08 03:32:00 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_BINDPOINTUPDATE");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_GOSSIP_COMPLETE:
|
|
|
|
|
|
handleGossipComplete(packet);
|
|
|
|
|
|
break;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
case Opcode::SMSG_SPIRIT_HEALER_CONFIRM: {
|
2026-02-07 21:12:54 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) {
|
2026-02-07 23:12:24 -08:00
|
|
|
|
LOG_WARNING("SMSG_SPIRIT_HEALER_CONFIRM too short");
|
2026-02-07 21:12:54 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
|
|
|
|
LOG_INFO("Spirit healer confirm from 0x", std::hex, npcGuid, std::dec);
|
|
|
|
|
|
if (npcGuid) {
|
|
|
|
|
|
resurrectCasterGuid_ = npcGuid;
|
2026-03-09 22:27:24 -07:00
|
|
|
|
resurrectCasterName_ = "";
|
|
|
|
|
|
resurrectIsSpiritHealer_ = true;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
resurrectRequestPending_ = true;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
case Opcode::SMSG_RESURRECT_REQUEST: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) {
|
|
|
|
|
|
LOG_WARNING("SMSG_RESURRECT_REQUEST too short");
|
2026-02-07 21:47:14 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
uint64_t casterGuid = packet.readUInt64();
|
2026-03-09 22:27:24 -07:00
|
|
|
|
// Optional caster name (CString, may be absent on some server builds)
|
|
|
|
|
|
std::string casterName;
|
|
|
|
|
|
if (packet.getReadPos() < packet.getSize()) {
|
|
|
|
|
|
casterName = packet.readString();
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec,
|
|
|
|
|
|
" name='", casterName, "'");
|
2026-02-07 23:12:24 -08:00
|
|
|
|
if (casterGuid) {
|
|
|
|
|
|
resurrectCasterGuid_ = casterGuid;
|
2026-03-09 22:27:24 -07:00
|
|
|
|
resurrectIsSpiritHealer_ = false;
|
|
|
|
|
|
if (!casterName.empty()) {
|
|
|
|
|
|
resurrectCasterName_ = casterName;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto nit = playerNameCache.find(casterGuid);
|
|
|
|
|
|
resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : "";
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
resurrectRequestPending_ = true;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::SMSG_TIME_SYNC_REQ: {
|
2026-02-07 21:47:14 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
LOG_WARNING("SMSG_TIME_SYNC_REQ too short");
|
2026-02-07 21:47:14 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
uint32_t counter = packet.readUInt32();
|
|
|
|
|
|
LOG_DEBUG("Time sync request counter: ", counter);
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
network::Packet resp(wireOpcode(Opcode::CMSG_TIME_SYNC_RESP));
|
|
|
|
|
|
resp.writeUInt32(counter);
|
|
|
|
|
|
resp.writeUInt32(nextMovementTimestampMs());
|
|
|
|
|
|
socket->send(resp);
|
|
|
|
|
|
}
|
2026-02-07 21:12:54 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_LIST_INVENTORY:
|
|
|
|
|
|
handleListInventory(packet);
|
|
|
|
|
|
break;
|
2026-02-08 14:33:39 -08:00
|
|
|
|
case Opcode::SMSG_TRAINER_LIST:
|
|
|
|
|
|
handleTrainerList(packet);
|
|
|
|
|
|
break;
|
2026-02-08 14:46:01 -08:00
|
|
|
|
case Opcode::SMSG_TRAINER_BUY_SUCCEEDED: {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
(void)guid;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Add to known spells immediately for prerequisite re-evaluation
|
|
|
|
|
|
// (SMSG_LEARNED_SPELL may come separately, but we need immediate update)
|
2026-02-17 15:13:54 -08:00
|
|
|
|
if (!knownSpells.count(spellId)) {
|
|
|
|
|
|
knownSpells.insert(spellId);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
LOG_INFO("Added spell ", spellId, " to known spells (trainer purchase)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
const std::string& name = getSpellName(spellId);
|
|
|
|
|
|
if (!name.empty())
|
|
|
|
|
|
addSystemChatMessage("You have learned " + name + ".");
|
|
|
|
|
|
else
|
|
|
|
|
|
addSystemChatMessage("Spell learned.");
|
2026-03-17 12:31:38 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playQuestActivate();
|
|
|
|
|
|
}
|
2026-02-08 14:46:01 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_TRAINER_BUY_FAILED: {
|
|
|
|
|
|
// Server rejected the spell purchase
|
|
|
|
|
|
// Packet format: uint64 trainerGuid, uint32 spellId, uint32 errorCode
|
|
|
|
|
|
uint64_t trainerGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t errorCode = 0;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
errorCode = packet.readUInt32();
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_WARNING("Trainer buy spell failed: guid=", trainerGuid,
|
|
|
|
|
|
" spellId=", spellId, " error=", errorCode);
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& spellName = getSpellName(spellId);
|
|
|
|
|
|
std::string msg = "Cannot learn ";
|
|
|
|
|
|
if (!spellName.empty()) msg += spellName;
|
|
|
|
|
|
else msg += "spell #" + std::to_string(spellId);
|
|
|
|
|
|
|
|
|
|
|
|
// Common error reasons
|
|
|
|
|
|
if (errorCode == 0) msg += " (not enough money)";
|
|
|
|
|
|
else if (errorCode == 1) msg += " (not enough skill)";
|
|
|
|
|
|
else if (errorCode == 2) msg += " (already known)";
|
|
|
|
|
|
else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")";
|
|
|
|
|
|
|
2026-03-17 17:36:25 -07:00
|
|
|
|
addUIError(msg);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-17 12:28:15 -07:00
|
|
|
|
// Play error sound so the player notices the failure
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playError();
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
// Silently ignore common packets we don't handle yet
|
2026-02-18 23:26:58 -08:00
|
|
|
|
case Opcode::SMSG_INIT_WORLD_STATES: {
|
2026-03-11 01:40:33 -07:00
|
|
|
|
// 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)
|
2026-02-18 23:26:58 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 10) {
|
|
|
|
|
|
LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-18 23:30:38 -08:00
|
|
|
|
worldStateMapId_ = packet.readUInt32();
|
2026-03-20 15:05:29 -07:00
|
|
|
|
{
|
|
|
|
|
|
uint32_t newZoneId = packet.readUInt32();
|
|
|
|
|
|
if (newZoneId != worldStateZoneId_ && newZoneId != 0) {
|
|
|
|
|
|
worldStateZoneId_ = newZoneId;
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("ZONE_CHANGED_NEW_AREA", {});
|
|
|
|
|
|
addonEventCallback_("ZONE_CHANGED", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
worldStateZoneId_ = newZoneId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 01:30:20 -07:00
|
|
|
|
// WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format
|
2026-03-11 01:40:33 -07:00
|
|
|
|
size_t remaining = packet.getSize() - packet.getReadPos();
|
2026-03-18 01:30:20 -07:00
|
|
|
|
bool isWotLKFormat = isActiveExpansion("wotlk");
|
2026-03-11 01:40:33 -07:00
|
|
|
|
if (isWotLKFormat && remaining >= 6) {
|
|
|
|
|
|
packet.readUInt32(); // areaId (WotLK only)
|
|
|
|
|
|
}
|
2026-02-18 23:26:58 -08:00
|
|
|
|
uint16_t count = packet.readUInt16();
|
|
|
|
|
|
size_t needed = static_cast<size_t>(count) * 8;
|
2026-02-22 07:44:32 -08:00
|
|
|
|
size_t available = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (available < needed) {
|
|
|
|
|
|
// Be tolerant across expansion/private-core variants: if packet shape
|
|
|
|
|
|
// still looks like N*(key,val) dwords, parse what is present.
|
|
|
|
|
|
if ((available % 8) == 0) {
|
|
|
|
|
|
uint16_t adjustedCount = static_cast<uint16_t>(available / 8);
|
|
|
|
|
|
LOG_WARNING("SMSG_INIT_WORLD_STATES count mismatch: header=", count,
|
|
|
|
|
|
" adjusted=", adjustedCount, " (available=", available, ")");
|
|
|
|
|
|
count = adjustedCount;
|
|
|
|
|
|
needed = available;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed,
|
|
|
|
|
|
" bytes of state pairs, got ", available);
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-18 23:26:58 -08:00
|
|
|
|
}
|
2026-02-18 23:30:38 -08:00
|
|
|
|
worldStates_.clear();
|
|
|
|
|
|
worldStates_.reserve(count);
|
2026-02-18 23:26:58 -08:00
|
|
|
|
for (uint16_t i = 0; i < count; ++i) {
|
2026-02-18 23:30:38 -08:00
|
|
|
|
uint32_t key = packet.readUInt32();
|
|
|
|
|
|
uint32_t val = packet.readUInt32();
|
|
|
|
|
|
worldStates_[key] = val;
|
2026-02-18 23:26:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_INITIALIZE_FACTIONS: {
|
|
|
|
|
|
// Minimal parse: uint32 count, repeated (uint8 flags, int32 standing)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
|
|
|
|
|
LOG_WARNING("SMSG_INITIALIZE_FACTIONS too short: ", packet.getSize(), " bytes");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
size_t needed = static_cast<size_t>(count) * 5;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < needed) {
|
|
|
|
|
|
LOG_WARNING("SMSG_INITIALIZE_FACTIONS truncated: expected ", needed,
|
|
|
|
|
|
" bytes of faction data, got ", packet.getSize() - packet.getReadPos());
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-18 23:30:38 -08:00
|
|
|
|
initialFactions_.clear();
|
|
|
|
|
|
initialFactions_.reserve(count);
|
2026-02-18 23:26:58 -08:00
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
2026-02-18 23:30:38 -08:00
|
|
|
|
FactionStandingInit fs{};
|
|
|
|
|
|
fs.flags = packet.readUInt8();
|
|
|
|
|
|
fs.standing = static_cast<int32_t>(packet.readUInt32());
|
|
|
|
|
|
initialFactions_.push_back(fs);
|
2026-02-18 23:26:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 14:48:30 -07:00
|
|
|
|
case Opcode::SMSG_SET_FACTION_STANDING: {
|
|
|
|
|
|
// uint8 showVisualEffect + uint32 count + count × (uint32 factionId + int32 standing)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
/*uint8_t showVisual =*/ packet.readUInt8();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
count = std::min(count, 128u);
|
|
|
|
|
|
loadFactionNameCache();
|
|
|
|
|
|
for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) {
|
|
|
|
|
|
uint32_t factionId = packet.readUInt32();
|
|
|
|
|
|
int32_t standing = static_cast<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);
|
2026-03-12 05:03:03 -07:00
|
|
|
|
watchedFactionId_ = factionId;
|
2026-03-12 01:51:18 -07:00
|
|
|
|
if (repChangeCallback_) repChangeCallback_(name, delta, standing);
|
2026-03-20 17:28:28 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("UPDATE_FACTION", {});
|
2026-03-09 14:48:30 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-12 23:30:44 -07:00
|
|
|
|
case Opcode::SMSG_SET_FACTION_ATWAR: {
|
|
|
|
|
|
// uint32 repListId + uint8 set (1=set at-war, 0=clear at-war)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t repListId = packet.readUInt32();
|
|
|
|
|
|
uint8_t setAtWar = packet.readUInt8();
|
|
|
|
|
|
if (repListId < initialFactions_.size()) {
|
|
|
|
|
|
if (setAtWar)
|
|
|
|
|
|
initialFactions_[repListId].flags |= FACTION_FLAG_AT_WAR;
|
|
|
|
|
|
else
|
|
|
|
|
|
initialFactions_[repListId].flags &= ~FACTION_FLAG_AT_WAR;
|
|
|
|
|
|
LOG_DEBUG("SMSG_SET_FACTION_ATWAR: repListId=", repListId,
|
|
|
|
|
|
" atWar=", (int)setAtWar);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_SET_FACTION_VISIBLE: {
|
|
|
|
|
|
// uint32 repListId + uint8 visible (1=show, 0=hide)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t repListId = packet.readUInt32();
|
|
|
|
|
|
uint8_t visible = packet.readUInt8();
|
|
|
|
|
|
if (repListId < initialFactions_.size()) {
|
|
|
|
|
|
if (visible)
|
|
|
|
|
|
initialFactions_[repListId].flags |= FACTION_FLAG_VISIBLE;
|
|
|
|
|
|
else
|
|
|
|
|
|
initialFactions_[repListId].flags &= ~FACTION_FLAG_VISIBLE;
|
|
|
|
|
|
LOG_DEBUG("SMSG_SET_FACTION_VISIBLE: repListId=", repListId,
|
|
|
|
|
|
" visible=", (int)visible);
|
|
|
|
|
|
}
|
2026-03-09 14:48:30 -07:00
|
|
|
|
break;
|
2026-03-12 23:30:44 -07:00
|
|
|
|
}
|
2026-03-09 14:48:30 -07:00
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_FEATURE_SYSTEM_STATUS:
|
2026-03-12 23:59:38 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER:
|
2026-03-12 23:59:38 -07:00
|
|
|
|
case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: {
|
|
|
|
|
|
// WotLK format: one or more (uint8 groupIndex, uint8 modOp, int32 value) tuples
|
|
|
|
|
|
// Each tuple is 6 bytes; iterate until packet is consumed.
|
|
|
|
|
|
const bool isFlat = (*logicalOp == Opcode::SMSG_SET_FLAT_SPELL_MODIFIER);
|
|
|
|
|
|
auto& modMap = isFlat ? spellFlatMods_ : spellPctMods_;
|
|
|
|
|
|
while (packet.getSize() - packet.getReadPos() >= 6) {
|
|
|
|
|
|
uint8_t groupIndex = packet.readUInt8();
|
|
|
|
|
|
uint8_t modOpRaw = packet.readUInt8();
|
|
|
|
|
|
int32_t value = static_cast<int32_t>(packet.readUInt32());
|
|
|
|
|
|
if (groupIndex > 5 || modOpRaw >= SPELL_MOD_OP_COUNT) continue;
|
|
|
|
|
|
SpellModKey key{ static_cast<SpellModOp>(modOpRaw), groupIndex };
|
|
|
|
|
|
modMap[key] = value;
|
|
|
|
|
|
LOG_DEBUG(isFlat ? "SMSG_SET_FLAT_SPELL_MODIFIER" : "SMSG_SET_PCT_SPELL_MODIFIER",
|
|
|
|
|
|
": group=", (int)groupIndex, " op=", (int)modOpRaw, " value=", value);
|
|
|
|
|
|
}
|
2026-03-09 23:48:06 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-12 23:59:38 -07:00
|
|
|
|
}
|
2026-03-09 23:48:06 -07:00
|
|
|
|
|
2026-03-09 19:46:52 -07:00
|
|
|
|
case Opcode::SMSG_SPELL_DELAYED: {
|
2026-03-09 23:48:06 -07:00
|
|
|
|
// WotLK: packed_guid (caster) + uint32 delayMs
|
|
|
|
|
|
// TBC/Classic: uint64 (caster) + uint32 delayMs
|
|
|
|
|
|
const bool spellDelayTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (spellDelayTbcLike ? 8u : 1u)) break;
|
|
|
|
|
|
uint64_t caster = spellDelayTbcLike
|
|
|
|
|
|
? packet.readUInt64()
|
|
|
|
|
|
: UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 23:39:00 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t delayMs = packet.readUInt32();
|
|
|
|
|
|
if (delayMs == 0) break;
|
|
|
|
|
|
float delaySec = delayMs / 1000.0f;
|
|
|
|
|
|
if (caster == playerGuid) {
|
2026-03-18 00:59:15 -07:00
|
|
|
|
if (casting) {
|
|
|
|
|
|
castTimeRemaining += delaySec;
|
|
|
|
|
|
castTimeTotal += delaySec; // keep progress percentage correct
|
|
|
|
|
|
}
|
2026-03-09 23:39:00 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
auto it = unitCastStates_.find(caster);
|
|
|
|
|
|
if (it != unitCastStates_.end() && it->second.casting) {
|
|
|
|
|
|
it->second.timeRemaining += delaySec;
|
|
|
|
|
|
it->second.timeTotal += delaySec;
|
2026-03-09 19:46:52 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-13 07:31:44 -07:00
|
|
|
|
case Opcode::SMSG_EQUIPMENT_SET_SAVED: {
|
2026-03-09 19:46:52 -07:00
|
|
|
|
// uint32 setIndex + uint64 guid — equipment set was successfully saved
|
2026-03-13 07:31:44 -07:00
|
|
|
|
std::string setName;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
uint32_t setIndex = packet.readUInt32();
|
|
|
|
|
|
uint64_t setGuid = packet.readUInt64();
|
2026-03-20 05:17:27 -07:00
|
|
|
|
// 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;
|
2026-03-13 07:31:44 -07:00
|
|
|
|
setName = es.name;
|
2026-03-20 05:17:27 -07:00
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Also update public-facing info
|
|
|
|
|
|
for (auto& info : equipmentSetInfo_) {
|
|
|
|
|
|
if (info.setGuid == setGuid || info.setId == setIndex) {
|
|
|
|
|
|
info.setGuid = setGuid;
|
2026-03-13 07:31:44 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 05:17:27 -07:00
|
|
|
|
// 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);
|
2026-03-13 07:31:44 -07:00
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(setName.empty()
|
|
|
|
|
|
? std::string("Equipment set saved.")
|
|
|
|
|
|
: "Equipment set \"" + setName + "\" saved.");
|
2026-03-09 19:46:52 -07:00
|
|
|
|
break;
|
2026-03-13 07:31:44 -07:00
|
|
|
|
}
|
2026-03-09 16:43:33 -07:00
|
|
|
|
case Opcode::SMSG_PERIODICAURALOG: {
|
2026-03-09 23:48:06 -07:00
|
|
|
|
// WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects
|
2026-03-10 00:38:47 -07:00
|
|
|
|
// 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;
|
2026-03-09 23:48:06 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < guidMinSz) break;
|
2026-03-10 00:38:47 -07:00
|
|
|
|
uint64_t victimGuid = periodicTbc
|
2026-03-09 23:48:06 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < guidMinSz) break;
|
2026-03-10 00:38:47 -07:00
|
|
|
|
uint64_t casterGuid = periodicTbc
|
2026-03-09 23:48:06 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 16:43:33 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
bool isPlayerVictim = (victimGuid == playerGuid);
|
|
|
|
|
|
bool isPlayerCaster = (casterGuid == playerGuid);
|
|
|
|
|
|
if (!isPlayerVictim && !isPlayerCaster) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 1; ++i) {
|
|
|
|
|
|
uint8_t auraType = packet.readUInt8();
|
|
|
|
|
|
if (auraType == 3 || auraType == 89) {
|
2026-03-11 03:34:27 -07:00
|
|
|
|
// Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes
|
2026-03-18 09:17:00 -07:00
|
|
|
|
// WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes
|
2026-03-11 03:34:27 -07:00
|
|
|
|
const bool periodicWotlk = isActiveExpansion("wotlk");
|
2026-03-18 09:17:00 -07:00
|
|
|
|
const size_t dotSz = periodicWotlk ? 21u : 16u;
|
2026-03-11 03:34:27 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < dotSz) break;
|
2026-03-09 16:43:33 -07:00
|
|
|
|
uint32_t dmg = packet.readUInt32();
|
2026-03-11 03:34:27 -07:00
|
|
|
|
if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32();
|
2026-03-09 16:43:33 -07:00
|
|
|
|
/*uint32_t school=*/ packet.readUInt32();
|
2026-03-11 03:30:24 -07:00
|
|
|
|
uint32_t abs = packet.readUInt32();
|
|
|
|
|
|
uint32_t res = packet.readUInt32();
|
2026-03-18 09:17:00 -07:00
|
|
|
|
bool dotCrit = false;
|
|
|
|
|
|
if (periodicWotlk) dotCrit = (packet.readUInt8() != 0);
|
2026-03-11 03:30:24 -07:00
|
|
|
|
if (dmg > 0)
|
2026-03-18 09:17:00 -07:00
|
|
|
|
addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE,
|
|
|
|
|
|
static_cast<int32_t>(dmg),
|
2026-03-13 11:48:42 -07:00
|
|
|
|
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
2026-03-11 03:30:24 -07:00
|
|
|
|
if (abs > 0)
|
|
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(abs),
|
2026-03-13 11:48:42 -07:00
|
|
|
|
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
2026-03-11 03:30:24 -07:00
|
|
|
|
if (res > 0)
|
|
|
|
|
|
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(res),
|
2026-03-13 11:48:42 -07:00
|
|
|
|
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
2026-03-09 16:43:33 -07:00
|
|
|
|
} else if (auraType == 8 || auraType == 124 || auraType == 45) {
|
2026-03-11 03:34:27 -07:00
|
|
|
|
// 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;
|
2026-03-09 16:43:33 -07:00
|
|
|
|
uint32_t heal = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t max=*/ packet.readUInt32();
|
|
|
|
|
|
/*uint32_t over=*/ packet.readUInt32();
|
2026-03-11 03:34:27 -07:00
|
|
|
|
uint32_t hotAbs = 0;
|
2026-03-18 09:17:00 -07:00
|
|
|
|
bool hotCrit = false;
|
2026-03-11 03:34:27 -07:00
|
|
|
|
if (healWotlk) {
|
|
|
|
|
|
hotAbs = packet.readUInt32();
|
2026-03-18 09:17:00 -07:00
|
|
|
|
hotCrit = (packet.readUInt8() != 0);
|
2026-03-11 03:34:27 -07:00
|
|
|
|
}
|
2026-03-18 09:17:00 -07:00
|
|
|
|
addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL,
|
|
|
|
|
|
static_cast<int32_t>(heal),
|
2026-03-13 11:48:42 -07:00
|
|
|
|
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
2026-03-11 03:34:27 -07:00
|
|
|
|
if (hotAbs > 0)
|
|
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(hotAbs),
|
2026-03-13 11:48:42 -07:00
|
|
|
|
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
|
2026-03-11 02:25:42 -07:00
|
|
|
|
} 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;
|
2026-03-13 06:08:21 -07:00
|
|
|
|
uint8_t periodicPowerType = static_cast<uint8_t>(packet.readUInt32());
|
2026-03-11 02:25:42 -07:00
|
|
|
|
uint32_t amount = packet.readUInt32();
|
|
|
|
|
|
if ((isPlayerVictim || isPlayerCaster) && amount > 0)
|
|
|
|
|
|
addCombatText(CombatTextEntry::ENERGIZE, static_cast<int32_t>(amount),
|
2026-03-13 11:48:42 -07:00
|
|
|
|
spellId, isPlayerCaster, periodicPowerType, casterGuid, victimGuid);
|
2026-03-11 02:25:42 -07:00
|
|
|
|
} else if (auraType == 98) {
|
|
|
|
|
|
// PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) break;
|
2026-03-13 23:56:44 -07:00
|
|
|
|
uint8_t powerType = static_cast<uint8_t>(packet.readUInt32());
|
2026-03-11 02:25:42 -07:00
|
|
|
|
uint32_t amount = packet.readUInt32();
|
2026-03-14 00:06:05 -07:00
|
|
|
|
float multiplier = packet.readFloat();
|
2026-03-11 02:25:42 -07:00
|
|
|
|
if (isPlayerVictim && amount > 0)
|
2026-03-13 23:56:44 -07:00
|
|
|
|
addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(amount),
|
|
|
|
|
|
spellId, false, powerType, casterGuid, victimGuid);
|
2026-03-14 00:06:05 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 16:43:33 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
// Unknown/untracked aura type — stop parsing this event safely
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 16:55:23 -07:00
|
|
|
|
case Opcode::SMSG_SPELLENERGIZELOG: {
|
2026-03-09 23:48:06 -07:00
|
|
|
|
// WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint8 powerType + int32 amount
|
2026-03-10 00:38:47 -07:00
|
|
|
|
// TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount
|
|
|
|
|
|
// Classic/Vanilla: packed_guid (same as WotLK)
|
|
|
|
|
|
const bool energizeTbc = isActiveExpansion("tbc");
|
2026-03-13 22:30:25 -07:00
|
|
|
|
auto readEnergizeGuid = [&]() -> uint64_t {
|
|
|
|
|
|
if (energizeTbc)
|
|
|
|
|
|
return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0;
|
|
|
|
|
|
return UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
};
|
2026-03-14 14:13:39 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)
|
|
|
|
|
|
|| (!energizeTbc && !hasFullPackedGuid(packet))) {
|
2026-03-13 22:30:25 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t victimGuid = readEnergizeGuid();
|
2026-03-14 14:13:39 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u)
|
|
|
|
|
|
|| (!energizeTbc && !hasFullPackedGuid(packet))) {
|
2026-03-13 22:30:25 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t casterGuid = readEnergizeGuid();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-13 06:08:21 -07:00
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint8_t energizePowerType = packet.readUInt8();
|
|
|
|
|
|
int32_t amount = static_cast<int32_t>(packet.readUInt32());
|
2026-03-09 16:55:23 -07:00
|
|
|
|
bool isPlayerVictim = (victimGuid == playerGuid);
|
|
|
|
|
|
bool isPlayerCaster = (casterGuid == playerGuid);
|
|
|
|
|
|
if ((isPlayerVictim || isPlayerCaster) && amount > 0)
|
2026-03-13 11:52:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType, casterGuid, victimGuid);
|
2026-03-09 16:55:23 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 17:18:18 -07:00
|
|
|
|
case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG: {
|
2026-03-09 17:22:07 -07:00
|
|
|
|
// uint64 victimGuid + uint8 envDmgType + uint32 damage + uint32 absorbed + uint32 resisted
|
2026-03-17 10:54:07 -07:00
|
|
|
|
// envDmgType: 0=Exhausted(fatigue), 1=Drowning, 2=Fall, 3=Lava, 4=Slime, 5=Fire
|
2026-03-09 17:22:07 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 21) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
uint64_t victimGuid = packet.readUInt64();
|
2026-03-17 10:54:07 -07:00
|
|
|
|
uint8_t envType = packet.readUInt8();
|
2026-03-09 17:22:07 -07:00
|
|
|
|
uint32_t dmg = packet.readUInt32();
|
2026-03-11 03:40:41 -07:00
|
|
|
|
uint32_t envAbs = packet.readUInt32();
|
|
|
|
|
|
uint32_t envRes = packet.readUInt32();
|
|
|
|
|
|
if (victimGuid == playerGuid) {
|
2026-03-17 10:54:07 -07:00
|
|
|
|
// Environmental damage: pass envType via powerType field for display differentiation
|
2026-03-11 03:40:41 -07:00
|
|
|
|
if (dmg > 0)
|
2026-03-17 10:54:07 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast<int32_t>(dmg), 0, false, envType, 0, victimGuid);
|
2026-03-11 03:40:41 -07:00
|
|
|
|
if (envAbs > 0)
|
2026-03-13 11:52:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(envAbs), 0, false, 0, 0, victimGuid);
|
2026-03-11 03:40:41 -07:00
|
|
|
|
if (envRes > 0)
|
2026-03-13 11:52:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(envRes), 0, false, 0, 0, victimGuid);
|
2026-03-11 03:40:41 -07:00
|
|
|
|
}
|
2026-03-09 17:18:18 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 20:23:38 -07:00
|
|
|
|
case Opcode::SMSG_SET_PROFICIENCY: {
|
|
|
|
|
|
// uint8 itemClass + uint32 itemSubClassMask
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
uint8_t itemClass = packet.readUInt8();
|
|
|
|
|
|
uint32_t mask = packet.readUInt32();
|
|
|
|
|
|
if (itemClass == 2) { // Weapon
|
|
|
|
|
|
weaponProficiency_ = mask;
|
|
|
|
|
|
LOG_DEBUG("SMSG_SET_PROFICIENCY: weapon mask=0x", std::hex, mask, std::dec);
|
|
|
|
|
|
} else if (itemClass == 4) { // Armor
|
|
|
|
|
|
armorProficiency_ = mask;
|
|
|
|
|
|
LOG_DEBUG("SMSG_SET_PROFICIENCY: armor mask=0x", std::hex, mask, std::dec);
|
|
|
|
|
|
}
|
2026-03-09 16:51:54 -07:00
|
|
|
|
break;
|
2026-03-09 20:23:38 -07:00
|
|
|
|
}
|
2026-03-09 16:51:54 -07:00
|
|
|
|
|
2026-03-09 16:45:53 -07:00
|
|
|
|
case Opcode::SMSG_ACTION_BUTTONS: {
|
2026-03-15 02:55:05 -07:00
|
|
|
|
// 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
|
2026-03-10 17:28:20 -07:00
|
|
|
|
// 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)
|
2026-03-09 16:45:53 -07:00
|
|
|
|
size_t rem = packet.getSize() - packet.getReadPos();
|
2026-03-10 17:28:20 -07:00
|
|
|
|
const bool hasModeByteExp = isActiveExpansion("wotlk");
|
|
|
|
|
|
int serverBarSlots;
|
|
|
|
|
|
if (isClassicLikeExpansion()) {
|
|
|
|
|
|
serverBarSlots = 120;
|
|
|
|
|
|
} else if (isActiveExpansion("tbc")) {
|
|
|
|
|
|
serverBarSlots = 132;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
serverBarSlots = 144;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasModeByteExp) {
|
|
|
|
|
|
if (rem < 1) break;
|
|
|
|
|
|
/*uint8_t mode =*/ packet.readUInt8();
|
|
|
|
|
|
rem--;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (int i = 0; i < serverBarSlots; ++i) {
|
2026-03-09 16:45:53 -07:00
|
|
|
|
if (rem < 4) break;
|
|
|
|
|
|
uint32_t packed = packet.readUInt32();
|
|
|
|
|
|
rem -= 4;
|
2026-03-10 06:04:43 -07:00
|
|
|
|
if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2
|
2026-03-09 16:45:53 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-15 02:55:05 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-09 16:45:53 -07:00
|
|
|
|
if (id == 0) continue;
|
|
|
|
|
|
ActionBarSlot slot;
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break;
|
2026-03-18 01:35:39 -07:00
|
|
|
|
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
|
2026-03-09 16:45:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
actionBar[i] = slot;
|
|
|
|
|
|
}
|
2026-03-20 06:59:23 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-20 08:01:54 -07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 06:59:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 16:45:53 -07:00
|
|
|
|
LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server");
|
2026-03-20 22:13:57 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {});
|
2026-03-09 16:45:53 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-02-20 02:23:19 -08:00
|
|
|
|
break;
|
2026-03-09 16:45:53 -07:00
|
|
|
|
}
|
2026-02-20 02:23:19 -08:00
|
|
|
|
|
2026-02-18 23:14:01 -08:00
|
|
|
|
case Opcode::SMSG_LEVELUP_INFO:
|
2026-02-20 02:23:19 -08:00
|
|
|
|
case Opcode::SMSG_LEVELUP_INFO_ALT: {
|
|
|
|
|
|
// Server-authoritative level-up event.
|
2026-03-12 17:54:49 -07:00
|
|
|
|
// WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas
|
2026-02-20 02:23:19 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t newLevel = packet.readUInt32();
|
|
|
|
|
|
if (newLevel > 0) {
|
2026-03-12 17:54:49 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-02-20 02:23:19 -08:00
|
|
|
|
uint32_t oldLevel = serverPlayerLevel_;
|
|
|
|
|
|
serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel);
|
|
|
|
|
|
for (auto& ch : characters) {
|
|
|
|
|
|
if (ch.guid == playerGuid) {
|
|
|
|
|
|
ch.level = serverPlayerLevel_;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 07:02:20 -07:00
|
|
|
|
if (newLevel > oldLevel) {
|
|
|
|
|
|
addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!");
|
2026-03-17 12:37:19 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playLevelUp();
|
|
|
|
|
|
}
|
2026-03-13 07:02:20 -07:00
|
|
|
|
if (levelUpCallback_) levelUpCallback_(newLevel);
|
2026-03-20 11:51:46 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_LEVEL_UP", {std::to_string(newLevel)});
|
2026-02-20 02:23:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-02-20 02:21:04 -08:00
|
|
|
|
break;
|
2026-02-20 02:23:19 -08:00
|
|
|
|
}
|
2026-02-20 02:21:04 -08:00
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_PLAY_SOUND:
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t soundId = packet.readUInt32();
|
|
|
|
|
|
LOG_DEBUG("SMSG_PLAY_SOUND id=", soundId);
|
2026-03-09 16:11:19 -07:00
|
|
|
|
if (playSoundCallback_) playSoundCallback_(soundId);
|
2026-02-20 02:21:04 -08:00
|
|
|
|
}
|
2026-02-13 19:52:49 -08:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 14:03:07 -07:00
|
|
|
|
case Opcode::SMSG_SERVER_MESSAGE: {
|
2026-03-12 01:22:42 -07:00
|
|
|
|
// uint32 type + string message
|
|
|
|
|
|
// Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled
|
2026-03-09 14:03:07 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
2026-03-12 01:22:42 -07:00
|
|
|
|
uint32_t msgType = packet.readUInt32();
|
2026-03-09 14:03:07 -07:00
|
|
|
|
std::string msg = packet.readString();
|
2026-03-12 01:22:42 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-09 14:03:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CHAT_SERVER_MESSAGE: {
|
|
|
|
|
|
// uint32 type + string text
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
/*uint32_t msgType =*/ packet.readUInt32();
|
|
|
|
|
|
std::string msg = packet.readString();
|
|
|
|
|
|
if (!msg.empty()) addSystemChatMessage("[Announcement] " + msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_AREA_TRIGGER_MESSAGE: {
|
|
|
|
|
|
// uint32 size, then string
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
/*uint32_t len =*/ packet.readUInt32();
|
|
|
|
|
|
std::string msg = packet.readString();
|
2026-03-12 11:06:40 -07:00
|
|
|
|
if (!msg.empty()) {
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError(msg);
|
2026-03-12 11:06:40 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
areaTriggerMsgs_.push_back(msg);
|
|
|
|
|
|
}
|
2026-03-09 14:03:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-20 04:53:54 -07:00
|
|
|
|
case Opcode::SMSG_TRIGGER_CINEMATIC: {
|
|
|
|
|
|
// uint32 cinematicId — we don't play cinematics; acknowledge immediately.
|
2026-03-09 14:03:07 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-03-20 04:53:54 -07:00
|
|
|
|
// Send CMSG_NEXT_CINEMATIC_CAMERA to signal cinematic completion;
|
|
|
|
|
|
// servers may block further packets until this is received.
|
|
|
|
|
|
network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA));
|
|
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped, sent CMSG_NEXT_CINEMATIC_CAMERA");
|
2026-03-09 14:03:07 -07:00
|
|
|
|
break;
|
2026-03-20 04:53:54 -07:00
|
|
|
|
}
|
2026-03-09 14:03:07 -07:00
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
case Opcode::SMSG_LOOT_MONEY_NOTIFY: {
|
2026-02-13 19:52:49 -08:00
|
|
|
|
// Format: uint32 money + uint8 soleLooter
|
2026-02-06 13:47:03 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t amount = packet.readUInt32();
|
2026-02-18 03:37:03 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
/*uint8_t soleLooter =*/ packet.readUInt8();
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
playerMoneyCopper_ += amount;
|
2026-02-13 19:47:49 -08:00
|
|
|
|
pendingMoneyDelta_ = amount;
|
|
|
|
|
|
pendingMoneyDeltaTimer_ = 2.0f;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
LOG_INFO("Looted ", amount, " copper (total: ", playerMoneyCopper_, ")");
|
2026-02-18 04:11:00 -08:00
|
|
|
|
uint64_t notifyGuid = pendingLootMoneyGuid_ != 0 ? pendingLootMoneyGuid_ : currentLoot.lootGuid;
|
|
|
|
|
|
pendingLootMoneyGuid_ = 0;
|
|
|
|
|
|
pendingLootMoneyAmount_ = 0;
|
|
|
|
|
|
pendingLootMoneyNotifyTimer_ = 0.0f;
|
2026-02-18 03:37:03 -08:00
|
|
|
|
bool alreadyAnnounced = false;
|
2026-02-18 04:11:00 -08:00
|
|
|
|
auto it = localLootState_.find(notifyGuid);
|
2026-02-18 03:37:03 -08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-18 04:11:00 -08:00
|
|
|
|
if (notifyGuid != 0) {
|
|
|
|
|
|
recentLootMoneyAnnounceCooldowns_[notifyGuid] = 1.5f;
|
|
|
|
|
|
}
|
2026-02-18 03:37:03 -08:00
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_LOOT_CLEAR_MONEY:
|
|
|
|
|
|
case Opcode::SMSG_NPC_TEXT_UPDATE:
|
2026-02-06 19:50:22 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SELL_ITEM: {
|
|
|
|
|
|
// uint64 vendorGuid, uint64 itemGuid, uint8 result
|
|
|
|
|
|
if ((packet.getSize() - packet.getReadPos()) >= 17) {
|
2026-02-19 05:28:13 -08:00
|
|
|
|
uint64_t vendorGuid = packet.readUInt64();
|
|
|
|
|
|
uint64_t itemGuid = packet.readUInt64(); // itemGuid
|
2026-02-06 19:50:22 -08:00
|
|
|
|
uint8_t result = packet.readUInt8();
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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);
|
2026-03-17 12:31:38 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playDropOnGround();
|
|
|
|
|
|
}
|
2026-02-19 05:28:13 -08:00
|
|
|
|
} else {
|
2026-02-19 05:48:40 -08:00
|
|
|
|
bool removedPending = false;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
auto it = pendingSellToBuyback_.find(itemGuid);
|
|
|
|
|
|
if (it != pendingSellToBuyback_.end()) {
|
|
|
|
|
|
for (auto bit = buybackItems_.begin(); bit != buybackItems_.end(); ++bit) {
|
|
|
|
|
|
if (bit->itemGuid == itemGuid) {
|
|
|
|
|
|
buybackItems_.erase(bit);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingSellToBuyback_.erase(it);
|
2026-02-19 05:48:40 -08:00
|
|
|
|
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();
|
2026-02-19 05:28:13 -08:00
|
|
|
|
}
|
2026-02-06 19:50:22 -08:00
|
|
|
|
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";
|
2026-03-17 17:25:27 -07:00
|
|
|
|
addUIError(std::string("Sell failed: ") + msg);
|
2026-02-06 19:50:22 -08:00
|
|
|
|
addSystemChatMessage(std::string("Sell failed: ") + msg);
|
2026-03-17 12:28:15 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playError();
|
|
|
|
|
|
}
|
2026-02-06 19:50:22 -08:00
|
|
|
|
LOG_WARNING("SMSG_SELL_ITEM error: ", (int)result, " (", msg, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_INVENTORY_CHANGE_FAILURE: {
|
|
|
|
|
|
if ((packet.getSize() - packet.getReadPos()) >= 1) {
|
|
|
|
|
|
uint8_t error = packet.readUInt8();
|
|
|
|
|
|
if (error != 0) {
|
|
|
|
|
|
LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error);
|
2026-03-10 17:10:31 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-02-07 13:59:39 -08:00
|
|
|
|
// InventoryResult enum (AzerothCore 3.3.5a)
|
|
|
|
|
|
const char* errMsg = nullptr;
|
2026-03-10 17:10:31 -07:00
|
|
|
|
char levelBuf[64];
|
2026-02-06 19:50:22 -08:00
|
|
|
|
switch (error) {
|
2026-03-10 17:10:31 -07:00
|
|
|
|
case 1:
|
|
|
|
|
|
if (requiredLevel > 0) {
|
|
|
|
|
|
std::snprintf(levelBuf, sizeof(levelBuf),
|
|
|
|
|
|
"You must reach level %u to use that item.", requiredLevel);
|
2026-03-17 17:36:25 -07:00
|
|
|
|
addUIError(levelBuf);
|
2026-03-10 17:10:31 -07:00
|
|
|
|
addSystemChatMessage(levelBuf);
|
|
|
|
|
|
} else {
|
2026-03-17 17:36:25 -07:00
|
|
|
|
addUIError("You must reach a higher level to use that item.");
|
2026-03-10 17:10:31 -07:00
|
|
|
|
addSystemChatMessage("You must reach a higher level to use that item.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-02-07 13:59:39 -08:00
|
|
|
|
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;
|
2026-02-06 19:50:22 -08:00
|
|
|
|
}
|
2026-02-07 13:59:39 -08:00
|
|
|
|
std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ").";
|
2026-03-17 17:24:23 -07:00
|
|
|
|
addUIError(msg);
|
2026-02-06 19:50:22 -08:00
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-17 12:37:19 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playError();
|
|
|
|
|
|
}
|
2026-02-06 19:50:22 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-17 17:44:48 -08:00
|
|
|
|
case Opcode::SMSG_BUY_FAILED: {
|
|
|
|
|
|
// vendorGuid(8) + itemId(4) + errorCode(1)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 13) {
|
2026-02-19 05:28:13 -08:00
|
|
|
|
uint64_t vendorGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t itemIdOrSlot = packet.readUInt32();
|
2026-02-17 17:44:48 -08:00
|
|
|
|
uint8_t errCode = packet.readUInt8();
|
2026-02-19 05:28:13 -08:00
|
|
|
|
LOG_INFO("SMSG_BUY_FAILED: vendorGuid=0x", std::hex, vendorGuid, std::dec,
|
|
|
|
|
|
" item/slot=", itemIdOrSlot,
|
|
|
|
|
|
" err=", static_cast<int>(errCode),
|
|
|
|
|
|
" pendingBuybackSlot=", pendingBuybackSlot_,
|
2026-02-19 05:48:40 -08:00
|
|
|
|
" pendingBuybackWireSlot=", pendingBuybackWireSlot_,
|
2026-02-19 05:28:13 -08:00
|
|
|
|
" pendingBuyItemId=", pendingBuyItemId_,
|
|
|
|
|
|
" pendingBuyItemSlot=", pendingBuyItemSlot_);
|
2026-02-19 05:48:40 -08:00
|
|
|
|
if (pendingBuybackSlot_ >= 0) {
|
|
|
|
|
|
// Some cores require probing absolute buyback slots until a live entry is found.
|
|
|
|
|
|
if (errCode == 0) {
|
|
|
|
|
|
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
|
|
|
|
|
|
constexpr uint32_t kBuybackSlotEnd = 85;
|
|
|
|
|
|
if (pendingBuybackWireSlot_ >= 74 && pendingBuybackWireSlot_ < kBuybackSlotEnd &&
|
|
|
|
|
|
socket && state == WorldState::IN_WORLD && currentVendorItems.vendorGuid != 0) {
|
|
|
|
|
|
++pendingBuybackWireSlot_;
|
|
|
|
|
|
LOG_INFO("Buyback retry: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
|
|
|
|
|
|
std::dec, " uiSlot=", pendingBuybackSlot_,
|
|
|
|
|
|
" wireSlot=", pendingBuybackWireSlot_);
|
|
|
|
|
|
network::Packet retry(kWotlkCmsgBuybackItemOpcode);
|
|
|
|
|
|
retry.writeUInt64(currentVendorItems.vendorGuid);
|
|
|
|
|
|
retry.writeUInt32(pendingBuybackWireSlot_);
|
|
|
|
|
|
socket->send(retry);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Exhausted slot probe: drop stale local row and advance.
|
|
|
|
|
|
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
|
|
|
|
|
|
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingBuybackSlot_ = -1;
|
|
|
|
|
|
pendingBuybackWireSlot_ = 0;
|
|
|
|
|
|
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
|
|
|
|
|
|
auto pkt = ListInventoryPacket::build(currentVendorItems.vendorGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingBuybackSlot_ = -1;
|
|
|
|
|
|
pendingBuybackWireSlot_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 17:44:48 -08:00
|
|
|
|
const char* msg = "Purchase failed.";
|
|
|
|
|
|
switch (errCode) {
|
2026-02-19 05:28:13 -08:00
|
|
|
|
case 0: msg = "Purchase failed: item not found."; break;
|
2026-02-17 17:44:48 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-17 17:25:27 -07:00
|
|
|
|
addUIError(msg);
|
2026-02-17 17:44:48 -08:00
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-17 12:28:15 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playError();
|
|
|
|
|
|
}
|
2026-02-17 17:44:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-10 06:10:29 -07:00
|
|
|
|
case Opcode::MSG_RAID_TARGET_UPDATE: {
|
|
|
|
|
|
// uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)),
|
|
|
|
|
|
// 1 = single update (uint8 icon + uint64 guid)
|
|
|
|
|
|
size_t remRTU = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (remRTU < 1) break;
|
|
|
|
|
|
uint8_t rtuType = packet.readUInt8();
|
|
|
|
|
|
if (rtuType == 0) {
|
|
|
|
|
|
// Full update: always 8 entries
|
|
|
|
|
|
for (uint32_t i = 0; i < kRaidMarkCount; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) break;
|
|
|
|
|
|
uint8_t icon = packet.readUInt8();
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
if (icon < kRaidMarkCount)
|
|
|
|
|
|
raidTargetGuids_[icon] = guid;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Single update
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 9) {
|
|
|
|
|
|
uint8_t icon = packet.readUInt8();
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
if (icon < kRaidMarkCount)
|
|
|
|
|
|
raidTargetGuids_[icon] = guid;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast<int>(rtuType));
|
2026-02-06 13:47:03 -08:00
|
|
|
|
break;
|
2026-03-10 06:10:29 -07:00
|
|
|
|
}
|
2026-03-09 14:57:46 -07:00
|
|
|
|
case Opcode::SMSG_BUY_ITEM: {
|
|
|
|
|
|
// uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount
|
|
|
|
|
|
// Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 20) {
|
2026-03-13 07:20:58 -07:00
|
|
|
|
/*uint64_t vendorGuid =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint32_t vendorSlot =*/ packet.readUInt32();
|
|
|
|
|
|
/*int32_t newCount =*/ static_cast<int32_t>(packet.readUInt32());
|
2026-03-09 14:57:46 -07:00
|
|
|
|
uint32_t itemCount = packet.readUInt32();
|
2026-03-13 07:20:58 -07:00
|
|
|
|
// Show purchase confirmation with item name if available
|
|
|
|
|
|
if (pendingBuyItemId_ != 0) {
|
|
|
|
|
|
std::string itemLabel;
|
2026-03-17 13:27:27 -07:00
|
|
|
|
uint32_t buyQuality = 1;
|
|
|
|
|
|
if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) {
|
2026-03-13 07:20:58 -07:00
|
|
|
|
if (!info->name.empty()) itemLabel = info->name;
|
2026-03-17 13:27:27 -07:00
|
|
|
|
buyQuality = info->quality;
|
|
|
|
|
|
}
|
2026-03-13 07:20:58 -07:00
|
|
|
|
if (itemLabel.empty()) itemLabel = "item #" + std::to_string(pendingBuyItemId_);
|
2026-03-17 13:27:27 -07:00
|
|
|
|
std::string msg = "Purchased: " + buildItemLink(pendingBuyItemId_, buyQuality, itemLabel);
|
2026-03-13 07:20:58 -07:00
|
|
|
|
if (itemCount > 1) msg += " x" + std::to_string(itemCount);
|
|
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-17 12:31:38 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playPickupBag();
|
|
|
|
|
|
}
|
2026-03-13 07:20:58 -07:00
|
|
|
|
}
|
2026-03-09 14:57:46 -07:00
|
|
|
|
pendingBuyItemId_ = 0;
|
|
|
|
|
|
pendingBuyItemSlot_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CRITERIA_UPDATE: {
|
|
|
|
|
|
// uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 20) {
|
|
|
|
|
|
uint32_t criteriaId = packet.readUInt32();
|
|
|
|
|
|
uint64_t progress = packet.readUInt64();
|
2026-03-12 03:03:02 -07:00
|
|
|
|
packet.readUInt32(); // elapsedTime
|
|
|
|
|
|
packet.readUInt32(); // creationTime
|
2026-03-20 17:33:34 -07:00
|
|
|
|
uint64_t oldProgress = 0;
|
|
|
|
|
|
auto cpit = criteriaProgress_.find(criteriaId);
|
|
|
|
|
|
if (cpit != criteriaProgress_.end()) oldProgress = cpit->second;
|
2026-03-12 03:03:02 -07:00
|
|
|
|
criteriaProgress_[criteriaId] = progress;
|
2026-03-09 14:57:46 -07:00
|
|
|
|
LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress);
|
2026-03-20 17:33:34 -07:00
|
|
|
|
// Fire addon event for achievement tracking addons
|
|
|
|
|
|
if (addonEventCallback_ && progress != oldProgress)
|
|
|
|
|
|
addonEventCallback_("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)});
|
2026-03-09 14:57:46 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BARBER_SHOP_RESULT: {
|
|
|
|
|
|
// uint32 result (0 = success, 1 = no money, 2 = not barber, 3 = sitting)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Hairstyle changed.");
|
2026-03-18 11:58:01 -07:00
|
|
|
|
barberShopOpen_ = false;
|
2026-03-09 14:57:46 -07:00
|
|
|
|
} 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.";
|
2026-03-17 18:08:27 -07:00
|
|
|
|
addUIError(msg);
|
2026-03-09 14:57:46 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_OVERRIDE_LIGHT: {
|
|
|
|
|
|
// uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
uint32_t zoneLightId = packet.readUInt32();
|
|
|
|
|
|
uint32_t overrideLightId = packet.readUInt32();
|
|
|
|
|
|
uint32_t transitionMs = packet.readUInt32();
|
|
|
|
|
|
overrideLightId_ = overrideLightId;
|
|
|
|
|
|
overrideLightTransMs_ = transitionMs;
|
|
|
|
|
|
LOG_DEBUG("SMSG_OVERRIDE_LIGHT: zone=", zoneLightId,
|
|
|
|
|
|
" override=", overrideLightId, " transition=", transitionMs, "ms");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-17 17:59:41 -08:00
|
|
|
|
case Opcode::SMSG_WEATHER: {
|
2026-03-11 04:25:00 -07:00
|
|
|
|
// 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) {
|
2026-02-17 17:59:41 -08:00
|
|
|
|
uint32_t wType = packet.readUInt32();
|
|
|
|
|
|
float wIntensity = packet.readFloat();
|
2026-03-11 04:25:00 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1)
|
|
|
|
|
|
/*uint8_t isAbrupt =*/ packet.readUInt8();
|
2026-03-13 07:39:41 -07:00
|
|
|
|
uint32_t prevWeatherType = weatherType_;
|
2026-02-17 17:59:41 -08:00
|
|
|
|
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);
|
2026-03-13 07:39:41 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-03-12 19:46:01 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-17 17:59:41 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:06:56 -07:00
|
|
|
|
case Opcode::SMSG_SCRIPT_MESSAGE: {
|
|
|
|
|
|
// Server-script text message — display in system chat
|
|
|
|
|
|
std::string msg = packet.readString();
|
|
|
|
|
|
if (!msg.empty()) {
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_ENCHANTMENTLOG: {
|
|
|
|
|
|
// uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 28) {
|
2026-03-20 16:21:52 -07:00
|
|
|
|
uint64_t enchTargetGuid = packet.readUInt64();
|
|
|
|
|
|
uint64_t enchCasterGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t enchSpellId = packet.readUInt32();
|
2026-03-09 15:06:56 -07:00
|
|
|
|
/*uint32_t displayId =*/ packet.readUInt32();
|
|
|
|
|
|
/*uint32_t animType =*/ packet.readUInt32();
|
2026-03-20 16:21:52 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 15:06:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_SOCKET_GEMS_RESULT: {
|
|
|
|
|
|
// uint64 itemGuid + uint32 result (0 = success)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
/*uint64_t itemGuid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Gems socketed successfully.");
|
|
|
|
|
|
} else {
|
2026-03-17 17:24:23 -07:00
|
|
|
|
addUIError("Failed to socket gems.");
|
2026-03-09 15:06:56 -07:00
|
|
|
|
addSystemChatMessage("Failed to socket gems.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_ITEM_REFUND_RESULT: {
|
|
|
|
|
|
// uint64 itemGuid + uint32 result (0=success)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
/*uint64_t itemGuid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Item returned. Refund processed.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Could not return item for refund.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_ITEM_REFUND_RESULT: result=", result);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_ITEM_TIME_UPDATE: {
|
|
|
|
|
|
// uint64 itemGuid + uint32 durationMs — item duration ticking down
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
/*uint64_t itemGuid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t durationMs = packet.readUInt32();
|
|
|
|
|
|
LOG_DEBUG("SMSG_ITEM_TIME_UPDATE: remainingMs=", durationMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_RESURRECT_FAILED: {
|
|
|
|
|
|
// uint32 reason — various resurrection failures
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t reason = packet.readUInt32();
|
|
|
|
|
|
const char* msg = (reason == 1) ? "The target cannot be resurrected right now."
|
|
|
|
|
|
: (reason == 2) ? "Cannot resurrect in this area."
|
|
|
|
|
|
: "Resurrection failed.";
|
2026-03-17 17:36:25 -07:00
|
|
|
|
addUIError(msg);
|
2026-03-09 15:06:56 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE:
|
|
|
|
|
|
handleGameObjectQueryResponse(packet);
|
|
|
|
|
|
break;
|
2026-02-20 23:31:30 -08:00
|
|
|
|
case Opcode::SMSG_GAMEOBJECT_PAGETEXT:
|
|
|
|
|
|
handleGameObjectPageText(packet);
|
|
|
|
|
|
break;
|
2026-02-23 05:39:02 -08:00
|
|
|
|
case Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM: {
|
|
|
|
|
|
if (packet.getSize() >= 12) {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
uint32_t animId = packet.readUInt32();
|
|
|
|
|
|
if (gameObjectCustomAnimCallback_) {
|
|
|
|
|
|
gameObjectCustomAnimCallback_(guid, animId);
|
|
|
|
|
|
}
|
2026-03-13 05:07:51 -07:00
|
|
|
|
// animId == 0 is the fishing bobber splash ("fish hooked").
|
|
|
|
|
|
// Detect by GO type 17 (FISHINGNODE) and notify the player so they
|
|
|
|
|
|
// know to click the bobber before the fish escapes.
|
|
|
|
|
|
if (animId == 0) {
|
|
|
|
|
|
auto goEnt = entityManager.getEntity(guid);
|
|
|
|
|
|
if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
auto go = std::static_pointer_cast<GameObject>(goEnt);
|
|
|
|
|
|
auto* info = getCachedGameObjectInfo(go->getEntry());
|
|
|
|
|
|
if (info && info->type == 17) { // GO_TYPE_FISHINGNODE
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError("A fish is on your line!");
|
2026-03-13 05:07:51 -07:00
|
|
|
|
addSystemChatMessage("A fish is on your line!");
|
|
|
|
|
|
// Play a distinctive UI sound to alert the player
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager()) {
|
|
|
|
|
|
sfx->playQuestUpdate(); // Distinct "ping" sound
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-23 05:39:02 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-20 23:31:30 -08:00
|
|
|
|
case Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE:
|
|
|
|
|
|
handlePageTextQueryResponse(packet);
|
|
|
|
|
|
break;
|
2026-02-06 20:10:10 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_STATUS: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 9) {
|
|
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
2026-02-17 05:27:03 -08:00
|
|
|
|
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
|
2026-02-06 20:10:10 -08:00
|
|
|
|
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
|
|
|
|
|
LOG_DEBUG("SMSG_QUESTGIVER_STATUS: guid=0x", std::hex, npcGuid, std::dec, " status=", (int)status);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) break;
|
|
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
2026-02-17 05:27:03 -08:00
|
|
|
|
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
|
2026-02-06 20:10:10 -08:00
|
|
|
|
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_QUESTGIVER_STATUS_MULTIPLE: ", count, " entries");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS:
|
2026-02-06 11:59:51 -08:00
|
|
|
|
handleQuestDetails(packet);
|
|
|
|
|
|
break;
|
2026-02-09 23:22:14 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_INVALID: {
|
|
|
|
|
|
// Quest query failed - parse failure reason
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t failReason = packet.readUInt32();
|
2026-02-19 01:12:14 -08:00
|
|
|
|
pendingTurnInRewardRequest_ = false;
|
2026-02-09 23:22:14 -08:00
|
|
|
|
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, ")");
|
2026-02-20 17:14:13 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-09 23:22:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: {
|
|
|
|
|
|
// Mark quest as complete in local log
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-02-09 23:22:14 -08:00
|
|
|
|
LOG_INFO("Quest completed: questId=", questId);
|
2026-02-19 01:12:14 -08:00
|
|
|
|
if (pendingTurnInQuestId_ == questId) {
|
|
|
|
|
|
pendingTurnInQuestId_ = 0;
|
|
|
|
|
|
pendingTurnInNpcGuid_ = 0;
|
|
|
|
|
|
pendingTurnInRewardRequest_ = false;
|
|
|
|
|
|
}
|
2026-02-06 20:16:38 -08:00
|
|
|
|
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
|
|
|
|
|
|
if (it->questId == questId) {
|
2026-03-12 04:53:03 -07:00
|
|
|
|
// Fire toast callback before erasing
|
|
|
|
|
|
if (questCompleteCallback_) {
|
|
|
|
|
|
questCompleteCallback_(questId, it->title);
|
|
|
|
|
|
}
|
2026-03-17 12:28:15 -07:00
|
|
|
|
// Play quest-complete sound
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playQuestComplete();
|
|
|
|
|
|
}
|
2026-02-06 20:16:38 -08:00
|
|
|
|
questLog_.erase(it);
|
2026-02-09 22:56:38 -08:00
|
|
|
|
LOG_INFO(" Removed quest ", questId, " from quest log");
|
2026-02-06 13:47:03 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 17:28:28 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("QUEST_LOG_UPDATE", {});
|
2026-02-06 20:16:38 -08:00
|
|
|
|
// 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) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 20:16:38 -08:00
|
|
|
|
qsPkt.writeUInt64(guid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_ADD_KILL: {
|
|
|
|
|
|
// Quest kill count update
|
2026-02-19 02:04:56 -08:00
|
|
|
|
// Compatibility: some classic-family opcode tables swap ADD_KILL and COMPLETE.
|
|
|
|
|
|
size_t rem = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (rem >= 12) {
|
2026-02-10 01:24:37 -08:00
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(questId);
|
2026-02-19 02:04:56 -08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-02-19 02:04:56 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-11 00:13:09 -07:00
|
|
|
|
// 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
|
2026-02-10 01:24:37 -08:00
|
|
|
|
quest.killCounts[entry] = {count, reqCount};
|
|
|
|
|
|
|
2026-03-10 07:45:53 -07:00
|
|
|
|
std::string creatureName = getCachedCreatureName(entry);
|
|
|
|
|
|
std::string progressMsg = quest.title + ": ";
|
|
|
|
|
|
if (!creatureName.empty()) {
|
|
|
|
|
|
progressMsg += creatureName + " ";
|
|
|
|
|
|
}
|
|
|
|
|
|
progressMsg += std::to_string(count) + "/" + std::to_string(reqCount);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
addSystemChatMessage(progressMsg);
|
|
|
|
|
|
|
2026-03-12 15:57:09 -07:00
|
|
|
|
if (questProgressCallback_) {
|
|
|
|
|
|
questProgressCallback_(quest.title, creatureName, count, reqCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
LOG_INFO("Updated kill count for quest ", questId, ": ",
|
|
|
|
|
|
count, "/", reqCount);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 02:04:56 -08:00
|
|
|
|
} else if (rem >= 4) {
|
|
|
|
|
|
// Swapped mapping fallback: treat as QUESTUPDATE_COMPLETE packet.
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(questId);
|
2026-02-19 02:04:56 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-18 04:06:14 -08:00
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_ADD_ITEM: {
|
|
|
|
|
|
// Quest item count update: itemId + count
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
queryItemInfo(itemId, 0);
|
|
|
|
|
|
|
|
|
|
|
|
std::string itemLabel = "item #" + std::to_string(itemId);
|
2026-03-17 13:25:33 -07:00
|
|
|
|
uint32_t questItemQuality = 1;
|
2026-02-18 04:06:14 -08:00
|
|
|
|
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
|
|
|
|
|
|
if (!info->name.empty()) itemLabel = info->name;
|
2026-03-17 13:25:33 -07:00
|
|
|
|
questItemQuality = info->quality;
|
2026-02-18 04:06:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool updatedAny = false;
|
|
|
|
|
|
for (auto& quest : questLog_) {
|
|
|
|
|
|
if (quest.complete) continue;
|
2026-03-11 00:13:09 -07:00
|
|
|
|
bool tracksItem =
|
2026-02-19 00:56:24 -08:00
|
|
|
|
quest.requiredItemCounts.count(itemId) > 0 ||
|
|
|
|
|
|
quest.itemCounts.count(itemId) > 0;
|
2026-03-11 00:13:09 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 00:56:24 -08:00
|
|
|
|
if (!tracksItem) continue;
|
2026-02-18 04:06:14 -08:00
|
|
|
|
quest.itemCounts[itemId] = count;
|
|
|
|
|
|
updatedAny = true;
|
|
|
|
|
|
}
|
2026-03-17 13:25:33 -07:00
|
|
|
|
addSystemChatMessage("Quest item: " + buildItemLink(itemId, questItemQuality, itemLabel) + " (" + std::to_string(count) + ")");
|
2026-03-12 15:57:09 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 04:06:14 -08:00
|
|
|
|
LOG_INFO("Quest item update: itemId=", itemId, " count=", count,
|
|
|
|
|
|
" trackedQuestsUpdated=", updatedAny);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_COMPLETE: {
|
2026-02-19 02:04:56 -08:00
|
|
|
|
// 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();
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(questId);
|
2026-02-19 02:04:56 -08:00
|
|
|
|
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();
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(questId);
|
2026-02-19 02:04:56 -08:00
|
|
|
|
LOG_INFO("Quest objectives completed: questId=", questId);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
2026-02-19 02:04:56 -08:00
|
|
|
|
for (auto& quest : questLog_) {
|
|
|
|
|
|
if (quest.questId == questId) {
|
|
|
|
|
|
quest.complete = true;
|
|
|
|
|
|
addSystemChatMessage("Quest Complete: " + quest.title);
|
|
|
|
|
|
LOG_INFO("Marked quest ", questId, " as complete");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-18 23:26:58 -08:00
|
|
|
|
case Opcode::SMSG_QUEST_FORCE_REMOVE: {
|
2026-03-10 20:29:55 -07:00
|
|
|
|
// 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).
|
2026-02-18 23:26:58 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
2026-03-10 20:29:55 -07:00
|
|
|
|
LOG_WARNING("SMSG_QUEST_FORCE_REMOVE/SET_REST_START too short");
|
2026-02-18 23:26:58 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-10 20:29:55 -07:00
|
|
|
|
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.");
|
2026-03-21 03:27:09 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_UPDATE_RESTING", {});
|
2026-03-10 20:29:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Classic/TBC: treat as QUEST_FORCE_REMOVE (uint32 questId)
|
|
|
|
|
|
uint32_t questId = value;
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(questId);
|
2026-02-19 00:30:21 -08:00
|
|
|
|
pendingQuestQueryIds_.erase(questId);
|
2026-02-18 23:48:11 -08:00
|
|
|
|
if (questId == 0) {
|
|
|
|
|
|
// Some servers emit a zero-id variant during world bootstrap.
|
|
|
|
|
|
// Treat as no-op to avoid false "Quest removed" spam.
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool removed = false;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
std::string removedTitle;
|
|
|
|
|
|
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
|
|
|
|
|
|
if (it->questId == questId) {
|
|
|
|
|
|
removedTitle = it->title;
|
|
|
|
|
|
questLog_.erase(it);
|
2026-02-18 23:48:11 -08:00
|
|
|
|
removed = true;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (currentQuestDetails.questId == questId) {
|
|
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 17:27:23 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-02-18 23:30:38 -08:00
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
2026-02-18 23:48:11 -08:00
|
|
|
|
removed = true;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
if (currentQuestRequestItems_.questId == questId) {
|
|
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
2026-02-18 23:48:11 -08:00
|
|
|
|
removed = true;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
if (currentQuestOfferReward_.questId == questId) {
|
|
|
|
|
|
questOfferRewardOpen_ = false;
|
|
|
|
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
2026-02-18 23:48:11 -08:00
|
|
|
|
removed = true;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
}
|
2026-02-18 23:48:11 -08:00
|
|
|
|
if (removed) {
|
|
|
|
|
|
if (!removedTitle.empty()) {
|
|
|
|
|
|
addSystemChatMessage("Quest removed: " + removedTitle);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Quest removed (ID " + std::to_string(questId) + ").");
|
|
|
|
|
|
}
|
2026-02-18 23:30:38 -08:00
|
|
|
|
}
|
2026-02-18 23:26:58 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-09 23:53:17 -08:00
|
|
|
|
case Opcode::SMSG_QUEST_QUERY_RESPONSE: {
|
|
|
|
|
|
if (packet.getSize() < 8) {
|
|
|
|
|
|
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-02-19 00:30:21 -08:00
|
|
|
|
packet.readUInt32(); // questMethod
|
2026-02-18 05:11:29 -08:00
|
|
|
|
|
2026-03-11 00:05:05 -07:00
|
|
|
|
// 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;
|
2026-02-19 00:30:21 -08:00
|
|
|
|
const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout);
|
2026-03-10 23:52:18 -07:00
|
|
|
|
const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout);
|
2026-03-11 22:30:16 -07:00
|
|
|
|
const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout);
|
2026-02-19 00:30:21 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-18 05:11:29 -08:00
|
|
|
|
}
|
2026-02-19 00:30:21 -08:00
|
|
|
|
if (!parsed.objectives.empty() &&
|
2026-02-19 00:56:24 -08:00
|
|
|
|
(q.objectives.empty() || q.objectives.size() < 16)) {
|
2026-02-19 00:30:21 -08:00
|
|
|
|
q.objectives = parsed.objectives;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
2026-03-10 23:52:18 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-03-11 00:05:05 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-03-10 23:52:18 -07:00
|
|
|
|
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, "]");
|
|
|
|
|
|
}
|
2026-03-11 22:30:16 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 00:30:21 -08:00
|
|
|
|
break;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
2026-02-09 23:53:17 -08:00
|
|
|
|
|
2026-02-19 00:30:21 -08:00
|
|
|
|
pendingQuestQueryIds_.erase(questId);
|
2026-02-09 23:53:17 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
case Opcode::SMSG_QUESTLOG_FULL:
|
|
|
|
|
|
// Zero-payload notification: the player's quest log is full (25 quests).
|
2026-03-17 17:24:23 -07:00
|
|
|
|
addUIError("Your quest log is full.");
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
addSystemChatMessage("Your quest log is full.");
|
|
|
|
|
|
LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity");
|
2026-02-10 01:24:37 -08:00
|
|
|
|
break;
|
2026-02-06 11:45:35 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS:
|
2026-02-06 21:50:15 -08:00
|
|
|
|
handleQuestRequestItems(packet);
|
|
|
|
|
|
break;
|
2026-02-06 11:45:35 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_OFFER_REWARD:
|
2026-02-06 21:50:15 -08:00
|
|
|
|
handleQuestOfferReward(packet);
|
|
|
|
|
|
break;
|
2026-03-12 21:08:40 -07:00
|
|
|
|
case Opcode::SMSG_GROUP_SET_LEADER: {
|
|
|
|
|
|
// SMSG_GROUP_SET_LEADER: string leaderName (null-terminated)
|
|
|
|
|
|
if (packet.getSize() > packet.getReadPos()) {
|
|
|
|
|
|
std::string leaderName = packet.readString();
|
|
|
|
|
|
// Update leaderGuid by name lookup in party members
|
|
|
|
|
|
for (const auto& m : partyData.members) {
|
|
|
|
|
|
if (m.name == leaderName) {
|
|
|
|
|
|
partyData.leaderGuid = m.guid;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!leaderName.empty())
|
|
|
|
|
|
addSystemChatMessage(leaderName + " is now the group leader.");
|
|
|
|
|
|
LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
break;
|
2026-03-12 21:08:40 -07:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// ---- Teleport / Transfer ----
|
2026-03-15 06:13:36 -07:00
|
|
|
|
case Opcode::MSG_MOVE_TELEPORT:
|
2026-02-07 16:59:20 -08:00
|
|
|
|
case Opcode::MSG_MOVE_TELEPORT_ACK:
|
|
|
|
|
|
handleTeleportAck(packet);
|
|
|
|
|
|
break;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
case Opcode::SMSG_TRANSFER_PENDING: {
|
|
|
|
|
|
// SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data
|
|
|
|
|
|
uint32_t pendingMapId = packet.readUInt32();
|
2026-03-10 04:56:42 -07:00
|
|
|
|
LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// Optional: if remaining data, there's a transport entry + mapId
|
|
|
|
|
|
if (packet.getReadPos() + 8 <= packet.getSize()) {
|
|
|
|
|
|
uint32_t transportEntry = packet.readUInt32();
|
|
|
|
|
|
uint32_t transportMapId = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO(" Transport entry=", transportEntry, " transportMapId=", transportMapId);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
break;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
case Opcode::SMSG_NEW_WORLD:
|
|
|
|
|
|
handleNewWorld(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_TRANSFER_ABORTED: {
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0;
|
|
|
|
|
|
LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason);
|
2026-03-11 02:36:55 -07:00
|
|
|
|
// Provide reason-specific feedback (WotLK TRANSFER_ABORT_* codes)
|
|
|
|
|
|
const char* abortMsg = nullptr;
|
|
|
|
|
|
switch (reason) {
|
|
|
|
|
|
case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break;
|
|
|
|
|
|
case 0x02: abortMsg = "Transfer aborted: expansion required."; break;
|
|
|
|
|
|
case 0x03: abortMsg = "Transfer aborted: instance not found."; break;
|
|
|
|
|
|
case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break;
|
|
|
|
|
|
case 0x06: abortMsg = "Transfer aborted: instance is full."; break;
|
|
|
|
|
|
case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break;
|
|
|
|
|
|
case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break;
|
|
|
|
|
|
case 0x09: abortMsg = "Transfer aborted: not enough players."; break;
|
|
|
|
|
|
case 0x0C: abortMsg = "Transfer aborted."; break;
|
|
|
|
|
|
default: abortMsg = "Transfer aborted."; break;
|
|
|
|
|
|
}
|
2026-03-17 17:49:06 -07:00
|
|
|
|
addUIError(abortMsg);
|
2026-03-11 02:36:55 -07:00
|
|
|
|
addSystemChatMessage(abortMsg);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
|
|
|
|
|
|
// ---- Taxi / Flight Paths ----
|
|
|
|
|
|
case Opcode::SMSG_SHOWTAXINODES:
|
|
|
|
|
|
handleShowTaxiNodes(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ACTIVATETAXIREPLY:
|
|
|
|
|
|
handleActivateTaxiReply(packet);
|
|
|
|
|
|
break;
|
2026-02-20 01:13:01 -08:00
|
|
|
|
case Opcode::SMSG_STANDSTATE_UPDATE:
|
|
|
|
|
|
// Server confirms stand state change (sit/stand/sleep/kneel)
|
Implement SMSG_STANDSTATE_UPDATE and SMSG_ITEM_PUSH_RESULT handlers
SMSG_STANDSTATE_UPDATE:
- Parse uint8 stand state from server confirmation packet
- Store in standState_ member (0=stand, 7=dead, 8=kneel, etc.)
- Expose getStandState(), isSitting(), isDead(), isKneeling() accessors
SMSG_ITEM_PUSH_RESULT:
- Parse full WotLK 3.3.5a payload: guid, received, created, showInChat,
bagSlot, itemSlot, itemId, suffixFactor, randomPropertyId, count, totalCount
- Show "Received: <name> x<count>" chat notification when showInChat=1
- Queue item info lookup via queryItemInfo so name resolves asap
2026-03-09 12:58:52 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
standState_ = packet.readUInt8();
|
|
|
|
|
|
LOG_INFO("Stand state updated: ", static_cast<int>(standState_),
|
|
|
|
|
|
" (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit"
|
|
|
|
|
|
: standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")");
|
2026-03-10 09:46:46 -07:00
|
|
|
|
if (standStateCallback_) {
|
|
|
|
|
|
standStateCallback_(standState_);
|
|
|
|
|
|
}
|
Implement SMSG_STANDSTATE_UPDATE and SMSG_ITEM_PUSH_RESULT handlers
SMSG_STANDSTATE_UPDATE:
- Parse uint8 stand state from server confirmation packet
- Store in standState_ member (0=stand, 7=dead, 8=kneel, etc.)
- Expose getStandState(), isSitting(), isDead(), isKneeling() accessors
SMSG_ITEM_PUSH_RESULT:
- Parse full WotLK 3.3.5a payload: guid, received, created, showInChat,
bagSlot, itemSlot, itemId, suffixFactor, randomPropertyId, count, totalCount
- Show "Received: <name> x<count>" chat notification when showInChat=1
- Queue item info lookup via queryItemInfo so name resolves asap
2026-03-09 12:58:52 -07:00
|
|
|
|
}
|
2026-02-20 01:13:01 -08:00
|
|
|
|
break;
|
2026-02-07 17:59:40 -08:00
|
|
|
|
case Opcode::SMSG_NEW_TAXI_PATH:
|
|
|
|
|
|
// Empty packet - server signals a new flight path was learned
|
|
|
|
|
|
// The actual node details come in the next SMSG_SHOWTAXINODES
|
|
|
|
|
|
addSystemChatMessage("New flight path discovered!");
|
|
|
|
|
|
break;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
// ---- Arena / Battleground ----
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_STATUS:
|
|
|
|
|
|
handleBattlefieldStatus(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_LIST:
|
2026-03-12 21:54:48 -07:00
|
|
|
|
handleBattlefieldList(packet);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_PORT_DENIED:
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError("Battlefield port denied.");
|
2026-02-07 23:47:43 -08:00
|
|
|
|
addSystemChatMessage("Battlefield port denied.");
|
|
|
|
|
|
break;
|
2026-03-17 20:54:59 -07:00
|
|
|
|
case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: {
|
|
|
|
|
|
bgPlayerPositions_.clear();
|
|
|
|
|
|
for (int grp = 0; grp < 2; ++grp) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 16; ++i) {
|
|
|
|
|
|
BgPlayerPosition pos;
|
|
|
|
|
|
pos.guid = packet.readUInt64();
|
|
|
|
|
|
pos.wowX = packet.readFloat();
|
|
|
|
|
|
pos.wowY = packet.readFloat();
|
|
|
|
|
|
pos.group = grp;
|
|
|
|
|
|
bgPlayerPositions_.push_back(pos);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-17 20:54:59 -07:00
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE:
|
|
|
|
|
|
addSystemChatMessage("You have been removed from the PvP queue.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_JOINED_BATTLEGROUND:
|
|
|
|
|
|
addSystemChatMessage("Your group has joined the battleground.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE:
|
|
|
|
|
|
addSystemChatMessage("You have joined the battleground queue.");
|
|
|
|
|
|
break;
|
2026-03-12 21:08:40 -07:00
|
|
|
|
case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: {
|
|
|
|
|
|
// SMSG_BATTLEGROUND_PLAYER_JOINED: uint64 guid
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
auto it = playerNameCache.find(guid);
|
|
|
|
|
|
std::string name = (it != playerNameCache.end()) ? it->second : "";
|
|
|
|
|
|
if (!name.empty())
|
|
|
|
|
|
addSystemChatMessage(name + " has entered the battleground.");
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEGROUND_PLAYER_JOINED: guid=0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
2026-03-12 21:08:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: {
|
|
|
|
|
|
// SMSG_BATTLEGROUND_PLAYER_LEFT: uint64 guid
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
auto it = playerNameCache.find(guid);
|
|
|
|
|
|
std::string name = (it != playerNameCache.end()) ? it->second : "";
|
|
|
|
|
|
if (!name.empty())
|
|
|
|
|
|
addSystemChatMessage(name + " has left the battleground.");
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEGROUND_PLAYER_LEFT: guid=0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
2026-03-12 21:08:40 -07:00
|
|
|
|
}
|
2026-02-26 17:56:11 -08:00
|
|
|
|
case Opcode::SMSG_INSTANCE_DIFFICULTY:
|
2026-03-11 01:48:18 -07:00
|
|
|
|
case Opcode::MSG_SET_DUNGEON_DIFFICULTY:
|
2026-02-26 17:56:11 -08:00
|
|
|
|
handleInstanceDifficulty(packet);
|
|
|
|
|
|
break;
|
2026-03-09 13:38:19 -07:00
|
|
|
|
case Opcode::SMSG_INSTANCE_SAVE_CREATED:
|
|
|
|
|
|
// Zero-payload: your instance save was just created on the server.
|
|
|
|
|
|
addSystemChatMessage("You are now saved to this instance.");
|
|
|
|
|
|
LOG_INFO("SMSG_INSTANCE_SAVE_CREATED");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_RAID_INSTANCE_MESSAGE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
uint32_t msgType = packet.readUInt32();
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t diff =*/ packet.readUInt32();
|
2026-03-13 07:10:10 -07:00
|
|
|
|
std::string mapLabel = getMapName(mapId);
|
|
|
|
|
|
if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId);
|
2026-03-09 13:38:19 -07:00
|
|
|
|
// type: 1=warning(time left), 2=saved, 3=welcome
|
|
|
|
|
|
if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t timeLeft = packet.readUInt32();
|
|
|
|
|
|
uint32_t minutes = timeLeft / 60;
|
2026-03-13 07:10:10 -07:00
|
|
|
|
addSystemChatMessage(mapLabel + " will reset in " +
|
|
|
|
|
|
std::to_string(minutes) + " minute(s).");
|
2026-03-09 13:38:19 -07:00
|
|
|
|
} else if (msgType == 2) {
|
2026-03-13 07:10:10 -07:00
|
|
|
|
addSystemChatMessage("You have been saved to " + mapLabel + ".");
|
2026-03-09 13:38:19 -07:00
|
|
|
|
} else if (msgType == 3) {
|
2026-03-13 07:10:10 -07:00
|
|
|
|
addSystemChatMessage("Welcome to " + mapLabel + ".");
|
2026-03-09 13:38:19 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_RAID_INSTANCE_MESSAGE: type=", msgType, " map=", mapId);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_INSTANCE_RESET: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
// Remove matching lockout from local cache
|
|
|
|
|
|
auto it = std::remove_if(instanceLockouts_.begin(), instanceLockouts_.end(),
|
|
|
|
|
|
[mapId](const InstanceLockout& lo){ return lo.mapId == mapId; });
|
|
|
|
|
|
instanceLockouts_.erase(it, instanceLockouts_.end());
|
2026-03-13 07:10:10 -07:00
|
|
|
|
std::string mapLabel = getMapName(mapId);
|
|
|
|
|
|
if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId);
|
|
|
|
|
|
addSystemChatMessage(mapLabel + " has been reset.");
|
2026-03-09 13:38:19 -07:00
|
|
|
|
LOG_INFO("SMSG_INSTANCE_RESET: mapId=", mapId);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_INSTANCE_RESET_FAILED: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
uint32_t reason = packet.readUInt32();
|
|
|
|
|
|
static const char* resetFailReasons[] = {
|
|
|
|
|
|
"Not max level.", "Offline party members.", "Party members inside.",
|
|
|
|
|
|
"Party members changing zone.", "Heroic difficulty only."
|
|
|
|
|
|
};
|
2026-03-13 07:10:10 -07:00
|
|
|
|
const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason.";
|
|
|
|
|
|
std::string mapLabel = getMapName(mapId);
|
|
|
|
|
|
if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId);
|
2026-03-17 17:39:02 -07:00
|
|
|
|
addUIError("Cannot reset " + mapLabel + ": " + reasonMsg);
|
2026-03-13 07:10:10 -07:00
|
|
|
|
addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg);
|
2026-03-09 13:38:19 -07:00
|
|
|
|
LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_INSTANCE_LOCK_WARNING_QUERY: {
|
|
|
|
|
|
// Server asks player to confirm entering a saved instance.
|
|
|
|
|
|
// We auto-confirm with CMSG_INSTANCE_LOCK_RESPONSE.
|
|
|
|
|
|
if (socket && packet.getSize() - packet.getReadPos() >= 17) {
|
2026-03-13 07:20:58 -07:00
|
|
|
|
uint32_t ilMapId = packet.readUInt32();
|
|
|
|
|
|
uint32_t ilDiff = packet.readUInt32();
|
|
|
|
|
|
uint32_t ilTimeLeft = packet.readUInt32();
|
2026-03-09 13:38:19 -07:00
|
|
|
|
packet.readUInt32(); // unk
|
2026-03-13 07:20:58 -07:00
|
|
|
|
uint8_t ilLocked = packet.readUInt8();
|
|
|
|
|
|
// Notify player which instance is being entered/resumed
|
|
|
|
|
|
std::string ilName = getMapName(ilMapId);
|
|
|
|
|
|
if (ilName.empty()) ilName = "instance #" + std::to_string(ilMapId);
|
|
|
|
|
|
static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"};
|
|
|
|
|
|
std::string ilMsg = "Entering " + ilName;
|
|
|
|
|
|
if (ilDiff < 4) ilMsg += std::string(" (") + kDiff[ilDiff] + ")";
|
|
|
|
|
|
if (ilLocked && ilTimeLeft > 0) {
|
|
|
|
|
|
uint32_t ilMins = ilTimeLeft / 60;
|
|
|
|
|
|
ilMsg += " — " + std::to_string(ilMins) + " min remaining.";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ilMsg += ".";
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(ilMsg);
|
2026-03-09 13:38:19 -07:00
|
|
|
|
// Send acceptance
|
|
|
|
|
|
network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE));
|
|
|
|
|
|
resp.writeUInt8(1); // 1=accept
|
|
|
|
|
|
socket->send(resp);
|
2026-03-13 07:20:58 -07:00
|
|
|
|
LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted mapId=", ilMapId,
|
|
|
|
|
|
" diff=", ilDiff, " timeLeft=", ilTimeLeft);
|
2026-03-09 13:38:19 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- LFG / Dungeon Finder ----
|
|
|
|
|
|
case Opcode::SMSG_LFG_JOIN_RESULT:
|
|
|
|
|
|
handleLfgJoinResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_QUEUE_STATUS:
|
|
|
|
|
|
handleLfgQueueStatus(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_PROPOSAL_UPDATE:
|
|
|
|
|
|
handleLfgProposalUpdate(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_ROLE_CHECK_UPDATE:
|
|
|
|
|
|
handleLfgRoleCheckUpdate(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_UPDATE_PLAYER:
|
|
|
|
|
|
case Opcode::SMSG_LFG_UPDATE_PARTY:
|
|
|
|
|
|
handleLfgUpdatePlayer(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_PLAYER_REWARD:
|
|
|
|
|
|
handleLfgPlayerReward(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_BOOT_PROPOSAL_UPDATE:
|
|
|
|
|
|
handleLfgBootProposalUpdate(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_TELEPORT_DENIED:
|
|
|
|
|
|
handleLfgTeleportDenied(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_DISABLED:
|
|
|
|
|
|
addSystemChatMessage("The Dungeon Finder is currently disabled.");
|
|
|
|
|
|
LOG_INFO("SMSG_LFG_DISABLED received");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_OFFER_CONTINUE:
|
|
|
|
|
|
addSystemChatMessage("Dungeon Finder: You may continue your dungeon.");
|
|
|
|
|
|
break;
|
2026-03-18 12:40:20 -07:00
|
|
|
|
case Opcode::SMSG_LFG_ROLE_CHOSEN: {
|
|
|
|
|
|
// uint64 guid + uint8 ready + uint32 roles
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 13) {
|
|
|
|
|
|
uint64_t roleGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t ready = packet.readUInt8();
|
|
|
|
|
|
uint32_t roles = packet.readUInt32();
|
|
|
|
|
|
// Build a descriptive message for group chat
|
|
|
|
|
|
std::string roleName;
|
|
|
|
|
|
if (roles & 0x02) roleName += "Tank ";
|
|
|
|
|
|
if (roles & 0x04) roleName += "Healer ";
|
|
|
|
|
|
if (roles & 0x08) roleName += "DPS ";
|
|
|
|
|
|
if (roleName.empty()) roleName = "None";
|
|
|
|
|
|
// Find player name
|
|
|
|
|
|
std::string pName = "A player";
|
|
|
|
|
|
if (auto e = entityManager.getEntity(roleGuid))
|
|
|
|
|
|
if (auto u = std::dynamic_pointer_cast<Unit>(e))
|
|
|
|
|
|
pName = u->getName();
|
|
|
|
|
|
if (ready)
|
|
|
|
|
|
addSystemChatMessage(pName + " has chosen: " + roleName);
|
|
|
|
|
|
LOG_DEBUG("SMSG_LFG_ROLE_CHOSEN: guid=", roleGuid,
|
|
|
|
|
|
" ready=", (int)ready, " roles=", roles);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 13:30:23 -07:00
|
|
|
|
case Opcode::SMSG_LFG_UPDATE_SEARCH:
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_LFG_LIST:
|
|
|
|
|
|
case Opcode::SMSG_LFG_PLAYER_INFO:
|
|
|
|
|
|
case Opcode::SMSG_LFG_PARTY_INFO:
|
|
|
|
|
|
// Informational LFG packets not yet surfaced in UI — consume silently.
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-12 21:24:42 -07:00
|
|
|
|
case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER:
|
|
|
|
|
|
// Server requests client to open the dungeon finder UI
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); // consume any payload
|
|
|
|
|
|
if (openLfgCallback_) openLfgCallback_();
|
|
|
|
|
|
break;
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT:
|
|
|
|
|
|
handleArenaTeamCommandResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_QUERY_RESPONSE:
|
|
|
|
|
|
handleArenaTeamQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_ROSTER:
|
2026-03-12 21:01:51 -07:00
|
|
|
|
handleArenaTeamRoster(packet);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_INVITE:
|
|
|
|
|
|
handleArenaTeamInvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_EVENT:
|
|
|
|
|
|
handleArenaTeamEvent(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_STATS:
|
2026-03-12 02:35:29 -07:00
|
|
|
|
handleArenaTeamStats(packet);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_ERROR:
|
|
|
|
|
|
handleArenaError(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::MSG_PVP_LOG_DATA:
|
2026-03-12 12:02:59 -07:00
|
|
|
|
handlePvpLogData(packet);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
2026-03-12 21:27:02 -07:00
|
|
|
|
case Opcode::MSG_INSPECT_ARENA_TEAMS: {
|
|
|
|
|
|
// WotLK: uint64 playerGuid + uint8 teamCount + per-team fields
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t inspGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t teamCount = packet.readUInt8();
|
|
|
|
|
|
if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5
|
|
|
|
|
|
if (inspGuid == inspectResult_.guid || inspectResult_.guid == 0) {
|
|
|
|
|
|
inspectResult_.guid = inspGuid;
|
|
|
|
|
|
inspectResult_.arenaTeams.clear();
|
|
|
|
|
|
for (uint8_t t = 0; t < teamCount; ++t) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 21) break;
|
|
|
|
|
|
InspectArenaTeam team;
|
|
|
|
|
|
team.teamId = packet.readUInt32();
|
|
|
|
|
|
team.type = packet.readUInt8();
|
|
|
|
|
|
team.weekGames = packet.readUInt32();
|
|
|
|
|
|
team.weekWins = packet.readUInt32();
|
|
|
|
|
|
team.seasonGames = packet.readUInt32();
|
|
|
|
|
|
team.seasonWins = packet.readUInt32();
|
|
|
|
|
|
team.name = packet.readString();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
team.personalRating = packet.readUInt32();
|
|
|
|
|
|
inspectResult_.arenaTeams.push_back(std::move(team));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec,
|
|
|
|
|
|
" teams=", (int)teamCount);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
break;
|
2026-03-12 21:27:02 -07:00
|
|
|
|
}
|
2026-03-10 12:53:05 -07:00
|
|
|
|
case Opcode::MSG_TALENT_WIPE_CONFIRM: {
|
|
|
|
|
|
// Server sends: uint64 npcGuid + uint32 cost
|
|
|
|
|
|
// Client must respond with the same opcode containing uint64 npcGuid to confirm.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
talentWipeNpcGuid_ = packet.readUInt64();
|
|
|
|
|
|
talentWipeCost_ = packet.readUInt32();
|
|
|
|
|
|
talentWipePending_ = true;
|
|
|
|
|
|
LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_,
|
|
|
|
|
|
std::dec, " cost=", talentWipeCost_);
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-10 12:53:05 -07:00
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// ---- MSG_MOVE_* opcodes (server relays other players' movement) ----
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_FORWARD:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_BACKWARD:
|
|
|
|
|
|
case Opcode::MSG_MOVE_STOP:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_STRAFE_LEFT:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_STRAFE_RIGHT:
|
|
|
|
|
|
case Opcode::MSG_MOVE_STOP_STRAFE:
|
|
|
|
|
|
case Opcode::MSG_MOVE_JUMP:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_TURN_LEFT:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_TURN_RIGHT:
|
|
|
|
|
|
case Opcode::MSG_MOVE_STOP_TURN:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_FACING:
|
|
|
|
|
|
case Opcode::MSG_MOVE_FALL_LAND:
|
|
|
|
|
|
case Opcode::MSG_MOVE_HEARTBEAT:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_SWIM:
|
|
|
|
|
|
case Opcode::MSG_MOVE_STOP_SWIM:
|
2026-03-10 11:24:15 -07:00
|
|
|
|
case Opcode::MSG_MOVE_SET_WALK_MODE:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_RUN_MODE:
|
2026-03-10 11:29:13 -07:00
|
|
|
|
case Opcode::MSG_MOVE_START_PITCH_UP:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_PITCH_DOWN:
|
|
|
|
|
|
case Opcode::MSG_MOVE_STOP_PITCH:
|
|
|
|
|
|
case Opcode::MSG_MOVE_START_ASCEND:
|
|
|
|
|
|
case Opcode::MSG_MOVE_STOP_ASCEND:
|
2026-03-10 11:30:55 -07:00
|
|
|
|
case Opcode::MSG_MOVE_START_DESCEND:
|
2026-03-10 11:44:57 -07:00
|
|
|
|
case Opcode::MSG_MOVE_SET_PITCH:
|
|
|
|
|
|
case Opcode::MSG_MOVE_GRAVITY_CHNG:
|
|
|
|
|
|
case Opcode::MSG_MOVE_UPDATE_CAN_FLY:
|
|
|
|
|
|
case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY:
|
2026-03-10 11:54:15 -07:00
|
|
|
|
case Opcode::MSG_MOVE_ROOT:
|
|
|
|
|
|
case Opcode::MSG_MOVE_UNROOT:
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleOtherPlayerMovement(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-13 03:13:29 -07:00
|
|
|
|
// ---- Broadcast speed changes (server→client, no ACK) ----
|
|
|
|
|
|
// Format: PackedGuid (mover) + MovementInfo (variable) + float speed
|
|
|
|
|
|
// MovementInfo is complex (optional transport/fall/spline blocks based on flags).
|
|
|
|
|
|
// We consume the packet to suppress "Unhandled world opcode" warnings.
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_RUN_SPEED:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_RUN_BACK_SPEED:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_WALK_SPEED:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_SWIM_SPEED:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_FLIGHT_SPEED:
|
|
|
|
|
|
case Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleMoveSetSpeed(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
|
// ---- Mail ----
|
|
|
|
|
|
case Opcode::SMSG_SHOW_MAILBOX:
|
|
|
|
|
|
handleShowMailbox(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MAIL_LIST_RESULT:
|
|
|
|
|
|
handleMailListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SEND_MAIL_RESULT:
|
|
|
|
|
|
handleSendMailResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_RECEIVED_MAIL:
|
|
|
|
|
|
handleReceivedMail(packet);
|
|
|
|
|
|
break;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
case Opcode::MSG_QUERY_NEXT_MAIL_TIME:
|
|
|
|
|
|
handleQueryNextMailTime(packet);
|
|
|
|
|
|
break;
|
2026-03-12 02:17:49 -07:00
|
|
|
|
case Opcode::SMSG_CHANNEL_LIST: {
|
|
|
|
|
|
// string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags)
|
|
|
|
|
|
std::string chanName = packet.readString();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
/*uint8_t chanFlags =*/ packet.readUInt8();
|
|
|
|
|
|
uint32_t memberCount = packet.readUInt32();
|
|
|
|
|
|
memberCount = std::min(memberCount, 200u);
|
|
|
|
|
|
addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):");
|
|
|
|
|
|
for (uint32_t i = 0; i < memberCount; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) break;
|
|
|
|
|
|
uint64_t memberGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t memberFlags = packet.readUInt8();
|
2026-03-13 07:20:58 -07:00
|
|
|
|
// Look up the name: entity manager > playerNameCache
|
2026-03-12 02:17:49 -07:00
|
|
|
|
auto entity = entityManager.getEntity(memberGuid);
|
2026-03-13 07:20:58 -07:00
|
|
|
|
std::string name;
|
2026-03-12 02:17:49 -07:00
|
|
|
|
if (entity) {
|
|
|
|
|
|
auto player = std::dynamic_pointer_cast<Player>(entity);
|
|
|
|
|
|
if (player && !player->getName().empty()) name = player->getName();
|
|
|
|
|
|
}
|
2026-03-13 07:20:58 -07:00
|
|
|
|
if (name.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(memberGuid);
|
|
|
|
|
|
if (nit != playerNameCache.end()) name = nit->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (name.empty()) name = "(unknown)";
|
2026-03-12 02:17:49 -07:00
|
|
|
|
std::string entry = " " + name;
|
|
|
|
|
|
if (memberFlags & 0x01) entry += " [Moderator]";
|
|
|
|
|
|
if (memberFlags & 0x02) entry += " [Muted]";
|
|
|
|
|
|
addSystemChatMessage(entry);
|
|
|
|
|
|
LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec,
|
|
|
|
|
|
" flags=", (int)memberFlags, " name=", name);
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-12 02:17:49 -07:00
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::SMSG_INSPECT_RESULTS_UPDATE:
|
|
|
|
|
|
handleInspectResults(packet);
|
|
|
|
|
|
break;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
// ---- Bank ----
|
|
|
|
|
|
case Opcode::SMSG_SHOW_BANK:
|
|
|
|
|
|
handleShowBank(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BUY_BANK_SLOT_RESULT:
|
|
|
|
|
|
handleBuyBankSlotResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Guild Bank ----
|
|
|
|
|
|
case Opcode::SMSG_GUILD_BANK_LIST:
|
|
|
|
|
|
handleGuildBankList(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Auction House ----
|
|
|
|
|
|
case Opcode::MSG_AUCTION_HELLO:
|
|
|
|
|
|
handleAuctionHello(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_LIST_RESULT:
|
|
|
|
|
|
handleAuctionListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_OWNER_LIST_RESULT:
|
|
|
|
|
|
handleAuctionOwnerListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_BIDDER_LIST_RESULT:
|
|
|
|
|
|
handleAuctionBidderListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_COMMAND_RESULT:
|
|
|
|
|
|
handleAuctionCommandResult(packet);
|
|
|
|
|
|
break;
|
2026-02-25 14:44:44 -08:00
|
|
|
|
case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: {
|
2026-03-20 21:02:12 -07:00
|
|
|
|
// auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ...
|
2026-03-13 07:27:01 -07:00
|
|
|
|
// action: 0=sold/won, 1=expired, 2=bid placed on your auction
|
2026-02-25 14:44:44 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 16) {
|
2026-03-13 07:27:01 -07:00
|
|
|
|
/*uint32_t auctionId =*/ packet.readUInt32();
|
|
|
|
|
|
uint32_t action = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t error =*/ packet.readUInt32();
|
2026-02-25 14:44:44 -08:00
|
|
|
|
uint32_t itemEntry = packet.readUInt32();
|
2026-03-20 21:02:12 -07:00
|
|
|
|
int32_t ownerRandProp = 0;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4)
|
|
|
|
|
|
ownerRandProp = static_cast<int32_t>(packet.readUInt32());
|
2026-02-25 14:44:44 -08:00
|
|
|
|
ensureItemInfo(itemEntry);
|
|
|
|
|
|
auto* info = getItemInfo(itemEntry);
|
2026-03-17 13:25:33 -07:00
|
|
|
|
std::string rawName = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry));
|
2026-03-20 21:02:12 -07:00
|
|
|
|
if (ownerRandProp != 0) {
|
|
|
|
|
|
std::string suffix = getRandomPropertyName(ownerRandProp);
|
|
|
|
|
|
if (!suffix.empty()) rawName += " " + suffix;
|
|
|
|
|
|
}
|
2026-03-17 13:25:33 -07:00
|
|
|
|
uint32_t aucQuality = info ? info->quality : 1u;
|
|
|
|
|
|
std::string itemLink = buildItemLink(itemEntry, aucQuality, rawName);
|
2026-03-13 07:27:01 -07:00
|
|
|
|
if (action == 1)
|
2026-03-17 13:25:33 -07:00
|
|
|
|
addSystemChatMessage("Your auction of " + itemLink + " has expired.");
|
2026-03-13 07:27:01 -07:00
|
|
|
|
else if (action == 2)
|
2026-03-17 13:25:33 -07:00
|
|
|
|
addSystemChatMessage("A bid has been placed on your auction of " + itemLink + ".");
|
2026-03-13 07:27:01 -07:00
|
|
|
|
else
|
2026-03-17 13:25:33 -07:00
|
|
|
|
addSystemChatMessage("Your auction of " + itemLink + " has sold!");
|
2026-02-25 14:44:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: {
|
2026-03-20 20:57:23 -07:00
|
|
|
|
// auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32)
|
2026-02-25 14:44:44 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
2026-03-20 20:57:23 -07:00
|
|
|
|
/*uint32_t auctionId =*/ packet.readUInt32();
|
2026-02-25 14:44:44 -08:00
|
|
|
|
uint32_t itemEntry = packet.readUInt32();
|
2026-03-20 20:57:23 -07:00
|
|
|
|
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());
|
2026-02-25 14:44:44 -08:00
|
|
|
|
ensureItemInfo(itemEntry);
|
|
|
|
|
|
auto* info = getItemInfo(itemEntry);
|
2026-03-17 13:25:33 -07:00
|
|
|
|
std::string rawName2 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry));
|
2026-03-20 20:57:23 -07:00
|
|
|
|
if (bidRandProp != 0) {
|
|
|
|
|
|
std::string suffix = getRandomPropertyName(bidRandProp);
|
|
|
|
|
|
if (!suffix.empty()) rawName2 += " " + suffix;
|
|
|
|
|
|
}
|
2026-03-17 13:25:33 -07:00
|
|
|
|
uint32_t bidQuality = info ? info->quality : 1u;
|
|
|
|
|
|
std::string bidLink = buildItemLink(itemEntry, bidQuality, rawName2);
|
|
|
|
|
|
addSystemChatMessage("You have been outbid on " + bidLink + ".");
|
2026-02-25 14:44:44 -08:00
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-02-25 14:44:44 -08:00
|
|
|
|
}
|
2026-03-09 15:12:34 -07:00
|
|
|
|
case Opcode::SMSG_AUCTION_REMOVED_NOTIFICATION: {
|
|
|
|
|
|
// uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
/*uint32_t auctionId =*/ packet.readUInt32();
|
|
|
|
|
|
uint32_t itemEntry = packet.readUInt32();
|
2026-03-20 20:57:23 -07:00
|
|
|
|
int32_t itemRandom = static_cast<int32_t>(packet.readUInt32());
|
2026-03-09 15:12:34 -07:00
|
|
|
|
ensureItemInfo(itemEntry);
|
|
|
|
|
|
auto* info = getItemInfo(itemEntry);
|
2026-03-17 13:25:33 -07:00
|
|
|
|
std::string rawName3 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry));
|
2026-03-20 20:57:23 -07:00
|
|
|
|
if (itemRandom != 0) {
|
|
|
|
|
|
std::string suffix = getRandomPropertyName(itemRandom);
|
|
|
|
|
|
if (!suffix.empty()) rawName3 += " " + suffix;
|
|
|
|
|
|
}
|
2026-03-17 13:25:33 -07:00
|
|
|
|
uint32_t remQuality = info ? info->quality : 1u;
|
|
|
|
|
|
std::string remLink = buildItemLink(itemEntry, remQuality, rawName3);
|
|
|
|
|
|
addSystemChatMessage("Your auction of " + remLink + " has expired.");
|
2026-03-09 15:12:34 -07:00
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_OPEN_CONTAINER: {
|
|
|
|
|
|
// uint64 containerGuid — tells client to open this container
|
|
|
|
|
|
// The actual items come via update packets; we just log this.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t containerGuid = packet.readUInt64();
|
|
|
|
|
|
LOG_DEBUG("SMSG_OPEN_CONTAINER: guid=0x", std::hex, containerGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_GM_TICKET_STATUS_UPDATE:
|
|
|
|
|
|
// GM ticket status (new/updated); no ticket UI yet
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-12 17:25:00 -07:00
|
|
|
|
case Opcode::SMSG_PLAYER_VEHICLE_DATA: {
|
|
|
|
|
|
// PackedGuid (player guid) + uint32 vehicleId
|
|
|
|
|
|
// vehicleId == 0 means the player left the vehicle
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
(void)UpdateObjectParser::readPackedGuid(packet); // player guid (unused)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
vehicleId_ = packet.readUInt32();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
vehicleId_ = 0;
|
|
|
|
|
|
}
|
2026-03-09 15:12:34 -07:00
|
|
|
|
break;
|
2026-03-12 17:25:00 -07:00
|
|
|
|
}
|
2026-03-09 15:12:34 -07:00
|
|
|
|
case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 19:30:18 -07:00
|
|
|
|
case Opcode::SMSG_TAXINODE_STATUS: {
|
|
|
|
|
|
// guid(8) + status(1): status 1 = NPC has available/new routes for this player
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 9) {
|
|
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t status = packet.readUInt8();
|
|
|
|
|
|
taxiNpcHasRoutes_[npcGuid] = (status != 0);
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
break;
|
2026-03-09 19:30:18 -07:00
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE:
|
2026-03-09 22:20:47 -07:00
|
|
|
|
case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: {
|
|
|
|
|
|
// TBC 2.4.3 aura tracking: replaces SMSG_AURA_UPDATE which doesn't exist in TBC.
|
|
|
|
|
|
// Format: uint64 targetGuid + uint8 count + N×{uint8 slot, uint32 spellId,
|
|
|
|
|
|
// uint8 effectIndex, uint8 flags, uint32 durationMs, uint32 maxDurationMs}
|
|
|
|
|
|
const bool isInit = (*logicalOp == Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE);
|
|
|
|
|
|
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
|
|
|
|
|
|
if (remaining() < 9) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
uint64_t auraTargetGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t count = packet.readUInt8();
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<AuraSlot>* auraList = nullptr;
|
|
|
|
|
|
if (auraTargetGuid == playerGuid) auraList = &playerAuras;
|
|
|
|
|
|
else if (auraTargetGuid == targetGuid) auraList = &targetAuras;
|
2026-03-12 12:02:59 -07:00
|
|
|
|
else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid];
|
2026-03-09 22:20:47 -07:00
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
|
2026-03-11 03:49:54 -07:00
|
|
|
|
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
|
2026-03-09 22:20:47 -07:00
|
|
|
|
|
|
|
|
|
|
if (auraList) {
|
|
|
|
|
|
while (auraList->size() <= slot) auraList->push_back(AuraSlot{});
|
|
|
|
|
|
AuraSlot& a = (*auraList)[slot];
|
|
|
|
|
|
a.spellId = spellId;
|
2026-03-12 21:39:22 -07:00
|
|
|
|
// 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;
|
2026-03-09 22:20:47 -07:00
|
|
|
|
a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast<int32_t>(durationMs);
|
|
|
|
|
|
a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast<int32_t>(maxDurMs);
|
|
|
|
|
|
a.receivedAtMs = nowMs;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 22:20:47 -07:00
|
|
|
|
}
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_WORLDPORT_ACK:
|
|
|
|
|
|
// Client uses this outbound; treat inbound variant as no-op for robustness.
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::MSG_MOVE_TIME_SKIPPED:
|
2026-02-18 23:42:28 -08:00
|
|
|
|
// Observed custom server packet (8 bytes). Safe-consume for now.
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
|
2026-03-09 15:23:02 -07:00
|
|
|
|
// ---- Logout cancel ACK ----
|
|
|
|
|
|
case Opcode::SMSG_LOGOUT_CANCEL_ACK:
|
|
|
|
|
|
// loggingOut_ already cleared by cancelLogout(); this is server's confirmation
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Guild decline ----
|
|
|
|
|
|
case Opcode::SMSG_GUILD_DECLINE: {
|
|
|
|
|
|
if (packet.getReadPos() < packet.getSize()) {
|
|
|
|
|
|
std::string name = packet.readString();
|
|
|
|
|
|
addSystemChatMessage(name + " declined your guild invitation.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Talents involuntarily reset ----
|
|
|
|
|
|
case Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET:
|
2026-03-17 13:50:49 -07:00
|
|
|
|
// Clear cached talent data so the talent screen reflects the reset.
|
|
|
|
|
|
learnedTalents_[0].clear();
|
|
|
|
|
|
learnedTalents_[1].clear();
|
2026-03-17 17:45:45 -07:00
|
|
|
|
addUIError("Your talents have been reset by the server.");
|
2026-03-09 15:23:02 -07:00
|
|
|
|
addSystemChatMessage("Your talents have been reset by the server.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Account data sync ----
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_ACCOUNT_DATA:
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Rest state ----
|
|
|
|
|
|
case Opcode::SMSG_SET_REST_START: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t restTrigger = packet.readUInt32();
|
2026-03-10 07:35:30 -07:00
|
|
|
|
isResting_ = (restTrigger > 0);
|
|
|
|
|
|
addSystemChatMessage(isResting_ ? "You are now resting."
|
|
|
|
|
|
: "You are no longer resting.");
|
2026-03-21 03:27:09 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_UPDATE_RESTING", {});
|
2026-03-09 15:23:02 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Aura duration update ----
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_AURA_DURATION: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 5) {
|
|
|
|
|
|
uint8_t slot = packet.readUInt8();
|
|
|
|
|
|
uint32_t durationMs = packet.readUInt32();
|
|
|
|
|
|
handleUpdateAuraDuration(slot, durationMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Item name query response ----
|
|
|
|
|
|
case Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
|
|
|
|
|
std::string name = packet.readString();
|
|
|
|
|
|
if (!itemInfoCache_.count(itemId) && !name.empty()) {
|
|
|
|
|
|
ItemQueryResponseData stub;
|
|
|
|
|
|
stub.entry = itemId;
|
|
|
|
|
|
stub.name = std::move(name);
|
|
|
|
|
|
stub.valid = true;
|
|
|
|
|
|
itemInfoCache_[itemId] = std::move(stub);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Mount special animation ----
|
|
|
|
|
|
case Opcode::SMSG_MOUNTSPECIAL_ANIM:
|
|
|
|
|
|
(void)UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Character customisation / faction change results ----
|
|
|
|
|
|
case Opcode::SMSG_CHAR_CUSTOMIZE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
addSystemChatMessage(result == 0 ? "Character customization complete."
|
|
|
|
|
|
: "Character customization failed.");
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CHAR_FACTION_CHANGE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
addSystemChatMessage(result == 0 ? "Faction change complete."
|
|
|
|
|
|
: "Faction change failed.");
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Invalidate cached player data ----
|
|
|
|
|
|
case Opcode::SMSG_INVALIDATE_PLAYER: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
playerNameCache.erase(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Movie trigger ----
|
2026-03-20 04:53:54 -07:00
|
|
|
|
case Opcode::SMSG_TRIGGER_MOVIE: {
|
|
|
|
|
|
// uint32 movieId — we don't play movies; acknowledge immediately.
|
2026-03-09 15:23:02 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-03-20 04:53:54 -07:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
break;
|
2026-03-20 04:53:54 -07:00
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Equipment sets ----
|
|
|
|
|
|
case Opcode::SMSG_EQUIPMENT_SET_LIST:
|
|
|
|
|
|
handleEquipmentSetList(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
2026-03-17 17:45:45 -07:00
|
|
|
|
if (result != 0) { addUIError("Failed to equip item set."); addSystemChatMessage("Failed to equip item set."); }
|
2026-03-09 15:23:02 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- LFG informational (not yet surfaced in UI) ----
|
|
|
|
|
|
case Opcode::SMSG_LFG_UPDATE:
|
|
|
|
|
|
case Opcode::SMSG_LFG_UPDATE_LFG:
|
|
|
|
|
|
case Opcode::SMSG_LFG_UPDATE_LFM:
|
|
|
|
|
|
case Opcode::SMSG_LFG_UPDATE_QUEUED:
|
|
|
|
|
|
case Opcode::SMSG_LFG_PENDING_INVITE:
|
|
|
|
|
|
case Opcode::SMSG_LFG_PENDING_MATCH:
|
|
|
|
|
|
case Opcode::SMSG_LFG_PENDING_MATCH_DONE:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 22:07:03 -07:00
|
|
|
|
// ---- LFG error/timeout states ----
|
|
|
|
|
|
case Opcode::SMSG_LFG_TIMEDOUT:
|
|
|
|
|
|
// Server-side LFG invite timed out (no response within time limit)
|
|
|
|
|
|
addSystemChatMessage("Dungeon Finder: Invite timed out.");
|
|
|
|
|
|
if (openLfgCallback_) openLfgCallback_();
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_OTHER_TIMEDOUT:
|
|
|
|
|
|
// Another party member failed to respond to a LFG role-check in time
|
|
|
|
|
|
addSystemChatMessage("Dungeon Finder: Another player's invite timed out.");
|
|
|
|
|
|
if (openLfgCallback_) openLfgCallback_();
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_AUTOJOIN_FAILED: {
|
|
|
|
|
|
// uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
(void)result;
|
|
|
|
|
|
}
|
2026-03-17 18:08:27 -07:00
|
|
|
|
addUIError("Dungeon Finder: Auto-join failed.");
|
2026-03-12 22:07:03 -07:00
|
|
|
|
addSystemChatMessage("Dungeon Finder: Auto-join failed.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER:
|
|
|
|
|
|
// No eligible players found for auto-join
|
2026-03-17 18:08:27 -07:00
|
|
|
|
addUIError("Dungeon Finder: No players available for auto-join.");
|
2026-03-12 22:07:03 -07:00
|
|
|
|
addSystemChatMessage("Dungeon Finder: No players available for auto-join.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LFG_LEADER_IS_LFM:
|
|
|
|
|
|
// Party leader is currently set to Looking for More (LFM) mode
|
|
|
|
|
|
addSystemChatMessage("Your party leader is currently Looking for More.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Meeting stone (Classic/TBC group-finding via summon stone) ----
|
|
|
|
|
|
case Opcode::SMSG_MEETINGSTONE_SETQUEUE: {
|
|
|
|
|
|
// uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 6) {
|
|
|
|
|
|
uint32_t zoneId = packet.readUInt32();
|
|
|
|
|
|
uint8_t levelMin = packet.readUInt8();
|
|
|
|
|
|
uint8_t levelMax = packet.readUInt8();
|
|
|
|
|
|
char buf[128];
|
2026-03-13 07:54:02 -07:00
|
|
|
|
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);
|
2026-03-12 22:07:03 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId,
|
|
|
|
|
|
" levels=", (int)levelMin, "-", (int)levelMax);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_MEETINGSTONE_COMPLETE:
|
|
|
|
|
|
// Server confirms group found and teleport summon is ready
|
|
|
|
|
|
addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon.");
|
|
|
|
|
|
LOG_INFO("SMSG_MEETINGSTONE_COMPLETE");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MEETINGSTONE_IN_PROGRESS:
|
|
|
|
|
|
// Meeting stone search is still ongoing
|
|
|
|
|
|
addSystemChatMessage("Meeting Stone: Searching for group members...");
|
|
|
|
|
|
LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED: {
|
|
|
|
|
|
// uint64 memberGuid — a player was added to your group via meeting stone
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t memberGuid = packet.readUInt64();
|
|
|
|
|
|
auto nit = playerNameCache.find(memberGuid);
|
|
|
|
|
|
if (nit != playerNameCache.end() && !nit->second.empty()) {
|
|
|
|
|
|
addSystemChatMessage("Meeting Stone: " + nit->second +
|
|
|
|
|
|
" has been added to your group.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Meeting Stone: A new player has been added to your group.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_MEETINGSTONE_JOINFAILED: {
|
|
|
|
|
|
// uint8 reason — failed to join group via meeting stone
|
|
|
|
|
|
// 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available
|
|
|
|
|
|
static const char* kMeetingstoneErrors[] = {
|
|
|
|
|
|
"Target player is not using the Meeting Stone.",
|
|
|
|
|
|
"Target player is already in a group.",
|
|
|
|
|
|
"You are not in a valid zone for that Meeting Stone.",
|
|
|
|
|
|
"Target player is not available.",
|
|
|
|
|
|
};
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t reason = packet.readUInt8();
|
|
|
|
|
|
const char* msg = (reason < 4) ? kMeetingstoneErrors[reason]
|
|
|
|
|
|
: "Meeting Stone: Could not join group.";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", (int)reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_MEETINGSTONE_LEAVE:
|
|
|
|
|
|
// Player was removed from the meeting stone queue (left, or group disbanded)
|
|
|
|
|
|
addSystemChatMessage("You have left the Meeting Stone queue.");
|
|
|
|
|
|
LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 15:23:02 -07:00
|
|
|
|
// ---- GM Ticket responses ----
|
|
|
|
|
|
case Opcode::SMSG_GMTICKET_CREATE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t res = packet.readUInt8();
|
|
|
|
|
|
addSystemChatMessage(res == 1 ? "GM ticket submitted."
|
|
|
|
|
|
: "Failed to submit GM ticket.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_GMTICKET_UPDATETEXT: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t res = packet.readUInt8();
|
|
|
|
|
|
addSystemChatMessage(res == 1 ? "GM ticket updated."
|
|
|
|
|
|
: "Failed to update GM ticket.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_GMTICKET_DELETETICKET: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t res = packet.readUInt8();
|
|
|
|
|
|
addSystemChatMessage(res == 9 ? "GM ticket deleted."
|
|
|
|
|
|
: "No ticket to delete.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
case Opcode::SMSG_GMTICKET_GETTICKET: {
|
|
|
|
|
|
// WotLK 3.3.5a format:
|
|
|
|
|
|
// uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended
|
|
|
|
|
|
// If status == 6 (GMTICKET_STATUS_HASTEXT):
|
|
|
|
|
|
// cstring ticketText
|
|
|
|
|
|
// uint32 ticketAge (seconds old)
|
|
|
|
|
|
// uint32 daysUntilOld (days remaining before escalation)
|
|
|
|
|
|
// float waitTimeHours (estimated GM wait time)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
uint8_t gmStatus = packet.readUInt8();
|
|
|
|
|
|
// Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text
|
|
|
|
|
|
if (gmStatus == 6 && packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
gmTicketText_ = packet.readString();
|
|
|
|
|
|
uint32_t ageSec = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0;
|
|
|
|
|
|
/*uint32_t daysLeft =*/ (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0;
|
|
|
|
|
|
gmTicketWaitHours_ = (packet.getSize() - packet.getReadPos() >= 4)
|
|
|
|
|
|
? packet.readFloat() : 0.0f;
|
|
|
|
|
|
gmTicketActive_ = true;
|
|
|
|
|
|
char buf[256];
|
|
|
|
|
|
if (ageSec < 60) {
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.",
|
|
|
|
|
|
ageSec, gmTicketWaitHours_);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
uint32_t ageMin = ageSec / 60;
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.",
|
|
|
|
|
|
ageMin, gmTicketWaitHours_);
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec,
|
|
|
|
|
|
"s wait=", gmTicketWaitHours_, "h");
|
|
|
|
|
|
} else if (gmStatus == 3) {
|
|
|
|
|
|
gmTicketActive_ = false;
|
|
|
|
|
|
gmTicketText_.clear();
|
|
|
|
|
|
addSystemChatMessage("Your GM ticket has been closed.");
|
|
|
|
|
|
LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed");
|
|
|
|
|
|
} else if (gmStatus == 10) {
|
|
|
|
|
|
gmTicketActive_ = false;
|
|
|
|
|
|
gmTicketText_.clear();
|
|
|
|
|
|
addSystemChatMessage("Your GM ticket has been suspended.");
|
|
|
|
|
|
LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Status 1 = no open ticket (default/no ticket)
|
|
|
|
|
|
gmTicketActive_ = false;
|
|
|
|
|
|
gmTicketText_.clear();
|
|
|
|
|
|
LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", (int)gmStatus, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: {
|
|
|
|
|
|
// uint32 status: 1 = GM support available, 0 = offline/unavailable
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t sysStatus = packet.readUInt32();
|
|
|
|
|
|
gmSupportAvailable_ = (sysStatus != 0);
|
|
|
|
|
|
addSystemChatMessage(gmSupportAvailable_
|
|
|
|
|
|
? "GM support is currently available."
|
|
|
|
|
|
: "GM support is currently unavailable.");
|
|
|
|
|
|
LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_);
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
|
2026-03-09 18:28:03 -07:00
|
|
|
|
// ---- DK rune tracking ----
|
|
|
|
|
|
case Opcode::SMSG_CONVERT_RUNE: {
|
|
|
|
|
|
// uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint8_t idx = packet.readUInt8();
|
|
|
|
|
|
uint8_t type = packet.readUInt8();
|
|
|
|
|
|
if (idx < 6) playerRunes_[idx].type = static_cast<RuneType>(type & 0x3);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_RESYNC_RUNES: {
|
|
|
|
|
|
// uint8 runeReadyMask (bit i=1 → rune i is ready)
|
|
|
|
|
|
// uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 7) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint8_t readyMask = packet.readUInt8();
|
|
|
|
|
|
for (int i = 0; i < 6; i++) {
|
|
|
|
|
|
uint8_t cd = packet.readUInt8();
|
|
|
|
|
|
playerRunes_[i].ready = (readyMask & (1u << i)) != 0;
|
|
|
|
|
|
playerRunes_[i].readyFraction = 1.0f - cd / 255.0f;
|
|
|
|
|
|
if (playerRunes_[i].ready) playerRunes_[i].readyFraction = 1.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_ADD_RUNE_POWER: {
|
|
|
|
|
|
// uint32 runeMask (bit i=1 → rune i just became ready)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t runeMask = packet.readUInt32();
|
|
|
|
|
|
for (int i = 0; i < 6; i++) {
|
|
|
|
|
|
if (runeMask & (1u << i)) {
|
|
|
|
|
|
playerRunes_[i].ready = true;
|
|
|
|
|
|
playerRunes_[i].readyFraction = 1.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
break;
|
2026-03-09 18:28:03 -07:00
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Spell combat logs (consume) ----
|
2026-03-09 20:15:34 -07:00
|
|
|
|
case Opcode::SMSG_SPELLDAMAGESHIELD: {
|
2026-03-14 01:47:06 -07:00
|
|
|
|
// 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;
|
2026-03-14 02:01:07 -07:00
|
|
|
|
const auto shieldRem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
2026-03-14 01:47:06 -07:00
|
|
|
|
const size_t shieldMinSz = shieldTbc ? 24u : 2u;
|
2026-03-11 02:27:57 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < shieldMinSz) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 01:47:06 -07:00
|
|
|
|
if (!shieldTbc && (!hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t victimGuid = shieldTbc
|
2026-03-11 02:27:57 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:47:06 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (shieldTbc ? 8u : 1u)
|
|
|
|
|
|
|| (!shieldTbc && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t casterGuid = shieldTbc
|
2026-03-11 02:27:57 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 02:01:07 -07:00
|
|
|
|
const size_t shieldTailSize = shieldWotlkLike ? 16u : 12u;
|
|
|
|
|
|
if (shieldRem() < shieldTailSize) {
|
2026-03-09 20:15:34 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-13 11:51:07 -07:00
|
|
|
|
uint32_t shieldSpellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t damage = packet.readUInt32();
|
2026-03-14 02:01:07 -07:00
|
|
|
|
if (shieldWotlkLike)
|
2026-03-11 02:27:57 -07:00
|
|
|
|
/*uint32_t absorbed =*/ packet.readUInt32();
|
2026-03-09 20:15:34 -07:00
|
|
|
|
/*uint32_t school =*/ packet.readUInt32();
|
|
|
|
|
|
// Show combat text: damage shield reflect
|
|
|
|
|
|
if (casterGuid == playerGuid) {
|
|
|
|
|
|
// We have a damage shield that reflected damage
|
2026-03-13 11:51:07 -07:00
|
|
|
|
addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast<int32_t>(damage), shieldSpellId, true, 0, casterGuid, victimGuid);
|
2026-03-09 20:15:34 -07:00
|
|
|
|
} else if (victimGuid == playerGuid) {
|
|
|
|
|
|
// A damage shield hit us (e.g. target's Thorns)
|
2026-03-13 11:51:07 -07:00
|
|
|
|
addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast<int32_t>(damage), shieldSpellId, false, 0, casterGuid, victimGuid);
|
2026-03-09 20:15:34 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-13 22:53:04 -07:00
|
|
|
|
case Opcode::SMSG_AURACASTLOG:
|
|
|
|
|
|
case Opcode::SMSG_SPELLBREAKLOG:
|
|
|
|
|
|
// These packets are not damage-shield events. Consume them without
|
|
|
|
|
|
// synthesizing reflected damage entries or misattributing GUIDs.
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 20:15:34 -07:00
|
|
|
|
case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: {
|
2026-03-14 01:25:47 -07:00
|
|
|
|
// 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;
|
2026-03-09 23:45:10 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < minSz) {
|
2026-03-09 20:15:34 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 01:25:47 -07:00
|
|
|
|
if (!immuneUsesFullGuid && !hasFullPackedGuid(packet)) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t casterGuid = immuneUsesFullGuid
|
2026-03-09 23:45:10 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:25:47 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (immuneUsesFullGuid ? 8u : 2u)
|
|
|
|
|
|
|| (!immuneUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t victimGuid = immuneUsesFullGuid
|
2026-03-09 23:45:10 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 20:15:34 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
2026-03-13 11:51:07 -07:00
|
|
|
|
uint32_t immuneSpellId = packet.readUInt32();
|
2026-03-09 20:15:34 -07:00
|
|
|
|
/*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) {
|
2026-03-13 11:51:07 -07:00
|
|
|
|
addCombatText(CombatTextEntry::IMMUNE, 0, immuneSpellId,
|
|
|
|
|
|
casterGuid == playerGuid, 0, casterGuid, victimGuid);
|
2026-03-09 20:15:34 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 20:20:24 -07:00
|
|
|
|
case Opcode::SMSG_SPELLDISPELLOG: {
|
2026-03-14 00:53:42 -07:00
|
|
|
|
// WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen
|
|
|
|
|
|
// TBC: full uint64 casterGuid + full uint64 victimGuid + ...
|
2026-03-09 20:20:24 -07:00
|
|
|
|
// + uint32 count + count × (uint32 dispelled_spellId + uint32 unk)
|
2026-03-14 00:53:42 -07:00
|
|
|
|
const bool dispelUsesFullGuid = isActiveExpansion("tbc");
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-09 20:20:24 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:53:42 -07:00
|
|
|
|
uint64_t casterGuid = dispelUsesFullGuid
|
2026-03-09 23:45:10 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:53:42 -07:00
|
|
|
|
uint64_t victimGuid = dispelUsesFullGuid
|
2026-03-09 23:45:10 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-09 20:20:24 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) break;
|
|
|
|
|
|
/*uint32_t dispelSpell =*/ packet.readUInt32();
|
|
|
|
|
|
uint8_t isStolen = packet.readUInt8();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
2026-03-13 20:14:02 -07:00
|
|
|
|
// Preserve every dispelled aura in the combat log instead of collapsing
|
|
|
|
|
|
// multi-aura packets down to the first entry only.
|
2026-03-14 00:53:42 -07:00
|
|
|
|
const size_t dispelEntrySize = dispelUsesFullGuid ? 8u : 5u;
|
2026-03-13 20:14:02 -07:00
|
|
|
|
std::vector<uint32_t> dispelledIds;
|
|
|
|
|
|
dispelledIds.reserve(count);
|
2026-03-13 20:38:59 -07:00
|
|
|
|
for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) {
|
2026-03-13 12:03:07 -07:00
|
|
|
|
uint32_t dispelledId = packet.readUInt32();
|
2026-03-14 00:53:42 -07:00
|
|
|
|
if (dispelUsesFullGuid) {
|
2026-03-13 20:38:59 -07:00
|
|
|
|
/*uint32_t unk =*/ packet.readUInt32();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
/*uint8_t isPositive =*/ packet.readUInt8();
|
|
|
|
|
|
}
|
2026-03-13 20:14:02 -07:00
|
|
|
|
if (dispelledId != 0) {
|
|
|
|
|
|
dispelledIds.push_back(dispelledId);
|
|
|
|
|
|
}
|
2026-03-13 12:03:07 -07:00
|
|
|
|
}
|
2026-03-09 20:20:24 -07:00
|
|
|
|
// Show system message if player was victim or caster
|
|
|
|
|
|
if (victimGuid == playerGuid || casterGuid == playerGuid) {
|
2026-03-13 20:23:24 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 21:00:34 -07:00
|
|
|
|
const std::string displaySpellNames = formatSpellNameList(*this, loggedIds);
|
|
|
|
|
|
if (!displaySpellNames.empty()) {
|
2026-03-09 20:20:24 -07:00
|
|
|
|
char buf[256];
|
2026-03-13 21:00:34 -07:00
|
|
|
|
const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were";
|
2026-03-13 20:06:39 -07:00
|
|
|
|
if (isStolen) {
|
|
|
|
|
|
if (victimGuid == playerGuid && casterGuid != playerGuid)
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s %s stolen.",
|
|
|
|
|
|
displaySpellNames.c_str(), passiveVerb);
|
2026-03-13 20:06:39 -07:00
|
|
|
|
else if (casterGuid == playerGuid)
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str());
|
2026-03-13 20:06:39 -07:00
|
|
|
|
else
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s %s stolen.",
|
|
|
|
|
|
displaySpellNames.c_str(), passiveVerb);
|
2026-03-13 20:06:39 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
if (victimGuid == playerGuid && casterGuid != playerGuid)
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s %s dispelled.",
|
|
|
|
|
|
displaySpellNames.c_str(), passiveVerb);
|
2026-03-13 20:06:39 -07:00
|
|
|
|
else if (casterGuid == playerGuid)
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str());
|
2026-03-13 20:06:39 -07:00
|
|
|
|
else
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s %s dispelled.",
|
|
|
|
|
|
displaySpellNames.c_str(), passiveVerb);
|
2026-03-13 20:06:39 -07:00
|
|
|
|
}
|
2026-03-09 20:20:24 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
2026-03-13 20:06:39 -07:00
|
|
|
|
// Preserve stolen auras as spellsteal events so the log wording stays accurate.
|
2026-03-13 20:23:24 -07:00
|
|
|
|
if (!loggedIds.empty()) {
|
2026-03-13 12:03:07 -07:00
|
|
|
|
bool isPlayerCaster = (casterGuid == playerGuid);
|
2026-03-13 20:23:24 -07:00
|
|
|
|
for (uint32_t dispelledId : loggedIds) {
|
2026-03-13 20:14:02 -07:00
|
|
|
|
addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL,
|
|
|
|
|
|
0, dispelledId, isPlayerCaster, 0,
|
|
|
|
|
|
casterGuid, victimGuid);
|
|
|
|
|
|
}
|
2026-03-13 12:03:07 -07:00
|
|
|
|
}
|
2026-03-09 20:20:24 -07:00
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_SPELLSTEALLOG: {
|
2026-03-11 03:03:44 -07:00
|
|
|
|
// Sent to the CASTER (Mage) when Spellsteal succeeds.
|
|
|
|
|
|
// Wire format mirrors SPELLDISPELLOG:
|
2026-03-14 00:53:42 -07:00
|
|
|
|
// 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");
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!stealUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-11 03:03:44 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:53:42 -07:00
|
|
|
|
uint64_t stealVictim = stealUsesFullGuid
|
2026-03-11 03:03:44 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!stealUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-11 03:03:44 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:53:42 -07:00
|
|
|
|
uint64_t stealCaster = stealUsesFullGuid
|
2026-03-11 03:03:44 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
/*uint32_t stealSpellId =*/ packet.readUInt32();
|
|
|
|
|
|
/*uint8_t isStolen =*/ packet.readUInt8();
|
|
|
|
|
|
uint32_t stealCount = packet.readUInt32();
|
2026-03-13 20:14:02 -07:00
|
|
|
|
// Preserve every stolen aura in the combat log instead of only the first.
|
2026-03-14 00:53:42 -07:00
|
|
|
|
const size_t stealEntrySize = stealUsesFullGuid ? 8u : 5u;
|
2026-03-13 20:14:02 -07:00
|
|
|
|
std::vector<uint32_t> stolenIds;
|
|
|
|
|
|
stolenIds.reserve(stealCount);
|
2026-03-13 20:38:59 -07:00
|
|
|
|
for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) {
|
2026-03-13 12:03:07 -07:00
|
|
|
|
uint32_t stolenId = packet.readUInt32();
|
2026-03-14 00:53:42 -07:00
|
|
|
|
if (stealUsesFullGuid) {
|
2026-03-13 20:38:59 -07:00
|
|
|
|
/*uint32_t unk =*/ packet.readUInt32();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
/*uint8_t isPos =*/ packet.readUInt8();
|
|
|
|
|
|
}
|
2026-03-13 20:14:02 -07:00
|
|
|
|
if (stolenId != 0) {
|
|
|
|
|
|
stolenIds.push_back(stolenId);
|
|
|
|
|
|
}
|
2026-03-13 12:03:07 -07:00
|
|
|
|
}
|
|
|
|
|
|
if (stealCaster == playerGuid || stealVictim == playerGuid) {
|
2026-03-13 20:23:24 -07:00
|
|
|
|
std::vector<uint32_t> loggedIds;
|
|
|
|
|
|
loggedIds.reserve(stolenIds.size());
|
|
|
|
|
|
for (uint32_t stolenId : stolenIds) {
|
|
|
|
|
|
if (shouldLogSpellstealAura(stealCaster, stealVictim, stolenId))
|
|
|
|
|
|
loggedIds.push_back(stolenId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 21:00:34 -07:00
|
|
|
|
const std::string displaySpellNames = formatSpellNameList(*this, loggedIds);
|
|
|
|
|
|
if (!displaySpellNames.empty()) {
|
2026-03-11 03:03:44 -07:00
|
|
|
|
char buf[256];
|
2026-03-13 12:03:07 -07:00
|
|
|
|
if (stealCaster == playerGuid)
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellNames.c_str());
|
2026-03-13 12:03:07 -07:00
|
|
|
|
else
|
2026-03-13 21:00:34 -07:00
|
|
|
|
std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(),
|
|
|
|
|
|
loggedIds.size() == 1 ? "was" : "were");
|
2026-03-11 03:03:44 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
2026-03-13 20:23:24 -07:00
|
|
|
|
// Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG
|
|
|
|
|
|
// for the same aura. Keep the first event and suppress the duplicate.
|
|
|
|
|
|
if (!loggedIds.empty()) {
|
2026-03-13 12:03:07 -07:00
|
|
|
|
bool isPlayerCaster = (stealCaster == playerGuid);
|
2026-03-13 20:23:24 -07:00
|
|
|
|
for (uint32_t stolenId : loggedIds) {
|
2026-03-13 20:14:02 -07:00
|
|
|
|
addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0,
|
|
|
|
|
|
stealCaster, stealVictim);
|
|
|
|
|
|
}
|
2026-03-13 12:03:07 -07:00
|
|
|
|
}
|
2026-03-11 03:03:44 -07:00
|
|
|
|
}
|
2026-03-09 20:20:24 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-12 06:06:41 -07:00
|
|
|
|
case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: {
|
2026-03-14 00:45:50 -07:00
|
|
|
|
// WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ...
|
|
|
|
|
|
// TBC: uint64 target + uint64 caster + uint32 spellId + ...
|
|
|
|
|
|
const bool procChanceUsesFullGuid = isActiveExpansion("tbc");
|
2026-03-13 20:52:34 -07:00
|
|
|
|
auto readProcChanceGuid = [&]() -> uint64_t {
|
2026-03-14 00:45:50 -07:00
|
|
|
|
if (procChanceUsesFullGuid)
|
2026-03-13 20:52:34 -07:00
|
|
|
|
return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0;
|
|
|
|
|
|
return UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
};
|
2026-03-14 01:18:28 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-12 06:06:41 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-13 20:52:34 -07:00
|
|
|
|
uint64_t procTargetGuid = readProcChanceGuid();
|
2026-03-14 01:18:28 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-12 06:06:41 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-13 20:52:34 -07:00
|
|
|
|
uint64_t procCasterGuid = readProcChanceGuid();
|
2026-03-12 06:06:41 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t procSpellId = packet.readUInt32();
|
|
|
|
|
|
// Show a "PROC!" floating text when the player triggers the proc
|
|
|
|
|
|
if (procCasterGuid == playerGuid && procSpellId > 0)
|
2026-03-13 11:57:45 -07:00
|
|
|
|
addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0,
|
|
|
|
|
|
procCasterGuid, procTargetGuid);
|
2026-03-12 06:06:41 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
case Opcode::SMSG_SPELLINSTAKILLLOG: {
|
|
|
|
|
|
// Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.)
|
2026-03-14 00:38:22 -07:00
|
|
|
|
// 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");
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
2026-03-14 01:32:45 -07:00
|
|
|
|
if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!ikUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:38:22 -07:00
|
|
|
|
uint64_t ikCaster = ikUsesFullGuid
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:32:45 -07:00
|
|
|
|
if (ik_rem() < (ikUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!ikUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:38:22 -07:00
|
|
|
|
uint64_t ikVictim = ikUsesFullGuid
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 14:51:27 -07:00
|
|
|
|
if (ik_rem() < 4) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t ikSpell = packet.readUInt32();
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
// Show kill/death feedback for the local player
|
|
|
|
|
|
if (ikCaster == playerGuid) {
|
2026-03-13 22:22:00 -07:00
|
|
|
|
addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim);
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
} else if (ikVictim == playerGuid) {
|
2026-03-13 22:22:00 -07:00
|
|
|
|
addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim);
|
2026-03-17 17:45:45 -07:00
|
|
|
|
addUIError("You were killed by an instant-kill effect.");
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
addSystemChatMessage("You were killed by an instant-kill effect.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster,
|
|
|
|
|
|
" victim=0x", ikVictim, std::dec, " spell=", ikSpell);
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-12 22:53:33 -07:00
|
|
|
|
case Opcode::SMSG_SPELLLOGEXECUTE: {
|
2026-03-14 00:24:21 -07:00
|
|
|
|
// WotLK/Classic/Turtle: packed_guid caster + uint32 spellId + uint32 effectCount
|
|
|
|
|
|
// TBC: uint64 caster + uint32 spellId + uint32 effectCount
|
2026-03-12 22:53:33 -07:00
|
|
|
|
// Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
// 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)
|
2026-03-14 00:24:21 -07:00
|
|
|
|
const bool exeUsesFullGuid = isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)) {
|
2026-03-12 22:53:33 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 01:10:43 -07:00
|
|
|
|
if (!exeUsesFullGuid && !hasFullPackedGuid(packet)) {
|
2026-03-13 23:47:57 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:24:21 -07:00
|
|
|
|
uint64_t exeCaster = exeUsesFullGuid
|
2026-03-12 22:53:33 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t exeSpellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t exeEffectCount = packet.readUInt32();
|
|
|
|
|
|
exeEffectCount = std::min(exeEffectCount, 32u); // sanity
|
|
|
|
|
|
|
|
|
|
|
|
const bool isPlayerCaster = (exeCaster == playerGuid);
|
|
|
|
|
|
for (uint32_t ei = 0; ei < exeEffectCount; ++ei) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
uint8_t effectType = packet.readUInt8();
|
|
|
|
|
|
uint32_t effectLogCount = packet.readUInt32();
|
|
|
|
|
|
effectLogCount = std::min(effectLogCount, 64u); // sanity
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
if (effectType == 10) {
|
|
|
|
|
|
// SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier
|
|
|
|
|
|
for (uint32_t li = 0; li < effectLogCount; ++li) {
|
2026-03-14 00:24:21 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)
|
2026-03-14 01:10:43 -07:00
|
|
|
|
|| (!exeUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-13 23:40:39 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:24:21 -07:00
|
|
|
|
uint64_t drainTarget = exeUsesFullGuid
|
2026-03-13 23:40:39 -07:00
|
|
|
|
? packet.readUInt64()
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
: 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
|
2026-03-14 00:06:05 -07:00
|
|
|
|
float drainMult = packet.readFloat();
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
if (drainAmount > 0) {
|
|
|
|
|
|
if (drainTarget == playerGuid)
|
2026-03-13 23:56:44 -07:00
|
|
|
|
addCombatText(CombatTextEntry::POWER_DRAIN, static_cast<int32_t>(drainAmount), exeSpellId, false,
|
|
|
|
|
|
static_cast<uint8_t>(drainPower),
|
2026-03-13 11:55:23 -07:00
|
|
|
|
exeCaster, drainTarget);
|
2026-03-14 00:06:05 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId,
|
2026-03-14 00:06:05 -07:00
|
|
|
|
" power=", drainPower, " amount=", drainAmount,
|
|
|
|
|
|
" multiplier=", drainMult);
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
} else if (effectType == 11) {
|
|
|
|
|
|
// SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier
|
|
|
|
|
|
for (uint32_t li = 0; li < effectLogCount; ++li) {
|
2026-03-14 00:24:21 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)
|
2026-03-14 01:10:43 -07:00
|
|
|
|
|| (!exeUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-13 23:40:39 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:24:21 -07:00
|
|
|
|
uint64_t leechTarget = exeUsesFullGuid
|
2026-03-13 23:40:39 -07:00
|
|
|
|
? packet.readUInt64()
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
: UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
uint32_t leechAmount = packet.readUInt32();
|
2026-03-14 00:16:28 -07:00
|
|
|
|
float leechMult = packet.readFloat();
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
if (leechAmount > 0) {
|
2026-03-14 00:16:28 -07:00
|
|
|
|
if (leechTarget == playerGuid) {
|
2026-03-13 11:55:23 -07:00
|
|
|
|
addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast<int32_t>(leechAmount), exeSpellId, false, 0,
|
|
|
|
|
|
exeCaster, leechTarget);
|
2026-03-14 00:16:28 -07:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
}
|
2026-03-14 00:16:28 -07:00
|
|
|
|
LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId,
|
|
|
|
|
|
" amount=", leechAmount, " multiplier=", leechMult);
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
} else if (effectType == 24 || effectType == 114) {
|
|
|
|
|
|
// SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry
|
2026-03-12 22:53:33 -07:00
|
|
|
|
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);
|
2026-03-17 10:12:49 -07:00
|
|
|
|
|
|
|
|
|
|
// Repeat-craft queue: re-cast if more crafts remaining
|
|
|
|
|
|
if (craftQueueRemaining_ > 0 && craftQueueSpellId_ == exeSpellId) {
|
|
|
|
|
|
--craftQueueRemaining_;
|
|
|
|
|
|
if (craftQueueRemaining_ > 0) {
|
|
|
|
|
|
castSpell(craftQueueSpellId_, 0);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 22:53:33 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
} else if (effectType == 26) {
|
|
|
|
|
|
// SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id
|
|
|
|
|
|
for (uint32_t li = 0; li < effectLogCount; ++li) {
|
2026-03-14 00:24:21 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)
|
2026-03-14 01:10:43 -07:00
|
|
|
|
|| (!exeUsesFullGuid && !hasFullPackedGuid(packet))) {
|
2026-03-13 23:40:39 -07:00
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:24:21 -07:00
|
|
|
|
uint64_t icTarget = exeUsesFullGuid
|
2026-03-13 23:40:39 -07:00
|
|
|
|
? packet.readUInt64()
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
: 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);
|
2026-03-13 12:03:07 -07:00
|
|
|
|
// Record interrupt in combat log when player is involved
|
|
|
|
|
|
if (isPlayerCaster || icTarget == playerGuid)
|
|
|
|
|
|
addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0,
|
|
|
|
|
|
exeCaster, icTarget);
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
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));
|
2026-03-17 13:27:27 -07:00
|
|
|
|
uint32_t feedQuality = info ? info->quality : 1u;
|
|
|
|
|
|
addSystemChatMessage("You feed your pet " + buildItemLink(feedItem, feedQuality, itemName) + ".");
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 22:53:33 -07:00
|
|
|
|
} else {
|
feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding
All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
|
|
|
|
// Unknown effect type — stop parsing to avoid misalignment
|
2026-03-12 22:53:33 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK:
|
|
|
|
|
|
case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 22:20:47 -07:00
|
|
|
|
case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: {
|
|
|
|
|
|
// TBC 2.4.3: clear a single aura slot for a unit
|
|
|
|
|
|
// Format: uint64 targetGuid + uint8 slot
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 9) {
|
|
|
|
|
|
uint64_t clearGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t slot = packet.readUInt8();
|
|
|
|
|
|
std::vector<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());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 15:23:02 -07:00
|
|
|
|
// ---- Misc consume ----
|
2026-03-12 18:15:51 -07:00
|
|
|
|
case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: {
|
|
|
|
|
|
// Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid
|
|
|
|
|
|
// slot: 0=main-hand, 1=off-hand, 2=ranged
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 24) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
/*uint64_t itemGuid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t enchSlot = packet.readUInt32();
|
|
|
|
|
|
uint32_t durationSec = packet.readUInt32();
|
|
|
|
|
|
/*uint64_t playerGuid =*/ packet.readUInt64();
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp to known slots (0-2)
|
|
|
|
|
|
if (enchSlot > 2) { break; }
|
|
|
|
|
|
|
|
|
|
|
|
uint64_t nowMs = static_cast<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");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-12 21:27:02 -07:00
|
|
|
|
case Opcode::SMSG_COMPLAIN_RESULT: {
|
|
|
|
|
|
// uint8 result: 0=success, 1=failed, 2=disabled
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
if (result == 0)
|
|
|
|
|
|
addSystemChatMessage("Your complaint has been submitted.");
|
|
|
|
|
|
else if (result == 2)
|
|
|
|
|
|
addUIError("Report a Player is currently disabled.");
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE:
|
|
|
|
|
|
case Opcode::SMSG_LOOT_LIST:
|
2026-03-12 21:27:02 -07:00
|
|
|
|
// Consume silently — informational, no UI action needed
|
2026-03-09 20:40:58 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 19:54:32 -07:00
|
|
|
|
case Opcode::SMSG_RESUME_CAST_BAR: {
|
2026-03-10 00:00:21 -07:00
|
|
|
|
// 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");
|
2026-03-09 19:54:32 -07:00
|
|
|
|
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
|
2026-03-10 00:00:21 -07:00
|
|
|
|
if (remaining() < (rcbTbc ? 8u : 1u)) break;
|
|
|
|
|
|
uint64_t caster = rcbTbc
|
|
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (remaining() < (rcbTbc ? 8u : 1u)) break;
|
|
|
|
|
|
if (rcbTbc) packet.readUInt64(); // target (discard)
|
|
|
|
|
|
else (void)UpdateObjectParser::readPackedGuid(packet); // target
|
2026-03-09 19:54:32 -07:00
|
|
|
|
if (remaining() < 12) break;
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t remainMs = packet.readUInt32();
|
|
|
|
|
|
uint32_t totalMs = packet.readUInt32();
|
2026-03-09 23:36:14 -07:00
|
|
|
|
if (totalMs > 0) {
|
|
|
|
|
|
if (caster == playerGuid) {
|
|
|
|
|
|
casting = true;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-03-09 23:36:14 -07:00
|
|
|
|
currentCastSpellId = spellId;
|
|
|
|
|
|
castTimeTotal = totalMs / 1000.0f;
|
|
|
|
|
|
castTimeRemaining = remainMs / 1000.0f;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto& s = unitCastStates_[caster];
|
|
|
|
|
|
s.casting = true;
|
|
|
|
|
|
s.spellId = spellId;
|
|
|
|
|
|
s.timeTotal = totalMs / 1000.0f;
|
|
|
|
|
|
s.timeRemaining = remainMs / 1000.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_RESUME_CAST_BAR: caster=0x", std::hex, caster, std::dec,
|
|
|
|
|
|
" spell=", spellId, " remaining=", remainMs, "ms total=", totalMs, "ms");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ---- Channeled spell start/tick (WotLK: packed GUIDs; TBC/Classic: full uint64) ----
|
|
|
|
|
|
case Opcode::MSG_CHANNEL_START: {
|
|
|
|
|
|
// casterGuid + uint32 spellId + uint32 totalDurationMs
|
|
|
|
|
|
const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
uint64_t chanCaster = tbcOrClassic
|
|
|
|
|
|
? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0)
|
|
|
|
|
|
: UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint32_t chanSpellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t chanTotalMs = packet.readUInt32();
|
|
|
|
|
|
if (chanTotalMs > 0 && chanCaster != 0) {
|
|
|
|
|
|
if (chanCaster == playerGuid) {
|
|
|
|
|
|
casting = true;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = true;
|
2026-03-09 23:36:14 -07:00
|
|
|
|
currentCastSpellId = chanSpellId;
|
|
|
|
|
|
castTimeTotal = chanTotalMs / 1000.0f;
|
|
|
|
|
|
castTimeRemaining = castTimeTotal;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto& s = unitCastStates_[chanCaster];
|
2026-03-17 19:45:45 -07:00
|
|
|
|
s.casting = true;
|
|
|
|
|
|
s.spellId = chanSpellId;
|
|
|
|
|
|
s.timeTotal = chanTotalMs / 1000.0f;
|
|
|
|
|
|
s.timeRemaining = s.timeTotal;
|
|
|
|
|
|
s.interruptible = isSpellInterruptible(chanSpellId);
|
2026-03-09 23:36:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec,
|
|
|
|
|
|
" spell=", chanSpellId, " total=", chanTotalMs, "ms");
|
2026-03-20 15:37:33 -07:00
|
|
|
|
// Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (chanCaster == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (chanCaster == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (chanCaster == focusGuid) unitId = "focus";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)});
|
|
|
|
|
|
}
|
2026-03-09 19:54:32 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 23:36:14 -07:00
|
|
|
|
case Opcode::MSG_CHANNEL_UPDATE: {
|
|
|
|
|
|
// casterGuid + uint32 remainingMs
|
|
|
|
|
|
const bool tbcOrClassic2 = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
uint64_t chanCaster2 = tbcOrClassic2
|
|
|
|
|
|
? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0)
|
|
|
|
|
|
: UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t chanRemainMs = packet.readUInt32();
|
|
|
|
|
|
if (chanCaster2 == playerGuid) {
|
|
|
|
|
|
castTimeRemaining = chanRemainMs / 1000.0f;
|
|
|
|
|
|
if (chanRemainMs == 0) {
|
|
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-03-09 23:36:14 -07:00
|
|
|
|
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");
|
2026-03-20 15:37:33 -07:00
|
|
|
|
// Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends
|
|
|
|
|
|
if (chanRemainMs == 0 && addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (chanCaster2 == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (chanCaster2 == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (chanCaster2 == focusGuid) unitId = "focus";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_CHANNEL_STOP", {unitId});
|
|
|
|
|
|
}
|
2026-03-09 23:36:14 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 19:54:32 -07:00
|
|
|
|
case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: {
|
|
|
|
|
|
// uint32 slot + packed_guid unit (0 packed = clear slot)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t slot = packet.readUInt32();
|
|
|
|
|
|
uint64_t unit = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (slot < kMaxEncounterSlots) {
|
|
|
|
|
|
encounterUnitGuids_[slot] = unit;
|
|
|
|
|
|
LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot,
|
|
|
|
|
|
" guid=0x", std::hex, unit, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP:
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_LAST_INSTANCE:
|
|
|
|
|
|
case Opcode::SMSG_SEND_ALL_COMBAT_LOG:
|
|
|
|
|
|
case Opcode::SMSG_SET_PROJECTILE_POSITION:
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_LIST_PENDING_SALES:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Server-first achievement broadcast ----
|
|
|
|
|
|
case Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT: {
|
|
|
|
|
|
// charName (cstring) + guid (uint64) + achievementId (uint32) + ...
|
|
|
|
|
|
if (packet.getReadPos() < packet.getSize()) {
|
|
|
|
|
|
std::string charName = packet.readString();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t achievementId = packet.readUInt32();
|
2026-03-09 19:36:58 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Forced faction reactions ----
|
|
|
|
|
|
case Opcode::SMSG_SET_FORCED_REACTIONS:
|
|
|
|
|
|
handleSetForcedReactions(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Spline speed changes for other units ----
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_WALK_SPEED:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_SET_TURN_RATE:
|
2026-03-10 11:44:57 -07:00
|
|
|
|
case Opcode::SMSG_SPLINE_SET_PITCH_RATE: {
|
2026-03-10 14:18:25 -07:00
|
|
|
|
// Minimal parse: PackedGuid + float speed
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) break;
|
|
|
|
|
|
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
float sSpeed = packet.readFloat();
|
|
|
|
|
|
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
|
|
|
|
|
|
if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED)
|
|
|
|
|
|
serverFlightSpeed_ = sSpeed;
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED)
|
|
|
|
|
|
serverFlightBackSpeed_ = sSpeed;
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED)
|
|
|
|
|
|
serverSwimBackSpeed_ = sSpeed;
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_WALK_SPEED)
|
|
|
|
|
|
serverWalkSpeed_ = sSpeed;
|
|
|
|
|
|
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_TURN_RATE)
|
|
|
|
|
|
serverTurnRate_ = sSpeed; // rad/s
|
2026-03-10 11:44:57 -07:00
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
break;
|
2026-03-10 11:44:57 -07:00
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Spline move flag changes for other units ----
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_UNROOT:
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER:
|
2026-03-10 11:42:54 -07:00
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: {
|
|
|
|
|
|
// Minimal parse: PackedGuid only — no animation-relevant state change.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
(void)UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
break;
|
2026-03-10 11:42:54 -07:00
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: {
|
|
|
|
|
|
// PackedGuid + synthesised move-flags=0 → clears flying animation.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
|
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break;
|
|
|
|
|
|
unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
|
2026-03-09 15:27:20 -07:00
|
|
|
|
// ---- Quest failure notification ----
|
|
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: {
|
|
|
|
|
|
// uint32 questId + uint32 reason
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
2026-03-13 06:36:04 -07:00
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-03-09 15:27:20 -07:00
|
|
|
|
uint32_t reason = packet.readUInt32();
|
2026-03-13 06:36:04 -07:00
|
|
|
|
std::string questTitle;
|
|
|
|
|
|
for (const auto& q : questLog_)
|
|
|
|
|
|
if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; }
|
|
|
|
|
|
const char* reasonStr = nullptr;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
switch (reason) {
|
2026-03-13 06:36:04 -07:00
|
|
|
|
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;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
}
|
2026-03-13 06:36:04 -07:00
|
|
|
|
std::string msg = questTitle.empty() ? "Quest" : ('"' + questTitle + '"');
|
|
|
|
|
|
msg += " failed";
|
|
|
|
|
|
if (reasonStr) msg += std::string(": ") + reasonStr;
|
|
|
|
|
|
msg += '.';
|
|
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-09 15:27:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Suspend comms (requires ACK) ----
|
|
|
|
|
|
case Opcode::SMSG_SUSPEND_COMMS: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t seqIdx = packet.readUInt32();
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
network::Packet ack(wireOpcode(Opcode::CMSG_SUSPEND_COMMS_ACK));
|
|
|
|
|
|
ack.writeUInt32(seqIdx);
|
|
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Pre-resurrect state ----
|
|
|
|
|
|
case Opcode::SMSG_PRE_RESURRECT: {
|
2026-03-18 00:06:39 -07:00
|
|
|
|
// 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, ")");
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Hearthstone bind error ----
|
|
|
|
|
|
case Opcode::SMSG_PLAYERBINDERROR: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t error = packet.readUInt32();
|
2026-03-17 17:56:53 -07:00
|
|
|
|
if (error == 0) {
|
|
|
|
|
|
addUIError("Your hearthstone is not bound.");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
addSystemChatMessage("Your hearthstone is not bound.");
|
2026-03-17 17:56:53 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
addUIError("Hearthstone bind failed.");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
addSystemChatMessage("Hearthstone bind failed.");
|
2026-03-17 17:56:53 -07:00
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Instance/raid errors ----
|
|
|
|
|
|
case Opcode::SMSG_RAID_GROUP_ONLY: {
|
2026-03-17 17:45:45 -07:00
|
|
|
|
addUIError("You must be in a raid group to enter this instance.");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
addSystemChatMessage("You must be in a raid group to enter this instance.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_RAID_READY_CHECK_ERROR: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t err = packet.readUInt8();
|
2026-03-17 17:45:45 -07:00
|
|
|
|
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."); }
|
2026-03-09 15:27:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_RESET_FAILED_NOTIFY: {
|
2026-03-17 17:45:45 -07:00
|
|
|
|
addUIError("Cannot reset instance: another player is still inside.");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
addSystemChatMessage("Cannot reset instance: another player is still inside.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Realm split ----
|
2026-03-09 16:39:52 -07:00
|
|
|
|
case Opcode::SMSG_REALM_SPLIT: {
|
|
|
|
|
|
// uint32 splitType + uint32 deferTime + string realmName
|
|
|
|
|
|
// Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers.
|
|
|
|
|
|
uint32_t splitType = 0;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4)
|
|
|
|
|
|
splitType = packet.readUInt32();
|
2026-03-09 15:27:20 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
2026-03-09 16:39:52 -07:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
break;
|
2026-03-09 16:39:52 -07:00
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
|
2026-03-17 13:44:14 -07:00
|
|
|
|
// ---- Real group update (group type, local player flags, leader) ----
|
|
|
|
|
|
// Sent when the player's group configuration changes: group type,
|
|
|
|
|
|
// role/flags (assistant/MT/MA), or leader changes.
|
|
|
|
|
|
// Format: uint8 groupType | uint32 memberFlags | uint64 leaderGuid
|
|
|
|
|
|
case Opcode::SMSG_REAL_GROUP_UPDATE: {
|
|
|
|
|
|
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
|
|
|
|
|
if (rem() < 1) break;
|
|
|
|
|
|
uint8_t newGroupType = packet.readUInt8();
|
|
|
|
|
|
if (rem() < 4) break;
|
|
|
|
|
|
uint32_t newMemberFlags = packet.readUInt32();
|
|
|
|
|
|
if (rem() < 8) break;
|
|
|
|
|
|
uint64_t newLeaderGuid = packet.readUInt64();
|
|
|
|
|
|
|
|
|
|
|
|
partyData.groupType = newGroupType;
|
|
|
|
|
|
partyData.leaderGuid = newLeaderGuid;
|
|
|
|
|
|
|
|
|
|
|
|
// Update local player's flags in the member list
|
|
|
|
|
|
uint64_t localGuid = playerGuid;
|
|
|
|
|
|
for (auto& m : partyData.members) {
|
|
|
|
|
|
if (m.guid == localGuid) {
|
|
|
|
|
|
m.flags = static_cast<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);
|
2026-03-09 15:27:20 -07:00
|
|
|
|
break;
|
2026-03-17 13:44:14 -07:00
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Play music (WotLK standard opcode) ----
|
|
|
|
|
|
case Opcode::SMSG_PLAY_MUSIC: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
2026-03-09 15:46:19 -07:00
|
|
|
|
uint32_t soundId = packet.readUInt32();
|
|
|
|
|
|
if (playMusicCallback_) playMusicCallback_(soundId);
|
2026-03-09 15:27:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Play object/spell sounds ----
|
|
|
|
|
|
case Opcode::SMSG_PLAY_OBJECT_SOUND:
|
2026-03-09 16:16:39 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
// uint32 soundId + uint64 sourceGuid
|
2026-03-17 18:26:55 -07:00
|
|
|
|
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);
|
2026-03-09 16:16:39 -07:00
|
|
|
|
if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid);
|
|
|
|
|
|
else if (playSoundCallback_) playSoundCallback_(soundId);
|
|
|
|
|
|
} else if (packet.getSize() - packet.getReadPos() >= 4) {
|
2026-03-09 16:12:52 -07:00
|
|
|
|
uint32_t soundId = packet.readUInt32();
|
|
|
|
|
|
if (playSoundCallback_) playSoundCallback_(soundId);
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-17 18:26:55 -07:00
|
|
|
|
case Opcode::SMSG_PLAY_SPELL_IMPACT: {
|
|
|
|
|
|
// uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t impTargetGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t impVisualId = packet.readUInt32();
|
|
|
|
|
|
if (impVisualId == 0) break;
|
|
|
|
|
|
auto* renderer = core::Application::getInstance().getRenderer();
|
|
|
|
|
|
if (!renderer) break;
|
|
|
|
|
|
glm::vec3 spawnPos;
|
|
|
|
|
|
if (impTargetGuid == playerGuid) {
|
|
|
|
|
|
spawnPos = renderer->getCharacterPosition();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto entity = entityManager.getEntity(impTargetGuid);
|
|
|
|
|
|
if (!entity) break;
|
|
|
|
|
|
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
|
|
|
|
|
|
spawnPos = core::coords::canonicalToRender(canonical);
|
|
|
|
|
|
}
|
2026-03-17 18:30:11 -07:00
|
|
|
|
renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true);
|
2026-03-17 18:26:55 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Resistance/combat log ----
|
2026-03-10 00:16:13 -07:00
|
|
|
|
case Opcode::SMSG_RESISTLOG: {
|
2026-03-14 00:31:35 -07:00
|
|
|
|
// 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
|
2026-03-10 00:16:13 -07:00
|
|
|
|
// Show RESIST combat text when player resists an incoming spell.
|
2026-03-14 00:31:35 -07:00
|
|
|
|
const bool rlUsesFullGuid = isActiveExpansion("tbc");
|
2026-03-10 00:16:13 -07:00
|
|
|
|
auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
|
|
|
|
|
if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
/*uint32_t hitInfo =*/ packet.readUInt32();
|
2026-03-14 01:39:53 -07:00
|
|
|
|
if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!rlUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:31:35 -07:00
|
|
|
|
uint64_t attackerGuid = rlUsesFullGuid
|
2026-03-10 00:16:13 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-03-14 01:39:53 -07:00
|
|
|
|
if (rl_rem() < (rlUsesFullGuid ? 8u : 1u)
|
|
|
|
|
|
|| (!rlUsesFullGuid && !hasFullPackedGuid(packet))) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
2026-03-14 00:31:35 -07:00
|
|
|
|
uint64_t victimGuid = rlUsesFullGuid
|
2026-03-10 00:16:13 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
2026-03-14 09:36:42 -07:00
|
|
|
|
// Resist payload includes:
|
|
|
|
|
|
// float resistFactor + uint32 targetResistance + uint32 resistedValue.
|
2026-03-14 15:06:29 -07:00
|
|
|
|
// Require the full payload so truncated packets cannot synthesize
|
|
|
|
|
|
// zero-value resist events.
|
|
|
|
|
|
if (rl_rem() < 12) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
/*float resistFactor =*/ packet.readFloat();
|
|
|
|
|
|
/*uint32_t targetRes =*/ packet.readUInt32();
|
|
|
|
|
|
int32_t resistedAmount = static_cast<int32_t>(packet.readUInt32());
|
2026-03-13 19:51:21 -07:00
|
|
|
|
// Show RESIST when the player is involved on either side.
|
2026-03-14 15:06:29 -07:00
|
|
|
|
if (resistedAmount > 0 && victimGuid == playerGuid) {
|
2026-03-14 09:36:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, false, 0, attackerGuid, victimGuid);
|
2026-03-14 15:06:29 -07:00
|
|
|
|
} else if (resistedAmount > 0 && attackerGuid == playerGuid) {
|
2026-03-14 09:36:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, true, 0, attackerGuid, victimGuid);
|
2026-03-10 00:16:13 -07:00
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-10 00:16:13 -07:00
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Read item results ----
|
|
|
|
|
|
case Opcode::SMSG_READ_ITEM_OK:
|
2026-03-12 18:21:50 -07:00
|
|
|
|
bookPages_.clear(); // fresh book for this item read
|
2026-03-09 15:27:20 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_READ_ITEM_FAILED:
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("You cannot read this item.");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
addSystemChatMessage("You cannot read this item.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Completed quests query ----
|
|
|
|
|
|
case Opcode::SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
if (count <= 4096) {
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
|
|
|
|
|
completedQuests_.insert(questId);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: ", count, " completed quests");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- PVP quest kill update ----
|
|
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL: {
|
2026-03-11 00:34:23 -07:00
|
|
|
|
// WotLK 3.3.5a format: uint64 guid + uint32 questId + uint32 count + uint32 reqCount
|
|
|
|
|
|
// Classic format: uint64 guid + uint32 questId + uint32 count (no reqCount)
|
2026-03-09 15:27:20 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 16) {
|
|
|
|
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
2026-03-11 00:34:23 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- NPC not responding ----
|
|
|
|
|
|
case Opcode::SMSG_NPC_WONT_TALK:
|
2026-03-17 18:08:27 -07:00
|
|
|
|
addUIError("That creature can't talk to you right now.");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
addSystemChatMessage("That creature can't talk to you right now.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Petition ----
|
|
|
|
|
|
case Opcode::SMSG_OFFER_PETITION_ERROR: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t err = packet.readUInt32();
|
|
|
|
|
|
if (err == 1) addSystemChatMessage("Player is already in a guild.");
|
|
|
|
|
|
else if (err == 2) addSystemChatMessage("Player already has a petition.");
|
|
|
|
|
|
else addSystemChatMessage("Cannot offer petition to that player.");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PETITION_QUERY_RESPONSE:
|
2026-03-18 12:31:48 -07:00
|
|
|
|
handlePetitionQueryResponse(packet);
|
|
|
|
|
|
break;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_PETITION_SHOW_SIGNATURES:
|
2026-03-18 12:31:48 -07:00
|
|
|
|
handlePetitionShowSignatures(packet);
|
|
|
|
|
|
break;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_PETITION_SIGN_RESULTS:
|
2026-03-18 12:31:48 -07:00
|
|
|
|
handlePetitionSignResults(packet);
|
2026-03-09 15:27:20 -07:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 22:53:09 -07:00
|
|
|
|
// ---- Pet system ----
|
|
|
|
|
|
case Opcode::SMSG_PET_MODE: {
|
|
|
|
|
|
// uint64 petGuid, uint32 mode
|
|
|
|
|
|
// mode bits: low byte = command state, next byte = react state
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
uint64_t modeGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t mode = packet.readUInt32();
|
|
|
|
|
|
if (modeGuid == petGuid_) {
|
|
|
|
|
|
petCommand_ = static_cast<uint8_t>(mode & 0xFF);
|
|
|
|
|
|
petReact_ = static_cast<uint8_t>((mode >> 8) & 0xFF);
|
|
|
|
|
|
LOG_DEBUG("SMSG_PET_MODE: command=", (int)petCommand_,
|
|
|
|
|
|
" react=", (int)petReact_);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_PET_BROKEN:
|
2026-03-09 22:53:09 -07:00
|
|
|
|
// Pet bond broken (died or forcibly dismissed) — clear pet state
|
|
|
|
|
|
petGuid_ = 0;
|
|
|
|
|
|
petSpellList_.clear();
|
|
|
|
|
|
petAutocastSpells_.clear();
|
|
|
|
|
|
memset(petActionSlots_, 0, sizeof(petActionSlots_));
|
|
|
|
|
|
addSystemChatMessage("Your pet has died.");
|
|
|
|
|
|
LOG_INFO("SMSG_PET_BROKEN: pet bond broken");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_PET_LEARNED_SPELL: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
petSpellList_.push_back(spellId);
|
2026-03-13 07:02:20 -07:00
|
|
|
|
const std::string& sname = getSpellName(spellId);
|
|
|
|
|
|
addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + "."));
|
2026-03-09 22:53:09 -07:00
|
|
|
|
LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PET_UNLEARNED_SPELL: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
petSpellList_.erase(
|
|
|
|
|
|
std::remove(petSpellList_.begin(), petSpellList_.end(), spellId),
|
|
|
|
|
|
petSpellList_.end());
|
|
|
|
|
|
petAutocastSpells_.erase(spellId);
|
|
|
|
|
|
LOG_DEBUG("SMSG_PET_UNLEARNED_SPELL: spellId=", spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PET_CAST_FAILED: {
|
2026-03-18 12:40:20 -07:00
|
|
|
|
// 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();
|
2026-03-09 22:53:09 -07:00
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
2026-03-13 06:30:30 -07:00
|
|
|
|
uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1)
|
|
|
|
|
|
? packet.readUInt8() : 0;
|
2026-03-09 22:53:09 -07:00
|
|
|
|
LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId,
|
2026-03-18 12:40:20 -07:00
|
|
|
|
" reason=", (int)reason);
|
2026-03-13 06:30:30 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-09 22:53:09 -07:00
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_PET_GUIDS:
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_PET_DISMISS_SOUND:
|
|
|
|
|
|
case Opcode::SMSG_PET_ACTION_SOUND:
|
2026-03-17 21:13:27 -07:00
|
|
|
|
case Opcode::SMSG_PET_UNLEARN_CONFIRM: {
|
|
|
|
|
|
// uint64 petGuid + uint32 cost (copper)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 12) {
|
|
|
|
|
|
petUnlearnGuid_ = packet.readUInt64();
|
|
|
|
|
|
petUnlearnCost_ = packet.readUInt32();
|
|
|
|
|
|
petUnlearnPending_ = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_PET_UPDATE_COMBO_POINTS:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-17 20:59:29 -07:00
|
|
|
|
case Opcode::SMSG_PET_RENAMEABLE:
|
|
|
|
|
|
// Server signals that the pet can now be named (first tame)
|
|
|
|
|
|
petRenameablePending_ = true;
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-13 07:02:20 -07:00
|
|
|
|
case Opcode::SMSG_PET_NAME_INVALID:
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("That pet name is invalid. Please choose a different name.");
|
2026-03-13 07:02:20 -07:00
|
|
|
|
addSystemChatMessage("That pet name is invalid. Please choose a different name.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
|
2026-03-12 18:15:51 -07:00
|
|
|
|
// ---- Inspect (Classic 1.12 gear inspection) ----
|
|
|
|
|
|
case Opcode::SMSG_INSPECT: {
|
|
|
|
|
|
// Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19)
|
|
|
|
|
|
// This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to
|
|
|
|
|
|
// SMSG_INSPECT_RESULTS_UPDATE which is handled separately.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (guid == 0) { packet.setReadPos(packet.getSize()); break; }
|
|
|
|
|
|
|
|
|
|
|
|
constexpr int kGearSlots = 19;
|
|
|
|
|
|
size_t needed = kGearSlots * sizeof(uint32_t);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < needed) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::array<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");
|
2026-03-09 15:27:20 -07:00
|
|
|
|
break;
|
2026-03-12 18:15:51 -07:00
|
|
|
|
}
|
2026-03-09 15:27:20 -07:00
|
|
|
|
|
|
|
|
|
|
// ---- Multiple aggregated packets/moves ----
|
|
|
|
|
|
case Opcode::SMSG_MULTIPLE_MOVES:
|
2026-03-09 19:42:27 -07:00
|
|
|
|
// Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[]
|
|
|
|
|
|
handleCompressedMoves(packet);
|
2026-03-09 15:27:20 -07:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-09 18:52:34 -07:00
|
|
|
|
case Opcode::SMSG_MULTIPLE_PACKETS: {
|
|
|
|
|
|
// Each sub-packet uses the standard WotLK server wire format:
|
|
|
|
|
|
// uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2)
|
|
|
|
|
|
// uint16_le subOpcode
|
|
|
|
|
|
// payload (subSize - 2 bytes)
|
|
|
|
|
|
const auto& pdata = packet.getData();
|
|
|
|
|
|
size_t dataLen = pdata.size();
|
|
|
|
|
|
size_t pos = packet.getReadPos();
|
|
|
|
|
|
static uint32_t multiPktWarnCount = 0;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
std::vector<network::Packet> subPackets;
|
2026-03-09 18:52:34 -07:00
|
|
|
|
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);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
subPackets.emplace_back(subOpcode, std::move(subPayload));
|
2026-03-09 18:52:34 -07:00
|
|
|
|
pos += 4 + payloadLen;
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) {
|
|
|
|
|
|
enqueueIncomingPacketFront(std::move(*it));
|
|
|
|
|
|
}
|
2026-03-09 18:52:34 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 22:35:37 -07:00
|
|
|
|
// ---- Misc consume (no state change needed) ----
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT:
|
|
|
|
|
|
case Opcode::SMSG_REDIRECT_CLIENT:
|
|
|
|
|
|
case Opcode::SMSG_PVP_QUEUE_STATS:
|
|
|
|
|
|
case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST:
|
|
|
|
|
|
case Opcode::SMSG_PLAYER_SKINNED:
|
2026-03-12 22:35:37 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-13 07:10:10 -07:00
|
|
|
|
case Opcode::SMSG_PROPOSE_LEVEL_GRANT: {
|
|
|
|
|
|
// Recruit-A-Friend: a mentor is offering to grant you a level
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t mentorGuid = packet.readUInt64();
|
|
|
|
|
|
std::string mentorName;
|
|
|
|
|
|
auto ent = entityManager.getEntity(mentorGuid);
|
|
|
|
|
|
if (auto* unit = dynamic_cast<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());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_REFER_A_FRIEND_EXPIRED:
|
|
|
|
|
|
addSystemChatMessage("Your Recruit-A-Friend link has expired.");
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_REFER_A_FRIEND_FAILURE: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t reason = packet.readUInt32();
|
|
|
|
|
|
static const char* kRafErrors[] = {
|
|
|
|
|
|
"Not eligible", // 0
|
|
|
|
|
|
"Target not eligible", // 1
|
|
|
|
|
|
"Too many referrals", // 2
|
|
|
|
|
|
"Wrong faction", // 3
|
|
|
|
|
|
"Not a recruit", // 4
|
|
|
|
|
|
"Recruit requirements not met", // 5
|
|
|
|
|
|
"Level above requirement", // 6
|
|
|
|
|
|
"Friend needs account upgrade", // 7
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* msg = (reason < 8) ? kRafErrors[reason]
|
|
|
|
|
|
: "Recruit-A-Friend failed.";
|
|
|
|
|
|
addSystemChatMessage(std::string("Recruit-A-Friend: ") + msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_REPORT_PVP_AFK_RESULT: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
if (result == 0)
|
|
|
|
|
|
addSystemChatMessage("AFK report submitted.");
|
|
|
|
|
|
else
|
|
|
|
|
|
addSystemChatMessage("Cannot report that player as AFK right now.");
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-12 23:23:02 -07:00
|
|
|
|
case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS:
|
|
|
|
|
|
handleRespondInspectAchievements(packet);
|
|
|
|
|
|
break;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE:
|
2026-03-11 00:18:23 -07:00
|
|
|
|
handleQuestPoiQueryResponse(packet);
|
|
|
|
|
|
break;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA:
|
2026-03-12 17:39:35 -07:00
|
|
|
|
vehicleId_ = 0; // Vehicle ride cancelled; clear UI
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
2026-03-09 15:27:20 -07:00
|
|
|
|
case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER:
|
|
|
|
|
|
case Opcode::SMSG_PROFILEDATA_RESPONSE:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 01:57:03 -07:00
|
|
|
|
case Opcode::SMSG_PLAY_TIME_WARNING: {
|
|
|
|
|
|
// uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t warnType = packet.readUInt32();
|
|
|
|
|
|
uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4)
|
|
|
|
|
|
? packet.readUInt32() : 0;
|
|
|
|
|
|
const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] ";
|
|
|
|
|
|
char buf[128];
|
|
|
|
|
|
if (minutesPlayed > 0) {
|
|
|
|
|
|
uint32_t h = minutesPlayed / 60;
|
|
|
|
|
|
uint32_t m = minutesPlayed % 60;
|
|
|
|
|
|
if (h > 0)
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m);
|
|
|
|
|
|
else
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity);
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
addUIError(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 15:29:08 -07:00
|
|
|
|
// ---- Item query multiple (same format as single, re-use handler) ----
|
|
|
|
|
|
case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE:
|
|
|
|
|
|
handleItemQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Object position/rotation queries ----
|
|
|
|
|
|
case Opcode::SMSG_QUERY_OBJECT_POSITION:
|
|
|
|
|
|
case Opcode::SMSG_QUERY_OBJECT_ROTATION:
|
|
|
|
|
|
case Opcode::SMSG_VOICESESSION_FULL:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 22:07:03 -07:00
|
|
|
|
// ---- Mirror image data (WotLK: Mage ability Mirror Image) ----
|
|
|
|
|
|
case Opcode::SMSG_MIRRORIMAGE_DATA: {
|
|
|
|
|
|
// WotLK 3.3.5a format:
|
|
|
|
|
|
// uint64 mirrorGuid — GUID of the mirror image unit
|
|
|
|
|
|
// uint32 displayId — display ID to render the image with
|
|
|
|
|
|
// uint8 raceId — race of caster
|
|
|
|
|
|
// uint8 genderFlag — gender of caster
|
|
|
|
|
|
// uint8 classId — class of caster
|
|
|
|
|
|
// uint64 casterGuid — GUID of the player who cast the spell
|
|
|
|
|
|
// Followed by equipped item display IDs (11 × uint32) if casterGuid != 0
|
|
|
|
|
|
// Purpose: tells client how to render the image (same appearance as caster).
|
|
|
|
|
|
// We parse the GUIDs so units render correctly via their existing display IDs.
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) break;
|
|
|
|
|
|
uint64_t mirrorGuid = packet.readUInt64();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) break;
|
|
|
|
|
|
uint32_t displayId = packet.readUInt32();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 3) break;
|
|
|
|
|
|
/*uint8_t raceId =*/ packet.readUInt8();
|
|
|
|
|
|
/*uint8_t gender =*/ packet.readUInt8();
|
|
|
|
|
|
/*uint8_t classId =*/ packet.readUInt8();
|
|
|
|
|
|
// Apply display ID to the mirror image unit so it renders correctly
|
|
|
|
|
|
if (mirrorGuid != 0 && displayId != 0) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(mirrorGuid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
auto unit = std::dynamic_pointer_cast<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());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 15:23:02 -07:00
|
|
|
|
// ---- Player movement flag changes (server-pushed) ----
|
|
|
|
|
|
case Opcode::SMSG_MOVE_GRAVITY_DISABLE:
|
2026-03-10 13:07:34 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::LEVITATING), true);
|
2026-03-10 11:36:06 -07:00
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_GRAVITY_ENABLE:
|
2026-03-10 13:07:34 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::LEVITATING), false);
|
2026-03-10 11:36:06 -07:00
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_LAND_WALK:
|
2026-03-10 13:18:04 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::WATER_WALK), false);
|
2026-03-10 11:34:56 -07:00
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_NORMAL_FALL:
|
2026-03-10 13:14:52 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FEATHER_FALL), false);
|
2026-03-10 11:34:56 -07:00
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY:
|
2026-03-10 11:40:46 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY",
|
|
|
|
|
|
Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true);
|
|
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY:
|
2026-03-10 11:40:46 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY",
|
|
|
|
|
|
Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false);
|
|
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_SET_COLLISION_HGT:
|
2026-03-10 11:40:46 -07:00
|
|
|
|
handleMoveSetCollisionHeight(packet);
|
2026-03-10 11:33:47 -07:00
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_SET_FLIGHT:
|
2026-03-10 11:33:47 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FLYING), true);
|
|
|
|
|
|
break;
|
2026-03-09 15:23:02 -07:00
|
|
|
|
case Opcode::SMSG_MOVE_UNSET_FLIGHT:
|
2026-03-10 11:33:47 -07:00
|
|
|
|
handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK,
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FLYING), false);
|
2026-03-09 15:23:02 -07:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 22:25:46 -07:00
|
|
|
|
// ---- Battlefield Manager (WotLK outdoor battlefields: Wintergrasp, Tol Barad) ----
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: {
|
|
|
|
|
|
// uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 20) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t bfGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t bfZoneId = packet.readUInt32();
|
|
|
|
|
|
uint64_t expireTime = packet.readUInt64();
|
|
|
|
|
|
(void)bfGuid; (void)expireTime;
|
|
|
|
|
|
// Store the invitation so the UI can show a prompt
|
|
|
|
|
|
bfMgrInvitePending_ = true;
|
|
|
|
|
|
bfMgrZoneId_ = bfZoneId;
|
|
|
|
|
|
char buf[128];
|
2026-03-13 08:00:46 -07:00
|
|
|
|
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);
|
2026-03-12 22:25:46 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_ENTERED: {
|
|
|
|
|
|
// uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
uint64_t bfGuid2 = packet.readUInt64();
|
|
|
|
|
|
(void)bfGuid2;
|
|
|
|
|
|
uint8_t isSafe = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0;
|
|
|
|
|
|
uint8_t onQueue = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0;
|
|
|
|
|
|
bfMgrInvitePending_ = false;
|
|
|
|
|
|
bfMgrActive_ = true;
|
|
|
|
|
|
addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)."
|
|
|
|
|
|
: "You have entered the battlefield!");
|
|
|
|
|
|
if (onQueue) addSystemChatMessage("You are in the battlefield queue.");
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", (int)isSafe, " onQueue=", (int)onQueue);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: {
|
|
|
|
|
|
// uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 20) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t bfGuid3 = packet.readUInt64();
|
|
|
|
|
|
uint32_t bfId = packet.readUInt32();
|
|
|
|
|
|
uint64_t expTime = packet.readUInt64();
|
|
|
|
|
|
(void)bfGuid3; (void)expTime;
|
|
|
|
|
|
bfMgrInvitePending_ = true;
|
|
|
|
|
|
bfMgrZoneId_ = bfId;
|
|
|
|
|
|
char buf[128];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"A spot has opened in the battlefield queue (battlefield %u).", bfId);
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: {
|
|
|
|
|
|
// uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result
|
|
|
|
|
|
// result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level,
|
|
|
|
|
|
// 4=in_cooldown, 5=queued_other_bf, 6=bf_full
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 11) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t bfId2 = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t teamId =*/ packet.readUInt32();
|
|
|
|
|
|
uint8_t accepted = packet.readUInt8();
|
|
|
|
|
|
/*uint8_t logging =*/ packet.readUInt8();
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
(void)bfId2;
|
|
|
|
|
|
if (accepted) {
|
|
|
|
|
|
addSystemChatMessage("You have joined the battlefield queue.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
static const char* kBfQueueErrors[] = {
|
|
|
|
|
|
"Queued for battlefield.", "Not in a group.", "Level too high.",
|
|
|
|
|
|
"Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.",
|
|
|
|
|
|
"Battlefield is full."
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* msg = (result < 7) ? kBfQueueErrors[result]
|
|
|
|
|
|
: "Battlefield queue request failed.";
|
|
|
|
|
|
addSystemChatMessage(std::string("Battlefield: ") + msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", (int)accepted,
|
|
|
|
|
|
" result=", (int)result);
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING: {
|
|
|
|
|
|
// uint64 battlefieldGuid + uint8 remove
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 9) {
|
|
|
|
|
|
uint64_t bfGuid4 = packet.readUInt64();
|
|
|
|
|
|
uint8_t remove = packet.readUInt8();
|
|
|
|
|
|
(void)bfGuid4;
|
|
|
|
|
|
if (remove) {
|
|
|
|
|
|
addSystemChatMessage("You will be removed from the battlefield shortly.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", (int)remove);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_EJECTED: {
|
|
|
|
|
|
// uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 17) {
|
|
|
|
|
|
uint64_t bfGuid5 = packet.readUInt64();
|
|
|
|
|
|
uint32_t reason = packet.readUInt32();
|
|
|
|
|
|
/*uint32_t status =*/ packet.readUInt32();
|
|
|
|
|
|
uint8_t relocated = packet.readUInt8();
|
|
|
|
|
|
(void)bfGuid5;
|
|
|
|
|
|
static const char* kEjectReasons[] = {
|
|
|
|
|
|
"Removed from battlefield.", "Transported from battlefield.",
|
|
|
|
|
|
"Left battlefield voluntarily.", "Offline.",
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* msg = (reason < 4) ? kEjectReasons[reason]
|
|
|
|
|
|
: "You have been ejected from the battlefield.";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
if (relocated) addSystemChatMessage("You have been relocated outside the battlefield.");
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", (int)relocated);
|
|
|
|
|
|
}
|
|
|
|
|
|
bfMgrActive_ = false;
|
|
|
|
|
|
bfMgrInvitePending_ = false;
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE: {
|
|
|
|
|
|
// uint32 oldState + uint32 newState
|
|
|
|
|
|
// States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
|
|
|
|
|
/*uint32_t oldState =*/ packet.readUInt32();
|
|
|
|
|
|
uint32_t newState = packet.readUInt32();
|
|
|
|
|
|
static const char* kBfStates[] = {
|
|
|
|
|
|
"waiting", "starting", "in progress", "ending", "in cooldown"
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state";
|
|
|
|
|
|
char buf[128];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr);
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- WotLK Calendar system (pending invites, event notifications, command results) ----
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_SEND_NUM_PENDING: {
|
|
|
|
|
|
// uint32 numPending — number of unacknowledged calendar invites
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t numPending = packet.readUInt32();
|
|
|
|
|
|
calendarPendingInvites_ = numPending;
|
|
|
|
|
|
if (numPending > 0) {
|
|
|
|
|
|
char buf[64];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"You have %u pending calendar invite%s.",
|
|
|
|
|
|
numPending, numPending == 1 ? "" : "s");
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_COMMAND_RESULT: {
|
|
|
|
|
|
// uint32 command + uint8 result + cstring info
|
|
|
|
|
|
// result 0 = success; non-zero = error code
|
|
|
|
|
|
// command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove,
|
|
|
|
|
|
// 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 5) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
/*uint32_t command =*/ packet.readUInt32();
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
std::string info = (packet.getReadPos() < packet.getSize()) ? packet.readString() : "";
|
|
|
|
|
|
if (result != 0) {
|
|
|
|
|
|
// Map common calendar error codes to friendly strings
|
|
|
|
|
|
static const char* kCalendarErrors[] = {
|
|
|
|
|
|
"",
|
|
|
|
|
|
"Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL
|
|
|
|
|
|
"Calendar: Guild event limit reached.",// 2
|
|
|
|
|
|
"Calendar: Event limit reached.", // 3
|
|
|
|
|
|
"Calendar: You cannot invite that player.", // 4
|
|
|
|
|
|
"Calendar: No invites remaining.", // 5
|
|
|
|
|
|
"Calendar: Invalid date.", // 6
|
|
|
|
|
|
"Calendar: Cannot invite yourself.", // 7
|
|
|
|
|
|
"Calendar: Cannot modify this event.", // 8
|
|
|
|
|
|
"Calendar: Not invited.", // 9
|
|
|
|
|
|
"Calendar: Already invited.", // 10
|
|
|
|
|
|
"Calendar: Player not found.", // 11
|
|
|
|
|
|
"Calendar: Not enough focus.", // 12
|
|
|
|
|
|
"Calendar: Event locked.", // 13
|
|
|
|
|
|
"Calendar: Event deleted.", // 14
|
|
|
|
|
|
"Calendar: Not a moderator.", // 15
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* errMsg = (result < 16) ? kCalendarErrors[result]
|
|
|
|
|
|
: "Calendar: Command failed.";
|
|
|
|
|
|
if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg);
|
|
|
|
|
|
else if (!info.empty()) addSystemChatMessage("Calendar: " + info);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT: {
|
|
|
|
|
|
// Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) +
|
|
|
|
|
|
// eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) +
|
|
|
|
|
|
// isGuildEvent(1) + inviterGuid(8)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
/*uint64_t eventId =*/ packet.readUInt64();
|
|
|
|
|
|
std::string title = (packet.getReadPos() < packet.getSize()) ? packet.readString() : "";
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); // consume remaining fields
|
|
|
|
|
|
if (!title.empty()) {
|
|
|
|
|
|
addSystemChatMessage("Calendar invite: " + title);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("You have a new calendar invite.");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (calendarPendingInvites_ < 255) ++calendarPendingInvites_;
|
|
|
|
|
|
LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Remaining calendar informational packets — parse title where possible and consume
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_STATUS: {
|
|
|
|
|
|
// Sent when an event invite's RSVP status changes for the local player
|
|
|
|
|
|
// Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) +
|
|
|
|
|
|
// inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 31) {
|
|
|
|
|
|
packet.setReadPos(packet.getSize()); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
/*uint64_t inviteId =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint64_t eventId =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint8_t evType =*/ packet.readUInt8();
|
|
|
|
|
|
/*uint32_t flags =*/ packet.readUInt32();
|
|
|
|
|
|
/*uint64_t invTime =*/ packet.readUInt64();
|
|
|
|
|
|
uint8_t status = packet.readUInt8();
|
|
|
|
|
|
/*uint8_t rank =*/ packet.readUInt8();
|
|
|
|
|
|
/*uint8_t isGuild =*/ packet.readUInt8();
|
|
|
|
|
|
std::string evTitle = (packet.getReadPos() < packet.getSize()) ? packet.readString() : "";
|
|
|
|
|
|
// status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative
|
|
|
|
|
|
static const char* kRsvpStatus[] = {
|
|
|
|
|
|
"invited", "accepted", "declined", "confirmed",
|
|
|
|
|
|
"out", "on standby", "signed up", "not signed up", "tentative"
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown";
|
|
|
|
|
|
if (!evTitle.empty()) {
|
|
|
|
|
|
char buf[256];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.",
|
|
|
|
|
|
evTitle.c_str(), statusStr);
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED: {
|
|
|
|
|
|
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 28) {
|
|
|
|
|
|
/*uint64_t inviteId =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint64_t eventId =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
uint32_t difficulty = packet.readUInt32();
|
|
|
|
|
|
/*uint64_t resetTime =*/ packet.readUInt64();
|
2026-03-13 07:14:40 -07:00
|
|
|
|
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);
|
2026-03-12 22:25:46 -07:00
|
|
|
|
LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: {
|
|
|
|
|
|
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 20) {
|
|
|
|
|
|
/*uint64_t inviteId =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint64_t eventId =*/ packet.readUInt64();
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
uint32_t difficulty = packet.readUInt32();
|
2026-03-13 07:14:40 -07:00
|
|
|
|
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);
|
2026-03-12 22:25:46 -07:00
|
|
|
|
LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId,
|
|
|
|
|
|
" difficulty=", difficulty);
|
|
|
|
|
|
}
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED: {
|
|
|
|
|
|
// Same format as LOCKOUT_ADDED; consume
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Remaining calendar opcodes: safe consume — data surfaced via SEND_CALENDAR/SEND_EVENT
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_SEND_CALENDAR:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_SEND_EVENT:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_ARENA_TEAM:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_FILTER_GUILD:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT:
|
|
|
|
|
|
case Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-03-12 22:35:37 -07:00
|
|
|
|
case Opcode::SMSG_SERVERTIME: {
|
|
|
|
|
|
// uint32 unixTime — server's current unix timestamp; use to sync gameTime_
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t srvTime = packet.readUInt32();
|
|
|
|
|
|
if (srvTime > 0) {
|
|
|
|
|
|
gameTime_ = static_cast<float>(srvTime);
|
|
|
|
|
|
LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_KICK_REASON: {
|
|
|
|
|
|
// uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string
|
|
|
|
|
|
// kickReasonType: 0=other, 1=afk, 2=vote kick
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (!packetHasRemaining(packet, 12)) {
|
2026-03-12 22:35:37 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t kickerGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t reasonType = packet.readUInt32();
|
|
|
|
|
|
std::string reason;
|
2026-03-14 22:18:28 -07:00
|
|
|
|
if (packet.getReadPos() < packet.getSize())
|
2026-03-12 22:35:37 -07:00
|
|
|
|
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.";
|
2026-03-13 07:37:40 -07:00
|
|
|
|
else if (reasonType == 2)
|
|
|
|
|
|
msg = "You have been removed from the group by vote.";
|
2026-03-12 22:35:37 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
addUIError(msg);
|
|
|
|
|
|
LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType,
|
|
|
|
|
|
" reason='", reason, "'");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_GROUPACTION_THROTTLED: {
|
|
|
|
|
|
// uint32 throttleMs — rate-limited group action; notify the player
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (packetHasRemaining(packet, 4)) {
|
2026-03-12 22:35:37 -07:00
|
|
|
|
uint32_t throttleMs = packet.readUInt32();
|
|
|
|
|
|
char buf[128];
|
|
|
|
|
|
if (throttleMs > 0) {
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"Group action throttled. Please wait %.1f seconds.",
|
|
|
|
|
|
throttleMs / 1000.0f);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf), "Group action throttled.");
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_GMRESPONSE_RECEIVED: {
|
|
|
|
|
|
// WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count
|
|
|
|
|
|
// per count: string responseText
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (!packetHasRemaining(packet, 4)) {
|
2026-03-12 22:35:37 -07:00
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t ticketId = packet.readUInt32();
|
|
|
|
|
|
std::string subject;
|
|
|
|
|
|
std::string body;
|
2026-03-14 22:18:28 -07:00
|
|
|
|
if (packet.getReadPos() < packet.getSize()) subject = packet.readString();
|
|
|
|
|
|
if (packet.getReadPos() < packet.getSize()) body = packet.readString();
|
2026-03-12 22:35:37 -07:00
|
|
|
|
uint32_t responseCount = 0;
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (packetHasRemaining(packet, 4))
|
2026-03-12 22:35:37 -07:00
|
|
|
|
responseCount = packet.readUInt32();
|
|
|
|
|
|
std::string responseText;
|
|
|
|
|
|
for (uint32_t i = 0; i < responseCount && i < 10; ++i) {
|
2026-03-14 22:18:28 -07:00
|
|
|
|
if (packet.getReadPos() < packet.getSize()) {
|
2026-03-12 22:35:37 -07:00
|
|
|
|
std::string t = packet.readString();
|
|
|
|
|
|
if (i == 0) responseText = t;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(void)ticketId;
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
if (!responseText.empty())
|
|
|
|
|
|
msg = "[GM Response] " + responseText;
|
|
|
|
|
|
else if (!body.empty())
|
|
|
|
|
|
msg = "[GM Response] " + body;
|
|
|
|
|
|
else if (!subject.empty())
|
|
|
|
|
|
msg = "[GM Response] " + subject;
|
|
|
|
|
|
else
|
|
|
|
|
|
msg = "[GM Response] Your ticket has been answered.";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
addUIError(msg);
|
|
|
|
|
|
LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId,
|
|
|
|
|
|
" subject='", subject, "'");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_GMRESPONSE_STATUS_UPDATE: {
|
|
|
|
|
|
// uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 5) {
|
|
|
|
|
|
uint32_t ticketId = packet.readUInt32();
|
|
|
|
|
|
uint8_t status = packet.readUInt8();
|
|
|
|
|
|
const char* statusStr = (status == 1) ? "open"
|
|
|
|
|
|
: (status == 2) ? "answered"
|
|
|
|
|
|
: (status == 3) ? "needs more info"
|
|
|
|
|
|
: "updated";
|
|
|
|
|
|
char buf[128];
|
|
|
|
|
|
std::snprintf(buf, sizeof(buf),
|
|
|
|
|
|
"[GM Ticket #%u] Status: %s.", ticketId, statusStr);
|
|
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId,
|
|
|
|
|
|
" status=", static_cast<int>(status));
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Voice chat (WotLK built-in voice) — consume silently ----
|
|
|
|
|
|
case Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE:
|
|
|
|
|
|
case Opcode::SMSG_VOICE_SESSION_LEAVE:
|
|
|
|
|
|
case Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY:
|
|
|
|
|
|
case Opcode::SMSG_VOICE_SET_TALKER_MUTED:
|
|
|
|
|
|
case Opcode::SMSG_VOICE_SESSION_ENABLE:
|
|
|
|
|
|
case Opcode::SMSG_VOICE_PARENTAL_CONTROLS:
|
|
|
|
|
|
case Opcode::SMSG_AVAILABLE_VOICE_CHANNEL:
|
|
|
|
|
|
case Opcode::SMSG_VOICE_CHAT_STATUS:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Dance / custom emote system (WotLK) — consume silently ----
|
|
|
|
|
|
case Opcode::SMSG_NOTIFY_DANCE:
|
|
|
|
|
|
case Opcode::SMSG_PLAY_DANCE:
|
|
|
|
|
|
case Opcode::SMSG_STOP_DANCE:
|
|
|
|
|
|
case Opcode::SMSG_DANCE_QUERY_RESPONSE:
|
|
|
|
|
|
case Opcode::SMSG_INVALIDATE_DANCE:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Commentator / spectator mode — consume silently ----
|
|
|
|
|
|
case Opcode::SMSG_COMMENTATOR_STATE_CHANGED:
|
|
|
|
|
|
case Opcode::SMSG_COMMENTATOR_MAP_INFO:
|
|
|
|
|
|
case Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO:
|
|
|
|
|
|
case Opcode::SMSG_COMMENTATOR_PLAYER_INFO:
|
|
|
|
|
|
case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1:
|
|
|
|
|
|
case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Debug / cheat / GM-only opcodes — consume silently ----
|
|
|
|
|
|
case Opcode::SMSG_DBLOOKUP:
|
|
|
|
|
|
case Opcode::SMSG_CHECK_FOR_BOTS:
|
|
|
|
|
|
case Opcode::SMSG_GODMODE:
|
|
|
|
|
|
case Opcode::SMSG_PETGODMODE:
|
|
|
|
|
|
case Opcode::SMSG_DEBUG_AISTATE:
|
|
|
|
|
|
case Opcode::SMSG_DEBUGAURAPROC:
|
|
|
|
|
|
case Opcode::SMSG_TEST_DROP_RATE_RESULT:
|
|
|
|
|
|
case Opcode::SMSG_COOLDOWN_CHEAT:
|
|
|
|
|
|
case Opcode::SMSG_GM_PLAYER_INFO:
|
|
|
|
|
|
case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE:
|
|
|
|
|
|
case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE:
|
|
|
|
|
|
case Opcode::SMSG_CHEAT_PLAYER_LOOKUP:
|
|
|
|
|
|
case Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT:
|
|
|
|
|
|
case Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT:
|
|
|
|
|
|
case Opcode::SMSG_DEBUG_LIST_TARGETS:
|
|
|
|
|
|
case Opcode::SMSG_DEBUG_SERVER_GEO:
|
|
|
|
|
|
case Opcode::SMSG_DUMP_OBJECTS_DATA:
|
|
|
|
|
|
case Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE:
|
|
|
|
|
|
case Opcode::SMSG_FORCEACTIONSHOW:
|
|
|
|
|
|
case Opcode::SMSG_MOVE_CHARACTER_CHEAT:
|
|
|
|
|
|
packet.setReadPos(packet.getSize());
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
default:
|
2026-02-12 01:53:21 -08:00
|
|
|
|
// 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) {
|
2026-02-21 04:05:53 -08:00
|
|
|
|
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);
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
|
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
2026-02-11 22:27:02 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-22 07:26:54 -08:00
|
|
|
|
} 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());
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-03-16 17:38:25 -07:00
|
|
|
|
lastRxTime_ = std::chrono::steady_clock::now();
|
|
|
|
|
|
rxSilenceLogged_ = false;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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), ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
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,
|
2026-02-13 01:51:49 -08:00
|
|
|
|
serverSeed,
|
|
|
|
|
|
realmId_
|
2026-02-02 12:24:50 -08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("CMSG_AUTH_SESSION packet size: ", packet.getSize(), " bytes");
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Send packet (unencrypted - this is the last unencrypted packet)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// 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");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
socket->initEncryption(sessionKey, build);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
setState(WorldState::AUTH_SENT);
|
2026-02-05 21:03:11 -08:00
|
|
|
|
LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE...");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Encryption was already enabled after sending AUTH_SESSION
|
|
|
|
|
|
LOG_INFO("AUTH_RESPONSE OK - world authentication successful");
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Request character list automatically
|
|
|
|
|
|
requestCharacterList();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Call success callback
|
|
|
|
|
|
if (onSuccess) {
|
|
|
|
|
|
onSuccess();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestCharacterList() {
|
2026-02-12 01:53:21 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
if (state != WorldState::READY && state != WorldState::AUTHENTICATED &&
|
|
|
|
|
|
state != WorldState::CHAR_LIST_RECEIVED) {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Cannot request character list in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Requesting character list from server...");
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// 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;
|
2026-02-13 19:40:54 -08:00
|
|
|
|
// 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);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (!parsed) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
fail("Failed to parse SMSG_CHAR_ENUM");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store characters
|
|
|
|
|
|
characters = response.characters;
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::CHAR_LIST_RECEIVED);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" CHARACTER LIST RECEIVED");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Found ", characters.size(), " character(s)");
|
|
|
|
|
|
|
|
|
|
|
|
if (characters.empty()) {
|
|
|
|
|
|
LOG_INFO("No characters on this account");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("Characters:");
|
|
|
|
|
|
for (size_t i = 0; i < characters.size(); ++i) {
|
|
|
|
|
|
const auto& character = characters[i];
|
|
|
|
|
|
LOG_INFO(" [", i + 1, "] ", character.name);
|
|
|
|
|
|
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
|
|
|
|
|
|
LOG_INFO(" ", getRaceName(character.race), " ",
|
|
|
|
|
|
getClassName(character.characterClass));
|
|
|
|
|
|
LOG_INFO(" Level ", (int)character.level);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Ready to select character");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
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.";
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Blocking CMSG_CHAR_CREATE in state=", worldStateName(state),
|
2026-02-12 01:53:21 -08:00
|
|
|
|
" (awaiting CHAR_LIST_RECEIVED)");
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(false, msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 04:16:27 -08:00
|
|
|
|
if (data.result == CharCreateResult::SUCCESS || data.result == CharCreateResult::IN_PROGRESS) {
|
|
|
|
|
|
LOG_INFO("Character created successfully (code=", static_cast<int>(data.result), ")");
|
2026-02-05 14:13:48 -08:00
|
|
|
|
requestCharacterList();
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(true, "Character created!");
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
switch (data.result) {
|
2026-02-18 18:39:07 -08:00
|
|
|
|
case CharCreateResult::CHAR_ERROR: msg = "Server error"; break;
|
2026-02-05 21:03:11 -08:00
|
|
|
|
case CharCreateResult::FAILED: msg = "Creation failed"; break;
|
2026-02-05 14:13:48 -08:00
|
|
|
|
case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break;
|
|
|
|
|
|
case CharCreateResult::DISABLED: msg = "Character creation disabled"; break;
|
2026-02-05 21:03:11 -08:00
|
|
|
|
case CharCreateResult::PVP_TEAMS_VIOLATION: msg = "PvP faction violation"; break;
|
2026-02-05 14:13:48 -08:00
|
|
|
|
case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break;
|
|
|
|
|
|
case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break;
|
2026-02-05 21:03:11 -08:00
|
|
|
|
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;
|
2026-02-17 04:16:27 -08:00
|
|
|
|
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;
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// 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;
|
2026-02-05 14:13:48 -08:00
|
|
|
|
}
|
2026-02-05 21:03:11 -08:00
|
|
|
|
LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast<int>(data.result), ")");
|
2026-02-05 14:13:48 -08:00
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(false, msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:24:46 -08:00
|
|
|
|
void GameHandler::deleteCharacter(uint64_t characterGuid) {
|
|
|
|
|
|
if (!socket) {
|
|
|
|
|
|
if (charDeleteCallback_) charDeleteCallback_(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE));
|
2026-02-06 03:24:46 -08:00
|
|
|
|
packet.writeUInt64(characterGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 14:38:58 -08:00
|
|
|
|
|
2026-02-05 15:07:13 -08:00
|
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
|
|
2026-02-05 14:38:58 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 14:55:42 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
|
|
2026-02-17 13:59:29 -08:00
|
|
|
|
void GameHandler::handleCharLoginFailed(network::Packet& packet) {
|
|
|
|
|
|
uint8_t reason = packet.readUInt8();
|
|
|
|
|
|
|
|
|
|
|
|
static const char* reasonNames[] = {
|
|
|
|
|
|
"Login failed", // 0
|
|
|
|
|
|
"World server is down", // 1
|
|
|
|
|
|
"Duplicate character", // 2 (session still active)
|
|
|
|
|
|
"No instance servers", // 3
|
|
|
|
|
|
"Login disabled", // 4
|
|
|
|
|
|
"Character not found", // 5
|
|
|
|
|
|
"Locked for transfer", // 6
|
|
|
|
|
|
"Locked by billing", // 7
|
|
|
|
|
|
"Using remote", // 8
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* msg = (reason < 9) ? reasonNames[reason] : "Unknown reason";
|
|
|
|
|
|
|
|
|
|
|
|
LOG_ERROR("SMSG_CHARACTER_LOGIN_FAILED: reason=", (int)reason, " (", msg, ")");
|
|
|
|
|
|
|
|
|
|
|
|
// Allow the player to re-select a character
|
|
|
|
|
|
setState(WorldState::CHAR_LIST_RECEIVED);
|
|
|
|
|
|
|
|
|
|
|
|
if (charLoginFailCallback_) {
|
|
|
|
|
|
charLoginFailCallback_(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::selectCharacter(uint64_t characterGuid) {
|
|
|
|
|
|
if (state != WorldState::CHAR_LIST_RECEIVED) {
|
|
|
|
|
|
LOG_WARNING("Cannot select character in state: ", (int)state);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
// Make the selected character authoritative in GameHandler.
|
|
|
|
|
|
// This avoids relying on UI/Application ordering for appearance-dependent logic.
|
|
|
|
|
|
activeCharacterGuid_ = characterGuid;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" ENTERING WORLD");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec);
|
|
|
|
|
|
|
|
|
|
|
|
// Find character name for logging
|
|
|
|
|
|
for (const auto& character : characters) {
|
|
|
|
|
|
if (character.guid == characterGuid) {
|
|
|
|
|
|
LOG_INFO("Character: ", character.name);
|
|
|
|
|
|
LOG_INFO("Level ", (int)character.level, " ",
|
|
|
|
|
|
getRaceName(character.race), " ",
|
|
|
|
|
|
getClassName(character.characterClass));
|
2026-02-08 03:05:38 -08:00
|
|
|
|
playerRace_ = character.race;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Store player GUID
|
|
|
|
|
|
playerGuid = characterGuid;
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
// Reset per-character state so previous character data doesn't bleed through
|
|
|
|
|
|
inventory = Inventory();
|
|
|
|
|
|
onlineItems_.clear();
|
2026-02-19 06:34:06 -08:00
|
|
|
|
itemInfoCache_.clear();
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
pendingItemQueries_.clear();
|
|
|
|
|
|
equipSlotGuids_ = {};
|
|
|
|
|
|
backpackSlotGuids_ = {};
|
2026-03-14 08:42:25 -07:00
|
|
|
|
keyringSlotGuids_ = {};
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
invSlotBase_ = -1;
|
|
|
|
|
|
packSlotBase_ = -1;
|
|
|
|
|
|
lastPlayerFields_.clear();
|
|
|
|
|
|
onlineEquipDirty_ = false;
|
|
|
|
|
|
playerMoneyCopper_ = 0;
|
2026-02-19 17:45:09 -08:00
|
|
|
|
playerArmorRating_ = 0;
|
2026-03-12 12:24:15 -07:00
|
|
|
|
std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0);
|
2026-03-10 23:08:15 -07:00
|
|
|
|
std::fill(std::begin(playerStats_), std::end(playerStats_), -1);
|
2026-03-13 08:35:18 -07:00
|
|
|
|
playerMeleeAP_ = -1;
|
|
|
|
|
|
playerRangedAP_ = -1;
|
2026-03-13 08:37:55 -07:00
|
|
|
|
std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1);
|
|
|
|
|
|
playerHealBonus_ = -1;
|
2026-03-13 08:35:18 -07:00
|
|
|
|
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);
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
knownSpells.clear();
|
|
|
|
|
|
spellCooldowns.clear();
|
2026-03-12 23:59:38 -07:00
|
|
|
|
spellFlatMods_.clear();
|
|
|
|
|
|
spellPctMods_.clear();
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
actionBar = {};
|
|
|
|
|
|
playerAuras.clear();
|
|
|
|
|
|
targetAuras.clear();
|
2026-03-12 11:44:30 -07:00
|
|
|
|
unitAurasCache_.clear();
|
2026-03-09 23:13:30 -07:00
|
|
|
|
unitCastStates_.clear();
|
2026-02-26 10:41:29 -08:00
|
|
|
|
petGuid_ = 0;
|
feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET)
- Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with
petNumber, entry, level, name, displayId, and active status
- Detect stable master via gossip option text/keyword matching and
auto-send MSG_LIST_STABLED_PETS request to open the stable UI
- Refresh list automatically after SMSG_STABLE_RESULT to reflect state
- New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket
- New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber)
- Stable window UI: shows active/stabled pets with store/retrieve buttons,
slot count, refresh, and close; opens when server sends pet list
- Clear stable state on world logout/disconnect
2026-03-12 19:15:52 -07:00
|
|
|
|
stableWindowOpen_ = false;
|
|
|
|
|
|
stableMasterGuid_ = 0;
|
|
|
|
|
|
stableNumSlots_ = 0;
|
|
|
|
|
|
stabledPets_.clear();
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
playerXp_ = 0;
|
|
|
|
|
|
playerNextLevelXp_ = 0;
|
|
|
|
|
|
serverPlayerLevel_ = 1;
|
2026-02-11 18:25:04 -08:00
|
|
|
|
std::fill(playerExploredZones_.begin(), playerExploredZones_.end(), 0u);
|
|
|
|
|
|
hasPlayerExploredZones_ = false;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
playerSkills_.clear();
|
|
|
|
|
|
questLog_.clear();
|
2026-02-19 00:30:21 -08:00
|
|
|
|
pendingQuestQueryIds_.clear();
|
2026-02-20 17:14:13 -08:00
|
|
|
|
pendingLoginQuestResync_ = false;
|
|
|
|
|
|
pendingLoginQuestResyncTimeout_ = 0.0f;
|
|
|
|
|
|
pendingQuestAcceptTimeouts_.clear();
|
|
|
|
|
|
pendingQuestAcceptNpcGuids_.clear();
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
npcQuestStatus_.clear();
|
|
|
|
|
|
hostileAttackers_.clear();
|
|
|
|
|
|
combatText.clear();
|
|
|
|
|
|
autoAttacking = false;
|
|
|
|
|
|
autoAttackTarget = 0;
|
|
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
currentCastSpellId = 0;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
pendingGameObjectInteractGuid_ = 0;
|
2026-03-13 05:02:58 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
castTimeTotal = 0.0f;
|
2026-03-18 01:15:04 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
playerDead_ = false;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
releasedSpirit_ = false;
|
2026-03-14 08:27:32 -07:00
|
|
|
|
corpseGuid_ = 0;
|
2026-03-17 23:52:45 -07:00
|
|
|
|
corpseReclaimAvailableMs_ = 0;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
targetGuid = 0;
|
|
|
|
|
|
focusGuid = 0;
|
|
|
|
|
|
lastTargetGuid = 0;
|
|
|
|
|
|
tabCycleStale = true;
|
|
|
|
|
|
entityManager = EntityManager();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// 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...");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
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)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
|
|
|
|
|
LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD");
|
2026-02-20 17:14:13 -08:00
|
|
|
|
const bool initialWorldEntry = (state == WorldState::ENTERING_WORLD);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Successfully entered the world (or teleported)
|
|
|
|
|
|
currentMapId_ = data.mapId;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
setState(WorldState::IN_WORLD);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world");
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-02-04 18:27:52 -08:00
|
|
|
|
// Initialize movement info with world entry position (server → canonical)
|
2026-03-10 04:56:42 -07:00
|
|
|
|
LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
|
2026-02-27 04:59:12 -08:00
|
|
|
|
") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId);
|
2026-02-04 18:27:52 -08:00
|
|
|
|
movementInfo.x = canonical.x;
|
|
|
|
|
|
movementInfo.y = canonical.y;
|
|
|
|
|
|
movementInfo.z = canonical.z;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = core::coords::serverToCanonicalYaw(data.orientation);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
2026-02-20 02:19:17 -08:00
|
|
|
|
movementClockStart_ = std::chrono::steady_clock::now();
|
|
|
|
|
|
lastMovementTimestampMs_ = 0;
|
|
|
|
|
|
movementInfo.time = nextMovementTimestampMs();
|
2026-03-10 12:36:56 -07:00
|
|
|
|
isFalling_ = false;
|
|
|
|
|
|
fallStartMs_ = 0;
|
|
|
|
|
|
movementInfo.fallTime = 0;
|
|
|
|
|
|
movementInfo.jumpVelocity = 0.0f;
|
|
|
|
|
|
movementInfo.jumpSinAngle = 0.0f;
|
|
|
|
|
|
movementInfo.jumpCosAngle = 0.0f;
|
|
|
|
|
|
movementInfo.jumpXYSpeed = 0.0f;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
resurrectPending_ = false;
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
2026-03-18 00:06:39 -07:00
|
|
|
|
selfResAvailable_ = false;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
onTaxiFlight_ = false;
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
taxiStartGrace_ = 0.0f;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
2026-03-12 17:25:00 -07:00
|
|
|
|
vehicleId_ = 0;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-10 06:10:29 -07:00
|
|
|
|
// Clear boss encounter unit slots and raid marks on world transfer
|
2026-03-09 19:54:32 -07:00
|
|
|
|
encounterUnitGuids_.fill(0);
|
2026-03-10 06:10:29 -07:00
|
|
|
|
raidTargetGuids_.fill(0);
|
2026-03-09 19:54:32 -07:00
|
|
|
|
|
2026-03-02 08:19:14 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-02-05 21:28:21 -08:00
|
|
|
|
// Notify application to load terrain for this map/position (online mode)
|
|
|
|
|
|
if (worldEntryCallback_) {
|
2026-03-10 08:35:36 -07:00
|
|
|
|
worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry);
|
2026-02-05 21:28:21 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-14 14:30:09 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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) {
|
2026-03-15 06:13:36 -07:00
|
|
|
|
LOG_DEBUG("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD");
|
2026-03-15 01:21:23 -07:00
|
|
|
|
sendPing();
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 17:14:13 -08:00
|
|
|
|
|
|
|
|
|
|
if (initialWorldEntry) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
pendingQuestAcceptTimeouts_.clear();
|
|
|
|
|
|
pendingQuestAcceptNpcGuids_.clear();
|
|
|
|
|
|
pendingQuestQueryIds_.clear();
|
|
|
|
|
|
pendingLoginQuestResync_ = true;
|
|
|
|
|
|
pendingLoginQuestResyncTimeout_ = 10.0f;
|
2026-03-09 15:32:11 -07:00
|
|
|
|
completedQuests_.clear();
|
2026-02-20 17:14:13 -08:00
|
|
|
|
LOG_INFO("Queued quest log resync for login (from server quest slots)");
|
2026-03-09 15:32:11 -07:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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.
|
2026-03-09 15:32:11 -07:00
|
|
|
|
if (socket) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-03-09 15:32:11 -07:00
|
|
|
|
}
|
2026-03-20 05:17:27 -07:00
|
|
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
2026-02-20 17:14:13 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
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], "]");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) {
|
|
|
|
|
|
wardenCREntries_.clear();
|
|
|
|
|
|
|
|
|
|
|
|
// Look for .cr file in warden cache
|
2026-02-18 17:38:08 -08:00
|
|
|
|
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";
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-20 01:37:20 -08:00
|
|
|
|
// 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"};
|
2026-02-13 18:59:09 -08:00
|
|
|
|
for (int i = 0; i < 9; i++) {
|
|
|
|
|
|
char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s;
|
|
|
|
|
|
}
|
2026-03-17 07:19:37 -07:00
|
|
|
|
LOG_WARNING("Warden: Check opcodes: ", opcHex);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
void GameHandler::handleWardenData(network::Packet& packet) {
|
|
|
|
|
|
const auto& data = packet.getData();
|
|
|
|
|
|
if (!wardenGateSeen_) {
|
|
|
|
|
|
wardenGateSeen_ = true;
|
|
|
|
|
|
wardenGateElapsed_ = 0.0f;
|
|
|
|
|
|
wardenGateNextStatusLog_ = 2.0f;
|
|
|
|
|
|
wardenPacketsAfterGate_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Initialize Warden crypto from session key on first packet
|
2026-02-12 02:09:15 -08:00
|
|
|
|
if (!wardenCrypto_) {
|
|
|
|
|
|
wardenCrypto_ = std::make_unique<WardenCrypto>();
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (sessionKey.size() != 40) {
|
|
|
|
|
|
LOG_ERROR("Warden: No valid session key (size=", sessionKey.size(), "), cannot init crypto");
|
2026-02-12 02:09:15 -08:00
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (!wardenCrypto_->initFromSessionKey(sessionKey)) {
|
|
|
|
|
|
LOG_ERROR("Warden: Failed to initialize crypto from session key");
|
|
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
return;
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_USE;
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Decrypt the payload
|
|
|
|
|
|
std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data);
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-25 09:50:33 -08:00
|
|
|
|
// Avoid expensive hex formatting when DEBUG logs are disabled.
|
|
|
|
|
|
if (core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG)) {
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::string hex;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
size_t logSize = std::min(decrypted.size(), size_t(256));
|
2026-02-13 16:53:28 -08:00
|
|
|
|
hex.reserve(logSize * 3);
|
|
|
|
|
|
for (size_t i = 0; i < logSize; ++i) {
|
2026-02-25 09:50:33 -08:00
|
|
|
|
char b[4];
|
|
|
|
|
|
snprintf(b, sizeof(b), "%02x ", decrypted[i]);
|
|
|
|
|
|
hex += b;
|
2026-02-12 02:09:15 -08:00
|
|
|
|
}
|
2026-02-25 09:50:33 -08:00
|
|
|
|
if (decrypted.size() > 64) {
|
2026-02-13 16:53:28 -08:00
|
|
|
|
hex += "... (" + std::to_string(decrypted.size() - 64) + " more)";
|
2026-02-25 09:50:33 -08:00
|
|
|
|
}
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Decrypted (", decrypted.size(), " bytes): ", hex);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
}
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (decrypted.empty()) {
|
|
|
|
|
|
LOG_WARNING("Warden: Empty decrypted payload");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
uint8_t wardenOpcode = decrypted[0];
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Helper to send an encrypted Warden response
|
|
|
|
|
|
auto sendWardenResponse = [&](const std::vector<uint8_t>& plaintext) {
|
|
|
|
|
|
std::vector<uint8_t> encrypted = wardenCrypto_->encrypt(plaintext);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
|
2026-02-13 16:53:28 -08:00
|
|
|
|
for (uint8_t byte : encrypted) {
|
2026-02-12 02:09:15 -08:00
|
|
|
|
response.writeUInt8(byte);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (socket && socket->isConnected()) {
|
|
|
|
|
|
socket->send(response);
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Sent response (", plaintext.size(), " bytes plaintext)");
|
2026-02-12 02:09:15 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
|
|
{
|
2026-02-19 16:17:06 -08:00
|
|
|
|
std::string hashHex;
|
2026-02-13 16:53:28 -08:00
|
|
|
|
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: MODULE_USE hash=", hashHex, " size=", wardenModuleSize_);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// Try to load pre-computed challenge/response entries
|
|
|
|
|
|
loadWardenCRFile(hashHex);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// 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;
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Sent MODULE_MISSING, waiting for module data chunks");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
uint16_t chunkSize = static_cast<uint16_t>(decrypted[1])
|
|
|
|
|
|
| (static_cast<uint16_t>(decrypted[2]) << 8);
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (decrypted.size() < 3u + chunkSize) {
|
|
|
|
|
|
LOG_ERROR("Warden: MODULE_CACHE chunk truncated (claimed ", chunkSize,
|
|
|
|
|
|
", have ", decrypted.size() - 3, ")");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenModuleData_.insert(wardenModuleData_.end(),
|
|
|
|
|
|
decrypted.begin() + 3,
|
|
|
|
|
|
decrypted.begin() + 3 + chunkSize);
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ",
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenModuleData_.size(), "/", wardenModuleSize_);
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Check if module download is complete
|
|
|
|
|
|
if (wardenModuleData_.size() >= wardenModuleSize_) {
|
|
|
|
|
|
LOG_INFO("Warden: Module download complete (",
|
|
|
|
|
|
wardenModuleData_.size(), " bytes)");
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_HASH_REQUEST;
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-14 19:20:32 -08:00
|
|
|
|
// Cache raw module to disk
|
|
|
|
|
|
{
|
2026-02-18 17:38:08 -08:00
|
|
|
|
#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
|
2026-02-14 19:20:32 -08:00
|
|
|
|
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());
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Cached module to ", cachePath);
|
2026-02-14 19:20:32 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load the module (decrypt, decompress, parse, relocate)
|
|
|
|
|
|
wardenLoadedModule_ = std::make_shared<WardenModule>();
|
2026-02-19 17:06:49 -08:00
|
|
|
|
if (wardenLoadedModule_->load(wardenModuleData_, wardenModuleHash_, wardenModuleKey_)) { // codeql[cpp/weak-cryptographic-algorithm]
|
2026-02-14 19:20:32 -08:00
|
|
|
|
LOG_INFO("Warden: Module loaded successfully (image size=",
|
|
|
|
|
|
wardenLoadedModule_->getModuleSize(), " bytes)");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_ERROR("Warden: Module loading FAILED");
|
|
|
|
|
|
wardenLoadedModule_.reset();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Send MODULE_OK (opcode 0x01)
|
|
|
|
|
|
std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK
|
|
|
|
|
|
sendWardenResponse(resp);
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Sent MODULE_OK");
|
2026-02-12 03:50:28 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// No response for intermediate chunks
|
|
|
|
|
|
break;
|
2026-02-12 03:50:28 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
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;
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::vector<uint8_t> seed(decrypted.begin() + 1, decrypted.begin() + 17);
|
2026-03-14 22:18:28 -07:00
|
|
|
|
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");
|
|
|
|
|
|
};
|
2026-02-12 02:22:04 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// --- 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (match) {
|
2026-03-16 20:55:30 -07:00
|
|
|
|
LOG_WARNING("Warden: HASH_REQUEST — CR entry MATCHED, sending pre-computed reply");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-03-16 20:55:30 -07:00
|
|
|
|
LOG_WARNING("Warden: Switched to CR key set");
|
2026-02-12 02:22:04 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_CHECKS;
|
|
|
|
|
|
break;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Warden: Seed not found in ", wardenCREntries_.size(), " CR entries");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-03-16 17:38:25 -07:00
|
|
|
|
// --- No CR match: decide strategy based on server strictness ---
|
2026-02-13 18:59:09 -08:00
|
|
|
|
{
|
2026-03-16 17:38:25 -07:00
|
|
|
|
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.
|
2026-03-15 01:21:23 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:38:25 -07:00
|
|
|
|
// 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)");
|
2026-02-14 19:20:32 -08:00
|
|
|
|
|
2026-03-16 17:38:25 -07:00
|
|
|
|
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);
|
2026-02-14 19:20:32 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 17:38:25 -07:00
|
|
|
|
if (fallbackReply.empty()) {
|
|
|
|
|
|
if (!wardenModuleData_.empty())
|
|
|
|
|
|
fallbackReply = auth::Crypto::sha1(wardenModuleData_);
|
|
|
|
|
|
else
|
|
|
|
|
|
fallbackReply.assign(20, 0);
|
2026-02-14 19:20:32 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::vector<uint8_t> resp;
|
2026-03-16 17:38:25 -07:00
|
|
|
|
resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT
|
|
|
|
|
|
resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end());
|
2026-02-13 16:53:28 -08:00
|
|
|
|
sendWardenResponse(resp);
|
2026-03-14 22:18:28 -07:00
|
|
|
|
applyWardenSeedRekey(seed);
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_CHECKS;
|
|
|
|
|
|
break;
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
case 0x02: { // WARDEN_SMSG_CHEAT_CHECKS_REQUEST
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: CHEAT_CHECKS_REQUEST (", decrypted.size(), " bytes)");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: String table: ", strings.size(), " entries");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
for (size_t i = 0; i < strings.size(); i++) {
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: [", i, "] = \"", strings[i], "\"");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// XOR byte is the last byte of the packet
|
|
|
|
|
|
uint8_t xorByte = decrypted.back();
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }());
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
2026-03-16 16:46:29 -07:00
|
|
|
|
// 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: {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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.
|
2026-03-16 16:46:29 -07:00
|
|
|
|
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);}(),
|
2026-03-17 07:19:37 -07:00
|
|
|
|
" len=", (int)readLen,
|
|
|
|
|
|
(strIdx ? " module=\"" + moduleName + "\"" : ""));
|
2026-03-16 16:46:29 -07:00
|
|
|
|
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 < (int)readLen; i++) { if (memBuf[i] != 0) { allZero = false; break; } }
|
|
|
|
|
|
std::string hexDump;
|
|
|
|
|
|
for (int i = 0; i < (int)readLen; i++) { char hx[4]; snprintf(hx,4,"%02x ",memBuf[i]); hexDump += hx; }
|
|
|
|
|
|
LOG_WARNING("Warden: MEM_CHECK served: [", hexDump, "] region=", region,
|
|
|
|
|
|
(allZero && offset >= 0x883000 ? " \xe2\x98\x85""BSS_ZERO\xe2\x98\x85" : ""));
|
|
|
|
|
|
if (offset == 0x7FFE026C && readLen == 12)
|
|
|
|
|
|
LOG_WARNING("Warden: Applying 4-byte ULONG alignment padding for WinVersionGet");
|
|
|
|
|
|
resultData.push_back(0x00);
|
|
|
|
|
|
resultData.insert(resultData.end(), memBuf.begin(), memBuf.end());
|
|
|
|
|
|
} else {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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);}(), ")");
|
2026-03-16 16:46:29 -07:00
|
|
|
|
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;
|
2026-03-16 20:55:30 -07:00
|
|
|
|
bool turtleFallback = false;
|
2026-03-16 16:46:29 -07:00
|
|
|
|
if (isKnownWantedCodeScan(seed, sha1, off, patLen)) {
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
} else if (wardenMemory_ && wardenMemory_->isLoaded() && patLen > 0) {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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()) {
|
2026-03-16 20:55:30 -07:00
|
|
|
|
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++) {
|
2026-03-16 16:46:29 -07:00
|
|
|
|
uint8_t h[20]; unsigned int hl = 0;
|
2026-03-16 20:55:30 -07:00
|
|
|
|
HMAC(EVP_sha1(), seed, 4, modMem+i, patLen, h, &hl);
|
2026-03-16 16:46:29 -07:00
|
|
|
|
if (hl == 20 && !std::memcmp(h, sha1, 20)) { found = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-16 16:46:29 -07:00
|
|
|
|
uint8_t pageResult = found ? 0x4A : 0x00;
|
|
|
|
|
|
LOG_WARNING("Warden: ", pageName, " offset=0x",
|
|
|
|
|
|
[&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}(),
|
2026-03-16 20:55:30 -07:00
|
|
|
|
" patLen=", (int)patLen, " found=", found ? "yes" : "no",
|
|
|
|
|
|
turtleFallback ? " (turtle-fallback)" : "");
|
2026-03-16 16:46:29 -07:00
|
|
|
|
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) {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// Cheat modules (unwanted — report not found)
|
2026-03-16 16:46:29 -07:00
|
|
|
|
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";
|
2026-03-17 07:19:37 -07:00
|
|
|
|
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; }
|
2026-03-16 16:46:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// 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"};
|
2026-02-20 01:36:12 -08:00
|
|
|
|
size_t checkEnd = decrypted.size() - 1; // exclude xorByte
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
auto decodeCheckType = [&](uint8_t raw) -> CheckType {
|
|
|
|
|
|
uint8_t decoded = raw ^ xorByte;
|
2026-02-20 01:37:20 -08:00
|
|
|
|
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
|
2026-02-13 18:59:09 -08:00
|
|
|
|
return CT_UNKNOWN;
|
|
|
|
|
|
};
|
2026-02-20 01:53:40 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-02-20 01:41:22 -08:00
|
|
|
|
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];
|
|
|
|
|
|
};
|
2026-02-20 01:36:12 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// --- Parse check entries and build response ---
|
|
|
|
|
|
std::vector<uint8_t> resultData;
|
|
|
|
|
|
int checkCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < checkEnd) {
|
|
|
|
|
|
CheckType ct = decodeCheckType(decrypted[pos]);
|
|
|
|
|
|
pos++;
|
|
|
|
|
|
checkCount++;
|
|
|
|
|
|
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Check #", checkCount, " type=", checkTypeNames[ct],
|
2026-02-13 18:59:09 -08:00
|
|
|
|
" at offset ", pos - 1);
|
|
|
|
|
|
|
|
|
|
|
|
switch (ct) {
|
|
|
|
|
|
case CT_TIMING: {
|
|
|
|
|
|
// No additional request data
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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)
|
2026-02-13 18:59:09 -08:00
|
|
|
|
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);
|
2026-03-17 07:19:37 -07:00
|
|
|
|
LOG_WARNING("Warden: (sync) TIMING ticks=", ticks);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_MEM: {
|
|
|
|
|
|
// Request: [1 stringIdx][4 offset][1 length]
|
|
|
|
|
|
if (pos + 6 > checkEnd) { pos = checkEnd; break; }
|
2026-02-20 01:41:22 -08:00
|
|
|
|
uint8_t strIdx = decrypted[pos++];
|
|
|
|
|
|
std::string moduleName = resolveWardenString(strIdx);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
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++];
|
2026-03-16 20:55:30 -07:00
|
|
|
|
LOG_WARNING("Warden: (sync) MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(),
|
|
|
|
|
|
" len=", (int)readLen,
|
|
|
|
|
|
moduleName.empty() ? "" : (" module=\"" + moduleName + "\""));
|
2026-02-14 02:00:15 -08:00
|
|
|
|
|
|
|
|
|
|
// Lazy-load WoW.exe PE image on first MEM_CHECK
|
|
|
|
|
|
if (!wardenMemory_) {
|
|
|
|
|
|
wardenMemory_ = std::make_unique<WardenMemory>();
|
2026-03-16 17:38:25 -07:00
|
|
|
|
if (!wardenMemory_->load(static_cast<uint16_t>(build), isActiveExpansion("turtle"))) {
|
2026-02-14 02:00:15 -08:00
|
|
|
|
LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix Classic field extraction, Warden PE patches, and auth/opcode corrections
Update field extraction in both CREATE_OBJECT and VALUES handlers to check
specific fields (maxHealth, level, faction, etc.) before power/maxpower range
checks. In Classic 1.12.1, power indices 23-27 are adjacent to maxHealth (28),
and maxPower indices 29-33 are adjacent to level (34) and faction (35), so
range checks like "key >= powerBase && key < powerBase+7" were incorrectly
capturing those fields.
Add build-aware WoW.exe selection and runtime global patching for Warden
SYSTEM_INFO, EndScene, WorldEnables, and LastHardwareAction chains. Fix
Classic opcodes and auth session addon data format for CMaNGOS compatibility.
2026-02-20 00:18:03 -08:00
|
|
|
|
// Read bytes from PE image (includes patched runtime globals)
|
2026-02-14 02:00:15 -08:00
|
|
|
|
std::vector<uint8_t> memBuf(readLen, 0);
|
|
|
|
|
|
if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) {
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: MEM_CHECK served from PE image");
|
2026-03-17 07:19:37 -07:00
|
|
|
|
resultData.push_back(0x00);
|
|
|
|
|
|
resultData.insert(resultData.end(), memBuf.begin(), memBuf.end());
|
2026-02-14 02:00:15 -08:00
|
|
|
|
} else {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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);
|
2026-02-14 02:00:15 -08:00
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-14 01:43:40 -08:00
|
|
|
|
case CT_PAGE_A: {
|
2026-02-20 01:36:12 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 01:53:40 -08:00
|
|
|
|
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)) {
|
2026-03-16 20:55:30 -07:00
|
|
|
|
pageResult = 0x4A;
|
|
|
|
|
|
} else if (wardenMemory_ && wardenMemory_->isLoaded() && len > 0) {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
if (wardenMemory_->searchCodePattern(seedBytes, reqHash, len, true, off))
|
2026-03-16 20:55:30 -07:00
|
|
|
|
pageResult = 0x4A;
|
|
|
|
|
|
}
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// Turtle PAGE_A fallback: runtime-patched offsets aren't in the
|
|
|
|
|
|
// on-disk PE. Server expects "found" for code integrity checks.
|
2026-03-16 20:55:30 -07:00
|
|
|
|
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);}());
|
2026-02-20 01:53:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-17 07:19:37 -07:00
|
|
|
|
if (consume >= 29) {
|
|
|
|
|
|
uint32_t off2 = uint32_t((decrypted.data()+pos)[24]) | (uint32_t((decrypted.data()+pos)[25])<<8) |
|
|
|
|
|
|
(uint32_t((decrypted.data()+pos)[26])<<16) | (uint32_t((decrypted.data()+pos)[27])<<24);
|
|
|
|
|
|
uint8_t len2 = (decrypted.data()+pos)[28];
|
|
|
|
|
|
LOG_WARNING("Warden: (sync) PAGE_A offset=0x",
|
|
|
|
|
|
[&]{char s[12];snprintf(s,12,"%08x",off2);return std::string(s);}(),
|
|
|
|
|
|
" patLen=", (int)len2,
|
|
|
|
|
|
" result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Warden: (sync) PAGE_A (short ", consume, "b) result=0x",
|
|
|
|
|
|
[&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
|
|
|
|
|
|
}
|
2026-02-20 01:36:12 -08:00
|
|
|
|
pos += consume;
|
2026-02-20 01:53:40 -08:00
|
|
|
|
resultData.push_back(pageResult);
|
2026-02-14 01:43:40 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_PAGE_B: {
|
2026-02-20 01:36:12 -08:00
|
|
|
|
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; }
|
|
|
|
|
|
}
|
2026-02-20 01:53:40 -08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: PAGE_B request bytes=", consume,
|
2026-02-20 01:53:40 -08:00
|
|
|
|
" result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
|
2026-02-20 01:36:12 -08:00
|
|
|
|
pos += consume;
|
2026-02-20 01:53:40 -08:00
|
|
|
|
resultData.push_back(pageResult);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_MPQ: {
|
2026-02-20 01:36:12 -08:00
|
|
|
|
// HASH_CLIENT_FILE request: [1 stringIdx]
|
|
|
|
|
|
if (pos + 1 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
uint8_t strIdx = decrypted[pos++];
|
2026-02-20 01:41:22 -08:00
|
|
|
|
std::string filePath = resolveWardenString(strIdx);
|
2026-03-17 07:19:37 -07:00
|
|
|
|
LOG_WARNING("Warden: (sync) MPQ file=\"", (filePath.empty() ? "?" : filePath), "\"");
|
2026-02-20 01:36:12 -08:00
|
|
|
|
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
std::vector<uint8_t> hash(20, 0);
|
|
|
|
|
|
if (!filePath.empty()) {
|
2026-02-20 01:57:21 -08:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 01:36:12 -08:00
|
|
|
|
auto* am = core::Application::getInstance().getAssetManager();
|
2026-02-20 01:57:21 -08:00
|
|
|
|
if (am && am->isInitialized() && !found) {
|
2026-02-20 01:49:43 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 01:36:12 -08:00
|
|
|
|
if (!fileData.empty()) {
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
hash = auth::Crypto::sha1(fileData);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 01:27:09 -08:00
|
|
|
|
}
|
2026-02-20 01:36:12 -08:00
|
|
|
|
|
2026-03-17 07:19:37 -07:00
|
|
|
|
// 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");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_LUA: {
|
|
|
|
|
|
// Request: [1 stringIdx]
|
|
|
|
|
|
if (pos + 1 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
uint8_t strIdx = decrypted[pos++];
|
2026-02-20 01:41:22 -08:00
|
|
|
|
std::string luaVar = resolveWardenString(strIdx);
|
2026-03-17 07:19:37 -07:00
|
|
|
|
LOG_WARNING("Warden: (sync) LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\"");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// 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++];
|
2026-02-20 01:41:22 -08:00
|
|
|
|
std::string driverName = resolveWardenString(strIdx);
|
2026-03-17 07:19:37 -07:00
|
|
|
|
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);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_MODULE: {
|
2026-02-20 01:36:12 -08:00
|
|
|
|
// FIND_MODULE_BY_NAME request: [4 seed][20 sha1] = 24 bytes
|
|
|
|
|
|
int moduleSize = 24;
|
2026-02-20 01:27:09 -08:00
|
|
|
|
if (pos + moduleSize > checkEnd) {
|
|
|
|
|
|
size_t remaining = checkEnd - pos;
|
|
|
|
|
|
LOG_WARNING("Warden: MODULE check truncated (remaining=", remaining,
|
|
|
|
|
|
", expected=", moduleSize, "), consuming remainder");
|
|
|
|
|
|
pos = checkEnd;
|
|
|
|
|
|
} else {
|
2026-02-20 01:57:21 -08:00
|
|
|
|
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);
|
2026-02-20 01:27:09 -08:00
|
|
|
|
pos += moduleSize;
|
2026-02-20 01:57:21 -08:00
|
|
|
|
|
2026-02-20 02:02:25 -08:00
|
|
|
|
bool shouldReportFound = false;
|
2026-03-17 07:19:37 -07:00
|
|
|
|
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);
|
2026-02-20 01:57:21 -08:00
|
|
|
|
break;
|
2026-02-20 01:27:09 -08:00
|
|
|
|
}
|
2026-02-20 01:57:21 -08:00
|
|
|
|
// Truncated module request fallback: module NOT loaded = clean
|
2026-03-17 07:19:37 -07:00
|
|
|
|
resultData.push_back(0x00);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_PROC: {
|
2026-02-20 01:36:12 -08:00
|
|
|
|
// API_CHECK request:
|
|
|
|
|
|
// [4 seed][20 sha1][1 stringIdx][1 stringIdx2][4 offset] = 30 bytes
|
|
|
|
|
|
int procSize = 30;
|
2026-02-14 01:26:26 -08:00
|
|
|
|
if (pos + procSize > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos += procSize;
|
2026-03-17 07:19:37 -07:00
|
|
|
|
LOG_WARNING("Warden: (sync) PROC check -> 0x01(not found)");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// Response: [uint8 result=1] (proc NOT found = clean)
|
|
|
|
|
|
resultData.push_back(0x01);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
default: {
|
2026-03-17 07:19:37 -07:00
|
|
|
|
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);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
pos = checkEnd; // stop parsing
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 20:55:30 -07:00
|
|
|
|
// 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, "]");
|
|
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// --- 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());
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::vector<uint8_t> resp;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
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());
|
2026-02-13 16:53:28 -08:00
|
|
|
|
sendWardenResponse(resp);
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Sent CHEAT_CHECKS_RESULT (", resp.size(), " bytes, ",
|
2026-02-13 18:59:09 -08:00
|
|
|
|
checkCount, " checks, checksum=0x",
|
|
|
|
|
|
[&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-14 01:26:26 -08:00
|
|
|
|
case 0x03: // WARDEN_SMSG_MODULE_INITIALIZE
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: MODULE_INITIALIZE (", decrypted.size(), " bytes, no response needed)");
|
2026-02-14 01:26:26 -08:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
default:
|
2026-02-25 09:46:27 -08:00
|
|
|
|
LOG_DEBUG("Warden: Unknown opcode 0x", std::hex, (int)wardenOpcode, std::dec,
|
2026-02-13 16:53:28 -08:00
|
|
|
|
" (state=", (int)wardenState_, ", size=", decrypted.size(), ")");
|
|
|
|
|
|
break;
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
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);
|
2026-02-05 13:22:15 -08:00
|
|
|
|
addSystemChatMessage(std::string("MOTD: ") + line);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-02-20 17:47:10 -08:00
|
|
|
|
// 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);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 00:28:51 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::sendPing() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Increment sequence number
|
|
|
|
|
|
pingSequence++;
|
|
|
|
|
|
|
2026-03-15 06:13:36 -07:00
|
|
|
|
LOG_DEBUG("Sending CMSG_PING: sequence=", pingSequence,
|
|
|
|
|
|
" latencyHintMs=", lastLatency);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-11 19:45:03 -07:00
|
|
|
|
// Record send time for RTT measurement
|
|
|
|
|
|
pingTimestamp_ = std::chrono::steady_clock::now();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Build and send ping packet
|
|
|
|
|
|
auto packet = PingPacket::build(pingSequence, lastLatency);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:25:00 -07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 05:12:24 -07:00
|
|
|
|
bool GameHandler::supportsEquipmentSets() const {
|
|
|
|
|
|
return wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE) != 0xFFFF;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:48:08 -07:00
|
|
|
|
void GameHandler::useEquipmentSet(uint32_t setId) {
|
2026-03-20 05:01:21 -07:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-03-20 05:12:24 -07:00
|
|
|
|
uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE);
|
|
|
|
|
|
if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; }
|
2026-03-20 05:01:21 -07:00
|
|
|
|
// 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)
|
2026-03-20 05:12:24 -07:00
|
|
|
|
network::Packet pkt(wire);
|
2026-03-20 05:01:21 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-12 17:48:08 -07:00
|
|
|
|
socket->send(pkt);
|
2026-03-20 05:01:21 -07:00
|
|
|
|
LOG_INFO("CMSG_EQUIPMENT_SET_USE: setId=", setId);
|
2026-03-12 17:48:08 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 04:43:46 -07:00
|
|
|
|
void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName,
|
|
|
|
|
|
uint64_t existingGuid, uint32_t setIndex) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) return;
|
2026-03-20 05:12:24 -07:00
|
|
|
|
uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE);
|
|
|
|
|
|
if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; }
|
2026-03-20 04:43:46 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 05:12:24 -07:00
|
|
|
|
network::Packet pkt(wire);
|
2026-03-20 04:43:46 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-20 05:17:27 -07:00
|
|
|
|
// Track pending save so SMSG_EQUIPMENT_SET_SAVED can add the new set locally
|
|
|
|
|
|
pendingSaveSetName_ = name;
|
|
|
|
|
|
pendingSaveSetIcon_ = iconName;
|
2026-03-20 04:43:46 -07:00
|
|
|
|
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;
|
2026-03-20 05:12:24 -07:00
|
|
|
|
uint16_t wire = wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET);
|
|
|
|
|
|
if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; }
|
2026-03-20 04:43:46 -07:00
|
|
|
|
// CMSG_DELETEEQUIPMENT_SET: uint64 setGuid
|
2026-03-20 05:12:24 -07:00
|
|
|
|
network::Packet pkt(wire);
|
2026-03-20 04:43:46 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 23:00:03 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::handlePong(network::Packet& packet) {
|
2026-03-15 06:13:36 -07:00
|
|
|
|
LOG_DEBUG("Handling SMSG_PONG");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 19:45:03 -07:00
|
|
|
|
// 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());
|
|
|
|
|
|
|
2026-03-15 06:13:36 -07:00
|
|
|
|
LOG_DEBUG("SMSG_PONG acknowledged: sequence=", data.sequence,
|
|
|
|
|
|
" latencyMs=", lastLatency);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 02:19:17 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::sendMovement(Opcode opcode) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Cannot send movement in state: ", (int)state);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Block manual movement while taxi is active/mounted, but always allow
|
|
|
|
|
|
// stop/heartbeat opcodes so stuck states can be recovered.
|
|
|
|
|
|
bool taxiAllowed =
|
2026-02-20 02:50:59 -08:00
|
|
|
|
(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);
|
2026-02-18 23:30:38 -08:00
|
|
|
|
if (!serverMovementAllowed_ && !taxiAllowed) return;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return;
|
|
|
|
|
|
if (resurrectPending_ && !taxiAllowed) return;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
|
2026-02-20 02:19:17 -08:00
|
|
|
|
// Always send a strictly increasing non-zero client movement clock value.
|
2026-03-15 06:13:36 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-18 00:25:04 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Update movement flags based on opcode
|
|
|
|
|
|
switch (opcode) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_FORWARD:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FORWARD);
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_BACKWARD:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::BACKWARD);
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_STOP:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::FORWARD) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::BACKWARD));
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_STRAFE_LEFT:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_LEFT);
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_STRAFE_RIGHT:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_STOP_STRAFE:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT));
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_JUMP:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FALLING);
|
2026-03-10 12:36:56 -07:00
|
|
|
|
// 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);
|
2026-03-10 12:37:53 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-10 12:36:56 -07:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_TURN_LEFT:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_LEFT);
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_START_TURN_RIGHT:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_RIGHT);
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_STOP_TURN:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::TURN_RIGHT));
|
|
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_FALL_LAND:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::FALLING);
|
2026-03-10 12:36:56 -07:00
|
|
|
|
isFalling_ = false;
|
|
|
|
|
|
fallStartMs_ = 0;
|
|
|
|
|
|
movementInfo.fallTime = 0;
|
|
|
|
|
|
movementInfo.jumpVelocity = 0.0f;
|
|
|
|
|
|
movementInfo.jumpSinAngle = 0.0f;
|
|
|
|
|
|
movementInfo.jumpCosAngle = 0.0f;
|
|
|
|
|
|
movementInfo.jumpXYSpeed = 0.0f;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
case Opcode::MSG_MOVE_HEARTBEAT:
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// No flag changes — just sends current position
|
2026-03-15 06:13:36 -07:00
|
|
|
|
timeSinceLastMoveHeartbeat_ = 0.0f;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
2026-03-10 14:32:30 -07:00
|
|
|
|
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;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 06:13:36 -07:00
|
|
|
|
if (opcode == Opcode::MSG_MOVE_SET_FACING) {
|
|
|
|
|
|
lastFacingSendTimeMs_ = movementInfo.time;
|
|
|
|
|
|
lastFacingSentOrientation_ = movementInfo.orientation;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:36:56 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) {
|
|
|
|
|
|
sanitizeMovementForTaxi();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 09:02:20 -07:00
|
|
|
|
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) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-11 02:23:37 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
|
|
|
|
|
|
movementInfo.transportGuid = playerTransportGuid_;
|
|
|
|
|
|
movementInfo.transportX = playerTransportOffset_.x;
|
|
|
|
|
|
movementInfo.transportY = playerTransportOffset_.y;
|
|
|
|
|
|
movementInfo.transportZ = playerTransportOffset_.z;
|
2026-02-11 15:24:05 -08:00
|
|
|
|
movementInfo.transportTime = movementInfo.time;
|
|
|
|
|
|
movementInfo.transportSeat = -1;
|
|
|
|
|
|
movementInfo.transportTime2 = movementInfo.time;
|
|
|
|
|
|
|
|
|
|
|
|
// ONTRANSPORT expects local orientation (player yaw relative to transport yaw).
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// Keep internal yaw canonical; convert to server yaw on the wire.
|
|
|
|
|
|
float transportYawCanonical = 0.0f;
|
2026-02-11 15:24:05 -08:00
|
|
|
|
if (transportManager_) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr) {
|
|
|
|
|
|
if (tr->hasServerYaw) {
|
2026-02-12 15:08:21 -08:00
|
|
|
|
transportYawCanonical = tr->serverYaw;
|
2026-02-12 00:04:53 -08:00
|
|
|
|
} else {
|
2026-02-12 15:08:21 -08:00
|
|
|
|
transportYawCanonical = glm::eulerAngles(tr->rotation).z;
|
2026-02-12 00:04:53 -08:00
|
|
|
|
}
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.transportO =
|
|
|
|
|
|
core::coords::normalizeAngleRad(movementInfo.orientation - transportYawCanonical);
|
2026-02-11 02:23:37 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
// Clear transport flag if not on transport
|
|
|
|
|
|
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
|
|
|
|
|
|
movementInfo.transportGuid = 0;
|
2026-02-11 15:24:05 -08:00
|
|
|
|
movementInfo.transportSeat = -1;
|
2026-02-11 02:23:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 06:13:36 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_DEBUG("Sending movement packet: opcode=0x", std::hex,
|
2026-02-12 22:56:36 -08:00
|
|
|
|
wireOpcode(opcode), std::dec,
|
2026-03-14 09:02:20 -07:00
|
|
|
|
(includeTransportInWire ? " ONTRANSPORT" : ""));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-04 18:27:52 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// Convert canonical → server yaw for the wire
|
|
|
|
|
|
wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation);
|
|
|
|
|
|
|
2026-02-11 02:23:37 -08:00
|
|
|
|
// Also convert transport local position to server coordinates if on transport
|
2026-03-14 09:02:20 -07:00
|
|
|
|
if (includeTransportInWire) {
|
2026-02-11 02:23:37 -08:00
|
|
|
|
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;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// transportO is a local delta; server<->canonical swap negates delta yaw.
|
|
|
|
|
|
wireInfo.transportO = core::coords::normalizeAngleRad(-wireInfo.transportO);
|
2026-02-11 02:23:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
// Build and send movement packet (expansion-specific format)
|
|
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildMovementPacket(opcode, wireInfo, playerGuid)
|
|
|
|
|
|
: MovementPacket::build(opcode, wireInfo, playerGuid);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
socket->send(packet);
|
2026-03-15 06:13:36 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
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;
|
2026-03-12 17:25:00 -07:00
|
|
|
|
vehicleId_ = 0;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
resurrectPending_ = false;
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
2026-03-18 00:06:39 -07:00
|
|
|
|
selfResAvailable_ = false;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
playerDead_ = false;
|
|
|
|
|
|
releasedSpirit_ = false;
|
2026-03-14 08:27:32 -07:00
|
|
|
|
corpseGuid_ = 0;
|
2026-03-17 23:57:47 -07:00
|
|
|
|
corpseReclaimAvailableMs_ = 0;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
repopPending_ = false;
|
|
|
|
|
|
pendingSpiritHealerGuid_ = 0;
|
|
|
|
|
|
resurrectCasterGuid_ = 0;
|
|
|
|
|
|
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
movementInfo.transportGuid = 0;
|
|
|
|
|
|
clearPlayerTransport();
|
|
|
|
|
|
|
|
|
|
|
|
if (socket && state == WorldState::IN_WORLD) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
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);
|
2026-02-11 21:14:35 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Force-cleared taxi/movement state");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
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;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (!packetParsers_->parseUpdateObject(packet, data)) {
|
2026-02-23 04:32:58 -08:00
|
|
|
|
static int updateObjErrors = 0;
|
|
|
|
|
|
if (++updateObjErrors <= 5)
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT");
|
2026-03-10 08:38:39 -07:00
|
|
|
|
if (data.blocks.empty()) return;
|
|
|
|
|
|
// Fall through: process any blocks that were successfully parsed before the failure.
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-02-13 19:40:54 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-13 19:47:49 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
switch (block.updateType) {
|
|
|
|
|
|
case UpdateType::CREATE_OBJECT:
|
|
|
|
|
|
case UpdateType::CREATE_OBJECT2: {
|
|
|
|
|
|
// Create new entity
|
|
|
|
|
|
std::shared_ptr<Entity> entity;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
switch (block.objectType) {
|
|
|
|
|
|
case ObjectType::PLAYER:
|
|
|
|
|
|
entity = std::make_shared<Player>(block.guid);
|
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
case ObjectType::UNIT:
|
|
|
|
|
|
entity = std::make_shared<Unit>(block.guid);
|
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
case ObjectType::GAMEOBJECT:
|
|
|
|
|
|
entity = std::make_shared<GameObject>(block.guid);
|
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
default:
|
|
|
|
|
|
entity = std::make_shared<Entity>(block.guid);
|
|
|
|
|
|
entity->setType(block.objectType);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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);
|
2026-03-18 08:39:35 -07:00
|
|
|
|
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
|
|
|
|
|
|
setPlayerOnTransport(block.transportGuid, canonicalOffset);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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();
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
2026-02-11 15:24:05 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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());
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
clearTransportAttachment(block.guid);
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// Set fields
|
|
|
|
|
|
for (const auto& field : block.fields) {
|
|
|
|
|
|
entity->setField(field.first, field.second);
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// Add to manager
|
|
|
|
|
|
entityManager.addEntity(block.guid, entity);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-02-13 20:13:37 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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);
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
queryCreatureInfo(it->second, block.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extract health/mana/power from fields (Phase 2) — single pass
|
|
|
|
|
|
if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
|
|
|
|
|
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
|
|
|
|
|
|
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
|
|
|
|
|
|
bool unitInitiallyDead = false;
|
|
|
|
|
|
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
|
|
|
|
|
|
const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1);
|
|
|
|
|
|
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
|
|
|
|
|
|
const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
|
|
|
|
|
|
const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
|
|
|
|
|
const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE);
|
|
|
|
|
|
const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS);
|
|
|
|
|
|
const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS);
|
|
|
|
|
|
const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID);
|
|
|
|
|
|
const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID);
|
|
|
|
|
|
const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS);
|
|
|
|
|
|
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
|
|
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
|
|
|
|
|
// Check all specific fields BEFORE power/maxpower range checks.
|
|
|
|
|
|
// In Classic, power indices (23-27) are adjacent to maxHealth (28),
|
|
|
|
|
|
// and maxPower indices (29-33) are adjacent to level (34) and faction (35).
|
|
|
|
|
|
// A range check like "key >= powerBase && key < powerBase+7" would
|
|
|
|
|
|
// incorrectly capture maxHealth/level/faction in Classic's tight layout.
|
|
|
|
|
|
if (key == ufHealth) {
|
|
|
|
|
|
unit->setHealth(val);
|
|
|
|
|
|
if (block.objectType == ObjectType::UNIT && val == 0) {
|
|
|
|
|
|
unitInitiallyDead = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (block.guid == playerGuid && val == 0) {
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
LOG_INFO("Player logged in dead");
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
|
|
|
|
|
|
else if (key == ufLevel) {
|
|
|
|
|
|
unit->setLevel(val);
|
|
|
|
|
|
} else if (key == ufFaction) { unit->setFactionTemplate(val); }
|
|
|
|
|
|
else if (key == ufFlags) { unit->setUnitFlags(val); }
|
|
|
|
|
|
else if (key == ufBytes0) {
|
|
|
|
|
|
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
|
|
|
|
|
|
} else if (key == ufDisplayId) { unit->setDisplayId(val); }
|
|
|
|
|
|
else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
|
|
|
|
|
else if (key == ufDynFlags) {
|
|
|
|
|
|
unit->setDynamicFlags(val);
|
|
|
|
|
|
if (block.objectType == ObjectType::UNIT &&
|
|
|
|
|
|
((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) {
|
|
|
|
|
|
unitInitiallyDead = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Power/maxpower range checks AFTER all specific fields
|
|
|
|
|
|
else if (key >= ufPowerBase && key < ufPowerBase + 7) {
|
|
|
|
|
|
unit->setPowerByType(static_cast<uint8_t>(key - ufPowerBase), val);
|
|
|
|
|
|
} else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) {
|
|
|
|
|
|
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufMaxPowerBase), val);
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (key == ufMountDisplayId) {
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
uint32_t old = currentMountDisplayId_;
|
|
|
|
|
|
currentMountDisplayId_ = val;
|
|
|
|
|
|
if (val != old && mountCallback_) mountCallback_(val);
|
|
|
|
|
|
if (old == 0 && val != 0) {
|
|
|
|
|
|
// Just mounted — find the mount aura (indefinite duration, self-cast)
|
|
|
|
|
|
mountAuraSpellId_ = 0;
|
|
|
|
|
|
for (const auto& a : playerAuras) {
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
|
|
|
|
|
|
mountAuraSpellId_ = a.spellId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
|
|
|
|
|
|
if (mountAuraSpellId_ == 0) {
|
|
|
|
|
|
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
|
|
|
|
|
|
if (ufAuras != 0xFFFF) {
|
|
|
|
|
|
for (const auto& [fk, fv] : block.fields) {
|
|
|
|
|
|
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
|
|
|
|
|
|
mountAuraSpellId_ = fv;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (old != 0 && val == 0) {
|
|
|
|
|
|
mountAuraSpellId_ = 0;
|
|
|
|
|
|
for (auto& a : playerAuras)
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
unit->setMountDisplayId(val);
|
|
|
|
|
|
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100;
|
|
|
|
|
|
if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) {
|
|
|
|
|
|
onTaxiFlight_ = true;
|
|
|
|
|
|
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
|
|
|
|
|
|
sanitizeMovementForTaxi();
|
|
|
|
|
|
applyTaxiMountForCurrentNode();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (block.guid == playerGuid &&
|
|
|
|
|
|
(unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) {
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
LOG_INFO("Player logged in dead (dynamic flags)");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Detect ghost state on login via PLAYER_FLAGS
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
|
|
|
|
|
|
auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS));
|
|
|
|
|
|
if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) {
|
|
|
|
|
|
releasedSpirit_ = true;
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
|
|
|
|
|
|
if (ghostStateCallback_) ghostStateCallback_(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create
|
|
|
|
|
|
if (block.guid == playerGuid && isClassicLikeExpansion()) {
|
|
|
|
|
|
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
|
|
|
|
|
|
const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS);
|
|
|
|
|
|
if (ufAuras != 0xFFFF) {
|
|
|
|
|
|
bool hasAuraField = false;
|
|
|
|
|
|
for (const auto& [fk, fv] : block.fields) {
|
|
|
|
|
|
if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasAuraField) {
|
|
|
|
|
|
playerAuras.clear();
|
|
|
|
|
|
playerAuras.resize(48);
|
|
|
|
|
|
uint64_t nowMs = static_cast<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)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 08:43:19 -07:00
|
|
|
|
if (unitInitiallyDead && npcDeathCallback_) {
|
|
|
|
|
|
npcDeathCallback_(block.guid);
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} 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));
|
2026-03-18 03:50:24 -07:00
|
|
|
|
const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF)
|
|
|
|
|
|
? static_cast<uint16_t>(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu;
|
2026-03-18 04:04:23 -07:00
|
|
|
|
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();
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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;
|
2026-03-18 04:04:23 -07:00
|
|
|
|
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;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-03-20 04:36:30 -07:00
|
|
|
|
const uint16_t ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY);
|
|
|
|
|
|
const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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) {
|
2026-03-20 11:56:59 -07:00
|
|
|
|
uint64_t oldMoney = playerMoneyCopper_;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
playerMoneyCopper_ = val;
|
|
|
|
|
|
LOG_DEBUG("Money set from update fields: ", val, " copper");
|
2026-03-20 11:56:59 -07:00
|
|
|
|
if (val != oldMoney && addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_MONEY", {});
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
2026-03-20 04:36:30 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
|
|
|
|
|
isResting_ = (restStateByte != 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-02-06 14:24:38 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
2026-02-07 21:47:14 -08:00
|
|
|
|
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
|
2026-02-18 04:02:08 -08:00
|
|
|
|
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
uint32_t oldDisplayId = unit->getDisplayId();
|
|
|
|
|
|
bool displayIdChanged = false;
|
|
|
|
|
|
bool npcDeathNotified = false;
|
|
|
|
|
|
bool npcRespawnNotified = false;
|
2026-03-20 14:15:00 -07:00
|
|
|
|
bool healthChanged = false;
|
|
|
|
|
|
bool powerChanged = false;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
|
2026-02-19 19:33:02 -08:00
|
|
|
|
const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
|
2026-02-19 19:33:02 -08:00
|
|
|
|
const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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);
|
2026-02-19 19:33:02 -08:00
|
|
|
|
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
|
2026-02-04 11:31:08 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (key == ufHealth) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
uint32_t oldHealth = unit->getHealth();
|
2026-02-12 22:56:36 -08:00
|
|
|
|
unit->setHealth(val);
|
2026-03-20 14:15:00 -07:00
|
|
|
|
healthChanged = true;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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_);
|
|
|
|
|
|
}
|
2026-03-18 08:43:19 -07:00
|
|
|
|
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
npcDeathCallback_(block.guid);
|
|
|
|
|
|
npcDeathNotified = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (oldHealth == 0 && val > 0) {
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
playerDead_ = false;
|
|
|
|
|
|
if (!releasedSpirit_) {
|
|
|
|
|
|
LOG_INFO("Player resurrected!");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("Player entered ghost form");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 08:43:19 -07:00
|
|
|
|
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
npcRespawnCallback_(block.guid);
|
|
|
|
|
|
npcRespawnNotified = true;
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// Specific fields checked BEFORE power/maxpower range checks
|
|
|
|
|
|
// (Classic packs maxHealth/level/faction adjacent to power indices)
|
2026-03-20 14:15:00 -07:00
|
|
|
|
} else if (key == ufMaxHealth) { unit->setMaxHealth(val); healthChanged = true; }
|
Fix Classic field extraction, Warden PE patches, and auth/opcode corrections
Update field extraction in both CREATE_OBJECT and VALUES handlers to check
specific fields (maxHealth, level, faction, etc.) before power/maxpower range
checks. In Classic 1.12.1, power indices 23-27 are adjacent to maxHealth (28),
and maxPower indices 29-33 are adjacent to level (34) and faction (35), so
range checks like "key >= powerBase && key < powerBase+7" were incorrectly
capturing those fields.
Add build-aware WoW.exe selection and runtime global patching for Warden
SYSTEM_INFO, EndScene, WorldEnables, and LastHardwareAction chains. Fix
Classic opcodes and auth session addon data format for CMaNGOS compatibility.
2026-02-20 00:18:03 -08:00
|
|
|
|
else if (key == ufBytes0) {
|
|
|
|
|
|
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} else if (key == ufFlags) { unit->setUnitFlags(val); }
|
2026-02-18 03:37:03 -08:00
|
|
|
|
else if (key == ufDynFlags) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
uint32_t oldDyn = unit->getDynamicFlags();
|
2026-02-18 03:37:03 -08:00
|
|
|
|
unit->setDynamicFlags(val);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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;
|
2026-03-18 00:06:39 -07:00
|
|
|
|
selfResAvailable_ = false;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_INFO("Player resurrected (dynamic flags)");
|
|
|
|
|
|
}
|
2026-03-18 08:43:19 -07:00
|
|
|
|
} else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
|
|
|
|
|
|
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
|
|
|
|
|
|
if (!wasDead && nowDead) {
|
|
|
|
|
|
if (!npcDeathNotified && npcDeathCallback_) {
|
|
|
|
|
|
npcDeathCallback_(block.guid);
|
|
|
|
|
|
npcDeathNotified = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (wasDead && !nowDead) {
|
|
|
|
|
|
if (!npcRespawnNotified && npcRespawnCallback_) {
|
|
|
|
|
|
npcRespawnCallback_(block.guid);
|
|
|
|
|
|
npcRespawnNotified = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key == ufLevel) {
|
|
|
|
|
|
uint32_t oldLvl = unit->getLevel();
|
|
|
|
|
|
unit->setLevel(val);
|
|
|
|
|
|
if (block.guid != playerGuid &&
|
|
|
|
|
|
entity->getType() == ObjectType::PLAYER &&
|
|
|
|
|
|
val > oldLvl && oldLvl > 0 &&
|
|
|
|
|
|
otherPlayerLevelUpCallback_) {
|
|
|
|
|
|
otherPlayerLevelUpCallback_(block.guid, val);
|
2026-02-18 03:37:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
uint32_t old = currentMountDisplayId_;
|
|
|
|
|
|
currentMountDisplayId_ = val;
|
|
|
|
|
|
if (val != old && mountCallback_) mountCallback_(val);
|
2026-02-14 16:42:47 -08:00
|
|
|
|
if (old == 0 && val != 0) {
|
|
|
|
|
|
mountAuraSpellId_ = 0;
|
|
|
|
|
|
for (const auto& a : playerAuras) {
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
|
|
|
|
|
|
mountAuraSpellId_ = a.spellId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_);
|
2026-02-14 16:42:47 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (old != 0 && val == 0) {
|
2026-02-14 16:42:47 -08:00
|
|
|
|
mountAuraSpellId_ = 0;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
for (auto& a : playerAuras)
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
}
|
|
|
|
|
|
unit->setMountDisplayId(val);
|
|
|
|
|
|
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// Power/maxpower range checks AFTER all specific fields
|
|
|
|
|
|
else if (key >= ufPowerBase && key < ufPowerBase + 7) {
|
|
|
|
|
|
unit->setPowerByType(static_cast<uint8_t>(key - ufPowerBase), val);
|
2026-03-20 14:15:00 -07:00
|
|
|
|
powerChanged = true;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) {
|
|
|
|
|
|
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufMaxPowerBase), val);
|
2026-03-20 14:15:00 -07:00
|
|
|
|
powerChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fire UNIT_HEALTH / UNIT_POWER events for Lua addons
|
|
|
|
|
|
if (addonEventCallback_ && (healthChanged || powerChanged)) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (block.guid == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (block.guid == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (block.guid == focusGuid) unitId = "focus";
|
|
|
|
|
|
else if (block.guid == petGuid_) unitId = "pet";
|
|
|
|
|
|
if (!unitId.empty()) {
|
|
|
|
|
|
if (healthChanged) addonEventCallback_("UNIT_HEALTH", {unitId});
|
|
|
|
|
|
if (powerChanged) addonEventCallback_("UNIT_POWER", {unitId});
|
2026-02-07 23:12:24 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
|
|
|
|
|
|
// Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated
|
2026-03-12 21:19:17 -07:00
|
|
|
|
if (block.guid == playerGuid && isClassicLikeExpansion()) {
|
2026-03-12 21:33:19 -07:00
|
|
|
|
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
|
|
|
|
|
|
const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS);
|
2026-03-12 21:19:17 -07:00
|
|
|
|
if (ufAuras != 0xFFFF) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
bool hasAuraUpdate = false;
|
2026-03-12 21:19:17 -07:00
|
|
|
|
for (const auto& [fk, fv] : block.fields) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; }
|
2026-03-12 21:19:17 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (hasAuraUpdate) {
|
2026-03-12 21:19:17 -07:00
|
|
|
|
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;
|
2026-03-12 21:33:19 -07:00
|
|
|
|
// Read aura flag byte: packed 4-per-uint32 at ufAuraFlags
|
2026-03-15 01:21:23 -07:00
|
|
|
|
uint8_t aFlag = 0;
|
2026-03-12 21:33:19 -07:00
|
|
|
|
if (ufAuraFlags != 0xFFFF) {
|
|
|
|
|
|
auto fit = allFields.find(static_cast<uint16_t>(ufAuraFlags + slot / 4));
|
|
|
|
|
|
if (fit != allFields.end())
|
2026-03-15 01:21:23 -07:00
|
|
|
|
aFlag = static_cast<uint8_t>((fit->second >> ((slot % 4) * 8)) & 0xFF);
|
2026-03-12 21:33:19 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
a.flags = aFlag;
|
2026-03-12 21:19:17 -07:00
|
|
|
|
a.durationMs = -1;
|
|
|
|
|
|
a.maxDurationMs = -1;
|
|
|
|
|
|
a.casterGuid = playerGuid;
|
|
|
|
|
|
a.receivedAtMs = nowMs;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)");
|
2026-03-12 21:19:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-02-13 19:40:54 -08:00
|
|
|
|
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());
|
2026-03-11 17:12:05 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec,
|
2026-03-15 01:21:23 -07:00
|
|
|
|
" displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render");
|
2026-02-13 19:40:54 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 08:43:19 -07:00
|
|
|
|
bool isDeadNow = (unit->getHealth() == 0) ||
|
|
|
|
|
|
((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0);
|
|
|
|
|
|
if (isDeadNow && !npcDeathNotified && npcDeathCallback_) {
|
|
|
|
|
|
npcDeathCallback_(block.guid);
|
|
|
|
|
|
npcDeathNotified = true;
|
|
|
|
|
|
}
|
2026-02-13 19:40:54 -08:00
|
|
|
|
} else if (creatureSpawnCallback_) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
float unitScale2 = 1.0f;
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
|
{
|
|
|
|
|
|
uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
|
|
|
|
|
|
if (scaleIdx != 0xFFFF) {
|
|
|
|
|
|
uint32_t raw = entity->getField(scaleIdx);
|
|
|
|
|
|
if (raw != 0) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
std::memcpy(&unitScale2, &raw, sizeof(float));
|
|
|
|
|
|
if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f;
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-05 21:55:52 -08:00
|
|
|
|
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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_) {
|
2026-02-18 03:37:03 -08:00
|
|
|
|
npcDeathCallback_(block.guid);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
npcDeathNotified = true;
|
2026-02-18 03:37:03 -08:00
|
|
|
|
}
|
2026-02-05 21:55:52 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 20:13:28 -08:00
|
|
|
|
qsPkt.writeUInt64(block.guid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
2026-02-05 21:55:52 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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_;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.
- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 00:59:43 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
auto mergeHint = lastPlayerFields_.end();
|
|
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
|
|
|
|
|
mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (needCoinageDetectSnapshot) {
|
|
|
|
|
|
maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_);
|
2026-02-13 22:14:34 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
maybeDetectVisibleItemLayout();
|
2026-02-06 18:34:45 -08:00
|
|
|
|
detectInventorySlotBases(block.fields);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
bool slotsChanged = false;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
|
|
|
|
|
|
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
|
|
|
|
|
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
|
2026-03-20 04:36:30 -07:00
|
|
|
|
const uint16_t ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY);
|
|
|
|
|
|
const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
|
2026-02-19 17:45:09 -08:00
|
|
|
|
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
|
2026-03-18 12:17:00 -07:00
|
|
|
|
const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2);
|
2026-03-12 20:23:36 -07:00
|
|
|
|
const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
const uint16_t ufStatsV[5] = {
|
2026-03-10 23:08:15 -07:00
|
|
|
|
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)
|
|
|
|
|
|
};
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-02-05 12:07:58 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (key == ufPlayerXp) {
|
|
|
|
|
|
playerXp_ = val;
|
|
|
|
|
|
LOG_DEBUG("XP updated: ", val);
|
2026-03-20 14:27:46 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_XP_UPDATE", {std::to_string(val)});
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
else if (key == ufPlayerNextXp) {
|
|
|
|
|
|
playerNextLevelXp_ = val;
|
|
|
|
|
|
LOG_DEBUG("Next level XP updated: ", val);
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) {
|
|
|
|
|
|
playerRestedXp_ = val;
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufPlayerLevel) {
|
|
|
|
|
|
serverPlayerLevel_ = val;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_DEBUG("Level updated: ", val);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
for (auto& ch : characters) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (ch.guid == playerGuid) {
|
|
|
|
|
|
ch.level = val;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufCoinage) {
|
2026-03-20 11:56:59 -07:00
|
|
|
|
uint64_t oldM = playerMoneyCopper_;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
playerMoneyCopper_ = val;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_DEBUG("Money updated via VALUES: ", val, " copper");
|
2026-03-20 11:56:59 -07:00
|
|
|
|
if (val != oldM && addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_MONEY", {});
|
2026-02-12 22:56:36 -08:00
|
|
|
|
}
|
2026-03-20 04:36:30 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-19 17:45:09 -08:00
|
|
|
|
else if (ufArmor != 0xFFFF && key == ufArmor) {
|
|
|
|
|
|
playerArmorRating_ = static_cast<int32_t>(val);
|
|
|
|
|
|
}
|
2026-03-12 12:24:15 -07:00
|
|
|
|
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
|
|
|
|
|
|
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(val);
|
|
|
|
|
|
}
|
2026-03-18 12:17:00 -07:00
|
|
|
|
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_();
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) {
|
2026-03-18 12:17:00 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-26 11:12:34 -08:00
|
|
|
|
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
|
2026-03-18 12:17:00 -07:00
|
|
|
|
LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
|
|
|
|
|
|
" bankBagSlots=", static_cast<int>(bankBagSlots),
|
|
|
|
|
|
" facial=", static_cast<int>(facialHair));
|
2026-02-26 11:12:34 -08:00
|
|
|
|
inventory.setPurchasedBankBagSlots(bankBagSlots);
|
2026-03-10 20:29:55 -07:00
|
|
|
|
// Byte 3 (bits 24-31): REST_STATE
|
|
|
|
|
|
// 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY
|
2026-03-10 15:45:35 -07:00
|
|
|
|
uint8_t restStateByte = static_cast<uint8_t>((val >> 24) & 0xFF);
|
2026-03-10 20:29:55 -07:00
|
|
|
|
isResting_ = (restStateByte != 0);
|
2026-03-18 12:17:00 -07:00
|
|
|
|
if (appearanceChangedCallback_)
|
|
|
|
|
|
appearanceChangedCallback_();
|
2026-02-26 11:12:34 -08:00
|
|
|
|
}
|
2026-03-12 20:23:36 -07:00
|
|
|
|
else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) {
|
|
|
|
|
|
chosenTitleBit_ = static_cast<int32_t>(val);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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;
|
2026-03-18 00:06:39 -07:00
|
|
|
|
selfResAvailable_ = false;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
corpseMapId_ = 0; // corpse reclaimed
|
|
|
|
|
|
corpseGuid_ = 0;
|
2026-03-17 23:57:47 -07:00
|
|
|
|
corpseReclaimAvailableMs_ = 0;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
|
2026-03-20 11:56:59 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_ALIVE", {});
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (ghostStateCallback_) ghostStateCallback_(false);
|
|
|
|
|
|
}
|
2026-03-20 21:18:25 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_FLAGS_CHANGED", {});
|
2026-03-12 20:23:36 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-03-13 08:37:55 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-03-13 08:35:18 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) {
|
|
|
|
|
|
playerCombatRatings_[key - ufRating1V] = static_cast<int32_t>(val);
|
2026-03-13 08:35:18 -07:00
|
|
|
|
}
|
2026-03-10 23:08:15 -07:00
|
|
|
|
else {
|
|
|
|
|
|
for (int si = 0; si < 5; ++si) {
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) {
|
2026-03-10 23:08:15 -07:00
|
|
|
|
playerStats_[si] = static_cast<int32_t>(val);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// Do not auto-create quests from VALUES quest-log slot fields for the
|
|
|
|
|
|
// same reason as CREATE_OBJECT2 above (can be misaligned per realm).
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (applyInventoryFields(block.fields)) slotsChanged = true;
|
2026-03-20 16:38:57 -07:00
|
|
|
|
if (slotsChanged) {
|
|
|
|
|
|
rebuildOnlineInventory();
|
|
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_EQUIPMENT_CHANGED", {});
|
|
|
|
|
|
}
|
2026-02-07 14:21:50 -08:00
|
|
|
|
extractSkillFields(lastPlayerFields_);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
extractExploredZoneFields(lastPlayerFields_);
|
2026-03-10 23:33:38 -07:00
|
|
|
|
applyQuestStateFromFields(lastPlayerFields_);
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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);
|
2026-03-18 03:50:24 -07:00
|
|
|
|
// ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset
|
|
|
|
|
|
// across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8).
|
2026-03-18 04:04:23 -07:00
|
|
|
|
// 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;
|
2026-03-18 03:50:24 -07:00
|
|
|
|
const uint16_t itemPermEnchField = itemEnchBase;
|
2026-03-18 04:04:23 -07:00
|
|
|
|
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;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
auto it = onlineItems_.find(block.guid);
|
|
|
|
|
|
bool isItemInInventory = (it != onlineItems_.end());
|
2026-02-11 18:25:04 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
|
|
|
|
|
if (key == itemStackField && isItemInInventory) {
|
|
|
|
|
|
if (it->second.stackCount != val) {
|
|
|
|
|
|
it->second.stackCount = val;
|
|
|
|
|
|
inventoryChanged = true;
|
2026-03-12 21:19:17 -07:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} else if (key == itemDurField && isItemInInventory) {
|
|
|
|
|
|
if (it->second.curDurability != val) {
|
2026-03-18 04:14:44 -07:00
|
|
|
|
const uint32_t prevDur = it->second.curDurability;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
it->second.curDurability = val;
|
|
|
|
|
|
inventoryChanged = true;
|
2026-03-18 04:14:44 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} else if (key == itemMaxDurField && isItemInInventory) {
|
|
|
|
|
|
if (it->second.maxDurability != val) {
|
|
|
|
|
|
it->second.maxDurability = val;
|
|
|
|
|
|
inventoryChanged = true;
|
2026-02-11 18:25:04 -08:00
|
|
|
|
}
|
2026-03-18 03:50:24 -07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2026-03-18 04:04:23 -07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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;
|
2026-03-10 23:08:15 -07:00
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
extractContainerFields(block.guid, block.fields);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (inventoryChanged) {
|
|
|
|
|
|
rebuildOnlineInventory();
|
2026-03-20 16:38:57 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("BAG_UPDATE", {});
|
|
|
|
|
|
addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"});
|
|
|
|
|
|
}
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
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());
|
2026-02-12 00:04:53 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
2026-02-11 15:24:05 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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());
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
clearTransportAttachment(block.guid);
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
2026-02-11 15:24:05 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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);
|
2026-03-18 08:39:35 -07:00
|
|
|
|
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
|
|
|
|
|
|
setPlayerOnTransport(block.transportGuid, canonicalOffset);
|
2026-03-15 01:21:23 -07:00
|
|
|
|
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;
|
2026-02-11 02:23:37 -08:00
|
|
|
|
} else {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
movementInfo.x = pos.x;
|
|
|
|
|
|
movementInfo.y = pos.y;
|
|
|
|
|
|
movementInfo.z = pos.z;
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
2026-02-11 02:23:37 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
// 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);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-03-15 01:21:23 -07:00
|
|
|
|
void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
tabCycleStale = true;
|
2026-02-09 14:50:14 -08:00
|
|
|
|
// Entity count logging disabled
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
2026-02-26 10:46:47 -08:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:55:52 -08:00
|
|
|
|
void GameHandler::handleCompressedUpdateObject(network::Packet& packet) {
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize());
|
2026-02-05 21:55:52 -08:00
|
|
|
|
|
|
|
|
|
|
// First 4 bytes = decompressed size
|
|
|
|
|
|
if (packet.getSize() < 4) {
|
|
|
|
|
|
LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t decompressedSize = packet.readUInt32();
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG(" Decompressed size: ", decompressedSize);
|
2026-02-05 21:55:52 -08:00
|
|
|
|
|
2026-03-20 05:59:11 -07:00
|
|
|
|
// 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) {
|
2026-02-05 21:55:52 -08:00
|
|
|
|
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
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed);
|
2026-02-05 21:55:52 -08:00
|
|
|
|
handleUpdateObject(decompressedPacket);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::handleDestroyObject(network::Packet& packet) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
LOG_DEBUG("Handling SMSG_DESTROY_OBJECT");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
DestroyObjectData data;
|
|
|
|
|
|
if (!DestroyObjectParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove entity
|
|
|
|
|
|
if (entityManager.hasEntity(data.guid)) {
|
2026-02-11 17:30:57 -08:00
|
|
|
|
if (transportGuids_.count(data.guid) > 0) {
|
2026-02-12 00:45:24 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-11 17:30:57 -08:00
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// 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);
|
2026-03-10 06:40:07 -07:00
|
|
|
|
} 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);
|
2026-03-10 07:00:43 -07:00
|
|
|
|
pendingNameQueries.erase(data.guid);
|
2026-02-11 21:14:35 -08:00
|
|
|
|
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
|
|
|
|
|
|
gameObjectDespawnCallback_(data.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 00:45:24 -08:00
|
|
|
|
if (transportGuids_.count(data.guid) > 0) {
|
|
|
|
|
|
transportGuids_.erase(data.guid);
|
|
|
|
|
|
serverUpdatedTransportGuids_.erase(data.guid);
|
|
|
|
|
|
if (playerTransportGuid_ == data.guid) {
|
|
|
|
|
|
clearPlayerTransport();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
|
clearTransportAttachment(data.guid);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
entityManager.removeEntity(data.guid);
|
|
|
|
|
|
LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec,
|
|
|
|
|
|
" (", (data.isDeath ? "death" : "despawn"), ")");
|
|
|
|
|
|
} else {
|
2026-02-16 00:51:59 -08:00
|
|
|
|
LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
// Clean up auto-attack and target if destroyed entity was our target
|
|
|
|
|
|
if (data.guid == autoAttackTarget) {
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.guid == targetGuid) {
|
|
|
|
|
|
targetGuid = 0;
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
hostileAttackers_.erase(data.guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// Remove online item/container tracking
|
|
|
|
|
|
containerContents_.erase(data.guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
if (onlineItems_.erase(data.guid)) {
|
|
|
|
|
|
rebuildOnlineInventory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 20:10:10 -08:00
|
|
|
|
// Clean up quest giver status
|
|
|
|
|
|
npcQuestStatus_.erase(data.guid);
|
|
|
|
|
|
|
2026-03-20 17:19:18 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
tabCycleStale = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Cannot send chat in state: ", (int)state);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.empty()) {
|
|
|
|
|
|
LOG_WARNING("Cannot send empty chat message");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Sending chat message: [", getChatTypeString(type), "] ", message);
|
|
|
|
|
|
|
|
|
|
|
|
// Determine language based on character (for now, use COMMON)
|
|
|
|
|
|
ChatLanguage language = ChatLanguage::COMMON;
|
|
|
|
|
|
|
|
|
|
|
|
// Build and send packet
|
|
|
|
|
|
auto packet = MessageChatPacket::build(type, language, message, target);
|
|
|
|
|
|
socket->send(packet);
|
2026-02-07 23:32:27 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 18:27:59 -08:00
|
|
|
|
if (type == ChatType::CHANNEL) {
|
|
|
|
|
|
echo.channelName = target;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:32:27 -08:00
|
|
|
|
addLocalChatMessage(echo);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleMessageChat(network::Packet& packet) {
|
|
|
|
|
|
LOG_DEBUG("Handling SMSG_MESSAGECHAT");
|
|
|
|
|
|
|
|
|
|
|
|
MessageChatData data;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (!packetParsers_->parseMessageChat(packet, data)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_WARNING("Failed to parse SMSG_MESSAGECHAT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:32:27 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 19:42:42 -08:00
|
|
|
|
|
|
|
|
|
|
// If still unknown, proactively query the server so the UI can show names soon after.
|
|
|
|
|
|
if (data.senderName.empty()) {
|
|
|
|
|
|
queryPlayerName(data.senderGuid);
|
|
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Add to chat history
|
|
|
|
|
|
chatHistory.push_back(data);
|
|
|
|
|
|
|
|
|
|
|
|
// Limit chat history size
|
|
|
|
|
|
if (chatHistory.size() > maxChatHistory) {
|
|
|
|
|
|
chatHistory.erase(chatHistory.begin());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:17:01 -08:00
|
|
|
|
// Track whisper sender for /r command
|
|
|
|
|
|
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
|
|
|
|
|
|
lastWhisperSender_ = data.senderName;
|
2026-03-18 10:50:42 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-07 13:17:01 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
// Trigger chat bubble for SAY/YELL messages from others
|
|
|
|
|
|
if (chatBubbleCallback_ && data.senderGuid != 0) {
|
|
|
|
|
|
if (data.type == ChatType::SAY || data.type == ChatType::YELL ||
|
2026-03-02 08:31:34 -08:00
|
|
|
|
data.type == ChatType::MONSTER_SAY || data.type == ChatType::MONSTER_YELL ||
|
|
|
|
|
|
data.type == ChatType::MONSTER_PARTY) {
|
2026-02-14 14:30:09 -08:00
|
|
|
|
bool isYell = (data.type == ChatType::YELL || data.type == ChatType::MONSTER_YELL);
|
|
|
|
|
|
chatBubbleCallback_(data.senderGuid, data.message, isYell);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Log the message
|
|
|
|
|
|
std::string senderInfo;
|
|
|
|
|
|
if (!data.senderName.empty()) {
|
|
|
|
|
|
senderInfo = data.senderName;
|
|
|
|
|
|
} else if (data.senderGuid != 0) {
|
2026-02-13 18:59:09 -08:00
|
|
|
|
senderInfo = "Unknown-" + std::to_string(data.senderGuid);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
senderInfo = "System";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string channelInfo;
|
|
|
|
|
|
if (!data.channelName.empty()) {
|
|
|
|
|
|
channelInfo = "[" + data.channelName + "] ";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 00:45:47 -08:00
|
|
|
|
LOG_DEBUG("[", getChatTypeString(data.type), "] ", channelInfo, senderInfo, ": ", data.message);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
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) {
|
2026-03-10 01:05:23 -07:00
|
|
|
|
// 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");
|
2026-02-14 14:30:09 -08:00
|
|
|
|
TextEmoteData data;
|
2026-03-10 01:05:23 -07:00
|
|
|
|
if (!TextEmoteParser::parse(packet, data, legacyFormat)) {
|
2026-02-14 14:30:09 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:11:43 -08:00
|
|
|
|
// 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 + ".";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
MessageChatData chatMsg;
|
|
|
|
|
|
chatMsg.type = ChatType::TEXT_EMOTE;
|
|
|
|
|
|
chatMsg.language = ChatLanguage::COMMON;
|
|
|
|
|
|
chatMsg.senderGuid = data.senderGuid;
|
|
|
|
|
|
chatMsg.senderName = senderName;
|
2026-02-14 15:11:43 -08:00
|
|
|
|
chatMsg.message = emoteText;
|
2026-02-14 14:30:09 -08:00
|
|
|
|
|
|
|
|
|
|
chatHistory.push_back(chatMsg);
|
|
|
|
|
|
if (chatHistory.size() > maxChatHistory) {
|
|
|
|
|
|
chatHistory.erase(chatHistory.begin());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:11:43 -08:00
|
|
|
|
// 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, ")");
|
2026-02-14 14:30:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::joinChannel(const std::string& channelName, const std::string& password) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-14 18:27:59 -08:00
|
|
|
|
auto packet = packetParsers_ ? packetParsers_->buildJoinChannel(channelName, password)
|
|
|
|
|
|
: JoinChannelPacket::build(channelName, password);
|
2026-02-14 14:30:09 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Requesting to join channel: ", channelName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::leaveChannel(const std::string& channelName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-14 18:27:59 -08:00
|
|
|
|
auto packet = packetParsers_ ? packetParsers_->buildLeaveChannel(channelName)
|
|
|
|
|
|
: LeaveChannelPacket::build(channelName);
|
2026-02-14 14:30:09 -08:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 18:27:59 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-16 20:16:14 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
feat: show chat messages for channel notification events
SMSG_CHANNEL_NOTIFY carries many event types that were silently dropped
in the default case: wrong password, muted, banned, throttled, kicked,
not owner, not moderator, password changed, owner changed, invalid name,
not in area, not in LFG. These are now surfaced as system chat messages
matching WoW-standard phrasing.
2026-03-13 06:18:23 -07:00
|
|
|
|
case ChannelNotifyType::NOT_IN_AREA:
|
|
|
|
|
|
addSystemChatMessage("You must be in the area to join '" + data.channelName + "'.");
|
2026-02-16 20:16:14 -08:00
|
|
|
|
LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)");
|
|
|
|
|
|
break;
|
feat: show chat messages for channel notification events
SMSG_CHANNEL_NOTIFY carries many event types that were silently dropped
in the default case: wrong password, muted, banned, throttled, kicked,
not owner, not moderator, password changed, owner changed, invalid name,
not in area, not in LFG. These are now surfaced as system chat messages
matching WoW-standard phrasing.
2026-03-13 06:18:23 -07:00
|
|
|
|
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;
|
feat: add missing SMSG_CHANNEL_NOTIFY feedback for 12 unhandled notification types
Previously, PLAYER_NOT_FOUND, ANNOUNCEMENTS_ON/OFF, MODERATION_ON/OFF,
PLAYER_BANNED, PLAYER_UNBANNED, PLAYER_NOT_BANNED, INVITE, WRONG_FACTION,
INVITE_WRONG_FACTION, NOT_MODERATED, PLAYER_INVITED, and PLAYER_INVITE_BANNED
all fell silently to the default log-only path. Now each shows an appropriate
system message in chat.
2026-03-13 06:48:12 -07:00
|
|
|
|
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;
|
2026-02-14 14:30:09 -08:00
|
|
|
|
default:
|
|
|
|
|
|
LOG_DEBUG("Channel notify type ", static_cast<int>(data.notifyType),
|
|
|
|
|
|
" for channel ", data.channelName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::autoJoinDefaultChannels() {
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("autoJoinDefaultChannels: general=", chatAutoJoin.general,
|
|
|
|
|
|
" trade=", chatAutoJoin.trade, " localDefense=", chatAutoJoin.localDefense,
|
|
|
|
|
|
" lfg=", chatAutoJoin.lfg, " local=", chatAutoJoin.local);
|
2026-02-14 18:27:59 -08:00
|
|
|
|
if (chatAutoJoin.general) joinChannel("General");
|
|
|
|
|
|
if (chatAutoJoin.trade) joinChannel("Trade");
|
|
|
|
|
|
if (chatAutoJoin.localDefense) joinChannel("LocalDefense");
|
|
|
|
|
|
if (chatAutoJoin.lfg) joinChannel("LookingForGroup");
|
2026-02-16 21:16:25 -08:00
|
|
|
|
if (chatAutoJoin.local) joinChannel("Local");
|
2026-02-14 14:30:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::setTarget(uint64_t guid) {
|
|
|
|
|
|
if (guid == targetGuid) return;
|
2026-02-07 13:44:36 -08:00
|
|
|
|
|
|
|
|
|
|
// Save previous target
|
|
|
|
|
|
if (targetGuid != 0) {
|
|
|
|
|
|
lastTargetGuid = targetGuid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
targetGuid = guid;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-10 06:43:16 -07:00
|
|
|
|
// 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{};
|
|
|
|
|
|
|
2026-03-09 23:13:30 -07:00
|
|
|
|
// Clear previous target's cast bar on target change
|
|
|
|
|
|
// (the new target's cast state is naturally fetched from unitCastStates_ by GUID)
|
2026-03-09 23:06:40 -07:00
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Inform server of target selection (Phase 1)
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = SetSelectionPacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (guid != 0) {
|
|
|
|
|
|
LOG_INFO("Target set: 0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
2026-03-20 11:51:46 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_TARGET_CHANGED", {});
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::clearTarget() {
|
|
|
|
|
|
if (targetGuid != 0) {
|
|
|
|
|
|
LOG_INFO("Target cleared");
|
2026-03-20 11:51:46 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_TARGET_CHANGED", {});
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
targetGuid = 0;
|
|
|
|
|
|
tabCycleIndex = -1;
|
|
|
|
|
|
tabCycleStale = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::shared_ptr<Entity> GameHandler::getTarget() const {
|
|
|
|
|
|
if (targetGuid == 0) return nullptr;
|
|
|
|
|
|
return entityManager.getEntity(targetGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:44:36 -08:00
|
|
|
|
void GameHandler::setFocus(uint64_t guid) {
|
|
|
|
|
|
focusGuid = guid;
|
2026-03-21 01:26:37 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {});
|
2026-02-07 13:44:36 -08:00
|
|
|
|
if (guid != 0) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
if (entity) {
|
2026-03-13 07:20:58 -07:00
|
|
|
|
std::string name;
|
|
|
|
|
|
auto unit = std::dynamic_pointer_cast<Unit>(entity);
|
|
|
|
|
|
if (unit && !unit->getName().empty()) {
|
|
|
|
|
|
name = unit->getName();
|
2026-02-07 13:44:36 -08:00
|
|
|
|
}
|
2026-03-13 07:20:58 -07:00
|
|
|
|
if (name.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(guid);
|
|
|
|
|
|
if (nit != playerNameCache.end()) name = nit->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (name.empty()) name = "Unknown";
|
2026-02-07 13:44:36 -08:00
|
|
|
|
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;
|
2026-03-21 01:26:37 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setMouseoverGuid(uint64_t guid) {
|
|
|
|
|
|
if (mouseoverGuid_ != guid) {
|
|
|
|
|
|
mouseoverGuid_ = guid;
|
|
|
|
|
|
if (addonEventCallback_) addonEventCallback_("UPDATE_MOUSEOVER_UNIT", {});
|
|
|
|
|
|
}
|
2026-02-07 13:44:36 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-13 11:37:13 -07:00
|
|
|
|
if (unit && guid != playerGuid && unit->isHostile()) {
|
2026-02-07 13:44:36 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:37:13 -08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-03-12 23:23:02 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:37:13 -08:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:58:11 -08:00
|
|
|
|
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;
|
2026-03-13 10:13:54 -07:00
|
|
|
|
logoutCountdown_ = 0.0f;
|
2026-02-07 12:58:11 -08:00
|
|
|
|
addSystemChatMessage("Logout cancelled.");
|
|
|
|
|
|
LOG_INFO("Cancelled logout");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setStandState(uint8_t standState) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot change stand state: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = StandStateChangePacket::build(standState);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Changed stand state to: ", (int)standState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:03:21 -08:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 09:36:14 -07:00
|
|
|
|
void GameHandler::cancelFollow() {
|
|
|
|
|
|
if (followTargetGuid_ == 0) {
|
|
|
|
|
|
addSystemChatMessage("You are not following anyone.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
followTargetGuid_ = 0;
|
|
|
|
|
|
addSystemChatMessage("You stop following.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:03:21 -08:00
|
|
|
|
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();
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto it = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_LO));
|
2026-02-07 13:03:21 -08:00
|
|
|
|
if (it != fields.end()) {
|
|
|
|
|
|
assistTargetGuid = it->second;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto it2 = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_HI));
|
2026-02-07 13:03:21 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add Tier 3 commands: guild management, PvP, ready check, and duel forfeit
- Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, /ginvite
- PvP toggle: /pvp to toggle PvP flag
- Ready check system: /readycheck, /ready, /notready for group coordination
- Duel forfeit: /yield, /forfeit, /surrender to cancel active duels
2026-02-07 13:09:12 -08:00
|
|
|
|
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);
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// 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.");
|
|
|
|
|
|
}
|
Add Tier 3 commands: guild management, PvP, ready check, and duel forfeit
- Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, /ginvite
- PvP toggle: /pvp to toggle PvP flag
- Ready check system: /readycheck, /ready, /notready for group coordination
- Duel forfeit: /yield, /forfeit, /surrender to cancel active duels
2026-02-07 13:09:12 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:58:02 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add Tier 3 commands: guild management, PvP, ready check, and duel forfeit
- Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, /ginvite
- PvP toggle: /pvp to toggle PvP flag
- Ready check system: /readycheck, /ready, /notready for group coordination
- Duel forfeit: /yield, /forfeit, /surrender to cancel active duels
2026-02-07 13:09:12 -08:00
|
|
|
|
void GameHandler::forfeitDuel() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot forfeit duel: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-09 13:58:02 -07:00
|
|
|
|
pendingDuelRequest_ = false; // cancel request if still pending
|
Add Tier 3 commands: guild management, PvP, ready check, and duel forfeit
- Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, /ginvite
- PvP toggle: /pvp to toggle PvP flag
- Ready check system: /readycheck, /ready, /notready for group coordination
- Duel forfeit: /yield, /forfeit, /surrender to cancel active duels
2026-02-07 13:09:12 -08:00
|
|
|
|
auto packet = DuelCancelPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("You have forfeited the duel.");
|
|
|
|
|
|
LOG_INFO("Forfeited duel");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:58:02 -07:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-03-13 06:46:56 -07:00
|
|
|
|
if (duelChallengerName_.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(duelChallengerGuid_);
|
|
|
|
|
|
if (nit != playerNameCache.end())
|
|
|
|
|
|
duelChallengerName_ = nit->second;
|
|
|
|
|
|
}
|
2026-03-09 13:58:02 -07:00
|
|
|
|
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!");
|
2026-03-17 12:37:19 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playTargetSelect();
|
|
|
|
|
|
}
|
2026-03-09 13:58:02 -07:00
|
|
|
|
LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_,
|
|
|
|
|
|
" flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_);
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("DUEL_REQUESTED", {duelChallengerName_});
|
2026-03-09 13:58:02 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-12 05:06:14 -07:00
|
|
|
|
duelCountdownMs_ = 0; // clear countdown once duel is resolved
|
2026-03-09 13:58:02 -07:00
|
|
|
|
if (!started) {
|
|
|
|
|
|
addSystemChatMessage("The duel was cancelled.");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast<int>(started));
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("DUEL_FINISHED", {});
|
2026-03-09 13:58:02 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleDuelWinner(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 3) return;
|
2026-03-13 06:16:19 -07:00
|
|
|
|
uint8_t duelType = packet.readUInt8(); // 0=normal win, 1=opponent fled duel area
|
2026-03-09 13:58:02 -07:00
|
|
|
|
std::string winner = packet.readString();
|
|
|
|
|
|
std::string loser = packet.readString();
|
|
|
|
|
|
|
2026-03-13 06:16:19 -07:00
|
|
|
|
std::string msg;
|
|
|
|
|
|
if (duelType == 1) {
|
|
|
|
|
|
msg = loser + " has fled from the duel. " + winner + " wins!";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg = winner + " has defeated " + loser + " in a duel!";
|
|
|
|
|
|
}
|
2026-03-09 13:58:02 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-13 06:16:19 -07:00
|
|
|
|
LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser, " type=", static_cast<int>(duelType));
|
2026-03-09 13:58:02 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:17:01 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:28:46 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 00:39:56 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:28:46 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:36:50 -08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 03:31:49 -08:00
|
|
|
|
// Send cancel cast packet only for real spell casts.
|
|
|
|
|
|
if (pendingGameObjectInteractGuid_ == 0 && currentCastSpellId != 0) {
|
|
|
|
|
|
auto packet = CancelCastPacket::build(currentCastSpellId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
2026-02-07 13:36:50 -08:00
|
|
|
|
|
2026-03-18 00:59:15 -07:00
|
|
|
|
// Reset casting state and clear any queued spell so it doesn't fire later
|
2026-02-07 13:36:50 -08:00
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-02-07 13:36:50 -08:00
|
|
|
|
currentCastSpellId = 0;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
pendingGameObjectInteractGuid_ = 0;
|
2026-03-13 05:02:58 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
2026-02-07 13:36:50 -08:00
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
castTimeTotal = 0.0f;
|
2026-03-18 00:59:15 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
|
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
2026-02-07 13:36:50 -08:00
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Cancelled spell cast");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 17:27:20 -08:00
|
|
|
|
void GameHandler::releaseSpirit() {
|
|
|
|
|
|
if (socket && state == WorldState::IN_WORLD) {
|
2026-02-07 21:47:14 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-06 17:27:20 -08:00
|
|
|
|
auto packet = RepopRequestPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
2026-03-18 05:35:23 -07:00
|
|
|
|
// 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).
|
2026-03-18 00:09:22 -07:00
|
|
|
|
selfResAvailable_ = false; // self-res window closes when spirit is released
|
2026-02-07 21:47:14 -08:00
|
|
|
|
repopPending_ = true;
|
|
|
|
|
|
lastRepopRequestMs_ = static_cast<uint64_t>(now);
|
2026-02-06 17:27:20 -08:00
|
|
|
|
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 22:31:56 -07:00
|
|
|
|
bool GameHandler::canReclaimCorpse() const {
|
2026-03-17 23:44:55 -07:00
|
|
|
|
// 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;
|
2026-03-09 22:31:56 -07:00
|
|
|
|
if (currentMapId_ != corpseMapId_) return false;
|
2026-03-13 00:59:43 -07:00
|
|
|
|
// 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
|
2026-03-09 22:31:56 -07:00
|
|
|
|
float dz = movementInfo.z - corpseZ_;
|
|
|
|
|
|
return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 23:52:45 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 22:31:56 -07:00
|
|
|
|
void GameHandler::reclaimCorpse() {
|
|
|
|
|
|
if (!canReclaimCorpse() || !socket) return;
|
2026-03-17 23:44:55 -07:00
|
|
|
|
// 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_);
|
2026-03-09 22:31:56 -07:00
|
|
|
|
socket->send(packet);
|
2026-03-17 23:44:55 -07:00
|
|
|
|
LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec);
|
2026-03-09 22:31:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:06:39 -07:00
|
|
|
|
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)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
pendingSpiritHealerGuid_ = npcGuid;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
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;
|
2026-03-09 22:27:24 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
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;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
|
2026-02-18 04:43:23 -08:00
|
|
|
|
// 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;
|
2026-02-20 20:37:55 -08:00
|
|
|
|
const uint64_t guid = e->getGuid();
|
2026-02-18 04:43:23 -08:00
|
|
|
|
auto* unit = dynamic_cast<Unit*>(e.get());
|
|
|
|
|
|
if (!unit) return false; // Not a unit (shouldn't happen after type filter)
|
2026-03-11 16:52:53 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-20 20:37:55 -08:00
|
|
|
|
const bool hostileByFaction = unit->isHostile();
|
|
|
|
|
|
const bool hostileByCombat = isAggressiveTowardPlayer(guid);
|
|
|
|
|
|
if (!hostileByFaction && !hostileByCombat) return false;
|
2026-02-18 04:43:23 -08:00
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Rebuild cycle list if stale (entity added/removed since last tab press).
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (tabCycleStale) {
|
|
|
|
|
|
tabCycleList.clear();
|
|
|
|
|
|
tabCycleIndex = -1;
|
|
|
|
|
|
|
2026-02-18 04:43:23 -08:00
|
|
|
|
struct EntityDist { uint64_t guid; float distance; };
|
2026-02-02 12:24:50 -08:00
|
|
|
|
std::vector<EntityDist> sortable;
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
|
|
|
|
|
auto t = entity->getType();
|
|
|
|
|
|
if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue;
|
2026-02-18 04:43:23 -08:00
|
|
|
|
if (guid == playerGuid) continue;
|
|
|
|
|
|
if (!isValidTabTarget(entity)) continue; // Skip dead / non-hostile
|
2026-02-02 12:24:50 -08:00
|
|
|
|
float dx = entity->getX() - playerX;
|
|
|
|
|
|
float dy = entity->getY() - playerY;
|
|
|
|
|
|
float dz = entity->getZ() - playerZ;
|
2026-02-18 04:43:23 -08:00
|
|
|
|
sortable.push_back({guid, std::sqrt(dx*dx + dy*dy + dz*dz)});
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 04:43:23 -08:00
|
|
|
|
// 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();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
|
|
|
|
|
|
chatHistory.push_back(msg);
|
|
|
|
|
|
if (chatHistory.size() > maxChatHistory) {
|
2026-02-04 11:31:08 -08:00
|
|
|
|
chatHistory.pop_front();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
feat: fire CHAT_MSG_* events to Lua addons for all chat types
Wire chat messages to the addon event system via AddonChatCallback.
Every chat message now fires the corresponding WoW event:
- CHAT_MSG_SAY, CHAT_MSG_YELL, CHAT_MSG_WHISPER
- CHAT_MSG_PARTY, CHAT_MSG_GUILD, CHAT_MSG_OFFICER
- CHAT_MSG_RAID, CHAT_MSG_RAID_WARNING, CHAT_MSG_BATTLEGROUND
- CHAT_MSG_SYSTEM, CHAT_MSG_CHANNEL, CHAT_MSG_EMOTE
Event handlers receive (eventName, message, senderName) arguments.
Addons can now filter, react to, or log chat messages in real-time.
2026-03-20 11:29:53 -07:00
|
|
|
|
if (addonChatCallback_) addonChatCallback_(msg);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 1: Name Queries
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryPlayerName(uint64_t guid) {
|
2026-03-10 06:21:05 -07:00
|
|
|
|
// 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;
|
2026-02-17 05:27:03 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-17 05:27:03 -08:00
|
|
|
|
LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 00:59:40 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
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;
|
2026-02-13 19:52:49 -08:00
|
|
|
|
if (!packetParsers_ || !packetParsers_->parseNameQueryResponse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
pendingNameQueries.erase(data.guid);
|
|
|
|
|
|
|
2026-02-17 05:27:03 -08:00
|
|
|
|
LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec,
|
|
|
|
|
|
" found=", (int)data.found, " name='", data.name, "'",
|
|
|
|
|
|
" race=", (int)data.race, " class=", (int)data.classId);
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (data.isValid()) {
|
|
|
|
|
|
playerNameCache[data.guid] = data.name;
|
|
|
|
|
|
// Update entity name
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.guid);
|
|
|
|
|
|
if (entity && entity->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto player = std::static_pointer_cast<Player>(entity);
|
|
|
|
|
|
player->setName(data.name);
|
|
|
|
|
|
}
|
2026-02-13 19:42:42 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-16 18:46:44 -08:00
|
|
|
|
|
|
|
|
|
|
// Backfill mail inbox sender names
|
|
|
|
|
|
for (auto& mail : mailInbox_) {
|
|
|
|
|
|
if (mail.messageType == 0 && mail.senderGuid == data.guid) {
|
|
|
|
|
|
mail.senderName = data.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 01:15:51 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-21 02:58:55 -07:00
|
|
|
|
|
|
|
|
|
|
// Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (data.guid == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (data.guid == focusGuid) unitId = "focus";
|
|
|
|
|
|
else if (data.guid == playerGuid) unitId = "player";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_NAME_UPDATE", {unitId});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCreatureQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
CreatureQueryResponseData data;
|
2026-03-09 21:44:07 -07:00
|
|
|
|
if (!packetParsers_->parseCreatureQueryResponse(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// GameObject Query
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
GameObjectQueryResponseData data;
|
2026-02-14 20:20:43 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseGameObjectQueryResponse(packet, data)
|
|
|
|
|
|
: GameObjectQueryResponseParser::parse(packet, data);
|
|
|
|
|
|
if (!ok) return;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 20:20:43 -08:00
|
|
|
|
|
|
|
|
|
|
// 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)) {
|
2026-02-20 20:31:04 -08:00
|
|
|
|
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId);
|
2026-02-14 20:20:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-02-20 20:31:04 -08:00
|
|
|
|
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId,
|
2026-02-14 20:20:43 -08:00
|
|
|
|
" not found in TaxiPathNode.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 23:31:30 -08:00
|
|
|
|
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) {
|
2026-03-12 18:21:50 -07:00
|
|
|
|
bookPages_.clear(); // start a fresh book for this interaction
|
2026-02-20 23:31:30 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-12 18:21:50 -07:00
|
|
|
|
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; }
|
2026-02-20 23:31:30 -08:00
|
|
|
|
}
|
2026-03-12 18:21:50 -07:00
|
|
|
|
if (!nextHave && socket && state == WorldState::IN_WORLD) {
|
|
|
|
|
|
auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid);
|
|
|
|
|
|
socket->send(req);
|
2026-02-20 23:31:30 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 18:21:50 -07:00
|
|
|
|
LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId,
|
|
|
|
|
|
" nextPage=", data.nextPageId,
|
|
|
|
|
|
" totalPages=", bookPages_.size());
|
2026-02-20 23:31:30 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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);
|
2026-02-13 20:13:37 -08:00
|
|
|
|
// 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;
|
2026-02-17 01:05:15 -08:00
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildItemQuery(entry, queryGuid)
|
|
|
|
|
|
: ItemQueryPacket::build(entry, queryGuid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
socket->send(packet);
|
2026-03-10 04:48:33 -07:00
|
|
|
|
LOG_DEBUG("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
|
|
|
|
|
|
" pending=", pendingItemQueries_.size());
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleItemQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
ItemQueryResponseData data;
|
2026-02-17 01:00:04 -08:00
|
|
|
|
bool parsed = packetParsers_
|
|
|
|
|
|
? packetParsers_->parseItemQueryResponse(packet, data)
|
|
|
|
|
|
: ItemQueryResponseParser::parse(packet, data);
|
2026-02-17 01:22:34 -08:00
|
|
|
|
if (!parsed) {
|
|
|
|
|
|
LOG_WARNING("handleItemQueryResponse: parse failed, size=", packet.getSize());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
|
|
|
|
|
pendingItemQueries_.erase(data.entry);
|
2026-03-10 04:48:33 -07:00
|
|
|
|
LOG_DEBUG("handleItemQueryResponse: entry=", data.entry, " name='", data.name,
|
|
|
|
|
|
"' displayInfoId=", data.displayInfoId, " pending=", pendingItemQueries_.size());
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
|
|
|
|
|
if (data.valid) {
|
|
|
|
|
|
itemInfoCache_[data.entry] = data;
|
|
|
|
|
|
rebuildOnlineInventory();
|
2026-02-13 20:10:19 -08:00
|
|
|
|
maybeDetectVisibleItemLayout();
|
2026-02-14 15:48:58 -08:00
|
|
|
|
|
2026-03-18 04:25:37 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:48:58 -08:00
|
|
|
|
// 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
|
2026-02-13 20:26:55 -08:00
|
|
|
|
if (playerEquipmentCallback_) {
|
|
|
|
|
|
for (const auto& [guid, entries] : inspectedPlayerItemEntries_) {
|
2026-02-14 15:48:58 -08:00
|
|
|
|
bool relevant = false;
|
|
|
|
|
|
for (uint32_t e : entries) {
|
|
|
|
|
|
if (e == resolvedEntry) { relevant = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!relevant) continue;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
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) {
|
2026-02-14 15:16:26 -08:00
|
|
|
|
// 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
|
2026-02-14 15:05:18 -08:00
|
|
|
|
// Per talent group: uint8 talentCount, [talentId(u32) + rank(u8)]..., uint8 glyphCount, [glyphId(u16)]...
|
2026-02-14 15:16:26 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
uint8_t talentType = packet.readUInt8();
|
|
|
|
|
|
|
|
|
|
|
|
if (talentType == 0) {
|
2026-03-12 03:15:56 -07:00
|
|
|
|
// 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();
|
2026-03-13 03:32:45 -07:00
|
|
|
|
learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed
|
2026-03-12 03:15:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) break;
|
2026-03-12 17:39:35 -07:00
|
|
|
|
learnedGlyphs_[g].fill(0);
|
2026-03-12 03:15:56 -07:00
|
|
|
|
uint8_t glyphCount = packet.readUInt8();
|
|
|
|
|
|
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) break;
|
2026-03-12 17:39:35 -07:00
|
|
|
|
uint16_t glyphId = packet.readUInt16();
|
|
|
|
|
|
if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId;
|
2026-03-12 03:15:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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=", (int)talentGroupCount, " active=", (int)activeTalentGroup,
|
|
|
|
|
|
" learned=", learnedTalents_[activeTalentGroup].size());
|
2026-02-14 15:16:26 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// talentType == 1: inspect result
|
2026-03-10 00:00:21 -07:00
|
|
|
|
// WotLK: packed GUID; TBC: full uint64
|
|
|
|
|
|
const bool talentTbc = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (talentTbc ? 8u : 2u)) return;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
2026-03-10 00:00:21 -07:00
|
|
|
|
uint64_t guid = talentTbc
|
|
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
if (guid == 0) return;
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
size_t bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 6) {
|
2026-02-14 15:16:26 -08:00
|
|
|
|
LOG_WARNING("SMSG_TALENTS_INFO: too short after guid, ", bytesLeft, " bytes");
|
2026-02-14 15:05:18 -08:00
|
|
|
|
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();
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
2026-02-14 15:05:18 -08:00
|
|
|
|
addSystemChatMessage("Inspecting " + name + " (no talent data available).");
|
2026-02-13 20:26:55 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
uint32_t unspentTalents = packet.readUInt32();
|
|
|
|
|
|
uint8_t talentGroupCount = packet.readUInt8();
|
|
|
|
|
|
uint8_t activeTalentGroup = packet.readUInt8();
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
// Parse enchantment slot mask + enchant IDs
|
2026-03-12 07:37:29 -07:00
|
|
|
|
std::array<uint16_t, 19> enchantIds{};
|
2026-02-14 15:05:18 -08:00
|
|
|
|
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;
|
2026-03-12 07:37:29 -07:00
|
|
|
|
enchantIds[slot] = packet.readUInt16();
|
2026-02-14 15:05:18 -08:00
|
|
|
|
}
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-02-14 15:05:18 -08:00
|
|
|
|
|
2026-03-12 02:52:40 -07:00
|
|
|
|
// Store inspect result for UI display
|
|
|
|
|
|
inspectResult_.guid = guid;
|
|
|
|
|
|
inspectResult_.playerName = playerName;
|
|
|
|
|
|
inspectResult_.totalTalents = totalTalents;
|
|
|
|
|
|
inspectResult_.unspentTalents = unspentTalents;
|
|
|
|
|
|
inspectResult_.talentGroups = talentGroupCount;
|
|
|
|
|
|
inspectResult_.activeTalentGroup = activeTalentGroup;
|
2026-03-12 07:37:29 -07:00
|
|
|
|
inspectResult_.enchantIds = enchantIds;
|
2026-03-12 02:52:40 -07:00
|
|
|
|
|
|
|
|
|
|
// 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 = {};
|
2026-02-14 15:05:18 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ",
|
|
|
|
|
|
unspentTalents, " unspent, ", (int)talentGroupCount, " specs");
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:52:28 -08:00
|
|
|
|
uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const {
|
|
|
|
|
|
if (itemId == 0) return 0;
|
|
|
|
|
|
for (const auto& [guid, info] : onlineItems_) {
|
2026-02-26 00:59:07 -08:00
|
|
|
|
if (info.entry == itemId) return guid;
|
2026-02-06 18:52:28 -08:00
|
|
|
|
}
|
2026-02-26 00:59:07 -08:00
|
|
|
|
return 0;
|
2026-02-06 18:52:28 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::detectInventorySlotBases(const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return;
|
2026-02-13 19:47:49 -08:00
|
|
|
|
if (fields.empty()) return;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-13 19:47:49 -08:00
|
|
|
|
// 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;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
matchingPairs.push_back(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (matchingPairs.empty()) return;
|
|
|
|
|
|
std::sort(matchingPairs.begin(), matchingPairs.end());
|
|
|
|
|
|
|
|
|
|
|
|
if (invSlotBase_ < 0) {
|
2026-02-06 19:13:38 -08:00
|
|
|
|
// 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.
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const int knownBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
|
2026-02-06 19:13:38 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
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;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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));
|
2026-03-14 08:42:25 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-14 08:42:25 -07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2026-02-26 11:12:34 -08:00
|
|
|
|
}
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
if (bankBase != 0xFFFF && key >= static_cast<uint16_t>(bankBase) &&
|
2026-02-26 11:12:34 -08:00
|
|
|
|
key <= static_cast<uint16_t>(bankBase) + (effectiveBankSlots_ * 2 - 1)) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 11:12:34 -08:00
|
|
|
|
// Bank bag slots starting at PLAYER_FIELD_BANKBAG_SLOT_1
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
if (bankBagBase != 0xFFFF && key >= static_cast<uint16_t>(bankBagBase) &&
|
2026-02-26 11:12:34 -08:00
|
|
|
|
key <= static_cast<uint16_t>(bankBagBase) + (effectiveBankBagSlots_ * 2 - 1)) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return slotsChanged;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
void GameHandler::rebuildOnlineInventory() {
|
|
|
|
|
|
|
2026-02-26 13:38:29 -08:00
|
|
|
|
uint8_t savedBankBagSlots = inventory.getPurchasedBankBagSlots();
|
2026-02-06 03:11:43 -08:00
|
|
|
|
inventory = Inventory();
|
2026-02-26 13:38:29 -08:00
|
|
|
|
inventory.setPurchasedBankBagSlots(savedBankBagSlots);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-03-10 16:21:09 -07:00
|
|
|
|
def.curDurability = itemIt->second.curDurability;
|
|
|
|
|
|
def.maxDurability = itemIt->second.maxDurability;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
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;
|
2026-02-18 03:50:47 -08:00
|
|
|
|
def.damageMin = infoIt->second.damageMin;
|
|
|
|
|
|
def.damageMax = infoIt->second.damageMax;
|
|
|
|
|
|
def.delayMs = infoIt->second.delayMs;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
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;
|
2026-03-10 16:30:01 -07:00
|
|
|
|
def.sellPrice = infoIt->second.sellPrice;
|
2026-03-10 16:26:20 -07:00
|
|
|
|
def.itemLevel = infoIt->second.itemLevel;
|
|
|
|
|
|
def.requiredLevel = infoIt->second.requiredLevel;
|
2026-03-10 16:47:55 -07:00
|
|
|
|
def.bindType = infoIt->second.bindType;
|
|
|
|
|
|
def.description = infoIt->second.description;
|
2026-03-10 17:05:04 -07:00
|
|
|
|
def.startQuestId = infoIt->second.startQuestId;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.extraStats.clear();
|
|
|
|
|
|
for (const auto& es : infoIt->second.extraStats)
|
|
|
|
|
|
def.extraStats.push_back({es.statType, es.statValue});
|
2026-02-06 03:11:43 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
queryItemInfo(def.itemId, guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-10 16:21:09 -07:00
|
|
|
|
def.curDurability = itemIt->second.curDurability;
|
|
|
|
|
|
def.maxDurability = itemIt->second.maxDurability;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
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;
|
2026-02-18 03:50:47 -08:00
|
|
|
|
def.damageMin = infoIt->second.damageMin;
|
|
|
|
|
|
def.damageMax = infoIt->second.damageMax;
|
|
|
|
|
|
def.delayMs = infoIt->second.delayMs;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
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;
|
2026-03-10 16:30:01 -07:00
|
|
|
|
def.sellPrice = infoIt->second.sellPrice;
|
2026-03-10 16:26:20 -07:00
|
|
|
|
def.itemLevel = infoIt->second.itemLevel;
|
|
|
|
|
|
def.requiredLevel = infoIt->second.requiredLevel;
|
2026-03-10 16:47:55 -07:00
|
|
|
|
def.bindType = infoIt->second.bindType;
|
|
|
|
|
|
def.description = infoIt->second.description;
|
2026-03-10 17:05:04 -07:00
|
|
|
|
def.startQuestId = infoIt->second.startQuestId;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.extraStats.clear();
|
|
|
|
|
|
for (const auto& es : infoIt->second.extraStats)
|
|
|
|
|
|
def.extraStats.push_back({es.statType, es.statValue});
|
2026-02-06 03:11:43 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
queryItemInfo(def.itemId, guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBackpackSlot(i, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 08:42:25 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// 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;
|
2026-03-10 16:21:09 -07:00
|
|
|
|
def.curDurability = itemIt->second.curDurability;
|
|
|
|
|
|
def.maxDurability = itemIt->second.maxDurability;
|
2026-02-13 22:14:34 -08:00
|
|
|
|
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;
|
2026-02-18 03:50:47 -08:00
|
|
|
|
def.damageMin = infoIt->second.damageMin;
|
|
|
|
|
|
def.damageMax = infoIt->second.damageMax;
|
|
|
|
|
|
def.delayMs = infoIt->second.delayMs;
|
2026-02-13 22:14:34 -08:00
|
|
|
|
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;
|
2026-03-10 16:30:01 -07:00
|
|
|
|
def.sellPrice = infoIt->second.sellPrice;
|
2026-03-10 16:26:20 -07:00
|
|
|
|
def.itemLevel = infoIt->second.itemLevel;
|
|
|
|
|
|
def.requiredLevel = infoIt->second.requiredLevel;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.bindType = infoIt->second.bindType;
|
|
|
|
|
|
def.description = infoIt->second.description;
|
2026-03-10 17:05:04 -07:00
|
|
|
|
def.startQuestId = infoIt->second.startQuestId;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.extraStats.clear();
|
|
|
|
|
|
for (const auto& es : infoIt->second.extraStats)
|
|
|
|
|
|
def.extraStats.push_back({es.statType, es.statValue});
|
2026-02-13 22:14:34 -08:00
|
|
|
|
def.bagSlots = infoIt->second.containerSlots;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
|
|
|
|
|
queryItemInfo(def.itemId, itemGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBagSlot(bagIdx, s, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 11:12:34 -08:00
|
|
|
|
// Bank slots (24 for Classic, 28 for TBC/WotLK)
|
|
|
|
|
|
for (int i = 0; i < effectiveBankSlots_; i++) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
uint64_t guid = bankSlotGuids_[i];
|
|
|
|
|
|
if (guid == 0) { inventory.clearBankSlot(i); continue; }
|
|
|
|
|
|
|
|
|
|
|
|
auto itemIt = onlineItems_.find(guid);
|
2026-02-26 10:46:47 -08:00
|
|
|
|
if (itemIt == onlineItems_.end()) { inventory.clearBankSlot(i); continue; }
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
|
|
|
|
|
|
ItemDef def;
|
|
|
|
|
|
def.itemId = itemIt->second.entry;
|
|
|
|
|
|
def.stackCount = itemIt->second.stackCount;
|
2026-03-10 16:21:09 -07:00
|
|
|
|
def.curDurability = itemIt->second.curDurability;
|
|
|
|
|
|
def.maxDurability = itemIt->second.maxDurability;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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;
|
2026-02-18 03:50:47 -08:00
|
|
|
|
def.damageMin = infoIt->second.damageMin;
|
|
|
|
|
|
def.damageMax = infoIt->second.damageMax;
|
|
|
|
|
|
def.delayMs = infoIt->second.delayMs;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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;
|
2026-03-10 16:26:20 -07:00
|
|
|
|
def.itemLevel = infoIt->second.itemLevel;
|
|
|
|
|
|
def.requiredLevel = infoIt->second.requiredLevel;
|
2026-03-10 16:47:55 -07:00
|
|
|
|
def.bindType = infoIt->second.bindType;
|
|
|
|
|
|
def.description = infoIt->second.description;
|
2026-03-10 17:05:04 -07:00
|
|
|
|
def.startQuestId = infoIt->second.startQuestId;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.extraStats.clear();
|
|
|
|
|
|
for (const auto& es : infoIt->second.extraStats)
|
|
|
|
|
|
def.extraStats.push_back({es.statType, es.statValue});
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 11:12:34 -08:00
|
|
|
|
// Bank bag contents (6 for Classic, 7 for TBC/WotLK)
|
|
|
|
|
|
for (int bagIdx = 0; bagIdx < effectiveBankBagSlots_; bagIdx++) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-26 13:38:29 -08:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
|
|
|
|
|
|
if (bagInfoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
numSlots = bagInfoIt->second.containerSlots;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-26 13:38:29 -08:00
|
|
|
|
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);
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-03-10 16:21:09 -07:00
|
|
|
|
def.curDurability = itemIt->second.curDurability;
|
|
|
|
|
|
def.maxDurability = itemIt->second.maxDurability;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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;
|
2026-02-18 03:50:47 -08:00
|
|
|
|
def.damageMin = infoIt->second.damageMin;
|
|
|
|
|
|
def.damageMax = infoIt->second.damageMax;
|
|
|
|
|
|
def.delayMs = infoIt->second.delayMs;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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;
|
2026-03-10 16:26:20 -07:00
|
|
|
|
def.itemLevel = infoIt->second.itemLevel;
|
|
|
|
|
|
def.requiredLevel = infoIt->second.requiredLevel;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
def.sellPrice = infoIt->second.sellPrice;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.bindType = infoIt->second.bindType;
|
|
|
|
|
|
def.description = infoIt->second.description;
|
2026-03-10 17:05:04 -07:00
|
|
|
|
def.startQuestId = infoIt->second.startQuestId;
|
feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.
Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
|
|
|
|
def.extraStats.clear();
|
|
|
|
|
|
for (const auto& es : infoIt->second.extraStats)
|
|
|
|
|
|
def.extraStats.push_back({es.statType, es.statValue});
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
def.bagSlots = infoIt->second.containerSlots;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
|
|
|
|
|
queryItemInfo(def.itemId, itemGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBankBagSlot(bagIdx, s, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 16:33:24 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-06 03:13:42 -08:00
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
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;
|
2026-03-14 08:42:25 -07:00
|
|
|
|
}(), " keyring=", [&](){
|
|
|
|
|
|
int c = 0; for (auto g : keyringSlotGuids_) if (g) c++; return c;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:10:19 -08:00
|
|
|
|
void GameHandler::maybeDetectVisibleItemLayout() {
|
2026-02-14 15:43:09 -08:00
|
|
|
|
if (visibleItemLayoutVerified_) return;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
if (lastPlayerFields_.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::array<uint32_t, 19> equipEntries{};
|
|
|
|
|
|
int nonZero = 0;
|
2026-02-13 20:19:33 -08:00
|
|
|
|
// 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.
|
2026-02-13 20:10:19 -08:00
|
|
|
|
for (int i = 0; i < 19; i++) {
|
2026-02-13 20:19:33 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
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;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
int bestMismatches = 9999;
|
|
|
|
|
|
int bestScore = -999999;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
int mismatches = 0;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
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);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
if (it == lastPlayerFields_.end()) continue;
|
|
|
|
|
|
if (it->second == want) {
|
|
|
|
|
|
matches++;
|
|
|
|
|
|
} else if (it->second != 0) {
|
|
|
|
|
|
mismatches++;
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
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;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
bestMatches = matches;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
bestMismatches = mismatches;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
bestBase = base;
|
|
|
|
|
|
bestStride = stride;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:43:09 -08:00
|
|
|
|
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, ")");
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
2026-02-14 15:43:09 -08:00
|
|
|
|
// 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());
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
2026-02-14 15:43:09 -08:00
|
|
|
|
// If heuristic didn't find a match, keep using the default WotLK layout (base=284, stride=2).
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
if (guid == 0 || guid == playerGuid) return;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
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);
|
2026-02-16 00:51:59 -08:00
|
|
|
|
LOG_DEBUG("Queued player 0x", std::hex, guid, std::dec, " for auto-inspect (layout not detected)");
|
2026-02-13 22:51:49 -08:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:10:19 -08:00
|
|
|
|
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{};
|
2026-02-13 20:26:55 -08:00
|
|
|
|
bool anyEntry = false;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
|
|
|
|
|
for (int s = 0; s < 19; s++) {
|
|
|
|
|
|
uint32_t entry = it->second[s];
|
|
|
|
|
|
if (entry == 0) continue;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
anyEntry = true;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
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);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::emitAllOtherPlayerEquipment() {
|
|
|
|
|
|
if (!playerEquipmentCallback_) return;
|
|
|
|
|
|
for (const auto& [guid, _] : otherPlayerVisibleItemEntries_) {
|
|
|
|
|
|
emitOtherPlayerEquipment(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 2: Combat
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::startAutoAttack(uint64_t targetGuid) {
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// Can't attack yourself
|
|
|
|
|
|
if (targetGuid == playerGuid) return;
|
2026-02-20 17:47:10 -08:00
|
|
|
|
if (targetGuid == 0) return;
|
2026-02-07 18:33:14 -08:00
|
|
|
|
|
|
|
|
|
|
// Dismount when entering combat
|
|
|
|
|
|
if (isMounted()) {
|
|
|
|
|
|
dismount();
|
|
|
|
|
|
}
|
2026-02-20 17:47:10 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 03:38:12 -08:00
|
|
|
|
autoAttackRequested_ = true;
|
2026-03-15 06:13:36 -07:00
|
|
|
|
autoAttackRetryPending_ = true;
|
2026-02-20 03:38:12 -08:00
|
|
|
|
// Keep combat animation/state server-authoritative. We only flip autoAttacking
|
|
|
|
|
|
// on SMSG_ATTACKSTART where attackerGuid == playerGuid.
|
|
|
|
|
|
autoAttacking = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
autoAttackTarget = targetGuid;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
autoAttackOutOfRange_ = false;
|
2026-02-20 17:47:10 -08:00
|
|
|
|
autoAttackOutOfRangeTime_ = 0.0f;
|
2026-02-17 15:37:02 -08:00
|
|
|
|
autoAttackResendTimer_ = 0.0f;
|
2026-02-20 03:14:48 -08:00
|
|
|
|
autoAttackFacingSyncTimer_ = 0.0f;
|
2026-02-04 13:29:27 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = AttackSwingPacket::build(targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
LOG_INFO("Starting auto-attack on 0x", std::hex, targetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::stopAutoAttack() {
|
2026-02-20 03:38:12 -08:00
|
|
|
|
if (!autoAttacking && !autoAttackRequested_) return;
|
|
|
|
|
|
autoAttackRequested_ = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
autoAttacking = false;
|
2026-03-15 06:13:36 -07:00
|
|
|
|
autoAttackRetryPending_ = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
autoAttackTarget = 0;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
autoAttackOutOfRange_ = false;
|
2026-02-20 17:47:10 -08:00
|
|
|
|
autoAttackOutOfRangeTime_ = 0.0f;
|
2026-02-17 15:37:02 -08:00
|
|
|
|
autoAttackResendTimer_ = 0.0f;
|
2026-02-20 03:14:48 -08:00
|
|
|
|
autoAttackFacingSyncTimer_ = 0.0f;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = AttackStopPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Stopping auto-attack");
|
2026-03-21 02:31:59 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_LEAVE_COMBAT", {});
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 11:48:42 -07:00
|
|
|
|
void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType,
|
|
|
|
|
|
uint64_t srcGuid, uint64_t dstGuid) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
CombatTextEntry entry;
|
|
|
|
|
|
entry.type = type;
|
|
|
|
|
|
entry.amount = amount;
|
|
|
|
|
|
entry.spellId = spellId;
|
|
|
|
|
|
entry.age = 0.0f;
|
|
|
|
|
|
entry.isPlayerSource = isPlayerSource;
|
2026-03-13 06:08:21 -07:00
|
|
|
|
entry.powerType = powerType;
|
2026-03-18 09:54:52 -07:00
|
|
|
|
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);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
combatText.push_back(entry);
|
feat: add persistent combat log window (/combatlog or /cl)
Stores up to 500 combat events in a rolling deque alongside the existing
floating combat text. Events are populated via the existing addCombatText()
call site, resolving attacker/target names from the entity manager and
player name cache at event time.
- CombatLogEntry struct in spell_defines.hpp (type, amount, spellId,
isPlayerSource, timestamp, sourceName, targetName)
- getCombatLog() / clearCombatLog() accessors on GameHandler
- renderCombatLog() in GameScreen: scrollable two-column table (Time +
Event), color-coded by event category, with Damage/Healing/Misc filter
checkboxes, auto-scroll toggle, and Clear button
- /combatlog (/cl) chat command toggles the window
2026-03-12 11:00:10 -07:00
|
|
|
|
|
2026-03-13 11:48:42 -07:00
|
|
|
|
// Persistent combat log — use explicit GUIDs if provided, else fall back to
|
|
|
|
|
|
// player/current-target (the old behaviour for events without specific participants).
|
feat: add persistent combat log window (/combatlog or /cl)
Stores up to 500 combat events in a rolling deque alongside the existing
floating combat text. Events are populated via the existing addCombatText()
call site, resolving attacker/target names from the entity manager and
player name cache at event time.
- CombatLogEntry struct in spell_defines.hpp (type, amount, spellId,
isPlayerSource, timestamp, sourceName, targetName)
- getCombatLog() / clearCombatLog() accessors on GameHandler
- renderCombatLog() in GameScreen: scrollable two-column table (Time +
Event), color-coded by event category, with Damage/Healing/Misc filter
checkboxes, auto-scroll toggle, and Clear button
- /combatlog (/cl) chat command toggles the window
2026-03-12 11:00:10 -07:00
|
|
|
|
CombatLogEntry log;
|
|
|
|
|
|
log.type = type;
|
|
|
|
|
|
log.amount = amount;
|
|
|
|
|
|
log.spellId = spellId;
|
|
|
|
|
|
log.isPlayerSource = isPlayerSource;
|
2026-03-17 10:54:07 -07:00
|
|
|
|
log.powerType = powerType;
|
feat: add persistent combat log window (/combatlog or /cl)
Stores up to 500 combat events in a rolling deque alongside the existing
floating combat text. Events are populated via the existing addCombatText()
call site, resolving attacker/target names from the entity manager and
player name cache at event time.
- CombatLogEntry struct in spell_defines.hpp (type, amount, spellId,
isPlayerSource, timestamp, sourceName, targetName)
- getCombatLog() / clearCombatLog() accessors on GameHandler
- renderCombatLog() in GameScreen: scrollable two-column table (Time +
Event), color-coded by event category, with Damage/Healing/Misc filter
checkboxes, auto-scroll toggle, and Clear button
- /combatlog (/cl) chat command toggles the window
2026-03-12 11:00:10 -07:00
|
|
|
|
log.timestamp = std::time(nullptr);
|
2026-03-14 09:29:02 -07:00
|
|
|
|
// 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.
|
2026-03-13 11:48:42 -07:00
|
|
|
|
uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid
|
2026-03-14 09:29:02 -07:00
|
|
|
|
: ((dstGuid != 0) ? 0 : (isPlayerSource ? playerGuid : targetGuid));
|
2026-03-13 11:48:42 -07:00
|
|
|
|
uint64_t effectiveDst = (dstGuid != 0) ? dstGuid
|
|
|
|
|
|
: (isPlayerSource ? targetGuid : playerGuid);
|
|
|
|
|
|
log.sourceName = lookupName(effectiveSrc);
|
|
|
|
|
|
log.targetName = (effectiveDst != 0) ? lookupName(effectiveDst) : std::string{};
|
feat: add persistent combat log window (/combatlog or /cl)
Stores up to 500 combat events in a rolling deque alongside the existing
floating combat text. Events are populated via the existing addCombatText()
call site, resolving attacker/target names from the entity manager and
player name cache at event time.
- CombatLogEntry struct in spell_defines.hpp (type, amount, spellId,
isPlayerSource, timestamp, sourceName, targetName)
- getCombatLog() / clearCombatLog() accessors on GameHandler
- renderCombatLog() in GameScreen: scrollable two-column table (Time +
Event), color-coded by event category, with Damage/Healing/Misc filter
checkboxes, auto-scroll toggle, and Clear button
- /combatlog (/cl) chat command toggles the window
2026-03-12 11:00:10 -07:00
|
|
|
|
if (combatLog_.size() >= MAX_COMBAT_LOG)
|
|
|
|
|
|
combatLog_.pop_front();
|
|
|
|
|
|
combatLog_.push_back(std::move(log));
|
2026-03-20 14:35:00 -07:00
|
|
|
|
|
|
|
|
|
|
// 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)));
|
|
|
|
|
|
addonEventCallback_("COMBAT_LOG_EVENT_UNFILTERED", {
|
|
|
|
|
|
timestamp, subevent,
|
|
|
|
|
|
srcBuf, log.sourceName, "0",
|
|
|
|
|
|
dstBuf, log.targetName, "0",
|
|
|
|
|
|
std::to_string(spellId), spellName,
|
|
|
|
|
|
std::to_string(amount)
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 20:23:24 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 20:51:53 -08:00
|
|
|
|
void GameHandler::autoTargetAttacker(uint64_t attackerGuid) {
|
|
|
|
|
|
if (attackerGuid == 0 || attackerGuid == playerGuid) return;
|
|
|
|
|
|
if (targetGuid != 0) return;
|
|
|
|
|
|
if (!entityManager.hasEntity(attackerGuid)) return;
|
|
|
|
|
|
setTarget(attackerGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleAttackStart(network::Packet& packet) {
|
|
|
|
|
|
AttackStartData data;
|
|
|
|
|
|
if (!AttackStartParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.attackerGuid == playerGuid) {
|
2026-02-20 03:38:12 -08:00
|
|
|
|
autoAttackRequested_ = true;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
autoAttacking = true;
|
2026-03-15 06:13:36 -07:00
|
|
|
|
autoAttackRetryPending_ = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
autoAttackTarget = data.victimGuid;
|
2026-03-21 02:31:59 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("PLAYER_ENTER_COMBAT", {});
|
2026-02-07 20:51:53 -08:00
|
|
|
|
} else if (data.victimGuid == playerGuid && data.attackerGuid != 0) {
|
|
|
|
|
|
hostileAttackers_.insert(data.attackerGuid);
|
|
|
|
|
|
autoTargetAttacker(data.attackerGuid);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-02-18 04:43:23 -08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAttackStop(network::Packet& packet) {
|
|
|
|
|
|
AttackStopData data;
|
|
|
|
|
|
if (!AttackStopParser::parse(packet, data)) return;
|
|
|
|
|
|
|
2026-02-20 03:38:12 -08:00
|
|
|
|
// Keep intent, but clear server-confirmed active state until ATTACKSTART resumes.
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (data.attackerGuid == playerGuid) {
|
2026-02-20 03:38:12 -08:00
|
|
|
|
autoAttacking = false;
|
2026-03-15 06:13:36 -07:00
|
|
|
|
autoAttackRetryPending_ = autoAttackRequested_;
|
|
|
|
|
|
autoAttackResendTimer_ = 0.0f;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)");
|
2026-02-06 18:34:45 -08:00
|
|
|
|
} else if (data.victimGuid == playerGuid) {
|
|
|
|
|
|
hostileAttackers_.erase(data.attackerGuid);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 17:59:40 -08:00
|
|
|
|
void GameHandler::dismount() {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (!socket) return;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
// Clear local mount state immediately (optimistic dismount).
|
|
|
|
|
|
// Server will confirm via SMSG_UPDATE_OBJECT with mountDisplayId=0.
|
2026-02-14 16:42:47 -08:00
|
|
|
|
uint32_t savedMountAura = mountAuraSpellId_;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
if (currentMountDisplayId_ != 0 || taxiMountActive_) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
2026-02-14 16:42:47 -08:00
|
|
|
|
mountAuraSpellId_ = 0;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
LOG_INFO("Dismount: cleared local mount state");
|
2026-02-11 21:14:35 -08:00
|
|
|
|
}
|
2026-02-14 16:42:47 -08:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* name,
|
|
|
|
|
|
Opcode ackOpcode, float* speedStorage) {
|
2026-03-09 23:58:15 -07:00
|
|
|
|
// WotLK: packed GUID; TBC/Classic: full uint64
|
|
|
|
|
|
const bool fscTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
uint64_t guid = fscTbcLike
|
|
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// uint32 counter
|
|
|
|
|
|
uint32_t counter = packet.readUInt32();
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
// float newSpeed
|
|
|
|
|
|
float newSpeed = packet.readFloat();
|
|
|
|
|
|
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
LOG_INFO("SMSG_FORCE_", name, "_CHANGE: guid=0x", std::hex, guid, std::dec,
|
2026-02-07 18:33:14 -08:00
|
|
|
|
" counter=", counter, " speed=", newSpeed);
|
|
|
|
|
|
|
|
|
|
|
|
if (guid != playerGuid) return;
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Always ACK the speed change to prevent server stall.
|
game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle. Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.
Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks. Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.
Affected: handleForceSpeedChange, handleForceMoveRootState,
handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
|
|
|
|
// Classic/TBC use full uint64 GUID; WotLK uses packed GUID.
|
|
|
|
|
|
if (socket) {
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
network::Packet ack(wireOpcode(ackOpcode));
|
2026-02-20 03:38:12 -08:00
|
|
|
|
const bool legacyGuidAck =
|
|
|
|
|
|
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
|
|
|
|
|
|
if (legacyGuidAck) {
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
ack.writeUInt64(playerGuid);
|
2026-02-20 03:38:12 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
MovementPacket::writePackedGuid(ack, playerGuid);
|
|
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
|
ack.writeUInt32(counter);
|
2026-02-11 21:14:35 -08:00
|
|
|
|
|
|
|
|
|
|
MovementInfo wire = movementInfo;
|
2026-02-20 02:19:17 -08:00
|
|
|
|
wire.time = nextMovementTimestampMs();
|
|
|
|
|
|
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
|
|
|
|
|
|
wire.transportTime = wire.time;
|
|
|
|
|
|
wire.transportTime2 = wire.time;
|
|
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-20 02:19:17 -08:00
|
|
|
|
if (packetParsers_) {
|
|
|
|
|
|
packetParsers_->writeMovementPayload(ack, wire);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
MovementPacket::writeMovementPayload(ack, wire);
|
|
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
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) {
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
LOG_WARNING("Ignoring invalid ", name, " speed: ", newSpeed);
|
2026-02-07 18:33:14 -08:00
|
|
|
|
return;
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
|
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
if (speedStorage) *speedStorage = newSpeed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleForceRunSpeedChange(network::Packet& packet) {
|
|
|
|
|
|
handleForceSpeedChange(packet, "RUN_SPEED", Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK, &serverRunSpeed_);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
|
|
|
|
|
|
// 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.
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
if (!onTaxiFlight_ && !taxiMountActive_ && currentMountDisplayId_ != 0 && serverRunSpeed_ <= 8.5f) {
|
|
|
|
|
|
LOG_INFO("Auto-clearing mount from speed change: speed=", serverRunSpeed_,
|
2026-02-11 19:28:15 -08:00
|
|
|
|
" displayId=", currentMountDisplayId_);
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 03:14:48 -08:00
|
|
|
|
void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) {
|
|
|
|
|
|
// Packet is server movement control update:
|
2026-03-10 00:06:11 -07:00
|
|
|
|
// WotLK: packed GUID + uint32 counter + [optional unknown field(s)]
|
|
|
|
|
|
// TBC/Classic: full uint64 + uint32 counter
|
2026-02-20 03:14:48 -08:00
|
|
|
|
// We always ACK with current movement state, same pattern as speed-change ACKs.
|
2026-03-10 00:06:11 -07:00
|
|
|
|
const bool rootTbc = isClassicLikeExpansion() || isActiveExpansion("tbc");
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < (rootTbc ? 8u : 2u)) return;
|
|
|
|
|
|
uint64_t guid = rootTbc
|
|
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-02-20 03:14:48 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle. Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.
Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks. Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.
Affected: handleForceSpeedChange, handleForceMoveRootState,
handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
|
|
|
|
if (!socket) return;
|
2026-02-20 03:14:48 -08:00
|
|
|
|
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);
|
2026-02-20 03:38:12 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-20 03:14:48 -08:00
|
|
|
|
ack.writeUInt32(counter);
|
|
|
|
|
|
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
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) {
|
2026-03-09 23:58:15 -07:00
|
|
|
|
// 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);
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle. Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.
Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks. Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.
Affected: handleForceSpeedChange, handleForceMoveRootState,
handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
|
|
|
|
if (!socket) return;
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:40:46 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
void GameHandler::handleMoveKnockBack(network::Packet& packet) {
|
2026-03-09 23:58:15 -07:00
|
|
|
|
// 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);
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4)
|
|
|
|
|
|
uint32_t counter = packet.readUInt32();
|
2026-03-10 12:28:11 -07:00
|
|
|
|
float vcos = packet.readFloat();
|
|
|
|
|
|
float vsin = packet.readFloat();
|
|
|
|
|
|
float hspeed = packet.readFloat();
|
|
|
|
|
|
float vspeed = packet.readFloat();
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec,
|
2026-03-10 12:28:11 -07:00
|
|
|
|
" counter=", counter, " vcos=", vcos, " vsin=", vsin,
|
|
|
|
|
|
" hspeed=", hspeed, " vspeed=", vspeed);
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
|
|
|
|
|
|
if (guid != playerGuid) return;
|
|
|
|
|
|
|
2026-03-10 12:28:11 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle. Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.
Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks. Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.
Affected: handleForceSpeedChange, handleForceMoveRootState,
handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
|
|
|
|
if (!socket) return;
|
Add missing movement ACK responses to avoid server stalls
Implement generic handlers for force speed changes (walk, run back,
swim, swim back, flight, flight back, turn rate, pitch rate),
movement flag toggles (CAN_FLY, HOVER, feather fall, water walk),
and knockback ACKs. Fix SMSG_TIME_SYNC_REQ to respond with
CMSG_TIME_SYNC_RESP instead of silently dropping.
2026-02-26 03:02:51 -08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-02-20 03:14:48 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Arena / Battleground Handlers
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
|
2026-03-11 04:22:18 -07:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t queueSlot = packet.readUInt32();
|
|
|
|
|
|
|
2026-03-11 04:22:18 -07:00
|
|
|
|
const bool classicFormat = isClassicLikeExpansion();
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
2026-03-11 04:22:18 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-02-09 23:53:17 -08:00
|
|
|
|
(void)clientInstanceId;
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
uint8_t isRatedArena = packet.readUInt8();
|
2026-02-09 23:53:17 -08:00
|
|
|
|
(void)isRatedArena;
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t statusId = packet.readUInt32();
|
|
|
|
|
|
|
2026-03-13 06:59:02 -07:00
|
|
|
|
// Map BG type IDs to their names (stable across all three expansions)
|
2026-03-18 12:04:38 -07:00
|
|
|
|
// BattlemasterList.dbc IDs (3.3.5a)
|
2026-03-13 06:59:02 -07:00
|
|
|
|
static const std::pair<uint32_t, const char*> kBgNames[] = {
|
|
|
|
|
|
{1, "Alterac Valley"},
|
|
|
|
|
|
{2, "Warsong Gulch"},
|
|
|
|
|
|
{3, "Arathi Basin"},
|
2026-03-18 12:04:38 -07:00
|
|
|
|
{4, "Nagrand Arena"},
|
|
|
|
|
|
{5, "Blade's Edge Arena"},
|
|
|
|
|
|
{6, "All Arenas"},
|
2026-03-18 12:03:36 -07:00
|
|
|
|
{7, "Eye of the Storm"},
|
2026-03-18 12:04:38 -07:00
|
|
|
|
{8, "Ruins of Lordaeron"},
|
2026-03-13 06:59:02 -07:00
|
|
|
|
{9, "Strand of the Ancients"},
|
2026-03-18 12:04:38 -07:00
|
|
|
|
{10, "Dalaran Sewers"},
|
|
|
|
|
|
{11, "Ring of Valor"},
|
|
|
|
|
|
{30, "Isle of Conquest"},
|
|
|
|
|
|
{32, "Random Battleground"},
|
2026-03-13 06:59:02 -07:00
|
|
|
|
};
|
|
|
|
|
|
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);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
if (arenaType > 0) {
|
|
|
|
|
|
bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena";
|
2026-03-13 06:59:02 -07:00
|
|
|
|
// If bgTypeId matches a named arena, prefer that name
|
|
|
|
|
|
for (const auto& kv : kBgNames) {
|
|
|
|
|
|
if (kv.first == bgTypeId) {
|
|
|
|
|
|
bgName += " (" + std::string(kv.second) + ")";
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 21:12:28 -07:00
|
|
|
|
// Parse status-specific fields
|
|
|
|
|
|
uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds)
|
2026-03-13 10:10:04 -07:00
|
|
|
|
uint32_t avgWaitSec = 0, timeInQueueSec = 0;
|
2026-03-10 21:12:28 -07:00
|
|
|
|
if (statusId == 1) {
|
|
|
|
|
|
// STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8) {
|
2026-03-13 10:10:04 -07:00
|
|
|
|
avgWaitSec = packet.readUInt32() / 1000; // ms → seconds
|
|
|
|
|
|
timeInQueueSec = packet.readUInt32() / 1000;
|
2026-03-10 21:12:28 -07:00
|
|
|
|
}
|
|
|
|
|
|
} 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
// Store queue state
|
|
|
|
|
|
if (queueSlot < bgQueues_.size()) {
|
2026-03-10 21:12:28 -07:00
|
|
|
|
bool wasInvite = (bgQueues_[queueSlot].statusId == 2);
|
2026-02-26 17:56:11 -08:00
|
|
|
|
bgQueues_[queueSlot].queueSlot = queueSlot;
|
|
|
|
|
|
bgQueues_[queueSlot].bgTypeId = bgTypeId;
|
|
|
|
|
|
bgQueues_[queueSlot].arenaType = arenaType;
|
|
|
|
|
|
bgQueues_[queueSlot].statusId = statusId;
|
2026-03-18 12:03:36 -07:00
|
|
|
|
bgQueues_[queueSlot].bgName = bgName;
|
2026-03-13 10:10:04 -07:00
|
|
|
|
if (statusId == 1) {
|
|
|
|
|
|
bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec;
|
|
|
|
|
|
bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec;
|
|
|
|
|
|
}
|
2026-03-10 21:12:28 -07:00
|
|
|
|
if (statusId == 2 && !wasInvite) {
|
|
|
|
|
|
bgQueues_[queueSlot].inviteTimeout = inviteTimeout;
|
|
|
|
|
|
bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
}
|
2026-02-26 17:56:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
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
|
2026-03-10 21:12:28 -07:00
|
|
|
|
// Popup shown by the UI; add chat notification too.
|
|
|
|
|
|
addSystemChatMessage(bgName + " is ready!");
|
|
|
|
|
|
LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName,
|
|
|
|
|
|
" timeout=", inviteTimeout, "s");
|
2026-02-07 23:47:43 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 21:54:48 -07:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 21:12:28 -07:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-03-10 21:12:28 -07:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
addSystemChatMessage("Accepting battleground invitation...");
|
|
|
|
|
|
LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:36:23 -07:00
|
|
|
|
void GameHandler::handleRaidInstanceInfo(network::Packet& packet) {
|
2026-03-09 22:39:08 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-03-09 13:36:23 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
instanceLockouts_.clear();
|
|
|
|
|
|
instanceLockouts_.reserve(count);
|
|
|
|
|
|
|
2026-03-09 22:39:08 -07:00
|
|
|
|
const size_t kEntrySize = useTbcFormat ? (4 + 4 + 4 + 1) : (4 + 4 + 8 + 1 + 1);
|
2026-03-09 13:36:23 -07:00
|
|
|
|
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();
|
2026-03-09 22:39:08 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-09 13:36:23 -07:00
|
|
|
|
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)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
void GameHandler::handleInstanceDifficulty(network::Packet& packet) {
|
2026-03-11 02:08:38 -07:00
|
|
|
|
// 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;
|
2026-03-13 07:42:40 -07:00
|
|
|
|
uint32_t prevDifficulty = instanceDifficulty_;
|
2026-02-26 17:56:11 -08:00
|
|
|
|
instanceDifficulty_ = packet.readUInt32();
|
2026-03-11 02:08:38 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-18 12:21:41 -07:00
|
|
|
|
inInstance_ = true;
|
2026-02-26 17:56:11 -08:00
|
|
|
|
LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_);
|
2026-03-13 07:42:40 -07:00
|
|
|
|
|
|
|
|
|
|
// 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 + ".");
|
|
|
|
|
|
}
|
2026-02-26 17:56:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:30:23 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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));
|
2026-03-13 08:14:47 -07:00
|
|
|
|
{
|
|
|
|
|
|
std::string dName = getLfgDungeonName(lfgDungeonId_);
|
|
|
|
|
|
if (!dName.empty())
|
|
|
|
|
|
addSystemChatMessage("Dungeon Finder: Joined the queue for " + dName + ".");
|
|
|
|
|
|
else
|
|
|
|
|
|
addSystemChatMessage("Dungeon Finder: Joined the queue.");
|
|
|
|
|
|
}
|
2026-03-09 13:30:23 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
const char* msg = lfgJoinResultString(result);
|
|
|
|
|
|
std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed.");
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError(errMsg);
|
2026-03-09 13:30:23 -07:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-03-09 13:47:07 -07:00
|
|
|
|
lfgDungeonId_ = dungeonId;
|
|
|
|
|
|
lfgProposalId_ = proposalId;
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
|
|
|
|
|
switch (proposalState) {
|
|
|
|
|
|
case 0:
|
2026-03-09 13:47:07 -07:00
|
|
|
|
lfgState_ = LfgState::Queued;
|
|
|
|
|
|
lfgProposalId_ = 0;
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("Dungeon Finder: Group proposal failed.");
|
2026-03-09 13:30:23 -07:00
|
|
|
|
addSystemChatMessage("Dungeon Finder: Group proposal failed.");
|
|
|
|
|
|
break;
|
2026-03-13 08:14:47 -07:00
|
|
|
|
case 1: {
|
2026-03-09 13:47:07 -07:00
|
|
|
|
lfgState_ = LfgState::InDungeon;
|
|
|
|
|
|
lfgProposalId_ = 0;
|
2026-03-13 08:14:47 -07:00
|
|
|
|
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...");
|
2026-03-09 13:30:23 -07:00
|
|
|
|
break;
|
2026-03-13 08:14:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
case 2: {
|
2026-03-09 13:30:23 -07:00
|
|
|
|
lfgState_ = LfgState::Proposal;
|
2026-03-13 08:14:47 -07:00
|
|
|
|
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.");
|
2026-03-09 13:30:23 -07:00
|
|
|
|
break;
|
2026-03-13 08:14:47 -07:00
|
|
|
|
}
|
2026-03-09 13:30:23 -07:00
|
|
|
|
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;
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError("Dungeon Finder: Role check failed — missing required role.");
|
2026-03-09 13:30:23 -07:00
|
|
|
|
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) {
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (!packetHasRemaining(packet, 4 + 4 + 1 + 4 + 4 + 4)) return;
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
|
|
|
|
|
/*uint32_t randomDungeonEntry =*/ packet.readUInt32();
|
|
|
|
|
|
/*uint32_t dungeonEntry =*/ packet.readUInt32();
|
|
|
|
|
|
packet.readUInt8(); // unk
|
|
|
|
|
|
uint32_t money = packet.readUInt32();
|
|
|
|
|
|
uint32_t xp = packet.readUInt32();
|
|
|
|
|
|
|
2026-03-09 22:45:06 -07:00
|
|
|
|
// 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";
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (packetHasRemaining(packet, 4)) {
|
2026-03-09 13:30:23 -07:00
|
|
|
|
uint32_t rewardCount = packet.readUInt32();
|
2026-03-14 22:27:42 -07:00
|
|
|
|
for (uint32_t i = 0; i < rewardCount && packetHasRemaining(packet, 9); ++i) {
|
2026-03-09 13:30:23 -07:00
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
|
|
|
|
|
uint32_t itemCount = packet.readUInt32();
|
|
|
|
|
|
packet.readUInt8(); // unk
|
|
|
|
|
|
if (i == 0) {
|
2026-03-13 06:56:37 -07:00
|
|
|
|
std::string itemLabel = "item #" + std::to_string(itemId);
|
2026-03-17 13:27:27 -07:00
|
|
|
|
uint32_t lfgItemQuality = 1;
|
|
|
|
|
|
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
|
2026-03-13 06:56:37 -07:00
|
|
|
|
if (!info->name.empty()) itemLabel = info->name;
|
2026-03-17 13:27:27 -07:00
|
|
|
|
lfgItemQuality = info->quality;
|
|
|
|
|
|
}
|
|
|
|
|
|
rewardMsg += ", " + buildItemLink(itemId, lfgItemQuality, itemLabel);
|
2026-03-09 13:30:23 -07:00
|
|
|
|
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) {
|
2026-03-14 22:27:42 -07:00
|
|
|
|
if (!packetHasRemaining(packet, 7 + 4 + 4 + 4 + 4)) return;
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
|
|
|
|
|
bool inProgress = packet.readUInt8() != 0;
|
2026-03-13 06:56:37 -07:00
|
|
|
|
/*bool myVote =*/ packet.readUInt8(); // whether local player has voted
|
|
|
|
|
|
/*bool myAnswer =*/ packet.readUInt8(); // local player's vote (yes/no) — unused; result derived from counts
|
2026-03-09 13:30:23 -07:00
|
|
|
|
uint32_t totalVotes = packet.readUInt32();
|
|
|
|
|
|
uint32_t bootVotes = packet.readUInt32();
|
|
|
|
|
|
uint32_t timeLeft = packet.readUInt32();
|
|
|
|
|
|
uint32_t votesNeeded = packet.readUInt32();
|
|
|
|
|
|
|
2026-03-12 02:17:49 -07:00
|
|
|
|
lfgBootVotes_ = bootVotes;
|
|
|
|
|
|
lfgBootTotal_ = totalVotes;
|
|
|
|
|
|
lfgBootTimeLeft_ = timeLeft;
|
|
|
|
|
|
lfgBootNeeded_ = votesNeeded;
|
2026-03-09 13:30:23 -07:00
|
|
|
|
|
2026-03-12 09:09:41 -07:00
|
|
|
|
// Optional: reason string and target name (null-terminated) follow the fixed fields
|
2026-03-14 22:18:28 -07:00
|
|
|
|
if (packet.getReadPos() < packet.getSize())
|
2026-03-12 09:09:41 -07:00
|
|
|
|
lfgBootReason_ = packet.readString();
|
2026-03-14 22:18:28 -07:00
|
|
|
|
if (packet.getReadPos() < packet.getSize())
|
2026-03-12 09:09:41 -07:00
|
|
|
|
lfgBootTargetName_ = packet.readString();
|
|
|
|
|
|
|
2026-03-09 13:30:23 -07:00
|
|
|
|
if (inProgress) {
|
2026-03-10 12:08:58 -07:00
|
|
|
|
lfgState_ = LfgState::Boot;
|
2026-03-09 13:30:23 -07:00
|
|
|
|
} else {
|
2026-03-13 06:56:37 -07:00
|
|
|
|
// 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);
|
2026-03-12 02:17:49 -07:00
|
|
|
|
lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0;
|
2026-03-12 09:09:41 -07:00
|
|
|
|
lfgBootTargetName_.clear();
|
|
|
|
|
|
lfgBootReason_.clear();
|
2026-03-10 12:08:58 -07:00
|
|
|
|
lfgState_ = LfgState::InDungeon;
|
2026-03-13 06:56:37 -07:00
|
|
|
|
if (bootPassed) {
|
2026-03-10 12:08:58 -07:00
|
|
|
|
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Dungeon Finder: Vote kick failed.");
|
|
|
|
|
|
}
|
2026-03-09 13:30:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress,
|
2026-03-12 09:09:41 -07:00
|
|
|
|
" bootVotes=", bootVotes, "/", totalVotes,
|
|
|
|
|
|
" target=", lfgBootTargetName_, " reason=", lfgBootReason_);
|
2026-03-09 13:30:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 16:10:29 -07:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:30:23 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:08:58 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-27 04:59:12 -08:00
|
|
|
|
// 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;
|
2026-02-26 17:56:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& at : areaTriggers_) {
|
|
|
|
|
|
if (at.mapId != currentMapId_) continue;
|
|
|
|
|
|
|
|
|
|
|
|
bool inside = false;
|
|
|
|
|
|
if (at.radius > 0.0f) {
|
2026-03-06 17:03:29 -08:00
|
|
|
|
// Sphere trigger — use actual radius, with small floor for very tiny triggers
|
|
|
|
|
|
float effectiveRadius = std::max(at.radius, 3.0f);
|
2026-02-26 17:56:11 -08:00
|
|
|
|
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) {
|
2026-03-06 17:03:29 -08:00
|
|
|
|
// Box trigger — use actual size, with small floor for tiny triggers
|
|
|
|
|
|
float boxMin = 4.0f;
|
2026-03-05 21:17:45 -08:00
|
|
|
|
float effLength = std::max(at.boxLength, boxMin);
|
|
|
|
|
|
float effWidth = std::max(at.boxWidth, boxMin);
|
|
|
|
|
|
float effHeight = std::max(at.boxHeight, boxMin);
|
2026-02-27 04:59:12 -08:00
|
|
|
|
|
2026-02-26 17:56:11 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-27 04:59:12 -08:00
|
|
|
|
inside = (std::abs(localX) <= effLength * 0.5f &&
|
|
|
|
|
|
std::abs(localY) <= effWidth * 0.5f &&
|
|
|
|
|
|
std::abs(dz) <= effHeight * 0.5f);
|
2026-02-26 17:56:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (inside) {
|
|
|
|
|
|
if (activeAreaTriggers_.count(at.id) == 0) {
|
|
|
|
|
|
activeAreaTriggers_.insert(at.id);
|
|
|
|
|
|
|
2026-02-27 04:59:12 -08:00
|
|
|
|
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);
|
2026-02-26 17:56:11 -08:00
|
|
|
|
|
2026-02-27 04:59:12 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-26 17:56:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Player left the trigger — allow re-fire on re-entry
|
|
|
|
|
|
activeAreaTriggers_.erase(at.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
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();
|
2026-03-18 12:26:23 -07:00
|
|
|
|
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));
|
2026-02-07 23:47:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 21:01:51 -07:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-03-13 07:37:40 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-07 23:47:43 -08:00
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-13 07:37:40 -07:00
|
|
|
|
LOG_INFO("Arena team event: ", (int)event, " ", param1, " ", param2);
|
2026-02-07 23:47:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 02:35:29 -07:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-03-18 12:26:23 -07:00
|
|
|
|
// Update or insert for this team (preserve name/type from query response)
|
2026-03-12 02:35:29 -07:00
|
|
|
|
for (auto& s : arenaTeamStats_) {
|
|
|
|
|
|
if (s.teamId == stats.teamId) {
|
2026-03-18 12:26:23 -07:00
|
|
|
|
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);
|
2026-03-12 02:35:29 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-18 12:26:23 -07:00
|
|
|
|
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);
|
2026-03-12 02:35:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 12:02:59 -07:00
|
|
|
|
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) {
|
2026-03-12 23:46:38 -07:00
|
|
|
|
// 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)
|
2026-03-12 12:02:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 23:46:38 -07:00
|
|
|
|
if (bgScoreboard_.isArena) {
|
|
|
|
|
|
LOG_INFO("Arena log: ", bgScoreboard_.players.size(), " players, hasWinner=",
|
|
|
|
|
|
bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner,
|
|
|
|
|
|
" team0='", bgScoreboard_.arenaTeams[0].teamName,
|
|
|
|
|
|
"' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[0].ratingChange,
|
|
|
|
|
|
" team1='", bgScoreboard_.arenaTeams[1].teamName,
|
|
|
|
|
|
"' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[1].ratingChange);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=",
|
|
|
|
|
|
bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner);
|
|
|
|
|
|
}
|
2026-03-12 12:02:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 03:13:29 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
|
2026-03-09 23:58:15 -07:00
|
|
|
|
// 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);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
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();
|
2026-02-13 19:40:54 -08:00
|
|
|
|
// 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();
|
2026-02-13 18:59:09 -08:00
|
|
|
|
info.time = packet.readUInt32();
|
|
|
|
|
|
info.x = packet.readFloat();
|
|
|
|
|
|
info.y = packet.readFloat();
|
|
|
|
|
|
info.z = packet.readFloat();
|
|
|
|
|
|
info.orientation = packet.readFloat();
|
|
|
|
|
|
|
2026-03-17 10:40:35 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// 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);
|
2026-03-17 10:40:35 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-19 16:45:39 -08:00
|
|
|
|
// 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;
|
2026-02-13 20:19:33 -08:00
|
|
|
|
auto itPrev = otherPlayerMoveTimeMs_.find(moverGuid);
|
|
|
|
|
|
if (itPrev != otherPlayerMoveTimeMs_.end()) {
|
2026-02-19 16:45:39 -08:00
|
|
|
|
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);
|
2026-02-13 20:19:33 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
otherPlayerMoveTimeMs_[moverGuid] = info.time;
|
|
|
|
|
|
|
2026-03-10 10:30:50 -07:00
|
|
|
|
// 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
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (creatureMoveCallback_) {
|
2026-03-10 10:30:50 -07:00
|
|
|
|
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.
|
2026-03-10 10:55:23 -07:00
|
|
|
|
// 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);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 03:13:17 -08:00
|
|
|
|
void GameHandler::handleCompressedMoves(network::Packet& packet) {
|
2026-03-15 03:40:58 -07:00
|
|
|
|
// 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();
|
2026-02-18 03:13:17 -08:00
|
|
|
|
|
|
|
|
|
|
// Wire opcodes for sub-packet routing
|
|
|
|
|
|
uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE);
|
|
|
|
|
|
uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT);
|
|
|
|
|
|
|
2026-03-09 20:15:34 -07:00
|
|
|
|
// Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*)
|
|
|
|
|
|
// Not static — wireOpcode() depends on runtime active opcode table.
|
2026-03-10 11:54:15 -07:00
|
|
|
|
const std::array<uint16_t, 29> kMoveOpcodes = {
|
2026-03-09 20:15:34 -07:00
|
|
|
|
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),
|
2026-03-10 11:25:58 -07:00
|
|
|
|
wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE),
|
|
|
|
|
|
wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE),
|
2026-03-10 11:29:13 -07:00
|
|
|
|
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),
|
2026-03-10 11:30:55 -07:00
|
|
|
|
wireOpcode(Opcode::MSG_MOVE_START_DESCEND),
|
2026-03-10 11:44:57 -07:00
|
|
|
|
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),
|
2026-03-10 11:54:15 -07:00
|
|
|
|
wireOpcode(Opcode::MSG_MOVE_ROOT),
|
|
|
|
|
|
wireOpcode(Opcode::MSG_MOVE_UNROOT),
|
2026-03-09 20:15:34 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 03:40:58 -07:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-02-18 03:13:17 -08:00
|
|
|
|
|
2026-03-15 03:40:58 -07:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
2026-02-18 03:13:17 -08:00
|
|
|
|
}
|
2026-03-15 03:40:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!decoded.ok || decoded.overrun) {
|
|
|
|
|
|
LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", decoded.endPos);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-18 03:13:17 -08:00
|
|
|
|
|
2026-03-15 03:40:58 -07:00
|
|
|
|
// Track unhandled sub-opcodes once per compressed packet (avoid log spam)
|
|
|
|
|
|
std::unordered_set<uint16_t> unhandledSeen;
|
2026-02-18 03:13:17 -08:00
|
|
|
|
|
2026-03-15 03:40:58 -07:00
|
|
|
|
for (const auto& entry : decoded.packets) {
|
|
|
|
|
|
network::Packet subPacket(entry.opcode, entry.payload);
|
|
|
|
|
|
|
|
|
|
|
|
if (entry.opcode == monsterMoveWire) {
|
2026-02-18 03:13:17 -08:00
|
|
|
|
handleMonsterMove(subPacket);
|
2026-03-15 03:40:58 -07:00
|
|
|
|
} else if (entry.opcode == monsterMoveTransportWire) {
|
2026-02-18 03:13:17 -08:00
|
|
|
|
handleMonsterMoveTransport(subPacket);
|
2026-03-09 20:15:34 -07:00
|
|
|
|
} else if (state == WorldState::IN_WORLD &&
|
2026-03-15 03:40:58 -07:00
|
|
|
|
std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), entry.opcode) != kMoveOpcodes.end()) {
|
2026-03-09 20:15:34 -07:00
|
|
|
|
// Player/NPC movement update packed in SMSG_MULTIPLE_MOVES
|
|
|
|
|
|
handleOtherPlayerMovement(subPacket);
|
2026-02-18 03:13:17 -08:00
|
|
|
|
} else {
|
2026-03-15 03:40:58 -07:00
|
|
|
|
if (unhandledSeen.insert(entry.opcode).second) {
|
2026-02-18 03:13:17 -08:00
|
|
|
|
LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x",
|
2026-03-15 03:40:58 -07:00
|
|
|
|
std::hex, entry.opcode, std::dec, " payloadLen=", entry.payload.size());
|
2026-02-18 03:13:17 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void GameHandler::handleMonsterMove(network::Packet& packet) {
|
2026-03-14 22:18:28 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
MonsterMoveData data;
|
2026-02-22 07:26:54 -08:00
|
|
|
|
auto logMonsterMoveParseFailure = [&](const std::string& msg) {
|
|
|
|
|
|
static uint32_t failCount = 0;
|
|
|
|
|
|
++failCount;
|
|
|
|
|
|
if (failCount <= 10 || (failCount % 100) == 0) {
|
|
|
|
|
|
LOG_WARNING(msg, " (occurrence=", failCount, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-14 22:01:26 -07:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-22 07:26:54 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-02-18 03:13:17 -08:00
|
|
|
|
// Turtle WoW (1.17+) compresses each SMSG_MONSTER_MOVE individually:
|
|
|
|
|
|
// format: uint32 decompressedSize + zlib data (zlib magic = 0x78 ??)
|
|
|
|
|
|
const auto& rawData = packet.getData();
|
2026-02-22 07:26:54 -08:00
|
|
|
|
const bool allowTurtleMoveCompression = isActiveExpansion("turtle");
|
|
|
|
|
|
bool isCompressed = allowTurtleMoveCompression &&
|
|
|
|
|
|
rawData.size() >= 6 &&
|
2026-02-18 03:13:17 -08:00
|
|
|
|
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);
|
2026-02-22 07:26:54 -08:00
|
|
|
|
std::vector<uint8_t> stripped;
|
|
|
|
|
|
bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped);
|
|
|
|
|
|
|
2026-03-14 22:01:26 -07:00
|
|
|
|
bool parsed = false;
|
|
|
|
|
|
if (hasWrappedForm) {
|
2026-02-22 07:26:54 -08:00
|
|
|
|
network::Packet wrappedPacket(packet.getOpcode(), stripped);
|
2026-03-14 22:01:26 -07:00
|
|
|
|
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) {
|
2026-02-22 07:26:54 -08:00
|
|
|
|
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " +
|
|
|
|
|
|
std::to_string(destLen) + " bytes, wrapped payload " +
|
|
|
|
|
|
std::to_string(stripped.size()) + " bytes)");
|
2026-03-14 22:01:26 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " +
|
|
|
|
|
|
std::to_string(destLen) + " bytes)");
|
2026-02-22 07:26:54 -08:00
|
|
|
|
}
|
2026-03-14 22:01:26 -07:00
|
|
|
|
return;
|
2026-02-18 03:13:17 -08:00
|
|
|
|
}
|
2026-02-20 00:52:59 -08:00
|
|
|
|
} else if (!packetParsers_->parseMonsterMove(packet, data)) {
|
2026-02-22 07:26:54 -08:00
|
|
|
|
// 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)) {
|
2026-03-14 22:01:26 -07:00
|
|
|
|
logWrappedUncompressedFallbackUsed();
|
2026-02-22 07:26:54 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update entity position in entity manager
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.guid);
|
2026-02-18 03:13:17 -08:00
|
|
|
|
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) {
|
2026-02-18 04:43:23 -08:00
|
|
|
|
// 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.
|
2026-02-18 03:13:17 -08:00
|
|
|
|
auto target = entityManager.getEntity(data.facingTarget);
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
float dx = target->getX() - entity->getX();
|
|
|
|
|
|
float dy = target->getY() - entity->getY();
|
2026-02-06 13:47:03 -08:00
|
|
|
|
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
|
2026-02-18 04:43:23 -08:00
|
|
|
|
orientation = std::atan2(-dy, dx);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-18 03:13:17 -08:00
|
|
|
|
} else {
|
2026-02-18 04:43:23 -08:00
|
|
|
|
// Normal move - face toward destination.
|
2026-02-18 03:13:17 -08:00
|
|
|
|
float dx = destCanonical.x - entity->getX();
|
|
|
|
|
|
float dy = destCanonical.y - entity->getY();
|
|
|
|
|
|
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
|
2026-02-18 04:43:23 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-18 03:13:17 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
|
2026-02-18 03:13:17 -08:00
|
|
|
|
// Interpolate entity position alongside renderer (so targeting matches visual)
|
|
|
|
|
|
entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z,
|
|
|
|
|
|
orientation, data.duration / 1000.0f);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
|
2026-02-18 03:13:17 -08:00
|
|
|
|
// 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);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
|
void GameHandler::handleMonsterMoveTransport(network::Packet& packet) {
|
|
|
|
|
|
// Parse transport-relative creature movement (NPCs on boats/zeppelins)
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
// Packet: moverGuid(8) + unk(1) + transportGuid(8) + localX/Y/Z(12) + spline data
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8 + 1 + 8 + 12) return;
|
2026-02-11 00:54:38 -08:00
|
|
|
|
uint64_t moverGuid = packet.readUInt64();
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
/*uint8_t unk =*/ packet.readUInt8();
|
2026-02-11 00:54:38 -08:00
|
|
|
|
uint64_t transportGuid = packet.readUInt64();
|
|
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
// Transport-local start position (server coords: x=east/west, y=north/south, z=up)
|
2026-02-11 00:54:38 -08:00
|
|
|
|
float localX = packet.readFloat();
|
|
|
|
|
|
float localY = packet.readFloat();
|
|
|
|
|
|
float localZ = packet.readFloat();
|
|
|
|
|
|
|
|
|
|
|
|
auto entity = entityManager.getEntity(moverGuid);
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
/*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();
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
if (splineFlags & 0x00000800) { // Parabolic
|
|
|
|
|
|
if (packet.getReadPos() + 8 > packet.getSize()) return;
|
|
|
|
|
|
packet.readFloat(); packet.readUInt32();
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
if (packet.getReadPos() + 4 > packet.getSize()) return;
|
|
|
|
|
|
uint32_t pointCount = packet.readUInt32();
|
2026-03-17 22:08:25 -07:00
|
|
|
|
constexpr uint32_t kMaxTransportSplinePoints = 1000;
|
|
|
|
|
|
if (pointCount > kMaxTransportSplinePoints) {
|
|
|
|
|
|
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount,
|
|
|
|
|
|
" clamped to ", kMaxTransportSplinePoints);
|
|
|
|
|
|
pointCount = kMaxTransportSplinePoints;
|
|
|
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
if (!transportManager_) {
|
|
|
|
|
|
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x",
|
|
|
|
|
|
std::hex, moverGuid, std::dec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
Implement transport spline movement and fix SMSG_QUESTLOG_FULL
SMSG_MONSTER_MOVE_TRANSPORT (handleMonsterMoveTransport):
- Parse full WotLK 3.3.5a spline payload after the transport-local start
position: splineId, moveType, facing data (spot/target/angle), splineFlags,
Animation flag block, duration, Parabolic flag block, pointCount, waypoints
- Extract destination in transport-local server coords, compose to world
space via TransportManager, then call entity->startMoveTo() with the
spline duration so NPC movement interpolates smoothly instead of teleporting
- Handle all facing modes (FacingSpot/Target/Angle/normal) in transport space
- Degenerate cases (no spline data, moveType==1 stop, no transport manager)
fall back to snapping start position as before
SMSG_QUESTLOG_FULL:
- This opcode is a zero-payload notification meaning the quest log is at
capacity (25 quests); it does not carry quest log data
- Replace placeholder LOG_INFO stubs with a proper "Your quest log is full."
chat notification and a single LOG_INFO
2026-03-09 13:04:35 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
|
|
|
|
|
|
AttackerStateUpdateData data;
|
2026-03-09 21:36:12 -07:00
|
|
|
|
if (!packetParsers_->parseAttackerStateUpdate(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
bool isPlayerAttacker = (data.attackerGuid == playerGuid);
|
|
|
|
|
|
bool isPlayerTarget = (data.targetGuid == playerGuid);
|
2026-02-17 15:27:02 -08:00
|
|
|
|
if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat
|
|
|
|
|
|
|
2026-03-12 20:05:36 -07:00
|
|
|
|
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_();
|
2026-02-05 14:01:26 -08:00
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
if (!isPlayerAttacker && npcSwingCallback_) {
|
|
|
|
|
|
npcSwingCallback_(data.attackerGuid);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (isPlayerTarget && data.attackerGuid != 0) {
|
|
|
|
|
|
hostileAttackers_.insert(data.attackerGuid);
|
2026-02-07 20:51:53 -08:00
|
|
|
|
autoTargetAttacker(data.attackerGuid);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 21:31:37 -08:00
|
|
|
|
// 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (data.isMiss()) {
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
} else if (data.victimState == 1) {
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
} else if (data.victimState == 2) {
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
2026-03-09 17:10:57 -07:00
|
|
|
|
} else if (data.victimState == 4) {
|
2026-03-11 03:36:45 -07:00
|
|
|
|
// VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount
|
|
|
|
|
|
if (data.totalDamage > 0)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
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);
|
2026-03-11 03:09:39 -07:00
|
|
|
|
} else if (data.victimState == 5) {
|
2026-03-13 23:32:57 -07:00
|
|
|
|
// VICTIMSTATE_EVADE: NPC evaded (out of combat zone).
|
|
|
|
|
|
addCombatText(CombatTextEntry::EVADE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
2026-03-11 03:09:39 -07:00
|
|
|
|
} else if (data.victimState == 6) {
|
|
|
|
|
|
// VICTIMSTATE_IS_IMMUNE: Target is immune to this attack.
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
2026-03-11 03:09:39 -07:00
|
|
|
|
} else if (data.victimState == 7) {
|
|
|
|
|
|
// VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect).
|
2026-03-13 23:08:49 -07:00
|
|
|
|
addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
} else {
|
2026-03-17 18:51:48 -07:00
|
|
|
|
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;
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
2026-03-11 03:29:37 -07:00
|
|
|
|
// 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)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(totalAbsorbed), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
2026-03-11 03:29:37 -07:00
|
|
|
|
if (totalResisted > 0)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(totalResisted), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 17:10:57 -07:00
|
|
|
|
(void)isPlayerTarget;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellDamageLog(network::Packet& packet) {
|
|
|
|
|
|
SpellDamageLogData data;
|
2026-03-09 21:36:12 -07:00
|
|
|
|
if (!packetParsers_->parseSpellDamageLog(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-17 15:27:02 -08:00
|
|
|
|
bool isPlayerSource = (data.attackerGuid == playerGuid);
|
|
|
|
|
|
bool isPlayerTarget = (data.targetGuid == playerGuid);
|
|
|
|
|
|
if (!isPlayerSource && !isPlayerTarget) return; // Not our combat
|
|
|
|
|
|
|
|
|
|
|
|
if (isPlayerTarget && data.attackerGuid != 0) {
|
2026-02-07 20:51:53 -08:00
|
|
|
|
hostileAttackers_.insert(data.attackerGuid);
|
|
|
|
|
|
autoTargetAttacker(data.attackerGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE;
|
2026-03-11 03:28:19 -07:00
|
|
|
|
if (data.damage > 0)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(type, static_cast<int32_t>(data.damage), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid);
|
2026-03-11 03:28:19 -07:00
|
|
|
|
if (data.absorbed > 0)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(data.absorbed), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid);
|
2026-03-11 03:28:19 -07:00
|
|
|
|
if (data.resisted > 0)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::RESIST, static_cast<int32_t>(data.resisted), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellHealLog(network::Packet& packet) {
|
|
|
|
|
|
SpellHealLogData data;
|
2026-03-09 21:36:12 -07:00
|
|
|
|
if (!packetParsers_->parseSpellHealLog(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
bool isPlayerSource = (data.casterGuid == playerGuid);
|
2026-02-17 15:27:02 -08:00
|
|
|
|
bool isPlayerTarget = (data.targetGuid == playerGuid);
|
|
|
|
|
|
if (!isPlayerSource && !isPlayerTarget) return; // Not our combat
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL;
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(type, static_cast<int32_t>(data.heal), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid);
|
2026-03-11 03:28:19 -07:00
|
|
|
|
if (data.absorbed > 0)
|
2026-03-13 11:48:42 -07:00
|
|
|
|
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(data.absorbed), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 3: Spells
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
|
2026-02-07 00:00:06 -08:00
|
|
|
|
// Attack (6603) routes to auto-attack instead of cast
|
2026-02-04 11:31:08 -08:00
|
|
|
|
if (spellId == 6603) {
|
|
|
|
|
|
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
|
|
|
|
|
|
if (target != 0) {
|
|
|
|
|
|
if (autoAttacking) {
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startAutoAttack(target);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// Casting any spell while mounted → dismount instead
|
|
|
|
|
|
if (isMounted()) {
|
|
|
|
|
|
dismount();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:21:46 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-17 03:50:36 -08:00
|
|
|
|
// 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.
|
2026-02-06 15:41:29 -08:00
|
|
|
|
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
|
2026-02-17 03:50:36 -08:00
|
|
|
|
// Self-targeted spells like hearthstone should not send a target
|
|
|
|
|
|
if (spellId == 8690) target = 0;
|
2026-02-19 21:13:13 -08:00
|
|
|
|
|
|
|
|
|
|
// Warrior Charge (ranks 1-3): client-side range check + charge callback
|
2026-02-19 21:31:37 -08:00
|
|
|
|
// Must face target and validate range BEFORE sending packet to server
|
2026-02-19 21:13:13 -08:00
|
|
|
|
if (spellId == 100 || spellId == 6178 || spellId == 11578) {
|
|
|
|
|
|
if (target == 0) {
|
|
|
|
|
|
addSystemChatMessage("You have no target.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
auto entity = entityManager.getEntity(target);
|
2026-02-19 21:31:37 -08:00
|
|
|
|
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;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_SET_FACING);
|
2026-02-19 21:31:37 -08:00
|
|
|
|
if (chargeCallback_) {
|
|
|
|
|
|
chargeCallback_(target, tx, ty, tz);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Instant melee abilities: client-side range + facing check to avoid server "not in front" errors
|
2026-03-11 02:40:27 -07:00
|
|
|
|
// Detected via physical school mask (1) from DBC cache — covers warrior, rogue, DK, paladin,
|
|
|
|
|
|
// feral druid, and hunter melee abilities generically.
|
2026-02-19 21:31:37 -08:00
|
|
|
|
{
|
2026-03-11 02:40:27 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-19 21:31:37 -08:00
|
|
|
|
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;
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_SET_FACING);
|
2026-02-19 21:13:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildCastSpell(spellId, target, ++castCount)
|
|
|
|
|
|
: CastSpellPacket::build(spellId, target, ++castCount);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
|
2026-03-12 05:38:13 -07:00
|
|
|
|
|
2026-03-21 02:10:09 -07:00
|
|
|
|
// Fire UNIT_SPELLCAST_SENT for cast bar addons (fires on client intent, before server confirms)
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string targetName;
|
|
|
|
|
|
if (target != 0) targetName = lookupName(target);
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 09:02:20 -07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::cancelCast() {
|
|
|
|
|
|
if (!casting) return;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
// GameObject interaction cast is client-side timing only.
|
|
|
|
|
|
if (pendingGameObjectInteractGuid_ == 0 &&
|
|
|
|
|
|
state == WorldState::IN_WORLD && socket &&
|
|
|
|
|
|
currentCastSpellId != 0) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
auto packet = CancelCastPacket::build(currentCastSpellId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
2026-02-19 03:31:49 -08:00
|
|
|
|
pendingGameObjectInteractGuid_ = 0;
|
2026-03-13 05:02:58 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
// Cancel craft queue and spell queue when player manually cancels cast
|
2026-03-17 10:12:49 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
2026-03-21 02:10:09 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"});
|
2026-03-17 10:12:49 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::cancelAura(uint32_t spellId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = CancelAuraPacket::build(spellId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 18:15:51 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 10:41:29 -08:00
|
|
|
|
void GameHandler::handlePetSpells(network::Packet& packet) {
|
2026-03-09 22:53:09 -07:00
|
|
|
|
const size_t remaining = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (remaining < 8) {
|
|
|
|
|
|
// Empty or undersized → pet cleared (dismissed / died)
|
2026-02-26 11:12:34 -08:00
|
|
|
|
petGuid_ = 0;
|
2026-03-09 22:53:09 -07:00
|
|
|
|
petSpellList_.clear();
|
|
|
|
|
|
petAutocastSpells_.clear();
|
|
|
|
|
|
memset(petActionSlots_, 0, sizeof(petActionSlots_));
|
|
|
|
|
|
LOG_INFO("SMSG_PET_SPELLS: pet cleared");
|
2026-02-26 11:12:34 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-09 22:53:09 -07:00
|
|
|
|
|
2026-02-26 10:41:29 -08:00
|
|
|
|
petGuid_ = packet.readUInt64();
|
2026-03-09 22:53:09 -07:00
|
|
|
|
if (petGuid_ == 0) {
|
|
|
|
|
|
petSpellList_.clear();
|
|
|
|
|
|
petAutocastSpells_.clear();
|
|
|
|
|
|
memset(petActionSlots_, 0, sizeof(petActionSlots_));
|
|
|
|
|
|
LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// uint16 duration (ms, 0 = permanent), uint16 timer (ms)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) goto done;
|
|
|
|
|
|
/*uint16_t dur =*/ packet.readUInt16();
|
|
|
|
|
|
/*uint16_t timer =*/ packet.readUInt16();
|
|
|
|
|
|
|
|
|
|
|
|
// uint8 reactState, uint8 commandState (packed order varies; WotLK: react first)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) goto done;
|
|
|
|
|
|
petReact_ = packet.readUInt8(); // 0=passive, 1=defensive, 2=aggressive
|
|
|
|
|
|
petCommand_ = packet.readUInt8(); // 0=stay, 1=follow, 2=attack, 3=dismiss
|
|
|
|
|
|
|
|
|
|
|
|
// 10 × uint32 action bar slots
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < PET_ACTION_BAR_SLOTS * 4u) goto done;
|
|
|
|
|
|
for (int i = 0; i < PET_ACTION_BAR_SLOTS; ++i) {
|
|
|
|
|
|
petActionSlots_[i] = packet.readUInt32();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// uint8 spell count, then per-spell: uint32 spellId, uint16 active flags
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) goto done;
|
|
|
|
|
|
{
|
|
|
|
|
|
uint8_t spellCount = packet.readUInt8();
|
|
|
|
|
|
petSpellList_.clear();
|
|
|
|
|
|
petAutocastSpells_.clear();
|
|
|
|
|
|
for (uint8_t i = 0; i < spellCount; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 6) break;
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint16_t activeFlags = packet.readUInt16();
|
|
|
|
|
|
petSpellList_.push_back(spellId);
|
|
|
|
|
|
// activeFlags bit 0 = autocast on
|
|
|
|
|
|
if (activeFlags & 0x0001) {
|
|
|
|
|
|
petAutocastSpells_.insert(spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
done:
|
|
|
|
|
|
LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec,
|
|
|
|
|
|
" react=", (int)petReact_, " command=", (int)petCommand_,
|
|
|
|
|
|
" spells=", petSpellList_.size());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) {
|
|
|
|
|
|
if (!hasPet() || state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto pkt = PetActionPacket::build(petGuid_, action, targetGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, petGuid_,
|
|
|
|
|
|
" action=0x", action, " target=0x", targetGuid, std::dec);
|
2026-02-26 10:41:29 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::dismissPet() {
|
|
|
|
|
|
if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = PetActionPacket::build(petGuid_, 0x07000000);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 05:08:10 -07:00
|
|
|
|
void GameHandler::togglePetSpellAutocast(uint32_t spellId) {
|
|
|
|
|
|
if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
bool currentlyOn = petAutocastSpells_.count(spellId) != 0;
|
|
|
|
|
|
uint8_t newState = currentlyOn ? 0 : 1;
|
|
|
|
|
|
// CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1)
|
|
|
|
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST));
|
|
|
|
|
|
pkt.writeUInt64(petGuid_);
|
|
|
|
|
|
pkt.writeUInt32(spellId);
|
|
|
|
|
|
pkt.writeUInt8(newState);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
// Optimistically update local state; server will confirm via SMSG_PET_SPELLS
|
|
|
|
|
|
if (newState)
|
|
|
|
|
|
petAutocastSpells_.insert(spellId);
|
|
|
|
|
|
else
|
|
|
|
|
|
petAutocastSpells_.erase(spellId);
|
|
|
|
|
|
LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 19:42:31 -07:00
|
|
|
|
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, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET)
- Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with
petNumber, entry, level, name, displayId, and active status
- Detect stable master via gossip option text/keyword matching and
auto-send MSG_LIST_STABLED_PETS request to open the stable UI
- Refresh list automatically after SMSG_STABLE_RESULT to reflect state
- New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket
- New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber)
- Stable window UI: shows active/stabled pets with store/retrieve buttons,
slot count, refresh, and close; opens when server sends pet list
- Clear stable state on world logout/disconnect
2026-03-12 19:15:52 -07:00
|
|
|
|
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=", (int)petCount, " numSlots=", (int)stableNumSlots_);
|
|
|
|
|
|
for (const auto& p : stabledPets_) {
|
|
|
|
|
|
LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry,
|
|
|
|
|
|
" level=", p.level, " name='", p.name, "' displayId=", p.displayId,
|
|
|
|
|
|
" active=", p.isActive);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
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;
|
2026-03-11 18:36:32 -07:00
|
|
|
|
// Pre-query item information so action bar displays item name instead of "Item" placeholder
|
|
|
|
|
|
if (type == ActionBarSlot::ITEM && id != 0) {
|
|
|
|
|
|
queryItemInfo(id, 0);
|
|
|
|
|
|
}
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
saveCharacterConfig();
|
2026-03-13 04:25:05 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-09 21:38:14 -07:00
|
|
|
|
if (!packetParsers_->parseInitialSpells(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells = {data.spellIds.begin(), data.spellIds.end()};
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-10 04:46:42 -07:00
|
|
|
|
LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u),
|
|
|
|
|
|
" 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u));
|
2026-02-09 22:13:31 -08:00
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Ensure Attack (6603) and Hearthstone (8690) are always present
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.insert(6603u);
|
|
|
|
|
|
knownSpells.insert(8690u);
|
2026-02-04 11:31:08 -08:00
|
|
|
|
|
2026-03-20 07:02:57 -07:00
|
|
|
|
// Set initial cooldowns — use the longer of individual vs category cooldown.
|
|
|
|
|
|
// Spells like potions have cooldownMs=0 but categoryCooldownMs=120000.
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
for (const auto& cd : data.cooldowns) {
|
2026-03-20 07:02:57 -07:00
|
|
|
|
uint32_t effectiveMs = std::max(cd.cooldownMs, cd.categoryCooldownMs);
|
|
|
|
|
|
if (effectiveMs > 0) {
|
|
|
|
|
|
spellCooldowns[cd.spellId] = effectiveMs / 1000.0f;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Load saved action bar or use defaults (Attack slot 1, Hearthstone slot 12)
|
2026-02-04 11:31:08 -08:00
|
|
|
|
actionBar[0].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[0].id = 6603; // Attack
|
2026-02-08 03:05:38 -08:00
|
|
|
|
actionBar[11].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[11].id = 8690; // Hearthstone
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
loadCharacterConfig();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-13 05:58:57 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-20 08:01:54 -07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 05:58:57 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 10:20:29 -07:00
|
|
|
|
// Pre-load skill line DBCs so isProfessionSpell() works immediately
|
|
|
|
|
|
// (not just after opening a trainer window)
|
|
|
|
|
|
loadSkillLineDbc();
|
|
|
|
|
|
loadSkillLineAbilityDbc();
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
LOG_INFO("Learned ", knownSpells.size(), " spells");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCastFailed(network::Packet& packet) {
|
|
|
|
|
|
CastFailedData data;
|
2026-02-14 16:54:43 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseCastFailed(packet, data)
|
|
|
|
|
|
: CastFailedParser::parse(packet, data);
|
|
|
|
|
|
if (!ok) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-13 05:03:50 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
2026-03-18 01:15:04 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-09 21:04:24 -07:00
|
|
|
|
// Stop precast sound — spell failed before completing
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* ssm = renderer->getSpellSoundManager()) {
|
|
|
|
|
|
ssm->stopPrecast();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:20:24 -07:00
|
|
|
|
// Show failure reason in the UIError overlay and in chat
|
2026-02-19 19:33:02 -08:00
|
|
|
|
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);
|
2026-03-17 17:20:24 -07:00
|
|
|
|
std::string errMsg = reason ? reason
|
|
|
|
|
|
: ("Spell cast failed (error " + std::to_string(data.result) + ")");
|
|
|
|
|
|
addUIError(errMsg);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
2026-03-17 17:20:24 -07:00
|
|
|
|
msg.message = errMsg;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
addLocalChatMessage(msg);
|
2026-03-18 10:50:42 -07:00
|
|
|
|
|
|
|
|
|
|
// Play error sound for cast failure feedback
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playError();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 19:24:09 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleSpellStart(network::Packet& packet) {
|
|
|
|
|
|
SpellStartData data;
|
2026-03-09 21:48:41 -07:00
|
|
|
|
if (!packetParsers_->parseSpellStart(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-09 23:13:30 -07:00
|
|
|
|
// Track cast bar for any non-player caster (target frame + boss frames)
|
|
|
|
|
|
if (data.casterUnit != playerGuid && data.castTime > 0) {
|
|
|
|
|
|
auto& s = unitCastStates_[data.casterUnit];
|
2026-03-17 19:43:19 -07:00
|
|
|
|
s.casting = true;
|
|
|
|
|
|
s.spellId = data.spellId;
|
|
|
|
|
|
s.timeTotal = data.castTime / 1000.0f;
|
|
|
|
|
|
s.timeRemaining = s.timeTotal;
|
|
|
|
|
|
s.interruptible = isSpellInterruptible(data.spellId);
|
2026-03-10 09:42:17 -07:00
|
|
|
|
// Trigger cast animation on the casting unit
|
|
|
|
|
|
if (spellCastAnimCallback_) {
|
|
|
|
|
|
spellCastAnimCallback_(data.casterUnit, true, false);
|
|
|
|
|
|
}
|
2026-03-09 23:06:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// If this is the player's own cast, start cast bar
|
|
|
|
|
|
if (data.casterUnit == playerGuid && data.castTime > 0) {
|
2026-03-13 04:37:36 -07:00
|
|
|
|
// 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());
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
casting = true;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentCastSpellId = data.spellId;
|
|
|
|
|
|
castTimeTotal = data.castTime / 1000.0f;
|
|
|
|
|
|
castTimeRemaining = castTimeTotal;
|
2026-02-17 03:50:36 -08:00
|
|
|
|
|
2026-03-09 19:24:09 -07:00
|
|
|
|
// Play precast (channeling) sound with correct magic school
|
2026-03-17 10:12:49 -07:00
|
|
|
|
// Skip sound for profession/tradeskill spells (crafting should be silent)
|
|
|
|
|
|
if (!isProfessionSpell(data.spellId)) {
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* ssm = renderer->getSpellSoundManager()) {
|
|
|
|
|
|
loadSpellNameCache();
|
|
|
|
|
|
auto it = spellNameCache_.find(data.spellId);
|
|
|
|
|
|
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
|
|
|
|
|
|
? schoolMaskToMagicSchool(it->second.schoolMask)
|
|
|
|
|
|
: audio::SpellSoundManager::MagicSchool::ARCANE;
|
|
|
|
|
|
ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM);
|
|
|
|
|
|
}
|
2026-02-17 03:50:36 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 21:57:42 -07:00
|
|
|
|
|
2026-03-10 09:42:17 -07:00
|
|
|
|
// Trigger cast animation on player character
|
|
|
|
|
|
if (spellCastAnimCallback_) {
|
|
|
|
|
|
spellCastAnimCallback_(playerGuid, true, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:57:42 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-03-20 14:15:00 -07:00
|
|
|
|
|
|
|
|
|
|
// Fire UNIT_SPELLCAST_START for Lua addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (data.casterUnit == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (data.casterUnit == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (data.casterUnit == focusGuid) unitId = "focus";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellGo(network::Packet& packet) {
|
|
|
|
|
|
SpellGoData data;
|
2026-03-09 21:48:41 -07:00
|
|
|
|
if (!packetParsers_->parseSpellGo(packet, data)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
// Cast completed
|
|
|
|
|
|
if (data.casterUnit == playerGuid) {
|
2026-03-09 19:24:09 -07:00
|
|
|
|
// Play cast-complete sound with correct magic school
|
2026-03-17 10:12:49 -07:00
|
|
|
|
// Skip sound for profession/tradeskill spells (crafting should be silent)
|
|
|
|
|
|
if (!isProfessionSpell(data.spellId)) {
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* ssm = renderer->getSpellSoundManager()) {
|
|
|
|
|
|
loadSpellNameCache();
|
|
|
|
|
|
auto it = spellNameCache_.find(data.spellId);
|
|
|
|
|
|
auto school = (it != spellNameCache_.end() && it->second.schoolMask)
|
|
|
|
|
|
? schoolMaskToMagicSchool(it->second.schoolMask)
|
|
|
|
|
|
: audio::SpellSoundManager::MagicSchool::ARCANE;
|
|
|
|
|
|
ssm->playCast(school);
|
|
|
|
|
|
}
|
2026-02-17 03:50:36 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 21:31:37 -08:00
|
|
|
|
// Instant melee abilities → trigger attack animation
|
2026-03-11 02:39:25 -07:00
|
|
|
|
// Detect via physical school mask (1 = Physical) from the spell DBC cache.
|
|
|
|
|
|
// This covers warrior, rogue, DK, paladin, feral druid, and hunter melee
|
|
|
|
|
|
// abilities generically instead of maintaining a brittle per-spell-ID list.
|
2026-02-19 21:31:37 -08:00
|
|
|
|
uint32_t sid = data.spellId;
|
2026-03-11 02:39:25 -07:00
|
|
|
|
bool isMeleeAbility = false;
|
|
|
|
|
|
{
|
|
|
|
|
|
loadSpellNameCache();
|
|
|
|
|
|
auto cacheIt = spellNameCache_.find(sid);
|
|
|
|
|
|
if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) {
|
|
|
|
|
|
// Physical school — treat as instant melee ability if cast time is zero.
|
|
|
|
|
|
// We don't store cast time in the cache; use the fact that if we were not
|
|
|
|
|
|
// in a cast (casting == true with this spellId) then it was instant.
|
|
|
|
|
|
isMeleeAbility = (currentCastSpellId != sid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-17 10:12:49 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 21:31:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 05:12:22 -07:00
|
|
|
|
// 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);
|
2026-03-13 04:59:05 -07:00
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-10 09:42:17 -07:00
|
|
|
|
|
2026-03-13 04:37:36 -07:00
|
|
|
|
// 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.
|
2026-03-13 04:59:05 -07:00
|
|
|
|
// Guard with wasInTimedCast to avoid firing on instant casts / procs.
|
|
|
|
|
|
if (wasInTimedCast && lastInteractedGoGuid_ != 0) {
|
2026-03-13 04:37:36 -07:00
|
|
|
|
lootTarget(lastInteractedGoGuid_);
|
|
|
|
|
|
lastInteractedGoGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 09:42:17 -07:00
|
|
|
|
// End cast animation on player character
|
|
|
|
|
|
if (spellCastAnimCallback_) {
|
|
|
|
|
|
spellCastAnimCallback_(playerGuid, false, false);
|
|
|
|
|
|
}
|
2026-03-18 00:21:46 -07:00
|
|
|
|
|
2026-03-21 02:10:09 -07:00
|
|
|
|
// Fire UNIT_SPELLCAST_STOP — cast bar should disappear
|
|
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)});
|
|
|
|
|
|
|
2026-03-18 00:21:46 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-03-17 10:12:49 -07:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-03-09 19:36:58 -07:00
|
|
|
|
|
2026-03-09 23:13:30 -07:00
|
|
|
|
// Clear unit cast bar when the spell lands (for any tracked unit)
|
|
|
|
|
|
unitCastStates_.erase(data.casterUnit);
|
2026-03-09 23:06:40 -07:00
|
|
|
|
|
2026-03-13 19:36:42 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-03-09 23:00:21 -07:00
|
|
|
|
for (const auto& m : data.missTargets) {
|
2026-03-13 19:36:42 -07:00
|
|
|
|
if (!playerIsCaster && m.targetGuid != playerGuid) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-14 09:44:52 -07:00
|
|
|
|
CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(m.missType);
|
2026-03-13 19:36:42 -07:00
|
|
|
|
addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid);
|
2026-03-09 23:00:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 10:12:49 -07:00
|
|
|
|
// 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
|
2026-03-09 19:36:58 -07:00
|
|
|
|
bool playerIsHit = false;
|
2026-03-17 10:12:49 -07:00
|
|
|
|
bool playerHitEnemy = false;
|
2026-03-09 19:36:58 -07:00
|
|
|
|
for (const auto& tgt : data.hitTargets) {
|
2026-03-17 10:12:49 -07:00
|
|
|
|
if (tgt == playerGuid) { playerIsHit = true; }
|
|
|
|
|
|
if (data.casterUnit == playerGuid && tgt != playerGuid && tgt != 0) { playerHitEnemy = true; }
|
2026-03-09 19:36:58 -07:00
|
|
|
|
}
|
2026-03-20 14:15:00 -07:00
|
|
|
|
// Fire UNIT_SPELLCAST_SUCCEEDED for Lua addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (data.casterUnit == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (data.casterUnit == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (data.casterUnit == focusGuid) unitId = "focus";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 10:12:49 -07:00
|
|
|
|
if (playerIsHit || playerHitEnemy) {
|
2026-03-09 19:36:58 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellCooldown(network::Packet& packet) {
|
2026-03-10 17:19:43 -07:00
|
|
|
|
// 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();
|
2026-03-12 00:59:25 -07:00
|
|
|
|
uint32_t cdItemId = 0;
|
|
|
|
|
|
if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format
|
2026-03-10 17:19:43 -07:00
|
|
|
|
uint32_t cooldownMs = packet.readUInt32();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
float seconds = cooldownMs / 1000.0f;
|
2026-03-12 05:38:13 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 09:02:20 -07:00
|
|
|
|
auto it = spellCooldowns.find(spellId);
|
|
|
|
|
|
if (it == spellCooldowns.end()) {
|
|
|
|
|
|
spellCooldowns[spellId] = seconds;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
it->second = mergeCooldownSeconds(it->second, seconds);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
for (auto& slot : actionBar) {
|
2026-03-12 00:59:25 -07:00
|
|
|
|
bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|
|
|
|
|
|
|| (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId);
|
|
|
|
|
|
if (match) {
|
2026-03-14 09:02:20 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 17:19:43 -07:00
|
|
|
|
LOG_DEBUG("handleSpellCooldown: parsed for ",
|
|
|
|
|
|
isClassicFormat ? "Classic" : "TBC/WotLK", " format");
|
2026-03-20 14:35:00 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("SPELL_UPDATE_COOLDOWN", {});
|
|
|
|
|
|
addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCooldownEvent(network::Packet& packet) {
|
2026-03-11 02:51:58 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
2026-03-11 02:51:58 -07:00
|
|
|
|
// WotLK appends the target unit guid (8 bytes) — skip it
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 8)
|
|
|
|
|
|
packet.readUInt64();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Cooldown finished
|
|
|
|
|
|
spellCooldowns.erase(spellId);
|
|
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
|
|
|
|
|
slot.cooldownRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 14:27:46 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("SPELL_UPDATE_COOLDOWN", {});
|
|
|
|
|
|
addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
|
|
|
|
|
|
AuraUpdateData data;
|
2026-03-09 21:38:14 -07:00
|
|
|
|
if (!packetParsers_->parseAuraUpdate(packet, data, isAll)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
// Determine which aura list to update
|
|
|
|
|
|
std::vector<AuraSlot>* auraList = nullptr;
|
|
|
|
|
|
if (data.guid == playerGuid) {
|
|
|
|
|
|
auraList = &playerAuras;
|
|
|
|
|
|
} else if (data.guid == targetGuid) {
|
|
|
|
|
|
auraList = &targetAuras;
|
|
|
|
|
|
}
|
2026-03-12 11:44:30 -07:00
|
|
|
|
// 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];
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
if (auraList) {
|
2026-02-08 00:00:12 -08:00
|
|
|
|
if (isAll) {
|
|
|
|
|
|
auraList->clear();
|
|
|
|
|
|
}
|
2026-02-17 15:49:12 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Ensure vector is large enough
|
|
|
|
|
|
while (auraList->size() <= slot) {
|
|
|
|
|
|
auraList->push_back(AuraSlot{});
|
|
|
|
|
|
}
|
|
|
|
|
|
(*auraList)[slot] = aura;
|
|
|
|
|
|
}
|
2026-02-14 16:42:47 -08:00
|
|
|
|
|
2026-03-20 14:15:00 -07:00
|
|
|
|
// Fire UNIT_AURA event for Lua addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (data.guid == playerGuid) unitId = "player";
|
|
|
|
|
|
else if (data.guid == targetGuid) unitId = "target";
|
|
|
|
|
|
else if (data.guid == focusGuid) unitId = "focus";
|
|
|
|
|
|
else if (data.guid == petGuid_) unitId = "pet";
|
|
|
|
|
|
if (!unitId.empty())
|
|
|
|
|
|
addonEventCallback_("UNIT_AURA", {unitId});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 16:42:47 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLearnedSpell(network::Packet& packet) {
|
2026-03-11 04:08:16 -07:00
|
|
|
|
// 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();
|
2026-03-13 05:51:15 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.insert(spellId);
|
2026-03-13 05:51:15 -07:00
|
|
|
|
LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : "");
|
2026-02-10 13:16:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Check if this spell corresponds to a talent rank
|
2026-03-21 02:29:48 -07:00
|
|
|
|
bool isTalentSpell = false;
|
2026-02-10 13:16:38 -08:00
|
|
|
|
for (const auto& [talentId, talent] : talentCache_) {
|
|
|
|
|
|
for (int rank = 0; rank < 5; ++rank) {
|
|
|
|
|
|
if (talent.rankSpells[rank] == spellId) {
|
|
|
|
|
|
// Found the talent! Update the rank for the active spec
|
|
|
|
|
|
uint8_t newRank = rank + 1; // rank is 0-indexed in array, but stored as 1-indexed
|
|
|
|
|
|
learnedTalents_[activeTalentSpec_][talentId] = newRank;
|
|
|
|
|
|
LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank,
|
|
|
|
|
|
" (spell ", spellId, ") in spec ", (int)activeTalentSpec_);
|
2026-03-21 02:29:48 -07:00
|
|
|
|
isTalentSpell = true;
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("CHARACTER_POINTS_CHANGED", {});
|
|
|
|
|
|
addonEventCallback_("PLAYER_TALENT_UPDATE", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
2026-02-10 13:16:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-21 02:29:48 -07:00
|
|
|
|
if (isTalentSpell) break;
|
2026-02-10 13:16:38 -08:00
|
|
|
|
}
|
2026-02-26 00:59:07 -08:00
|
|
|
|
|
2026-03-20 14:27:46 -07:00
|
|
|
|
// Fire LEARNED_SPELL_IN_TAB / SPELLS_CHANGED for Lua addons
|
|
|
|
|
|
if (!alreadyKnown && addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)});
|
|
|
|
|
|
addonEventCallback_("SPELLS_CHANGED", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 02:29:48 -07:00
|
|
|
|
if (isTalentSpell) return; // talent spells don't show chat message
|
|
|
|
|
|
|
2026-03-13 05:51:15 -07:00
|
|
|
|
// 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.");
|
|
|
|
|
|
}
|
2026-02-26 00:59:07 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleRemovedSpell(network::Packet& packet) {
|
2026-03-11 04:08:16 -07:00
|
|
|
|
// 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();
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.erase(spellId);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
LOG_INFO("Removed spell: ", spellId);
|
2026-03-20 14:27:46 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("SPELLS_CHANGED", {});
|
2026-03-13 06:11:10 -07:00
|
|
|
|
|
2026-03-17 12:35:05 -07:00
|
|
|
|
const std::string& name = getSpellName(spellId);
|
|
|
|
|
|
if (!name.empty())
|
|
|
|
|
|
addSystemChatMessage("You have unlearned: " + name + ".");
|
|
|
|
|
|
else
|
|
|
|
|
|
addSystemChatMessage("A spell has been removed.");
|
|
|
|
|
|
|
2026-03-13 06:11:10 -07:00
|
|
|
|
// 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();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
void GameHandler::handleSupercededSpell(network::Packet& packet) {
|
|
|
|
|
|
// Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2)
|
2026-03-11 04:08:16 -07:00
|
|
|
|
// 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();
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Remove old spell
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.erase(oldSpellId);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
2026-03-13 06:00:39 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// Add new spell
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.insert(newSpellId);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId);
|
|
|
|
|
|
|
2026-03-13 05:37:15 -07:00
|
|
|
|
// 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.
|
2026-03-13 05:42:24 -07:00
|
|
|
|
bool barChanged = false;
|
2026-03-13 05:37:15 -07:00
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) {
|
|
|
|
|
|
slot.id = newSpellId;
|
|
|
|
|
|
slot.cooldownRemaining = 0.0f;
|
|
|
|
|
|
slot.cooldownTotal = 0.0f;
|
2026-03-13 05:42:24 -07:00
|
|
|
|
barChanged = true;
|
2026-03-13 05:37:15 -07:00
|
|
|
|
LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 22:13:57 -07:00
|
|
|
|
if (barChanged) {
|
|
|
|
|
|
saveCharacterConfig();
|
|
|
|
|
|
if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {});
|
|
|
|
|
|
}
|
2026-03-13 05:37:15 -07:00
|
|
|
|
|
2026-03-13 06:00:39 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleUnlearnSpells(network::Packet& packet) {
|
|
|
|
|
|
// Sent when unlearning multiple spells (e.g., spec change, respec)
|
2026-03-11 03:03:44 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
uint32_t spellCount = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("Unlearning ", spellCount, " spells");
|
|
|
|
|
|
|
2026-03-13 06:11:10 -07:00
|
|
|
|
bool barChanged = false;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
2026-02-17 15:13:54 -08:00
|
|
|
|
knownSpells.erase(spellId);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
LOG_INFO(" Unlearned spell: ", spellId);
|
2026-03-13 06:11:10 -07:00
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
|
|
|
|
|
slot = ActionBarSlot{};
|
|
|
|
|
|
barChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
2026-03-13 06:11:10 -07:00
|
|
|
|
if (barChanged) saveCharacterConfig();
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
if (spellCount > 0) {
|
|
|
|
|
|
addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Talents
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTalentsInfo(network::Packet& packet) {
|
2026-03-13 00:47:04 -07:00
|
|
|
|
// 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
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
2026-03-13 00:47:04 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
uint8_t talentType = packet.readUInt8();
|
|
|
|
|
|
if (talentType != 0) {
|
|
|
|
|
|
// type 1 = inspect result; handled by handleInspectResults — ignore here
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-13 00:47:04 -07:00
|
|
|
|
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();
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
2026-03-13 00:47:04 -07:00
|
|
|
|
activeTalentSpec_ = activeTalentGroup;
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
2026-03-13 00:47:04 -07:00
|
|
|
|
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();
|
2026-03-13 03:32:45 -07:00
|
|
|
|
learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed
|
2026-03-13 00:47:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 00:47:04 -07:00
|
|
|
|
unspentTalentPoints_[activeTalentGroup] =
|
|
|
|
|
|
static_cast<uint8_t>(unspentTalents > 255 ? 255 : unspentTalents);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("handleTalentsInfo: unspent=", unspentTalents,
|
|
|
|
|
|
" groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup,
|
|
|
|
|
|
" learned=", learnedTalents_[activeTalentGroup].size());
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
2026-03-21 02:29:48 -07:00
|
|
|
|
// Fire talent-related events for addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("CHARACTER_POINTS_CHANGED", {});
|
|
|
|
|
|
addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {});
|
|
|
|
|
|
addonEventCallback_("PLAYER_TALENT_UPDATE", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 07:41:27 -07:00
|
|
|
|
if (!talentsInitialized_) {
|
|
|
|
|
|
talentsInitialized_ = true;
|
2026-03-13 00:47:04 -07:00
|
|
|
|
if (unspentTalents > 0) {
|
|
|
|
|
|
addSystemChatMessage("You have " + std::to_string(unspentTalents)
|
|
|
|
|
|
+ " unspent talent point" + (unspentTalents != 1 ? "s" : "") + ".");
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("learnTalent: Not in world or no socket connection");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Requesting to learn talent: id=", talentId, " rank=", requestedRank);
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = LearnTalentPacket::build(talentId, requestedRank);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::switchTalentSpec(uint8_t newSpec) {
|
|
|
|
|
|
if (newSpec > 1) {
|
|
|
|
|
|
LOG_WARNING("Invalid talent spec: ", (int)newSpec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (newSpec == activeTalentSpec_) {
|
|
|
|
|
|
LOG_INFO("Already on spec ", (int)newSpec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 22:49:23 -07:00
|
|
|
|
// 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=", (int)newSpec);
|
|
|
|
|
|
}
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
activeTalentSpec_ = newSpec;
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Switched to talent spec ", (int)newSpec,
|
|
|
|
|
|
" (unspent=", (int)unspentTalentPoints_[newSpec],
|
|
|
|
|
|
", learned=", learnedTalents_[newSpec].size(), ")");
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg = "Switched to spec " + std::to_string(newSpec + 1);
|
|
|
|
|
|
if (unspentTalentPoints_[newSpec] > 0) {
|
|
|
|
|
|
msg += " (" + std::to_string(unspentTalentPoints_[newSpec]) + " unspent point";
|
|
|
|
|
|
if (unspentTalentPoints_[newSpec] > 1) msg += "s";
|
|
|
|
|
|
msg += ")";
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 21:13:27 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:53:05 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 11:58:01 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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");
|
2026-03-20 14:44:48 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("GROUP_ROSTER_UPDATE", {});
|
|
|
|
|
|
addonEventCallback_("PARTY_MEMBERS_CHANGED", {});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-02-05 13:22:15 -08:00
|
|
|
|
if (!data.inviterName.empty()) {
|
|
|
|
|
|
addSystemChatMessage(data.inviterName + " has invited you to a group.");
|
|
|
|
|
|
}
|
2026-03-17 12:37:19 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playTargetSelect();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-03-10 00:58:56 -07:00
|
|
|
|
// 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");
|
2026-03-13 06:38:50 -07:00
|
|
|
|
// Snapshot state before reset so we can detect transitions.
|
|
|
|
|
|
const uint32_t prevCount = partyData.memberCount;
|
2026-03-20 17:33:34 -07:00
|
|
|
|
const uint8_t prevLootMethod = partyData.lootMethod;
|
2026-03-13 06:38:50 -07:00
|
|
|
|
const bool wasInGroup = !partyData.isEmpty();
|
2026-03-10 06:21:05 -07:00
|
|
|
|
// Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta.
|
|
|
|
|
|
// Without this, repeated GROUP_LIST packets push duplicate members.
|
|
|
|
|
|
partyData = GroupListData{};
|
2026-03-10 00:58:56 -07:00
|
|
|
|
if (!GroupListParser::parse(packet, partyData, hasRoles)) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-03-13 06:38:50 -07:00
|
|
|
|
const bool nowInGroup = !partyData.isEmpty();
|
|
|
|
|
|
if (!nowInGroup && wasInGroup) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
LOG_INFO("No longer in a group");
|
2026-02-05 13:22:15 -08:00
|
|
|
|
addSystemChatMessage("You are no longer in a group.");
|
2026-03-13 06:38:50 -07:00
|
|
|
|
} 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");
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-03-20 17:33:34 -07:00
|
|
|
|
// 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 + ".");
|
|
|
|
|
|
}
|
2026-03-20 14:15:00 -07:00
|
|
|
|
// Fire GROUP_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED for Lua addons
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("GROUP_ROSTER_UPDATE", {});
|
|
|
|
|
|
addonEventCallback_("PARTY_MEMBERS_CHANGED", {});
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGroupUninvite(network::Packet& packet) {
|
|
|
|
|
|
(void)packet;
|
|
|
|
|
|
partyData = GroupListData{};
|
|
|
|
|
|
LOG_INFO("Removed from group");
|
|
|
|
|
|
|
2026-03-20 14:44:48 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("GROUP_ROSTER_UPDATE", {});
|
|
|
|
|
|
addonEventCallback_("PARTY_MEMBERS_CHANGED", {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
msg.message = "You have been removed from the group.";
|
2026-03-17 17:43:10 -07:00
|
|
|
|
addUIError("You have been removed from the group.");
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handlePartyCommandResult(network::Packet& packet) {
|
|
|
|
|
|
PartyCommandResultData data;
|
|
|
|
|
|
if (!PartyCommandResultParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.result != PartyResult::OK) {
|
2026-03-13 05:54:01 -07:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:43:10 -07:00
|
|
|
|
addUIError(buf);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
2026-03-13 05:54:01 -07:00
|
|
|
|
msg.message = buf;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
addLocalChatMessage(msg);
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 10:25:55 -08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 00:42:52 -07:00
|
|
|
|
// 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
|
2026-03-09 23:53:43 -07:00
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-02-26 10:25:55 -08:00
|
|
|
|
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();
|
2026-03-12 12:07:46 -07:00
|
|
|
|
// Collect aura updates for this member and store in unitAurasCache_
|
|
|
|
|
|
// so party frame debuff dots can use them.
|
|
|
|
|
|
std::vector<AuraSlot> newAuras;
|
2026-02-26 10:25:55 -08:00
|
|
|
|
for (int i = 0; i < 64; ++i) {
|
|
|
|
|
|
if (auraMask & (uint64_t(1) << i)) {
|
2026-03-12 12:07:46 -07:00
|
|
|
|
AuraSlot a;
|
|
|
|
|
|
a.level = static_cast<uint8_t>(i); // use slot index
|
2026-02-26 10:25:55 -08:00
|
|
|
|
if (isWotLK) {
|
|
|
|
|
|
// WotLK: uint32 spellId + uint8 auraFlags
|
|
|
|
|
|
if (remaining() < 5) break;
|
2026-03-12 12:07:46 -07:00
|
|
|
|
a.spellId = packet.readUInt32();
|
|
|
|
|
|
a.flags = packet.readUInt8();
|
2026-02-26 10:25:55 -08:00
|
|
|
|
} else {
|
2026-03-12 12:07:46 -07:00
|
|
|
|
// Classic/TBC: uint16 spellId only; negative auras not indicated here
|
2026-02-26 10:25:55 -08:00
|
|
|
|
if (remaining() < 2) break;
|
2026-03-12 12:07:46 -07:00
|
|
|
|
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
|
2026-02-26 10:25:55 -08:00
|
|
|
|
}
|
2026-03-12 12:07:46 -07:00
|
|
|
|
if (a.spellId != 0) newAuras.push_back(a);
|
2026-02-26 10:25:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 12:07:46 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-26 10:25:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-03-21 01:55:30 -07:00
|
|
|
|
|
|
|
|
|
|
// Fire addon events for party/raid member health/power/aura changes
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
// Resolve unit ID for this member (party1..4 or raid1..40)
|
|
|
|
|
|
std::string unitId;
|
|
|
|
|
|
if (partyData.groupType == 1) {
|
|
|
|
|
|
// Raid: find 1-based index
|
|
|
|
|
|
for (size_t i = 0; i < partyData.members.size(); ++i) {
|
|
|
|
|
|
if (partyData.members[i].guid == memberGuid) {
|
|
|
|
|
|
unitId = "raid" + std::to_string(i + 1);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Party: find 1-based index excluding self
|
|
|
|
|
|
int found = 0;
|
|
|
|
|
|
for (const auto& m : partyData.members) {
|
|
|
|
|
|
if (m.guid == playerGuid) continue;
|
|
|
|
|
|
++found;
|
|
|
|
|
|
if (m.guid == memberGuid) {
|
|
|
|
|
|
unitId = "party" + std::to_string(found);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!unitId.empty()) {
|
|
|
|
|
|
if (updateFlags & (0x0002 | 0x0004)) // CUR_HP or MAX_HP
|
|
|
|
|
|
addonEventCallback_("UNIT_HEALTH", {unitId});
|
|
|
|
|
|
if (updateFlags & (0x0010 | 0x0020)) // CUR_POWER or MAX_POWER
|
|
|
|
|
|
addonEventCallback_("UNIT_POWER", {unitId});
|
|
|
|
|
|
if (updateFlags & 0x0200) // AURAS
|
|
|
|
|
|
addonEventCallback_("UNIT_AURA", {unitId});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-26 10:25:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 20:16:14 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 02:31:12 -07:00
|
|
|
|
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);
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
gmTicketActive_ = false;
|
|
|
|
|
|
gmTicketText_.clear();
|
2026-03-12 02:31:12 -07:00
|
|
|
|
LOG_INFO("Deleting GM ticket");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
open ticket, closed, suspended), extracts ticket text, age and
server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
automatically when the GM Ticket UI window is opened, so the player
sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
wait time, and hides the Delete button when no ticket is active.
Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 09:44:43 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 14:44:44 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 12:31:48 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 14:44:44 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
void GameHandler::handleGuildInfo(network::Packet& packet) {
|
|
|
|
|
|
GuildInfoData data;
|
|
|
|
|
|
if (!GuildInfoParser::parse(packet, data)) return;
|
|
|
|
|
|
|
2026-02-25 14:44:44 -08:00
|
|
|
|
guildInfoData_ = data;
|
2026-02-13 21:39:48 -08:00
|
|
|
|
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");
|
2026-03-21 03:04:59 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("GUILD_ROSTER_UPDATE", {});
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
GuildQueryResponseData data;
|
|
|
|
|
|
if (!packetParsers_->parseGuildQueryResponse(packet, data)) return;
|
|
|
|
|
|
|
2026-03-18 09:44:43 -07:00
|
|
|
|
// 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_ + ">");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName);
|
|
|
|
|
|
}
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildEvent(network::Packet& packet) {
|
|
|
|
|
|
GuildEventData data;
|
|
|
|
|
|
if (!GuildEventParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
switch (data.eventType) {
|
|
|
|
|
|
case GuildEvent::PROMOTION:
|
|
|
|
|
|
if (data.numStrings >= 3)
|
|
|
|
|
|
msg = data.strings[0] + " has promoted " + data.strings[1] + " to " + data.strings[2] + ".";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::DEMOTION:
|
|
|
|
|
|
if (data.numStrings >= 3)
|
|
|
|
|
|
msg = data.strings[0] + " has demoted " + data.strings[1] + " to " + data.strings[2] + ".";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::MOTD:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = "Guild MOTD: " + data.strings[0];
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::JOINED:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = data.strings[0] + " has joined the guild.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::LEFT:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = data.strings[0] + " has left the guild.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::REMOVED:
|
|
|
|
|
|
if (data.numStrings >= 2)
|
|
|
|
|
|
msg = data.strings[1] + " has been kicked from the guild by " + data.strings[0] + ".";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::LEADER_IS:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = data.strings[0] + " is the guild leader.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::LEADER_CHANGED:
|
|
|
|
|
|
if (data.numStrings >= 2)
|
|
|
|
|
|
msg = data.strings[0] + " has made " + data.strings[1] + " the new guild leader.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::DISBANDED:
|
|
|
|
|
|
msg = "Guild has been disbanded.";
|
|
|
|
|
|
guildName_.clear();
|
|
|
|
|
|
guildRankNames_.clear();
|
|
|
|
|
|
guildRoster_ = GuildRosterData{};
|
|
|
|
|
|
hasGuildRoster_ = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::SIGNED_ON:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = "[Guild] " + data.strings[0] + " has come online.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::SIGNED_OFF:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = "[Guild] " + data.strings[0] + " has gone offline.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
msg = "Guild event " + std::to_string(data.eventType);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!msg.empty()) {
|
|
|
|
|
|
MessageChatData chatMsg;
|
|
|
|
|
|
chatMsg.type = ChatType::GUILD;
|
|
|
|
|
|
chatMsg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
chatMsg.message = msg;
|
|
|
|
|
|
addLocalChatMessage(chatMsg);
|
|
|
|
|
|
}
|
2026-02-16 20:16:14 -08:00
|
|
|
|
|
2026-03-21 03:04:59 -07:00
|
|
|
|
// Fire addon events for guild state changes
|
|
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
switch (data.eventType) {
|
|
|
|
|
|
case GuildEvent::MOTD:
|
|
|
|
|
|
addonEventCallback_("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""});
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::SIGNED_ON:
|
|
|
|
|
|
case GuildEvent::SIGNED_OFF:
|
|
|
|
|
|
case GuildEvent::PROMOTION:
|
|
|
|
|
|
case GuildEvent::DEMOTION:
|
|
|
|
|
|
case GuildEvent::JOINED:
|
|
|
|
|
|
case GuildEvent::LEFT:
|
|
|
|
|
|
case GuildEvent::REMOVED:
|
|
|
|
|
|
case GuildEvent::LEADER_CHANGED:
|
|
|
|
|
|
case GuildEvent::DISBANDED:
|
|
|
|
|
|
addonEventCallback_("GUILD_ROSTER_UPDATE", {});
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 20:16:14 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildInvite(network::Packet& packet) {
|
|
|
|
|
|
GuildInviteResponseData data;
|
|
|
|
|
|
if (!GuildInviteResponseParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingGuildInvite_ = true;
|
|
|
|
|
|
pendingGuildInviterName_ = data.inviterName;
|
|
|
|
|
|
pendingGuildInviteGuildName_ = data.guildName;
|
|
|
|
|
|
LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName);
|
|
|
|
|
|
addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + ".");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildCommandResult(network::Packet& packet) {
|
|
|
|
|
|
GuildCommandResultData data;
|
|
|
|
|
|
if (!GuildCommandResultParser::parse(packet, data)) return;
|
|
|
|
|
|
|
2026-03-13 06:43:11 -07:00
|
|
|
|
// 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";
|
2026-02-13 21:39:48 -08:00
|
|
|
|
if (!data.name.empty()) msg += " for " + data.name;
|
|
|
|
|
|
msg += " (error " + std::to_string(data.errorCode) + ")";
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-03-13 06:43:11 -07:00
|
|
|
|
addSystemChatMessage(msg);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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;
|
2026-03-21 01:55:30 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {});
|
2026-03-12 17:58:24 -07:00
|
|
|
|
masterLootCandidates_.clear();
|
2026-02-06 18:52:28 -08:00
|
|
|
|
if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) {
|
|
|
|
|
|
clearTarget();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = LootReleasePacket::build(currentLoot.lootGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
currentLoot = LootResponseData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:58:24 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::interactWithNpc(uint64_t guid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GossipHelloPacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
void GameHandler::interactWithGameObject(uint64_t guid) {
|
2026-02-19 03:31:49 -08:00
|
|
|
|
if (guid == 0) return;
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-20 19:51:04 -08:00
|
|
|
|
// 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);
|
2026-02-19 03:31:49 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
|
|
|
|
|
|
if (guid == 0) return;
|
2026-02-07 19:44:03 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// 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();
|
2026-02-20 19:51:04 -08:00
|
|
|
|
// Keep duplicate suppression, but allow quick retry clicks.
|
2026-03-18 01:30:20 -07:00
|
|
|
|
constexpr int64_t minRepeatMs = 150;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
if (guid == lastInteractGuid &&
|
2026-02-18 04:21:05 -08:00
|
|
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastInteractTime).count() < minRepeatMs) {
|
2026-02-20 19:51:04 -08:00
|
|
|
|
return;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
lastInteractGuid = guid;
|
|
|
|
|
|
lastInteractTime = now;
|
|
|
|
|
|
|
2026-02-20 19:51:04 -08:00
|
|
|
|
// Ensure GO interaction isn't blocked by stale or active melee state.
|
|
|
|
|
|
stopAutoAttack();
|
2026-02-16 18:46:44 -08:00
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
2026-02-20 19:51:04 -08:00
|
|
|
|
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;
|
2026-02-20 23:31:30 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-20 19:51:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
// 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);
|
2026-03-17 10:12:49 -07:00
|
|
|
|
if (dist3d > 10.0f) {
|
2026-02-20 19:51:04 -08:00
|
|
|
|
addSystemChatMessage("Too far away.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-18 06:49:43 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-20 19:51:04 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-16 18:46:44 -08:00
|
|
|
|
|
2026-03-18 06:49:43 -07:00
|
|
|
|
LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec,
|
|
|
|
|
|
" entry=", goEntry, " type=", goType,
|
|
|
|
|
|
" name='", goName, "' dist=", entity ? std::sqrt(
|
|
|
|
|
|
(entity->getX() - movementInfo.x) * (entity->getX() - movementInfo.x) +
|
|
|
|
|
|
(entity->getY() - movementInfo.y) * (entity->getY() - movementInfo.y) +
|
|
|
|
|
|
(entity->getZ() - movementInfo.z) * (entity->getZ() - movementInfo.z)) : -1.0f);
|
2026-02-07 19:44:03 -08:00
|
|
|
|
auto packet = GameObjectUsePacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
2026-03-13 04:37:36 -07:00
|
|
|
|
lastInteractedGoGuid_ = guid;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
|
|
|
|
|
|
// For mailbox GameObjects (type 19), open mail UI and request mail list.
|
|
|
|
|
|
// In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends
|
|
|
|
|
|
// animation/sound and expects the client to request the mail list.
|
2026-02-18 04:13:26 -08:00
|
|
|
|
bool isMailbox = false;
|
2026-02-20 19:51:04 -08:00
|
|
|
|
bool chestLike = false;
|
2026-03-11 19:45:03 -07:00
|
|
|
|
// Always send CMSG_LOOT after CMSG_GAMEOBJ_USE for any gameobject that could be
|
|
|
|
|
|
// lootable. The server silently ignores CMSG_LOOT for non-lootable objects
|
|
|
|
|
|
// (doors, buttons, etc.), so this is safe. Not sending it is the main reason
|
|
|
|
|
|
// chests fail to open when their GO type is not yet cached or their name doesn't
|
|
|
|
|
|
// contain the word "chest" (e.g. lockboxes, coffers, strongboxes, caches).
|
|
|
|
|
|
bool shouldSendLoot = true;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
auto go = std::static_pointer_cast<GameObject>(entity);
|
|
|
|
|
|
auto* info = getCachedGameObjectInfo(go->getEntry());
|
|
|
|
|
|
if (info && info->type == 19) {
|
2026-02-18 04:13:26 -08:00
|
|
|
|
isMailbox = true;
|
|
|
|
|
|
shouldSendLoot = false;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
LOG_INFO("Mailbox interaction: opening mail UI and requesting mail list");
|
|
|
|
|
|
mailboxGuid_ = guid;
|
|
|
|
|
|
mailboxOpen_ = true;
|
|
|
|
|
|
hasNewMail_ = false;
|
|
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
|
|
|
|
|
refreshMailList();
|
2026-02-20 19:51:04 -08:00
|
|
|
|
} 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)); });
|
2026-03-11 19:45:03 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-14 04:47:34 -07:00
|
|
|
|
// Some servers require CMSG_GAMEOBJ_REPORT_USE for lootable gameobjects.
|
|
|
|
|
|
// Only send it when the active opcode table actually supports it.
|
|
|
|
|
|
if (!isMailbox) {
|
|
|
|
|
|
const auto* table = getActiveOpcodeTable();
|
|
|
|
|
|
if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) {
|
|
|
|
|
|
network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE));
|
|
|
|
|
|
reportUse.writeUInt64(guid);
|
|
|
|
|
|
socket->send(reportUse);
|
|
|
|
|
|
}
|
2026-02-16 18:46:44 -08:00
|
|
|
|
}
|
2026-02-18 04:11:00 -08:00
|
|
|
|
if (shouldSendLoot) {
|
2026-03-17 10:12:49 -07:00
|
|
|
|
// Don't send CMSG_LOOT immediately — give the server time to process
|
|
|
|
|
|
// CMSG_GAMEOBJ_USE first (chests need to transition to lootable state,
|
|
|
|
|
|
// gathering nodes start a spell cast). A premature CMSG_LOOT can cause
|
|
|
|
|
|
// an empty SMSG_LOOT_RESPONSE that clears our gather-cast loot state.
|
2026-03-14 04:41:46 -07:00
|
|
|
|
pendingGameObjectLootOpens_.erase(
|
|
|
|
|
|
std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(),
|
|
|
|
|
|
[&](const PendingLootOpen& p) { return p.guid == guid; }),
|
|
|
|
|
|
pendingGameObjectLootOpens_.end());
|
2026-03-17 10:12:49 -07:00
|
|
|
|
// Short delay for chests (server makes them lootable quickly after USE),
|
|
|
|
|
|
// plus a longer retry to catch slow state transitions.
|
|
|
|
|
|
pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.20f});
|
2026-03-14 04:41:46 -07:00
|
|
|
|
pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f});
|
2026-03-13 05:06:00 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
// Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be
|
|
|
|
|
|
// sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot
|
|
|
|
|
|
// guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT.
|
|
|
|
|
|
lastInteractedGoGuid_ = 0;
|
2026-02-20 19:51:04 -08:00
|
|
|
|
}
|
2026-03-17 10:12:49 -07:00
|
|
|
|
// Don't retry CMSG_GAMEOBJ_USE — resending can toggle chest state on some
|
|
|
|
|
|
// servers (opening→closing the chest). The delayed CMSG_LOOT retries above
|
|
|
|
|
|
// handle the case where the first loot attempt arrives too early.
|
2026-02-07 19:44:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::selectGossipOption(uint32_t optionId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
LOG_INFO("selectGossipOption: optionId=", optionId,
|
|
|
|
|
|
" npcGuid=0x", std::hex, currentGossip.npcGuid, std::dec,
|
|
|
|
|
|
" menuId=", currentGossip.menuId,
|
|
|
|
|
|
" numOptions=", currentGossip.options.size());
|
2026-02-06 11:45:35 -08:00
|
|
|
|
auto packet = GossipSelectOptionPacket::build(currentGossip.npcGuid, currentGossip.menuId, optionId);
|
|
|
|
|
|
socket->send(packet);
|
2026-02-08 03:32:00 -08:00
|
|
|
|
|
|
|
|
|
|
for (const auto& opt : currentGossip.options) {
|
|
|
|
|
|
if (opt.id != optionId) continue;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
LOG_INFO(" matched option: id=", opt.id, " icon=", (int)opt.icon, " text='", opt.text, "'");
|
|
|
|
|
|
|
|
|
|
|
|
// Icon-based NPC interaction fallbacks
|
|
|
|
|
|
// Some servers need the specific activate packet in addition to gossip select
|
|
|
|
|
|
if (opt.icon == 6) {
|
|
|
|
|
|
// GOSSIP_ICON_MONEY_BAG = banker
|
|
|
|
|
|
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Text-based NPC type detection for servers using placeholder strings
|
2026-02-08 03:32:00 -08:00
|
|
|
|
std::string text = opt.text;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
std::string textLower = text;
|
|
|
|
|
|
std::transform(textLower.begin(), textLower.end(), textLower.begin(),
|
2026-02-08 03:32:00 -08:00
|
|
|
|
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 05:31:17 -07:00
|
|
|
|
// Vendor / repair: some servers require an explicit CMSG_LIST_INVENTORY after gossip select.
|
|
|
|
|
|
const bool isVendor = (text == "GOSSIP_OPTION_VENDOR" ||
|
|
|
|
|
|
(textLower.find("browse") != std::string::npos &&
|
|
|
|
|
|
(textLower.find("goods") != std::string::npos || textLower.find("wares") != std::string::npos)));
|
|
|
|
|
|
const bool isArmorer = (text == "GOSSIP_OPTION_ARMORER" || textLower.find("repair") != std::string::npos);
|
|
|
|
|
|
if (isVendor || isArmorer) {
|
|
|
|
|
|
if (isArmorer) {
|
|
|
|
|
|
setVendorCanRepair(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
auto pkt = ListInventoryPacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip.npcGuid, std::dec,
|
|
|
|
|
|
" vendor=", (int)isVendor, " repair=", (int)isArmorer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
if (textLower.find("make this inn your home") != std::string::npos ||
|
|
|
|
|
|
textLower.find("set your home") != std::string::npos) {
|
2026-02-08 03:32:00 -08:00
|
|
|
|
auto bindPkt = BinderActivatePacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(bindPkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
|
|
|
|
|
}
|
feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET)
- Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with
petNumber, entry, level, name, displayId, and active status
- Detect stable master via gossip option text/keyword matching and
auto-send MSG_LIST_STABLED_PETS request to open the stable UI
- Refresh list automatically after SMSG_STABLE_RESULT to reflect state
- New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket
- New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber)
- Stable window UI: shows active/stabled pets with store/retrieve buttons,
slot count, refresh, and close; opens when server sends pet list
- Clear stable state on world logout/disconnect
2026-03-12 19:15:52 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-02-08 03:32:00 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::selectGossipQuest(uint32_t questId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
|
2026-02-09 23:24:04 -08:00
|
|
|
|
|
2026-02-19 02:27:01 -08:00
|
|
|
|
// Keep quest-log fallback for servers that don't provide stable icon semantics.
|
2026-02-19 02:04:56 -08:00
|
|
|
|
const QuestLogEntry* activeQuest = nullptr;
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId) {
|
|
|
|
|
|
activeQuest = &q;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 02:27:01 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
2026-02-20 17:14:13 -08:00
|
|
|
|
const bool questInServerLog = questInServerLogSlots(questId);
|
|
|
|
|
|
if (questInServerLog && !activeQuest) {
|
|
|
|
|
|
addQuestToLocalLogIfMissing(questId, "Quest #" + std::to_string(questId), "");
|
|
|
|
|
|
requestQuestQuery(questId, false);
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId) {
|
|
|
|
|
|
activeQuest = &q;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const bool activeQuestConfirmedByServer = questInServerLog;
|
2026-02-19 02:27:01 -08:00
|
|
|
|
// 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;
|
2026-02-19 02:04:56 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-09 23:24:04 -08:00
|
|
|
|
|
2026-02-06 11:45:35 -08:00
|
|
|
|
gossipWindowOpen = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 00:30:21 -08:00
|
|
|
|
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);
|
2026-03-11 00:18:23 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 00:30:21 -08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:18:23 -07:00
|
|
|
|
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();
|
2026-03-11 00:20:31 -07:00
|
|
|
|
|
|
|
|
|
|
// Remove any previously added POI markers for this quest to avoid duplicates
|
|
|
|
|
|
// on repeated queries (e.g. zone change or force-refresh).
|
|
|
|
|
|
gossipPois_.erase(
|
|
|
|
|
|
std::remove_if(gossipPois_.begin(), gossipPois_.end(),
|
|
|
|
|
|
[questId, this](const GossipPoi& p) {
|
|
|
|
|
|
// Match by questId embedded in data field (set below).
|
|
|
|
|
|
return p.data == questId;
|
|
|
|
|
|
}),
|
|
|
|
|
|
gossipPois_.end());
|
|
|
|
|
|
|
|
|
|
|
|
// Find the quest title for the marker label.
|
|
|
|
|
|
std::string questTitle;
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId) { questTitle = q.title; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:18:23 -07:00
|
|
|
|
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;
|
2026-03-11 00:20:31 -07:00
|
|
|
|
// Add as a GossipPoi; use data field to carry questId for later dedup.
|
2026-03-11 00:18:23 -07:00
|
|
|
|
GossipPoi poi;
|
2026-03-11 00:20:31 -07:00
|
|
|
|
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
|
2026-03-11 00:18:23 -07:00
|
|
|
|
poi.name = questTitle.empty() ? "Quest objective" : questTitle;
|
|
|
|
|
|
LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId,
|
|
|
|
|
|
" centroid=(", poi.x, ",", poi.y, ") title=", poi.name);
|
2026-03-20 18:51:05 -07:00
|
|
|
|
if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin());
|
2026-03-11 00:20:31 -07:00
|
|
|
|
gossipPois_.push_back(std::move(poi));
|
2026-03-11 00:18:23 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
void GameHandler::handleQuestDetails(network::Packet& packet) {
|
|
|
|
|
|
QuestDetailsData data;
|
2026-02-18 04:56:23 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data)
|
|
|
|
|
|
: QuestDetailsParser::parse(packet, data);
|
|
|
|
|
|
if (!ok) {
|
2026-02-06 11:59:51 -08:00
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_QUEST_DETAILS");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
currentQuestDetails = data;
|
2026-02-19 00:30:21 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-10 20:39:49 -07:00
|
|
|
|
// Pre-fetch item info for all reward items so icons and names are ready
|
2026-03-11 17:27:23 -07:00
|
|
|
|
// both in this details window and later in the offer-reward dialog (after the player turns in).
|
2026-03-10 20:39:49 -07:00
|
|
|
|
for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0);
|
2026-03-11 17:27:23 -07:00
|
|
|
|
// Delay opening the window slightly to allow item queries to complete
|
|
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
|
2026-02-06 11:59:51 -08:00
|
|
|
|
gossipWindowOpen = false;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("QUEST_DETAIL", {});
|
2026-02-06 11:59:51 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
bool GameHandler::hasQuestInLog(uint32_t questId) const {
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 23:20:02 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
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));
|
2026-03-20 17:28:28 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("QUEST_ACCEPTED", {std::to_string(questId)});
|
|
|
|
|
|
addonEventCallback_("QUEST_LOG_UPDATE", {});
|
|
|
|
|
|
}
|
2026-02-20 17:14:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-10 23:33:38 -07:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-02-20 17:14:13 -08:00
|
|
|
|
for (uint16_t slot = 0; slot < 25; ++slot) {
|
2026-03-10 23:33:38 -07:00
|
|
|
|
const uint16_t idField = ufQuestStart + slot * qStride;
|
|
|
|
|
|
const uint16_t stateField = ufQuestStart + slot * qStride + 1;
|
2026-02-20 17:14:13 -08:00
|
|
|
|
auto it = lastPlayerFields_.find(idField);
|
|
|
|
|
|
if (it == lastPlayerFields_.end()) continue;
|
2026-03-10 23:33:38 -07:00
|
|
|
|
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;
|
2026-02-20 17:14:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 23:33:38 -07:00
|
|
|
|
std::unordered_set<uint32_t> serverQuestIds;
|
|
|
|
|
|
serverQuestIds.reserve(serverQuestComplete.size());
|
|
|
|
|
|
for (const auto& [qid, _] : serverQuestComplete) serverQuestIds.insert(qid);
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 23:33:38 -07:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
if (forceQueryMetadata) {
|
|
|
|
|
|
for (uint32_t questId : serverQuestIds) {
|
|
|
|
|
|
requestQuestQuery(questId, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Quest log resync from server slots: server=", serverQuestIds.size(),
|
2026-03-10 23:33:38 -07:00
|
|
|
|
" localBefore=", localBefore, " removed=", removed, " added=", added,
|
|
|
|
|
|
" markedComplete=", marked);
|
2026-02-20 17:14:13 -08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 23:33:38 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 23:52:18 -07:00
|
|
|
|
// 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;
|
2026-03-11 00:13:09 -07:00
|
|
|
|
// 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);
|
2026-03-10 23:52:18 -07:00
|
|
|
|
// Don't overwrite live kill count with stale packed data if already non-zero.
|
|
|
|
|
|
if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue;
|
|
|
|
|
|
quest.killCounts[entryKey] = {counts[i], obj.required};
|
|
|
|
|
|
LOG_DEBUG("Quest ", quest.questId, " objective[", i, "]: npcOrGo=",
|
|
|
|
|
|
obj.npcOrGoId, " count=", (int)counts[i], "/", obj.required);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply item objective counts (only available in WotLK stride+3 positions 4-5).
|
|
|
|
|
|
// Item counts also arrive live via SMSG_QUESTUPDATE_ADD_ITEM; just initialise here.
|
|
|
|
|
|
for (int i = 0; i < 6; ++i) {
|
|
|
|
|
|
const auto& obj = quest.itemObjectives[i];
|
|
|
|
|
|
if (obj.itemId == 0 || obj.required == 0) continue;
|
|
|
|
|
|
if (i < 2 && qStride >= 5) {
|
|
|
|
|
|
uint8_t cnt = counts[4 + i];
|
|
|
|
|
|
if (cnt > 0) {
|
|
|
|
|
|
quest.itemCounts[obj.itemId] = std::max(quest.itemCounts[obj.itemId], static_cast<uint32_t>(cnt));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
quest.requiredItemCounts.emplace(obj.itemId, obj.required);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 17:14:13 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
void GameHandler::acceptQuest() {
|
|
|
|
|
|
if (!questDetailsOpen || state != WorldState::IN_WORLD || !socket) return;
|
2026-02-20 17:14:13 -08:00
|
|
|
|
const uint32_t questId = currentQuestDetails.questId;
|
|
|
|
|
|
if (questId == 0) return;
|
2026-02-06 20:16:38 -08:00
|
|
|
|
uint64_t npcGuid = currentQuestDetails.npcGuid;
|
2026-02-20 17:14:13 -08:00
|
|
|
|
if (pendingQuestAcceptTimeouts_.count(questId) != 0) {
|
|
|
|
|
|
LOG_DEBUG("Ignoring duplicate quest accept while pending: questId=", questId);
|
|
|
|
|
|
triggerQuestAcceptResync(questId, npcGuid, "duplicate-accept");
|
|
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 17:27:23 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-02-20 17:14:13 -08:00
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-20 23:20:02 -08:00
|
|
|
|
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);
|
2026-02-20 17:14:13 -08:00
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 17:27:23 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-02-20 17:14:13 -08:00
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-20 23:20:02 -08:00
|
|
|
|
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; });
|
|
|
|
|
|
}
|
2026-02-20 17:14:13 -08:00
|
|
|
|
|
2026-02-20 23:20:02 -08:00
|
|
|
|
network::Packet packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildAcceptQuestPacket(npcGuid, questId)
|
|
|
|
|
|
: QuestgiverAcceptQuestPacket::build(npcGuid, questId);
|
2026-02-06 11:59:51 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-20 17:14:13 -08:00
|
|
|
|
pendingQuestAcceptTimeouts_[questId] = 5.0f;
|
|
|
|
|
|
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
|
2026-03-17 12:28:15 -07:00
|
|
|
|
// Play quest-accept sound
|
|
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playQuestActivate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 17:27:23 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-02-06 11:59:51 -08:00
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
2026-02-06 20:16:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Re-query quest giver status so marker updates (! → ?)
|
|
|
|
|
|
if (npcGuid) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 20:16:38 -08:00
|
|
|
|
qsPkt.writeUInt64(npcGuid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
2026-02-06 11:59:51 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::declineQuest() {
|
|
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 17:27:23 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-02-06 11:59:51 -08:00
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void GameHandler::abandonQuest(uint32_t questId) {
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(questId);
|
2026-02-20 23:20:02 -08:00
|
|
|
|
int localIndex = -1;
|
|
|
|
|
|
for (size_t i = 0; i < questLog_.size(); ++i) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
if (questLog_[i].questId == questId) {
|
2026-02-20 23:20:02 -08:00
|
|
|
|
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);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
2026-02-20 23:20:02 -08:00
|
|
|
|
} 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));
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
2026-03-11 00:21:33 -07:00
|
|
|
|
|
|
|
|
|
|
// 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());
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 09:56:38 -07:00
|
|
|
|
void GameHandler::shareQuestWithParty(uint32_t questId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
addSystemChatMessage("Cannot share quest: not in world.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isInGroup()) {
|
|
|
|
|
|
addSystemChatMessage("You must be in a group to share a quest.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY));
|
|
|
|
|
|
pkt.writeUInt32(questId);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
// Local feedback: find quest title
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId && !q.title.empty()) {
|
|
|
|
|
|
addSystemChatMessage("Sharing quest: " + q.title);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage("Quest shared.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 21:50:15 -08:00
|
|
|
|
void GameHandler::handleQuestRequestItems(network::Packet& packet) {
|
|
|
|
|
|
QuestRequestItemsData data;
|
|
|
|
|
|
if (!QuestRequestItemsParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_REQUEST_ITEMS");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(data.questId);
|
2026-02-19 01:12:14 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 21:50:15 -08:00
|
|
|
|
currentQuestRequestItems_ = data;
|
|
|
|
|
|
questRequestItemsOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 17:27:23 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-02-06 21:50:15 -08:00
|
|
|
|
|
|
|
|
|
|
// Query item names for required items
|
|
|
|
|
|
for (const auto& item : data.requiredItems) {
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
2026-02-19 00:56:24 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-06 21:50:15 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleQuestOfferReward(network::Packet& packet) {
|
|
|
|
|
|
QuestOfferRewardData data;
|
|
|
|
|
|
if (!QuestOfferRewardParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_OFFER_REWARD");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-20 17:14:13 -08:00
|
|
|
|
clearPendingQuestAccept(data.questId);
|
2026-02-09 22:56:38 -08:00
|
|
|
|
LOG_INFO("Quest offer reward: questId=", data.questId, " title=\"", data.title, "\"");
|
2026-02-19 01:12:14 -08:00
|
|
|
|
if (pendingTurnInQuestId_ == data.questId) {
|
|
|
|
|
|
pendingTurnInQuestId_ = 0;
|
|
|
|
|
|
pendingTurnInNpcGuid_ = 0;
|
|
|
|
|
|
pendingTurnInRewardRequest_ = false;
|
|
|
|
|
|
}
|
2026-02-06 21:50:15 -08:00
|
|
|
|
currentQuestOfferReward_ = data;
|
2026-03-11 19:11:02 -07:00
|
|
|
|
questOfferRewardOpen_ = true;
|
2026-02-06 21:50:15 -08:00
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
questDetailsOpen = false;
|
2026-03-11 19:11:02 -07:00
|
|
|
|
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("QUEST_COMPLETE", {});
|
2026-02-06 21:50:15 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-19 01:12:14 -08:00
|
|
|
|
pendingTurnInQuestId_ = currentQuestRequestItems_.questId;
|
|
|
|
|
|
pendingTurnInNpcGuid_ = currentQuestRequestItems_.npcGuid;
|
|
|
|
|
|
pendingTurnInRewardRequest_ = currentQuestRequestItems_.isCompletable();
|
|
|
|
|
|
|
|
|
|
|
|
// Default quest turn-in flow used by all branches.
|
2026-02-06 21:50:15 -08:00
|
|
|
|
auto packet = QuestgiverCompleteQuestPacket::build(
|
|
|
|
|
|
currentQuestRequestItems_.npcGuid, currentQuestRequestItems_.questId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeQuestRequestItems() {
|
2026-02-19 01:12:14 -08:00
|
|
|
|
pendingTurnInRewardRequest_ = false;
|
2026-02-06 21:50:15 -08:00
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::chooseQuestReward(uint32_t rewardIndex) {
|
|
|
|
|
|
if (!questOfferRewardOpen_ || state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
uint64_t npcGuid = currentQuestOfferReward_.npcGuid;
|
2026-02-09 22:56:38 -08:00
|
|
|
|
LOG_INFO("Completing quest: questId=", currentQuestOfferReward_.questId,
|
|
|
|
|
|
" npcGuid=", npcGuid, " rewardIndex=", rewardIndex);
|
2026-02-06 21:50:15 -08:00
|
|
|
|
auto packet = QuestgiverChooseRewardPacket::build(
|
|
|
|
|
|
npcGuid, currentQuestOfferReward_.questId, rewardIndex);
|
|
|
|
|
|
socket->send(packet);
|
2026-02-19 01:12:14 -08:00
|
|
|
|
pendingTurnInQuestId_ = 0;
|
|
|
|
|
|
pendingTurnInNpcGuid_ = 0;
|
|
|
|
|
|
pendingTurnInRewardRequest_ = false;
|
2026-02-06 21:50:15 -08:00
|
|
|
|
questOfferRewardOpen_ = false;
|
|
|
|
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
|
|
|
|
|
|
|
|
|
|
// Re-query quest giver status so markers update
|
|
|
|
|
|
if (npcGuid) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 21:50:15 -08:00
|
|
|
|
qsPkt.writeUInt64(npcGuid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeQuestOfferReward() {
|
2026-02-19 01:12:14 -08:00
|
|
|
|
pendingTurnInRewardRequest_ = false;
|
2026-02-06 21:50:15 -08:00
|
|
|
|
questOfferRewardOpen_ = false;
|
|
|
|
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::closeGossip() {
|
|
|
|
|
|
gossipWindowOpen = false;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {});
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentGossip = GossipMessageData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 21:38:08 -07:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::openVendor(uint64_t npcGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
buybackItems_.clear();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
auto packet = ListInventoryPacket::build(npcGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
void GameHandler::closeVendor() {
|
2026-03-20 22:22:36 -07:00
|
|
|
|
bool wasOpen = vendorWindowOpen;
|
2026-02-06 11:59:51 -08:00
|
|
|
|
vendorWindowOpen = false;
|
|
|
|
|
|
currentVendorItems = ListInventoryData{};
|
2026-02-19 05:28:13 -08:00
|
|
|
|
buybackItems_.clear();
|
|
|
|
|
|
pendingSellToBuyback_.clear();
|
|
|
|
|
|
pendingBuybackSlot_ = -1;
|
2026-02-19 05:48:40 -08:00
|
|
|
|
pendingBuybackWireSlot_ = 0;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
pendingBuyItemId_ = 0;
|
|
|
|
|
|
pendingBuyItemSlot_ = 0;
|
2026-03-20 22:22:36 -07:00
|
|
|
|
if (wasOpen && addonEventCallback_) addonEventCallback_("MERCHANT_CLOSED", {});
|
2026-02-06 11:59:51 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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;
|
2026-02-19 05:48:40 -08:00
|
|
|
|
// 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);
|
2026-03-11 04:49:18 -07:00
|
|
|
|
// WotLK/AzerothCore expects a trailing byte; Classic/TBC do not
|
|
|
|
|
|
const bool isWotLk = isActiveExpansion("wotlk");
|
|
|
|
|
|
if (isWotLk) {
|
|
|
|
|
|
packet.writeUInt8(0);
|
|
|
|
|
|
}
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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;
|
2026-02-19 05:48:40 -08:00
|
|
|
|
// Build directly so this compiles even when Opcode::CMSG_BUYBACK_ITEM / BuybackItemPacket
|
|
|
|
|
|
// are not available in some branches.
|
|
|
|
|
|
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
LOG_INFO("Buyback request: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
|
|
|
|
|
|
std::dec, " uiSlot=", buybackSlot, " wireSlot=", wireSlot,
|
|
|
|
|
|
" source=absolute-buyback-slot",
|
2026-02-19 05:48:40 -08:00
|
|
|
|
" wire=0x", std::hex, kWotlkCmsgBuybackItemOpcode, std::dec);
|
2026-02-19 05:28:13 -08:00
|
|
|
|
pendingBuybackSlot_ = static_cast<int>(buybackSlot);
|
2026-02-19 05:48:40 -08:00
|
|
|
|
pendingBuybackWireSlot_ = wireSlot;
|
|
|
|
|
|
network::Packet packet(kWotlkCmsgBuybackItemOpcode);
|
|
|
|
|
|
packet.writeUInt64(currentVendorItems.vendorGuid);
|
|
|
|
|
|
packet.writeUInt32(wireSlot);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 16:21:09 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 19:50:22 -08:00
|
|
|
|
void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
auto packet = SellItemPacket::build(vendorGuid, itemGuid, count);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void GameHandler::sellItemBySlot(int backpackIndex) {
|
|
|
|
|
|
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
|
|
|
|
|
if (slot.empty()) return;
|
|
|
|
|
|
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 23:52:16 -08:00
|
|
|
|
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) {
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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;
|
2026-02-06 23:52:16 -08:00
|
|
|
|
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);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
} else {
|
2026-02-06 23:52:16 -08:00
|
|
|
|
addSystemChatMessage("Cannot sell: no vendor.");
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::autoEquipItemBySlot(int backpackIndex) {
|
|
|
|
|
|
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
|
|
|
|
|
if (slot.empty()) return;
|
|
|
|
|
|
|
2026-02-06 19:13:38 -08:00
|
|
|
|
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));
|
2026-02-06 18:34:45 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
// 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) {
|
2026-02-19 05:28:13 -08:00
|
|
|
|
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;
|
2026-02-17 01:00:04 -08:00
|
|
|
|
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
|
|
|
|
|
|
} else if (itemGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("Cannot sell: item not found.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Cannot sell: no vendor.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
void GameHandler::unequipToBackpack(EquipSlot equipSlot) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
|
|
|
|
|
int freeSlot = inventory.findFreeBackpackSlot();
|
|
|
|
|
|
if (freeSlot < 0) {
|
|
|
|
|
|
addSystemChatMessage("Cannot unequip: no free backpack slots.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF.
|
|
|
|
|
|
uint8_t srcBag = 0xFF;
|
|
|
|
|
|
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
|
|
|
|
|
|
uint8_t dstBag = 0xFF;
|
|
|
|
|
|
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot,
|
|
|
|
|
|
" -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")");
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
void GameHandler::swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot) {
|
|
|
|
|
|
if (!socket || !socket->isConnected()) return;
|
|
|
|
|
|
LOG_INFO("swapContainerItems: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
|
|
|
|
|
|
") -> dst(bag=", (int)dstBag, " slot=", (int)dstSlot, ")");
|
|
|
|
|
|
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 22:34:22 -08:00
|
|
|
|
void GameHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) {
|
|
|
|
|
|
if (srcBagIndex < 0 || srcBagIndex > 3 || dstBagIndex < 0 || dstBagIndex > 3) return;
|
|
|
|
|
|
if (srcBagIndex == dstBagIndex) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Local swap for immediate visual feedback
|
|
|
|
|
|
auto srcEquip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + srcBagIndex);
|
|
|
|
|
|
auto dstEquip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + dstBagIndex);
|
|
|
|
|
|
auto srcItem = inventory.getEquipSlot(srcEquip).item;
|
|
|
|
|
|
auto dstItem = inventory.getEquipSlot(dstEquip).item;
|
|
|
|
|
|
inventory.setEquipSlot(srcEquip, dstItem);
|
|
|
|
|
|
inventory.setEquipSlot(dstEquip, srcItem);
|
|
|
|
|
|
|
|
|
|
|
|
// Also swap bag contents locally
|
|
|
|
|
|
inventory.swapBagContents(srcBagIndex, dstBagIndex);
|
|
|
|
|
|
|
|
|
|
|
|
// Send to server using CMSG_SWAP_ITEM with INVENTORY_SLOT_BAG_0 (255)
|
|
|
|
|
|
// CMSG_SWAP_INV_ITEM doesn't support bag equip slots (19-22)
|
|
|
|
|
|
if (socket && socket->isConnected()) {
|
|
|
|
|
|
uint8_t srcSlot = static_cast<uint8_t>(19 + srcBagIndex);
|
|
|
|
|
|
uint8_t dstSlot = static_cast<uint8_t>(19 + dstBagIndex);
|
|
|
|
|
|
LOG_INFO("swapBagSlots: bag ", srcBagIndex, " (slot ", (int)srcSlot,
|
|
|
|
|
|
") <-> bag ", dstBagIndex, " (slot ", (int)dstSlot, ")");
|
|
|
|
|
|
auto packet = SwapItemPacket::build(255, dstSlot, 255, srcSlot);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 06:34:06 -08:00
|
|
|
|
void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
if (count == 0) count = 1;
|
|
|
|
|
|
|
|
|
|
|
|
// AzerothCore WotLK expects CMSG_DESTROYITEM(bag:u8, slot:u8, count:u32).
|
|
|
|
|
|
// This opcode is currently not modeled as a logical opcode in our table.
|
|
|
|
|
|
constexpr uint16_t kCmsgDestroyItem = 0x111;
|
|
|
|
|
|
network::Packet packet(kCmsgDestroyItem);
|
|
|
|
|
|
packet.writeUInt8(bag);
|
|
|
|
|
|
packet.writeUInt8(slot);
|
|
|
|
|
|
packet.writeUInt32(static_cast<uint32_t>(count));
|
|
|
|
|
|
LOG_DEBUG("Destroy item request: bag=", (int)bag, " slot=", (int)slot,
|
|
|
|
|
|
" count=", (int)count, " wire=0x", std::hex, kCmsgDestroyItem, std::dec);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 11:07:27 -07:00
|
|
|
|
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=", (int)srcBag, " slot=", (int)srcSlot,
|
|
|
|
|
|
") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")");
|
|
|
|
|
|
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Try equipped bags
|
|
|
|
|
|
for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) {
|
|
|
|
|
|
int bagSize = inventory.getBagSize(b);
|
|
|
|
|
|
for (int s = 0; s < bagSize; s++) {
|
|
|
|
|
|
if (inventory.getBagSlot(b, s).empty()) {
|
|
|
|
|
|
uint8_t dstBag = static_cast<uint8_t>(19 + b);
|
|
|
|
|
|
uint8_t dstSlot = static_cast<uint8_t>(s);
|
|
|
|
|
|
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
|
|
|
|
|
|
") count=", (int)count, " -> dst(bag=", (int)dstBag,
|
|
|
|
|
|
" slot=", (int)dstSlot, ")");
|
|
|
|
|
|
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage("Cannot split: no free inventory slots.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::useItemBySlot(int backpackIndex) {
|
|
|
|
|
|
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
2026-02-26 00:59:07 -08:00
|
|
|
|
if (slot.empty()) return;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
|
2026-02-06 18:52:28 -08:00
|
|
|
|
if (itemGuid == 0) {
|
|
|
|
|
|
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
|
|
|
|
|
}
|
2026-02-17 01:00:04 -08:00
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
|
2026-02-26 00:59:07 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 19:13:38 -08:00
|
|
|
|
// WoW inventory: equipment 0-18, bags 19-22, backpack 23-38
|
2026-02-14 21:29:44 -08:00
|
|
|
|
auto packet = packetParsers_
|
2026-02-26 00:59:07 -08:00
|
|
|
|
? packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId)
|
|
|
|
|
|
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-06 18:52:28 -08:00
|
|
|
|
} else if (itemGuid == 0) {
|
2026-02-26 00:59:07 -08:00
|
|
|
|
addSystemChatMessage("Cannot use that item right now.");
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
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) {
|
2026-02-26 00:59:07 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
// 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_
|
2026-02-26 00:59:07 -08:00
|
|
|
|
? packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
|
|
|
|
|
|
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex,
|
|
|
|
|
|
" packetSize=", packet.getSize());
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
} else if (itemGuid == 0) {
|
|
|
|
|
|
LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex);
|
2026-02-26 00:59:07 -08:00
|
|
|
|
addSystemChatMessage("Cannot use that item right now.");
|
2026-02-17 01:00:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 06:06:29 -07:00
|
|
|
|
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=", (int)wowBag, " slot=", slotIndex);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 19:17:35 -08:00
|
|
|
|
void GameHandler::useItemById(uint32_t itemId) {
|
|
|
|
|
|
if (itemId == 0) return;
|
2026-03-10 04:48:33 -07:00
|
|
|
|
LOG_DEBUG("useItemById: searching for itemId=", itemId);
|
2026-02-17 01:00:04 -08:00
|
|
|
|
// Search backpack first
|
2026-02-06 19:17:35 -08:00
|
|
|
|
for (int i = 0; i < inventory.getBackpackSize(); i++) {
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(i);
|
|
|
|
|
|
if (!slot.empty() && slot.item.itemId == itemId) {
|
2026-03-10 04:48:33 -07:00
|
|
|
|
LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i);
|
2026-02-06 19:17:35 -08:00
|
|
|
|
useItemBySlot(i);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-17 01:00:04 -08:00
|
|
|
|
// 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) {
|
2026-03-10 04:48:33 -07:00
|
|
|
|
LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
|
2026-02-17 01:00:04 -08:00
|
|
|
|
useItemInBag(bag, slot);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_WARNING("useItemById: itemId=", itemId, " not found in inventory");
|
2026-02-06 19:17:35 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
void GameHandler::unstuck() {
|
|
|
|
|
|
if (unstuckCallback_) {
|
|
|
|
|
|
unstuckCallback_();
|
2026-02-08 15:32:04 -08:00
|
|
|
|
addSystemChatMessage("Unstuck: snapped upward. Use /unstuckgy for full teleport.");
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:24:12 -08:00
|
|
|
|
void GameHandler::unstuckGy() {
|
|
|
|
|
|
if (unstuckGyCallback_) {
|
|
|
|
|
|
unstuckGyCallback_();
|
2026-02-08 15:32:04 -08:00
|
|
|
|
addSystemChatMessage("Unstuck: teleported to safe location.");
|
2026-02-08 03:24:12 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:03:28 -08:00
|
|
|
|
void GameHandler::unstuckHearth() {
|
|
|
|
|
|
if (unstuckHearthCallback_) {
|
|
|
|
|
|
unstuckHearthCallback_();
|
|
|
|
|
|
addSystemChatMessage("Unstuck: teleported to hearthstone location.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("No hearthstone bind point set.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleLootResponse(network::Packet& packet) {
|
2026-03-18 08:18:21 -07:00
|
|
|
|
// All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType).
|
|
|
|
|
|
// WotLK adds a quest item list after the regular items.
|
2026-03-11 04:01:07 -07:00
|
|
|
|
const bool wotlkLoot = isActiveExpansion("wotlk");
|
|
|
|
|
|
if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return;
|
2026-03-17 10:12:49 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
lootWindowOpen = true;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("LOOT_OPENED", {});
|
2026-03-13 04:37:36 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo
|
2026-03-14 04:41:46 -07:00
|
|
|
|
pendingGameObjectLootOpens_.erase(
|
|
|
|
|
|
std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(),
|
|
|
|
|
|
[&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }),
|
|
|
|
|
|
pendingGameObjectLootOpens_.end());
|
2026-03-14 07:31:15 -07:00
|
|
|
|
auto& localLoot = localLootState_[currentLoot.lootGuid];
|
|
|
|
|
|
localLoot.data = currentLoot;
|
2026-02-07 14:00:56 -08:00
|
|
|
|
|
|
|
|
|
|
// Query item info so loot window can show names instead of IDs
|
|
|
|
|
|
for (const auto& item : currentLoot.items) {
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 13:22:15 -08:00
|
|
|
|
if (currentLoot.gold > 0) {
|
2026-02-06 23:52:16 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
2026-02-06 19:24:44 -08:00
|
|
|
|
// Auto-loot gold by sending CMSG_LOOT_MONEY (server handles the rest)
|
2026-02-18 04:11:00 -08:00
|
|
|
|
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;
|
2026-02-06 19:24:44 -08:00
|
|
|
|
auto pkt = LootMoneyPacket::build();
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
currentLoot.gold = 0;
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
|
}
|
2026-02-05 13:22:15 -08:00
|
|
|
|
}
|
2026-02-17 16:31:00 -08:00
|
|
|
|
|
|
|
|
|
|
// Auto-loot items when enabled
|
2026-03-14 07:31:15 -07:00
|
|
|
|
if (autoLoot_ && state == WorldState::IN_WORLD && socket && !localLoot.itemAutoLootSent) {
|
2026-02-17 16:31:00 -08:00
|
|
|
|
for (const auto& item : currentLoot.items) {
|
|
|
|
|
|
auto pkt = AutostoreLootItemPacket::build(item.slotIndex);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
2026-03-14 07:31:15 -07:00
|
|
|
|
localLoot.itemAutoLootSent = true;
|
2026-02-17 16:31:00 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLootReleaseResponse(network::Packet& packet) {
|
|
|
|
|
|
(void)packet;
|
2026-02-18 03:37:03 -08:00
|
|
|
|
localLootState_.erase(currentLoot.lootGuid);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
lootWindowOpen = false;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {});
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
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) {
|
2026-02-18 03:37:03 -08:00
|
|
|
|
std::string itemName = "item #" + std::to_string(it->itemId);
|
2026-03-17 13:25:33 -07:00
|
|
|
|
uint32_t quality = 1;
|
2026-02-18 03:37:03 -08:00
|
|
|
|
if (const ItemQueryResponseData* info = getItemInfo(it->itemId)) {
|
2026-03-17 13:25:33 -07:00
|
|
|
|
if (!info->name.empty()) itemName = info->name;
|
|
|
|
|
|
quality = info->quality;
|
2026-02-18 03:37:03 -08:00
|
|
|
|
}
|
2026-03-17 13:25:33 -07:00
|
|
|
|
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);
|
2026-03-17 12:31:38 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playLootItem();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentLoot.items.erase(it);
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)});
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGossipMessage(network::Packet& packet) {
|
2026-02-14 19:38:11 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseGossipMessage(packet, currentGossip)
|
|
|
|
|
|
: GossipMessageParser::parse(packet, currentGossip);
|
|
|
|
|
|
if (!ok) return;
|
2026-02-06 12:08:47 -08:00
|
|
|
|
if (questDetailsOpen) return; // Don't reopen gossip while viewing quest
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
gossipWindowOpen = true;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {});
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
vendorWindowOpen = false; // Close vendor if gossip opens
|
2026-02-09 01:29:44 -08:00
|
|
|
|
|
2026-02-19 02:04:56 -08:00
|
|
|
|
// 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;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-09 23:53:17 -08:00
|
|
|
|
for (const auto& questItem : currentGossip.quests) {
|
2026-02-17 15:57:51 -08:00
|
|
|
|
// 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.
|
2026-02-19 03:31:49 -08:00
|
|
|
|
bool isCompletable = questIconIsCompletable(questItem.questIcon);
|
|
|
|
|
|
bool isIncomplete = questIconIsIncomplete(questItem.questIcon);
|
|
|
|
|
|
bool isAvailable = questIconIsAvailable(questItem.questIcon);
|
2026-02-19 02:04:56 -08:00
|
|
|
|
|
|
|
|
|
|
hasAvailableQuest |= isAvailable;
|
|
|
|
|
|
hasRewardQuest |= isCompletable;
|
|
|
|
|
|
hasIncompleteQuest |= isIncomplete;
|
2026-02-09 23:53:17 -08:00
|
|
|
|
|
2026-02-19 02:04:56 -08:00
|
|
|
|
// Update existing quest entry if present
|
2026-02-09 23:53:17 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 02:04:56 -08:00
|
|
|
|
}
|
2026-02-09 23:53:17 -08:00
|
|
|
|
|
2026-02-19 02:04:56 -08:00
|
|
|
|
// 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;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
if (derivedStatus != QuestGiverStatus::NONE) {
|
|
|
|
|
|
npcQuestStatus_[currentGossip.npcGuid] = derivedStatus;
|
|
|
|
|
|
}
|
2026-02-09 23:53:17 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:29:44 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 02:53:44 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-10 01:54:01 -07:00
|
|
|
|
// 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");
|
2026-02-19 02:53:44 -08:00
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
|
2026-03-10 01:54:01 -07:00
|
|
|
|
if (hasQuestFlagsField && packet.getSize() - packet.getReadPos() >= 5) {
|
2026-02-19 02:53:44 -08:00
|
|
|
|
q.questFlags = packet.readUInt32();
|
|
|
|
|
|
q.isRepeatable = packet.readUInt8();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
q.questFlags = 0;
|
|
|
|
|
|
q.isRepeatable = 0;
|
|
|
|
|
|
}
|
2026-03-10 01:54:01 -07:00
|
|
|
|
q.title = normalizeWowTextTokens(packet.readString());
|
2026-02-19 02:53:44 -08:00
|
|
|
|
if (q.questId != 0) {
|
|
|
|
|
|
data.quests.push_back(std::move(q));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentGossip = std::move(data);
|
|
|
|
|
|
gossipWindowOpen = true;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {});
|
2026-02-19 02:53:44 -08:00
|
|
|
|
vendorWindowOpen = false;
|
|
|
|
|
|
|
|
|
|
|
|
bool hasAvailableQuest = false;
|
|
|
|
|
|
bool hasRewardQuest = false;
|
|
|
|
|
|
bool hasIncompleteQuest = false;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-19 02:53:44 -08:00
|
|
|
|
for (const auto& questItem : currentGossip.quests) {
|
2026-02-19 03:31:49 -08:00
|
|
|
|
bool isCompletable = questIconIsCompletable(questItem.questIcon);
|
|
|
|
|
|
bool isIncomplete = questIconIsIncomplete(questItem.questIcon);
|
|
|
|
|
|
bool isAvailable = questIconIsAvailable(questItem.questIcon);
|
2026-02-19 02:53:44 -08:00
|
|
|
|
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;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
if (derivedStatus != QuestGiverStatus::NONE) {
|
|
|
|
|
|
npcQuestStatus_[currentGossip.npcGuid] = derivedStatus;
|
|
|
|
|
|
}
|
2026-02-19 02:53:44 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Questgiver quest list: npc=0x", std::hex, currentGossip.npcGuid, std::dec,
|
|
|
|
|
|
" quests=", currentGossip.quests.size());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleGossipComplete(network::Packet& packet) {
|
|
|
|
|
|
(void)packet;
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
gossipWindowOpen = false;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {});
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
currentGossip = GossipMessageData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleListInventory(network::Packet& packet) {
|
2026-03-13 03:06:45 -07:00
|
|
|
|
bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (!ListInventoryParser::parse(packet, currentVendorItems)) return;
|
2026-03-13 03:06:45 -07:00
|
|
|
|
|
2026-03-14 05:39:00 -07:00
|
|
|
|
// Check NPC_FLAG_REPAIR (0x1000) on the vendor entity — this handles vendors that open
|
2026-03-13 03:06:45 -07:00
|
|
|
|
// 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);
|
2026-03-14 05:39:00 -07:00
|
|
|
|
// MaNGOS/Trinity: UNIT_NPC_FLAG_REPAIR = 0x00001000.
|
|
|
|
|
|
if (unit->getNpcFlags() & 0x1000) {
|
2026-03-13 03:06:45 -07:00
|
|
|
|
savedCanRepair = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 16:28:59 -07:00
|
|
|
|
currentVendorItems.canRepair = savedCanRepair;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
vendorWindowOpen = true;
|
|
|
|
|
|
gossipWindowOpen = false; // Close gossip if vendor opens
|
2026-03-20 22:22:36 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("MERCHANT_SHOW", {});
|
2026-02-06 11:59:51 -08:00
|
|
|
|
|
2026-03-17 20:21:06 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 20:27:45 -07:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
// Query item info for all vendor items so we can show names
|
|
|
|
|
|
for (const auto& item : currentVendorItems.items) {
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:33:39 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Trainer
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTrainerList(network::Packet& packet) {
|
2026-03-10 01:20:41 -07:00
|
|
|
|
const bool isClassic = isClassicLikeExpansion();
|
|
|
|
|
|
if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return;
|
2026-02-08 14:33:39 -08:00
|
|
|
|
trainerWindowOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRAINER_SHOW", {});
|
2026-02-08 14:33:39 -08:00
|
|
|
|
|
2026-03-10 04:46:42 -07:00
|
|
|
|
LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells");
|
|
|
|
|
|
LOG_DEBUG("Known spells count: ", knownSpells.size());
|
2026-02-10 01:24:37 -08:00
|
|
|
|
if (knownSpells.size() <= 50) {
|
2026-02-09 21:59:00 -08:00
|
|
|
|
std::string spellList;
|
|
|
|
|
|
for (uint32_t id : knownSpells) {
|
|
|
|
|
|
if (!spellList.empty()) spellList += ", ";
|
|
|
|
|
|
spellList += std::to_string(id);
|
|
|
|
|
|
}
|
2026-03-10 04:46:42 -07:00
|
|
|
|
LOG_DEBUG("Known spells: ", spellList);
|
2026-02-09 21:59:00 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 04:46:42 -07:00
|
|
|
|
LOG_DEBUG("Prerequisite check: 527=", knownSpells.count(527u),
|
|
|
|
|
|
" 25312=", knownSpells.count(25312u));
|
2026-02-10 01:24:37 -08:00
|
|
|
|
for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) {
|
|
|
|
|
|
const auto& s = currentTrainerList_.spells[i];
|
2026-03-10 04:46:42 -07:00
|
|
|
|
LOG_DEBUG(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
|
|
|
|
|
|
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
|
|
|
|
|
|
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
|
2026-02-10 01:24:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
// Ensure caches are populated
|
2026-02-08 14:33:39 -08:00
|
|
|
|
loadSpellNameCache();
|
2026-02-08 14:46:01 -08:00
|
|
|
|
loadSkillLineDbc();
|
|
|
|
|
|
loadSkillLineAbilityDbc();
|
|
|
|
|
|
categorizeTrainerSpells();
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::trainSpell(uint32_t spellId) {
|
2026-02-09 21:59:00 -08:00
|
|
|
|
LOG_INFO("trainSpell called: spellId=", spellId, " state=", (int)state, " socket=", (socket ? "yes" : "no"));
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("trainSpell: Not in world or no socket connection");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
|
|
2026-02-09 21:59:00 -08:00
|
|
|
|
LOG_INFO("Sending CMSG_TRAINER_BUY_SPELL: guid=", currentTrainerList_.trainerGuid,
|
2026-02-10 01:24:37 -08:00
|
|
|
|
" spellId=", spellId);
|
2026-02-08 15:03:43 -08:00
|
|
|
|
auto packet = TrainerBuySpellPacket::build(
|
|
|
|
|
|
currentTrainerList_.trainerGuid,
|
|
|
|
|
|
spellId);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-09 21:59:00 -08:00
|
|
|
|
LOG_INFO("CMSG_TRAINER_BUY_SPELL sent");
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeTrainer() {
|
|
|
|
|
|
trainerWindowOpen_ = false;
|
2026-03-21 01:35:18 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRAINER_CLOSED", {});
|
2026-02-08 14:33:39 -08:00
|
|
|
|
currentTrainerList_ = TrainerListData{};
|
2026-02-08 14:46:01 -08:00
|
|
|
|
trainerTabs_.clear();
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:09:21 -07:00
|
|
|
|
// 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(), ")");
|
2026-02-08 14:33:39 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
|
2026-03-09 19:24:09 -07:00
|
|
|
|
|
|
|
|
|
|
// 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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 06:55:16 -07:00
|
|
|
|
// 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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 19:43:19 -07:00
|
|
|
|
// 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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 13:08:41 -07:00
|
|
|
|
// Tooltip/description field
|
|
|
|
|
|
uint32_t tooltipField = 0xFFFFFFFF;
|
|
|
|
|
|
if (spellL) {
|
|
|
|
|
|
uint32_t f = spellL->field("Tooltip");
|
|
|
|
|
|
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:33:39 -08:00
|
|
|
|
uint32_t count = dbc->getRecordCount();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
if (id == 0) continue;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
|
|
|
|
|
|
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
if (!name.empty()) {
|
2026-03-17 19:43:19 -07:00
|
|
|
|
SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0};
|
2026-03-12 13:08:41 -07:00
|
|
|
|
if (tooltipField != 0xFFFFFFFF) {
|
|
|
|
|
|
entry.description = dbc->getString(i, tooltipField);
|
|
|
|
|
|
}
|
2026-03-09 19:24:09 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-12 06:55:16 -07:00
|
|
|
|
if (hasDispelField) {
|
|
|
|
|
|
entry.dispelType = static_cast<uint8_t>(dbc->getUInt32(i, dispelField));
|
|
|
|
|
|
}
|
2026-03-17 19:43:19 -07:00
|
|
|
|
if (hasAttrExField) {
|
|
|
|
|
|
entry.attrEx = dbc->getUInt32(i, attrExField);
|
|
|
|
|
|
}
|
2026-03-09 19:24:09 -07:00
|
|
|
|
spellNameCache_[id] = std::move(entry);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
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()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr;
|
2026-02-08 14:46:01 -08:00
|
|
|
|
for (uint32_t i = 0; i < slaDbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
uint32_t skillLineId = slaDbc->getUInt32(i, slaL ? (*slaL)["SkillLineID"] : 1);
|
|
|
|
|
|
uint32_t spellId = slaDbc->getUInt32(i, slaL ? (*slaL)["SpellID"] : 2);
|
2026-02-08 14:46:01 -08:00
|
|
|
|
if (spellId > 0 && skillLineId > 0) {
|
|
|
|
|
|
spellToSkillLine_[spellId] = skillLineId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Trainer: Loaded ", spellToSkillLine_.size(), " skill line abilities");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::categorizeTrainerSpells() {
|
|
|
|
|
|
trainerTabs_.clear();
|
|
|
|
|
|
|
|
|
|
|
|
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
|
|
|
|
|
|
|
|
|
|
|
|
// Group spells by skill line (category 7 = class spec tabs)
|
|
|
|
|
|
std::map<uint32_t, std::vector<const TrainerSpell*>> specialtySpells;
|
|
|
|
|
|
std::vector<const TrainerSpell*> generalSpells;
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& spell : currentTrainerList_.spells) {
|
|
|
|
|
|
auto slIt = spellToSkillLine_.find(spell.spellId);
|
|
|
|
|
|
if (slIt != spellToSkillLine_.end()) {
|
|
|
|
|
|
uint32_t skillLineId = slIt->second;
|
|
|
|
|
|
auto catIt = skillLineCategories_.find(skillLineId);
|
|
|
|
|
|
if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
|
|
|
|
|
|
specialtySpells[skillLineId].push_back(&spell);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
generalSpells.push_back(&spell);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort by spell name within each group
|
|
|
|
|
|
auto byName = [this](const TrainerSpell* a, const TrainerSpell* b) {
|
|
|
|
|
|
return getSpellName(a->spellId) < getSpellName(b->spellId);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Build named tabs sorted alphabetically
|
|
|
|
|
|
std::vector<std::pair<std::string, std::vector<const TrainerSpell*>>> named;
|
|
|
|
|
|
for (auto& [skillLineId, spells] : specialtySpells) {
|
|
|
|
|
|
auto nameIt = skillLineNames_.find(skillLineId);
|
|
|
|
|
|
std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Specialty";
|
|
|
|
|
|
std::sort(spells.begin(), spells.end(), byName);
|
|
|
|
|
|
named.push_back({std::move(tabName), std::move(spells)});
|
|
|
|
|
|
}
|
|
|
|
|
|
std::sort(named.begin(), named.end(),
|
|
|
|
|
|
[](const auto& a, const auto& b) { return a.first < b.first; });
|
|
|
|
|
|
|
|
|
|
|
|
for (auto& [name, spells] : named) {
|
|
|
|
|
|
trainerTabs_.push_back({std::move(name), std::move(spells)});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// General tab last
|
|
|
|
|
|
if (!generalSpells.empty()) {
|
|
|
|
|
|
std::sort(generalSpells.begin(), generalSpells.end(), byName);
|
|
|
|
|
|
trainerTabs_.push_back({"General", std::move(generalSpells)});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Trainer: Categorized into ", trainerTabs_.size(), " tabs");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
uint32_t count = talentDbc->getRecordCount();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
TalentEntry entry;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.talentId = talentDbc->getUInt32(i, tID);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
if (entry.talentId == 0) continue;
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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));
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
|
|
|
|
|
// Rank spells (1-5 ranks)
|
|
|
|
|
|
for (int r = 0; r < 5; ++r) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.rankSpells[r] = talentDbc->getUInt32(i, tRank0 + r);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prerequisites
|
|
|
|
|
|
for (int p = 0; p < 3; ++p) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.prereqTalent[p] = talentDbc->getUInt32(i, tPrereq0 + p);
|
|
|
|
|
|
entry.prereqRank[p] = static_cast<uint8_t>(talentDbc->getUInt32(i, tPrereqR0 + p));
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* ttL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TalentTab") : nullptr;
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
uint32_t count = tabDbc->getRecordCount();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
TalentTabEntry entry;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.tabId = tabDbc->getUInt32(i, ttL ? (*ttL)["ID"] : 0);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
if (entry.tabId == 0) continue;
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:33:39 -08:00
|
|
|
|
static const std::string EMPTY_STRING;
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& GameHandler::getSpellName(uint32_t spellId) const {
|
|
|
|
|
|
auto it = spellNameCache_.find(spellId);
|
|
|
|
|
|
return (it != spellNameCache_.end()) ? it->second.name : EMPTY_STRING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& GameHandler::getSpellRank(uint32_t spellId) const {
|
|
|
|
|
|
auto it = spellNameCache_.find(spellId);
|
|
|
|
|
|
return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 13:08:41 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 06:55:16 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 19:43:19 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 19:56:52 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 12:01:03 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Single-player local combat
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 12:07:58 -08:00
|
|
|
|
|
2026-02-05 12:01:03 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 12:07:58 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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
|
2026-03-09 17:13:31 -07:00
|
|
|
|
addCombatText(CombatTextEntry::XP_GAIN, static_cast<int32_t>(data.totalXp), 0, true);
|
2026-02-05 13:22:15 -08:00
|
|
|
|
|
2026-03-20 16:21:52 -07:00
|
|
|
|
// 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.";
|
|
|
|
|
|
}
|
2026-02-05 13:22:15 -08:00
|
|
|
|
if (data.groupBonus > 0) {
|
|
|
|
|
|
msg += " (+" + std::to_string(data.groupBonus) + " group bonus)";
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-20 21:47:39 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(data.totalXp)});
|
2026-02-05 13:22:15 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
|
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);
|
2026-03-20 21:47:39 -07:00
|
|
|
|
if (addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("CHAT_MSG_MONEY", {msg});
|
2026-02-05 14:01:26 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 13:22:15 -08:00
|
|
|
|
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);
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
// ============================================================
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Teleport Handler
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTeleportAck(network::Packet& packet) {
|
2026-03-10 00:00:21 -07:00
|
|
|
|
// 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)) {
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 00:00:21 -07:00
|
|
|
|
uint64_t guid = taTbc
|
|
|
|
|
|
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t counter = packet.readUInt32();
|
|
|
|
|
|
|
2026-03-11 04:32:00 -07:00
|
|
|
|
// 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) {
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
packet.readUInt32(); // moveFlags
|
2026-03-11 04:32:00 -07:00
|
|
|
|
if (!taNoFlags2)
|
|
|
|
|
|
packet.readUInt16(); // moveFlags2 (WotLK only)
|
2026-02-07 16:59:20 -08:00
|
|
|
|
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;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Send the ack back to the server
|
|
|
|
|
|
// Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time
|
game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle. Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.
Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks. Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.
Affected: handleForceSpeedChange, handleForceMoveRootState,
handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
|
|
|
|
// Classic/TBC use full uint64 GUID; WotLK uses packed GUID.
|
|
|
|
|
|
if (socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK));
|
2026-02-20 03:38:12 -08:00
|
|
|
|
const bool legacyGuidAck =
|
|
|
|
|
|
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
|
|
|
|
|
|
if (legacyGuidAck) {
|
game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle. Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.
Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks. Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.
Affected: handleForceSpeedChange, handleForceMoveRootState,
handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
|
|
|
|
ack.writeUInt64(playerGuid); // CMaNGOS/VMaNGOS expects full GUID for Classic/TBC
|
2026-02-20 03:38:12 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
MovementPacket::writePackedGuid(ack, playerGuid);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
ack.writeUInt32(counter);
|
|
|
|
|
|
ack.writeUInt32(moveTime);
|
|
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
LOG_INFO("Sent MSG_MOVE_TELEPORT_ACK response");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 02:23:41 -08:00
|
|
|
|
// Notify application of teleport — the callback decides whether to do
|
|
|
|
|
|
// a full world reload (map change) or just update position (same map).
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (worldEntryCallback_) {
|
2026-03-10 08:35:36 -07:00
|
|
|
|
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ, false);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-03-10 04:56:42 -07:00
|
|
|
|
LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId,
|
2026-02-08 03:05:38 -08:00
|
|
|
|
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")",
|
|
|
|
|
|
" orient=", orientation);
|
|
|
|
|
|
|
2026-02-18 04:43:23 -08:00
|
|
|
|
// 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;
|
2026-03-13 04:04:38 -07:00
|
|
|
|
corpseMapId_ = 0;
|
2026-03-14 08:27:32 -07:00
|
|
|
|
corpseGuid_ = 0;
|
2026-02-18 04:43:23 -08:00
|
|
|
|
hostileAttackers_.clear();
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
tabCycleStale = true;
|
2026-03-18 00:59:15 -07:00
|
|
|
|
casting = false;
|
|
|
|
|
|
castIsChannel = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-18 01:15:04 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:59:15 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
2026-02-18 04:43:23 -08:00
|
|
|
|
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
|
|
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK (resurrection)");
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
currentMapId_ = mapId;
|
2026-03-18 12:21:41 -07:00
|
|
|
|
inInstance_ = false; // cleared on map change; re-set if SMSG_INSTANCE_DIFFICULTY follows
|
2026-03-15 01:21:23 -07:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
socket->tracePacketsFor(std::chrono::seconds(12), "new_world");
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
movementInfo.flags = 0;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
movementInfo.flags2 = 0;
|
2026-02-18 23:30:38 -08:00
|
|
|
|
serverMovementAllowed_ = true;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-03-20 18:05:09 -07:00
|
|
|
|
// 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();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
entityManager.clear();
|
|
|
|
|
|
hostileAttackers_.clear();
|
2026-02-18 23:30:38 -08:00
|
|
|
|
worldStates_.clear();
|
2026-03-11 00:24:35 -07:00
|
|
|
|
// 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();
|
2026-02-18 23:30:38 -08:00
|
|
|
|
worldStateMapId_ = mapId;
|
|
|
|
|
|
worldStateZoneId_ = 0;
|
2026-02-26 17:56:11 -08:00
|
|
|
|
activeAreaTriggers_.clear();
|
|
|
|
|
|
areaTriggerCheckTimer_ = -5.0f; // 5-second cooldown after map transfer
|
2026-02-27 04:59:12 -08:00
|
|
|
|
areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire
|
2026-02-08 03:05:38 -08:00
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
casting = false;
|
2026-03-12 00:43:29 -07:00
|
|
|
|
castIsChannel = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
currentCastSpellId = 0;
|
2026-02-19 03:31:49 -08:00
|
|
|
|
pendingGameObjectInteractGuid_ = 0;
|
2026-03-13 05:26:27 -07:00
|
|
|
|
lastInteractedGoGuid_ = 0;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
castTimeRemaining = 0.0f;
|
2026-03-18 01:15:04 -07:00
|
|
|
|
craftQueueSpellId_ = 0;
|
|
|
|
|
|
craftQueueRemaining_ = 0;
|
2026-03-18 00:21:46 -07:00
|
|
|
|
queuedSpellId_ = 0;
|
|
|
|
|
|
queuedSpellTarget_ = 0;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready
|
|
|
|
|
|
if (socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
|
2026-02-08 03:05:38 -08:00
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK");
|
|
|
|
|
|
}
|
2026-03-15 01:21:23 -07:00
|
|
|
|
|
|
|
|
|
|
timeSinceLastPing = 0.0f;
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK");
|
|
|
|
|
|
sendPing();
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-03-11 19:59:42 -07:00
|
|
|
|
// 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.
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (worldEntryCallback_) {
|
2026-03-11 19:59:42 -07:00
|
|
|
|
worldEntryCallback_(mapId, serverX, serverY, serverZ, isSameMap);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* tnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiNodes") : nullptr;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
uint32_t fieldCount = nodesDbc->getFieldCount();
|
2026-02-07 16:59:20 -08:00
|
|
|
|
for (uint32_t i = 0; i < nodesDbc->getRecordCount(); i++) {
|
|
|
|
|
|
TaxiNode node;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-18 20:02:12 -08:00
|
|
|
|
uint32_t nodeId = node.id;
|
|
|
|
|
|
if (nodeId > 0) {
|
|
|
|
|
|
taxiNodes_[nodeId] = std::move(node);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
2026-02-18 20:02:12 -08:00
|
|
|
|
if (nodeId == 195) {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
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()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* tpL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPath") : nullptr;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
for (uint32_t i = 0; i < pathDbc->getRecordCount(); i++) {
|
|
|
|
|
|
TaxiPathEdge edge;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
taxiPathEdges_.push_back(edge);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Loaded ", taxiPathEdges_.size(), " taxi path edges from TaxiPath.dbc");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Could not load TaxiPath.dbc");
|
|
|
|
|
|
}
|
2026-02-08 21:32:38 -08:00
|
|
|
|
|
|
|
|
|
|
auto pathNodeDbc = am->loadDBC("TaxiPathNode.dbc");
|
|
|
|
|
|
if (pathNodeDbc && pathNodeDbc->isLoaded()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* tpnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPathNode") : nullptr;
|
2026-02-08 21:32:38 -08:00
|
|
|
|
for (uint32_t i = 0; i < pathNodeDbc->getRecordCount(); i++) {
|
|
|
|
|
|
TaxiPathNode node;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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);
|
2026-02-08 21:32:38 -08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleShowTaxiNodes(network::Packet& packet) {
|
|
|
|
|
|
ShowTaxiNodesData data;
|
|
|
|
|
|
if (!ShowTaxiNodesParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_SHOWTAXINODES");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadTaxiDbc();
|
|
|
|
|
|
|
2026-02-07 17:59:40 -08:00
|
|
|
|
// 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)) {
|
2026-02-07 19:44:03 -08:00
|
|
|
|
uint32_t nodeId = i * 32 + bit + 1;
|
2026-02-07 17:59:40 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
currentTaxiData_ = data;
|
|
|
|
|
|
taxiNpcGuid_ = data.npcGuid;
|
|
|
|
|
|
taxiWindowOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
2026-02-07 19:04:15 -08:00
|
|
|
|
buildTaxiCostMap();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_INFO("Taxi window opened, nearest node=", data.nearestNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
void GameHandler::applyTaxiMountForCurrentNode() {
|
|
|
|
|
|
if (taxiMountActive_ || !mountCallback_) return;
|
|
|
|
|
|
auto it = taxiNodes_.find(currentTaxiData_.nearestNode);
|
2026-02-14 21:18:36 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (mountId == 541) mountId = 0; // Placeholder/invalid in some DBC sets
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (mountId == 0) {
|
|
|
|
|
|
mountId = isAlliance ? it->second.mountDisplayIdHorde
|
|
|
|
|
|
: it->second.mountDisplayIdAlliance;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (mountId == 541) mountId = 0;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
|
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) {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
|
if (taxiClientPath_.size() < 2) {
|
|
|
|
|
|
LOG_WARNING("Taxi path too short: ", taxiClientPath_.size(), " waypoints");
|
|
|
|
|
|
return;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// 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);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
glm::vec3 dirNorm = dir / dirLen;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
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;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-08 22:05:38 -08:00
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
if (playerEntity) {
|
2026-02-08 22:00:33 -08:00
|
|
|
|
playerEntity->setPosition(start.x, start.y, start.z, initialOrientation);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
}
|
2026-02-08 22:00:33 -08:00
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (taxiOrientationCallback_) {
|
|
|
|
|
|
taxiOrientationCallback_(initialRenderYaw, initialPitch, initialRoll);
|
2026-02-08 22:00:33 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
|
LOG_INFO("Taxi flight started with ", taxiClientPath_.size(), " spline waypoints");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiClientActive_ = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::updateClientTaxi(float deltaTime) {
|
|
|
|
|
|
if (!taxiClientActive_ || taxiClientPath_.size() < 2) return;
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
auto finishTaxiFlight = [&]() {
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
|
// 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, ")");
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
2026-02-08 23:15:26 -08:00
|
|
|
|
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
2026-02-08 20:01:23 -08:00
|
|
|
|
currentMountDisplayId_ = 0;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
if (socket) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Taxi flight landed (client path)");
|
2026-02-11 19:28:15 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
|
|
|
|
|
|
finishTaxiFlight();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 22:00:33 -08:00
|
|
|
|
// 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
|
|
|
|
|
|
);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-08 22:00:33 -08:00
|
|
|
|
// 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
|
|
|
|
|
|
);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 22:00:33 -08:00
|
|
|
|
|
2026-02-08 22:05:38 -08:00
|
|
|
|
// Calculate yaw from horizontal direction
|
2026-02-11 19:28:15 -08:00
|
|
|
|
float targetOrientation = std::atan2(tangent.y, tangent.x);
|
2026-02-08 22:00:33 -08:00
|
|
|
|
|
2026-02-08 22:05:38 -08:00
|
|
|
|
// Calculate pitch from vertical component (altitude change)
|
2026-02-11 18:25:04 -08:00
|
|
|
|
glm::vec3 tangentNorm = tangent / std::max(tangentLen, 0.0001f);
|
2026-02-08 22:05:38 -08:00
|
|
|
|
float pitch = std::asin(std::clamp(tangentNorm.z, -1.0f, 1.0f));
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate roll (banking) from rate of yaw change
|
2026-02-08 22:00:33 -08:00
|
|
|
|
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;
|
2026-02-08 22:05:38 -08:00
|
|
|
|
// 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)
|
2026-02-08 22:00:33 -08:00
|
|
|
|
float smoothOrientation = currentOrientation + orientDiff * std::min(1.0f, deltaTime * 3.0f);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (playerEntity) {
|
|
|
|
|
|
playerEntity->setPosition(nextPos.x, nextPos.y, nextPos.z, smoothOrientation);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
movementInfo.x = nextPos.x;
|
|
|
|
|
|
movementInfo.y = nextPos.y;
|
|
|
|
|
|
movementInfo.z = nextPos.z;
|
2026-02-08 22:00:33 -08:00
|
|
|
|
movementInfo.orientation = smoothOrientation;
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Update mount rotation with yaw/pitch/roll. Use render-space tangent yaw to
|
|
|
|
|
|
// avoid canonical<->render convention mismatches.
|
2026-02-08 22:00:33 -08:00
|
|
|
|
if (taxiOrientationCallback_) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
glm::vec3 renderTangent = core::coords::canonicalToRender(tangent);
|
|
|
|
|
|
float renderYaw = std::atan2(renderTangent.y, renderTangent.x);
|
|
|
|
|
|
taxiOrientationCallback_(renderYaw, pitch, roll);
|
2026-02-08 22:00:33 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
|
|
|
|
|
|
ActivateTaxiReplyData data;
|
|
|
|
|
|
if (!ActivateTaxiReplyParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_ACTIVATETAXIREPLY");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Guard against stray/mis-mapped packets being treated as taxi replies.
|
2026-02-11 22:27:02 -08:00
|
|
|
|
// We only consume a reply while an activation request is pending.
|
|
|
|
|
|
if (!taxiActivatePending_) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
LOG_DEBUG("Ignoring stray taxi reply: result=", data.result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (data.result == 0) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
onTaxiFlight_ = true;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-07 16:59:20 -08:00
|
|
|
|
taxiWindowOpen_ = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
applyTaxiMountForCurrentNode();
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (socket) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_INFO("Taxi flight started!");
|
|
|
|
|
|
} else {
|
2026-02-11 22:27:02 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_WARNING("Taxi activation failed, result=", data.result);
|
|
|
|
|
|
addSystemChatMessage("Cannot take that flight path.");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeTaxi() {
|
|
|
|
|
|
taxiWindowOpen_ = false;
|
2026-02-09 01:08:39 -08:00
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:08:39 -08:00
|
|
|
|
// 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;
|
2026-02-09 01:35:29 -08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:04:15 -08:00
|
|
|
|
void GameHandler::buildTaxiCostMap() {
|
|
|
|
|
|
taxiCostMap_.clear();
|
|
|
|
|
|
uint32_t startNode = currentTaxiData_.nearestNode;
|
|
|
|
|
|
if (startNode == 0) return;
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
// Build adjacency list with costs from all edges (path may traverse unknown nodes)
|
2026-02-07 19:04:15 -08:00
|
|
|
|
struct AdjEntry { uint32_t node; uint32_t cost; };
|
|
|
|
|
|
std::unordered_map<uint32_t, std::vector<AdjEntry>> adj;
|
|
|
|
|
|
for (const auto& edge : taxiPathEdges_) {
|
2026-02-07 19:44:03 -08:00
|
|
|
|
adj[edge.fromNode].push_back({edge.toNode, edge.cost});
|
2026-02-07 19:04:15 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
void GameHandler::activateTaxi(uint32_t destNodeId) {
|
|
|
|
|
|
if (!socket || state != WorldState::IN_WORLD) return;
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// One-shot taxi activation until server replies or timeout.
|
|
|
|
|
|
if (taxiActivatePending_ || onTaxiFlight_) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
uint32_t startNode = currentTaxiData_.nearestNode;
|
|
|
|
|
|
if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return;
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 08:05:16 -07:00
|
|
|
|
{
|
|
|
|
|
|
auto destIt = taxiNodes_.find(destNodeId);
|
2026-03-13 09:44:27 -07:00
|
|
|
|
if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) {
|
|
|
|
|
|
taxiDestName_ = destIt->second.name;
|
2026-03-13 08:05:16 -07:00
|
|
|
|
addSystemChatMessage("Requesting flight to " + destIt->second.name + "...");
|
2026-03-13 09:44:27 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
taxiDestName_.clear();
|
2026-03-13 08:05:16 -07:00
|
|
|
|
addSystemChatMessage("Taxi: requesting flight...");
|
2026-03-13 09:44:27 -07:00
|
|
|
|
}
|
2026-03-13 08:05:16 -07:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-07 20:05:14 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
uint32_t totalCost = getTaxiCostTo(destNodeId);
|
|
|
|
|
|
LOG_INFO("Taxi activate: start=", startNode, " dest=", destNodeId, " cost=", totalCost);
|
2026-02-07 20:05:14 -08:00
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Some servers only accept basic CMSG_ACTIVATETAXI.
|
2026-02-07 20:05:14 -08:00
|
|
|
|
auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId);
|
|
|
|
|
|
socket->send(basicPkt);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// AzerothCore in this setup rejects/misparses CMSG_ACTIVATETAXIEXPRESS (0x312),
|
|
|
|
|
|
// so keep taxi activation on the basic packet only.
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Optimistically start taxi visuals; server will correct if it denies.
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiWindowOpen_ = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = true;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiStartGrace_ = 2.0f;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (!onTaxiFlight_) {
|
|
|
|
|
|
onTaxiFlight_ = true;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
applyTaxiMountForCurrentNode();
|
|
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (socket) {
|
2026-02-20 02:50:59 -08:00
|
|
|
|
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
}
|
2026-02-08 21:32:38 -08:00
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
// Trigger terrain precache immediately (non-blocking).
|
2026-02-08 21:32:38 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
// Flight starts immediately; upload callback stays opportunistic/non-blocking.
|
|
|
|
|
|
if (taxiFlightStartCallback_) {
|
|
|
|
|
|
taxiFlightStartCallback_();
|
|
|
|
|
|
}
|
|
|
|
|
|
startClientTaxiPath(path);
|
2026-02-11 22:27:02 -08:00
|
|
|
|
// We run taxi movement locally immediately; don't keep a long-lived pending state.
|
|
|
|
|
|
if (taxiClientActive_) {
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Save recovery target in case of disconnect during taxi.
|
|
|
|
|
|
auto destIt = taxiNodes_.find(destNodeId);
|
2026-03-13 08:05:16 -07:00
|
|
|
|
if (destIt != taxiNodes_.end() && !destIt->second.name.empty())
|
|
|
|
|
|
addSystemChatMessage("Flight to " + destIt->second.name + " started.");
|
|
|
|
|
|
else
|
|
|
|
|
|
addSystemChatMessage("Flight started.");
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-02-07 12:43:32 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 03:09:52 -07:00
|
|
|
|
totalTimePlayed_ = data.totalTimePlayed;
|
|
|
|
|
|
levelTimePlayed_ = data.levelTimePlayed;
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
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) {
|
2026-03-10 01:08:13 -07:00
|
|
|
|
// 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");
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
uint32_t displayCount = packet.readUInt32();
|
|
|
|
|
|
uint32_t onlineCount = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online");
|
|
|
|
|
|
|
2026-03-12 10:41:18 -07:00
|
|
|
|
// Store structured results for the who-results window
|
|
|
|
|
|
whoResults_.clear();
|
|
|
|
|
|
whoOnlineCount_ = onlineCount;
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
if (displayCount == 0) {
|
|
|
|
|
|
addSystemChatMessage("No players found.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (uint32_t i = 0; i < displayCount; ++i) {
|
2026-03-10 01:08:13 -07:00
|
|
|
|
if (packet.getReadPos() >= packet.getSize()) break;
|
2026-02-07 12:43:32 -08:00
|
|
|
|
std::string playerName = packet.readString();
|
|
|
|
|
|
std::string guildName = packet.readString();
|
2026-03-10 01:08:13 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) break;
|
|
|
|
|
|
uint32_t level = packet.readUInt32();
|
2026-02-07 12:43:32 -08:00
|
|
|
|
uint32_t classId = packet.readUInt32();
|
2026-03-10 01:08:13 -07:00
|
|
|
|
uint32_t raceId = packet.readUInt32();
|
|
|
|
|
|
if (hasGender && packet.getSize() - packet.getReadPos() >= 1)
|
|
|
|
|
|
packet.readUInt8(); // gender (WotLK only, unused)
|
2026-03-10 08:06:21 -07:00
|
|
|
|
uint32_t zoneId = 0;
|
2026-03-10 01:08:13 -07:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4)
|
2026-03-10 08:06:21 -07:00
|
|
|
|
zoneId = packet.readUInt32();
|
2026-02-07 12:43:32 -08:00
|
|
|
|
|
2026-03-12 10:41:18 -07:00
|
|
|
|
// 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));
|
2026-03-12 08:49:18 -07:00
|
|
|
|
|
2026-03-10 08:06:21 -07:00
|
|
|
|
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId,
|
|
|
|
|
|
" Race:", raceId, " Zone:", zoneId);
|
2026-02-07 12:43:32 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 01:15:51 -07:00
|
|
|
|
void GameHandler::handleFriendList(network::Packet& packet) {
|
|
|
|
|
|
// Classic 1.12 / TBC 2.4.3 SMSG_FRIEND_LIST format:
|
|
|
|
|
|
// uint8 count
|
|
|
|
|
|
// for each entry:
|
|
|
|
|
|
// uint64 guid (full)
|
|
|
|
|
|
// uint8 status (0=offline, 1=online, 2=AFK, 3=DND)
|
|
|
|
|
|
// if status != 0:
|
|
|
|
|
|
// uint32 area
|
|
|
|
|
|
// uint32 level
|
|
|
|
|
|
// uint32 class
|
|
|
|
|
|
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
|
|
|
|
|
if (rem() < 1) return;
|
|
|
|
|
|
uint8_t count = packet.readUInt8();
|
|
|
|
|
|
LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries");
|
2026-03-10 05:46:03 -07:00
|
|
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
|
|
2026-03-10 01:15:51 -07:00
|
|
|
|
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);
|
2026-03-10 05:46:03 -07:00
|
|
|
|
std::string name;
|
2026-03-10 01:15:51 -07:00
|
|
|
|
if (nit != playerNameCache.end()) {
|
2026-03-10 05:46:03 -07:00
|
|
|
|
name = nit->second;
|
|
|
|
|
|
friendsCache[name] = guid;
|
|
|
|
|
|
LOG_INFO(" Friend: ", name, " status=", (int)status);
|
2026-03-10 01:15:51 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec,
|
|
|
|
|
|
" status=", (int)status, " (name pending)");
|
|
|
|
|
|
queryPlayerName(guid);
|
|
|
|
|
|
}
|
2026-03-10 05:46:03 -07:00
|
|
|
|
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));
|
2026-03-10 01:15:51 -07:00
|
|
|
|
}
|
2026-03-21 03:01:55 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {});
|
2026-03-10 01:15:51 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-10 05:46:03 -07:00
|
|
|
|
contacts_.clear();
|
2026-03-10 01:15:51 -07:00
|
|
|
|
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
|
2026-03-10 05:46:03 -07:00
|
|
|
|
uint8_t status = 0;
|
|
|
|
|
|
uint32_t areaId = 0;
|
|
|
|
|
|
uint32_t level = 0;
|
|
|
|
|
|
uint32_t classId = 0;
|
2026-03-10 01:15:51 -07:00
|
|
|
|
if (flags & 0x1) { // SOCIAL_FLAG_FRIEND
|
|
|
|
|
|
if (rem() < 1) break;
|
2026-03-10 05:46:03 -07:00
|
|
|
|
status = packet.readUInt8();
|
2026-03-10 01:15:51 -07:00
|
|
|
|
if (status != 0 && rem() >= 12) {
|
2026-03-10 05:46:03 -07:00
|
|
|
|
areaId = packet.readUInt32();
|
|
|
|
|
|
level = packet.readUInt32();
|
|
|
|
|
|
classId = packet.readUInt32();
|
2026-03-10 01:15:51 -07:00
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-03-10 05:46:03 -07:00
|
|
|
|
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));
|
2026-03-10 01:15:51 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_,
|
|
|
|
|
|
" count=", lastContactListCount_);
|
2026-03-21 03:01:55 -07:00
|
|
|
|
if (addonEventCallback_) {
|
|
|
|
|
|
addonEventCallback_("FRIENDLIST_UPDATE", {});
|
|
|
|
|
|
if (lastContactListMask_ & 0x2) // ignore list
|
|
|
|
|
|
addonEventCallback_("IGNORELIST_UPDATE", {});
|
|
|
|
|
|
}
|
2026-03-10 01:15:51 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
void GameHandler::handleFriendStatus(network::Packet& packet) {
|
|
|
|
|
|
FriendStatusData data;
|
|
|
|
|
|
if (!FriendStatusParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_FRIEND_STATUS");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 07:20:58 -07:00
|
|
|
|
// Look up player name: contacts_ (populated by SMSG_FRIEND_LIST) > playerNameCache
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
std::string playerName;
|
2026-03-13 07:20:58 -07:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 05:48:37 -07:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
// Status messages
|
|
|
|
|
|
switch (data.status) {
|
|
|
|
|
|
case 0:
|
|
|
|
|
|
addSystemChatMessage(playerName + " has been removed from your friends list.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
addSystemChatMessage(playerName + " has been added to your friends list.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is now online.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is now offline.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 4:
|
|
|
|
|
|
addSystemChatMessage("Player not found.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 5:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is already in your friends list.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 6:
|
|
|
|
|
|
addSystemChatMessage("Your friends list is full.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 7:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is ignoring you.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
LOG_INFO("Friend status: ", (int)data.status, " for ", playerName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Friend status update: ", playerName, " status=", (int)data.status);
|
2026-03-21 03:01:55 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {});
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:58:11 -08:00
|
|
|
|
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...");
|
2026-03-13 10:13:54 -07:00
|
|
|
|
logoutCountdown_ = 0.0f;
|
2026-02-07 12:58:11 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Logging out in 20 seconds...");
|
2026-03-13 10:13:54 -07:00
|
|
|
|
logoutCountdown_ = 20.0f;
|
2026-02-07 12:58:11 -08:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Logout response: success, instant=", (int)data.instant);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Failure
|
|
|
|
|
|
addSystemChatMessage("Cannot logout right now.");
|
|
|
|
|
|
loggingOut_ = false;
|
2026-03-13 10:13:54 -07:00
|
|
|
|
logoutCountdown_ = 0.0f;
|
2026-02-07 12:58:11 -08:00
|
|
|
|
LOG_WARNING("Logout failed, result=", data.result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) {
|
|
|
|
|
|
addSystemChatMessage("Logout complete.");
|
|
|
|
|
|
loggingOut_ = false;
|
2026-03-13 10:13:54 -07:00
|
|
|
|
logoutCountdown_ = 0.0f;
|
2026-02-07 12:58:11 -08:00
|
|
|
|
LOG_INFO("Logout complete");
|
|
|
|
|
|
// Server will disconnect us
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
uint32_t GameHandler::generateClientSeed() {
|
|
|
|
|
|
// Generate cryptographically random seed
|
|
|
|
|
|
std::random_device rd;
|
|
|
|
|
|
std::mt19937 gen(rd());
|
|
|
|
|
|
std::uniform_int_distribution<uint32_t> dis(1, 0xFFFFFFFF);
|
|
|
|
|
|
return dis(gen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setState(WorldState newState) {
|
|
|
|
|
|
if (state != newState) {
|
|
|
|
|
|
LOG_DEBUG("World state: ", (int)state, " -> ", (int)newState);
|
|
|
|
|
|
state = newState;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::fail(const std::string& reason) {
|
|
|
|
|
|
LOG_ERROR("World connection failed: ", reason);
|
|
|
|
|
|
setState(WorldState::FAILED);
|
|
|
|
|
|
|
|
|
|
|
|
if (onFailure) {
|
|
|
|
|
|
onFailure(reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 10:12:49 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
|
2026-02-07 14:21:50 -08:00
|
|
|
|
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
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);
|
2026-02-07 14:21:50 -08:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t PLAYER_SKILL_INFO_START = fieldIndex(UF::PLAYER_SKILL_INFO_START);
|
2026-02-07 14:21:50 -08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-13 10:58:05 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
PlayerSkill skill;
|
|
|
|
|
|
skill.skillId = skillId;
|
|
|
|
|
|
skill.value = value;
|
|
|
|
|
|
skill.maxValue = maxValue;
|
2026-03-13 10:58:05 -07:00
|
|
|
|
skill.bonusTemp = bonusTemp;
|
|
|
|
|
|
skill.bonusPerm = bonusPerm;
|
2026-02-07 14:21:50 -08:00
|
|
|
|
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) {
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
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) + ".");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 21:57:27 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 14:21:50 -08:00
|
|
|
|
playerSkills_ = std::move(newSkills);
|
2026-03-20 21:57:27 -07:00
|
|
|
|
if (skillsChanged && addonEventCallback_)
|
|
|
|
|
|
addonEventCallback_("SKILL_LINES_CHANGED", {});
|
2026-02-07 14:21:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
void GameHandler::extractExploredZoneFields(const std::map<uint16_t, uint32_t>& fields) {
|
2026-03-10 23:18:16 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) {
|
|
|
|
|
|
playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool foundAny = false;
|
2026-03-10 23:18:16 -07:00
|
|
|
|
for (size_t i = 0; i < zoneCount; i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t fieldIdx = static_cast<uint16_t>(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
auto it = fields.find(fieldIdx);
|
|
|
|
|
|
if (it == fields.end()) continue;
|
|
|
|
|
|
playerExploredZones_[i] = it->second;
|
|
|
|
|
|
foundAny = true;
|
|
|
|
|
|
}
|
2026-03-10 23:18:16 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
|
|
|
|
|
|
if (foundAny) {
|
|
|
|
|
|
hasPlayerExploredZones_ = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 02:07:59 -07:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
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";
|
2026-02-09 17:39:21 -08:00
|
|
|
|
out << "gender=" << static_cast<int>(ch->gender) << "\n";
|
2026-02-18 22:36:34 -08:00
|
|
|
|
// 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";
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
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";
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
2026-03-18 02:44:28 -07:00
|
|
|
|
// Save client-side macro text (escape newlines as \n literal)
|
2026-03-18 02:07:59 -07:00
|
|
|
|
for (const auto& [id, text] : macros_) {
|
2026-03-18 02:44:28 -07:00
|
|
|
|
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";
|
|
|
|
|
|
}
|
2026-03-18 02:07:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// 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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 09:44:41 -07:00
|
|
|
|
// 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";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
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;
|
2026-02-09 17:39:21 -08:00
|
|
|
|
int savedGender = -1;
|
2026-02-09 17:56:04 -08:00
|
|
|
|
int savedUseFemaleModel = -1;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
|
|
|
|
|
|
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 (...) {}
|
2026-02-09 17:39:21 -08:00
|
|
|
|
} else if (key == "gender") {
|
|
|
|
|
|
try { savedGender = std::stoi(val); } catch (...) {}
|
2026-02-09 17:56:04 -08:00
|
|
|
|
} else if (key == "use_female_model") {
|
|
|
|
|
|
try { savedUseFemaleModel = std::stoi(val); } catch (...) {}
|
2026-03-18 02:07:59 -07:00
|
|
|
|
} 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; }
|
2026-03-18 02:44:28 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-20 09:44:41 -07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:56:04 -08:00
|
|
|
|
// Apply saved gender and body type (allows nonbinary to persist even though server only stores male/female)
|
2026-02-09 17:39:21 -08:00
|
|
|
|
if (savedGender >= 0 && savedGender <= 2) {
|
|
|
|
|
|
for (auto& character : characters) {
|
|
|
|
|
|
if (character.guid == playerGuid) {
|
|
|
|
|
|
character.gender = static_cast<Gender>(savedGender);
|
2026-02-18 22:36:34 -08:00
|
|
|
|
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);
|
2026-02-09 17:56:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Applied saved gender: ", getGenderName(character.gender),
|
|
|
|
|
|
", body type: ", (character.useFemaleModel ? "feminine" : "masculine"));
|
2026-02-09 17:39:21 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Mail System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeMailbox() {
|
2026-03-20 22:43:29 -07:00
|
|
|
|
bool wasOpen = mailboxOpen_;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
mailboxOpen_ = false;
|
|
|
|
|
|
mailboxGuid_ = 0;
|
|
|
|
|
|
mailInbox_.clear();
|
|
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
2026-03-20 22:43:29 -07:00
|
|
|
|
if (wasOpen && addonEventCallback_) addonEventCallback_("MAIL_CLOSED", {});
|
2026-02-15 14:00:41 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-02-16 18:46:44 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-25 14:11:09 -08:00
|
|
|
|
// 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);
|
2026-02-16 18:46:44 -08:00
|
|
|
|
LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money,
|
2026-02-25 14:11:09 -08:00
|
|
|
|
" attachments=", itemGuids.size(), " mailboxGuid=", mailboxGuid_);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-25 14:11:09 -08:00
|
|
|
|
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;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::mailTakeMoney(uint32_t mailId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
|
|
|
|
|
auto packet = MailTakeMoneyPacket::build(mailboxGuid_, mailId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 07:11:18 -07:00
|
|
|
|
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemGuidLow) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
2026-03-14 07:11:18 -07:00
|
|
|
|
auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemGuidLow);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-16 18:46:44 -08:00
|
|
|
|
auto packet = packetParsers_->buildMailDelete(mailboxGuid_, mailId, templateId);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
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;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
hasNewMail_ = false;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
2026-03-20 22:43:29 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("MAIL_SHOW", {});
|
2026-02-15 14:00:41 -08:00
|
|
|
|
// Request inbox contents
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleMailListResult(network::Packet& packet) {
|
|
|
|
|
|
size_t remaining = packet.getSize() - packet.getReadPos();
|
2026-02-16 18:46:44 -08:00
|
|
|
|
if (remaining < 1) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
LOG_WARNING("SMSG_MAIL_LIST_RESULT too short (", remaining, " bytes)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Delegate parsing to expansion-aware packet parser
|
|
|
|
|
|
packetParsers_->parseMailList(packet, mailInbox_);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Resolve sender names (needs GameHandler context, so done here)
|
|
|
|
|
|
for (auto& msg : mailInbox_) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
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";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-15 14:00:41 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Vanilla errors: 0=OK, 1=equipError, 2=cannotSendToSelf, 3=notEnoughMoney, 4=recipientNotFound, 5=notYourTeam, 6=internalError
|
2026-02-15 14:00:41 -08:00
|
|
|
|
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;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
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;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
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!");
|
2026-02-16 18:46:44 -08:00
|
|
|
|
hasNewMail_ = true;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
addSystemChatMessage("New mail has arrived.");
|
|
|
|
|
|
// If mailbox is open, refresh
|
|
|
|
|
|
if (mailboxOpen_) {
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Bank System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openBank(uint64_t guid) {
|
|
|
|
|
|
if (!isConnected()) return;
|
|
|
|
|
|
auto pkt = BankerActivatePacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeBank() {
|
2026-03-20 22:32:21 -07:00
|
|
|
|
bool wasOpen = bankOpen_;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
bankOpen_ = false;
|
|
|
|
|
|
bankerGuid_ = 0;
|
2026-03-20 22:32:21 -07:00
|
|
|
|
if (wasOpen && addonEventCallback_) addonEventCallback_("BANKFRAME_CLOSED", {});
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::buyBankSlot() {
|
2026-02-26 13:38:29 -08:00
|
|
|
|
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()));
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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
|
2026-03-20 22:32:21 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("BANKFRAME_OPENED", {});
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
// Bank items are already tracked via update fields (bank slot GUIDs)
|
|
|
|
|
|
// Trigger rebuild to populate bank slots in inventory
|
|
|
|
|
|
rebuildOnlineInventory();
|
2026-02-26 13:38:29 -08:00
|
|
|
|
// Count bank bags that actually have items/containers
|
|
|
|
|
|
int filledBags = 0;
|
|
|
|
|
|
for (int i = 0; i < effectiveBankBagSlots_; i++) {
|
|
|
|
|
|
if (inventory.getBankBagSize(i) > 0) filledBags++;
|
|
|
|
|
|
}
|
2026-03-10 04:56:42 -07:00
|
|
|
|
LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec,
|
2026-02-26 13:38:29 -08:00
|
|
|
|
" purchased=", static_cast<int>(inventory.getPurchasedBankBagSlots()),
|
|
|
|
|
|
" filledBags=", filledBags,
|
|
|
|
|
|
" effectiveBankBagSlots=", effectiveBankBagSlots_);
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleBuyBankSlotResult(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
2026-03-10 04:56:42 -07:00
|
|
|
|
LOG_INFO("SMSG_BUY_BANK_SLOT_RESULT: result=", result);
|
2026-02-26 13:38:29 -08:00
|
|
|
|
// AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK
|
|
|
|
|
|
if (result == 3) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
addSystemChatMessage("Bank slot purchased.");
|
|
|
|
|
|
inventory.setPurchasedBankBagSlots(inventory.getPurchasedBankBagSlots() + 1);
|
2026-02-26 13:38:29 -08:00
|
|
|
|
} 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.");
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
} else {
|
2026-02-26 13:38:29 -08:00
|
|
|
|
addSystemChatMessage("Cannot purchase bank slot (error " + std::to_string(result) + ").");
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Guild Bank System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openGuildBank(uint64_t guid) {
|
|
|
|
|
|
if (!isConnected()) return;
|
|
|
|
|
|
auto pkt = GuildBankerActivatePacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeGuildBank() {
|
|
|
|
|
|
guildBankOpen_ = false;
|
|
|
|
|
|
guildBankerGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryGuildBankTab(uint8_t tabId) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
guildBankActiveTab_ = tabId;
|
|
|
|
|
|
auto pkt = GuildBankQueryTabPacket::build(guildBankerGuid_, tabId, true);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::buyGuildBankTab() {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
uint8_t nextTab = static_cast<uint8_t>(guildBankData_.tabs.size());
|
|
|
|
|
|
auto pkt = GuildBankBuyTabPacket::build(guildBankerGuid_, nextTab);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::depositGuildBankMoney(uint32_t amount) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankDepositMoneyPacket::build(guildBankerGuid_, amount);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::withdrawGuildBankMoney(uint32_t amount) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankWithdrawMoneyPacket::build(guildBankerGuid_, amount);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::guildBankWithdrawItem(uint8_t tabId, uint8_t bankSlot, uint8_t destBag, uint8_t destSlot) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankSwapItemsPacket::buildBankToInventory(guildBankerGuid_, tabId, bankSlot, destBag, destSlot);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::guildBankDepositItem(uint8_t tabId, uint8_t bankSlot, uint8_t srcBag, uint8_t srcSlot) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankSwapItemsPacket::buildInventoryToBank(guildBankerGuid_, tabId, bankSlot, srcBag, srcSlot);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildBankList(network::Packet& packet) {
|
|
|
|
|
|
GuildBankData data;
|
|
|
|
|
|
if (!GuildBankListParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_GUILD_BANK_LIST");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
guildBankData_ = data;
|
|
|
|
|
|
guildBankOpen_ = true;
|
|
|
|
|
|
guildBankActiveTab_ = data.tabId;
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure item info for all guild bank items
|
|
|
|
|
|
for (const auto& item : data.tabItems) {
|
|
|
|
|
|
if (item.itemEntry != 0) ensureItemInfo(item.itemEntry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_GUILD_BANK_LIST: tab=", (int)data.tabId,
|
|
|
|
|
|
" items=", data.tabItems.size(),
|
|
|
|
|
|
" tabs=", data.tabs.size(),
|
|
|
|
|
|
" money=", data.money);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Auction House System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openAuctionHouse(uint64_t guid) {
|
|
|
|
|
|
if (!isConnected()) return;
|
|
|
|
|
|
auto pkt = AuctionHelloPacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeAuctionHouse() {
|
2026-03-20 22:43:29 -07:00
|
|
|
|
bool wasOpen = auctionOpen_;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
auctionOpen_ = false;
|
|
|
|
|
|
auctioneerGuid_ = 0;
|
2026-03-20 22:43:29 -07:00
|
|
|
|
if (wasOpen && addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_CLOSED", {});
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-25 14:44:44 -08:00
|
|
|
|
// Save search params for pagination and auto-refresh
|
|
|
|
|
|
lastAuctionSearch_ = {name, levelMin, levelMax, quality, itemClass, itemSubClass, invTypeMask, usableOnly, offset};
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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
|
2026-03-20 22:43:29 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_SHOW", {});
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
auctionActiveTab_ = 0;
|
|
|
|
|
|
auctionBrowseResults_ = AuctionListResult{};
|
|
|
|
|
|
auctionOwnerResults_ = AuctionListResult{};
|
|
|
|
|
|
auctionBidderResults_ = AuctionListResult{};
|
|
|
|
|
|
LOG_INFO("MSG_AUCTION_HELLO: auctioneer=0x", std::hex, data.auctioneerGuid, std::dec,
|
|
|
|
|
|
" house=", data.auctionHouseId, " enabled=", (int)data.enabled);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuctionListResult(network::Packet& packet) {
|
2026-03-10 01:25:27 -07:00
|
|
|
|
// Classic 1.12 has 1 enchant slot per auction entry; TBC/WotLK have 3.
|
|
|
|
|
|
const int enchSlots = isClassicLikeExpansion() ? 1 : 3;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
AuctionListResult result;
|
2026-03-10 01:25:27 -07:00
|
|
|
|
if (!AuctionListResultParser::parse(packet, result, enchSlots)) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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) {
|
2026-03-10 01:25:27 -07:00
|
|
|
|
const int enchSlots = isClassicLikeExpansion() ? 1 : 3;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
AuctionListResult result;
|
2026-03-10 01:25:27 -07:00
|
|
|
|
if (!AuctionListResultParser::parse(packet, result, enchSlots)) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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) {
|
2026-03-10 01:25:27 -07:00
|
|
|
|
const int enchSlots = isClassicLikeExpansion() ? 1 : 3;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
AuctionListResult result;
|
2026-03-10 01:25:27 -07:00
|
|
|
|
if (!AuctionListResultParser::parse(packet, result, enchSlots)) {
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
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);
|
2026-02-25 14:44:44 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
} 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;
|
2026-03-17 17:56:53 -07:00
|
|
|
|
addUIError(msg);
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName,
|
|
|
|
|
|
" error=", result.errorCode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:15:59 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Item text (SMSG_ITEM_TEXT_QUERY_RESPONSE)
|
|
|
|
|
|
// uint64 itemGuid + uint8 isEmpty + string text (when !isEmpty)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleItemTextQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
size_t rem = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (rem < 9) return; // guid(8) + isEmpty(1)
|
|
|
|
|
|
|
|
|
|
|
|
/*uint64_t guid =*/ packet.readUInt64();
|
|
|
|
|
|
uint8_t isEmpty = packet.readUInt8();
|
|
|
|
|
|
if (!isEmpty) {
|
|
|
|
|
|
itemText_ = packet.readString();
|
|
|
|
|
|
itemTextOpen_= !itemText_.empty();
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_ITEM_TEXT_QUERY_RESPONSE: isEmpty=", (int)isEmpty,
|
|
|
|
|
|
" len=", itemText_.size());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryItemText(uint64_t itemGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_ITEM_TEXT_QUERY));
|
|
|
|
|
|
pkt.writeUInt64(itemGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_DEBUG("CMSG_ITEM_TEXT_QUERY: guid=0x", std::hex, itemGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:14:15 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-03-13 07:10:10 -07:00
|
|
|
|
if (sharedQuestSharerName_.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(sharedQuestSharerGuid_);
|
|
|
|
|
|
if (nit != playerNameCache.end())
|
|
|
|
|
|
sharedQuestSharerName_ = nit->second;
|
|
|
|
|
|
}
|
2026-03-09 14:14:15 -07:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:07:50 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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();
|
2026-03-13 06:37:26 -07:00
|
|
|
|
uint32_t zoneId = packet.readUInt32();
|
2026-03-09 14:07:50 -07:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-03-13 06:37:26 -07:00
|
|
|
|
if (summonerName_.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(summonerGuid_);
|
|
|
|
|
|
if (nit != playerNameCache.end())
|
|
|
|
|
|
summonerName_ = nit->second;
|
|
|
|
|
|
}
|
2026-03-09 14:07:50 -07:00
|
|
|
|
if (summonerName_.empty()) {
|
|
|
|
|
|
char tmp[32];
|
|
|
|
|
|
std::snprintf(tmp, sizeof(tmp), "0x%llX",
|
|
|
|
|
|
static_cast<unsigned long long>(summonerGuid_));
|
|
|
|
|
|
summonerName_ = tmp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 06:37:26 -07:00
|
|
|
|
std::string msg = summonerName_ + " is summoning you";
|
|
|
|
|
|
std::string zoneName = getAreaName(zoneId);
|
|
|
|
|
|
if (!zoneName.empty())
|
|
|
|
|
|
msg += " to " + zoneName;
|
|
|
|
|
|
msg += '.';
|
|
|
|
|
|
addSystemChatMessage(msg);
|
2026-03-09 14:07:50 -07:00
|
|
|
|
LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_,
|
2026-03-13 06:37:26 -07:00
|
|
|
|
" zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s");
|
2026-03-09 14:07:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-03-13 06:37:26 -07:00
|
|
|
|
if (tradePeerName_.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(tradePeerGuid_);
|
|
|
|
|
|
if (nit != playerNameCache.end())
|
|
|
|
|
|
tradePeerName_ = nit->second;
|
|
|
|
|
|
}
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
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.");
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRADE_REQUEST", {});
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 2: // OPEN_WINDOW
|
2026-03-11 00:44:07 -07:00
|
|
|
|
myTradeSlots_.fill(TradeSlot{});
|
|
|
|
|
|
peerTradeSlots_.fill(TradeSlot{});
|
|
|
|
|
|
myTradeGold_ = 0;
|
|
|
|
|
|
peerTradeGold_ = 0;
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
tradeStatus_ = TradeStatus::Open;
|
|
|
|
|
|
addSystemChatMessage("Trade window opened.");
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRADE_SHOW", {});
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
break;
|
2026-03-13 06:30:30 -07:00
|
|
|
|
case 3: // CANCELLED
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
case 12: // CLOSE_WINDOW
|
2026-03-11 00:44:07 -07:00
|
|
|
|
resetTradeState();
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
addSystemChatMessage("Trade cancelled.");
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {});
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
break;
|
2026-03-13 06:30:30 -07:00
|
|
|
|
case 9: // REJECTED — other player clicked Decline
|
|
|
|
|
|
resetTradeState();
|
|
|
|
|
|
addSystemChatMessage("Trade declined.");
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {});
|
2026-03-13 06:30:30 -07:00
|
|
|
|
break;
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
case 4: // ACCEPTED (partner accepted)
|
|
|
|
|
|
tradeStatus_ = TradeStatus::Accepted;
|
|
|
|
|
|
addSystemChatMessage("Trade accepted. Awaiting other player...");
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRADE_ACCEPT_UPDATE", {});
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
break;
|
|
|
|
|
|
case 8: // COMPLETE
|
|
|
|
|
|
addSystemChatMessage("Trade complete!");
|
2026-03-21 02:57:00 -07:00
|
|
|
|
if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {});
|
2026-03-11 00:44:07 -07:00
|
|
|
|
resetTradeState();
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
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() {
|
2026-03-14 07:02:52 -07:00
|
|
|
|
if (!isTradeOpen() || !socket) return;
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
tradeStatus_ = TradeStatus::Accepted;
|
|
|
|
|
|
socket->send(AcceptTradePacket::build());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::cancelTrade() {
|
|
|
|
|
|
if (!socket) return;
|
2026-03-11 00:44:07 -07:00
|
|
|
|
resetTradeState();
|
Implement basic trade request/accept/decline flow
- Parse SMSG_TRADE_STATUS for all 20+ status codes: incoming request,
open/cancel/complete/accept notifications, error conditions (too far,
wrong faction, stunned, dead, trial account, etc.)
- SMSG_TRADE_STATUS_EXTENDED consumed via shared handler (no full item
window yet; state tracking sufficient for accept/decline flow)
- Add acceptTradeRequest() (CMSG_BEGIN_TRADE), declineTradeRequest(),
acceptTrade() (CMSG_ACCEPT_TRADE), cancelTrade() (CMSG_CANCEL_TRADE)
- Add BeginTradePacket, CancelTradePacket, AcceptTradePacket builders
- Add renderTradeRequestPopup(): shows "X wants to trade" with
Accept/Decline buttons when tradeStatus_ == PendingIncoming
- TradeStatus enum tracks None/PendingIncoming/Open/Accepted/Complete
2026-03-09 14:05:42 -07:00
|
|
|
|
socket->send(CancelTradePacket::build());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:44:07 -07:00
|
|
|
|
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) {
|
2026-03-17 22:42:20 -07:00
|
|
|
|
// 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;
|
2026-03-11 00:44:07 -07:00
|
|
|
|
|
2026-03-17 22:42:20 -07:00
|
|
|
|
uint8_t isSelf = packet.readUInt8();
|
|
|
|
|
|
if (isWotLK) {
|
|
|
|
|
|
/*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t slotCount = packet.readUInt32();
|
2026-03-11 00:44:07 -07:00
|
|
|
|
|
2026-03-17 22:42:20 -07:00
|
|
|
|
// 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;
|
2026-03-11 00:44:07 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:46:11 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:44:07 -07:00
|
|
|
|
LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf,
|
|
|
|
|
|
" myGold=", myTradeGold_, " peerGold=", peerTradeGold_);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:53:42 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-09 14:01:27 -07:00
|
|
|
|
// Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLootRoll(network::Packet& packet) {
|
2026-03-11 05:09:43 -07:00
|
|
|
|
// 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;
|
2026-03-09 14:01:27 -07:00
|
|
|
|
size_t rem = packet.getSize() - packet.getReadPos();
|
2026-03-11 05:09:43 -07:00
|
|
|
|
if (rem < minSize) return;
|
2026-03-09 14:01:27 -07:00
|
|
|
|
|
|
|
|
|
|
uint64_t objectGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t slot = packet.readUInt32();
|
|
|
|
|
|
uint64_t rollerGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
2026-03-11 05:09:43 -07:00
|
|
|
|
if (isWotLK) {
|
|
|
|
|
|
/*uint32_t randSuffix =*/ packet.readUInt32();
|
|
|
|
|
|
/*uint32_t randProp =*/ packet.readUInt32();
|
|
|
|
|
|
}
|
2026-03-09 14:01:27 -07:00
|
|
|
|
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;
|
2026-03-12 08:59:38 -07:00
|
|
|
|
pendingLootRoll_.playerRolls.clear();
|
2026-03-10 20:59:02 -07:00
|
|
|
|
// Ensure item info is in cache; query if not
|
|
|
|
|
|
queryItemInfo(itemId, 0);
|
2026-03-09 14:01:27 -07:00
|
|
|
|
// 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;
|
2026-03-12 04:57:36 -07:00
|
|
|
|
pendingLootRoll_.rollCountdownMs = 60000;
|
|
|
|
|
|
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
|
2026-03-09 14:01:27 -07:00
|
|
|
|
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";
|
|
|
|
|
|
|
2026-03-12 08:59:38 -07:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:01:27 -07:00
|
|
|
|
auto* info = getItemInfo(itemId);
|
2026-03-17 13:33:07 -07:00
|
|
|
|
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);
|
2026-03-09 14:01:27 -07:00
|
|
|
|
|
2026-03-17 13:33:07 -07:00
|
|
|
|
addSystemChatMessage(rollerName + " rolls " + rollName + " (" + std::to_string(rollNum) + ") on " + rollItemLink);
|
2026-03-09 14:01:27 -07:00
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("SMSG_LOOT_ROLL: ", rollerName, " rolled ", rollName,
|
|
|
|
|
|
" (", rollNum, ") on item ", itemId);
|
|
|
|
|
|
(void)objectGuid; (void)slot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLootRollWon(network::Packet& packet) {
|
2026-03-11 05:09:43 -07:00
|
|
|
|
// 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;
|
2026-03-09 14:01:27 -07:00
|
|
|
|
size_t rem = packet.getSize() - packet.getReadPos();
|
2026-03-11 05:09:43 -07:00
|
|
|
|
if (rem < minSize) return;
|
2026-03-09 14:01:27 -07:00
|
|
|
|
|
|
|
|
|
|
/*uint64_t objectGuid =*/ packet.readUInt64();
|
|
|
|
|
|
/*uint32_t slot =*/ packet.readUInt32();
|
|
|
|
|
|
uint64_t winnerGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t itemId = packet.readUInt32();
|
2026-03-20 19:22:59 -07:00
|
|
|
|
int32_t wonRandProp = 0;
|
2026-03-11 05:09:43 -07:00
|
|
|
|
if (isWotLK) {
|
|
|
|
|
|
/*uint32_t randSuffix =*/ packet.readUInt32();
|
2026-03-20 19:22:59 -07:00
|
|
|
|
wonRandProp = static_cast<int32_t>(packet.readUInt32());
|
2026-03-11 05:09:43 -07:00
|
|
|
|
}
|
2026-03-09 14:01:27 -07:00
|
|
|
|
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);
|
2026-03-17 13:33:07 -07:00
|
|
|
|
std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId);
|
2026-03-20 19:22:59 -07:00
|
|
|
|
if (wonRandProp != 0) {
|
|
|
|
|
|
std::string suffix = getRandomPropertyName(wonRandProp);
|
|
|
|
|
|
if (!suffix.empty()) iName += " " + suffix;
|
|
|
|
|
|
}
|
2026-03-17 13:33:07 -07:00
|
|
|
|
uint32_t wonItemQuality = info ? info->quality : 1u;
|
|
|
|
|
|
std::string wonItemLink = buildItemLink(itemId, wonItemQuality, iName);
|
2026-03-09 14:01:27 -07:00
|
|
|
|
|
2026-03-17 13:33:07 -07:00
|
|
|
|
addSystemChatMessage(winnerName + " wins " + wonItemLink + " (" + rollName + " " + std::to_string(rollNum) + ")!");
|
2026-03-09 14:01:27 -07:00
|
|
|
|
|
2026-03-12 09:00:31 -07:00
|
|
|
|
// Dismiss roll popup — roll contest is over regardless of who won
|
|
|
|
|
|
pendingLootRollActive_ = false;
|
2026-03-09 14:01:27 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-09 13:53:42 -07:00
|
|
|
|
// 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)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-12 19:05:54 -07:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 20:23:36 -07:00
|
|
|
|
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 {};
|
|
|
|
|
|
|
2026-03-12 20:52:58 -07:00
|
|
|
|
static const std::string kUnknown = "unknown";
|
|
|
|
|
|
auto nameIt = playerNameCache.find(playerGuid);
|
|
|
|
|
|
const std::string& pName = (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown;
|
2026-03-12 20:23:36 -07:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 19:34:33 -07:00
|
|
|
|
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;
|
2026-03-12 12:49:38 -07:00
|
|
|
|
uint32_t descField = achL ? achL->field("Description") : 0xFFFFFFFF;
|
|
|
|
|
|
uint32_t ptsField = achL ? achL->field("Points") : 0xFFFFFFFF;
|
2026-03-09 19:34:33 -07:00
|
|
|
|
|
2026-03-12 12:49:38 -07:00
|
|
|
|
uint32_t fieldCount = dbc->getFieldCount();
|
2026-03-09 19:34:33 -07:00
|
|
|
|
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);
|
2026-03-12 12:49:38 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-09 19:34:33 -07:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 13:53:42 -07:00
|
|
|
|
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();
|
2026-03-12 07:22:36 -07:00
|
|
|
|
uint32_t earnDate = packet.readUInt32(); // WoW PackedTime bitfield
|
2026-03-09 13:53:42 -07:00
|
|
|
|
|
2026-03-09 19:34:33 -07:00
|
|
|
|
loadAchievementNameCache();
|
|
|
|
|
|
auto nameIt = achievementNameCache_.find(achievementId);
|
|
|
|
|
|
const std::string& achName = (nameIt != achievementNameCache_.end())
|
|
|
|
|
|
? nameIt->second : std::string();
|
|
|
|
|
|
|
2026-03-09 13:53:42 -07:00
|
|
|
|
// Show chat notification
|
|
|
|
|
|
bool isSelf = (guid == playerGuid);
|
|
|
|
|
|
if (isSelf) {
|
2026-03-09 19:34:33 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-09 13:53:42 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
|
2026-03-10 20:53:21 -07:00
|
|
|
|
earnedAchievements_.insert(achievementId);
|
2026-03-12 07:22:36 -07:00
|
|
|
|
achievementDates_[achievementId] = earnDate;
|
2026-03-17 12:37:19 -07:00
|
|
|
|
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
|
|
|
|
|
if (auto* sfx = renderer->getUiSoundManager())
|
|
|
|
|
|
sfx->playAchievementAlert();
|
|
|
|
|
|
}
|
2026-03-09 13:53:42 -07:00
|
|
|
|
if (achievementEarnedCallback_) {
|
2026-03-10 20:53:21 -07:00
|
|
|
|
achievementEarnedCallback_(achievementId, achName);
|
2026-03-09 13:53:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
} 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();
|
|
|
|
|
|
}
|
2026-03-13 06:36:04 -07:00
|
|
|
|
if (senderName.empty()) {
|
|
|
|
|
|
auto nit = playerNameCache.find(guid);
|
|
|
|
|
|
if (nit != playerNameCache.end())
|
|
|
|
|
|
senderName = nit->second;
|
|
|
|
|
|
}
|
2026-03-09 13:53:42 -07:00
|
|
|
|
if (senderName.empty()) {
|
|
|
|
|
|
char tmp[32];
|
|
|
|
|
|
std::snprintf(tmp, sizeof(tmp), "0x%llX",
|
|
|
|
|
|
static_cast<unsigned long long>(guid));
|
|
|
|
|
|
senderName = tmp;
|
|
|
|
|
|
}
|
|
|
|
|
|
char buf[256];
|
2026-03-09 19:34:33 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-09 13:53:42 -07:00
|
|
|
|
addSystemChatMessage(buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec,
|
2026-03-09 19:34:33 -07:00
|
|
|
|
" achievementId=", achievementId, " self=", isSelf,
|
|
|
|
|
|
achName.empty() ? "" : " name=", achName);
|
2026-03-09 13:53:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:53:21 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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();
|
2026-03-12 07:22:36 -07:00
|
|
|
|
achievementDates_.clear();
|
2026-03-10 20:53:21 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-03-12 07:22:36 -07:00
|
|
|
|
uint32_t date = packet.readUInt32();
|
2026-03-10 20:53:21 -07:00
|
|
|
|
earnedAchievements_.insert(id);
|
2026-03-12 07:22:36 -07:00
|
|
|
|
achievementDates_[id] = date;
|
2026-03-10 20:53:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 03:03:02 -07:00
|
|
|
|
// Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF
|
|
|
|
|
|
criteriaProgress_.clear();
|
2026-03-10 20:53:21 -07:00
|
|
|
|
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;
|
2026-03-12 03:03:02 -07:00
|
|
|
|
uint64_t counter = packet.readUInt64();
|
2026-03-10 20:53:21 -07:00
|
|
|
|
packet.readUInt32(); // date
|
|
|
|
|
|
packet.readUInt32(); // unknown / flags
|
2026-03-12 03:03:02 -07:00
|
|
|
|
criteriaProgress_[id] = counter;
|
2026-03-10 20:53:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 03:03:02 -07:00
|
|
|
|
LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(),
|
|
|
|
|
|
" achievements, ", criteriaProgress_.size(), " criteria");
|
2026-03-10 20:53:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 23:23:02 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:48:30 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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
|
2026-03-12 23:30:44 -07:00
|
|
|
|
// 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;
|
2026-03-09 14:48:30 -07:00
|
|
|
|
if (dbc->getFieldCount() <= NAME_FIELD) {
|
|
|
|
|
|
LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount());
|
2026-03-12 23:30:44 -07:00
|
|
|
|
// Don't abort — still try to load names from a shorter layout
|
2026-03-09 14:48:30 -07:00
|
|
|
|
}
|
2026-03-12 23:30:44 -07:00
|
|
|
|
const uint32_t nameField = (dbc->getFieldCount() > NAME_FIELD) ? NAME_FIELD : 22u;
|
2026-03-09 14:48:30 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-12 23:30:44 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-09 14:48:30 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 23:30:44 -07:00
|
|
|
|
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;
|
2026-03-09 14:48:30 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 04:50:49 -07:00
|
|
|
|
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, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:48:30 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 14:52:13 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 08:06:21 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 07:10:10 -07:00
|
|
|
|
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{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 08:14:47 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 15:23:02 -07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
2026-03-12 17:48:08 -07:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
2026-03-09 15:23:02 -07:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 22:25:46 -07:00
|
|
|
|
// ---- 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} // namespace game
|
|
|
|
|
|
} // namespace wowee
|