Skip to content

Commit 2e8e2b4

Browse files
committed
Adding Support for Warhammer 40000 Dawn of War
1 parent 661f858 commit 2e8e2b4

8 files changed

Lines changed: 447 additions & 0 deletions

File tree

docs/tests/protocols/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Protocols Tests
3030
test_palworld/index
3131
test_tmn/index
3232
test_doom3/index
33+
test_w40kdow/index
3334
test_samp/index
3435
test_ase/index
3536
test_teamspeak3/index
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.. _test_w40kdow:
2+
3+
test_w40kdow
4+
============
5+
6+
.. toctree::
7+
test_get_status
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
test_get_status
2+
===============
3+
4+
Here are the results for the test method.
5+
6+
.. code-block:: json
7+
8+
{
9+
"guid": "{9958fa06-1fc7-4478-b95e-4ed185f00c4e}",
10+
"hostname": "Spiel von Banane",
11+
"current_players": 1,
12+
"max_players": 4,
13+
"ip_address": "172.29.100.29",
14+
"port": 6112,
15+
"magic_marker": "WODW",
16+
"build_number": 1001,
17+
"version": "1.1",
18+
"mod_name": "dxp2",
19+
"game_title": "Dawn of War: Dark Crusade",
20+
"map_scenario": "Heiliges Quadrat (4)",
21+
"faction_codes": [
22+
"FDIA",
23+
"TSSR",
24+
"MTKL",
25+
"AEHC",
26+
"COLS",
27+
"DPSG",
28+
"HSSR",
29+
"TRSR"
30+
],
31+
"map_features": [
32+
"᪻ꧤ\u000b\u0000"
33+
],
34+
"expansion_name": "Dark Crusade"
35+
}

opengsq/protocols/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@
3838
from opengsq.protocols.unreal2 import Unreal2
3939
from opengsq.protocols.ut3 import UT3
4040
from opengsq.protocols.vcmp import Vcmp
41+
from opengsq.protocols.w40kdow import W40kDow
4142
from opengsq.protocols.warcraft3 import Warcraft3
4243
from opengsq.protocols.won import WON

opengsq/protocols/w40kdow.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import struct
5+
import ipaddress
6+
from typing import Optional
7+
8+
from opengsq.binary_reader import BinaryReader
9+
from opengsq.exceptions import InvalidPacketException
10+
from opengsq.protocol_base import ProtocolBase
11+
from opengsq.responses.w40kdow import Status
12+
13+
14+
class W40kDow(ProtocolBase):
15+
"""
16+
This class represents the Warhammer 40K Dawn of War Protocol.
17+
It provides methods to listen for broadcast announcements from DoW servers.
18+
"""
19+
20+
full_name = "Warhammer 40K Dawn of War Protocol"
21+
22+
def __init__(self, host: str, port: int = 6112, timeout: float = 5.0):
23+
"""
24+
Initializes the W4kDow object with the given parameters.
25+
26+
:param host: The host of the server to listen for.
27+
:param port: The port of the server (default: 6112).
28+
:param timeout: The timeout for listening to broadcasts.
29+
"""
30+
super().__init__(host, port, timeout)
31+
32+
async def get_status(self) -> Status:
33+
"""
34+
Asynchronously retrieves the server status by listening for broadcast announcements.
35+
36+
Dawn of War servers continuously broadcast their status on the network.
37+
This method listens for these broadcasts and returns the first matching broadcast
38+
from the specified host.
39+
40+
:return: A Status object containing the server status.
41+
:raises InvalidPacketException: If the received packet is invalid.
42+
:raises asyncio.TimeoutError: If no broadcast is received within the timeout period.
43+
"""
44+
import socket
45+
46+
# Create UDP socket
47+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
48+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
49+
sock.bind(('0.0.0.0', self._port))
50+
sock.setblocking(False)
51+
52+
loop = asyncio.get_running_loop()
53+
54+
try:
55+
# Keep receiving broadcasts until we get one from the expected host
56+
while True:
57+
data, addr = await asyncio.wait_for(
58+
loop.sock_recvfrom(sock, 2048),
59+
timeout=self._timeout
60+
)
61+
62+
# Only process broadcasts from the expected host
63+
if addr[0] == self._host:
64+
# Parse and return the broadcast data
65+
return self._parse_broadcast(data, addr)
66+
67+
finally:
68+
sock.close()
69+
70+
def _parse_broadcast(self, data: bytes, addr: tuple) -> Status:
71+
"""
72+
Parse a Dawn of War server broadcast packet.
73+
74+
:param data: Raw broadcast data.
75+
:param addr: Sender address tuple (ip, port).
76+
:return: Status object with parsed data.
77+
:raises InvalidPacketException: If the packet is invalid.
78+
"""
79+
try:
80+
br = BinaryReader(data)
81+
82+
# Validate header magic (0x08 0x01)
83+
header = br.read_bytes(2)
84+
if header != b'\x08\x01':
85+
raise InvalidPacketException(
86+
f"Invalid header. Expected: 0x0801. Received: {header.hex()}"
87+
)
88+
89+
# Read GUID length and GUID
90+
guid_len = br.read_long(unsigned=True)
91+
if guid_len != 38:
92+
raise InvalidPacketException(
93+
f"Unexpected GUID length. Expected: 38. Received: {guid_len}"
94+
)
95+
96+
guid = br.read_bytes(guid_len).decode('ascii', errors='ignore')
97+
98+
# Read hostname (UTF-16LE with length prefix in code units)
99+
hostname_len_units = br.read_long(unsigned=True)
100+
hostname_len_bytes = hostname_len_units * 2
101+
hostname_bytes = br.read_bytes(hostname_len_bytes)
102+
hostname = hostname_bytes.decode('utf-16le', errors='ignore')
103+
104+
# Skip null terminator + padding (4 bytes total after hostname)
105+
br.read_bytes(4)
106+
107+
# Read player counts
108+
current_players = br.read_long(unsigned=True)
109+
max_players = br.read_long(unsigned=True)
110+
111+
# Skip unknown flags/status (9 bytes)
112+
br.read_bytes(9)
113+
114+
# Read IP address (4 bytes, network byte order)
115+
ip_bytes = br.read_bytes(4)
116+
ip_address = str(ipaddress.IPv4Address(ip_bytes))
117+
118+
# Validate that the IP in the packet matches the sender's IP
119+
if ip_address != addr[0]:
120+
raise InvalidPacketException(
121+
f"IP mismatch. Packet IP: {ip_address}, Sender IP: {addr[0]}"
122+
)
123+
124+
# Read port (2 bytes, little endian)
125+
port = br.read_short(unsigned=True)
126+
127+
# Skip 4 unknown bytes after port
128+
br.read_bytes(4)
129+
130+
# Read total payload size (4 bytes) - note: first byte appears twice (redundant)
131+
br.read_bytes(4) # Payload size (we don't really need this value)
132+
br.read_byte() # Skip the redundant duplicate byte
133+
134+
# Read and validate magic marker "WODW"
135+
magic_marker = br.read_bytes(4).decode('ascii', errors='ignore')
136+
if magic_marker != 'WODW':
137+
raise InvalidPacketException(
138+
f"Invalid magic marker. Expected: WODW. Received: {magic_marker}"
139+
)
140+
141+
# Read build number
142+
build_number = br.read_long(unsigned=True)
143+
144+
# Read version string
145+
version_len = br.read_long(unsigned=True)
146+
version = br.read_bytes(version_len).decode('ascii', errors='ignore')
147+
148+
# Read mod name
149+
mod_name_len = br.read_long(unsigned=True)
150+
mod_name = br.read_bytes(mod_name_len).decode('ascii', errors='ignore')
151+
152+
# Read game title (UTF-16LE with length in code units)
153+
game_title_len_units = br.read_long(unsigned=True)
154+
game_title_len_bytes = game_title_len_units * 2
155+
game_title_bytes = br.read_bytes(game_title_len_bytes)
156+
game_title = game_title_bytes.decode('utf-16le', errors='ignore')
157+
158+
# Read unknown ASCII field (appears to be a version like "1.0", length in bytes)
159+
unknown_ascii_len = br.read_long(unsigned=True)
160+
unknown_ascii = br.read_bytes(unknown_ascii_len).decode('ascii', errors='ignore')
161+
162+
# Read map/scenario name (UTF-16LE with length in code units)
163+
map_scenario_len_units = br.read_long(unsigned=True)
164+
map_scenario_len_bytes = map_scenario_len_units * 2
165+
map_scenario_bytes = br.read_bytes(map_scenario_len_bytes)
166+
map_scenario = map_scenario_bytes.decode('utf-16le', errors='ignore')
167+
168+
# Skip unknown null bytes/padding after map scenario (10 bytes)
169+
br.read_bytes(10)
170+
171+
# Read number of factions (4 bytes, little endian uint32)
172+
num_factions = br.read_long(unsigned=True)
173+
174+
# Read faction codes (each is 4 ASCII bytes + 4 padding bytes = 8 bytes total)
175+
faction_codes = []
176+
for _ in range(num_factions):
177+
faction_code = br.read_bytes(4).decode('ascii', errors='ignore')
178+
br.read_bytes(4) # Skip 4 padding bytes after each faction code
179+
faction_codes.append(faction_code)
180+
181+
# Read map features (length-prefixed UTF-16LE strings in code units)
182+
# Continue reading until we run out of data or hit an invalid length
183+
map_features = []
184+
while br.remaining_bytes() >= 4:
185+
try:
186+
feature_len_units = br.read_long(unsigned=True)
187+
188+
# Sanity check: length should be reasonable (< 500 characters)
189+
if feature_len_units == 0 or feature_len_units > 500:
190+
break
191+
192+
feature_len_bytes = feature_len_units * 2
193+
194+
if br.remaining_bytes() < feature_len_bytes:
195+
break
196+
197+
feature_bytes = br.read_bytes(feature_len_bytes)
198+
feature = feature_bytes.decode('utf-16le', errors='ignore')
199+
map_features.append(feature)
200+
except Exception:
201+
# If we can't read a feature, break
202+
break
203+
204+
# Create Status object
205+
status_data = {
206+
'guid': guid,
207+
'hostname': hostname,
208+
'current_players': current_players,
209+
'max_players': max_players,
210+
'ip_address': ip_address,
211+
'port': port,
212+
'magic_marker': magic_marker,
213+
'build_number': build_number,
214+
'version': version,
215+
'mod_name': mod_name,
216+
'game_title': game_title,
217+
'map_scenario': map_scenario,
218+
'faction_codes': faction_codes,
219+
'map_features': map_features
220+
}
221+
222+
return Status(status_data)
223+
224+
except Exception as e:
225+
if isinstance(e, InvalidPacketException):
226+
raise
227+
raise InvalidPacketException(f"Failed to parse broadcast packet: {e}")
228+
229+
230+
if __name__ == "__main__":
231+
import asyncio
232+
233+
async def main_async():
234+
# Test with the provided server
235+
w4kdow = W40kDow(host="172.29.100.29", port=6112, timeout=10.0)
236+
237+
try:
238+
print("Listening for Dawn of War server broadcasts...")
239+
status = await w4kdow.get_status()
240+
print(f"\n{'='*60}")
241+
print(f"Server Status:")
242+
print(f"{'='*60}")
243+
print(f"GUID: {status.guid}")
244+
print(f"Hostname: {status.hostname}")
245+
print(f"Players: {status.current_players}/{status.max_players}")
246+
print(f"IP:Port: {status.ip_address}:{status.port}")
247+
print(f"Version: {status.version}")
248+
print(f"Mod: {status.mod_name} ({status.expansion_name})")
249+
print(f"Game Title: {status.game_title}")
250+
print(f"Map/Scenario: {status.map_scenario}")
251+
print(f"Build: {status.build_number}")
252+
print(f"Magic: {status.magic_marker}")
253+
print(f"\nFaction Codes: {', '.join(status.faction_codes)}")
254+
print(f"\nMap Features:")
255+
for i, feature in enumerate(status.map_features, 1):
256+
print(f" {i}. {feature}")
257+
258+
except asyncio.TimeoutError:
259+
print("Error: No broadcast received within timeout period")
260+
print("Make sure a Dawn of War server is running and broadcasting on the network")
261+
except Exception as e:
262+
print(f"Error: {e}")
263+
import traceback
264+
traceback.print_exc()
265+
266+
asyncio.run(main_async())
267+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .status import Status
2+

0 commit comments

Comments
 (0)