Add chat channels, chat settings, and fix missing chat text

Fix WotLK chat parser not stripping null terminators from messages,
fix channel message local echo missing channelName, expand default
channels to General/Trade/LocalDefense/LookingForGroup with
configurable auto-join, add Classic packet format for join/leave
channel, display channel index prefix in chat, and add Chat settings
tab with timestamps, font size, and auto-join toggles.
This commit is contained in:
Kelsi 2026-02-14 18:27:59 -08:00
parent bdedab7c1b
commit c2467cf2fc
8 changed files with 197 additions and 7 deletions

View file

@ -234,6 +234,16 @@ public:
void leaveChannel(const std::string& channelName); void leaveChannel(const std::string& channelName);
const std::vector<std::string>& getJoinedChannels() const { return joinedChannels_; } const std::vector<std::string>& getJoinedChannels() const { return joinedChannels_; }
std::string getChannelByIndex(int index) const; std::string getChannelByIndex(int index) const;
int getChannelIndex(const std::string& channelName) const;
// Chat auto-join settings (set by UI before autoJoinDefaultChannels)
struct ChatAutoJoin {
bool general = true;
bool trade = true;
bool localDefense = true;
bool lfg = true;
};
ChatAutoJoin chatAutoJoin;
// Chat bubble callback: (senderGuid, message, isYell) // Chat bubble callback: (senderGuid, message, isYell)
using ChatBubbleCallback = std::function<void(uint64_t, const std::string&, bool)>; using ChatBubbleCallback = std::function<void(uint64_t, const std::string&, bool)>;

View file

@ -136,6 +136,18 @@ public:
return GuildQueryResponseParser::parse(packet, data); return GuildQueryResponseParser::parse(packet, data);
} }
// --- Channels ---
/** Build CMSG_JOIN_CHANNEL */
virtual network::Packet buildJoinChannel(const std::string& channelName, const std::string& password) {
return JoinChannelPacket::build(channelName, password);
}
/** Build CMSG_LEAVE_CHANNEL */
virtual network::Packet buildLeaveChannel(const std::string& channelName) {
return LeaveChannelPacket::build(channelName);
}
// --- Utility --- // --- Utility ---
/** Read a packed GUID from the packet */ /** Read a packed GUID from the packet */
@ -216,6 +228,8 @@ public:
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 parseGuildRoster(network::Packet& packet, GuildRosterData& data) override;
bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override; bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override;
network::Packet buildJoinChannel(const std::string& channelName, const std::string& password) override;
network::Packet buildLeaveChannel(const std::string& channelName) override;
}; };
/** /**

View file

@ -11,6 +11,7 @@
#include <string> #include <string>
#include <map> #include <map>
#include <unordered_map> #include <unordered_map>
#include <chrono>
namespace wowee { namespace wowee {
namespace game { namespace game {
@ -653,6 +654,7 @@ struct MessageChatData {
std::string message; std::string message;
std::string channelName; // For channel messages std::string channelName; // For channel messages
uint8_t chatTag = 0; // Player flags (AFK, DND, GM, etc.) uint8_t chatTag = 0; // Player flags (AFK, DND, GM, etc.)
std::chrono::system_clock::time_point timestamp = std::chrono::system_clock::now();
bool isValid() const { return !message.empty(); } bool isValid() const { return !message.empty(); }
}; };

View file

@ -215,6 +215,17 @@ private:
GLuint backpackIconTexture_ = 0; GLuint backpackIconTexture_ = 0;
GLuint emptyBagSlotTexture_ = 0; GLuint emptyBagSlotTexture_ = 0;
// Chat settings
bool chatShowTimestamps_ = false;
int chatFontSize_ = 1; // 0=small, 1=medium, 2=large
bool chatAutoJoinGeneral_ = true;
bool chatAutoJoinTrade_ = true;
bool chatAutoJoinLocalDefense_ = true;
bool chatAutoJoinLFG_ = true;
// Join channel input buffer
char joinChannelBuffer_[128] = "";
static std::string getSettingsPath(); static std::string getSettingsPath();
// Gender placeholder replacement // Gender placeholder replacement

View file

@ -3912,6 +3912,10 @@ void GameHandler::sendChatMessage(ChatType type, const std::string& message, con
echo.type = type; echo.type = type;
} }
if (type == ChatType::CHANNEL) {
echo.channelName = target;
}
addLocalChatMessage(echo); addLocalChatMessage(echo);
} }
@ -4075,14 +4079,16 @@ void GameHandler::handleTextEmote(network::Packet& packet) {
void GameHandler::joinChannel(const std::string& channelName, const std::string& password) { void GameHandler::joinChannel(const std::string& channelName, const std::string& password) {
if (state != WorldState::IN_WORLD || !socket) return; if (state != WorldState::IN_WORLD || !socket) return;
auto packet = JoinChannelPacket::build(channelName, password); auto packet = packetParsers_ ? packetParsers_->buildJoinChannel(channelName, password)
: JoinChannelPacket::build(channelName, password);
socket->send(packet); socket->send(packet);
LOG_INFO("Requesting to join channel: ", channelName); LOG_INFO("Requesting to join channel: ", channelName);
} }
void GameHandler::leaveChannel(const std::string& channelName) { void GameHandler::leaveChannel(const std::string& channelName) {
if (state != WorldState::IN_WORLD || !socket) return; if (state != WorldState::IN_WORLD || !socket) return;
auto packet = LeaveChannelPacket::build(channelName); auto packet = packetParsers_ ? packetParsers_->buildLeaveChannel(channelName)
: LeaveChannelPacket::build(channelName);
socket->send(packet); socket->send(packet);
LOG_INFO("Requesting to leave channel: ", channelName); LOG_INFO("Requesting to leave channel: ", channelName);
} }
@ -4092,6 +4098,13 @@ std::string GameHandler::getChannelByIndex(int index) const {
return joinedChannels_[index - 1]; return joinedChannels_[index - 1];
} }
int GameHandler::getChannelIndex(const std::string& channelName) const {
for (int i = 0; i < static_cast<int>(joinedChannels_.size()); ++i) {
if (joinedChannels_[i] == channelName) return i + 1; // 1-based
}
return 0;
}
void GameHandler::handleChannelNotify(network::Packet& packet) { void GameHandler::handleChannelNotify(network::Packet& packet) {
ChannelNotifyData data; ChannelNotifyData data;
if (!ChannelNotifyParser::parse(packet, data)) { if (!ChannelNotifyParser::parse(packet, data)) {
@ -4135,8 +4148,10 @@ void GameHandler::handleChannelNotify(network::Packet& packet) {
} }
void GameHandler::autoJoinDefaultChannels() { void GameHandler::autoJoinDefaultChannels() {
joinChannel("General"); if (chatAutoJoin.general) joinChannel("General");
joinChannel("Trade"); if (chatAutoJoin.trade) joinChannel("Trade");
if (chatAutoJoin.localDefense) joinChannel("LocalDefense");
if (chatAutoJoin.lfg) joinChannel("LookingForGroup");
} }
void GameHandler::setTarget(uint64_t guid) { void GameHandler::setTarget(uint64_t guid) {

View file

@ -487,6 +487,26 @@ bool ClassicPacketParsers::parseMessageChat(network::Packet& packet, MessageChat
return true; return true;
} }
// ============================================================================
// Classic CMSG_JOIN_CHANNEL / CMSG_LEAVE_CHANNEL
// Classic format: just string channelName + string password (no channelId/hasVoice/joinedByZone)
// ============================================================================
network::Packet ClassicPacketParsers::buildJoinChannel(const std::string& channelName, const std::string& password) {
network::Packet packet(wireOpcode(Opcode::CMSG_JOIN_CHANNEL));
packet.writeString(channelName);
packet.writeString(password);
LOG_DEBUG("[Classic] Built CMSG_JOIN_CHANNEL: channel=", channelName);
return packet;
}
network::Packet ClassicPacketParsers::buildLeaveChannel(const std::string& channelName) {
network::Packet packet(wireOpcode(Opcode::CMSG_LEAVE_CHANNEL));
packet.writeString(channelName);
LOG_DEBUG("[Classic] Built CMSG_LEAVE_CHANNEL: channel=", channelName);
return packet;
}
// ============================================================================ // ============================================================================
// Classic guild roster parser // Classic guild roster parser
// Differences from WotLK: // Differences from WotLK:

View file

@ -1233,6 +1233,10 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) {
for (uint32_t i = 0; i < messageLen; ++i) { for (uint32_t i = 0; i < messageLen; ++i) {
data.message[i] = static_cast<char>(packet.readUInt8()); data.message[i] = static_cast<char>(packet.readUInt8());
} }
// Strip trailing null terminator (servers include it in messageLen)
if (!data.message.empty() && data.message.back() == '\0') {
data.message.pop_back();
}
} }
// Read chat tag // Read chat tag

View file

@ -34,6 +34,8 @@
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <cctype> #include <cctype>
#include <chrono>
#include <ctime>
#include <unordered_set> #include <unordered_set>
namespace { namespace {
@ -200,6 +202,12 @@ void GameScreen::render(game::GameHandler& gameHandler) {
} }
} }
// Sync chat auto-join settings to GameHandler
gameHandler.chatAutoJoin.general = chatAutoJoinGeneral_;
gameHandler.chatAutoJoin.trade = chatAutoJoinTrade_;
gameHandler.chatAutoJoin.localDefense = chatAutoJoinLocalDefense_;
gameHandler.chatAutoJoin.lfg = chatAutoJoinLFG_;
// Process targeting input before UI windows // Process targeting input before UI windows
processTargetInput(gameHandler); processTargetInput(gameHandler);
@ -562,6 +570,10 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
// Chat history // Chat history
const auto& chatHistory = gameHandler.getChatHistory(); const auto& chatHistory = gameHandler.getChatHistory();
// Apply chat font size scaling
float chatScale = chatFontSize_ == 0 ? 0.85f : (chatFontSize_ == 2 ? 1.2f : 1.0f);
ImGui::SetWindowFontScale(chatScale);
ImGui::BeginChild("ChatHistory", ImVec2(0, -70), true, ImGuiWindowFlags_HorizontalScrollbar); ImGui::BeginChild("ChatHistory", ImVec2(0, -70), true, ImGuiWindowFlags_HorizontalScrollbar);
bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem);
@ -847,20 +859,54 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImVec4 color = getChatTypeColor(msg.type); ImVec4 color = getChatTypeColor(msg.type);
// Optional timestamp prefix
std::string tsPrefix;
if (chatShowTimestamps_) {
auto tt = std::chrono::system_clock::to_time_t(msg.timestamp);
std::tm tm{};
localtime_r(&tt, &tm);
char tsBuf[16];
snprintf(tsBuf, sizeof(tsBuf), "[%02d:%02d] ", tm.tm_hour, tm.tm_min);
tsPrefix = tsBuf;
}
if (msg.type == game::ChatType::SYSTEM) { if (msg.type == game::ChatType::SYSTEM) {
if (!tsPrefix.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 0.6f, 0.6f, 1.0f));
ImGui::TextWrapped("%s", tsPrefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
}
renderTextWithLinks(msg.message, color); renderTextWithLinks(msg.message, color);
} else if (msg.type == game::ChatType::TEXT_EMOTE) { } else if (msg.type == game::ChatType::TEXT_EMOTE) {
if (!tsPrefix.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 0.6f, 0.6f, 1.0f));
ImGui::TextWrapped("%s", tsPrefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
}
renderTextWithLinks(msg.message, color); renderTextWithLinks(msg.message, color);
} else if (!msg.senderName.empty()) { } else if (!msg.senderName.empty()) {
if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_YELL) { if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_YELL) {
std::string prefix = msg.senderName + " says: "; std::string prefix = tsPrefix + msg.senderName + " says: ";
ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
renderTextWithLinks(msg.message, color);
} else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) {
int chIdx = gameHandler.getChannelIndex(msg.channelName);
std::string chDisplay = chIdx > 0
? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]"
: "[" + msg.channelName + "]";
std::string prefix = tsPrefix + chDisplay + " [" + msg.senderName + "]: ";
ImGui::PushStyleColor(ImGuiCol_Text, color); ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", prefix.c_str()); ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor(); ImGui::PopStyleColor();
ImGui::SameLine(0, 0); ImGui::SameLine(0, 0);
renderTextWithLinks(msg.message, color); renderTextWithLinks(msg.message, color);
} else { } else {
std::string prefix = "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": "; std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": ";
ImGui::PushStyleColor(ImGuiCol_Text, color); ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", prefix.c_str()); ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor(); ImGui::PopStyleColor();
@ -868,7 +914,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
renderTextWithLinks(msg.message, color); renderTextWithLinks(msg.message, color);
} }
} else { } else {
std::string prefix = "[" + std::string(getChatTypeName(msg.type)) + "] "; std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] ";
ImGui::PushStyleColor(ImGuiCol_Text, color); ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", prefix.c_str()); ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor(); ImGui::PopStyleColor();
@ -884,6 +930,9 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::EndChild(); ImGui::EndChild();
// Reset font scale after chat history
ImGui::SetWindowFontScale(1.0f);
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); ImGui::Spacing();
@ -5464,6 +5513,59 @@ void GameScreen::renderSettingsWindow() {
ImGui::EndTabItem(); ImGui::EndTabItem();
} }
// ============================================================
// CHAT TAB
// ============================================================
if (ImGui::BeginTabItem("Chat")) {
ImGui::Spacing();
ImGui::Text("Appearance");
ImGui::Separator();
if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps_)) {
saveSettings();
}
ImGui::SetItemTooltip("Show [HH:MM] before each chat message");
const char* fontSizes[] = { "Small", "Medium", "Large" };
if (ImGui::Combo("Chat Font Size", &chatFontSize_, fontSizes, 3)) {
saveSettings();
}
ImGui::Spacing();
ImGui::Spacing();
ImGui::Text("Auto-Join Channels");
ImGui::Separator();
if (ImGui::Checkbox("General", &chatAutoJoinGeneral_)) saveSettings();
if (ImGui::Checkbox("Trade", &chatAutoJoinTrade_)) saveSettings();
if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense_)) saveSettings();
if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG_)) saveSettings();
ImGui::Spacing();
ImGui::Spacing();
ImGui::Text("Joined Channels");
ImGui::Separator();
ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels.");
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) {
chatShowTimestamps_ = false;
chatFontSize_ = 1;
chatAutoJoinGeneral_ = true;
chatAutoJoinTrade_ = true;
chatAutoJoinLocalDefense_ = true;
chatAutoJoinLFG_ = true;
saveSettings();
}
ImGui::EndTabItem();
}
ImGui::EndTabBar(); ImGui::EndTabBar();
} }
@ -5920,6 +6022,12 @@ void GameScreen::saveSettings() {
// Chat // Chat
out << "chat_active_tab=" << activeChatTab_ << "\n"; out << "chat_active_tab=" << activeChatTab_ << "\n";
out << "chat_timestamps=" << (chatShowTimestamps_ ? 1 : 0) << "\n";
out << "chat_font_size=" << chatFontSize_ << "\n";
out << "chat_autojoin_general=" << (chatAutoJoinGeneral_ ? 1 : 0) << "\n";
out << "chat_autojoin_trade=" << (chatAutoJoinTrade_ ? 1 : 0) << "\n";
out << "chat_autojoin_localdefense=" << (chatAutoJoinLocalDefense_ ? 1 : 0) << "\n";
out << "chat_autojoin_lfg=" << (chatAutoJoinLFG_ ? 1 : 0) << "\n";
LOG_INFO("Settings saved to ", path); LOG_INFO("Settings saved to ", path);
} }
@ -5973,6 +6081,12 @@ void GameScreen::loadSettings() {
else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0);
// Chat // Chat
else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3);
else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0);
else if (key == "chat_font_size") chatFontSize_ = std::clamp(std::stoi(val), 0, 2);
else if (key == "chat_autojoin_general") chatAutoJoinGeneral_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_trade") chatAutoJoinTrade_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_localdefense") chatAutoJoinLocalDefense_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_lfg") chatAutoJoinLFG_ = (std::stoi(val) != 0);
} catch (...) {} } catch (...) {}
} }
LOG_INFO("Settings loaded from ", path); LOG_INFO("Settings loaded from ", path);