smp_tool 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +13 -0
  3. data/.vscode/launch.json +21 -0
  4. data/CHANGELOG.md +3 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +109 -0
  7. data/Rakefile +16 -0
  8. data/lib/smp_tool/autoloader.rb +24 -0
  9. data/lib/smp_tool/basic_10.rb +95 -0
  10. data/lib/smp_tool/basic_20.rb +96 -0
  11. data/lib/smp_tool/filename.rb +71 -0
  12. data/lib/smp_tool/version.rb +5 -0
  13. data/lib/smp_tool/virtual_volume/data_entry.rb +47 -0
  14. data/lib/smp_tool/virtual_volume/data_entry_header.rb +85 -0
  15. data/lib/smp_tool/virtual_volume/file_interface.rb +33 -0
  16. data/lib/smp_tool/virtual_volume/utils/converter_from_volume_io.rb +81 -0
  17. data/lib/smp_tool/virtual_volume/utils/converter_to_volume_io.rb +125 -0
  18. data/lib/smp_tool/virtual_volume/utils/empty_vol_data_initializer.rb +32 -0
  19. data/lib/smp_tool/virtual_volume/utils/file_converter.rb +55 -0
  20. data/lib/smp_tool/virtual_volume/utils/file_extracter.rb +90 -0
  21. data/lib/smp_tool/virtual_volume/utils/volume_params_validator.rb +20 -0
  22. data/lib/smp_tool/virtual_volume/utils.rb +10 -0
  23. data/lib/smp_tool/virtual_volume/volume.rb +249 -0
  24. data/lib/smp_tool/virtual_volume/volume_data.rb +216 -0
  25. data/lib/smp_tool/virtual_volume/volume_params.rb +70 -0
  26. data/lib/smp_tool/virtual_volume/volume_params_contract.rb +53 -0
  27. data/lib/smp_tool/virtual_volume.rb +8 -0
  28. data/lib/smp_tool/volume_io/bootloader.rb +18 -0
  29. data/lib/smp_tool/volume_io/data.rb +14 -0
  30. data/lib/smp_tool/volume_io/dir_entry.rb +45 -0
  31. data/lib/smp_tool/volume_io/dir_seg.rb +33 -0
  32. data/lib/smp_tool/volume_io/dir_seg_header.rb +23 -0
  33. data/lib/smp_tool/volume_io/directory.rb +26 -0
  34. data/lib/smp_tool/volume_io/file_content.rb +13 -0
  35. data/lib/smp_tool/volume_io/home_block.rb +18 -0
  36. data/lib/smp_tool/volume_io/volume_io.rb +53 -0
  37. data/lib/smp_tool/volume_io.rb +10 -0
  38. data/lib/smp_tool.rb +54 -0
  39. data/sig/smp_tool.rbs +4 -0
  40. data/smp_tool.gemspec +40 -0
  41. metadata +206 -0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ module Utils
6
+ #
7
+ # Converts volume IO to the virtual volume.
8
+ #
9
+ module ConverterFromVolumeIO
10
+ class << self
11
+ private
12
+
13
+ def _volume_data(entries, data, extra_word)
14
+ VolumeData.new(
15
+ _data_entries(entries, data),
16
+ extra_word
17
+ )
18
+ end
19
+
20
+ def _data_entries(entries, data)
21
+ entries.each_with_index.map do |e, i|
22
+ DataEntry.new(
23
+ header: DataEntryHeader.new(e.snapshot.to_h),
24
+ data: data[i]
25
+ )
26
+ end
27
+ end
28
+
29
+ # Extra word value is defined by the target BASIC version,
30
+ # and the target BASIC version can be identified by the
31
+ # number of extra bytes per entry (and vice versa).
32
+ def _choose_extra_word(n_extra_bytes_per_entry)
33
+ case n_extra_bytes_per_entry
34
+ when 0
35
+ Basic10::ENTRY_EXTRA_WORD
36
+ else
37
+ Basic20::ENTRY_EXTRA_WORD
38
+ end
39
+ end
40
+
41
+ def _build_volume_params(volume_io)
42
+ VirtualVolume::VolumeParams.new(
43
+ n_clusters_allocated: volume_io.n_clusters_allocated.to_i,
44
+ n_extra_bytes_per_entry: volume_io.n_extra_bytes_per_entry.to_i,
45
+ n_dir_segs: volume_io.n_dir_segs.to_i,
46
+ n_clusters_per_dir_seg: volume_io.n_clusters_per_dir_seg.to_i,
47
+ extra_word: _choose_extra_word(volume_io.n_extra_bytes_per_entry)
48
+ )
49
+ end
50
+
51
+ def _read_entries(volume_io)
52
+ volume_io.directory.segments.to_ary.flat_map(&:dir_seg_entries)
53
+ .reject { |e| e.status == DIR_SEG_FOOTER }
54
+ end
55
+ end
56
+
57
+ def self.read_io(io)
58
+ read_volume_io(
59
+ SMPTool::VolumeIO::VolumeIO.read(io)
60
+ )
61
+ end
62
+
63
+ def self.read_volume_io(volume_io)
64
+ entries = _read_entries(volume_io)
65
+ data = volume_io.data.to_ary
66
+
67
+ raise ArgumentError, "entries => data lengths mismatch" unless entries.length == data.length
68
+
69
+ volume_params = _build_volume_params(volume_io)
70
+
71
+ VirtualVolume::Volume.new(
72
+ bootloader: volume_io.bootloader.bytes.to_ary,
73
+ home_block: volume_io.home_block.bytes.to_ary,
74
+ volume_params: volume_params,
75
+ volume_data: _volume_data(entries, data, volume_params.extra_word)
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ module Utils
6
+ #
7
+ # Converts virtual volume to the volume IO.
8
+ #
9
+ class ConverterToVolumeIO
10
+ def initialize(volume)
11
+ @bootloader = volume.bootloader
12
+ @home_block = volume.home_block
13
+ @n_clusters_allocated = volume.n_clusters_allocated
14
+ @n_extra_bytes_per_entry = volume.n_extra_bytes_per_entry
15
+ @n_clusters_per_dir_seg = volume.n_clusters_per_dir_seg
16
+ @n_max_entries_per_dir_seg = volume.n_max_entries_per_dir_seg
17
+ @n_dir_segs = volume.n_dir_segs
18
+
19
+ @data = _group_entries(volume.data)
20
+
21
+ @data_offset = N_SYS_CLUSTERS + @n_dir_segs * @n_clusters_per_dir_seg
22
+ end
23
+
24
+ def call
25
+ VolumeIO::VolumeIO.new(
26
+ bootloader: _build_bootloader,
27
+ home_block: _build_home_block,
28
+ directory: _build_directory,
29
+ data: _build_data,
30
+ n_clusters_allocated: @n_clusters_allocated
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def _build_data
37
+ @data.flatten.map do |e|
38
+ VolumeIO::FileContent.new(
39
+ e.data
40
+ )
41
+ end
42
+ end
43
+
44
+ def _group_entries(arr)
45
+ result = arr.each_slice(@n_max_entries_per_dir_seg).to_a
46
+ diff = @n_dir_segs - result.length
47
+ diff.times { result << [] }
48
+ result
49
+ end
50
+
51
+ def _build_bootloader
52
+ VolumeIO::Bootloader.new(bytes: @bootloader)
53
+ end
54
+
55
+ def _build_home_block
56
+ VolumeIO::HomeBlock.new(bytes: @home_block)
57
+ end
58
+
59
+ def _build_directory
60
+ segments = (1..@n_dir_segs).map do |i_dir_seg|
61
+ _build_dir_seg(i_dir_seg, _build_dir_seg_entries(i_dir_seg))
62
+ end
63
+
64
+ VolumeIO::Directory.new(
65
+ segments: segments
66
+ )
67
+ end
68
+
69
+ def _build_dir_seg_entries(i_dir_seg)
70
+ @data[i_dir_seg - 1]
71
+ .map { |e| _build_dir_entry(e) }
72
+ .push(_build_dir_seg_footer)
73
+ end
74
+
75
+ def _build_dir_entry(v_entry)
76
+ VolumeIO::DirEntry.new(
77
+ status: v_entry.status,
78
+ filename: v_entry.filename,
79
+ n_clusters: v_entry.n_clusters,
80
+ ch_job: v_entry.ch_job,
81
+ date: v_entry.date,
82
+ extra_word: v_entry.extra_word
83
+ )
84
+ end
85
+
86
+ def _build_dir_seg(i_dir_seg, dir_seg_entries)
87
+ i_next_seg = i_dir_seg == @n_dir_segs ? 0 : i_dir_seg + 1
88
+
89
+ dir_seg = VolumeIO::DirSeg.new(
90
+ header: _build_header(
91
+ i_next_seg: i_next_seg,
92
+ data_offset: @data_offset
93
+ ),
94
+ dir_seg_entries: dir_seg_entries
95
+ )
96
+
97
+ _upd_data_offset(i_dir_seg)
98
+
99
+ dir_seg
100
+ end
101
+
102
+ def _upd_data_offset(i_dir_seg)
103
+ n_clustes_in_files = @data[i_dir_seg - 1].sum(&:n_clusters)
104
+
105
+ @data_offset += n_clustes_in_files
106
+ end
107
+
108
+ def _build_header(i_next_seg:, data_offset:)
109
+ VolumeIO::DirSegHeader.new(
110
+ n_dir_segs: @n_dir_segs,
111
+ i_next_seg: i_next_seg,
112
+ n_extra_bytes_per_entry: @n_extra_bytes_per_entry,
113
+ data_offset: data_offset
114
+ )
115
+ end
116
+
117
+ def _build_dir_seg_footer
118
+ VolumeIO::DirEntry.new(
119
+ status: DIR_SEG_FOOTER
120
+ )
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ module Utils
6
+ #
7
+ # Initializes empty data for a volume with the given params.
8
+ #
9
+ module EmptyVolDataInitializer
10
+ #
11
+ # Initialize empty data.
12
+ #
13
+ # @param [Hash{ Symbol => Object }] volume_params
14
+ #
15
+ # @return [VolumeData]
16
+ #
17
+ def self.call(volume_params)
18
+ n_data_clusters = volume_params.n_clusters_allocated -
19
+ N_SYS_CLUSTERS -
20
+ (volume_params.n_dir_segs * volume_params.n_clusters_per_dir_seg)
21
+
22
+ data = VolumeData.new(
23
+ [],
24
+ volume_params.extra_word
25
+ )
26
+
27
+ data.push_empty_entry(n_data_clusters)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ module Utils
6
+ #
7
+ # Converts hashes to data entry objects.
8
+ #
9
+ class FileConverter
10
+ def initialize(f_hash, extra_word, &block)
11
+ str = _process_data(f_hash[:data], &block)
12
+ @n_clusters = _calc_n_clusters(str)
13
+ @data = str.ljust(@n_clusters * CLUSTER_SIZE, PAD_CHR)
14
+
15
+ @filename = f_hash[:filename]
16
+ @extra_word = extra_word
17
+ end
18
+
19
+ def call
20
+ DataEntry.new(
21
+ header: _make_header,
22
+ data: @data
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def _make_header
29
+ DataEntryHeader.new(
30
+ status: PERM_ENTRY,
31
+ filename: Filename.new(ascii: @filename).radix50,
32
+ n_clusters: @n_clusters,
33
+ ch_job: DEF_CH_JOB,
34
+ date: DEF_DATE,
35
+ extra_word: @extra_word
36
+ )
37
+ end
38
+
39
+ def _process_data(arr, &block)
40
+ arr.map(&block)
41
+ .join("\r\n")
42
+ .prepend(0x0A.chr)
43
+ .prepend(0x0D.chr)
44
+ .concat(0x0D.chr)
45
+ .concat(0x0A.chr)
46
+ .concat(0x00.chr)
47
+ end
48
+
49
+ def _calc_n_clusters(str)
50
+ (str.length.to_f / CLUSTER_SIZE).ceil
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ module Utils
6
+ #
7
+ # Extracts files.
8
+ #
9
+ class FileExtracter
10
+ def initialize(data)
11
+ @data = data.reject { |e| e.status == EMPTY_ENTRY }
12
+ end
13
+
14
+ #
15
+ # Extract file as is.
16
+ #
17
+ # @param [Filename] file_id
18
+ #
19
+ # @return [FileInterface]
20
+ #
21
+ def f_extract_raw(file_id)
22
+ FileInterface.new(
23
+ filename: file_id.print_ascii,
24
+ data: _extract_raw_data(file_id)
25
+ )
26
+ end
27
+
28
+ #
29
+ # Extract file as array of strings.
30
+ #
31
+ # @param [Filename] file_id
32
+ #
33
+ # @yield [str]
34
+ #
35
+ # @return [FileInterface]
36
+ #
37
+ def f_extract_txt(file_id, &block)
38
+ FileInterface.new(
39
+ filename: file_id.print_ascii,
40
+ data: _text_data(file_id, &block)
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ #
47
+ # Extract file content as an array of strings.
48
+ #
49
+ # @param [Filename] id
50
+ #
51
+ # @yield [str]
52
+ #
53
+ # @return [Array<Strings>]
54
+ # Line by line content of the file.
55
+ #
56
+ def _text_data(id, &block)
57
+ _payload(id).split("\r\n")
58
+ .reject(&:empty?)
59
+ .map(&block)
60
+ end
61
+
62
+ #
63
+ # Return payload of a file.
64
+ #
65
+ # @param [Filename] id
66
+ #
67
+ # @return [String]
68
+ #
69
+ def _payload(id)
70
+ _extract_raw_data(id).split(/\x00/).first
71
+ end
72
+
73
+ #
74
+ # Extract raw data of a file.
75
+ #
76
+ # @param [Filename]
77
+ #
78
+ # @return [String]
79
+ #
80
+ def _extract_raw_data(id)
81
+ index = @data.find_index { |e| e.filename == id.radix50 }
82
+
83
+ raise ArgumentError, "File '#{id.ascii}' not found in the volume" unless index
84
+
85
+ @data[index].data
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ module Utils
6
+ #
7
+ # Validates virtual volume params.
8
+ #
9
+ module VolumeParamsValidator
10
+ def self.call(params)
11
+ result = VolumeParamsContract.new.call(params)
12
+
13
+ raise ArgumentError, result.errors.to_h.to_a.join(": ") unless result.success?
14
+
15
+ result.schema_result.output
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ #
6
+ # Namespace for utils to manipulate the virtual volume.
7
+ #
8
+ module Utils; end
9
+ end
10
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SMPTool
4
+ module VirtualVolume
5
+ #
6
+ # Ruby representation of the volume.
7
+ #
8
+ class Volume
9
+ extend Forwardable
10
+
11
+ def_delegators :@volume_params, :n_clusters_allocated, :n_extra_bytes_per_entry, :n_dir_segs,
12
+ :n_clusters_per_dir_seg, :extra_word, :n_max_entries_per_dir_seg, :n_max_entries
13
+
14
+ attr_reader :bootloader, :home_block, :volume_params, :data
15
+
16
+ def self.read_volume_io(volume_io)
17
+ Utils::ConverterFromVolumeIO.read_volume_io(volume_io)
18
+ end
19
+
20
+ def self.read_io(volume_io)
21
+ Utils::ConverterFromVolumeIO.read_io(volume_io)
22
+ end
23
+
24
+ #
25
+ # Create new virtual volume.
26
+ #
27
+ # @param [Array<Integer>] bootloader
28
+ # @param [Array<Integer>] home_block
29
+ # @param [VolumeParams] volume_params
30
+ # @param [VolumeData, nil] volume_data
31
+ # Existing volume data (parsed from an existing VolumeIO obj) or nil.
32
+ # Nil indicates that an empty volume should be created, and empty data
33
+ # must be initialized.
34
+ #
35
+ def initialize(bootloader:, home_block:, volume_params:, volume_data: nil)
36
+ @bootloader = bootloader
37
+ @home_block = home_block
38
+
39
+ @volume_params = volume_params
40
+ @data = volume_data || Utils::EmptyVolDataInitializer.call(@volume_params)
41
+ end
42
+
43
+ def snapshot
44
+ {
45
+ volume_params: @volume_params.snapshot,
46
+ volume_data: @data.snapshot,
47
+ n_free_clusters: @data.calc_n_free_clusters
48
+ }
49
+ end
50
+
51
+ #
52
+ # Convert `self` to a VolumeIO object.
53
+ #
54
+ # @return [VolumeIO]
55
+ #
56
+ def to_volume_io
57
+ Utils::ConverterToVolumeIO.new(self).call
58
+ end
59
+
60
+ #
61
+ # Convert `self` to a binary string. Write this string to a binary file
62
+ # to get a MK90 volume that works on an emulator or on a real machine.
63
+ #
64
+ # @return [String]
65
+ #
66
+ def to_binary_s
67
+ to_volume_io.to_binary_s
68
+ end
69
+
70
+ #
71
+ # Allocate more clusters to the volume or trim free clusters.
72
+ #
73
+ # @param [Integer] n_clusters
74
+ # Number of clusters to add (pos. int.) or to trim (neg. int.).
75
+ #
76
+ # @return [Integer]
77
+ # Number of clusters that were added/trimmed.
78
+ #
79
+ def resize(n_clusters)
80
+ if n_clusters.positive?
81
+ _resize_validate_pos_input(n_clusters)
82
+ elsif n_clusters.negative?
83
+ _resize_validate_neg_input(n_clusters)
84
+ else
85
+ return n_clusters
86
+ end
87
+
88
+ _resize(n_clusters)
89
+ end
90
+
91
+ #
92
+ # Push a file to the volume.
93
+ #
94
+ # @param [FileInterface, Hash{ Symbol => Object }] file_obj
95
+ #
96
+ # @yield [str]
97
+ # Each line of a file gets passed through this block. The default block encodes
98
+ # a string from the UTF-8 to the KOI-7, but a custom block allows to alter this
99
+ # behavior (e.g. when the file is already in the KOI-7 encoding).
100
+ #
101
+ # @return [String]
102
+ # ASCII filename of the pushed file.
103
+ #
104
+ def f_push(file_obj, &block)
105
+ block = ->(str) { InjalidDejice.utf_to_koi(str, forced_latin: "\"") } unless block_given?
106
+
107
+ _f_push(file_obj, &block)
108
+ end
109
+
110
+ #
111
+ # Extract content of a file as an array of strings.
112
+ #
113
+ # @param [<String>] filename
114
+ # ASCII filename of the file to extract.
115
+ #
116
+ # @yield [str]
117
+ # Each line of a file gets passed through this block. The default block decodes
118
+ # a string from the KOI-7 to the UTF-8, but a custom block allows to alter this
119
+ # behavior.
120
+ #
121
+ # @return [FileInterface]
122
+ #
123
+ def f_extract_txt(filename, &block)
124
+ block = ->(str) { InjalidDejice.koi_to_utf(str) } unless block_given?
125
+
126
+ Utils::FileExtracter.new(@data).f_extract_txt(
127
+ Filename.new(ascii: filename),
128
+ &block
129
+ )
130
+ end
131
+
132
+ #
133
+ # Extract all files as arrays of strings.
134
+ #
135
+ # @return [Array<FileInterface>]
136
+ #
137
+ def f_extract_txt_all
138
+ _all_filenames.map { |fn| f_extract_txt(fn) }
139
+ end
140
+
141
+ #
142
+ # Extract content of a file as a 'raw' string (as is).
143
+ #
144
+ # @param [<String>] filename
145
+ # ASCII filename of the file to extract.
146
+ #
147
+ # @return [FileInterface]
148
+ #
149
+ def f_extract_raw(filename)
150
+ Utils::FileExtracter.new(@data).f_extract_raw(
151
+ Filename.new(ascii: filename)
152
+ )
153
+ end
154
+
155
+ #
156
+ # Extract all files as 'raw' strings.
157
+ #
158
+ # @return [Array<FileInterface>]
159
+ #
160
+ def f_extract_raw_all
161
+ _all_filenames.map { |fn| f_extract_raw(fn) }
162
+ end
163
+
164
+ #
165
+ # Rename a file.
166
+ #
167
+ # @param [<String>] old_filename
168
+ # @param [<String>] new_filename
169
+ #
170
+ # @return [Array<String>]
171
+ # Old and new ASCII filenames of a renamed file.
172
+ #
173
+ def f_rename(old_filename, new_filename)
174
+ @data.f_rename(
175
+ Filename.new(ascii: old_filename),
176
+ Filename.new(ascii: new_filename)
177
+ )
178
+ end
179
+
180
+ #
181
+ # Delete a file.
182
+ #
183
+ # @param [<String>] filename
184
+ #
185
+ # @return [String]
186
+ # ASCII filename of a deleted file.
187
+ #
188
+ def f_delete(filename)
189
+ @data.f_delete(Filename.new(ascii: filename))
190
+ end
191
+
192
+ #
193
+ # Consolidate all free space at the end ot the volume.
194
+ #
195
+ # @return [Integer]
196
+ # Number of free clusters that were joined.
197
+ #
198
+ def squeeze
199
+ @data.squeeze
200
+ end
201
+
202
+ private
203
+
204
+ def _all_filenames
205
+ @data.reject { |e| e.status == EMPTY_ENTRY }
206
+ .map { |e| e.header.print_ascii_filename }
207
+ end
208
+
209
+ def _resize_validate_pos_input(n_delta_clusters)
210
+ _check_dir_overflow
211
+
212
+ return if n_delta_clusters + @volume_params.n_clusters_allocated <= N_CLUSTERS_MAX
213
+
214
+ raise ArgumentError, "Volume size can't be more than #{N_CLUSTERS_MAX} clusters"
215
+ end
216
+
217
+ def _resize_validate_neg_input(n_delta_clusters)
218
+ n_free_clusters = @data.calc_n_free_clusters
219
+
220
+ return if n_delta_clusters.abs <= n_free_clusters
221
+
222
+ raise ArgumentError, "Can't trim more than #{n_free_clusters} clusters"
223
+ end
224
+
225
+ def _resize(n_clusters)
226
+ @volume_params.n_clusters_allocated += n_clusters
227
+ @data.resize(n_clusters)
228
+ end
229
+
230
+ def _check_dir_overflow
231
+ raise ArgumentError, "Directory table is full" if @data.length >= @volume_params.n_max_entries
232
+ end
233
+
234
+ def _f_push(f_hash, &block)
235
+ @data.squeeze
236
+
237
+ _check_dir_overflow
238
+
239
+ file = SMPTool::VirtualVolume::Utils::FileConverter.new(
240
+ f_hash,
241
+ @volume_params.extra_word,
242
+ &block
243
+ ).call
244
+
245
+ @data.f_push(file)
246
+ end
247
+ end
248
+ end
249
+ end