From ee155c3367931781db62238c02c51b008979f81c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 8 Feb 2026 14:46:01 -0800 Subject: [PATCH] Fix trainer buy spell and add specialization tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix SMSG_BINDPOINTUPDATE opcode from 0x1B3 to 0x155 — the old value collided with SMSG_TRAINER_BUY_SUCCEEDED, causing buy responses to be misinterpreted as bindpoint updates. Add specialization tabs using SkillLineAbility.dbc to group spells by class spec (category 7). --- include/game/game_handler.hpp | 12 ++++ include/game/opcodes.hpp | 3 +- src/game/game_handler.cpp | 96 ++++++++++++++++++++++++++++++- src/ui/game_screen.cpp | 105 +++++++++++++++++++++------------- 4 files changed, 174 insertions(+), 42 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2c84e9a0..ab586d17 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -544,6 +544,13 @@ public: void closeTrainer(); const std::string& getSpellName(uint32_t spellId) const; const std::string& getSpellRank(uint32_t spellId) const; + const std::string& getSkillLineName(uint32_t spellId) const; + + struct TrainerTab { + std::string name; + std::vector spells; + }; + const std::vector& getTrainerTabs() const { return trainerTabs_; } const ItemQueryResponseData* getItemInfo(uint32_t itemId) const { auto it = itemInfoCache_.find(itemId); return (it != itemInfoCache_.end()) ? &it->second : nullptr; @@ -962,8 +969,10 @@ private: struct SpellNameEntry { std::string name; std::string rank; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; + std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); + void categorizeTrainerSpells(); // Callbacks WorldConnectSuccessCallback onSuccess; @@ -985,8 +994,11 @@ private: std::map playerSkills_; std::unordered_map skillLineNames_; std::unordered_map skillLineCategories_; + std::unordered_map spellToSkillLine_; // spellID -> skillLineID bool skillLineDbcLoaded_ = false; + bool skillLineAbilityLoaded_ = false; void loadSkillLineDbc(); + void loadSkillLineAbilityDbc(); void extractSkillFields(const std::map& fields); NpcDeathCallback npcDeathCallback_; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 19cd0ed0..0e5f0f0b 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -277,7 +277,8 @@ enum class Opcode : uint16_t { // ---- Battleground ---- SMSG_BATTLEFIELD_PORT_DENIED = 0x014B, SMSG_REMOVED_FROM_PVP_QUEUE = 0x0170, - SMSG_BINDPOINTUPDATE = 0x01B3, + SMSG_TRAINER_BUY_SUCCEEDED = 0x01B3, + SMSG_BINDPOINTUPDATE = 0x0155, CMSG_BATTLEFIELD_LIST = 0x023C, SMSG_BATTLEFIELD_LIST = 0x023D, CMSG_BATTLEFIELD_JOIN = 0x023E, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index dde6b795..d1ad873c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -654,6 +654,17 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_TRAINER_LIST: handleTrainerList(packet); break; + case Opcode::SMSG_TRAINER_BUY_SUCCEEDED: { + uint64_t guid = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + (void)guid; + const std::string& name = getSpellName(spellId); + if (!name.empty()) + addSystemChatMessage("You have learned " + name + "."); + else + addSystemChatMessage("Spell learned."); + break; + } // Silently ignore common packets we don't handle yet case Opcode::SMSG_FEATURE_SYSTEM_STATUS: @@ -4562,8 +4573,11 @@ void GameHandler::handleTrainerList(network::Packet& packet) { trainerWindowOpen_ = true; gossipWindowOpen = false; - // Ensure spell name cache is populated + // Ensure caches are populated loadSpellNameCache(); + loadSkillLineDbc(); + loadSkillLineAbilityDbc(); + categorizeTrainerSpells(); } void GameHandler::trainSpell(uint32_t spellId) { @@ -4575,6 +4589,7 @@ void GameHandler::trainSpell(uint32_t spellId) { void GameHandler::closeTrainer() { trainerWindowOpen_ = false; currentTrainerList_ = TrainerListData{}; + trainerTabs_.clear(); } void GameHandler::loadSpellNameCache() { @@ -4609,6 +4624,78 @@ void GameHandler::loadSpellNameCache() { LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc"); } +void GameHandler::loadSkillLineAbilityDbc() { + if (skillLineAbilityLoaded_) return; + skillLineAbilityLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + // SkillLineAbility.dbc: field 1=skillLineID, field 2=spellID + auto slaDbc = am->loadDBC("SkillLineAbility.dbc"); + if (slaDbc && slaDbc->isLoaded()) { + for (uint32_t i = 0; i < slaDbc->getRecordCount(); i++) { + uint32_t skillLineId = slaDbc->getUInt32(i, 1); + uint32_t spellId = slaDbc->getUInt32(i, 2); + if (spellId > 0 && skillLineId > 0) { + spellToSkillLine_[spellId] = skillLineId; + } + } + LOG_INFO("Trainer: Loaded ", spellToSkillLine_.size(), " skill line abilities"); + } +} + +void GameHandler::categorizeTrainerSpells() { + trainerTabs_.clear(); + + static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7; + + // Group spells by skill line (category 7 = class spec tabs) + std::map> specialtySpells; + std::vector generalSpells; + + for (const auto& spell : currentTrainerList_.spells) { + auto slIt = spellToSkillLine_.find(spell.spellId); + if (slIt != spellToSkillLine_.end()) { + uint32_t skillLineId = slIt->second; + auto catIt = skillLineCategories_.find(skillLineId); + if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { + specialtySpells[skillLineId].push_back(&spell); + continue; + } + } + generalSpells.push_back(&spell); + } + + // Sort by spell name within each group + auto byName = [this](const TrainerSpell* a, const TrainerSpell* b) { + return getSpellName(a->spellId) < getSpellName(b->spellId); + }; + + // Build named tabs sorted alphabetically + std::vector>> named; + for (auto& [skillLineId, spells] : specialtySpells) { + auto nameIt = skillLineNames_.find(skillLineId); + std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Specialty"; + std::sort(spells.begin(), spells.end(), byName); + named.push_back({std::move(tabName), std::move(spells)}); + } + std::sort(named.begin(), named.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + + for (auto& [name, spells] : named) { + trainerTabs_.push_back({std::move(name), std::move(spells)}); + } + + // General tab last + if (!generalSpells.empty()) { + std::sort(generalSpells.begin(), generalSpells.end(), byName); + trainerTabs_.push_back({"General", std::move(generalSpells)}); + } + + LOG_INFO("Trainer: Categorized into ", trainerTabs_.size(), " tabs"); +} + static const std::string EMPTY_STRING; const std::string& GameHandler::getSpellName(uint32_t spellId) const { @@ -4621,6 +4708,13 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; } +const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { + auto slIt = spellToSkillLine_.find(spellId); + if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; + auto nameIt = skillLineNames_.find(slIt->second); + return (nameIt != skillLineNames_.end()) ? nameIt->second : EMPTY_STRING; +} + // ============================================================ // Single-player local combat // ============================================================ diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c82d454b..72af7f84 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3569,25 +3569,18 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { return std::find(knownSpells.begin(), knownSpells.end(), id) != knownSpells.end(); }; - if (ImGui::BeginTable("TrainerTable", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { - ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); - ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 55.0f); - ImGui::TableHeadersRow(); - - for (const auto& spell : trainer.spells) { + // Renders spell rows into the current table + auto renderSpellRows = [&](const std::vector& spells) { + for (const auto* spell : spells) { ImGui::TableNextRow(); - ImGui::PushID(static_cast(spell.spellId)); + ImGui::PushID(static_cast(spell->spellId)); - // State color: 0=known(green), 1=available(white), 2=unavailable(gray) ImVec4 color; const char* statusLabel; - if (spell.state == 0 || isKnown(spell.spellId)) { + if (spell->state == 0 || isKnown(spell->spellId)) { color = ImVec4(0.3f, 0.9f, 0.3f, 1.0f); statusLabel = "Known"; - } else if (spell.state == 1) { + } else if (spell->state == 1) { color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); statusLabel = "Available"; } else { @@ -3597,19 +3590,17 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { // Spell name ImGui::TableSetColumnIndex(0); - const std::string& name = gameHandler.getSpellName(spell.spellId); - const std::string& rank = gameHandler.getSpellRank(spell.spellId); + const std::string& name = gameHandler.getSpellName(spell->spellId); + const std::string& rank = gameHandler.getSpellRank(spell->spellId); if (!name.empty()) { - if (!rank.empty()) { + if (!rank.empty()) ImGui::TextColored(color, "%s (%s)", name.c_str(), rank.c_str()); - } else { + else ImGui::TextColored(color, "%s", name.c_str()); - } } else { - ImGui::TextColored(color, "Spell #%u", spell.spellId); + ImGui::TextColored(color, "Spell #%u", spell->spellId); } - // Tooltip if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); if (!name.empty()) { @@ -3617,27 +3608,27 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { if (!rank.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", rank.c_str()); } ImGui::Text("Status: %s", statusLabel); - if (spell.reqLevel > 0) ImGui::Text("Required Level: %u", spell.reqLevel); - if (spell.reqSkill > 0) ImGui::Text("Required Skill: %u (value %u)", spell.reqSkill, spell.reqSkillValue); - if (spell.chainNode1 > 0) { - const std::string& prereq = gameHandler.getSpellName(spell.chainNode1); + if (spell->reqLevel > 0) ImGui::Text("Required Level: %u", spell->reqLevel); + if (spell->reqSkill > 0) ImGui::Text("Required Skill: %u (value %u)", spell->reqSkill, spell->reqSkillValue); + if (spell->chainNode1 > 0) { + const std::string& prereq = gameHandler.getSpellName(spell->chainNode1); if (!prereq.empty()) ImGui::Text("Requires: %s", prereq.c_str()); - else ImGui::Text("Requires: Spell #%u", spell.chainNode1); + else ImGui::Text("Requires: Spell #%u", spell->chainNode1); } ImGui::EndTooltip(); } // Level ImGui::TableSetColumnIndex(1); - ImGui::TextColored(color, "%u", spell.reqLevel); + ImGui::TextColored(color, "%u", spell->reqLevel); // Cost ImGui::TableSetColumnIndex(2); - if (spell.spellCost > 0) { - uint32_t g = spell.spellCost / 10000; - uint32_t s = (spell.spellCost / 100) % 100; - uint32_t c = spell.spellCost % 100; - bool canAfford = money >= spell.spellCost; + if (spell->spellCost > 0) { + uint32_t g = spell->spellCost / 10000; + uint32_t s = (spell->spellCost / 100) % 100; + uint32_t c = spell->spellCost % 100; + bool canAfford = money >= spell->spellCost; ImVec4 costColor = canAfford ? color : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); ImGui::TextColored(costColor, "%ug %us %uc", g, s, c); } else { @@ -3646,21 +3637,55 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { // Train button ImGui::TableSetColumnIndex(3); - bool canTrain = (spell.state == 1) && (money >= spell.spellCost); - if (!canTrain) { - ImGui::BeginDisabled(); - } + bool canTrain = (spell->state == 1) && (money >= spell->spellCost); + if (!canTrain) ImGui::BeginDisabled(); if (ImGui::SmallButton("Train")) { - gameHandler.trainSpell(spell.spellId); - } - if (!canTrain) { - ImGui::EndDisabled(); + gameHandler.trainSpell(spell->spellId); } + if (!canTrain) ImGui::EndDisabled(); ImGui::PopID(); } + }; - ImGui::EndTable(); + auto renderSpellTable = [&](const char* tableId, const std::vector& spells) { + if (ImGui::BeginTable(tableId, 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableHeadersRow(); + renderSpellRows(spells); + ImGui::EndTable(); + } + }; + + const auto& tabs = gameHandler.getTrainerTabs(); + if (tabs.size() > 1) { + // Multiple tabs - show tab bar + if (ImGui::BeginTabBar("TrainerTabs")) { + for (size_t i = 0; i < tabs.size(); i++) { + char tabLabel[64]; + snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)", + tabs[i].name.c_str(), tabs[i].spells.size()); + + if (ImGui::BeginTabItem(tabLabel)) { + char tableId[32]; + snprintf(tableId, sizeof(tableId), "TT%zu", i); + renderSpellTable(tableId, tabs[i].spells); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + } else { + // Single tab or no categorization - flat list + std::vector allSpells; + for (const auto& spell : trainer.spells) { + allSpells.push_back(&spell); + } + renderSpellTable("TrainerTable", allSpells); } } }