Skip to main content
  1. Writing/

Finding A Good Way To Track Halo Infinite Playlist Wait Times

·5060 words
This blog post is part of a series on exploring the Halo game API.

Introduction #

It’s holiday season here in North America, which means that I have a bit of time to tinker with some APIs that I long wanted to tinker with. One of those is the Advanced Message Queuing Protocol (AMQP) API for the Halo Infinite lobby. I talked briefly about this particular API back in my blog post about identifying playlist weights, but since it was not a very user-friendly API to work with, I shelved looking inside it for as much as I possibly could. I guess that time came sooner than I expected, because a data point I want to track without launching the game is playlist wait times.

What are playlist wait times #

Every time you go into a playlist in Halo Infinite, you see the wait time displayed right at the bottom of the interaction menu - just below the Play button. The “Estimated wait” is the value I am looking for. And if you’re like me and want to play Ranked Tactical, then may the odds be ever in your favor, because the wait times are pretty long.

There is a lot that likely goes into calculating wait times, such as your rank and rank of other players that want to queue up in the playlist, your geographical region and whether you set the search to “Local” or “Expanded.” I am sure there is more, but so far for me to be able to know when the best times to join a game has been this:

  1. Launch Halo Infinite.
  2. Wait for it to boot.
  3. Go to “Multiplayer” and then open the desired playlist.
  4. See wait time, quit the game and hope that it will change later.

Not exactly the most time-saving approach, so naturally I started exploring what options I have to actually get the wait times programmatically and maybe even build an open data service that properly exposes them from an aggregated data set (on that later). None of that information is actually captured in the Halo Infinite REST API, and it would make sense - the data is dynamic and can change frequently, so there is likely another avenue. Whether it’s better or not is up to the implementer, but I figured that if playlist information is fetched through the AMQP endpoint, then wait times are likely there as well.

Finding wait times in AMQP API #

To get to the data, I fired up Fiddler and set the proxy for WinHTTP to the localhost endpoint:

 netsh winhttp import proxy source=ie

Then, I started Halo Infinite and was looking at the list of endpoints being called. Pro tip - if you’re running this on a Windows machine, the Fiddler request list is going to become noisy quick, so to help with that I filter out only to URLs that contain halowaypoint.com, like this:

Screenshot of Fiddler on Windows filtering by URL.
Screenshot of Fiddler on Windows filtering by URL.

The first thing I am looking for is the lobby endpoint that uses WebSockets. This is important, because the one that uses WebSockets is the one that is kept persistently alive - that’s where AMQP message exchanges happen. A bit of trawling through the logs, and I spot it:

Halo Infinite lobby endpoint in Fiddler.
Halo Infinite lobby endpoint in Fiddler.

Double clicking on any of these (I launched Halo Infinite a few times) opens the built-in WebSocket inspector. We can see that some binary data is being exchanged back and forth:

Binary data being exchanged over a WebSockets connection, inspected in Fiddler.
Binary data being exchanged over a WebSockets connection, inspected in Fiddler.

At this point, I am just assuming that what I am looking for actually gets transferred through the pipe, so the way to either confirm or bust this assumption is to peek inside. To do this, I first look at the headers for the initial request:

GET https://lobby-hi.svc.halowaypoint.com/ HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Accept: application/x-bond-compact-binary
Accept-Language: en-US
User-Agent: SHIVA-2043073184/6.10025.12948.0 (release; PC)
343-Telemetry-Session-Id: SOME_TELEMETRY_SESSION_GUID
Sec-WebSocket-Protocol: AMQPWSB10
X-343-Authorization-Spartan: v4=THE_SPARTAN_V4_JWT
Sec-WebSocket-Key: SOME_WEB_SOCKET_KEY
Sec-WebSocket-Version: 13
Host: lobby-hi.svc.halowaypoint.com

This gives us a few clues to look at:

  1. The data is exchanged in Bond format, as identified by application/x-bond-compact-binary in the Accept header.
  2. The request is authenticated, and does need the Spartan V4 token attached to the request via X-343-Authorization-Spartan.
  3. The transport is using Advanced Message Queuing Protocol (AMQP) WebSocket Binding (WSB) Version 1.0, as identified by AMQPWSB10 value in the Sec-WebSocket-Protocol header.

Alright, that’s a good starting point. For me to replicate the AMQP exchange (I still do not know what data is there) I need to understand how the client handshake actually happens. But before I get to that, what’s the best way to confirm that wait times are actually in the data?

Well, I can once again look into the WebSocket inspector in Fiddler and open the socket data in HexView (a tab in the same Fiddler window). There, my hunch is that the playlist data would be encapsulated in the more “stuffed” responses, i.e., the ones that are the biggest.

The first one I see is 2,410 bytes, so I selected all bytes and saved them into a file named 2410.bin, and then passed it through the Bond Reader tool I built to “unfurl” the Bond structures.

br parse --version 2 --input "PATH_TO_FILE\2410.bin" --output "PATH_TO_FILE\output.txt"

Anticipating some ground-breaking insights, I ran the tool and got this:

Bond Reader showing the output of trying to open the 2410.bin file.
Bond Reader showing the output of trying to open the 2410.bin file.

This clearly ain’t it - out of 2,410 bytes, there is no way that we have no data. To try and understand the structure better, I implemented a new flag in the tool, called --iterative-discovery and what is does is offset the starting byte incrementally and try to parse the rest of the structure as Bond, in the hopes that eventually it will hit a jackpot. And somewhat of a jackpot it hit, right around byte 55:

Bond Reader detecting a Bond structure starting with byte 55.
Bond Reader detecting a Bond structure starting with byte 55.

There’s some data here, like the player XUID, but most importantly we start seeing some GUIDs flow in, represented by the quad structure - a BT_UINT32, BT_UINT16, BT_UINT16, and BT_UINT64.

Bond Reader detecting a list of playlists.
Bond Reader detecting a list of playlists.

The way to know that were looking at the right thing is to count how many playlists there are in the game today. I counted 18, and snapshotted (from the game) the estimated wait times:

Playlist In-Game Wait Time
Workshop: Squad Battle Networking 0:24
Firefight: King Of The Hill 0:14
Firefight: Heroic King Of The Hill 0:13
Firefight: Legendary King Of The Hill 0:20
Quick Play 0:19
Halo 3 Refueled 0:19
Husky Raid 0:16
Super Fiesta 0:16
Big Team Battle 0:49
Big Team Heavies 1:00
Infection 0:41
Team Snipers 0:43
Ranked Arena 0:48
Ranked Tactical 3:11
Team Slayer 0:23
Tactical Slayer 0:39
Team Doubles 0:27
Rumble Pit 1:22

Notice that in the Bond Reader detection, I am looking at a container with 18 items - looks like we found our playlists. It does help that the GUIDs come in pairs, which likely means that we’re looking at the asset ID and the asset version. The quick and dirty way to verify that it’s in fact a playlist is to convert the numeric values into an actual GUID, with a snippet like this:

uint A = 3657583684;
ushort B = 31786;
ushort C = 18875;
ulong D = 43091701380546470;

byte[] guid = new byte[0];
guid = guid.Concat(BitConverter.GetBytes(A))
           .Concat(BitConverter.GetBytes(B))
           .Concat(BitConverter.GetBytes(C))
           .Concat(BitConverter.GetBytes(D)).ToArray();

Guid actualGuid = new Guid(guid);
Console.WriteLine(actualGuid.ToString().ToLower());

This returns da024c44-7c2a-49bb-a6ff-8d91ac179900, which we can then pass on to another endpoint to test that the GUID is a playlist:

https://gamecms-hacs.svc.halowaypoint.com
  /hi
  /Multiplayer
  /file
  /playlists
  /assets
  /da024c44-7c2a-49bb-a6ff-8d91ac179900.json

And we get:

{
  "NameHint": "ga_HuskyRaid",
  "PlatformMatchmakingHopperId": "GA-RETAIL_HuskyRaid",
  "UgcPlaylistVersion": "19bfb62e-76dd-46bf-ab6e-180e69b8873c",
  "GameStartRulesId": "arenaRules",
  "TrueMatchSettings": "socialPlayLoose.json",
  "ThunderheadContentConfiguration": "NonCampaign",
  "ThunderheadVmSize": "Medium",
  "HasCsr": false,
  "PlaylistExperience": "Arena",
  "MatchmakingDelaySec": 0,
  "UseGameVariantFilter": false,
  "MatchOnCsr": false
}

Bingo, looks like we actually have the playlist data. Alas, though, scrolling through the Bond data it doesn’t seem like we have the actual wait times captured in this particular response. That means going back to Fiddler. I, once again, started looking for the next “stuffed” response, which had 1,900 bytes. There are actually two responses with the same size, so I started with the first one and exported it as 1900_1.bin

This time, I knew that I needed to look for a container of 18 items, so I scrolled through and spotted this in the 57th iteration (meaning this time we offset 57 bytes from the start of the message):

Bond Reader detecting a list of playlists with wait times.
Bond Reader detecting a list of playlists with wait times.

Whoa - see that 25.056992667739113 and 15.427926036408234? That is oddly similar to the wait times for Workshop: Squad Battle Networking and Firefight: King Of The Hill. I think we have our winner. But let’s validate that. The GUIDs in human-ready format are:

  • f8b6abf1-55bd-49a4-a2ca-ee42262e10e9
  • 96aedf55-1c7e-46d5-bdaf-19a1329fb95d

Doing the same tests as above results in the following two responses:

{
    "NameHint": "ga_squadbattle_tcn",
    "PlatformMatchmakingHopperId": "GA-RETAIL_squadBattle",
    "UgcPlaylistVersion": "8c16233f-53a8-4388-862a-23d7b3e6c489",
    "GameStartRulesId": "arenaRules",
    "TrueMatchSettings": "largePlaylistLoose.json",
    "ThunderheadContentConfiguration": "NonCampaign",
    "ThunderheadVmSize": "Medium",
    "HasCsr": false,
    "PlaylistExperience": "BTB",
    "MatchOnCsr": false,
    "MatchmakingDelaySec": 0,
    "UseGameVariantFilter": false
}
{
    "NameHint": "ga_firefight_koth_normal",
    "PlatformMatchmakingHopperId": "GA-RETAIL_PVE_Normal",
    "UgcPlaylistVersion": "4c2d0a3b-c783-4b95-a9fe-8327cd0588d1",
    "GameStartRulesId": "arenaRules",
    "TrueMatchSettings": "singleTeamLoose.json",
    "ThunderheadContentConfiguration": "NonCampaign",
    "ThunderheadVmSize": "Medium",
    "HasCsr": false,
    "PlaylistExperience": "Arena",
    "MatchOnCsr": false,
    "MatchmakingDelaySec": 0,
    "UseGameVariantFilter": false
}

Yep, we definitely have the playlists and the wait time data. Congratulations! The first step of the journey is complete. Now, how do we obtain this data ourselves without launching the game?

Acquiring the data from your own code #

To get the data myself, I will first need to understand how the handshake is performed between client and server. Looking at the binary data flowing through the WebSocket connection, I noticed that the first message is always 699 bytes long. To see what exactly is encapsulated there, I thought that maybe a good idea would be to first start the game a few times, capture the message, and then do a binary comparison.

Binary comparison between two hello messages for the AMQP endpoint.
Binary comparison between two hello messages for the AMQP endpoint.

Seems like there are two things that are changing - the lobby GUID and the telemetry session GUID. I wonder if we can replicate this connection somehow?

Let’s take a closer look at the envelope The starting structure should be:

4 octets 1 octet 1 octet 1 octet 1 octet
AMQP %d0 Major Minor Revision

We have this. Running the message through Bond Reader it doesn’t seem like this is Bond per-se. The first “hello” message seems to also contain some extra structures that can be identified by searching for the 02 00 00 00 sequence, which I am assuming is some kind of delimiter.

Four bytes prior to this structure represent the length of the structure including the length-defining bytes. Let’s take a closer look:

02 00 00 00 bytes acting as structure identifiers.
02 00 00 00 bytes acting as structure identifiers.

Digging a bit more through the file, the following observations emerge:

  1. The first “hello” packet has the AMQP header. The response to this is a response from the server that is just the AMQP header.
  2. 02 00 00 00 acts as a separator. Four bytes prior to it are the Big Endian length of the data sequence, including the length bytes.
  3. Another separator within the content of the message is the A1 byte, followed by the little-endian length of the following data sequence excluding the length byte.
  4. In specific four-byte groups, 00 53 seems some kind of message identifier, followed by incrementing bytes (message IDs, maybe), and C0 for messages that are responses and D0 for messages that are requests. This group also acts as a separator.
  5. Next four bytes following the message identifier represent the length of the data structure excluding the length packets and message identifier.

When we finally receive the packet with the playlist wait times, it comes in response to the exact same 141 byte-long lobby initialization message. We can store it and re-use it for later.

When the playlist response arrives, we’re looking for the message ID 00 53 75 B0 inside the binary response. What follows is, as mentioned earlier, the length of the Bond-encoded response, and then the Bond data itself.

Let’s now start exploring this a bit further by trying to put our hands on the keyboard.

Automating the data acquisition #

With the fundamental understanding in place, I wanted to now figure out how to automate it end-to-end so that there is no interaction that is needed from me to be able to constantly get the wait times and analyze them over time.

Bootstrapping the lobby #

The data I want is tackled by the lobby AMQP endpoint. To be able to connect to the lobby AMQP API, we first need to bootstrap a new lobby. Without doing this, establishing a socket connection to the AMQP endpoint and sending the “hello” messages will result in nothing - you won’t get any data back other than an error.

Luckily, looking at what Halo Infinite was doing, I learned that there is a helpful endpoint that tackles this scenario:

https://lobby-hi.svc.halowaypoint.com
    /hi
    /lobbies
    /LOBBY_GUID
    /players
    /xuid(YOUR_XUID)?auth=st

To create a lobby with this endpoint, we need to issue a PUT request with the lobby bootstrap payload. The payload is a bit too “wordy” to include here, but it’s worth calling out the following:

  1. It’s not AMQP-formatted - it’s a standard Bond-encoded payload.
  2. It contains information about:
    1. The player XUID.
    2. The telemetry session ID.
    3. The flight (or, as we know it - clearance) in two distinct places.
    4. A server nonce, that can be safely ignored.

If you use a tool like br you can expand the structure and see exactly where the data is stored. I did just that and then tried to mimic the layout of the payload via C# classes, that looked like this:

namespace WaitTimes.Models
{
    [Bond.Schema]
    public class LobbyBootstrapEnvelope
    {
        [Bond.Id(0)]
        public Core Core { get; set; }

        [Bond.Id(1)]
        public SubCore SubCore { get; set; }

        [Bond.Id(2)]
        public List<int> ListOfLobbyActivationNonces { get; set; }

        [Bond.Id(3)]
        public List<Definition> Definitions { get; set; }
    }
}

I am not going to include everything here because, once again - it’s quite wordy, but given that structure layout and field IDs are openly available, it is possible to replicate those in code.

The process for populating the payload involved running the game, then copying the payload, running it through br, deserializing the data in C# classes, amending the values with my own (XUID, flight ID, lobby ID, and telemetry session ID), and then packaging it all up in byte[] and passing onto the API.

And that is when I encountered by first HTTP 403 Forbidden. What the heck? Double checking the payload, I noticed no abnormalities. For all intents and purposes, it looked just like what Halo Infinite itself sends over. I verified that the Spartan token (X-343-Authorization-Spartan) was properly attached, and so were all the headers. What’s going wrong?

Then, I thought that instead of using my Spartan V4 token, I will borrow one that the game sends during the exchange and see if the exact same payload will work with a different token. And it did. I managed to successfully create a new lobby. What gives? I could still use my token to call other APIs, but not the lobby creation one. Something tells me that maybe the problem is with my token.

So what could be wrong with a self-generated Spartan V4 token when it can still be used for other calls? I started sifting through Fiddler requests to see if the game is doing anything special, and soon enough I spotted the likely culprit. When the authentication occurs and the tokens are exchanged for a Xbox Secure Token Service (XSTS) token that is then exchanged for a Spartan V4 token, two things are attached in addition to the standard user token - the device and title tokens.

The official documentation gives us a hint as to what those tokens are:

The basic identity tokens - for User, Device, and Title - are provided by the Xbox Authentication Services (XAS). Each XAS is responsible for generating an identity token which specifies values for various claims that they are responsible for.

  • XASD (XAS for Devices): creates a DToken which provides a Device identity
  • XASU (XAS for Users): creates a UToken which provides a User identity
  • XAST (XAS for Titles): creates a TToken which provides a Title identity

I started looking a bit more through the existing authentication flows and deduced an approach to actually get those.

Device tokens can be obtained by implementing the Signed HTTP Request flow and including some device information along for the ride. If you’ve ever heard of Proof-of-Possession (PoP), that’s exactly what this is. We create a local key, and then using that key to sign the request that also contains metadata about the host device that talks to the Xbox services.

A title token is trickier because it is associated with a title, i.e., the game itself. Producing it programmatically without having a registered applications is not something that I wanted to even try doing, so instead I tried to replicate exactly what Halo Infinite does. Because the application is a public client, we can grab the sniffed client ID and use it for the exchange.

The Xbox Sign-In/Sign-Up (SISU) service can be used for the exchange, with the full flow looking something like this:

%%{ init: { 'theme': 'base', 'themeVariables': { 'primaryColor': '#22577a', 'primaryTextColor': '#fff', 'primaryBorderColor': '#0e6ba8', 'lineColor': '#0d1b2a', 'secondaryColor': '#6c757d', 'tertiaryColor': '#fefae0' } } }%% flowchart TD; subgraph Microsoft/Xbox Authentication API DT("Xbox Live Device Authentication") -- Device token --> SA("SISU /authenticate") -- URL to authenticate --> LA("Microsoft Account Authentication") LA -- Access and refresh tokens --> SAUTH("SISU /authorize") -- Device, User, Authorization, and Title tokens --> XT("XSTS Token") end subgraph Halo Infinite API XT -.-> ST{{"Spartan V4 Token"}} end

And sure enough, once I got the device and title tokens, produced an XSTS token, and exchanged it for a Spartan V4 token, I managed to get one step closer to bootstrapping a lobby. The response was simple, yet satisfying:

{
    "joinLobbyError": 12,
    "lobbyActivationNonce": 1265039708
}

So you have this error code here - but what does it actually mean? Is there a trick to learning how to actually handle it and what needs to be tweaked in the response?

Indeed! If we set the Accept header to application/xml we will successfully get the error name - LobbyRecovering. Not exactly helpful per-se, but pay close attention to lobbyActivationNonce. After experimenting a bit I noticed that in some of the bootstrapping requests the nonce was included in the binary (Bond-encoded) payload.

What I realized is that I can first intentionally fail the request to the lobby endpoint with the payload with the wrong nonce, get the nonce from the response, embed the nonce in the bootstrapping envelope, and re-send the request, after which I would get the desired HTTP 200 OK. This worked, but then I also realized that I can just remove the nonce altogether from the request and it will work just as well, so that’s what I ended up doing.

Reading AMQP via sockets #

With the lobby bootstrapped, I now started looking at how to establish the AMQP connection. I will preface this right away that I did not want to get into learning AMQP fully just yet.

That is, conceptually a lot of the observations above likely would ring familiar to someone who is an AMQP expert. They might astutely spot that some of the values can represent:

  1. The protocol identifier.
  2. The frame type.
  3. Channel.
  4. Frame size.

I put some learning materials on my “to do” list for the future, but for now I wanted a quick and dirty way to just get playlist wait times. If I can reliably understand the most important patterns in the binary material being exchanged I can extract the data and move on with my life.

To establish the socket connection in C#, I used the following snippet (with other piece of code explained shortly):

private static async Task GetPlaylistWaitTimes(Guid lobbyGuid)
{
    byte[] helloMessage = GetHelloMessage(@"hello.bin", haloInfiniteClient.Xuid, lobbyGuid, telemetryGuid);
    byte[] initializeMessage = File.ReadAllBytes(@"playlist-boot.bin");
    byte[] attachHandleMessage = GetAttachHandleMessage(@"attach-handle.bin", lobbyGuid, telemetryGuid);

    if (helloMessage != null)
    {
        WriteTimedLogEntry("Successfully created the socket bootstrap payload.");

        string AcceptHeader = "application/x-bond-compact-binary";
        string AcceptLanguageHeader = "en-US";
        string UserAgentHeader = "SHIVA-2043073184/6.10025.12948.0 (release; PC)";
        string SecWebsocketProtocolHeader = "AMQPWSB10";

        CancellationTokenSource source = new();
        using var ws = new ClientWebSocket();
        ws.Options.SetRequestHeader("343-Telemetry-Session-Id", telemetryGuid.ToString());
        ws.Options.SetRequestHeader("X-343-Authorization-Spartan", haloInfiniteClient.SpartanToken);
        ws.Options.SetRequestHeader("Accept", AcceptHeader);
        ws.Options.SetRequestHeader("Accept-Language", AcceptLanguageHeader);
        ws.Options.SetRequestHeader("User-Agent", UserAgentHeader);

        ws.Options.AddSubProtocol(SecWebsocketProtocolHeader);

        WriteTimedLogEntry("Connecting to lobby service...");
        await ws.ConnectAsync(new Uri("wss://lobby-hi.svc.halowaypoint.com/"), CancellationToken.None);

        WriteTimedLogEntry("Sending the hello message...");
        await ws.SendAsync(helloMessage, WebSocketMessageType.Binary, true, CancellationToken.None);

        byte[] buffer = new byte[4096];

        while (ws.State == WebSocketState.Open)
        {
            // Handle socket messages.
        }
    }
}

First, I am taking the binary content from local files that I created based on content that I saw the game exchange with the AMQP endpoint. The “hello” message was 699 bytes long, and the only three things that were changed from session to session were the player XUID (if using different accounts), the lobby GUID, and the telemetry session ID.

GetHelloMessage does “dumb” substitution of the binary material in the file with the new lobby and telemetry session GUIDs that I created during the application runtime with the help of another function, ReplaceBytes:

private static byte[]? ReplaceBytes(byte[] content, string searchedText, string replacementText)
{
    byte[] compoundResult = content;

    byte[] searchedBytes = Encoding.UTF8.GetBytes(searchedText);
    byte[] replacementBytes = Encoding.UTF8.GetBytes(replacementText);

    int index = 0;

    while (index != -1)
    {
        index = IndexOfBytes(compoundResult, searchedBytes, index);

        if (index > 0)
        {
            byte[] newFileBytes = new byte[compoundResult.Length + replacementBytes.Length - searchedBytes.Length];

            // Copy the first chunk, up until the first instance of discovered content.
            Buffer.BlockCopy(compoundResult, 0, newFileBytes, 0, index);

            // Copy the replacement bytes into the new array.
            Buffer.BlockCopy(replacementBytes, 0, newFileBytes, index, replacementBytes.Length);

            Buffer.BlockCopy(compoundResult, index + searchedBytes.Length, newFileBytes, index + replacementBytes.Length, compoundResult.Length - index - searchedBytes.Length);

            compoundResult = newFileBytes;
        }
    }

    return compoundResult;
}

initializeMessage above contains the content for the request to initialize the lobby and give us the playlist and wait time information, and attachHandleMessage is used for, what seems like, actually setting up the session before we can request data from it.

With the binary content prepared, I set up the headers that are needed for establishing the connection, mimicking what the game does, and then using ClientWebSocket to create a new socket connection.

Once the connection is established, it’s time to handle the socket messages.

Is there a way to verify that the connection was actually established and the playlist data can be obtained?

There is - and the easiest way to do that is to verify that after the initial stream of ACK messages you get a message that is more than 1,800 bytes long. If we’d be doing this the proper way and actually interpreting the AMQP content, we might be able to read the data passed in messages, but because this is “quick and dirty,” the way to spot an issue is that if post-ACK you ended up with some short messages being given to you.

What’s interesting here is that you might not even realize that you’ve had an error with the lobby bootstrapping up until you establish the AMQP connection. For example, if you set up the clearance incorrectly in the bootstrapping payload, the method there will be successful, but once you establish a socket connection to the AMQP API, you won’t be able to get any of the playlist data, so always worth double-checking that you are using the correct values.

With the connection established, I now set up the following “markers” that I saw consistently show up across the different message types being sent over:

static byte[] acknowledgeBootstrap = [0x00, 0x53, 0x14, 0xC0, 0x11, 0x0B, 0x43, 0x43];
static byte[] acknowledgeAttach = [0x00, 0x53, 0x13, 0xC0, 0x1A, 0x0B, 0x43, 0x70];
static byte[] playlistResponse = [0x00, 0x53, 0x14, 0xC0, 0x12, 0x0B, 0x43, 0x52];

static byte[] playlistMessageIdentifier = [0x00, 0x53, 0x75, 0xB0];

The above are needed to properly identity which step in the connection we’re in:

%%{ init: { 'theme': 'base', 'themeVariables': { 'primaryColor': '#22577a', 'primaryTextColor': '#fff', 'primaryBorderColor': '#0e6ba8', 'lineColor': '#0d1b2a', 'secondaryColor': '#6c757d', 'tertiaryColor': '#fefae0' } } }%% flowchart TD; INIT["Initialize AMQP pipe (Hello)"] --> ACK>"Server ACK"] --> ATT["Attach"] --> ACK2>"Server ACK"] --> REQ["Initialize lobby"] --> ACK3>"Lobby data"]

To get message data, inside the while loop after the socket is open I am trying to figure out which message I am handling:

var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
    await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
else
{
    if (result.Count >= 16)
    {
        byte[] messageClass = HandleMessage(buffer, result.Count);

        // The bootstraping routine has been acknowledged and we have a lobby.
        if (messageClass.SequenceEqual(acknowledgeBootstrap))
        {
            await ws.SendAsync(attachHandleMessage, WebSocketMessageType.Binary, true, CancellationToken.None);
        }
        else if (messageClass.SequenceEqual(acknowledgeAttach))
        {
            await ws.SendAsync(initializeMessage, WebSocketMessageType.Binary, true, CancellationToken.None);
        }
        else if (messageClass.SequenceEqual(playlistResponse))
        {
            ProcessPlaylists(buffer);

            try
            {
                // Because we processed the playlist data, we no longer need to keep the socket connection open.
                await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
                WriteTimedLogEntry("Socket closed by the client.");
            }
            catch
            {
                WriteTimedLogEntry("Socket closed by the server.");
            }
        }
    }
}

For non-ACK messages (larger than 16 bytes), I am skipping the first 8 bytes, which are metadata about the message, and grabbing the next 8 to try and “guess” the message type:

static byte[] HandleMessage(byte[] buffer, int count)
{
    WriteTimedLogEntry($"Received {count} bytes.");
    return buffer.Skip(8).Take(8).ToArray();
}

Depending on the message, I then decide whether to continue the sequence or whether I reached the playlist data. If the playlist data is reached, I start processing the data based on the logic I mentioned earlier in this blog post with the help of ProcessPlaylists:

private static async Task ProcessPlaylists(byte[] buffer)
{
    var index = IndexOfBytes(buffer, playlistMessageIdentifier, 0);
    if (index != 0)
    {
        byte[] bondLength = new byte[4];

        Buffer.BlockCopy(buffer, index + 4, bondLength, 0, 4);

        int bondContentLength = GetLength(bondLength);

        WriteTimedLogEntry($"Got length of the playlist Bond message: {bondContentLength}");

        byte[] bondContent = new byte[bondContentLength];
        Buffer.BlockCopy(buffer, index + 8, bondContent, 0, bondContentLength);

        var inputBuffer = new Bond.IO.Unsafe.InputBuffer(bondContent);
        var reader = new CompactBinaryReader<Bond.IO.Unsafe.InputBuffer>(inputBuffer, 2);

        var playlistData = Deserialize<PlaylistResponse>.From(reader);
        WriteTimedLogEntry("Got the playlist container.");

        if (playlistData != null && playlistData.Containers.Count > 0)
        {
            using var connection = new SqliteConnection($"Data Source={databasePath}");
            connection.Open();

            foreach (var playlist in playlistData.Containers[0])
            {
                Guid assetId = playlist.Information.AssetId;
                Guid versionId = playlist.Information.VersionId;

                WriteTimedLogEntry($"({assetId}/{versionId}) - {TimeSpan.FromSeconds(playlist.WaitTime).ToString(@"mm\:ss")}");
            }
        }
    }
}

Because I know which frame section to look for and how to identify the length of the Bond-encoded data (which, thankfully, is the only data in the message), I can grab the binary content and then deserialize it into a C# class - PlaylistResponse.

Recall from earlier in the blog post that the lobby initialization response is pretty large, so it would be fairly cumbersome to try and implement every single property. Instead, I knew that I needed the field with the ID equal to 51, that looked like this:

namespace WaitTimes.Models
{
    [Bond.Schema]
    internal class PlaylistResponse: BasePlaylistResponse
    {
        [Bond.Id(51)]
        public List<List<PlaylistContainer>> Containers { get; set; }
    }
}

In turn, PlaylistContainer is barebones too:

amespace WaitTimes.Models
{
    [Bond.Schema]
    public class PlaylistContainer
    {
        [Bond.Id(2)]
        public double WaitTime { get; set; }

        [Bond.Id(3)]
        public PlaylistInformation Information { get; set; }
    }
}

And so is PlaylistInformation:

using Bond;

namespace WaitTimes.Models
{
    [Bond.Schema]
    public class PlaylistInformation
    {
        [Bond.Id(0)]
        public int SomeValue { get; set; }

        [Bond.Id(1)]
        public GUID AssetId { get; set; }

        [Bond.Id(2)]
        public GUID VersionId { get; set; }
    }
}

What’s nice about the Bond.CSharp package used here is that I can use proper GUID types that are Bond-specific (use the four values you could spot in screenshots above) that can be converted to CLR-specific Guid types. It’s really neat.

Once I extract the Bond data from the message and then transform it into proper programmable types, I can now track wait times!

Wait times identified for Halo Infinite playlists shown in a terminal window in Windows.
Wait times identified for Halo Infinite playlists shown in a terminal window in Windows.

Mission accomplished, or at least the first step of said mission.

Analyzing the data #

This whole investigation took me four days of tinkering with the various tokens, responses, trying to fiddle with just the right combination of API requests in JSON and Bond formats, and tackling cryptic errors and mitigation matrices. But at the end, I managed to get the wait times and start aggregating them into a SQLite database.

Wait times for Halo Infinite playlists as seen DB Browser for SQLite.
Wait times for Halo Infinite playlists as seen DB Browser for SQLite.

I ran my script for the past day (a bit less) and ended up with a pretty nifty representation of wait time changes across all 18 of the spotted playlists. All times below are in seconds, measured in 10-minute intervals from the West Coast of the United States (times in Pacific Time). I found that the data is updated about once every ten minutes anyway, so there is no point in polling it more frequently. Region-based wait time detection will be a topic for another article.

Ranked Tactical #

Playlist ID: 57E417DD-7366-4DDA-9BDD-2802151D5E81

Version ID: 0F1585AD-182E-4362-8237-00D99CC9B5E6

FFA Slayer #

Playlist ID: F6C93DDD-A623-41B1-B9E3-81632FF73CFB

Version ID: 134CFCFD-89F6-439B-BCF1-B07AFF4DA1D8

Husky Raid #

Playlist ID: DA024C44-7C2A-49BB-A6FF-8D91AC179900

Version ID: 19BFB62E-76DD-46BF-AB6E-180E69B8873C

Tactical Slayer #

Playlist ID: 70BB9184-E674-4307-8846-239AB4A30CB6

Version ID: 3CBCD2A3-A358-4F91-A52B-F994F33002CF

Halo 3 Refueled #

Playlist ID: 83EC8A72-E539-4CBE-948A-E53E5653B733

Version ID: 3D114D05-D3E5-4F6B-BDAC-75AB33156D15

Team Slayer #

Playlist ID: AA41F6A9-51BE-4F25-A53F-48192CE14DE7

Version ID: 45633CC5-D7BB-48FB-9A03-E04F65351569

Big Team Social #

Playlist ID: 7DE5ED5B-381E-49E5-B334-D959056DBC2B

Version ID: 468C48D6-2E3F-4D3F-A805-995B6509217D

Firefight: King of the Hill #

Playlist ID: 96AEDF55-1C7E-46D5-BDAF-19A1329FB95D

Version ID: 4C2D0A3B-C783-4B95-A9FE-8327CD0588D1

Quick Play #

Playlist ID: BDCEEFB3-1C52-4848-A6B7-D49ACD13109D

Version ID: 5098837D-2205-41F3-9546-31ADA3B544DA

Team Snipers #

Playlist ID: 325C18A5-D85B-4BA6-B98F-21465D9C19E2

Version ID: 5C2CB734-8D3B-42DD-BDF3-5947E63A8525

Team Doubles #

Playlist ID: 73B48E1E-05C4-4004-927D-965549B28396

Version ID: 844DCEF0-7F89-4591-953B-022EE7ECBC8C

Firefight: Legendary King of the Hill #

Playlist ID: 759021FE-1D82-470F-A2E6-E431300B384B

Version ID: 89AC4696-2C88-4071-84C1-5D9B7F3E8846

Workshop: Squad Battle Networking #

Playlist ID: F8B6ABF1-55BD-49A4-A2CA-EE42262E10E9

Version ID: 8C16233F-53A8-4388-862A-23D7B3E6C489

Super Fiesta #

Playlist ID: 4829F027-A9AF-4B2F-86DD-7B290D6BB0A4

Version ID: A4EC61AF-D0F4-4DA7-B489-A674619406AD

Infection #

Playlist ID: 00CD3AB8-4B24-4181-8493-7AEE34751F52

Version ID: AC51C51A-E0E9-4276-B37A-9E32AB3E24BA

Firefight: Heroic King of the Hill #

Playlist ID: D8AC67E8-647C-4602-8AF0-F42012BA8DD8

Version ID: B1D3EAA1-4CE2-4B43-A25F-8D2F8F2CE174

Big Team Battle #

Playlist ID: DC4929DE-216C-43BC-B207-1702253F4576

Version ID: C487B0AF-5B4A-4576-8AE7-34FF9773A20F

Ranked Arena #

Playlist ID: EDFEF3AC-9CBE-4FA2-B949-8F29DEAFD483

Version ID: ED73099A-255D-4338-B636-ECCA09E3A360

Conclusion #

As it turns out, in the grand expanse of the Halo Infinite universe, my journey into the heart of the game’s AMQP API has been nothing short of an odyssey. Much like a well-executed Warthog maneuver, the knowledge I’ve gained through this experiment empowers myself and others to move faster and to get playlist data a bit easier than launching the game to see that you have too long to wait for the next match.

My next step here will be building a web-based experience that showcases this data in a bit more readable format for others, making it easier to track the times without running any apps or scripts locally.