Skip to content

Commit

Permalink
Add support for Mindustry (#178)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Douile authored Jan 17, 2024
1 parent ba92466 commit 07de516
Show file tree
Hide file tree
Showing 9 changed files with 251 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
49 changes: 49 additions & 0 deletions crates/lib/src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
// 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
Expand Down
5 changes: 3 additions & 2 deletions crates/lib/src/games/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)),
};
25 changes: 25 additions & 0 deletions crates/lib/src/games/mindustry/mod.rs
Original file line number Diff line number Diff line change
@@ -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<u16>, timeout_settings: &Option<TimeoutSettings>) -> GDResult<ServerData> {
let address = SocketAddr::new(*ip, port.unwrap_or(DEFAULT_PORT));

protocol::query_with_retries(&address, timeout_settings)
}
58 changes: 58 additions & 0 deletions crates/lib/src/games/mindustry/protocol.rs
Original file line number Diff line number Diff line change
@@ -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<B: byteorder::ByteOrder, D: buffer::StringDecoder>(
buffer: &mut Buffer<B>,
) -> GDResult<ServerData> {
Ok(ServerData {
host: buffer.read_string::<D>(None)?,
map: buffer.read_string::<D>(None)?,
players: buffer.read()?,
wave: buffer.read()?,
version: buffer.read()?,
version_type: buffer.read_string::<D>(None)?,
gamemode: buffer.read::<u8>()?.try_into()?,
player_limit: buffer.read()?,
description: buffer.read_string::<D>(None)?,
mode_name: buffer.read_string::<D>(None).ok(),
})
}

/// Query a Mindustry server (without retries).
pub fn query(address: &SocketAddr, timeout_settings: &Option<TimeoutSettings>) -> GDResult<ServerData> {
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::<byteorder::BigEndian, buffer::Utf8LengthPrefixedDecoder>(&mut buffer)
}

/// Query a Mindustry server.
pub fn query_with_retries(address: &SocketAddr, timeout_settings: &Option<TimeoutSettings>) -> GDResult<ServerData> {
let retries = TimeoutSettings::get_retries_or_default(timeout_settings);

utils::retry_on_timeout(retries, || query(address, timeout_settings))
}
108 changes: 108 additions & 0 deletions crates/lib/src/games/mindustry/types.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<u8> for GameMode {
type Error = GDErrorKind;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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"));
}
}
2 changes: 2 additions & 0 deletions crates/lib/src/games/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/lib/src/games/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions crates/lib/src/protocols/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub enum ProprietaryProtocol {
FFOW,
JC2M,
Savage2,
Mindustry,
}

/// Enumeration of all valid protocol types
Expand All @@ -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),
Expand Down

0 comments on commit 07de516

Please sign in to comment.