badline 0.2.0 → 0.3.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/CLAUDE.md +6 -0
  4. data/README.md +24 -6
  5. data/doc/pinned-behaviour.md +18 -6
  6. data/exe/badline +5 -2
  7. data/lib/badline/address_bus.rb +14 -6
  8. data/lib/badline/addressable.rb +1 -1
  9. data/lib/badline/audio/bare_player.rb +20 -3
  10. data/lib/badline/audio/machine_player.rb +9 -6
  11. data/lib/badline/audio/renderer.rb +3 -3
  12. data/lib/badline/audio/stream.rb +117 -0
  13. data/lib/badline/audio.rb +1 -0
  14. data/lib/badline/cartridge/easy_flash.rb +6 -2
  15. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  16. data/lib/badline/cartridge/mach5.rb +1 -1
  17. data/lib/badline/cartridge/magic_desk.rb +5 -1
  18. data/lib/badline/cartridge/ocean.rb +5 -2
  19. data/lib/badline/cartridge/rex_utility.rb +1 -1
  20. data/lib/badline/cartridge/rgcd.rb +1 -1
  21. data/lib/badline/cartridge/simons_basic.rb +2 -2
  22. data/lib/badline/cartridge/westermann.rb +2 -2
  23. data/lib/badline/cartridge.rb +16 -8
  24. data/lib/badline/checkpoint.rb +98 -0
  25. data/lib/badline/cia/interrupt_register.rb +20 -6
  26. data/lib/badline/cia.rb +36 -19
  27. data/lib/badline/color_memory.rb +2 -2
  28. data/lib/badline/computer.rb +41 -11
  29. data/lib/badline/cpu/microcode.rb +41 -43
  30. data/lib/badline/cpu.rb +3 -3
  31. data/lib/badline/gui/application.rb +41 -9
  32. data/lib/badline/kernal_trap/channel.rb +118 -8
  33. data/lib/badline/kernal_trap/drive/block_commands.rb +79 -0
  34. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  35. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  36. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  37. data/lib/badline/kernal_trap/drive/status.rb +49 -0
  38. data/lib/badline/kernal_trap/drive.rb +148 -132
  39. data/lib/badline/kernal_trap/file.rb +0 -5
  40. data/lib/badline/kernal_trap/load.rb +93 -25
  41. data/lib/badline/kernal_trap/routine.rb +17 -0
  42. data/lib/badline/kernal_trap/save.rb +48 -15
  43. data/lib/badline/kernal_trap/serial.rb +34 -2
  44. data/lib/badline/media.rb +2 -2
  45. data/lib/badline/memory.rb +7 -2
  46. data/lib/badline/rom.rb +14 -5
  47. data/lib/badline/sid/catch_up.rb +110 -0
  48. data/lib/badline/sid/decimator.rb +6 -1
  49. data/lib/badline/sid/filter.rb +5 -5
  50. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  51. data/lib/badline/sid/waveform.rb +7 -7
  52. data/lib/badline/sid.rb +16 -92
  53. data/lib/badline/status.rb +255 -32
  54. data/lib/badline/storage/d64_image.rb +6 -0
  55. data/lib/badline/storage/d71_image.rb +4 -0
  56. data/lib/badline/storage/d81_image.rb +4 -0
  57. data/lib/badline/storage/disk_image.rb +78 -17
  58. data/lib/badline/storage/host_directory.rb +26 -2
  59. data/lib/badline/storage/sid_file.rb +37 -3
  60. data/lib/badline/storage.rb +3 -1
  61. data/lib/badline/time_of_day.rb +7 -0
  62. data/lib/badline/version.rb +1 -1
  63. data/lib/badline/vic/graphics_shifter.rb +8 -2
  64. data/lib/badline/vic/registers.rb +16 -27
  65. data/lib/badline/vic.rb +23 -10
  66. data/lib/badline.rb +1 -0
  67. metadata +9 -1
@@ -4,29 +4,139 @@ module Badline
4
4
  module KernalTrap
5
5
  # One open channel on the virtual drive, holding a file, a block buffer
6
6
  # or the command channel's status message. It gives out one byte at a
7
- # time and flags the last one, so the bus can raise EOI with it.
7
+ # time and flags the last one, so the bus can raise EOI with it. A
8
+ # channel cut short by a read error never flags one, and holds the
9
+ # error for the drive to report once its bytes run out.
8
10
  class Channel
9
- attr_accessor :pointer
11
+ attr_accessor :buffer
12
+ attr_reader :error, :pointer
13
+ attr_writer :lead
10
14
 
11
- def initialize(bytes = [])
15
+ def initialize(bytes = [], error: nil, writable: false, base: 0)
16
+ @base = base
12
17
  replace(bytes)
18
+ @error = error
19
+ @writable = writable
13
20
  end
14
21
 
15
- def replace(bytes)
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
36
+ end
37
+
38
+ # A file's bytes, up to the first block its chain can't read. The
39
+ # drive reads a block ahead before sending the last byte of the one
40
+ # before, so a bad block holds that byte back too, and a bad first
41
+ # block or a missing file leaves nothing to send.
42
+ def self.for_file(storage, name, type)
43
+ data = storage.read_file(name, type:)
44
+ return new([], error: [Drive::FILE_NOT_FOUND]) unless data
45
+
46
+ failure = storage.respond_to?(:read_error) && storage.read_error(name, type:)
47
+ return new(data) unless failure
48
+
49
+ new(data[0, failure[:offset] - 1] || [], error: failure.values_at(:error, :track, :sector))
50
+ end
51
+
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)
16
76
  @bytes = bytes
17
- @pointer = 0
77
+ rewind(length)
78
+ end
79
+
80
+ def rewind(length)
81
+ @length = length
82
+ self.pointer = 0
18
83
  end
19
84
 
85
+ def pointer=(position)
86
+ @pointer = position
87
+ @lead = nil
88
+ end
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
+
20
101
  def exhausted?
21
- @pointer >= @bytes.length
102
+ @pointer >= @length
103
+ end
104
+
105
+ # A file that holds nothing but its error never opened.
106
+ def failed?
107
+ exhausted? && !@error.nil?
108
+ end
109
+
110
+ # Each byte lands at the pointer, which wraps within the block.
111
+ def write(bytes)
112
+ @lead = nil
113
+ bytes.each do |byte|
114
+ @bytes[@base + @pointer] = byte
115
+ @pointer = (@pointer + 1) & 0xff
116
+ end
22
117
  end
23
118
 
24
119
  def read
120
+ return read_buffer if @writable
25
121
  return if exhausted?
26
122
 
27
- byte = @bytes[@pointer]
123
+ byte = @lead || @bytes[@base + @pointer]
124
+ @lead = nil
28
125
  @pointer += 1
29
- [byte, exhausted?]
126
+ [byte, exhausted? && !@error]
127
+ end
128
+
129
+ private
130
+
131
+ # A buffer never runs out. Past its last byte, which goes out with
132
+ # EOI, the pointer wraps back to the second, and a pointer set past
133
+ # the end runs on around the block to it.
134
+ def read_buffer
135
+ byte = @lead || @bytes[@base + @pointer]
136
+ @lead = nil
137
+ last = @pointer == @length - 1
138
+ @pointer = last ? 1 : (@pointer + 1) & 0xff
139
+ [byte, last]
30
140
  end
31
141
  end
32
142
  end
@@ -0,0 +1,79 @@
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
+ # The disk is write-protected, so a block write that gets as far as
23
+ # the disk 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
+ store_count(@channels[channel]) if counted
29
+ report(WRITE_PROTECT_ON, track, sector)
30
+ end
31
+
32
+ # B-W first stores the index of the last byte written, the pointer
33
+ # less one but at least 1, as the block's first byte, which leaves
34
+ # the pointer at 1. B-R reads that count back.
35
+ def counted_block_write(arguments)
36
+ block_write(arguments, counted: true)
37
+ end
38
+
39
+ def store_count(buffer)
40
+ count = [buffer.pointer - 1, 1].max
41
+ buffer.pointer = 0
42
+ buffer.write([count])
43
+ end
44
+
45
+ # Block access needs an open buffer channel, a disk and a block on it.
46
+ # Returns the block, or nil once it has reported why there is none.
47
+ def fetch_block(channel, track, sector)
48
+ return report(NO_CHANNEL) unless @channels[channel]
49
+ return report(DRIVE_NOT_READY) unless @storage.respond_to?(:read_block)
50
+
51
+ data = sector && @storage.read_block(track, sector)
52
+ data || report(ILLEGAL_TRACK_OR_SECTOR, track, sector)
53
+ end
54
+
55
+ # A block the image's error table marks bad still fills the buffer,
56
+ # but the read reports its error, which copy protection checks for.
57
+ def report_block_error(track, sector)
58
+ error = @storage.block_error(track, sector)
59
+ error ? report(error, track, sector) : report(OK)
60
+ end
61
+
62
+ # B-R takes the block's first byte as the index of its last one, and
63
+ # hands out the bytes from the second up to there.
64
+ def counted_block_read(arguments)
65
+ block_read(arguments, counted: true)
66
+ end
67
+
68
+ def buffer_pointer(arguments)
69
+ channel, position = arguments
70
+ buffer = @channels[channel]
71
+ return report(NO_CHANNEL) unless buffer
72
+
73
+ buffer.pointer = position.to_i & 0xff
74
+ report(OK)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ 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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The command channel's side of the drive: the last DOS error with the
7
+ # track and sector it happened at, or the bytes an M-R read. Once its
8
+ # last byte has been read, it holds OK again.
9
+ class Status
10
+ MESSAGES = [20, 21, 22, 23, 24, 27].to_h { |code| [code, "READ ERROR"] }.merge(
11
+ 0 => " OK",
12
+ 25 => "WRITE ERROR",
13
+ 26 => "WRITE PROTECT ON",
14
+ 28 => "WRITE ERROR",
15
+ 29 => "DISK ID MISMATCH",
16
+ 30 => "SYNTAX ERROR",
17
+ 62 => "FILE NOT FOUND",
18
+ 63 => "FILE EXISTS",
19
+ 66 => "ILLEGAL TRACK OR SECTOR",
20
+ 70 => "NO CHANNEL",
21
+ 73 => "CBM DOS V2.6 1541",
22
+ 74 => "DRIVE NOT READY"
23
+ ).freeze
24
+
25
+ def initialize
26
+ @channel = Channel.new
27
+ end
28
+
29
+ def report(code, track = 0, sector = 0)
30
+ message = format("%<code>02d,%<message>s,%<track>02d,%<sector>02d\r",
31
+ code:, message: MESSAGES.fetch(code),
32
+ track: track.to_i, sector: sector.to_i)
33
+ @channel.replace(message.bytes)
34
+ nil
35
+ end
36
+
37
+ def replace(bytes)
38
+ @channel.replace(bytes)
39
+ end
40
+
41
+ def read
42
+ result = @channel.read
43
+ report(0) if result&.last
44
+ result
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end