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
This commit is contained in:
Tom 2024-01-17 13:53:40 +00:00 committed by GitHub
parent ba92466ae1
commit 07de5168f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 251 additions and 3 deletions

View file

@ -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).

View file

@ -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

View file

@ -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)),
};

View file

@ -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)
}

View file

@ -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))
}

View file

@ -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"));
}
}

View file

@ -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

View file

@ -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) => {

View file

@ -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),