badline 0.2.1 → 0.4.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/session-start.sh +57 -0
  3. data/.claude/settings.json +14 -0
  4. data/CHANGELOG.md +107 -0
  5. data/CLAUDE.md +51 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +119 -51
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +293 -39
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +28 -14
  12. data/lib/badline/addressable.rb +1 -1
  13. data/lib/badline/audio/aiff.rb +8 -3
  14. data/lib/badline/audio/bare_player.rb +20 -3
  15. data/lib/badline/audio/cli.rb +17 -12
  16. data/lib/badline/audio/console.rb +12 -58
  17. data/lib/badline/audio/machine_player.rb +9 -6
  18. data/lib/badline/audio/playback.rb +3 -0
  19. data/lib/badline/audio/renderer.rb +3 -3
  20. data/lib/badline/audio/sdl_sink.rb +20 -52
  21. data/lib/badline/audio/stream.rb +117 -0
  22. data/lib/badline/audio/terminal.rb +90 -0
  23. data/lib/badline/audio.rb +3 -2
  24. data/lib/badline/cartridge/easy_flash.rb +6 -2
  25. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  26. data/lib/badline/cartridge/geo_ram.rb +63 -0
  27. data/lib/badline/cartridge/mach5.rb +1 -1
  28. data/lib/badline/cartridge/magic_desk.rb +5 -1
  29. data/lib/badline/cartridge/ocean.rb +5 -2
  30. data/lib/badline/cartridge/rex_utility.rb +1 -1
  31. data/lib/badline/cartridge/rgcd.rb +1 -1
  32. data/lib/badline/cartridge/simons_basic.rb +2 -2
  33. data/lib/badline/cartridge/westermann.rb +2 -2
  34. data/lib/badline/cartridge.rb +9 -0
  35. data/lib/badline/checkpoint.rb +98 -0
  36. data/lib/badline/cia/interrupt_register.rb +34 -10
  37. data/lib/badline/cia.rb +19 -10
  38. data/lib/badline/color_memory.rb +2 -2
  39. data/lib/badline/computer.rb +34 -14
  40. data/lib/badline/cpu.rb +2 -2
  41. data/lib/badline/gui/application.rb +70 -26
  42. data/lib/badline/gui/gamepads.rb +22 -22
  43. data/lib/badline/gui/joy_map.rb +1 -1
  44. data/lib/badline/gui/key_map.rb +6 -1
  45. data/lib/badline/gui/pane.rb +7 -6
  46. data/lib/badline/gui/screen_pane.rb +9 -13
  47. data/lib/badline/gui/window.rb +35 -20
  48. data/lib/badline/gui.rb +1 -1
  49. data/lib/badline/input/mouse1351.rb +21 -6
  50. data/lib/badline/input/paddles.rb +11 -6
  51. data/lib/badline/kernal_trap/channel.rb +102 -7
  52. data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
  53. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  54. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  55. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  56. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  57. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  58. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  59. data/lib/badline/kernal_trap/drive.rb +109 -131
  60. data/lib/badline/kernal_trap/file.rb +6 -14
  61. data/lib/badline/kernal_trap/load.rb +6 -0
  62. data/lib/badline/kernal_trap/save.rb +13 -7
  63. data/lib/badline/keyboard.rb +19 -3
  64. data/lib/badline/media.rb +44 -13
  65. data/lib/badline/memory.rb +2 -2
  66. data/lib/badline/options.rb +209 -0
  67. data/lib/badline/ram_expansion/banking.rb +30 -0
  68. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  69. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  70. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  71. data/lib/badline/ram_expansion.rb +21 -0
  72. data/lib/badline/region.rb +51 -0
  73. data/lib/badline/rom.rb +14 -5
  74. data/lib/badline/sdl/events.rb +47 -0
  75. data/lib/badline/sdl/functions.rb +73 -0
  76. data/lib/badline/sdl.rb +67 -0
  77. data/lib/badline/sid/catch_up.rb +110 -0
  78. data/lib/badline/sid/decimator.rb +6 -1
  79. data/lib/badline/sid/filter.rb +5 -5
  80. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  81. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  82. data/lib/badline/sid/waveform.rb +8 -8
  83. data/lib/badline/sid.rb +19 -93
  84. data/lib/badline/storage/d64_image.rb +10 -0
  85. data/lib/badline/storage/d71_image.rb +12 -0
  86. data/lib/badline/storage/d81_image.rb +9 -0
  87. data/lib/badline/storage/disk_image/bam.rb +111 -0
  88. data/lib/badline/storage/disk_image/directory.rb +79 -0
  89. data/lib/badline/storage/disk_image/writing.rb +110 -0
  90. data/lib/badline/storage/disk_image.rb +49 -4
  91. data/lib/badline/storage/host_directory.rb +17 -4
  92. data/lib/badline/storage/sid_file.rb +36 -2
  93. data/lib/badline/storage.rb +18 -1
  94. data/lib/badline/time_of_day.rb +11 -2
  95. data/lib/badline/version.rb +1 -1
  96. data/lib/badline/via/control_lines.rb +104 -0
  97. data/lib/badline/via/interrupt_register.rb +58 -0
  98. data/lib/badline/via/shift_register.rb +135 -0
  99. data/lib/badline/via/timer1.rb +82 -0
  100. data/lib/badline/via/timer2.rb +84 -0
  101. data/lib/badline/via.rb +262 -0
  102. data/lib/badline/vic/bank.rb +4 -3
  103. data/lib/badline/vic/color_patches.rb +6 -3
  104. data/lib/badline/vic/display_state.rb +4 -0
  105. data/lib/badline/vic/graphics_shifter.rb +49 -8
  106. data/lib/badline/vic/registers.rb +19 -27
  107. data/lib/badline/vic/sequencer.rb +18 -18
  108. data/lib/badline/vic/sequencer_output.rb +4 -4
  109. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  110. data/lib/badline/vic/sprite/shifter.rb +25 -3
  111. data/lib/badline/vic/sprite.rb +5 -4
  112. data/lib/badline/vic/sprites.rb +8 -3
  113. data/lib/badline/vic.rb +98 -46
  114. data/lib/badline.rb +5 -0
  115. data/packaging/homebrew/badline.rb +34 -0
  116. metadata +37 -20
  117. data/exe/badline +0 -75
  118. data/exe/badline-sid +0 -34
  119. data/lib/badline/audio/options.rb +0 -137
@@ -8,12 +8,31 @@ module Badline
8
8
  # channel cut short by a read error never flags one, and holds the
9
9
  # error for the drive to report once its bytes run out.
10
10
  class Channel
11
- attr_accessor :pointer
12
- attr_reader :error
11
+ attr_accessor :buffer
12
+ attr_reader :error, :pointer
13
+ attr_writer :lead
13
14
 
14
- def initialize(bytes = [], error: nil)
15
+ def initialize(bytes = [], error: nil, writable: false, base: 0)
16
+ @base = base
15
17
  replace(bytes)
16
18
  @error = error
19
+ @writable = writable
20
+ end
21
+
22
+ # A block buffer opened with "#", which reads and writes one of the
23
+ # drive's buffers in its RAM, so M-R and M-W reach the same bytes. It
24
+ # hands out the whole block unless a B-R sets a shorter end, and
25
+ # flags that byte as the last. The DOS opens it with the pointer at 1
26
+ # and the buffer's number as the byte to send, so the first read
27
+ # answers with the number and the next one moves on to the third
28
+ # byte.
29
+ def self.buffer(memory, number)
30
+ new(memory.ram, writable: true, base: Drive::Memory.buffer_address(number)).tap do |channel|
31
+ channel.buffer = number
32
+ channel.rewind(Drive::BLOCK_SIZE)
33
+ channel.pointer = 1
34
+ channel.lead = number
35
+ end
17
36
  end
18
37
 
19
38
  # A file's bytes, up to the first block its chain can't read. The
@@ -30,22 +49,98 @@ module Badline
30
49
  new(data[0, failure[:offset] - 1] || [], error: failure.values_at(:error, :track, :sector))
31
50
  end
32
51
 
33
- def replace(bytes)
52
+ # The file an open names. The drive keeps the first block of the file
53
+ # a LOAD (secondary address 0) opens at $7E and $026F of its memory,
54
+ # and a LOAD of a name starting with "*" reopens that file rather than
55
+ # the first one on the disk. Loaders write the block there with M-W
56
+ # to load a file the directory doesn't list.
57
+ def self.for_name(storage, memory, secondary, name, type)
58
+ file = Storage.parse_name(name).first
59
+ return for_file(storage, file, type) unless secondary.zero? && storage.respond_to?(:first_block)
60
+
61
+ last = memory.last_program if name.start_with?("*")
62
+ return at_block(storage, *last) if last
63
+
64
+ block = storage.first_block(file, type:)
65
+ memory.last_program = block if block
66
+ for_file(storage, file, type)
67
+ end
68
+
69
+ # The file whose chain starts at the block.
70
+ def self.at_block(storage, track, sector)
71
+ data = storage.read_file_at(track, sector)
72
+ data ? new(data) : new([], error: [Drive::ILLEGAL_TRACK_OR_SECTOR, track, sector])
73
+ end
74
+
75
+ def replace(bytes, length = bytes.length)
34
76
  @bytes = bytes
35
- @pointer = 0
77
+ rewind(length)
78
+ end
79
+
80
+ def rewind(length)
81
+ @length = length
82
+ self.pointer = 0
83
+ end
84
+
85
+ def pointer=(position)
86
+ @pointer = position
87
+ @lead = nil
36
88
  end
37
89
 
90
+ # A block read into the channel. A buffer channel takes it into its
91
+ # buffer, a file channel hands it out in place of the file.
92
+ def load(block, length)
93
+ return replace(block.dup, length) unless @writable
94
+
95
+ @bytes[@base, block.length] = block
96
+ rewind(length)
97
+ end
98
+
99
+ def writable? = @writable
100
+
101
+ # A buffer channel's block, as a block write puts it on the disk.
102
+ def block = @bytes[@base, Drive::BLOCK_SIZE]
103
+
38
104
  def exhausted?
39
- @pointer >= @bytes.length
105
+ @pointer >= @length
106
+ end
107
+
108
+ # A file that holds nothing but its error never opened.
109
+ def failed?
110
+ exhausted? && !@error.nil?
111
+ end
112
+
113
+ # Each byte lands at the pointer, which wraps within the block.
114
+ def write(bytes)
115
+ @lead = nil
116
+ bytes.each do |byte|
117
+ @bytes[@base + @pointer] = byte
118
+ @pointer = (@pointer + 1) & 0xff
119
+ end
40
120
  end
41
121
 
42
122
  def read
123
+ return read_buffer if @writable
43
124
  return if exhausted?
44
125
 
45
- byte = @bytes[@pointer]
126
+ byte = @lead || @bytes[@base + @pointer]
127
+ @lead = nil
46
128
  @pointer += 1
47
129
  [byte, exhausted? && !@error]
48
130
  end
131
+
132
+ private
133
+
134
+ # A buffer never runs out. Past its last byte, which goes out with
135
+ # EOI, the pointer wraps back to the second, and a pointer set past
136
+ # the end runs on around the block to it.
137
+ def read_buffer
138
+ byte = @lead || @bytes[@base + @pointer]
139
+ @lead = nil
140
+ last = @pointer == @length - 1
141
+ @pointer = last ? 1 : (@pointer + 1) & 0xff
142
+ [byte, last]
143
+ end
49
144
  end
50
145
  end
51
146
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The commands that read and write a block through a buffer channel,
7
+ # and B-P, which moves its pointer.
8
+ module BlockCommands
9
+ private
10
+
11
+ def block_read(arguments, counted: false)
12
+ channel, _drive, track, sector = arguments
13
+ data = fetch_block(channel, track, sector)
14
+ return unless data
15
+
16
+ buffer = @channels[channel]
17
+ buffer.load(data, counted ? data[0] + 1 : data.length)
18
+ buffer.pointer = 1 if counted
19
+ report_block_error(track, sector)
20
+ end
21
+
22
+ # A block write puts the buffer's block on the disk. On a
23
+ # write-protected disk it fails at the block it names.
24
+ def block_write(arguments, counted: false)
25
+ channel, _drive, track, sector = arguments
26
+ return unless fetch_block(channel, track, sector)
27
+
28
+ buffer = @channels[channel]
29
+ store_count(buffer) if counted
30
+ return report(WRITE_PROTECT_ON, track, sector) unless writable_disk?
31
+
32
+ writing { @storage.write_block(track, sector, buffer.block) }
33
+ end
34
+
35
+ # B-A marks a block in use. One already in use fails as NO BLOCK,
36
+ # naming the next free one.
37
+ def block_allocate(arguments)
38
+ _drive, track, sector = arguments
39
+ return unless bam_entry(track, sector)
40
+ return report(NO_BLOCK, *@storage.next_free_block(track, sector)) unless @storage.block_free?(track, sector)
41
+
42
+ writing { @storage.allocate_block(track, sector) }
43
+ end
44
+
45
+ # B-F marks a block free.
46
+ def block_free(arguments)
47
+ _drive, track, sector = arguments
48
+ writing { @storage.free_block(track, sector) } if bam_entry(track, sector)
49
+ end
50
+
51
+ # A BAM change needs a disk that takes writes and a block the BAM
52
+ # has an entry for. Returns whether both hold, once it has reported
53
+ # why not.
54
+ def bam_entry(track, sector)
55
+ return report(WRITE_PROTECT_ON) unless writable_disk?
56
+ return true if sector && @storage.bam_block?(track, sector)
57
+
58
+ report(ILLEGAL_TRACK_OR_SECTOR, track, sector)
59
+ end
60
+
61
+ # B-W first stores the index of the last byte written, the pointer
62
+ # less one but at least 1, as the block's first byte, which leaves
63
+ # the pointer at 1. B-R reads that count back.
64
+ def counted_block_write(arguments)
65
+ block_write(arguments, counted: true)
66
+ end
67
+
68
+ def store_count(buffer)
69
+ count = [buffer.pointer - 1, 1].max
70
+ buffer.pointer = 0
71
+ buffer.write([count])
72
+ end
73
+
74
+ # Block access needs an open buffer channel, a disk and a block on it.
75
+ # Returns the block, or nil once it has reported why there is none.
76
+ def fetch_block(channel, track, sector)
77
+ return report(NO_CHANNEL) unless @channels[channel]
78
+ return report(DRIVE_NOT_READY) unless @storage.respond_to?(:read_block)
79
+
80
+ data = sector && @storage.read_block(track, sector)
81
+ data || report(ILLEGAL_TRACK_OR_SECTOR, track, sector)
82
+ end
83
+
84
+ # A block the image's error table marks bad still fills the buffer,
85
+ # but the read reports its error, which copy protection checks for.
86
+ def report_block_error(track, sector)
87
+ error = @storage.block_error(track, sector)
88
+ error ? report(error, track, sector) : report(OK)
89
+ end
90
+
91
+ # B-R takes the block's first byte as the index of its last one, and
92
+ # hands out the bytes from the second up to there.
93
+ def counted_block_read(arguments)
94
+ block_read(arguments, counted: true)
95
+ end
96
+
97
+ def buffer_pointer(arguments)
98
+ channel, position = arguments
99
+ buffer = @channels[channel]
100
+ return report(NO_CHANNEL) unless buffer
101
+
102
+ buffer.pointer = position.to_i & 0xff
103
+ report(OK)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The drive's open data channels by secondary address, and the five
7
+ # buffers they hold. The DOS hands out the highest free buffer.
8
+ # Reading the BAM when it initializes the disk takes buffer 4, so
9
+ # channels get 3 down to 0, and the fifth data channel finds none.
10
+ class Channels
11
+ BAM_BUFFER = 4
12
+
13
+ def initialize
14
+ @channels = {}
15
+ end
16
+
17
+ def [](secondary) = @channels[secondary]
18
+
19
+ def open?(secondary) = @channels.key?(secondary)
20
+
21
+ def close(secondary) = @channels.delete(secondary)
22
+
23
+ def clear = @channels.clear
24
+
25
+ # Opens a buffer channel on the buffer a "#n" name asks for, or on
26
+ # the highest free one. Returns nil when that buffer isn't free,
27
+ # which leaves a channel asking again for its own buffer open.
28
+ def open_buffer(secondary, number, memory)
29
+ return if number && !free_buffers.include?(number)
30
+
31
+ close(secondary)
32
+ number ||= free_buffers.max
33
+ @channels[secondary] = Channel.buffer(memory, number) if number
34
+ end
35
+
36
+ # A file that opens takes a buffer, though its bytes don't pass
37
+ # through drive RAM here. One that fails to open takes none. Returns
38
+ # nil, leaving the channel closed, when no buffer is free.
39
+ def open_file(secondary, channel)
40
+ close(secondary)
41
+ channel.buffer = free_buffers.max unless channel.failed?
42
+ @channels[secondary] = channel if channel.failed? || channel.buffer
43
+ end
44
+
45
+ private
46
+
47
+ def free_buffers
48
+ (0...BAM_BUFFER).to_a - @channels.values.map(&:buffer)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The drive's 2K of RAM, as M-W and M-R reach it, with the job queue
7
+ # that runs a read job written into it: a job code for each of five
8
+ # buffers at $00, their track and sector pairs from $06, and the
9
+ # buffers from $0300. A job code is replaced by its result, 1 for
10
+ # success or an error table code: the DOS error less 18, or 15 for
11
+ # DRIVE NOT READY.
12
+ class Memory
13
+ SIZE = 0x800
14
+ JOBS = 5
15
+ READ_JOB = 0x80
16
+ JOB_OK = 1
17
+ HEADER_NOT_FOUND = 20
18
+
19
+ # The RAM itself, which buffer channels read and write in place.
20
+ attr_reader :ram
21
+
22
+ # The disk that read jobs read from.
23
+ attr_writer :storage
24
+
25
+ # Buffer n takes the page $0300 + n * $100.
26
+ def self.buffer_address(number)
27
+ 0x300 + (number * BLOCK_SIZE)
28
+ end
29
+
30
+ def initialize
31
+ @storage = nil
32
+ @ram = Array.new(SIZE, 0)
33
+ @bam = false
34
+ end
35
+
36
+ # Writes past the RAM are dropped, since the rest of the address
37
+ # space is I/O and ROM.
38
+ def write(address, bytes)
39
+ bytes.each.with_index(address) do |byte, target|
40
+ @ram[target] = byte if target < SIZE
41
+ end
42
+ run_jobs
43
+ end
44
+
45
+ # Only the RAM reads back.
46
+ def read(address, count)
47
+ Array.new(count) { |i| @ram.fetch(address + i, 0) }
48
+ end
49
+
50
+ # A reset through the reset vector clears the RAM, and the BAM
51
+ # with it, until the next open initializes the disk again.
52
+ def clear
53
+ @ram.fill(0)
54
+ @bam = false
55
+ end
56
+
57
+ # Initializing the disk reads its BAM, the header block, into buffer
58
+ # 4, where M-R finds it. Storage without blocks has none.
59
+ def read_bam
60
+ @bam = true
61
+ block = @storage.respond_to?(:header_block) && @storage.read_block(*@storage.header_block)
62
+ load_buffer(Channels::BAM_BUFFER, block) if block
63
+ end
64
+
65
+ # Whether the BAM has been read since the last reset.
66
+ def bam? = @bam
67
+
68
+ def load_buffer(number, block)
69
+ @ram[Memory.buffer_address(number), block.length] = block
70
+ end
71
+
72
+ # The first block of the file the last LOAD read, which the DOS
73
+ # keeps at $7E and $026F, or nil while the track there is 0.
74
+ def last_program
75
+ [@ram[0x7e], @ram[0x26f]] unless @ram[0x7e].zero?
76
+ end
77
+
78
+ def last_program=(block)
79
+ @ram[0x7e], @ram[0x26f] = block
80
+ end
81
+
82
+ private
83
+
84
+ def run_jobs
85
+ JOBS.times do |job|
86
+ next unless @ram[job] == READ_JOB
87
+
88
+ track, sector = @ram[6 + (job * 2), 2]
89
+ @ram[job] = read_job(job, track, sector)
90
+ end
91
+ end
92
+
93
+ def read_job(number, track, sector)
94
+ data = @storage.respond_to?(:read_block) && @storage.read_block(track, sector)
95
+ return job_result(HEADER_NOT_FOUND) unless data
96
+
97
+ load_buffer(number, data)
98
+ job_result(@storage.block_error(track, sector))
99
+ end
100
+
101
+ def job_result(error)
102
+ return JOB_OK unless error
103
+
104
+ error == DRIVE_NOT_READY ? 15 : error - 18
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The numbers a DOS command takes. The DOS skips spaces, commas and
7
+ # cursor-rights before a parameter, reads it from the characters $30
8
+ # to $3F that follow, the low nibble of each, and skips whatever
9
+ # character ends it. So "18,\x02" reads as 18 and 0, ":" to "?" are
10
+ # digits 10 to 15, and a run of digits keeps its last three.
11
+ module Parameters
12
+ # Space, comma and cursor-right
13
+ GAPS = [0x20, 0x2c, 0x1d].freeze
14
+
15
+ def self.parse(text)
16
+ rest = text.to_s.bytes
17
+ values = []
18
+ until (rest = rest.drop_while { |byte| GAPS.include?(byte) }).empty?
19
+ digits = rest.take_while { |byte| byte.between?(0x30, 0x3f) }
20
+ values << digits.last(3).reduce(0) { |value, digit| (value * 10) + (digit & 0x0f) }
21
+ rest = rest.drop(digits.length + 1)
22
+ end
23
+ values
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -9,6 +9,7 @@ module Badline
9
9
  class Status
10
10
  MESSAGES = [20, 21, 22, 23, 24, 27].to_h { |code| [code, "READ ERROR"] }.merge(
11
11
  0 => " OK",
12
+ 1 => " FILES SCRATCHED",
12
13
  25 => "WRITE ERROR",
13
14
  26 => "WRITE PROTECT ON",
14
15
  28 => "WRITE ERROR",
@@ -16,8 +17,10 @@ module Badline
16
17
  30 => "SYNTAX ERROR",
17
18
  62 => "FILE NOT FOUND",
18
19
  63 => "FILE EXISTS",
20
+ 65 => "NO BLOCK",
19
21
  66 => "ILLEGAL TRACK OR SECTOR",
20
22
  70 => "NO CHANNEL",
23
+ 72 => "DISK FULL",
21
24
  73 => "CBM DOS V2.6 1541",
22
25
  74 => "DRIVE NOT READY"
23
26
  ).freeze
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # A channel open for writing a file. It gathers the bytes sent to it,
7
+ # and the drive writes the file to the disk when the channel closes.
8
+ # An append starts from the file's end, a replace writes over the
9
+ # file of the same name.
10
+ class WriteFile
11
+ attr_accessor :buffer
12
+ attr_reader :name, :type, :mode
13
+
14
+ def initialize(name, type, mode: :write)
15
+ @name = name
16
+ @type = type
17
+ @mode = mode
18
+ @bytes = []
19
+ end
20
+
21
+ def write(bytes) = @bytes.concat(bytes)
22
+
23
+ def writable? = true
24
+
25
+ def failed? = false
26
+
27
+ # A file open for writing has nothing to send.
28
+ def read = nil
29
+
30
+ # Writes the gathered bytes to the storage.
31
+ def store(storage)
32
+ if @mode == :append
33
+ storage.append_file(@name, @bytes, type: @type)
34
+ else
35
+ storage.write_file(@name, @bytes, type: @type, replace: @mode == :replace)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # Opens for writing, the S command and the reports a write makes. A
7
+ # file open for writing takes the type its name asks for, or SEQ on a
8
+ # data channel. On a write-protected disk, or storage that doesn't
9
+ # write files through channels, an open for writing fails.
10
+ module Writes
11
+ private
12
+
13
+ # A name already on the disk fails as FILE EXISTS unless @ asks to
14
+ # replace it. A write-protected disk fails anything else and leaves
15
+ # the channel closed.
16
+ def open_write(secondary, name, file, type)
17
+ replace = name.start_with?("@")
18
+ if !replace && @storage.read_file(file, type: nil)
19
+ @channels.close(secondary)
20
+ return report(FILE_EXISTS)
21
+ end
22
+ return refuse_write(secondary) unless writable_disk?
23
+
24
+ open_writer(secondary, WriteFile.new(file, type || :seq, mode: replace ? :replace : :write))
25
+ end
26
+
27
+ # An A mode open needs the file on the disk.
28
+ def open_append(secondary, file, type)
29
+ unless @storage.read_file(file, type:)
30
+ @channels.close(secondary)
31
+ return report(FILE_NOT_FOUND)
32
+ end
33
+ return refuse_append(secondary, file, type) unless writable_disk?
34
+
35
+ open_writer(secondary, WriteFile.new(file, type, mode: :append))
36
+ end
37
+
38
+ def open_writer(secondary, channel)
39
+ @channels.open_file(secondary, channel) ? report(OK) : report(NO_CHANNEL)
40
+ end
41
+
42
+ def writable_disk? = @storage.respond_to?(:writable?) && @storage.writable?
43
+
44
+ # Runs a write to the disk and reports how it went. Returns whether
45
+ # the disk took it.
46
+ def writing
47
+ written = yield != false
48
+ @memory.read_bam if @memory.bam?
49
+ written ? report(OK) : report(WRITE_PROTECT_ON)
50
+ written
51
+ rescue Storage::WriteError => e
52
+ report(e.code)
53
+ false
54
+ end
55
+
56
+ # An open for writing on a write-protected disk fails as WRITE
57
+ # PROTECT ON.
58
+ def refuse_write(secondary)
59
+ @channels.close(secondary)
60
+ report(WRITE_PROTECT_ON, *protected_block(secondary))
61
+ end
62
+
63
+ # An append on a write-protected disk fails at the file's last block,
64
+ # the first one an append writes back.
65
+ def refuse_append(secondary, file, type)
66
+ @channels.close(secondary)
67
+ report(WRITE_PROTECT_ON, *(@storage.last_block(file, type:) if @storage.respond_to?(:last_block)))
68
+ end
69
+
70
+ # SAVE's channel fails at the directory block its entry would go to,
71
+ # a W mode open at the disk's header block.
72
+ def protected_block(secondary)
73
+ return [] unless @storage.respond_to?(:header_block)
74
+
75
+ secondary == 1 ? @storage.new_entry_block : @storage.header_block
76
+ end
77
+
78
+ # S takes names separated by commas, each of which can be a pattern,
79
+ # and reports how many files it scratched.
80
+ def scratch(names)
81
+ return report(WRITE_PROTECT_ON) unless writable_disk?
82
+
83
+ count = 0
84
+ scratched = writing do
85
+ names.split(",").each { |name| count += @storage.scratch(Storage.strip_drive_prefix(name)) }
86
+ end
87
+ report(FILES_SCRATCHED, count) if scratched
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end