flyinghyrax.net

Querying the BeamMP Server List with PowerShell

BeamMP is a modification for the BeamNG.drive automotive driving simulator that adds multiplayer capability to the base game.

The BeamMP team provides a server program so anyone can host their own dedicated server, as well as a "launcher" client program that handles patching the base game client and communicating with servers. It's a pretty impressive project that works surprisingly well given that it's an all-volunteer effort. And thankfully, it is also all open source! There is a BeamMP organization on GitHub including (among other things) the server, launcher, and game mod implementations.

I've recently enjoyed playing BeamMP, in particular the "CaRP" mod community servers. It's a popular mod and the servers are often crowded, so sometimes I'd launch the game only to find the servers all full! So I wondered if I could somehow directly query for the server list for servers with open slots, without having to launch the game.

It turned out to be simple to do!

Initial Research

The first thing to do is always to check for official documentation. BeamMP has official documentation for running your own server, so perhaps this included some kind of public server status API?

The official documentation has a guide for creating your own server as well as for Server Maintenance and Server-side Lua Scripting. Although there does not appear to be an official, documented status API, I found the following things notable:

Although I didn't end up needing to interact with the server program to accomplish what I wanted, these could be relevant for future tinkering with the client/server protocols.

Static Analysis

Since there wasn't anything obvious in the documentation, I next tried to figure out how the client queried the backend for the server list.

There are two main approaches to analyzing program behavior: static and dynamic. Static analysis consists of tracing the source code (or decompiled object code) of a program to determine its behavior. Dynamic analysis is to run the program and somehow observe how it behaves. In this case we could easily do either:

Packet captures may have been the fastest way to identify the exact thing I'm trying to find, but since I was also interested in how the client worked in general I decided to take a look at the client source.

The client is divided into two components: a Launcher and a BeamNG.drive mod.

Tha launcher is written in C++ and the code for is pretty clear and easy to follow. The the program entry point calls just a few functions and they have pretty clear roles:

int main(int argc, char* argv[]) {
    // ...
    GetEP(argv[0]);

    InitLauncher(argc,argv);

    try {
        LegitimacyCheck();
    }catch (std::exception& e){
        fatal("Main 1 : " + std::string(e.what()));
    }

    PreGame(GetGameDir());
    InitGame(GetGameDir());
    CoreNetwork();

    // ...
}

Skimming each function provides a rough idea of the tasks performed by each one. The CoreNetwork function and its callees were the most interesting. The launcher listens on a local socket and creates a background thread to handle each connection.

Call graph rooted at 'main'. 'main' calls 'CoreNetwork', which calls 'CoreMain' in an infinite loop. 'CoreMain' contains the socket setup and connection handling loop.
Call graph rooted at 'main', showing the primary functions called by 'main' and the path to the network 'Parse' function that handles client messages.

There were a few embedded URLs that were potentially interesting, and they're easy to search for via GitHub or grep:

That last one seems relevant. On the off chance that it works, we can try HTTP POST to backend.beammp.com/servers and see what we get:

PS C:\Users\mrsei> Invoke-WebRequest -Method Post -Uri 'https://backend.beammp.com/servers'

StatusCode        : 200
StatusDescription : OK
Content           : [{"players":"8","playerslist":"guest3294259;guest6386025;a_random_user.mp.4;valnoa
                    ;IInfierno;I_LUV_MILFS;OrgeEspace;Pufferinasuit;","maxplayers":"8","ip":"45.137.11
                    6.59","location":"GB","port":"27200"…
RawContent        : HTTP/1.1 200 OK
                    Date: Sun, 06 Feb 2022 20:29:40 GMT
                    Connection: keep-alive
                    Content-Security-Policy: default-src 'self';base-uri
                    'self';block-all-mixed-content;font-src 'self' https: data:;frame-anc…
Headers           : {[Date, System.String[]], [Connection, System.String[]],
                    [Content-Security-Policy, System.String[]], [x-dns-prefetch-control,
                    System.String[]]}
Images            : {}
InputFields       : {}
Links             : {}
RawContentLength  : 377425
RelationLink      : {}

Well how about that! That looks like JSON:

PS C:\Users\mrsei> Invoke-WebRequest -Method Post -Uri 'https://backend.beammp.com/servers' `
>> | Select-Object -ExpandProperty Headers `
>> | ForEach-Object { $_["Content-Type"]}
application/json; charset=utf-8

There's more to see as far as how the BeamMP mod (written in Lua) communicates with the BeamMP launcher (the C++ program above) - but it's not really necessary for my original goal.

Querying with PowerShell

Before going any further, it's a nice idea to have the response data saved in a variable, so I'm not querying the backend repeatedly while debugging my PowerShell pipelines:

PS C:\Users\mrsei> Invoke-WebRequest -Method Post -Uri 'https://backend.beammp.com/servers' -OutVariable Response
...

Now let's have a look at the data:

PS C:\Users\mrsei> $Response | Select-Object -ExpandProperty Content | ConvertFrom-Json | Get-Member

   TypeName: System.Management.Automation.PSCustomObject

Name          MemberType   Definition
----          ----------   ----------
Equals        Method       bool Equals(System.Object obj)
GetHashCode   Method       int GetHashCode()
GetType       Method       type GetType()
ToString      Method       string ToString()
cversion      NoteProperty string cversion=2.0
ip            NoteProperty string ip=45.137.116.59
location      NoteProperty string location=GB
map           NoteProperty string map=/Levels/west_coast_usa/info.json
maxplayers    NoteProperty string maxplayers=8
modlist       NoteProperty string modlist=
modstotal     NoteProperty string modstotal=0
modstotalsize NoteProperty string modstotalsize=0
official      NoteProperty bool official=False
owner         NoteProperty string owner=Nikmub (Heker) i hek yo ip#7957
players       NoteProperty string players=2
playerslist   NoteProperty string playerslist=guest3294259;a_random_user.mp.4;
port          NoteProperty string port=27200
pps           NoteProperty string pps=13
sdesc         NoteProperty string sdesc=a ZAP-Hosting BeamMP Gameserver
sname         NoteProperty string sname=Nikmub
time          NoteProperty datetime time=2022-02-06 20:44:22
version       NoteProperty string version=2.3.3

All those NoteProperty members are our JSON fields. We even get to see some nice sample data in the "Definition" column. To reduce typing, I'll save the decoded JSON in a variable as well:

PS C:\Users\mrsei> $ServerList = ($Response | Select-Object -ExpandProperty Content | ConvertFrom-Json)

Now it is easy to select and re-format things as needed. For instance, here's all the official servers:

PS C:\Users\mrsei> $ServerList | Where-Object -Property official -EQ True `
>> | Format-Table -Property sname,location,players,maxplayers

sname                                                             location players maxplayers
-----                                                             -------- ------- ----------
Official BeamMP Server | Utah (1) | 1 Vehicle                     DE       9       10
Official BeamMP Server | Gridmap [Modded] | 1 Vehicle             DE       2       10
Official BeamMP ^6Development/Testing ^rServer                    FI       8       32
Official BeamMP Server | Italy (1) | 1 Vehicle                    DE       7       10
Official BeamMP Server | Italy (1) | 2 Vehicles                   US       7       10
Official BeamMP Server | Gridmap v2 (1) | 1 Vehicle               DE       8       10
...

And servers with a mod we're interested in:

PS C:\Users\mrsei> $ServerList | Where-Object -Property modlist -Like '*/carp;*' | ft -prop sname

sname
-----
[^b🐟 CaRP^r Test Server v0.21][^4Jungle Rock Island^r]  🎯 ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapitalism^r™
[^b🐟 CaRP^r Test Server v0.21][^5Italy^r]  🎯 ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapitalism
[^b🐟 CaRP^r Test Server][^4East Coast^r]  🎯 ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapit
[^b🐟 CaRP^r Test Server][^4East Coast 2^r]  🎯 ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapit
[^b🐟 CaRP^r Test Server][^5Italy 2^r]  🎯 ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapitalism
[^b🐟 CaRP^r Test Server][^4West Coast USA 2^r]   🎯 ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapit
[^b🐟 CaRP^r Test Server][^4West Coast USA^r] ^6Real Missions^r™️ / 💵 ^eActual In-Game Economy 😱 ^r™️ / 🎩 ^bCapita

We're seeing something interesting in the server names - ^b, ^r, ^5, etc. Turns out these are text formatting escape codes and the BeamMP docs have a full list of them under "Customize the look of your server name".

Rather than try to translate these into shell formatting codes Windows Terminal will understand, we can just remove them:

PS C:\Users\mrsei> function Remove-EscapeCodes ($ServerName) {
>>   $ServerName -replace '\^\w',''
>> }

PS C:\Users\mrsei> Remove-EscapeCodes -ServerName "^bTest ^r"
Test

PS C:\Users\mrsei> function Format-ServerName ($ServerInfo) {
>>   $ServerInfo.sname = (Remove-EscapeCodes -ServerName $ServerInfo.sname)
>>   $ServerInfo
>> }

PS C:\Users\mrsei> $ServerList | ForEach-Object -Process { Format-ServerName -ServerInfo $_ } `
>> | Where-Object -Property modlist -Like '*/carp;*' `
>> | Select-Object -First 1 -Property sname

sname
-----
[🐟 CaRP Test Server v0.21][Jungle Rock Island]  🎯 Real Missions™️ / 💵 Actual In-Game Economy 😱 ™️ / 🎩 Capitalism™

While we're at it, there are a few other things we can do to help with querying and readability:

# 2 of the fields are semicolon-delimited lists; split them into arrays
PS C:\Users\mrsei> function Split-ListProps ($ServerInfo) {
>>   $ServerInfo.modlist = $ServerInfo.modlist -split ';'
>>   $ServerInfo.playerslist = $ServerInfo.playerslist -split ';'
>>   $ServerInfo
>> }

# the map field contains a path to a metadata file, but all we're interested in is the folder name
PS C:\Users\mrsei> function Extract-MapName ($ServerInfo) {
>>   $ServerInfo.map -match '^/levels/(.+)/' | Out-Null
>>   $ServerInfo.map = $matches[1]
>>   $ServerInfo
>> }

# a wrapper function to do all our reformatting
PS C:\Users\mrsei> function Format-ServerInfo ($ServerInfo) {
>>   $t = Format-ServerName -ServerInfo $ServerInfo
>>   $t = Split-ListProps -ServerInfo $t
>>   $t = Extract-MapName -ServerInfo $t
>>   $t
>> }

Our wrapper function is ugly, assigning an intermediate result to a variable repeatedly. This could be cleaned up by turning all our functions into Cmdlets, so they could accept pipeline input.

We can now get some pretty clean results:

$ServerList=( `
>>   Invoke-WebRequest -Method Post -Uri 'https://backend.beammp.com/servers' `
>>   | Select-Object -ExpandProperty Content `
>>   | ConvertFrom-Json `
>>   | ForEach-Object -Process { Format-ServerInfo -ServerInfo $_ } `
>> )

PS C:\Users\mrsei> $ServerList | Select -First 10 `
>> | Format-Table -Property sname,map,location,players,maxplayers,pps

sname                                                                        map                               location players maxplayers pps
-----                                                                        ---                               -------- ------- ---------- ---
Nikmub                                                                       west_coast_usa                    GB       4       8          9
Gamlin's BeamNG server                                                       utah                              FR       6       10         8
Keys BeamNG                                                                  Utah                              AU       2       10         30
Comission Of Gamers BeamMP                                                   Desert_Highway                    US       1       3          -
Tristens BeamMP Server [2 Vehicles] [Nice/Cool Mods] [discord.gg/43DXG4uTyW] utah                              US       5       10         34
  aHost  | American Road | MODDED                                            mymap                             US       0       10         -
Simply Sideways Ebisu Kita No Guests - Drift - United States                 ebisu_kita                        DE       0       15         -
//FHost [USA] -> [Utah]                                                      utah                              US       11      12         5
Simply Sideways  Rocky Mountain Drift Club No Guests - Drift - United States rocky_mountain_drift_club_in_snow DE       0       15         -
  aHost  | East Coast USA | MODDED                                           east_coast_usa                    US       1       10         -

At this point, it is easy to use PowerShell's Where-Object to filter for whatever servers we want to see, as well as other standard PowerShell data-wrangling commands like Sort-Object and Select-Object.

Final Script File

Finally, here's a cleaned up script file to demonstrate everything working together:

# Query public BeamMP servers!

[CmdletBinding()]
param (
    # filter servers by name
    [string] $Name,

    # filter for servers that have a particular mod
    [string] $Mod,

    # Only include servers that aren't full
    [switch] $NotFull,

    # Only include servers that aren't empty
    [switch] $NotEmpty
)

function Format-BeamMPServerFields {
    [CmdletBinding()]
    param (
        # Server object, as returned from BeamMP backend API
        [Parameter(Mandatory,ValueFromPipeline)]
        [object] $MpServer
    )
    
    process {
        # Remove string formatting codes from server name
        $MpServer.sname = $MpServer.sname -replace '\^\w',''

        # split semicolon-delimited lists into PS arrays
        $MpServer.playerslist = $MpServer.playerslist -split ';'
        $MpServer.modlist = $MpServer.modlist -split ';'

        # Remove redundant map metadata file path components
        $MpServer.map -match '^/levels/(.+)/' | Out-Null
        $MpServer.map = $matches[1]

        Write-Output $MpServer
    }
}

function Get-BeamMPServers {
    [CmdletBinding()]
    param (
        # URI to HTTP POST for server list    
        [string] $BackendUri = 'https://backend.beammp.com/servers'
    )

    process {
        Invoke-WebRequest -Uri $BackendUri -Method Post `
        | Select-Object -ExpandProperty Content `
        | ConvertFrom-Json `
        | Format-BeamMPServerFields `
        | Write-Output
    }
}


function IsFull {
    param (
        [Parameter(Mandatory,Position=0)]
        [object] $MpServer
    )

    $MpServer.players -eq $MpServer.maxplayers
}

function IsEmpty {
    param (
        [Parameter(Mandatory,Position=0)]
        [object] $MpServer
    )

    $MpServer.players -eq 0
}

$ServerList = Get-BeamMPServers

if ($Name) {
    Write-Debug "Filtering: .sname -ilike '*$Name*'"
    $ServerList = $ServerList | Where-Object -Property sname -ILike "*$Name*"
}

if ($Mod) {
    Write-Debug "Filtering: .modlist -icontains: '/$Mod'"
    $ServerList = $ServerList | Where-Object -Property modlist -IContains "/$Mod"
}

if ($NotFull) {
    $ServerList = $ServerList | Where-Object -FilterScript { (IsFull $_) -eq $false }
}

if ($NotEmpty) {
    $ServerList = $ServerList | Where-Object -FilterScript { (IsEmpty $_) -eq $false }
}

Write-Output $ServerList