feat: Add the unreal2 protocol (#124)

* WIP: Add unreal2 protocol

* Add/Update badge

* protocols/unreal2: Update doc comments and TODOs

* protocols/unreal2: Don't pre-allocate as many bot players

* protocols/unreal2: Use "encoding-rs" for decoding unreal2 strings

* Add/Update badge

* protocols/unreal2: Add constants for player pre-allocation.

Also improve some doc comments and update PACKET_SIZE.

* protocols/unreal2: Early break when enough players have been parsed

Add a fast-path to avoid waiting for packet timeout when we have parsed
as many players as specified in the server info packet.

* protocols/unreal2: Use HashSet to store mutators

* protocols/unreal2: Handle server sending multiple values for a rule

* protocols/unreal2: Add GatheringSettings to control what to query

GatheringSettings allows skipping querying rules and/or players which
can make the query return much faster. This also required moving each
individual query into its own helper.

* protocols/unreal2: Add more derives to types

* protocols/unreal2: Simplify ServerInfo::parse()

Co-Authored-By: CosminPerRam <cosmin.p@live.com>

* Docs: Add unreal2 protocol documentation

I used a website to generate the markdown RESPONSES table, the save file
from this website is included to make updating the table easier in the
future.

https://www.tablesgenerator.com/markdown_tables

* Add/Update badge

* protocols/unreal2: Use the correct encoding for UCS2 strings

* Docs: Remove unnecessary TGN file

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: CosminPerRam <cosmin.p@live.com>
This commit is contained in:
Tom 2023-10-30 11:37:15 +00:00 committed by GitHub
parent 5c1568251a
commit 529abe9d76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 708 additions and 52 deletions

View file

@ -1,5 +1,5 @@
<svg width="163.6" height="20" viewBox="0 0 1636 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Node game coverage: 11%">
<title>Node game coverage: 11%</title>
<svg width="163.6" height="20" viewBox="0 0 1636 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Node game coverage: 13%">
<title>Node game coverage: 13%</title>
<linearGradient id="a" x2="0" y2="100%">
<stop offset="0" stop-opacity=".1" stop-color="#EEE"/>
<stop offset="1" stop-opacity=".1"/>
@ -13,8 +13,8 @@
<g aria-hidden="true" fill="#fff" text-anchor="start" font-family="Verdana,DejaVu Sans,sans-serif" font-size="110">
<text x="60" y="148" textLength="1176" fill="#000" opacity="0.25">Node game coverage</text>
<text x="50" y="138" textLength="1176">Node game coverage</text>
<text x="1331" y="148" textLength="260" fill="#000" opacity="0.25">11%</text>
<text x="1321" y="138" textLength="260">11%</text>
<text x="1331" y="148" textLength="260" fill="#000" opacity="0.25">13%</text>
<text x="1321" y="138" textLength="260">13%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

View file

@ -3,11 +3,15 @@ Who knows what the future holds...
# 0.X.Y - DD/MM/YYYY
### Changes:
Games:
- [Valheim](https://store.steampowered.com/app/892970/Valheim/) support.
- [The Front](https://store.steampowered.com/app/2285150/The_Front/) support.
- [Conan Exiles](https://store.steampowered.com/app/440900/Conan_Exiles/) support.
- Added a valve protocol query example.
Protocols:
- Added the unreal2 protocol and its associated games: Darkest Hour, Devastation, Killing Floor, Red Orchestra, Unreal Tournament 2003, Unreal Tournament 2004 (by @Douile).
### Breaking:
Game:
- Changed identifications of the following games as they weren't properly expecting the naming rules:

View file

@ -28,6 +28,7 @@ byteorder = "1.5"
bzip2-rs = "0.1"
crc32fast = "1.3"
serde_json = "1.0"
encoding_rs = "0.8"
serde = { version = "1.0", optional = true }

View file

@ -65,6 +65,12 @@ Beware of the `Notes` column, as it contains information about query port offset
| Valheim | VALHEIM | Valve | Query Port offset: 1. Does not respond to the A2S rules. |
| The Front | THEFRONT | Valve | Responds with wrong values on `name` (gives out a SteamID instead of the server name) and `players_maximum` (always 200). |
| Conan Exiles | CONANEXILES | Valve | Does not respond to the players query. |
| Darkest Hour: Europe '44-'45 | DARKESTHOUR | Unreal2 | Query port offset: 1 |
| Devastation | DEVASTATION | Unreal2 | Query port offset: 1 |
| Killing Floor | KILLINGFLOOR | Unreal2 | Query port offset: 1 |
| Red Orchestra | REDORCHESTRA | Unreal2 | Query port offset: 1 |
| Unreal Tournament 2003 | UT2003 | Unreal2 | Query port offset: 1 |
| Unreal Tournament 2004 | UT2004 | Unreal2 | Query port offset: 1 |
## Planned to add support:
_

View file

@ -7,6 +7,7 @@ A protocol is defined as proprietary if it is being used only for a single scope
| Minecraft | Games | Yes | Java: [List Server Protocol](https://wiki.vg/Server_List_Ping) <br> Bedrock: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/minecraftbedrock.js) | |
| GameSpy | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy3.js) | These protocols are not really standardized, gamedig tries to get the most common fields amongst its supported games, if there are parsing problems, use the `query_vars` function. |
| Quake | Games | No | One: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js) Two: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake2.js) Three: [Node-GameDig Source](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake3.js) | |
| Unreal2 | Games | Yes | [Node-GameDig Source](https://github.com/gamedig/node-gamedig) | Sometimes servers send strings that node-gamedig would treat as latin1 that are UTF-8 encoded, when this happens the remove color code breaks because latin1 decodes the colour sequences differently. Some games provide additional info at the end of the server info packet, this is not currently handled (see the node implementation). Some games use a bot player to denote the team names, this is not currently handled. |
## Planned to add support:
_

View file

@ -5,50 +5,53 @@ In the case that a field that performs the same function exists in the current c
# Response table
| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP |
|:---------------------|------------------|---------------------------|---------------|---------------------------|-----------------------|----------------------|----------------------------------|---------------------------|-------------------|--------------------------|--------------------|
| name | `Option<String>` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` |
| description | `Option<String>` | | | | `String` | | | | `String` | | `String` |
| game_mode | `Option<String>` | `String` | | `String` | | `Option<GameMode>` | `String` | | `String` | `String` | |
| game_version | `Option<String>` | `String` | | `String` | `String` | | `String` | `String` | `String` | `String` | `String` |
| map | `Option<String>` | `String` | `String` | `String` | | `Option<String>` | `String` | `String` | `String` | `String` | |
| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u8` | `u8` | `u32` |
| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u8` | `u8` | `u32` |
| players_bots | `Option<u32>` | | | | | | `u8` | | | `u8` | |
| has_password | `Option<bool>` | `bool` | `bool` | `bool` | | | `bool` | | `bool` | `bool` | `bool` |
| players_minimum | | `Option<u8>` | `Option<u8>` | `Option<u8>` | | | | | | | |
| players | | `Vec<Player>` | `Vec<Player>` | `Vec<Player>` | `Option<Vec<Player>>` | | `Option<Vec<ServerPlayer>>` | `Vec<P>` | | `Vec<TheShipPlayer>` | `Vec<Player>` |
| tournament | | `bool` | | `bool` | | | | | | | |
| unused_entries | | `Hashmap<String, String>` | | `HashMap<String, String>` | | | | `HashMap<String, String>` | | | |
| teams | | | `Vec<Team>` | `Vec<Team>` | | | | | | | |
| protocol_version | | | | | `i32` | `String` | `u8` | | `u8` | `u8` | |
| server_type | | | | | `Server` | `Server` | `Server` | | | `Server` | |
| rules | | | | | | | `Option<HashMap<String,String>>` | | | `HashMap<String,String>` | |
| environment_type | | | | | | | `Environment` | | `Environment` | | |
| vac_secured | | | | | | | `bool` | | `bool` | `bool` | |
| map_title | | `Option<String>` | | | | | | | | | |
| admin_contact | | `Option<String>` | | | | | | | | | |
| admin_name | | `Option<String>` | | | | | | | | | |
| favicon | | | | | `Option<String>` | | | | | | |
| previews_chat | | | | | `Option<bool>` | | | | | | |
| enforces_secure_chat | | | | | `Option<bool>` | | | | | | |
| edition | | | | | | `String` | | | | | |
| id | | | | | | `String` | | | | | |
| the_ship | | | | | | | `Option<TheShip>` | | | | |
| is_mod | | | | | | | `bool` | | | | |
| extra_data | | | | | | | `Option<ExtraData>` | | | | |
| mod_data | | | | | | | `Option<ModData>` | | | | |
| folder | | | | | | | `String` | | | | |
| appid | | | | | | | `u32` | | | | |
| active_mod | | | | | | | | | `String` | | |
| round | | | | | | | | | `u8` | | |
| rounds_maximum | | | | | | | | | `u8` | | |
| time_left | | | | | | | | | `u16` | | |
| port | | | | | | | | | | `Option<u16>` | |
| steam_id | | | | | | | | | | `Option<u64>` | |
| tv_port | | | | | | | | | | `Option<u16>` | |
| tv_name | | | | | | | | | | `Option<String>` | |
| keywords | | | | | | | | | | `Option<string>` | |
| mode | | | | | | | | | | `u8` | |
| witnesses | | | | | | | | | | `u8` | |
| duration | | | | | | | | | | `u8` | |
| Field | Generic | GameSpy(1) | GameSpy(2) | GameSpy(3) | Minecraft(Java) | Minecraft(Bedrock) | Valve | Quake | Unreal2 | Proprietary: FFOW | Proprietary: TheShip | Proprietary: JC2MP |
|----------------------|------------------|---------------------------|---------------|---------------------------|-----------------------|--------------------|-----------------------------------|---------------------------|--------------------------------|-------------------|---------------------------|--------------------|
| name | `Option<String>` | `String` | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | `String` | `String` |
| description | `Option<String>` | | | | `String` | | | | | `String` | | `String` |
| game_mode | `Option<String>` | `String` | | `String` | | `Option<GameMode>` | `String` | | `String` | `String` | `String` | |
| game_version | `Option<String>` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` |
| map | `Option<String>` | `String` | `String` | `String` | | `Option<String>` | `String` | `String` | `String` | `String` | `String` | |
| players_maxmimum | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` |
| players_online | `u32` | `u32` | `u32` | `u32` | `u32` | `u32` | `u8` | `u8` | `u32` | `u8` | `u8` | `u32` |
| players_bots | `Option<u32>` | | | | | | `u8` | | | | `u8` | |
| has_password | `Option<bool>` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` |
| players_minimum | | `Option<u8>` | `Option<u8>` | `Option<u8>` | | | | | | | | |
| players | | `Vec<Player>` | `Vec<Player>` | `Vec<Player>` | `Option<Vec<Player>>` | | `Option<Vec<ServerPlayer>>` | `Vec<P>` | `Vec<Player>` | | `Vec<TheShipPlayer>` | `Vec<Player>` |
| tournament | | `bool` | | `bool` | | | | | | | | |
| unused_entries | | `Hashmap<String, String>` | | `HashMap<String, String>` | | | | `HashMap<String, String>` | | | | |
| teams | | | `Vec<Team>` | `Vec<Team>` | | | | | | | | |
| protocol_version | | | | | `i32` | `String` | `u8` | | | `u8` | `u8` | |
| server_type | | | | | `Server` | `Server` | `Server` | | | | `Server` | |
| rules | | | | | | | `Option<HashMap<String, String>>` | | `HashMap<String, Vec<String>>` | | `HashMap<String, String>` | |
| environment_type | | | | | | | `Environment` | | | `Environment` | | |
| vac_secured | | | | | | | `bool` | | | `bool` | `bool` | |
| map_title | | `Option<String>` | | | | | | | | | | |
| admin_contact | | `Option<String>` | | | | | | | | | | |
| admin_name | | `Option<String>` | | | | | | | | | | |
| favicon | | | | | `Option<String>` | | | | | | | |
| previews_chat | | | | | `Option<bool>` | | | | | | | |
| enforces_secure_chat | | | | | `Option<bool>` | | | | | | | |
| edition | | | | | | `String` | | | | | | |
| id | | | | | | `String` | | | `String` | | | |
| the_ship | | | | | | | `Option<TheShip>` | | | | | |
| is_mod | | | | | | | `bool` | | | | | |
| extra_data | | | | | | | `Option<ExtraData>` | | | | | |
| mod_data | | | | | | | `Option<ModData>` | | | | | |
| folder | | | | | | | `String` | | | | | |
| appid | | | | | | | `u32` | | | | | |
| active_mod | | | | | | | | | | `String` | | |
| round | | | | | | | | | | `u8` | | |
| rounds_maximum | | | | | | | | | | `u8` | | |
| time_left | | | | | | | | | | `u16` | | |
| port | | | | | | | | | `u32` | | `Option<u16>` | |
| steam_id | | | | | | | | | | | `Option<u64>` | |
| tv_port | | | | | | | | | | | `Option<u16>` | |
| tv_name | | | | | | | | | | | `Option<String>` | |
| keywords | | | | | | | | | | | `Option<String>` | |
| mode | | | | | | | | | | | `u8` | |
| witnesses | | | | | | | | | | | `u8` | |
| duration | | | | | | | | | | | `u8` | |
| query_port | | | | | | | | | `u32` | | | |
| ip | | | | | | | | | `String` | | | |
| mutators | | | | | | | | | `HashSet<String>` | | | |

View file

@ -111,4 +111,10 @@ pub static GAMES: Map<&'static str, Game> = phf_map! {
"vrising" => game!("V Rising", 27016, Protocol::Valve(Engine::new(1_604_030))),
"jc2m" => game!("Just Cause 2: Multiplayer", 7777, Protocol::PROPRIETARY(ProprietaryProtocol::JC2M)),
"warsow" => game!("Warsow", 44400, Protocol::Quake(QuakeVersion::Three)),
"darkesthour" => game!("Darkest Hour: Europe '44-'45 (2008)", 7758, Protocol::Unreal2),
"devastation" => game!("Devastation (2003)", 7778, Protocol::Unreal2),
"killingfloor" => game!("Killing Floor", 7708, Protocol::Unreal2),
"redorchestra" => game!("Red Orchestra", 7759, Protocol::Unreal2),
"ut2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2),
"ut2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2),
};

View file

@ -5,10 +5,12 @@ use serde::{Deserialize, Serialize};
pub mod gamespy;
pub mod quake;
pub mod unreal2;
pub mod valve;
pub use gamespy::*;
pub use quake::*;
pub use unreal2::*;
pub use valve::*;
/// Battalion 1944
@ -129,6 +131,16 @@ pub fn query_with_timeout_and_extra_settings(
QuakeVersion::Three => protocols::quake::three::query(&socket_addr, timeout_settings).map(Box::new)?,
}
}
Protocol::Unreal2 => {
protocols::unreal2::query(
&socket_addr,
&extra_settings
.map(ExtraRequestSettings::into)
.unwrap_or_default(),
timeout_settings,
)
.map(Box::new)?
}
Protocol::PROPRIETARY(protocol) => {
match protocol {
ProprietaryProtocol::TheShip => {

10
src/games/unreal2.rs Normal file
View file

@ -0,0 +1,10 @@
//! Unreal2 game query modules
use crate::protocols::unreal2::game_query_mod;
game_query_mod!(darkesthour, "Darkest Hour: Europe '44-'45 (2008)", 7758);
game_query_mod!(devastation, "Devastation (2003)", 7778);
game_query_mod!(killingfloor, "Killing Floor", 7708);
game_query_mod!(redorchestra, "Red Orchestra", 7759);
game_query_mod!(ut2003, "Unreal Tournament 2003", 7758);
game_query_mod!(ut2004, "Unreal Tournament 2004", 7778);

View file

@ -12,6 +12,8 @@ pub mod minecraft;
pub mod quake;
/// General types that are used by all protocols.
pub mod types;
/// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/unreal2.js)
pub mod unreal2;
/// Reference: [Server Query](https://developer.valvesoftware.com/wiki/Server_queries)
pub mod valve;

View file

@ -1,4 +1,4 @@
use crate::protocols::{gamespy, minecraft, quake, valve};
use crate::protocols::{gamespy, minecraft, quake, unreal2, valve};
use crate::GDErrorKind::InvalidInput;
use crate::GDResult;
@ -24,6 +24,7 @@ pub enum Protocol {
Minecraft(Option<minecraft::types::Server>),
Quake(quake::QuakeVersion),
Valve(valve::Engine),
Unreal2,
#[cfg(feature = "games")]
PROPRIETARY(ProprietaryProtocol),
}
@ -35,6 +36,7 @@ pub enum GenericResponse<'a> {
Minecraft(minecraft::VersionedResponse<'a>),
Quake(quake::VersionedResponse<'a>),
Valve(&'a valve::Response),
Unreal2(&'a unreal2::Response),
#[cfg(feature = "games")]
TheShip(&'a crate::games::theship::Response),
#[cfg(feature = "games")]
@ -51,6 +53,7 @@ pub enum GenericPlayer<'a> {
QuakeTwo(&'a quake::two::Player),
Minecraft(&'a minecraft::Player),
Gamespy(gamespy::VersionedPlayer<'a>),
Unreal2(&'a unreal2::Player),
#[cfg(feature = "games")]
TheShip(&'a crate::games::theship::TheShipPlayer),
#[cfg(feature = "games")]

View file

@ -0,0 +1,54 @@
/// The implementation.
pub mod protocol;
/// All types used by the implementation.
pub mod types;
pub use protocol::*;
pub use types::*;
/// Generate a module containing a query function for a valve game.
///
/// * `mod_name` - The name to be given to the game module (see ID naming
/// conventions in CONTRIBUTING.md).
/// * `pretty_name` - The full name of the game, will be used as the
/// documentation for the created module.
/// * `default_port` - Passed through to [game_query_fn].
macro_rules! game_query_mod {
($mod_name: ident, $pretty_name: expr, $default_port: literal) => {
#[doc = $pretty_name]
pub mod $mod_name {
crate::protocols::unreal2::game_query_fn!($default_port);
}
};
}
pub(crate) use game_query_mod;
// Allow generating doc comments:
// https://users.rust-lang.org/t/macros-filling-text-in-comments/20473
/// Generate a query function for a valve game.
///
/// * `default_port` - The default port the game uses.
macro_rules! game_query_fn {
($default_port: literal) => {
crate::protocols::unreal2::game_query_fn! {@gen $default_port, concat!(
"Make a Unreal2 query for with default timeout settings and default extra request settings.\n\n",
"If port is `None`, then the default port (", stringify!($default_port), ") will be used.")}
};
(@gen $default_port: literal, $doc: expr) => {
#[doc = $doc]
pub fn query(
address: &std::net::IpAddr,
port: Option<u16>,
) -> crate::GDResult<crate::protocols::unreal2::Response> {
crate::protocols::unreal2::query(
&std::net::SocketAddr::new(*address, port.unwrap_or($default_port)),
&crate::protocols::unreal2::GatheringSettings::default(),
None,
)
}
};
}
pub(crate) use game_query_fn;

View file

@ -0,0 +1,308 @@
use crate::buffer::{Buffer, StringDecoder};
use crate::errors::GDErrorKind::PacketBad;
use crate::protocols::types::TimeoutSettings;
use crate::socket::{Socket, UdpSocket};
use crate::utils::retry_on_timeout;
use crate::GDResult;
use super::{GatheringSettings, MutatorsAndRules, PacketKind, Players, Response, ServerInfo};
use std::net::SocketAddr;
use byteorder::{ByteOrder, LittleEndian};
use encoding_rs::{UTF_16LE, WINDOWS_1252};
/// Response packets don't seem to exceed 500 bytes, set to 1024 just to be
/// safe.
const PACKET_SIZE: usize = 1024;
/// Default amount of players to pre-allocate if numplayers was not included in
/// server info response.
const DEFAULT_PLAYER_PREALLOCATION: usize = 10;
/// Maximum amount of players to pre-allocate: if the server specifies a number
/// larger than this in serverinfo we don't allocate that many.
const MAXIMUM_PLAYER_PREALLOCATION: usize = 50;
/// The Unreal2 protocol implementation.
pub(crate) struct Unreal2Protocol {
socket: UdpSocket,
retry_count: usize,
}
impl Unreal2Protocol {
pub fn new(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
let socket = UdpSocket::new(address)?;
let retry_count = timeout_settings
.as_ref()
.map(|t| t.get_retries())
.unwrap_or_else(|| TimeoutSettings::default().get_retries());
socket.apply_timeout(&timeout_settings)?;
Ok(Self {
socket,
retry_count,
})
}
/// Send a request packet and recieve the first response (with retries).
fn get_request_data(&mut self, packet_type: PacketKind) -> GDResult<Vec<u8>> {
retry_on_timeout(self.retry_count, move || {
self.get_request_data_impl(packet_type)
})
}
/// Send a request packet
fn get_request_data_impl(&mut self, packet_type: PacketKind) -> GDResult<Vec<u8>> {
let request = [0x79, 0, 0, 0, packet_type as u8];
self.socket.send(&request)?;
let data = self.socket.receive(Some(PACKET_SIZE))?;
Ok(data)
}
/// Consume the header part of a response packet, validate that the packet
/// type matches what is expected.
fn consume_response_headers<B: ByteOrder>(
buffer: &mut Buffer<B>,
expected_packet_type: PacketKind,
) -> GDResult<()> {
// Skip header
buffer.move_cursor(4)?;
let packet_type: u8 = buffer.read()?;
let packet_type: PacketKind = packet_type.try_into()?;
if packet_type != expected_packet_type {
Err(PacketBad.context(format!(
"Packet response ({:?}) didn't match request ({:?}) packet type",
packet_type, expected_packet_type
)))
} else {
Ok(())
}
}
/// Send server info query.
pub fn query_server_info(&mut self) -> GDResult<ServerInfo> {
let data = self.get_request_data(PacketKind::ServerInfo)?;
let mut buffer = Buffer::<LittleEndian>::new(&data);
// TODO: Maybe put consume headers in individual packet parse methods
Self::consume_response_headers(&mut buffer, PacketKind::ServerInfo)?;
ServerInfo::parse(&mut buffer)
}
/// Send mutators and rules query.
pub fn query_mutators_and_rules(&mut self) -> GDResult<MutatorsAndRules> {
// This is a required packet so we validate that we get at least one response.
// However there can be many packets in response to a single request so
// we greedily handle packets until we get a timeout (or any receive
// error).
let mut mutators_and_rules = MutatorsAndRules::default();
{
let data = self.get_request_data(PacketKind::MutatorsAndRules)?;
let mut buffer = Buffer::<LittleEndian>::new(&data);
// TODO: Maybe put consume headers in individual packet parse methods
Self::consume_response_headers(&mut buffer, PacketKind::MutatorsAndRules)?;
mutators_and_rules.parse(&mut buffer)?
};
// We could receive multiple packets in response
while let Ok(data) = self.socket.receive(Some(PACKET_SIZE)) {
let mut buffer = Buffer::<LittleEndian>::new(&data);
let r = Self::consume_response_headers(&mut buffer, PacketKind::MutatorsAndRules);
if r.is_err() {
println!("{:?}", r);
break;
}
mutators_and_rules.parse(&mut buffer)?;
}
Ok(mutators_and_rules)
}
/// Send players query.
pub fn query_players(&mut self, server_info: Option<&ServerInfo>) -> GDResult<Players> {
// Pre-allocate the player arrays, but don't over allocate memory if the server
// specifies an insane number of players.
let num_players: Option<usize> = server_info.and_then(|i| i.num_players.try_into().ok());
let mut players = Players::with_capacity(
num_players
.unwrap_or(DEFAULT_PLAYER_PREALLOCATION)
.min(MAXIMUM_PLAYER_PREALLOCATION),
);
// Fetch first players packet (with retries)
let mut players_data = self.get_request_data(PacketKind::Players);
// Players are non required so if we don't get any responses we continue to
// return
while let Ok(data) = players_data {
let mut buffer = Buffer::<LittleEndian>::new(&data);
Self::consume_response_headers(&mut buffer, PacketKind::Players)?;
players.parse(&mut buffer)?;
if let Some(num_players) = num_players {
if players.total_len() >= num_players {
// If we have already received the amount of players specified in server info
// then we don't need to wait for more player packets to time out.
break;
}
}
// Receive next packet
players_data = self.socket.receive(Some(PACKET_SIZE));
}
Ok(players)
}
/// Make a full server query.
pub fn query(&mut self, gather_settings: &GatheringSettings) -> GDResult<Response> {
// Fetch the server info, this can only handle one response packet
let server_info = self.query_server_info()?;
let mutators_and_rules = if gather_settings.mutators_and_rules {
self.query_mutators_and_rules()?
} else {
MutatorsAndRules::default()
};
let players = if gather_settings.players {
self.query_players(Some(&server_info))?
} else {
Players::with_capacity(0)
};
// TODO: Handle extra info parsing when we detect certain game types (or maybe
// include that in gather settings).
Ok(Response {
server_info,
mutators_and_rules,
players,
})
}
}
/// Unreal 2 string decoder
pub struct Unreal2StringDecoder;
impl StringDecoder for Unreal2StringDecoder {
type Delimiter = [u8; 1];
const DELIMITER: Self::Delimiter = [0x00];
fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult<String> {
let mut ucs2 = false;
let mut length: usize = (*data
.first()
.ok_or(PacketBad.context("Tried to decode string without length"))?)
.into();
let mut start = 0;
// Check if it is a UCS-2 string
if length >= 0x80 {
ucs2 = true;
length = (length & 0x7f) * 2;
start += 1;
// For UCS-2 strings, some unreal 2 games randomly insert an extra 0x01 here,
// not included in the length. Skip it if present (hopefully this never happens
// legitimately)
if let Some(1) = data[start ..].first() {
start += 1;
}
}
// If UCS2 the first byte is the masked length of the string
let result = if ucs2 {
let string_data = &data[start .. start + length];
if string_data.len() != length {
return Err(PacketBad.context("Not enough data in buffer to read string"));
}
// When node decodes UCS2 it uses the UFT16LE encoding.
// https://github.com/nodejs/node/blob/2aaa21f9f684484edb54be30589c4af0b923cdef/lib/buffer.js#L637-L645
let (result, _, invalid_sequences) = UTF_16LE.decode(string_data);
if invalid_sequences {
return Err(PacketBad.context("UTF-8 string contained invalid character(s)"));
}
result
} else {
// Else the string is null-delimited latin1
// TODO: Replace this with delimiter finder helper
let position = data
// Create an iterator over the data.
.iter()
// Find the position of the delimiter
.position(|&b| b == delimiter.as_ref()[0])
// If the delimiter is not found, use the whole data slice.
.unwrap_or(data.len());
length = position + 1;
// Decode as latin1
let (result, _, invalid_sequences) = WINDOWS_1252.decode(&data[0 .. position]);
if invalid_sequences {
return Err(PacketBad.context("latin1 string contained invalid character(s)"));
}
result
};
// Strip color encodings
// TODO: Improve efficiency
// TODO: There might be a nicer way to do this once string patterns are stable
// https://github.com/rust-lang/rust/issues/27721
// After '0x1b' skip 3 characters (including the '0x1b')
let mut char_skip = 0usize;
let result: String = result
.chars()
.filter(|c: &char| {
if '\x1b'.eq(c) {
char_skip = 4;
return false;
}
char_skip = char_skip.saturating_sub(1);
char_skip == 0
})
.collect();
// Remove all characters between 0x00 and 0x1a
let result = result.replace(|c: char| c > '\x00' && c <= '\x1a', "");
*cursor += start + length;
// Strip delimiter that wasn't included in length
Ok(result.trim_matches('\0').to_string())
}
}
/// Make an unreal2 query.
pub fn query(
address: &SocketAddr,
gather_settings: &GatheringSettings,
timeout_settings: Option<TimeoutSettings>,
) -> GDResult<Response> {
let mut client = Unreal2Protocol::new(address, timeout_settings)?;
client.query(gather_settings)
}
// TODO: Add tests

View file

@ -0,0 +1,246 @@
use crate::buffer::Buffer;
use crate::errors::GDErrorKind::PacketBad;
use crate::protocols::types::{CommonPlayer, CommonResponse, ExtraRequestSettings, GenericPlayer};
use crate::protocols::GenericResponse;
use crate::{GDError, GDResult};
use super::Unreal2StringDecoder;
use std::collections::{HashMap, HashSet};
use byteorder::ByteOrder;
/// Unreal 2 packet types.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum PacketKind {
ServerInfo = 0,
MutatorsAndRules = 1,
Players = 2,
}
impl TryFrom<u8> for PacketKind {
type Error = GDError;
fn try_from(value: u8) -> GDResult<Self> {
match value {
0 => Ok(Self::ServerInfo),
1 => Ok(Self::MutatorsAndRules),
2 => Ok(Self::Players),
_ => Err(PacketBad.context("Unknown packet type")),
}
}
}
/// Unreal 2 server info.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ServerInfo {
pub server_id: u32,
pub ip: String,
pub game_port: u32,
pub query_port: u32,
pub name: String,
pub map: String,
pub game_type: String,
pub num_players: u32,
pub max_players: u32,
}
impl ServerInfo {
pub fn parse<B: ByteOrder>(buffer: &mut Buffer<B>) -> GDResult<Self> {
Ok(ServerInfo {
server_id: buffer.read()?,
ip: buffer.read_string::<Unreal2StringDecoder>(None)?,
game_port: buffer.read()?,
query_port: buffer.read()?,
name: buffer.read_string::<Unreal2StringDecoder>(None)?,
map: buffer.read_string::<Unreal2StringDecoder>(None)?,
game_type: buffer.read_string::<Unreal2StringDecoder>(None)?,
num_players: buffer.read()?,
max_players: buffer.read()?,
})
}
}
/// Unreal 2 mutators and rules.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MutatorsAndRules {
pub mutators: HashSet<String>,
pub rules: HashMap<String, Vec<String>>,
}
impl MutatorsAndRules {
pub fn parse<B: ByteOrder>(&mut self, buffer: &mut Buffer<B>) -> GDResult<()> {
while buffer.remaining_length() > 0 {
let key = buffer.read_string::<Unreal2StringDecoder>(None)?;
let value = buffer.read_string::<Unreal2StringDecoder>(None).ok();
if key.eq_ignore_ascii_case("mutator") {
if let Some(value) = value {
self.mutators.insert(value);
}
} else {
let rule_vec = self.rules.get_mut(&key);
let rule_vec = if let Some(rule_vec) = rule_vec {
rule_vec
} else {
self.rules.insert(key.clone(), Vec::default());
self.rules
.get_mut(&key)
.expect("Value should be in HashMap after we inserted")
};
if let Some(value) = value {
rule_vec.push(value);
}
}
}
Ok(())
}
}
/// Unreal 2 players and bots.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Players {
/// List of players returned by server (without 0 ping).
pub players: Vec<Player>,
/// List of bots returned by server (players with 0 ping).
pub bots: Vec<Player>,
}
impl Players {
/// Pre-allocate the vectors inside the players struct based on the provided
/// capacity.
pub fn with_capacity(capacity: usize) -> Self {
Players {
players: Vec::with_capacity(capacity),
// Allocate half as many bots as we don't expect there to be as many
bots: Vec::with_capacity(capacity / 2),
}
}
/// Parse a raw buffer of players into the current struct.
pub fn parse<B: ByteOrder>(&mut self, buffer: &mut Buffer<B>) -> GDResult<()> {
while buffer.remaining_length() > 0 {
let player = Player {
id: buffer.read()?,
name: buffer.read_string::<Unreal2StringDecoder>(None)?,
ping: buffer.read()?,
score: buffer.read()?,
stats_id: buffer.read()?,
};
// If ping is 0 the player is a bot
if player.ping == 0 {
self.bots.push(player);
} else {
self.players.push(player);
}
}
Ok(())
}
/// Length of both players and bots.
pub fn total_len(&self) -> usize { self.players.len() + self.bots.len() }
}
/// Unreal 2 player info.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Player {
pub id: u32,
pub name: String,
pub ping: u32,
pub score: i32,
pub stats_id: u32,
}
impl CommonPlayer for Player {
fn name(&self) -> &str { &self.name }
fn score(&self) -> Option<i32> { Some(self.score) }
fn as_original(&self) -> GenericPlayer { GenericPlayer::Unreal2(self) }
}
/// Unreal 2 response.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Response {
pub server_info: ServerInfo,
pub mutators_and_rules: MutatorsAndRules,
pub players: Players,
}
impl CommonResponse for Response {
fn map(&self) -> Option<&str> { Some(&self.server_info.map) }
fn name(&self) -> Option<&str> { Some(&self.server_info.name) }
fn game_mode(&self) -> Option<&str> { Some(&self.server_info.game_type) }
fn players_online(&self) -> u32 { self.server_info.num_players }
fn players_maximum(&self) -> u32 { self.server_info.max_players }
fn players(&self) -> Option<Vec<&dyn crate::protocols::types::CommonPlayer>> {
Some(
self.players
.players
.iter()
.map(|player| player as _)
.collect(),
)
}
fn as_original(&self) -> GenericResponse { GenericResponse::Unreal2(self) }
}
/// What data to gather, purely used only with the query function.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct GatheringSettings {
pub players: bool,
pub mutators_and_rules: bool,
}
impl GatheringSettings {
/// Default values are true for both the players and the rules.
pub const fn default() -> Self {
Self {
players: true,
mutators_and_rules: true,
}
}
pub const fn into_extra(self) -> ExtraRequestSettings {
ExtraRequestSettings {
hostname: None,
protocol_version: None,
gather_players: Some(self.players),
gather_rules: Some(self.mutators_and_rules),
check_app_id: None,
}
}
}
impl Default for GatheringSettings {
fn default() -> Self { GatheringSettings::default() }
}
impl From<ExtraRequestSettings> for GatheringSettings {
fn from(value: ExtraRequestSettings) -> Self {
let default = Self::default();
Self {
players: value.gather_players.unwrap_or(default.players),
mutators_and_rules: value.gather_rules.unwrap_or(default.mutators_and_rules),
}
}
}
// TODO: Add tests