From 161932b55ee3182f7d49b0f47abff9046a9fb9ba Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 9 Jul 2025 14:00:01 +0200 Subject: [PATCH 01/20] Adding Game_type Mapping in IT3 --- opengsq/protocols/ut3.py | 1 + 1 file changed, 1 insertion(+) diff --git a/opengsq/protocols/ut3.py b/opengsq/protocols/ut3.py index aa01d24..5cf6b9b 100644 --- a/opengsq/protocols/ut3.py +++ b/opengsq/protocols/ut3.py @@ -89,6 +89,7 @@ def _parse_response(self, buffer: bytes) -> dict: value_index = setting['value_index'] if setting_id == 32779: # Game Mode + base_response['game_type'] = self.GAMEMODE_NAMES.get(value_index, f"Unknown_{value_index}") 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) From 53e70d13f879b85433647a07ae0e800275304906 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 9 Jul 2025 15:06:49 +0200 Subject: [PATCH 02/20] Adding ElDewrito as supported Protocol --- README.md | 2 + docs/tests/protocols/index.rst | 43 ++--- docs/tests/protocols/test_eldewrito/index.rst | 7 + .../test_eldewrito/test_get_status.rst | 37 ++++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/eldewrito.py | 178 ++++++++++++++++++ opengsq/responses/eldewrito/__init__.py | 3 + opengsq/responses/eldewrito/status.py | 49 +++++ tests/protocols/test_eldewrito.py | 18 ++ 9 files changed, 317 insertions(+), 21 deletions(-) create mode 100644 docs/tests/protocols/test_eldewrito/index.rst create mode 100644 docs/tests/protocols/test_eldewrito/test_get_status.rst create mode 100644 opengsq/protocols/eldewrito.py create mode 100644 opengsq/responses/eldewrito/__init__.py create mode 100644 opengsq/responses/eldewrito/status.py create mode 100644 tests/protocols/test_eldewrito.py diff --git a/README.md b/README.md index a27fe77..4df0f50 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ from opengsq.protocols import ( ASE, Battlefield, Doom3, + ElDewrito, EOS, FiveM, + Flatout2, GameSpy1, GameSpy2, GameSpy3, diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index d8dc0ef..7addb88 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -4,34 +4,35 @@ Protocols Tests =============== .. toctree:: - test_gamespy4/index - test_teamspeak3/index + test_flatout2/index + test_source/index test_won/index - test_toxikk/index - test_gamespy1/index - test_minecraft/index - test_raknet/index + test_fivem/index + test_gamespy2/index + test_nadeo/index + test_ut3/index + test_eldewrito/index test_eos/index test_renegadex/index + test_quake2/index + test_gamespy3/index test_kaillera/index - test_ase/index - test_quake1/index - test_killingfloor/index - test_source/index - test_samp/index + test_toxikk/index + test_gamespy1/index test_scum/index - test_ut3/index - test_unreal2/index - test_quake3/index - test_warcraft3/index - test_nadeo/index + test_raknet/index + test_killingfloor/index test_battlefield/index - test_fivem/index test_palworld/index - test_quake2/index - test_gamespy2/index - test_flatout2/index test_doom3/index + test_samp/index + test_ase/index + test_teamspeak3/index test_vcmp/index + test_minecraft/index + test_quake3/index + test_warcraft3/index + test_quake1/index + test_unreal2/index + test_gamespy4/index test_satisfactory/index - test_gamespy3/index diff --git a/docs/tests/protocols/test_eldewrito/index.rst b/docs/tests/protocols/test_eldewrito/index.rst new file mode 100644 index 0000000..0998ff8 --- /dev/null +++ b/docs/tests/protocols/test_eldewrito/index.rst @@ -0,0 +1,7 @@ +.. _test_eldewrito: + +test_eldewrito +============== + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_eldewrito/test_get_status.rst b/docs/tests/protocols/test_eldewrito/test_get_status.rst new file mode 100644 index 0000000..34fccf6 --- /dev/null +++ b/docs/tests/protocols/test_eldewrito/test_get_status.rst @@ -0,0 +1,37 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "name": "HaloOnline Server", + "port": 11774, + "file_server_port": 11778, + "host_player": "Floss", + "sprint_state": "2", + "sprint_unlimited_enabled": "0", + "dual_wielding": "1", + "assassination_enabled": "0", + "vote_system_type": 0, + "teams": false, + "map": "Guardian", + "map_file": "guardian", + "variant": "none", + "variant_type": "none", + "status": "InLobby", + "num_players": 0, + "max_players": 16, + "mod_count": 0, + "mod_package_name": "", + "mod_package_author": "", + "mod_package_hash": "", + "mod_package_version": "", + "xnkid": "80d8abbfefbe00428dd0dc3298746e9f", + "xnaddr": "2c6be54815f2cc4391290cd349a5bab0", + "players": [], + "is_dedicated": true, + "game_version": "1.106708_cert_ms23___release", + "eldewrito_version": "0.7.1" + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 91e7083..fd197da 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -1,6 +1,7 @@ from opengsq.protocols.ase import ASE from opengsq.protocols.battlefield import Battlefield from opengsq.protocols.doom3 import Doom3 +from opengsq.protocols.eldewrito import ElDewrito from opengsq.protocols.eos import EOS from opengsq.protocols.fivem import FiveM from opengsq.protocols.flatout2 import Flatout2 diff --git a/opengsq/protocols/eldewrito.py b/opengsq/protocols/eldewrito.py new file mode 100644 index 0000000..ceece07 --- /dev/null +++ b/opengsq/protocols/eldewrito.py @@ -0,0 +1,178 @@ +import asyncio +import aiohttp +import json +from opengsq.protocol_base import ProtocolBase +from opengsq.protocol_socket import UdpClient +from opengsq.responses.eldewrito.status import Status, Player +from opengsq.binary_reader import BinaryReader +import struct +import logging + +class ElDewrito(ProtocolBase): + """ElDewrito Protocol Implementation""" + + ELDEWRITO_BROADCAST_PORT = 11774 + ELDEWRITO_HTTP_PORT = 11775 + + # ElDewrito broadcast query payload + BROADCAST_QUERY = bytes([ + 0x01, 0x62, 0x6c, 0x61, 0x6d, 0x00, 0x00, 0x00, + 0x09, 0x81, 0x00, 0x02, 0x00, 0x01, 0x2d, 0xc3, + 0x04, 0x93, 0xdc, 0x05, 0xd9, 0x95, 0x40 + ]) + + @property + def full_name(self) -> str: + return "ElDewrito Protocol" + + def __init__(self, host: str, port: int = ELDEWRITO_BROADCAST_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + self._allow_broadcast = True + self.logger = logging.getLogger(f"{__name__}.ElDewrito") + + async def get_status(self) -> Status: + """ + Get server status using ElDewrito's two-step discovery process: + 1. Send broadcast query to port 11774 + 2. Get HTTP response from port 11775 + """ + # Step 1: Send broadcast query and wait for response + try: + data = await UdpClient.communicate( + self, + self.BROADCAST_QUERY, + source_port=self.ELDEWRITO_BROADCAST_PORT + ) + + # Step 2: Validate response (must be > 120 bytes from port 11774) + if not self._is_valid_broadcast_response(data): + raise Exception("Invalid broadcast response") + + # Step 3: Query HTTP endpoint for detailed server info + server_info = await self._query_http_endpoint() + + # Step 4: Parse and return status + return self._parse_server_info(server_info) + + except Exception as e: + self.logger.error(f"Error getting ElDewrito server status: {e}") + raise + + def _is_valid_broadcast_response(self, data: bytes) -> bool: + """ + Validate ElDewrito broadcast response. + Response should be > 120 bytes and from port 11774. + """ + return len(data) > 120 + + async def _query_http_endpoint(self) -> dict: + """ + Query the ElDewrito HTTP endpoint on port 11775 for server information. + """ + url = f"http://{self._host}:{self.ELDEWRITO_HTTP_PORT}/" + + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self._timeout)) as session: + async with session.get(url) as response: + if response.status == 200: + return await response.json() + else: + raise Exception(f"HTTP request failed with status {response.status}") + + except Exception as e: + self.logger.error(f"Error querying HTTP endpoint: {e}") + raise + + def _parse_server_info(self, server_info: dict) -> Status: + """ + Parse the JSON response from ElDewrito HTTP endpoint into Status object. + """ + try: + # Parse players list + players = [] + for player_data in server_info.get('players', []): + player = Player( + name=player_data.get('name', ''), + uid=player_data.get('uid', ''), + team=player_data.get('team', 0), + score=player_data.get('score', 0), + kills=player_data.get('kills', 0), + assists=player_data.get('assists', 0), + deaths=player_data.get('deaths', 0), + betrayals=player_data.get('betrayals', 0), + time_spent_alive=player_data.get('timeSpentAlive', 0), + suicides=player_data.get('suicides', 0), + best_streak=player_data.get('bestStreak', 0) + ) + players.append(player) + + # Create Status object + status = Status( + name=server_info.get('name', 'Unknown ElDewrito Server'), + port=server_info.get('port', self.ELDEWRITO_BROADCAST_PORT), + file_server_port=server_info.get('fileServerPort', 11778), + host_player=server_info.get('hostPlayer', ''), + sprint_state=server_info.get('sprintState', '2'), + sprint_unlimited_enabled=server_info.get('sprintUnlimitedEnabled', '0'), + dual_wielding=server_info.get('dualWielding', '1'), + assassination_enabled=server_info.get('assassinationEnabled', '0'), + vote_system_type=server_info.get('voteSystemType', 0), + teams=server_info.get('teams', False), + map=server_info.get('map', 'Unknown Map'), + map_file=server_info.get('mapFile', ''), + variant=server_info.get('variant', 'none'), + variant_type=server_info.get('variantType', 'none'), + status=server_info.get('status', 'Unknown'), + num_players=server_info.get('numPlayers', 0), + max_players=server_info.get('maxPlayers', 16), + mod_count=server_info.get('modCount', 0), + mod_package_name=server_info.get('modPackageName', ''), + mod_package_author=server_info.get('modPackageAuthor', ''), + mod_package_hash=server_info.get('modPackageHash', ''), + mod_package_version=server_info.get('modPackageVersion', ''), + xnkid=server_info.get('xnkid', ''), + xnaddr=server_info.get('xnaddr', ''), + players=players, + is_dedicated=server_info.get('isDedicated', True), + game_version=server_info.get('gameVersion', 'Unknown'), + eldewrito_version=server_info.get('eldewritoVersion', 'Unknown') + ) + + return status + + except Exception as e: + self.logger.error(f"Error parsing server info: {e}") + raise Exception(f"Failed to parse ElDewrito server info: {e}") + + async def discover_servers(self, broadcast_address: str = "255.255.255.255") -> list: + """ + Discover ElDewrito servers using broadcast query. + This method can be used for network discovery. + """ + discovered_servers = [] + + try: + # Create a temporary instance for broadcast + broadcast_client = ElDewrito(broadcast_address, self.ELDEWRITO_BROADCAST_PORT, self._timeout) + + # Send broadcast query - use regular communicate method + data = await UdpClient.communicate( + broadcast_client, + self.BROADCAST_QUERY, + source_port=self.ELDEWRITO_BROADCAST_PORT + ) + + # Process response if valid + if self._is_valid_broadcast_response(data): + try: + # Create client for specific server + server_client = ElDewrito(broadcast_address, self.ELDEWRITO_BROADCAST_PORT, self._timeout) + status = await server_client.get_status() + discovered_servers.append(((broadcast_address, self.ELDEWRITO_BROADCAST_PORT), status)) + except Exception as e: + self.logger.debug(f"Failed to get status from {broadcast_address}:{self.ELDEWRITO_BROADCAST_PORT}: {e}") + + except Exception as e: + self.logger.error(f"Error during server discovery: {e}") + + return discovered_servers \ No newline at end of file diff --git a/opengsq/responses/eldewrito/__init__.py b/opengsq/responses/eldewrito/__init__.py new file mode 100644 index 0000000..ab1d2d9 --- /dev/null +++ b/opengsq/responses/eldewrito/__init__.py @@ -0,0 +1,3 @@ +from .status import Status, Player + +__all__ = ['Status', 'Player'] \ No newline at end of file diff --git a/opengsq/responses/eldewrito/status.py b/opengsq/responses/eldewrito/status.py new file mode 100644 index 0000000..7f78a9e --- /dev/null +++ b/opengsq/responses/eldewrito/status.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass +from typing import List, Dict, Any, Optional + +@dataclass +class Player: + """Represents a player in an ElDewrito server""" + name: str + uid: str = "" + team: int = 0 + score: int = 0 + kills: int = 0 + assists: int = 0 + deaths: int = 0 + betrayals: int = 0 + time_spent_alive: int = 0 + suicides: int = 0 + best_streak: int = 0 + +@dataclass +class Status: + """ElDewrito server status information""" + name: str + port: int + file_server_port: int + host_player: str + sprint_state: str + sprint_unlimited_enabled: str + dual_wielding: str + assassination_enabled: str + vote_system_type: int + teams: bool + map: str + map_file: str + variant: str + variant_type: str + status: str + num_players: int + max_players: int + mod_count: int + mod_package_name: str + mod_package_author: str + mod_package_hash: str + mod_package_version: str + xnkid: str + xnaddr: str + players: List[Player] + is_dedicated: bool + game_version: str + eldewrito_version: str \ No newline at end of file diff --git a/tests/protocols/test_eldewrito.py b/tests/protocols/test_eldewrito.py new file mode 100644 index 0000000..ee966ce --- /dev/null +++ b/tests/protocols/test_eldewrito.py @@ -0,0 +1,18 @@ +import pytest +from opengsq.protocols.eldewrito import ElDewrito + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True +handler.delay_per_test = 1 + +# tf2 +eldewrito = ElDewrito(host="172.29.100.29", port=11774) + + +@pytest.mark.asyncio +async def test_get_info(): + result = await eldewrito.get_status() + await handler.save_result("test_get_status", result) + From 419383c48f5916c4d0b3713d4da5ea8e20956aab Mon Sep 17 00:00:00 2001 From: Stephan Schaffner Date: Fri, 11 Jul 2025 10:52:46 +0200 Subject: [PATCH 03/20] Getting Mapfile Name in Warcraft 3 and extract this as Mapname --- opengsq/protocols/warcraft3.py | 67 +++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/opengsq/protocols/warcraft3.py b/opengsq/protocols/warcraft3.py index 3e6886c..690ae3b 100644 --- a/opengsq/protocols/warcraft3.py +++ b/opengsq/protocols/warcraft3.py @@ -191,8 +191,71 @@ async def get_status(self) -> Status: ) def _get_map_name_from_settings(self, settings_raw: bytearray) -> str: - """Map name parsing is skipped due to encoding complexity""" - return "Map name unavailable" + """ + Extract map name from the encoded settings string. + Based on the Go implementation from gowarcraft3. + """ + try: + # Decode the settings string (every even byte was incremented by 1) + decoded = bytearray() + i = 0 + while i < len(settings_raw): + if i >= len(settings_raw): + break + + # Read control byte + control = settings_raw[i] + i += 1 + + # Process next 7 bytes based on control byte + for j in range(7): + if i >= len(settings_raw): + break + + byte_val = settings_raw[i] + i += 1 + + # Check if this byte was modified (bit j+1 in control byte) + if control & (1 << (j + 1)) == 0: + # Byte was incremented, so decrement it + decoded.append(byte_val - 1) + else: + # Byte was not modified + decoded.append(byte_val) + + if len(decoded) < 16: + return "Map name unavailable" + + # Parse the decoded settings + # Skip: flags (4), unknown (1), width (2), height (2), xoro (4) = 13 bytes + pos = 13 + + # Read map path (null-terminated string) + map_path = "" + while pos < len(decoded) and decoded[pos] != 0: + map_path += chr(decoded[pos]) + pos += 1 + + if not map_path: + return "Map name unavailable" + + # Extract filename from path and remove extension + # Handle both forward and backward slashes + map_path = map_path.replace('\\', '/') + filename = map_path.split('/')[-1] + + # Remove file extension + if '.' in filename: + filename = filename.rsplit('.', 1)[0] + + # Remove player count prefix like "(2)", "(4)", etc. + import re + filename = re.sub(r'^\(\d+\)\s*', '', filename) + + return filename if filename else "Map name unavailable" + + except Exception: + return "Map name unavailable" def _get_game_type(self, flags: GameFlags) -> str: """Convert game flags to a readable game type""" From 9213ed50f14f397f086a3778a87276aec7728a5b Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 16 Jul 2025 15:07:48 +0200 Subject: [PATCH 04/20] Adding Trackmania Nations / Nations Forever Gameclient Protocol to achive Gameserverdata from unauthorized Gameservers --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_tmn/index.rst | 7 + .../protocols/test_tmn/test_get_status.rst | 15 + opengsq/protocols/__init__.py | 1 + opengsq/protocols/tmn.py | 339 ++++++++++++++++++ opengsq/responses/tmn/__init__.py | 3 + opengsq/responses/tmn/status.py | 10 + tests/protocols/test_tmn.py | 17 + 8 files changed, 393 insertions(+) create mode 100644 docs/tests/protocols/test_tmn/index.rst create mode 100644 docs/tests/protocols/test_tmn/test_get_status.rst create mode 100644 opengsq/protocols/tmn.py create mode 100644 opengsq/responses/tmn/__init__.py create mode 100644 opengsq/responses/tmn/status.py create mode 100644 tests/protocols/test_tmn.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 7addb88..a6dfa10 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -24,6 +24,7 @@ Protocols Tests test_killingfloor/index test_battlefield/index test_palworld/index + test_tmn/index test_doom3/index test_samp/index test_ase/index diff --git a/docs/tests/protocols/test_tmn/index.rst b/docs/tests/protocols/test_tmn/index.rst new file mode 100644 index 0000000..5178aba --- /dev/null +++ b/docs/tests/protocols/test_tmn/index.rst @@ -0,0 +1,7 @@ +.. _test_tmn: + +test_tmn +======== + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_tmn/test_get_status.rst b/docs/tests/protocols/test_tmn/test_get_status.rst new file mode 100644 index 0000000..3b46119 --- /dev/null +++ b/docs/tests/protocols/test_tmn/test_get_status.rst @@ -0,0 +1,15 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "name": "Organic", + "map": "C08-Obstacle", + "game_type": "Time Attack", + "num_players": 0, + "max_players": 22, + "password_protected": false + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index fd197da..b44e2aa 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -24,6 +24,7 @@ from opengsq.protocols.scum import Scum from opengsq.protocols.source import Source from opengsq.protocols.teamspeak3 import TeamSpeak3 +from opengsq.protocols.tmn import TMN from opengsq.protocols.toxikk import Toxikk from opengsq.protocols.udk import UDK from opengsq.protocols.unreal2 import Unreal2 diff --git a/opengsq/protocols/tmn.py b/opengsq/protocols/tmn.py new file mode 100644 index 0000000..85bd47e --- /dev/null +++ b/opengsq/protocols/tmn.py @@ -0,0 +1,339 @@ +from opengsq.protocol_base import ProtocolBase +from opengsq.responses.tmn.status import Status +import asyncio + +class TMN(ProtocolBase): + @property + def full_name(self) -> str: + return "Trackmania Nations Forever Protocol" + + TMN_PORT = 2350 + + # TCP packets from documentation + TCP_PACKET_1 = bytes.fromhex("0e000000820399f895580700000008000000") + TCP_PACKET_2 = bytes.fromhex("1200000082033bd464400700000007000000d53d4100") + + def __init__(self, host: str, port: int = TMN_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + + async def get_status(self) -> Status: + """Get server status using direct TCP connection.""" + tcp_data = await self._tcp_info_gathering() + parsed_data = self._parse_tcp_response(tcp_data) + return Status(**parsed_data) + + async def _tcp_info_gathering(self) -> bytes: + """Connect via TCP and gather server information.""" + reader, writer = await asyncio.open_connection(self._host, self._port) + + try: + # Send first TCP packet + writer.write(self.TCP_PACKET_1) + await writer.drain() + + # Wait 200ms between packets + await asyncio.sleep(0.2) + + # Send second TCP packet + writer.write(self.TCP_PACKET_2) + await writer.drain() + + # Read response + response = await reader.read(4096) + return response + + finally: + writer.close() + await writer.wait_closed() + + def _parse_tcp_response(self, payload: bytes) -> dict: + """Parse TCP response to extract server information.""" + # Check if this is a server response (not game client) + if len(payload) < 100: + raise Exception("Response too short - likely from game client, not server") + + # Extract server information + server_name = self._extract_server_name(payload) + max_players = self._extract_max_players(payload) + current_players_count = self._extract_current_players_count(payload) + map_name = self._extract_map_name(payload) + + # Extract game mode + game_mode_id = self._extract_game_mode(payload) + game_mode = self._get_game_mode_name(game_mode_id) + + result = { + 'name': server_name, + 'map': map_name, + 'game_type': game_mode, + 'num_players': current_players_count, + 'max_players': max_players, + 'password_protected': False, # Not available in this protocol + } + + return result + + def _extract_server_name(self, payload: bytes) -> str: + """Extract server name from TCP payload.""" + try: + # Server name comes after #SRV# marker + srv_marker = b'#SRV#' + idx = payload.find(srv_marker) + + if idx == -1: + return "Unknown" + + # Look for the server name after the #SRV# structure + search_start = idx + len(srv_marker) + 10 + + # Find potential server name by looking for readable ASCII sequences + for i in range(search_start, min(len(payload), search_start + 200)): + if payload[i] >= 32 and payload[i] <= 126: # Printable ASCII + name_start = i + name_end = name_start + + # Find end of name (stop at non-printable or null) + while (name_end < len(payload) and + payload[name_end] >= 32 and + payload[name_end] <= 126): + name_end += 1 + + # If we found a reasonable length name (3+ chars) + if name_end - name_start >= 3: + name_bytes = payload[name_start:name_end] + name = name_bytes.decode('utf-8', errors='ignore').strip() + if name and len(name) >= 3: # Valid server name + # Check if the next bytes indicate structure vs continued name + next_bytes = payload[name_end:name_end+4] if name_end+4 <= len(payload) else payload[name_end:] + last_char = name[-1] + + # Check pattern: if next bytes start with specific patterns, + # the last character might be structure data misread as text + needs_correction = False + if len(next_bytes) >= 2: + # Pattern 1: 01 04 (original detection) + if next_bytes[0] == 0x01 and next_bytes[1] == 0x04: + needs_correction = True + # Pattern 2: 01 09 (new pattern seen in "Organich") + elif next_bytes[0] == 0x01 and next_bytes[1] == 0x09: + needs_correction = True + # Pattern 3: Check if last char is 0x68 ('h') and followed by 01 + elif last_char == 'h' and next_bytes[0] == 0x01: + needs_correction = True + + if needs_correction and len(name) > 1: + corrected_name = name[:-1] + return corrected_name + else: + return name + + return "Unknown" + except Exception as e: + return "Unknown" + + def _extract_max_players(self, payload: bytes) -> int: + """Extract maximum player count from TCP payload.""" + try: + srv_marker = b'#SRV#' + idx = payload.find(srv_marker) + + if idx != -1: + # Check position +9 first (seems to be max players for .25 servers) + if len(payload) > idx + 9: + value_9 = payload[idx + 9] + if len(payload) > idx + 10: + value_10 = payload[idx + 10] + + # If +9 is small and +10 is larger, then +9 might be current players and +10 might be max players + if value_9 <= 5 and value_9 > 0 and value_10 > value_9: + return value_10 + + # Original logic for no-players case (when +9 is the max players) + if value_9 >= 5 and value_9 <= 32: # Reasonable max player range + return value_9 + + # Check position +11 (might be max players for .29 servers) + if len(payload) > idx + 11: + value_11 = payload[idx + 11] + if value_11 >= 5 and value_11 <= 32: # Reasonable max player range + return value_11 + + # Fallback to original position + original_value = payload[idx + 10] if len(payload) > idx + 10 else 0 + return original_value + + return 0 + except Exception as e: + return 0 + + def _extract_current_players_count(self, payload: bytes) -> int: + """Extract current player count from TCP payload (position +10 after #SRV#).""" + try: + srv_marker = b'#SRV#' + idx = payload.find(srv_marker) + + if idx != -1: + # Logic: If we see pattern like +9=2, +10=5, then +9 is likely current players + if len(payload) > idx + 10: + value_9 = payload[idx + 9] if len(payload) > idx + 9 else 0 + value_10 = payload[idx + 10] + + # If +9 is a small number (like 2) and +10 is larger (like 5), + # then +9 is likely the current player count + if value_9 > 0 and value_9 <= 5 and value_10 > value_9: + return value_9 + else: + return value_10 + + # Fallback to original position + if len(payload) > idx + 10: + current_count = payload[idx + 10] + return current_count + + return 0 + except Exception as e: + return 0 + + def _extract_map_name(self, payload: bytes) -> str: + """Extract map name from TCP payload.""" + try: + # Method 1: Look for common map patterns like A01-Race, A02-Race, etc. + import re + + # Convert hex to ASCII and look for map patterns + try: + ascii_parts = [] + for i in range(0, len(payload), 1): + if payload[i] >= 0x20 and payload[i] <= 0x7E: # Printable ASCII + ascii_parts.append(chr(payload[i])) + else: + ascii_parts.append(' ') + + ascii_string = ''.join(ascii_parts) + + # Look for patterns like A01-Race, A02-Race, etc. + map_patterns = [ + r'A\d{2}-Race', + r'B\d{2}-Race', + r'C\d{2}-[A-Za-z]+', + r'A\d{2}-[A-Za-z]+', + r'[A-Z]\d{2}-[A-Za-z]+', + ] + + for pattern in map_patterns: + matches = re.findall(pattern, ascii_string) + if matches: + map_name = matches[0] + + # Apply same 'h' correction as for server names + # Check if map name ends with 'h' that might be a structure byte (0x68) + if map_name.endswith('h') and len(map_name) > 5: + # For map names like "C04-Raceh" -> "C04-Race" + corrected_name = map_name[:-1] + # Validate that the corrected name still makes sense + if corrected_name.endswith('Race') or corrected_name.endswith('Speed') or corrected_name.endswith('Rally'): + return corrected_name + + return map_name + + except Exception as e: + pass + + # Method 2: Direct byte search for known map names + map_candidates = [b'A01-Race', b'A02-Race', b'A03-Race', b'A04-Race', b'A05-Race', + b'B01-Race', b'B02-Race', b'B03-Race', b'B04-Race', b'B05-Race', + b'C01-Race', b'C02-Race', b'C03-Race', b'C04-Race', b'C05-Race'] + + for candidate in map_candidates: + pos = payload.find(candidate) + if pos != -1: + map_name = candidate.decode('utf-8') + return map_name + + # Method 3: Look for any reasonable ASCII string that might be a map name + i = 0 + while i < len(payload) - 5: + if (payload[i] in [ord('A'), ord('B'), ord('C')] and + payload[i+1] >= ord('0') and payload[i+1] <= ord('9') and + payload[i+2] >= ord('0') and payload[i+2] <= ord('9') and + payload[i+3] == ord('-')): + + # Found potential map name start + name_start = i + name_end = i + 4 # Start after "AXX-" + + # Read until non-ASCII or reasonable end + while (name_end < len(payload) and + payload[name_end] >= 0x20 and payload[name_end] <= 0x7E and + name_end - name_start < 20): # Max 20 chars + name_end += 1 + + if name_end - name_start >= 5: # At least "A01-X" + potential_map = payload[name_start:name_end].decode('utf-8', errors='ignore') + # Basic validation + if '-' in potential_map and len(potential_map) >= 5: + return potential_map + + i += 1 + + return "Unknown" + + except Exception as e: + return "Unknown" + + def _extract_game_mode(self, payload: bytes) -> int: + """Extract game mode ID from TCP payload.""" + try: + # Method 1: Search for the standard pattern with FF FF FF FF marker + marker = b'\xff\xff\xff\xff' + idx = payload.find(marker) + + if idx != -1 and len(payload) > idx + 7: + # Check the pattern type and extract game mode at offset +7 + pattern_type = payload[idx + 4] if len(payload) > idx + 4 else 0 + game_mode_id = payload[idx + 7] + return game_mode_id + + # Method 2: Alternative patterns for .25 servers that don't use FF FF FF FF + # Look for "Stadium" and check the pattern after it + stadium_pos = payload.find(b'Stadium') + if stadium_pos != -1: + # Look for patterns after Stadium + pattern_start = stadium_pos + len(b'Stadium') + if len(payload) > pattern_start + 10: + # Check for specific byte patterns that indicate game modes + # Pattern analysis from debug data: + pattern_area = payload[pattern_start:pattern_start + 10] + + # Check specific positions for game mode indicators + if len(pattern_area) >= 7: + key_byte_1 = pattern_area[5] if len(pattern_area) > 5 else 0 + key_byte_2 = pattern_area[6] if len(pattern_area) > 6 else 0 + + # Known patterns for .25 servers: + if key_byte_1 == 0x01 and key_byte_2 == 0x20: # 01 20 + return 0 # Time Attack + elif key_byte_1 == 0x03 and key_byte_2 == 0x1e: # 03 1e + return 3 # Tournament + elif key_byte_1 == 0x06 and key_byte_2 == 0x32: # 06 32 + return 6 # Team + elif key_byte_1 == 0x07 and key_byte_2 == 0x03: # 07 03 + return 7 # Rounds + elif key_byte_1 == 0x09: # 09 XX + return 9 # Cup + + # Fallback: return 0 (Time Attack) as default + return 0 + except Exception as e: + return 0 + + def _get_game_mode_name(self, mode_id: int) -> str: + """Convert game mode ID to human readable name.""" + mode_names = { + 0: "Time Attack", + 3: "Tournament", + 6: "Team", + 7: "Rounds", + 9: "Cup" + } + return mode_names.get(mode_id, "Unknown") \ No newline at end of file diff --git a/opengsq/responses/tmn/__init__.py b/opengsq/responses/tmn/__init__.py new file mode 100644 index 0000000..b5071e2 --- /dev/null +++ b/opengsq/responses/tmn/__init__.py @@ -0,0 +1,3 @@ +from .status import Status + +__all__ = ['Status'] \ No newline at end of file diff --git a/opengsq/responses/tmn/status.py b/opengsq/responses/tmn/status.py new file mode 100644 index 0000000..751263d --- /dev/null +++ b/opengsq/responses/tmn/status.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +@dataclass +class Status: + name: str + map: str + game_type: str + num_players: int + max_players: int + password_protected: bool \ No newline at end of file diff --git a/tests/protocols/test_tmn.py b/tests/protocols/test_tmn.py new file mode 100644 index 0000000..e5987b8 --- /dev/null +++ b/tests/protocols/test_tmn.py @@ -0,0 +1,17 @@ +import pytest +from opengsq.protocols.tmn import TMN + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True +handler.delay_per_test = 1 + +# tmn +tmn = TMN(host="172.29.100.25") + + +@pytest.mark.asyncio +async def test_get_info(): + result = await tmn.get_status() + await handler.save_result("test_get_status", result) \ No newline at end of file From f86ed3867a378e0795347200d2b5ff82d3e5fd69 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Tue, 22 Jul 2025 13:15:59 +0200 Subject: [PATCH 05/20] Recreating Trackmania Nations Forever since Bugs for fetching were there --- opengsq/protocols/__init__.py | 2 +- opengsq/protocols/tmn.py | 339 -------------- opengsq/protocols/trackmania_nations.py | 431 ++++++++++++++++++ opengsq/responses/tmn/__init__.py | 3 - opengsq/responses/tmn/status.py | 10 - .../responses/trackmania_nations/__init__.py | 3 + .../trackmania_nations/server_info.py | 119 +++++ 7 files changed, 554 insertions(+), 353 deletions(-) delete mode 100644 opengsq/protocols/tmn.py create mode 100644 opengsq/protocols/trackmania_nations.py delete mode 100644 opengsq/responses/tmn/__init__.py delete mode 100644 opengsq/responses/tmn/status.py create mode 100644 opengsq/responses/trackmania_nations/__init__.py create mode 100644 opengsq/responses/trackmania_nations/server_info.py diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index b44e2aa..d47d5c0 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -24,7 +24,7 @@ from opengsq.protocols.scum import Scum from opengsq.protocols.source import Source from opengsq.protocols.teamspeak3 import TeamSpeak3 -from opengsq.protocols.tmn import TMN +from opengsq.protocols.trackmania_nations import TrackmaniaNations from opengsq.protocols.toxikk import Toxikk from opengsq.protocols.udk import UDK from opengsq.protocols.unreal2 import Unreal2 diff --git a/opengsq/protocols/tmn.py b/opengsq/protocols/tmn.py deleted file mode 100644 index 85bd47e..0000000 --- a/opengsq/protocols/tmn.py +++ /dev/null @@ -1,339 +0,0 @@ -from opengsq.protocol_base import ProtocolBase -from opengsq.responses.tmn.status import Status -import asyncio - -class TMN(ProtocolBase): - @property - def full_name(self) -> str: - return "Trackmania Nations Forever Protocol" - - TMN_PORT = 2350 - - # TCP packets from documentation - TCP_PACKET_1 = bytes.fromhex("0e000000820399f895580700000008000000") - TCP_PACKET_2 = bytes.fromhex("1200000082033bd464400700000007000000d53d4100") - - def __init__(self, host: str, port: int = TMN_PORT, timeout: float = 5.0): - super().__init__(host, port, timeout) - - async def get_status(self) -> Status: - """Get server status using direct TCP connection.""" - tcp_data = await self._tcp_info_gathering() - parsed_data = self._parse_tcp_response(tcp_data) - return Status(**parsed_data) - - async def _tcp_info_gathering(self) -> bytes: - """Connect via TCP and gather server information.""" - reader, writer = await asyncio.open_connection(self._host, self._port) - - try: - # Send first TCP packet - writer.write(self.TCP_PACKET_1) - await writer.drain() - - # Wait 200ms between packets - await asyncio.sleep(0.2) - - # Send second TCP packet - writer.write(self.TCP_PACKET_2) - await writer.drain() - - # Read response - response = await reader.read(4096) - return response - - finally: - writer.close() - await writer.wait_closed() - - def _parse_tcp_response(self, payload: bytes) -> dict: - """Parse TCP response to extract server information.""" - # Check if this is a server response (not game client) - if len(payload) < 100: - raise Exception("Response too short - likely from game client, not server") - - # Extract server information - server_name = self._extract_server_name(payload) - max_players = self._extract_max_players(payload) - current_players_count = self._extract_current_players_count(payload) - map_name = self._extract_map_name(payload) - - # Extract game mode - game_mode_id = self._extract_game_mode(payload) - game_mode = self._get_game_mode_name(game_mode_id) - - result = { - 'name': server_name, - 'map': map_name, - 'game_type': game_mode, - 'num_players': current_players_count, - 'max_players': max_players, - 'password_protected': False, # Not available in this protocol - } - - return result - - def _extract_server_name(self, payload: bytes) -> str: - """Extract server name from TCP payload.""" - try: - # Server name comes after #SRV# marker - srv_marker = b'#SRV#' - idx = payload.find(srv_marker) - - if idx == -1: - return "Unknown" - - # Look for the server name after the #SRV# structure - search_start = idx + len(srv_marker) + 10 - - # Find potential server name by looking for readable ASCII sequences - for i in range(search_start, min(len(payload), search_start + 200)): - if payload[i] >= 32 and payload[i] <= 126: # Printable ASCII - name_start = i - name_end = name_start - - # Find end of name (stop at non-printable or null) - while (name_end < len(payload) and - payload[name_end] >= 32 and - payload[name_end] <= 126): - name_end += 1 - - # If we found a reasonable length name (3+ chars) - if name_end - name_start >= 3: - name_bytes = payload[name_start:name_end] - name = name_bytes.decode('utf-8', errors='ignore').strip() - if name and len(name) >= 3: # Valid server name - # Check if the next bytes indicate structure vs continued name - next_bytes = payload[name_end:name_end+4] if name_end+4 <= len(payload) else payload[name_end:] - last_char = name[-1] - - # Check pattern: if next bytes start with specific patterns, - # the last character might be structure data misread as text - needs_correction = False - if len(next_bytes) >= 2: - # Pattern 1: 01 04 (original detection) - if next_bytes[0] == 0x01 and next_bytes[1] == 0x04: - needs_correction = True - # Pattern 2: 01 09 (new pattern seen in "Organich") - elif next_bytes[0] == 0x01 and next_bytes[1] == 0x09: - needs_correction = True - # Pattern 3: Check if last char is 0x68 ('h') and followed by 01 - elif last_char == 'h' and next_bytes[0] == 0x01: - needs_correction = True - - if needs_correction and len(name) > 1: - corrected_name = name[:-1] - return corrected_name - else: - return name - - return "Unknown" - except Exception as e: - return "Unknown" - - def _extract_max_players(self, payload: bytes) -> int: - """Extract maximum player count from TCP payload.""" - try: - srv_marker = b'#SRV#' - idx = payload.find(srv_marker) - - if idx != -1: - # Check position +9 first (seems to be max players for .25 servers) - if len(payload) > idx + 9: - value_9 = payload[idx + 9] - if len(payload) > idx + 10: - value_10 = payload[idx + 10] - - # If +9 is small and +10 is larger, then +9 might be current players and +10 might be max players - if value_9 <= 5 and value_9 > 0 and value_10 > value_9: - return value_10 - - # Original logic for no-players case (when +9 is the max players) - if value_9 >= 5 and value_9 <= 32: # Reasonable max player range - return value_9 - - # Check position +11 (might be max players for .29 servers) - if len(payload) > idx + 11: - value_11 = payload[idx + 11] - if value_11 >= 5 and value_11 <= 32: # Reasonable max player range - return value_11 - - # Fallback to original position - original_value = payload[idx + 10] if len(payload) > idx + 10 else 0 - return original_value - - return 0 - except Exception as e: - return 0 - - def _extract_current_players_count(self, payload: bytes) -> int: - """Extract current player count from TCP payload (position +10 after #SRV#).""" - try: - srv_marker = b'#SRV#' - idx = payload.find(srv_marker) - - if idx != -1: - # Logic: If we see pattern like +9=2, +10=5, then +9 is likely current players - if len(payload) > idx + 10: - value_9 = payload[idx + 9] if len(payload) > idx + 9 else 0 - value_10 = payload[idx + 10] - - # If +9 is a small number (like 2) and +10 is larger (like 5), - # then +9 is likely the current player count - if value_9 > 0 and value_9 <= 5 and value_10 > value_9: - return value_9 - else: - return value_10 - - # Fallback to original position - if len(payload) > idx + 10: - current_count = payload[idx + 10] - return current_count - - return 0 - except Exception as e: - return 0 - - def _extract_map_name(self, payload: bytes) -> str: - """Extract map name from TCP payload.""" - try: - # Method 1: Look for common map patterns like A01-Race, A02-Race, etc. - import re - - # Convert hex to ASCII and look for map patterns - try: - ascii_parts = [] - for i in range(0, len(payload), 1): - if payload[i] >= 0x20 and payload[i] <= 0x7E: # Printable ASCII - ascii_parts.append(chr(payload[i])) - else: - ascii_parts.append(' ') - - ascii_string = ''.join(ascii_parts) - - # Look for patterns like A01-Race, A02-Race, etc. - map_patterns = [ - r'A\d{2}-Race', - r'B\d{2}-Race', - r'C\d{2}-[A-Za-z]+', - r'A\d{2}-[A-Za-z]+', - r'[A-Z]\d{2}-[A-Za-z]+', - ] - - for pattern in map_patterns: - matches = re.findall(pattern, ascii_string) - if matches: - map_name = matches[0] - - # Apply same 'h' correction as for server names - # Check if map name ends with 'h' that might be a structure byte (0x68) - if map_name.endswith('h') and len(map_name) > 5: - # For map names like "C04-Raceh" -> "C04-Race" - corrected_name = map_name[:-1] - # Validate that the corrected name still makes sense - if corrected_name.endswith('Race') or corrected_name.endswith('Speed') or corrected_name.endswith('Rally'): - return corrected_name - - return map_name - - except Exception as e: - pass - - # Method 2: Direct byte search for known map names - map_candidates = [b'A01-Race', b'A02-Race', b'A03-Race', b'A04-Race', b'A05-Race', - b'B01-Race', b'B02-Race', b'B03-Race', b'B04-Race', b'B05-Race', - b'C01-Race', b'C02-Race', b'C03-Race', b'C04-Race', b'C05-Race'] - - for candidate in map_candidates: - pos = payload.find(candidate) - if pos != -1: - map_name = candidate.decode('utf-8') - return map_name - - # Method 3: Look for any reasonable ASCII string that might be a map name - i = 0 - while i < len(payload) - 5: - if (payload[i] in [ord('A'), ord('B'), ord('C')] and - payload[i+1] >= ord('0') and payload[i+1] <= ord('9') and - payload[i+2] >= ord('0') and payload[i+2] <= ord('9') and - payload[i+3] == ord('-')): - - # Found potential map name start - name_start = i - name_end = i + 4 # Start after "AXX-" - - # Read until non-ASCII or reasonable end - while (name_end < len(payload) and - payload[name_end] >= 0x20 and payload[name_end] <= 0x7E and - name_end - name_start < 20): # Max 20 chars - name_end += 1 - - if name_end - name_start >= 5: # At least "A01-X" - potential_map = payload[name_start:name_end].decode('utf-8', errors='ignore') - # Basic validation - if '-' in potential_map and len(potential_map) >= 5: - return potential_map - - i += 1 - - return "Unknown" - - except Exception as e: - return "Unknown" - - def _extract_game_mode(self, payload: bytes) -> int: - """Extract game mode ID from TCP payload.""" - try: - # Method 1: Search for the standard pattern with FF FF FF FF marker - marker = b'\xff\xff\xff\xff' - idx = payload.find(marker) - - if idx != -1 and len(payload) > idx + 7: - # Check the pattern type and extract game mode at offset +7 - pattern_type = payload[idx + 4] if len(payload) > idx + 4 else 0 - game_mode_id = payload[idx + 7] - return game_mode_id - - # Method 2: Alternative patterns for .25 servers that don't use FF FF FF FF - # Look for "Stadium" and check the pattern after it - stadium_pos = payload.find(b'Stadium') - if stadium_pos != -1: - # Look for patterns after Stadium - pattern_start = stadium_pos + len(b'Stadium') - if len(payload) > pattern_start + 10: - # Check for specific byte patterns that indicate game modes - # Pattern analysis from debug data: - pattern_area = payload[pattern_start:pattern_start + 10] - - # Check specific positions for game mode indicators - if len(pattern_area) >= 7: - key_byte_1 = pattern_area[5] if len(pattern_area) > 5 else 0 - key_byte_2 = pattern_area[6] if len(pattern_area) > 6 else 0 - - # Known patterns for .25 servers: - if key_byte_1 == 0x01 and key_byte_2 == 0x20: # 01 20 - return 0 # Time Attack - elif key_byte_1 == 0x03 and key_byte_2 == 0x1e: # 03 1e - return 3 # Tournament - elif key_byte_1 == 0x06 and key_byte_2 == 0x32: # 06 32 - return 6 # Team - elif key_byte_1 == 0x07 and key_byte_2 == 0x03: # 07 03 - return 7 # Rounds - elif key_byte_1 == 0x09: # 09 XX - return 9 # Cup - - # Fallback: return 0 (Time Attack) as default - return 0 - except Exception as e: - return 0 - - def _get_game_mode_name(self, mode_id: int) -> str: - """Convert game mode ID to human readable name.""" - mode_names = { - 0: "Time Attack", - 3: "Tournament", - 6: "Team", - 7: "Rounds", - 9: "Cup" - } - return mode_names.get(mode_id, "Unknown") \ No newline at end of file diff --git a/opengsq/protocols/trackmania_nations.py b/opengsq/protocols/trackmania_nations.py new file mode 100644 index 0000000..ede2a3a --- /dev/null +++ b/opengsq/protocols/trackmania_nations.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass, field +from typing import List, Optional, Union +from opengsq.protocol_base import ProtocolBase +from opengsq.exceptions import InvalidPacketException +from opengsq.responses.trackmania_nations import ServerInfo + + +@dataclass +class SrvInfo: + """Repräsentiert die wichtigsten Informationen eines #SRV#-Pakets.""" + variant: str + server_name: Optional[str] + environment: Optional[str] + comment: Optional[str] + maps: List[str] = field(default_factory=list) + max_players: Optional[int] = None + current_players: Optional[int] = None + host_id: Optional[str] = None + raw_strings: List[str] = field(default_factory=list, repr=False) + + +class TrackmaniaNations(ProtocolBase): + """ + Trackmania Nations Protocol Implementation + Basiert auf Reverse-Engineering der #SRV# Server-Announcement-Payloads + """ + + @property + def full_name(self) -> str: + return "Trackmania Nations Protocol" + + # Standard Trackmania Nations port + DEFAULT_PORT = 2350 + + # TCP packets as specified + _PACKET_1 = bytes.fromhex("0e000000820399f895580700000008000000") + _PACKET_2 = bytes.fromhex("1200000082033bd464400700000007000000d53d4100") + + def __init__(self, host: str, port: int = DEFAULT_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + + async def get_info(self) -> ServerInfo: + """ + Retrieves server information by sending the two TCP packets in sequence. + + :return: A ServerInfo object containing server information + :raises InvalidPacketException: If the response doesn't contain #SRV# marker + """ + # Connect via TCP + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self._host, self._port), + timeout=self._timeout + ) + except (OSError, asyncio.TimeoutError) as e: + raise InvalidPacketException(f"Failed to connect to {self._host}:{self._port}: {e}") + + try: + # Send first packet + writer.write(self._PACKET_1) + await writer.drain() + + # Wait 200ms as specified + await asyncio.sleep(0.2) + + # Send second packet + writer.write(self._PACKET_2) + await writer.drain() + + # Read response + response_data = await asyncio.wait_for( + reader.read(4096), + timeout=self._timeout + ) + + # Validate response contains #SRV# marker + if b'#SRV#' not in response_data: + raise InvalidPacketException(f"Response does not contain #SRV# marker. Got {len(response_data)} bytes.") + + # Parse using the new serializer + srv_info = self.parse_srv_payload(response_data) + + # Convert to ServerInfo format + return ServerInfo( + name=srv_info.server_name or "Unknown", + map=srv_info.maps[0] if srv_info.maps else "Unknown", + players=srv_info.current_players or 0, + max_players=srv_info.max_players or 0, + game_mode="Unknown", + password_protected=srv_info.variant in ['p', 'x'], + version=None, + environment=srv_info.environment, + comment=srv_info.comment, + server_login="", + pc_guid=srv_info.host_id, + time_limit=0, + nb_laps=0, + spectator_slots=0, + build_number=0, + private_server=srv_info.variant in ['p', 'x'], + ladder_server=srv_info.variant in ['s', 'x'], + status_flags=0, + challenge_crc=0, + public_ip="", + local_ip="", + raw_data=response_data.hex() + ) + + except asyncio.TimeoutError: + raise InvalidPacketException("Timeout while waiting for server response") + finally: + writer.close() + await writer.wait_closed() + + def parse_srv_payload(self, payload: Union[str, bytes, bytearray]) -> SrvInfo: + """Zerlegt ein Trackmania-Server-Paket (#SRV#) und liefert ein SrvInfo-Objekt. + + Argumente + --------- + payload + Hex-String (ohne Leerzeichen) *oder* Roh-Bytes. + """ + # ASCII regex für längere Sequenzen + _ASCII_RE = re.compile(rb"[ -~]{3,}") + _MAP_RE = re.compile(r"^[A-Z]\d{2}-") + + # 1) Eingabe normieren + if isinstance(payload, str): + data = bytes.fromhex(payload.strip()) + else: + data = bytes(payload) + + # 2) Header suchen (#SRV#X) + header_idx = data.find(b"#SRV#") + if header_idx == -1 or header_idx + 5 >= len(data): + raise ValueError("Kein #SRV#-Header im Payload gefunden") + + variant_byte = data[header_idx + 5: header_idx + 6] + variant = variant_byte.decode("ascii", errors="ignore") or "?" + + # 3) ASCII-Sequenzen extrahieren (sowohl Regex als auch Length-Prefixed) + ascii_strings = [m.decode("ascii") for m in _ASCII_RE.findall(data)] + + # ERWEITERT: Extrahiere auch Length-Prefixed Strings für bessere Genauigkeit + length_prefixed_strings = self._extract_length_prefixed_strings(data, header_idx) + + # 4) Korrigierte Zuordnung der Strings basierend auf MCP-Analyse + # Suche nach #SRV# Header (mit oder ohne Variant) + header_str_index = -1 + for i, s in enumerate(ascii_strings): + if s.startswith("#SRV#"): + header_str_index = i + break + + # KORREKTUR basierend auf MCP-Analyse: + # Position 0: Host-ID (z.B. "dgn-deb", "PC-ce9b0c") + # Position 1: "#SRV#" oder "#SRV#P" + # Position 2: Echter Servername (z.B. "Organich", "BananeBongo") + # Position 3: Environment (z.B. "Stadium", "Stadiumt") + # Position 4: Comment/weitere Infos + + if header_str_index >= 0: + # Host-ID ist der String VOR #SRV# (Position header_str_index - 1) + host_id = ascii_strings[header_str_index - 1] if header_str_index >= 1 else None + + # VERBESSERT: Verwende Length-Prefixed Strings für dynamische Erkennung + if length_prefixed_strings: + # Vollständig dynamische Identifikation basierend nur auf Struktur + server_name = None + environment = None + + # SCHRITT 1: Erkenne Environment zuerst + for string in length_prefixed_strings: + if self._is_environment_name(string.lower()) and not environment: + if string.lower().startswith('stad'): + environment = "Stadium" + elif string.lower() == 'island': + environment = "Island" + elif string.lower() == 'bay': + environment = "Bay" + elif string.lower() == 'coast': + environment = "Coast" + else: + environment = string + break + + # SCHRITT 2: Erkenne Server-Name (nicht Environment, nicht Map) + for string in length_prefixed_strings: + if (not self._is_environment_name(string.lower()) and + not re.match(r'^[A-Z]\d{2,3}-', string) and + len(string) >= 3): + if not server_name or len(string) > len(server_name): + server_name = string + + # Fallback auf ASCII-Parsing für Comment + comment = ascii_strings[header_str_index + 3] if header_str_index + 3 < len(ascii_strings) else None + + # ZUSÄTZLICHER FALLBACK: Wenn kein Server-Name in Length-Prefixed Strings gefunden wurde, + # prüfe die ASCII-Strings mit strukturbasierten Filtern + if not server_name: + for string in ascii_strings: + if (self._is_potential_server_name(string) and + not self._is_environment_name(string.lower())): + server_name = string + break + + # ERWEITETER FALLBACK: Wenn der Length-Prefixed Name zu kurz ist (< 5 Zeichen), + # schaue ob es einen längeren Namen in den ASCII-Strings gibt + if server_name and len(server_name) < 5: + for string in ascii_strings: + if (self._is_potential_server_name(string) and + not self._is_environment_name(string.lower()) and + len(string) > len(server_name)): + server_name = string + break + else: + # Fallback auf ASCII-String-Methode mit strukturbasierten Filtern + server_name = None + environment = None + comment = None + + # Finde Server-Name und Environment in ASCII-Strings + for i, string in enumerate(ascii_strings): + if i <= header_str_index: # Überspringe Strings vor/bei #SRV# + continue + + if self._is_environment_name(string.lower()) and not environment: + environment = string + elif self._is_potential_server_name(string) and not server_name: + server_name = string + elif not comment and len(string) >= 4: + comment = string + + # Stoppe wenn alles gefunden + if server_name and environment and comment: + break + else: + # Fallback für unbekannte Struktur + server_name = "Unknown" + host_id = None + environment = None + comment = None + maps = [s for s in ascii_strings if _MAP_RE.match(s)] + + # 5) Spielerzahlen abschätzen + max_players, current_players = self._guess_player_counts(data, header_idx) + + return SrvInfo( + variant=variant, + server_name=server_name, + environment=environment, + comment=comment, + maps=maps, + max_players=max_players, + current_players=current_players, + host_id=host_id, + raw_strings=ascii_strings, + ) + + def _guess_player_counts(self, data: bytes, header_pos: int) -> tuple[Optional[int], Optional[int]]: + """Versucht max und current players aus den 64 Bytes vor header_pos zu lesen.""" + ints: List[int] = [ + int.from_bytes(data[o:o + 4], "little") + for o in range(max(0, header_pos - 64), header_pos, 4) + ] + plausible = [x for x in ints if 0 < x <= 255] + # Im beobachteten Paket fanden sich max- und current-Spieler als erste beiden kleinen Werte. + if len(plausible) >= 2: + return plausible[0], plausible[1] + if len(plausible) == 1: + return plausible[0], plausible[0] + return None, None + + def _extract_length_prefixed_strings(self, data: bytes, start_pos: int) -> List[str]: + """Vollständig dynamische Extraktion von Length-Prefixed Strings basierend auf Payload-Struktur. + + Analysiert die Bytes nach #SRV# und findet alle Length-Prefixed Strings ohne hardcodierte Namen. + """ + strings = [] + + # Erweiterte Suche nach #SRV# + search_start = start_pos + 6 + search_end = min(start_pos + 80, len(data)) + + # Sammle alle möglichen String-Kandidaten + candidates = [] + + for pos in range(search_start, search_end - 3): + byte = data[pos] + + # Prüfe ob das ein plausibles Length-Byte ist (1-20 Zeichen) + if 1 <= byte <= 20: + # Teste verschiedene Offsets nach dem Length-Byte + for offset in range(1, 5): + string_start = pos + offset + string_end = string_start + byte + + if string_end <= len(data): + string_bytes = data[string_start:string_end] + + # Prüfe ob alle Bytes druckbare ASCII-Zeichen sind + if all(32 <= b <= 126 for b in string_bytes): + try: + string = string_bytes.decode('ascii') + + # Strukturbasierte Validierung (ohne Namen-spezifische Regeln) + if self._is_structurally_valid_string(string): + quality_score = self._calculate_structural_quality(string, pos, offset, byte) + candidates.append({ + 'string': string, + 'score': quality_score, + 'pos': pos, + 'offset': offset, + 'length': byte, + 'start': string_start, + 'end': string_end + }) + except UnicodeDecodeError: + pass + + # Sortiere nach Qualität und entferne Überlappungen + candidates.sort(key=lambda x: x['score'], reverse=True) + + selected = [] + used_ranges = [] + + for candidate in candidates: + # Prüfe auf Überlappung + overlaps = any( + not (candidate['end'] <= used_start or candidate['start'] >= used_end) + for used_start, used_end in used_ranges + ) + + if not overlaps and candidate['string'] not in selected: + selected.append(candidate['string']) + used_ranges.append((candidate['start'], candidate['end'])) + + if len(selected) >= 4: # Maximal 4 Strings + break + + return selected + + def _is_structurally_valid_string(self, string: str) -> bool: + """Strukturbasierte Validierung ohne hardcodierte Namen.""" + # Mindestlänge + if len(string) < 3: + return False + + # Muss hauptsächlich alphabetisch sein + alpha_count = sum(1 for c in string if c.isalpha()) + if alpha_count < 2: + return False + + # Nicht nur Sonderzeichen + special_count = sum(1 for c in string if not c.isalnum()) + if special_count > len(string) // 2: + return False + + # Keine reinen Zahlen + if string.isdigit(): + return False + + return True + + def _calculate_structural_quality(self, string: str, pos: int, offset: int, expected_length: int) -> float: + """Berechnet Qualität basierend auf Struktur, nicht auf Namen.""" + score = 0.0 + + # Length-Byte muss exakt stimmen + if len(string) != expected_length: + return 0.0 + + # Längere Strings sind oft wichtiger (Server-Namen) + score += len(string) * 0.2 + + # Alphabetische Zeichen sind gut + alpha_ratio = sum(1 for c in string if c.isalpha()) / len(string) + score += alpha_ratio * 3.0 + + # Nur bekannte Environment-Namen (fest im Spiel) + if string.lower() in ['stadium', 'island', 'bay', 'coast']: + score += 2.0 + elif string.lower().startswith('stad'): # Stadium-Varianten + score += 1.5 + + # Kleinere Offsets sind wahrscheinlicher + score -= offset * 0.1 + + # Erste Strings im Payload sind oft wichtiger + score -= pos * 0.001 + + return score + + def _is_environment_name(self, string_lower: str) -> bool: + """Prüft ob ein String ein bekanntes TrackMania Environment ist (fest im Spiel).""" + return (string_lower in ['stadium', 'island', 'bay', 'coast'] or + string_lower.startswith('stad')) + + def _is_potential_server_name(self, string: str) -> bool: + """Strukturbasierte Prüfung ob ein String ein potentieller Server-Name ist.""" + if len(string) < 3: + return False + + # Filtere technische Strings + if (string.startswith('#') or + string.startswith('PC-') or + re.match(r'^[A-Z]\d{2,3}-', string)): # Map-Namen + return False + + # Filtere Host-IDs (lange Strings mit Bindestrichen) + if '-' in string and len(string) > 8: + return False + + # Muss hauptsächlich alphabetische Zeichen haben + alpha_count = sum(1 for c in string if c.isalpha()) + if alpha_count < 3: + return False + + # Nicht nur Sonderzeichen + special_count = sum(1 for c in string if not c.isalnum()) + if special_count > len(string) // 2: + return False + + return True + + + diff --git a/opengsq/responses/tmn/__init__.py b/opengsq/responses/tmn/__init__.py deleted file mode 100644 index b5071e2..0000000 --- a/opengsq/responses/tmn/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .status import Status - -__all__ = ['Status'] \ No newline at end of file diff --git a/opengsq/responses/tmn/status.py b/opengsq/responses/tmn/status.py deleted file mode 100644 index 751263d..0000000 --- a/opengsq/responses/tmn/status.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass - -@dataclass -class Status: - name: str - map: str - game_type: str - num_players: int - max_players: int - password_protected: bool \ No newline at end of file diff --git a/opengsq/responses/trackmania_nations/__init__.py b/opengsq/responses/trackmania_nations/__init__.py new file mode 100644 index 0000000..d703815 --- /dev/null +++ b/opengsq/responses/trackmania_nations/__init__.py @@ -0,0 +1,3 @@ +from .server_info import ServerInfo + +__all__ = ['ServerInfo'] \ No newline at end of file diff --git a/opengsq/responses/trackmania_nations/server_info.py b/opengsq/responses/trackmania_nations/server_info.py new file mode 100644 index 0000000..dec66ec --- /dev/null +++ b/opengsq/responses/trackmania_nations/server_info.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ServerInfo: + """ + Trackmania Nations Server Information + Erweitert basierend auf Reverse-Engineering der #SRV# Server-Announcement-Payloads + """ + + name: str + """Name of the server.""" + + map: str + """Current map being played.""" + + players: int + """Current number of players on the server.""" + + max_players: int + """Maximum number of players the server can hold.""" + + game_mode: str + """Current game mode (e.g., Time Attack, Rounds, Cup, etc.).""" + + password_protected: bool = False + """Whether the server requires a password.""" + + version: Optional[str] = None + """Server/game version.""" + + # Erweiterte Felder aus der Dokumentation + environment: str = "Unknown" + """Map environment (Stadium, Canyon, Valley, etc.).""" + + comment: str = "" + """Server comment/description.""" + + server_login: str = "" + """Server login name.""" + + pc_guid: str = "" + """PC GUID identifier.""" + + time_limit: int = 0 + """Time limit in milliseconds.""" + + nb_laps: int = 0 + """Number of laps for lap-based modes.""" + + spectator_slots: int = 0 + """Maximum number of spectator slots.""" + + build_number: int = 0 + """Game build number.""" + + private_server: bool = False + """Whether the server is private.""" + + ladder_server: bool = False + """Whether the server is a ladder server.""" + + status_flags: int = 0 + """Server status flags bitfield.""" + + challenge_crc: int = 0 + """Challenge/Map CRC checksum.""" + + public_ip: str = "" + """Public IP address of the server.""" + + local_ip: str = "" + """Local IP address of the server.""" + + raw_data: Optional[str] = field(default=None, repr=False) + """Raw response data from the server as hex string.""" + + def __str__(self) -> str: + """ + Returns a human-readable string representation of the server info. + """ + return ( + f"Trackmania Nations Server: {self.name}\n" + f"Map: {self.map} ({self.environment})\n" + f"Players: {self.players}/{self.max_players}\n" + f"Game Mode: {self.game_mode}\n" + f"Password Protected: {self.password_protected}\n" + f"Version: {self.version}\n" + f"Comment: {self.comment}" + ) + + def to_dict(self) -> dict: + """ + Convert to dictionary for JSON serialization, excluding raw_data. + """ + return { + 'name': self.name, + 'map': self.map, + 'players': self.players, + 'max_players': self.max_players, + 'game_mode': self.game_mode, + 'password_protected': self.password_protected, + 'version': self.version, + 'environment': self.environment, + 'comment': self.comment, + 'server_login': self.server_login, + 'pc_guid': self.pc_guid, + 'time_limit': self.time_limit, + 'nb_laps': self.nb_laps, + 'spectator_slots': self.spectator_slots, + 'build_number': self.build_number, + 'private_server': self.private_server, + 'ladder_server': self.ladder_server, + 'status_flags': self.status_flags, + 'challenge_crc': self.challenge_crc, + 'public_ip': self.public_ip, + 'local_ip': self.local_ip + } \ No newline at end of file From 6eafd7a6eea079c02420979cfba1faa2adf5a980 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 27 Aug 2025 14:51:34 +0200 Subject: [PATCH 06/20] Adding Trackmania Nations unauthenticated --- docs/tests/protocols/index.rst | 1 + .../test_trackmania_nations/index.rst | 7 + .../test_trackmania_nations/test_get_info.rst | 31 + opengsq/protocols/trackmania_nations.py | 996 ++++++++++++------ tests/protocols/test_trackmania_nations.py | 24 + 5 files changed, 742 insertions(+), 317 deletions(-) create mode 100644 docs/tests/protocols/test_trackmania_nations/index.rst create mode 100644 docs/tests/protocols/test_trackmania_nations/test_get_info.rst create mode 100644 tests/protocols/test_trackmania_nations.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index a6dfa10..a715a3c 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -10,6 +10,7 @@ Protocols Tests test_fivem/index test_gamespy2/index test_nadeo/index + test_trackmania_nations/index test_ut3/index test_eldewrito/index test_eos/index diff --git a/docs/tests/protocols/test_trackmania_nations/index.rst b/docs/tests/protocols/test_trackmania_nations/index.rst new file mode 100644 index 0000000..14e905f --- /dev/null +++ b/docs/tests/protocols/test_trackmania_nations/index.rst @@ -0,0 +1,7 @@ +.. _test_trackmania_nations: + +test_trackmania_nations +======================= + +.. toctree:: + test_get_info diff --git a/docs/tests/protocols/test_trackmania_nations/test_get_info.rst b/docs/tests/protocols/test_trackmania_nations/test_get_info.rst new file mode 100644 index 0000000..8568411 --- /dev/null +++ b/docs/tests/protocols/test_trackmania_nations/test_get_info.rst @@ -0,0 +1,31 @@ +test_get_info +============= + +Here are the results for the test method. + +.. code-block:: json + + { + "name": "Kawabonga", + "map": "B02-Race", + "players": 1, + "max_players": 6, + "game_mode": "Team", + "password_protected": true, + "version": null, + "environment": "Stadium", + "comment": "PC-ce9b0c", + "server_login": "", + "pc_guid": "PC-ce9b0c", + "time_limit": 0, + "nb_laps": 0, + "spectator_slots": 0, + "build_number": 0, + "private_server": true, + "ladder_server": false, + "status_flags": 0, + "challenge_crc": 0, + "public_ip": "", + "local_ip": "", + "raw_data": "9b0000008303681ac2009b0000000a0700000006000000d53d41008b5c00000b2d1d641dac2e090900000050432d636539623063050000002353525623500204000106000600094402074b617761626f6e6761075001075374616469756d0100002c6c0001ffffffff940602e09304000178030001080000004230322d526163657a710000b9020079020374040b000040070000005374616469756d110000" + } diff --git a/opengsq/protocols/trackmania_nations.py b/opengsq/protocols/trackmania_nations.py index ede2a3a..f10cc36 100644 --- a/opengsq/protocols/trackmania_nations.py +++ b/opengsq/protocols/trackmania_nations.py @@ -1,42 +1,54 @@ from __future__ import annotations import asyncio -import re -from dataclasses import dataclass, field -from typing import List, Optional, Union +import struct +from typing import Optional, Dict, Any, List, Tuple +from dataclasses import dataclass from opengsq.protocol_base import ProtocolBase from opengsq.exceptions import InvalidPacketException from opengsq.responses.trackmania_nations import ServerInfo @dataclass -class SrvInfo: - """Repräsentiert die wichtigsten Informationen eines #SRV#-Pakets.""" - variant: str - server_name: Optional[str] - environment: Optional[str] - comment: Optional[str] - maps: List[str] = field(default_factory=list) +class TrackmaniaPayloadData: + """Strukturierte Daten aus dem Trackmania Payload""" + server_name: Optional[str] = None + srv_type: Optional[str] = None + environment: Optional[str] = None + maps: List[str] = None + players: Optional[int] = None max_players: Optional[int] = None - current_players: Optional[int] = None - host_id: Optional[str] = None - raw_strings: List[str] = field(default_factory=list, repr=False) + game_mode: Optional[str] = None + comment: Optional[str] = None + raw_strings: List[str] = None + + def __post_init__(self): + if self.maps is None: + self.maps = [] + if self.raw_strings is None: + self.raw_strings = [] class TrackmaniaNations(ProtocolBase): """ Trackmania Nations Protocol Implementation - Basiert auf Reverse-Engineering der #SRV# Server-Announcement-Payloads + Basiert auf MCP/Ghidra Reverse-Engineering + + MCP-Erkenntnisse: + - Servername bei Position 0x27 mit 4-Byte Längen-Präfix (Little Endian) + - #SRV# Marker mit 5-Byte Länge und Typ-Indikator + - Drei Haupt-Typen: SRV#f (Float), SRV#s (String), SRV#p (Packet) + - Strings verwenden 4-Byte Längen-Präfixe """ @property def full_name(self) -> str: - return "Trackmania Nations Protocol" + return "Trackmania Nations Protocol (MCP-Enhanced)" # Standard Trackmania Nations port DEFAULT_PORT = 2350 - # TCP packets as specified + # TCP packets (verifiziert) _PACKET_1 = bytes.fromhex("0e000000820399f895580700000008000000") _PACKET_2 = bytes.fromhex("1200000082033bd464400700000007000000d53d4100") @@ -81,28 +93,29 @@ async def get_info(self) -> ServerInfo: if b'#SRV#' not in response_data: raise InvalidPacketException(f"Response does not contain #SRV# marker. Got {len(response_data)} bytes.") - # Parse using the new serializer - srv_info = self.parse_srv_payload(response_data) + # Parse using MCP-based parser + payload_data = self.parse_server_payload(response_data) # Convert to ServerInfo format + # Die Namens-Logik in parse_server_payload hat bereits die richtigen Namen zugeordnet return ServerInfo( - name=srv_info.server_name or "Unknown", - map=srv_info.maps[0] if srv_info.maps else "Unknown", - players=srv_info.current_players or 0, - max_players=srv_info.max_players or 0, - game_mode="Unknown", - password_protected=srv_info.variant in ['p', 'x'], + name=payload_data.server_name or "Unknown", # Echter Server-Name (korrigiert in parse_server_payload) + map=payload_data.maps[0] if payload_data.maps else "Unknown", + players=payload_data.players or 0, + max_players=payload_data.max_players or 0, + game_mode=payload_data.game_mode or "Unknown", + password_protected=payload_data.srv_type == 'p', version=None, - environment=srv_info.environment, - comment=srv_info.comment, + environment=payload_data.environment, + comment=payload_data.comment, # PC-UID oder andere Info server_login="", - pc_guid=srv_info.host_id, + pc_guid=payload_data.comment if payload_data.comment and payload_data.comment.startswith('PC-') else None, # PC-UID time_limit=0, nb_laps=0, spectator_slots=0, build_number=0, - private_server=srv_info.variant in ['p', 'x'], - ladder_server=srv_info.variant in ['s', 'x'], + private_server=payload_data.srv_type == 'p', + ladder_server=payload_data.srv_type == 's', status_flags=0, challenge_crc=0, public_ip="", @@ -116,316 +129,665 @@ async def get_info(self) -> ServerInfo: writer.close() await writer.wait_closed() - def parse_srv_payload(self, payload: Union[str, bytes, bytearray]) -> SrvInfo: - """Zerlegt ein Trackmania-Server-Paket (#SRV#) und liefert ein SrvInfo-Objekt. - - Argumente - --------- - payload - Hex-String (ohne Leerzeichen) *oder* Roh-Bytes. + def parse_server_payload(self, data: bytes) -> TrackmaniaPayloadData: """ - # ASCII regex für längere Sequenzen - _ASCII_RE = re.compile(rb"[ -~]{3,}") - _MAP_RE = re.compile(r"^[A-Z]\d{2}-") - - # 1) Eingabe normieren - if isinstance(payload, str): - data = bytes.fromhex(payload.strip()) - else: - data = bytes(payload) - - # 2) Header suchen (#SRV#X) - header_idx = data.find(b"#SRV#") - if header_idx == -1 or header_idx + 5 >= len(data): - raise ValueError("Kein #SRV#-Header im Payload gefunden") - - variant_byte = data[header_idx + 5: header_idx + 6] - variant = variant_byte.decode("ascii", errors="ignore") or "?" - - # 3) ASCII-Sequenzen extrahieren (sowohl Regex als auch Length-Prefixed) - ascii_strings = [m.decode("ascii") for m in _ASCII_RE.findall(data)] - - # ERWEITERT: Extrahiere auch Length-Prefixed Strings für bessere Genauigkeit - length_prefixed_strings = self._extract_length_prefixed_strings(data, header_idx) - - # 4) Korrigierte Zuordnung der Strings basierend auf MCP-Analyse - # Suche nach #SRV# Header (mit oder ohne Variant) - header_str_index = -1 - for i, s in enumerate(ascii_strings): - if s.startswith("#SRV#"): - header_str_index = i - break - - # KORREKTUR basierend auf MCP-Analyse: - # Position 0: Host-ID (z.B. "dgn-deb", "PC-ce9b0c") - # Position 1: "#SRV#" oder "#SRV#P" - # Position 2: Echter Servername (z.B. "Organich", "BananeBongo") - # Position 3: Environment (z.B. "Stadium", "Stadiumt") - # Position 4: Comment/weitere Infos - - if header_str_index >= 0: - # Host-ID ist der String VOR #SRV# (Position header_str_index - 1) - host_id = ascii_strings[header_str_index - 1] if header_str_index >= 1 else None + Parst einen Server-Payload basierend auf MCP-Erkenntnissen. + + Args: + data: Die Rohdaten des Payloads - # VERBESSERT: Verwende Length-Prefixed Strings für dynamische Erkennung - if length_prefixed_strings: - # Vollständig dynamische Identifikation basierend nur auf Struktur - server_name = None - environment = None - - # SCHRITT 1: Erkenne Environment zuerst - for string in length_prefixed_strings: - if self._is_environment_name(string.lower()) and not environment: - if string.lower().startswith('stad'): - environment = "Stadium" - elif string.lower() == 'island': - environment = "Island" - elif string.lower() == 'bay': - environment = "Bay" - elif string.lower() == 'coast': - environment = "Coast" - else: - environment = string - break - - # SCHRITT 2: Erkenne Server-Name (nicht Environment, nicht Map) - for string in length_prefixed_strings: - if (not self._is_environment_name(string.lower()) and - not re.match(r'^[A-Z]\d{2,3}-', string) and - len(string) >= 3): - if not server_name or len(string) > len(server_name): - server_name = string - - # Fallback auf ASCII-Parsing für Comment - comment = ascii_strings[header_str_index + 3] if header_str_index + 3 < len(ascii_strings) else None - - # ZUSÄTZLICHER FALLBACK: Wenn kein Server-Name in Length-Prefixed Strings gefunden wurde, - # prüfe die ASCII-Strings mit strukturbasierten Filtern - if not server_name: - for string in ascii_strings: - if (self._is_potential_server_name(string) and - not self._is_environment_name(string.lower())): - server_name = string - break - - # ERWEITETER FALLBACK: Wenn der Length-Prefixed Name zu kurz ist (< 5 Zeichen), - # schaue ob es einen längeren Namen in den ASCII-Strings gibt - if server_name and len(server_name) < 5: - for string in ascii_strings: - if (self._is_potential_server_name(string) and - not self._is_environment_name(string.lower()) and - len(string) > len(server_name)): - server_name = string - break + Returns: + TrackmaniaPayloadData mit extrahierten Informationen + """ + result = TrackmaniaPayloadData() + + # 1. String bei 0x27 extrahieren (kann PC-UID oder Server-Name sein, abhängig vom SRV-Typ) + string_at_0x27 = None + if len(data) >= 0x2b: # 0x27 + 4 bytes für Länge + string_at_0x27, _ = self._deserialize_string(data, 0x27) + # Temporär speichern - wird später basierend auf SRV-Typ zugeordnet + result.server_name = string_at_0x27 + + # 2. #SRV# Marker und Typ finden + srv_pos = data.find(b'#SRV#') + if srv_pos != -1 and srv_pos + 5 < len(data): + # Typ-Byte nach #SRV# + srv_type_byte = data[srv_pos + 5] + if srv_type_byte == 0x00: + result.srv_type = 'null' + elif chr(srv_type_byte).lower() in ['f', 's', 'p']: + result.srv_type = chr(srv_type_byte).lower() else: - # Fallback auf ASCII-String-Methode mit strukturbasierten Filtern - server_name = None - environment = None - comment = None - - # Finde Server-Name und Environment in ASCII-Strings - for i, string in enumerate(ascii_strings): - if i <= header_str_index: # Überspringe Strings vor/bei #SRV# - continue - - if self._is_environment_name(string.lower()) and not environment: - environment = string - elif self._is_potential_server_name(string) and not server_name: - server_name = string - elif not comment and len(string) >= 4: - comment = string - - # Stoppe wenn alles gefunden - if server_name and environment and comment: - break - else: - # Fallback für unbekannte Struktur - server_name = "Unknown" - host_id = None - environment = None - comment = None - maps = [s for s in ascii_strings if _MAP_RE.match(s)] - - # 5) Spielerzahlen abschätzen - max_players, current_players = self._guess_player_counts(data, header_idx) - - return SrvInfo( - variant=variant, - server_name=server_name, - environment=environment, - comment=comment, - maps=maps, - max_players=max_players, - current_players=current_players, - host_id=host_id, - raw_strings=ascii_strings, - ) - - def _guess_player_counts(self, data: bytes, header_pos: int) -> tuple[Optional[int], Optional[int]]: - """Versucht max und current players aus den 64 Bytes vor header_pos zu lesen.""" - ints: List[int] = [ - int.from_bytes(data[o:o + 4], "little") - for o in range(max(0, header_pos - 64), header_pos, 4) - ] - plausible = [x for x in ints if 0 < x <= 255] - # Im beobachteten Paket fanden sich max- und current-Spieler als erste beiden kleinen Werte. - if len(plausible) >= 2: - return plausible[0], plausible[1] - if len(plausible) == 1: - return plausible[0], plausible[0] - return None, None - - def _extract_length_prefixed_strings(self, data: bytes, start_pos: int) -> List[str]: - """Vollständig dynamische Extraktion von Length-Prefixed Strings basierend auf Payload-Struktur. + result.srv_type = f'unknown_{srv_type_byte:02x}' - Analysiert die Bytes nach #SRV# und findet alle Length-Prefixed Strings ohne hardcodierte Namen. - """ - strings = [] + # 3. MCP-basierte Challenge/Map-Namen Extraktion (mit SRV-Typ) + challenge_name = self._extract_challenge_name(data, srv_pos, result.srv_type) + if challenge_name: + result.maps.append(challenge_name) + + # 4. Weitere Strings extrahieren für Environment, etc. + strings = self._extract_all_strings(data) + result.raw_strings = strings - # Erweiterte Suche nach #SRV# - search_start = start_pos + 6 - search_end = min(start_pos + 80, len(data)) + # 5. Spezifische Daten extrahieren + for string in strings: + # Fallback für Maps wenn MCP-Extraktion nichts fand + if not result.maps and self._is_valid_challenge_name(string): + result.maps.append(string) + # Environment + elif string.lower() in ['stadium', 'island', 'bay', 'coast']: + result.environment = string.title() - # Sammle alle möglichen String-Kandidaten - candidates = [] + # 6. Spielerzahlen extrahieren (basierend auf Typ) + player_data = self._extract_player_counts(data, srv_pos, result.srv_type) + if player_data: + result.players, result.max_players = player_data - for pos in range(search_start, search_end - 3): - byte = data[pos] + # 7. Game-Mode via MCP/Ghidra-Marker extrahieren (robust, ohne String-Heuristik) + mode_name = self._extract_game_mode(data) + if mode_name: + result.game_mode = mode_name + + # 8. MCP-basierte korrekte Zuordnung basierend auf SRV-Typ + if result.srv_type == 'p': + # Private Server: 0x27 = PC-UID, echter Name in ASCII-Strings + result.comment = string_at_0x27 # PC-UID - # Prüfe ob das ein plausibles Length-Byte ist (1-20 Zeichen) - if 1 <= byte <= 20: - # Teste verschiedene Offsets nach dem Length-Byte - for offset in range(1, 5): - string_start = pos + offset - string_end = string_start + byte - - if string_end <= len(data): - string_bytes = data[string_start:string_end] - - # Prüfe ob alle Bytes druckbare ASCII-Zeichen sind - if all(32 <= b <= 126 for b in string_bytes): - try: - string = string_bytes.decode('ascii') - - # Strukturbasierte Validierung (ohne Namen-spezifische Regeln) - if self._is_structurally_valid_string(string): - quality_score = self._calculate_structural_quality(string, pos, offset, byte) - candidates.append({ - 'string': string, - 'score': quality_score, - 'pos': pos, - 'offset': offset, - 'length': byte, - 'start': string_start, - 'end': string_end - }) - except UnicodeDecodeError: - pass - - # Sortiere nach Qualität und entferne Überlappungen - candidates.sort(key=lambda x: x['score'], reverse=True) - - selected = [] - used_ranges = [] - - for candidate in candidates: - # Prüfe auf Überlappung - overlaps = any( - not (candidate['end'] <= used_start or candidate['start'] >= used_end) - for used_start, used_end in used_ranges - ) + # Finde echten Server-Namen aus ASCII-Strings + potential_names = [s for s in strings if + len(s) >= 4 and + not s.startswith('PC-') and + s != result.environment and + not self._is_valid_challenge_name(s) and + not s.startswith('#') and + 'lanparty' not in s.lower() and + 'obstacle' not in s.lower()] # Filter korrupte Namen - if not overlaps and candidate['string'] not in selected: - selected.append(candidate['string']) - used_ranges.append((candidate['start'], candidate['end'])) - - if len(selected) >= 4: # Maximal 4 Strings - break - - return selected - - def _is_structurally_valid_string(self, string: str) -> bool: - """Strukturbasierte Validierung ohne hardcodierte Namen.""" - # Mindestlänge - if len(string) < 3: - return False + if potential_names: + potential_names.sort(key=len, reverse=True) + result.server_name = potential_names[0] # Längster = echter Server-Name - # Muss hauptsächlich alphabetisch sein - alpha_count = sum(1 for c in string if c.isalpha()) - if alpha_count < 2: - return False + elif result.srv_type == 'null' or result.srv_type is None: + # Default/Null Server: Finde echten Server-Namen in ASCII-Strings + # 0x27 könnte PC-UID oder Server-Name sein - prüfe Pattern - # Nicht nur Sonderzeichen - special_count = sum(1 for c in string if not c.isalnum()) - if special_count > len(string) // 2: - return False + # Finde potentielle Server-Namen (alphabetische Namen bevorzugt) + potential_names = [s for s in strings if + 3 <= len(s) <= 15 and # Kurze, prägnante Namen + not s.startswith('PC-') and + not s.startswith('#') and + s != result.environment and + not self._is_valid_challenge_name(s) and + not any(kw in s.lower() for kw in ['stadium', 'lanparty', 'obstacle']) and + s.isalpha()] # Nur alphabetische Namen (wie "Bruno") - # Keine reinen Zahlen - if string.isdigit(): - return False + if potential_names: + # Priorisiere kürzeste alphabetische Namen + potential_names.sort(key=len) + real_server_name = potential_names[0] + + # Wenn 0x27 String länger/anders ist, ist es wahrscheinlich PC-UID + if string_at_0x27 and string_at_0x27 != real_server_name: + result.server_name = real_server_name + result.comment = string_at_0x27 # PC-UID/Login + else: + result.server_name = string_at_0x27 or real_server_name + result.comment = None + else: + # Fallback: 0x27 als Server-Name + result.server_name = string_at_0x27 + result.comment = None + + else: + # Andere Server-Typen: Fallback zur alten Logik + potential_names = [s for s in strings if + len(s) >= 3 and + s != result.environment and + not self._is_valid_challenge_name(s) and + not s.startswith('#')] + + if potential_names: + potential_names.sort(key=len, reverse=True) + longest = potential_names[0] + if len(longest) > len(string_at_0x27 or ''): + result.server_name = longest + result.comment = string_at_0x27 + else: + result.comment = longest + + return result + + def _deserialize_string(self, data: bytes, offset: int) -> Tuple[Optional[str], int]: + """ + Deserialisiert einen String mit 4-Byte Längen-Präfix (Little Endian). + + Args: + data: Die Rohdaten + offset: Start-Position - return True + Returns: + Tuple aus (String oder None, Anzahl gelesener Bytes) + """ + if offset + 4 > len(data): + return None, 0 + + # Länge lesen (4 Bytes, Little Endian) + length = struct.unpack(' 100 or offset + 4 + length > len(data): + return None, 0 + + # String lesen + try: + string_data = data[offset+4:offset+4+length] + string = string_data.decode('utf-8', errors='replace') + return string, 4 + length + except: + return None, 0 - def _calculate_structural_quality(self, string: str, pos: int, offset: int, expected_length: int) -> float: - """Berechnet Qualität basierend auf Struktur, nicht auf Namen.""" - score = 0.0 - - # Length-Byte muss exakt stimmen - if len(string) != expected_length: - return 0.0 - - # Längere Strings sind oft wichtiger (Server-Namen) - score += len(string) * 0.2 - - # Alphabetische Zeichen sind gut - alpha_ratio = sum(1 for c in string if c.isalpha()) / len(string) - score += alpha_ratio * 3.0 - - # Nur bekannte Environment-Namen (fest im Spiel) - if string.lower() in ['stadium', 'island', 'bay', 'coast']: - score += 2.0 - elif string.lower().startswith('stad'): # Stadium-Varianten - score += 1.5 + def _extract_all_strings(self, data: bytes) -> List[str]: + """ + Extrahiert alle lesbaren ASCII-Strings aus den Daten. + + Args: + data: Die Rohdaten - # Kleinere Offsets sind wahrscheinlicher - score -= offset * 0.1 + Returns: + Liste der gefundenen Strings + """ + strings = [] + current_string = bytearray() - # Erste Strings im Payload sind oft wichtiger - score -= pos * 0.001 + for byte in data: + if 32 <= byte <= 126: # Druckbare ASCII-Zeichen + current_string.append(byte) + else: + if len(current_string) >= 3: # Mindestens 3 Zeichen + try: + string = current_string.decode('ascii') + strings.append(string) + except: + pass + current_string = bytearray() - return score + # Letzten String nicht vergessen + if len(current_string) >= 3: + try: + string = current_string.decode('ascii') + strings.append(string) + except: + pass + + return strings - def _is_environment_name(self, string_lower: str) -> bool: - """Prüft ob ein String ein bekanntes TrackMania Environment ist (fest im Spiel).""" - return (string_lower in ['stadium', 'island', 'bay', 'coast'] or - string_lower.startswith('stad')) + def _is_map_name(self, string: str) -> bool: + """ + Prüft ob ein String ein Map-Name ist (striktere Typen, keine korrupten Suffixe). + """ + import re + allowed = r'(race|acrobatic|speed|endurance|platform|puzzle)' + if re.match(rf'^[A-E]\d{{2}}-{allowed}$', string, re.IGNORECASE): + return True + if re.match(rf'^\d+-{allowed}$', string, re.IGNORECASE): + return True + return False - def _is_potential_server_name(self, string: str) -> bool: - """Strukturbasierte Prüfung ob ein String ein potentieller Server-Name ist.""" - if len(string) < 3: - return False - - # Filtere technische Strings - if (string.startswith('#') or - string.startswith('PC-') or - re.match(r'^[A-Z]\d{2,3}-', string)): # Map-Namen - return False - - # Filtere Host-IDs (lange Strings mit Bindestrichen) - if '-' in string and len(string) > 8: - return False + def _extract_player_counts(self, data: bytes, srv_pos: int, srv_type: str) -> Optional[Tuple[int, int]]: + """ + Extrahiert Spielerzahlen basierend auf dem SRV-Typ. + + MCP-Erkenntnisse zeigen verschiedene Offsets für verschiedene Typen. + """ + if srv_pos == -1: + return None + + # Verschiedene Offset-Patterns basierend auf Typ (MCP-korrigiert) + if srv_type == 'null': + # Für Null-Byte: Offsets +7 und +9 + offsets = [(7, 9)] + elif srv_type == 'p': + # Für Private Server: MCP-Analyse zeigt +10/+11 für aktive Spieler, +9/+11 fallback + offsets = [ + # Beobachtung 172.29.100.29: plausibles Paar 1/6 bei SRV+29/SRV+50 + (29, 50), + (10, 11), (9, 11), (7, 11), + # zusätzliche pragmatische Kandidaten, beobachtet auf manchen 'p'-Servern + (12, 14), (7, 9), (41, 45) + ] # Reihenfolge: etabliert, dann heuristisch + else: + # Für andere Typen: Teste mehrere Patterns + offsets = [(7, 9), (9, 11), (7, 11), (41, 45), (12, 14), (15, 17)] + + # Teste die Offset-Patterns (MCP-korrigiert für aktuelle Spieler) + for current_offset, max_offset in offsets: + if srv_pos + max_offset < len(data): + current_players = data[srv_pos + current_offset] + max_players = data[srv_pos + max_offset] + + # Plausibilitätsprüfung + if (0 <= current_players <= max_players <= 200 and + max_players > 0): + return current_players, max_players + + # Letzter Fallback nur für 'p'-Server: heuristische Suche in kleinem Fenster + # Motiv: Es gibt Varianten, bei denen die Felder deutlich verschoben sind. + if srv_type == 'p': + window_start = max(0, srv_pos) + window_end = min(len(data), srv_pos + 96) + best_pair = None + best_score = 1e9 + common_max_values = {6, 8, 10, 12, 14, 16, 20, 24, 32, 48, 64} + for max_idx in range(srv_pos + 16, window_end): + max_val = data[max_idx] + if not (1 <= max_val <= 64): + continue + # Suche current in der Nähe, bevorzugt vorher + search_from = max(window_start, max_idx - 40) + for cur_idx in range(search_from, max_idx): + cur_val = data[cur_idx] + if 0 <= cur_val <= max_val: + # Scoring: kleinere max-Werte bevorzugen (realistische Slot-Zahlen), Nähe der Felder + score = (0 if max_val in common_max_values else 10) + (max_idx - cur_idx) + if score < best_score: + best_score = score + best_pair = (cur_val, max_val) + if best_pair is not None: + return best_pair + + return None + + def _extract_game_mode(self, data: bytes) -> Optional[str]: + """ + Extrahiert den Spielmodus aus dem Payload. + + Strategien (in dieser Reihenfolge): + 1) #SRV#-Offset-Erkennung: Für bestimmte Varianten (z. B. 'p') liegt die Mode-ID an einem festen Offset + 2) Marker-basierte Erkennung: Suche nach 0xFF 0xFF 0xFF 0xFF und nutze Byte an +7 als Modus-ID + 3) Stadium-Pattern-Fallback: Auswertung der Bytes nach dem 'Stadium' String + 4) Letzter Fallback: Keine Heuristik über Mapnamen (vermeidet Fehlzuordnung wie 'A01-Race') + """ + # 1) Marker-basierte Erkennung + mode_id = self._extract_game_mode_id_by_marker(data) + if mode_id is not None: + name = self._map_game_mode_id_to_name(mode_id) + if name: + return name + + # 2) Stadium-Pattern-Fallback + mode_id = self._extract_game_mode_id_by_stadium_pattern(data) + if mode_id is not None: + name = self._map_game_mode_id_to_name(mode_id) + if name: + return name + + return None + + def _extract_game_mode_id_by_marker(self, data: bytes) -> Optional[int]: + """ + Sucht nach dem 0xFFFFFFFF Marker und liest das Spielmodus-Byte bei +7. + Laut Analyse liefert dieses Byte Werte wie 0x09 (Cup), 0x07 (Rounds), 0x06 (Team), 0x00 (Time Attack). + """ + marker = b'\xff\xff\xff\xff' + idx = data.find(marker) + if idx != -1 and idx + 8 <= len(data): + try: + # Kandidaten-Offsets testen (+6, +7, +5), nur plausible IDs akzeptieren + candidates = [idx + 7, idx + 6, idx + 5] + valid_ids = {0, 1, 2, 3, 4, 5, 6, 7, 9} + for off in candidates: + if 0 <= off < len(data): + val = data[off] + if val in valid_ids: + return val + except Exception: + return None + return None + + def _extract_game_mode_id_by_stadium_pattern(self, data: bytes) -> Optional[int]: + """ + Fallback-Erkennung über Byte-Muster relativ zum 'Stadium'-String. + Bekanntes Mapping: + - 0x01 0x20 => TimeAttack (ID 0) + - 0x03 0x1e => Tournament (ID 3) + - 0x06 0x32 => Team (ID 6) + - 0x07 0x03 => Rounds (ID 7) + - 0x09 xx => Cup (ID 9) + """ + # Suche 'Stadium' NACH dem '#SRV#'-Marker, um den richtigen Kontext zu erwischen + anchor = b'Stadium' + srv_pos = data.find(b'#SRV#') + if srv_pos == -1: + return None + pos = data.find(anchor, srv_pos) + if pos == -1: + return None + pattern_start = pos + len(anchor) + # Wir benötigen mindestens ein kleines Fenster nach dem Anchor + window_end = min(len(data), pattern_start + 32) + if pattern_start >= window_end: + return None + + # 1) Klassische Position b5/b6 (kompatibel zu früherer Implementierung) + if len(data) > pattern_start + 6: + b5 = data[pattern_start + 5] + b6 = data[pattern_start + 6] + if b5 == 0x01 and b6 == 0x20: + return 0 # TimeAttack + if b5 == 0x03 and b6 == 0x1e: + return 3 # Tournament + if b5 == 0x06 and b6 == 0x32: + return 6 # Team + if b5 == 0x07 and b6 == 0x03: + return 7 # Rounds + if b5 == 0x09: + return 9 # Cup + + # 2) Flexibles Scannen im kleinen Fenster: suche bekannte Paare in beliebiger Ausrichtung + window = data[pattern_start:window_end] + # Paare, die als direkt aufeinanderfolgende Bytes auftreten sollten + pair_to_mode = { + (0x01, 0x20): 0, # TimeAttack + (0x03, 0x1e): 3, # Tournament + (0x06, 0x32): 6, # Team + (0x07, 0x03): 7, # Rounds + } + for i in range(0, len(window) - 1): + a, b = window[i], window[i + 1] + if (a, b) in pair_to_mode: + return pair_to_mode[(a, b)] + # Cup kann als Einzelwert im Fenster auftreten + if 0x09 in window: + return 9 + + # 3) Schwache Heuristik: Einzel-ID im Fenster (z. B. 0x07 für Rounds) bevorzugt, wenn eindeutig + for candidate in (7, 6, 3, 0): + if candidate in window: + return candidate + + return None + + def _map_game_mode_id_to_name(self, mode_id: int) -> Optional[str]: + """ + Mappt erkannte Modus-IDs auf sprechende Namen. + Bevorzugt bekannte TMNF-Bezeichnungen. + """ + mapping = { + 0: 'TimeAttack', + 3: 'Tournament', # In manchen Quellen auch 'Tournament'; hier konservativ auf Laps mappen + 6: 'Team', + 7: 'Rounds', + 9: 'Cup', + } + # Weitere bekannte IDs aus Dokus (falls auftauchen) + extra_aliases = { + 1: 'TimeAttack', + 2: 'Team', + 4: 'Stunts', + 5: 'Cup', + } + return mapping.get(mode_id) or extra_aliases.get(mode_id) + + def _extract_challenge_name(self, data: bytes, srv_pos: int, srv_type: str = None) -> Optional[str]: + """ + Extrahiert den Challenge/Map-Namen basierend auf MCP-Analyse. + + WICHTIGE MCP-Erkenntnisse: + - Default/Null Server: Challenge-Namen MIT 4-Byte Längenpräfix (Little Endian) + - Private Server: Challenge-Namen OHNE Längenpräfix (direkte ASCII-Strings) + + Args: + data: Die Rohdaten + srv_pos: Position des #SRV# Markers + srv_type: Typ des Servers ('null', 'p', etc.) - # Muss hauptsächlich alphabetische Zeichen haben - alpha_count = sum(1 for c in string if c.isalpha()) - if alpha_count < 3: - return False + Returns: + Challenge-Name oder None + """ + if srv_pos == -1: + return None + + # Zuerst Prefix-Varianten versuchen (1/2/4 Bytes), unabhängig vom SRV-Typ + name = self._extract_challenge_with_prefix(data, srv_pos) + if name: + return name + # Fallback: direkte ASCII-Strings + return self._extract_challenge_without_prefix(data, srv_pos) + + def _extract_challenge_with_prefix(self, data: bytes, srv_pos: int) -> Optional[str]: + """ + Extrahiert Challenge-Namen mit Längenpräfix (1/2/4 Byte; LE für 2/4). + """ + for prefix_size in (1, 2, 4): + candidate = self._scan_challenge_with_prefix_size(data, srv_pos, prefix_size) + if candidate: + return candidate + return None + + def _scan_challenge_with_prefix_size(self, data: bytes, srv_pos: int, prefix_size: int) -> Optional[str]: + """ + Durchsucht den Bereich nach #SRV# nach einem length-prefixed String mit gegebener Präfixgröße. + """ + search_start = srv_pos + 32 + search_end = min(len(data), srv_pos + 220) + if search_start >= search_end: + return None + + step = 1 + for offset in range(search_start, search_end - (prefix_size + 4), step): + try: + if offset + prefix_size >= len(data): + break + + if prefix_size == 1: + length = data[offset] + elif prefix_size == 2: + length = struct.unpack(' len(data): + continue + + segment = data[start:end] + try: + raw_text = segment.decode('ascii', errors='ignore') + except Exception: + continue + + # Nur druckbare Zeichen behalten + cleaned = ''.join(ch for ch in raw_text if 32 <= ord(ch) <= 126) + if not cleaned: + continue + + # Strikte Map-Erkennung als Substring + strict = self._find_strict_challenge_in_text(cleaned) + if strict: + return strict + except Exception: + continue + return None + + def _extract_challenge_without_prefix(self, data: bytes, srv_pos: int) -> Optional[str]: + """ + Extrahiert Challenge-Namen ohne Längenpräfix (für Private Server). + """ + # Suche nach direkten ASCII-Strings ab SRV-Position + search_start = srv_pos + 10 + search_data = data[search_start:] + + current_string = bytearray() + found_strings = [] + + for i, byte in enumerate(search_data): + if 32 <= byte <= 126: # Druckbare ASCII-Zeichen + current_string.append(byte) + else: + if len(current_string) >= 5: # Mindestens 5 Zeichen für Challenge-Namen + try: + string = current_string.decode('ascii') + if self._is_valid_challenge_name(string): + return string + found_strings.append(string) + except: + pass + current_string = bytearray() + + # Letzten String nicht vergessen + if len(current_string) >= 5: + try: + string = current_string.decode('ascii') + if self._is_valid_challenge_name(string): + return string + found_strings.append(string) + except: + pass + + # Fallback: Erste gültige Challenge aus gefundenen Strings + for string in found_strings: + if self._is_valid_challenge_name(string): + return string + + return None + + def _is_valid_challenge_name(self, name: str) -> bool: + """ + Prüft ob ein String ein gültiger Challenge/Map-Name ist. + + MCP-Erkenntnisse zeigen folgende Patterns: + - Standard TrackMania Challenge-Namen: A01-Race, C02-Acrobatic, etc. + - GBX-Referenzen + - Race/Challenge Keywords + + Args: + name: Zu prüfender String - # Nicht nur Sonderzeichen - special_count = sum(1 for c in string if not c.isalnum()) - if special_count > len(string) // 2: + Returns: + True wenn gültiger Challenge-Name + """ + import re + + # Nur druckbare ASCII-Zeichen zulassen + if any(ord(c) < 32 or ord(c) > 126 for c in name): return False - - return True + # Zu kurz oder zu lang + if len(name) < 3 or len(name) > 50: + return False + + # Standard TrackMania Challenge Pattern: A01-Race, C02-Acrobatic + # Aber nur vollständige bekannte Challenge-Namen (KEINE korrupten wie "C04-Raceh") + standard_pattern = re.match(r'^[A-E]\d{2}-([A-Za-z]{4,})$', name) + if standard_pattern: + challenge_type = standard_pattern.group(1).lower() + # Nur bekannte Challenge-Typen aus MCP-Analyse + known_types = ['race', 'acrobatic', 'speed', 'endurance', 'platform', 'puzzle'] + # WICHTIG: "raceh" ist NICHT in known_types, also wird C04-Raceh abgelehnt! + if challenge_type in known_types: + return True + + # Verkürzte Namen: 5-Endurance, 1-Speed (nur bekannte Typen) + short_pattern = re.match(r'^\d+-([A-Za-z]{4,})$', name) + if short_pattern: + challenge_type = short_pattern.group(1).lower() + # Nur bekannte Challenge-Typen + known_types = ['race', 'acrobatic', 'speed', 'endurance', 'platform', 'puzzle'] + if challenge_type in known_types: + return True + + # Challenge/Race Keywords (aus MCP-Strings) + challenge_keywords = [ + 'race', 'speed', 'endurance', 'acrobatic', 'challenge', + 'track', 'circuit', 'course', 'stage' + ] + + name_lower = name.lower() + if any(keyword in name_lower for keyword in challenge_keywords): + # Aber nicht wenn es offensichtlich ein Server-Name oder anderer String ist + # Und mindestens ein Wort muss vollständig sein (nicht nur Teil eines Wortes) + # WICHTIG: Blockiere korrupte Namen wie "raceh" (race + unbekanntes Ende) + if (not any(exclude in name_lower for exclude in ['server', 'player', 'time', 'score']) and + len(name) >= 4 and # Mindestlänge + not name_lower.endswith('p') and # Nicht unvollständig wie "RaceP" + not name_lower.endswith('h') and # Nicht unvollständig wie "Raceh" + not re.match(r'^[A-E]\d{2}-.*[ph]$', name, re.IGNORECASE) and # Nicht Standard-Pattern mit 'p'/'h' am Ende + (' ' in name or len(name) >= 5)): # Entweder Leerzeichen oder mindestens 5 Zeichen + return True + + # GBX-Pattern (aus MCP: .TrackMania.gbx) + if '.gbx' in name_lower or 'trackmania' in name_lower: + return True + + return False + def _find_strict_challenge_in_text(self, text: str) -> Optional[str]: + """ + Sucht in einem Text nach einem strikt passenden Challenge-Namen + (z. B. A01-Race, C06-Speed, etc.) und gibt den ersten Treffer zurück. + """ + import re + pattern = re.compile(r'([A-E]\d{2}-(?:Race|Acrobatic|Speed|Endurance|Platform|Puzzle))', re.IGNORECASE) + m = pattern.search(text) + return m.group(0) if m else None + def debug_payload(self, data: bytes) -> Dict[str, Any]: + """ + Debug-Funktion zur Analyse eines Payloads. + + Args: + data: Die Rohdaten + + Returns: + Dictionary mit Debug-Informationen + """ + debug_info = { + 'length': len(data), + 'hex_dump': data[:100].hex() if len(data) > 100 else data.hex(), + 'server_name_offset': 0x27, + 'srv_marker_pos': -1, + 'srv_type': None, + 'strings': [], + 'potential_player_offsets': {} + } + + # Servername bei 0x27 + if len(data) >= 0x2b: + server_name, bytes_read = self._deserialize_string(data, 0x27) + debug_info['server_name'] = server_name + debug_info['server_name_bytes_read'] = bytes_read + + # SRV Marker + srv_pos = data.find(b'#SRV#') + if srv_pos != -1: + debug_info['srv_marker_pos'] = srv_pos + if srv_pos + 5 < len(data): + srv_type_byte = data[srv_pos + 5] + debug_info['srv_type_byte'] = f'0x{srv_type_byte:02x}' + if srv_type_byte == 0x00: + debug_info['srv_type'] = 'null' + elif chr(srv_type_byte) in ['f', 's', 'p']: + debug_info['srv_type'] = chr(srv_type_byte) + + # Alle Strings + debug_info['strings'] = self._extract_all_strings(data) + + # MCP-basierte Challenge-Namen Extraktion + challenge_name = self._extract_challenge_name(data, srv_pos) + debug_info['mcp_challenge_name'] = challenge_name + debug_info['challenge_extraction_method'] = 'MCP-based' if challenge_name else 'fallback' + + # Potentielle Spielerzahl-Offsets + if srv_pos != -1: + test_offsets = [(7, 9), (41, 45), (12, 14), (15, 17)] + for curr_off, max_off in test_offsets: + if srv_pos + max_off < len(data): + curr = data[srv_pos + curr_off] + max_val = data[srv_pos + max_off] + debug_info['potential_player_offsets'][f'+{curr_off}/+{max_off}'] = f'{curr}/{max_val}' + + return debug_info \ No newline at end of file diff --git a/tests/protocols/test_trackmania_nations.py b/tests/protocols/test_trackmania_nations.py new file mode 100644 index 0000000..7174687 --- /dev/null +++ b/tests/protocols/test_trackmania_nations.py @@ -0,0 +1,24 @@ +import pytest +from opengsq.protocols.trackmania_nations import TrackmaniaNations + +from ..result_handler import ResultHandler + + +handler = ResultHandler(__file__) +handler.enable_save = True + +# Test server configuration +SERVER_IP = "172.29.100.29" +SERVER_PORT = 2350 + +tmn = TrackmaniaNations(host=SERVER_IP, port=SERVER_PORT) + + +@pytest.mark.asyncio +async def test_get_info(): + result = await tmn.get_info() + await handler.save_result("test_get_info", result) + + + + From 0cfad80c883c124adc17b8bf4c4f49bf4a6110a4 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 10 Sep 2025 18:51:39 +0200 Subject: [PATCH 07/20] Adding first Implementation of Directplay, AoE1 and AoE2 --- opengsq/protocols/__init__.py | 3 + opengsq/protocols/aoe1.py | 86 ++++++++++ opengsq/protocols/aoe2.py | 147 +++++++++++++++++ opengsq/protocols/directplay.py | 195 +++++++++++++++++++++++ opengsq/responses/aoe1/__init__.py | 0 opengsq/responses/aoe1/status.py | 21 +++ opengsq/responses/aoe2/__init__.py | 0 opengsq/responses/aoe2/status.py | 24 +++ opengsq/responses/directplay/__init__.py | 0 opengsq/responses/directplay/status.py | 37 +++++ 10 files changed, 513 insertions(+) create mode 100644 opengsq/protocols/aoe1.py create mode 100644 opengsq/protocols/aoe2.py create mode 100644 opengsq/protocols/directplay.py create mode 100644 opengsq/responses/aoe1/__init__.py create mode 100644 opengsq/responses/aoe1/status.py create mode 100644 opengsq/responses/aoe2/__init__.py create mode 100644 opengsq/responses/aoe2/status.py create mode 100644 opengsq/responses/directplay/__init__.py create mode 100644 opengsq/responses/directplay/status.py diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index d47d5c0..4c7ee50 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -1,5 +1,8 @@ +from opengsq.protocols.aoe1 import AoE1 +from opengsq.protocols.aoe2 import AoE2 from opengsq.protocols.ase import ASE from opengsq.protocols.battlefield import Battlefield +from opengsq.protocols.directplay import DirectPlay from opengsq.protocols.doom3 import Doom3 from opengsq.protocols.eldewrito import ElDewrito from opengsq.protocols.eos import EOS diff --git a/opengsq/protocols/aoe1.py b/opengsq/protocols/aoe1.py new file mode 100644 index 0000000..d7345d7 --- /dev/null +++ b/opengsq/protocols/aoe1.py @@ -0,0 +1,86 @@ +from opengsq.protocols.directplay import DirectPlay +from opengsq.responses.aoe1.status import Status +from opengsq.binary_reader import BinaryReader + + +class AoE1(DirectPlay): + """ + Age of Empires 1 DirectPlay Protocol + + Erweitert das DirectPlay Basis-Protokoll um spezifische + Age of Empires 1 Implementierungsdetails. + """ + + full_name = "Age of Empires 1 DirectPlay Protocol" + + # AoE1 spezifische Konstanten und Payload + AOE1_UDP_PAYLOAD = bytes.fromhex("3400b0fa020008fc000000000000000000000000706c617902000e0082e92234891ad111b09300a024c747760000000001000000") + + # DirectPlay Payload-Struktur für AoE1: + # Bytes 0-27: Gemeinsamer DirectPlay Header (identisch mit AoE2) + # Bytes 20-23: "play" - DirectPlay Identifikation + # Bytes 28-43: Spiel-spezifische GUID: 82e92234-891a-d111-b093-00a024c74776 + # Bytes 44-47: Padding/Reserved (00 00 00 00) + # Bytes 48-51: Version/Type ID: 01 00 00 00 (unterscheidet sich von AoE2) + AOE1_GAME_GUID = "82e92234-891a-d111-b093-00a024c74776" + + def __init__(self, host: str, port: int = DirectPlay.DIRECTPLAY_UDP_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + + def _build_query_packet(self) -> bytes: + """ + Erstellt das AoE1-spezifische UDP Query Packet. + + Verwendet den echten DirectPlay-Payload für Age of Empires 1: + 3400b0fa020008fc000000000000000000000000706c617902000e0082e92234891ad111b09300a024c747760000000001000000 + + Returns: + bytes: Das AoE1 Query Packet + """ + return self.AOE1_UDP_PAYLOAD + + def _parse_response(self, buffer: bytes) -> dict: + """ + Parsed die TCP-Antwort vom AoE1 Server. + + TODO: Dies ist ein Platzhalter. Die echte Parsing-Logik muss + durch Analyse der TCP-Pakete implementiert werden. + + Args: + buffer: Die rohen TCP-Antwortdaten + + Returns: + dict: Geparste AoE1 Server-Informationen + """ + if len(buffer) < 4: + raise Exception("Antwort zu kurz für AoE1 DirectPlay Protokoll") + + br = BinaryReader(buffer) + + # Placeholder Parsing Logic + # TODO: Echte AoE1 Paket-Struktur implementieren + + # Beispiel DirectPlay Header lesen + magic = br.read_bytes(4) + + # Placeholder Werte + result = { + 'name': 'AoE1 Server (Placeholder)', + 'game_type': 'Age of Empires', + 'map': 'Unknown Map', + 'num_players': 0, + 'max_players': 8, + 'password_protected': False, + 'game_version': '1.0', + 'game_mode': 'Standard', + 'difficulty': 'Standard', + 'speed': 'Normal', + 'players': [], + 'raw': { + 'magic': magic.hex(), + 'buffer_length': len(buffer), + 'full_buffer': buffer.hex() + } + } + + return result diff --git a/opengsq/protocols/aoe2.py b/opengsq/protocols/aoe2.py new file mode 100644 index 0000000..2235e5c --- /dev/null +++ b/opengsq/protocols/aoe2.py @@ -0,0 +1,147 @@ +from opengsq.protocols.directplay import DirectPlay +from opengsq.responses.aoe2.status import Status +from opengsq.binary_reader import BinaryReader + + +class AoE2(DirectPlay): + """ + Age of Empires 2 DirectPlay Protocol + + Erweitert das DirectPlay Basis-Protokoll um spezifische + Age of Empires 2 Implementierungsdetails. + """ + + full_name = "Age of Empires 2 DirectPlay Protocol" + + # AoE2 spezifische Konstanten und Payload + AOE2_UDP_PAYLOAD = bytes.fromhex("3400b0fa020008fc000000000000000000000000706c617902000e0060a269fb3150d311a2d4006097ba65500000000011000000") + + # DirectPlay Payload-Struktur für AoE2: + # Bytes 0-27: Gemeinsamer DirectPlay Header (identisch mit AoE1) + # Bytes 20-23: "play" - DirectPlay Identifikation + # Bytes 28-43: Spiel-spezifische GUID: 60a269fb-3150-d311-a2d4-006097ba6550 + # Bytes 44-47: Padding/Reserved (00 00 00 00) + # Bytes 48-51: Version/Type ID: 11 00 00 00 (unterscheidet sich von AoE1) + AOE2_GAME_GUID = "60a269fb-3150-d311-a2d4-006097ba6550" + + # AoE2 Civilizations + CIVILIZATIONS = { + 0: "Unknown", + 1: "Britons", + 2: "Franks", + 3: "Goths", + 4: "Teutons", + 5: "Japanese", + 6: "Chinese", + 7: "Byzantines", + 8: "Persians", + 9: "Saracens", + 10: "Turks", + 11: "Vikings", + 12: "Mongols", + 13: "Celts", + 14: "Spanish", + 15: "Aztecs", + 16: "Mayans", + 17: "Huns", + 18: "Koreans" + } + + # AoE2 Game Modes + GAME_MODES = { + 0: "Random Map", + 1: "Regicide", + 2: "Death Match", + 3: "Scenario", + 4: "Campaign", + 5: "King of the Hill", + 6: "Wonder Race", + 7: "Defend the Wonder" + } + + def __init__(self, host: str, port: int = DirectPlay.DIRECTPLAY_UDP_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + + def _build_query_packet(self) -> bytes: + """ + Erstellt das AoE2-spezifische UDP Query Packet. + + Verwendet den echten DirectPlay-Payload für Age of Empires 2: + 3400b0fa020008fc000000000000000000000000706c617902000e0060a269fb3150d311a2d4006097ba65500000000011000000 + + Returns: + bytes: Das AoE2 Query Packet + """ + return self.AOE2_UDP_PAYLOAD + + def _parse_response(self, buffer: bytes) -> dict: + """ + Parsed die TCP-Antwort vom AoE2 Server. + + TODO: Dies ist ein Platzhalter. Die echte Parsing-Logik muss + durch Analyse der TCP-Pakete implementiert werden. + + Args: + buffer: Die rohen TCP-Antwortdaten + + Returns: + dict: Geparste AoE2 Server-Informationen + """ + if len(buffer) < 4: + raise Exception("Antwort zu kurz für AoE2 DirectPlay Protokoll") + + br = BinaryReader(buffer) + + # Placeholder Parsing Logic + # TODO: Echte AoE2 Paket-Struktur implementieren + + # Beispiel DirectPlay Header lesen + magic = br.read_bytes(4) + + # Placeholder Werte + result = { + 'name': 'AoE2 Server (Placeholder)', + 'game_type': 'Age of Empires II', + 'map': 'Unknown Map', + 'num_players': 0, + 'max_players': 8, + 'password_protected': False, + 'game_version': '2.0', + 'game_mode': 'Random Map', + 'difficulty': 'Standard', + 'speed': 'Normal', + 'players': [], + 'raw': { + 'magic': magic.hex(), + 'buffer_length': len(buffer), + 'full_buffer': buffer.hex(), + 'civilizations': self.CIVILIZATIONS, + 'game_modes': self.GAME_MODES + } + } + + return result + + def _get_civilization_name(self, civ_id: int) -> str: + """ + Konvertiert eine Zivilisations-ID zu einem lesbaren Namen. + + Args: + civ_id: Die Zivilisations-ID + + Returns: + str: Der Zivilisationsname + """ + return self.CIVILIZATIONS.get(civ_id, f"Unknown ({civ_id})") + + def _get_game_mode_name(self, mode_id: int) -> str: + """ + Konvertiert eine Game Mode ID zu einem lesbaren Namen. + + Args: + mode_id: Die Game Mode ID + + Returns: + str: Der Game Mode Name + """ + return self.GAME_MODES.get(mode_id, f"Unknown ({mode_id})") diff --git a/opengsq/protocols/directplay.py b/opengsq/protocols/directplay.py new file mode 100644 index 0000000..307b190 --- /dev/null +++ b/opengsq/protocols/directplay.py @@ -0,0 +1,195 @@ +import asyncio +import socket +from opengsq.protocol_base import ProtocolBase +from opengsq.responses.directplay.status import Status +from opengsq.binary_reader import BinaryReader + + +class DirectPlay(ProtocolBase): + """ + DirectPlay Protocol Base Class + + DirectPlay ist ein Netzwerkprotokoll, das von verschiedenen Spielen verwendet wird, + insbesondere von älteren Microsoft-Spielen wie Age of Empires 1 und 2. + + Das Protokoll funktioniert folgendermaßen: + 1. Ein lokaler TCP Socket wird auf Port 2300 geöffnet + 2. Eine UDP-Anfrage wird an Port 47624 des Spieleservers gesendet + 3. Der Spieleserver antwortet über TCP an unseren lokalen Port 2300 + + DirectPlay UDP-Payload Struktur (52 Bytes): + - Bytes 0-3: Header (34 00 b0 fa) + - Bytes 4-7: Protokoll Info (02 00 08 fc) + - Bytes 8-19: Padding/Reserved (alle 00) + - Bytes 20-23: "play" - DirectPlay Identifikation + - Bytes 24-27: Weitere Header-Info (02 00 0e 00) + - Bytes 28-43: Spiel-spezifische GUID (16 Bytes, unterscheidet Spiele) + - Bytes 44-47: Padding/Reserved (00 00 00 00) + - Bytes 48-51: Version/Type ID (unterscheidet Spielversionen) + """ + + full_name = "DirectPlay Protocol" + + # DirectPlay Konstanten + DIRECTPLAY_UDP_PORT = 47624 + DIRECTPLAY_TCP_PORT = 2300 + + def __init__(self, host: str, port: int = DIRECTPLAY_UDP_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + self._tcp_listen_port = self.DIRECTPLAY_TCP_PORT + + async def get_status(self) -> Status: + """ + Führt eine DirectPlay-Abfrage durch. + + Returns: + Status: Parsed server status information + """ + # Erstelle den UDP Query Packet (wird von Subklassen überschrieben) + query_packet = self._build_query_packet() + + # Führe die DirectPlay-Kommunikation durch + response_data = await self._directplay_communicate(query_packet) + + # Parse die Antwort (wird von Subklassen überschrieben) + parsed_data = self._parse_response(response_data) + + return Status(**parsed_data) + + async def _directplay_communicate(self, query_packet: bytes) -> bytes: + """ + Führt die DirectPlay-spezifische Kommunikation durch: + 1. Öffnet einen TCP Socket auf einem verfügbaren Port zum Empfangen der Antwort + 2. Sendet UDP Query an den Spieleserver + 3. Wartet auf TCP-Antwort + + Args: + query_packet: Das UDP-Paket, das an den Server gesendet wird + + Returns: + bytes: Die TCP-Antwort vom Server + """ + # Verwende asyncio.Future für saubere async communication + response_future = asyncio.Future() + actual_tcp_port = self._tcp_listen_port + + class DirectPlayTcpProtocol(asyncio.Protocol): + def __init__(self): + self.transport = None + self.received_data = b'' + + def connection_made(self, transport): + self.transport = transport + + def data_received(self, data): + self.received_data += data + # Setze das Future-Result mit den empfangenen Daten + if not response_future.done(): + response_future.set_result(self.received_data) + # Schließe die Verbindung nach dem Empfang der Daten + if self.transport: + self.transport.close() + + def connection_lost(self, exc): + if exc and not response_future.done(): + response_future.set_exception(Exception(f"Connection lost: {exc}")) + + try: + # TCP Server starten - versuche verschiedene Ports falls 2300 belegt ist + loop = asyncio.get_running_loop() + server = None + for port_offset in range(10): # Versuche Ports 2300-2309 + try: + actual_tcp_port = self._tcp_listen_port + port_offset + server = await loop.create_server( + DirectPlayTcpProtocol, + '0.0.0.0', + actual_tcp_port + ) + break + except OSError: + if port_offset == 9: # Letzter Versuch + raise Exception(f"Could not bind TCP server to ports {self._tcp_listen_port}-{actual_tcp_port}") + continue + + # Sicherstellen, dass der Server wirklich läuft + await server.start_serving() + await asyncio.sleep(0.1) # Kurz warten bis Server bereit ist + + # UDP Query senden + await self._send_udp_query(query_packet) + + # Warten auf TCP-Antwort mit asyncio.Future + response_data = await asyncio.wait_for(response_future, timeout=self._timeout) + + return response_data + + except asyncio.TimeoutError: + raise Exception(f"DirectPlay Timeout nach {self._timeout} Sekunden") + finally: + if server: + server.close() + await server.wait_closed() + + async def _send_udp_query(self, query_packet: bytes): + """ + Sendet den UDP Query an den Spieleserver. + + Args: + query_packet: Das UDP-Paket, das gesendet wird + """ + loop = asyncio.get_running_loop() + + # UDP Socket erstellen + transport, protocol = await loop.create_datagram_endpoint( + lambda: asyncio.DatagramProtocol(), + local_addr=('0.0.0.0', 0) + ) + + try: + # Query senden + transport.sendto(query_packet, (self._host, self._port)) + # Kurz warten um sicherzustellen, dass das Paket gesendet wurde + await asyncio.sleep(0.05) + finally: + transport.close() + + def _build_query_packet(self) -> bytes: + """ + Erstellt das UDP Query Packet. + Muss von Subklassen implementiert werden. + + Returns: + bytes: Das Query Packet + """ + raise NotImplementedError("Subclasses must implement _build_query_packet") + + def _parse_response(self, buffer: bytes) -> dict: + """ + Parsed die TCP-Antwort vom Server. + Muss von Subklassen implementiert werden. + + Args: + buffer: Die rohen Antwortdaten + + Returns: + dict: Geparste Server-Informationen + """ + raise NotImplementedError("Subclasses must implement _parse_response") + + def _read_string(self, br: BinaryReader, encoding: str = 'utf-8') -> str: + """ + Hilfsfunktion zum Lesen von Strings aus BinaryReader. + + Args: + br: BinaryReader instance + encoding: String encoding (default: utf-8) + + Returns: + str: Der gelesene String + """ + # Standard DirectPlay String Format (kann überschrieben werden) + length = br.read_uint16() + if length == 0: + return "" + return br.read_bytes(length).decode(encoding, errors='ignore') diff --git a/opengsq/responses/aoe1/__init__.py b/opengsq/responses/aoe1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opengsq/responses/aoe1/status.py b/opengsq/responses/aoe1/status.py new file mode 100644 index 0000000..00f19ac --- /dev/null +++ b/opengsq/responses/aoe1/status.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List +from opengsq.responses.directplay.status import Status as DirectPlayStatus + + +@dataclass +class Status(DirectPlayStatus): + """Age of Empires 1 specific status response""" + + # AoE1 spezifische Felder + epoch: str = "Stone Age" # Stone Age, Tool Age, Bronze Age, Iron Age + population_limit: int = 50 + resources_setting: str = "Standard" # Low, Standard, High + reveal_map: bool = False + starting_resources: str = "Standard" + victory_conditions: List[str] = None # Standard, Conquest, Ruins, Artifacts + + def __post_init__(self): + if self.victory_conditions is None: + self.victory_conditions = ["Conquest"] diff --git a/opengsq/responses/aoe2/__init__.py b/opengsq/responses/aoe2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opengsq/responses/aoe2/status.py b/opengsq/responses/aoe2/status.py new file mode 100644 index 0000000..e1f29a5 --- /dev/null +++ b/opengsq/responses/aoe2/status.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List +from opengsq.responses.directplay.status import Status as DirectPlayStatus + + +@dataclass +class Status(DirectPlayStatus): + """Age of Empires 2 specific status response""" + + # AoE2 spezifische Felder + age: str = "Dark Age" # Dark Age, Feudal Age, Castle Age, Imperial Age + population_limit: int = 200 + starting_age: str = "Dark Age" + resources_setting: str = "Standard" # Low, Standard, High + reveal_map: bool = False + map_size: str = "Normal" # Tiny, Small, Normal, Large, Giant + victory_conditions: List[str] = None # Standard, Conquest, Relics, Wonder, Time + teams_locked: bool = False + all_techs: bool = False + + def __post_init__(self): + if self.victory_conditions is None: + self.victory_conditions = ["Conquest"] diff --git a/opengsq/responses/directplay/__init__.py b/opengsq/responses/directplay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opengsq/responses/directplay/status.py b/opengsq/responses/directplay/status.py new file mode 100644 index 0000000..debd65e --- /dev/null +++ b/opengsq/responses/directplay/status.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import Union, List +from enum import IntEnum + + +class DirectPlayGameType(IntEnum): + """DirectPlay Game Types""" + UNKNOWN = 0 + AGE_OF_EMPIRES_1 = 1 + AGE_OF_EMPIRES_2 = 2 + + +@dataclass +class Player: + """DirectPlay Player Information""" + name: str + civilization: str = "" + team: int = 0 + color: int = 0 + ready: bool = False + + +@dataclass +class Status: + """DirectPlay Status Response""" + name: str + game_type: str + map: str + num_players: int + max_players: int + password_protected: bool + game_version: str + game_mode: str + difficulty: str + speed: str + players: List[Player] + raw: dict[str, Union[str, int, bool, list]] From 13999be239a0f66ad87a971f4cf94bdc5e3f26a5 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Mon, 15 Sep 2025 12:35:36 +0200 Subject: [PATCH 08/20] Updating AoE 1 and 2 Protocol for correct Gameserver and Player parsing --- opengsq/protocols/aoe1.py | 310 ++++++++++++++++++++++--- opengsq/protocols/aoe2.py | 300 +++++++++++++++++++++--- opengsq/protocols/directplay.py | 393 +++++++++++++++++++++++++++++++- 3 files changed, 940 insertions(+), 63 deletions(-) diff --git a/opengsq/protocols/aoe1.py b/opengsq/protocols/aoe1.py index d7345d7..bea2fbc 100644 --- a/opengsq/protocols/aoe1.py +++ b/opengsq/protocols/aoe1.py @@ -43,8 +43,7 @@ def _parse_response(self, buffer: bytes) -> dict: """ Parsed die TCP-Antwort vom AoE1 Server. - TODO: Dies ist ein Platzhalter. Die echte Parsing-Logik muss - durch Analyse der TCP-Pakete implementiert werden. + Erweitert die Basis-DirectPlay-Parsing um AoE1-spezifische Logik. Args: buffer: Die rohen TCP-Antwortdaten @@ -52,35 +51,288 @@ def _parse_response(self, buffer: bytes) -> dict: Returns: dict: Geparste AoE1 Server-Informationen """ - if len(buffer) < 4: - raise Exception("Antwort zu kurz für AoE1 DirectPlay Protokoll") + # Nutze die Basis-DirectPlay-Parsing-Logik + result = super()._parse_response(buffer) + + # AoE1-spezifische Anpassungen + result['game_type'] = 'Age of Empires' + + # Extrahiere echte Version-Informationen + version_info = self._extract_version_info(buffer) + if 'likely_version' in version_info: + result['game_version'] = version_info['likely_version'].replace('Age of Empires ', '') + elif 'detected_version' in version_info: + result['game_version'] = version_info['detected_version'].replace('Age of Empires ', '') + elif 'game_version' in version_info: + result['game_version'] = version_info['game_version'].replace('Age of Empires ', '') + else: + result['game_version'] = '1.0c' # Fallback + + # Versuche AoE1-spezifische Daten zu parsen + try: + aoe1_data = self._parse_aoe1_specific_data(buffer) + result.update(aoe1_data) + except Exception as e: + result['raw']['aoe1_parse_error'] = str(e) + + # Debug-Informationen hinzufügen + result['raw']['game_guid'] = self.AOE1_GAME_GUID + result['raw']['buffer_size'] = len(buffer) + result['raw']['buffer_preview'] = buffer[:50].hex() if len(buffer) > 50 else buffer.hex() + result['raw']['version_info'] = version_info + + return result + + def _parse_aoe1_specific_data(self, buffer: bytes) -> dict: + """ + Parsed AoE1-spezifische Daten aus der DirectPlay-Antwort. + + Args: + buffer: Die rohen Antwortdaten + Returns: + dict: AoE1-spezifische Daten + """ + result = {} + + if len(buffer) < 10: + return result + br = BinaryReader(buffer) - # Placeholder Parsing Logic - # TODO: Echte AoE1 Paket-Struktur implementieren - - # Beispiel DirectPlay Header lesen - magic = br.read_bytes(4) - - # Placeholder Werte - result = { - 'name': 'AoE1 Server (Placeholder)', - 'game_type': 'Age of Empires', - 'map': 'Unknown Map', - 'num_players': 0, - 'max_players': 8, - 'password_protected': False, - 'game_version': '1.0', - 'game_mode': 'Standard', - 'difficulty': 'Standard', - 'speed': 'Normal', - 'players': [], - 'raw': { - 'magic': magic.hex(), - 'buffer_length': len(buffer), - 'full_buffer': buffer.hex() - } - } + try: + # Skip DirectPlay Header (4 bytes) + br.read_bytes(4) + + # Versuche, AoE1-spezifische Strukturen zu erkennen + # AoE1 verwendet oft spezifische Byte-Sequenzen + + # Suche nach bekannten AoE1-Mustern + remaining_data = br.read_bytes(br.remaining_bytes()) + + # Suche nach Spielnamen (oft nach bestimmten Byte-Sequenzen) + game_name = self._extract_aoe1_game_name(remaining_data) + if game_name: + result['name'] = game_name + + # Versuche Spieleranzahl zu ermitteln + player_count = self._extract_aoe1_player_count(remaining_data) + if player_count >= 0: # 0 ist auch gültig (leerer Server) + result['num_players'] = player_count + + # Versuche Max Players zu ermitteln + max_players = self._extract_aoe1_max_players(remaining_data) + if max_players > 0: + result['max_players'] = max_players + + # Versuche Kartennamen zu extrahieren + map_name = self._extract_aoe1_map_name(remaining_data) + if map_name: + result['map'] = map_name + + # AoE1-spezifische Spielmodi + game_mode = self._extract_aoe1_game_mode(remaining_data) + if game_mode: + result['game_mode'] = game_mode + + except Exception as e: + result['aoe1_specific_error'] = str(e) return result + + def _extract_aoe1_game_name(self, data: bytes) -> str: + """ + Versucht, den Spielnamen aus den AoE1-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Spielname oder leer + """ + try: + # Der Spielname ist typischerweise am Ende des DirectPlay-Pakets + # als length-prefixed UTF-16LE String + + # Suche nach length-prefixed Unicode-Strings + # Typischerweise bei den letzten ~50 Bytes des Pakets + search_start = max(0, len(data) - 100) + + # Suche nach 16-bit Length-Prefix für UTF-16LE String + for i in range(search_start, len(data) - 8, 2): + if i + 2 < len(data): + # Lese 16-bit Längenwert (little-endian) + potential_length = int.from_bytes(data[i:i+2], 'little') + + # Plausible Länge für einen Spielnamen (6-200 chars = 12-400 bytes für UTF-16LE) + if 12 <= potential_length <= 400 and potential_length % 2 == 0: + # Der String kann Padding haben - prüfe beide Varianten + for padding in [0, 2]: # Mit und ohne 2-Byte Padding + name_start = i + 2 + padding + + # Begrenze die Länge auf das verfügbare Data + available_length = len(data) - name_start + effective_length = min(potential_length - padding, available_length) + + if effective_length > 0 and name_start < len(data): + name_bytes = data[name_start:name_start + effective_length] + + try: + decoded = name_bytes.decode('utf-16le', errors='strict') + clean_name = decoded.rstrip('\x00').strip() + + # Validierung: Name sollte druckbare Zeichen enthalten + if (len(clean_name) >= 3 and + all(ord(c) >= 32 or c.isspace() for c in clean_name) and + any(c.isalnum() for c in clean_name)): + return clean_name + except UnicodeDecodeError: + continue + + except Exception: + pass + + return "" + + def _extract_aoe1_player_count(self, data: bytes) -> int: + """ + Versucht, die Spieleranzahl aus den AoE1-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die Spieleranzahl oder 0 + """ + try: + # DirectPlay Session Data beginnt nach dem GUID (ab Offset 40 vom Header) + # Die Spielerzahl steht typischerweise bei festen Offsets + + # Bei AoE1 sind die Session-Daten strukturiert: + # Offset 64-67: Max Players (8) + # Offset 68-71: Current Players (1) + + if len(data) >= 48: # Genug Daten für Session Info + # Offset 40 (nach 4-byte header) entspricht Offset 68 in absoluten Koordinaten + session_start = 40 # Nach dem Header + + # Max players bei Offset +24 im Session-Bereich + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 # Offset 64 absolut + current_players_offset = session_start + 28 # Offset 68 absolut + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + # Validierung der Werte + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return current_players + + # Fallback: Suche nach plausiblen Werten + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + # Suche nach dem Muster: current_players, max_players + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return value + + except Exception: + pass + + return 0 + + def _extract_aoe1_max_players(self, data: bytes) -> int: + """ + Versucht, die maximale Spieleranzahl aus den AoE1-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die maximale Spieleranzahl oder 0 + """ + try: + # Verwende dieselbe Logik wie bei player_count, aber für max_players + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 # Offset 64 absolut + current_players_offset = session_start + 28 # Offset 68 absolut + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + # Validierung der Werte + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return max_players + + # Fallback: Suche nach dem zweiten Wert im Spieler-Paar + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + # Suche nach dem Muster: current_players, max_players + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return next_value + + except Exception: + pass + + return 8 # Standard für AoE1 + + def _extract_aoe1_map_name(self, data: bytes) -> str: + """ + Versucht, den Kartennamen aus den AoE1-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Kartenname oder leer + """ + # Bekannte AoE1-Kartennamen + known_maps = [ + "River Nile", "Continental", "Coastal", "Inland", "Highland", + "Mediterranean", "Hill Country", "Large Islands", "Small Islands", + "King of the Hill", "Unknown", "Random Map" + ] + + try: + # Suche nach bekannten Kartennamen in den Daten + data_str = data.decode('ascii', errors='ignore').lower() + + for map_name in known_maps: + if map_name.lower() in data_str: + return map_name + + except Exception: + pass + + return "Unknown Map" + + def _extract_aoe1_game_mode(self, data: bytes) -> str: + """ + Versucht, den Spielmodus aus den AoE1-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Spielmodus oder leer + """ + # AoE1 Spielmodi + game_modes = ["Random Map", "Death Match", "Scenario"] + + try: + data_str = data.decode('ascii', errors='ignore').lower() + + for mode in game_modes: + if mode.lower() in data_str: + return mode + + except Exception: + pass + + return "Random Map" # Standard-Modus diff --git a/opengsq/protocols/aoe2.py b/opengsq/protocols/aoe2.py index 2235e5c..b51cf1f 100644 --- a/opengsq/protocols/aoe2.py +++ b/opengsq/protocols/aoe2.py @@ -78,8 +78,7 @@ def _parse_response(self, buffer: bytes) -> dict: """ Parsed die TCP-Antwort vom AoE2 Server. - TODO: Dies ist ein Platzhalter. Die echte Parsing-Logik muss - durch Analyse der TCP-Pakete implementiert werden. + Erweitert die Basis-DirectPlay-Parsing um AoE2-spezifische Logik. Args: buffer: Die rohen TCP-Antwortdaten @@ -87,41 +86,280 @@ def _parse_response(self, buffer: bytes) -> dict: Returns: dict: Geparste AoE2 Server-Informationen """ - if len(buffer) < 4: - raise Exception("Antwort zu kurz für AoE2 DirectPlay Protokoll") + # Nutze die Basis-DirectPlay-Parsing-Logik + result = super()._parse_response(buffer) + + # AoE2-spezifische Anpassungen + result['game_type'] = 'Age of Empires II' + + # Extrahiere echte Version-Informationen + version_info = self._extract_version_info(buffer) + if 'likely_version' in version_info: + result['game_version'] = version_info['likely_version'].replace('Age of Empires II ', '') + elif 'detected_version' in version_info: + result['game_version'] = version_info['detected_version'].replace('Age of Empires II ', '') + elif 'game_version' in version_info: + result['game_version'] = version_info['game_version'].replace('Age of Empires II ', '') + else: + result['game_version'] = '2.0a' # Fallback + + # Versuche AoE2-spezifische Daten zu parsen + try: + aoe2_data = self._parse_aoe2_specific_data(buffer) + result.update(aoe2_data) + except Exception as e: + result['raw']['aoe2_parse_error'] = str(e) + + # Debug-Informationen hinzufügen + result['raw']['game_guid'] = self.AOE2_GAME_GUID + result['raw']['buffer_size'] = len(buffer) + result['raw']['buffer_preview'] = buffer[:50].hex() if len(buffer) > 50 else buffer.hex() + result['raw']['version_info'] = version_info + result['raw']['civilizations'] = self.CIVILIZATIONS + result['raw']['game_modes'] = self.GAME_MODES + + return result + + def _parse_aoe2_specific_data(self, buffer: bytes) -> dict: + """ + Parsed AoE2-spezifische Daten aus der DirectPlay-Antwort. + + Args: + buffer: Die rohen Antwortdaten + Returns: + dict: AoE2-spezifische Daten + """ + result = {} + + if len(buffer) < 10: + return result + br = BinaryReader(buffer) - # Placeholder Parsing Logic - # TODO: Echte AoE2 Paket-Struktur implementieren - - # Beispiel DirectPlay Header lesen - magic = br.read_bytes(4) - - # Placeholder Werte - result = { - 'name': 'AoE2 Server (Placeholder)', - 'game_type': 'Age of Empires II', - 'map': 'Unknown Map', - 'num_players': 0, - 'max_players': 8, - 'password_protected': False, - 'game_version': '2.0', - 'game_mode': 'Random Map', - 'difficulty': 'Standard', - 'speed': 'Normal', - 'players': [], - 'raw': { - 'magic': magic.hex(), - 'buffer_length': len(buffer), - 'full_buffer': buffer.hex(), - 'civilizations': self.CIVILIZATIONS, - 'game_modes': self.GAME_MODES - } - } + try: + # Skip DirectPlay Header (4 bytes) + br.read_bytes(4) + + # Versuche, AoE2-spezifische Strukturen zu erkennen + remaining_data = br.read_bytes(br.remaining_bytes()) + + # Suche nach Spielnamen (AoE2 verwendet ASCII-Strings) + game_name = self._extract_aoe2_game_name(remaining_data) + if game_name: + result['name'] = game_name + + # Versuche Spieleranzahl zu ermitteln (ähnlich wie AoE1) + player_count = self._extract_aoe2_player_count(remaining_data) + if player_count >= 0: + result['num_players'] = player_count + + # Versuche Max Players zu ermitteln + max_players = self._extract_aoe2_max_players(remaining_data) + if max_players > 0: + result['max_players'] = max_players + + # Versuche Kartennamen zu extrahieren + map_name = self._extract_aoe2_map_name(remaining_data) + if map_name: + result['map'] = map_name + + # AoE2-spezifische Spielmodi + game_mode = self._extract_aoe2_game_mode(remaining_data) + if game_mode: + result['game_mode'] = game_mode + + except Exception as e: + result['aoe2_specific_error'] = str(e) return result + def _extract_aoe2_game_name(self, data: bytes) -> str: + """ + Versucht, den Spielnamen aus den AoE2-Daten zu extrahieren. + + AoE2 verwendet ASCII-Strings mit 32-bit Length-Prefix. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Spielname oder leer + """ + try: + # AoE2 String-Format: 32-bit length prefix + ASCII string + null terminator + search_start = max(0, len(data) - 100) + + for i in range(search_start, len(data) - 8, 4): + if i + 4 < len(data): + # Lese 32-bit Längenwert (little-endian) + potential_length = int.from_bytes(data[i:i+4], 'little') + + # Plausible Länge für einen Spielnamen (3-200 chars für ASCII, kann auch komplette String-Sektion sein) + if 3 <= potential_length <= 200: + name_start = i + 4 + + # Begrenze auf verfügbare Daten + available_length = len(data) - name_start + effective_length = min(potential_length, available_length) + + if effective_length > 0: + name_bytes = data[name_start:name_start + effective_length] + + try: + # AoE2 verwendet ASCII/UTF-8 encoding + decoded = name_bytes.decode('ascii', errors='strict') + + # Finde den ersten null-terminierten String + null_pos = decoded.find('\x00') + if null_pos >= 0: + clean_name = decoded[:null_pos].strip() + else: + clean_name = decoded.strip() + + # Validierung: Name sollte druckbare Zeichen enthalten + if (len(clean_name) >= 3 and + all(ord(c) >= 32 or c.isspace() for c in clean_name) and + any(c.isalnum() for c in clean_name)): + return clean_name + except UnicodeDecodeError: + continue + + except Exception: + pass + + return "" + + def _extract_aoe2_player_count(self, data: bytes) -> int: + """ + Versucht, die Spieleranzahl aus den AoE2-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die Spieleranzahl oder 0 + """ + try: + # Verwende ähnliche Logik wie bei AoE1 + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 + current_players_offset = session_start + 28 + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + # Validierung der Werte (AoE2 unterstützt bis zu 8 Spieler) + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return current_players + + # Fallback: Suche nach plausiblen Werten + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return value + + except Exception: + pass + + return 0 + + def _extract_aoe2_max_players(self, data: bytes) -> int: + """ + Versucht, die maximale Spieleranzahl aus den AoE2-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die maximale Spieleranzahl oder 0 + """ + try: + # Verwende dieselbe Logik wie bei player_count, aber für max_players + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 + current_players_offset = session_start + 28 + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return max_players + + # Fallback: Suche nach dem zweiten Wert im Spieler-Paar + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return next_value + + except Exception: + pass + + return 8 # Standard für AoE2 + + def _extract_aoe2_map_name(self, data: bytes) -> str: + """ + Versucht, den Kartennamen aus den AoE2-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Kartenname oder leer + """ + # Bekannte AoE2-Kartennamen + known_maps = [ + "Arabia", "Black Forest", "Baltic", "Mediterranean", "Rivers", + "Coastal", "Continental", "Highland", "Islands", "Team Islands", + "Random Map", "Archipelago", "Arena", "Fortress", "Gold Rush", + "Nomad", "Oasis", "Random Land Map", "Scandinavia" + ] + + try: + # Suche nach bekannten Kartennamen in den ASCII-Daten + data_str = data.decode('ascii', errors='ignore').lower() + + for map_name in known_maps: + if map_name.lower() in data_str: + return map_name + + except Exception: + pass + + return "Unknown Map" + + def _extract_aoe2_game_mode(self, data: bytes) -> str: + """ + Versucht, den Spielmodus aus den AoE2-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Spielmodus oder leer + """ + try: + data_str = data.decode('ascii', errors='ignore').lower() + + for mode_id, mode_name in self.GAME_MODES.items(): + if mode_name.lower() in data_str: + return mode_name + + except Exception: + pass + + return "Random Map" # Standard-Modus + def _get_civilization_name(self, civ_id: int) -> str: """ Konvertiert eine Zivilisations-ID zu einem lesbaren Namen. diff --git a/opengsq/protocols/directplay.py b/opengsq/protocols/directplay.py index 307b190..9d39bb7 100644 --- a/opengsq/protocols/directplay.py +++ b/opengsq/protocols/directplay.py @@ -54,7 +54,16 @@ async def get_status(self) -> Status: # Parse die Antwort (wird von Subklassen überschrieben) parsed_data = self._parse_response(response_data) - return Status(**parsed_data) + # Filtere nur gültige Status-Parameter + status_fields = { + 'name', 'game_type', 'map', 'num_players', 'max_players', + 'password_protected', 'game_version', 'game_mode', + 'difficulty', 'speed', 'players', 'raw' + } + + filtered_data = {k: v for k, v in parsed_data.items() if k in status_fields} + + return Status(**filtered_data) async def _directplay_communicate(self, query_packet: bytes) -> bytes: """ @@ -167,7 +176,7 @@ def _build_query_packet(self) -> bytes: def _parse_response(self, buffer: bytes) -> dict: """ Parsed die TCP-Antwort vom Server. - Muss von Subklassen implementiert werden. + Kann von Subklassen überschrieben werden für spezifische Implementierungen. Args: buffer: Die rohen Antwortdaten @@ -175,7 +184,188 @@ def _parse_response(self, buffer: bytes) -> dict: Returns: dict: Geparste Server-Informationen """ - raise NotImplementedError("Subclasses must implement _parse_response") + if len(buffer) < 4: + raise Exception("DirectPlay Antwort zu kurz") + + br = BinaryReader(buffer) + + # DirectPlay Header lesen + magic = br.read_bytes(4) + + # Basis-Parsing für DirectPlay-Pakete + result = { + 'name': 'DirectPlay Server', + 'game_type': 'DirectPlay Game', + 'map': 'Unknown Map', + 'num_players': 0, + 'max_players': 8, + 'password_protected': False, + 'game_version': '1.0', + 'game_mode': 'Standard', + 'difficulty': 'Standard', + 'speed': 'Normal', + 'players': [], + 'raw': { + 'magic': magic.hex(), + 'buffer_length': len(buffer), + 'full_buffer': buffer.hex() + } + } + + # Versuche weitere Daten zu parsen, falls vorhanden + try: + result.update(self._parse_directplay_data(br)) + except Exception as e: + # Fallback bei Parsing-Fehlern + result['raw']['parse_error'] = str(e) + + return result + + def _parse_directplay_data(self, br: BinaryReader) -> dict: + """ + Parsed erweiterte DirectPlay-Daten basierend auf Wireshark-Implementierung. + + Args: + br: BinaryReader instance mit verbleibendem Buffer + + Returns: + dict: Geparste DirectPlay-Daten + """ + result = {} + + try: + # DirectPlay-Protokoll basiert auf Sessions und Messages + # Versuche, bekannte DirectPlay-Strukturen zu erkennen + + if br.remaining_bytes() >= 8: + # Session ID (32-bit) + session_id = br.read_uint32() + result['session_id'] = session_id + + # Message Type (32-bit) + message_type = br.read_uint32() + result['message_type'] = message_type + + # Unterschiedliche Message Types verarbeiten + if message_type == 0x0001: # ENUM_SESSIONS_REPLY + result.update(self._parse_enum_sessions_reply(br)) + elif message_type == 0x0002: # SESSION_DESCRIPTION + result.update(self._parse_session_description(br)) + elif message_type == 0x0008: # PLAYER_DATA + result.update(self._parse_player_data(br)) + + except Exception as e: + result['parse_warning'] = f"Partial parsing error: {str(e)}" + + return result + + def _parse_enum_sessions_reply(self, br: BinaryReader) -> dict: + """Parse ENUM_SESSIONS_REPLY message""" + result = {} + + try: + if br.remaining_bytes() >= 4: + # Session count + session_count = br.read_uint32() + result['session_count'] = session_count + + # Parse each session + sessions = [] + for i in range(min(session_count, 10)): # Limit für Sicherheit + if br.remaining_bytes() < 16: + break + + session = {} + + # Session GUID (16 bytes) + guid_bytes = br.read_bytes(16) + session['guid'] = guid_bytes.hex() + + # Session name length und name + if br.remaining_bytes() >= 2: + name_length = br.read_uint16() + if br.remaining_bytes() >= name_length: + session['name'] = br.read_bytes(name_length).decode('utf-16le', errors='ignore').rstrip('\x00') + + sessions.append(session) + + result['sessions'] = sessions + if sessions: + result['name'] = sessions[0].get('name', 'DirectPlay Game') + + except Exception as e: + result['enum_sessions_error'] = str(e) + + return result + + def _parse_session_description(self, br: BinaryReader) -> dict: + """Parse SESSION_DESCRIPTION message""" + result = {} + + try: + if br.remaining_bytes() >= 8: + # Max players + max_players = br.read_uint32() + current_players = br.read_uint32() + + result['max_players'] = max_players + result['num_players'] = current_players + + # Session flags + if br.remaining_bytes() >= 4: + flags = br.read_uint32() + result['password_protected'] = bool(flags & 0x1) + result['session_flags'] = flags + + except Exception as e: + result['session_desc_error'] = str(e) + + return result + + def _parse_player_data(self, br: BinaryReader) -> dict: + """Parse PLAYER_DATA message""" + result = {} + + try: + players = [] + + # Player count + if br.remaining_bytes() >= 4: + player_count = br.read_uint32() + result['num_players'] = player_count + + # Parse each player + for i in range(min(player_count, 16)): # Limit für Sicherheit + if br.remaining_bytes() < 8: + break + + player = {} + + # Player ID + player_id = br.read_uint32() + player['id'] = player_id + + # Player name length + name_length = br.read_uint16() + if br.remaining_bytes() >= name_length: + # Player name (Unicode) + name_bytes = br.read_bytes(name_length) + player['name'] = name_bytes.decode('utf-16le', errors='ignore').rstrip('\x00') + + # Player flags/status + if br.remaining_bytes() >= 2: + player_flags = br.read_uint16() + player['ready'] = bool(player_flags & 0x1) + player['flags'] = player_flags + + players.append(player) + + result['players'] = players + + except Exception as e: + result['player_data_error'] = str(e) + + return result def _read_string(self, br: BinaryReader, encoding: str = 'utf-8') -> str: """ @@ -193,3 +383,200 @@ def _read_string(self, br: BinaryReader, encoding: str = 'utf-8') -> str: if length == 0: return "" return br.read_bytes(length).decode(encoding, errors='ignore') + + def _read_directplay_string(self, br: BinaryReader) -> str: + """ + Liest einen DirectPlay-String (Unicode, length-prefixed). + + Args: + br: BinaryReader instance + + Returns: + str: Der gelesene String + """ + if br.remaining_bytes() < 2: + return "" + + length = br.read_uint16() + if length == 0 or br.remaining_bytes() < length: + return "" + + # DirectPlay verwendet oft UTF-16LE für Strings + string_bytes = br.read_bytes(length) + return string_bytes.decode('utf-16le', errors='ignore').rstrip('\x00') + + def _read_cstring(self, br: BinaryReader, encoding: str = 'utf-8') -> str: + """ + Liest einen null-terminierten C-String. + + Args: + br: BinaryReader instance + encoding: String encoding + + Returns: + str: Der gelesene String + """ + string_bytes = b'' + while br.remaining_bytes() > 0: + byte = br.read_bytes(1) + if byte == b'\x00': + break + string_bytes += byte + + return string_bytes.decode(encoding, errors='ignore') + + def _validate_directplay_magic(self, magic: bytes) -> bool: + """ + Validiert DirectPlay Magic Bytes. + + Args: + magic: Die ersten 4 Bytes des Pakets + + Returns: + bool: True wenn gültiger DirectPlay Magic + """ + # DirectPlay verwendet verschiedene Magic Values + known_magic = [ + b'\x34\x00\xb0\xfa', # Standard DirectPlay + b'\x20\x00\x00\x00', # Alternative DirectPlay + b'\x10\x00\x00\x00', # DirectPlay Session + ] + + return magic in known_magic + + def _extract_game_guid(self, payload: bytes) -> str: + """ + Extrahiert die Game GUID aus dem DirectPlay Payload. + + Args: + payload: Das DirectPlay UDP Payload + + Returns: + str: Die Game GUID als String oder leer bei Fehlern + """ + try: + # Game GUID ist bei Offset 28-43 (16 bytes) + if len(payload) >= 44: + guid_bytes = payload[28:44] + # Konvertiere zu standard GUID Format + return self._format_guid(guid_bytes) + except Exception: + pass + + return "" + + def _format_guid(self, guid_bytes: bytes) -> str: + """ + Formatiert GUID Bytes zu Standard-GUID-String. + + Args: + guid_bytes: 16 Bytes der GUID + + Returns: + str: Formatierte GUID + """ + if len(guid_bytes) != 16: + return "" + + # Microsoft GUID Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + return (f"{guid_bytes[0:4][::-1].hex()}-" + f"{guid_bytes[4:6][::-1].hex()}-" + f"{guid_bytes[6:8][::-1].hex()}-" + f"{guid_bytes[8:10].hex()}-" + f"{guid_bytes[10:16].hex()}") + + def _get_debug_info(self, buffer: bytes) -> dict: + """ + Hilfsfunktion für Debugging - gibt detaillierte Paket-Informationen zurück. + + Args: + buffer: Die rohen Antwortdaten + + Returns: + dict: Debug-Informationen + """ + debug_info = { + 'buffer_size': len(buffer), + 'buffer_hex': buffer[:100].hex() if len(buffer) > 100 else buffer.hex(), + 'ascii_preview': buffer[:50].decode('ascii', errors='replace') if len(buffer) > 0 else "", + } + + if len(buffer) >= 4: + magic = buffer[:4] + debug_info['magic_hex'] = magic.hex() + debug_info['magic_valid'] = self._validate_directplay_magic(magic) + + # Versuche Game GUID zu extrahieren + if hasattr(self, '_build_query_packet'): + try: + query_packet = self._build_query_packet() + debug_info['game_guid'] = self._extract_game_guid(query_packet) + except Exception: + pass + + return debug_info + + def _extract_version_info(self, buffer: bytes) -> dict: + """ + Extrahiert Version-Informationen aus DirectPlay-Paketen. + + Args: + buffer: Die rohen Antwortdaten + + Returns: + dict: Version-Informationen + """ + version_info = {} + + if len(buffer) < 52: + return version_info + + try: + # Magic Number Analysis (TCP Response) + magic = int.from_bytes(buffer[0:4], 'little') + version_info['magic_number'] = f"0x{magic:08x}" + + # Bekannte Magic Numbers für verschiedene Versionen + known_versions = { + 0x8e00b0fa: "Age of Empires 1.0c", + 0x8800b0fa: "Age of Empires II 2.0a", + 0x3400b0fa: "DirectPlay Query" + } + + if magic in known_versions: + version_info['detected_version'] = known_versions[magic] + + # UDP Query Version ID (wenn verfügbar im Original Query) + if hasattr(self, '_build_query_packet'): + try: + query_packet = self._build_query_packet() + if len(query_packet) >= 52: + udp_version_id = int.from_bytes(query_packet[48:52], 'little') + version_info['udp_version_id'] = udp_version_id + + # Bekannte UDP Version IDs + udp_versions = { + 1: "Age of Empires 1.0", + 17: "Age of Empires II 2.0" + } + + if udp_version_id in udp_versions: + version_info['game_version'] = udp_versions[udp_version_id] + except Exception: + pass + + # Session Data Version Analysis (Offset 84) + if len(buffer) >= 88: + session_version = int.from_bytes(buffer[84:88], 'little') + version_info['session_version'] = session_version + + # Charakteristische Session Version Values + if session_version == 567281: # 0x0008a7f1 + version_info['likely_version'] = "Age of Empires 1.0c" + elif session_version == 2274156: # 0x0022b36c + version_info['likely_version'] = "Age of Empires II 2.0a" + + except Exception as e: + version_info['version_error'] = str(e) + + return version_info From 18b38ec84ed601d9ec869fa88f0c52fe0a551768 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 8 Oct 2025 13:00:15 +0200 Subject: [PATCH 09/20] Adding Alien vs. Predator 2 as supported Game --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_avp2/index.rst | 11 ++++ .../protocols/test_avp2/test_get_basic.rst | 12 ++++ .../protocols/test_avp2/test_get_info.rst | 21 ++++++ .../protocols/test_avp2/test_get_players.rst | 15 +++++ .../protocols/test_avp2/test_get_rules.rst | 38 +++++++++++ .../protocols/test_avp2/test_get_status.rst | 64 +++++++++++++++++++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/avp2.py | 30 +++++++++ tests/protocols/test_avp2.py | 46 +++++++++++++ 10 files changed, 239 insertions(+) create mode 100644 docs/tests/protocols/test_avp2/index.rst create mode 100644 docs/tests/protocols/test_avp2/test_get_basic.rst create mode 100644 docs/tests/protocols/test_avp2/test_get_info.rst create mode 100644 docs/tests/protocols/test_avp2/test_get_players.rst create mode 100644 docs/tests/protocols/test_avp2/test_get_rules.rst create mode 100644 docs/tests/protocols/test_avp2/test_get_status.rst create mode 100644 opengsq/protocols/avp2.py create mode 100644 tests/protocols/test_avp2.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index a715a3c..749979b 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -19,6 +19,7 @@ Protocols Tests test_gamespy3/index test_kaillera/index test_toxikk/index + test_avp2/index test_gamespy1/index test_scum/index test_raknet/index diff --git a/docs/tests/protocols/test_avp2/index.rst b/docs/tests/protocols/test_avp2/index.rst new file mode 100644 index 0000000..6d1ff16 --- /dev/null +++ b/docs/tests/protocols/test_avp2/index.rst @@ -0,0 +1,11 @@ +.. _test_avp2: + +test_avp2 +========= + +.. toctree:: + test_get_basic + test_get_players + test_get_status + test_get_info + test_get_rules diff --git a/docs/tests/protocols/test_avp2/test_get_basic.rst b/docs/tests/protocols/test_avp2/test_get_basic.rst new file mode 100644 index 0000000..e3c8de5 --- /dev/null +++ b/docs/tests/protocols/test_avp2/test_get_basic.rst @@ -0,0 +1,12 @@ +test_get_basic +============== + +Here are the results for the test method. + +.. code-block:: json + + { + "gamename": "avp2", + "gamever": "1.0.9.6", + "location": "0" + } diff --git a/docs/tests/protocols/test_avp2/test_get_info.rst b/docs/tests/protocols/test_avp2/test_get_info.rst new file mode 100644 index 0000000..da13233 --- /dev/null +++ b/docs/tests/protocols/test_avp2/test_get_info.rst @@ -0,0 +1,21 @@ +test_get_info +============= + +Here are the results for the test method. + +.. code-block:: json + + { + "mspatch": "2.4", + "website": "www.avp2msp.com", + "hostname": "Aliens vs. Predator 2 [D]", + "hostport": "27888", + "mapname": "dm_verloc", + "gametype": "Team DM", + "gamemode": "openplaying", + "numplayers": "1", + "maxplayers": "16", + "lock": "0", + "ded": "1", + "bandwidth": "10000000" + } diff --git a/docs/tests/protocols/test_avp2/test_get_players.rst b/docs/tests/protocols/test_avp2/test_get_players.rst new file mode 100644 index 0000000..a2af9f9 --- /dev/null +++ b/docs/tests/protocols/test_avp2/test_get_players.rst @@ -0,0 +1,15 @@ +test_get_players +================ + +Here are the results for the test method. + +.. code-block:: json + + [ + { + "player": "1-NoName", + "race": "1", + "score": "0", + "ping": "10" + } + ] diff --git a/docs/tests/protocols/test_avp2/test_get_rules.rst b/docs/tests/protocols/test_avp2/test_get_rules.rst new file mode 100644 index 0000000..c6616cd --- /dev/null +++ b/docs/tests/protocols/test_avp2/test_get_rules.rst @@ -0,0 +1,38 @@ +test_get_rules +============== + +Here are the results for the test method. + +.. code-block:: json + + { + "maxa": "8", + "maxm": "8", + "maxp": "8", + "maxc": "8", + "frags": "0", + "score": "0", + "time": "1800", + "rounds": "0", + "lc": "0", + "hrace": "0", + "prace": "0", + "ratio": "0", + "srace": "0", + "mrace": "0", + "drace": "0", + "dlive": "0", + "arace": "0", + "alive": "0", + "speed": "100", + "respawn": "100", + "damage": "100", + "hitloc": "1", + "ff": "0", + "fn": "0", + "mask": "0", + "class": "1", + "exosuit": "4", + "queen": "1", + "cscore": "0" + } diff --git a/docs/tests/protocols/test_avp2/test_get_status.rst b/docs/tests/protocols/test_avp2/test_get_status.rst new file mode 100644 index 0000000..3c93d17 --- /dev/null +++ b/docs/tests/protocols/test_avp2/test_get_status.rst @@ -0,0 +1,64 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "gamename": "avp2", + "gamever": "1.0.9.6", + "location": "0", + "mspatch": "2.4", + "website": "www.avp2msp.com", + "hostname": "Aliens vs. Predator 2 [D]", + "hostport": "27888", + "mapname": "dm_verloc", + "gametype": "Team DM", + "gamemode": "openplaying", + "numplayers": "1", + "maxplayers": "16", + "lock": "0", + "ded": "1", + "bandwidth": "10000000", + "maxa": "8", + "maxm": "8", + "maxp": "8", + "maxc": "8", + "frags": "0", + "score": "0", + "time": "1800", + "rounds": "0", + "lc": "0", + "hrace": "0", + "prace": "0", + "ratio": "0", + "srace": "0", + "mrace": "0", + "drace": "0", + "dlive": "0", + "arace": "0", + "alive": "0", + "speed": "100", + "respawn": "100", + "damage": "100", + "hitloc": "1", + "ff": "0", + "fn": "0", + "mask": "0", + "class": "1", + "exosuit": "4", + "queen": "1", + "cscore": "0" + }, + "players": [ + { + "player": "1-NoName", + "race": "1", + "score": "0", + "ping": "10" + } + ], + "teams": [] + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 4c7ee50..02ecb8b 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -1,6 +1,7 @@ from opengsq.protocols.aoe1 import AoE1 from opengsq.protocols.aoe2 import AoE2 from opengsq.protocols.ase import ASE +from opengsq.protocols.avp2 import AVP2 from opengsq.protocols.battlefield import Battlefield from opengsq.protocols.directplay import DirectPlay from opengsq.protocols.doom3 import Doom3 diff --git a/opengsq/protocols/avp2.py b/opengsq/protocols/avp2.py new file mode 100644 index 0000000..4aa0219 --- /dev/null +++ b/opengsq/protocols/avp2.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from opengsq.protocols.gamespy1 import GameSpy1 + + +class AVP2(GameSpy1): + """Alien vs Predator 2 Protocol (based on GameSpy1)""" + + full_name = "Alien vs Predator 2" + + def __init__(self, host: str, port: int = 27888, timeout: float = 5.0): + """ + Initialize the AVP2 protocol. + + :param host: The server host address + :param port: The server port (default: 27888) + :param timeout: The timeout for the connection (default: 5.0 seconds) + """ + super().__init__(host, port, timeout) + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + avp2 = AVP2(host="172.29.100.29", port=27888, timeout=5.0) + status = await avp2.get_info() + print(status) + + asyncio.run(main_async()) diff --git a/tests/protocols/test_avp2.py b/tests/protocols/test_avp2.py new file mode 100644 index 0000000..8bb87da --- /dev/null +++ b/tests/protocols/test_avp2.py @@ -0,0 +1,46 @@ +import pytest +from opengsq.protocols.avp2 import AVP2 + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True +handler.delay_per_test = 1 + +test = AVP2(host="172.29.100.29", port=27888) + + +@pytest.mark.asyncio +async def test_get_basic(): + result = await test.get_basic() + await handler.save_result("test_get_basic", result) + + +@pytest.mark.asyncio +async def test_get_info(): + result = await test.get_info() + await handler.save_result("test_get_info", result) + + +@pytest.mark.asyncio +async def test_get_rules(): + result = await test.get_rules() + await handler.save_result("test_get_rules", result) + + +@pytest.mark.asyncio +async def test_get_players(): + result = await test.get_players() + await handler.save_result("test_get_players", result) + + +@pytest.mark.asyncio +async def test_get_status(): + result = await test.get_status() + await handler.save_result("test_get_status", result) + + +@pytest.mark.asyncio +async def test_get_teams(): + result = await test.get_teams() + await handler.save_result("test_get_teams", result) From a3f543f597382162b1a0a9b350c8dfd7354b3676 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Wed, 8 Oct 2025 13:51:41 +0200 Subject: [PATCH 10/20] Adding Battlefield 2 Support --- docs/tests/protocols/index.rst | 1 + .../protocols/test_battlefield2/index.rst | 7 + .../test_battlefield2/test_get_status.rst | 71 +++++++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/battlefield2.py | 201 ++++++++++++++++++ tests/protocols/test_battlefield2.py | 16 ++ 6 files changed, 297 insertions(+) create mode 100644 docs/tests/protocols/test_battlefield2/index.rst create mode 100644 docs/tests/protocols/test_battlefield2/test_get_status.rst create mode 100644 opengsq/protocols/battlefield2.py create mode 100644 tests/protocols/test_battlefield2.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 749979b..6fe59b4 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -5,6 +5,7 @@ Protocols Tests .. toctree:: test_flatout2/index + test_battlefield2/index test_source/index test_won/index test_fivem/index diff --git a/docs/tests/protocols/test_battlefield2/index.rst b/docs/tests/protocols/test_battlefield2/index.rst new file mode 100644 index 0000000..842fd84 --- /dev/null +++ b/docs/tests/protocols/test_battlefield2/index.rst @@ -0,0 +1,7 @@ +.. _test_battlefield2: + +test_battlefield2 +================= + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_battlefield2/test_get_status.rst b/docs/tests/protocols/test_battlefield2/test_get_status.rst new file mode 100644 index 0000000..9220e9d --- /dev/null +++ b/docs/tests/protocols/test_battlefield2/test_get_status.rst @@ -0,0 +1,71 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "hostname": "Default Server Name", + "gamename": "battlefield2", + "gamever": "1.0.2442.0", + "mapname": "Daqing_oilfields", + "gametype": "gpm_cq", + "gamevariant": "bf2", + "numplayers": "1", + "maxplayers": "64", + "gamemode": "openplaying", + "password": "0", + "timelimit": "0", + "roundtime": "3", + "hostport": "16567", + "bf2_dedicated": "0", + "bf2_ranked": "0", + "bf2_anticheat": "0", + "bf2_os": "win32", + "bf2_autorec": "0", + "bf2_d_idx": "http://", + "bf2_d_dl": "http://", + "bf2_voip": "1", + "bf2_autobalanced": "0", + "bf2_friendlyfire": "1", + "bf2_tkmode": "Punish", + "bf2_startdelay": "15", + "bf2_spawntime": "15.000000", + "bf2_sponsortext": "", + "bf2_sponsorlogo_url": "", + "bf2_communitylogo_url": "", + "bf2_scorelimit": "0", + "bf2_ticketratio": "100", + "bf2_teamratio": "100.000000", + "bf2_team1": "CH", + "bf2_team2": "US", + "bf2_bots": "0", + "bf2_pure": "1", + "bf2_mapsize": "32", + "bf2_globalunlocks": "0", + "bf2_fps": "" + }, + "players": [ + { + "name": "Gamie", + "score": "0", + "ping": "0", + "team": "2", + "deaths": "0", + "pid": "0", + "skill": "0" + } + ], + "teams": [ + { + "name": "CH", + "score": "0" + }, + { + "name": "US", + "score": "0" + } + ] + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 02ecb8b..576e29f 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -3,6 +3,7 @@ from opengsq.protocols.ase import ASE from opengsq.protocols.avp2 import AVP2 from opengsq.protocols.battlefield import Battlefield +from opengsq.protocols.battlefield2 import Battlefield2 from opengsq.protocols.directplay import DirectPlay from opengsq.protocols.doom3 import Doom3 from opengsq.protocols.eldewrito import ElDewrito diff --git a/opengsq/protocols/battlefield2.py b/opengsq/protocols/battlefield2.py new file mode 100644 index 0000000..ab55f27 --- /dev/null +++ b/opengsq/protocols/battlefield2.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +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.gamespy2 import Status + + +class Battlefield2(ProtocolBase): + """ + This class represents the Battlefield 2 Protocol. It provides methods to interact with Battlefield 2 game servers. + Battlefield 2 uses the GameSpy Protocol version 3 for server queries. + """ + + full_name = "Battlefield 2" + challenge_required = False + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the status of the Battlefield 2 game server. The status includes information about the server, + players, and teams. + + :return: A Status object containing the status of the game server. + """ + # Connect to remote host + with UdpClient() as udpClient: + udpClient.settimeout(self._timeout) + await udpClient.connect((self._host, self._port)) + + request_h = b"\xFE\xFD" + timestamp = b"\x04\x05\x06\x07" + challenge = b"" + + if self.challenge_required: + # Packet 1: Initial request - (https://wiki.unrealadmin.org/UT3_query_protocol#Packet_1:_Initial_request) + udpClient.send(request_h + b"\x09" + timestamp) + + # Packet 2: First response - (https://wiki.unrealadmin.org/UT3_query_protocol#Packet_2:_First_response) + response = await udpClient.recv() + + if response[0] != 9: + raise InvalidPacketException( + "Packet header mismatch. Received: {}. Expected: {}.".format( + chr(response[0]), chr(9) + ) + ) + + # Packet 3: Second request - (http://wiki.unrealadmin.org/UT3_query_protocol#Packet_3:_Second_request) + challenge = int(response[5:].decode("ascii").strip("\x00")) + challenge = ( + b"" if challenge == 0 else challenge.to_bytes(4, "big", signed=True) + ) + + request_data = request_h + b"\x00" + timestamp + challenge + udpClient.send(request_data + b"\xFF\xFF\xFF\x01") + + # Packet 4: Server information response + # (http://wiki.unrealadmin.org/UT3_query_protocol#Packet_4:_Server_information_response) + response = await self.__read(udpClient) + + br = BinaryReader(response) + + info = {} + + while True: + key = br.read_string() + + if key == "": + break + + info[key] = br.read_string() + + status = Status( + info, + self.__get_dictionaries(br, "player"), + self.__get_dictionaries(br, "team"), + ) + + return status + + async def __read(self, udpClient: UdpClient) -> bytes: + packet_count = -1 + payloads = {} + + while packet_count == -1 or len(payloads) > packet_count: + response = await udpClient.recv() + + br = BinaryReader(response) + header = br.read_byte() + + if header != 0: + raise InvalidPacketException( + "Packet header mismatch. Received: {}. Expected: {}.".format( + chr(header), chr(0) + ) + ) + + # Skip the timestamp and splitnum + br.read_bytes(13) + + # The 'numPackets' byte + num_packets = br.read_byte() + + # The low 7 bits are the packet index (starting at zero) + number = num_packets & 0x7F + + # The high bit is whether or not this is the last packet + if num_packets & 0x80: + # Set packet_count if it is the last packet + packet_count = number + 1 + + # The object id + # 0 = server kv information + # 1 = player_ \x00\x01player_\x00\x00 since \x01 + # 2 = team_t \x00\x02team_t\x00\x00 since \x02 + # etc... + obj_id = br.read_byte() + header = b"" + + if obj_id >= 1: + # The object key name + string = br.read_string() + + # How many times did the value appear in the previous packet + count = br.read_byte() + + # Set back the packet header if it didn't appear before + header = ( + b"\x00" + bytes([obj_id]) + string.encode() + b"\x00\x00" + if count == 0 + else b"" + ) + + payload = header + br.read()[:-1] + + # Remove the last trash string on the payload + payloads[number] = payload[: payload.rfind(b"\x00") + 1] + + response = b"".join(payloads[number] for number in sorted(payloads)) + + return response + + def __get_dictionaries( + self, br: BinaryReader, object_type: str + ) -> list[dict[str, str]]: + kvs: list[dict[str, str]] = [] + + # Return if BaseStream is end + if br.is_end(): + return kvs + + # Skip a byte + br.read_byte() + + # Player/Team index + i = 0 + + while not br.is_end(): + key = br.read_string() + + if key: + # Skip \x00 + br.read_byte() + + # Remove the trailing "_t" + key = key.rstrip("t").rstrip("_") + + # Change the key to name + if key == object_type: + key = "name" + + while not br.is_end(): + value = br.read_string().strip() + + if value: + # Add a Dictionary object if not exists + if len(kvs) < i + 1: + kvs.append({}) + + kvs[i][key] = value + i += 1 + else: + break + + i = 0 + else: + break + + return kvs + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + bf2 = Battlefield2(host="your.bf2.server.com", port=29900, timeout=5.0) + server = await bf2.get_status() + print(server) + + asyncio.run(main_async()) diff --git a/tests/protocols/test_battlefield2.py b/tests/protocols/test_battlefield2.py new file mode 100644 index 0000000..edaa3fe --- /dev/null +++ b/tests/protocols/test_battlefield2.py @@ -0,0 +1,16 @@ +import pytest +from opengsq.protocols.battlefield2 import Battlefield2 + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +# Example Battlefield 2 server - you may need to update with a real server +test = Battlefield2(host="172.29.100.29", port=29900) + + +@pytest.mark.asyncio +async def test_get_status(): + result = await test.get_status() + await handler.save_result("test_get_status", result) From e292e3cc1e816a8c6d89c4c1fd653eabf8e39f8e Mon Sep 17 00:00:00 2001 From: Hornochs Date: Thu, 9 Oct 2025 13:45:38 +0200 Subject: [PATCH 11/20] Adding CoD 4 Support --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_cod4/index.rst | 10 + .../test_cod4/test_get_full_status.rst | 58 ++++++ .../protocols/test_cod4/test_get_info.rst | 30 +++ .../protocols/test_cod4/test_get_status.rst | 33 ++++ .../test_cod4/test_protocol_properties.rst | 14 ++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/cod4.py | 173 ++++++++++++++++++ opengsq/responses/cod4/__init__.py | 10 + opengsq/responses/cod4/cod4_status.py | 24 +++ opengsq/responses/cod4/info.py | 125 +++++++++++++ opengsq/responses/cod4/status.py | 137 ++++++++++++++ tests/protocols/test_cod4.py | 44 +++++ 13 files changed, 660 insertions(+) create mode 100644 docs/tests/protocols/test_cod4/index.rst create mode 100644 docs/tests/protocols/test_cod4/test_get_full_status.rst create mode 100644 docs/tests/protocols/test_cod4/test_get_info.rst create mode 100644 docs/tests/protocols/test_cod4/test_get_status.rst create mode 100644 docs/tests/protocols/test_cod4/test_protocol_properties.rst create mode 100644 opengsq/protocols/cod4.py create mode 100644 opengsq/responses/cod4/__init__.py create mode 100644 opengsq/responses/cod4/cod4_status.py create mode 100644 opengsq/responses/cod4/info.py create mode 100644 opengsq/responses/cod4/status.py create mode 100644 tests/protocols/test_cod4.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 6fe59b4..270fb68 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -4,6 +4,7 @@ Protocols Tests =============== .. toctree:: + test_cod4/index test_flatout2/index test_battlefield2/index test_source/index diff --git a/docs/tests/protocols/test_cod4/index.rst b/docs/tests/protocols/test_cod4/index.rst new file mode 100644 index 0000000..10dbf80 --- /dev/null +++ b/docs/tests/protocols/test_cod4/index.rst @@ -0,0 +1,10 @@ +.. _test_cod4: + +test_cod4 +========= + +.. toctree:: + test_get_status + test_get_info + test_protocol_properties + test_get_full_status diff --git a/docs/tests/protocols/test_cod4/test_get_full_status.rst b/docs/tests/protocols/test_cod4/test_get_full_status.rst new file mode 100644 index 0000000..62ba00c --- /dev/null +++ b/docs/tests/protocols/test_cod4/test_get_full_status.rst @@ -0,0 +1,58 @@ +test_get_full_status +==================== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "sv_maxPing": "350", + "voice": "0", + "mod": "0", + "hw": "1", + "od": "1", + "hc": "1", + "ki": "1", + "ff": "0", + "pswrd": "0", + "shortversion": "x21", + "build": "1154", + "pure": "1", + "gametype": "war", + "sv_maxclients": "30", + "g_humanplayers": "0", + "clients": "0", + "mapname": "mp_bog", + "hostname": "LinuxGSM", + "protocol": "6", + "challenge": "xxx", + "gametype_translated": "Team Death Match" + }, + "status": { + "sv_maxclients": "32", + "version": "CoD4 X - linux-i386 build 1154 May 1 2022", + "shortversion": "-", + "build": "1154", + "branch": "master", + "revision": "0beb470e43b71d1567d068518a61f8003870176d", + "protocol": "21", + "sv_privateClients": "2", + "sv_hostname": "LinuxGSM", + "sv_minPing": "0", + "sv_maxPing": "350", + "sv_disableClientConsole": "0", + "sv_voice": "0", + "g_mapStartTime": "Thu Oct 9 11:39:30 2025", + "uptime": "25 minutes", + "g_gametype": "war", + "mapname": "mp_bog", + "sv_maxRate": "100000", + "sv_floodprotect": "4", + "sv_pure": "1", + "gamename": "Call of Duty 4", + "g_compassShowEnemies": "0", + "_Admin": "Admin", + "g_gametype_translated": "Team Death Match" + } + } diff --git a/docs/tests/protocols/test_cod4/test_get_info.rst b/docs/tests/protocols/test_cod4/test_get_info.rst new file mode 100644 index 0000000..8ef939f --- /dev/null +++ b/docs/tests/protocols/test_cod4/test_get_info.rst @@ -0,0 +1,30 @@ +test_get_info +============= + +Here are the results for the test method. + +.. code-block:: json + + { + "sv_maxPing": "350", + "voice": "0", + "mod": "0", + "hw": "1", + "od": "1", + "hc": "1", + "ki": "1", + "ff": "0", + "pswrd": "0", + "shortversion": "x21", + "build": "1154", + "pure": "1", + "gametype": "war", + "sv_maxclients": "30", + "g_humanplayers": "0", + "clients": "0", + "mapname": "mp_bog", + "hostname": "LinuxGSM", + "protocol": "6", + "challenge": "xxx", + "gametype_translated": "Team Death Match" + } diff --git a/docs/tests/protocols/test_cod4/test_get_status.rst b/docs/tests/protocols/test_cod4/test_get_status.rst new file mode 100644 index 0000000..1ae79a6 --- /dev/null +++ b/docs/tests/protocols/test_cod4/test_get_status.rst @@ -0,0 +1,33 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "sv_maxclients": "32", + "version": "CoD4 X - linux-i386 build 1154 May 1 2022", + "shortversion": "-", + "build": "1154", + "branch": "master", + "revision": "0beb470e43b71d1567d068518a61f8003870176d", + "protocol": "21", + "sv_privateClients": "2", + "sv_hostname": "LinuxGSM", + "sv_minPing": "0", + "sv_maxPing": "350", + "sv_disableClientConsole": "0", + "sv_voice": "0", + "g_mapStartTime": "Thu Oct 9 11:39:30 2025", + "uptime": "25 minutes", + "g_gametype": "war", + "mapname": "mp_bog", + "sv_maxRate": "100000", + "sv_floodprotect": "4", + "sv_pure": "1", + "gamename": "Call of Duty 4", + "g_compassShowEnemies": "0", + "_Admin": "Admin", + "g_gametype_translated": "Team Death Match" + } diff --git a/docs/tests/protocols/test_cod4/test_protocol_properties.rst b/docs/tests/protocols/test_cod4/test_protocol_properties.rst new file mode 100644 index 0000000..198a70f --- /dev/null +++ b/docs/tests/protocols/test_cod4/test_protocol_properties.rst @@ -0,0 +1,14 @@ +test_protocol_properties +======================== + +Here are the results for the test method. + +.. code-block:: json + + { + "_host": "172.29.101.68", + "_port": 28960, + "_timeout": 5.0, + "_allow_broadcast": false, + "_source_port": 28960 + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 576e29f..b89ebc0 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -4,6 +4,7 @@ from opengsq.protocols.avp2 import AVP2 from opengsq.protocols.battlefield import Battlefield from opengsq.protocols.battlefield2 import Battlefield2 +from opengsq.protocols.cod4 import CoD4 from opengsq.protocols.directplay import DirectPlay from opengsq.protocols.doom3 import Doom3 from opengsq.protocols.eldewrito import ElDewrito diff --git a/opengsq/protocols/cod4.py b/opengsq/protocols/cod4.py new file mode 100644 index 0000000..79bebb6 --- /dev/null +++ b/opengsq/protocols/cod4.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +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.cod4 import Info, Status, Cod4Status + + +class CoD4(ProtocolBase): + """ + This class represents the Call of Duty 4 Protocol. It provides methods to interact with CoD4 servers. + """ + + full_name = "Call of Duty 4 Protocol" + + def __init__(self, host: str, port: int = 28960, timeout: float = 5.0): + """ + Initializes the CoD4 object with the given parameters. + + :param host: The host of the server. + :param port: The port of the server (default: 28960). + :param timeout: The timeout for the server connection. + """ + super().__init__(host, port, timeout) + self._source_port = 28960 # CoD4 requires source port 28960 + + async def get_info(self, challenge: str = "xxx") -> Info: + """ + Asynchronously retrieves the server information. + + :param challenge: The challenge string to send (default: "xxx"). + :return: An Info object containing the server information. + """ + # Construct the getinfo payload: ffffffff676574696e666f20787878 + payload = b"\xFF\xFF\xFF\xFF" + b"getinfo " + challenge.encode('ascii') + + response_data = await UdpClient.communicate(self, payload, source_port=self._source_port) + + # Parse the response + br = BinaryReader(response_data) + + # Skip the header (4 bytes of 0xFF) + header = br.read_bytes(4) + if header != b"\xFF\xFF\xFF\xFF": + raise InvalidPacketException( + f"Invalid packet header. Expected: \\xFF\\xFF\\xFF\\xFF. Received: {header.hex()}" + ) + + # Read the response type + response_type = br.read_string([b'\n']) + if response_type != "infoResponse": + raise InvalidPacketException( + f"Unexpected response type. Expected: infoResponse. Received: {response_type}" + ) + + # Parse the key-value pairs + info_data = self._parse_key_value_pairs(br) + + return Info(info_data) + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the server status. + + :return: A Status object containing the server status. + """ + # Construct the getstatus payload: ffffffff676574737461747573 + payload = b"\xFF\xFF\xFF\xFF" + b"getstatus" + + response_data = await UdpClient.communicate(self, payload, source_port=self._source_port) + + # Parse the response + br = BinaryReader(response_data) + + # Skip the header (4 bytes of 0xFF) + header = br.read_bytes(4) + if header != b"\xFF\xFF\xFF\xFF": + raise InvalidPacketException( + f"Invalid packet header. Expected: \\xFF\\xFF\\xFF\\xFF. Received: {header.hex()}" + ) + + # Read the response type + response_type = br.read_string([b'\n']) + if response_type != "statusResponse": + raise InvalidPacketException( + f"Unexpected response type. Expected: statusResponse. Received: {response_type}" + ) + + # Parse the key-value pairs + status_data = self._parse_key_value_pairs(br) + + return Status(status_data) + + async def get_full_status(self, challenge: str = "xxx") -> Cod4Status: + """ + Asynchronously retrieves both server info and status. + + :param challenge: The challenge string to send (default: "xxx"). + :return: A Cod4Status object containing both info and status. + """ + import asyncio + + # Add a small delay between requests to avoid socket conflicts + info = await self.get_info(challenge) + await asyncio.sleep(0.1) # 100ms delay + status = await self.get_status() + + return Cod4Status(info=info, status=status) + + def _parse_key_value_pairs(self, br: BinaryReader) -> dict[str, str]: + """ + Parses key-value pairs from the binary reader. + CoD4 uses backslash (\) as delimiter between keys and values. + + :param br: The BinaryReader object to parse from. + :return: A dictionary containing the parsed key-value pairs. + """ + data = {} + + # Read the remaining data as string + remaining_data = br.read().decode('ascii', errors='ignore') + + # Split by backslash and process pairs + parts = remaining_data.split('\\') + + # Remove empty first element if it exists (starts with \) + if parts and parts[0] == '': + parts = parts[1:] + + # Process pairs (key, value, key, value, ...) + for i in range(0, len(parts) - 1, 2): + if i + 1 < len(parts): + key = parts[i].strip() + value = parts[i + 1].strip() + if key: # Only add non-empty keys + data[key] = value + + return data + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + # Test with the provided server + cod4 = CoD4(host="172.29.101.68", port=28960, timeout=5.0) + + try: + print("Getting server info...") + info = await cod4.get_info() + print(f"Info: {info}") + print(f"Hostname: {info.hostname}") + print(f"Map: {info.mapname}") + print(f"Gametype: {info.gametype}") + print(f"Players: {info.clients}/{info.sv_maxclients}") + + print("\n" + "="*50) + print("Getting server status...") + await asyncio.sleep(0.2) # Wait a bit before next request + status = await cod4.get_status() + print(f"Status: {status}") + print(f"Server Name: {status.sv_hostname}") + print(f"Version: {status.version}") + print(f"Game: {status.gamename}") + print(f"Uptime: {status.uptime}") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + asyncio.run(main_async()) diff --git a/opengsq/responses/cod4/__init__.py b/opengsq/responses/cod4/__init__.py new file mode 100644 index 0000000..0817b58 --- /dev/null +++ b/opengsq/responses/cod4/__init__.py @@ -0,0 +1,10 @@ +from .info import Info +from .status import Status +from .cod4_status import Cod4Status + + + + + + + diff --git a/opengsq/responses/cod4/cod4_status.py b/opengsq/responses/cod4/cod4_status.py new file mode 100644 index 0000000..a498a93 --- /dev/null +++ b/opengsq/responses/cod4/cod4_status.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from .info import Info +from .status import Status + + +@dataclass +class Cod4Status: + """ + Represents the combined status information from a Call of Duty 4 server. + Contains both info and status responses. + """ + + info: Info + """The server info response.""" + + status: Status + """The server status response.""" + + + + + + + diff --git a/opengsq/responses/cod4/info.py b/opengsq/responses/cod4/info.py new file mode 100644 index 0000000..aa59ff7 --- /dev/null +++ b/opengsq/responses/cod4/info.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass + + +def translate_gametype(gametype_code: str) -> str: + """ + Translate CoD4 gametype codes to German display names. + + :param gametype_code: The gametype code from the server + :return: German display name for the gametype + """ + gametype_translations = { + 'dm': 'Death Match', + 'war': 'Team Death Match', + 'dom': 'Domination', + 'koth': 'HQ', + 'sab': 'Sabotage', + 'sd': 'Search and Destroy' + } + + return gametype_translations.get(gametype_code.lower(), gametype_code) + + +@dataclass +class Info: + """ + Represents the info response from a Call of Duty 4 server. + """ + + sv_maxPing: str = "" + """Maximum ping allowed.""" + + voice: str = "" + """Voice chat enabled.""" + + mod: str = "" + """Mod information.""" + + hw: str = "" + """Hardware information.""" + + od: str = "" + """Unknown parameter.""" + + hc: str = "" + """Hardcore mode.""" + + ki: str = "" + """Kill info.""" + + ff: str = "" + """Friendly fire.""" + + pswrd: str = "" + """Password protected.""" + + shortversion: str = "" + """Short version string.""" + + build: str = "" + """Build number.""" + + pure: str = "" + """Pure server.""" + + gametype: str = "" + """Game type.""" + + sv_maxclients: str = "" + """Maximum clients.""" + + g_humanplayers: str = "" + """Human players count.""" + + clients: str = "" + """Current clients.""" + + mapname: str = "" + """Current map name.""" + + hostname: str = "" + """Server hostname.""" + + protocol: str = "" + """Protocol version.""" + + challenge: str = "" + """Challenge string.""" + + def __init__(self, data: dict[str, str]): + """ + Initialize Info object from parsed data dictionary. + + :param data: Dictionary containing server information + """ + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + @property + def gametype_translated(self) -> str: + """ + Get the translated gametype name. + + :return: German display name for the gametype + """ + return translate_gametype(self.gametype) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the translated gametype + result['gametype_translated'] = self.gametype_translated + return result + return object.__getattribute__(self, name) + + + + + + + diff --git a/opengsq/responses/cod4/status.py b/opengsq/responses/cod4/status.py new file mode 100644 index 0000000..530a16f --- /dev/null +++ b/opengsq/responses/cod4/status.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass + + +def translate_gametype(gametype_code: str) -> str: + """ + Translate CoD4 gametype codes to German display names. + + :param gametype_code: The gametype code from the server + :return: German display name for the gametype + """ + gametype_translations = { + 'dm': 'Death Match', + 'war': 'Team Death Match', + 'dom': 'Domination', + 'koth': 'HQ', + 'sab': 'Sabotage', + 'sd': 'Search and Destroy' + } + + return gametype_translations.get(gametype_code.lower(), gametype_code) + + +@dataclass +class Status: + """ + Represents the status response from a Call of Duty 4 server. + """ + + sv_maxclients: str = "" + """Maximum clients.""" + + version: str = "" + """Server version.""" + + shortversion: str = "" + """Short version string.""" + + build: str = "" + """Build number.""" + + branch: str = "" + """Branch information.""" + + revision: str = "" + """Revision information.""" + + _CoD4_X_Site: str = "" + """CoD4X site information.""" + + protocol: str = "" + """Protocol version.""" + + sv_privateClients: str = "" + """Private clients.""" + + sv_hostname: str = "" + """Server hostname.""" + + sv_minPing: str = "" + """Minimum ping.""" + + sv_maxPing: str = "" + """Maximum ping.""" + + sv_disableClientConsole: str = "" + """Client console disabled.""" + + sv_voice: str = "" + """Voice chat.""" + + g_mapStartTime: str = "" + """Map start time.""" + + uptime: str = "" + """Server uptime.""" + + g_gametype: str = "" + """Game type.""" + + mapname: str = "" + """Current map name.""" + + sv_maxRate: str = "" + """Maximum rate.""" + + sv_floodprotect: str = "" + """Flood protection.""" + + sv_pure: str = "" + """Pure server.""" + + gamename: str = "" + """Game name.""" + + g_compassShowEnemies: str = "" + """Compass show enemies.""" + + _Admin: str = "" + """Admin information.""" + + def __init__(self, data: dict[str, str]): + """ + Initialize Status object from parsed data dictionary. + + :param data: Dictionary containing server status information + """ + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + @property + def g_gametype_translated(self) -> str: + """ + Get the translated gametype name. + + :return: German display name for the gametype + """ + return translate_gametype(self.g_gametype) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the translated gametype + result['g_gametype_translated'] = self.g_gametype_translated + return result + return object.__getattribute__(self, name) + + + + + + + diff --git a/tests/protocols/test_cod4.py b/tests/protocols/test_cod4.py new file mode 100644 index 0000000..56c740c --- /dev/null +++ b/tests/protocols/test_cod4.py @@ -0,0 +1,44 @@ +import pytest +from opengsq.protocols.cod4 import CoD4 + + +class TestCoD4: + @pytest.mark.asyncio + async def test_get_info(self): + cod4 = CoD4(host="172.29.101.68", port=28960, timeout=5.0) + info = await cod4.get_info() + assert info is not None + # Check that we got some basic info + assert hasattr(info, 'hostname') + assert hasattr(info, 'mapname') + assert hasattr(info, 'gametype') + + @pytest.mark.asyncio + async def test_get_status(self): + cod4 = CoD4(host="172.29.101.68", port=28960, timeout=5.0) + status = await cod4.get_status() + assert status is not None + # Check that we got some basic status info + assert hasattr(status, 'sv_hostname') + assert hasattr(status, 'mapname') + assert hasattr(status, 'gamename') + + @pytest.mark.asyncio + async def test_get_full_status(self): + cod4 = CoD4(host="172.29.101.68", port=28960, timeout=5.0) + full_status = await cod4.get_full_status() + assert full_status is not None + assert full_status.info is not None + assert full_status.status is not None + + def test_protocol_properties(self): + cod4 = CoD4(host="172.29.101.68", port=28960) + assert cod4.full_name == "Call of Duty 4 Protocol" + assert cod4._source_port == 28960 + + + + + + + From 5b3cde86f22417b8f59a0dbbc939f38c457a9ff7 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Thu, 9 Oct 2025 14:25:28 +0200 Subject: [PATCH 12/20] Adding Missing Test save in CoD4 --- tests/protocols/test_cod4.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/protocols/test_cod4.py b/tests/protocols/test_cod4.py index 56c740c..060de5d 100644 --- a/tests/protocols/test_cod4.py +++ b/tests/protocols/test_cod4.py @@ -2,6 +2,11 @@ from opengsq.protocols.cod4 import CoD4 +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + class TestCoD4: @pytest.mark.asyncio async def test_get_info(self): @@ -12,6 +17,7 @@ async def test_get_info(self): assert hasattr(info, 'hostname') assert hasattr(info, 'mapname') assert hasattr(info, 'gametype') + await handler.save_result("test_get_info", info) @pytest.mark.asyncio async def test_get_status(self): @@ -22,7 +28,7 @@ async def test_get_status(self): assert hasattr(status, 'sv_hostname') assert hasattr(status, 'mapname') assert hasattr(status, 'gamename') - + await handler.save_result("test_get_status", status) @pytest.mark.asyncio async def test_get_full_status(self): cod4 = CoD4(host="172.29.101.68", port=28960, timeout=5.0) @@ -30,11 +36,13 @@ async def test_get_full_status(self): assert full_status is not None assert full_status.info is not None assert full_status.status is not None - - def test_protocol_properties(self): + await handler.save_result("test_get_full_status", full_status) + @pytest.mark.asyncio + async def test_protocol_properties(self): cod4 = CoD4(host="172.29.101.68", port=28960) assert cod4.full_name == "Call of Duty 4 Protocol" assert cod4._source_port == 28960 + await handler.save_result("test_protocol_properties", cod4) From 9eb1ee62d145b9d6ded505c7fe8c3cd90df99b48 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Thu, 9 Oct 2025 15:10:29 +0200 Subject: [PATCH 13/20] Adding CoD 1 Support --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_cod1/index.rst | 10 + .../test_cod1/test_get_full_status.rst | 37 ++++ .../protocols/test_cod1/test_get_info.rst | 20 ++ .../protocols/test_cod1/test_get_status.rst | 22 +++ .../test_cod1/test_protocol_properties.rst | 14 ++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/cod1.py | 173 ++++++++++++++++++ opengsq/responses/cod1/__init__.py | 7 + opengsq/responses/cod1/cod1_status.py | 21 +++ opengsq/responses/cod1/info.py | 122 ++++++++++++ opengsq/responses/cod1/status.py | 134 ++++++++++++++ tests/protocols/test_cod1.py | 52 ++++++ 13 files changed, 614 insertions(+) create mode 100644 docs/tests/protocols/test_cod1/index.rst create mode 100644 docs/tests/protocols/test_cod1/test_get_full_status.rst create mode 100644 docs/tests/protocols/test_cod1/test_get_info.rst create mode 100644 docs/tests/protocols/test_cod1/test_get_status.rst create mode 100644 docs/tests/protocols/test_cod1/test_protocol_properties.rst create mode 100644 opengsq/protocols/cod1.py create mode 100644 opengsq/responses/cod1/__init__.py create mode 100644 opengsq/responses/cod1/cod1_status.py create mode 100644 opengsq/responses/cod1/info.py create mode 100644 opengsq/responses/cod1/status.py create mode 100644 tests/protocols/test_cod1.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 270fb68..6df5cd8 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -40,4 +40,5 @@ Protocols Tests test_quake1/index test_unreal2/index test_gamespy4/index + test_cod1/index test_satisfactory/index diff --git a/docs/tests/protocols/test_cod1/index.rst b/docs/tests/protocols/test_cod1/index.rst new file mode 100644 index 0000000..87076b8 --- /dev/null +++ b/docs/tests/protocols/test_cod1/index.rst @@ -0,0 +1,10 @@ +.. _test_cod1: + +test_cod1 +========= + +.. toctree:: + test_get_status + test_get_info + test_protocol_properties + test_get_full_status diff --git a/docs/tests/protocols/test_cod1/test_get_full_status.rst b/docs/tests/protocols/test_cod1/test_get_full_status.rst new file mode 100644 index 0000000..e0f02cc --- /dev/null +++ b/docs/tests/protocols/test_cod1/test_get_full_status.rst @@ -0,0 +1,37 @@ +test_get_full_status +==================== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "challenge": "xxx", + "protocol": "5", + "hostname": "Garasch", + "mapname": "mp_bocage", + "clients": "1", + "sv_maxclients": "20", + "gametype": "dm", + "pure": "1", + "hw": "4", + "mod": "0", + "gametype_translated": "Death Match" + }, + "status": { + "g_gametype": "dm", + "gamename": "Call of Duty", + "mapname": "mp_bocage", + "protocol": "5", + "shortversion": "1.4", + "sv_hostname": "Garasch", + "sv_maxclients": "20", + "sv_maxPing": "0", + "sv_maxRate": "0", + "sv_minPing": "0", + "sv_privateClients": "0", + "sv_pure": "1", + "g_gametype_translated": "Death Match" + } + } diff --git a/docs/tests/protocols/test_cod1/test_get_info.rst b/docs/tests/protocols/test_cod1/test_get_info.rst new file mode 100644 index 0000000..04f7708 --- /dev/null +++ b/docs/tests/protocols/test_cod1/test_get_info.rst @@ -0,0 +1,20 @@ +test_get_info +============= + +Here are the results for the test method. + +.. code-block:: json + + { + "challenge": "xxx", + "protocol": "5", + "hostname": "Garasch", + "mapname": "mp_bocage", + "clients": "1", + "sv_maxclients": "20", + "gametype": "dm", + "pure": "1", + "hw": "4", + "mod": "0", + "gametype_translated": "Death Match" + } diff --git a/docs/tests/protocols/test_cod1/test_get_status.rst b/docs/tests/protocols/test_cod1/test_get_status.rst new file mode 100644 index 0000000..cd2d831 --- /dev/null +++ b/docs/tests/protocols/test_cod1/test_get_status.rst @@ -0,0 +1,22 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "g_gametype": "dm", + "gamename": "Call of Duty", + "mapname": "mp_bocage", + "protocol": "5", + "shortversion": "1.4", + "sv_hostname": "Garasch", + "sv_maxclients": "20", + "sv_maxPing": "0", + "sv_maxRate": "0", + "sv_minPing": "0", + "sv_privateClients": "0", + "sv_pure": "1", + "g_gametype_translated": "Death Match" + } diff --git a/docs/tests/protocols/test_cod1/test_protocol_properties.rst b/docs/tests/protocols/test_cod1/test_protocol_properties.rst new file mode 100644 index 0000000..f345103 --- /dev/null +++ b/docs/tests/protocols/test_cod1/test_protocol_properties.rst @@ -0,0 +1,14 @@ +test_protocol_properties +======================== + +Here are the results for the test method. + +.. code-block:: json + + { + "_host": "172.29.100.29", + "_port": 28960, + "_timeout": 5.0, + "_allow_broadcast": false, + "_source_port": 28960 + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index b89ebc0..496210f 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -4,6 +4,7 @@ from opengsq.protocols.avp2 import AVP2 from opengsq.protocols.battlefield import Battlefield from opengsq.protocols.battlefield2 import Battlefield2 +from opengsq.protocols.cod1 import CoD1 from opengsq.protocols.cod4 import CoD4 from opengsq.protocols.directplay import DirectPlay from opengsq.protocols.doom3 import Doom3 diff --git a/opengsq/protocols/cod1.py b/opengsq/protocols/cod1.py new file mode 100644 index 0000000..7189cbf --- /dev/null +++ b/opengsq/protocols/cod1.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +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.cod1 import Info, Status, Cod1Status + + +class CoD1(ProtocolBase): + """ + This class represents the Call of Duty 1 Protocol. It provides methods to interact with CoD1 servers. + """ + + full_name = "Call of Duty 1 Protocol" + + def __init__(self, host: str, port: int = 28960, timeout: float = 5.0): + """ + Initializes the CoD1 object with the given parameters. + + :param host: The host of the server. + :param port: The port of the server (default: 28960). + :param timeout: The timeout for the server connection. + """ + super().__init__(host, port, timeout) + self._source_port = 28960 # CoD1 requires source port 28960 + + async def get_info(self, challenge: str = "xxx") -> Info: + """ + Asynchronously retrieves the server information. + + :param challenge: The challenge string to send (default: "xxx"). + :return: An Info object containing the server information. + """ + # Construct the getinfo payload: ffffffff676574696e666f20787878 + payload = b"\xFF\xFF\xFF\xFF" + b"getinfo " + challenge.encode('ascii') + + response_data = await UdpClient.communicate(self, payload, source_port=self._source_port) + + # Parse the response + br = BinaryReader(response_data) + + # Skip the header (4 bytes of 0xFF) + header = br.read_bytes(4) + if header != b"\xFF\xFF\xFF\xFF": + raise InvalidPacketException( + f"Invalid packet header. Expected: \\xFF\\xFF\\xFF\\xFF. Received: {header.hex()}" + ) + + # Read the response type + response_type = br.read_string([b'\n']) + if response_type != "infoResponse": + raise InvalidPacketException( + f"Unexpected response type. Expected: infoResponse. Received: {response_type}" + ) + + # Parse the key-value pairs + info_data = self._parse_key_value_pairs(br) + + return Info(info_data) + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the server status. + + :return: A Status object containing the server status. + """ + # Construct the getstatus payload: ffffffff676574737461747573 + payload = b"\xFF\xFF\xFF\xFF" + b"getstatus" + + response_data = await UdpClient.communicate(self, payload, source_port=self._source_port) + + # Parse the response + br = BinaryReader(response_data) + + # Skip the header (4 bytes of 0xFF) + header = br.read_bytes(4) + if header != b"\xFF\xFF\xFF\xFF": + raise InvalidPacketException( + f"Invalid packet header. Expected: \\xFF\\xFF\\xFF\\xFF. Received: {header.hex()}" + ) + + # Read the response type + response_type = br.read_string([b'\n']) + if response_type != "statusResponse": + raise InvalidPacketException( + f"Unexpected response type. Expected: statusResponse. Received: {response_type}" + ) + + # Parse the key-value pairs + status_data = self._parse_key_value_pairs(br) + + return Status(status_data) + + async def get_full_status(self, challenge: str = "xxx") -> Cod1Status: + """ + Asynchronously retrieves both server info and status. + + :param challenge: The challenge string to send (default: "xxx"). + :return: A Cod1Status object containing both info and status. + """ + import asyncio + + # Add a small delay between requests to avoid socket conflicts + info = await self.get_info(challenge) + await asyncio.sleep(0.1) # 100ms delay + status = await self.get_status() + + return Cod1Status(info=info, status=status) + + def _parse_key_value_pairs(self, br: BinaryReader) -> dict[str, str]: + """ + Parses key-value pairs from the binary reader. + CoD1 uses backslash (\) as delimiter between keys and values. + + :param br: The BinaryReader object to parse from. + :return: A dictionary containing the parsed key-value pairs. + """ + data = {} + + # Read the remaining data as string + remaining_data = br.read().decode('ascii', errors='ignore') + + # Split by backslash and process pairs + parts = remaining_data.split('\\') + + # Remove empty first element if it exists (starts with \) + if parts and parts[0] == '': + parts = parts[1:] + + # Process pairs (key, value, key, value, ...) + for i in range(0, len(parts) - 1, 2): + if i + 1 < len(parts): + key = parts[i].strip() + value = parts[i + 1].strip() + if key: # Only add non-empty keys + data[key] = value + + return data + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + # Test with the provided server + cod1 = CoD1(host="172.29.100.29", port=28960, timeout=5.0) + + try: + print("Getting server info...") + info = await cod1.get_info() + print(f"Info: {info}") + print(f"Hostname: {info.hostname}") + print(f"Map: {info.mapname}") + print(f"Gametype: {info.gametype}") + print(f"Players: {info.clients}/{info.sv_maxclients}") + + print("\n" + "="*50) + print("Getting server status...") + await asyncio.sleep(0.2) # Wait a bit before next request + status = await cod1.get_status() + print(f"Status: {status}") + print(f"Server Name: {status.sv_hostname}") + print(f"Version: {status.version}") + print(f"Game: {status.gamename}") + print(f"Uptime: {status.uptime}") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + asyncio.run(main_async()) diff --git a/opengsq/responses/cod1/__init__.py b/opengsq/responses/cod1/__init__.py new file mode 100644 index 0000000..0014654 --- /dev/null +++ b/opengsq/responses/cod1/__init__.py @@ -0,0 +1,7 @@ +from .info import Info +from .status import Status +from .cod1_status import Cod1Status + + + + diff --git a/opengsq/responses/cod1/cod1_status.py b/opengsq/responses/cod1/cod1_status.py new file mode 100644 index 0000000..c067894 --- /dev/null +++ b/opengsq/responses/cod1/cod1_status.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from .info import Info +from .status import Status + + +@dataclass +class Cod1Status: + """ + Represents the combined status information from a Call of Duty 1 server. + Contains both info and status responses. + """ + + info: Info + """The server info response.""" + + status: Status + """The server status response.""" + + + + diff --git a/opengsq/responses/cod1/info.py b/opengsq/responses/cod1/info.py new file mode 100644 index 0000000..3e32bea --- /dev/null +++ b/opengsq/responses/cod1/info.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass + + +def translate_gametype(gametype_code: str) -> str: + """ + Translate CoD1 gametype codes to German display names. + + :param gametype_code: The gametype code from the server + :return: German display name for the gametype + """ + gametype_translations = { + 'dm': 'Death Match', + 'war': 'Team Death Match', + 'dom': 'Domination', + 'koth': 'HQ', + 'sab': 'Sabotage', + 'sd': 'Search and Destroy' + } + + return gametype_translations.get(gametype_code.lower(), gametype_code) + + +@dataclass +class Info: + """ + Represents the info response from a Call of Duty 1 server. + """ + + sv_maxPing: str = "" + """Maximum ping allowed.""" + + voice: str = "" + """Voice chat enabled.""" + + mod: str = "" + """Mod information.""" + + hw: str = "" + """Hardware information.""" + + od: str = "" + """Unknown parameter.""" + + hc: str = "" + """Hardcore mode.""" + + ki: str = "" + """Kill info.""" + + ff: str = "" + """Friendly fire.""" + + pswrd: str = "" + """Password protected.""" + + shortversion: str = "" + """Short version string.""" + + build: str = "" + """Build number.""" + + pure: str = "" + """Pure server.""" + + gametype: str = "" + """Game type.""" + + sv_maxclients: str = "" + """Maximum clients.""" + + g_humanplayers: str = "" + """Human players count.""" + + clients: str = "" + """Current clients.""" + + mapname: str = "" + """Current map name.""" + + hostname: str = "" + """Server hostname.""" + + protocol: str = "" + """Protocol version.""" + + challenge: str = "" + """Challenge string.""" + + def __init__(self, data: dict[str, str]): + """ + Initialize Info object from parsed data dictionary. + + :param data: Dictionary containing server information + """ + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + @property + def gametype_translated(self) -> str: + """ + Get the translated gametype name. + + :return: German display name for the gametype + """ + return translate_gametype(self.gametype) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the translated gametype + result['gametype_translated'] = self.gametype_translated + return result + return object.__getattribute__(self, name) + + + + diff --git a/opengsq/responses/cod1/status.py b/opengsq/responses/cod1/status.py new file mode 100644 index 0000000..d30814e --- /dev/null +++ b/opengsq/responses/cod1/status.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass + + +def translate_gametype(gametype_code: str) -> str: + """ + Translate CoD1 gametype codes to German display names. + + :param gametype_code: The gametype code from the server + :return: German display name for the gametype + """ + gametype_translations = { + 'dm': 'Death Match', + 'war': 'Team Death Match', + 'dom': 'Domination', + 'koth': 'HQ', + 'sab': 'Sabotage', + 'sd': 'Search and Destroy' + } + + return gametype_translations.get(gametype_code.lower(), gametype_code) + + +@dataclass +class Status: + """ + Represents the status response from a Call of Duty 1 server. + """ + + sv_maxclients: str = "" + """Maximum clients.""" + + version: str = "" + """Server version.""" + + shortversion: str = "" + """Short version string.""" + + build: str = "" + """Build number.""" + + branch: str = "" + """Branch information.""" + + revision: str = "" + """Revision information.""" + + _CoD4_X_Site: str = "" + """CoD4X site information.""" + + protocol: str = "" + """Protocol version.""" + + sv_privateClients: str = "" + """Private clients.""" + + sv_hostname: str = "" + """Server hostname.""" + + sv_minPing: str = "" + """Minimum ping.""" + + sv_maxPing: str = "" + """Maximum ping.""" + + sv_disableClientConsole: str = "" + """Client console disabled.""" + + sv_voice: str = "" + """Voice chat.""" + + g_mapStartTime: str = "" + """Map start time.""" + + uptime: str = "" + """Server uptime.""" + + g_gametype: str = "" + """Game type.""" + + mapname: str = "" + """Current map name.""" + + sv_maxRate: str = "" + """Maximum rate.""" + + sv_floodprotect: str = "" + """Flood protection.""" + + sv_pure: str = "" + """Pure server.""" + + gamename: str = "" + """Game name.""" + + g_compassShowEnemies: str = "" + """Compass show enemies.""" + + _Admin: str = "" + """Admin information.""" + + def __init__(self, data: dict[str, str]): + """ + Initialize Status object from parsed data dictionary. + + :param data: Dictionary containing server status information + """ + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + @property + def g_gametype_translated(self) -> str: + """ + Get the translated gametype name. + + :return: German display name for the gametype + """ + return translate_gametype(self.g_gametype) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the translated gametype + result['g_gametype_translated'] = self.g_gametype_translated + return result + return object.__getattribute__(self, name) + + + + diff --git a/tests/protocols/test_cod1.py b/tests/protocols/test_cod1.py new file mode 100644 index 0000000..768afec --- /dev/null +++ b/tests/protocols/test_cod1.py @@ -0,0 +1,52 @@ +import pytest +from opengsq.protocols.cod1 import CoD1 + + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +class TestCoD1: + @pytest.mark.asyncio + async def test_get_info(self): + cod1 = CoD1(host="172.29.100.29", port=28960, timeout=5.0) + info = await cod1.get_info() + assert info is not None + # Check that we got some basic info + assert hasattr(info, 'hostname') + assert hasattr(info, 'mapname') + assert hasattr(info, 'gametype') + await handler.save_result("test_get_info", info) + + @pytest.mark.asyncio + async def test_get_status(self): + cod1 = CoD1(host="172.29.100.29", port=28960, timeout=5.0) + status = await cod1.get_status() + assert status is not None + # Check that we got some basic status info + assert hasattr(status, 'sv_hostname') + assert hasattr(status, 'mapname') + assert hasattr(status, 'gamename') + await handler.save_result("test_get_status", status) + @pytest.mark.asyncio + async def test_get_full_status(self): + cod1 = CoD1(host="172.29.100.29", port=28960, timeout=5.0) + full_status = await cod1.get_full_status() + assert full_status is not None + assert full_status.info is not None + assert full_status.status is not None + await handler.save_result("test_get_full_status", full_status) + @pytest.mark.asyncio + async def test_protocol_properties(self): + cod1 = CoD1(host="172.29.100.29", port=28960) + assert cod1.full_name == "Call of Duty 1 Protocol" + assert cod1._source_port == 28960 + await handler.save_result("test_protocol_properties", cod1) + + + + + + + From 661f8584c1c605ccb858a34f05493a51fde71474 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Tue, 14 Oct 2025 10:04:07 +0200 Subject: [PATCH 14/20] Adding CoD5 Support --- opengsq/protocols/__init__.py | 1 + opengsq/protocols/cod5.py | 172 ++++++++++++++++++++++++++ opengsq/responses/cod5/__init__.py | 3 + opengsq/responses/cod5/cod5_status.py | 17 +++ opengsq/responses/cod5/info.py | 95 ++++++++++++++ opengsq/responses/cod5/status.py | 131 ++++++++++++++++++++ 6 files changed, 419 insertions(+) create mode 100644 opengsq/protocols/cod5.py create mode 100644 opengsq/responses/cod5/__init__.py create mode 100644 opengsq/responses/cod5/cod5_status.py create mode 100644 opengsq/responses/cod5/info.py create mode 100644 opengsq/responses/cod5/status.py diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 496210f..277fdfb 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -6,6 +6,7 @@ from opengsq.protocols.battlefield2 import Battlefield2 from opengsq.protocols.cod1 import CoD1 from opengsq.protocols.cod4 import CoD4 +from opengsq.protocols.cod5 import CoD5 from opengsq.protocols.directplay import DirectPlay from opengsq.protocols.doom3 import Doom3 from opengsq.protocols.eldewrito import ElDewrito diff --git a/opengsq/protocols/cod5.py b/opengsq/protocols/cod5.py new file mode 100644 index 0000000..0745765 --- /dev/null +++ b/opengsq/protocols/cod5.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +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.cod5 import Info, Status, Cod5Status + + +class CoD5(ProtocolBase): + """ + This class represents the Call of Duty 5: World at War Protocol. It provides methods to interact with CoD5 servers. + """ + + full_name = "Call of Duty 5: World at War Protocol" + + def __init__(self, host: str, port: int = 28960, timeout: float = 5.0): + """ + Initializes the CoD5 object with the given parameters. + + :param host: The host of the server. + :param port: The port of the server (default: 28960). + :param timeout: The timeout for the server connection. + """ + super().__init__(host, port, timeout) + self._source_port = 28960 # CoD5 requires source port 28960 + + async def get_info(self, challenge: str = "xxx") -> Info: + """ + Asynchronously retrieves the server information. + + :param challenge: The challenge string to send (default: "xxx"). + :return: An Info object containing the server information. + """ + # Construct the getinfo payload: ffffffff676574696e666f20787878 + payload = b"\xFF\xFF\xFF\xFF" + b"getinfo " + challenge.encode('ascii') + + response_data = await UdpClient.communicate(self, payload, source_port=self._source_port) + + # Parse the response + br = BinaryReader(response_data) + + # Skip the header (4 bytes of 0xFF) + header = br.read_bytes(4) + if header != b"\xFF\xFF\xFF\xFF": + raise InvalidPacketException( + f"Invalid packet header. Expected: \\xFF\\xFF\\xFF\\xFF. Received: {header.hex()}" + ) + + # Read the response type + response_type = br.read_string([b'\n']) + if response_type != "infoResponse": + raise InvalidPacketException( + f"Unexpected response type. Expected: infoResponse. Received: {response_type}" + ) + + # Parse the key-value pairs + info_data = self._parse_key_value_pairs(br) + + return Info(info_data) + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the server status. + + :return: A Status object containing the server status. + """ + # Construct the getstatus payload: ffffffff676574737461747573 + payload = b"\xFF\xFF\xFF\xFF" + b"getstatus" + + response_data = await UdpClient.communicate(self, payload, source_port=self._source_port) + + # Parse the response + br = BinaryReader(response_data) + + # Skip the header (4 bytes of 0xFF) + header = br.read_bytes(4) + if header != b"\xFF\xFF\xFF\xFF": + raise InvalidPacketException( + f"Invalid packet header. Expected: \\xFF\\xFF\\xFF\\xFF. Received: {header.hex()}" + ) + + # Read the response type + response_type = br.read_string([b'\n']) + if response_type != "statusResponse": + raise InvalidPacketException( + f"Unexpected response type. Expected: statusResponse. Received: {response_type}" + ) + + # Parse the key-value pairs + status_data = self._parse_key_value_pairs(br) + + return Status(status_data) + + async def get_full_status(self, challenge: str = "xxx") -> Cod5Status: + """ + Asynchronously retrieves both server info and status. + + :param challenge: The challenge string to send (default: "xxx"). + :return: A Cod5Status object containing both info and status. + """ + import asyncio + + # Add a small delay between requests to avoid socket conflicts + info = await self.get_info(challenge) + await asyncio.sleep(0.1) # 100ms delay + status = await self.get_status() + + return Cod5Status(info=info, status=status) + + def _parse_key_value_pairs(self, br: BinaryReader) -> dict[str, str]: + """ + Parses key-value pairs from the binary reader. + CoD5 uses backslash (\) as delimiter between keys and values. + + :param br: The BinaryReader object to parse from. + :return: A dictionary containing the parsed key-value pairs. + """ + data = {} + + # Read the remaining data as string + remaining_data = br.read().decode('ascii', errors='ignore') + + # Split by backslash and process pairs + parts = remaining_data.split('\\') + + # Remove empty first element if it exists (starts with \) + if parts and parts[0] == '': + parts = parts[1:] + + # Process pairs (key, value, key, value, ...) + for i in range(0, len(parts) - 1, 2): + if i + 1 < len(parts): + key = parts[i].strip() + value = parts[i + 1].strip() + if key: # Only add non-empty keys + data[key] = value + + return data + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + # Test with the provided server + cod5 = CoD5(host="172.29.100.29", port=28960, timeout=5.0) + + try: + print("Getting server info...") + info = await cod5.get_info() + print(f"Info: {info}") + print(f"Hostname: {info.hostname}") + print(f"Map: {info.mapname}") + print(f"Gametype: {info.gametype}") + print(f"Players: {info.clients}/{info.sv_maxclients}") + + print("\n" + "="*50) + print("Getting server status...") + await asyncio.sleep(0.2) # Wait a bit before next request + status = await cod5.get_status() + print(f"Status: {status}") + print(f"Server Name: {status.sv_hostname}") + print(f"Game: {status.gamename}") + print(f"Map: {status.mapname}") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + asyncio.run(main_async()) diff --git a/opengsq/responses/cod5/__init__.py b/opengsq/responses/cod5/__init__.py new file mode 100644 index 0000000..fc47438 --- /dev/null +++ b/opengsq/responses/cod5/__init__.py @@ -0,0 +1,3 @@ +from .info import Info +from .status import Status +from .cod5_status import Cod5Status diff --git a/opengsq/responses/cod5/cod5_status.py b/opengsq/responses/cod5/cod5_status.py new file mode 100644 index 0000000..3d09acc --- /dev/null +++ b/opengsq/responses/cod5/cod5_status.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from .info import Info +from .status import Status + + +@dataclass +class Cod5Status: + """ + Represents the combined status information from a Call of Duty 5: World at War server. + Contains both info and status responses. + """ + + info: Info + """The server info response.""" + + status: Status + """The server status response.""" diff --git a/opengsq/responses/cod5/info.py b/opengsq/responses/cod5/info.py new file mode 100644 index 0000000..e94e1ca --- /dev/null +++ b/opengsq/responses/cod5/info.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass + + +def translate_gametype(gametype_code: str) -> str: + """ + Translate CoD5 gametype codes to German display names. + + :param gametype_code: The gametype code from the server + :return: German display name for the gametype + """ + gametype_translations = { + 'dm': 'Death Match', + 'war': 'Team Death Match', + 'dom': 'Domination', + 'koth': 'HQ', + 'sab': 'Sabotage', + 'sd': 'Search and Destroy', + 'ctf': 'Capture the Flag' + } + + return gametype_translations.get(gametype_code.lower(), gametype_code) + + +@dataclass +class Info: + """ + Represents the info response from a Call of Duty 5: World at War server. + """ + + challenge: str = "" + """Challenge string.""" + + protocol: str = "" + """Protocol version.""" + + hostname: str = "" + """Server hostname.""" + + mapname: str = "" + """Current map name.""" + + clients: str = "" + """Current clients.""" + + sv_maxclients: str = "" + """Maximum clients.""" + + gametype: str = "" + """Game type.""" + + pure: str = "" + """Pure server.""" + + hw: str = "" + """Hardware information.""" + + mod: str = "" + """Mod information.""" + + voice: str = "" + """Voice chat enabled.""" + + pb: str = "" + """PunkBuster enabled.""" + + def __init__(self, data: dict[str, str]): + """ + Initialize Info object from parsed data dictionary. + + :param data: Dictionary containing server information + """ + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + @property + def gametype_translated(self) -> str: + """ + Get the translated gametype name. + + :return: German display name for the gametype + """ + return translate_gametype(self.gametype) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the translated gametype + result['gametype_translated'] = self.gametype_translated + return result + return object.__getattribute__(self, name) diff --git a/opengsq/responses/cod5/status.py b/opengsq/responses/cod5/status.py new file mode 100644 index 0000000..db993fd --- /dev/null +++ b/opengsq/responses/cod5/status.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass + + +def translate_gametype(gametype_code: str) -> str: + """ + Translate CoD5 gametype codes to German display names. + + :param gametype_code: The gametype code from the server + :return: German display name for the gametype + """ + gametype_translations = { + 'dm': 'Death Match', + 'tdm': 'Team Death Match', + 'dom': 'Domination', + 'koth': 'HQ', + 'sab': 'Sabotage', + 'sd': 'Search and Destroy', + 'twar': 'War (Capture the Flag)' + } + + return gametype_translations.get(gametype_code.lower(), gametype_code) + + +@dataclass +class Status: + """ + Represents the status response from a Call of Duty 5: World at War server. + """ + + fxfrustumCutoff: str = "" + """FX frustum cutoff setting.""" + + g_compassShowEnemies: str = "" + """Compass show enemies setting.""" + + g_gametype: str = "" + """Game type.""" + + gamename: str = "" + """Game name.""" + + mapname: str = "" + """Current map name.""" + + penetrationCount: str = "" + """Penetration count setting.""" + + protocol: str = "" + """Protocol version.""" + + r_watersim_enabled: str = "" + """Water simulation enabled.""" + + shortversion: str = "" + """Short version string.""" + + sv_allowAnonymous: str = "" + """Allow anonymous players.""" + + sv_disableClientConsole: str = "" + """Client console disabled.""" + + sv_floodprotect: str = "" + """Flood protection.""" + + sv_hostname: str = "" + """Server hostname.""" + + sv_maxclients: str = "" + """Maximum clients.""" + + sv_maxPing: str = "" + """Maximum ping.""" + + sv_maxRate: str = "" + """Maximum rate.""" + + sv_minPing: str = "" + """Minimum ping.""" + + sv_privateClients: str = "" + """Private clients.""" + + sv_punkbuster: str = "" + """PunkBuster enabled.""" + + sv_pure: str = "" + """Pure server.""" + + sv_voice: str = "" + """Voice chat.""" + + ui_maxclients: str = "" + """UI maximum clients.""" + + pswrd: str = "" + """Password protected.""" + + mod: str = "" + """Mod information.""" + + def __init__(self, data: dict[str, str]): + """ + Initialize Status object from parsed data dictionary. + + :param data: Dictionary containing server status information + """ + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + @property + def g_gametype_translated(self) -> str: + """ + Get the translated gametype name. + + :return: German display name for the gametype + """ + return translate_gametype(self.g_gametype) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the translated gametype + result['g_gametype_translated'] = self.g_gametype_translated + return result + return object.__getattribute__(self, name) From 2e8e2b412840b20456113aa32ccae96ddf799c07 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Tue, 14 Oct 2025 14:31:29 +0200 Subject: [PATCH 15/20] Adding Support for Warhammer 40000 Dawn of War --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_w40kdow/index.rst | 7 + .../test_w40kdow/test_get_status.rst | 35 +++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/w40kdow.py | 267 ++++++++++++++++++ opengsq/responses/w40kdow/__init__.py | 2 + opengsq/responses/w40kdow/status.py | 116 ++++++++ tests/protocols/test_w40kdow.py | 18 ++ 8 files changed, 447 insertions(+) create mode 100644 docs/tests/protocols/test_w40kdow/index.rst create mode 100644 docs/tests/protocols/test_w40kdow/test_get_status.rst create mode 100644 opengsq/protocols/w40kdow.py create mode 100644 opengsq/responses/w40kdow/__init__.py create mode 100644 opengsq/responses/w40kdow/status.py create mode 100644 tests/protocols/test_w40kdow.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 6df5cd8..26254b8 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -30,6 +30,7 @@ Protocols Tests test_palworld/index test_tmn/index test_doom3/index + test_w40kdow/index test_samp/index test_ase/index test_teamspeak3/index diff --git a/docs/tests/protocols/test_w40kdow/index.rst b/docs/tests/protocols/test_w40kdow/index.rst new file mode 100644 index 0000000..0f2cb8c --- /dev/null +++ b/docs/tests/protocols/test_w40kdow/index.rst @@ -0,0 +1,7 @@ +.. _test_w40kdow: + +test_w40kdow +============ + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_w40kdow/test_get_status.rst b/docs/tests/protocols/test_w40kdow/test_get_status.rst new file mode 100644 index 0000000..86ae652 --- /dev/null +++ b/docs/tests/protocols/test_w40kdow/test_get_status.rst @@ -0,0 +1,35 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "guid": "{9958fa06-1fc7-4478-b95e-4ed185f00c4e}", + "hostname": "Spiel von Banane", + "current_players": 1, + "max_players": 4, + "ip_address": "172.29.100.29", + "port": 6112, + "magic_marker": "WODW", + "build_number": 1001, + "version": "1.1", + "mod_name": "dxp2", + "game_title": "Dawn of War: Dark Crusade", + "map_scenario": "Heiliges Quadrat (4)", + "faction_codes": [ + "FDIA", + "TSSR", + "MTKL", + "AEHC", + "COLS", + "DPSG", + "HSSR", + "TRSR" + ], + "map_features": [ + "᪻ꧤ\u000b\u0000" + ], + "expansion_name": "Dark Crusade" + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 277fdfb..6cbae4d 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -38,5 +38,6 @@ from opengsq.protocols.unreal2 import Unreal2 from opengsq.protocols.ut3 import UT3 from opengsq.protocols.vcmp import Vcmp +from opengsq.protocols.w40kdow import W40kDow from opengsq.protocols.warcraft3 import Warcraft3 from opengsq.protocols.won import WON \ No newline at end of file diff --git a/opengsq/protocols/w40kdow.py b/opengsq/protocols/w40kdow.py new file mode 100644 index 0000000..28e7e0c --- /dev/null +++ b/opengsq/protocols/w40kdow.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import asyncio +import struct +import ipaddress +from typing import Optional + +from opengsq.binary_reader import BinaryReader +from opengsq.exceptions import InvalidPacketException +from opengsq.protocol_base import ProtocolBase +from opengsq.responses.w40kdow import Status + + +class W40kDow(ProtocolBase): + """ + This class represents the Warhammer 40K Dawn of War Protocol. + It provides methods to listen for broadcast announcements from DoW servers. + """ + + full_name = "Warhammer 40K Dawn of War Protocol" + + def __init__(self, host: str, port: int = 6112, timeout: float = 5.0): + """ + Initializes the W4kDow object with the given parameters. + + :param host: The host of the server to listen for. + :param port: The port of the server (default: 6112). + :param timeout: The timeout for listening to broadcasts. + """ + super().__init__(host, port, timeout) + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the server status by listening for broadcast announcements. + + Dawn of War servers continuously broadcast their status on the network. + This method listens for these broadcasts and returns the first matching broadcast + from the specified host. + + :return: A Status object containing the server status. + :raises InvalidPacketException: If the received packet is invalid. + :raises asyncio.TimeoutError: If no broadcast is received within the timeout period. + """ + import socket + + # Create UDP socket + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('0.0.0.0', self._port)) + sock.setblocking(False) + + loop = asyncio.get_running_loop() + + try: + # Keep receiving broadcasts until we get one from the expected host + while True: + data, addr = await asyncio.wait_for( + loop.sock_recvfrom(sock, 2048), + timeout=self._timeout + ) + + # Only process broadcasts from the expected host + if addr[0] == self._host: + # Parse and return the broadcast data + return self._parse_broadcast(data, addr) + + finally: + sock.close() + + def _parse_broadcast(self, data: bytes, addr: tuple) -> Status: + """ + Parse a Dawn of War server broadcast packet. + + :param data: Raw broadcast data. + :param addr: Sender address tuple (ip, port). + :return: Status object with parsed data. + :raises InvalidPacketException: If the packet is invalid. + """ + try: + br = BinaryReader(data) + + # Validate header magic (0x08 0x01) + header = br.read_bytes(2) + if header != b'\x08\x01': + raise InvalidPacketException( + f"Invalid header. Expected: 0x0801. Received: {header.hex()}" + ) + + # Read GUID length and GUID + guid_len = br.read_long(unsigned=True) + if guid_len != 38: + raise InvalidPacketException( + f"Unexpected GUID length. Expected: 38. Received: {guid_len}" + ) + + guid = br.read_bytes(guid_len).decode('ascii', errors='ignore') + + # Read hostname (UTF-16LE with length prefix in code units) + hostname_len_units = br.read_long(unsigned=True) + hostname_len_bytes = hostname_len_units * 2 + hostname_bytes = br.read_bytes(hostname_len_bytes) + hostname = hostname_bytes.decode('utf-16le', errors='ignore') + + # Skip null terminator + padding (4 bytes total after hostname) + br.read_bytes(4) + + # Read player counts + current_players = br.read_long(unsigned=True) + max_players = br.read_long(unsigned=True) + + # Skip unknown flags/status (9 bytes) + br.read_bytes(9) + + # Read IP address (4 bytes, network byte order) + ip_bytes = br.read_bytes(4) + ip_address = str(ipaddress.IPv4Address(ip_bytes)) + + # Validate that the IP in the packet matches the sender's IP + if ip_address != addr[0]: + raise InvalidPacketException( + f"IP mismatch. Packet IP: {ip_address}, Sender IP: {addr[0]}" + ) + + # Read port (2 bytes, little endian) + port = br.read_short(unsigned=True) + + # Skip 4 unknown bytes after port + br.read_bytes(4) + + # Read total payload size (4 bytes) - note: first byte appears twice (redundant) + br.read_bytes(4) # Payload size (we don't really need this value) + br.read_byte() # Skip the redundant duplicate byte + + # Read and validate magic marker "WODW" + magic_marker = br.read_bytes(4).decode('ascii', errors='ignore') + if magic_marker != 'WODW': + raise InvalidPacketException( + f"Invalid magic marker. Expected: WODW. Received: {magic_marker}" + ) + + # Read build number + build_number = br.read_long(unsigned=True) + + # Read version string + version_len = br.read_long(unsigned=True) + version = br.read_bytes(version_len).decode('ascii', errors='ignore') + + # Read mod name + mod_name_len = br.read_long(unsigned=True) + mod_name = br.read_bytes(mod_name_len).decode('ascii', errors='ignore') + + # Read game title (UTF-16LE with length in code units) + game_title_len_units = br.read_long(unsigned=True) + game_title_len_bytes = game_title_len_units * 2 + game_title_bytes = br.read_bytes(game_title_len_bytes) + game_title = game_title_bytes.decode('utf-16le', errors='ignore') + + # Read unknown ASCII field (appears to be a version like "1.0", length in bytes) + unknown_ascii_len = br.read_long(unsigned=True) + unknown_ascii = br.read_bytes(unknown_ascii_len).decode('ascii', errors='ignore') + + # Read map/scenario name (UTF-16LE with length in code units) + map_scenario_len_units = br.read_long(unsigned=True) + map_scenario_len_bytes = map_scenario_len_units * 2 + map_scenario_bytes = br.read_bytes(map_scenario_len_bytes) + map_scenario = map_scenario_bytes.decode('utf-16le', errors='ignore') + + # Skip unknown null bytes/padding after map scenario (10 bytes) + br.read_bytes(10) + + # Read number of factions (4 bytes, little endian uint32) + num_factions = br.read_long(unsigned=True) + + # Read faction codes (each is 4 ASCII bytes + 4 padding bytes = 8 bytes total) + faction_codes = [] + for _ in range(num_factions): + faction_code = br.read_bytes(4).decode('ascii', errors='ignore') + br.read_bytes(4) # Skip 4 padding bytes after each faction code + faction_codes.append(faction_code) + + # Read map features (length-prefixed UTF-16LE strings in code units) + # Continue reading until we run out of data or hit an invalid length + map_features = [] + while br.remaining_bytes() >= 4: + try: + feature_len_units = br.read_long(unsigned=True) + + # Sanity check: length should be reasonable (< 500 characters) + if feature_len_units == 0 or feature_len_units > 500: + break + + feature_len_bytes = feature_len_units * 2 + + if br.remaining_bytes() < feature_len_bytes: + break + + feature_bytes = br.read_bytes(feature_len_bytes) + feature = feature_bytes.decode('utf-16le', errors='ignore') + map_features.append(feature) + except Exception: + # If we can't read a feature, break + break + + # Create Status object + status_data = { + 'guid': guid, + 'hostname': hostname, + 'current_players': current_players, + 'max_players': max_players, + 'ip_address': ip_address, + 'port': port, + 'magic_marker': magic_marker, + 'build_number': build_number, + 'version': version, + 'mod_name': mod_name, + 'game_title': game_title, + 'map_scenario': map_scenario, + 'faction_codes': faction_codes, + 'map_features': map_features + } + + return Status(status_data) + + except Exception as e: + if isinstance(e, InvalidPacketException): + raise + raise InvalidPacketException(f"Failed to parse broadcast packet: {e}") + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + # Test with the provided server + w4kdow = W40kDow(host="172.29.100.29", port=6112, timeout=10.0) + + try: + print("Listening for Dawn of War server broadcasts...") + status = await w4kdow.get_status() + print(f"\n{'='*60}") + print(f"Server Status:") + print(f"{'='*60}") + print(f"GUID: {status.guid}") + print(f"Hostname: {status.hostname}") + print(f"Players: {status.current_players}/{status.max_players}") + print(f"IP:Port: {status.ip_address}:{status.port}") + print(f"Version: {status.version}") + print(f"Mod: {status.mod_name} ({status.expansion_name})") + print(f"Game Title: {status.game_title}") + print(f"Map/Scenario: {status.map_scenario}") + print(f"Build: {status.build_number}") + print(f"Magic: {status.magic_marker}") + print(f"\nFaction Codes: {', '.join(status.faction_codes)}") + print(f"\nMap Features:") + for i, feature in enumerate(status.map_features, 1): + print(f" {i}. {feature}") + + except asyncio.TimeoutError: + print("Error: No broadcast received within timeout period") + print("Make sure a Dawn of War server is running and broadcasting on the network") + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + asyncio.run(main_async()) + diff --git a/opengsq/responses/w40kdow/__init__.py b/opengsq/responses/w40kdow/__init__.py new file mode 100644 index 0000000..69b675d --- /dev/null +++ b/opengsq/responses/w40kdow/__init__.py @@ -0,0 +1,2 @@ +from .status import Status + diff --git a/opengsq/responses/w40kdow/status.py b/opengsq/responses/w40kdow/status.py new file mode 100644 index 0000000..699d240 --- /dev/null +++ b/opengsq/responses/w40kdow/status.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from typing import List + + +@dataclass +class Status: + """ + Represents the status response from a Warhammer 40K Dawn of War server broadcast. + """ + + guid: str = "" + """Server GUID (unique identifier).""" + + hostname: str = "" + """Server hostname/name.""" + + current_players: int = 0 + """Current number of players.""" + + max_players: int = 0 + """Maximum number of players.""" + + ip_address: str = "" + """Server IP address.""" + + port: int = 6112 + """Server port (default: 6112).""" + + magic_marker: str = "" + """Magic marker (should be 'WODW').""" + + build_number: int = 0 + """Build number (expected: 1001).""" + + version: str = "" + """Game version (e.g., '1.51', '1.1').""" + + mod_name: str = "" + """Mod/expansion identifier (w40k, wxp, dxp2).""" + + game_title: str = "" + """Full game title.""" + + map_scenario: str = "" + """Map/scenario name.""" + + faction_codes: List[str] = None + """List of faction codes (8 factions).""" + + map_features: List[str] = None + """List of map features.""" + + def __post_init__(self): + """Initialize mutable defaults after dataclass init.""" + if self.faction_codes is None: + self.faction_codes = [] + if self.map_features is None: + self.map_features = [] + + @property + def expansion_name(self) -> str: + """ + Get the human-readable expansion name based on mod_name. + + :return: Expansion name + """ + expansion_map = { + 'w40k': 'Dawn of War', + 'wxp': 'Winter Assault', + 'dxp2': 'Dark Crusade', + 'dxp3': 'Soulstorm' + } + return expansion_map.get(self.mod_name, self.mod_name) + + def __getattribute__(self, name): + if name == '__dict__': + # Create a custom dict that includes properties + result = {} + # Get the original __dict__ first + original_dict = object.__getattribute__(self, '__dict__') + result.update(original_dict) + # Add the expansion name + result['expansion_name'] = self.expansion_name + return result + return object.__getattribute__(self, name) + + def __init__(self, data: dict = None): + """ + Initialize Status object from parsed data dictionary. + + :param data: Dictionary containing server status information + """ + if data is None: + data = {} + + # Set defaults first + self.guid = "" + self.hostname = "" + self.current_players = 0 + self.max_players = 0 + self.ip_address = "" + self.port = 6112 + self.magic_marker = "" + self.build_number = 0 + self.version = "" + self.mod_name = "" + self.game_title = "" + self.map_scenario = "" + self.faction_codes = [] + self.map_features = [] + + # Update with provided data + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + diff --git a/tests/protocols/test_w40kdow.py b/tests/protocols/test_w40kdow.py new file mode 100644 index 0000000..0d12b47 --- /dev/null +++ b/tests/protocols/test_w40kdow.py @@ -0,0 +1,18 @@ +import pytest +from opengsq.protocols.w40kdow import W40kDow + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +#handler.enable_save = True + +# W4Kdow +test = W40kDow( + host="172.29.100.29" +) + + +@pytest.mark.asyncio +async def test_get_status(): + result = await test.get_status() + await handler.save_result("test_get_status", result) \ No newline at end of file From 553b6d51693b05cf05279beb8e3d535b3ca1c812 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Thu, 23 Oct 2025 12:11:09 +0200 Subject: [PATCH 16/20] Adding Halo1 Support --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_halo1/index.rst | 7 ++++ .../protocols/test_halo1/test_get_status.rst | 36 +++++++++++++++++++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/halo1.py | 31 ++++++++++++++++ tests/protocols/test_halo1.py | 16 +++++++++ 6 files changed, 92 insertions(+) create mode 100644 docs/tests/protocols/test_halo1/index.rst create mode 100644 docs/tests/protocols/test_halo1/test_get_status.rst create mode 100644 opengsq/protocols/halo1.py create mode 100644 tests/protocols/test_halo1.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 26254b8..41c4ea8 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -5,6 +5,7 @@ Protocols Tests .. toctree:: test_cod4/index + test_halo1/index test_flatout2/index test_battlefield2/index test_source/index diff --git a/docs/tests/protocols/test_halo1/index.rst b/docs/tests/protocols/test_halo1/index.rst new file mode 100644 index 0000000..4b4c6fc --- /dev/null +++ b/docs/tests/protocols/test_halo1/index.rst @@ -0,0 +1,7 @@ +.. _test_halo1: + +test_halo1 +========== + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_halo1/test_get_status.rst b/docs/tests/protocols/test_halo1/test_get_status.rst new file mode 100644 index 0000000..8ecb919 --- /dev/null +++ b/docs/tests/protocols/test_halo1/test_get_status.rst @@ -0,0 +1,36 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "hostname": "YoMama", + "gamever": "01.00.00.0564", + "hostport": "", + "maxplayers": "4", + "password": "0", + "mapname": "beavercreek", + "dedicated": "0", + "gamemode": "openplaying", + "game_classic": "0", + "numplayers": "1", + "gametype": "Oddball", + "teamplay": "0", + "gamevariant": "Juggernaut", + "fraglimit": "15", + "player_flags": "1129320516,2", + "game_flags": "12579" + }, + "players": [ + { + "player": "New001", + "score": ":00", + "ping": "", + "team": "0" + } + ], + "teams": [] + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 6cbae4d..cab4010 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -17,6 +17,7 @@ from opengsq.protocols.gamespy2 import GameSpy2 from opengsq.protocols.gamespy3 import GameSpy3 from opengsq.protocols.gamespy4 import GameSpy4 +from opengsq.protocols.halo1 import Halo1 from opengsq.protocols.kaillera import Kaillera from opengsq.protocols.killingfloor import KillingFloor from opengsq.protocols.minecraft import Minecraft diff --git a/opengsq/protocols/halo1.py b/opengsq/protocols/halo1.py new file mode 100644 index 0000000..666888c --- /dev/null +++ b/opengsq/protocols/halo1.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from opengsq.protocols.gamespy2 import GameSpy2 + + +class Halo1(GameSpy2): + """Halo 1 Multiplayer Protocol (based on GameSpy2)""" + + full_name = "Halo 1 Multiplayer" + + def __init__(self, host: str, port: int = 2302, timeout: float = 5.0): + """ + Initialize the Halo 1 protocol. + + :param host: The server host address + :param port: The server port (default: 2302) + :param timeout: The timeout for the connection (default: 5.0 seconds) + """ + super().__init__(host, port, timeout) + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + halo1 = Halo1(host="172.29.100.29", port=2302, timeout=5.0) + status = await halo1.get_status() + print(status) + + asyncio.run(main_async()) + diff --git a/tests/protocols/test_halo1.py b/tests/protocols/test_halo1.py new file mode 100644 index 0000000..06cf9b2 --- /dev/null +++ b/tests/protocols/test_halo1.py @@ -0,0 +1,16 @@ +import pytest +from opengsq.protocols.halo1 import Halo1 + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +# bfv +test = Halo1(host="172.29.100.29", port=2302) + + +@pytest.mark.asyncio +async def test_get_status(): + result = await test.get_status() + await handler.save_result("test_get_status", result) From 2a1f6f8ac3f06c4cc5b44da69e765c9ec0b6c389 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Fri, 24 Oct 2025 10:14:06 +0200 Subject: [PATCH 17/20] Adding Support for Serious Sam Classic (First and Second Encounter) --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_ssc/index.rst | 12 +++ .../protocols/test_ssc/test_get_basic.rst | 35 +++++++ .../protocols/test_ssc/test_get_info.rst | 17 ++++ .../protocols/test_ssc/test_get_players.rst | 14 +++ .../protocols/test_ssc/test_get_rules.rst | 21 +++++ .../protocols/test_ssc/test_get_status.rst | 42 +++++++++ .../protocols/test_ssc/test_get_teams.rst | 8 ++ opengsq/protocols/ssc.py | 91 +++++++++++++++++++ tests/protocols/test_ssc.py | 46 ++++++++++ 10 files changed, 287 insertions(+) create mode 100644 docs/tests/protocols/test_ssc/index.rst create mode 100644 docs/tests/protocols/test_ssc/test_get_basic.rst create mode 100644 docs/tests/protocols/test_ssc/test_get_info.rst create mode 100644 docs/tests/protocols/test_ssc/test_get_players.rst create mode 100644 docs/tests/protocols/test_ssc/test_get_rules.rst create mode 100644 docs/tests/protocols/test_ssc/test_get_status.rst create mode 100644 docs/tests/protocols/test_ssc/test_get_teams.rst create mode 100644 opengsq/protocols/ssc.py create mode 100644 tests/protocols/test_ssc.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 41c4ea8..207c551 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -8,6 +8,7 @@ Protocols Tests test_halo1/index test_flatout2/index test_battlefield2/index + test_ssc/index test_source/index test_won/index test_fivem/index diff --git a/docs/tests/protocols/test_ssc/index.rst b/docs/tests/protocols/test_ssc/index.rst new file mode 100644 index 0000000..5241d94 --- /dev/null +++ b/docs/tests/protocols/test_ssc/index.rst @@ -0,0 +1,12 @@ +.. _test_ssc: + +test_ssc +======== + +.. toctree:: + test_get_basic + test_get_players + test_get_status + test_get_info + test_get_rules + test_get_teams diff --git a/docs/tests/protocols/test_ssc/test_get_basic.rst b/docs/tests/protocols/test_ssc/test_get_basic.rst new file mode 100644 index 0000000..de67ef7 --- /dev/null +++ b/docs/tests/protocols/test_ssc/test_get_basic.rst @@ -0,0 +1,35 @@ +test_get_basic +============== + +Here are the results for the test method. + +.. code-block:: json + + { + "gamename": "serioussam", + "gamever": "1.05", + "location": "DEU", + "hostname": "Unnamed session", + "hostport": "25600", + "mapname": "Hatshepsut", + "gametype": "Cooperative", + "activemod": "", + "numplayers": "1", + "maxplayers": "8", + "gamemode": "openplaying", + "difficulty": "Normal", + "friendlyfire": "1", + "weaponsstay": "0", + "ammostays": "0", + "healthandarmorstays": "0", + "allowhealth": "0", + "allowarmor": "0", + "infiniteammo": "1", + "respawninplace": "1", + "credits": "infinite", + "password": "0", + "vipplayers": "0", + "player_0": "Serious Sam", + "frags_0": "0", + "ping_0": "3" + } diff --git a/docs/tests/protocols/test_ssc/test_get_info.rst b/docs/tests/protocols/test_ssc/test_get_info.rst new file mode 100644 index 0000000..7b1a64c --- /dev/null +++ b/docs/tests/protocols/test_ssc/test_get_info.rst @@ -0,0 +1,17 @@ +test_get_info +============= + +Here are the results for the test method. + +.. code-block:: json + + { + "hostname": "Unnamed session", + "hostport": "25600", + "mapname": "Hatshepsut", + "gametype": "Cooperative", + "activemod": "", + "numplayers": "1", + "maxplayers": "8", + "gamemode": "openplaying" + } diff --git a/docs/tests/protocols/test_ssc/test_get_players.rst b/docs/tests/protocols/test_ssc/test_get_players.rst new file mode 100644 index 0000000..d8ebe4a --- /dev/null +++ b/docs/tests/protocols/test_ssc/test_get_players.rst @@ -0,0 +1,14 @@ +test_get_players +================ + +Here are the results for the test method. + +.. code-block:: json + + [ + { + "player": "Serious Sam", + "frags": "0", + "ping": "2" + } + ] diff --git a/docs/tests/protocols/test_ssc/test_get_rules.rst b/docs/tests/protocols/test_ssc/test_get_rules.rst new file mode 100644 index 0000000..a755322 --- /dev/null +++ b/docs/tests/protocols/test_ssc/test_get_rules.rst @@ -0,0 +1,21 @@ +test_get_rules +============== + +Here are the results for the test method. + +.. code-block:: json + + { + "difficulty": "Normal", + "friendlyfire": "1", + "weaponsstay": "0", + "ammostays": "0", + "healthandarmorstays": "0", + "allowhealth": "0", + "allowarmor": "0", + "infiniteammo": "1", + "respawninplace": "1", + "credits": "infinite", + "password": "0", + "vipplayers": "0" + } diff --git a/docs/tests/protocols/test_ssc/test_get_status.rst b/docs/tests/protocols/test_ssc/test_get_status.rst new file mode 100644 index 0000000..2b2d009 --- /dev/null +++ b/docs/tests/protocols/test_ssc/test_get_status.rst @@ -0,0 +1,42 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "gamename": "serioussam", + "gamever": "1.05", + "location": "DEU", + "hostname": "Unnamed session", + "hostport": "25600", + "mapname": "Hatshepsut", + "gametype": "Cooperative", + "activemod": "", + "numplayers": "1", + "maxplayers": "8", + "gamemode": "openplaying", + "difficulty": "Normal", + "friendlyfire": "1", + "weaponsstay": "0", + "ammostays": "0", + "healthandarmorstays": "0", + "allowhealth": "0", + "allowarmor": "0", + "infiniteammo": "1", + "respawninplace": "1", + "credits": "infinite", + "password": "0", + "vipplayers": "0" + }, + "players": [ + { + "player": "Serious Sam", + "frags": "0", + "ping": "2" + } + ], + "teams": [] + } diff --git a/docs/tests/protocols/test_ssc/test_get_teams.rst b/docs/tests/protocols/test_ssc/test_get_teams.rst new file mode 100644 index 0000000..ccb52a0 --- /dev/null +++ b/docs/tests/protocols/test_ssc/test_get_teams.rst @@ -0,0 +1,8 @@ +test_get_teams +============== + +Here are the results for the test method. + +.. code-block:: json + + [] diff --git a/opengsq/protocols/ssc.py b/opengsq/protocols/ssc.py new file mode 100644 index 0000000..e2b5e32 --- /dev/null +++ b/opengsq/protocols/ssc.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from opengsq.protocols.gamespy1 import GameSpy1 + + +class SSC(GameSpy1): + """Serious Sam Classic: The First Encounter Protocol""" + + full_name = "Serious Sam Classic: The First Encounter" + + def __init__(self, host: str, port: int = 25601, timeout: float = 5.0): + """ + Initialize the Serious Sam Classic protocol. + + :param host: The hostname or IP address of the server. + :param port: The port number of the server (default: 25601). + :param timeout: The timeout for the connection in seconds (default: 5.0). + """ + super().__init__(host, port, timeout) + + async def get_basic(self) -> dict[str, str]: + """ + Asynchronously retrieves comprehensive information about the game server. + + For Serious Sam Classic, we return the full status information as the basic query. + + :return: A dictionary containing comprehensive server information. + """ + # Get full status and flatten all information into one dict + status = await self.get_status() + + # Combine info with player information in a flattened format + result = dict(status.info) + + # Add player information as indexed fields + for i, player in enumerate(status.players): + for key, value in player.items(): + result[f"{key}_{i}"] = value + + return result + + async def get_status(self, xserverquery: bool = False): + """ + Asynchronously retrieves the status of the game server. + + Serious Sam Classic doesn't support XServerQuery, so we always use the legacy format. + + :param xserverquery: Ignored for Serious Sam Classic (always uses legacy format). + :return: A Status object containing the status of the game server. + """ + # Always use legacy format for Serious Sam Classic (no xserverquery) + return await super().get_status(xserverquery=False) + + async def get_info(self, xserverquery: bool = False) -> dict[str, str]: + """ + Asynchronously retrieves the information of the current game running on the server. + + :param xserverquery: Ignored for Serious Sam Classic (always uses legacy format). + :return: A dictionary containing the information of the current game. + """ + return await super().get_info(xserverquery=False) + + async def get_rules(self, xserverquery: bool = False) -> dict[str, str]: + """ + Asynchronously retrieves the rules of the current game running on the server. + + :param xserverquery: Ignored for Serious Sam Classic (always uses legacy format). + :return: A dictionary containing the rules of the current game. + """ + return await super().get_rules(xserverquery=False) + + async def get_players(self, xserverquery: bool = False) -> list[dict[str, str]]: + """ + Asynchronously retrieves the information of each player on the server. + + :param xserverquery: Ignored for Serious Sam Classic (always uses legacy format). + :return: A list containing the information of each player. + """ + return await super().get_players(xserverquery=False) + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + ssc = SSC(host="172.29.100.29", port=25601, timeout=5.0) + status = await ssc.get_status() + print(status) + + asyncio.run(main_async()) + diff --git a/tests/protocols/test_ssc.py b/tests/protocols/test_ssc.py new file mode 100644 index 0000000..1721f57 --- /dev/null +++ b/tests/protocols/test_ssc.py @@ -0,0 +1,46 @@ +import pytest +from opengsq.protocols.ssc import SSC + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +# handler.enable_save = True +handler.delay_per_test = 1 + +test = SSC(host="172.29.100.29", port=25601) + + +@pytest.mark.asyncio +async def test_get_basic(): + result = await test.get_basic() + await handler.save_result("test_get_basic", result) + + +@pytest.mark.asyncio +async def test_get_info(): + result = await test.get_info() + await handler.save_result("test_get_info", result) + + +@pytest.mark.asyncio +async def test_get_rules(): + result = await test.get_rules() + await handler.save_result("test_get_rules", result) + + +@pytest.mark.asyncio +async def test_get_players(): + result = await test.get_players() + await handler.save_result("test_get_players", result) + + +@pytest.mark.asyncio +async def test_get_status(): + result = await test.get_status() + await handler.save_result("test_get_status", result) + + +@pytest.mark.asyncio +async def test_get_teams(): + result = await test.get_teams() + await handler.save_result("test_get_teams", result) From 46508e3c654821de5004216d658a3952f2d0e7a5 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Fri, 24 Oct 2025 10:39:03 +0200 Subject: [PATCH 18/20] Adding Missing File for SSC --- opengsq/protocols/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index cab4010..3ab5210 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -32,6 +32,7 @@ from opengsq.protocols.satisfactory import Satisfactory from opengsq.protocols.scum import Scum from opengsq.protocols.source import Source +from opengsq.protocols.ssc import SSC from opengsq.protocols.teamspeak3 import TeamSpeak3 from opengsq.protocols.trackmania_nations import TrackmaniaNations from opengsq.protocols.toxikk import Toxikk From d0144b7e1e3f77f70190858646d19833492d25db Mon Sep 17 00:00:00 2001 From: Hornochs Date: Fri, 24 Oct 2025 13:34:36 +0200 Subject: [PATCH 19/20] Adding Support for Stronghold Crusader and Stronghold Crusader Extreme --- docs/tests/protocols/index.rst | 2 + .../protocols/test_stronghold_ce/index.rst | 7 + .../test_stronghold_ce/test_get_status.rst | 28 ++ .../test_stronghold_crusader/index.rst | 7 + .../test_get_status.rst | 29 ++ opengsq/protocols/__init__.py | 2 + opengsq/protocols/stronghold_ce.py | 269 ++++++++++++++++++ opengsq/protocols/stronghold_crusader.py | 269 ++++++++++++++++++ opengsq/responses/stronghold_ce/__init__.py | 4 + opengsq/responses/stronghold_ce/status.py | 14 + .../responses/stronghold_crusader/__init__.py | 4 + .../responses/stronghold_crusader/status.py | 14 + tests/protocols/test_stronghold_ce.py | 16 ++ tests/protocols/test_stronghold_crusader.py | 16 ++ 14 files changed, 681 insertions(+) create mode 100644 docs/tests/protocols/test_stronghold_ce/index.rst create mode 100644 docs/tests/protocols/test_stronghold_ce/test_get_status.rst create mode 100644 docs/tests/protocols/test_stronghold_crusader/index.rst create mode 100644 docs/tests/protocols/test_stronghold_crusader/test_get_status.rst create mode 100644 opengsq/protocols/stronghold_ce.py create mode 100644 opengsq/protocols/stronghold_crusader.py create mode 100644 opengsq/responses/stronghold_ce/__init__.py create mode 100644 opengsq/responses/stronghold_ce/status.py create mode 100644 opengsq/responses/stronghold_crusader/__init__.py create mode 100644 opengsq/responses/stronghold_crusader/status.py create mode 100644 tests/protocols/test_stronghold_ce.py create mode 100644 tests/protocols/test_stronghold_crusader.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 207c551..70372f2 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -19,8 +19,10 @@ Protocols Tests test_eldewrito/index test_eos/index test_renegadex/index + test_stronghold_ce/index test_quake2/index test_gamespy3/index + test_stronghold_crusader/index test_kaillera/index test_toxikk/index test_avp2/index diff --git a/docs/tests/protocols/test_stronghold_ce/index.rst b/docs/tests/protocols/test_stronghold_ce/index.rst new file mode 100644 index 0000000..c4eaf3f --- /dev/null +++ b/docs/tests/protocols/test_stronghold_ce/index.rst @@ -0,0 +1,7 @@ +.. _test_stronghold_ce: + +test_stronghold_ce +================== + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_stronghold_ce/test_get_status.rst b/docs/tests/protocols/test_stronghold_ce/test_get_status.rst new file mode 100644 index 0000000..05a8e18 --- /dev/null +++ b/docs/tests/protocols/test_stronghold_ce/test_get_status.rst @@ -0,0 +1,28 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "name": "Stronghold-Kreuzrittersadads", + "game_type": "Stronghold Crusader Extreme", + "map": "Unknown Map", + "num_players": 2, + "max_players": 8, + "password_protected": false, + "game_version": "1.4.1", + "game_mode": "Standard", + "difficulty": "Standard", + "speed": "Normal", + "players": [], + "raw": { + "magic": "aa00b0fa", + "buffer_length": 170, + "full_buffer": "aa00b0fa020008ff000000000000000000000000706c617901000e005000000044a00000d5716f81339e6147bb33c075fab5d595f04d0c49c79b4c4cb959d41f1cce460e08000000020000000000000000000000fd144b0400000000000000000000000000000000000000005c0000005300740072006f006e00670068006f006c0064002d004b007200650075007a007200690074007400650072007300610064006100640073000000", + "game_guid": "f04d0c49-c79b-4c4c-b959-d41f1cce460e", + "buffer_size": 170, + "buffer_preview": "aa00b0fa020008ff000000000000000000000000706c617901000e005000000044a00000d5716f81339e6147bb33c075fab5" + } + } diff --git a/docs/tests/protocols/test_stronghold_crusader/index.rst b/docs/tests/protocols/test_stronghold_crusader/index.rst new file mode 100644 index 0000000..61a5802 --- /dev/null +++ b/docs/tests/protocols/test_stronghold_crusader/index.rst @@ -0,0 +1,7 @@ +.. _test_stronghold_crusader: + +test_stronghold_crusader +======================== + +.. toctree:: + test_get_status diff --git a/docs/tests/protocols/test_stronghold_crusader/test_get_status.rst b/docs/tests/protocols/test_stronghold_crusader/test_get_status.rst new file mode 100644 index 0000000..fcb33d6 --- /dev/null +++ b/docs/tests/protocols/test_stronghold_crusader/test_get_status.rst @@ -0,0 +1,29 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "name": "Stronghold-Kreuzritter123", + "game_type": "Stronghold Crusader", + "map": "Unknown Map", + "num_players": 1, + "max_players": 8, + "password_protected": false, + "game_version": "1.41", + "game_mode": "Standard", + "difficulty": "Standard", + "speed": "Normal", + "players": [], + "raw": { + "magic": "a400b0fa", + "buffer_length": 164, + "full_buffer": "a400b0fa020008fc000000000000000000000000706c617901000e005000000044a0000001f40819a948014e97b5443d5707b266482f5e1dc0e8e549aed8b124da9e305908000000010000000000000000000000d078da0400000000000000000000000000000000000000005c0000005300740072006f006e00670068006f006c0064002d004b007200650075007a007200690074007400650072003100320033000000", + "game_guid": "482f5e1d-c0e8-e549-aed8-b124da9e3059", + "tcp_port": 2301, + "buffer_size": 164, + "buffer_preview": "a400b0fa020008fc000000000000000000000000706c617901000e005000000044a0000001f40819a948014e97b5443d5707" + } + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 3ab5210..547a83c 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -33,6 +33,8 @@ from opengsq.protocols.scum import Scum from opengsq.protocols.source import Source from opengsq.protocols.ssc import SSC +from opengsq.protocols.stronghold_ce import StrongholdCE +from opengsq.protocols.stronghold_crusader import StrongholdCrusader from opengsq.protocols.teamspeak3 import TeamSpeak3 from opengsq.protocols.trackmania_nations import TrackmaniaNations from opengsq.protocols.toxikk import Toxikk diff --git a/opengsq/protocols/stronghold_ce.py b/opengsq/protocols/stronghold_ce.py new file mode 100644 index 0000000..8ae2683 --- /dev/null +++ b/opengsq/protocols/stronghold_ce.py @@ -0,0 +1,269 @@ +from opengsq.protocols.directplay import DirectPlay +from opengsq.responses.stronghold_ce.status import Status +from opengsq.binary_reader import BinaryReader + + +class StrongholdCE(DirectPlay): + """ + Stronghold Crusader Extreme DirectPlay Protocol + + Erweitert das DirectPlay Basis-Protokoll um spezifische + Stronghold Crusader Extreme Implementierungsdetails. + """ + + full_name = "Stronghold Crusader Extreme DirectPlay Protocol" + + # Stronghold Crusader Extreme spezifische Konstanten und Payload + STRONGHOLD_CE_UDP_PAYLOAD = bytes.fromhex("3400b0fa020008fc000000000000000000000000706c617902000e00f04d0c49c79b4c4cb959d41f1cce460e0000000091000000") + + # DirectPlay Payload-Struktur für Stronghold Crusader Extreme: + # Bytes 0-27: Gemeinsamer DirectPlay Header (identisch mit AoE1/AoE2) + # Bytes 20-23: "play" - DirectPlay Identifikation + # Bytes 28-43: Spiel-spezifische GUID: f04d0c49-c79b-4c4c-b959-d41f1cce460e + # Bytes 44-47: Padding/Reserved (00 00 00 00) + # Bytes 48-51: Version/Type ID: 91 00 00 00 (145 dezimal) + STRONGHOLD_CE_GAME_GUID = "f04d0c49-c79b-4c4c-b959-d41f1cce460e" + + def __init__(self, host: str, port: int = DirectPlay.DIRECTPLAY_UDP_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + + def _build_query_packet(self) -> bytes: + """ + Erstellt das Stronghold Crusader Extreme-spezifische UDP Query Packet. + + Verwendet den echten DirectPlay-Payload für Stronghold Crusader Extreme: + 3400b0fa020008fc000000000000000000000000706c617902000e00f04d0c49c79b4c4cb959d41f1cce460e0000000091000000 + + Returns: + bytes: Das Stronghold CE Query Packet + """ + return self.STRONGHOLD_CE_UDP_PAYLOAD + + def _parse_response(self, buffer: bytes) -> dict: + """ + Parsed die TCP-Antwort vom Stronghold Crusader Extreme Server. + + Erweitert die Basis-DirectPlay-Parsing um Stronghold CE-spezifische Logik. + + Args: + buffer: Die rohen TCP-Antwortdaten + + Returns: + dict: Geparste Stronghold CE Server-Informationen + """ + # Nutze die Basis-DirectPlay-Parsing-Logik + result = super()._parse_response(buffer) + + # Stronghold CE-spezifische Anpassungen + result['game_type'] = 'Stronghold Crusader Extreme' + result['game_version'] = '1.4.1' # Stronghold Crusader Extreme Version + + # Versuche Stronghold CE-spezifische Daten zu parsen + try: + stronghold_data = self._parse_stronghold_ce_specific_data(buffer) + result.update(stronghold_data) + except Exception as e: + result['raw']['stronghold_ce_parse_error'] = str(e) + + # Debug-Informationen hinzufügen + result['raw']['game_guid'] = self.STRONGHOLD_CE_GAME_GUID + result['raw']['buffer_size'] = len(buffer) + result['raw']['buffer_preview'] = buffer[:50].hex() if len(buffer) > 50 else buffer.hex() + + return result + + def _parse_stronghold_ce_specific_data(self, buffer: bytes) -> dict: + """ + Parsed Stronghold CE-spezifische Daten aus der DirectPlay-Antwort. + + Args: + buffer: Die rohen Antwortdaten + + Returns: + dict: Stronghold CE-spezifische Daten + """ + result = {} + + if len(buffer) < 10: + return result + + br = BinaryReader(buffer) + + try: + # Skip DirectPlay Header (4 bytes) + br.read_bytes(4) + + # Versuche, Stronghold CE-spezifische Strukturen zu erkennen + remaining_data = br.read_bytes(br.remaining_bytes()) + + # Suche nach Spielnamen (Stronghold CE verwendet UTF-16LE Strings) + game_name = self._extract_stronghold_ce_game_name(remaining_data) + if game_name: + result['name'] = game_name + + # Versuche Spieleranzahl zu ermitteln + player_count = self._extract_stronghold_ce_player_count(remaining_data) + if player_count >= 0: + result['num_players'] = player_count + + # Versuche Max Players zu ermitteln + max_players = self._extract_stronghold_ce_max_players(remaining_data) + if max_players > 0: + result['max_players'] = max_players + + except Exception as e: + result['stronghold_ce_specific_error'] = str(e) + + return result + + def _extract_stronghold_ce_game_name(self, data: bytes) -> str: + """ + Versucht, den Spielnamen aus den Stronghold CE-Daten zu extrahieren. + + Stronghold CE verwendet UTF-16LE Strings mit 32-bit Length-Prefix, + ähnlich wie Age of Empires, aber mit eigener Struktur. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Spielname oder leer + """ + try: + # Suche nach dem UTF-16LE String-Pattern + # Der Spielname ist typischerweise am Ende des DirectPlay-Pakets + # In der Beispiel-Antwort beginnt der Name bei Offset ~92 (0x5c) + + # Analysiere die Beispiel-Antwort: + # aa00b0fa020008ff... bis ...5c0000005300740072006f006e00670068006f006c0064002d004b007200650075007a007200690074007400650072007300610064006100640073000000 + # Der 32-bit Length-Prefix ist 0x0000005c (92 bytes) für den gesamten String-Bereich + # Danach folgt der UTF-16LE String: "Stronghold-Kreuzrittersadads" + + # Suche nach 32-bit Length-Prefix für UTF-16LE String + search_start = max(0, len(data) - 200) # Starte weiter hinten + + for i in range(search_start, len(data) - 8, 4): + if i + 4 < len(data): + # Lese 32-bit Längenwert (little-endian) + potential_length = int.from_bytes(data[i:i+4], 'little') + + # Plausible Länge für einen Spielnamen (12-400 bytes für UTF-16LE) + # Der Length-Wert kann die gesamte String-Sektion oder nur den String repräsentieren + if 12 <= potential_length <= 400: + name_start = i + 4 + + # Begrenze auf verfügbare Daten + available_length = len(data) - name_start + effective_length = min(potential_length, available_length) + + if effective_length > 0: + name_bytes = data[name_start:name_start + effective_length] + + try: + # Stronghold CE verwendet UTF-16LE encoding + decoded = name_bytes.decode('utf-16le', errors='strict') + + # Finde den ersten null-terminierten String + null_pos = decoded.find('\x00') + if null_pos >= 0: + clean_name = decoded[:null_pos].strip() + else: + clean_name = decoded.strip() + + # Validierung: Name sollte druckbare Zeichen enthalten + # und "Stronghold" oder ähnliche Begriffe könnten vorkommen + if (len(clean_name) >= 3 and + all(ord(c) >= 32 or c.isspace() for c in clean_name) and + any(c.isalnum() for c in clean_name)): + return clean_name + except UnicodeDecodeError: + continue + + except Exception: + pass + + return "" + + def _extract_stronghold_ce_player_count(self, data: bytes) -> int: + """ + Versucht, die Spieleranzahl aus den Stronghold CE-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die Spieleranzahl oder 0 + """ + try: + # DirectPlay Session Data beginnt nach dem GUID + # Die Spielerzahl steht typischerweise bei festen Offsets + + # Bei Stronghold CE sind die Session-Daten strukturiert: + # Ähnlich wie bei AoE, aber mit möglicherweise anderen Offsets + + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 + current_players_offset = session_start + 28 + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + # Validierung der Werte (Stronghold CE unterstützt bis zu 8 Spieler) + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return current_players + + # Fallback: Suche nach plausiblen Werten + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + # Suche nach dem Muster: current_players, max_players + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return value + + except Exception: + pass + + return 0 + + def _extract_stronghold_ce_max_players(self, data: bytes) -> int: + """ + Versucht, die maximale Spieleranzahl aus den Stronghold CE-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die maximale Spieleranzahl oder 0 + """ + try: + # Verwende dieselbe Logik wie bei player_count, aber für max_players + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 + current_players_offset = session_start + 28 + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return max_players + + # Fallback: Suche nach dem zweiten Wert im Spieler-Paar + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return next_value + + except Exception: + pass + + return 8 # Standard für Stronghold Crusader Extreme + diff --git a/opengsq/protocols/stronghold_crusader.py b/opengsq/protocols/stronghold_crusader.py new file mode 100644 index 0000000..0a855bc --- /dev/null +++ b/opengsq/protocols/stronghold_crusader.py @@ -0,0 +1,269 @@ +from opengsq.protocols.directplay import DirectPlay +from opengsq.responses.stronghold_crusader.status import Status +from opengsq.binary_reader import BinaryReader + + +class StrongholdCrusader(DirectPlay): + """ + Stronghold Crusader DirectPlay Protocol + + Erweitert das DirectPlay Basis-Protokoll um spezifische + Stronghold Crusader Implementierungsdetails. + + Wichtig: Stronghold Crusader verwendet TCP Port 2301 statt 2300! + """ + + full_name = "Stronghold Crusader DirectPlay Protocol" + + # Stronghold Crusader spezifische Konstanten und Payload + STRONGHOLD_CRUSADER_UDP_PAYLOAD = bytes.fromhex("3400b0fa020008fd000000000000000000000000706c617902000e00482f5e1dc0e8e549aed8b124da9e30590000000091000000") + + # DirectPlay Payload-Struktur für Stronghold Crusader: + # Bytes 0-27: Gemeinsamer DirectPlay Header (identisch mit AoE1/AoE2) + # Bytes 20-23: "play" - DirectPlay Identifikation + # Bytes 28-43: Spiel-spezifische GUID: 482f5e1d-c0e8-e549-aed8-b124da9e3059 + # Bytes 44-47: Padding/Reserved (00 00 00 00) + # Bytes 48-51: Version/Type ID: 91 00 00 00 (145 dezimal) + STRONGHOLD_CRUSADER_GAME_GUID = "482f5e1d-c0e8-e549-aed8-b124da9e3059" + + # Stronghold Crusader verwendet TCP Port 2301 statt 2300 + STRONGHOLD_CRUSADER_TCP_PORT = 2301 + + def __init__(self, host: str, port: int = DirectPlay.DIRECTPLAY_UDP_PORT, timeout: float = 5.0): + super().__init__(host, port, timeout) + # Überschreibe den TCP Listen Port für Stronghold Crusader + self._tcp_listen_port = self.STRONGHOLD_CRUSADER_TCP_PORT + + def _build_query_packet(self) -> bytes: + """ + Erstellt das Stronghold Crusader-spezifische UDP Query Packet. + + Verwendet den echten DirectPlay-Payload für Stronghold Crusader: + 3400b0fa020008fd000000000000000000000000706c617902000e00482f5e1dc0e8e549aed8b124da9e30590000000091000000 + + Returns: + bytes: Das Stronghold Crusader Query Packet + """ + return self.STRONGHOLD_CRUSADER_UDP_PAYLOAD + + def _parse_response(self, buffer: bytes) -> dict: + """ + Parsed die TCP-Antwort vom Stronghold Crusader Server. + + Erweitert die Basis-DirectPlay-Parsing um Stronghold Crusader-spezifische Logik. + + Args: + buffer: Die rohen TCP-Antwortdaten + + Returns: + dict: Geparste Stronghold Crusader Server-Informationen + """ + # Nutze die Basis-DirectPlay-Parsing-Logik + result = super()._parse_response(buffer) + + # Stronghold Crusader-spezifische Anpassungen + result['game_type'] = 'Stronghold Crusader' + result['game_version'] = '1.41' # Stronghold Crusader Version + + # Versuche Stronghold Crusader-spezifische Daten zu parsen + try: + stronghold_data = self._parse_stronghold_crusader_specific_data(buffer) + result.update(stronghold_data) + except Exception as e: + result['raw']['stronghold_crusader_parse_error'] = str(e) + + # Debug-Informationen hinzufügen + result['raw']['game_guid'] = self.STRONGHOLD_CRUSADER_GAME_GUID + result['raw']['tcp_port'] = self.STRONGHOLD_CRUSADER_TCP_PORT + result['raw']['buffer_size'] = len(buffer) + result['raw']['buffer_preview'] = buffer[:50].hex() if len(buffer) > 50 else buffer.hex() + + return result + + def _parse_stronghold_crusader_specific_data(self, buffer: bytes) -> dict: + """ + Parsed Stronghold Crusader-spezifische Daten aus der DirectPlay-Antwort. + + Args: + buffer: Die rohen Antwortdaten + + Returns: + dict: Stronghold Crusader-spezifische Daten + """ + result = {} + + if len(buffer) < 10: + return result + + br = BinaryReader(buffer) + + try: + # Skip DirectPlay Header (4 bytes) + br.read_bytes(4) + + # Versuche, Stronghold Crusader-spezifische Strukturen zu erkennen + remaining_data = br.read_bytes(br.remaining_bytes()) + + # Suche nach Spielnamen (Stronghold Crusader verwendet UTF-16LE Strings) + game_name = self._extract_stronghold_crusader_game_name(remaining_data) + if game_name: + result['name'] = game_name + + # Versuche Spieleranzahl zu ermitteln + player_count = self._extract_stronghold_crusader_player_count(remaining_data) + if player_count >= 0: + result['num_players'] = player_count + + # Versuche Max Players zu ermitteln + max_players = self._extract_stronghold_crusader_max_players(remaining_data) + if max_players > 0: + result['max_players'] = max_players + + except Exception as e: + result['stronghold_crusader_specific_error'] = str(e) + + return result + + def _extract_stronghold_crusader_game_name(self, data: bytes) -> str: + """ + Versucht, den Spielnamen aus den Stronghold Crusader-Daten zu extrahieren. + + Stronghold Crusader verwendet UTF-16LE Strings mit 32-bit Length-Prefix, + ähnlich wie Age of Empires und Stronghold CE. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + str: Der Spielname oder leer + """ + try: + # Suche nach dem UTF-16LE String-Pattern + # Der Spielname ist typischerweise am Ende des DirectPlay-Pakets + + # Suche nach 32-bit Length-Prefix für UTF-16LE String + search_start = max(0, len(data) - 200) # Starte weiter hinten + + for i in range(search_start, len(data) - 8, 4): + if i + 4 < len(data): + # Lese 32-bit Längenwert (little-endian) + potential_length = int.from_bytes(data[i:i+4], 'little') + + # Plausible Länge für einen Spielnamen (12-400 bytes für UTF-16LE) + if 12 <= potential_length <= 400: + name_start = i + 4 + + # Begrenze auf verfügbare Daten + available_length = len(data) - name_start + effective_length = min(potential_length, available_length) + + if effective_length > 0: + name_bytes = data[name_start:name_start + effective_length] + + try: + # Stronghold Crusader verwendet UTF-16LE encoding + decoded = name_bytes.decode('utf-16le', errors='strict') + + # Finde den ersten null-terminierten String + null_pos = decoded.find('\x00') + if null_pos >= 0: + clean_name = decoded[:null_pos].strip() + else: + clean_name = decoded.strip() + + # Validierung: Name sollte druckbare Zeichen enthalten + if (len(clean_name) >= 3 and + all(ord(c) >= 32 or c.isspace() for c in clean_name) and + any(c.isalnum() for c in clean_name)): + return clean_name + except UnicodeDecodeError: + continue + + except Exception: + pass + + return "" + + def _extract_stronghold_crusader_player_count(self, data: bytes) -> int: + """ + Versucht, die Spieleranzahl aus den Stronghold Crusader-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die Spieleranzahl oder 0 + """ + try: + # DirectPlay Session Data beginnt nach dem GUID + # Die Spielerzahl steht typischerweise bei festen Offsets + + # Bei Stronghold Crusader sind die Session-Daten strukturiert: + # Ähnlich wie bei AoE, aber mit möglicherweise anderen Offsets + + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 + current_players_offset = session_start + 28 + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + # Validierung der Werte (Stronghold Crusader unterstützt bis zu 8 Spieler) + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return current_players + + # Fallback: Suche nach plausiblen Werten + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + # Suche nach dem Muster: current_players, max_players + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return value + + except Exception: + pass + + return 0 + + def _extract_stronghold_crusader_max_players(self, data: bytes) -> int: + """ + Versucht, die maximale Spieleranzahl aus den Stronghold Crusader-Daten zu extrahieren. + + Args: + data: Die Daten nach dem DirectPlay-Header + + Returns: + int: Die maximale Spieleranzahl oder 0 + """ + try: + # Verwende dieselbe Logik wie bei player_count, aber für max_players + if len(data) >= 48: + session_start = 40 + + if len(data) >= session_start + 28: + max_players_offset = session_start + 24 + current_players_offset = session_start + 28 + + max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little') + current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little') + + if 1 <= max_players <= 8 and 0 <= current_players <= max_players: + return max_players + + # Fallback: Suche nach dem zweiten Wert im Spieler-Paar + for i in range(len(data) - 8): + value = int.from_bytes(data[i:i+4], 'little') + next_value = int.from_bytes(data[i+4:i+8], 'little') + + if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value): + return next_value + + except Exception: + pass + + return 8 # Standard für Stronghold Crusader + diff --git a/opengsq/responses/stronghold_ce/__init__.py b/opengsq/responses/stronghold_ce/__init__.py new file mode 100644 index 0000000..c9aa210 --- /dev/null +++ b/opengsq/responses/stronghold_ce/__init__.py @@ -0,0 +1,4 @@ +from opengsq.responses.stronghold_ce.status import Status + +__all__ = ['Status'] + diff --git a/opengsq/responses/stronghold_ce/status.py b/opengsq/responses/stronghold_ce/status.py new file mode 100644 index 0000000..0a78b26 --- /dev/null +++ b/opengsq/responses/stronghold_ce/status.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List +from opengsq.responses.directplay.status import Status as DirectPlayStatus + + +@dataclass +class Status(DirectPlayStatus): + """Stronghold Crusader Extreme specific status response""" + + # Stronghold Crusader Extreme spezifische Felder können hier hinzugefügt werden + # wenn weitere Informationen aus dem Spiel extrahiert werden können + pass + diff --git a/opengsq/responses/stronghold_crusader/__init__.py b/opengsq/responses/stronghold_crusader/__init__.py new file mode 100644 index 0000000..6d4c18a --- /dev/null +++ b/opengsq/responses/stronghold_crusader/__init__.py @@ -0,0 +1,4 @@ +from opengsq.responses.stronghold_crusader.status import Status + +__all__ = ['Status'] + diff --git a/opengsq/responses/stronghold_crusader/status.py b/opengsq/responses/stronghold_crusader/status.py new file mode 100644 index 0000000..e6aff31 --- /dev/null +++ b/opengsq/responses/stronghold_crusader/status.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List +from opengsq.responses.directplay.status import Status as DirectPlayStatus + + +@dataclass +class Status(DirectPlayStatus): + """Stronghold Crusader specific status response""" + + # Stronghold Crusader spezifische Felder können hier hinzugefügt werden + # wenn weitere Informationen aus dem Spiel extrahiert werden können + pass + diff --git a/tests/protocols/test_stronghold_ce.py b/tests/protocols/test_stronghold_ce.py new file mode 100644 index 0000000..8b1da3b --- /dev/null +++ b/tests/protocols/test_stronghold_ce.py @@ -0,0 +1,16 @@ +import pytest +from opengsq.protocols.stronghold_ce import StrongholdCE + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +# StrongHold: Crusader Europe +stronghold_ce = StrongholdCE(host="172.29.100.29") + + +@pytest.mark.asyncio +async def test_get_status(): + result = await stronghold_ce.get_status() + await handler.save_result("test_get_status", result) diff --git a/tests/protocols/test_stronghold_crusader.py b/tests/protocols/test_stronghold_crusader.py new file mode 100644 index 0000000..dbea5ca --- /dev/null +++ b/tests/protocols/test_stronghold_crusader.py @@ -0,0 +1,16 @@ +import pytest +from opengsq.protocols.stronghold_crusader import StrongholdCrusader + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +# StrongHold: Crusader Europe +stronghold_crusader = StrongholdCrusader(host="172.29.100.29") + + +@pytest.mark.asyncio +async def test_get_status(): + result = await stronghold_crusader.get_status() + await handler.save_result("test_get_status", result) From a069f42f926d883339965a7af3d12824fe63453c Mon Sep 17 00:00:00 2001 From: Hornochs Date: Fri, 24 Oct 2025 13:46:16 +0200 Subject: [PATCH 20/20] Deleting not needed Test File anymore --- tests/protocols/test_tmn.py | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 tests/protocols/test_tmn.py diff --git a/tests/protocols/test_tmn.py b/tests/protocols/test_tmn.py deleted file mode 100644 index e5987b8..0000000 --- a/tests/protocols/test_tmn.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -from opengsq.protocols.tmn import TMN - -from ..result_handler import ResultHandler - -handler = ResultHandler(__file__) -handler.enable_save = True -handler.delay_per_test = 1 - -# tmn -tmn = TMN(host="172.29.100.25") - - -@pytest.mark.asyncio -async def test_get_info(): - result = await tmn.get_status() - await handler.save_result("test_get_status", result) \ No newline at end of file