mix1 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f9662823ec31f7243a3a0ba294d4a6fe12fc5408258008c0237af5dc7fe00b5b
4
+ data.tar.gz: b9f2a3c9bff699e42937b757681d772d9a51f310542ec72dee09aed7147bd401
5
+ SHA512:
6
+ metadata.gz: 01fece90e7ee3fc44a81554288332ed51236ebeba48f5d00ed55f8fe556dad89d783e3fbd54519a580d0b2f37a76554bf3345aff3554476b4bfa4185a43edffb
7
+ data.tar.gz: 11e29f7724b96e869878eff617866bb9ed363ebe9219067b6b87cb38bc8c9a609921d5593a59be73a3b140f8b01311331851a1a237b4fb4c3a38b18da89c0ac5
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 2.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-03-13
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in mix1.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "standard", "~> 1.3"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 cyberarm
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.
data/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # MIX1
2
+
3
+ A gem for working with Westwood's MIX1 format (.mix) used on Command & Conquer: Renegade and other games.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ $ bundle add mix1
10
+
11
+ If bundler is not being used to manage dependencies, install the gem by executing:
12
+
13
+ $ gem install mix1
14
+
15
+ ## Usage
16
+
17
+ TODO: Write usage instructions here
18
+
19
+ ## Development
20
+
21
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
22
+
23
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
24
+
25
+ ## Contributing
26
+
27
+ Bug reports and pull requests are welcome on GitHub at https://github.com/cyberarm/mix1.
28
+
29
+ ## License
30
+
31
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "standard/rake"
5
+
6
+ task default: :standard
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mix1
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mix1.rb ADDED
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "stringio"
5
+ require_relative "mix1/version"
6
+
7
+ # https://github.com/TheUnstoppable/MixLibrary used for reference
8
+ class Mix1
9
+ DEFAULT_BUFFER_SIZE = 32_000_000
10
+
11
+ class MixParserException < RuntimeError; end
12
+
13
+ class MixFormatException < RuntimeError; end
14
+
15
+ class MemoryBuffer
16
+ def initialize(file_path:, mode:, buffer_size:, encoding: Encoding::ASCII_8BIT)
17
+ @mode = mode
18
+
19
+ @file = File.open(file_path, (mode == :read) ? "rb" : "wb")
20
+ @file.pos = 0
21
+ @file_size = File.size(file_path)
22
+
23
+ @buffer_size = buffer_size
24
+ @chunk = 0
25
+ @last_chunk = 0
26
+ @max_chunks = @file_size / @buffer_size
27
+ @last_cached_chunk = nil
28
+
29
+ @encoding = encoding
30
+
31
+ @last_buffer_pos = 0
32
+ @buffer = (@mode == :read) ? StringIO.new(@file.read(@buffer_size)) : StringIO.new
33
+ @buffer.set_encoding(encoding)
34
+
35
+ # Cache frequently accessed chunks to reduce disk hits
36
+ @cache = {}
37
+ end
38
+
39
+ def pos
40
+ @chunk * @buffer_size + @buffer.pos
41
+ end
42
+
43
+ def pos=(offset)
44
+ last_chunk = @chunk
45
+ @chunk = offset / @buffer_size
46
+
47
+ raise "No backsies! #{offset} (#{@chunk}/#{last_chunk})" if @mode == :write && @chunk < last_chunk
48
+
49
+ fetch_chunk(@chunk) if @mode == :read
50
+
51
+ @buffer.pos = offset % @buffer_size
52
+ end
53
+
54
+ # string of bytes
55
+ def write(bytes)
56
+ length = bytes.length
57
+
58
+ # Crossing buffer boundry
59
+ if @buffer.pos + length > @buffer_size
60
+
61
+ edge_size = @buffer_size - @buffer.pos
62
+ buffer_edge = bytes[0...edge_size]
63
+
64
+ bytes_to_write = bytes.length - buffer_edge.length
65
+ chunks_to_write = (bytes_to_write / @buffer_size.to_f).ceil
66
+ bytes_written = buffer_edge.length
67
+
68
+ @buffer.write(buffer_edge)
69
+ flush_chunk
70
+
71
+ chunks_to_write.times do |i|
72
+ i += 1
73
+
74
+ @buffer.write(bytes[bytes_written...bytes_written + @buffer_size])
75
+ bytes_written += @buffer_size
76
+
77
+ flush_chunk if string.length == @buffer_size
78
+ end
79
+ else
80
+ @buffer.write(bytes)
81
+ end
82
+
83
+ bytes
84
+ end
85
+
86
+ def write_header(data_offset:, name_offset:)
87
+ flush_chunk
88
+
89
+ @file.pos = 4
90
+ write_i32(data_offset)
91
+ write_i32(name_offset)
92
+
93
+ @file.pos = 0
94
+ end
95
+
96
+ def write_i32(int)
97
+ @file.write([int].pack("l"))
98
+ end
99
+
100
+ def read(bytes = nil)
101
+ raise ArgumentError, "Cannot read whole file" if bytes.nil?
102
+ raise ArgumentError, "Cannot under read buffer" if bytes.negative?
103
+
104
+ # Long read, need to fetch next chunk while reading, mostly defeats this class...?
105
+ if @buffer.pos + bytes > buffered
106
+ buff = string[@buffer.pos..buffered]
107
+
108
+ bytes_to_read = bytes - buff.length
109
+ chunks_to_read = (bytes_to_read / @buffer_size.to_f).ceil
110
+
111
+ chunks_to_read.times do |i|
112
+ i += 1
113
+
114
+ fetch_chunk(@chunk + 1)
115
+
116
+ if i == chunks_to_read # read partial
117
+ already_read_bytes = (chunks_to_read - 1) * @buffer_size
118
+ bytes_more_to_read = bytes_to_read - already_read_bytes
119
+
120
+ buff << @buffer.read(bytes_more_to_read)
121
+ else
122
+ buff << @buffer.read
123
+ end
124
+ end
125
+
126
+ buff
127
+ else
128
+ fetch_chunk(@chunk) if @last_chunk != @chunk
129
+
130
+ @buffer.read(bytes)
131
+ end
132
+ end
133
+
134
+ def readbyte
135
+ fetch_chunk(@chunk + 1) if @buffer.pos + 1 > buffered
136
+
137
+ @buffer.readbyte
138
+ end
139
+
140
+ def fetch_chunk(chunk)
141
+ raise ArgumentError, "Cannot fetch chunk #{chunk}, only #{@max_chunks} exist!" if chunk > @max_chunks
142
+ @last_chunk = @chunk
143
+ @chunk = chunk
144
+ @last_buffer_pos = @buffer.pos
145
+
146
+ cached = @cache[chunk]
147
+
148
+ if cached
149
+ @buffer.string = cached
150
+ else
151
+ @file.pos = chunk * @buffer_size
152
+ buff = @buffer.string = @file.read(@buffer_size)
153
+
154
+ # Cache the active chunk (implementation bounces from @file_data_chunk and back to this for each 'file' processed)
155
+ if @chunk != @file_data_chunk && @chunk != @last_cached_chunk
156
+ @cache.delete(@last_cached_chunk) unless @last_cached_chunk == @file_data_chunk
157
+ @cache[@chunk] = buff
158
+ @last_cached_chunk = @chunk
159
+ end
160
+
161
+ buff
162
+ end
163
+ end
164
+
165
+ # This is accessed quite often, keep it around
166
+ def cache_file_data_chunk!
167
+ @file_data_chunk = @chunk
168
+
169
+ last_buffer_pos = @buffer.pos
170
+ @buffer.pos = 0
171
+ @cache[@chunk] = @buffer.read
172
+ @buffer.pos = last_buffer_pos
173
+ end
174
+
175
+ def flush_chunk
176
+ @last_chunk = @chunk
177
+ @chunk += 1
178
+
179
+ @file.pos = @last_chunk * @buffer_size
180
+ @file.write(string)
181
+
182
+ @buffer.string = ""
183
+ end
184
+
185
+ def string
186
+ @buffer.string
187
+ end
188
+
189
+ def buffered
190
+ @buffer.string.length
191
+ end
192
+
193
+ def close
194
+ @file&.close
195
+ end
196
+ end
197
+
198
+ class Reader
199
+ attr_reader :package
200
+
201
+ def initialize(file_path:, ignore_crc_mismatches: false, metadata_only: false, buffer_size: DEFAULT_BUFFER_SIZE)
202
+ @package = Package.new
203
+
204
+ @buffer = MemoryBuffer.new(file_path: file_path, mode: :read, buffer_size: buffer_size)
205
+
206
+ @buffer.pos = 0
207
+
208
+ # Valid header
209
+ if read_i32 == 0x3158494D
210
+ file_data_offset = read_i32
211
+ file_names_offset = read_i32
212
+
213
+ @buffer.pos = file_names_offset
214
+ file_count = read_i32
215
+
216
+ file_count.times do
217
+ @package.files << Package::File.new(name: read_string)
218
+ end
219
+
220
+ @buffer.pos = file_data_offset
221
+ @buffer.cache_file_data_chunk!
222
+
223
+ _file_count = read_i32
224
+
225
+ file_count.times do |i|
226
+ file = @package.files[i]
227
+
228
+ file.mix_crc = read_u32.to_s(16).rjust(8, "0")
229
+ file.content_offset = read_u32
230
+ file.content_length = read_u32
231
+
232
+ if !ignore_crc_mismatches && file.mix_crc != file.file_crc
233
+ raise MixParserException, "CRC mismatch for #{file.name}. #{file.mix_crc.inspect} != #{file.file_crc.inspect}"
234
+ end
235
+
236
+ pos = @buffer.pos
237
+ @buffer.pos = file.content_offset
238
+ file.data = @buffer.read(file.content_length) unless metadata_only
239
+ @buffer.pos = pos
240
+ end
241
+ else
242
+ raise MixParserException, "Invalid MIX file"
243
+ end
244
+ ensure
245
+ @buffer&.close
246
+ @buffer = nil # let GC collect
247
+ end
248
+
249
+ def read_i32
250
+ @buffer.read(4).unpack1("l")
251
+ end
252
+
253
+ def read_u32
254
+ @buffer.read(4).unpack1("L")
255
+ end
256
+
257
+ def read_string
258
+ buffer = ""
259
+
260
+ length = @buffer.readbyte
261
+
262
+ length.times do
263
+ buffer << @buffer.readbyte
264
+ end
265
+
266
+ buffer.strip
267
+ end
268
+ end
269
+
270
+ class Writer
271
+ attr_reader :package
272
+
273
+ def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE)
274
+ @package = package
275
+
276
+ @buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size)
277
+ @buffer.pos = 0
278
+
279
+ @buffer.write("MIX1")
280
+
281
+ files = @package.files.sort_by(&:file_crc)
282
+
283
+ @buffer.pos = 16
284
+
285
+ files.each do |file|
286
+ file.content_offset = @buffer.pos
287
+ file.content_length = file.data.length
288
+ @buffer.write(file.data)
289
+
290
+ @buffer.pos += -@buffer.pos & 7
291
+ end
292
+
293
+ file_data_offset = @buffer.pos
294
+ write_i32(files.count)
295
+
296
+ files.each do |file|
297
+ write_u32(file.file_crc.to_i(16))
298
+ write_u32(file.content_offset)
299
+ write_u32(file.content_length)
300
+ end
301
+
302
+ file_name_offset = @buffer.pos
303
+ write_i32(files.count)
304
+
305
+ files.each do |file|
306
+ write_byte(file.name.length + 1)
307
+ @buffer.write("#{file.name}\0")
308
+ end
309
+
310
+ @buffer.write_header(data_offset: file_data_offset, name_offset: file_name_offset)
311
+ ensure
312
+ @buffer&.close
313
+ end
314
+
315
+ def write_i32(int)
316
+ @buffer.write([int].pack("l"))
317
+ end
318
+
319
+ def write_u32(uint)
320
+ @buffer.write([uint].pack("L"))
321
+ end
322
+
323
+ def write_byte(byte)
324
+ @buffer.write([byte].pack("c"))
325
+ end
326
+ end
327
+
328
+ class Package
329
+ attr_reader :files
330
+
331
+ def initialize(files: [])
332
+ @files = files
333
+ end
334
+
335
+ class File
336
+ attr_accessor :name, :mix_crc, :content_offset, :content_length, :data
337
+
338
+ def initialize(name:, mix_crc: nil, content_offset: nil, content_length: nil, data: nil)
339
+ @name = name
340
+ @mix_crc = mix_crc
341
+ @content_offset = content_offset
342
+ @content_length = content_length
343
+ @data = data
344
+ end
345
+
346
+ def file_crc
347
+ return "e6fe46b8" if @name.downcase == ".w3dhub.patch"
348
+
349
+ Digest::CRC32.hexdigest(@name.upcase)
350
+ end
351
+
352
+ def data_crc
353
+ Digest::CRC32.hexdigest(@data)
354
+ end
355
+ end
356
+ end
357
+ end
data/sig/mix1.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Mix1
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mix1
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cyberarm
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-03-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: digest-crc
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
27
+ description: 'A gem for reading and writing Westwood''s MIX1 format (.mix) used in
28
+ Command & Conquer: Renegade and other games.'
29
+ email:
30
+ - matthewlikesrobots@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".standard.yml"
36
+ - CHANGELOG.md
37
+ - Gemfile
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - lib/mix1.rb
42
+ - lib/mix1/version.rb
43
+ - sig/mix1.rbs
44
+ homepage: https://github.com/cyberarm/mix1
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ allowed_push_host: https://rubygems.org
49
+ homepage_uri: https://github.com/cyberarm/mix1
50
+ source_code_uri: https://github.com/cyberarm/mix1
51
+ changelog_uri: https://github.com/cyberarm/cyberarm_engine/blob/master/CHANGELOG.md
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.6.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.4.6
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: 'Read and write Westwood''s MIX1 format (.mix) used in Command & Conquer:
71
+ Renegade and other games.'
72
+ test_files: []