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,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module SZDD
|
|
5
|
+
# Compressor creates SZDD compressed files
|
|
6
|
+
#
|
|
7
|
+
# SZDD files wrap LZSS-compressed data with a header containing metadata
|
|
8
|
+
# about the original file. The compressor supports both NORMAL (used by
|
|
9
|
+
# MS-DOS EXPAND.EXE) and QBASIC formats.
|
|
10
|
+
class Compressor
|
|
11
|
+
attr_reader :io_system
|
|
12
|
+
|
|
13
|
+
# Initialize a new SZDD compressor
|
|
14
|
+
#
|
|
15
|
+
# @param io_system [System::IOSystem, nil] Custom I/O system or nil for
|
|
16
|
+
# default
|
|
17
|
+
def initialize(io_system = nil)
|
|
18
|
+
@io_system = io_system || System::IOSystem.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Compress a file to SZDD format
|
|
22
|
+
#
|
|
23
|
+
# @param input_file [String] Path to input file
|
|
24
|
+
# @param output_file [String] Path to output SZDD file
|
|
25
|
+
# @param options [Hash] Compression options
|
|
26
|
+
# @option options [String] :missing_char Last character of original
|
|
27
|
+
# filename for reconstruction
|
|
28
|
+
# @option options [Symbol] :format Format to use (:normal or :qbasic,
|
|
29
|
+
# default: :normal)
|
|
30
|
+
# @return [Integer] Bytes written to output file
|
|
31
|
+
# @raise [Errors::CompressionError] if compression fails
|
|
32
|
+
def compress(input_file, output_file, **options)
|
|
33
|
+
format = options.fetch(:format, :normal)
|
|
34
|
+
missing_char = options[:missing_char]
|
|
35
|
+
|
|
36
|
+
validate_format(format)
|
|
37
|
+
validate_missing_char(missing_char) if missing_char
|
|
38
|
+
|
|
39
|
+
input_handle = @io_system.open(input_file, Constants::MODE_READ)
|
|
40
|
+
output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
# Get input size
|
|
44
|
+
input_size = @io_system.seek(input_handle, 0, Constants::SEEK_END)
|
|
45
|
+
@io_system.seek(input_handle, 0, Constants::SEEK_START)
|
|
46
|
+
|
|
47
|
+
# Write header
|
|
48
|
+
header_bytes = write_header(
|
|
49
|
+
output_handle,
|
|
50
|
+
format,
|
|
51
|
+
input_size,
|
|
52
|
+
missing_char,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Compress data using LZSS
|
|
56
|
+
lzss_mode = if format == :normal
|
|
57
|
+
Compressors::LZSS::MODE_EXPAND
|
|
58
|
+
else
|
|
59
|
+
Compressors::LZSS::MODE_QBASIC
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
compressor = Compressors::LZSS.new(
|
|
63
|
+
@io_system,
|
|
64
|
+
input_handle,
|
|
65
|
+
output_handle,
|
|
66
|
+
2048,
|
|
67
|
+
lzss_mode,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
compressed_bytes = compressor.compress
|
|
71
|
+
|
|
72
|
+
header_bytes + compressed_bytes
|
|
73
|
+
ensure
|
|
74
|
+
@io_system.close(input_handle) if input_handle
|
|
75
|
+
@io_system.close(output_handle) if output_handle
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Compress data from memory to SZDD format
|
|
80
|
+
#
|
|
81
|
+
# @param data [String] Input data to compress
|
|
82
|
+
# @param output_file [String] Path to output SZDD file
|
|
83
|
+
# @param options [Hash] Compression options
|
|
84
|
+
# @option options [String] :missing_char Last character of original
|
|
85
|
+
# filename
|
|
86
|
+
# @option options [Symbol] :format Format to use (:normal or :qbasic,
|
|
87
|
+
# default: :normal)
|
|
88
|
+
# @return [Integer] Bytes written to output file
|
|
89
|
+
# @raise [Errors::CompressionError] if compression fails
|
|
90
|
+
def compress_data(data, output_file, **options)
|
|
91
|
+
format = options.fetch(:format, :normal)
|
|
92
|
+
missing_char = options[:missing_char]
|
|
93
|
+
|
|
94
|
+
validate_format(format)
|
|
95
|
+
validate_missing_char(missing_char) if missing_char
|
|
96
|
+
|
|
97
|
+
input_handle = System::MemoryHandle.new(data)
|
|
98
|
+
output_handle = @io_system.open(output_file, Constants::MODE_WRITE)
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
# Write header
|
|
102
|
+
header_bytes = write_header(
|
|
103
|
+
output_handle,
|
|
104
|
+
format,
|
|
105
|
+
data.bytesize,
|
|
106
|
+
missing_char,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Compress data using LZSS
|
|
110
|
+
lzss_mode = if format == :normal
|
|
111
|
+
Compressors::LZSS::MODE_EXPAND
|
|
112
|
+
else
|
|
113
|
+
Compressors::LZSS::MODE_QBASIC
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
compressor = Compressors::LZSS.new(
|
|
117
|
+
@io_system,
|
|
118
|
+
input_handle,
|
|
119
|
+
output_handle,
|
|
120
|
+
2048,
|
|
121
|
+
lzss_mode,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
compressed_bytes = compressor.compress
|
|
125
|
+
|
|
126
|
+
header_bytes + compressed_bytes
|
|
127
|
+
ensure
|
|
128
|
+
@io_system.close(output_handle) if output_handle
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Write SZDD header to output
|
|
135
|
+
#
|
|
136
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
137
|
+
# @param format [Symbol] Format to use (:normal or :qbasic)
|
|
138
|
+
# @param uncompressed_size [Integer] Size of uncompressed data
|
|
139
|
+
# @param missing_char [String, nil] Missing character or nil
|
|
140
|
+
# @return [Integer] Number of bytes written
|
|
141
|
+
def write_header(output_handle, format, uncompressed_size, missing_char)
|
|
142
|
+
if format == :normal
|
|
143
|
+
write_normal_header(output_handle, uncompressed_size, missing_char)
|
|
144
|
+
else
|
|
145
|
+
write_qbasic_header(output_handle, uncompressed_size)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Write NORMAL format header
|
|
150
|
+
#
|
|
151
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
152
|
+
# @param uncompressed_size [Integer] Size of uncompressed data
|
|
153
|
+
# @param missing_char [String, nil] Missing character or nil
|
|
154
|
+
# @return [Integer] Number of bytes written (14 bytes)
|
|
155
|
+
def write_normal_header(output_handle, uncompressed_size, missing_char)
|
|
156
|
+
header = Binary::SZDDStructures::NormalHeader.new
|
|
157
|
+
header.signature = Binary::SZDDStructures::SIGNATURE_NORMAL
|
|
158
|
+
header.compression_mode = 0x41 # 'A'
|
|
159
|
+
header.missing_char = missing_char ? missing_char.ord : 0x00
|
|
160
|
+
header.uncompressed_size = uncompressed_size
|
|
161
|
+
|
|
162
|
+
header_data = header.to_binary_s
|
|
163
|
+
written = @io_system.write(output_handle, header_data)
|
|
164
|
+
|
|
165
|
+
unless written == header_data.bytesize
|
|
166
|
+
raise Errors::CompressionError,
|
|
167
|
+
"Failed to write SZDD header"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
written
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Write QBASIC format header
|
|
174
|
+
#
|
|
175
|
+
# @param output_handle [System::FileHandle] Output file handle
|
|
176
|
+
# @param uncompressed_size [Integer] Size of uncompressed data
|
|
177
|
+
# @return [Integer] Number of bytes written (12 bytes)
|
|
178
|
+
def write_qbasic_header(output_handle, uncompressed_size)
|
|
179
|
+
header = Binary::SZDDStructures::QBasicHeader.new
|
|
180
|
+
header.signature = Binary::SZDDStructures::SIGNATURE_QBASIC
|
|
181
|
+
header.uncompressed_size = uncompressed_size
|
|
182
|
+
|
|
183
|
+
header_data = header.to_binary_s
|
|
184
|
+
written = @io_system.write(output_handle, header_data)
|
|
185
|
+
|
|
186
|
+
unless written == header_data.bytesize
|
|
187
|
+
raise Errors::CompressionError,
|
|
188
|
+
"Failed to write SZDD header"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
written
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Validate format parameter
|
|
195
|
+
#
|
|
196
|
+
# @param format [Symbol] Format to validate
|
|
197
|
+
# @raise [ArgumentError] if format is invalid
|
|
198
|
+
def validate_format(format)
|
|
199
|
+
return if %i[normal qbasic].include?(format)
|
|
200
|
+
|
|
201
|
+
raise ArgumentError,
|
|
202
|
+
"Format must be :normal or :qbasic, got #{format.inspect}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Validate missing character parameter
|
|
206
|
+
#
|
|
207
|
+
# @param missing_char [String] Missing character to validate
|
|
208
|
+
# @raise [ArgumentError] if missing_char is invalid
|
|
209
|
+
def validate_missing_char(missing_char)
|
|
210
|
+
return if missing_char.is_a?(String) && missing_char.length == 1
|
|
211
|
+
|
|
212
|
+
raise ArgumentError,
|
|
213
|
+
"Missing character must be a single character string, got #{missing_char.inspect}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module SZDD
|
|
5
|
+
# Decompressor is the main interface for SZDD file operations
|
|
6
|
+
#
|
|
7
|
+
# SZDD files use LZSS compression and are decompressed using the
|
|
8
|
+
# Decompressors::LZSS class with appropriate mode settings.
|
|
9
|
+
class Decompressor
|
|
10
|
+
attr_reader :io_system, :parser
|
|
11
|
+
attr_accessor :buffer_size
|
|
12
|
+
|
|
13
|
+
# Input buffer size for decompression
|
|
14
|
+
DEFAULT_BUFFER_SIZE = 2048
|
|
15
|
+
|
|
16
|
+
# Initialize a new SZDD decompressor
|
|
17
|
+
#
|
|
18
|
+
# @param io_system [System::IOSystem, nil] Custom I/O system or nil for
|
|
19
|
+
# default
|
|
20
|
+
def initialize(io_system = nil)
|
|
21
|
+
@io_system = io_system || System::IOSystem.new
|
|
22
|
+
@parser = Parser.new(@io_system)
|
|
23
|
+
@buffer_size = DEFAULT_BUFFER_SIZE
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Open and parse an SZDD file
|
|
27
|
+
#
|
|
28
|
+
# @param filename [String] Path to the SZDD file
|
|
29
|
+
# @return [Models::SZDDHeader] Parsed header with file handle
|
|
30
|
+
# @raise [Errors::ParseError] if the file is not a valid SZDD
|
|
31
|
+
def open(filename)
|
|
32
|
+
header = @parser.parse(filename)
|
|
33
|
+
header.filename = filename
|
|
34
|
+
header
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Close an SZDD file (no-op for compatibility)
|
|
38
|
+
#
|
|
39
|
+
# @param _header [Models::SZDDHeader] Header to close
|
|
40
|
+
# @return [void]
|
|
41
|
+
def close(_header)
|
|
42
|
+
# No resources to free in the header itself
|
|
43
|
+
# File handles are managed separately during extraction
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Extract an SZDD file to output
|
|
48
|
+
#
|
|
49
|
+
# @param header [Models::SZDDHeader] SZDD header from open()
|
|
50
|
+
# @param output_path [String] Where to write the decompressed file
|
|
51
|
+
# @return [Integer] Number of bytes written
|
|
52
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
53
|
+
def extract(header, output_path)
|
|
54
|
+
raise ArgumentError, "Header must not be nil" unless header
|
|
55
|
+
raise ArgumentError, "Output path must not be nil" unless output_path
|
|
56
|
+
|
|
57
|
+
input_handle = @io_system.open(header.filename, Constants::MODE_READ)
|
|
58
|
+
output_handle = @io_system.open(output_path, Constants::MODE_WRITE)
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
# Seek to compressed data start
|
|
62
|
+
data_offset = @parser.data_offset(header.format)
|
|
63
|
+
@io_system.seek(input_handle, data_offset, Constants::SEEK_START)
|
|
64
|
+
|
|
65
|
+
# Determine LZSS mode based on format
|
|
66
|
+
lzss_mode = if header.normal_format?
|
|
67
|
+
Decompressors::LZSS::MODE_EXPAND
|
|
68
|
+
else
|
|
69
|
+
Decompressors::LZSS::MODE_QBASIC
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Create LZSS decompressor
|
|
73
|
+
decompressor = Decompressors::LZSS.new(
|
|
74
|
+
@io_system,
|
|
75
|
+
input_handle,
|
|
76
|
+
output_handle,
|
|
77
|
+
@buffer_size,
|
|
78
|
+
lzss_mode,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Decompress
|
|
82
|
+
bytes_written = decompressor.decompress(header.length)
|
|
83
|
+
|
|
84
|
+
# Verify decompressed size matches expected
|
|
85
|
+
if bytes_written != header.length && Cabriolet.verbose && Cabriolet.verbose
|
|
86
|
+
warn "[Cabriolet] WARNING; decompressed #{bytes_written} bytes, " \
|
|
87
|
+
"expected #{header.length} bytes"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
bytes_written
|
|
91
|
+
ensure
|
|
92
|
+
@io_system.close(input_handle) if input_handle
|
|
93
|
+
@io_system.close(output_handle) if output_handle
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Extract SZDD file to memory
|
|
98
|
+
#
|
|
99
|
+
# @param header [Models::SZDDHeader] SZDD header from open()
|
|
100
|
+
# @return [String] Decompressed data
|
|
101
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
102
|
+
def extract_to_memory(header)
|
|
103
|
+
raise ArgumentError, "Header must not be nil" unless header
|
|
104
|
+
|
|
105
|
+
input_handle = @io_system.open(header.filename, Constants::MODE_READ)
|
|
106
|
+
output_handle = System::MemoryHandle.new("", Constants::MODE_WRITE)
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
# Seek to compressed data start
|
|
110
|
+
data_offset = @parser.data_offset(header.format)
|
|
111
|
+
@io_system.seek(input_handle, data_offset, Constants::SEEK_START)
|
|
112
|
+
|
|
113
|
+
# Determine LZSS mode based on format
|
|
114
|
+
lzss_mode = if header.normal_format?
|
|
115
|
+
Decompressors::LZSS::MODE_EXPAND
|
|
116
|
+
else
|
|
117
|
+
Decompressors::LZSS::MODE_QBASIC
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Create LZSS decompressor
|
|
121
|
+
decompressor = Decompressors::LZSS.new(
|
|
122
|
+
@io_system,
|
|
123
|
+
input_handle,
|
|
124
|
+
output_handle,
|
|
125
|
+
@buffer_size,
|
|
126
|
+
lzss_mode,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Decompress
|
|
130
|
+
decompressor.decompress(header.length)
|
|
131
|
+
|
|
132
|
+
# Return the decompressed data
|
|
133
|
+
output_handle.data
|
|
134
|
+
ensure
|
|
135
|
+
@io_system.close(input_handle) if input_handle
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# One-shot decompression from input file to output file
|
|
140
|
+
#
|
|
141
|
+
# This method combines open(), extract(), and close() for convenience.
|
|
142
|
+
# Similar to MS-DOS EXPAND.EXE behavior.
|
|
143
|
+
#
|
|
144
|
+
# @param input_path [String] Path to compressed SZDD file
|
|
145
|
+
# @param output_path [String, nil] Path to output file, or nil to
|
|
146
|
+
# auto-detect
|
|
147
|
+
# @return [Integer] Number of bytes written
|
|
148
|
+
# @raise [Errors::ParseError] if input is not valid SZDD
|
|
149
|
+
# @raise [Errors::DecompressionError] if decompression fails
|
|
150
|
+
def decompress(input_path, output_path = nil)
|
|
151
|
+
# Parse header
|
|
152
|
+
header = self.open(input_path)
|
|
153
|
+
|
|
154
|
+
# Auto-detect output filename if not provided
|
|
155
|
+
output_path ||= auto_output_filename(input_path, header)
|
|
156
|
+
|
|
157
|
+
# Extract
|
|
158
|
+
bytes_written = extract(header, output_path)
|
|
159
|
+
|
|
160
|
+
# Close (no-op but kept for API consistency)
|
|
161
|
+
close(header)
|
|
162
|
+
|
|
163
|
+
bytes_written
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Generate output filename from input filename and header
|
|
167
|
+
#
|
|
168
|
+
# @param input_path [String] Input file path
|
|
169
|
+
# @param header [Models::SZDDHeader] SZDD header
|
|
170
|
+
# @return [String] Suggested output filename
|
|
171
|
+
def auto_output_filename(input_path, header)
|
|
172
|
+
# Get base filename without directory
|
|
173
|
+
base = ::File.basename(input_path)
|
|
174
|
+
|
|
175
|
+
# Use header's suggested filename method
|
|
176
|
+
suggested = header.suggested_filename(base)
|
|
177
|
+
|
|
178
|
+
# Combine with original directory
|
|
179
|
+
dir = ::File.dirname(input_path)
|
|
180
|
+
::File.join(dir, suggested)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cabriolet
|
|
4
|
+
module SZDD
|
|
5
|
+
# Parser reads and parses SZDD file headers
|
|
6
|
+
#
|
|
7
|
+
# SZDD files are single-file compressed archives using LZSS compression.
|
|
8
|
+
# There are two format variants:
|
|
9
|
+
# - NORMAL: Used by MS-DOS EXPAND.EXE (signature: SZDD\x88\xF0\x27\x33)
|
|
10
|
+
# - QBASIC: Used by QBasic (signature: SZDD \x88\xF0\x27\x33\xD1)
|
|
11
|
+
class Parser
|
|
12
|
+
attr_reader :io_system
|
|
13
|
+
|
|
14
|
+
# Expected compression mode for NORMAL format
|
|
15
|
+
COMPRESSION_MODE_NORMAL = 0x41
|
|
16
|
+
|
|
17
|
+
# Initialize a new parser
|
|
18
|
+
#
|
|
19
|
+
# @param io_system [System::IOSystem] I/O system for reading
|
|
20
|
+
def initialize(io_system)
|
|
21
|
+
@io_system = io_system
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Parse an SZDD file and return header information
|
|
25
|
+
#
|
|
26
|
+
# @param filename [String] Path to the SZDD file
|
|
27
|
+
# @return [Models::SZDDHeader] Parsed header
|
|
28
|
+
# @raise [Errors::ParseError] if the file is not a valid SZDD
|
|
29
|
+
def parse(filename)
|
|
30
|
+
handle = @io_system.open(filename, Constants::MODE_READ)
|
|
31
|
+
header = parse_handle(handle, filename)
|
|
32
|
+
@io_system.close(handle)
|
|
33
|
+
header
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Parse SZDD header from an already-open handle
|
|
37
|
+
#
|
|
38
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Open handle
|
|
39
|
+
# @param filename [String] Filename for reference
|
|
40
|
+
# @return [Models::SZDDHeader] Parsed header
|
|
41
|
+
# @raise [Errors::ParseError] if not a valid SZDD
|
|
42
|
+
def parse_handle(handle, filename = nil)
|
|
43
|
+
# Read signature (8 bytes)
|
|
44
|
+
signature = @io_system.read(handle, 8)
|
|
45
|
+
raise ParseError, "Cannot read SZDD signature" if
|
|
46
|
+
signature.bytesize < 8
|
|
47
|
+
|
|
48
|
+
# Determine format based on signature
|
|
49
|
+
if signature == Binary::SZDDStructures::SIGNATURE_NORMAL
|
|
50
|
+
parse_normal_header(handle, filename)
|
|
51
|
+
elsif signature == Binary::SZDDStructures::SIGNATURE_QBASIC
|
|
52
|
+
parse_qbasic_header(handle, filename)
|
|
53
|
+
else
|
|
54
|
+
raise ParseError, "Invalid SZDD signature"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get the data offset for the compressed data
|
|
59
|
+
#
|
|
60
|
+
# @param format [Symbol] Format type (:normal or :qbasic)
|
|
61
|
+
# @return [Integer] Offset in bytes where compressed data starts
|
|
62
|
+
def data_offset(format)
|
|
63
|
+
format == Models::SZDDHeader::FORMAT_NORMAL ? 14 : 12
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Parse NORMAL format SZDD header (EXPAND.EXE)
|
|
69
|
+
#
|
|
70
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Open handle
|
|
71
|
+
# @param filename [String, nil] Filename for reference
|
|
72
|
+
# @return [Models::SZDDHeader] Parsed header
|
|
73
|
+
# @raise [Errors::ParseError] if header is invalid
|
|
74
|
+
def parse_normal_header(handle, filename)
|
|
75
|
+
# Read remaining header fields (6 bytes)
|
|
76
|
+
# - 1 byte: compression mode (should be 0x41)
|
|
77
|
+
# - 1 byte: missing character
|
|
78
|
+
# - 4 bytes: uncompressed size (little-endian)
|
|
79
|
+
header_data = @io_system.read(handle, 6)
|
|
80
|
+
raise ParseError, "Cannot read SZDD header" if
|
|
81
|
+
header_data.bytesize < 6
|
|
82
|
+
|
|
83
|
+
compression_mode = header_data[0].ord
|
|
84
|
+
missing_char = header_data[1].chr
|
|
85
|
+
uncompressed_size = header_data[2..5].unpack1("V") # Little-endian uint32
|
|
86
|
+
|
|
87
|
+
# Validate compression mode
|
|
88
|
+
unless compression_mode == COMPRESSION_MODE_NORMAL
|
|
89
|
+
raise ParseError,
|
|
90
|
+
"Invalid compression mode: #{compression_mode}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Create header model
|
|
94
|
+
Models::SZDDHeader.new(
|
|
95
|
+
format: Models::SZDDHeader::FORMAT_NORMAL,
|
|
96
|
+
length: uncompressed_size,
|
|
97
|
+
missing_char: missing_char,
|
|
98
|
+
filename: filename,
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Parse QBASIC format SZDD header
|
|
103
|
+
#
|
|
104
|
+
# @param handle [System::FileHandle, System::MemoryHandle] Open handle
|
|
105
|
+
# @param filename [String, nil] Filename for reference
|
|
106
|
+
# @return [Models::SZDDHeader] Parsed header
|
|
107
|
+
# @raise [Errors::ParseError] if header is invalid
|
|
108
|
+
def parse_qbasic_header(handle, filename)
|
|
109
|
+
# Read remaining header fields (4 bytes)
|
|
110
|
+
# - 4 bytes: uncompressed size (little-endian)
|
|
111
|
+
header_data = @io_system.read(handle, 4)
|
|
112
|
+
raise ParseError, "Cannot read SZDD header" if
|
|
113
|
+
header_data.bytesize < 4
|
|
114
|
+
|
|
115
|
+
uncompressed_size = header_data.unpack1("V") # Little-endian uint32
|
|
116
|
+
|
|
117
|
+
# Create header model (no missing character in QBASIC format)
|
|
118
|
+
Models::SZDDHeader.new(
|
|
119
|
+
format: Models::SZDDHeader::FORMAT_QBASIC,
|
|
120
|
+
length: uncompressed_size,
|
|
121
|
+
missing_char: nil,
|
|
122
|
+
filename: filename,
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|