mirror of
https://github.com/amineo/t2_server_query_elixir.git
synced 2026-01-20 18:44:45 +00:00
233 lines
7.1 KiB
Elixir
233 lines
7.1 KiB
Elixir
defmodule T2ServerQuery.PacketParser do
|
|
@moduledoc """
|
|
This module does the heavy lifting with parsing a Tribes 2 query response packet.
|
|
|
|
## UDP Packet Anatomy
|
|
|
|
### Info Packet
|
|
<<
|
|
_header :: size(192),
|
|
server_name :: bitstring
|
|
>>
|
|
|
|
### Status Packet
|
|
<<
|
|
_header :: size(48),
|
|
|
|
game_type_length :: little-integer,
|
|
game_type :: binary-size(game_type_length),
|
|
mission_type_length :: little-integer,
|
|
mission_type :: binary-size(mission_type_length),
|
|
map_name_length :: little-integer,
|
|
map_name :: binary-size(map_name_length),
|
|
|
|
_skip_a :: size(8),
|
|
|
|
player_count :: little-integer,
|
|
max_player_count :: little-integer,
|
|
bot_count :: little-integer,
|
|
|
|
_skip_b :: size(16),
|
|
|
|
server_description_length :: little-integer,
|
|
server_description :: binary-size(server_description_length),
|
|
|
|
_skip_c :: size(16),
|
|
|
|
team_count :: binary-size(1),
|
|
|
|
rest :: bitstring
|
|
>>
|
|
|
|
Notice the `_skip_(a|b|c)` mappings. I havn't quite figured out what they refer to yet but they don't seem that important. They likely relate to a few server flags like `tournament_mode`, `cpu_speed`, `is_linux`.
|
|
|
|
Refer to `T2ServerQuery.QueryResult` for what a typical struct would look like.
|
|
|
|
|
|
"""
|
|
|
|
alias T2ServerQuery.QueryResult
|
|
|
|
@doc """
|
|
This function expects both an `info` and `status` packet to be passed in that is in a `Base.encode16` format.
|
|
Normally you wouldn't need to run this function manually since it's called in a pipeline from the main `T2ServerQuery.query`
|
|
|
|
"""
|
|
def init({:error, host}, _) do
|
|
results = %QueryResult{}
|
|
|
|
{:error,
|
|
%{results |
|
|
server_status: :offline,
|
|
server_name: host,
|
|
server_description: "Host unreachable, timed out."
|
|
}
|
|
}
|
|
end
|
|
|
|
def init(info_packet, status_packet) when is_binary(info_packet) and is_binary(status_packet) do
|
|
|
|
info_results = info_packet
|
|
|> decode_clean_packet()
|
|
|> handle_info_packet()
|
|
|
|
|
|
status_results = status_packet
|
|
|> decode_clean_packet()
|
|
|> handle_status_packet()
|
|
|> parse_player_team_scores()
|
|
|
|
|
|
pack_results({:ok, status_results, info_results})
|
|
end
|
|
|
|
defp pack_results({:ok, status_results, info_results}) do
|
|
results = %QueryResult{}
|
|
|
|
{:ok,
|
|
%{results |
|
|
server_status: :online,
|
|
server_name: info_results.server_name,
|
|
game_type: status_results.game_type,
|
|
mission_type: status_results.mission_type,
|
|
map_name: status_results.map_name,
|
|
player_count: status_results.player_count,
|
|
max_player_count: status_results.max_player_count,
|
|
bot_count: status_results.bot_count,
|
|
server_description: status_results.server_description,
|
|
team_count: status_results.team_count,
|
|
teams: status_results.teams,
|
|
players: status_results.players
|
|
}
|
|
}
|
|
end
|
|
|
|
# Info packet structure
|
|
defp handle_info_packet({:ok, info_packet}) do
|
|
<<
|
|
_header :: size(192),
|
|
server_name :: bitstring
|
|
>> = info_packet
|
|
|
|
%{server_name: server_name}
|
|
end
|
|
|
|
|
|
# Status packet structure
|
|
defp handle_status_packet({:ok, status_packet}) do
|
|
#IO.inspect status_packet, limit: :infinity
|
|
<<
|
|
_header :: size(48),
|
|
|
|
game_type_length :: little-integer,
|
|
game_type :: binary-size(game_type_length),
|
|
mission_type_length :: little-integer,
|
|
mission_type :: binary-size(mission_type_length),
|
|
map_name_length :: little-integer,
|
|
map_name :: binary-size(map_name_length),
|
|
|
|
_skip_a :: size(8),
|
|
|
|
player_count :: little-integer,
|
|
max_player_count :: little-integer,
|
|
bot_count :: little-integer,
|
|
|
|
_skip_b :: size(16),
|
|
|
|
server_description_length :: little-integer,
|
|
server_description :: binary-size(server_description_length),
|
|
|
|
_skip_c :: size(16),
|
|
|
|
team_count :: binary-size(1),
|
|
|
|
rest :: bitstring
|
|
>> = status_packet
|
|
|
|
|
|
%{
|
|
game_type_length: game_type_length,
|
|
game_type: game_type,
|
|
mission_type_length: mission_type_length,
|
|
mission_type: mission_type,
|
|
map_name_length: map_name_length,
|
|
map_name: map_name,
|
|
player_count: player_count,
|
|
max_player_count: max_player_count,
|
|
bot_count: bot_count,
|
|
server_description_length: server_description_length,
|
|
server_description: server_description,
|
|
team_count: String.to_integer(team_count),
|
|
teams: [],
|
|
players: [],
|
|
data: rest
|
|
}
|
|
end
|
|
|
|
|
|
|
|
# Take the ..rest of the status packet and parse out the team and player scores
|
|
defp parse_player_team_scores(packet) do
|
|
## Break the status query packet into multiple parts
|
|
## raw_game_info contains the map, gametype, mod, and description
|
|
## raw_players_info contains players, assigned team and score
|
|
[raw_team_scores | raw_players_info] = String.split(packet.data, "\n#{packet.player_count}", trim: true)
|
|
|
|
pack_teams = raw_team_scores
|
|
|> String.trim_leading
|
|
|> String.split("\n")
|
|
|> Enum.map(&parse_team_scores(&1))
|
|
|> Enum.to_list
|
|
|
|
|
|
pack_players = raw_players_info
|
|
|> clean_player_info()
|
|
|> Enum.map(&parse_player_scores(&1))
|
|
|> Enum.to_list
|
|
|
|
# We're done parsing the data key so we can remove it from our compiled struct
|
|
cleaned_packet = Map.delete(packet, :data)
|
|
%{cleaned_packet | teams: pack_teams, players: pack_players }
|
|
end
|
|
|
|
|
|
# Convert player array into a map
|
|
# parse_player_scores(["Inferno", "305"])
|
|
# > %{team: "Inferno", score: 305}
|
|
defp parse_team_scores(raw_team_scores) do
|
|
Enum.zip([:name, :score], String.split(raw_team_scores, "\t"))
|
|
|> Map.new
|
|
|> convert_score()
|
|
end
|
|
|
|
|
|
# Convert player array into a map
|
|
# parse_player_scores(["Anthony", "Storm", "100"])
|
|
# > %{player: "ElKapitan ", score: 100, team: "Inferno"}
|
|
defp parse_player_scores(player) do
|
|
Enum.zip([:player, :team, :score], String.split(player, "\t", trim: true))
|
|
|> Map.new
|
|
|> convert_score()
|
|
end
|
|
|
|
# Clean and spaces that might be in the packet for odd reason
|
|
defp decode_clean_packet(packet) do
|
|
packet
|
|
|> String.replace(" ", "")
|
|
|> Base.decode16(case: :mixed)
|
|
end
|
|
|
|
# Convert string scores into integers
|
|
defp convert_score(%{score: score} = data) when is_binary(score) and not is_nil(data), do: %{data | score: String.to_integer(score)}
|
|
defp convert_score(%{score: score} = data) when is_integer(score) and not is_nil(data), do: data
|
|
defp convert_score(data), do: data
|
|
|
|
# Strip all non-printable UTF chars but preserve spaces, tabs and new-lines
|
|
defp clean_player_info(raw_players_info) do
|
|
Regex.replace(~r/(*UTF)[^\w\ \t\n\/*+-]+/, List.to_string(raw_players_info), "")
|
|
|> String.trim_leading
|
|
|> String.split("\n")
|
|
end
|
|
|
|
end
|