feat: parse Classic SMSG_INSPECT gear + implement temp weapon enchant timers

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.
This commit is contained in:
Kelsi 2026-03-12 18:15:51 -07:00
parent 2f479c6230
commit 218d68e275
3 changed files with 178 additions and 4 deletions

View file

@ -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<TempEnchantTimer>& 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(uint64_t guid, uint32_t newLevel)>;
void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); }
@ -2808,6 +2819,7 @@ private:
ChargeCallback chargeCallback_;
LevelUpCallback levelUpCallback_;
LevelUpDeltas lastLevelUpDeltas_;
std::vector<TempEnchantTimer> tempEnchantTimers_;
OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_;
AchievementEarnedCallback achievementEarnedCallback_;
AreaDiscoveryCallback areaDiscoveryCallback_;

View file

@ -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<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
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<uint64_t>(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<uint32_t, 19> 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<Player>(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<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (const auto& t : tempEnchantTimers_) {
if (t.slot == slot) {
return (t.expireMs > nowMs)
? static_cast<uint32_t>(t.expireMs - nowMs) : 0u;
}
}
return 0u;
}
void GameHandler::handlePetSpells(network::Packet& packet) {
const size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 8) {

View file

@ -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<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
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<float>(ImGui::GetTime()) * 4.0f);
col = ImVec4(pulse, 0.2f, 0.1f, 1.0f);
}
// Format remaining time
uint32_t secs = static_cast<uint32_t>((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<int>(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();