From 872b10fe6862b64dcdf6f9c3ff02e913b46d4319 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 13:26:08 -0800 Subject: [PATCH 01/13] 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 --- include/rendering/water_renderer.hpp | 1 + src/core/application.cpp | 5 ++++- src/rendering/terrain_manager.cpp | 8 ++++++++ src/rendering/water_renderer.cpp | 29 ++++++++++++++++++++++++---- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/include/rendering/water_renderer.hpp b/include/rendering/water_renderer.hpp index cf04cbc5..99767782 100644 --- a/include/rendering/water_renderer.hpp +++ b/include/rendering/water_renderer.hpp @@ -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; diff --git a/src/core/application.cpp b/src/core/application.cpp index eb9d0a7a..e7d7f656 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -661,7 +661,10 @@ void Application::logoutToLogin() { if (auto* m2 = renderer->getM2Renderer()) { m2->clear(); } - // TerrainManager will be re-initialized on next world entry + // Unload all terrain tiles + water surfaces so next world entry starts fresh + if (auto* terrain = renderer->getTerrainManager()) { + terrain->unloadAll(); + } if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { questMarkers->clear(); } diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 5caeab9d..afd50824 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -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 diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 8ecb1d7a..09d07b0b 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -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; } From 26a685187e321040fd85c6eb71d34ec18377d1ca Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 13:37:09 -0800 Subject: [PATCH 02/13] Fix /logout hang caused by blocking worker thread joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- include/rendering/terrain_manager.hpp | 1 + src/core/application.cpp | 5 +++-- src/rendering/terrain_manager.cpp | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index da99ed53..ee06eb30 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -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.) diff --git a/src/core/application.cpp b/src/core/application.cpp index e7d7f656..994813c6 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -661,9 +661,10 @@ void Application::logoutToLogin() { if (auto* m2 = renderer->getM2Renderer()) { m2->clear(); } - // Unload all terrain tiles + water surfaces so next world entry starts fresh + // 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->unloadAll(); + terrain->softReset(); } if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { questMarkers->clear(); diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index afd50824..3554b3d3 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -1296,6 +1296,32 @@ void TerrainManager::unloadAll() { } } +void TerrainManager::softReset() { + // Clear queues (workers may still be running — they'll find empty queues) + { + std::lock_guard 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}; From e220ce888db153d3dc1dfd2d6917ff128251439e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 13:42:58 -0800 Subject: [PATCH 03/13] 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. --- src/rendering/terrain_manager.cpp | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 3554b3d3..ee118c1f 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -1238,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(); From 2ab5cf5eb6fdb9d4b17efe87d8f11201d8c6f9a5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 13:47:42 -0800 Subject: [PATCH 04/13] 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. --- src/ui/inventory_screen.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index aa48cc40..e12f24ca 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1402,13 +1402,20 @@ 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_->isBankOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + // Deposit backpack item into bank: bag=0xFF, slot=23+index + gameHandler_->depositItem(0xFF, static_cast(23 + backpackIndex)); + } else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && isBagSlot) { + // Deposit bag item into bank: bag=19+bagIndex, slot=slotIndex + gameHandler_->depositItem(static_cast(19 + bagIndex), static_cast(bagSlotIndex)); + } else if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) { gameHandler_->sellItemBySlot(backpackIndex); } else if (vendorMode_ && kind == SlotKind::BACKPACK && isBagSlot) { gameHandler_->sellItemInBag(bagIndex, bagSlotIndex); From af7fb4242c6fa3ddccd57fecadd80f2fcf9d0adc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 13:54:47 -0800 Subject: [PATCH 05/13] 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+). --- include/ui/inventory_screen.hpp | 2 ++ src/ui/game_screen.cpp | 47 ++++++++++++++++++++++++++------- src/ui/inventory_screen.cpp | 19 +++++++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index baffe8de..ccc55631 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -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 diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d1e6fa22..618af2c9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7472,13 +7472,24 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { // Main bank slots (28 = 7 columns × 4 rows) ImGui::Text("Bank Slots"); ImGui::Separator(); + bool isHolding = inventoryScreen.isHoldingItem(); for (int i = 0; i < game::Inventory::BANK_SLOTS; i++) { if (i % 7 != 0) ImGui::SameLine(); const auto& slot = inv.getBankSlot(i); ImGui::PushID(i + 1000); if (slot.empty()) { + // Highlight as drop target when holding an item + if (isHolding) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.08f, 0.20f, 0.08f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.35f, 0.0f, 0.9f)); + } ImGui::Button("##bank", ImVec2(42, 42)); + if (isHolding) ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && isHolding) { + // Drop held item into empty bank slot + inventoryScreen.dropIntoBankSlot(gameHandler, 0xFF, static_cast(39 + i)); + } } else { ImVec4 qc = InventoryScreen::getQualityColor(slot.item.quality); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); @@ -7486,13 +7497,17 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { std::string label = std::to_string(slot.item.stackCount > 1 ? slot.item.stackCount : 0); if (slot.item.stackCount <= 1) label = "##b" + std::to_string(i); - if (ImGui::Button(label.c_str(), ImVec2(42, 42))) { - // Right-click to withdraw: bag=0xFF means bank, slot=i - // Use CMSG_AUTOSTORE_BANK_ITEM with bank container - // WoW bank slots are inventory slots 39-66 (BANK_SLOT_1 = 39) - gameHandler.withdrawItem(0xFF, static_cast(39 + i)); - } + ImGui::Button(label.c_str(), ImVec2(42, 42)); ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + if (isHolding) { + // Swap held item with bank slot + inventoryScreen.dropIntoBankSlot(gameHandler, 0xFF, static_cast(39 + i)); + } else { + // Withdraw on click + gameHandler.withdrawItem(0xFF, static_cast(39 + i)); + } + } if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::TextColored(qc, "%s", slot.item.name.c_str()); @@ -7537,17 +7552,29 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { const auto& slot = inv.getBankBagSlot(bagIdx, s); ImGui::PushID(3000 + bagIdx * 100 + s); if (slot.empty()) { + if (isHolding) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.08f, 0.20f, 0.08f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.35f, 0.0f, 0.9f)); + } ImGui::Button("##bb", ImVec2(42, 42)); + if (isHolding) ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && isHolding) { + inventoryScreen.dropIntoBankSlot(gameHandler, static_cast(67 + bagIdx), static_cast(s)); + } } else { ImVec4 qc = InventoryScreen::getQualityColor(slot.item.quality); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qc.x * 0.5f, qc.y * 0.5f, qc.z * 0.5f, 0.9f)); std::string lbl = slot.item.stackCount > 1 ? std::to_string(slot.item.stackCount) : ("##bb" + std::to_string(bagIdx * 100 + s)); - if (ImGui::Button(lbl.c_str(), ImVec2(42, 42))) { - // Withdraw from bank bag: bank bag container indices start at 67 - gameHandler.withdrawItem(static_cast(67 + bagIdx), static_cast(s)); - } + ImGui::Button(lbl.c_str(), ImVec2(42, 42)); ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + if (isHolding) { + inventoryScreen.dropIntoBankSlot(gameHandler, static_cast(67 + bagIdx), static_cast(s)); + } else { + gameHandler.withdrawItem(static_cast(67 + bagIdx), static_cast(s)); + } + } if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::TextColored(qc, "%s", slot.item.name.c_str()); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index e12f24ca..8bc1f8d4 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -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(23 + heldBackpackIndex); + } else if (heldSource == HeldSource::BAG) { + srcBag = static_cast(19 + heldBagIndex); + srcSlot = static_cast(heldBagSlotIndex); + } else if (heldSource == HeldSource::EQUIPMENT) { + srcSlot = static_cast(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); From d61b7b385dde0a20af6f205a95cd6a3653376ed6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 13:57:37 -0800 Subject: [PATCH 06/13] 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. --- src/ui/inventory_screen.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8bc1f8d4..0b4704b9 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1428,11 +1428,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, " vendorMode=", vendorMode_, " bankOpen=", gameHandler_->isBankOpen()); - if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) { - // Deposit backpack item into bank: bag=0xFF, slot=23+index + // Bank deposit: only for non-equippable, non-usable items (materials, etc.) + // Equippable items should equip; usable items should be used. + bool bankDeposit = gameHandler_->isBankOpen() && item.inventoryType == 0; + if (bankDeposit && kind == SlotKind::BACKPACK && backpackIndex >= 0) { gameHandler_->depositItem(0xFF, static_cast(23 + backpackIndex)); - } else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && isBagSlot) { - // Deposit bag item into bank: bag=19+bagIndex, slot=slotIndex + } else if (bankDeposit && kind == SlotKind::BACKPACK && isBagSlot) { gameHandler_->depositItem(static_cast(19 + bagIndex), static_cast(bagSlotIndex)); } else if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) { gameHandler_->sellItemBySlot(backpackIndex); From ce30cedf4a769a4747432f40b5ae5a730d99b539 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 14:00:54 -0800 Subject: [PATCH 07/13] 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. --- src/ui/inventory_screen.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 0b4704b9..757df530 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1428,12 +1428,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, " vendorMode=", vendorMode_, " bankOpen=", gameHandler_->isBankOpen()); - // Bank deposit: only for non-equippable, non-usable items (materials, etc.) - // Equippable items should equip; usable items should be used. - bool bankDeposit = gameHandler_->isBankOpen() && item.inventoryType == 0; - if (bankDeposit && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) { gameHandler_->depositItem(0xFF, static_cast(23 + backpackIndex)); - } else if (bankDeposit && kind == SlotKind::BACKPACK && isBagSlot) { + } else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && isBagSlot) { gameHandler_->depositItem(static_cast(19 + bagIndex), static_cast(bagSlotIndex)); } else if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) { gameHandler_->sellItemBySlot(backpackIndex); From 9906269671c3ee63c8ed369fd8957172926aced5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 14:11:09 -0800 Subject: [PATCH 08/13] 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 --- include/game/game_handler.hpp | 21 +++++- include/game/packet_parsers.hpp | 8 ++- include/game/world_packets.hpp | 3 +- src/game/game_handler.cpp | 108 +++++++++++++++++++++++++++- src/game/packet_parsers_classic.cpp | 7 +- src/game/world_packets.cpp | 10 ++- src/ui/game_screen.cpp | 57 +++++++++++++-- src/ui/inventory_screen.cpp | 6 +- 8 files changed, 203 insertions(+), 17 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d0364020..34cca73d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -856,12 +856,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& 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); @@ -1568,6 +1584,7 @@ private: int selectedMailIndex_ = -1; bool showMailCompose_ = false; bool hasNewMail_ = false; + std::array mailAttachments_{}; // Bank bool bankOpen_ = false; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index edd97e8c..15a271bd 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -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& 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& itemGuids = {}) override; bool parseMailList(network::Packet& packet, std::vector& 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; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 9124d75e..95e17f6d 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2290,7 +2290,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& itemGuids = {}); }; /** CMSG_MAIL_TAKE_MONEY packet builder */ diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4f1c844a..3a6aea5c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12851,10 +12851,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 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(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(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(19 + bagIndex); + mailAttachments_[i].srcSlot = static_cast(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) { diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 6dcfe934..a6e81764 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -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& 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) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 51b7bf44..b181284e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3939,7 +3939,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& itemGuids) { // WotLK 3.3.5a format network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL)); packet.writeUInt64(mailboxGuid); @@ -3948,7 +3949,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(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; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 618af2c9..2f7bfda9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7384,8 +7384,8 @@ void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, screenH / 2 - 200), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(380, 400), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 250), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(400, 500), ImGuiCond_Appearing); bool open = true; if (ImGui::Begin("Send Mail", &open)) { @@ -7401,8 +7401,56 @@ void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { ImGui::Text("Body:"); ImGui::InputTextMultiline("##MailBody", mailBodyBuffer_, sizeof(mailBodyBuffer_), - ImVec2(-1, 150)); + ImVec2(-1, 120)); + // Attachments section + int attachCount = gameHandler.getMailAttachmentCount(); + ImGui::Text("Attachments (%d/12):", attachCount); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Right-click items in bags to attach"); + + const auto& attachments = gameHandler.getMailAttachments(); + // Show attachment slots in a grid (6 per row) + for (int i = 0; i < game::GameHandler::MAIL_MAX_ATTACHMENTS; ++i) { + if (i % 6 != 0) ImGui::SameLine(); + ImGui::PushID(i + 5000); + const auto& att = attachments[i]; + if (att.occupied()) { + // Show item with quality color border + ImVec4 qualColor = ui::InventoryScreen::getQualityColor(att.item.quality); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qualColor.x * 0.3f, qualColor.y * 0.3f, qualColor.z * 0.3f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qualColor.x * 0.5f, qualColor.y * 0.5f, qualColor.z * 0.5f, 0.9f)); + + // Try to show icon + VkDescriptorSet icon = inventoryScreen.getItemIcon(att.item.displayInfoId); + bool clicked = false; + if (icon) { + clicked = ImGui::ImageButton("##att", (ImTextureID)icon, ImVec2(30, 30)); + } else { + // Truncate name to fit + std::string label = att.item.name.substr(0, 4); + clicked = ImGui::Button(label.c_str(), ImVec2(36, 36)); + } + ImGui::PopStyleColor(2); + + if (clicked) { + gameHandler.detachMailAttachment(i); + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qualColor, "%s", att.item.name.c_str()); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Click to remove"); + ImGui::EndTooltip(); + } + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.5f)); + ImGui::Button("##empty", ImVec2(36, 36)); + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + + ImGui::Spacing(); ImGui::Text("Money:"); ImGui::SameLine(60); ImGui::SetNextItemWidth(60); @@ -7429,7 +7477,8 @@ void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { static_cast(mailComposeMoney_[1]) * 100 + static_cast(mailComposeMoney_[2]); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Sending cost: 30c"); + uint32_t sendCost = attachCount > 0 ? static_cast(30 * attachCount) : 30u; + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Sending cost: %uc", sendCost); ImGui::Spacing(); bool canSend = (strlen(mailRecipientBuffer_) > 0); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 757df530..3d7f03dc 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1428,7 +1428,11 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, " vendorMode=", vendorMode_, " bankOpen=", gameHandler_->isBankOpen()); - if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + 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(23 + backpackIndex)); } else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && isBagSlot) { gameHandler_->depositItem(static_cast(19 + bagIndex), static_cast(bagSlotIndex)); From 1ae5fe867c84c895d6723f782ae37d9773eeb214 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 14:44:44 -0800 Subject: [PATCH 09/13] 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 --- include/game/game_handler.hpp | 31 ++ include/game/world_packets.hpp | 54 +++ include/ui/game_screen.hpp | 10 + src/game/game_handler.cpp | 115 ++++- src/game/world_packets.cpp | 79 ++++ src/ui/game_screen.cpp | 813 +++++++++++++++++++++++++++------ 6 files changed, 945 insertions(+), 157 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 34cca73d..1520bfe8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -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(); @@ -1123,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); @@ -1467,10 +1481,15 @@ private: std::string guildName_; std::vector 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; @@ -1607,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; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 95e17f6d..308ec642 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -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; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cd0cf212..cc1bd4ab 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -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 diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3a6aea5c..22bd86be 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -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]); @@ -13305,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, @@ -13437,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", diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index b181284e..260ea594 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -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()); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2f7bfda9..75c91a10 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2357,6 +2357,24 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /gcreate command + if (cmdLower == "gcreate" || cmdLower == "guildcreate") { + if (spacePos != std::string::npos) { + std::string guildName = command.substr(spacePos + 1); + gameHandler.createGuild(guildName); + chatInputBuffer[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gcreate "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + // /gdisband command if (cmdLower == "gdisband" || cmdLower == "guilddisband") { gameHandler.disbandGuild(); @@ -4300,11 +4318,50 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { } } gameHandler.requestGuildRoster(); + gameHandler.requestGuildInfo(); } } + // Petition creation dialog (shown when NPC sends SMSG_PETITION_SHOWLIST) + if (gameHandler.hasPetitionShowlist()) { + ImGui::OpenPopup("CreateGuildPetition"); + gameHandler.clearPetitionDialog(); + } + if (ImGui::BeginPopupModal("CreateGuildPetition", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Create Guild Charter"); + ImGui::Separator(); + uint32_t cost = gameHandler.getPetitionCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + ImGui::Text("Cost: %ug %us %uc", gold, silver, copper); + ImGui::Spacing(); + ImGui::Text("Guild Name:"); + ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); + ImGui::Spacing(); + if (ImGui::Button("Create", ImVec2(120, 0))) { + if (petitionNameBuffer_[0] != '\0') { + gameHandler.buyPetition(gameHandler.getPetitionNpcGuid(), petitionNameBuffer_); + petitionNameBuffer_[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + petitionNameBuffer_[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + if (!showGuildRoster_) return; + // Get zone manager for name lookup + game::ZoneManager* zoneManager = nullptr; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + zoneManager = renderer->getZoneManager(); + } + auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; @@ -4312,164 +4369,311 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); - std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Roster") : "Guild Roster"; + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Guild") : "Guild"; bool open = showGuildRoster_; if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { - if (!gameHandler.hasGuildRoster()) { - ImGui::Text("Loading roster..."); - } else { - const auto& roster = gameHandler.getGuildRoster(); + // Tab bar: Roster | Guild Info + if (ImGui::BeginTabBar("GuildTabs")) { + if (ImGui::BeginTabItem("Roster")) { + guildRosterTab_ = 0; + if (!gameHandler.hasGuildRoster()) { + ImGui::Text("Loading roster..."); + } else { + const auto& roster = gameHandler.getGuildRoster(); - // MOTD - if (!roster.motd.empty()) { - ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); - ImGui::Separator(); - } - - // Count online - int onlineCount = 0; - for (const auto& m : roster.members) { - if (m.online) ++onlineCount; - } - ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount); - ImGui::Separator(); - - const auto& rankNames = gameHandler.getGuildRankNames(); - - // Table - if (ImGui::BeginTable("GuildRoster", 7, - ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | - ImGuiTableFlags_Sortable)) { - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); - ImGui::TableSetupColumn("Rank"); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); - ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f); - ImGui::TableSetupColumn("Note"); - ImGui::TableSetupColumn("Officer Note"); - ImGui::TableHeadersRow(); - - // Online members first, then offline - auto sortedMembers = roster.members; - std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { - if (a.online != b.online) return a.online > b.online; - return a.name < b.name; - }); - - static const char* classNames[] = { - "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", - "Priest", "Death Knight", "Shaman", "Mage", "Warlock", - "", "Druid" - }; - - for (const auto& m : sortedMembers) { - ImGui::TableNextRow(); - ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) - : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.name.c_str()); - - // Right-click context menu - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - selectedGuildMember_ = m.name; - ImGui::OpenPopup("GuildMemberContext"); + // MOTD + if (!roster.motd.empty()) { + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); + ImGui::Separator(); } - ImGui::TableNextColumn(); - // Show rank name instead of index - if (m.rankIndex < rankNames.size()) { - ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str()); - } else { - ImGui::TextColored(textColor, "Rank %u", m.rankIndex); + // Count online + int onlineCount = 0; + for (const auto& m : roster.members) { + if (m.online) ++onlineCount; } + ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount); + ImGui::Separator(); - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%u", m.level); + const auto& rankNames = gameHandler.getGuildRankNames(); - ImGui::TableNextColumn(); - const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; - ImGui::TextColored(textColor, "%s", className); + // Table + if (ImGui::BeginTable("GuildRoster", 7, + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); + ImGui::TableSetupColumn("Rank"); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Note"); + ImGui::TableSetupColumn("Officer Note"); + ImGui::TableHeadersRow(); - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%u", m.zoneId); + // Online members first, then offline + auto sortedMembers = roster.members; + std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { + if (a.online != b.online) return a.online > b.online; + return a.name < b.name; + }); - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); + static const char* classNames[] = { + "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", + "Priest", "Death Knight", "Shaman", "Mage", "Warlock", + "", "Druid" + }; - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.officerNote.c_str()); - } - ImGui::EndTable(); - } + for (const auto& m : sortedMembers) { + ImGui::TableNextRow(); + ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - // Context menu popup - if (ImGui::BeginPopup("GuildMemberContext")) { - ImGui::Text("%s", selectedGuildMember_.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Promote")) { - gameHandler.promoteGuildMember(selectedGuildMember_); - } - if (ImGui::MenuItem("Demote")) { - gameHandler.demoteGuildMember(selectedGuildMember_); - } - if (ImGui::MenuItem("Kick")) { - gameHandler.kickGuildMember(selectedGuildMember_); - } - ImGui::Separator(); - if (ImGui::MenuItem("Set Public Note...")) { - showGuildNoteEdit_ = true; - editingOfficerNote_ = false; - guildNoteEditBuffer_[0] = '\0'; - // Pre-fill with existing note - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { - snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str()); - break; + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.name.c_str()); + + // Right-click context menu + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + selectedGuildMember_ = m.name; + ImGui::OpenPopup("GuildMemberContext"); + } + + ImGui::TableNextColumn(); + // Show rank name instead of index + if (m.rankIndex < rankNames.size()) { + ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str()); + } else { + ImGui::TextColored(textColor, "Rank %u", m.rankIndex); + } + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%u", m.level); + + ImGui::TableNextColumn(); + const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; + ImGui::TextColored(textColor, "%s", className); + + ImGui::TableNextColumn(); + // Zone name lookup + if (zoneManager) { + const auto* zoneInfo = zoneManager->getZoneInfo(m.zoneId); + if (zoneInfo && !zoneInfo->name.empty()) { + ImGui::TextColored(textColor, "%s", zoneInfo->name.c_str()); + } else { + ImGui::TextColored(textColor, "%u", m.zoneId); + } + } else { + ImGui::TextColored(textColor, "%u", m.zoneId); + } + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.officerNote.c_str()); } + ImGui::EndTable(); } - } - if (ImGui::MenuItem("Set Officer Note...")) { - showGuildNoteEdit_ = true; - editingOfficerNote_ = true; - guildNoteEditBuffer_[0] = '\0'; - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { - snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str()); - break; + + // Context menu popup + if (ImGui::BeginPopup("GuildMemberContext")) { + ImGui::Text("%s", selectedGuildMember_.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Promote")) { + gameHandler.promoteGuildMember(selectedGuildMember_); } + if (ImGui::MenuItem("Demote")) { + gameHandler.demoteGuildMember(selectedGuildMember_); + } + if (ImGui::MenuItem("Kick")) { + gameHandler.kickGuildMember(selectedGuildMember_); + } + ImGui::Separator(); + if (ImGui::MenuItem("Set Public Note...")) { + showGuildNoteEdit_ = true; + editingOfficerNote_ = false; + guildNoteEditBuffer_[0] = '\0'; + // Pre-fill with existing note + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { + snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str()); + break; + } + } + } + if (ImGui::MenuItem("Set Officer Note...")) { + showGuildNoteEdit_ = true; + editingOfficerNote_ = true; + guildNoteEditBuffer_[0] = '\0'; + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { + snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str()); + break; + } + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Set as Leader")) { + gameHandler.setGuildLeader(selectedGuildMember_); + } + ImGui::EndPopup(); + } + + // Note edit modal + if (showGuildNoteEdit_) { + ImGui::OpenPopup("EditGuildNote"); + showGuildNoteEdit_ = false; + } + if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("%s %s for %s:", + editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str()); + ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_)); + if (ImGui::Button("Save")) { + if (editingOfficerNote_) { + gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_); + } else { + gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); } } - ImGui::Separator(); - if (ImGui::MenuItem("Set as Leader")) { - gameHandler.setGuildLeader(selectedGuildMember_); - } - ImGui::EndPopup(); + ImGui::EndTabItem(); } - // Note edit modal - if (showGuildNoteEdit_) { - ImGui::OpenPopup("EditGuildNote"); - showGuildNoteEdit_ = false; - } - if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("%s %s for %s:", - editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str()); - ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_)); - if (ImGui::Button("Save")) { - if (editingOfficerNote_) { - gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_); - } else { - gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_); + if (ImGui::BeginTabItem("Guild Info")) { + guildRosterTab_ = 1; + const auto& infoData = gameHandler.getGuildInfoData(); + const auto& queryData = gameHandler.getGuildQueryData(); + const auto& roster = gameHandler.getGuildRoster(); + const auto& rankNames = gameHandler.getGuildRankNames(); + + // Guild name (large, gold) + ImGui::PushFont(nullptr); // default font + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "<%s>", gameHandler.getGuildName().c_str()); + ImGui::PopFont(); + ImGui::Separator(); + + // Creation date + if (infoData.isValid()) { + ImGui::Text("Created: %u/%u/%u", infoData.creationDay, infoData.creationMonth, infoData.creationYear); + ImGui::Text("Members: %u | Accounts: %u", infoData.numMembers, infoData.numAccounts); + } + ImGui::Spacing(); + + // Guild description / info text + if (!roster.guildInfo.empty()) { + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "Description:"); + ImGui::TextWrapped("%s", roster.guildInfo.c_str()); + } + ImGui::Spacing(); + + // MOTD with edit button + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD:"); + ImGui::SameLine(); + if (!roster.motd.empty()) { + ImGui::TextWrapped("%s", roster.motd.c_str()); + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "(not set)"); + } + if (ImGui::Button("Set MOTD")) { + showMotdEdit_ = true; + snprintf(guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_), "%s", roster.motd.c_str()); + } + ImGui::Spacing(); + + // MOTD edit modal + if (showMotdEdit_) { + ImGui::OpenPopup("EditMotd"); + showMotdEdit_ = false; + } + if (ImGui::BeginPopupModal("EditMotd", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Set Message of the Day:"); + ImGui::InputText("##motdinput", guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_)); + if (ImGui::Button("Save", ImVec2(120, 0))) { + gameHandler.setGuildMotd(guildMotdEditBuffer_); + ImGui::CloseCurrentPopup(); } - ImGui::CloseCurrentPopup(); + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + // Emblem info + if (queryData.isValid()) { + ImGui::Separator(); + ImGui::Text("Emblem: Style %u, Color %u | Border: Style %u, Color %u | BG: %u", + queryData.emblemStyle, queryData.emblemColor, + queryData.borderStyle, queryData.borderColor, queryData.backgroundColor); + } + + // Rank list + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Ranks:"); + for (size_t i = 0; i < rankNames.size(); ++i) { + if (rankNames[i].empty()) continue; + // Show rank permission summary from roster data + if (i < roster.ranks.size()) { + uint32_t rights = roster.ranks[i].rights; + std::string perms; + if (rights & 0x01) perms += "Invite "; + if (rights & 0x02) perms += "Remove "; + if (rights & 0x40) perms += "Promote "; + if (rights & 0x80) perms += "Demote "; + if (rights & 0x04) perms += "OChat "; + if (rights & 0x10) perms += "MOTD "; + ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); + if (!perms.empty()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "[%s]", perms.c_str()); + } + } else { + ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); + } + } + + // Rank management buttons + ImGui::Spacing(); + if (ImGui::Button("Add Rank")) { + showAddRankModal_ = true; + addRankNameBuffer_[0] = '\0'; } ImGui::SameLine(); - if (ImGui::Button("Cancel")) { - ImGui::CloseCurrentPopup(); + if (ImGui::Button("Delete Last Rank")) { + gameHandler.deleteGuildRank(); } - ImGui::EndPopup(); + + // Add rank modal + if (showAddRankModal_) { + ImGui::OpenPopup("AddGuildRank"); + showAddRankModal_ = false; + } + if (ImGui::BeginPopupModal("AddGuildRank", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("New Rank Name:"); + ImGui::InputText("##rankname", addRankNameBuffer_, sizeof(addRankNameBuffer_)); + if (ImGui::Button("Add", ImVec2(120, 0))) { + if (addRankNameBuffer_[0] != '\0') { + gameHandler.addGuildRank(addRankNameBuffer_); + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::EndTabItem(); } + + ImGui::EndTabBar(); } } ImGui::End(); @@ -7793,8 +7997,74 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (tab == 0) { // Browse tab - Search filters + + // --- Helper: resolve current UI filter state into wire-format search params --- + // WoW 3.3.5a item class IDs: + // 0=Consumable, 1=Container, 2=Weapon, 3=Gem, 4=Armor, + // 7=Projectile/TradeGoods, 9=Recipe, 11=Quiver, 15=Miscellaneous + struct AHClassMapping { const char* label; uint32_t classId; }; + static const AHClassMapping classMappings[] = { + {"All", 0xFFFFFFFF}, + {"Weapon", 2}, + {"Armor", 4}, + {"Container", 1}, + {"Consumable", 0}, + {"Trade Goods", 7}, + {"Gem", 3}, + {"Recipe", 9}, + {"Quiver", 11}, + {"Miscellaneous", 15}, + }; + static constexpr int NUM_CLASSES = 10; + + // Weapon subclass IDs (WoW 3.3.5a) + struct AHSubMapping { const char* label; uint32_t subId; }; + static const AHSubMapping weaponSubs[] = { + {"All", 0xFFFFFFFF}, {"Axe (1H)", 0}, {"Axe (2H)", 1}, {"Bow", 2}, + {"Gun", 3}, {"Mace (1H)", 4}, {"Mace (2H)", 5}, {"Polearm", 6}, + {"Sword (1H)", 7}, {"Sword (2H)", 8}, {"Staff", 10}, + {"Fist Weapon", 13}, {"Dagger", 15}, {"Thrown", 16}, + {"Crossbow", 18}, {"Wand", 19}, + }; + static constexpr int NUM_WEAPON_SUBS = 16; + + // Armor subclass IDs + static const AHSubMapping armorSubs[] = { + {"All", 0xFFFFFFFF}, {"Cloth", 1}, {"Leather", 2}, {"Mail", 3}, + {"Plate", 4}, {"Shield", 6}, {"Miscellaneous", 0}, + }; + static constexpr int NUM_ARMOR_SUBS = 7; + + auto getSearchClassId = [&]() -> uint32_t { + if (auctionItemClass_ < 0 || auctionItemClass_ >= NUM_CLASSES) return 0xFFFFFFFF; + return classMappings[auctionItemClass_].classId; + }; + + auto getSearchSubClassId = [&]() -> uint32_t { + if (auctionItemSubClass_ < 0) return 0xFFFFFFFF; + uint32_t cid = getSearchClassId(); + if (cid == 2 && auctionItemSubClass_ < NUM_WEAPON_SUBS) + return weaponSubs[auctionItemSubClass_].subId; + if (cid == 4 && auctionItemSubClass_ < NUM_ARMOR_SUBS) + return armorSubs[auctionItemSubClass_].subId; + return 0xFFFFFFFF; + }; + + auto doSearch = [&](uint32_t offset) { + auctionBrowseOffset_ = offset; + auctionLevelMin_ = std::clamp(auctionLevelMin_, 0, 80); + auctionLevelMax_ = std::clamp(auctionLevelMax_, 0, 80); + uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; + gameHandler.auctionSearch(auctionSearchName_, + static_cast(auctionLevelMin_), + static_cast(auctionLevelMax_), + q, getSearchClassId(), getSearchSubClassId(), 0, 0, offset); + }; + + // Row 1: Name + Level range ImGui::SetNextItemWidth(200); - ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_)); + bool enterPressed = ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_), + ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); ImGui::SetNextItemWidth(50); ImGui::InputInt("Min Lv", &auctionLevelMin_, 0); @@ -7802,23 +8072,49 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(50); ImGui::InputInt("Max Lv", &auctionLevelMax_, 0); + // Row 2: Quality + Category + Subcategory + Search button const char* qualities[] = {"All", "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary"}; ImGui::SetNextItemWidth(100); ImGui::Combo("Quality", &auctionQuality_, qualities, 7); + ImGui::SameLine(); + // Build class label list from mappings + const char* classLabels[NUM_CLASSES]; + for (int c = 0; c < NUM_CLASSES; c++) classLabels[c] = classMappings[c].label; + ImGui::SetNextItemWidth(120); + int classIdx = auctionItemClass_ < 0 ? 0 : auctionItemClass_; + if (ImGui::Combo("Category", &classIdx, classLabels, NUM_CLASSES)) { + if (classIdx != auctionItemClass_) auctionItemSubClass_ = -1; + auctionItemClass_ = classIdx; + } + + // Subcategory (only for Weapon and Armor) + uint32_t curClassId = getSearchClassId(); + if (curClassId == 2 || curClassId == 4) { + const AHSubMapping* subs = (curClassId == 2) ? weaponSubs : armorSubs; + int numSubs = (curClassId == 2) ? NUM_WEAPON_SUBS : NUM_ARMOR_SUBS; + const char* subLabels[20]; + for (int s = 0; s < numSubs && s < 20; s++) subLabels[s] = subs[s].label; + int subIdx = auctionItemSubClass_ + 1; // -1 → 0 ("All") + if (subIdx < 0 || subIdx >= numSubs) subIdx = 0; + ImGui::SameLine(); + ImGui::SetNextItemWidth(110); + if (ImGui::Combo("Subcat", &subIdx, subLabels, numSubs)) { + auctionItemSubClass_ = subIdx - 1; // 0 → -1 ("All") + } + } + ImGui::SameLine(); float delay = gameHandler.getAuctionSearchDelay(); if (delay > 0.0f) { + char delayBuf[32]; + snprintf(delayBuf, sizeof(delayBuf), "Search (%.0fs)", delay); ImGui::BeginDisabled(); - ImGui::Button("Search..."); + ImGui::Button(delayBuf); ImGui::EndDisabled(); } else { - if (ImGui::Button("Search")) { - uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; - gameHandler.auctionSearch(auctionSearchName_, - static_cast(auctionLevelMin_), - static_cast(auctionLevelMax_), - q, 0xFFFFFFFF, 0xFFFFFFFF, 0, 0); + if (ImGui::Button("Search") || enterPressed) { + doSearch(0); } } @@ -7826,9 +8122,34 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Results table const auto& results = gameHandler.getAuctionBrowseResults(); + constexpr uint32_t AH_PAGE_SIZE = 50; ImGui::Text("%zu results (of %u total)", results.auctions.size(), results.totalCount); - if (ImGui::BeginChild("AuctionResults", ImVec2(0, -80), true)) { + // Pagination + if (results.totalCount > AH_PAGE_SIZE) { + ImGui::SameLine(); + uint32_t page = auctionBrowseOffset_ / AH_PAGE_SIZE + 1; + uint32_t totalPages = (results.totalCount + AH_PAGE_SIZE - 1) / AH_PAGE_SIZE; + + if (auctionBrowseOffset_ == 0) ImGui::BeginDisabled(); + if (ImGui::SmallButton("< Prev")) { + uint32_t newOff = (auctionBrowseOffset_ >= AH_PAGE_SIZE) ? auctionBrowseOffset_ - AH_PAGE_SIZE : 0; + doSearch(newOff); + } + if (auctionBrowseOffset_ == 0) ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::Text("Page %u/%u", page, totalPages); + + ImGui::SameLine(); + if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Next >")) { + doSearch(auctionBrowseOffset_ + AH_PAGE_SIZE); + } + if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::EndDisabled(); + } + + if (ImGui::BeginChild("AuctionResults", ImVec2(0, -110), true)) { if (ImGui::BeginTable("AuctionTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); @@ -7847,7 +8168,47 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); + // Item icon + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(16, 16)); + ImGui::SameLine(); + } + } ImGui::TextColored(qc, "%s", name.c_str()); + // Item tooltip on hover + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", info->name.c_str()); + if (info->inventoryType > 0) { + if (!info->subclassName.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1), "%s", info->subclassName.c_str()); + } + if (info->armor > 0) ImGui::Text("%d Armor", info->armor); + if (info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + std::string bonusLine; + auto appendStat = [](std::string& out, int32_t val, const char* n) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " " + n; + }; + appendStat(bonusLine, info->strength, "Str"); + appendStat(bonusLine, info->agility, "Agi"); + appendStat(bonusLine, info->stamina, "Sta"); + appendStat(bonusLine, info->intellect, "Int"); + appendStat(bonusLine, info->spirit, "Spi"); + if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); + if (info->sellPrice > 0) { + ImGui::TextColored(ImVec4(1, 0.84f, 0, 1), "Sell: %ug %us %uc", + info->sellPrice / 10000, (info->sellPrice / 100) % 100, info->sellPrice % 100); + } + ImGui::EndTooltip(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", auction.stackCount); @@ -7894,8 +8255,52 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Sell section ImGui::Separator(); - ImGui::Text("Sell:"); - ImGui::SameLine(); + ImGui::Text("Sell Item:"); + + // Item picker from backpack + { + auto& inv = gameHandler.getInventory(); + // Build list of non-empty backpack slots + std::string preview = (auctionSellSlotIndex_ >= 0) + ? ([&]() -> std::string { + const auto& slot = inv.getBackpackSlot(auctionSellSlotIndex_); + if (!slot.empty()) { + std::string s = slot.item.name; + if (slot.item.stackCount > 1) s += " x" + std::to_string(slot.item.stackCount); + return s; + } + return "Select item..."; + })() + : "Select item..."; + + ImGui::SetNextItemWidth(250); + if (ImGui::BeginCombo("##sellitem", preview.c_str())) { + for (int i = 0; i < game::Inventory::BACKPACK_SLOTS; i++) { + const auto& slot = inv.getBackpackSlot(i); + if (slot.empty()) continue; + ImGui::PushID(i + 9000); + // Item icon + if (slot.item.displayInfoId != 0) { + VkDescriptorSet sIcon = inventoryScreen.getItemIcon(slot.item.displayInfoId); + if (sIcon) { + ImGui::Image((void*)(intptr_t)sIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + std::string label = slot.item.name; + if (slot.item.stackCount > 1) label += " x" + std::to_string(slot.item.stackCount); + ImVec4 iqc = InventoryScreen::getQualityColor(slot.item.quality); + ImGui::PushStyleColor(ImGuiCol_Text, iqc); + if (ImGui::Selectable(label.c_str(), auctionSellSlotIndex_ == i)) { + auctionSellSlotIndex_ = i; + } + ImGui::PopStyleColor(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + } + ImGui::Text("Bid:"); ImGui::SameLine(); ImGui::SetNextItemWidth(50); @@ -7907,7 +8312,7 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(35); ImGui::InputInt("##sbc", &auctionSellBid_[2], 0); ImGui::SameLine(); ImGui::Text("c"); - ImGui::Text(" "); ImGui::SameLine(); + ImGui::SameLine(0, 20); ImGui::Text("Buyout:"); ImGui::SameLine(); ImGui::SetNextItemWidth(50); @@ -7920,31 +8325,92 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::InputInt("##sboc", &auctionSellBuyout_[2], 0); ImGui::SameLine(); ImGui::Text("c"); const char* durations[] = {"12 hours", "24 hours", "48 hours"}; - ImGui::SameLine(); ImGui::SetNextItemWidth(90); ImGui::Combo("##dur", &auctionSellDuration_, durations, 3); + ImGui::SameLine(); + + // Create Auction button + bool canCreate = auctionSellSlotIndex_ >= 0 && + !gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_).empty() && + (auctionSellBid_[0] > 0 || auctionSellBid_[1] > 0 || auctionSellBid_[2] > 0); + if (!canCreate) ImGui::BeginDisabled(); + if (ImGui::Button("Create Auction")) { + uint32_t bidCopper = static_cast(auctionSellBid_[0]) * 10000 + + static_cast(auctionSellBid_[1]) * 100 + + static_cast(auctionSellBid_[2]); + uint32_t buyoutCopper = static_cast(auctionSellBuyout_[0]) * 10000 + + static_cast(auctionSellBuyout_[1]) * 100 + + static_cast(auctionSellBuyout_[2]); + const uint32_t durationMins[] = {720, 1440, 2880}; + uint32_t dur = durationMins[auctionSellDuration_]; + uint64_t itemGuid = gameHandler.getBackpackItemGuid(auctionSellSlotIndex_); + const auto& slot = gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_); + uint32_t stackCount = slot.item.stackCount; + if (itemGuid != 0) { + gameHandler.auctionSellItem(itemGuid, stackCount, bidCopper, buyoutCopper, dur); + // Clear sell inputs + auctionSellSlotIndex_ = -1; + auctionSellBid_[0] = auctionSellBid_[1] = auctionSellBid_[2] = 0; + auctionSellBuyout_[0] = auctionSellBuyout_[1] = auctionSellBuyout_[2] = 0; + } + } + if (!canCreate) ImGui::EndDisabled(); } else if (tab == 1) { // Bids tab const auto& results = gameHandler.getAuctionBidderResults(); ImGui::Text("Your Bids: %zu items", results.auctions.size()); - if (ImGui::BeginTable("BidTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + if (ImGui::BeginTable("BidTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); ImGui::TableSetupColumn("Your Bid", ImGuiTableColumnFlags_WidthFixed, 90); ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60); ImGui::TableHeadersRow(); - for (const auto& a : results.auctions) { + for (size_t bi = 0; bi < results.auctions.size(); bi++) { + const auto& a = results.auctions[bi]; auto* info = gameHandler.getItemInfo(a.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; + ImVec4 bqc = InventoryScreen::getQualityColor(quality); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - ImGui::TextColored(InventoryScreen::getQualityColor(quality), "%s", name.c_str()); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet bIcon = inventoryScreen.getItemIcon(info->displayInfoId); + if (bIcon) { + ImGui::Image((void*)(intptr_t)bIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(bqc, "%s", name.c_str()); + // Tooltip + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(bqc, "%s", info->name.c_str()); + if (info->armor > 0) ImGui::Text("%d Armor", info->armor); + if (info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + } + std::string bl; + auto appS = [](std::string& o, int32_t v, const char* n) { + if (v <= 0) return; + if (!o.empty()) o += " "; + o += "+" + std::to_string(v) + " " + n; + }; + appS(bl, info->strength, "Str"); appS(bl, info->agility, "Agi"); + appS(bl, info->stamina, "Sta"); appS(bl, info->intellect, "Int"); + appS(bl, info->spirit, "Spi"); + if (!bl.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", bl.c_str()); + if (info->sellPrice > 0) + ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", + info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); + ImGui::EndTooltip(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); @@ -7959,6 +8425,20 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (mins > 720) ImGui::Text("Long"); else if (mins > 120) ImGui::Text("Medium"); else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short"); + + ImGui::TableSetColumnIndex(5); + ImGui::PushID(static_cast(bi) + 7500); + if (a.buyoutPrice > 0 && ImGui::SmallButton("Buy")) { + gameHandler.auctionBuyout(a.auctionId, a.buyoutPrice); + } + if (a.buyoutPrice > 0) ImGui::SameLine(); + if (ImGui::SmallButton("Bid")) { + uint32_t bidAmt = a.currentBid > 0 + ? a.currentBid + a.minBidIncrement + : a.startBid; + gameHandler.auctionPlaceBid(a.auctionId, bidAmt); + } + ImGui::PopID(); } ImGui::EndTable(); } @@ -7984,7 +8464,38 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - ImGui::TextColored(InventoryScreen::getQualityColor(quality), "%s", name.c_str()); + ImVec4 oqc = InventoryScreen::getQualityColor(quality); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet oIcon = inventoryScreen.getItemIcon(info->displayInfoId); + if (oIcon) { + ImGui::Image((void*)(intptr_t)oIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(oqc, "%s", name.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(oqc, "%s", info->name.c_str()); + if (info->armor > 0) ImGui::Text("%d Armor", info->armor); + if (info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + } + std::string ol; + auto appO = [](std::string& o, int32_t v, const char* n) { + if (v <= 0) return; + if (!o.empty()) o += " "; + o += "+" + std::to_string(v) + " " + n; + }; + appO(ol, info->strength, "Str"); appO(ol, info->agility, "Agi"); + appO(ol, info->stamina, "Sta"); appO(ol, info->intellect, "Int"); + appO(ol, info->spirit, "Spi"); + if (!ol.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", ol.c_str()); + if (info->sellPrice > 0) + ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", + info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); + ImGui::EndTooltip(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); From 889cd86fb09d825db23897af73d70c07f63c0bd0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 14:45:53 -0800 Subject: [PATCH 10/13] Remove level cap from auction search to allow finding items of any required level --- src/ui/game_screen.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 75c91a10..44a37578 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8052,8 +8052,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { auto doSearch = [&](uint32_t offset) { auctionBrowseOffset_ = offset; - auctionLevelMin_ = std::clamp(auctionLevelMin_, 0, 80); - auctionLevelMax_ = std::clamp(auctionLevelMax_, 0, 80); + if (auctionLevelMin_ < 0) auctionLevelMin_ = 0; + if (auctionLevelMax_ < 0) auctionLevelMax_ = 0; uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; gameHandler.auctionSearch(auctionSearchName_, static_cast(auctionLevelMin_), From da959cfb8f662356033a82838733010f7c0c029a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 14:55:40 -0800 Subject: [PATCH 11/13] 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 --- include/ui/spellbook_screen.hpp | 26 +- include/ui/talent_screen.hpp | 12 +- src/ui/spellbook_screen.cpp | 272 +++++++++++------ src/ui/talent_screen.cpp | 517 ++++++++++++++++++++------------ 4 files changed, 530 insertions(+), 297 deletions(-) diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index b77a2ece..537e862c 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -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 spellIconPaths; // SpellIconID -> path - std::unordered_map spellIconCache; // SpellIconID -> GL texture + std::unordered_map spellIconCache; // SpellIconID -> texture // Skill line data (loaded from SkillLine.dbc + SkillLineAbility.dbc) bool skillLineDbLoaded = false; - std::unordered_map skillLineNames; // skillLineID -> name - std::unordered_map skillLineCategories; // skillLineID -> categoryID - std::unordered_map spellToSkillLine; // spellID -> skillLineID + std::unordered_map skillLineNames; + std::unordered_map skillLineCategories; + std::unordered_map spellToSkillLine; - // Categorized spell tabs (rebuilt when spell list changes) - // ordered map so tabs appear in consistent order + // Categorized spell tabs std::vector 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& 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 diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 792e7706..18bbe152 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -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 spellIconIds; // spellId -> iconId + std::unordered_map spellIconIds; // spellId -> iconId std::unordered_map spellIconPaths; // iconId -> path std::unordered_map spellIconCache; // iconId -> texture - std::unordered_map spellTooltips; // spellId -> description + std::unordered_map spellTooltips; // spellId -> description + std::unordered_map bgTextureCache_; // tabId -> bg texture }; } // namespace ui diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 9fec4524..243c4f2e 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -9,9 +9,29 @@ #include "core/logger.hpp" #include #include +#include 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(haystack[i + j])) != + std::tolower(static_cast(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,17 +150,12 @@ 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& 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; std::map> specialtySpells; @@ -177,12 +182,10 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; }; - // Specialty tabs sorted alphabetically by skill line name std::vector>> named; for (auto& [skillLineId, spells] : specialtySpells) { auto nameIt = skillLineNames.find(skillLineId); - std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second - : "Specialty"; + 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)}); } @@ -193,7 +196,6 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known spellTabs.push_back({std::move(name), std::move(spells)}); } - // General tab last if (!generalSpells.empty()) { std::sort(generalSpells.begin(), generalSpells.end(), byName); spellTabs.push_back({"General", std::move(generalSpells)}); @@ -244,6 +246,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 +315,156 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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 +473,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 +500,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana } } ImGui::End(); + ImGui::PopStyleVar(); if (!windowOpen) { open = false; @@ -410,7 +509,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 +519,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; } } } diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index 62f49593..eeff7c41 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -7,9 +7,20 @@ #include "pipeline/blp_loader.hpp" #include "pipeline/dbc_layout.hpp" #include +#include 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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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 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 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 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(tint.x * 255), static_cast(tint.y * 255), + static_cast(tint.z * 255), static_cast(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(borderColor.x * 255), static_cast(borderColor.y * 255), + static_cast(borderColor.z * 255), static_cast(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(currentRank), " maxRank=", static_cast(talent.maxRank), - " unspent=", static_cast(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(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) { From 0d87a86516c57aa19f6b5098fca6d16f293aa103 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 14:59:09 -0800 Subject: [PATCH 12/13] 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 --- src/ui/spellbook_screen.cpp | 92 ++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 243c4f2e..f899a581 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -156,9 +156,18 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { void SpellbookScreen::categorizeSpells(const std::unordered_set& knownSpells) { spellTabs.clear(); - 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 professions (Cooking, First Aid, Fishing, Riding) - std::map> specialtySpells; + // Riding skill line ID — mount spells are linked here via SkillLineAbility + static constexpr uint32_t SKILLLINE_RIDING = 762; + + // Buckets + std::map> specSpells; // skillLineId -> spells (class specs) + std::map> profSpells; // skillLineId -> spells (professions) + std::vector mountSpells; std::vector generalSpells; for (uint32_t spellId : knownSpells) { @@ -170,11 +179,35 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& 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: spells linked to Riding skill line (762) + if (skillLineId == SKILLLINE_RIDING) { + mountSpells.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 (Alchemy, Blacksmithing, etc.) + if (cat == CAT_PROFESSION) { + profSpells[skillLineId].push_back(info); + continue; + } + + // Secondary professions (Cooking, First Aid, Fishing — but NOT Riding, already handled) + if (cat == CAT_SECONDARY) { + profSpells[skillLineId].push_back(info); + continue; + } + } } generalSpells.push_back(info); @@ -182,25 +215,50 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; }; - std::vector>> named; - for (auto& [skillLineId, spells] : specialtySpells) { - auto nameIt = skillLineNames.find(skillLineId); - std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : "Specialty"; - std::sort(spells.begin(), spells.end(), byName); - named.push_back({std::move(tabName), std::move(spells)}); - } - std::sort(named.begin(), named.end(), - [](const auto& a, const auto& b) { return a.first < b.first; }); - - for (auto& [name, spells] : named) { - spellTabs.push_back({std::move(name), std::move(spells)}); + // 1. Class spec tabs (sorted alphabetically by spec name) + { + std::vector>> named; + for (auto& [skillLineId, spells] : specSpells) { + auto nameIt = skillLineNames.find(skillLineId); + std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : "Spec"; + 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)}); + } } + // 2. General tab (everything not in a specific category) if (!generalSpells.empty()) { std::sort(generalSpells.begin(), generalSpells.end(), byName); spellTabs.push_back({"General", std::move(generalSpells)}); } + // 3. Professions tabs (primary + secondary, each skill line gets its own tab) + { + std::vector>> named; + for (auto& [skillLineId, spells] : profSpells) { + auto nameIt = skillLineNames.find(skillLineId); + std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : "Profession"; + 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)}); + } + } + + // 4. Mounts tab + if (!mountSpells.empty()) { + std::sort(mountSpells.begin(), mountSpells.end(), byName); + spellTabs.push_back({"Mounts", std::move(mountSpells)}); + } + lastKnownSpellCount = knownSpells.size(); } From 7982815a673b218f3cc0c8323b8e71374e9a5d23 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 15:04:17 -0800 Subject: [PATCH 13/13] Add Companions tab to spellbook for vanity pets (SkillLine 778) - 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 --- src/ui/spellbook_screen.cpp | 63 ++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index f899a581..73a43091 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -159,15 +159,17 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known // 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 professions (Cooking, First Aid, Fishing, Riding) + static constexpr uint32_t CAT_SECONDARY = 9; // Secondary skills (Cooking, First Aid, Fishing, Riding, Companions) - // Riding skill line ID — mount spells are linked here via SkillLineAbility - static constexpr uint32_t SKILLLINE_RIDING = 762; + // 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> specSpells; // skillLineId -> spells (class specs) - std::map> profSpells; // skillLineId -> spells (professions) + std::map> specSpells; // class spec trees + std::map> profSpells; // professions + secondary std::vector mountSpells; + std::vector companionSpells; std::vector generalSpells; for (uint32_t spellId : knownSpells) { @@ -180,12 +182,18 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known if (slIt != spellToSkillLine.end()) { uint32_t skillLineId = slIt->second; - // Mounts: spells linked to Riding skill line (762) + // 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; @@ -196,13 +204,13 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known continue; } - // Primary professions (Alchemy, Blacksmithing, etc.) + // Primary professions if (cat == CAT_PROFESSION) { profSpells[skillLineId].push_back(info); continue; } - // Secondary professions (Cooking, First Aid, Fishing — but NOT Riding, already handled) + // Secondary skills (Cooking, First Aid, Fishing) if (cat == CAT_SECONDARY) { profSpells[skillLineId].push_back(info); continue; @@ -215,12 +223,13 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; }; - // 1. Class spec tabs (sorted alphabetically by spec name) - { + // Helper: add sorted skill-line-grouped tabs + auto addGroupedTabs = [&](std::map>& groups, + const char* fallbackName) { std::vector>> named; - for (auto& [skillLineId, spells] : specSpells) { + for (auto& [skillLineId, spells] : groups) { auto nameIt = skillLineNames.find(skillLineId); - std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : "Spec"; + 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)}); } @@ -229,29 +238,19 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known for (auto& [name, spells] : named) { spellTabs.push_back({std::move(name), std::move(spells)}); } - } + }; - // 2. General tab (everything not in a specific category) + // 1. Class spec tabs + addGroupedTabs(specSpells, "Spec"); + + // 2. General tab if (!generalSpells.empty()) { std::sort(generalSpells.begin(), generalSpells.end(), byName); spellTabs.push_back({"General", std::move(generalSpells)}); } - // 3. Professions tabs (primary + secondary, each skill line gets its own tab) - { - std::vector>> named; - for (auto& [skillLineId, spells] : profSpells) { - auto nameIt = skillLineNames.find(skillLineId); - std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : "Profession"; - 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)}); - } - } + // 3. Professions tabs + addGroupedTabs(profSpells, "Profession"); // 4. Mounts tab if (!mountSpells.empty()) { @@ -259,6 +258,12 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known 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(); }