From 8ae94d0f90ce4f6003e76e71aeb8527b681a6f01 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Thu, 23 Jan 2025 13:03:57 +0100 Subject: [PATCH 1/3] Adding Support for Nadeo Games (Example Trackmania, Shootmania etc) --- opengsq/protocol_socket.py | 15 ++++- opengsq/protocols/nadeo.py | 96 ++++++++++++++++++++++++++++ opengsq/responses/nadeo/status.py | 102 ++++++++++++++++++++++++++++++ tests/protocols/test_nadeo.py | 40 ++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 opengsq/protocols/nadeo.py create mode 100644 opengsq/responses/nadeo/status.py create mode 100644 tests/protocols/test_nadeo.py diff --git a/opengsq/protocol_socket.py b/opengsq/protocol_socket.py index d10b975..57debbc 100644 --- a/opengsq/protocol_socket.py +++ b/opengsq/protocol_socket.py @@ -21,6 +21,12 @@ def __init__(self, kind: SocketKind): self.__protocol = None self.__kind = kind + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.close() + def __enter__(self): return self @@ -59,7 +65,14 @@ def send(self, data: bytes): else: self.__transport.sendto(data) - async def recv(self) -> bytes: + async def recv(self, size: int = None) -> bytes: + if size: + data = b"" + while len(data) < size: + chunk = await self.__protocol.recv() + data += chunk + if len(data) >= size: + return data[:size] return await self.__protocol.recv() class __Protocol(asyncio.Protocol): diff --git a/opengsq/protocols/nadeo.py b/opengsq/protocols/nadeo.py new file mode 100644 index 0000000..0be3621 --- /dev/null +++ b/opengsq/protocols/nadeo.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +import struct +import xmlrpc.client as xmlrpclib +from typing import Any, Optional + +from opengsq.exceptions import InvalidPacketException +from opengsq.protocol_base import ProtocolBase +from opengsq.responses.nadeo import Status + + +class Nadeo(ProtocolBase): + full_name = "Nadeo GBXRemote Protocol" + INITIAL_HANDLER = 0x80000000 + MAXIMUM_HANDLER = 0xFFFFFFFF + + def __init__(self, host: str, port: int = 5000, timeout: float = 5.0): + super().__init__(host, port, timeout) + self.handler = self.MAXIMUM_HANDLER + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + + async def connect(self) -> None: + self._reader, self._writer = await asyncio.open_connection(self._host, self._port) + + # Read and validate header + data = await self._reader.read(4) + header_length = struct.unpack(' None: + if self._writer: + self._writer.close() + await self._writer.wait_closed() + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() + + async def _execute(self, method: str, *args) -> Any: + if self.handler == self.MAXIMUM_HANDLER: + self.handler = self.INITIAL_HANDLER + else: + self.handler += 1 + + handler_bytes = self.handler.to_bytes(4, byteorder='little') + data = xmlrpclib.dumps(args, method).encode() + packet_len = len(data) + + packet = packet_len.to_bytes(4, byteorder='little') + handler_bytes + data + + self._writer.write(packet) + await self._writer.drain() + + # Read response + header = await self._reader.read(8) + size = struct.unpack(' bool: + await self.connect() + result = await self._execute('Authenticate', username, password) + return bool(result) + + async def get_status(self) -> Status: + version = await self._execute('GetVersion') + server_info = await self._execute('GetServerOptions') + player_list = await self._execute('GetPlayerList', 100, 0) + current_map = await self._execute('GetCurrentChallengeInfo') + + return Status.from_raw_data( + version_data=version, + server_data=server_info, + players_data=player_list, + map_data=current_map + ) \ No newline at end of file diff --git a/opengsq/responses/nadeo/status.py b/opengsq/responses/nadeo/status.py new file mode 100644 index 0000000..c851660 --- /dev/null +++ b/opengsq/responses/nadeo/status.py @@ -0,0 +1,102 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class Player: + """Represents a player on the server.""" + login: str + nickname: str + player_id: int + team_id: int + is_spectator: bool + ladder_ranking: int + flags: int + + +@dataclass +class ServerOptions: + """Represents server configuration options.""" + name: str + comment: str + password: bool + max_players: int + max_spectators: int + current_game_mode: int + current_chat_time: int + hide_server: int + ladder_mode: int + vehicle_quality: int + + +@dataclass +class Version: + """Represents the server version information.""" + name: str + version: str + build: str + + +@dataclass +class Status: + version: Version + server_options: ServerOptions + players: list[Player] + map_info: MapInfo # Add this + + @classmethod + def from_raw_data(cls, version_data: dict[str, str], + server_data: dict[str, Any], + players_data: list[dict[str, Any]], + map_data: dict[str, Any]) -> Status: + version = Version( + name=version_data.get('Name', ''), + version=version_data.get('Version', ''), + build=version_data.get('Build', '') + ) + + server_options = ServerOptions( + name=server_data.get('Name', ''), + comment=server_data.get('Comment', ''), + password=server_data.get('Password', False), + max_players=server_data.get('CurrentMaxPlayers', 0), + max_spectators=server_data.get('CurrentMaxSpectators', 0), + current_game_mode=server_data.get('CurrentGameMode', 0), + current_chat_time=server_data.get('CurrentChatTime', 0), + hide_server=server_data.get('HideServer', 0), + ladder_mode=server_data.get('CurrentLadderMode', 0), + vehicle_quality=server_data.get('CurrentVehicleNetQuality', 0) + ) + + players = [ + Player( + login=p.get('Login', ''), + nickname=p.get('NickName', ''), + player_id=p.get('PlayerId', -1), + team_id=p.get('TeamId', -1), + is_spectator=p.get('IsSpectator', False), + ladder_ranking=p.get('LadderRanking', 0), + flags=p.get('Flags', 0) + ) + for p in players_data + ] + + map_info = MapInfo.from_dict(map_data) + + return cls(version, server_options, players, map_info) + +@dataclass +class MapInfo: + """Represents current map information.""" + name: str + author: str + environment: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MapInfo: + return cls( + name=data.get('Name', ''), + author=data.get('Author', ''), + environment=data.get('Environment', '') + ) \ No newline at end of file diff --git a/tests/protocols/test_nadeo.py b/tests/protocols/test_nadeo.py new file mode 100644 index 0000000..9c6b0af --- /dev/null +++ b/tests/protocols/test_nadeo.py @@ -0,0 +1,40 @@ +import pytest +from opengsq.protocols.nadeo import Nadeo + +from ..result_handler import ResultHandler + +handler = ResultHandler(__file__) +# handler.enable_save = True + +# Test server configuration +SERVER_IP = "192.168.100.2" +SERVER_PORT = 5000 +SERVER_USER = "SuperAdmin" +SERVER_PASSWORD = "SuperAdmin" + +nadeo = Nadeo(host=SERVER_IP, port=SERVER_PORT) + + +@pytest.mark.asyncio +async def test_authenticate(): + result = await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD) + assert result is True + + +@pytest.mark.asyncio +async def test_get_status(): + await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD) + result = await nadeo.get_status() + await handler.save_result("test_get_status", result) + + +@pytest.mark.asyncio +async def test_get_map_info(): + await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD) + result = await nadeo.get_status() + print(f"Server: {result.server_options.name}") + print(f"Map: {result.map_info.name}") + print(f"Author: {result.map_info.author}") + print(f"Players: {len(result.players)}/{result.server_options.max_players}") + assert result.map_info.name != "" + assert result.server_options.name != "" \ No newline at end of file From 7b7d108faa435019915838dcaf4b4f7d88512f39 Mon Sep 17 00:00:00 2001 From: Hornochs Date: Thu, 23 Jan 2025 13:04:20 +0100 Subject: [PATCH 2/3] Adding missing File Update --- opengsq/protocols/__init__.py | 1 + opengsq/responses/nadeo/__init__.py | 1 + 2 files changed, 2 insertions(+) create mode 100644 opengsq/responses/nadeo/__init__.py diff --git a/opengsq/protocols/__init__.py b/opengsq/protocols/__init__.py index c983d5e..b6382e7 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -10,6 +10,7 @@ from opengsq.protocols.kaillera import Kaillera from opengsq.protocols.killingfloor import KillingFloor from opengsq.protocols.minecraft import Minecraft +from opengsq.protocols.nadeo import Nadeo from opengsq.protocols.palworld import Palworld from opengsq.protocols.quake1 import Quake1 from opengsq.protocols.quake2 import Quake2 diff --git a/opengsq/responses/nadeo/__init__.py b/opengsq/responses/nadeo/__init__.py new file mode 100644 index 0000000..2f91dae --- /dev/null +++ b/opengsq/responses/nadeo/__init__.py @@ -0,0 +1 @@ +from .status import Status From b7799669c00d83f1c5c0c552f770ccb584c7ed3b Mon Sep 17 00:00:00 2001 From: Stephan Schaffner Date: Thu, 23 Jan 2025 13:05:01 +0100 Subject: [PATCH 3/3] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ee1cd73..308474b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ from opengsq.protocols import ( Kaillera, KillingFloor, Minecraft, + Nadeo, Palworld, Quake1, Quake2,