From 218d68e2753de7a11d6f259164e0403455530b3f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Mar 2026 18:15:51 -0700 Subject: [PATCH] feat: parse Classic SMSG_INSPECT gear + implement temp weapon enchant timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic 1.12 SMSG_INSPECT (wire 0x115): parse PackedGUID + 19×uint32 itemEntries to populate InspectResult and inspectedPlayerItemEntries_ cache, enabling gear inspection of other players on Classic servers. Triggers item queries for all filled slots so the inspect window shows names/ilevels. SMSG_ITEM_ENCHANT_TIME_UPDATE: parse itemGuid/slot/durationSec/playerGuid and store per-slot expire timestamps in tempEnchantTimers_. Fires 5min/1min chat warnings before expiry. getTempEnchantRemainingMs() helper queries live remaining time. Buff bar renders timed slot buttons (gold/teal/purple per slot) that pulse red below 60s — useful for Shaman imbues, Rogue poisons, whetstones and oils across all three expansions. --- include/game/game_handler.hpp | 12 ++++ src/game/game_handler.cpp | 116 ++++++++++++++++++++++++++++++++-- src/ui/game_screen.cpp | 54 ++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 0e56ea59..621a8586 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1451,6 +1451,17 @@ public: }; const LevelUpDeltas& getLastLevelUpDeltas() const { return lastLevelUpDeltas_; } + // Temporary weapon enchant timers (from SMSG_ITEM_ENCHANT_TIME_UPDATE) + // Slot: 0=main-hand, 1=off-hand, 2=ranged. Value: expire time (steady_clock ms). + struct TempEnchantTimer { + uint32_t slot = 0; + uint64_t expireMs = 0; // std::chrono::steady_clock ms timestamp when it expires + }; + const std::vector& getTempEnchantTimers() const { return tempEnchantTimers_; } + // Returns remaining ms for a given slot, or 0 if absent/expired. + uint32_t getTempEnchantRemainingMs(uint32_t slot) const; + static constexpr const char* kTempEnchantSlotNames[] = { "Main Hand", "Off Hand", "Ranged" }; + // Other player level-up callback — fires when another player gains a level using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } @@ -2808,6 +2819,7 @@ private: ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; LevelUpDeltas lastLevelUpDeltas_; + std::vector tempEnchantTimers_; OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; AreaDiscoveryCallback areaDiscoveryCallback_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0670e917..052c0cbc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5698,9 +5698,56 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Misc consume ---- + case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: { + // Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid + // slot: 0=main-hand, 1=off-hand, 2=ranged + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t enchSlot = packet.readUInt32(); + uint32_t durationSec = packet.readUInt32(); + /*uint64_t playerGuid =*/ packet.readUInt64(); + + // Clamp to known slots (0-2) + if (enchSlot > 2) { break; } + + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + if (durationSec == 0) { + // Enchant expired / removed — erase the slot entry + tempEnchantTimers_.erase( + std::remove_if(tempEnchantTimers_.begin(), tempEnchantTimers_.end(), + [enchSlot](const TempEnchantTimer& t) { return t.slot == enchSlot; }), + tempEnchantTimers_.end()); + } else { + uint64_t expireMs = nowMs + static_cast(durationSec) * 1000u; + bool found = false; + for (auto& t : tempEnchantTimers_) { + if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; } + } + if (!found) tempEnchantTimers_.push_back({enchSlot, expireMs}); + + // Warn at important thresholds + if (durationSec <= 60 && durationSec > 55) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName); + addSystemChatMessage(buf); + } else if (durationSec <= 300 && durationSec > 295) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName); + addSystemChatMessage(buf); + } + } + LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); + break; + } case Opcode::SMSG_COMPLAIN_RESULT: case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: - case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: // Consume — not yet processed packet.setReadPos(packet.getSize()); @@ -6212,10 +6259,58 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; - // ---- Inspect (full character inspection) ---- - case Opcode::SMSG_INSPECT: - packet.setReadPos(packet.getSize()); + // ---- Inspect (Classic 1.12 gear inspection) ---- + case Opcode::SMSG_INSPECT: { + // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) + // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to + // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0) { packet.setReadPos(packet.getSize()); break; } + + constexpr int kGearSlots = 19; + size_t needed = kGearSlots * sizeof(uint32_t); + if (packet.getSize() - packet.getReadPos() < needed) { + packet.setReadPos(packet.getSize()); break; + } + + std::array items{}; + for (int s = 0; s < kGearSlots; ++s) + items[s] = packet.readUInt32(); + + // Resolve player name + auto ent = entityManager.getEntity(guid); + std::string playerName = "Target"; + if (ent) { + auto pl = std::dynamic_pointer_cast(ent); + if (pl && !pl->getName().empty()) playerName = pl->getName(); + } + + // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = 0; + inspectResult_.unspentTalents = 0; + inspectResult_.talentGroups = 0; + inspectResult_.activeTalentGroup = 0; + inspectResult_.itemEntries = items; + inspectResult_.enchantIds = {}; + + // Also cache for future talent-inspect cross-reference + inspectedPlayerItemEntries_[guid] = items; + + // Trigger item queries for non-empty slots + for (int s = 0; s < kGearSlots; ++s) { + if (items[s] != 0) queryItemInfo(items[s], 0); + } + + LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", + std::count_if(items.begin(), items.end(), + [](uint32_t e) { return e != 0; }), "/19 slots"); break; + } // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: @@ -14383,6 +14478,19 @@ void GameHandler::cancelAura(uint32_t spellId) { socket->send(packet); } +uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + for (const auto& t : tempEnchantTimers_) { + if (t.slot == slot) { + return (t.expireMs > nowMs) + ? static_cast(t.expireMs - nowMs) : 0u; + } + } + return 0u; +} + void GameHandler::handlePetSpells(network::Packet& packet) { const size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 8) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 69cc6cef..0ee760e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -12036,6 +12036,60 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } ImGui::PopStyleColor(2); } + + // Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.) + { + const auto& timers = gameHandler.getTempEnchantTimers(); + if (!timers.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + static const ImVec4 kEnchantSlotColors[] = { + ImVec4(0.9f, 0.6f, 0.1f, 1.0f), // main-hand: gold + ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal + ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple + }; + uint64_t enchNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (const auto& t : timers) { + if (t.slot > 2) continue; + uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0; + if (remMs == 0) continue; + + ImVec4 col = kEnchantSlotColors[t.slot]; + // Flash red when < 60s remaining + if (remMs < 60000) { + float pulse = 0.6f + 0.4f * std::sin( + static_cast(ImGui::GetTime()) * 4.0f); + col = ImVec4(pulse, 0.2f, 0.1f, 1.0f); + } + + // Format remaining time + uint32_t secs = static_cast((remMs + 999) / 1000); + char timeStr[16]; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%ds", secs); + + ImGui::PushID(static_cast(t.slot) + 5000); + ImGui::PushStyleColor(ImGuiCol_Button, col); + char label[40]; + snprintf(label, sizeof(label), "~%s %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr); + ImGui::Button(label, ImVec2(-1, 16)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], + timeStr); + ImGui::PopStyleColor(); + ImGui::PopID(); + } + } + } } ImGui::End();