Published on

Learning Elixir by Querying a Tribes 2 Game Server with UDP Packets

Authors

I started learning Elixir in my spare time because I've been intrigued with the Phoenix framework. But before I dive into Phoenix, I figured it would be best to grasp a solid foundation of Elixir (and a little bit of Erlang, too) first. So I thought to myself, "Self! Let's jump right into the deep end and figure it out as we go!" and so I did... after I went through Pragmatic Studio's Elixir/OTP course, of course.

How to perform a Tribes 2 server query

Querying a Tribes 2 server involves sending a 6-byte UDP packet, "the query," where the first byte denotes the type of information we're asking for in response, and the rest are mainly there for padding but expected by the server. There are two types of query packets you must send that make up a complete query. The first is the info packet, and the second is the status packet.

The response for the info packet contains mainly the server name, while the status packet contains all the meat and potatoes like map name, team, and player scores.

The info query packet structure in HEX looks like 0E 02 01 02 03 04. The status query packet structure is 12 02 01 02 03 04.

If you send a UDP query packet to any Tribes 2 server containing either the info or status structures, the server will respond to the senders address with a UDP packet containing the query result.

A typical status result could look something like this:

14 02 01 02 03 04 07 43 6C 61 73 73 69 63 09 4C 61 6B 52 61 62 62 69 74 0E 4D 69 6E 69 20 53 75 6E 20 
44 72 69 65 64 A1 00 40 00 EA 08 6A 43 65 6C 65 62 72 61 74 69 6E 67 20 32 30 20 59 65 61 72 73 20 6F 
66 20 54 72 69 62 65 73 32 21 20 4D 6F 72 65 20 69 6E 66 6F 72 6D 61 74 69 6F 6E 20 69 6E 20 44 69 73 
63 6F 72 64 2E 20 3C 61 3A 70 6C 61 79 74 32 2E 63 6F 6D 2F 64 69 73 63 6F 72 64 3E 70 6C 61 79 74 32 
2E 63 6F 6D 2F 64 69 73 63 6F 72 64 3C 2F 61 3E 0B 00 31 0A 53 74 6F 72 6D 09 30 0A 30

The response could be much larger depending on how popular the server is, as it also contains all players and scores.

Parsing a UDP packet that uses Pascal-strings

The response packets that Tribes 2 sends do not contain consistent delimiters except for players and scores where tabs and new-lines are used.

So, how would we parse out the mod name, game type, and server description if there are no delimiters? To make things harder, Tribes 2 was released in 2001 and is now over 20-years old with little to no documentation anywhere.

If you were to convert a status packet to ASCII, taking only the first part of the packet, you would see something like this: \07Classic\tLakRabbit\0eMini Sun Dried\a1\00@\00\ea\08jCelebrating

The HEX representation would look like this:

07 43 6C 61 73 73 69 63 09 4C 61 6B 52 61 62 62 69 74 0E 4D 69 6E 69 20 53 75 6E 20 44 72 69 65 64 A1 
00 40 00 EA 08 6A 43 65 6C 65 62 72 61 74 69 6E 67

Admittedly, this took me some time to figure out, but the response uses pascal-strings to delimit different parts of the packet. Pascal strings are strings prefixed by their length.

Looking back at our HEX representation:

07 43 6C 61 73 73 69 63     
07 C  l  a  s  s  i  c

We can see that 07 followed by the next 7-bytes 43 6C 61 73 73 69 63 turns out to be the word "Classic" the mod name.

Then the next byte is 09, grabbing the next 9 bytes 4C 61 6B 52 61 62 62 69 74 we get "LakRabbit" the game type.

You can repeat this process for the map name and server description. After the server description, the delimiters change to tabs and new-lines.

So, how would we use Elixir to parse a packet that contained pascal strings?

Well, as it turns out, Elixir makes this pretty easy. Since every binary in Elixir is a bitstring, you can leverage one of the basic building blocks in the Kernal.SpecialForms construct, the type operator, and use it with bitstrings!

Here's how we utilize special forms and account for pascal strings:

<<
    _header             :: size(48) # skip the header, the first six characters in bits is 48 = 6 bytes

    game_type_length    :: little-integer,     # 7
    game_type           :: binary-size(game_type_length),  # Classic, 7 - chars

    mission_type_length :: little-integer,  # 9
    mission_type        :: binary-size(mission_type_length),  # LakRabbit, 9 - chars

    map_name_length     :: little-integer,  # 0E as decimal -> 14
    map_name            :: binary-size(map_name_length),  # Mini Sun Dried, 14 - chars

    ...
>> = status_packet

As you can see, this is pretty powerful. We're stepping through the packet, mapping types and sizes to variables without any loops. Using game_type_length and game_type as an example, we're able to read the game_type_length as a little-integer and then reference that value for the game_type's binary-size to calculate the final length of that string.

You can find more about the anatomy of the Tribes 2 UDP packet that I documented here.

Check out the t2_server_query package

You can find the t2_server_query package on Hex. If you're interested in the rest of the code, you can view the repo on GitHub.

I'm having a blast learning Elixir, and any feedback on how I could make my code better would be greatly appreciated! It's a beautiful languange and cant wait to learn more.

Source Code Available

Want to check out the code repo for this post?

© 2021 AnthonyMineo.com