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:
- Setting up a public server requires registering for an Authentication Key ("AuthKey")
- This key is "Used to identify your server with the backend."
- The examples in the docs look like standard RFC 4122 UUIDs
- The port forwarding configuration (for servers behind NAT) needs both TCP and UDP
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:
- The program is open-source, so we can trace the client program source directly (static)
- Since we're trying to observe a network call, we can use packet capture software (e.g. Wireshark) to see what network resources the client accesses.
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.
There were a few embedded URLs that were potentially interesting, and they're easy to search for via GitHub or grep:
https://beammp.com/builds/launcher?version=true
- returns the latest BeamMP launcher version, so it can update itselfhttps://beammp.com/builds/launcher?download=true
- serves the latest version of the launcher, again for self-updateshttps://backup1.beammp.com/...
- secondary / fallback server forbeammp.com
https://auth.beammp.com/userlogin
- user login form handlerhttps://backend.beammp.com/servers
- used inNetwork.c
'sParse(string,SOCKET)
function in response to aB
message
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