From 71cabddbd6d61f2657d31cc2df2d41030111a7ce Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:30:55 -0700 Subject: [PATCH 01/71] net: add MSG_MOVE_START_DESCEND to other-player movement dispatch The complement to MSG_MOVE_START_ASCEND was missing from both the main dispatch switch and the compressed-moves opcode table, causing downward vertical movement of flying players to be dropped. --- src/game/game_handler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 747a557d..35097034 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4480,6 +4480,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_STOP_PITCH: case Opcode::MSG_MOVE_START_ASCEND: case Opcode::MSG_MOVE_STOP_ASCEND: + case Opcode::MSG_MOVE_START_DESCEND: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } @@ -12245,7 +12246,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12268,6 +12269,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_STOP_PITCH), wireOpcode(Opcode::MSG_MOVE_START_ASCEND), wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), + wireOpcode(Opcode::MSG_MOVE_START_DESCEND), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) From ca141bb131e87d598ffc6e65c3fb140f94929a7f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:33:47 -0700 Subject: [PATCH 02/71] net: send CMSG_MOVE_FLIGHT_ACK in response to SMSG_MOVE_SET/UNSET_FLIGHT SMSG_MOVE_SET_FLIGHT and SMSG_MOVE_UNSET_FLIGHT were previously consumed silently without sending the required ack. Most server implementations expect CMSG_MOVE_FLIGHT_ACK before toggling the FLYING movement flag on the player; without it the server may not grant or revoke flight state. Also updates movementInfo.flags so subsequent movement packets reflect the FLYING flag correctly. --- src/game/game_handler.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 35097034..ada42a6d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5540,10 +5540,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::SMSG_MOVE_SET_COLLISION_HGT: - case Opcode::SMSG_MOVE_SET_FLIGHT: - case Opcode::SMSG_MOVE_UNSET_FLIGHT: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_MOVE_SET_FLIGHT: + handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, + static_cast(MovementFlags::FLYING), true); + break; + case Opcode::SMSG_MOVE_UNSET_FLIGHT: + handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, + static_cast(MovementFlags::FLYING), false); + break; default: // In pre-world states we need full visibility (char create/login handshakes). From b3441ee9ceb34f4b004bc9239322097d95d61106 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:34:56 -0700 Subject: [PATCH 03/71] net: ack SMSG_MOVE_LAND_WALK and SMSG_MOVE_NORMAL_FALL These are the removal counterparts to SMSG_MOVE_WATER_WALK and SMSG_MOVE_FEATHER_FALL. The server expects the matching ack with the flag cleared; previously these packets were consumed silently which could leave the server's state machine waiting for an acknowledgement. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ada42a6d..08d08f2d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5536,7 +5536,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_MOVE_GRAVITY_DISABLE: case Opcode::SMSG_MOVE_GRAVITY_ENABLE: case Opcode::SMSG_MOVE_LAND_WALK: + handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, false); + break; case Opcode::SMSG_MOVE_NORMAL_FALL: + handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, false); + break; case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::SMSG_MOVE_SET_COLLISION_HGT: From c72186fd11078d9664e0e930d30021b2afc668ce Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:36:06 -0700 Subject: [PATCH 04/71] net: ack SMSG_MOVE_GRAVITY_DISABLE/ENABLE and fix fall-through bug These opcodes were inadvertently falling through to the LAND_WALK handler (same case label), causing incorrect CMSG_MOVE_WATER_WALK_ACK acks to be sent for gravity changes. Split into dedicated cases that send CMSG_MOVE_GRAVITY_DISABLE_ACK and CMSG_MOVE_GRAVITY_ENABLE_ACK respectively, as required by the server protocol. --- src/game/game_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 08d08f2d..f1fe799a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5534,7 +5534,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: + handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, 0, true); + break; case Opcode::SMSG_MOVE_GRAVITY_ENABLE: + handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK, 0, true); + break; case Opcode::SMSG_MOVE_LAND_WALK: handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, false); break; From 84558fda695da6ab90f711345aaaab2adec62598 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:40:46 -0700 Subject: [PATCH 05/71] net: ack SMSG_MOVE_SET/UNSET_CAN_TRANSITION_SWIM_FLY and SMSG_MOVE_SET_COLLISION_HGT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three server-push opcodes were silently consumed without sending the required client acks, causing the server to stall waiting for confirmation before granting the capability. - SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY → CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK (via handleForceMoveFlagChange) - SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY → same ack opcode (no separate unset ack exists in WotLK 3.3.5a) - SMSG_MOVE_SET_COLLISION_HGT → CMSG_MOVE_SET_COLLISION_HGT_ACK via new handleMoveSetCollisionHeight() which appends the float height after the standard movement block (required by server-side ack validation) --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 49 ++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8e3420c5..baa745a3 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1535,6 +1535,7 @@ private: void handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage); void handleForceMoveRootState(network::Packet& packet, bool rooted); void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set); + void handleMoveSetCollisionHeight(network::Packet& packet); void handleMoveKnockBack(network::Packet& packet); // ---- Area trigger detection ---- diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f1fe799a..28a9a211 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5546,9 +5546,15 @@ void GameHandler::handlePacket(network::Packet& packet) { handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, false); break; case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY", + Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true); + break; case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY", + Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false); + break; case Opcode::SMSG_MOVE_SET_COLLISION_HGT: - packet.setReadPos(packet.getSize()); + handleMoveSetCollisionHeight(packet); break; case Opcode::SMSG_MOVE_SET_FLIGHT: handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, @@ -11413,6 +11419,47 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* socket->send(ack); } +void GameHandler::handleMoveSetCollisionHeight(network::Packet& packet) { + // SMSG_MOVE_SET_COLLISION_HGT: packed guid + counter + float (height) + // ACK: CMSG_MOVE_SET_COLLISION_HGT_ACK = packed guid + counter + movement block + float (height) + const bool legacyGuid = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (legacyGuid ? 8u : 2u)) return; + uint64_t guid = legacyGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) return; // counter(4) + height(4) + uint32_t counter = packet.readUInt32(); + float height = packet.readFloat(); + + LOG_INFO("SMSG_MOVE_SET_COLLISION_HGT: guid=0x", std::hex, guid, std::dec, + " counter=", counter, " height=", height); + + if (guid != playerGuid) return; + if (!socket) return; + + uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_SET_COLLISION_HGT_ACK); + if (ackWire == 0xFFFF) return; + + network::Packet ack(ackWire); + const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); + if (legacyGuidAck) { + ack.writeUInt64(playerGuid); + } else { + MovementPacket::writePackedGuid(ack, playerGuid); + } + ack.writeUInt32(counter); + + MovementInfo wire = movementInfo; + wire.time = nextMovementTimestampMs(); + glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z)); + wire.x = serverPos.x; + wire.y = serverPos.y; + wire.z = serverPos.z; + if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire); + else MovementPacket::writeMovementPayload(ack, wire); + ack.writeFloat(height); + + socket->send(ack); +} + void GameHandler::handleMoveKnockBack(network::Packet& packet) { // WotLK: packed GUID; TBC/Classic: full uint64 const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); From cfc6dc37c84252f0293580fba3bb91eaeb5ac354 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:42:54 -0700 Subject: [PATCH 06/71] net: fix SMSG_SPLINE_MOVE_UNSET_FLYING and parse UNROOT/UNSET_HOVER/WATER_WALK Previously these four spline-move opcodes were silently consumed with packet.setReadPos(getSize()), skipping even the packed-GUID read. - SMSG_SPLINE_MOVE_UNSET_FLYING: now reads packed guid and fires unitMoveFlagsCallback_(guid, 0) to clear the flying animation state on nearby entities (counterpart to SMSG_SPLINE_MOVE_SET_FLYING). - SMSG_SPLINE_MOVE_UNROOT, SMSG_SPLINE_MOVE_UNSET_HOVER, SMSG_SPLINE_MOVE_WATER_WALK: now properly parse the packed guid instead of consuming the full packet; no animation-state callback needed. --- src/game/game_handler.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 28a9a211..0782e119 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5165,11 +5165,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spline move flag changes for other units ---- case Opcode::SMSG_SPLINE_MOVE_UNROOT: - case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER: - case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: { + // Minimal parse: PackedGuid only — no animation-relevant state change. + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); + } break; + } + case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: { + // PackedGuid + synthesised move-flags=0 → clears flying animation. + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; + unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY + break; + } // ---- Quest failure notification ---- case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { From 8152314ba83b8f755d25bdc5a3c23b11c9e14649 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:44:57 -0700 Subject: [PATCH 07/71] net: dispatch MSG_MOVE_SET_PITCH, GRAVITY_CHNG, UPDATE_CAN_FLY, UPDATE_CAN_TRANSITION_SWIM_FLY These four movement-broadcast opcodes (server relaying another player's movement packet) were not dispatched at all, causing nearby entity positions to be silently dropped for pitch changes and gravity/fly state broadcasts. Also add them to the kMoveOpcodes batch-parse table used by SMSG_COMPRESSED_MOVES, and parse SMSG_SPLINE_SET_FLIGHT/WALK/etc. speeds properly instead of consuming the whole packet. --- src/game/game_handler.cpp | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0782e119..6dbec7aa 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4481,6 +4481,10 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_START_ASCEND: case Opcode::MSG_MOVE_STOP_ASCEND: case Opcode::MSG_MOVE_START_DESCEND: + case Opcode::MSG_MOVE_SET_PITCH: + case Opcode::MSG_MOVE_GRAVITY_CHNG: + case Opcode::MSG_MOVE_UPDATE_CAN_FLY: + case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } @@ -5159,9 +5163,15 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_WALK_SPEED: case Opcode::SMSG_SPLINE_SET_TURN_RATE: - case Opcode::SMSG_SPLINE_SET_PITCH_RATE: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SPLINE_SET_PITCH_RATE: { + // Minimal parse: PackedGuid + float speed (no per-entity speed store yet) + if (packet.getSize() - packet.getReadPos() >= 5) { + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() >= 4) + (void)packet.readFloat(); + } break; + } // ---- Spline move flag changes for other units ---- case Opcode::SMSG_SPLINE_MOVE_UNROOT: @@ -12318,7 +12328,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12342,6 +12352,10 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_START_ASCEND), wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), wireOpcode(Opcode::MSG_MOVE_START_DESCEND), + wireOpcode(Opcode::MSG_MOVE_SET_PITCH), + wireOpcode(Opcode::MSG_MOVE_GRAVITY_CHNG), + wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_FLY), + wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) From 1180f0227cf887f9e3d6ebd47ceb36fe5a8f8ba2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:51:43 -0700 Subject: [PATCH 08/71] rendering: fix WMO portal AABB transform for rotated WMOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPortalVisible() was computing the world-space AABB by directly transforming pMin/pMax with the model matrix. This is incorrect for rotated WMOs — when the model matrix includes rotations, components can be swapped or negated, yielding an inverted AABB (worldMin.x > worldMax.x) that causes frustum.intersectsAABB() to fail. Fix: transform all 8 corners of the portal bounding box and take the component-wise min/max, which gives the correct world-space AABB for any rotation/scale. This was the root cause of portals being incorrectly culled in rotated WMO instances (e.g. many dungeon and city WMOs). Also squash the earlier spline-speed no-op fix (parse guid + float instead of consuming the full packet for SMSG_SPLINE_SET_FLIGHT_SPEED and friends) into this commit. --- src/rendering/wmo_renderer.cpp | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 4d52fd76..3df2b3fd 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -2049,12 +2049,25 @@ bool WMORenderer::isPortalVisible(const ModelData& model, uint16_t portalIndex, } center /= static_cast(portal.vertexCount); - // Transform bounds to world space for frustum test - glm::vec4 worldMin = modelMatrix * glm::vec4(pMin, 1.0f); - glm::vec4 worldMax = modelMatrix * glm::vec4(pMax, 1.0f); + // Transform all 8 corners to world space to build the correct world AABB. + // Direct transform of pMin/pMax is wrong for rotated WMOs — the matrix can + // swap or negate components, inverting min/max and causing frustum test failures. + const glm::vec3 corners[8] = { + {pMin.x, pMin.y, pMin.z}, {pMax.x, pMin.y, pMin.z}, + {pMin.x, pMax.y, pMin.z}, {pMax.x, pMax.y, pMin.z}, + {pMin.x, pMin.y, pMax.z}, {pMax.x, pMin.y, pMax.z}, + {pMin.x, pMax.y, pMax.z}, {pMax.x, pMax.y, pMax.z}, + }; + glm::vec3 worldMin( std::numeric_limits::max()); + glm::vec3 worldMax(-std::numeric_limits::max()); + for (const auto& c : corners) { + glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f)); + worldMin = glm::min(worldMin, wc); + worldMax = glm::max(worldMax, wc); + } // Check if portal AABB intersects frustum (more robust than point test) - return frustum.intersectsAABB(glm::vec3(worldMin), glm::vec3(worldMax)); + return frustum.intersectsAABB(worldMin, worldMax); } void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, From a33119c070626aa95a0f57a6e56fc06746028712 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:54:15 -0700 Subject: [PATCH 09/71] net: dispatch MSG_MOVE_ROOT and MSG_MOVE_UNROOT for other entities --- src/game/game_handler.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6dbec7aa..96044e4a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4485,6 +4485,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_GRAVITY_CHNG: case Opcode::MSG_MOVE_UPDATE_CAN_FLY: case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + case Opcode::MSG_MOVE_ROOT: + case Opcode::MSG_MOVE_UNROOT: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } @@ -12328,7 +12330,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12356,6 +12358,8 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_GRAVITY_CHNG), wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_FLY), wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY), + wireOpcode(Opcode::MSG_MOVE_ROOT), + wireOpcode(Opcode::MSG_MOVE_UNROOT), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) From 30a65320fb251701ec9515ddefc3d38de3316c12 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:56:50 -0700 Subject: [PATCH 10/71] anim: add flying state tracking and Fly/FlyIdle animation selection for entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the move-flags callback only tracked SWIMMING and WALKING, so flying players/mounts always played Run(5) or Stand(0) animations instead of Fly(61)/FlyIdle(60). Changes: - Add creatureFlyingState_ (mirroring creatureSwimmingState_) set by the FLYING flag (0x01000000) in unitMoveFlagsCallback_. - Update animation selection: moving+flying → 61 (Fly/FlyForward), idle+flying → 60 (FlyIdle/hover). Flying takes priority over swim in the priority chain: fly > swim > walk > run. - Clear creatureFlyingState_ on world reset. --- include/core/application.hpp | 1 + src/core/application.cpp | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 0c7ca61e..a1168ba4 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -190,6 +190,7 @@ private: std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) + std::unordered_map creatureFlyingState_; // guid -> currently flying (FLYING flag) std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check diff --git a/src/core/application.cpp b/src/core/application.cpp index ca692dd6..818544e2 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -752,6 +752,7 @@ void Application::logoutToLogin() { creatureWasMoving_.clear(); creatureSwimmingState_.clear(); creatureWalkingState_.clear(); + creatureFlyingState_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -1485,6 +1486,7 @@ void Application::update(float deltaTime) { // Don't override Death (1) animation. 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]; if (isMovingNow != prevMoving) { creatureWasMoving_[guid] = isMovingNow; @@ -1492,10 +1494,16 @@ void Application::update(float deltaTime) { bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { uint32_t targetAnim; - if (isMovingNow) - targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run - else - targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand + if (isMovingNow) { + if (isFlyingNow) targetAnim = 61u; // Fly (FlyForward) + else if (isSwimmingNow) targetAnim = 42u; // Swim + else if (isWalkingNow) targetAnim = 4u; // Walk + else targetAnim = 5u; // Run + } else { + if (isFlyingNow) targetAnim = 60u; // FlyIdle (hover) + else if (isSwimmingNow) targetAnim = 41u; // SwimIdle + else targetAnim = 0u; // Stand + } charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); } } @@ -2810,10 +2818,13 @@ void Application::setupUICallbacks() { gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) { const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; + const bool isFlying = (moveFlags & static_cast(game::MovementFlags::FLYING)) != 0; if (isSwimming) creatureSwimmingState_[guid] = true; else creatureSwimmingState_.erase(guid); if (isWalking) creatureWalkingState_[guid] = true; else creatureWalkingState_.erase(guid); + if (isFlying) creatureFlyingState_[guid] = true; + else creatureFlyingState_.erase(guid); }); // Emote animation callback — play server-driven emote animations on NPCs and other players From 8a20ccb69de53a71c1c92097df146b9fffd2ffde Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 11:58:19 -0700 Subject: [PATCH 11/71] anim: fix fly animation IDs to 158/159 (FlyIdle/FlyForward) --- src/core/application.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 818544e2..27fba61e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1495,14 +1495,14 @@ void Application::update(float deltaTime) { if (!gotState || curAnimId != 1 /*Death*/) { uint32_t targetAnim; if (isMovingNow) { - if (isFlyingNow) targetAnim = 61u; // Fly (FlyForward) - else if (isSwimmingNow) targetAnim = 42u; // Swim - else if (isWalkingNow) targetAnim = 4u; // Walk - else targetAnim = 5u; // Run + 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 = 60u; // FlyIdle (hover) - else if (isSwimmingNow) targetAnim = 41u; // SwimIdle - else targetAnim = 0u; // Stand + if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) + else if (isSwimmingNow) targetAnim = 41u; // SwimIdle + else targetAnim = 0u; // Stand } charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); } From 2717018631d7eb4d1164031c359c77e08d527557 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:03:33 -0700 Subject: [PATCH 12/71] anim: fix creature animation not updating on swim/fly state transitions Previously, the animation update for other entities (creatures, players) was only triggered when the moving/idle state changed. This meant a creature landing while still moving would stay in FlyForward instead of switching to Run, and a flying-idle creature touching down would keep the FlyIdle animation instead of returning to Stand. Fix: track creatureWasSwimming_ and creatureWasFlying_ alongside creatureWasMoving_, and fire the animation update whenever any of the three locomotion flags change. Clean up the new maps on world reset and on per-creature despawn. --- include/core/application.hpp | 4 +++- src/core/application.cpp | 23 ++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index a1168ba4..af7ece0b 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -187,7 +187,9 @@ private: std::unordered_map creatureInstances_; // guid → render instanceId std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position - std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureWasSwimming_; // guid -> previous-frame swim state (for anim transition detection) + std::unordered_map creatureWasFlying_; // guid -> previous-frame flying state (for anim transition detection) std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) std::unordered_map creatureFlyingState_; // guid -> currently flying (FLYING flag) diff --git a/src/core/application.cpp b/src/core/application.cpp index 27fba61e..96838e29 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -750,6 +750,8 @@ void Application::logoutToLogin() { creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); creatureWasMoving_.clear(); + creatureWasSwimming_.clear(); + creatureWasFlying_.clear(); creatureSwimmingState_.clear(); creatureWalkingState_.clear(); creatureFlyingState_.clear(); @@ -1487,9 +1489,18 @@ void Application::update(float deltaTime) { 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]; - if (isMovingNow != prevMoving) { - creatureWasMoving_[guid] = isMovingNow; + bool prevMoving = creatureWasMoving_[guid]; + bool prevSwimming = creatureWasSwimming_[guid]; + bool prevFlying = creatureWasFlying_[guid]; + // Trigger animation update on any locomotion-state transition, not just + // moving/idle — e.g. creature lands while still moving → FlyForward→Run. + const bool stateChanged = (isMovingNow != prevMoving) || + (isSwimmingNow != prevSwimming) || + (isFlyingNow != prevFlying); + if (stateChanged) { + creatureWasMoving_[guid] = isMovingNow; + creatureWasSwimming_[guid] = isSwimmingNow; + creatureWasFlying_[guid] = isFlyingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { @@ -6945,6 +6956,9 @@ void Application::despawnOnlinePlayer(uint64_t guid) { pendingOnlinePlayerEquipment_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -8538,8 +8552,11 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } From acbfe994017fe771fe81f06cbf917da84b86c036 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:04:59 -0700 Subject: [PATCH 13/71] anim: also trigger animation update on walk/run transitions for creatures Extend the locomotion state-change detection to include the WALKING movement flag. Previously a creature that switched from walking to running (or vice versa) while staying in the moving state would keep playing the wrong animation because only the moving/idle transition was tracked. Add creatureWasWalking_ alongside creatureWasSwimming_ and creatureWasFlying_; guard the walking check with isMovingNow to avoid spurious triggers when the flag flips while the creature is idle. Clear and erase the new map at world reset and creature/player despawn. --- include/core/application.hpp | 1 + src/core/application.cpp | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index af7ece0b..570f0658 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -190,6 +190,7 @@ private: std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state std::unordered_map creatureWasSwimming_; // guid -> previous-frame swim state (for anim transition detection) std::unordered_map creatureWasFlying_; // guid -> previous-frame flying state (for anim transition detection) + std::unordered_map creatureWasWalking_; // guid -> previous-frame walking state (walk vs run transition detection) std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) std::unordered_map creatureFlyingState_; // guid -> currently flying (FLYING flag) diff --git a/src/core/application.cpp b/src/core/application.cpp index 96838e29..a2c80c91 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -752,6 +752,7 @@ void Application::logoutToLogin() { creatureWasMoving_.clear(); creatureWasSwimming_.clear(); creatureWasFlying_.clear(); + creatureWasWalking_.clear(); creatureSwimmingState_.clear(); creatureWalkingState_.clear(); creatureFlyingState_.clear(); @@ -1492,15 +1493,19 @@ void Application::update(float deltaTime) { bool prevMoving = creatureWasMoving_[guid]; bool prevSwimming = creatureWasSwimming_[guid]; bool prevFlying = creatureWasFlying_[guid]; + bool prevWalking = creatureWasWalking_[guid]; // Trigger animation update on any locomotion-state transition, not just - // moving/idle — e.g. creature lands while still moving → FlyForward→Run. - const bool stateChanged = (isMovingNow != prevMoving) || + // moving/idle — e.g. creature lands while still moving → FlyForward→Run, + // or server changes WALKING flag while creature is already running → Walk. + const bool stateChanged = (isMovingNow != prevMoving) || (isSwimmingNow != prevSwimming) || - (isFlyingNow != prevFlying); + (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*/) { @@ -6957,8 +6962,10 @@ void Application::despawnOnlinePlayer(uint64_t guid) { creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); creatureFlyingState_.erase(guid); + creatureWasMoving_.erase(guid); creatureWasSwimming_.erase(guid); creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -8554,6 +8561,7 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureWasMoving_.erase(guid); creatureWasSwimming_.erase(guid); creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); creatureFlyingState_.erase(guid); From 8856af6b2d153dff923811a53f374dbe2a81ae34 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:08:58 -0700 Subject: [PATCH 14/71] lfg: implement CMSG_LFG_SET_BOOT_VOTE and vote-to-kick UI CMSG_LFG_SET_BOOT_VOTE was defined in the opcode table but never sent. - Add GameHandler::lfgSetBootVote(bool) which sends the packet - Fix handleLfgBootProposalUpdate() to set lfgState_=Boot while the vote is in progress and return to InDungeon when it ends - Add Yes/No vote buttons to the Dungeon Finder window when in Boot state --- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 23 ++++++++++++++++++++--- src/ui/game_screen.cpp | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index baa745a3..166d2573 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -944,6 +944,7 @@ public: void lfgJoin(uint32_t dungeonId, uint8_t roles); void lfgLeave(); void lfgAcceptProposal(uint32_t proposalId, bool accept); + void lfgSetBootVote(bool vote); void lfgTeleport(bool toLfgDungeon = true); LfgState getLfgState() const { return lfgState_; } bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 96044e4a..91c7372c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11952,13 +11952,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; if (inProgress) { + lfgState_ = LfgState::Boot; addSystemChatMessage( std::string("Dungeon Finder: Vote to kick in progress (") + std::to_string(timeLeft) + "s remaining)."); - } else if (myAnswer) { - addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); } else { - addSystemChatMessage("Dungeon Finder: Vote kick failed."); + // Boot vote ended — return to InDungeon state regardless of outcome + lfgState_ = LfgState::InDungeon; + if (myAnswer) { + addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); + } else { + addSystemChatMessage("Dungeon Finder: Vote kick failed."); + } } LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, @@ -12027,6 +12032,18 @@ void GameHandler::lfgTeleport(bool toLfgDungeon) { LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon); } +void GameHandler::lfgSetBootVote(bool vote) { + if (!socket) return; + uint16_t wireOp = wireOpcode(Opcode::CMSG_LFG_SET_BOOT_VOTE); + if (wireOp == 0xFFFF) return; + + network::Packet pkt(wireOp); + pkt.writeUInt8(vote ? 1 : 0); + + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_SET_BOOT_VOTE: vote=", vote); +} + void GameHandler::loadAreaTriggerDbc() { if (areaTriggerDbcLoaded_) return; areaTriggerDbcLoaded_ = true; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1a7ac0e1..14d78caf 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10583,6 +10583,20 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::Separator(); } + // ---- Vote-to-kick buttons ---- + if (state == LfgState::Boot) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Vote to kick in progress:"); + ImGui::Spacing(); + if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(true); + } + ImGui::SameLine(); + if (ImGui::Button("Vote No (keep)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(false); + } + ImGui::Separator(); + } + // ---- Teleport button (in dungeon) ---- if (state == LfgState::InDungeon) { if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) { From dd3f9e5b9e3883b91f6ffab91fc140b6ee4a7e57 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:11:13 -0700 Subject: [PATCH 15/71] wmo: enable portal culling by default after AABB transform fix The AABB transform bug (direct min/max transform was wrong for rotated WMOs) was fixed in a prior commit. Portal culling now uses the correct world-space AABB computed from all 8 corners, so frustum intersection is valid. The AABB-based test is conservative (no portal plane-side check): a visible portal can only be incorrectly INCLUDED, never EXCLUDED. This means no geometry can disappear, and any overdraw is handled by the z-buffer. Enable by default to get the performance benefit inside WMOs and dungeons. --- include/rendering/wmo_renderer.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 50261865..136cbc0e 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -696,7 +696,7 @@ private: // Rendering state bool wireframeMode = false; bool frustumCulling = true; - bool portalCulling = false; // Disabled by default - needs debugging + bool portalCulling = true; // AABB transform bug fixed; conservative frustum test (no plane-side check) is visually safe bool distanceCulling = false; // Disabled - causes ground to disappear float maxGroupDistance = 500.0f; float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2 From c622fde7be6300fccd856c872a2a3f3d5b22cd6b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:28:11 -0700 Subject: [PATCH 16/71] physics: implement knockback simulation from SMSG_MOVE_KNOCK_BACK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the handler ACKed with current position and ignored the velocity fields entirely (vcos/vsin/hspeed/vspeed were [[maybe_unused]]). The server expects the client to fly through the air on knockback — without simulation the player stays in place while the server models them as airborne, causing position desync and rubberbanding. Changes: - CameraController: add applyKnockBack(vcos, vsin, hspeed, vspeed) that sets knockbackHorizVel_ and launches verticalVelocity = -vspeed (server sends vspeed as negative for upward launches, matching TrinityCore) - Physics loop: each tick adds knockbackHorizVel_ to targetPos then applies exponential drag (KNOCKBACK_HORIZ_DRAG=4.5/s) until velocity < 0.05 u/s - GameHandler: parse all four fields, add KnockBackCallback, call it for the local player so the camera controller receives the impulse - Application: register the callback — routes server knockback to physics The existing ACK path is unchanged; the server gets position confirmation as before while the client now actually simulates the trajectory. --- include/game/game_handler.hpp | 6 +++++ include/rendering/camera_controller.hpp | 14 ++++++++++++ src/core/application.cpp | 5 +++++ src/game/game_handler.cpp | 17 +++++++++----- src/rendering/camera_controller.cpp | 30 +++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 166d2573..b34bc1a9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -693,6 +693,11 @@ public: using WorldEntryCallback = std::function; void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } + // Knockback callback: called when server sends SMSG_MOVE_KNOCK_BACK for the player. + // Parameters: vcos, vsin (render-space direction), hspeed, vspeed (raw from packet). + using KnockBackCallback = std::function; + void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) using UnstuckCallback = std::function; void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } @@ -1812,6 +1817,7 @@ private: // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; + KnockBackCallback knockBackCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 79a7d622..679b2fa4 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -103,6 +103,12 @@ public: // Trigger mount jump (applies vertical velocity for physics hop) void triggerMountJump(); + // Apply server-driven knockback impulse. + // dir: render-space 2D direction unit vector (from vcos/vsin in packet) + // hspeed: horizontal speed magnitude (units/s) + // vspeed: raw packet vspeed field (server sends negative for upward launch) + void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed); + // For first-person player hiding void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { characterRenderer = cr; @@ -313,6 +319,14 @@ private: float cachedFloorHeight_ = 0.0f; bool hasCachedFloor_ = false; static constexpr float COLLISION_CACHE_DISTANCE = 0.15f; // Re-check every 15cm + + // Server-driven knockback state. + // When the server sends SMSG_MOVE_KNOCK_BACK, we apply horizontal + vertical + // impulse here and let the normal physics loop (gravity, collision) resolve it. + bool knockbackActive_ = false; + glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s) + // Horizontal velocity decays via WoW-like drag so the player doesn't slide forever. + static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s) }; } // namespace rendering diff --git a/src/core/application.cpp b/src/core/application.cpp index a2c80c91..065912dc 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -636,6 +636,11 @@ void Application::setState(AppState newState) { renderer->triggerMeleeSwing(); } }); + gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); + } + }); } // Load quest marker models loadQuestMarkerModels(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 91c7372c..76a4a053 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11491,16 +11491,23 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4) uint32_t counter = packet.readUInt32(); - [[maybe_unused]] float vcos = packet.readFloat(); - [[maybe_unused]] float vsin = packet.readFloat(); - [[maybe_unused]] float hspeed = packet.readFloat(); - [[maybe_unused]] float vspeed = packet.readFloat(); + float vcos = packet.readFloat(); + float vsin = packet.readFloat(); + float hspeed = packet.readFloat(); + float vspeed = packet.readFloat(); LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec, - " counter=", counter, " hspeed=", hspeed, " vspeed=", vspeed); + " counter=", counter, " vcos=", vcos, " vsin=", vsin, + " hspeed=", hspeed, " vspeed=", vspeed); if (guid != playerGuid) return; + // Apply knockback physics locally so the player visually flies through the air. + // The callback forwards to CameraController::applyKnockBack(). + if (knockBackCallback_) { + knockBackCallback_(vcos, vsin, hspeed, vspeed); + } + if (!socket) return; uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK); if (ackWire == 0xFFFF) return; diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index cd6f7c27..bceb41ba 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -685,6 +685,20 @@ void CameraController::update(float deltaTime) { targetPos += movement * speed * physicsDeltaTime; } + // Apply server-driven knockback horizontal velocity (decays over time). + if (knockbackActive_) { + targetPos.x += knockbackHorizVel_.x * physicsDeltaTime; + targetPos.y += knockbackHorizVel_.y * physicsDeltaTime; + // Exponential drag: reduce each frame so the player decelerates naturally. + float drag = std::exp(-KNOCKBACK_HORIZ_DRAG * physicsDeltaTime); + knockbackHorizVel_ *= drag; + // Once negligible, clear the flag so collision/grounding work normally. + if (glm::length(knockbackHorizVel_) < 0.05f) { + knockbackActive_ = false; + knockbackHorizVel_ = glm::vec2(0.0f); + } + } + // Jump with input buffering and coyote time if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME; if (grounded) coyoteTimer = COYOTE_TIME; @@ -2096,5 +2110,21 @@ void CameraController::triggerMountJump() { } } +void CameraController::applyKnockBack(float vcos, float vsin, float hspeed, float vspeed) { + // The server sends (vcos, vsin) as the 2D direction vector in server/wire + // coordinate space. After the server→canonical→render swaps, the direction + // in render space is simply (vcos, vsin) — the two swaps cancel each other. + knockbackHorizVel_ = glm::vec2(vcos, vsin) * hspeed; + knockbackActive_ = true; + + // vspeed in the wire packet is negative when the server wants to launch the + // player upward (matches TrinityCore: data << float(-speedZ)). Negate it + // here to obtain the correct upward initial velocity. + verticalVelocity = -vspeed; + grounded = false; + coyoteTimer = 0.0f; + jumpBufferTimer = 0.0f; +} + } // namespace rendering } // namespace wowee From 70abb12398ddaa58ea446990a0b62404a2b2dd12 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:30:13 -0700 Subject: [PATCH 17/71] physics: send MSG_MOVE_JUMP on knockback to set FALLING flag correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyKnockBack() sets grounded=false and applies vertical velocity, but the normal jump detection path (nowJump && !wasJumping && grounded) never fires during a server-driven knockback because no jump key is pressed. Without MSG_MOVE_JUMP the game_handler never sets MovementFlags::FALLING in movementInfo.flags, so all subsequent heartbeat packets carry incorrect flags — the server sees the player as grounded while airborne. Fix: fire movementCallback(MSG_MOVE_JUMP) directly from applyKnockBack() so the FALLING flag is set immediately. MSG_MOVE_FALL_LAND is already sent when grounded becomes true again (the existing wasFalling && grounded path). --- src/rendering/camera_controller.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index bceb41ba..0e6f9f43 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -2124,6 +2124,13 @@ void CameraController::applyKnockBack(float vcos, float vsin, float hspeed, floa grounded = false; coyoteTimer = 0.0f; jumpBufferTimer = 0.0f; + + // Notify the server that the player left the ground so the FALLING flag is + // set in subsequent movement heartbeats. The normal jump detection + // (nowJump && grounded) does not fire during a server-driven knockback. + if (movementCallback) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_JUMP)); + } } } // namespace rendering From 4cf73a6def005aebb96ff591e51ca190761978fe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:36:56 -0700 Subject: [PATCH 18/71] movement: track fallTime and jump fields in movement packets Previously movementInfo.fallTime was always 0 and jumpVelocity/jumpSinAngle/ jumpCosAngle/jumpXYSpeed were never populated. The server reads fallTime unconditionally from every movement packet and uses it to compute fall damage and anti-cheat heuristics; the jump fields are required when FALLING is set. Changes: - Add isFalling_ / fallStartMs_ to track fall state across packets - MSG_MOVE_JUMP: set isFalling_=true, record fallStartMs_, populate jump fields (jumpVelocity=7.96, direction from facing angle, jumpXYSpeed from server run speed or walk speed when WALKING flag is set) - MSG_MOVE_FALL_LAND: clear all fall/jump fields - sendMovement: update movementInfo.fallTime = (time - fallStartMs_) each call so every heartbeat and position packet carries the correct elapsed fall time - World entry: reset all fall/jump fields alongside the flag reset --- include/game/game_handler.hpp | 6 +++++ src/game/game_handler.cpp | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b34bc1a9..2607f216 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1686,6 +1686,12 @@ private: uint32_t lastMovementTimestampMs_ = 0; bool serverMovementAllowed_ = true; + // Fall/jump tracking for movement packet correctness. + // fallTime must be the elapsed ms since the FALLING flag was set; the server + // uses it for fall-damage calculations and anti-cheat validation. + bool isFalling_ = false; + uint32_t fallStartMs_ = 0; // movementInfo.time value when FALLING started + // Inventory Inventory inventory; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 76a4a053..046bdb02 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6113,6 +6113,13 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementClockStart_ = std::chrono::steady_clock::now(); lastMovementTimestampMs_ = 0; movementInfo.time = nextMovementTimestampMs(); + isFalling_ = false; + fallStartMs_ = 0; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; resurrectPending_ = false; resurrectRequestPending_ = false; onTaxiFlight_ = false; @@ -7230,6 +7237,21 @@ void GameHandler::sendMovement(Opcode opcode) { break; case Opcode::MSG_MOVE_JUMP: movementInfo.flags |= static_cast(MovementFlags::FALLING); + // Record fall start and capture horizontal velocity for jump fields. + isFalling_ = true; + fallStartMs_ = movementInfo.time; + movementInfo.fallTime = 0; + // jumpVelocity: WoW convention is the upward speed at launch. + movementInfo.jumpVelocity = 7.96f; // WOW_JUMP_VELOCITY from CameraController + { + // Facing direction encodes the horizontal movement direction at launch. + const float facingRad = movementInfo.orientation; + movementInfo.jumpCosAngle = std::cos(facingRad); + movementInfo.jumpSinAngle = std::sin(facingRad); + // Use server run speed as the horizontal speed at jump time. + const bool isWalking = (movementInfo.flags & static_cast(MovementFlags::WALKING)) != 0; + movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f); + } break; case Opcode::MSG_MOVE_START_TURN_LEFT: movementInfo.flags |= static_cast(MovementFlags::TURN_LEFT); @@ -7243,6 +7265,13 @@ void GameHandler::sendMovement(Opcode opcode) { break; case Opcode::MSG_MOVE_FALL_LAND: movementInfo.flags &= ~static_cast(MovementFlags::FALLING); + isFalling_ = false; + fallStartMs_ = 0; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; break; case Opcode::MSG_MOVE_HEARTBEAT: // No flag changes — just sends current position @@ -7251,6 +7280,24 @@ void GameHandler::sendMovement(Opcode opcode) { break; } + // Keep fallTime current: it must equal the elapsed milliseconds since FALLING + // was set, so the server can compute fall damage correctly. + if (isFalling_ && movementInfo.hasFlag(MovementFlags::FALLING)) { + // movementInfo.time is the strictly-increasing client clock (ms). + // Subtract fallStartMs_ to get elapsed fall time; clamp to non-negative. + uint32_t elapsed = (movementInfo.time >= fallStartMs_) + ? (movementInfo.time - fallStartMs_) + : 0u; + movementInfo.fallTime = elapsed; + } else if (!movementInfo.hasFlag(MovementFlags::FALLING)) { + // Ensure fallTime is zeroed whenever we're not falling. + if (isFalling_) { + isFalling_ = false; + fallStartMs_ = 0; + } + movementInfo.fallTime = 0; + } + if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) { sanitizeMovementForTaxi(); } From 9291637977ef85415fdd85f3e786212dd0c2f521 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:37:53 -0700 Subject: [PATCH 19/71] movement: fix jumpXYSpeed to be 0 when jumping in place jumpXYSpeed should reflect actual horizontal movement at jump time: - non-zero (run/walk speed) only when movement flags indicate forward/ backward/strafe movement - zero when jumping straight up without horizontal movement This prevents the server from thinking the player launched with full run speed when they jumped in place, which could affect position prediction. --- src/game/game_handler.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 046bdb02..ef41b16e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7248,9 +7248,19 @@ void GameHandler::sendMovement(Opcode opcode) { const float facingRad = movementInfo.orientation; movementInfo.jumpCosAngle = std::cos(facingRad); movementInfo.jumpSinAngle = std::sin(facingRad); - // Use server run speed as the horizontal speed at jump time. - const bool isWalking = (movementInfo.flags & static_cast(MovementFlags::WALKING)) != 0; - movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f); + // Horizontal speed: only non-zero when actually moving at jump time. + const uint32_t horizFlags = + static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT); + const bool movingHoriz = (movementInfo.flags & horizFlags) != 0; + if (movingHoriz) { + const bool isWalking = (movementInfo.flags & static_cast(MovementFlags::WALKING)) != 0; + movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f); + } else { + movementInfo.jumpXYSpeed = 0.0f; + } } break; case Opcode::MSG_MOVE_START_TURN_LEFT: From ea291179ddd3a88dc74548c8b0868a1b0cae5148 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 12:53:05 -0700 Subject: [PATCH 20/71] gameplay: fix talent reset and ignore list population on login - SMSG_IGNORE_LIST was silently consumed; now parses guid+name pairs to populate ignoreCache so /unignore works correctly for pre-existing ignores loaded at login. - MSG_TALENT_WIPE_CONFIRM was discarded without responding; now parses the NPC GUID and cost, shows a confirm dialog, and sends the required response packet when the player confirms. Without this, talent reset via Talent Master NPC was completely broken. --- include/game/game_handler.hpp | 8 ++++ include/ui/game_screen.hpp | 1 + src/game/game_handler.cpp | 52 +++++++++++++++++++++--- src/ui/game_screen.cpp | 75 +++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2607f216..c333676a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -835,6 +835,10 @@ public: bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } const std::string& getResurrectCasterName() const { return resurrectCasterName_; } + bool showTalentWipeConfirmDialog() const { return talentWipePending_; } + uint32_t getTalentWipeCost() const { return talentWipeCost_; } + void confirmTalentWipe(); + void cancelTalentWipe() { talentWipePending_ = false; } /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ @@ -2326,6 +2330,10 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + // ---- Talent wipe confirm dialog ---- + bool talentWipePending_ = false; + uint64_t talentWipeNpcGuid_ = 0; + uint32_t talentWipeCost_ = 0; bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST uint64_t resurrectCasterGuid_ = 0; std::string resurrectCasterName_; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 15e098e7..d2b303c8 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -232,6 +232,7 @@ private: void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); + void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); void renderQuestMarkers(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ef41b16e..5751f602 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1530,10 +1530,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) handleFriendList(packet); break; - case Opcode::SMSG_IGNORE_LIST: - // Ignore list: consume to avoid spurious warnings; not parsed. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_IGNORE_LIST: { + // uint8 count + count × (uint64 guid + string name) + // Populate ignoreCache so /unignore works for pre-existing ignores. + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t ignCount = packet.readUInt8(); + for (uint8_t i = 0; i < ignCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + uint64_t ignGuid = packet.readUInt64(); + std::string ignName = packet.readString(); + if (!ignName.empty() && ignGuid != 0) { + ignoreCache[ignName] = ignGuid; + } + } + LOG_DEBUG("SMSG_IGNORE_LIST: loaded ", (int)ignCount, " ignored players"); break; + } case Opcode::MSG_RANDOM_ROLL: if (state == WorldState::IN_WORLD) { @@ -4452,10 +4464,20 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_INSPECT_ARENA_TEAMS: LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); break; - case Opcode::MSG_TALENT_WIPE_CONFIRM: - // Talent reset confirmation payload is not needed client-side right now. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_TALENT_WIPE_CONFIRM: { + // Server sends: uint64 npcGuid + uint32 cost + // Client must respond with the same opcode containing uint64 npcGuid to confirm. + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); + break; + } + talentWipeNpcGuid_ = packet.readUInt64(); + talentWipeCost_ = packet.readUInt32(); + talentWipePending_ = true; + LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_, + std::dec, " cost=", talentWipeCost_); break; + } // ---- MSG_MOVE_* opcodes (server relays other players' movement) ---- case Opcode::MSG_MOVE_START_FORWARD: @@ -13568,6 +13590,24 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { addSystemChatMessage(msg); } +void GameHandler::confirmTalentWipe() { + if (!talentWipePending_) return; + talentWipePending_ = false; + + if (state != WorldState::IN_WORLD || !socket) return; + + // Respond to MSG_TALENT_WIPE_CONFIRM with the trainer GUID to trigger the reset. + // Packet: opcode(2) + uint64 npcGuid = 10 bytes. + network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); + pkt.writeUInt64(talentWipeNpcGuid_); + socket->send(pkt); + + LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec); + addSystemChatMessage("Talent reset confirmed. The server will update your talents."); + talentWipeNpcGuid_ = 0; + talentWipeCost_ = 0; +} + // ============================================================ // Phase 4: Group/Party // ============================================================ diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 14d78caf..c9c897fc 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -436,6 +436,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDeathScreen(gameHandler); renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); + renderTalentWipeConfirmDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); @@ -7525,6 +7526,80 @@ void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Talent Wipe Confirm Dialog +// ============================================================ + +void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showTalentWipeConfirmDialog()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##TalentWipeDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getTalentWipeCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = "Reset your talents for "; + text += costStr; + text += "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f)); + if (ImGui::Button("Confirm", ImVec2(btnW, 30))) { + gameHandler.confirmTalentWipe(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f)); + if (ImGui::Button("Cancel", ImVec2(btnW, 30))) { + gameHandler.cancelTalentWipe(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Settings Window // ============================================================ From 21604461fc0e9b7081c7a5a9996d1e67d598b9df Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:01:44 -0700 Subject: [PATCH 21/71] physics: block client-side movement when server roots the player When SMSG_FORCE_MOVE_ROOT sets ROOT in movementInfo.flags, the camera controller was not aware and continued to accept directional input. This caused position desync (client moves, server sees player as rooted). - Add movementRooted_ flag to CameraController with setter/getter. - Block nowForward/nowBackward/nowStrafe when movementRooted_ is set. - Sync isPlayerRooted() from GameHandler to CameraController each frame alongside the existing run-speed sync in application.cpp. - Add GameHandler::isPlayerRooted() convenience accessor. --- include/game/game_handler.hpp | 3 +++ include/rendering/camera_controller.hpp | 4 ++++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 6 ++++-- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c333676a..8b39d3d9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1152,6 +1152,9 @@ public: bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } float getServerRunSpeed() const { return serverRunSpeed_; } + bool isPlayerRooted() const { + return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; + } void dismount(); // Taxi / Flight Paths diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 679b2fa4..2c8baf3a 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -92,6 +92,8 @@ public: void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } + void setMovementRooted(bool rooted) { movementRooted_ = rooted; } + bool isMovementRooted() const { return movementRooted_; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -268,6 +270,8 @@ private: // Server-driven run speed override (0 = use default WOW_RUN_SPEED) float runSpeedOverride_ = 0.0f; + // Server-driven root state: when true, block all horizontal movement input. + bool movementRooted_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 065912dc..7de5e2d9 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1009,6 +1009,7 @@ void Application::update(float deltaTime) { runInGameStage("post-update sync", [&] { if (renderer && gameHandler && renderer->getCameraController()) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); + renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); } bool onTaxi = gameHandler && diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 0e6f9f43..63935117 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -275,8 +275,10 @@ void CameraController::update(float deltaTime) { if (mouseAutorun) { autoRunning = false; } - bool nowForward = keyW || mouseAutorun || autoRunning; - bool nowBackward = keyS; + // When the server has rooted the player, suppress all horizontal movement input. + const bool movBlocked = movementRooted_; + bool nowForward = !movBlocked && (keyW || mouseAutorun || autoRunning); + bool nowBackward = !movBlocked && keyS; bool nowStrafeLeft = false; bool nowStrafeRight = false; bool nowTurnLeft = false; From dd6f6d1174ac9f15c96ea0db67c9bdf70eedf917 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:02:35 -0700 Subject: [PATCH 22/71] physics: also block strafe input when movement is rooted --- src/rendering/camera_controller.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 63935117..1c5930b0 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -287,14 +287,15 @@ void CameraController::update(float deltaTime) { // WoW-like third-person keyboard behavior: // - RMB held: A/D strafe // - RMB released: A/D turn character+camera, Q/E strafe + // Turning is allowed even while rooted; only positional movement is blocked. if (thirdPerson && !rightMouseDown) { nowTurnLeft = keyA; nowTurnRight = keyD; - nowStrafeLeft = keyQ; - nowStrafeRight = keyE; + nowStrafeLeft = !movBlocked && keyQ; + nowStrafeRight = !movBlocked && keyE; } else { - nowStrafeLeft = keyA || keyQ; - nowStrafeRight = keyD || keyE; + nowStrafeLeft = !movBlocked && (keyA || keyQ); + nowStrafeRight = !movBlocked && (keyD || keyE); } // Keyboard turning updates camera yaw (character follows yaw in renderer) From f2337aeaa7442845e74b979a2a3a33f5c821bbfa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:07:34 -0700 Subject: [PATCH 23/71] physics: disable gravity when server sends SMSG_MOVE_GRAVITY_DISABLE SMSG_MOVE_GRAVITY_DISABLE/ENABLE now correctly set/clear the LEVITATING movement flag instead of passing flag=0. GameHandler::isGravityDisabled() reads the LEVITATING bit and is synced to CameraController each frame. When gravity is disabled the physics loop bleeds off downward velocity and skips gravity accumulation, so Levitate and similar effects actually float the player rather than letting them fall through the world. --- include/game/game_handler.hpp | 3 +++ include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 1 + src/game/game_handler.cpp | 6 ++++-- src/rendering/camera_controller.cpp | 10 ++++++++-- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8b39d3d9..60afeb23 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1155,6 +1155,9 @@ public: bool isPlayerRooted() const { return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; } + bool isGravityDisabled() const { + return (movementInfo.flags & static_cast(MovementFlags::LEVITATING)) != 0; + } void dismount(); // Taxi / Flight Paths diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 2c8baf3a..d42ec5c0 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -94,6 +94,7 @@ public: void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } + void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -272,6 +273,8 @@ private: float runSpeedOverride_ = 0.0f; // Server-driven root state: when true, block all horizontal movement input. bool movementRooted_ = false; + // Server-driven gravity disable (levitate/hover): skip gravity accumulation. + bool gravityDisabled_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 7de5e2d9..06c43ff5 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1010,6 +1010,7 @@ void Application::update(float deltaTime) { if (renderer && gameHandler && renderer->getCameraController()) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); + renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); } bool onTaxi = gameHandler && diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5751f602..51fce186 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5579,10 +5579,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: - handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, 0, true); + handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, + static_cast(MovementFlags::LEVITATING), true); break; case Opcode::SMSG_MOVE_GRAVITY_ENABLE: - handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK, 0, true); + handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK, + static_cast(MovementFlags::LEVITATING), false); break; case Opcode::SMSG_MOVE_LAND_WALK: handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, false); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 1c5930b0..b8f4ce30 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -717,8 +717,14 @@ void CameraController::update(float deltaTime) { jumpBufferTimer -= physicsDeltaTime; coyoteTimer -= physicsDeltaTime; - // Apply gravity - verticalVelocity += gravity * physicsDeltaTime; + // Apply gravity (skip when server has disabled gravity, e.g. Levitate spell) + if (gravityDisabled_) { + // Float in place: bleed off any downward velocity, allow upward to decay slowly + if (verticalVelocity < 0.0f) verticalVelocity = 0.0f; + else verticalVelocity *= std::max(0.0f, 1.0f - 3.0f * physicsDeltaTime); + } else { + verticalVelocity += gravity * physicsDeltaTime; + } targetPos.z += verticalVelocity * physicsDeltaTime; } } else { From 701cb94ba6633decb80de5cc13397ee5c9f23149 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:11:50 -0700 Subject: [PATCH 24/71] physics: apply server walk and swim speed overrides to CameraController serverWalkSpeed_ and serverSwimSpeed_ were stored in GameHandler but never exposed or synced to the camera controller. The controller used hardcoded WOW_WALK_SPEED and speed*SWIM_SPEED_FACTOR regardless of server-sent speed changes. Add getServerWalkSpeed()/getServerSwimSpeed() accessors, walkSpeedOverride_ and swimSpeedOverride_ fields in CameraController, and sync all three server speeds each frame. Both swim speed sites (main and camera-collision path) now use the override when set. This makes Slow debuffs (walk speed), Swim Form, and Engineering fins actually affect movement speed. --- include/game/game_handler.hpp | 2 ++ include/rendering/camera_controller.hpp | 6 +++++- src/core/application.cpp | 2 ++ src/rendering/camera_controller.cpp | 9 ++++++--- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 60afeb23..481a2381 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1152,6 +1152,8 @@ public: bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } float getServerRunSpeed() const { return serverRunSpeed_; } + float getServerWalkSpeed() const { return serverWalkSpeed_; } + float getServerSwimSpeed() const { return serverSwimSpeed_; } bool isPlayerRooted() const { return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; } diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index d42ec5c0..fc770d6f 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -92,6 +92,8 @@ public: void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } + void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } + void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } @@ -269,8 +271,10 @@ private: return std::sqrt(2.0f * std::abs(MOUNT_GRAVITY) * MOUNT_JUMP_HEIGHT); } - // Server-driven run speed override (0 = use default WOW_RUN_SPEED) + // Server-driven speed overrides (0 = use hardcoded default) float runSpeedOverride_ = 0.0f; + float walkSpeedOverride_ = 0.0f; + float swimSpeedOverride_ = 0.0f; // Server-driven root state: when true, block all horizontal movement input. bool movementRooted_ = false; // Server-driven gravity disable (levitate/hover): skip gravity accumulation. diff --git a/src/core/application.cpp b/src/core/application.cpp index 06c43ff5..5eb188b3 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1009,6 +1009,8 @@ void Application::update(float deltaTime) { runInGameStage("post-update sync", [&] { if (renderer && gameHandler && renderer->getCameraController()) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); + renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); + renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index b8f4ce30..94eadaac 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -320,7 +320,8 @@ void CameraController::update(float deltaTime) { if (nowBackward && !nowForward) { speed = WOW_BACK_SPEED; } else if (ctrlDown) { - speed = WOW_WALK_SPEED; + speed = (walkSpeedOverride_ > 0.0f && walkSpeedOverride_ < 100.0f && !std::isnan(walkSpeedOverride_)) + ? walkSpeedOverride_ : WOW_WALK_SPEED; } else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) { speed = runSpeedOverride_; } else { @@ -507,7 +508,8 @@ void CameraController::update(float deltaTime) { swimming = true; // Swim movement follows look pitch (forward/back), while strafe stays // lateral for stable control. - float swimSpeed = speed * SWIM_SPEED_FACTOR; + float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_)) + ? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR; float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z; // For auto-run/auto-swim: use character facing (immune to camera pan) @@ -1518,7 +1520,8 @@ void CameraController::update(float deltaTime) { if (inWater) { swimming = true; - float swimSpeed = speed * SWIM_SPEED_FACTOR; + float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_)) + ? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR; float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z; bool diveIntent = nowForward && (forward3D.z < -0.28f); From 0b99cbafb2ac147c8cba0bbcb1b1fb334da02209 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:14:52 -0700 Subject: [PATCH 25/71] physics: implement feather fall and water walk movement flag tracking Feather Fall (SMSG_MOVE_FEATHER_FALL / SMSG_MOVE_NORMAL_FALL): - Add FEATHER_FALL = 0x00004000 to MovementFlags enum - Fix handlers to set/clear the flag instead of passing flag=0 - Cap downward terminal velocity at -2.0 m/s in CameraController when feather fall is active (Slow Fall, Parachute, etc.) All three handlers now correctly propagate server movement state flags that were previously acknowledged without updating any local state. --- include/game/game_handler.hpp | 3 +++ include/game/world_packets.hpp | 1 + include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 1 + src/game/game_handler.cpp | 6 ++++-- src/rendering/camera_controller.cpp | 3 +++ 6 files changed, 15 insertions(+), 2 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 481a2381..dcf8538e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1160,6 +1160,9 @@ public: bool isGravityDisabled() const { return (movementInfo.flags & static_cast(MovementFlags::LEVITATING)) != 0; } + bool isFeatherFalling() const { + return (movementInfo.flags & static_cast(MovementFlags::FEATHER_FALL)) != 0; + } void dismount(); // Taxi / Flight Paths diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c13659c3..07a22d23 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -395,6 +395,7 @@ enum class MovementFlags : uint32_t { ROOT = 0x00000800, FALLING = 0x00001000, FALLINGFAR = 0x00002000, + FEATHER_FALL = 0x00004000, // Slow fall / Parachute SWIMMING = 0x00200000, ASCENDING = 0x00400000, CAN_FLY = 0x00800000, diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index fc770d6f..b82630f4 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -97,6 +97,7 @@ public: void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } + void setFeatherFallActive(bool active) { featherFallActive_ = active; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -279,6 +280,8 @@ private: bool movementRooted_ = false; // Server-driven gravity disable (levitate/hover): skip gravity accumulation. bool gravityDisabled_ = false; + // Server-driven feather fall: cap downward velocity to slow-fall terminal. + bool featherFallActive_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 5eb188b3..d494ada2 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1013,6 +1013,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); + renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); } bool onTaxi = gameHandler && diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 51fce186..44ab4bb7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2455,7 +2455,8 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::CAN_FLY), false); break; case Opcode::SMSG_MOVE_FEATHER_FALL: - handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, true); + handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, + static_cast(MovementFlags::FEATHER_FALL), true); break; case Opcode::SMSG_MOVE_WATER_WALK: handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, true); @@ -5590,7 +5591,8 @@ void GameHandler::handlePacket(network::Packet& packet) { handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, false); break; case Opcode::SMSG_MOVE_NORMAL_FALL: - handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, false); + handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, + static_cast(MovementFlags::FEATHER_FALL), false); break; case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY", diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 94eadaac..22887e83 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -726,6 +726,9 @@ void CameraController::update(float deltaTime) { else verticalVelocity *= std::max(0.0f, 1.0f - 3.0f * physicsDeltaTime); } else { verticalVelocity += gravity * physicsDeltaTime; + // Feather Fall / Slow Fall: cap downward terminal velocity to ~2 m/s + if (featherFallActive_ && verticalVelocity < -2.0f) + verticalVelocity = -2.0f; } targetPos.z += verticalVelocity * physicsDeltaTime; } From 1853e8aa564dadc07b4518b7366830e26f07b3b4 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:18:04 -0700 Subject: [PATCH 26/71] physics: implement Water Walk movement state tracking and surface clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMSG_MOVE_WATER_WALK / SMSG_MOVE_LAND_WALK now correctly set/clear WATER_WALK (0x00008000) in movementInfo.flags, ensuring the flag is included in movement ACKs sent to the server. In CameraController, when waterWalkActive_ is set and the player is at or above the water surface (within 0.5 units), clamp them to the water surface and mark as grounded — preventing water entry and allowing them to walk across the water surface as the spell intends. --- include/game/game_handler.hpp | 3 +++ include/game/world_packets.hpp | 1 + include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 1 + src/game/game_handler.cpp | 6 ++++-- src/rendering/camera_controller.cpp | 9 ++++++++- 6 files changed, 20 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index dcf8538e..fb1b0393 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1163,6 +1163,9 @@ public: bool isFeatherFalling() const { return (movementInfo.flags & static_cast(MovementFlags::FEATHER_FALL)) != 0; } + bool isWaterWalking() const { + return (movementInfo.flags & static_cast(MovementFlags::WATER_WALK)) != 0; + } void dismount(); // Taxi / Flight Paths diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 07a22d23..870e00b7 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -396,6 +396,7 @@ enum class MovementFlags : uint32_t { FALLING = 0x00001000, FALLINGFAR = 0x00002000, FEATHER_FALL = 0x00004000, // Slow fall / Parachute + WATER_WALK = 0x00008000, // Walk on water surface SWIMMING = 0x00200000, ASCENDING = 0x00400000, CAN_FLY = 0x00800000, diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index b82630f4..58fde4a1 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -98,6 +98,7 @@ public: bool isMovementRooted() const { return movementRooted_; } void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } void setFeatherFallActive(bool active) { featherFallActive_ = active; } + void setWaterWalkActive(bool active) { waterWalkActive_ = active; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -282,6 +283,8 @@ private: bool gravityDisabled_ = false; // Server-driven feather fall: cap downward velocity to slow-fall terminal. bool featherFallActive_ = false; + // Server-driven water walk: treat water surface as ground (don't swim). + bool waterWalkActive_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index d494ada2..4134ee89 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1014,6 +1014,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); + renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); } bool onTaxi = gameHandler && diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 44ab4bb7..df05652b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2459,7 +2459,8 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::FEATHER_FALL), true); break; case Opcode::SMSG_MOVE_WATER_WALK: - handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, true); + handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, + static_cast(MovementFlags::WATER_WALK), true); break; case Opcode::SMSG_MOVE_SET_HOVER: handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK, @@ -5588,7 +5589,8 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::LEVITATING), false); break; case Opcode::SMSG_MOVE_LAND_WALK: - handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, false); + handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, + static_cast(MovementFlags::WATER_WALK), false); break; case Opcode::SMSG_MOVE_NORMAL_FALL: handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 22887e83..1617b0d0 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -410,7 +410,14 @@ void CameraController::update(float deltaTime) { constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f; constexpr float MIN_SWIM_WATER_DEPTH = 1.0f; bool inWater = false; - if (waterH && targetPos.z < *waterH) { + // Water Walk: treat water surface as ground — player walks on top, not through. + if (waterWalkActive_ && waterH && targetPos.z >= *waterH - 0.5f) { + // Clamp to water surface so the player stands on it + targetPos.z = *waterH; + verticalVelocity = 0.0f; + grounded = true; + inWater = false; + } else if (waterH && targetPos.z < *waterH) { std::optional waterType; if (waterRenderer) { waterType = waterRenderer->getWaterTypeAt(targetPos.x, targetPos.y); From 27d18b218986ef156e3e54be514f6395d6469879 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:23:38 -0700 Subject: [PATCH 27/71] physics: implement player-controlled flying mount physics When CAN_FLY + FLYING movement flags are both set (flying mounts, Druid Flight Form), the CameraController now uses 3D pitch-following movement instead of ground physics: - Forward/back follows the camera's 3D look direction (ascend when looking up, descend when looking down) - Space = ascend vertically, X (while mounted) = descend - No gravity, no grounding, no jump coyote time - Fall-damage checks suppressed (grounded=true) Also wire up all remaining server movement state flags to CameraController: - Feather Fall: cap terminal velocity at -2 m/s - Water Walk: clamp to water surface, skip swim entry - Flying: 3D movement with no gravity All states synced each frame from GameHandler via isPlayerFlying(), isFeatherFalling(), isWaterWalking(), isGravityDisabled(). --- include/game/game_handler.hpp | 5 ++++ include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 31 ++++++++++++++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index fb1b0393..c26f152b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1166,6 +1166,11 @@ public: bool isWaterWalking() const { return (movementInfo.flags & static_cast(MovementFlags::WATER_WALK)) != 0; } + bool isPlayerFlying() const { + const uint32_t flyMask = static_cast(MovementFlags::CAN_FLY) | + static_cast(MovementFlags::FLYING); + return (movementInfo.flags & flyMask) == flyMask; + } void dismount(); // Taxi / Flight Paths diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 58fde4a1..754de661 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -99,6 +99,7 @@ public: void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } void setFeatherFallActive(bool active) { featherFallActive_ = active; } void setWaterWalkActive(bool active) { waterWalkActive_ = active; } + void setFlyingActive(bool active) { flyingActive_ = active; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -285,6 +286,8 @@ private: bool featherFallActive_ = false; // Server-driven water walk: treat water surface as ground (don't swim). bool waterWalkActive_ = false; + // Player-controlled flight (CAN_FLY + FLYING): 3D movement, no gravity. + bool flyingActive_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 4134ee89..10a69def 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1015,6 +1015,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); + renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying()); } bool onTaxi = gameHandler && diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 1617b0d0..5d536b33 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -692,6 +692,34 @@ void CameraController::update(float deltaTime) { } swimming = false; + // Player-controlled flight (flying mount / druid Flight Form): + // Use 3D pitch-following movement with no gravity or grounding. + if (flyingActive_) { + grounded = true; // suppress fall-damage checks + verticalVelocity = 0.0f; + jumpBufferTimer = 0.0f; + coyoteTimer = 0.0f; + + // Forward/back follows camera 3D direction (same as swim) + glm::vec3 flyFwd = glm::normalize(forward3D); + if (glm::length(flyFwd) < 1e-4f) flyFwd = forward; + glm::vec3 flyMove(0.0f); + if (nowForward) flyMove += flyFwd; + if (nowBackward) flyMove -= flyFwd; + if (nowStrafeLeft) flyMove += right; + if (nowStrafeRight) flyMove -= right; + // Space = ascend, X = descend while airborne + bool flyDescend = !uiWantsKeyboard && xDown && mounted_; + if (nowJump) flyMove.z += 1.0f; + if (flyDescend) flyMove.z -= 1.0f; + if (glm::length(flyMove) > 0.001f) { + flyMove = glm::normalize(flyMove); + targetPos += flyMove * speed * physicsDeltaTime; + } + targetPos.z += verticalVelocity * physicsDeltaTime; + // Skip all ground physics — go straight to collision/WMO sections + } else { + if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); targetPos += movement * speed * physicsDeltaTime; @@ -738,7 +766,8 @@ void CameraController::update(float deltaTime) { verticalVelocity = -2.0f; } targetPos.z += verticalVelocity * physicsDeltaTime; - } + } // end !flyingActive_ ground physics + } // end !inWater } else { // External follow (e.g., taxi): trust server position without grounding. swimming = false; From a1ee9827d8498c0a11d6c575400a28adb9ed8727 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:25:10 -0700 Subject: [PATCH 28/71] physics: apply server flight speed to flying mount movement serverFlightSpeed_ (from SMSG_FORCE_FLIGHT_SPEED_CHANGE) was stored but never synced to CameraController. Add getServerFlightSpeed() accessor, flightSpeedOverride_ field, and use it in the flying physics path so normal vs epic flying mounts actually move at their correct speeds. --- include/game/game_handler.hpp | 1 + include/rendering/camera_controller.hpp | 2 ++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 5 ++++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index c26f152b..96918172 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1154,6 +1154,7 @@ public: float getServerRunSpeed() const { return serverRunSpeed_; } float getServerWalkSpeed() const { return serverWalkSpeed_; } float getServerSwimSpeed() const { return serverSwimSpeed_; } + float getServerFlightSpeed() const { return serverFlightSpeed_; } bool isPlayerRooted() const { return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; } diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 754de661..5e26d62b 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -94,6 +94,7 @@ public: void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } + void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } @@ -278,6 +279,7 @@ private: float runSpeedOverride_ = 0.0f; float walkSpeedOverride_ = 0.0f; float swimSpeedOverride_ = 0.0f; + float flightSpeedOverride_ = 0.0f; // Server-driven root state: when true, block all horizontal movement input. bool movementRooted_ = false; // Server-driven gravity disable (levitate/hover): skip gravity accumulation. diff --git a/src/core/application.cpp b/src/core/application.cpp index 10a69def..82b12114 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1011,6 +1011,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); + renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 5d536b33..2f588e27 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -714,7 +714,10 @@ void CameraController::update(float deltaTime) { if (flyDescend) flyMove.z -= 1.0f; if (glm::length(flyMove) > 0.001f) { flyMove = glm::normalize(flyMove); - targetPos += flyMove * speed * physicsDeltaTime; + float flySpeed = (flightSpeedOverride_ > 0.0f && flightSpeedOverride_ < 200.0f + && !std::isnan(flightSpeedOverride_)) + ? flightSpeedOverride_ : speed; + targetPos += flyMove * flySpeed * physicsDeltaTime; } targetPos.z += verticalVelocity * physicsDeltaTime; // Skip all ground physics — go straight to collision/WMO sections From 56ec49f8376dbabb6a8100414ec96b7a07b1a283 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:28:53 -0700 Subject: [PATCH 29/71] physics: sync all server movement speeds to CameraController Previously only run speed was synced. Now all server-driven movement speeds are forwarded to the camera controller each frame: - runSpeedOverride_: server run speed (existing) - walkSpeedOverride_: server walk speed (Ctrl key movement) - swimSpeedOverride_: swim speed (Swim Form, Engineering fins) - flightSpeedOverride_: flight speed (epic vs normal flying mounts) - runBackSpeedOverride_: back-pedal speed Each uses the server value when non-zero/sane, falling back to the hardcoded WoW default constant otherwise. --- include/game/game_handler.hpp | 1 + include/rendering/camera_controller.hpp | 2 ++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 4 +++- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 96918172..a9bf2eb9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1155,6 +1155,7 @@ public: float getServerWalkSpeed() const { return serverWalkSpeed_; } float getServerSwimSpeed() const { return serverSwimSpeed_; } float getServerFlightSpeed() const { return serverFlightSpeed_; } + float getServerRunBackSpeed() const { return serverRunBackSpeed_; } bool isPlayerRooted() const { return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; } diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 5e26d62b..e68a8093 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -95,6 +95,7 @@ public: void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } + void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } @@ -280,6 +281,7 @@ private: float walkSpeedOverride_ = 0.0f; float swimSpeedOverride_ = 0.0f; float flightSpeedOverride_ = 0.0f; + float runBackSpeedOverride_ = 0.0f; // Server-driven root state: when true, block all horizontal movement input. bool movementRooted_ = false; // Server-driven gravity disable (levitate/hover): skip gravity accumulation. diff --git a/src/core/application.cpp b/src/core/application.cpp index 82b12114..afad190e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1012,6 +1012,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); + renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 2f588e27..f1da0403 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -318,7 +318,9 @@ void CameraController::update(float deltaTime) { if (useWoWSpeed) { // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower) if (nowBackward && !nowForward) { - speed = WOW_BACK_SPEED; + speed = (runBackSpeedOverride_ > 0.0f && runBackSpeedOverride_ < 100.0f + && !std::isnan(runBackSpeedOverride_)) + ? runBackSpeedOverride_ : WOW_BACK_SPEED; } else if (ctrlDown) { speed = (walkSpeedOverride_ > 0.0f && walkSpeedOverride_ < 100.0f && !std::isnan(walkSpeedOverride_)) ? walkSpeedOverride_ : WOW_WALK_SPEED; From 23293d6453bb8e893d376ffc4ade46faf9dcbeed Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:39:23 -0700 Subject: [PATCH 30/71] physics: implement HOVER movement flag physics in CameraController When the server sets MovementFlags::HOVER (SMSG_MOVE_SET_HOVER), the player now floats 4 yards above the nearest ground surface instead of standing on it. Uses the existing floor-snap path with a HOVER_HEIGHT offset applied to the snap target. - game_handler.hpp: add isHovering() accessor (reads HOVER flag from movementInfo.flags, which is already set by handleForceMoveFlagChange) - camera_controller.hpp: add hoverActive_ field and setHoverActive() - camera_controller.cpp: apply HOVER_HEIGHT = 4.0f offset at floor snap - application.cpp: sync hover state each frame alongside other movement states (gravity, feather fall, water walk, flying) --- include/game/game_handler.hpp | 3 +++ include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 5 ++++- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index a9bf2eb9..9e5ad6a7 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1173,6 +1173,9 @@ public: static_cast(MovementFlags::FLYING); return (movementInfo.flags & flyMask) == flyMask; } + bool isHovering() const { + return (movementInfo.flags & static_cast(MovementFlags::HOVER)) != 0; + } void dismount(); // Taxi / Flight Paths diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index e68a8093..2471fb8d 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -102,6 +102,7 @@ public: void setFeatherFallActive(bool active) { featherFallActive_ = active; } void setWaterWalkActive(bool active) { waterWalkActive_ = active; } void setFlyingActive(bool active) { flyingActive_ = active; } + void setHoverActive(bool active) { hoverActive_ = active; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -292,6 +293,8 @@ private: bool waterWalkActive_ = false; // Player-controlled flight (CAN_FLY + FLYING): 3D movement, no gravity. bool flyingActive_ = false; + // Server-driven hover (HOVER flag): float at fixed height above ground. + bool hoverActive_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index afad190e..a5df593e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1018,6 +1018,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying()); + renderer->getCameraController()->setHoverActive(gameHandler->isHovering()); } bool onTaxi = gameHandler && diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index f1da0403..c4e93e3c 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1249,7 +1249,10 @@ void CameraController::update(float deltaTime) { dz >= -0.25f && dz <= stepUp * 1.5f); if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) { - targetPos.z = *groundH; + // HOVER: float at fixed height above ground instead of standing on it + static constexpr float HOVER_HEIGHT = 4.0f; // ~4 yards above ground + const float snapH = hoverActive_ ? (*groundH + HOVER_HEIGHT) : *groundH; + targetPos.z = snapH; verticalVelocity = 0.0f; grounded = true; lastGroundZ = *groundH; From a33f6354905ea9ca56b3a3d6df340814cb652fab Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 13:51:47 -0700 Subject: [PATCH 31/71] physics: add server swim-back speed override to CameraController Backward swimming was using 50% of forward swim speed as a hardcoded fallback. Wire up the server-authoritative swim back speed so Warlock Dark Pact, buffs, and server-forced speed changes all apply correctly when swimming backward. - game_handler.hpp: add getServerSwimBackSpeed() accessor - camera_controller.hpp: add swimBackSpeedOverride_ field + setter - camera_controller.cpp: apply swimBackSpeedOverride_ when player swims backward without forward input; fall back to 50% of swim speed - application.cpp: sync swim back speed each frame --- include/game/game_handler.hpp | 1 + include/rendering/camera_controller.hpp | 2 ++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 8 +++++++- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9e5ad6a7..100e2640 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1154,6 +1154,7 @@ public: float getServerRunSpeed() const { return serverRunSpeed_; } float getServerWalkSpeed() const { return serverWalkSpeed_; } float getServerSwimSpeed() const { return serverSwimSpeed_; } + float getServerSwimBackSpeed() const { return serverSwimBackSpeed_; } float getServerFlightSpeed() const { return serverFlightSpeed_; } float getServerRunBackSpeed() const { return serverRunBackSpeed_; } bool isPlayerRooted() const { diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 2471fb8d..e93ad241 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -94,6 +94,7 @@ public: void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } + void setSwimBackSpeedOverride(float speed) { swimBackSpeedOverride_ = speed; } void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } @@ -281,6 +282,7 @@ private: float runSpeedOverride_ = 0.0f; float walkSpeedOverride_ = 0.0f; float swimSpeedOverride_ = 0.0f; + float swimBackSpeedOverride_ = 0.0f; float flightSpeedOverride_ = 0.0f; float runBackSpeedOverride_ = 0.0f; // Server-driven root state: when true, block all horizontal movement input. diff --git a/src/core/application.cpp b/src/core/application.cpp index a5df593e..f6ff1229 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1011,6 +1011,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); + renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed()); renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index c4e93e3c..4f315659 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -537,6 +537,10 @@ void CameraController::update(float deltaTime) { // Use character's facing direction for strafe, not camera's right vector glm::vec3 swimRight = right; // Character's right (horizontal facing), not camera's + float swimBackSpeed = (swimBackSpeedOverride_ > 0.0f && swimBackSpeedOverride_ < 100.0f + && !std::isnan(swimBackSpeedOverride_)) + ? swimBackSpeedOverride_ : swimSpeed * 0.5f; + glm::vec3 swimMove(0.0f); if (nowForward) swimMove += swimForward; if (nowBackward) swimMove -= swimForward; @@ -545,7 +549,9 @@ void CameraController::update(float deltaTime) { if (glm::length(swimMove) > 0.001f) { swimMove = glm::normalize(swimMove); - targetPos += swimMove * swimSpeed * physicsDeltaTime; + // Use backward swim speed when moving backwards only (not when combining with strafe) + float applySpeed = (nowBackward && !nowForward) ? swimBackSpeed : swimSpeed; + targetPos += swimMove * applySpeed * physicsDeltaTime; } // Spacebar = swim up (continuous, not a jump) From e2f65dfc591dbde4b03a66966621d45496f88d91 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 14:05:50 -0700 Subject: [PATCH 32/71] physics: add server flight-back speed override to CameraController SMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE was already ACK'd and stored in serverFlightBackSpeed_, but the value was never accessible or synced to the CameraController. Backward flight movement always used forward flight speed (flightSpeedOverride_), making it faster than the server intended. - Add getServerFlightBackSpeed() accessor in GameHandler - Add flightBackSpeedOverride_ field and setter in CameraController - Apply it in the fly movement block: backward-only flight uses the back speed; forward or strafing uses the forward speed as WoW does - Fallback: 50% of forward flight speed when override is unset - Sync per-frame in application.cpp alongside the other speed overrides --- include/game/game_handler.hpp | 1 + include/rendering/camera_controller.hpp | 2 ++ src/core/application.cpp | 1 + src/rendering/camera_controller.cpp | 10 +++++++--- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 100e2640..af623646 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1156,6 +1156,7 @@ public: float getServerSwimSpeed() const { return serverSwimSpeed_; } float getServerSwimBackSpeed() const { return serverSwimBackSpeed_; } float getServerFlightSpeed() const { return serverFlightSpeed_; } + float getServerFlightBackSpeed() const { return serverFlightBackSpeed_; } float getServerRunBackSpeed() const { return serverRunBackSpeed_; } bool isPlayerRooted() const { return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index e93ad241..c13df29b 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -96,6 +96,7 @@ public: void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } void setSwimBackSpeedOverride(float speed) { swimBackSpeedOverride_ = speed; } void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } + void setFlightBackSpeedOverride(float speed) { flightBackSpeedOverride_ = speed; } void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } @@ -284,6 +285,7 @@ private: float swimSpeedOverride_ = 0.0f; float swimBackSpeedOverride_ = 0.0f; float flightSpeedOverride_ = 0.0f; + float flightBackSpeedOverride_ = 0.0f; float runBackSpeedOverride_ = 0.0f; // Server-driven root state: when true, block all horizontal movement input. bool movementRooted_ = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index f6ff1229..b6bfc975 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1013,6 +1013,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed()); renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); + renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed()); renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 4f315659..21bb1392 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -722,9 +722,13 @@ void CameraController::update(float deltaTime) { if (flyDescend) flyMove.z -= 1.0f; if (glm::length(flyMove) > 0.001f) { flyMove = glm::normalize(flyMove); - float flySpeed = (flightSpeedOverride_ > 0.0f && flightSpeedOverride_ < 200.0f - && !std::isnan(flightSpeedOverride_)) - ? flightSpeedOverride_ : speed; + float flyFwdSpeed = (flightSpeedOverride_ > 0.0f && flightSpeedOverride_ < 200.0f + && !std::isnan(flightSpeedOverride_)) + ? flightSpeedOverride_ : speed; + float flyBackSpeed = (flightBackSpeedOverride_ > 0.0f && flightBackSpeedOverride_ < 200.0f + && !std::isnan(flightBackSpeedOverride_)) + ? flightBackSpeedOverride_ : flyFwdSpeed * 0.5f; + float flySpeed = (nowBackward && !nowForward) ? flyBackSpeed : flyFwdSpeed; targetPos += flyMove * flySpeed * physicsDeltaTime; } targetPos.z += verticalVelocity * physicsDeltaTime; From a9ddfe70c2c4d68a306b759668d0ff92aa20db1e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 14:18:25 -0700 Subject: [PATCH 33/71] physics: sync server turn rate and fix SPLINE speed handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getServerTurnRate() accessor and turnRateOverride_ field so the keyboard turn speed respects SMSG_FORCE_TURN_RATE_CHANGE from server - Convert rad/s → deg/s before applying to camera yaw logic - Fix SMSG_SPLINE_SET_RUN_BACK/SWIM/FLIGHT/FLIGHT_BACK/SWIM_BACK/WALK/ TURN_RATE handlers: all previously discarded the value; now update the corresponding serverXxxSpeed_ / serverTurnRate_ field when GUID matches playerGuid (camera controller syncs these every frame) --- include/game/game_handler.hpp | 1 + include/rendering/camera_controller.hpp | 3 +++ src/core/application.cpp | 1 + src/game/game_handler.cpp | 31 ++++++++++++++++++------- src/rendering/camera_controller.cpp | 11 ++++++--- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index af623646..d715b17a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1158,6 +1158,7 @@ public: float getServerFlightSpeed() const { return serverFlightSpeed_; } float getServerFlightBackSpeed() const { return serverFlightBackSpeed_; } float getServerRunBackSpeed() const { return serverRunBackSpeed_; } + float getServerTurnRate() const { return serverTurnRate_; } bool isPlayerRooted() const { return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; } diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index c13df29b..eabbe81c 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -98,6 +98,8 @@ public: void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } void setFlightBackSpeedOverride(float speed) { flightBackSpeedOverride_ = speed; } void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; } + // Server turn rate in rad/s (SMSG_FORCE_TURN_RATE_CHANGE); 0 = use WOW_TURN_SPEED default + void setTurnRateOverride(float rateRadS) { turnRateOverride_ = rateRadS; } void setMovementRooted(bool rooted) { movementRooted_ = rooted; } bool isMovementRooted() const { return movementRooted_; } void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } @@ -287,6 +289,7 @@ private: float flightSpeedOverride_ = 0.0f; float flightBackSpeedOverride_ = 0.0f; float runBackSpeedOverride_ = 0.0f; + float turnRateOverride_ = 0.0f; // rad/s; 0 = WOW_TURN_SPEED default (π rad/s) // Server-driven root state: when true, block all horizontal movement input. bool movementRooted_ = false; // Server-driven gravity disable (levitate/hover): skip gravity accumulation. diff --git a/src/core/application.cpp b/src/core/application.cpp index b6bfc975..78ec827d 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1015,6 +1015,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed()); renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); + renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index df05652b..74656907 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2401,9 +2401,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t guid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; float speed = packet.readFloat(); - if (guid == playerGuid && std::isfinite(speed) && speed > 0.1f && speed < 100.0f && - *logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) { - serverRunSpeed_ = speed; + if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) { + if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) + serverRunSpeed_ = speed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED) + serverRunBackSpeed_ = speed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_SPEED) + serverSwimSpeed_ = speed; } break; } @@ -5190,11 +5194,22 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPLINE_SET_WALK_SPEED: case Opcode::SMSG_SPLINE_SET_TURN_RATE: case Opcode::SMSG_SPLINE_SET_PITCH_RATE: { - // Minimal parse: PackedGuid + float speed (no per-entity speed store yet) - if (packet.getSize() - packet.getReadPos() >= 5) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 4) - (void)packet.readFloat(); + // Minimal parse: PackedGuid + float speed + if (packet.getSize() - packet.getReadPos() < 5) break; + uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + float sSpeed = packet.readFloat(); + if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) { + if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED) + serverFlightSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED) + serverFlightBackSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED) + serverSwimBackSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_WALK_SPEED) + serverWalkSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_TURN_RATE) + serverTurnRate_ = sSpeed; // rad/s } break; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 21bb1392..ff6205aa 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -298,11 +298,16 @@ void CameraController::update(float deltaTime) { nowStrafeRight = !movBlocked && (keyD || keyE); } - // Keyboard turning updates camera yaw (character follows yaw in renderer) + // Keyboard turning updates camera yaw (character follows yaw in renderer). + // Use server turn rate (rad/s) when set; otherwise fall back to WOW_TURN_SPEED (deg/s). + const float activeTurnSpeedDeg = (turnRateOverride_ > 0.0f && turnRateOverride_ < 20.0f + && !std::isnan(turnRateOverride_)) + ? glm::degrees(turnRateOverride_) + : WOW_TURN_SPEED; if (nowTurnLeft && !nowTurnRight) { - yaw += WOW_TURN_SPEED * deltaTime; + yaw += activeTurnSpeedDeg * deltaTime; } else if (nowTurnRight && !nowTurnLeft) { - yaw -= WOW_TURN_SPEED * deltaTime; + yaw -= activeTurnSpeedDeg * deltaTime; } if (nowTurnLeft || nowTurnRight) { camera->setRotation(yaw, pitch); From 132598fc8850581a4224277e92a331bb97cb148f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 14:32:30 -0700 Subject: [PATCH 34/71] physics: send MSG_MOVE_START/STOP_ASCEND and START_DESCEND during flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When flyingActive_, detect Space/X key transitions and emit proper flight vertical movement opcodes so the server (and other players) see the correct ascending/descending animation state: - MSG_MOVE_START_ASCEND (Space pressed while flying) → sets ASCENDING flag - MSG_MOVE_STOP_ASCEND (Space released while flying) → clears ASCENDING flag - MSG_MOVE_START_DESCEND (X pressed while flying) → clears ASCENDING flag - MSG_MOVE_STOP_ASCEND (X released while flying) → clears vertical state Track wasAscending_/wasDescending_ member state to detect transitions. Also clear lingering vertical state when leaving flight mode. --- include/rendering/camera_controller.hpp | 2 ++ src/game/game_handler.cpp | 11 +++++++++ src/rendering/camera_controller.cpp | 30 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index eabbe81c..3337a755 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -256,6 +256,8 @@ private: bool wasTurningRight = false; bool wasJumping = false; bool wasFalling = false; + bool wasAscending_ = false; // Space held while flyingActive_ + bool wasDescending_ = false; // X held while flyingActive_ bool moveForwardActive = false; bool moveBackwardActive = false; bool strafeLeftActive = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 74656907..c1f65f18 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7329,6 +7329,17 @@ void GameHandler::sendMovement(Opcode opcode) { case Opcode::MSG_MOVE_HEARTBEAT: // No flag changes — just sends current position break; + case Opcode::MSG_MOVE_START_ASCEND: + movementInfo.flags |= static_cast(MovementFlags::ASCENDING); + break; + case Opcode::MSG_MOVE_STOP_ASCEND: + // Clears ascending (and descending) — one stop opcode for both directions + movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); + break; + case Opcode::MSG_MOVE_START_DESCEND: + // Descending: no separate flag; clear ASCENDING so they don't conflict + movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); + break; default: break; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index ff6205aa..cfa6120a 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -217,6 +217,7 @@ void CameraController::update(float deltaTime) { bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); + bool spaceDown = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; @@ -1795,6 +1796,35 @@ void CameraController::update(float deltaTime) { } } + // Flight ascend/descend transitions (Space = ascend, X = descend while mounted+flying) + if (movementCallback && !externalFollow_) { + const bool nowAscending = flyingActive_ && spaceDown; + const bool nowDescending = flyingActive_ && xDown && mounted_; + + if (flyingActive_) { + if (nowAscending && !wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_START_ASCEND)); + } else if (!nowAscending && wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + if (nowDescending && !wasDescending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_START_DESCEND)); + } else if (!nowDescending && wasDescending_) { + // No separate STOP_DESCEND opcode; STOP_ASCEND ends all vertical movement + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + } else { + // Left flight mode: clear any lingering vertical movement states + if (wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } else if (wasDescending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + } + wasAscending_ = nowAscending; + wasDescending_ = nowDescending; + } + // Update previous-frame state wasSwimming = swimming; wasMovingForward = nowForward; From 920d6ac1207076c093db4a53503f0bbe43394209 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 14:46:17 -0700 Subject: [PATCH 35/71] physics: sync camera pitch to movement packets and mount tilt during flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add setMovementPitch() and isSwimming() to GameHandler - In the per-frame sync block, derive the pitch angle from the camera's forward vector (asin of the Z component) and write it to movementInfo.pitch whenever FLYING or SWIMMING flags are set — the server includes the pitch field in those packets, so sending 0 made other players see the character flying perfectly flat even when the camera was pitched - Also tilt the mount model (setMountPitchRoll) to match the flight direction during player-controlled flight, and reset to 0 when not flying --- include/game/game_handler.hpp | 6 ++++++ src/core/application.cpp | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d715b17a..4cc7f45c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1179,6 +1179,12 @@ public: bool isHovering() const { return (movementInfo.flags & static_cast(MovementFlags::HOVER)) != 0; } + bool isSwimming() const { + return (movementInfo.flags & static_cast(MovementFlags::SWIMMING)) != 0; + } + // Set the character pitch angle (radians) for movement packets (flight / swimming). + // Positive = nose up, negative = nose down. + void setMovementPitch(float radians) { movementInfo.pitch = radians; } void dismount(); // Taxi / Flight Paths diff --git a/src/core/application.cpp b/src/core/application.cpp index 78ec827d..64d98566 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1022,6 +1022,29 @@ void Application::update(float deltaTime) { renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying()); renderer->getCameraController()->setHoverActive(gameHandler->isHovering()); + + // Sync camera forward pitch to movement packets during flight / swimming. + // The server writes the pitch field when FLYING or SWIMMING flags are set; + // without this sync it would always be 0 (horizontal), causing other + // players to see the character flying flat even when pitching up/down. + if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) { + if (auto* cam = renderer->getCamera()) { + glm::vec3 fwd = cam->getForward(); + float len = glm::length(fwd); + if (len > 1e-4f) { + float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f)); + gameHandler->setMovementPitch(pitchRad); + // Tilt the mount/character model to match flight direction + // (taxi flight uses setTaxiOrientationCallback for this instead) + if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) { + renderer->setMountPitchRoll(pitchRad, 0.0f); + } + } + } + } else if (gameHandler->isMounted()) { + // Reset mount pitch when not flying + renderer->setMountPitchRoll(0.0f, 0.0f); + } } bool onTaxi = gameHandler && From 60ebb565bbf122582a60d6f600f0d8b9997cbd28 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 14:59:02 -0700 Subject: [PATCH 36/71] rendering: fix WMO portal culling and chat message format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wmo_renderer: pass character position (not camera position) to portal visibility traversal — the 3rd-person camera can orbit outside a WMO while the character is inside, causing interior groups to cull; render() now accepts optional viewerPos that defaults to camPos for compatibility - renderer: pass &characterPosition to wmoRenderer->render() at both main and single-threaded call sites; reflection pass keeps camPos - renderer: apply mount pitch/roll to rider during all flight, not just taxiFlight_ (fixes zero rider tilt during player-controlled flying) - game_screen: format SAY/YELL/WHISPER/EMOTE using WoW-style "Name says:" instead of "[SAY] Name:" bracket prefix --- include/rendering/wmo_renderer.hpp | 3 ++- src/rendering/renderer.cpp | 8 ++++---- src/rendering/wmo_renderer.cpp | 10 ++++++++-- src/ui/game_screen.cpp | 11 +++++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 136cbc0e..08108dc0 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -150,7 +150,8 @@ public: */ /** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */ void prepareRender(); - void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, + const glm::vec3* viewerPos = nullptr); /** * Initialize shadow pipeline (Phase 7) diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 6da94182..71cb2a7c 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2096,8 +2096,8 @@ void Renderer::updateCharacterAnimation() { // Rider uses character facing yaw, not mount bone rotation // (rider faces character direction, seat bone only provides position) float yawRad = glm::radians(characterYaw); - float riderPitch = taxiFlight_ ? mountPitch_ * 0.35f : 0.0f; - float riderRoll = taxiFlight_ ? mountRoll_ * 0.35f : 0.0f; + float riderPitch = mountPitch_ * 0.35f; + float riderRoll = mountRoll_ * 0.35f; characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad)); } else { // Fallback to old manual positioning if attachment not found @@ -4737,7 +4737,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { auto t0 = std::chrono::steady_clock::now(); VkCommandBuffer cmd = beginSecondary(SEC_WMO); setSecondaryViewportScissor(cmd); - wmoRenderer->render(cmd, perFrameSet, *camera); + wmoRenderer->render(cmd, perFrameSet, *camera, &characterPosition); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -4905,7 +4905,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (wmoRenderer && camera && !skipWMO) { wmoRenderer->prepareRender(); auto wmoStart = std::chrono::steady_clock::now(); - wmoRenderer->render(currentCmd, perFrameSet, *camera); + wmoRenderer->render(currentCmd, perFrameSet, *camera, &characterPosition); lastWMORenderMs = std::chrono::duration( std::chrono::steady_clock::now() - wmoStart).count(); } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 3df2b3fd..85f56431 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1356,7 +1356,8 @@ void WMORenderer::prepareRender() { } } -void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { +void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, + const glm::vec3* viewerPos) { if (!opaquePipeline_ || instances.empty()) { lastDrawCalls = 0; return; @@ -1380,6 +1381,11 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } glm::vec3 camPos = camera.getPosition(); + // For portal culling, use the character/player position when available. + // The 3rd-person camera can orbit outside a WMO while the character is inside, + // causing the portal traversal to start from outside and cull interior groups. + // Passing the actual character position as the viewer fixes this. + glm::vec3 portalViewerPos = viewerPos ? *viewerPos : camPos; bool doPortalCull = portalCulling; bool doDistanceCull = distanceCulling; @@ -1400,7 +1406,7 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); if (usePortalCulling) { std::unordered_set pvgSet; - glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); + glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(portalViewerPos, 1.0f); getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, instance.modelMatrix, pvgSet); portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end()); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c9c897fc..c1b1274f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1203,16 +1203,19 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } else if (msg.type == game::ChatType::TEXT_EMOTE) { renderTextWithLinks(tsPrefix + processedMessage, color); } else if (!msg.senderName.empty()) { - if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { + if (msg.type == game::ChatType::SAY || + msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { std::string fullMsg = tsPrefix + msg.senderName + " says: " + processedMessage; renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_YELL) { + } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { std::string fullMsg = tsPrefix + msg.senderName + " yells: " + processedMessage; renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { + } else if (msg.type == game::ChatType::WHISPER || + msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { std::string fullMsg = tsPrefix + msg.senderName + " whispers: " + processedMessage; renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { + } else if (msg.type == game::ChatType::EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { std::string fullMsg = tsPrefix + msg.senderName + " " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { From df47d425f4965c4d2eccc86ab9c16eaa656c5ad8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 15:08:21 -0700 Subject: [PATCH 37/71] ui: fix chat type display names and outgoing whisper format - getChatTypeName: use WoW-style mixed-case names (Party/Guild/Raid/etc.) instead of all-caps (PARTY/GUILD/RAID) - WHISPER_INFORM: display "To Name: message" instead of "[To] Name: message" using receiverName when available, falling back to senderName --- src/ui/game_screen.cpp | 47 +++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c1b1274f..cefd10d3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1214,6 +1214,11 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { std::string fullMsg = tsPrefix + msg.senderName + " whispers: " + processedMessage; renderTextWithLinks(fullMsg, color); + } else if (msg.type == game::ChatType::WHISPER_INFORM) { + // Outgoing whisper — show "To Name: message" (WoW-style) + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : msg.senderName; + std::string fullMsg = tsPrefix + "To " + target + ": " + processedMessage; + renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::EMOTE || msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { std::string fullMsg = tsPrefix + msg.senderName + " " + processedMessage; @@ -3378,29 +3383,29 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { const char* GameScreen::getChatTypeName(game::ChatType type) const { switch (type) { - case game::ChatType::SAY: return "SAY"; - case game::ChatType::YELL: return "YELL"; - case game::ChatType::EMOTE: return "EMOTE"; - case game::ChatType::TEXT_EMOTE: return "EMOTE"; - case game::ChatType::PARTY: return "PARTY"; - case game::ChatType::GUILD: return "GUILD"; - case game::ChatType::OFFICER: return "OFFICER"; - case game::ChatType::RAID: return "RAID"; - case game::ChatType::RAID_LEADER: return "RAID LEADER"; - case game::ChatType::RAID_WARNING: return "RAID WARNING"; - case game::ChatType::BATTLEGROUND: return "BATTLEGROUND"; - case game::ChatType::BATTLEGROUND_LEADER: return "BG LEADER"; - case game::ChatType::WHISPER: return "WHISPER"; - case game::ChatType::WHISPER_INFORM: return "TO"; - case game::ChatType::SYSTEM: return "SYSTEM"; - case game::ChatType::MONSTER_SAY: return "SAY"; - case game::ChatType::MONSTER_YELL: return "YELL"; - case game::ChatType::MONSTER_EMOTE: return "EMOTE"; - case game::ChatType::CHANNEL: return "CHANNEL"; - case game::ChatType::ACHIEVEMENT: return "ACHIEVEMENT"; + case game::ChatType::SAY: return "Say"; + case game::ChatType::YELL: return "Yell"; + case game::ChatType::EMOTE: return "Emote"; + case game::ChatType::TEXT_EMOTE: return "Emote"; + case game::ChatType::PARTY: return "Party"; + case game::ChatType::GUILD: return "Guild"; + case game::ChatType::OFFICER: return "Officer"; + case game::ChatType::RAID: return "Raid"; + case game::ChatType::RAID_LEADER: return "Raid Leader"; + case game::ChatType::RAID_WARNING: return "Raid Warning"; + case game::ChatType::BATTLEGROUND: return "Battleground"; + case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; + case game::ChatType::WHISPER: return "Whisper"; + case game::ChatType::WHISPER_INFORM: return "To"; + case game::ChatType::SYSTEM: return "System"; + case game::ChatType::MONSTER_SAY: return "Say"; + case game::ChatType::MONSTER_YELL: return "Yell"; + case game::ChatType::MONSTER_EMOTE: return "Emote"; + case game::ChatType::CHANNEL: return "Channel"; + case game::ChatType::ACHIEVEMENT: return "Achievement"; case game::ChatType::DND: return "DND"; case game::ChatType::AFK: return "AFK"; - default: return "UNKNOWN"; + default: return "Unknown"; } } From 4987388ce77a19075ccfc1a11bd7443888af0971 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 15:09:41 -0700 Subject: [PATCH 38/71] ui: show GM/AFK/DND chat tags and fix channel/bracket name display - Display , , prefix before sender name in all chat message formats based on the chatTag bitmask byte (0x04=GM, 0x01=AFK, 0x02=DND) from SMSG_MESSAGECHAT - Apply tagPrefix consistently across SAY/YELL/WHISPER/EMOTE/CHANNEL and the generic bracket-type fallback --- src/ui/game_screen.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cefd10d3..98c835cb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1198,6 +1198,12 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { tsPrefix = tsBuf; } + // Build chat tag prefix: , , from chatTag bitmask + std::string tagPrefix; + if (msg.chatTag & 0x04) tagPrefix = " "; + else if (msg.chatTag & 0x01) tagPrefix = " "; + else if (msg.chatTag & 0x02) tagPrefix = " "; + if (msg.type == game::ChatType::SYSTEM) { renderTextWithLinks(tsPrefix + processedMessage, color); } else if (msg.type == game::ChatType::TEXT_EMOTE) { @@ -1205,14 +1211,14 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } else if (!msg.senderName.empty()) { if (msg.type == game::ChatType::SAY || msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - std::string fullMsg = tsPrefix + msg.senderName + " says: " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " says: " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - std::string fullMsg = tsPrefix + msg.senderName + " yells: " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " yells: " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::WHISPER || msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - std::string fullMsg = tsPrefix + msg.senderName + " whispers: " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " whispers: " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::WHISPER_INFORM) { // Outgoing whisper — show "To Name: message" (WoW-style) @@ -1221,17 +1227,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::EMOTE || msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - std::string fullMsg = tsPrefix + msg.senderName + " " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { int chIdx = gameHandler.getChannelIndex(msg.channelName); std::string chDisplay = chIdx > 0 ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" : "[" + msg.channelName + "]"; - std::string fullMsg = tsPrefix + chDisplay + " [" + msg.senderName + "]: " + processedMessage; + std::string fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + msg.senderName + "]: " + processedMessage; renderTextWithLinks(fullMsg, color); } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": " + processedMessage; + std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + msg.senderName + ": " + processedMessage; renderTextWithLinks(fullMsg, color); } } else { From 942df21c664df3e84d40e4a981fa955c155ed7c6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 15:18:00 -0700 Subject: [PATCH 39/71] ui: resolve chat sender names at render time to fix [Say] prefix When SMSG_MESSAGECHAT arrives before the entity has spawned or its name is cached, senderName is empty and messages fell through to the generic '[Say] message' branch. Fix: - GameHandler::lookupName(guid): checks playerNameCache then entity manager (Unit subclass cast) at call time - Chat display: resolves senderName via lookupName() at render time so messages show "Name says: msg" even if the name was unavailable when the packet was first parsed --- include/game/game_handler.hpp | 16 ++++++++++++++++ src/ui/game_screen.cpp | 26 ++++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 4cc7f45c..b53a97bf 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -823,6 +823,22 @@ public: // Player GUID uint64_t getPlayerGuid() const { return playerGuid; } + + // Look up a display name for any guid: checks playerNameCache then entity manager. + // Returns empty string if unknown. Used by chat display to resolve names at render time. + const std::string& lookupName(uint64_t guid) const { + static const std::string kEmpty; + auto it = playerNameCache.find(guid); + if (it != playerNameCache.end()) return it->second; + auto entity = entityManager.getEntity(guid); + if (entity) { + if (auto* unit = dynamic_cast(entity.get())) { + if (!unit->getName().empty()) return unit->getName(); + } + } + return kEmpty; + } + uint8_t getPlayerClass() const { const Character* ch = getActiveCharacter(); return ch ? static_cast(ch->characterClass) : 0; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 98c835cb..1263e359 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1181,6 +1181,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (!shouldShowMessage(msg, activeChatTab_)) continue; std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); + // Resolve sender name at render time in case it wasn't available at parse time. + // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. + const std::string& resolvedSenderName = [&]() -> const std::string& { + if (!msg.senderName.empty()) return msg.senderName; + if (msg.senderGuid == 0) return msg.senderName; + const std::string& cached = gameHandler.lookupName(msg.senderGuid); + if (!cached.empty()) return cached; + return msg.senderName; + }(); + ImVec4 color = getChatTypeColor(msg.type); // Optional timestamp prefix @@ -1208,36 +1218,36 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { renderTextWithLinks(tsPrefix + processedMessage, color); } else if (msg.type == game::ChatType::TEXT_EMOTE) { renderTextWithLinks(tsPrefix + processedMessage, color); - } else if (!msg.senderName.empty()) { + } else if (!resolvedSenderName.empty()) { if (msg.type == game::ChatType::SAY || msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " says: " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " yells: " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::WHISPER || msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " whispers: " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::WHISPER_INFORM) { // Outgoing whisper — show "To Name: message" (WoW-style) - const std::string& target = !msg.receiverName.empty() ? msg.receiverName : msg.senderName; + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; std::string fullMsg = tsPrefix + "To " + target + ": " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::EMOTE || msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - std::string fullMsg = tsPrefix + tagPrefix + msg.senderName + " " + processedMessage; + std::string fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; renderTextWithLinks(fullMsg, color); } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { int chIdx = gameHandler.getChannelIndex(msg.channelName); std::string chDisplay = chIdx > 0 ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" : "[" + msg.channelName + "]"; - std::string fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + msg.senderName + "]: " + processedMessage; + std::string fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; renderTextWithLinks(fullMsg, color); } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + msg.senderName + ": " + processedMessage; + std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; renderTextWithLinks(fullMsg, color); } } else { From 1a370fef76b759c7b66c7b139a262fd674845f57 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 15:32:04 -0700 Subject: [PATCH 40/71] fix: chat prefix, hostile faction display, and game object looting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BG_SYSTEM_NEUTRAL/ALLIANCE/HORDE chat types (0x52-0x54) and reclassify them as SYSTEM in the parser — prevents bogus [Say] prefix on arena/BG system messages - Remove fallback [TypeName] bracket for sender-less SAY/YELL/WHISPER messages; only group-channel types (Party/Guild/Raid/BG) show brackets without a sender - Remove factionTemplate != 0 guard — units with FT=0 now get setHostile() like any other unit (defaulting to hostile from the map default), fixing NPCs that appeared friendly due to unset faction template - Enable CMSG_LOOT for WotLK type=3 (chest) game objects in addition to CMSG_GAMEOBJ_USE — fixes Milly's Harvest and other quest gather objects on AzerothCore WotLK servers --- include/game/world_packets.hpp | 6 +++++- src/game/game_handler.cpp | 33 +++++++++++++++++---------------- src/game/world_packets.cpp | 8 ++++++++ src/ui/game_screen.cpp | 24 ++++++++++++++++++++++-- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 870e00b7..771bbda8 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -615,7 +615,11 @@ enum class ChatType : uint8_t { MONSTER_WHISPER = 42, RAID_BOSS_WHISPER = 43, RAID_BOSS_EMOTE = 44, - MONSTER_PARTY = 50 + MONSTER_PARTY = 50, + // BG/Arena system messages (WoW 3.3.5a — no sender, treated as SYSTEM in display) + BG_SYSTEM_NEUTRAL = 82, + BG_SYSTEM_ALLIANCE = 83, + BG_SYSTEM_HORDE = 84 }; /** diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c1f65f18..8e5bc602 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7930,10 +7930,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(true); } } - // Determine hostility from faction template for online creatures - if (unit->getFactionTemplate() != 0) { - unit->setHostile(isHostileFaction(unit->getFactionTemplate())); - } + // Determine hostility from faction template for online creatures. + // Always call isHostileFaction — factionTemplate=0 defaults to hostile + // in the lookup rather than silently staying at the struct default (false). + unit->setHostile(isHostileFaction(unit->getFactionTemplate())); // Trigger creature spawn callback for units/players with displayId if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, @@ -14287,8 +14287,9 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { // animation/sound and expects the client to request the mail list. bool isMailbox = false; bool chestLike = false; - // Stock-like behavior: GO use opens GO loot context. Keep eager CMSG_LOOT only - // as Classic/Turtle fallback behavior. + // Chest-type game objects (type=3): on all expansions, also send CMSG_LOOT so + // the server opens the loot response. Other harvestable/interactive types rely + // on the server auto-sending SMSG_LOOT_RESPONSE after CMSG_GAMEOBJ_USE. bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle"); if (entity && entity->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(entity); @@ -14305,6 +14306,8 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { refreshMailList(); } else if (info && info->type == 3) { chestLike = true; + // Type-3 chests require CMSG_LOOT on all expansions (AzerothCore WotLK included) + shouldSendLoot = true; } else if (turtleMode) { // Turtle compatibility: keep eager loot open behavior. shouldSendLoot = true; @@ -14315,21 +14318,19 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); chestLike = (lower.find("chest") != std::string::npos); + if (chestLike) shouldSendLoot = true; } - // For WotLK chest-like gameobjects, report use but let server open loot. - if (!isMailbox && chestLike) { - if (isActiveExpansion("wotlk")) { - network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); - reportUse.writeUInt64(guid); - socket->send(reportUse); - } + // For WotLK chest-like gameobjects, also send CMSG_GAMEOBJ_REPORT_USE. + if (!isMailbox && chestLike && isActiveExpansion("wotlk")) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); } if (shouldSendLoot) { lootTarget(guid); } - // Retry use briefly to survive packet loss/order races. Keep loot retries only - // when we intentionally use eager loot-open mode. - const bool retryLoot = shouldSendLoot && (turtleMode || isActiveExpansion("classic")); + // Retry use briefly to survive packet loss/order races. + const bool retryLoot = shouldSendLoot; const bool retryUse = turtleMode || isActiveExpansion("classic"); if (retryUse || retryLoot) { pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot}); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index d47c568d..95b518f0 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1422,6 +1422,14 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { break; } + case ChatType::BG_SYSTEM_NEUTRAL: + case ChatType::BG_SYSTEM_ALLIANCE: + case ChatType::BG_SYSTEM_HORDE: + // BG/Arena system messages — no sender GUID or name field, just message. + // Reclassify as SYSTEM for consistent display. + data.type = ChatType::SYSTEM; + break; + default: // SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc. // All have receiverGuid (typically senderGuid repeated) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1263e359..2e11cfe9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1251,8 +1251,25 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { renderTextWithLinks(fullMsg, color); } } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; - renderTextWithLinks(fullMsg, color); + // No sender name. For group/channel types show a bracket prefix; + // for sender-specific types (SAY, YELL, WHISPER, etc.) just show the + // raw message — these are server-side announcements without a speaker. + bool isGroupType = + msg.type == game::ChatType::PARTY || + msg.type == game::ChatType::GUILD || + msg.type == game::ChatType::OFFICER || + msg.type == game::ChatType::RAID || + msg.type == game::ChatType::RAID_LEADER || + msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::BATTLEGROUND || + msg.type == game::ChatType::BATTLEGROUND_LEADER; + if (isGroupType) { + std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; + renderTextWithLinks(fullMsg, color); + } else { + // SAY, YELL, WHISPER, unknown BG_SYSTEM_* types, etc. — no prefix + renderTextWithLinks(tsPrefix + processedMessage, color); + } } } @@ -3421,6 +3438,9 @@ const char* GameScreen::getChatTypeName(game::ChatType type) const { case game::ChatType::ACHIEVEMENT: return "Achievement"; case game::ChatType::DND: return "DND"; case game::ChatType::AFK: return "AFK"; + case game::ChatType::BG_SYSTEM_NEUTRAL: + case game::ChatType::BG_SYSTEM_ALLIANCE: + case game::ChatType::BG_SYSTEM_HORDE: return "System"; default: return "Unknown"; } } From ec5e7c66c34449ca2bf66679678b2bd947b5929c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 15:45:35 -0700 Subject: [PATCH 41/71] fix: derive rest state from PLAYER_BYTES_2 and add action bar 2 settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XP bar rest state: - isResting_ now set from PLAYER_BYTES_2 byte 3 bit 0 (rest state flag) on both CREATE and VALUES update object handlers - playerRestedXp_ was missing from VALUES handler — now tracked there too - Eliminates dependency on SMSG_SET_REST_START (wrong in WotLK opcodes.json) Interface settings: - New "Interface" tab in Settings window - "Show Second Action Bar" toggle (default: on) - Horizontal/vertical position offset sliders for bar 2 - Settings persisted to/from save file --- include/ui/game_screen.hpp | 3 +++ src/game/game_handler.cpp | 10 +++++++ src/ui/game_screen.cpp | 54 +++++++++++++++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index d2b303c8..8c57b1c1 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -112,6 +112,9 @@ private: bool pendingSeparateBags = true; bool pendingAutoLoot = false; bool pendingUseOriginalSoundtrack = true; + bool pendingShowActionBar2 = true; // Show second action bar above main bar + float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position + float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x bool pendingNormalMapping = true; // on by default diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8e5bc602..dbdf296c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8086,6 +8086,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte & 0x01) != 0; } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce @@ -8354,6 +8357,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { bool slotsChanged = false; const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); @@ -8368,6 +8372,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerNextLevelXp_ = val; LOG_DEBUG("Next level XP updated: ", val); } + else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { + playerRestedXp_ = val; + } else if (key == ufPlayerLevel) { serverPlayerLevel_ = val; LOG_DEBUG("Level updated: ", val); @@ -8390,6 +8397,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte & 0x01) != 0; } else if (key == ufPlayerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2e11cfe9..f55e293f 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4137,13 +4137,14 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { }; // Bar 2 (slots 12-23) — only show if at least one slot is populated - { + if (pendingShowActionBar2) { bool bar2HasContent = false; for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } - float bar2Y = barY - barH - 2.0f; - ImGui::SetNextWindowPos(ImVec2(barX, bar2Y), ImGuiCond_Always); + float bar2X = barX + pendingActionBar2OffsetX; + float bar2Y = barY - barH - 2.0f + pendingActionBar2OffsetY; + ImGui::SetNextWindowPos(ImVec2(bar2X, bar2Y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); @@ -7995,6 +7996,44 @@ void GameScreen::renderSettingsWindow() { ImGui::EndTabItem(); } + // ============================================================ + // INTERFACE TAB + // ============================================================ + if (ImGui::BeginTabItem("Interface")) { + ImGui::Spacing(); + ImGui::BeginChild("InterfaceSettings", ImVec2(0, 360), true); + + ImGui::SeparatorText("Action Bars"); + ImGui::Spacing(); + + if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Shift+1 through Shift+=)"); + + if (pendingShowActionBar2) { + ImGui::Spacing(); + ImGui::TextUnformatted("Second Bar Position Offset"); + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Horizontal##bar2x", &pendingActionBar2OffsetX, -600.0f, 600.0f, "%.0f px")) { + saveSettings(); + } + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical##bar2y", &pendingActionBar2OffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + if (ImGui::Button("Reset Position##bar2")) { + pendingActionBar2OffsetX = 0.0f; + pendingActionBar2OffsetY = 0.0f; + saveSettings(); + } + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + // ============================================================ // AUDIO TAB // ============================================================ @@ -9054,6 +9093,9 @@ void GameScreen::saveSettings() { out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n"; out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 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"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -9143,6 +9185,12 @@ void GameScreen::loadSettings() { } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); + } else if (key == "show_action_bar2") { + pendingShowActionBar2 = (std::stoi(val) != 0); + } else if (key == "action_bar2_offset_x") { + pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); + } else if (key == "action_bar2_offset_y") { + pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } // Audio else if (key == "sound_muted") { From bee4dde9b7a8dbda53b33a3d5568ee945460512c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 15:56:41 -0700 Subject: [PATCH 42/71] ui: add side action bars, fix resize positioning, and fix player nameplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action bars: - Expand from 2 bars (24 slots) to 4 bars (48 slots) - Bar 2: right-edge vertical bar (slots 24-35), off by default - Bar 3: left-edge vertical bar (slots 36-47), off by default - New "Interface" settings tab with toggles and offset sliders for all bars - XP bar Y position now tracks bar 2 visibility and vertical offset HUD resize fix: - All HUD elements (action bars, bag bar, XP bar, cast bar, mirror timers) now use ImGui::GetIO().DisplaySize instead of window->getWidth/Height() - DisplaySize is always in sync with the current frame — eliminates the one-frame lag that caused bars to misalign after window resize Player nameplates: - Show player name only on nameplate (no level number clutter) - Fall back to "Player (level)" while name query is pending - NPC nameplates unchanged (still show "level Name") --- include/game/game_handler.hpp | 10 ++- include/ui/game_screen.hpp | 4 + src/ui/game_screen.cpp | 159 +++++++++++++++++++++++++++++----- 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b53a97bf..f35304c6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -588,10 +588,14 @@ public: const std::unordered_map& getAllTalentTabs() const { return talentTabCache_; } void loadTalentDbc(); - // Action bar — 2 bars × 12 slots = 24 total + // Action bar — 4 bars × 12 slots = 48 total + // Bar 0 (slots 0-11): main bottom bar (1-0, -, =) + // Bar 1 (slots 12-23): second bar above main (Shift+1 ... Shift+=) + // Bar 2 (slots 24-35): right side vertical bar + // Bar 3 (slots 36-47): left side vertical bar static constexpr int SLOTS_PER_BAR = 12; - static constexpr int ACTION_BARS = 2; - static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24 + static constexpr int ACTION_BARS = 4; + static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 48 std::array& getActionBar() { return actionBar; } const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 8c57b1c1..5dc0aa6d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -115,6 +115,10 @@ private: bool pendingShowActionBar2 = true; // Show second action bar above main bar float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) + bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35) + bool pendingShowLeftBar = false; // Left-edge vertical action bar (bar 4, slots 36-47) + float pendingRightBarOffsetY = 0.0f; // Vertical offset from screen center + float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x bool pendingNormalMapping = true; // on by default diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f55e293f..1db98170 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3897,9 +3897,11 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage } void GameScreen::renderActionBar(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + // Use ImGui's display size — always in sync with the current swap-chain/frame, + // whereas window->getWidth/Height() can lag by one frame on resize events. + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* assetMgr = core::Application::getInstance().getAssetManager(); float slotSize = 48.0f; @@ -4175,6 +4177,64 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); ImGui::PopStyleVar(4); + // Right side vertical bar (bar 3, slots 24-35) + if (pendingShowRightBar) { + bool bar3HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 2 + i].isEmpty()) { bar3HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = screenW - sideBarW - 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + pendingRightBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar3HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarRight", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 2 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Left side vertical bar (bar 4, slots 36-47) + if (pendingShowLeftBar) { + bool bar4HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 3 + i].isEmpty()) { bar4HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + pendingLeftBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar4HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarLeft", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 3 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + // Handle action bar drag: render icon at cursor and detect drop outside if (actionBarDragSlot_ >= 0) { ImVec2 mousePos = ImGui::GetMousePos(); @@ -4211,9 +4271,9 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* assetMgr = core::Application::getInstance().getAssetManager(); float slotSize = 42.0f; @@ -4458,9 +4518,11 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { uint32_t currentXp = gameHandler.getPlayerXp(); uint32_t restedXp = gameHandler.getPlayerRestedXp(); bool isResting = gameHandler.isPlayerResting(); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + (void)window; // Not used for positioning; kept for AssetManager if needed // Position just above both action bars (bar1 at screenH-barH, bar2 above that) float slotSize = 48.0f; @@ -4472,8 +4534,17 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { float xpBarH = 20.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; - // bar1 is at screenH-barH, bar2 is at screenH-2*barH-2; XP bar sits above bar2 - float xpBarY = screenH - 2.0f * barH - 2.0f - xpBarH - 2.0f; + // XP bar sits just above whichever bar is topmost. + // bar1 top edge: screenH - barH + // bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset + float bar1TopY = screenH - barH; + float xpBarY; + if (pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH - 2.0f + pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); @@ -4564,9 +4635,9 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { void GameScreen::renderCastBar(game::GameHandler& gameHandler) { if (!gameHandler.isCasting()) return; - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; float barW = 300.0f; float barX = (screenW - barW) / 2.0f; @@ -4611,9 +4682,9 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; static const struct { const char* label; ImVec4 color; } kTimerInfo[3] = { { "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) }, @@ -4992,16 +5063,26 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Name + level label above health bar uint32_t level = unit->getLevel(); + const std::string& unitName = unit->getName(); char labelBuf[96]; - if (level > 0) { + if (isPlayer) { + // Player nameplates: show name only (no level clutter). + // Fall back to level as placeholder while the name query is pending. + if (!unitName.empty()) + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); + else if (level > 0) + snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level); + else + snprintf(labelBuf, sizeof(labelBuf), "Player"); + } else if (level > 0) { uint32_t playerLevel = gameHandler.getPlayerLevel(); // Show skull for units more than 10 levels above the player if (playerLevel > 0 && level > playerLevel + 10) - snprintf(labelBuf, sizeof(labelBuf), "?? %s", unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str()); else - snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str()); } else { - snprintf(labelBuf, sizeof(labelBuf), "%s", unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); } ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; @@ -8030,6 +8111,32 @@ void GameScreen::renderSettingsWindow() { } } + ImGui::Spacing(); + if (ImGui::Checkbox("Show Right Side Bar", &pendingShowRightBar)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Slots 25-36)"); + if (pendingShowRightBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##rbar", &pendingRightBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + } + + ImGui::Spacing(); + if (ImGui::Checkbox("Show Left Side Bar", &pendingShowLeftBar)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Slots 37-48)"); + if (pendingShowLeftBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##lbar", &pendingLeftBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + } + ImGui::EndChild(); ImGui::EndTabItem(); } @@ -9096,6 +9203,10 @@ void GameScreen::saveSettings() { out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; + out << "show_right_bar=" << (pendingShowRightBar ? 1 : 0) << "\n"; + out << "show_left_bar=" << (pendingShowLeftBar ? 1 : 0) << "\n"; + out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; + out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -9191,6 +9302,14 @@ void GameScreen::loadSettings() { pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); } else if (key == "action_bar2_offset_y") { pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "show_right_bar") { + pendingShowRightBar = (std::stoi(val) != 0); + } else if (key == "show_left_bar") { + pendingShowLeftBar = (std::stoi(val) != 0); + } else if (key == "right_bar_offset_y") { + pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "left_bar_offset_y") { + pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); } // Audio else if (key == "sound_muted") { From 094ef88e57b60765e9d8d8c3733ce7d4c254067e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:08:24 -0700 Subject: [PATCH 43/71] fix: NPC animation/position sync for distant creatures and restore WoW ding overlay - application.cpp creature sync loop: use entity->isEntityMoving() alongside planarDist to detect movement; entities > 150u have stale getX/Y/Z (distance culled in GameHandler::update) but isEntityMoving() correctly reflects active startMoveTo paths from SMSG_MONSTER_MOVE. Fixes distant NPCs playing Stand while creatureMoveCallback drives their renderer to Run. - Switch sync loop to getLatestX/Y/Z (server-authoritative destination) for both the distance check and renderPos so creature positions are never stale from cull lag, and don't call moveInstanceTo when only entityIsMoving (no planarDist): the renderer's spline-driven move from creatureMoveCallback is already correct and shouldn't be cancelled by the per-frame sync. - game_screen.cpp: replace scratch-built ring-burst level-up overlay with a simple "You have reached level X!" centered text (WoW style). The actual 3D visual is already handled by Renderer::triggerLevelUpEffect (LevelUp.m2). --- src/core/application.cpp | 18 +++++++-- src/ui/game_screen.cpp | 83 ++++++++++------------------------------ 2 files changed, 35 insertions(+), 66 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 64d98566..320a79c8 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1426,7 +1426,10 @@ void Application::update(float deltaTime) { } } - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Use getLatestX/Y/Z (server-authoritative destination) for position sync + // rather than getX/Y/Z (interpolated), which may be stale for entities + // outside the 150-unit updateMovement() culling radius in GameHandler. + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); float canonDistSq = 0.0f; if (havePlayerPos) { glm::vec3 d = canonical - playerPos; @@ -1514,13 +1517,22 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); - const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f); + // isEntityMoving() reflects server-authoritative move state set by + // 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(); + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); - } else if (isMovingNow) { + } else if (planarDist > 0.03f || dz > 0.08f) { + // Position changed in entity coords → drive renderer toward it. float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } + // When entity is moving but getX/Y/Z is stale (distance-culled), + // don't call moveInstanceTo — creatureMoveCallback_ already drove + // the renderer to the correct destination via the spline packet. posIt->second = renderPos; // Drive movement animation: Walk/Run/Swim (4/5/42) when moving, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1db98170..41ea69a3 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10569,77 +10569,34 @@ void GameScreen::renderDingEffect() { dingTimer_ -= dt; if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; - float alpha = dingTimer_ < 0.8f ? (dingTimer_ / 0.8f) : 1.0f; // fade out last 0.8s - float elapsed = DING_DURATION - dingTimer_; // 0 → DING_DURATION + // Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s. + // The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2). + constexpr float kFadeTime = 0.5f; + float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f; + if (alpha <= 0.0f) return; ImGuiIO& io = ImGui::GetIO(); float cx = io.DisplaySize.x * 0.5f; - float cy = io.DisplaySize.y * 0.5f; + float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float baseSize = ImGui::GetFontSize(); + float fontSize = baseSize * 1.8f; - // ---- Golden radial ring burst (3 waves staggered by 0.45s) ---- - { - constexpr float kMaxRadius = 420.0f; - constexpr float kRingWidth = 18.0f; - constexpr float kWaveLen = 1.4f; // each wave lasts 1.4s - constexpr int kNumWaves = 3; - constexpr float kStagger = 0.45f; // seconds between waves + char buf[64]; + snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_); - for (int w = 0; w < kNumWaves; ++w) { - float waveElapsed = elapsed - w * kStagger; - if (waveElapsed <= 0.0f || waveElapsed >= kWaveLen) continue; + ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); + float tx = cx - sz.x * 0.5f; + float ty = cy - sz.y * 0.5f; - float t = waveElapsed / kWaveLen; // 0 → 1 - float radius = t * kMaxRadius; - float ringAlpha = (1.0f - t) * alpha; // fades as it expands - - ImU32 outerCol = IM_COL32(255, 215, 60, (int)(ringAlpha * 200)); - ImU32 innerCol = IM_COL32(255, 255, 150, (int)(ringAlpha * 120)); - - draw->AddCircle(ImVec2(cx, cy), radius, outerCol, 64, kRingWidth); - draw->AddCircle(ImVec2(cx, cy), radius * 0.92f, innerCol, 64, kRingWidth * 0.5f); - } - } - - // ---- Full-screen golden flash on first frame ---- - if (elapsed < 0.15f) { - float flashA = (1.0f - elapsed / 0.15f) * 0.45f; - draw->AddRectFilled(ImVec2(0, 0), io.DisplaySize, - IM_COL32(255, 200, 50, (int)(flashA * 255))); - } - - // "LEVEL X!" text — visible for first 2.2s - if (dingTimer_ > 0.8f) { - ImFont* font = ImGui::GetFont(); - float baseSize = ImGui::GetFontSize(); - float fontSize = baseSize * 2.8f; - - char buf[32]; - snprintf(buf, sizeof(buf), "LEVEL %u!", dingLevel_); - - ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); - float tx = cx - sz.x * 0.5f; - float ty = cy - sz.y * 0.5f - 20.0f; - - // Drop shadow - draw->AddText(font, fontSize, ImVec2(tx + 3, ty + 3), - IM_COL32(0, 0, 0, (int)(alpha * 200)), buf); - // Gold text - draw->AddText(font, fontSize, ImVec2(tx, ty), - IM_COL32(255, 215, 0, (int)(alpha * 255)), buf); - - // "DING!" subtitle - const char* ding = "DING!"; - float dingSize = baseSize * 1.8f; - ImVec2 dingSz = font->CalcTextSizeA(dingSize, FLT_MAX, 0.0f, ding); - float dx = cx - dingSz.x * 0.5f; - float dy = ty + sz.y + 6.0f; - draw->AddText(font, dingSize, ImVec2(dx + 2, dy + 2), - IM_COL32(0, 0, 0, (int)(alpha * 180)), ding); - draw->AddText(font, dingSize, ImVec2(dx, dy), - IM_COL32(255, 255, 150, (int)(alpha * 255)), ding); - } + // Slight black outline for readability + draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2), + IM_COL32(0, 0, 0, (int)(alpha * 180)), buf); + // Gold text + draw->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); } void GameScreen::triggerAchievementToast(uint32_t achievementId) { From 0afa41e90880bfc69c5908ddf9e546b48cf46a04 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:21:09 -0700 Subject: [PATCH 44/71] feat: implement item durability tracking and vendor repair - Add ITEM_FIELD_DURABILITY (60) and ITEM_FIELD_MAXDURABILITY (61) to update_field_table.hpp enum and wotlk/update_fields.json - Add curDurability/maxDurability to OnlineItemInfo and ItemDef structs - Parse durability fields in OBJECT_CREATE and OBJECT_VALUES handlers; preserve existing values on partial updates (fixes stale durability being reset to 0 on stack-count-only updates) - Propagate durability to ItemDef in all 5 rebuildOnlineInventory() paths - Implement GameHandler::repairItem() and repairAll() via CMSG_REPAIR_ITEM (itemGuid=0 repairs all equipped items per WotLK protocol) - Add canRepair flag to ListInventoryData; set it when player selects GOSSIP_OPTION_ARMORER in gossip window - Show "Repair All" button in vendor window header when canRepair=true - Display color-coded durability in item tooltip (green >50%, yellow >25%, red <=25%) --- Data/expansions/wotlk/update_fields.json | 2 + include/game/game_handler.hpp | 5 ++ include/game/inventory.hpp | 2 + include/game/update_field_table.hpp | 2 + include/game/world_packets.hpp | 1 + src/core/application.cpp | 13 ++++-- src/game/game_handler.cpp | 58 ++++++++++++++++++++++-- src/ui/game_screen.cpp | 14 ++++++ src/ui/inventory_screen.cpp | 9 ++++ 9 files changed, 96 insertions(+), 10 deletions(-) diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index fa4b9ada..67019c80 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -33,6 +33,8 @@ "PLAYER_EXPLORED_ZONES_START": 1041, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 60, + "ITEM_FIELD_MAXDURABILITY": 61, "CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_SLOT_1": 66 } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f35304c6..2307f849 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1258,6 +1258,8 @@ public: uint32_t count = 1; }; void buyBackItem(uint32_t buybackSlot); + void repairItem(uint64_t vendorGuid, uint64_t itemGuid); + void repairAll(uint64_t vendorGuid, bool useGuildBank = false); const std::deque& getBuybackItems() const { return buybackItems_; } void autoEquipItemBySlot(int backpackIndex); void autoEquipItemInBag(int bagIndex, int slotIndex); @@ -1269,6 +1271,7 @@ public: void useItemById(uint32_t itemId); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } + void setVendorCanRepair(bool v) { currentVendorItems.canRepair = v; } // Mail bool isMailboxOpen() const { return mailboxOpen_; } @@ -1831,6 +1834,8 @@ private: struct OnlineItemInfo { uint32_t entry = 0; uint32_t stackCount = 1; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index b25d5234..8dcd4ce2 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -46,6 +46,8 @@ struct ItemDef { int32_t spirit = 0; uint32_t displayInfoId = 0; uint32_t sellPrice = 0; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; }; struct ItemSlot { diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index fd208554..67651b00 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -56,6 +56,8 @@ enum class UF : uint16_t { // Item fields ITEM_FIELD_STACK_COUNT, + ITEM_FIELD_DURABILITY, + ITEM_FIELD_MAXDURABILITY, // Container fields CONTAINER_FIELD_NUM_SLOTS, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 771bbda8..eaf1fd2f 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2179,6 +2179,7 @@ struct VendorItem { struct ListInventoryData { uint64_t vendorGuid = 0; std::vector items; + bool canRepair = false; // Set when vendor was opened via GOSSIP_OPTION_ARMORER bool isValid() const { return true; } }; diff --git a/src/core/application.cpp b/src/core/application.cpp index 320a79c8..35ebca5f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1426,17 +1426,20 @@ void Application::update(float deltaTime) { } } - // Use getLatestX/Y/Z (server-authoritative destination) for position sync - // rather than getX/Y/Z (interpolated), which may be stale for entities - // outside the 150-unit updateMovement() culling radius in GameHandler. - glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + // Distance check uses getLatestX/Y/Z (server-authoritative destination) to + // avoid false-culling entities that moved while getX/Y/Z was stale. + // Position sync still uses getX/Y/Z to preserve smooth interpolation for + // nearby entities; distant entities (> 150u) have planarDist≈0 anyway + // so the renderer remains driven correctly by creatureMoveCallback_. + glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); float canonDistSq = 0.0f; if (havePlayerPos) { - glm::vec3 d = canonical - playerPos; + glm::vec3 d = latestCanonical - playerPos; canonDistSq = glm::dot(d, d); if (canonDistSq > syncRadiusSq) continue; } + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // Visual collision guard: keep hostile melee units from rendering inside the diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index dbdf296c..a79ab584 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8023,10 +8023,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); + auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); + auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); if (entryIt != block.fields.end() && entryIt->second != 0) { - OnlineItemInfo info; + // Preserve existing info when doing partial updates + OnlineItemInfo info = onlineItems_.count(block.guid) + ? onlineItems_[block.guid] : OnlineItemInfo{}; info.entry = entryIt->second; - info.stackCount = (stackIt != block.fields.end()) ? stackIt->second : 1; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt!= block.fields.end()) info.maxDurability = maxDurIt->second; bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); onlineItems_[block.guid] = info; if (isNew) newItemCreated = true; @@ -8427,19 +8433,31 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { extractExploredZoneFields(lastPlayerFields_); } - // Update item stack count for online items + // Update item stack count / durability for online items if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { bool inventoryChanged = false; - const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); + const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); for (const auto& [key, val] : block.fields) { + auto it = onlineItems_.find(block.guid); if (key == itemStackField) { - auto it = onlineItems_.find(block.guid); if (it != onlineItems_.end() && it->second.stackCount != val) { it->second.stackCount = val; inventoryChanged = true; } + } else if (key == itemDurField) { + if (it != onlineItems_.end() && it->second.curDurability != val) { + it->second.curDurability = val; + inventoryChanged = true; + } + } else if (key == itemMaxDurField) { + if (it != onlineItems_.end() && it->second.maxDurability != val) { + it->second.maxDurability = val; + inventoryChanged = true; + } } } // Update container slot GUIDs on bag content changes @@ -10719,6 +10737,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10757,6 +10777,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10830,6 +10852,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10870,6 +10894,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10951,6 +10977,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -14866,6 +14894,26 @@ void GameHandler::buyBackItem(uint32_t buybackSlot) { socket->send(packet); } +void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_REPAIR_ITEM: npcGuid(8) + itemGuid(8) + useGuildBank(uint8) + network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(itemGuid); + packet.writeUInt8(0); // do not use guild bank + socket->send(packet); +} + +void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { + if (state != WorldState::IN_WORLD || !socket) return; + // itemGuid = 0 signals "repair all equipped" to the server + network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(0); + packet.writeUInt8(useGuildBank ? 1 : 0); + socket->send(packet); +} + void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { if (state != WorldState::IN_WORLD || !socket) return; LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid, diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 41ea69a3..cf924c08 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6538,6 +6538,9 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { std::string processedText = replaceGenderPlaceholders(displayText, gameHandler); std::string label = std::string(icon) + " " + processedText; if (ImGui::Selectable(label.c_str())) { + if (opt.text == "GOSSIP_OPTION_ARMORER") { + gameHandler.setVendorCanRepair(true); + } gameHandler.selectGossipOption(opt.id); } ImGui::PopID(); @@ -6936,6 +6939,17 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + + if (vendor.canRepair) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.0f); + if (ImGui::SmallButton("Repair All")) { + gameHandler.repairAll(vendor.vendorGuid, false); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Repair all equipped items"); + } + } ImGui::Separator(); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell"); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 320fc316..edf18525 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1805,6 +1805,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (!bonusLine.empty()) { ImGui::TextColored(green, "%s", bonusLine.c_str()); } + if (item.maxDurability > 0) { + float durPct = static_cast(item.curDurability) / static_cast(item.maxDurability); + ImVec4 durColor; + if (durPct > 0.5f) durColor = ImVec4(0.1f, 1.0f, 0.1f, 1.0f); // green + else if (durPct > 0.25f) durColor = ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // yellow + else durColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); // red + ImGui::TextColored(durColor, "Durability %u / %u", + item.curDurability, item.maxDurability); + } if (item.sellPrice > 0) { uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; From 67db7383ad4169622636cece25d73e3be1ddd503 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:23:12 -0700 Subject: [PATCH 45/71] feat: add durability bar overlay on equipment slots in character panel Draw a 3px color-coded strip at the bottom of each equipment slot icon (green >50%, yellow >25%, red <=25%) so broken or near-broken gear is immediately visible at a glance without opening the tooltip. --- src/ui/inventory_screen.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index edf18525..49ff82ba 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1594,6 +1594,20 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite IM_COL32(255, 255, 255, 220), countStr); } + // Durability bar on equipment slots (3px strip at bottom of slot icon) + if (kind == SlotKind::EQUIPMENT && item.maxDurability > 0) { + float durPct = static_cast(item.curDurability) / + static_cast(item.maxDurability); + ImU32 durCol; + if (durPct > 0.5f) durCol = IM_COL32(0, 200, 0, 220); + else if (durPct > 0.25f) durCol = IM_COL32(220, 220, 0, 220); + else durCol = IM_COL32(220, 40, 40, 220); + float barW = size * durPct; + drawList->AddRectFilled(ImVec2(pos.x, pos.y + size - 3.0f), + ImVec2(pos.x + barW, pos.y + size), + durCol); + } + ImGui::InvisibleButton("slot", ImVec2(size, size)); // Left mouse: hold to pick up, release to drop/swap From 179354955031f46d55ab0b525926d578f1bbe11f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:26:20 -0700 Subject: [PATCH 46/71] feat: parse and display item level and required level in tooltips - Add itemLevel/requiredLevel fields to ItemQueryResponseData (parsed from SMSG_ITEM_QUERY_SINGLE_RESPONSE) and ItemDef - Propagate through all 5 rebuildOnlineInventory() paths - Show "Item Level N" and "Requires Level N" in item tooltip in standard WoW order (below item name, above required level/stats) --- include/game/inventory.hpp | 2 ++ include/game/world_packets.hpp | 2 ++ src/game/game_handler.cpp | 10 ++++++++++ src/game/world_packets.cpp | 4 ++-- src/ui/inventory_screen.cpp | 6 ++++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index 8dcd4ce2..88b7db38 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -48,6 +48,8 @@ struct ItemDef { uint32_t sellPrice = 0; uint32_t curDurability = 0; uint32_t maxDurability = 0; + uint32_t itemLevel = 0; + uint32_t requiredLevel = 0; }; struct ItemSlot { diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index eaf1fd2f..03fdef5c 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1552,6 +1552,8 @@ struct ItemQueryResponseData { int32_t intellect = 0; int32_t spirit = 0; uint32_t sellPrice = 0; + uint32_t itemLevel = 0; + uint32_t requiredLevel = 0; std::string subclassName; // Item spells (up to 5) struct ItemSpell { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a79ab584..85fc56b5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10758,6 +10758,8 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10798,6 +10800,8 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10873,6 +10877,8 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); @@ -10915,6 +10921,8 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; def.sellPrice = infoIt->second.sellPrice; def.bagSlots = infoIt->second.containerSlots; } else { @@ -10998,6 +11006,8 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; def.sellPrice = infoIt->second.sellPrice; def.bagSlots = infoIt->second.containerSlots; } else { diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 95b518f0..beb98e6c 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2449,8 +2449,8 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 49ff82ba..54d8abab 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1714,6 +1714,9 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImVec4 qColor = getQualityColor(item.quality); ImGui::TextColored(qColor, "%s", item.name.c_str()); + if (item.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel); + } if (item.itemId == 6948 && gameHandler_) { uint32_t mapId = 0; @@ -1819,6 +1822,9 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (!bonusLine.empty()) { ImGui::TextColored(green, "%s", bonusLine.c_str()); } + if (item.requiredLevel > 1) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel); + } if (item.maxDurability > 0) { float durPct = static_cast(item.curDurability) / static_cast(item.maxDurability); ImVec4 durColor; From 30058a8df56ee902ad2c33919cb227a125ae0917 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:28:59 -0700 Subject: [PATCH 47/71] fix: preserve vendor canRepair flag when SMSG_LIST_INVENTORY arrives ListInventoryParser::parse() overwrites currentVendorItems entirely, resetting canRepair=false. Save the flag before parsing and restore it after so the "Repair All" button remains visible when an armorer vendor also sells items. --- src/game/game_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 85fc56b5..a73da37f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15487,7 +15487,9 @@ void GameHandler::handleGossipComplete(network::Packet& packet) { } void GameHandler::handleListInventory(network::Packet& packet) { + bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set before openVendor() if (!ListInventoryParser::parse(packet, currentVendorItems)) return; + currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens From dce8a4e442a14e58bdecf3842705dadb5e5acfd5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:30:01 -0700 Subject: [PATCH 48/71] fix: propagate sellPrice to all rebuildOnlineInventory() inventory paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equipment, backpack, and bag-content paths were missing def.sellPrice assignment — only bank/bank-bag paths had it. This caused the "Sell" price in item tooltips to show 0g 0s 0c for equipped and backpack items. --- src/game/game_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a73da37f..ab490ea7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10758,6 +10758,7 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; } else { @@ -10800,6 +10801,7 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; } else { @@ -10877,6 +10879,7 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.bagSlots = infoIt->second.containerSlots; From f53f16a59b11c11cfa5ebb7b8f219f74ec5a3e69 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:31:18 -0700 Subject: [PATCH 49/71] feat: add ITEM_FIELD_DURABILITY/MAXDURABILITY to all expansion update_fields - Classic/Turtle: indices 48/49 (no spell-charge fields between stack count and durability in 1.12) - TBC: indices 60/61 (same layout as WotLK, matches TBC 2.4.3 item fields) - WotLK: already added in previous commit Enables durability tracking across all supported expansion profiles. --- Data/expansions/classic/update_fields.json | 2 ++ Data/expansions/tbc/update_fields.json | 2 ++ Data/expansions/turtle/update_fields.json | 2 ++ 3 files changed, 6 insertions(+) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 4549a48c..0d61eacc 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -34,6 +34,8 @@ "PLAYER_END": 1282, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 48, + "ITEM_FIELD_MAXDURABILITY": 49, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 } diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index bee972ca..c6d77c76 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -33,6 +33,8 @@ "PLAYER_EXPLORED_ZONES_START": 1312, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 60, + "ITEM_FIELD_MAXDURABILITY": 61, "CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_SLOT_1": 66 } diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 393694a0..a91a314b 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -34,6 +34,8 @@ "PLAYER_END": 1282, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 48, + "ITEM_FIELD_MAXDURABILITY": 49, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 } \ No newline at end of file From 76bd6b409eee964d23470b9204d1465595cd6bd1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:47:55 -0700 Subject: [PATCH 50/71] feat: enhance item tooltips with binding, description, speed, and spell effects - Parse Bonding and Description fields from SMSG_ITEM_QUERY_SINGLE_RESPONSE (read after the 5 spell slots: bindType uint32, then description cstring) - Add bindType and description to ItemQueryResponseData and ItemDef - Propagate bindType and description through all 5 rebuildOnlineInventory paths - Tooltip now shows: "Binds when picked up/equipped/used/quest item" - Tooltip now shows weapon damage range ("X - Y Damage") and speed ("Speed 2.60") on same line, plus DPS in parentheses below - Tooltip now shows spell effects ("Use: ", "Equip: ", "Chance on Hit: ...") using existing getSpellName() lookup - Tooltip now shows item flavor/lore description in italic-style yellow text --- include/game/inventory.hpp | 2 ++ include/game/world_packets.hpp | 2 ++ src/game/game_handler.cpp | 6 +++++ src/game/world_packets.cpp | 8 ++++++ src/ui/inventory_screen.cpp | 49 +++++++++++++++++++++++++++++++--- 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index 88b7db38..5721155a 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -50,6 +50,8 @@ struct ItemDef { uint32_t maxDurability = 0; uint32_t itemLevel = 0; uint32_t requiredLevel = 0; + uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ + std::string description; // Flavor/lore text shown in tooltip (italic yellow) }; struct ItemSlot { diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 03fdef5c..65a49430 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1561,6 +1561,8 @@ struct ItemQueryResponseData { uint32_t spellTrigger = 0; // 0=Use, 1=Equip, 2=ChanceOnHit, 5=Learn }; std::array spells{}; + uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ + std::string description; // Flavor/lore text bool valid = false; }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ab490ea7..040eeb48 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10761,6 +10761,8 @@ void GameHandler::rebuildOnlineInventory() { def.sellPrice = infoIt->second.sellPrice; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10804,6 +10806,8 @@ void GameHandler::rebuildOnlineInventory() { def.sellPrice = infoIt->second.sellPrice; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10926,6 +10930,8 @@ void GameHandler::rebuildOnlineInventory() { def.spirit = infoIt->second.spirit; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; def.sellPrice = infoIt->second.sellPrice; def.bagSlots = infoIt->second.containerSlots; } else { diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index beb98e6c..2f31b774 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2518,6 +2518,14 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // SpellCategoryCooldown } + // Bonding type (0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ) + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Flavor/lore text (Description cstring) + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + data.valid = !data.name.empty(); return true; } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 54d8abab..705acf52 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1718,6 +1718,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel); } + // Binding type + switch (item.bindType) { + case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; + case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break; + case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break; + case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break; + default: break; + } + if (item.itemId == 6948 && gameHandler_) { uint32_t mapId = 0; glm::vec3 pos; @@ -1793,13 +1802,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I }; const bool isWeapon = isWeaponInventoryType(item.inventoryType); - // Compact stats view for weapons: DPS + condensed stat bonuses. - // Non-weapons keep armor/sell info visible. + // Compact stats view for weapons: damage range + speed + DPS ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) { float speed = static_cast(item.delayMs) / 1000.0f; float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + ImGui::Text("%.0f - %.0f Damage", item.damageMin, item.damageMax); + ImGui::SameLine(160.0f); + ImGui::TextDisabled("Speed %.2f", speed); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps); } // Armor appears before stat bonuses — matches WoW tooltip order @@ -1834,6 +1845,38 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::TextColored(durColor, "Durability %u / %u", item.curDurability, item.maxDurability); } + // Item spell effects (Use/Equip/Chance on Hit) + if (gameHandler_) { + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info) { + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* trigger = nullptr; + switch (sp.spellTrigger) { + case 0: trigger = "Use"; break; + case 1: trigger = "Equip"; break; + case 2: trigger = "Chance on Hit"; break; + case 6: trigger = "Soulstone"; break; + default: break; + } + if (!trigger) continue; + const std::string& spName = gameHandler_->getSpellName(sp.spellId); + if (!spName.empty()) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: %s", trigger, spName.c_str()); + } else { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: Spell #%u", trigger, sp.spellId); + } + } + } + } + + // Flavor / lore text (italic yellow in WoW, just yellow here) + if (!item.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", item.description.c_str()); + } + if (item.sellPrice > 0) { uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; From 5004929f0792578d01fb899638e066b5e87b7952 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:50:14 -0700 Subject: [PATCH 51/71] fix: complete classic/vanilla item query parsing for itemLevel, spells, binding, description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic packet parser was stopping after armor/resistances/delay without reading the remaining tail fields present in vanilla 1.12.1 item packets: - Store itemLevel and requiredLevel (were read but discarded) - Read AmmoType and RangedModRange after delay - Read 5 spell slots (SpellId, SpellTrigger, Charges, Cooldown, Category, CatCooldown) - Read Bonding type (bindType) after spells - Read Description (flavor/lore text) cstring after bonding All new fields now flow into ItemDef via rebuildOnlineInventory and display in the item tooltip (same as WotLK/TBC — binding text, spell effects, description). --- src/game/packet_parsers_classic.cpp | 30 +++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 33d39b77..5863c377 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1242,8 +1242,8 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell @@ -1302,6 +1302,32 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ data.delayMs = packet.readUInt32(); } + // AmmoType + RangedModRange (2 fields, 8 bytes) + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + } + + // 2 item spells in Vanilla (3 fields each: SpellId, Trigger, Charges) + // Actually vanilla has 5 spells: SpellId, Trigger, Charges, Cooldown, Category, CatCooldown = 24 bytes each + for (int i = 0; i < 5; i++) { + if (packet.getReadPos() + 24 > packet.getSize()) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Description (flavor/lore text) + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + data.valid = !data.name.empty(); LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality, " invType=", data.inventoryType, " stack=", data.maxStack, ")"); From 6075207d94ffc2268171e9000ec7e171b4694adb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 16:53:00 -0700 Subject: [PATCH 52/71] fix: complete TBC item query parsing for itemLevel, spells, binding, description TBC parser was truncating item query response after armor/resistances, discarding itemLevel, requiredLevel, spell slots, bind type, and description. Now stores itemLevel/requiredLevel, reads AmmoType+RangedModRange, reads 5 spell slots into data.spells[], reads bindType and description cstring. Matches the Classic and WotLK parser fixes from the previous commits. --- src/game/packet_parsers_tbc.cpp | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index d4cad578..65cdd913 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -906,8 +906,8 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell @@ -963,6 +963,31 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery data.delayMs = packet.readUInt32(); } + // AmmoType + RangedModRange + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + } + + // 5 item spells + for (int i = 0; i < 5; i++) { + if (packet.getReadPos() + 24 > packet.getSize()) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Flavor/lore text + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + data.valid = !data.name.empty(); LOG_DEBUG("[TBC] Item query: ", data.name, " quality=", data.quality, " invType=", data.inventoryType, " armor=", data.armor); From 321aaeae54fbc9cdaf09ed48927f05a78a037733 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:00:24 -0700 Subject: [PATCH 53/71] feat: capture and display all item stat types in tooltips Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored, discarding hit rating, crit, haste, attack power, spell power, resilience, expertise, armor penetration, MP5, and many others. Changes: - Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays - All three expansion parsers (WotLK/TBC/Classic) now capture non-primary stat type/value pairs into extraStats instead of silently dropping them - All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef - Tooltip now renders each extra stat on its own line with a name lookup covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.) - Also fix Classic/TBC bag-content and bank-bag paths that were missing bindType, description propagation from previous commits --- include/game/inventory.hpp | 4 +++ include/game/world_packets.hpp | 3 ++ src/game/game_handler.cpp | 19 ++++++++++++ src/game/packet_parsers_classic.cpp | 5 +++- src/game/packet_parsers_tbc.cpp | 5 +++- src/game/world_packets.cpp | 5 +++- src/ui/inventory_screen.cpp | 46 +++++++++++++++++++++++++++++ 7 files changed, 84 insertions(+), 3 deletions(-) diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index 5721155a..fcd50c3f 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -52,6 +53,9 @@ struct ItemDef { uint32_t requiredLevel = 0; uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ std::string description; // Flavor/lore text shown in tooltip (italic yellow) + // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) + struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; + std::vector extraStats; }; struct ItemSlot { diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 65a49430..eef6dba5 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1563,6 +1563,9 @@ struct ItemQueryResponseData { std::array spells{}; uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ std::string description; // Flavor/lore text + // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) + struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; + std::vector extraStats; bool valid = false; }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 040eeb48..8677e9b5 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10763,6 +10763,9 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10808,6 +10811,9 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10886,6 +10892,11 @@ void GameHandler::rebuildOnlineInventory() { def.sellPrice = infoIt->second.sellPrice; def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); @@ -10932,6 +10943,9 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.sellPrice = infoIt->second.sellPrice; def.bagSlots = infoIt->second.containerSlots; } else { @@ -11018,6 +11032,11 @@ void GameHandler::rebuildOnlineInventory() { def.itemLevel = infoIt->second.itemLevel; def.requiredLevel = infoIt->second.requiredLevel; def.sellPrice = infoIt->second.sellPrice; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 5863c377..9cf0d570 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1266,7 +1266,10 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 65cdd913..44626ce2 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -931,7 +931,10 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } // TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 2f31b774..4668d821 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2474,7 +2474,10 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 705acf52..2754a60b 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1833,6 +1833,52 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (!bonusLine.empty()) { ImGui::TextColored(green, "%s", bonusLine.c_str()); } + + // Extra stats (hit, crit, haste, AP, SP, etc.) — one line each + for (const auto& es : item.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 0: statName = "Mana"; break; + case 1: statName = "Health"; break; + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 15: statName = "Block Rating"; break; + case 16: statName = "Hit Rating"; break; + case 17: statName = "Hit Rating"; break; + case 18: statName = "Hit Rating"; break; + case 19: statName = "Crit Rating"; break; + case 20: statName = "Crit Rating"; break; + case 21: statName = "Crit Rating"; break; + case 28: statName = "Haste Rating"; break; + case 29: statName = "Haste Rating"; break; + case 30: statName = "Haste Rating"; break; + case 31: statName = "Hit Rating"; break; + case 32: statName = "Crit Rating"; break; + case 35: statName = "Resilience"; break; + case 36: statName = "Haste Rating"; break; + case 37: statName = "Expertise Rating"; break; + case 38: statName = "Attack Power"; break; + case 39: statName = "Ranged Attack Power"; break; + case 41: statName = "Healing Power"; break; + case 42: statName = "Spell Damage"; break; + case 43: statName = "Mana per 5 sec"; break; + case 44: statName = "Armor Penetration"; break; + case 45: statName = "Spell Power"; break; + case 46: statName = "Health per 5 sec"; break; + case 47: statName = "Spell Penetration"; break; + case 48: statName = "Block Value"; break; + default: statName = nullptr; break; + } + char buf[64]; + if (statName) { + std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName); + } else { + std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType); + } + ImGui::TextColored(green, "%s", buf); + } + if (item.requiredLevel > 1) { ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel); } From 5fcf71e3ffbb269046d2905bfb5076eb63a4013d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:03:11 -0700 Subject: [PATCH 54/71] feat: add stat diff comparison in item shift-tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift-hover tooltip now shows stat differences vs the equipped item instead of just listing the equipped item's stats. Each compared stat shows: value (▲ gain green / ▼ loss red / unchanged grey). Covers: DPS (weapons), Armor, Str/Agi/Sta/Int/Spi, and all extra stats (Hit, Crit, Haste, Expertise, AP, SP, Resilience, MP5, etc.) using a union of stat types from both items. --- src/ui/inventory_screen.cpp | 86 ++++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 2754a60b..66614e6d 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1942,23 +1942,79 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { - float speed = static_cast(eq->item.delayMs) / 1000.0f; - float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + // Helper: render a numeric stat diff line + auto showDiff = [](const char* label, float newVal, float eqVal) { + if (newVal == 0.0f && eqVal == 0.0f) return; + float diff = newVal - eqVal; + char buf[128]; + if (diff > 0.0f) { + std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, newVal, diff); + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "%s", buf); + } else if (diff < 0.0f) { + std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, newVal, -diff); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", buf); + } else { + std::snprintf(buf, sizeof(buf), "%s: %.0f", label, newVal); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf); + } + }; + + // DPS comparison for weapons + if (isWeaponInventoryType(item.inventoryType) && isWeaponInventoryType(eq->item.inventoryType)) { + float newDps = 0.0f, eqDps = 0.0f; + if (item.damageMax > 0.0f && item.delayMs > 0) + newDps = ((item.damageMin + item.damageMax) * 0.5f) / (item.delayMs / 1000.0f); + if (eq->item.damageMax > 0.0f && eq->item.delayMs > 0) + eqDps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / (eq->item.delayMs / 1000.0f); + showDiff("DPS", newDps, eqDps); } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); + + // Armor + showDiff("Armor", static_cast(item.armor), static_cast(eq->item.armor)); + + // Primary stats + showDiff("Str", static_cast(item.strength), static_cast(eq->item.strength)); + showDiff("Agi", static_cast(item.agility), static_cast(eq->item.agility)); + showDiff("Sta", static_cast(item.stamina), static_cast(eq->item.stamina)); + showDiff("Int", static_cast(item.intellect), static_cast(eq->item.intellect)); + showDiff("Spi", static_cast(item.spirit), static_cast(eq->item.spirit)); + + // Extra stats diff — union of stat types from both items + auto findExtraStat = [](const game::ItemDef& it, uint32_t type) -> int32_t { + for (const auto& es : it.extraStats) + if (es.statType == type) return es.statValue; + return 0; + }; + // Collect all extra stat types + std::vector allTypes; + for (const auto& es : item.extraStats) allTypes.push_back(es.statType); + for (const auto& es : eq->item.extraStats) { + bool found = false; + for (uint32_t t : allTypes) if (t == es.statType) { found = true; break; } + if (!found) allTypes.push_back(es.statType); } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + for (uint32_t t : allTypes) { + int32_t nv = findExtraStat(item, t); + int32_t ev = findExtraStat(eq->item, t); + // Find a label for this stat type + const char* lbl = nullptr; + switch (t) { + case 31: lbl = "Hit"; break; + case 32: lbl = "Crit"; break; + case 35: lbl = "Resilience"; break; + case 36: lbl = "Haste"; break; + case 37: lbl = "Expertise"; break; + case 38: lbl = "Attack Power"; break; + case 39: lbl = "Ranged AP"; break; + case 43: lbl = "MP5"; break; + case 44: lbl = "Armor Pen"; break; + case 45: lbl = "Spell Power"; break; + case 46: lbl = "HP5"; break; + case 48: lbl = "Block Value"; break; + default: lbl = nullptr; break; + } + if (!lbl) continue; + showDiff(lbl, static_cast(nv), static_cast(ev)); } } } From 62b7622f751ed3cb6a11182464a8683ebad94685 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:05:04 -0700 Subject: [PATCH 55/71] feat: parse and display StartQuest field from item query response Items that begin a quest (like quest starter drop items) now show "Begins a Quest" in the tooltip. All three expansion parsers (WotLK/TBC/Classic) now read the PageText/LanguageID/PageMaterial/StartQuest fields after Description. startQuestId is propagated through all 5 inventory rebuild paths and stored in ItemDef. --- include/game/inventory.hpp | 1 + include/game/world_packets.hpp | 1 + src/game/game_handler.cpp | 5 +++++ src/game/packet_parsers_classic.cpp | 8 ++++++++ src/game/packet_parsers_tbc.cpp | 8 ++++++++ src/game/world_packets.cpp | 8 ++++++++ src/ui/inventory_screen.cpp | 5 +++++ 7 files changed, 36 insertions(+) diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index fcd50c3f..7a3bcb8c 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -56,6 +56,7 @@ struct ItemDef { // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; std::vector extraStats; + uint32_t startQuestId = 0; // Non-zero: item begins a quest }; struct ItemSlot { diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index eef6dba5..786cab8b 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1566,6 +1566,7 @@ struct ItemQueryResponseData { // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; std::vector extraStats; + uint32_t startQuestId = 0; // Non-zero: item begins a quest bool valid = false; }; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 8677e9b5..a8841074 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10763,6 +10763,7 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); @@ -10811,6 +10812,7 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); @@ -10894,6 +10896,7 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); @@ -10943,6 +10946,7 @@ void GameHandler::rebuildOnlineInventory() { def.requiredLevel = infoIt->second.requiredLevel; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); @@ -11034,6 +11038,7 @@ void GameHandler::rebuildOnlineInventory() { def.sellPrice = infoIt->second.sellPrice; def.bindType = infoIt->second.bindType; def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; def.extraStats.clear(); for (const auto& es : infoIt->second.extraStats) def.extraStats.push_back({es.statType, es.statValue}); diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 9cf0d570..35dc54f4 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1331,6 +1331,14 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ if (packet.getReadPos() < packet.getSize()) data.description = packet.readString(); + // Post-description: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality, " invType=", data.inventoryType, " stack=", data.maxStack, ")"); diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 44626ce2..ebe467ea 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -991,6 +991,14 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery if (packet.getReadPos() < packet.getSize()) data.description = packet.readString(); + // Post-description: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); LOG_DEBUG("[TBC] Item query: ", data.name, " quality=", data.quality, " invType=", data.inventoryType, " armor=", data.armor); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4668d821..710ad501 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2529,6 +2529,14 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa if (packet.getReadPos() < packet.getSize()) data.description = packet.readString(); + // Post-description fields: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); return true; } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 66614e6d..8567c3ce 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1918,6 +1918,11 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } + // "Begins a Quest" line (shown in yellow-green like the game) + if (item.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + // Flavor / lore text (italic yellow in WoW, just yellow here) if (!item.description.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", item.description.c_str()); From 068b6bc2cbb3a8d371ced0da0c006e2f9a82ff27 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:10:31 -0700 Subject: [PATCH 56/71] fix: parse SMSG_INVENTORY_CHANGE_FAILURE additional fields correctly Previously the handler read only the error byte, producing: - A literal "%d" in the "requires level" message (error 1) - No consumption of the following item GUIDs and bag slot bytes Now reads item_guid1(8) + item_guid2(8) + bag_slot(1) after the error byte, and for error 1 (EQUIP_ERR_LEVEL_REQ) reads the required level uint32 and shows the correct message: "You must reach level N to use that item." --- src/game/game_handler.cpp | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index a8841074..1b20c142 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3603,10 +3603,29 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t error = packet.readUInt8(); if (error != 0) { LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error); + // After error byte: item_guid1(8) + item_guid2(8) + bag_slot(1) = 17 bytes + uint32_t requiredLevel = 0; + if (packet.getSize() - packet.getReadPos() >= 17) { + packet.readUInt64(); // item_guid1 + packet.readUInt64(); // item_guid2 + packet.readUInt8(); // bag_slot + // Error 1 = EQUIP_ERR_LEVEL_REQ: server appends required level as uint32 + if (error == 1 && packet.getSize() - packet.getReadPos() >= 4) + requiredLevel = packet.readUInt32(); + } // InventoryResult enum (AzerothCore 3.3.5a) const char* errMsg = nullptr; + char levelBuf[64]; switch (error) { - case 1: errMsg = "You must reach level %d to use that item."; break; + case 1: + if (requiredLevel > 0) { + std::snprintf(levelBuf, sizeof(levelBuf), + "You must reach level %u to use that item.", requiredLevel); + addSystemChatMessage(levelBuf); + } else { + addSystemChatMessage("You must reach a higher level to use that item."); + } + break; case 2: errMsg = "You don't have the required skill."; break; case 3: errMsg = "That item doesn't go in that slot."; break; case 4: errMsg = "That bag is full."; break; From 59597ff39ee2ad1e327670d54353696e155e3c13 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:14:46 -0700 Subject: [PATCH 57/71] feat: display mana cost, cast time, and range in spellbook tooltip Load SpellCastTimes.dbc and SpellRange.dbc during DBC init and populate SpellInfo.castTimeMs, manaCost, powerType, rangeIndex. renderSpellTooltip now shows resource cost (Mana/Rage/Energy/Focus), cast time ("Instant cast" or "X.X sec cast"), and range ("X yd range" or "Melee range") for active spells, matching WoW's native tooltip layout with cost on left and cast time aligned to the right. --- src/ui/spellbook_screen.cpp | 110 ++++++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 6e857d73..4a161c19 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -52,8 +52,37 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + // Load SpellCastTimes.dbc: field 0=ID, field 1=Base(ms), field 2=PerLevel, field 3=Minimum + std::unordered_map castTimeMap; // index → base ms + auto castTimeDbc = assetManager->loadDBC("SpellCastTimes.dbc"); + if (castTimeDbc && castTimeDbc->isLoaded()) { + for (uint32_t i = 0; i < castTimeDbc->getRecordCount(); ++i) { + uint32_t id = castTimeDbc->getUInt32(i, 0); + int32_t base = static_cast(castTimeDbc->getUInt32(i, 1)); + if (id > 0 && base > 0) + castTimeMap[id] = static_cast(base); + } + } + + // Load SpellRange.dbc: field 0=ID, field 5=MaxRangeHostile (float) + std::unordered_map rangeMap; // index → max yards + auto rangeDbc = assetManager->loadDBC("SpellRange.dbc"); + if (rangeDbc && rangeDbc->isLoaded()) { + uint32_t rangeFieldCount = rangeDbc->getFieldCount(); + if (rangeFieldCount >= 6) { + for (uint32_t i = 0; i < rangeDbc->getRecordCount(); ++i) { + uint32_t id = rangeDbc->getUInt32(i, 0); + float maxRange = rangeDbc->getFloat(i, 5); + if (id > 0 && maxRange > 0.0f) + rangeMap[id] = maxRange; + } + } + } + auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField, uint32_t nameField, uint32_t rankField, uint32_t tooltipField, + uint32_t powerTypeField, uint32_t manaCostField, + uint32_t castTimeIndexField, uint32_t rangeIndexField, const char* label) { spellData.clear(); uint32_t count = dbc->getRecordCount(); @@ -68,6 +97,18 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { info.name = dbc->getString(i, nameField); info.rank = dbc->getString(i, rankField); info.description = dbc->getString(i, tooltipField); + info.powerType = dbc->getUInt32(i, powerTypeField); + info.manaCost = dbc->getUInt32(i, manaCostField); + uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField); + if (ctIdx > 0) { + auto ctIt = castTimeMap.find(ctIdx); + if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second; + } + uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField); + if (rangeIdx > 0) { + auto rangeIt = rangeMap.find(rangeIdx); + if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast(rangeIt->second); + } if (!info.name.empty()) { spellData[spellId] = std::move(info); @@ -77,16 +118,26 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { }; if (spellL) { - uint32_t tooltipField = 139; - // Try to get Tooltip field from layout, fall back to 139 - try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + uint32_t tooltipField = 139; + uint32_t powerTypeField = 14; + uint32_t manaCostField = 39; + uint32_t castTimeIdxField = 47; + uint32_t rangeIdxField = 49; + try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + try { powerTypeField = (*spellL)["PowerType"]; } catch (...) {} + try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {} + try { castTimeIdxField = (*spellL)["CastingTimeIndex"]; } catch (...) {} + try { rangeIdxField = (*spellL)["RangeIndex"]; } catch (...) {} tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], - (*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout"); + (*spellL)["Name"], (*spellL)["Rank"], tooltipField, + powerTypeField, manaCostField, castTimeIdxField, rangeIdxField, + "expansion layout"); } if (spellData.empty() && fieldCount >= 200) { LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); - tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback"); + // WotLK Spell.dbc field indices (verified against 3.3.5a schema) + tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback"); } dbcLoaded = !spellData.empty(); @@ -363,6 +414,55 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive"); } + // Resource cost + cast time on same row (WoW style) + if (!info->isPassive()) { + // Left: resource cost + char costBuf[64] = ""; + if (info->manaCost > 0) { + const char* powerName = "Mana"; + switch (info->powerType) { + case 1: powerName = "Rage"; break; + case 3: powerName = "Energy"; break; + case 4: powerName = "Focus"; break; + default: break; + } + std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName); + } + + // Right: cast time + char castBuf[32] = ""; + if (info->castTimeMs == 0) { + std::snprintf(castBuf, sizeof(castBuf), "Instant cast"); + } else { + float secs = info->castTimeMs / 1000.0f; + std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs); + } + + if (costBuf[0] || castBuf[0]) { + float wrapW = 320.0f; + if (costBuf[0] && castBuf[0]) { + float castW = ImGui::CalcTextSize(castBuf).x; + ImGui::Text("%s", costBuf); + ImGui::SameLine(wrapW - castW); + ImGui::Text("%s", castBuf); + } else if (castBuf[0]) { + ImGui::Text("%s", castBuf); + } else { + ImGui::Text("%s", costBuf); + } + } + + // Range + if (info->rangeIndex > 0) { + char rangeBuf[32]; + if (info->rangeIndex <= 5) + std::snprintf(rangeBuf, sizeof(rangeBuf), "Melee range"); + else + std::snprintf(rangeBuf, sizeof(rangeBuf), "%u yd range", info->rangeIndex); + ImGui::Text("%s", rangeBuf); + } + } + // Cooldown if active float cd = gameHandler.getSpellCooldown(info->spellId); if (cd > 0.0f) { From 56588e0dadd159ec9d54d7b4ff321900171f8fc3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:19:43 -0700 Subject: [PATCH 58/71] fix: correct SMSG_SPELL_COOLDOWN parsing for Classic 1.12 expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic 1.12 sends guid(8) + N×[spellId(4)+itemId(4)+cooldown(4)] with no flags byte and 12 bytes per entry, while TBC/WotLK send guid(8)+ flags(1) + N×[spellId(4)+cooldown(4)] with 8 bytes per entry. The previous parser always consumed the WotLK flags byte, which on Classic servers would corrupt the first spell ID (reading one byte into spellId) and misalign all subsequent entries. Fixed by detecting isClassicLikeExpansion() and using the correct 12-byte-per-entry format (skipping itemId) for Classic builds. --- src/game/game_handler.cpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1b20c142..7f4425f9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -13487,20 +13487,35 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } void GameHandler::handleSpellCooldown(network::Packet& packet) { - SpellCooldownData data; - if (!SpellCooldownParser::parse(packet, data)) return; + // Classic 1.12: guid(8) + N×[spellId(4) + itemId(4) + cooldown(4)] — no flags byte, 12 bytes/entry + // TBC 2.4.3 / WotLK 3.3.5a: guid(8) + flags(1) + N×[spellId(4) + cooldown(4)] — 8 bytes/entry + const bool isClassicFormat = isClassicLikeExpansion(); + + if (packet.getSize() - packet.getReadPos() < 8) return; + /*data.guid =*/ packet.readUInt64(); // guid (not used further) + + if (!isClassicFormat) { + if (packet.getSize() - packet.getReadPos() < 1) return; + /*data.flags =*/ packet.readUInt8(); // flags (consumed but not stored) + } + + const size_t entrySize = isClassicFormat ? 12u : 8u; + while (packet.getSize() - packet.getReadPos() >= entrySize) { + uint32_t spellId = packet.readUInt32(); + if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used + uint32_t cooldownMs = packet.readUInt32(); - for (const auto& [spellId, cooldownMs] : data.cooldowns) { float seconds = cooldownMs / 1000.0f; spellCooldowns[spellId] = seconds; - // Update action bar cooldowns for (auto& slot : actionBar) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { - slot.cooldownTotal = seconds; + slot.cooldownTotal = seconds; slot.cooldownRemaining = seconds; } } } + LOG_DEBUG("handleSpellCooldown: parsed for ", + isClassicFormat ? "Classic" : "TBC/WotLK", " format"); } void GameHandler::handleCooldownEvent(network::Packet& packet) { From 63c09163dc2efe9f3fae97149183fb80a4498d6b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:28:20 -0700 Subject: [PATCH 59/71] fix: correct SMSG_ACTION_BUTTONS parsing for Classic and TBC expansions Classic 1.12 sends 120 action button slots with no leading mode byte (480 bytes total). TBC 2.4.3 sends 132 slots with no mode byte (528 bytes). WotLK 3.3.5a sends a uint8 mode byte followed by 144 slots (577 bytes total). The previous code always consumed a mode byte and assumed 144 slots. On Classic servers this would misparse the first action button (reading one byte as the mode, shifting all subsequent entries), causing the action bar to load garbage spells/items from the server. Fixed by detecting expansion type at runtime and selecting the appropriate slot count and presence of mode byte accordingly. --- src/game/game_handler.cpp | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7f4425f9..79071a51 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3403,15 +3403,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_ACTION_BUTTONS: { - // uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons // packed: bits 0-23 = actionId, bits 24-31 = type // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) + // Format differences: + // Classic 1.12: no mode byte, 120 slots (480 bytes) + // TBC 2.4.3: no mode byte, 132 slots (528 bytes) + // WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes) size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 1) break; - /*uint8_t mode =*/ packet.readUInt8(); - rem--; - constexpr int SERVER_BAR_SLOTS = 144; - for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { + const bool hasModeByteExp = isActiveExpansion("wotlk"); + int serverBarSlots; + if (isClassicLikeExpansion()) { + serverBarSlots = 120; + } else if (isActiveExpansion("tbc")) { + serverBarSlots = 132; + } else { + serverBarSlots = 144; + } + if (hasModeByteExp) { + if (rem < 1) break; + /*uint8_t mode =*/ packet.readUInt8(); + rem--; + } + for (int i = 0; i < serverBarSlots; ++i) { if (rem < 4) break; uint32_t packed = packet.readUInt32(); rem -= 4; From fcb133dbbef38e3d1c96be391ed8cb0d31f7bfef Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:41:05 -0700 Subject: [PATCH 60/71] fix: guard optional DBC field reads against out-of-bounds indices for Classic/TBC When expansion DBC layouts lack PowerType/ManaCost/CastingTimeIndex/RangeIndex, default to UINT32_MAX instead of WotLK hardcoded indices to prevent reading wrong data from Classic/TBC Spell.dbc files. tryLoad now skips any field index >= fieldCount. --- src/ui/spellbook_screen.cpp | 42 ++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 4a161c19..eec714b9 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -86,6 +86,7 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { const char* label) { spellData.clear(); uint32_t count = dbc->getRecordCount(); + const uint32_t fc = dbc->getFieldCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t spellId = dbc->getUInt32(i, idField); if (spellId == 0) continue; @@ -95,19 +96,24 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { info.attributes = dbc->getUInt32(i, attrField); info.iconId = dbc->getUInt32(i, iconField); info.name = dbc->getString(i, nameField); - info.rank = dbc->getString(i, rankField); - info.description = dbc->getString(i, tooltipField); - info.powerType = dbc->getUInt32(i, powerTypeField); - info.manaCost = dbc->getUInt32(i, manaCostField); - uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField); - if (ctIdx > 0) { - auto ctIt = castTimeMap.find(ctIdx); - if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second; + if (rankField < fc) info.rank = dbc->getString(i, rankField); + if (tooltipField < fc) info.description = dbc->getString(i, tooltipField); + // Optional fields: only read if field index is valid for this DBC version + if (powerTypeField < fc) info.powerType = dbc->getUInt32(i, powerTypeField); + if (manaCostField < fc) info.manaCost = dbc->getUInt32(i, manaCostField); + if (castTimeIndexField < fc) { + uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField); + if (ctIdx > 0) { + auto ctIt = castTimeMap.find(ctIdx); + if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second; + } } - uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField); - if (rangeIdx > 0) { - auto rangeIt = rangeMap.find(rangeIdx); - if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast(rangeIt->second); + if (rangeIndexField < fc) { + uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField); + if (rangeIdx > 0) { + auto rangeIt = rangeMap.find(rangeIdx); + if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast(rangeIt->second); + } } if (!info.name.empty()) { @@ -118,11 +124,13 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { }; if (spellL) { - uint32_t tooltipField = 139; - uint32_t powerTypeField = 14; - uint32_t manaCostField = 39; - uint32_t castTimeIdxField = 47; - uint32_t rangeIdxField = 49; + // Default to UINT32_MAX for optional fields; tryLoad will skip them if >= fieldCount. + // Avoids reading wrong data from expansion DBCs that lack these fields (e.g. Classic/TBC). + uint32_t tooltipField = UINT32_MAX; + uint32_t powerTypeField = UINT32_MAX; + uint32_t manaCostField = UINT32_MAX; + uint32_t castTimeIdxField = UINT32_MAX; + uint32_t rangeIdxField = UINT32_MAX; try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} try { powerTypeField = (*spellL)["PowerType"]; } catch (...) {} try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {} From 2f3f9f1a21dc9c9b624f15fb923e875c14e0019d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 17:53:17 -0700 Subject: [PATCH 61/71] fix: enable Classic/Turtle Spell.dbc loading and add WotLK optional spell fields to layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower fieldCount threshold from 154→148 so Classic 1.12 and Turtle WoW Spell.dbc (148 fields, Tooltip at index 147) are accepted by the spellbook loader instead of being silently skipped. Add PowerType/ManaCost/CastingTimeIndex/RangeIndex to the WotLK dbc_layouts.json Spell section so mana cost, cast time, and range continue to display correctly when the DBC layout path is active (the old hardcoded-index fallback path is now bypassed since layout-path loads spell names first and spellData.empty() is no longer true). --- Data/expansions/wotlk/dbc_layouts.json | 3 ++- src/ui/spellbook_screen.cpp | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 5b500741..d802b540 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,7 +1,8 @@ { "Spell": { "ID": 0, "Attributes": 4, "IconID": 133, - "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225 + "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, + "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index eec714b9..38c01fe8 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -45,8 +45,10 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { } uint32_t fieldCount = dbc->getFieldCount(); - if (fieldCount < 154) { - LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, expected 234+"); + // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~167, WotLK has 234. + // Require at least 148 fields so all expansions can load spell names/icons via the DBC layout. + if (fieldCount < 148) { + LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, too few to load"); return; } From 53d144c51eb95271534d022814a9828d0f627015 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 18:09:21 -0700 Subject: [PATCH 62/71] fix: expansion-aware SpellRange.dbc loading and Classic spell tooltip fields SpellRange.dbc layout fix: - Classic 1.12 uses field 2 (MaxRange), TBC/WotLK use field 4 (MaxRangeHostile) - Add SpellRange layout to each expansion's dbc_layouts.json - Replace hardcoded field 5 with layout-driven lookup in SpellRange loading - Corrects previously wrong range values in WotLK spellbook tooltips Classic 1.12 Spell.dbc field additions: - Add CastingTimeIndex=15, PowerType=28, ManaCost=29, RangeIndex=33 to classic/dbc_layouts.json so Classic spellbook shows mana cost, cast time, and range in tooltips Trainer fieldCount guard: - Lower Trainer::loadSpellNameCache() Spell.dbc fieldCount threshold from 154 to 148 so Classic trainers correctly resolve spell names from Spell.dbc --- Data/expansions/classic/dbc_layouts.json | 4 +++- Data/expansions/tbc/dbc_layouts.json | 1 + Data/expansions/wotlk/dbc_layouts.json | 1 + src/game/game_handler.cpp | 6 ++++-- src/ui/spellbook_screen.cpp | 17 ++++++++++++++--- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 4ec229d5..102074be 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,8 +1,10 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 }, + "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index d40a5766..8b597ba8 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -3,6 +3,7 @@ "ID": 0, "Attributes": 5, "IconID": 124, "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 }, + "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index d802b540..82252391 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -4,6 +4,7 @@ "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225, "PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49 }, + "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 79071a51..e8ecdf72 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15671,8 +15671,10 @@ void GameHandler::loadSpellNameCache() { return; } - if (dbc->getFieldCount() < 154) { - LOG_WARNING("Trainer: Spell.dbc has too few fields"); + // Classic 1.12 Spell.dbc has 148 fields; TBC/WotLK have more. + // Require at least 148 so Classic trainers can resolve spell names. + if (dbc->getFieldCount() < 148) { + LOG_WARNING("Trainer: Spell.dbc has too few fields (", dbc->getFieldCount(), ")"); return; } diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 38c01fe8..99af8c1f 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -66,15 +66,26 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { } } - // Load SpellRange.dbc: field 0=ID, field 5=MaxRangeHostile (float) + // Load SpellRange.dbc. Field layout differs by expansion: + // Classic 1.12: 0=ID, 1=MinRange, 2=MaxRange, 3=Flags, 4+=strings + // TBC / WotLK: 0=ID, 1=MinRangeFriendly, 2=MinRangeHostile, + // 3=MaxRangeFriendly, 4=MaxRangeHostile, 5=Flags, 6+=strings + // The correct field is declared in each expansion's dbc_layouts.json. + uint32_t spellRangeMaxField = 4; // WotLK / TBC default: MaxRangeHostile + const auto* spellRangeL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") + : nullptr; + if (spellRangeL) { + try { spellRangeMaxField = (*spellRangeL)["MaxRange"]; } catch (...) {} + } std::unordered_map rangeMap; // index → max yards auto rangeDbc = assetManager->loadDBC("SpellRange.dbc"); if (rangeDbc && rangeDbc->isLoaded()) { uint32_t rangeFieldCount = rangeDbc->getFieldCount(); - if (rangeFieldCount >= 6) { + if (rangeFieldCount > spellRangeMaxField) { for (uint32_t i = 0; i < rangeDbc->getRecordCount(); ++i) { uint32_t id = rangeDbc->getUInt32(i, 0); - float maxRange = rangeDbc->getFloat(i, 5); + float maxRange = rangeDbc->getFloat(i, spellRangeMaxField); if (id > 0 && maxRange > 0.0f) rangeMap[id] = maxRange; } From 2e38a9af659260b439fc696a9ea7870e078d1d2e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 18:26:02 -0700 Subject: [PATCH 63/71] feat: add pet action bar to pet frame UI Show the 10 SMSG_PET_SPELLS action slots as clickable icon/text buttons in the pet frame. Spell slots with icons render as ImageButtons; built-in commands (Attack/Follow/Stay) render as text buttons. Autocast-on slots are tinted green. Clicking a spell slot sends CMSG_PET_ACTION with the current target GUID; built-in commands send without a target. Tooltips show the spell name on hover. --- src/ui/game_screen.cpp | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index cf924c08..f61d03cd 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2049,6 +2049,88 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::SmallButton("Dismiss")) { gameHandler.dismissPet(); } + + // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS + { + const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS; + // Filter to non-zero slots; lay them out as small icon/text buttons. + // Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID, + // high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type). + // Built-in commands: id=2 follow, id=3 stay/move, id=5 attack. + auto* assetMgr = core::Application::getInstance().getAssetManager(); + const float iconSz = 20.0f; + const float spacing = 2.0f; + ImGui::Separator(); + + int rendered = 0; + for (int i = 0; i < slotCount; ++i) { + uint32_t slotVal = gameHandler.getPetActionSlot(i); + if (slotVal == 0) continue; + + uint32_t actionId = slotVal & 0x00FFFFFFu; + bool autocastOn = (slotVal & 0xFF000000u) == 0x80000000u; + + ImGui::PushID(i); + if (rendered > 0) ImGui::SameLine(0.0f, spacing); + + // Try to show spell icon; fall back to abbreviated text label. + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const char* builtinLabel = nullptr; + if (actionId == 2) builtinLabel = "Fol"; + else if (actionId == 3) builtinLabel = "Sty"; + else if (actionId == 5) builtinLabel = "Atk"; + else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); + + // Tint green when autocast is on. + ImVec4 tint = autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) + : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + bool clicked = false; + if (iconTex) { + clicked = ImGui::ImageButton("##pa", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(0.1f,0.1f,0.1f,0.9f), tint); + } else { + char label[8]; + if (builtinLabel) { + snprintf(label, sizeof(label), "%s", builtinLabel); + } else { + // Show first 3 chars of spell name or spell ID. + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100); + else snprintf(label, sizeof(label), "%.3s", nm.c_str()); + } + ImGui::PushStyleColor(ImGuiCol_Button, + autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) + : ImVec4(0.2f,0.2f,0.3f,0.9f)); + clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); + ImGui::PopStyleColor(); + } + + if (clicked) { + // Send pet action; use current target for spells. + uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; + gameHandler.sendPetAction(slotVal, targetGuid); + } + + // Tooltip: show spell name or built-in command name. + if (ImGui::IsItemHovered()) { + const char* tip = builtinLabel + ? (actionId == 5 ? "Attack" : actionId == 4 ? "Follow" : actionId == 2 ? "Follow" : "Stay") + : nullptr; + std::string spellNm; + if (!tip && actionId > 5) { + spellNm = gameHandler.getSpellName(actionId); + if (!spellNm.empty()) tip = spellNm.c_str(); + } + if (tip) ImGui::SetTooltip("%s", tip); + } + + ImGui::PopID(); + ++rendered; + } + } } ImGui::End(); From 373dbbf95d05a00fb0988b6a7d195e86c455226a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 18:37:05 -0700 Subject: [PATCH 64/71] fix: use authoritative autocast state for pet action bar and correct tooltip labels - Use isPetSpellAutocast() instead of parsing the slot value high byte for autocast detection; the authoritative source is the SMSG_PET_SPELLS spell list activeFlags, not the action bar slot value. - Fix tooltip mapping: actionId==2 maps to "Follow", actionId==5 to "Attack", others to "Stay" (removed erroneous duplicate Follow case for actionId==4). - Update spellbook comment: TBC Spell.dbc has ~220+ fields (not ~167). --- src/ui/game_screen.cpp | 5 +++-- src/ui/spellbook_screen.cpp | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f61d03cd..f66e6749 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2068,7 +2068,8 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (slotVal == 0) continue; uint32_t actionId = slotVal & 0x00FFFFFFu; - bool autocastOn = (slotVal & 0xFF000000u) == 0x80000000u; + // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. + bool autocastOn = gameHandler.isPetSpellAutocast(actionId); ImGui::PushID(i); if (rendered > 0) ImGui::SameLine(0.0f, spacing); @@ -2117,7 +2118,7 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { // Tooltip: show spell name or built-in command name. if (ImGui::IsItemHovered()) { const char* tip = builtinLabel - ? (actionId == 5 ? "Attack" : actionId == 4 ? "Follow" : actionId == 2 ? "Follow" : "Stay") + ? (actionId == 5 ? "Attack" : actionId == 2 ? "Follow" : "Stay") : nullptr; std::string spellNm; if (!tip && actionId > 5) { diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 99af8c1f..08cdf0a0 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -45,7 +45,7 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { } uint32_t fieldCount = dbc->getFieldCount(); - // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~167, WotLK has 234. + // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~220+ (SchoolMask at 215), WotLK has 234. // Require at least 148 fields so all expansions can load spell names/icons via the DBC layout. if (fieldCount < 148) { LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, too few to load"); From 9f8a0907c48f0afa39114895832dcfff52495642 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 18:55:01 -0700 Subject: [PATCH 65/71] feat: add spell stats to TBC and Turtle WoW DBC layouts TBC 2.4.3: TBC added 7 fields after position 5 vs Classic 1.12, giving a consistent +7 offset for all fields in the middle/upper range. Derive CastingTimeIndex (22), PowerType (35), ManaCost (36), and RangeIndex (40) from the verified Classic positions (15/28/29/33) using this offset. This enables mana cost, cast time, and range display in the TBC spellbook. Turtle WoW: Inherits Classic 1.12.1 Spell.dbc field layout. Add CastingTimeIndex (15), PowerType (28), ManaCost (29), RangeIndex (33), and SpellRange.MaxRange (2) matching Classic 1.12. Enables spell stat display for Turtle WoW players. Also update README: pet action bar (10 slots, icons, autocast tinting). --- Data/expansions/tbc/dbc_layouts.json | 3 ++- Data/expansions/turtle/dbc_layouts.json | 4 +++- README.md | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 8b597ba8..5bca8165 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,7 +1,8 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 + "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215, + "CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40 }, "SpellRange": { "MaxRange": 4 }, "ItemDisplayInfo": { diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 4e86338a..e31634e4 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,8 +1,10 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1, + "CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33 }, + "SpellRange": { "MaxRange": 2 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, diff --git a/README.md b/README.md index 7353ed15..ab1a90d0 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. - **Gossip** -- NPC interaction, dialogue options - **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips - **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS -- **Pets** -- Pet tracking via SMSG_PET_SPELLS, dismiss pet button +- **Pets** -- Pet tracking via SMSG_PET_SPELLS, action bar (10 slots with icon/autocast tinting/tooltips), dismiss button - **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior - **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine) - **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) From 1ff48259cc72b45d391dbb7de981b3a631a25ef3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:05:34 -0700 Subject: [PATCH 66/71] feat: display quest reward items in quest details acceptance window Parse and store reward items (choice and fixed) from SMSG_QUESTGIVER_QUEST_DETAILS in both WotLK (QuestDetailsParser) and TBC/Classic (TbcPacketParsers) parsers. Show item icons, names, and counts in the quest acceptance dialog alongside XP/money. Move QuestRewardItem before QuestDetailsData in header to fix forward-reference. --- include/game/world_packets.hpp | 18 +++++----- src/game/packet_parsers_tbc.cpp | 23 +++++++++---- src/game/world_packets.cpp | 23 +++++++++---- src/ui/game_screen.cpp | 58 ++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 21 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 786cab8b..5d75e887 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2086,6 +2086,14 @@ public: static network::Packet build(uint64_t npcGuid, uint32_t questId); }; +/** Reward item entry (shared by quest detail/offer windows) */ +struct QuestRewardItem { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t displayInfoId = 0; + uint32_t choiceSlot = 0; // Original reward slot index from server payload +}; + /** SMSG_QUESTGIVER_QUEST_DETAILS data (simplified) */ struct QuestDetailsData { uint64_t npcGuid = 0; @@ -2096,6 +2104,8 @@ struct QuestDetailsData { uint32_t suggestedPlayers = 0; uint32_t rewardMoney = 0; uint32_t rewardXp = 0; + std::vector rewardChoiceItems; // Player picks one of these + std::vector rewardItems; // These are always given }; /** SMSG_QUESTGIVER_QUEST_DETAILS parser */ @@ -2104,14 +2114,6 @@ public: static bool parse(network::Packet& packet, QuestDetailsData& data); }; -/** Reward item entry (shared by quest detail/offer windows) */ -struct QuestRewardItem { - uint32_t itemId = 0; - uint32_t count = 0; - uint32_t displayInfoId = 0; - uint32_t choiceSlot = 0; // Original reward slot index from server payload -}; - /** SMSG_QUESTGIVER_REQUEST_ITEMS data (turn-in progress check) */ struct QuestRequestItemsData { uint64_t npcGuid = 0; diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ebe467ea..c523df13 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -739,9 +739,15 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t choiceCount = packet.readUInt32(); for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = i; + data.rewardChoiceItems.push_back(ri); + } } } @@ -749,9 +755,14 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t rewardCount = packet.readUInt32(); for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } } } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 710ad501..c3adbcb0 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -3446,9 +3446,15 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) /*choiceCount*/ packet.readUInt32(); for (int i = 0; i < 6; i++) { if (packet.getReadPos() + 12 > packet.getSize()) break; - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = static_cast(i); + data.rewardChoiceItems.push_back(ri); + } } } @@ -3457,9 +3463,14 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) /*rewardCount*/ packet.readUInt32(); for (int i = 0; i < 4; i++) { if (packet.getReadPos() + 12 > packet.getSize()) break; - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f66e6749..80afafb2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6714,7 +6714,63 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { ImGui::TextWrapped("%s", processedObjectives.c_str()); } - // Rewards + // Choice reward items (player picks one) + if (!quest.rewardChoiceItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose one reward:"); + for (const auto& ri : quest.rewardChoiceItems) { + gameHandler.ensureItemInfo(ri.itemId); + auto* info = gameHandler.getItemInfo(ri.itemId); + VkDescriptorSet iconTex = VK_NULL_HANDLE; + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); + + std::string label; + if (info && info->valid && !info->name.empty()) + label = info->name; + else + label = "Item " + std::to_string(ri.itemId); + if (ri.count > 1) label += " x" + std::to_string(ri.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + ImGui::SameLine(); + } + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), " %s", label.c_str()); + } + } + + // Fixed reward items (always given) + if (!quest.rewardItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will receive:"); + for (const auto& ri : quest.rewardItems) { + gameHandler.ensureItemInfo(ri.itemId); + auto* info = gameHandler.getItemInfo(ri.itemId); + VkDescriptorSet iconTex = VK_NULL_HANDLE; + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); + + std::string label; + if (info && info->valid && !info->name.empty()) + label = info->name; + else + label = "Item " + std::to_string(ri.itemId); + if (ri.count > 1) label += " x" + std::to_string(ri.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + ImGui::SameLine(); + } + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), " %s", label.c_str()); + } + } + + // XP and money rewards if (quest.rewardXp > 0 || quest.rewardMoney > 0) { ImGui::Spacing(); ImGui::Separator(); From 458a95ae8e42b5762ee7871dc5ab714343621550 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:12:43 -0700 Subject: [PATCH 67/71] refactor: use quality colors and hover tooltips for quest reward items Replace flat white coloring with item quality colors and add hover tooltips showing item name (quality-colored) and description for quest acceptance window. Extract renderQuestRewardItem lambda to eliminate code duplication between choice and fixed reward item rendering. --- src/ui/game_screen.cpp | 78 ++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 80afafb2..f01db61b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6715,30 +6715,50 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { } // Choice reward items (player picks one) + auto renderQuestRewardItem = [&](const game::QuestRewardItem& ri) { + gameHandler.ensureItemInfo(ri.itemId); + auto* info = gameHandler.getItemInfo(ri.itemId); + VkDescriptorSet iconTex = VK_NULL_HANDLE; + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); + + std::string label; + ImVec4 nameCol = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (info && info->valid && !info->name.empty()) { + label = info->name; + nameCol = InventoryScreen::getQualityColor(static_cast(info->quality)); + } else { + label = "Item " + std::to_string(ri.itemId); + } + if (ri.count > 1) label += " x" + std::to_string(ri.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(nameCol, "%s", info->name.c_str()); + if (!info->description.empty()) + ImGui::TextWrapped("%s", info->description.c_str()); + ImGui::EndTooltip(); + } + ImGui::SameLine(); + } + ImGui::TextColored(nameCol, " %s", label.c_str()); + if (ImGui::IsItemHovered() && info && info->valid && !info->description.empty()) { + ImGui::BeginTooltip(); + ImGui::TextColored(nameCol, "%s", info->name.c_str()); + ImGui::TextWrapped("%s", info->description.c_str()); + ImGui::EndTooltip(); + } + }; + if (!quest.rewardChoiceItems.empty()) { ImGui::Spacing(); ImGui::Separator(); ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose one reward:"); for (const auto& ri : quest.rewardChoiceItems) { - gameHandler.ensureItemInfo(ri.itemId); - auto* info = gameHandler.getItemInfo(ri.itemId); - VkDescriptorSet iconTex = VK_NULL_HANDLE; - uint32_t dispId = ri.displayInfoId; - if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; - if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); - - std::string label; - if (info && info->valid && !info->name.empty()) - label = info->name; - else - label = "Item " + std::to_string(ri.itemId); - if (ri.count > 1) label += " x" + std::to_string(ri.count); - - if (iconTex) { - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - ImGui::SameLine(); - } - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), " %s", label.c_str()); + renderQuestRewardItem(ri); } } @@ -6748,25 +6768,7 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will receive:"); for (const auto& ri : quest.rewardItems) { - gameHandler.ensureItemInfo(ri.itemId); - auto* info = gameHandler.getItemInfo(ri.itemId); - VkDescriptorSet iconTex = VK_NULL_HANDLE; - uint32_t dispId = ri.displayInfoId; - if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; - if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); - - std::string label; - if (info && info->valid && !info->name.empty()) - label = info->name; - else - label = "Item " + std::to_string(ri.itemId); - if (ri.count > 1) label += " x" + std::to_string(ri.count); - - if (iconTex) { - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - ImGui::SameLine(); - } - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), " %s", label.c_str()); + renderQuestRewardItem(ri); } } From bae3477c94484b90d333690df6068f362f1e8dfe Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:22:48 -0700 Subject: [PATCH 68/71] feat: display spell school in spellbook tooltip Load SchoolMask (TBC/WotLK bitmask) or SchoolEnum (Classic/Turtle 0-6 enum, converted to mask via 1<(rangeIt->second); } } + if (schoolField_ < fc) { + uint32_t raw = dbc->getUInt32(i, schoolField_); + // Classic/Turtle use a 0-6 school enum; TBC/WotLK use a bitmask. + // enum→mask: schoolEnum N maps to bit (1u << N), e.g. 0→1 (physical), 4→16 (frost). + info.schoolMask = isSchoolEnum_ ? (raw <= 6 ? (1u << raw) : 0u) : raw; + } if (!info.name.empty()) { spellData[spellId] = std::move(info); @@ -149,6 +160,13 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {} try { castTimeIdxField = (*spellL)["CastingTimeIndex"]; } catch (...) {} try { rangeIdxField = (*spellL)["RangeIndex"]; } catch (...) {} + // Try SchoolMask (TBC/WotLK bitmask) then SchoolEnum (Classic/Turtle 0-6 value) + schoolField_ = UINT32_MAX; + isSchoolEnum_ = false; + try { schoolField_ = (*spellL)["SchoolMask"]; } catch (...) {} + if (schoolField_ == UINT32_MAX) { + try { schoolField_ = (*spellL)["SchoolEnum"]; isSchoolEnum_ = true; } catch (...) {} + } tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], (*spellL)["Name"], (*spellL)["Rank"], tooltipField, powerTypeField, manaCostField, castTimeIdxField, rangeIdxField, @@ -157,7 +175,9 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { if (spellData.empty() && fieldCount >= 200) { LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); - // WotLK Spell.dbc field indices (verified against 3.3.5a schema) + // WotLK Spell.dbc field indices (verified against 3.3.5a schema); SchoolMask at field 225 + schoolField_ = 225; + isSchoolEnum_ = false; tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback"); } @@ -435,6 +455,32 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive"); } + // Spell school — only show for non-physical schools (physical is the default/implicit) + if (info->schoolMask != 0 && info->schoolMask != 1 /*physical*/) { + struct SchoolEntry { uint32_t mask; const char* name; ImVec4 color; }; + static constexpr SchoolEntry kSchools[] = { + { 2, "Holy", { 1.0f, 1.0f, 0.6f, 1.0f } }, + { 4, "Fire", { 1.0f, 0.5f, 0.1f, 1.0f } }, + { 8, "Nature", { 0.4f, 0.9f, 0.3f, 1.0f } }, + { 16, "Frost", { 0.5f, 0.8f, 1.0f, 1.0f } }, + { 32, "Shadow", { 0.7f, 0.4f, 1.0f, 1.0f } }, + { 64, "Arcane", { 0.9f, 0.5f, 1.0f, 1.0f } }, + }; + bool first = true; + for (const auto& s : kSchools) { + if (info->schoolMask & s.mask) { + if (!first) ImGui::SameLine(0, 0); + if (first) { + ImGui::TextColored(s.color, "%s", s.name); + first = false; + } else { + ImGui::SameLine(0, 2); + ImGui::TextColored(s.color, "/%s", s.name); + } + } + } + } + // Resource cost + cast time on same row (WoW style) if (!info->isPassive()) { // Left: resource cost From 7bbf2c7769e916e052bc9ddb89a49ef1cf53bbb1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:25:26 -0700 Subject: [PATCH 69/71] refactor: improve quest offer reward item display - Use getQualityColor() for consistent quality coloring (choice+fixed) - Show item icons for fixed rewards (previously text-only) - Replace useless "Reward option" tooltip with real item name+description - Render icon before selectable label (not after) for choice rewards - Call ensureItemInfo for all reward items to trigger async fetch - Use structured bindings (C++17) to unify icon+color resolution --- src/ui/game_screen.cpp | 100 ++++++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f01db61b..6f3eea0a 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6938,6 +6938,35 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } // Choice rewards (pick one) + // Trigger item info fetch for all reward items + for (const auto& item : quest.choiceRewards) gameHandler.ensureItemInfo(item.itemId); + for (const auto& item : quest.fixedRewards) gameHandler.ensureItemInfo(item.itemId); + + // Helper: resolve icon tex + quality color for a reward item + auto resolveRewardItemVis = [&](const game::QuestRewardItem& ri) + -> std::pair + { + auto* info = gameHandler.getItemInfo(ri.itemId); + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + ImVec4 col = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + return {iconTex, col}; + }; + + // Helper: show item tooltip + auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 nameCol) { + auto* info = gameHandler.getItemInfo(ri.itemId); + if (!info || !info->valid) return; + ImGui::BeginTooltip(); + ImGui::TextColored(nameCol, "%s", info->name.c_str()); + if (!info->description.empty()) + ImGui::TextWrapped("%s", info->description.c_str()); + ImGui::EndTooltip(); + }; + if (!quest.choiceRewards.empty()) { ImGui::Spacing(); ImGui::Separator(); @@ -6946,48 +6975,29 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { const auto& item = quest.choiceRewards[i]; auto* info = gameHandler.getItemInfo(item.itemId); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); bool selected = (selectedChoice == static_cast(i)); - - // Get item icon if we have displayInfoId - VkDescriptorSet iconTex = VK_NULL_HANDLE; - if (info && info->valid && info->displayInfoId != 0) { - iconTex = inventoryScreen.getItemIcon(info->displayInfoId); - } - - // Quality color - ImVec4 qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White (poor) - if (info && info->valid) { - switch (info->quality) { - case 1: qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // Common (white) - case 2: qualityColor = ImVec4(0.0f, 1.0f, 0.0f, 1.0f); break; // Uncommon (green) - case 3: qualityColor = ImVec4(0.0f, 0.5f, 1.0f, 1.0f); break; // Rare (blue) - case 4: qualityColor = ImVec4(0.64f, 0.21f, 0.93f, 1.0f); break; // Epic (purple) - case 5: qualityColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // Legendary (orange) - } - } - - // Render item with icon + visible selectable label ImGui::PushID(static_cast(i)); - std::string label; - if (info && info->valid && !info->name.empty()) { - label = info->name; - } else { - label = "Item " + std::to_string(item.itemId); + + // Icon then selectable on same line + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(20, 20)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); } - if (item.count > 1) { - label += " x" + std::to_string(item.count); - } - if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 24))) { + ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); + if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { selectedChoice = static_cast(i); } - if (ImGui::IsItemHovered() && iconTex) { - ImGui::SetTooltip("Reward option"); - } - if (iconTex) { - ImGui::SameLine(); - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::PopID(); } } @@ -6999,10 +7009,20 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will also receive:"); for (const auto& item : quest.fixedRewards) { auto* info = gameHandler.getItemInfo(item.itemId); - if (info && info->valid) - ImGui::Text(" %s x%u", info->name.c_str(), item.count); - else - ImGui::Text(" Item %u x%u", item.itemId, item.count); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); + } + ImGui::TextColored(qualityColor, " %s", label.c_str()); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); } } From caf0d18393007d6babf7e211f277f12b120ee666 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:31:46 -0700 Subject: [PATCH 70/71] feat: show rich spell tooltip on action bar hover Expose SpellbookScreen::renderSpellInfoTooltip() as a public method, then use it in the action bar slot tooltip. Action bar spell tooltips now show the same full tooltip as the spellbook: spell school (colored), mana/rage/energy cost, cast time, range, cooldown, and description. Falls back to a plain spell name if DBC data is not yet loaded. Hearthstone location note is appended after the rich body. Cooldown text moved inside each branch for consistent styling. --- include/ui/spellbook_screen.hpp | 5 +++++ src/ui/game_screen.cpp | 39 +++++++++++++++++++++++---------- src/ui/spellbook_screen.cpp | 9 ++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 7d562077..77f1c2d6 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -44,6 +44,11 @@ public: // Spell name lookup — triggers DBC load if needed, used by action bar tooltips std::string lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager); + // Rich tooltip — renders a full spell tooltip (inside an already-open BeginTooltip block). + // Triggers DBC load if needed. Returns true if spell data was found. + bool renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, + pipeline::AssetManager* assetManager); + // Drag-and-drop state for action bar assignment bool isDraggingSpell() const { return draggingSpell_; } uint32_t getDragSpellId() const { return dragSpellId_; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6f3eea0a..2911a722 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4141,9 +4141,15 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { // Tooltip if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { - ImGui::BeginTooltip(); if (slot.type == game::ActionBarSlot::SPELL) { - ImGui::Text("%s", getSpellName(slot.id).c_str()); + // Use the spellbook's rich tooltip (school, cost, cast time, range, description). + // Falls back to the simple name if DBC data isn't loaded yet. + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(slot.id, gameHandler, assetMgr); + if (!richOk) { + ImGui::Text("%s", getSpellName(slot.id).c_str()); + } + // Hearthstone: add location note after the spell tooltip body if (slot.id == 8690) { uint32_t mapId = 0; glm::vec3 pos; if (gameHandler.getHomeBind(mapId, pos)) { @@ -4156,25 +4162,34 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); } - ImGui::TextDisabled("Use: Teleport home"); } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { + ImGui::BeginTooltip(); if (barItemDef && !barItemDef->name.empty()) ImGui::Text("%s", barItemDef->name.c_str()); else if (!itemNameFromQuery.empty()) ImGui::Text("%s", itemNameFromQuery.c_str()); else ImGui::Text("Item #%u", slot.id); + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); } - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); - else - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Cooldown: %.1f sec", cd); - } - ImGui::EndTooltip(); } // Cooldown overlay: WoW-style clock-sweep + time text diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 5352eb41..f90090f7 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -184,6 +184,15 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { dbcLoaded = !spellData.empty(); } +bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, + pipeline::AssetManager* assetManager) { + if (!dbcLoadAttempted) loadSpellDBC(assetManager); + const SpellInfo* info = getSpellInfo(spellId); + if (!info) return false; + renderSpellTooltip(info, gameHandler); + return true; +} + std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager) { if (!dbcLoadAttempted) { loadSpellDBC(assetManager); From 34bab8edd64d1518cb4df9e9755641966a743572 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 10 Mar 2026 19:33:25 -0700 Subject: [PATCH 71/71] feat: use rich spell tooltip for buff/debuff frame icons Player buff bar and target debuff bar icons now show full spell tooltip (school, cost, cast time, range, description) on hover, matching the action bar and spellbook. Falls back to plain spell name if DBC is not loaded. Remaining aura duration is shown below the spell body. --- src/ui/game_screen.cpp | 46 ++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2911a722..3b3c7216 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2393,20 +2393,23 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { IM_COL32(255, 220, 50, 255), chargeStr); } - // Tooltip + // Tooltip: rich spell info + remaining duration if (ImGui::IsItemHovered()) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } if (tRemainMs > 0) { int seconds = tRemainMs / 1000; - if (seconds < 60) { - ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); - } else { - ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); - } - } else { - ImGui::SetTooltip("%s", name.c_str()); + char durBuf[32]; + if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds); + else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf); } + ImGui::EndTooltip(); } ImGui::PopID(); @@ -6394,20 +6397,23 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } } - // Tooltip with spell name and countdown + // Tooltip: rich spell info + remaining duration if (ImGui::IsItemHovered()) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } if (remainMs > 0) { int seconds = remainMs / 1000; - if (seconds < 60) { - ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); - } else { - ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); - } - } else { - ImGui::SetTooltip("%s", name.c_str()); + char durBuf[32]; + if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds); + else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf); } + ImGui::EndTooltip(); } ImGui::PopID();