From 22728b461fd20a02baefe8ed979408407dcb384c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Feb 2026 21:39:48 -0800 Subject: [PATCH] Fix vanilla M2 animations, movement packets, and DBC locale - Parse vanilla M2 animation tracks (flat arrays with M2Range indices) instead of skipping them, fixing T-pose on all vanilla models - Use C4Quaternion (float[4]) for vanilla bone rotations instead of CompressedQuat (int16[4]) which produced garbage transforms - Fix vanilla M2 attachment struct size (48 bytes, not 40) so weapons attach to correct bones instead of model origin - Route movement packets through expansion-specific packet parsers instead of hardcoded WotLK format, fixing server-side position sync - Fix Spell.dbc field indices for classic/turtle (Name=120, Rank=129, IconID=117) - were pointing to Portuguese locale column (+7 offset) - Change guild roster keybind from J to O (WoW default) - Add guild opcodes for all expansions --- Data/expansions/classic/dbc_layouts.json | 4 +- Data/expansions/classic/opcodes.json | 6 + Data/expansions/tbc/opcodes.json | 6 + Data/expansions/turtle/dbc_layouts.json | 4 +- Data/expansions/turtle/opcodes.json | 6 + Data/expansions/wotlk/opcodes.json | 6 + include/game/game_handler.hpp | 30 ++++ include/game/packet_parsers.hpp | 14 ++ include/game/world_packets.hpp | 160 +++++++++++++++++++ include/ui/game_screen.hpp | 3 + src/game/game_handler.cpp | 191 ++++++++++++++++++++++- src/game/opcode_table.cpp | 6 + src/game/packet_parsers_classic.cpp | 73 +++++++++ src/game/world_packets.cpp | 145 +++++++++++++++++ src/pipeline/m2_loader.cpp | 174 ++++++++++++++++++--- src/ui/game_screen.cpp | 149 ++++++++++++++++++ 16 files changed, 951 insertions(+), 26 deletions(-) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 8520a655..be0cd13e 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136 + "ID": 0, "Attributes": 5, "IconID": 117, + "Name": 120, "Tooltip": 147, "Rank": 129 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/classic/opcodes.json b/Data/expansions/classic/opcodes.json index d527f0e0..7afbbe3b 100644 --- a/Data/expansions/classic/opcodes.json +++ b/Data/expansions/classic/opcodes.json @@ -69,6 +69,12 @@ "CMSG_GUILD_MOTD": "0x091", "SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_ROSTER": "0x08A", + "CMSG_GUILD_QUERY": "0x051", + "SMSG_GUILD_QUERY_RESPONSE": "0x052", + "SMSG_GUILD_INVITE": "0x083", + "CMSG_GUILD_REMOVE": "0x08E", + "SMSG_GUILD_EVENT": "0x092", + "SMSG_GUILD_COMMAND_RESULT": "0x093", "MSG_RAID_READY_CHECK": "0x322", "CMSG_DUEL_PROPOSED": "0x166", "CMSG_DUEL_ACCEPTED": "0x16C", diff --git a/Data/expansions/tbc/opcodes.json b/Data/expansions/tbc/opcodes.json index ab1bb627..7ca7795d 100644 --- a/Data/expansions/tbc/opcodes.json +++ b/Data/expansions/tbc/opcodes.json @@ -71,6 +71,12 @@ "CMSG_GUILD_MOTD": "0x091", "SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_ROSTER": "0x08A", + "CMSG_GUILD_QUERY": "0x051", + "SMSG_GUILD_QUERY_RESPONSE": "0x052", + "SMSG_GUILD_INVITE": "0x083", + "CMSG_GUILD_REMOVE": "0x08E", + "SMSG_GUILD_EVENT": "0x092", + "SMSG_GUILD_COMMAND_RESULT": "0x093", "MSG_RAID_READY_CHECK": "0x322", "MSG_RAID_READY_CHECK_CONFIRM": "0x3AE", "CMSG_DUEL_PROPOSED": "0x166", diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 8520a655..be0cd13e 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136 + "ID": 0, "Attributes": 5, "IconID": 117, + "Name": 120, "Tooltip": 147, "Rank": 129 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index d527f0e0..7afbbe3b 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -69,6 +69,12 @@ "CMSG_GUILD_MOTD": "0x091", "SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_ROSTER": "0x08A", + "CMSG_GUILD_QUERY": "0x051", + "SMSG_GUILD_QUERY_RESPONSE": "0x052", + "SMSG_GUILD_INVITE": "0x083", + "CMSG_GUILD_REMOVE": "0x08E", + "SMSG_GUILD_EVENT": "0x092", + "SMSG_GUILD_COMMAND_RESULT": "0x093", "MSG_RAID_READY_CHECK": "0x322", "CMSG_DUEL_PROPOSED": "0x166", "CMSG_DUEL_ACCEPTED": "0x16C", diff --git a/Data/expansions/wotlk/opcodes.json b/Data/expansions/wotlk/opcodes.json index f082c82a..fb091a55 100644 --- a/Data/expansions/wotlk/opcodes.json +++ b/Data/expansions/wotlk/opcodes.json @@ -73,6 +73,12 @@ "CMSG_GUILD_MOTD": "0x091", "SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_ROSTER": "0x08A", + "CMSG_GUILD_QUERY": "0x051", + "SMSG_GUILD_QUERY_RESPONSE": "0x052", + "SMSG_GUILD_INVITE": "0x083", + "CMSG_GUILD_REMOVE": "0x08E", + "SMSG_GUILD_EVENT": "0x092", + "SMSG_GUILD_COMMAND_RESULT": "0x093", "MSG_RAID_READY_CHECK": "0x322", "MSG_RAID_READY_CHECK_CONFIRM": "0x3AE", "CMSG_DUEL_PROPOSED": "0x166", diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index b84b948f..1b609853 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -314,6 +314,19 @@ public: void demoteGuildMember(const std::string& playerName); void leaveGuild(); void inviteToGuild(const std::string& playerName); + void kickGuildMember(const std::string& playerName); + void acceptGuildInvite(); + void declineGuildInvite(); + void queryGuildInfo(uint32_t guildId); + + // Guild state accessors + bool isInGuild() const { return !guildName_.empty(); } + const std::string& getGuildName() const { return guildName_; } + const GuildRosterData& getGuildRoster() const { return guildRoster_; } + bool hasGuildRoster() const { return hasGuildRoster_; } + bool hasPendingGuildInvite() const { return pendingGuildInvite_; } + const std::string& getPendingGuildInviterName() const { return pendingGuildInviterName_; } + const std::string& getPendingGuildInviteGuildName() const { return pendingGuildInviteGuildName_; } // Ready check void initiateReadyCheck(); @@ -878,6 +891,14 @@ private: void handleGroupUninvite(network::Packet& packet); void handlePartyCommandResult(network::Packet& packet); + // ---- Guild handlers ---- + void handleGuildInfo(network::Packet& packet); + void handleGuildRoster(network::Packet& packet); + void handleGuildQueryResponse(network::Packet& packet); + void handleGuildEvent(network::Packet& packet); + void handleGuildInvite(network::Packet& packet); + void handleGuildCommandResult(network::Packet& packet); + // ---- Character creation handler ---- void handleCharCreateResponse(network::Packet& packet); @@ -1155,6 +1176,15 @@ private: bool pendingGroupInvite = false; std::string pendingInviterName; + // ---- Guild state ---- + std::string guildName_; + std::vector guildRankNames_; + GuildRosterData guildRoster_; + bool hasGuildRoster_ = false; + bool pendingGuildInvite_ = false; + std::string pendingGuildInviterName_; + std::string pendingGuildInviteGuildName_; + uint64_t activeCharacterGuid_ = 0; Race playerRace_ = Race::HUMAN; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index cd2a31c2..5652f6f8 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -114,6 +114,18 @@ public: return DestroyObjectParser::parse(packet, data); } + // --- Guild --- + + /** Parse SMSG_GUILD_ROSTER */ + virtual bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) { + return GuildRosterParser::parse(packet, data); + } + + /** Parse SMSG_GUILD_QUERY_RESPONSE */ + virtual bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) { + return GuildQueryResponseParser::parse(packet, data); + } + // --- Utility --- /** Read a packed GUID from the packet */ @@ -190,6 +202,8 @@ public: const MovementInfo& info, uint64_t playerGuid = 0) override; bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; + bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; + bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override; }; /** diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index ece7a142..ac75704d 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -880,6 +880,166 @@ public: static network::Packet build(const std::string& playerName); }; +/** CMSG_GUILD_QUERY packet builder */ +class GuildQueryPacket { +public: + static network::Packet build(uint32_t guildId); +}; + +/** CMSG_GUILD_REMOVE packet builder */ +class GuildRemovePacket { +public: + static network::Packet build(const std::string& playerName); +}; + +/** CMSG_GUILD_ACCEPT packet builder (empty body) */ +class GuildAcceptPacket { +public: + static network::Packet build(); +}; + +/** CMSG_GUILD_DECLINE_INVITATION packet builder (empty body) */ +class GuildDeclineInvitationPacket { +public: + static network::Packet build(); +}; + +// Guild event type constants +namespace GuildEvent { + constexpr uint8_t PROMOTION = 0; + constexpr uint8_t DEMOTION = 1; + constexpr uint8_t MOTD = 2; + constexpr uint8_t JOINED = 3; + constexpr uint8_t LEFT = 4; + constexpr uint8_t REMOVED = 5; + constexpr uint8_t LEADER_IS = 6; + constexpr uint8_t LEADER_CHANGED = 7; + constexpr uint8_t DISBANDED = 8; + constexpr uint8_t SIGNED_ON = 14; + constexpr uint8_t SIGNED_OFF = 15; +} + +/** SMSG_GUILD_QUERY_RESPONSE data */ +struct GuildQueryResponseData { + uint32_t guildId = 0; + std::string guildName; + std::string rankNames[10]; + uint32_t emblemStyle = 0; + uint32_t emblemColor = 0; + uint32_t borderStyle = 0; + uint32_t borderColor = 0; + uint32_t backgroundColor = 0; + uint32_t rankCount = 0; + + bool isValid() const { return guildId != 0 && !guildName.empty(); } +}; + +/** SMSG_GUILD_QUERY_RESPONSE parser */ +class GuildQueryResponseParser { +public: + static bool parse(network::Packet& packet, GuildQueryResponseData& data); +}; + +/** SMSG_GUILD_INFO data */ +struct GuildInfoData { + std::string guildName; + uint32_t creationDay = 0; + uint32_t creationMonth = 0; + uint32_t creationYear = 0; + uint32_t numMembers = 0; + uint32_t numAccounts = 0; + + bool isValid() const { return !guildName.empty(); } +}; + +/** SMSG_GUILD_INFO parser */ +class GuildInfoParser { +public: + static bool parse(network::Packet& packet, GuildInfoData& data); +}; + +/** Guild roster member entry */ +struct GuildRosterMember { + uint64_t guid = 0; + bool online = false; + std::string name; + uint32_t rankIndex = 0; + uint8_t level = 0; + uint8_t classId = 0; + uint8_t gender = 0; + uint32_t zoneId = 0; + float lastOnline = 0.0f; + std::string publicNote; + std::string officerNote; +}; + +/** Guild rank info */ +struct GuildRankInfo { + uint32_t rights = 0; + uint32_t goldLimit = 0; +}; + +/** SMSG_GUILD_ROSTER data */ +struct GuildRosterData { + std::string motd; + std::string guildInfo; + std::vector ranks; + std::vector members; + + bool isEmpty() const { return members.empty(); } +}; + +/** SMSG_GUILD_ROSTER parser */ +class GuildRosterParser { +public: + static bool parse(network::Packet& packet, GuildRosterData& data); +}; + +/** SMSG_GUILD_EVENT data */ +struct GuildEventData { + uint8_t eventType = 0; + uint8_t numStrings = 0; + std::string strings[3]; + uint64_t guid = 0; + + bool isValid() const { return true; } +}; + +/** SMSG_GUILD_EVENT parser */ +class GuildEventParser { +public: + static bool parse(network::Packet& packet, GuildEventData& data); +}; + +/** SMSG_GUILD_INVITE data */ +struct GuildInviteResponseData { + std::string inviterName; + std::string guildName; + + bool isValid() const { return !inviterName.empty(); } +}; + +/** SMSG_GUILD_INVITE parser */ +class GuildInviteResponseParser { +public: + static bool parse(network::Packet& packet, GuildInviteResponseData& data); +}; + +/** SMSG_GUILD_COMMAND_RESULT data */ +struct GuildCommandResultData { + uint32_t command = 0; + std::string name; + uint32_t errorCode = 0; + + bool isValid() const { return true; } +}; + +/** SMSG_GUILD_COMMAND_RESULT parser */ +class GuildCommandResultParser { +public: + static bool parse(network::Packet& packet, GuildCommandResultData& data); +}; + // ============================================================ // Ready Check // ============================================================ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 3a927adc..20c0d19e 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -53,6 +53,7 @@ private: bool showEntityWindow = false; bool showChatWindow = true; bool showPlayerInfo = false; + bool showGuildRoster_ = false; bool refocusChatInput = false; bool chatWindowLocked = true; ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); @@ -166,6 +167,8 @@ private: void renderSettingsWindow(); void renderQuestMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler); + void renderGuildRoster(game::GameHandler& gameHandler); + void renderGuildInvitePopup(game::GameHandler& gameHandler); /** * Inventory screen diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 2a20d852..53276882 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -879,6 +879,26 @@ void GameHandler::handlePacket(network::Packet& packet) { handlePartyCommandResult(packet); break; + // ---- Guild ---- + case Opcode::SMSG_GUILD_INFO: + handleGuildInfo(packet); + break; + case Opcode::SMSG_GUILD_ROSTER: + handleGuildRoster(packet); + break; + case Opcode::SMSG_GUILD_QUERY_RESPONSE: + handleGuildQueryResponse(packet); + break; + case Opcode::SMSG_GUILD_EVENT: + handleGuildEvent(packet); + break; + case Opcode::SMSG_GUILD_INVITE: + handleGuildInvite(packet); + break; + case Opcode::SMSG_GUILD_COMMAND_RESULT: + handleGuildCommandResult(packet); + break; + // ---- Phase 5: Loot/Gossip/Vendor ---- case Opcode::SMSG_LOOT_RESPONSE: handleLootResponse(packet); @@ -1914,6 +1934,16 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { worldEntryCallback_(data.mapId, data.x, data.y, data.z); } + // Auto-query guild info on login + const Character* activeChar = getActiveCharacter(); + if (activeChar && activeChar->hasGuild() && socket) { + auto gqPacket = GuildQueryPacket::build(activeChar->guildId); + socket->send(gqPacket); + auto grPacket = GuildRosterPacket::build(); + socket->send(grPacket); + LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + } + // If we disconnected mid-taxi, attempt to recover to destination after login. if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) { float dx = movementInfo.x - taxiRecoverPos_.x; @@ -2629,8 +2659,10 @@ void GameHandler::sendMovement(Opcode opcode) { wireInfo.transportO = core::coords::normalizeAngleRad(-wireInfo.transportO); } - // Build and send movement packet - auto packet = MovementPacket::build(opcode, wireInfo, playerGuid); + // Build and send movement packet (expansion-specific format) + auto packet = packetParsers_ + ? packetParsers_->buildMovementPacket(opcode, wireInfo, playerGuid) + : MovementPacket::build(opcode, wireInfo, playerGuid); socket->send(packet); } @@ -6481,6 +6513,161 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { } } +// ============================================================ +// Guild Handlers +// ============================================================ + +void GameHandler::kickGuildMember(const std::string& playerName) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GuildRemovePacket::build(playerName); + socket->send(packet); + LOG_INFO("Kicking guild member: ", playerName); +} + +void GameHandler::acceptGuildInvite() { + if (state != WorldState::IN_WORLD || !socket) return; + pendingGuildInvite_ = false; + auto packet = GuildAcceptPacket::build(); + socket->send(packet); + LOG_INFO("Accepted guild invite"); +} + +void GameHandler::declineGuildInvite() { + if (state != WorldState::IN_WORLD || !socket) return; + pendingGuildInvite_ = false; + auto packet = GuildDeclineInvitationPacket::build(); + socket->send(packet); + LOG_INFO("Declined guild invite"); +} + +void GameHandler::queryGuildInfo(uint32_t guildId) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GuildQueryPacket::build(guildId); + socket->send(packet); + LOG_INFO("Querying guild info: guildId=", guildId); +} + +void GameHandler::handleGuildInfo(network::Packet& packet) { + GuildInfoData data; + if (!GuildInfoParser::parse(packet, data)) return; + + addSystemChatMessage("Guild: " + data.guildName + " (" + + std::to_string(data.numMembers) + " members, " + + std::to_string(data.numAccounts) + " accounts)"); +} + +void GameHandler::handleGuildRoster(network::Packet& packet) { + GuildRosterData data; + if (!packetParsers_->parseGuildRoster(packet, data)) return; + + guildRoster_ = std::move(data); + hasGuildRoster_ = true; + LOG_INFO("Guild roster received: ", guildRoster_.members.size(), " members"); +} + +void GameHandler::handleGuildQueryResponse(network::Packet& packet) { + GuildQueryResponseData data; + if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; + + guildName_ = data.guildName; + guildRankNames_.clear(); + for (uint32_t i = 0; i < 10; ++i) { + guildRankNames_.push_back(data.rankNames[i]); + } + LOG_INFO("Guild name set to: ", guildName_); + addSystemChatMessage("Guild: <" + guildName_ + ">"); +} + +void GameHandler::handleGuildEvent(network::Packet& packet) { + GuildEventData data; + if (!GuildEventParser::parse(packet, data)) return; + + std::string msg; + switch (data.eventType) { + case GuildEvent::PROMOTION: + if (data.numStrings >= 3) + msg = data.strings[0] + " has promoted " + data.strings[1] + " to " + data.strings[2] + "."; + break; + case GuildEvent::DEMOTION: + if (data.numStrings >= 3) + msg = data.strings[0] + " has demoted " + data.strings[1] + " to " + data.strings[2] + "."; + break; + case GuildEvent::MOTD: + if (data.numStrings >= 1) + msg = "Guild MOTD: " + data.strings[0]; + break; + case GuildEvent::JOINED: + if (data.numStrings >= 1) + msg = data.strings[0] + " has joined the guild."; + break; + case GuildEvent::LEFT: + if (data.numStrings >= 1) + msg = data.strings[0] + " has left the guild."; + break; + case GuildEvent::REMOVED: + if (data.numStrings >= 2) + msg = data.strings[1] + " has been kicked from the guild by " + data.strings[0] + "."; + break; + case GuildEvent::LEADER_IS: + if (data.numStrings >= 1) + msg = data.strings[0] + " is the guild leader."; + break; + case GuildEvent::LEADER_CHANGED: + if (data.numStrings >= 2) + msg = data.strings[0] + " has made " + data.strings[1] + " the new guild leader."; + break; + case GuildEvent::DISBANDED: + msg = "Guild has been disbanded."; + guildName_.clear(); + guildRankNames_.clear(); + guildRoster_ = GuildRosterData{}; + hasGuildRoster_ = false; + break; + case GuildEvent::SIGNED_ON: + if (data.numStrings >= 1) + msg = "[Guild] " + data.strings[0] + " has come online."; + break; + case GuildEvent::SIGNED_OFF: + if (data.numStrings >= 1) + msg = "[Guild] " + data.strings[0] + " has gone offline."; + break; + default: + msg = "Guild event " + std::to_string(data.eventType); + break; + } + + if (!msg.empty()) { + MessageChatData chatMsg; + chatMsg.type = ChatType::GUILD; + chatMsg.language = ChatLanguage::UNIVERSAL; + chatMsg.message = msg; + addLocalChatMessage(chatMsg); + } +} + +void GameHandler::handleGuildInvite(network::Packet& packet) { + GuildInviteResponseData data; + if (!GuildInviteResponseParser::parse(packet, data)) return; + + pendingGuildInvite_ = true; + pendingGuildInviterName_ = data.inviterName; + pendingGuildInviteGuildName_ = data.guildName; + LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName); + addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + "."); +} + +void GameHandler::handleGuildCommandResult(network::Packet& packet) { + GuildCommandResultData data; + if (!GuildCommandResultParser::parse(packet, data)) return; + + if (data.errorCode != 0) { + std::string msg = "Guild command failed"; + if (!data.name.empty()) msg += " for " + data.name; + msg += " (error " + std::to_string(data.errorCode) + ")"; + addSystemChatMessage(msg); + } +} + // ============================================================ // Phase 5: Loot, Gossip, Vendor // ============================================================ diff --git a/src/game/opcode_table.cpp b/src/game/opcode_table.cpp index 8a8d64fa..74a132f6 100644 --- a/src/game/opcode_table.cpp +++ b/src/game/opcode_table.cpp @@ -378,6 +378,12 @@ void OpcodeTable::loadWotlkDefaults() { {LogicalOpcode::CMSG_GUILD_MOTD, 0x091}, {LogicalOpcode::SMSG_GUILD_INFO, 0x088}, {LogicalOpcode::SMSG_GUILD_ROSTER, 0x08A}, + {LogicalOpcode::CMSG_GUILD_QUERY, 0x051}, + {LogicalOpcode::SMSG_GUILD_QUERY_RESPONSE, 0x052}, + {LogicalOpcode::SMSG_GUILD_INVITE, 0x083}, + {LogicalOpcode::CMSG_GUILD_REMOVE, 0x08E}, + {LogicalOpcode::SMSG_GUILD_EVENT, 0x092}, + {LogicalOpcode::SMSG_GUILD_COMMAND_RESULT, 0x093}, {LogicalOpcode::MSG_RAID_READY_CHECK, 0x322}, {LogicalOpcode::MSG_RAID_READY_CHECK_CONFIRM, 0x3AE}, {LogicalOpcode::CMSG_DUEL_PROPOSED, 0x166}, diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 5ae99e6a..6b129887 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -444,5 +444,78 @@ bool ClassicPacketParsers::parseMessageChat(network::Packet& packet, MessageChat return true; } +// ============================================================================ +// Classic guild roster parser +// Differences from WotLK: +// - No rankCount field (fixed 10 ranks, read rights only) +// - No per-rank bank tab data +// - No gender byte per member +// ============================================================================ + +bool ClassicPacketParsers::parseGuildRoster(network::Packet& packet, GuildRosterData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("Classic SMSG_GUILD_ROSTER too small: ", packet.getSize()); + return false; + } + uint32_t numMembers = packet.readUInt32(); + data.motd = packet.readString(); + data.guildInfo = packet.readString(); + + // Classic: fixed 10 ranks, just uint32 rights each (no goldLimit, no bank tabs) + data.ranks.resize(10); + for (int i = 0; i < 10; ++i) { + data.ranks[i].rights = packet.readUInt32(); + data.ranks[i].goldLimit = 0; + } + + data.members.resize(numMembers); + for (uint32_t i = 0; i < numMembers; ++i) { + auto& m = data.members[i]; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + m.name = packet.readString(); + m.rankIndex = packet.readUInt32(); + m.level = packet.readUInt8(); + m.classId = packet.readUInt8(); + // Classic: NO gender byte + m.gender = 0; + m.zoneId = packet.readUInt32(); + if (!m.online) { + m.lastOnline = packet.readFloat(); + } + m.publicNote = packet.readString(); + m.officerNote = packet.readString(); + } + LOG_INFO("Parsed Classic SMSG_GUILD_ROSTER: ", numMembers, " members"); + return true; +} + +// ============================================================================ +// Classic guild query response parser +// Differences from WotLK: +// - No trailing rankCount uint32 +// ============================================================================ + +bool ClassicPacketParsers::parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) { + if (packet.getSize() < 8) { + LOG_ERROR("Classic SMSG_GUILD_QUERY_RESPONSE too small: ", packet.getSize()); + return false; + } + data.guildId = packet.readUInt32(); + data.guildName = packet.readString(); + for (int i = 0; i < 10; ++i) { + data.rankNames[i] = packet.readString(); + } + data.emblemStyle = packet.readUInt32(); + data.emblemColor = packet.readUInt32(); + data.borderStyle = packet.readUInt32(); + data.borderColor = packet.readUInt32(); + data.backgroundColor = packet.readUInt32(); + // Classic: NO trailing rankCount + data.rankCount = 10; + LOG_INFO("Parsed Classic SMSG_GUILD_QUERY_RESPONSE: guild=", data.guildName); + return true; +} + } // namespace game } // namespace wowee diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5d13485f..5f610d92 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1531,6 +1531,151 @@ network::Packet GuildInvitePacket::build(const std::string& playerName) { return packet; } +network::Packet GuildQueryPacket::build(uint32_t guildId) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_QUERY)); + packet.writeUInt32(guildId); + LOG_DEBUG("Built CMSG_GUILD_QUERY: guildId=", guildId); + return packet; +} + +network::Packet GuildRemovePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_REMOVE)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GUILD_REMOVE: ", playerName); + return packet; +} + +network::Packet GuildAcceptPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ACCEPT)); + LOG_DEBUG("Built CMSG_GUILD_ACCEPT"); + return packet; +} + +network::Packet GuildDeclineInvitationPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DECLINE_INVITATION)); + LOG_DEBUG("Built CMSG_GUILD_DECLINE_INVITATION"); + return packet; +} + +bool GuildQueryResponseParser::parse(network::Packet& packet, GuildQueryResponseData& data) { + if (packet.getSize() < 8) { + LOG_ERROR("SMSG_GUILD_QUERY_RESPONSE too small: ", packet.getSize()); + return false; + } + data.guildId = packet.readUInt32(); + data.guildName = packet.readString(); + for (int i = 0; i < 10; ++i) { + data.rankNames[i] = packet.readString(); + } + data.emblemStyle = packet.readUInt32(); + data.emblemColor = packet.readUInt32(); + data.borderStyle = packet.readUInt32(); + data.borderColor = packet.readUInt32(); + data.backgroundColor = packet.readUInt32(); + if ((packet.getSize() - packet.getReadPos()) >= 4) { + data.rankCount = packet.readUInt32(); + } + LOG_INFO("Parsed SMSG_GUILD_QUERY_RESPONSE: guild=", data.guildName, " id=", data.guildId); + return true; +} + +bool GuildInfoParser::parse(network::Packet& packet, GuildInfoData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_GUILD_INFO too small: ", packet.getSize()); + return false; + } + data.guildName = packet.readString(); + data.creationDay = packet.readUInt32(); + data.creationMonth = packet.readUInt32(); + data.creationYear = packet.readUInt32(); + data.numMembers = packet.readUInt32(); + data.numAccounts = packet.readUInt32(); + LOG_INFO("Parsed SMSG_GUILD_INFO: ", data.guildName, " members=", data.numMembers); + return true; +} + +bool GuildRosterParser::parse(network::Packet& packet, GuildRosterData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_GUILD_ROSTER too small: ", packet.getSize()); + return false; + } + uint32_t numMembers = packet.readUInt32(); + data.motd = packet.readString(); + data.guildInfo = packet.readString(); + + uint32_t rankCount = packet.readUInt32(); + data.ranks.resize(rankCount); + for (uint32_t i = 0; i < rankCount; ++i) { + data.ranks[i].rights = packet.readUInt32(); + data.ranks[i].goldLimit = packet.readUInt32(); + // 6 bank tab flags + 6 bank tab items per day + for (int t = 0; t < 6; ++t) { + packet.readUInt32(); // tabFlags + packet.readUInt32(); // tabItemsPerDay + } + } + + data.members.resize(numMembers); + for (uint32_t i = 0; i < numMembers; ++i) { + auto& m = data.members[i]; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + m.name = packet.readString(); + m.rankIndex = packet.readUInt32(); + m.level = packet.readUInt8(); + m.classId = packet.readUInt8(); + m.gender = packet.readUInt8(); + m.zoneId = packet.readUInt32(); + if (!m.online) { + m.lastOnline = packet.readFloat(); + } + m.publicNote = packet.readString(); + m.officerNote = packet.readString(); + } + LOG_INFO("Parsed SMSG_GUILD_ROSTER: ", numMembers, " members, motd=", data.motd); + return true; +} + +bool GuildEventParser::parse(network::Packet& packet, GuildEventData& data) { + if (packet.getSize() < 2) { + LOG_ERROR("SMSG_GUILD_EVENT too small: ", packet.getSize()); + return false; + } + data.eventType = packet.readUInt8(); + data.numStrings = packet.readUInt8(); + for (uint8_t i = 0; i < data.numStrings && i < 3; ++i) { + data.strings[i] = packet.readString(); + } + if ((packet.getSize() - packet.getReadPos()) >= 8) { + data.guid = packet.readUInt64(); + } + LOG_INFO("Parsed SMSG_GUILD_EVENT: type=", (int)data.eventType, " strings=", (int)data.numStrings); + return true; +} + +bool GuildInviteResponseParser::parse(network::Packet& packet, GuildInviteResponseData& data) { + if (packet.getSize() < 2) { + LOG_ERROR("SMSG_GUILD_INVITE too small: ", packet.getSize()); + return false; + } + data.inviterName = packet.readString(); + data.guildName = packet.readString(); + LOG_INFO("Parsed SMSG_GUILD_INVITE: from=", data.inviterName, " guild=", data.guildName); + return true; +} + +bool GuildCommandResultParser::parse(network::Packet& packet, GuildCommandResultData& data) { + if (packet.getSize() < 8) { + LOG_ERROR("SMSG_GUILD_COMMAND_RESULT too small: ", packet.getSize()); + return false; + } + data.command = packet.readUInt32(); + data.name = packet.readString(); + data.errorCode = packet.readUInt32(); + LOG_INFO("Parsed SMSG_GUILD_COMMAND_RESULT: cmd=", data.command, " error=", data.errorCode); + return true; +} + // ============================================================ // Ready Check // ============================================================ diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 29ff9ba9..a5dea683 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -312,13 +312,29 @@ struct M2TextureTransformDisk { M2TrackDisk scaling; // 20 }; -// M2 attachment point (on-disk) +// Vanilla M2 texture transform (3 × 28-byte tracks = 84 bytes) +struct M2TextureTransformDiskVanilla { + M2TrackDiskVanilla translation; // 28 + M2TrackDiskVanilla rotation; // 28 + M2TrackDiskVanilla scaling; // 28 +}; + +// M2 attachment point (on-disk, WotLK — 40 bytes) struct M2AttachmentDisk { uint32_t id; uint16_t bone; uint16_t unknown; float position[3]; - uint8_t trackData[20]; // M2Track — skip + uint8_t trackData[20]; // M2TrackDisk (20 bytes) +}; + +// M2 attachment point (on-disk, vanilla — 48 bytes, track is 28 bytes) +struct M2AttachmentDiskVanilla { + uint32_t id; + uint16_t bone; + uint16_t unknown; + float position[3]; + uint8_t trackData[28]; // M2TrackDiskVanilla (28 bytes) }; template @@ -454,6 +470,96 @@ void parseAnimTrack(const std::vector& data, } } +// Vanilla M2 range: indices into flat timestamp/key arrays for a given sequence +struct M2Range { uint32_t start; uint32_t end; }; + +// Parse a vanilla M2 animation track (version < 264). +// Vanilla uses flat arrays with per-sequence M2Range indices, unlike WotLK's array-of-arrays. +// Vanilla also uses Quaternion16 (simple x/32767) instead of WotLK's CompressedQuaternion. +void parseAnimTrackVanilla(const std::vector& data, + const M2TrackDiskVanilla& disk, + M2AnimationTrack& track, + TrackType type) { + track.interpolationType = disk.interpolationType; + track.globalSequence = disk.globalSequence; + + if (disk.nTimestamps == 0 || disk.nKeys == 0) return; + // Sanity caps + if (disk.nTimestamps > 100000 || disk.nKeys > 100000) return; + + // Validate flat timestamp array + if (disk.ofsTimestamps + disk.nTimestamps * sizeof(uint32_t) > data.size()) return; + auto allTimestamps = readArray(data, disk.ofsTimestamps, disk.nTimestamps); + + // Validate flat key array + // Vanilla stores rotations as full float quaternions (16 bytes), NOT compressed int16 (8 bytes) + size_t keySize; + if (type == TrackType::FLOAT) keySize = sizeof(float); + else if (type == TrackType::VEC3) keySize = sizeof(float) * 3; + else keySize = sizeof(float) * 4; // C4Quaternion (float[4]) in vanilla + if (disk.ofsKeys + disk.nKeys * keySize > data.size()) return; + + // Read per-sequence ranges + std::vector ranges; + if (disk.nRanges > 0 && disk.ofsRanges > 0 && + disk.nRanges < 4096 && + disk.ofsRanges + disk.nRanges * sizeof(M2Range) <= data.size()) { + ranges = readArray(data, disk.ofsRanges, disk.nRanges); + } + + // If no ranges, treat entire array as one sequence + if (ranges.empty()) { + ranges.push_back({0, disk.nTimestamps}); + } + + track.sequences.resize(ranges.size()); + + for (size_t i = 0; i < ranges.size(); i++) { + uint32_t start = ranges[i].start; + uint32_t end = ranges[i].end; + if (start >= end || start >= disk.nTimestamps) continue; + end = std::min(end, disk.nTimestamps); + + // Copy timestamps for this sequence + track.sequences[i].timestamps.assign( + allTimestamps.begin() + start, allTimestamps.begin() + end); + + // Copy key values for this sequence + if (start >= disk.nKeys) continue; + uint32_t keyEnd = std::min(end, disk.nKeys); + uint32_t keyCount = keyEnd - start; + + if (type == TrackType::FLOAT) { + auto allValues = readArray(data, disk.ofsKeys, disk.nKeys); + track.sequences[i].floatValues.assign( + allValues.begin() + start, allValues.begin() + start + keyCount); + } else if (type == TrackType::VEC3) { + struct Vec3Disk { float x, y, z; }; + auto allValues = readArray(data, disk.ofsKeys, disk.nKeys); + track.sequences[i].vec3Values.reserve(keyCount); + for (uint32_t k = start; k < start + keyCount; k++) { + track.sequences[i].vec3Values.emplace_back( + allValues[k].x, allValues[k].y, allValues[k].z); + } + } else { + // Vanilla: C4Quaternion — full float[4] per key (XYZW on disk) + // NOT compressed int16 like WotLK + struct C4Quaternion { float x, y, z, w; }; + auto allQ = readArray(data, disk.ofsKeys, disk.nKeys); + track.sequences[i].quatValues.reserve(keyCount); + for (uint32_t k = start; k < start + keyCount; k++) { + const auto& fq = allQ[k]; + // Disk order: XYZW, glm::quat constructor: (w, x, y, z) + glm::quat q(fq.w, fq.x, fq.y, fq.z); + float len = glm::length(q); + if (len > 0.001f) q = q / len; + else q = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); + track.sequences[i].quatValues.push_back(q); + } + } + } +} + // Parse an FBlock (particle lifetime curve) from a 16-byte on-disk header. // FBlocks are like M2Track but WITHOUT the interpolationType/globalSequence prefix. void parseFBlock(const std::vector& data, uint32_t offset, @@ -803,11 +909,17 @@ M2Model M2Loader::load(const std::vector& m2Data) { scale = db.scale; } - // Parse animation tracks (skip for vanilla — flat array format differs from WotLK) + // Parse animation tracks if (header.version >= 264) { parseAnimTrack(m2Data, translation, bone.translation, TrackType::VEC3, seqFlags); parseAnimTrack(m2Data, rotation, bone.rotation, TrackType::QUAT_COMPRESSED, seqFlags); parseAnimTrack(m2Data, scale, bone.scale, TrackType::VEC3, seqFlags); + } else { + // Vanilla: flat array format with per-sequence ranges + Quaternion16 + M2BoneDiskVanilla dbv = readValue(m2Data, boneOffset); + parseAnimTrackVanilla(m2Data, dbv.translation, bone.translation, TrackType::VEC3); + parseAnimTrackVanilla(m2Data, dbv.rotation, bone.rotation, TrackType::QUAT_COMPRESSED); + parseAnimTrackVanilla(m2Data, dbv.scale, bone.scale, TrackType::VEC3); } if (bone.translation.hasData() || bone.rotation.hasData() || bone.scale.hasData()) { @@ -860,8 +972,8 @@ M2Model M2Loader::load(const std::vector& m2Data) { core::Logger::getInstance().debug(" Materials: ", model.materials.size()); } - // Read texture transforms (UV animation data) — skip for vanilla (different track format) - if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0 && header.version >= 264) { + // Read texture transforms (UV animation data) + if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0) { // Build per-sequence flags for skipping external .anim data std::vector seqFlags; seqFlags.reserve(model.sequences.size()); @@ -869,16 +981,27 @@ M2Model M2Loader::load(const std::vector& m2Data) { seqFlags.push_back(seq.flags); } + size_t uvStructSize = (header.version >= 264) + ? sizeof(M2TextureTransformDisk) + : sizeof(M2TextureTransformDiskVanilla); + model.textureTransforms.reserve(header.nUVAnimation); for (uint32_t i = 0; i < header.nUVAnimation; i++) { - uint32_t ofs = header.ofsUVAnimation + i * sizeof(M2TextureTransformDisk); - if (ofs + sizeof(M2TextureTransformDisk) > m2Data.size()) break; + uint32_t ofs = header.ofsUVAnimation + i * uvStructSize; + if (ofs + uvStructSize > m2Data.size()) break; - M2TextureTransformDisk dt = readValue(m2Data, ofs); M2TextureTransform tt; - parseAnimTrack(m2Data, dt.translation, tt.translation, TrackType::VEC3, seqFlags); - parseAnimTrack(m2Data, dt.rotation, tt.rotation, TrackType::QUAT_COMPRESSED, seqFlags); - parseAnimTrack(m2Data, dt.scaling, tt.scale, TrackType::VEC3, seqFlags); + if (header.version >= 264) { + M2TextureTransformDisk dt = readValue(m2Data, ofs); + parseAnimTrack(m2Data, dt.translation, tt.translation, TrackType::VEC3, seqFlags); + parseAnimTrack(m2Data, dt.rotation, tt.rotation, TrackType::QUAT_COMPRESSED, seqFlags); + parseAnimTrack(m2Data, dt.scaling, tt.scale, TrackType::VEC3, seqFlags); + } else { + M2TextureTransformDiskVanilla dt = readValue(m2Data, ofs); + parseAnimTrackVanilla(m2Data, dt.translation, tt.translation, TrackType::VEC3); + parseAnimTrackVanilla(m2Data, dt.rotation, tt.rotation, TrackType::QUAT_COMPRESSED); + parseAnimTrackVanilla(m2Data, dt.scaling, tt.scale, TrackType::VEC3); + } model.textureTransforms.push_back(std::move(tt)); } core::Logger::getInstance().debug(" Texture transforms: ", model.textureTransforms.size()); @@ -889,16 +1012,27 @@ M2Model M2Loader::load(const std::vector& m2Data) { model.textureTransformLookup = readArray(m2Data, header.ofsTransLookup, header.nTransLookup); } - // Read attachment points + // Read attachment points (vanilla uses 48-byte struct, WotLK uses 40-byte) if (header.nAttachments > 0 && header.ofsAttachments > 0) { - auto diskAttachments = readArray(m2Data, header.ofsAttachments, header.nAttachments); - model.attachments.reserve(diskAttachments.size()); - for (const auto& da : diskAttachments) { - M2Attachment att; - att.id = da.id; - att.bone = da.bone; - att.position = glm::vec3(da.position[0], da.position[1], da.position[2]); - model.attachments.push_back(att); + model.attachments.reserve(header.nAttachments); + if (header.version < 264) { + auto diskAttachments = readArray(m2Data, header.ofsAttachments, header.nAttachments); + for (const auto& da : diskAttachments) { + M2Attachment att; + att.id = da.id; + att.bone = da.bone; + att.position = glm::vec3(da.position[0], da.position[1], da.position[2]); + model.attachments.push_back(att); + } + } else { + auto diskAttachments = readArray(m2Data, header.ofsAttachments, header.nAttachments); + for (const auto& da : diskAttachments) { + M2Attachment att; + att.id = da.id; + att.bone = da.bone; + att.position = glm::vec3(da.position[0], da.position[1], da.position[2]); + model.attachments.push_back(att); + } } core::Logger::getInstance().debug(" Attachments: ", model.attachments.size()); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d27467e3..146423f4 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -170,6 +170,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderCombatText(gameHandler); renderPartyFrames(gameHandler); renderGroupInvitePopup(gameHandler); + renderGuildInvitePopup(gameHandler); + renderGuildRoster(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); renderGossipWindow(gameHandler); @@ -1589,6 +1591,24 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /gkick command + if (cmdLower == "gkick" || cmdLower == "guildkick") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.kickGuildMember(playerName); + chatInputBuffer[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gkick "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + // /readycheck command if (cmdLower == "readycheck" || cmdLower == "rc") { gameHandler.initiateReadyCheck(); @@ -3181,6 +3201,135 @@ void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingGuildInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 250), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Guild Invite", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::TextWrapped("%s has invited you to join %s.", + gameHandler.getPendingGuildInviterName().c_str(), + gameHandler.getPendingGuildInviteGuildName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(155, 30))) { + gameHandler.acceptGuildInvite(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(155, 30))) { + gameHandler.declineGuildInvite(); + } + } + ImGui::End(); +} + +void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { + // O key toggle (WoW default Social/Guild keybind) + if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { + showGuildRoster_ = !showGuildRoster_; + if (showGuildRoster_) { + if (!gameHandler.isInGuild()) { + gameHandler.addLocalChatMessage(game::MessageChatData{ + game::ChatType::SYSTEM, game::ChatLanguage::UNIVERSAL, 0, "", 0, "", "You are not in a guild.", "", 0}); + showGuildRoster_ = false; + return; + } + gameHandler.requestGuildRoster(); + } + } + + if (!showGuildRoster_) 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; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 300, screenH / 2 - 250), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_Once); + + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Roster") : "Guild Roster"; + bool open = showGuildRoster_; + if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { + if (!gameHandler.hasGuildRoster()) { + ImGui::Text("Loading roster..."); + } else { + const auto& roster = gameHandler.getGuildRoster(); + + // MOTD + if (!roster.motd.empty()) { + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); + ImGui::Separator(); + } + + // Count online + int onlineCount = 0; + for (const auto& m : roster.members) { + if (m.online) ++onlineCount; + } + ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount); + ImGui::Separator(); + + // Table + if (ImGui::BeginTable("GuildRoster", 6, + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); + ImGui::TableSetupColumn("Rank"); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Note"); + ImGui::TableHeadersRow(); + + // Online members first, then offline + auto sortedMembers = roster.members; + std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { + if (a.online != b.online) return a.online > b.online; + return a.name < b.name; + }); + + static const char* classNames[] = { + "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", + "Priest", "Death Knight", "Shaman", "Mage", "Warlock", + "", "Druid" + }; + + for (const auto& m : sortedMembers) { + ImGui::TableNextRow(); + ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.name.c_str()); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%u", m.rankIndex); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%u", m.level); + + ImGui::TableNextColumn(); + const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; + ImGui::TextColored(textColor, "%s", className); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%u", m.zoneId); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); + } + ImGui::EndTable(); + } + } + } + ImGui::End(); + showGuildRoster_ = open; +} + // ============================================================ // Buff/Debuff Bar (Phase 3) // ============================================================