From 48d15fc653b5bcddee2b118cc4d2547e3fbcb0ed Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:41:01 -0700 Subject: [PATCH 01/71] fix: quest markers, level-up effect, and emote loop - renderer: construct QuestMarkerRenderer via make_unique (was never instantiated, causing getQuestMarkerRenderer() to always return null and all quest-marker updates to be silently skipped) - m2_renderer: add "levelup" to effectByName so LevelUp.m2 is treated as a spell effect (additive blend, no collision, particle-dominated) - renderer: auto-cancel non-looping emote animations when they reach end-of-sequence, transitioning player back to IDLE state --- src/rendering/m2_renderer.cpp | 3 ++- src/rendering/renderer.cpp | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index b079f50a..2c25cd77 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1148,7 +1148,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("lavasplash") != std::string::npos) || (lowerName.find("lavabubble") != std::string::npos) || (lowerName.find("lavasteam") != std::string::npos) || - (lowerName.find("wisps") != std::string::npos); + (lowerName.find("wisps") != std::string::npos) || + (lowerName.find("levelup") != std::string::npos); gpuModel.isSpellEffect = effectByName || (hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 71cb2a7c..56ce7c07 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -710,6 +710,8 @@ bool Renderer::initialize(core::Window* win) { levelUpEffect = std::make_unique(); + questMarkerRenderer = std::make_unique(); + LOG_INFO("Vulkan sub-renderers initialized (Phase 3)"); // LightingManager doesn't use GL — initialize for data-only use @@ -2222,6 +2224,14 @@ void Renderer::updateCharacterAnimation() { } else if (sitting) { cancelEmote(); newState = CharAnimState::SIT_DOWN; + } else if (!emoteLoop && characterRenderer && characterInstanceId > 0) { + // Auto-cancel non-looping emotes once animation completes + uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; + if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur) + && curDur > 0.1f && curT >= curDur - 0.05f) { + cancelEmote(); + newState = CharAnimState::IDLE; + } } break; From 564a2862824f16b567911eac861182227dff4d5a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:49:33 -0700 Subject: [PATCH 02/71] fix: stand-up-on-move and nameplate position tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera controller / sitting: - Any movement key (WASD/QE/Space) pressed while sitting now clears the sitting flag immediately, matching WoW's sit-to-stand-on-move behaviour - Added StandUpCallback: when the player stands up via local input the callback fires setStandState(0) → CMSG_STAND_STATE_CHANGE(STAND) so the server releases the sit lock and restores normal movement - Fixes character getting stuck in sit state after accidentally right-clicking a chair GO in Goldshire Inn (or similar) Nameplates: - Use getRenderPositionForGuid() (renderer visual position) as primary source for nameplate anchor, falling back to entity X/Y/Z only when no render instance exists yet; keeps health bars in sync with the rendered model instead of the parallel entity interpolator --- include/rendering/camera_controller.hpp | 6 ++++++ src/core/application.cpp | 5 +++++ src/rendering/camera_controller.cpp | 21 ++++++++++++++++----- src/ui/game_screen.cpp | 10 +++++++--- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 3337a755..7401ffdd 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -90,6 +90,11 @@ public: // Movement callback for sending opcodes to server using MovementCallback = std::function; void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } + + // Callback invoked when the player stands up via local input (space/X/movement key + // while server-sitting), so the caller can send CMSG_STAND_STATE_CHANGE(0). + using StandUpCallback = std::function; + void setStandUpCallback(StandUpCallback cb) { standUpCallback_ = std::move(cb); } void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } @@ -265,6 +270,7 @@ private: // Movement callback MovementCallback movementCallback; + StandUpCallback standUpCallback_; // Movement speeds bool useWoWSpeed = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 35ebca5f..91126c66 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -628,6 +628,11 @@ void Application::setState(AppState newState) { gameHandler->sendMovement(static_cast(opcode)); } }); + cc->setStandUpCallback([this]() { + if (gameHandler) { + gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND) + } + }); cc->setUseWoWSpeed(true); } if (gameHandler) { diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index cfa6120a..77908f3a 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -369,6 +369,7 @@ void CameraController::update(float deltaTime) { // Toggle sit/crouch with X key (edge-triggered) — only when UI doesn't want keyboard // Blocked while mounted + bool prevSitting = sitting; bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X); if (xDown && !xKeyWasDown && !mounted_) { sitting = !sitting; @@ -376,6 +377,21 @@ void CameraController::update(float deltaTime) { if (mounted_) sitting = false; xKeyWasDown = xDown; + // Stand up on any movement key or jump while sitting (WoW behaviour) + if (!uiWantsKeyboard && sitting && !movementSuppressed) { + bool anyMoveKey = + input.isKeyPressed(SDL_SCANCODE_W) || input.isKeyPressed(SDL_SCANCODE_S) || + input.isKeyPressed(SDL_SCANCODE_A) || input.isKeyPressed(SDL_SCANCODE_D) || + input.isKeyPressed(SDL_SCANCODE_Q) || input.isKeyPressed(SDL_SCANCODE_E) || + input.isKeyPressed(SDL_SCANCODE_SPACE); + if (anyMoveKey) sitting = false; + } + + // Notify server when the player stands up via local input + if (prevSitting && !sitting && standUpCallback_) { + standUpCallback_(); + } + // Update eye height based on crouch state (smooth transition) float targetEyeHeight = sitting ? CROUCH_EYE_HEIGHT : STAND_EYE_HEIGHT; float heightLerpSpeed = 10.0f * deltaTime; @@ -389,11 +405,6 @@ void CameraController::update(float deltaTime) { if (nowStrafeLeft) movement += right; if (nowStrafeRight) movement -= right; - // Stand up if jumping while crouched - if (!uiWantsKeyboard && sitting && input.isKeyPressed(SDL_SCANCODE_SPACE)) { - sitting = false; - } - // Third-person orbit camera mode if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3b3c7216..e41909d6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5108,9 +5108,13 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Player nameplates are always shown; NPC nameplates respect the V-key toggle if (!isPlayer && !showNameplates_) continue; - // Convert canonical WoW position → render space, raise to head height - glm::vec3 renderPos = core::coords::canonicalToRender( - glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + // Prefer the renderer's actual instance position so the nameplate tracks the + // rendered model exactly (avoids drift from the parallel entity interpolator). + glm::vec3 renderPos; + if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + } renderPos.z += 2.3f; // Cull distance: target or other players up to 40 units; NPC others up to 20 units From 28550dbc9979bcee83f2ed7501e903e01f6e7262 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:59:23 -0700 Subject: [PATCH 03/71] fix: reduce GO fallback hit radius to prevent invisible chair click-lock Fallback sphere for GameObjects without a loaded renderer instance was 2.5f, causing invisible/unloaded chairs in Goldshire Inn to be accidentally targeted during camera right-drag. This sent CMSG_GAMEOBJ_USE which set server stand state to SIT, trapping the player until a stand-up packet was sent. Reduce fallback radius to 1.2f and height offset to 1.0f so only deliberate close-range direct clicks register on unloaded GO geometry. --- src/ui/game_screen.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e41909d6..cbe89daf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1683,11 +1683,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { heightOffset = 0.3f; } } else if (t == game::ObjectType::GAMEOBJECT) { - // Do not hard-filter by GO type here. Some realms/content - // classify usable objects (including some chests) with types - // that look decorative in cache data. - hitRadius = 2.5f; - heightOffset = 1.2f; + // 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. + hitRadius = 1.2f; + heightOffset = 1.0f; } hitCenter = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); From 2b9f216dae78c721006fd6c6c02815ede11cbf49 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 20:29:55 -0700 Subject: [PATCH 04/71] fix: rest state detection and minimap north-up orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WotLK opcode 0x21E is aliased to both SMSG_SET_REST_START and SMSG_QUEST_FORCE_REMOVE. In WotLK, treat as SET_REST_START (non-zero = entering rest area, zero = leaving); Classic/TBC treat as quest removal. - PLAYER_BYTES_2 rest state byte: change from `& 0x01` to `!= 0` to also detect REST_TYPE_IN_CITY (value 2), not just REST_TYPE_IN_TAVERN (1). - Minimap arrow: server orientation (π/2=North) needed conversion to minimap arrow space (0=North). Subtract π/2 in both render paths so arrow points North when player faces North. --- src/game/game_handler.cpp | 35 ++++++++++++++++++++++++++++------- src/rendering/renderer.cpp | 12 ++++++++++-- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e8ecdf72..16a476d9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4175,12 +4175,31 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_QUEST_FORCE_REMOVE: { - // Minimal parse: uint32 questId + // This opcode is aliased to SMSG_SET_REST_START in the opcode table + // because both share opcode 0x21E in WotLK 3.3.5a. + // In WotLK: payload = uint32 areaId (entering rest) or 0 (leaving rest). + // In Classic/TBC: payload = uint32 questId (force-remove a quest). if (packet.getSize() - packet.getReadPos() < 4) { - LOG_WARNING("SMSG_QUEST_FORCE_REMOVE too short"); + LOG_WARNING("SMSG_QUEST_FORCE_REMOVE/SET_REST_START too short"); break; } - uint32_t questId = packet.readUInt32(); + uint32_t value = packet.readUInt32(); + + // WotLK uses this opcode as SMSG_SET_REST_START: non-zero = entering + // a rest area (inn/city), zero = leaving. Classic/TBC use it for quest removal. + if (!isClassicLikeExpansion() && !isActiveExpansion("tbc")) { + // WotLK: treat as SET_REST_START + bool nowResting = (value != 0); + if (nowResting != isResting_) { + isResting_ = nowResting; + addSystemChatMessage(isResting_ ? "You are now resting." + : "You are no longer resting."); + } + break; + } + + // Classic/TBC: treat as QUEST_FORCE_REMOVE (uint32 questId) + uint32_t questId = value; clearPendingQuestAccept(questId); pendingQuestQueryIds_.erase(questId); if (questId == 0) { @@ -8124,9 +8143,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte & 0x01) != 0; + isResting_ = (restStateByte != 0); } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce @@ -8435,9 +8455,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); - // Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte & 0x01) != 0; + isResting_ = (restStateByte != 0); } else if (key == ufPlayerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 56ce7c07..c06e5bed 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -4855,7 +4855,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + // Server orientation is in WoW space: π/2 = North, 0 = East. + // Minimap arrow expects render space: 0 = North, π/2 = East. + // Convert: minimap_angle = server_orientation - π/2 + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation + - static_cast(M_PI_2); hasMinimapPlayerOrientation = true; } minimap->render(cmd, *camera, minimapCenter, @@ -4983,7 +4987,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + // Server orientation is in WoW space: π/2 = North, 0 = East. + // Minimap arrow expects render space: 0 = North, π/2 = East. + // Convert: minimap_angle = server_orientation - π/2 + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation + - static_cast(M_PI_2); hasMinimapPlayerOrientation = true; } minimap->render(currentCmd, *camera, minimapCenter, From b87b6cee0f1edd818d4d793a3ea7bdaa07839bb7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 20:35:42 -0700 Subject: [PATCH 05/71] fix: add ParentAreaNum/MapID to AreaTable DBC layout for world map exploration AreaTable["ParentAreaNum"] was missing from all expansion DBC layouts, causing getUInt32(i, 0xFFFFFFFF) to return 0 for every area's parent. This made childBitsByParent keyed by 0 instead of the actual parent area IDs, so sub-zone explore bits were never associated with their parent zones on the world map. Result: newly explored sub-zones (e.g. Stormwind Keep) would not reveal their parent continent zones (Stormwind City) because the zone's exploreBits only included the direct zone bit, not sub-zone bits. Fix: add "MapID": 1, "ParentAreaNum": 2 to all expansion AreaTable layouts. --- Data/expansions/classic/dbc_layouts.json | 2 +- Data/expansions/tbc/dbc_layouts.json | 2 +- Data/expansions/turtle/dbc_layouts.json | 2 +- Data/expansions/wotlk/dbc_layouts.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 102074be..ca8c8a50 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -30,7 +30,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 5bca8165..fdc9e07d 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -30,7 +30,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index e31634e4..a2482e0d 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -30,7 +30,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 82252391..0d1667a1 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -31,7 +31,7 @@ "ReputationBase2": 12, "ReputationBase3": 13 }, "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, From 984decd664cbb820324ff025cdfd667e70112226 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 20:39:49 -0700 Subject: [PATCH 06/71] fix: pre-fetch quest reward item info when quest details packet arrives When SMSG_QUESTGIVER_QUEST_DETAILS is received (quest accept dialog), immediately query item info for all rewardChoiceItems and rewardItems. This ensures item names and icons are cached before the offer-reward dialog opens on turn-in, eliminating the "Item {id}" placeholder that appeared when the dialog opened before item queries completed. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 16a476d9..d18ec99c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14627,6 +14627,10 @@ void GameHandler::handleQuestDetails(network::Packet& packet) { } break; } + // Pre-fetch item info for all reward items so icons and names are ready + // by the time the offer-reward dialog opens (after the player turns in). + for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0); + for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0); questDetailsOpen = true; gossipWindowOpen = false; } From 6275a45ec0c9bf8ab0c9f7a9142edbc01b95bb45 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 20:53:21 -0700 Subject: [PATCH 07/71] feat: achievement name in toast, parse earned achievements, loot item tooltips - Parse SMSG_ALL_ACHIEVEMENT_DATA on login to populate earnedAchievements_ set - Pass achievement name through callback so toast shows name instead of ID - Add renderItemTooltip(ItemQueryResponseData) overload for loot/non-inventory contexts - Loot window now shows full item tooltip on hover (stats, sell price, bind type, etc.) --- include/game/game_handler.hpp | 6 +- include/ui/game_screen.hpp | 3 +- include/ui/inventory_screen.hpp | 1 + src/core/application.cpp | 4 +- src/game/game_handler.cpp | 37 +++++++- src/ui/game_screen.cpp | 22 ++++- src/ui/inventory_screen.cpp | 162 ++++++++++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2307f849..e48a585b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1134,8 +1134,9 @@ public: void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } // Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received - using AchievementEarnedCallback = std::function; + using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } + const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -2246,6 +2247,9 @@ private: std::unordered_map achievementNameCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); + // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_set earnedAchievements_; + void handleAllAchievementData(network::Packet& packet); // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) std::unordered_map areaNameCache_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5dc0aa6d..1ffc804f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -366,6 +366,7 @@ private: static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; float achievementToastTimer_ = 0.0f; uint32_t achievementToastId_ = 0; + std::string achievementToastName_; void renderAchievementToast(); // Zone discovery text ("Entering: ") @@ -377,7 +378,7 @@ private: public: void triggerDing(uint32_t newLevel); - void triggerAchievementToast(uint32_t achievementId); + void triggerAchievementToast(uint32_t achievementId, std::string name = {}); }; } // namespace ui diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index a0a19386..4f3e7970 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -96,6 +96,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); + void renderItemTooltip(const game::ItemQueryResponseData& info); private: // Character model preview diff --git a/src/core/application.cpp b/src/core/application.cpp index 91126c66..b265d45c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2335,9 +2335,9 @@ void Application::setupUICallbacks() { }); // Achievement earned callback — show toast banner - gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) { + gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { if (uiManager) { - uiManager->getGameScreen().triggerAchievementToast(achievementId); + uiManager->getGameScreen().triggerAchievementToast(achievementId, name); } }); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d18ec99c..ac47f913 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2695,7 +2695,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAchievementEarned(packet); break; case Opcode::SMSG_ALL_ACHIEVEMENT_DATA: - // Initial data burst on login — ignored for now (no achievement tracker UI). + handleAllAchievementData(packet); break; case Opcode::SMSG_ITEM_COOLDOWN: { // uint64 itemGuid + uint32 spellId + uint32 cooldownMs @@ -18711,8 +18711,9 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { } addSystemChatMessage(buf); + earnedAchievements_.insert(achievementId); if (achievementEarnedCallback_) { - achievementEarnedCallback_(achievementId); + achievementEarnedCallback_(achievementId, achName); } } else { // Another player in the zone earned an achievement @@ -18743,6 +18744,38 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { achName.empty() ? "" : " name=", achName); } +// --------------------------------------------------------------------------- +// SMSG_ALL_ACHIEVEMENT_DATA (WotLK 3.3.5a) +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 packedDate, ... } until 0xFFFFFFFF +// --------------------------------------------------------------------------- +void GameHandler::handleAllAchievementData(network::Packet& packet) { + loadAchievementNameCache(); + earnedAchievements_.clear(); + + // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t date =*/ packet.readUInt32(); + earnedAchievements_.insert(id); + } + + // Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unknown(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + packet.readUInt64(); // counter + packet.readUInt32(); // date + packet.readUInt32(); // unknown / flags + } + + LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements"); +} + // --------------------------------------------------------------------------- // Faction name cache (lazily loaded from Faction.dbc) // --------------------------------------------------------------------------- diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cbe89daf..282d1ecb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6499,6 +6499,13 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } bool hovered = ImGui::IsItemHovered(); + // Show item tooltip on hover + if (hovered && info && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered && !itemName.empty() && itemName[0] != 'I') { + ImGui::SetTooltip("%s", itemName.c_str()); + } + ImDrawList* drawList = ImGui::GetWindowDrawList(); // Draw hover highlight @@ -10799,8 +10806,9 @@ void GameScreen::renderDingEffect() { IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); } -void GameScreen::triggerAchievementToast(uint32_t achievementId) { +void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { achievementToastId_ = achievementId; + achievementToastName_ = std::move(name); achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; // Play a UI sound if available @@ -10859,9 +10867,15 @@ void GameScreen::renderAchievementToast() { draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), IM_COL32(255, 215, 0, (int)(alpha * 255)), title); - // Achievement ID line (until we have Achievement.dbc name lookup) - char idBuf[64]; - std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + // Achievement name (falls back to ID if name not available) + char idBuf[256]; + const char* achText = achievementToastName_.empty() + ? nullptr : achievementToastName_.c_str(); + if (achText) { + std::snprintf(idBuf, sizeof(idBuf), "%s", achText); + } else { + std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + } float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; float idX = toastX + (TOAST_W - idW) * 0.5f; draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8567c3ce..b9cf88ef 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2027,5 +2027,167 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::EndTooltip(); } +// --------------------------------------------------------------------------- +// Tooltip overload for ItemQueryResponseData (used by loot window, etc.) +// --------------------------------------------------------------------------- +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) { + ImGui::BeginTooltip(); + + ImVec4 qColor = getQualityColor(static_cast(info.quality)); + ImGui::TextColored(qColor, "%s", info.name.c_str()); + if (info.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel); + } + + // Binding type + switch (info.bindType) { + case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; + case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break; + case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break; + case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break; + default: break; + } + + // Slot / subclass + if (info.inventoryType > 0) { + const char* slotName = ""; + switch (info.inventoryType) { + case 1: slotName = "Head"; break; + case 2: slotName = "Neck"; break; + case 3: slotName = "Shoulder"; break; + case 4: slotName = "Shirt"; break; + case 5: slotName = "Chest"; break; + case 6: slotName = "Waist"; break; + case 7: slotName = "Legs"; break; + case 8: slotName = "Feet"; break; + case 9: slotName = "Wrist"; break; + case 10: slotName = "Hands"; break; + case 11: slotName = "Finger"; break; + case 12: slotName = "Trinket"; break; + case 13: slotName = "One-Hand"; break; + case 14: slotName = "Shield"; break; + case 15: slotName = "Ranged"; break; + case 16: slotName = "Back"; break; + case 17: slotName = "Two-Hand"; break; + case 18: slotName = "Bag"; break; + case 19: slotName = "Tabard"; break; + case 20: slotName = "Robe"; break; + case 21: slotName = "Main Hand"; break; + case 22: slotName = "Off Hand"; break; + case 23: slotName = "Held In Off-hand"; break; + case 25: slotName = "Thrown"; break; + case 26: slotName = "Ranged"; break; + default: break; + } + if (slotName[0]) { + if (!info.subclassName.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info.subclassName.c_str()); + else + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); + } + } + + // Weapon stats + auto isWeaponInvType = [](uint32_t t) { + return t == 13 || t == 15 || t == 17 || t == 21 || t == 25 || t == 26; + }; + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + if (isWeaponInvType(info.inventoryType) && info.damageMax > 0.0f && info.delayMs > 0) { + float speed = static_cast(info.delayMs) / 1000.0f; + float dps = ((info.damageMin + info.damageMax) * 0.5f) / speed; + ImGui::Text("%.0f - %.0f Damage", info.damageMin, info.damageMax); + ImGui::SameLine(160.0f); + ImGui::TextDisabled("Speed %.2f", speed); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps); + } + + if (info.armor > 0) ImGui::Text("%d Armor", info.armor); + + auto appendBonus = [](std::string& out, int32_t val, const char* name) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " " + name; + }; + std::string bonusLine; + appendBonus(bonusLine, info.strength, "Str"); + appendBonus(bonusLine, info.agility, "Agi"); + appendBonus(bonusLine, info.stamina, "Sta"); + appendBonus(bonusLine, info.intellect, "Int"); + appendBonus(bonusLine, info.spirit, "Spi"); + if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); + + // Extra stats + for (const auto& es : info.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 16: case 17: case 18: case 31: statName = "Hit Rating"; break; + case 19: case 20: case 21: case 32: statName = "Crit Rating"; break; + case 28: case 29: case 30: case 36: statName = "Haste Rating"; break; + case 35: statName = "Resilience"; break; + case 37: statName = "Expertise Rating"; break; + case 38: statName = "Attack Power"; break; + case 39: statName = "Ranged Attack Power"; break; + case 41: statName = "Healing Power"; break; + case 42: statName = "Spell Damage"; break; + case 43: statName = "Mana per 5 sec"; break; + case 44: statName = "Armor Penetration"; break; + case 45: statName = "Spell Power"; break; + case 46: statName = "Health per 5 sec"; break; + case 47: statName = "Spell Penetration"; break; + case 48: statName = "Block Value"; break; + default: statName = nullptr; break; + } + char buf[64]; + if (statName) + std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName); + else + std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType); + ImGui::TextColored(green, "%s", buf); + } + + if (info.requiredLevel > 1) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", info.requiredLevel); + } + + // Spell effects + for (const auto& sp : info.spells) { + if (sp.spellId == 0) continue; + const char* trigger = nullptr; + switch (sp.spellTrigger) { + case 0: trigger = "Use"; break; + case 1: trigger = "Equip"; break; + case 2: trigger = "Chance on Hit"; break; + default: break; + } + if (!trigger) continue; + if (gameHandler_) { + const std::string& spName = gameHandler_->getSpellName(sp.spellId); + if (!spName.empty()) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str()); + else + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); + } + } + + if (info.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + if (!info.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", info.description.c_str()); + } + + if (info.sellPrice > 0) { + uint32_t g = info.sellPrice / 10000; + uint32_t s = (info.sellPrice / 100) % 100; + uint32_t c = info.sellPrice % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + } + + ImGui::EndTooltip(); +} + } // namespace ui } // namespace wowee From 4986308581c93924707dd1005aca4aeb9ddc7d4a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 20:59:02 -0700 Subject: [PATCH 08/71] feat: rich item tooltips in vendor and loot-roll windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vendor window: replace manual stat-only tooltip with full renderItemTooltip (now shows bind type, slot, weapon stats, armor, extra stats, spell effects, flavor text, and sell price — consistent with inventory) - Loot-roll popup: add item icon and hover tooltip via renderItemTooltip - Loot-roll: pre-fetch item info via queryItemInfo when roll prompt appears --- src/game/game_handler.cpp | 2 ++ src/ui/game_screen.cpp | 31 +++++++++++++------------------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ac47f913..0895685b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18567,6 +18567,8 @@ void GameHandler::handleLootRoll(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + // Ensure item info is in cache; query if not + queryItemInfo(itemId, 0); // Look up item name from cache auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 282d1ecb..e70bf2ac 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5775,7 +5775,19 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; ImGui::Text("An item is up for rolls:"); + + // Show item icon if available + const auto* rollInfo = gameHandler.getItemInfo(roll.itemId); + uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0; + VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE; + if (rollIcon) { + ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); + ImGui::SameLine(); + } ImGui::TextColored(col, "[%s]", roll.itemName.c_str()); + if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { + inventoryScreen.renderItemTooltip(*rollInfo); + } ImGui::Spacing(); if (ImGui::Button("Need", ImVec2(80, 30))) { @@ -7229,24 +7241,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); // Tooltip with stats on hover if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); - if (info->damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", info->damageMin, info->damageMax); - if (info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("Speed %.2f", speed); - ImGui::Text("%.1f damage per second", dps); - } - } - if (info->armor > 0) ImGui::Text("Armor: %d", info->armor); - if (info->stamina > 0) ImGui::Text("+%d Stamina", info->stamina); - if (info->strength > 0) ImGui::Text("+%d Strength", info->strength); - if (info->agility > 0) ImGui::Text("+%d Agility", info->agility); - if (info->intellect > 0) ImGui::Text("+%d Intellect", info->intellect); - if (info->spirit > 0) ImGui::Text("+%d Spirit", info->spirit); - ImGui::EndTooltip(); + inventoryScreen.renderItemTooltip(*info); } } else { ImGui::Text("Item %u", item.itemId); From a7a559cdcc6f9e95f42d42374e3db207e1ea680c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:12:28 -0700 Subject: [PATCH 09/71] feat: battleground invitation popup with countdown timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the text-only "/join to enter" message with an interactive popup that shows the BG name, a live countdown progress bar, and Enter/Leave Queue buttons. - Parse STATUS_WAIT_JOIN timeout from SMSG_BATTLEFIELD_STATUS - Store inviteReceivedTime (steady_clock) on the queue slot - BgQueueSlot moved to public section so UI can read invite details - Add declineBattlefield() that sends CMSG_BATTLEFIELD_PORT(action=0) - acceptBattlefield() optimistically sets statusId=3 to dismiss popup - renderBgInvitePopup: colored countdown bar (green→yellow→red), named BG (Alterac Valley, Warsong Gulch, etc.), auto-dismisses on expiry --- include/game/game_handler.hpp | 18 ++++--- include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 79 ++++++++++++++++++++++++++++- src/ui/game_screen.cpp | 94 +++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e48a585b..235def82 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -340,9 +340,21 @@ public: // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); + // Battleground queue slot (public so UI can read invite details) + 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 + uint32_t inviteTimeout = 80; + std::chrono::steady_clock::time_point inviteReceivedTime{}; + }; + // Battleground bool hasPendingBgInvite() const; void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + const std::array& getBgQueues() const { return bgQueues_; } // Logout commands void requestLogout(); @@ -1970,12 +1982,6 @@ private: std::unordered_set petAutocastSpells_; // spells with autocast on // ---- 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 diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1ffc804f..d5938e7b 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -248,6 +248,7 @@ private: void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler); + void renderBgInvitePopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0895685b..360e2312 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11832,12 +11832,41 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; } + // Parse status-specific fields + uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) + if (statusId == 1) { + // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t avgWait =*/ packet.readUInt32(); + /*uint32_t inQueue =*/ packet.readUInt32(); + } + } else if (statusId == 2) { + // STATUS_WAIT_JOIN: timeout(4) + mapId(4) + if (packet.getSize() - packet.getReadPos() >= 4) { + inviteTimeout = packet.readUInt32(); + } + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t mapId =*/ packet.readUInt32(); + } + } else if (statusId == 3) { + // STATUS_IN_PROGRESS: mapId(4) + timeSinceStart(4) + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t mapId =*/ packet.readUInt32(); + /*uint32_t elapsed =*/ packet.readUInt32(); + } + } + // Store queue state if (queueSlot < bgQueues_.size()) { + bool wasInvite = (bgQueues_[queueSlot].statusId == 2); bgQueues_[queueSlot].queueSlot = queueSlot; bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; + if (statusId == 2 && !wasInvite) { + bgQueues_[queueSlot].inviteTimeout = inviteTimeout; + bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now(); + } } switch (statusId) { @@ -11849,8 +11878,10 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName); break; case 2: // STATUS_WAIT_JOIN - addSystemChatMessage(bgName + " is ready! Type /join to enter."); - LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName); + // Popup shown by the UI; add chat notification too. + addSystemChatMessage(bgName + " is ready!"); + LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName, + " timeout=", inviteTimeout, "s"); break; case 3: // STATUS_IN_PROGRESS addSystemChatMessage("Entered " + bgName + "."); @@ -11865,6 +11896,44 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { } } +void GameHandler::declineBattlefield(uint32_t queueSlot) { + if (state != WorldState::IN_WORLD) return; + if (!socket) return; + + 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 with action=0 (decline) + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); + pkt.writeUInt8(slot->arenaType); + pkt.writeUInt8(0x00); + pkt.writeUInt32(slot->bgTypeId); + pkt.writeUInt16(0x0000); + pkt.writeUInt8(0); // 0 = decline + + socket->send(pkt); + + // Clear queue slot + uint32_t clearSlot = slot->queueSlot; + if (clearSlot < bgQueues_.size()) { + bgQueues_[clearSlot] = BgQueueSlot{}; + } + + addSystemChatMessage("Battleground invitation declined."); + LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: decline"); +} + bool GameHandler::hasPendingBgInvite() const { for (const auto& slot : bgQueues_) { if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN @@ -11901,6 +11970,12 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { socket->send(pkt); + // Optimistically clear the invite so the popup disappears immediately. + uint32_t clearSlot = slot->queueSlot; + if (clearSlot < bgQueues_.size()) { + bgQueues_[clearSlot].statusId = 3; // STATUS_IN_PROGRESS (server will confirm) + } + addSystemChatMessage("Accepting battleground invitation..."); LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e70bf2ac..1e996ce1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -414,6 +414,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderItemTextWindow(gameHandler); renderGuildInvitePopup(gameHandler); renderReadyCheckPopup(gameHandler); + renderBgInvitePopup(gameHandler); renderGuildRoster(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); @@ -5867,6 +5868,99 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingBgInvite()) return; + + const auto& queues = gameHandler.getBgQueues(); + // Find the first WAIT_JOIN slot + const game::GameHandler::BgQueueSlot* slot = nullptr; + for (const auto& s : queues) { + if (s.statusId == 2) { slot = &s; break; } + } + if (!slot) return; + + // Compute time remaining + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - slot->inviteReceivedTime).count(); + double remaining = static_cast(slot->inviteTimeout) - elapsed; + + // If invite has expired, clear it silently (server will handle the queue) + if (remaining <= 0.0) { + gameHandler.declineBattlefield(slot->queueSlot); + return; + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 70), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags popupFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) { + // BG name + std::string bgName; + if (slot->arenaType > 0) { + bgName = std::to_string(slot->arenaType) + "v" + std::to_string(slot->arenaType) + " Arena"; + } else { + switch (slot->bgTypeId) { + case 1: bgName = "Alterac Valley"; break; + case 2: bgName = "Warsong Gulch"; break; + case 3: bgName = "Arathi Basin"; break; + case 7: bgName = "Eye of the Storm"; break; + case 9: bgName = "Strand of the Ancients"; break; + case 11: bgName = "Isle of Conquest"; break; + default: bgName = "Battleground"; break; + } + } + + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str()); + ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast(remaining)); + ImGui::Spacing(); + + // Countdown progress bar + float frac = static_cast(remaining / static_cast(slot->inviteTimeout)); + frac = std::clamp(frac, 0.0f, 1.0f); + ImVec4 barColor = frac > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) + : frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f) + : ImVec4(0.9f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + char countdownLabel[32]; + snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast(remaining)); + ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel); + ImGui::PopStyleColor(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) { + gameHandler.acceptBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Leave Queue", ImVec2(175, 30))) { + gameHandler.declineBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // O key toggle (WoW default Social/Guild keybind) if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { From 8ab83987f192f2994fba1f330cd26abfecbab726 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:15:24 -0700 Subject: [PATCH 10/71] feat: focus target frame with health/power bars and cast bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a compact focus target frame on the right side of the screen when the player has a focus target set via /focus. - Shows [Focus] label, name (colored by hostility/level diff), level - HP bar with green→yellow→red coloring; power bar with type colors - Cast bar showing spell name and remaining time when focus is casting - Clicking the frame targets the focus entity - Clears automatically when focus is lost (/clearfocus) --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index d5938e7b..f6af0566 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -189,6 +189,7 @@ private: * Render target frame */ void renderTargetFrame(game::GameHandler& gameHandler); + void renderFocusFrame(game::GameHandler& gameHandler); /** * Render pet frame (below player frame when player has an active pet) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1e996ce1..e626fa28 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -380,6 +380,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderTargetFrame(gameHandler); } + // Focus target frame (only when we have a focus) + if (gameHandler.hasFocus()) { + renderFocusFrame(gameHandler); + } + // Render windows if (showPlayerInfo) { renderPlayerInfo(gameHandler); @@ -2480,6 +2485,134 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } +void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { + auto focus = gameHandler.getFocus(); + if (!focus) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Position: right side of screen, mirroring the target frame on the opposite side + float frameW = 200.0f; + float frameX = screenW - frameW - 10.0f; + + ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + // Determine color based on relation (same logic as target frame) + ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); + if (focus->getType() == game::ObjectType::PLAYER) { + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (focus->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(focus); + if (u->getHealth() == 0 && u->getMaxHealth() > 0) { + focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (u->isHostile()) { + uint32_t playerLv = gameHandler.getPlayerLevel(); + uint32_t mobLv = u->getLevel(); + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) + focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); + else if (diff >= 10) + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); + else if (diff >= 5) + focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); + else if (diff >= -2) + focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); + else + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else { + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus + + if (ImGui::Begin("##FocusFrame", nullptr, flags)) { + // "Focus" label + ImGui::TextDisabled("[Focus]"); + ImGui::SameLine(); + + std::string focusName = getEntityName(focus); + ImGui::TextColored(focusColor, "%s", focusName.c_str()); + + if (focus->getType() == game::ObjectType::UNIT || + focus->getType() == game::ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(focus); + + // Level + health on same row + ImGui::SameLine(); + ImGui::TextDisabled("Lv %u", unit->getLevel()); + + uint32_t hp = unit->getHealth(); + uint32_t maxHp = unit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : + ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay); + ImGui::PopStyleColor(); + + // Power bar + uint8_t pType = unit->getPowerType(); + uint32_t pwr = unit->getPower(); + uint32_t maxPwr = unit->getMaxPower(); + if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100; + if (maxPwr > 0) { + float mpPct = static_cast(pwr) / static_cast(maxPwr); + ImVec4 pwrColor; + switch (pType) { + case 0: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + case 1: pwrColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; + case 3: pwrColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; + case 6: pwrColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; + default: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor); + ImGui::ProgressBar(mpPct, ImVec2(-1, 10), ""); + ImGui::PopStyleColor(); + } + } + + // Focus cast bar + const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid()); + if (focusCast) { + float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f; + float rem = focusCast->timeRemaining; + float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); + const std::string& spName = gameHandler.getSpellName(focusCast->spellId); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + char castBuf[64]; + if (!spName.empty()) + snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); + else + snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + ImGui::PopStyleColor(); + } + } + + // Clicking the focus frame targets it + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { + gameHandler.setTarget(focus->getGuid()); + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); From 4a445081d8705e048ae11a1115ae378ca417afb8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:19:42 -0700 Subject: [PATCH 11/71] feat: latency indicator, BG queue status, and ToT improvements - Add latency indicator below minimap (color-coded: green/yellow/orange/red) using the lastLatency value measured via CMSG_PING/SMSG_PONG - Add BG queue status indicator below minimap when in WAIT_QUEUE (abbreviated name: AV/WSG/AB/EotS etc.) - Target-of-Target frame: add level display and click-to-target support - Expose getLatencyMs() accessor on GameHandler --- include/game/game_handler.hpp | 3 ++ src/ui/game_screen.cpp | 80 ++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 235def82..c0ec24c6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -356,6 +356,9 @@ public: void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); const std::array& getBgQueues() const { return bgQueues_; } + // Network latency (milliseconds, updated each PONG response) + uint32_t getLatencyMs() const { return lastLatency; } + // Logout commands void requestLogout(); void cancelLogout(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e626fa28..73d4e997 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2464,6 +2464,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (totEntity->getType() == game::ObjectType::UNIT || totEntity->getType() == game::ObjectType::PLAYER) { auto totUnit = std::static_pointer_cast(totEntity); + if (totUnit->getLevel() > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv%u", totUnit->getLevel()); + } uint32_t hp = totUnit->getHealth(); uint32_t maxHp = totUnit->getMaxHealth(); if (maxHp > 0) { @@ -2476,6 +2480,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } } + // Click to target the target-of-target + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { + gameHandler.setTarget(totGuid); + } } ImGui::End(); ImGui::PopStyleColor(2); @@ -9368,21 +9376,73 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); - // "New Mail" indicator below the minimap + // Indicators below the minimap (stacked: new mail, then BG queue, then latency) + float indicatorX = centerX - mapRadius; + float nextIndicatorY = centerY + mapRadius + 4.0f; + const float indicatorW = mapRadius * 2.0f; + constexpr float kIndicatorH = 22.0f; + ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; + + // "New Mail" indicator if (gameHandler.hasNewMail()) { - float indicatorX = centerX - mapRadius; - float indicatorY = centerY + mapRadius + 4.0f; - ImGui::SetNextWindowPos(ImVec2(indicatorX, indicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(mapRadius * 2.0f, 22), ImGuiCond_Always); - ImGuiWindowFlags mailFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; - if (ImGui::Begin("##NewMailIndicator", nullptr, mailFlags)) { - // Pulsing effect + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!"); } ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // BG queue status indicator (when in queue but not yet invited) + for (const auto& slot : gameHandler.getBgQueues()) { + if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only + + std::string bgName; + if (slot.arenaType > 0) { + bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena"; + } else { + switch (slot.bgTypeId) { + case 1: bgName = "AV"; break; + case 2: bgName = "WSG"; break; + case 3: bgName = "AB"; break; + case 7: bgName = "EotS"; break; + case 9: bgName = "SotA"; break; + case 11: bgName = "IoC"; break; + default: bgName = "BG"; break; + } + } + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "In Queue: %s", bgName.c_str()); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + break; // Show at most one queue slot indicator + } + + // Latency indicator (shown when in world and last latency is known) + uint32_t latMs = gameHandler.getLatencyMs(); + if (latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { + ImVec4 latColor; + if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.8f); // Green < 100ms + else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.8f); // Yellow < 250ms + else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.8f); // Orange < 500ms + else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.8f); // Red >= 500ms + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(latColor, "%u ms", latMs); + } + ImGui::End(); } } From bc18fb7c3ec8eec523cc99eb1fcbb04bfa1ef484 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:24:40 -0700 Subject: [PATCH 12/71] feat: leader crown and LFG role indicators in party/raid frames - Party frames: gold star prefix and gold name color for group leader; LFG role badges [T]/[H]/[D] shown inline after member name - Raid frames: leader name rendered in gold with a corner star marker; role letter (T/H/D) drawn in bottom-right corner of each compact cell; uses partyData.leaderGuid already present in the function scope - Minimap party dots already use gold for leader (unchanged) --- src/ui/game_screen.cpp | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 73d4e997..ca885795 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5459,13 +5459,27 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { bool isDead = (m.onlineStatus & 0x0020) != 0; bool isGhost = (m.onlineStatus & 0x0010) != 0; - // Name text (truncated) + // Name text (truncated); leader name is gold char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); - ImU32 nameCol = (!isOnline || isDead || isGhost) - ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); + bool isMemberLeader = (m.guid == partyData.leaderGuid); + ImU32 nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : + (!isOnline || isDead || isGhost) + ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); + // Leader crown star in top-right of cell + if (isMemberLeader) + draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); + + // LFG role badge in bottom-right corner of cell + if (m.roles & 0x02) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); + else if (m.roles & 0x04) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H"); + else if (m.roles & 0x08) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D"); + // Health bar uint32_t hp = m.hasPartyStats ? m.curHealth : 0; uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; @@ -5543,11 +5557,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f)); if (ImGui::Begin("##PartyFrames", nullptr, flags)) { + const uint64_t leaderGuid = partyData.leaderGuid; for (const auto& member : partyData.members) { ImGui::PushID(static_cast(member.guid)); - // Name with level and status info - std::string label = member.name; + bool isLeader = (member.guid == leaderGuid); + + // Name with level and status info — leader gets a gold star prefix + std::string label = (isLeader ? "* " : " ") + member.name; if (member.hasPartyStats && member.level > 0) { label += " [" + std::to_string(member.level) + "]"; } @@ -5559,10 +5576,20 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { else if (isDead || isGhost) label += " (dead)"; } - // Clickable name to target + // Clickable name to target; leader name is gold + if (isLeader) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + if (isLeader) ImGui::PopStyleColor(); + + // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set + if (member.roles != 0) { + ImGui::SameLine(); + if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]"); + if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); } + if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } + } // Health bar: prefer party stats, fall back to entity uint32_t hp = 0, maxHp = 0; From 5fbeb7938c113c630835dd3dd98795a4c7d3f440 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:27:26 -0700 Subject: [PATCH 13/71] feat: right-click context menu for party member frames Right-clicking a party member name in the 5-man party frame opens a context menu with: Target, Set Focus, Whisper, Trade, Inspect. - Whisper switches chat type to WHISPER and pre-fills the target name - Trade calls GameHandler::initiateTrade(guid) - Inspect sets target then calls GameHandler::inspectTarget() - Uses BeginPopupContextItem tied to the Selectable widget --- src/ui/game_screen.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ca885795..dc7f21f7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5648,6 +5648,32 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Right-click context menu for party member actions + if (ImGui::BeginPopupContextItem("PartyMemberCtx")) { + ImGui::TextDisabled("%s", member.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) { + gameHandler.setTarget(member.guid); + } + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(member.guid); + } + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; // WHISPER + strncpy(whisperTargetBuffer, member.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(member.guid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(member.guid); + gameHandler.inspectTarget(); + } + ImGui::EndPopup(); + } + ImGui::Separator(); ImGui::PopID(); } From a7474b96cfab290ca60f765180bc49157b6bf1e9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:29:47 -0700 Subject: [PATCH 14/71] fix: move buff bar to top-right, split buffs/debuffs, raise aura cap to 40 - Relocates buff bar from top-left Y=145 (overlapping party frames) to top-right (screenW - barW - 10, 140) where it doesn't conflict with party/raid frames anchored on the left side - Increases max shown auras from 16 to 40 (WotLK supports 48 slots) - Two-pass rendering: buffs shown first, debuffs below with a spacing gap between them; both still use green/red borders for visual distinction - Widens row to 12 icons for better horizontal use of screen space --- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dc7f21f7..081c09bf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6602,12 +6602,14 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { auto* assetMgr = core::Application::getInstance().getAssetManager(); - // Position below the player frame in top-left + // Position in top-right to avoid overlapping the party frame on the left constexpr float ICON_SIZE = 32.0f; - constexpr int ICONS_PER_ROW = 8; + constexpr int ICONS_PER_ROW = 12; float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f; - // Dock under player frame in top-left (player frame is at 10, 30 with ~110px height) - ImGui::SetNextWindowPos(ImVec2(10.0f, 145.0f), ImGuiCond_Always); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + // Anchor to top-right, below minimap area (~140px from top) + ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 140.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | @@ -6618,16 +6620,22 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); if (ImGui::Begin("##BuffBar", nullptr, flags)) { - int shown = 0; - for (size_t i = 0; i < auras.size() && shown < 16; ++i) { + // Separate buffs and debuffs; show buffs first, then debuffs with a visual gap + // Render one pass for buffs, one for debuffs + for (int pass = 0; pass < 2; ++pass) { + bool wantBuff = (pass == 0); + int shown = 0; + for (size_t i = 0; i < auras.size() && shown < 40; ++i) { const auto& aura = auras[i]; if (aura.isEmpty()) continue; + bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag + if (isBuff != wantBuff) continue; // only render matching pass + if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); - ImGui::PushID(static_cast(i)); + ImGui::PushID(static_cast(i) + (pass * 256)); - bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); // Try to get spell icon @@ -6722,10 +6730,14 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PopID(); shown++; - } + } // end aura loop + // Add visual gap between buffs and debuffs + if (pass == 0 && shown > 0) ImGui::Spacing(); + } // end pass loop + // Dismiss Pet button if (gameHandler.hasPet()) { - if (shown > 0) ImGui::Spacing(); + ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f)); if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) { From b4469b1577a85580ad770caae210abbc72b71d7b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:32:58 -0700 Subject: [PATCH 15/71] fix: correct buff bar and quest tracker vertical positions - Buff bar was at Y=140 which overlaps the minimap (Y=10 to Y=210); moved to Y=215 (just below minimap bottom edge) with 8 icons per row - Quest tracker moved from Y=200 (inside minimap area) to Y=320 to leave space for up to 3 rows of buffs between minimap and tracker - Both are right-anchored and no longer conflict with the minimap or each other in typical usage (up to ~20 active auras) --- src/ui/game_screen.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 081c09bf..abc95a1a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5009,7 +5009,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { if (toShow.empty()) return; float x = screenW - TRACKER_W - RIGHT_MARGIN; - float y = 200.0f; // below minimap area + float y = 320.0f; // below minimap (210) + buff bar space (up to 3 rows ≈ 114px) ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); @@ -6602,14 +6602,15 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { auto* assetMgr = core::Application::getInstance().getAssetManager(); - // Position in top-right to avoid overlapping the party frame on the left + // Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210) + // Anchored to the right side to stay away from party frames on the left constexpr float ICON_SIZE = 32.0f; - constexpr int ICONS_PER_ROW = 12; + constexpr int ICONS_PER_ROW = 8; float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f; ImVec2 displaySize = ImGui::GetIO().DisplaySize; float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - // Anchor to top-right, below minimap area (~140px from top) - ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 140.0f), ImGuiCond_Always); + // Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210) + ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | From 7c8bda0907631fe7a08e48a421defa4e472f187e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:34:54 -0700 Subject: [PATCH 16/71] fix: suppress wchar_t '>= 0' tautological comparison warning on arm64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows arm64, wchar_t is unsigned so 'wc >= 0' is always true and GCC/Clang emit -Wtype-limits. Drop the redundant lower bound check — only the upper bound 'wc <= 0x7f' is needed. --- src/rendering/amd_fsr3_runtime.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rendering/amd_fsr3_runtime.cpp b/src/rendering/amd_fsr3_runtime.cpp index e7606fb6..26fc5ce1 100644 --- a/src/rendering/amd_fsr3_runtime.cpp +++ b/src/rendering/amd_fsr3_runtime.cpp @@ -64,7 +64,7 @@ std::string narrowWString(const wchar_t* msg) { std::string out; for (const wchar_t* p = msg; *p; ++p) { const wchar_t wc = *p; - if (wc >= 0 && wc <= 0x7f) { + if (wc <= 0x7f) { out.push_back(static_cast(wc)); } else { out.push_back('?'); From 8f2974b17c20122994fad52824149cf971f4eab1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 21:40:21 -0700 Subject: [PATCH 17/71] feat: add standalone LFG group-found popup (renderLfgProposalPopup) Shows a centered modal when LfgState::Proposal is active regardless of whether the Dungeon Finder window is open, matching WoW behaviour where the accept/decline prompt always appears over the game world. Mirrors the BG invite popup pattern; buttons call lfgAcceptProposal(). --- include/ui/game_screen.hpp | 1 + src/ui/game_screen.cpp | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f6af0566..8bd19235 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -250,6 +250,7 @@ private: void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler); void renderBgInvitePopup(game::GameHandler& gameHandler); + void renderLfgProposalPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index abc95a1a..94b4deaa 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -420,6 +420,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGuildInvitePopup(gameHandler); renderReadyCheckPopup(gameHandler); renderBgInvitePopup(gameHandler); + renderLfgProposalPopup(gameHandler); renderGuildRoster(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); @@ -6155,6 +6156,55 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { ImGui::PopStyleColor(3); } +void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::Proposal) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Dungeon Finder", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "A group has been found!"); + ImGui::Spacing(); + ImGui::TextWrapped("Please accept or decline to join the dungeon."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // O key toggle (WoW default Social/Guild keybind) if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { From 19eb7a1fb793dfa918a5a7cb32389ba97f4a7529 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 22:26:50 -0700 Subject: [PATCH 18/71] fix: animation stutter, resolution crash, memory cap, spell tooltip hints, GO collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Animation stutter: skip playAnimation(Run) for the local player in the server movement callback — the player renderer state machine already manages it; resetting animTime on every movement packet caused visible stutter - Resolution crash: reorder swapchain recreation so old swapchain is only destroyed after confirming the new build succeeded; add null-swapchain guard in beginFrame to survive the retry window - Memory cap: reduce cache budget from 80% uncapped to 50% hard-capped at 16 GB to prevent excessive RAM use on high-memory systems - Spell tooltip: suppress "Drag to action bar / Double-click to cast" hints when the tooltip is shown from the action bar (showUsageHints=false) - M2 collision: add watermelon/melon/squash/gourd to foliage (no-collision); exclude chair/bench/stool/seat/throne from smallSolidProp so invisible chair bounding boxes no longer trap the player --- include/ui/spellbook_screen.hpp | 4 ++-- src/core/application.cpp | 30 ++++++++++++++++++++---------- src/core/memory_monitor.cpp | 12 ++++++------ src/pipeline/asset_manager.cpp | 2 +- src/rendering/m2_renderer.cpp | 14 +++++++++++++- src/rendering/vk_context.cpp | 20 ++++++++++++++------ src/ui/spellbook_screen.cpp | 8 ++++---- 7 files changed, 60 insertions(+), 30 deletions(-) diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 77f1c2d6..470cb233 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -94,8 +94,8 @@ private: VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); const SpellInfo* getSpellInfo(uint32_t spellId) const; - // Tooltip rendering helper - void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler); + // Tooltip rendering helper (showUsageHints=false when called from action bar) + void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints = true); }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index b265d45c..8a7b51e4 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2553,13 +2553,19 @@ void Application::setupUICallbacks() { // Don't override Death animation (1). The per-frame sync loop will return to // Stand when movement stops. if (durationMs > 0) { - uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; - auto* cr = renderer->getCharacterRenderer(); - bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); - if (!gotState || curAnimId != 1 /*Death*/) { - cr->playAnimation(instanceId, 5u, /*loop=*/true); + // Player animation is managed by the local renderer state machine — + // don't reset it here or every server movement packet restarts the + // run cycle from frame 0, causing visible stutter. + if (!isPlayer) { + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + auto* cr = renderer->getCharacterRenderer(); + bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); + // Only start Run if not already running and not in Death animation. + if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) { + cr->playAnimation(instanceId, 5u, /*loop=*/true); + } + creatureWasMoving_[guid] = true; } - if (!isPlayer) creatureWasMoving_[guid] = true; } } }); @@ -8701,17 +8707,21 @@ void Application::updateQuestMarkers() { int markerType = -1; // -1 = no marker using game::QuestGiverStatus; + float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests) switch (status) { case QuestGiverStatus::AVAILABLE: + markerType = 0; // Yellow ! + break; case QuestGiverStatus::AVAILABLE_LOW: - markerType = 0; // Available (yellow !) + markerType = 0; // Grey ! (same texture, desaturated in shader) + markerGrayscale = 1.0f; break; case QuestGiverStatus::REWARD: case QuestGiverStatus::REWARD_REP: - markerType = 1; // Turn-in (yellow ?) + markerType = 1; // Yellow ? break; case QuestGiverStatus::INCOMPLETE: - markerType = 2; // Incomplete (grey ?) + markerType = 2; // Grey ? break; default: break; @@ -8745,7 +8755,7 @@ void Application::updateQuestMarkers() { } // Set the marker (renderer will handle positioning, bob, glow, etc.) - questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight); + questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale); markersAdded++; } diff --git a/src/core/memory_monitor.cpp b/src/core/memory_monitor.cpp index 913240fd..080a1ef6 100644 --- a/src/core/memory_monitor.cpp +++ b/src/core/memory_monitor.cpp @@ -109,16 +109,16 @@ size_t MemoryMonitor::getAvailableRAM() const { size_t MemoryMonitor::getRecommendedCacheBudget() const { size_t available = getAvailableRAM(); - // Use 80% of available RAM for caches (very aggressive), but cap at 90% of total - size_t budget = available * 80 / 100; - size_t maxBudget = totalRAM_ * 90 / 100; - return budget < maxBudget ? budget : maxBudget; + // Use 50% of available RAM for caches, hard-capped at 16 GB. + static constexpr size_t kHardCapBytes = 16ull * 1024 * 1024 * 1024; // 16 GB + size_t budget = available * 50 / 100; + return budget < kHardCapBytes ? budget : kHardCapBytes; } bool MemoryMonitor::isMemoryPressure() const { size_t available = getAvailableRAM(); - // Memory pressure if < 20% RAM available - return available < (totalRAM_ * 20 / 100); + // Memory pressure if < 10% RAM available + return available < (totalRAM_ * 10 / 100); } } // namespace core diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 89b063c5..469df669 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -92,7 +92,7 @@ void AssetManager::setupFileCacheBudget() { const size_t envMaxMB = parseEnvSizeMB("WOWEE_FILE_CACHE_MAX_MB"); const size_t minBudgetBytes = 256ull * 1024ull * 1024ull; - const size_t defaultMaxBudgetBytes = 32768ull * 1024ull * 1024ull; + const size_t defaultMaxBudgetBytes = 12288ull * 1024ull * 1024ull; // 12 GB max for file cache const size_t maxBudgetBytes = (envMaxMB > 0) ? (envMaxMB * 1024ull * 1024ull) : defaultMaxBudgetBytes; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 2c25cd77..48b2d346 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -979,8 +979,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("monument") != std::string::npos) || (lowerName.find("sculpture") != std::string::npos); gpuModel.collisionStatue = statueName; + // Sittable furniture: chairs/benches/stools cause players to get stuck against + // invisible bounding boxes; WMOs already handle room collision. + bool sittableFurnitureName = + (lowerName.find("chair") != std::string::npos) || + (lowerName.find("bench") != std::string::npos) || + (lowerName.find("stool") != std::string::npos) || + (lowerName.find("seat") != std::string::npos) || + (lowerName.find("throne") != std::string::npos); bool smallSolidPropName = - statueName || + (statueName && !sittableFurnitureName) || (lowerName.find("crate") != std::string::npos) || (lowerName.find("box") != std::string::npos) || (lowerName.find("chest") != std::string::npos) || @@ -1023,6 +1031,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("bamboo") != std::string::npos) || (lowerName.find("banana") != std::string::npos) || (lowerName.find("coconut") != std::string::npos) || + (lowerName.find("watermelon") != std::string::npos) || + (lowerName.find("melon") != std::string::npos) || + (lowerName.find("squash") != std::string::npos) || + (lowerName.find("gourd") != std::string::npos) || (lowerName.find("canopy") != std::string::npos) || (lowerName.find("hedge") != std::string::npos) || (lowerName.find("cactus") != std::string::npos) || diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index dc4144fa..fdd07d8e 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -1051,14 +1051,21 @@ bool VkContext::recreateSwapchain(int width, int height) { auto swapRet = builder.build(); - if (oldSwapchain) { - vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + if (!swapRet) { + // Destroy old swapchain now that we failed (it can't be used either) + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + swapchain = VK_NULL_HANDLE; + } + LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); + // Keep swapchainDirty=true so the next frame retries + swapchainDirty = true; + return false; } - if (!swapRet) { - LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); - swapchain = VK_NULL_HANDLE; - return false; + // Success — safe to retire the old swapchain + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); } auto vkbSwap = swapRet.value(); @@ -1322,6 +1329,7 @@ bool VkContext::recreateSwapchain(int width, int height) { VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { if (deviceLost_) return VK_NULL_HANDLE; + if (swapchain == VK_NULL_HANDLE) return VK_NULL_HANDLE; // Swapchain lost; recreate pending auto& frame = frames[currentFrame]; diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index f90090f7..0a355ff3 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -189,7 +189,7 @@ bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler if (!dbcLoadAttempted) loadSpellDBC(assetManager); const SpellInfo* info = getSpellInfo(spellId); if (!info) return false; - renderSpellTooltip(info, gameHandler); + renderSpellTooltip(info, gameHandler, /*showUsageHints=*/false); return true; } @@ -446,7 +446,7 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { return (it != spellData.end()) ? &it->second : nullptr; } -void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) { +void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints) { ImGui::BeginTooltip(); ImGui::PushTextWrapPos(320.0f); @@ -551,8 +551,8 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextWrapped("%s", info->description.c_str()); } - // Usage hints - if (!info->isPassive()) { + // Usage hints — only shown when browsing the spellbook, not on action bar hover + if (!info->isPassive() && showUsageHints) { ImGui::Spacing(); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar"); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast"); From 6928b8ddf65f41130e210e751145255f95bc4772 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 22:26:56 -0700 Subject: [PATCH 19/71] feat: desaturate quest markers for trivial (gray) quests Trivial/low-level quests now show gray '!' / '?' markers instead of yellow, matching the in-game distinction between available and trivial quests. Add grayscale parameter to QuestMarkerRenderer::setMarker and the push-constant block; application sets grayscale=1.0 for trivial markers and 0.0 for all others. --- assets/shaders/quest_marker.frag.glsl | 5 ++++- assets/shaders/quest_marker.frag.spv | Bin 1396 -> 1844 bytes include/rendering/quest_marker_renderer.hpp | 5 ++++- src/rendering/quest_marker_renderer.cpp | 13 ++++++++----- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/assets/shaders/quest_marker.frag.glsl b/assets/shaders/quest_marker.frag.glsl index 020b625d..0e209d8f 100644 --- a/assets/shaders/quest_marker.frag.glsl +++ b/assets/shaders/quest_marker.frag.glsl @@ -5,6 +5,7 @@ layout(set = 1, binding = 0) uniform sampler2D markerTexture; layout(push_constant) uniform Push { mat4 model; float alpha; + float grayscale; // 0 = full colour, 1 = fully desaturated (trivial quests) } push; layout(location = 0) in vec2 TexCoord; @@ -14,5 +15,7 @@ layout(location = 0) out vec4 outColor; void main() { vec4 texColor = texture(markerTexture, TexCoord); if (texColor.a < 0.1) discard; - outColor = vec4(texColor.rgb, texColor.a * push.alpha); + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + vec3 rgb = mix(texColor.rgb, vec3(lum), push.grayscale); + outColor = vec4(rgb, texColor.a * push.alpha); } diff --git a/assets/shaders/quest_marker.frag.spv b/assets/shaders/quest_marker.frag.spv index e947d04c250e061fc5974110b6961604f7963add..90814c3072bc24151b35ce84c10df0fb079f4bbe 100644 GIT binary patch literal 1844 zcmYk5+fGwK6ovz{wtVb*SWtdLw|@A_KWQ{Wg%+QR>J+n8t%l{6Hes&G2%1MaO_*8P;lUf(Npn~BezSgvE-eP1 zbyO|;fp*97CdHt3Sd6%Iq@(YHuy|T86@ympq`5DyX5&!{?^SI!&g;%8Cv{H=#B~0o zY!*K6JP~=u?AjV}Q^Ibn#_4m-|GJq;e@sUXbQ>{_y5Y8koZf?;;mA$BF882V_{nI> zEloytTAU}0NKZa|=H=(&7}?)O*`+^Y3?FBbXwv=fiI z(9f5G~^=Y*X#9Jv8^__OkV zU~cq`&vyq$&kwYFLYqJ>_zwm5W{a}Xx_FOe(-(CR51;SA^~(JG5RPGH5XyDhBk@#?%Iw$^!0LNU3 zL0_W+{1<7M;kf0P?N7zHs-5*)dd{v0aP)%xN8Mt>5tBM51@i2vOHcch00&rY0&nwC zIjP~MfM!OZZr%aT#k(y#9&b)Q9ALS4uBUl%#MoDKPx~DK&Vlz}wo3xv12cDbmo4w^ zK9UdT&|`aA5l3FWIqD$?TI!$G&N(qX?W+Quo5NGt^b?;$RzAF|ZCy6C@jbcPHe|!U zx9_$Yof!lF!g9}KlaJ-NE!l9?>$q*pF^7Fkjk)az+(*wj@#%+eoU^}*up;~en8bUv delta 619 zcmYL_$w~u35QeK~a|z=`+%98l+@r=N2SpTvhXgzr4+=;DOdW`uZvMIzHuol8tiBuF+hM*|037^SPeic;nx8S|MsN_OMi*fiGGKveO z#DnU1_cZJhG;Akwj1C-aSvhmqVGH6|?Cf&3mA@c|cd8KXJqQmj74E=yAMMz#G7jR{ eo-z*B!jJLK_95P9KF`_U1ix=#dG?E^96*259V;yW diff --git a/include/rendering/quest_marker_renderer.hpp b/include/rendering/quest_marker_renderer.hpp index 2d6a73d3..a0d18776 100644 --- a/include/rendering/quest_marker_renderer.hpp +++ b/include/rendering/quest_marker_renderer.hpp @@ -35,8 +35,10 @@ public: * @param position World position (NPC base position) * @param markerType 0=available(!), 1=turnin(?), 2=incomplete(?) * @param boundingHeight NPC bounding height (optional, default 2.0f) + * @param grayscale 0 = full colour, 1 = desaturated grey (trivial/low-level quests) */ - void setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight = 2.0f); + void setMarker(uint64_t guid, const glm::vec3& position, int markerType, + float boundingHeight = 2.0f, float grayscale = 0.0f); /** * Remove a quest marker @@ -61,6 +63,7 @@ private: glm::vec3 position; int type; // 0=available, 1=turnin, 2=incomplete float boundingHeight = 2.0f; + float grayscale = 0.0f; // 0 = colour, 1 = desaturated (trivial quests) }; std::unordered_map markers_; diff --git a/src/rendering/quest_marker_renderer.cpp b/src/rendering/quest_marker_renderer.cpp index bc481d5a..d9aa3886 100644 --- a/src/rendering/quest_marker_renderer.cpp +++ b/src/rendering/quest_marker_renderer.cpp @@ -17,8 +17,9 @@ namespace wowee { namespace rendering { // Push constant layout matching quest_marker.vert.glsl / quest_marker.frag.glsl struct QuestMarkerPushConstants { - glm::mat4 model; // 64 bytes, used by vertex shader - float alpha; // 4 bytes, used by fragment shader + glm::mat4 model; // 64 bytes, used by vertex shader + float alpha; // 4 bytes, used by fragment shader + float grayscale; // 4 bytes: 0=colour, 1=desaturated (trivial quests) }; QuestMarkerRenderer::QuestMarkerRenderer() { @@ -340,8 +341,9 @@ void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) { } } -void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight) { - markers_[guid] = {position, markerType, boundingHeight}; +void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, + float boundingHeight, float grayscale) { + markers_[guid] = {position, markerType, boundingHeight, grayscale}; } void QuestMarkerRenderer::removeMarker(uint64_t guid) { @@ -436,10 +438,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 1, 1, &texDescSets_[marker.type], 0, nullptr); - // Push constants: model matrix + alpha + // Push constants: model matrix + alpha + grayscale tint QuestMarkerPushConstants push{}; push.model = model; push.alpha = fadeAlpha; + push.grayscale = marker.grayscale; vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, From 00a939a733c499505c76f2e0c9a44d84ce3dcea5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 22:27:00 -0700 Subject: [PATCH 20/71] fix: world map exploration fallback when server mask is unavailable When the server has not sent SMSG_INIT_WORLD_STATES or the mask is empty, fall back to locally-accumulated explored zones tracked by player position. The local set is cleared when a real server mask arrives so it doesn't persist stale data. --- include/rendering/world_map.hpp | 2 ++ src/rendering/world_map.cpp | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 89568209..47956b42 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -117,6 +117,8 @@ private: std::vector serverExplorationMask; bool hasServerExplorationMask = false; std::unordered_set exploredZones; + // Locally accumulated exploration (used as fallback when server mask is unavailable) + std::unordered_set locallyExploredZones_; }; } // namespace rendering diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index a1debba9..cf4c70fd 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -233,6 +233,10 @@ void WorldMap::setMapName(const std::string& name) { void WorldMap::setServerExplorationMask(const std::vector& masks, bool hasData) { if (!hasData || masks.empty()) { + // New session or no data yet — reset both server mask and local accumulation + if (hasServerExplorationMask) { + locallyExploredZones_.clear(); + } hasServerExplorationMask = false; serverExplorationMask.clear(); return; @@ -765,9 +769,12 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { } if (markedAny) return; + // Server mask unavailable or empty — fall back to locally-accumulated position tracking. + // Add the zone the player is currently in to the local set and display that. float wowX = playerRenderPos.y; float wowY = playerRenderPos.x; + bool foundPos = false; for (int i = 0; i < static_cast(zones.size()); i++) { const auto& z = zones[i]; if (z.areaID == 0) continue; @@ -775,15 +782,18 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { float minY = std::min(z.locTop, z.locBottom), maxY = std::max(z.locTop, z.locBottom); if (maxX - minX < 0.001f || maxY - minY < 0.001f) continue; if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) { - exploredZones.insert(i); - markedAny = true; + locallyExploredZones_.insert(i); + foundPos = true; } } - if (!markedAny) { + if (!foundPos) { int zoneIdx = findZoneForPlayer(playerRenderPos); - if (zoneIdx >= 0) exploredZones.insert(zoneIdx); + if (zoneIdx >= 0) locallyExploredZones_.insert(zoneIdx); } + + // Display the accumulated local set + exploredZones = locallyExploredZones_; } void WorldMap::zoomIn(const glm::vec3& playerRenderPos) { From b658743e9444bded081c751df6175c1b7990b3a9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 22:27:04 -0700 Subject: [PATCH 21/71] feat: highlight required level in item tooltips when player is under-level Display 'Requires Level N' in red when the player does not meet the item's level requirement, and in normal colour when they do. Applies to both equipped-item and bag-item tooltip paths. --- src/ui/inventory_screen.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index b9cf88ef..11f825ed 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1880,7 +1880,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } if (item.requiredLevel > 1) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel); + uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0; + bool meetsReq = (playerLvl >= item.requiredLevel); + ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(reqColor, "Requires Level %u", item.requiredLevel); } if (item.maxDurability > 0) { float durPct = static_cast(item.curDurability) / static_cast(item.maxDurability); @@ -2149,7 +2152,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) } if (info.requiredLevel > 1) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", info.requiredLevel); + uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0; + bool meetsReq = (playerLvl >= info.requiredLevel); + ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel); } // Spell effects From d95abfb607d4fab2bc16fac6a1bbae1f9e98728a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 22:45:47 -0700 Subject: [PATCH 22/71] feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT update fields and passes it through the full creature and game object spawn chain: game_handler callbacks → pending spawn structs → async load results → createInstance() calls. This gives boss giants, gnomes, children, and other non-unit-scale NPCs correct visual size, and ensures scaled GOs (e.g. large treasure chests, oversized plants) render at the server-specified scale rather than always at 1.0. - Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json - Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback - Propagated scale through PendingCreatureSpawn, PreparedCreatureModel, PendingGameObjectSpawn, PreparedGameObjectWMO - Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls - Sanity-clamped raw float to [0.01, 100.0] range before use --- Data/expansions/classic/update_fields.json | 1 + Data/expansions/tbc/update_fields.json | 1 + Data/expansions/turtle/update_fields.json | 1 + Data/expansions/wotlk/update_fields.json | 1 + include/core/application.hpp | 8 +++- include/game/game_handler.hpp | 8 ++-- include/game/update_field_table.hpp | 1 + src/core/application.cpp | 45 ++++++++++++++-------- src/game/game_handler.cpp | 39 +++++++++++++++++-- src/game/update_field_table.cpp | 4 ++ 10 files changed, 85 insertions(+), 24 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 0d61eacc..c393c6e6 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index c6d77c76..1df171f7 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index a91a314b..b268c5f8 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 67019c80..0bff52fc 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 6, "UNIT_FIELD_TARGET_HI": 7, "UNIT_FIELD_BYTES_0": 23, diff --git a/include/core/application.hpp b/include/core/application.hpp index 570f0658..7da1469b 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -98,7 +98,7 @@ private: void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); void buildFactionHostilityMap(uint8_t playerRace); pipeline::M2Model loadCreatureM2Sync(const std::string& m2Path); - void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation); + void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); void despawnOnlineCreature(uint64_t guid); bool tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId); void spawnOnlinePlayer(uint64_t guid, @@ -113,7 +113,7 @@ private: void despawnOnlinePlayer(uint64_t guid); void buildCreatureDisplayLookups(); std::string getModelPathForDisplayId(uint32_t displayId) const; - void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation); + void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); void despawnOnlineGameObject(uint64_t guid); void buildGameObjectDisplayLookups(); std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const; @@ -214,6 +214,7 @@ private: uint32_t displayId; uint32_t modelId; float x, y, z, orientation; + float scale = 1.0f; std::shared_ptr model; // parsed on background thread std::unordered_map predecodedTextures; // decoded on bg thread bool valid = false; @@ -300,6 +301,7 @@ private: uint64_t guid; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; }; std::deque pendingCreatureSpawns_; static constexpr int MAX_SPAWNS_PER_FRAME = 3; @@ -393,6 +395,7 @@ private: uint32_t entry; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; }; std::vector pendingGameObjectSpawns_; void processGameObjectSpawnQueue(); @@ -403,6 +406,7 @@ private: uint32_t entry; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; std::shared_ptr wmoModel; std::unordered_map predecodedTextures; // decoded on bg thread bool valid = false; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c0ec24c6..8656b9db 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -735,8 +735,8 @@ public: void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); } // Creature spawn callback (online mode - triggered when creature enters view) - // Parameters: guid, displayId, x, y, z (canonical), orientation - using CreatureSpawnCallback = std::function; + // Parameters: guid, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X) + using CreatureSpawnCallback = std::function; void setCreatureSpawnCallback(CreatureSpawnCallback cb) { creatureSpawnCallback_ = std::move(cb); } // Creature despawn callback (online mode - triggered when creature leaves view) @@ -766,8 +766,8 @@ public: void setPlayerEquipmentCallback(PlayerEquipmentCallback cb) { playerEquipmentCallback_ = std::move(cb); } // GameObject spawn callback (online mode - triggered when gameobject enters view) - // Parameters: guid, entry, displayId, x, y, z (canonical), orientation - using GameObjectSpawnCallback = std::function; + // Parameters: guid, entry, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X) + using GameObjectSpawnCallback = std::function; void setGameObjectSpawnCallback(GameObjectSpawnCallback cb) { gameObjectSpawnCallback_ = std::move(cb); } // GameObject move callback (online mode - triggered when gameobject position updates) diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 67651b00..88ce8a30 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -14,6 +14,7 @@ namespace game { enum class UF : uint16_t { // Object fields OBJECT_FIELD_ENTRY, + OBJECT_FIELD_SCALE_X, // Unit fields UNIT_FIELD_TARGET_LO, diff --git a/src/core/application.cpp b/src/core/application.cpp index 8a7b51e4..b04a5269 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -985,6 +985,18 @@ void Application::update(float deltaTime) { retrySpawn.y = unit->getY(); retrySpawn.z = unit->getZ(); retrySpawn.orientation = unit->getOrientation(); + { + using game::fieldIndex; using game::UF; + uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (si != 0xFFFF) { + uint32_t raw = unit->getField(si); + if (raw != 0) { + float s2 = 1.0f; + std::memcpy(&s2, &raw, sizeof(float)); + if (s2 > 0.01f && s2 < 100.0f) retrySpawn.scale = s2; + } + } + } pendingCreatureSpawns_.push_back(retrySpawn); pendingCreatureSpawnGuids_.insert(guid); } @@ -2198,12 +2210,12 @@ void Application::setupUICallbacks() { // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models - gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { + gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { // Queue spawns to avoid hanging when many creatures appear at once. // Deduplicate so repeated updates don't flood pending queue. if (creatureInstances_.count(guid)) return; if (pendingCreatureSpawnGuids_.count(guid)) return; - pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation}); + pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation, scale}); pendingCreatureSpawnGuids_.insert(guid); }); @@ -2249,8 +2261,8 @@ void Application::setupUICallbacks() { }); // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) - gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation}); + gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation, scale}); }); // GameObject despawn callback (online mode) - remove static models @@ -4754,7 +4766,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // Process ALL pending game object spawns. while (!pendingGameObjectSpawns_.empty()) { auto& s = pendingGameObjectSpawns_.front(); - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); } @@ -5285,7 +5297,7 @@ pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) { return model; } -void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { +void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return; // Skip if lookups not yet built (asset manager not ready) @@ -5722,9 +5734,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Convert canonical WoW orientation (0=north) -> render yaw (0=west) float renderYaw = orientation + glm::radians(90.0f); - // Create instance + // Create instance (apply server-provided scale from OBJECT_FIELD_SCALE_X) uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); + glm::vec3(0.0f, 0.0f, renderYaw), scale); if (instanceId == 0) { LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec); @@ -7035,7 +7047,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) { creatureWasWalking_.erase(guid); } -void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { +void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer || !assetManager) return; if (!gameObjectLookupsBuilt_) { @@ -7192,7 +7204,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (loadedAsWmo) { uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawWmo), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawWmo), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); return; @@ -7300,7 +7312,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawM2go), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawM2go), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); return; @@ -7418,6 +7430,7 @@ void Application::processAsyncCreatureResults(bool unlimited) { s.y = result.y; s.z = result.z; s.orientation = result.orientation; + s.scale = result.scale; pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(result.guid); } @@ -7732,6 +7745,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { result.y = s.y; result.z = s.z; result.orientation = s.orientation; + result.scale = s.scale; auto m2Data = am->readFile(m2Path); if (m2Data.empty()) { @@ -7810,7 +7824,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) { auto spawnStart = std::chrono::steady_clock::now(); - spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); auto spawnEnd = std::chrono::steady_clock::now(); float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); if (spawnMs > 100.0f) { @@ -8015,7 +8029,7 @@ void Application::processAsyncGameObjectResults() { if (!result.valid || !result.isWmo || !result.wmoModel) { // Fallback: spawn via sync path (likely an M2 or failed WMO) spawnOnlineGameObject(result.guid, result.entry, result.displayId, - result.x, result.y, result.z, result.orientation); + result.x, result.y, result.z, result.orientation, result.scale); continue; } @@ -8042,7 +8056,7 @@ void Application::processAsyncGameObjectResults() { glm::vec3 renderPos = core::coords::canonicalToRender( glm::vec3(result.x, result.y, result.z)); uint32_t instanceId = wmoRenderer->createInstance( - modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), 1.0f); + modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); if (instanceId == 0) continue; gameObjectInstances_[result.guid] = {modelId, instanceId, true}; @@ -8129,6 +8143,7 @@ void Application::processGameObjectSpawnQueue() { result.y = capture.y; result.z = capture.z; result.orientation = capture.orientation; + result.scale = capture.scale; result.modelPath = capturePath; result.isWmo = true; @@ -8194,7 +8209,7 @@ void Application::processGameObjectSpawnQueue() { } // Cached WMO or M2 — spawn synchronously (cheap) - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); } } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 360e2312..9964828e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8009,8 +8009,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, " displayId=", unit->getDisplayId(), " at (", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + float unitScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale, &raw, sizeof(float)); + if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; + } + } + } creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); if (unitInitiallyDead && npcDeathCallback_) { npcDeathCallback_(block.guid); } @@ -8060,8 +8071,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created } if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { + float goScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&goScale, &raw, sizeof(float)); + if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; + } + } + } gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), - go->getX(), go->getY(), go->getZ(), go->getOrientation()); + go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); } // Fire transport move callback for transports (position update on re-creation) if (transportGuids_.count(block.guid) && transportMoveCallback_) { @@ -8366,8 +8388,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } else if (creatureSpawnCallback_) { + float unitScale2 = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale2, &raw, sizeof(float)); + if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; + } + } + } creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); bool isDeadNow = (unit->getHealth() == 0) || ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 505b86e6..1026c29e 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -19,6 +19,7 @@ struct UFNameEntry { static const UFNameEntry kUFNames[] = { {"OBJECT_FIELD_ENTRY", UF::OBJECT_FIELD_ENTRY}, + {"OBJECT_FIELD_SCALE_X", UF::OBJECT_FIELD_SCALE_X}, {"UNIT_FIELD_TARGET_LO", UF::UNIT_FIELD_TARGET_LO}, {"UNIT_FIELD_TARGET_HI", UF::UNIT_FIELD_TARGET_HI}, {"UNIT_FIELD_BYTES_0", UF::UNIT_FIELD_BYTES_0}, @@ -52,6 +53,9 @@ static const UFNameEntry kUFNames[] = { {"PLAYER_EXPLORED_ZONES_START", UF::PLAYER_EXPLORED_ZONES_START}, {"GAMEOBJECT_DISPLAYID", UF::GAMEOBJECT_DISPLAYID}, {"ITEM_FIELD_STACK_COUNT", UF::ITEM_FIELD_STACK_COUNT}, + {"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY}, + {"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY}, + {"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE}, {"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS}, {"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1}, }; From 99de1fa3e5ac0b1cd1cca9c0916808cab2983372 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 23:08:15 -0700 Subject: [PATCH 23/71] feat: track UNIT_FIELD_STAT0-4 from server update fields for accurate character stats Add UNIT_FIELD_STAT0-4 (STR/AGI/STA/INT/SPI) to the UF enum and wire up per-expansion indices in all four expansion JSON files (WotLK: 84-88, Classic/Turtle: 138-142, TBC: 159-163). Read the values in both CREATE and VALUES player update handlers and store in playerStats_[5]. renderStatsPanel now uses the server-authoritative totals when available, falling back to the previous 20+level estimate only if the server hasn't sent UNIT_FIELD_STAT* yet. Item-query bonuses are still shown as (+N) alongside the server total for both paths. --- Data/expansions/classic/update_fields.json | 5 ++ Data/expansions/tbc/update_fields.json | 5 ++ Data/expansions/turtle/update_fields.json | 5 ++ Data/expansions/wotlk/update_fields.json | 5 ++ include/game/game_handler.hpp | 9 +++ include/game/update_field_table.hpp | 5 ++ include/ui/inventory_screen.hpp | 3 +- src/game/game_handler.cpp | 27 ++++++++ src/game/update_field_table.cpp | 5 ++ src/ui/inventory_screen.cpp | 76 +++++++++++++--------- 10 files changed, 115 insertions(+), 30 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index c393c6e6..8bba605d 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index 1df171f7..196d70ce 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 168, "UNIT_DYNAMIC_FLAGS": 164, "UNIT_FIELD_RESISTANCES": 185, + "UNIT_FIELD_STAT0": 159, + "UNIT_FIELD_STAT1": 160, + "UNIT_FIELD_STAT2": 161, + "UNIT_FIELD_STAT3": 162, + "UNIT_FIELD_STAT4": 163, "UNIT_END": 234, "PLAYER_FLAGS": 236, "PLAYER_BYTES": 237, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index b268c5f8..153fd2ed 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 0bff52fc..2c5c1a8d 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -17,6 +17,11 @@ "UNIT_NPC_FLAGS": 82, "UNIT_DYNAMIC_FLAGS": 147, "UNIT_FIELD_RESISTANCES": 99, + "UNIT_FIELD_STAT0": 84, + "UNIT_FIELD_STAT1": 85, + "UNIT_FIELD_STAT2": 86, + "UNIT_FIELD_STAT3": 87, + "UNIT_FIELD_STAT4": 88, "UNIT_END": 148, "PLAYER_FLAGS": 150, "PLAYER_BYTES": 153, diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8656b9db..11969c88 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -295,6 +295,13 @@ public: // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } + // Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI). + // Returns -1 if the server hasn't sent the value yet. + int32_t getPlayerStat(int idx) const { + if (idx < 0 || idx > 4) return -1; + return playerStats_[idx]; + } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -2109,6 +2116,8 @@ private: std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; int32_t playerArmorRating_ = 0; + // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet + int32_t playerStats_[5] = {-1, -1, -1, -1, -1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating // money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime. uint32_t pendingMoneyDelta_ = 0; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 88ce8a30..07c735fd 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -34,6 +34,11 @@ enum class UF : uint16_t { UNIT_NPC_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array) + UNIT_FIELD_STAT0, // Strength (effective base, includes items) + UNIT_FIELD_STAT1, // Agility + UNIT_FIELD_STAT2, // Stamina + UNIT_FIELD_STAT3, // Intellect + UNIT_FIELD_STAT4, // Spirit UNIT_END, // Player fields diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 4f3e7970..bfca779f 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -148,7 +148,8 @@ private: int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper); void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); - void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0); + void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, + const int32_t* serverStats = nullptr); void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9964828e..f007c947 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6098,6 +6098,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; + std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); actionBar = {}; @@ -8142,6 +8143,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } @@ -8170,6 +8176,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce // phantom "already accepted" quests that block quest acceptance. @@ -8454,6 +8468,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; @@ -8510,6 +8529,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(false); } } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } } // Do not auto-create quests from VALUES quest-log slot fields for the // same reason as CREATE_OBJECT2 above (can be misaligned per realm). diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 1026c29e..41473539 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -37,6 +37,11 @@ static const UFNameEntry kUFNames[] = { {"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS}, {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES}, + {"UNIT_FIELD_STAT0", UF::UNIT_FIELD_STAT0}, + {"UNIT_FIELD_STAT1", UF::UNIT_FIELD_STAT1}, + {"UNIT_FIELD_STAT2", UF::UNIT_FIELD_STAT2}, + {"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3}, + {"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4}, {"UNIT_END", UF::UNIT_END}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_BYTES", UF::PLAYER_BYTES}, diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 11f825ed..8ceb64a5 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1086,7 +1086,10 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (ImGui::BeginTabItem("Stats")) { ImGui::Spacing(); - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating()); + int32_t stats[5]; + for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); + const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); ImGui::EndTabItem(); } @@ -1376,18 +1379,18 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // Stats Panel // ============================================================ -void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor) { - // Sum equipment stats - int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0; - +void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, + int32_t serverArmor, const int32_t* serverStats) { + // Sum equipment stats for item-query bonus display + int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; - totalStr += slot.item.strength; - totalAgi += slot.item.agility; - totalSta += slot.item.stamina; - totalInt += slot.item.intellect; - totalSpi += slot.item.spirit; + itemStr += slot.item.strength; + itemAgi += slot.item.agility; + itemSta += slot.item.stamina; + itemInt += slot.item.intellect; + itemSpi += slot.item.spirit; } // Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available. @@ -1399,9 +1402,6 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor; - // Base stats: 20 + level - int32_t baseStat = 20 + static_cast(playerLevel); - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); @@ -1414,23 +1414,41 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImGui::TextColored(gray, "Armor: 0"); } - // Helper to render a stat line - auto renderStat = [&](const char* name, int32_t equipBonus) { - int32_t total = baseStat + equipBonus; - if (equipBonus > 0) { - ImGui::TextColored(white, "%s: %d", name, total); - ImGui::SameLine(); - ImGui::TextColored(green, "(+%d)", equipBonus); - } else { - ImGui::TextColored(gray, "%s: %d", name, total); + if (serverStats) { + // Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus. + // serverStats[i] is the server's effective base stat (items included, buffs excluded). + const char* statNames[5] = {"Strength", "Agility", "Stamina", "Intellect", "Spirit"}; + const int32_t itemBonuses[5] = {itemStr, itemAgi, itemSta, itemInt, itemSpi}; + for (int i = 0; i < 5; ++i) { + int32_t total = serverStats[i]; + int32_t bonus = itemBonuses[i]; + if (bonus > 0) { + ImGui::TextColored(white, "%s: %d", statNames[i], total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", bonus); + } else { + ImGui::TextColored(gray, "%s: %d", statNames[i], total); + } } - }; - - renderStat("Strength", totalStr); - renderStat("Agility", totalAgi); - renderStat("Stamina", totalSta); - renderStat("Intellect", totalInt); - renderStat("Spirit", totalSpi); + } else { + // Fallback: estimated base (20 + level) plus item query bonuses. + int32_t baseStat = 20 + static_cast(playerLevel); + auto renderStat = [&](const char* name, int32_t equipBonus) { + int32_t total = baseStat + equipBonus; + if (equipBonus > 0) { + ImGui::TextColored(white, "%s: %d", name, total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", equipBonus); + } else { + ImGui::TextColored(gray, "%s: %d", name, total); + } + }; + renderStat("Strength", itemStr); + renderStat("Agility", itemAgi); + renderStat("Stamina", itemSta); + renderStat("Intellect", itemInt); + renderStat("Spirit", itemSpi); + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { From 1b55ebb3874cbdd209aff4f9125d6eba1d415f40 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 23:14:18 -0700 Subject: [PATCH 24/71] fix: correct PLAYER_REST_STATE_EXPERIENCE wire indices for all expansions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST_STATE_EXPERIENCE field was erroneously set to the same index as PLAYER_SKILL_INFO_START in all four expansion JSON files, causing the rested XP tracker to read the first skill slot ID as the rested XP value. Correct indices derived from layout: EXPLORED_ZONES_START + 128 zone fields (or 64 for Classic) immediately precede PLAYER_FIELD_COINAGE, with REST_STATE_EXPERIENCE in the one slot between them. - WotLK: 636 → 1169 (1041 + 128 = 1169, before COINAGE=1170) - Classic: 718 → 1175 (1111 + 64 = 1175, before COINAGE=1176) - TBC: 928 → 1440 (1312 + 128 = 1440, before COINAGE=1441) - Turtle: 718 → 1175 (same as Classic layout) --- Data/expansions/classic/update_fields.json | 2 +- Data/expansions/tbc/update_fields.json | 2 +- Data/expansions/turtle/update_fields.json | 2 +- Data/expansions/wotlk/update_fields.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 8bba605d..bb269d8a 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -28,7 +28,7 @@ "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, - "PLAYER_REST_STATE_EXPERIENCE": 718, + "PLAYER_REST_STATE_EXPERIENCE": 1175, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index 196d70ce..05e37180 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -28,7 +28,7 @@ "PLAYER_BYTES_2": 238, "PLAYER_XP": 926, "PLAYER_NEXT_LEVEL_XP": 927, - "PLAYER_REST_STATE_EXPERIENCE": 928, + "PLAYER_REST_STATE_EXPERIENCE": 1440, "PLAYER_FIELD_COINAGE": 1441, "PLAYER_QUEST_LOG_START": 244, "PLAYER_FIELD_INV_SLOT_HEAD": 650, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 153fd2ed..74b873ae 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -28,7 +28,7 @@ "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, - "PLAYER_REST_STATE_EXPERIENCE": 718, + "PLAYER_REST_STATE_EXPERIENCE": 1175, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 2c5c1a8d..1532f628 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -28,7 +28,7 @@ "PLAYER_BYTES_2": 154, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, - "PLAYER_REST_STATE_EXPERIENCE": 636, + "PLAYER_REST_STATE_EXPERIENCE": 1169, "PLAYER_FIELD_COINAGE": 1170, "PLAYER_QUEST_LOG_START": 158, "PLAYER_FIELD_INV_SLOT_HEAD": 324, From 3a7ff71262cdd5f05a37efc628ba6fb8e7a0aa86 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 23:18:16 -0700 Subject: [PATCH 25/71] fix: use expansion-aware explored zone count to prevent fog-of-war corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic 1.12 and Turtle WoW have only 64 PLAYER_EXPLORED_ZONES uint32 fields (zone IDs pack into 2048 bits). TBC and WotLK use 128 (needed for Outland/Northrend zone IDs up to bit 4095). The hardcoded PLAYER_EXPLORED_ZONES_COUNT=128 caused extractExploredZoneFields to read 64 extra fields beyond the actual zone block in Classic/Turtle — consuming PLAYER_REST_STATE_EXPERIENCE, PLAYER_FIELD_COINAGE, and character- points fields as zone flags. On the world map, this could mark zones as explored based on random bit patterns in those unrelated fields. Add `exploredZonesCount()` virtual method to PacketParsers (default=128, Classic/Turtle override=64) and use it in extractExploredZoneFields to limit reads to the correct block and zero-fill remaining slots. --- include/game/packet_parsers.hpp | 8 ++++++++ src/game/game_handler.cpp | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index d2556e7b..2cb17fdb 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -207,6 +207,11 @@ public: * WotLK: 5 fields per slot, Classic/Vanilla: 3. */ virtual uint8_t questLogStride() const { return 5; } + /** Number of PLAYER_EXPLORED_ZONES uint32 fields in update-object blocks. + * Classic/Vanilla/Turtle: 64 (bit-packs up to zone ID 2047). + * TBC/WotLK: 128 (covers Outland/Northrend zone IDs up to 4095). */ + virtual uint8_t exploredZonesCount() const { return 128; } + // --- Quest Giver Status --- /** Read quest giver status from packet. @@ -407,6 +412,9 @@ public: network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; // parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3) uint8_t questLogStride() const override { return 3; } + // Classic 1.12 has 64 explored-zone uint32 fields (zone IDs fit in 2048 bits). + // TBC/WotLK use 128 (needed for Outland/Northrend zone IDs up to 4095). + uint8_t exploredZonesCount() const override { return 64; } bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f007c947..0783c91a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17556,18 +17556,31 @@ void GameHandler::extractSkillFields(const std::map& fields) } void GameHandler::extractExploredZoneFields(const std::map& fields) { + // Number of explored-zone uint32 fields varies by expansion: + // Classic/Turtle = 64, TBC/WotLK = 128. Always allocate 128 for world-map + // bit lookups, but only read the expansion-specific count to avoid reading + // player money or rest-XP fields as zone flags. + const size_t zoneCount = packetParsers_ + ? static_cast(packetParsers_->exploredZonesCount()) + : PLAYER_EXPLORED_ZONES_COUNT; + if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) { playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u); } bool foundAny = false; - for (size_t i = 0; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { + for (size_t i = 0; i < zoneCount; i++) { const uint16_t fieldIdx = static_cast(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i); auto it = fields.find(fieldIdx); if (it == fields.end()) continue; playerExploredZones_[i] = it->second; foundAny = true; } + // Zero out slots beyond the expansion's zone count to prevent stale data + // from polluting the fog-of-war display. + for (size_t i = zoneCount; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { + playerExploredZones_[i] = 0u; + } if (foundAny) { hasPlayerExploredZones_ = true; From 7e55d21cddc4a69bd9271f08ffe12318b8929614 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 23:33:38 -0700 Subject: [PATCH 26/71] feat: read quest completion state from update fields on login and mid-session resyncQuestLogFromServerSlots now reads the state field (slot*stride+1) alongside the quest ID field, and marks quest.complete=true when the server reports QuestStatus=1 (complete/ready-to-turn-in). Previously, quests that were already complete before login would remain incorrectly marked as incomplete until SMSG_QUESTUPDATE_COMPLETE fired, which only happens when objectives are NEWLY completed during the session. applyQuestStateFromFields() is a lightweight companion called from both the CREATE and VALUES update handlers that applies the same state-field check to already-tracked quests mid-session, catching the case where the last objective completes via an update-field delta rather than the dedicated quest-complete packet. Works across all expansion strides (Classic stride=3, TBC stride=4, WotLK stride=5); guarded against stride<2 (no state field available). --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 85 ++++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 11969c88..331248f3 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2362,6 +2362,7 @@ private: void loadSkillLineAbilityDbc(); void extractSkillFields(const std::map& fields); void extractExploredZoneFields(const std::map& fields); + void applyQuestStateFromFields(const std::map& fields); NpcDeathCallback npcDeathCallback_; NpcAggroCallback npcAggroCallback_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0783c91a..e49f2afd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8193,6 +8193,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { maybeDetectVisibleItemLayout(); extractSkillFields(lastPlayerFields_); extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); } break; } @@ -8544,6 +8545,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (slotsChanged) rebuildOnlineInventory(); extractSkillFields(lastPlayerFields_); extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); } // Update item stack count / durability for online items @@ -14805,15 +14807,38 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; - std::unordered_set serverQuestIds; - serverQuestIds.reserve(25); + + // Collect quest IDs and their completion state from update fields. + // State field (slot*stride+1) uses the same QuestStatus enum across all expansions: + // 0 = none, 1 = complete (ready to turn in), 3 = incomplete/active, etc. + static constexpr uint32_t kQuestStatusComplete = 1; + + std::unordered_map serverQuestComplete; // questId → complete + serverQuestComplete.reserve(25); for (uint16_t slot = 0; slot < 25; ++slot) { - const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t stateField = ufQuestStart + slot * qStride + 1; auto it = lastPlayerFields_.find(idField); if (it == lastPlayerFields_.end()) continue; - if (it->second != 0) serverQuestIds.insert(it->second); + uint32_t questId = it->second; + if (questId == 0) continue; + + bool complete = false; + if (qStride >= 2) { + auto stateIt = lastPlayerFields_.find(stateField); + if (stateIt != lastPlayerFields_.end()) { + // Lower byte is the quest state; treat any variant of "complete" as done. + uint32_t state = stateIt->second & 0xFF; + complete = (state == kQuestStatusComplete); + } + } + serverQuestComplete[questId] = complete; } + std::unordered_set serverQuestIds; + serverQuestIds.reserve(serverQuestComplete.size()); + for (const auto& [qid, _] : serverQuestComplete) serverQuestIds.insert(qid); + const size_t localBefore = questLog_.size(); std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == 0 || serverQuestIds.count(q.questId) == 0; @@ -14827,6 +14852,20 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { ++added; } + // Apply server-authoritative completion state to all tracked quests. + // This initialises quest.complete correctly on login for quests that were + // already complete before the current session started. + size_t marked = 0; + for (auto& quest : questLog_) { + auto it = serverQuestComplete.find(quest.questId); + if (it == serverQuestComplete.end()) continue; + if (it->second && !quest.complete) { + quest.complete = true; + ++marked; + LOG_DEBUG("Quest ", quest.questId, " marked complete from update fields"); + } + } + if (forceQueryMetadata) { for (uint32_t questId : serverQuestIds) { requestQuestQuery(questId, false); @@ -14834,10 +14873,46 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { } LOG_INFO("Quest log resync from server slots: server=", serverQuestIds.size(), - " localBefore=", localBefore, " removed=", removed, " added=", added); + " localBefore=", localBefore, " removed=", removed, " added=", added, + " markedComplete=", marked); return true; } +// Apply quest completion state from player update fields to already-tracked local quests. +// Called from VALUES update handler so quests that complete mid-session (or that were +// complete on login) get quest.complete=true without waiting for SMSG_QUESTUPDATE_COMPLETE. +void GameHandler::applyQuestStateFromFields(const std::map& fields) { + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + if (ufQuestStart == 0xFFFF || questLog_.empty()) return; + + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + if (qStride < 2) return; // Need at least 2 fields per slot (id + state) + + static constexpr uint32_t kQuestStatusComplete = 1; + + for (uint16_t slot = 0; slot < 25; ++slot) { + const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t stateField = idField + 1; + auto idIt = fields.find(idField); + if (idIt == fields.end()) continue; + uint32_t questId = idIt->second; + if (questId == 0) continue; + + auto stateIt = fields.find(stateField); + if (stateIt == fields.end()) continue; + bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete); + if (!serverComplete) continue; + + for (auto& quest : questLog_) { + if (quest.questId == questId && !quest.complete) { + quest.complete = true; + LOG_INFO("Quest ", questId, " marked complete from VALUES update field state"); + break; + } + } + } +} + void GameHandler::clearPendingQuestAccept(uint32_t questId) { pendingQuestAcceptTimeouts_.erase(questId); pendingQuestAcceptNpcGuids_.erase(questId); From 73439a4457ca5ddd24d106be89522fe4405e344f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 23:52:18 -0700 Subject: [PATCH 27/71] feat: restore quest kill counts from update fields using parsed objectives Parse kill/item objectives from SMSG_QUEST_QUERY_RESPONSE binary data: - extractQuestQueryObjectives() scans past the fixed integer header and variable-length strings to reach the 4 entity + 6 item objective entries (using known offsets: 40 fields for Classic/TBC, 55 for WotLK) - Objectives stored in QuestLogEntry.killObjectives / itemObjectives arrays - After storing, applyPackedKillCountsFromFields() reads 6-bit packed counts from update-field slots (stride+2 / stride+3) and populates killCounts using the parsed creature/GO entry IDs as keys This means on login, quests that were in progress show correct kill count progress (e.g. "2/5 Defias Bandits killed") without waiting for the first server SMSG_QUESTUPDATE_ADD_KILL notification. --- include/game/game_handler.hpp | 20 ++++- src/game/game_handler.cpp | 155 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 331248f3..946c66b0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1069,12 +1069,27 @@ public: std::string title; std::string objectives; bool complete = false; - // Objective kill counts: objectiveIndex -> (current, required) + // Objective kill counts: npcOrGoEntry -> (current, required) std::unordered_map> killCounts; // Quest item progress: itemId -> current count std::unordered_map itemCounts; // Server-authoritative quest item requirements from REQUEST_ITEMS std::unordered_map requiredItemCounts; + // Structured kill objectives parsed from SMSG_QUEST_QUERY_RESPONSE. + // Index 0-3 map to the server's objective slot order (packed into update fields). + // npcOrGoId != 0 => entity objective (kill NPC or interact with GO). + struct KillObjective { + int32_t npcOrGoId = 0; // negative = game-object entry + uint32_t required = 0; + }; + std::array killObjectives{}; // zeroed by default + // Required item objectives parsed from SMSG_QUEST_QUERY_RESPONSE. + // itemId != 0 => collect items of that type. + struct ItemObjective { + uint32_t itemId = 0; + uint32_t required = 0; + }; + std::array itemObjectives{}; // zeroed by default }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); @@ -2363,6 +2378,9 @@ private: void extractSkillFields(const std::map& fields); void extractExploredZoneFields(const std::map& fields); void applyQuestStateFromFields(const std::map& fields); + // Apply packed kill counts from player update fields to a quest entry that has + // already had its killObjectives populated from SMSG_QUEST_QUERY_RESPONSE. + void applyPackedKillCountsFromFields(QuestLogEntry& quest); NpcDeathCallback npcDeathCallback_; NpcAggroCallback npcAggroCallback_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e49f2afd..191efc37 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -357,6 +357,70 @@ QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector& data return best; } + +// Parse kill/item objectives from SMSG_QUEST_QUERY_RESPONSE raw data. +// Returns true if the objective block was found and at least one entry read. +// +// Format after the fixed integer header (40*4 Classic or 55*4 WotLK bytes post questId+questMethod): +// N strings (title, objectives, details, endText; + completedText for WotLK) +// 4x { int32 npcOrGoId, uint32 count } -- entity (kill/interact) objectives +// 6x { uint32 itemId, uint32 count } -- item collect objectives +// 4x cstring -- per-objective display text +// +// We use the same fixed-offset heuristic as pickBestQuestQueryTexts and then scan past +// the string section to reach the objective data. +struct QuestQueryObjectives { + struct Kill { int32_t npcOrGoId; uint32_t required; }; + struct Item { uint32_t itemId; uint32_t required; }; + std::array kills{}; + std::array items{}; + bool valid = false; +}; + +static uint32_t readU32At(const std::vector& d, size_t pos) { + return static_cast(d[pos]) + | (static_cast(d[pos + 1]) << 8) + | (static_cast(d[pos + 2]) << 16) + | (static_cast(d[pos + 3]) << 24); +} + +QuestQueryObjectives extractQuestQueryObjectives(const std::vector& data, bool classicHint) { + QuestQueryObjectives out; + if (data.size() < 16) return out; + + const size_t base = 8; // questId(4) + questMethod(4) already at start + // Number of fixed uint32 fields before the first string (title). + const size_t fixedFields = classicHint ? 40u : 55u; + size_t pos = base + fixedFields * 4; + + // Number of strings before the objective data. + const int nStrings = classicHint ? 4 : 5; + + // Scan past each string (null-terminated). + for (int si = 0; si < nStrings; ++si) { + while (pos < data.size() && data[pos] != 0) ++pos; + if (pos >= data.size()) return out; + ++pos; // consume null terminator + } + + // Read 4 entity objectives: int32 npcOrGoId + uint32 count each. + for (int i = 0; i < 4; ++i) { + if (pos + 8 > data.size()) return out; + out.kills[i].npcOrGoId = static_cast(readU32At(data, pos)); pos += 4; + out.kills[i].required = readU32At(data, pos); pos += 4; + } + + // Read 6 item objectives: uint32 itemId + uint32 count each. + for (int i = 0; i < 6; ++i) { + if (pos + 8 > data.size()) break; + out.items[i].itemId = readU32At(data, pos); pos += 4; + out.items[i].required = readU32At(data, pos); pos += 4; + } + + out.valid = true; + return out; +} + } // namespace @@ -4253,6 +4317,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); + const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); for (auto& q : questLog_) { if (q.questId != questId) continue; @@ -4276,6 +4341,26 @@ void GameHandler::handlePacket(network::Packet& packet) { (q.objectives.empty() || q.objectives.size() < 16)) { q.objectives = parsed.objectives; } + + // Store structured kill/item objectives for later kill-count restoration. + if (objs.valid) { + for (int i = 0; i < 4; ++i) { + q.killObjectives[i].npcOrGoId = objs.kills[i].npcOrGoId; + q.killObjectives[i].required = objs.kills[i].required; + } + for (int i = 0; i < 6; ++i) { + q.itemObjectives[i].itemId = objs.items[i].itemId; + q.itemObjectives[i].required = objs.items[i].required; + } + // Now that we have the objective creature IDs, apply any packed kill + // counts from the player update fields that arrived at login. + applyPackedKillCountsFromFields(q); + LOG_DEBUG("Quest ", questId, " objectives parsed: kills=[", + objs.kills[0].npcOrGoId, "/", objs.kills[0].required, ", ", + objs.kills[1].npcOrGoId, "/", objs.kills[1].required, ", ", + objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ", + objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]"); + } break; } @@ -14913,6 +14998,76 @@ void GameHandler::applyQuestStateFromFields(const std::map& } } +// Extract packed 6-bit kill/objective counts from WotLK/TBC/Classic quest-log update fields +// and populate quest.killCounts + quest.itemCounts using the structured objectives obtained +// from a prior SMSG_QUEST_QUERY_RESPONSE. Silently does nothing if objectives are absent. +void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { + if (lastPlayerFields_.empty()) return; + + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + if (ufQuestStart == 0xFFFF) return; + + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + if (qStride < 3) return; // Need at least id + state + packed-counts field + + // Find which server slot this quest occupies. + int slot = findQuestLogSlotIndexFromServer(quest.questId); + if (slot < 0) return; + + // Packed count fields: stride+2 (all expansions), stride+3 (WotLK only, stride==5) + const uint16_t countField1 = ufQuestStart + static_cast(slot) * qStride + 2; + const uint16_t countField2 = (qStride >= 5) + ? static_cast(countField1 + 1) + : static_cast(0xFFFF); + + auto f1It = lastPlayerFields_.find(countField1); + if (f1It == lastPlayerFields_.end()) return; + const uint32_t packed1 = f1It->second; + + uint32_t packed2 = 0; + if (countField2 != 0xFFFF) { + auto f2It = lastPlayerFields_.find(countField2); + if (f2It != lastPlayerFields_.end()) packed2 = f2It->second; + } + + // Unpack six 6-bit counts (bit fields 0-5, 6-11, 12-17, 18-23 in packed1; + // bits 0-5, 6-11 in packed2 for objectives 4 and 5). + auto unpack6 = [](uint32_t word, int idx) -> uint8_t { + return static_cast((word >> (idx * 6)) & 0x3F); + }; + const uint8_t counts[6] = { + unpack6(packed1, 0), unpack6(packed1, 1), + unpack6(packed1, 2), unpack6(packed1, 3), + unpack6(packed2, 0), unpack6(packed2, 1), + }; + + // Apply kill objective counts (indices 0-3). + for (int i = 0; i < 4; ++i) { + const auto& obj = quest.killObjectives[i]; + if (obj.npcOrGoId == 0 || obj.required == 0) continue; + const uint32_t entryKey = static_cast(obj.npcOrGoId); + // Don't overwrite live kill count with stale packed data if already non-zero. + if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue; + quest.killCounts[entryKey] = {counts[i], obj.required}; + LOG_DEBUG("Quest ", quest.questId, " objective[", i, "]: npcOrGo=", + obj.npcOrGoId, " count=", (int)counts[i], "/", obj.required); + } + + // Apply item objective counts (only available in WotLK stride+3 positions 4-5). + // Item counts also arrive live via SMSG_QUESTUPDATE_ADD_ITEM; just initialise here. + for (int i = 0; i < 6; ++i) { + const auto& obj = quest.itemObjectives[i]; + if (obj.itemId == 0 || obj.required == 0) continue; + if (i < 2 && qStride >= 5) { + uint8_t cnt = counts[4 + i]; + if (cnt > 0) { + quest.itemCounts[obj.itemId] = std::max(quest.itemCounts[obj.itemId], static_cast(cnt)); + } + } + quest.requiredItemCounts.emplace(obj.itemId, obj.required); + } +} + void GameHandler::clearPendingQuestAccept(uint32_t questId) { pendingQuestAcceptTimeouts_.erase(questId); pendingQuestAcceptNpcGuids_.erase(questId); From e64b566d7292ce85db40faa06f212b09645dcf85 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:05:05 -0700 Subject: [PATCH 28/71] fix: correct TBC quest objective parsing and show creature names in quest log SMSG_QUEST_QUERY_RESPONSE uses 40 fixed uint32 fields + 4 strings for both Classic/Turtle and TBC, but the isClassicLayout flag was only set for stride-3 expansions (Classic/Turtle). TBC (stride 4) was incorrectly using the WotLK 55-field path, causing objective parsing to fail. - Extend isClassicLayout to cover stride <= 4 (includes TBC) - Refactor extractQuestQueryObjectives to try both layouts with fallback, matching the robustness of pickBestQuestQueryTexts - Pre-fetch creature/GO/item name queries when quest objectives are parsed so names are ready before the player opens the quest log - Quest log detail view: show creature names instead of raw entry IDs for kill objectives, and show required count (x/y) for item objectives --- src/game/game_handler.cpp | 59 ++++++++++++++++++++++++++++--------- src/ui/quest_log_screen.cpp | 9 ++++-- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 191efc37..5f4663e5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -384,30 +384,25 @@ static uint32_t readU32At(const std::vector& d, size_t pos) { | (static_cast(d[pos + 3]) << 24); } -QuestQueryObjectives extractQuestQueryObjectives(const std::vector& data, bool classicHint) { +// Try to parse objective block starting at `startPos` with `nStrings` strings before it. +// Returns a valid QuestQueryObjectives if the data looks plausible, otherwise invalid. +static QuestQueryObjectives tryParseQuestObjectivesAt(const std::vector& data, + size_t startPos, int nStrings) { QuestQueryObjectives out; - if (data.size() < 16) return out; - - const size_t base = 8; // questId(4) + questMethod(4) already at start - // Number of fixed uint32 fields before the first string (title). - const size_t fixedFields = classicHint ? 40u : 55u; - size_t pos = base + fixedFields * 4; - - // Number of strings before the objective data. - const int nStrings = classicHint ? 4 : 5; + size_t pos = startPos; // Scan past each string (null-terminated). for (int si = 0; si < nStrings; ++si) { while (pos < data.size() && data[pos] != 0) ++pos; - if (pos >= data.size()) return out; + if (pos >= data.size()) return out; // truncated ++pos; // consume null terminator } // Read 4 entity objectives: int32 npcOrGoId + uint32 count each. for (int i = 0; i < 4; ++i) { if (pos + 8 > data.size()) return out; - out.kills[i].npcOrGoId = static_cast(readU32At(data, pos)); pos += 4; - out.kills[i].required = readU32At(data, pos); pos += 4; + out.kills[i].npcOrGoId = static_cast(readU32At(data, pos)); pos += 4; + out.kills[i].required = readU32At(data, pos); pos += 4; } // Read 6 item objectives: uint32 itemId + uint32 count each. @@ -421,6 +416,28 @@ QuestQueryObjectives extractQuestQueryObjectives(const std::vector& dat return out; } +QuestQueryObjectives extractQuestQueryObjectives(const std::vector& data, bool classicHint) { + if (data.size() < 16) return {}; + + // questId(4) + questMethod(4) prefix before the fixed integer header. + const size_t base = 8; + // Classic/TBC: 40 fixed uint32 fields + 4 strings before objectives. + // WotLK: 55 fixed uint32 fields + 5 strings before objectives. + const size_t classicStart = base + 40u * 4u; + const size_t wotlkStart = base + 55u * 4u; + + // Try the expected layout first, then fall back to the other. + if (classicHint) { + auto r = tryParseQuestObjectivesAt(data, classicStart, 4); + if (r.valid) return r; + return tryParseQuestObjectivesAt(data, wotlkStart, 5); + } else { + auto r = tryParseQuestObjectivesAt(data, wotlkStart, 5); + if (r.valid) return r; + return tryParseQuestObjectivesAt(data, classicStart, 4); + } +} + } // namespace @@ -4315,7 +4332,9 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t questId = packet.readUInt32(); packet.readUInt32(); // questMethod - const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3; + // Classic/Turtle = stride 3, TBC = stride 4 — all use 40 fixed fields + 4 strings. + // WotLK = stride 5, uses 55 fixed fields + 5 strings. + const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); @@ -4355,6 +4374,18 @@ void GameHandler::handlePacket(network::Packet& packet) { // Now that we have the objective creature IDs, apply any packed kill // counts from the player update fields that arrived at login. applyPackedKillCountsFromFields(q); + // Pre-fetch creature/GO names and item info so objective display is + // populated by the time the player opens the quest log. + for (int i = 0; i < 4; ++i) { + int32_t id = objs.kills[i].npcOrGoId; + if (id == 0 || objs.kills[i].required == 0) continue; + if (id > 0) queryCreatureInfo(static_cast(id), 0); + else queryGameObjectInfo(static_cast(-id), 0); + } + for (int i = 0; i < 6; ++i) { + if (objs.items[i].itemId != 0 && objs.items[i].required != 0) + queryItemInfo(objs.items[i].itemId, 0); + } LOG_DEBUG("Quest ", questId, " objectives parsed: kills=[", objs.kills[0].npcOrGoId, "/", objs.kills[0].required, ", ", objs.kills[1].npcOrGoId, "/", objs.kills[1].required, ", ", diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 00fbd173..d524d0c1 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -379,14 +379,19 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Tracked Progress"); for (const auto& [entry, progress] : sel.killCounts) { - ImGui::BulletText("Kill %u: %u/%u", entry, progress.first, progress.second); + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) name = "Unknown (" + std::to_string(entry) + ")"; + ImGui::BulletText("%s: %u/%u", name.c_str(), progress.first, progress.second); } for (const auto& [itemId, count] : sel.itemCounts) { std::string itemLabel = "Item " + std::to_string(itemId); if (const auto* info = gameHandler.getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; } - ImGui::BulletText("%s: %u", itemLabel.c_str(), count); + uint32_t required = 1; + auto reqIt = sel.requiredItemCounts.find(itemId); + if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; + ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); } } From 12aa5e01b6933e7b330ba4536694345932d4eb4c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:13:09 -0700 Subject: [PATCH 29/71] fix: correct game-object quest objective handling and item count fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SMSG_QUESTUPDATE_ADD_KILL: use absolute value of npcOrGoId when looking up required count from killObjectives (negative values = game objects) - applyPackedKillCountsFromFields: same fix — use abs(npcOrGoId) as map key so GO objective counts are stored with the correct entry key - SMSG_QUESTUPDATE_ADD_ITEM: also match quests via itemObjectives when requiredItemCounts is not yet populated (race at quest accept time) - Quest log and minimap sidebar: fall back to GO name cache for entries that return empty from getCachedCreatureName (interact/loot objectives) --- src/game/game_handler.cpp | 34 +++++++++++++++++++++++++++++++--- src/ui/game_screen.cpp | 11 ++++++++--- src/ui/quest_log_screen.cpp | 5 +++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5f4663e5..ce8eb976 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4157,8 +4157,22 @@ void GameHandler::handlePacket(network::Packet& packet) { if (reqCount == 0) { auto it = quest.killCounts.find(entry); if (it != quest.killCounts.end()) reqCount = it->second.second; - if (reqCount == 0) reqCount = count; } + // Fall back to killObjectives (parsed from SMSG_QUEST_QUERY_RESPONSE). + // Note: npcOrGoId < 0 means game object; server always sends entry as uint32 + // in QUESTUPDATE_ADD_KILL regardless of type, so match by absolute value. + if (reqCount == 0) { + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId == 0 || obj.required == 0) continue; + uint32_t objEntry = static_cast( + obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); + if (objEntry == entry) { + reqCount = obj.required; + break; + } + } + } + if (reqCount == 0) reqCount = count; // last-resort: avoid 0/0 display quest.killCounts[entry] = {count, reqCount}; std::string creatureName = getCachedCreatureName(entry); @@ -4204,9 +4218,20 @@ void GameHandler::handlePacket(network::Packet& packet) { bool updatedAny = false; for (auto& quest : questLog_) { if (quest.complete) continue; - const bool tracksItem = + bool tracksItem = quest.requiredItemCounts.count(itemId) > 0 || quest.itemCounts.count(itemId) > 0; + // Also check itemObjectives parsed from SMSG_QUEST_QUERY_RESPONSE in case + // requiredItemCounts hasn't been populated yet (race during quest accept). + if (!tracksItem) { + for (const auto& obj : quest.itemObjectives) { + if (obj.itemId == itemId && obj.required > 0) { + quest.requiredItemCounts.emplace(itemId, obj.required); + tracksItem = true; + break; + } + } + } if (!tracksItem) continue; quest.itemCounts[itemId] = count; updatedAny = true; @@ -15076,7 +15101,10 @@ void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { for (int i = 0; i < 4; ++i) { const auto& obj = quest.killObjectives[i]; if (obj.npcOrGoId == 0 || obj.required == 0) continue; - const uint32_t entryKey = static_cast(obj.npcOrGoId); + // Negative npcOrGoId means game object; use absolute value as the map key + // (SMSG_QUESTUPDATE_ADD_KILL always sends a positive entry regardless of type). + const uint32_t entryKey = static_cast( + obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); // Don't overwrite live kill count with stale packed data if already non-zero. if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue; quest.killCounts[entryKey] = {counts[i], obj.required}; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 94b4deaa..663a031a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5048,10 +5048,15 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } else { // Kill counts for (const auto& [entry, progress] : q.killCounts) { - std::string creatureName = gameHandler.getCachedCreatureName(entry); - if (!creatureName.empty()) { + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + // May be a game object objective; fall back to GO name cache. + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } + if (!name.empty()) { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %s: %u/%u", creatureName.c_str(), + " %s: %u/%u", name.c_str(), progress.first, progress.second); } else { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index d524d0c1..8a9ddd55 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -380,6 +380,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Tracked Progress"); for (const auto& [entry, progress] : sel.killCounts) { std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + // Game object objective: fall back to GO name cache. + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } if (name.empty()) name = "Unknown (" + std::to_string(entry) + ")"; ImGui::BulletText("%s: %u/%u", name.c_str(), progress.first, progress.second); } From 6f5bdb2e91598c64251dba1b478e8d64305cd474 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:18:23 -0700 Subject: [PATCH 30/71] feat: implement WotLK quest POI query to show objective locations on minimap Send CMSG_QUEST_POI_QUERY alongside each CMSG_QUEST_QUERY (WotLK only, gated by questLogStride == 5 and opcode availability). Parse the response to extract POI region centroids and add them as GossipPoi markers so the existing minimap rendering shows quest objective locations as cyan diamonds. Each quest POI region is reduced to its centroid point; markers for the current map only are shown. This gives players visual guidance for where to go for active quests directly on the minimap. --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 946c66b0..f2a83f38 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1618,6 +1618,7 @@ private: void handleGossipMessage(network::Packet& packet); void handleQuestgiverQuestList(network::Packet& packet); void handleGossipComplete(network::Packet& packet); + void handleQuestPoiQueryResponse(network::Packet& packet); void handleQuestDetails(network::Packet& packet); void handleQuestRequestItems(network::Packet& packet); void handleQuestOfferReward(network::Packet& packet); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ce8eb976..4873cef2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5767,6 +5767,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: + handleQuestPoiQueryResponse(packet); + break; case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: @@ -14883,9 +14885,84 @@ bool GameHandler::requestQuestQuery(uint32_t questId, bool force) { pkt.writeUInt32(questId); socket->send(pkt); pendingQuestQueryIds_.insert(questId); + + // WotLK supports CMSG_QUEST_POI_QUERY to get objective map locations. + // Only send if the opcode is mapped (stride==5 means WotLK). + if (packetParsers_ && packetParsers_->questLogStride() == 5) { + const uint32_t wirePoiQuery = wireOpcode(Opcode::CMSG_QUEST_POI_QUERY); + if (wirePoiQuery != 0xFFFF) { + network::Packet poiPkt(static_cast(wirePoiQuery)); + poiPkt.writeUInt32(1); // count = 1 + poiPkt.writeUInt32(questId); + socket->send(poiPkt); + } + } return true; } +void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { + // WotLK 3.3.5a SMSG_QUEST_POI_QUERY_RESPONSE format: + // uint32 questCount + // per quest: + // uint32 questId + // uint32 poiCount + // per poi: + // uint32 poiId + // int32 objIndex (-1 = no specific objective) + // uint32 mapId + // uint32 areaId + // uint32 floorId + // uint32 unk1 + // uint32 unk2 + // uint32 pointCount + // per point: int32 x, int32 y + if (packet.getSize() - packet.getReadPos() < 4) return; + const uint32_t questCount = packet.readUInt32(); + for (uint32_t qi = 0; qi < questCount; ++qi) { + if (packet.getSize() - packet.getReadPos() < 8) return; + const uint32_t questId = packet.readUInt32(); + const uint32_t poiCount = packet.readUInt32(); + for (uint32_t pi = 0; pi < poiCount; ++pi) { + if (packet.getSize() - packet.getReadPos() < 28) return; + packet.readUInt32(); // poiId + packet.readUInt32(); // objIndex (int32) + const uint32_t mapId = packet.readUInt32(); + packet.readUInt32(); // areaId + packet.readUInt32(); // floorId + packet.readUInt32(); // unk1 + packet.readUInt32(); // unk2 + const uint32_t pointCount = packet.readUInt32(); + if (pointCount == 0) continue; + if (packet.getSize() - packet.getReadPos() < pointCount * 8) return; + // Compute centroid of the poi region to place a minimap marker. + float sumX = 0.0f, sumY = 0.0f; + for (uint32_t pt = 0; pt < pointCount; ++pt) { + const int32_t px = static_cast(packet.readUInt32()); + const int32_t py = static_cast(packet.readUInt32()); + sumX += static_cast(px); + sumY += static_cast(py); + } + // POI points in WotLK are zone-level coordinates. + // Skip POIs for maps other than the player's current map. + if (mapId != currentMapId_) continue; + // Find the quest title for the marker label. + std::string questTitle; + for (const auto& q : questLog_) { + if (q.questId == questId) { questTitle = q.title; break; } + } + // Add as a GossipPoi so the existing minimap code displays it. + GossipPoi poi; + poi.x = sumX / static_cast(pointCount); // WoW canonical X (north) + poi.y = sumY / static_cast(pointCount); // WoW canonical Y (west) + poi.icon = 6; // generic POI icon + poi.name = questTitle.empty() ? "Quest objective" : questTitle; + gossipPois_.push_back(std::move(poi)); + LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId, + " centroid=(", poi.x, ",", poi.y, ") title=", poi.name); + } + } +} + void GameHandler::handleQuestDetails(network::Packet& packet) { QuestDetailsData data; bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data) From ef0e171da563fb5777cb23d9129ef98cb34da94d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:20:31 -0700 Subject: [PATCH 31/71] fix: deduplicate quest POI markers on repeated queries and fix LOG_DEBUG ordering Remove existing POI markers for a quest before adding new ones (using the data field as questId tag) so repeated CMSG_QUEST_POI_QUERY calls don't accumulate duplicate markers. Also fix LOG_DEBUG to appear before the move. --- src/game/game_handler.cpp | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4873cef2..5ba1cf29 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -14922,6 +14922,23 @@ void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) return; const uint32_t questId = packet.readUInt32(); const uint32_t poiCount = packet.readUInt32(); + + // Remove any previously added POI markers for this quest to avoid duplicates + // on repeated queries (e.g. zone change or force-refresh). + gossipPois_.erase( + std::remove_if(gossipPois_.begin(), gossipPois_.end(), + [questId, this](const GossipPoi& p) { + // Match by questId embedded in data field (set below). + return p.data == questId; + }), + gossipPois_.end()); + + // Find the quest title for the marker label. + std::string questTitle; + for (const auto& q : questLog_) { + if (q.questId == questId) { questTitle = q.title; break; } + } + for (uint32_t pi = 0; pi < poiCount; ++pi) { if (packet.getSize() - packet.getReadPos() < 28) return; packet.readUInt32(); // poiId @@ -14942,23 +14959,18 @@ void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { sumX += static_cast(px); sumY += static_cast(py); } - // POI points in WotLK are zone-level coordinates. // Skip POIs for maps other than the player's current map. if (mapId != currentMapId_) continue; - // Find the quest title for the marker label. - std::string questTitle; - for (const auto& q : questLog_) { - if (q.questId == questId) { questTitle = q.title; break; } - } - // Add as a GossipPoi so the existing minimap code displays it. + // Add as a GossipPoi; use data field to carry questId for later dedup. GossipPoi poi; - poi.x = sumX / static_cast(pointCount); // WoW canonical X (north) - poi.y = sumY / static_cast(pointCount); // WoW canonical Y (west) - poi.icon = 6; // generic POI icon + poi.x = sumX / static_cast(pointCount); // WoW canonical X + poi.y = sumY / static_cast(pointCount); // WoW canonical Y + poi.icon = 6; // generic quest POI icon + poi.data = questId; // used for dedup on subsequent queries poi.name = questTitle.empty() ? "Quest objective" : questTitle; - gossipPois_.push_back(std::move(poi)); LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId, " centroid=(", poi.x, ",", poi.y, ") title=", poi.name); + gossipPois_.push_back(std::move(poi)); } } } From 2ee0934653c44e1f1f424606d20c01d091d843ec Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:21:33 -0700 Subject: [PATCH 32/71] fix: remove quest POI minimap markers when quest is abandoned When abandonQuest() removes a quest from the log, also remove any gossipPoi markers tagged with that questId (data field) so stale objective markers don't linger on the minimap. --- src/game/game_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5ba1cf29..afc54e80 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15318,6 +15318,12 @@ void GameHandler::abandonQuest(uint32_t questId) { if (localIndex >= 0) { questLog_.erase(questLog_.begin() + static_cast(localIndex)); } + + // Remove any quest POI minimap markers for this quest. + gossipPois_.erase( + std::remove_if(gossipPois_.begin(), gossipPois_.end(), + [questId](const GossipPoi& p) { return p.data == questId; }), + gossipPois_.end()); } void GameHandler::handleQuestRequestItems(network::Packet& packet) { From 72a16a24270cfb7a59dcc8f7980c79925ff76ce5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:24:35 -0700 Subject: [PATCH 33/71] fix: clear gossip/quest POI markers on map change (SMSG_NEW_WORLD) Quest POI markers are map-specific. Clearing gossipPois_ on world entry prevents stale markers from previous maps being displayed on the new map. Quest POIs will be re-fetched as the quest log re-queries on the new map. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index afc54e80..c67a5adf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16702,6 +16702,10 @@ void GameHandler::handleNewWorld(network::Packet& packet) { entityManager.clear(); hostileAttackers_.clear(); worldStates_.clear(); + // Quest POI markers are map-specific; remove those that don't apply to the new map. + // Markers without a questId tag (data==0) are gossip-window POIs — keep them cleared + // here since gossipWindowOpen is reset on teleport anyway. + gossipPois_.clear(); worldStateMapId_ = mapId; worldStateZoneId_ = 0; activeAreaTriggers_.clear(); From 77ce54833aec5d087317a3a331fd3487f961fc20 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:29:35 -0700 Subject: [PATCH 34/71] =?UTF-8?q?feat:=20add=20quest=20kill=20objective=20?= =?UTF-8?q?indicator=20(=E2=9A=94)=20to=20unit=20nameplates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yellow crossed-swords icon appears to the right of the unit name when the creature's entry is an incomplete kill objective in a tracked quest. Updated icon is suppressed once the kill count is satisfied. Uses unit->getEntry() (Unit subclass method) rather than the base Entity pointer, matching how questKillEntries keys are stored. --- src/ui/game_screen.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 663a031a..3bfa4b83 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5242,6 +5242,27 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { const uint64_t playerGuid = gameHandler.getPlayerGuid(); const uint64_t targetGuid = gameHandler.getTargetGuid(); + // Build set of creature entries that are kill objectives in active (incomplete) quests. + std::unordered_set questKillEntries; + { + const auto& questLog = gameHandler.getQuestLog(); + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& q : questLog) { + if (q.complete || q.questId == 0) continue; + // Only highlight for tracked quests (or all if nothing tracked). + if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId > 0 && obj.required > 0) { + // Check if not already completed. + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) { + questKillEntries.insert(static_cast(obj.npcOrGoId)); + } + } + } + } + } + ImDrawList* drawList = ImGui::GetBackgroundDrawList(); for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { @@ -5367,6 +5388,14 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym); drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym); } + + // Quest kill objective indicator: small yellow sword icon to the right of the name + if (!isPlayer && questKillEntries.count(unit->getEntry())) { + const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) + float objX = nameX + textSize.x + 4.0f; + drawList->AddText(ImVec2(objX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); + drawList->AddText(ImVec2(objX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + } } // Click to target: detect left-click inside the combined nameplate region From eaf827668a4d5734c38cf1082db59f6911c4bb4c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:34:23 -0700 Subject: [PATCH 35/71] fix: parse SMSG_QUESTUPDATE_ADD_PVP_KILL and update quest log kill counts Previously only displayed a chat message without updating the quest tracker. Now parses the full packet (guid+questId+count+reqCount), stores progress under entry-key 0 in killCounts, and shows a progress message matching the format used for creature kills. Handles both WotLK (4-field) and Classic (3-field, no reqCount) variants with fallback to the existing killCounts or killObjectives for reqCount. --- src/game/game_handler.cpp | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c67a5adf..c6ee7dbc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5606,15 +5606,44 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- PVP quest kill update ---- case Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL: { - // uint64 guid + uint32 questId + uint32 killCount + // WotLK 3.3.5a format: uint64 guid + uint32 questId + uint32 count + uint32 reqCount + // Classic format: uint64 guid + uint32 questId + uint32 count (no reqCount) if (packet.getSize() - packet.getReadPos() >= 16) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t questId = packet.readUInt32(); uint32_t count = packet.readUInt32(); - char buf[64]; - std::snprintf(buf, sizeof(buf), "PVP kill counted for quest #%u (%u).", - questId, count); - addSystemChatMessage(buf); + uint32_t reqCount = 0; + if (packet.getSize() - packet.getReadPos() >= 4) { + reqCount = packet.readUInt32(); + } + + // Update quest log kill counts (PvP kills use entry=0 as the key + // since there's no specific creature entry — one slot per quest). + constexpr uint32_t PVP_KILL_ENTRY = 0u; + for (auto& quest : questLog_) { + if (quest.questId != questId) continue; + + if (reqCount == 0) { + auto it = quest.killCounts.find(PVP_KILL_ENTRY); + if (it != quest.killCounts.end()) reqCount = it->second.second; + } + if (reqCount == 0) { + // Pull required count from kill objectives (npcOrGoId == 0 slot, if any) + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId == 0 && obj.required > 0) { + reqCount = obj.required; + break; + } + } + } + if (reqCount == 0) reqCount = count; + quest.killCounts[PVP_KILL_ENTRY] = {count, reqCount}; + + std::string progressMsg = quest.title + ": PvP kills " + + std::to_string(count) + "/" + std::to_string(reqCount); + addSystemChatMessage(progressMsg); + break; + } } break; } From 7c5d688c009596694eadd6e8ceb0094e6579ada7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:36:40 -0700 Subject: [PATCH 36/71] fix: show area name in SMSG_ZONE_UNDER_ATTACK system message Replace the raw area ID in the zone-under-attack message with the resolved area name from the zone manager, matching retail WoW behavior ('Hillsbrad Foothills is under attack!' instead of 'area 267'). --- src/game/game_handler.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c6ee7dbc..e6e234c4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2826,10 +2826,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 areaId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t areaId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "A zone is under attack! (area %u)", areaId); - addSystemChatMessage(buf); + std::string areaName = getAreaName(areaId); + std::string msg = areaName.empty() + ? std::string("A zone is under attack!") + : (areaName + " is under attack!"); + addSystemChatMessage(msg); } break; } From 06facc0060f775c827a525edd5362fbca991f27c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:44:07 -0700 Subject: [PATCH 37/71] feat: implement trade window UI with item slots and gold offering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously trade only showed an accept/decline popup with no way to actually offer items or gold. This commit adds the complete trade flow: Packets: - CMSG_SET_TRADE_ITEM (tradeSlot, bag, bagSlot) — add item to slot - CMSG_CLEAR_TRADE_ITEM (tradeSlot) — remove item from slot - CMSG_SET_TRADE_GOLD (uint64 copper) — set gold offered - CMSG_UNACCEPT_TRADE — unaccept without cancelling - SMSG_TRADE_STATUS_EXTENDED parser — updates trade slot/gold state State: - TradeSlot struct: itemId, displayId, stackCount, bag, bagSlot - myTradeSlots_/peerTradeSlots_ arrays (6 slots each) - myTradeGold_/peerTradeGold_ (copper) - resetTradeState() helper clears all state on cancel/complete/close UI (renderTradeWindow): - Two-column layout: my offer | peer offer - Each column shows 6 item slots with item names - Double-click own slot to remove; right-click empty slot to open backpack picker popup - Gold input field (copper, Enter to set) - Accept Trade / Cancel buttons - Window close button triggers cancel trade --- include/game/game_handler.hpp | 31 +++++++ include/game/world_packets.hpp | 27 ++++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 109 +++++++++++++++++++++++-- src/game/world_packets.cpp | 29 +++++++ src/ui/game_screen.cpp | 145 +++++++++++++++++++++++++++++++++ 6 files changed, 337 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f2a83f38..25b6b8de 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -933,13 +933,38 @@ public: enum class TradeStatus : uint8_t { None = 0, PendingIncoming, Open, Accepted, Complete }; + + static constexpr int TRADE_SLOT_COUNT = 6; // WoW has 6 normal trade slots + slot 6 for non-trade item + + struct TradeSlot { + uint32_t itemId = 0; + uint32_t displayId = 0; + uint32_t stackCount = 0; + uint64_t itemGuid = 0; + uint8_t bag = 0xFF; // 0xFF = not set + uint8_t bagSlot = 0xFF; + bool occupied = false; + }; + TradeStatus getTradeStatus() const { return tradeStatus_; } bool hasPendingTradeRequest() const { return tradeStatus_ == TradeStatus::PendingIncoming; } + bool isTradeOpen() const { return tradeStatus_ == TradeStatus::Open || tradeStatus_ == TradeStatus::Accepted; } const std::string& getTradePeerName() const { return tradePeerName_; } + + // My trade slots (what I'm offering) + const std::array& getMyTradeSlots() const { return myTradeSlots_; } + // Peer's trade slots (what they're offering) + const std::array& getPeerTradeSlots() const { return peerTradeSlots_; } + uint64_t getMyTradeGold() const { return myTradeGold_; } + uint64_t getPeerTradeGold() const { return peerTradeGold_; } + void acceptTradeRequest(); // respond to incoming SMSG_TRADE_STATUS(1) with CMSG_BEGIN_TRADE void declineTradeRequest(); // respond with CMSG_CANCEL_TRADE void acceptTrade(); // lock in offer: CMSG_ACCEPT_TRADE void cancelTrade(); // CMSG_CANCEL_TRADE + void setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot); + void clearTradeItem(uint8_t tradeSlot); + void setTradeGold(uint64_t copper); // ---- Duel ---- bool hasPendingDuelRequest() const { return pendingDuelRequest_; } @@ -1653,6 +1678,8 @@ private: void handleQuestConfirmAccept(network::Packet& packet); void handleSummonRequest(network::Packet& packet); void handleTradeStatus(network::Packet& packet); + void handleTradeStatusExtended(network::Packet& packet); + void resetTradeState(); void handleDuelRequested(network::Packet& packet); void handleDuelComplete(network::Packet& packet); void handleDuelWinner(network::Packet& packet); @@ -2077,6 +2104,10 @@ private: TradeStatus tradeStatus_ = TradeStatus::None; uint64_t tradePeerGuid_= 0; std::string tradePeerName_; + std::array myTradeSlots_{}; + std::array peerTradeSlots_{}; + uint64_t myTradeGold_ = 0; + uint64_t peerTradeGold_ = 0; // Duel state bool pendingDuelRequest_ = false; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 5d75e887..61d36ebf 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1356,6 +1356,33 @@ public: static network::Packet build(); }; +/** CMSG_SET_TRADE_ITEM packet builder (tradeSlot, bag, bagSlot) */ +class SetTradeItemPacket { +public: + // tradeSlot: 0-5 (normal) or 6 (backpack money-only slot) + // bag: 255 = main backpack, 19-22 = bag slots + // bagSlot: slot within bag + static network::Packet build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot); +}; + +/** CMSG_CLEAR_TRADE_ITEM packet builder (remove item from trade slot) */ +class ClearTradeItemPacket { +public: + static network::Packet build(uint8_t tradeSlot); +}; + +/** CMSG_SET_TRADE_GOLD packet builder (gold offered, in copper) */ +class SetTradeGoldPacket { +public: + static network::Packet build(uint64_t copper); +}; + +/** CMSG_UNACCEPT_TRADE packet builder (unaccept without cancelling) */ +class UnacceptTradePacket { +public: + static network::Packet build(); +}; + /** CMSG_ATTACKSWING packet builder */ class AttackSwingPacket { public: diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 8bd19235..655b20cb 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -224,6 +224,7 @@ private: void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); void renderTradeRequestPopup(game::GameHandler& gameHandler); + void renderTradeWindow(game::GameHandler& gameHandler); void renderSummonRequestPopup(game::GameHandler& gameHandler); void renderSharedQuestPopup(game::GameHandler& gameHandler); void renderItemTextWindow(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e6e234c4..af655890 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3102,9 +3102,11 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("Summon cancelled."); break; case Opcode::SMSG_TRADE_STATUS: - case Opcode::SMSG_TRADE_STATUS_EXTENDED: handleTradeStatus(packet); break; + case Opcode::SMSG_TRADE_STATUS_EXTENDED: + handleTradeStatusExtended(packet); + break; case Opcode::SMSG_LOOT_ROLL: handleLootRoll(packet); break; @@ -19047,13 +19049,17 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { break; } case 2: // OPEN_WINDOW + myTradeSlots_.fill(TradeSlot{}); + peerTradeSlots_.fill(TradeSlot{}); + myTradeGold_ = 0; + peerTradeGold_ = 0; tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); break; case 3: // CANCELLED case 9: // REJECTED case 12: // CLOSE_WINDOW - tradeStatus_ = TradeStatus::None; + resetTradeState(); addSystemChatMessage("Trade cancelled."); break; case 4: // ACCEPTED (partner accepted) @@ -19061,9 +19067,8 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { addSystemChatMessage("Trade accepted. Awaiting other player..."); break; case 8: // COMPLETE - tradeStatus_ = TradeStatus::Complete; addSystemChatMessage("Trade complete!"); - tradeStatus_ = TradeStatus::None; // reset after notification + resetTradeState(); break; case 7: // BACK_TO_TRADE (unaccepted after a change) tradeStatus_ = TradeStatus::Open; @@ -19102,10 +19107,104 @@ void GameHandler::acceptTrade() { void GameHandler::cancelTrade() { if (!socket) return; - tradeStatus_ = TradeStatus::None; + resetTradeState(); socket->send(CancelTradePacket::build()); } +void GameHandler::setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; + socket->send(SetTradeItemPacket::build(tradeSlot, bag, bagSlot)); +} + +void GameHandler::clearTradeItem(uint8_t tradeSlot) { + if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; + myTradeSlots_[tradeSlot] = TradeSlot{}; + socket->send(ClearTradeItemPacket::build(tradeSlot)); +} + +void GameHandler::setTradeGold(uint64_t copper) { + if (!isTradeOpen() || !socket) return; + myTradeGold_ = copper; + socket->send(SetTradeGoldPacket::build(copper)); +} + +void GameHandler::resetTradeState() { + tradeStatus_ = TradeStatus::None; + myTradeGold_ = 0; + peerTradeGold_ = 0; + myTradeSlots_.fill(TradeSlot{}); + peerTradeSlots_.fill(TradeSlot{}); +} + +void GameHandler::handleTradeStatusExtended(network::Packet& packet) { + // WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format: + // uint8 isSelfState (1 = my trade window, 0 = peer's) + // uint32 tradeId + // uint32 slotCount (7: 6 normal + 1 extra for enchanting) + // Per slot (up to slotCount): + // uint8 slotIndex + // uint32 itemId + // uint32 displayId + // uint32 stackCount + // uint8 isWrapped + // uint64 giftCreatorGuid + // uint32 enchantId (and several more enchant/stat fields) + // ... (complex; we parse only the essential fields) + // uint64 coins (gold offered by the sender of this message) + + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 9) return; + + uint8_t isSelf = packet.readUInt8(); + uint32_t tradeId = packet.readUInt32(); (void)tradeId; + uint32_t slotCount= packet.readUInt32(); + + auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_; + + for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) { + uint8_t slotIdx = packet.readUInt8(); + uint32_t itemId = packet.readUInt32(); + uint32_t displayId = packet.readUInt32(); + uint32_t stackCount = packet.readUInt32(); + + // isWrapped + giftCreatorGuid + several enchant fields — skip them all + // We need at least 1+8+4*5 = 29 bytes for the rest of this slot entry + bool isWrapped = false; + if (packet.getSize() - packet.getReadPos() >= 1) { + isWrapped = (packet.readUInt8() != 0); + } + // Skip giftCreatorGuid (8) + enchantId*5 (20) + suffixFactor (4) + randPropId (4) + lockId (4) + // + maxDurability (4) + durability (4) = 49 bytes + // Plus if wrapped: giftCreatorGuid already consumed; additional guid = 0 + constexpr size_t SLOT_TRAIL = 49; + if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { + packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); + } else { + packet.setReadPos(packet.getSize()); + return; + } + (void)isWrapped; + + if (slotIdx < TRADE_SLOT_COUNT) { + TradeSlot& s = slots[slotIdx]; + s.itemId = itemId; + s.displayId = displayId; + s.stackCount = stackCount; + s.occupied = (itemId != 0); + } + } + + // Gold offered (uint64 copper) + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t coins = packet.readUInt64(); + if (isSelf) myTradeGold_ = coins; + else peerTradeGold_ = coins; + } + + LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf, + " myGold=", myTradeGold_, " peerGold=", peerTradeGold_); +} + // --------------------------------------------------------------------------- // Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL) // --------------------------------------------------------------------------- diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c3adbcb0..d9388cd8 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2177,6 +2177,35 @@ network::Packet AcceptTradePacket::build() { return packet; } +network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + packet.writeUInt8(bag); + packet.writeUInt8(bagSlot); + LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", (int)tradeSlot, " bag=", (int)bag, " bagSlot=", (int)bagSlot); + return packet; +} + +network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", (int)tradeSlot); + return packet; +} + +network::Packet SetTradeGoldPacket::build(uint64_t copper) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD)); + packet.writeUInt64(copper); + LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper); + return packet; +} + +network::Packet UnacceptTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE)); + LOG_DEBUG("Built CMSG_UNACCEPT_TRADE"); + return packet; +} + network::Packet InitiateTradePacket::build(uint64_t targetGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE)); packet.writeUInt64(targetGuid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3bfa4b83..803e07d4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -414,6 +414,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDuelRequestPopup(gameHandler); renderLootRollPopup(gameHandler); renderTradeRequestPopup(gameHandler); + renderTradeWindow(gameHandler); renderSummonRequestPopup(gameHandler); renderSharedQuestPopup(gameHandler); renderItemTextWindow(gameHandler); @@ -5980,6 +5981,150 @@ void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isTradeOpen()) return; + + const auto& mySlots = gameHandler.getMyTradeSlots(); + const auto& peerSlots = gameHandler.getPeerTradeSlots(); + const uint64_t myGold = gameHandler.getMyTradeGold(); + const uint64_t peerGold = gameHandler.getPeerTradeGold(); + const auto& peerName = gameHandler.getTradePeerName(); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (ImGui::Begin(("Trade with " + peerName).c_str(), &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + + auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) { + uint64_t g = copper / 10000; + uint64_t s = (copper % 10000) / 100; + uint64_t c = copper % 100; + if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc", + (unsigned long long)g, (unsigned long long)s, (unsigned long long)c); + else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc", + (unsigned long long)s, (unsigned long long)c); + else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c); + }; + + auto renderSlotColumn = [&](const char* label, + const std::array& slots, + uint64_t gold, bool isMine) { + ImGui::Text("%s", label); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) { + const auto& slot = slots[i]; + ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100)); + + if (slot.occupied && slot.itemId != 0) { + const auto* info = gameHandler.getItemInfo(slot.itemId); + std::string name = (info && info->valid && !info->name.empty()) + ? info->name + : ("Item " + std::to_string(slot.itemId)); + if (slot.stackCount > 1) + name += " x" + std::to_string(slot.stackCount); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), " %d. %s", i + 1, name.c_str()); + + if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + gameHandler.clearTradeItem(static_cast(i)); + } + if (isMine && ImGui::IsItemHovered()) { + ImGui::SetTooltip("Double-click to remove"); + } + } else { + ImGui::TextDisabled(" %d. (empty)", i + 1); + + // Allow dragging inventory items into trade slots via right-click context menu + if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup(("##additem" + std::to_string(i)).c_str()); + } + } + + if (isMine) { + // Drag-from-inventory: show small popup listing bag items + if (ImGui::BeginPopup(("##additem" + std::to_string(i)).c_str())) { + ImGui::TextDisabled("Add from inventory:"); + const auto& inv = gameHandler.getInventory(); + // Backpack slots 0-15 (bag=255) + for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) { + const auto& slot = inv.getBackpackSlot(si); + if (slot.empty()) continue; + const auto* ii = gameHandler.getItemInfo(slot.item.itemId); + std::string iname = (ii && ii->valid && !ii->name.empty()) + ? ii->name + : (!slot.item.name.empty() ? slot.item.name + : ("Item " + std::to_string(slot.item.itemId))); + if (ImGui::Selectable(iname.c_str())) { + // bag=255 = main backpack + gameHandler.setTradeItem(static_cast(i), 255u, + static_cast(si)); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } + ImGui::PopID(); + } + + // Gold row + char gbuf[48]; + formatGold(gold, gbuf, sizeof(gbuf)); + ImGui::Spacing(); + if (isMine) { + ImGui::Text("Gold offered: %s", gbuf); + static char goldInput[32] = "0"; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput), + ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) { + uint64_t copper = std::strtoull(goldInput, nullptr, 10); + gameHandler.setTradeGold(copper); + } + ImGui::SameLine(); + ImGui::TextDisabled("(copper, Enter to set)"); + } else { + ImGui::Text("Gold offered: %s", gbuf); + } + }; + + // Two-column layout: my offer | peer offer + float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f; + ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true); + renderSlotColumn("Your offer", mySlots, myGold, true); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true); + renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false); + ImGui::EndChild(); + + // Buttons + ImGui::Spacing(); + ImGui::Separator(); + float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) { + gameHandler.acceptTrade(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(bw, 0))) { + gameHandler.cancelTrade(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.cancelTrade(); + } +} + void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (!gameHandler.hasPendingLootRoll()) return; From 170ff1597c4eb216506c2d5f4019cf5f3c10ad09 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 00:46:11 -0700 Subject: [PATCH 38/71] fix: prefetch item info for trade slot items in SMSG_TRADE_STATUS_EXTENDED After parsing the peer's trade window state, query item info for all occupied slots so item names display immediately rather than showing 'Item 12345' until the cache is populated on the next frame. --- src/game/game_handler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index af655890..47eaf0b4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -19201,6 +19201,11 @@ void GameHandler::handleTradeStatusExtended(network::Packet& packet) { else peerTradeGold_ = coins; } + // Prefetch item info for all occupied trade slots so names show immediately + for (const auto& s : slots) { + if (s.occupied && s.itemId != 0) queryItemInfo(s.itemId, 0); + } + LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf, " myGold=", myTradeGold_, " peerGold=", peerTradeGold_); } From 568c566e1a90ab573625add2c721121ef63d3c95 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 01:00:08 -0700 Subject: [PATCH 39/71] fix: correct quest offer reward parser and trade slot trail size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuestOfferRewardParser: replace 4-variant heuristic with 0..16 byte prefix scan × fixed/variable arrays (34 candidates total). AzerothCore WotLK 3.3.5a sends uint32 autoFinish + uint32 suggestedPlayers = 8 bytes before emoteCount; old uint8 read caused 3-byte misalignment, producing wrong item IDs and missing icons on quest reward windows. Scoring now strongly favours the 8-byte prefix and exact byte consumption. - Quest reward tooltip: delegate to InventoryScreen::renderItemTooltip() for full stats (armor, DPS, stats, bind type, etc.); show "Loading…" while item data is still fetching instead of showing nothing. - SMSG_TRADE_STATUS_EXTENDED: fix SLOT_TRAIL 49→52 bytes. AC 3.3.5a sends giftCreatorGuid(8) + 6 enchant slots(24) + randPropId(4) + suffixFactor(4) + durability(4) + maxDurability(4) + createPlayedTime(4) = 52 bytes after isWrapped; wrong skip misaligned all subsequent slots. --- src/game/game_handler.cpp | 12 ++--- src/game/world_packets.cpp | 90 +++++++++++++++++++++++--------------- src/ui/game_screen.cpp | 17 +++---- 3 files changed, 70 insertions(+), 49 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 47eaf0b4..9e1b9f68 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -19167,16 +19167,16 @@ void GameHandler::handleTradeStatusExtended(network::Packet& packet) { uint32_t displayId = packet.readUInt32(); uint32_t stackCount = packet.readUInt32(); - // isWrapped + giftCreatorGuid + several enchant fields — skip them all - // We need at least 1+8+4*5 = 29 bytes for the rest of this slot entry bool isWrapped = false; if (packet.getSize() - packet.getReadPos() >= 1) { isWrapped = (packet.readUInt8() != 0); } - // Skip giftCreatorGuid (8) + enchantId*5 (20) + suffixFactor (4) + randPropId (4) + lockId (4) - // + maxDurability (4) + durability (4) = 49 bytes - // Plus if wrapped: giftCreatorGuid already consumed; additional guid = 0 - constexpr size_t SLOT_TRAIL = 49; + // AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped: + // giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12) + // + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24] + // + randomPropertyId (4) + suffixFactor (4) + // + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes + constexpr size_t SLOT_TRAIL = 52; if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); } else { diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d9388cd8..545f2f70 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3666,11 +3666,19 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.title = normalizeWowTextTokens(packet.readString()); data.rewardText = normalizeWowTextTokens(packet.readString()); - if (packet.getReadPos() + 10 > packet.getSize()) { + if (packet.getReadPos() + 8 > packet.getSize()) { LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); return true; } + // After the two strings the packet contains a variable prefix (autoFinish + optional fields) + // before the emoteCount. Different expansions and server emulator versions differ: + // Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes + // TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays) + // WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays) + // Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix). + // We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each. + struct ParsedTail { uint32_t rewardMoney = 0; uint32_t rewardXp = 0; @@ -3678,28 +3686,27 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData std::vector fixedRewards; bool ok = false; int score = -1000; + size_t prefixSkip = 0; + bool fixedArrays = false; }; - auto parseTail = [&](size_t startPos, bool hasFlags, bool fixedArrays) -> ParsedTail { + auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail { ParsedTail out; + out.prefixSkip = prefixSkip; + out.fixedArrays = fixedArrays; packet.setReadPos(startPos); - if (packet.getReadPos() + 1 > packet.getSize()) return out; - /*autoFinish*/ packet.readUInt8(); - if (hasFlags) { - if (packet.getReadPos() + 4 > packet.getSize()) return out; - /*flags*/ packet.readUInt32(); - } - if (packet.getReadPos() + 4 > packet.getSize()) return out; - /*suggestedPlayers*/ packet.readUInt32(); + // Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount) + if (packet.getReadPos() + prefixSkip > packet.getSize()) return out; + packet.setReadPos(packet.getReadPos() + prefixSkip); if (packet.getReadPos() + 4 > packet.getSize()) return out; uint32_t emoteCount = packet.readUInt32(); - if (emoteCount > 64) return out; // guard against misalignment + if (emoteCount > 32) return out; // guard against misalignment for (uint32_t i = 0; i < emoteCount; ++i) { if (packet.getReadPos() + 8 > packet.getSize()) return out; packet.readUInt32(); // delay - packet.readUInt32(); // emote + packet.readUInt32(); // emote type } if (packet.getReadPos() + 4 > packet.getSize()) return out; @@ -3717,7 +3724,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData item.choiceSlot = i; if (item.itemId > 0) { out.choiceRewards.push_back(item); - nonZeroChoice++; + ++nonZeroChoice; } } @@ -3735,7 +3742,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData item.displayInfoId = packet.readUInt32(); if (item.itemId > 0) { out.fixedRewards.push_back(item); - nonZeroFixed++; + ++nonZeroFixed; } } @@ -3746,43 +3753,56 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData out.ok = true; out.score = 0; - if (hasFlags) out.score += 1; - if (fixedArrays) out.score += 1; + // Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers) + if (prefixSkip == 8) out.score += 3; + else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers + // Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots) + if (fixedArrays) out.score += 2; + // Valid counts if (choiceCount <= 6) out.score += 3; if (rewardCount <= 4) out.score += 3; - if (fixedArrays) { - if (nonZeroChoice <= choiceCount) out.score += 3; - if (nonZeroFixed <= rewardCount) out.score += 3; - } else { - out.score += 3; // variable arrays align naturally with count - } - if (packet.getReadPos() <= packet.getSize()) out.score += 2; + // All non-zero items are within declared counts + if (nonZeroChoice <= choiceCount) out.score += 2; + if (nonZeroFixed <= rewardCount) out.score += 2; + // No bytes left over (or only a few) size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining <= 32) out.score += 2; + if (remaining == 0) out.score += 5; + else if (remaining <= 4) out.score += 3; + else if (remaining <= 8) out.score += 2; + else if (remaining <= 16) out.score += 1; + else out.score -= static_cast(remaining / 4); + // Plausible money/XP values + if (out.rewardMoney < 5000000u) out.score += 1; // < 500g + if (out.rewardXp < 200000u) out.score += 1; // < 200k XP return out; }; size_t tailStart = packet.getReadPos(); - ParsedTail a = parseTail(tailStart, true, true); // WotLK-like (flags + fixed 6/4 arrays) - ParsedTail b = parseTail(tailStart, false, true); // no flags + fixed 6/4 arrays - ParsedTail c = parseTail(tailStart, true, false); // flags + variable arrays - ParsedTail d = parseTail(tailStart, false, false); // classic-like variable arrays + // Try prefix sizes 0..16 bytes with both fixed and variable array layouts + std::vector candidates; + candidates.reserve(34); + for (size_t skip = 0; skip <= 16; ++skip) { + candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays + candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays + } const ParsedTail* best = nullptr; - for (const ParsedTail* cand : {&a, &b, &c, &d}) { - if (!cand->ok) continue; - if (!best || cand->score > best->score) best = cand; + for (const auto& cand : candidates) { + if (!cand.ok) continue; + if (!best || cand.score > best->score) best = &cand; } if (best) { data.choiceRewards = best->choiceRewards; - data.fixedRewards = best->fixedRewards; - data.rewardMoney = best->rewardMoney; - data.rewardXp = best->rewardXp; + data.fixedRewards = best->fixedRewards; + data.rewardMoney = best->rewardMoney; + data.rewardXp = best->rewardXp; } LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, - "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size()); + "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(), + " prefix=", (best ? best->prefixSkip : size_t(0)), + (best && best->fixedArrays ? " fixed" : " var")); return true; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 803e07d4..e1e064fc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7530,15 +7530,16 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { return {iconTex, col}; }; - // Helper: show item tooltip - auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 nameCol) { + // Helper: show full item tooltip (reuses InventoryScreen's rich tooltip) + auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 /*nameCol*/) { auto* info = gameHandler.getItemInfo(ri.itemId); - if (!info || !info->valid) return; - ImGui::BeginTooltip(); - ImGui::TextColored(nameCol, "%s", info->name.c_str()); - if (!info->description.empty()) - ImGui::TextWrapped("%s", info->description.c_str()); - ImGui::EndTooltip(); + if (!info || !info->valid) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Loading item data..."); + ImGui::EndTooltip(); + return; + } + inventoryScreen.renderItemTooltip(*info); }; if (!quest.choiceRewards.empty()) { From f462db6bfa83d27d3a6a08cb7602015c8b8a62e4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 01:29:56 -0700 Subject: [PATCH 40/71] fix: terrain descriptor pool leak, minimap quest markers, and item comparison UI - terrain_renderer: add FREE_DESCRIPTOR_SET_BIT flag and vkFreeDescriptorSets in destroyChunkGPU so material descriptor sets are returned to the pool; prevents GPU device lost from pool exhaustion near populated areas - game_screen: fix projectToMinimap to use the exact inverse of the minimap shader transform so quest objective markers appear at the correct position and orientation regardless of camera bearing - inventory_screen: fix item comparison tooltip to not compare equipped items against themselves (character screen); add item level diff line; show (=) indicator when stats are equal rather than bare value which looked identical to the item's own tooltip --- src/rendering/terrain_renderer.cpp | 5 +++++ src/ui/game_screen.cpp | 11 +++++++---- src/ui/inventory_screen.cpp | 22 ++++++++++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 4e8593f5..3af644cf 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -89,6 +89,7 @@ bool TerrainRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameL VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_MATERIAL_SETS; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; @@ -1034,6 +1035,10 @@ void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { destroyBuffer(allocator, ab); chunk.paramsUBO = VK_NULL_HANDLE; } + // Return material descriptor set to the pool so it can be reused by new chunks + if (chunk.materialSet && materialDescPool) { + vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &chunk.materialSet); + } chunk.materialSet = VK_NULL_HANDLE; // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e1e064fc..71681c84 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9406,10 +9406,13 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float dx = worldRenderPos.x - playerRender.x; float dy = worldRenderPos.y - playerRender.y; - // Match minimap shader transform exactly. - // Render axes: +X=west, +Y=north. Minimap screen axes: +X=right(east), +Y=down(south). - float rx = -dx * cosB + dy * sinB; - float ry = -dx * sinB - dy * cosB; + // Exact inverse of minimap display shader: + // shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2 + // where rotated = R(bearing) * center, center in [-0.5, 0.5] + // Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2) + // With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south): + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; // Scale to minimap pixels float px = rx / viewRadius * mapRadius; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8ceb64a5..0bb2c8c3 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1722,7 +1722,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } if (ImGui::IsItemHovered() && !holdingItem) { - renderItemTooltip(item, &inventory); + // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise + const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; + renderItemTooltip(item, tooltipInv); } } } @@ -1968,6 +1970,22 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + // Item level comparison (always shown when different) + if (eq->item.itemLevel > 0 || item.itemLevel > 0) { + char ilvlBuf[64]; + float diff = static_cast(item.itemLevel) - static_cast(eq->item.itemLevel); + if (diff > 0.0f) + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", item.itemLevel, diff); + else if (diff < 0.0f) + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", item.itemLevel, -diff); + else + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", item.itemLevel); + ImVec4 ilvlColor = (diff > 0.0f) ? ImVec4(0.0f, 1.0f, 0.0f, 1.0f) + : (diff < 0.0f) ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) + : ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + ImGui::TextColored(ilvlColor, "%s", ilvlBuf); + } + // Helper: render a numeric stat diff line auto showDiff = [](const char* label, float newVal, float eqVal) { if (newVal == 0.0f && eqVal == 0.0f) return; @@ -1980,7 +1998,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, newVal, -diff); ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", buf); } else { - std::snprintf(buf, sizeof(buf), "%s: %.0f", label, newVal); + std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, newVal); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf); } }; From ce3617100017fff1986c8cbb139ab991beb5d78c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 01:35:37 -0700 Subject: [PATCH 41/71] fix: prefer variationIndex=0 run animation and silence spurious compositeWithRegions warns - character_renderer: playAnimation now prefers the primary variation (variationIndex==0) when multiple sequences share the same animation ID; this fixes hitching on human female run where a variation sequence was selected first before the base cycle - character_renderer: move the compositeWithRegions size-mismatch warning inside the else branch so it only fires when sizes genuinely don't match, not for every successful 1:1 or scaled blit --- src/rendering/character_renderer.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 5683af91..82e4ff89 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1327,12 +1327,12 @@ VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath, blitOverlay(composite, width, height, overlay, dstX, dstY); } } else { + // Size mismatch — blit at natural size (may clip or leave gap) + core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx, + " at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height, + " expected=", expectedW, "x", expectedH, " from ", rl.second); blitOverlay(composite, width, height, overlay, dstX, dstY); } - - core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx, - " at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height, - " expected=", expectedW, "x", expectedH, " from ", rl.second); } // Upload to GPU via VkTexture @@ -1580,12 +1580,20 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, instance.animationTime = 0.0f; instance.animationLoop = loop; + // Prefer variationIndex==0 (primary animation); fall back to first match + int firstMatch = -1; for (size_t i = 0; i < model.sequences.size(); i++) { if (model.sequences[i].id == animationId) { - instance.currentSequenceIndex = static_cast(i); - break; + if (firstMatch < 0) firstMatch = static_cast(i); + if (model.sequences[i].variationIndex == 0) { + instance.currentSequenceIndex = static_cast(i); + break; + } } } + if (instance.currentSequenceIndex < 0 && firstMatch >= 0) { + instance.currentSequenceIndex = firstMatch; + } if (instance.currentSequenceIndex < 0) { // Fall back to first sequence From 43937984097c0e42234d1cdd013691c27c9f0106 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 01:40:33 -0700 Subject: [PATCH 42/71] fix: parse WotLK areaId field in SMSG_INIT_WORLD_STATES to fix truncation warning --- src/game/game_handler.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9e1b9f68..0145dec5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3259,13 +3259,20 @@ void GameHandler::handlePacket(network::Packet& packet) { // Silently ignore common packets we don't handle yet case Opcode::SMSG_INIT_WORLD_STATES: { - // Minimal parse: uint32 mapId, uint32 zoneId, uint16 count, repeated (uint32 key, uint32 val) + // WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val) + // Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(uint32 key, uint32 val) if (packet.getSize() - packet.getReadPos() < 10) { LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes"); break; } worldStateMapId_ = packet.readUInt32(); worldStateZoneId_ = packet.readUInt32(); + // WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent + size_t remaining = packet.getSize() - packet.getReadPos(); + bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle"); + if (isWotLKFormat && remaining >= 6) { + packet.readUInt32(); // areaId (WotLK only) + } uint16_t count = packet.readUInt16(); size_t needed = static_cast(count) * 8; size_t available = packet.getSize() - packet.getReadPos(); From f0430777463a29cec51450c5ea3f1309a6382e2c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 01:44:12 -0700 Subject: [PATCH 43/71] fix: free WMO and M2 material descriptor sets on group/model destroy to prevent pool exhaustion --- src/rendering/m2_renderer.cpp | 3 ++- src/rendering/wmo_renderer.cpp | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 48b2d346..4a30274e 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -743,9 +743,10 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { VmaAllocator alloc = vkCtx_->getAllocator(); if (model.vertexBuffer) { vmaDestroyBuffer(alloc, model.vertexBuffer, model.vertexAlloc); model.vertexBuffer = VK_NULL_HANDLE; } if (model.indexBuffer) { vmaDestroyBuffer(alloc, model.indexBuffer, model.indexAlloc); model.indexBuffer = VK_NULL_HANDLE; } + VkDevice device = vkCtx_->getDevice(); for (auto& batch : model.batches) { + if (batch.materialSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &batch.materialSet); batch.materialSet = VK_NULL_HANDLE; } if (batch.materialUBO) { vmaDestroyBuffer(alloc, batch.materialUBO, batch.materialUBOAlloc); batch.materialUBO = VK_NULL_HANDLE; } - // materialSet freed when pool is reset/destroyed } } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 85f56431..84c7f956 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -124,6 +124,7 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_MATERIAL_SETS; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; @@ -1946,8 +1947,13 @@ void WMORenderer::destroyGroupGPU(GroupResources& group) { group.indexAlloc = VK_NULL_HANDLE; } - // Destroy material UBOs (descriptor sets are freed when pool is reset/destroyed) + // Destroy material UBOs and free descriptor sets back to pool + VkDevice device = vkCtx_->getDevice(); for (auto& mb : group.mergedBatches) { + if (mb.materialSet) { + vkFreeDescriptorSets(device, materialDescPool_, 1, &mb.materialSet); + mb.materialSet = VK_NULL_HANDLE; + } if (mb.materialUBO) { vmaDestroyBuffer(allocator, mb.materialUBO, mb.materialUBOAlloc); mb.materialUBO = VK_NULL_HANDLE; From 570465f51abce054671d508f3e933ca5da6f5ba1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 01:48:18 -0700 Subject: [PATCH 44/71] fix: handle MSG_SET_DUNGEON_DIFFICULTY and suppress SMSG_LEARNED_DANCE_MOVES warnings --- src/game/game_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0145dec5..b48c576e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2006,6 +2006,11 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } + case Opcode::SMSG_LEARNED_DANCE_MOVES: + // Contains bitmask of learned dance moves — cosmetic only, no gameplay effect. + LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")"); + break; + // ---- Hearthstone binding ---- case Opcode::SMSG_PLAYERBOUND: { // uint64 binderGuid + uint32 mapId + uint32 zoneId @@ -4530,6 +4535,7 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Battleground player left"); break; case Opcode::SMSG_INSTANCE_DIFFICULTY: + case Opcode::MSG_SET_DUNGEON_DIFFICULTY: handleInstanceDifficulty(packet); break; case Opcode::SMSG_INSTANCE_SAVE_CREATED: From 7c77c4a81e5cebfc4bad2ed18b0d1bdbd30e5146 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:01:23 -0700 Subject: [PATCH 45/71] Fix per-frame particle descriptor set leak in M2 renderer Pre-allocate one stable VkDescriptorSet per particle emitter at model upload time (particleTexSets[]) instead of allocating a new set from materialDescPool_ every frame for each particle group. The per-frame path exhausted the 8192-set pool in ~14 s at 60 fps with 10 active particle emitters, causing GPU device-lost crashes. The old path is kept as an explicit fallback but should never be reached in practice. --- include/rendering/m2_renderer.hpp | 3 +- src/rendering/m2_renderer.cpp | 74 ++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index e26583b5..3d79379f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -127,7 +127,8 @@ struct M2ModelGPU { // Particle emitter data (kept from M2Model) std::vector particleEmitters; - std::vector particleTextures; // Resolved Vulkan textures per emitter + std::vector particleTextures; // Resolved Vulkan textures per emitter + std::vector particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc) // Texture transform data for UV animation std::vector textureTransforms; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 4a30274e..b9a52c3e 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -748,6 +748,11 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { if (batch.materialSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &batch.materialSet); batch.materialSet = VK_NULL_HANDLE; } if (batch.materialUBO) { vmaDestroyBuffer(alloc, batch.materialUBO, batch.materialUBOAlloc); batch.materialUBO = VK_NULL_HANDLE; } } + // Free pre-allocated particle texture descriptor sets + for (auto& pSet : model.particleTexSets) { + if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; } + } + model.particleTexSets.clear(); } void M2Renderer::destroyInstanceBones(M2Instance& inst) { @@ -1349,6 +1354,31 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Pre-allocate one stable descriptor set per particle emitter to avoid per-frame allocation. + // This prevents materialDescPool_ exhaustion when many emitters are active each frame. + if (particleTexLayout_ && materialDescPool_ && !model.particleEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.particleTexSets.resize(model.particleEmitters.size(), VK_NULL_HANDLE); + for (size_t ei = 0; ei < model.particleEmitters.size(); ei++) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.particleTexSets[ei]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.particleTextures[ei]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.particleTexSets[ei]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; @@ -3415,6 +3445,7 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame uint8_t blendType; uint16_t tilesX; uint16_t tilesY; + VkDescriptorSet preAllocSet = VK_NULL_HANDLE; // Pre-allocated stable set, avoids per-frame alloc std::vector vertexData; // 9 floats per particle }; std::unordered_map groups; @@ -3456,6 +3487,11 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame group.blendType = em.blendingType; group.tilesX = tilesX; group.tilesY = tilesY; + // Capture pre-allocated descriptor set on first insertion for this key + if (group.preAllocSet == VK_NULL_HANDLE && + p.emitterIndex < static_cast(gpu.particleTexSets.size())) { + group.preAllocSet = gpu.particleTexSets[p.emitterIndex]; + } group.vertexData.push_back(p.position.x); group.vertexData.push_back(p.position.y); @@ -3499,23 +3535,27 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame currentPipeline = desiredPipeline; } - // Allocate descriptor set for this group's texture - VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; - ai.descriptorPool = materialDescPool_; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &particleTexLayout_; - VkDescriptorSet texSet = VK_NULL_HANDLE; - if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { - VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); - VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = texSet; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &imgInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); - + // Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable + VkDescriptorSet texSet = group.preAllocSet; + if (texSet == VK_NULL_HANDLE) { + // Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice) + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { + VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = texSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + if (texSet != VK_NULL_HANDLE) { vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); } From a67feb6d93f88f93c5d1e5a0a2699e425196ad11 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:08:38 -0700 Subject: [PATCH 46/71] Fix handleInstanceDifficulty to handle variable-length packet formats MSG_SET_DUNGEON_DIFFICULTY sends 4 or 12 bytes (difficulty + optional isInGroup + savedBool) while SMSG_INSTANCE_DIFFICULTY sends 8 bytes (difficulty + heroic). The previous guard of < 8 caused the handler to silently return for 4-byte variants, leaving instanceDifficulty_ unchanged. Now reads as much as available and infers heroic flag from the field count. --- src/game/game_handler.cpp | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b48c576e..525f2aba 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12266,10 +12266,27 @@ void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { } void GameHandler::handleInstanceDifficulty(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 8) return; + // SMSG_INSTANCE_DIFFICULTY: uint32 difficulty, uint32 heroic (8 bytes) + // MSG_SET_DUNGEON_DIFFICULTY: uint32 difficulty[, uint32 isInGroup, uint32 savedBool] (4 or 12 bytes) + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 4) return; instanceDifficulty_ = packet.readUInt32(); - uint32_t isHeroic = packet.readUInt32(); - instanceIsHeroic_ = (isHeroic != 0); + if (rem() >= 4) { + uint32_t secondField = packet.readUInt32(); + // SMSG_INSTANCE_DIFFICULTY: second field is heroic flag (0 or 1) + // MSG_SET_DUNGEON_DIFFICULTY: second field is isInGroup (not heroic) + // Heroic = difficulty value 1 for 5-man, so use the field value for SMSG and + // infer from difficulty for MSG variant (which has larger payloads). + if (rem() >= 4) { + // Three+ fields: this is MSG_SET_DUNGEON_DIFFICULTY; heroic = (difficulty == 1) + instanceIsHeroic_ = (instanceDifficulty_ == 1); + } else { + // Two fields: SMSG_INSTANCE_DIFFICULTY format + instanceIsHeroic_ = (secondField != 0); + } + } else { + instanceIsHeroic_ = (instanceDifficulty_ == 1); + } LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); } From c4b2089d3107d9fbfb8c47225ea733726fe8164b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:25:42 -0700 Subject: [PATCH 47/71] fix: handle OBS_MOD_POWER and PERIODIC_ENERGIZE aura types in SMSG_PERIODICAURALOG Add PERIODIC_ENERGIZE (91) and OBS_MOD_POWER (46) handling so mana/energy/rage restore ticks from common WotLK auras (Replenishment, Mana Spring Totem, Divine Plea, etc.) appear as ENERGIZE in floating combat text. Also handle PERIODIC_MANA_LEECH (98) to properly consume its 12 bytes instead of halting mid-event parse. --- src/game/game_handler.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 525f2aba..0fd1afd8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3438,6 +3438,25 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t over=*/ packet.readUInt32(); addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), spellId, isPlayerCaster); + } else if (auraType == 46 || auraType == 91) { + // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount + // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. + if (packet.getSize() - packet.getReadPos() < 8) break; + /*uint32_t powerType =*/ packet.readUInt32(); + uint32_t amount = packet.readUInt32(); + if ((isPlayerVictim || isPlayerCaster) && amount > 0) + addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), + spellId, isPlayerCaster); + } else if (auraType == 98) { + // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier + if (packet.getSize() - packet.getReadPos() < 12) break; + /*uint32_t powerType =*/ packet.readUInt32(); + uint32_t amount = packet.readUInt32(); + /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) + // Show as periodic damage from victim's perspective (mana drained) + if (isPlayerVictim && amount > 0) + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), + spellId, false); } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); From 3082df2ac0923ec54e93fd916c6a1ca34de21b1b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:27:57 -0700 Subject: [PATCH 48/71] fix: use packed guids in SMSG_SPELLDAMAGESHIELD for WotLK and read absorbed field WotLK 3.3.5a format uses packed guids (not full uint64) for victim and caster, and adds an absorbed(4) field before schoolMask. Classic/TBC use full uint64 guids. Previously the handler always read full uint64 guids, causing misparse on WotLK (e.g. Thorns and Shield Spike damage shield combat text was garbled/wrong). --- src/game/game_handler.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0fd1afd8..28ac2ae8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5129,14 +5129,24 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_AURACASTLOG: case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { - // victimGuid(8) + casterGuid(8) + spellId(4) + damage(4) + schoolMask(4) - if (packet.getSize() - packet.getReadPos() < 24) { + // Classic/TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) + // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) + const bool shieldClassicLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const size_t shieldMinSz = shieldClassicLike ? 24u : 2u; + if (packet.getSize() - packet.getReadPos() < shieldMinSz) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = shieldClassicLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = shieldClassicLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = packet.readUInt64(); - uint64_t casterGuid = packet.readUInt64(); /*uint32_t spellId =*/ packet.readUInt32(); uint32_t damage = packet.readUInt32(); + if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) + /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect if (casterGuid == playerGuid) { From 643d48ee89718468e0f48bd185283ab57cdcf3de Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:36:55 -0700 Subject: [PATCH 49/71] fix: show reason-specific messages for SMSG_TRANSFER_ABORTED Replace generic 'Transfer aborted' message with WotLK TRANSFER_ABORT_* reason codes: difficulty, expansion required, instance full, too many instances, zone in combat, etc. --- src/game/game_handler.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 28ac2ae8..a6d9f803 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4495,7 +4495,21 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t mapId = packet.readUInt32(); uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason); - addSystemChatMessage("Transfer aborted."); + // Provide reason-specific feedback (WotLK TRANSFER_ABORT_* codes) + const char* abortMsg = nullptr; + switch (reason) { + case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break; + case 0x02: abortMsg = "Transfer aborted: expansion required."; break; + case 0x03: abortMsg = "Transfer aborted: instance not found."; break; + case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break; + case 0x06: abortMsg = "Transfer aborted: instance is full."; break; + case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break; + case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break; + case 0x09: abortMsg = "Transfer aborted: not enough players."; break; + case 0x0C: abortMsg = "Transfer aborted."; break; + default: abortMsg = "Transfer aborted."; break; + } + addSystemChatMessage(abortMsg); break; } From 21c55ad6b43b9b969a8ca7db585a17f1a66af7bf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:39:25 -0700 Subject: [PATCH 50/71] fix: detect melee abilities via spell school mask instead of hardcoded spell ID list Replace the brittle warrior-only hardcoded spell ID list for melee ability detection with a DBC-driven check: physical school mask (1) from spellNameCache_ covers warrior, rogue, DK, paladin, feral druid, and all other physical-school instant abilities generically. Instant detection: spellId != currentCastSpellId. --- src/game/game_handler.cpp | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a6d9f803..c2275945 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13831,21 +13831,21 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } // Instant melee abilities → trigger attack animation + // Detect via physical school mask (1 = Physical) from the spell DBC cache. + // This covers warrior, rogue, DK, paladin, feral druid, and hunter melee + // abilities generically instead of maintaining a brittle per-spell-ID list. uint32_t sid = data.spellId; - bool isMeleeAbility = - sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike ranks - sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 || - sid == 25286 || sid == 29707 || sid == 30324 || - sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend ranks - sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 || - sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge ranks - sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 || - sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave ranks - sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 || - sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike ranks - sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 || - sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam ranks - sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488; + bool isMeleeAbility = false; + { + loadSpellNameCache(); + auto cacheIt = spellNameCache_.find(sid); + if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + // Physical school — treat as instant melee ability if cast time is zero. + // We don't store cast time in the cache; use the fact that if we were not + // in a cast (casting == true with this spellId) then it was instant. + isMeleeAbility = (currentCastSpellId != sid); + } + } if (isMeleeAbility && meleeSwingCallback_) { meleeSwingCallback_(); } From d696da92272d8d1f0f1bf38f8789bc5601488323 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:40:27 -0700 Subject: [PATCH 51/71] fix: also use school mask for pre-cast melee range/facing check in castSpell() Same DBC-driven physical school detection replaces the brittle hardcoded warrior spell list in the pre-cast range check, so rogue, DK, paladin, feral druid, and hunter melee abilities get correct range/facing enforcement. --- src/game/game_handler.cpp | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c2275945..10b325e8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13536,22 +13536,16 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { } // Instant melee abilities: client-side range + facing check to avoid server "not in front" errors + // Detected via physical school mask (1) from DBC cache — covers warrior, rogue, DK, paladin, + // feral druid, and hunter melee abilities generically. { - uint32_t sid = spellId; - bool isMeleeAbility = - sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike - sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 || - sid == 25286 || sid == 29707 || sid == 30324 || - sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend - sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 || - sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge - sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 || - sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave - sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 || - sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike - sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 || - sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam - sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488; + loadSpellNameCache(); + bool isMeleeAbility = false; + auto cacheIt = spellNameCache_.find(spellId); + if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + // Physical school and no cast time (instant) — treat as melee ability + isMeleeAbility = true; + } if (isMeleeAbility && target != 0) { auto entity = entityManager.getEntity(target); if (entity) { From 1f4880985bde442859f398efab6ee4d42e2506ef Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:47:15 -0700 Subject: [PATCH 52/71] fix: correct SMSG_SPELLLOGMISS packet format for all expansions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All expansions send spellId(4) before the caster guid — the previous handler was missing this field entirely, causing the caster guid read to consume spellId bytes and corrupt all subsequent parsing. Additionally, in WotLK mode, victim guids inside the per-miss loop are packed guids (not full uint64), matching the caster guid format. Also handle the REFLECT (missInfo=11) extra payload in WotLK: the server appends reflectSpellId(4) + reflectResult(1) for reflected spells, which previously caused the following loop entries to be mis-parsed. --- src/game/game_handler.cpp | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 10b325e8..071a6e72 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2378,26 +2378,42 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { - // WotLK: packed_guid caster + packed_guid target + uint8 isCrit + uint32 count - // TBC/Classic: full uint64 caster + full uint64 target + uint8 isCrit + uint32 count - // + count × (uint64 victimGuid + uint8 missInfo) + // All expansions: uint32 spellId first. + // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count + // + count × (packed_guid victim + uint8 missInfo) + // [missInfo==11(REFLECT): + uint32 reflectSpellId + uint8 reflectResult] + // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count + // + count × (uint64 victim + uint8 missInfo) const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { if (spellMissTbcLike) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; + // spellId prefix present in all expansions + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t spellId =*/ packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; uint64_t casterGuid = readSpellMissGuid(); - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; - /*uint64_t targetGuidLog =*/ readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; - /*uint8_t isCrit =*/ packet.readUInt8(); + /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 32u); - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 9; ++i) { - /*uint64_t victimGuid =*/ packet.readUInt64(); + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; + /*uint64_t victimGuid =*/ readSpellMissGuid(); + if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); + // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult + if (missInfo == 11 && !spellMissTbcLike) { + if (packet.getSize() - packet.getReadPos() >= 5) { + /*uint32_t reflectSpellId =*/ packet.readUInt32(); + /*uint8_t reflectResult =*/ packet.readUInt8(); + } else { + packet.setReadPos(packet.getSize()); + break; + } + } // Show combat text only for local player's spell misses if (casterGuid == playerGuid) { static const CombatTextEntry::Type missTypes[] = { From 9cd7e7978d774b36339085b89b0b1fce3705f399 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:49:37 -0700 Subject: [PATCH 53/71] fix: correct SMSG_DISPEL_FAILED packet format and improve message WotLK sends spellId(4) + packed_guid caster + packed_guid victim, while TBC/Classic sends full uint64 caster + uint64 victim + spellId(4). The previous handler assumed TBC format unconditionally, causing incorrect reads in WotLK mode. Also use the spell name cache to display "Purge failed to dispel." rather than a raw "Dispel failed! (spell N)" message. --- src/game/game_handler.cpp | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 071a6e72..d7867113 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2864,13 +2864,33 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAuraUpdate(packet, true); break; case Opcode::SMSG_DISPEL_FAILED: { - // casterGuid(8) + victimGuid(8) + spellId(4) [+ failing spellId(4)...] - if (packet.getSize() - packet.getReadPos() >= 20) { - /*uint64_t casterGuid =*/ packet.readUInt64(); - /*uint64_t victimGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); + // WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim + // [+ count × uint32 failedSpellId] + // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + // [+ count × uint32 failedSpellId] + const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint32_t dispelSpellId = 0; + if (dispelTbcLike) { + if (packet.getSize() - packet.getReadPos() < 20) break; + /*uint64_t caster =*/ packet.readUInt64(); + /*uint64_t victim =*/ packet.readUInt64(); + dispelSpellId = packet.readUInt32(); + } else { + if (packet.getSize() - packet.getReadPos() < 4) break; + dispelSpellId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 1) break; + /*uint64_t caster =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); + } + { + loadSpellNameCache(); + auto it = spellNameCache_.find(dispelSpellId); char buf[128]; - std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", spellId); + if (it != spellNameCache_.end() && !it->second.name.empty()) + std::snprintf(buf, sizeof(buf), "%s failed to dispel.", it->second.name.c_str()); + else + std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", dispelSpellId); addSystemChatMessage(buf); } break; From 0a176835456e5863ba1122c99dc6ea214620b20d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:50:53 -0700 Subject: [PATCH 54/71] fix: correct WotLK packed guid format in SMSG_PROCRESIST and SMSG_TOTEM_CREATED Both opcodes use packed GUIDs in WotLK 3.3.5a but were reading full uint64, causing incorrect GUID parsing and potentially matching wrong player entities. SMSG_PROCRESIST: caster + victim guids (packed in WotLK, uint64 in TBC/Classic) SMSG_TOTEM_CREATED: totem guid (packed in WotLK, uint64 in TBC/Classic) --- src/game/game_handler.cpp | 46 +++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d7867113..012aaf5e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1935,14 +1935,23 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { - // casterGuid(8) + victimGuid(8) + uint32 spellId + uint8 logSchoolMask - if (packet.getSize() - packet.getReadPos() >= 17) { - /*uint64_t caster =*/ packet.readUInt64(); - uint64_t victim = packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); - if (victim == playerGuid) - addCombatText(CombatTextEntry::MISS, 0, spellId, false); - } + // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + ... + // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + ... + const bool prTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto readPrGuid = [&]() -> uint64_t { + if (prTbcLike) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + /*uint64_t caster =*/ readPrGuid(); + if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + uint64_t victim = readPrGuid(); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t spellId = packet.readUInt32(); + if (victim == playerGuid) + addCombatText(CombatTextEntry::MISS, 0, spellId, false); + packet.setReadPos(packet.getSize()); break; } @@ -2896,15 +2905,20 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_TOTEM_CREATED: { - // uint8 slot + uint64 guid + uint32 duration + uint32 spellId - if (packet.getSize() - packet.getReadPos() >= 17) { - uint8_t slot = packet.readUInt8(); + // WotLK: uint8 slot + packed_guid + uint32 duration + uint32 spellId + // TBC/Classic: uint8 slot + uint64 guid + uint32 duration + uint32 spellId + const bool totemTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (totemTbcLike ? 17u : 9u)) break; + uint8_t slot = packet.readUInt8(); + if (totemTbcLike) /*uint64_t guid =*/ packet.readUInt64(); - uint32_t duration = packet.readUInt32(); - uint32_t spellId = packet.readUInt32(); - LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, - " spellId=", spellId, " duration=", duration, "ms"); - } + else + /*uint64_t guid =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t duration = packet.readUInt32(); + uint32_t spellId = packet.readUInt32(); + LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, + " spellId=", spellId, " duration=", duration, "ms"); break; } case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: { From 603e52e5b034175060cb9b87669bc87647a55b0d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:51:58 -0700 Subject: [PATCH 55/71] fix: add size check and skip WotLK guid suffix in handleCooldownEvent SMSG_COOLDOWN_EVENT in WotLK appends an 8-byte unit guid after the spellId. The handler was reading without a size check and not consuming the trailing guid, which could misalign subsequent reads. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 012aaf5e..d6c6dcad 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13982,7 +13982,11 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { } void GameHandler::handleCooldownEvent(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); + // WotLK appends the target unit guid (8 bytes) — skip it + if (packet.getSize() - packet.getReadPos() >= 8) + packet.readUInt64(); // Cooldown finished spellCooldowns.erase(spellId); for (auto& slot : actionBar) { From ae6c2aa056737e5be41262320a3ebe53d6436351 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 02:57:05 -0700 Subject: [PATCH 56/71] fix: correct SMSG_SPELLDISPELLOG entry size from 8 to 5 bytes Each dispelled spell entry is uint32(spellId) + uint8(isPositive) = 5 bytes, not uint32 + uint32 = 8 bytes as the loop previously assumed. The incorrect stride caused the second and subsequent entries to be read at wrong offsets, potentially showing the wrong spell name for multi-dispels. --- src/game/game_handler.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d6c6dcad..b6c0cd64 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5267,10 +5267,11 @@ void GameHandler::handlePacket(network::Packet& packet) { if (victimGuid == playerGuid || casterGuid == playerGuid) { const char* verb = isStolen ? "stolen" : "dispelled"; // Collect first dispelled spell name for the message + // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { uint32_t dispelledId = packet.readUInt32(); - /*uint32_t unk =*/ packet.readUInt32(); + /*uint8_t isPositive =*/ packet.readUInt8(); if (i == 0) { const std::string& nm = getSpellName(dispelledId); firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; From 1646bef1c23a0d77809b74d3de2680550b0721f5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:03:44 -0700 Subject: [PATCH 57/71] fix: add size guards to spell learn/remove handlers and implement SMSG_SPELLSTEALLOG handleLearnedSpell, handleRemovedSpell, handleSupercededSpell, and handleUnlearnSpells all lacked size checks before reading packet fields. Also implements SMSG_SPELLSTEALLOG (previously silently consumed) with proper player feedback showing the stolen spell name when the local player is the caster, matching the same expansion-conditional packed-guid format as SPELLDISPELLOG. --- src/game/game_handler.cpp | 45 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b6c0cd64..4ab88c51 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5292,8 +5292,45 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLSTEALLOG: { - // Similar to SPELLDISPELLOG but always isStolen=true; same wire format - // Just consume — SPELLDISPELLOG handles the player-facing case above + // Sent to the CASTER (Mage) when Spellsteal succeeds. + // Wire format mirrors SPELLDISPELLOG: + // WotLK: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count + // + count × (uint32 stolenSpellId + uint8 isPositive) + // TBC/Classic: full uint64 victim + full uint64 caster + same tail + const bool stealTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t stealVictim =*/ stealTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t stealCaster = stealTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + /*uint32_t stealSpellId =*/ packet.readUInt32(); + /*uint8_t isStolen =*/ packet.readUInt8(); + uint32_t stealCount = packet.readUInt32(); + // Show feedback only when we are the caster (we stole something) + if (stealCaster == playerGuid) { + std::string stolenName; + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + uint32_t stolenId = packet.readUInt32(); + /*uint8_t isPos =*/ packet.readUInt8(); + if (i == 0) { + const std::string& nm = getSpellName(stolenId); + stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; + } + } + if (!stolenName.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + addSystemChatMessage(buf); + } + } packet.setReadPos(packet.getSize()); break; } @@ -14042,6 +14079,7 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } void GameHandler::handleLearnedSpell(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); knownSpells.insert(spellId); LOG_INFO("Learned spell: ", spellId); @@ -14070,6 +14108,7 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { } void GameHandler::handleRemovedSpell(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO("Removed spell: ", spellId); @@ -14077,6 +14116,7 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { void GameHandler::handleSupercededSpell(network::Packet& packet) { // Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2) + if (packet.getSize() - packet.getReadPos() < 8) return; uint32_t oldSpellId = packet.readUInt32(); uint32_t newSpellId = packet.readUInt32(); @@ -14096,6 +14136,7 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { void GameHandler::handleUnlearnSpells(network::Packet& packet) { // Sent when unlearning multiple spells (e.g., spec change, respec) + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellCount = packet.readUInt32(); LOG_INFO("Unlearning ", spellCount, " spells"); From 35683920ffc551988170e76cc3a84c2c1090c6d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:09:39 -0700 Subject: [PATCH 58/71] fix: handle EVADE/IMMUNE/DEFLECT victimStates in melee combat text SMSG_ATTACKERSTATEUPDATE victimState values 5 (EVADE), 6 (IMMUNE), and 7 (DEFLECT) were previously falling through to the damage display path, showing incorrect damage numbers instead of the proper miss/immune feedback. Now correctly shows MISS for evade/deflect and IMMUNE for immune hits. --- src/game/game_handler.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4ab88c51..b87d08b0 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13517,6 +13517,15 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); } else if (data.victimState == 4) { addCombatText(CombatTextEntry::BLOCK, 0, 0, isPlayerAttacker); + } else if (data.victimState == 5) { + // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + } else if (data.victimState == 6) { + // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. + addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker); + } else if (data.victimState == 7) { + // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker); From d5196abaecff867adb4d8a64b23e92cf14d4e6cf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:13:14 -0700 Subject: [PATCH 59/71] fix: show IMMUNE text for miss type 5 in SMSG_SPELL_GO and SMSG_SPELLLOGMISS IMMUNE misses (spell miss type 5) were shown as generic MISS text in both spell cast feedback handlers. Now consistently shows IMMUNE combat text to match the fix already applied to SMSG_ATTACKERSTATEUPDATE. --- src/game/game_handler.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b87d08b0..9024347a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2430,8 +2430,8 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE → show as MISS - CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT CombatTextEntry::MISS, // 7=ABSORB CombatTextEntry::MISS, // 8=RESIST @@ -13964,8 +13964,8 @@ void GameHandler::handleSpellGo(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE → show as MISS - CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT CombatTextEntry::MISS, // 7=ABSORB CombatTextEntry::MISS, // 8=RESIST From e902375763b363baff7510240877cf55cd261526 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:23:01 -0700 Subject: [PATCH 60/71] feat: add ABSORB and RESIST combat text types for spell misses Adds dedicated CombatTextEntry::Type entries for ABSORB (miss type 7) and RESIST (miss type 8), replacing the generic MISS display. Updates missTypes arrays in SMSG_SPELLLOGMISS and SMSG_SPELL_GO, and adds light-blue "Absorb" and grey "Resist" rendering in the combat text overlay. --- include/game/spell_defines.hpp | 2 +- src/game/game_handler.cpp | 8 ++++---- src/ui/game_screen.cpp | 8 ++++++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 041b44f6..c4d70380 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,7 +51,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE + ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST }; Type type; int32_t amount = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9024347a..34486b53 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2433,8 +2433,8 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::MISS, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::MISS, // 7=ABSORB - CombatTextEntry::MISS, // 8=RESIST + CombatTextEntry::ABSORB, // 7=ABSORB + CombatTextEntry::RESIST, // 8=RESIST }; CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; addCombatText(ct, 0, 0, true); @@ -13967,8 +13967,8 @@ void GameHandler::handleSpellGo(network::Packet& packet) { CombatTextEntry::MISS, // 4=EVADE CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::MISS, // 7=ABSORB - CombatTextEntry::MISS, // 8=RESIST + CombatTextEntry::ABSORB, // 7=ABSORB + CombatTextEntry::RESIST, // 8=RESIST }; // Show text for each miss (usually just 1 target per spell go) for (const auto& m : data.missTargets) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 71681c84..4e8c29a5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5203,6 +5203,14 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Immune!"); color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune break; + case game::CombatTextEntry::ABSORB: + snprintf(text, sizeof(text), "Absorb"); + color = ImVec4(0.5f, 0.8f, 1.0f, alpha); // Light blue for absorb + break; + case game::CombatTextEntry::RESIST: + snprintf(text, sizeof(text), "Resist"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); From d2ae4d8215edbbe1b0da362dbaa38a58ab4b2964 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:28:19 -0700 Subject: [PATCH 61/71] feat: show partial absorb/resist amounts in spell combat text handleSpellDamageLog now emits ABSORB/RESIST entries when data.absorbed or data.resisted are nonzero, so players see 'Absorbed 123' alongside damage numbers (e.g. vs. Power Word: Shield or Ice Barrier). handleSpellHealLog does the same for heal absorbs (e.g. Vampiric Embrace counter-absorbs). renderCombatText now formats amount when nonzero. --- src/game/game_handler.cpp | 9 ++++++++- src/ui/game_screen.cpp | 10 ++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 34486b53..038f57f8 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13548,7 +13548,12 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { } auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE; - addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + if (data.damage > 0) + addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + if (data.absorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); + if (data.resisted > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource); } void GameHandler::handleSpellHealLog(network::Packet& packet) { @@ -13561,6 +13566,8 @@ void GameHandler::handleSpellHealLog(network::Packet& packet) { auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource); + if (data.absorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); } // ============================================================ diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4e8c29a5..5f213707 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5204,11 +5204,17 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune break; case game::CombatTextEntry::ABSORB: - snprintf(text, sizeof(text), "Absorb"); + if (entry.amount > 0) + snprintf(text, sizeof(text), "Absorbed %d", entry.amount); + else + snprintf(text, sizeof(text), "Absorbed"); color = ImVec4(0.5f, 0.8f, 1.0f, alpha); // Light blue for absorb break; case game::CombatTextEntry::RESIST: - snprintf(text, sizeof(text), "Resist"); + if (entry.amount > 0) + snprintf(text, sizeof(text), "Resisted %d", entry.amount); + else + snprintf(text, sizeof(text), "Resisted"); color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist break; default: From dfc78572f508f3c9fdb6795d9db4ddfe4a8f1a4b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:29:37 -0700 Subject: [PATCH 62/71] feat: show melee absorb/resist in combat text from SMSG_ATTACKERSTATEUPDATE Sub-damage entries carry absorbed/resisted per school. Accumulate these and emit ABSORB/RESIST combat text alongside the hit damage when nonzero, matching the behavior just added for SMSG_SPELLNONMELEEDAMAGELOG. --- src/game/game_handler.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 038f57f8..ecf1980e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13529,6 +13529,16 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker); + // Show partial absorb/resist from sub-damage entries + uint32_t totalAbsorbed = 0, totalResisted = 0; + for (const auto& sub : data.subDamages) { + totalAbsorbed += sub.absorbed; + totalResisted += sub.resisted; + } + if (totalAbsorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker); + if (totalResisted > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker); } (void)isPlayerTarget; From 031448ec6d62dbc7f91abf8081f8909dac30565c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:30:24 -0700 Subject: [PATCH 63/71] feat: show absorb/resist on periodic damage (DoT) ticks SMSG_PERIODICAURALOG already parsed abs/res fields for type 3/89 but discarded them. Surface these as ABSORB/RESIST combat text so players see when DoT ticks are being partially absorbed (e.g. vs. PW:Shield). --- src/game/game_handler.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ecf1980e..d08016c1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3476,10 +3476,17 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 16) break; uint32_t dmg = packet.readUInt32(); /*uint32_t school=*/ packet.readUInt32(); - /*uint32_t abs=*/ packet.readUInt32(); - /*uint32_t res=*/ packet.readUInt32(); - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), - spellId, isPlayerCaster); + uint32_t abs = packet.readUInt32(); + uint32_t res = packet.readUInt32(); + if (dmg > 0) + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), + spellId, isPlayerCaster); + if (abs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(abs), + spellId, isPlayerCaster); + if (res > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(res), + spellId, isPlayerCaster); } else if (auraType == 8 || auraType == 124 || auraType == 45) { // PERIODIC_HEAL / PERIODIC_HEAL_PCT / OBS_MOD_HEALTH: heal+maxHeal+overHeal if (packet.getSize() - packet.getReadPos() < 12) break; From f50cb048877d7edf248fbe5a38170dfafff33f63 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:31:33 -0700 Subject: [PATCH 64/71] feat: surface absorb/resist from SMSG_ENVIRONMENTALDAMAGELOG Environmental damage (drowning, lava, fire) also carries absorb/resist fields. Show these as ABSORB/RESIST combat text so players see the full picture of incoming environmental hits, consistent with spell/melee. --- src/game/game_handler.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index d08016c1..1eaa137f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2450,10 +2450,15 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t damage = packet.readUInt32(); - /*uint32_t absorb =*/ packet.readUInt32(); - /*uint32_t resist =*/ packet.readUInt32(); - if (victimGuid == playerGuid && damage > 0) { - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + uint32_t absorb = packet.readUInt32(); + uint32_t resist = packet.readUInt32(); + if (victimGuid == playerGuid) { + if (damage > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + if (absorb > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false); + if (resist > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false); } break; } From d1c5e091279e3a08d82c2db5514ad3110e241c6d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:34:27 -0700 Subject: [PATCH 65/71] fix: correct SMSG_PERIODICAURALOG packet format for WotLK 3.3.5a WotLK adds an overkill(4) field between damage and school for aura type 3/89 (periodic damage), and adds absorbed(4)+isCrit(1) after overHeal for aura type 8/124/45 (periodic heal). Without these fields the absorb and resist values were reading out-of-alignment, producing garbage data. Also surfaces the heal-absorbed amount as ABSORB combat text (e.g. when a HoT tick is partially absorbed by Vampiric Embrace counter-healing). --- src/game/game_handler.cpp | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1eaa137f..f730d6aa 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3477,9 +3477,13 @@ void GameHandler::handlePacket(network::Packet& packet) { for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 1; ++i) { uint8_t auraType = packet.readUInt8(); if (auraType == 3 || auraType == 89) { - // PERIODIC_DAMAGE / PERIODIC_DAMAGE_PERCENT: damage+school+absorbed+resisted - if (packet.getSize() - packet.getReadPos() < 16) break; + // Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes + // WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4) = 20 bytes + const bool periodicWotlk = isActiveExpansion("wotlk"); + const size_t dotSz = periodicWotlk ? 20u : 16u; + if (packet.getSize() - packet.getReadPos() < dotSz) break; uint32_t dmg = packet.readUInt32(); + if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32(); /*uint32_t school=*/ packet.readUInt32(); uint32_t abs = packet.readUInt32(); uint32_t res = packet.readUInt32(); @@ -3493,13 +3497,24 @@ void GameHandler::handlePacket(network::Packet& packet) { addCombatText(CombatTextEntry::RESIST, static_cast(res), spellId, isPlayerCaster); } else if (auraType == 8 || auraType == 124 || auraType == 45) { - // PERIODIC_HEAL / PERIODIC_HEAL_PCT / OBS_MOD_HEALTH: heal+maxHeal+overHeal - if (packet.getSize() - packet.getReadPos() < 12) break; + // Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes + // WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes + const bool healWotlk = isActiveExpansion("wotlk"); + const size_t hotSz = healWotlk ? 17u : 12u; + if (packet.getSize() - packet.getReadPos() < hotSz) break; uint32_t heal = packet.readUInt32(); /*uint32_t max=*/ packet.readUInt32(); /*uint32_t over=*/ packet.readUInt32(); + uint32_t hotAbs = 0; + if (healWotlk) { + hotAbs = packet.readUInt32(); + /*uint8_t isCrit=*/ packet.readUInt8(); + } addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), spellId, isPlayerCaster); + if (hotAbs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), + spellId, isPlayerCaster); } else if (auraType == 46 || auraType == 91) { // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. From fb01361837997e8349836b94861c6517c6efaa0b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:36:45 -0700 Subject: [PATCH 66/71] feat: show blocked amount and reduced damage on VICTIMSTATE_BLOCKS When an attack is partially blocked, the server sends the remaining damage in totalDamage and the blocked amount in data.blocked. Show both: the damage taken and a 'Block N' entry. When block amount is zero (full block with no damage), just show 'Block'. --- src/game/game_handler.cpp | 5 ++++- src/ui/game_screen.cpp | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f730d6aa..9db4a4f3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13543,7 +13543,10 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { } else if (data.victimState == 2) { addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); } else if (data.victimState == 4) { - addCombatText(CombatTextEntry::BLOCK, 0, 0, isPlayerAttacker); + // VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount + if (data.totalDamage > 0) + addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker); } else if (data.victimState == 5) { // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5f213707..52e056fb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5173,7 +5173,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; case game::CombatTextEntry::BLOCK: - snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); + if (entry.amount > 0) + snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); + else + snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; From 00db93b7f237b1678d942cff320723f9e99ed34e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:38:39 -0700 Subject: [PATCH 67/71] fix: show RESIST (not MISS) for SMSG_PROCRESIST combat text SMSG_PROCRESIST is sent when a proc effect is resisted. Show 'Resisted' rather than 'Miss' to correctly communicate what happened to the player. --- src/game/game_handler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9db4a4f3..9f0ae33b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1950,7 +1950,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t spellId = packet.readUInt32(); if (victim == playerGuid) - addCombatText(CombatTextEntry::MISS, 0, spellId, false); + addCombatText(CombatTextEntry::RESIST, 0, spellId, false); packet.setReadPos(packet.getSize()); break; } From 84a6ee48018afb6185c23dd1a04e5c5e9c06577c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:40:41 -0700 Subject: [PATCH 68/71] fix: surface absorb/resist in SMSG_ENVIRONMENTAL_DAMAGE_LOG (Classic/TBC) The Classic/TBC variant handler was discarding the resisted field entirely (only reading absorbed but discarding it). Now reads and shows both as ABSORB/RESIST combat text, matching the WotLK SMSG_ENVIRONMENTALDAMAGELOG fix from the previous commit. --- src/game/game_handler.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9f0ae33b..9bb2fa6a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3573,9 +3573,16 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t dmg = packet.readUInt32(); - /*uint32_t abs =*/ packet.readUInt32(); - if (victimGuid == playerGuid && dmg > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + uint32_t envAbs = packet.readUInt32(); + uint32_t envRes = packet.readUInt32(); + if (victimGuid == playerGuid) { + if (dmg > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + if (envAbs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false); + if (envRes > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false); + } packet.setReadPos(packet.getSize()); break; } From 1446d4fddd3d9e4735403db07e2649f814fa8eb6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:41:49 -0700 Subject: [PATCH 69/71] fix: pass player power type to getSpellCastResultString for result 85 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result 85 is 'not enough power' — the message should say 'Not enough rage', 'Not enough energy', 'Not enough runic power', etc. based on the player's actual power type rather than always showing 'Not enough mana'. --- src/game/game_handler.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9bb2fa6a..f7910c56 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1906,7 +1906,13 @@ void GameHandler::handlePacket(network::Packet& packet) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - const char* reason = getSpellCastResultString(castResult, -1); + // Pass player's power type so result 85 says "Not enough rage/energy/etc." + int playerPowerType = -1; + if (auto pe = entityManager.getEntity(playerGuid)) { + if (auto pu = std::dynamic_pointer_cast(pe)) + playerPowerType = static_cast(pu->getPowerType()); + } + const char* reason = getSpellCastResultString(castResult, playerPowerType); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; From 144c87a72f627591476118c542f69003cc6f3d46 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:42:41 -0700 Subject: [PATCH 70/71] feat: show spell failure reason in chat from SMSG_SPELL_FAILURE SMSG_SPELL_FAILURE carries a failReason byte (same enum as SMSG_CAST_RESULT) that was previously ignored. Now parse castCount+spellId+failReason and display the localized reason string for the player's interrupted casts (e.g. 'Interrupted', 'Stunned', 'Can\'t do that while moving'). --- src/game/game_handler.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f7910c56..81a05706 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2752,6 +2752,27 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t failGuid = tbcOrClassic ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); + // Read castCount + spellId + failReason + if (packet.getSize() - packet.getReadPos() >= 6) { + /*uint8_t castCount =*/ packet.readUInt8(); + /*uint32_t spellId =*/ packet.readUInt32(); + uint8_t failReason = packet.readUInt8(); + if (failGuid == playerGuid && failReason != 0) { + // Show interruption/failure reason in chat for player + int pt = -1; + if (auto pe = entityManager.getEntity(playerGuid)) + if (auto pu = std::dynamic_pointer_cast(pe)) + pt = static_cast(pu->getPowerType()); + const char* reason = getSpellCastResultString(failReason, pt); + if (reason) { + MessageChatData emsg; + emsg.type = ChatType::SYSTEM; + emsg.language = ChatLanguage::UNIVERSAL; + emsg.message = reason; + addLocalChatMessage(emsg); + } + } + } if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed casting = false; From 2f0809b57074ecb665057bed4bac67a80728581e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Mar 2026 03:49:54 -0700 Subject: [PATCH 71/71] fix: correct TBC aura entry minimum-size guard from 13 to 15 bytes Each SMSG_INIT/SET_EXTRA_AURA_INFO entry is 15 bytes: uint8 slot(1) + uint32 spellId(4) + uint8 effectIndex(1) + uint8 flags(1) + uint32 durationMs(4) + uint32 maxDurMs(4) = 15 The previous guard of 13 would allow the loop to start reading a partial entry, silently returning zeroes for durationMs/maxDurMs when 13-14 bytes remained in the packet. --- src/game/game_handler.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 81a05706..e9452785 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5019,13 +5019,13 @@ void GameHandler::handlePacket(network::Packet& packet) { std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); - for (uint8_t i = 0; i < count && remaining() >= 13; i++) { - uint8_t slot = packet.readUInt8(); - uint32_t spellId = packet.readUInt32(); - (void) packet.readUInt8(); // effectIndex (unused for slot display) - uint8_t flags = packet.readUInt8(); - uint32_t durationMs = packet.readUInt32(); - uint32_t maxDurMs = packet.readUInt32(); + for (uint8_t i = 0; i < count && remaining() >= 15; i++) { + uint8_t slot = packet.readUInt8(); // 1 byte + uint32_t spellId = packet.readUInt32(); // 4 bytes + (void) packet.readUInt8(); // effectIndex: 1 byte (unused for slot display) + uint8_t flags = packet.readUInt8(); // 1 byte + uint32_t durationMs = packet.readUInt32(); // 4 bytes + uint32_t maxDurMs = packet.readUInt32(); // 4 bytes — total 15 bytes per entry if (auraList) { while (auraList->size() <= slot) auraList->push_back(AuraSlot{});