mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-27 05:23:51 +00:00
Extract CatmullRomSpline (include/math/spline.hpp, src/math/spline.cpp) as a
standalone, immutable, thread-safe spline module with O(log n) binary segment
search and fused position+tangent evaluation — replacing the duplicated O(n)
evalTimedCatmullRom/orientationFromTangent pair in TransportManager.
Consolidate 7 copies of spline packet parsing into shared functions in
game/spline_packet.{hpp,cpp}: parseMonsterMoveSplineBody (WotLK/TBC),
parseMonsterMoveSplineBodyVanilla, parseClassicMoveUpdateSpline,
parseWotlkMoveUpdateSpline, and decodePackedDelta. Named SplineFlag constants
replace magic hex literals throughout.
Extract TransportPathRepository (game/transport_path_repository.{hpp,cpp}) from
TransportManager — owns path data, DBC loading, and path inference. Paths stored
as PathEntry wrapping CatmullRomSpline + metadata (zOnly, fromDBC, worldCoords).
TransportManager reduced from ~1200 to ~500 lines, focused on transport lifecycle
and server sync.
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1420 lines
53 KiB
C++
1420 lines
53 KiB
C++
#include "game/world_packets.hpp"
|
||
#include "game/packet_parsers.hpp"
|
||
#include "game/opcodes.hpp"
|
||
#include "game/character.hpp"
|
||
#include "auth/crypto.hpp"
|
||
#include "core/logger.hpp"
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cctype>
|
||
#include <cmath>
|
||
#include <cstring>
|
||
#include <sstream>
|
||
#include <iomanip>
|
||
#include <zlib.h>
|
||
|
||
namespace {
|
||
|
||
inline uint32_t bswap32(uint32_t v) {
|
||
return ((v & 0xFF000000u) >> 24) | ((v & 0x00FF0000u) >> 8)
|
||
| ((v & 0x0000FF00u) << 8) | ((v & 0x000000FFu) << 24);
|
||
}
|
||
|
||
inline uint16_t bswap16(uint16_t v) {
|
||
return static_cast<uint16_t>(((v & 0xFF00u) >> 8) | ((v & 0x00FFu) << 8));
|
||
}
|
||
|
||
} // anonymous namespace
|
||
|
||
namespace wowee {
|
||
namespace game {
|
||
|
||
bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
|
||
// Always reset output to avoid stale targets when callers reuse buffers.
|
||
data = SpellGoData{};
|
||
|
||
// Packed GUIDs are variable-length, so only require the smallest possible
|
||
// shape up front: 2 GUID masks + fixed fields through hitCount.
|
||
if (!packet.hasRemaining(16)) return false;
|
||
|
||
size_t startPos = packet.getReadPos();
|
||
if (!packet.hasFullPackedGuid()) {
|
||
return false;
|
||
}
|
||
data.casterGuid = packet.readPackedGuid();
|
||
if (!packet.hasFullPackedGuid()) {
|
||
packet.setReadPos(startPos);
|
||
return false;
|
||
}
|
||
data.casterUnit = packet.readPackedGuid();
|
||
|
||
// Validate remaining fixed fields up to hitCount/missCount
|
||
if (!packet.hasRemaining(14)) { // castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1)
|
||
packet.setReadPos(startPos);
|
||
return false;
|
||
}
|
||
|
||
data.castCount = packet.readUInt8();
|
||
data.spellId = packet.readUInt32();
|
||
data.castFlags = packet.readUInt32();
|
||
// Timestamp in 3.3.5a
|
||
packet.readUInt32();
|
||
|
||
const uint8_t rawHitCount = packet.readUInt8();
|
||
if (rawHitCount > 128) {
|
||
LOG_WARNING("Spell go: hitCount capped (requested=", static_cast<int>(rawHitCount), ")");
|
||
}
|
||
const uint8_t storedHitLimit = std::min<uint8_t>(rawHitCount, 128);
|
||
|
||
bool truncatedTargets = false;
|
||
|
||
data.hitTargets.reserve(storedHitLimit);
|
||
for (uint16_t i = 0; i < rawHitCount; ++i) {
|
||
// WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid).
|
||
if (!packet.hasRemaining(8)) {
|
||
LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", static_cast<int>(rawHitCount));
|
||
truncatedTargets = true;
|
||
break;
|
||
}
|
||
const uint64_t targetGuid = packet.readUInt64();
|
||
if (i < storedHitLimit) {
|
||
data.hitTargets.push_back(targetGuid);
|
||
}
|
||
}
|
||
if (truncatedTargets) {
|
||
packet.setReadPos(startPos);
|
||
return false;
|
||
}
|
||
data.hitCount = static_cast<uint8_t>(data.hitTargets.size());
|
||
|
||
// missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation.
|
||
if (!packet.hasRemaining(1)) {
|
||
LOG_WARNING("Spell go: missing missCount after hit target list");
|
||
packet.setReadPos(startPos);
|
||
return false;
|
||
}
|
||
|
||
const size_t missCountPos = packet.getReadPos();
|
||
const uint8_t rawMissCount = packet.readUInt8();
|
||
if (rawMissCount > 20) {
|
||
// Likely offset error — dump context bytes for diagnostics.
|
||
const auto& raw = packet.getData();
|
||
std::string hexCtx;
|
||
size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos;
|
||
size_t dumpEnd = std::min(missCountPos + 16, raw.size());
|
||
for (size_t i = dumpStart; i < dumpEnd; ++i) {
|
||
char buf[4];
|
||
std::snprintf(buf, sizeof(buf), "%02x ", raw[i]);
|
||
hexCtx += buf;
|
||
if (i == missCountPos - 1) hexCtx += "[";
|
||
if (i == missCountPos) hexCtx += "] ";
|
||
}
|
||
LOG_WARNING("Spell go: suspect missCount=", static_cast<int>(rawMissCount),
|
||
" spell=", data.spellId, " hits=", static_cast<int>(data.hitCount),
|
||
" castFlags=0x", std::hex, data.castFlags, std::dec,
|
||
" missCountPos=", missCountPos, " pktSize=", packet.getSize(),
|
||
" ctx=", hexCtx);
|
||
}
|
||
if (rawMissCount > 128) {
|
||
LOG_WARNING("Spell go: missCount capped (requested=", static_cast<int>(rawMissCount),
|
||
") spell=", data.spellId, " hits=", static_cast<int>(data.hitCount),
|
||
" remaining=", packet.getRemainingSize());
|
||
}
|
||
const uint8_t storedMissLimit = std::min<uint8_t>(rawMissCount, 128);
|
||
|
||
data.missTargets.reserve(storedMissLimit);
|
||
for (uint16_t i = 0; i < rawMissCount; ++i) {
|
||
// WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType.
|
||
// REFLECT additionally appends uint8 reflectResult.
|
||
if (!packet.hasRemaining(9)) { // 8 GUID + 1 missType
|
||
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", static_cast<int>(rawMissCount),
|
||
" spell=", data.spellId, " hits=", static_cast<int>(data.hitCount));
|
||
truncatedTargets = true;
|
||
break;
|
||
}
|
||
SpellGoMissEntry m;
|
||
m.targetGuid = packet.readUInt64();
|
||
m.missType = packet.readUInt8();
|
||
if (m.missType == 11) { // SPELL_MISS_REFLECT
|
||
if (!packet.hasRemaining(1)) {
|
||
LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", static_cast<int>(rawMissCount));
|
||
truncatedTargets = true;
|
||
break;
|
||
}
|
||
(void)packet.readUInt8(); // reflectResult
|
||
}
|
||
if (i < storedMissLimit) {
|
||
data.missTargets.push_back(m);
|
||
}
|
||
}
|
||
data.missCount = static_cast<uint8_t>(data.missTargets.size());
|
||
|
||
// If miss targets were truncated, salvage the successfully-parsed hit data
|
||
// rather than discarding the entire spell. The server already applied effects;
|
||
// we just need the hit list for UI feedback (combat text, health bars).
|
||
if (truncatedTargets) {
|
||
LOG_DEBUG("Spell go: salvaging ", static_cast<int>(data.hitCount), " hits despite miss truncation");
|
||
packet.skipAll(); // consume remaining bytes
|
||
return true;
|
||
}
|
||
|
||
// WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that
|
||
// any trailing fields after the target section are not misaligned for
|
||
// ground-targeted or AoE spells. Same layout as SpellStartParser.
|
||
if (packet.hasData()) {
|
||
if (packet.hasRemaining(4)) {
|
||
uint32_t targetFlags = packet.readUInt32();
|
||
|
||
auto readPackedTarget = [&](uint64_t* out) -> bool {
|
||
if (!packet.hasFullPackedGuid()) return false;
|
||
uint64_t g = packet.readPackedGuid();
|
||
if (out) *out = g;
|
||
return true;
|
||
};
|
||
auto skipPackedAndFloats3 = [&]() -> bool {
|
||
if (!packet.hasFullPackedGuid()) return false;
|
||
packet.readPackedGuid(); // transport GUID
|
||
if (!packet.hasRemaining(12)) return false;
|
||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
||
return true;
|
||
};
|
||
|
||
// UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID
|
||
if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) {
|
||
readPackedTarget(&data.targetGuid);
|
||
}
|
||
// ITEM/TRADE_ITEM share one item target GUID
|
||
if (targetFlags & (0x0010u | 0x0100u)) {
|
||
readPackedTarget(nullptr);
|
||
}
|
||
// SOURCE_LOCATION: PackedGuid (transport) + float x,y,z
|
||
if (targetFlags & 0x0020u) {
|
||
skipPackedAndFloats3();
|
||
}
|
||
// DEST_LOCATION: PackedGuid (transport) + float x,y,z
|
||
if (targetFlags & 0x0040u) {
|
||
skipPackedAndFloats3();
|
||
}
|
||
// STRING: null-terminated
|
||
if (targetFlags & 0x0200u) {
|
||
while (packet.hasData() && packet.readUInt8() != 0) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", static_cast<int>(data.hitCount),
|
||
" misses=", static_cast<int>(data.missCount));
|
||
return true;
|
||
}
|
||
|
||
bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool isAll) {
|
||
// Validation: packed GUID (1-8 bytes minimum for reading)
|
||
if (!packet.hasRemaining(1)) return false;
|
||
|
||
data.guid = packet.readPackedGuid();
|
||
|
||
// Cap number of aura entries to prevent unbounded loop DoS
|
||
uint32_t maxAuras = isAll ? 512 : 1;
|
||
uint32_t auraCount = 0;
|
||
|
||
while (packet.hasData() && auraCount < maxAuras) {
|
||
// Validate we can read slot (1) + spellId (4) = 5 bytes minimum
|
||
if (!packet.hasRemaining(5)) {
|
||
LOG_DEBUG("Aura update: truncated entry at position ", auraCount);
|
||
break;
|
||
}
|
||
|
||
uint8_t slot = packet.readUInt8();
|
||
uint32_t spellId = packet.readUInt32();
|
||
auraCount++;
|
||
|
||
AuraSlot aura;
|
||
if (spellId != 0) {
|
||
aura.spellId = spellId;
|
||
|
||
// Validate flags + level + charges (3 bytes)
|
||
if (!packet.hasRemaining(3)) {
|
||
LOG_WARNING("Aura update: truncated flags/level/charges at entry ", auraCount);
|
||
aura.flags = 0;
|
||
aura.level = 0;
|
||
aura.charges = 0;
|
||
} else {
|
||
aura.flags = packet.readUInt8();
|
||
aura.level = packet.readUInt8();
|
||
aura.charges = packet.readUInt8();
|
||
}
|
||
|
||
if (!(aura.flags & 0x08)) { // NOT_CASTER flag
|
||
// Validate space for packed GUID read (minimum 1 byte)
|
||
if (!packet.hasRemaining(1)) {
|
||
aura.casterGuid = 0;
|
||
} else {
|
||
aura.casterGuid = packet.readPackedGuid();
|
||
}
|
||
}
|
||
|
||
if (aura.flags & 0x20) { // DURATION - need 8 bytes (two uint32s)
|
||
if (!packet.hasRemaining(8)) {
|
||
LOG_WARNING("Aura update: truncated duration fields at entry ", auraCount);
|
||
aura.maxDurationMs = 0;
|
||
aura.durationMs = 0;
|
||
} else {
|
||
aura.maxDurationMs = static_cast<int32_t>(packet.readUInt32());
|
||
aura.durationMs = static_cast<int32_t>(packet.readUInt32());
|
||
}
|
||
}
|
||
|
||
if (aura.flags & 0x40) { // EFFECT_AMOUNTS
|
||
// Only read amounts for active effect indices (flags 0x01, 0x02, 0x04)
|
||
for (int i = 0; i < 3; ++i) {
|
||
if (aura.flags & (1 << i)) {
|
||
if (packet.hasRemaining(4)) {
|
||
packet.readUInt32();
|
||
} else {
|
||
LOG_WARNING("Aura update: truncated effect amount ", i, " at entry ", auraCount);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
data.updates.push_back({slot, aura});
|
||
|
||
// For single update, only one entry
|
||
if (!isAll) break;
|
||
}
|
||
|
||
if (auraCount >= maxAuras && packet.hasData()) {
|
||
LOG_WARNING("Aura update: capped at ", maxAuras, " entries, remaining data ignored");
|
||
}
|
||
|
||
LOG_DEBUG("Aura update for 0x", std::hex, data.guid, std::dec,
|
||
": ", data.updates.size(), " slots");
|
||
return true;
|
||
}
|
||
|
||
bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data) {
|
||
// Upfront validation: guid(8) + flags(1) = 9 bytes minimum
|
||
if (!packet.hasRemaining(9)) return false;
|
||
|
||
data.guid = packet.readUInt64();
|
||
data.flags = packet.readUInt8();
|
||
|
||
// Cap cooldown entries to prevent unbounded memory allocation (each entry is 8 bytes)
|
||
uint32_t maxCooldowns = 512;
|
||
uint32_t cooldownCount = 0;
|
||
|
||
while (packet.hasRemaining(8) && cooldownCount < maxCooldowns) {
|
||
uint32_t spellId = packet.readUInt32();
|
||
uint32_t cooldownMs = packet.readUInt32();
|
||
data.cooldowns.push_back({spellId, cooldownMs});
|
||
cooldownCount++;
|
||
}
|
||
|
||
if (cooldownCount >= maxCooldowns && packet.hasRemaining(8)) {
|
||
LOG_WARNING("Spell cooldowns: capped at ", maxCooldowns, " entries, remaining data ignored");
|
||
}
|
||
|
||
LOG_DEBUG("Spell cooldowns: ", data.cooldowns.size(), " entries");
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// Group/Party System
|
||
// ============================================================
|
||
|
||
network::Packet GroupInvitePacket::build(const std::string& playerName) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_INVITE));
|
||
packet.writeString(playerName);
|
||
packet.writeUInt32(0); // unused
|
||
LOG_DEBUG("Built CMSG_GROUP_INVITE: ", playerName);
|
||
return packet;
|
||
}
|
||
|
||
bool GroupInviteResponseParser::parse(network::Packet& packet, GroupInviteResponseData& data) {
|
||
// Validate minimum packet size: canAccept(1)
|
||
if (!packet.hasRemaining(1)) {
|
||
LOG_WARNING("SMSG_GROUP_INVITE: packet too small (", packet.getSize(), " bytes)");
|
||
return false;
|
||
}
|
||
|
||
data.canAccept = packet.readUInt8();
|
||
// Note: inviterName is a string, which is always safe to read even if empty
|
||
data.inviterName = packet.readString();
|
||
LOG_INFO("Group invite from: ", data.inviterName, " (canAccept=", static_cast<int>(data.canAccept), ")");
|
||
return true;
|
||
}
|
||
|
||
network::Packet GroupAcceptPacket::build() {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_ACCEPT));
|
||
packet.writeUInt32(0); // unused in 3.3.5a
|
||
return packet;
|
||
}
|
||
|
||
network::Packet GroupDeclinePacket::build() {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_DECLINE));
|
||
return packet;
|
||
}
|
||
|
||
bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool hasRoles) {
|
||
auto rem = [&]() { return packet.getRemainingSize(); };
|
||
|
||
if (rem() < 3) return false;
|
||
data.groupType = packet.readUInt8();
|
||
data.subGroup = packet.readUInt8();
|
||
data.flags = packet.readUInt8();
|
||
|
||
// WotLK 3.3.5a added a roles byte (tank/healer/dps) for the dungeon finder.
|
||
// Classic 1.12 and TBC 2.4.3 do not have this byte.
|
||
if (hasRoles) {
|
||
if (rem() < 1) return false;
|
||
data.roles = packet.readUInt8();
|
||
} else {
|
||
data.roles = 0;
|
||
}
|
||
|
||
// WotLK: LFG data gated by groupType bit 0x04 (LFD group type)
|
||
if (hasRoles && (data.groupType & 0x04)) {
|
||
if (rem() < 5) return false;
|
||
packet.readUInt8(); // lfg state
|
||
packet.readUInt32(); // lfg entry
|
||
// WotLK 3.3.5a may or may not send the lfg flags byte — read it only if present
|
||
if (rem() >= 13) { // enough for lfgFlags(1)+groupGuid(8)+counter(4)
|
||
packet.readUInt8(); // lfg flags
|
||
}
|
||
}
|
||
|
||
if (rem() < 12) return false;
|
||
packet.readUInt64(); // group GUID
|
||
packet.readUInt32(); // update counter
|
||
|
||
if (rem() < 4) return false;
|
||
data.memberCount = packet.readUInt32();
|
||
if (data.memberCount > 40) {
|
||
LOG_WARNING("GroupListParser: implausible memberCount=", data.memberCount, ", clamping");
|
||
data.memberCount = 40;
|
||
}
|
||
data.members.reserve(data.memberCount);
|
||
|
||
for (uint32_t i = 0; i < data.memberCount; ++i) {
|
||
if (rem() == 0) break;
|
||
GroupMember member;
|
||
member.name = packet.readString();
|
||
if (rem() < 8) break;
|
||
member.guid = packet.readUInt64();
|
||
if (rem() < 3) break;
|
||
member.isOnline = packet.readUInt8();
|
||
member.subGroup = packet.readUInt8();
|
||
member.flags = packet.readUInt8();
|
||
// WotLK added per-member roles byte; Classic/TBC do not have it.
|
||
if (hasRoles) {
|
||
if (rem() < 1) break;
|
||
member.roles = packet.readUInt8();
|
||
} else {
|
||
member.roles = 0;
|
||
}
|
||
data.members.push_back(member);
|
||
}
|
||
|
||
if (rem() < 8) {
|
||
LOG_INFO("Group list: ", data.memberCount, " members (no leader GUID in packet)");
|
||
return true;
|
||
}
|
||
data.leaderGuid = packet.readUInt64();
|
||
|
||
if (data.memberCount > 0 && rem() >= 10) {
|
||
data.lootMethod = packet.readUInt8();
|
||
data.looterGuid = packet.readUInt64();
|
||
data.lootThreshold = packet.readUInt8();
|
||
// Dungeon difficulty (heroic/normal) — Classic doesn't send this; TBC/WotLK do
|
||
if (rem() >= 1) data.difficultyId = packet.readUInt8();
|
||
// Raid difficulty — WotLK only
|
||
if (rem() >= 1) data.raidDifficultyId = packet.readUInt8();
|
||
// Extra byte in some 3.3.5a builds
|
||
if (hasRoles && rem() >= 1) packet.readUInt8();
|
||
}
|
||
|
||
LOG_INFO("Group list: ", data.memberCount, " members, leader=0x",
|
||
std::hex, data.leaderGuid, std::dec);
|
||
return true;
|
||
}
|
||
|
||
bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResultData& data) {
|
||
// Upfront validation: command(4) + name(var) + result(4) = 8 bytes minimum (plus name string)
|
||
if (!packet.hasRemaining(8)) return false;
|
||
|
||
data.command = static_cast<PartyCommand>(packet.readUInt32());
|
||
data.name = packet.readString();
|
||
|
||
// Validate result field exists (4 bytes)
|
||
if (!packet.hasRemaining(4)) {
|
||
data.result = static_cast<PartyResult>(0);
|
||
return true; // Partial read is acceptable
|
||
}
|
||
|
||
data.result = static_cast<PartyResult>(packet.readUInt32());
|
||
LOG_DEBUG("Party command result: ", static_cast<int>(data.result));
|
||
return true;
|
||
}
|
||
|
||
bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData& data) {
|
||
// Upfront validation: playerName is a CString (minimum 1 null terminator)
|
||
if (!packet.hasRemaining(1)) return false;
|
||
|
||
data.playerName = packet.readString();
|
||
LOG_INFO("Group decline from: ", data.playerName);
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// Loot System
|
||
// ============================================================
|
||
|
||
network::Packet LootPacket::build(uint64_t targetGuid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_LOOT));
|
||
packet.writeUInt64(targetGuid);
|
||
LOG_DEBUG("Built CMSG_LOOT: target=0x", std::hex, targetGuid, std::dec);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_AUTOSTORE_LOOT_ITEM));
|
||
packet.writeUInt8(slotIndex);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_USE_ITEM));
|
||
packet.writeUInt8(bagIndex);
|
||
packet.writeUInt8(slotIndex);
|
||
packet.writeUInt8(0); // cast count
|
||
packet.writeUInt32(spellId); // spell id from item data
|
||
packet.writeUInt64(itemGuid); // full 8-byte GUID
|
||
packet.writeUInt32(0); // glyph index
|
||
packet.writeUInt8(0); // cast flags
|
||
// SpellCastTargets: self
|
||
packet.writeUInt32(0x00);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM));
|
||
packet.writeUInt8(bagIndex);
|
||
packet.writeUInt8(slotIndex);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM));
|
||
packet.writeUInt8(srcBag);
|
||
packet.writeUInt8(srcSlot);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_ITEM));
|
||
packet.writeUInt8(dstBag);
|
||
packet.writeUInt8(dstSlot);
|
||
packet.writeUInt8(srcBag);
|
||
packet.writeUInt8(srcSlot);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot,
|
||
uint8_t dstBag, uint8_t dstSlot, uint8_t count) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM));
|
||
packet.writeUInt8(srcBag);
|
||
packet.writeUInt8(srcSlot);
|
||
packet.writeUInt8(dstBag);
|
||
packet.writeUInt8(dstSlot);
|
||
packet.writeUInt8(count);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM));
|
||
packet.writeUInt8(srcSlot);
|
||
packet.writeUInt8(dstSlot);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet LootMoneyPacket::build() {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_MONEY));
|
||
return packet;
|
||
}
|
||
|
||
network::Packet LootReleasePacket::build(uint64_t lootGuid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_RELEASE));
|
||
packet.writeUInt64(lootGuid);
|
||
return packet;
|
||
}
|
||
|
||
bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) {
|
||
data = LootResponseData{};
|
||
size_t avail = packet.getRemainingSize();
|
||
|
||
// Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with
|
||
// lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened,
|
||
// needs a key, or another player is looting). We treat this as an empty-loot
|
||
// signal and return false so the caller knows not to open the loot window.
|
||
if (avail < 9) {
|
||
LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)");
|
||
return false;
|
||
}
|
||
|
||
data.lootGuid = packet.readUInt64();
|
||
data.lootType = packet.readUInt8();
|
||
|
||
// Short failure packet — no gold/item data follows.
|
||
avail = packet.getRemainingSize();
|
||
if (avail < 5) {
|
||
LOG_DEBUG("LootResponseParser: lootType=", static_cast<int>(data.lootType), " (empty/failure response)");
|
||
return false;
|
||
}
|
||
|
||
data.gold = packet.readUInt32();
|
||
uint8_t itemCount = packet.readUInt8();
|
||
|
||
// Per-item wire size is 22 bytes across all expansions:
|
||
// slot(1)+itemId(4)+count(4)+displayInfo(4)+randSuffix(4)+randProp(4)+slotType(1) = 22
|
||
constexpr size_t kItemSize = 22u;
|
||
|
||
auto parseLootItemList = [&](uint8_t listCount, bool markQuestItems) -> bool {
|
||
for (uint8_t i = 0; i < listCount; ++i) {
|
||
size_t remaining = packet.getRemainingSize();
|
||
if (remaining < kItemSize) {
|
||
return false;
|
||
}
|
||
|
||
LootItem item;
|
||
item.slotIndex = packet.readUInt8();
|
||
item.itemId = packet.readUInt32();
|
||
item.count = packet.readUInt32();
|
||
item.displayInfoId = packet.readUInt32();
|
||
item.randomSuffix = packet.readUInt32();
|
||
item.randomPropertyId = packet.readUInt32();
|
||
item.lootSlotType = packet.readUInt8();
|
||
item.isQuestItem = markQuestItems;
|
||
data.items.push_back(item);
|
||
}
|
||
return true;
|
||
};
|
||
|
||
data.items.reserve(itemCount);
|
||
if (!parseLootItemList(itemCount, false)) {
|
||
LOG_WARNING("LootResponseParser: truncated regular item list");
|
||
return false;
|
||
}
|
||
|
||
// Quest item section only present in WotLK 3.3.5a
|
||
uint8_t questItemCount = 0;
|
||
if (isWotlkFormat && packet.hasRemaining(1)) {
|
||
questItemCount = packet.readUInt8();
|
||
data.items.reserve(data.items.size() + questItemCount);
|
||
if (!parseLootItemList(questItemCount, true)) {
|
||
LOG_WARNING("LootResponseParser: truncated quest item list");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
LOG_DEBUG("Loot response: ", static_cast<int>(itemCount), " regular + ", static_cast<int>(questItemCount),
|
||
" quest items, ", data.gold, " copper");
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// NPC Gossip
|
||
// ============================================================
|
||
|
||
network::Packet GossipHelloPacket::build(uint64_t npcGuid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_HELLO));
|
||
packet.writeUInt64(npcGuid);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet QuestgiverHelloPacket::build(uint64_t npcGuid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_HELLO));
|
||
packet.writeUInt64(npcGuid);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet GossipSelectOptionPacket::build(uint64_t npcGuid, uint32_t menuId, uint32_t optionId, const std::string& code) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_SELECT_OPTION));
|
||
packet.writeUInt64(npcGuid);
|
||
packet.writeUInt32(menuId);
|
||
packet.writeUInt32(optionId);
|
||
if (!code.empty()) {
|
||
packet.writeString(code);
|
||
}
|
||
return packet;
|
||
}
|
||
|
||
network::Packet QuestgiverQueryQuestPacket::build(uint64_t npcGuid, uint32_t questId) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_QUERY_QUEST));
|
||
packet.writeUInt64(npcGuid);
|
||
packet.writeUInt32(questId);
|
||
packet.writeUInt8(1); // isDialogContinued = 1 (from gossip)
|
||
return packet;
|
||
}
|
||
|
||
network::Packet QuestgiverAcceptQuestPacket::build(uint64_t npcGuid, uint32_t questId) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST));
|
||
packet.writeUInt64(npcGuid);
|
||
packet.writeUInt32(questId);
|
||
packet.writeUInt32(0); // AzerothCore/WotLK expects trailing unk1
|
||
return packet;
|
||
}
|
||
|
||
bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) {
|
||
if (packet.getSize() < 20) return false;
|
||
data.npcGuid = packet.readUInt64();
|
||
|
||
// WotLK has informUnit(u64) before questId; Vanilla/TBC do not.
|
||
// Detect: try WotLK first (read informUnit + questId), then check if title
|
||
// string looks valid. If not, rewind and try vanilla (questId directly).
|
||
size_t preInform = packet.getReadPos();
|
||
/*informUnit*/ packet.readUInt64();
|
||
data.questId = packet.readUInt32();
|
||
data.title = normalizeWowTextTokens(packet.readString());
|
||
if (data.title.empty() || data.questId > 100000) {
|
||
// Likely vanilla format — rewind past informUnit
|
||
packet.setReadPos(preInform);
|
||
data.questId = packet.readUInt32();
|
||
data.title = normalizeWowTextTokens(packet.readString());
|
||
}
|
||
data.details = normalizeWowTextTokens(packet.readString());
|
||
data.objectives = normalizeWowTextTokens(packet.readString());
|
||
|
||
if (!packet.hasRemaining(10)) {
|
||
LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'");
|
||
return true;
|
||
}
|
||
|
||
/*activateAccept*/ packet.readUInt8();
|
||
/*flags*/ packet.readUInt32();
|
||
data.suggestedPlayers = packet.readUInt32();
|
||
/*isFinished*/ packet.readUInt8();
|
||
|
||
// Reward choice items: server always writes 6 entries (QUEST_REWARD_CHOICES_COUNT)
|
||
if (packet.hasRemaining(4)) {
|
||
/*choiceCount*/ packet.readUInt32();
|
||
for (int i = 0; i < 6; i++) {
|
||
if (!packet.hasRemaining(12)) break;
|
||
uint32_t itemId = packet.readUInt32();
|
||
uint32_t count = packet.readUInt32();
|
||
uint32_t dispId = packet.readUInt32();
|
||
if (itemId != 0) {
|
||
QuestRewardItem ri;
|
||
ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId;
|
||
ri.choiceSlot = static_cast<uint32_t>(i);
|
||
data.rewardChoiceItems.push_back(ri);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reward items: server always writes 4 entries (QUEST_REWARDS_COUNT)
|
||
if (packet.hasRemaining(4)) {
|
||
/*rewardCount*/ packet.readUInt32();
|
||
for (int i = 0; i < 4; i++) {
|
||
if (!packet.hasRemaining(12)) break;
|
||
uint32_t itemId = packet.readUInt32();
|
||
uint32_t count = packet.readUInt32();
|
||
uint32_t dispId = packet.readUInt32();
|
||
if (itemId != 0) {
|
||
QuestRewardItem ri;
|
||
ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId;
|
||
data.rewardItems.push_back(ri);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Money and XP rewards
|
||
if (packet.hasRemaining(4))
|
||
data.rewardMoney = packet.readUInt32();
|
||
if (packet.hasRemaining(4))
|
||
data.rewardXp = packet.readUInt32();
|
||
|
||
LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'");
|
||
return true;
|
||
}
|
||
|
||
bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) {
|
||
// Upfront validation: npcGuid(8) + menuId(4) + titleTextId(4) + optionCount(4) = 20 bytes minimum
|
||
if (!packet.hasRemaining(20)) return false;
|
||
|
||
data.npcGuid = packet.readUInt64();
|
||
data.menuId = packet.readUInt32();
|
||
data.titleTextId = packet.readUInt32();
|
||
uint32_t optionCount = packet.readUInt32();
|
||
|
||
// Cap option count to prevent unbounded memory allocation
|
||
const uint32_t MAX_GOSSIP_OPTIONS = 64;
|
||
if (optionCount > MAX_GOSSIP_OPTIONS) {
|
||
LOG_WARNING("GossipMessageParser: optionCount capped (requested=", optionCount, ")");
|
||
optionCount = MAX_GOSSIP_OPTIONS;
|
||
}
|
||
|
||
data.options.clear();
|
||
data.options.reserve(optionCount);
|
||
for (uint32_t i = 0; i < optionCount; ++i) {
|
||
// Each option: id(4) + icon(1) + isCoded(1) + boxMoney(4) + text(var) + boxText(var)
|
||
// Minimum: 10 bytes + 2 empty strings (2 null terminators) = 12 bytes
|
||
if (!packet.hasRemaining(12)) {
|
||
LOG_WARNING("GossipMessageParser: truncated options at index ", i, "/", optionCount);
|
||
break;
|
||
}
|
||
GossipOption opt;
|
||
opt.id = packet.readUInt32();
|
||
opt.icon = packet.readUInt8();
|
||
opt.isCoded = (packet.readUInt8() != 0);
|
||
opt.boxMoney = packet.readUInt32();
|
||
opt.text = packet.readString();
|
||
opt.boxText = packet.readString();
|
||
data.options.push_back(opt);
|
||
}
|
||
|
||
// Validate questCount field exists (4 bytes)
|
||
if (!packet.hasRemaining(4)) {
|
||
LOG_DEBUG("Gossip: ", data.options.size(), " options (no quest data)");
|
||
return true;
|
||
}
|
||
|
||
uint32_t questCount = packet.readUInt32();
|
||
// Cap quest count to prevent unbounded memory allocation
|
||
const uint32_t MAX_GOSSIP_QUESTS = 64;
|
||
if (questCount > MAX_GOSSIP_QUESTS) {
|
||
LOG_WARNING("GossipMessageParser: questCount capped (requested=", questCount, ")");
|
||
questCount = MAX_GOSSIP_QUESTS;
|
||
}
|
||
|
||
data.quests.clear();
|
||
data.quests.reserve(questCount);
|
||
for (uint32_t i = 0; i < questCount; ++i) {
|
||
// Each quest: questId(4) + questIcon(4) + questLevel(4) + questFlags(4) + isRepeatable(1) + title(var)
|
||
// Minimum: 17 bytes + empty string (1 null terminator) = 18 bytes
|
||
if (!packet.hasRemaining(18)) {
|
||
LOG_WARNING("GossipMessageParser: truncated quests at index ", i, "/", questCount);
|
||
break;
|
||
}
|
||
GossipQuestItem quest;
|
||
quest.questId = packet.readUInt32();
|
||
quest.questIcon = packet.readUInt32();
|
||
quest.questLevel = static_cast<int32_t>(packet.readUInt32());
|
||
quest.questFlags = packet.readUInt32();
|
||
quest.isRepeatable = packet.readUInt8();
|
||
quest.title = normalizeWowTextTokens(packet.readString());
|
||
data.quests.push_back(quest);
|
||
}
|
||
|
||
LOG_DEBUG("Gossip: ", data.options.size(), " options, ", data.quests.size(), " quests");
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// Bind Point (Hearthstone)
|
||
// ============================================================
|
||
|
||
network::Packet BinderActivatePacket::build(uint64_t npcGuid) {
|
||
network::Packet pkt(wireOpcode(Opcode::CMSG_BINDER_ACTIVATE));
|
||
pkt.writeUInt64(npcGuid);
|
||
return pkt;
|
||
}
|
||
|
||
bool BindPointUpdateParser::parse(network::Packet& packet, BindPointUpdateData& data) {
|
||
if (packet.getSize() < 20) return false;
|
||
data.x = packet.readFloat();
|
||
data.y = packet.readFloat();
|
||
data.z = packet.readFloat();
|
||
data.mapId = packet.readUInt32();
|
||
data.zoneId = packet.readUInt32();
|
||
return true;
|
||
}
|
||
|
||
bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsData& data) {
|
||
if (!packet.hasRemaining(20)) return false;
|
||
data.npcGuid = packet.readUInt64();
|
||
data.questId = packet.readUInt32();
|
||
data.title = normalizeWowTextTokens(packet.readString());
|
||
data.completionText = normalizeWowTextTokens(packet.readString());
|
||
|
||
if (!packet.hasRemaining(9)) {
|
||
LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'");
|
||
return true;
|
||
}
|
||
|
||
struct ParsedTail {
|
||
uint32_t requiredMoney = 0;
|
||
uint32_t completableFlags = 0;
|
||
std::vector<QuestRewardItem> requiredItems;
|
||
bool ok = false;
|
||
int score = -1;
|
||
};
|
||
|
||
auto parseTail = [&](size_t startPos, size_t prefixSkip) -> ParsedTail {
|
||
ParsedTail out;
|
||
packet.setReadPos(startPos);
|
||
|
||
if (!packet.hasRemaining(prefixSkip)) return out;
|
||
packet.setReadPos(packet.getReadPos() + prefixSkip);
|
||
|
||
if (!packet.hasRemaining(8)) return out;
|
||
out.requiredMoney = packet.readUInt32();
|
||
uint32_t requiredItemCount = packet.readUInt32();
|
||
if (requiredItemCount > 64) return out; // sanity guard against misalignment
|
||
|
||
out.requiredItems.reserve(requiredItemCount);
|
||
for (uint32_t i = 0; i < requiredItemCount; ++i) {
|
||
if (!packet.hasRemaining(12)) return out;
|
||
QuestRewardItem item;
|
||
item.itemId = packet.readUInt32();
|
||
item.count = packet.readUInt32();
|
||
item.displayInfoId = packet.readUInt32();
|
||
if (item.itemId != 0) out.requiredItems.push_back(item);
|
||
}
|
||
|
||
if (!packet.hasRemaining(4)) return out;
|
||
out.completableFlags = packet.readUInt32();
|
||
out.ok = true;
|
||
|
||
// Prefer layouts that produce plausible quest-requirement shapes.
|
||
out.score = 0;
|
||
if (requiredItemCount <= 6) out.score += 4;
|
||
if (out.requiredItems.size() == requiredItemCount) out.score += 3;
|
||
if ((out.completableFlags & ~0x3u) == 0) out.score += 5;
|
||
if (out.requiredMoney == 0) out.score += 4;
|
||
else if (out.requiredMoney <= 100000) out.score += 2; // <=10g is common
|
||
else if (out.requiredMoney >= 1000000) out.score -= 3; // implausible for most quests
|
||
if (!out.requiredItems.empty()) out.score += 1;
|
||
size_t remaining = packet.getRemainingSize();
|
||
if (remaining <= 16) out.score += 3;
|
||
else if (remaining <= 32) out.score += 2;
|
||
else if (remaining <= 64) out.score += 1;
|
||
if (prefixSkip == 0) out.score += 1;
|
||
else if (prefixSkip <= 12) out.score += 1;
|
||
return out;
|
||
};
|
||
|
||
size_t tailStart = packet.getReadPos();
|
||
std::vector<ParsedTail> candidates;
|
||
candidates.reserve(25);
|
||
for (size_t skip = 0; skip <= 24; ++skip) {
|
||
candidates.push_back(parseTail(tailStart, skip));
|
||
}
|
||
|
||
const ParsedTail* chosen = nullptr;
|
||
for (const auto& cand : candidates) {
|
||
if (!cand.ok) continue;
|
||
if (!chosen || cand.score > chosen->score) chosen = &cand;
|
||
}
|
||
if (!chosen) {
|
||
return true;
|
||
}
|
||
|
||
data.requiredMoney = chosen->requiredMoney;
|
||
data.completableFlags = chosen->completableFlags;
|
||
data.requiredItems = chosen->requiredItems;
|
||
|
||
LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title,
|
||
"' items=", data.requiredItems.size(), " completable=", data.isCompletable());
|
||
return true;
|
||
}
|
||
|
||
bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData& data) {
|
||
if (!packet.hasRemaining(20)) return false;
|
||
data.npcGuid = packet.readUInt64();
|
||
data.questId = packet.readUInt32();
|
||
data.title = normalizeWowTextTokens(packet.readString());
|
||
data.rewardText = normalizeWowTextTokens(packet.readString());
|
||
|
||
if (!packet.hasRemaining(8)) {
|
||
LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");
|
||
return true;
|
||
}
|
||
|
||
// After the two strings the packet contains a variable prefix (autoFinish + optional fields)
|
||
// before the emoteCount. Different expansions and server emulator versions differ:
|
||
// Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes
|
||
// TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays)
|
||
// WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays)
|
||
// Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix).
|
||
// We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each.
|
||
|
||
struct ParsedTail {
|
||
uint32_t rewardMoney = 0;
|
||
uint32_t rewardXp = 0;
|
||
std::vector<QuestRewardItem> choiceRewards;
|
||
std::vector<QuestRewardItem> fixedRewards;
|
||
bool ok = false;
|
||
int score = -1000;
|
||
size_t prefixSkip = 0;
|
||
bool fixedArrays = false;
|
||
};
|
||
|
||
auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail {
|
||
ParsedTail out;
|
||
out.prefixSkip = prefixSkip;
|
||
out.fixedArrays = fixedArrays;
|
||
packet.setReadPos(startPos);
|
||
|
||
// Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount)
|
||
if (!packet.hasRemaining(prefixSkip)) return out;
|
||
packet.setReadPos(packet.getReadPos() + prefixSkip);
|
||
|
||
if (!packet.hasRemaining(4)) return out;
|
||
uint32_t emoteCount = packet.readUInt32();
|
||
if (emoteCount > 32) return out; // guard against misalignment
|
||
for (uint32_t i = 0; i < emoteCount; ++i) {
|
||
if (!packet.hasRemaining(8)) return out;
|
||
packet.readUInt32(); // delay
|
||
packet.readUInt32(); // emote type
|
||
}
|
||
|
||
if (!packet.hasRemaining(4)) return out;
|
||
uint32_t choiceCount = packet.readUInt32();
|
||
if (choiceCount > 6) return out;
|
||
uint32_t choiceSlots = fixedArrays ? 6u : choiceCount;
|
||
out.choiceRewards.reserve(choiceCount);
|
||
uint32_t nonZeroChoice = 0;
|
||
for (uint32_t i = 0; i < choiceSlots; ++i) {
|
||
if (!packet.hasRemaining(12)) return out;
|
||
QuestRewardItem item;
|
||
item.itemId = packet.readUInt32();
|
||
item.count = packet.readUInt32();
|
||
item.displayInfoId = packet.readUInt32();
|
||
item.choiceSlot = i;
|
||
if (item.itemId > 0) {
|
||
out.choiceRewards.push_back(item);
|
||
++nonZeroChoice;
|
||
}
|
||
}
|
||
|
||
if (!packet.hasRemaining(4)) return out;
|
||
uint32_t rewardCount = packet.readUInt32();
|
||
if (rewardCount > 4) return out;
|
||
uint32_t rewardSlots = fixedArrays ? 4u : rewardCount;
|
||
out.fixedRewards.reserve(rewardCount);
|
||
uint32_t nonZeroFixed = 0;
|
||
for (uint32_t i = 0; i < rewardSlots; ++i) {
|
||
if (!packet.hasRemaining(12)) return out;
|
||
QuestRewardItem item;
|
||
item.itemId = packet.readUInt32();
|
||
item.count = packet.readUInt32();
|
||
item.displayInfoId = packet.readUInt32();
|
||
if (item.itemId > 0) {
|
||
out.fixedRewards.push_back(item);
|
||
++nonZeroFixed;
|
||
}
|
||
}
|
||
|
||
if (packet.hasRemaining(4))
|
||
out.rewardMoney = packet.readUInt32();
|
||
if (packet.hasRemaining(4))
|
||
out.rewardXp = packet.readUInt32();
|
||
|
||
out.ok = true;
|
||
out.score = 0;
|
||
// Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers)
|
||
if (prefixSkip == 8) out.score += 3;
|
||
else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers
|
||
// Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots)
|
||
if (fixedArrays) out.score += 2;
|
||
// Valid counts
|
||
if (choiceCount <= 6) out.score += 3;
|
||
if (rewardCount <= 4) out.score += 3;
|
||
// All non-zero items are within declared counts
|
||
if (nonZeroChoice <= choiceCount) out.score += 2;
|
||
if (nonZeroFixed <= rewardCount) out.score += 2;
|
||
// No bytes left over (or only a few)
|
||
size_t remaining = packet.getRemainingSize();
|
||
if (remaining == 0) out.score += 5;
|
||
else if (remaining <= 4) out.score += 3;
|
||
else if (remaining <= 8) out.score += 2;
|
||
else if (remaining <= 16) out.score += 1;
|
||
else out.score -= static_cast<int>(remaining / 4);
|
||
// Plausible money/XP values
|
||
if (out.rewardMoney < 5000000u) out.score += 1; // < 500g
|
||
if (out.rewardXp < 200000u) out.score += 1; // < 200k XP
|
||
return out;
|
||
};
|
||
|
||
size_t tailStart = packet.getReadPos();
|
||
// Try prefix sizes 0..16 bytes with both fixed and variable array layouts
|
||
std::vector<ParsedTail> candidates;
|
||
candidates.reserve(34);
|
||
for (size_t skip = 0; skip <= 16; ++skip) {
|
||
candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays
|
||
candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays
|
||
}
|
||
|
||
const ParsedTail* best = nullptr;
|
||
for (const auto& cand : candidates) {
|
||
if (!cand.ok) continue;
|
||
if (!best || cand.score > best->score) best = &cand;
|
||
}
|
||
|
||
if (best) {
|
||
data.choiceRewards = best->choiceRewards;
|
||
data.fixedRewards = best->fixedRewards;
|
||
data.rewardMoney = best->rewardMoney;
|
||
data.rewardXp = best->rewardXp;
|
||
}
|
||
|
||
LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title,
|
||
"' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(),
|
||
" prefix=", (best ? best->prefixSkip : size_t(0)),
|
||
(best && best->fixedArrays ? " fixed" : " var"));
|
||
return true;
|
||
}
|
||
|
||
network::Packet QuestgiverCompleteQuestPacket::build(uint64_t npcGuid, uint32_t questId) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_COMPLETE_QUEST));
|
||
packet.writeUInt64(npcGuid);
|
||
packet.writeUInt32(questId);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet QuestgiverRequestRewardPacket::build(uint64_t npcGuid, uint32_t questId) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD));
|
||
packet.writeUInt64(npcGuid);
|
||
packet.writeUInt32(questId);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet QuestgiverChooseRewardPacket::build(uint64_t npcGuid, uint32_t questId, uint32_t rewardIndex) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_CHOOSE_REWARD));
|
||
packet.writeUInt64(npcGuid);
|
||
packet.writeUInt32(questId);
|
||
packet.writeUInt32(rewardIndex);
|
||
return packet;
|
||
}
|
||
|
||
// ============================================================
|
||
// Vendor
|
||
// ============================================================
|
||
|
||
network::Packet ListInventoryPacket::build(uint64_t npcGuid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_LIST_INVENTORY));
|
||
packet.writeUInt64(npcGuid);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_BUY_ITEM));
|
||
packet.writeUInt64(vendorGuid);
|
||
packet.writeUInt32(itemId); // item entry
|
||
packet.writeUInt32(slot); // vendor slot index from SMSG_LIST_INVENTORY
|
||
packet.writeUInt32(count);
|
||
// Note: WotLK/AzerothCore expects a trailing byte; Classic/TBC do not.
|
||
// This static helper always adds it (appropriate for CMaNGOS/AzerothCore).
|
||
// For Classic/TBC, use the GameHandler::buyItem() path which checks expansion.
|
||
packet.writeUInt8(0);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet SellItemPacket::build(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_SELL_ITEM));
|
||
packet.writeUInt64(vendorGuid);
|
||
packet.writeUInt64(itemGuid);
|
||
packet.writeUInt32(count);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet BuybackItemPacket::build(uint64_t vendorGuid, uint32_t slot) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_BUYBACK_ITEM));
|
||
packet.writeUInt64(vendorGuid);
|
||
packet.writeUInt32(slot);
|
||
return packet;
|
||
}
|
||
|
||
bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data) {
|
||
// Preserve canRepair — it was set by the gossip handler before this packet
|
||
// arrived and is not part of the wire format.
|
||
const bool savedCanRepair = data.canRepair;
|
||
data = ListInventoryData{};
|
||
data.canRepair = savedCanRepair;
|
||
|
||
if (!packet.hasRemaining(9)) {
|
||
LOG_WARNING("ListInventoryParser: packet too short");
|
||
return false;
|
||
}
|
||
|
||
data.vendorGuid = packet.readUInt64();
|
||
uint8_t itemCount = packet.readUInt8();
|
||
|
||
if (itemCount == 0) {
|
||
LOG_INFO("Vendor has nothing for sale");
|
||
return true;
|
||
}
|
||
|
||
// Auto-detect whether server sends 7 fields (28 bytes/item) or 8 fields (32 bytes/item).
|
||
// Some servers omit the extendedCost field entirely; reading 8 fields on a 7-field packet
|
||
// misaligns every item after the first and produces garbage prices.
|
||
size_t remaining = packet.getRemainingSize();
|
||
const size_t bytesPerItemNoExt = 28;
|
||
const size_t bytesPerItemWithExt = 32;
|
||
bool hasExtendedCost = false;
|
||
if (remaining < static_cast<size_t>(itemCount) * bytesPerItemNoExt) {
|
||
LOG_WARNING("ListInventoryParser: truncated packet (items=", static_cast<int>(itemCount),
|
||
", remaining=", remaining, ")");
|
||
return false;
|
||
}
|
||
if (remaining >= static_cast<size_t>(itemCount) * bytesPerItemWithExt) {
|
||
hasExtendedCost = true;
|
||
}
|
||
|
||
data.items.reserve(itemCount);
|
||
for (uint8_t i = 0; i < itemCount; ++i) {
|
||
const size_t perItemBytes = hasExtendedCost ? bytesPerItemWithExt : bytesPerItemNoExt;
|
||
if (!packet.hasRemaining(perItemBytes)) {
|
||
LOG_WARNING("ListInventoryParser: item ", static_cast<int>(i), " truncated");
|
||
return false;
|
||
}
|
||
VendorItem item;
|
||
item.slot = packet.readUInt32();
|
||
item.itemId = packet.readUInt32();
|
||
item.displayInfoId = packet.readUInt32();
|
||
item.maxCount = static_cast<int32_t>(packet.readUInt32());
|
||
item.buyPrice = packet.readUInt32();
|
||
item.durability = packet.readUInt32();
|
||
item.stackCount = packet.readUInt32();
|
||
item.extendedCost = hasExtendedCost ? packet.readUInt32() : 0;
|
||
data.items.push_back(item);
|
||
}
|
||
|
||
LOG_DEBUG("Vendor inventory: ", static_cast<int>(itemCount), " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")");
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// Trainer
|
||
// ============================================================
|
||
|
||
bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bool isClassic) {
|
||
// WotLK per-entry: spellId(4) + state(1) + cost(4) + profDialog(4) + profButton(4) +
|
||
// reqLevel(1) + reqSkill(4) + reqSkillValue(4) + chain×3(12) = 38 bytes
|
||
// Classic per-entry: spellId(4) + state(1) + cost(4) + reqLevel(1) +
|
||
// reqSkill(4) + reqSkillValue(4) + chain×3(12) + unk(4) = 34 bytes
|
||
data = TrainerListData{};
|
||
if (!packet.hasRemaining(16)) return false; // guid(8) + type(4) + count(4)
|
||
|
||
data.trainerGuid = packet.readUInt64();
|
||
data.trainerType = packet.readUInt32();
|
||
uint32_t spellCount = packet.readUInt32();
|
||
|
||
if (spellCount > 1000) {
|
||
LOG_ERROR("TrainerListParser: unreasonable spell count ", spellCount);
|
||
return false;
|
||
}
|
||
|
||
data.spells.reserve(spellCount);
|
||
for (uint32_t i = 0; i < spellCount; ++i) {
|
||
// Validate minimum entry size before reading
|
||
const size_t minEntrySize = isClassic ? 34 : 38;
|
||
if (!packet.hasRemaining(minEntrySize)) {
|
||
LOG_WARNING("TrainerListParser: truncated at spell ", i);
|
||
break;
|
||
}
|
||
|
||
TrainerSpell spell;
|
||
spell.spellId = packet.readUInt32();
|
||
spell.state = packet.readUInt8();
|
||
spell.spellCost = packet.readUInt32();
|
||
if (isClassic) {
|
||
// Classic 1.12: reqLevel immediately after cost; no profDialog/profButton
|
||
spell.profDialog = 0;
|
||
spell.profButton = 0;
|
||
spell.reqLevel = packet.readUInt8();
|
||
} else {
|
||
// TBC / WotLK: profDialog + profButton before reqLevel
|
||
spell.profDialog = packet.readUInt32();
|
||
spell.profButton = packet.readUInt32();
|
||
spell.reqLevel = packet.readUInt8();
|
||
}
|
||
spell.reqSkill = packet.readUInt32();
|
||
spell.reqSkillValue = packet.readUInt32();
|
||
spell.chainNode1 = packet.readUInt32();
|
||
spell.chainNode2 = packet.readUInt32();
|
||
spell.chainNode3 = packet.readUInt32();
|
||
if (isClassic) {
|
||
packet.readUInt32(); // trailing unk / sort index
|
||
}
|
||
data.spells.push_back(spell);
|
||
}
|
||
|
||
if (!packet.hasData()) {
|
||
LOG_WARNING("TrainerListParser: truncated before greeting");
|
||
data.greeting.clear();
|
||
} else {
|
||
data.greeting = packet.readString();
|
||
}
|
||
|
||
LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ",
|
||
spellCount, " spells, type=", data.trainerType,
|
||
", greeting=\"", data.greeting, "\"");
|
||
return true;
|
||
}
|
||
|
||
network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spellId) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_TRAINER_BUY_SPELL));
|
||
packet.writeUInt64(trainerGuid);
|
||
packet.writeUInt32(spellId);
|
||
return packet;
|
||
}
|
||
|
||
// ============================================================
|
||
// Talents
|
||
// ============================================================
|
||
|
||
bool TalentsInfoParser::parse(network::Packet& packet, TalentsInfoData& data) {
|
||
// SMSG_TALENTS_INFO format (AzerothCore variant):
|
||
// uint8 activeSpec
|
||
// uint8 unspentPoints
|
||
// be32 talentCount (metadata, may not match entry count)
|
||
// be16 entryCount (actual number of id+rank entries)
|
||
// Entry[entryCount]: { le32 id, uint8 rank }
|
||
// le32 glyphSlots
|
||
// le16 glyphIds[glyphSlots]
|
||
|
||
const size_t startPos = packet.getReadPos();
|
||
const size_t remaining = packet.getSize() - startPos;
|
||
|
||
if (remaining < 2 + 4 + 2) {
|
||
LOG_ERROR("SMSG_TALENTS_INFO: packet too short (remaining=", remaining, ")");
|
||
return false;
|
||
}
|
||
|
||
data = TalentsInfoData{};
|
||
|
||
// Read header
|
||
data.talentSpec = packet.readUInt8();
|
||
data.unspentPoints = packet.readUInt8();
|
||
|
||
// These two counts are big-endian (network byte order)
|
||
uint32_t talentCountBE = packet.readUInt32();
|
||
uint32_t talentCount = bswap32(talentCountBE);
|
||
|
||
uint16_t entryCountBE = packet.readUInt16();
|
||
uint16_t entryCount = bswap16(entryCountBE);
|
||
|
||
// Sanity check: prevent corrupt packets from allocating excessive memory
|
||
if (entryCount > 64) {
|
||
LOG_ERROR("SMSG_TALENTS_INFO: entryCount too large (", entryCount, "), rejecting packet");
|
||
return false;
|
||
}
|
||
|
||
LOG_INFO("SMSG_TALENTS_INFO: spec=", static_cast<int>(data.talentSpec),
|
||
" unspent=", static_cast<int>(data.unspentPoints),
|
||
" talentCount=", talentCount,
|
||
" entryCount=", entryCount);
|
||
|
||
// Parse learned entries (id + rank pairs)
|
||
// These may be talents, glyphs, or other learned abilities
|
||
data.talents.clear();
|
||
data.talents.reserve(entryCount);
|
||
|
||
for (uint16_t i = 0; i < entryCount; ++i) {
|
||
if (!packet.hasRemaining(5)) {
|
||
LOG_ERROR("SMSG_TALENTS_INFO: truncated entry list at i=", i);
|
||
return false;
|
||
}
|
||
uint32_t id = packet.readUInt32(); // LE
|
||
uint8_t rank = packet.readUInt8();
|
||
data.talents.push_back({id, rank});
|
||
|
||
LOG_INFO(" Entry: id=", id, " rank=", static_cast<int>(rank));
|
||
}
|
||
|
||
// Parse glyph tail: glyphSlots + glyphIds[]
|
||
if (!packet.hasRemaining(1)) {
|
||
LOG_WARNING("SMSG_TALENTS_INFO: no glyph tail data");
|
||
return true; // Not fatal, older formats may not have glyphs
|
||
}
|
||
|
||
uint8_t glyphSlots = packet.readUInt8();
|
||
|
||
// Sanity check: Wrath has 6 glyph slots, cap at 12 for safety
|
||
if (glyphSlots > 12) {
|
||
LOG_WARNING("SMSG_TALENTS_INFO: glyphSlots too large (", static_cast<int>(glyphSlots), "), clamping to 12");
|
||
glyphSlots = 12;
|
||
}
|
||
|
||
LOG_INFO(" GlyphSlots: ", static_cast<int>(glyphSlots));
|
||
|
||
data.glyphs.clear();
|
||
data.glyphs.reserve(glyphSlots);
|
||
|
||
for (uint8_t i = 0; i < glyphSlots; ++i) {
|
||
if (!packet.hasRemaining(2)) {
|
||
LOG_ERROR("SMSG_TALENTS_INFO: truncated glyph list at i=", i);
|
||
return false;
|
||
}
|
||
uint16_t glyphId = packet.readUInt16(); // LE
|
||
data.glyphs.push_back(glyphId);
|
||
if (glyphId != 0) {
|
||
LOG_INFO(" Glyph slot ", i, ": ", glyphId);
|
||
}
|
||
}
|
||
|
||
LOG_INFO("SMSG_TALENTS_INFO: bytesConsumed=", (packet.getReadPos() - startPos),
|
||
" bytesRemaining=", (packet.getRemainingSize()));
|
||
|
||
return true;
|
||
}
|
||
|
||
network::Packet LearnTalentPacket::build(uint32_t talentId, uint32_t requestedRank) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_LEARN_TALENT));
|
||
packet.writeUInt32(talentId);
|
||
packet.writeUInt32(requestedRank);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet TalentWipeConfirmPacket::build(bool accept) {
|
||
network::Packet packet(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM));
|
||
packet.writeUInt32(accept ? 1 : 0);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet ActivateTalentGroupPacket::build(uint32_t group) {
|
||
// CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3 in WotLK 3.3.5a)
|
||
// Payload: uint32 group (0 = primary, 1 = secondary)
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE));
|
||
packet.writeUInt32(group);
|
||
return packet;
|
||
}
|
||
|
||
// ============================================================
|
||
// Death/Respawn
|
||
// ============================================================
|
||
|
||
network::Packet RepopRequestPacket::build() {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_REPOP_REQUEST));
|
||
packet.writeUInt8(1); // request release (1 = manual)
|
||
return packet;
|
||
}
|
||
|
||
network::Packet ReclaimCorpsePacket::build(uint64_t guid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE));
|
||
packet.writeUInt64(guid);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE));
|
||
packet.writeUInt64(npcGuid);
|
||
return packet;
|
||
}
|
||
|
||
network::Packet ResurrectResponsePacket::build(uint64_t casterGuid, bool accept) {
|
||
network::Packet packet(wireOpcode(Opcode::CMSG_RESURRECT_RESPONSE));
|
||
packet.writeUInt64(casterGuid);
|
||
packet.writeUInt8(accept ? 1 : 0);
|
||
return packet;
|
||
}
|
||
|
||
// ============================================================
|
||
// Taxi / Flight Paths
|
||
// ============================================================
|
||
|
||
} // namespace game
|
||
} // namespace wowee
|