From f8f514d28c8704aa13340c3ed21c9305b1f17652 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:49:37 -0700 Subject: [PATCH 01/58] fix: add $C (class) and $R (race) quest text placeholders Quest dialogs were showing literal "$C" instead of the player's class name. Added support for $c/$C (class) and $r/$R (race) placeholders in both game_screen and quest_log_screen substitution functions. --- src/ui/game_screen.cpp | 12 +++++++++++- src/ui/quest_log_screen.cpp | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f4f8cd11..44d02cb1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -19600,8 +19600,16 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: pos += replacement.length(); } + // Resolve class and race names for $C and $R placeholders + std::string className = "Adventurer"; + std::string raceName = "Unknown"; + if (character) { + className = game::getClassName(character->characterClass); + raceName = game::getRaceName(character->race); + } + // Replace simple placeholders. - // $n = player name + // $n/$N = player name, $c/$C = class name, $r/$R = race name // $p = subject pronoun (he/she/they) // $o = object pronoun (him/her/them) // $s = possessive adjective (his/her/their) @@ -19615,6 +19623,8 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: std::string replacement; switch (code) { case 'n': case 'N': replacement = playerName; break; + case 'c': case 'C': replacement = className; break; + case 'r': case 'R': replacement = raceName; break; case 'p': replacement = pronouns.subject; break; case 'o': replacement = pronouns.object; break; case 's': replacement = pronouns.possessive; break; diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 92b52bd9..fe5cd2cb 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -82,6 +82,14 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler pos += replacement.length(); } + // Resolve class and race names for $C and $R placeholders + std::string className = "Adventurer"; + std::string raceName = "Unknown"; + if (character) { + className = game::getClassName(character->characterClass); + raceName = game::getRaceName(character->race); + } + // Replace simple placeholders pos = 0; while ((pos = result.find('$', pos)) != std::string::npos) { @@ -92,11 +100,12 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler switch (code) { case 'n': case 'N': replacement = playerName; break; + case 'c': case 'C': replacement = className; break; + case 'r': case 'R': replacement = raceName; break; case 'p': replacement = pronouns.subject; break; case 'o': replacement = pronouns.object; break; case 's': replacement = pronouns.possessive; break; case 'S': replacement = pronouns.possessiveP; break; - case 'r': replacement = pronouns.object; break; case 'b': case 'B': replacement = "\n"; break; case 'g': case 'G': pos++; continue; default: pos++; continue; From c8922e48265814cccdb17f7ccb8bbe88659da763 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:49:43 -0700 Subject: [PATCH 02/58] fix: stop player movement before game object interaction Servers may reject CMSG_GAMEOBJ_USE or cancel the resulting pickup spell cast if movement flags are still active. Now sends MSG_MOVE_STOP to clear directional movement before the interaction packet. Also adds diagnostic logging for GO interactions to help trace collection issues. --- src/game/game_handler.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3591d97a..77fe91b5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -19932,6 +19932,17 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { addSystemChatMessage("Too far away."); return; } + // Stop movement before interacting — servers may reject GO use or + // immediately cancel the resulting spell cast if the player is moving. + const uint32_t moveFlags = movementInfo.flags; + const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD + (moveFlags & 0x00000002u) || // BACKWARD + (moveFlags & 0x00000004u) || // STRAFE_LEFT + (moveFlags & 0x00000008u); // STRAFE_RIGHT + if (isMoving) { + movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags + sendMovement(Opcode::MSG_MOVE_STOP); + } if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { movementInfo.orientation = std::atan2(-dy, dx); sendMovement(Opcode::MSG_MOVE_SET_FACING); @@ -19939,6 +19950,12 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } + LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec, + " entry=", goEntry, " type=", goType, + " name='", goName, "' dist=", entity ? std::sqrt( + (entity->getX() - movementInfo.x) * (entity->getX() - movementInfo.x) + + (entity->getY() - movementInfo.y) * (entity->getY() - movementInfo.y) + + (entity->getZ() - movementInfo.z) * (entity->getZ() - movementInfo.z)) : -1.0f); auto packet = GameObjectUsePacket::build(guid); socket->send(packet); lastInteractedGoGuid_ = guid; From 3c60ef8464d38a04ecc1539db023ff65bf4dffe4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 06:57:15 -0700 Subject: [PATCH 03/58] fix: add hex dump diagnostics to spell-go missCount parsing When SMSG_SPELL_GO reads a suspiciously high missCount (>20), log the surrounding packet bytes, castFlags, and position for debugging the persistent offset error causing garbage miss counts (46, 48, 241). --- src/game/world_packets.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index e6f6d872..d950994e 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3889,7 +3889,27 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { return false; } + const size_t missCountPos = packet.getReadPos(); const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 20) { + // Likely offset error — dump context bytes for diagnostics. + const auto& raw = packet.getData(); + std::string hexCtx; + size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos; + size_t dumpEnd = std::min(missCountPos + 16, raw.size()); + for (size_t i = dumpStart; i < dumpEnd; ++i) { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%02x ", raw[i]); + hexCtx += buf; + if (i == missCountPos - 1) hexCtx += "["; + if (i == missCountPos) hexCtx += "] "; + } + LOG_WARNING("Spell go: suspect missCount=", (int)rawMissCount, + " spell=", data.spellId, " hits=", (int)data.hitCount, + " castFlags=0x", std::hex, data.castFlags, std::dec, + " missCountPos=", missCountPos, " pktSize=", packet.getSize(), + " ctx=", hexCtx); + } if (rawMissCount > 128) { LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ") spell=", data.spellId, " hits=", (int)data.hitCount, From f78d885e13372d3c068ef9d1353ab67c3869297e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 07:00:50 -0700 Subject: [PATCH 04/58] fix: add 60-second grace period to M2 model cleanup Models that lose all instances are no longer immediately evicted from GPU memory. Instead they get a 60-second grace period, preventing the thrash cycle where GO models (barrels, chests, herbs) were evicted every 5 seconds and re-loaded when the same object type respawned. --- include/rendering/m2_renderer.hpp | 4 ++++ src/rendering/m2_renderer.cpp | 23 +++++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 22578309..1f19b46e 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace wowee { @@ -434,6 +435,9 @@ private: void* glowVBMapped_ = nullptr; std::unordered_map models; + // Grace period for model cleanup: track when a model first became instanceless. + // Models are only evicted after 60 seconds with no instances. + std::unordered_map modelUnusedSince_; std::vector instances; // O(1) dedup: key = (modelId, quantized x, quantized y, quantized z) → instanceId diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 390ee2c5..40ffd4b1 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1753,6 +1753,7 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, return 0; } const auto& mdlRef = modelIt->second; + modelUnusedSince_.erase(modelId); // Deduplicate: skip if same model already at nearly the same position. // Uses hash map for O(1) lookup instead of O(N) scan. @@ -1864,6 +1865,7 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4& LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); return 0; } + modelUnusedSince_.erase(modelId); // Deduplicate: O(1) hash lookup { @@ -4276,11 +4278,28 @@ void M2Renderer::cleanupUnusedModels() { usedModelIds.insert(instance.modelId); } - // Find and remove models with no instances + const auto now = std::chrono::steady_clock::now(); + constexpr auto kGracePeriod = std::chrono::seconds(60); + + // Find models with no instances that have exceeded the grace period. + // Models that just lost their last instance get tracked but not evicted + // immediately — this prevents thrashing when GO models are briefly + // instance-free between despawn and respawn cycles. std::vector toRemove; for (const auto& [id, model] : models) { - if (usedModelIds.find(id) == usedModelIds.end()) { + if (usedModelIds.find(id) != usedModelIds.end()) { + // Model still in use — clear any pending unused timestamp + modelUnusedSince_.erase(id); + continue; + } + auto unusedIt = modelUnusedSince_.find(id); + if (unusedIt == modelUnusedSince_.end()) { + // First cycle with no instances — start the grace timer + modelUnusedSince_[id] = now; + } else if (now - unusedIt->second >= kGracePeriod) { + // Grace period expired — mark for removal toRemove.push_back(id); + modelUnusedSince_.erase(unusedIt); } } From 6484dfc32d87abf90cd8a377d4f4c0b79e89921d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 07:05:17 -0700 Subject: [PATCH 05/58] fix: gate spline verticalAccel/effectStartTime on PARABOLIC flag The legacy UPDATE_OBJECT spline path was reading verticalAccel (float) and effectStartTime (uint32) unconditionally, but these 8 bytes are only present when SPLINEFLAG_PARABOLIC (0x00000800) is set. Without the flag, the extra reads shifted the stream by 8 bytes, causing pointCount to read garbage (e.g. 3323328650) and failing the entire update block parse. --- src/game/world_packets.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d950994e..4262fdcf 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1061,22 +1061,26 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Legacy UPDATE_OBJECT spline layout used by many servers: // timePassed, duration, splineId, durationMod, durationModNext, // [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)], - // verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint. + // [PARABOLIC: verticalAccel(4)+effectStartTime(4) if SPLINEFLAG_PARABOLIC(0x00000800)], + // pointCount, points, splineMode, endPoint. const size_t legacyStart = packet.getReadPos(); - if (!bytesAvailable(12 + 8 + 8 + 4)) return false; + if (!bytesAvailable(12 + 8 + 4)) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); /*float durationMod =*/ packet.readFloat(); /*float durationModNext =*/ packet.readFloat(); - // Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION if (!bytesAvailable(5)) return false; packet.readUInt8(); // animationType packet.readUInt32(); // animTime } - /*float verticalAccel =*/ packet.readFloat(); - /*uint32_t effectStartTime =*/ packet.readUInt32(); + if (splineFlags & 0x00000800) { // SPLINEFLAG_PARABOLIC + if (!bytesAvailable(8)) return false; + /*float verticalAccel =*/ packet.readFloat(); + /*uint32_t effectStartTime =*/ packet.readUInt32(); + } + if (!bytesAvailable(4)) return false; uint32_t pointCount = packet.readUInt32(); const size_t remainingAfterCount = packet.getSize() - packet.getReadPos(); From ce3caf0438dbbc02c3505eda9607fbf4093b27c6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 07:23:51 -0700 Subject: [PATCH 06/58] fix: auto-detect Classic vs WotLK spline format in UPDATE_OBJECT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spline parser assumed WotLK format (durationMod, durationModNext, conditional PARABOLIC fields) for all expansions. Classic/Turtle has a simpler layout: timePassed+duration+splineId+pointCount directly. Reading WotLK-specific fields from Classic data consumed wrong bytes, causing pointCount to read garbage and the entire update block to fail — losing dozens of NPC spawns in multi-block packets. Now tries Classic format first (pointCount at offset 12), then WotLK (offset 20+), then compact fallback. Also fixes WotLK SMSG_SPELL_GO hit/miss targets to use full uint64 GUIDs instead of PackedGuid, which was the root cause of garbage missCount values (46, 64, 241). --- src/game/world_packets.cpp | 109 +++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 53 deletions(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4262fdcf..5001e7e4 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1058,50 +1058,60 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*float finalAngle =*/ packet.readFloat(); } - // Legacy UPDATE_OBJECT spline layout used by many servers: - // timePassed, duration, splineId, durationMod, durationModNext, - // [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)], - // [PARABOLIC: verticalAccel(4)+effectStartTime(4) if SPLINEFLAG_PARABOLIC(0x00000800)], - // pointCount, points, splineMode, endPoint. + // Spline data layout varies by expansion: + // Classic/Vanilla: timePassed(4)+duration(4)+splineId(4)+pointCount(4)+points+mode(1)+endPoint(12) + // WotLK: timePassed(4)+duration(4)+splineId(4)+durationMod(4)+durationModNext(4) + // +[ANIMATION(5)]+[PARABOLIC(8)]+pointCount(4)+points+mode(1)+endPoint(12) + // Since the parser has no expansion context, auto-detect by trying Classic first. const size_t legacyStart = packet.getReadPos(); - if (!bytesAvailable(12 + 8 + 4)) return false; + if (!bytesAvailable(16)) return false; // minimum: 12 common + 4 pointCount /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); - /*float durationMod =*/ packet.readFloat(); - /*float durationModNext =*/ packet.readFloat(); - if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION - if (!bytesAvailable(5)) return false; - packet.readUInt8(); // animationType - packet.readUInt32(); // animTime - } - if (splineFlags & 0x00000800) { // SPLINEFLAG_PARABOLIC - if (!bytesAvailable(8)) return false; - /*float verticalAccel =*/ packet.readFloat(); - /*uint32_t effectStartTime =*/ packet.readUInt32(); - } - if (!bytesAvailable(4)) return false; - uint32_t pointCount = packet.readUInt32(); + const size_t afterSplineId = packet.getReadPos(); - const size_t remainingAfterCount = packet.getSize() - packet.getReadPos(); - const bool legacyCountLooksValid = (pointCount <= 256); - const size_t legacyPointsBytes = static_cast(pointCount) * 12ull; - const bool legacyPayloadFits = (legacyPointsBytes + 13ull) <= remainingAfterCount; - - if (legacyCountLooksValid && legacyPayloadFits) { - for (uint32_t i = 0; i < pointCount; i++) { - /*float px =*/ packet.readFloat(); - /*float py =*/ packet.readFloat(); - /*float pz =*/ packet.readFloat(); + // Helper: try to parse uncompressed spline points from current read position. + auto tryParseUncompressedSpline = [&](const char* tag) -> bool { + if (!bytesAvailable(4)) return false; + uint32_t pc = packet.readUInt32(); + if (pc > 256) return false; + size_t needed = static_cast(pc) * 12ull + 13ull; + if (!bytesAvailable(needed)) return false; + for (uint32_t i = 0; i < pc; i++) { + packet.readFloat(); packet.readFloat(); packet.readFloat(); } - /*uint8_t splineMode =*/ packet.readUInt8(); - /*float endPointX =*/ packet.readFloat(); - /*float endPointY =*/ packet.readFloat(); - /*float endPointZ =*/ packet.readFloat(); - LOG_DEBUG(" Spline pointCount=", pointCount, " (legacy)"); - } else { - // Legacy pointCount looks invalid; try compact WotLK layout as recovery. - // This keeps malformed/variant packets from desyncing the whole update block. + packet.readUInt8(); // splineMode + packet.readFloat(); packet.readFloat(); packet.readFloat(); // endPoint + LOG_DEBUG(" Spline pointCount=", pc, " (", tag, ")"); + return true; + }; + + // --- Try 1: Classic format (pointCount immediately after splineId) --- + bool splineParsed = tryParseUncompressedSpline("classic"); + + // --- Try 2: WotLK format (durationMod+durationModNext+conditional+pointCount) --- + if (!splineParsed) { + packet.setReadPos(afterSplineId); + bool wotlkOk = bytesAvailable(8); // durationMod + durationModNext + if (wotlkOk) { + /*float durationMod =*/ packet.readFloat(); + /*float durationModNext =*/ packet.readFloat(); + if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION + if (!bytesAvailable(5)) { wotlkOk = false; } + else { packet.readUInt8(); packet.readUInt32(); } + } + } + if (wotlkOk && (splineFlags & 0x00000800)) { // SPLINEFLAG_PARABOLIC + if (!bytesAvailable(8)) { wotlkOk = false; } + else { packet.readFloat(); packet.readUInt32(); } + } + if (wotlkOk) { + splineParsed = tryParseUncompressedSpline("wotlk"); + } + } + + // --- Try 3: Compact layout (compressed points) as final recovery --- + if (!splineParsed) { packet.setReadPos(legacyStart); const size_t afterFinalFacingPos = packet.getReadPos(); if (splineFlags & 0x00400000) { // Animation @@ -1122,8 +1132,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock static uint32_t badSplineCount = 0; ++badSplineCount; if (badSplineCount <= 5 || (badSplineCount % 100) == 0) { - LOG_WARNING(" Spline pointCount=", pointCount, - " invalid (legacy+compact) at readPos=", + LOG_WARNING(" Spline invalid (classic+wotlk+compact) at readPos=", afterFinalFacingPos, "/", packet.getSize(), ", occurrence=", badSplineCount); } @@ -1143,7 +1152,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock if (!bytesAvailable(compactPayloadBytes)) return false; packet.setReadPos(packet.getReadPos() + compactPayloadBytes); } - } // end else (compact fallback) + } // end compact fallback } } else if (updateFlags & UPDATEFLAG_POSITION) { @@ -3869,13 +3878,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.reserve(storedHitLimit); for (uint16_t i = 0; i < rawHitCount; ++i) { - // WotLK hit targets are packed GUIDs, like the caster and miss targets. - if (!hasFullPackedGuid(packet)) { + // WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid). + if (packet.getSize() - packet.getReadPos() < 8) { LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount); truncatedTargets = true; break; } - const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + const uint64_t targetGuid = packet.readUInt64(); if (i < storedHitLimit) { data.hitTargets.push_back(targetGuid); } @@ -3923,22 +3932,16 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.reserve(storedMissLimit); for (uint16_t i = 0; i < rawMissCount; ++i) { - // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). + // WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType. // REFLECT additionally appends uint8 reflectResult. - if (!hasFullPackedGuid(packet)) { + if (packet.getSize() - packet.getReadPos() < 9) { // 8 GUID + 1 missType LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount, " spell=", data.spellId, " hits=", (int)data.hitCount); truncatedTargets = true; break; } SpellGoMissEntry m; - m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK - if (packet.getSize() - packet.getReadPos() < 1) { - LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount, - " spell=", data.spellId); - truncatedTargets = true; - break; - } + m.targetGuid = packet.readUInt64(); m.missType = packet.readUInt8(); if (m.missType == 11) { // SPELL_MISS_REFLECT if (packet.getSize() - packet.getReadPos() < 1) { From 0a04a00234cba7c70b4449943d6f9dca2dbbbeb5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 07:39:40 -0700 Subject: [PATCH 07/58] fix: harden Turtle movement block parser with bounds checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Turtle parseMovementBlock had no bounds checking on any reads. Since Packet::readUInt8() returns 0 past the end without failing, the parser could "succeed" with all-zero garbage data, then subsequent parseUpdateFields would read from wrong positions, producing "truncated field value" and "truncated update mask" errors. Added bounds checks before every conditional read section (transport, swimming pitch, fall time, jumping, spline elevation, speeds, spline data, tail flags). Also removed the WotLK movement block fallback from the Turtle parser chain — WotLK format is fundamentally incompatible (uint16 flags, 9 speeds) and false-positive parses corrupt NPC data. Also changed spline pointCount > 256 from cap-to-zero to return false so the parser correctly fails instead of silently dropping waypoints. --- src/game/packet_parsers_classic.cpp | 39 +++++++++++++++++++++++------ src/game/world_packets.cpp | 2 +- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index e0dd01f8..57780873 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1918,6 +1918,9 @@ namespace TurtleMoveFlags { } bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; + uint8_t updateFlags = packet.readUInt8(); block.updateFlags = static_cast(updateFlags); @@ -1931,6 +1934,8 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes + if (rem() < 52) return false; size_t livingStart = packet.getReadPos(); uint32_t moveFlags = packet.readUInt32(); @@ -1949,8 +1954,10 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // Transport — Classic flag position 0x02000000 if (moveFlags & TurtleMoveFlags::ONTRANSPORT) { + if (rem() < 1) return false; // PackedGuid mask byte block.onTransport = true; block.transportGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 20) return false; // 4 floats + u32 timestamp block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); @@ -1960,14 +1967,17 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // Pitch (swimming only, Classic-style) if (moveFlags & TurtleMoveFlags::SWIMMING) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time (always present) + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jump data if (moveFlags & TurtleMoveFlags::JUMPING) { + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -1976,10 +1986,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // Spline elevation if (moveFlags & TurtleMoveFlags::SPLINE_ELEVATION) { + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Turtle: 6 speeds (same as Classic — no flight speeds) + if (rem() < 24) return false; // 6 × float float walkSpeed = packet.readFloat(); float runSpeed = packet.readFloat(); float runBackSpeed = packet.readFloat(); @@ -1997,17 +2009,23 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) || (moveFlags & TurtleMoveFlags::SPLINE_TBC); if (hasSpline) { + if (rem() < 4) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" [Turtle] Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { + if (rem() < 12) return false; packet.readFloat(); packet.readFloat(); packet.readFloat(); } else if (splineFlags & 0x00020000) { + if (rem() < 8) return false; packet.readUInt64(); } else if (splineFlags & 0x00040000) { + if (rem() < 4) return false; packet.readFloat(); } + // timePassed + duration + splineId + pointCount = 16 bytes + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); @@ -2018,10 +2036,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc ++badTurtleSplineCount; if (badTurtleSplineCount <= 5 || (badTurtleSplineCount % 100) == 0) { LOG_WARNING(" [Turtle] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badTurtleSplineCount, ")"); + " exceeds max (occurrence=", badTurtleSplineCount, ")"); } - pointCount = 0; + return false; } + // points + endPoint + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { packet.readFloat(); packet.readFloat(); packet.readFloat(); } @@ -2034,6 +2054,7 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc " bytes, readPos now=", packet.getReadPos()); } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -2045,18 +2066,22 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // High GUID — 1×u32 if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t highGuid =*/ packet.readUInt32(); } if (updateFlags & UPDATEFLAG_ALL) { + if (rem() < 4) return false; /*uint32_t unkAll =*/ packet.readUInt32(); } if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) { + if (rem() < 1) return false; /*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet); } if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; /*uint32_t transportTime =*/ packet.readUInt32(); } @@ -2185,12 +2210,10 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec return this->TbcPacketParsers::parseMovementBlock(p, b); }, "tbc"); } - if (!ok) { - ok = parseMovementVariant( - [](network::Packet& p, UpdateBlock& b) { - return UpdateObjectParser::parseMovementBlock(p, b); - }, "wotlk"); - } + // NOTE: Do NOT fall back to WotLK parseMovementBlock here. + // WotLK uses uint16 updateFlags and 9 speeds vs Classic's uint8 + // and 6 speeds. A false-positive WotLK parse consumes wrong bytes, + // corrupting subsequent update fields and losing NPC data. break; case UpdateType::OUT_OF_RANGE_OBJECTS: case UpdateType::NEAR_OBJECTS: diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5001e7e4..d6c7109a 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1024,7 +1024,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*float splineElevation =*/ packet.readFloat(); } - // Speeds (7 speed values) + // Speeds (9 values in WotLK: walk/run/runBack/swim/swimBack/flight/flightBack/turn/pitch) /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); From 14cd6c82b260a397744a6fbd624a696ea322e9bb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 07:47:46 -0700 Subject: [PATCH 08/58] fix: add bounds checks to Classic movement block parser Mirror the Turtle parser hardening: check remaining bytes before every conditional read in ClassicPacketParsers::parseMovementBlock. Prevents silent garbage reads (readUInt8 returns 0 past EOF) that corrupt subsequent update fields and lose NPC data in multi-block packets. --- src/game/packet_parsers_classic.cpp | 46 +++++++++++++++++------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 57780873..0d4d09e2 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -189,11 +189,8 @@ uint32_t classicWireMoveFlags(uint32_t internalFlags) { // Same as TBC: u8 UpdateFlags, JUMPING=0x2000, 8 speeds, no pitchRate // ============================================================================ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { - // Validate minimum packet size for updateFlags byte - if (packet.getReadPos() >= packet.getSize()) { - LOG_WARNING("[Classic] Movement block packet too small (need at least 1 byte for updateFlags)"); - return false; - } + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; // Classic: UpdateFlags is uint8 (same as TBC) uint8_t updateFlags = packet.readUInt8(); @@ -209,6 +206,9 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes + if (rem() < 52) return false; + // Movement flags (u32 only — NO extra flags byte in Classic) uint32_t moveFlags = packet.readUInt32(); /*uint32_t time =*/ packet.readUInt32(); @@ -225,26 +225,29 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // Transport data (Classic: ONTRANSPORT=0x02000000, no timestamp) if (moveFlags & ClassicMoveFlags::ONTRANSPORT) { + if (rem() < 1) return false; block.onTransport = true; block.transportGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 16) return false; // 4 floats block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); block.transportO = packet.readFloat(); - // Classic: NO transport timestamp (TBC adds u32 timestamp) - // Classic: NO transport seat byte } // Pitch (Classic: only SWIMMING, no FLYING or ONTRANSPORT pitch) if (moveFlags & ClassicMoveFlags::SWIMMING) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time (always present) + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jumping (Classic: JUMPING=0x2000, same as TBC) if (moveFlags & ClassicMoveFlags::JUMPING) { + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -253,12 +256,12 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // Spline elevation if (moveFlags & ClassicMoveFlags::SPLINE_ELEVATION) { + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Speeds (Classic: 6 values — no flight speeds, no pitchRate) - // TBC added flying_speed + backwards_flying_speed (8 total) - // WotLK added pitchRate (9 total) + if (rem() < 24) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -271,34 +274,34 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // Spline data (Classic: SPLINE_ENABLED=0x00400000) if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) { + if (rem() < 4) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" [Classic] Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { // FINAL_POINT + if (rem() < 12) return false; /*float finalX =*/ packet.readFloat(); /*float finalY =*/ packet.readFloat(); /*float finalZ =*/ packet.readFloat(); } else if (splineFlags & 0x00020000) { // FINAL_TARGET + if (rem() < 8) return false; /*uint64_t finalTarget =*/ packet.readUInt64(); } else if (splineFlags & 0x00040000) { // FINAL_ANGLE + if (rem() < 4) return false; /*float finalAngle =*/ packet.readFloat(); } - // Classic spline: timePassed, duration, id, nodes, finalNode (same as TBC) + // Classic spline: timePassed, duration, id, pointCount + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); - if (pointCount > 256) { - static uint32_t badClassicSplineCount = 0; - ++badClassicSplineCount; - if (badClassicSplineCount <= 5 || (badClassicSplineCount % 100) == 0) { - LOG_WARNING(" [Classic] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badClassicSplineCount, ")"); - } - pointCount = 0; - } + if (pointCount > 256) return false; + + // points + endPoint (no splineMode in Classic) + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { /*float px =*/ packet.readFloat(); /*float py =*/ packet.readFloat(); @@ -312,6 +315,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo } } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -323,21 +327,25 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // High GUID if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t highGuid =*/ packet.readUInt32(); } // ALL/SELF extra uint32 if (updateFlags & UPDATEFLAG_ALL) { + if (rem() < 4) return false; /*uint32_t unkAll =*/ packet.readUInt32(); } // Current melee target as packed guid if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) { + if (rem() < 1) return false; /*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet); } // Transport progress / world time if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; /*uint32_t transportTime =*/ packet.readUInt32(); } From eca570140a2b240f5485ca24b8d0d60182b1a458 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 07:54:05 -0700 Subject: [PATCH 09/58] fix: eliminate 8-second teleport freeze on same-map teleport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace processAllReadyTiles() with bounded processReadyTiles() in the same-map teleport and reconnect paths. processAllReadyTiles finalizes every pending tile synchronously with a GPU sync wait, which caused 8+ second main-thread stalls when many tiles were queued. The bounded version processes 1-4 tiles per call with async GPU upload — remaining tiles finalize incrementally over subsequent frames. --- include/rendering/terrain_manager.hpp | 7 +++---- src/core/application.cpp | 12 +++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 290c45eb..9fa540b3 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -279,6 +279,9 @@ public: /** Process one ready tile (for loading screens with per-tile progress updates) */ void processOneReadyTile(); + /** Process a bounded batch of ready tiles with async GPU upload (no sync wait) */ + void processReadyTiles(); + private: /** * Get tile coordinates from GL world position @@ -317,10 +320,6 @@ private: */ void workerLoop(); - /** - * Main thread: poll for completed tiles and upload to GPU - */ - void processReadyTiles(); void ensureGroundEffectTablesLoaded(); void generateGroundClutterPlacements(std::shared_ptr& pending, std::unordered_set& preparedModelIds); diff --git a/src/core/application.cpp b/src/core/application.cpp index 4ff3aae1..d26be986 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1990,7 +1990,7 @@ void Application::setupUICallbacks() { worldEntryMovementGraceTimer_ = 2.0f; taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; - renderer->getTerrainManager()->processAllReadyTiles(); + renderer->getTerrainManager()->processReadyTiles(); { auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); std::vector> nearbyTiles; @@ -2023,10 +2023,12 @@ void Application::setupUICallbacks() { renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(0.5f); } - // Flush any tiles that finished background parsing during the cast - // (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before - // the first frame at the new position. - renderer->getTerrainManager()->processAllReadyTiles(); + // Kick off async upload for any tiles that finished background + // parsing. Use the bounded processReadyTiles() instead of + // processAllReadyTiles() to avoid multi-second main-thread stalls + // when many tiles are ready (the rest will finalize over subsequent + // frames via the normal terrain update loop). + renderer->getTerrainManager()->processReadyTiles(); // Queue all remaining tiles within the load radius (8 tiles = 17x17) // at the new position. precacheTiles skips already-loaded/pending tiles, From e802decc840ab68d288dbd379e5bbc3f6dac33a1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:01:39 -0700 Subject: [PATCH 10/58] fix: add bounds checks to TBC movement block parser Same hardening as the Classic and Turtle parsers: check remaining bytes before every conditional read in TbcPacketParsers::parseMovementBlock. Change spline pointCount > 256 to return false instead of capping to zero (which silently consumed wrong bytes for the endPoint). --- src/game/packet_parsers_tbc.cpp | 57 +++++++++++++++++---------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index c1397460..9d68879f 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -30,11 +30,8 @@ namespace TbcMoveFlags { // - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32) // ============================================================================ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { - // Validate minimum packet size for updateFlags byte - if (packet.getReadPos() >= packet.getSize()) { - LOG_WARNING("[TBC] Movement block packet too small (need at least 1 byte for updateFlags)"); - return false; - } + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; // TBC 2.4.3: UpdateFlags is uint8 (1 byte) uint8_t updateFlags = packet.readUInt8(); @@ -58,6 +55,9 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& const uint8_t UPDATEFLAG_HIGHGUID = 0x10; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+moveFlags2(1)+time(4)+position(16)+fallTime(4)+speeds(32) = 61 + if (rem() < 61) return false; + // Full movement block for living units uint32_t moveFlags = packet.readUInt32(); uint8_t moveFlags2 = packet.readUInt8(); // TBC: uint8, not uint16 @@ -76,29 +76,33 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& // Transport data if (moveFlags & TbcMoveFlags::ON_TRANSPORT) { + if (rem() < 1) return false; block.onTransport = true; block.transportGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 20) return false; // 4 floats + 1 uint32 block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); block.transportO = packet.readFloat(); /*uint32_t tTime =*/ packet.readUInt32(); - // TBC: NO transport seat byte - // TBC: NO interpolated movement check } // Pitch: SWIMMING, or else ONTRANSPORT (TBC-specific secondary pitch) if (moveFlags & TbcMoveFlags::SWIMMING) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } else if (moveFlags & TbcMoveFlags::ONTRANSPORT) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time (always present) + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jumping (TBC: JUMPING=0x2000, WotLK: FALLING=0x1000) if (moveFlags & TbcMoveFlags::JUMPING) { + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -107,11 +111,12 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& // Spline elevation (TBC: 0x02000000, WotLK: 0x04000000) if (moveFlags & TbcMoveFlags::SPLINE_ELEVATION) { + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Speeds (TBC: 8 values — walk, run, runBack, swim, fly, flyBack, swimBack, turn) - // WotLK adds pitchRate (9 total) + if (rem() < 32) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -126,49 +131,47 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& // Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000) if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) { + if (rem() < 4) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" [TBC] Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { // FINAL_POINT + if (rem() < 12) return false; /*float finalX =*/ packet.readFloat(); /*float finalY =*/ packet.readFloat(); /*float finalZ =*/ packet.readFloat(); } else if (splineFlags & 0x00020000) { // FINAL_TARGET + if (rem() < 8) return false; /*uint64_t finalTarget =*/ packet.readUInt64(); } else if (splineFlags & 0x00040000) { // FINAL_ANGLE + if (rem() < 4) return false; /*float finalAngle =*/ packet.readFloat(); } - // TBC spline: timePassed, duration, id, nodes, finalNode - // (no durationMod, durationModNext, verticalAccel, effectStartTime, splineMode) + // TBC spline: timePassed, duration, id, pointCount + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); - if (pointCount > 256) { - static uint32_t badTbcSplineCount = 0; - ++badTbcSplineCount; - if (badTbcSplineCount <= 5 || (badTbcSplineCount % 100) == 0) { - LOG_WARNING(" [TBC] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badTbcSplineCount, ")"); - } - pointCount = 0; - } + if (pointCount > 256) return false; + + // points + endPoint (no splineMode in TBC) + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { /*float px =*/ packet.readFloat(); /*float py =*/ packet.readFloat(); /*float pz =*/ packet.readFloat(); } - // TBC: NO splineMode byte (WotLK adds it) /*float endPointX =*/ packet.readFloat(); /*float endPointY =*/ packet.readFloat(); /*float endPointZ =*/ packet.readFloat(); } } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { - // TBC: Simple stationary position (same as WotLK STATIONARY) + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -177,29 +180,29 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& LOG_DEBUG(" [TBC] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")"); } - // TBC: No UPDATEFLAG_POSITION (0x0100) code path // Target GUID if (updateFlags & UPDATEFLAG_HAS_TARGET) { + if (rem() < 1) return false; /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); } // Transport time if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; /*uint32_t transportTime =*/ packet.readUInt32(); } - // TBC: No VEHICLE flag (WotLK 0x0080) - // TBC: No ROTATION flag (WotLK 0x0200) - - // HIGH_GUID (0x08) — TBC has 2 u32s, Classic has 1 u32 + // LOWGUID (0x08) — TBC has 2 u32s, Classic has 1 u32 if (updateFlags & UPDATEFLAG_LOWGUID) { + if (rem() < 8) return false; /*uint32_t unknown0 =*/ packet.readUInt32(); /*uint32_t unknown1 =*/ packet.readUInt32(); } - // ALL (0x10) + // HIGHGUID (0x10) if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t unknown2 =*/ packet.readUInt32(); } From d1c99b1c0e28e2775c5994277a82e33f3a78911c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:04:00 -0700 Subject: [PATCH 11/58] fix: add bounds checks to WotLK movement block parser Complete the parser hardening across all expansions. Check remaining bytes before every conditional read in the WotLK base UpdateObjectParser::parseMovementBlock: LIVING entry (66-byte minimum), transport, pitch, fall time, jumping, spline elevation, speeds, POSITION, STATIONARY, and all tail flags (HAS_TARGET, TRANSPORT, VEHICLE, ROTATION, LOWGUID, HIGHGUID). Prevents silent garbage reads when Packet::readUInt8/readFloat return 0 past EOF. --- src/game/world_packets.cpp | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d6c7109a..cbeb011d 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -913,6 +913,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // 1. UpdateFlags (1 byte, sometimes 2) // 2. Movement data depends on update flags + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + // Update flags (3.3.5a uses 2 bytes for flags) uint16_t updateFlags = packet.readUInt16(); block.updateFlags = updateFlags; @@ -957,6 +960,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock const uint16_t UPDATEFLAG_HIGHGUID = 0x0010; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+moveFlags2(2)+time(4)+position(16)+fallTime(4)+speeds(36) = 66 + if (rem() < 66) return false; + // Full movement block for living units uint32_t moveFlags = packet.readUInt32(); uint16_t moveFlags2 = packet.readUInt16(); @@ -974,8 +980,10 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Transport data (if on transport) if (moveFlags & 0x00000200) { // MOVEMENTFLAG_ONTRANSPORT + if (rem() < 1) return false; block.onTransport = true; block.transportGuid = readPackedGuid(packet); + if (rem() < 21) return false; // 4 floats + uint32 + uint8 block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); @@ -987,6 +995,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); if (moveFlags2 & 0x0200) { // MOVEMENTFLAG2_INTERPOLATED_MOVEMENT + if (rem() < 4) return false; /*uint32_t tTime2 =*/ packet.readUInt32(); } } @@ -1005,14 +1014,17 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock if ((moveFlags & 0x00200000) /* SWIMMING */ || (moveFlags & 0x01000000) /* FLYING */ || (moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jumping if (moveFlags & 0x00001000) { // MOVEMENTFLAG_FALLING + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -1021,10 +1033,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Spline elevation if (moveFlags & 0x04000000) { // MOVEMENTFLAG_SPLINE_ELEVATION + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Speeds (9 values in WotLK: walk/run/runBack/swim/swimBack/flight/flightBack/turn/pitch) + if (rem() < 36) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -1157,7 +1171,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } else if (updateFlags & UPDATEFLAG_POSITION) { // Transport position update (UPDATEFLAG_POSITION = 0x0100) + if (rem() < 1) return false; uint64_t transportGuid = readPackedGuid(packet); + if (rem() < 32) return false; // 8 floats block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -1186,7 +1202,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } } else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) { - // Simple stationary position (4 floats) + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -1198,32 +1214,38 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Target GUID (for units with target) if (updateFlags & UPDATEFLAG_HAS_TARGET) { + if (rem() < 1) return false; /*uint64_t targetGuid =*/ readPackedGuid(packet); } // Transport time if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; /*uint32_t transportTime =*/ packet.readUInt32(); } // Vehicle if (updateFlags & UPDATEFLAG_VEHICLE) { + if (rem() < 8) return false; /*uint32_t vehicleId =*/ packet.readUInt32(); /*float vehicleOrientation =*/ packet.readFloat(); } // Rotation (GameObjects) if (updateFlags & UPDATEFLAG_ROTATION) { + if (rem() < 8) return false; /*int64_t rotation =*/ packet.readUInt64(); } // Low GUID if (updateFlags & UPDATEFLAG_LOWGUID) { + if (rem() < 4) return false; /*uint32_t lowGuid =*/ packet.readUInt32(); } // High GUID if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t highGuid =*/ packet.readUInt32(); } From 64b03ffdf56087ac03e0fb2b2e0052ac7f6567fc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:08:08 -0700 Subject: [PATCH 12/58] fix: add bounds checks to update block and field parsers Check remaining packet data before reading update type, GUIDs, object type, and block count in parseUpdateBlock and parseUpdateFields. Prevents silent garbage reads when the parser reaches the end of a truncated or misaligned packet. --- src/game/world_packets.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index cbeb011d..24a2b4fb 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1255,6 +1255,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& block) { size_t startPos = packet.getReadPos(); + if (packet.getReadPos() >= packet.getSize()) return false; + // Read number of blocks (each block is 32 fields = 32 bits) uint8_t blockCount = packet.readUInt8(); @@ -1342,6 +1344,8 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& block) { + if (packet.getReadPos() >= packet.getSize()) return false; + // Read update type uint8_t updateTypeVal = packet.readUInt8(); block.updateType = static_cast(updateTypeVal); @@ -1351,6 +1355,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& switch (block.updateType) { case UpdateType::VALUES: { // Partial update - changed fields only + if (packet.getReadPos() >= packet.getSize()) return false; block.guid = readPackedGuid(packet); LOG_DEBUG(" VALUES update for GUID: 0x", std::hex, block.guid, std::dec); @@ -1359,6 +1364,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& case UpdateType::MOVEMENT: { // Movement update + if (packet.getReadPos() + 8 > packet.getSize()) return false; block.guid = packet.readUInt64(); LOG_DEBUG(" MOVEMENT update for GUID: 0x", std::hex, block.guid, std::dec); @@ -1368,10 +1374,12 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& case UpdateType::CREATE_OBJECT: case UpdateType::CREATE_OBJECT2: { // Create new object with full data + if (packet.getReadPos() >= packet.getSize()) return false; block.guid = readPackedGuid(packet); LOG_DEBUG(" CREATE_OBJECT for GUID: 0x", std::hex, block.guid, std::dec); // Read object type + if (packet.getReadPos() >= packet.getSize()) return false; uint8_t objectTypeVal = packet.readUInt8(); block.objectType = static_cast(objectTypeVal); LOG_DEBUG(" Object type: ", (int)objectTypeVal); From 0b33bcbe53cebfd33a4fc2bf114a37c4c827b3f2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:18:21 -0700 Subject: [PATCH 13/58] fix: reject oversized MonsterMove spline and fix loot format comment Change WotLK MonsterMove pointCount > 1000 from cap-to-1000 to return false. Capping caused the parser to read only 1000 of N points, leaving the remaining point data unread and misaligning subsequent reads. Also correct misleading loot response comment: Classic/TBC DO include randomSuffix and randomPropertyId (22 bytes/item, same as WotLK). The only WotLK difference is the quest item list appended after regular items. --- src/game/game_handler.cpp | 4 ++-- src/game/world_packets.cpp | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 77fe91b5..8b96c8e4 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21253,8 +21253,8 @@ void GameHandler::unstuckHearth() { } void GameHandler::handleLootResponse(network::Packet& packet) { - // Classic 1.12 and TBC 2.4.3 use 14 bytes/item (no randomSuffix/randomProp fields); - // WotLK 3.3.5a uses 22 bytes/item. + // All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType). + // WotLK adds a quest item list after the regular items. const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 24a2b4fb..d9f40091 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3252,12 +3252,11 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { if (pointCount == 0) return true; - // Cap pointCount to prevent excessive iteration from malformed packets. constexpr uint32_t kMaxSplinePoints = 1000; if (pointCount > kMaxSplinePoints) { LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, - " (guid=0x", std::hex, data.guid, std::dec, "), capping"); - pointCount = kMaxSplinePoints; + " (guid=0x", std::hex, data.guid, std::dec, ")"); + return false; } // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). From 18c06d98ac855d7dde2dd5d6bef66200e4bdf250 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:22:50 -0700 Subject: [PATCH 14/58] fix: stop creature run animation when movement interpolation completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creatures were stuck in Run/Walk animation during the dead-reckoning overrun window (up to 2x movement duration). The animation check used isEntityMoving() which stays true through dead reckoning, causing creatures to "run in place" after reaching their destination. Add isActivelyMoving() which is true only during the active interpolation phase (moveElapsed < moveDuration), and use it for animation state transitions. Dead reckoning still works for position extrapolation — only the animation now correctly stops at arrival. --- include/game/entity.hpp | 7 +++++++ src/core/application.cpp | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 57147902..a05ff782 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -135,6 +135,13 @@ public: bool isEntityMoving() const { return isMoving_; } + /// True only during the active interpolation phase (before reaching destination). + /// Unlike isEntityMoving(), this does NOT include the dead-reckoning overrun window, + /// so animations (Run/Walk) should use this to avoid "running in place" after arrival. + bool isActivelyMoving() const { + return isMoving_ && moveElapsed_ < moveDuration_; + } + // Returns the latest server-authoritative position: destination if moving, current if not. // Unlike getX/Y/Z (which only update via updateMovement), this always reflects the // last known server position regardless of distance culling. diff --git a/src/core/application.cpp b/src/core/application.cpp index d26be986..45a97cac 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1647,7 +1647,11 @@ void Application::update(float deltaTime) { // startMoveTo() in handleMonsterMove, regardless of distance-cull. // This correctly detects movement for distant creatures (> 150u) // where updateMovement() is not called and getX/Y/Z() stays stale. - const bool entityIsMoving = entity->isEntityMoving(); + // Use isActivelyMoving() (not isEntityMoving()) so the + // Run/Walk animation stops when the creature reaches its + // destination, rather than persisting through the dead- + // reckoning overrun window. + const bool entityIsMoving = entity->isActivelyMoving(); const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); From a619f44dfbc698a6b042c1cbe7490a804ef600b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:33:45 -0700 Subject: [PATCH 15/58] fix: add per-frame animation sync for online players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Online players had no animation state machine — once Run started from a movement packet, it never transitioned back to Stand/Idle. This mirrors the creature sync loop: position, orientation, and locomotion animation (Run/Walk/Swim/Fly ↔ Stand/SwimIdle/FlyIdle) are now driven per-frame based on Entity::isActivelyMoving() state transitions. Also cleans up creatureRenderPosCache_ on player despawn. --- src/core/application.cpp | 105 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index 45a97cac..45a11e95 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1720,6 +1720,110 @@ void Application::update(float deltaTime) { } } + // --- Online player render sync (position, orientation, animation) --- + // Mirrors the creature sync loop above but without collision guard or + // weapon-attach logic. Without this, online players never transition + // back to Stand after movement stops ("run in place" bug). + auto playerSyncStart = std::chrono::steady_clock::now(); + if (renderer && gameHandler && renderer->getCharacterRenderer()) { + auto* charRenderer = renderer->getCharacterRenderer(); + glm::vec3 pPos(0.0f); + bool havePPos = false; + if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) { + pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ()); + havePPos = true; + } + const float pSyncRadiusSq = 320.0f * 320.0f; + + for (const auto& [guid, instanceId] : playerInstances_) { + auto entity = gameHandler->getEntityManager().getEntity(guid); + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + + // Distance cull + if (havePPos) { + glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + glm::vec3 d = latestCanonical - pPos; + if (glm::dot(d, d) > pSyncRadiusSq) continue; + } + + // Position sync + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + auto posIt = creatureRenderPosCache_.find(guid); + if (posIt == creatureRenderPosCache_.end()) { + charRenderer->setInstancePosition(instanceId, renderPos); + creatureRenderPosCache_[guid] = renderPos; + } else { + const glm::vec3 prevPos = posIt->second; + const glm::vec2 delta2(renderPos.x - prevPos.x, renderPos.y - prevPos.y); + float planarDist = glm::length(delta2); + float dz = std::abs(renderPos.z - prevPos.z); + + auto unitPtr = std::static_pointer_cast(entity); + const bool deadOrCorpse = unitPtr->getHealth() == 0; + const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); + const bool entityIsMoving = entity->isActivelyMoving(); + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f); + + if (deadOrCorpse || largeCorrection) { + charRenderer->setInstancePosition(instanceId, renderPos); + } else if (planarDist > 0.03f || dz > 0.08f) { + float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); + charRenderer->moveInstanceTo(instanceId, renderPos, duration); + } + posIt->second = renderPos; + + // Drive movement animation (same logic as creatures) + const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; + const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; + const bool isFlyingNow = creatureFlyingState_.count(guid) > 0; + bool prevMoving = creatureWasMoving_[guid]; + bool prevSwimming = creatureWasSwimming_[guid]; + bool prevFlying = creatureWasFlying_[guid]; + bool prevWalking = creatureWasWalking_[guid]; + const bool stateChanged = (isMovingNow != prevMoving) || + (isSwimmingNow != prevSwimming) || + (isFlyingNow != prevFlying) || + (isWalkingNow != prevWalking && isMovingNow); + if (stateChanged) { + creatureWasMoving_[guid] = isMovingNow; + creatureWasSwimming_[guid] = isSwimmingNow; + creatureWasFlying_[guid] = isFlyingNow; + creatureWasWalking_[guid] = isWalkingNow; + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + uint32_t targetAnim; + if (isMovingNow) { + if (isFlyingNow) targetAnim = 159u; // FlyForward + else if (isSwimmingNow) targetAnim = 42u; // Swim + else if (isWalkingNow) targetAnim = 4u; // Walk + else targetAnim = 5u; // Run + } else { + if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) + else if (isSwimmingNow) targetAnim = 41u; // SwimIdle + else targetAnim = 0u; // Stand + } + charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); + } + } + } + + // Orientation sync + float renderYaw = entity->getOrientation() + glm::radians(90.0f); + charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); + } + } + { + float psMs = std::chrono::duration( + std::chrono::steady_clock::now() - playerSyncStart).count(); + if (psMs > 5.0f) { + LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (", + playerInstances_.size(), " players)"); + } + } + // Movement heartbeat is sent from GameHandler::update() to avoid // duplicate packets from multiple update loops. @@ -7050,6 +7154,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) { playerInstances_.erase(it); onlinePlayerAppearance_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid); + creatureRenderPosCache_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); creatureFlyingState_.erase(guid); From e54ed1d46fcb78a519baa05f7c749e2ec845ee9d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:39:35 -0700 Subject: [PATCH 16/58] fix: pass correct offset to setPlayerOnTransport on transport boarding Both CREATE_OBJECT and MOVEMENT update paths called setPlayerOnTransport(guid, vec3(0)) then immediately overwrote playerTransportOffset_ on the next line. This left a one-frame window where the composed world position used (0,0,0) as the local offset, causing the player to visually snap to the transport origin. Compute the canonical offset first and pass it directly. --- 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 8b96c8e4..6db0e1fc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11293,10 +11293,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem // Track player-on-transport state if (block.guid == playerGuid) { if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); // Convert transport offset from server → canonical coordinates glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + setPlayerOnTransport(block.transportGuid, canonicalOffset); if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); entity->setPosition(composed.x, composed.y, composed.z, oCanonical); @@ -12430,10 +12430,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem // Track player-on-transport state from MOVEMENT updates if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); // Convert transport offset from server → canonical coordinates glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + setPlayerOnTransport(block.transportGuid, canonicalOffset); if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); entity->setPosition(composed.x, composed.y, composed.z, oCanonical); From 100d66d18b0111d061ce3509b38b42afe1dab114 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:43:19 -0700 Subject: [PATCH 17/58] fix: play death/attack animations for online players, not just NPCs Death, respawn, and melee swing callbacks only checked creatureInstances_, so online players never played death animation when killed, never returned to idle on resurrect, and never showed attack swings. Extended all three callbacks to also check playerInstances_. Also extended the game_handler death/respawn callback triggers to fire for PLAYER entities (not just UNIT), and added spawn-time death detection for players that are already dead when first seen. --- src/core/application.cpp | 39 ++++++++++++++++++++++++++++++--------- src/game/game_handler.cpp | 15 ++++++++++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 45a11e95..78af06ab 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2999,29 +2999,50 @@ void Application::setupUICallbacks() { } }); - // NPC death callback (online mode) - play death animation + // NPC/player death callback (online mode) - play death animation gameHandler->setNpcDeathCallback([this](uint64_t guid) { deadCreatureGuids_.insert(guid); + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death + if (it != creatureInstances_.end()) instanceId = it->second; + else { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0) { + renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death } }); - // NPC respawn callback (online mode) - reset to idle animation + // NPC/player respawn callback (online mode) - reset to idle animation gameHandler->setNpcRespawnCallback([this](uint64_t guid) { deadCreatureGuids_.erase(guid); + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 0, true); // Idle + if (it != creatureInstances_.end()) instanceId = it->second; + else { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0) { + renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle } }); - // NPC swing callback (online mode) - play attack animation + // NPC/player swing callback (online mode) - play attack animation gameHandler->setNpcSwingCallback([this](uint64_t guid) { + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack + if (it != creatureInstances_.end()) instanceId = it->second; + else { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0) { + renderer->getCharacterRenderer()->playAnimation(instanceId, 16, false); // Attack } }); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6db0e1fc..6e2452d5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11562,6 +11562,9 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); } } + if (unitInitiallyDead && npcDeathCallback_) { + npcDeathCallback_(block.guid); + } } else if (creatureSpawnCallback_) { LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, " displayId=", unit->getDisplayId(), " at (", @@ -11908,7 +11911,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem corpseX_, ",", corpseY_, ",", corpseZ_, ") map=", corpseMapId_); } - if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) { npcDeathCallback_(block.guid); npcDeathNotified = true; } @@ -11921,7 +11924,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem LOG_INFO("Player entered ghost form"); } } - if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) { npcRespawnCallback_(block.guid); npcRespawnNotified = true; } @@ -11952,7 +11955,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem selfResAvailable_ = false; LOG_INFO("Player resurrected (dynamic flags)"); } - } else if (entity->getType() == ObjectType::UNIT) { + } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; if (!wasDead && nowDead) { @@ -12088,6 +12091,12 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); } } + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } } else if (creatureSpawnCallback_) { float unitScale2 = 1.0f; { From 1af5acba3fff09f89b6c8ae69b7cfd8b3a543067 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:49:16 -0700 Subject: [PATCH 18/58] fix: show real player names on nameplates instead of "Player" Player class declared its own 'name' member and getName()/setName() that shadowed the inherited Unit::name. Since getName() is non-virtual, code using Unit* pointers (nameplates, target frame, entity list) read Unit::name (always empty) while Player::setName() wrote to the shadowed Player::name. Removed the redundant declaration so Player inherits name storage from Unit. --- include/game/entity.hpp | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index a05ff782..a608f6f5 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -284,18 +284,14 @@ protected: /** * Player entity + * Name is inherited from Unit — do NOT redeclare it here or the + * shadowed field will diverge from Unit::name, causing nameplates + * and other Unit*-based lookups to read an empty string. */ class Player : public Unit { public: Player() { type = ObjectType::PLAYER; } explicit Player(uint64_t guid) : Unit(guid) { type = ObjectType::PLAYER; } - - // Name - const std::string& getName() const { return name; } - void setName(const std::string& n) { name = n; } - -protected: - std::string name; }; /** From 6d9adc547a915018303aeb710df28b551bbaaf85 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 08:52:00 -0700 Subject: [PATCH 19/58] fix: extend world-load animation callbacks to handle online players loadOnlineWorldTerrain re-registers the death/respawn/swing callbacks, overriding the ones from setupUICallbacks. The world-load versions only checked creatureInstances_, so the player lookup fix from the previous commit was silently reverted whenever the world loaded. Now both registration sites check playerInstances_ as a fallback. --- src/core/application.cpp | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 78af06ab..28f2fad1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4854,24 +4854,42 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) { app->deadCreatureGuids_.insert(guid); + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 1, false); // animation ID 1 = Death + if (it != app->creatureInstances_.end()) instanceId = it->second; + else { + auto pit = app->playerInstances_.find(guid); + if (pit != app->playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death } }); gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) { app->deadCreatureGuids_.erase(guid); + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle + if (it != app->creatureInstances_.end()) instanceId = it->second; + else { + auto pit = app->playerInstances_.find(guid); + if (pit != app->playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle } }); gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) { + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1 + if (it != app->creatureInstances_.end()) instanceId = it->second; + else { + auto pit = app->playerInstances_.find(guid); + if (pit != app->playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1 } }); } From 8b7786f2b3a24d627f06b8c1fe08a3627773a455 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:08:46 -0700 Subject: [PATCH 20/58] feat: display combo points on target frame Add 5-dot combo point indicator between target power bar and cast bar. Lit dots are yellow (1-4 CP) or red (5 CP) with glow effect; unlit dots show as dark outlines. Only visible when the player's combo target matches the current target. --- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 44d02cb1..88130e6d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4330,6 +4330,38 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // Combo points — shown when the player has combo points on this target + { + uint8_t cp = gameHandler.getComboPoints(); + if (cp > 0 && gameHandler.getComboTarget() == target->getGuid()) { + const float dotSize = 12.0f; + const float dotSpacing = 4.0f; + const int maxCP = 5; + float totalW = maxCP * dotSize + (maxCP - 1) * dotSpacing; + float startX = (frameW - totalW) * 0.5f; + ImGui::SetCursorPosX(startX); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (int ci = 0; ci < maxCP; ++ci) { + float cx = cursor.x + ci * (dotSize + dotSpacing) + dotSize * 0.5f; + float cy = cursor.y + dotSize * 0.5f; + if (ci < static_cast(cp)) { + // Lit: yellow for 1-4, red glow for 5 + ImU32 col = (cp >= 5) + ? IM_COL32(255, 50, 30, 255) + : IM_COL32(255, 210, 30, 255); + dl->AddCircleFilled(ImVec2(cx, cy), dotSize * 0.45f, col); + // Subtle glow + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.5f, IM_COL32(255, 255, 200, 80), 0, 1.5f); + } else { + // Unlit: dark outline + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.4f, IM_COL32(80, 80, 80, 180), 0, 1.5f); + } + } + ImGui::Dummy(ImVec2(totalW, dotSize + 2.0f)); + } + } + // Target cast bar — shown when the target is casting if (gameHandler.isTargetCasting()) { float castPct = gameHandler.getTargetCastProgress(); From 003ad8b20ce48ccb6ef2725884e448f01a27040b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:17:00 -0700 Subject: [PATCH 21/58] fix: read WotLK periodic damage isCrit byte in SMSG_PERIODICAURALOG The WotLK periodic damage format includes an isCrit byte after resisted (21 bytes total, not 20). Missing this byte caused parse misalignment for multi-effect periodicauralog packets. Also use the already-read isCrit on periodic heals to display critical HoT ticks distinctly. --- src/game/game_handler.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6e2452d5..0cdfe948 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4250,17 +4250,20 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t auraType = packet.readUInt8(); if (auraType == 3 || auraType == 89) { // 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 + // WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes const bool periodicWotlk = isActiveExpansion("wotlk"); - const size_t dotSz = periodicWotlk ? 20u : 16u; + const size_t dotSz = periodicWotlk ? 21u : 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(); + bool dotCrit = false; + if (periodicWotlk) dotCrit = (packet.readUInt8() != 0); if (dmg > 0) - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), + addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE, + static_cast(dmg), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (abs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(abs), @@ -4278,11 +4281,13 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t max=*/ packet.readUInt32(); /*uint32_t over=*/ packet.readUInt32(); uint32_t hotAbs = 0; + bool hotCrit = false; if (healWotlk) { hotAbs = packet.readUInt32(); - /*uint8_t isCrit=*/ packet.readUInt8(); + hotCrit = (packet.readUInt8() != 0); } - addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), + addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL, + static_cast(heal), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (hotAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), From e572cdfb4af91c594120854dcb87f2c3bfaef7f4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:44:43 -0700 Subject: [PATCH 22/58] feat: show guild names on player nameplates Read PLAYER_GUILDID from entity update fields (UNIT_END + 3) and query guild names via CMSG_GUILD_QUERY. Cache results in guildNameCache_ so each guild ID is queried only once. Display in grey below the player name on nameplates. Fix handleGuildQueryResponse to not overwrite the local player's guild data when querying other guilds. --- include/game/game_handler.hpp | 9 ++++++ src/game/game_handler.cpp | 58 ++++++++++++++++++++++++++++------- src/ui/game_screen.cpp | 20 ++++++++++++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 47997040..02bd74cb 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -608,6 +608,13 @@ public: uint32_t getPetitionCost() const { return petitionCost_; } uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; } + // Guild name lookup for other players' nameplates + // Returns the guild name for a given guildId, or empty if unknown. + // Automatically queries the server for unknown guild IDs. + const std::string& lookupGuildName(uint32_t guildId); + // Returns the guildId for a player entity (from PLAYER_GUILDID update field). + uint32_t getEntityGuildId(uint64_t guid) const; + // Ready check struct ReadyCheckResult { std::string name; @@ -2952,6 +2959,8 @@ private: GuildInfoData guildInfoData_; GuildQueryResponseData guildQueryData_; bool hasGuildRoster_ = false; + std::unordered_map guildNameCache_; // guildId → guild name + std::unordered_set pendingGuildNameQueries_; // in-flight guild queries bool pendingGuildInvite_ = false; std::string pendingGuildInviterName_; std::string pendingGuildInviteGuildName_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0cdfe948..15e55819 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -759,6 +759,8 @@ void GameHandler::disconnect() { activeCharacterGuid_ = 0; playerNameCache.clear(); pendingNameQueries.clear(); + guildNameCache_.clear(); + pendingGuildNameQueries_.clear(); friendGuids_.clear(); contacts_.clear(); transportAttachments_.clear(); @@ -19577,6 +19579,28 @@ void GameHandler::queryGuildInfo(uint32_t guildId) { LOG_INFO("Querying guild info: guildId=", guildId); } +static const std::string kEmptyString; + +const std::string& GameHandler::lookupGuildName(uint32_t guildId) { + if (guildId == 0) return kEmptyString; + auto it = guildNameCache_.find(guildId); + if (it != guildNameCache_.end()) return it->second; + // Query the server if we haven't already + if (pendingGuildNameQueries_.insert(guildId).second) { + queryGuildInfo(guildId); + } + return kEmptyString; +} + +uint32_t GameHandler::getEntityGuildId(uint64_t guid) const { + auto entity = entityManager.getEntity(guid); + if (!entity || entity->getType() != ObjectType::PLAYER) return 0; + // PLAYER_GUILDID = UNIT_END + 3 across all expansions + const uint16_t ufUnitEnd = fieldIndex(UF::UNIT_END); + if (ufUnitEnd == 0xFFFF) return 0; + return entity->getField(ufUnitEnd + 3); +} + void GameHandler::createGuild(const std::string& guildName) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildCreatePacket::build(guildName); @@ -19661,18 +19685,30 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; - const bool wasUnknown = guildName_.empty(); - guildName_ = data.guildName; - guildQueryData_ = data; - guildRankNames_.clear(); - for (uint32_t i = 0; i < 10; ++i) { - guildRankNames_.push_back(data.rankNames[i]); + // Always cache the guild name for nameplate lookups + if (data.guildId != 0 && !data.guildName.empty()) { + guildNameCache_[data.guildId] = data.guildName; + pendingGuildNameQueries_.erase(data.guildId); + } + + // Check if this is the local player's guild + const Character* ch = getActiveCharacter(); + bool isLocalGuild = (ch && ch->hasGuild() && ch->guildId == data.guildId); + + if (isLocalGuild) { + const bool wasUnknown = guildName_.empty(); + guildName_ = data.guildName; + guildQueryData_ = data; + guildRankNames_.clear(); + for (uint32_t i = 0; i < 10; ++i) { + guildRankNames_.push_back(data.rankNames[i]); + } + LOG_INFO("Guild name set to: ", guildName_); + if (wasUnknown && !guildName_.empty()) + addSystemChatMessage("Guild: <" + guildName_ + ">"); + } else { + LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName); } - LOG_INFO("Guild name set to: ", guildName_); - // Only announce once — when we first learn our own guild name at login. - // Subsequent queries (e.g. querying other players' guilds) are silent. - if (wasUnknown && !guildName_.empty()) - addSystemChatMessage("Guild: <" + guildName_ + ">"); } void GameHandler::handleGuildEvent(network::Packet& packet) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 88130e6d..902940e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11125,9 +11125,29 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC } + // Guild name for player nameplates: shift name up to make room + std::string guildTag; + if (isPlayer) { + uint32_t guildId = gameHandler.getEntityGuildId(guid); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) guildTag = "<" + gn + ">"; + } + } + if (!guildTag.empty()) nameY -= 10.0f; // shift name up for guild line + drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + // Guild tag below the name (WoW-style in lighter color) + if (!guildTag.empty()) { + ImVec2 guildSz = ImGui::CalcTextSize(guildTag.c_str()); + float guildX = sx - guildSz.x * 0.5f; + float guildY = nameY + textSize.y + 1.0f; + drawList->AddText(ImVec2(guildX + 1.0f, guildY + 1.0f), IM_COL32(0, 0, 0, A(120)), guildTag.c_str()); + drawList->AddText(ImVec2(guildX, guildY), IM_COL32(180, 180, 180, A(200)), guildTag.c_str()); + } + // Group leader crown to the right of the name on player nameplates if (isPlayer && gameHandler.isInGroup() && gameHandler.getPartyData().leaderGuid == guid) { From 6aea48aea9b9bfb1340e64059ba4251d34fb0a66 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:48:03 -0700 Subject: [PATCH 23/58] feat: show guild name on target frame for players Display below the player name in the target frame, using the same guild name cache as nameplates. --- src/ui/game_screen.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 902940e8..db869acf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4182,6 +4182,17 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // Player guild name (e.g. "") — mirrors NPC subtitle styling + if (target->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(target->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + // Right-click context menu on the target name if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); From 63b4394e3e69bc0a46733068345287b1aacb2471 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:54:52 -0700 Subject: [PATCH 24/58] feat: world-space floating combat text above entities Combat text (damage, heals, misses, crits, etc.) now floats above the target entity in 3D space instead of appearing at fixed screen positions. Text rises upward from the entity's head, with random horizontal stagger to prevent stacking. HUD-only types (XP, Honor, Procs) and entries without a valid entity anchor fall back to the original screen overlay. --- include/game/spell_defines.hpp | 3 + src/game/game_handler.cpp | 6 + src/ui/game_screen.cpp | 529 +++++++++++++++++++-------------- 3 files changed, 308 insertions(+), 230 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 67d94c2d..c6fe3663 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -61,6 +61,9 @@ struct CombatTextEntry { float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower + uint64_t srcGuid = 0; // Source entity (attacker/caster) + uint64_t dstGuid = 0; // Destination entity (victim/target) — used for world-space positioning + float xSeed = 0.0f; // Random horizontal offset seed (-1..1) to stagger overlapping text static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 15e55819..eac38722 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15560,6 +15560,12 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; entry.powerType = powerType; + entry.srcGuid = srcGuid; + entry.dstGuid = dstGuid; + // Random horizontal stagger so simultaneous hits don't stack vertically + static std::mt19937 rng(std::random_device{}()); + std::uniform_real_distribution dist(-1.0f, 1.0f); + entry.xSeed = dist(rng); combatText.push_back(entry); // Persistent combat log — use explicit GUIDs if provided, else fall back to diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index db869acf..dd727550 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10408,262 +10408,331 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { if (entries.empty()) return; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); - // Render combat text entries overlaid on screen - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + // Camera for world-space projection + auto* appRenderer = core::Application::getInstance().getRenderer(); + rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr; + glm::mat4 viewProj; + if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + const float baseFontSize = ImGui::GetFontSize(); - if (ImGui::Begin("##CombatText", nullptr, flags)) { - // Incoming events (enemy attacks player) float near screen center (over the player). - // Outgoing events (player attacks enemy) float on the right side (near the target). - const float incomingX = screenW * 0.40f; - const float outgoingX = screenW * 0.68f; + // HUD fallback: entries without world-space anchor use classic screen-position layout. + // We still need an ImGui window for those. + const float hudIncomingX = screenW * 0.40f; + const float hudOutgoingX = screenW * 0.68f; + int hudInIdx = 0, hudOutIdx = 0; + bool needsHudWindow = false; - int inIdx = 0, outIdx = 0; - for (const auto& entry : entries) { - float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); - float yOffset = 200.0f - entry.age * 60.0f; - const bool outgoing = entry.isPlayerSource; + for (const auto& entry : entries) { + const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); + const bool outgoing = entry.isPlayerSource; - ImVec4 color; - char text[64]; - switch (entry.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 1.0f, 0.3f, alpha) : // Outgoing = yellow - ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red - break; - case game::CombatTextEntry::CRIT_DAMAGE: - snprintf(text, sizeof(text), "-%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.8f, 0.0f, alpha) : // Outgoing crit = bright yellow - ImVec4(1.0f, 0.5f, 0.0f, alpha); // Incoming crit = orange - break; - case game::CombatTextEntry::HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::CRIT_HEAL: - snprintf(text, sizeof(text), "+%d!", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::MISS: - snprintf(text, sizeof(text), "Miss"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); - break; - case game::CombatTextEntry::DODGE: - // outgoing=true: enemy dodged player's attack - // outgoing=false: player dodged incoming attack - snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PARRY: - snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::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; - case game::CombatTextEntry::EVADE: - snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PERIODIC_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.9f, 0.3f, alpha) : // Outgoing DoT = pale yellow - ImVec4(1.0f, 0.4f, 0.4f, alpha); // Incoming DoT = pale red - break; - case game::CombatTextEntry::PERIODIC_HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.4f, 1.0f, 0.5f, alpha); - break; - case game::CombatTextEntry::ENVIRONMENTAL: { - const char* envLabel = ""; - switch (entry.powerType) { - case 0: envLabel = "Fatigue "; break; - case 1: envLabel = "Drowning "; break; - case 2: envLabel = ""; break; // Fall: just show the number (WoW convention) - case 3: envLabel = "Lava "; break; - case 4: envLabel = "Slime "; break; - case 5: envLabel = "Fire "; break; - default: envLabel = ""; break; - } - snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); - color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental - break; + // --- Format text and color (identical logic for both world and HUD paths) --- + ImVec4 color; + char text[128]; + switch (entry.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 1.0f, 0.3f, alpha) : + ImVec4(1.0f, 0.3f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_DAMAGE: + snprintf(text, sizeof(text), "-%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.8f, 0.0f, alpha) : + ImVec4(1.0f, 0.5f, 0.0f, alpha); + break; + case game::CombatTextEntry::HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_HEAL: + snprintf(text, sizeof(text), "+%d!", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::MISS: + snprintf(text, sizeof(text), "Miss"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DODGE: + snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PARRY: + snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::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; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PERIODIC_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.9f, 0.3f, alpha) : + ImVec4(1.0f, 0.4f, 0.4f, alpha); + break; + case game::CombatTextEntry::PERIODIC_HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.4f, 1.0f, 0.5f, alpha); + break; + case game::CombatTextEntry::ENVIRONMENTAL: { + const char* envLabel = ""; + switch (entry.powerType) { + case 0: envLabel = "Fatigue "; break; + case 1: envLabel = "Drowning "; break; + case 2: envLabel = ""; break; + case 3: envLabel = "Lava "; break; + case 4: envLabel = "Slime "; break; + case 5: envLabel = "Fire "; break; + default: envLabel = ""; break; } - case game::CombatTextEntry::ENERGIZE: - snprintf(text, sizeof(text), "+%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; // Rage: red - case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; // Focus: orange - case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; // Energy: yellow - case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; // Runic Power: teal - default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue - } - break; - case game::CombatTextEntry::POWER_DRAIN: - snprintf(text, sizeof(text), "-%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; - case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; - case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; - case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; - default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; - } - break; - case game::CombatTextEntry::XP_GAIN: - snprintf(text, sizeof(text), "+%d XP", entry.amount); - color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP - break; - case game::CombatTextEntry::IMMUNE: - snprintf(text, sizeof(text), "Immune!"); - color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune - break; - case game::CombatTextEntry::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: - 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; - case game::CombatTextEntry::DEFLECT: - snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); - color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) - : ImVec4(0.5f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::REFLECT: { - const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!reflectName.empty()) - snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); - else - snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); - color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) - : ImVec4(0.75f, 0.85f, 1.0f, alpha); - break; + snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); + color = ImVec4(0.9f, 0.5f, 0.2f, alpha); + break; + } + case game::CombatTextEntry::ENERGIZE: + snprintf(text, sizeof(text), "+%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; } - case game::CombatTextEntry::PROC_TRIGGER: { - const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!procName.empty()) - snprintf(text, sizeof(text), "%s!", procName.c_str()); - else - snprintf(text, sizeof(text), "PROC!"); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc - break; + break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; } - case game::CombatTextEntry::DISPEL: - if (entry.spellId != 0) { - const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); - if (!dispelledName.empty()) - snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); - else - snprintf(text, sizeof(text), "Dispel"); - } else { + break; + case game::CombatTextEntry::XP_GAIN: + snprintf(text, sizeof(text), "+%d XP", entry.amount); + color = ImVec4(0.7f, 0.3f, 1.0f, alpha); + break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); + break; + case game::CombatTextEntry::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); + break; + case game::CombatTextEntry::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); + break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; + } + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else snprintf(text, sizeof(text), "Dispel"); - } - color = ImVec4(0.6f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::STEAL: - if (entry.spellId != 0) { - const std::string& stolenName = gameHandler.getSpellName(entry.spellId); - if (!stolenName.empty()) - snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); - else - snprintf(text, sizeof(text), "Spellsteal"); - } else { - snprintf(text, sizeof(text), "Spellsteal"); - } - color = ImVec4(0.8f, 0.7f, 1.0f, alpha); - break; - case game::CombatTextEntry::INTERRUPT: { - const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!interruptedName.empty()) - snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); - else - snprintf(text, sizeof(text), "Interrupt"); - color = ImVec4(1.0f, 0.6f, 0.9f, alpha); - break; + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : + ImVec4(0.75f, 0.35f, 0.35f, alpha); + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : + ImVec4(1.0f, 0.15f, 0.15f, alpha); + break; + default: + snprintf(text, sizeof(text), "%d", entry.amount); + color = ImVec4(1.0f, 1.0f, 1.0f, alpha); + break; + } + + // --- Rendering style --- + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + + // --- Try world-space anchor if we have a destination entity --- + // Types that should always stay as HUD elements (no world anchor) + bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN || + entry.type == game::CombatTextEntry::HONOR_GAIN || + entry.type == game::CombatTextEntry::PROC_TRIGGER); + + bool rendered = false; + if (!isHudOnly && camera && entry.dstGuid != 0) { + // Look up the destination entity's render position + glm::vec3 renderPos; + bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos); + if (!havePos) { + // Fallback to entity canonical position + auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + havePos = true; + } } - case game::CombatTextEntry::INSTAKILL: - snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); - color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) - : ImVec4(1.0f, 0.1f, 0.1f, alpha); - break; - case game::CombatTextEntry::HONOR_GAIN: - snprintf(text, sizeof(text), "+%d Honor", entry.amount); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for honor - break; - case game::CombatTextEntry::GLANCING: - snprintf(text, sizeof(text), "~%d", entry.amount); - color = outgoing ? - ImVec4(0.75f, 0.75f, 0.5f, alpha) : // Outgoing glancing = muted yellow - ImVec4(0.75f, 0.35f, 0.35f, alpha); // Incoming glancing = muted red - break; - case game::CombatTextEntry::CRUSHING: - snprintf(text, sizeof(text), "%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.55f, 0.1f, alpha) : // Outgoing crushing = orange - ImVec4(1.0f, 0.15f, 0.15f, alpha); // Incoming crushing = bright red - break; - default: - snprintf(text, sizeof(text), "%d", entry.amount); - color = ImVec4(1.0f, 1.0f, 1.0f, alpha); - break; } - // Outgoing → right side (near target), incoming → center-left (near player) - int& idx = outgoing ? outIdx : inIdx; - float baseX = outgoing ? outgoingX : incomingX; + if (havePos) { + // Float upward from above the entity's head + renderPos.z += 2.5f + entry.age * 1.2f; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w > 0.01f) { + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) { + float sx = (ndc.x * 0.5f + 0.5f) * screenW; + float sy = (ndc.y * 0.5f + 0.5f) * screenH; + + // Horizontal stagger using the random seed + sx += entry.xSeed * 40.0f; + + // Center the text horizontally on the projected point + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + sx -= ts.x * 0.5f; + + // Clamp to screen bounds + sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f)); + + drawList->AddText(font, renderFontSize, + ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text); + drawList->AddText(font, renderFontSize, + ImVec2(sx, sy), textCol, text); + rendered = true; + } + } + } + } + + // --- HUD fallback for entries without world anchor or HUD-only types --- + if (!rendered) { + if (!needsHudWindow) { + needsHudWindow = true; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImGui::Begin("##CombatText", nullptr, flags); + } + + float yOffset = 200.0f - entry.age * 60.0f; + int& idx = outgoing ? hudOutIdx : hudInIdx; + float baseX = outgoing ? hudOutgoingX : hudIncomingX; float xOffset = baseX + (idx % 3 - 1) * 60.0f; ++idx; - // Crits render at 1.35× normal font size for visual impact - bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || - entry.type == game::CombatTextEntry::CRIT_HEAL); - ImFont* font = ImGui::GetFont(); - float baseFontSize = ImGui::GetFontSize(); - float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; - - // Advance cursor so layout accounting is correct, then read screen pos ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); ImVec2 screenPos = ImGui::GetCursorScreenPos(); - // Drop shadow for readability over complex backgrounds - ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); - ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); - ImDrawList* dl = ImGui::GetWindowDrawList(); + ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), shadowCol, text); dl->AddText(font, renderFontSize, screenPos, textCol, text); - // Reserve space so ImGui doesn't clip the window prematurely ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); ImGui::Dummy(ts); } } - ImGui::End(); + + if (needsHudWindow) { + ImGui::End(); + } } // ============================================================ From 02a1b5cbf3ba9801bc9c4f579aa22152c25ca625 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:59:54 -0700 Subject: [PATCH 25/58] fix: show reflected spell name in combat text SMSG_SPELL_MISS_LOG REFLECT entries include a reflectSpellId field that was parsed but discarded. Now store it in SpellMissLogEntry and pass it to addCombatText, so floating combat text shows the actual reflected spell name instead of the original cast spell. --- src/game/game_handler.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index eac38722..a918db20 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2951,6 +2951,7 @@ void GameHandler::handlePacket(network::Packet& packet) { struct SpellMissLogEntry { uint64_t victimGuid = 0; uint8_t missInfo = 0; + uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT) }; std::vector parsedMisses; parsedMisses.reserve(storedLimit); @@ -2969,17 +2970,18 @@ void GameHandler::handlePacket(network::Packet& packet) { } const uint8_t missInfo = packet.readUInt8(); // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult + uint32_t reflectSpellId = 0; if (missInfo == 11) { if (packet.getSize() - packet.getReadPos() >= 5) { - /*uint32_t reflectSpellId =*/ packet.readUInt32(); - /*uint8_t reflectResult =*/ packet.readUInt8(); + reflectSpellId = packet.readUInt32(); + /*uint8_t reflectResult =*/ packet.readUInt8(); } else { truncated = true; break; } } if (i < storedLimit) { - parsedMisses.push_back({victimGuid, missInfo}); + parsedMisses.push_back({victimGuid, missInfo, reflectSpellId}); } } @@ -2992,12 +2994,15 @@ void GameHandler::handlePacket(network::Packet& packet) { const uint64_t victimGuid = miss.victimGuid; const uint8_t missInfo = miss.missInfo; CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo); + // For REFLECT, use the reflected spell ID so combat text shows the spell name + uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0) + ? miss.reflectSpellId : spellId; if (casterGuid == playerGuid) { // We cast a spell and it missed the target - addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid); + addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) - addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid); + addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid); } } break; From 209f60031efcbf9a67ec751b110203aa500b8c06 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:01:53 -0700 Subject: [PATCH 26/58] feat: respect loot roll voteMask for button visibility Store the voteMask from SMSG_LOOT_START_ROLL and use it to conditionally show Need/Greed/Disenchant/Pass buttons. Previously all four buttons were always shown regardless of the server's allowed roll types. --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 5 +++-- src/ui/game_screen.cpp | 32 +++++++++++++++++++++----------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 02bd74cb..0a44957d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1458,6 +1458,7 @@ public: std::string itemName; uint8_t itemQuality = 0; uint32_t rollCountdownMs = 60000; // Duration of roll window in ms + uint8_t voteMask = 0xFF; // Bitmask: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant std::chrono::steady_clock::time_point rollStartedAt{}; struct PlayerRollResult { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a918db20..0bd365dc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2344,7 +2344,7 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t randProp =*/ packet.readUInt32(); } uint32_t countdown = packet.readUInt32(); - /*uint8_t voteMask =*/ packet.readUInt8(); + uint8_t voteMask = packet.readUInt8(); // Trigger the roll popup for local player pendingLootRollActive_ = true; pendingLootRoll_.objectGuid = objectGuid; @@ -2358,9 +2358,10 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000; + pendingLootRoll_.voteMask = voteMask; pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, - ") slot=", slot); + ") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec); break; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index dd727550..9aacfa58 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13198,20 +13198,30 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { } ImGui::Spacing(); - if (ImGui::Button("Need", ImVec2(80, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + // voteMask bits: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant + const uint8_t vm = roll.voteMask; + bool first = true; + if (vm & 0x02) { + if (ImGui::Button("Need", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Greed", ImVec2(80, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + if (vm & 0x04) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Greed", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Disenchant", ImVec2(95, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + if (vm & 0x08) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Disenchant", ImVec2(95, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Pass", ImVec2(70, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); + if (vm & 0x01) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Pass", ImVec2(70, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); } // Live roll results from group members From fd7886f4ce3191de486110085e2f0892baae7ff7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:05:49 -0700 Subject: [PATCH 27/58] feat: show NPC subtitle on nameplates Display creature subtitles (e.g. , ) below NPC names on nameplates, mirroring the guild tag display for players. The subtitle is fetched from the creature info cache populated by SMSG_CREATURE_QUERY_RESPONSE. --- src/ui/game_screen.cpp | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 9aacfa58..c2325886 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11205,27 +11205,31 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC } - // Guild name for player nameplates: shift name up to make room - std::string guildTag; + // Sub-label below the name: guild tag for players, subtitle for NPCs + std::string subLabel; if (isPlayer) { uint32_t guildId = gameHandler.getEntityGuildId(guid); if (guildId != 0) { const std::string& gn = gameHandler.lookupGuildName(guildId); - if (!gn.empty()) guildTag = "<" + gn + ">"; + if (!gn.empty()) subLabel = "<" + gn + ">"; } + } else { + // NPC subtitle (e.g. "", "") + std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) subLabel = "<" + sub + ">"; } - if (!guildTag.empty()) nameY -= 10.0f; // shift name up for guild line + if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); - // Guild tag below the name (WoW-style in lighter color) - if (!guildTag.empty()) { - ImVec2 guildSz = ImGui::CalcTextSize(guildTag.c_str()); - float guildX = sx - guildSz.x * 0.5f; - float guildY = nameY + textSize.y + 1.0f; - drawList->AddText(ImVec2(guildX + 1.0f, guildY + 1.0f), IM_COL32(0, 0, 0, A(120)), guildTag.c_str()); - drawList->AddText(ImVec2(guildX, guildY), IM_COL32(180, 180, 180, A(200)), guildTag.c_str()); + // Sub-label below the name (WoW-style or in lighter color) + if (!subLabel.empty()) { + ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str()); + float subX = sx - subSz.x * 0.5f; + float subY = nameY + textSize.y + 1.0f; + drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str()); + drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str()); } // Group leader crown to the right of the name on player nameplates From 402bbc2f14db80fe45b80c21045220f7de4814ca Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:07:40 -0700 Subject: [PATCH 28/58] feat: elite/boss/rare border decorations on nameplates Add rank-specific outer borders on NPC nameplates: gold for Elite and Rare Elite, red for Boss, silver for Rare. Provides immediate visual identification of dangerous mobs without needing to target them. --- src/ui/game_screen.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c2325886..fa3755b5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11032,6 +11032,10 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { isTargetingPlayer = (unitTarget == playerGuid); } } + // Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver) + int creatureRank = -1; + if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry()); + // Border: gold = currently selected, orange = targeting player, dark = default ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) @@ -11055,6 +11059,24 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + // Elite/Boss/Rare decoration: extra outer border with rank-specific color + if (creatureRank == 1 || creatureRank == 2) { + // Elite / Rare Elite: gold double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 200, 50, A(200)), 3.0f); + } else if (creatureRank == 3) { + // Boss: red double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 40, 40, A(200)), 3.0f); + } else if (creatureRank == 4) { + // Rare: silver double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(170, 200, 230, A(200)), 3.0f); + } + // HP % text centered on health bar (non-corpse, non-full-health for readability) if (!isCorpse && unit->getMaxHealth() > 0) { int hpPct = static_cast(healthPct * 100.0f + 0.5f); From 1ea9334eca5e431fe94a561012be5e0409f3f63a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:08:44 -0700 Subject: [PATCH 29/58] feat: enable login screen background music Re-enable the login screen music system that was previously disabled. Randomly selects from available tracks in assets/Original Music/ and plays them at 80% volume during authentication. --- src/ui/auth_screen.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/auth_screen.cpp b/src/ui/auth_screen.cpp index 2f4b83cc..777285cf 100644 --- a/src/ui/auth_screen.cpp +++ b/src/ui/auth_screen.cpp @@ -206,8 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) { } } } - // Login screen music disabled - if (false && renderer) { + // Login screen music + if (renderer) { auto* music = renderer->getMusicManager(); if (music) { if (!loginMusicVolumeAdjusted_) { From c8f80339f13e5647975ffc1ea3c2215baff9f475 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:12:03 -0700 Subject: [PATCH 30/58] feat: display creature type on target frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show creature classification (Beast, Humanoid, Demon, etc.) next to the level on the target frame. Useful for knowing which CC abilities apply (Polymorph → Humanoid/Beast, Banish → Demon/Elemental, etc.). --- include/game/game_handler.hpp | 10 ++++++++++ src/ui/game_screen.cpp | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 0a44957d..7ef14f76 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -674,6 +674,16 @@ public: auto it = creatureInfoCache.find(entry); return (it != creatureInfoCache.end()) ? static_cast(it->second.rank) : -1; } + // Returns creature type (1=Beast,2=Dragonkin,...,7=Humanoid,...) or 0 if not cached + uint32_t getCreatureType(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.creatureType : 0; + } + // Returns creature family (e.g. pet family for beasts) or 0 + uint32_t getCreatureFamily(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.family : 0; + } // ---- Phase 2: Combat ---- void startAutoAttack(uint64_t targetGuid); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index fa3755b5..f74a4332 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4291,6 +4291,30 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); } } + // Creature type label (Beast, Humanoid, Demon, etc.) + if (target->getType() == game::ObjectType::UNIT) { + uint32_t ctype = gameHandler.getCreatureType(unit->getEntry()); + const char* ctypeName = nullptr; + switch (ctype) { + case 1: ctypeName = "Beast"; break; + case 2: ctypeName = "Dragonkin"; break; + case 3: ctypeName = "Demon"; break; + case 4: ctypeName = "Elemental"; break; + case 5: ctypeName = "Giant"; break; + case 6: ctypeName = "Undead"; break; + case 7: ctypeName = "Humanoid"; break; + case 8: ctypeName = "Critter"; break; + case 9: ctypeName = "Mechanical"; break; + case 11: ctypeName = "Totem"; break; + case 12: ctypeName = "Non-combat Pet"; break; + case 13: ctypeName = "Gas Cloud"; break; + default: break; + } + if (ctypeName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", ctypeName); + } + } if (confirmedCombatWithTarget) { float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); From 355b75c3c7b1e4cac56cc1bfb835513efe2f9dc5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:14:09 -0700 Subject: [PATCH 31/58] feat: add creature type and guild name to focus frame Show creature type (Beast, Humanoid, etc.) on the focus frame next to the rank badge, matching the target frame. Also display player guild names on focus frame for player targets. --- src/ui/game_screen.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f74a4332..247df7d2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5070,12 +5070,42 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "[Boss]"); } else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } + // Creature type + { + uint32_t fctype = gameHandler.getCreatureType(focusUnit->getEntry()); + const char* fctName = nullptr; + switch (fctype) { + case 1: fctName="Beast"; break; case 2: fctName="Dragonkin"; break; + case 3: fctName="Demon"; break; case 4: fctName="Elemental"; break; + case 5: fctName="Giant"; break; case 6: fctName="Undead"; break; + case 7: fctName="Humanoid"; break; case 8: fctName="Critter"; break; + case 9: fctName="Mechanical"; break; case 11: fctName="Totem"; break; + case 12: fctName="Non-combat Pet"; break; case 13: fctName="Gas Cloud"; break; + default: break; + } + if (fctName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", fctName); + } + } + // Creature subtitle const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); if (!fSub.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); } + // Player guild name on focus frame + if (focus->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(focus->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); const uint64_t fGuid = focus->getGuid(); From dfddc71ebbeed6560d57793a353d810780489eb4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:19:25 -0700 Subject: [PATCH 32/58] docs: update status with nameplate and combat text features --- docs/status.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/status.md b/docs/status.md index 991d813d..fca68f19 100644 --- a/docs/status.md +++ b/docs/status.md @@ -25,6 +25,9 @@ Implemented (working in normal use): - Talent tree UI with proper visuals and functionality - Pet tracking (SMSG_PET_SPELLS), dismiss pet button - Party: group invites, party list, out-of-range member health (SMSG_PARTY_MEMBER_STATS) +- Nameplates: NPC subtitles, guild names, elite/boss/rare borders, quest/raid indicators, cast bars, debuff dots +- Floating combat text: world-space damage/heal numbers above entities with 3D projection +- Target/focus frames: guild name, creature type, rank badges, combo points, cast bars - Map exploration: subzone-level fog-of-war reveal - Warden anti-cheat: full module execution via Unicorn Engine x86 emulation; module caching - Audio: ambient, movement, combat, spell, and UI sound systems From 09860e5fc675dbd646fc84d24521f50b5a5d4c71 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:22:39 -0700 Subject: [PATCH 33/58] feat: add /loc command to show player coordinates Type /loc, /coords, or /whereami in chat to display current position (X, Y, Z) and zone name as a system message. Useful for sharing locations or debugging position issues. --- src/ui/game_screen.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 247df7d2..0b7defa5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5879,6 +5879,26 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /loc command — print player coordinates and zone name + if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { + const auto& pmi = gameHandler.getMovementInfo(); + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + char buf[256]; + snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", + pmi.x, pmi.y, pmi.z, + zoneName.empty() ? "" : " — ", + zoneName.c_str()); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = buf; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + // /zone command — print current zone name if (cmdLower == "zone") { std::string zoneName; From 6a0b0a99d1dae94875d9a6a119ed7bf249a640ad Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:23:42 -0700 Subject: [PATCH 34/58] fix: add /loc to /help command listing --- src/ui/game_screen.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0b7defa5..0f2be6f7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5976,7 +5976,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Items: /use /equip /equipset [name]", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", + "Misc: /played /time /zone /loc /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", " /score /unstuck /logout /ticket /help", }; From a417a00d3acc6ed4ac6292fd4e6a0938714e8937 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:27:25 -0700 Subject: [PATCH 35/58] feat: add FPS counter to latency meter Display color-coded FPS alongside latency at top of screen. Green >=60, yellow >=30, red <30. Shows FPS even without latency data. --- src/ui/game_screen.cpp | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0f2be6f7..0d680919 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -19653,18 +19653,28 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { nextIndicatorY += kIndicatorH; } - // Latency indicator — centered at top of screen + // Latency + FPS indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); - if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { + if (showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { + float currentFps = ImGui::GetIO().Framerate; ImVec4 latColor; if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); - char latBuf[32]; - snprintf(latBuf, sizeof(latBuf), "%u ms", latMs); - ImVec2 textSize = ImGui::CalcTextSize(latBuf); + ImVec4 fpsColor; + if (currentFps >= 60.0f) fpsColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (currentFps >= 30.0f) fpsColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else fpsColor = ImVec4(1.0f, 0.3f, 0.3f, 0.9f); + + char infoText[64]; + if (latMs > 0) + snprintf(infoText, sizeof(infoText), "%.0f fps | %u ms", currentFps, latMs); + else + snprintf(infoText, sizeof(infoText), "%.0f fps", currentFps); + + ImVec2 textSize = ImGui::CalcTextSize(infoText); float latW = textSize.x + 16.0f; float latH = textSize.y + 8.0f; ImGuiIO& lio = ImGui::GetIO(); @@ -19673,7 +19683,14 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); ImGui::SetNextWindowBgAlpha(0.45f); if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { - ImGui::TextColored(latColor, "%s", latBuf); + // Color the FPS and latency portions differently + ImGui::TextColored(fpsColor, "%.0f fps", currentFps); + if (latMs > 0) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "|"); + ImGui::SameLine(0, 4); + ImGui::TextColored(latColor, "%u ms", latMs); + } } ImGui::End(); } From 2dc5b213418f348deb29125f386094971262c78e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:47:34 -0700 Subject: [PATCH 36/58] feat: add screenshot capture (PrintScreen key and /screenshot command) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the Vulkan swapchain image to PNG via stb_image_write. Screenshots saved to ~/.wowee/screenshots/ with timestamped filenames. Cross-platform: BGRA→RGBA swizzle, localtime_r/localtime_s. --- include/rendering/renderer.hpp | 3 ++ include/ui/game_screen.hpp | 1 + src/rendering/renderer.cpp | 99 ++++++++++++++++++++++++++++++++++ src/ui/game_screen.cpp | 50 ++++++++++++++++- 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 1f33d2f4..588fa3af 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -154,6 +154,9 @@ public: void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); + // Screenshot capture — copies swapchain image to PNG file + bool captureScreenshot(const std::string& outputPath); + // Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT) // useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0054bf05..95e07994 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -398,6 +398,7 @@ private: void renderBattlegroundScore(game::GameHandler& gameHandler); void renderDPSMeter(game::GameHandler& gameHandler); void renderDurabilityWarning(game::GameHandler& gameHandler); + void takeScreenshot(game::GameHandler& gameHandler); /** * Inventory screen diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 4da8bad7..2d942c23 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -67,6 +67,10 @@ #include #include #include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb_image_write.h" #include #include #include @@ -2574,6 +2578,101 @@ void Renderer::cancelEmote() { emoteLoop = false; } +bool Renderer::captureScreenshot(const std::string& outputPath) { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D extent = vkCtx->getSwapchainExtent(); + const auto& images = vkCtx->getSwapchainImages(); + + if (images.empty() || currentImageIndex >= images.size()) return false; + + VkImage srcImage = images[currentImageIndex]; + uint32_t w = extent.width; + uint32_t h = extent.height; + VkDeviceSize bufSize = static_cast(w) * h * 4; + + // Stall GPU so the swapchain image is idle + vkDeviceWaitIdle(device); + + // Create staging buffer + VkBufferCreateInfo bufInfo{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bufInfo.size = bufSize; + bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_ONLY; + + VkBuffer stagingBuf = VK_NULL_HANDLE; + VmaAllocation stagingAlloc = VK_NULL_HANDLE; + if (vmaCreateBuffer(alloc, &bufInfo, &allocCI, &stagingBuf, &stagingAlloc, nullptr) != VK_SUCCESS) { + LOG_WARNING("Screenshot: failed to create staging buffer"); + return false; + } + + // Record copy commands + VkCommandBuffer cmd = vkCtx->beginSingleTimeCommands(); + + // Transition swapchain image: PRESENT_SRC → TRANSFER_SRC + VkImageMemoryBarrier toTransfer{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + toTransfer.srcAccessMask = VK_ACCESS_MEMORY_READ_BIT; + toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + toTransfer.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + toTransfer.image = srcImage; + toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &toTransfer); + + // Copy image to buffer + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {w, h, 1}; + vkCmdCopyImageToBuffer(cmd, srcImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + stagingBuf, 1, ®ion); + + // Transition back: TRANSFER_SRC → PRESENT_SRC + VkImageMemoryBarrier toPresent = toTransfer; + toPresent.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + toPresent.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT; + toPresent.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + toPresent.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + 0, 0, nullptr, 0, nullptr, 1, &toPresent); + + vkCtx->endSingleTimeCommands(cmd); + + // Map and convert BGRA → RGBA + void* mapped = nullptr; + vmaMapMemory(alloc, stagingAlloc, &mapped); + auto* pixels = static_cast(mapped); + for (uint32_t i = 0; i < w * h; ++i) { + std::swap(pixels[i * 4 + 0], pixels[i * 4 + 2]); // B ↔ R + } + + // Ensure output directory exists + std::filesystem::path outPath(outputPath); + if (outPath.has_parent_path()) + std::filesystem::create_directories(outPath.parent_path()); + + int ok = stbi_write_png(outputPath.c_str(), + static_cast(w), static_cast(h), + 4, pixels, static_cast(w * 4)); + + vmaUnmapMemory(alloc, stagingAlloc); + vmaDestroyBuffer(alloc, stagingBuf, stagingAlloc); + + if (ok) { + LOG_INFO("Screenshot saved: ", outputPath); + } else { + LOG_WARNING("Screenshot: stbi_write_png failed for ", outputPath); + } + return ok != 0; +} + void Renderer::triggerLevelUpEffect(const glm::vec3& position) { if (!levelUpEffect) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0d680919..a216723d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2809,6 +2809,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showTitlesWindow_ = !showTitlesWindow_; } + // Screenshot (PrintScreen key) + if (input.isKeyJustPressed(SDL_SCANCODE_PRINTSCREEN)) { + takeScreenshot(gameHandler); + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -5899,6 +5904,13 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /screenshot command — capture current frame to PNG + if (cmdLower == "screenshot" || cmdLower == "ss") { + takeScreenshot(gameHandler); + chatInputBuffer[0] = '\0'; + return; + } + // /zone command — print current zone name if (cmdLower == "zone") { std::string zoneName; @@ -5978,7 +5990,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /loc /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /score /unstuck /logout /ticket /help", + " /score /unstuck /logout /ticket /screenshot /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; @@ -12160,6 +12172,42 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { // Durability Warning (equipment damage indicator) // ============================================================ +void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) return; + + // Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png + const char* home = std::getenv("HOME"); + if (!home) home = std::getenv("USERPROFILE"); + if (!home) home = "/tmp"; + std::string dir = std::string(home) + "/.wowee/screenshots"; + + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &tt); +#else + localtime_r(&tt, &tm); +#endif + + char filename[128]; + std::snprintf(filename, sizeof(filename), + "WoWee_%04d%02d%02d_%02d%02d%02d.png", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + + std::string path = dir + "/" + filename; + + if (renderer->captureScreenshot(path)) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Screenshot saved: " + path; + core::Application::getInstance().getGameHandler()->addLocalChatMessage(sysMsg); + } +} + void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { if (gameHandler.getPlayerGuid() == 0) return; From 7f2ee8aa7ec3b24b52c7d31d08aea769aa506f51 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:50:42 -0700 Subject: [PATCH 37/58] fix: add error sound on cast failure and AFK/DND whisper auto-reply Play UI error sound on SMSG_CAST_FAILED for consistent audio feedback, matching other error handlers (vendor, inventory, trainer). Auto-reply to incoming whispers with AFK/DND message when player has set /afk or /dnd status. --- src/game/game_handler.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0bd365dc..821f337a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12759,6 +12759,15 @@ void GameHandler::handleMessageChat(network::Packet& packet) { // Track whisper sender for /r command if (data.type == ChatType::WHISPER && !data.senderName.empty()) { lastWhisperSender_ = data.senderName; + + // Auto-reply if AFK or DND + if (afkStatus_ && !data.senderName.empty()) { + std::string reply = afkMessage_.empty() ? "Away from Keyboard" : afkMessage_; + sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); + } else if (dndStatus_ && !data.senderName.empty()) { + std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_; + sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); + } } // Trigger chat bubble for SAY/YELL messages from others @@ -18533,6 +18542,12 @@ void GameHandler::handleCastFailed(network::Packet& packet) { msg.language = ChatLanguage::UNIVERSAL; msg.message = errMsg; addLocalChatMessage(msg); + + // Play error sound for cast failure feedback + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } } static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) { From 1cff1a03a50eb36db079bded17d6c5009fa3d6a7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:54:03 -0700 Subject: [PATCH 38/58] feat: add clock display on minimap Show local time (12-hour AM/PM) at the bottom-right corner of the minimap with a semi-transparent background. --- src/ui/game_screen.cpp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index a216723d..2f74f899 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -19558,6 +19558,37 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); + // Clock display at bottom-right of minimap (local time) + { + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tmBuf{}; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockText[16]; + std::snprintf(clockText, sizeof(clockText), "%d:%02d %s", + (tmBuf.tm_hour % 12 == 0) ? 12 : tmBuf.tm_hour % 12, + tmBuf.tm_min, + tmBuf.tm_hour >= 12 ? "PM" : "AM"); + ImVec2 clockSz = ImGui::CalcTextSize(clockText); + float clockW = clockSz.x + 10.0f; + float clockH = clockSz.y + 6.0f; + ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - clockW - 2.0f, + centerY + mapRadius - clockH - 2.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(clockW, clockH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + ImGuiWindowFlags clockFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##MinimapClock", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.8f, 0.85f), "%s", clockText); + } + ImGui::End(); + } + // Indicators below the minimap (stacked: new mail, then BG queue, then latency) float indicatorX = centerX - mapRadius; float nextIndicatorY = centerY + mapRadius + 4.0f; From 17d652947c0105b3385a1d16e834074b421743f7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 10:56:44 -0700 Subject: [PATCH 39/58] feat: extend cursor hover to NPCs and players Hand cursor now shows when hovering over any interactive entity in the 3D world (NPCs, players, game objects), not just game objects. Helps identify clickable targets at a glance. --- src/ui/game_screen.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2f74f899..ba2465f1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2873,7 +2873,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } - // Cursor affordance: show hand cursor over interactable game objects. + // Cursor affordance: show hand cursor over interactable entities. if (!io.WantCaptureMouse) { auto* renderer = core::Application::getInstance().getRenderer(); auto* camera = renderer ? renderer->getCamera() : nullptr; @@ -2884,17 +2884,21 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { float screenH = static_cast(window->getHeight()); rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH); float closestT = 1e30f; - bool hoverInteractableGo = false; + bool hoverInteractable = false; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (entity->getType() != game::ObjectType::GAMEOBJECT) continue; + bool isGo = (entity->getType() == game::ObjectType::GAMEOBJECT); + bool isUnit = (entity->getType() == game::ObjectType::UNIT); + bool isPlayer = (entity->getType() == game::ObjectType::PLAYER); + if (!isGo && !isUnit && !isPlayer) continue; + if (guid == gameHandler.getPlayerGuid()) continue; // skip self glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); if (!hasBounds) { - hitRadius = 2.5f; + hitRadius = isGo ? 2.5f : 1.8f; hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - hitCenter.z += 1.2f; + hitCenter.z += isGo ? 1.2f : 1.0f; } else { hitRadius = std::max(hitRadius * 1.1f, 0.8f); } @@ -2902,10 +2906,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT) && hitT < closestT) { closestT = hitT; - hoverInteractableGo = true; + hoverInteractable = true; } } - if (hoverInteractableGo) { + if (hoverInteractable) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } } From 9b32a328c3527c67531c6ef54e1e0059f651a189 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:07:27 -0700 Subject: [PATCH 40/58] feat: add item stack splitting via Shift+right-click Implements CMSG_SPLIT_ITEM (0x10E) with a slider popup for choosing split count. Auto-finds empty destination slot across backpack and bags. Shift+right-click on stackable items (count > 1) opens split dialog; non-stackable items still get the destroy confirmation. --- include/game/game_handler.hpp | 1 + include/game/world_packets.hpp | 7 +++ include/ui/inventory_screen.hpp | 8 ++++ src/game/game_handler.cpp | 34 +++++++++++++++ src/game/world_packets.cpp | 11 +++++ src/ui/inventory_screen.cpp | 76 ++++++++++++++++++++++++++------- 6 files changed, 122 insertions(+), 15 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7ef14f76..f21c0bdf 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2029,6 +2029,7 @@ public: void openItemBySlot(int backpackIndex); void openItemInBag(int bagIndex, int slotIndex); void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1); + void splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count); void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot); void swapBagSlots(int srcBagIndex, int dstBagIndex); void useItemById(uint32_t itemId); diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c2aa581f..e5492eb4 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2046,6 +2046,13 @@ public: static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot); }; +/** CMSG_SPLIT_ITEM packet builder */ +class SplitItemPacket { +public: + static network::Packet build(uint8_t srcBag, uint8_t srcSlot, + uint8_t dstBag, uint8_t dstSlot, uint8_t count); +}; + /** CMSG_SWAP_INV_ITEM packet builder */ class SwapInvItemPacket { public: diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 21ccdc00..dca0e5a5 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -187,6 +187,14 @@ private: uint8_t destroyCount_ = 1; std::string destroyItemName_; + // Stack split popup state + bool splitConfirmOpen_ = false; + uint8_t splitBag_ = 0xFF; + uint8_t splitSlot_ = 0; + int splitMax_ = 1; + int splitCount_ = 1; + std::string splitItemName_; + // Pending chat item link from shift-click std::string pendingChatItemLink_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 821f337a..5ffb2028 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -21177,6 +21177,40 @@ void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) { socket->send(packet); } +void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) { + if (state != WorldState::IN_WORLD || !socket) return; + if (count == 0) return; + + // Find a free slot for the split destination: try backpack first, then bags + int freeBp = inventory.findFreeBackpackSlot(); + if (freeBp >= 0) { + uint8_t dstBag = 0xFF; + uint8_t dstSlot = static_cast(23 + freeBp); + LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, + ") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")"); + auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); + socket->send(packet); + return; + } + // Try equipped bags + for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) { + int bagSize = inventory.getBagSize(b); + for (int s = 0; s < bagSize; s++) { + if (inventory.getBagSlot(b, s).empty()) { + uint8_t dstBag = static_cast(19 + b); + uint8_t dstSlot = static_cast(s); + LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, + ") count=", (int)count, " -> dst(bag=", (int)dstBag, + " slot=", (int)dstSlot, ")"); + auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); + socket->send(packet); + return; + } + } + } + addSystemChatMessage("Cannot split: no free inventory slots."); +} + void GameHandler::useItemBySlot(int backpackIndex) { if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; const auto& slot = inventory.getBackpackSlot(backpackIndex); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d9f40091..77ccf49c 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -4358,6 +4358,17 @@ network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t s return packet; } +network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot, + uint8_t dstBag, uint8_t dstSlot, uint8_t count) { + network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM)); + packet.writeUInt8(srcBag); + packet.writeUInt8(srcSlot); + packet.writeUInt8(dstBag); + packet.writeUInt8(dstSlot); + packet.writeUInt8(count); + return packet; +} + network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) { network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM)); packet.writeUInt8(srcSlot); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index b4e2ac89..366e9fa0 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -871,6 +871,35 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { ImGui::EndPopup(); } + // Stack split popup + if (splitConfirmOpen_) { + ImVec2 mousePos = ImGui::GetIO().MousePos; + ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always); + ImGui::OpenPopup("##SplitStack"); + splitConfirmOpen_ = false; + } + if (ImGui::BeginPopup("##SplitStack", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + ImGui::Text("Split %s", splitItemName_.c_str()); + ImGui::Spacing(); + ImGui::SetNextItemWidth(120.0f); + ImGui::SliderInt("##splitcount", &splitCount_, 1, splitMax_ - 1); + ImGui::Spacing(); + if (ImGui::Button("OK", ImVec2(55, 0))) { + if (gameHandler_ && splitCount_ > 0 && splitCount_ < splitMax_) { + gameHandler_->splitItem(splitBag_, splitSlot_, static_cast(splitCount_)); + } + splitItemName_.clear(); + inventoryDirty = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(55, 0))) { + splitItemName_.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + // Draw held item at cursor renderHeldItem(); } @@ -2302,22 +2331,39 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } - // Shift+right-click: open destroy confirmation for non-quest items + // Shift+right-click: split stack (if stackable >1) or destroy confirmation if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && - !holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) { - destroyConfirmOpen_ = true; - destroyItemName_ = item.name; - destroyCount_ = static_cast(std::clamp( - std::max(1u, item.stackCount), 1u, 255u)); - if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { - destroyBag_ = 0xFF; - destroySlot_ = static_cast(23 + backpackIndex); - } else if (kind == SlotKind::BACKPACK && isBagSlot) { - destroyBag_ = static_cast(19 + bagIndex); - destroySlot_ = static_cast(bagSlotIndex); - } else if (kind == SlotKind::EQUIPMENT) { - destroyBag_ = 0xFF; - destroySlot_ = static_cast(equipSlot); + !holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0) { + if (item.stackCount > 1 && item.maxStack > 1) { + // Open split popup for stackable items + splitConfirmOpen_ = true; + splitItemName_ = item.name; + splitMax_ = static_cast(item.stackCount); + splitCount_ = splitMax_ / 2; + if (splitCount_ < 1) splitCount_ = 1; + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + splitBag_ = 0xFF; + splitSlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + splitBag_ = static_cast(19 + bagIndex); + splitSlot_ = static_cast(bagSlotIndex); + } + } else if (item.bindType != 4) { + // Destroy confirmation for non-quest, non-stackable items + destroyConfirmOpen_ = true; + destroyItemName_ = item.name; + destroyCount_ = static_cast(std::clamp( + std::max(1u, item.stackCount), 1u, 255u)); + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + destroyBag_ = static_cast(19 + bagIndex); + destroySlot_ = static_cast(bagSlotIndex); + } else if (kind == SlotKind::EQUIPMENT) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(equipSlot); + } } } From d4c715720853cefc63c2fb9cf28ec4e531c2bfab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:16:43 -0700 Subject: [PATCH 41/58] feat: add vendor purchase confirmation for expensive items Shows a confirmation dialog before buying items costing 1 gold or more, preventing accidental purchases. Displays item name, quantity, and total cost in gold/silver/copper. --- include/ui/game_screen.hpp | 9 ++++++++ src/ui/game_screen.cpp | 42 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 95e07994..e28c5f15 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -533,6 +533,15 @@ private: // Vendor search filter char vendorSearchFilter_[128] = ""; + // Vendor purchase confirmation for expensive items + bool vendorConfirmOpen_ = false; + uint64_t vendorConfirmGuid_ = 0; + uint32_t vendorConfirmItemId_ = 0; + uint32_t vendorConfirmSlot_ = 0; + uint32_t vendorConfirmQty_ = 1; + uint32_t vendorConfirmPrice_ = 0; + std::string vendorConfirmItemName_; + // Trainer search filter char trainerSearchFilter_[128] = ""; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ba2465f1..5ac51276 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -16036,8 +16036,19 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (ImGui::SmallButton(buyBtnId.c_str())) { int qty = vendorBuyQty; if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; - gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, - static_cast(qty)); + uint32_t totalCost = item.buyPrice * static_cast(qty); + if (totalCost >= 10000) { // >= 1 gold: confirm + vendorConfirmOpen_ = true; + vendorConfirmGuid_ = vendor.vendorGuid; + vendorConfirmItemId_ = item.itemId; + vendorConfirmSlot_ = item.slot; + vendorConfirmQty_ = static_cast(qty); + vendorConfirmPrice_ = totalCost; + vendorConfirmItemName_ = (info && info->valid) ? info->name : "Item"; + } else { + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, + static_cast(qty)); + } } if (outOfStock) ImGui::EndDisabled(); @@ -16053,6 +16064,33 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (!open) { gameHandler.closeVendor(); } + + // Vendor purchase confirmation popup for expensive items + if (vendorConfirmOpen_) { + ImGui::OpenPopup("Confirm Purchase##vendor"); + vendorConfirmOpen_ = false; + } + if (ImGui::BeginPopupModal("Confirm Purchase##vendor", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + ImGui::Text("Buy %s", vendorConfirmItemName_.c_str()); + if (vendorConfirmQty_ > 1) + ImGui::Text("Quantity: %u", vendorConfirmQty_); + uint32_t g = vendorConfirmPrice_ / 10000; + uint32_t s = (vendorConfirmPrice_ / 100) % 100; + uint32_t c = vendorConfirmPrice_ % 100; + ImGui::Text("Cost: %ug %us %uc", g, s, c); + ImGui::Spacing(); + if (ImGui::Button("Buy", ImVec2(80, 0))) { + gameHandler.buyItem(vendorConfirmGuid_, vendorConfirmItemId_, + vendorConfirmSlot_, vendorConfirmQty_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } // ============================================================ From ef4cf461a50c8d722dcacc85ebe68c716347576c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:21:14 -0700 Subject: [PATCH 42/58] feat: add duration countdown and stack count to nameplate debuff dots Nameplate debuff indicators now show: clock-sweep overlay for elapsed duration, countdown text below each dot (color-coded red < 5s, yellow < 15s), stack count badge, and duration in hover tooltip. --- src/ui/game_screen.cpp | 71 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5ac51276..8edb82ec 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11258,14 +11258,79 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), IM_COL32(0, 0, 0, A(150)), 1.0f); - // Spell name tooltip on hover + // Duration clock-sweep overlay (like target frame auras) + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remainMs = aura.getRemainingMs(nowMs); + if (aura.maxDurationMs > 0 && remainMs > 0) { + float pct = 1.0f - static_cast(remainMs) / static_cast(aura.maxDurationMs); + pct = std::clamp(pct, 0.0f, 1.0f); + float cx = dotX + dotSize * 0.5f; + float cy = nameplateBottom + dotSize * 0.5f; + float r = dotSize * 0.5f; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + pct * IM_PI * 2.0f; + ImVec2 center(cx, cy); + const int segments = 12; + for (int seg = 0; seg < segments; seg++) { + float a0 = startAngle + (endAngle - startAngle) * seg / segments; + float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments; + drawList->AddTriangleFilled( + center, + ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)), + ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)), + IM_COL32(0, 0, 0, A(100))); + } + } + + // Stack count on dot (upper-left corner) + if (aura.charges > 1) { + char stackBuf[8]; + snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges); + drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf); + drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf); + } + + // Duration text below dot + if (remainMs > 0) { + char durBuf[8]; + if (remainMs >= 60000) + snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000); + else + snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000); + ImVec2 durSz = ImGui::CalcTextSize(durBuf); + float durX = dotX + (dotSize - durSz.x) * 0.5f; + float durY = nameplateBottom + dotSize + 1.0f; + drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf); + // Color: red if < 5s, yellow if < 15s, white otherwise + ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240)) + : remainMs < 15000 ? IM_COL32(255, 200, 60, A(240)) + : IM_COL32(230, 230, 230, A(220)); + drawList->AddText(ImVec2(durX, durY), durCol, durBuf); + } + + // Spell name + duration tooltip on hover { ImVec2 mouse = ImGui::GetMousePos(); if (mouse.x >= dotX && mouse.x < dotX + dotSize && mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); - if (!dotSpellName.empty()) - ImGui::SetTooltip("%s", dotSpellName.c_str()); + if (!dotSpellName.empty()) { + if (remainMs > 0) { + int secs = remainMs / 1000; + int mins = secs / 60; + secs %= 60; + char tipBuf[128]; + if (mins > 0) + snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs); + else + snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs); + ImGui::SetTooltip("%s", tipBuf); + } else { + ImGui::SetTooltip("%s", dotSpellName.c_str()); + } + } } } From 9368c8a715efbbe71cd3df1f59a0df70b3f54743 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:23:35 -0700 Subject: [PATCH 43/58] feat: add confirmation dialog before spending talent points Clicking a learnable talent now opens a modal confirmation popup showing the spell name and rank, preventing accidental talent spending. --- include/ui/talent_screen.hpp | 6 ++++++ src/ui/talent_screen.cpp | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 72eafc2a..82a674e4 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -45,6 +45,12 @@ private: std::unordered_map spellTooltips; // spellId -> description std::unordered_map bgTextureCache_; // tabId -> bg texture + // Talent learn confirmation + bool talentConfirmOpen_ = false; + uint32_t pendingTalentId_ = 0; + uint32_t pendingTalentRank_ = 0; + std::string pendingTalentName_; + // GlyphProperties.dbc cache: glyphId -> { spellId, isMajor } struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; }; std::unordered_map glyphProperties_; // glyphId -> info diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index c2b92eff..5f87712f 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -176,6 +176,29 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { ImGui::EndTabBar(); } + + // Talent learn confirmation popup + if (talentConfirmOpen_) { + ImGui::OpenPopup("Learn Talent?##talent_confirm"); + talentConfirmOpen_ = false; + } + if (ImGui::BeginPopupModal("Learn Talent?##talent_confirm", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", pendingTalentName_.c_str()); + ImGui::Text("Rank %u", pendingTalentRank_ + 1); + ImGui::Spacing(); + ImGui::TextWrapped("Spend a talent point?"); + ImGui::Spacing(); + if (ImGui::Button("Learn", ImVec2(80, 0))) { + gameHandler.learnTalent(pendingTalentId_, pendingTalentRank_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId, @@ -574,10 +597,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, ImGui::EndTooltip(); } - // Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...) - // CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value) + // Handle click — open confirmation dialog instead of learning directly if (clicked && canLearn && prereqsMet) { - gameHandler.learnTalent(talent.talentId, currentRank); + talentConfirmOpen_ = true; + pendingTalentId_ = talent.talentId; + pendingTalentRank_ = currentRank; + uint32_t nextSpell = (currentRank < 5) ? talent.rankSpells[currentRank] : 0; + pendingTalentName_ = nextSpell ? gameHandler.getSpellName(nextSpell) : ""; + if (pendingTalentName_.empty()) + pendingTalentName_ = spellId ? gameHandler.getSpellName(spellId) : "Talent"; } ImGui::PopID(); From 0caf945a44b6f760019ec482b34c59a8d54b0d40 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:25:35 -0700 Subject: [PATCH 44/58] feat: add NumLock auto-run toggle and HUD indicator NumLock now toggles auto-run alongside the existing tilde key. A cyan [Auto-Run] indicator appears in the player info area when active. --- src/rendering/camera_controller.cpp | 5 +++-- src/ui/game_screen.cpp | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index d16fa26c..53be1a25 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -273,8 +273,9 @@ void CameraController::update(float deltaTime) { keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false; } - // Tilde toggles auto-run; any forward/backward key cancels it - bool tildeDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_GRAVE); + // Tilde or NumLock toggles auto-run; any forward/backward key cancels it + bool tildeDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_GRAVE) || + input.isKeyPressed(SDL_SCANCODE_NUMLOCKCLEAR)); if (tildeDown && !tildeWasDown) { autoRunning = !autoRunning; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 8edb82ec..4c0fe1d7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3304,6 +3304,15 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); } + if (auto* ren = core::Application::getInstance().getRenderer()) { + if (auto* cam = ren->getCameraController()) { + if (cam->isAutoRunning()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[Auto-Run]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Auto-running — press ` or NumLock to stop"); + } + } + } if (inCombatConfirmed && !isDead) { float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); From 4a30fdf9f64c16ffcb637ac9662c28b7583a0e90 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:29:08 -0700 Subject: [PATCH 45/58] feat: add spell icon to nameplate cast bars Nameplate cast bars now display the spell icon to the left of the bar, matching the visual treatment of target frame and party cast bars. --- src/ui/game_screen.cpp | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4c0fe1d7..3fc994d0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -11197,15 +11197,32 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); const float cbH = 6.0f * nameplateScale_; - // Spell name above the cast bar + // Spell icon + name above the cast bar const std::string& spellName = gameHandler.getSpellName(cs->spellId); - if (!spellName.empty()) { - ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); - float snX = sx - snSz.x * 0.5f; - float snY = castBarBaseY; - drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); - drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); - castBarBaseY += snSz.y + 2.0f; + { + auto* castAm = core::Application::getInstance().getAssetManager(); + VkDescriptorSet castIcon = (cs->spellId && castAm) + ? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE; + float iconSz = cbH + 8.0f; + if (castIcon) { + // Draw icon to the left of the cast bar + float iconX = barX - iconSz - 2.0f; + float iconY = castBarBaseY; + drawList->AddImage((ImTextureID)(uintptr_t)castIcon, + ImVec2(iconX, iconY), + ImVec2(iconX + iconSz, iconY + iconSz)); + drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f), + ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f), + IM_COL32(0, 0, 0, A(180)), 1.0f); + } + if (!spellName.empty()) { + ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); + float snX = sx - snSz.x * 0.5f; + float snY = castBarBaseY; + drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); + drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); + castBarBaseY += snSz.y + 2.0f; + } } // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete From f283f9eb868d93d0d50d1343dae00cf67e8afaab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:30:34 -0700 Subject: [PATCH 46/58] fix: show equipment durability summary in repair button tooltip The Repair All button tooltip now shows how many items are damaged or broken instead of a generic message, helping players gauge repair need. --- src/ui/game_screen.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3fc994d0..f9c3af10 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15890,7 +15890,22 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { gameHandler.repairAll(vendor.vendorGuid, false); } if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Repair all equipped items using your gold"); + // Show durability summary of all equipment + const auto& inv = gameHandler.getInventory(); + int damagedCount = 0; + int brokenCount = 0; + for (int s = 0; s < static_cast(game::EquipSlot::BAG1); s++) { + const auto& slot = inv.getEquipSlot(static_cast(s)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) brokenCount++; + else if (slot.item.curDurability < slot.item.maxDurability) damagedCount++; + } + if (brokenCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d damaged, %d broken", damagedCount, brokenCount); + else if (damagedCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d item%s need repair", damagedCount, damagedCount > 1 ? "s" : ""); + else + ImGui::SetTooltip("All equipment is in good condition"); } if (gameHandler.isInGuild()) { ImGui::SameLine(); From d6c752fba55ba5045c9861ecd25e8c50548b1187 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:35:05 -0700 Subject: [PATCH 47/58] feat: Escape key closes topmost open window before showing menu Escape now closes UI windows in priority order (vendor, bank, trainer, who, combat log, social, talents, spellbook, quest log, character, inventory, world map) before falling through to the escape menu, matching standard WoW behavior. --- src/ui/game_screen.cpp | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f9c3af10..b5d34a0d 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2751,7 +2751,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) { if (showSettingsWindow) { - // Close settings window if open showSettingsWindow = false; } else if (showEscapeMenu) { showEscapeMenu = false; @@ -2762,6 +2761,30 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.closeLoot(); } else if (gameHandler.isGossipWindowOpen()) { gameHandler.closeGossip(); + } else if (gameHandler.isVendorWindowOpen()) { + gameHandler.closeVendor(); + } else if (gameHandler.isBankOpen()) { + gameHandler.closeBank(); + } else if (gameHandler.isTrainerWindowOpen()) { + gameHandler.closeTrainer(); + } else if (showWhoWindow_) { + showWhoWindow_ = false; + } else if (showCombatLog_) { + showCombatLog_ = false; + } else if (showSocialFrame_) { + showSocialFrame_ = false; + } else if (talentScreen.isOpen()) { + talentScreen.setOpen(false); + } else if (spellbookScreen.isOpen()) { + spellbookScreen.setOpen(false); + } else if (questLogScreen.isOpen()) { + questLogScreen.setOpen(false); + } else if (inventoryScreen.isCharacterOpen()) { + inventoryScreen.toggleCharacter(); + } else if (inventoryScreen.isOpen()) { + inventoryScreen.setOpen(false); + } else if (showWorldMap_) { + showWorldMap_ = false; } else { showEscapeMenu = true; } From bf8710d6a4e26f6e0a389fe95a55046292962e81 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:43:39 -0700 Subject: [PATCH 48/58] feat: add Shift+V toggle for friendly player nameplates V key now toggles enemy/NPC nameplates, while Shift+V independently toggles friendly player nameplates. Setting is persisted to config. --- include/ui/game_screen.hpp | 3 ++- src/ui/game_screen.cpp | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e28c5f15..e9f4419a 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -86,7 +86,8 @@ private: bool showEntityWindow = false; bool showChatWindow = true; bool showMinimap_ = true; // M key toggles minimap - bool showNameplates_ = true; // V key toggles nameplates + bool showNameplates_ = true; // V key toggles enemy/NPC nameplates + bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none) ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b5d34a0d..4b26b3e7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2805,7 +2805,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { - showNameplates_ = !showNameplates_; + if (ImGui::GetIO().KeyShift) + showFriendlyNameplates_ = !showFriendlyNameplates_; + else + showNameplates_ = !showNameplates_; } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { @@ -11072,7 +11075,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); bool isTarget = (guid == targetGuid); - // Player nameplates are always shown; NPC nameplates respect the V-key toggle + // Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle + if (isPlayer && !showFriendlyNameplates_) continue; if (!isPlayer && !showNameplates_) continue; // For corpses (dead units), only show a minimal grey nameplate if selected @@ -20268,6 +20272,7 @@ void GameScreen::saveSettings() { out << "show_keyring=" << (pendingShowKeyring ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "nameplate_scale=" << nameplateScale_ << "\n"; + out << "show_friendly_nameplates=" << (showFriendlyNameplates_ ? 1 : 0) << "\n"; out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; @@ -20397,6 +20402,8 @@ void GameScreen::loadSettings() { pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); } else if (key == "nameplate_scale") { nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); + } else if (key == "show_friendly_nameplates") { + showFriendlyNameplates_ = (std::stoi(val) != 0); } else if (key == "show_action_bar2") { pendingShowActionBar2 = (std::stoi(val) != 0); } else if (key == "action_bar2_offset_x") { From 8dfd916fe4cde32d918c4f037d6f489a5d4acfb3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:48:22 -0700 Subject: [PATCH 49/58] feat: add right-click context menu to target and focus frames Right-clicking the target or focus frame name now opens a context menu with Set Focus/Target, Clear Focus, Whisper, Invite, Trade, Duel, Inspect, Add Friend, and Ignore options (player-specific options only shown for player targets). --- src/ui/game_screen.cpp | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 4b26b3e7..3f201d59 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4185,6 +4185,39 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Right-click context menu on target frame + if (ImGui::BeginPopupContextItem("##TargetFrameCtx")) { + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(target->getGuid()); + if (target->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(name); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(target->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(target->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(name); + } + ImGui::EndPopup(); + } + // Group leader crown — golden ♛ when the targeted player is the party/raid leader if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { @@ -5075,6 +5108,42 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Right-click context menu on focus frame + if (ImGui::BeginPopupContextItem("##FocusFrameCtx")) { + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(focus->getGuid()); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focus->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(focus->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(focus->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(focus->getGuid()); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } + // Group leader crown — golden ♛ when the focused player is the party/raid leader if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { From 64fd7eddf8844d877b82260157f2e961fd5d91a0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 11:58:01 -0700 Subject: [PATCH 50/58] feat: implement barber shop UI with hair/facial customization Adds a functional barber shop window triggered by SMSG_ENABLE_BARBER_SHOP. Players can adjust hair style, hair color, and facial features using sliders bounded by race/gender max values. Sends CMSG_ALTER_APPEARANCE on confirm; server result closes the window on success. Escape key also closes the barber shop. --- include/game/game_handler.hpp | 9 +++ include/game/world_packets.hpp | 7 ++ include/ui/game_screen.hpp | 10 +++ src/game/game_handler.cpp | 10 ++- src/game/world_packets.cpp | 9 +++ src/ui/game_screen.cpp | 116 +++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f21c0bdf..624b78ae 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1219,6 +1219,12 @@ public: uint32_t getPetUnlearnCost() const { return petUnlearnCost_; } void confirmPetUnlearn(); void cancelPetUnlearn() { petUnlearnPending_ = false; } + + // Barber shop + bool isBarberShopOpen() const { return barberShopOpen_; } + void closeBarberShop() { barberShopOpen_ = false; } + void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair); + /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; /** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */ @@ -2983,6 +2989,9 @@ private: uint64_t activeCharacterGuid_ = 0; Race playerRace_ = Race::HUMAN; + // Barber shop + bool barberShopOpen_ = false; + // ---- Phase 5: Loot ---- bool lootWindowOpen = false; bool autoLoot_ = false; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index e5492eb4..c0408743 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2796,5 +2796,12 @@ public: static network::Packet build(int32_t titleBit); }; +/** CMSG_ALTER_APPEARANCE – barber shop: change hair style, color, facial hair. + * Payload: uint32 hairStyle, uint32 hairColor, uint32 facialHair. */ +class AlterAppearancePacket { +public: + static network::Packet build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair); +}; + } // namespace game } // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index e9f4419a..72523cc6 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -366,6 +366,7 @@ private: void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); void renderTrainerWindow(game::GameHandler& gameHandler); + void renderBarberShopWindow(game::GameHandler& gameHandler); void renderStableWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); void renderLogoutCountdown(game::GameHandler& gameHandler); @@ -543,6 +544,15 @@ private: uint32_t vendorConfirmPrice_ = 0; std::string vendorConfirmItemName_; + // Barber shop UI state + int barberHairStyle_ = 0; + int barberHairColor_ = 0; + int barberFacialHair_ = 0; + int barberOrigHairStyle_ = 0; + int barberOrigHairColor_ = 0; + int barberOrigFacialHair_ = 0; + bool barberInitialized_ = false; + // Trainer search filter char trainerSearchFilter_[128] = ""; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5ffb2028..c831f1f3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2681,8 +2681,8 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_ENABLE_BARBER_SHOP: // Sent by server when player sits in barber chair — triggers barber shop UI - // No payload; we don't have barber shop UI yet, so just log LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); + barberShopOpen_ = true; break; case Opcode::SMSG_FEIGN_DEATH_RESISTED: addUIError("Your Feign Death was resisted."); @@ -4902,6 +4902,7 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Hairstyle changed."); + barberShopOpen_ = false; } else { const char* msg = (result == 1) ? "Not enough money for new hairstyle." : (result == 2) ? "You are not at a barber shop." @@ -19180,6 +19181,13 @@ void GameHandler::confirmTalentWipe() { talentWipeCost_ = 0; } +void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { + if (state != WorldState::IN_WORLD || !socket) return; + auto pkt = AlterAppearancePacket::build(hairStyle, hairColor, facialHair); + socket->send(pkt); + LOG_INFO("sendAlterAppearance: hair=", hairStyle, " color=", hairColor, " facial=", facialHair); +} + // ============================================================ // Phase 4: Group/Party // ============================================================ diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 77ccf49c..e20c2d09 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -5878,5 +5878,14 @@ network::Packet SetTitlePacket::build(int32_t titleBit) { return p; } +network::Packet AlterAppearancePacket::build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { + // CMSG_ALTER_APPEARANCE: uint32 hairStyle + uint32 hairColor + uint32 facialHair + network::Packet p(wireOpcode(Opcode::CMSG_ALTER_APPEARANCE)); + p.writeUInt32(hairStyle); + p.writeUInt32(hairColor); + p.writeUInt32(facialHair); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3f201d59..ef4081a1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -733,6 +733,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); renderTrainerWindow(gameHandler); + renderBarberShopWindow(gameHandler); renderStableWindow(gameHandler); renderTaxiWindow(gameHandler); renderMailWindow(gameHandler); @@ -2763,6 +2764,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.closeGossip(); } else if (gameHandler.isVendorWindowOpen()) { gameHandler.closeVendor(); + } else if (gameHandler.isBarberShopOpen()) { + gameHandler.closeBarberShop(); } else if (gameHandler.isBankOpen()) { gameHandler.closeBank(); } else if (gameHandler.isTrainerWindowOpen()) { @@ -16806,6 +16809,119 @@ void GameScreen::renderEscapeMenu() { ImGui::End(); } +// ============================================================ +// Barber Shop Window +// ============================================================ + +void GameScreen::renderBarberShopWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isBarberShopOpen()) { + barberInitialized_ = false; + return; + } + + const auto* ch = gameHandler.getActiveCharacter(); + if (!ch) return; + + uint8_t race = static_cast(ch->race); + game::Gender gender = ch->gender; + game::Race raceEnum = ch->race; + + // Initialize sliders from current appearance + if (!barberInitialized_) { + barberOrigHairStyle_ = static_cast((ch->appearanceBytes >> 16) & 0xFF); + barberOrigHairColor_ = static_cast((ch->appearanceBytes >> 24) & 0xFF); + barberOrigFacialHair_ = static_cast(ch->facialFeatures); + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + barberInitialized_ = true; + } + + int maxHairStyle = static_cast(game::getMaxHairStyle(raceEnum, gender)); + int maxHairColor = static_cast(game::getMaxHairColor(raceEnum, gender)); + int maxFacialHair = static_cast(game::getMaxFacialFeature(raceEnum, gender)); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float winW = 300.0f; + float winH = 220.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - winW) / 2.0f, (screenH - winH) / 2.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Appearing); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + bool open = true; + if (ImGui::Begin("Barber Shop", &open, flags)) { + ImGui::Text("Choose your new look:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushItemWidth(-1); + + // Hair Style + ImGui::Text("Hair Style"); + ImGui::SliderInt("##HairStyle", &barberHairStyle_, 0, maxHairStyle, + "%d"); + + // Hair Color + ImGui::Text("Hair Color"); + ImGui::SliderInt("##HairColor", &barberHairColor_, 0, maxHairColor, + "%d"); + + // Facial Hair / Piercings / Markings + const char* facialLabel = (gender == game::Gender::FEMALE) ? "Piercings" : "Facial Hair"; + // Some races use "Markings" or "Tusks" etc. + if (race == 8 || race == 6) facialLabel = "Features"; // Trolls, Tauren + ImGui::Text("%s", facialLabel); + ImGui::SliderInt("##FacialHair", &barberFacialHair_, 0, maxFacialHair, + "%d"); + + ImGui::PopItemWidth(); + + ImGui::Spacing(); + ImGui::Separator(); + + // Show whether anything changed + bool changed = (barberHairStyle_ != barberOrigHairStyle_ || + barberHairColor_ != barberOrigHairColor_ || + barberFacialHair_ != barberOrigFacialHair_); + + // OK / Reset / Cancel buttons + float btnW = 80.0f; + float totalW = btnW * 3 + ImGui::GetStyle().ItemSpacing.x * 2; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) / 2.0f); + + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("OK", ImVec2(btnW, 0))) { + gameHandler.sendAlterAppearance( + static_cast(barberHairStyle_), + static_cast(barberHairColor_), + static_cast(barberFacialHair_)); + // Keep window open — server will respond with SMSG_BARBER_SHOP_RESULT + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("Reset", ImVec2(btnW, 0))) { + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.closeBarberShop(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeBarberShop(); + } +} + // ============================================================ // Pet Stable Window // ============================================================ From 5d5083683f06d71fab59059fc821d2a43cdcd31e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:03:36 -0700 Subject: [PATCH 51/58] fix: correct Eye of the Storm bgTypeId and simplify BG invite popup Eye of the Storm uses bgTypeId 7 (from BattlemasterList.dbc), not 6. BG invite popup now uses the stored bgName from the queue slot instead of re-deriving the name with a duplicate switch statement. --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 3 ++- src/ui/game_screen.cpp | 18 ++---------------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 624b78ae..d0b6a1db 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -453,6 +453,7 @@ public: uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE) uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE) std::chrono::steady_clock::time_point inviteReceivedTime{}; + std::string bgName; // human-readable BG/arena name }; // Available BG list (populated by SMSG_BATTLEFIELD_LIST) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c831f1f3..63dae478 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16119,7 +16119,7 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, - {6, "Eye of the Storm"}, + {7, "Eye of the Storm"}, {9, "Strand of the Ancients"}, {11, "Isle of Conquest"}, {30, "Nagrand Arena"}, @@ -16177,6 +16177,7 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; + bgQueues_[queueSlot].bgName = bgName; if (statusId == 1) { bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec; bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ef4081a1..e09aed65 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13730,22 +13730,8 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { 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; - } - } - + // BG name from stored queue data + std::string bgName = slot->bgName.empty() ? "Battleground" : slot->bgName; 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(); From 2e134b686dac89d9d42391a9f023671e23882990 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:04:38 -0700 Subject: [PATCH 52/58] fix: correct BattlemasterList.dbc IDs for arenas and Isle of Conquest Arena and BG type IDs now match actual 3.3.5a BattlemasterList.dbc: Nagrand Arena=4, Blade's Edge=5, Ruins of Lordaeron=8, Dalaran Sewers=10, Ring of Valor=11, Isle of Conquest=30, Random BG=32. --- src/game/game_handler.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 63dae478..8980febd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16115,18 +16115,21 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { uint32_t statusId = packet.readUInt32(); // Map BG type IDs to their names (stable across all three expansions) + // BattlemasterList.dbc IDs (3.3.5a) static const std::pair kBgNames[] = { {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, + {4, "Nagrand Arena"}, + {5, "Blade's Edge Arena"}, + {6, "All Arenas"}, {7, "Eye of the Storm"}, + {8, "Ruins of Lordaeron"}, {9, "Strand of the Ancients"}, - {11, "Isle of Conquest"}, - {30, "Nagrand Arena"}, - {31, "Blade's Edge Arena"}, - {32, "Dalaran Sewers"}, - {33, "Ring of Valor"}, - {34, "Ruins of Lordaeron"}, + {10, "Dalaran Sewers"}, + {11, "Ring of Valor"}, + {30, "Isle of Conquest"}, + {32, "Random Battleground"}, }; std::string bgName = "Battleground"; for (const auto& kv : kBgNames) { From 801f29f04335bdc9f0b8b2b7686085804c6a9294 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:17:00 -0700 Subject: [PATCH 53/58] fix: sync player appearance after barber shop or polymorph PLAYER_BYTES and PLAYER_BYTES_2 changes in SMSG_UPDATE_OBJECT now update the Character struct's appearanceBytes and facialFeatures, and fire an appearance-changed callback that resets the inventory screen preview so it reloads with the new hair/face values. --- include/game/game_handler.hpp | 5 +++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 28 ++++++++++++++++++++++++++-- src/ui/game_screen.cpp | 8 ++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d0b6a1db..254a6d79 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -949,6 +949,10 @@ public: using StandStateCallback = std::function; void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); } + // Appearance changed callback — fired when PLAYER_BYTES or facial features update (barber shop, etc.) + using AppearanceChangedCallback = std::function; + void setAppearanceChangedCallback(AppearanceChangedCallback cb) { appearanceChangedCallback_ = std::move(cb); } + // Ghost state callback — fired when player enters or leaves ghost (spirit) form using GhostStateCallback = std::function; void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); } @@ -3348,6 +3352,7 @@ private: NpcAggroCallback npcAggroCallback_; NpcRespawnCallback npcRespawnCallback_; StandStateCallback standStateCallback_; + AppearanceChangedCallback appearanceChangedCallback_; GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 72523cc6..4bc10707 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -665,6 +665,7 @@ private: float resurrectFlashTimer_ = 0.0f; static constexpr float kResurrectFlashDuration = 3.0f; bool ghostStateCallbackSet_ = false; + bool appearanceCallbackSet_ = false; bool ghostOpacityStateKnown_ = false; bool ghostOpacityLastState_ = false; uint32_t ghostOpacityLastInstanceId_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8980febd..301ca142 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -12177,6 +12177,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES); const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); const uint16_t ufStatsV[5] = { @@ -12227,15 +12228,38 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { playerResistances_[key - ufArmor - 1] = static_cast(val); } + else if (ufPBytesV != 0xFFFF && key == ufPBytesV) { + // PLAYER_BYTES changed (barber shop, polymorph, etc.) + // Update the Character struct so inventory preview refreshes + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.appearanceBytes = val; + break; + } + } + if (appearanceChangedCallback_) + appearanceChangedCallback_(); + } else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { + // Byte 0 (bits 0-7): facial hair / piercings + uint8_t facialHair = static_cast(val & 0xFF); + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.facialFeatures = facialHair; + break; + } + } uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); + LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots), + " facial=", static_cast(facialHair)); inventory.setPurchasedBankBagSlots(bankBagSlots); // 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 != 0); + if (appearanceChangedCallback_) + appearanceChangedCallback_(); } else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { chosenTitleBit_ = static_cast(val); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index e09aed65..26c38c32 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -401,6 +401,14 @@ void GameScreen::render(game::GameHandler& gameHandler) { ghostStateCallbackSet_ = true; } + // Set up appearance-changed callback to refresh inventory preview (barber shop, etc.) + if (!appearanceCallbackSet_) { + gameHandler.setAppearanceChangedCallback([this]() { + inventoryScreenCharGuid_ = 0; // force preview re-sync on next frame + }); + appearanceCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { From aed8c94544dde4b8366bd59a976791d91232eb7e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:21:41 -0700 Subject: [PATCH 54/58] feat: add instance difficulty indicator on minimap Show Normal/Heroic/25-Man difficulty badge below zone name when inside a dungeon or raid instance. Orange-highlighted for heroic modes. --- include/game/game_handler.hpp | 6 ++++++ src/game/game_handler.cpp | 2 ++ src/ui/game_screen.cpp | 25 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 254a6d79..9f362cb0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1230,6 +1230,11 @@ public: void closeBarberShop() { barberShopOpen_ = false; } void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair); + // Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK) + uint32_t getInstanceDifficulty() const { return instanceDifficulty_; } + bool isInstanceHeroic() const { return instanceIsHeroic_; } + bool isInInstance() const { return inInstance_; } + /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; /** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */ @@ -2867,6 +2872,7 @@ private: // Instance difficulty uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; + bool inInstance_ = false; // Raid target markers (icon 0-7 -> guid; 0 = empty slot) std::array raidTargetGuids_ = {}; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 301ca142..a9b9262e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16458,6 +16458,7 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) { } else { instanceIsHeroic_ = (instanceDifficulty_ == 1); } + inInstance_ = true; LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); // Announce difficulty change to the player (only when it actually changes) @@ -22386,6 +22387,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { } currentMapId_ = mapId; + inInstance_ = false; // cleared on map change; re-set if SMSG_INSTANCE_DIFFICULTY follows if (socket) { socket->tracePacketsFor(std::chrono::seconds(12), "new_world"); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 26c38c32..7c2cd196 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -19650,6 +19650,31 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Instance difficulty indicator — just below zone name, inside minimap top edge + if (gameHandler.isInInstance()) { + static const char* kDiffLabels[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; + uint32_t diff = gameHandler.getInstanceDifficulty(); + const char* label = (diff < 4) ? kDiffLabels[diff] : "Unknown"; + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.85f; + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label); + float tx = centerX - ts.x * 0.5f; + // Position below zone name: top edge + zone font size + small gap + float ty = centerY - mapRadius + 4.0f + ImGui::GetFontSize() + 2.0f; + float pad = 2.0f; + + // Color-code: heroic=orange, normal=light gray + ImU32 bgCol = gameHandler.isInstanceHeroic() ? IM_COL32(120, 60, 0, 180) : IM_COL32(0, 0, 0, 160); + ImU32 textCol = gameHandler.isInstanceHeroic() ? IM_COL32(255, 180, 50, 255) : IM_COL32(200, 200, 200, 220); + + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + bgCol, 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), textCol, label); + } + // Hover tooltip and right-click context menu { ImVec2 mouse = ImGui::GetMousePos(); From 41e15349c5157277a90f689d2eb6fe6c1e279b88 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:26:23 -0700 Subject: [PATCH 55/58] feat: improve arena team UI with names, types, and roster requests Store team name and type (2v2/3v3/5v5) from SMSG_ARENA_TEAM_QUERY_RESPONSE. Display proper team labels instead of raw IDs. Add Load/Refresh roster buttons and CMSG_ARENA_TEAM_ROSTER request support. --- include/game/game_handler.hpp | 3 +++ src/game/game_handler.cpp | 45 ++++++++++++++++++++++++++++------- src/ui/game_screen.cpp | 21 ++++++++++++---- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9f362cb0..7221e85b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1431,8 +1431,11 @@ public: uint32_t seasonGames = 0; uint32_t seasonWins = 0; uint32_t rank = 0; + std::string teamName; + uint32_t teamType = 0; // 2, 3, or 5 }; const std::vector& getArenaTeamStats() const { return arenaTeamStats_; } + void requestArenaTeamRoster(uint32_t teamId); // ---- Arena Team Roster ---- struct ArenaTeamMember { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a9b9262e..660c9d0a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17000,7 +17000,25 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t teamId = packet.readUInt32(); std::string teamName = packet.readString(); - LOG_INFO("Arena team query response: id=", teamId, " name=", teamName); + uint32_t teamType = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + teamType = packet.readUInt32(); + LOG_INFO("Arena team query response: id=", teamId, " name=", teamName, " type=", teamType); + + // Store name and type in matching ArenaTeamStats entry + for (auto& s : arenaTeamStats_) { + if (s.teamId == teamId) { + s.teamName = teamName; + s.teamType = teamType; + return; + } + } + // No stats entry yet — create a placeholder so we can show the name + ArenaTeamStats stub; + stub.teamId = teamId; + stub.teamName = teamName; + stub.teamType = teamType; + arenaTeamStats_.push_back(std::move(stub)); } void GameHandler::handleArenaTeamRoster(network::Packet& packet) { @@ -17144,18 +17162,29 @@ void GameHandler::handleArenaTeamStats(network::Packet& packet) { stats.seasonWins = packet.readUInt32(); stats.rank = packet.readUInt32(); - // Update or insert for this team + // Update or insert for this team (preserve name/type from query response) for (auto& s : arenaTeamStats_) { if (s.teamId == stats.teamId) { - s = stats; - LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, - " rating=", stats.rating, " rank=", stats.rank); + stats.teamName = std::move(s.teamName); + stats.teamType = s.teamType; + s = std::move(stats); + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", s.teamId, + " rating=", s.rating, " rank=", s.rank); return; } } - arenaTeamStats_.push_back(stats); - LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, - " rating=", stats.rating, " rank=", stats.rank); + arenaTeamStats_.push_back(std::move(stats)); + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", arenaTeamStats_.back().teamId, + " rating=", arenaTeamStats_.back().rating, + " rank=", arenaTeamStats_.back().rank); +} + +void GameHandler::requestArenaTeamRoster(uint32_t teamId) { + if (!socket) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_ARENA_TEAM_ROSTER)); + pkt.writeUInt32(teamId); + socket->send(pkt); + LOG_INFO("Requesting arena team roster for teamId=", teamId); } void GameHandler::handleArenaError(network::Packet& packet) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 7c2cd196..c8250564 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14755,10 +14755,15 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { const auto& ts = arenaStats[ai]; ImGui::PushID(static_cast(ai)); - // Team header with rating - char teamLabel[48]; - snprintf(teamLabel, sizeof(teamLabel), "Team #%u", ts.teamId); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel); + // Team header: "2v2: Team Name" or fallback "Team #id" + std::string teamLabel; + if (ts.teamType > 0) + teamLabel = std::to_string(ts.teamType) + "v" + std::to_string(ts.teamType) + ": "; + if (!ts.teamName.empty()) + teamLabel += ts.teamName; + else + teamLabel += "Team #" + std::to_string(ts.teamId); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel.c_str()); ImGui::Indent(8.0f); // Rating and rank @@ -14784,6 +14789,10 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::TextDisabled("-- Roster (%zu members) --", roster->members.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh")) + gameHandler.requestArenaTeamRoster(ts.teamId); + // Column headers ImGui::Columns(4, "##arenaRosterCols", false); ImGui::SetColumnWidth(0, 110.0f); @@ -14819,6 +14828,10 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::NextColumn(); } ImGui::Columns(1); + } else { + ImGui::Spacing(); + if (ImGui::SmallButton("Load Roster")) + gameHandler.requestArenaTeamRoster(ts.teamId); } ImGui::Unindent(8.0f); From d149255c58eda96838cfb87fe0d5f4001b2227f0 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:31:48 -0700 Subject: [PATCH 56/58] feat: implement petition signing flow for guild charter creation Parse SMSG_PETITION_QUERY_RESPONSE, SMSG_PETITION_SHOW_SIGNATURES, and SMSG_PETITION_SIGN_RESULTS. Add UI to view signatures, sign petitions, and turn in completed charters. Send CMSG_PETITION_SIGN and CMSG_TURN_IN_PETITION packets. --- include/game/game_handler.hpp | 24 +++++++ src/game/game_handler.cpp | 118 +++++++++++++++++++++++++++++++++- src/ui/game_screen.cpp | 56 ++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7221e85b..569261b2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -609,6 +609,26 @@ public: uint32_t getPetitionCost() const { return petitionCost_; } uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; } + // Petition signatures (guild charter signing flow) + struct PetitionSignature { + uint64_t playerGuid = 0; + std::string playerName; // resolved later or empty + }; + struct PetitionInfo { + uint64_t petitionGuid = 0; + uint64_t ownerGuid = 0; + std::string guildName; + uint32_t signatureCount = 0; + uint32_t signaturesRequired = 9; // guild default; arena teams differ + std::vector signatures; + bool showUI = false; + }; + const PetitionInfo& getPetitionInfo() const { return petitionInfo_; } + bool hasPetitionSignaturesUI() const { return petitionInfo_.showUI; } + void clearPetitionSignaturesUI() { petitionInfo_.showUI = false; } + void signPetition(uint64_t petitionGuid); + void turnInPetition(uint64_t petitionGuid); + // Guild name lookup for other players' nameplates // Returns the guild name for a given guildId, or empty if unknown. // Automatically queries the server for unknown guild IDs. @@ -2375,6 +2395,9 @@ private: void handleGuildInvite(network::Packet& packet); void handleGuildCommandResult(network::Packet& packet); void handlePetitionShowlist(network::Packet& packet); + void handlePetitionQueryResponse(network::Packet& packet); + void handlePetitionShowSignatures(network::Packet& packet); + void handlePetitionSignResults(network::Packet& packet); void handlePetSpells(network::Packet& packet); void handleTurnInPetitionResults(network::Packet& packet); @@ -2999,6 +3022,7 @@ private: bool showPetitionDialog_ = false; uint32_t petitionCost_ = 0; uint64_t petitionNpcGuid_ = 0; + PetitionInfo petitionInfo_; uint64_t activeCharacterGuid_ = 0; Race playerRace_ = Race::HUMAN; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 660c9d0a..f3ca595f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7630,9 +7630,13 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PETITION_QUERY_RESPONSE: + handlePetitionQueryResponse(packet); + break; case Opcode::SMSG_PETITION_SHOW_SIGNATURES: + handlePetitionShowSignatures(packet); + break; case Opcode::SMSG_PETITION_SIGN_RESULTS: - packet.setReadPos(packet.getSize()); + handlePetitionSignResults(packet); break; // ---- Pet system ---- @@ -19742,6 +19746,118 @@ void GameHandler::handlePetitionShowlist(network::Packet& packet) { LOG_INFO("Petition showlist: cost=", data.cost); } +void GameHandler::handlePetitionQueryResponse(network::Packet& packet) { + // SMSG_PETITION_QUERY_RESPONSE (3.3.5a): + // uint32 petitionEntry, uint64 petitionGuid, string guildName, + // string bodyText (empty), uint32 flags, uint32 minSignatures, + // uint32 maxSignatures, ...plus more fields we can skip + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 12) return; + + /*uint32_t entry =*/ packet.readUInt32(); + uint64_t petGuid = packet.readUInt64(); + std::string guildName = packet.readString(); + /*std::string body =*/ packet.readString(); + + // Update petition info if it matches our current petition + if (petitionInfo_.petitionGuid == petGuid) { + petitionInfo_.guildName = guildName; + } + + LOG_INFO("SMSG_PETITION_QUERY_RESPONSE: guid=", petGuid, " name=", guildName); + packet.setReadPos(packet.getSize()); // skip remaining fields +} + +void GameHandler::handlePetitionShowSignatures(network::Packet& packet) { + // SMSG_PETITION_SHOW_SIGNATURES (3.3.5a): + // uint64 itemGuid (petition item in inventory) + // uint64 ownerGuid + // uint32 petitionGuid (low part / entry) + // uint8 signatureCount + // For each signature: + // uint64 playerGuid + // uint32 unk (always 0) + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 21) return; + + petitionInfo_ = PetitionInfo{}; + petitionInfo_.petitionGuid = packet.readUInt64(); + petitionInfo_.ownerGuid = packet.readUInt64(); + /*uint32_t petEntry =*/ packet.readUInt32(); + uint8_t sigCount = packet.readUInt8(); + + petitionInfo_.signatureCount = sigCount; + petitionInfo_.signatures.reserve(sigCount); + + for (uint8_t i = 0; i < sigCount; ++i) { + if (rem() < 12) break; + PetitionSignature sig; + sig.playerGuid = packet.readUInt64(); + /*uint32_t unk =*/ packet.readUInt32(); + petitionInfo_.signatures.push_back(sig); + } + + petitionInfo_.showUI = true; + LOG_INFO("SMSG_PETITION_SHOW_SIGNATURES: petGuid=", petitionInfo_.petitionGuid, + " owner=", petitionInfo_.ownerGuid, + " sigs=", sigCount); +} + +void GameHandler::handlePetitionSignResults(network::Packet& packet) { + // SMSG_PETITION_SIGN_RESULTS (3.3.5a): + // uint64 petitionGuid, uint64 playerGuid, uint32 result + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 20) return; + + uint64_t petGuid = packet.readUInt64(); + uint64_t playerGuid = packet.readUInt64(); + uint32_t result = packet.readUInt32(); + + switch (result) { + case 0: // PETITION_SIGN_OK + addSystemChatMessage("Petition signed successfully."); + // Increment local count + if (petitionInfo_.petitionGuid == petGuid) { + petitionInfo_.signatureCount++; + PetitionSignature sig; + sig.playerGuid = playerGuid; + petitionInfo_.signatures.push_back(sig); + } + break; + case 1: // PETITION_SIGN_ALREADY_SIGNED + addSystemChatMessage("You have already signed that petition."); + break; + case 2: // PETITION_SIGN_ALREADY_IN_GUILD + addSystemChatMessage("You are already in a guild."); + break; + case 3: // PETITION_SIGN_CANT_SIGN_OWN + addSystemChatMessage("You cannot sign your own petition."); + break; + default: + addSystemChatMessage("Cannot sign petition (error " + std::to_string(result) + ")."); + break; + } + LOG_INFO("SMSG_PETITION_SIGN_RESULTS: pet=", petGuid, " player=", playerGuid, + " result=", result); +} + +void GameHandler::signPetition(uint64_t petitionGuid) { + if (!socket || state != WorldState::IN_WORLD) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_PETITION_SIGN)); + pkt.writeUInt64(petitionGuid); + pkt.writeUInt8(0); // unk + socket->send(pkt); + LOG_INFO("Signing petition: ", petitionGuid); +} + +void GameHandler::turnInPetition(uint64_t petitionGuid) { + if (!socket || state != WorldState::IN_WORLD) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_TURN_IN_PETITION)); + pkt.writeUInt64(petitionGuid); + socket->send(pkt); + LOG_INFO("Turning in petition: ", petitionGuid); +} + void GameHandler::handleTurnInPetitionResults(network::Packet& packet) { uint32_t result = 0; if (!TurnInPetitionResultsParser::parse(packet, result)) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c8250564..cc29c799 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -13942,6 +13942,62 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndPopup(); } + // Petition signatures window (shown when a petition item is used or offered) + if (gameHandler.hasPetitionSignaturesUI()) { + ImGui::OpenPopup("PetitionSignatures"); + gameHandler.clearPetitionSignaturesUI(); + } + if (ImGui::BeginPopupModal("PetitionSignatures", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& pInfo = gameHandler.getPetitionInfo(); + if (!pInfo.guildName.empty()) + ImGui::Text("Guild Charter: %s", pInfo.guildName.c_str()); + else + ImGui::Text("Guild Charter"); + ImGui::Separator(); + + ImGui::Text("Signatures: %u / %u", pInfo.signatureCount, pInfo.signaturesRequired); + ImGui::Spacing(); + + if (!pInfo.signatures.empty()) { + for (size_t i = 0; i < pInfo.signatures.size(); ++i) { + const auto& sig = pInfo.signatures[i]; + // Try to resolve name from entity manager + std::string sigName; + if (sig.playerGuid != 0) { + auto entity = gameHandler.getEntityManager().getEntity(sig.playerGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) sigName = unit->getName(); + } + } + if (sigName.empty()) + sigName = "Player " + std::to_string(i + 1); + ImGui::BulletText("%s", sigName.c_str()); + } + ImGui::Spacing(); + } + + // If we're not the owner, show Sign button + bool isOwner = (pInfo.ownerGuid == gameHandler.getPlayerGuid()); + if (!isOwner) { + if (ImGui::Button("Sign", ImVec2(120, 0))) { + gameHandler.signPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } else if (pInfo.signatureCount >= pInfo.signaturesRequired) { + // Owner with enough sigs — turn in + if (ImGui::Button("Turn In", ImVec2(120, 0))) { + gameHandler.turnInPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } + if (ImGui::Button("Close", ImVec2(120, 0))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + if (!showGuildRoster_) return; // Get zone manager for name lookup From 86cc6e16a4f4cf554aacc5489b78af8be67bf0be Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:40:20 -0700 Subject: [PATCH 57/58] fix: correct PET_CAST_FAILED expansion format and parse LFG role choices SMSG_PET_CAST_FAILED: Classic/TBC omit the castCount byte (matching SMSG_CAST_FAILED pattern). Without this fix, TBC parsing reads garbage. SMSG_LFG_ROLE_CHOSEN: surface role selection messages in chat during dungeon finder role checks. --- src/game/game_handler.cpp | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f3ca595f..16666085 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5806,7 +5806,31 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LFG_OFFER_CONTINUE: addSystemChatMessage("Dungeon Finder: You may continue your dungeon."); break; - case Opcode::SMSG_LFG_ROLE_CHOSEN: + case Opcode::SMSG_LFG_ROLE_CHOSEN: { + // uint64 guid + uint8 ready + uint32 roles + if (packet.getSize() - packet.getReadPos() >= 13) { + uint64_t roleGuid = packet.readUInt64(); + uint8_t ready = packet.readUInt8(); + uint32_t roles = packet.readUInt32(); + // Build a descriptive message for group chat + std::string roleName; + if (roles & 0x02) roleName += "Tank "; + if (roles & 0x04) roleName += "Healer "; + if (roles & 0x08) roleName += "DPS "; + if (roleName.empty()) roleName = "None"; + // Find player name + std::string pName = "A player"; + if (auto e = entityManager.getEntity(roleGuid)) + if (auto u = std::dynamic_pointer_cast(e)) + pName = u->getName(); + if (ready) + addSystemChatMessage(pName + " has chosen: " + roleName); + LOG_DEBUG("SMSG_LFG_ROLE_CHOSEN: guid=", roleGuid, + " ready=", (int)ready, " roles=", roles); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_LFG_UPDATE_SEARCH: case Opcode::SMSG_UPDATE_LFG_LIST: case Opcode::SMSG_LFG_PLAYER_INFO: @@ -7690,13 +7714,17 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PET_CAST_FAILED: { - if (packet.getSize() - packet.getReadPos() >= 5) { - uint8_t castCount = packet.readUInt8(); + // WotLK: castCount(1) + spellId(4) + reason(1) + // Classic/TBC: spellId(4) + reason(1) (no castCount) + const bool hasCount = isActiveExpansion("wotlk"); + const size_t minSize = hasCount ? 6u : 5u; + if (packet.getSize() - packet.getReadPos() >= minSize) { + if (hasCount) /*uint8_t castCount =*/ packet.readUInt8(); uint32_t spellId = packet.readUInt32(); uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, - " reason=", (int)reason, " castCount=", (int)castCount); + " reason=", (int)reason); if (reason != 0) { const char* reasonStr = getSpellCastResultString(reason); const std::string& sName = getSpellName(spellId); From 0b8e1834f628ccbecb60954d4c53f86f4a0a2210 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 12:43:04 -0700 Subject: [PATCH 58/58] feat: group dungeon finder list by expansion with separator headers Categorize dungeons into Random/Classic/TBC/WotLK sections with visual separators in the dropdown for easier navigation. --- src/ui/game_screen.cpp | 66 +++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cc29c799..811ef73e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -23222,36 +23222,36 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::Text("Dungeon:"); struct DungeonEntry { uint32_t id; const char* name; }; - static const DungeonEntry kDungeons[] = { - { 861, "Random Dungeon" }, - { 862, "Random Heroic" }, - // Vanilla classics - { 36, "Deadmines" }, - { 43, "Ragefire Chasm" }, - { 47, "Razorfen Kraul" }, - { 48, "Blackfathom Deeps" }, - { 52, "Uldaman" }, - { 57, "Dire Maul: East" }, - { 70, "Onyxia's Lair" }, - // TBC heroics - { 264, "The Blood Furnace" }, - { 269, "The Shattered Halls" }, - // WotLK normals/heroics - { 576, "The Nexus" }, - { 578, "The Oculus" }, - { 595, "The Culling of Stratholme" }, - { 599, "Halls of Stone" }, - { 600, "Drak'Tharon Keep" }, - { 601, "Azjol-Nerub" }, - { 604, "Gundrak" }, - { 608, "Violet Hold" }, - { 619, "Ahn'kahet: Old Kingdom" }, - { 623, "Halls of Lightning" }, - { 632, "The Forge of Souls" }, - { 650, "Trial of the Champion" }, - { 658, "Pit of Saron" }, - { 668, "Halls of Reflection" }, + // Category 0=Random, 1=Classic, 2=TBC, 3=WotLK + struct DungeonEntryEx { uint32_t id; const char* name; uint8_t cat; }; + static const DungeonEntryEx kDungeons[] = { + { 861, "Random Dungeon", 0 }, + { 862, "Random Heroic", 0 }, + { 36, "Deadmines", 1 }, + { 43, "Ragefire Chasm", 1 }, + { 47, "Razorfen Kraul", 1 }, + { 48, "Blackfathom Deeps", 1 }, + { 52, "Uldaman", 1 }, + { 57, "Dire Maul: East", 1 }, + { 70, "Onyxia's Lair", 1 }, + { 264, "The Blood Furnace", 2 }, + { 269, "The Shattered Halls", 2 }, + { 576, "The Nexus", 3 }, + { 578, "The Oculus", 3 }, + { 595, "The Culling of Stratholme", 3 }, + { 599, "Halls of Stone", 3 }, + { 600, "Drak'Tharon Keep", 3 }, + { 601, "Azjol-Nerub", 3 }, + { 604, "Gundrak", 3 }, + { 608, "Violet Hold", 3 }, + { 619, "Ahn'kahet: Old Kingdom", 3 }, + { 623, "Halls of Lightning", 3 }, + { 632, "The Forge of Souls", 3 }, + { 650, "Trial of the Champion", 3 }, + { 658, "Pit of Saron", 3 }, + { 668, "Halls of Reflection", 3 }, }; + static const char* kCatHeaders[] = { nullptr, "-- Classic --", "-- TBC --", "-- WotLK --" }; // Find current index int curIdx = 0; @@ -23261,7 +23261,15 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(-1); if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) { + uint8_t lastCat = 255; for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + if (kDungeons[i].cat != lastCat && kCatHeaders[kDungeons[i].cat]) { + if (lastCat != 255) ImGui::Separator(); + ImGui::TextDisabled("%s", kCatHeaders[kDungeons[i].cat]); + lastCat = kDungeons[i].cat; + } else if (kDungeons[i].cat != lastCat) { + lastCat = kDungeons[i].cat; + } bool selected = (kDungeons[i].id == lfgSelectedDungeon_); if (ImGui::Selectable(kDungeons[i].name, selected)) lfgSelectedDungeon_ = kDungeons[i].id;