Kelsidavis-WoWee/src/game/game_handler_packets.cpp
Kelsi 069dd36698 fix(parsing): bail on suspicious maskBlockCount in CREATE_OBJECT blocks
When spline parsing consumes the wrong number of bytes, the subsequent
blockCount read lands on garbage data (e.g. 71 instead of ~5 for UNIT).
Previously the parser logged a warning but continued, reading garbage
mask/field data until hitting truncation. Now it returns false for
CREATE_OBJECT blocks with suspicious counts, letting the block loop
skip cleanly to the next entity.

Also downgrade ~44 diagnostic LOG_WARNING messages to LOG_DEBUG across
17 files (equipment, transport, DBC, heartbeat, chat, GO raypick, etc.)
to reduce log noise and make real warnings visible.
2026-04-05 20:12:17 -07:00

2938 lines
140 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "game/game_handler.hpp"
#include "game/game_utils.hpp"
#include "game/chat_handler.hpp"
#include "game/movement_handler.hpp"
#include "game/combat_handler.hpp"
#include "game/spell_handler.hpp"
#include "game/inventory_handler.hpp"
#include "game/social_handler.hpp"
#include "game/quest_handler.hpp"
#include "game/warden_handler.hpp"
#include "game/packet_parsers.hpp"
#include "game/transport_manager.hpp"
#include "game/warden_crypto.hpp"
#include "game/warden_memory.hpp"
#include "game/warden_module.hpp"
#include "game/opcodes.hpp"
#include "game/update_field_table.hpp"
#include "game/expansion_profile.hpp"
#include "rendering/renderer.hpp"
#include "rendering/spell_visual_system.hpp"
#include "audio/audio_coordinator.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/combat_sound_manager.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/ui_sound_manager.hpp"
#include "pipeline/dbc_layout.hpp"
#include "network/world_socket.hpp"
#include "network/packet.hpp"
#include "auth/crypto.hpp"
#include "core/coordinates.hpp"
#include "core/application.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "core/logger.hpp"
#include "rendering/animation/animation_ids.hpp"
#include <glm/gtx/quaternion.hpp>
#include <algorithm>
#include <cmath>
#include <cctype>
#include <ctime>
#include <random>
#include <zlib.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <unordered_map>
#include <unordered_set>
#include <functional>
#include <array>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <openssl/sha.h>
#include <openssl/hmac.h>
namespace wowee {
namespace game {
namespace {
const char* worldStateName(WorldState state) {
switch (state) {
case WorldState::DISCONNECTED: return "DISCONNECTED";
case WorldState::CONNECTING: return "CONNECTING";
case WorldState::CONNECTED: return "CONNECTED";
case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED";
case WorldState::AUTH_SENT: return "AUTH_SENT";
case WorldState::AUTHENTICATED: return "AUTHENTICATED";
case WorldState::READY: return "READY";
case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED";
case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED";
case WorldState::ENTERING_WORLD: return "ENTERING_WORLD";
case WorldState::IN_WORLD: return "IN_WORLD";
case WorldState::FAILED: return "FAILED";
}
return "UNKNOWN";
}
bool isAuthCharPipelineOpcode(LogicalOpcode op) {
switch (op) {
case Opcode::SMSG_AUTH_CHALLENGE:
case Opcode::SMSG_AUTH_RESPONSE:
case Opcode::SMSG_CLIENTCACHE_VERSION:
case Opcode::SMSG_TUTORIAL_FLAGS:
case Opcode::SMSG_WARDEN_DATA:
case Opcode::SMSG_CHAR_ENUM:
case Opcode::SMSG_CHAR_CREATE:
case Opcode::SMSG_CHAR_DELETE:
return true;
default:
return false;
}
}
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);
}
float slowPacketLogThresholdMs() {
static const int thresholdMs =
parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000);
return static_cast<float>(thresholdMs);
}
constexpr size_t kMaxQueuedInboundPackets = 4096;
} // end anonymous namespace
void GameHandler::registerOpcodeHandlers() {
// -----------------------------------------------------------------------
// Auth / session / pre-world handshake
// -----------------------------------------------------------------------
dispatchTable_[Opcode::SMSG_AUTH_CHALLENGE] = [this](network::Packet& packet) {
if (state == WorldState::CONNECTED)
handleAuthChallenge(packet);
else
LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state));
};
dispatchTable_[Opcode::SMSG_AUTH_RESPONSE] = [this](network::Packet& packet) {
if (state == WorldState::AUTH_SENT)
handleAuthResponse(packet);
else
LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state));
};
dispatchTable_[Opcode::SMSG_CHAR_CREATE] = [this](network::Packet& packet) {
handleCharCreateResponse(packet);
};
dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) {
uint8_t result = packet.readUInt8();
lastCharDeleteResult_ = result;
bool success = (result == 0x00 || result == 0x47);
LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast<int>(result), success ? " (success)" : " (failed)");
requestCharacterList();
if (charDeleteCallback_) charDeleteCallback_(success);
};
dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) {
if (state == WorldState::CHAR_LIST_REQUESTED)
handleCharEnum(packet);
else
LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state));
};
registerHandler(Opcode::SMSG_CHARACTER_LOGIN_FAILED, &GameHandler::handleCharLoginFailed);
dispatchTable_[Opcode::SMSG_LOGIN_VERIFY_WORLD] = [this](network::Packet& packet) {
if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD)
handleLoginVerifyWorld(packet);
else
LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state));
};
registerHandler(Opcode::SMSG_LOGIN_SETTIMESPEED, &GameHandler::handleLoginSetTimeSpeed);
registerHandler(Opcode::SMSG_CLIENTCACHE_VERSION, &GameHandler::handleClientCacheVersion);
registerHandler(Opcode::SMSG_TUTORIAL_FLAGS, &GameHandler::handleTutorialFlags);
registerHandler(Opcode::SMSG_ACCOUNT_DATA_TIMES, &GameHandler::handleAccountDataTimes);
registerHandler(Opcode::SMSG_MOTD, &GameHandler::handleMotd);
registerHandler(Opcode::SMSG_NOTIFICATION, &GameHandler::handleNotification);
registerHandler(Opcode::SMSG_PONG, &GameHandler::handlePong);
// -----------------------------------------------------------------------
// World object updates + entity queries (delegated to EntityController)
// -----------------------------------------------------------------------
entityController_->registerOpcodes(dispatchTable_);
// -----------------------------------------------------------------------
// Item push / logout
// -----------------------------------------------------------------------
registerSkipHandler(Opcode::SMSG_ADDON_INFO);
registerSkipHandler(Opcode::SMSG_EXPECTED_SPAM_RECORDS);
// -----------------------------------------------------------------------
// XP / exploration
// -----------------------------------------------------------------------
registerHandler(Opcode::SMSG_LOG_XPGAIN, &GameHandler::handleXpGain);
dispatchTable_[Opcode::SMSG_EXPLORATION_EXPERIENCE] = [this](network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint32_t areaId = packet.readUInt32();
uint32_t xpGained = packet.readUInt32();
if (xpGained > 0) {
std::string areaName = getAreaName(areaId);
std::string msg;
if (!areaName.empty()) {
msg = "Discovered " + areaName + "! Gained " + std::to_string(xpGained) + " experience.";
} else {
char buf[128];
std::snprintf(buf, sizeof(buf), "Discovered new area! Gained %u experience.", xpGained);
msg = buf;
}
addSystemChatMessage(msg);
addCombatText(CombatTextEntry::XP_GAIN, static_cast<int32_t>(xpGained), 0, true);
if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained);
fireAddonEvent("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)});
}
}
};
registerSkipHandler(Opcode::SMSG_PET_NAME_QUERY_RESPONSE);
// -----------------------------------------------------------------------
// Entity delta updates: health / power / world state / combo / timers / PvP
// (SMSG_HEALTH_UPDATE, SMSG_POWER_UPDATE, SMSG_UPDATE_COMBO_POINTS,
// SMSG_PVP_CREDIT, SMSG_PROCRESIST → moved to CombatHandler)
// -----------------------------------------------------------------------
dispatchTable_[Opcode::SMSG_UPDATE_WORLD_STATE] = [this](network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint32_t field = packet.readUInt32();
uint32_t value = packet.readUInt32();
worldStates_[field] = value;
LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value);
fireAddonEvent("UPDATE_WORLD_STATES", {});
};
dispatchTable_[Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t serverTime = packet.readUInt32();
LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime);
}
};
dispatchTable_[Opcode::SMSG_START_MIRROR_TIMER] = [this](network::Packet& packet) {
if (!packet.hasRemaining(21)) return;
uint32_t type = packet.readUInt32();
int32_t value = static_cast<int32_t>(packet.readUInt32());
int32_t maxV = static_cast<int32_t>(packet.readUInt32());
int32_t scale = static_cast<int32_t>(packet.readUInt32());
/*uint32_t tracker =*/ packet.readUInt32();
uint8_t paused = packet.readUInt8();
if (type < 3) {
mirrorTimers_[type].value = value;
mirrorTimers_[type].maxValue = maxV;
mirrorTimers_[type].scale = scale;
mirrorTimers_[type].paused = (paused != 0);
mirrorTimers_[type].active = true;
fireAddonEvent("MIRROR_TIMER_START", {
std::to_string(type), std::to_string(value),
std::to_string(maxV), std::to_string(scale),
paused ? "1" : "0"});
}
};
dispatchTable_[Opcode::SMSG_STOP_MIRROR_TIMER] = [this](network::Packet& packet) {
if (!packet.hasRemaining(4)) return;
uint32_t type = packet.readUInt32();
if (type < 3) {
mirrorTimers_[type].active = false;
mirrorTimers_[type].value = 0;
fireAddonEvent("MIRROR_TIMER_STOP", {std::to_string(type)});
}
};
dispatchTable_[Opcode::SMSG_PAUSE_MIRROR_TIMER] = [this](network::Packet& packet) {
if (!packet.hasRemaining(5)) return;
uint32_t type = packet.readUInt32();
uint8_t paused = packet.readUInt8();
if (type < 3) {
mirrorTimers_[type].paused = (paused != 0);
fireAddonEvent("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"});
}
};
// -----------------------------------------------------------------------
// Cast result / spell proc
// (SMSG_CAST_RESULT, SMSG_SPELL_FAILED_OTHER → moved to SpellHandler)
// (SMSG_PROCRESIST → moved to CombatHandler)
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Pet stable
// -----------------------------------------------------------------------
dispatchTable_[Opcode::MSG_LIST_STABLED_PETS] = [this](network::Packet& packet) {
if (state == WorldState::IN_WORLD) handleListStabledPets(packet);
};
dispatchTable_[Opcode::SMSG_STABLE_RESULT] = [this](network::Packet& packet) {
if (!packet.hasRemaining(1)) return;
uint8_t result = packet.readUInt8();
const char* msg = nullptr;
switch (result) {
case 0x01: msg = "Pet stored in stable."; break;
case 0x06: msg = "Pet retrieved from stable."; break;
case 0x07: msg = "Stable slot purchased."; break;
case 0x08: msg = "Stable list updated."; break;
case 0x09: msg = "Stable failed: not enough money or other error."; addUIError(msg); break;
default: break;
}
if (msg) addSystemChatMessage(msg);
LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast<int>(result));
if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) {
auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_);
socket->send(refreshPkt);
}
};
// -----------------------------------------------------------------------
// Titles / achievements / character services
// -----------------------------------------------------------------------
dispatchTable_[Opcode::SMSG_TITLE_EARNED] = [this](network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint32_t titleBit = packet.readUInt32();
uint32_t isLost = packet.readUInt32();
loadTitleNameCache();
std::string titleStr;
auto tit = titleNameCache_.find(titleBit);
if (tit != titleNameCache_.end() && !tit->second.empty()) {
const auto& ln = lookupName(playerGuid);
const std::string& pName = ln.empty() ? std::string("you") : ln;
const std::string& fmt = tit->second;
size_t pos = fmt.find("%s");
if (pos != std::string::npos)
titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2);
else
titleStr = fmt;
}
std::string msg;
if (!titleStr.empty()) {
msg = isLost ? ("Title removed: " + titleStr + ".") : ("Title earned: " + titleStr + "!");
} else {
char buf[64];
std::snprintf(buf, sizeof(buf), isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", titleBit);
msg = buf;
}
if (isLost) knownTitleBits_.erase(titleBit);
else knownTitleBits_.insert(titleBit);
addSystemChatMessage(msg);
LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, " title='", titleStr, "'");
};
dispatchTable_[Opcode::SMSG_LEARNED_DANCE_MOVES] = [this](network::Packet& packet) {
LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")");
};
dispatchTable_[Opcode::SMSG_CHAR_RENAME] = [this](network::Packet& packet) {
if (packet.hasRemaining(13)) {
uint32_t result = packet.readUInt32();
/*uint64_t guid =*/ packet.readUInt64();
std::string newName = packet.readString();
if (result == 0) {
addSystemChatMessage("Character name changed to: " + newName);
} else {
static const char* kRenameErrors[] = {
nullptr, "Name already in use.", "Name too short.", "Name too long.",
"Name contains invalid characters.", "Name contains a profanity.",
"Name is reserved.", "Character name does not meet requirements.",
};
const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr;
std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg : "Character rename failed.";
addUIError(renameErr); addSystemChatMessage(renameErr);
}
LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName);
}
};
// -----------------------------------------------------------------------
// Bind / heartstone / phase / barber / corpse
// -----------------------------------------------------------------------
dispatchTable_[Opcode::SMSG_PLAYERBOUND] = [this](network::Packet& packet) {
if (!packet.hasRemaining(16)) return;
/*uint64_t binderGuid =*/ packet.readUInt64();
uint32_t mapId = packet.readUInt32();
uint32_t zoneId = packet.readUInt32();
homeBindMapId_ = mapId;
homeBindZoneId_ = zoneId;
std::string pbMsg = "Your home location has been set";
std::string zoneName = getAreaName(zoneId);
if (!zoneName.empty()) pbMsg += " to " + zoneName;
pbMsg += '.';
addSystemChatMessage(pbMsg);
};
registerSkipHandler(Opcode::SMSG_BINDER_CONFIRM);
registerSkipHandler(Opcode::SMSG_SET_PHASE_SHIFT);
dispatchTable_[Opcode::SMSG_TOGGLE_XP_GAIN] = [this](network::Packet& packet) {
if (!packet.hasRemaining(1)) return;
uint8_t enabled = packet.readUInt8();
addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled.");
};
dispatchTable_[Opcode::SMSG_BINDZONEREPLY] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t result = packet.readUInt32();
if (result == 0) addSystemChatMessage("Your home is now set to this location.");
else { addUIError("You are too far from the innkeeper."); addSystemChatMessage("You are too far from the innkeeper."); }
}
};
dispatchTable_[Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t result = packet.readUInt32();
if (result == 0) {
addSystemChatMessage("Difficulty changed.");
} else {
static const char* reasons[] = {
"", "Error", "Too many members", "Already in dungeon",
"You are in a battleground", "Raid not allowed in heroic",
"You must be in a raid group", "Player not in group"
};
const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed.";
addUIError(std::string("Cannot change difficulty: ") + msg);
addSystemChatMessage(std::string("Cannot change difficulty: ") + msg);
}
}
};
dispatchTable_[Opcode::SMSG_CORPSE_NOT_IN_INSTANCE] = [this](network::Packet& /*packet*/) {
addUIError("Your corpse is outside this instance.");
addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it.");
};
dispatchTable_[Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD] = [this](network::Packet& packet) {
if (packet.hasRemaining(12)) {
uint64_t guid = packet.readUInt64();
uint32_t threshold = packet.readUInt32();
if (guid == playerGuid && threshold > 0) addSystemChatMessage("You feel rather drunk.");
LOG_DEBUG("SMSG_CROSSED_INEBRIATION_THRESHOLD: guid=0x", std::hex, guid, std::dec, " threshold=", threshold);
}
};
dispatchTable_[Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE] = [this](network::Packet& /*packet*/) {
LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE");
};
registerSkipHandler(Opcode::SMSG_COMBAT_EVENT_FAILED);
dispatchTable_[Opcode::SMSG_FORCE_ANIM] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint64_t animGuid = packet.readPackedGuid();
if (packet.hasRemaining(4)) {
uint32_t animId = packet.readUInt32();
if (emoteAnimCallback_) emoteAnimCallback_(animGuid, animId);
}
}
};
// Consume silently — opcodes we receive but don't need to act on
for (auto op : {
Opcode::SMSG_FLIGHT_SPLINE_SYNC, Opcode::SMSG_FORCE_DISPLAY_UPDATE,
Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS, Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID,
Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE, Opcode::SMSG_DAMAGE_CALC_LOG,
Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT, Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE,
}) { registerSkipHandler(op); }
// Game object despawn animation — reset state to closed before actual despawn
dispatchTable_[Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM] = [this](network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint64_t guid = packet.readUInt64();
// Trigger a CLOSE animation / freeze before the object is removed
if (gameObjectStateCallback_) gameObjectStateCallback_(guid, 0);
};
// Game object reset state — return to READY(closed) state
dispatchTable_[Opcode::SMSG_GAMEOBJECT_RESET_STATE] = [this](network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint64_t guid = packet.readUInt64();
if (gameObjectStateCallback_) gameObjectStateCallback_(guid, 0);
};
dispatchTable_[Opcode::SMSG_FORCED_DEATH_UPDATE] = [this](network::Packet& packet) {
playerDead_ = true;
if (ghostStateCallback_) ghostStateCallback_(false);
fireAddonEvent("PLAYER_DEAD", {});
addSystemChatMessage("You have been killed.");
LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed");
packet.skipAll();
};
// SMSG_DEFENSE_MESSAGE — moved to ChatHandler::registerOpcodes
dispatchTable_[Opcode::SMSG_CORPSE_RECLAIM_DELAY] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t delayMs = packet.readUInt32();
auto nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
corpseReclaimAvailableMs_ = nowMs + delayMs;
LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms");
}
};
dispatchTable_[Opcode::SMSG_DEATH_RELEASE_LOC] = [this](network::Packet& packet) {
if (packet.hasRemaining(16)) {
uint32_t relMapId = packet.readUInt32();
float relX = packet.readFloat(), relY = packet.readFloat(), relZ = packet.readFloat();
LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, " x=", relX, " y=", relY, " z=", relZ);
}
};
dispatchTable_[Opcode::SMSG_ENABLE_BARBER_SHOP] = [this](network::Packet& /*packet*/) {
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
barberShopOpen_ = true;
fireAddonEvent("BARBER_SHOP_OPEN", {});
};
// ---- Batch 3: Corpse/gametime, combat clearing, mount, loot notify,
// movement/speed/flags, attack, spells, group ----
dispatchTable_[Opcode::MSG_CORPSE_QUERY] = [this](network::Packet& packet) {
if (!packet.hasRemaining(1)) return;
uint8_t found = packet.readUInt8();
if (found && packet.hasRemaining(20)) {
/*uint32_t mapId =*/ packet.readUInt32();
float cx = packet.readFloat();
float cy = packet.readFloat();
float cz = packet.readFloat();
uint32_t corpseMapId = packet.readUInt32();
corpseX_ = cx;
corpseY_ = cy;
corpseZ_ = cz;
corpseMapId_ = corpseMapId;
LOG_INFO("MSG_CORPSE_QUERY: corpse at (", cx, ",", cy, ",", cz, ") map=", corpseMapId);
}
};
dispatchTable_[Opcode::SMSG_FEIGN_DEATH_RESISTED] = [this](network::Packet& /*packet*/) {
addUIError("Your Feign Death was resisted.");
addSystemChatMessage("Your Feign Death attempt was resisted.");
};
dispatchTable_[Opcode::SMSG_CHANNEL_MEMBER_COUNT] = [this](network::Packet& packet) {
std::string chanName = packet.readString();
if (packet.hasRemaining(5)) {
/*uint8_t flags =*/ packet.readUInt8();
uint32_t count = packet.readUInt32();
LOG_DEBUG("SMSG_CHANNEL_MEMBER_COUNT: channel=", chanName, " members=", count);
}
};
for (auto op : { Opcode::SMSG_GAMETIME_SET, Opcode::SMSG_GAMETIME_UPDATE }) {
dispatchTable_[op] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t gameTimePacked = packet.readUInt32();
gameTime_ = static_cast<float>(gameTimePacked);
}
packet.skipAll();
};
}
dispatchTable_[Opcode::SMSG_GAMESPEED_SET] = [this](network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint32_t gameTimePacked = packet.readUInt32();
float timeSpeed = packet.readFloat();
gameTime_ = static_cast<float>(gameTimePacked);
timeSpeed_ = timeSpeed;
}
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_GAMETIMEBIAS_SET] = [this](network::Packet& packet) {
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_ACHIEVEMENT_DELETED] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t achId = packet.readUInt32();
earnedAchievements_.erase(achId);
achievementDates_.erase(achId);
}
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_CRITERIA_DELETED] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t critId = packet.readUInt32();
criteriaProgress_.erase(critId);
}
packet.skipAll();
};
// Combat clearing
dispatchTable_[Opcode::SMSG_BREAK_TARGET] = [this](network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint64_t bGuid = packet.readUInt64();
if (bGuid == targetGuid) targetGuid = 0;
}
};
dispatchTable_[Opcode::SMSG_CLEAR_TARGET] = [this](network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint64_t cGuid = packet.readUInt64();
if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0;
}
};
// Mount/dismount
dispatchTable_[Opcode::SMSG_DISMOUNT] = [this](network::Packet& /*packet*/) {
currentMountDisplayId_ = 0;
if (mountCallback_) mountCallback_(0);
};
dispatchTable_[Opcode::SMSG_MOUNTRESULT] = [this](network::Packet& packet) {
if (!packet.hasRemaining(4)) return;
uint32_t result = packet.readUInt32();
if (result != 4) {
const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.",
"Too far away to mount.", "Already mounted." };
std::string mountErr = result < 4 ? msgs[result] : "Cannot mount.";
addUIError(mountErr);
addSystemChatMessage(mountErr);
}
};
dispatchTable_[Opcode::SMSG_DISMOUNTRESULT] = [this](network::Packet& packet) {
if (!packet.hasRemaining(4)) return;
uint32_t result = packet.readUInt32();
if (result != 0) {
addUIError("Cannot dismount here.");
addSystemChatMessage("Cannot dismount here.");
}
};
// Camera shake
dispatchTable_[Opcode::SMSG_CAMERA_SHAKE] = [this](network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint32_t shakeId = packet.readUInt32();
uint32_t shakeType = packet.readUInt32();
(void)shakeType;
float magnitude = (shakeId < 50) ? 0.04f : 0.08f;
if (cameraShakeCallback_)
cameraShakeCallback_(magnitude, 18.0f, 0.5f);
}
};
// (SMSG_PLAY_SPELL_VISUAL, SMSG_CLEAR_COOLDOWN, SMSG_MODIFY_COOLDOWN → moved to SpellHandler)
// ---- Batch 4: Ready check, duels, guild, loot/gossip/vendor, factions, spell mods ----
// Guild
registerHandler(Opcode::SMSG_PET_SPELLS, &GameHandler::handlePetSpells);
// Loot/gossip/vendor delegates
registerHandler(Opcode::SMSG_SUMMON_REQUEST, &GameHandler::handleSummonRequest);
dispatchTable_[Opcode::SMSG_SUMMON_CANCEL] = [this](network::Packet& /*packet*/) {
pendingSummonRequest_ = false;
addSystemChatMessage("Summon cancelled.");
};
// Bind point
dispatchTable_[Opcode::SMSG_BINDPOINTUPDATE] = [this](network::Packet& packet) {
BindPointUpdateData data;
if (BindPointUpdateParser::parse(packet, data)) {
glm::vec3 canonical = core::coords::serverToCanonical(
glm::vec3(data.x, data.y, data.z));
bool wasSet = hasHomeBind_;
hasHomeBind_ = true;
homeBindMapId_ = data.mapId;
homeBindZoneId_ = data.zoneId;
homeBindPos_ = canonical;
if (bindPointCallback_)
bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z);
if (wasSet) {
std::string bindMsg = "Your home has been set";
std::string zoneName = getAreaName(data.zoneId);
if (!zoneName.empty()) bindMsg += " to " + zoneName;
bindMsg += '.';
addSystemChatMessage(bindMsg);
}
}
};
// Spirit healer / resurrect
dispatchTable_[Opcode::SMSG_SPIRIT_HEALER_CONFIRM] = [this](network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint64_t npcGuid = packet.readUInt64();
if (npcGuid) {
resurrectCasterGuid_ = npcGuid;
resurrectCasterName_ = "";
resurrectIsSpiritHealer_ = true;
resurrectRequestPending_ = true;
}
};
dispatchTable_[Opcode::SMSG_RESURRECT_REQUEST] = [this](network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint64_t casterGuid = packet.readUInt64();
std::string casterName;
if (packet.hasData())
casterName = packet.readString();
if (casterGuid) {
resurrectCasterGuid_ = casterGuid;
resurrectIsSpiritHealer_ = false;
if (!casterName.empty()) {
resurrectCasterName_ = casterName;
} else {
resurrectCasterName_ = lookupName(casterGuid);
}
resurrectRequestPending_ = true;
fireAddonEvent("RESURRECT_REQUEST", {resurrectCasterName_});
}
};
// Time sync
dispatchTable_[Opcode::SMSG_TIME_SYNC_REQ] = [this](network::Packet& packet) {
if (!packet.hasRemaining(4)) return;
uint32_t counter = packet.readUInt32();
if (socket) {
network::Packet resp(wireOpcode(Opcode::CMSG_TIME_SYNC_RESP));
resp.writeUInt32(counter);
resp.writeUInt32(nextMovementTimestampMs());
socket->send(resp);
}
};
// (SMSG_TRAINER_BUY_SUCCEEDED, SMSG_TRAINER_BUY_FAILED → moved to InventoryHandler)
// Minimap ping
dispatchTable_[Opcode::MSG_MINIMAP_PING] = [this](network::Packet& packet) {
const bool mmTbcLike = isPreWotlk();
if (!packet.hasRemaining(mmTbcLike ? 8u : 1u) ) return;
uint64_t senderGuid = mmTbcLike
? packet.readUInt64() : packet.readPackedGuid();
if (!packet.hasRemaining(8)) return;
float pingX = packet.readFloat();
float pingY = packet.readFloat();
MinimapPing ping;
ping.senderGuid = senderGuid;
ping.wowX = pingY;
ping.wowY = pingX;
ping.age = 0.0f;
minimapPings_.push_back(ping);
if (senderGuid != playerGuid) {
withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playMinimapPing(); });
}
};
dispatchTable_[Opcode::SMSG_ZONE_UNDER_ATTACK] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t areaId = packet.readUInt32();
std::string areaName = getAreaName(areaId);
std::string msg = areaName.empty()
? std::string("A zone is under attack!")
: (areaName + " is under attack!");
addUIError(msg);
addSystemChatMessage(msg);
}
};
// Spirit healer time / durability
dispatchTable_[Opcode::SMSG_AREA_SPIRIT_HEALER_TIME] = [this](network::Packet& packet) {
if (packet.hasRemaining(12)) {
/*uint64_t guid =*/ packet.readUInt64();
uint32_t timeMs = packet.readUInt32();
uint32_t secs = timeMs / 1000;
char buf[128];
std::snprintf(buf, sizeof(buf), "You will be able to resurrect in %u seconds.", secs);
addSystemChatMessage(buf);
}
};
dispatchTable_[Opcode::SMSG_DURABILITY_DAMAGE_DEATH] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t pct = packet.readUInt32();
char buf[80];
std::snprintf(buf, sizeof(buf),
"You have lost %u%% of your gear's durability due to death.", pct);
addUIError(buf);
addSystemChatMessage(buf);
}
};
// (SMSG_INITIALIZE_FACTIONS, SMSG_SET_FACTION_STANDING,
// SMSG_SET_FACTION_ATWAR, SMSG_SET_FACTION_VISIBLE → moved to SocialHandler)
dispatchTable_[Opcode::SMSG_FEATURE_SYSTEM_STATUS] = [this](network::Packet& packet) {
packet.skipAll();
};
// (SMSG_SET_FLAT_SPELL_MODIFIER, SMSG_SET_PCT_SPELL_MODIFIER, SMSG_SPELL_DELAYED → moved to SpellHandler)
// Proficiency
dispatchTable_[Opcode::SMSG_SET_PROFICIENCY] = [this](network::Packet& packet) {
if (!packet.hasRemaining(5)) return;
uint8_t itemClass = packet.readUInt8();
uint32_t mask = packet.readUInt32();
if (itemClass == 2) weaponProficiency_ = mask;
else if (itemClass == 4) armorProficiency_ = mask;
};
// Loot money / misc consume
for (auto op : { Opcode::SMSG_LOOT_CLEAR_MONEY, Opcode::SMSG_NPC_TEXT_UPDATE }) {
dispatchTable_[op] = [](network::Packet& /*packet*/) {};
}
// Play sound
dispatchTable_[Opcode::SMSG_PLAY_SOUND] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t soundId = packet.readUInt32();
if (playSoundCallback_) playSoundCallback_(soundId);
}
};
// SMSG_SERVER_MESSAGE — moved to ChatHandler::registerOpcodes
// SMSG_CHAT_SERVER_MESSAGE — moved to ChatHandler::registerOpcodes
// SMSG_AREA_TRIGGER_MESSAGE — moved to ChatHandler::registerOpcodes
dispatchTable_[Opcode::SMSG_TRIGGER_CINEMATIC] = [this](network::Packet& packet) {
packet.skipAll();
network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA));
socket->send(ack);
};
// ---- Batch 5: Teleport, taxi, BG, LFG, arena, movement relay, mail, bank, auction, quests ----
// Teleport
dispatchTable_[Opcode::SMSG_TRANSFER_PENDING] = [this](network::Packet& packet) {
uint32_t pendingMapId = packet.readUInt32();
if (packet.hasRemaining(8)) {
packet.readUInt32(); // transportEntry
packet.readUInt32(); // transportMapId
}
(void)pendingMapId;
};
dispatchTable_[Opcode::SMSG_TRANSFER_ABORTED] = [this](network::Packet& packet) {
uint32_t mapId = packet.readUInt32();
uint8_t reason = (packet.hasData()) ? packet.readUInt8() : 0;
(void)mapId;
const char* abortMsg = nullptr;
switch (reason) {
case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break;
case 0x02: abortMsg = "Transfer aborted: expansion required."; break;
case 0x03: abortMsg = "Transfer aborted: instance not found."; break;
case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break;
case 0x06: abortMsg = "Transfer aborted: instance is full."; break;
case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break;
case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break;
case 0x09: abortMsg = "Transfer aborted: not enough players."; break;
default: abortMsg = "Transfer aborted."; break;
}
addUIError(abortMsg);
addSystemChatMessage(abortMsg);
};
// Taxi
dispatchTable_[Opcode::SMSG_STANDSTATE_UPDATE] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
standState_ = packet.readUInt8();
if (standStateCallback_) standStateCallback_(standState_);
}
};
dispatchTable_[Opcode::SMSG_NEW_TAXI_PATH] = [this](network::Packet& /*packet*/) {
addSystemChatMessage("New flight path discovered!");
};
// Arena
dispatchTable_[Opcode::MSG_TALENT_WIPE_CONFIRM] = [this](network::Packet& packet) {
if (!packet.hasRemaining(12)) { packet.skipAll(); return; }
talentWipeNpcGuid_ = packet.readUInt64();
talentWipeCost_ = packet.readUInt32();
talentWipePending_ = true;
fireAddonEvent("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)});
};
// (SMSG_CHANNEL_LIST → moved to ChatHandler)
// (SMSG_GROUP_SET_LEADER → moved to SocialHandler)
// Gameobject / page text (entity queries moved to EntityController::registerOpcodes)
dispatchTable_[Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM] = [this](network::Packet& packet) {
if (packet.getSize() < 12) return;
uint64_t guid = packet.readUInt64();
uint32_t animId = packet.readUInt32();
if (gameObjectCustomAnimCallback_)
gameObjectCustomAnimCallback_(guid, animId);
if (animId == 0) {
auto goEnt = entityController_->getEntityManager().getEntity(guid);
if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(goEnt);
// Only show fishing message if the bobber belongs to us
// OBJECT_FIELD_CREATED_BY is a uint64 at field indices 6-7
uint64_t createdBy = static_cast<uint64_t>(go->getField(6))
| (static_cast<uint64_t>(go->getField(7)) << 32);
if (createdBy == playerGuid) {
auto* info = getCachedGameObjectInfo(go->getEntry());
if (info && info->type == 17) {
addUIError("A fish is on your line!");
addSystemChatMessage("A fish is on your line!");
withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playQuestUpdate(); });
}
}
}
}
};
// Item refund / socket gems / item time
dispatchTable_[Opcode::SMSG_ITEM_REFUND_RESULT] = [this](network::Packet& packet) {
if (packet.hasRemaining(12)) {
packet.readUInt64(); // itemGuid
uint32_t result = packet.readUInt32();
addSystemChatMessage(result == 0 ? "Item returned. Refund processed."
: "Could not return item for refund.");
}
};
dispatchTable_[Opcode::SMSG_SOCKET_GEMS_RESULT] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t result = packet.readUInt32();
if (result == 0) addSystemChatMessage("Gems socketed successfully.");
else addSystemChatMessage("Failed to socket gems.");
}
};
dispatchTable_[Opcode::SMSG_ITEM_TIME_UPDATE] = [this](network::Packet& packet) {
if (packet.hasRemaining(12)) {
packet.readUInt64(); // itemGuid
packet.readUInt32(); // durationMs
}
};
// ---- Batch 6: Spell miss / env damage / control / spell failure ----
// ---- Achievement / fishing delegates ----
dispatchTable_[Opcode::SMSG_ALL_ACHIEVEMENT_DATA] = [this](network::Packet& packet) {
handleAllAchievementData(packet);
};
dispatchTable_[Opcode::SMSG_FISH_NOT_HOOKED] = [this](network::Packet& /*packet*/) {
addSystemChatMessage("Your fish got away.");
};
dispatchTable_[Opcode::SMSG_FISH_ESCAPED] = [this](network::Packet& /*packet*/) {
addSystemChatMessage("Your fish escaped!");
};
// ---- Auto-repeat / auras / dispel / totem ----
dispatchTable_[Opcode::SMSG_CANCEL_AUTO_REPEAT] = [this](network::Packet& /*packet*/) {
// Server signals to stop a repeating spell (wand/shoot); no client action needed
};
// ---- Batch 7: World states, action buttons, level-up, vendor, inventory ----
// ---- SMSG_INIT_WORLD_STATES ----
dispatchTable_[Opcode::SMSG_INIT_WORLD_STATES] = [this](network::Packet& packet) {
// WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val)
// Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(uint32 key, uint32 val)
if (!packet.hasRemaining(10)) {
LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes");
return;
}
worldStateMapId_ = packet.readUInt32();
{
uint32_t newZoneId = packet.readUInt32();
if (newZoneId != worldStateZoneId_ && newZoneId != 0) {
worldStateZoneId_ = newZoneId;
fireAddonEvent("ZONE_CHANGED_NEW_AREA", {});
fireAddonEvent("ZONE_CHANGED", {});
} else {
worldStateZoneId_ = newZoneId;
}
}
// WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format
size_t remaining = packet.getRemainingSize();
bool isWotLKFormat = isActiveExpansion("wotlk");
if (isWotLKFormat && remaining >= 6) {
packet.readUInt32(); // areaId (WotLK only)
}
uint16_t count = packet.readUInt16();
size_t needed = static_cast<size_t>(count) * 8;
size_t available = packet.getRemainingSize();
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.skipAll();
return;
}
}
worldStates_.clear();
worldStates_.reserve(count);
for (uint16_t i = 0; i < count; ++i) {
uint32_t key = packet.readUInt32();
uint32_t val = packet.readUInt32();
worldStates_[key] = val;
}
};
// ---- SMSG_ACTION_BUTTONS ----
dispatchTable_[Opcode::SMSG_ACTION_BUTTONS] = [this](network::Packet& packet) {
// Slot encoding differs by expansion:
// Classic/Turtle: uint16 actionId + uint8 type + uint8 misc
// type: 0=spell, 1=item, 64=macro
// TBC/WotLK: uint32 packed = actionId | (type << 24)
// type: 0x00=spell, 0x80=item, 0x40=macro
// Format differences:
// Classic 1.12: no mode byte, 120 slots (480 bytes)
// TBC 2.4.3: no mode byte, 132 slots (528 bytes)
// WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes)
size_t rem = packet.getRemainingSize();
const bool hasModeByteExp = isActiveExpansion("wotlk");
int serverBarSlots;
if (isClassicLikeExpansion()) {
serverBarSlots = 120;
} else if (isActiveExpansion("tbc")) {
serverBarSlots = 132;
} else {
serverBarSlots = 144;
}
if (hasModeByteExp) {
if (rem < 1) return;
/*uint8_t mode =*/ packet.readUInt8();
rem--;
}
for (int i = 0; i < serverBarSlots; ++i) {
if (rem < 4) return;
uint32_t packed = packet.readUInt32();
rem -= 4;
if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2
if (packed == 0) {
// Empty slot — only clear if not already set to Attack/Hearthstone defaults
// so we don't wipe hardcoded fallbacks when the server sends zeros.
continue;
}
uint8_t type = 0;
uint32_t id = 0;
if (isClassicLikeExpansion()) {
id = packed & 0x0000FFFFu;
type = static_cast<uint8_t>((packed >> 16) & 0xFF);
} else {
type = static_cast<uint8_t>((packed >> 24) & 0xFF);
id = packed & 0x00FFFFFFu;
}
if (id == 0) continue;
ActionBarSlot slot;
switch (type) {
case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break;
case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item
case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item
case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions)
default: continue; // unknown — leave as-is
}
actionBar[i] = slot;
}
// Apply any pending cooldowns from spellHandler's cooldowns 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.
if (spellHandler_) {
const auto& cooldowns = spellHandler_->getSpellCooldowns();
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id != 0) {
auto cdIt = cooldowns.find(slot.id);
if (cdIt != cooldowns.end() && cdIt->second > 0.0f) {
slot.cooldownRemaining = cdIt->second;
slot.cooldownTotal = cdIt->second;
}
} else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) {
// Items (potions, trinkets): look up the item's on-use spell
// and check if that spell has a pending cooldown.
const auto* qi = getItemInfo(slot.id);
if (qi && qi->valid) {
for (const auto& sp : qi->spells) {
if (sp.spellId == 0) continue;
auto cdIt = cooldowns.find(sp.spellId);
if (cdIt != cooldowns.end() && cdIt->second > 0.0f) {
slot.cooldownRemaining = cdIt->second;
slot.cooldownTotal = cdIt->second;
break;
}
}
}
}
}
}
LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server");
fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {});
packet.skipAll();
};
// ---- SMSG_LEVELUP_INFO / SMSG_LEVELUP_INFO_ALT (shared body) ----
for (auto op : {Opcode::SMSG_LEVELUP_INFO, Opcode::SMSG_LEVELUP_INFO_ALT}) {
dispatchTable_[op] = [this](network::Packet& packet) {
// Server-authoritative level-up event.
// WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas
if (packet.hasRemaining(4)) {
uint32_t newLevel = packet.readUInt32();
if (newLevel > 0) {
// Parse stat deltas (WotLK layout has 7 more uint32s)
lastLevelUpDeltas_ = {};
if (packet.hasRemaining(28)) {
lastLevelUpDeltas_.hp = packet.readUInt32();
lastLevelUpDeltas_.mana = packet.readUInt32();
lastLevelUpDeltas_.str = packet.readUInt32();
lastLevelUpDeltas_.agi = packet.readUInt32();
lastLevelUpDeltas_.sta = packet.readUInt32();
lastLevelUpDeltas_.intel = packet.readUInt32();
lastLevelUpDeltas_.spi = packet.readUInt32();
}
uint32_t oldLevel = serverPlayerLevel_;
serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel);
// Update the character-list entry so the selection screen
// shows the correct level if the player logs out and back.
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.level = serverPlayerLevel_;
break; // was 'return' — must NOT exit here or level-up notification is skipped
}
}
if (newLevel > oldLevel) {
addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!");
withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playLevelUp(); });
if (levelUpCallback_) levelUpCallback_(newLevel);
fireAddonEvent("PLAYER_LEVEL_UP", {std::to_string(newLevel)});
}
}
}
packet.skipAll();
};
}
// ---- MSG_RAID_TARGET_UPDATE ----
dispatchTable_[Opcode::MSG_RAID_TARGET_UPDATE] = [this](network::Packet& packet) {
// uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)),
// 1 = single update (uint8 icon + uint64 guid)
size_t remRTU = packet.getRemainingSize();
if (remRTU < 1) return;
uint8_t rtuType = packet.readUInt8();
if (rtuType == 0) {
// Full update: always 8 entries
for (uint32_t i = 0; i < kRaidMarkCount; ++i) {
if (!packet.hasRemaining(9)) return;
uint8_t icon = packet.readUInt8();
uint64_t guid = packet.readUInt64();
if (socialHandler_)
socialHandler_->setRaidTargetGuid(icon, guid);
}
} else {
// Single update
if (packet.hasRemaining(9)) {
uint8_t icon = packet.readUInt8();
uint64_t guid = packet.readUInt64();
if (socialHandler_)
socialHandler_->setRaidTargetGuid(icon, guid);
}
}
LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast<int>(rtuType));
fireAddonEvent("RAID_TARGET_UPDATE", {});
};
// ---- SMSG_CRITERIA_UPDATE ----
dispatchTable_[Opcode::SMSG_CRITERIA_UPDATE] = [this](network::Packet& packet) {
// uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime
if (packet.hasRemaining(20)) {
uint32_t criteriaId = packet.readUInt32();
uint64_t progress = packet.readUInt64();
packet.readUInt32(); // elapsedTime
packet.readUInt32(); // creationTime
uint64_t oldProgress = 0;
auto cpit = criteriaProgress_.find(criteriaId);
if (cpit != criteriaProgress_.end()) oldProgress = cpit->second;
criteriaProgress_[criteriaId] = progress;
LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress);
// Fire addon event for achievement tracking addons
if (progress != oldProgress)
fireAddonEvent("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)});
}
};
// ---- SMSG_BARBER_SHOP_RESULT ----
dispatchTable_[Opcode::SMSG_BARBER_SHOP_RESULT] = [this](network::Packet& packet) {
// uint32 result (0 = success, 1 = no money, 2 = not barber, 3 = sitting)
if (packet.hasRemaining(4)) {
uint32_t result = packet.readUInt32();
if (result == 0) {
addSystemChatMessage("Hairstyle changed.");
barberShopOpen_ = false;
fireAddonEvent("BARBER_SHOP_CLOSE", {});
} else {
const char* msg = (result == 1) ? "Not enough money for new hairstyle."
: (result == 2) ? "You are not at a barber shop."
: (result == 3) ? "You must stand up to use the barber shop."
: "Barber shop unavailable.";
addUIError(msg);
addSystemChatMessage(msg);
}
LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result);
}
};
// -----------------------------------------------------------------------
// Batch 8-12: Remaining opcodes (inspects, quests, auctions, spells,
// calendars, battlefields, voice, misc consume-only)
// -----------------------------------------------------------------------
// uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs
dispatchTable_[Opcode::SMSG_OVERRIDE_LIGHT] = [this](network::Packet& packet) {
// uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs
if (packet.hasRemaining(12)) {
uint32_t zoneLightId = packet.readUInt32();
uint32_t overrideLightId = packet.readUInt32();
uint32_t transitionMs = packet.readUInt32();
overrideLightId_ = overrideLightId;
overrideLightTransMs_ = transitionMs;
LOG_DEBUG("SMSG_OVERRIDE_LIGHT: zone=", zoneLightId,
" override=", overrideLightId, " transition=", transitionMs, "ms");
}
};
// Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt)
// TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes)
dispatchTable_[Opcode::SMSG_WEATHER] = [this](network::Packet& packet) {
// Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt)
// TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes)
if (packet.hasRemaining(8)) {
uint32_t wType = packet.readUInt32();
float wIntensity = packet.readFloat();
if (packet.hasRemaining(1))
/*uint8_t isAbrupt =*/ packet.readUInt8();
uint32_t prevWeatherType = weatherType_;
weatherType_ = wType;
weatherIntensity_ = wIntensity;
const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear";
LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity);
// Announce weather changes (including initial zone weather)
if (wType != prevWeatherType) {
const char* weatherMsg = nullptr;
if (wIntensity < 0.05f || wType == 0) {
if (prevWeatherType != 0)
weatherMsg = "The weather clears.";
} else if (wType == 1) {
weatherMsg = "It begins to rain.";
} else if (wType == 2) {
weatherMsg = "It begins to snow.";
} else if (wType == 3) {
weatherMsg = "A storm rolls in.";
}
if (weatherMsg) addSystemChatMessage(weatherMsg);
}
// Notify addons of weather change
fireAddonEvent("WEATHER_CHANGED", {std::to_string(wType), std::to_string(wIntensity)});
// Storm transition: trigger a low-frequency thunder rumble shake
if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) {
float mag = 0.03f + wIntensity * 0.04f; // 0.030.07 units
cameraShakeCallback_(mag, 6.0f, 0.6f);
}
}
};
// Server-script text message — display in system chat
dispatchTable_[Opcode::SMSG_SCRIPT_MESSAGE] = [this](network::Packet& packet) {
// Server-script text message — display in system chat
std::string msg = packet.readString();
if (!msg.empty()) {
addSystemChatMessage(msg);
LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg);
}
};
// uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType
dispatchTable_[Opcode::SMSG_ENCHANTMENTLOG] = [this](network::Packet& packet) {
// uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType
if (packet.hasRemaining(28)) {
uint64_t enchTargetGuid = packet.readUInt64();
uint64_t enchCasterGuid = packet.readUInt64();
uint32_t enchSpellId = packet.readUInt32();
/*uint32_t displayId =*/ packet.readUInt32();
/*uint32_t animType =*/ packet.readUInt32();
LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", enchSpellId);
// Show enchant message if the player is involved
if (enchTargetGuid == playerGuid || enchCasterGuid == playerGuid) {
const std::string& enchName = getSpellName(enchSpellId);
std::string casterName = lookupName(enchCasterGuid);
if (!enchName.empty()) {
std::string msg;
if (enchCasterGuid == playerGuid)
msg = "You enchant with " + enchName + ".";
else if (!casterName.empty())
msg = casterName + " enchants your item with " + enchName + ".";
else
msg = "Your item has been enchanted with " + enchName + ".";
addSystemChatMessage(msg);
}
}
}
};
// WotLK: uint64 playerGuid + uint8 teamCount + per-team fields
dispatchTable_[Opcode::MSG_INSPECT_ARENA_TEAMS] = [this](network::Packet& packet) {
// WotLK: uint64 playerGuid + uint8 teamCount + per-team fields
if (!packet.hasRemaining(9)) {
packet.skipAll();
return;
}
uint64_t inspGuid = packet.readUInt64();
uint8_t teamCount = packet.readUInt8();
if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5
if (socialHandler_) {
auto& ir = socialHandler_->mutableInspectResult();
if (inspGuid == ir.guid || ir.guid == 0) {
ir.guid = inspGuid;
ir.arenaTeams.clear();
for (uint8_t t = 0; t < teamCount; ++t) {
if (!packet.hasRemaining(21)) break;
SocialHandler::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.hasRemaining(4)) break;
team.personalRating = packet.readUInt32();
ir.arenaTeams.push_back(std::move(team));
}
}
}
LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec,
" teams=", static_cast<int>(teamCount));
};
// auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ...
// action: 0=sold/won, 1=expired, 2=bid placed on your auction
// auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32)
// uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled
// uint64 containerGuid — tells client to open this container
// The actual items come via update packets; we just log this.
// PackedGuid (player guid) + uint32 vehicleId
// vehicleId == 0 means the player left the vehicle
dispatchTable_[Opcode::SMSG_PLAYER_VEHICLE_DATA] = [this](network::Packet& packet) {
// PackedGuid (player guid) + uint32 vehicleId
// vehicleId == 0 means the player left the vehicle
if (packet.hasRemaining(1)) {
(void)packet.readPackedGuid(); // player guid (unused)
}
uint32_t newVehicleId = 0;
if (packet.hasRemaining(4)) {
newVehicleId = packet.readUInt32();
}
bool wasInVehicle = vehicleId_ != 0;
bool nowInVehicle = newVehicleId != 0;
vehicleId_ = newVehicleId;
if (wasInVehicle != nowInVehicle && vehicleStateCallback_) {
vehicleStateCallback_(nowInVehicle, newVehicleId);
}
};
// guid(8) + status(1): status 1 = NPC has available/new routes for this player
dispatchTable_[Opcode::SMSG_TAXINODE_STATUS] = [this](network::Packet& packet) {
// guid(8) + status(1): status 1 = NPC has available/new routes for this player
if (packet.hasRemaining(9)) {
uint64_t npcGuid = packet.readUInt64();
uint8_t status = packet.readUInt8();
taxiNpcHasRoutes_[npcGuid] = (status != 0);
}
};
// SMSG_GUILD_DECLINE — moved to SocialHandler::registerOpcodes
// Clear cached talent data so the talent screen reflects the reset.
dispatchTable_[Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET] = [this](network::Packet& packet) {
// Clear cached talent data so the talent screen reflects the reset.
if (spellHandler_) spellHandler_->resetTalentState();
addUIError("Your talents have been reset by the server.");
addSystemChatMessage("Your talents have been reset by the server.");
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_SET_REST_START] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t restTrigger = packet.readUInt32();
isResting_ = (restTrigger > 0);
addSystemChatMessage(isResting_ ? "You are now resting."
: "You are no longer resting.");
fireAddonEvent("PLAYER_UPDATE_RESTING", {});
}
};
dispatchTable_[Opcode::SMSG_UPDATE_AURA_DURATION] = [this](network::Packet& packet) {
if (packet.hasRemaining(5)) {
uint8_t slot = packet.readUInt8();
uint32_t durationMs = packet.readUInt32();
handleUpdateAuraDuration(slot, durationMs);
}
};
dispatchTable_[Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) {
if (packet.hasRemaining(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.skipAll();
};
dispatchTable_[Opcode::SMSG_MOUNTSPECIAL_ANIM] = [this](network::Packet& packet) { (void)packet.readPackedGuid(); };
dispatchTable_[Opcode::SMSG_CHAR_CUSTOMIZE] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint8_t result = packet.readUInt8();
addSystemChatMessage(result == 0 ? "Character customization complete."
: "Character customization failed.");
}
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_CHAR_FACTION_CHANGE] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint8_t result = packet.readUInt8();
addSystemChatMessage(result == 0 ? "Faction change complete."
: "Faction change failed.");
}
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_INVALIDATE_PLAYER] = [this](network::Packet& packet) {
if (packet.hasRemaining(8)) {
uint64_t guid = packet.readUInt64();
entityController_->invalidatePlayerName(guid);
}
};
// uint32 movieId — we don't play movies; acknowledge immediately.
dispatchTable_[Opcode::SMSG_TRIGGER_MOVIE] = [this](network::Packet& packet) {
// uint32 movieId — we don't play movies; acknowledge immediately.
packet.skipAll();
// 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");
}
};
// Server-side LFG invite timed out (no response within time limit)
dispatchTable_[Opcode::SMSG_LFG_TIMEDOUT] = [this](network::Packet& packet) {
// Server-side LFG invite timed out (no response within time limit)
addSystemChatMessage("Dungeon Finder: Invite timed out.");
if (openLfgCallback_) openLfgCallback_();
packet.skipAll();
};
// Another party member failed to respond to a LFG role-check in time
dispatchTable_[Opcode::SMSG_LFG_OTHER_TIMEDOUT] = [this](network::Packet& packet) {
// Another party member failed to respond to a LFG role-check in time
addSystemChatMessage("Dungeon Finder: Another player's invite timed out.");
if (openLfgCallback_) openLfgCallback_();
packet.skipAll();
};
// uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time)
dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED] = [this](network::Packet& packet) {
// uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time)
if (packet.hasRemaining(4)) {
uint32_t result = packet.readUInt32();
(void)result;
}
addUIError("Dungeon Finder: Auto-join failed.");
addSystemChatMessage("Dungeon Finder: Auto-join failed.");
packet.skipAll();
};
// No eligible players found for auto-join
dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER] = [this](network::Packet& packet) {
// No eligible players found for auto-join
addUIError("Dungeon Finder: No players available for auto-join.");
addSystemChatMessage("Dungeon Finder: No players available for auto-join.");
packet.skipAll();
};
// Party leader is currently set to Looking for More (LFM) mode
dispatchTable_[Opcode::SMSG_LFG_LEADER_IS_LFM] = [this](network::Packet& packet) {
// Party leader is currently set to Looking for More (LFM) mode
addSystemChatMessage("Your party leader is currently Looking for More.");
packet.skipAll();
};
// uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone
dispatchTable_[Opcode::SMSG_MEETINGSTONE_SETQUEUE] = [this](network::Packet& packet) {
// uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone
if (packet.hasRemaining(6)) {
uint32_t zoneId = packet.readUInt32();
uint8_t levelMin = packet.readUInt8();
uint8_t levelMax = packet.readUInt8();
char buf[128];
std::string zoneName = getAreaName(zoneId);
if (!zoneName.empty())
std::snprintf(buf, sizeof(buf),
"You are now in the Meeting Stone queue for %s (levels %u-%u).",
zoneName.c_str(), levelMin, levelMax);
else
std::snprintf(buf, sizeof(buf),
"You are now in the Meeting Stone queue for zone %u (levels %u-%u).",
zoneId, levelMin, levelMax);
addSystemChatMessage(buf);
LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId,
" levels=", static_cast<int>(levelMin), "-", static_cast<int>(levelMax));
}
packet.skipAll();
};
// Server confirms group found and teleport summon is ready
dispatchTable_[Opcode::SMSG_MEETINGSTONE_COMPLETE] = [this](network::Packet& packet) {
// Server confirms group found and teleport summon is ready
addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon.");
LOG_INFO("SMSG_MEETINGSTONE_COMPLETE");
packet.skipAll();
};
// Meeting stone search is still ongoing
dispatchTable_[Opcode::SMSG_MEETINGSTONE_IN_PROGRESS] = [this](network::Packet& packet) {
// Meeting stone search is still ongoing
addSystemChatMessage("Meeting Stone: Searching for group members...");
LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS");
packet.skipAll();
};
// uint64 memberGuid — a player was added to your group via meeting stone
dispatchTable_[Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED] = [this](network::Packet& packet) {
// uint64 memberGuid — a player was added to your group via meeting stone
if (packet.hasRemaining(8)) {
uint64_t memberGuid = packet.readUInt64();
const auto& memberName = lookupName(memberGuid);
if (!memberName.empty()) {
addSystemChatMessage("Meeting Stone: " + memberName +
" has been added to your group.");
} else {
addSystemChatMessage("Meeting Stone: A new player has been added to your group.");
}
LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec);
}
};
// uint8 reason — failed to join group via meeting stone
// 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available
dispatchTable_[Opcode::SMSG_MEETINGSTONE_JOINFAILED] = [this](network::Packet& packet) {
// uint8 reason — failed to join group via meeting stone
// 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available
static const char* kMeetingstoneErrors[] = {
"Target player is not using the Meeting Stone.",
"Target player is already in a group.",
"You are not in a valid zone for that Meeting Stone.",
"Target player is not available.",
};
if (packet.hasRemaining(1)) {
uint8_t reason = packet.readUInt8();
const char* msg = (reason < 4) ? kMeetingstoneErrors[reason]
: "Meeting Stone: Could not join group.";
addSystemChatMessage(msg);
LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", static_cast<int>(reason));
}
};
// Player was removed from the meeting stone queue (left, or group disbanded)
dispatchTable_[Opcode::SMSG_MEETINGSTONE_LEAVE] = [this](network::Packet& packet) {
// Player was removed from the meeting stone queue (left, or group disbanded)
addSystemChatMessage("You have left the Meeting Stone queue.");
LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE");
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_GMTICKET_CREATE] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint8_t res = packet.readUInt8();
addSystemChatMessage(res == 1 ? "GM ticket submitted."
: "Failed to submit GM ticket.");
}
};
dispatchTable_[Opcode::SMSG_GMTICKET_UPDATETEXT] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint8_t res = packet.readUInt8();
addSystemChatMessage(res == 1 ? "GM ticket updated."
: "Failed to update GM ticket.");
}
};
dispatchTable_[Opcode::SMSG_GMTICKET_DELETETICKET] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint8_t res = packet.readUInt8();
addSystemChatMessage(res == 9 ? "GM ticket deleted."
: "No ticket to delete.");
}
};
// WotLK 3.3.5a format:
// uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended
// If status == 6 (GMTICKET_STATUS_HASTEXT):
// cstring ticketText
// uint32 ticketAge (seconds old)
// uint32 daysUntilOld (days remaining before escalation)
// float waitTimeHours (estimated GM wait time)
dispatchTable_[Opcode::SMSG_GMTICKET_GETTICKET] = [this](network::Packet& packet) {
// WotLK 3.3.5a format:
// uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended
// If status == 6 (GMTICKET_STATUS_HASTEXT):
// cstring ticketText
// uint32 ticketAge (seconds old)
// uint32 daysUntilOld (days remaining before escalation)
// float waitTimeHours (estimated GM wait time)
if (!packet.hasRemaining(1)) { packet.skipAll(); return; }
uint8_t gmStatus = packet.readUInt8();
// Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text
if (gmStatus == 6 && packet.hasRemaining(1)) {
gmTicketText_ = packet.readString();
uint32_t ageSec = (packet.hasRemaining(4)) ? packet.readUInt32() : 0;
/*uint32_t daysLeft =*/ (packet.hasRemaining(4)) ? packet.readUInt32() : 0;
gmTicketWaitHours_ = (packet.hasRemaining(4))
? packet.readFloat() : 0.0f;
gmTicketActive_ = true;
char buf[256];
if (ageSec < 60) {
std::snprintf(buf, sizeof(buf),
"You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.",
ageSec, gmTicketWaitHours_);
} else {
uint32_t ageMin = ageSec / 60;
std::snprintf(buf, sizeof(buf),
"You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.",
ageMin, gmTicketWaitHours_);
}
addSystemChatMessage(buf);
LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec,
"s wait=", gmTicketWaitHours_, "h");
} else if (gmStatus == 3) {
gmTicketActive_ = false;
gmTicketText_.clear();
addSystemChatMessage("Your GM ticket has been closed.");
LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed");
} else if (gmStatus == 10) {
gmTicketActive_ = false;
gmTicketText_.clear();
addSystemChatMessage("Your GM ticket has been suspended.");
LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended");
} else {
// Status 1 = no open ticket (default/no ticket)
gmTicketActive_ = false;
gmTicketText_.clear();
LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", static_cast<int>(gmStatus), ")");
}
packet.skipAll();
};
// uint32 status: 1 = GM support available, 0 = offline/unavailable
dispatchTable_[Opcode::SMSG_GMTICKET_SYSTEMSTATUS] = [this](network::Packet& packet) {
// uint32 status: 1 = GM support available, 0 = offline/unavailable
if (packet.hasRemaining(4)) {
uint32_t sysStatus = packet.readUInt32();
gmSupportAvailable_ = (sysStatus != 0);
addSystemChatMessage(gmSupportAvailable_
? "GM support is currently available."
: "GM support is currently unavailable.");
LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_);
}
packet.skipAll();
};
// uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death)
dispatchTable_[Opcode::SMSG_CONVERT_RUNE] = [this](network::Packet& packet) {
// uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death)
if (!packet.hasRemaining(2)) {
packet.skipAll();
return;
}
uint8_t idx = packet.readUInt8();
uint8_t type = packet.readUInt8();
if (idx < 6) playerRunes_[idx].type = static_cast<RuneType>(type & 0x3);
};
// uint8 runeReadyMask (bit i=1 → rune i is ready)
// uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255)
dispatchTable_[Opcode::SMSG_RESYNC_RUNES] = [this](network::Packet& packet) {
// uint8 runeReadyMask (bit i=1 → rune i is ready)
// uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255)
if (!packet.hasRemaining(7)) {
packet.skipAll();
return;
}
uint8_t readyMask = packet.readUInt8();
for (int i = 0; i < 6; i++) {
uint8_t cd = packet.readUInt8();
playerRunes_[i].ready = (readyMask & (1u << i)) != 0;
playerRunes_[i].readyFraction = 1.0f - cd / 255.0f;
if (playerRunes_[i].ready) playerRunes_[i].readyFraction = 1.0f;
}
};
// uint32 runeMask (bit i=1 → rune i just became ready)
dispatchTable_[Opcode::SMSG_ADD_RUNE_POWER] = [this](network::Packet& packet) {
// uint32 runeMask (bit i=1 → rune i just became ready)
if (!packet.hasRemaining(4)) {
packet.skipAll();
return;
}
uint32_t runeMask = packet.readUInt32();
for (int i = 0; i < 6; i++) {
if (runeMask & (1u << i)) {
playerRunes_[i].ready = true;
playerRunes_[i].readyFraction = 1.0f;
}
}
};
// uint8 result: 0=success, 1=failed, 2=disabled
dispatchTable_[Opcode::SMSG_COMPLAIN_RESULT] = [this](network::Packet& packet) {
// uint8 result: 0=success, 1=failed, 2=disabled
if (packet.hasRemaining(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.skipAll();
};
// uint32 slot + packed_guid unit (0 packed = clear slot)
dispatchTable_[Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT] = [this](network::Packet& packet) {
// uint32 slot + packed_guid unit (0 packed = clear slot)
if (!packet.hasRemaining(5)) {
packet.skipAll();
return;
}
uint32_t slot = packet.readUInt32();
uint64_t unit = packet.readPackedGuid();
if (socialHandler_) {
socialHandler_->setEncounterUnitGuid(slot, unit);
LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot,
" guid=0x", std::hex, unit, std::dec);
}
};
// charName (cstring) + guid (uint64) + achievementId (uint32) + ...
dispatchTable_[Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT] = [this](network::Packet& packet) {
// charName (cstring) + guid (uint64) + achievementId (uint32) + ...
if (packet.hasData()) {
std::string charName = packet.readString();
if (packet.hasRemaining(12)) {
/*uint64_t guid =*/ packet.readUInt64();
uint32_t achievementId = packet.readUInt32();
loadAchievementNameCache();
auto nit = achievementNameCache_.find(achievementId);
char buf[256];
if (nit != achievementNameCache_.end() && !nit->second.empty()) {
std::snprintf(buf, sizeof(buf),
"%s is the first on the realm to earn: %s!",
charName.c_str(), nit->second.c_str());
} else {
std::snprintf(buf, sizeof(buf),
"%s is the first on the realm to earn achievement #%u!",
charName.c_str(), achievementId);
}
addSystemChatMessage(buf);
}
}
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_SUSPEND_COMMS] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t seqIdx = packet.readUInt32();
if (socket) {
network::Packet ack(wireOpcode(Opcode::CMSG_SUSPEND_COMMS_ACK));
ack.writeUInt32(seqIdx);
socket->send(ack);
}
}
};
// SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect.
// Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock),
// or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept.
dispatchTable_[Opcode::SMSG_PRE_RESURRECT] = [this](network::Packet& packet) {
// SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect.
// Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock),
// or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept.
uint64_t targetGuid = packet.readPackedGuid();
if (targetGuid == playerGuid || targetGuid == 0) {
selfResAvailable_ = true;
LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x",
std::hex, targetGuid, std::dec, ")");
}
};
dispatchTable_[Opcode::SMSG_PLAYERBINDERROR] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t error = packet.readUInt32();
if (error == 0) {
addUIError("Your hearthstone is not bound.");
addSystemChatMessage("Your hearthstone is not bound.");
} else {
addUIError("Hearthstone bind failed.");
addSystemChatMessage("Hearthstone bind failed.");
}
}
};
dispatchTable_[Opcode::SMSG_RAID_GROUP_ONLY] = [this](network::Packet& packet) {
addUIError("You must be in a raid group to enter this instance.");
addSystemChatMessage("You must be in a raid group to enter this instance.");
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_RAID_READY_CHECK_ERROR] = [this](network::Packet& packet) {
if (packet.hasRemaining(1)) {
uint8_t err = packet.readUInt8();
if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); }
else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); }
else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); }
}
};
dispatchTable_[Opcode::SMSG_RESET_FAILED_NOTIFY] = [this](network::Packet& packet) {
addUIError("Cannot reset instance: another player is still inside.");
addSystemChatMessage("Cannot reset instance: another player is still inside.");
packet.skipAll();
};
// uint32 splitType + uint32 deferTime + string realmName
// Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers.
dispatchTable_[Opcode::SMSG_REALM_SPLIT] = [this](network::Packet& packet) {
// uint32 splitType + uint32 deferTime + string realmName
// Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers.
uint32_t splitType = 0;
if (packet.hasRemaining(4))
splitType = packet.readUInt32();
packet.skipAll();
if (socket) {
network::Packet resp(wireOpcode(Opcode::CMSG_REALM_SPLIT));
resp.writeUInt32(splitType);
resp.writeString("3.3.5");
socket->send(resp);
LOG_DEBUG("SMSG_REALM_SPLIT splitType=", splitType, " — sent CMSG_REALM_SPLIT ack");
}
};
dispatchTable_[Opcode::SMSG_REAL_GROUP_UPDATE] = [this](network::Packet& packet) {
auto rem = [&]() { return packet.getRemainingSize(); };
if (rem() < 1) return;
uint8_t newGroupType = packet.readUInt8();
if (rem() < 4) return;
uint32_t newMemberFlags = packet.readUInt32();
if (rem() < 8) return;
uint64_t newLeaderGuid = packet.readUInt64();
if (socialHandler_) {
auto& pd = socialHandler_->mutablePartyData();
pd.groupType = newGroupType;
pd.leaderGuid = newLeaderGuid;
// Update local player's flags in the member list
uint64_t localGuid = playerGuid;
for (auto& m : pd.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);
fireAddonEvent("PARTY_LEADER_CHANGED", {});
fireAddonEvent("GROUP_ROSTER_UPDATE", {});
};
dispatchTable_[Opcode::SMSG_PLAY_MUSIC] = [this](network::Packet& packet) {
if (packet.hasRemaining(4)) {
uint32_t soundId = packet.readUInt32();
if (playMusicCallback_) playMusicCallback_(soundId);
}
};
dispatchTable_[Opcode::SMSG_PLAY_OBJECT_SOUND] = [this](network::Packet& packet) {
if (packet.hasRemaining(12)) {
// uint32 soundId + uint64 sourceGuid
uint32_t soundId = packet.readUInt32();
uint64_t srcGuid = packet.readUInt64();
LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec);
if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid);
else if (playSoundCallback_) playSoundCallback_(soundId);
} else if (packet.hasRemaining(4)) {
uint32_t soundId = packet.readUInt32();
if (playSoundCallback_) playSoundCallback_(soundId);
}
};
// uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL)
dispatchTable_[Opcode::SMSG_PLAY_SPELL_IMPACT] = [this](network::Packet& packet) {
// uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL)
if (!packet.hasRemaining(12)) {
packet.skipAll(); return;
}
uint64_t impTargetGuid = packet.readUInt64();
uint32_t impVisualId = packet.readUInt32();
if (impVisualId == 0) return;
auto* renderer = services_.renderer;
if (!renderer) return;
glm::vec3 spawnPos;
if (impTargetGuid == playerGuid) {
spawnPos = renderer->getCharacterPosition();
} else {
auto entity = entityController_->getEntityManager().getEntity(impTargetGuid);
if (!entity) return;
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
spawnPos = core::coords::canonicalToRender(canonical);
}
if (auto* sv = renderer->getSpellVisualSystem()) sv->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true);
};
// SMSG_READ_ITEM_OK — moved to InventoryHandler::registerOpcodes
// SMSG_READ_ITEM_FAILED — moved to InventoryHandler::registerOpcodes
// SMSG_QUERY_QUESTS_COMPLETED_RESPONSE — moved to QuestHandler::registerOpcodes
dispatchTable_[Opcode::SMSG_NPC_WONT_TALK] = [this](network::Packet& packet) {
addUIError("That creature can't talk to you right now.");
addSystemChatMessage("That creature can't talk to you right now.");
packet.skipAll();
};
// SMSG_PET_UNLEARN_CONFIRM: uint64 petGuid + uint32 cost (copper).
// The other pet opcodes have different formats and must NOT set unlearn state.
dispatchTable_[Opcode::SMSG_PET_UNLEARN_CONFIRM] = [this](network::Packet& packet) {
if (packet.hasRemaining(12)) {
petUnlearnGuid_ = packet.readUInt64();
petUnlearnCost_ = packet.readUInt32();
petUnlearnPending_ = true;
}
packet.skipAll();
};
// These pet opcodes have incompatible formats — just consume the packet.
// Previously they shared the unlearn handler, which misinterpreted sound IDs
// or GUID lists as unlearn costs and could trigger a bogus unlearn dialog.
for (auto op : { Opcode::SMSG_PET_GUIDS, Opcode::SMSG_PET_DISMISS_SOUND,
Opcode::SMSG_PET_ACTION_SOUND }) {
dispatchTable_[op] = [](network::Packet& packet) { packet.skipAll(); };
}
// Server signals that the pet can now be named (first tame)
dispatchTable_[Opcode::SMSG_PET_RENAMEABLE] = [this](network::Packet& packet) {
// Server signals that the pet can now be named (first tame)
petRenameablePending_ = true;
packet.skipAll();
};
dispatchTable_[Opcode::SMSG_PET_NAME_INVALID] = [this](network::Packet& packet) {
addUIError("That pet name is invalid. Please choose a different name.");
addSystemChatMessage("That pet name is invalid. Please choose a different name.");
packet.skipAll();
};
// Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19)
// This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to
// SMSG_INSPECT_RESULTS_UPDATE which is handled separately.
dispatchTable_[Opcode::SMSG_INSPECT] = [this](network::Packet& packet) {
// Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19)
// This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to
// SMSG_INSPECT_RESULTS_UPDATE which is handled separately.
if (!packet.hasRemaining(2)) {
packet.skipAll(); return;
}
uint64_t guid = packet.readPackedGuid();
if (guid == 0) { packet.skipAll(); return; }
constexpr int kGearSlots = 19;
size_t needed = kGearSlots * sizeof(uint32_t);
if (!packet.hasRemaining(needed)) {
packet.skipAll(); return;
}
std::array<uint32_t, 19> items{};
for (int s = 0; s < kGearSlots; ++s)
items[s] = packet.readUInt32();
// Resolve player name
auto ent = entityController_->getEntityManager().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)
if (socialHandler_) {
auto& ir = socialHandler_->mutableInspectResult();
ir.guid = guid;
ir.playerName = playerName;
ir.totalTalents = 0;
ir.unspentTalents = 0;
ir.talentGroups = 0;
ir.activeTalentGroup = 0;
ir.itemEntries = items;
ir.enchantIds = {};
}
// Also cache for future talent-inspect cross-reference
inspectedPlayerItemEntries_[guid] = items;
// Trigger item queries for non-empty slots
for (int s = 0; s < kGearSlots; ++s) {
if (items[s] != 0) queryItemInfo(items[s], 0);
}
LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ",
std::count_if(items.begin(), items.end(),
[](uint32_t e) { return e != 0; }), "/19 slots");
if (addonEventCallback_) {
char guidBuf[32];
snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid);
fireAddonEvent("INSPECT_READY", {guidBuf});
}
};
// Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[]
dispatchTable_[Opcode::SMSG_MULTIPLE_MOVES] = [this](network::Packet& packet) {
// Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[]
if (movementHandler_) movementHandler_->handleCompressedMoves(packet);
};
// Each sub-packet uses the standard WotLK server wire format:
// uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2)
// uint16_le subOpcode
// payload (subSize - 2 bytes)
dispatchTable_[Opcode::SMSG_MULTIPLE_PACKETS] = [this](network::Packet& packet) {
// Each sub-packet uses the standard WotLK server wire format:
// uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2)
// uint16_le subOpcode
// payload (subSize - 2 bytes)
const auto& pdata = packet.getData();
size_t dataLen = pdata.size();
size_t pos = packet.getReadPos();
static uint32_t multiPktWarnCount = 0;
std::vector<network::Packet> subPackets;
while (pos + 4 <= dataLen) {
uint16_t subSize = static_cast<uint16_t>(
(static_cast<uint16_t>(pdata[pos]) << 8) | pdata[pos + 1]);
if (subSize < 2) break;
size_t payloadLen = subSize - 2;
if (pos + 4 + payloadLen > dataLen) {
if (++multiPktWarnCount <= 10) {
LOG_WARNING("SMSG_MULTIPLE_PACKETS: sub-packet overruns buffer at pos=",
pos, " subSize=", subSize, " dataLen=", dataLen);
}
break;
}
uint16_t subOpcode = static_cast<uint16_t>(pdata[pos + 2]) |
(static_cast<uint16_t>(pdata[pos + 3]) << 8);
std::vector<uint8_t> subPayload(pdata.begin() + pos + 4,
pdata.begin() + pos + 4 + payloadLen);
subPackets.emplace_back(subOpcode, std::move(subPayload));
pos += 4 + payloadLen;
}
for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) {
enqueueIncomingPacketFront(std::move(*it));
}
packet.skipAll();
};
// Recruit-A-Friend: a mentor is offering to grant you a level
dispatchTable_[Opcode::SMSG_PROPOSE_LEVEL_GRANT] = [this](network::Packet& packet) {
// Recruit-A-Friend: a mentor is offering to grant you a level
if (packet.hasRemaining(8)) {
uint64_t mentorGuid = packet.readUInt64();
std::string mentorName;
auto ent = entityController_->getEntityManager().getEntity(mentorGuid);
if (auto* unit = dynamic_cast<Unit*>(ent.get())) mentorName = unit->getName();
if (mentorName.empty()) mentorName = lookupName(mentorGuid);
addSystemChatMessage(mentorName.empty()
? "A player is offering to grant you a level."
: (mentorName + " is offering to grant you a level."));
}
packet.skipAll();
};
// SMSG_REFER_A_FRIEND_EXPIRED — moved to SocialHandler::registerOpcodes
// SMSG_REFER_A_FRIEND_FAILURE — moved to SocialHandler::registerOpcodes
// SMSG_REPORT_PVP_AFK_RESULT — moved to SocialHandler::registerOpcodes
dispatchTable_[Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS] = [this](network::Packet& packet) {
loadAchievementNameCache();
if (!packet.hasRemaining(1)) return;
uint64_t inspectedGuid = packet.readPackedGuid();
if (inspectedGuid == 0) { packet.skipAll(); return; }
std::unordered_set<uint32_t> achievements;
while (packet.hasRemaining(4)) {
uint32_t id = packet.readUInt32();
if (id == 0xFFFFFFFF) break;
if (!packet.hasRemaining(4)) break;
/*date*/ packet.readUInt32();
achievements.insert(id);
}
while (packet.hasRemaining(4)) {
uint32_t id = packet.readUInt32();
if (id == 0xFFFFFFFF) break;
if (!packet.hasRemaining(16)) break;
packet.readUInt64(); packet.readUInt32(); packet.readUInt32();
}
inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements);
LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec,
" achievements=", inspectedPlayerAchievements_[inspectedGuid].size());
};
dispatchTable_[Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA] = [this](network::Packet& packet) {
vehicleId_ = 0; // Vehicle ride cancelled; clear UI
if (vehicleStateCallback_) {
vehicleStateCallback_(false, 0);
}
packet.skipAll();
};
// uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played
dispatchTable_[Opcode::SMSG_PLAY_TIME_WARNING] = [this](network::Packet& packet) {
// uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played
if (packet.hasRemaining(4)) {
uint32_t warnType = packet.readUInt32();
uint32_t minutesPlayed = (packet.hasRemaining(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);
}
};
// WotLK 3.3.5a format:
// uint64 mirrorGuid — GUID of the mirror image unit
// uint32 displayId — display ID to render the image with
// uint8 raceId — race of caster
// uint8 genderFlag — gender of caster
// uint8 classId — class of caster
// uint64 casterGuid — GUID of the player who cast the spell
// Followed by equipped item display IDs (11 × uint32) if casterGuid != 0
// Purpose: tells client how to render the image (same appearance as caster).
// We parse the GUIDs so units render correctly via their existing display IDs.
dispatchTable_[Opcode::SMSG_MIRRORIMAGE_DATA] = [this](network::Packet& packet) {
// WotLK 3.3.5a format:
// uint64 mirrorGuid — GUID of the mirror image unit
// uint32 displayId — display ID to render the image with
// uint8 raceId — race of caster
// uint8 genderFlag — gender of caster
// uint8 classId — class of caster
// uint64 casterGuid — GUID of the player who cast the spell
// Followed by equipped item display IDs (11 × uint32) if casterGuid != 0
// Purpose: tells client how to render the image (same appearance as caster).
// We parse the GUIDs so units render correctly via their existing display IDs.
if (!packet.hasRemaining(8)) return;
uint64_t mirrorGuid = packet.readUInt64();
if (!packet.hasRemaining(4)) return;
uint32_t displayId = packet.readUInt32();
if (!packet.hasRemaining(3)) return;
/*uint8_t raceId =*/ packet.readUInt8();
/*uint8_t gender =*/ packet.readUInt8();
/*uint8_t classId =*/ packet.readUInt8();
// Apply display ID to the mirror image unit so it renders correctly
if (mirrorGuid != 0 && displayId != 0) {
auto entity = entityController_->getEntityManager().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.skipAll();
};
// uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds)
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE] = [this](network::Packet& packet) {
// uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds)
if (!packet.hasRemaining(20)) {
packet.skipAll(); return;
}
uint64_t bfGuid = packet.readUInt64();
uint32_t bfZoneId = packet.readUInt32();
uint64_t expireTime = packet.readUInt64();
(void)bfGuid; (void)expireTime;
// Store the invitation so the UI can show a prompt
bfMgrInvitePending_ = true;
bfMgrZoneId_ = bfZoneId;
char buf[128];
std::string bfZoneName = getAreaName(bfZoneId);
if (!bfZoneName.empty())
std::snprintf(buf, sizeof(buf),
"You are invited to the outdoor battlefield in %s. Click to enter.",
bfZoneName.c_str());
else
std::snprintf(buf, sizeof(buf),
"You are invited to the outdoor battlefield in zone %u. Click to enter.",
bfZoneId);
addSystemChatMessage(buf);
LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId);
};
// uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTERED] = [this](network::Packet& packet) {
// uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue
if (packet.hasRemaining(8)) {
uint64_t bfGuid2 = packet.readUInt64();
(void)bfGuid2;
uint8_t isSafe = (packet.hasRemaining(1)) ? packet.readUInt8() : 0;
uint8_t onQueue = (packet.hasRemaining(1)) ? packet.readUInt8() : 0;
bfMgrInvitePending_ = false;
bfMgrActive_ = true;
addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)."
: "You have entered the battlefield!");
if (onQueue) addSystemChatMessage("You are in the battlefield queue.");
LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", static_cast<int>(isSafe), " onQueue=", static_cast<int>(onQueue));
}
packet.skipAll();
};
// uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE] = [this](network::Packet& packet) {
// uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime
if (!packet.hasRemaining(20)) {
packet.skipAll(); return;
}
uint64_t bfGuid3 = packet.readUInt64();
uint32_t bfId = packet.readUInt32();
uint64_t expTime = packet.readUInt64();
(void)bfGuid3; (void)expTime;
bfMgrInvitePending_ = true;
bfMgrZoneId_ = bfId;
char buf[128];
std::snprintf(buf, sizeof(buf),
"A spot has opened in the battlefield queue (battlefield %u).", bfId);
addSystemChatMessage(buf);
LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId);
};
// uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result
// result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level,
// 4=in_cooldown, 5=queued_other_bf, 6=bf_full
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE] = [this](network::Packet& packet) {
// uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result
// result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level,
// 4=in_cooldown, 5=queued_other_bf, 6=bf_full
if (!packet.hasRemaining(11)) {
packet.skipAll(); return;
}
uint32_t bfId2 = packet.readUInt32();
/*uint32_t teamId =*/ packet.readUInt32();
uint8_t accepted = packet.readUInt8();
/*uint8_t logging =*/ packet.readUInt8();
uint8_t result = packet.readUInt8();
(void)bfId2;
if (accepted) {
addSystemChatMessage("You have joined the battlefield queue.");
} else {
static const char* kBfQueueErrors[] = {
"Queued for battlefield.", "Not in a group.", "Level too high.",
"Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.",
"Battlefield is full."
};
const char* msg = (result < 7) ? kBfQueueErrors[result]
: "Battlefield queue request failed.";
addSystemChatMessage(std::string("Battlefield: ") + msg);
}
LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", static_cast<int>(accepted),
" result=", static_cast<int>(result));
packet.skipAll();
};
// uint64 battlefieldGuid + uint8 remove
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING] = [this](network::Packet& packet) {
// uint64 battlefieldGuid + uint8 remove
if (packet.hasRemaining(9)) {
uint64_t bfGuid4 = packet.readUInt64();
uint8_t remove = packet.readUInt8();
(void)bfGuid4;
if (remove) {
addSystemChatMessage("You will be removed from the battlefield shortly.");
}
LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", static_cast<int>(remove));
}
packet.skipAll();
};
// uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECTED] = [this](network::Packet& packet) {
// uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated
if (packet.hasRemaining(17)) {
uint64_t bfGuid5 = packet.readUInt64();
uint32_t reason = packet.readUInt32();
/*uint32_t status =*/ packet.readUInt32();
uint8_t relocated = packet.readUInt8();
(void)bfGuid5;
static const char* kEjectReasons[] = {
"Removed from battlefield.", "Transported from battlefield.",
"Left battlefield voluntarily.", "Offline.",
};
const char* msg = (reason < 4) ? kEjectReasons[reason]
: "You have been ejected from the battlefield.";
addSystemChatMessage(msg);
if (relocated) addSystemChatMessage("You have been relocated outside the battlefield.");
LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", static_cast<int>(relocated));
}
bfMgrActive_ = false;
bfMgrInvitePending_ = false;
packet.skipAll();
};
// uint32 oldState + uint32 newState
// States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown
dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE] = [this](network::Packet& packet) {
// uint32 oldState + uint32 newState
// States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown
if (packet.hasRemaining(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.skipAll();
};
// uint32 numPending — number of unacknowledged calendar invites
dispatchTable_[Opcode::SMSG_CALENDAR_SEND_NUM_PENDING] = [this](network::Packet& packet) {
// uint32 numPending — number of unacknowledged calendar invites
if (packet.hasRemaining(4)) {
uint32_t numPending = packet.readUInt32();
calendarPendingInvites_ = numPending;
if (numPending > 0) {
char buf[64];
std::snprintf(buf, sizeof(buf),
"You have %u pending calendar invite%s.",
numPending, numPending == 1 ? "" : "s");
addSystemChatMessage(buf);
}
LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites");
}
};
// uint32 command + uint8 result + cstring info
// result 0 = success; non-zero = error code
// command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove,
// 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status
dispatchTable_[Opcode::SMSG_CALENDAR_COMMAND_RESULT] = [this](network::Packet& packet) {
// uint32 command + uint8 result + cstring info
// result 0 = success; non-zero = error code
// command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove,
// 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status
if (!packet.hasRemaining(5)) {
packet.skipAll(); return;
}
/*uint32_t command =*/ packet.readUInt32();
uint8_t result = packet.readUInt8();
std::string info = (packet.hasData()) ? 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.skipAll();
};
// Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) +
// eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) +
// isGuildEvent(1) + inviterGuid(8)
dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT] = [this](network::Packet& packet) {
// Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) +
// eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) +
// isGuildEvent(1) + inviterGuid(8)
if (!packet.hasRemaining(9)) {
packet.skipAll(); return;
}
/*uint64_t eventId =*/ packet.readUInt64();
std::string title = (packet.hasData()) ? packet.readString() : "";
packet.skipAll(); // consume remaining fields
if (!title.empty()) {
addSystemChatMessage("Calendar invite: " + title);
} else {
addSystemChatMessage("You have a new calendar invite.");
}
if (calendarPendingInvites_ < 255) ++calendarPendingInvites_;
LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'");
};
// Sent when an event invite's RSVP status changes for the local player
// Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) +
// inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring)
dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_STATUS] = [this](network::Packet& packet) {
// Sent when an event invite's RSVP status changes for the local player
// Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) +
// inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring)
if (!packet.hasRemaining(31)) {
packet.skipAll(); return;
}
/*uint64_t inviteId =*/ packet.readUInt64();
/*uint64_t eventId =*/ packet.readUInt64();
/*uint8_t evType =*/ packet.readUInt8();
/*uint32_t flags =*/ packet.readUInt32();
/*uint64_t invTime =*/ packet.readUInt64();
uint8_t status = packet.readUInt8();
/*uint8_t rank =*/ packet.readUInt8();
/*uint8_t isGuild =*/ packet.readUInt8();
std::string evTitle = (packet.hasData()) ? 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.skipAll();
};
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime
dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED] = [this](network::Packet& packet) {
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime
if (packet.hasRemaining(28)) {
/*uint64_t inviteId =*/ packet.readUInt64();
/*uint64_t eventId =*/ packet.readUInt64();
uint32_t mapId = packet.readUInt32();
uint32_t difficulty = packet.readUInt32();
/*uint64_t resetTime =*/ packet.readUInt64();
std::string mapLabel = getMapName(mapId);
if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId);
static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"};
const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr;
std::string msg = "Calendar: Raid lockout added for " + mapLabel;
if (diffStr) msg += std::string(" (") + diffStr + ")";
msg += '.';
addSystemChatMessage(msg);
LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty);
}
packet.skipAll();
};
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty
dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED] = [this](network::Packet& packet) {
// uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty
if (packet.hasRemaining(20)) {
/*uint64_t inviteId =*/ packet.readUInt64();
/*uint64_t eventId =*/ packet.readUInt64();
uint32_t mapId = packet.readUInt32();
uint32_t difficulty = packet.readUInt32();
std::string mapLabel = getMapName(mapId);
if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId);
static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"};
const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr;
std::string msg = "Calendar: Raid lockout removed for " + mapLabel;
if (diffStr) msg += std::string(" (") + diffStr + ")";
msg += '.';
addSystemChatMessage(msg);
LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId,
" difficulty=", difficulty);
}
packet.skipAll();
};
// uint32 unixTime — server's current unix timestamp; use to sync gameTime_
dispatchTable_[Opcode::SMSG_SERVERTIME] = [this](network::Packet& packet) {
// uint32 unixTime — server's current unix timestamp; use to sync gameTime_
if (packet.hasRemaining(4)) {
uint32_t srvTime = packet.readUInt32();
if (srvTime > 0) {
gameTime_ = static_cast<float>(srvTime);
LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime);
}
}
};
// uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string
// kickReasonType: 0=other, 1=afk, 2=vote kick
dispatchTable_[Opcode::SMSG_KICK_REASON] = [this](network::Packet& packet) {
// uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string
// kickReasonType: 0=other, 1=afk, 2=vote kick
if (!packet.hasRemaining(12)) {
packet.skipAll();
return;
}
uint64_t kickerGuid = packet.readUInt64();
uint32_t reasonType = packet.readUInt32();
std::string reason;
if (packet.hasData())
reason = packet.readString();
(void)kickerGuid; // not displayed; reasonType IS used below
std::string msg = "You have been removed from the group.";
if (!reason.empty())
msg = "You have been removed from the group: " + reason;
else if (reasonType == 1)
msg = "You have been removed from the group for being AFK.";
else if (reasonType == 2)
msg = "You have been removed from the group by vote.";
addSystemChatMessage(msg);
addUIError(msg);
LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType,
" reason='", reason, "'");
};
// uint32 throttleMs — rate-limited group action; notify the player
dispatchTable_[Opcode::SMSG_GROUPACTION_THROTTLED] = [this](network::Packet& packet) {
// uint32 throttleMs — rate-limited group action; notify the player
if (packet.hasRemaining(4)) {
uint32_t throttleMs = packet.readUInt32();
char buf[128];
if (throttleMs > 0) {
std::snprintf(buf, sizeof(buf),
"Group action throttled. Please wait %.1f seconds.",
throttleMs / 1000.0f);
} else {
std::snprintf(buf, sizeof(buf), "Group action throttled.");
}
addSystemChatMessage(buf);
LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs);
}
};
// WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count
// per count: string responseText
dispatchTable_[Opcode::SMSG_GMRESPONSE_RECEIVED] = [this](network::Packet& packet) {
// WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count
// per count: string responseText
if (!packet.hasRemaining(4)) {
packet.skipAll();
return;
}
uint32_t ticketId = packet.readUInt32();
std::string subject;
std::string body;
if (packet.hasData()) subject = packet.readString();
if (packet.hasData()) body = packet.readString();
uint32_t responseCount = 0;
if (packet.hasRemaining(4))
responseCount = packet.readUInt32();
std::string responseText;
for (uint32_t i = 0; i < responseCount && i < 10; ++i) {
if (packet.hasData()) {
std::string t = packet.readString();
if (i == 0) responseText = t;
}
}
(void)ticketId;
std::string msg;
if (!responseText.empty())
msg = "[GM Response] " + responseText;
else if (!body.empty())
msg = "[GM Response] " + body;
else if (!subject.empty())
msg = "[GM Response] " + subject;
else
msg = "[GM Response] Your ticket has been answered.";
addSystemChatMessage(msg);
addUIError(msg);
LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId,
" subject='", subject, "'");
};
// uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help)
dispatchTable_[Opcode::SMSG_GMRESPONSE_STATUS_UPDATE] = [this](network::Packet& packet) {
// uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help)
if (packet.hasRemaining(5)) {
uint32_t ticketId = packet.readUInt32();
uint8_t status = packet.readUInt8();
const char* statusStr = (status == 1) ? "open"
: (status == 2) ? "answered"
: (status == 3) ? "needs more info"
: "updated";
char buf[128];
std::snprintf(buf, sizeof(buf),
"[GM Ticket #%u] Status: %s.", ticketId, statusStr);
addSystemChatMessage(buf);
LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId,
" status=", static_cast<int>(status));
}
};
// GM ticket status (new/updated); no ticket UI yet
registerSkipHandler(Opcode::SMSG_GM_TICKET_STATUS_UPDATE);
// Client uses this outbound; treat inbound variant as no-op for robustness.
registerSkipHandler(Opcode::MSG_MOVE_WORLDPORT_ACK);
// Observed custom server packet (8 bytes). Safe-consume for now.
registerSkipHandler(Opcode::MSG_MOVE_TIME_SKIPPED);
// loggingOut_ already cleared by cancelLogout(); this is server's confirmation
registerSkipHandler(Opcode::SMSG_LOGOUT_CANCEL_ACK);
// These packets are not damage-shield events. Consume them without
// synthesizing reflected damage entries or misattributing GUIDs.
registerSkipHandler(Opcode::SMSG_AURACASTLOG);
// These packets are not damage-shield events. Consume them without
// synthesizing reflected damage entries or misattributing GUIDs.
registerSkipHandler(Opcode::SMSG_SPELLBREAKLOG);
// Consume silently — informational, no UI action needed
registerSkipHandler(Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE);
// Consume silently — informational, no UI action needed
registerSkipHandler(Opcode::SMSG_LOOT_LIST);
// Same format as LOCKOUT_ADDED; consume
registerSkipHandler(Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED);
// Consume — remaining server notifications not yet parsed
for (auto op : {
Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE,
Opcode::SMSG_AUCTION_LIST_PENDING_SALES,
Opcode::SMSG_AVAILABLE_VOICE_CHANNEL,
Opcode::SMSG_CALENDAR_ARENA_TEAM,
Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION,
Opcode::SMSG_CALENDAR_EVENT_INVITE,
Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES,
Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT,
Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED,
Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT,
Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT,
Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT,
Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT,
Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT,
Opcode::SMSG_CALENDAR_FILTER_GUILD,
Opcode::SMSG_CALENDAR_SEND_CALENDAR,
Opcode::SMSG_CALENDAR_SEND_EVENT,
Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE,
Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE,
Opcode::SMSG_CHEAT_PLAYER_LOOKUP,
Opcode::SMSG_CHECK_FOR_BOTS,
Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO,
Opcode::SMSG_COMMENTATOR_MAP_INFO,
Opcode::SMSG_COMMENTATOR_PLAYER_INFO,
Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1,
Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2,
Opcode::SMSG_COMMENTATOR_STATE_CHANGED,
Opcode::SMSG_COOLDOWN_CHEAT,
Opcode::SMSG_DANCE_QUERY_RESPONSE,
Opcode::SMSG_DBLOOKUP,
Opcode::SMSG_DEBUGAURAPROC,
Opcode::SMSG_DEBUG_AISTATE,
Opcode::SMSG_DEBUG_LIST_TARGETS,
Opcode::SMSG_DEBUG_SERVER_GEO,
Opcode::SMSG_DUMP_OBJECTS_DATA,
Opcode::SMSG_FORCEACTIONSHOW,
Opcode::SMSG_GM_PLAYER_INFO,
Opcode::SMSG_GODMODE,
Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT,
Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT,
Opcode::SMSG_INVALIDATE_DANCE,
Opcode::SMSG_LFG_PENDING_INVITE,
Opcode::SMSG_LFG_PENDING_MATCH,
Opcode::SMSG_LFG_PENDING_MATCH_DONE,
Opcode::SMSG_LFG_UPDATE,
Opcode::SMSG_LFG_UPDATE_LFG,
Opcode::SMSG_LFG_UPDATE_LFM,
Opcode::SMSG_LFG_UPDATE_QUEUED,
Opcode::SMSG_MOVE_CHARACTER_CHEAT,
Opcode::SMSG_NOTIFY_DANCE,
Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST,
Opcode::SMSG_PETGODMODE,
Opcode::SMSG_PET_UPDATE_COMBO_POINTS,
Opcode::SMSG_PLAYER_SKINNED,
Opcode::SMSG_PLAY_DANCE,
Opcode::SMSG_PROFILEDATA_RESPONSE,
Opcode::SMSG_PVP_QUEUE_STATS,
Opcode::SMSG_QUERY_OBJECT_POSITION,
Opcode::SMSG_QUERY_OBJECT_ROTATION,
Opcode::SMSG_REDIRECT_CLIENT,
Opcode::SMSG_RESET_RANGED_COMBAT_TIMER,
Opcode::SMSG_SEND_ALL_COMBAT_LOG,
Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE,
Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT,
Opcode::SMSG_SET_PROJECTILE_POSITION,
Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK,
Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS,
Opcode::SMSG_STOP_DANCE,
Opcode::SMSG_TEST_DROP_RATE_RESULT,
Opcode::SMSG_UPDATE_ACCOUNT_DATA,
Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE,
Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP,
Opcode::SMSG_UPDATE_LAST_INSTANCE,
Opcode::SMSG_VOICESESSION_FULL,
Opcode::SMSG_VOICE_CHAT_STATUS,
Opcode::SMSG_VOICE_PARENTAL_CONTROLS,
Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY,
Opcode::SMSG_VOICE_SESSION_ENABLE,
Opcode::SMSG_VOICE_SESSION_LEAVE,
Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE,
Opcode::SMSG_VOICE_SET_TALKER_MUTED
}) { registerSkipHandler(op); }
// -----------------------------------------------------------------------
// Domain handler registrations (override duplicate entries above)
// -----------------------------------------------------------------------
chatHandler_->registerOpcodes(dispatchTable_);
movementHandler_->registerOpcodes(dispatchTable_);
combatHandler_->registerOpcodes(dispatchTable_);
spellHandler_->registerOpcodes(dispatchTable_);
inventoryHandler_->registerOpcodes(dispatchTable_);
socialHandler_->registerOpcodes(dispatchTable_);
questHandler_->registerOpcodes(dispatchTable_);
wardenHandler_->registerOpcodes(dispatchTable_);
}
void GameHandler::handlePacket(network::Packet& packet) {
if (packet.getSize() < 1) {
LOG_DEBUG("Received empty world packet (ignored)");
return;
}
uint16_t opcode = packet.getOpcode();
try {
const bool allowVanillaAliases = isPreWotlk();
// Vanilla compatibility aliases:
// - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers
// and SMSG_WEATHER on others
// - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers)
//
// We gate these by payload shape so expansion-native mappings remain intact.
if (allowVanillaAliases && opcode == 0x006B) {
// Try compressed movement batch first:
// [u8 subSize][u16 subOpcode][subPayload...] ...
// where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT.
const auto& data = packet.getData();
if (packet.getReadPos() + 3 <= data.size()) {
size_t pos = packet.getReadPos();
uint8_t subSize = data[pos];
if (subSize >= 2 && pos + 1 + subSize <= data.size()) {
uint16_t subOpcode = static_cast<uint16_t>(data[pos + 1]) |
(static_cast<uint16_t>(data[pos + 2]) << 8);
uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE);
uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT);
if ((monsterMoveWire != 0xFFFF && subOpcode == monsterMoveWire) ||
(monsterMoveTransportWire != 0xFFFF && subOpcode == monsterMoveTransportWire)) {
LOG_INFO("Opcode 0x006B interpreted as SMSG_COMPRESSED_MOVES (subOpcode=0x",
std::hex, subOpcode, std::dec, ")");
if (movementHandler_) movementHandler_->handleCompressedMoves(packet);
return;
}
}
}
// Expected weather payload: uint32 weatherType, float intensity, uint8 abrupt
if (packet.hasRemaining(9)) {
uint32_t wType = packet.readUInt32();
float wIntensity = packet.readFloat();
uint8_t abrupt = packet.readUInt8();
bool plausibleWeather =
(wType <= 3) &&
std::isfinite(wIntensity) &&
(wIntensity >= 0.0f && wIntensity <= 1.5f) &&
(abrupt <= 1);
if (plausibleWeather) {
weatherType_ = wType;
weatherIntensity_ = wIntensity;
const char* typeName =
(wType == 1) ? "Rain" :
(wType == 2) ? "Snow" :
(wType == 3) ? "Storm" : "Clear";
LOG_INFO("Weather changed (0x006B alias): type=", wType,
" (", typeName, "), intensity=", wIntensity,
", abrupt=", static_cast<int>(abrupt));
return;
}
// Not weather-shaped: rewind and fall through to normal opcode table handling.
packet.setReadPos(0);
}
} else if (allowVanillaAliases && opcode == 0x0103) {
// Expected play-music payload: uint32 sound/music id
if (packet.getRemainingSize() == 4) {
uint32_t soundId = packet.readUInt32();
LOG_INFO("SMSG_PLAY_MUSIC (0x0103 alias): soundId=", soundId);
if (playMusicCallback_) playMusicCallback_(soundId);
return;
}
} else if (opcode == 0x0480) {
// Observed on this WotLK profile immediately after CMSG_BUYBACK_ITEM.
// Treat as vendor/buyback transaction result (7-byte payload on this core).
if (packet.hasRemaining(7)) {
uint8_t opType = packet.readUInt8();
uint8_t resultCode = packet.readUInt8();
uint8_t slotOrCount = packet.readUInt8();
uint32_t itemId = packet.readUInt32();
LOG_INFO("Vendor txn result (0x480): opType=", static_cast<int>(opType),
" result=", static_cast<int>(resultCode),
" slot/count=", static_cast<int>(slotOrCount),
" itemId=", itemId,
" pendingBuybackSlot=", pendingBuybackSlot_,
" pendingBuyItemId=", pendingBuyItemId_,
" pendingBuyItemSlot=", pendingBuyItemSlot_);
if (pendingBuybackSlot_ >= 0) {
if (resultCode == 0) {
// Success: remove the bought-back slot from our local UI cache.
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
}
} else {
const char* msg = "Buyback failed.";
// Best-effort mapping; keep raw code visible for unknowns.
switch (resultCode) {
case 2: msg = "Buyback failed: not enough money."; break;
case 4: msg = "Buyback failed: vendor too far away."; break;
case 5: msg = "Buyback failed: item unavailable."; break;
case 6: msg = "Buyback failed: inventory full."; break;
case 8: msg = "Buyback failed: requirements not met."; break;
default: break;
}
addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")");
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
// Refresh vendor list so UI state stays in sync after buyback result.
if (getVendorItems().vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
auto pkt = ListInventoryPacket::build(getVendorItems().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.hasRemaining(25)) {
packet.setReadPos(packet.getReadPos() + 25);
return;
}
}
auto preLogicalOp = opcodeTable_.fromWire(opcode);
if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) {
++wardenPacketsAfterGate_;
}
if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) {
LOG_DEBUG("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec,
" logical=", static_cast<uint32_t>(*preLogicalOp),
" state=", worldStateName(state),
" size=", packet.getSize());
}
LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec,
" size=", packet.getSize(), " bytes");
// Translate wire opcode to logical opcode via expansion table
auto logicalOp = opcodeTable_.fromWire(opcode);
if (!logicalOp) {
static std::unordered_set<uint16_t> loggedUnknownWireOpcodes;
if (loggedUnknownWireOpcodes.insert(opcode).second) {
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
" state=", static_cast<int>(state),
" size=", packet.getSize());
}
return;
}
// Dispatch via the opcode handler table
auto it = dispatchTable_.find(*logicalOp);
if (it != dispatchTable_.end()) {
it->second(packet);
} else {
// In pre-world states we need full visibility (char create/login handshakes).
// In-world we keep de-duplication to avoid heavy log I/O in busy areas.
if (state != WorldState::IN_WORLD) {
static std::unordered_set<uint32_t> loggedUnhandledByState;
const uint32_t key = (static_cast<uint32_t>(static_cast<uint8_t>(state)) << 16) |
static_cast<uint32_t>(opcode);
if (loggedUnhandledByState.insert(key).second) {
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
" state=", static_cast<int>(state),
" size=", packet.getSize());
const auto& data = packet.getData();
std::string hex;
size_t limit = std::min<size_t>(data.size(), 48);
hex.reserve(limit * 3);
for (size_t i = 0; i < limit; ++i) {
char b[4];
snprintf(b, sizeof(b), "%02x ", data[i]);
hex += b;
}
LOG_INFO("Unhandled opcode payload hex (first ", limit, " bytes): ", hex);
}
} else {
static std::unordered_set<uint16_t> loggedUnhandledOpcodes;
if (loggedUnhandledOpcodes.insert(static_cast<uint16_t>(opcode)).second) {
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec);
}
}
}
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM while handling world opcode=0x", std::hex, opcode, std::dec,
" state=", worldStateName(state),
" size=", packet.getSize(),
" readPos=", packet.getReadPos(),
" what=", e.what());
if (socket && state == WorldState::IN_WORLD) {
disconnect();
fail("Out of memory while parsing world packet");
}
} catch (const std::exception& e) {
LOG_ERROR("Exception while handling world opcode=0x", std::hex, opcode, std::dec,
" state=", worldStateName(state),
" size=", packet.getSize(),
" readPos=", packet.getReadPos(),
" what=", e.what());
}
}
void GameHandler::enqueueIncomingPacket(const network::Packet& packet) {
if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) {
LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(),
" packets); dropping oldest packet to preserve responsiveness");
pendingIncomingPackets_.pop_front();
}
pendingIncomingPackets_.push_back(packet);
lastRxTime_ = std::chrono::steady_clock::now();
rxSilenceLogged_ = false;
rxSilence15sLogged_ = false;
}
void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) {
if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) {
LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(),
" packets); dropping newest queued packet to preserve ordering");
pendingIncomingPackets_.pop_back();
}
pendingIncomingPackets_.emplace_front(std::move(packet));
}
// enqueueUpdateObjectWork and processPendingUpdateObjectWork moved to EntityController
void GameHandler::processQueuedIncomingPackets() {
if (pendingIncomingPackets_.empty() && !entityController_->hasPendingUpdateObjectWork()) {
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 (entityController_->hasPendingUpdateObjectWork()) {
entityController_->processPendingUpdateObjectWork(start, budgetMs);
if (entityController_->hasPendingUpdateObjectWork()) {
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 (entityController_->hasPendingUpdateObjectWork()) {
return;
}
if (!pendingIncomingPackets_.empty()) {
LOG_DEBUG("GameHandler packet budget reached (processed=", processed,
", remaining=", pendingIncomingPackets_.size(),
", state=", worldStateName(state), ")");
}
}
} // namespace game
} // namespace wowee