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.
- checksums.yaml +4 -4
- data/.claude/hooks/session-start.sh +57 -0
- data/.claude/settings.json +14 -0
- data/CHANGELOG.md +107 -0
- data/CLAUDE.md +51 -16
- data/CONTRIBUTING.md +95 -3
- data/README.md +119 -51
- data/SECURITY.md +9 -0
- data/doc/pinned-behaviour.md +293 -39
- data/exe/badline-ruby +59 -0
- data/lib/badline/address_bus.rb +28 -14
- data/lib/badline/addressable.rb +1 -1
- data/lib/badline/audio/aiff.rb +8 -3
- data/lib/badline/audio/bare_player.rb +20 -3
- data/lib/badline/audio/cli.rb +17 -12
- data/lib/badline/audio/console.rb +12 -58
- data/lib/badline/audio/machine_player.rb +9 -6
- data/lib/badline/audio/playback.rb +3 -0
- data/lib/badline/audio/renderer.rb +3 -3
- data/lib/badline/audio/sdl_sink.rb +20 -52
- data/lib/badline/audio/stream.rb +117 -0
- data/lib/badline/audio/terminal.rb +90 -0
- data/lib/badline/audio.rb +3 -2
- data/lib/badline/cartridge/easy_flash.rb +6 -2
- data/lib/badline/cartridge/epyx_fastload.rb +1 -1
- data/lib/badline/cartridge/geo_ram.rb +63 -0
- data/lib/badline/cartridge/mach5.rb +1 -1
- data/lib/badline/cartridge/magic_desk.rb +5 -1
- data/lib/badline/cartridge/ocean.rb +5 -2
- data/lib/badline/cartridge/rex_utility.rb +1 -1
- data/lib/badline/cartridge/rgcd.rb +1 -1
- data/lib/badline/cartridge/simons_basic.rb +2 -2
- data/lib/badline/cartridge/westermann.rb +2 -2
- data/lib/badline/cartridge.rb +9 -0
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia/interrupt_register.rb +34 -10
- data/lib/badline/cia.rb +19 -10
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +34 -14
- data/lib/badline/cpu.rb +2 -2
- data/lib/badline/gui/application.rb +70 -26
- data/lib/badline/gui/gamepads.rb +22 -22
- data/lib/badline/gui/joy_map.rb +1 -1
- data/lib/badline/gui/key_map.rb +6 -1
- data/lib/badline/gui/pane.rb +7 -6
- data/lib/badline/gui/screen_pane.rb +9 -13
- data/lib/badline/gui/window.rb +35 -20
- data/lib/badline/gui.rb +1 -1
- data/lib/badline/input/mouse1351.rb +21 -6
- data/lib/badline/input/paddles.rb +11 -6
- data/lib/badline/kernal_trap/channel.rb +102 -7
- data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
- data/lib/badline/kernal_trap/drive/channels.rb +53 -0
- data/lib/badline/kernal_trap/drive/memory.rb +109 -0
- data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
- data/lib/badline/kernal_trap/drive/status.rb +3 -0
- data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
- data/lib/badline/kernal_trap/drive/writes.rb +92 -0
- data/lib/badline/kernal_trap/drive.rb +109 -131
- data/lib/badline/kernal_trap/file.rb +6 -14
- data/lib/badline/kernal_trap/load.rb +6 -0
- data/lib/badline/kernal_trap/save.rb +13 -7
- data/lib/badline/keyboard.rb +19 -3
- data/lib/badline/media.rb +44 -13
- data/lib/badline/memory.rb +2 -2
- data/lib/badline/options.rb +209 -0
- data/lib/badline/ram_expansion/banking.rb +30 -0
- data/lib/badline/ram_expansion/plus256k.rb +38 -0
- data/lib/badline/ram_expansion/plus60k.rb +31 -0
- data/lib/badline/ram_expansion/unexpanded.rb +22 -0
- data/lib/badline/ram_expansion.rb +21 -0
- data/lib/badline/region.rb +51 -0
- data/lib/badline/rom.rb +14 -5
- data/lib/badline/sdl/events.rb +47 -0
- data/lib/badline/sdl/functions.rb +73 -0
- data/lib/badline/sdl.rb +67 -0
- data/lib/badline/sid/catch_up.rb +110 -0
- data/lib/badline/sid/decimator.rb +6 -1
- data/lib/badline/sid/filter.rb +5 -5
- data/lib/badline/sid/waveform/fast_forward.rb +10 -10
- data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
- data/lib/badline/sid/waveform.rb +8 -8
- data/lib/badline/sid.rb +19 -93
- data/lib/badline/storage/d64_image.rb +10 -0
- data/lib/badline/storage/d71_image.rb +12 -0
- data/lib/badline/storage/d81_image.rb +9 -0
- data/lib/badline/storage/disk_image/bam.rb +111 -0
- data/lib/badline/storage/disk_image/directory.rb +79 -0
- data/lib/badline/storage/disk_image/writing.rb +110 -0
- data/lib/badline/storage/disk_image.rb +49 -4
- data/lib/badline/storage/host_directory.rb +17 -4
- data/lib/badline/storage/sid_file.rb +36 -2
- data/lib/badline/storage.rb +18 -1
- data/lib/badline/time_of_day.rb +11 -2
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +104 -0
- data/lib/badline/via/interrupt_register.rb +58 -0
- data/lib/badline/via/shift_register.rb +135 -0
- data/lib/badline/via/timer1.rb +82 -0
- data/lib/badline/via/timer2.rb +84 -0
- data/lib/badline/via.rb +262 -0
- data/lib/badline/vic/bank.rb +4 -3
- data/lib/badline/vic/color_patches.rb +6 -3
- data/lib/badline/vic/display_state.rb +4 -0
- data/lib/badline/vic/graphics_shifter.rb +49 -8
- data/lib/badline/vic/registers.rb +19 -27
- data/lib/badline/vic/sequencer.rb +18 -18
- data/lib/badline/vic/sequencer_output.rb +4 -4
- data/lib/badline/vic/sprite/internal_bus.rb +4 -0
- data/lib/badline/vic/sprite/shifter.rb +25 -3
- data/lib/badline/vic/sprite.rb +5 -4
- data/lib/badline/vic/sprites.rb +8 -3
- data/lib/badline/vic.rb +98 -46
- data/lib/badline.rb +5 -0
- data/packaging/homebrew/badline.rb +34 -0
- metadata +37 -20
- data/exe/badline +0 -75
- data/exe/badline-sid +0 -34
- 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 :
|
|
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
|
-
|
|
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
|
-
|
|
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 >= @
|
|
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
|