Implement mailbox interaction and expansion-aware mail system

Fix mailbox right-click (transposed CMSG_GAMEOBJECT_USE opcode, missing
mail opcodes in Turtle WoW JSON, decorative GO type filtering). Add
expansion-aware mail packet handling via PacketParsers: Classic format
(single item, no msgSize prefix, Vanilla field order) vs WotLK format
(attachment arrays, enchant slots). Fix CMSG_MAIL_TAKE_ITEM and
CMSG_MAIL_DELETE for Vanilla (no trailing fields). Add pulsing "New
Mail" indicator below minimap, SMSG_RECEIVED_MAIL and
MSG_QUERY_NEXT_MAIL_TIME handlers, and async sender name backfill.
This commit is contained in:
Kelsi 2026-02-16 18:46:44 -08:00
parent 2e274e3dd1
commit e13e1064d4
8 changed files with 421 additions and 126 deletions

View file

@ -1521,6 +1521,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_RECEIVED_MAIL:
handleReceivedMail(packet);
break;
case Opcode::MSG_QUERY_NEXT_MAIL_TIME:
handleQueryNextMailTime(packet);
break;
default:
// In pre-world states we need full visibility (char create/login handshakes).
@ -5401,6 +5404,13 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) {
msg.senderName = data.name;
}
}
// Backfill mail inbox sender names
for (auto& mail : mailInbox_) {
if (mail.messageType == 0 && mail.senderGuid == data.guid) {
mail.senderName = data.name;
}
}
}
}
@ -7343,8 +7353,39 @@ void GameHandler::interactWithNpc(uint64_t guid) {
void GameHandler::interactWithGameObject(uint64_t guid) {
if (state != WorldState::IN_WORLD || !socket) return;
// Rate-limit to prevent spamming the server
static uint64_t lastInteractGuid = 0;
static std::chrono::steady_clock::time_point lastInteractTime{};
auto now = std::chrono::steady_clock::now();
if (guid == lastInteractGuid &&
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastInteractTime).count() < 1000) {
return; // Ignore repeated clicks within 1 second
}
lastInteractGuid = guid;
lastInteractTime = now;
auto entity = entityManager.getEntity(guid);
auto packet = GameObjectUsePacket::build(guid);
socket->send(packet);
// For mailbox GameObjects (type 19), open mail UI and request mail list.
// In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends
// animation/sound and expects the client to request the mail list.
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
auto* info = getCachedGameObjectInfo(go->getEntry());
if (info && info->type == 19) {
LOG_INFO("Mailbox interaction: opening mail UI and requesting mail list");
mailboxGuid_ = guid;
mailboxOpen_ = true;
hasNewMail_ = false;
selectedMailIndex_ = -1;
showMailCompose_ = false;
refreshMailList();
}
}
}
void GameHandler::selectGossipOption(uint32_t optionId) {
@ -9639,8 +9680,21 @@ void GameHandler::refreshMailList() {
void GameHandler::sendMail(const std::string& recipient, const std::string& subject,
const std::string& body, uint32_t money, uint32_t cod) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = SendMailPacket::build(mailboxGuid_, recipient, subject, body, money, cod);
if (state != WorldState::IN_WORLD) {
LOG_WARNING("sendMail: not in world");
return;
}
if (!socket) {
LOG_WARNING("sendMail: no socket");
return;
}
if (mailboxGuid_ == 0) {
LOG_WARNING("sendMail: mailboxGuid_ is 0 (mailbox closed?)");
return;
}
auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod);
LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money,
" mailboxGuid=", mailboxGuid_);
socket->send(packet);
}
@ -9652,7 +9706,7 @@ void GameHandler::mailTakeMoney(uint32_t mailId) {
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemIndex) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = MailTakeItemPacket::build(mailboxGuid_, mailId, itemIndex);
auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemIndex);
socket->send(packet);
}
@ -9666,7 +9720,7 @@ void GameHandler::mailDelete(uint32_t mailId) {
break;
}
}
auto packet = MailDeletePacket::build(mailboxGuid_, mailId, templateId);
auto packet = packetParsers_->buildMailDelete(mailboxGuid_, mailId, templateId);
socket->send(packet);
}
@ -9685,6 +9739,7 @@ void GameHandler::handleShowMailbox(network::Packet& packet) {
LOG_INFO("SMSG_SHOW_MAILBOX: guid=0x", std::hex, guid, std::dec);
mailboxGuid_ = guid;
mailboxOpen_ = true;
hasNewMail_ = false;
selectedMailIndex_ = -1;
showMailCompose_ = false;
// Request inbox contents
@ -9693,95 +9748,16 @@ void GameHandler::handleShowMailbox(network::Packet& packet) {
void GameHandler::handleMailListResult(network::Packet& packet) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 5) {
if (remaining < 1) {
LOG_WARNING("SMSG_MAIL_LIST_RESULT too short (", remaining, " bytes)");
return;
}
uint32_t totalCount = packet.readUInt32();
uint8_t shownCount = packet.readUInt8();
// Delegate parsing to expansion-aware packet parser
packetParsers_->parseMailList(packet, mailInbox_);
LOG_INFO("SMSG_MAIL_LIST_RESULT: total=", totalCount, " shown=", (int)shownCount);
mailInbox_.clear();
mailInbox_.reserve(shownCount);
for (uint8_t i = 0; i < shownCount; ++i) {
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 2) break;
// Read size of this mail entry (uint16)
uint16_t msgSize = packet.readUInt16();
size_t startPos = packet.getReadPos();
MailMessage msg;
if (remaining < static_cast<size_t>(msgSize) + 2) {
LOG_WARNING("Mail entry ", i, " truncated");
break;
}
msg.messageId = packet.readUInt32();
msg.messageType = packet.readUInt8();
switch (msg.messageType) {
case 0: // Normal player mail
msg.senderGuid = packet.readUInt64();
break;
case 2: // Auction
case 3: // Creature
case 4: // GameObject
case 5: // Calendar
msg.senderEntry = packet.readUInt32();
break;
default:
msg.senderEntry = packet.readUInt32();
break;
}
msg.cod = packet.readUInt32();
packet.readUInt32(); // unknown / item text id
packet.readUInt32(); // unknown
msg.stationeryId = packet.readUInt32();
msg.money = packet.readUInt32();
msg.flags = packet.readUInt32();
msg.expirationTime = packet.readFloat();
msg.mailTemplateId = packet.readUInt32();
msg.subject = packet.readString();
// Body - only present if not a mail template
if (msg.mailTemplateId == 0) {
msg.body = packet.readString();
}
// Attachments
uint8_t attachCount = packet.readUInt8();
msg.attachments.reserve(attachCount);
for (uint8_t j = 0; j < attachCount; ++j) {
MailAttachment att;
att.slot = packet.readUInt8();
att.itemGuidLow = packet.readUInt32();
att.itemId = packet.readUInt32();
// Enchantments (7 slots: id, duration, charges per slot = 21 uint32s)
for (int e = 0; e < 7; ++e) {
uint32_t enchId = packet.readUInt32();
packet.readUInt32(); // duration
packet.readUInt32(); // charges
if (e == 0) att.enchantId = enchId;
}
att.randomPropertyId = packet.readUInt32();
att.randomSuffix = packet.readUInt32();
att.stackCount = packet.readUInt32();
att.chargesOrDurability = packet.readUInt32();
att.maxDurability = packet.readUInt32();
msg.attachments.push_back(att);
}
msg.read = (msg.flags & 0x01) != 0; // MAIL_CHECK_MASK_READ
// Resolve sender name for player mail
// Resolve sender names (needs GameHandler context, so done here)
for (auto& msg : mailInbox_) {
if (msg.messageType == 0 && msg.senderGuid != 0) {
msg.senderName = getCachedPlayerName(msg.senderGuid);
if (msg.senderName.empty()) {
@ -9796,20 +9772,16 @@ void GameHandler::handleMailListResult(network::Packet& packet) {
} else {
msg.senderName = "System";
}
mailInbox_.push_back(std::move(msg));
// Skip any unread bytes in this mail entry
size_t consumed = packet.getReadPos() - startPos;
if (consumed < msgSize) {
size_t skip = msgSize - consumed;
for (size_t s = 0; s < skip && packet.getReadPos() < packet.getSize(); ++s) {
packet.readUInt8();
}
}
}
LOG_INFO("Parsed ", mailInbox_.size(), " mail messages");
// Open the mailbox UI if it isn't already open (Vanilla has no SMSG_SHOW_MAILBOX).
if (!mailboxOpen_) {
LOG_INFO("Opening mailbox UI (triggered by SMSG_MAIL_LIST_RESULT)");
mailboxOpen_ = true;
hasNewMail_ = false;
selectedMailIndex_ = -1;
showMailCompose_ = false;
}
}
void GameHandler::handleSendMailResult(network::Packet& packet) {
@ -9823,7 +9795,7 @@ void GameHandler::handleSendMailResult(network::Packet& packet) {
uint32_t error = packet.readUInt32();
// Commands: 0=send, 1=moneyTaken, 2=itemTaken, 3=returnedToSender, 4=deleted, 5=madePermanent
// Errors: 0=OK, 1=equip, 2=cannotSend, 3=messageTooBig, 4=noMoney, ...
// Vanilla errors: 0=OK, 1=equipError, 2=cannotSendToSelf, 3=notEnoughMoney, 4=recipientNotFound, 5=notYourTeam, 6=internalError
static const char* cmdNames[] = {"Send", "TakeMoney", "TakeItem", "Return", "Delete", "MadePermanent"};
const char* cmdName = (command < 6) ? cmdNames[command] : "Unknown";
@ -9858,19 +9830,17 @@ void GameHandler::handleSendMailResult(network::Packet& packet) {
std::string errMsg = "Mail error: ";
switch (error) {
case 1: errMsg += "Equipment error."; break;
case 2: errMsg += "Cannot send mail."; break;
case 3: errMsg += "Message too big."; break;
case 4: errMsg += "Not enough money."; break;
case 5: errMsg += "Not enough items."; break;
case 6: errMsg += "Recipient not found."; break;
case 7: errMsg += "Cannot send to that player."; break;
case 8: errMsg += "Equip error."; break;
case 9: errMsg += "Inventory full."; break;
case 10: errMsg += "Not a GM."; break;
case 11: errMsg += "Max attachments exceeded."; break;
case 14: errMsg += "Cannot send wrapped COD."; break;
case 15: errMsg += "Mail and chat suspended."; break;
case 16: errMsg += "Too many attachments."; break;
case 2: errMsg += "You cannot send mail to yourself."; break;
case 3: errMsg += "Not enough money."; break;
case 4: errMsg += "Recipient not found."; break;
case 5: errMsg += "Cannot send to the opposing faction."; break;
case 6: errMsg += "Internal mail error."; break;
case 14: errMsg += "Disabled for trial accounts."; break;
case 15: errMsg += "Recipient's mailbox is full."; break;
case 16: errMsg += "Cannot send wrapped items COD."; break;
case 17: errMsg += "Mail and chat suspended."; break;
case 18: errMsg += "Too many attachments."; break;
case 19: errMsg += "Invalid attachment."; break;
default: errMsg += "Unknown error (" + std::to_string(error) + ")."; break;
}
addSystemChatMessage(errMsg);
@ -9884,6 +9854,7 @@ void GameHandler::handleReceivedMail(network::Packet& packet) {
(void)nextMailTime;
}
LOG_INFO("SMSG_RECEIVED_MAIL: New mail arrived!");
hasNewMail_ = true;
addSystemChatMessage("New mail has arrived.");
// If mailbox is open, refresh
if (mailboxOpen_) {
@ -9891,6 +9862,25 @@ void GameHandler::handleReceivedMail(network::Packet& packet) {
}
}
void GameHandler::handleQueryNextMailTime(network::Packet& packet) {
// Server response to MSG_QUERY_NEXT_MAIL_TIME
// If there's pending mail, the packet contains a float with time until next mail delivery
// A value of 0.0 or the presence of mail entries means there IS mail waiting
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining >= 4) {
float nextMailTime = packet.readFloat();
// In Vanilla: 0x00000000 = has mail, 0xC7A8C000 (big negative) = no mail
uint32_t rawValue;
std::memcpy(&rawValue, &nextMailTime, sizeof(uint32_t));
if (rawValue == 0 || nextMailTime >= 0.0f) {
hasNewMail_ = true;
LOG_INFO("MSG_QUERY_NEXT_MAIL_TIME: Player has pending mail");
} else {
LOG_INFO("MSG_QUERY_NEXT_MAIL_TIME: No pending mail (value=", nextMailTime, ")");
}
}
}
glm::vec3 GameHandler::getComposedWorldPosition() {
if (playerTransportGuid_ != 0 && transportManager_) {
return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);

View file

@ -488,7 +488,7 @@ void OpcodeTable::loadWotlkDefaults() {
{LogicalOpcode::SMSG_GOSSIP_MESSAGE, 0x17D},
{LogicalOpcode::SMSG_GOSSIP_COMPLETE, 0x17E},
{LogicalOpcode::SMSG_NPC_TEXT_UPDATE, 0x180},
{LogicalOpcode::CMSG_GAMEOBJECT_USE, 0x01B},
{LogicalOpcode::CMSG_GAMEOBJECT_USE, 0x0B1},
{LogicalOpcode::CMSG_QUESTGIVER_STATUS_QUERY, 0x182},
{LogicalOpcode::SMSG_QUESTGIVER_STATUS, 0x183},
{LogicalOpcode::SMSG_QUESTGIVER_STATUS_MULTIPLE, 0x198},
@ -596,15 +596,15 @@ void OpcodeTable::loadWotlkDefaults() {
{LogicalOpcode::SMSG_CHANNEL_LIST, 0x09B},
{LogicalOpcode::SMSG_INSPECT_TALENT, 0x3F4},
// Mail
{LogicalOpcode::SMSG_SHOW_MAILBOX, 0x24B},
{LogicalOpcode::SMSG_SHOW_MAILBOX, 0x297},
{LogicalOpcode::CMSG_GET_MAIL_LIST, 0x23A},
{LogicalOpcode::SMSG_MAIL_LIST_RESULT, 0x23B},
{LogicalOpcode::CMSG_SEND_MAIL, 0x238},
{LogicalOpcode::SMSG_SEND_MAIL_RESULT, 0x239},
{LogicalOpcode::CMSG_MAIL_TAKE_MONEY, 0x245},
{LogicalOpcode::CMSG_MAIL_TAKE_ITEM, 0x244},
{LogicalOpcode::CMSG_MAIL_DELETE, 0x243},
{LogicalOpcode::CMSG_MAIL_MARK_AS_READ, 0x242},
{LogicalOpcode::CMSG_MAIL_TAKE_ITEM, 0x246},
{LogicalOpcode::CMSG_MAIL_DELETE, 0x249},
{LogicalOpcode::CMSG_MAIL_MARK_AS_READ, 0x247},
{LogicalOpcode::SMSG_RECEIVED_MAIL, 0x285},
{LogicalOpcode::MSG_QUERY_NEXT_MAIL_TIME, 0x284},
};

View file

@ -698,5 +698,147 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
return true;
}
// ============================================================================
// Classic CMSG_SEND_MAIL — Vanilla 1.12 format
// Differences from WotLK:
// - Single uint64 itemGuid instead of uint8 attachmentCount + item array
// - Trailing uint64 unk3 + uint8 unk4 (clients > 1.9.4)
// ============================================================================
network::Packet ClassicPacketParsers::buildSendMail(uint64_t mailboxGuid,
const std::string& recipient,
const std::string& subject,
const std::string& body,
uint32_t money, uint32_t cod) {
network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL));
packet.writeUInt64(mailboxGuid);
packet.writeString(recipient);
packet.writeString(subject);
packet.writeString(body);
packet.writeUInt32(0); // stationery
packet.writeUInt32(0); // unknown
packet.writeUInt64(0); // item GUID (0 = no attachment, single item only in Vanilla)
packet.writeUInt32(money);
packet.writeUInt32(cod);
packet.writeUInt64(0); // unk3 (clients > 1.9.4)
packet.writeUInt8(0); // unk4 (clients > 1.9.4)
return packet;
}
// ============================================================================
// Classic SMSG_MAIL_LIST_RESULT — Vanilla 1.12 format (per vmangos)
// Key differences from WotLK:
// - uint8 count (not uint32 totalCount + uint8 shownCount)
// - No msgSize prefix per entry
// - Subject comes before item data
// - Single inline item (not attachment count + array)
// - uint8 stackCount (not uint32)
// - No enchantment array (single permanentEnchant uint32)
// ============================================================================
bool ClassicPacketParsers::parseMailList(network::Packet& packet,
std::vector<MailMessage>& inbox) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 1) return false;
uint8_t count = packet.readUInt8();
LOG_INFO("SMSG_MAIL_LIST_RESULT (Classic): count=", (int)count);
inbox.clear();
inbox.reserve(count);
for (uint8_t i = 0; i < count; ++i) {
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 5) {
LOG_WARNING("Classic mail entry ", i, " truncated (", remaining, " bytes left)");
break;
}
MailMessage msg;
// vmangos HandleGetMailList format:
// u32 messageId, u8 messageType, sender (guid or u32),
// string subject, u32 itemTextId, u32 package, u32 stationery,
// item fields (entry, enchant, randomProp, suffixFactor,
// u8 stackCount, u32 charges, u32 maxDur, u32 dur),
// u32 money, u32 cod, u32 flags, float expirationTime,
// u32 mailTemplateId (build-dependent)
msg.messageId = packet.readUInt32();
msg.messageType = packet.readUInt8();
switch (msg.messageType) {
case 0: msg.senderGuid = packet.readUInt64(); break;
default: msg.senderEntry = packet.readUInt32(); break;
}
msg.subject = packet.readString();
uint32_t itemTextId = packet.readUInt32();
(void)itemTextId;
packet.readUInt32(); // package (unused)
msg.stationeryId = packet.readUInt32();
// Single inline item (Vanilla: one item per mail)
uint32_t itemEntry = packet.readUInt32();
uint32_t permanentEnchant = packet.readUInt32();
uint32_t randomPropertyId = packet.readUInt32();
uint32_t suffixFactor = packet.readUInt32();
uint8_t stackCount = packet.readUInt8();
packet.readUInt32(); // charges
uint32_t maxDurability = packet.readUInt32();
uint32_t durability = packet.readUInt32();
if (itemEntry != 0) {
MailAttachment att;
att.slot = 0;
att.itemGuidLow = 0; // Not provided in Vanilla list
att.itemId = itemEntry;
att.enchantId = permanentEnchant;
att.randomPropertyId = randomPropertyId;
att.randomSuffix = suffixFactor;
att.stackCount = stackCount;
att.chargesOrDurability = durability;
att.maxDurability = maxDurability;
msg.attachments.push_back(att);
}
msg.money = packet.readUInt32();
msg.cod = packet.readUInt32();
msg.flags = packet.readUInt32();
msg.expirationTime = packet.readFloat();
msg.mailTemplateId = packet.readUInt32();
msg.read = (msg.flags & 0x01) != 0;
inbox.push_back(std::move(msg));
}
LOG_INFO("Parsed ", inbox.size(), " mail messages");
return true;
}
// ============================================================================
// Classic CMSG_MAIL_TAKE_ITEM — Vanilla only sends mailboxGuid + mailId
// (no itemSlot — Vanilla only supports 1 item per mail)
// ============================================================================
network::Packet ClassicPacketParsers::buildMailTakeItem(uint64_t mailboxGuid,
uint32_t mailId,
uint32_t /*itemSlot*/) {
network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_ITEM));
packet.writeUInt64(mailboxGuid);
packet.writeUInt32(mailId);
return packet;
}
// ============================================================================
// Classic CMSG_MAIL_DELETE — Vanilla only sends mailboxGuid + mailId
// (no mailTemplateId field)
// ============================================================================
network::Packet ClassicPacketParsers::buildMailDelete(uint64_t mailboxGuid,
uint32_t mailId,
uint32_t /*mailTemplateId*/) {
network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_DELETE));
packet.writeUInt64(mailboxGuid);
packet.writeUInt32(mailId);
return packet;
}
} // namespace game
} // namespace wowee

View file

@ -1,4 +1,5 @@
#include "game/world_packets.hpp"
#include "game/packet_parsers.hpp"
#include "game/opcodes.hpp"
#include "game/character.hpp"
#include "auth/crypto.hpp"
@ -3316,14 +3317,15 @@ network::Packet GetMailListPacket::build(uint64_t mailboxGuid) {
network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& recipient,
const std::string& subject, const std::string& body,
uint32_t money, uint32_t cod) {
// WotLK 3.3.5a format
network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL));
packet.writeUInt64(mailboxGuid);
packet.writeString(recipient);
packet.writeString(subject);
packet.writeString(body);
packet.writeUInt32(0); // stationery (default)
packet.writeUInt32(0); // stationery
packet.writeUInt32(0); // unknown
packet.writeUInt8(0); // attachment count (no item attachments for now)
packet.writeUInt8(0); // attachment count (0 = no attachments)
packet.writeUInt32(money);
packet.writeUInt32(cod);
return packet;
@ -3359,5 +3361,95 @@ network::Packet MailMarkAsReadPacket::build(uint64_t mailboxGuid, uint32_t mailI
return packet;
}
// ============================================================================
// PacketParsers::parseMailList — WotLK 3.3.5a format (base/default)
// ============================================================================
bool PacketParsers::parseMailList(network::Packet& packet, std::vector<MailMessage>& inbox) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 5) return false;
uint32_t totalCount = packet.readUInt32();
uint8_t shownCount = packet.readUInt8();
(void)totalCount;
LOG_INFO("SMSG_MAIL_LIST_RESULT (WotLK): total=", totalCount, " shown=", (int)shownCount);
inbox.clear();
inbox.reserve(shownCount);
for (uint8_t i = 0; i < shownCount; ++i) {
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 2) break;
uint16_t msgSize = packet.readUInt16();
size_t startPos = packet.getReadPos();
MailMessage msg;
if (remaining < static_cast<size_t>(msgSize) + 2) {
LOG_WARNING("Mail entry ", i, " truncated");
break;
}
msg.messageId = packet.readUInt32();
msg.messageType = packet.readUInt8();
switch (msg.messageType) {
case 0: msg.senderGuid = packet.readUInt64(); break;
case 2: case 3: case 4: case 5:
msg.senderEntry = packet.readUInt32(); break;
default: msg.senderEntry = packet.readUInt32(); break;
}
msg.cod = packet.readUInt32();
packet.readUInt32(); // item text id
packet.readUInt32(); // unknown
msg.stationeryId = packet.readUInt32();
msg.money = packet.readUInt32();
msg.flags = packet.readUInt32();
msg.expirationTime = packet.readFloat();
msg.mailTemplateId = packet.readUInt32();
msg.subject = packet.readString();
if (msg.mailTemplateId == 0) {
msg.body = packet.readString();
}
uint8_t attachCount = packet.readUInt8();
msg.attachments.reserve(attachCount);
for (uint8_t j = 0; j < attachCount; ++j) {
MailAttachment att;
att.slot = packet.readUInt8();
att.itemGuidLow = packet.readUInt32();
att.itemId = packet.readUInt32();
for (int e = 0; e < 7; ++e) {
uint32_t enchId = packet.readUInt32();
packet.readUInt32(); // duration
packet.readUInt32(); // charges
if (e == 0) att.enchantId = enchId;
}
att.randomPropertyId = packet.readUInt32();
att.randomSuffix = packet.readUInt32();
att.stackCount = packet.readUInt32();
att.chargesOrDurability = packet.readUInt32();
att.maxDurability = packet.readUInt32();
msg.attachments.push_back(att);
}
msg.read = (msg.flags & 0x01) != 0;
inbox.push_back(std::move(msg));
// Skip unread bytes
size_t consumed = packet.getReadPos() - startPos;
if (consumed < msgSize) {
size_t skip = msgSize - consumed;
for (size_t s = 0; s < skip && packet.getReadPos() < packet.getSize(); ++s)
packet.readUInt8();
}
}
LOG_INFO("Parsed ", inbox.size(), " mail messages");
return true;
}
} // namespace game
} // namespace wowee

View file

@ -1199,8 +1199,14 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
hitRadius = 1.2f;
heightOffset = 0.8f;
// Check GO type — skip non-interactable decorations
auto go = std::static_pointer_cast<game::GameObject>(entity);
auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry());
uint32_t goType = goInfo ? goInfo->type : 0;
// Type 5 = GENERIC (decorations), skip
if (goType == 5) continue;
hitRadius = 2.5f;
heightOffset = 1.2f;
}
hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
@ -1262,8 +1268,14 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
hitRadius = 1.2f;
heightOffset = 0.8f;
// Check GO type — skip non-interactable decorations
auto go = std::static_pointer_cast<game::GameObject>(entity);
auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry());
uint32_t goType = goInfo ? goInfo->type : 0;
// Type 5 = GENERIC (decorations), skip
if (goType == 5) continue;
hitRadius = 2.5f;
heightOffset = 1.2f;
}
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
@ -5728,6 +5740,23 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
ImGui::PopStyleVar(2);
}
ImGui::End();
// "New Mail" indicator below the minimap
if (gameHandler.hasNewMail()) {
float indicatorX = centerX - mapRadius;
float indicatorY = centerY + mapRadius + 4.0f;
ImGui::SetNextWindowPos(ImVec2(indicatorX, indicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(mapRadius * 2.0f, 22), ImGuiCond_Always);
ImGuiWindowFlags mailFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs;
if (ImGui::Begin("##NewMailIndicator", nullptr, mailFlags)) {
// Pulsing effect
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!");
}
ImGui::End();
}
}
std::string GameScreen::getSettingsPath() {