Compare commits

...

13 commits

Author SHA1 Message Date
Kelsi
7982815a67 Add Companions tab to spellbook for vanity pets (SkillLine 778)
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
- Companion pet spells (SkillLine 778) get their own "Companions" tab
- Mounts (SkillLine 762) and Companions are now split out from secondary
  skills instead of being lumped into profession tabs
- Refactored tab grouping into addGroupedTabs helper to reduce duplication
- Tab order: Specs > General > Professions > Mounts > Companions
- Double-click to summon companions/mounts works via existing castSpell
2026-02-25 15:04:17 -08:00
Kelsi
0d87a86516 Separate spellbook into spec, general, profession, and mount tabs
- Class spec abilities now get individual tabs (e.g. Fire, Frost, Arcane)
- Primary professions each get their own tab (Alchemy, Blacksmithing, etc.)
- Secondary professions get tabs (Cooking, First Aid, Fishing)
- Mounts tab for all spells linked to Riding skill line (762)
- General tab for everything else (racials, weapon skills, etc.)
- Tab order: specs first, then General, then professions, then Mounts
2026-02-25 14:59:09 -08:00
Kelsi
da959cfb8f Revamp talent and spellbook UIs with proper visuals and functionality
Talent screen:
- Remove all debug text and per-frame LOG_INFO spam
- Show class name in window title (e.g. "Warrior Talents")
- Display point distribution in header (0/31/20) and per-tab counts
- Highlighted active spec button with styled spec switcher
- Load and render tree background textures from TalentTab.dbc
- Draw prerequisite arrows with arrowheads (green=met, gray=unmet)
- Fix rank display (was showing rank+1, now correct 1-indexed values)
- Rank counter with dark background pill for readability
- Hover glow effect, rounded corners, centered grid layout
- Wider window (680x600) for 4-column WoW talent grid

Spellbook:
- Add search/filter bar for finding spells by name
- Add spell descriptions from Spell.dbc tooltip field
- Rich tooltips with name, rank, passive indicator, cooldown, description
- Visual icon borders: yellow=passive, red=cooldown, default=active
- Cooldown overlay on icon with countdown number
- Hover highlight on spell rows
- Tab counts update to reflect search filter results
- Rounded corners on icons and hover states
- Extracted renderSpellTooltip helper for consistent tooltip rendering
2026-02-25 14:55:40 -08:00
Kelsi
889cd86fb0 Remove level cap from auction search to allow finding items of any required level 2026-02-25 14:45:53 -08:00
Kelsi
1ae5fe867c Improve auction house UI with search filters, pagination, sell picker, and tooltips
- Add item class/subclass category filters (Weapon, Armor, etc.) with correct WoW 3.3.5a IDs
- Add sell item picker dropdown with icons and Create Auction button
- Add pagination (Prev/Next) for browse results with filter preservation
- Add Buy/Bid action buttons to Bids tab
- Add item icons and stat tooltips on hover across all three tabs
- Add Enter-to-search from name field and search delay countdown
- Parse SMSG_AUCTION_OWNER/BIDDER_NOTIFICATION into chat messages
- Auto-refresh browse results after bid/buyout using saved search params
- Clamp level range inputs to 0-80
2026-02-25 14:44:44 -08:00
Kelsi
9906269671 Add mail item attachment support for sending
- CMSG_SEND_MAIL now includes item GUIDs (up to 12 per WotLK)
- Right-click items in bags to attach when mail compose is open
- Compose window shows 12-slot attachment grid with item icons
- Click attached items to remove them
- Classic/Vanilla falls back to single item GUID format
2026-02-25 14:11:09 -08:00
Kelsi
ce30cedf4a Deposit all items to bank on right-click when bank is open
All right-clicked inventory items now deposit to bank regardless of
whether they are equippable, usable, or materials.
2026-02-25 14:00:54 -08:00
Kelsi
d61b7b385d Only auto-deposit non-equippable items to bank on right-click
Right-clicking equippable items (inventoryType > 0) while the bank is
open now equips them as normal instead of depositing. Only materials,
quest items, and other non-equippable items auto-deposit to bank.
2026-02-25 13:57:37 -08:00
Kelsi
af7fb4242c Add drag-and-drop support for inventory to bank slots
Bank window slots now act as drop targets when holding an item from
inventory. Empty bank slots highlight green, and clicking drops the
held item via CMSG_SWAP_ITEM. Occupied bank slots accept swaps too.
Works for both main bank slots (39-66) and bank bag slots (67+).
2026-02-25 13:54:47 -08:00
Kelsi
2ab5cf5eb6 Add inventory-to-bank deposit on right-click
When the bank is open, right-clicking a backpack or bag item now
deposits it into the bank via CMSG_AUTOBANK_ITEM instead of trying
to equip/use it. Bank deposit takes priority over vendor sell and
auto-equip actions.
2026-02-25 13:47:42 -08:00
Kelsi
e220ce888d Fix window close hang from blocking worker thread joins
unloadAll() now uses a 500ms deadline with pthread_timedjoin_np to
avoid blocking indefinitely when worker threads are mid-prepareTile
(reading MPQ archives / parsing ADT files). Threads that don't finish
within the deadline are detached so the app can exit promptly.
2026-02-25 13:42:58 -08:00
Kelsi
26a685187e Fix /logout hang caused by blocking worker thread joins
unloadAll() joins worker threads which blocks if they're mid-tile
(prepareTile can take seconds for heavy ADTs). Replace with softReset()
which clears tile data, queues, and water surfaces without stopping
worker threads — workers find empty queues and idle naturally.
2026-02-25 13:37:09 -08:00
Kelsi
872b10fe68 Fix water descriptor pool leak and add water rendering diagnostics
- Add VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT to water material
  descriptor pool so individual sets can be freed when tiles are unloaded
- Free descriptor sets in destroyWaterMesh() instead of leaking them
- Add terrain manager unloadAll() during logout to properly clear stale
  tiles, water surfaces, and queues between sessions
- Add diagnostic logging for water surface loading, material allocation
  failures, and render skip reasons to investigate missing water
2026-02-25 13:26:08 -08:00
19 changed files with 1913 additions and 507 deletions

View file

@ -355,6 +355,11 @@ public:
void acceptGuildInvite();
void declineGuildInvite();
void queryGuildInfo(uint32_t guildId);
void createGuild(const std::string& guildName);
void addGuildRank(const std::string& rankName);
void deleteGuildRank();
void requestPetitionShowlist(uint64_t npcGuid);
void buyPetition(uint64_t npcGuid, const std::string& guildName);
// Guild state accessors
bool isInGuild() const {
@ -369,6 +374,13 @@ public:
bool hasPendingGuildInvite() const { return pendingGuildInvite_; }
const std::string& getPendingGuildInviterName() const { return pendingGuildInviterName_; }
const std::string& getPendingGuildInviteGuildName() const { return pendingGuildInviteGuildName_; }
const GuildInfoData& getGuildInfoData() const { return guildInfoData_; }
const GuildQueryResponseData& getGuildQueryData() const { return guildQueryData_; }
bool hasGuildInfoData() const { return guildInfoData_.isValid(); }
bool hasPetitionShowlist() const { return showPetitionDialog_; }
void clearPetitionDialog() { showPetitionDialog_ = false; }
uint32_t getPetitionCost() const { return petitionCost_; }
uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; }
// Ready check
void initiateReadyCheck();
@ -856,12 +868,28 @@ public:
int getSelectedMailIndex() const { return selectedMailIndex_; }
void setSelectedMailIndex(int idx) { selectedMailIndex_ = idx; }
bool isMailComposeOpen() const { return showMailCompose_; }
void openMailCompose() { showMailCompose_ = true; }
void closeMailCompose() { showMailCompose_ = false; }
void openMailCompose() { showMailCompose_ = true; clearMailAttachments(); }
void closeMailCompose() { showMailCompose_ = false; clearMailAttachments(); }
bool hasNewMail() const { return hasNewMail_; }
void closeMailbox();
void sendMail(const std::string& recipient, const std::string& subject,
const std::string& body, uint32_t money, uint32_t cod = 0);
// Mail attachments (max 12 per WotLK)
static constexpr int MAIL_MAX_ATTACHMENTS = 12;
struct MailAttachSlot {
uint64_t itemGuid = 0;
game::ItemDef item;
uint8_t srcBag = 0xFF; // source container for return
uint8_t srcSlot = 0;
bool occupied() const { return itemGuid != 0; }
};
bool attachItemFromBackpack(int backpackIndex);
bool attachItemFromBag(int bagIndex, int slotIndex);
bool detachMailAttachment(int attachIndex);
void clearMailAttachments();
const std::array<MailAttachSlot, 12>& getMailAttachments() const { return mailAttachments_; }
int getMailAttachmentCount() const;
void mailTakeMoney(uint32_t mailId);
void mailTakeItem(uint32_t mailId, uint32_t itemIndex);
void mailDelete(uint32_t mailId);
@ -1107,6 +1135,8 @@ private:
void handleGuildEvent(network::Packet& packet);
void handleGuildInvite(network::Packet& packet);
void handleGuildCommandResult(network::Packet& packet);
void handlePetitionShowlist(network::Packet& packet);
void handleTurnInPetitionResults(network::Packet& packet);
// ---- Character creation handler ----
void handleCharCreateResponse(network::Packet& packet);
@ -1451,10 +1481,15 @@ private:
std::string guildName_;
std::vector<std::string> guildRankNames_;
GuildRosterData guildRoster_;
GuildInfoData guildInfoData_;
GuildQueryResponseData guildQueryData_;
bool hasGuildRoster_ = false;
bool pendingGuildInvite_ = false;
std::string pendingGuildInviterName_;
std::string pendingGuildInviteGuildName_;
bool showPetitionDialog_ = false;
uint32_t petitionCost_ = 0;
uint64_t petitionNpcGuid_ = 0;
uint64_t activeCharacterGuid_ = 0;
Race playerRace_ = Race::HUMAN;
@ -1568,6 +1603,7 @@ private:
int selectedMailIndex_ = -1;
bool showMailCompose_ = false;
bool hasNewMail_ = false;
std::array<MailAttachSlot, MAIL_MAX_ATTACHMENTS> mailAttachments_{};
// Bank
bool bankOpen_ = false;
@ -1590,6 +1626,18 @@ private:
AuctionListResult auctionBidderResults_;
int auctionActiveTab_ = 0; // 0=Browse, 1=Bids, 2=Auctions
float auctionSearchDelayTimer_ = 0.0f;
// Last search params for re-query (pagination, auto-refresh after bid/buyout)
struct AuctionSearchParams {
std::string name;
uint8_t levelMin = 0, levelMax = 0;
uint32_t quality = 0xFFFFFFFF;
uint32_t itemClass = 0xFFFFFFFF;
uint32_t itemSubClass = 0xFFFFFFFF;
uint32_t invTypeMask = 0;
uint8_t usableOnly = 0;
uint32_t offset = 0;
};
AuctionSearchParams lastAuctionSearch_;
// Routing: which result vector to populate from next SMSG_AUCTION_LIST_RESULT
enum class AuctionResultTarget { BROWSE, OWNER, BIDDER };
AuctionResultTarget pendingAuctionTarget_ = AuctionResultTarget::BROWSE;

View file

@ -217,8 +217,9 @@ public:
/** Build CMSG_SEND_MAIL */
virtual network::Packet buildSendMail(uint64_t mailboxGuid, const std::string& recipient,
const std::string& subject, const std::string& body,
uint32_t money, uint32_t cod) {
return SendMailPacket::build(mailboxGuid, recipient, subject, body, money, cod);
uint32_t money, uint32_t cod,
const std::vector<uint64_t>& itemGuids = {}) {
return SendMailPacket::build(mailboxGuid, recipient, subject, body, money, cod, itemGuids);
}
/** Parse SMSG_MAIL_LIST_RESULT into a vector of MailMessage */
@ -323,7 +324,8 @@ public:
network::Packet buildLeaveChannel(const std::string& channelName) override;
network::Packet buildSendMail(uint64_t mailboxGuid, const std::string& recipient,
const std::string& subject, const std::string& body,
uint32_t money, uint32_t cod) override;
uint32_t money, uint32_t cod,
const std::vector<uint64_t>& itemGuids = {}) override;
bool parseMailList(network::Packet& packet, std::vector<MailMessage>& inbox) override;
network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) override;
network::Packet buildMailDelete(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) override;

View file

@ -1050,6 +1050,60 @@ public:
static network::Packet build();
};
/** CMSG_GUILD_CREATE packet builder */
class GuildCreatePacket {
public:
static network::Packet build(const std::string& guildName);
};
/** CMSG_GUILD_ADD_RANK packet builder */
class GuildAddRankPacket {
public:
static network::Packet build(const std::string& rankName);
};
/** CMSG_GUILD_DEL_RANK packet builder (empty body) */
class GuildDelRankPacket {
public:
static network::Packet build();
};
/** CMSG_PETITION_SHOWLIST packet builder */
class PetitionShowlistPacket {
public:
static network::Packet build(uint64_t npcGuid);
};
/** CMSG_PETITION_BUY packet builder */
class PetitionBuyPacket {
public:
static network::Packet build(uint64_t npcGuid, const std::string& guildName);
};
/** SMSG_PETITION_SHOWLIST data */
struct PetitionShowlistData {
uint64_t npcGuid = 0;
uint32_t itemId = 0;
uint32_t displayId = 0;
uint32_t cost = 0;
uint32_t charterType = 0;
uint32_t requiredSigs = 0;
bool isValid() const { return npcGuid != 0; }
};
/** SMSG_PETITION_SHOWLIST parser */
class PetitionShowlistParser {
public:
static bool parse(network::Packet& packet, PetitionShowlistData& data);
};
/** SMSG_TURN_IN_PETITION_RESULTS parser */
class TurnInPetitionResultsParser {
public:
static bool parse(network::Packet& packet, uint32_t& result);
};
// Guild event type constants
namespace GuildEvent {
constexpr uint8_t PROMOTION = 0;
@ -2290,7 +2344,8 @@ class SendMailPacket {
public:
static network::Packet build(uint64_t mailboxGuid, const std::string& recipient,
const std::string& subject, const std::string& body,
uint32_t money, uint32_t cod);
uint32_t money, uint32_t cod,
const std::vector<uint64_t>& itemGuids = {});
};
/** CMSG_MAIL_TAKE_MONEY packet builder */

View file

@ -212,6 +212,7 @@ public:
* Unload all tiles
*/
void unloadAll();
void softReset(); // Clear tile data without stopping worker threads (non-blocking)
/**
* Precache a set of tiles (for taxi routes, etc.)

View file

@ -172,6 +172,7 @@ private:
VkImageView sceneDepthView = VK_NULL_HANDLE;
VkExtent2D sceneHistoryExtent = {0, 0};
bool sceneHistoryReady = false;
mutable uint32_t renderDiagCounter_ = 0;
// Planar reflection resources
static constexpr uint32_t REFLECTION_WIDTH = 512;

View file

@ -68,6 +68,12 @@ private:
bool showGuildNoteEdit_ = false;
bool editingOfficerNote_ = false;
char guildNoteEditBuffer_[256] = {0};
int guildRosterTab_ = 0; // 0=Roster, 1=Guild Info
char guildMotdEditBuffer_[256] = {0};
bool showMotdEdit_ = false;
char petitionNameBuffer_[64] = {0};
char addRankNameBuffer_[64] = {0};
bool showAddRankModal_ = false;
bool refocusChatInput = false;
bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session
bool chatWindowLocked = true;
@ -284,6 +290,10 @@ private:
int auctionSellBid_[3] = {0, 0, 0}; // gold, silver, copper
int auctionSellBuyout_[3] = {0, 0, 0}; // gold, silver, copper
int auctionSelectedItem_ = -1;
int auctionSellSlotIndex_ = -1; // Selected backpack slot for selling
uint32_t auctionBrowseOffset_ = 0; // Pagination offset for browse results
int auctionItemClass_ = -1; // Item class filter (-1 = All)
int auctionItemSubClass_ = -1; // Item subclass filter (-1 = All)
// Guild bank money input
int guildBankMoneyInput_[3] = {0, 0, 0}; // gold, silver, copper

View file

@ -173,6 +173,8 @@ public:
/// Drop the currently held item into a specific equipment slot.
/// Returns true if the drop was accepted and consumed.
bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot);
/// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM.
void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot);
};
} // namespace ui

View file

@ -18,8 +18,13 @@ struct SpellInfo {
uint32_t spellId = 0;
std::string name;
std::string rank;
uint32_t iconId = 0; // SpellIconID
uint32_t attributes = 0; // Spell attributes (field 75)
std::string description; // Tooltip/description text from Spell.dbc
uint32_t iconId = 0; // SpellIconID
uint32_t attributes = 0; // Spell attributes (field 4)
uint32_t castTimeMs = 0; // Cast time in ms (0 = instant)
uint32_t manaCost = 0; // Mana cost
uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy
uint32_t rangeIndex = 0; // Range index from SpellRange.dbc
bool isPassive() const { return (attributes & 0x40) != 0; }
};
@ -55,19 +60,21 @@ private:
// Icon data (loaded from SpellIcon.dbc)
bool iconDbLoaded = false;
std::unordered_map<uint32_t, std::string> spellIconPaths; // SpellIconID -> path
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // SpellIconID -> GL texture
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // SpellIconID -> texture
// Skill line data (loaded from SkillLine.dbc + SkillLineAbility.dbc)
bool skillLineDbLoaded = false;
std::unordered_map<uint32_t, std::string> skillLineNames; // skillLineID -> name
std::unordered_map<uint32_t, uint32_t> skillLineCategories; // skillLineID -> categoryID
std::unordered_map<uint32_t, uint32_t> spellToSkillLine; // spellID -> skillLineID
std::unordered_map<uint32_t, std::string> skillLineNames;
std::unordered_map<uint32_t, uint32_t> skillLineCategories;
std::unordered_map<uint32_t, uint32_t> spellToSkillLine;
// Categorized spell tabs (rebuilt when spell list changes)
// ordered map so tabs appear in consistent order
// Categorized spell tabs
std::vector<SpellTabInfo> spellTabs;
size_t lastKnownSpellCount = 0;
// Search filter
char searchFilter_[128] = "";
// Drag-and-drop from spellbook to action bar
bool draggingSpell_ = false;
uint32_t dragSpellId_ = 0;
@ -79,6 +86,9 @@ private:
void categorizeSpells(const std::unordered_set<uint32_t>& knownSpells);
VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager);
const SpellInfo* getSpellInfo(uint32_t spellId) const;
// Tooltip rendering helper
void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler);
};
} // namespace ui

View file

@ -20,8 +20,11 @@ public:
private:
void renderTalentTrees(game::GameHandler& gameHandler);
void renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId);
void renderTalent(game::GameHandler& gameHandler, const game::GameHandler::TalentEntry& talent);
void renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId,
const std::string& bgFile);
void renderTalent(game::GameHandler& gameHandler,
const game::GameHandler::TalentEntry& talent,
uint32_t pointsInTree);
void loadSpellDBC(pipeline::AssetManager* assetManager);
void loadSpellIconDBC(pipeline::AssetManager* assetManager);
@ -33,10 +36,11 @@ private:
// DBC caches
bool spellDbcLoaded = false;
bool iconDbcLoaded = false;
std::unordered_map<uint32_t, uint32_t> spellIconIds; // spellId -> iconId
std::unordered_map<uint32_t, uint32_t> spellIconIds; // spellId -> iconId
std::unordered_map<uint32_t, std::string> spellIconPaths; // iconId -> path
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // iconId -> texture
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, VkDescriptorSet> bgTextureCache_; // tabId -> bg texture
};
} // namespace ui

View file

@ -661,7 +661,11 @@ void Application::logoutToLogin() {
if (auto* m2 = renderer->getM2Renderer()) {
m2->clear();
}
// TerrainManager will be re-initialized on next world entry
// Clear terrain tile tracking + water surfaces so next world entry starts fresh.
// Use softReset() instead of unloadAll() to avoid blocking on worker thread joins.
if (auto* terrain = renderer->getTerrainManager()) {
terrain->softReset();
}
if (auto* questMarkers = renderer->getQuestMarkerRenderer()) {
questMarkers->clear();
}

View file

@ -1774,6 +1774,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_GUILD_COMMAND_RESULT:
handleGuildCommandResult(packet);
break;
case Opcode::SMSG_PETITION_SHOWLIST:
handlePetitionShowlist(packet);
break;
case Opcode::SMSG_TURN_IN_PETITION_RESULTS:
handleTurnInPetitionResults(packet);
break;
// ---- Phase 5: Loot/Gossip/Vendor ----
case Opcode::SMSG_LOOT_RESPONSE:
@ -2781,11 +2787,36 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_AUCTION_COMMAND_RESULT:
handleAuctionCommandResult(packet);
break;
case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION:
case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION:
// Auction notification payloads are informational; ignore until UI support lands.
case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: {
// auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ...
if (packet.getSize() - packet.getReadPos() >= 16) {
uint32_t auctionId = packet.readUInt32();
uint32_t action = packet.readUInt32();
uint32_t error = packet.readUInt32();
uint32_t itemEntry = packet.readUInt32();
(void)auctionId; (void)action; (void)error;
ensureItemInfo(itemEntry);
auto* info = getItemInfo(itemEntry);
std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry));
addSystemChatMessage("Your auction of " + itemName + " has sold!");
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: {
// auctionId(u32) + itemEntry(u32) + ...
if (packet.getSize() - packet.getReadPos() >= 8) {
uint32_t auctionId = packet.readUInt32();
uint32_t itemEntry = packet.readUInt32();
(void)auctionId;
ensureItemInfo(itemEntry);
auto* info = getItemInfo(itemEntry);
std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry));
addSystemChatMessage("You have been outbid on " + itemName + ".");
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_TAXINODE_STATUS:
// Node status cache not implemented yet.
packet.setReadPos(packet.getSize());
@ -9568,10 +9599,72 @@ void GameHandler::queryGuildInfo(uint32_t guildId) {
LOG_INFO("Querying guild info: guildId=", guildId);
}
void GameHandler::createGuild(const std::string& guildName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildCreatePacket::build(guildName);
socket->send(packet);
LOG_INFO("Creating guild: ", guildName);
}
void GameHandler::addGuildRank(const std::string& rankName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildAddRankPacket::build(rankName);
socket->send(packet);
LOG_INFO("Adding guild rank: ", rankName);
// Refresh roster to update rank list
requestGuildRoster();
}
void GameHandler::deleteGuildRank() {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildDelRankPacket::build();
socket->send(packet);
LOG_INFO("Deleting last guild rank");
// Refresh roster to update rank list
requestGuildRoster();
}
void GameHandler::requestPetitionShowlist(uint64_t npcGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = PetitionShowlistPacket::build(npcGuid);
socket->send(packet);
}
void GameHandler::buyPetition(uint64_t npcGuid, const std::string& guildName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = PetitionBuyPacket::build(npcGuid, guildName);
socket->send(packet);
LOG_INFO("Buying guild petition: ", guildName);
}
void GameHandler::handlePetitionShowlist(network::Packet& packet) {
PetitionShowlistData data;
if (!PetitionShowlistParser::parse(packet, data)) return;
petitionNpcGuid_ = data.npcGuid;
petitionCost_ = data.cost;
showPetitionDialog_ = true;
LOG_INFO("Petition showlist: cost=", data.cost);
}
void GameHandler::handleTurnInPetitionResults(network::Packet& packet) {
uint32_t result = 0;
if (!TurnInPetitionResultsParser::parse(packet, result)) return;
switch (result) {
case 0: addSystemChatMessage("Guild created successfully!"); break;
case 1: addSystemChatMessage("Guild creation failed: already in a guild."); break;
case 2: addSystemChatMessage("Guild creation failed: not enough signatures."); break;
case 3: addSystemChatMessage("Guild creation failed: name already taken."); break;
default: addSystemChatMessage("Guild creation failed (error " + std::to_string(result) + ")."); break;
}
}
void GameHandler::handleGuildInfo(network::Packet& packet) {
GuildInfoData data;
if (!GuildInfoParser::parse(packet, data)) return;
guildInfoData_ = data;
addSystemChatMessage("Guild: " + data.guildName + " (" +
std::to_string(data.numMembers) + " members, " +
std::to_string(data.numAccounts) + " accounts)");
@ -9591,6 +9684,7 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
if (!packetParsers_->parseGuildQueryResponse(packet, data)) return;
guildName_ = data.guildName;
guildQueryData_ = data;
guildRankNames_.clear();
for (uint32_t i = 0; i < 10; ++i) {
guildRankNames_.push_back(data.rankNames[i]);
@ -12851,10 +12945,114 @@ void GameHandler::sendMail(const std::string& recipient, const std::string& subj
LOG_WARNING("sendMail: mailboxGuid_ is 0 (mailbox closed?)");
return;
}
auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod);
// Collect attached item GUIDs
std::vector<uint64_t> itemGuids;
for (const auto& att : mailAttachments_) {
if (att.occupied()) {
itemGuids.push_back(att.itemGuid);
}
}
auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod, itemGuids);
LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money,
" mailboxGuid=", mailboxGuid_);
" attachments=", itemGuids.size(), " mailboxGuid=", mailboxGuid_);
socket->send(packet);
clearMailAttachments();
}
bool GameHandler::attachItemFromBackpack(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return false;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
if (slot.empty()) return false;
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid == 0) {
addSystemChatMessage("Cannot attach: item not found.");
return false;
}
// Check not already attached
for (const auto& att : mailAttachments_) {
if (att.occupied() && att.itemGuid == itemGuid) return false;
}
// Find free attachment slot
for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) {
if (!mailAttachments_[i].occupied()) {
mailAttachments_[i].itemGuid = itemGuid;
mailAttachments_[i].item = slot.item;
mailAttachments_[i].srcBag = 0xFF;
mailAttachments_[i].srcSlot = static_cast<uint8_t>(23 + backpackIndex);
LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x",
std::hex, itemGuid, std::dec, " from backpack[", backpackIndex, "]");
return true;
}
}
addSystemChatMessage("Cannot attach: all attachment slots full.");
return false;
}
bool GameHandler::attachItemFromBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return false;
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return false;
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
if (slot.empty()) return false;
uint64_t itemGuid = 0;
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
if (bagGuid != 0) {
auto it = containerContents_.find(bagGuid);
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
itemGuid = it->second.slotGuids[slotIndex];
}
}
if (itemGuid == 0) {
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
}
if (itemGuid == 0) {
addSystemChatMessage("Cannot attach: item not found.");
return false;
}
for (const auto& att : mailAttachments_) {
if (att.occupied() && att.itemGuid == itemGuid) return false;
}
for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) {
if (!mailAttachments_[i].occupied()) {
mailAttachments_[i].itemGuid = itemGuid;
mailAttachments_[i].item = slot.item;
mailAttachments_[i].srcBag = static_cast<uint8_t>(19 + bagIndex);
mailAttachments_[i].srcSlot = static_cast<uint8_t>(slotIndex);
LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x",
std::hex, itemGuid, std::dec, " from bag[", bagIndex, "][", slotIndex, "]");
return true;
}
}
addSystemChatMessage("Cannot attach: all attachment slots full.");
return false;
}
bool GameHandler::detachMailAttachment(int attachIndex) {
if (attachIndex < 0 || attachIndex >= MAIL_MAX_ATTACHMENTS) return false;
if (!mailAttachments_[attachIndex].occupied()) return false;
LOG_INFO("Mail detach: slot=", attachIndex, " item='", mailAttachments_[attachIndex].item.name, "'");
mailAttachments_[attachIndex] = MailAttachSlot{};
return true;
}
void GameHandler::clearMailAttachments() {
for (auto& att : mailAttachments_) att = MailAttachSlot{};
}
int GameHandler::getMailAttachmentCount() const {
int count = 0;
for (const auto& att : mailAttachments_) {
if (att.occupied()) ++count;
}
return count;
}
void GameHandler::mailTakeMoney(uint32_t mailId) {
@ -13201,6 +13399,8 @@ void GameHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8
addSystemChatMessage("Please wait before searching again.");
return;
}
// Save search params for pagination and auto-refresh
lastAuctionSearch_ = {name, levelMin, levelMax, quality, itemClass, itemSubClass, invTypeMask, usableOnly, offset};
pendingAuctionTarget_ = AuctionResultTarget::BROWSE;
auto pkt = AuctionListItemsPacket::build(auctioneerGuid_, offset, name,
levelMin, levelMax, invTypeMask,
@ -13333,9 +13533,16 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) {
if (result.errorCode == 0) {
std::string msg = std::string("Auction ") + actionName + " successful.";
addSystemChatMessage(msg);
// Refresh appropriate list
if (result.action == 0) auctionListOwnerItems();
else if (result.action == 1) auctionListOwnerItems();
// Refresh appropriate lists
if (result.action == 0) auctionListOwnerItems(); // create
else if (result.action == 1) auctionListOwnerItems(); // cancel
else if (result.action == 2 || result.action == 3) { // bid or buyout
auctionListBidderItems();
// Re-query browse results with the same filters the user last searched with
const auto& s = lastAuctionSearch_;
auctionSearch(s.name, s.levelMin, s.levelMax, s.quality,
s.itemClass, s.itemSubClass, s.invTypeMask, s.usableOnly, s.offset);
}
} else {
const char* errors[] = {"OK", "Inventory", "Not enough money", "Item not found",
"Higher bid", "Increment", "Not enough items",

View file

@ -728,7 +728,8 @@ network::Packet ClassicPacketParsers::buildSendMail(uint64_t mailboxGuid,
const std::string& recipient,
const std::string& subject,
const std::string& body,
uint32_t money, uint32_t cod) {
uint32_t money, uint32_t cod,
const std::vector<uint64_t>& itemGuids) {
network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL));
packet.writeUInt64(mailboxGuid);
packet.writeString(recipient);
@ -736,7 +737,9 @@ network::Packet ClassicPacketParsers::buildSendMail(uint64_t mailboxGuid,
packet.writeString(body);
packet.writeUInt32(0); // stationery
packet.writeUInt32(0); // unknown
packet.writeUInt64(0); // item GUID (0 = no attachment, single item only in Vanilla)
// Vanilla supports only one item attachment (single uint64 GUID)
uint64_t singleItemGuid = itemGuids.empty() ? 0 : itemGuids[0];
packet.writeUInt64(singleItemGuid);
packet.writeUInt32(money);
packet.writeUInt32(cod);
packet.writeUInt64(0); // unk3 (clients > 1.9.4)

View file

@ -1843,6 +1843,85 @@ network::Packet GuildDeclineInvitationPacket::build() {
return packet;
}
network::Packet GuildCreatePacket::build(const std::string& guildName) {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_CREATE));
packet.writeString(guildName);
LOG_DEBUG("Built CMSG_GUILD_CREATE: ", guildName);
return packet;
}
network::Packet GuildAddRankPacket::build(const std::string& rankName) {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ADD_RANK));
packet.writeString(rankName);
LOG_DEBUG("Built CMSG_GUILD_ADD_RANK: ", rankName);
return packet;
}
network::Packet GuildDelRankPacket::build() {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DEL_RANK));
LOG_DEBUG("Built CMSG_GUILD_DEL_RANK");
return packet;
}
network::Packet PetitionShowlistPacket::build(uint64_t npcGuid) {
network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_SHOWLIST));
packet.writeUInt64(npcGuid);
LOG_DEBUG("Built CMSG_PETITION_SHOWLIST: guid=", npcGuid);
return packet;
}
network::Packet PetitionBuyPacket::build(uint64_t npcGuid, const std::string& guildName) {
network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_BUY));
packet.writeUInt64(npcGuid); // NPC GUID
packet.writeUInt32(0); // unk
packet.writeUInt64(0); // unk
packet.writeString(guildName); // guild name
packet.writeUInt32(0); // body text (empty)
packet.writeUInt32(0); // min sigs
packet.writeUInt32(0); // max sigs
packet.writeUInt32(0); // unk
packet.writeUInt32(0); // unk
packet.writeUInt32(0); // unk
packet.writeUInt32(0); // unk
packet.writeUInt16(0); // unk
packet.writeUInt32(0); // unk
packet.writeUInt32(0); // unk index
packet.writeUInt32(0); // unk
LOG_DEBUG("Built CMSG_PETITION_BUY: npcGuid=", npcGuid, " name=", guildName);
return packet;
}
bool PetitionShowlistParser::parse(network::Packet& packet, PetitionShowlistData& data) {
if (packet.getSize() < 12) {
LOG_ERROR("SMSG_PETITION_SHOWLIST too small: ", packet.getSize());
return false;
}
data.npcGuid = packet.readUInt64();
uint32_t count = packet.readUInt32();
if (count > 0) {
data.itemId = packet.readUInt32();
data.displayId = packet.readUInt32();
data.cost = packet.readUInt32();
// Skip unused fields if present
if ((packet.getSize() - packet.getReadPos()) >= 8) {
data.charterType = packet.readUInt32();
data.requiredSigs = packet.readUInt32();
}
}
LOG_INFO("Parsed SMSG_PETITION_SHOWLIST: npcGuid=", data.npcGuid, " cost=", data.cost);
return true;
}
bool TurnInPetitionResultsParser::parse(network::Packet& packet, uint32_t& result) {
if (packet.getSize() < 4) {
LOG_ERROR("SMSG_TURN_IN_PETITION_RESULTS too small: ", packet.getSize());
return false;
}
result = packet.readUInt32();
LOG_INFO("Parsed SMSG_TURN_IN_PETITION_RESULTS: result=", result);
return true;
}
bool GuildQueryResponseParser::parse(network::Packet& packet, GuildQueryResponseData& data) {
if (packet.getSize() < 8) {
LOG_ERROR("SMSG_GUILD_QUERY_RESPONSE too small: ", packet.getSize());
@ -3939,7 +4018,8 @@ network::Packet GetMailListPacket::build(uint64_t mailboxGuid) {
network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& recipient,
const std::string& subject, const std::string& body,
uint32_t money, uint32_t cod) {
uint32_t money, uint32_t cod,
const std::vector<uint64_t>& itemGuids) {
// WotLK 3.3.5a format
network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL));
packet.writeUInt64(mailboxGuid);
@ -3948,7 +4028,12 @@ network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& r
packet.writeString(body);
packet.writeUInt32(0); // stationery
packet.writeUInt32(0); // unknown
packet.writeUInt8(0); // attachment count (0 = no attachments)
uint8_t attachCount = static_cast<uint8_t>(itemGuids.size());
packet.writeUInt8(attachCount);
for (uint8_t i = 0; i < attachCount; ++i) {
packet.writeUInt8(i); // attachment slot index
packet.writeUInt64(itemGuids[i]);
}
packet.writeUInt32(money);
packet.writeUInt32(cod);
return packet;

View file

@ -705,7 +705,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
// Load water immediately after terrain (same frame) — water is now
// deduplicated to ~1-2 merged surfaces per tile, so this is fast.
if (waterRenderer) {
size_t beforeSurfaces = waterRenderer->getSurfaceCount();
waterRenderer->loadFromTerrain(pending->terrain, true, x, y);
size_t afterSurfaces = waterRenderer->getSurfaceCount();
if (afterSurfaces > beforeSurfaces) {
LOG_INFO("Water: tile [", x, ",", y, "] added ", afterSurfaces - beforeSurfaces,
" surfaces (total: ", afterSurfaces, ")");
}
} else {
LOG_WARNING("Water: waterRenderer is null during tile [", x, ",", y, "] finalization!");
}
// Ensure M2 renderer has asset manager
@ -1230,13 +1238,35 @@ void TerrainManager::unloadTile(int x, int y) {
}
void TerrainManager::unloadAll() {
// Stop worker threads
// Signal worker threads to stop and wait briefly for them to finish.
// Workers may be mid-prepareTile (reading MPQ / parsing ADT) which can
// take seconds, so use a short deadline and detach any stragglers.
if (workerRunning.load()) {
workerRunning.store(false);
queueCV.notify_all();
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
for (auto& t : workerThreads) {
if (t.joinable()) {
t.join();
if (!t.joinable()) continue;
// Try a timed wait via polling — std::thread has no timed join.
bool joined = false;
while (std::chrono::steady_clock::now() < deadline) {
// Check if thread finished by trying a native timed join
#ifdef __linux__
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 50000000; // 50ms
if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; }
if (pthread_timedjoin_np(t.native_handle(), nullptr, &ts) == 0) {
joined = true;
break;
}
#else
std::this_thread::sleep_for(std::chrono::milliseconds(50));
#endif
}
if (!joined && t.joinable()) {
t.detach();
}
}
workerThreads.clear();
@ -1288,6 +1318,32 @@ void TerrainManager::unloadAll() {
}
}
void TerrainManager::softReset() {
// Clear queues (workers may still be running — they'll find empty queues)
{
std::lock_guard<std::mutex> lock(queueMutex);
loadQueue.clear();
while (!readyQueue.empty()) readyQueue.pop();
}
pendingTiles.clear();
finalizingTiles_.clear();
placedDoodadIds.clear();
LOG_INFO("Soft-resetting terrain (clearing tiles + water, workers stay alive)");
loadedTiles.clear();
failedTiles.clear();
currentTile = {-1, -1};
lastStreamTile = {-1, -1};
if (terrainRenderer) {
terrainRenderer->clear();
}
if (waterRenderer) {
waterRenderer->clear();
}
}
TileCoord TerrainManager::worldToTile(float glX, float glY) const {
auto [tileX, tileY] = core::coords::worldToTile(glX, glY);
return {tileX, tileY};

View file

@ -76,6 +76,7 @@ bool WaterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLay
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
poolInfo.maxSets = MAX_WATER_SETS;
poolInfo.poolSizeCount = 1;
poolInfo.pPoolSizes = &poolSize;
@ -541,6 +542,8 @@ void WaterRenderer::updateMaterialUBO(WaterSurface& surface) {
write.pBufferInfo = &bufInfo;
vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr);
} else {
LOG_WARNING("Water: failed to allocate material descriptor set (pool exhaustion?)");
}
}
@ -802,8 +805,10 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap
totalSurfaces++;
}
LOG_DEBUG("Water: Loaded ", totalSurfaces, " surfaces from tile [", tileX, ",", tileY,
"] (", mergeGroups.size(), " groups), total surfaces: ", surfaces.size());
if (totalSurfaces > 0) {
LOG_INFO("Water: Loaded ", totalSurfaces, " surfaces from tile [", tileX, ",", tileY,
"] (", mergeGroups.size(), " groups), total surfaces: ", surfaces.size());
}
}
void WaterRenderer::removeTile(int tileX, int tileY) {
@ -936,8 +941,21 @@ void WaterRenderer::clear() {
void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
const Camera& /*camera*/, float /*time*/, bool use1x) {
VkPipeline pipeline = (use1x && water1xPipeline) ? water1xPipeline : waterPipeline;
if (!renderingEnabled || surfaces.empty() || !pipeline) return;
if (!sceneSet) return;
if (!renderingEnabled || surfaces.empty() || !pipeline) {
if (renderDiagCounter_++ % 300 == 0 && !surfaces.empty()) {
LOG_WARNING("Water: render skipped — enabled=", renderingEnabled,
" surfaces=", surfaces.size(),
" pipeline=", (pipeline ? "ok" : "null"),
" use1x=", use1x);
}
return;
}
if (!sceneSet) {
if (renderDiagCounter_++ % 300 == 0) {
LOG_WARNING("Water: render skipped — sceneSet is null, surfaces=", surfaces.size());
}
return;
}
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
@ -1251,6 +1269,9 @@ void WaterRenderer::destroyWaterMesh(WaterSurface& surface) {
destroyBuffer(allocator, ab);
surface.materialUBO = VK_NULL_HANDLE;
}
if (surface.materialSet && materialDescPool) {
vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &surface.materialSet);
}
surface.materialSet = VK_NULL_HANDLE;
}

File diff suppressed because it is too large Load diff

View file

@ -543,6 +543,25 @@ bool InventoryScreen::dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipS
return !holdingItem;
}
void InventoryScreen::dropIntoBankSlot(game::GameHandler& /*gh*/, uint8_t dstBag, uint8_t dstSlot) {
if (!holdingItem || !gameHandler_) return;
uint8_t srcBag = 0xFF;
uint8_t srcSlot = 0;
if (heldSource == HeldSource::BACKPACK && heldBackpackIndex >= 0) {
srcSlot = static_cast<uint8_t>(23 + heldBackpackIndex);
} else if (heldSource == HeldSource::BAG) {
srcBag = static_cast<uint8_t>(19 + heldBagIndex);
srcSlot = static_cast<uint8_t>(heldBagSlotIndex);
} else if (heldSource == HeldSource::EQUIPMENT) {
srcSlot = static_cast<uint8_t>(heldEquipSlot);
} else {
return;
}
gameHandler_->swapContainerItems(srcBag, srcSlot, dstBag, dstSlot);
holdingItem = false;
inventoryDirty = true;
}
bool InventoryScreen::beginPickupFromEquipSlot(game::Inventory& inv, game::EquipSlot slot) {
if (holdingItem) return false;
const auto& eq = inv.getEquipSlot(slot);
@ -1402,13 +1421,22 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
}
// Right-click: vendor sell (if vendor mode) or auto-equip/use
// 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,
" backpackIndex=", backpackIndex,
" bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex,
" vendorMode=", vendorMode_);
if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) {
" vendorMode=", vendorMode_,
" bankOpen=", gameHandler_->isBankOpen());
if (gameHandler_->isMailComposeOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) {
gameHandler_->attachItemFromBackpack(backpackIndex);
} else if (gameHandler_->isMailComposeOpen() && kind == SlotKind::BACKPACK && isBagSlot) {
gameHandler_->attachItemFromBag(bagIndex, bagSlotIndex);
} else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) {
gameHandler_->depositItem(0xFF, static_cast<uint8_t>(23 + backpackIndex));
} else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && isBagSlot) {
gameHandler_->depositItem(static_cast<uint8_t>(19 + bagIndex), static_cast<uint8_t>(bagSlotIndex));
} else if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) {
gameHandler_->sellItemBySlot(backpackIndex);
} else if (vendorMode_ && kind == SlotKind::BACKPACK && isBagSlot) {
gameHandler_->sellItemInBag(bagIndex, bagSlotIndex);

View file

@ -9,9 +9,29 @@
#include "core/logger.hpp"
#include <algorithm>
#include <map>
#include <cctype>
namespace wowee { namespace ui {
// Case-insensitive substring match
static bool containsCI(const std::string& haystack, const char* needle) {
if (!needle || !needle[0]) return true;
size_t needleLen = strlen(needle);
if (needleLen > haystack.size()) return false;
for (size_t i = 0; i <= haystack.size() - needleLen; i++) {
bool match = true;
for (size_t j = 0; j < needleLen; j++) {
if (std::tolower(static_cast<unsigned char>(haystack[i + j])) !=
std::tolower(static_cast<unsigned char>(needle[j]))) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
if (dbcLoadAttempted) return;
dbcLoadAttempted = true;
@ -30,12 +50,11 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
return;
}
// Try expansion-specific layout first, then fall back to WotLK hardcoded indices
// if the DBC is from WotLK MPQs but the active expansion uses different field offsets.
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField,
uint32_t nameField, uint32_t rankField, const char* label) {
uint32_t nameField, uint32_t rankField, uint32_t tooltipField,
const char* label) {
spellData.clear();
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
@ -48,6 +67,7 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
info.iconId = dbc->getUInt32(i, iconField);
info.name = dbc->getString(i, nameField);
info.rank = dbc->getString(i, rankField);
info.description = dbc->getString(i, tooltipField);
if (!info.name.empty()) {
spellData[spellId] = std::move(info);
@ -56,17 +76,17 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc (", label, ")");
};
// Try active expansion layout
if (spellL) {
uint32_t tooltipField = 139;
// Try to get Tooltip field from layout, fall back to 139
try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {}
tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"],
(*spellL)["Name"], (*spellL)["Rank"], "expansion layout");
(*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout");
}
// If layout failed or loaded 0 spells, try WotLK hardcoded indices
// (binary DBC may be from WotLK MPQs regardless of active expansion)
if (spellData.empty() && fieldCount >= 200) {
LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)");
tryLoad(0, 4, 133, 136, 153, "WotLK fallback");
tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback");
}
dbcLoaded = !spellData.empty();
@ -88,10 +108,7 @@ void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("SpellIcon.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Spellbook: Could not load SpellIcon.dbc");
return;
}
if (!dbc || !dbc->isLoaded()) return;
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
@ -101,8 +118,6 @@ void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
spellIconPaths[id] = path;
}
}
LOG_INFO("Spellbook: Loaded ", spellIconPaths.size(), " spell icon paths");
}
void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
@ -111,7 +126,6 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
if (!assetManager || !assetManager->isInitialized()) return;
// Load SkillLine.dbc: field 0 = ID, field 1 = categoryID, field 3 = name_enUS
auto skillLineDbc = assetManager->loadDBC("SkillLine.dbc");
const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
if (skillLineDbc && skillLineDbc->isLoaded()) {
@ -124,12 +138,8 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
skillLineCategories[id] = category;
}
}
LOG_INFO("Spellbook: Loaded ", skillLineNames.size(), " skill lines");
} else {
LOG_WARNING("Spellbook: Could not load SkillLine.dbc");
}
// Load SkillLineAbility.dbc: field 0 = ID, field 1 = skillLineID, field 2 = spellID
auto slaDbc = assetManager->loadDBC("SkillLineAbility.dbc");
const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr;
if (slaDbc && slaDbc->isLoaded()) {
@ -140,20 +150,26 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) {
spellToSkillLine[spellId] = skillLineId;
}
}
LOG_INFO("Spellbook: Loaded ", spellToSkillLine.size(), " skill line abilities");
} else {
LOG_WARNING("Spellbook: Could not load SkillLineAbility.dbc");
}
}
void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& knownSpells) {
spellTabs.clear();
// Only SkillLine category 7 ("Class") gets its own tab (the 3 specialties).
// Everything else (weapons, professions, racials, general utilities) → General.
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
// SkillLine.dbc category IDs
static constexpr uint32_t CAT_CLASS = 7; // Class abilities (spec trees)
static constexpr uint32_t CAT_PROFESSION = 11; // Primary professions
static constexpr uint32_t CAT_SECONDARY = 9; // Secondary skills (Cooking, First Aid, Fishing, Riding, Companions)
std::map<uint32_t, std::vector<const SpellInfo*>> specialtySpells;
// 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
// Buckets
std::map<uint32_t, std::vector<const SpellInfo*>> specSpells; // class spec trees
std::map<uint32_t, std::vector<const SpellInfo*>> profSpells; // professions + secondary
std::vector<const SpellInfo*> mountSpells;
std::vector<const SpellInfo*> companionSpells;
std::vector<const SpellInfo*> generalSpells;
for (uint32_t spellId : knownSpells) {
@ -165,11 +181,41 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& known
auto slIt = spellToSkillLine.find(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(info);
// Mounts: Riding skill line (762)
if (skillLineId == SKILLLINE_RIDING) {
mountSpells.push_back(info);
continue;
}
// Companions: vanity pets skill line (778)
if (skillLineId == SKILLLINE_COMPANIONS) {
companionSpells.push_back(info);
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;
}
}
}
generalSpells.push_back(info);
@ -177,28 +223,47 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set<uint32_t>& known
auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; };
// Specialty tabs sorted alphabetically by skill line name
std::vector<std::pair<std::string, std::vector<const SpellInfo*>>> 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; });
// Helper: add sorted skill-line-grouped tabs
auto addGroupedTabs = [&](std::map<uint32_t, std::vector<const SpellInfo*>>& groups,
const char* fallbackName) {
std::vector<std::pair<std::string, std::vector<const SpellInfo*>>> named;
for (auto& [skillLineId, spells] : groups) {
auto nameIt = skillLineNames.find(skillLineId);
std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : fallbackName;
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) {
spellTabs.push_back({std::move(name), std::move(spells)});
}
};
for (auto& [name, spells] : named) {
spellTabs.push_back({std::move(name), std::move(spells)});
}
// 1. Class spec tabs
addGroupedTabs(specSpells, "Spec");
// General tab last
// 2. General tab
if (!generalSpells.empty()) {
std::sort(generalSpells.begin(), generalSpells.end(), byName);
spellTabs.push_back({"General", std::move(generalSpells)});
}
// 3. Professions tabs
addGroupedTabs(profSpells, "Profession");
// 4. Mounts tab
if (!mountSpells.empty()) {
std::sort(mountSpells.begin(), mountSpells.end(), byName);
spellTabs.push_back({"Mounts", std::move(mountSpells)});
}
// 5. Companions tab
if (!companionSpells.empty()) {
std::sort(companionSpells.begin(), companionSpells.end(), byName);
spellTabs.push_back({"Companions", std::move(companionSpells)});
}
lastKnownSpellCount = knownSpells.size();
}
@ -244,6 +309,47 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const {
return (it != spellData.end()) ? &it->second : nullptr;
}
void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(320.0f);
// Spell name in yellow
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", info->name.c_str());
// Rank in gray
if (!info->rank.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "(%s)", info->rank.c_str());
}
// Passive indicator
if (info->isPassive()) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive");
}
// Cooldown if active
float cd = gameHandler.getSpellCooldown(info->spellId);
if (cd > 0.0f) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1fs", cd);
}
// Description
if (!info->description.empty()) {
ImGui::Spacing();
ImGui::TextWrapped("%s", info->description.c_str());
}
// Usage hints
if (!info->isPassive()) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar");
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast");
}
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetManager* assetManager) {
// P key toggle (edge-triggered)
bool wantsTextInput = ImGui::GetIO().WantTextInput;
@ -272,88 +378,156 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float bookW = 360.0f;
float bookH = std::min(520.0f, screenH - 120.0f);
float bookW = 380.0f;
float bookH = std::min(560.0f, screenH - 100.0f);
float bookX = screenW - bookW - 10.0f;
float bookY = 80.0f;
ImGui::SetNextWindowPos(ImVec2(bookX, bookY), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(bookW, bookH), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSizeConstraints(ImVec2(280, 200), ImVec2(screenW, screenH));
ImGui::SetNextWindowSizeConstraints(ImVec2(300, 250), ImVec2(screenW, screenH));
bool windowOpen = open;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8));
if (ImGui::Begin("Spellbook", &windowOpen)) {
// Clamp window position to stay on screen
ImVec2 winPos = ImGui::GetWindowPos();
ImVec2 winSize = ImGui::GetWindowSize();
float clampedX = std::max(0.0f, std::min(winPos.x, screenW - winSize.x));
float clampedY = std::max(0.0f, std::min(winPos.y, screenH - winSize.y));
if (clampedX != winPos.x || clampedY != winPos.y) {
ImGui::SetWindowPos(ImVec2(clampedX, clampedY));
}
// Search bar
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##search", "Search spells...", searchFilter_, sizeof(searchFilter_));
ImGui::Spacing();
// Tab bar
if (ImGui::BeginTabBar("SpellbookTabs")) {
for (size_t tabIdx = 0; tabIdx < spellTabs.size(); tabIdx++) {
const auto& tab = spellTabs[tabIdx];
char tabLabel[64];
snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)",
tab.name.c_str(), tab.spells.size());
// Count visible spells (respecting search filter)
int visibleCount = 0;
for (const SpellInfo* info : tab.spells) {
if (containsCI(info->name, searchFilter_)) visibleCount++;
}
char tabLabel[128];
snprintf(tabLabel, sizeof(tabLabel), "%s (%d)###sbtab%zu",
tab.name.c_str(), visibleCount, tabIdx);
if (ImGui::BeginTabItem(tabLabel)) {
if (tab.spells.empty()) {
ImGui::TextDisabled("No spells in this category.");
if (visibleCount == 0) {
if (searchFilter_[0])
ImGui::TextDisabled("No matching spells.");
else
ImGui::TextDisabled("No spells in this category.");
}
ImGui::BeginChild("SpellList", ImVec2(0, 0), true);
float iconSize = 32.0f;
const float iconSize = 36.0f;
const float rowHeight = iconSize + 4.0f;
for (const SpellInfo* info : tab.spells) {
// Apply search filter
if (!containsCI(info->name, searchFilter_)) continue;
ImGui::PushID(static_cast<int>(info->spellId));
float cd = gameHandler.getSpellCooldown(info->spellId);
bool onCooldown = cd > 0.0f;
bool isPassive = info->isPassive();
bool isDim = isPassive || onCooldown;
VkDescriptorSet iconTex = getSpellIcon(info->iconId, assetManager);
// Selectable consumes clicks properly (prevents window drag)
// Row selectable
ImGui::Selectable("##row", false,
ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, iconSize));
ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight));
bool rowHovered = ImGui::IsItemHovered();
bool rowClicked = ImGui::IsItemClicked(0);
ImVec2 rMin = ImGui::GetItemRectMin();
ImVec2 rMax = ImGui::GetItemRectMax();
auto* dl = ImGui::GetWindowDrawList();
// Draw icon on top of selectable
// Hover highlight
if (rowHovered) {
dl->AddRectFilled(rMin, rMax, IM_COL32(255, 255, 255, 15), 3.0f);
}
// Icon background
ImVec2 iconMin = rMin;
ImVec2 iconMax(rMin.x + iconSize, rMin.y + iconSize);
dl->AddRectFilled(iconMin, iconMax, IM_COL32(25, 25, 35, 200), 3.0f);
// Icon
if (iconTex) {
ImU32 tint = (isPassive || onCooldown) ? IM_COL32(150, 150, 150, 255) : IM_COL32(255, 255, 255, 255);
dl->AddImage((ImTextureID)(uintptr_t)iconTex,
rMin, ImVec2(rMin.x + iconSize, rMin.y + iconSize));
} else {
dl->AddRectFilled(rMin,
ImVec2(rMin.x + iconSize, rMin.y + iconSize),
IM_COL32(60, 60, 80, 255));
ImVec2(iconMin.x + 1, iconMin.y + 1),
ImVec2(iconMax.x - 1, iconMax.y - 1),
ImVec2(0, 0), ImVec2(1, 1), tint);
}
// Draw name and rank text
ImU32 textCol = isDim ? IM_COL32(153, 153, 153, 255)
: ImGui::GetColorU32(ImGuiCol_Text);
ImU32 dimCol = ImGui::GetColorU32(ImGuiCol_TextDisabled);
float textX = rMin.x + iconSize + 4.0f;
dl->AddText(ImVec2(textX, rMin.y), textCol, info->name.c_str());
if (!info->rank.empty()) {
dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()),
dimCol, info->rank.c_str());
// Icon border
ImU32 borderCol;
if (isPassive) {
borderCol = IM_COL32(180, 180, 50, 200); // Yellow for passive
} else if (onCooldown) {
char cdBuf[32];
snprintf(cdBuf, sizeof(cdBuf), "%.1fs cooldown", cd);
dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()),
dimCol, cdBuf);
borderCol = IM_COL32(120, 40, 40, 200); // Red for cooldown
} else {
borderCol = IM_COL32(100, 100, 120, 200); // Default border
}
dl->AddRect(iconMin, iconMax, borderCol, 3.0f, 0, 1.5f);
// Cooldown overlay on icon
if (onCooldown) {
// Darkened sweep
dl->AddRectFilled(iconMin, iconMax, IM_COL32(0, 0, 0, 120), 3.0f);
// Cooldown text centered on icon
char cdBuf[16];
snprintf(cdBuf, sizeof(cdBuf), "%.0f", cd);
ImVec2 cdSize = ImGui::CalcTextSize(cdBuf);
ImVec2 cdPos(iconMin.x + (iconSize - cdSize.x) * 0.5f,
iconMin.y + (iconSize - cdSize.y) * 0.5f);
dl->AddText(ImVec2(cdPos.x + 1, cdPos.y + 1), IM_COL32(0, 0, 0, 255), cdBuf);
dl->AddText(cdPos, IM_COL32(255, 80, 80, 255), cdBuf);
}
// Spell name
float textX = rMin.x + iconSize + 8.0f;
float nameY = rMin.y + 2.0f;
ImU32 nameCol;
if (isPassive) {
nameCol = IM_COL32(255, 255, 130, 255); // Yellow-ish for passive
} else if (onCooldown) {
nameCol = IM_COL32(150, 150, 150, 255);
} else {
nameCol = IM_COL32(255, 255, 255, 255);
}
dl->AddText(ImVec2(textX, nameY), nameCol, info->name.c_str());
// Second line: rank or passive/cooldown indicator
float subY = nameY + ImGui::GetTextLineHeight() + 1.0f;
if (!info->rank.empty()) {
dl->AddText(ImVec2(textX, subY),
IM_COL32(150, 150, 150, 255), info->rank.c_str());
}
if (isPassive) {
float afterRank = textX;
if (!info->rank.empty()) {
afterRank += ImGui::CalcTextSize(info->rank.c_str()).x + 8.0f;
}
dl->AddText(ImVec2(afterRank, subY),
IM_COL32(200, 200, 80, 200), "Passive");
} else if (onCooldown) {
float afterRank = textX;
if (!info->rank.empty()) {
afterRank += ImGui::CalcTextSize(info->rank.c_str()).x + 8.0f;
}
char cdText[32];
snprintf(cdText, sizeof(cdText), "%.1fs", cd);
dl->AddText(ImVec2(afterRank, subY),
IM_COL32(255, 100, 100, 200), cdText);
}
// Interaction
if (rowHovered) {
// Start drag on click (not passive)
if (rowClicked && !isPassive) {
@ -362,31 +536,18 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
dragSpellIconTex_ = iconTex;
}
// Double-click to cast
if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) {
draggingSpell_ = false;
dragSpellId_ = 0;
dragSpellIconTex_ = 0;
dragSpellIconTex_ = VK_NULL_HANDLE;
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(info->spellId, target);
}
// Tooltip (only when not dragging)
if (!draggingSpell_) {
ImGui::BeginTooltip();
ImGui::Text("%s", info->name.c_str());
if (!info->rank.empty()) {
ImGui::TextDisabled("%s", info->rank.c_str());
}
ImGui::TextDisabled("Spell ID: %u", info->spellId);
if (isPassive) {
ImGui::TextDisabled("Passive");
} else {
ImGui::TextDisabled("Drag to action bar to assign");
if (!onCooldown) {
ImGui::TextDisabled("Double-click to cast");
}
}
ImGui::EndTooltip();
renderSpellTooltip(info, gameHandler);
}
}
@ -402,6 +563,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
}
}
ImGui::End();
ImGui::PopStyleVar();
if (!windowOpen) {
open = false;
@ -410,7 +572,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
// Render dragged spell icon at cursor
if (draggingSpell_ && dragSpellId_ != 0) {
ImVec2 mousePos = ImGui::GetMousePos();
float dragSize = 32.0f;
float dragSize = 36.0f;
if (dragSpellIconTex_) {
ImGui::GetForegroundDrawList()->AddImage(
(ImTextureID)(uintptr_t)dragSpellIconTex_,
@ -420,14 +582,13 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
ImGui::GetForegroundDrawList()->AddRectFilled(
ImVec2(mousePos.x - dragSize * 0.5f, mousePos.y - dragSize * 0.5f),
ImVec2(mousePos.x + dragSize * 0.5f, mousePos.y + dragSize * 0.5f),
IM_COL32(80, 80, 120, 180));
IM_COL32(80, 80, 120, 180), 3.0f);
}
// Cancel drag on mouse release (action bar consumes it before this if dropped on a slot)
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
draggingSpell_ = false;
dragSpellId_ = 0;
dragSpellIconTex_ = 0;
dragSpellIconTex_ = VK_NULL_HANDLE;
}
}
}

View file

@ -7,9 +7,20 @@
#include "pipeline/blp_loader.hpp"
#include "pipeline/dbc_layout.hpp"
#include <algorithm>
#include <cmath>
namespace wowee { namespace ui {
// WoW class names indexed by class ID (1-11)
static const char* classNames[] = {
"Unknown", "Warrior", "Paladin", "Hunter", "Rogue", "Priest",
"Death Knight", "Shaman", "Mage", "Warlock", "Unknown", "Druid"
};
static const char* getClassName(uint8_t classId) {
return (classId >= 1 && classId <= 11) ? classNames[classId] : "Unknown";
}
void TalentScreen::render(game::GameHandler& gameHandler) {
// N key toggle (edge-triggered)
bool wantsTextInput = ImGui::GetIO().WantTextInput;
@ -25,19 +36,28 @@ void TalentScreen::render(game::GameHandler& gameHandler) {
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float winW = 600.0f; // Wider for talent grid
float winH = 550.0f;
float winW = 680.0f;
float winH = 600.0f;
float winX = (screenW - winW) * 0.5f;
float winY = (screenH - winH) * 0.5f;
ImGui::SetNextWindowPos(ImVec2(winX, winY), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_FirstUseEver);
// Build title with point distribution
uint8_t playerClass = gameHandler.getPlayerClass();
std::string title = "Talents";
if (playerClass > 0) {
title = std::string(getClassName(playerClass)) + " Talents";
}
bool windowOpen = open;
if (ImGui::Begin("Talents", &windowOpen)) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8));
if (ImGui::Begin(title.c_str(), &windowOpen)) {
renderTalentTrees(gameHandler);
}
ImGui::End();
ImGui::PopStyleVar();
if (!windowOpen) {
open = false;
@ -47,93 +67,95 @@ void TalentScreen::render(game::GameHandler& gameHandler) {
void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
auto* assetManager = core::Application::getInstance().getAssetManager();
// Ensure talent DBCs are loaded (even if server hasn't sent SMSG_TALENTS_INFO)
// Ensure talent DBCs are loaded once
static bool dbcLoadAttempted = false;
if (!dbcLoadAttempted) {
dbcLoadAttempted = true;
gameHandler.loadTalentDbc();
loadSpellDBC(assetManager);
loadSpellIconDBC(assetManager);
LOG_INFO("Talent window opened, DBC load triggered");
}
uint8_t playerClass = gameHandler.getPlayerClass();
LOG_INFO("Talent window: playerClass=", static_cast<int>(playerClass));
// Active spec indicator and switcher
uint8_t activeSpec = gameHandler.getActiveTalentSpec();
ImGui::Text("Active Spec: %u", activeSpec + 1);
ImGui::SameLine();
// Spec buttons
if (ImGui::SmallButton("Spec 1")) {
gameHandler.switchTalentSpec(0);
}
ImGui::SameLine();
if (ImGui::SmallButton("Spec 2")) {
gameHandler.switchTalentSpec(1);
}
ImGui::SameLine();
// Show unspent points for both specs
ImGui::Text("| Unspent: Spec1=%u Spec2=%u",
gameHandler.getUnspentTalentPoints(0),
gameHandler.getUnspentTalentPoints(1));
ImGui::Separator();
// Debug info
ImGui::Text("Player Class: %u", playerClass);
ImGui::Text("Total Talent Tabs: %zu", gameHandler.getAllTalentTabs().size());
ImGui::Text("Total Talents: %zu", gameHandler.getAllTalents().size());
ImGui::Separator();
if (playerClass == 0) {
ImGui::TextDisabled("Class information not available.");
LOG_WARNING("Talent window: getPlayerClass() returned 0");
return;
}
// Get talent tabs for this class (class mask: 1 << (class - 1))
// Get talent tabs for this class, sorted by orderIndex
uint32_t classMask = 1u << (playerClass - 1);
LOG_INFO("Talent window: classMask=0x", std::hex, classMask, std::dec);
// Collect talent tabs for this class, sorted by orderIndex
std::vector<const game::GameHandler::TalentTabEntry*> classTabs;
for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) {
if (tab.classMask & classMask) {
classTabs.push_back(&tab);
}
}
std::sort(classTabs.begin(), classTabs.end(),
[](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; });
LOG_INFO("Talent window: found ", classTabs.size(), " tabs for class mask 0x", std::hex, classMask, std::dec);
ImGui::Text("Class Mask: 0x%X", classMask);
ImGui::Text("Tabs for this class: %zu", classTabs.size());
if (classTabs.empty()) {
ImGui::TextDisabled("No talent trees available for your class.");
ImGui::Spacing();
ImGui::TextDisabled("Available tabs:");
for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) {
ImGui::Text(" Tab %u: %s (mask: 0x%X)", tabId, tab.name.c_str(), tab.classMask);
}
return;
}
// Display points
uint8_t unspentPoints = gameHandler.getUnspentTalentPoints();
ImGui::Text("Unspent Points: %u", unspentPoints);
// Compute points-per-tree for display
uint32_t treeTotals[3] = {0, 0, 0};
for (size_t ti = 0; ti < classTabs.size() && ti < 3; ti++) {
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == classTabs[ti]->tabId) {
treeTotals[ti] += rank;
}
}
}
// Header: spec switcher + unspent points + point distribution
uint8_t activeSpec = gameHandler.getActiveTalentSpec();
uint8_t unspent = gameHandler.getUnspentTalentPoints();
// Spec buttons
for (uint8_t s = 0; s < 2; s++) {
if (s > 0) ImGui::SameLine();
bool isActive = (s == activeSpec);
if (isActive) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.8f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.6f, 0.9f, 1.0f));
}
char specLabel[32];
snprintf(specLabel, sizeof(specLabel), "Spec %u", s + 1);
if (ImGui::Button(specLabel, ImVec2(70, 0))) {
if (!isActive) gameHandler.switchTalentSpec(s);
}
if (isActive) ImGui::PopStyleColor(2);
}
// Point distribution
ImGui::SameLine(0, 20);
if (classTabs.size() >= 3) {
ImGui::Text("(%u / %u / %u)", treeTotals[0], treeTotals[1], treeTotals[2]);
}
// Unspent points
ImGui::SameLine(0, 20);
if (unspent > 0) {
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "%u point%s available",
unspent, unspent > 1 ? "s" : "");
} else {
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "No points available");
}
ImGui::Separator();
// Render tabs
// Render tabs with point counts in tab labels
if (ImGui::BeginTabBar("TalentTabs")) {
for (const auto* tab : classTabs) {
if (ImGui::BeginTabItem(tab->name.c_str())) {
renderTalentTree(gameHandler, tab->tabId);
for (size_t ti = 0; ti < classTabs.size(); ti++) {
const auto* tab = classTabs[ti];
char tabLabel[128];
uint32_t pts = (ti < 3) ? treeTotals[ti] : 0;
snprintf(tabLabel, sizeof(tabLabel), "%s (%u)###tab%u", tab->name.c_str(), pts, tab->tabId);
if (ImGui::BeginTabItem(tabLabel)) {
renderTalentTree(gameHandler, tab->tabId, tab->backgroundFile);
ImGui::EndTabItem();
}
}
@ -141,7 +163,10 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
}
}
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId) {
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId,
const std::string& bgFile) {
auto* assetManager = core::Application::getInstance().getAssetManager();
// Collect all talents for this tab
std::vector<const game::GameHandler::TalentEntry*> talents;
for (const auto& [talentId, talent] : gameHandler.getAllTalents()) {
@ -155,25 +180,132 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
return;
}
// Sort talents by row then column for consistent rendering
std::sort(talents.begin(), talents.end(), [](const auto* a, const auto* b) {
if (a->row != b->row) return a->row < b->row;
return a->column < b->column;
});
// Find grid dimensions
uint8_t maxRow = 0, maxCol = 0;
for (const auto* talent : talents) {
maxRow = std::max(maxRow, talent->row);
maxCol = std::max(maxCol, talent->column);
}
// WoW talent grids are always 4 columns wide
if (maxCol < 3) maxCol = 3;
const float iconSize = 40.0f;
const float spacing = 8.0f;
const float cellSize = iconSize + spacing;
const float gridWidth = (maxCol + 1) * cellSize + spacing;
const float gridHeight = (maxRow + 1) * cellSize + spacing;
// Points in this tree
uint32_t pointsInTree = 0;
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == tabId) {
pointsInTree += rank;
}
}
// Center the grid
float availW = ImGui::GetContentRegionAvail().x;
float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f);
ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false);
// Render grid
for (uint8_t row = 0; row <= maxRow; ++row) {
// Row label
ImGui::Text("Tier %u", row);
ImGui::SameLine(80);
ImVec2 gridOrigin = ImGui::GetCursorScreenPos();
gridOrigin.x += offsetX;
// Draw background texture if available
if (!bgFile.empty() && assetManager) {
VkDescriptorSet bgTex = VK_NULL_HANDLE;
auto bgIt = bgTextureCache_.find(tabId);
if (bgIt != bgTextureCache_.end()) {
bgTex = bgIt->second;
} else {
// Try to load the background texture
std::string bgPath = bgFile;
// Normalize path separators
for (auto& c : bgPath) { if (c == '\\') c = '/'; }
bgPath += ".blp";
auto blpData = assetManager->readFile(bgPath);
if (!blpData.empty()) {
auto image = pipeline::BLPLoader::load(blpData);
if (image.isValid()) {
auto* window = core::Application::getInstance().getWindow();
auto* vkCtx = window ? window->getVkContext() : nullptr;
if (vkCtx) {
bgTex = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
}
}
}
bgTextureCache_[tabId] = bgTex;
}
if (bgTex) {
auto* drawList = ImGui::GetWindowDrawList();
float bgW = gridWidth + spacing * 2;
float bgH = gridHeight + spacing * 2;
drawList->AddImage((ImTextureID)(uintptr_t)bgTex,
ImVec2(gridOrigin.x - spacing, gridOrigin.y - spacing),
ImVec2(gridOrigin.x + bgW - spacing, gridOrigin.y + bgH - spacing),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 255, 60)); // Subtle background
}
}
// Build a position lookup for prerequisite arrows
struct TalentPos {
const game::GameHandler::TalentEntry* talent;
ImVec2 center;
};
std::unordered_map<uint32_t, TalentPos> talentPositions;
// First pass: compute positions
for (const auto* talent : talents) {
float x = gridOrigin.x + talent->column * cellSize + spacing;
float y = gridOrigin.y + talent->row * cellSize + spacing;
ImVec2 center(x + iconSize * 0.5f, y + iconSize * 0.5f);
talentPositions[talent->talentId] = {talent, center};
}
// Draw prerequisite arrows
auto* drawList = ImGui::GetWindowDrawList();
for (const auto* talent : talents) {
for (int i = 0; i < 3; ++i) {
if (talent->prereqTalent[i] == 0) continue;
auto fromIt = talentPositions.find(talent->prereqTalent[i]);
auto toIt = talentPositions.find(talent->talentId);
if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue;
uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]);
bool met = prereqRank >= talent->prereqRank[i];
ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150);
ImVec2 from = fromIt->second.center;
ImVec2 to = toIt->second.center;
// Draw line from bottom of prerequisite to top of dependent
ImVec2 lineStart(from.x, from.y + iconSize * 0.5f);
ImVec2 lineEnd(to.x, to.y - iconSize * 0.5f);
drawList->AddLine(lineStart, lineEnd, lineCol, 2.0f);
// Arrow head
float arrowSize = 5.0f;
drawList->AddTriangleFilled(
ImVec2(lineEnd.x, lineEnd.y),
ImVec2(lineEnd.x - arrowSize, lineEnd.y - arrowSize * 1.5f),
ImVec2(lineEnd.x + arrowSize, lineEnd.y - arrowSize * 1.5f),
lineCol);
}
}
// Render talent icons
for (uint8_t row = 0; row <= maxRow; ++row) {
for (uint8_t col = 0; col <= maxCol; ++col) {
// Find talent at this position
const game::GameHandler::TalentEntry* talent = nullptr;
for (const auto* t : talents) {
if (t->row == row && t->column == col) {
@ -182,23 +314,31 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
}
}
if (col > 0) ImGui::SameLine();
float x = gridOrigin.x + col * cellSize + spacing;
float y = gridOrigin.y + row * cellSize + spacing;
ImGui::SetCursorScreenPos(ImVec2(x, y));
if (talent) {
renderTalent(gameHandler, *talent);
renderTalent(gameHandler, *talent, pointsInTree);
} else {
// Empty slot
ImGui::InvisibleButton(("empty_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
ImVec2(iconSize, iconSize));
// Empty cell — invisible placeholder
ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
ImVec2(iconSize, iconSize));
}
}
}
// Reserve space for the full grid so scrolling works
ImGui::SetCursorScreenPos(ImVec2(gridOrigin.x, gridOrigin.y + gridHeight));
ImGui::Dummy(ImVec2(gridWidth, 0));
ImGui::EndChild();
}
void TalentScreen::renderTalent(game::GameHandler& gameHandler,
const game::GameHandler::TalentEntry& talent) {
const game::GameHandler::TalentEntry& talent,
uint32_t pointsInTree) {
auto* assetManager = core::Application::getInstance().getAssetManager();
uint8_t currentRank = gameHandler.getTalentRank(talent.talentId);
@ -220,38 +360,35 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
}
}
// Check tier requirement (need 5 points in previous tier)
// Check tier requirement (need row*5 points in tree)
if (talent.row > 0) {
// Count points spent in this tree
uint32_t pointsInTree = 0;
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == talent.tabId) {
pointsInTree += rank;
}
}
uint32_t requiredPoints = talent.row * 5;
if (pointsInTree < requiredPoints) {
canLearn = false;
}
}
// Determine state color and tint
// Determine visual state
enum TalentState { MAXED, PARTIAL, AVAILABLE, LOCKED };
TalentState state;
if (currentRank >= talent.maxRank) {
state = MAXED;
} else if (currentRank > 0) {
state = PARTIAL;
} else if (canLearn && prereqsMet) {
state = AVAILABLE;
} else {
state = LOCKED;
}
// Colors per state
ImVec4 borderColor;
ImVec4 tint;
if (currentRank == talent.maxRank) {
borderColor = ImVec4(0.3f, 0.9f, 0.3f, 1.0f); // Green border (maxed)
tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color
} else if (currentRank > 0) {
borderColor = ImVec4(1.0f, 0.9f, 0.3f, 1.0f); // Yellow border (partial)
tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color
} else if (canLearn && prereqsMet) {
borderColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White border (available)
tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color
} else {
borderColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); // Gray border (locked)
tint = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); // Desaturated
switch (state) {
case MAXED: borderColor = ImVec4(0.2f, 0.9f, 0.2f, 1.0f); tint = ImVec4(1,1,1,1); break;
case PARTIAL: borderColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); tint = ImVec4(1,1,1,1); break;
case AVAILABLE:borderColor = ImVec4(1.0f, 1.0f, 1.0f, 0.8f); tint = ImVec4(1,1,1,1); break;
case LOCKED: borderColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); tint = ImVec4(0.4f,0.4f,0.4f,1); break;
}
const float iconSize = 40.0f;
@ -267,60 +404,76 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
}
}
// Use InvisibleButton for click handling
bool clicked = ImGui::InvisibleButton("##talent", ImVec2(iconSize, iconSize));
// Click target
bool clicked = ImGui::InvisibleButton("##t", ImVec2(iconSize, iconSize));
bool hovered = ImGui::IsItemHovered();
// Draw icon and border
ImVec2 pMin = ImGui::GetItemRectMin();
ImVec2 pMax = ImGui::GetItemRectMax();
auto* drawList = ImGui::GetWindowDrawList();
auto* dl = ImGui::GetWindowDrawList();
// Background fill
ImU32 bgCol;
if (state == LOCKED) {
bgCol = IM_COL32(20, 20, 25, 200);
} else {
bgCol = IM_COL32(30, 30, 40, 200);
}
dl->AddRectFilled(pMin, pMax, bgCol, 3.0f);
// Icon
if (iconTex) {
ImU32 tintCol = IM_COL32(
static_cast<int>(tint.x * 255), static_cast<int>(tint.y * 255),
static_cast<int>(tint.z * 255), static_cast<int>(tint.w * 255));
dl->AddImage((ImTextureID)(uintptr_t)iconTex,
ImVec2(pMin.x + 2, pMin.y + 2),
ImVec2(pMax.x - 2, pMax.y - 2),
ImVec2(0, 0), ImVec2(1, 1), tintCol);
}
// Border
float borderThickness = hovered ? 3.0f : 2.0f;
ImU32 borderCol = IM_COL32(borderColor.x * 255, borderColor.y * 255, borderColor.z * 255, 255);
drawList->AddRect(pMin, pMax, borderCol, 0.0f, 0, borderThickness);
float borderThick = hovered ? 2.5f : 1.5f;
ImU32 borderCol = IM_COL32(
static_cast<int>(borderColor.x * 255), static_cast<int>(borderColor.y * 255),
static_cast<int>(borderColor.z * 255), static_cast<int>(borderColor.w * 255));
dl->AddRect(pMin, pMax, borderCol, 3.0f, 0, borderThick);
// Icon or colored background
if (iconTex) {
ImU32 tintCol = IM_COL32(tint.x * 255, tint.y * 255, tint.z * 255, tint.w * 255);
drawList->AddImage((ImTextureID)(uintptr_t)iconTex,
ImVec2(pMin.x + 2, pMin.y + 2),
ImVec2(pMax.x - 2, pMax.y - 2),
ImVec2(0, 0), ImVec2(1, 1), tintCol);
} else {
ImU32 bgCol = IM_COL32(borderColor.x * 80, borderColor.y * 80, borderColor.z * 80, 255);
drawList->AddRectFilled(ImVec2(pMin.x + 2, pMin.y + 2),
ImVec2(pMax.x - 2, pMax.y - 2), bgCol);
// Hover glow
if (hovered && state != LOCKED) {
dl->AddRect(ImVec2(pMin.x - 1, pMin.y - 1), ImVec2(pMax.x + 1, pMax.y + 1),
IM_COL32(255, 255, 255, 60), 3.0f, 0, 1.0f);
}
// Rank indicator overlay
if (talent.maxRank > 1) {
ImVec2 pMax = ImGui::GetItemRectMax();
auto* drawList = ImGui::GetWindowDrawList();
// Display rank: if learned, show (rank+1) since ranks are 0-indexed
const auto& learned = gameHandler.getLearnedTalents();
uint8_t displayRank = (learned.find(talent.talentId) != learned.end()) ? currentRank + 1 : 0;
// Rank counter (bottom-right corner)
{
char rankText[16];
snprintf(rankText, sizeof(rankText), "%u/%u", displayRank, talent.maxRank);
snprintf(rankText, sizeof(rankText), "%u/%u", currentRank, talent.maxRank);
ImVec2 textSize = ImGui::CalcTextSize(rankText);
ImVec2 textPos(pMax.x - textSize.x - 2, pMax.y - textSize.y - 2);
ImVec2 textPos(pMax.x - textSize.x - 2, pMax.y - textSize.y - 1);
// Shadow
drawList->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText);
// Text
ImU32 rankCol = displayRank == talent.maxRank ? IM_COL32(0, 255, 0, 255) :
displayRank > 0 ? IM_COL32(255, 255, 0, 255) :
IM_COL32(255, 255, 255, 255);
drawList->AddText(textPos, rankCol, rankText);
// Background pill for readability
dl->AddRectFilled(ImVec2(textPos.x - 2, textPos.y - 1),
ImVec2(pMax.x, pMax.y),
IM_COL32(0, 0, 0, 180), 2.0f);
// Text shadow
dl->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText);
// Rank text color
ImU32 rankCol;
switch (state) {
case MAXED: rankCol = IM_COL32(80, 255, 80, 255); break;
case PARTIAL: rankCol = IM_COL32(80, 255, 80, 255); break;
default: rankCol = IM_COL32(200, 200, 200, 255); break;
}
dl->AddText(textPos, rankCol, rankText);
}
// Enhanced tooltip
// Tooltip
if (hovered) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(320.0f);
// Spell name
const std::string& spellName = gameHandler.getSpellName(spellId);
@ -330,60 +483,55 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Talent #%u", talent.talentId);
}
// Rank
ImGui::TextColored(borderColor, "Rank %u/%u", currentRank, talent.maxRank);
// Rank display
ImVec4 rankColor;
switch (state) {
case MAXED: rankColor = ImVec4(0.3f, 0.9f, 0.3f, 1); break;
case PARTIAL: rankColor = ImVec4(0.3f, 0.9f, 0.3f, 1); break;
default: rankColor = ImVec4(0.7f, 0.7f, 0.7f, 1); break;
}
ImGui::TextColored(rankColor, "Rank %u/%u", currentRank, talent.maxRank);
// Current rank description
if (currentRank > 0 && talent.rankSpells[currentRank - 1] != 0) {
if (currentRank > 0 && currentRank <= 5 && talent.rankSpells[currentRank - 1] != 0) {
auto tooltipIt = spellTooltips.find(talent.rankSpells[currentRank - 1]);
if (tooltipIt != spellTooltips.end() && !tooltipIt->second.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 300.0f);
ImGui::Spacing();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Current:");
ImGui::TextWrapped("%s", tooltipIt->second.c_str());
ImGui::PopTextWrapPos();
}
}
// Next rank description
if (currentRank < talent.maxRank && talent.rankSpells[currentRank] != 0) {
if (currentRank < talent.maxRank && currentRank < 5 && talent.rankSpells[currentRank] != 0) {
auto tooltipIt = spellTooltips.find(talent.rankSpells[currentRank]);
if (tooltipIt != spellTooltips.end() && !tooltipIt->second.empty()) {
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 300.0f);
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Next Rank:");
ImGui::TextWrapped("%s", tooltipIt->second.c_str());
ImGui::PopTextWrapPos();
}
}
// Prerequisites
for (int i = 0; i < 3; ++i) {
if (talent.prereqTalent[i] != 0) {
const auto* prereq = gameHandler.getTalentEntry(talent.prereqTalent[i]);
if (prereq && prereq->rankSpells[0] != 0) {
uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
bool met = prereqCurrentRank >= talent.prereqRank[i];
ImVec4 prereqColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
if (talent.prereqTalent[i] == 0) continue;
const auto* prereq = gameHandler.getTalentEntry(talent.prereqTalent[i]);
if (!prereq || prereq->rankSpells[0] == 0) continue;
const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]);
ImGui::Spacing();
ImGui::TextColored(prereqColor, "Requires %u point%s in %s",
talent.prereqRank[i],
talent.prereqRank[i] > 1 ? "s" : "",
prereqName.empty() ? "prerequisite" : prereqName.c_str());
}
}
uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
bool met = prereqCurrentRank >= talent.prereqRank[i];
ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1);
const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]);
ImGui::Spacing();
ImGui::TextColored(pColor, "Requires %u point%s in %s",
talent.prereqRank[i],
talent.prereqRank[i] > 1 ? "s" : "",
prereqName.empty() ? "prerequisite" : prereqName.c_str());
}
// Tier requirement
if (talent.row > 0 && currentRank == 0) {
uint32_t pointsInTree = 0;
for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) {
const auto* t = gameHandler.getTalentEntry(tid);
if (t && t->tabId == talent.tabId) {
pointsInTree += rank;
}
}
uint32_t requiredPoints = talent.row * 5;
if (pointsInTree < requiredPoints) {
ImGui::Spacing();
@ -397,38 +545,22 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
if (canLearn && prereqsMet) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Click to learn");
} else if (currentRank >= talent.maxRank) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Maxed");
}
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
// Handle click
if (clicked) {
LOG_INFO("Talent clicked: id=", talent.talentId, " canLearn=", canLearn, " prereqsMet=", prereqsMet,
" currentRank=", static_cast<int>(currentRank), " maxRank=", static_cast<int>(talent.maxRank),
" unspent=", static_cast<int>(gameHandler.getUnspentTalentPoints()));
if (canLearn && prereqsMet) {
// Rank is 0-indexed: first point = rank 0, second = rank 1, etc.
// Check if talent is already learned
const auto& learned = gameHandler.getLearnedTalents();
uint8_t desiredRank;
if (learned.find(talent.talentId) == learned.end()) {
// Not learned yet, learn first rank (0)
desiredRank = 0;
} else {
// Already learned, upgrade to next rank
desiredRank = currentRank + 1;
}
LOG_INFO("Sending CMSG_LEARN_TALENT for talent ", talent.talentId, " rank ", static_cast<int>(desiredRank), " (0-indexed)");
gameHandler.learnTalent(talent.talentId, desiredRank);
if (clicked && canLearn && prereqsMet) {
const auto& learned = gameHandler.getLearnedTalents();
uint8_t desiredRank;
if (learned.find(talent.talentId) == learned.end()) {
desiredRank = 0; // First rank (0-indexed on wire)
} else {
if (!canLearn) LOG_WARNING("Cannot learn: canLearn=false");
if (!prereqsMet) LOG_WARNING("Cannot learn: prereqsMet=false");
desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn
}
gameHandler.learnTalent(talent.talentId, desiredRank);
}
ImGui::PopID();
@ -441,12 +573,8 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("Spell.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Talent screen: Could not load Spell.dbc");
return;
}
if (!dbc || !dbc->isLoaded()) return;
// WoW 3.3.5a Spell.dbc fields: 0=SpellID, 133=SpellIconID, 136=SpellName_enUS, 139=Tooltip_enUS
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
@ -461,8 +589,6 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
spellTooltips[spellId] = tooltip;
}
}
LOG_INFO("Talent screen: Loaded ", spellIconIds.size(), " spell icons from Spell.dbc");
}
void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
@ -472,10 +598,7 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("SpellIcon.dbc");
if (!dbc || !dbc->isLoaded()) {
LOG_WARNING("Talent screen: Could not load SpellIcon.dbc");
return;
}
if (!dbc || !dbc->isLoaded()) return;
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
@ -485,8 +608,6 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
spellIconPaths[id] = path;
}
}
LOG_INFO("Talent screen: Loaded ", spellIconPaths.size(), " spell icon paths from SpellIcon.dbc");
}
VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) {