From 786da81ea5a4a851c577281b852a9a417c1d04de Mon Sep 17 00:00:00 2001 From: CosminPerRam Date: Mon, 17 Apr 2023 15:10:51 +0300 Subject: [PATCH] [Protocol] Add GameSpy 3 support. (#25) * [Protocol] Gamespy3 initial code * [Protocol] Add rest of challenge solving * [Protocol] Remove unused stuff * [Protocol] Remove adding unused bytes * [Protocol] Clean up code * [Protocol] Make gs3 a struct * [Protocol] Add initial key-value parsing * [Protocol] Manage multiple packets * [Protocol] Split server vars and other vars * Revert "[Protocol] Split server vars and other vars" This reverts commit 9a930aeb68802fcf3d0908a2e031dfea054d37d0. * [Protocol] Proper packet management and initial response struct * [Protocol] Fix players_minimum * [Protocol] Fix server vars to parse only the first packet * [Protocol] Update CHANGELOG.md * [Protocol] Initial player parsing * [Protocol] Split GS one and three * [Protocol] Add common code file * [Protocol] Change static to const * [Protocol] Fix players_online and break on data to map on empty key * [Protocol] Remove unused types and printlns * [Protocol] Add teams parsing * [Protocol] Split key_values and parsing data * [Crate] Update PROTOCOLS.md --- CHANGELOG.md | 2 + PROTOCOLS.md | 10 +- examples/master_querant.rs | 2 + src/bufferer.rs | 12 + src/protocols/gamespy/common.rs | 17 + src/protocols/gamespy/mod.rs | 10 +- src/protocols/gamespy/protocol/mod.rs | 2 - src/protocols/gamespy/protocols/mod.rs | 5 + src/protocols/gamespy/protocols/one/mod.rs | 5 + .../one.rs => protocols/one/protocol.rs} | 30 +- .../gamespy/{ => protocols/one}/types.rs | 2 +- src/protocols/gamespy/protocols/three/mod.rs | 5 + .../gamespy/protocols/three/protocol.rs | 351 ++++++++++++++++++ .../gamespy/protocols/three/types.rs | 42 +++ 14 files changed, 459 insertions(+), 36 deletions(-) create mode 100644 src/protocols/gamespy/common.rs delete mode 100644 src/protocols/gamespy/protocol/mod.rs create mode 100644 src/protocols/gamespy/protocols/mod.rs create mode 100644 src/protocols/gamespy/protocols/one/mod.rs rename src/protocols/gamespy/{protocol/one.rs => protocols/one/protocol.rs} (91%) rename src/protocols/gamespy/{ => protocols/one}/types.rs (96%) create mode 100644 src/protocols/gamespy/protocols/three/mod.rs create mode 100644 src/protocols/gamespy/protocols/three/protocol.rs create mode 100644 src/protocols/gamespy/protocols/three/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad27d7..9a8d49e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Crate: Protocols: - GameSpy 1: Add key `admin` as a possible variable for `admin_name`. +- GameSpy 3 support. Games: - [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support. @@ -16,6 +17,7 @@ Games: ### Breaking: Protocols: - Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info` +- GameSpy 1: `players_minimum` is now an `Option` instead of an `u8` # 0.2.1 - 03/03/2023 ### Changes: diff --git a/PROTOCOLS.md b/PROTOCOLS.md index 53f5482..39b7de1 100644 --- a/PROTOCOLS.md +++ b/PROTOCOLS.md @@ -1,11 +1,11 @@ A protocol is defined as proprietary if it is being used only for a single scope (or series, like Minecraft). # Supported protocols: -| Name | For | Proprietary? | Documentation reference | Notes | -|----------------|-------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Valve Protocol | Games | No | [Server Queries](https://developer.valvesoftware.com/wiki/Server_queries) | In some cases, the players details query might contain some 0-length named players. Multi-packet decompression not tested. | -| 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) | 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. | +| Name | For | Proprietary? | Documentation reference | Notes | +|----------------|-------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Valve Protocol | Games | No | [Server Queries](https://developer.valvesoftware.com/wiki/Server_queries) | In some cases, the players details query might contain some 0-length named players. Multi-packet decompression not tested. | +| 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) 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. | ## Planned to add support: _ diff --git a/examples/master_querant.rs b/examples/master_querant.rs index a7052f5..94ee720 100644 --- a/examples/master_querant.rs +++ b/examples/master_querant.rs @@ -165,6 +165,8 @@ fn main() -> GDResult<()> { "ut" => println!("{:#?}", ut::query(ip, port)), "bf1942" => println!("{:#?}", bf1942::query(ip, port)), "ss" => println!("{:#?}", ss::query(ip, port)), + "_gamespy3" => println!("{:#?}", gamespy::three::query(ip, port.unwrap(), None)), + "_gamespy3_vars" => println!("{:#?}", gamespy::three::query_vars(ip, port.unwrap(), None)), _ => panic!("Undefined game: {}", args[1]), }; diff --git a/src/bufferer.rs b/src/bufferer.rs index 00fae6b..9daf84e 100644 --- a/src/bufferer.rs +++ b/src/bufferer.rs @@ -108,6 +108,18 @@ impl Bufferer { Ok(value) } + pub fn get_string_utf8_optional(&mut self) -> GDResult { + match self.get_string_utf8() { + Ok(data) => Ok(data), + Err(e) => { + match e { + PacketUnderflow => Ok(String::new()), + x => Err(x), + } + } + } + } + pub fn get_string_utf8_unended(&mut self) -> GDResult { let sub_buf = self.remaining_data(); if sub_buf.is_empty() { diff --git a/src/protocols/gamespy/common.rs b/src/protocols/gamespy/common.rs new file mode 100644 index 0000000..5c1950d --- /dev/null +++ b/src/protocols/gamespy/common.rs @@ -0,0 +1,17 @@ +use crate::{GDError, GDResult}; +use std::collections::HashMap; + +pub fn has_password(server_vars: &mut HashMap) -> GDResult { + let password_value = server_vars + .remove("password") + .ok_or(GDError::PacketBad)? + .to_lowercase(); + + if let Ok(has) = password_value.parse::() { + return Ok(has); + } + + let as_numeral: u8 = password_value.parse().map_err(|_| GDError::TypeParse)?; + + Ok(as_numeral != 0) +} diff --git a/src/protocols/gamespy/mod.rs b/src/protocols/gamespy/mod.rs index 3d98ab9..1f5150f 100644 --- a/src/protocols/gamespy/mod.rs +++ b/src/protocols/gamespy/mod.rs @@ -1,7 +1,5 @@ -/// The implementation. -pub mod protocol; -/// All types used by the implementation. -pub mod types; +mod common; +/// The implementations. +pub mod protocols; -pub use protocol::*; -pub use types::*; +pub use protocols::*; diff --git a/src/protocols/gamespy/protocol/mod.rs b/src/protocols/gamespy/protocol/mod.rs deleted file mode 100644 index 9f32c0a..0000000 --- a/src/protocols/gamespy/protocol/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -/// GameSpy 1 -pub mod one; diff --git a/src/protocols/gamespy/protocols/mod.rs b/src/protocols/gamespy/protocols/mod.rs new file mode 100644 index 0000000..c1d250c --- /dev/null +++ b/src/protocols/gamespy/protocols/mod.rs @@ -0,0 +1,5 @@ +pub mod one; +pub mod three; + +pub use one::*; +pub use three::*; diff --git a/src/protocols/gamespy/protocols/one/mod.rs b/src/protocols/gamespy/protocols/one/mod.rs new file mode 100644 index 0000000..14c6b48 --- /dev/null +++ b/src/protocols/gamespy/protocols/one/mod.rs @@ -0,0 +1,5 @@ +pub mod protocol; +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/src/protocols/gamespy/protocol/one.rs b/src/protocols/gamespy/protocols/one/protocol.rs similarity index 91% rename from src/protocols/gamespy/protocol/one.rs rename to src/protocols/gamespy/protocols/one/protocol.rs index e348cec..5a6d87e 100644 --- a/src/protocols/gamespy/protocol/one.rs +++ b/src/protocols/gamespy/protocols/one/protocol.rs @@ -1,7 +1,7 @@ use crate::{ bufferer::{Bufferer, Endianess}, protocols::{ - gamespy::{Player, Response}, + gamespy::one::{Player, Response}, types::TimeoutSettings, }, socket::{Socket, UdpSocket}, @@ -9,6 +9,7 @@ use crate::{ GDResult, }; +use crate::protocols::gamespy::common::has_password; use std::collections::HashMap; fn get_server_values( @@ -173,21 +174,6 @@ fn extract_players(server_vars: &mut HashMap, players_maximum: u Ok(players) } -fn has_password(server_vars: &mut HashMap) -> GDResult { - let password_value = server_vars - .remove("password") - .ok_or(GDError::PacketBad)? - .to_lowercase(); - - if let Ok(has) = password_value.parse::() { - return Ok(has); - } - - let as_numeral: u8 = password_value.parse().map_err(|_| GDError::TypeParse)?; - - Ok(as_numeral != 0) -} - /// If there are parsing problems using the `query` function, you can directly /// get the server's values using this function. pub fn query_vars( @@ -209,6 +195,10 @@ pub fn query(address: &str, port: u16, timeout_settings: Option .ok_or(GDError::PacketBad)? .parse() .map_err(|_| GDError::TypeParse)?; + let players_minimum = match server_vars.remove("minplayers") { + None => None, + Some(v) => Some(v.parse::().map_err(|_| GDError::TypeParse)?), + }; let players = extract_players(&mut server_vars, players_maximum)?; @@ -225,15 +215,11 @@ pub fn query(address: &str, port: u16, timeout_settings: Option game_version: server_vars.remove("gamever").ok_or(GDError::PacketBad)?, players_maximum, players_online: players.len(), - players_minimum: server_vars - .remove("minplayers") - .unwrap_or_else(|| "0".to_string()) - .parse() - .map_err(|_| GDError::TypeParse)?, + players_minimum, players, tournament: server_vars .remove("tournament") - .unwrap_or_else(|| "true".to_string()) + .unwrap_or("true".to_string()) .to_lowercase() .parse() .map_err(|_| GDError::TypeParse)?, diff --git a/src/protocols/gamespy/types.rs b/src/protocols/gamespy/protocols/one/types.rs similarity index 96% rename from src/protocols/gamespy/types.rs rename to src/protocols/gamespy/protocols/one/types.rs index 8fcefeb..7f328dd 100644 --- a/src/protocols/gamespy/types.rs +++ b/src/protocols/gamespy/protocols/one/types.rs @@ -34,7 +34,7 @@ pub struct Response { pub game_version: String, pub players_maximum: usize, pub players_online: usize, - pub players_minimum: u8, + pub players_minimum: Option, pub players: Vec, pub tournament: bool, pub unused_entries: HashMap, diff --git a/src/protocols/gamespy/protocols/three/mod.rs b/src/protocols/gamespy/protocols/three/mod.rs new file mode 100644 index 0000000..14c6b48 --- /dev/null +++ b/src/protocols/gamespy/protocols/three/mod.rs @@ -0,0 +1,5 @@ +pub mod protocol; +pub mod types; + +pub use protocol::*; +pub use types::*; diff --git a/src/protocols/gamespy/protocols/three/protocol.rs b/src/protocols/gamespy/protocols/three/protocol.rs new file mode 100644 index 0000000..c834d97 --- /dev/null +++ b/src/protocols/gamespy/protocols/three/protocol.rs @@ -0,0 +1,351 @@ +use crate::bufferer::{Bufferer, Endianess}; +use crate::protocols::gamespy::common::has_password; +use crate::protocols::gamespy::three::{Player, Response}; +use crate::protocols::gamespy::Team; +use crate::protocols::types::TimeoutSettings; +use crate::socket::{Socket, UdpSocket}; +use crate::{GDError, GDResult}; +use std::collections::HashMap; + +const THIS_SESSION_ID: u32 = 1; + +struct RequestPacket { + header: u16, + kind: u8, + session_id: u32, + challenge: Option, + payload: Option<[u8; 4]>, +} + +impl RequestPacket { + fn to_bytes(self) -> Vec { + let mut packet: Vec = Vec::with_capacity(7); + packet.extend_from_slice(&self.header.to_be_bytes()); + packet.push(self.kind); + packet.extend_from_slice(&self.session_id.to_be_bytes()); + + if let Some(challenge) = self.challenge { + packet.extend_from_slice(&challenge.to_be_bytes()); + } + + if let Some(payload) = self.payload { + packet.extend_from_slice(&payload); + } + + packet + } +} + +struct GameSpy3 { + socket: UdpSocket, +} + +const PACKET_SIZE: usize = 2048; + +impl GameSpy3 { + fn new(address: &str, port: u16, timeout_settings: Option) -> GDResult { + let socket = UdpSocket::new(address, port)?; + socket.apply_timeout(timeout_settings)?; + + Ok(Self { socket }) + } + + fn receive(&mut self, size: Option, kind: u8) -> GDResult { + let received = self.socket.receive(size.or(Some(PACKET_SIZE)))?; + let mut buf = Bufferer::new_with_data(Endianess::Big, &received); + + if buf.get_u8()? != kind { + return Err(GDError::PacketBad); + } + + if buf.get_u32()? != THIS_SESSION_ID { + return Err(GDError::PacketBad); + } + + Ok(buf) + } + + fn make_initial_handshake(&mut self) -> GDResult> { + self.socket.send( + &RequestPacket { + header: 65277, + kind: 9, + session_id: THIS_SESSION_ID, + challenge: None, + payload: None, + } + .to_bytes(), + )?; + + let mut buf = self.receive(Some(16), 9)?; + + let challenge_as_string = buf.get_string_utf8()?; + let challenge = challenge_as_string + .parse() + .map_err(|_| GDError::TypeParse)?; + + Ok(match challenge == 0 { + true => None, + false => Some(challenge), + }) + } + + fn send_data_request(&mut self, challenge: Option) -> GDResult<()> { + self.socket.send( + &RequestPacket { + header: 65277, + kind: 0, + session_id: THIS_SESSION_ID, + challenge, + payload: Some([0xff, 0xff, 0xff, 0x01]), + } + .to_bytes(), + ) + } +} + +fn get_server_packets(address: &str, port: u16, timeout_settings: Option) -> GDResult>> { + let mut gs3 = GameSpy3::new(address, port, timeout_settings)?; + + let challenge = gs3.make_initial_handshake()?; + gs3.send_data_request(challenge)?; + + let mut values: Vec> = Vec::new(); + + let mut expected_number_of_packets: Option = None; + + while expected_number_of_packets.is_none() || values.len() != expected_number_of_packets.unwrap() { + let mut buf = gs3.receive(None, 0)?; + + if buf.get_string_utf8()? != "splitnum" { + return Err(GDError::PacketBad); + } + + let id = buf.get_u8()?; + let is_last = (id & 0x80) > 0; + let packet_id = (id & 0x7f) as usize; + buf.move_position_ahead(1); //unknown byte regarding packet no. + + if is_last { + expected_number_of_packets = Some(packet_id + 1); + } + + while values.len() <= packet_id { + values.push(Vec::new()); + } + + values[packet_id] = buf.remaining_data_vec(); + } + + if values.iter().any(|v| v.is_empty()) { + return Err(GDError::PacketBad); + } + + Ok(values) +} + +fn data_to_map(packet: &Vec) -> GDResult<(HashMap, Vec)> { + let mut vars = HashMap::new(); + + let mut buf = Bufferer::new_with_data(Endianess::Big, &packet); + while buf.remaining_length() > 0 { + let key = buf.get_string_utf8()?; + if key.is_empty() { + break; + } + + let value = buf.get_string_utf8_optional()?; + + vars.insert(key, value); + } + + Ok((vars, buf.remaining_data_vec())) +} + +/// If there are parsing problems using the `query` function, you can directly +/// get the server's values using this function. +pub fn query_vars( + address: &str, + port: u16, + timeout_settings: Option, +) -> GDResult> { + let packets = get_server_packets(address, port, timeout_settings)?; + + let mut vars = HashMap::new(); + + for packet in &packets { + let (key_values, _remaining_data) = data_to_map(packet)?; + vars.extend(key_values); + } + + Ok(vars) +} + +fn parse_players_and_teams(packets: Vec>) -> GDResult<(Vec, Vec)> { + let mut players_data: Vec> = vec![HashMap::new()]; + let mut teams_data: Vec> = vec![HashMap::new()]; + + for packet in packets { + let mut buf = Bufferer::new_with_data(Endianess::Little, &packet); + + while buf.remaining_length() > 0 { + if buf.get_u8()? < 3 { + continue; + } + + buf.move_position_backward(1); + + let field = buf.get_string_utf8()?; + if field.is_empty() { + continue; + } + + let field_split: Vec<&str> = field.split('_').collect(); + let field_name = field_split.get(0).ok_or(GDError::PacketBad)?; + if !["player", "score", "ping", "team", "deaths", "pid", "skill"].contains(field_name) { + continue; + } + + let field_type = match field_split.get(1) { + None => None, + Some(v) => { + match v.is_empty() { + true => None, + false => { + if v != &"t" { + Err(GDError::PacketBad)? + } + + Some(v) + } + } + } + }; + + let mut offset = buf.get_u8()? as usize; + + let data = match field_type.is_none() { + true => &mut players_data, + false => &mut teams_data, + }; + + while buf.remaining_length() > 0 { + let item = buf.get_string_utf8()?; + if item.is_empty() { + break; + } + + while data.len() <= offset { + data.push(HashMap::new()) + } + + let entry_data = data.get_mut(offset).unwrap(); + entry_data.insert(field_name.to_string(), item); + + offset += 1; + } + } + } + + let mut players: Vec = Vec::new(); + for player_data in players_data { + players.push(Player { + name: player_data + .get("player") + .ok_or(GDError::PacketBad)? + .to_string(), + score: player_data + .get("score") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + ping: player_data + .get("ping") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + team: player_data + .get("team") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + deaths: player_data + .get("deaths") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + skill: player_data + .get("skill") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + }) + } + + let mut teams: Vec = Vec::new(); + for team_data in teams_data { + teams.push(Team { + name: team_data.get("team").ok_or(GDError::PacketBad)?.to_string(), + score: team_data + .get("score") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + }) + } + + Ok((players, teams)) +} + +/// Query a server by providing the address, the port and timeout settings. +/// Providing None to the timeout settings results in using the default values. +/// (TimeoutSettings::[default](TimeoutSettings::default)). +pub fn query(address: &str, port: u16, timeout_settings: Option) -> GDResult { + let packets = get_server_packets(address, port, timeout_settings)?; + + let (mut server_vars, remaining_data) = data_to_map(packets.get(0).ok_or(GDError::PacketBad)?)?; + + let mut remaining_data_packets = vec![remaining_data]; + remaining_data_packets.extend_from_slice(&packets[1 ..]); + let (players, teams) = parse_players_and_teams(remaining_data_packets)?; + + let players_maximum = server_vars + .remove("maxplayers") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::TypeParse)?; + let players_minimum = match server_vars.remove("minplayers") { + None => None, + Some(v) => Some(v.parse::().map_err(|_| GDError::TypeParse)?), + }; + let players_online = match server_vars.remove("numplayers") { + None => players.len(), + Some(v) => { + let reported_players = v.parse().map_err(|_| GDError::TypeParse)?; + match reported_players < players.len() { + true => players.len(), + false => reported_players, + } + } + }; + + Ok(Response { + name: server_vars.remove("hostname").ok_or(GDError::PacketBad)?, + map: server_vars.remove("mapname").ok_or(GDError::PacketBad)?, + has_password: has_password(&mut server_vars)?, + game_type: server_vars.remove("gametype").ok_or(GDError::PacketBad)?, + game_version: server_vars.remove("gamever").ok_or(GDError::PacketBad)?, + players_maximum, + players_online, + players_minimum, + players, + teams, + tournament: server_vars + .remove("tournament") + .unwrap_or("true".to_string()) + .to_lowercase() + .parse() + .map_err(|_| GDError::TypeParse)?, + unused_entries: server_vars, + }) +} diff --git a/src/protocols/gamespy/protocols/three/types.rs b/src/protocols/gamespy/protocols/three/types.rs new file mode 100644 index 0000000..164b19a --- /dev/null +++ b/src/protocols/gamespy/protocols/three/types.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A player’s details. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Player { + pub name: String, + pub score: i32, + pub ping: u16, + pub team: u8, + pub deaths: u32, + pub skill: u32, +} + +/// A team's details +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Team { + pub name: String, + pub score: i32, +} + +/// A query response. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub name: String, + pub map: String, + pub has_password: bool, + pub game_type: String, + pub game_version: String, + pub players_maximum: usize, + pub players_online: usize, + pub players_minimum: Option, + pub players: Vec, + pub teams: Vec, + pub tournament: bool, + pub unused_entries: HashMap, +}