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..70372f2 100644 --- a/docs/tests/protocols/index.rst +++ b/docs/tests/protocols/index.rst @@ -4,34 +4,46 @@ Protocols Tests =============== .. toctree:: - test_gamespy4/index - test_teamspeak3/index + test_cod4/index + test_halo1/index + test_flatout2/index + test_battlefield2/index + test_ssc/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_trackmania_nations/index + test_ut3/index + 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_ase/index - test_quake1/index - test_killingfloor/index - test_source/index - test_samp/index + test_toxikk/index + test_avp2/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_tmn/index test_doom3/index + test_w40kdow/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_cod1/index test_satisfactory/index - test_gamespy3/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/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/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/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/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/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/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/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/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/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/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 91e7083..547a83c 100644 --- a/opengsq/protocols/__init__.py +++ b/opengsq/protocols/__init__.py @@ -1,6 +1,15 @@ +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.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 from opengsq.protocols.eos import EOS from opengsq.protocols.fivem import FiveM from opengsq.protocols.flatout2 import Flatout2 @@ -8,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 @@ -22,11 +32,16 @@ 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.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 from opengsq.protocols.udk import UDK from opengsq.protocols.unreal2 import Unreal2 from opengsq.protocols.ut3 import UT3 from opengsq.protocols.vcmp import Vcmp +from opengsq.protocols.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/aoe1.py b/opengsq/protocols/aoe1.py new file mode 100644 index 0000000..bea2fbc --- /dev/null +++ b/opengsq/protocols/aoe1.py @@ -0,0 +1,338 @@ +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. + + Erweitert die Basis-DirectPlay-Parsing um AoE1-spezifische Logik. + + Args: + buffer: Die rohen TCP-Antwortdaten + + Returns: + dict: Geparste AoE1 Server-Informationen + """ + # 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) + + 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 new file mode 100644 index 0000000..b51cf1f --- /dev/null +++ b/opengsq/protocols/aoe2.py @@ -0,0 +1,385 @@ +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. + + Erweitert die Basis-DirectPlay-Parsing um AoE2-spezifische Logik. + + Args: + buffer: Die rohen TCP-Antwortdaten + + Returns: + dict: Geparste AoE2 Server-Informationen + """ + # 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) + + 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. + + 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/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/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/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/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/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/protocols/directplay.py b/opengsq/protocols/directplay.py new file mode 100644 index 0000000..9d39bb7 --- /dev/null +++ b/opengsq/protocols/directplay.py @@ -0,0 +1,582 @@ +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) + + # 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: + """ + 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. + Kann von Subklassen überschrieben werden für spezifische Implementierungen. + + Args: + buffer: Die rohen Antwortdaten + + Returns: + dict: Geparste Server-Informationen + """ + 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: + """ + 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') + + 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 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/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/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/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/protocols/trackmania_nations.py b/opengsq/protocols/trackmania_nations.py new file mode 100644 index 0000000..f10cc36 --- /dev/null +++ b/opengsq/protocols/trackmania_nations.py @@ -0,0 +1,793 @@ +from __future__ import annotations + +import asyncio +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 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 + 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 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 (MCP-Enhanced)" + + # Standard Trackmania Nations port + DEFAULT_PORT = 2350 + + # TCP packets (verifiziert) + _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 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=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=payload_data.environment, + comment=payload_data.comment, # PC-UID oder andere Info + server_login="", + 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=payload_data.srv_type == 'p', + ladder_server=payload_data.srv_type == 's', + 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_server_payload(self, data: bytes) -> TrackmaniaPayloadData: + """ + Parst einen Server-Payload basierend auf MCP-Erkenntnissen. + + Args: + data: Die Rohdaten des Payloads + + 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: + result.srv_type = f'unknown_{srv_type_byte:02x}' + + # 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 + + # 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() + + # 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 + + # 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 + + # 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 potential_names: + potential_names.sort(key=len, reverse=True) + result.server_name = potential_names[0] # Längster = echter Server-Name + + 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 + + # 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") + + 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 + + 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 _extract_all_strings(self, data: bytes) -> List[str]: + """ + Extrahiert alle lesbaren ASCII-Strings aus den Daten. + + Args: + data: Die Rohdaten + + Returns: + Liste der gefundenen Strings + """ + strings = [] + current_string = bytearray() + + 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() + + # Letzten String nicht vergessen + if len(current_string) >= 3: + try: + string = current_string.decode('ascii') + strings.append(string) + except: + pass + + return strings + + 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 _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.) + + 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 + + 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 + + # 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/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) 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/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""" 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/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/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/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) 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]] 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/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/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 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_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) 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) 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) + + + + + + + diff --git a/tests/protocols/test_cod4.py b/tests/protocols/test_cod4.py new file mode 100644 index 0000000..060de5d --- /dev/null +++ b/tests/protocols/test_cod4.py @@ -0,0 +1,52 @@ +import pytest +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): + 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') + await handler.save_result("test_get_info", info) + + @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') + 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) + 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 + 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) + + + + + + + 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) + 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) 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) 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) 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) + + + + 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