[Protocol] Add GameSpy 3 support. (#25)

* [Protocol] Gamespy3 initial code

* [Protocol] Add rest of challenge solving

* [Protocol] Remove unused stuff

* [Protocol] Remove adding unused bytes

* [Protocol] Clean up code

* [Protocol] Make gs3 a struct

* [Protocol] Add initial key-value parsing

* [Protocol] Manage multiple packets

* [Protocol] Split server vars and other vars

* Revert "[Protocol] Split server vars and other vars"

This reverts commit 9a930aeb68802fcf3d0908a2e031dfea054d37d0.

* [Protocol] Proper packet management and initial response struct

* [Protocol] Fix players_minimum

* [Protocol] Fix server vars to parse only the first packet

* [Protocol] Update CHANGELOG.md

* [Protocol] Initial player parsing

* [Protocol] Split GS one and three

* [Protocol] Add common code file

* [Protocol] Change static to const

* [Protocol] Fix players_online and break on data to map on empty key

* [Protocol] Remove unused types and printlns

* [Protocol] Add teams parsing

* [Protocol] Split key_values and parsing data

* [Crate] Update PROTOCOLS.md
This commit is contained in:
CosminPerRam 2023-04-17 15:10:51 +03:00 committed by GitHub
parent 1b13d39856
commit 786da81ea5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 459 additions and 36 deletions

View file

@ -9,6 +9,7 @@ Crate:
Protocols:
- GameSpy 1: Add key `admin` as a possible variable for `admin_name`.
- GameSpy 3 support.
Games:
- [Serious Sam](https://www.gog.com/game/serious_sam_the_first_encounter) support.
@ -16,6 +17,7 @@ Games:
### Breaking:
Protocols:
- Valve: Request type enums have been renamed from all caps to starting-only uppercase, ex: `INFO` to `Info`
- GameSpy 1: `players_minimum` is now an `Option<u8>` instead of an `u8`
# 0.2.1 - 03/03/2023
### Changes:

View file

@ -1,11 +1,11 @@
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) <br> 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) | 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. |
| 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) <br> 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. |
## Planned to add support:
_

View file

@ -165,6 +165,8 @@ fn main() -> GDResult<()> {
"ut" => println!("{:#?}", ut::query(ip, port)),
"bf1942" => println!("{:#?}", bf1942::query(ip, port)),
"ss" => println!("{:#?}", ss::query(ip, port)),
"_gamespy3" => println!("{:#?}", gamespy::three::query(ip, port.unwrap(), None)),
"_gamespy3_vars" => println!("{:#?}", gamespy::three::query_vars(ip, port.unwrap(), None)),
_ => panic!("Undefined game: {}", args[1]),
};

View file

@ -108,6 +108,18 @@ impl Bufferer {
Ok(value)
}
pub fn get_string_utf8_optional(&mut self) -> GDResult<String> {
match self.get_string_utf8() {
Ok(data) => Ok(data),
Err(e) => {
match e {
PacketUnderflow => Ok(String::new()),
x => Err(x),
}
}
}
}
pub fn get_string_utf8_unended(&mut self) -> GDResult<String> {
let sub_buf = self.remaining_data();
if sub_buf.is_empty() {

View file

@ -0,0 +1,17 @@
use crate::{GDError, GDResult};
use std::collections::HashMap;
pub fn has_password(server_vars: &mut HashMap<String, String>) -> GDResult<bool> {
let password_value = server_vars
.remove("password")
.ok_or(GDError::PacketBad)?
.to_lowercase();
if let Ok(has) = password_value.parse::<bool>() {
return Ok(has);
}
let as_numeral: u8 = password_value.parse().map_err(|_| GDError::TypeParse)?;
Ok(as_numeral != 0)
}

View file

@ -1,7 +1,5 @@
/// The implementation.
pub mod protocol;
/// All types used by the implementation.
pub mod types;
mod common;
/// The implementations.
pub mod protocols;
pub use protocol::*;
pub use types::*;
pub use protocols::*;

View file

@ -1,2 +0,0 @@
/// GameSpy 1
pub mod one;

View file

@ -0,0 +1,5 @@
pub mod one;
pub mod three;
pub use one::*;
pub use three::*;

View file

@ -0,0 +1,5 @@
pub mod protocol;
pub mod types;
pub use protocol::*;
pub use types::*;

View file

@ -1,7 +1,7 @@
use crate::{
bufferer::{Bufferer, Endianess},
protocols::{
gamespy::{Player, Response},
gamespy::one::{Player, Response},
types::TimeoutSettings,
},
socket::{Socket, UdpSocket},
@ -9,6 +9,7 @@ use crate::{
GDResult,
};
use crate::protocols::gamespy::common::has_password;
use std::collections::HashMap;
fn get_server_values(
@ -173,21 +174,6 @@ fn extract_players(server_vars: &mut HashMap<String, String>, players_maximum: u
Ok(players)
}
fn has_password(server_vars: &mut HashMap<String, String>) -> GDResult<bool> {
let password_value = server_vars
.remove("password")
.ok_or(GDError::PacketBad)?
.to_lowercase();
if let Ok(has) = password_value.parse::<bool>() {
return Ok(has);
}
let as_numeral: u8 = password_value.parse().map_err(|_| GDError::TypeParse)?;
Ok(as_numeral != 0)
}
/// If there are parsing problems using the `query` function, you can directly
/// get the server's values using this function.
pub fn query_vars(
@ -209,6 +195,10 @@ pub fn query(address: &str, port: u16, timeout_settings: Option<TimeoutSettings>
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::TypeParse)?;
let players_minimum = match server_vars.remove("minplayers") {
None => None,
Some(v) => Some(v.parse::<u8>().map_err(|_| GDError::TypeParse)?),
};
let players = extract_players(&mut server_vars, players_maximum)?;
@ -225,15 +215,11 @@ pub fn query(address: &str, port: u16, timeout_settings: Option<TimeoutSettings>
game_version: server_vars.remove("gamever").ok_or(GDError::PacketBad)?,
players_maximum,
players_online: players.len(),
players_minimum: server_vars
.remove("minplayers")
.unwrap_or_else(|| "0".to_string())
.parse()
.map_err(|_| GDError::TypeParse)?,
players_minimum,
players,
tournament: server_vars
.remove("tournament")
.unwrap_or_else(|| "true".to_string())
.unwrap_or("true".to_string())
.to_lowercase()
.parse()
.map_err(|_| GDError::TypeParse)?,

View file

@ -34,7 +34,7 @@ pub struct Response {
pub game_version: String,
pub players_maximum: usize,
pub players_online: usize,
pub players_minimum: u8,
pub players_minimum: Option<u8>,
pub players: Vec<Player>,
pub tournament: bool,
pub unused_entries: HashMap<String, String>,

View file

@ -0,0 +1,5 @@
pub mod protocol;
pub mod types;
pub use protocol::*;
pub use types::*;

View file

@ -0,0 +1,351 @@
use crate::bufferer::{Bufferer, Endianess};
use crate::protocols::gamespy::common::has_password;
use crate::protocols::gamespy::three::{Player, Response};
use crate::protocols::gamespy::Team;
use crate::protocols::types::TimeoutSettings;
use crate::socket::{Socket, UdpSocket};
use crate::{GDError, GDResult};
use std::collections::HashMap;
const THIS_SESSION_ID: u32 = 1;
struct RequestPacket {
header: u16,
kind: u8,
session_id: u32,
challenge: Option<i32>,
payload: Option<[u8; 4]>,
}
impl RequestPacket {
fn to_bytes(self) -> Vec<u8> {
let mut packet: Vec<u8> = Vec::with_capacity(7);
packet.extend_from_slice(&self.header.to_be_bytes());
packet.push(self.kind);
packet.extend_from_slice(&self.session_id.to_be_bytes());
if let Some(challenge) = self.challenge {
packet.extend_from_slice(&challenge.to_be_bytes());
}
if let Some(payload) = self.payload {
packet.extend_from_slice(&payload);
}
packet
}
}
struct GameSpy3 {
socket: UdpSocket,
}
const PACKET_SIZE: usize = 2048;
impl GameSpy3 {
fn new(address: &str, port: u16, timeout_settings: Option<TimeoutSettings>) -> GDResult<Self> {
let socket = UdpSocket::new(address, port)?;
socket.apply_timeout(timeout_settings)?;
Ok(Self { socket })
}
fn receive(&mut self, size: Option<usize>, kind: u8) -> GDResult<Bufferer> {
let received = self.socket.receive(size.or(Some(PACKET_SIZE)))?;
let mut buf = Bufferer::new_with_data(Endianess::Big, &received);
if buf.get_u8()? != kind {
return Err(GDError::PacketBad);
}
if buf.get_u32()? != THIS_SESSION_ID {
return Err(GDError::PacketBad);
}
Ok(buf)
}
fn make_initial_handshake(&mut self) -> GDResult<Option<i32>> {
self.socket.send(
&RequestPacket {
header: 65277,
kind: 9,
session_id: THIS_SESSION_ID,
challenge: None,
payload: None,
}
.to_bytes(),
)?;
let mut buf = self.receive(Some(16), 9)?;
let challenge_as_string = buf.get_string_utf8()?;
let challenge = challenge_as_string
.parse()
.map_err(|_| GDError::TypeParse)?;
Ok(match challenge == 0 {
true => None,
false => Some(challenge),
})
}
fn send_data_request(&mut self, challenge: Option<i32>) -> GDResult<()> {
self.socket.send(
&RequestPacket {
header: 65277,
kind: 0,
session_id: THIS_SESSION_ID,
challenge,
payload: Some([0xff, 0xff, 0xff, 0x01]),
}
.to_bytes(),
)
}
}
fn get_server_packets(address: &str, port: u16, timeout_settings: Option<TimeoutSettings>) -> GDResult<Vec<Vec<u8>>> {
let mut gs3 = GameSpy3::new(address, port, timeout_settings)?;
let challenge = gs3.make_initial_handshake()?;
gs3.send_data_request(challenge)?;
let mut values: Vec<Vec<u8>> = Vec::new();
let mut expected_number_of_packets: Option<usize> = None;
while expected_number_of_packets.is_none() || values.len() != expected_number_of_packets.unwrap() {
let mut buf = gs3.receive(None, 0)?;
if buf.get_string_utf8()? != "splitnum" {
return Err(GDError::PacketBad);
}
let id = buf.get_u8()?;
let is_last = (id & 0x80) > 0;
let packet_id = (id & 0x7f) as usize;
buf.move_position_ahead(1); //unknown byte regarding packet no.
if is_last {
expected_number_of_packets = Some(packet_id + 1);
}
while values.len() <= packet_id {
values.push(Vec::new());
}
values[packet_id] = buf.remaining_data_vec();
}
if values.iter().any(|v| v.is_empty()) {
return Err(GDError::PacketBad);
}
Ok(values)
}
fn data_to_map(packet: &Vec<u8>) -> GDResult<(HashMap<String, String>, Vec<u8>)> {
let mut vars = HashMap::new();
let mut buf = Bufferer::new_with_data(Endianess::Big, &packet);
while buf.remaining_length() > 0 {
let key = buf.get_string_utf8()?;
if key.is_empty() {
break;
}
let value = buf.get_string_utf8_optional()?;
vars.insert(key, value);
}
Ok((vars, buf.remaining_data_vec()))
}
/// If there are parsing problems using the `query` function, you can directly
/// get the server's values using this function.
pub fn query_vars(
address: &str,
port: u16,
timeout_settings: Option<TimeoutSettings>,
) -> GDResult<HashMap<String, String>> {
let packets = get_server_packets(address, port, timeout_settings)?;
let mut vars = HashMap::new();
for packet in &packets {
let (key_values, _remaining_data) = data_to_map(packet)?;
vars.extend(key_values);
}
Ok(vars)
}
fn parse_players_and_teams(packets: Vec<Vec<u8>>) -> GDResult<(Vec<Player>, Vec<Team>)> {
let mut players_data: Vec<HashMap<String, String>> = vec![HashMap::new()];
let mut teams_data: Vec<HashMap<String, String>> = vec![HashMap::new()];
for packet in packets {
let mut buf = Bufferer::new_with_data(Endianess::Little, &packet);
while buf.remaining_length() > 0 {
if buf.get_u8()? < 3 {
continue;
}
buf.move_position_backward(1);
let field = buf.get_string_utf8()?;
if field.is_empty() {
continue;
}
let field_split: Vec<&str> = field.split('_').collect();
let field_name = field_split.get(0).ok_or(GDError::PacketBad)?;
if !["player", "score", "ping", "team", "deaths", "pid", "skill"].contains(field_name) {
continue;
}
let field_type = match field_split.get(1) {
None => None,
Some(v) => {
match v.is_empty() {
true => None,
false => {
if v != &"t" {
Err(GDError::PacketBad)?
}
Some(v)
}
}
}
};
let mut offset = buf.get_u8()? as usize;
let data = match field_type.is_none() {
true => &mut players_data,
false => &mut teams_data,
};
while buf.remaining_length() > 0 {
let item = buf.get_string_utf8()?;
if item.is_empty() {
break;
}
while data.len() <= offset {
data.push(HashMap::new())
}
let entry_data = data.get_mut(offset).unwrap();
entry_data.insert(field_name.to_string(), item);
offset += 1;
}
}
}
let mut players: Vec<Player> = Vec::new();
for player_data in players_data {
players.push(Player {
name: player_data
.get("player")
.ok_or(GDError::PacketBad)?
.to_string(),
score: player_data
.get("score")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::PacketBad)?,
ping: player_data
.get("ping")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::PacketBad)?,
team: player_data
.get("team")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::PacketBad)?,
deaths: player_data
.get("deaths")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::PacketBad)?,
skill: player_data
.get("skill")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::PacketBad)?,
})
}
let mut teams: Vec<Team> = Vec::new();
for team_data in teams_data {
teams.push(Team {
name: team_data.get("team").ok_or(GDError::PacketBad)?.to_string(),
score: team_data
.get("score")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::PacketBad)?,
})
}
Ok((players, teams))
}
/// Query a server by providing the address, the port and timeout settings.
/// Providing None to the timeout settings results in using the default values.
/// (TimeoutSettings::[default](TimeoutSettings::default)).
pub fn query(address: &str, port: u16, timeout_settings: Option<TimeoutSettings>) -> GDResult<Response> {
let packets = get_server_packets(address, port, timeout_settings)?;
let (mut server_vars, remaining_data) = data_to_map(packets.get(0).ok_or(GDError::PacketBad)?)?;
let mut remaining_data_packets = vec![remaining_data];
remaining_data_packets.extend_from_slice(&packets[1 ..]);
let (players, teams) = parse_players_and_teams(remaining_data_packets)?;
let players_maximum = server_vars
.remove("maxplayers")
.ok_or(GDError::PacketBad)?
.parse()
.map_err(|_| GDError::TypeParse)?;
let players_minimum = match server_vars.remove("minplayers") {
None => None,
Some(v) => Some(v.parse::<u8>().map_err(|_| GDError::TypeParse)?),
};
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,
}
}
};
Ok(Response {
name: server_vars.remove("hostname").ok_or(GDError::PacketBad)?,
map: server_vars.remove("mapname").ok_or(GDError::PacketBad)?,
has_password: has_password(&mut server_vars)?,
game_type: server_vars.remove("gametype").ok_or(GDError::PacketBad)?,
game_version: server_vars.remove("gamever").ok_or(GDError::PacketBad)?,
players_maximum,
players_online,
players_minimum,
players,
teams,
tournament: server_vars
.remove("tournament")
.unwrap_or("true".to_string())
.to_lowercase()
.parse()
.map_err(|_| GDError::TypeParse)?,
unused_entries: server_vars,
})
}

View file

@ -0,0 +1,42 @@
use std::collections::HashMap;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// A players details.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Player {
pub name: String,
pub score: i32,
pub ping: u16,
pub team: u8,
pub deaths: u32,
pub skill: u32,
}
/// A team's details
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Team {
pub name: String,
pub score: i32,
}
/// A query response.
#[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 game_type: String,
pub game_version: String,
pub players_maximum: usize,
pub players_online: usize,
pub players_minimum: Option<u8>,
pub players: Vec<Player>,
pub teams: Vec<Team>,
pub tournament: bool,
pub unused_entries: HashMap<String, String>,
}