Fix quest accept/abandon flow and expansion-specific accept packet format

Normalize WoW quest text tokens during parsing so quest titles/details no longer leak raw markup like  and |n into UI. Apply to WotLK and Classic parser paths, including quest list parsing in GameHandler.

Harden quest state handling by mapping abandon requests to authoritative server quest-log slots (PLAYER_QUEST_LOG_START) instead of local vector order, with a guarded fallback when update fields are unavailable.

Improve accept de-duplication by trusting server slot state over stale local cache; allow re-accept when local/server state diverges and trigger resync semantics.

Add expansion-aware CMSG_QUESTGIVER_ACCEPT_QUEST builders: WotLK sends guid+questId+unk1(uint32), while TBC/Classic/Turtle send guid+questId only. Wire GameHandler through PacketParsers for compatibility across expansions and cores.
This commit is contained in:
Kelsi 2026-02-20 23:20:02 -08:00
parent 73273a6ab5
commit ace24e8ccc
7 changed files with 125 additions and 34 deletions

View file

@ -1130,6 +1130,7 @@ private:
void clearPendingQuestAccept(uint32_t questId);
void triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason);
bool hasQuestInLog(uint32_t questId) const;
int findQuestLogSlotIndexFromServer(uint32_t questId) const;
void addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives);
bool resyncQuestLogFromServerSlots(bool forceQueryMetadata);
void handleListInventory(network::Packet& packet);

View file

@ -156,6 +156,12 @@ public:
return QuestgiverQueryQuestPacket::build(npcGuid, questId); // includes unk1
}
/** Build CMSG_QUESTGIVER_ACCEPT_QUEST.
* WotLK/AzerothCore expects trailing unk1 uint32; older expansions may not. */
virtual network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) {
return QuestgiverAcceptQuestPacket::build(npcGuid, questId);
}
/** Parse SMSG_QUESTGIVER_QUEST_DETAILS.
* WotLK has an extra informUnit GUID before questId; Vanilla/Classic do not. */
virtual bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) {
@ -279,6 +285,7 @@ public:
bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) override;
bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override;
bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override;
network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override;
};
/**
@ -324,6 +331,7 @@ public:
bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override;
uint8_t readQuestGiverStatus(network::Packet& packet) override;
network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override;
network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override;
bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override;
uint8_t questLogStride() const override { return 3; }
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override {

View file

@ -16,6 +16,9 @@
namespace wowee {
namespace game {
// Normalize WoW in-text tokens (e.g. "$B", "|n") into plain text suitable for UI.
std::string normalizeWowTextTokens(std::string text);
/**
* SMSG_AUTH_CHALLENGE data (from server)
*

View file

@ -9957,6 +9957,20 @@ bool GameHandler::hasQuestInLog(uint32_t questId) const {
return false;
}
int GameHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const {
if (questId == 0 || lastPlayerFields_.empty()) return -1;
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
for (uint16_t slot = 0; slot < 25; ++slot) {
const uint16_t idField = ufQuestStart + slot * qStride;
auto it = lastPlayerFields_.find(idField);
if (it != lastPlayerFields_.end() && it->second == questId) {
return static_cast<int>(slot);
}
}
return -1;
}
void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives) {
if (questId == 0 || hasQuestInLog(questId)) return;
QuestLogEntry entry;
@ -10039,18 +10053,23 @@ void GameHandler::acceptQuest() {
currentQuestDetails = QuestDetailsData{};
return;
}
if (hasQuestInLog(questId)) {
LOG_INFO("Ignoring duplicate quest accept already in local log: questId=", questId);
const bool inLocalLog = hasQuestInLog(questId);
const int serverSlot = findQuestLogSlotIndexFromServer(questId);
if (serverSlot >= 0) {
LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId,
" slot=", serverSlot);
questDetailsOpen = false;
currentQuestDetails = QuestDetailsData{};
return;
}
if (inLocalLog) {
LOG_WARNING("Quest accept local/server mismatch, allowing re-accept: questId=", questId);
std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == questId; });
}
// Keep quest accept payload minimal and expansion-safe: guid + questId.
// Some server cores reject trailing bytes and throw ByteBufferException.
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST));
packet.writeUInt64(npcGuid);
packet.writeUInt32(questId);
network::Packet packet = packetParsers_
? packetParsers_->buildAcceptQuestPacket(npcGuid, questId)
: QuestgiverAcceptQuestPacket::build(npcGuid, questId);
socket->send(packet);
pendingQuestAcceptTimeouts_[questId] = 5.0f;
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
@ -10073,20 +10092,34 @@ void GameHandler::declineQuest() {
void GameHandler::abandonQuest(uint32_t questId) {
clearPendingQuestAccept(questId);
// Find the quest's index in our local log
for (size_t i = 0; i < questLog_.size(); i++) {
int localIndex = -1;
for (size_t i = 0; i < questLog_.size(); ++i) {
if (questLog_[i].questId == questId) {
// Tell server to remove it (slot index in server quest log)
// We send the local index; server maps it via PLAYER_QUEST_LOG fields
if (state == WorldState::IN_WORLD && socket) {
network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
pkt.writeUInt8(static_cast<uint8_t>(i));
socket->send(pkt);
}
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(i));
return;
localIndex = static_cast<int>(i);
break;
}
}
int slotIndex = findQuestLogSlotIndexFromServer(questId);
if (slotIndex < 0 && localIndex >= 0) {
// Best-effort fallback if update fields are stale/missing.
slotIndex = localIndex;
LOG_WARNING("Abandon quest using local slot fallback: questId=", questId, " slot=", slotIndex);
}
if (slotIndex >= 0 && slotIndex < 25) {
if (state == WorldState::IN_WORLD && socket) {
network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
pkt.writeUInt8(static_cast<uint8_t>(slotIndex));
socket->send(pkt);
}
} else {
LOG_WARNING("Abandon quest failed: no quest-log slot found for questId=", questId);
}
if (localIndex >= 0) {
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(localIndex));
}
}
void GameHandler::handleQuestRequestItems(network::Packet& packet) {
@ -10770,17 +10803,17 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() >= 5) {
q.questFlags = packet.readUInt32();
q.isRepeatable = packet.readUInt8();
q.title = packet.readString();
q.title = normalizeWowTextTokens(packet.readString());
if (q.title.empty()) {
packet.setReadPos(titlePos);
q.questFlags = 0;
q.isRepeatable = 0;
q.title = packet.readString();
q.title = normalizeWowTextTokens(packet.readString());
}
} else {
q.questFlags = 0;
q.isRepeatable = 0;
q.title = packet.readString();
q.title = normalizeWowTextTokens(packet.readString());
}
if (q.questId != 0) {
data.quests.push_back(std::move(q));

View file

@ -705,7 +705,7 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
// Classic: NO questFlags, NO isRepeatable
quest.questFlags = 0;
quest.isRepeatable = 0;
quest.title = packet.readString();
quest.title = normalizeWowTextTokens(packet.readString());
data.quests.push_back(quest);
}
@ -1220,6 +1220,14 @@ network::Packet ClassicPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, ui
return packet;
}
network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) {
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST));
packet.writeUInt64(npcGuid);
packet.writeUInt32(questId);
// Classic/Turtle: no trailing unk1 uint32
return packet;
}
// ============================================================================
// Classic SMSG_QUESTGIVER_QUEST_DETAILS — Vanilla 1.12 format
// WotLK inserts an informUnit GUID (8 bytes) between npcGuid and questId.
@ -1231,9 +1239,9 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai
data.npcGuid = packet.readUInt64();
// Vanilla: questId follows immediately — no informUnit GUID
data.questId = packet.readUInt32();
data.title = packet.readString();
data.details = packet.readString();
data.objectives = packet.readString();
data.title = normalizeWowTextTokens(packet.readString());
data.details = normalizeWowTextTokens(packet.readString());
data.objectives = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 5 > packet.getSize()) {
LOG_INFO("Quest details classic (short): id=", data.questId, " title='", data.title, "'");

View file

@ -450,6 +450,14 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa
return true;
}
network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) {
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST));
packet.writeUInt64(npcGuid);
packet.writeUInt32(questId);
// TBC servers generally expect guid + questId only.
return packet;
}
// ============================================================================
// TBC parseAuraUpdate - SMSG_AURA_UPDATE doesn't exist in TBC
// TBC uses inline aura update fields + SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE (0x3A3) /

View file

@ -14,6 +14,35 @@
namespace wowee {
namespace game {
std::string normalizeWowTextTokens(std::string text) {
if (text.empty()) return text;
size_t pos = 0;
while ((pos = text.find('$', pos)) != std::string::npos) {
if (pos + 1 >= text.size()) break;
const char code = text[pos + 1];
if (code == 'b' || code == 'B') {
text.replace(pos, 2, "\n");
++pos;
} else {
++pos;
}
}
pos = 0;
while ((pos = text.find("|n", pos)) != std::string::npos) {
text.replace(pos, 2, "\n");
++pos;
}
pos = 0;
while ((pos = text.find("|N", pos)) != std::string::npos) {
text.replace(pos, 2, "\n");
++pos;
}
return text;
}
network::Packet AuthSessionPacket::build(uint32_t build,
const std::string& accountName,
uint32_t clientSeed,
@ -3068,6 +3097,7 @@ network::Packet QuestgiverAcceptQuestPacket::build(uint64_t npcGuid, uint32_t qu
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;
}
@ -3081,15 +3111,15 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
size_t preInform = packet.getReadPos();
/*informUnit*/ packet.readUInt64();
data.questId = packet.readUInt32();
data.title = packet.readString();
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 = packet.readString();
data.title = normalizeWowTextTokens(packet.readString());
}
data.details = packet.readString();
data.objectives = packet.readString();
data.details = normalizeWowTextTokens(packet.readString());
data.objectives = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 10 > packet.getSize()) {
LOG_INFO("Quest details (short): id=", data.questId, " title='", data.title, "'");
@ -3162,7 +3192,7 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data
quest.questLevel = static_cast<int32_t>(packet.readUInt32());
quest.questFlags = packet.readUInt32();
quest.isRepeatable = packet.readUInt8();
quest.title = packet.readString();
quest.title = normalizeWowTextTokens(packet.readString());
data.quests.push_back(quest);
}
@ -3194,8 +3224,8 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa
if (packet.getSize() - packet.getReadPos() < 20) return false;
data.npcGuid = packet.readUInt64();
data.questId = packet.readUInt32();
data.title = packet.readString();
data.completionText = packet.readString();
data.title = normalizeWowTextTokens(packet.readString());
data.completionText = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 9 > packet.getSize()) {
LOG_INFO("Quest request items (short): id=", data.questId, " title='", data.title, "'");
@ -3283,8 +3313,8 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
if (packet.getSize() - packet.getReadPos() < 20) return false;
data.npcGuid = packet.readUInt64();
data.questId = packet.readUInt32();
data.title = packet.readString();
data.rewardText = packet.readString();
data.title = normalizeWowTextTokens(packet.readString());
data.rewardText = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 10 > packet.getSize()) {
LOG_INFO("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");