Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/tests/protocols/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ Protocols Tests
test_ut3/index
test_unreal2/index
test_quake3/index
test_warcraft3/index
test_nadeo/index
test_battlefield/index
test_fivem/index
test_palworld/index
test_quake2/index
test_gamespy2/index
test_flatout2/index
test_doom3/index
test_vcmp/index
test_satisfactory/index
test_gamespy3/index
test_renegadex/index
8 changes: 8 additions & 0 deletions docs/tests/protocols/test_flatout2/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.. _test_flatout2:

test_flatout2
=============

.. toctree::
test_flatout2_get_status
test_get_status
16 changes: 16 additions & 0 deletions docs/tests/protocols/test_flatout2/test_flatout2_get_status.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
test_flatout2_get_status
========================

Here are the results for the test method.

.. code-block:: json

{
"info": {
"hostname": "TestServer",
"timestamp": "-4022673819270859506",
"flags": "679480060",
"status": "3422879744",
"config": "c00000000000081000086124"
}
}
17 changes: 17 additions & 0 deletions docs/tests/protocols/test_flatout2/test_get_status.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
test_get_status
===============

Here are the results for the test method.

.. code-block:: json

{
"info": {
"hostname": "TestServer",
"timestamp": "1234567890",
"flags": "2818572332",
"status": "6094848",
"config": "000000000810000861240400101440"
},
"players": []
}
7 changes: 7 additions & 0 deletions docs/tests/protocols/test_warcraft3/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.. _test_warcraft3:

test_warcraft3
==============

.. toctree::
test_warcraft3_status
24 changes: 24 additions & 0 deletions docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
test_warcraft3_status
=====================

Here are the results for the test method.

.. code-block:: json

{
"game_version": "PX3W 26",
"hostname": "Lokales Spiel (Banane)",
"map_name": "Map name unavailable",
"game_type": "Custom Game",
"num_players": 1,
"max_players": 2,
"raw": {
"product": "PX3W",
"version": 26,
"host_counter": 6,
"entry_key": 52398541,
"settings_raw": "0103490701015501a955010fc791334d8b6171735d47736f857b656f5569736f456f655d293329555b6973697367616d6b476d616565732f477733790143616f8b616f650101272d35cd8315819ba93f8be953214553e7c513a1bd9b4b",
"game_flags": 9,
"remaining_data": ""
}
}
2 changes: 2 additions & 0 deletions opengsq/protocols/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from opengsq.protocols.doom3 import Doom3
from opengsq.protocols.eos import EOS
from opengsq.protocols.fivem import FiveM
from opengsq.protocols.flatout2 import Flatout2
from opengsq.protocols.gamespy1 import GameSpy1
from opengsq.protocols.gamespy2 import GameSpy2
from opengsq.protocols.gamespy3 import GameSpy3
Expand All @@ -27,4 +28,5 @@
from opengsq.protocols.unreal2 import Unreal2
from opengsq.protocols.ut3 import UT3
from opengsq.protocols.vcmp import Vcmp
from opengsq.protocols.warcraft3 import Warcraft3
from opengsq.protocols.won import WON
168 changes: 168 additions & 0 deletions opengsq/protocols/flatout2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
from __future__ import annotations

import os
from opengsq.binary_reader import BinaryReader
from opengsq.exceptions import InvalidPacketException
from opengsq.protocol_base import ProtocolBase
from opengsq.protocol_socket import UdpClient
from opengsq.responses.flatout2 import Status


class Flatout2(ProtocolBase):
"""
This class represents the Flatout 2 Protocol. It provides methods to interact with Flatout 2 game servers.
The protocol uses broadcast packets to discover and query servers.
"""

full_name = "Flatout 2 Protocol"
FLATOUT2_PORT = 23757 # Default broadcast port for Flatout 2

# Protocol specific constants
REQUEST_HEADER = b"\x22\x00"
RESPONSE_HEADER = b"\x59\x00"
GAME_IDENTIFIER = b"FO14"
SESSION_ID = b"\x99\x72\xcc\x8f"
COMMAND_QUERY = b"\x18\x0c"
PACKET_END = b"\x2e\x55\x19\xb4\xe1\x4f\x81\x4a"

def __init__(self, host: str, port: int = FLATOUT2_PORT, timeout: float = 5.0):
"""
Initialize the Flatout 2 protocol handler.

:param host: The broadcast address (usually '255.255.255.255' for LAN)
:param port: The port to use (default: 23757)
:param timeout: Connection timeout in seconds
"""
if port != self.FLATOUT2_PORT:
raise ValueError(f"Flatout 2 protocol requires port {self.FLATOUT2_PORT}")
super().__init__(host, self.FLATOUT2_PORT, timeout)
self._allow_broadcast = True

async def get_status(self) -> Status:
"""
Asynchronously retrieves the status of Flatout 2 servers via broadcast.
Expects a response packet with server information.

:return: A Status object containing the status of the game server.
"""
# Build the request packet
request_data = (
self.REQUEST_HEADER + # Protocol header
self.SESSION_ID + # Session ID
b"\x00" * 4 + # Padding pre-identifier
self.GAME_IDENTIFIER + # "FO14"
b"\x00" * 8 + # Padding post-identifier
self.COMMAND_QUERY + # Query command
b"\x00\x00\x22\x00" + # Command data
self.PACKET_END # Standard packet end
)

# Send broadcast and receive response
data = await UdpClient.communicate(self, request_data, source_port=self.FLATOUT2_PORT)

# Debug output for packet analysis
print(f"Response header: {data[:2].hex()}, Expected: {self.RESPONSE_HEADER.hex()}")
print(f"Session ID: {data[2:6].hex()}")
print(f"Game ID: {data[10:14]}, Expected: {self.GAME_IDENTIFIER}")
print(f"Full packet length: {len(data)}")

# Verify response packet
if not self._verify_packet(data):
raise InvalidPacketException("Invalid response packet received")

br = BinaryReader(data)
return self._parse_response(br)

def _verify_packet(self, data: bytes) -> bool:
"""
Verifies that a packet is a valid Flatout 2 response.

:param data: The packet data to verify
:return: True if the packet is valid, False otherwise
"""
if len(data) < 14: # Minimum length for header + session ID + padding + game ID
print(f"Packet too short: {len(data)} bytes")
return False

# Check response header
header_matches = data.startswith(self.RESPONSE_HEADER)
if not header_matches:
print(f"Header mismatch: got {data[:2].hex()}, expected {self.RESPONSE_HEADER.hex()}")
return False

# Check game identifier (position 10-14, after session ID and padding)
game_id = data[10:14]
game_id_matches = game_id == self.GAME_IDENTIFIER
if not game_id_matches:
print(f"Game ID mismatch: got {game_id}, expected {self.GAME_IDENTIFIER}")
return False

return True

def _read_utf16_string(self, br: BinaryReader) -> str:
"""
Reads a UTF-16 encoded string from the binary reader.

:param br: The binary reader to read from
:return: The decoded string
"""
bytes_list = []
while True:
# Read two bytes at a time (UTF-16)
char_bytes = br.read_bytes(2)
if char_bytes == b"\x00\x00": # End of string
break
bytes_list.extend(char_bytes)

return bytes(bytes_list).decode('utf-16-le').strip()

def _parse_response(self, br: BinaryReader) -> Status:
"""
Parses the binary response into a Status object.
The response contains UTF-16 encoded strings and various server information.

:param br: BinaryReader containing the response data
:return: A Status object containing the parsed information
"""
# Skip header (2), session ID (4), padding (4), game ID (4), padding (8), command (4), data (2), and packet end (8)
br.read_bytes(36) # Skip to the server data section

info = {}

try:
# Read server name (UTF-16 encoded)
server_name = self._read_utf16_string(br)
info["hostname"] = server_name

# Read server information
timestamp = br.read_long_long() # Server timestamp
info["timestamp"] = str(timestamp)

server_flags = br.read_long(unsigned=True) # Server configuration flags
info["flags"] = str(server_flags)

# Skip reserved bytes
br.read_bytes(8)

# Read server status
status_flags = br.read_long(unsigned=True)
info["status"] = str(status_flags)

# Read server configuration
config = br.read_bytes(12) # Remaining configuration data
info["config"] = config.hex()

except Exception as e:
print(f"Error parsing response: {e}")

return Status(info=info)


if __name__ == "__main__":
import asyncio

async def main_async():
# Use broadcast address for LAN discovery
flatout2 = Flatout2(host="255.255.255.255", port=23757, timeout=5.0)
status = await flatout2.get_status()
print(status)
Loading