defmodule A2S do
@moduledoc """
A collection of pure functions for forming A2S challenges, requests, and parsing responses.
"""
## Type Definitions
defmodule Info do
@moduledoc """
Struct representing an [A2S_INFO](https://developer.valvesoftware.com/wiki/Server_queries#Response_Format) response.
"""
defstruct [
:protocol,
:name,
:map,
:folder,
:game,
:appid,
:players,
:max_players,
:bots,
:server_type,
:environment,
:visibility,
:vac,
:version,
# Extra Data Fields (Not guaranteed)
:gameport,
:steamid,
:spectator_port,
:spectator_name,
:keywords,
:gameid
]
@type t :: %Info{
protocol: byte(),
name: String.t(),
map: String.t(),
folder: String.t(),
game: String.t(),
appid: integer(),
players: byte(),
max_players: byte(),
bots: byte(),
server_type: :dedicated | :non_dedicated | :proxy | :unknown,
environment: :linux | :windows | :mac | :unknown,
visibility: :public | :private,
vac: :secured | :unsecured | :unknown,
# Extra Data Fields
gameport: :inet.port_number() | nil,
steamid: integer() | nil,
spectator_port: :inet.port_number() | nil,
spectator_name: String.t() | nil,
keywords: String.t() | nil,
gameid: integer() | nil
}
end
defmodule Players do
@moduledoc """
Struct representing an [A2S_PLAYER](https://developer.valvesoftware.com/wiki/Server_queries#Response_Format_2) response.
"""
defstruct [
:count,
:players
]
@type t :: %Players{
count: byte(),
players: list(A2S.Player.t())
}
end
defmodule Player do
@moduledoc """
Struct representing a player entry in an [A2S_PLAYER](https://developer.valvesoftware.com/wiki/Server_queries#Response_Format_2) response.
"""
defstruct [
:index,
:name,
:score,
:duration
]
@type t :: %Player{
index: integer(),
name: String.t(),
score: integer(),
duration: float()
}
end
defmodule Rules do
@moduledoc """
Struct representing an [A2S_RULES](https://developer.valvesoftware.com/wiki/Server_queries#Response_Format_3) response.
"""
defstruct [
:count,
:rules
]
@type t :: %Rules{
count: byte(),
rules: list({String.t(), String.t()})
}
end
defmodule MultiPacketHeader do
@moduledoc """
Struct representing a [multi-packet response header](https://developer.valvesoftware.com/wiki/Server_queries#Multi-packet_Response_Format).
"""
defstruct [
:id,
:total,
:index,
:size
]
@type t :: %MultiPacketHeader{
id: integer(),
total: byte(),
index: byte(),
size: integer()
}
end
## Constants
@queries [:info, :players, :rules]
# <<0xFF, 0xFF, 0xFF, 0xFF>>
@simple_header <<-1::signed-32-little>>
# <<0xFF, 0xFF, 0xFF, 0xFE>> (primarily for A2S_RULES)
@multipacket_header <<-2::signed-32-little>>
# 0x41
@challenge_response_header ?A
# 0x54 / 0x49
@info_request_header ?T
@info_response_header ?I
# 0x55 / 0x44
@player_request_header ?U
@player_response_header ?D
# 0x56 / 0x45
@rules_challenge_header ?V
@rules_response_header ?E
@doc """
Returns the challenge request packet for the given query type.
"""
@spec challenge_request(:info | :players | :rules) :: binary()
def challenge_request(:info) do
<<@simple_header, @info_request_header, "Source Engine Query\0">>
end
def challenge_request(:players) do
<<@simple_header, @player_request_header, -1::signed-32-little>>
end
def challenge_request(:rules) do
<<@simple_header, @rules_challenge_header, -1::signed-32-little>>
end
def challenge_request(term) do
raise ArgumentError, """
Unknown A2S query #{inspect(term)}, expected one of #{inspect(@queries)}.
"""
end
@doc """
Parses a challenge response packet. Some game servers don't implement the challenge flow and will
immediately respond with the requested data. In that case `:immediate` will be returned with the data.
If the server returns data immediately, and that data is multipacket, `:multipacket` will be returned.
"""
@spec parse_challenge(binary()) ::
{:challenge, binary()}
| {:immediate, {:info, A2S.Info.t()}}
| {:immediate, {:players, A2S.Players.t()}}
| {:immediate, {:rules, A2S.Rules.t()}}
| {:multipacket, {A2S.MultiPacketHeader.t(), binary()}}
| {:error, A2S.ParseError.t()}
def parse_challenge(<<@simple_header, @challenge_response_header, challenge::bytes>>) do
{:challenge, challenge}
end
# reasonably clean wrapper around an ugly API
def parse_challenge(<<@simple_header, _::bytes>> = packet) do
case parse_response(packet) do
{:error, reason} -> {:error, reason}
{:multipacket, result} -> {:multipacket, result}
response -> {:immediate, response}
end
end
@spec parse_challenge!(binary()) ::
{:challenge, binary()}
| {:immediate, {:info, A2S.Info.t()}}
| {:immediate, {:players, A2S.Players.t()}}
| {:immediate, {:rules, A2S.Rules.t()}}
| {:multipacket, {A2S.MultiPacketHeader.t(), binary()}}
def parse_challenge!(packet) do
case parse_challenge(packet) do
{:error, error} -> raise error
result -> result
end
end
@spec sign_challenge(:info | :players | :rules, challenge :: binary()) :: binary()
def sign_challenge(:info, challenge) when is_binary(challenge) do
<<@simple_header, @info_request_header, "Source Engine Query\0", challenge::bytes>>
end
def sign_challenge(:players, challenge) when is_binary(challenge) do
<<@simple_header, @player_request_header, challenge::bytes>>
end
def sign_challenge(:rules, challenge) when is_binary(challenge) do
<<@simple_header, @rules_challenge_header, challenge::bytes>>
end
def sign_challenge(term, _challenge) when term not in @queries do
raise ArgumentError, """
Unknown A2S query #{inspect(term)}, expected one of #{inspect(@queries)}.
"""
end
@spec parse_response(binary()) ::
{:info, A2S.Info.t()}
| {:multipacket, {A2S.MultiPacketHeader.t(), binary()}}
| {:players, A2S.Players.t()}
| {:rules, A2S.Rules.t()}
| {:error, A2S.ParseError.t()}
def parse_response(<<@simple_header, type_header::8, payload::bytes>> = data) do
{query_type, parse_fn} =
case type_header do
@info_response_header -> {:info, &parse_info_payload/1}
@player_response_header -> {:players, &parse_player_payload/1}
@rules_response_header -> {:rules, &parse_rules_payload/1}
_ -> {:error, %A2S.ParseError{response_type: type_header, data: data}}
end
try do
{query_type, parse_fn.(payload)}
rescue
error -> {:error, %A2S.ParseError{response_type: query_type, data: data, exception: error}}
end
end
def parse_response(<<@multipacket_header, payload::bytes>> = data) do
try do
{:multipacket, parse_multipacket_part(payload)}
rescue
error ->
{:error, %A2S.ParseError{response_type: :multipacket_part, data: data, exception: error}}
end
end
def parse_response(packet) when is_binary(packet) do
{:error, %A2S.ParseError{response_type: :unknown, data: packet}}
end
@spec parse_response!(binary()) ::
{:info, Info.t()}
| {:players, Player.t()}
| {:rules, Rules.t()}
| {:multipacket, {MultiPacketHeader.t(), binary()}}
def parse_response!(packet) when is_binary(packet) do
case parse_response(packet) do
{:error, error} -> raise error
result -> result
end
end
## Multipacket handling
@spec parse_multipacket_response(list({MultiPacketHeader.t(), binary()})) ::
{:info, Info.t()}
| {:players, Player.t()}
| {:rules, Rules.t()}
| {:error, A2S.ParseError.t()}
def parse_multipacket_response(packets) when is_list(packets) do
# read the first packet header
packets = sort_multipacket(packets)
packets
|> glue_packets()
|> parse_response()
end
@spec parse_multipacket_response!(list({MultiPacketHeader.t(), binary()})) ::
{:info, Info.t()}
| {:players, Player.t()}
| {:rules, Rules.t()}
def parse_multipacket_response!(packets) when is_list(packets) do
case parse_multipacket_response(packets) do
{:error, reason} -> raise reason
result -> result
end
end
@spec parse_multipacket_part(packet :: binary()) :: {MultiPacketHeader.t(), binary()}
# first packet, check for compression
defp parse_multipacket_part(<<i::bits-40, 0::8, rest::bytes>>) do
import Bitwise, only: [&&&: 2]
<<id::signed-32-little, total::8>> = i
<<size::signed-16-little, payload::bytes>> = rest
compressed = (id &&& 0x80000000) !== 0
if compressed, do: raise("compression not supported")
{%MultiPacketHeader{
id: id,
total: total,
index: 0,
size: size
}, payload}
end
# other packets
defp parse_multipacket_part(<<i::bits-64, payload::bytes>>) do
<<
id::signed-32-little,
total::8,
index::8,
size::signed-16-little
>> = i
{%MultiPacketHeader{
id: id,
total: total,
index: index,
size: size
}, payload}
end
defp parse_multipacket_part(_), do: raise("bad multipacket part")
## A2S_INFO Parsing
defp parse_info_payload(data) when is_binary(data) do
<<protocol::8, data::bytes>> = data
{name, data} = read_null_term_string(data)
{map, data} = read_null_term_string(data)
{folder, data} = read_null_term_string(data)
{game, data} = read_null_term_string(data)
<<
id::16,
players::8,
max_players::8,
bots::8,
server_type::8,
environment::8,
visibility::8,
vac::8,
data::bytes
>> = data
{version, data} = read_null_term_string(data)
{gameport, steamid, spectator_port, spectator_name, keywords, gameid} = parse_edf(data)
%Info{
protocol: protocol,
name: name,
map: map,
folder: folder,
game: game,
appid: id,
players: players,
max_players: max_players,
bots: bots,
server_type: parse_server_type(server_type),
environment: parse_environment(environment),
visibility: parse_visibility(visibility),
vac: parse_vac(vac),
version: version,
gameport: gameport,
steamid: steamid,
spectator_port: spectator_port,
spectator_name: spectator_name,
keywords: keywords,
gameid: gameid
}
end
defp parse_server_type(t) do
case t do
?d -> :dedicated
?l -> :non_dedicated
?p -> :proxy
_ -> :unknown
end
end
defp parse_environment(e) do
case e do
?l -> :linux
?w -> :windows
?m -> :mac
?o -> :mac
_ -> :unknown
end
end
defp parse_visibility(v) do
case v do
0 -> :public
1 -> :private
_ -> :unknown
end
end
defp parse_vac(0), do: :unsecured
defp parse_vac(1), do: :secured
defp parse_vac(_), do: :unknown
defp parse_edf(<<>>), do: %{}
defp parse_edf(<<edf::8, data::bytes>>) do
import Bitwise, only: [&&&: 2]
{gameport, data} =
if (edf &&& 0x80) !== 0 do
<<gameport::16-little, data::bytes>> = data
{gameport, data}
else
{nil, data}
end
{steamid, data} =
if (edf &&& 0x10) !== 0 do
<<steamid::signed-64-little, data::bytes>> = data
{steamid, data}
else
{nil, data}
end
{spec_port, spec_name, data} =
if (edf &&& 0x40) !== 0 do
<<port::signed-16-little, data::bytes>> = data
{name, data} = read_null_term_string(data)
{port, name, data}
else
{nil, nil, data}
end
{keywords, data} =
if (edf &&& 0x20) !== 0 do
read_null_term_string(data)
else
{nil, data}
end
gameid =
if (edf &&& 0x01) !== 0 do
<<gameid::signed-64-little>> = data
gameid
else
nil
end
{gameport, steamid, spec_port, spec_name, keywords, gameid}
end
## A2S_PLAYER Parsing
defp parse_player_payload(<<count::8, rest::bytes>>) do
%Players{
count: count,
players: read_players(rest)
}
end
@spec read_players(binary()) :: list(Player.t())
defp read_players(data, players \\ [])
defp read_players(<<>>, players), do: Enum.reverse(players)
defp read_players(data, players) do
<<index::8, data::bytes>> = data
{name, data} = read_null_term_string(data)
<<score::signed-32-little, data::bytes>> = data
<<duration::float-32-little, data::bytes>> = data
player = %Player{
index: index,
name: name,
score: score,
duration: duration
}
read_players(data, [player | players])
end
## A2S_RULES Parsing
defp parse_rules_payload(<<count::signed-16-little, data::bytes>>) do
%Rules{
count: count,
rules: read_rules(data)
}
end
@spec read_rules(binary()) :: list(Rule.t())
defp read_rules(data, rules \\ [])
defp read_rules(<<>>, rules), do: Enum.reverse(rules)
defp read_rules(data, rules) do
{name, data} = read_null_term_string(data)
{value, data} = read_null_term_string(data)
read_rules(data, [{name, value} | rules])
end
## Helper functions
# Accumulates bytes from data to the next null terminator returning the resulting string and remainder.
defp read_null_term_string(data, str \\ [])
defp read_null_term_string(<<0, rest::bytes>>, str) do
{str |> IO.iodata_to_binary() |> String.replace_invalid(), rest}
end
defp read_null_term_string(<<char::8, rest::bytes>>, str) do
read_null_term_string(rest, [str, char])
end
defp glue_packets(packets, acc \\ [])
defp glue_packets([], acc), do: IO.iodata_to_binary(acc)
defp glue_packets([{_multipacket_header, payload} | tail], acc) do
glue_packets(tail, [acc | payload])
end
defp sort_multipacket(collected),
do: Enum.sort(collected, fn {%{index: a}, _}, {%{index: b}, _} -> a < b end)
end