Fix trainer system and add critical spell/quest opcodes

Trainer System Fixes:
- Fix CMSG_TRAINER_BUY_SPELL packet: remove incorrect trainerType field (12 bytes not 16)
- Correct spell state interpretation: 0=available, 1=unavailable, 2=known
- Add dynamic prerequisite re-evaluation in real-time as spells are learned
- Immediately update knownSpells on SMSG_TRAINER_BUY_SUCCEEDED
- Add "Show unavailable spells" checkbox filter to trainer window
- Override server state when prerequisites become met client-side

New Spell Opcodes:
- SMSG_SUPERCEDED_SPELL (0x12C): handle spell rank upgrades
- SMSG_SEND_UNLEARN_SPELLS (0x41F): handle bulk unlearning (respec/dual-spec)
- CMSG_TRAINER_LIST (0x1B0): trainer request opcode

Quest System:
- SMSG_QUESTUPDATE_COMPLETE (0x195): mark quests complete when objectives done
- Show "Quest Complete" message and enable turn-in UI

Detailed logging:
- SMSG_INITIAL_SPELLS now logs packet size and first 10 spell IDs
- Money values logged during trainer purchases
- Trainer spell states and prerequisites logged for debugging

This enables proper spell progression chains, spec changes, and quest completion
notifications matching retail WoW 3.3.5a behavior.
This commit is contained in:
Kelsi 2026-02-10 01:24:37 -08:00
parent 8af895c025
commit a764eea2ec
6 changed files with 632 additions and 36 deletions

View file

@ -495,6 +495,8 @@ public:
std::string title;
std::string objectives;
bool complete = false;
// Objective kill counts: objectiveIndex -> (current, required)
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> killCounts;
};
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
void abandonQuest(uint32_t questId);
@ -697,7 +699,9 @@ private:
void handleCooldownEvent(network::Packet& packet);
void handleAuraUpdate(network::Packet& packet, bool isAll);
void handleLearnedSpell(network::Packet& packet);
void handleSupercededSpell(network::Packet& packet);
void handleRemovedSpell(network::Packet& packet);
void handleUnlearnSpells(network::Packet& packet);
// ---- Phase 4 handlers ----
void handleGroupInvite(network::Packet& packet);

View file

@ -159,7 +159,9 @@ enum class Opcode : uint16_t {
SMSG_UPDATE_AURA_DURATION = 0x137,
SMSG_INITIAL_SPELLS = 0x12A,
SMSG_LEARNED_SPELL = 0x12B,
SMSG_SUPERCEDED_SPELL = 0x12C,
SMSG_REMOVED_SPELL = 0x203,
SMSG_SEND_UNLEARN_SPELLS = 0x41F,
SMSG_SPELL_DELAYED = 0x1E2,
SMSG_AURA_UPDATE = 0x3FA,
SMSG_AURA_UPDATE_ALL = 0x495,
@ -223,8 +225,11 @@ enum class Opcode : uint16_t {
SMSG_QUESTGIVER_QUEST_INVALID = 0x18F,
SMSG_QUESTGIVER_QUEST_COMPLETE = 0x191,
CMSG_QUESTLOG_REMOVE_QUEST = 0x194,
SMSG_QUESTUPDATE_ADD_KILL = 0x196, // Quest kill count update
SMSG_QUESTUPDATE_COMPLETE = 0x195, // Quest objectives completed
CMSG_QUEST_QUERY = 0x05C, // Client requests quest data
SMSG_QUEST_QUERY_RESPONSE = 0x05D, // Server sends quest data
SMSG_QUESTLOG_FULL = 0x1A3, // Full quest log on login
// ---- Phase 5: Vendor ----
CMSG_LIST_INVENTORY = 0x19E,
@ -235,8 +240,10 @@ enum class Opcode : uint16_t {
SMSG_BUY_FAILED = 0x1A5,
// ---- Trainer ----
CMSG_TRAINER_LIST = 0x01B0,
SMSG_TRAINER_LIST = 0x01B1,
CMSG_TRAINER_BUY_SPELL = 0x01B2,
SMSG_TRAINER_BUY_FAILED = 0x01B4,
// ---- Phase 5: Item/Equip ----
CMSG_ITEM_QUERY_SINGLE = 0x056,

View file

@ -1750,7 +1750,7 @@ public:
class TrainerBuySpellPacket {
public:
static network::Packet build(uint64_t trainerGuid, uint32_t trainerId, uint32_t spellId);
static network::Packet build(uint64_t trainerGuid, uint32_t spellId);
};
// ============================================================

View file

@ -579,9 +579,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_LEARNED_SPELL:
handleLearnedSpell(packet);
break;
case Opcode::SMSG_SUPERCEDED_SPELL:
handleSupercededSpell(packet);
break;
case Opcode::SMSG_REMOVED_SPELL:
handleRemovedSpell(packet);
break;
case Opcode::SMSG_SEND_UNLEARN_SPELLS:
handleUnlearnSpells(packet);
break;
// ---- Phase 4: Group ----
case Opcode::SMSG_GROUP_INVITE:
@ -682,6 +688,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint64_t guid = packet.readUInt64();
uint32_t spellId = packet.readUInt32();
(void)guid;
// Add to known spells immediately for prerequisite re-evaluation
// (SMSG_LEARNED_SPELL may come separately, but we need immediate update)
bool alreadyKnown = std::find(knownSpells.begin(), knownSpells.end(), spellId) != knownSpells.end();
if (!alreadyKnown) {
knownSpells.push_back(spellId);
LOG_INFO("Added spell ", spellId, " to known spells (trainer purchase)");
}
const std::string& name = getSpellName(spellId);
if (!name.empty())
addSystemChatMessage("You have learned " + name + ".");
@ -689,6 +704,32 @@ void GameHandler::handlePacket(network::Packet& packet) {
addSystemChatMessage("Spell learned.");
break;
}
case Opcode::SMSG_TRAINER_BUY_FAILED: {
// Server rejected the spell purchase
// Packet format: uint64 trainerGuid, uint32 spellId, uint32 errorCode
uint64_t trainerGuid = packet.readUInt64();
uint32_t spellId = packet.readUInt32();
uint32_t errorCode = 0;
if (packet.getSize() - packet.getReadPos() >= 4) {
errorCode = packet.readUInt32();
}
LOG_WARNING("Trainer buy spell failed: guid=", trainerGuid,
" spellId=", spellId, " error=", errorCode);
const std::string& spellName = getSpellName(spellId);
std::string msg = "Cannot learn ";
if (!spellName.empty()) msg += spellName;
else msg += "spell #" + std::to_string(spellId);
// Common error reasons
if (errorCode == 0) msg += " (not enough money)";
else if (errorCode == 1) msg += " (not enough skill)";
else if (errorCode == 2) msg += " (already known)";
else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")";
addSystemChatMessage(msg);
break;
}
// Silently ignore common packets we don't handle yet
case Opcode::SMSG_FEATURE_SYSTEM_STATUS:
@ -851,8 +892,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
case 19: reasonStr = "Can't take any more quests"; break;
}
LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")");
// Show error to user
addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr);
// Only show error to user for real errors (not informational messages)
if (failReason != 13 && failReason != 18) { // Don't spam "already on/completed"
addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr);
}
}
break;
}
@ -883,6 +926,52 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
}
case Opcode::SMSG_QUESTUPDATE_ADD_KILL: {
// Quest kill count update
if (packet.getSize() - packet.getReadPos() >= 16) {
uint32_t questId = packet.readUInt32();
uint32_t entry = packet.readUInt32(); // Creature entry
uint32_t count = packet.readUInt32(); // Current kills
uint32_t reqCount = packet.readUInt32(); // Required kills
LOG_INFO("Quest kill update: questId=", questId, " entry=", entry,
" count=", count, "/", reqCount);
// Update quest log with kill count
for (auto& quest : questLog_) {
if (quest.questId == questId) {
// Store kill progress (using entry as objective index)
quest.killCounts[entry] = {count, reqCount};
// Show progress message
std::string progressMsg = quest.title + ": " +
std::to_string(count) + "/" +
std::to_string(reqCount);
addSystemChatMessage(progressMsg);
LOG_INFO("Updated kill count for quest ", questId, ": ",
count, "/", reqCount);
break;
}
}
}
break;
}
case Opcode::SMSG_QUESTUPDATE_COMPLETE: {
// Quest objectives completed - mark as ready to turn in
uint32_t questId = packet.readUInt32();
LOG_INFO("Quest objectives completed: questId=", questId);
for (auto& quest : questLog_) {
if (quest.questId == questId) {
quest.complete = true;
addSystemChatMessage("Quest Complete: " + quest.title);
LOG_INFO("Marked quest ", questId, " as complete");
break;
}
}
break;
}
case Opcode::SMSG_QUEST_QUERY_RESPONSE: {
// Quest data from server (big packet with title, objectives, rewards, etc.)
LOG_INFO("SMSG_QUEST_QUERY_RESPONSE: packet size=", packet.getSize());
@ -897,11 +986,50 @@ void GameHandler::handlePacket(network::Packet& packet) {
LOG_INFO(" questId=", questId, " questMethod=", questMethod);
// We received quest template data - this means the quest exists
// Check if player has this quest active by checking if it's in gossip
// For now, just log that we received the data
// TODO: Parse full quest template (title, objectives, etc.)
// Parse quest title (after method comes level, flags, type, etc., then title string)
// Skip intermediate fields to get to title
if (packet.getReadPos() + 16 < packet.getSize()) {
packet.readUInt32(); // quest level
packet.readUInt32(); // min level
packet.readUInt32(); // sort ID (zone)
packet.readUInt32(); // quest type/info
packet.readUInt32(); // suggested players
packet.readUInt32(); // reputation objective faction
packet.readUInt32(); // reputation objective value
packet.readUInt32(); // required opposite faction
packet.readUInt32(); // next quest in chain
packet.readUInt32(); // XP ID
packet.readUInt32(); // reward or required money
packet.readUInt32(); // reward money max level
packet.readUInt32(); // reward spell
packet.readUInt32(); // reward spell cast
packet.readUInt32(); // reward honor
packet.readUInt32(); // reward honor multiplier
packet.readUInt32(); // source item ID
packet.readUInt32(); // quest flags
// ... there are many more fields before title, let's try to read title string
if (packet.getReadPos() + 1 < packet.getSize()) {
std::string title = packet.readString();
LOG_INFO(" Quest title: ", title);
// Update quest log entry with title
for (auto& q : questLog_) {
if (q.questId == questId) {
q.title = title;
LOG_INFO("Updated quest log entry ", questId, " with title: ", title);
break;
}
}
}
}
break;
}
case Opcode::SMSG_QUESTLOG_FULL: {
LOG_INFO("***** RECEIVED SMSG_QUESTLOG_FULL *****");
LOG_INFO(" Packet size: ", packet.getSize());
LOG_INFO(" Server uses SMSG_QUESTLOG_FULL for quest log sync!");
// TODO: Parse quest log entries from this packet
break;
}
case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS:
@ -1801,8 +1929,89 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
// Extract XP / inventory slot / skill fields for player entity
if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) {
// Store baseline snapshot on first update
static bool baselineStored = false;
static std::map<uint16_t, uint32_t> baselineFields;
if (!baselineStored) {
baselineFields = block.fields;
baselineStored = true;
LOG_INFO("===== BASELINE PLAYER FIELDS STORED =====");
LOG_INFO(" Total fields: ", block.fields.size());
}
// Diff against baseline to find changes
std::vector<uint16_t> changedIndices;
std::vector<uint16_t> newIndices;
std::vector<uint16_t> removedIndices;
for (const auto& [idx, val] : block.fields) {
auto it = baselineFields.find(idx);
if (it == baselineFields.end()) {
newIndices.push_back(idx);
} else if (it->second != val) {
changedIndices.push_back(idx);
}
}
for (const auto& [idx, val] : baselineFields) {
if (block.fields.find(idx) == block.fields.end()) {
removedIndices.push_back(idx);
}
}
lastPlayerFields_ = block.fields;
detectInventorySlotBases(block.fields);
// Debug: Show field changes
LOG_INFO("Player update with ", block.fields.size(), " fields");
if (!changedIndices.empty() || !newIndices.empty() || !removedIndices.empty()) {
LOG_INFO(" ===== FIELD CHANGES DETECTED =====");
if (!changedIndices.empty()) {
LOG_INFO(" Changed fields (", changedIndices.size(), "):");
std::sort(changedIndices.begin(), changedIndices.end());
for (size_t i = 0; i < std::min(size_t(30), changedIndices.size()); ++i) {
uint16_t idx = changedIndices[i];
uint32_t oldVal = baselineFields[idx];
uint32_t newVal = block.fields.at(idx);
LOG_INFO(" [", idx, "]: ", oldVal, " -> ", newVal,
" (0x", std::hex, oldVal, " -> 0x", newVal, std::dec, ")");
}
if (changedIndices.size() > 30) {
LOG_INFO(" ... (", changedIndices.size() - 30, " more)");
}
}
if (!newIndices.empty()) {
LOG_INFO(" New fields (", newIndices.size(), "):");
std::sort(newIndices.begin(), newIndices.end());
for (size_t i = 0; i < std::min(size_t(20), newIndices.size()); ++i) {
uint16_t idx = newIndices[i];
uint32_t val = block.fields.at(idx);
LOG_INFO(" [", idx, "]: ", val, " (0x", std::hex, val, std::dec, ")");
}
if (newIndices.size() > 20) {
LOG_INFO(" ... (", newIndices.size() - 20, " more)");
}
}
if (!removedIndices.empty()) {
LOG_INFO(" Removed fields (", removedIndices.size(), "):");
std::sort(removedIndices.begin(), removedIndices.end());
for (size_t i = 0; i < std::min(size_t(20), removedIndices.size()); ++i) {
uint16_t idx = removedIndices[i];
uint32_t val = baselineFields.at(idx);
LOG_INFO(" [", idx, "]: was ", val, " (0x", std::hex, val, std::dec, ")");
}
}
}
uint16_t maxField = 0;
for (const auto& [key, val] : block.fields) {
if (key > maxField) maxField = key;
}
LOG_INFO(" Highest field index: ", maxField);
bool slotsChanged = false;
for (const auto& [key, val] : block.fields) {
if (key == 634) { playerXp_ = val; } // PLAYER_XP
@ -1813,7 +2022,41 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (ch.guid == playerGuid) { ch.level = val; break; }
}
}
else if (key == 1170) { playerMoneyCopper_ = val; } // PLAYER_FIELD_COINAGE
else if (key == 1170) {
playerMoneyCopper_ = val;
LOG_INFO("Money set from update fields: ", val, " copper");
} // PLAYER_FIELD_COINAGE
// Parse quest log fields (PLAYER_QUEST_LOG_1_1 = UNIT_END + 10 = 158, stride 5)
// Quest slots: 158, 163, 168, 173, ... (25 slots max = up to index 278)
else if (key >= 158 && key < 283 && (key - 158) % 5 == 0) {
uint32_t questId = val;
if (questId != 0) {
// Check if quest is already in log
bool found = false;
for (auto& q : questLog_) {
if (q.questId == questId) {
found = true;
break;
}
}
if (!found) {
// Add quest to log and request quest details
QuestLogEntry entry;
entry.questId = questId;
entry.complete = false; // Will be updated by gossip or quest status packets
entry.title = "Quest #" + std::to_string(questId);
questLog_.push_back(entry);
LOG_INFO("Found quest in update fields: ", questId);
// Request quest details from server
if (socket) {
network::Packet qPkt(static_cast<uint16_t>(Opcode::CMSG_QUEST_QUERY));
qPkt.writeUInt32(questId);
socket->send(qPkt);
}
}
}
}
}
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
@ -4175,6 +4418,45 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) {
LOG_INFO("Removed spell: ", spellId);
}
void GameHandler::handleSupercededSpell(network::Packet& packet) {
// Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2)
uint32_t oldSpellId = packet.readUInt32();
uint32_t newSpellId = packet.readUInt32();
// Remove old spell
knownSpells.erase(
std::remove(knownSpells.begin(), knownSpells.end(), oldSpellId),
knownSpells.end());
// Add new spell
knownSpells.push_back(newSpellId);
LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId);
const std::string& newName = getSpellName(newSpellId);
if (!newName.empty()) {
addSystemChatMessage("Upgraded to " + newName);
}
}
void GameHandler::handleUnlearnSpells(network::Packet& packet) {
// Sent when unlearning multiple spells (e.g., spec change, respec)
uint32_t spellCount = packet.readUInt32();
LOG_INFO("Unlearning ", spellCount, " spells");
for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) {
uint32_t spellId = packet.readUInt32();
knownSpells.erase(
std::remove(knownSpells.begin(), knownSpells.end(), spellId),
knownSpells.end());
LOG_INFO(" Unlearned spell: ", spellId);
}
if (spellCount > 0) {
addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells");
}
}
// ============================================================
// Phase 4: Group/Party
// ============================================================
@ -4746,7 +5028,7 @@ void GameHandler::handleTrainerList(network::Packet& packet) {
// Debug: log known spells
LOG_INFO("Known spells count: ", knownSpells.size());
if (knownSpells.size() <= 20) {
if (knownSpells.size() <= 50) {
std::string spellList;
for (uint32_t id : knownSpells) {
if (!spellList.empty()) spellList += ", ";
@ -4755,6 +5037,21 @@ void GameHandler::handleTrainerList(network::Packet& packet) {
LOG_INFO("Known spells: ", spellList);
}
// Check if specific prerequisite spells are known
bool has527 = std::find(knownSpells.begin(), knownSpells.end(), 527u) != knownSpells.end();
bool has25312 = std::find(knownSpells.begin(), knownSpells.end(), 25312u) != knownSpells.end();
LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312);
// Debug: log first few trainer spells to see their state
LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells");
for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) {
const auto& s = currentTrainerList_.spells[i];
LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
}
// Ensure caches are populated
loadSpellNameCache();
loadSkillLineDbc();
@ -4768,11 +5065,21 @@ void GameHandler::trainSpell(uint32_t spellId) {
LOG_WARNING("trainSpell: Not in world or no socket connection");
return;
}
// Find spell cost in trainer list
uint32_t spellCost = 0;
for (const auto& spell : currentTrainerList_.spells) {
if (spell.spellId == spellId) {
spellCost = spell.spellCost;
break;
}
}
LOG_INFO("Player money: ", playerMoneyCopper_, " copper, spell cost: ", spellCost, " copper");
LOG_INFO("Sending CMSG_TRAINER_BUY_SPELL: guid=", currentTrainerList_.trainerGuid,
" trainerId=", currentTrainerList_.trainerType, " spellId=", spellId);
" spellId=", spellId);
auto packet = TrainerBuySpellPacket::build(
currentTrainerList_.trainerGuid,
currentTrainerList_.trainerType,
spellId);
socket->send(packet);
LOG_INFO("CMSG_TRAINER_BUY_SPELL sent");
@ -6022,6 +6329,18 @@ void GameHandler::extractSkillFields(const std::map<uint16_t, uint32_t>& fields)
if (skill.value == 0) continue;
auto oldIt = playerSkills_.find(skillId);
if (oldIt != playerSkills_.end() && skill.value > oldIt->second.value) {
// Filter out racial, generic, and hidden skills from announcements
// Category 5 = Attributes (Defense, etc.)
// Category 10 = Languages (Orcish, Common, etc.)
// Category 12 = Not Displayed (generic/hidden)
auto catIt = skillLineCategories_.find(skillId);
if (catIt != skillLineCategories_.end()) {
uint32_t category = catIt->second;
if (category == 5 || category == 10 || category == 12) {
continue; // Skip announcement for racial/generic skills
}
}
const std::string& name = getSkillName(skillId);
std::string skillName = name.empty() ? ("Skill #" + std::to_string(skillId)) : name;
addSystemChatMessage("Your skill in " + skillName + " has increased to " + std::to_string(skill.value) + ".");
@ -6065,6 +6384,16 @@ void GameHandler::saveCharacterConfig() {
out << "action_bar_" << i << "_type=" << static_cast<int>(actionBar[i].type) << "\n";
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
}
// Save quest log
out << "quest_log_count=" << questLog_.size() << "\n";
for (size_t i = 0; i < questLog_.size(); i++) {
const auto& quest = questLog_[i];
out << "quest_" << i << "_id=" << quest.questId << "\n";
out << "quest_" << i << "_title=" << quest.title << "\n";
out << "quest_" << i << "_complete=" << (quest.complete ? 1 : 0) << "\n";
}
LOG_INFO("Character config saved to ", path);
}

View file

@ -829,6 +829,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
}
bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& block) {
size_t startPos = packet.getReadPos();
// Read number of blocks (each block is 32 fields = 32 bits)
uint8_t blockCount = packet.readUInt8();
@ -836,7 +838,10 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
return true; // No fields to update
}
LOG_DEBUG(" Parsing ", (int)blockCount, " field blocks");
uint32_t fieldsCapacity = blockCount * 32;
LOG_INFO(" UPDATE MASK PARSE:");
LOG_INFO(" maskBlockCount = ", (int)blockCount);
LOG_INFO(" fieldsCapacity (blocks * 32) = ", fieldsCapacity);
// Read update mask
std::vector<uint32_t> updateMask(blockCount);
@ -844,6 +849,10 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
updateMask[i] = packet.readUInt32();
}
// Find highest set bit
uint16_t highestSetBit = 0;
uint32_t valuesReadCount = 0;
// Read field values for each bit set in mask
for (int blockIdx = 0; blockIdx < blockCount; ++blockIdx) {
uint32_t mask = updateMask[blockIdx];
@ -851,15 +860,27 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
for (int bit = 0; bit < 32; ++bit) {
if (mask & (1 << bit)) {
uint16_t fieldIndex = blockIdx * 32 + bit;
if (fieldIndex > highestSetBit) {
highestSetBit = fieldIndex;
}
uint32_t value = packet.readUInt32();
block.fields[fieldIndex] = value;
valuesReadCount++;
LOG_DEBUG(" Field[", fieldIndex, "] = 0x", std::hex, value, std::dec);
}
}
}
LOG_DEBUG(" Parsed ", block.fields.size(), " fields");
size_t endPos = packet.getReadPos();
size_t bytesUsed = endPos - startPos;
size_t bytesRemaining = packet.getSize() - endPos;
LOG_INFO(" highestSetBitIndex = ", highestSetBit);
LOG_INFO(" valuesReadCount = ", valuesReadCount);
LOG_INFO(" bytesUsedForFields = ", bytesUsed);
LOG_INFO(" bytesRemainingInPacket = ", bytesRemaining);
LOG_INFO(" Parsed ", block.fields.size(), " fields");
return true;
}
@ -932,6 +953,10 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data)
// Read block count
data.blockCount = packet.readUInt32();
LOG_INFO("SMSG_UPDATE_OBJECT:");
LOG_INFO(" objectCount = ", data.blockCount);
LOG_INFO(" packetSize = ", packet.getSize());
// Check for out-of-range objects first
if (packet.getReadPos() + 1 <= packet.getSize()) {
uint8_t firstByte = packet.readUInt8();
@ -1973,9 +1998,12 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) {
// ============================================================
bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data) {
size_t packetSize = packet.getSize();
data.talentSpec = packet.readUInt8();
uint16_t spellCount = packet.readUInt16();
LOG_INFO("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount);
data.spellIds.reserve(spellCount);
for (uint16_t i = 0; i < spellCount; ++i) {
uint32_t spellId = packet.readUInt32();
@ -1997,8 +2025,19 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data
data.cooldowns.push_back(entry);
}
LOG_INFO("Initial spells: ", data.spellIds.size(), " spells, ",
LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ",
data.cooldowns.size(), " cooldowns");
// Log first 10 spell IDs for debugging
if (!data.spellIds.empty()) {
std::string first10;
for (size_t i = 0; i < std::min(size_t(10), data.spellIds.size()); ++i) {
if (!first10.empty()) first10 += ", ";
first10 += std::to_string(data.spellIds[i]);
}
LOG_INFO("First spells: ", first10);
}
return true;
}
@ -2682,10 +2721,9 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) {
return true;
}
network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t trainerId, uint32_t spellId) {
network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spellId) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_TRAINER_BUY_SPELL));
packet.writeUInt64(trainerGuid);
packet.writeUInt32(trainerId);
packet.writeUInt32(spellId);
return packet;
}

View file

@ -110,6 +110,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
// ---- New UI elements ----
renderActionBar(gameHandler);
renderBagBar(gameHandler);
renderXpBar(gameHandler);
renderCastBar(gameHandler);
renderCombatText(gameHandler);
@ -480,7 +481,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
}
if (ImGui::IsItemClicked()) {
std::string cmd = "xdg-open '" + url + "' &";
system(cmd.c_str());
[[maybe_unused]] int result = system(cmd.c_str());
}
ImGui::PopStyleColor();
@ -2580,6 +2581,139 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) {
}
}
// ============================================================
// Bag Bar
// ============================================================
void GameScreen::renderBagBar(game::GameHandler& gameHandler) {
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
auto* assetMgr = core::Application::getInstance().getAssetManager();
float slotSize = 42.0f;
float spacing = 4.0f;
float padding = 6.0f;
// 5 slots: backpack + 4 bags
float barW = 5 * slotSize + 4 * spacing + padding * 2;
float barH = slotSize + padding * 2;
// Position in bottom right corner
float barX = screenW - barW - 10.0f;
float barY = screenH - barH - 10.0f;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f));
if (ImGui::Begin("##BagBar", nullptr, flags)) {
auto& inv = gameHandler.getInventory();
// Load backpack icon if needed
if (!backpackIconTexture_ && assetMgr && assetMgr->isInitialized()) {
auto blpData = assetMgr->readFile("Interface\\Buttons\\Button-Backpack-Up.blp");
if (!blpData.empty()) {
auto image = pipeline::BLPLoader::load(blpData);
if (image.isValid()) {
glGenTextures(1, &backpackIconTexture_);
glBindTexture(GL_TEXTURE_2D, backpackIconTexture_);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, image.data.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
}
}
}
// Slots 1-4: Bag slots (leftmost)
for (int i = 0; i < 4; ++i) {
if (i > 0) ImGui::SameLine(0, spacing);
ImGui::PushID(i + 1);
game::EquipSlot bagSlot = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + i);
const auto& bagItem = inv.getEquipSlot(bagSlot);
GLuint bagIcon = 0;
if (!bagItem.empty() && bagItem.item.displayInfoId != 0) {
bagIcon = inventoryScreen.getItemIcon(bagItem.item.displayInfoId);
}
if (bagIcon) {
if (ImGui::ImageButton("##bag", (ImTextureID)(uintptr_t)bagIcon,
ImVec2(slotSize - 4, slotSize - 4),
ImVec2(0, 0), ImVec2(1, 1),
ImVec4(0.1f, 0.1f, 0.1f, 0.9f),
ImVec4(1, 1, 1, 1))) {
// TODO: Open specific bag
inventoryScreen.toggle();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", bagItem.item.name.c_str());
}
} else {
// Empty bag slot
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
if (ImGui::Button("##empty", ImVec2(slotSize, slotSize))) {
// Empty slot - maybe show equipment to find a bag?
}
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Empty Bag Slot");
}
}
// Accept dragged item from inventory
if (ImGui::IsItemHovered() && inventoryScreen.isHoldingItem()) {
const auto& heldItem = inventoryScreen.getHeldItem();
// Check if held item is a bag (bagSlots > 0)
if (heldItem.bagSlots > 0 && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
// Equip the bag to inventory
auto& inventory = gameHandler.getInventory();
inventory.setEquipSlot(bagSlot, heldItem);
inventoryScreen.returnHeldItem(inventory);
}
}
ImGui::PopID();
}
// Backpack (rightmost slot)
ImGui::SameLine(0, spacing);
ImGui::PushID(0);
if (backpackIconTexture_) {
if (ImGui::ImageButton("##backpack", (ImTextureID)(uintptr_t)backpackIconTexture_,
ImVec2(slotSize - 4, slotSize - 4),
ImVec2(0, 0), ImVec2(1, 1),
ImVec4(0.1f, 0.1f, 0.1f, 0.9f),
ImVec4(1, 1, 1, 1))) {
inventoryScreen.toggle();
}
} else {
if (ImGui::Button("B", ImVec2(slotSize, slotSize))) {
inventoryScreen.toggle();
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Backpack");
}
ImGui::PopID();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// XP Bar
// ============================================================
@ -3353,6 +3487,11 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) {
const auto& quest = gameHandler.getQuestOfferReward();
static int selectedChoice = -1;
// Auto-select if only one choice reward
if (quest.choiceRewards.size() == 1 && selectedChoice == -1) {
selectedChoice = 0;
}
std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler);
if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) {
if (!quest.rewardText.empty()) {
@ -3365,19 +3504,74 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose a reward:");
for (size_t i = 0; i < quest.choiceRewards.size(); ++i) {
const auto& item = quest.choiceRewards[i];
auto* info = gameHandler.getItemInfo(item.itemId);
char label[256];
if (info && info->valid)
snprintf(label, sizeof(label), "%s x%u", info->name.c_str(), item.count);
else
snprintf(label, sizeof(label), "Item %u x%u", item.itemId, item.count);
bool selected = (selectedChoice == static_cast<int>(i));
if (ImGui::Selectable(label, selected)) {
// Get item icon if we have displayInfoId
uint32_t iconTex = 0;
if (info && info->valid && info->displayInfoId != 0) {
iconTex = inventoryScreen.getItemIcon(info->displayInfoId);
}
// Quality color
ImVec4 qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White (poor)
if (info && info->valid) {
switch (info->quality) {
case 1: qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // Common (white)
case 2: qualityColor = ImVec4(0.0f, 1.0f, 0.0f, 1.0f); break; // Uncommon (green)
case 3: qualityColor = ImVec4(0.0f, 0.5f, 1.0f, 1.0f); break; // Rare (blue)
case 4: qualityColor = ImVec4(0.64f, 0.21f, 0.93f, 1.0f); break; // Epic (purple)
case 5: qualityColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // Legendary (orange)
}
}
// Render item with icon
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable("##reward", selected, 0, ImVec2(0, 40))) {
selectedChoice = static_cast<int>(i);
}
// Draw icon and text over the selectable
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() - ImGui::GetItemRectSize().x + 4);
if (iconTex) {
ImGui::Image((void*)(intptr_t)iconTex, ImVec2(36, 36));
ImGui::SameLine();
}
ImGui::BeginGroup();
if (info && info->valid) {
ImGui::TextColored(qualityColor, "%s", info->name.c_str());
if (item.count > 1) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "x%u", item.count);
}
// Show stats
if (info->armor > 0 || info->stamina > 0 || info->strength > 0 ||
info->agility > 0 || info->intellect > 0 || info->spirit > 0) {
std::string stats;
if (info->armor > 0) stats += std::to_string(info->armor) + " Armor ";
if (info->stamina > 0) stats += "+" + std::to_string(info->stamina) + " Sta ";
if (info->strength > 0) stats += "+" + std::to_string(info->strength) + " Str ";
if (info->agility > 0) stats += "+" + std::to_string(info->agility) + " Agi ";
if (info->intellect > 0) stats += "+" + std::to_string(info->intellect) + " Int ";
if (info->spirit > 0) stats += "+" + std::to_string(info->spirit) + " Spi ";
ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "%s", stats.c_str());
}
} else {
ImGui::TextColored(qualityColor, "Item %u", item.itemId);
if (item.count > 0) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "x%u", item.count);
}
}
ImGui::EndGroup();
ImGui::PopID();
}
}
@ -3587,6 +3781,10 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
uint32_t ms = static_cast<uint32_t>((money / 100) % 100);
uint32_t mc = static_cast<uint32_t>(money % 100);
ImGui::Text("Your money: %ug %us %uc", mg, ms, mc);
// Filter checkbox
static bool showUnavailable = false;
ImGui::Checkbox("Show unavailable spells", &showUnavailable);
ImGui::Separator();
if (trainer.spells.empty()) {
@ -3596,23 +3794,25 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
const auto& knownSpells = gameHandler.getKnownSpells();
auto isKnown = [&](uint32_t id) {
if (id == 0) return true;
// Check if spell is in knownSpells list
bool found = std::find(knownSpells.begin(), knownSpells.end(), id) != knownSpells.end();
static int debugCount = 0;
if (debugCount < 5 && !found && id != 0) {
LOG_INFO("isKnown(", id, ") = false, knownSpells.size()=", knownSpells.size());
debugCount++;
if (found) return true;
// Also check if spell is in trainer list with state=2 (explicitly known)
// state=0 means unavailable (could be no prereqs, wrong level, etc.) - don't count as known
for (const auto& ts : trainer.spells) {
if (ts.spellId == id && ts.state == 2) {
return true;
}
}
return found;
return false;
};
uint32_t playerLevel = gameHandler.getPlayerLevel();
// Renders spell rows into the current table
auto renderSpellRows = [&](const std::vector<const game::TrainerSpell*>& spells) {
for (const auto* spell : spells) {
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(spell->spellId));
// Check prerequisites client-side
// Check prerequisites client-side first
bool prereq1Met = isKnown(spell->chainNode1);
bool prereq2Met = isKnown(spell->chainNode2);
bool prereq3Met = isKnown(spell->chainNode3);
@ -3620,12 +3820,30 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
bool levelMet = (spell->reqLevel == 0 || playerLevel >= spell->reqLevel);
bool alreadyKnown = isKnown(spell->spellId);
// Dynamically determine effective state based on current prerequisites
// Server sends state, but we override if prerequisites are now met
uint8_t effectiveState = spell->state;
if (spell->state == 1 && prereqsMet && levelMet) {
// Server said unavailable, but we now meet all requirements
effectiveState = 0; // Treat as available
}
// Filter: skip unavailable spells if checkbox is unchecked
// Use effectiveState so spells with newly met prereqs aren't filtered
if (!showUnavailable && effectiveState == 1) {
continue;
}
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(spell->spellId));
ImVec4 color;
const char* statusLabel;
if (alreadyKnown) {
// WotLK trainer states: 0=available, 1=unavailable, 2=known
if (effectiveState == 2 || alreadyKnown) {
color = ImVec4(0.3f, 0.9f, 0.3f, 1.0f);
statusLabel = "Known";
} else if (spell->state == 1 && prereqsMet && levelMet) {
} else if (effectiveState == 0) {
color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
statusLabel = "Available";
} else {
@ -3693,7 +3911,8 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
// Train button - only enabled if available, affordable, prereqs met
ImGui::TableSetColumnIndex(3);
bool canTrain = !alreadyKnown && spell->state == 1
// Use effectiveState so newly available spells (after learning prereqs) can be trained
bool canTrain = !alreadyKnown && effectiveState == 0
&& prereqsMet && levelMet
&& (money >= spell->spellCost);
@ -4079,7 +4298,6 @@ void GameScreen::renderSettingsWindow() {
constexpr bool kDefaultVsync = true;
constexpr bool kDefaultShadows = false;
constexpr int kDefaultMusicVolume = 30;
constexpr int kDefaultSfxVolume = 100;
constexpr float kDefaultMouseSensitivity = 0.2f;
constexpr bool kDefaultInvertMouse = false;