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,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