Files
WoWee/tests/test_quest_rewards.cpp
Kelsi 181162f0ce fix(quest): parse quest reward items with verified per-expansion layouts
Quest rewards showed garbage item ids in the quest log, accept, and completion
dialogs. Verified the wire formats against vmangos/cmangos-tbc/AzerothCore and
rewrote all three parsers: query-response rewards are interleaved (id,count)
pairs at per-expansion offsets, details/offer-reward use uint32 autoFinish and
trailing emotes pre-WotLK, and WotLK has no portrait strings and variable-count
reward arrays. Adds era-parameterized parsing plus synthetic-packet regression
tests for every layout.
2026-07-14 00:44:53 -07:00

464 lines
17 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Quest reward packet parsing across expansions.
//
// Layouts emulated here byte-for-byte from the server serializers:
// Classic — vmangos src/game/Server/Packets/Quest.cpp
// TBC — cmangos-tbc src/game/Entities/GossipDef.cpp
// WotLK — azerothcore-wotlk src/server/game/Entities/Creature/GossipDef.cpp
// Reward items were previously misparsed (wrong offsets / parallel-array
// assumptions / phantom 4.x portrait strings), showing garbage item numbers.
#include <catch_amalgamated.hpp>
#include "game/world_packets.hpp"
#include "game/packet_parsers.hpp"
#include "core/application.hpp"
#include <cstring>
// Parsers under test are era-parameterized or expansion-agnostic; none touch
// the Application singleton, so a null instance satisfies the linker.
namespace wowee {
namespace core {
Application* Application::instance = nullptr;
}
}
using namespace wowee::game;
namespace network = wowee::network;
using Bytes = std::vector<uint8_t>;
namespace {
void putU8(Bytes& b, uint8_t v) { b.push_back(v); }
void putU32(Bytes& b, uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
}
void putU64(Bytes& b, uint64_t v) {
putU32(b, static_cast<uint32_t>(v));
putU32(b, static_cast<uint32_t>(v >> 32));
}
void putF(Bytes& b, float v) {
uint32_t u; std::memcpy(&u, &v, 4); putU32(b, u);
}
void putStr(Bytes& b, const char* s) {
while (*s) b.push_back(static_cast<uint8_t>(*s++));
b.push_back(0);
}
} // namespace
// ============================================================
// SMSG_QUEST_QUERY_RESPONSE — quest log rewards
// ============================================================
TEST_CASE("Quest query rewards: Classic layout (vmangos)", "[quest_rewards]") {
Bytes b;
putU32(b, 100); // questId
putU32(b, 2); // questMethod
putU32(b, 5); // questLevel
putU32(b, 12); // zoneOrSort
putU32(b, 0); // type
putU32(b, 0); // repObjectiveFaction
putU32(b, 0); // repObjectiveValue
putU32(b, 0); // requiredOppositeFaction
putU32(b, 0); // requiredOppositeValue
putU32(b, 101); // nextQuestInChain
putU32(b, 150); // rewOrReqMoney
putU32(b, 900); // rewMoneyMaxLevel
putU32(b, 0); // rewSpell
putU32(b, 0); // srcItemId
putU32(b, 8); // questFlags
// 4 reward (id, count) pairs — fixed loop, interleaved
putU32(b, 1234); putU32(b, 1);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
// 6 choice (id, count) pairs — fixed loop, interleaved
putU32(b, 2345); putU32(b, 2);
putU32(b, 3456); putU32(b, 1);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); // pointMapId
putF(b, 0.0f); // pointX
putF(b, 0.0f); // pointY
putU32(b, 0); // pointOpt
putStr(b, "Test Quest");
putStr(b, "Objectives");
putStr(b, "Details");
putStr(b, "");
auto r = QuestQueryRewardsParser::parse(b, /*questLogStride=*/3);
REQUIRE(r.valid);
CHECK(r.rewardMoney == 150);
CHECK(r.itemId[0] == 1234);
CHECK(r.itemCount[0] == 1);
CHECK(r.itemId[1] == 0);
CHECK(r.choiceItemId[0] == 2345);
CHECK(r.choiceItemCount[0] == 2);
CHECK(r.choiceItemId[1] == 3456);
CHECK(r.choiceItemCount[1] == 1);
CHECK(r.choiceItemId[2] == 0);
}
TEST_CASE("Quest query rewards: TBC layout (cmangos-tbc)", "[quest_rewards]") {
Bytes b;
putU32(b, 200); // questId
putU32(b, 2); // questMethod
putU32(b, 61); // questLevel
putU32(b, 3520); // zoneOrSort
putU32(b, 0); // type
putU32(b, 0); // suggestedPlayers (TBC addition)
putU32(b, 0); // repObjectiveFaction
putU32(b, 0); // repObjectiveValue
putU32(b, 0); // requiredOppositeFaction
putU32(b, 0); // requiredOppositeValue
putU32(b, 0); // nextQuestInChain
putU32(b, 7700); // rewOrReqMoney
putU32(b, 9000); // rewMoneyMaxLevel
putU32(b, 0); // rewSpell
putU32(b, 0); // rewSpellCast (TBC addition)
putU32(b, 0); // rewHonorableKills (TBC addition)
putU32(b, 0); // srcItemId
putU32(b, 8); // questFlags
putU32(b, 0); // charTitleId (TBC addition)
// 4 reward pairs, interleaved
putU32(b, 25406); putU32(b, 1);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
// 6 choice pairs, interleaved
putU32(b, 25407); putU32(b, 1);
putU32(b, 25408); putU32(b, 1);
putU32(b, 25409); putU32(b, 1);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 530); putF(b, 1.0f); putF(b, 2.0f); putU32(b, 0); // POI
putStr(b, "Through the Dark Portal");
putStr(b, "Objectives");
putStr(b, "Details");
putStr(b, "");
auto r = QuestQueryRewardsParser::parse(b, /*questLogStride=*/4);
REQUIRE(r.valid);
CHECK(r.rewardMoney == 7700);
CHECK(r.itemId[0] == 25406);
CHECK(r.choiceItemId[0] == 25407);
CHECK(r.choiceItemId[1] == 25408);
CHECK(r.choiceItemId[2] == 25409);
CHECK(r.choiceItemCount[2] == 1);
CHECK(r.choiceItemId[3] == 0);
}
TEST_CASE("Quest query rewards: WotLK layout (AzerothCore)", "[quest_rewards]") {
Bytes b;
putU32(b, 300); // questId
putU32(b, 2); // questMethod
putU32(b, 70); // questLevel
putU32(b, 68); // minLevel (WotLK addition)
putU32(b, 3537); // zoneOrSort
putU32(b, 0); // type
putU32(b, 0); // suggestedPlayers
putU32(b, 0); // repObjectiveFaction
putU32(b, 0); // repObjectiveValue
putU32(b, 0); // repObjectiveFaction2
putU32(b, 0); // repObjectiveValue2
putU32(b, 0); // nextQuestInChain
putU32(b, 5); // xpId
putU32(b, 47400); // moneyReward
putU32(b, 19800); // rewMoneyMaxLevel
putU32(b, 0); // rewSpell
putU32(b, 0); // rewSpellCast
putU32(b, 0); // rewHonorAddition
putF(b, 0.0f); // rewHonorMultiplier
putU32(b, 0); // srcItemId
putU32(b, 8); // flags
putU32(b, 0); // charTitleId
putU32(b, 0); // playersSlain
putU32(b, 0); // bonusTalents
putU32(b, 0); // rewArenaPoints
putU32(b, 0); // reviewRepShowMask
// 4 reward pairs, interleaved
putU32(b, 35953); putU32(b, 2);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
// 6 choice pairs, interleaved
putU32(b, 36926); putU32(b, 1);
putU32(b, 36927); putU32(b, 1);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
putU32(b, 0); putU32(b, 0);
// 3 × 5 reputation arrays
for (int i = 0; i < 15; ++i) putU32(b, 0);
putU32(b, 571); putF(b, 1.0f); putF(b, 2.0f); putU32(b, 0); // POI
putStr(b, "The Last Rites");
putStr(b, "Objectives");
putStr(b, "Details");
putStr(b, "Area");
putStr(b, "Completed");
auto r = QuestQueryRewardsParser::parse(b, /*questLogStride=*/5);
REQUIRE(r.valid);
CHECK(r.rewardMoney == 47400);
CHECK(r.itemId[0] == 35953);
CHECK(r.itemCount[0] == 2);
CHECK(r.choiceItemId[0] == 36926);
CHECK(r.choiceItemId[1] == 36927);
CHECK(r.choiceItemId[2] == 0);
}
TEST_CASE("Quest query rewards: implausible data rejected", "[quest_rewards]") {
// Payload long enough but reward region contains string bytes (layout mismatch)
Bytes b;
putU32(b, 400); putU32(b, 2);
for (int i = 0; i < 20; ++i) putStr(b, "Lorem ipsum dolor sit amet");
auto r = QuestQueryRewardsParser::parse(b, /*questLogStride=*/3);
CHECK_FALSE(r.valid);
}
// ============================================================
// SMSG_QUESTGIVER_QUEST_DETAILS — accept dialog rewards
// ============================================================
TEST_CASE("Quest details: Classic layout (vmangos)", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456); // npcGuid
putU32(b, 100); // questId
putStr(b, "Test Quest");
putStr(b, "Some details");
putStr(b, "Some objectives");
putU32(b, 1); // activateAccept — uint32 in vanilla
// choice items: count + count × (id, count, display) — only non-empty slots
putU32(b, 2);
putU32(b, 2345); putU32(b, 1); putU32(b, 7001);
putU32(b, 3456); putU32(b, 5); putU32(b, 7002);
// reward items
putU32(b, 1);
putU32(b, 1234); putU32(b, 1); putU32(b, 7003);
putU32(b, 150); // money
putU32(b, 0); // rewSpell
putU32(b, 4); // emote count — LAST, after rewards
for (int i = 0; i < 4; ++i) { putU32(b, 0); putU32(b, 0); }
network::Packet pkt(0, b);
QuestDetailsData d;
REQUIRE(TbcPacketParsers::parseQuestDetailsPreWotlk(pkt, d, /*hasSuggestedPlayers=*/false));
CHECK(d.questId == 100);
CHECK(d.title == "Test Quest");
REQUIRE(d.rewardChoiceItems.size() == 2);
CHECK(d.rewardChoiceItems[0].itemId == 2345);
CHECK(d.rewardChoiceItems[1].itemId == 3456);
CHECK(d.rewardChoiceItems[1].count == 5);
REQUIRE(d.rewardItems.size() == 1);
CHECK(d.rewardItems[0].itemId == 1234);
CHECK(d.rewardMoney == 150);
}
TEST_CASE("Quest details: Classic hidden rewards", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456);
putU32(b, 101);
putStr(b, "Hidden Quest");
putStr(b, "d"); putStr(b, "o");
putU32(b, 0); // activateAccept
putU32(b, 0); // choice count hidden
putU32(b, 0); // item count hidden
putU32(b, 0); // money hidden
putU32(b, 0); // rewSpell
putU32(b, 4);
for (int i = 0; i < 4; ++i) { putU32(b, 0); putU32(b, 0); }
network::Packet pkt(0, b);
QuestDetailsData d;
REQUIRE(TbcPacketParsers::parseQuestDetailsPreWotlk(pkt, d, false));
CHECK(d.rewardChoiceItems.empty());
CHECK(d.rewardItems.empty());
CHECK(d.rewardMoney == 0);
}
TEST_CASE("Quest details: TBC layout (cmangos-tbc)", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456);
putU32(b, 200);
putStr(b, "TBC Quest");
putStr(b, "d"); putStr(b, "o");
putU32(b, 1); // activateAccept — uint32
putU32(b, 3); // suggestedPlayers (TBC only)
putU32(b, 1);
putU32(b, 25407); putU32(b, 1); putU32(b, 8001);
putU32(b, 0); // reward item count
putU32(b, 7700); // money
putU32(b, 0); // honor
putU32(b, 0); // rewSpell
putU32(b, 0); // rewSpellCast
putU32(b, 0); // charTitleBitIndex
putU32(b, 4);
for (int i = 0; i < 4; ++i) { putU32(b, 0); putU32(b, 0); }
network::Packet pkt(0, b);
QuestDetailsData d;
REQUIRE(TbcPacketParsers::parseQuestDetailsPreWotlk(pkt, d, /*hasSuggestedPlayers=*/true));
CHECK(d.suggestedPlayers == 3);
REQUIRE(d.rewardChoiceItems.size() == 1);
CHECK(d.rewardChoiceItems[0].itemId == 25407);
CHECK(d.rewardItems.empty());
CHECK(d.rewardMoney == 7700);
}
TEST_CASE("Quest details: WotLK layout (AzerothCore)", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456); // npcGuid
putU64(b, 0); // informUnit (GetDivider)
putU32(b, 300); // questId
putStr(b, "WotLK Quest");
putStr(b, "Some details");
putStr(b, "Some objectives");
putU8(b, 1); // activateAccept — uint8 in WotLK
putU32(b, 8); // flags
putU32(b, 0); // suggestedPlayers
putU8(b, 0); // isFinished
// choice items: VARIABLE count (only non-empty slots serialized)
putU32(b, 2);
putU32(b, 36926); putU32(b, 1); putU32(b, 9001);
putU32(b, 36927); putU32(b, 1); putU32(b, 9002);
// reward items
putU32(b, 1);
putU32(b, 35953); putU32(b, 2); putU32(b, 9003);
putU32(b, 47400); // money
putU32(b, 20000); // xp
putU32(b, 0); // honor
putF(b, 0.0f); // honor multiplier
putU32(b, 0); // rewSpell
putU32(b, 0); // rewSpellCast
putU32(b, 0); // titleId
putU32(b, 0); // bonusTalents
putU32(b, 0); // arenaPoints
putU32(b, 0); // unk
for (int i = 0; i < 15; ++i) putU32(b, 0); // reputation arrays
putU32(b, 4); // emote count
for (int i = 0; i < 4; ++i) { putU32(b, 0); putU32(b, 0); }
network::Packet pkt(0, b);
QuestDetailsData d;
REQUIRE(QuestDetailsParser::parse(pkt, d));
CHECK(d.questId == 300);
CHECK(d.title == "WotLK Quest");
REQUIRE(d.rewardChoiceItems.size() == 2);
CHECK(d.rewardChoiceItems[0].itemId == 36926);
CHECK(d.rewardChoiceItems[1].itemId == 36927);
REQUIRE(d.rewardItems.size() == 1);
CHECK(d.rewardItems[0].itemId == 35953);
CHECK(d.rewardItems[0].count == 2);
CHECK(d.rewardMoney == 47400);
CHECK(d.rewardXp == 20000);
}
// ============================================================
// SMSG_QUESTGIVER_OFFER_REWARD — completion dialog rewards
// ============================================================
TEST_CASE("Offer reward: Classic layout (vmangos)", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456);
putU32(b, 100);
putStr(b, "Test Quest");
putStr(b, "Well done, $n!");
putU32(b, 1); // autoFinish — uint32, 4-byte prefix
putU32(b, 1); // emote count
putU32(b, 0); putU32(b, 1); // delay, emote
putU32(b, 2); // choice count
putU32(b, 2345); putU32(b, 1); putU32(b, 7001);
putU32(b, 3456); putU32(b, 5); putU32(b, 7002);
putU32(b, 0); // reward item count
putU32(b, 500); // money
putU32(b, 8); // quest flags (trailing)
putU32(b, 0); // rewSpell (trailing)
network::Packet pkt(0, b);
QuestOfferRewardData d;
REQUIRE(QuestOfferRewardParser::parse(pkt, d, QuestPacketEra::CLASSIC));
CHECK(d.questId == 100);
REQUIRE(d.choiceRewards.size() == 2);
CHECK(d.choiceRewards[0].itemId == 2345);
CHECK(d.choiceRewards[1].itemId == 3456);
CHECK(d.choiceRewards[1].count == 5);
CHECK(d.fixedRewards.empty());
CHECK(d.rewardMoney == 500);
CHECK(d.rewardXp == 0); // no XP field pre-WotLK
}
TEST_CASE("Offer reward: TBC layout (cmangos-tbc)", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456);
putU32(b, 200);
putStr(b, "TBC Quest");
putStr(b, "Reward text");
putU32(b, 1); // autoFinish
putU32(b, 0); // suggestedPlayers — 8-byte prefix
putU32(b, 0); // emote count
putU32(b, 1); // choice count
putU32(b, 25407); putU32(b, 1); putU32(b, 8001);
putU32(b, 1); // reward count
putU32(b, 25406); putU32(b, 1); putU32(b, 8002);
putU32(b, 7700); // money
putU32(b, 0); // honor (trailing)
putU32(b, 0x08); // unused (trailing)
putU32(b, 0); // rewSpell (trailing)
putU32(b, 0); // rewSpellCast (trailing)
putU32(b, 0); // charTitle (trailing)
network::Packet pkt(0, b);
QuestOfferRewardData d;
REQUIRE(QuestOfferRewardParser::parse(pkt, d, QuestPacketEra::TBC));
REQUIRE(d.choiceRewards.size() == 1);
CHECK(d.choiceRewards[0].itemId == 25407);
REQUIRE(d.fixedRewards.size() == 1);
CHECK(d.fixedRewards[0].itemId == 25406);
CHECK(d.rewardMoney == 7700);
CHECK(d.rewardXp == 0);
}
TEST_CASE("Offer reward: WotLK layout (AzerothCore)", "[quest_rewards]") {
Bytes b;
putU64(b, 0x123456);
putU32(b, 300);
putStr(b, "WotLK Quest");
putStr(b, "Reward text");
putU8(b, 1); // autoFinish — uint8 in WotLK, no portraits
putU32(b, 8); // flags
putU32(b, 0); // suggestedPlayers
putU32(b, 1); // emote count
putU32(b, 0); putU32(b, 1); // delay, emote
putU32(b, 2); // choice count — VARIABLE entries follow
putU32(b, 36926); putU32(b, 1); putU32(b, 9001);
putU32(b, 36927); putU32(b, 1); putU32(b, 9002);
putU32(b, 1); // reward count
putU32(b, 35953); putU32(b, 2); putU32(b, 9003);
putU32(b, 47400); // money
putU32(b, 20000); // xp
putU32(b, 0); // honor
putF(b, 0.0f); // honor multiplier
putU32(b, 0x08); // unused
putU32(b, 0); // rewSpell
putU32(b, 0); // rewSpellCast
putU32(b, 0); // titleId
putU32(b, 0); // bonusTalents
putU32(b, 0); // arenaPoints
putU32(b, 0); // unk
for (int i = 0; i < 15; ++i) putU32(b, 0); // reputation arrays
network::Packet pkt(0, b);
QuestOfferRewardData d;
REQUIRE(QuestOfferRewardParser::parse(pkt, d, QuestPacketEra::WOTLK));
REQUIRE(d.choiceRewards.size() == 2);
CHECK(d.choiceRewards[0].itemId == 36926);
CHECK(d.choiceRewards[1].itemId == 36927);
REQUIRE(d.fixedRewards.size() == 1);
CHECK(d.fixedRewards[0].itemId == 35953);
CHECK(d.rewardMoney == 47400);
CHECK(d.rewardXp == 20000);
}