From 07de5168f42a666182b00380e0234d73a2880975 Mon Sep 17 00:00:00 2001 From: Tom <25043847+Douile@users.noreply.github.com> Date: Wed, 17 Jan 2024 13:53:40 +0000 Subject: [PATCH] Add support for Mindustry (#178) * buffer: Add UTF8LengthPrefixed string decoder * games: Use expression for default port This allows us to refer to constants for the default ports if we want to (literals will still work). * games: Add support for mindustry --- CHANGELOG.md | 1 + crates/lib/src/buffer.rs | 49 ++++++++++ crates/lib/src/games/definitions.rs | 5 +- crates/lib/src/games/mindustry/mod.rs | 25 +++++ crates/lib/src/games/mindustry/protocol.rs | 58 +++++++++++ crates/lib/src/games/mindustry/types.rs | 108 +++++++++++++++++++++ crates/lib/src/games/mod.rs | 2 + crates/lib/src/games/query.rs | 3 +- crates/lib/src/protocols/types.rs | 3 + 9 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 crates/lib/src/games/mindustry/mod.rs create mode 100644 crates/lib/src/games/mindustry/protocol.rs create mode 100644 crates/lib/src/games/mindustry/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2542c91..fc4dd53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Games: - [Zombie Panic: Source](https://store.steampowered.com/app/17500/Zombie_Panic_Source/) support. - Added a valve protocol query example. - Made all of Just Cause 2: Multiplayer Response and Player fields public. +- [Mindustry](https://mindustrygame.github.io/) support. Protocols: - Added the unreal2 protocol and its associated games: Darkest Hour, Devastation, Killing Floor, Red Orchestra, Unreal Tournament 2003, Unreal Tournament 2004 (by @Douile). diff --git a/crates/lib/src/buffer.rs b/crates/lib/src/buffer.rs index 420be8b..1a0c0aa 100644 --- a/crates/lib/src/buffer.rs +++ b/crates/lib/src/buffer.rs @@ -361,6 +361,55 @@ impl StringDecoder for Utf8Decoder { } } +/// A decoder for UTF-8 encoded strings prefixed by a single byte denoting the +/// string's length. +/// +/// This decoder uses a single null byte (`0x00`) as the default delimiter. +pub struct Utf8LengthPrefixedDecoder; + +impl StringDecoder for Utf8LengthPrefixedDecoder { + type Delimiter = [u8; 1]; + + const DELIMITER: Self::Delimiter = [0x00]; + + /// Decodes a UTF-8 string from the given data, updating the cursor position + /// accordingly. + fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult { + // Find the maximum length of the string + let length = *data + .first() + .ok_or(PacketBad.context("Length of string not found"))?; + + // Find the position of the delimiter in the data. If the delimiter is not + // found, the length is returned. + let position = data + // Create an iterator over the data. + .iter() + .skip(1) + .take(length as usize) + // 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(length as usize); + + // Convert the data until the found position into a UTF-8 string. + let result = std::str::from_utf8( + // Take a slice of data until the position. + &data[1 .. position + 1] + ) + // If the data cannot be converted into a UTF-8 string, return an error + .map_err(|e| PacketBad.context(e))? + // Convert the resulting &str into a String + .to_owned(); + + // Update the cursor position + // The +1 is to skip t length + *cursor += position + 1; + + Ok(result) + } +} + /// A decoder for UTF-16 encoded strings. /// /// This decoder uses a pair of null bytes (`0x00, 0x00`) as the default diff --git a/crates/lib/src/games/definitions.rs b/crates/lib/src/games/definitions.rs index df5f748..18c736d 100644 --- a/crates/lib/src/games/definitions.rs +++ b/crates/lib/src/games/definitions.rs @@ -9,7 +9,7 @@ use crate::protocols::valve::GatheringSettings; use phf::{phf_map, Map}; macro_rules! game { - ($name: literal, $default_port: literal, $protocol: expr) => { + ($name: literal, $default_port: expr, $protocol: expr) => { game!( $name, $default_port, @@ -18,7 +18,7 @@ macro_rules! game { ) }; - ($name: literal, $default_port: literal, $protocol: expr, $extra_request_settings: expr) => { + ($name: literal, $default_port: expr, $protocol: expr, $extra_request_settings: expr) => { Game { name: $name, default_port: $default_port, @@ -132,4 +132,5 @@ pub static GAMES: Map<&'static str, Game> = phf_map! { "unrealtournament2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2), "unrealtournament2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2), "zps" => game!("Zombie Panic: Source", 27015, Protocol::Valve(Engine::new(17_500))), + "mindustry" => game!("Mindustry", crate::games::mindustry::DEFAULT_PORT, Protocol::PROPRIETARY(ProprietaryProtocol::Mindustry)), }; diff --git a/crates/lib/src/games/mindustry/mod.rs b/crates/lib/src/games/mindustry/mod.rs new file mode 100644 index 0000000..dc3fb7c --- /dev/null +++ b/crates/lib/src/games/mindustry/mod.rs @@ -0,0 +1,25 @@ +//! Mindustry game ping (v146) +//! +//! [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/ArcNetProvider.java#L225-L259) + +use std::{net::IpAddr, net::SocketAddr}; + +use crate::{GDResult, TimeoutSettings}; + +use self::types::ServerData; + +pub mod types; + +pub mod protocol; + +/// Default mindustry server port +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/Vars.java#L141-L142) +pub const DEFAULT_PORT: u16 = 6567; + +/// Query a mindustry server. +pub fn query(ip: &IpAddr, port: Option, timeout_settings: &Option) -> GDResult { + let address = SocketAddr::new(*ip, port.unwrap_or(DEFAULT_PORT)); + + protocol::query_with_retries(&address, timeout_settings) +} diff --git a/crates/lib/src/games/mindustry/protocol.rs b/crates/lib/src/games/mindustry/protocol.rs new file mode 100644 index 0000000..a42d69a --- /dev/null +++ b/crates/lib/src/games/mindustry/protocol.rs @@ -0,0 +1,58 @@ +use std::net::SocketAddr; + +use crate::{ + buffer::{self, Buffer}, + socket::{Socket, UdpSocket}, + utils, + GDResult, + TimeoutSettings, +}; + +use super::types::ServerData; + +/// Mindustry max datagram packet size. +pub const MAX_BUFFER_SIZE: usize = 500; + +/// Send a ping packet. +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/ArcNetProvider.java#L248) +pub fn send_ping(socket: &mut UdpSocket) -> GDResult<()> { socket.send(&[-2i8 as u8, 1i8 as u8]) } + +/// Parse server data. +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/NetworkIO.java#L122-L135) +pub fn parse_server_data( + buffer: &mut Buffer, +) -> GDResult { + Ok(ServerData { + host: buffer.read_string::(None)?, + map: buffer.read_string::(None)?, + players: buffer.read()?, + wave: buffer.read()?, + version: buffer.read()?, + version_type: buffer.read_string::(None)?, + gamemode: buffer.read::()?.try_into()?, + player_limit: buffer.read()?, + description: buffer.read_string::(None)?, + mode_name: buffer.read_string::(None).ok(), + }) +} + +/// Query a Mindustry server (without retries). +pub fn query(address: &SocketAddr, timeout_settings: &Option) -> GDResult { + let mut socket = UdpSocket::new(address, timeout_settings)?; + + send_ping(&mut socket)?; + + let socket_data = socket.receive(Some(MAX_BUFFER_SIZE))?; + let mut buffer = Buffer::new(&socket_data); + + parse_server_data::(&mut buffer) +} + +/// Query a Mindustry server. +pub fn query_with_retries(address: &SocketAddr, timeout_settings: &Option) -> GDResult { + let retries = TimeoutSettings::get_retries_or_default(timeout_settings); + + utils::retry_on_timeout(retries, || query(address, timeout_settings)) +} diff --git a/crates/lib/src/games/mindustry/types.rs b/crates/lib/src/games/mindustry/types.rs new file mode 100644 index 0000000..c1e84f4 --- /dev/null +++ b/crates/lib/src/games/mindustry/types.rs @@ -0,0 +1,108 @@ +use crate::{ + protocols::types::{CommonResponse, GenericResponse}, + GDErrorKind, +}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Mindustry sever data +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/net/NetworkIO.java#L122-L135) +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct ServerData { + pub host: String, + pub map: String, + pub players: i32, + pub wave: i32, + pub version: i32, + pub version_type: String, + pub gamemode: GameMode, + pub player_limit: i32, + pub description: String, + pub mode_name: Option, +} + +/// Mindustry game mode +/// +/// [Reference](https://github.com/Anuken/Mindustry/blob/a2e5fbdedb2fc1c8d3c157bf344d10ad6d321442/core/src/mindustry/game/Gamemode.java) +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub enum GameMode { + Survival, + Sandbox, + Attack, + PVP, + Editor, +} + +impl TryFrom for GameMode { + type Error = GDErrorKind; + fn try_from(value: u8) -> Result { + use GameMode::*; + Ok(match value { + 0 => Survival, + 1 => Sandbox, + 2 => Attack, + 3 => PVP, + 4 => Editor, + _ => return Err(GDErrorKind::TypeParse), + }) + } +} + +impl GameMode { + fn as_str(&self) -> &'static str { + use GameMode::*; + match self { + Survival => "survival", + Sandbox => "sandbox", + Attack => "attack", + PVP => "pvp", + Editor => "editor", + } + } +} + +impl CommonResponse for ServerData { + fn as_original(&self) -> GenericResponse { GenericResponse::Mindustry(self) } + + fn players_online(&self) -> u32 { self.players.try_into().unwrap_or(0) } + fn players_maximum(&self) -> u32 { self.player_limit.try_into().unwrap_or(0) } + + fn game_mode(&self) -> Option<&str> { Some(self.gamemode.as_str()) } + + fn map(&self) -> Option<&str> { Some(&self.map) } + fn description(&self) -> Option<&str> { Some(&self.description) } +} + +#[cfg(test)] +mod test { + use crate::protocols::types::CommonResponse; + + use super::ServerData; + + #[test] + fn common_impl() { + let data = ServerData { + host: String::from("host"), + map: String::from("map"), + players: 5, + wave: 2, + version: 142, + version_type: String::from("steam"), + gamemode: super::GameMode::PVP, + player_limit: 20, + description: String::from("description"), + mode_name: Some(String::from("campaign")), + }; + + let common: &dyn CommonResponse = &data; + + assert_eq!(common.players_online(), 5); + assert_eq!(common.players_maximum(), 20); + assert_eq!(common.game_mode(), Some("pvp")); + assert_eq!(common.map(), Some("map")); + assert_eq!(common.description(), Some("description")); + } +} diff --git a/crates/lib/src/games/mod.rs b/crates/lib/src/games/mod.rs index 32467b5..5088ffd 100644 --- a/crates/lib/src/games/mod.rs +++ b/crates/lib/src/games/mod.rs @@ -16,6 +16,8 @@ pub mod battalion1944; pub mod ffow; /// Just Cause 2: Multiplayer pub mod jc2m; +/// Mindustry +pub mod mindustry; /// Minecraft pub mod minecraft; /// Savage 2 diff --git a/crates/lib/src/games/query.rs b/crates/lib/src/games/query.rs index d37cdd8..2d2bf9c 100644 --- a/crates/lib/src/games/query.rs +++ b/crates/lib/src/games/query.rs @@ -3,7 +3,7 @@ use std::net::{IpAddr, SocketAddr}; use crate::games::types::Game; -use crate::games::{ffow, jc2m, minecraft, savage2, theship}; +use crate::games::{ffow, jc2m, mindustry, minecraft, savage2, theship}; use crate::protocols; use crate::protocols::gamespy::GameSpyVersion; use crate::protocols::quake::QuakeVersion; @@ -84,6 +84,7 @@ pub fn query_with_timeout_and_extra_settings( } ProprietaryProtocol::FFOW => ffow::query_with_timeout(address, port, timeout_settings).map(Box::new)?, ProprietaryProtocol::JC2M => jc2m::query_with_timeout(address, port, timeout_settings).map(Box::new)?, + ProprietaryProtocol::Mindustry => mindustry::query(address, port, &timeout_settings).map(Box::new)?, ProprietaryProtocol::Minecraft(version) => { match version { Some(minecraft::Server::Java) => { diff --git a/crates/lib/src/protocols/types.rs b/crates/lib/src/protocols/types.rs index 182d594..7986769 100644 --- a/crates/lib/src/protocols/types.rs +++ b/crates/lib/src/protocols/types.rs @@ -19,6 +19,7 @@ pub enum ProprietaryProtocol { FFOW, JC2M, Savage2, + Mindustry, } /// Enumeration of all valid protocol types @@ -42,6 +43,8 @@ pub enum GenericResponse<'a> { Valve(&'a valve::Response), Unreal2(&'a unreal2::Response), #[cfg(feature = "games")] + Mindustry(&'a crate::games::mindustry::types::ServerData), + #[cfg(feature = "games")] Minecraft(minecraft::VersionedResponse<'a>), #[cfg(feature = "games")] TheShip(&'a crate::games::theship::Response),