Fix item use (CMSG_USE_ITEM), mount tab, and inventory right-click

- Fix SMSG_ITEM_QUERY_SINGLE_RESPONSE parsing: read statsCount stat
  pairs instead of always 10, use 2 damage entries (MAX_ITEM_PROTO_DAMAGES),
  and parse item spell data (spellId + spellTrigger per slot)
- Pass item spell ID in CMSG_USE_ITEM packet so server processes item
  use requests (spellId=0 caused silent server rejection)
- Add spellId parameter to buildUseItem interface across all expansions
- Fix spellbook mount tab to use SkillLine 777 (Mounts) instead of 762
  (Riding), so known mount summon spells appear correctly
- Fix inventory right-click: use IsItemHovered+IsMouseClicked instead
  of IsItemClicked for InvisibleButton (which only tracks left-clicks)
- Fix SlotKind enum declaration order in inventory_screen.hpp
This commit is contained in:
Kelsi 2026-02-26 00:59:07 -08:00
parent 7982815a67
commit c919477e74
9 changed files with 233 additions and 152 deletions

View file

@ -51,8 +51,8 @@ public:
}
/** Build CMSG_USE_ITEM (WotLK default: bag + slot + castCount + spellId + itemGuid + glyphIndex + castFlags + targets) */
virtual network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid) {
return UseItemPacket::build(bagIndex, slotIndex, itemGuid);
virtual network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) {
return UseItemPacket::build(bagIndex, slotIndex, itemGuid, spellId);
}
// --- Character Enumeration ---
@ -313,7 +313,7 @@ public:
const MovementInfo& info,
uint64_t playerGuid = 0) override;
network::Packet buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) override;
network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid) override;
network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) override;
bool parseCastFailed(network::Packet& packet, CastFailedData& data) override;
bool parseMessageChat(network::Packet& packet, MessageChatData& data) override;
bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override;

View file

@ -1512,6 +1512,12 @@ struct ItemQueryResponseData {
int32_t spirit = 0;
uint32_t sellPrice = 0;
std::string subclassName;
// Item spells (up to 5)
struct ItemSpell {
uint32_t spellId = 0;
uint32_t spellTrigger = 0; // 0=Use, 1=Equip, 2=ChanceOnHit, 5=Learn
};
std::array<ItemSpell, 5> spells{};
bool valid = false;
};
@ -1879,7 +1885,7 @@ public:
/** CMSG_USE_ITEM packet builder */
class UseItemPacket {
public:
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid);
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0);
};
/** CMSG_AUTOEQUIP_ITEM packet builder */

View file

@ -125,6 +125,19 @@ private:
int heldBagSlotIndex = -1;
game::EquipSlot heldEquipSlot = game::EquipSlot::NUM_SLOTS;
// Slot rendering with interaction support
enum class SlotKind { BACKPACK, EQUIPMENT };
// Click-and-hold pickup tracking
bool pickupPending_ = false;
float pickupPressTime_ = 0.0f;
SlotKind pickupSlotKind_ = SlotKind::BACKPACK;
int pickupBackpackIndex_ = -1;
int pickupBagIndex_ = -1;
int pickupBagSlotIndex_ = -1;
game::EquipSlot pickupEquipSlot_ = game::EquipSlot::NUM_SLOTS;
static constexpr float kPickupHoldThreshold = 0.12f; // seconds
void renderSeparateBags(game::Inventory& inventory, uint64_t moneyCopper);
void renderAggregateBags(game::Inventory& inventory, uint64_t moneyCopper);
void renderBagWindow(const char* title, bool& isOpen, game::Inventory& inventory,
@ -133,8 +146,6 @@ private:
void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false);
void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0);
// Slot rendering with interaction support
enum class SlotKind { BACKPACK, EQUIPMENT };
void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot,
float size, const char* label,
SlotKind kind, int backpackIndex,

View file

@ -66,7 +66,7 @@ private:
bool skillLineDbLoaded = false;
std::unordered_map<uint32_t, std::string> skillLineNames;
std::unordered_map<uint32_t, uint32_t> skillLineCategories;
std::unordered_map<uint32_t, uint32_t> spellToSkillLine;
std::unordered_multimap<uint32_t, uint32_t> spellToSkillLine;
// Categorized spell tabs
std::vector<SpellTabInfo> spellTabs;

View file

@ -7436,15 +7436,10 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const {
if (itemId == 0) return 0;
uint64_t found = 0;
for (const auto& [guid, info] : onlineItems_) {
if (info.entry != itemId) continue;
if (found != 0) {
return 0; // Ambiguous
}
found = guid;
if (info.entry == itemId) return guid;
}
return found;
return 0;
}
void GameHandler::detectInventorySlotBases(const std::map<uint16_t, uint32_t>& fields) {
@ -9309,6 +9304,14 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) {
}
}
}
// Show chat message for non-talent spells
const std::string& name = getSpellName(spellId);
if (!name.empty()) {
addSystemChatMessage("You have learned a new spell: " + name + ".");
} else {
addSystemChatMessage("You have learned a new spell.");
}
}
void GameHandler::handleRemovedSpell(network::Packet& packet) {
@ -10673,29 +10676,33 @@ void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) {
LOG_WARNING("useItemBySlot: slot ", backpackIndex, " is empty");
return;
}
LOG_INFO("useItemBySlot: backpackIndex=", backpackIndex, " itemId=", slot.item.itemId,
" wowSlot=", 23 + backpackIndex);
if (slot.empty()) return;
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
LOG_INFO("useItemBySlot: itemGuid=0x", std::hex, itemGuid, std::dec);
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
// Find the item's on-use spell ID from cached item info
uint32_t useSpellId = 0;
if (auto* info = getItemInfo(slot.item.itemId)) {
for (const auto& sp : info->spells) {
// SpellTrigger: 0=Use, 5=Learn
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
useSpellId = sp.spellId;
break;
}
}
}
// WoW inventory: equipment 0-18, bags 19-22, backpack 23-38
auto packet = packetParsers_
? packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid);
LOG_INFO("useItemBySlot: sending CMSG_USE_ITEM, packetSize=", packet.getSize());
? packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId);
socket->send(packet);
} else if (itemGuid == 0) {
LOG_WARNING("Use item failed: missing item GUID for slot ", backpackIndex);
addSystemChatMessage("Cannot use that item right now.");
}
}
@ -10722,17 +10729,29 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
" itemGuid=0x", std::hex, itemGuid, std::dec);
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
// Find the item's on-use spell ID
uint32_t useSpellId = 0;
if (auto* info = getItemInfo(slot.item.itemId)) {
for (const auto& sp : info->spells) {
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
useSpellId = sp.spellId;
break;
}
}
}
// WoW bag addressing: bagIndex = equip slot of bag container (19-22)
// For CMSG_USE_ITEM: bag = 19+bagIndex, slot = slot within bag
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
auto packet = packetParsers_
? packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid)
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid);
? packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex,
" packetSize=", packet.getSize());
socket->send(packet);
} else if (itemGuid == 0) {
LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex);
addSystemChatMessage("Cannot use that item right now.");
}
}

View file

@ -305,7 +305,7 @@ network::Packet ClassicPacketParsers::buildCastSpell(uint32_t spellId, uint64_t
// Vanilla 1.12.x: bag(u8) + slot(u8) + spellIndex(u8) + SpellCastTargets(u16)
// NO spellId, itemGuid, glyphIndex, or castFlags fields (those are WotLK)
// ============================================================================
network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t /*itemGuid*/) {
network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t /*itemGuid*/, uint32_t /*spellId*/) {
network::Packet packet(wireOpcode(LogicalOpcode::CMSG_USE_ITEM));
packet.writeUInt8(bagIndex);
packet.writeUInt8(slotIndex);

View file

@ -2398,94 +2398,62 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
data.containerSlots = packet.readUInt32();
uint32_t statsCount = packet.readUInt32();
// Server always sends 10 stat pairs; statsCount tells how many are meaningful
for (uint32_t i = 0; i < 10; i++) {
// Server sends exactly statsCount stat pairs (not always 10).
uint32_t statsToRead = std::min(statsCount, 10u);
for (uint32_t i = 0; i < statsToRead; i++) {
uint32_t statType = packet.readUInt32();
int32_t statValue = static_cast<int32_t>(packet.readUInt32());
if (i < statsCount) {
switch (statType) {
case 3: data.agility = statValue; break;
case 4: data.strength = statValue; break;
case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break;
default: break;
}
switch (statType) {
case 3: data.agility = statValue; break;
case 4: data.strength = statValue; break;
case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break;
default: break;
}
}
packet.readUInt32(); // ScalingStatDistribution
packet.readUInt32(); // ScalingStatValue
const size_t preDamagePos = packet.getReadPos();
struct DamageParseResult {
float damageMin = 0.0f;
float damageMax = 0.0f;
int32_t armor = 0;
uint32_t delayMs = 0;
bool ok = false;
};
auto parseDamageBlock = [&](int damageEntries) -> DamageParseResult {
DamageParseResult r;
packet.setReadPos(preDamagePos);
bool haveWeaponDamage = false;
for (int i = 0; i < damageEntries; i++) {
float dmgMin = packet.readFloat();
float dmgMax = packet.readFloat();
uint32_t damageType = packet.readUInt32();
if (!haveWeaponDamage && dmgMax > 0.0f) {
if (damageType == 0 || r.damageMax <= 0.0f) {
r.damageMin = dmgMin;
r.damageMax = dmgMax;
haveWeaponDamage = (damageType == 0);
}
// WotLK 3.3.5a: MAX_ITEM_PROTO_DAMAGES = 2
bool haveWeaponDamage = false;
for (int i = 0; i < 2; i++) {
float dmgMin = packet.readFloat();
float dmgMax = packet.readFloat();
uint32_t damageType = packet.readUInt32();
if (!haveWeaponDamage && dmgMax > 0.0f) {
if (damageType == 0 || data.damageMax <= 0.0f) {
data.damageMin = dmgMin;
data.damageMax = dmgMax;
haveWeaponDamage = (damageType == 0);
}
}
r.armor = static_cast<int32_t>(packet.readUInt32());
if (packet.getSize() - packet.getReadPos() >= 28) {
packet.readUInt32(); // HolyRes
packet.readUInt32(); // FireRes
packet.readUInt32(); // NatureRes
packet.readUInt32(); // FrostRes
packet.readUInt32(); // ShadowRes
packet.readUInt32(); // ArcaneRes
r.delayMs = packet.readUInt32();
r.ok = true;
}
return r;
};
// All WoW versions (Classic, TBC, WotLK) use exactly 5 damage entries in
// SMSG_ITEM_QUERY_SINGLE_RESPONSE. Default to 5. Fall back to 2 only if
// the 5-entry parse fails or yields clearly implausible results for weapons.
DamageParseResult parsed2 = parseDamageBlock(2);
DamageParseResult parsed5 = parseDamageBlock(5);
auto looksWeaponItem = [&](const DamageParseResult& r) {
return (data.itemClass == 2) && (r.damageMax > 0.0f) && (r.delayMs > 0);
};
const DamageParseResult* chosen = &parsed5;
if (parsed5.ok && parsed2.ok) {
// Only prefer parsed2 if it identifies as a weapon and parsed5 doesn't.
// This handles non-standard 2-entry servers for weapon items.
if (looksWeaponItem(parsed2) && !looksWeaponItem(parsed5)) chosen = &parsed2;
} else if (!parsed5.ok && parsed2.ok) {
chosen = &parsed2;
}
int chosenDamageEntries = (chosen == &parsed5) ? 5 : 2;
data.damageMin = chosen->damageMin;
data.damageMax = chosen->damageMax;
data.armor = chosen->armor;
data.delayMs = chosen->delayMs;
data.armor = static_cast<int32_t>(packet.readUInt32());
packet.readUInt32(); // HolyRes
packet.readUInt32(); // FireRes
packet.readUInt32(); // NatureRes
packet.readUInt32(); // FrostRes
packet.readUInt32(); // ShadowRes
packet.readUInt32(); // ArcaneRes
data.delayMs = packet.readUInt32();
packet.readUInt32(); // AmmoType
packet.readFloat(); // RangedModRange
// 5 item spells: SpellId, SpellTrigger, SpellCharges, SpellCooldown, SpellCategory, SpellCategoryCooldown
for (int i = 0; i < 5; i++) {
if (packet.getReadPos() + 24 > packet.getSize()) break;
data.spells[i].spellId = packet.readUInt32();
data.spells[i].spellTrigger = packet.readUInt32();
packet.readUInt32(); // SpellCharges
packet.readUInt32(); // SpellCooldown
packet.readUInt32(); // SpellCategory
packet.readUInt32(); // SpellCategoryCooldown
}
data.valid = !data.name.empty();
LOG_INFO("Item query: '", data.name, "' class=", data.itemClass,
" invType=", data.inventoryType, " quality=", data.quality,
" armor=", data.armor, " dmgEntries=", chosenDamageEntries,
" statsCount=", statsCount, " sellPrice=", data.sellPrice);
return true;
}
@ -3142,13 +3110,13 @@ network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) {
return packet;
}
network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid) {
network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) {
network::Packet packet(wireOpcode(Opcode::CMSG_USE_ITEM));
packet.writeUInt8(bagIndex);
packet.writeUInt8(slotIndex);
packet.writeUInt8(0); // cast count
packet.writeUInt32(0); // spell id
packet.writeUInt64(itemGuid);
packet.writeUInt32(spellId); // spell id from item data
packet.writeUInt64(itemGuid); // full 8-byte GUID
packet.writeUInt32(0); // glyph index
packet.writeUInt8(0); // cast flags
// SpellCastTargets: self

View file

@ -648,6 +648,11 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
cancelPickup(inventory);
}
// Cancel pending pickup if mouse released before threshold
if (pickupPending_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
pickupPending_ = false;
}
if (separateBags_) {
renderSeparateBags(inventory, moneyCopper);
} else {
@ -1341,7 +1346,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
ImGui::InvisibleButton("slot", ImVec2(size, size));
if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && holdingItem && validDrop) {
// Drop held item on mouse release over empty slot
if (ImGui::IsItemHovered() && holdingItem && validDrop &&
ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
placeInBackpack(inventory, backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
@ -1400,17 +1407,44 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
ImGui::InvisibleButton("slot", ImVec2(size, size));
// Left-click: pickup or place/swap
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
if (!holdingItem) {
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
pickupFromBackpack(inventory, backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
pickupFromBag(inventory, bagIndex, bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
pickupFromEquipment(inventory, equipSlot);
// Left mouse: hold to pick up, release to drop/swap
if (!holdingItem) {
// Start pickup tracking on mouse press
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
pickupPending_ = true;
pickupPressTime_ = ImGui::GetTime();
pickupSlotKind_ = kind;
pickupBackpackIndex_ = backpackIndex;
pickupBagIndex_ = bagIndex;
pickupBagSlotIndex_ = bagSlotIndex;
pickupEquipSlot_ = equipSlot;
}
// Check if held long enough to pick up
if (pickupPending_ && ImGui::IsMouseDown(ImGuiMouseButton_Left) &&
(ImGui::GetTime() - pickupPressTime_) >= kPickupHoldThreshold) {
// Verify this is the same slot that was pressed
bool sameSlot = (pickupSlotKind_ == kind);
if (kind == SlotKind::BACKPACK && !isBagSlot)
sameSlot = sameSlot && (pickupBackpackIndex_ == backpackIndex);
else if (kind == SlotKind::BACKPACK && isBagSlot)
sameSlot = sameSlot && (pickupBagIndex_ == bagIndex) && (pickupBagSlotIndex_ == bagSlotIndex);
else if (kind == SlotKind::EQUIPMENT)
sameSlot = sameSlot && (pickupEquipSlot_ == equipSlot);
if (sameSlot && ImGui::IsItemHovered()) {
pickupPending_ = false;
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
pickupFromBackpack(inventory, backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
pickupFromBag(inventory, bagIndex, bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
pickupFromEquipment(inventory, equipSlot);
}
}
} else {
}
} else {
// Drop/swap on mouse release over a filled slot
if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
placeInBackpack(inventory, backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
@ -1422,12 +1456,14 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
// Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use
if (ImGui::IsItemClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) {
LOG_INFO("Right-click slot: kind=", (int)kind,
// Note: InvisibleButton only tracks left-click by default, so use IsItemHovered+IsMouseClicked
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) {
LOG_WARNING("Right-click slot: kind=", (int)kind,
" backpackIndex=", backpackIndex,
" bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex,
" vendorMode=", vendorMode_,
" bankOpen=", gameHandler_->isBankOpen());
" bankOpen=", gameHandler_->isBankOpen(),
" item='", item.name, "' invType=", (int)item.inventoryType);
if (gameHandler_->isMailComposeOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) {
gameHandler_->attachItemFromBackpack(backpackIndex);
} else if (gameHandler_->isMailComposeOpen() && kind == SlotKind::BACKPACK && isBagSlot) {
@ -1444,12 +1480,18 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
LOG_INFO("UI unequip request: equipSlot=", (int)equipSlot);
gameHandler_->unequipToBackpack(equipSlot);
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
LOG_INFO("Right-click backpack item: name='", item.name,
"' inventoryType=", (int)item.inventoryType,
" itemId=", item.itemId);
if (item.inventoryType > 0) {
gameHandler_->autoEquipItemBySlot(backpackIndex);
} else {
gameHandler_->useItemBySlot(backpackIndex);
}
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
LOG_INFO("Right-click bag item: name='", item.name,
"' inventoryType=", (int)item.inventoryType,
" bagIndex=", bagIndex, " slotIndex=", bagSlotIndex);
if (item.inventoryType > 0) {
gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex);
} else {

View file

@ -133,11 +133,17 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
uint32_t id = skillLineDbc->getUInt32(i, slL ? (*slL)["ID"] : 0);
uint32_t category = skillLineDbc->getUInt32(i, slL ? (*slL)["Category"] : 1);
std::string name = skillLineDbc->getString(i, slL ? (*slL)["Name"] : 3);
if (id > 0 && !name.empty()) {
skillLineNames[id] = name;
if (id > 0) {
if (!name.empty()) {
skillLineNames[id] = name;
}
skillLineCategories[id] = category;
}
}
LOG_INFO("Spellbook: Loaded ", skillLineNames.size(), " skill line names, ",
skillLineCategories.size(), " categories from SkillLine.dbc");
} else {
LOG_WARNING("Spellbook: Could not load SkillLine.dbc");
}
auto slaDbc = assetManager->loadDBC("SkillLineAbility.dbc");
@ -147,9 +153,12 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
uint32_t skillLineId = slaDbc->getUInt32(i, slaL ? (*slaL)["SkillLineID"] : 1);
uint32_t spellId = slaDbc->getUInt32(i, slaL ? (*slaL)["SpellID"] : 2);
if (spellId > 0 && skillLineId > 0) {
spellToSkillLine[spellId] = skillLineId;
spellToSkillLine.emplace(spellId, skillLineId);
}
}
LOG_INFO("Spellbook: Loaded ", spellToSkillLine.size(), " spell-to-skillline mappings from SkillLineAbility.dbc");
} else {
LOG_WARNING("Spellbook: Could not load SkillLineAbility.dbc");
}
}
@ -161,9 +170,10 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& known
static constexpr uint32_t CAT_PROFESSION = 11; // Primary professions
static constexpr uint32_t CAT_SECONDARY = 9; // Secondary skills (Cooking, First Aid, Fishing, Riding, Companions)
// Special skill line IDs within category 9 that get their own tabs
static constexpr uint32_t SKILLLINE_RIDING = 762; // Mounts
static constexpr uint32_t SKILLLINE_COMPANIONS = 778; // Vanity/companion pets
// Special skill line IDs that get their own tabs
static constexpr uint32_t SKILLLINE_MOUNTS = 777; // Mount summon spells (category 7)
static constexpr uint32_t SKILLLINE_RIDING = 762; // Riding skill ranks (category 9)
static constexpr uint32_t SKILLLINE_COMPANIONS = 778; // Vanity/companion pets (category 7)
// Buckets
std::map<uint32_t, std::vector<const SpellInfo*>> specSpells; // class spec trees
@ -178,47 +188,72 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& known
const SpellInfo* info = &it->second;
auto slIt = spellToSkillLine.find(spellId);
if (slIt != spellToSkillLine.end()) {
// Check all skill lines this spell belongs to, prefer class (cat 7) > profession > secondary > special
auto range = spellToSkillLine.equal_range(spellId);
bool categorized = false;
uint32_t bestSkillLine = 0;
int bestPriority = -1; // 4=class, 3=profession, 2=secondary, 1=mount/companion
for (auto slIt = range.first; slIt != range.second; ++slIt) {
uint32_t skillLineId = slIt->second;
// Mounts: Riding skill line (762)
if (skillLineId == SKILLLINE_RIDING) {
mountSpells.push_back(info);
if (skillLineId == SKILLLINE_MOUNTS || skillLineId == SKILLLINE_RIDING) {
if (bestPriority < 1) { bestPriority = 1; bestSkillLine = SKILLLINE_MOUNTS; }
continue;
}
// Companions: vanity pets skill line (778)
if (skillLineId == SKILLLINE_COMPANIONS) {
companionSpells.push_back(info);
if (bestPriority < 1) { bestPriority = 1; bestSkillLine = skillLineId; }
continue;
}
auto catIt = skillLineCategories.find(skillLineId);
if (catIt != skillLineCategories.end()) {
uint32_t cat = catIt->second;
// Class spec abilities
if (cat == CAT_CLASS) {
specSpells[skillLineId].push_back(info);
continue;
}
// Primary professions
if (cat == CAT_PROFESSION) {
profSpells[skillLineId].push_back(info);
continue;
}
// Secondary skills (Cooking, First Aid, Fishing)
if (cat == CAT_SECONDARY) {
profSpells[skillLineId].push_back(info);
continue;
if (cat == CAT_CLASS && bestPriority < 4) {
bestPriority = 4; bestSkillLine = skillLineId;
} else if (cat == CAT_PROFESSION && bestPriority < 3) {
bestPriority = 3; bestSkillLine = skillLineId;
} else if (cat == CAT_SECONDARY && bestPriority < 2) {
bestPriority = 2; bestSkillLine = skillLineId;
}
}
}
generalSpells.push_back(info);
if (bestSkillLine > 0) {
if (bestSkillLine == SKILLLINE_MOUNTS) {
mountSpells.push_back(info);
categorized = true;
} else if (bestSkillLine == SKILLLINE_COMPANIONS) {
companionSpells.push_back(info);
categorized = true;
} else {
auto catIt = skillLineCategories.find(bestSkillLine);
if (catIt != skillLineCategories.end()) {
uint32_t cat = catIt->second;
if (cat == CAT_CLASS) {
specSpells[bestSkillLine].push_back(info);
categorized = true;
} else if (cat == CAT_PROFESSION || cat == CAT_SECONDARY) {
profSpells[bestSkillLine].push_back(info);
categorized = true;
}
}
}
}
if (!categorized) {
generalSpells.push_back(info);
}
}
LOG_INFO("Spellbook categorize: ", specSpells.size(), " spec groups, ",
generalSpells.size(), " general, ", profSpells.size(), " prof groups, ",
mountSpells.size(), " mounts, ", companionSpells.size(), " companions");
for (const auto& [slId, spells] : specSpells) {
auto nameIt = skillLineNames.find(slId);
LOG_INFO(" Spec tab: skillLine=", slId, " name='",
(nameIt != skillLineNames.end() ? nameIt->second : "?"), "' spells=", spells.size());
}
auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; };