diff --git a/assets/shaders/m2_particle.frag.glsl b/assets/shaders/m2_particle.frag.glsl index f91a3fb7..49fac7e1 100644 --- a/assets/shaders/m2_particle.frag.glsl +++ b/assets/shaders/m2_particle.frag.glsl @@ -25,6 +25,9 @@ void main() { if (lum < 0.05) discard; } - float edge = smoothstep(0.5, 0.4, length(p - 0.5)); - outColor = texColor * vColor * vec4(vec3(1.0), edge); + // Soft circular falloff for point-sprite edges. + float edge = 1.0 - smoothstep(0.4, 0.5, length(p - 0.5)); + float alpha = texColor.a * vColor.a * edge; + vec3 rgb = texColor.rgb * vColor.rgb * alpha; + outColor = vec4(rgb, alpha); } diff --git a/assets/shaders/minimap_display.frag.glsl b/assets/shaders/minimap_display.frag.glsl index dacaaed1..3f4d42e4 100644 --- a/assets/shaders/minimap_display.frag.glsl +++ b/assets/shaders/minimap_display.frag.glsl @@ -44,19 +44,23 @@ void main() { vec4 mapColor = texture(uComposite, mapUV); - // Player arrow - float acs = cos(push.arrowRotation); - float asn = sin(push.arrowRotation); - vec2 ac = center; - vec2 arrowPos = vec2(-(ac.x * acs - ac.y * asn), ac.x * asn + ac.y * acs); - - vec2 tip = vec2(0.0, -0.04); - vec2 left = vec2(-0.02, 0.02); - vec2 right = vec2(0.02, 0.02); - - if (pointInTriangle(arrowPos, tip, left, right)) { - mapColor = vec4(1.0, 0.8, 0.0, 1.0); + // Single player direction indicator (center arrow) rendered in-shader. + vec2 local = center; // [-0.5, 0.5] around minimap center + float ac = cos(push.arrowRotation); + float as = sin(push.arrowRotation); + // TexCoord Y grows downward on screen; use negative Y so 0-angle points North (up). + vec2 tip = vec2(0.0, -0.09); + vec2 left = vec2(-0.045, 0.02); + vec2 right = vec2( 0.045, 0.02); + mat2 rot = mat2(ac, -as, as, ac); + tip = rot * tip; + left = rot * left; + right = rot * right; + if (pointInTriangle(local, tip, left, right)) { + mapColor.rgb = vec3(1.0, 0.86, 0.05); } + float centerDot = smoothstep(0.016, 0.0, length(local)); + mapColor.rgb = mix(mapColor.rgb, vec3(1.0), centerDot * 0.95); // Dark border ring float border = smoothstep(0.48, 0.5, dist); diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7ce07326..b1ddeb97 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1978,7 +1978,7 @@ public: const std::array& getMailAttachments() const { return mailAttachments_; } int getMailAttachmentCount() const; void mailTakeMoney(uint32_t mailId); - void mailTakeItem(uint32_t mailId, uint32_t itemIndex); + void mailTakeItem(uint32_t mailId, uint32_t itemGuidLow); void mailDelete(uint32_t mailId); void mailMarkAsRead(uint32_t mailId); void refreshMailList(); @@ -2534,6 +2534,7 @@ private: std::unordered_set pendingItemQueries_; std::array equipSlotGuids_{}; std::array backpackSlotGuids_{}; + std::array keyringSlotGuids_{}; // Container (bag) contents: containerGuid -> array of item GUIDs per slot struct ContainerInfo { uint32_t numSlots = 0; @@ -2829,6 +2830,7 @@ private: struct LocalLootState { LootResponseData data; bool moneyTaken = false; + bool itemAutoLootSent = false; }; std::unordered_map localLootState_; struct PendingLootRetry { @@ -3200,6 +3202,7 @@ private: bool releasedSpirit_ = false; uint32_t corpseMapId_ = 0; float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f; + uint64_t corpseGuid_ = 0; // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially std::array playerRunes_ = [] { std::array r{}; diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index ac6af201..ea6f6110 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -69,6 +69,7 @@ struct ItemSlot { class Inventory { public: static constexpr int BACKPACK_SLOTS = 16; + static constexpr int KEYRING_SLOTS = 32; static constexpr int NUM_EQUIP_SLOTS = 23; static constexpr int NUM_BAG_SLOTS = 4; static constexpr int MAX_BAG_SIZE = 36; @@ -88,6 +89,12 @@ public: bool setEquipSlot(EquipSlot slot, const ItemDef& item); bool clearEquipSlot(EquipSlot slot); + // Keyring + const ItemSlot& getKeyringSlot(int index) const; + bool setKeyringSlot(int index, const ItemDef& item); + bool clearKeyringSlot(int index); + int getKeyringSize() const { return KEYRING_SLOTS; } + // Extra bags int getBagSize(int bagIndex) const; void setBagSize(int bagIndex, int size); @@ -123,6 +130,7 @@ public: private: std::array backpack{}; + std::array keyring_{}; std::array equipment{}; struct BagData { diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 40655045..38560cc7 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -266,8 +266,8 @@ public: virtual bool parseMailList(network::Packet& packet, std::vector& inbox); /** Build CMSG_MAIL_TAKE_ITEM */ - virtual network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) { - return MailTakeItemPacket::build(mailboxGuid, mailId, itemSlot); + virtual network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) { + return MailTakeItemPacket::build(mailboxGuid, mailId, itemGuidLow); } /** Build CMSG_MAIL_DELETE */ @@ -404,7 +404,7 @@ public: 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 buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) override; network::Packet buildMailDelete(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) override; network::Packet buildItemQuery(uint32_t entry, uint64_t guid) override; bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 5e42a049..e4687352 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -57,6 +57,7 @@ enum class UF : uint16_t { PLAYER_QUEST_LOG_START, PLAYER_FIELD_INV_SLOT_HEAD, PLAYER_FIELD_PACK_SLOT_1, + PLAYER_FIELD_KEYRING_SLOT_1, PLAYER_FIELD_BANK_SLOT_1, PLAYER_FIELD_BANKBAG_SLOT_1, PLAYER_SKILL_INFO_START, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index f29eecb7..15b8f8ff 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2441,6 +2441,12 @@ public: static network::Packet build(); }; +/** CMSG_RECLAIM_CORPSE packet builder */ +class ReclaimCorpsePacket { +public: + static network::Packet build(uint64_t guid); +}; + /** CMSG_SPIRIT_HEALER_ACTIVATE packet builder */ class SpiritHealerActivatePacket { public: @@ -2511,7 +2517,7 @@ public: /** CMSG_MAIL_TAKE_ITEM packet builder */ class MailTakeItemPacket { public: - static network::Packet build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemIndex); + static network::Packet build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow); }; /** CMSG_MAIL_DELETE packet builder */ diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index dd727caf..aed61820 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -161,6 +161,7 @@ public: // Targeting support void setTargetPosition(const glm::vec3* pos); void setInCombat(bool combat) { inCombat_ = combat; } + void resetCombatVisualState(); bool isMoving() const; void triggerMeleeSwing(); void setEquippedWeaponType(uint32_t inventoryType) { equippedWeaponInvType_ = inventoryType; meleeAnimId = 0; } @@ -340,6 +341,10 @@ private: // Character animation state enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT, CHARGE, COMBAT_IDLE }; CharAnimState charAnimState = CharAnimState::IDLE; + float locomotionStopGraceTimer_ = 0.0f; + bool locomotionWasSprinting_ = false; + uint32_t lastPlayerAnimRequest_ = UINT32_MAX; + bool lastPlayerAnimLoopRequest_ = true; void updateCharacterAnimation(); bool isFootstepAnimationState() const; bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f22ba4da..a6ebf9c0 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -621,6 +621,9 @@ private: float resurrectFlashTimer_ = 0.0f; static constexpr float kResurrectFlashDuration = 3.0f; bool ghostStateCallbackSet_ = false; + bool ghostOpacityStateKnown_ = false; + bool ghostOpacityLastState_ = false; + uint32_t ghostOpacityLastInstanceId_ = 0; void renderResurrectFlash(); // Zone discovery text ("Entering: ") diff --git a/include/ui/keybinding_manager.hpp b/include/ui/keybinding_manager.hpp index 385340ab..e242ae5d 100644 --- a/include/ui/keybinding_manager.hpp +++ b/include/ui/keybinding_manager.hpp @@ -29,7 +29,6 @@ public: TOGGLE_WORLD_MAP, TOGGLE_NAMEPLATES, TOGGLE_RAID_FRAMES, - TOGGLE_QUEST_LOG, TOGGLE_ACHIEVEMENTS, ACTION_COUNT }; diff --git a/src/core/application.cpp b/src/core/application.cpp index 83886782..6f023fee 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -391,143 +391,183 @@ void Application::run() { } auto lastTime = std::chrono::high_resolution_clock::now(); + std::atomic watchdogRunning{true}; + std::atomic watchdogHeartbeatMs{ + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count() + }; + std::thread watchdogThread([this, &watchdogRunning, &watchdogHeartbeatMs]() { + bool releasedForCurrentStall = false; + while (watchdogRunning.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + const int64_t nowMs = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire); + const int64_t stallMs = nowMs - lastBeatMs; - while (running && !window->shouldClose()) { - // Calculate delta time - auto currentTime = std::chrono::high_resolution_clock::now(); - std::chrono::duration deltaTimeDuration = currentTime - lastTime; - float deltaTime = deltaTimeDuration.count(); - lastTime = currentTime; - - // Cap delta time to prevent large jumps - if (deltaTime > 0.1f) { - deltaTime = 0.1f; + // Failsafe: if the main loop stalls while relative mouse mode is active, + // forcibly release grab so the user can move the cursor and close the app. + if (stallMs > 1500) { + if (!releasedForCurrentStall) { + SDL_SetRelativeMouseMode(SDL_FALSE); + SDL_ShowCursor(SDL_ENABLE); + if (window && window->getSDLWindow()) { + SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE); + } + LOG_WARNING("Main-loop stall detected (", stallMs, + "ms) — force-released mouse capture failsafe"); + releasedForCurrentStall = true; + } + } else { + releasedForCurrentStall = false; + } } + }); - // Poll events - SDL_Event event; - while (SDL_PollEvent(&event)) { - // Pass event to UI manager first - if (uiManager) { - uiManager->processEvent(event); + try { + while (running && !window->shouldClose()) { + watchdogHeartbeatMs.store( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(), + std::memory_order_release); + + // Calculate delta time + auto currentTime = std::chrono::high_resolution_clock::now(); + std::chrono::duration deltaTimeDuration = currentTime - lastTime; + float deltaTime = deltaTimeDuration.count(); + lastTime = currentTime; + + // Cap delta time to prevent large jumps + if (deltaTime > 0.1f) { + deltaTime = 0.1f; } - // Pass mouse events to camera controller (skip when UI has mouse focus) - if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) { - if (event.type == SDL_MOUSEMOTION) { - renderer->getCameraController()->processMouseMotion(event.motion); + // Poll events + SDL_Event event; + while (SDL_PollEvent(&event)) { + // Pass event to UI manager first + if (uiManager) { + uiManager->processEvent(event); } - else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { - renderer->getCameraController()->processMouseButton(event.button); + + // Pass mouse events to camera controller (skip when UI has mouse focus) + if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) { + if (event.type == SDL_MOUSEMOTION) { + renderer->getCameraController()->processMouseMotion(event.motion); + } + else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { + renderer->getCameraController()->processMouseButton(event.button); + } + else if (event.type == SDL_MOUSEWHEEL) { + renderer->getCameraController()->processMouseWheel(static_cast(event.wheel.y)); + } } - else if (event.type == SDL_MOUSEWHEEL) { - renderer->getCameraController()->processMouseWheel(static_cast(event.wheel.y)); + + // Handle window events + if (event.type == SDL_QUIT) { + window->setShouldClose(true); + } + else if (event.type == SDL_WINDOWEVENT) { + if (event.window.event == SDL_WINDOWEVENT_RESIZED) { + int newWidth = event.window.data1; + int newHeight = event.window.data2; + window->setSize(newWidth, newHeight); + // Vulkan viewport set in command buffer, not globally + if (renderer && renderer->getCamera()) { + renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); + } + } + } + // Debug controls + else if (event.type == SDL_KEYDOWN) { + // Skip non-function-key input when UI (chat) has keyboard focus + bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard; + auto sc = event.key.keysym.scancode; + bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12); + if (uiHasKeyboard && !isFKey) { + continue; // Let ImGui handle the keystroke + } + + // F1: Toggle performance HUD + if (event.key.keysym.scancode == SDL_SCANCODE_F1) { + if (renderer && renderer->getPerformanceHUD()) { + renderer->getPerformanceHUD()->toggle(); + bool enabled = renderer->getPerformanceHUD()->isEnabled(); + LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF"); + } + } + // F4: Toggle shadows + else if (event.key.keysym.scancode == SDL_SCANCODE_F4) { + if (renderer) { + bool enabled = !renderer->areShadowsEnabled(); + renderer->setShadowsEnabled(enabled); + LOG_INFO("Shadows: ", enabled ? "ON" : "OFF"); + } + } + // F8: Debug WMO floor at current position + else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) { + if (renderer && renderer->getWMORenderer()) { + glm::vec3 pos = renderer->getCharacterPosition(); + LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z); + } + } } } - // Handle window events - if (event.type == SDL_QUIT) { + // Update input + Input::getInstance().update(); + + // Update application state + try { + update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } + // Render + try { + render(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } + // Swap buffers + try { + window->swapBuffers(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during swapBuffers: ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during swapBuffers: ", e.what()); + throw; + } + + // Exit gracefully on GPU device lost (unrecoverable) + if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) { + LOG_ERROR("GPU device lost — exiting application"); window->setShouldClose(true); } - else if (event.type == SDL_WINDOWEVENT) { - if (event.window.event == SDL_WINDOWEVENT_RESIZED) { - int newWidth = event.window.data1; - int newHeight = event.window.data2; - window->setSize(newWidth, newHeight); - // Vulkan viewport set in command buffer, not globally - if (renderer && renderer->getCamera()) { - renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); - } - } - } - // Debug controls - else if (event.type == SDL_KEYDOWN) { - // Skip non-function-key input when UI (chat) has keyboard focus - bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard; - auto sc = event.key.keysym.scancode; - bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12); - if (uiHasKeyboard && !isFKey) { - continue; // Let ImGui handle the keystroke - } + } + } catch (...) { + watchdogRunning.store(false, std::memory_order_release); + if (watchdogThread.joinable()) { + watchdogThread.join(); + } + throw; + } - // F1: Toggle performance HUD - if (event.key.keysym.scancode == SDL_SCANCODE_F1) { - if (renderer && renderer->getPerformanceHUD()) { - renderer->getPerformanceHUD()->toggle(); - bool enabled = renderer->getPerformanceHUD()->isEnabled(); - LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF"); - } - } - // F4: Toggle shadows - else if (event.key.keysym.scancode == SDL_SCANCODE_F4) { - if (renderer) { - bool enabled = !renderer->areShadowsEnabled(); - renderer->setShadowsEnabled(enabled); - LOG_INFO("Shadows: ", enabled ? "ON" : "OFF"); - } - } - // F7: Test level-up effect (ignore key repeat) - else if (event.key.keysym.scancode == SDL_SCANCODE_F7 && event.key.repeat == 0) { - if (renderer) { - renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); - LOG_INFO("Triggered test level-up effect"); - } - if (uiManager) { - uiManager->getGameScreen().triggerDing(99); - } - } - // F8: Debug WMO floor at current position - else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) { - if (renderer && renderer->getWMORenderer()) { - glm::vec3 pos = renderer->getCharacterPosition(); - LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z); - } - } - } - } - - // Update input - Input::getInstance().update(); - - // Update application state - try { - update(deltaTime); - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM during Application::update (state=", static_cast(state), - ", dt=", deltaTime, "): ", e.what()); - throw; - } catch (const std::exception& e) { - LOG_ERROR("Exception during Application::update (state=", static_cast(state), - ", dt=", deltaTime, "): ", e.what()); - throw; - } - // Render - try { - render(); - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); - throw; - } catch (const std::exception& e) { - LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); - throw; - } - // Swap buffers - try { - window->swapBuffers(); - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM during swapBuffers: ", e.what()); - throw; - } catch (const std::exception& e) { - LOG_ERROR("Exception during swapBuffers: ", e.what()); - throw; - } - - // Exit gracefully on GPU device lost (unrecoverable) - if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) { - LOG_ERROR("GPU device lost — exiting application"); - window->setShouldClose(true); - } + watchdogRunning.store(false, std::memory_order_release); + if (watchdogThread.joinable()) { + watchdogThread.join(); } LOG_INFO("Main loop ended"); @@ -807,6 +847,7 @@ void Application::logoutToLogin() { world.reset(); if (renderer) { + renderer->resetCombatVisualState(); // Remove old player model so it doesn't persist into next session if (auto* charRenderer = renderer->getCharacterRenderer()) { charRenderer->removeInstance(1); @@ -1074,6 +1115,15 @@ void Application::update(float deltaTime) { gameHandler->isTaxiMountActive() || gameHandler->isTaxiActivationPending()); bool onTransportNow = gameHandler && gameHandler->isOnTransport(); + // Clear stale client-side transport state when the tracked transport no longer exists. + if (onTransportNow && gameHandler->getTransportManager()) { + auto* currentTracked = gameHandler->getTransportManager()->getTransport( + gameHandler->getPlayerTransportGuid()); + if (!currentTracked) { + gameHandler->clearPlayerTransport(); + onTransportNow = false; + } + } // M2 transports (trams) use position-delta approach: player keeps normal // movement and the transport's frame-to-frame delta is applied on top. // Only WMO transports (ships) use full external-driven mode. @@ -1309,23 +1359,29 @@ void Application::update(float deltaTime) { } else { glm::vec3 renderPos = renderer->getCharacterPosition(); - // M2 transport riding: apply transport's frame-to-frame position delta - // so the player moves with the tram while retaining normal movement input. + // M2 transport riding: resolve in canonical space and lock once per frame. + // This avoids visible jitter from mixed render/canonical delta application. if (isM2Transport && gameHandler->getTransportManager()) { auto* tr = gameHandler->getTransportManager()->getTransport( gameHandler->getPlayerTransportGuid()); if (tr) { - static glm::vec3 lastTransportCanonical(0); - static uint64_t lastTransportGuid = 0; - if (lastTransportGuid == gameHandler->getPlayerTransportGuid()) { - glm::vec3 deltaCanonical = tr->position - lastTransportCanonical; - glm::vec3 deltaRender = core::coords::canonicalToRender(deltaCanonical) - - core::coords::canonicalToRender(glm::vec3(0)); - renderPos += deltaRender; - renderer->getCharacterPosition() = renderPos; + // Keep passenger locked to elevator vertical motion while grounded. + // Without this, floor clamping can hold world-Z static unless the + // player is jumping, which makes lifts appear to not move vertically. + glm::vec3 tentativeCanonical = core::coords::renderToCanonical(renderPos); + glm::vec3 localOffset = gameHandler->getPlayerTransportOffset(); + localOffset.x = tentativeCanonical.x - tr->position.x; + localOffset.y = tentativeCanonical.y - tr->position.y; + if (renderer->getCameraController() && + !renderer->getCameraController()->isGrounded()) { + // While airborne (jump/fall), allow local Z offset to change. + localOffset.z = tentativeCanonical.z - tr->position.z; } - lastTransportCanonical = tr->position; - lastTransportGuid = gameHandler->getPlayerTransportGuid(); + gameHandler->setPlayerTransportOffset(localOffset); + + glm::vec3 lockedCanonical = tr->position + localOffset; + renderPos = core::coords::canonicalToRender(lockedCanonical); + renderer->getCharacterPosition() = renderPos; } } @@ -1362,21 +1418,45 @@ void Application::update(float deltaTime) { } // Client-side transport boarding detection (for M2 transports like trams - // where the server doesn't send transport attachment data). - // Use a generous AABB around each transport's current position. + // and lifts where the server doesn't send transport attachment data). + // Thunder Bluff elevators use model origins that can be far from the deck + // the player stands on, so they need wider attachment bounds. if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) { auto* tm = gameHandler->getTransportManager(); glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); + constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f; + constexpr float kM2BoardVertDist = 15.0f; + constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f; + constexpr float kTbLiftBoardVertDist = 14.0f; + uint64_t bestGuid = 0; + float bestScore = 1e30f; for (auto& [guid, transport] : tm->getTransports()) { if (!transport.isM2) continue; + const bool isThunderBluffLift = + (transport.entry >= 20649u && transport.entry <= 20657u); + const float maxHorizDistSq = isThunderBluffLift + ? kTbLiftBoardHorizDistSq + : kM2BoardHorizDistSq; + const float maxVertDist = isThunderBluffLift + ? kTbLiftBoardVertDist + : kM2BoardVertDist; glm::vec3 diff = playerCanonical - transport.position; float horizDistSq = diff.x * diff.x + diff.y * diff.y; float vertDist = std::abs(diff.z); - if (horizDistSq < 144.0f && vertDist < 15.0f) { - gameHandler->setPlayerOnTransport(guid, playerCanonical - transport.position); - LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, guid, std::dec); - break; + if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) { + float score = horizDistSq + vertDist * vertDist; + if (score < bestScore) { + bestScore = score; + bestGuid = guid; + } + } + } + if (bestGuid != 0) { + auto* tr = tm->getTransport(bestGuid); + if (tr) { + gameHandler->setPlayerOnTransport(bestGuid, playerCanonical - tr->position); + LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, bestGuid, std::dec); } } } @@ -1389,7 +1469,19 @@ void Application::update(float deltaTime) { glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); glm::vec3 diff = playerCanonical - tr->position; float horizDistSq = diff.x * diff.x + diff.y * diff.y; - if (horizDistSq > 225.0f) { + const bool isThunderBluffLift = + (tr->entry >= 20649u && tr->entry <= 20657u); + constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f; + constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f; + constexpr float kM2DisembarkVertDist = 18.0f; + constexpr float kTbLiftDisembarkVertDist = 16.0f; + const float disembarkHorizDistSq = isThunderBluffLift + ? kTbLiftDisembarkHorizDistSq + : kM2DisembarkHorizDistSq; + const float disembarkVertDist = isThunderBluffLift + ? kTbLiftDisembarkVertDist + : kM2DisembarkVertDist; + if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) { gameHandler->clearPlayerTransport(); LOG_DEBUG("M2 transport disembark"); } @@ -1822,6 +1914,9 @@ void Application::setupUICallbacks() { gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) { LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" " initial=", isInitialEntry); + if (renderer) { + renderer->resetCombatVisualState(); + } // Reconnect to the same map: terrain stays loaded but all online entities are stale. // Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world. @@ -2832,6 +2927,12 @@ void Application::setupUICallbacks() { } transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); + // Keep type in sync with the spawned instance; needed for M2 lift boarding/motion. + if (!it->second.isWmo) { + if (auto* tr = transportManager->getTransport(guid)) { + tr->isM2 = true; + } + } } else { pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, @@ -3011,9 +3112,11 @@ void Application::setupUICallbacks() { if (charInstId == 0) return; // WoW stand state → M2 animation ID mapping // 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72 + // Do not force Stand(0) here: locomotion state machine already owns standing/running. + // Forcing Stand on packet timing causes visible run-cycle hitching while steering. uint32_t animId = 0; if (standState == 0) { - animId = 0; // Stand + return; } else if (standState >= 1 && standState <= 6) { animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height) } else if (standState == 7) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 30e91ccb..16374768 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -309,6 +309,16 @@ bool isPlaceholderQuestTitle(const std::string& s) { return s.rfind("Quest #", 0) == 0; } +float mergeCooldownSeconds(float current, float incoming) { + constexpr float kEpsilon = 0.05f; + if (incoming <= 0.0f) return 0.0f; + if (current <= 0.0f) return incoming; + // Cooldowns should normally tick down. If a duplicate/late packet reports a + // larger value, keep the local remaining time to avoid visible timer resets. + if (incoming > current + kEpsilon) return current; + return incoming; +} + bool looksLikeQuestDescriptionText(const std::string& s) { int spaces = 0; int commas = 0; @@ -831,6 +841,13 @@ void GameHandler::update(float deltaTime) { it->timer -= deltaTime; if (it->timer <= 0.0f) { if (state == WorldState::IN_WORLD && socket) { + // Avoid sending CMSG_LOOT while a timed cast is active (e.g. gathering). + // handleSpellGo will trigger loot after the cast completes. + if (casting && currentCastSpellId != 0) { + it->timer = 0.20f; + ++it; + continue; + } lootTarget(it->guid); } it = pendingGameObjectLootOpens_.erase(it); @@ -3201,7 +3218,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t cdMs = packet.readUInt32(); float cdSec = cdMs / 1000.0f; if (cdSec > 0.0f) { - if (spellId != 0) spellCooldowns[spellId] = cdSec; + if (spellId != 0) { + auto it = spellCooldowns.find(spellId); + if (it == spellCooldowns.end()) { + spellCooldowns[spellId] = cdSec; + } else { + it->second = mergeCooldownSeconds(it->second, cdSec); + } + } // Resolve itemId from the GUID so item-type slots are also updated uint32_t itemId = 0; auto iit = onlineItems_.find(itemGuid); @@ -3210,8 +3234,14 @@ void GameHandler::handlePacket(network::Packet& packet) { bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId) || (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId); if (match) { - slot.cooldownTotal = cdSec; - slot.cooldownRemaining = cdSec; + float prevRemaining = slot.cooldownRemaining; + float merged = mergeCooldownSeconds(slot.cooldownRemaining, cdSec); + slot.cooldownRemaining = merged; + if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) { + slot.cooldownTotal = cdSec; + } else { + slot.cooldownTotal = std::max(slot.cooldownTotal, merged); + } } } LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec, @@ -8440,6 +8470,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { pendingItemQueries_.clear(); equipSlotGuids_ = {}; backpackSlotGuids_ = {}; + keyringSlotGuids_ = {}; invSlotBase_ = -1; packSlotBase_ = -1; lastPlayerFields_.clear(); @@ -8499,6 +8530,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { castTimeTotal = 0.0f; playerDead_ = false; releasedSpirit_ = false; + corpseGuid_ = 0; targetGuid = 0; focusGuid = 0; lastTargetGuid = 0; @@ -9840,8 +9872,17 @@ void GameHandler::sendMovement(Opcode opcode) { sanitizeMovementForTaxi(); } - // Add transport data if player is on a transport - if (isOnTransport()) { + bool includeTransportInWire = isOnTransport(); + if (includeTransportInWire && transportManager_) { + if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr && tr->isM2) { + // Client-detected M2 elevators/trams are not always server-recognized transports. + // Sending ONTRANSPORT for these can trigger bad fall-state corrections server-side. + includeTransportInWire = false; + } + } + + // Add transport data if player is on a server-recognized transport + if (includeTransportInWire) { // Keep authoritative world position synchronized to parent transport transform // so heartbeats/corrections don't drag the passenger through geometry. if (transportManager_) { @@ -9883,7 +9924,7 @@ void GameHandler::sendMovement(Opcode opcode) { LOG_DEBUG("Sending movement packet: opcode=0x", std::hex, wireOpcode(opcode), std::dec, - (isOnTransport() ? " ONTRANSPORT" : "")); + (includeTransportInWire ? " ONTRANSPORT" : "")); // Convert canonical → server coordinates for the wire MovementInfo wireInfo = movementInfo; @@ -9896,7 +9937,7 @@ void GameHandler::sendMovement(Opcode opcode) { wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation); // Also convert transport local position to server coordinates if on transport - if (isOnTransport()) { + if (includeTransportInWire) { glm::vec3 serverTransportPos = core::coords::canonicalToServer( glm::vec3(wireInfo.transportX, wireInfo.transportY, wireInfo.transportZ)); wireInfo.transportX = serverTransportPos.x; @@ -9956,6 +9997,7 @@ void GameHandler::forceClearTaxiAndMovementState() { resurrectRequestPending_ = false; playerDead_ = false; releasedSpirit_ = false; + corpseGuid_ = 0; repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; @@ -10573,11 +10615,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { // Server coords from movement block + corpseGuid_ = block.guid; corpseX_ = block.x; corpseY_ = block.y; corpseZ_ = block.z; corpseMapId_ = currentMapId_; - LOG_INFO("Corpse object detected: server=(", block.x, ", ", block.y, ", ", block.z, + LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, + " server=(", block.x, ", ", block.y, ", ", block.z, ") map=", corpseMapId_); } } @@ -11129,6 +11173,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { repopPending_ = false; resurrectPending_ = false; corpseMapId_ = 0; // corpse reclaimed + corpseGuid_ = 0; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (ghostStateCallback_) ghostStateCallback_(false); } @@ -12901,9 +12946,12 @@ bool GameHandler::canReclaimCorpse() const { void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; - network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + // Reclaim expects the corpse object guid when known; fallback to player guid. + uint64_t reclaimGuid = (corpseGuid_ != 0) ? corpseGuid_ : playerGuid; + auto packet = ReclaimCorpsePacket::build(reclaimGuid); socket->send(packet); - LOG_INFO("Sent CMSG_RECLAIM_CORPSE"); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE for guid=0x", std::hex, reclaimGuid, std::dec, + (corpseGuid_ == 0 ? " (fallback player guid)" : "")); } void GameHandler::activateSpiritHealer(uint64_t npcGuid) { @@ -13582,6 +13630,21 @@ bool GameHandler::applyInventoryFields(const std::map& field bool slotsChanged = false; int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD)); int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast(fieldIndex(UF::PLAYER_FIELD_PACK_SLOT_1)); + int bankBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1)); + int bankBagBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1)); + + // Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7). + if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) { + effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28); + effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7; + } + + int keyringBase = static_cast(fieldIndex(UF::PLAYER_FIELD_KEYRING_SLOT_1)); + if (keyringBase == 0xFFFF && bankBagBase != 0xFFFF) { + // Layout fallback for profiles that don't define PLAYER_FIELD_KEYRING_SLOT_1. + // Bank bag slots are followed by 12 vendor buyback slots (24 fields), then keyring. + keyringBase = bankBagBase + (effectiveBankBagSlots_ * 2) + 24; + } for (const auto& [key, val] : fields) { if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) { @@ -13602,15 +13665,17 @@ bool GameHandler::applyInventoryFields(const std::map& field else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); slotsChanged = true; } - } - - // Bank slots starting at PLAYER_FIELD_BANK_SLOT_1 - int bankBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1)); - int bankBagBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1)); - // Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7) - if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) { - effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28); - effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7; + } else if (keyringBase != 0xFFFF && + key >= keyringBase && + key <= keyringBase + (game::Inventory::KEYRING_SLOTS * 2 - 1)) { + int slotIndex = (key - keyringBase) / 2; + bool isLow = ((key - keyringBase) % 2 == 0); + if (slotIndex < static_cast(keyringSlotGuids_.size())) { + uint64_t& guid = keyringSlotGuids_[slotIndex]; + if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; + else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); + slotsChanged = true; + } } if (bankBase != 0xFFFF && key >= static_cast(bankBase) && key <= static_cast(bankBase) + (effectiveBankSlots_ * 2 - 1)) { @@ -13771,6 +13836,55 @@ void GameHandler::rebuildOnlineInventory() { inventory.setBackpackSlot(i, def); } + // Keyring slots + for (int i = 0; i < game::Inventory::KEYRING_SLOTS; i++) { + uint64_t guid = keyringSlotGuids_[i]; + if (guid == 0) continue; + + auto itemIt = onlineItems_.find(guid); + if (itemIt == onlineItems_.end()) continue; + + ItemDef def; + def.itemId = itemIt->second.entry; + def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; + def.maxStack = 1; + + auto infoIt = itemInfoCache_.find(itemIt->second.entry); + if (infoIt != itemInfoCache_.end()) { + def.name = infoIt->second.name; + def.quality = static_cast(infoIt->second.quality); + def.inventoryType = infoIt->second.inventoryType; + def.maxStack = std::max(1, infoIt->second.maxStack); + def.displayInfoId = infoIt->second.displayInfoId; + def.subclassName = infoIt->second.subclassName; + def.damageMin = infoIt->second.damageMin; + def.damageMax = infoIt->second.damageMax; + def.delayMs = infoIt->second.delayMs; + def.armor = infoIt->second.armor; + def.stamina = infoIt->second.stamina; + def.strength = infoIt->second.strength; + def.agility = infoIt->second.agility; + def.intellect = infoIt->second.intellect; + def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); + } else { + def.name = "Item " + std::to_string(def.itemId); + queryItemInfo(def.itemId, guid); + } + + inventory.setKeyringSlot(i, def); + } + // Bag contents (BAG1-BAG4 are equip slots 19-22) for (int bagIdx = 0; bagIdx < 4; bagIdx++) { uint64_t bagGuid = equipSlotGuids_[19 + bagIdx]; @@ -14014,6 +14128,8 @@ void GameHandler::rebuildOnlineInventory() { int c = 0; for (auto g : equipSlotGuids_) if (g) c++; return c; }(), " backpack=", [&](){ int c = 0; for (auto g : backpackSlotGuids_) if (g) c++; return c; + }(), " keyring=", [&](){ + int c = 0; for (auto g : keyringSlotGuids_) if (g) c++; return c; }()); } @@ -16660,9 +16776,12 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { socket->send(packet); LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); - // Optimistically start GCD immediately on cast — server will confirm or override - gcdTotal_ = 1.5f; - gcdStartedAt_ = std::chrono::steady_clock::now(); + // Optimistically start GCD immediately on cast, but do not restart it while + // already active (prevents timeout animation reset on repeated key presses). + if (!isGCDActive()) { + gcdTotal_ = 1.5f; + gcdStartedAt_ = std::chrono::steady_clock::now(); + } } void GameHandler::cancelCast() { @@ -17177,13 +17296,24 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { continue; } - spellCooldowns[spellId] = seconds; + auto it = spellCooldowns.find(spellId); + if (it == spellCooldowns.end()) { + spellCooldowns[spellId] = seconds; + } else { + it->second = mergeCooldownSeconds(it->second, seconds); + } for (auto& slot : actionBar) { bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) || (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId); if (match) { - slot.cooldownTotal = seconds; - slot.cooldownRemaining = seconds; + float prevRemaining = slot.cooldownRemaining; + float merged = mergeCooldownSeconds(slot.cooldownRemaining, seconds); + slot.cooldownRemaining = merged; + if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) { + slot.cooldownTotal = seconds; + } else { + slot.cooldownTotal = std::max(slot.cooldownTotal, merged); + } } } } @@ -18365,14 +18495,25 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { lower.find("coffer") != std::string::npos || lower.find("cache") != std::string::npos); } - // For WotLK, CMSG_GAMEOBJ_REPORT_USE is required for chests (and is harmless for others). - if (!isMailbox && isActiveExpansion("wotlk")) { - network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); - reportUse.writeUInt64(guid); - socket->send(reportUse); + // Some servers require CMSG_GAMEOBJ_REPORT_USE for lootable gameobjects. + // Only send it when the active opcode table actually supports it. + if (!isMailbox) { + const auto* table = getActiveOpcodeTable(); + if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); + } } if (shouldSendLoot) { lootTarget(guid); + // Some servers/scripts only make certain quest/chest GOs lootable after a short delay + // (use animation, state change). Queue one delayed loot attempt to catch that case. + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == guid; }), + pendingGameObjectLootOpens_.end()); + pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f}); } else { // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be // sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot @@ -18427,6 +18568,21 @@ void GameHandler::selectGossipOption(uint32_t optionId) { LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec); } + // Vendor / repair: some servers require an explicit CMSG_LIST_INVENTORY after gossip select. + const bool isVendor = (text == "GOSSIP_OPTION_VENDOR" || + (textLower.find("browse") != std::string::npos && + (textLower.find("goods") != std::string::npos || textLower.find("wares") != std::string::npos))); + const bool isArmorer = (text == "GOSSIP_OPTION_ARMORER" || textLower.find("repair") != std::string::npos); + if (isVendor || isArmorer) { + if (isArmorer) { + setVendorCanRepair(true); + } + auto pkt = ListInventoryPacket::build(currentGossip.npcGuid); + socket->send(pkt); + LOG_INFO("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip.npcGuid, std::dec, + " vendor=", (int)isVendor, " repair=", (int)isArmorer); + } + if (textLower.find("make this inn your home") != std::string::npos || textLower.find("set your home") != std::string::npos) { auto bindPkt = BinderActivatePacket::build(currentGossip.npcGuid); @@ -19545,7 +19701,12 @@ void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; lootWindowOpen = true; lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo - localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false}; + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }), + pendingGameObjectLootOpens_.end()); + auto& localLoot = localLootState_[currentLoot.lootGuid]; + localLoot.data = currentLoot; // Query item info so loot window can show names instead of IDs for (const auto& item : currentLoot.items) { @@ -19570,11 +19731,12 @@ void GameHandler::handleLootResponse(network::Packet& packet) { } // Auto-loot items when enabled - if (autoLoot_ && state == WorldState::IN_WORLD && socket) { + if (autoLoot_ && state == WorldState::IN_WORLD && socket && !localLoot.itemAutoLootSent) { for (const auto& item : currentLoot.items) { auto pkt = AutostoreLootItemPacket::build(item.slotIndex); socket->send(pkt); } + localLoot.itemAutoLootSent = true; } } @@ -19783,13 +19945,14 @@ void GameHandler::handleListInventory(network::Packet& packet) { bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path if (!ListInventoryParser::parse(packet, currentVendorItems)) return; - // Check NPC_FLAG_REPAIR (0x40) on the vendor entity — this handles vendors that open + // Check NPC_FLAG_REPAIR (0x1000) on the vendor entity — this handles vendors that open // directly without going through the gossip armorer option. if (!savedCanRepair && currentVendorItems.vendorGuid != 0) { auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); if (entity && entity->getType() == ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); - if (unit->getNpcFlags() & 0x40) { // NPC_FLAG_REPAIR + // MaNGOS/Trinity: UNIT_NPC_FLAG_REPAIR = 0x00001000. + if (unit->getNpcFlags() & 0x1000) { savedCanRepair = true; } } @@ -20380,6 +20543,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; corpseMapId_ = 0; + corpseGuid_ = 0; hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true; @@ -22098,9 +22262,9 @@ void GameHandler::mailTakeMoney(uint32_t mailId) { socket->send(packet); } -void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemIndex) { +void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemGuidLow) { if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return; - auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemIndex); + auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemGuidLow); socket->send(packet); } @@ -22851,7 +23015,7 @@ void GameHandler::declineTradeRequest() { } void GameHandler::acceptTrade() { - if (tradeStatus_ != TradeStatus::Open || !socket) return; + if (!isTradeOpen() || !socket) return; tradeStatus_ = TradeStatus::Accepted; socket->send(AcceptTradePacket::build()); } diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 259fb872..0d694aba 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -45,6 +45,23 @@ bool Inventory::clearEquipSlot(EquipSlot slot) { return true; } +const ItemSlot& Inventory::getKeyringSlot(int index) const { + if (index < 0 || index >= KEYRING_SLOTS) return EMPTY_SLOT; + return keyring_[index]; +} + +bool Inventory::setKeyringSlot(int index, const ItemDef& item) { + if (index < 0 || index >= KEYRING_SLOTS) return false; + keyring_[index].item = item; + return true; +} + +bool Inventory::clearKeyringSlot(int index) { + if (index < 0 || index >= KEYRING_SLOTS) return false; + keyring_[index].item = ItemDef{}; + return true; +} + int Inventory::getBagSize(int bagIndex) const { if (bagIndex < 0 || bagIndex >= NUM_BAG_SLOTS) return 0; return bags[bagIndex].size; diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 955f8eaa..a9ff5cba 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -251,8 +251,10 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float if (path.durationMs == 0) { // Just update transform (position already set) updateTransformMatrices(transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } return; } @@ -287,8 +289,10 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float } else { // Strict server-authoritative mode: do not guess movement between server snapshots. updateTransformMatrices(transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } return; } @@ -777,8 +781,10 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos } updateTransformMatrices(*transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + if (transport->isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); } return; } diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index ae45deb7..6a736546 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -54,6 +54,7 @@ static const UFNameEntry kUFNames[] = { {"PLAYER_QUEST_LOG_START", UF::PLAYER_QUEST_LOG_START}, {"PLAYER_FIELD_INV_SLOT_HEAD", UF::PLAYER_FIELD_INV_SLOT_HEAD}, {"PLAYER_FIELD_PACK_SLOT_1", UF::PLAYER_FIELD_PACK_SLOT_1}, + {"PLAYER_FIELD_KEYRING_SLOT_1", UF::PLAYER_FIELD_KEYRING_SLOT_1}, {"PLAYER_FIELD_BANK_SLOT_1", UF::PLAYER_FIELD_BANK_SLOT_1}, {"PLAYER_FIELD_BANKBAG_SLOT_1", UF::PLAYER_FIELD_BANKBAG_SLOT_1}, {"PLAYER_SKILL_INFO_START", UF::PLAYER_SKILL_INFO_START}, diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4c0a0b1e..659003d7 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1482,6 +1482,47 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { // Read unknown field packet.readUInt32(); + auto tryReadSizedCString = [&](std::string& out, uint32_t maxLen, size_t minTrailingBytes) -> bool { + size_t start = packet.getReadPos(); + size_t remaining = packet.getSize() - start; + if (remaining < 4 + minTrailingBytes) return false; + + uint32_t len = packet.readUInt32(); + if (len < 2 || len > maxLen) { + packet.setReadPos(start); + return false; + } + if ((packet.getSize() - packet.getReadPos()) < (static_cast(len) + minTrailingBytes)) { + packet.setReadPos(start); + return false; + } + + std::string tmp; + tmp.resize(len); + for (uint32_t i = 0; i < len; ++i) { + tmp[i] = static_cast(packet.readUInt8()); + } + if (tmp.empty() || tmp.back() != '\0') { + packet.setReadPos(start); + return false; + } + tmp.pop_back(); + if (tmp.empty()) { + packet.setReadPos(start); + return false; + } + for (char c : tmp) { + unsigned char uc = static_cast(c); + if (uc < 32 || uc > 126) { + packet.setReadPos(start); + return false; + } + } + + out = std::move(tmp); + return true; + }; + // Type-specific data // WoW 3.3.5 SMSG_MESSAGECHAT format: after senderGuid+unk, most types // have a receiverGuid (uint64). Some types have extra fields before it. @@ -1537,6 +1578,27 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { break; } + case ChatType::WHISPER: + case ChatType::WHISPER_INFORM: { + // Some cores include an explicit sized sender/receiver name for whisper chat. + // Consume it when present so /r has a reliable last whisper sender. + if (data.type == ChatType::WHISPER) { + tryReadSizedCString(data.senderName, 128, 8 + 4 + 1); + } else { + tryReadSizedCString(data.receiverName, 128, 8 + 4 + 1); + } + + data.receiverGuid = packet.readUInt64(); + + // Optional trailing whisper target/source name on some formats. + if (data.type == ChatType::WHISPER && data.receiverName.empty()) { + tryReadSizedCString(data.receiverName, 128, 4 + 1); + } else if (data.type == ChatType::WHISPER_INFORM && data.senderName.empty()) { + tryReadSizedCString(data.senderName, 128, 4 + 1); + } + break; + } + case ChatType::BG_SYSTEM_NEUTRAL: case ChatType::BG_SYSTEM_ALLIANCE: case ChatType::BG_SYSTEM_HORDE: @@ -4881,6 +4943,12 @@ network::Packet RepopRequestPacket::build() { return packet; } +network::Packet ReclaimCorpsePacket::build(uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + packet.writeUInt64(guid); + return packet; +} + network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE)); packet.writeUInt64(npcGuid); @@ -5001,11 +5069,12 @@ network::Packet MailTakeMoneyPacket::build(uint64_t mailboxGuid, uint32_t mailId return packet; } -network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemIndex) { +network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) { network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_ITEM)); packet.writeUInt64(mailboxGuid); packet.writeUInt32(mailId); - packet.writeUInt32(itemIndex); + // WotLK expects attachment item GUID low, not attachment slot index. + packet.writeUInt32(itemGuidLow); return packet; } @@ -5072,10 +5141,9 @@ bool PacketParsers::parseMailList(network::Packet& packet, std::vector= 1.0f) { inst.position = inst.moveEnd; inst.isMoving = false; - // Return to idle when movement completes - if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) { - playAnimation(pair.first, 0, true); - } } else { inst.position = glm::mix(inst.moveStart, inst.moveEnd, t); } diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index d7ae0b2a..aad92ab5 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -2654,8 +2654,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const (batch.blendMode >= 3) || batch.colorKeyBlack || ((batch.materialFlags & 0x01) != 0); - if ((batch.glowCardLike && lanternLikeModel) || - (cardLikeSkipMesh && !lanternLikeModel)) { + const bool lanternGlowCardSkip = + lanternLikeModel && + batch.lanternGlowHint && + smallCardLikeBatch && + cardLikeSkipMesh; + if (lanternGlowCardSkip || (cardLikeSkipMesh && !lanternLikeModel)) { continue; } } @@ -2851,16 +2855,25 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Skip glow sprites (handled after loop) const bool batchUnlit = (batch.materialFlags & 0x01) != 0; + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; + const bool smallCardLikeBatch = + (batch.glowSize <= 1.35f) || + (batch.lanternGlowHint && batch.glowSize <= 6.0f); const bool shouldUseGlowSprite = - !batch.colorKeyBlack && + !koboldFlameCard && (model.isElvenLike || model.isLanternLike) && !model.isSpellEffect && - (batch.glowSize <= 1.35f || (batch.lanternGlowHint && batch.glowSize <= 6.0f)) && + smallCardLikeBatch && (batch.lanternGlowHint || (batch.blendMode >= 3) || (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); if (shouldUseGlowSprite) { const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; - if ((batch.glowCardLike && model.isLanternLike) || (cardLikeSkipMesh && !model.isLanternLike)) + const bool lanternGlowCardSkip = + model.isLanternLike && + batch.lanternGlowHint && + smallCardLikeBatch && + cardLikeSkipMesh; + if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike)) continue; } diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index a5a76ab2..d6119e74 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -461,7 +461,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Turn left/right"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Strafe left/right"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index f5ab086e..fdf0432b 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -1031,13 +1031,14 @@ void Renderer::beginFrame() { // FXAA resource management — FXAA can coexist with FSR1 and FSR3. // When both FXAA and FSR3 are enabled, FXAA runs as a post-FSR3 pass. - // When both FXAA and FSR1 are enabled, FXAA takes priority (native res render). - if (fxaa_.needsRecreate && fxaa_.sceneFramebuffer) { + // Do not force this pass for ghost mode; keep AA quality strictly user-controlled. + const bool useFXAAPostPass = fxaa_.enabled; + if ((fxaa_.needsRecreate || !useFXAAPostPass) && fxaa_.sceneFramebuffer) { destroyFXAAResources(); fxaa_.needsRecreate = false; - if (!fxaa_.enabled) LOG_INFO("FXAA: disabled"); + if (!useFXAAPostPass) LOG_INFO("FXAA: disabled"); } - if (fxaa_.enabled && !fxaa_.sceneFramebuffer) { + if (useFXAAPostPass && !fxaa_.sceneFramebuffer) { if (!initFXAAResources()) { LOG_ERROR("FXAA: initialization failed, disabling"); fxaa_.enabled = false; @@ -1060,9 +1061,8 @@ void Renderer::beginFrame() { destroyFSR2Resources(); initFSR2Resources(); } - // Recreate FXAA resources for new swapchain dimensions - // FXAA can coexist with FSR1 and FSR3 simultaneously. - if (fxaa_.enabled) { + // Recreate FXAA resources for new swapchain dimensions. + if (useFXAAPostPass) { destroyFXAAResources(); initFXAAResources(); } @@ -1152,7 +1152,7 @@ void Renderer::beginFrame() { if (fsr2_.enabled && fsr2_.sceneFramebuffer) { rpInfo.framebuffer = fsr2_.sceneFramebuffer; renderExtent = { fsr2_.internalWidth, fsr2_.internalHeight }; - } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { + } else if (useFXAAPostPass && fxaa_.sceneFramebuffer) { // FXAA takes priority over FSR1: renders at native res with AA post-process. // When both FSR1 and FXAA are enabled, FXAA wins (native res, no downscale). rpInfo.framebuffer = fxaa_.sceneFramebuffer; @@ -1856,7 +1856,18 @@ void Renderer::updateCharacterAnimation() { CharAnimState newState = charAnimState; - bool moving = cameraController->isMoving(); + const bool rawMoving = cameraController->isMoving(); + const bool rawSprinting = cameraController->isSprinting(); + constexpr float kLocomotionStopGraceSec = 0.12f; + if (rawMoving) { + locomotionStopGraceTimer_ = kLocomotionStopGraceSec; + locomotionWasSprinting_ = rawSprinting; + } else { + locomotionStopGraceTimer_ = std::max(0.0f, locomotionStopGraceTimer_ - lastDeltaTime_); + } + // Debounce brief input/state dropouts (notably during both-mouse steering) so + // locomotion clips do not restart every few frames. + bool moving = rawMoving || locomotionStopGraceTimer_ > 0.0f; bool movingForward = cameraController->isMovingForward(); bool movingBackward = cameraController->isMovingBackward(); bool autoRunning = cameraController->isAutoRunning(); @@ -1869,7 +1880,7 @@ void Renderer::updateCharacterAnimation() { bool anyStrafeRight = strafeRight && !strafeLeft && pureStrafe; bool grounded = cameraController->isGrounded(); bool jumping = cameraController->isJumping(); - bool sprinting = cameraController->isSprinting(); + bool sprinting = rawSprinting || (!rawMoving && moving && locomotionWasSprinting_); bool sitting = cameraController->isSitting(); bool swim = cameraController->isSwimming(); bool forceMelee = meleeSwingTimer > 0.0f && grounded && !swim; @@ -2529,8 +2540,14 @@ void Renderer::updateCharacterAnimation() { float currentAnimTimeMs = 0.0f; float currentAnimDurationMs = 0.0f; bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs); - if (!haveState || currentAnimId != animId) { + // Some frames may transiently fail getAnimationState() while resources/instance state churn. + // Avoid reissuing the same clip on those frames, which restarts locomotion and causes hitches. + const bool requestChanged = (lastPlayerAnimRequest_ != animId) || (lastPlayerAnimLoopRequest_ != loop); + const bool shouldPlay = (haveState && currentAnimId != animId) || (!haveState && requestChanged); + if (shouldPlay) { characterRenderer->playAnimation(characterInstanceId, animId, loop); + lastPlayerAnimRequest_ = animId; + lastPlayerAnimLoopRequest_ = loop; } } @@ -2701,6 +2718,13 @@ void Renderer::setTargetPosition(const glm::vec3* pos) { targetPosition = pos; } +void Renderer::resetCombatVisualState() { + inCombat_ = false; + targetPosition = nullptr; + meleeSwingTimer = 0.0f; + meleeSwingCooldown = 0.0f; +} + bool Renderer::isMoving() const { return cameraController && cameraController->isMoving(); } @@ -5074,7 +5098,7 @@ void Renderer::renderFXAAPass() { vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); - // Pass rcpFrame + sharpness + desaturate (vec4, 16 bytes). + // Pass rcpFrame + sharpness + effect flag (vec4, 16 bytes). // When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the // post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes. float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f; @@ -5082,7 +5106,7 @@ void Renderer::renderFXAAPass() { 1.0f / static_cast(ext.width), 1.0f / static_cast(ext.height), sharpness, - ghostMode_ ? 1.0f : 0.0f // desaturate: 1=ghost grayscale, 0=normal + 0.0f }; vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc); @@ -5272,12 +5296,6 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint, cmd); } } - // Ghost mode desaturation overlay (non-FXAA path approximation). - // When FXAA is active the FXAA shader applies true per-pixel desaturation; - // otherwise a high-opacity gray overlay gives a similar washed-out effect. - if (ghostMode_ && overlayPipeline && !fxaa_.enabled) { - renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f), cmd); - } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -5412,10 +5430,6 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint); } } - // Ghost mode desaturation overlay (non-FXAA path approximation). - if (ghostMode_ && overlayPipeline && !fxaa_.enabled) { - renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f)); - } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index a8b518a2..775881d3 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -396,9 +396,13 @@ bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, // Allocate and write material descriptor set gpuChunk.materialSet = allocateMaterialSet(); - if (gpuChunk.materialSet) { - writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); + if (!gpuChunk.materialSet) { + // Pool exhaustion can happen transiently while tile churn is high. + // Drop this chunk instead of retaining non-renderable GPU resources. + destroyChunkGPU(gpuChunk); + continue; } + writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); chunks.push_back(std::move(gpuChunk)); } @@ -487,9 +491,12 @@ bool TerrainRenderer::loadTerrainIncremental(const pipeline::TerrainMesh& mesh, } gpuChunk.materialSet = allocateMaterialSet(); - if (gpuChunk.materialSet) { - writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); + if (!gpuChunk.materialSet) { + // Keep memory/work bounded under descriptor pool pressure. + destroyChunkGPU(gpuChunk); + continue; } + writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); chunks.push_back(std::move(gpuChunk)); uploaded++; @@ -653,7 +660,11 @@ VkDescriptorSet TerrainRenderer::allocateMaterialSet() { VkDescriptorSet set = VK_NULL_HANDLE; if (vkAllocateDescriptorSets(vkCtx->getDevice(), &allocInfo, &set) != VK_SUCCESS) { - LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set"); + static uint64_t failCount = 0; + ++failCount; + if (failCount <= 8 || (failCount % 256) == 0) { + LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set (count=", failCount, ")"); + } return VK_NULL_HANDLE; } return set; diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 7210d1be..523bf818 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -2054,6 +2054,9 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, const Frustum& frustum, const glm::mat4& modelMatrix, std::unordered_set& outVisibleGroups) const { + constexpr uint32_t WMO_GROUP_FLAG_OUTDOOR = 0x8; + constexpr uint32_t WMO_GROUP_FLAG_INDOOR = 0x2000; + // Find camera's containing group int cameraGroup = findContainingGroup(model, cameraLocalPos); @@ -2067,6 +2070,21 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, return; } + // Outdoor city WMOs (e.g. Stormwind) often have portal graphs that are valid for + // indoor visibility but too aggressive outdoors, causing direction-dependent popout. + // Only trust portal traversal when the camera is in an interior-only group. + if (cameraGroup < static_cast(model.groups.size())) { + const uint32_t gFlags = model.groups[cameraGroup].groupFlags; + const bool isIndoor = (gFlags & WMO_GROUP_FLAG_INDOOR) != 0; + const bool isOutdoor = (gFlags & WMO_GROUP_FLAG_OUTDOOR) != 0; + if (!isIndoor || isOutdoor) { + for (size_t gi = 0; gi < model.groups.size(); gi++) { + outVisibleGroups.insert(static_cast(gi)); + } + return; + } + } + // If the camera group has no portal refs, it's a dead-end group (utility/transition group). // Fall back to showing all groups to avoid the rest of the WMO going invisible. if (cameraGroup < static_cast(model.groupPortalRefs.size())) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 788150b6..287f0456 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -845,7 +845,23 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Update renderer face-target position and selection circle auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { - renderer->setInCombat(gameHandler.isInCombat()); + renderer->setInCombat(gameHandler.isInCombat() && + !gameHandler.isPlayerDead() && + !gameHandler.isPlayerGhost()); + if (auto* cr = renderer->getCharacterRenderer()) { + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId != 0) { + const bool isGhost = gameHandler.isPlayerGhost(); + if (!ghostOpacityStateKnown_ || + ghostOpacityLastState_ != isGhost || + ghostOpacityLastInstanceId_ != charInstId) { + cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f); + ghostOpacityStateKnown_ = true; + ghostOpacityLastState_ = isGhost; + ghostOpacityLastInstanceId_ = charInstId; + } + } + } static glm::vec3 targetGLPos; if (gameHandler.hasTarget()) { auto target = gameHandler.getTarget(); @@ -2275,9 +2291,25 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto& io = ImGui::GetIO(); auto& input = core::Input::getInstance(); + // If the user is typing (or about to focus chat this frame), do not allow + // A-Z or 1-0 shortcuts to fire. + if (!io.WantTextInput && !chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { + refocusChatInput = true; + chatInputBuffer[0] = '/'; + chatInputBuffer[1] = '\0'; + chatInputMoveCursorToEnd = true; + } + if (!io.WantTextInput && !chatInputActive && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { + refocusChatInput = true; + } + + const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput; + // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { - if (input.isKeyJustPressed(SDL_SCANCODE_TAB)) { + // When typing in chat (or any text input), never treat keys as gameplay/UI shortcuts. + if (!textFocus && input.isKeyJustPressed(SDL_SCANCODE_TAB)) { const auto& movement = gameHandler.getMovementInfo(); gameHandler.tabTarget(movement.x, movement.y, movement.z); } @@ -2300,88 +2332,68 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } - // Toggle character screen (C) and inventory/bags (I) - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { - inventoryScreen.toggleCharacter(); - } + if (!textFocus) { + // Toggle character screen (C) and inventory/bags (I) + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { + const bool wasOpen = inventoryScreen.isCharacterOpen(); + inventoryScreen.toggleCharacter(); + if (!wasOpen && gameHandler.isConnected()) { + gameHandler.requestPlayedTime(); + } + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { - inventoryScreen.toggle(); - } - - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { - if (inventoryScreen.isSeparateBags()) { - inventoryScreen.openAllBags(); - } else { + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { inventoryScreen.toggle(); } - } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { - showNameplates_ = !showNameplates_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { + showNameplates_ = !showNameplates_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { - showWorldMap_ = !showWorldMap_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { + showWorldMap_ = !showWorldMap_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { - showMinimap_ = !showMinimap_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { + showMinimap_ = !showMinimap_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { - showRaidFrames_ = !showRaidFrames_; - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { + showRaidFrames_ = !showRaidFrames_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) { - questLogScreen.toggle(); - } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { + showAchievementWindow_ = !showAchievementWindow_; + } - if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { - showAchievementWindow_ = !showAchievementWindow_; - } + // Toggle Titles window with H (hero/title screen — no conflicting keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + showTitlesWindow_ = !showTitlesWindow_; + } - // Toggle Titles window with H (hero/title screen — no conflicting keybinding) - if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { - showTitlesWindow_ = !showTitlesWindow_; - } - - - // Action bar keys (1-9, 0, -, =) - static const SDL_Scancode actionBarKeys[] = { - SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, - SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, - SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS - }; - const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); - const auto& bar = gameHandler.getActionBar(); - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (input.isKeyJustPressed(actionBarKeys[i])) { - int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; - if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(bar[slotIdx].id, target); - } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { - gameHandler.useItemById(bar[slotIdx].id); + // Action bar keys (1-9, 0, -, =) + static const SDL_Scancode actionBarKeys[] = { + SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, + SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, + SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS + }; + const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const auto& bar = gameHandler.getActionBar(); + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (input.isKeyJustPressed(actionBarKeys[i])) { + int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; + if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(bar[slotIdx].id, target); + } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { + gameHandler.useItemById(bar[slotIdx].id); + } } } } } - // Slash key: focus chat input — always works unless already typing in chat - if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { - refocusChatInput = true; - chatInputBuffer[0] = '/'; - chatInputBuffer[1] = '\0'; - chatInputMoveCursorToEnd = true; - } - - // Enter key: focus chat input (empty) — always works unless already typing - if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { - refocusChatInput = true; - } - // Cursor affordance: show hand cursor over interactable game objects. if (!io.WantCaptureMouse) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -2534,6 +2546,25 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto* camera = renderer ? renderer->getCamera() : nullptr; auto* window = core::Application::getInstance().getWindow(); if (camera && window) { + // If a quest objective gameobject is under the cursor, prefer it over + // hostile units so quest pickups (e.g. "Bundle of Wood") are reliable. + std::unordered_set questObjectiveGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + questObjectiveGoEntries.reserve(32); + for (const auto& q : ql) { + if (q.complete) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId >= 0 || obj.required == 0) continue; + uint32_t entry = static_cast(-obj.npcOrGoId); + uint32_t cur = 0; + auto it = q.killCounts.find(entry); + if (it != q.killCounts.end()) cur = it->second.first; + if (cur < obj.required) questObjectiveGoEntries.insert(entry); + } + } + } + glm::vec2 mousePos = input.getMousePosition(); float screenW = static_cast(window->getWidth()); float screenH = static_cast(window->getHeight()); @@ -2543,13 +2574,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { game::ObjectType closestType = game::ObjectType::OBJECT; float closestHostileUnitT = 1e30f; uint64_t closestHostileUnitGuid = 0; + float closestQuestGoT = 1e30f; + uint64_t closestQuestGoGuid = 0; const uint64_t myGuid = gameHandler.getPlayerGuid(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER && - t != game::ObjectType::GAMEOBJECT) continue; + t != game::ObjectType::GAMEOBJECT) + continue; if (guid == myGuid) continue; + glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); @@ -2564,10 +2599,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } else if (t == game::ObjectType::GAMEOBJECT) { // For GOs with no renderer instance yet, use a tight fallback - // sphere (not 2.5f) so invisible/unloaded GOs (chairs, doodads) - // are not accidentally clicked during camera right-drag. + // sphere so invisible/unloaded doodads aren't accidentally clicked. hitRadius = 1.2f; heightOffset = 1.0f; + // Quest objective GOs should be easier to click. + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + hitRadius = 2.2f; + heightOffset = 1.2f; + } } hitCenter = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); @@ -2575,6 +2615,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else { hitRadius = std::max(hitRadius * 1.1f, 0.6f); } + float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) { if (t == game::ObjectType::UNIT) { @@ -2585,6 +2626,15 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { closestHostileUnitGuid = guid; } } + if (t == game::ObjectType::GAMEOBJECT && !questObjectiveGoEntries.empty()) { + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + if (hitT < closestQuestGoT) { + closestQuestGoT = hitT; + closestQuestGoGuid = guid; + } + } + } if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -2592,11 +2642,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } } - // Prefer hostile monsters over nearby gameobjects/others when right-click picking. - if (closestHostileUnitGuid != 0) { + + // Prefer quest objective GOs over hostile monsters when both are hittable. + if (closestQuestGoGuid != 0) { + closestGuid = closestQuestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else if (closestHostileUnitGuid != 0) { + // Prefer hostile monsters over nearby gameobjects/others when right-click picking. closestGuid = closestHostileUnitGuid; closestType = game::ObjectType::UNIT; } + if (closestGuid != 0) { if (closestType == game::ObjectType::GAMEOBJECT) { gameHandler.setTarget(closestGuid); @@ -11224,7 +11280,9 @@ void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Guild Roster toggle (customizable keybind) - if (!ImGui::GetIO().WantCaptureKeyboard && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + !ImGui::GetIO().WantCaptureKeyboard && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { // Open friends tab directly if not in guild @@ -13552,6 +13610,13 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { bool open = true; if (ImGui::Begin("Trainer", &open)) { + // If user clicked window close, short-circuit before rendering large trainer tables. + if (!open) { + ImGui::End(); + gameHandler.closeTrainer(); + return; + } + const auto& trainer = gameHandler.getTrainerSpells(); // NPC name @@ -15831,27 +15896,6 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { return true; }; - // Player position marker — always drawn at minimap center with a directional arrow. - { - // The player is always at centerX, centerY on the minimap. - // Draw a yellow arrow pointing in the player's facing direction. - glm::vec3 fwd = camera->getForward(); - float facing = std::atan2(fwd.y, -fwd.x); // clockwise bearing from North - float cosF = std::cos(facing - bearing); - float sinF = std::sin(facing - bearing); - float arrowLen = 8.0f; - float arrowW = 4.0f; - ImVec2 tip(centerX + sinF * arrowLen, centerY - cosF * arrowLen); - ImVec2 left(centerX - cosF * arrowW - sinF * arrowLen * 0.3f, - centerY - sinF * arrowW + cosF * arrowLen * 0.3f); - ImVec2 right(centerX + cosF * arrowW - sinF * arrowLen * 0.3f, - centerY + sinF * arrowW + cosF * arrowLen * 0.3f); - drawList->AddTriangleFilled(tip, left, right, IM_COL32(255, 220, 0, 255)); - drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); - // White dot at player center - drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220)); - } - // Build sets of entries that are incomplete objectives for tracked quests. // minimapQuestEntries: NPC creature entries (npcOrGoId > 0) // minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value) @@ -17687,7 +17731,7 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { } ImGui::SameLine(); if (ImGui::SmallButton("Take")) { - gameHandler.mailTakeItem(mail.messageId, att.slot); + gameHandler.mailTakeItem(mail.messageId, att.itemGuidLow); } ImGui::PopID(); @@ -17696,7 +17740,7 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { if (mail.attachments.size() > 1) { if (ImGui::SmallButton("Take All")) { for (const auto& att2 : mail.attachments) { - gameHandler.mailTakeItem(mail.messageId, att2.slot); + gameHandler.mailTakeItem(mail.messageId, att2.itemGuidLow); } } } @@ -19543,7 +19587,8 @@ void GameScreen::renderZoneText() { // --------------------------------------------------------------------------- void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // Toggle Dungeon Finder (customizable keybind) - if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { showDungeonFinder_ = !showDungeonFinder_; } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index fb0a0df7..3d4b0c17 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -743,17 +743,6 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { bool bToggled = bagsDown && !bKeyWasDown; bKeyWasDown = bagsDown; - // Character screen toggle (C key, edge-triggered) - bool characterDown = KeybindingManager::getInstance().isActionPressed( - KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN, false); - if (characterDown && !cKeyWasDown) { - characterOpen = !characterOpen; - if (characterOpen && gameHandler_) { - gameHandler_->requestPlayedTime(); - } - } - cKeyWasDown = characterDown; - bool wantsTextInput = ImGui::GetIO().WantTextInput; if (separateBags_) { @@ -1028,7 +1017,11 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, int rows = (numSlots + columns - 1) / columns; float contentH = rows * (slotSize + 4.0f) + 10.0f; - if (bagIndex < 0) contentH += 25.0f; // money display for backpack + if (bagIndex < 0) { + int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns; + contentH += 25.0f; // money display for backpack + contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots + } float gridW = columns * (slotSize + 4.0f) + 30.0f; // Ensure window is wide enough for the title + close button const char* displayTitle = title; @@ -1076,6 +1069,23 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, ImGui::PopID(); } + if (bagIndex < 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); + for (int i = 0; i < inventory.getKeyringSize(); ++i) { + if (i % columns != 0) ImGui::SameLine(); + const auto& slot = inventory.getKeyringSlot(i); + char id[32]; + snprintf(id, sizeof(id), "##skr_%d", i); + ImGui::PushID(id); + // Keyring is display-only for now. + renderItemSlot(inventory, slot, slotSize, nullptr, + SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); + ImGui::PopID(); + } + } + // Money display at bottom of backpack if (bagIndex < 0 && moneyCopper > 0) { ImGui::Spacing(); @@ -2031,6 +2041,30 @@ void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool colla ImGui::PopID(); } } + + bool keyringHasAnyItems = false; + for (int i = 0; i < inventory.getKeyringSize(); ++i) { + if (!inventory.getKeyringSlot(i).empty()) { + keyringHasAnyItems = true; + break; + } + } + if (!collapseEmptySections || keyringHasAnyItems) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); + for (int i = 0; i < inventory.getKeyringSize(); ++i) { + if (i % columns != 0) ImGui::SameLine(); + const auto& slot = inventory.getKeyringSlot(i); + char sid[32]; + snprintf(sid, sizeof(sid), "##keyring_%d", i); + ImGui::PushID(sid); + // Keyring is display-only for now. + renderItemSlot(inventory, slot, slotSize, nullptr, + SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); + ImGui::PopID(); + } + } } void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index fbe70e33..20d562c5 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -5,6 +5,11 @@ namespace wowee::ui { +static bool isReservedMovementKey(ImGuiKey key) { + return key == ImGuiKey_W || key == ImGuiKey_A || key == ImGuiKey_S || + key == ImGuiKey_D || key == ImGuiKey_Q || key == ImGuiKey_E; +} + KeybindingManager& KeybindingManager::getInstance() { static KeybindingManager instance; return instance; @@ -30,14 +35,25 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) - bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_None; // Q conflicts with strafe-left; quest log accessible via TOGGLE_QUESTS (L) bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) } bool KeybindingManager::isActionPressed(Action action, bool repeat) { auto it = bindings_.find(static_cast(action)); if (it == bindings_.end()) return false; - return ImGui::IsKeyPressed(it->second, repeat); + ImGuiKey key = it->second; + if (key == ImGuiKey_None) return false; + + // When typing in a text field (e.g. chat input), never treat A-Z or 0-9 as shortcuts. + const ImGuiIO& io = ImGui::GetIO(); + if (io.WantTextInput) { + if ((key >= ImGuiKey_A && key <= ImGuiKey_Z) || + (key >= ImGuiKey_0 && key <= ImGuiKey_9)) { + return false; + } + } + + return ImGui::IsKeyPressed(key, repeat); } ImGuiKey KeybindingManager::getKeyForAction(Action action) const { @@ -47,6 +63,11 @@ ImGuiKey KeybindingManager::getKeyForAction(Action action) const { } void KeybindingManager::setKeyForAction(Action action, ImGuiKey key) { + // Reserve movement keys so they cannot be used as UI shortcuts. + (void)action; + if (isReservedMovementKey(key)) { + key = ImGuiKey_None; + } bindings_[static_cast(action)] = key; } @@ -71,7 +92,6 @@ const char* KeybindingManager::getActionName(Action action) { case Action::TOGGLE_WORLD_MAP: return "World Map"; case Action::TOGGLE_NAMEPLATES: return "Nameplates"; case Action::TOGGLE_RAID_FRAMES: return "Raid Frames"; - case Action::TOGGLE_QUEST_LOG: return "Quest Log"; case Action::TOGGLE_ACHIEVEMENTS: return "Achievements"; case Action::ACTION_COUNT: break; } @@ -136,7 +156,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { else if (action == "toggle_world_map") actionIdx = static_cast(Action::TOGGLE_WORLD_MAP); else if (action == "toggle_nameplates") actionIdx = static_cast(Action::TOGGLE_NAMEPLATES); else if (action == "toggle_raid_frames") actionIdx = static_cast(Action::TOGGLE_RAID_FRAMES); - else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUEST_LOG); + else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUESTS); // legacy alias else if (action == "toggle_achievements") actionIdx = static_cast(Action::TOGGLE_ACHIEVEMENTS); if (actionIdx < 0) continue; @@ -175,9 +195,14 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { } } - if (key != ImGuiKey_None) { - bindings_[actionIdx] = key; + if (key == ImGuiKey_None) continue; + + // Reserve movement keys so they cannot be used as UI shortcuts. + if (isReservedMovementKey(key)) { + continue; } + + bindings_[actionIdx] = key; } file.close(); @@ -228,7 +253,6 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const { {Action::TOGGLE_WORLD_MAP, "toggle_world_map"}, {Action::TOGGLE_NAMEPLATES, "toggle_nameplates"}, {Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"}, - {Action::TOGGLE_QUEST_LOG, "toggle_quest_log"}, {Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"}, };