From c83842530fd2ae64a24b161482cb20ce3565c265 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Sat, 31 May 2025 16:17:43 +0200 Subject: [PATCH 1/5] Adding Flatout2 Support --- docs/tests/protocols/index.rst | 2 +- docs/tests/protocols/test_flatout2/index.rst | 8 + .../test_flatout2_get_status.rst | 16 ++ .../test_flatout2/test_get_status.rst | 17 ++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/flatout2.py | 168 ++++++++++++++++++ opengsq/responses/flatout2.py | 27 +++ opengsq/responses/flatout2/__init__.py | 1 + opengsq/responses/flatout2/status.py | 36 ++++ tests/protocols/test_flatout2.py | 146 +++++++++++++++ 10 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 docs/tests/protocols/test_flatout2/index.rst create mode 100644 docs/tests/protocols/test_flatout2/test_flatout2_get_status.rst create mode 100644 docs/tests/protocols/test_flatout2/test_get_status.rst create mode 100644 opengsq/protocols/flatout2.py create mode 100644 opengsq/responses/flatout2.py create mode 100644 opengsq/responses/flatout2/__init__.py create mode 100644 opengsq/responses/flatout2/status.py create mode 100644 tests/protocols/test_flatout2.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index 7b83a16..b2763d7 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -29,8 +29,8 @@ Protocols Tests test_palworld/index test_quake2/index test_gamespy2/index + test_flatout2/index test_doom3/index test_vcmp/index test_satisfactory/index test_gamespy3/index - test_renegadex/index \ No newline at end of file diff --git a/docs/tests/protocols/test_flatout2/index.rst b/docs/tests/protocols/test_flatout2/index.rst new file mode 100644 index 0000000..bb8cf82 --- /dev/null +++ b/docs/tests/protocols/test_flatout2/index.rst @@ -0,0 +1,8 @@ +.. _test_flatout2: + +test_flatout2 +============= + +.. toctree:: + test_flatout2_get_status + test_get_status diff --git a/docs/tests/protocols/test_flatout2/test_flatout2_get_status.rst b/docs/tests/protocols/test_flatout2/test_flatout2_get_status.rst new file mode 100644 index 0000000..152b018 --- /dev/null +++ b/docs/tests/protocols/test_flatout2/test_flatout2_get_status.rst @@ -0,0 +1,16 @@ +test_flatout2_get_status +======================== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "hostname": "TestServer", + "timestamp": "-4022673819270859506", + "flags": "679480060", + "status": "3422879744", + "config": "c00000000000081000086124" + } + } diff --git a/docs/tests/protocols/test_flatout2/test_get_status.rst b/docs/tests/protocols/test_flatout2/test_get_status.rst new file mode 100644 index 0000000..a91a13c --- /dev/null +++ b/docs/tests/protocols/test_flatout2/test_get_status.rst @@ -0,0 +1,17 @@ +test_get_status +=============== + +Here are the results for the test method. + +.. code-block:: json + + { + "info": { + "hostname": "TestServer", + "timestamp": "1234567890", + "flags": "2818572332", + "status": "6094848", + "config": "000000000810000861240400101440" + }, + "players": [] + } \ No newline at end of file diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index b9513b7..64510c6 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -3,6 +3,7 @@ from opengsq.protocols.doom3 import Doom3 from opengsq.protocols.eos import EOS from opengsq.protocols.fivem import FiveM +from opengsq.protocols.flatout2 import Flatout2 from opengsq.protocols.gamespy1 import GameSpy1 from opengsq.protocols.gamespy2 import GameSpy2 from opengsq.protocols.gamespy3 import GameSpy3 diff --git a/opengsq/protocols/flatout2.py b/opengsq/protocols/flatout2.py new file mode 100644 index 0000000..e3081d1 --- /dev/null +++ b/opengsq/protocols/flatout2.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import os +from opengsq.binary_reader import BinaryReader +from opengsq.exceptions import InvalidPacketException +from opengsq.protocol_base import ProtocolBase +from opengsq.protocol_socket import UdpClient +from opengsq.responses.flatout2 import Status + + +class Flatout2(ProtocolBase): + """ + This class represents the Flatout 2 Protocol. It provides methods to interact with Flatout 2 game servers. + The protocol uses broadcast packets to discover and query servers. + """ + + full_name = "Flatout 2 Protocol" + FLATOUT2_PORT = 23757 # Default broadcast port for Flatout 2 + + # Protocol specific constants + REQUEST_HEADER = b"\x22\x00" + RESPONSE_HEADER = b"\x59\x00" + GAME_IDENTIFIER = b"FO14" + SESSION_ID = b"\x99\x72\xcc\x8f" + COMMAND_QUERY = b"\x18\x0c" + PACKET_END = b"\x2e\x55\x19\xb4\xe1\x4f\x81\x4a" + + def __init__(self, host: str, port: int = FLATOUT2_PORT, timeout: float = 5.0): + """ + Initialize the Flatout 2 protocol handler. + + :param host: The broadcast address (usually '255.255.255.255' for LAN) + :param port: The port to use (default: 23757) + :param timeout: Connection timeout in seconds + """ + if port != self.FLATOUT2_PORT: + raise ValueError(f"Flatout 2 protocol requires port {self.FLATOUT2_PORT}") + super().__init__(host, self.FLATOUT2_PORT, timeout) + self._allow_broadcast = True + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the status of Flatout 2 servers via broadcast. + Expects a response packet with server information. + + :return: A Status object containing the status of the game server. + """ + # Build the request packet + request_data = ( + self.REQUEST_HEADER + # Protocol header + self.SESSION_ID + # Session ID + b"\x00" * 4 + # Padding pre-identifier + self.GAME_IDENTIFIER + # "FO14" + b"\x00" * 8 + # Padding post-identifier + self.COMMAND_QUERY + # Query command + b"\x00\x00\x22\x00" + # Command data + self.PACKET_END # Standard packet end + ) + + # Send broadcast and receive response + data = await UdpClient.communicate(self, request_data, source_port=self.FLATOUT2_PORT) + + # Debug output for packet analysis + print(f"Response header: {data[:2].hex()}, Expected: {self.RESPONSE_HEADER.hex()}") + print(f"Session ID: {data[2:6].hex()}") + print(f"Game ID: {data[10:14]}, Expected: {self.GAME_IDENTIFIER}") + print(f"Full packet length: {len(data)}") + + # Verify response packet + if not self._verify_packet(data): + raise InvalidPacketException("Invalid response packet received") + + br = BinaryReader(data) + return self._parse_response(br) + + def _verify_packet(self, data: bytes) -> bool: + """ + Verifies that a packet is a valid Flatout 2 response. + + :param data: The packet data to verify + :return: True if the packet is valid, False otherwise + """ + if len(data) < 14: # Minimum length for header + session ID + padding + game ID + print(f"Packet too short: {len(data)} bytes") + return False + + # Check response header + header_matches = data.startswith(self.RESPONSE_HEADER) + if not header_matches: + print(f"Header mismatch: got {data[:2].hex()}, expected {self.RESPONSE_HEADER.hex()}") + return False + + # Check game identifier (position 10-14, after session ID and padding) + game_id = data[10:14] + game_id_matches = game_id == self.GAME_IDENTIFIER + if not game_id_matches: + print(f"Game ID mismatch: got {game_id}, expected {self.GAME_IDENTIFIER}") + return False + + return True + + def _read_utf16_string(self, br: BinaryReader) -> str: + """ + Reads a UTF-16 encoded string from the binary reader. + + :param br: The binary reader to read from + :return: The decoded string + """ + bytes_list = [] + while True: + # Read two bytes at a time (UTF-16) + char_bytes = br.read_bytes(2) + if char_bytes == b"\x00\x00": # End of string + break + bytes_list.extend(char_bytes) + + return bytes(bytes_list).decode('utf-16-le').strip() + + def _parse_response(self, br: BinaryReader) -> Status: + """ + Parses the binary response into a Status object. + The response contains UTF-16 encoded strings and various server information. + + :param br: BinaryReader containing the response data + :return: A Status object containing the parsed information + """ + # Skip header (2), session ID (4), padding (4), game ID (4), padding (8), command (4), data (2), and packet end (8) + br.read_bytes(36) # Skip to the server data section + + info = {} + + try: + # Read server name (UTF-16 encoded) + server_name = self._read_utf16_string(br) + info["hostname"] = server_name + + # Read server information + timestamp = br.read_long_long() # Server timestamp + info["timestamp"] = str(timestamp) + + server_flags = br.read_long(unsigned=True) # Server configuration flags + info["flags"] = str(server_flags) + + # Skip reserved bytes + br.read_bytes(8) + + # Read server status + status_flags = br.read_long(unsigned=True) + info["status"] = str(status_flags) + + # Read server configuration + config = br.read_bytes(12) # Remaining configuration data + info["config"] = config.hex() + + except Exception as e: + print(f"Error parsing response: {e}") + + return Status(info=info) + + +if __name__ == "__main__": + import asyncio + + async def main_async(): + # Use broadcast address for LAN discovery + flatout2 = Flatout2(host="255.255.255.255", port=23757, timeout=5.0) + status = await flatout2.get_status() + print(status) \ No newline at end of file diff --git a/opengsq/responses/flatout2.py b/opengsq/responses/flatout2.py new file mode 100644 index 0000000..ea62937 --- /dev/null +++ b/opengsq/responses/flatout2.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Dict, List, Any + + +@dataclass +class Status: + """ + Represents the status response from a Flatout 2 server. + Contains server information and player data. + """ + info: Dict[str, Any] + players: List[Dict[str, Any]] + + def __str__(self) -> str: + """ + Returns a human-readable string representation of the server status. + """ + output = ["Server Information:"] + for key, value in self.info.items(): + output.append(f"{key}: {value}") + + if self.players: + output.append("\nPlayers:") + for player in self.players: + output.append(f"{player['name']} - Score: {player['score']}") + + return "\n".join(output) \ No newline at end of file diff --git a/opengsq/responses/flatout2/__init__.py b/opengsq/responses/flatout2/__init__.py new file mode 100644 index 0000000..f4d7d9c --- /dev/null +++ b/opengsq/responses/flatout2/__init__.py @@ -0,0 +1 @@ +from .status import Status \ No newline at end of file diff --git a/opengsq/responses/flatout2/status.py b/opengsq/responses/flatout2/status.py new file mode 100644 index 0000000..9279149 --- /dev/null +++ b/opengsq/responses/flatout2/status.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + + +@dataclass +class Status: + """ + Represents the status of a Flatout 2 server. + """ + + info: Dict[str, str] + """ + Server information dictionary containing: + - hostname: Server name (UTF-16 encoded) + - timestamp: Server timestamp + - flags: Server configuration flags + - status: Server status flags + - config: Additional configuration data in hex format + """ + + def __str__(self) -> str: + """ + Returns a human-readable string representation of the server status. + + :return: Formatted server status string + """ + result = [] + + # Add server info + result.append("Server Information:") + for key, value in self.info.items(): + result.append(f" {key}: {value}") + + return "\n".join(result) \ No newline at end of file diff --git a/tests/protocols/test_flatout2.py b/tests/protocols/test_flatout2.py new file mode 100644 index 0000000..748b64c --- /dev/null +++ b/tests/protocols/test_flatout2.py @@ -0,0 +1,146 @@ +import pytest +from unittest.mock import AsyncMock, patch + +from opengsq.protocols.flatout2 import Flatout2 +from opengsq.exceptions import InvalidPacketException +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +@pytest.mark.asyncio +async def test_flatout2_get_status(): + """Test successful server status query via broadcast""" + # Example response packet based on actual server response + response_data = bytes.fromhex( + "5900" # Response header + "9972cc8f" # Session ID + "00000000" # Padding pre-identifier + "464f3134" # "FO14" Game Identifier + "0000000000000000" # Padding post-identifier + "19437312" # Command + "0000" # Additional data + "2e5519b4e14f814a" # Packet end marker + "47006f006d00620069000000" # Server name "Gombi" in UTF-16 + "0eb518737997" # Timestamp + "2cc8fc0a80281000" # Server flags + "0000000000000000" # Reserved bytes + "05ccc000" # Status flags + "000000000810000861240400101440" # Configuration data + ) + + print(f"\nTest packet structure:") + print(f"Header: {response_data[:2].hex()}") + print(f"Session ID: {response_data[2:6].hex()}") + print(f"Game ID: {response_data[10:14]}") + print(f"Full packet: {response_data.hex()}") + + # Mock the UdpClient.communicate method + mock_communicate = AsyncMock(return_value=response_data) + + with patch("opengsq.protocol_socket.UdpClient.communicate", mock_communicate): + flatout2 = Flatout2(host="255.255.255.255") + status = await flatout2.get_status() + + # Verify the mock was called with broadcast parameters + mock_communicate.assert_called_once() + call_args = mock_communicate.call_args + assert call_args is not None + args, kwargs = call_args + assert kwargs.get('source_port') == Flatout2.FLATOUT2_PORT + + # Verify server info + assert "hostname" in status.info + assert status.info["hostname"] # Check that hostname is not empty + assert "timestamp" in status.info + assert "flags" in status.info + assert "status" in status.info + assert "config" in status.info + + # Save the result for documentation + await handler.save_result("test_flatout2_get_status", status) + + +@pytest.mark.asyncio +async def test_flatout2_invalid_header(): + """Test handling of invalid packet header""" + # Response with invalid header (0x22 instead of 0x59) + invalid_response = bytes.fromhex( + "2200" # Wrong header + "9972cc8f" # Session ID + "00000000" # Padding pre-identifier + "464f3134" # "FO14" Game Identifier + "0000000000000000" # Padding post-identifier + "19437312" # Command + "0000" # Additional data + "2e5519b4e14f814a" # Packet end marker + "47006f006d00620069000000" # Server name "Gombi" in UTF-16 + "0eb518737997" # Rest of the data... + "2cc8fc0a802810000000000000000000000005ccc00000000000" + "081000086124040010144000" + ) + + # Mock the UdpClient.communicate method + mock_communicate = AsyncMock(return_value=invalid_response) + + with patch("opengsq.protocol_socket.UdpClient.communicate", mock_communicate): + flatout2 = Flatout2(host="255.255.255.255") + with pytest.raises(InvalidPacketException): + await flatout2.get_status() + + +@pytest.mark.asyncio +async def test_flatout2_invalid_game_id(): + """Test handling of invalid game identifier""" + # Response with wrong game ID (BADID instead of FO14) + invalid_game_id = bytes.fromhex( + "5900" # Response header + "9972cc8f" # Session ID + "00000000" # Padding pre-identifier + "42414449" # Wrong game ID "BADI" + "0000000000000000" # Padding post-identifier + "19437312" # Command + "0000" # Additional data + "2e5519b4e14f814a" # Packet end marker + "47006f006d00620069000000" # Server name "Gombi" in UTF-16 + "0eb518737997" # Rest of the data... + "2cc8fc0a802810000000000000000000000005ccc00000000000" + "081000086124040010144000" + ) + + # Mock the UdpClient.communicate method + mock_communicate = AsyncMock(return_value=invalid_game_id) + + with patch("opengsq.protocol_socket.UdpClient.communicate", mock_communicate): + flatout2 = Flatout2(host="255.255.255.255") + with pytest.raises(InvalidPacketException): + await flatout2.get_status() + + +@pytest.mark.asyncio +async def test_flatout2_wrong_port(): + """Test handling of wrong port number""" + with pytest.raises(ValueError) as exc_info: + Flatout2(host="255.255.255.255", port=27015) # Wrong port + assert str(exc_info.value) == f"Flatout 2 protocol requires port {Flatout2.FLATOUT2_PORT}" + + +def test_status_string_representation(): + """Test the string representation of the Status class""" + from opengsq.responses.flatout2 import Status + + # Create a test status object + info = { + "hostname": "Test Server", + "timestamp": "123456789", + "flags": "1234", + "status": "5678", + "config": "abcdef" + } + + status = Status(info=info) + str_repr = str(status) + + # Verify the string representation + assert "Server Information:" in str_repr + assert "hostname:" in str_repr # Just check that the hostname field exists \ No newline at end of file From 68db41eb012f18219af12c316603833d490624c1 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Sat, 31 May 2025 22:36:28 +0200 Subject: [PATCH 2/5] First Working Warcraft3 FT Implementation --- docs/tests/protocols/index.rst | 1 + docs/tests/protocols/test_warcraft3/index.rst | 7 ++ .../test_warcraft3/test_warcraft3_status.rst | 23 +++++ opengsq/protocols/__init__.py | 1 + opengsq/protocols/warcraft3.py | 89 +++++++++++++++++++ opengsq/responses/warcraft3/__init__.py | 4 + opengsq/responses/warcraft3/player.py | 9 ++ opengsq/responses/warcraft3/status.py | 17 ++++ tests/protocols/test_warcraft3.py | 30 +++++++ 9 files changed, 181 insertions(+) create mode 100644 docs/tests/protocols/test_warcraft3/index.rst create mode 100644 docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst create mode 100644 opengsq/protocols/warcraft3.py create mode 100644 opengsq/responses/warcraft3/__init__.py create mode 100644 opengsq/responses/warcraft3/player.py create mode 100644 opengsq/responses/warcraft3/status.py create mode 100644 tests/protocols/test_warcraft3.py diff --git a/docs/tests/protocols/index.rst b/docs/tests/protocols/index.rst index b2763d7..d8dc0ef 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -23,6 +23,7 @@ Protocols Tests test_ut3/index test_unreal2/index test_quake3/index + test_warcraft3/index test_nadeo/index test_battlefield/index test_fivem/index diff --git a/docs/tests/protocols/test_warcraft3/index.rst b/docs/tests/protocols/test_warcraft3/index.rst new file mode 100644 index 0000000..7bdf91e --- /dev/null +++ b/docs/tests/protocols/test_warcraft3/index.rst @@ -0,0 +1,7 @@ +.. _test_warcraft3: + +test_warcraft3 +============== + +.. toctree:: + test_warcraft3_status diff --git a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst new file mode 100644 index 0000000..a938b5d --- /dev/null +++ b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst @@ -0,0 +1,23 @@ +test_warcraft3_status +===================== + +Here are the results for the test method. + +.. code-block:: json + + { + "game_version": "01000000", + "hostname": "Lokales Spiel (Banane)", + "map_name": "Unknown", + "game_type": "Unknown", + "num_players": 0, + "max_players": 12, + "players": [], + "raw": { + "response_size": "8a00", + "protocol_id": "505833571a000000", + "game_version": "01000000", + "server_info": "943e0200", + "remaining_data": "00010349070101a101b94901138fe76f4d8b6171735d293329ad436f6f757943613b792f77336d01432b616f616f6501011577e33b25fd815d73437739539341d13973adcb3f772100020000000900000001000000020000001a070000e017" + } + } diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index 64510c6..91e7083 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -28,4 +28,5 @@ from opengsq.protocols.unreal2 import Unreal2 from opengsq.protocols.ut3 import UT3 from opengsq.protocols.vcmp import Vcmp +from opengsq.protocols.warcraft3 import Warcraft3 from opengsq.protocols.won import WON \ No newline at end of file diff --git a/opengsq/protocols/warcraft3.py b/opengsq/protocols/warcraft3.py new file mode 100644 index 0000000..81b8cfb --- /dev/null +++ b/opengsq/protocols/warcraft3.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from opengsq.protocol_base import ProtocolBase +from opengsq.protocol_socket import UdpClient +from opengsq.binary_reader import BinaryReader +from opengsq.responses.warcraft3 import Status, Player + +class Warcraft3(ProtocolBase): + """ + This class represents the Warcraft 3 Protocol. It provides methods to interact with Warcraft 3 game servers. + """ + + full_name = "Warcraft 3 Protocol" + WARCRAFT3_PORT = 6112 # Default port for Warcraft 3 + + # Protocol specific constants + _REQUEST_HEADER = bytes.fromhex("f7 2f 10 00 50 58 33 57") + _RESPONSE_HEADER = bytes.fromhex("f7 30") + + def __init__(self, host: str, port: int = WARCRAFT3_PORT, timeout: float = 5.0): + """ + Initialize the Warcraft 3 protocol handler. + + :param host: The server address + :param port: The port to use (default: 6112) + :param timeout: Connection timeout in seconds + """ + if port != self.WARCRAFT3_PORT: + raise ValueError(f"Warcraft 3 protocol requires port {self.WARCRAFT3_PORT}") + super().__init__(host, self.WARCRAFT3_PORT, timeout) + + async def get_status(self) -> Status: + """ + Asynchronously retrieves the status of the game server. + + :return: A Status object containing the status of the game server. + """ + # Create the full request packet + request = self._REQUEST_HEADER + bytes.fromhex("1a 00 00 00 00 00 00 00") + + # Send request and receive response + response = await UdpClient.communicate(self, request) + + # Validate response header + if not response.startswith(self._RESPONSE_HEADER): + raise Exception("Invalid response header") + + # Parse the response using BinaryReader + br = BinaryReader(response[2:]) # Skip the header bytes + + # Read response size + response_size = br.read_bytes(2) # 8A 00 + + # Read protocol identifier + protocol_id = br.read_bytes(8) # 50 58 33 57 1A 00 00 00 + + # Read game version + game_version = br.read_bytes(4) # 01 00 00 00 + + # Read server info + server_info = br.read_bytes(4) # 94 3E 02 00 + + # Read hostname (null-terminated string) + hostname = "" + while True: + byte = br.read_bytes(1) + if byte == b'\x00': + break + hostname += byte.decode('latin1') + + # Store raw data for debugging + raw = { + 'response_size': response_size.hex(), + 'protocol_id': protocol_id.hex(), + 'game_version': game_version.hex(), + 'server_info': server_info.hex(), + 'remaining_data': br.read_bytes(br.remaining_bytes()).hex() + } + + return Status( + game_version=game_version.hex(), + hostname=hostname, + map_name="Unknown", # TODO: Parse from remaining data + game_type="Unknown", # TODO: Parse from remaining data + num_players=0, # TODO: Parse from remaining data + max_players=12, # Default max players in WC3 + players=[], # TODO: Parse from remaining data + raw=raw + ) \ No newline at end of file diff --git a/opengsq/responses/warcraft3/__init__.py b/opengsq/responses/warcraft3/__init__.py new file mode 100644 index 0000000..aa5e6c5 --- /dev/null +++ b/opengsq/responses/warcraft3/__init__.py @@ -0,0 +1,4 @@ +from opengsq.responses.warcraft3.status import Status +from opengsq.responses.warcraft3.player import Player + +__all__ = ["Status", "Player"] \ No newline at end of file diff --git a/opengsq/responses/warcraft3/player.py b/opengsq/responses/warcraft3/player.py new file mode 100644 index 0000000..4dee32b --- /dev/null +++ b/opengsq/responses/warcraft3/player.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +@dataclass +class Player: + """ + Represents a player in a Warcraft 3 game server. + """ + name: str + ping: int \ No newline at end of file diff --git a/opengsq/responses/warcraft3/status.py b/opengsq/responses/warcraft3/status.py new file mode 100644 index 0000000..7fffa1f --- /dev/null +++ b/opengsq/responses/warcraft3/status.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import List, Dict, Any +from opengsq.responses.warcraft3.player import Player + +@dataclass +class Status: + """ + Represents the status of a Warcraft 3 game server. + """ + game_version: str + hostname: str + map_name: str + game_type: str + num_players: int + max_players: int + players: List[Player] + raw: Dict[str, Any] \ No newline at end of file diff --git a/tests/protocols/test_warcraft3.py b/tests/protocols/test_warcraft3.py new file mode 100644 index 0000000..a22c47a --- /dev/null +++ b/tests/protocols/test_warcraft3.py @@ -0,0 +1,30 @@ +import pytest +from opengsq.protocols.warcraft3 import Warcraft3 +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +handler.enable_save = True + +@pytest.mark.asyncio +async def test_warcraft3_status(): + warcraft3 = Warcraft3(host="10.10.101.4", port=6112) # Replace with your test server + result = await warcraft3.get_status() + + print("\nWarcraft 3 Server Details:") + print(f"Server Name: {result.hostname}") + print(f"Game Version: {result.game_version}") + print(f"Map: {result.map_name}") + print(f"Game Type: {result.game_type}") + print(f"Players: {result.num_players}/{result.max_players}") + + print("\nPlayers:") + for player in result.players: + print(f" {player.name} (Ping: {player.ping}ms)") + + # Example raw data that might be useful for debugging + print("\nRaw Data:") + if hasattr(result, 'raw'): + for key, value in result.raw.items(): + print(f"{key}: {value}") + + await handler.save_result("test_warcraft3_status", result) \ No newline at end of file From 311840d314c00a9ab7383de037758e8eaf7a0dc5 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Sat, 31 May 2025 23:31:52 +0200 Subject: [PATCH 3/5] Better WC3 Implementation with Mapping Issues --- .../test_warcraft3/test_warcraft3_status.rst | 23 +- opengsq/protocols/warcraft3.py | 223 ++++++++++++++---- 2 files changed, 193 insertions(+), 53 deletions(-) diff --git a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst index a938b5d..cae36a4 100644 --- a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst +++ b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst @@ -6,18 +6,21 @@ Here are the results for the test method. .. code-block:: json { - "game_version": "01000000", + "game_version": "PX3W 26", "hostname": "Lokales Spiel (Banane)", - "map_name": "Unknown", - "game_type": "Unknown", - "num_players": 0, - "max_players": 12, + "map_name": "±u\u0001ùéÛÿM‹aqs])7)­Uimcesm\u0017awIome/Ow3m\u0001CaoËaoe\u0001\u0001\u000bË?uÙå + ³ÉýÍ\rm¡õ¡³Ã\u0017ûIóC", + "game_type": "Custom Game", + "num_players": 1, + "max_players": 6, "players": [], "raw": { - "response_size": "8a00", - "protocol_id": "505833571a000000", - "game_version": "01000000", - "server_info": "943e0200", - "remaining_data": "00010349070101a101b94901138fe76f4d8b6171735d293329ad436f6f757943613b792f77336d01432b616f616f6501011577e33b25fd815d73437739539341d13973adcb3f772100020000000900000001000000020000001a070000e017" + "product": "PX3W", + "version": 26, + "host_counter": 3, + "entry_key": 20206372, + "settings_raw": "0103490701017501b17501f9e9dbff4d8b6171735d293729ad55696d6365736d176177496f6d652f4f77336d0143616fcb616f6501010bcb3f75d9e585b3c9fdcd0d6da1f5a1b3c317fb49f343", + "game_flags": 9, + "remaining_data": "" } } diff --git a/opengsq/protocols/warcraft3.py b/opengsq/protocols/warcraft3.py index 81b8cfb..c433422 100644 --- a/opengsq/protocols/warcraft3.py +++ b/opengsq/protocols/warcraft3.py @@ -4,18 +4,81 @@ from opengsq.protocol_socket import UdpClient from opengsq.binary_reader import BinaryReader from opengsq.responses.warcraft3 import Status, Player +from enum import IntFlag, IntEnum +from typing import List, Optional + +class GameFlags(IntFlag): + """Game flags based on the Go implementation""" + CUSTOM_GAME = 0x000001 + SINGLE_PLAYER = 0x000005 + LADDER_1V1 = 0x000010 + LADDER_2V2 = 0x000020 + LADDER_3V3 = 0x000040 + LADDER_4V4 = 0x000080 + TEAM_LADDER = 0x000020 + SAVED_GAME = 0x000200 + TYPE_MASK = 0x0002F5 + SIGNED_MAP = 0x000008 + PRIVATE_GAME = 0x000800 + CREATOR_USER = 0x002000 + CREATOR_BLIZZARD = 0x004000 + CREATOR_MASK = 0x006000 + MAP_TYPE_MELEE = 0x008000 + MAP_TYPE_SCENARIO = 0x010000 + MAP_TYPE_MASK = 0x018000 + SIZE_SMALL = 0x020000 + SIZE_MEDIUM = 0x040000 + SIZE_LARGE = 0x080000 + SIZE_MASK = 0x0E0000 + OBS_FULL = 0x100000 + OBS_ON_DEFEAT = 0x200000 + OBS_NONE = 0x400000 + OBS_MASK = 0x700000 + FILTER_MASK = 0x7FE000 + +class GameSettingFlags(IntFlag): + """Game setting flags based on the Go implementation""" + SPEED_SLOW = 0x00000000 + SPEED_NORMAL = 0x00000001 + SPEED_FAST = 0x00000002 + SPEED_MASK = 0x0000000F + TERRAIN_HIDDEN = 0x00000100 + TERRAIN_EXPLORED = 0x00000200 + TERRAIN_VISIBLE = 0x00000400 + TERRAIN_DEFAULT = 0x00000800 + TERRAIN_MASK = 0x00000F00 + OBS_NONE = 0x00000000 + OBS_ENABLED = 0x00001000 + OBS_ON_DEFEAT = 0x00002000 + OBS_FULL = 0x00003000 + OBS_REFEREES = 0x40000000 + OBS_MASK = 0x40003000 + TEAMS_TOGETHER = 0x00004000 + TEAMS_FIXED = 0x00060000 + SHARED_CONTROL = 0x01000000 + RANDOM_HERO = 0x02000000 + RANDOM_RACE = 0x04000000 + +class SlotStatus(IntEnum): + """Slot status based on the Go implementation""" + OPEN = 0x00 + CLOSED = 0x01 + OCCUPIED = 0x02 class Warcraft3(ProtocolBase): """ This class represents the Warcraft 3 Protocol. It provides methods to interact with Warcraft 3 game servers. + Based on the gowarcraft3 implementation. """ full_name = "Warcraft 3 Protocol" WARCRAFT3_PORT = 6112 # Default port for Warcraft 3 + PROTOCOL_SIG = 0xF7 # Protocol signature + CURRENT_GAME_VERSION = 26 # Current game version (changed from 10032 to 26) - # Protocol specific constants - _REQUEST_HEADER = bytes.fromhex("f7 2f 10 00 50 58 33 57") - _RESPONSE_HEADER = bytes.fromhex("f7 30") + # Packet types + PID_SEARCH_GAME = 0x2F + PID_GAME_INFO = 0x30 def __init__(self, host: str, port: int = WARCRAFT3_PORT, timeout: float = 5.0): """ @@ -29,61 +92,135 @@ def __init__(self, host: str, port: int = WARCRAFT3_PORT, timeout: float = 5.0): raise ValueError(f"Warcraft 3 protocol requires port {self.WARCRAFT3_PORT}") super().__init__(host, self.WARCRAFT3_PORT, timeout) + def _create_search_game_packet(self) -> bytes: + """Creates a search game packet based on the Go implementation""" + packet = bytearray() + packet.extend([ + self.PROTOCOL_SIG, # Protocol signature + self.PID_SEARCH_GAME, # Packet type + 0x10, 0x00, # Packet size (16 bytes) + 0x50, 0x58, 0x33, 0x57, # Product "PX3W" (reversed "W3XP") + # Game version (little endian) + self.CURRENT_GAME_VERSION & 0xFF, + (self.CURRENT_GAME_VERSION >> 8) & 0xFF, + (self.CURRENT_GAME_VERSION >> 16) & 0xFF, + (self.CURRENT_GAME_VERSION >> 24) & 0xFF, + # Host counter + 0x00, 0x00, 0x00, 0x00 + ]) + return bytes(packet) + async def get_status(self) -> Status: """ Asynchronously retrieves the status of the game server. :return: A Status object containing the status of the game server. """ - # Create the full request packet - request = self._REQUEST_HEADER + bytes.fromhex("1a 00 00 00 00 00 00 00") + request = self._create_search_game_packet() + response = await UdpClient.communicate(self, request, source_port=self.WARCRAFT3_PORT) + + if not response or len(response) < 4: + raise Exception("Invalid response") + + br = BinaryReader(response) - # Send request and receive response - response = await UdpClient.communicate(self, request) + # Validate protocol signature + if int.from_bytes(br.read_bytes(1), 'little') != self.PROTOCOL_SIG: + raise Exception("Invalid protocol signature") - # Validate response header - if not response.startswith(self._RESPONSE_HEADER): - raise Exception("Invalid response header") + # Validate packet type + if int.from_bytes(br.read_bytes(1), 'little') != self.PID_GAME_INFO: + raise Exception("Invalid response packet type") - # Parse the response using BinaryReader - br = BinaryReader(response[2:]) # Skip the header bytes + # Read packet size + packet_size = int.from_bytes(br.read_bytes(2), 'little') + if len(response) < packet_size: + raise Exception("Incomplete response") - # Read response size - response_size = br.read_bytes(2) # 8A 00 - - # Read protocol identifier - protocol_id = br.read_bytes(8) # 50 58 33 57 1A 00 00 00 - - # Read game version - game_version = br.read_bytes(4) # 01 00 00 00 - - # Read server info - server_info = br.read_bytes(4) # 94 3E 02 00 - - # Read hostname (null-terminated string) - hostname = "" + # Read game version info + product = br.read_bytes(4).decode('ascii') # Product code (WAR3/W3XP) + version = int.from_bytes(br.read_bytes(4), 'little') # Game version + host_counter = int.from_bytes(br.read_bytes(4), 'little') # Host counter + entry_key = int.from_bytes(br.read_bytes(4), 'little') # Entry key + + # Read game name (null-terminated) + game_name = "" while True: - byte = br.read_bytes(1) - if byte == b'\x00': + char = int.from_bytes(br.read_bytes(1), 'little') + if char == 0: break - hostname += byte.decode('latin1') - + game_name += chr(char) + + # Skip unknown byte + br.read_bytes(1) + + # Read game settings string (null-terminated) + settings_raw = bytearray() + while True: + byte = int.from_bytes(br.read_bytes(1), 'little') + if byte == 0: + break + settings_raw.append(byte) + + # Read remaining fields + slots_total = int.from_bytes(br.read_bytes(4), 'little') + game_flags = GameFlags(int.from_bytes(br.read_bytes(4), 'little')) + slots_used = int.from_bytes(br.read_bytes(4), 'little') + slots_available = int.from_bytes(br.read_bytes(4), 'little') + uptime_seconds = int.from_bytes(br.read_bytes(4), 'little') + port = int.from_bytes(br.read_bytes(2), 'little') + # Store raw data for debugging raw = { - 'response_size': response_size.hex(), - 'protocol_id': protocol_id.hex(), - 'game_version': game_version.hex(), - 'server_info': server_info.hex(), + 'product': product, + 'version': version, + 'host_counter': host_counter, + 'entry_key': entry_key, + 'settings_raw': settings_raw.hex(), + 'game_flags': game_flags, 'remaining_data': br.read_bytes(br.remaining_bytes()).hex() } - + return Status( - game_version=game_version.hex(), - hostname=hostname, - map_name="Unknown", # TODO: Parse from remaining data - game_type="Unknown", # TODO: Parse from remaining data - num_players=0, # TODO: Parse from remaining data - max_players=12, # Default max players in WC3 - players=[], # TODO: Parse from remaining data + game_version=f"{product} {version}", + hostname=game_name, + map_name=self._get_map_name_from_settings(settings_raw), + game_type=self._get_game_type(game_flags), + num_players=slots_used, + max_players=slots_total, + players=[], # Would need additional packet parsing for player info raw=raw - ) \ No newline at end of file + ) + + def _get_map_name_from_settings(self, settings_raw: bytearray) -> str: + """Extract map name from settings data - simplified version""" + try: + # Skip the first few bytes of settings + br = BinaryReader(settings_raw[8:]) + map_name = "" + while br.remaining_bytes() > 0: + char = int.from_bytes(br.read_bytes(1), 'little') + if char == 0: + break + map_name += chr(char) + return map_name + except: + return "Unknown" + + def _get_game_type(self, flags: GameFlags) -> str: + """Convert game flags to a readable game type""" + if flags & GameFlags.CUSTOM_GAME: + return "Custom Game" + elif flags & GameFlags.SINGLE_PLAYER: + return "Single Player" + elif flags & GameFlags.LADDER_1V1: + return "Ladder 1v1" + elif flags & GameFlags.LADDER_2V2: + return "Ladder 2v2" + elif flags & GameFlags.LADDER_3V3: + return "Ladder 3v3" + elif flags & GameFlags.LADDER_4V4: + return "Ladder 4v4" + elif flags & GameFlags.SAVED_GAME: + return "Saved Game" + return "Unknown" \ No newline at end of file From 65844b64dfabfcc22ab5ed92b08f83f2f030c563 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Sat, 31 May 2025 23:36:16 +0200 Subject: [PATCH 4/5] Better WC3 Implementation (Disabled Map Name) --- .../test_warcraft3/test_warcraft3_status.rst | 11 +++++------ opengsq/protocols/warcraft3.py | 15 ++------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst index cae36a4..ca52b74 100644 --- a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst +++ b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst @@ -8,18 +8,17 @@ Here are the results for the test method. { "game_version": "PX3W 26", "hostname": "Lokales Spiel (Banane)", - "map_name": "±u\u0001ùéÛÿM‹aqs])7)­Uimcesm\u0017awIome/Ow3m\u0001CaoËaoe\u0001\u0001\u000bË?uÙå - ³ÉýÍ\rm¡õ¡³Ã\u0017ûIóC", + "map_name": "Map name unavailable", "game_type": "Custom Game", "num_players": 1, - "max_players": 6, + "max_players": 2, "players": [], "raw": { "product": "PX3W", "version": 26, - "host_counter": 3, - "entry_key": 20206372, - "settings_raw": "0103490701017501b17501f9e9dbff4d8b6171735d293729ad55696d6365736d176177496f6d652f4f77336d0143616fcb616f6501010bcb3f75d9e585b3c9fdcd0d6da1f5a1b3c317fb49f343", + "host_counter": 6, + "entry_key": 52398541, + "settings_raw": "0103490701015501a955010fc791334d8b6171735d47736f857b656f5569736f456f655d293329555b6973697367616d6b476d616565732f477733790143616f8b616f650101272d35cd8315819ba93f8be953214553e7c513a1bd9b4b", "game_flags": 9, "remaining_data": "" } diff --git a/opengsq/protocols/warcraft3.py b/opengsq/protocols/warcraft3.py index c433422..7574a82 100644 --- a/opengsq/protocols/warcraft3.py +++ b/opengsq/protocols/warcraft3.py @@ -193,19 +193,8 @@ async def get_status(self) -> Status: ) def _get_map_name_from_settings(self, settings_raw: bytearray) -> str: - """Extract map name from settings data - simplified version""" - try: - # Skip the first few bytes of settings - br = BinaryReader(settings_raw[8:]) - map_name = "" - while br.remaining_bytes() > 0: - char = int.from_bytes(br.read_bytes(1), 'little') - if char == 0: - break - map_name += chr(char) - return map_name - except: - return "Unknown" + """Map name parsing is skipped due to encoding complexity""" + return "Map name unavailable" def _get_game_type(self, flags: GameFlags) -> str: """Convert game flags to a readable game type""" From 742dc59ba5bf014669a723c2219f1bf0bc61b3b3 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Sat, 31 May 2025 23:39:13 +0200 Subject: [PATCH 5/5] Removed Playerlist Because it wasn't working at all --- .../protocols/test_warcraft3/test_warcraft3_status.rst | 1 - opengsq/protocols/warcraft3.py | 4 +--- opengsq/responses/warcraft3/__init__.py | 3 +-- opengsq/responses/warcraft3/player.py | 9 --------- opengsq/responses/warcraft3/status.py | 4 +--- tests/protocols/test_warcraft3.py | 4 ---- 6 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 opengsq/responses/warcraft3/player.py diff --git a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst index ca52b74..ceb0953 100644 --- a/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst +++ b/docs/tests/protocols/test_warcraft3/test_warcraft3_status.rst @@ -12,7 +12,6 @@ Here are the results for the test method. "game_type": "Custom Game", "num_players": 1, "max_players": 2, - "players": [], "raw": { "product": "PX3W", "version": 26, diff --git a/opengsq/protocols/warcraft3.py b/opengsq/protocols/warcraft3.py index 7574a82..3e6886c 100644 --- a/opengsq/protocols/warcraft3.py +++ b/opengsq/protocols/warcraft3.py @@ -3,9 +3,8 @@ from opengsq.protocol_base import ProtocolBase from opengsq.protocol_socket import UdpClient from opengsq.binary_reader import BinaryReader -from opengsq.responses.warcraft3 import Status, Player +from opengsq.responses.warcraft3 import Status from enum import IntFlag, IntEnum -from typing import List, Optional class GameFlags(IntFlag): """Game flags based on the Go implementation""" @@ -188,7 +187,6 @@ async def get_status(self) -> Status: game_type=self._get_game_type(game_flags), num_players=slots_used, max_players=slots_total, - players=[], # Would need additional packet parsing for player info raw=raw ) diff --git a/opengsq/responses/warcraft3/__init__.py b/opengsq/responses/warcraft3/__init__.py index aa5e6c5..77a37e0 100644 --- a/opengsq/responses/warcraft3/__init__.py +++ b/opengsq/responses/warcraft3/__init__.py @@ -1,4 +1,3 @@ from opengsq.responses.warcraft3.status import Status -from opengsq.responses.warcraft3.player import Player -__all__ = ["Status", "Player"] \ No newline at end of file +__all__ = ["Status"] \ No newline at end of file diff --git a/opengsq/responses/warcraft3/player.py b/opengsq/responses/warcraft3/player.py deleted file mode 100644 index 4dee32b..0000000 --- a/opengsq/responses/warcraft3/player.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass - -@dataclass -class Player: - """ - Represents a player in a Warcraft 3 game server. - """ - name: str - ping: int \ No newline at end of file diff --git a/opengsq/responses/warcraft3/status.py b/opengsq/responses/warcraft3/status.py index 7fffa1f..45ef9ce 100644 --- a/opengsq/responses/warcraft3/status.py +++ b/opengsq/responses/warcraft3/status.py @@ -1,6 +1,5 @@ from dataclasses import dataclass -from typing import List, Dict, Any -from opengsq.responses.warcraft3.player import Player +from typing import Dict, Any @dataclass class Status: @@ -13,5 +12,4 @@ class Status: game_type: str num_players: int max_players: int - players: List[Player] raw: Dict[str, Any] \ No newline at end of file diff --git a/tests/protocols/test_warcraft3.py b/tests/protocols/test_warcraft3.py index a22c47a..c354480 100644 --- a/tests/protocols/test_warcraft3.py +++ b/tests/protocols/test_warcraft3.py @@ -17,10 +17,6 @@ async def test_warcraft3_status(): print(f"Game Type: {result.game_type}") print(f"Players: {result.num_players}/{result.max_players}") - print("\nPlayers:") - for player in result.players: - print(f" {player.name} (Ping: {player.ping}ms)") - # Example raw data that might be useful for debugging print("\nRaw Data:") if hasattr(result, 'raw'):