smp_tool 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 (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