From c2467cf2fcdd7d3911a5584af20012dcca4025c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Feb 2026 18:27:59 -0800 Subject: [PATCH] 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. --- include/game/game_handler.hpp | 10 +++ include/game/packet_parsers.hpp | 14 ++++ include/game/world_packets.hpp | 2 + include/ui/game_screen.hpp | 11 +++ src/game/game_handler.cpp | 23 +++++- src/game/packet_parsers_classic.cpp | 20 +++++ src/game/world_packets.cpp | 4 + src/ui/game_screen.cpp | 120 +++++++++++++++++++++++++++- 8 files changed, 197 insertions(+), 7 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d38f67d0..f03ae158 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -234,6 +234,16 @@ public: void leaveChannel(const std::string& channelName); const std::vector& getJoinedChannels() const { return joinedChannels_; } 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) using ChatBubbleCallback = std::function; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 1b74c240..989144ea 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -136,6 +136,18 @@ public: 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 --- /** Read a packed GUID from the packet */ @@ -216,6 +228,8 @@ public: bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; bool parseGuildRoster(network::Packet& packet, GuildRosterData& 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; }; /** diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index d01381c8..8e70b36c 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -653,6 +654,7 @@ struct MessageChatData { std::string message; std::string channelName; // For channel messages 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(); } }; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1ca68a04..415f8f6d 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -215,6 +215,17 @@ private: GLuint backpackIconTexture_ = 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(); // Gender placeholder replacement diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f5543477..52ed41f9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3912,6 +3912,10 @@ void GameHandler::sendChatMessage(ChatType type, const std::string& message, con echo.type = type; } + if (type == ChatType::CHANNEL) { + echo.channelName = target; + } + addLocalChatMessage(echo); } @@ -4075,14 +4079,16 @@ void GameHandler::handleTextEmote(network::Packet& packet) { void GameHandler::joinChannel(const std::string& channelName, const std::string& password) { 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); LOG_INFO("Requesting to join channel: ", channelName); } void GameHandler::leaveChannel(const std::string& channelName) { if (state != WorldState::IN_WORLD || !socket) return; - auto packet = LeaveChannelPacket::build(channelName); + auto packet = packetParsers_ ? packetParsers_->buildLeaveChannel(channelName) + : LeaveChannelPacket::build(channelName); socket->send(packet); LOG_INFO("Requesting to leave channel: ", channelName); } @@ -4092,6 +4098,13 @@ std::string GameHandler::getChannelByIndex(int index) const { return joinedChannels_[index - 1]; } +int GameHandler::getChannelIndex(const std::string& channelName) const { + for (int i = 0; i < static_cast(joinedChannels_.size()); ++i) { + if (joinedChannels_[i] == channelName) return i + 1; // 1-based + } + return 0; +} + void GameHandler::handleChannelNotify(network::Packet& packet) { ChannelNotifyData data; if (!ChannelNotifyParser::parse(packet, data)) { @@ -4135,8 +4148,10 @@ void GameHandler::handleChannelNotify(network::Packet& packet) { } void GameHandler::autoJoinDefaultChannels() { - joinChannel("General"); - joinChannel("Trade"); + if (chatAutoJoin.general) joinChannel("General"); + if (chatAutoJoin.trade) joinChannel("Trade"); + if (chatAutoJoin.localDefense) joinChannel("LocalDefense"); + if (chatAutoJoin.lfg) joinChannel("LookingForGroup"); } void GameHandler::setTarget(uint64_t guid) { diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 5e6c5e6e..6c618f4e 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -487,6 +487,26 @@ bool ClassicPacketParsers::parseMessageChat(network::Packet& packet, MessageChat 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 // Differences from WotLK: diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8acc43bf..8dd86db1 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1233,6 +1233,10 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { for (uint32_t i = 0; i < messageLen; ++i) { data.message[i] = static_cast(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 diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 955c2f0e..b52c8abd 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -34,6 +34,8 @@ #include #include #include +#include +#include #include 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 processTargetInput(gameHandler); @@ -562,6 +570,10 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Chat history 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); bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); @@ -847,20 +859,54 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { 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 (!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); } 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); } else if (!msg.senderName.empty()) { 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::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); renderTextWithLinks(msg.message, color); } 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::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); @@ -868,7 +914,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { renderTextWithLinks(msg.message, color); } } else { - std::string prefix = "[" + std::string(getChatTypeName(msg.type)) + "] "; + std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] "; ImGui::PushStyleColor(ImGuiCol_Text, color); ImGui::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); @@ -884,6 +930,9 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::EndChild(); + // Reset font scale after chat history + ImGui::SetWindowFontScale(1.0f); + ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); @@ -5464,6 +5513,59 @@ void GameScreen::renderSettingsWindow() { 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(); } @@ -5920,6 +6022,12 @@ void GameScreen::saveSettings() { // Chat 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); } @@ -5973,6 +6081,12 @@ void GameScreen::loadSettings() { else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); // Chat 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 (...) {} } LOG_INFO("Settings loaded from ", path);