cabriolet 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/ARCHITECTURE.md +799 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +29 -0
- data/README.adoc +1207 -0
- data/exe/cabriolet +6 -0
- data/lib/cabriolet/auto.rb +173 -0
- data/lib/cabriolet/binary/bitstream.rb +148 -0
- data/lib/cabriolet/binary/bitstream_writer.rb +180 -0
- data/lib/cabriolet/binary/chm_structures.rb +213 -0
- data/lib/cabriolet/binary/hlp_structures.rb +66 -0
- data/lib/cabriolet/binary/kwaj_structures.rb +74 -0
- data/lib/cabriolet/binary/lit_structures.rb +107 -0
- data/lib/cabriolet/binary/oab_structures.rb +112 -0
- data/lib/cabriolet/binary/structures.rb +56 -0
- data/lib/cabriolet/binary/szdd_structures.rb +60 -0
- data/lib/cabriolet/cab/compressor.rb +382 -0
- data/lib/cabriolet/cab/decompressor.rb +510 -0
- data/lib/cabriolet/cab/extractor.rb +357 -0
- data/lib/cabriolet/cab/parser.rb +264 -0
- data/lib/cabriolet/chm/compressor.rb +513 -0
- data/lib/cabriolet/chm/decompressor.rb +436 -0
- data/lib/cabriolet/chm/parser.rb +254 -0
- data/lib/cabriolet/cli.rb +776 -0
- data/lib/cabriolet/compressors/base.rb +34 -0
- data/lib/cabriolet/compressors/lzss.rb +250 -0
- data/lib/cabriolet/compressors/lzx.rb +581 -0
- data/lib/cabriolet/compressors/mszip.rb +315 -0
- data/lib/cabriolet/compressors/quantum.rb +446 -0
- data/lib/cabriolet/constants.rb +75 -0
- data/lib/cabriolet/decompressors/base.rb +39 -0
- data/lib/cabriolet/decompressors/lzss.rb +138 -0
- data/lib/cabriolet/decompressors/lzx.rb +726 -0
- data/lib/cabriolet/decompressors/mszip.rb +390 -0
- data/lib/cabriolet/decompressors/none.rb +27 -0
- data/lib/cabriolet/decompressors/quantum.rb +456 -0
- data/lib/cabriolet/errors.rb +39 -0
- data/lib/cabriolet/format_detector.rb +156 -0
- data/lib/cabriolet/hlp/compressor.rb +272 -0
- data/lib/cabriolet/hlp/decompressor.rb +198 -0
- data/lib/cabriolet/hlp/parser.rb +131 -0
- data/lib/cabriolet/huffman/decoder.rb +79 -0
- data/lib/cabriolet/huffman/encoder.rb +108 -0
- data/lib/cabriolet/huffman/tree.rb +138 -0
- data/lib/cabriolet/kwaj/compressor.rb +479 -0
- data/lib/cabriolet/kwaj/decompressor.rb +237 -0
- data/lib/cabriolet/kwaj/parser.rb +183 -0
- data/lib/cabriolet/lit/compressor.rb +255 -0
- data/lib/cabriolet/lit/decompressor.rb +250 -0
- data/lib/cabriolet/models/cabinet.rb +81 -0
- data/lib/cabriolet/models/chm_file.rb +28 -0
- data/lib/cabriolet/models/chm_header.rb +67 -0
- data/lib/cabriolet/models/chm_section.rb +38 -0
- data/lib/cabriolet/models/file.rb +119 -0
- data/lib/cabriolet/models/folder.rb +102 -0
- data/lib/cabriolet/models/folder_data.rb +21 -0
- data/lib/cabriolet/models/hlp_file.rb +45 -0
- data/lib/cabriolet/models/hlp_header.rb +37 -0
- data/lib/cabriolet/models/kwaj_header.rb +98 -0
- data/lib/cabriolet/models/lit_header.rb +55 -0
- data/lib/cabriolet/models/oab_header.rb +95 -0
- data/lib/cabriolet/models/szdd_header.rb +72 -0
- data/lib/cabriolet/modifier.rb +326 -0
- data/lib/cabriolet/oab/compressor.rb +353 -0
- data/lib/cabriolet/oab/decompressor.rb +315 -0
- data/lib/cabriolet/parallel.rb +333 -0
- data/lib/cabriolet/repairer.rb +288 -0
- data/lib/cabriolet/streaming.rb +221 -0
- data/lib/cabriolet/system/file_handle.rb +107 -0
- data/lib/cabriolet/system/io_system.rb +87 -0
- data/lib/cabriolet/system/memory_handle.rb +105 -0
- data/lib/cabriolet/szdd/compressor.rb +217 -0
- data/lib/cabriolet/szdd/decompressor.rb +184 -0
- data/lib/cabriolet/szdd/parser.rb +127 -0
- data/lib/cabriolet/validator.rb +332 -0
- data/lib/cabriolet/version.rb +5 -0
- data/lib/cabriolet.rb +104 -0
- metadata +157 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module KWAJ
|
|
5
|
+
# Compressor creates KWAJ compressed files
|
|
6
|
+
#
|
|
7
|
+
# KWAJ files support multiple compression methods:
|
|
8
|
+
# - NONE: Direct copy
|
|
9
|
+
# - XOR: XOR with 0xFF "encryption"
|
|
10
|
+
# - SZDD: LZSS compression
|
|
11
|
+
# - MSZIP: DEFLATE compression
|
|
12
|
+
#
|
|
13
|
+
# KWAJ headers contain optional fields controlled by flag bits:
|
|
14
|
+
# - Uncompressed length (4 bytes)
|
|
15
|
+
# - Filename (up to 9 bytes, null-terminated)
|
|
16
|
+
# - File extension (up to 4 bytes, null-terminated)
|
|
17
|
+
# - Extra data (2 bytes length + variable data)
|
|
18
|
+
class Compressor
|
|
19
|
+
attr_reader :io_system
|
|
20
|
+
|
|
21
|
+
# Initialize a new KWAJ compressor
|
|
22
|
+
#
|
|
23
|
+
# @param io_system [System::IOSystem, nil] Custom I/O system or nil for
|
|
24
|
+
# default
|
|
25
|
+
def initialize(io_system = nil)
|
|
26
|
+
@io_system = io_system || System::IOSystem.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compress a file to KWAJ format
|
|
30
|
+
#
|
|
31
|
+
# @param input_file [String] Path to input file
|
|
32
|
+
# @param output_file [String] Path to output KWAJ file
|
|
33
|
+
# @param options [Hash] Compression options
|
|
34
|
+
# @option options [Symbol] :compression Compression type (:none, :xor,
|
|
35
|
+
# :szdd, :mszip), default: :szdd
|
|
36
|
+
# @option options [Boolean] :include_length Include uncompressed length
|
|
37
|
+
# in header
|
|
38
|
+
# @option options [String] :filename Original filename to embed
|
|
39
|
+
# @option options [String] :extra_data Extra data to include
|
|
40
|
+
# @return [Integer] Bytes written to output file
|
|
41
|
+
# @raise [Error] if compression fails
|
|
42
|
+
def compress(input_file, output_file, **options)
|
|
43
|
+
compression_type = options.fetch(:compression, :szdd)
|
|
44
|
+
include_length = options.fetch(:include_length, false)
|
|
45
|
+
filename = options[:filename]
|
|
46
|
+
extra_data = options[:extra_data]
|
|
47
|
+
|
|
48
|
+
validate_compression_type(compression_type)
|
|
49
|
+
|
|
50
|
+
input_handle = @io_system.open(input_file, Constants::MODE_READ)
|
|
51
|
+
output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
# Get input size
|
|
55
|
+
input_size = @io_system.seek(input_handle, 0, Constants::SEEK_END)
|
|
56
|
+
@io_system.seek(input_handle, 0, Constants::SEEK_START)
|
|
57
|
+
|
|
58
|
+
# Write header
|
|
59
|
+
header_bytes = write_header(
|
|
60
|
+
output_handle,
|
|
61
|
+
compression_type,
|
|
62
|
+
input_size,
|
|
63
|
+
include_length,
|
|
64
|
+
filename,
|
|
65
|
+
extra_data,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Compress data
|
|
69
|
+
compressed_bytes = compress_data_stream(
|
|
70
|
+
compression_type,
|
|
71
|
+
input_handle,
|
|
72
|
+
output_handle,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
header_bytes + compressed_bytes
|
|
76
|
+
ensure
|
|
77
|
+
@io_system.close(input_handle) if input_handle
|
|
78
|
+
@io_system.close(output_handle) if output_handle
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Compress data from memory to KWAJ format
|
|
83
|
+
#
|
|
84
|
+
# @param data [String] Input data to compress
|
|
85
|
+
# @param output_file [String] Path to output KWAJ file
|
|
86
|
+
# @param options [Hash] Compression options
|
|
87
|
+
# @option options [Symbol] :compression Compression type (:none, :xor,
|
|
88
|
+
# :szdd, :mszip), default: :szdd
|
|
89
|
+
# @option options [Boolean] :include_length Include uncompressed length
|
|
90
|
+
# in header
|
|
91
|
+
# @option options [String] :filename Original filename to embed
|
|
92
|
+
# @option options [String] :extra_data Extra data to include
|
|
93
|
+
# @return [Integer] Bytes written to output file
|
|
94
|
+
# @raise [Error] if compression fails
|
|
95
|
+
def compress_data(data, output_file, **options)
|
|
96
|
+
compression_type = options.fetch(:compression, :szdd)
|
|
97
|
+
include_length = options.fetch(:include_length, false)
|
|
98
|
+
filename = options[:filename]
|
|
99
|
+
extra_data = options[:extra_data]
|
|
100
|
+
|
|
101
|
+
validate_compression_type(compression_type)
|
|
102
|
+
|
|
103
|
+
input_handle = System::MemoryHandle.new(data)
|
|
104
|
+
output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
# Write header
|
|
108
|
+
header_bytes = write_header(
|
|
109
|
+
output_handle,
|
|
110
|
+
compression_type,
|
|
111
|
+
data.bytesize,
|
|
112
|
+
include_length,
|
|
113
|
+
filename,
|
|
114
|
+
extra_data,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Compress data
|
|
118
|
+
compressed_bytes = compress_data_stream(
|
|
119
|
+
compression_type,
|
|
120
|
+
input_handle,
|
|
121
|
+
output_handle,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
header_bytes + compressed_bytes
|
|
125
|
+
ensure
|
|
126
|
+
@io_system.close(output_handle) if output_handle
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Write KWAJ header to output
|
|
133
|
+
#
|
|
134
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
135
|
+
# @param compression_type [Symbol] Compression type
|
|
136
|
+
# @param uncompressed_size [Integer] Size of uncompressed data
|
|
137
|
+
# @param include_length [Boolean] Include length field
|
|
138
|
+
# @param filename [String, nil] Original filename
|
|
139
|
+
# @param extra_data [String, nil] Extra data
|
|
140
|
+
# @return [Integer] Number of bytes written
|
|
141
|
+
def write_header(output_handle, compression_type, uncompressed_size,
|
|
142
|
+
include_length, filename, extra_data)
|
|
143
|
+
# Build header flags
|
|
144
|
+
flags = 0
|
|
145
|
+
flags |= Constants::KWAJ_HDR_HASLENGTH if include_length
|
|
146
|
+
|
|
147
|
+
# Split filename if provided
|
|
148
|
+
name_part = nil
|
|
149
|
+
ext_part = nil
|
|
150
|
+
if filename
|
|
151
|
+
name_part, ext_part = split_filename(filename)
|
|
152
|
+
flags |= Constants::KWAJ_HDR_HASFILENAME if name_part
|
|
153
|
+
flags |= Constants::KWAJ_HDR_HASFILEEXT if ext_part
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Extra data flag
|
|
157
|
+
flags |= Constants::KWAJ_HDR_HASEXTRATEXT if extra_data
|
|
158
|
+
|
|
159
|
+
# Calculate data offset
|
|
160
|
+
data_offset = calculate_data_offset(
|
|
161
|
+
include_length,
|
|
162
|
+
name_part,
|
|
163
|
+
ext_part,
|
|
164
|
+
extra_data,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Write base header
|
|
168
|
+
bytes_written = write_base_header(
|
|
169
|
+
output_handle,
|
|
170
|
+
compression_type_to_constant(compression_type),
|
|
171
|
+
data_offset,
|
|
172
|
+
flags,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Write optional fields
|
|
176
|
+
if include_length
|
|
177
|
+
bytes_written += write_length_field(output_handle,
|
|
178
|
+
uncompressed_size)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if name_part
|
|
182
|
+
bytes_written += write_filename_field(output_handle,
|
|
183
|
+
name_part)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if ext_part
|
|
187
|
+
bytes_written += write_extension_field(output_handle,
|
|
188
|
+
ext_part)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if extra_data
|
|
192
|
+
bytes_written += write_extra_data_field(output_handle,
|
|
193
|
+
extra_data)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
bytes_written
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Write KWAJ base header (14 bytes)
|
|
200
|
+
#
|
|
201
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
202
|
+
# @param comp_method [Integer] Compression method constant
|
|
203
|
+
# @param data_offset [Integer] Offset to compressed data
|
|
204
|
+
# @param flags [Integer] Header flags
|
|
205
|
+
# @return [Integer] Number of bytes written (14)
|
|
206
|
+
def write_base_header(output_handle, comp_method, data_offset, flags)
|
|
207
|
+
header = Binary::KWAJStructures::BaseHeader.new
|
|
208
|
+
header.signature1 = Binary::KWAJStructures::SIGNATURE1
|
|
209
|
+
header.signature2 = Binary::KWAJStructures::SIGNATURE2
|
|
210
|
+
header.comp_method = comp_method
|
|
211
|
+
header.data_offset = data_offset
|
|
212
|
+
header.flags = flags
|
|
213
|
+
|
|
214
|
+
header_data = header.to_binary_s
|
|
215
|
+
written = @io_system.write(output_handle, header_data)
|
|
216
|
+
|
|
217
|
+
unless written == header_data.bytesize
|
|
218
|
+
raise Error,
|
|
219
|
+
"Failed to write KWAJ base header"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
written
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Write length field (4 bytes)
|
|
226
|
+
#
|
|
227
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
228
|
+
# @param length [Integer] Uncompressed length
|
|
229
|
+
# @return [Integer] Number of bytes written (4)
|
|
230
|
+
def write_length_field(output_handle, length)
|
|
231
|
+
field = Binary::KWAJStructures::LengthField.new
|
|
232
|
+
field.uncompressed_length = length
|
|
233
|
+
|
|
234
|
+
field_data = field.to_binary_s
|
|
235
|
+
written = @io_system.write(output_handle, field_data)
|
|
236
|
+
|
|
237
|
+
unless written == field_data.bytesize
|
|
238
|
+
raise Error,
|
|
239
|
+
"Failed to write length field"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
written
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Write filename field (null-terminated)
|
|
246
|
+
#
|
|
247
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
248
|
+
# @param filename [String] Filename (max 8 chars)
|
|
249
|
+
# @return [Integer] Number of bytes written
|
|
250
|
+
def write_filename_field(output_handle, filename)
|
|
251
|
+
# Truncate to 8 characters and add null terminator
|
|
252
|
+
name = filename[0, 8]
|
|
253
|
+
data = "#{name}\x00"
|
|
254
|
+
written = @io_system.write(output_handle, data)
|
|
255
|
+
|
|
256
|
+
unless written == data.bytesize
|
|
257
|
+
raise Error,
|
|
258
|
+
"Failed to write filename field"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
written
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Write extension field (null-terminated)
|
|
265
|
+
#
|
|
266
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
267
|
+
# @param extension [String] Extension (max 3 chars)
|
|
268
|
+
# @return [Integer] Number of bytes written
|
|
269
|
+
def write_extension_field(output_handle, extension)
|
|
270
|
+
# Truncate to 3 characters and add null terminator
|
|
271
|
+
ext = extension[0, 3]
|
|
272
|
+
data = "#{ext}\x00"
|
|
273
|
+
written = @io_system.write(output_handle, data)
|
|
274
|
+
|
|
275
|
+
unless written == data.bytesize
|
|
276
|
+
raise Error,
|
|
277
|
+
"Failed to write extension field"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
written
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Write extra data field (2 bytes length + data)
|
|
284
|
+
#
|
|
285
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
286
|
+
# @param extra_data [String] Extra data
|
|
287
|
+
# @return [Integer] Number of bytes written
|
|
288
|
+
def write_extra_data_field(output_handle, extra_data)
|
|
289
|
+
field = Binary::KWAJStructures::ExtraTextField.new
|
|
290
|
+
field.text_length = extra_data.bytesize
|
|
291
|
+
field.data = extra_data
|
|
292
|
+
|
|
293
|
+
field_data = field.to_binary_s
|
|
294
|
+
written = @io_system.write(output_handle, field_data)
|
|
295
|
+
|
|
296
|
+
unless written == field_data.bytesize
|
|
297
|
+
raise Error,
|
|
298
|
+
"Failed to write extra data field"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
written
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Compress data stream using selected compression method
|
|
305
|
+
#
|
|
306
|
+
# @param compression_type [Symbol] Compression type
|
|
307
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
308
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
309
|
+
# @return [Integer] Number of bytes written
|
|
310
|
+
def compress_data_stream(compression_type, input_handle, output_handle)
|
|
311
|
+
case compression_type
|
|
312
|
+
when :none
|
|
313
|
+
compress_none(input_handle, output_handle)
|
|
314
|
+
when :xor
|
|
315
|
+
compress_xor(input_handle, output_handle)
|
|
316
|
+
when :szdd
|
|
317
|
+
compress_szdd(input_handle, output_handle)
|
|
318
|
+
when :mszip
|
|
319
|
+
compress_mszip(input_handle, output_handle)
|
|
320
|
+
else
|
|
321
|
+
raise Error,
|
|
322
|
+
"Unsupported compression type: #{compression_type}"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Compress with NONE method (direct copy)
|
|
327
|
+
#
|
|
328
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
329
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
330
|
+
# @return [Integer] Number of bytes written
|
|
331
|
+
def compress_none(input_handle, output_handle)
|
|
332
|
+
bytes_written = 0
|
|
333
|
+
buffer_size = 2048
|
|
334
|
+
|
|
335
|
+
loop do
|
|
336
|
+
data = @io_system.read(input_handle, buffer_size)
|
|
337
|
+
break if data.empty?
|
|
338
|
+
|
|
339
|
+
written = @io_system.write(output_handle, data)
|
|
340
|
+
bytes_written += written
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
bytes_written
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Compress with XOR method (XOR each byte with 0xFF)
|
|
347
|
+
#
|
|
348
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
349
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
350
|
+
# @return [Integer] Number of bytes written
|
|
351
|
+
def compress_xor(input_handle, output_handle)
|
|
352
|
+
bytes_written = 0
|
|
353
|
+
buffer_size = 2048
|
|
354
|
+
|
|
355
|
+
loop do
|
|
356
|
+
data = @io_system.read(input_handle, buffer_size)
|
|
357
|
+
break if data.empty?
|
|
358
|
+
|
|
359
|
+
# XOR each byte with 0xFF
|
|
360
|
+
xored = data.bytes.map { |b| b ^ 0xFF }.pack("C*")
|
|
361
|
+
|
|
362
|
+
written = @io_system.write(output_handle, xored)
|
|
363
|
+
bytes_written += written
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
bytes_written
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Compress with SZDD method (LZSS compression)
|
|
370
|
+
#
|
|
371
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
372
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
373
|
+
# @return [Integer] Number of bytes written
|
|
374
|
+
def compress_szdd(input_handle, output_handle)
|
|
375
|
+
compressor = Compressors::LZSS.new(
|
|
376
|
+
@io_system,
|
|
377
|
+
input_handle,
|
|
378
|
+
output_handle,
|
|
379
|
+
2048,
|
|
380
|
+
Compressors::LZSS::MODE_QBASIC,
|
|
381
|
+
)
|
|
382
|
+
compressor.compress
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Compress with MSZIP method (DEFLATE compression)
|
|
386
|
+
#
|
|
387
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
388
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
389
|
+
# @return [Integer] Number of bytes written
|
|
390
|
+
# @raise [Error] MSZIP compressor not yet implemented
|
|
391
|
+
def compress_mszip(_input_handle, _output_handle)
|
|
392
|
+
raise Error,
|
|
393
|
+
"MSZIP compression is not yet implemented. " \
|
|
394
|
+
"Use SZDD compression instead."
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Calculate data offset based on optional fields
|
|
398
|
+
#
|
|
399
|
+
# @param include_length [Boolean] Whether length field is included
|
|
400
|
+
# @param name_part [String, nil] Filename part
|
|
401
|
+
# @param ext_part [String, nil] Extension part
|
|
402
|
+
# @param extra_data [String, nil] Extra data
|
|
403
|
+
# @return [Integer] Data offset in bytes
|
|
404
|
+
def calculate_data_offset(include_length, name_part, ext_part, extra_data)
|
|
405
|
+
offset = 14 # Base header size
|
|
406
|
+
|
|
407
|
+
offset += 4 if include_length
|
|
408
|
+
|
|
409
|
+
if name_part
|
|
410
|
+
# Filename is truncated to 8 chars + null terminator
|
|
411
|
+
offset += [name_part.length, 8].min + 1
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
if ext_part
|
|
415
|
+
# Extension is truncated to 3 chars + null terminator
|
|
416
|
+
offset += [ext_part.length, 3].min + 1
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
if extra_data
|
|
420
|
+
# 2 bytes for length + data
|
|
421
|
+
offset += 2 + extra_data.bytesize
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
offset
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Split filename into name and extension parts
|
|
428
|
+
#
|
|
429
|
+
# @param filename [String] Filename to split
|
|
430
|
+
# @return [Array<String, String>] [name, extension] (extension may be nil)
|
|
431
|
+
def split_filename(filename)
|
|
432
|
+
# Remove directory path if present
|
|
433
|
+
basename = ::File.basename(filename)
|
|
434
|
+
|
|
435
|
+
# Split on last dot
|
|
436
|
+
if basename.include?(".")
|
|
437
|
+
parts = basename.rpartition(".")
|
|
438
|
+
name = parts[0]
|
|
439
|
+
ext = parts[2]
|
|
440
|
+
[name, ext.empty? ? nil : ext]
|
|
441
|
+
else
|
|
442
|
+
[basename, nil]
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Convert compression type symbol to constant
|
|
447
|
+
#
|
|
448
|
+
# @param compression_type [Symbol] Compression type
|
|
449
|
+
# @return [Integer] Compression type constant
|
|
450
|
+
def compression_type_to_constant(compression_type)
|
|
451
|
+
case compression_type
|
|
452
|
+
when :none
|
|
453
|
+
Constants::KWAJ_COMP_NONE
|
|
454
|
+
when :xor
|
|
455
|
+
Constants::KWAJ_COMP_XOR
|
|
456
|
+
when :szdd
|
|
457
|
+
Constants::KWAJ_COMP_SZDD
|
|
458
|
+
when :mszip
|
|
459
|
+
Constants::KWAJ_COMP_MSZIP
|
|
460
|
+
else
|
|
461
|
+
raise ArgumentError, "Unknown compression type: #{compression_type}"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Validate compression type parameter
|
|
466
|
+
#
|
|
467
|
+
# @param compression_type [Symbol] Compression type to validate
|
|
468
|
+
# @raise [ArgumentError] if compression type is invalid
|
|
469
|
+
def validate_compression_type(compression_type)
|
|
470
|
+
valid_types = %i[none xor szdd mszip]
|
|
471
|
+
return if valid_types.include?(compression_type)
|
|
472
|
+
|
|
473
|
+
raise ArgumentError,
|
|
474
|
+
"Compression type must be one of #{valid_types.inspect}, " \
|
|
475
|
+
"got #{compression_type.inspect}"
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module KWAJ
|
|
5
|
+
# Decompressor is the main interface for KWAJ file operations
|
|
6
|
+
#
|
|
7
|
+
# KWAJ files support multiple compression methods:
|
|
8
|
+
# - NONE: Direct copy
|
|
9
|
+
# - XOR: XOR with 0xFF then copy
|
|
10
|
+
# - SZDD: LZSS compression
|
|
11
|
+
# - LZH: LZSS with Huffman (not fully implemented)
|
|
12
|
+
# - MSZIP: DEFLATE compression
|
|
13
|
+
class Decompressor
|
|
14
|
+
attr_reader :io_system, :parser
|
|
15
|
+
attr_accessor :buffer_size
|
|
16
|
+
|
|
17
|
+
# Input buffer size for decompression
|
|
18
|
+
DEFAULT_BUFFER_SIZE = 2048
|
|
19
|
+
|
|
20
|
+
# Initialize a new KWAJ decompressor
|
|
21
|
+
#
|
|
22
|
+
# @param io_system [System::IOSystem, nil] Custom I/O system or nil for
|
|
23
|
+
# default
|
|
24
|
+
def initialize(io_system = nil)
|
|
25
|
+
@io_system = io_system || System::IOSystem.new
|
|
26
|
+
@parser = Parser.new(@io_system)
|
|
27
|
+
@buffer_size = DEFAULT_BUFFER_SIZE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Open and parse a KWAJ file
|
|
31
|
+
#
|
|
32
|
+
# @param filename [String] Path to the KWAJ file
|
|
33
|
+
# @return [Models::KWAJHeader] Parsed header
|
|
34
|
+
# @raise [Errors::ParseError] if the file is not a valid KWAJ
|
|
35
|
+
def open(filename)
|
|
36
|
+
@parser.parse(filename)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Close a KWAJ file (no-op for compatibility)
|
|
40
|
+
#
|
|
41
|
+
# @param _header [Models::KWAJHeader] Header to close
|
|
42
|
+
# @return [void]
|
|
43
|
+
def close(_header)
|
|
44
|
+
# No resources to free in the header itself
|
|
45
|
+
# File handles are managed separately during extraction
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Extract a KWAJ file to output
|
|
50
|
+
#
|
|
51
|
+
# @param header [Models::KWAJHeader] KWAJ header from open()
|
|
52
|
+
# @param filename [String] Input filename
|
|
53
|
+
# @param output_path [String] Where to write the decompressed file
|
|
54
|
+
# @return [Integer] Number of bytes written
|
|
55
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
56
|
+
def extract(header, filename, output_path)
|
|
57
|
+
raise ArgumentError, "Header must not be nil" unless header
|
|
58
|
+
raise ArgumentError, "Output path must not be nil" unless output_path
|
|
59
|
+
|
|
60
|
+
input_handle = @io_system.open(filename, Constants::MODE_READ)
|
|
61
|
+
output_handle = @io_system.open(output_path, Constants::MODE_WRITE)
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
# Seek to compressed data start
|
|
65
|
+
@io_system.seek(
|
|
66
|
+
input_handle, header.data_offset, Constants::SEEK_START
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Decompress based on compression type
|
|
70
|
+
bytes_written = decompress_data(
|
|
71
|
+
header, input_handle, output_handle
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Verify decompressed size if known
|
|
75
|
+
if header.length && bytes_written != header.length
|
|
76
|
+
warn "[Cabriolet] WARNING: decompressed #{bytes_written} bytes, " \
|
|
77
|
+
"expected #{header.length} bytes"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
bytes_written
|
|
81
|
+
ensure
|
|
82
|
+
@io_system.close(input_handle) if input_handle
|
|
83
|
+
@io_system.close(output_handle) if output_handle
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# One-shot decompression from input file to output file
|
|
88
|
+
#
|
|
89
|
+
# @param input_path [String] Path to compressed KWAJ file
|
|
90
|
+
# @param output_path [String, nil] Path to output file, or nil to
|
|
91
|
+
# auto-detect
|
|
92
|
+
# @return [Integer] Number of bytes written
|
|
93
|
+
# @raise [Errors::ParseError] if input is not valid KWAJ
|
|
94
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
95
|
+
def decompress(input_path, output_path = nil)
|
|
96
|
+
# Parse header
|
|
97
|
+
header = open(input_path)
|
|
98
|
+
|
|
99
|
+
# Auto-detect output filename if not provided
|
|
100
|
+
output_path ||= auto_output_filename(input_path, header)
|
|
101
|
+
|
|
102
|
+
# Extract
|
|
103
|
+
bytes_written = extract(header, input_path, output_path)
|
|
104
|
+
|
|
105
|
+
# Close (no-op but kept for API consistency)
|
|
106
|
+
close(header)
|
|
107
|
+
|
|
108
|
+
bytes_written
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Generate output filename from input filename and header
|
|
112
|
+
#
|
|
113
|
+
# @param input_path [String] Input file path
|
|
114
|
+
# @param header [Models::KWAJHeader] KWAJ header
|
|
115
|
+
# @return [String] Suggested output filename
|
|
116
|
+
def auto_output_filename(input_path, header)
|
|
117
|
+
# Use embedded filename if available
|
|
118
|
+
if header.filename && !header.filename.empty?
|
|
119
|
+
dir = ::File.dirname(input_path)
|
|
120
|
+
return ::File.join(dir, header.filename)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Fall back to removing extension
|
|
124
|
+
base = ::File.basename(input_path, ".*")
|
|
125
|
+
dir = ::File.dirname(input_path)
|
|
126
|
+
::File.join(dir, base)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Decompress data based on compression type
|
|
132
|
+
#
|
|
133
|
+
# @param header [Models::KWAJHeader] KWAJ header
|
|
134
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
135
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
136
|
+
# @return [Integer] Number of bytes written
|
|
137
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
138
|
+
def decompress_data(header, input_handle, output_handle)
|
|
139
|
+
case header.comp_type
|
|
140
|
+
when Constants::KWAJ_COMP_NONE
|
|
141
|
+
decompress_none(input_handle, output_handle)
|
|
142
|
+
when Constants::KWAJ_COMP_XOR
|
|
143
|
+
decompress_xor(input_handle, output_handle)
|
|
144
|
+
when Constants::KWAJ_COMP_SZDD
|
|
145
|
+
decompress_szdd(input_handle, output_handle)
|
|
146
|
+
when Constants::KWAJ_COMP_LZH
|
|
147
|
+
decompress_lzh(input_handle, output_handle)
|
|
148
|
+
when Constants::KWAJ_COMP_MSZIP
|
|
149
|
+
decompress_mszip(input_handle, output_handle)
|
|
150
|
+
else
|
|
151
|
+
raise Errors::DecompressionError,
|
|
152
|
+
"Unsupported compression type: #{header.comp_type}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Decompress NONE type (direct copy)
|
|
157
|
+
#
|
|
158
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
159
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
160
|
+
# @return [Integer] Number of bytes written
|
|
161
|
+
def decompress_none(input_handle, output_handle)
|
|
162
|
+
bytes_written = 0
|
|
163
|
+
loop do
|
|
164
|
+
data = @io_system.read(input_handle, @buffer_size)
|
|
165
|
+
break if data.empty?
|
|
166
|
+
|
|
167
|
+
written = @io_system.write(output_handle, data)
|
|
168
|
+
bytes_written += written
|
|
169
|
+
end
|
|
170
|
+
bytes_written
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Decompress XOR type (XOR with 0xFF then copy)
|
|
174
|
+
#
|
|
175
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
176
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
177
|
+
# @return [Integer] Number of bytes written
|
|
178
|
+
def decompress_xor(input_handle, output_handle)
|
|
179
|
+
bytes_written = 0
|
|
180
|
+
loop do
|
|
181
|
+
data = @io_system.read(input_handle, @buffer_size)
|
|
182
|
+
break if data.empty?
|
|
183
|
+
|
|
184
|
+
# XOR each byte with 0xFF
|
|
185
|
+
xored = data.bytes.map { |b| b ^ 0xFF }.pack("C*")
|
|
186
|
+
|
|
187
|
+
written = @io_system.write(output_handle, xored)
|
|
188
|
+
bytes_written += written
|
|
189
|
+
end
|
|
190
|
+
bytes_written
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Decompress SZDD type (LZSS)
|
|
194
|
+
#
|
|
195
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
196
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
197
|
+
# @return [Integer] Number of bytes written
|
|
198
|
+
def decompress_szdd(input_handle, output_handle)
|
|
199
|
+
decompressor = Decompressors::LZSS.new(
|
|
200
|
+
@io_system,
|
|
201
|
+
input_handle,
|
|
202
|
+
output_handle,
|
|
203
|
+
@buffer_size,
|
|
204
|
+
Decompressors::LZSS::MODE_QBASIC,
|
|
205
|
+
)
|
|
206
|
+
decompressor.decompress(0)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Decompress LZH type (LZSS with Huffman)
|
|
210
|
+
#
|
|
211
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
212
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
213
|
+
# @return [Integer] Number of bytes written
|
|
214
|
+
# @raise [Errors::DecompressionError] LZH not yet implemented
|
|
215
|
+
def decompress_lzh(_input_handle, _output_handle)
|
|
216
|
+
raise Errors::DecompressionError,
|
|
217
|
+
"LZH compression type is not yet implemented. " \
|
|
218
|
+
"This requires custom Huffman tree implementation."
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Decompress MSZIP type (DEFLATE)
|
|
222
|
+
#
|
|
223
|
+
# @param input_handle [System::FileHandle] Input handle
|
|
224
|
+
# @param output_handle [System::FileHandle] Output handle
|
|
225
|
+
# @return [Integer] Number of bytes written
|
|
226
|
+
def decompress_mszip(input_handle, output_handle)
|
|
227
|
+
decompressor = Decompressors::MSZIP.new(
|
|
228
|
+
@io_system,
|
|
229
|
+
input_handle,
|
|
230
|
+
output_handle,
|
|
231
|
+
@buffer_size,
|
|
232
|
+
)
|
|
233
|
+
decompressor.decompress(0)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|