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 +7 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +31 -0
- data/Rakefile +6 -0
- data/lib/mix1/version.rb +5 -0
- data/lib/mix1.rb +357 -0
- data/sig/mix1.rbs +4 -0
- metadata +72 -0
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
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
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
data/lib/mix1/version.rb
ADDED
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
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: []
|