Skip to content

Commit d0144b7

Browse files
committed
Adding Support for Stronghold Crusader and Stronghold Crusader Extreme
1 parent 46508e3 commit d0144b7

14 files changed

Lines changed: 681 additions & 0 deletions

File tree

docs/tests/protocols/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ Protocols Tests
1919
test_eldewrito/index
2020
test_eos/index
2121
test_renegadex/index
22+
test_stronghold_ce/index
2223
test_quake2/index
2324
test_gamespy3/index
25+
test_stronghold_crusader/index
2426
test_kaillera/index
2527
test_toxikk/index
2628
test_avp2/index
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. _test_stronghold_ce:
2+
3+
test_stronghold_ce
4+
==================
5+
6+
.. toctree::
7+
test_get_status
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
test_get_status
2+
===============
3+
4+
Here are the results for the test method.
5+
6+
.. code-block:: json
7+
8+
{
9+
"name": "Stronghold-Kreuzrittersadads",
10+
"game_type": "Stronghold Crusader Extreme",
11+
"map": "Unknown Map",
12+
"num_players": 2,
13+
"max_players": 8,
14+
"password_protected": false,
15+
"game_version": "1.4.1",
16+
"game_mode": "Standard",
17+
"difficulty": "Standard",
18+
"speed": "Normal",
19+
"players": [],
20+
"raw": {
21+
"magic": "aa00b0fa",
22+
"buffer_length": 170,
23+
"full_buffer": "aa00b0fa020008ff000000000000000000000000706c617901000e005000000044a00000d5716f81339e6147bb33c075fab5d595f04d0c49c79b4c4cb959d41f1cce460e08000000020000000000000000000000fd144b0400000000000000000000000000000000000000005c0000005300740072006f006e00670068006f006c0064002d004b007200650075007a007200690074007400650072007300610064006100640073000000",
24+
"game_guid": "f04d0c49-c79b-4c4c-b959-d41f1cce460e",
25+
"buffer_size": 170,
26+
"buffer_preview": "aa00b0fa020008ff000000000000000000000000706c617901000e005000000044a00000d5716f81339e6147bb33c075fab5"
27+
}
28+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. _test_stronghold_crusader:
2+
3+
test_stronghold_crusader
4+
========================
5+
6+
.. toctree::
7+
test_get_status
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
test_get_status
2+
===============
3+
4+
Here are the results for the test method.
5+
6+
.. code-block:: json
7+
8+
{
9+
"name": "Stronghold-Kreuzritter123",
10+
"game_type": "Stronghold Crusader",
11+
"map": "Unknown Map",
12+
"num_players": 1,
13+
"max_players": 8,
14+
"password_protected": false,
15+
"game_version": "1.41",
16+
"game_mode": "Standard",
17+
"difficulty": "Standard",
18+
"speed": "Normal",
19+
"players": [],
20+
"raw": {
21+
"magic": "a400b0fa",
22+
"buffer_length": 164,
23+
"full_buffer": "a400b0fa020008fc000000000000000000000000706c617901000e005000000044a0000001f40819a948014e97b5443d5707b266482f5e1dc0e8e549aed8b124da9e305908000000010000000000000000000000d078da0400000000000000000000000000000000000000005c0000005300740072006f006e00670068006f006c0064002d004b007200650075007a007200690074007400650072003100320033000000",
24+
"game_guid": "482f5e1d-c0e8-e549-aed8-b124da9e3059",
25+
"tcp_port": 2301,
26+
"buffer_size": 164,
27+
"buffer_preview": "a400b0fa020008fc000000000000000000000000706c617901000e005000000044a0000001f40819a948014e97b5443d5707"
28+
}
29+
}

opengsq/protocols/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from opengsq.protocols.scum import Scum
3434
from opengsq.protocols.source import Source
3535
from opengsq.protocols.ssc import SSC
36+
from opengsq.protocols.stronghold_ce import StrongholdCE
37+
from opengsq.protocols.stronghold_crusader import StrongholdCrusader
3638
from opengsq.protocols.teamspeak3 import TeamSpeak3
3739
from opengsq.protocols.trackmania_nations import TrackmaniaNations
3840
from opengsq.protocols.toxikk import Toxikk

opengsq/protocols/stronghold_ce.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
from opengsq.protocols.directplay import DirectPlay
2+
from opengsq.responses.stronghold_ce.status import Status
3+
from opengsq.binary_reader import BinaryReader
4+
5+
6+
class StrongholdCE(DirectPlay):
7+
"""
8+
Stronghold Crusader Extreme DirectPlay Protocol
9+
10+
Erweitert das DirectPlay Basis-Protokoll um spezifische
11+
Stronghold Crusader Extreme Implementierungsdetails.
12+
"""
13+
14+
full_name = "Stronghold Crusader Extreme DirectPlay Protocol"
15+
16+
# Stronghold Crusader Extreme spezifische Konstanten und Payload
17+
STRONGHOLD_CE_UDP_PAYLOAD = bytes.fromhex("3400b0fa020008fc000000000000000000000000706c617902000e00f04d0c49c79b4c4cb959d41f1cce460e0000000091000000")
18+
19+
# DirectPlay Payload-Struktur für Stronghold Crusader Extreme:
20+
# Bytes 0-27: Gemeinsamer DirectPlay Header (identisch mit AoE1/AoE2)
21+
# Bytes 20-23: "play" - DirectPlay Identifikation
22+
# Bytes 28-43: Spiel-spezifische GUID: f04d0c49-c79b-4c4c-b959-d41f1cce460e
23+
# Bytes 44-47: Padding/Reserved (00 00 00 00)
24+
# Bytes 48-51: Version/Type ID: 91 00 00 00 (145 dezimal)
25+
STRONGHOLD_CE_GAME_GUID = "f04d0c49-c79b-4c4c-b959-d41f1cce460e"
26+
27+
def __init__(self, host: str, port: int = DirectPlay.DIRECTPLAY_UDP_PORT, timeout: float = 5.0):
28+
super().__init__(host, port, timeout)
29+
30+
def _build_query_packet(self) -> bytes:
31+
"""
32+
Erstellt das Stronghold Crusader Extreme-spezifische UDP Query Packet.
33+
34+
Verwendet den echten DirectPlay-Payload für Stronghold Crusader Extreme:
35+
3400b0fa020008fc000000000000000000000000706c617902000e00f04d0c49c79b4c4cb959d41f1cce460e0000000091000000
36+
37+
Returns:
38+
bytes: Das Stronghold CE Query Packet
39+
"""
40+
return self.STRONGHOLD_CE_UDP_PAYLOAD
41+
42+
def _parse_response(self, buffer: bytes) -> dict:
43+
"""
44+
Parsed die TCP-Antwort vom Stronghold Crusader Extreme Server.
45+
46+
Erweitert die Basis-DirectPlay-Parsing um Stronghold CE-spezifische Logik.
47+
48+
Args:
49+
buffer: Die rohen TCP-Antwortdaten
50+
51+
Returns:
52+
dict: Geparste Stronghold CE Server-Informationen
53+
"""
54+
# Nutze die Basis-DirectPlay-Parsing-Logik
55+
result = super()._parse_response(buffer)
56+
57+
# Stronghold CE-spezifische Anpassungen
58+
result['game_type'] = 'Stronghold Crusader Extreme'
59+
result['game_version'] = '1.4.1' # Stronghold Crusader Extreme Version
60+
61+
# Versuche Stronghold CE-spezifische Daten zu parsen
62+
try:
63+
stronghold_data = self._parse_stronghold_ce_specific_data(buffer)
64+
result.update(stronghold_data)
65+
except Exception as e:
66+
result['raw']['stronghold_ce_parse_error'] = str(e)
67+
68+
# Debug-Informationen hinzufügen
69+
result['raw']['game_guid'] = self.STRONGHOLD_CE_GAME_GUID
70+
result['raw']['buffer_size'] = len(buffer)
71+
result['raw']['buffer_preview'] = buffer[:50].hex() if len(buffer) > 50 else buffer.hex()
72+
73+
return result
74+
75+
def _parse_stronghold_ce_specific_data(self, buffer: bytes) -> dict:
76+
"""
77+
Parsed Stronghold CE-spezifische Daten aus der DirectPlay-Antwort.
78+
79+
Args:
80+
buffer: Die rohen Antwortdaten
81+
82+
Returns:
83+
dict: Stronghold CE-spezifische Daten
84+
"""
85+
result = {}
86+
87+
if len(buffer) < 10:
88+
return result
89+
90+
br = BinaryReader(buffer)
91+
92+
try:
93+
# Skip DirectPlay Header (4 bytes)
94+
br.read_bytes(4)
95+
96+
# Versuche, Stronghold CE-spezifische Strukturen zu erkennen
97+
remaining_data = br.read_bytes(br.remaining_bytes())
98+
99+
# Suche nach Spielnamen (Stronghold CE verwendet UTF-16LE Strings)
100+
game_name = self._extract_stronghold_ce_game_name(remaining_data)
101+
if game_name:
102+
result['name'] = game_name
103+
104+
# Versuche Spieleranzahl zu ermitteln
105+
player_count = self._extract_stronghold_ce_player_count(remaining_data)
106+
if player_count >= 0:
107+
result['num_players'] = player_count
108+
109+
# Versuche Max Players zu ermitteln
110+
max_players = self._extract_stronghold_ce_max_players(remaining_data)
111+
if max_players > 0:
112+
result['max_players'] = max_players
113+
114+
except Exception as e:
115+
result['stronghold_ce_specific_error'] = str(e)
116+
117+
return result
118+
119+
def _extract_stronghold_ce_game_name(self, data: bytes) -> str:
120+
"""
121+
Versucht, den Spielnamen aus den Stronghold CE-Daten zu extrahieren.
122+
123+
Stronghold CE verwendet UTF-16LE Strings mit 32-bit Length-Prefix,
124+
ähnlich wie Age of Empires, aber mit eigener Struktur.
125+
126+
Args:
127+
data: Die Daten nach dem DirectPlay-Header
128+
129+
Returns:
130+
str: Der Spielname oder leer
131+
"""
132+
try:
133+
# Suche nach dem UTF-16LE String-Pattern
134+
# Der Spielname ist typischerweise am Ende des DirectPlay-Pakets
135+
# In der Beispiel-Antwort beginnt der Name bei Offset ~92 (0x5c)
136+
137+
# Analysiere die Beispiel-Antwort:
138+
# aa00b0fa020008ff... bis ...5c0000005300740072006f006e00670068006f006c0064002d004b007200650075007a007200690074007400650072007300610064006100640073000000
139+
# Der 32-bit Length-Prefix ist 0x0000005c (92 bytes) für den gesamten String-Bereich
140+
# Danach folgt der UTF-16LE String: "Stronghold-Kreuzrittersadads"
141+
142+
# Suche nach 32-bit Length-Prefix für UTF-16LE String
143+
search_start = max(0, len(data) - 200) # Starte weiter hinten
144+
145+
for i in range(search_start, len(data) - 8, 4):
146+
if i + 4 < len(data):
147+
# Lese 32-bit Längenwert (little-endian)
148+
potential_length = int.from_bytes(data[i:i+4], 'little')
149+
150+
# Plausible Länge für einen Spielnamen (12-400 bytes für UTF-16LE)
151+
# Der Length-Wert kann die gesamte String-Sektion oder nur den String repräsentieren
152+
if 12 <= potential_length <= 400:
153+
name_start = i + 4
154+
155+
# Begrenze auf verfügbare Daten
156+
available_length = len(data) - name_start
157+
effective_length = min(potential_length, available_length)
158+
159+
if effective_length > 0:
160+
name_bytes = data[name_start:name_start + effective_length]
161+
162+
try:
163+
# Stronghold CE verwendet UTF-16LE encoding
164+
decoded = name_bytes.decode('utf-16le', errors='strict')
165+
166+
# Finde den ersten null-terminierten String
167+
null_pos = decoded.find('\x00')
168+
if null_pos >= 0:
169+
clean_name = decoded[:null_pos].strip()
170+
else:
171+
clean_name = decoded.strip()
172+
173+
# Validierung: Name sollte druckbare Zeichen enthalten
174+
# und "Stronghold" oder ähnliche Begriffe könnten vorkommen
175+
if (len(clean_name) >= 3 and
176+
all(ord(c) >= 32 or c.isspace() for c in clean_name) and
177+
any(c.isalnum() for c in clean_name)):
178+
return clean_name
179+
except UnicodeDecodeError:
180+
continue
181+
182+
except Exception:
183+
pass
184+
185+
return ""
186+
187+
def _extract_stronghold_ce_player_count(self, data: bytes) -> int:
188+
"""
189+
Versucht, die Spieleranzahl aus den Stronghold CE-Daten zu extrahieren.
190+
191+
Args:
192+
data: Die Daten nach dem DirectPlay-Header
193+
194+
Returns:
195+
int: Die Spieleranzahl oder 0
196+
"""
197+
try:
198+
# DirectPlay Session Data beginnt nach dem GUID
199+
# Die Spielerzahl steht typischerweise bei festen Offsets
200+
201+
# Bei Stronghold CE sind die Session-Daten strukturiert:
202+
# Ähnlich wie bei AoE, aber mit möglicherweise anderen Offsets
203+
204+
if len(data) >= 48:
205+
session_start = 40
206+
207+
if len(data) >= session_start + 28:
208+
max_players_offset = session_start + 24
209+
current_players_offset = session_start + 28
210+
211+
max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little')
212+
current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little')
213+
214+
# Validierung der Werte (Stronghold CE unterstützt bis zu 8 Spieler)
215+
if 1 <= max_players <= 8 and 0 <= current_players <= max_players:
216+
return current_players
217+
218+
# Fallback: Suche nach plausiblen Werten
219+
for i in range(len(data) - 8):
220+
value = int.from_bytes(data[i:i+4], 'little')
221+
next_value = int.from_bytes(data[i+4:i+8], 'little')
222+
223+
# Suche nach dem Muster: current_players, max_players
224+
if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value):
225+
return value
226+
227+
except Exception:
228+
pass
229+
230+
return 0
231+
232+
def _extract_stronghold_ce_max_players(self, data: bytes) -> int:
233+
"""
234+
Versucht, die maximale Spieleranzahl aus den Stronghold CE-Daten zu extrahieren.
235+
236+
Args:
237+
data: Die Daten nach dem DirectPlay-Header
238+
239+
Returns:
240+
int: Die maximale Spieleranzahl oder 0
241+
"""
242+
try:
243+
# Verwende dieselbe Logik wie bei player_count, aber für max_players
244+
if len(data) >= 48:
245+
session_start = 40
246+
247+
if len(data) >= session_start + 28:
248+
max_players_offset = session_start + 24
249+
current_players_offset = session_start + 28
250+
251+
max_players = int.from_bytes(data[max_players_offset:max_players_offset+4], 'little')
252+
current_players = int.from_bytes(data[current_players_offset:current_players_offset+4], 'little')
253+
254+
if 1 <= max_players <= 8 and 0 <= current_players <= max_players:
255+
return max_players
256+
257+
# Fallback: Suche nach dem zweiten Wert im Spieler-Paar
258+
for i in range(len(data) - 8):
259+
value = int.from_bytes(data[i:i+4], 'little')
260+
next_value = int.from_bytes(data[i+4:i+8], 'little')
261+
262+
if (0 <= value <= 8 and 1 <= next_value <= 8 and value <= next_value):
263+
return next_value
264+
265+
except Exception:
266+
pass
267+
268+
return 8 # Standard für Stronghold Crusader Extreme
269+

0 commit comments

Comments
 (0)