mpq 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.2.0
@@ -0,0 +1,75 @@
1
+ # MPQ files store some data in a serialized format that's strikingly similar to
2
+ # JSON, so I've called it **JSONish**. It's a fairly simple format, documented
3
+ # at http://github.com/GraylinKim/sc2reader/wiki/serialized.data and at
4
+ # http://teamliquid.net/forum/viewmessage.php?topic_id=117260&currentpage=3#45.
5
+ module MPQ
6
+ module JSONish
7
+
8
+ # `parse` is the public API here, the following two methods are simply
9
+ # helpers.
10
+ def self.parse data
11
+ self.parse_recur String.new data
12
+ end
13
+
14
+ # JSONish consists of strings, arrays, maps, and integers. The first byte
15
+ # of each of these indicates which is about to follow.
16
+ def self.parse_recur data
17
+ case data.slice!(0).bytes.next
18
+
19
+ # `02` indicates a string. The next byte is a variable-length integer
20
+ # (see below) indicating the string's length, and the remaining bytes are
21
+ # the string itself.
22
+ when 2
23
+ data.slice! 0, vlf(data)
24
+
25
+ # `04` is an array, a list of values. Each value indicates its type, so
26
+ # this is largely just a recursive process.
27
+ when 4
28
+ data.slice! 0, 2
29
+ (0...vlf(data)).map {|i| parse_recur data }
30
+
31
+ # `05` starts a map, also known as a Hash or an object literal. It maps
32
+ # keys to values. In JSONish, keys are always variable-length integers,
33
+ # while values can be anything.
34
+ when 5
35
+ Hash.[]((0...vlf(data)).map do |i|
36
+ [vlf(data), parse_recur(data)]
37
+ end)
38
+
39
+ # `06` is a single-byte integer.
40
+ when 6
41
+ data.slice! 0
42
+
43
+ # `07` is a four-byte integer in little-endian format.
44
+ when 7
45
+ data.slice!(0, 4).unpack("V")[0]
46
+
47
+ # `09` is a standalone (i.e. not a key or length) variable-length integer.
48
+ when 9
49
+ vlf data
50
+
51
+ # If there are other types in JSONish, we don't know about them.
52
+ else
53
+ nil
54
+ end
55
+ end
56
+
57
+ # A variable-length integer is a concise serialization of an
58
+ # arbitrary-precision integer. Each byte (except the last) sets the high bit
59
+ # to `1` to indicate that the next byte is included in the integer. Seven
60
+ # bits of each byte, plus all eight bits of the final byte, make up the
61
+ # final number in little-endian format.
62
+ def self.vlf data
63
+ ret, shift = 0, 0
64
+ loop do
65
+ char = data.slice!(0)
66
+ return nil unless char
67
+ byte = char.bytes.next
68
+ ret += (byte & 0x7F) << (7 * shift)
69
+ break if byte & 0x80 == 0
70
+ shift += 1
71
+ end
72
+ (ret >> 1) * ((ret & 0x1) == 0 ? 1 : -1)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,205 @@
1
+ # StarCraft 2 replay files are MPQ archives, with some data serialized as what
2
+ # I'm calling *JSONish*. We also use `bindata` as a DSL for some of the file
3
+ # contents.
4
+ require 'bindata'
5
+ require 'mpq'
6
+ require 'jsonish'
7
+
8
+ module MPQ
9
+ class SC2ReplayFile < Archive
10
+
11
+ # Game length is given as the number of frames.
12
+ def game_length
13
+ @game_length ||= user_data[3] / FRAMES_PER_SECOND
14
+ end
15
+
16
+ # These are the numbers you see in the bottom left of the game's menu. Use
17
+ # the `build` to change features, etc. based on game version. Use the rest
18
+ # when presenting a version to a person.
19
+ def game_version
20
+ @game_version ||= {
21
+ :major => user_data[1][1],
22
+ :minor => user_data[1][2],
23
+ :patch => user_data[1][3],
24
+ :build => user_data[1][4]
25
+ }
26
+ end
27
+
28
+ # Player information is spread among a couple of files: the
29
+ # `replay.details` file and the `replay.attributes.events` file. Here we
30
+ # combine the information contained in each.
31
+ def players
32
+ return @players if defined? @players
33
+ @players = details[0].map do |player|
34
+ { :name => player[0],
35
+
36
+ # This could probably be 'unknown' in some circumstances I haven't
37
+ # yet checked.
38
+ :outcome => OUTCOMES[player[8]]
39
+ }
40
+ end
41
+
42
+ # Unlike the `replay.initData` file, this method of determining race is
43
+ # the same across all localizations.
44
+ attributes.each do |attr|
45
+ case attr.id.to_i
46
+ when 0x01f4
47
+ @players[attr.player - 1][:type] = ATTRIBUTES[:player_type][attr.sval]
48
+ when 0x0bb9
49
+ @players[attr.player - 1][:race] = ATTRIBUTES[:player_race][attr.sval]
50
+ when 0x0bba
51
+ @players[attr.player - 1][:color] =
52
+ ATTRIBUTES[:player_color][attr.sval]
53
+ end
54
+ end
55
+ @players
56
+ end
57
+
58
+ # The localized map name. Should probably translate it.
59
+ def map_name
60
+ details[1]
61
+ end
62
+
63
+ # The start date of the game, probably off by a time zone or ten.
64
+ def start_date
65
+ =begin
66
+ FIXME: parse this as UTC (possibly requires use of time zone offset
67
+ present in details).
68
+ =end
69
+ Time.at((details[5] - 116444735995904000) / 1e7)
70
+ end
71
+
72
+ # Two-uppercase-character abbreviation, like `NA`.
73
+ def realm
74
+ initdata.rest.split('s2ma')[1][2, 2]
75
+ end
76
+
77
+ # Each of these getters are for information contained in the
78
+ # `replay.attributes.events` file, but their exact position cannot be
79
+ # assumed from file to file, so we might as well extract them all when
80
+ # asked for any one of them.
81
+ %w[game_type game_speed category].each do |lazy_getter|
82
+ class_eval <<-EVAL
83
+ def #{lazy_getter}
84
+ parse_global_attributes unless defined? @#{lazy_getter}
85
+ @#{lazy_getter}
86
+ end
87
+ EVAL
88
+ end
89
+
90
+ # Wrappers for deserializing some JSONish.
91
+ private
92
+ def user_data
93
+ @user_data ||= JSONish.parse @user_header.user_data
94
+ end
95
+
96
+ def details
97
+ @details ||= JSONish.parse read_file "replay.details"
98
+ end
99
+
100
+ # `replay.initData` has some useful information, but almost all of it can
101
+ # be more reliably obtained elsewhere. All that's really useful here is the
102
+ # realm the game was played in.
103
+ def initdata
104
+ @initdata ||= InitData.read read_file "replay.initData"
105
+ end
106
+
107
+ class InitData < BinData::Record
108
+ uint8 :num_players
109
+ array :players, :initial_length => :num_players do
110
+ uint8 :player_name_length
111
+ string :player_name, :length => :player_name_length
112
+ skip :length => 5
113
+ end
114
+ string :unknown_24, :length => 24
115
+ uint8 :account_length
116
+ string :account, :length => :account_length
117
+ rest :rest
118
+ end
119
+
120
+ # `replay.attributes.events` has plenty of handy information. Here we
121
+ # simply deserialize all the attributes, taking into account a format
122
+ # change that took place in build 17326, for later processing.
123
+ def attributes
124
+ return @attributes if defined? @attributes
125
+ data = read_file "replay.attributes.events"
126
+ data.slice! 0, (game_version[:build] < 17326 ? 4 : 5)
127
+ @attributes = []
128
+ data.slice!(0, 4).unpack("V")[0].times do
129
+ @attributes << Attribute.read(data.slice!(0, 13))
130
+ end
131
+ @attributes
132
+ end
133
+
134
+ class Attribute < BinData::Record
135
+ endian :little
136
+
137
+ string :header, :length => 4
138
+ uint32 :id
139
+ uint8 :player
140
+ string :val, :length => 4
141
+
142
+ def sval
143
+ val.reverse
144
+ end
145
+ end
146
+
147
+ # Several pieces of information come from `replay.attributes.events`, and
148
+ # finding one of them is about as hard as finding all of them, so we just
149
+ # find all of them here when asked.
150
+ def parse_global_attributes
151
+ attributes.each do |attr|
152
+ case attr.id.to_i
153
+ when 0x07d1
154
+ @game_type = attr.sval
155
+ @game_type = @game_type == 'Cust' ? :custom : @game_type[1, 3].to_sym
156
+ when 0x0bb8
157
+ @game_speed = ATTRIBUTES[:game_speed][attr.sval]
158
+ when 0x0bc1
159
+ @category = ATTRIBUTES[:category][attr.sval]
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ # Some translations for various values in replays.
167
+ FRAMES_PER_SECOND = 16
168
+ OUTCOMES = [:unknown, :win, :loss]
169
+ ATTRIBUTES = {
170
+ :player_type => {"Humn" => :human, "Comp" => :computer},
171
+ :player_race => {
172
+ "RAND" => :random, "Terr" => :terran, "Prot" => :protoss, "Zerg" => :zerg
173
+ },
174
+ :player_color => {
175
+ "tc01" => :red,
176
+ "tc02" => :blue,
177
+ "tc03" => :teal,
178
+ "tc04" => :purple,
179
+ "tc05" => :yellow,
180
+ "tc06" => :orange,
181
+ "tc07" => :green,
182
+ "tc08" => :light_pink,
183
+ "tc09" => :violet,
184
+ "tc10" => :light_grey,
185
+ "tc11" => :dark_green,
186
+ "tc12" => :brown,
187
+ "tc13" => :light_green,
188
+ "tc14" => :dark_grey,
189
+ "tc15" => :pink
190
+ },
191
+ :game_speed => {
192
+ "Slor" => :slower,
193
+ "Slow" => :slow,
194
+ "Norm" => :normal,
195
+ "Fast" => :fast,
196
+ "Fasr" => :faster
197
+ },
198
+ :category => {
199
+ "Priv" => :private,
200
+
201
+ # `Amm` is assumed to stand for "automatic matchmaker".
202
+ "Amm" => :ladder,
203
+ "Pub" => :public
204
+ }
205
+ }
@@ -0,0 +1,78 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{mpq}
8
+ s.version = "0.2.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Nolan Waite"]
12
+ s.date = %q{2011-06-16}
13
+ s.description = %q{Read files and metadata from MPQ archives}
14
+ s.email = %q{nolan@nolanw.ca}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE.txt",
17
+ "README.md"
18
+ ]
19
+ s.files = [
20
+ "Gemfile",
21
+ "Gemfile.lock",
22
+ "LICENSE.txt",
23
+ "README.md",
24
+ "Rakefile",
25
+ "VERSION",
26
+ "lib/jsonish.rb",
27
+ "lib/mpq.rb",
28
+ "lib/replay_file.rb",
29
+ "mpq.gemspec",
30
+ "test/helper.rb",
31
+ "test/some.SC2Replay",
32
+ "test/test_jsonish.rb",
33
+ "test/test_mpq.rb",
34
+ "test/test_replay_file.rb"
35
+ ]
36
+ s.homepage = %q{http://github.com/nolanw/mpq}
37
+ s.licenses = ["WTFPL"]
38
+ s.require_paths = ["lib"]
39
+ s.rubygems_version = %q{1.7.2}
40
+ s.summary = %q{Read files and metadata from MPQ archives}
41
+ s.test_files = [
42
+ "test/helper.rb",
43
+ "test/test_jsonish.rb",
44
+ "test/test_mpq.rb",
45
+ "test/test_replay_file.rb"
46
+ ]
47
+
48
+ if s.respond_to? :specification_version then
49
+ s.specification_version = 3
50
+
51
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
52
+ s.add_runtime_dependency(%q<bindata>, ["~> 1.3.1"])
53
+ s.add_runtime_dependency(%q<bzip2-ruby>, ["~> 0.2.7"])
54
+ s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
55
+ s.add_development_dependency(%q<jeweler>, ["~> 1.5.2"])
56
+ s.add_development_dependency(%q<rocco>, ["~> 0.6"])
57
+ s.add_runtime_dependency(%q<bindata>, [">= 1.3.1"])
58
+ s.add_development_dependency(%q<rocco>, [">= 0.6"])
59
+ else
60
+ s.add_dependency(%q<bindata>, ["~> 1.3.1"])
61
+ s.add_dependency(%q<bzip2-ruby>, ["~> 0.2.7"])
62
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
63
+ s.add_dependency(%q<jeweler>, ["~> 1.5.2"])
64
+ s.add_dependency(%q<rocco>, ["~> 0.6"])
65
+ s.add_dependency(%q<bindata>, [">= 1.3.1"])
66
+ s.add_dependency(%q<rocco>, [">= 0.6"])
67
+ end
68
+ else
69
+ s.add_dependency(%q<bindata>, ["~> 1.3.1"])
70
+ s.add_dependency(%q<bzip2-ruby>, ["~> 0.2.7"])
71
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
72
+ s.add_dependency(%q<jeweler>, ["~> 1.5.2"])
73
+ s.add_dependency(%q<rocco>, ["~> 0.6"])
74
+ s.add_dependency(%q<bindata>, [">= 1.3.1"])
75
+ s.add_dependency(%q<rocco>, [">= 0.6"])
76
+ end
77
+ end
78
+
@@ -0,0 +1,40 @@
1
+ $:.unshift File.expand_path(File.join File.dirname(__FILE__), '..', 'src')
2
+ require "jsonish"
3
+ require "test/unit"
4
+
5
+ class TestJSONish < Test::Unit::TestCase
6
+ def test_vlf
7
+ assert_equal 0, MPQ::JSONish.vlf("\x00")
8
+ assert_equal 0, MPQ::JSONish.vlf("\x01")
9
+ assert_equal 1, MPQ::JSONish.vlf("\x02")
10
+ assert_equal -1, MPQ::JSONish.vlf("\x03")
11
+ assert_equal 2, MPQ::JSONish.vlf("\x04")
12
+ assert_equal 63, MPQ::JSONish.vlf("\x7e")
13
+ assert_equal -63, MPQ::JSONish.vlf("\x7f")
14
+ assert_equal nil, MPQ::JSONish.vlf("\x80")
15
+ assert_equal 0, MPQ::JSONish.vlf("\x80\x00")
16
+ assert_equal 64, MPQ::JSONish.vlf("\x80\x01")
17
+ assert_equal -64, MPQ::JSONish.vlf("\x81\x01")
18
+ assert_equal 65, MPQ::JSONish.vlf("\x82\x01")
19
+ assert_equal 128, MPQ::JSONish.vlf("\x80\x02")
20
+ assert_equal -128, MPQ::JSONish.vlf("\x81\x02")
21
+ assert_equal 18092, MPQ::JSONish.vlf("\xd8\x9a\x02")
22
+ assert_equal 18317, MPQ::JSONish.vlf("\x9a\x9e\x02")
23
+ assert_equal 16777216, MPQ::JSONish.vlf("\x80\x80\x80\x10")
24
+ end
25
+
26
+ def test_parse
27
+ assert_equal "hi", MPQ::JSONish.parse("\x02\x04\x68\x69")
28
+ assert_equal "Pille", MPQ::JSONish.parse("\x02\x0a\x50\x69\x6c\x6c\x65")
29
+ assert_equal(["Pille", "\x2a", "\xa6", "\x8d"],
30
+ MPQ::JSONish.parse("\x04\x00\x01\x08\x02\x0A\x50\x69\x6C\x6C\x65" +
31
+ "\x06\x2A\x06\xA6\x06\x8D"))
32
+ assert_equal({0 => "hi"},
33
+ MPQ::JSONish.parse("\x05\x02\x00\x02\x04\x68\x69"))
34
+ assert_equal({0 => "hi", 1 => "hi"},
35
+ MPQ::JSONish.parse("\x05\x04\x00\x02\x04\x68\x69\x02\x02\x04\x68\x69"))
36
+ assert_equal({0 => 1, 1 => 2, 4 => 3},
37
+ MPQ::JSONish.parse("\x05\x06\x00\x09\x02\x02\x09\x04\x08\x09\x06"))
38
+ assert_equal 76, MPQ::JSONish.parse("\x06\x4C").ord
39
+ end
40
+ end
@@ -0,0 +1,59 @@
1
+ require 'replay_file'
2
+ require 'test/unit'
3
+ require 'time'
4
+
5
+ class TestReplayFile < Test::Unit::TestCase
6
+ def setup
7
+ @file = File.new File.join(File.dirname(__FILE__), "some.SC2Replay")
8
+ @replay = MPQ::SC2ReplayFile.new @file
9
+ end
10
+
11
+ def teardown
12
+ @file.close
13
+ end
14
+
15
+ def test_game_version
16
+ assert_equal 1, @replay.game_version[:major]
17
+ assert_equal 3, @replay.game_version[:minor]
18
+ assert_equal 18317, @replay.game_version[:build]
19
+ end
20
+
21
+ def test_game_length
22
+ assert_equal 1260, @replay.game_length
23
+ end
24
+
25
+ def test_players
26
+ names = @replay.players.map{|p| p[:name]}
27
+ assert names.include? "ESCGoOdy"
28
+ goody = @replay.players[names.index "ESCGoOdy"]
29
+ assert_equal :terran, goody[:race]
30
+ assert_equal :purple, goody[:color]
31
+ assert_equal :human, goody[:type]
32
+ end
33
+
34
+ def test_map_name
35
+ assert_equal "The Shattered Temple", @replay.map_name
36
+ end
37
+
38
+ def test_start_date
39
+ # FIXME: should be parsed as UTC, and this test should reflect that.
40
+ assert (Time.parse("2011-04-24 10:09:18 -0600") -
41
+ @replay.start_date).abs < 1
42
+ end
43
+
44
+ def test_realm
45
+ assert_equal "EU", @replay.realm
46
+ end
47
+
48
+ def test_game_type
49
+ assert_equal :"1v1", @replay.game_type
50
+ end
51
+
52
+ def test_game_speed
53
+ assert_equal :faster, @replay.game_speed
54
+ end
55
+
56
+ def test_category
57
+ assert_equal :private, @replay.category
58
+ end
59
+ end
metadata CHANGED
@@ -2,7 +2,7 @@
2
2
  name: mpq
3
3
  version: !ruby/object:Gem::Version
4
4
  prerelease:
5
- version: 0.1.0
5
+ version: 0.2.0
6
6
  platform: ruby
7
7
  authors:
8
8
  - Nolan Waite
@@ -10,7 +10,7 @@ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
12
 
13
- date: 2011-05-01 00:00:00 Z
13
+ date: 2011-06-16 00:00:00 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: bindata
@@ -105,10 +105,15 @@ files:
105
105
  - README.md
106
106
  - Rakefile
107
107
  - VERSION
108
+ - lib/jsonish.rb
108
109
  - lib/mpq.rb
110
+ - lib/replay_file.rb
111
+ - mpq.gemspec
109
112
  - test/helper.rb
110
113
  - test/some.SC2Replay
114
+ - test/test_jsonish.rb
111
115
  - test/test_mpq.rb
116
+ - test/test_replay_file.rb
112
117
  homepage: http://github.com/nolanw/mpq
113
118
  licenses:
114
119
  - WTFPL
@@ -122,7 +127,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
122
127
  requirements:
123
128
  - - ">="
124
129
  - !ruby/object:Gem::Version
125
- hash: -4446640813622028280
130
+ hash: -1950555829179380990
126
131
  segments:
127
132
  - 0
128
133
  version: "0"
@@ -141,4 +146,6 @@ specification_version: 3
141
146
  summary: Read files and metadata from MPQ archives
142
147
  test_files:
143
148
  - test/helper.rb
149
+ - test/test_jsonish.rb
144
150
  - test/test_mpq.rb
151
+ - test/test_replay_file.rb