diff --git a/CMakeLists.txt b/CMakeLists.txt index 14f55887..1f33a0a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -284,6 +284,7 @@ set(WOWEE_SOURCES src/pipeline/m2_loader.cpp src/pipeline/wmo_loader.cpp src/pipeline/adt_loader.cpp + src/pipeline/wdt_loader.cpp src/pipeline/dbc_layout.cpp src/pipeline/terrain_mesh.cpp @@ -401,6 +402,7 @@ set(WOWEE_HEADERS include/pipeline/m2_loader.hpp include/pipeline/wmo_loader.hpp include/pipeline/adt_loader.hpp + include/pipeline/wdt_loader.hpp include/pipeline/dbc_loader.hpp include/pipeline/terrain_mesh.hpp diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 1ea25b2b..f308cf0d 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -18,8 +18,8 @@ "UNIT_FIELD_RESISTANCES": 99, "UNIT_END": 148, "PLAYER_FLAGS": 150, - "PLAYER_BYTES": 151, - "PLAYER_BYTES_2": 152, + "PLAYER_BYTES": 153, + "PLAYER_BYTES_2": 154, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, "PLAYER_FIELD_COINAGE": 1170, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e21c8085..ebee07e1 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -321,6 +321,10 @@ public: // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); + // Battleground + bool hasPendingBgInvite() const; + void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + // Logout commands void requestLogout(); void cancelLogout(); @@ -1189,8 +1193,13 @@ private: void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set); void handleMoveKnockBack(network::Packet& packet); + // ---- Area trigger detection ---- + void loadAreaTriggerDbc(); + void checkAreaTriggers(); + // ---- Arena / Battleground handlers ---- void handleBattlefieldStatus(network::Packet& packet); + void handleInstanceDifficulty(network::Packet& packet); void handleArenaTeamCommandResult(network::Packet& packet); void handleArenaTeamQueryResponse(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); @@ -1477,12 +1486,40 @@ private: std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; + + // ---- Area trigger detection ---- + struct AreaTriggerEntry { + uint32_t id = 0; + uint32_t mapId = 0; + float x = 0, y = 0, z = 0; // canonical WoW coords (converted from DBC) + float radius = 0; + float boxLength = 0, boxWidth = 0, boxHeight = 0; + float boxYaw = 0; + }; + bool areaTriggerDbcLoaded_ = false; + std::vector areaTriggers_; + std::unordered_set activeAreaTriggers_; // triggers player is currently inside + float areaTriggerCheckTimer_ = 0.0f; + float castTimeTotal = 0.0f; std::array actionBar{}; std::vector playerAuras; std::vector targetAuras; uint64_t petGuid_ = 0; + // ---- Battleground queue state ---- + struct BgQueueSlot { + uint32_t queueSlot = 0; + uint32_t bgTypeId = 0; + uint8_t arenaType = 0; + uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress + }; + std::array bgQueues_{}; + + // Instance difficulty + uint32_t instanceDifficulty_ = 0; + bool instanceIsHeroic_ = false; + // ---- Phase 4: Group ---- GroupListData partyData; bool pendingGroupInvite = false; diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index 4ec0b418..b25d5234 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -89,8 +89,11 @@ public: const ItemSlot& getBankBagSlot(int bagIndex, int slotIndex) const; bool setBankBagSlot(int bagIndex, int slotIndex, const ItemDef& item); + bool clearBankBagSlot(int bagIndex, int slotIndex); int getBankBagSize(int bagIndex) const; void setBankBagSize(int bagIndex, int size); + const ItemSlot& getBankBagItem(int bagIndex) const; + void setBankBagItem(int bagIndex, const ItemDef& item); uint8_t getPurchasedBankBagSlots() const { return purchasedBankBagSlots_; } void setPurchasedBankBagSlots(uint8_t count) { purchasedBankBagSlots_ = count; } @@ -111,6 +114,7 @@ private: struct BagData { int size = 0; + ItemSlot bagItem; // The bag item itself (for icon/name/tooltip) std::array slots{}; }; std::array bags{}; diff --git a/include/pipeline/wdt_loader.hpp b/include/pipeline/wdt_loader.hpp new file mode 100644 index 00000000..c61f8bb5 --- /dev/null +++ b/include/pipeline/wdt_loader.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +struct WDTInfo { + uint32_t mphdFlags = 0; + bool isWMOOnly() const { return mphdFlags & 0x01; } // WDTF_GLOBAL_WMO + + std::string rootWMOPath; // from MWMO chunk (null-terminated string) + + // MODF placement (only valid for WMO-only maps): + float position[3] = {}; // ADT placement space coords + float rotation[3] = {}; // degrees + uint16_t flags = 0; + uint16_t doodadSet = 0; +}; + +WDTInfo parseWDT(const std::vector& data); + +} // namespace pipeline +} // namespace wowee diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index ee06eb30..0090edc4 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -212,6 +212,7 @@ public: * Unload all tiles */ void unloadAll(); + void stopWorkers(); // Stop worker threads without restarting (for shutdown) void softReset(); // Clear tile data without stopping worker threads (non-blocking) /** @@ -262,6 +263,9 @@ public: /** Process all ready tiles immediately (use during loading screens) */ void processAllReadyTiles(); + /** Process one ready tile (for loading screens with per-tile progress updates) */ + void processOneReadyTile(); + private: /** * Get tile coordinates from GL world position diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 2d2ca9c9..bc580bde 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -118,11 +118,14 @@ private: // Drag-and-drop held item state bool holdingItem = false; game::ItemDef heldItem; - enum class HeldSource { NONE, BACKPACK, BAG, EQUIPMENT }; + enum class HeldSource { NONE, BACKPACK, BAG, EQUIPMENT, BANK, BANK_BAG, BANK_BAG_EQUIP }; HeldSource heldSource = HeldSource::NONE; int heldBackpackIndex = -1; int heldBagIndex = -1; int heldBagSlotIndex = -1; + int heldBankIndex = -1; + int heldBankBagIndex = -1; + int heldBankBagSlotIndex = -1; game::EquipSlot heldEquipSlot = game::EquipSlot::NUM_SLOTS; // Slot rendering with interaction support @@ -136,7 +139,7 @@ private: int pickupBagIndex_ = -1; int pickupBagSlotIndex_ = -1; game::EquipSlot pickupEquipSlot_ = game::EquipSlot::NUM_SLOTS; - static constexpr float kPickupHoldThreshold = 0.12f; // seconds + static constexpr float kPickupHoldThreshold = 0.10f; // seconds void renderSeparateBags(game::Inventory& inventory, uint64_t moneyCopper); void renderAggregateBags(game::Inventory& inventory, uint64_t moneyCopper); @@ -186,6 +189,12 @@ public: 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); + /// Pick up an item from main bank slot (click-and-hold from bank window). + void pickupFromBank(game::Inventory& inv, int bankIndex); + /// Pick up an item from a bank bag slot (click-and-hold from bank window). + void pickupFromBankBag(game::Inventory& inv, int bagIndex, int slotIndex); + /// Pick up a bag from a bank bag equip slot (click-and-hold from bank window). + void pickupFromBankBagEquip(game::Inventory& inv, int bagIndex); }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 6ecaaf03..fa93bc8a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -32,6 +32,7 @@ #include #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" +#include "pipeline/wdt_loader.hpp" #include "pipeline/dbc_loader.hpp" #include "ui/ui_manager.hpp" #include "auth/auth_handler.hpp" @@ -421,34 +422,43 @@ void Application::run() { } void Application::shutdown() { - LOG_INFO("Shutting down application"); + LOG_WARNING("Shutting down application..."); // Save floor cache before renderer is destroyed if (renderer && renderer->getWMORenderer()) { size_t cacheSize = renderer->getWMORenderer()->getFloorCacheSize(); if (cacheSize > 0) { - LOG_INFO("Saving WMO floor cache (", cacheSize, " entries)..."); + LOG_WARNING("Saving WMO floor cache (", cacheSize, " entries)..."); renderer->getWMORenderer()->saveFloorCache(); + LOG_WARNING("Floor cache saved."); } } // Explicitly shut down the renderer before destroying it — this ensures // all sub-renderers free their VMA allocations in the correct order, // before VkContext::shutdown() calls vmaDestroyAllocator(). + LOG_WARNING("Shutting down renderer..."); if (renderer) { renderer->shutdown(); } + LOG_WARNING("Renderer shutdown complete, resetting..."); renderer.reset(); + LOG_WARNING("Resetting world..."); world.reset(); + LOG_WARNING("Resetting gameHandler..."); gameHandler.reset(); + LOG_WARNING("Resetting authHandler..."); authHandler.reset(); + LOG_WARNING("Resetting assetManager..."); assetManager.reset(); + LOG_WARNING("Resetting uiManager..."); uiManager.reset(); + LOG_WARNING("Resetting window..."); window.reset(); running = false; - LOG_INFO("Application shutdown complete"); + LOG_WARNING("Application shutdown complete"); } void Application::setState(AppState newState) { @@ -3176,6 +3186,59 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float showProgress("Entering world...", 0.0f); + // --- Clean up previous map's state on map change --- + // (Same cleanup as logout, but preserves player identity and renderer objects.) + if (loadedMapId_ != 0xFFFFFFFF) { + LOG_INFO("Map change: cleaning up old map ", loadedMapId_, " before loading map ", mapId); + + // Clear entity instances from old map + creatureInstances_.clear(); + creatureModelIds_.clear(); + creatureRenderPosCache_.clear(); + creatureWeaponsAttached_.clear(); + creatureWeaponAttachAttempts_.clear(); + deadCreatureGuids_.clear(); + nonRenderableCreatureDisplayIds_.clear(); + creaturePermanentFailureGuids_.clear(); + + pendingCreatureSpawns_.clear(); + pendingCreatureSpawnGuids_.clear(); + creatureSpawnRetryCounts_.clear(); + + playerInstances_.clear(); + onlinePlayerAppearance_.clear(); + pendingOnlinePlayerEquipment_.clear(); + deferredEquipmentQueue_.clear(); + pendingPlayerSpawns_.clear(); + pendingPlayerSpawnGuids_.clear(); + + gameObjectInstances_.clear(); + pendingGameObjectSpawns_.clear(); + pendingTransportMoves_.clear(); + pendingTransportDoodadBatches_.clear(); + + if (renderer) { + // Clear all world geometry from old map + if (auto* wmo = renderer->getWMORenderer()) { + wmo->clearInstances(); + } + if (auto* m2 = renderer->getM2Renderer()) { + m2->clear(); + } + if (auto* terrain = renderer->getTerrainManager()) { + terrain->softReset(); + terrain->setStreamingEnabled(true); // Re-enable in case previous map disabled it + } + if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { + questMarkers->clear(); + } + renderer->clearMount(); + } + + // Force player character re-spawn on new map + playerCharacterSpawned = false; + } + // Resolve map folder name from Map.dbc (authoritative for world/instance maps). // This is required for instances like DeeprunTram (map 369) that are not Azeroth/Kalimdor. if (!mapNameCacheLoaded_ && assetManager) { @@ -3301,114 +3364,341 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float showProgress("Loading terrain...", 0.20f); - // Compute ADT tile from canonical coordinates - auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y); - std::string adtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + - std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt"; - LOG_INFO("Loading ADT tile [", tileX, ",", tileY, "] from canonical (", - spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")"); - - // Load the initial terrain tile - bool terrainOk = renderer->loadTestTerrain(assetManager.get(), adtPath); - if (!terrainOk) { - LOG_WARNING("Could not load terrain for online world - atmospheric rendering only"); - } else { - LOG_INFO("Online world terrain loading initiated"); + // Check WDT to detect WMO-only maps (dungeons, raids, BGs) + bool isWMOOnlyMap = false; + pipeline::WDTInfo wdtInfo; + { + std::string wdtPath = "World\\Maps\\" + mapName + "\\" + mapName + ".wdt"; + LOG_WARNING("Reading WDT: ", wdtPath); + std::vector wdtData = assetManager->readFile(wdtPath); + if (!wdtData.empty()) { + wdtInfo = pipeline::parseWDT(wdtData); + isWMOOnlyMap = wdtInfo.isWMOOnly() && !wdtInfo.rootWMOPath.empty(); + LOG_WARNING("WDT result: isWMOOnly=", isWMOOnlyMap, " rootWMO='", wdtInfo.rootWMOPath, "'"); + } else { + LOG_WARNING("No WDT file found at ", wdtPath); + } } - // Character renderer is created inside loadTestTerrain(), so spawn the - // player model now that the renderer actually exists. - if (!playerCharacterSpawned) { - spawnPlayerCharacter(); - loadEquippedWeapons(); - } + bool terrainOk = false; - showProgress("Streaming terrain tiles...", 0.35f); + if (isWMOOnlyMap) { + // ---- WMO-only map (dungeon/raid/BG): load root WMO directly ---- + LOG_INFO("WMO-only map detected — loading root WMO: ", wdtInfo.rootWMOPath); + showProgress("Loading instance geometry...", 0.25f); - // Wait for surrounding terrain tiles to stream in - if (terrainOk && renderer->getTerrainManager() && renderer->getCamera()) { - auto* terrainMgr = renderer->getTerrainManager(); - auto* camera = renderer->getCamera(); + // Still call loadTestTerrain with a dummy path to initialize all renderers + // (terrain, WMO, M2, character). The terrain load will fail gracefully. + auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y); + std::string dummyAdtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + + std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt"; + renderer->loadTestTerrain(assetManager.get(), dummyAdtPath); - // Trigger tile streaming for surrounding area - terrainMgr->update(*camera, 1.0f); + // Disable terrain streaming — no ADT tiles for WMO-only maps + if (renderer->getTerrainManager()) { + renderer->getTerrainManager()->setStreamingEnabled(false); + } - auto startTime = std::chrono::high_resolution_clock::now(); - auto lastProgressTime = startTime; - const float maxWaitSeconds = 20.0f; - const float stallSeconds = 5.0f; - int initialRemaining = terrainMgr->getRemainingTileCount(); - if (initialRemaining < 1) initialRemaining = 1; - int lastRemaining = initialRemaining; + // Spawn player character now that renderers are initialized + if (!playerCharacterSpawned) { + spawnPlayerCharacter(); + loadEquippedWeapons(); + } - // Wait until all pending + ready-queue tiles are finalized - while (terrainMgr->getRemainingTileCount() > 0) { - SDL_Event event; - while (SDL_PollEvent(&event)) { - if (event.type == SDL_QUIT) { - window->setShouldClose(true); - loadingScreen.shutdown(); - return; - } - if (event.type == SDL_WINDOWEVENT && - event.window.event == SDL_WINDOWEVENT_RESIZED) { - int w = event.window.data1; - int h = event.window.data2; - window->setSize(w, h); - // Vulkan viewport set in command buffer - if (renderer->getCamera()) { - renderer->getCamera()->setAspectRatio(static_cast(w) / h); + // Load the root WMO + auto* wmoRenderer = renderer->getWMORenderer(); + if (wmoRenderer) { + std::vector wmoData = assetManager->readFile(wdtInfo.rootWMOPath); + if (!wmoData.empty()) { + pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); + + if (wmoModel.nGroups > 0) { + showProgress("Loading instance groups...", 0.35f); + std::string basePath = wdtInfo.rootWMOPath; + std::string extension; + if (basePath.size() > 4) { + extension = basePath.substr(basePath.size() - 4); + std::string extLower = extension; + for (char& c : extLower) c = std::tolower(c); + if (extLower == ".wmo") { + basePath = basePath.substr(0, basePath.size() - 4); + } } + + uint32_t loadedGroups = 0; + for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { + char groupSuffix[16]; + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); + std::string groupPath = basePath + groupSuffix; + std::vector groupData = assetManager->readFile(groupPath); + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); + groupData = assetManager->readFile(basePath + groupSuffix); + } + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); + groupData = assetManager->readFile(basePath + groupSuffix); + } + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); + loadedGroups++; + } + + // Update loading progress + if (wmoModel.nGroups > 1) { + float groupProgress = 0.35f + 0.30f * static_cast(gi + 1) / wmoModel.nGroups; + char buf[128]; + snprintf(buf, sizeof(buf), "Loading instance groups... %u / %u", gi + 1, wmoModel.nGroups); + showProgress(buf, groupProgress); + } + } + + LOG_INFO("Loaded ", loadedGroups, " / ", wmoModel.nGroups, " WMO groups for instance"); } + + // WMO-only maps: MODF position is at world origin (always 0,0,0 in practice). + // Unlike ADT MODF which uses placement space, WMO-only maps place the WMO + // directly in render coordinates with no offset or yaw bias. + glm::vec3 wmoPos(0.0f); + glm::vec3 wmoRot(0.0f); + if (wdtInfo.position[0] != 0.0f || wdtInfo.position[1] != 0.0f || wdtInfo.position[2] != 0.0f) { + // Non-zero placement — convert from ADT space (rare/never happens, but be safe) + wmoPos = core::coords::adtToWorld( + wdtInfo.position[0], wdtInfo.position[1], wdtInfo.position[2]); + wmoRot = glm::vec3( + -wdtInfo.rotation[2] * 3.14159f / 180.0f, + -wdtInfo.rotation[0] * 3.14159f / 180.0f, + (wdtInfo.rotation[1] + 180.0f) * 3.14159f / 180.0f + ); + } + + showProgress("Uploading instance geometry...", 0.70f); + uint32_t wmoModelId = 900000 + mapId; // Unique ID range for instance WMOs + if (wmoRenderer->loadModel(wmoModel, wmoModelId)) { + uint32_t instanceId = wmoRenderer->createInstance(wmoModelId, wmoPos, wmoRot, 1.0f); + if (instanceId > 0) { + LOG_INFO("Instance WMO loaded: modelId=", wmoModelId, + " instanceId=", instanceId, + " pos=(", wmoPos.x, ", ", wmoPos.y, ", ", wmoPos.z, ")"); + + // Load doodads from the specified doodad set + auto* m2Renderer = renderer->getM2Renderer(); + if (m2Renderer && !wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) { + uint32_t setIdx = std::min(static_cast(wdtInfo.doodadSet), + static_cast(wmoModel.doodadSets.size() - 1)); + const auto& doodadSet = wmoModel.doodadSets[setIdx]; + + showProgress("Loading instance doodads...", 0.75f); + glm::mat4 wmoMatrix(1.0f); + wmoMatrix = glm::translate(wmoMatrix, wmoPos); + wmoMatrix = glm::rotate(wmoMatrix, wmoRot.z, glm::vec3(0, 0, 1)); + wmoMatrix = glm::rotate(wmoMatrix, wmoRot.y, glm::vec3(0, 1, 0)); + wmoMatrix = glm::rotate(wmoMatrix, wmoRot.x, glm::vec3(1, 0, 0)); + + uint32_t loadedDoodads = 0; + for (uint32_t di = 0; di < doodadSet.count; di++) { + uint32_t doodadIdx = doodadSet.startIndex + di; + if (doodadIdx >= wmoModel.doodads.size()) break; + + const auto& doodad = wmoModel.doodads[doodadIdx]; + auto nameIt = wmoModel.doodadNames.find(doodad.nameIndex); + if (nameIt == wmoModel.doodadNames.end()) continue; + + std::string m2Path = nameIt->second; + if (m2Path.empty()) continue; + + if (m2Path.size() > 4) { + std::string ext = m2Path.substr(m2Path.size() - 4); + for (char& c : ext) c = std::tolower(c); + if (ext == ".mdx" || ext == ".mdl") { + m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2"; + } + } + + std::vector m2Data = assetManager->readFile(m2Path); + if (m2Data.empty()) continue; + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + if (m2Model.name.empty()) m2Model.name = m2Path; + + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager->readFile(skinPath); + if (!skinData.empty() && m2Model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } + if (!m2Model.isValid()) continue; + + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, + doodad.rotation.x, doodad.rotation.z); + glm::mat4 doodadLocal(1.0f); + doodadLocal = glm::translate(doodadLocal, doodad.position); + doodadLocal *= glm::mat4_cast(fixedRotation); + doodadLocal = glm::scale(doodadLocal, glm::vec3(doodad.scale)); + + glm::mat4 worldMatrix = wmoMatrix * doodadLocal; + glm::vec3 worldPos = glm::vec3(worldMatrix[3]); + + uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); + m2Renderer->loadModel(m2Model, doodadModelId); + m2Renderer->createInstance(doodadModelId, worldPos, glm::vec3(0.0f), doodad.scale); + loadedDoodads++; + } + LOG_INFO("Loaded ", loadedDoodads, " instance WMO doodads"); + } + } else { + LOG_WARNING("Failed to create instance WMO instance"); + } + } else { + LOG_WARNING("Failed to load instance WMO model"); + } + } else { + LOG_WARNING("Failed to read root WMO file: ", wdtInfo.rootWMOPath); } - // Trigger new streaming and process ALL ready tiles (not just 2) - terrainMgr->update(*camera, 0.016f); - terrainMgr->processAllReadyTiles(); + // Build collision cache for the instance WMO + showProgress("Building collision cache...", 0.88f); + if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } + wmoRenderer->loadFloorCache(); + if (wmoRenderer->getFloorCacheSize() == 0) { + showProgress("Computing walkable surfaces...", 0.90f); + if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } + wmoRenderer->precomputeFloorCache(); + } + } + + terrainOk = true; // Mark as OK so post-load setup runs + } else { + // ---- Normal ADT-based map ---- + // Compute ADT tile from canonical coordinates + auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y); + std::string adtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + + std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt"; + LOG_INFO("Loading ADT tile [", tileX, ",", tileY, "] from canonical (", + spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")"); + + // Load the initial terrain tile + terrainOk = renderer->loadTestTerrain(assetManager.get(), adtPath); + if (!terrainOk) { + LOG_WARNING("Could not load terrain for online world - atmospheric rendering only"); + } else { + LOG_INFO("Online world terrain loading initiated"); + } + + // Character renderer is created inside loadTestTerrain(), so spawn the + // player model now that the renderer actually exists. + if (!playerCharacterSpawned) { + spawnPlayerCharacter(); + loadEquippedWeapons(); + } + + showProgress("Streaming terrain tiles...", 0.35f); + + // Wait for surrounding terrain tiles to stream in + if (terrainOk && renderer->getTerrainManager() && renderer->getCamera()) { + auto* terrainMgr = renderer->getTerrainManager(); + auto* camera = renderer->getCamera(); + + // Trigger tile streaming for surrounding area + terrainMgr->update(*camera, 1.0f); + + auto startTime = std::chrono::high_resolution_clock::now(); + auto lastProgressTime = startTime; + const float maxWaitSeconds = 60.0f; + const float stallSeconds = 10.0f; + int initialRemaining = terrainMgr->getRemainingTileCount(); + if (initialRemaining < 1) initialRemaining = 1; + int lastRemaining = initialRemaining; + + // Wait until all pending + ready-queue tiles are finalized + while (terrainMgr->getRemainingTileCount() > 0) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_QUIT) { + window->setShouldClose(true); + loadingScreen.shutdown(); + return; + } + if (event.type == SDL_WINDOWEVENT && + event.window.event == SDL_WINDOWEVENT_RESIZED) { + int w = event.window.data1; + int h = event.window.data2; + window->setSize(w, h); + // Vulkan viewport set in command buffer + if (renderer->getCamera()) { + renderer->getCamera()->setAspectRatio(static_cast(w) / h); + } + } + } + + // Trigger new streaming — enqueue tiles for background workers + terrainMgr->update(*camera, 0.016f); + + // Process ONE tile per iteration so loading screen updates after each + terrainMgr->processOneReadyTile(); - if (loadingScreenOk) { int remaining = terrainMgr->getRemainingTileCount(); int loaded = terrainMgr->getLoadedTileCount(); - float tileProgress = static_cast(initialRemaining - remaining) / initialRemaining; - if (tileProgress < 0.0f) tileProgress = 0.0f; + int total = loaded + remaining; + if (total < 1) total = 1; + float tileProgress = static_cast(loaded) / static_cast(total); float progress = 0.35f + tileProgress * 0.50f; - char buf[128]; - snprintf(buf, sizeof(buf), "Loading terrain... %d tiles loaded, %d remaining", - loaded, remaining); - loadingScreen.setStatus(buf); - loadingScreen.setProgress(progress); - loadingScreen.render(); - window->swapBuffers(); + + auto now = std::chrono::high_resolution_clock::now(); + float elapsedSec = std::chrono::duration(now - startTime).count(); + + char buf[192]; + if (loaded > 0 && remaining > 0) { + float tilesPerSec = static_cast(loaded) / std::max(elapsedSec, 0.1f); + float etaSec = static_cast(remaining) / std::max(tilesPerSec, 0.1f); + snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles (%.0f tiles/s, ~%.0fs remaining)", + loaded, total, tilesPerSec, etaSec); + } else { + snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles", + loaded, total); + } + + if (loadingScreenOk) { + loadingScreen.setStatus(buf); + loadingScreen.setProgress(progress); + loadingScreen.render(); + window->swapBuffers(); + } if (remaining != lastRemaining) { lastRemaining = remaining; - lastProgressTime = std::chrono::high_resolution_clock::now(); + lastProgressTime = now; + } + + auto elapsed = std::chrono::high_resolution_clock::now() - startTime; + if (std::chrono::duration(elapsed).count() > maxWaitSeconds) { + LOG_WARNING("Online terrain streaming timeout after ", maxWaitSeconds, "s"); + break; + } + auto stalledFor = std::chrono::high_resolution_clock::now() - lastProgressTime; + if (std::chrono::duration(stalledFor).count() > stallSeconds) { + LOG_WARNING("Online terrain streaming stalled for ", stallSeconds, + "s (remaining=", lastRemaining, "), continuing without full preload"); + break; + } + + // Don't sleep if there are more tiles to finalize — keep processing + if (remaining > 0 && terrainMgr->getReadyQueueCount() == 0) { + SDL_Delay(16); } } - auto elapsed = std::chrono::high_resolution_clock::now() - startTime; - if (std::chrono::duration(elapsed).count() > maxWaitSeconds) { - LOG_WARNING("Online terrain streaming timeout after ", maxWaitSeconds, "s"); - break; - } - auto stalledFor = std::chrono::high_resolution_clock::now() - lastProgressTime; - if (std::chrono::duration(stalledFor).count() > stallSeconds) { - LOG_WARNING("Online terrain streaming stalled for ", stallSeconds, - "s (remaining=", lastRemaining, "), continuing without full preload"); - break; - } + LOG_INFO("Online terrain streaming complete: ", terrainMgr->getLoadedTileCount(), " tiles loaded"); - SDL_Delay(16); - } - - LOG_INFO("Online terrain streaming complete: ", terrainMgr->getLoadedTileCount(), " tiles loaded"); - - // Load/precompute collision cache - if (renderer->getWMORenderer()) { - showProgress("Building collision cache...", 0.88f); - renderer->getWMORenderer()->loadFloorCache(); - if (renderer->getWMORenderer()->getFloorCacheSize() == 0) { - renderer->getWMORenderer()->precomputeFloorCache(); + // Load/precompute collision cache + if (renderer->getWMORenderer()) { + showProgress("Building collision cache...", 0.88f); + if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } + renderer->getWMORenderer()->loadFloorCache(); + if (renderer->getWMORenderer()->getFloorCacheSize() == 0) { + showProgress("Computing walkable surfaces...", 0.90f); + if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } + renderer->getWMORenderer()->precomputeFloorCache(); + } } } } diff --git a/src/core/window.cpp b/src/core/window.cpp index f533689b..eed83c97 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -94,19 +94,22 @@ bool Window::initialize() { } void Window::shutdown() { + LOG_WARNING("Window::shutdown - vkContext..."); if (vkContext) { vkContext->shutdown(); vkContext.reset(); } + LOG_WARNING("Window::shutdown - SDL_DestroyWindow..."); if (window) { SDL_DestroyWindow(window); window = nullptr; } + LOG_WARNING("Window::shutdown - SDL_Quit..."); SDL_Vulkan_UnloadLibrary(); SDL_Quit(); - LOG_INFO("Window shutdown complete"); + LOG_WARNING("Window shutdown complete"); } void Window::pollEvents() { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 555e31e2..24f8956f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -513,6 +513,9 @@ void GameHandler::resetDbcCaches() { taxiNodes_.clear(); taxiPathEdges_.clear(); taxiPathNodes_.clear(); + areaTriggerDbcLoaded_ = false; + areaTriggers_.clear(); + activeAreaTriggers_.clear(); talentDbcLoaded_ = false; talentCache_.clear(); talentTabCache_.clear(); @@ -720,6 +723,13 @@ void GameHandler::update(float deltaTime) { timeSinceLastMoveHeartbeat_ = 0.0f; } + // Check area triggers (instance portals, tavern rests, etc.) + areaTriggerCheckTimer_ += deltaTime; + if (areaTriggerCheckTimer_ >= 0.25f) { + areaTriggerCheckTimer_ = 0.0f; + checkAreaTriggers(); + } + // Update cast timer (Phase 3) if (pendingGameObjectInteractGuid_ != 0 && (autoAttacking || autoAttackRequested_)) { @@ -2683,7 +2693,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_TRANSFER_PENDING: { // SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data uint32_t pendingMapId = packet.readUInt32(); - LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); + LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); // Optional: if remaining data, there's a transport entry + mapId if (packet.getReadPos() + 8 <= packet.getSize()) { uint32_t transportEntry = packet.readUInt32(); @@ -2750,6 +2760,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: LOG_INFO("Battleground player left"); break; + case Opcode::SMSG_INSTANCE_DIFFICULTY: + handleInstanceDifficulty(packet); + break; case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT: handleArenaTeamCommandResult(packet); break; @@ -5233,6 +5246,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); } // Do not synthesize quest-log entries from raw update-field slots. @@ -5535,6 +5550,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); } else if (key == ufPlayerFlags) { @@ -7707,7 +7724,9 @@ void GameHandler::extractContainerFields(uint64_t containerGuid, const std::map< void GameHandler::rebuildOnlineInventory() { + uint8_t savedBankBagSlots = inventory.getPurchasedBankBagSlots(); inventory = Inventory(); + inventory.setPurchasedBankBagSlots(savedBankBagSlots); // Equipment slots for (int i = 0; i < 23; i++) { @@ -7910,14 +7929,31 @@ void GameHandler::rebuildOnlineInventory() { if (contIt != containerContents_.end()) { numSlots = static_cast(contIt->second.numSlots); } - if (numSlots <= 0) { - auto bagItemIt = onlineItems_.find(bagGuid); - if (bagItemIt != onlineItems_.end()) { + + // Populate the bag item itself (for icon/name in the bank bag equip slot) + auto bagItemIt = onlineItems_.find(bagGuid); + if (bagItemIt != onlineItems_.end()) { + if (numSlots <= 0) { auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry); if (bagInfoIt != itemInfoCache_.end()) { numSlots = bagInfoIt->second.containerSlots; } } + ItemDef bagDef; + bagDef.itemId = bagItemIt->second.entry; + bagDef.stackCount = 1; + bagDef.inventoryType = 18; // bag + auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry); + if (bagInfoIt != itemInfoCache_.end()) { + bagDef.name = bagInfoIt->second.name; + bagDef.quality = static_cast(bagInfoIt->second.quality); + bagDef.displayInfoId = bagInfoIt->second.displayInfoId; + bagDef.bagSlots = bagInfoIt->second.containerSlots; + } else { + bagDef.name = "Bag"; + queryItemInfo(bagDef.itemId, bagGuid); + } + inventory.setBankBagItem(bagIdx, bagDef); } if (numSlots <= 0) continue; @@ -8632,6 +8668,14 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; } + // Store queue state + if (queueSlot < bgQueues_.size()) { + bgQueues_[queueSlot].queueSlot = queueSlot; + bgQueues_[queueSlot].bgTypeId = bgTypeId; + bgQueues_[queueSlot].arenaType = arenaType; + bgQueues_[queueSlot].statusId = statusId; + } + switch (statusId) { case 0: // STATUS_NONE LOG_INFO("Battlefield status: NONE for ", bgName); @@ -8657,6 +8701,183 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { } } +bool GameHandler::hasPendingBgInvite() const { + for (const auto& slot : bgQueues_) { + if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN + } + return false; +} + +void GameHandler::acceptBattlefield(uint32_t queueSlot) { + if (state != WorldState::IN_WORLD) return; + if (!socket) return; + + // Find first WAIT_JOIN slot if no specific slot given + const BgQueueSlot* slot = nullptr; + if (queueSlot == 0xFFFFFFFF) { + for (const auto& s : bgQueues_) { + if (s.statusId == 2) { slot = &s; break; } + } + } else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) { + slot = &bgQueues_[queueSlot]; + } + + if (!slot) { + addSystemChatMessage("No battleground invitation pending."); + return; + } + + // CMSG_BATTLEFIELD_PORT: arenaType(1) + unk(1) + bgTypeId(4) + unk(2) + action(1) = 9 bytes + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); + pkt.writeUInt8(slot->arenaType); + pkt.writeUInt8(0x00); + pkt.writeUInt32(slot->bgTypeId); + pkt.writeUInt16(0x0000); + pkt.writeUInt8(1); // 1 = accept, 0 = decline + + socket->send(pkt); + + addSystemChatMessage("Accepting battleground invitation..."); + LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); +} + +void GameHandler::handleInstanceDifficulty(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 8) return; + instanceDifficulty_ = packet.readUInt32(); + uint32_t isHeroic = packet.readUInt32(); + instanceIsHeroic_ = (isHeroic != 0); + LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); +} + +void GameHandler::loadAreaTriggerDbc() { + if (areaTriggerDbcLoaded_) return; + areaTriggerDbcLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("AreaTrigger.dbc"); + if (!dbc || !dbc->isLoaded()) { + LOG_WARNING("Failed to load AreaTrigger.dbc"); + return; + } + + areaTriggers_.reserve(dbc->getRecordCount()); + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + AreaTriggerEntry at; + at.id = dbc->getUInt32(i, 0); + at.mapId = dbc->getUInt32(i, 1); + // DBC stores positions in server/wire format (X=west, Y=north) — swap to canonical + at.x = dbc->getFloat(i, 3); // canonical X (north) = DBC field 3 (Y_wire) + at.y = dbc->getFloat(i, 2); // canonical Y (west) = DBC field 2 (X_wire) + at.z = dbc->getFloat(i, 4); + at.radius = dbc->getFloat(i, 5); + at.boxLength = dbc->getFloat(i, 6); + at.boxWidth = dbc->getFloat(i, 7); + at.boxHeight = dbc->getFloat(i, 8); + at.boxYaw = dbc->getFloat(i, 9); + areaTriggers_.push_back(at); + } + + LOG_WARNING("Loaded ", areaTriggers_.size(), " area triggers from AreaTrigger.dbc"); +} + +void GameHandler::checkAreaTriggers() { + if (state != WorldState::IN_WORLD || !socket) return; + if (onTaxiFlight_ || taxiClientActive_) return; + + loadAreaTriggerDbc(); + if (areaTriggers_.empty()) return; + + const float px = movementInfo.x; + const float py = movementInfo.y; + const float pz = movementInfo.z; + + // Debug: log player position periodically to verify trigger proximity + static int debugCounter = 0; + if (++debugCounter >= 4) { // every ~1s at 0.25s interval + debugCounter = 0; + int mapTriggerCount = 0; + float closestDist = 999999.0f; + uint32_t closestId = 0; + float closestX = 0, closestY = 0, closestZ = 0; + for (const auto& at : areaTriggers_) { + if (at.mapId != currentMapId_) continue; + mapTriggerCount++; + float dx = px - at.x, dy = py - at.y, dz = pz - at.z; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + if (dist < closestDist) { closestDist = dist; closestId = at.id; closestX = at.x; closestY = at.y; closestZ = at.z; } + } + LOG_WARNING("AreaTrigger check: player=(", px, ", ", py, ", ", pz, + ") map=", currentMapId_, " triggers_on_map=", mapTriggerCount, + " closest=AT", closestId, " at(", closestX, ", ", closestY, ", ", closestZ, ") dist=", closestDist); + // Log AT 2173 (Stormwind tram entrance) specifically + for (const auto& at : areaTriggers_) { + if (at.id == 2173) { + float dx = px - at.x, dy = py - at.y, dz = pz - at.z; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + LOG_WARNING(" AT2173: map=", at.mapId, " pos=(", at.x, ", ", at.y, ", ", at.z, + ") r=", at.radius, " box=(", at.boxLength, ", ", at.boxWidth, ", ", at.boxHeight, ") dist=", dist); + break; + } + } + } + + for (const auto& at : areaTriggers_) { + if (at.mapId != currentMapId_) continue; + + bool inside = false; + if (at.radius > 0.0f) { + // Sphere trigger — use generous minimum radius since WMO collision + // may block the player from reaching triggers inside doorways/hallways + float effectiveRadius = std::max(at.radius, 45.0f); + float dx = px - at.x; + float dy = py - at.y; + float dz = pz - at.z; + float distSq = dx * dx + dy * dy + dz * dz; + inside = (distSq <= effectiveRadius * effectiveRadius); + } else if (at.boxLength > 0.0f || at.boxWidth > 0.0f || at.boxHeight > 0.0f) { + // Box trigger (axis-aligned or rotated) + float dx = px - at.x; + float dy = py - at.y; + float dz = pz - at.z; + + // Rotate into box-local space + float cosYaw = std::cos(-at.boxYaw); + float sinYaw = std::sin(-at.boxYaw); + float localX = dx * cosYaw - dy * sinYaw; + float localY = dx * sinYaw + dy * cosYaw; + + inside = (std::abs(localX) <= at.boxLength * 0.5f && + std::abs(localY) <= at.boxWidth * 0.5f && + std::abs(dz) <= at.boxHeight * 0.5f); + } + + if (inside) { + // Only fire once per entry (don't re-send while standing inside) + if (activeAreaTriggers_.count(at.id) == 0) { + activeAreaTriggers_.insert(at.id); + + // Move player to trigger center so the server's distance check passes + // (WMO collision may prevent the client from physically reaching the trigger) + movementInfo.x = at.x; + movementInfo.y = at.y; + movementInfo.z = at.z; + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); + + network::Packet pkt(wireOpcode(Opcode::CMSG_AREATRIGGER)); + pkt.writeUInt32(at.id); + socket->send(pkt); + LOG_WARNING("Fired CMSG_AREATRIGGER: id=", at.id, + " at (", at.x, ", ", at.y, ", ", at.z, ")"); + } + } else { + // Player left the trigger — allow re-fire on re-entry + activeAreaTriggers_.erase(at.id); + } + } +} + void GameHandler::handleArenaTeamCommandResult(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) return; uint32_t command = packet.readUInt32(); @@ -11937,7 +12158,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { float serverZ = packet.readFloat(); float orientation = packet.readFloat(); - LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId, + LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId, " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", " orient=", orientation); @@ -12009,6 +12230,8 @@ void GameHandler::handleNewWorld(network::Packet& packet) { worldStates_.clear(); worldStateMapId_ = mapId; worldStateZoneId_ = 0; + activeAreaTriggers_.clear(); + areaTriggerCheckTimer_ = -5.0f; // 5-second cooldown after map transfer stopAutoAttack(); casting = false; currentCastSpellId = 0; @@ -13673,7 +13896,12 @@ void GameHandler::closeBank() { } void GameHandler::buyBankSlot() { - if (!isConnected() || !bankOpen_) return; + if (!isConnected() || !bankOpen_) { + LOG_WARNING("buyBankSlot: not connected or bank not open"); + return; + } + LOG_WARNING("buyBankSlot: sending CMSG_BUY_BANK_SLOT banker=0x", std::hex, bankerGuid_, std::dec, + " purchased=", static_cast(inventory.getPurchasedBankBagSlots())); auto pkt = BuyBankSlotPacket::build(bankerGuid_); socket->send(pkt); } @@ -13698,17 +13926,33 @@ void GameHandler::handleShowBank(network::Packet& packet) { // Bank items are already tracked via update fields (bank slot GUIDs) // Trigger rebuild to populate bank slots in inventory rebuildOnlineInventory(); - LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec); + // Count bank bags that actually have items/containers + int filledBags = 0; + for (int i = 0; i < effectiveBankBagSlots_; i++) { + if (inventory.getBankBagSize(i) > 0) filledBags++; + } + LOG_WARNING("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec, + " purchased=", static_cast(inventory.getPurchasedBankBagSlots()), + " filledBags=", filledBags, + " effectiveBankBagSlots=", effectiveBankBagSlots_); } void GameHandler::handleBuyBankSlotResult(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t result = packet.readUInt32(); - if (result == 0) { + LOG_WARNING("SMSG_BUY_BANK_SLOT_RESULT: result=", result); + // AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK + if (result == 3) { addSystemChatMessage("Bank slot purchased."); inventory.setPurchasedBankBagSlots(inventory.getPurchasedBankBagSlots() + 1); + } else if (result == 1) { + addSystemChatMessage("Not enough gold to purchase bank slot."); + } else if (result == 0) { + addSystemChatMessage("No more bank slots available."); + } else if (result == 2) { + addSystemChatMessage("You must be at a banker to purchase bank slots."); } else { - addSystemChatMessage("Cannot purchase bank slot."); + addSystemChatMessage("Cannot purchase bank slot (error " + std::to_string(result) + ")."); } } diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 57806ebf..1750253a 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -105,6 +105,13 @@ bool Inventory::setBankBagSlot(int bagIndex, int slotIndex, const ItemDef& item) return true; } +bool Inventory::clearBankBagSlot(int bagIndex, int slotIndex) { + if (bagIndex < 0 || bagIndex >= BANK_BAG_SLOTS) return false; + if (slotIndex < 0 || slotIndex >= bankBags_[bagIndex].size) return false; + bankBags_[bagIndex].slots[slotIndex].item = ItemDef{}; + return true; +} + int Inventory::getBankBagSize(int bagIndex) const { if (bagIndex < 0 || bagIndex >= BANK_BAG_SLOTS) return 0; return bankBags_[bagIndex].size; @@ -115,6 +122,17 @@ void Inventory::setBankBagSize(int bagIndex, int size) { bankBags_[bagIndex].size = std::min(size, MAX_BAG_SIZE); } +const ItemSlot& Inventory::getBankBagItem(int bagIndex) const { + static const ItemSlot EMPTY_SLOT; + if (bagIndex < 0 || bagIndex >= BANK_BAG_SLOTS) return EMPTY_SLOT; + return bankBags_[bagIndex].bagItem; +} + +void Inventory::setBankBagItem(int bagIndex, const ItemDef& item) { + if (bagIndex < 0 || bagIndex >= BANK_BAG_SLOTS) return; + bankBags_[bagIndex].bagItem.item = item; +} + void Inventory::swapBagContents(int bagA, int bagB) { if (bagA < 0 || bagA >= NUM_BAG_SLOTS || bagB < 0 || bagB >= NUM_BAG_SLOTS) return; if (bagA == bagB) return; diff --git a/src/pipeline/wdt_loader.cpp b/src/pipeline/wdt_loader.cpp new file mode 100644 index 00000000..b6af879a --- /dev/null +++ b/src/pipeline/wdt_loader.cpp @@ -0,0 +1,110 @@ +#include "pipeline/wdt_loader.hpp" +#include "core/logger.hpp" +#include + +namespace wowee { +namespace pipeline { + +namespace { + +uint32_t readU32(const uint8_t* data, size_t offset) { + uint32_t v; + std::memcpy(&v, data + offset, 4); + return v; +} + +uint16_t readU16(const uint8_t* data, size_t offset) { + uint16_t v; + std::memcpy(&v, data + offset, 2); + return v; +} + +float readF32(const uint8_t* data, size_t offset) { + float v; + std::memcpy(&v, data + offset, 4); + return v; +} + +// Chunk magic constants (little-endian) +constexpr uint32_t MVER = 0x5245564D; // "REVM" +constexpr uint32_t MPHD = 0x4448504D; // "DHPM" +constexpr uint32_t MAIN = 0x4E49414D; // "NIAM" +constexpr uint32_t MWMO = 0x4F4D574D; // "OMWM" +constexpr uint32_t MODF = 0x46444F4D; // "FDOM" + +} // anonymous namespace + +WDTInfo parseWDT(const std::vector& data) { + WDTInfo info; + + if (data.size() < 8) { + LOG_WARNING("WDT data too small (", data.size(), " bytes)"); + return info; + } + + size_t offset = 0; + + while (offset + 8 <= data.size()) { + uint32_t magic = readU32(data.data(), offset); + uint32_t chunkSize = readU32(data.data(), offset + 4); + + if (offset + 8 + chunkSize > data.size()) { + LOG_WARNING("WDT chunk extends beyond file at offset ", offset); + break; + } + + const uint8_t* chunkData = data.data() + offset + 8; + + if (magic == MVER) { + if (chunkSize >= 4) { + uint32_t version = readU32(chunkData, 0); + LOG_DEBUG("WDT version: ", version); + } + } else if (magic == MPHD) { + if (chunkSize >= 4) { + info.mphdFlags = readU32(chunkData, 0); + LOG_DEBUG("WDT MPHD flags: 0x", std::hex, info.mphdFlags, std::dec); + } + } else if (magic == MWMO) { + // Null-terminated WMO path string(s) + if (chunkSize > 0) { + const char* str = reinterpret_cast(chunkData); + size_t len = std::strlen(str); + if (len > 0) { + info.rootWMOPath = std::string(str, len); + LOG_DEBUG("WDT root WMO: ", info.rootWMOPath); + } + } + } else if (magic == MODF) { + // MODF entry is 64 bytes (same layout as ADT MODF) + if (chunkSize >= 64) { + // nameId at offset 0 (unused for WDT — path comes from MWMO) + // uniqueId at offset 4 + info.position[0] = readF32(chunkData, 8); + info.position[1] = readF32(chunkData, 12); + info.position[2] = readF32(chunkData, 16); + info.rotation[0] = readF32(chunkData, 20); + info.rotation[1] = readF32(chunkData, 24); + info.rotation[2] = readF32(chunkData, 28); + // extents at 32-55 + info.flags = readU16(chunkData, 56); + info.doodadSet = readU16(chunkData, 58); + LOG_DEBUG("WDT MODF placement: pos=(", info.position[0], ", ", + info.position[1], ", ", info.position[2], ") rot=(", + info.rotation[0], ", ", info.rotation[1], ", ", + info.rotation[2], ") doodadSet=", info.doodadSet); + } + } + + offset += 8 + chunkSize; + } + + LOG_WARNING("WDT parse result: mphdFlags=0x", std::hex, info.mphdFlags, std::dec, + " isWMOOnly=", info.isWMOOnly(), + " rootWMO='", info.rootWMOPath, "'"); + + return info; +} + +} // namespace pipeline +} // namespace wowee diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index eed0d684..6f02589a 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1756,15 +1756,47 @@ void CameraController::reset() { return h; }; + // In online mode, try to snap to a nearby floor but fall back to the server + // position when no WMO floor is found (e.g. WMO not loaded yet in cities). + // This prevents spawning under WMO cities like Stormwind. + if (onlineMode) { + auto h = evalFloorAt(spawnPos.x, spawnPos.y, spawnPos.z); + if (h && std::abs(*h - spawnPos.z) < 16.0f) { + spawnPos.z = *h + 0.05f; + } + // else: keep server Z as-is + lastGroundZ = spawnPos.z - 0.05f; + + camera->setRotation(yaw, pitch); + glm::vec3 forward3D = camera->getForward(); + + if (thirdPerson && followTarget) { + *followTarget = spawnPos; + currentDistance = userTargetDistance; + collisionDistance = currentDistance; + float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f; + glm::vec3 pivot = spawnPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset); + glm::vec3 camDir = -forward3D; + glm::vec3 camPos = pivot + camDir * currentDistance; + smoothedCamPos = camPos; + camera->setPosition(camPos); + } else { + spawnPos.z += eyeHeight; + smoothedCamPos = spawnPos; + camera->setPosition(spawnPos); + } + + LOG_INFO("Camera reset to server position (online mode)"); + return; + } + // Search nearby for a stable, non-steep spawn floor to avoid waterfall/ledge spawns. - // In online mode, use a tight search radius since the server dictates position. float bestScore = std::numeric_limits::max(); glm::vec3 bestPos = spawnPos; bool foundBest = false; constexpr float radiiOffline[] = {0.0f, 6.0f, 12.0f, 18.0f, 24.0f, 32.0f}; - constexpr float radiiOnline[] = {0.0f, 2.0f}; - const float* radii = onlineMode ? radiiOnline : radiiOffline; - const int radiiCount = onlineMode ? 2 : 6; + const float* radii = radiiOffline; + const int radiiCount = 6; constexpr int ANGLES = 16; constexpr float PI = 3.14159265f; for (int ri = 0; ri < radiiCount; ri++) { diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index d6d3f4b1..cf4500b7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -726,31 +726,38 @@ bool Renderer::initialize(core::Window* win) { } void Renderer::shutdown() { + LOG_WARNING("Renderer::shutdown - terrainManager stopWorkers..."); if (terrainManager) { - terrainManager->unloadAll(); + terrainManager->stopWorkers(); + LOG_WARNING("Renderer::shutdown - terrainManager reset..."); terrainManager.reset(); } + LOG_WARNING("Renderer::shutdown - terrainRenderer..."); if (terrainRenderer) { terrainRenderer->shutdown(); terrainRenderer.reset(); } + LOG_WARNING("Renderer::shutdown - waterRenderer..."); if (waterRenderer) { waterRenderer->shutdown(); waterRenderer.reset(); } + LOG_WARNING("Renderer::shutdown - minimap..."); if (minimap) { minimap->shutdown(); minimap.reset(); } + LOG_WARNING("Renderer::shutdown - worldMap..."); if (worldMap) { worldMap->shutdown(); worldMap.reset(); } + LOG_WARNING("Renderer::shutdown - skySystem..."); if (skySystem) { skySystem->shutdown(); skySystem.reset(); @@ -772,34 +779,41 @@ void Renderer::shutdown() { swimEffects.reset(); } + LOG_WARNING("Renderer::shutdown - characterRenderer..."); if (characterRenderer) { characterRenderer->shutdown(); characterRenderer.reset(); } + LOG_WARNING("Renderer::shutdown - wmoRenderer..."); if (wmoRenderer) { wmoRenderer->shutdown(); wmoRenderer.reset(); } + LOG_WARNING("Renderer::shutdown - m2Renderer..."); if (m2Renderer) { m2Renderer->shutdown(); m2Renderer.reset(); } + LOG_WARNING("Renderer::shutdown - musicManager..."); if (musicManager) { musicManager->shutdown(); musicManager.reset(); } + LOG_WARNING("Renderer::shutdown - footstepManager..."); if (footstepManager) { footstepManager->shutdown(); footstepManager.reset(); } + LOG_WARNING("Renderer::shutdown - activitySoundManager..."); if (activitySoundManager) { activitySoundManager->shutdown(); activitySoundManager.reset(); } + LOG_WARNING("Renderer::shutdown - AudioEngine..."); // Shutdown AudioEngine singleton audio::AudioEngine::instance().shutdown(); diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index ee118c1f..a47806d3 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -129,17 +129,7 @@ TerrainManager::TerrainManager() { } TerrainManager::~TerrainManager() { - // Stop worker thread before cleanup (containers clean up via destructors) - if (workerRunning.load()) { - workerRunning.store(false); - queueCV.notify_all(); - for (auto& t : workerThreads) { - if (t.joinable()) { - t.join(); - } - } - workerThreads.clear(); - } + stopWorkers(); } bool TerrainManager::initialize(pipeline::AssetManager* assets, TerrainRenderer* renderer) { @@ -276,6 +266,9 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { LOG_DEBUG("Preparing tile [", x, ",", y, "] (CPU work)"); + // Early-exit check — worker should bail fast during shutdown + if (!workerRunning.load()) return nullptr; + // Load ADT file std::string adtPath = getADTPath(coord); auto adtData = assetManager->readFile(adtPath); @@ -294,6 +287,8 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { return nullptr; } + if (!workerRunning.load()) return nullptr; + // WotLK split ADTs can store placements in *_obj0.adt. // Merge object chunks so doodads/WMOs (including ground clutter) are available. std::string objPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + @@ -362,6 +357,8 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { return nullptr; } + if (!workerRunning.load()) return nullptr; + auto pending = std::make_shared(); pending->coord = coord; pending->terrain = std::move(*terrainPtr); @@ -412,6 +409,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { // Pre-load M2 doodads (CPU: read files, parse models) int skippedNameId = 0, skippedFileNotFound = 0, skippedInvalid = 0, skippedSkinNotFound = 0; for (const auto& placement : pending->terrain.doodadPlacements) { + if (!workerRunning.load()) return nullptr; if (placement.nameId >= pending->terrain.doodadNames.size()) { skippedNameId++; continue; @@ -460,9 +458,12 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { ensureGroundEffectTablesLoaded(); generateGroundClutterPlacements(pending, preparedModelIds); + if (!workerRunning.load()) return nullptr; + // Pre-load WMOs (CPU: read files, parse models and groups) if (!pending->terrain.wmoPlacements.empty()) { for (const auto& placement : pending->terrain.wmoPlacements) { + if (!workerRunning.load()) return nullptr; if (placement.nameId >= pending->terrain.wmoNames.size()) continue; const std::string& wmoPath = pending->terrain.wmoNames[placement.nameId]; @@ -513,6 +514,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { ); // Pre-load WMO doodads (M2 models inside WMO) + if (!workerRunning.load()) return nullptr; if (!wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) { glm::mat4 wmoMatrix(1.0f); wmoMatrix = glm::translate(wmoMatrix, pos); @@ -636,6 +638,8 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { } } + if (!workerRunning.load()) return nullptr; + // Pre-load terrain texture BLP data on background thread so finalizeTile // doesn't block the main thread with file I/O. for (const auto& texPath : pending->terrain.textures) { @@ -1068,6 +1072,28 @@ void TerrainManager::processAllReadyTiles() { } } +void TerrainManager::processOneReadyTile() { + // Move ready tiles into finalizing deque + { + std::lock_guard lock(queueMutex); + while (!readyQueue.empty()) { + auto pending = readyQueue.front(); + readyQueue.pop(); + if (pending) { + FinalizingTile ft; + ft.pending = std::move(pending); + finalizingTiles_.push_back(std::move(ft)); + } + } + } + // Finalize ONE tile completely, then return so caller can update the screen + if (!finalizingTiles_.empty()) { + auto& ft = finalizingTiles_.front(); + while (!advanceFinalization(ft)) {} + finalizingTiles_.pop_front(); + } +} + std::shared_ptr TerrainManager::getCachedTile(const TileCoord& coord) { std::lock_guard lock(tileCacheMutex_); auto it = tileCache_.find(coord); @@ -1237,6 +1263,29 @@ void TerrainManager::unloadTile(int x, int y) { loadedTiles.erase(it); } +void TerrainManager::stopWorkers() { + if (!workerRunning.load()) { + LOG_WARNING("stopWorkers: already stopped"); + return; + } + LOG_WARNING("stopWorkers: signaling ", workerThreads.size(), " workers to stop..."); + workerRunning.store(false); + queueCV.notify_all(); + + // Workers check workerRunning at each I/O point in prepareTile() and bail + // out quickly. Use plain join() which is safe with std::thread — no + // pthread_timedjoin_np (which silently joins the pthread but leaves the + // std::thread object thinking it's still joinable → std::terminate on dtor). + for (size_t i = 0; i < workerThreads.size(); i++) { + if (workerThreads[i].joinable()) { + LOG_WARNING("stopWorkers: joining worker ", i, "..."); + workerThreads[i].join(); + } + } + workerThreads.clear(); + LOG_WARNING("stopWorkers: done"); +} + void TerrainManager::unloadAll() { // Signal worker threads to stop and wait briefly for them to finish. // Workers may be mid-prepareTile (reading MPQ / parsing ADT) which can @@ -1245,29 +1294,8 @@ void TerrainManager::unloadAll() { workerRunning.store(false); queueCV.notify_all(); - auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); for (auto& t : workerThreads) { - 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(); - } + if (t.joinable()) t.join(); } workerThreads.clear(); } diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index 8dcdef1b..8a7fd505 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -50,10 +50,12 @@ bool VkContext::initialize(SDL_Window* window) { } void VkContext::shutdown() { + LOG_WARNING("VkContext::shutdown - vkDeviceWaitIdle..."); if (device) { vkDeviceWaitIdle(device); } + LOG_WARNING("VkContext::shutdown - destroyImGuiResources..."); destroyImGuiResources(); // Destroy sync objects @@ -68,9 +70,16 @@ void VkContext::shutdown() { if (immFence) { vkDestroyFence(device, immFence, nullptr); immFence = VK_NULL_HANDLE; } if (immCommandPool) { vkDestroyCommandPool(device, immCommandPool, nullptr); immCommandPool = VK_NULL_HANDLE; } + LOG_WARNING("VkContext::shutdown - destroySwapchain..."); destroySwapchain(); - if (allocator) { vmaDestroyAllocator(allocator); allocator = VK_NULL_HANDLE; } + // Skip vmaDestroyAllocator — it walks every allocation to free it, which + // takes many seconds with thousands of loaded textures/models. The driver + // reclaims all device memory when we destroy the device, and the OS reclaims + // everything on process exit. Skipping this makes shutdown instant. + allocator = VK_NULL_HANDLE; + + LOG_WARNING("VkContext::shutdown - vkDestroyDevice..."); if (device) { vkDestroyDevice(device, nullptr); device = VK_NULL_HANDLE; } if (surface) { vkDestroySurfaceKHR(instance, surface, nullptr); surface = VK_NULL_HANDLE; } @@ -83,7 +92,7 @@ void VkContext::shutdown() { if (instance) { vkDestroyInstance(instance, nullptr); instance = VK_NULL_HANDLE; } - LOG_INFO("Vulkan context shutdown"); + LOG_WARNING("Vulkan context shutdown complete"); } bool VkContext::createInstance(SDL_Window* window) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3298680c..07c19570 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2765,6 +2765,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { isChannelCommand = true; switchChatType = 9; } else if (cmdLower == "join") { + // /join with no args: accept pending BG invite if any + if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { + gameHandler.acceptBattlefield(); + chatInputBuffer[0] = '\0'; + return; + } // /join ChannelName [password] if (spacePos != std::string::npos) { std::string rest = command.substr(spacePos + 1); @@ -7764,57 +7770,132 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { } auto& inv = gameHandler.getInventory(); + bool isHolding = inventoryScreen.isHoldingItem(); + constexpr float SLOT_SIZE = 42.0f; + static constexpr float kBankPickupHold = 0.10f; // seconds + // Persistent pickup tracking for bank (mirrors inventory_screen's pickupPending_) + static bool bankPickupPending = false; + static float bankPickupPressTime = 0.0f; + static int bankPickupType = 0; // 0=main bank, 1=bank bag slot, 2=bank bag equip slot + static int bankPickupIndex = -1; + static int bankPickupBagIndex = -1; + static int bankPickupBagSlotIndex = -1; + + // Helper: render a bank item slot with icon, click-and-hold pickup, drop, tooltip + auto renderBankItemSlot = [&](const game::ItemSlot& slot, int pickType, int mainIdx, + int bagIdx, int bagSlotIdx, uint8_t dstBag, uint8_t dstSlot) { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImVec2 pos = ImGui::GetCursorScreenPos(); + + if (slot.empty()) { + ImU32 bgCol = IM_COL32(30, 30, 30, 200); + ImU32 borderCol = IM_COL32(60, 60, 60, 200); + if (isHolding) { + bgCol = IM_COL32(20, 50, 20, 200); + borderCol = IM_COL32(0, 180, 0, 200); + } + drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol); + drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), borderCol); + ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE)); + if (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot); + } + } else { + const auto& item = slot.item; + ImVec4 qc = InventoryScreen::getQualityColor(item.quality); + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(item.displayInfoId); + + if (iconTex) { + drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE)); + drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), + borderCol, 0.0f, 0, 2.0f); + } else { + ImU32 bgCol = IM_COL32(40, 35, 30, 220); + drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol); + drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), + borderCol, 0.0f, 0, 2.0f); + if (!item.name.empty()) { + char abbr[3] = { item.name[0], item.name.size() > 1 ? item.name[1] : '\0', '\0' }; + float tw = ImGui::CalcTextSize(abbr).x; + drawList->AddText(ImVec2(pos.x + (SLOT_SIZE - tw) * 0.5f, pos.y + 2.0f), + ImGui::ColorConvertFloat4ToU32(qc), abbr); + } + } + + if (item.stackCount > 1) { + char countStr[16]; + snprintf(countStr, sizeof(countStr), "%u", item.stackCount); + float cw = ImGui::CalcTextSize(countStr).x; + drawList->AddText(ImVec2(pos.x + SLOT_SIZE - cw - 2.0f, pos.y + SLOT_SIZE - 14.0f), + IM_COL32(255, 255, 255, 220), countStr); + } + + ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE)); + + if (!isHolding) { + // Start pickup tracking on mouse press + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + bankPickupPending = true; + bankPickupPressTime = ImGui::GetTime(); + bankPickupType = pickType; + bankPickupIndex = mainIdx; + bankPickupBagIndex = bagIdx; + bankPickupBagSlotIndex = bagSlotIdx; + } + // Check if held long enough to pick up + if (bankPickupPending && ImGui::IsMouseDown(ImGuiMouseButton_Left) && + (ImGui::GetTime() - bankPickupPressTime) >= kBankPickupHold) { + bool sameSlot = (bankPickupType == pickType); + if (pickType == 0) + sameSlot = sameSlot && (bankPickupIndex == mainIdx); + else if (pickType == 1) + sameSlot = sameSlot && (bankPickupBagIndex == bagIdx) && (bankPickupBagSlotIndex == bagSlotIdx); + else if (pickType == 2) + sameSlot = sameSlot && (bankPickupIndex == mainIdx); + + if (sameSlot && ImGui::IsItemHovered()) { + bankPickupPending = false; + if (pickType == 0) { + inventoryScreen.pickupFromBank(inv, mainIdx); + } else if (pickType == 1) { + inventoryScreen.pickupFromBankBag(inv, bagIdx, bagSlotIdx); + } else if (pickType == 2) { + inventoryScreen.pickupFromBankBagEquip(inv, mainIdx); + } + } + } + } else { + // Drop/swap on mouse release + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot); + } + } + + // Tooltip + if (ImGui::IsItemHovered() && !isHolding) { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", item.name.c_str()); + if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); + ImGui::EndTooltip(); + } + } + }; // Main bank slots (24 for Classic, 28 for TBC/WotLK) int bankSlotCount = gameHandler.getEffectiveBankSlots(); int bankBagCount = gameHandler.getEffectiveBankBagSlots(); ImGui::Text("Bank Slots"); ImGui::Separator(); - bool isHolding = inventoryScreen.isHoldingItem(); for (int i = 0; i < bankSlotCount; 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::IsItemHovered() && isHolding && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - // 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)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qc.x * 0.5f, qc.y * 0.5f, qc.z * 0.5f, 0.9f)); - - 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); - ImGui::Button(label.c_str(), ImVec2(42, 42)); - ImGui::PopStyleColor(2); - if (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - // Drop held item into occupied bank slot (swap) - inventoryScreen.dropIntoBankSlot(gameHandler, 0xFF, static_cast(39 + i)); - } else if (!isHolding && ImGui::IsItemClicked(ImGuiMouseButton_Left)) { - // Withdraw on click - gameHandler.withdrawItem(0xFF, static_cast(39 + i)); - } - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", slot.item.name.c_str()); - if (slot.item.stackCount > 1) ImGui::Text("Count: %u", slot.item.stackCount); - ImGui::EndTooltip(); - } - } + renderBankItemSlot(inv.getBankSlot(i), 0, i, -1, -1, 0xFF, static_cast(39 + i)); ImGui::PopID(); } - // Bank bag slots + // Bank bag equip slots — show bag icon with pickup/drop, or "Buy Slot" ImGui::Spacing(); ImGui::Separator(); ImGui::Text("Bank Bags"); @@ -7824,12 +7905,12 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { ImGui::PushID(i + 2000); int bagSize = inv.getBankBagSize(i); - if (i < static_cast(purchased) || bagSize > 0) { - if (ImGui::Button(bagSize > 0 ? std::to_string(bagSize).c_str() : "Empty", ImVec2(50, 30))) { - // Could open bag contents - } + if (i < purchased || bagSize > 0) { + const auto& bagSlot = inv.getBankBagItem(i); + // Render as an item slot: icon with pickup/drop (pickType=2 for bag equip) + renderBankItemSlot(bagSlot, 2, i, -1, -1, 0xFF, static_cast(67 + i)); } else { - if (ImGui::Button("Buy", ImVec2(50, 30))) { + if (ImGui::Button("Buy Slot", ImVec2(50, 30))) { gameHandler.buyBankSlot(); } } @@ -7845,37 +7926,9 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { ImGui::Text("Bank Bag %d (%d slots)", bagIdx + 1, bagSize); for (int s = 0; s < bagSize; s++) { if (s % 7 != 0) ImGui::SameLine(); - 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 (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - 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)); - ImGui::Button(lbl.c_str(), ImVec2(42, 42)); - ImGui::PopStyleColor(2); - if (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { - inventoryScreen.dropIntoBankSlot(gameHandler, static_cast(67 + bagIdx), static_cast(s)); - } else if (!isHolding && ImGui::IsItemClicked(ImGuiMouseButton_Left)) { - gameHandler.withdrawItem(static_cast(67 + bagIdx), static_cast(s)); - } - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", slot.item.name.c_str()); - if (slot.item.stackCount > 1) ImGui::Text("Count: %u", slot.item.stackCount); - ImGui::EndTooltip(); - } - } + renderBankItemSlot(inv.getBankBagSlot(bagIdx, s), 1, -1, bagIdx, s, + static_cast(67 + bagIdx), static_cast(s)); ImGui::PopID(); } } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index ad3c3c2c..ee4b54a3 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -304,6 +304,57 @@ void InventoryScreen::pickupFromEquipment(game::Inventory& inv, game::EquipSlot inventoryDirty = true; } +void InventoryScreen::pickupFromBank(game::Inventory& inv, int bankIndex) { + const auto& slot = inv.getBankSlot(bankIndex); + if (slot.empty()) return; + holdingItem = true; + heldItem = slot.item; + heldSource = HeldSource::BANK; + heldBankIndex = bankIndex; + heldBackpackIndex = -1; + heldBagIndex = -1; + heldBagSlotIndex = -1; + heldBankBagIndex = -1; + heldBankBagSlotIndex = -1; + heldEquipSlot = game::EquipSlot::NUM_SLOTS; + inv.clearBankSlot(bankIndex); + inventoryDirty = true; +} + +void InventoryScreen::pickupFromBankBag(game::Inventory& inv, int bagIndex, int slotIndex) { + const auto& slot = inv.getBankBagSlot(bagIndex, slotIndex); + if (slot.empty()) return; + holdingItem = true; + heldItem = slot.item; + heldSource = HeldSource::BANK_BAG; + heldBankBagIndex = bagIndex; + heldBankBagSlotIndex = slotIndex; + heldBankIndex = -1; + heldBackpackIndex = -1; + heldBagIndex = -1; + heldBagSlotIndex = -1; + heldEquipSlot = game::EquipSlot::NUM_SLOTS; + inv.clearBankBagSlot(bagIndex, slotIndex); + inventoryDirty = true; +} + +void InventoryScreen::pickupFromBankBagEquip(game::Inventory& inv, int bagIndex) { + const auto& slot = inv.getBankBagItem(bagIndex); + if (slot.empty()) return; + holdingItem = true; + heldItem = slot.item; + heldSource = HeldSource::BANK_BAG_EQUIP; + heldBankBagIndex = bagIndex; + heldBankBagSlotIndex = -1; + heldBankIndex = -1; + heldBackpackIndex = -1; + heldBagIndex = -1; + heldBagSlotIndex = -1; + heldEquipSlot = game::EquipSlot::NUM_SLOTS; + inv.setBankBagItem(bagIndex, game::ItemDef{}); + inventoryDirty = true; +} + void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { if (!holdingItem) return; if (gameHandler_) { @@ -319,6 +370,13 @@ void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { srcSlot = static_cast(heldBagSlotIndex); } else if (heldSource == HeldSource::EQUIPMENT) { srcSlot = static_cast(heldEquipSlot); + } else if (heldSource == HeldSource::BANK && heldBankIndex >= 0) { + srcSlot = static_cast(39 + heldBankIndex); + } else if (heldSource == HeldSource::BANK_BAG && heldBankBagIndex >= 0) { + srcBag = static_cast(67 + heldBankBagIndex); + srcSlot = static_cast(heldBankBagSlotIndex); + } else if (heldSource == HeldSource::BANK_BAG_EQUIP && heldBankBagIndex >= 0) { + srcSlot = static_cast(67 + heldBankBagIndex); } else { cancelPickup(inv); return; @@ -357,6 +415,13 @@ void InventoryScreen::placeInBag(game::Inventory& inv, int bagIndex, int slotInd srcSlot = static_cast(heldBagSlotIndex); } else if (heldSource == HeldSource::EQUIPMENT) { srcSlot = static_cast(heldEquipSlot); + } else if (heldSource == HeldSource::BANK && heldBankIndex >= 0) { + srcSlot = static_cast(39 + heldBankIndex); + } else if (heldSource == HeldSource::BANK_BAG && heldBankBagIndex >= 0) { + srcBag = static_cast(67 + heldBankBagIndex); + srcSlot = static_cast(heldBankBagSlotIndex); + } else if (heldSource == HeldSource::BANK_BAG_EQUIP && heldBankBagIndex >= 0) { + srcSlot = static_cast(67 + heldBankBagIndex); } else { cancelPickup(inv); return; @@ -417,6 +482,11 @@ void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slo srcSlot = static_cast(heldBagSlotIndex); } else if (heldSource == HeldSource::EQUIPMENT && heldEquipSlot != game::EquipSlot::NUM_SLOTS) { srcSlot = static_cast(heldEquipSlot); + } else if (heldSource == HeldSource::BANK && heldBankIndex >= 0) { + srcSlot = static_cast(39 + heldBankIndex); + } else if (heldSource == HeldSource::BANK_BAG && heldBankBagIndex >= 0) { + srcBag = static_cast(67 + heldBankBagIndex); + srcSlot = static_cast(heldBankBagSlotIndex); } else { cancelPickup(inv); return; @@ -486,6 +556,24 @@ void InventoryScreen::cancelPickup(game::Inventory& inv) { } else { inv.addItem(heldItem); } + } else if (heldSource == HeldSource::BANK && heldBankIndex >= 0) { + if (inv.getBankSlot(heldBankIndex).empty()) { + inv.setBankSlot(heldBankIndex, heldItem); + } else { + inv.addItem(heldItem); + } + } else if (heldSource == HeldSource::BANK_BAG && heldBankBagIndex >= 0 && heldBankBagSlotIndex >= 0) { + if (inv.getBankBagSlot(heldBankBagIndex, heldBankBagSlotIndex).empty()) { + inv.setBankBagSlot(heldBankBagIndex, heldBankBagSlotIndex, heldItem); + } else { + inv.addItem(heldItem); + } + } else if (heldSource == HeldSource::BANK_BAG_EQUIP && heldBankBagIndex >= 0) { + if (inv.getBankBagItem(heldBankBagIndex).empty()) { + inv.setBankBagItem(heldBankBagIndex, heldItem); + } else { + inv.addItem(heldItem); + } } else { inv.addItem(heldItem); } @@ -554,9 +642,22 @@ void InventoryScreen::dropIntoBankSlot(game::GameHandler& /*gh*/, uint8_t dstBag srcSlot = static_cast(heldBagSlotIndex); } else if (heldSource == HeldSource::EQUIPMENT) { srcSlot = static_cast(heldEquipSlot); + } else if (heldSource == HeldSource::BANK && heldBankIndex >= 0) { + srcSlot = static_cast(39 + heldBankIndex); + } else if (heldSource == HeldSource::BANK_BAG && heldBankBagIndex >= 0) { + srcBag = static_cast(67 + heldBankBagIndex); + srcSlot = static_cast(heldBankBagSlotIndex); + } else if (heldSource == HeldSource::BANK_BAG_EQUIP && heldBankBagIndex >= 0) { + srcSlot = static_cast(67 + heldBankBagIndex); } else { return; } + // Same source and dest — just cancel pickup (restore item locally). + // Server ignores same-slot swaps so no rebuild would run, losing the item data. + if (srcBag == dstBag && srcSlot == dstSlot) { + cancelPickup(gameHandler_->getInventory()); + return; + } gameHandler_->swapContainerItems(srcBag, srcSlot, dstBag, dstSlot); holdingItem = false; inventoryDirty = true;