Skip to content

Commit

Permalink
WIP: Add unreal2 protocol
Browse files Browse the repository at this point in the history
  • Loading branch information
Douile committed Oct 15, 2023
1 parent 66ae3c2 commit 000594e
Show file tree
Hide file tree
Showing 8 changed files with 517 additions and 1 deletion.
6 changes: 6 additions & 0 deletions src/games/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,10 @@ pub static GAMES: Map<&'static str, Game> = phf_map! {
"vrising" => game!("V Rising", 27016, Protocol::Valve(SteamApp::VRISING)),
"jc2m" => game!("Just Cause 2: Multiplayer", 7777, Protocol::PROPRIETARY(ProprietaryProtocol::JC2M)),
"warsow" => game!("Warsow", 44400, Protocol::Quake(QuakeVersion::Three)),
"darkesthour" => game!("Darkest Hour: Europe '44-'45 (2008)", 7758, Protocol::Unreal2),
"devastation" => game!("Devastation (2003)", 7778, Protocol::Unreal2),
"killingfloor" => game!("Killing Floor", 7708, Protocol::Unreal2),
"redorchestra" => game!("Red Orchestra", 7759, Protocol::Unreal2),
"ut2003" => game!("Unreal Tournament 2003", 7758, Protocol::Unreal2),
"ut2004" => game!("Unreal Tournament 2004", 7778, Protocol::Unreal2),
};
3 changes: 3 additions & 0 deletions src/games/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ use serde::{Deserialize, Serialize};

pub mod gamespy;
pub mod quake;
pub mod unreal2;
pub mod valve;

pub use gamespy::*;
pub use quake::*;
pub use unreal2::*;
pub use valve::*;

/// Battalion 1944
Expand Down Expand Up @@ -125,6 +127,7 @@ pub fn query_with_timeout_and_extra_settings(
QuakeVersion::Three => protocols::quake::three::query(&socket_addr, timeout_settings).map(Box::new)?,
}
}
Protocol::Unreal2 => protocols::unreal2::query(&socket_addr, timeout_settings).map(Box::new)?,
Protocol::PROPRIETARY(protocol) => {
match protocol {
ProprietaryProtocol::TheShip => {
Expand Down
10 changes: 10 additions & 0 deletions src/games/unreal2.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! Unreal2 game query modules
use crate::protocols::unreal2::game_query_mod;

game_query_mod!(darkesthour, "Darkest Hour: Europe '44-'45 (2008)", 7758);
game_query_mod!(devastation, "Devastation (2003)", 7778);
game_query_mod!(killingfloor, "Killing Floor", 7708);
game_query_mod!(redorchestra, "Red Orchestra", 7759);
game_query_mod!(ut2003, "Unreal Tournament 2003", 7758);
game_query_mod!(ut2004, "Unreal Tournament 2004", 7778);
2 changes: 2 additions & 0 deletions src/protocols/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub mod minecraft;
pub mod quake;
/// General types that are used by all protocols.
pub mod types;
/// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/unreal2.js)
pub mod unreal2;
/// Reference: [Server Query](https://developer.valvesoftware.com/wiki/Server_queries)
pub mod valve;

Expand Down
5 changes: 4 additions & 1 deletion src/protocols/types.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::protocols::{gamespy, minecraft, quake, valve};
use crate::protocols::{gamespy, minecraft, quake, unreal2, valve};
use crate::GDErrorKind::InvalidInput;
use crate::GDResult;

Expand All @@ -24,6 +24,7 @@ pub enum Protocol {
Minecraft(Option<minecraft::types::Server>),
Quake(quake::QuakeVersion),
Valve(valve::SteamApp),
Unreal2,
#[cfg(feature = "games")]
PROPRIETARY(ProprietaryProtocol),
}
Expand All @@ -35,6 +36,7 @@ pub enum GenericResponse<'a> {
Minecraft(minecraft::VersionedResponse<'a>),
Quake(quake::VersionedResponse<'a>),
Valve(&'a valve::Response),
Unreal2(&'a unreal2::Response),
#[cfg(feature = "games")]
TheShip(&'a crate::games::theship::Response),
#[cfg(feature = "games")]
Expand All @@ -51,6 +53,7 @@ pub enum GenericPlayer<'a> {
QuakeTwo(&'a quake::two::Player),
Minecraft(&'a minecraft::Player),
Gamespy(gamespy::VersionedPlayer<'a>),
Unreal2(&'a unreal2::Player),
#[cfg(feature = "games")]
TheShip(&'a crate::games::theship::TheShipPlayer),
#[cfg(feature = "games")]
Expand Down
53 changes: 53 additions & 0 deletions src/protocols/unreal2/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/// The implementation.
pub mod protocol;
/// All types used by the implementation.
pub mod types;

pub use protocol::*;
pub use types::*;

/// Generate a module containing a query function for a valve game.
///
/// * `mod_name` - The name to be given to the game module (see ID naming
/// conventions in CONTRIBUTING.md).
/// * `pretty_name` - The full name of the game, will be used as the
/// documentation for the created module.
/// * `default_port` - Passed through to [game_query_fn].
macro_rules! game_query_mod {
($mod_name: ident, $pretty_name: expr, $default_port: literal) => {
#[doc = $pretty_name]
pub mod $mod_name {
crate::protocols::unreal2::game_query_fn!($default_port);
}
};
}

pub(crate) use game_query_mod;

// Allow generating doc comments:
// https://users.rust-lang.org/t/macros-filling-text-in-comments/20473
/// Generate a query function for a valve game.
///
/// * `default_port` - The default port the game uses.
macro_rules! game_query_fn {
($default_port: literal) => {
crate::protocols::unreal2::game_query_fn! {@gen $default_port, concat!(
"Make a Unreal2 query for with default timeout settings and default extra request settings.\n\n",
"If port is `None`, then the default port (", stringify!($default_port), ") will be used.")}
};

(@gen $default_port: literal, $doc: expr) => {
#[doc = $doc]
pub fn query(
address: &std::net::IpAddr,
port: Option<u16>,
) -> crate::GDResult<crate::protocols::unreal2::Response> {
crate::protocols::unreal2::query(
&std::net::SocketAddr::new(*address, port.unwrap_or($default_port)),
None,
)
}
};
}

pub(crate) use game_query_fn;
246 changes: 246 additions & 0 deletions src/protocols/unreal2/protocol.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
use crate::buffer::{Buffer, StringDecoder};
use crate::errors::GDErrorKind::PacketBad;
use crate::protocols::types::TimeoutSettings;
use crate::socket::{Socket, UdpSocket};
use crate::utils::retry_on_timeout;
use crate::GDResult;

use super::{MutatorsAndRules, PacketKind, Players, Response, ServerInfo};

use std::net::SocketAddr;

use byteorder::{ByteOrder, LittleEndian};

// TODO: Validate this is the correct packet size
pub const PACKET_SIZE: usize = 5012;

/// The Unreal2 protocol implementation.
pub(crate) struct Unreal2Protocol {
socket: UdpSocket,
retry_count: usize,
}

impl Unreal2Protocol {
pub fn new(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
let socket = UdpSocket::new(address)?;
let retry_count = timeout_settings
.as_ref()
.map(|t| t.get_retries())
.unwrap_or_else(|| TimeoutSettings::default().get_retries());
socket.apply_timeout(&timeout_settings)?;

Ok(Self {
socket,
retry_count,
})
}

/// Send a request packet and recieve the first response (with retries).
fn get_request_data(&mut self, packet_type: PacketKind) -> GDResult<Vec<u8>> {
retry_on_timeout(self.retry_count, move || {
self.get_request_data_impl(packet_type)
})
}

/// Send a request packet
fn get_request_data_impl(&mut self, packet_type: PacketKind) -> GDResult<Vec<u8>> {
let request = [0x79, 0, 0, 0, packet_type as u8];
self.socket.send(&request)?;

let data = self.socket.receive(Some(PACKET_SIZE))?;

Ok(data)
}

/// Consume the header part of a response packet, validate that the packet
/// type matches what is expected.
fn consume_response_headers<B: ByteOrder>(
buffer: &mut Buffer<B>,
expected_packet_type: PacketKind,
) -> GDResult<()> {
// Skip header
buffer.move_cursor(4)?;

let packet_type: u8 = buffer.read()?;

let packet_type: PacketKind = packet_type.try_into()?;

if packet_type != expected_packet_type {
Err(PacketBad.context(format!(
"Packet response ({:?}) didn't match request ({:?}) packet type",
packet_type, expected_packet_type
)))
} else {
Ok(())
}
}

/// Make a full server query.
pub fn query(&mut self) -> GDResult<Response> {
// Fetch the server info, this can only handle one response packet
let server_info = {
let data = self.get_request_data(PacketKind::ServerInfo)?;
let mut buffer = Buffer::<LittleEndian>::new(&data);
// TODO: Maybe put consume headers in individual packet parse methods
Self::consume_response_headers(&mut buffer, PacketKind::ServerInfo)?;
ServerInfo::parse(&mut buffer)?
};

// TODO: Remove debug logging
println!("{:#?}", server_info);

// Fetch mutators and rules, this is a required packet so we validate that we
// get at least one response. However there can be many packets in
// response to a single request so we greedily handle packets until
// we get a timeout (or any receive error).
let mut mutators_and_rules = MutatorsAndRules::default();
{
let data = self.get_request_data(PacketKind::MutatorsAndRules)?;
let mut buffer = Buffer::<LittleEndian>::new(&data);
// TODO: Maybe put consume headers in individual packet parse methods
Self::consume_response_headers(&mut buffer, PacketKind::MutatorsAndRules)?;
mutators_and_rules.parse(&mut buffer)?
};

// We could receive multiple packets in response
while let Ok(data) = self.socket.receive(Some(PACKET_SIZE)) {
let mut buffer = Buffer::<LittleEndian>::new(&data);

let r = Self::consume_response_headers(&mut buffer, PacketKind::MutatorsAndRules);
if r.is_err() {
println!("{:?}", r);
break;
}

mutators_and_rules.parse(&mut buffer)?;
}

// TODO: Remove debug logging
println!("{:#?}", mutators_and_rules);

// Pre-allocate the player arrays, but don't over allocate memory if the server
// specifies an insane number of players.
let mut players = Players::with_capacity(server_info.num_players.try_into().unwrap_or(10).min(50));

// Fetch first players packet (with retries)
let mut players_data = self.get_request_data(PacketKind::Players);
// Players are non required so if we don't get any responses we continue to
// return
while let Ok(data) = players_data {
let mut buffer = Buffer::<LittleEndian>::new(&data);

Self::consume_response_headers(&mut buffer, PacketKind::Players)?;

players.parse(&mut buffer)?;

// Receive next packet
players_data = self.socket.receive(Some(PACKET_SIZE));
}

// TODO: Handle extra info parsing when we detect certain game types (or maybe
// include that in gather settings).

Ok(Response {
server_info,
mutators_and_rules,
players,
})
}
}

/// Unreal 2 string decoder
pub struct Unreal2StringDecoder;
impl StringDecoder for Unreal2StringDecoder {
type Delimiter = [u8; 1];

const DELIMITER: Self::Delimiter = [0x00];

fn decode_string(data: &[u8], cursor: &mut usize, delimiter: Self::Delimiter) -> GDResult<String> {
let mut ucs2 = false;
let mut length: usize = (*data
.first()
.ok_or(PacketBad.context("Tried to decode string without length"))?)
.into();

let mut start = 0;

// Check if it is a UCS-2 string
if length >= 0x80 {
ucs2 = true;

length = (length & 0x7f) * 2;

start += 1;

// For UCS-2 strings, some unreal 2 games randomly insert an extra 0x01 here,
// not included in the length. Skip it if present (hopefully this never happens
// legitimately)
if let Some(1) = data[start ..].first() {
start += 1;
}
}

// If UCS2 the first byte is the masked length of the string
let string_data = if ucs2 {
let string_data = &data[start .. start + length];
if string_data.len() != length {
return Err(PacketBad.context("Not enough data in buffer to read string"));
}
string_data
} else {
// Else the string is null-delimited latin1

// TODO: Replace this with delimiter finder helper
let position = data
// Create an iterator over the data.
.iter()
// 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(data.len());

length = position + 1;

&data[1.min(position) .. position]
};

// FIXME: This should be latin1 or usc2
let result = String::from_utf8_lossy(string_data);

// Strip color encodings
// TODO: Improve efficiency
// TODO: There might be a nicer way to do this once string patterns are stable
// https://github.com/rust-lang/rust/issues/27721

// After '0x1b' skip 3 characters (including the '0x1b')
let mut char_skip = 0usize;
let result: String = result
.chars()
.filter(|c: &char| {
if '\x1b'.eq(c) {
char_skip = 4;
return false;
}
char_skip = char_skip.saturating_sub(1);
char_skip == 0
})
.collect();

// Remove all characters between 0x00 and 0x1a
let result = result.replace(|c: char| c > '\x00' && c <= '\x1a', "");

*cursor += start + length;

// Strip delimiter that wasn't included in length
Ok(result.trim_matches('\0').to_string())
}
}

/// Make an unreal2 query.
pub fn query(address: &SocketAddr, timeout_settings: Option<TimeoutSettings>) -> GDResult<Response> {
let mut client = Unreal2Protocol::new(address, timeout_settings)?;

client.query()
}

// TODO: Add tests
Loading

0 comments on commit 000594e

Please sign in to comment.