diff --git a/README.md b/README.md index 95bbc8b..d2a113f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ from opengsq.protocols import ( Scum, Source, TeamSpeak3, + UDK, Unreal2, + UT3, Vcmp, WON, ) diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 9470f4c..016b0f0 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -4,29 +4,30 @@ Protocols Tests =============== .. toctree:: - test_ase/index - test_battlefield/index - test_doom3/index - test_eos/index - test_fivem/index - test_gamespy1/index - test_gamespy2/index - test_gamespy3/index test_gamespy4/index - test_kaillera/index - test_killingfloor/index + test_teamspeak3/index + test_won/index + test_gamespy1/index test_minecraft/index - test_nadeo/index - test_palworld/index - test_quake1/index - test_quake2/index - test_quake3/index test_raknet/index + test_eos/index + test_kaillera/index + test_ase/index + test_quake1/index + test_killingfloor/index + test_source/index test_samp/index - test_satisfactory/index test_scum/index - test_source/index - test_teamspeak3/index + test_ut3/index test_unreal2/index + test_quake3/index + test_nadeo/index + test_battlefield/index + test_fivem/index + test_palworld/index + test_quake2/index + test_gamespy2/index + test_doom3/index test_vcmp/index - test_won/index + test_satisfactory/index + test_gamespy3/index diff --git a/docs/tests/protocols/test_ut3/index.rst b/docs/tests/protocols/test_ut3/index.rst new file mode 100644 index 0000000..a505935 --- /dev/null +++ b/docs/tests/protocols/test_ut3/index.rst @@ -0,0 +1,7 @@ +.. _test_ut3: + +test_ut3 +======== + +.. toctree:: + test_ut3_status \ No newline at end of file diff --git a/docs/tests/protocols/test_ut3/test_ut3_status.rst b/docs/tests/protocols/test_ut3/test_ut3_status.rst new file mode 100644 index 0000000..3edeead --- /dev/null +++ b/docs/tests/protocols/test_ut3/test_ut3_status.rst @@ -0,0 +1,167 @@ +test_ut3_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "name": "Test UT3 InstaGib Server", + "map": "DM-Deck", + "game_type": "UTGame.UTDeathmatch", + "num_players": 0, + "max_players": 32, + "password_protected": false, + "stats_enabled": false, + "lan_mode": true, + "players": [], + "raw": { + "hostaddress": "0.0.0.0", + "hostport": 7777, + "num_players": 0, + "max_players": 32, + "lan_mode": true, + "uses_stats": false, + "owner_id": "0000000000000000", + "owner_name": "Test UT3 InstaGib Server", + "localized_settings": [ + { + "id": 32779, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 0, + "value_index": 2, + "advertisement_type": 1 + }, + { + "id": 1, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 6, + "value_index": 1, + "advertisement_type": 1 + }, + { + "id": 7, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 8, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 9, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 10, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 11, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 12, + "value_index": 0, + "advertisement_type": 1 + }, + { + "id": 13, + "value_index": 1, + "advertisement_type": 1 + }, + { + "id": 14, + "value_index": 1, + "advertisement_type": 1 + } + ], + "settings_properties": [ + { + "id": 1073741825, + "data": "DM-Deck", + "advertisement_type": 2 + }, + { + "id": 1073741826, + "data": "UTGame.UTDeathmatch", + "advertisement_type": 2 + }, + { + "id": 268435704, + "data": 25, + "advertisement_type": 1 + }, + { + "id": 268435705, + "data": 20, + "advertisement_type": 1 + }, + { + "id": 268435703, + "data": 6, + "advertisement_type": 1 + }, + { + "id": 1073741827, + "data": "", + "advertisement_type": 2 + }, + { + "id": 268435717, + "data": 32, + "advertisement_type": 1 + }, + { + "id": 1073741828, + "data": "", + "advertisement_type": 2 + }, + { + "id": 1073741829, + "data": "", + "advertisement_type": 2 + }, + { + "id": 268435706, + "data": 6, + "advertisement_type": 0 + }, + { + "id": 268435968, + "data": 0, + "advertisement_type": 0 + }, + { + "id": 268435969, + "data": 0, + "advertisement_type": 0 + } + ], + "map": "DM-Deck", + "gametype": "UTGame.UTDeathmatch", + "frag_limit": 25, + "time_limit": 20, + "numbots": 6, + "stock_mutators": [ + "Instagib" + ], + "custom_mutators": [], + "gamemode": "Deathmatch", + "bot_skill": "Experienced", + "pure_server": 1, + "password": 0, + "vs_bots": "None", + "force_respawn": 0 + } + } \ No newline at end of file diff --git a/opengsq/protocol_base.py b/opengsq/protocol_base.py index 001aab4..eb063fc 100644 --- a/opengsq/protocol_base.py +++ b/opengsq/protocol_base.py @@ -11,3 +11,4 @@ def __init__(self, host: str, port: int, timeout: float = 5.0): self._host = host self._port = port self._timeout = timeout + self._allow_broadcast = False \ No newline at end of file diff --git a/opengsq/protocol_socket.py b/opengsq/protocol_socket.py index 57debbc..f782ed3 100644 --- a/opengsq/protocol_socket.py +++ b/opengsq/protocol_socket.py @@ -15,11 +15,41 @@ class Socket(): async def gethostbyname(hostname: str): return await asyncio.get_running_loop().run_in_executor(None, socket.gethostbyname, hostname) + class Protocol(asyncio.Protocol): + def __init__(self, timeout: float): + self.__packets = asyncio.Queue() + self.__timeout = timeout + + async def recv(self): + return await asyncio.wait_for(self.__packets.get(), timeout=self.__timeout) + + def connection_made(self, transport): + pass + + def connection_lost(self, exc): + pass + + def data_received(self, data): + self.__packets.put_nowait(data) + + def eof_received(self): + pass + + def datagram_received(self, data, addr): + self.__packets.put_nowait(data) + + def error_received(self, exc): + pass + def __init__(self, kind: SocketKind): self.__timeout = None self.__transport = None self.__protocol = None self.__kind = kind + self.__local_port = None + + def bind_port(self, port: int): + self.__local_port = port async def __aenter__(self): return self @@ -48,11 +78,13 @@ async def __connect(self, remote_addr): lambda: self.__protocol, host=remote_addr[0], port=remote_addr[1], + local_addr=('0.0.0.0', self.__local_port) if self.__local_port else None ) else: self.__transport, _ = await loop.create_datagram_endpoint( lambda: self.__protocol, remote_addr=remote_addr, + local_addr=('0.0.0.0', self.__local_port) if self.__local_port else None ) def close(self): @@ -115,12 +147,24 @@ def error_received(self, exc): class UdpClient(Socket): @staticmethod - async def communicate(protocol: ProtocolBase, data: bytes): + async def communicate(protocol: ProtocolBase, data: bytes, source_port: int = None): with UdpClient() as udpClient: + if source_port: + udpClient.bind_port(source_port) udpClient.settimeout(protocol._timeout) - await udpClient.connect((protocol._host, protocol._port)) - udpClient.send(data) - return await udpClient.recv() + + loop = asyncio.get_running_loop() + transport, protocol_instance = await loop.create_datagram_endpoint( + lambda: Socket.Protocol(protocol._timeout), # Use public Protocol class + local_addr=('0.0.0.0', source_port if source_port else 0), + allow_broadcast=protocol._allow_broadcast + ) + + try: + transport.sendto(data, (protocol._host, protocol._port)) + return await protocol_instance.recv() + finally: + transport.close() def __init__(self): super().__init__(SocketKind.SOCK_DGRAM) @@ -150,4 +194,4 @@ async def test_socket_async(): loop = asyncio.get_event_loop() loop.run_until_complete(test_socket_async()) - loop.close() + loop.close() \ No newline at end of file diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index b6382e7..242ea7d 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -21,6 +21,8 @@ from opengsq.protocols.scum import Scum from opengsq.protocols.source import Source from opengsq.protocols.teamspeak3 import TeamSpeak3 +from opengsq.protocols.udk import UDK from opengsq.protocols.unreal2 import Unreal2 +from opengsq.protocols.ut3 import UT3 from opengsq.protocols.vcmp import Vcmp -from opengsq.protocols.won import WON +from opengsq.protocols.won import WON \ No newline at end of file diff --git a/opengsq/protocols/udk.py b/opengsq/protocols/udk.py new file mode 100644 index 0000000..d8e047c --- /dev/null +++ b/opengsq/protocols/udk.py @@ -0,0 +1,177 @@ +from opengsq.protocol_base import ProtocolBase +from opengsq.protocol_socket import UdpClient +from opengsq.responses.udk.status import Status, PlatformType, Player +from opengsq.binary_reader import BinaryReader +import struct +import os + +class UDK(ProtocolBase): + full_name = "UnrealEngine3/UDK Protocol" + LAN_BEACON_PACKET_HEADER_SIZE = 16 + UDK_PORT = 14001 + + def __init__(self, host: str, port: int = UDK_PORT, timeout: float = 5.0): + if port != self.UDK_PORT: + raise ValueError(f"UDK protocol requires port {self.UDK_PORT}") + super().__init__(host, self.UDK_PORT, timeout) + self._allow_broadcast = True + self.packet_version = 5 + self.game_id = 0x00000000 + self.platform = PlatformType.Windows + self.client_nonce = os.urandom(8) + self.packet_types_query = (b'S', b'Q') + self.packet_types_response = (b'S', b'R') + + async def get_status(self) -> Status: + packet = self._build_query_packet() + data = await UdpClient.communicate(self, packet, source_port=self.UDK_PORT) + if not self._is_valid_response(data): + raise Exception("Invalid response") + parsed_data = self._parse_response(data) + return Status(**parsed_data) + + def _build_query_packet(self) -> bytes: + packet = bytearray(self.LAN_BEACON_PACKET_HEADER_SIZE) + struct.pack_into("!BB", packet, 0, self.packet_version, self.platform) + struct.pack_into("!I", packet, 2, self.game_id) + packet[6:7] = self.packet_types_query[0] + packet[7:8] = self.packet_types_query[1] + packet[8:16] = self.client_nonce + return bytes(packet) + + def _is_valid_response(self, buffer: bytes) -> bool: + if len(buffer) <= self.LAN_BEACON_PACKET_HEADER_SIZE: + return False + + version = buffer[0] + platform = buffer[1] + game_id = struct.unpack("!I", buffer[2:6])[0] + response_type = (buffer[6:7], buffer[7:8]) + response_nonce = buffer[8:16] + + return (version == self.packet_version and + platform == self.platform and + game_id == self.game_id and + response_type == self.packet_types_response and + response_nonce == self.client_nonce) + + def _parse_response(self, buffer: bytes) -> dict: + br = BinaryReader(buffer[self.LAN_BEACON_PACKET_HEADER_SIZE:]) + + # Parse IP and port + ip = struct.unpack("!I", br.read_bytes(4))[0] + port = struct.unpack("!I", br.read_bytes(4))[0] + ip_str = f"{(ip >> 24) & 255}.{(ip >> 16) & 255}.{(ip >> 8) & 255}.{ip & 255}" + + # Parse connection info + num_open_public_conn = struct.unpack("!I", br.read_bytes(4))[0] + num_open_private_conn = struct.unpack("!I", br.read_bytes(4))[0] + num_public_conn = struct.unpack("!I", br.read_bytes(4))[0] + num_private_conn = struct.unpack("!I", br.read_bytes(4))[0] + + # Parse flags + should_advertise = br.read_bytes(1)[0] == 1 + is_lan_match = br.read_bytes(1)[0] == 1 + uses_stats = br.read_bytes(1)[0] == 1 + allow_join_in_progress = br.read_bytes(1)[0] == 1 + allow_invites = br.read_bytes(1)[0] == 1 + uses_presence = br.read_bytes(1)[0] == 1 + allow_join_via_presence = br.read_bytes(1)[0] == 1 + uses_arbitration = br.read_bytes(1)[0] == 1 + + if self.packet_version >= 5: + anti_cheat_protected = br.read_bytes(1)[0] == 1 + + # Read owner info + owner_id = br.read_bytes(8) + owner_name = self._read_string(br) + + # Read properties + num_advertised_properties = struct.unpack("!I", br.read_bytes(4))[0] + localized_settings = [] + for _ in range(num_advertised_properties): + if br.remaining_bytes() <= 0: + break + setting_id = struct.unpack("!i", br.read_bytes(4))[0] + value_index = struct.unpack("!i", br.read_bytes(4))[0] + advertisement_type = br.read_bytes(1)[0] + localized_settings.append({ + 'id': setting_id, + 'value_index': value_index, + 'advertisement_type': advertisement_type + }) + + num_properties = struct.unpack("!I", br.read_bytes(4))[0] + settings_properties = [] + for _ in range(num_properties): + if br.remaining_bytes() <= 0: + break + property_id = struct.unpack("!I", br.read_bytes(4))[0] + data_type = br.read_bytes(1)[0] + data = self._read_settings_data(br, data_type) + advertisement_type = br.read_bytes(1)[0] + settings_properties.append({ + 'id': property_id, + 'data': data, + 'advertisement_type': advertisement_type + }) + + raw = { + 'hostaddress': ip_str, + 'hostport': port, + 'num_players': num_public_conn - num_open_public_conn, + 'max_players': num_public_conn, + 'lan_mode': is_lan_match, + 'uses_stats': uses_stats, + 'owner_id': owner_id.hex(), + 'owner_name': owner_name, + 'localized_settings': localized_settings, + 'settings_properties': settings_properties + } + + result = { + 'name': owner_name, + 'map': '', + 'game_type': '', + 'num_players': raw['num_players'], + 'max_players': raw['max_players'], + 'password_protected': False, + 'stats_enabled': uses_stats, + 'lan_mode': is_lan_match, + 'players': [], + 'raw': raw + } + + # Map properties by ID + for prop in settings_properties: + if prop['id'] == 1073741825: # Map + result['map'] = prop['data'] + elif prop['id'] == 1073741826: # Game Type + result['game_type'] = prop['data'] + + return result + + def _read_string(self, br: BinaryReader) -> str: + length = struct.unpack("!i", br.read_bytes(4))[0] + if length <= 0: + return "" + return br.read_bytes(length).decode('utf-8') + + def _read_settings_data(self, br: BinaryReader, data_type: int) -> any: + if data_type == 0: # SDT_Empty + return None + elif data_type == 1: # SDT_Int32 + return struct.unpack("!i", br.read_bytes(4))[0] + elif data_type == 2: # SDT_Int64 + return struct.unpack("!q", br.read_bytes(8))[0] + elif data_type == 3: # SDT_Double + return struct.unpack("!d", br.read_bytes(8))[0] + elif data_type == 4: # SDT_String + return self._read_string(br) + elif data_type == 5: # SDT_Float + return struct.unpack("!f", br.read_bytes(4))[0] + elif data_type == 6: # SDT_Blob + raise NotImplementedError("Blob data type not supported") + elif data_type == 7: # SDT_DateTime + return (struct.unpack("!i", br.read_bytes(4))[0], + struct.unpack("!i", br.read_bytes(4))[0]) # (date, time) \ No newline at end of file diff --git a/opengsq/protocols/ut3.py b/opengsq/protocols/ut3.py new file mode 100644 index 0000000..aa01d24 --- /dev/null +++ b/opengsq/protocols/ut3.py @@ -0,0 +1,115 @@ +from opengsq.protocols.udk import UDK +from opengsq.responses.ut3.status import Status + +class UT3(UDK): + GAMEMODE_NAMES = { + 0: "Deathmatch", + 1: "Team Deathmatch", + 2: "Capture The Flag", + 3: "Vehicle CTF", + 4: "Warfare", + 5: "Duel", + 6: "Campaign", + 7: "Greed", + 8: "Betrayal", + 9: "Custom" + } + + MUTATOR_NAMES = { + 0x1: "SlowTimeKills", + 0x2: "BigHead", + 0x4: "NoOrbs", + 0x8: "FriendlyFire", + 0x10: "Handicap", + 0x20: "Instagib", + 0x40: "LowGrav", + 0x80: "NoPowerups", + 0x100: "NoTranslocator", + 0x200: "Slomo", + 0x400: "SpeedFreak", + 0x800: "SuperBerserk", + 0x1000: "WeaponReplacement", + 0x2000: "WeaponsRespawn", + 0x4000: "Survival", + 0x8000: "Hero", + 0x10000: "Arena" + } + + BOT_SKILL_NAMES = { + 0: "Novice", + 1: "Average", + 2: "Experienced", + 3: "Skilled", + 4: "Adept", + 5: "Masterful", + 6: "Inhuman", + 7: "Godlike" + } + + VS_BOTS_NAMES = { + 0: "None", + 1: "1:1", + 2: "3:2", + 3: "2:1" + } + + full_name = "Unreal Tournament 3 Protocol" + + def __init__(self, host: str, port: int = 14001, timeout: float = 5.0): + super().__init__(host, port, timeout) + self.game_id = 0x4D5707DB + + def _parse_response(self, buffer: bytes) -> dict: + base_response = super()._parse_response(buffer) + + # Process properties + ut3_properties = {} + for prop in base_response['raw']['settings_properties']: + prop_id = prop['id'] + if prop_id == 1073741825: # Map + base_response['map'] = prop['data'] + ut3_properties['map'] = prop['data'] + elif prop_id == 1073741826: # Game Type + base_response['game_type'] = prop['data'] + ut3_properties['gametype'] = prop['data'] + elif prop_id == 268435704: # Frag Limit + ut3_properties['frag_limit'] = prop['data'] + elif prop_id == 268435705: # Time Limit + ut3_properties['time_limit'] = prop['data'] + elif prop_id == 268435703: # Number of Bots + ut3_properties['numbots'] = prop['data'] + if prop_id == 268435717: # Stock Mutators + ut3_properties['stock_mutators'] = self._parse_mutators(prop['data']) + elif prop_id == 1073741828: # Custom Mutators + ut3_properties['custom_mutators'] = self._parse_mutators(prop['data']) + + # Process localized settings + for setting in base_response['raw']['localized_settings']: + setting_id = setting['id'] + value_index = setting['value_index'] + + if setting_id == 32779: # Game Mode + ut3_properties['gamemode'] = self.GAMEMODE_NAMES.get(value_index, f"Unknown_{value_index}") + elif setting_id == 0: + ut3_properties['bot_skill'] = self.BOT_SKILL_NAMES.get(value_index) + elif setting_id == 6: + ut3_properties['pure_server'] = value_index + elif setting_id == 7: + base_response['password_protected'] = value_index == 1 + ut3_properties['password'] = value_index + elif setting_id == 8: + ut3_properties['vs_bots'] = self.VS_BOTS_NAMES.get(value_index) + elif setting_id == 10: + ut3_properties['force_respawn'] = value_index + + base_response['raw'].update(ut3_properties) + return base_response + + def _parse_mutators(self, mutator_value: any) -> list: + if not mutator_value: + return [] + try: + int_value = int(mutator_value) + return [name for flag, name in self.MUTATOR_NAMES.items() if int_value & flag] + except (ValueError, TypeError): + return [] \ No newline at end of file diff --git a/opengsq/responses/udk/__init__.py b/opengsq/responses/udk/__init__.py new file mode 100644 index 0000000..2a4c93e --- /dev/null +++ b/opengsq/responses/udk/__init__.py @@ -0,0 +1 @@ +from .status import Status \ No newline at end of file diff --git a/opengsq/responses/udk/status.py b/opengsq/responses/udk/status.py new file mode 100644 index 0000000..5b55174 --- /dev/null +++ b/opengsq/responses/udk/status.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from typing import Union, List +from enum import IntEnum + +class PlatformType(IntEnum): + Unknown = 0 + Windows = 1 + Xenon = 4 + PS3 = 8 + Linux = 16 + MacOSX = 32 + +@dataclass +class Player: + name: str + score: int = 0 + ping: int = 0 + team: int = 0 + +@dataclass +class Status: + name: str + map: str + game_type: str + num_players: int + max_players: int + password_protected: bool + stats_enabled: bool + lan_mode: bool + players: List[Player] + raw: dict[str, Union[str, int, bool, list]] \ No newline at end of file diff --git a/opengsq/responses/ut3/__init__.py b/opengsq/responses/ut3/__init__.py new file mode 100644 index 0000000..2a4c93e --- /dev/null +++ b/opengsq/responses/ut3/__init__.py @@ -0,0 +1 @@ +from .status import Status \ No newline at end of file diff --git a/opengsq/responses/ut3/status.py b/opengsq/responses/ut3/status.py new file mode 100644 index 0000000..a4b552c --- /dev/null +++ b/opengsq/responses/ut3/status.py @@ -0,0 +1,11 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List +from opengsq.responses.udk.status import Status as UDKStatus + +@dataclass +class Status(UDKStatus): + """UT3-specific status response""" + mutators: List[str] = None + stock_mutators: List[str] = None + custom_mutators: List[str] = None \ No newline at end of file diff --git a/tests/protocols/test_ut3.py b/tests/protocols/test_ut3.py new file mode 100644 index 0000000..824cdb3 --- /dev/null +++ b/tests/protocols/test_ut3.py @@ -0,0 +1,40 @@ +import pytest +from opengsq.protocols.ut3 import UT3 +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +#handler.enable_save = True + +@pytest.mark.asyncio +async def test_ut3_status(): + ut3 = UT3(host="10.13.36.1", port=14001) + result = await ut3.get_status() + + print("\nUT3 Server Details:") + print(f"Server Name: {result.name}") + print(f"Map: {result.map}") + print(f"Game Type: {result.game_type}") + print(f"Game Mode: {result.raw.get('gamemode')}") + print(f"Players: {result.num_players}/{result.max_players}") + print(f"Time Limit: {result.raw.get('time_limit')} minutes") + print(f"Score Limit: {result.raw.get('frag_limit')} frags") + + print("\nBot Settings:") + print(f"Bot Count: {result.raw.get('numbots')}") + print(f"Bot Skill: {result.raw.get('bot_skill')}") + print(f"VS Bots Mode: {result.raw.get('vs_bots')}") + + print("\nServer Settings:") + print(f"Password Protected: {'Yes' if result.password_protected else 'No'}") + print(f"LAN Mode: {'Yes' if result.lan_mode else 'No'}") + print(f"Stats Enabled: {'Yes' if result.stats_enabled else 'No'}") + + print("\nMutators:") + if 'stock_mutators' in result.raw: + stock = result.raw['stock_mutators'] + print("Stock:", ", ".join(stock) if stock else "None") + if 'custom_mutators' in result.raw: + custom = result.raw['custom_mutators'] + print("Custom:", ", ".join(custom) if custom else "None") + + await handler.save_result("test_ut3_status", result) \ No newline at end of file