LAN discovery works by broadcasting encrypted UDP packets to the address 255.255.255.255, from port 8086-8093 (inclusive). Every LAN discovery message is sent across each of these ports with the same content.
The game does this as a fallback, in case it can't bind its preferred discovery port for whatever reason. It prefers to listen for broadcasts on port 8086 but will bind one of the other ports if it can't bind 8086.
All LAN discovery messages have a fixed size of 476 bytes. The content of the messages is usually smaller, but the game allocates a fixed size buffer for LAN discovery and reuses it every time. The packet encryption scheme (covered below) works on the entire packet, including garbage data, so keep that in mind if you want to send messages to the game.
LAN discovery messages go through a process like this:
-
The packet's message is encoded into the packet buffer, starting at byte 4 and using as much of the packet as necessary.
-
A hash of the packet is calculated and written to the first four bytes. The first four bytes are skipped when calculating the hash, but everything else (including the unused portion of the buffer after the message) is included.
-
The packet is encrypted. This includes the whole buffer, with the hash and any unused bytes.
public static uint HashPacket(ReadOnlySpan<byte> packet)
{
var hashval = 0u;
foreach (var b in packet)
hashval = (b + (hashval * 2)) + (hashval >> 31);
return hashval;
}
const uint KeyIV = 0x38d9b7d4;
const uint KeyIncr = 0x80c63af2;
// NOTE: The LittleEndian parts assume you are running on a little-endian
// processor. The C# equivalents of ntohl / htonl do not accept slices.
private static void EncryptPacket(Span<byte> packet)
{
Debug.Assert(packet.Length % 4 == 0, "Packet length not a multiple of 4");
var key = KeyIV;
for (var i = 0; i < packet.Length; i += 4)
{
var currentInt = packet.Slice(i);
var value = BinaryPrimitives.ReadUInt32LittleEndian(currentInt);
value ^= key;
BinaryPrimitives.WriteUInt32BigEndian(currentInt, value);
key += KeyIncr;
}
}
private static void DecryptPacket(Span<byte> packet)
{
Debug.Assert(packet.Length % 4 == 0, "Packet length not a multiple of 4");
var key = KeyIV;
for (var i = 0; i < packet.Length; i += 4)
{
var currentInt = packet.Slice(i);
var value = BinaryPrimitives.ReadUInt32BigEndian(currentInt);
value ^= key;
BinaryPrimitives.WriteUInt32LittleEndian(currentInt, value);
key += KeyIncr;
}
}
You can validate these yourself by running a copy of cnc3game.dat
(not the
EXE, as that is only a launcher) through Ghidra. Look for the KeyIV and
KeyIncr constants mentioned in the code - the only two mentions I found
are in the packet encrypt routine at 0x661073 and the packet decrypt
routine at 0x665ac3.
(If you want to do this the hard way, start from sendto
and work
your way back up.
You'll need the symbol ordinals for WinSock2 to make any
sense of the external function references in the Import section)
At this point you can decode and validate the messages coming off the wire. Interpreting them is a bit more complicated and I have only a partial understanding of how this works.
If you're following along in Ghidra, take a look at the LANAPI class that the C++ analyzer found. Its vtable should be at address 0xa84dc4. The methods here contain the logic for generating and parsing the packet contents.
All packets share a common 34 byte header. I'm using C type names to describe these, so an int is 32 bits and a short is 16 bits. All integers are big-endian, and all characters and strings are little-endian UTF-16.
packet[0:4]
Int - The packet hash.packet[4:8]
Int - The command. Note that these are fit within 1 byte, so you can think of this as being a byte followed by 3 bytes of zeroes.packet[8:30]
String - The player's nickname. Note that this is NUL-terminated, so even though you can fit 11 Unicode characters (22 bytes), you can only use the first 10 of them.packet[30:32]
Char - The first character returned by the functionGetUserNameA
.packet[32:34]
Char - The first character returned by the functionGetComputerNameA
.
(If you're following along at home, look at the vtable entry at 0xa84ea4)
The last two entries are oddballs. The game doesn't validate these as far as I can tell, you can put whatever characters you want in these if you are crafting your own messages. I can only assume EA's developers found this useful for identifying packets when debugging? Hopefully there aren't two Daves in the office!
If you track down the references to the function that generates the packet header, or you find where the packet command is dispatched (vtable 0xa84dd8), you'll see that there are 20 separate commands with IDs from 0x00 to 0x13.
I know the layouts of a handful of these. Enough to detect players on the LAN myself and send chat messages into the global lobby and individual game lobbies.
IDLE messages use the command code 0x02. They contain only the header and no other data. Sending this causes your nickname to appear in the player list of anyone on the same network.
CHAT messages use the command code 0x0b. They look like this:
-
packet[34:68]
String - The session of the ID that the chat was sent to. NUL-terminated like nickname. Each game lobby has a 16-character session ID, which is upper-case hex consisting of the host's IP address (C0A801C8 being 192.168.1.200) and an additional 4 bytes whose purpose I don't know. The entire session ID may be NUL bytes which indicates a chat sent to the global lobby screen instead of a specific game lobby. -
packet[68:72]
Int - The type of chat message. As with the command there are only a few types of these, so usually on the first byte is set. I know of three: 0x00 "player" chats sent within the main chat window, 0x01 "comrade" chats sent from the chat window (the mail icon in the top right), and 0x03 "rule" chats sent by the system for things like rule changes and announcing the match start countdown. -
packet[72:274]
String - The chat message, 100 Unicode characters followed by a NUL terminator. 100 characters is a guess based on string copy calls in the decompiled code.
CONFIG messages use command code 0x10. Their only content, besides the header, is a NUL-terminated Unicode string. I don't know what the max length on these is.
They are used for transmitting changes that players make to their own game
settings, such as their chosen Color or Faction. They also contain some system
properties that are unused as far as I know (at least in LAN - some look related
to GameSpy). Most are Key=Value
like User=Frank
, Host=DESKTOP-ABCD0123
,
PlayerTemplate=8
, or Color=0
.
HOST (0x01) and ADVERTISE (0x13) are both broadcast by a host to tell other players about their game lobby. This is the most complicated packet format and is the one where Tiberium Wars and Kane's Wrath show differences.
HOST contains all the data that ADVERTISE does plus some more, so I'll be
marking HOST-only parts of the packet. Similarly with the place where TW
vs. KW makes a difference. Offsets are kind of strange in this format -
the parsing code contains lots of references to strcpy
and strdup
which
makes me think some of this data is variable length. Because of this I won't
list offsets except for the first few fields.
packet[34:68]
String - Session ID, as in CHAT messages. Not present in ADVERTISE messages.packet[68:70]
Unknown. Always zero from what I've seen.- Packed data
- The map name. This is a NUL-terminated ASCII string. It contains a leading
m
that's not actually part of the map identifier (mmap_mp_2_chuck1
when the asset files call itmap_mp_2_chuck1
). I have no idea why. I also have no idea why this is ASCII when every other string is Unicode, even the session ID which only contains ASCII characters. - 1 short, followed by four ints. Purpose unknown.
- The match speed encoded as an int. Goes from 0 to 100.
- The number of starting resources encoded as an int. Starts at 10k, going up by 5k increments.
- 4 ints. Purpose unknown, though I assume they are some kind of match config parameter.
- Whether random crates are enabled, encoded as an int. Either 1 or 0.
- 5 ints (in Tiberium Wars), or 7 ints (in Kane's Wrath). Several of these are probably signed -1 values (0xffffffff) but I don't know what they are.
- Tiberum Wars only:
- An int.
- The ASCII string "CNC3" followed by a NUL terminator.
- A short.
- Kane's Wrath only:
- 2 ints followed by a byte.
- The map name. This is a NUL-terminated ASCII string. It contains a leading
The remainder of the packet is what 8 of what I'm calling a "player description" blocks. These start with a single ASCII character giving the player type, followed by different data for each player type:
P
A human player.- The nickname, same format as in the header.
- The player's IP address as a big-endian int.
- The player's port number. Not the port used for game data, just the port used for LAN broadcasting. Usually 8086.
- 6 bytes. These correspond to things like the player's faction, team, color, and handicap but I haven't mapped each individual byte to a specific function.
E
/M
/H
/B
A bot, with the letter signifying difficulty (easy/medium/hard/brutal).- Contains the same final 6 bytes as the player.
O
An open slot. Contains no data.C
A closed slot. Contains no data.X
An error slot? Contains no data. Only mentioned in the decompiled packet parser (code 0x66dde2), I've never seen this live.
Yeah :/ The story here is a tragedy, starring myself, Ghidra, and libpcap.
The original goal here was to write a LAN game relay. I self-host Wireguard and really like it, at least compared to my old OpenVPN setup or figuring out which of a jillion VPN providers is trustworthy and Linux-compatible.
(There is also CNC Online, but the Linux compatibility story there is sketchy, it amounts to "apply a pile of tweaks to Proton 7" and "ask some dude for a special version of the installer". I'll pass. As much work as hacking out my own solution is, I at least learn something along the way and get an end result that I understand.)
The problem is that Wireguard is a layer 3 VPN, which is OK for direct game-to-game communication (or more traditional game servers), but it doesn't support UDP broadcast which is necessary to play CNC3.
The plan was to write a small relay that would run locally, alongside the game, intercept the LAN broadcast messages, and then forward them to other copies of the program run by all players. Then their relay would send a broadcast packet exactly matching (all of it, including Ethernet and IP headers) what the other relay received. Thus faking broadcast over a medium that doesn't support it.
I got the monitoring part working, but the packet injection story fell apart for two reasons:
-
On Linux, pcap can only write packets to the interface for sending. This is a huge problem because it means the only way for CNC3 to see the packets is for the router to mirror them back. This didn't work for me, so it is at best fragile. (This is over ethernet - I couldn't get packet injection to work at all on the wireless interface).
-
On Windows, npcap died the moment I tried to inject packets onto the wireless network. I don't have the Windows development chops to explain why.
The next best option I know of involves mucking around with TAP devices, which (a) is basically half-assing a layer 2 VPN (a worse version of OpenVPN bridge mode), and (b) is something of a dark art on Windows. At least AIUI, basically everywhere I read about installing the TAP driver amounted to "install OpenVPN" and code samples were hard to come by.
At this point you may decide that you want to send your own messages. Be aware that the game tracks the other games on the local network using the source IP address and port number from the packet.
Meaning, if you want the game to see you as a single client, you need to reuse the same socket for every message. Some message types like chats are dropped if they are not associated with a known client (first introduced by an IDLE message).
Note also that known clients expire on a timer. So if you stop regularly sending out ping messages, other clients on the network will start ignoring you a few seconds later.
You'll notice that there is no game version in any of the above headers. The game can only detect your mod (Tiberium Wars or Kane's Wrath) using some messages and not others! This leads to the situation where TW and KW players can sit on the same LAN and chat with each other, but cannot join each others games.
I don't know how this works. My decoder guesses by looking at a specific offset within game advertisement messages, since Tiberium Wars puts the string CNC3 there but Kane's Wrath puts other data there. This is probably not how the game itself does it, but then again not much is different in the packet structure for game advertisement messages, so maybe they do...?
sender.py
should Just Work. Run it with th game running on the LAN lobby screen.
packet-crypto.py
is the decoder. If you have a PCAP containing some CNC3 network
traffic, export all the packets that match the filter ip.addr == 255.255.255.255 && udp.src_port == 8086
into JSON format.