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.
- checksums.yaml +7 -0
- data/.rubocop.yml +13 -0
- data/.vscode/launch.json +21 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/Rakefile +16 -0
- data/lib/smp_tool/autoloader.rb +24 -0
- data/lib/smp_tool/basic_10.rb +95 -0
- data/lib/smp_tool/basic_20.rb +96 -0
- data/lib/smp_tool/filename.rb +71 -0
- data/lib/smp_tool/version.rb +5 -0
- data/lib/smp_tool/virtual_volume/data_entry.rb +47 -0
- data/lib/smp_tool/virtual_volume/data_entry_header.rb +85 -0
- data/lib/smp_tool/virtual_volume/file_interface.rb +33 -0
- data/lib/smp_tool/virtual_volume/utils/converter_from_volume_io.rb +81 -0
- data/lib/smp_tool/virtual_volume/utils/converter_to_volume_io.rb +125 -0
- data/lib/smp_tool/virtual_volume/utils/empty_vol_data_initializer.rb +32 -0
- data/lib/smp_tool/virtual_volume/utils/file_converter.rb +55 -0
- data/lib/smp_tool/virtual_volume/utils/file_extracter.rb +90 -0
- data/lib/smp_tool/virtual_volume/utils/volume_params_validator.rb +20 -0
- data/lib/smp_tool/virtual_volume/utils.rb +10 -0
- data/lib/smp_tool/virtual_volume/volume.rb +249 -0
- data/lib/smp_tool/virtual_volume/volume_data.rb +216 -0
- data/lib/smp_tool/virtual_volume/volume_params.rb +70 -0
- data/lib/smp_tool/virtual_volume/volume_params_contract.rb +53 -0
- data/lib/smp_tool/virtual_volume.rb +8 -0
- data/lib/smp_tool/volume_io/bootloader.rb +18 -0
- data/lib/smp_tool/volume_io/data.rb +14 -0
- data/lib/smp_tool/volume_io/dir_entry.rb +45 -0
- data/lib/smp_tool/volume_io/dir_seg.rb +33 -0
- data/lib/smp_tool/volume_io/dir_seg_header.rb +23 -0
- data/lib/smp_tool/volume_io/directory.rb +26 -0
- data/lib/smp_tool/volume_io/file_content.rb +13 -0
- data/lib/smp_tool/volume_io/home_block.rb +18 -0
- data/lib/smp_tool/volume_io/volume_io.rb +53 -0
- data/lib/smp_tool/volume_io.rb +10 -0
- data/lib/smp_tool.rb +54 -0
- data/sig/smp_tool.rbs +4 -0
- data/smp_tool.gemspec +40 -0
- metadata +206 -0
@@ -0,0 +1,216 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VirtualVolume
|
5
|
+
#
|
6
|
+
# Provides set of methods to work with volume's data.
|
7
|
+
#
|
8
|
+
class VolumeData < SimpleDelegator
|
9
|
+
def initialize(obj, extra_word)
|
10
|
+
@obj = obj
|
11
|
+
@extra_word = extra_word
|
12
|
+
|
13
|
+
super(obj)
|
14
|
+
end
|
15
|
+
|
16
|
+
def snapshot
|
17
|
+
map(&:snapshot)
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# Rename file on the virtual volume.
|
22
|
+
#
|
23
|
+
# @param [SMPTool::Filename] old_id
|
24
|
+
# @param [SMPTool::Filename] new_id
|
25
|
+
#
|
26
|
+
# @return [Array<String>]
|
27
|
+
# Old and new ASCII filenames of a renamed file.
|
28
|
+
#
|
29
|
+
# @raise [ArgumentError]
|
30
|
+
# - Can't assign name `new_id` to the file since another file with
|
31
|
+
# the same name already exists;
|
32
|
+
# - File `old_id` not found.
|
33
|
+
#
|
34
|
+
def f_rename(old_id, new_id)
|
35
|
+
_raise_already_exists(new_id.print_ascii) if _already_exists?(new_id.radix50)
|
36
|
+
|
37
|
+
idx = _find_idx(old_id.radix50)
|
38
|
+
|
39
|
+
_raise_file_not_found(old_id.print_ascii) unless idx
|
40
|
+
|
41
|
+
self[idx].rename(new_id.radix50)
|
42
|
+
|
43
|
+
[old_id.print_ascii, new_id.print_ascii]
|
44
|
+
end
|
45
|
+
|
46
|
+
#
|
47
|
+
# Delete file from the virtual volume.
|
48
|
+
#
|
49
|
+
# @param [SMPTool::Filename] file_id
|
50
|
+
#
|
51
|
+
# @return [String]
|
52
|
+
# ASCII filename of a deleted file.
|
53
|
+
#
|
54
|
+
# @raise [ArgumentError]
|
55
|
+
# - File with `file_id` not found.
|
56
|
+
#
|
57
|
+
def f_delete(file_id)
|
58
|
+
idx = _find_idx(file_id.radix50)
|
59
|
+
|
60
|
+
_raise_file_not_found(file_id.print_ascii) unless idx
|
61
|
+
|
62
|
+
self[idx].clean
|
63
|
+
|
64
|
+
file_id.print_ascii
|
65
|
+
end
|
66
|
+
|
67
|
+
#
|
68
|
+
# Consolidate all free space at the end of the volume.
|
69
|
+
#
|
70
|
+
# @return [Integer] n_free_clusters
|
71
|
+
# Number of free clusters that were joined.
|
72
|
+
#
|
73
|
+
def squeeze
|
74
|
+
n_free_clusters = calc_n_free_clusters
|
75
|
+
|
76
|
+
return n_free_clusters if n_free_clusters.zero?
|
77
|
+
|
78
|
+
reject!(&:empty_entry?)
|
79
|
+
|
80
|
+
push_empty_entry(n_free_clusters)
|
81
|
+
|
82
|
+
n_free_clusters
|
83
|
+
end
|
84
|
+
|
85
|
+
#
|
86
|
+
# Calculate total number of free clusters.
|
87
|
+
#
|
88
|
+
# @return [Integer]
|
89
|
+
#
|
90
|
+
def calc_n_free_clusters
|
91
|
+
self.select(&:empty_entry?)
|
92
|
+
.sum(&:n_clusters)
|
93
|
+
end
|
94
|
+
|
95
|
+
#
|
96
|
+
# Append a file.
|
97
|
+
#
|
98
|
+
# @param [SMPTool::VirtualVolume::DataEntry] file
|
99
|
+
#
|
100
|
+
# @return [String] file.print_ascii_filename
|
101
|
+
# ASCII filename of the pushed file.
|
102
|
+
#
|
103
|
+
def f_push(file)
|
104
|
+
_raise_already_exists(file.print_ascii_filename) if _already_exists?(file.filename)
|
105
|
+
|
106
|
+
# We're starting from the end of the array, since free space tend to locate
|
107
|
+
# at the end of the volume (esp. after the 'squeeze' command).
|
108
|
+
idx = index(reverse_each.detect { |e| e.n_clusters >= file.n_clusters && e.empty_entry? })
|
109
|
+
|
110
|
+
unless idx
|
111
|
+
raise ArgumentError,
|
112
|
+
"no free space found to fit the file (try to squeeze, delete files or allocate more clusters)"
|
113
|
+
end
|
114
|
+
|
115
|
+
_f_push(file, idx)
|
116
|
+
|
117
|
+
file.print_ascii_filename
|
118
|
+
end
|
119
|
+
|
120
|
+
def push_empty_entry(n_free_clusters)
|
121
|
+
push(_new_empty_entry(n_free_clusters))
|
122
|
+
|
123
|
+
self
|
124
|
+
end
|
125
|
+
|
126
|
+
#
|
127
|
+
# <Description>
|
128
|
+
#
|
129
|
+
# @param [Integer] n_clusters
|
130
|
+
# Number of clusters to add (pos. int.) or to trim (neg. int.).
|
131
|
+
#
|
132
|
+
# @return [Integer] n_clusters
|
133
|
+
# Number of clusters that were added/trimmed.
|
134
|
+
#
|
135
|
+
def resize(n_clusters)
|
136
|
+
n_free_clusters = calc_n_free_clusters
|
137
|
+
diff = n_free_clusters + n_clusters
|
138
|
+
|
139
|
+
if reject(&:empty_entry?).empty? && diff < 1
|
140
|
+
raise ArgumentError, "Can't trim: volume should keep at least one empty/file entry"
|
141
|
+
end
|
142
|
+
|
143
|
+
reject!(&:empty_entry?)
|
144
|
+
push_empty_entry(diff) unless diff.zero?
|
145
|
+
|
146
|
+
n_clusters
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
def _new_empty_entry(n_free_clusters)
|
152
|
+
DataEntry.new(
|
153
|
+
header: DataEntryHeader.new(
|
154
|
+
_free_entry_header_params(n_free_clusters)
|
155
|
+
),
|
156
|
+
data: PAD_CHR * (n_free_clusters * CLUSTER_SIZE)
|
157
|
+
)
|
158
|
+
end
|
159
|
+
|
160
|
+
def _f_push(file, idx)
|
161
|
+
n_clusters_left = self[idx].n_clusters - file.n_clusters
|
162
|
+
|
163
|
+
insert(idx, file)
|
164
|
+
delete_at(idx + 1)
|
165
|
+
|
166
|
+
return if n_clusters_left.zero?
|
167
|
+
|
168
|
+
insert(-idx, _new_empty_entry(n_clusters_left))
|
169
|
+
end
|
170
|
+
|
171
|
+
def _free_entry_header_params(n_clusters)
|
172
|
+
{
|
173
|
+
status: EMPTY_ENTRY,
|
174
|
+
filename: [PAD_WORD, PAD_WORD, PAD_WORD],
|
175
|
+
n_clusters: n_clusters,
|
176
|
+
ch_job: DEF_CH_JOB,
|
177
|
+
date: DEF_DATE,
|
178
|
+
extra_word: @extra_word
|
179
|
+
}
|
180
|
+
end
|
181
|
+
|
182
|
+
def _raise_file_not_found(filename)
|
183
|
+
raise ArgumentError, "File '#{filename}' not found on the volume"
|
184
|
+
end
|
185
|
+
|
186
|
+
def _raise_already_exists(filename)
|
187
|
+
raise ArgumentError, "File with the filename '#{filename}' already exists on the volume"
|
188
|
+
end
|
189
|
+
|
190
|
+
#
|
191
|
+
# Check if file with the Radix-50 filename `file_id` already exists.
|
192
|
+
#
|
193
|
+
# @param [Array<Integer>] file_id
|
194
|
+
# RADIX-50 filename.
|
195
|
+
#
|
196
|
+
# @return [Boolean]
|
197
|
+
#
|
198
|
+
def _already_exists?(file_id)
|
199
|
+
idx = _find_idx(file_id)
|
200
|
+
idx.nil? ? false : true
|
201
|
+
end
|
202
|
+
|
203
|
+
#
|
204
|
+
# Find index of a file with the Radix-50 filename `file_id`.
|
205
|
+
#
|
206
|
+
# @param [Array<Integer>] file_id
|
207
|
+
# RADIX-50 filename.
|
208
|
+
#
|
209
|
+
# @return [Integer, nil]
|
210
|
+
#
|
211
|
+
def _find_idx(file_id)
|
212
|
+
find_index { |e| e.filename == file_id }
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VirtualVolume
|
5
|
+
#
|
6
|
+
# Virtual volume parameters.
|
7
|
+
#
|
8
|
+
class VolumeParams
|
9
|
+
attr_accessor :n_clusters_allocated
|
10
|
+
|
11
|
+
attr_reader :n_extra_bytes_per_entry, :n_dir_segs,
|
12
|
+
:n_clusters_per_dir_seg, :extra_word, :n_max_entries_per_dir_seg,
|
13
|
+
:n_max_entries
|
14
|
+
|
15
|
+
def initialize(
|
16
|
+
n_clusters_allocated:,
|
17
|
+
n_extra_bytes_per_entry:,
|
18
|
+
n_dir_segs:,
|
19
|
+
n_clusters_per_dir_seg:,
|
20
|
+
extra_word:
|
21
|
+
)
|
22
|
+
@n_clusters_allocated = n_clusters_allocated
|
23
|
+
@n_extra_bytes_per_entry = n_extra_bytes_per_entry
|
24
|
+
@n_dir_segs = n_dir_segs
|
25
|
+
@n_clusters_per_dir_seg = n_clusters_per_dir_seg
|
26
|
+
@extra_word = extra_word
|
27
|
+
|
28
|
+
_validate_input
|
29
|
+
|
30
|
+
@n_max_entries_per_dir_seg = _calc_n_max_entries_per_dir_seg
|
31
|
+
@n_max_entries = @n_dir_segs * @n_max_entries_per_dir_seg
|
32
|
+
end
|
33
|
+
|
34
|
+
def snapshot
|
35
|
+
{
|
36
|
+
n_clusters_allocated: @n_clusters_allocated,
|
37
|
+
n_extra_bytes_per_entry: @n_extra_bytes_per_entry,
|
38
|
+
n_dir_segs: @n_dir_segs,
|
39
|
+
n_clusters_per_dir_seg: @n_clusters_per_dir_seg,
|
40
|
+
extra_word: @extra_word,
|
41
|
+
n_max_entries_per_dir_seg: @n_max_entries_per_dir_seg,
|
42
|
+
n_max_entries: @n_max_entries
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def _validate_input
|
49
|
+
result = VolumeParamsContract.new.call(_input_snapshot)
|
50
|
+
|
51
|
+
raise ArgumentError, result.errors.to_h.to_a.join(": ") unless result.success?
|
52
|
+
end
|
53
|
+
|
54
|
+
def _input_snapshot
|
55
|
+
{
|
56
|
+
n_clusters_allocated: @n_clusters_allocated,
|
57
|
+
n_extra_bytes_per_entry: @n_extra_bytes_per_entry,
|
58
|
+
n_dir_segs: @n_dir_segs,
|
59
|
+
n_clusters_per_dir_seg: @n_clusters_per_dir_seg,
|
60
|
+
extra_word: @extra_word
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
def _calc_n_max_entries_per_dir_seg
|
65
|
+
entry_size = ENTRY_BASE_SIZE + @n_extra_bytes_per_entry
|
66
|
+
(((@n_clusters_per_dir_seg * CLUSTER_SIZE) - HEADER_SIZE - FOOTER_SIZE) / entry_size).floor
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VirtualVolume
|
5
|
+
#
|
6
|
+
# Virtual volume params contract.
|
7
|
+
#
|
8
|
+
class VolumeParamsContract < Dry::Validation::Contract
|
9
|
+
config.messages.default_locale = :en
|
10
|
+
|
11
|
+
json do
|
12
|
+
required(:n_clusters_allocated).value(
|
13
|
+
:integer,
|
14
|
+
lteq?: N_CLUSTERS_MAX
|
15
|
+
)
|
16
|
+
|
17
|
+
required(:n_extra_bytes_per_entry).value(
|
18
|
+
:integer,
|
19
|
+
included_in?: [0, 2]
|
20
|
+
)
|
21
|
+
|
22
|
+
required(:n_dir_segs).value(
|
23
|
+
:integer,
|
24
|
+
gteq?: 1,
|
25
|
+
lteq?: 2
|
26
|
+
)
|
27
|
+
|
28
|
+
required(:n_clusters_per_dir_seg).value(
|
29
|
+
:integer,
|
30
|
+
gteq?: 1,
|
31
|
+
lteq?: 2
|
32
|
+
)
|
33
|
+
|
34
|
+
required(:extra_word).value(
|
35
|
+
:integer
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
rule(:n_dir_segs, :n_clusters_per_dir_seg) do
|
40
|
+
msg = "Can't use :n_dir_segs => #{values[:n_dir_segs]}" \
|
41
|
+
"& :n_clusters_per_dir_seg => #{values[:n_clusters_per_dir_seg]}"
|
42
|
+
|
43
|
+
key.failure(msg) if values[:n_dir_segs] == 2 && values[:n_clusters_per_dir_seg] == 1
|
44
|
+
end
|
45
|
+
|
46
|
+
rule(:n_clusters_allocated, :n_clusters_per_dir_seg, :n_dir_segs) do
|
47
|
+
n_min_clusters = N_SYS_CLUSTERS + values[:n_clusters_per_dir_seg] * values[:n_dir_segs] + 1
|
48
|
+
msg = "Min. number of clusters for the configuration is: #{n_min_clusters}"
|
49
|
+
key.failure(msg) if values[:n_clusters_allocated] < n_min_clusters
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VolumeIO
|
5
|
+
#
|
6
|
+
# Bootloader.
|
7
|
+
#
|
8
|
+
class Bootloader < BinData::Record
|
9
|
+
endian :little
|
10
|
+
|
11
|
+
array :bytes, initial_length: -> { CLUSTER_SIZE } do
|
12
|
+
uint8
|
13
|
+
end
|
14
|
+
|
15
|
+
string :padding, length: -> { CLUSTER_SIZE - padding.rel_offset }, pad_byte: PAD_BYTE
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VolumeIO
|
5
|
+
#
|
6
|
+
# A single directory entry.
|
7
|
+
#
|
8
|
+
class DirEntry < BinData::Record
|
9
|
+
endian :little
|
10
|
+
|
11
|
+
virtual :ascii_filename, value: -> { _radix_to_ascii(filename) }, onlyif: -> { status != DIR_SEG_FOOTER }
|
12
|
+
|
13
|
+
# Status word: empty area/permanent file/end-of-segment:
|
14
|
+
uint16le :status
|
15
|
+
|
16
|
+
# RADIX-50 filename (6 bytes):
|
17
|
+
array :filename, onlyif: -> { status != DIR_SEG_FOOTER }, initial_length: RAD50_FN_SIZE do
|
18
|
+
uint16le initial_value: PAD_WORD
|
19
|
+
end
|
20
|
+
|
21
|
+
# Number of clusters occupied by the file:
|
22
|
+
uint16le :n_clusters, onlyif: -> { status != DIR_SEG_FOOTER }
|
23
|
+
|
24
|
+
# Job & channel, unused in the MK90:
|
25
|
+
uint16le :ch_job, onlyif: -> { status != DIR_SEG_FOOTER }, initial_value: DEF_CH_JOB
|
26
|
+
|
27
|
+
# Creation date, unused in the MK90:
|
28
|
+
uint16le :date, onlyif: -> { status != DIR_SEG_FOOTER }, initial_value: DEF_DATE
|
29
|
+
|
30
|
+
# Extra word is unused in the BASIC v.1.0, but it is required in the BASIC v.2.0.
|
31
|
+
# The use in the v.2.0 is unknown, probably it was reserved for a checksum.
|
32
|
+
# BASIC v.2.0 always sets it to 0x00A0.
|
33
|
+
uint16le :extra_word,
|
34
|
+
onlyif: lambda {
|
35
|
+
status != DIR_SEG_FOOTER && header.n_extra_bytes_per_entry.positive?
|
36
|
+
}
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def _radix_to_ascii(int_arr)
|
41
|
+
DECRadix50.decode(DECRadix50::MK90_CHARSET, int_arr)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "dir_seg_header"
|
4
|
+
require_relative "dir_entry"
|
5
|
+
|
6
|
+
module SMPTool
|
7
|
+
module VolumeIO
|
8
|
+
#
|
9
|
+
# A single segment from the directory.
|
10
|
+
#
|
11
|
+
class DirSeg < BinData::Record
|
12
|
+
endian :little
|
13
|
+
hide :padding
|
14
|
+
|
15
|
+
dir_seg_header :header
|
16
|
+
|
17
|
+
array :dir_seg_entries, read_until: -> { element.status == DIR_SEG_FOOTER } do
|
18
|
+
dir_entry :dir_entry
|
19
|
+
end
|
20
|
+
|
21
|
+
virtual :n_clusters_per_dir_seg, initial_value: -> { header.data_offset == 3 ? 1 : N_CLUSTERS_PER_DIR_SEG }
|
22
|
+
virtual :entry_size, initial_value: -> { ENTRY_BASE_SIZE + header.n_extra_bytes_per_entry }
|
23
|
+
virtual :n_max_entries, initial_value: lambda {
|
24
|
+
(((n_clusters_per_dir_seg * CLUSTER_SIZE) - header.num_bytes - FOOTER_SIZE) / entry_size).floor
|
25
|
+
}
|
26
|
+
|
27
|
+
string :padding, length: -> { n_clusters_per_dir_seg * CLUSTER_SIZE - padding.rel_offset }, pad_byte: PAD_BYTE
|
28
|
+
|
29
|
+
# Number of entries in this dirseg:
|
30
|
+
virtual :n_entries, initial_value: -> { dir_seg_entries.reject { |e| e.status == DIR_SEG_FOOTER }.length }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VolumeIO
|
5
|
+
#
|
6
|
+
# Directory segment header. See Table 1-2: Directory Segment Header Words [DEC].
|
7
|
+
#
|
8
|
+
# Notes:
|
9
|
+
# - word #4 should be set to 0x0000 for the BASIC v.1.0, 0x0002 for the BASIC v.2.0.
|
10
|
+
# - word #5 allows to use only one cluster for the entire directory.
|
11
|
+
#
|
12
|
+
class DirSegHeader < BinData::Record
|
13
|
+
endian :little
|
14
|
+
|
15
|
+
uint16le :n_dir_segs # The total number of segments in this directory.
|
16
|
+
uint16le :i_next_seg # The index of the next logical directory segment.
|
17
|
+
uint16le :i_high_seg, # The index of the highest segment currently in use.
|
18
|
+
initial_value: 1
|
19
|
+
uint16le :n_extra_bytes_per_entry # The number of extra bytes per directory entry, an even number.
|
20
|
+
uint16le :data_offset # The block number where the actual stored data begins.
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "dir_seg"
|
4
|
+
|
5
|
+
module SMPTool
|
6
|
+
module VolumeIO
|
7
|
+
#
|
8
|
+
# From the DEC's manual [DEC]:
|
9
|
+
#
|
10
|
+
# The directory consists of a series of two-block segments. Each segment is 512 words
|
11
|
+
# long and contains information about files such as name, length, and creation date.
|
12
|
+
#
|
13
|
+
class Directory < BinData::Record
|
14
|
+
endian :little
|
15
|
+
|
16
|
+
# Last dirseg should set 'i_next_seg' word in the header to 0,
|
17
|
+
# so that indicates the last segment in the directory.
|
18
|
+
array :segments, read_until: -> { element.header.i_next_seg.zero? } do
|
19
|
+
dir_seg
|
20
|
+
end
|
21
|
+
|
22
|
+
# Total number of entries (from all dirsegs) in this dir.
|
23
|
+
virtual :n_entries, initial_value: -> { segments.to_ary.sum(&:n_entries) }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VolumeIO
|
5
|
+
#
|
6
|
+
# Content of a single entry (file).
|
7
|
+
#
|
8
|
+
class FileContent < BinData::String
|
9
|
+
default_parameter read_length: -> { all_entries.to_a[index].n_clusters * CLUSTER_SIZE }
|
10
|
+
default_parameter pad_byte: PAD_BYTE
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMPTool
|
4
|
+
module VolumeIO
|
5
|
+
#
|
6
|
+
# Home block.
|
7
|
+
#
|
8
|
+
class HomeBlock < BinData::Record
|
9
|
+
endian :little
|
10
|
+
|
11
|
+
array :bytes, initial_length: -> { CLUSTER_SIZE } do
|
12
|
+
uint8
|
13
|
+
end
|
14
|
+
|
15
|
+
string :padding, length: -> { CLUSTER_SIZE - padding.rel_offset }, pad_byte: PAD_BYTE
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "bootloader"
|
4
|
+
require_relative "home_block"
|
5
|
+
require_relative "directory"
|
6
|
+
require_relative "file_content"
|
7
|
+
require_relative "data"
|
8
|
+
|
9
|
+
module SMPTool
|
10
|
+
module VolumeIO
|
11
|
+
#
|
12
|
+
# MK90 BASIC volume.
|
13
|
+
#
|
14
|
+
class VolumeIO < BinData::Record
|
15
|
+
hide :bootloader
|
16
|
+
hide :home_block
|
17
|
+
|
18
|
+
bootloader :bootloader
|
19
|
+
home_block :home_block
|
20
|
+
directory :directory
|
21
|
+
data :data
|
22
|
+
|
23
|
+
virtual :n_clusters_allocated, initial_value: -> { num_bytes / CLUSTER_SIZE }
|
24
|
+
|
25
|
+
# Empty and permanent entries.
|
26
|
+
virtual :all_entries,
|
27
|
+
initial_value: lambda {
|
28
|
+
directory.segments.flat_map(&:dir_seg_entries)
|
29
|
+
.reject { |e| e.status == DIR_SEG_FOOTER }
|
30
|
+
}
|
31
|
+
|
32
|
+
# Permanent entries (i.e. files) only.
|
33
|
+
virtual :file_only_entries,
|
34
|
+
initial_value: lambda {
|
35
|
+
directory.segments.flat_map(&:dir_seg_entries)
|
36
|
+
.reject { |e| e.status == DIR_SEG_FOOTER }
|
37
|
+
.reject { |e| e.status == EMPTY_ENTRY }
|
38
|
+
}
|
39
|
+
|
40
|
+
virtual :n_extra_bytes_per_entry,
|
41
|
+
initial_value: -> { directory.segments.first.header.n_extra_bytes_per_entry }
|
42
|
+
|
43
|
+
virtual :n_max_entries_per_dir_seg,
|
44
|
+
initial_value: -> { directory.segments.first.n_max_entries }
|
45
|
+
|
46
|
+
virtual :n_dir_segs,
|
47
|
+
initial_value: -> { directory.segments.length }
|
48
|
+
|
49
|
+
virtual :n_clusters_per_dir_seg,
|
50
|
+
initial_value: -> { directory.segments.first.n_clusters_per_dir_seg }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
data/lib/smp_tool.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bindata"
|
4
|
+
require "delegate"
|
5
|
+
require "dec_radix_50"
|
6
|
+
require "dry/validation"
|
7
|
+
require "forwardable"
|
8
|
+
require "injalid_dejice"
|
9
|
+
require "zeitwerk"
|
10
|
+
|
11
|
+
require_relative "smp_tool/autoloader"
|
12
|
+
require_relative "smp_tool/version"
|
13
|
+
|
14
|
+
SMPTool::Autoloader.setup
|
15
|
+
|
16
|
+
#
|
17
|
+
# Lib to work with Elektronika MK90 bin volumes.
|
18
|
+
#
|
19
|
+
module SMPTool
|
20
|
+
class Error < StandardError; end
|
21
|
+
|
22
|
+
#
|
23
|
+
# Documentation sources:
|
24
|
+
#
|
25
|
+
# 1. [DEC] http://www.bitsavers.org/pdf/dec/pdp11/rt11/v5.6_Aug91/AA-PD6PA-TC_RT-11_Volume_and_File_Formats_Manual_Aug91.pdf
|
26
|
+
#
|
27
|
+
|
28
|
+
PAD_BYTE = 0x20
|
29
|
+
PAD_CHR = PAD_BYTE.chr.freeze
|
30
|
+
PAD_WORD = 0x2020
|
31
|
+
|
32
|
+
# Sizes, in clusters:
|
33
|
+
N_SYS_CLUSTERS = 2 # Bootloader + home block.
|
34
|
+
N_CLUSTERS_PER_DIR_SEG = 2
|
35
|
+
N_CLUSTERS_MAX = 127
|
36
|
+
|
37
|
+
# Sizes, in bytes:
|
38
|
+
CLUSTER_SIZE = 512
|
39
|
+
HEADER_SIZE = 10
|
40
|
+
FOOTER_SIZE = 2
|
41
|
+
ENTRY_BASE_SIZE = 14
|
42
|
+
|
43
|
+
# Sizes, in 16-bit words:
|
44
|
+
RAD50_FN_SIZE = 3 # RADIX-50 filename size.
|
45
|
+
|
46
|
+
# Directory entry status codes.
|
47
|
+
EMPTY_ENTRY = 0x0200 # Empty entry.
|
48
|
+
PERM_ENTRY = 0x0400 # Permanent file (occupied entry).
|
49
|
+
DIR_SEG_FOOTER = 0x0800 # Directory segment footer, a.k.a. end-of-segment marker.
|
50
|
+
|
51
|
+
# Default entry attributes:
|
52
|
+
DEF_CH_JOB = 0x0000
|
53
|
+
DEF_DATE = 0xFFFF
|
54
|
+
end
|
data/sig/smp_tool.rbs
ADDED