|
From: <xer...@us...> - 2026-07-18 02:18:37
|
Revision: 1674
http://sourceforge.net/p/seq/svn/1674
Author: xerxes00
Date: 2026-07-18 02:18:34 +0000 (Sat, 18 Jul 2026)
Log Message:
-----------
ucs-chat: decode cross-zone Universal Chat Service channel chat
- learn the UCS session port from its SessionResponse (XOR format 0x04) and
decode the chained-XOR data packets inline, emitting channel chat under a
new MT_ChatChannel ("Chat") category
- packetcapture: include WorldServerChat2Port in the post-detection filter so
the separate UCS session survives filter narrowing
- messageshell: drop the client-dir MOTD duplicate of outgoing channel
commands (they echo back over UCS as Chat)
- learn full channel names from /list and join notices to repair the
seed-masked first character on chat lines
Modified Paths:
--------------
showeq/branches/xerxes-trunk-dev/src/interface.cpp
showeq/branches/xerxes-trunk-dev/src/message.cpp
showeq/branches/xerxes-trunk-dev/src/message.h
showeq/branches/xerxes-trunk-dev/src/messageshell.cpp
showeq/branches/xerxes-trunk-dev/src/messageshell.h
showeq/branches/xerxes-trunk-dev/src/messagewindow.cpp
showeq/branches/xerxes-trunk-dev/src/packet.cpp
showeq/branches/xerxes-trunk-dev/src/packet.h
showeq/branches/xerxes-trunk-dev/src/packetcapture.cpp
showeq/branches/xerxes-trunk-dev/src/terminal.cpp
Modified: showeq/branches/xerxes-trunk-dev/src/interface.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/interface.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/interface.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -720,6 +720,11 @@
"specialMessageStruct", SZC_None,
m_messageShell,
SLOT(specialMessage(const uint8_t*, size_t, uint8_t)));
+ // UCS: decoded cross-zone/global chat -> message log. This is a plain Qt
+ // connect (a custom EQPacket signal, not an opcode), so it does NOT go
+ // through connect2's opcode registration.
+ connect(m_packet, SIGNAL(decodedChatMessage(const uint8_t*, size_t, uint8_t)),
+ m_messageShell, SLOT(ucsChatMessage(const uint8_t*, size_t, uint8_t)));
m_packet->connect2("OP_GuildMOTD", SP_Zone, DIR_Server,
"guildMOTDStruct", SZC_None,
m_messageShell,
Modified: showeq/branches/xerxes-trunk-dev/src/message.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/message.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/message.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -61,5 +61,6 @@
"Caution",
"Hunt",
"Locate",
+ "Chat", // UCS: MT_ChatChannel
};
Modified: showeq/branches/xerxes-trunk-dev/src/message.h
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/message.h 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/message.h 2026-07-18 02:18:34 UTC (rev 1674)
@@ -68,7 +68,8 @@
MT_Caution,
MT_Hunt,
MT_Locate,
- MT_Max = MT_Locate,
+ MT_ChatChannel, // UCS: cross-zone/General channel chat
+ MT_Max = MT_ChatChannel,
};
//----------------------------------------------------------------------
Modified: showeq/branches/xerxes-trunk-dev/src/messageshell.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/messageshell.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/messageshell.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -21,6 +21,8 @@
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
+#include <QStringList> // UCS: channel-name learning
+
#include "messageshell.h"
#include "eqstr.h"
#include "messages.h"
@@ -88,10 +90,13 @@
//-----------------------------------------------------------------------------
// Tells and Group by us happen twice *shrug*. Ignore the client->server one.
- if (dir == DIR_Client &&
+ if (dir == DIR_Client &&
(cmsg->chanNum == MT_Tell || cmsg->chanNum == MT_Group || cmsg->chanNum == MT_Guild ||
cmsg->chanNum == MT_OOC || cmsg->chanNum == MT_Shout || cmsg->chanNum == MT_Auction ||
- cmsg->chanNum == MT_System || cmsg->chanNum == MT_Raid))
+ cmsg->chanNum == MT_System || cmsg->chanNum == MT_Raid ||
+ cmsg->chanNum == MT_Motd)) // UCS: outgoing channel commands echo back
+ // over UCS as "Chat:"; drop the client-dir MOTD
+ // duplicate. (Real server MOTD is DIR_Server.)
{
return;
}
@@ -179,6 +184,199 @@
cmsg = 0;
}
+// UCS: true if >=85% of the string is printable ASCII. A bad XOR decode
+// yields mostly non-printable bytes, so this gates out garbage records.
+static bool mostlyPrintable(const QString& s)
+{
+ if (s.isEmpty())
+ return false;
+ int p = 0;
+ for (int i = 0; i < s.length(); ++i)
+ if (s[i].unicode() >= 32 && s[i].unicode() < 127)
+ ++p;
+ return (p * 100) >= (s.length() * 85);
+}
+
+// UCS: full channel names learned at runtime. Every per-message channel name
+// has its first character in the seed-masked header (so it decodes garbled),
+// but the FULL name appears un-masked inside /list output and join/invite
+// notices. We harvest those and use them to repair the garbled first char on
+// chat lines.
+static QStringList s_ucsChannels;
+
+static void ucsLearnName(const QString& name)
+{
+ if ((name.length() >= 2) && !s_ucsChannels.contains(name))
+ s_ucsChannels.append(name);
+}
+
+// Scan a decoded buffer's text for channel names: "/list" prints
+// "Channels: 1=General2(400), 2=myraid(1), ..."; join/invite notices say
+// "channel <name>" / "joined <name>".
+static void ucsLearnChannels(const QString& text)
+{
+ int i = 0;
+ while ((i = text.indexOf('=', i)) >= 0) // <n>=<name>(
+ {
+ int j = i + 1;
+ QString name;
+ while ((j < text.length()) &&
+ (text[j].isLetterOrNumber() || (text[j] == '_')))
+ name += text[j++];
+ if ((j < text.length()) && (text[j] == '('))
+ ucsLearnName(name);
+ i = (j > i) ? j : (i + 1);
+ }
+ QStringList kws;
+ kws << "channel " << "joined ";
+ for (int w = 0; w < kws.size(); ++w)
+ {
+ int p = text.indexOf(kws[w]);
+ if (p < 0)
+ continue;
+ p += kws[w].length();
+ QString name;
+ while ((p < text.length()) &&
+ (text[p].isLetterOrNumber() || (text[p] == '_')))
+ name += text[p++];
+ ucsLearnName(name);
+ }
+}
+
+// Repair a channel name whose readable tail (first char may be masked/garbled)
+// is `tail`: match a learned name equal to the tail or to the tail minus its
+// first char; else rebuild the auto-joined "General*" channels; else best-effort.
+static QString ucsResolveChannel(const QString& tail)
+{
+ if (tail.isEmpty())
+ return QString();
+ for (int c = 0; c < s_ucsChannels.size(); ++c)
+ {
+ const QString& name = s_ucsChannels[c];
+ if (tail.endsWith(name) || ((name.length() >= 2) && tail.endsWith(name.mid(1))))
+ return name;
+ }
+ int g = tail.indexOf("eneral"); // General, General1, General2, ...
+ if (g >= 0)
+ return QString("General") + tail.mid(g + 6);
+ return tail;
+}
+
+// UCS: display decoded Universal Chat Service (channel) chat. The buffer
+// arrives XOR-decoded from EQPacket::decodeUCSPacket, SOE OP_Packet-wrapped
+// with a VARIABLE header, so we anchor on the "SPAM:" trailer that ends every
+// real chat record. NUL('\0')-separated layout around it:
+// [4-byte masked hdr]<channel>\0 <server>.<sender>\0 <message>\0 SPAM:<n>:<id>\0
+// message = field before "SPAM:", <server>.<sender> = field before that,
+// <channel> = field before that (first char masked -> repaired via
+// ucsResolveChannel), and sender = text after the LAST '.'. One packet may
+// carry several records (SOE combine). Incoming (server) direction only.
+// Every read is bounds-checked.
+void MessageShell::ucsChatMessage(const uint8_t* data, size_t len, uint8_t dir)
+{
+ if (dir != DIR_Server) // outgoing rides the zone server
+ return;
+ if ((data == NULL) || (len < 8))
+ return;
+
+ // Learn full channel names from any /list / join / invite text in this buffer.
+ ucsLearnChannels(QString::fromLatin1((const char*)data, (int)len));
+
+ size_t scan = 0;
+ while (scan + 5 <= len)
+ {
+ size_t p = scan;
+ bool found = false;
+ for (; p + 5 <= len; ++p)
+ {
+ if (data[p] == 'S' && data[p+1] == 'P' && data[p+2] == 'A' &&
+ data[p+3] == 'M' && data[p+4] == ':')
+ {
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ break;
+ scan = p + 5;
+
+ if ((p < 2) || (data[p-1] != 0)) // "SPAM:" must follow a NUL
+ continue;
+
+ // message = field ending at the NUL just before "SPAM:".
+ size_t msgEnd = p - 1;
+ size_t msgStart = msgEnd;
+ while ((msgStart > 0) && (data[msgStart-1] != 0))
+ --msgStart;
+ if ((msgStart >= msgEnd) || (msgStart < 2))
+ continue;
+
+ // <server>.<sender> = the field ending at the NUL before the message.
+ size_t ssEnd = msgStart - 1;
+ size_t ssStart = ssEnd;
+ while ((ssStart > 0) && (data[ssStart-1] != 0))
+ --ssStart;
+ if (ssStart >= ssEnd)
+ continue;
+
+ QString message = QString::fromLatin1((const char*)(data + msgStart),
+ (int)(msgEnd - msgStart));
+ QString serverSender = QString::fromLatin1((const char*)(data + ssStart),
+ (int)(ssEnd - ssStart));
+ int dot = serverSender.lastIndexOf('.');
+ QString sender = (dot >= 0) ? serverSender.mid(dot + 1) : serverSender;
+
+ // <channel> = the field before <server>.<sender>. Take its trailing
+ // printable run (drops leading masked-header bytes), then repair the
+ // first char.
+ QString channel;
+ if (ssStart >= 2)
+ {
+ size_t chEnd = ssStart - 1;
+ size_t chStart = chEnd;
+ while ((chStart > 0) && (data[chStart-1] != 0))
+ --chStart;
+ QString field = QString::fromLatin1((const char*)(data + chStart),
+ (int)(chEnd - chStart));
+ int k = field.length();
+ while ((k > 0) && (field[k-1].unicode() >= 32) && (field[k-1].unicode() < 127))
+ --k;
+ channel = ucsResolveChannel(field.mid(k));
+ }
+
+ if (!mostlyPrintable(sender) || !mostlyPrintable(message) ||
+ (sender.length() > 32))
+ continue;
+
+ // Server-assigned spam score = the digits right after "SPAM:". The game
+ // client hides/marks score>0 lines; we SHOW everything and tag score>0
+ // with "(SPAM)" so filtered chat stays visible.
+ bool spam = false;
+ {
+ size_t q = p + 5; // p..p+4 == "SPAM:"
+ long score = 0;
+ bool haveDigit = false;
+ while ((q < len) && (data[q] >= '0') && (data[q] <= '9'))
+ {
+ score = (score * 10) + (data[q] - '0');
+ haveDigit = true;
+ ++q;
+ }
+ spam = haveDigit && (score > 0);
+ }
+
+ QString inner = spam
+ ? QString("(SPAM) '%1' - %2").arg(sender).arg(message)
+ : QString("'%1' - %2").arg(sender).arg(message);
+ QString text = channel.isEmpty()
+ ? inner
+ : QString("#%1: %2").arg(channel).arg(inner);
+ // MT_ChatChannel gives UCS chat its own "Chat" category, so the console
+ // prefix is "Chat:" instead of the redundant "General:".
+ m_messages->addMessage(MT_ChatChannel, text);
+ }
+}
+
static MessageType chatColor2MessageType(ChatColor chatColor)
{
MessageType messageType;
Modified: showeq/branches/xerxes-trunk-dev/src/messageshell.h
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/messageshell.h 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/messageshell.h 2026-07-18 02:18:34 UTC (rev 1674)
@@ -62,6 +62,8 @@
void formattedMessage(const uint8_t* cmsg, size_t, uint8_t);
void simpleMessage(const uint8_t* cmsg, size_t, uint8_t);
void specialMessage(const uint8_t* smsg, size_t, uint8_t);
+ // UCS: display decoded Universal Chat Service cross-zone/global chat.
+ void ucsChatMessage(const uint8_t* data, size_t len, uint8_t dir);
void guildMOTD(const uint8_t* gmotd, size_t, uint8_t);
void consent(const uint8_t* consent, size_t, uint8_t);
void moneyOnCorpse(const uint8_t* money);
Modified: showeq/branches/xerxes-trunk-dev/src/messagewindow.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/messagewindow.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/messagewindow.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -586,6 +586,11 @@
m_typeFilterMenu->addSeparator();
+ // UCS: default the "Chat" category to the in-game universal-chat purple
+ // (RGB 125,75,175). The load() below uses this as the default, so a
+ // user-set color preference still overrides it.
+ m_typeStyles[MT_ChatChannel].setColor(QColor(125, 75, 175));
+
QString typeName;
// iterate over the message types, filling in various menus and getting
// font color preferences
Modified: showeq/branches/xerxes-trunk-dev/src/packet.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/packet.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/packet.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -33,6 +33,7 @@
#include <QTimer>
#include <QFileInfo>
+#include <QByteArray> // UCS: buffer for inline UCS chat decode
#include "everquest.h"
#include "packet.h"
@@ -325,6 +326,7 @@
m_ip = inet_ntoa(ia);
m_detectingClient = (m_ip == AUTOMATIC_CLIENT_IP);
+ m_ucsPort = 0; // UCS chat session port, learned from the wire
}
@@ -642,8 +644,32 @@
seqInfo("Client Detected: %s", m_ip.toLatin1().data());
}
+ // UCS (Universal Chat Service): cross-zone / global-channel chat rides a
+ // separate, persistent SOE session whose SessionResponse advertises format
+ // byte 0x04 (XOR-obfuscated) rather than 0x01 (zlib). Its UDP port varies
+ // by server (e.g. 9875 on some, 9877 on others), so learn it from the wire
+ // instead of hardcoding. payload()[11] is the SessionResponse format byte.
+ {
+ const uint8_t* soe = packet.getUDPPayload();
+ uint32_t soeLen = packet.getUDPPayloadLength();
+ if (soe && soeLen >= 12 &&
+ soe[0] == 0x00 && soe[1] == 0x02 && soe[11] == 0x04)
+ m_ucsPort = packet.getSourcePort(); // SessionResponse is server->client
+ }
+
// Dispatch based on known streams
- if ((packet.getDestPort() == ChatServerPort) ||
+ if (m_ucsPort &&
+ ((packet.getDestPort() == m_ucsPort) ||
+ (packet.getSourcePort() == m_ucsPort)))
+ {
+ // UCS chat session: decode + emit it ourselves. The XOR-obfuscated data
+ // packets have no cleartext SOE framing, so the normal stream/CRC path
+ // would choke. Must sit before the chat-port drops below, since the UCS
+ // port can coincide with one of them.
+ decodeUCSPacket(packet);
+ return;
+ }
+ else if ((packet.getDestPort() == ChatServerPort) ||
(packet.getSourcePort() == ChatServerPort))
{
// Drop chat server traffic
@@ -699,6 +725,50 @@
} /* end dispatchPacket() */
////////////////////////////////////////////////////
+// UCS: decode a Universal Chat Service (cross-zone chat) SOE data packet
+// inline.
+//
+// The chat session is a standard SOE session using SOE's XOR "encode" mode
+// (format byte 0x04) rather than zlib. Its data packets are obfuscated with a
+// self-synchronizing chained XOR that needs no key: for the raw UDP payload,
+// plain[i] = cipher[i] ^ cipher[i-4] for i>=4 (the first 4 bytes are a masked
+// header we leave alone). SOE control packets carry no chat and are harmlessly
+// ignored downstream (the parser anchors on a chat-only trailer). These data
+// packets have no cleartext SOE framing, so the normal stream/CRC/reassembly
+// path can't handle them; we decode and emit here instead.
+void EQPacket::decodeUCSPacket(EQUDPIPPacketFormat& packet)
+{
+ // Full UDP payload starting at the SOE net-opcode. UCS chat data is
+ // OP_Packet-wrapped, so the encrypted region sits after a variable-length
+ // SOE header. We deliberately do NOT strip that header: the chained XOR
+ // below is position-absolute, so decoding the whole payload leaves the
+ // encrypted region correctly decoded wherever it begins, and the consumer
+ // anchors its parse on a trailer rather than a fixed offset.
+ const uint8_t* raw = packet.getUDPPayload();
+ uint32_t rawLen = packet.getUDPPayloadLength();
+
+ // Too short to carry a chat record (ACKs, keepalives, etc.) -> ignore.
+ if ((raw == NULL) || (rawLen < 12))
+ return;
+
+ // Self-synchronizing XOR: plain[i] = cipher[i] ^ cipher[i-4] for i>=4. Read
+ // the ORIGINAL ciphertext at i-4 (from a pristine input buffer), NOT the
+ // already-decoded value -- decoding forward in place corrupts everything
+ // from byte 8 on.
+ QByteArray in((const char*)raw, rawLen);
+ QByteArray buf(in);
+ int len = in.size();
+ for (int i = 4; i < len; ++i)
+ buf[i] = (char)((uint8_t)in[i] ^ (uint8_t)in[i - 4]);
+
+ // Direction: from our client, or to it.
+ uint8_t dir = (packet.getIPv4SourceN() == m_client_addr) ?
+ DIR_Client : DIR_Server;
+
+ emit decodedChatMessage((const uint8_t*)buf.data(), (size_t)buf.size(), dir);
+}
+
+////////////////////////////////////////////////////
// Handle zone2client stream closing
void EQPacket::closeStream(uint32_t sessionId, EQStreamID streamId)
{
Modified: showeq/branches/xerxes-trunk-dev/src/packet.h
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/packet.h 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/packet.h 2026-07-18 02:18:34 UTC (rev 1674)
@@ -164,6 +164,9 @@
uint16_t opcode, const EQPacketOPCode* opcodeEntry,
bool unknown);
+ // UCS: decoded cross-zone/global chat payload, ready for display.
+ void decodedChatMessage(const uint8_t* data, size_t len, uint8_t dir);
+
private:
void validateIP();
@@ -177,6 +180,11 @@
bool m_detectingClient;
in_addr_t m_client_addr;
+ // UCS (Universal Chat Service) session port, learned dynamically from the
+ // SessionResponse whose format byte is 0x04 (XOR-obfuscated chat session).
+ // Varies by server, so it is not hardcoded. 0 = not yet seen.
+ in_port_t m_ucsPort;
+
uint16_t m_arqSeqGiveUp;
QString m_device;
QString m_ip;
@@ -202,6 +210,9 @@
void connectStream(EQPacketStream* stream);
void dispatchPacket (int size, unsigned char *buffer);
void dispatchPacket(EQUDPIPPacketFormat& packet);
+ // UCS: decode a Universal Chat Service (cross-zone chat) SOE data packet
+ // inline, bypassing the normal stream/CRC/reassembly path.
+ void decodeUCSPacket(EQUDPIPPacketFormat& packet);
protected slots:
void resetEQPacket();
void dispatchWorldChatData (size_t len, uint8_t* data, uint8_t direction = 0);
Modified: showeq/branches/xerxes-trunk-dev/src/packetcapture.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/packetcapture.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/packetcapture.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -393,11 +393,15 @@
}
else
{
- // restrict to client/zone port and world server ports
+ // restrict to client/zone port and world server ports. Include
+ // WorldServerChat2Port (UCS/xgame chat) so the separate cross-zone
+ // chat session survives the post-detection filter narrowing - it
+ // rides a different port than the zone stream.
pfb += sprintf(pfb, "udp");
- pfb += sprintf(pfb, " and (portrange %d-%d or port %d or port %d)",
+ pfb += sprintf(pfb, " and (portrange %d-%d or port %d or port %d or port %d)",
WorldServerGeneralMinPort, WorldServerGeneralMaxPort,
- WorldServerChatPort, (client_port) ? client_port : zone_port);
+ WorldServerChatPort, WorldServerChat2Port,
+ (client_port) ? client_port : zone_port);
}
if (address_type == IP_ADDRESS_TYPE)
Modified: showeq/branches/xerxes-trunk-dev/src/terminal.cpp
===================================================================
--- showeq/branches/xerxes-trunk-dev/src/terminal.cpp 2026-07-18 01:43:54 UTC (rev 1673)
+++ showeq/branches/xerxes-trunk-dev/src/terminal.cpp 2026-07-18 02:18:34 UTC (rev 1674)
@@ -67,6 +67,8 @@
"\e[1;37;43m", // 32 - Caution
"\e[1;37;46m", // 33 - Hunt
"\e[1;37;44m", // 34 - Locate
+ NULL, // 35
+ "\e[38;2;125;75;175m", // 36 - Chat (UCS: in-game universal-chat purple 125,75,175)
};
//----------------------------------------------------------------------
This was sent by the SourceForge.net collaborative development platform, the world's largest Open Source development site.
|