badline 0.2.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -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 +6 -4
  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 +8 -0
  24. data/lib/badline/checkpoint.rb +98 -0
  25. data/lib/badline/cia.rb +4 -2
  26. data/lib/badline/color_memory.rb +2 -2
  27. data/lib/badline/computer.rb +11 -6
  28. data/lib/badline/cpu.rb +2 -2
  29. data/lib/badline/gui/application.rb +41 -9
  30. data/lib/badline/kernal_trap/channel.rb +99 -7
  31. data/lib/badline/kernal_trap/drive/block_commands.rb +79 -0
  32. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  33. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  34. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  35. data/lib/badline/kernal_trap/drive.rb +88 -110
  36. data/lib/badline/kernal_trap/file.rb +0 -5
  37. data/lib/badline/kernal_trap/load.rb +6 -0
  38. data/lib/badline/kernal_trap/save.rb +5 -4
  39. data/lib/badline/media.rb +2 -2
  40. data/lib/badline/memory.rb +2 -2
  41. data/lib/badline/rom.rb +14 -5
  42. data/lib/badline/sid/catch_up.rb +110 -0
  43. data/lib/badline/sid/decimator.rb +6 -1
  44. data/lib/badline/sid/filter.rb +5 -5
  45. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  46. data/lib/badline/sid/waveform.rb +7 -7
  47. data/lib/badline/sid.rb +16 -92
  48. data/lib/badline/storage/disk_image.rb +22 -0
  49. data/lib/badline/storage/host_directory.rb +15 -3
  50. data/lib/badline/storage/sid_file.rb +36 -2
  51. data/lib/badline/storage.rb +3 -1
  52. data/lib/badline/time_of_day.rb +7 -0
  53. data/lib/badline/version.rb +1 -1
  54. data/lib/badline/vic/graphics_shifter.rb +8 -2
  55. data/lib/badline/vic/registers.rb +16 -27
  56. data/lib/badline/vic.rb +5 -2
  57. data/lib/badline.rb +1 -0
  58. metadata +8 -1
@@ -168,10 +168,18 @@ module Badline
168
168
  Bank.new(data)
169
169
  end
170
170
 
171
+ # A bank the image leaves out reads as erased ROM.
171
172
  def bank(banks, number)
172
173
  banks[number] || EMPTY_BANK
173
174
  end
174
175
 
176
+ # The bank register bits a ROM decodes. The ROM holds the highest bank
177
+ # in the image, and has address lines for the power of two that covers
178
+ # it; the register bits above those lines are ignored.
179
+ def bank_mask(banks)
180
+ (1 << (banks.length - 1).bit_length) - 1
181
+ end
182
+
175
183
  # Sorts the CHIP packets into ROML and ROMH banks by load address. A 16K
176
184
  # chip at $8000 spans both.
177
185
  def banks_from(chips)
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ # A digest of the machine's state at one cycle, for comparing two runs of
5
+ # the same scenario, on two builds or two revisions. Each component is
6
+ # hashed on its own with 32-bit FNV-1a, so a mismatch names the part of the
7
+ # machine that went its own way. Nothing is read through a chip's bus
8
+ # interface, so taking a checkpoint leaves the machine as it was.
9
+ #
10
+ # Colour RAM is digested as the four bits each cell stores: the upper
11
+ # nibble a CPU read sees comes off the open bus.
12
+ class Checkpoint
13
+ COMPONENTS = %w[cpu ram color_ram display vic cia1 cia2 sid].freeze
14
+
15
+ FNV_OFFSET = 0x811c9dc5
16
+ FNV_PRIME = 0x01000193
17
+
18
+ attr_reader :cycle, :digests
19
+
20
+ class << self
21
+ def take(computer)
22
+ new(computer.cycles, [
23
+ fnv1a(cpu_state(computer.cpu)),
24
+ fnv1a(ram(computer.ram)),
25
+ fnv1a(color_ram(computer.address_bus.color_ram)),
26
+ fnv1a(computer.vic.display),
27
+ fnv1a(vic_state(computer.vic)),
28
+ fnv1a(cia_state(computer.cia1)),
29
+ fnv1a(cia_state(computer.cia2)),
30
+ fnv1a(Array.new(0x20) { |reg| computer.sid.register(reg) })
31
+ ])
32
+ end
33
+
34
+ # Reads a line written by #to_s.
35
+ def parse(line)
36
+ fields = line.split
37
+ digests = COMPONENTS.map do |name|
38
+ field = fields.find { |f| f.start_with?("#{name}=") }
39
+ raise ArgumentError, "no #{name} digest in #{line.inspect}" unless field
40
+
41
+ field.split("=").last.to_i(16)
42
+ end
43
+ new(fields.first.to_i, digests)
44
+ end
45
+
46
+ def fnv1a(values)
47
+ hash = FNV_OFFSET
48
+ values.each { |value| hash = ((hash ^ (value & 0xffffffff)) * FNV_PRIME) & 0xffffffff }
49
+ hash
50
+ end
51
+
52
+ private
53
+
54
+ def cpu_state(cpu)
55
+ [cpu.program_counter, cpu.a, cpu.x, cpu.y, cpu.stack_pointer, cpu.p, cpu.cycles]
56
+ end
57
+
58
+ def ram(memory)
59
+ Array.new(0x10000) { |addr| memory.peek(addr) }
60
+ end
61
+
62
+ def color_ram(memory)
63
+ Array.new(0x400) { |offset| memory.nibble(0xd800 + offset) }
64
+ end
65
+
66
+ def vic_state(vic)
67
+ vic.register_file + [vic.rasterline, vic.column]
68
+ end
69
+
70
+ def cia_state(cia)
71
+ start = cia.start
72
+ [cia.peek(start), cia.peek(start + 1), cia.peek(start + 2), cia.peek(start + 3),
73
+ cia.timer_a, cia.timer_a_latch, cia.timer_b, cia.timer_b_latch, cia.serial.data,
74
+ cia.control_a.value, cia.control_b.value, cia.interrupt_status.value,
75
+ cia.interrupt_control.value] + cia.time_of_day.registers
76
+ end
77
+ end
78
+
79
+ def initialize(cycle, digests)
80
+ @cycle = cycle
81
+ @digests = digests
82
+ end
83
+
84
+ # The components whose digests differ from `other`'s.
85
+ def differences(other)
86
+ COMPONENTS.each_index.reject { |i| digests[i] == other.digests[i] }.map { |i| COMPONENTS[i] }
87
+ end
88
+
89
+ def ==(other)
90
+ other.is_a?(Checkpoint) && cycle == other.cycle && digests == other.digests
91
+ end
92
+
93
+ def to_s
94
+ fields = COMPONENTS.each_index.map { |i| "#{COMPONENTS[i]}=#{digests[i].to_s(16).rjust(8, '0')}" }
95
+ "#{cycle} #{fields.join(' ')}"
96
+ end
97
+ end
98
+ end
data/lib/badline/cia.rb CHANGED
@@ -17,6 +17,8 @@ module Badline
17
17
 
18
18
  def interrupt!(delay = 1) = @icr.assert!(delay)
19
19
 
20
+ def time_of_day = @tod
21
+
20
22
  def timer_a = @ta.counter
21
23
 
22
24
  def timer_a=(value)
@@ -115,7 +117,7 @@ module Badline
115
117
  end
116
118
 
117
119
  def peek(addr)
118
- case index(addr) & 0x0f
120
+ case offset_of(addr) & 0x0f
119
121
  when 0x00 then read_port_a
120
122
  when 0x01 then read_port_b
121
123
  when 0x02 then @data_dir_a
@@ -136,7 +138,7 @@ module Badline
136
138
  end
137
139
 
138
140
  def poke(addr, value)
139
- case index(addr) & 0x0f
141
+ case offset_of(addr) & 0x0f
140
142
  when 0x00 then @data_port_a = value
141
143
  when 0x01 then update_port_b { @data_port_b = value }
142
144
  when 0x02 then @data_dir_a = value
@@ -10,7 +10,7 @@ module Badline
10
10
  end
11
11
 
12
12
  def peek(addr)
13
- (@vic.phi1_data & 0xf0) | @storage[index(addr)]
13
+ (@vic.phi1_data & 0xf0) | @storage[offset_of(addr)]
14
14
  end
15
15
 
16
16
  def poke(addr, value)
@@ -19,7 +19,7 @@ module Badline
19
19
 
20
20
  # The four bits the cell stores, as the VIC's c-access sees them.
21
21
  def nibble(addr)
22
- @storage[index(addr)]
22
+ @storage[offset_of(addr)]
23
23
  end
24
24
  end
25
25
  end
@@ -45,6 +45,7 @@ module Badline
45
45
  @freeze_writes = 0
46
46
  @init_handlers = []
47
47
  @pending_keys = nil
48
+ @drive = nil
48
49
  end
49
50
 
50
51
  INIT_THRESHOLD = 2_500_000
@@ -92,14 +93,16 @@ module Badline
92
93
  reset!
93
94
  end
94
95
 
95
- # The RES line reaches the CPU and its port, both CIAs, the SID and the
96
- # cartridge port. The VIC has no reset pin.
96
+ # The RES line reaches the CPU and its port, both CIAs, the SID, the
97
+ # cartridge port and, through the serial bus's RESET line, the drive.
98
+ # The VIC has no reset pin.
97
99
  def reset!
98
100
  address_bus.reset_port!
99
101
  @cia1.reset!
100
102
  @cia2.reset!
101
103
  @sid.reset!
102
104
  address_bus.cartridge&.reset
105
+ @drive&.reset!
103
106
  @freezing = false
104
107
  @nmi_asserted = false
105
108
  cpu.reset!
@@ -120,14 +123,16 @@ module Badline
120
123
  address_bus.cartridge&.release_button
121
124
  end
122
125
 
126
+ # A disk swap keeps the drive's RAM, which only a drive reset clears.
123
127
  def mount(storage)
124
- drive = KernalTrap::Drive.new(storage)
125
- load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, drive:)
128
+ @drive_memory ||= KernalTrap::Drive::Memory.new
129
+ @drive = KernalTrap::Drive.new(storage, @drive_memory)
130
+ load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, drive: @drive)
126
131
  cpu.install_trap(KernalTrap::Load::ADDRESS) { load_trap.call }
127
- KernalTrap::Serial.new(cpu:, bus: address_bus, drive:).install
132
+ KernalTrap::Serial.new(cpu:, bus: address_bus, drive: @drive).install
128
133
  return unless storage.respond_to?(:write_file)
129
134
 
130
- save_trap = KernalTrap::Save.new(cpu:, bus: address_bus, storage:)
135
+ save_trap = KernalTrap::Save.new(cpu:, bus: address_bus, drive: @drive)
131
136
  cpu.install_trap(KernalTrap::Save::ADDRESS) { save_trap.call }
132
137
  end
133
138
 
data/lib/badline/cpu.rb CHANGED
@@ -24,10 +24,10 @@ module Badline
24
24
 
25
25
  # +ane_constant+ is ANE's magic constant, which varies from chip to
26
26
  # chip. The default is the C64 6510's.
27
- def initialize(memory = nil, debug: false, ane_constant: 0xef)
27
+ def initialize(memory, debug: false, ane_constant: 0xef)
28
28
  @debug = debug
29
29
  @ane_constant = ane_constant
30
- @memory = memory || Memory.new
30
+ @memory = memory
31
31
  @status = CPUStatus.new(STATUS_FLAGS, value: 0b00100000)
32
32
  reset_registers
33
33
 
@@ -6,6 +6,7 @@ module Badline
6
6
  PAL_CLOCK_HZ = 985_248
7
7
  TITLE = "Badline"
8
8
  TOGGLE_SYM = SDL2::Key::TAB
9
+ MUTE_SYM = SDL2::Key::F10
9
10
  REVERSE_MOD = SDL2::Key::Mod::SHIFT
10
11
 
11
12
  SHARED_KEYS = %i[up left cursor_h cursor_v space w a s d lshift].freeze
@@ -28,24 +29,23 @@ module Badline
28
29
  }.freeze
29
30
  MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
30
31
 
31
- def initialize(media_path: nil, autostart: true, song: nil, sid_model: nil, debug: false)
32
- @computer = Computer.new(debug:, sid_model: sid_model || Media.sid_model(media_path))
32
+ def initialize(media_path: nil, autostart: true, song: nil, sid_model: nil, sound: false)
33
+ @computer = Computer.new(sid_model: sid_model || Media.sid_model(media_path))
33
34
  puts Media.attach(@computer, media_path, autostart:, song:) if media_path
34
35
 
35
36
  @mode = :keyboard
36
37
  @pot_device = nil
37
38
  @panes = [ScreenPane.new(@computer)]
39
+ @stream = open_stream if sound
40
+ @paced = ENV["NOVSYNC"].nil?
38
41
  @window = Window.new(
39
42
  title: TITLE,
40
43
  width: canvas_width, height: canvas_height,
41
- vsync: ENV["NOVSYNC"].nil?
44
+ vsync: @paced && !@stream
42
45
  )
43
46
  @gamepads = Gamepads.new(@computer)
44
47
  @gamepads.names.each { |name| puts "Gamepad: #{name}" }
45
-
46
- rate = @window.refresh_rate
47
- @cycles_per_frame = PAL_CLOCK_HZ / rate
48
- puts "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
48
+ fit_frame
49
49
  end
50
50
 
51
51
  def run
@@ -54,9 +54,12 @@ module Badline
54
54
  handle_events
55
55
  @gamepads.poll
56
56
  @cycles_per_frame.times { @computer.cycle! }
57
+ @stream&.feed
57
58
  @window.draw(@panes)
59
+ @stream.pace(@frame_seconds) if @stream && @paced
58
60
  end
59
61
  ensure
62
+ @stream&.close
60
63
  @gamepads.close
61
64
  puts @computer.cpu.inspect
62
65
  end
@@ -84,6 +87,7 @@ module Badline
84
87
 
85
88
  def handle_key_down(event)
86
89
  return cycle_mode(event.mod.anybits?(REVERSE_MOD) ? -1 : 1) if event.sym == TOGGLE_SYM
90
+ return toggle_mute if event.sym == MUTE_SYM
87
91
 
88
92
  port, dir = JoyMap.parse(event) if @mode == :joystick
89
93
  if port
@@ -94,7 +98,7 @@ module Badline
94
98
  end
95
99
 
96
100
  def handle_key_up(event)
97
- return if event.sym == TOGGLE_SYM
101
+ return if [TOGGLE_SYM, MUTE_SYM].include?(event.sym)
98
102
 
99
103
  port, dir = JoyMap.parse(event) if @mode == :joystick
100
104
  if port
@@ -123,7 +127,35 @@ module Badline
123
127
  @mode = modes[(modes.index(@mode) + step) % modes.size]
124
128
  release_inputs
125
129
  attach_pot_device
126
- @window.title = [TITLE, MODES[@mode] && "[#{MODES[@mode]}]"].compact.join(" ")
130
+ update_title
131
+ end
132
+
133
+ def toggle_mute
134
+ return unless @stream
135
+
136
+ @stream.toggle_mute
137
+ update_title
138
+ end
139
+
140
+ def update_title
141
+ tags = [MODES[@mode], @stream&.muted? && "MUTED"].select(&:itself)
142
+ @window.title = [TITLE, *tags.map { |tag| "[#{tag}]" }].join(" ")
143
+ end
144
+
145
+ def open_stream
146
+ sink = Audio::SDLSink.new(rate: Audio::Renderer::DEFAULT_RATE)
147
+ puts "Sound at #{sink.rate} Hz, F10 mutes"
148
+ Audio::Stream.new(sink, @computer.sid,
149
+ on_underrun: -> { puts "Running below real time, so the sound will stutter." })
150
+ rescue Audio::SDLSink::Error => e
151
+ warn "badline: no sound, can't open the audio device: #{e.message}"
152
+ end
153
+
154
+ def fit_frame
155
+ rate = @window.refresh_rate
156
+ @cycles_per_frame = PAL_CLOCK_HZ / rate
157
+ @frame_seconds = @cycles_per_frame.fdiv(PAL_CLOCK_HZ)
158
+ puts "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
127
159
  end
128
160
 
129
161
  def attach_pot_device
@@ -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,95 @@ 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
+
38
101
  def exhausted?
39
- @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
40
117
  end
41
118
 
42
119
  def read
120
+ return read_buffer if @writable
43
121
  return if exhausted?
44
122
 
45
- byte = @bytes[@pointer]
123
+ byte = @lead || @bytes[@base + @pointer]
124
+ @lead = nil
46
125
  @pointer += 1
47
126
  [byte, exhausted? && !@error]
48
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]
140
+ end
49
141
  end
50
142
  end
51
143
  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