Skip to content

Commit aae150a

Browse files
committed
Fixes several minor issues, reorganizes some data, and adds realm support.
There we a couple minor syntax/naming issues from the switch back to standard python coding conventions which were fixed. The replay class had its players attribute restructured to have two access points: replay.players which is an unsorted list of players, and replay.player which is a dictionary mapping players to their pids. Basic replay.initData parasing was implemented to gain support for realms. This addition made it possible to fix the player urls to work across realms! A small shell.py script was added for debugging purposes, to use it invoke the interactive prompt with `python -i shell.py` which will execute the setup code and bring you to the python shell for further work.
1 parent aa038b7 commit aae150a

File tree

8 files changed

+110
-59
lines changed

8 files changed

+110
-59
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
*.pyc
33
dist
44
build
5-
sc2reader.egg-info
5+
sc2reader.egg-info
6+
PKG-INFO.txt

sc2reader/objects.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,13 @@ def __repr__(self):
139139

140140
class Player(object):
141141

142-
def __init__(self, pid, data):
142+
def __init__(self, pid, data, realm="us"):
143143
self.pid = pid
144+
self.realm = realm
144145
self.name = data[0].decode("hex")
145146
self.uid = data[1][4]
146147
self.uidIndex = data[1][2]
147-
self.url = "http://us.battle.net/sc2/en/profile/%s/%s/%s/" % (self.uid, self.uidIndex, self.name)
148+
self.url = "http://%s.battle.net/sc2/en/profile/%s/%s/%s/" % (self.realm, self.uid, self.uidIndex, self.name)
148149
self.race = data[2].decode("hex")
149150
self.rgba = dict([
150151
['r', data[3][1]],
@@ -154,6 +155,8 @@ def __init__(self, pid, data):
154155
])
155156
self.recorder = True
156157
self.handicap = data[6]
158+
self.team = None # A number to be supplied later
159+
self.type = "" # Human or Computer
157160

158161
def __str__(self):
159162
return "Player %s - %s (%s)" % (self.pid, self.name, self.race)

sc2reader/parsers.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from time import ctime
22
from collections import defaultdict
33

4-
from objects import attribute, Message, Player, Event
5-
from EventParsers import *
4+
from objects import Attribute, Message, Player, Event
5+
from eventparsers import *
66
from utils import ByteStream
77
from exceptions import ParseError
88

@@ -14,7 +14,7 @@
1414
#################################################
1515
def get_detail_parser(build):
1616
#This file format appears to have never changed
17-
return detail_parser()
17+
return DetailParser()
1818

1919
def get_attribute_parser(build):
2020
if build >= 17326:
@@ -36,6 +36,29 @@ def get_message_parser(build):
3636
#format appears to have not changed
3737
return MessageParser()
3838

39+
def get_initdata_parser(build):
40+
return InitdataParser()
41+
42+
class InitdataParser(object):
43+
def load(self,replay,filecontents):
44+
bytes = ByteStream(filecontents)
45+
num_players = bytes.get_big_int(1)
46+
for p in range(0,num_players):
47+
name_length = bytes.get_big_int(1)
48+
name = bytes.get_string(name_length)
49+
bytes.skip(5)
50+
51+
bytes.skip(5) # Unknown
52+
bytes.get_string(4) # Always Dflt
53+
bytes.skip(15) #Unknown
54+
id_length = bytes.get_big_int(1)
55+
sc_account_id = bytes.get_string(id_length)
56+
bytes.skip(684) # Fixed Length data for unknown purpose
57+
while( bytes.get_string(4).lower() == 's2ma' ):
58+
bytes.skip(2)
59+
replay.realm = bytes.get_string(2).lower()
60+
unknown_map_hash = bytes.get_big(32)
61+
3962
#################################################
4063
# replay.attributes.events Parsing classes
4164
#################################################
@@ -54,7 +77,7 @@ def load_attribute(self, replay, bytes):
5477
]
5578

5679
#Complete the decoding in the attribute object
57-
return attribute(attr_data)
80+
return Attribute(attr_data)
5881

5982
def load(self, replay, filecontents):
6083
bytes = ByteStream(filecontents)
@@ -80,18 +103,17 @@ def load(self, replay, filecontents):
80103
#Set player attributes as available, requires already populated player list
81104
for pid, attributes in data.iteritems():
82105
if pid == 16: continue
83-
player = replay.players[pid]
106+
player = replay.player[pid]
84107
player.color = attributes['Color']
85108
player.team = attributes['Teams'+replay.type]
86109
player.race2 = attributes['Race']
87110
player.difficulty = attributes['Difficulty']
88-
89111
#Computer players can't record games
90112
player.type = attributes['Player Type']
91113
if player.type == "Computer":
92114
player.recorder = False
93115

94-
class attribute_parser_17326(attribute_parser):
116+
class AttributeParser_17326(AttributeParser):
95117
def load_header(self, replay, bytes):
96118
bytes.skip(5, byte_code=True) #Always start with 4 nulls
97119
self.count = bytes.get_little_int(4) #get total attribute count
@@ -103,13 +125,14 @@ class DetailParser(object):
103125
def load(self, replay, filecontents):
104126
data = ByteStream(filecontents).parse_serialized_data()
105127

106-
replay.players = [None] #Pad the front for proper IDs
107128
for pid, pdata in enumerate(data[0]):
108-
replay.players.append(Player(pid+1, pdata)) #shift the id to start @ 1
129+
replay.add_player(Player(pid+1, pdata, replay.realm)) #shift the id to start @ 1
109130

110131
replay.map = data[1].decode("hex")
111132
replay.file_time = data[5]
112133
replay.date = ctime( (data[5]-116444735995904000)/10000000 )
134+
135+
replay.details_data = data
113136

114137
##################################################
115138
# replay.message.events parsing classes
@@ -134,7 +157,7 @@ def load(self, replay, filecontents):
134157
elif flags & 0x0F == 0:
135158
bytes.skip(4)
136159
if player_id < len(replay.players):
137-
replay.players[player_id].recorder = False
160+
replay.player[player_id].recorder = False
138161
else:
139162
pass #This "player" is an observer or something
140163

sc2reader/replay.py

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@
1010
class Replay(object):
1111

1212
def __init__(self, replay, partial_parse=True, full_parse=True):
13-
self.file = ""
13+
self.filename = replay
1414
self.speed = ""
1515
self.release_string = ""
1616
self.build = ""
1717
self.type = ""
1818
self.category = ""
1919
self.map = ""
20+
self.realm = ""
2021
self.events = list()
2122
self.results = dict()
22-
self.teams = dict()
23-
self.players = list()
23+
self.teams = defaultdict(list)
24+
self.players = list() #Unordered list of Player
25+
self.player = dict() #Maps pid to Player
2426
self.events_by_type = dict()
2527
self.attributes = list()
2628
self.length = None # (minutes, seconds) tuple
@@ -29,44 +31,54 @@ def __init__(self, replay, partial_parse=True, full_parse=True):
2931
self.versions = None # (number,number,number,number) tuple
3032
self.recorder = None # Player object
3133
self.frames = None # Integer representing FPS
34+
35+
# Set in parsers.DetailParser.load, should we hide this?
36+
self.file_time = None # Probably number milliseconds since EPOCH
37+
# Marked as private in case people want raw file access
38+
self._files = dict() # Files extracted from mpyq
39+
40+
#Used internally to ensure parse ordering
41+
self.__parsed = dict(details=False, attributes=False, messages=False, events=False, initdata=False)
3242

3343
# TODO: Change to something better
3444
self.date = "" # Date when the game was played
35-
# TODO: hide this?
36-
self.files = dict() # From the mpq lib
37-
# TODO: investigate where this gets set
38-
self.file_time # Probably number of seconds or milliseconds since EPOCH (the 1970 date)
39-
# TODO: This is used for internal purposes, better hide it!
40-
self.parsed = dict()
41-
42-
#Make sure the file exists and is readable
43-
if not os.access(replay, os.F_OK):
44-
raise ValueError("File at '%s' cannot be found" % replay)
45-
elif not os.access(replay, os.R_OK):
46-
raise ValueError("File at '%s' cannot be read" % replay)
47-
48-
self.file = replay
45+
46+
47+
#Make sure the file exists and is readable, first and foremost
48+
if not os.access(self.filename, os.F_OK):
49+
raise ValueError("File at '%s' cannot be found" % self.filename)
50+
elif not os.access(self.filename, os.R_OK):
51+
raise ValueError("File at '%s' cannot be read" % self.filename)
52+
53+
#Always parse the header first, the extract the files
4954
self._parse_header()
50-
self.files = MPQArchive(replay).extract()
51-
self.parsed = dict(details=False, attributes=False, messages=False, events=False)
55+
#Extract the available file from the MPQArchive
56+
self._files = MPQArchive(replay).extract()
5257

5358
#These are quickly parsed files that contain most of the game information
5459
#The order is important, I need some way to reinforce it in the future
5560
if partial_parse or full_parse:
61+
self._parse_initdata()
5662
self._parse_details()
5763
self._parse_attributes()
5864
self._parse_messages()
5965

66+
#Parsing events takes forever, so only do this on request
6067
if full_parse:
6168
self._parse_events()
6269

70+
71+
def add_player(self,player):
72+
self.players.append(player)
73+
self.player[player.pid] = player
74+
6375
def _parse_header(self):
6476
#Open up a ByteStream for its contents
65-
source = ByteStream(open(self.file).read())
77+
source = ByteStream(open(self.filename).read())
6678

6779
#Check the file type for the MPQ header bytes
68-
if source.getBig(4) != "4D50511B":
69-
raise TypeError("File '%s' is not an MPQ file" % self.file)
80+
if source.get_big(4) != "4D50511B":
81+
raise TypeError("File '%s' is not an MPQ file" % self.filename)
7082

7183
#Extract replay header data
7284
max_data_size = source.get_little_int(4) #possibly data max size
@@ -83,39 +95,45 @@ def _parse_header(self):
8395
self.frames,self.seconds = (data[3], data[3]/16)
8496
self.length = (self.seconds/60, self.seconds%60)
8597

98+
def _parse_initdata(self):
99+
parsers.get_initdata_parser(self.build).load(self, self._files['replay.initData'])
100+
self.__parsed['initdata'] = True
101+
86102
def _parse_details(self):
103+
if not self.__parsed['initdata']:
104+
raise ValueError("The replay initdata must be parsed before parsing details")
105+
87106
#Get player and map information
88-
parsers.get_detail_parser(self.build).load(self, self.files['replay.details'])
89-
self.parsed['details'] = True
107+
parsers.get_detail_parser(self.build).load(self, self._files['replay.details'])
108+
self.__parsed['details'] = True
90109

91110
def _parse_attributes(self):
92111
#The details file data is required for parsing
93-
if not self.parsed['details']:
112+
if not self.__parsed['details']:
94113
raise ValueError("The replay details must be parsed before parsing attributes")
95114

96-
parsers.get_attribute_parser(self.build).load(self, self.files['replay.attributes.events'])
97-
self.parsed['attributes'] = True
115+
parsers.get_attribute_parser(self.build).load(self, self._files['replay.attributes.events'])
116+
self.__parsed['attributes'] = True
98117

99118
#We can now create teams
100-
self.teams = defaultdict(list)
101-
for player in self.players[1:]: #Skip the 'None' player 0
119+
for player in self.players: #Skip the 'None' player 0
102120
self.teams[player.team].append(player)
103121

104122
def _parse_messages(self):
105123
#The details file data is required for parsing
106-
if not self.parsed['details']:
124+
if not self.__parsed['details']:
107125
raise ValueError("The replay details must be parsed before parsing messages")
108126

109-
parsers.get_message_parser(self.build).load(self, self.files['replay.message.events'])
110-
self.parsed['messages'] = True
127+
parsers.get_message_parser(self.build).load(self, self._files['replay.message.events'])
128+
self.__parsed['messages'] = True
111129

112130
def _parse_events(self):
113131
#The details file data is required for parsing
114-
if not self.parsed['details']:
132+
if not self.__parsed['details']:
115133
raise ValueError("The replay details must be parsed before parsing events")
116134

117-
parsers.get_event_parser(self.build).load(self, self.files['replay.game.events'])
118-
self.parsed['events'] = True
135+
parsers.get_event_parser(self.build).load(self, self._files['replay.game.events'])
136+
self.__parsed['events'] = True
119137

120138
#We can now sort events by type and get results
121139
self.events_by_type = defaultdict(list)
@@ -126,15 +144,15 @@ def _parse_events(self):
126144

127145
def _process_results(self):
128146
#The details,attributes, and events are required
129-
if not (self.parsed['details'] and self.parsed['attributes'] and self.parsed['events']):
147+
if not (self.__parsed['details'] and self.__parsed['attributes'] and self.__parsed['events']):
130148
raise ValueError("The replay details must be parsed before parsing attributes")
131149

132150
#Remove players from the teams as they drop out of the game
133151
self.results = dict([team, len(players)] for team, players in self.teams.iteritems())
134152
for event in self.events_by_type['leave']:
135153
#Some spectator actions seem to be recorded, they aren't on teams anyway
136154
if event.player < len(self.players):
137-
team = self.players[event.player].team
155+
team = self.player[event.player].team
138156
self.results[team] -= 1
139157

140158
#mark all teams with no players left as losing, save the rest of the teams
@@ -165,7 +183,7 @@ def _process_results(self):
165183
if len(remaining) == 1:
166184
self.results[remaining.pop()] = "Won"
167185

168-
for player in self.players[1:]:
186+
for player in self.players:
169187
player.result = self.results[player.team]
170188

171189
if __name__ == '__main__':

sc2reader/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def get_little(self, number, byte_code=False):
4747
return result, byte_string
4848
return result
4949

50-
def getString(self, length, byte_code=False):
50+
def get_string(self, length, byte_code=False):
5151
string, bytes = self.get_big(length, byte_code=True)
5252
if byte_code:
5353
return string.decode("hex"), bytes
@@ -72,7 +72,7 @@ def get_count(self, byte_code=False):
7272
return num/2, byte_string
7373
return num/2
7474

75-
def getTimestamp(self, byte_code=False):
75+
def get_timestamp(self, byte_code=False):
7676
#Get the first byte
7777
byte, byte_string = self.get_big_int(1, byte_code=True)
7878

scripts/sc2printer

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!c:\\Python27\python.exe
22

33
import os, sys
4+
sys.path.append(r'C:\Users\graylinkim\sc2reader')
45
from sc2reader import Replay
56
from sc2reader.exceptions import ParseError
67

shell.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from sc2reader import Replay
2+
from mpyq import MPQArchive

test_replays/test_all.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
# TODO:
55
# - Performance tests to measure the effect of optimizations
6-
import os
6+
import os,sys
77
import pytest
88

9+
sys.path.append(r"C:\Users\graylinkim\sc2reader")
910
from sc2reader import Replay
1011
from sc2reader.exceptions import ParseError
1112

@@ -44,7 +45,9 @@ def test_1():
4445
assert replay.type == "1v1"
4546
assert replay.category == "Ladder"
4647

47-
# assert len(replay.players) == 2
48+
assert len(replay.players) == 2
49+
assert replay.player[1].name == "Emperor"
50+
assert replay.player[2].name == "Boom"
4851
emperor = find(lambda player: player.name == "Emperor", replay.players)
4952
assert emperor.team == 1
5053
assert emperor.race == "Protoss"
@@ -55,15 +58,15 @@ def test_1():
5558
assert boom.race == "Terran"
5659
assert boom.recorder == True
5760

58-
# for player in replay.players:
59-
# assert player.type == "Human"
61+
for player in replay.players:
62+
assert player.type == "Human"
6063

6164
# Because it is a 1v1 and the recording player quit, we should know the winner.
6265
assert emperor.result == "Won"
6366
assert boom.result == "Lost"
6467

65-
# assert emperor.url == "http://eu.battle.net/sc2/en/profile/520049/1/Emperor/"
66-
# assert boom.url == "http://eu.battle.net/sc2/en/profile/1694745/1/Boom/"
68+
assert emperor.url == "http://eu.battle.net/sc2/en/profile/520049/1/Emperor/"
69+
assert boom.url == "http://eu.battle.net/sc2/en/profile/1694745/1/Boom/"
6770

6871
assert len(replay.messages) == 12
6972
assert find(lambda player: player.pid == replay.messages[0].player, replay.players).name == "Emperor"

0 commit comments

Comments
 (0)