herostats 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE +7 -0
- data/README.md +34 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/herostats.gemspec +33 -0
- data/lib/heroprotocol/LICENSE +19 -0
- data/lib/heroprotocol/README.md +91 -0
- data/lib/heroprotocol/__init__.py +0 -0
- data/lib/heroprotocol/decoders.py +311 -0
- data/lib/heroprotocol/heroprotocol.py +136 -0
- data/lib/heroprotocol/mpyq/LICENSE +22 -0
- data/lib/heroprotocol/mpyq/MANIFEST.in +3 -0
- data/lib/heroprotocol/mpyq/README.md +202 -0
- data/lib/heroprotocol/mpyq/__init__.py +0 -0
- data/lib/heroprotocol/mpyq/mpyq.py +406 -0
- data/lib/heroprotocol/mpyq/setup.py +33 -0
- data/lib/heroprotocol/protocol29406.py +491 -0
- data/lib/heroprotocol/protocol30414.py +492 -0
- data/lib/heroprotocol/protocol30509.py +492 -0
- data/lib/heroprotocol/protocol30829.py +492 -0
- data/lib/heroprotocol/protocol30948.py +492 -0
- data/lib/heroprotocol/protocol31090.py +492 -0
- data/lib/heroprotocol/protocol31360.py +494 -0
- data/lib/heroprotocol/protocol31566.py +494 -0
- data/lib/heroprotocol/protocol31726.py +494 -0
- data/lib/heroprotocol/protocol31948.py +494 -0
- data/lib/heroprotocol/protocol32120.py +494 -0
- data/lib/heroprotocol/protocol32253.py +494 -0
- data/lib/heroprotocol/protocol32455.py +496 -0
- data/lib/heroprotocol/protocol32524.py +496 -0
- data/lib/heroprotocol/protocol33182.py +499 -0
- data/lib/heroprotocol/protocol33353.py +499 -0
- data/lib/heroprotocol/protocol33684.py +502 -0
- data/lib/heroprotocol/protocol34053.py +502 -0
- data/lib/heroprotocol/protocol34190.py +502 -0
- data/lib/heroprotocol/protocol34659.py +502 -0
- data/lib/heroprotocol/protocol34846.py +502 -0
- data/lib/heroprotocol/protocol35360.py +502 -0
- data/lib/heroprotocol/protocol35529.py +502 -0
- data/lib/heroprotocol/protocol35634.py +502 -0
- data/lib/heroprotocol/protocol35702.py +502 -0
- data/lib/heroprotocol/protocol36144.py +502 -0
- data/lib/heroprotocol/protocol36280.py +502 -0
- data/lib/heroprotocol/protocol36359.py +502 -0
- data/lib/heroprotocol/protocol36536.py +502 -0
- data/lib/heroprotocol/protocol36693.py +502 -0
- data/lib/heroprotocol/protocol37069.py +507 -0
- data/lib/heroprotocol/protocol37117.py +507 -0
- data/lib/heroprotocol/protocol37274.py +507 -0
- data/lib/heroprotocol/protocol37351.py +507 -0
- data/lib/heroprotocol/protocol37569.py +507 -0
- data/lib/heroprotocol/protocol37795.py +507 -0
- data/lib/heroprotocol/protocol38236.py +508 -0
- data/lib/heroprotocol/protocol38500.py +508 -0
- data/lib/heroprotocol/protocol38593.py +508 -0
- data/lib/heroprotocol/protocol38793.py +508 -0
- data/lib/heroprotocol/protocol39015.py +508 -0
- data/lib/heroprotocol/protocol39153.py +508 -0
- data/lib/heroprotocol/protocol39271.py +508 -0
- data/lib/heroprotocol/protocol39445.py +508 -0
- data/lib/heroprotocol/protocol39595.py +508 -0
- data/lib/heroprotocol/protocol39709.py +508 -0
- data/lib/heroprotocol/protocol39951.py +508 -0
- data/lib/heroprotocol/protocol40087.py +508 -0
- data/lib/heroprotocol/protocol40322.py +508 -0
- data/lib/heroprotocol/protocol40336.py +526 -0
- data/lib/heroprotocol/protocol40431.py +526 -0
- data/lib/heroprotocol/protocol40697.py +526 -0
- data/lib/heroprotocol/protocol40798.py +526 -0
- data/lib/herostats.rb +10 -0
- data/lib/herostats/exp_breakdown.rb +24 -0
- data/lib/herostats/game.rb +25 -0
- data/lib/herostats/init_data_parser.rb +45 -0
- data/lib/herostats/player.rb +25 -0
- data/lib/herostats/replay_parser.rb +34 -0
- data/lib/herostats/team.rb +10 -0
- data/lib/herostats/tracker_events_parser.rb +158 -0
- data/lib/herostats/version.rb +3 -0
- metadata +171 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2015 Blizzard Entertainment
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in
|
|
13
|
+
# all copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
# THE SOFTWARE.
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
import argparse
|
|
25
|
+
import pprint
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
from mpyq import mpyq
|
|
29
|
+
import protocol29406
|
|
30
|
+
|
|
31
|
+
class EventLogger:
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self._event_stats = {}
|
|
34
|
+
|
|
35
|
+
def log(self, output, event):
|
|
36
|
+
# update stats
|
|
37
|
+
if '_event' in event and '_bits' in event:
|
|
38
|
+
stat = self._event_stats.get(event['_event'], [0, 0])
|
|
39
|
+
stat[0] += 1 # count of events
|
|
40
|
+
stat[1] += event['_bits'] # count of bits
|
|
41
|
+
self._event_stats[event['_event']] = stat
|
|
42
|
+
# write structure
|
|
43
|
+
if args.json:
|
|
44
|
+
s = json.dumps(event, encoding="ISO-8859-1");
|
|
45
|
+
print(s);
|
|
46
|
+
else:
|
|
47
|
+
pprint.pprint(event, stream=output)
|
|
48
|
+
|
|
49
|
+
def log_stats(self, output):
|
|
50
|
+
for name, stat in sorted(self._event_stats.iteritems(), key=lambda x: x[1][1]):
|
|
51
|
+
print >> output, '"%s", %d, %d,' % (name, stat[0], stat[1] / 8)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == '__main__':
|
|
55
|
+
parser = argparse.ArgumentParser()
|
|
56
|
+
parser.add_argument('replay_file', help='.StormReplay file to load')
|
|
57
|
+
parser.add_argument("--gameevents", help="print game events",
|
|
58
|
+
action="store_true")
|
|
59
|
+
parser.add_argument("--messageevents", help="print message events",
|
|
60
|
+
action="store_true")
|
|
61
|
+
parser.add_argument("--trackerevents", help="print tracker events",
|
|
62
|
+
action="store_true")
|
|
63
|
+
parser.add_argument("--attributeevents", help="print attributes events",
|
|
64
|
+
action="store_true")
|
|
65
|
+
parser.add_argument("--header", help="print protocol header",
|
|
66
|
+
action="store_true")
|
|
67
|
+
parser.add_argument("--details", help="print protocol details",
|
|
68
|
+
action="store_true")
|
|
69
|
+
parser.add_argument("--initdata", help="print protocol initdata",
|
|
70
|
+
action="store_true")
|
|
71
|
+
parser.add_argument("--stats", help="print stats",
|
|
72
|
+
action="store_true")
|
|
73
|
+
parser.add_argument("--json", help="protocol information is printed in json format.",
|
|
74
|
+
action="store_true")
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
archive = mpyq.MPQArchive(args.replay_file)
|
|
78
|
+
|
|
79
|
+
logger = EventLogger()
|
|
80
|
+
logger.args = args;
|
|
81
|
+
|
|
82
|
+
# Read the protocol header, this can be read with any protocol
|
|
83
|
+
contents = archive.header['user_data_header']['content']
|
|
84
|
+
header = protocol29406.decode_replay_header(contents)
|
|
85
|
+
if args.header:
|
|
86
|
+
logger.log(sys.stdout, header)
|
|
87
|
+
|
|
88
|
+
# The header's baseBuild determines which protocol to use
|
|
89
|
+
baseBuild = header['m_version']['m_baseBuild']
|
|
90
|
+
try:
|
|
91
|
+
protocol = __import__('protocol%s' % (baseBuild,))
|
|
92
|
+
except:
|
|
93
|
+
print >> sys.stderr, 'Unsupported base build: %d' % baseBuild
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
# Print protocol details
|
|
97
|
+
if args.details:
|
|
98
|
+
contents = archive.read_file('replay.details')
|
|
99
|
+
details = protocol.decode_replay_details(contents)
|
|
100
|
+
logger.log(sys.stdout, details)
|
|
101
|
+
|
|
102
|
+
# Print protocol init data
|
|
103
|
+
if args.initdata:
|
|
104
|
+
contents = archive.read_file('replay.initData')
|
|
105
|
+
initdata = protocol.decode_replay_initdata(contents)
|
|
106
|
+
logger.log(sys.stdout, initdata['m_syncLobbyState']['m_gameDescription']['m_cacheHandles'])
|
|
107
|
+
logger.log(sys.stdout, initdata)
|
|
108
|
+
|
|
109
|
+
# Print game events and/or game events stats
|
|
110
|
+
if args.gameevents:
|
|
111
|
+
contents = archive.read_file('replay.game.events')
|
|
112
|
+
for event in protocol.decode_replay_game_events(contents):
|
|
113
|
+
logger.log(sys.stdout, event)
|
|
114
|
+
|
|
115
|
+
# Print message events
|
|
116
|
+
if args.messageevents:
|
|
117
|
+
contents = archive.read_file('replay.message.events')
|
|
118
|
+
for event in protocol.decode_replay_message_events(contents):
|
|
119
|
+
logger.log(sys.stdout, event)
|
|
120
|
+
|
|
121
|
+
# Print tracker events
|
|
122
|
+
if args.trackerevents:
|
|
123
|
+
if hasattr(protocol, 'decode_replay_tracker_events'):
|
|
124
|
+
contents = archive.read_file('replay.tracker.events')
|
|
125
|
+
for event in protocol.decode_replay_tracker_events(contents):
|
|
126
|
+
logger.log(sys.stdout, event)
|
|
127
|
+
|
|
128
|
+
# Print attributes events
|
|
129
|
+
if args.attributeevents:
|
|
130
|
+
contents = archive.read_file('replay.attributes.events')
|
|
131
|
+
attributes = protocol.decode_replay_attributes_events(contents)
|
|
132
|
+
logger.log(sys.stdout, attributes)
|
|
133
|
+
|
|
134
|
+
# Print stats
|
|
135
|
+
if args.stats:
|
|
136
|
+
logger.log_stats(sys.stderr)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2010, 2011 Aku Kotkavuo. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
|
4
|
+
modification, are permitted provided that the following conditions are met:
|
|
5
|
+
|
|
6
|
+
1. Redistributions of source code must retain the above copyright
|
|
7
|
+
notice, this list of conditions and the following disclaimer.
|
|
8
|
+
|
|
9
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
10
|
+
notice, this list of conditions and the following disclaimer in the
|
|
11
|
+
documentation and/or other materials provided with the distribution.
|
|
12
|
+
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
14
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
15
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
17
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
18
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
19
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
20
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
21
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
22
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# mpyq
|
|
2
|
+
|
|
3
|
+
mpyq is a Python library for reading MPQ (MoPaQ) archives used in many of
|
|
4
|
+
Blizzard's games. It was originally developed for data mining Starcraft II
|
|
5
|
+
replay files.
|
|
6
|
+
|
|
7
|
+
In addition to being a library, mpyq also has a command line interface that
|
|
8
|
+
exposes some of the library's core functionality such as extracting archives.
|
|
9
|
+
|
|
10
|
+
At this early stage in development only files compressed with DEFLATE or bzip2
|
|
11
|
+
are uncompressed. This means that this library can not be used to extract most
|
|
12
|
+
big game asset archives that Blizzard's games use. More compression formats
|
|
13
|
+
will be supported in the future.
|
|
14
|
+
|
|
15
|
+
Also, as mpyq is so far pure Python code, it might be unfeasible to try to
|
|
16
|
+
extract very large MPQ archives, even if all the compression methods used
|
|
17
|
+
inside the archive were supported.
|
|
18
|
+
|
|
19
|
+
Note that listing files inside an archive does not require full extraction.
|
|
20
|
+
You can safely take a peek inside any MPQ archive with this library.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
A stable version of mpyq is available from PyPI and can be installed with
|
|
25
|
+
either `easy_install` or `pip`.
|
|
26
|
+
|
|
27
|
+
$ easy_install mpyq
|
|
28
|
+
$ pip install mpyq
|
|
29
|
+
|
|
30
|
+
mpyq can be installed manually with the included setup.py script.
|
|
31
|
+
|
|
32
|
+
$ python setup.py install
|
|
33
|
+
|
|
34
|
+
Running this command will install mpyq both as a library and a stand-alone
|
|
35
|
+
script that can be run from anywhere, provided that you have added Python's
|
|
36
|
+
bin directory to your PATH environment variable.
|
|
37
|
+
|
|
38
|
+
Alternative way to install mpyq is to clone this git repository somewhere on
|
|
39
|
+
your filesystem and then either adjust your PYTHONPATH environment variable to
|
|
40
|
+
point to the directory that contains the repository or create a symbolic link
|
|
41
|
+
to your Python's site-packages directory pointing at the repository.
|
|
42
|
+
|
|
43
|
+
Note that the command line interface part of mpyq uses the argparse module,
|
|
44
|
+
which was included into Python's standard library in version 2.7. If you
|
|
45
|
+
didn't install mpyq from PyPI and you wish to use the command line interface
|
|
46
|
+
part with Python 2.6, you should install argparse from PyPI manually.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### As a library
|
|
51
|
+
|
|
52
|
+
>>> from mpyq import MPQArchive
|
|
53
|
+
>>> archive = MPQArchive('game.SC2Replay')
|
|
54
|
+
|
|
55
|
+
Now you have a MPQArchive object of the file you opened. One common thing
|
|
56
|
+
to do now is to extract the files from the archive.
|
|
57
|
+
|
|
58
|
+
>>> files = archive.extract()
|
|
59
|
+
|
|
60
|
+
This will extract and return the archive's contents in memory. Be advised
|
|
61
|
+
that you might not want to do this with multi-gigabyte MPQ files from
|
|
62
|
+
World of Warcraft, for example.
|
|
63
|
+
|
|
64
|
+
Files inside the archive can be also extracted and written to disk.
|
|
65
|
+
|
|
66
|
+
>>> archive.extract_to_disk()
|
|
67
|
+
|
|
68
|
+
If you want to skip reading the (listfile) inside the archive, you can do
|
|
69
|
+
so by passing `listfile=False` to the constructor.
|
|
70
|
+
|
|
71
|
+
>>> archive = MPQArchive('bad_listfile.SC2Replay', listfile=False)
|
|
72
|
+
|
|
73
|
+
This might be required if the (listfile) is encrypted or has been tampered
|
|
74
|
+
with. Note that you can't list files or extract the whole archive if you do
|
|
75
|
+
this -- you need to know in advance which files you want to read.
|
|
76
|
+
|
|
77
|
+
>>> archive.read('replay.details')
|
|
78
|
+
'\x05\x1c\x00\x04\x01\x00\x04\x05...'
|
|
79
|
+
|
|
80
|
+
For more information, consult `help(mpyq)` in your Python console.
|
|
81
|
+
|
|
82
|
+
### From the command line
|
|
83
|
+
|
|
84
|
+
usage: mpyq.py [-h] [-I] [-H] [-b] [-s] [-t] [-x] file
|
|
85
|
+
|
|
86
|
+
mpyq reads and extracts MPQ archives.
|
|
87
|
+
|
|
88
|
+
positional arguments:
|
|
89
|
+
file path to the archive
|
|
90
|
+
|
|
91
|
+
optional arguments:
|
|
92
|
+
-h, --help show this help message and exit
|
|
93
|
+
-I, --headers print header information from the archive
|
|
94
|
+
-H, --hash-table print hash table
|
|
95
|
+
-b, --block-table print block table
|
|
96
|
+
-s, --skip-listfile skip reading (listfile)
|
|
97
|
+
-t, --list-files list files inside the archive
|
|
98
|
+
-x, --extract extract files from the archive
|
|
99
|
+
|
|
100
|
+
You can extract all the files inside the archive with `-x/--extract`.
|
|
101
|
+
|
|
102
|
+
$ mpyq -x game.SC2Replay
|
|
103
|
+
|
|
104
|
+
This will create a directory called 'game' with the files inside.
|
|
105
|
+
|
|
106
|
+
You can print the header information from a given archive with `-I/--headers`.
|
|
107
|
+
|
|
108
|
+
$ mpyq -I game.SC2Replay
|
|
109
|
+
MPQ archive header
|
|
110
|
+
------------------
|
|
111
|
+
magic 'MPQ\x1a'
|
|
112
|
+
header_size 44
|
|
113
|
+
arhive_size 19801
|
|
114
|
+
format_version 1
|
|
115
|
+
sector_size_shift 3
|
|
116
|
+
hash_table_offset 19385
|
|
117
|
+
block_table_offset 19641
|
|
118
|
+
hash_table_entries 16
|
|
119
|
+
block_table_entries 10
|
|
120
|
+
extended_block_table_offset 0
|
|
121
|
+
hash_table_offset_high 0
|
|
122
|
+
block_table_offset_high 0
|
|
123
|
+
offset 1024
|
|
124
|
+
|
|
125
|
+
MPQ user data header
|
|
126
|
+
--------------------
|
|
127
|
+
magic 'MPQ\x1b'
|
|
128
|
+
user_data_size 512
|
|
129
|
+
mpq_header_offset 1024
|
|
130
|
+
user_data_header_size 60
|
|
131
|
+
content '\x05\x08\x00\x02,StarCraft II replay\x1b
|
|
132
|
+
11\x02\x05\x0c\x00\t\x02\x02\t\x00\x04\t
|
|
133
|
+
(\x06\t\x00\x08\t\xc8\xfa\x01\n\t\xc8\xf
|
|
134
|
+
a\x01\x04\t\x04\x06\t\xa2\x99\x01'
|
|
135
|
+
|
|
136
|
+
You can display the archive's hash table with `-H/--hash-table`.
|
|
137
|
+
|
|
138
|
+
$ mpyq -H game.SC2Replay
|
|
139
|
+
MPQ archive hash table
|
|
140
|
+
----------------------
|
|
141
|
+
Hash A Hash B Locl Plat BlockIdx
|
|
142
|
+
D38437CB 07DFEAEC 0000 0000 00000009
|
|
143
|
+
AAC2A54B F4762B95 0000 0000 00000002
|
|
144
|
+
FFFFFFFF FFFFFFFF FFFF FFFF FFFFFFFF
|
|
145
|
+
FFFFFFFF FFFFFFFF FFFF FFFF FFFFFFFF
|
|
146
|
+
FFFFFFFF FFFFFFFF FFFF FFFF FFFFFFFF
|
|
147
|
+
C9E5B770 3B18F6B6 0000 0000 00000005
|
|
148
|
+
343C087B 278E3682 0000 0000 00000004
|
|
149
|
+
3B2B1EA0 B72EF057 0000 0000 00000006
|
|
150
|
+
5A7E8BDC FF253F5C 0000 0000 00000001
|
|
151
|
+
FD657910 4E9B98A7 0000 0000 00000008
|
|
152
|
+
D383C29C EF402E92 0000 0000 00000000
|
|
153
|
+
FFFFFFFF FFFFFFFF FFFF FFFF FFFFFFFF
|
|
154
|
+
FFFFFFFF FFFFFFFF FFFF FFFF FFFFFFFF
|
|
155
|
+
FFFFFFFF FFFFFFFF FFFF FFFF FFFFFFFF
|
|
156
|
+
1DA8B0CF A2CEFF28 0000 0000 00000007
|
|
157
|
+
31952289 6A5FFAA3 0000 0000 00000003
|
|
158
|
+
|
|
159
|
+
You can display the archive's block table with `-b/--block-table`.
|
|
160
|
+
|
|
161
|
+
$ mpyq -b game.SC2Replay
|
|
162
|
+
MPQ archive block table
|
|
163
|
+
-----------------------
|
|
164
|
+
Offset ArchSize RealSize Flags
|
|
165
|
+
0000002C 443 443 81000200
|
|
166
|
+
000001E7 609 1082 81000200
|
|
167
|
+
00000448 16077 42859 81000200
|
|
168
|
+
00004315 94 94 81000200
|
|
169
|
+
00004373 96 96 81000200
|
|
170
|
+
000043D3 591 765 81000200
|
|
171
|
+
00004622 802 1444 81000200
|
|
172
|
+
00004944 247 580 81000200
|
|
173
|
+
00004A3B 120 164 81000200
|
|
174
|
+
00004AB3 262 288 81000200
|
|
175
|
+
|
|
176
|
+
You can list all files inside the archive with `-t/--list-files`.
|
|
177
|
+
|
|
178
|
+
$ mpyq -t game.SC2Replay
|
|
179
|
+
replay.attributes.events 580 bytes
|
|
180
|
+
replay.details 443 bytes
|
|
181
|
+
replay.game.events 42859 bytes
|
|
182
|
+
replay.initData 1082 bytes
|
|
183
|
+
replay.load.info 96 bytes
|
|
184
|
+
replay.message.events 94 bytes
|
|
185
|
+
replay.smartcam.events 1444 bytes
|
|
186
|
+
replay.sync.events 765 bytes
|
|
187
|
+
|
|
188
|
+
You can skip reading the listfile with `-s/--skip-listfile`. This might be
|
|
189
|
+
necessarry if the listfile is encrypted or corrupted. Note that you cannot
|
|
190
|
+
list files or extract the whole archive without the listfile.
|
|
191
|
+
|
|
192
|
+
## References
|
|
193
|
+
|
|
194
|
+
The following two documents were used as references for the MPQ format:
|
|
195
|
+
|
|
196
|
+
* [http://www.zezula.net/en/mpq/mpqformat.html](http://www.zezula.net/en/mpq/mpqformat.html)
|
|
197
|
+
* [http://wiki.devklog.net/index.php?title=The_MoPaQ_Archive_Format](http://wiki.devklog.net/index.php?title=The_MoPaQ_Archive_Format)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
## Copyright
|
|
201
|
+
|
|
202
|
+
Copyright 2010, 2011 Aku Kotkavuo. See LICENSE for details.
|
|
File without changes
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# coding: utf-8
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
mpyq is a Python library for reading MPQ (MoPaQ) archives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import bz2
|
|
9
|
+
import cStringIO
|
|
10
|
+
import os
|
|
11
|
+
import struct
|
|
12
|
+
import zlib
|
|
13
|
+
from collections import namedtuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__author__ = "Aku Kotkavuo"
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
MPQ_FILE_IMPLODE = 0x00000100
|
|
21
|
+
MPQ_FILE_COMPRESS = 0x00000200
|
|
22
|
+
MPQ_FILE_ENCRYPTED = 0x00010000
|
|
23
|
+
MPQ_FILE_FIX_KEY = 0x00020000
|
|
24
|
+
MPQ_FILE_SINGLE_UNIT = 0x01000000
|
|
25
|
+
MPQ_FILE_DELETE_MARKER = 0x02000000
|
|
26
|
+
MPQ_FILE_SECTOR_CRC = 0x04000000
|
|
27
|
+
MPQ_FILE_EXISTS = 0x80000000
|
|
28
|
+
|
|
29
|
+
MPQFileHeader = namedtuple('MPQFileHeader',
|
|
30
|
+
'''
|
|
31
|
+
magic
|
|
32
|
+
header_size
|
|
33
|
+
archive_size
|
|
34
|
+
format_version
|
|
35
|
+
sector_size_shift
|
|
36
|
+
hash_table_offset
|
|
37
|
+
block_table_offset
|
|
38
|
+
hash_table_entries
|
|
39
|
+
block_table_entries
|
|
40
|
+
'''
|
|
41
|
+
)
|
|
42
|
+
MPQFileHeader.struct_format = '<4s2I2H4I'
|
|
43
|
+
|
|
44
|
+
MPQFileHeaderExt = namedtuple('MPQFileHeaderExt',
|
|
45
|
+
'''
|
|
46
|
+
extended_block_table_offset
|
|
47
|
+
hash_table_offset_high
|
|
48
|
+
block_table_offset_high
|
|
49
|
+
'''
|
|
50
|
+
)
|
|
51
|
+
MPQFileHeaderExt.struct_format = 'q2h'
|
|
52
|
+
|
|
53
|
+
MPQUserDataHeader = namedtuple('MPQUserDataHeader',
|
|
54
|
+
'''
|
|
55
|
+
magic
|
|
56
|
+
user_data_size
|
|
57
|
+
mpq_header_offset
|
|
58
|
+
user_data_header_size
|
|
59
|
+
'''
|
|
60
|
+
)
|
|
61
|
+
MPQUserDataHeader.struct_format = '<4s3I'
|
|
62
|
+
|
|
63
|
+
MPQHashTableEntry = namedtuple('MPQHashTableEntry',
|
|
64
|
+
'''
|
|
65
|
+
hash_a
|
|
66
|
+
hash_b
|
|
67
|
+
locale
|
|
68
|
+
platform
|
|
69
|
+
block_table_index
|
|
70
|
+
'''
|
|
71
|
+
)
|
|
72
|
+
MPQHashTableEntry.struct_format = '2I2HI'
|
|
73
|
+
|
|
74
|
+
MPQBlockTableEntry = namedtuple('MPQBlockTableEntry',
|
|
75
|
+
'''
|
|
76
|
+
offset
|
|
77
|
+
archived_size
|
|
78
|
+
size
|
|
79
|
+
flags
|
|
80
|
+
'''
|
|
81
|
+
)
|
|
82
|
+
MPQBlockTableEntry.struct_format = '4I'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MPQArchive(object):
|
|
86
|
+
|
|
87
|
+
def __init__(self, filename, listfile=True):
|
|
88
|
+
"""Create a MPQArchive object.
|
|
89
|
+
|
|
90
|
+
You can skip reading the listfile if you pass listfile=False
|
|
91
|
+
to the constructor. The 'files' attribute will be unavailable
|
|
92
|
+
if you do this.
|
|
93
|
+
"""
|
|
94
|
+
if hasattr(filename, 'read'):
|
|
95
|
+
self.file = filename
|
|
96
|
+
else:
|
|
97
|
+
self.file = open(filename, 'rb')
|
|
98
|
+
self.header = self.read_header()
|
|
99
|
+
self.hash_table = self.read_table('hash')
|
|
100
|
+
self.block_table = self.read_table('block')
|
|
101
|
+
if listfile:
|
|
102
|
+
self.files = self.read_file('(listfile)').splitlines()
|
|
103
|
+
else:
|
|
104
|
+
self.files = None
|
|
105
|
+
|
|
106
|
+
def read_header(self):
|
|
107
|
+
"""Read the header of a MPQ archive."""
|
|
108
|
+
|
|
109
|
+
def read_mpq_header(offset=None):
|
|
110
|
+
if offset:
|
|
111
|
+
self.file.seek(offset)
|
|
112
|
+
data = self.file.read(32)
|
|
113
|
+
header = MPQFileHeader._make(
|
|
114
|
+
struct.unpack(MPQFileHeader.struct_format, data))
|
|
115
|
+
header = header._asdict()
|
|
116
|
+
if header['format_version'] == 1:
|
|
117
|
+
data = self.file.read(12)
|
|
118
|
+
extended_header = MPQFileHeaderExt._make(
|
|
119
|
+
struct.unpack(MPQFileHeaderExt.struct_format, data))
|
|
120
|
+
header.update(extended_header._asdict())
|
|
121
|
+
return header
|
|
122
|
+
|
|
123
|
+
def read_mpq_user_data_header():
|
|
124
|
+
data = self.file.read(16)
|
|
125
|
+
header = MPQUserDataHeader._make(
|
|
126
|
+
struct.unpack(MPQUserDataHeader.struct_format, data))
|
|
127
|
+
header = header._asdict()
|
|
128
|
+
header['content'] = self.file.read(header['user_data_header_size'])
|
|
129
|
+
return header
|
|
130
|
+
|
|
131
|
+
magic = self.file.read(4)
|
|
132
|
+
self.file.seek(0)
|
|
133
|
+
|
|
134
|
+
if magic == 'MPQ\x1a':
|
|
135
|
+
header = read_mpq_header()
|
|
136
|
+
header['offset'] = 0
|
|
137
|
+
elif magic == 'MPQ\x1b':
|
|
138
|
+
user_data_header = read_mpq_user_data_header()
|
|
139
|
+
header = read_mpq_header(user_data_header['mpq_header_offset'])
|
|
140
|
+
header['offset'] = user_data_header['mpq_header_offset']
|
|
141
|
+
header['user_data_header'] = user_data_header
|
|
142
|
+
|
|
143
|
+
return header
|
|
144
|
+
|
|
145
|
+
def read_table(self, table_type):
|
|
146
|
+
"""Read either the hash or block table of a MPQ archive."""
|
|
147
|
+
|
|
148
|
+
if table_type == 'hash':
|
|
149
|
+
entry_class = MPQHashTableEntry
|
|
150
|
+
elif table_type == 'block':
|
|
151
|
+
entry_class = MPQBlockTableEntry
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError("Invalid table type.")
|
|
154
|
+
|
|
155
|
+
table_offset = self.header['%s_table_offset' % table_type]
|
|
156
|
+
table_entries = self.header['%s_table_entries' % table_type]
|
|
157
|
+
key = self._hash('(%s table)' % table_type, 'TABLE')
|
|
158
|
+
|
|
159
|
+
self.file.seek(table_offset + self.header['offset'])
|
|
160
|
+
data = self.file.read(table_entries * 16)
|
|
161
|
+
data = self._decrypt(data, key)
|
|
162
|
+
|
|
163
|
+
def unpack_entry(position):
|
|
164
|
+
entry_data = data[position*16:position*16+16]
|
|
165
|
+
return entry_class._make(
|
|
166
|
+
struct.unpack(entry_class.struct_format, entry_data))
|
|
167
|
+
|
|
168
|
+
return [unpack_entry(i) for i in range(table_entries)]
|
|
169
|
+
|
|
170
|
+
def get_hash_table_entry(self, filename):
|
|
171
|
+
"""Get the hash table entry corresponding to a given filename."""
|
|
172
|
+
hash_a = self._hash(filename, 'HASH_A')
|
|
173
|
+
hash_b = self._hash(filename, 'HASH_B')
|
|
174
|
+
for entry in self.hash_table:
|
|
175
|
+
if (entry.hash_a == hash_a and entry.hash_b == hash_b):
|
|
176
|
+
return entry
|
|
177
|
+
|
|
178
|
+
def read_file(self, filename, force_decompress=False):
|
|
179
|
+
"""Read a file from the MPQ archive."""
|
|
180
|
+
|
|
181
|
+
def decompress(data):
|
|
182
|
+
"""Read the compression type and decompress file data."""
|
|
183
|
+
compression_type = ord(data[0])
|
|
184
|
+
if compression_type == 0:
|
|
185
|
+
return data
|
|
186
|
+
elif compression_type == 2:
|
|
187
|
+
return zlib.decompress(data[1:], 15)
|
|
188
|
+
elif compression_type == 16:
|
|
189
|
+
return bz2.decompress(data[1:])
|
|
190
|
+
else:
|
|
191
|
+
raise RuntimeError("Unsupported compression type.")
|
|
192
|
+
|
|
193
|
+
hash_entry = self.get_hash_table_entry(filename)
|
|
194
|
+
if hash_entry is None:
|
|
195
|
+
return None
|
|
196
|
+
block_entry = self.block_table[hash_entry.block_table_index]
|
|
197
|
+
|
|
198
|
+
# Read the block.
|
|
199
|
+
if block_entry.flags & MPQ_FILE_EXISTS:
|
|
200
|
+
if block_entry.archived_size == 0:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
offset = block_entry.offset + self.header['offset']
|
|
204
|
+
self.file.seek(offset)
|
|
205
|
+
file_data = self.file.read(block_entry.archived_size)
|
|
206
|
+
|
|
207
|
+
if block_entry.flags & MPQ_FILE_ENCRYPTED:
|
|
208
|
+
raise NotImplementedError("Encryption is not supported yet.")
|
|
209
|
+
|
|
210
|
+
if not block_entry.flags & MPQ_FILE_SINGLE_UNIT:
|
|
211
|
+
# File consist of many sectors. They all need to be
|
|
212
|
+
# decompressed separately and united.
|
|
213
|
+
sector_size = 512 << self.header['sector_size_shift']
|
|
214
|
+
sectors = block_entry.size / sector_size + 1
|
|
215
|
+
if block_entry.flags & MPQ_FILE_SECTOR_CRC:
|
|
216
|
+
crc = True
|
|
217
|
+
sectors += 1
|
|
218
|
+
else:
|
|
219
|
+
crc = False
|
|
220
|
+
positions = struct.unpack('<%dI' % (sectors + 1),
|
|
221
|
+
file_data[:4*(sectors+1)])
|
|
222
|
+
result = cStringIO.StringIO()
|
|
223
|
+
for i in range(len(positions) - (2 if crc else 1)):
|
|
224
|
+
sector = file_data[positions[i]:positions[i+1]]
|
|
225
|
+
if (block_entry.flags & MPQ_FILE_COMPRESS and
|
|
226
|
+
(force_decompress or block_entry.size > block_entry.archived_size)):
|
|
227
|
+
sector = decompress(sector)
|
|
228
|
+
result.write(sector)
|
|
229
|
+
file_data = result.getvalue()
|
|
230
|
+
else:
|
|
231
|
+
# Single unit files only need to be decompressed, but
|
|
232
|
+
# compression only happens when at least one byte is gained.
|
|
233
|
+
if (block_entry.flags & MPQ_FILE_COMPRESS and
|
|
234
|
+
(force_decompress or block_entry.size > block_entry.archived_size)):
|
|
235
|
+
file_data = decompress(file_data)
|
|
236
|
+
|
|
237
|
+
return file_data
|
|
238
|
+
|
|
239
|
+
def extract(self):
|
|
240
|
+
"""Extract all the files inside the MPQ archive in memory."""
|
|
241
|
+
if self.files:
|
|
242
|
+
return dict((f, self.read_file(f)) for f in self.files)
|
|
243
|
+
else:
|
|
244
|
+
raise RuntimeError("Can't extract whole archive without listfile.")
|
|
245
|
+
|
|
246
|
+
def extract_to_disk(self):
|
|
247
|
+
"""Extract all files and write them to disk."""
|
|
248
|
+
archive_name, extension = os.path.splitext(os.path.basename(self.file.name))
|
|
249
|
+
if not os.path.isdir(os.path.join(os.getcwd(), archive_name)):
|
|
250
|
+
os.mkdir(archive_name)
|
|
251
|
+
os.chdir(archive_name)
|
|
252
|
+
for filename, data in self.extract().items():
|
|
253
|
+
f = open(filename, 'wb')
|
|
254
|
+
f.write(data)
|
|
255
|
+
f.close()
|
|
256
|
+
|
|
257
|
+
def extract_files(self, *filenames):
|
|
258
|
+
"""Extract given files from the archive to disk."""
|
|
259
|
+
for filename in filenames:
|
|
260
|
+
data = self.read_file(filename)
|
|
261
|
+
f = open(filename, 'wb')
|
|
262
|
+
f.write(data)
|
|
263
|
+
f.close()
|
|
264
|
+
|
|
265
|
+
def print_headers(self):
|
|
266
|
+
print "MPQ archive header"
|
|
267
|
+
print "------------------"
|
|
268
|
+
for key, value in self.header.iteritems():
|
|
269
|
+
if key == "user_data_header":
|
|
270
|
+
continue
|
|
271
|
+
print "{0:30} {1!r}".format(key, value)
|
|
272
|
+
if self.header.get('user_data_header'):
|
|
273
|
+
print
|
|
274
|
+
print "MPQ user data header"
|
|
275
|
+
print "--------------------"
|
|
276
|
+
for key, value in self.header['user_data_header'].iteritems():
|
|
277
|
+
print "{0:30} {1!r}".format(key, value)
|
|
278
|
+
print
|
|
279
|
+
|
|
280
|
+
def print_hash_table(self):
|
|
281
|
+
print "MPQ archive hash table"
|
|
282
|
+
print "----------------------"
|
|
283
|
+
print " Hash A Hash B Locl Plat BlockIdx"
|
|
284
|
+
for entry in self.hash_table:
|
|
285
|
+
print '%08X %08X %04X %04X %08X' % entry
|
|
286
|
+
print
|
|
287
|
+
|
|
288
|
+
def print_block_table(self):
|
|
289
|
+
print "MPQ archive block table"
|
|
290
|
+
print "-----------------------"
|
|
291
|
+
print " Offset ArchSize RealSize Flags"
|
|
292
|
+
for entry in self.block_table:
|
|
293
|
+
print '%08X %8d %8d %8X' % entry
|
|
294
|
+
print
|
|
295
|
+
|
|
296
|
+
def print_files(self):
|
|
297
|
+
if self.files:
|
|
298
|
+
print "Files"
|
|
299
|
+
print "-----"
|
|
300
|
+
width = max(len(name) for name in self.files) + 2
|
|
301
|
+
for filename in self.files:
|
|
302
|
+
hash_entry = self.get_hash_table_entry(filename)
|
|
303
|
+
block_entry = self.block_table[hash_entry.block_table_index]
|
|
304
|
+
print "{0:{width}} {1:>8} bytes".format(filename,
|
|
305
|
+
block_entry.size,
|
|
306
|
+
width=width)
|
|
307
|
+
|
|
308
|
+
def _hash(self, string, hash_type):
|
|
309
|
+
"""Hash a string using MPQ's hash function."""
|
|
310
|
+
hash_types = {
|
|
311
|
+
'TABLE_OFFSET': 0,
|
|
312
|
+
'HASH_A': 1,
|
|
313
|
+
'HASH_B': 2,
|
|
314
|
+
'TABLE': 3
|
|
315
|
+
}
|
|
316
|
+
seed1 = 0x7FED7FED
|
|
317
|
+
seed2 = 0xEEEEEEEE
|
|
318
|
+
|
|
319
|
+
for ch in string:
|
|
320
|
+
ch = ord(ch.upper())
|
|
321
|
+
value = self.encryption_table[(hash_types[hash_type] << 8) + ch]
|
|
322
|
+
seed1 = (value ^ (seed1 + seed2)) & 0xFFFFFFFF
|
|
323
|
+
seed2 = ch + seed1 + seed2 + (seed2 << 5) + 3 & 0xFFFFFFFF
|
|
324
|
+
|
|
325
|
+
return seed1
|
|
326
|
+
|
|
327
|
+
def _decrypt(self, data, key):
|
|
328
|
+
"""Decrypt hash or block table or a sector."""
|
|
329
|
+
seed1 = key
|
|
330
|
+
seed2 = 0xEEEEEEEE
|
|
331
|
+
result = cStringIO.StringIO()
|
|
332
|
+
|
|
333
|
+
for i in range(len(data) // 4):
|
|
334
|
+
seed2 += self.encryption_table[0x400 + (seed1 & 0xFF)]
|
|
335
|
+
seed2 &= 0xFFFFFFFF
|
|
336
|
+
value = struct.unpack("<I", data[i*4:i*4+4])[0]
|
|
337
|
+
value = (value ^ (seed1 + seed2)) & 0xFFFFFFFF
|
|
338
|
+
|
|
339
|
+
seed1 = ((~seed1 << 0x15) + 0x11111111) | (seed1 >> 0x0B)
|
|
340
|
+
seed1 &= 0xFFFFFFFF
|
|
341
|
+
seed2 = value + seed2 + (seed2 << 5) + 3 & 0xFFFFFFFF
|
|
342
|
+
|
|
343
|
+
result.write(struct.pack("<I", value))
|
|
344
|
+
|
|
345
|
+
return result.getvalue()
|
|
346
|
+
|
|
347
|
+
def _prepare_encryption_table():
|
|
348
|
+
"""Prepare encryption table for MPQ hash function."""
|
|
349
|
+
seed = 0x00100001
|
|
350
|
+
crypt_table = {}
|
|
351
|
+
|
|
352
|
+
for i in range(256):
|
|
353
|
+
index = i
|
|
354
|
+
for j in range(5):
|
|
355
|
+
seed = (seed * 125 + 3) % 0x2AAAAB
|
|
356
|
+
temp1 = (seed & 0xFFFF) << 0x10
|
|
357
|
+
|
|
358
|
+
seed = (seed * 125 + 3) % 0x2AAAAB
|
|
359
|
+
temp2 = (seed & 0xFFFF)
|
|
360
|
+
|
|
361
|
+
crypt_table[index] = (temp1 | temp2)
|
|
362
|
+
|
|
363
|
+
index += 0x100
|
|
364
|
+
|
|
365
|
+
return crypt_table
|
|
366
|
+
|
|
367
|
+
encryption_table = _prepare_encryption_table()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main():
|
|
371
|
+
import argparse
|
|
372
|
+
description = "mpyq reads and extracts MPQ archives."
|
|
373
|
+
parser = argparse.ArgumentParser(description=description)
|
|
374
|
+
parser.add_argument("file", action="store", help="path to the archive")
|
|
375
|
+
parser.add_argument("-I", "--headers", action="store_true", dest="headers",
|
|
376
|
+
help="print header information from the archive")
|
|
377
|
+
parser.add_argument("-H", "--hash-table", action="store_true",
|
|
378
|
+
dest="hash_table", help="print hash table"),
|
|
379
|
+
parser.add_argument("-b", "--block-table", action="store_true",
|
|
380
|
+
dest="block_table", help="print block table"),
|
|
381
|
+
parser.add_argument("-s", "--skip-listfile", action="store_true",
|
|
382
|
+
dest="skip_listfile", help="skip reading (listfile)"),
|
|
383
|
+
parser.add_argument("-t", "--list-files", action="store_true", dest="list",
|
|
384
|
+
help="list files inside the archive")
|
|
385
|
+
parser.add_argument("-x", "--extract", action="store_true", dest="extract",
|
|
386
|
+
help="extract files from the archive")
|
|
387
|
+
args = parser.parse_args()
|
|
388
|
+
if args.file:
|
|
389
|
+
if not args.skip_listfile:
|
|
390
|
+
archive = MPQArchive(args.file)
|
|
391
|
+
else:
|
|
392
|
+
archive = MPQArchive(args.file, listfile=False)
|
|
393
|
+
if args.headers:
|
|
394
|
+
archive.print_headers()
|
|
395
|
+
if args.hash_table:
|
|
396
|
+
archive.print_hash_table()
|
|
397
|
+
if args.block_table:
|
|
398
|
+
archive.print_block_table()
|
|
399
|
+
if args.list:
|
|
400
|
+
archive.print_files()
|
|
401
|
+
if args.extract:
|
|
402
|
+
archive.extract_to_disk()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == '__main__':
|
|
406
|
+
main()
|