mpq 0.1.0 → 0.2.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.
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