diff --git a/.github/badges/node.svg b/.github/badges/node.svg index c329d50..bff5a5b 100644 --- a/.github/badges/node.svg +++ b/.github/badges/node.svg @@ -1,5 +1,5 @@ - - Node game coverage: 11% + + Node game coverage: 13% @@ -13,8 +13,8 @@ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df35d8..6d29a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/Cargo.toml b/Cargo.toml index 4949cc1..1d9d30a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/GAMES.md b/GAMES.md index 67f169b..5d7bad6 100644 --- a/GAMES.md +++ b/GAMES.md @@ -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: _ diff --git a/PROTOCOLS.md b/PROTOCOLS.md index 0b838b3..6b1ef0c 100644 --- a/PROTOCOLS.md +++ b/PROTOCOLS.md @@ -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)
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: _ diff --git a/RESPONSES.md b/RESPONSES.md index aa1eeaa..14415fe 100644 --- a/RESPONSES.md +++ b/RESPONSES.md @@ -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` | -| description | `Option` | | | | `String` | | | | `String` | | `String` | -| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | `String` | | -| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | `String` | `String` | `String` | -| map | `Option` | `String` | `String` | `String` | | `Option` | `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` | | | | | | `u8` | | | `u8` | | -| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | `bool` | `bool` | `bool` | -| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | -| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec

` | | `Vec` | `Vec` | -| tournament | | `bool` | | `bool` | | | | | | | | -| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | -| teams | | | `Vec` | `Vec` | | | | | | | | -| protocol_version | | | | | `i32` | `String` | `u8` | | `u8` | `u8` | | -| server_type | | | | | `Server` | `Server` | `Server` | | | `Server` | | -| rules | | | | | | | `Option>` | | | `HashMap` | | -| environment_type | | | | | | | `Environment` | | `Environment` | | | -| vac_secured | | | | | | | `bool` | | `bool` | `bool` | | -| map_title | | `Option` | | | | | | | | | | -| admin_contact | | `Option` | | | | | | | | | | -| admin_name | | `Option` | | | | | | | | | | -| favicon | | | | | `Option` | | | | | | | -| previews_chat | | | | | `Option` | | | | | | | -| enforces_secure_chat | | | | | `Option` | | | | | | | -| edition | | | | | | `String` | | | | | | -| id | | | | | | `String` | | | | | | -| the_ship | | | | | | | `Option` | | | | | -| is_mod | | | | | | | `bool` | | | | | -| extra_data | | | | | | | `Option` | | | | | -| mod_data | | | | | | | `Option` | | | | | -| folder | | | | | | | `String` | | | | | -| appid | | | | | | | `u32` | | | | | -| active_mod | | | | | | | | | `String` | | | -| round | | | | | | | | | `u8` | | | -| rounds_maximum | | | | | | | | | `u8` | | | -| time_left | | | | | | | | | `u16` | | | -| port | | | | | | | | | | `Option` | | -| steam_id | | | | | | | | | | `Option` | | -| tv_port | | | | | | | | | | `Option` | | -| tv_name | | | | | | | | | | `Option` | | -| keywords | | | | | | | | | | `Option` | | -| 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` | +| description | `Option` | | | | `String` | | | | | `String` | | `String` | +| game_mode | `Option` | `String` | | `String` | | `Option` | `String` | | `String` | `String` | `String` | | +| game_version | `Option` | `String` | | `String` | `String` | | `String` | `String` | | `String` | `String` | `String` | +| map | `Option` | `String` | `String` | `String` | | `Option` | `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` | | | | | | `u8` | | | | `u8` | | +| has_password | `Option` | `bool` | `bool` | `bool` | | | `bool` | | | `bool` | `bool` | `bool` | +| players_minimum | | `Option` | `Option` | `Option` | | | | | | | | | +| players | | `Vec` | `Vec` | `Vec` | `Option>` | | `Option>` | `Vec

` | `Vec` | | `Vec` | `Vec` | +| tournament | | `bool` | | `bool` | | | | | | | | | +| unused_entries | | `Hashmap` | | `HashMap` | | | | `HashMap` | | | | | +| teams | | | `Vec` | `Vec` | | | | | | | | | +| protocol_version | | | | | `i32` | `String` | `u8` | | | `u8` | `u8` | | +| server_type | | | | | `Server` | `Server` | `Server` | | | | `Server` | | +| rules | | | | | | | `Option>` | | `HashMap>` | | `HashMap` | | +| environment_type | | | | | | | `Environment` | | | `Environment` | | | +| vac_secured | | | | | | | `bool` | | | `bool` | `bool` | | +| map_title | | `Option` | | | | | | | | | | | +| admin_contact | | `Option` | | | | | | | | | | | +| admin_name | | `Option` | | | | | | | | | | | +| favicon | | | | | `Option` | | | | | | | | +| previews_chat | | | | | `Option` | | | | | | | | +| enforces_secure_chat | | | | | `Option` | | | | | | | | +| edition | | | | | | `String` | | | | | | | +| id | | | | | | `String` | | | `String` | | | | +| the_ship | | | | | | | `Option` | | | | | | +| is_mod | | | | | | | `bool` | | | | | | +| extra_data | | | | | | | `Option` | | | | | | +| mod_data | | | | | | | `Option` | | | | | | +| folder | | | | | | | `String` | | | | | | +| appid | | | | | | | `u32` | | | | | | +| active_mod | | | | | | | | | | `String` | | | +| round | | | | | | | | | | `u8` | | | +| rounds_maximum | | | | | | | | | | `u8` | | | +| time_left | | | | | | | | | | `u16` | | | +| port | | | | | | | | | `u32` | | `Option` | | +| steam_id | | | | | | | | | | | `Option` | | +| tv_port | | | | | | | | | | | `Option` | | +| tv_name | | | | | | | | | | | `Option` | | +| keywords | | | | | | | | | | | `Option` | | +| mode | | | | | | | | | | | `u8` | | +| witnesses | | | | | | | | | | | `u8` | | +| duration | | | | | | | | | | | `u8` | | +| query_port | | | | | | | | | `u32` | | | | +| ip | | | | | | | | | `String` | | | | +| mutators | | | | | | | | | `HashSet` | | | | diff --git a/src/games/definitions.rs b/src/games/definitions.rs index 62c4882..b620ea0 100644 --- a/src/games/definitions.rs +++ b/src/games/definitions.rs @@ -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), }; diff --git a/src/games/mod.rs b/src/games/mod.rs index 35ebbd3..7749d5b 100644 --- a/src/games/mod.rs +++ b/src/games/mod.rs @@ -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 => { diff --git a/src/games/unreal2.rs b/src/games/unreal2.rs new file mode 100644 index 0000000..1ce4585 --- /dev/null +++ b/src/games/unreal2.rs @@ -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); diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 1b5128f..697d7e8 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -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; diff --git a/src/protocols/types.rs b/src/protocols/types.rs index 2fcd1a1..67e7d13 100644 --- a/src/protocols/types.rs +++ b/src/protocols/types.rs @@ -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), 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")] diff --git a/src/protocols/unreal2/mod.rs b/src/protocols/unreal2/mod.rs new file mode 100644 index 0000000..c268020 --- /dev/null +++ b/src/protocols/unreal2/mod.rs @@ -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, + ) -> crate::GDResult { + 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; diff --git a/src/protocols/unreal2/protocol.rs b/src/protocols/unreal2/protocol.rs new file mode 100644 index 0000000..0dde6ca --- /dev/null +++ b/src/protocols/unreal2/protocol.rs @@ -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) -> GDResult { + 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> { + 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> { + 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( + buffer: &mut Buffer, + 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 { + let data = self.get_request_data(PacketKind::ServerInfo)?; + let mut buffer = Buffer::::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 { + // 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::::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::::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 { + // Pre-allocate the player arrays, but don't over allocate memory if the server + // specifies an insane number of players. + let num_players: Option = 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::::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 { + // 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 { + 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, +) -> GDResult { + let mut client = Unreal2Protocol::new(address, timeout_settings)?; + + client.query(gather_settings) +} + +// TODO: Add tests diff --git a/src/protocols/unreal2/types.rs b/src/protocols/unreal2/types.rs new file mode 100644 index 0000000..594e76f --- /dev/null +++ b/src/protocols/unreal2/types.rs @@ -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 for PacketKind { + type Error = GDError; + fn try_from(value: u8) -> GDResult { + 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(buffer: &mut Buffer) -> GDResult { + Ok(ServerInfo { + server_id: buffer.read()?, + ip: buffer.read_string::(None)?, + game_port: buffer.read()?, + query_port: buffer.read()?, + name: buffer.read_string::(None)?, + map: buffer.read_string::(None)?, + game_type: buffer.read_string::(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, + pub rules: HashMap>, +} + +impl MutatorsAndRules { + pub fn parse(&mut self, buffer: &mut Buffer) -> GDResult<()> { + while buffer.remaining_length() > 0 { + let key = buffer.read_string::(None)?; + let value = buffer.read_string::(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, + /// List of bots returned by server (players with 0 ping). + pub bots: Vec, +} + +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(&mut self, buffer: &mut Buffer) -> GDResult<()> { + while buffer.remaining_length() > 0 { + let player = Player { + id: buffer.read()?, + name: buffer.read_string::(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 { 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> { + 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 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