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
This commit is contained in:
Kelsi 2026-02-13 21:39:48 -08:00
parent 60c93fa1e3
commit 22728b461f
16 changed files with 951 additions and 26 deletions

View file

@ -1,7 +1,7 @@
{ {
"Spell": { "Spell": {
"ID": 0, "Attributes": 5, "IconID": 124, "ID": 0, "Attributes": 5, "IconID": 117,
"Name": 127, "Tooltip": 154, "Rank": 136 "Name": 120, "Tooltip": 147, "Rank": 129
}, },
"ItemDisplayInfo": { "ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "ID": 0, "LeftModel": 1, "LeftModelTexture": 3,

View file

@ -69,6 +69,12 @@
"CMSG_GUILD_MOTD": "0x091", "CMSG_GUILD_MOTD": "0x091",
"SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_INFO": "0x088",
"SMSG_GUILD_ROSTER": "0x08A", "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": "0x322",
"CMSG_DUEL_PROPOSED": "0x166", "CMSG_DUEL_PROPOSED": "0x166",
"CMSG_DUEL_ACCEPTED": "0x16C", "CMSG_DUEL_ACCEPTED": "0x16C",

View file

@ -71,6 +71,12 @@
"CMSG_GUILD_MOTD": "0x091", "CMSG_GUILD_MOTD": "0x091",
"SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_INFO": "0x088",
"SMSG_GUILD_ROSTER": "0x08A", "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": "0x322",
"MSG_RAID_READY_CHECK_CONFIRM": "0x3AE", "MSG_RAID_READY_CHECK_CONFIRM": "0x3AE",
"CMSG_DUEL_PROPOSED": "0x166", "CMSG_DUEL_PROPOSED": "0x166",

View file

@ -1,7 +1,7 @@
{ {
"Spell": { "Spell": {
"ID": 0, "Attributes": 5, "IconID": 124, "ID": 0, "Attributes": 5, "IconID": 117,
"Name": 127, "Tooltip": 154, "Rank": 136 "Name": 120, "Tooltip": 147, "Rank": 129
}, },
"ItemDisplayInfo": { "ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "ID": 0, "LeftModel": 1, "LeftModelTexture": 3,

View file

@ -69,6 +69,12 @@
"CMSG_GUILD_MOTD": "0x091", "CMSG_GUILD_MOTD": "0x091",
"SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_INFO": "0x088",
"SMSG_GUILD_ROSTER": "0x08A", "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": "0x322",
"CMSG_DUEL_PROPOSED": "0x166", "CMSG_DUEL_PROPOSED": "0x166",
"CMSG_DUEL_ACCEPTED": "0x16C", "CMSG_DUEL_ACCEPTED": "0x16C",

View file

@ -73,6 +73,12 @@
"CMSG_GUILD_MOTD": "0x091", "CMSG_GUILD_MOTD": "0x091",
"SMSG_GUILD_INFO": "0x088", "SMSG_GUILD_INFO": "0x088",
"SMSG_GUILD_ROSTER": "0x08A", "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": "0x322",
"MSG_RAID_READY_CHECK_CONFIRM": "0x3AE", "MSG_RAID_READY_CHECK_CONFIRM": "0x3AE",
"CMSG_DUEL_PROPOSED": "0x166", "CMSG_DUEL_PROPOSED": "0x166",

View file

@ -314,6 +314,19 @@ public:
void demoteGuildMember(const std::string& playerName); void demoteGuildMember(const std::string& playerName);
void leaveGuild(); void leaveGuild();
void inviteToGuild(const std::string& playerName); 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 // Ready check
void initiateReadyCheck(); void initiateReadyCheck();
@ -878,6 +891,14 @@ private:
void handleGroupUninvite(network::Packet& packet); void handleGroupUninvite(network::Packet& packet);
void handlePartyCommandResult(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 ---- // ---- Character creation handler ----
void handleCharCreateResponse(network::Packet& packet); void handleCharCreateResponse(network::Packet& packet);
@ -1155,6 +1176,15 @@ private:
bool pendingGroupInvite = false; bool pendingGroupInvite = false;
std::string pendingInviterName; std::string pendingInviterName;
// ---- Guild state ----
std::string guildName_;
std::vector<std::string> guildRankNames_;
GuildRosterData guildRoster_;
bool hasGuildRoster_ = false;
bool pendingGuildInvite_ = false;
std::string pendingGuildInviterName_;
std::string pendingGuildInviteGuildName_;
uint64_t activeCharacterGuid_ = 0; uint64_t activeCharacterGuid_ = 0;
Race playerRace_ = Race::HUMAN; Race playerRace_ = Race::HUMAN;

View file

@ -114,6 +114,18 @@ public:
return DestroyObjectParser::parse(packet, data); 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 --- // --- Utility ---
/** Read a packed GUID from the packet */ /** Read a packed GUID from the packet */
@ -190,6 +202,8 @@ public:
const MovementInfo& info, const MovementInfo& info,
uint64_t playerGuid = 0) override; uint64_t playerGuid = 0) override;
bool parseMessageChat(network::Packet& packet, MessageChatData& data) 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;
}; };
/** /**

View file

@ -880,6 +880,166 @@ public:
static network::Packet build(const std::string& playerName); 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<GuildRankInfo> ranks;
std::vector<GuildRosterMember> 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 // Ready Check
// ============================================================ // ============================================================

View file

@ -53,6 +53,7 @@ private:
bool showEntityWindow = false; bool showEntityWindow = false;
bool showChatWindow = true; bool showChatWindow = true;
bool showPlayerInfo = false; bool showPlayerInfo = false;
bool showGuildRoster_ = false;
bool refocusChatInput = false; bool refocusChatInput = false;
bool chatWindowLocked = true; bool chatWindowLocked = true;
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
@ -166,6 +167,8 @@ private:
void renderSettingsWindow(); void renderSettingsWindow();
void renderQuestMarkers(game::GameHandler& gameHandler); void renderQuestMarkers(game::GameHandler& gameHandler);
void renderMinimapMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler);
void renderGuildRoster(game::GameHandler& gameHandler);
void renderGuildInvitePopup(game::GameHandler& gameHandler);
/** /**
* Inventory screen * Inventory screen

View file

@ -879,6 +879,26 @@ void GameHandler::handlePacket(network::Packet& packet) {
handlePartyCommandResult(packet); handlePartyCommandResult(packet);
break; 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 ---- // ---- Phase 5: Loot/Gossip/Vendor ----
case Opcode::SMSG_LOOT_RESPONSE: case Opcode::SMSG_LOOT_RESPONSE:
handleLootResponse(packet); handleLootResponse(packet);
@ -1914,6 +1934,16 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
worldEntryCallback_(data.mapId, data.x, data.y, data.z); 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 we disconnected mid-taxi, attempt to recover to destination after login.
if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) { if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) {
float dx = movementInfo.x - taxiRecoverPos_.x; float dx = movementInfo.x - taxiRecoverPos_.x;
@ -2629,8 +2659,10 @@ void GameHandler::sendMovement(Opcode opcode) {
wireInfo.transportO = core::coords::normalizeAngleRad(-wireInfo.transportO); wireInfo.transportO = core::coords::normalizeAngleRad(-wireInfo.transportO);
} }
// Build and send movement packet // Build and send movement packet (expansion-specific format)
auto packet = MovementPacket::build(opcode, wireInfo, playerGuid); auto packet = packetParsers_
? packetParsers_->buildMovementPacket(opcode, wireInfo, playerGuid)
: MovementPacket::build(opcode, wireInfo, playerGuid);
socket->send(packet); 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 // Phase 5: Loot, Gossip, Vendor
// ============================================================ // ============================================================

View file

@ -378,6 +378,12 @@ void OpcodeTable::loadWotlkDefaults() {
{LogicalOpcode::CMSG_GUILD_MOTD, 0x091}, {LogicalOpcode::CMSG_GUILD_MOTD, 0x091},
{LogicalOpcode::SMSG_GUILD_INFO, 0x088}, {LogicalOpcode::SMSG_GUILD_INFO, 0x088},
{LogicalOpcode::SMSG_GUILD_ROSTER, 0x08A}, {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, 0x322},
{LogicalOpcode::MSG_RAID_READY_CHECK_CONFIRM, 0x3AE}, {LogicalOpcode::MSG_RAID_READY_CHECK_CONFIRM, 0x3AE},
{LogicalOpcode::CMSG_DUEL_PROPOSED, 0x166}, {LogicalOpcode::CMSG_DUEL_PROPOSED, 0x166},

View file

@ -444,5 +444,78 @@ bool ClassicPacketParsers::parseMessageChat(network::Packet& packet, MessageChat
return true; 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 game
} // namespace wowee } // namespace wowee

View file

@ -1531,6 +1531,151 @@ network::Packet GuildInvitePacket::build(const std::string& playerName) {
return packet; 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 // Ready Check
// ============================================================ // ============================================================

View file

@ -312,13 +312,29 @@ struct M2TextureTransformDisk {
M2TrackDisk scaling; // 20 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 { struct M2AttachmentDisk {
uint32_t id; uint32_t id;
uint16_t bone; uint16_t bone;
uint16_t unknown; uint16_t unknown;
float position[3]; float position[3];
uint8_t trackData[20]; // M2Track<uint8_t> — 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<typename T> template<typename T>
@ -454,6 +470,96 @@ void parseAnimTrack(const std::vector<uint8_t>& 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<uint8_t>& 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<uint32_t>(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<M2Range> ranges;
if (disk.nRanges > 0 && disk.ofsRanges > 0 &&
disk.nRanges < 4096 &&
disk.ofsRanges + disk.nRanges * sizeof(M2Range) <= data.size()) {
ranges = readArray<M2Range>(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<float>(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<Vec3Disk>(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<C4Quaternion>(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. // Parse an FBlock (particle lifetime curve) from a 16-byte on-disk header.
// FBlocks are like M2Track but WITHOUT the interpolationType/globalSequence prefix. // FBlocks are like M2Track but WITHOUT the interpolationType/globalSequence prefix.
void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset, void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset,
@ -803,11 +909,17 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
scale = db.scale; scale = db.scale;
} }
// Parse animation tracks (skip for vanilla — flat array format differs from WotLK) // Parse animation tracks
if (header.version >= 264) { if (header.version >= 264) {
parseAnimTrack(m2Data, translation, bone.translation, TrackType::VEC3, seqFlags); parseAnimTrack(m2Data, translation, bone.translation, TrackType::VEC3, seqFlags);
parseAnimTrack(m2Data, rotation, bone.rotation, TrackType::QUAT_COMPRESSED, seqFlags); parseAnimTrack(m2Data, rotation, bone.rotation, TrackType::QUAT_COMPRESSED, seqFlags);
parseAnimTrack(m2Data, scale, bone.scale, TrackType::VEC3, seqFlags); parseAnimTrack(m2Data, scale, bone.scale, TrackType::VEC3, seqFlags);
} else {
// Vanilla: flat array format with per-sequence ranges + Quaternion16
M2BoneDiskVanilla dbv = readValue<M2BoneDiskVanilla>(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()) { if (bone.translation.hasData() || bone.rotation.hasData() || bone.scale.hasData()) {
@ -860,8 +972,8 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
core::Logger::getInstance().debug(" Materials: ", model.materials.size()); core::Logger::getInstance().debug(" Materials: ", model.materials.size());
} }
// Read texture transforms (UV animation data) — skip for vanilla (different track format) // Read texture transforms (UV animation data)
if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0 && header.version >= 264) { if (header.nUVAnimation > 0 && header.ofsUVAnimation > 0) {
// Build per-sequence flags for skipping external .anim data // Build per-sequence flags for skipping external .anim data
std::vector<uint32_t> seqFlags; std::vector<uint32_t> seqFlags;
seqFlags.reserve(model.sequences.size()); seqFlags.reserve(model.sequences.size());
@ -869,16 +981,27 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
seqFlags.push_back(seq.flags); seqFlags.push_back(seq.flags);
} }
size_t uvStructSize = (header.version >= 264)
? sizeof(M2TextureTransformDisk)
: sizeof(M2TextureTransformDiskVanilla);
model.textureTransforms.reserve(header.nUVAnimation); model.textureTransforms.reserve(header.nUVAnimation);
for (uint32_t i = 0; i < header.nUVAnimation; i++) { for (uint32_t i = 0; i < header.nUVAnimation; i++) {
uint32_t ofs = header.ofsUVAnimation + i * sizeof(M2TextureTransformDisk); uint32_t ofs = header.ofsUVAnimation + i * uvStructSize;
if (ofs + sizeof(M2TextureTransformDisk) > m2Data.size()) break; if (ofs + uvStructSize > m2Data.size()) break;
M2TextureTransformDisk dt = readValue<M2TextureTransformDisk>(m2Data, ofs);
M2TextureTransform tt; M2TextureTransform tt;
parseAnimTrack(m2Data, dt.translation, tt.translation, TrackType::VEC3, seqFlags); if (header.version >= 264) {
parseAnimTrack(m2Data, dt.rotation, tt.rotation, TrackType::QUAT_COMPRESSED, seqFlags); M2TextureTransformDisk dt = readValue<M2TextureTransformDisk>(m2Data, ofs);
parseAnimTrack(m2Data, dt.scaling, tt.scale, TrackType::VEC3, seqFlags); 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<M2TextureTransformDiskVanilla>(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)); model.textureTransforms.push_back(std::move(tt));
} }
core::Logger::getInstance().debug(" Texture transforms: ", model.textureTransforms.size()); core::Logger::getInstance().debug(" Texture transforms: ", model.textureTransforms.size());
@ -889,16 +1012,27 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.textureTransformLookup = readArray<uint16_t>(m2Data, header.ofsTransLookup, header.nTransLookup); model.textureTransformLookup = readArray<uint16_t>(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) { if (header.nAttachments > 0 && header.ofsAttachments > 0) {
auto diskAttachments = readArray<M2AttachmentDisk>(m2Data, header.ofsAttachments, header.nAttachments); model.attachments.reserve(header.nAttachments);
model.attachments.reserve(diskAttachments.size()); if (header.version < 264) {
for (const auto& da : diskAttachments) { auto diskAttachments = readArray<M2AttachmentDiskVanilla>(m2Data, header.ofsAttachments, header.nAttachments);
M2Attachment att; for (const auto& da : diskAttachments) {
att.id = da.id; M2Attachment att;
att.bone = da.bone; att.id = da.id;
att.position = glm::vec3(da.position[0], da.position[1], da.position[2]); att.bone = da.bone;
model.attachments.push_back(att); att.position = glm::vec3(da.position[0], da.position[1], da.position[2]);
model.attachments.push_back(att);
}
} else {
auto diskAttachments = readArray<M2AttachmentDisk>(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()); core::Logger::getInstance().debug(" Attachments: ", model.attachments.size());
} }

View file

@ -170,6 +170,8 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderCombatText(gameHandler); renderCombatText(gameHandler);
renderPartyFrames(gameHandler); renderPartyFrames(gameHandler);
renderGroupInvitePopup(gameHandler); renderGroupInvitePopup(gameHandler);
renderGuildInvitePopup(gameHandler);
renderGuildRoster(gameHandler);
renderBuffBar(gameHandler); renderBuffBar(gameHandler);
renderLootWindow(gameHandler); renderLootWindow(gameHandler);
renderGossipWindow(gameHandler); renderGossipWindow(gameHandler);
@ -1589,6 +1591,24 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return; 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 <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /readycheck command // /readycheck command
if (cmdLower == "readycheck" || cmdLower == "rc") { if (cmdLower == "readycheck" || cmdLower == "rc") {
gameHandler.initiateReadyCheck(); gameHandler.initiateReadyCheck();
@ -3181,6 +3201,135 @@ void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) {
ImGui::End(); ImGui::End();
} }
void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingGuildInvite()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(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<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(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) // Buff/Debuff Bar (Phase 3)
// ============================================================ // ============================================================