From b366773f29223ecc44003a3c609b59833c03ef7a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 27 Mar 2026 17:54:56 -0700 Subject: [PATCH] fix: inspect (packed GUID), follow (client-side auto-walk); add loot/raid commands Inspect: CMSG_INSPECT was writing full uint64 GUID instead of packed GUID. Server silently rejected the malformed packet. Fixed both InspectPacket and QueryInspectAchievementsPacket to use writePackedGuid(). Follow: was a no-op (only stored GUID). Added client-side auto-follow system: camera controller walks toward followed entity, faces target, cancels on WASD/mouse input, stops within 3 units, cancels at 40+ units distance. Party commands: - /lootmethod (ffa/roundrobin/master/group/nbg) sends CMSG_LOOT_METHOD - /lootthreshold (0-5 or quality name) sets minimum loot quality - /raidconvert converts party to raid (leader only) Equipment diagnostic logging still active for debugging naked players. --- include/game/game_handler.hpp | 8 ++ include/game/world_packets.hpp | 17 ++++ include/rendering/camera_controller.hpp | 18 +++++ src/core/application.cpp | 14 ++++ src/game/game_handler.cpp | 49 +++++++++++- src/game/world_packets.cpp | 23 +++++- src/rendering/camera_controller.cpp | 41 +++++++++- src/ui/game_screen.cpp | 102 +++++++++++++++++++++++- 8 files changed, 264 insertions(+), 8 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7e51d85a..132c6725 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1122,6 +1122,10 @@ public: using CameraShakeCallback = std::function; void setCameraShakeCallback(CameraShakeCallback cb) { cameraShakeCallback_ = std::move(cb); } + // Auto-follow callback: pass render-space position pointer to start, nullptr to cancel. + using AutoFollowCallback = std::function; + void setAutoFollowCallback(AutoFollowCallback cb) { autoFollowCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) using UnstuckCallback = std::function; void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } @@ -1352,6 +1356,8 @@ public: void acceptGroupInvite(); void declineGroupInvite(); void leaveGroup(); + void convertToRaid(); + void sendSetLootMethod(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid); bool isInGroup() const { return !partyData.isEmpty(); } const GroupListData& getPartyData() const { return partyData; } const std::vector& getContacts() const { return contacts_; } @@ -2812,6 +2818,7 @@ private: // ---- Follow state ---- uint64_t followTargetGuid_ = 0; + glm::vec3 followRenderPos_{0.0f}; // Render-space position of followed entity (updated each frame) // ---- AFK/DND status ---- bool afkStatus_ = false; @@ -2905,6 +2912,7 @@ private: WorldEntryCallback worldEntryCallback_; KnockBackCallback knockBackCallback_; CameraShakeCallback cameraShakeCallback_; + AutoFollowCallback autoFollowCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 7c7a25f5..db66a9fe 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1315,6 +1315,23 @@ public: static network::Packet build(); }; +/** CMSG_GROUP_RAID_CONVERT packet builder */ +class GroupRaidConvertPacket { +public: + static network::Packet build(); +}; + +/** CMSG_LOOT_METHOD packet builder */ +class SetLootMethodPacket { +public: + /** + * @param method 0=FFA, 1=RoundRobin, 2=MasterLoot, 3=GroupLoot, 4=NeedBeforeGreed + * @param threshold item quality threshold (0-6) + * @param masterLooterGuid GUID of master looter (only relevant for method=2) + */ + static network::Packet build(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid); +}; + /** MSG_RAID_TARGET_UPDATE packet builder */ class RaidTargetUpdatePacket { public: diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index fae92812..3bc64218 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -96,6 +96,11 @@ public: // while server-sitting), so the caller can send CMSG_STAND_STATE_CHANGE(0). using StandUpCallback = std::function; void setStandUpCallback(StandUpCallback cb) { standUpCallback_ = std::move(cb); } + + // Callback invoked when auto-follow is cancelled by user movement input. + using AutoFollowCancelCallback = std::function; + void setAutoFollowCancelCallback(AutoFollowCancelCallback cb) { autoFollowCancelCallback_ = std::move(cb); } + void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } @@ -121,6 +126,13 @@ public: void clearMovementInputs(); void suppressMovementFor(float seconds) { movementSuppressTimer_ = seconds; } + // Auto-follow: walk toward a target position each frame (WoW /follow). + // The caller updates *targetPos every frame with the followed entity's render position. + // Stops within FOLLOW_STOP_DIST; cancels on manual WASD input. + void setAutoFollow(const glm::vec3* targetPos) { autoFollowTarget_ = targetPos; } + void cancelAutoFollow() { autoFollowTarget_ = nullptr; } + bool isAutoFollowing() const { return autoFollowTarget_ != nullptr; } + // Trigger mount jump (applies vertical velocity for physics hop) void triggerMountJump(); @@ -259,6 +271,11 @@ private: bool autoRunning = false; bool tildeWasDown = false; + // Auto-follow target position (WoW /follow). Non-null when following. + const glm::vec3* autoFollowTarget_ = nullptr; + static constexpr float FOLLOW_STOP_DIST = 3.0f; // Stop within 3 units of target + static constexpr float FOLLOW_MAX_DIST = 40.0f; // Cancel if > 40 units away + // Movement state tracking (for sending opcodes on state change) bool wasMovingForward = false; bool wasMovingBackward = false; @@ -278,6 +295,7 @@ private: // Movement callback MovementCallback movementCallback; StandUpCallback standUpCallback_; + AutoFollowCancelCallback autoFollowCancelCallback_; // Movement speeds bool useWoWSpeed = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 09f18146..5bf0d034 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -965,6 +965,11 @@ void Application::setState(AppState newState) { gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND) } }); + cc->setAutoFollowCancelCallback([this]() { + if (gameHandler) { + gameHandler->cancelFollow(); + } + }); cc->setUseWoWSpeed(true); } if (gameHandler) { @@ -983,6 +988,15 @@ void Application::setState(AppState newState) { renderer->getCameraController()->triggerShake(magnitude, frequency, duration); } }); + gameHandler->setAutoFollowCallback([this](const glm::vec3* renderPos) { + if (renderer && renderer->getCameraController()) { + if (renderPos) { + renderer->getCameraController()->setAutoFollow(renderPos); + } else { + renderer->getCameraController()->cancelAutoFollow(); + } + } + }); } // Load quest marker models loadQuestMarkerModels(); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index db638d70..18df947f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1379,6 +1379,17 @@ void GameHandler::update(float deltaTime) { clearTarget(); } + // Update auto-follow: refresh render position or cancel if entity disappeared + if (followTargetGuid_ != 0) { + auto followEnt = entityManager.getEntity(followTargetGuid_); + if (followEnt) { + followRenderPos_ = core::coords::canonicalToRender( + glm::vec3(followEnt->getX(), followEnt->getY(), followEnt->getZ())); + } else { + cancelFollow(); + } + } + // Detect combat state transitions → fire PLAYER_REGEN_DISABLED / PLAYER_REGEN_ENABLED { bool combatNow = isInCombat(); @@ -13213,6 +13224,14 @@ void GameHandler::followTarget() { // Set follow target followTargetGuid_ = targetGuid; + // Initialize render-space position from entity's canonical coords + followRenderPos_ = core::coords::canonicalToRender(glm::vec3(target->getX(), target->getY(), target->getZ())); + + // Tell camera controller to start auto-following + if (autoFollowCallback_) { + autoFollowCallback_(&followRenderPos_); + } + // Get target name std::string targetName = "Target"; if (target->getType() == ObjectType::PLAYER) { @@ -13232,10 +13251,12 @@ void GameHandler::followTarget() { void GameHandler::cancelFollow() { if (followTargetGuid_ == 0) { - addSystemChatMessage("You are not following anyone."); return; } followTargetGuid_ = 0; + if (autoFollowCallback_) { + autoFollowCallback_(nullptr); + } addSystemChatMessage("You stop following."); fireAddonEvent("AUTOFOLLOW_END", {}); } @@ -19146,6 +19167,32 @@ void GameHandler::leaveGroup() { fireAddonEvent("PARTY_MEMBERS_CHANGED", {}); } +void GameHandler::convertToRaid() { + if (!isInWorld()) return; + if (!isInGroup()) { + addSystemChatMessage("You are not in a group."); + return; + } + if (partyData.leaderGuid != getPlayerGuid()) { + addSystemChatMessage("You must be the party leader to convert to raid."); + return; + } + if (partyData.groupType == 1) { + addSystemChatMessage("You are already in a raid group."); + return; + } + auto packet = GroupRaidConvertPacket::build(); + socket->send(packet); + LOG_INFO("Sent CMSG_GROUP_RAID_CONVERT"); +} + +void GameHandler::sendSetLootMethod(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { + if (!isInWorld()) return; + auto packet = SetLootMethodPacket::build(method, threshold, masterLooterGuid); + socket->send(packet); + LOG_INFO("sendSetLootMethod: method=", method, " threshold=", threshold); +} + void GameHandler::handleGroupInvite(network::Packet& packet) { GroupInviteResponseData data; if (!GroupInviteResponseParser::parse(packet, data)) return; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 36d9fd17..82214d21 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1767,16 +1767,15 @@ network::Packet SetActiveMoverPacket::build(uint64_t guid) { network::Packet InspectPacket::build(uint64_t targetGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_INSPECT)); - packet.writeUInt64(targetGuid); + packet.writePackedGuid(targetGuid); LOG_DEBUG("Built CMSG_INSPECT: target=0x", std::hex, targetGuid, std::dec); return packet; } network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) { - // CMSG_QUERY_INSPECT_ACHIEVEMENTS: uint64 targetGuid + uint8 unk (always 0) + // CMSG_QUERY_INSPECT_ACHIEVEMENTS: PackedGuid targetGuid network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS)); - packet.writeUInt64(targetGuid); - packet.writeUInt8(0); // unk / achievementSlot — always 0 for WotLK + packet.writePackedGuid(targetGuid); LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec); return packet; } @@ -2474,6 +2473,22 @@ network::Packet GroupDisbandPacket::build() { return packet; } +network::Packet GroupRaidConvertPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_RAID_CONVERT)); + LOG_DEBUG("Built CMSG_GROUP_RAID_CONVERT"); + return packet; +} + +network::Packet SetLootMethodPacket::build(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_METHOD)); + packet.writeUInt32(method); + packet.writeUInt32(threshold); + packet.writeUInt64(masterLooterGuid); + LOG_DEBUG("Built CMSG_LOOT_METHOD: method=", method, " threshold=", threshold, + " masterLooter=0x", std::hex, masterLooterGuid, std::dec); + return packet; +} + network::Packet RaidTargetUpdatePacket::build(uint8_t targetIndex, uint64_t targetGuid) { network::Packet packet(wireOpcode(Opcode::MSG_RAID_TARGET_UPDATE)); packet.writeUInt8(targetIndex); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 2da20ebd..d0145ab6 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -283,17 +283,56 @@ void CameraController::update(float deltaTime) { autoRunning = !autoRunning; } tildeWasDown = tildeDown; + // Helper: cancel auto-follow and notify game handler + auto doCancelAutoFollow = [&]() { + if (autoFollowTarget_) { + autoFollowTarget_ = nullptr; + if (autoFollowCancelCallback_) autoFollowCancelCallback_(); + } + }; + if (keyW || keyS) { autoRunning = false; + doCancelAutoFollow(); } bool mouseAutorun = !uiWantsKeyboard && !sitting && leftMouseDown && rightMouseDown; if (mouseAutorun) { autoRunning = false; + doCancelAutoFollow(); } + + // Auto-follow: face target and walk forward when within range + bool autoFollowWalk = false; + if (autoFollowTarget_ && followTarget && !movementRooted_) { + glm::vec3 myPos = *followTarget; + glm::vec3 tgtPos = *autoFollowTarget_; + float dx = tgtPos.x - myPos.x; + float dy = tgtPos.y - myPos.y; + float dist2D = std::sqrt(dx * dx + dy * dy); + + if (dist2D > FOLLOW_MAX_DIST) { + doCancelAutoFollow(); + } else if (dist2D > FOLLOW_STOP_DIST) { + // Face target (render-space yaw: atan2(-dx, -dy) -> degrees) + float targetYawRad = std::atan2(-dx, -dy); + float targetYawDeg = targetYawRad * 180.0f / 3.14159265f; + facingYaw = targetYawDeg; + yaw = targetYawDeg; + autoFollowWalk = true; + } + // else: within stop distance, stay put + + // Cancel on strafe/turn keys + if (keyA || keyD || keyQ || keyE) { + doCancelAutoFollow(); + autoFollowWalk = false; + } + } + // When the server has rooted the player, suppress all horizontal movement input. const bool movBlocked = movementRooted_; - bool nowForward = !movBlocked && (keyW || mouseAutorun || autoRunning); + bool nowForward = !movBlocked && (keyW || mouseAutorun || autoRunning || autoFollowWalk); bool nowBackward = !movBlocked && keyS; bool nowStrafeLeft = false; bool nowStrafeRight = false; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2d92d2e3..394e1d29 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2535,12 +2535,13 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { "/i", "/ignore", "/inspect", "/instance", "/invite", "/j", "/join", "/kick", "/kneel", "/l", "/leave", "/leaveparty", "/loc", "/local", "/logout", + "/lootmethod", "/lootthreshold", "/macrohelp", "/mainassist", "/maintank", "/mark", "/me", "/notready", "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", "/played", "/pvp", - "/r", "/raid", "/raidinfo", "/raidwarning", "/random", "/ready", + "/r", "/raid", "/raidconvert", "/raidinfo", "/raidwarning", "/random", "/ready", "/readycheck", "/reload", "/reloadui", "/removefriend", "/reply", "/rl", "/roll", "/run", "/s", "/say", "/score", "/screenshot", "/script", "/setloot", @@ -6306,7 +6307,8 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", "Social: /who /friend add/remove /ignore /unignore", "Party: /invite /uninvite /leave /readycheck /mark /roll", - " /maintank /mainassist /raidinfo", + " /maintank /mainassist /raidconvert /raidinfo", + " /lootmethod /lootthreshold", "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", " /gleader /groster /ginfo /gcreate /gdisband", "Combat: /cast /castsequence /use /startattack /stopattack", @@ -7043,6 +7045,102 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "raidconvert") { + gameHandler.convertToRaid(); + chatInputBuffer[0] = '\0'; + return; + } + + // /lootmethod (or /grouploot, /setloot) — set party/raid loot method + if (cmdLower == "lootmethod" || cmdLower == "grouploot" || cmdLower == "setloot") { + if (!gameHandler.isInGroup()) { + gameHandler.addUIError("You are not in a group."); + } else if (spacePos == std::string::npos) { + // No argument — show current method and usage + static constexpr const char* kMethodNames[] = { + "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" + }; + const auto& pd = gameHandler.getPartyData(); + const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot method: ") + cur; + gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; + gameHandler.addLocalChatMessage(msg); + } else { + std::string arg = command.substr(spacePos + 1); + // Lowercase the argument + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + uint32_t method = 0xFFFFFFFF; + if (arg == "ffa" || arg == "freeforall") method = 0; + else if (arg == "roundrobin" || arg == "rr") method = 1; + else if (arg == "master" || arg == "masterloot") method = 2; + else if (arg == "group" || arg == "grouploot") method = 3; + else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; + + if (method == 0xFFFFFFFF) { + gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); + } else { + const auto& pd = gameHandler.getPartyData(); + // Master loot uses player guid as master looter; otherwise 0 + uint64_t masterGuid = (method == 2) ? gameHandler.getPlayerGuid() : 0; + gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); + } + } + chatInputBuffer[0] = '\0'; + return; + } + + // /lootthreshold — set minimum item quality for group loot rolls + if (cmdLower == "lootthreshold") { + if (!gameHandler.isInGroup()) { + gameHandler.addUIError("You are not in a group."); + } else if (spacePos == std::string::npos) { + const auto& pd = gameHandler.getPartyData(); + static constexpr const char* kQualityNames[] = { + "Poor (grey)", "Common (white)", "Uncommon (green)", + "Rare (blue)", "Epic (purple)", "Legendary (orange)" + }; + const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot threshold: ") + cur; + gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; + gameHandler.addLocalChatMessage(msg); + } else { + std::string arg = command.substr(spacePos + 1); + // Trim whitespace + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + uint32_t threshold = 0xFFFFFFFF; + if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { + threshold = static_cast(arg[0] - '0'); + } else { + // Accept quality names + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; + else if (arg == "common" || arg == "white") threshold = 1; + else if (arg == "uncommon" || arg == "green") threshold = 2; + else if (arg == "rare" || arg == "blue") threshold = 3; + else if (arg == "epic" || arg == "purple") threshold = 4; + else if (arg == "legendary" || arg == "orange") threshold = 5; + } + + if (threshold == 0xFFFFFFFF) { + gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); + } else { + const auto& pd = gameHandler.getPartyData(); + uint64_t masterGuid = (pd.lootMethod == 2) ? gameHandler.getPlayerGuid() : 0; + gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); + } + } + chatInputBuffer[0] = '\0'; + return; + } + // /mark [icon] — set or clear a raid target mark on the current target. // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear)