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
data/exe/cabriolet
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "format_detector"
|
|
4
|
+
|
|
5
|
+
module Cabriolet
|
|
6
|
+
# Auto-detection and extraction module
|
|
7
|
+
module Auto
|
|
8
|
+
class << self
|
|
9
|
+
# Open and parse an archive with automatic format detection
|
|
10
|
+
#
|
|
11
|
+
# @param path [String] Path to the archive file
|
|
12
|
+
# @param options [Hash] Options to pass to the parser
|
|
13
|
+
# @return [Object] Parsed archive object
|
|
14
|
+
# @raise [UnsupportedFormatError] if format cannot be detected or is unsupported
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# archive = Cabriolet::Auto.open('unknown.archive')
|
|
18
|
+
# archive.files.each { |f| puts f.name }
|
|
19
|
+
def open(path, **options)
|
|
20
|
+
format = FormatDetector.detect(path)
|
|
21
|
+
unless format
|
|
22
|
+
raise UnsupportedFormatError,
|
|
23
|
+
"Unable to detect format for: #{path}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
parser_class = FormatDetector.format_to_parser(format)
|
|
27
|
+
unless parser_class
|
|
28
|
+
raise UnsupportedFormatError,
|
|
29
|
+
"No parser available for format: #{format}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
parser_class.new(**options).parse(path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Detect format and extract all files automatically
|
|
36
|
+
#
|
|
37
|
+
# @param archive_path [String] Path to the archive
|
|
38
|
+
# @param output_dir [String] Directory to extract to
|
|
39
|
+
# @param options [Hash] Extraction options
|
|
40
|
+
# @option options [Boolean] :preserve_paths (true) Preserve directory structure
|
|
41
|
+
# @option options [Boolean] :overwrite (false) Overwrite existing files
|
|
42
|
+
# @option options [Boolean] :parallel (false) Use parallel extraction
|
|
43
|
+
# @option options [Integer] :workers (4) Number of parallel workers
|
|
44
|
+
# @return [Hash] Extraction statistics
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# Cabriolet::Auto.extract('archive.cab', 'output/')
|
|
48
|
+
# Cabriolet::Auto.extract('file.chm', 'docs/', parallel: true, workers: 8)
|
|
49
|
+
def extract(archive_path, output_dir, **options)
|
|
50
|
+
archive = open(archive_path)
|
|
51
|
+
|
|
52
|
+
extractor = if options[:parallel]
|
|
53
|
+
ParallelExtractor.new(archive, output_dir, **options)
|
|
54
|
+
else
|
|
55
|
+
SimpleExtractor.new(archive, output_dir, **options)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
extractor.extract_all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Detect format only without parsing
|
|
62
|
+
#
|
|
63
|
+
# @param path [String] Path to the file
|
|
64
|
+
# @return [Symbol, nil] Detected format symbol or nil
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# format = Cabriolet::Auto.detect_format('file.cab')
|
|
68
|
+
# # => :cab
|
|
69
|
+
def detect_format(path)
|
|
70
|
+
FormatDetector.detect(path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get information about an archive without full extraction
|
|
74
|
+
#
|
|
75
|
+
# @param path [String] Path to the archive
|
|
76
|
+
# @return [Hash] Archive information
|
|
77
|
+
#
|
|
78
|
+
# @example
|
|
79
|
+
# info = Cabriolet::Auto.info('archive.cab')
|
|
80
|
+
# # => { format: :cab, file_count: 145, total_size: 52428800, ... }
|
|
81
|
+
def info(path)
|
|
82
|
+
archive = open(path)
|
|
83
|
+
format = detect_format(path)
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
format: format,
|
|
87
|
+
path: path,
|
|
88
|
+
file_count: archive.files.count,
|
|
89
|
+
total_size: archive.files.sum { |f| f.size || 0 },
|
|
90
|
+
compressed_size: File.size(path),
|
|
91
|
+
compression_ratio: calculate_compression_ratio(archive, path),
|
|
92
|
+
files: archive.files.map { |f| file_info(f) },
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def calculate_compression_ratio(archive, path)
|
|
99
|
+
total_uncompressed = archive.files.sum { |f| f.size || 0 }
|
|
100
|
+
compressed = File.size(path)
|
|
101
|
+
|
|
102
|
+
return 0 if total_uncompressed.zero?
|
|
103
|
+
|
|
104
|
+
((compressed.to_f / total_uncompressed) * 100).round(2)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def file_info(file)
|
|
108
|
+
{
|
|
109
|
+
name: file.name,
|
|
110
|
+
size: file.size,
|
|
111
|
+
compressed_size: file.respond_to?(:compressed_size) ? file.compressed_size : nil,
|
|
112
|
+
attributes: file.respond_to?(:attributes) ? file.attributes : nil,
|
|
113
|
+
date: file.respond_to?(:date) ? file.date : nil,
|
|
114
|
+
time: file.respond_to?(:time) ? file.time : nil,
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Simple sequential extractor
|
|
120
|
+
class SimpleExtractor
|
|
121
|
+
def initialize(archive, output_dir, **options)
|
|
122
|
+
@archive = archive
|
|
123
|
+
@output_dir = output_dir
|
|
124
|
+
@options = options
|
|
125
|
+
@preserve_paths = options.fetch(:preserve_paths, true)
|
|
126
|
+
@overwrite = options.fetch(:overwrite, false)
|
|
127
|
+
@stats = { extracted: 0, skipped: 0, failed: 0, bytes: 0 }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def extract_all
|
|
131
|
+
FileUtils.mkdir_p(@output_dir)
|
|
132
|
+
|
|
133
|
+
@archive.files.each do |file|
|
|
134
|
+
extract_file(file)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@stats
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def extract_file(file)
|
|
143
|
+
output_path = build_output_path(file.name)
|
|
144
|
+
|
|
145
|
+
if File.exist?(output_path) && !@overwrite
|
|
146
|
+
@stats[:skipped] += 1
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
151
|
+
File.write(output_path, file.data, mode: "wb")
|
|
152
|
+
|
|
153
|
+
@stats[:extracted] += 1
|
|
154
|
+
@stats[:bytes] += file.data.bytesize
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
@stats[:failed] += 1
|
|
157
|
+
warn "Failed to extract #{file.name}: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_output_path(filename)
|
|
161
|
+
if @preserve_paths
|
|
162
|
+
# Keep directory structure
|
|
163
|
+
clean_name = filename.gsub("\\", "/")
|
|
164
|
+
File.join(@output_dir, clean_name)
|
|
165
|
+
else
|
|
166
|
+
# Flatten to output directory
|
|
167
|
+
base_name = File.basename(filename.gsub("\\", "/"))
|
|
168
|
+
File.join(@output_dir, base_name)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module Binary
|
|
5
|
+
# Bitstream provides bit-level I/O operations for reading compressed data
|
|
6
|
+
class Bitstream
|
|
7
|
+
attr_reader :io_system, :handle, :buffer_size
|
|
8
|
+
|
|
9
|
+
# Initialize a new bitstream
|
|
10
|
+
#
|
|
11
|
+
# @param io_system [System::IOSystem] I/O system for reading data
|
|
12
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Handle to read from
|
|
13
|
+
# @param buffer_size [Integer] Size of the input buffer
|
|
14
|
+
def initialize(io_system, handle,
|
|
15
|
+
buffer_size = Cabriolet.default_buffer_size)
|
|
16
|
+
@io_system = io_system
|
|
17
|
+
@handle = handle
|
|
18
|
+
@buffer_size = buffer_size
|
|
19
|
+
@buffer = ""
|
|
20
|
+
@buffer_pos = 0
|
|
21
|
+
@bit_buffer = 0
|
|
22
|
+
@bits_left = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Read specified number of bits from the stream
|
|
26
|
+
#
|
|
27
|
+
# @param num_bits [Integer] Number of bits to read (1-32)
|
|
28
|
+
# @return [Integer] Bits read as an integer
|
|
29
|
+
# @raise [DecompressionError] if unable to read required bits
|
|
30
|
+
def read_bits(num_bits)
|
|
31
|
+
if num_bits < 1 || num_bits > 32
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"Can only read 1-32 bits at a time"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Ensure we have enough bits in the buffer
|
|
37
|
+
while @bits_left < num_bits
|
|
38
|
+
byte = read_byte
|
|
39
|
+
return 0 if byte.nil? # EOF
|
|
40
|
+
|
|
41
|
+
@bit_buffer |= (byte << @bits_left)
|
|
42
|
+
@bits_left += 8
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Extract the requested bits
|
|
46
|
+
result = @bit_buffer & ((1 << num_bits) - 1)
|
|
47
|
+
@bit_buffer >>= num_bits
|
|
48
|
+
@bits_left -= num_bits
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Read a single byte from the input
|
|
54
|
+
#
|
|
55
|
+
# @return [Integer, nil] Byte value or nil at EOF
|
|
56
|
+
def read_byte
|
|
57
|
+
if @buffer_pos >= @buffer.bytesize
|
|
58
|
+
@buffer = @io_system.read(@handle, @buffer_size)
|
|
59
|
+
@buffer_pos = 0
|
|
60
|
+
return nil if @buffer.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
byte = @buffer.getbyte(@buffer_pos)
|
|
64
|
+
@buffer_pos += 1
|
|
65
|
+
byte
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Align to the next byte boundary
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def byte_align
|
|
72
|
+
discard_bits = @bits_left % 8
|
|
73
|
+
@bit_buffer >>= discard_bits
|
|
74
|
+
@bits_left -= discard_bits
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Peek at bits without consuming them
|
|
78
|
+
#
|
|
79
|
+
# @param num_bits [Integer] Number of bits to peek at
|
|
80
|
+
# @return [Integer] Bits as an integer
|
|
81
|
+
def peek_bits(num_bits)
|
|
82
|
+
if num_bits < 1 || num_bits > 32
|
|
83
|
+
raise ArgumentError,
|
|
84
|
+
"Can only peek 1-32 bits at a time"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Ensure we have enough bits
|
|
88
|
+
while @bits_left < num_bits
|
|
89
|
+
byte = read_byte
|
|
90
|
+
return 0 if byte.nil?
|
|
91
|
+
|
|
92
|
+
@bit_buffer |= (byte << @bits_left)
|
|
93
|
+
@bits_left += 8
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@bit_buffer & ((1 << num_bits) - 1)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Skip specified number of bits
|
|
100
|
+
#
|
|
101
|
+
# @param num_bits [Integer] Number of bits to skip
|
|
102
|
+
# @return [void]
|
|
103
|
+
def skip_bits(num_bits)
|
|
104
|
+
read_bits(num_bits)
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Read bits in big-endian (MSB first) order
|
|
109
|
+
#
|
|
110
|
+
# @param num_bits [Integer] Number of bits to read
|
|
111
|
+
# @return [Integer] Bits as an integer
|
|
112
|
+
def read_bits_be(num_bits)
|
|
113
|
+
result = 0
|
|
114
|
+
num_bits.times do
|
|
115
|
+
result = (result << 1) | read_bits(1)
|
|
116
|
+
end
|
|
117
|
+
result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Read a 16-bit little-endian value
|
|
121
|
+
#
|
|
122
|
+
# @return [Integer] 16-bit value
|
|
123
|
+
def read_uint16_le
|
|
124
|
+
read_bits(16)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Read a 32-bit little-endian value
|
|
128
|
+
#
|
|
129
|
+
# @return [Integer] 32-bit value
|
|
130
|
+
def read_uint32_le
|
|
131
|
+
low = read_bits(16)
|
|
132
|
+
high = read_bits(16)
|
|
133
|
+
(high << 16) | low
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Reset the bitstream state
|
|
137
|
+
#
|
|
138
|
+
# @return [void]
|
|
139
|
+
def reset
|
|
140
|
+
@buffer = ""
|
|
141
|
+
@buffer_pos = 0
|
|
142
|
+
@bit_buffer = 0
|
|
143
|
+
@bits_left = 0
|
|
144
|
+
@io_system.seek(@handle, 0, Constants::SEEK_START)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module Binary
|
|
5
|
+
# BitstreamWriter provides bit-level I/O operations for writing compressed data
|
|
6
|
+
class BitstreamWriter
|
|
7
|
+
attr_reader :io_system, :handle, :buffer_size
|
|
8
|
+
|
|
9
|
+
# Initialize a new bitstream writer
|
|
10
|
+
#
|
|
11
|
+
# @param io_system [System::IOSystem] I/O system for writing data
|
|
12
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Handle to write to
|
|
13
|
+
# @param buffer_size [Integer] Size of the output buffer
|
|
14
|
+
# @param msb_first [Boolean] Whether to write bits MSB-first (for Quantum)
|
|
15
|
+
def initialize(io_system, handle,
|
|
16
|
+
buffer_size = Cabriolet.default_buffer_size, msb_first: false)
|
|
17
|
+
@io_system = io_system
|
|
18
|
+
@handle = handle
|
|
19
|
+
@buffer_size = buffer_size
|
|
20
|
+
@msb_first = msb_first
|
|
21
|
+
@bit_buffer = 0
|
|
22
|
+
@bits_in_buffer = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Write specified number of bits to the stream
|
|
26
|
+
#
|
|
27
|
+
# @param value [Integer] Value to write
|
|
28
|
+
# @param num_bits [Integer] Number of bits to write (1-32)
|
|
29
|
+
# @return [void]
|
|
30
|
+
# @raise [ArgumentError] if num_bits is out of range
|
|
31
|
+
def write_bits(value, num_bits)
|
|
32
|
+
if num_bits < 1 || num_bits > 32
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"Can only write 1-32 bits at a time"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Add bits to buffer (LSB first, like DEFLATE)
|
|
38
|
+
@bit_buffer |= ((value & ((1 << num_bits) - 1)) << @bits_in_buffer)
|
|
39
|
+
@bits_in_buffer += num_bits
|
|
40
|
+
|
|
41
|
+
# Flush complete bytes
|
|
42
|
+
while @bits_in_buffer >= 8
|
|
43
|
+
byte = @bit_buffer & 0xFF
|
|
44
|
+
write_byte(byte)
|
|
45
|
+
@bit_buffer >>= 8
|
|
46
|
+
@bits_in_buffer -= 8
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Align to the next byte boundary by padding with zeros
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
def byte_align
|
|
54
|
+
return if @bits_in_buffer.zero?
|
|
55
|
+
|
|
56
|
+
# Pad with zeros to complete the current byte
|
|
57
|
+
padding_bits = 8 - (@bits_in_buffer % 8)
|
|
58
|
+
write_bits(0, padding_bits) if padding_bits < 8
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Flush any remaining bits in the buffer
|
|
62
|
+
#
|
|
63
|
+
# @return [void]
|
|
64
|
+
def flush
|
|
65
|
+
return if @bits_in_buffer.zero?
|
|
66
|
+
|
|
67
|
+
# Write any remaining bits (padded with zeros)
|
|
68
|
+
byte = @bit_buffer & 0xFF
|
|
69
|
+
write_byte(byte)
|
|
70
|
+
@bit_buffer = 0
|
|
71
|
+
@bits_in_buffer = 0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Write a single byte to the output
|
|
75
|
+
#
|
|
76
|
+
# @param byte [Integer] Byte value to write
|
|
77
|
+
# @return [void]
|
|
78
|
+
def write_byte(byte)
|
|
79
|
+
data = [byte].pack("C")
|
|
80
|
+
@io_system.write(@handle, data)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Write a raw byte directly (for signatures, etc.)
|
|
84
|
+
# This ensures the bit buffer is flushed first
|
|
85
|
+
#
|
|
86
|
+
# @param byte [Integer] Byte value to write
|
|
87
|
+
# @return [void]
|
|
88
|
+
def write_raw_byte(byte)
|
|
89
|
+
flush if @bits_in_buffer.positive?
|
|
90
|
+
write_byte(byte)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Write multiple bytes to the output
|
|
94
|
+
#
|
|
95
|
+
# @param bytes [String, Array<Integer>] Bytes to write
|
|
96
|
+
# @return [void]
|
|
97
|
+
def write_bytes(bytes)
|
|
98
|
+
data = bytes.is_a?(String) ? bytes : bytes.pack("C*")
|
|
99
|
+
@io_system.write(@handle, data)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Write bits in big-endian (MSB first) order
|
|
103
|
+
#
|
|
104
|
+
# @param value [Integer] Value to write
|
|
105
|
+
# @param num_bits [Integer] Number of bits to write
|
|
106
|
+
# @return [void]
|
|
107
|
+
def write_bits_be(value, num_bits)
|
|
108
|
+
num_bits.times do |i|
|
|
109
|
+
bit = (value >> (num_bits - 1 - i)) & 1
|
|
110
|
+
write_bits(bit, 1)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Write a 16-bit little-endian value
|
|
115
|
+
#
|
|
116
|
+
# @param value [Integer] 16-bit value
|
|
117
|
+
# @return [void]
|
|
118
|
+
def write_uint16_le(value)
|
|
119
|
+
write_bits(value & 0xFFFF, 16)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Write a 32-bit little-endian value
|
|
123
|
+
#
|
|
124
|
+
# @param value [Integer] 32-bit value
|
|
125
|
+
# @return [void]
|
|
126
|
+
def write_uint32_le(value)
|
|
127
|
+
write_bits(value & 0xFFFF, 16)
|
|
128
|
+
write_bits((value >> 16) & 0xFFFF, 16)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Write bits MSB-first (for Quantum compression)
|
|
132
|
+
# Accumulates bits and writes 16-bit words MSB-first
|
|
133
|
+
#
|
|
134
|
+
# @param value [Integer] Value to write
|
|
135
|
+
# @param num_bits [Integer] Number of bits to write
|
|
136
|
+
# @return [void]
|
|
137
|
+
def write_bits_msb(value, num_bits)
|
|
138
|
+
if num_bits < 1 || num_bits > 32
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"Can only write 1-32 bits at a time"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Add bits to buffer (MSB first)
|
|
144
|
+
@bit_buffer = (@bit_buffer << num_bits) | (value & ((1 << num_bits) - 1))
|
|
145
|
+
@bits_in_buffer += num_bits
|
|
146
|
+
|
|
147
|
+
# Flush complete 16-bit words MSB-first
|
|
148
|
+
while @bits_in_buffer >= 16
|
|
149
|
+
@bits_in_buffer -= 16
|
|
150
|
+
word = (@bit_buffer >> @bits_in_buffer) & 0xFFFF
|
|
151
|
+
# Write MSB first
|
|
152
|
+
write_byte((word >> 8) & 0xFF)
|
|
153
|
+
write_byte(word & 0xFF)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Flush MSB buffer (write remaining bits padded to 16-bit boundary)
|
|
158
|
+
#
|
|
159
|
+
# @return [void]
|
|
160
|
+
def flush_msb
|
|
161
|
+
return if @bits_in_buffer.zero?
|
|
162
|
+
|
|
163
|
+
# Pad to 16-bit boundary
|
|
164
|
+
padding = (16 - @bits_in_buffer) % 16
|
|
165
|
+
@bit_buffer <<= padding if padding.positive?
|
|
166
|
+
@bits_in_buffer += padding
|
|
167
|
+
|
|
168
|
+
# Write final 16-bit word
|
|
169
|
+
if @bits_in_buffer == 16
|
|
170
|
+
word = @bit_buffer & 0xFFFF
|
|
171
|
+
write_byte((word >> 8) & 0xFF)
|
|
172
|
+
write_byte(word & 0xFF)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
@bit_buffer = 0
|
|
176
|
+
@bits_in_buffer = 0
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
|
|
5
|
+
module Cabriolet
|
|
6
|
+
module Binary
|
|
7
|
+
# CHM ITSF Header (main file header)
|
|
8
|
+
class CHMITSFHeader < BinData::Record
|
|
9
|
+
endian :little
|
|
10
|
+
|
|
11
|
+
string :signature, length: 4 # 'ITSF'
|
|
12
|
+
uint32 :version
|
|
13
|
+
uint32 :header_len
|
|
14
|
+
uint32 :unknown1
|
|
15
|
+
uint32 :timestamp
|
|
16
|
+
uint32 :language_id
|
|
17
|
+
string :guid1, length: 16
|
|
18
|
+
string :guid2, length: 16
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# CHM Header Section Table
|
|
22
|
+
class CHMHeaderSectionTable < BinData::Record
|
|
23
|
+
endian :little
|
|
24
|
+
|
|
25
|
+
uint64 :offset_hs0
|
|
26
|
+
uint64 :length_hs0
|
|
27
|
+
uint64 :offset_hs1
|
|
28
|
+
uint64 :length_hs1
|
|
29
|
+
uint64 :offset_cs0 # Only in version 3+
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# CHM Header Section 0
|
|
33
|
+
class CHMHeaderSection0 < BinData::Record
|
|
34
|
+
endian :little
|
|
35
|
+
|
|
36
|
+
uint32 :unknown1
|
|
37
|
+
uint32 :unknown2
|
|
38
|
+
uint64 :file_len
|
|
39
|
+
uint32 :unknown3
|
|
40
|
+
uint32 :unknown4
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# CHM Header Section 1 (Directory header)
|
|
44
|
+
class CHMHeaderSection1 < BinData::Record
|
|
45
|
+
endian :little
|
|
46
|
+
|
|
47
|
+
string :signature, length: 4 # 'ITSP'
|
|
48
|
+
uint32 :version
|
|
49
|
+
uint32 :header_len
|
|
50
|
+
uint32 :unknown1
|
|
51
|
+
uint32 :chunk_size
|
|
52
|
+
uint32 :density
|
|
53
|
+
uint32 :depth
|
|
54
|
+
int32 :index_root
|
|
55
|
+
uint32 :first_pmgl
|
|
56
|
+
uint32 :last_pmgl
|
|
57
|
+
uint32 :unknown2
|
|
58
|
+
uint32 :num_chunks
|
|
59
|
+
uint32 :language_id
|
|
60
|
+
string :guid, length: 16
|
|
61
|
+
uint32 :unknown3
|
|
62
|
+
uint32 :unknown4
|
|
63
|
+
uint32 :unknown5
|
|
64
|
+
uint32 :unknown6
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# PMGL Chunk Header (directory listing chunk)
|
|
68
|
+
class PMGLChunkHeader < BinData::Record
|
|
69
|
+
endian :little
|
|
70
|
+
|
|
71
|
+
string :signature, length: 4 # 'PMGL'
|
|
72
|
+
uint32 :quickref_size
|
|
73
|
+
uint32 :unknown1
|
|
74
|
+
int32 :prev_chunk
|
|
75
|
+
int32 :next_chunk
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# PMGI Chunk Header (directory index chunk)
|
|
79
|
+
class PMGIChunkHeader < BinData::Record
|
|
80
|
+
endian :little
|
|
81
|
+
|
|
82
|
+
string :signature, length: 4 # 'PMGI'
|
|
83
|
+
uint32 :quickref_size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# LZX Control Data
|
|
87
|
+
class LZXControlData < BinData::Record
|
|
88
|
+
endian :little
|
|
89
|
+
|
|
90
|
+
uint32 :len
|
|
91
|
+
string :signature, length: 4 # 'LZXC'
|
|
92
|
+
uint32 :version
|
|
93
|
+
uint32 :reset_interval
|
|
94
|
+
uint32 :window_size
|
|
95
|
+
uint32 :cache_size
|
|
96
|
+
uint32 :unknown1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# LZX Reset Table Header
|
|
100
|
+
class LZXResetTableHeader < BinData::Record
|
|
101
|
+
endian :little
|
|
102
|
+
|
|
103
|
+
uint32 :unknown1
|
|
104
|
+
uint32 :num_entries
|
|
105
|
+
uint32 :entry_size
|
|
106
|
+
uint32 :table_offset
|
|
107
|
+
uint64 :uncomp_len
|
|
108
|
+
uint64 :comp_len
|
|
109
|
+
uint64 :frame_len
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Helper class for reading ENCINT (variable-length integers)
|
|
113
|
+
class ENCINTReader
|
|
114
|
+
# Read an ENCINT from an IO stream
|
|
115
|
+
# Returns the integer value
|
|
116
|
+
def self.read(io)
|
|
117
|
+
result = 0
|
|
118
|
+
byte = 0x80
|
|
119
|
+
bytes_read = 0
|
|
120
|
+
max_bytes = 9 # 63 bits max
|
|
121
|
+
|
|
122
|
+
while byte.anybits?(0x80) && bytes_read < max_bytes
|
|
123
|
+
byte_data = io.read(1)
|
|
124
|
+
if byte_data.nil?
|
|
125
|
+
raise Cabriolet::FormatError,
|
|
126
|
+
"Unexpected end of ENCINT"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
byte = byte_data.unpack1("C")
|
|
130
|
+
result = (result << 7) | (byte & 0x7F)
|
|
131
|
+
bytes_read += 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if bytes_read == max_bytes && byte.anybits?(0x80)
|
|
135
|
+
raise Cabriolet::FormatError,
|
|
136
|
+
"ENCINT too large"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Read an ENCINT from a string at a given position
|
|
143
|
+
# Returns [value, new_position]
|
|
144
|
+
def self.read_from_string(str, pos)
|
|
145
|
+
result = 0
|
|
146
|
+
byte = 0x80
|
|
147
|
+
bytes_read = 0
|
|
148
|
+
max_bytes = 9
|
|
149
|
+
|
|
150
|
+
while byte.anybits?(0x80) && bytes_read < max_bytes
|
|
151
|
+
if pos >= str.length
|
|
152
|
+
raise Cabriolet::FormatError,
|
|
153
|
+
"ENCINT beyond string"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
byte = str.getbyte(pos)
|
|
157
|
+
pos += 1
|
|
158
|
+
result = (result << 7) | (byte & 0x7F)
|
|
159
|
+
bytes_read += 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if bytes_read == max_bytes && byte.anybits?(0x80)
|
|
163
|
+
raise Cabriolet::FormatError,
|
|
164
|
+
"ENCINT too large"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[result, pos]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Helper class for writing ENCINT (variable-length integers)
|
|
172
|
+
class ENCINTWriter
|
|
173
|
+
# Write an ENCINT to an IO stream
|
|
174
|
+
# @param io [IO] IO object to write to
|
|
175
|
+
# @param value [Integer] Value to encode
|
|
176
|
+
# @return [Integer] Number of bytes written
|
|
177
|
+
def self.write(io, value)
|
|
178
|
+
bytes = encode(value)
|
|
179
|
+
io.write(bytes)
|
|
180
|
+
bytes.bytesize
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Encode an integer as ENCINT bytes
|
|
184
|
+
# @param value [Integer] Value to encode (must be non-negative)
|
|
185
|
+
# @return [String] Encoded bytes
|
|
186
|
+
def self.encode(value)
|
|
187
|
+
if value.negative?
|
|
188
|
+
raise ArgumentError,
|
|
189
|
+
"ENCINT value must be non-negative"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Special case: zero
|
|
193
|
+
return "\x00".b if value.zero?
|
|
194
|
+
|
|
195
|
+
bytes = []
|
|
196
|
+
|
|
197
|
+
# Encode 7 bits at a time
|
|
198
|
+
while value.positive?
|
|
199
|
+
byte = value & 0x7F
|
|
200
|
+
value >>= 7
|
|
201
|
+
bytes.unshift(byte)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Set high bit on all but last byte
|
|
205
|
+
(0...(bytes.length - 1)).each do |i|
|
|
206
|
+
bytes[i] |= 0x80
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
bytes.pack("C*")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|