diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cacb71..6b87f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,11 @@ Who knows what the future holds... # 0.X.Y - DD/MM/2023 ### Changes: -To be made... +Protocols: +- GameSpy 2 support. + +Games: +- [Halo: Combat Evolved](https://en.wikipedia.org/wiki/Halo:_Combat_Evolved) support. ### Breaking... Crate: diff --git a/GAMES.md b/GAMES.md index 0b9adfa..42a01b3 100644 --- a/GAMES.md +++ b/GAMES.md @@ -55,6 +55,7 @@ Beware of the `Notes` column, as it contains information about query port offset | Quake 3: Arena | QUAKE3A | Quake 3 | | | Hell Let Loose | HLL | Valve Protocol | Query port is 26420. Note that on this port it might not send players data, as there might be another query port that does send players data. | | Soldier of Fortune 2 | SOF2 | Quake 3 | | +| Halo: Combat Evolved | HALOCE | GameSpy 2 | | ## Planned to add support: _ diff --git a/PROTOCOLS.md b/PROTOCOLS.md index 4a67279..0b838b3 100644 --- a/PROTOCOLS.md +++ b/PROTOCOLS.md @@ -1,12 +1,12 @@ 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) 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) | | +| 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) 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) | | ## Planned to add support: _ diff --git a/examples/master_querant.rs b/examples/master_querant.rs index 109b9e7..15390fc 100644 --- a/examples/master_querant.rs +++ b/examples/master_querant.rs @@ -27,6 +27,7 @@ use gamedig::{ dst, ffow, gm, + haloce, hl2dm, hldms, hll, @@ -186,6 +187,8 @@ fn main() -> GDResult<()> { "quake3a" => println!("{:#?}", quake3a::query(ip, port)?), "hll" => println!("{:#?}", hll::query(ip, port)?), "sof2" => println!("{:#?}", sof2::query(ip, port)?), + "_gamespy2" => println!("{:#?}", gamespy::two::query(address, None)), + "haloce" => println!("{:#?}", haloce::query(ip, port)?), _ => panic!("Undefined game: {}", args[1]), }; diff --git a/src/games/haloce.rs b/src/games/haloce.rs new file mode 100644 index 0000000..6ab7af8 --- /dev/null +++ b/src/games/haloce.rs @@ -0,0 +1,8 @@ +use crate::protocols::gamespy; +use crate::protocols::gamespy::two::Response; +use crate::GDResult; +use std::net::{IpAddr, SocketAddr}; + +pub fn query(address: &IpAddr, port: Option) -> GDResult { + gamespy::two::query(&SocketAddr::new(*address, port.unwrap_or(2302)), None) +} diff --git a/src/games/mod.rs b/src/games/mod.rs index 0a4e006..068492c 100644 --- a/src/games/mod.rs +++ b/src/games/mod.rs @@ -48,6 +48,8 @@ pub mod dst; pub mod ffow; /// Garry's Mod pub mod gm; +/// Halo: Combat Evolved +pub mod haloce; /// Half-Life 2 Deathmatch pub mod hl2dm; /// Half-Life Deathmatch: Source diff --git a/src/protocols/gamespy/protocols/mod.rs b/src/protocols/gamespy/protocols/mod.rs index c2a9530..bdd73c4 100644 --- a/src/protocols/gamespy/protocols/mod.rs +++ b/src/protocols/gamespy/protocols/mod.rs @@ -1,2 +1,3 @@ pub mod one; pub mod three; +pub mod two; diff --git a/src/protocols/gamespy/protocols/two/mod.rs b/src/protocols/gamespy/protocols/two/mod.rs new file mode 100644 index 0000000..14c6b48 --- /dev/null +++ b/src/protocols/gamespy/protocols/two/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/two/protocol.rs b/src/protocols/gamespy/protocols/two/protocol.rs new file mode 100644 index 0000000..5b09d5f --- /dev/null +++ b/src/protocols/gamespy/protocols/two/protocol.rs @@ -0,0 +1,185 @@ +use crate::bufferer::{Bufferer, Endianess}; +use crate::protocols::gamespy::two::{Player, Response, Team}; +use crate::protocols::types::TimeoutSettings; +use crate::socket::{Socket, UdpSocket}; +use crate::{GDError, GDResult}; +use std::collections::HashMap; +use std::net::SocketAddr; + +struct GameSpy2 { + socket: UdpSocket, +} + +macro_rules! table_extract { + ($table:expr, $name:literal, $index:expr) => { + $table + .get($name) + .ok_or(GDError::PacketBad)? + .get($index) + .ok_or(GDError::PacketBad)? + }; +} + +macro_rules! table_extract_parse { + ($table:expr, $name:literal, $index:expr) => { + table_extract!($table, $name, $index) + .parse() + .map_err(|_| GDError::PacketBad)? + }; +} + +fn data_as_table(data: &mut Bufferer) -> GDResult<(HashMap>, usize)> { + if data.get_u8()? != 0 { + Err(GDError::PacketBad)? + } + + let rows = data.get_u8()? as usize; + + if rows == 0 { + return Ok((HashMap::new(), 0)); + } + + let mut column_heads = Vec::new(); + + let mut current_column = data.get_string_utf8()?; + while !current_column.is_empty() { + column_heads.push(current_column); + current_column = data.get_string_utf8()?; + } + + let columns = column_heads.len(); + let mut table = HashMap::with_capacity(columns); + for head in &column_heads { + table.insert(head.clone(), Vec::new()); // TODO: This doesn't look good nor it is performant, fix later + } + + for _ in 0 .. rows { + for column in column_heads.iter() { + let value = data.get_string_utf8()?; + table.get_mut(column).ok_or(GDError::PacketBad)?.push(value); + } + } + + Ok((table, rows)) +} + +impl GameSpy2 { + fn new(address: &SocketAddr, timeout_settings: Option) -> GDResult { + let socket = UdpSocket::new(address)?; + socket.apply_timeout(timeout_settings)?; + + Ok(Self { socket }) + } + + fn request_data(&mut self) -> GDResult { + self.socket + .send(&[0xFE, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xFF])?; + + let received = self.socket.receive(None)?; + let mut buf = Bufferer::new_with_data(Endianess::Big, &received); + + if buf.get_u8()? != 0 { + return Err(GDError::PacketBad); + } + + if buf.get_u32()? != 1 { + return Err(GDError::PacketBad); + } + + Ok(buf) + } +} + +fn get_server_vars(bufferer: &mut Bufferer) -> GDResult> { + let mut values = HashMap::new(); + + let mut done_processing_vars = false; + while !done_processing_vars && bufferer.remaining_length() > 0 { + let key = bufferer.get_string_utf8()?; + let value = bufferer.get_string_utf8_optional()?; + + if key.is_empty() { + if value.is_empty() { + bufferer.move_position_backward(1); + done_processing_vars = true; + } + + continue; + } + + values.insert(key, value); + } + + Ok(values) +} + +fn get_teams(bufferer: &mut Bufferer) -> GDResult> { + let mut teams = Vec::new(); + + let (table, entries) = data_as_table(bufferer)?; + + for index in 0 .. entries { + teams.push(Team { + name: table_extract!(table, "team_t", index).clone(), + score: table_extract_parse!(table, "score_t", index), + }) + } + + Ok(teams) +} + +fn get_players(bufferer: &mut Bufferer) -> GDResult> { + let mut players = Vec::new(); + + let (table, entries) = data_as_table(bufferer)?; + + for index in 0 .. entries { + players.push(Player { + name: table_extract!(table, "player_", index).clone(), + score: table_extract_parse!(table, "score_", index), + ping: table_extract_parse!(table, "ping_", index), + team_index: table_extract_parse!(table, "team_", index), + }) + } + + Ok(players) +} + +pub fn query(address: &SocketAddr, timeout_settings: Option) -> GDResult { + let mut client = GameSpy2::new(address, timeout_settings)?; + let mut data = client.request_data()?; + + let mut server_vars = get_server_vars(&mut data)?; + let players = get_players(&mut data)?; + + 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, + } + } + }; + let players_minimum = match server_vars.remove("minplayers") { + None => None, + Some(v) => Some(v.parse::().map_err(|_| GDError::TypeParse)?), + }; + + Ok(Response { + name: server_vars.remove("hostname").ok_or(GDError::PacketBad)?, + map: server_vars.remove("mapname").ok_or(GDError::PacketBad)?, + has_password: server_vars.remove("password").ok_or(GDError::PacketBad)? == "1", + teams: get_teams(&mut data)?, + players_maximum: server_vars + .remove("maxplayers") + .ok_or(GDError::PacketBad)? + .parse() + .map_err(|_| GDError::PacketBad)?, + players_online, + players_minimum, + players, + unused_entries: server_vars, + }) +} diff --git a/src/protocols/gamespy/protocols/two/types.rs b/src/protocols/gamespy/protocols/two/types.rs new file mode 100644 index 0000000..086204c --- /dev/null +++ b/src/protocols/gamespy/protocols/two/types.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Team { + pub name: String, + pub score: u16, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Player { + pub name: String, + pub score: u16, + pub ping: u16, + pub team_index: u16, +} + +#[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 teams: Vec, + pub players_maximum: usize, + pub players_online: usize, + pub players_minimum: Option, + pub players: Vec, + pub unused_entries: HashMap, +}