mix1 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 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: []