Skip to content

Commit 63d85a2

Browse files
Merge pull request #69 from happyleavesaoc/HD
HD >= 4.6 support
2 parents 4d99b6c + 20f2468 commit 63d85a2

31 files changed

Lines changed: 363 additions & 55 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Age of Empires II recorded game parsing and summarization in Python 3.
88
- The Conquerors (`.mgx`)
99
- Userpatch 1.4 (`.mgz`)
1010
- Userpatch 1.5 (`.mgz`)
11+
- HD Edition >= 4.6 (`.aoe2record`)
1112
- Definitive Edition (`.aoe2record`)
1213

1314
## Architecture
@@ -30,6 +31,8 @@ Abstractions take parser output as input and return an object with normalized da
3031
| The Conquerors (`.mgx`) | || ||| |
3132
| Userpatch <= 1.4 (`.mgz`) | || ||||
3233
| Userpatch 1.5 (`.mgz`) |||||||
34+
| HD Edition >= 4.6 | || ||||
35+
| HD Edition 5.8 |||||||
3336
| Definitive Edition <= 13.34 (`.aoe2record`) | || ||||
3437
| Definitive Edition > 13.34 (`.aoe2record`) |||||||
3538

mgz/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
from mgz.header.scenario import scenario
1313
from mgz.header.lobby import lobby
1414
from mgz.header.de import de
15+
from mgz.header.hd import hd
1516

1617

1718
compressed_header = Struct(
1819
"game_version"/CString(encoding='latin1'),
1920
"save_version"/VersionAdapter(Float32l),
2021
"version"/Computed(lambda ctx: get_version(ctx.game_version, ctx.save_version, None)),
22+
"hd"/If(lambda ctx: ctx.version == Version.HD and ctx.save_version > 12.34, hd),
2123
"de"/If(lambda ctx: ctx.version == Version.DE, de),
2224
ai,
2325
replay,
@@ -29,6 +31,7 @@
2931
Terminated
3032
)
3133

34+
3235
subheader = Struct(
3336
"check"/Peek(Int32ul),
3437
"chapter_address"/If(lambda ctx: ctx.check < 100000000, Int32ul),

mgz/enums.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ def ResourceLevelEnum(ctx):
203203
medium=2,
204204
high=3,
205205
unknown1=4,
206-
unknown2=5
206+
unknown2=5,
207+
unknown3=6
207208
)
208209

209210
def RevealMapEnum(ctx):

mgz/fast/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Action(Enum):
3737
GROUP_MULTI_WAYPOINTS = 31
3838
CHAPTER = 32
3939
DE_ATTACK_MOVE = 33
40+
HD_UNKNOWN_34 = 34
4041
DE_UNKNOWN_35 = 35
4142
DE_UNKNOWN_37 = 37
4243
DE_AUTOSCOUT = 38

mgz/fast/header.py

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,18 @@ def aoc_string(data):
4444

4545
def de_string(data):
4646
"""Read DE string."""
47-
assert data.read(2) == b'\x60\x0A'
47+
assert data.read(2) == b'\x60\x0a'
4848
length = unpack('<h', data)
4949
return unpack(f'<{length}s', data)
5050

5151

52+
def hd_string(data):
53+
"""Read HD string."""
54+
length = unpack('<h', data)
55+
assert data.read(2) == b'\x60\x0a'
56+
return unpack(f'<{length}s', data)
57+
58+
5259
def parse_object(data, offset):
5360
"""Parse an object."""
5461
class_id, object_id, instance_id, pos_x, pos_y = struct.unpack_from('<bxH14xIxff', data, offset)
@@ -141,11 +148,13 @@ def parse_lobby(data, version, save):
141148
if save >= 20.06:
142149
data.read(9)
143150
data.read(8)
144-
if version is not Version.DE:
151+
if version not in (Version.DE, Version.HD):
145152
data.read(1)
146153
reveal_map_id, map_size, population, game_type_id, lock_teams = unpack('I4xIIbb', data)
147-
if version is Version.DE:
148-
data.read(9)
154+
if version in (Version.DE, Version.HD):
155+
data.read(5)
156+
if save >= 13.13:
157+
data.read(4)
149158
chat = []
150159
for _ in range(0, unpack('<I', data)):
151160
message = data.read(unpack('<I', data)).strip(b'\x00')
@@ -157,7 +166,7 @@ def parse_lobby(data, version, save):
157166
return dict(
158167
reveal_map_id=reveal_map_id,
159168
map_size=map_size,
160-
population=population * (25 if version is not Version.DE else 1),
169+
population=population * (25 if version not in (Version.DE, Version.HD) else 1),
161170
game_type_id=game_type_id,
162171
lock_teams=lock_teams == 1,
163172
chat=chat,
@@ -175,7 +184,7 @@ def parse_map(data, version):
175184
size_x, size_y, zone_num = unpack('<III', data)
176185
tile_num = size_x * size_y
177186
for _ in range(zone_num):
178-
if version is Version.DE:
187+
if version in (Version.DE, Version.HD):
179188
data.read(2048 + (tile_num * 2))
180189
else:
181190
data.read(1275 + tile_num)
@@ -216,15 +225,19 @@ def parse_scenario(data, num_players, version):
216225
data.read(196)
217226
for _ in range(0, 16):
218227
data.read(24)
219-
if version is Version.DE:
228+
if version in (Version.DE, Version.HD):
220229
data.read(4)
221230
data.read(12672)
222231
if version is Version.DE:
223232
data.read(196)
224233
else:
225234
for _ in range(0, 16):
226235
data.read(332)
236+
if version is Version.HD:
237+
data.read(644)
227238
data.read(88)
239+
if version is Version.HD:
240+
data.read(16)
228241
map_id, difficulty_id = unpack('<II', data)
229242
remainder = data.read()
230243
if version is Version.DE:
@@ -293,7 +306,60 @@ def parse_de(data, version, save):
293306
players=players,
294307
guid=str(uuid.UUID(bytes=guid)),
295308
lobby=lobby.decode('utf-8'),
296-
mod=mod
309+
mod=mod.decode('utf-8')
310+
)
311+
312+
313+
def parse_hd(data, version, save):
314+
"""Parse HD-specifc header."""
315+
if version is not Version.HD or save <= 12.34:
316+
return None
317+
data.read(12)
318+
dlc_count = unpack('<I', data)
319+
data.read(dlc_count * 4)
320+
data.read(8)
321+
map_id = unpack('<I', data)
322+
data.read(80)
323+
players = []
324+
for _ in range(8):
325+
data.read(4)
326+
color_id = unpack('<i', data)
327+
data.read(12)
328+
civilization_id = unpack('<I', data)
329+
hd_string(data)
330+
data.read(1)
331+
hd_string(data)
332+
name = hd_string(data)
333+
data.read(4)
334+
steam_id, number = unpack('<Qi', data)
335+
data.read(8)
336+
if name:
337+
players.append(dict(
338+
number=number,
339+
color_id=color_id,
340+
name=name,
341+
profile_id=steam_id,
342+
civilization_id=civilization_id
343+
))
344+
data.read(26)
345+
hd_string(data)
346+
data.read(8)
347+
hd_string(data)
348+
data.read(8)
349+
hd_string(data)
350+
data.read(8)
351+
guid = data.read(16)
352+
lobby = hd_string(data)
353+
mod = hd_string(data)
354+
data.read(8)
355+
hd_string(data)
356+
data.read(4)
357+
return dict(
358+
players=players,
359+
guid=str(uuid.UUID(bytes=guid)),
360+
lobby=lobby.decode('utf-8'),
361+
mod=mod.decode('utf-8'),
362+
map_id=map_id
297363
)
298364

299365

@@ -310,15 +376,15 @@ def parse_version(header, data):
310376
log = unpack('<I', data)
311377
game, save = unpack('<7sxf', header)
312378
version = get_version(game.decode('ascii'), round(save, 2), log)
313-
if version not in (Version.USERPATCH15, Version.DE):
379+
if version not in (Version.USERPATCH15, Version.DE, Version.HD):
314380
raise RuntimeError(f"{version} not supported")
315381
return version, round(save, 2)
316382

317383

318384
def parse_players(header, num_players, version):
319385
"""Parse all players."""
320386
cur = header.tell()
321-
gaia = b'Gaia' if version is Version.DE else b'GAIA'
387+
gaia = b'Gaia' if version in (Version.DE, Version.HD) else b'GAIA'
322388
anchor = header.read().find(b'\x05\x00' + gaia + b'\x00')
323389
header.seek(cur + anchor - num_players - 43)
324390
mod = parse_mod(header, num_players, version)
@@ -350,6 +416,7 @@ def parse(data):
350416
header = decompress(data)
351417
version, save = parse_version(header, data)
352418
de = parse_de(header, version, save)
419+
hd = parse_hd(header, version, save)
353420
metadata, num_players = parse_metadata(header)
354421
map_ = parse_map(header, version)
355422
players, mod = parse_players(header, num_players, version)
@@ -362,6 +429,7 @@ def parse(data):
362429
players=players,
363430
map=map_,
364431
de=de,
432+
hd=hd,
365433
mod=mod,
366434
metadata=metadata,
367435
scenario=scenario,

mgz/header/de.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@
7474
"resolved_team_id"/Byte,
7575
"dat_crc"/Bytes(8),
7676
"mp_game_version"/Byte,
77-
"civ_id"/Byte,
78-
Const(b"\x00\x00\x00"),
77+
"civ_id"/Int32ul,
7978
"ai_type"/de_string,
8079
"ai_civ_name_index"/Byte,
8180
"ai_name"/de_string,

mgz/header/hd.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from construct import (
2+
Struct, Int32ul, Float32l, Array, Padding, Flag, If,
3+
Byte, Int16ul, Bytes, Int32sl, Peek, Const, RepeatUntil,
4+
Int64ul, Computed, Embedded, IfThenElse
5+
)
6+
7+
from mgz.enums import VictoryEnum, ResourceLevelEnum, AgeEnum, PlayerTypeEnum, DifficultyEnum
8+
from mgz.util import find_save_version
9+
10+
separator = Const(b"\xa3_\x02\x00")
11+
12+
hd_string = Struct(
13+
"length"/Int16ul,
14+
Const(b"\x60\x0A"),
15+
"value"/Bytes(lambda ctx: ctx.length)
16+
)
17+
18+
test_57 = "test_57"/Struct(
19+
"check"/Int32ul,
20+
Padding(4),
21+
If(lambda ctx: ctx._._.version >= 1006, Bytes(1)),
22+
Padding(15),
23+
hd_string,
24+
Padding(1),
25+
If(lambda ctx: ctx._._.version >= 1005, hd_string),
26+
hd_string,
27+
Padding(16),
28+
"test"/Int32ul,
29+
"is_57"/Computed(lambda ctx: ctx.check == ctx.test)
30+
)
31+
32+
player = Struct(
33+
"dlc_id"/Int32ul,
34+
"color_id"/Int32ul,
35+
"unk1_1006"/If(lambda ctx: ctx._._.version >= 1006, Bytes(1)),
36+
"unk"/Bytes(2),
37+
"dat_crc"/Bytes(4),
38+
"mp_game_version"/Byte,
39+
"team_index"/Int32ul,
40+
"civ_id"/Int32ul,
41+
"ai_type"/hd_string,
42+
"ai_civ_name_index"/Byte,
43+
"ai_name"/If(lambda ctx: ctx._._.version >= 1005, hd_string),
44+
"name"/hd_string,
45+
"type"/PlayerTypeEnum(Int32ul),
46+
"steam_id"/Int64ul,
47+
"player_number"/Int32sl,
48+
Embedded(If(lambda ctx: ctx._._.version >= 1006 and not ctx._.test_57.is_57, Struct(
49+
"hd_rm_rating"/Int32ul,
50+
"hd_dm_rating"/Int32ul,
51+
)))
52+
)
53+
54+
hd = "hd"/Struct(
55+
"version"/Float32l,
56+
"interval_version"/Int32ul,
57+
"game_options_version"/Int32ul,
58+
"dlc_count"/Int32ul,
59+
"dlc_ids"/Array(lambda ctx: ctx.dlc_count, Int32ul),
60+
"dataset_ref"/Int32ul,
61+
Peek("difficulty_id"/Int32ul),
62+
DifficultyEnum("difficulty"/Int32ul),
63+
"selected_map_id"/Int32ul,
64+
"resolved_map_id"/Int32ul,
65+
"reveal_map"/Int32ul,
66+
Peek("victory_type_id"/Int32ul),
67+
VictoryEnum("victory_type"/Int32ul),
68+
Peek("starting_resources_id"/Int32ul),
69+
ResourceLevelEnum("starting_resources"/Int32ul),
70+
"starting_age_id"/Int32ul,
71+
"starting_age"/AgeEnum(Computed(lambda ctx: ctx.starting_age_id)),
72+
"ending_age_id"/Int32ul,
73+
"ending_age"/AgeEnum(Computed(lambda ctx: ctx.ending_age_id)),
74+
"game_type"/If(lambda ctx: ctx.version >= 1006, Int32ul),
75+
separator,
76+
"ver1000"/If(lambda ctx: ctx.version == 1000, Struct(
77+
"map_name"/hd_string,
78+
"unk"/hd_string
79+
)),
80+
separator,
81+
"speed"/Float32l,
82+
"treaty_length"/Int32ul,
83+
"population_limit"/Int32ul,
84+
"num_players"/Int32ul,
85+
"unused_player_color"/Int32ul,
86+
"victory_amount"/Int32ul,
87+
separator,
88+
"trade_enabled"/Flag,
89+
"team_bonus_disabled"/Flag,
90+
"random_positions"/Flag,
91+
"all_techs"/Flag,
92+
"num_starting_units"/Byte,
93+
"lock_teams"/Flag,
94+
"lock_speed"/Flag,
95+
"multiplayer"/Flag,
96+
"cheats"/Flag,
97+
"record_game"/Flag,
98+
"animals_enabled"/Flag,
99+
"predators_enabled"/Flag,
100+
"turbo_enabled"/Flag,
101+
"shared_exploration"/Flag,
102+
"team_positions"/Flag,
103+
"unk"/Bytes(1),
104+
Embedded(IfThenElse(lambda ctx: ctx.version == 1000,
105+
Struct(
106+
Bytes(40*3),
107+
separator,
108+
Bytes(40),
109+
"strings"/Array(8, hd_string),
110+
Bytes(16),
111+
separator,
112+
Bytes(10),
113+
),
114+
Struct(
115+
Peek(test_57),
116+
"players"/Array(8, player),
117+
"fog_of_war"/Flag,
118+
"cheat_notifications"/Flag,
119+
"colored_chat"/Flag,
120+
Bytes(9),
121+
separator,
122+
"is_ranked"/Flag,
123+
"allow_specs"/Flag,
124+
"lobby_visibility"/Int32ul,
125+
"custom_random_map_file_crc"/Int32ul,
126+
"custom_scenario_or_campaign_file"/hd_string,
127+
Bytes(8),
128+
"custom_random_map_file"/hd_string,
129+
Bytes(8),
130+
"custom_random_map_scenarion_file"/hd_string,
131+
Bytes(8),
132+
"guid"/Bytes(16),
133+
"lobby_name"/hd_string,
134+
"modded_dataset"/hd_string,
135+
"modded_dataset_workshop_id"/Bytes(4),
136+
If(lambda ctx: ctx._.version >= 1005,
137+
Struct(
138+
Bytes(4),
139+
hd_string,
140+
Bytes(4)
141+
)
142+
)
143+
)
144+
))
145+
)

0 commit comments

Comments
 (0)