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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +799 -0
  3. data/CHANGELOG.md +44 -0
  4. data/LICENSE +29 -0
  5. data/README.adoc +1207 -0
  6. data/exe/cabriolet +6 -0
  7. data/lib/cabriolet/auto.rb +173 -0
  8. data/lib/cabriolet/binary/bitstream.rb +148 -0
  9. data/lib/cabriolet/binary/bitstream_writer.rb +180 -0
  10. data/lib/cabriolet/binary/chm_structures.rb +213 -0
  11. data/lib/cabriolet/binary/hlp_structures.rb +66 -0
  12. data/lib/cabriolet/binary/kwaj_structures.rb +74 -0
  13. data/lib/cabriolet/binary/lit_structures.rb +107 -0
  14. data/lib/cabriolet/binary/oab_structures.rb +112 -0
  15. data/lib/cabriolet/binary/structures.rb +56 -0
  16. data/lib/cabriolet/binary/szdd_structures.rb +60 -0
  17. data/lib/cabriolet/cab/compressor.rb +382 -0
  18. data/lib/cabriolet/cab/decompressor.rb +510 -0
  19. data/lib/cabriolet/cab/extractor.rb +357 -0
  20. data/lib/cabriolet/cab/parser.rb +264 -0
  21. data/lib/cabriolet/chm/compressor.rb +513 -0
  22. data/lib/cabriolet/chm/decompressor.rb +436 -0
  23. data/lib/cabriolet/chm/parser.rb +254 -0
  24. data/lib/cabriolet/cli.rb +776 -0
  25. data/lib/cabriolet/compressors/base.rb +34 -0
  26. data/lib/cabriolet/compressors/lzss.rb +250 -0
  27. data/lib/cabriolet/compressors/lzx.rb +581 -0
  28. data/lib/cabriolet/compressors/mszip.rb +315 -0
  29. data/lib/cabriolet/compressors/quantum.rb +446 -0
  30. data/lib/cabriolet/constants.rb +75 -0
  31. data/lib/cabriolet/decompressors/base.rb +39 -0
  32. data/lib/cabriolet/decompressors/lzss.rb +138 -0
  33. data/lib/cabriolet/decompressors/lzx.rb +726 -0
  34. data/lib/cabriolet/decompressors/mszip.rb +390 -0
  35. data/lib/cabriolet/decompressors/none.rb +27 -0
  36. data/lib/cabriolet/decompressors/quantum.rb +456 -0
  37. data/lib/cabriolet/errors.rb +39 -0
  38. data/lib/cabriolet/format_detector.rb +156 -0
  39. data/lib/cabriolet/hlp/compressor.rb +272 -0
  40. data/lib/cabriolet/hlp/decompressor.rb +198 -0
  41. data/lib/cabriolet/hlp/parser.rb +131 -0
  42. data/lib/cabriolet/huffman/decoder.rb +79 -0
  43. data/lib/cabriolet/huffman/encoder.rb +108 -0
  44. data/lib/cabriolet/huffman/tree.rb +138 -0
  45. data/lib/cabriolet/kwaj/compressor.rb +479 -0
  46. data/lib/cabriolet/kwaj/decompressor.rb +237 -0
  47. data/lib/cabriolet/kwaj/parser.rb +183 -0
  48. data/lib/cabriolet/lit/compressor.rb +255 -0
  49. data/lib/cabriolet/lit/decompressor.rb +250 -0
  50. data/lib/cabriolet/models/cabinet.rb +81 -0
  51. data/lib/cabriolet/models/chm_file.rb +28 -0
  52. data/lib/cabriolet/models/chm_header.rb +67 -0
  53. data/lib/cabriolet/models/chm_section.rb +38 -0
  54. data/lib/cabriolet/models/file.rb +119 -0
  55. data/lib/cabriolet/models/folder.rb +102 -0
  56. data/lib/cabriolet/models/folder_data.rb +21 -0
  57. data/lib/cabriolet/models/hlp_file.rb +45 -0
  58. data/lib/cabriolet/models/hlp_header.rb +37 -0
  59. data/lib/cabriolet/models/kwaj_header.rb +98 -0
  60. data/lib/cabriolet/models/lit_header.rb +55 -0
  61. data/lib/cabriolet/models/oab_header.rb +95 -0
  62. data/lib/cabriolet/models/szdd_header.rb +72 -0
  63. data/lib/cabriolet/modifier.rb +326 -0
  64. data/lib/cabriolet/oab/compressor.rb +353 -0
  65. data/lib/cabriolet/oab/decompressor.rb +315 -0
  66. data/lib/cabriolet/parallel.rb +333 -0
  67. data/lib/cabriolet/repairer.rb +288 -0
  68. data/lib/cabriolet/streaming.rb +221 -0
  69. data/lib/cabriolet/system/file_handle.rb +107 -0
  70. data/lib/cabriolet/system/io_system.rb +87 -0
  71. data/lib/cabriolet/system/memory_handle.rb +105 -0
  72. data/lib/cabriolet/szdd/compressor.rb +217 -0
  73. data/lib/cabriolet/szdd/decompressor.rb +184 -0
  74. data/lib/cabriolet/szdd/parser.rb +127 -0
  75. data/lib/cabriolet/validator.rb +332 -0
  76. data/lib/cabriolet/version.rb +5 -0
  77. data/lib/cabriolet.rb +104 -0
  78. 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