badline 0.4.2 → 0.5.1

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CLAUDE.md +32 -27
  4. data/CONTRIBUTING.md +5 -0
  5. data/README.md +55 -24
  6. data/doc/pinned-behaviour.md +223 -0
  7. data/exe/badline-ruby +7 -4
  8. data/lib/badline/address_bus/ultimax_pages.rb +38 -0
  9. data/lib/badline/address_bus.rb +19 -29
  10. data/lib/badline/audio/jukebox.rb +6 -4
  11. data/lib/badline/audio/machine_player.rb +10 -6
  12. data/lib/badline/cartridge/kcs_power.rb +3 -3
  13. data/lib/badline/cia.rb +21 -3
  14. data/lib/badline/computer.rb +86 -7
  15. data/lib/badline/cpu.rb +39 -0
  16. data/lib/badline/drive1541/bus.rb +197 -0
  17. data/lib/badline/drive1541/disk.rb +256 -0
  18. data/lib/badline/drive1541/disk_via.rb +33 -0
  19. data/lib/badline/drive1541/gcr.rb +57 -0
  20. data/lib/badline/drive1541/idle.rb +136 -0
  21. data/lib/badline/drive1541/mechanism.rb +277 -0
  22. data/lib/badline/drive1541/orbit.rb +81 -0
  23. data/lib/badline/drive1541/rotation.rb +111 -0
  24. data/lib/badline/drive1541/sector_reader.rb +76 -0
  25. data/lib/badline/drive1541/serial_port.rb +67 -0
  26. data/lib/badline/drive1541/sleep.rb +114 -0
  27. data/lib/badline/drive1541/track.rb +98 -0
  28. data/lib/badline/drive1541.rb +234 -0
  29. data/lib/badline/gui/application.rb +15 -3
  30. data/lib/badline/gui/drive_led_pane.rb +29 -0
  31. data/lib/badline/gui.rb +1 -0
  32. data/lib/badline/iec_bus.rb +109 -0
  33. data/lib/badline/kernal_trap/channel.rb +9 -1
  34. data/lib/badline/kernal_trap/serial.rb +9 -3
  35. data/lib/badline/media/true_drive.rb +54 -0
  36. data/lib/badline/media.rb +22 -1
  37. data/lib/badline/memory.rb +3 -0
  38. data/lib/badline/options.rb +24 -4
  39. data/lib/badline/region.rb +35 -10
  40. data/lib/badline/reu/dma.rb +228 -0
  41. data/lib/badline/reu/ram.rb +65 -0
  42. data/lib/badline/reu/trigger.rb +32 -0
  43. data/lib/badline/reu.rb +276 -0
  44. data/lib/badline/roms/README +10 -0
  45. data/lib/badline/roms/dos1541.rom +0 -0
  46. data/lib/badline/sdl/functions.rb +1 -0
  47. data/lib/badline/storage/d64_image.rb +18 -9
  48. data/lib/badline/storage/d81_image.rb +1 -0
  49. data/lib/badline/storage/disk_image/catalog.rb +52 -0
  50. data/lib/badline/storage/disk_image/writing.rb +16 -0
  51. data/lib/badline/storage/disk_image.rb +2 -0
  52. data/lib/badline/storage/g64_image.rb +222 -0
  53. data/lib/badline/storage/host_directory.rb +21 -1
  54. data/lib/badline/storage/listing.rb +118 -0
  55. data/lib/badline/storage/t64.rb +14 -0
  56. data/lib/badline/storage.rb +2 -0
  57. data/lib/badline/traps.rb +4 -0
  58. data/lib/badline/version.rb +1 -1
  59. data/lib/badline/via/control_lines.rb +3 -0
  60. data/lib/badline/via/fast_forward.rb +53 -0
  61. data/lib/badline/via/shift_register.rb +8 -3
  62. data/lib/badline/via/timer1.rb +36 -0
  63. data/lib/badline/via/timer2.rb +31 -0
  64. data/lib/badline/via.rb +2 -0
  65. data/lib/badline/vic/display_state.rb +7 -7
  66. data/lib/badline/vic/sprite/internal_bus.rb +11 -7
  67. data/lib/badline/vic/sprite/shifter.rb +10 -15
  68. data/lib/badline/vic/sprite/timing.rb +60 -0
  69. data/lib/badline/vic/sprite.rb +14 -12
  70. data/lib/badline/vic/sprites.rb +2 -2
  71. data/lib/badline/vic.rb +86 -39
  72. data/lib/badline.rb +3 -0
  73. metadata +28 -1
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # The drive asleep, as Idle and Orbit let it: host_cycle! counts the
6
+ # host cycles that go by, and waking turns them into drive cycles
7
+ # through the phase accumulator and runs them. Whole orbits and whole
8
+ # passes go in bulk, and the rest cycle by cycle, which may find the
9
+ # idle loop and put the drive back to sleep.
10
+ #
11
+ # The drive looks at ATN only when the C64 may have moved it, on a
12
+ # bus that says when (IECBus#notifies?), and every host cycle on one
13
+ # that doesn't.
14
+ module Sleep
15
+ # Whether the drive is skipping its idle loop right now.
16
+ def asleep? = @asleep
17
+
18
+ # Brings the drive up to the present, cycle for cycle, and drops the
19
+ # pass it was recording and the orbits it found, since whoever called
20
+ # may change it. Every reader of the drive's parts calls this.
21
+ def settle!
22
+ if @asleep
23
+ wake!
24
+ @serial_port.latch_host
25
+ @asleep = false
26
+ @orbit_cycles = nil
27
+ end
28
+ stop_recording if @recording
29
+ forget_orbits
30
+ end
31
+
32
+ private
33
+
34
+ def init_sleep
35
+ @asleep = false
36
+ @host_still = false
37
+ @owed = 0
38
+ @budget = 0
39
+ @slept = 0
40
+ @wake_at = 0
41
+ end
42
+
43
+ # A host cycle asleep that may wake the drive. The bus says whether
44
+ # the next ones need a look at ATN.
45
+ def doze
46
+ @host_still = @serial_bus.notifies?
47
+ if @serial_port.atn_moved?
48
+ settle!
49
+ elsif @slept == @wake_at
50
+ wake!
51
+ @serial_port.latch_host
52
+ plan_wake if @asleep
53
+ end
54
+ end
55
+
56
+ # Asleep, host_cycle! counts host cycles, and leaves it to wake! to
57
+ # turn them into drive cycles through the phase accumulator. This sets
58
+ # the host cycle that brings the drive cycles owed up to the budget.
59
+ # On an orbit the drive sleeps until something outside wakes it:
60
+ # @slept counts from 1, so it never comes to 0.
61
+ def plan_wake
62
+ @slept = 0
63
+ return @wake_at = 0 if @orbit_cycles
64
+
65
+ needed = ((@budget - @owed) * @host_clock_hz) - @phase
66
+ @wake_at = needed.positive? ? (needed + CLOCK_HZ - 1) / CLOCK_HZ : 1
67
+ end
68
+
69
+ # Runs the cycles owed.
70
+ def wake!
71
+ phase = @phase + (@slept * CLOCK_HZ)
72
+ @phase = phase % @host_clock_hz
73
+ owed = @owed + (phase / @host_clock_hz)
74
+ @owed = 0
75
+ @slept = 0
76
+ catch_up(owed)
77
+ end
78
+
79
+ # Runs +owed+ drive cycles: whole orbits and whole passes in bulk,
80
+ # passes only up to the budget, and the rest cycle by cycle, which
81
+ # may put the drive back to sleep.
82
+ def catch_up(owed)
83
+ while owed.positive?
84
+ if @asleep
85
+ if @orbit_cycles && owed >= @orbit_cycles
86
+ owed -= skip_orbits(owed / @orbit_cycles)
87
+ next
88
+ end
89
+ @orbit_cycles = nil
90
+ passes = [owed, @budget].min / @pass_cycles
91
+ if passes.positive?
92
+ owed -= skip_passes(passes)
93
+ next
94
+ end
95
+ @asleep = false
96
+ end
97
+ run_cycle
98
+ owed -= 1
99
+ end
100
+ end
101
+
102
+ # Runs whole passes, moving the counters on. Returns the cycles.
103
+ def skip_passes(passes)
104
+ cycles = passes * @pass_cycles
105
+ @cpu.fast_forward(cycles, passes * @pass_instructions)
106
+ @via1.fast_forward(cycles)
107
+ @via2.fast_forward(cycles)
108
+ @cycles += cycles
109
+ @budget -= cycles
110
+ cycles
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # One ring of flux on the disk, as the GCR bytes the head reads from it,
6
+ # most significant bit first, and the speed zone it was written in
7
+ # (0-3). The bytes wrap around: the last one's bits run into the
8
+ # first's. A track is as long as its bytes, which is what a .g64 image
9
+ # stores for each half track.
10
+ #
11
+ # The disk turns at 300 rpm, so every track passes under the head in
12
+ # the same 200 ms, and its bits pass at the rate they were written at.
13
+ # Each bit is a cell of time, starting at the track's index angle with
14
+ # the first byte's top bit, and a 1 bit is a flux transition at the
15
+ # start of its cell. The cells are spread evenly over the turn, so a
16
+ # track as long as a turn holds at its zone's rate
17
+ # (Disk::TRACK_LENGTHS) passes at that rate, within the part of a byte
18
+ # the length rounds off, and a longer or shorter one faster or slower.
19
+ # A .g64 speed map gives each byte the zone it was written in, and the
20
+ # cells of each byte are as wide as their zone's, scaled together to
21
+ # fill the turn.
22
+ #
23
+ # Times are in TICKs, 1/65536 of a tick of the drive's 16 MHz crystal,
24
+ # so the cells add up to a turn within a tick.
25
+ class Track
26
+ TICK = 1 << 16
27
+ TURN = 200_000 * 16 * TICK
28
+
29
+ # A bit cell at the zone's rate, in TICKs.
30
+ def self.cell(zone) = 4 * (16 - zone) * TICK
31
+
32
+ attr_reader :bytes, :zone, :speeds, :width, :widths
33
+
34
+ # +speeds+ holds each byte's zone, where the track's rate changes
35
+ # along it.
36
+ def initialize(bytes, zone, speeds = nil)
37
+ @bytes = bytes
38
+ @zone = zone
39
+ @speeds = speeds
40
+ @widths = speeds && scaled_widths(speeds)
41
+ @width = speeds ? @widths[0] : TURN / (bytes.length * 8)
42
+ end
43
+
44
+ def length = @bytes.length
45
+
46
+ # Whether the whole track was written at the +zone+'s rate.
47
+ def written_at?(zone) = @widths.nil? && @zone == zone
48
+
49
+ # The bit cell under the head +time+ TICKs into the turn, as
50
+ # [byte, mask, the time the cell ends].
51
+ def cell_at(time)
52
+ start = 0
53
+ index = 0
54
+ if @widths
55
+ while index < length - 1 && time >= start + (@widths[index] * 8)
56
+ start += @widths[index] * 8
57
+ index += 1
58
+ end
59
+ else
60
+ index = [time / (@width * 8), length - 1].min
61
+ start = index * @width * 8
62
+ end
63
+ width = @widths ? @widths[index] : @width
64
+ bit = [(time - start) / width, 7].min
65
+ cell_end = index == length - 1 && bit == 7 ? TURN : start + ((bit + 1) * width)
66
+ [index, 0x80 >> bit, cell_end]
67
+ end
68
+
69
+ # The track laid out again as one turn at the +zone+'s rate, each
70
+ # flux transition in the cell under it.
71
+ def relaid(zone)
72
+ bits = Disk::TRACK_LENGTHS[zone] * 8
73
+ cell = TURN / bits
74
+ relaid = Array.new(bits / 8, 0)
75
+ time = 0
76
+ @bytes.each_with_index do |byte, index|
77
+ width = @widths ? @widths[index] : @width
78
+ 8.times do |bit|
79
+ if byte.anybits?(0x80 >> bit)
80
+ cell_index = [time / cell, bits - 1].min
81
+ relaid[cell_index >> 3] |= 0x80 >> (cell_index & 7)
82
+ end
83
+ time += width
84
+ end
85
+ end
86
+ Track.new(relaid, zone)
87
+ end
88
+
89
+ private
90
+
91
+ def scaled_widths(speeds)
92
+ units = 0
93
+ speeds.each { |zone| units += 16 - zone }
94
+ speeds.map { |zone| TURN * (16 - zone) / (units * 8) }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/drive1541/bus"
4
+ require "badline/drive1541/serial_port"
5
+ require "badline/drive1541/gcr"
6
+ require "badline/drive1541/track"
7
+ require "badline/drive1541/disk"
8
+ require "badline/drive1541/mechanism"
9
+ require "badline/drive1541/disk_via"
10
+ require "badline/drive1541/sleep"
11
+ require "badline/drive1541/idle"
12
+ require "badline/drive1541/orbit"
13
+
14
+ module Badline
15
+ # The 1541 disk drive as a machine of its own: a 6502 running the DOS
16
+ # ROM at 1 MHz, with 2 KB of RAM and two VIAs on its bus. VIA 1 faces
17
+ # the serial bus and VIA 2 the disk mechanism.
18
+ #
19
+ # The drive runs on its own crystal, so it clocks against the host's
20
+ # clock through a fractional accumulator: each host cycle adds 1 MHz
21
+ # worth of phase, and every whole host period in it runs a drive cycle.
22
+ # Against the PAL C64's 985,248 Hz that's one drive cycle per host cycle
23
+ # and a second one about every 67. The host sets its clock on attaching
24
+ # the drive, and until then the drive runs one cycle per host cycle.
25
+ #
26
+ # VIA 2's port B runs the Mechanism, which reads a Disk put in with
27
+ # insert.
28
+ #
29
+ # While the DOS idles, host_cycle! skips the drive's cycles and catches
30
+ # up on them later, exactly (see Idle). The readers of the drive's parts
31
+ # catch up first, so they find the drive as running every cycle would
32
+ # have left it. Once the DOS's timer interrupts come round, the drive
33
+ # sleeps through them too (see Orbit).
34
+ class Drive1541
35
+ include Sleep
36
+ include Idle
37
+ include Orbit
38
+
39
+ CLOCK_HZ = 1_000_000
40
+
41
+ attr_reader :device, :serial_bus
42
+
43
+ def cpu
44
+ settle!
45
+ @cpu
46
+ end
47
+
48
+ def bus
49
+ settle!
50
+ @bus
51
+ end
52
+
53
+ def via1
54
+ settle!
55
+ @via1
56
+ end
57
+
58
+ def via2
59
+ settle!
60
+ @via2
61
+ end
62
+
63
+ def mechanism
64
+ settle!
65
+ @mechanism
66
+ end
67
+
68
+ def cycles
69
+ settle!
70
+ @cycles
71
+ end
72
+
73
+ def ram = bus.ram
74
+
75
+ def host_clock_hz=(clock_hz)
76
+ settle!
77
+ @host_clock_hz = clock_hz
78
+ end
79
+
80
+ # +rom+ covers $C000-$FFFF, and defaults to the DOS image in the ROM
81
+ # path. +device+ is the number the jumpers on VIA 1's PB5 and PB6 set,
82
+ # 8 to 11.
83
+ def initialize(rom: nil, host_clock_hz: CLOCK_HZ, device: 8, debug: false)
84
+ @device = device
85
+ @serial_port = SerialPort.new(device:)
86
+ @via1 = VIA.new(start: 0x1800, peripheral: @serial_port)
87
+ @mechanism = Mechanism.new(self)
88
+ @via2 = DiskVIA.new(start: 0x1c00, mechanism: @mechanism)
89
+ @bus = Bus.new(rom: rom || ROM.load("dos1541.rom", 0xc000), via1: @via1, via2: @via2)
90
+ @cpu = CPU.new(@bus, debug:)
91
+ @host_clock_hz = host_clock_hz
92
+ @phase = 0
93
+ @cycles = 0
94
+ @so_pending = false
95
+ @serial_bus = nil
96
+ init_idle(debug)
97
+ # CA1 powers up at the level of a released ATN, without an edge.
98
+ @via1.ca1 = false
99
+ @via1.reset!
100
+ connect(IECBus.new)
101
+ end
102
+
103
+ # Plugs the drive into a serial bus, leaving the one it was on. A drive
104
+ # starts out on a bus of its own, with nothing else on it.
105
+ def connect(serial_bus)
106
+ settle!
107
+ @serial_bus&.detach(self)
108
+ @serial_bus = serial_bus
109
+ @serial_port.bus = serial_bus
110
+ serial_bus.attach(self)
111
+ @via1.ca1 = @serial_port.atn_low?
112
+ end
113
+
114
+ # Puts a Disk in the drive (Disk.from_d64 makes one from an image).
115
+ # Nil takes the disk out.
116
+ def insert(disk)
117
+ settle!
118
+ @mechanism.insert(disk)
119
+ end
120
+
121
+ def disk = mechanism.disk
122
+
123
+ # Whether the LED is lit. Reading it leaves the drive asleep: the LED
124
+ # is the same at the end of every pass the drive sleeps through.
125
+ def led_on? = @mechanism.led_on?
126
+
127
+ # VIA 1's port B as it drives the serial bus. It holds still while the
128
+ # drive sleeps, so reading it leaves the drive asleep.
129
+ def serial_output = @via1.port_b_output
130
+
131
+ # Stores what the head wrote since the motor last stopped in the
132
+ # disk's image, as the motor stopping does.
133
+ def flush
134
+ settle!
135
+ @mechanism.flush
136
+ end
137
+
138
+ # The serial bus's RESET line reaches the CPU and both VIAs. RAM keeps
139
+ # its contents.
140
+ def reset!
141
+ settle!
142
+ @via1.reset!
143
+ @via2.reset!
144
+ @cpu.reset!
145
+ end
146
+
147
+ # Runs the drive cycles that fall in one host cycle: none, one or two.
148
+ # The host has run this cycle already, and the drive sees what it did
149
+ # from the next one on (see SerialPort). Asleep, the drive owes the
150
+ # cycles instead, until ATN moves or its counters are due (see Idle).
151
+ # It looks at ATN only after the C64 wrote CIA 2's port A, on a bus
152
+ # that says so (see host_written!).
153
+ def host_cycle!
154
+ if @asleep
155
+ return if (@slept += 1) != @wake_at && @host_still
156
+
157
+ return doze
158
+ end
159
+
160
+ phase = @phase + CLOCK_HZ
161
+ while phase >= @host_clock_hz
162
+ @asleep ? @owed += 1 : run_cycle
163
+ phase -= @host_clock_hz
164
+ end
165
+ @phase = phase
166
+ settle! if @serial_port.atn_moved?
167
+ @serial_port.latch_host
168
+ plan_wake if @asleep
169
+ end
170
+
171
+ # The C64 wrote CIA 2's port A, so ATN may have moved.
172
+ def host_written!
173
+ @host_still = false
174
+ end
175
+
176
+ # Runs one drive cycle, catching up first on any the drive owes (see
177
+ # step).
178
+ def cycle!
179
+ settle!
180
+ step
181
+ end
182
+
183
+ def inspect
184
+ "#<#{self.class.name} cycles=#{cycles} cpu=(#{cpu.inspect})>"
185
+ end
186
+
187
+ # The read electronics signal a whole GCR byte. BYTE READY pulls VIA 2's
188
+ # CA1 low, a falling edge that sets its flag and, with latching on,
189
+ # latches port A. It reaches the CPU's SO pin while VIA 2's CA2 (SOE)
190
+ # is high, which lets the DOS spin on BVC for each byte. The CPU
191
+ # samples SO on the next cycle (see step).
192
+ def byte_ready!
193
+ @via2.ca1 = false
194
+ @so_pending = true if @via2.ca2_output
195
+ end
196
+
197
+ # BYTE READY lets go of CA1 with the next bit.
198
+ def byte_ready_ended!
199
+ @via2.ca1 = true
200
+ end
201
+
202
+ private
203
+
204
+ # A drive cycle from host_cycle!, which may find the idle loop.
205
+ def run_cycle
206
+ step
207
+ idle_loop_reached if @cpu.program_counter == IDLE_LOOP && @idle_skip
208
+ end
209
+
210
+ # One drive cycle. The VIAs clock ahead of the CPU, as the C64's chips
211
+ # do, and either one pulls IRQ. Nothing drives NMI on the 1541. The CPU
212
+ # runs whether or not the motor turns, since it answers ATN.
213
+ #
214
+ # The disk mechanism runs first, so BYTE READY lands on the VIA ahead
215
+ # of the CPU's cycle. The CPU samples SO a cycle late: BYTE READY sets
216
+ # V for the next cycle's instruction step.
217
+ #
218
+ # ATN reaches VIA 1's CA1 through the same inverter as PB7, so CA1 goes
219
+ # high as the C64 asserts ATN, as the serial port sees it: from the
220
+ # host cycle after the one that asserts it.
221
+ def step
222
+ so = @so_pending
223
+ @so_pending = false
224
+ @mechanism.cycle!
225
+ @via1.ca1 = @serial_port.atn_low?
226
+ @via1.cycle!
227
+ @via2.cycle!
228
+ @cpu.irq = @via1.irq? || @via2.irq?
229
+ @cpu.so! if so
230
+ @cpu.cycle!
231
+ @cycles += 1
232
+ end
233
+ end
234
+ end
@@ -28,15 +28,20 @@ module Badline
28
28
  }.freeze
29
29
  MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
30
30
 
31
- # The media options (autostart:, song:, disk:) go to Media.attach.
32
- def initialize(media_path: nil, sid_model: nil, sound: false, verbose: false, **media)
31
+ # The machine options (sid_model:, reu:, true_drive:, ntsc:) build
32
+ # the machine: reu plugs in an REU of that many K, true_drive puts a
33
+ # true 1541 on device 8, and ntsc makes it an NTSC C64. The media
34
+ # options (autostart:, song:, disk:) go to Media.attach.
35
+ def initialize(media_path: nil, machine: {}, sound: false, verbose: false, **media)
33
36
  @verbose = verbose
34
- @computer = Computer.new(sid_model: sid_model || Media.sid_model(media_path))
37
+ @computer = Computer.new(**machine_options(machine, media_path))
38
+ Media::TrueDrive.plug(@computer) if machine[:true_drive]
35
39
  puts Media.attach(@computer, media_path, **media) if media_path
36
40
 
37
41
  @mode = :keyboard
38
42
  @pot_device = nil
39
43
  @panes = [ScreenPane.new(@computer)]
44
+ @panes << DriveLedPane.new(@computer.drive1541, @panes.first) if @computer.drive1541
40
45
  @stream = open_stream if sound
41
46
  @paced = ENV["NOVSYNC"].nil?
42
47
  @window = Window.new(
@@ -60,6 +65,7 @@ module Badline
60
65
  @stream.pace(@frame_seconds) if @stream && @paced
61
66
  end
62
67
  ensure
68
+ @computer.drive1541&.flush
63
69
  @stream&.close
64
70
  @gamepads.close
65
71
  @window.close
@@ -163,6 +169,12 @@ module Badline
163
169
  warn "badline: no sound, can't open the audio device: #{e.message}"
164
170
  end
165
171
 
172
+ def machine_options(machine, media_path)
173
+ options = { sid_model: machine[:sid_model] || Media.sid_model(media_path), **machine.slice(:reu) }
174
+ options[:region] = Region::NTSC if machine[:ntsc]
175
+ options
176
+ end
177
+
166
178
  def fit_frame
167
179
  rate = @window.refresh_rate
168
180
  clock_hz = @computer.region.clock_hz
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module GUI
5
+ # The true drive's red LED, in the bottom right corner of the screen
6
+ # pane's border: bright while the DOS lights it, as it does while a file
7
+ # is open or after an error, and dim otherwise.
8
+ class DriveLedPane < Pane
9
+ WIDTH = 12
10
+ HEIGHT = 4
11
+
12
+ LIT = [0xff, 0x20, 0x20].freeze
13
+ DARK = [0x40, 0x00, 0x00].freeze
14
+
15
+ def initialize(drive, screen)
16
+ super(width: WIDTH, height: HEIGHT,
17
+ left: screen.left + screen.width - WIDTH - 8, top: screen.top + screen.height - HEIGHT - 6)
18
+ @drive = drive
19
+ end
20
+
21
+ def color = @drive.led_on? ? LIT : DARK
22
+
23
+ def render(renderer)
24
+ SDL::SetRenderDrawColor.call(renderer, *color, 0xff)
25
+ SDL::RenderFillRect.call(renderer, @rect)
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/badline/gui.rb CHANGED
@@ -8,5 +8,6 @@ require "badline/gui/joy_map"
8
8
  require "badline/gui/gamepads"
9
9
  require "badline/gui/pane"
10
10
  require "badline/gui/screen_pane"
11
+ require "badline/gui/drive_led_pane"
11
12
  require "badline/gui/window"
12
13
  require "badline/gui/application"
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ # The serial bus between the C64 and its drives: ATN, CLK and DATA, each
5
+ # an open-collector line with a pull-up. A line is low while any device
6
+ # pulls it (wired-AND), and high otherwise. The bus holds no state of its
7
+ # own: every read works the levels out from what each side drives now.
8
+ #
9
+ # The C64 drives the lines from CIA 2's port A through 7406 inverters, so
10
+ # an output bit of 1 pulls its line low: PA3 ATN, PA4 CLK and PA5 DATA.
11
+ # PA6 and PA7 read CLK and DATA directly, 1 for a released line. The bus
12
+ # stands in as CIA 2's peripheral for those two inputs.
13
+ #
14
+ # A drive drives CLK and DATA from VIA 1's PB3 and PB1, the same way. It
15
+ # also pulls DATA while ATN IN differs from its ATN acknowledge on PB4
16
+ # (an XOR gate), which answers ATN in hardware before the DOS gets to it.
17
+ # SerialPort reads the lines back into VIA 1.
18
+ class IECBus
19
+ # CIA 2 port A
20
+ HOST_ATN_OUT = 0x08
21
+ HOST_CLK_OUT = 0x10
22
+ HOST_DATA_OUT = 0x20
23
+ HOST_CLK_IN = 0x40
24
+ HOST_DATA_IN = 0x80
25
+
26
+ # VIA 1 port B
27
+ DRIVE_DATA_OUT = 0x02
28
+ DRIVE_CLK_OUT = 0x08
29
+ DRIVE_ATNA = 0x10
30
+
31
+ # The lines pulled low, as ATN, CLK and DATA bits
32
+ ATN = 0x01
33
+ CLK = 0x02
34
+ DATA = 0x04
35
+
36
+ attr_reader :host, :drives
37
+
38
+ # +host+ is CIA 2, or nil for a bus with only drives on it.
39
+ def initialize(host: nil)
40
+ @host = host
41
+ @drives = []
42
+ @notifies = false
43
+ end
44
+
45
+ # Stands in as the host's peripheral, CIA 2's, and has it say when it
46
+ # writes port A (see notifies?).
47
+ def plug_in!
48
+ @host.peripheral = self
49
+ @host.on_port_a_write { host_written }
50
+ @notifies = true
51
+ end
52
+
53
+ # Whether the host says when it may have moved a line (see plug_in!).
54
+ # Without, a drive asleep checks ATN every cycle.
55
+ def notifies? = @notifies
56
+
57
+ # CIA 2 wrote its port A, which may have moved a line.
58
+ def host_written = @drives.each(&:host_written!)
59
+
60
+ def attach(drive)
61
+ @drives << drive unless @drives.include?(drive)
62
+ end
63
+
64
+ def detach(drive)
65
+ @drives.delete(drive)
66
+ end
67
+
68
+ # One pass over everything on the bus, for a reader that wants more
69
+ # than one line. +host+ is the C64's port A lines as the reader sees
70
+ # them, which a drive takes a cycle late (see Drive1541::SerialPort).
71
+ def low_lines(host = host_lines)
72
+ atn = host.anybits?(HOST_ATN_OUT)
73
+ low = atn ? ATN : 0
74
+ low |= CLK if host.anybits?(HOST_CLK_OUT)
75
+ low |= DATA if host.anybits?(HOST_DATA_OUT)
76
+ i = 0
77
+ while i < @drives.length
78
+ drive = @drives[i].serial_output
79
+ low |= CLK if drive.anybits?(DRIVE_CLK_OUT)
80
+ low |= DATA if drive.anybits?(DRIVE_DATA_OUT) || atn != drive.anybits?(DRIVE_ATNA)
81
+ i += 1
82
+ end
83
+ low
84
+ end
85
+
86
+ def atn_low?(host = host_lines) = host.anybits?(HOST_ATN_OUT)
87
+
88
+ def clk_low? = low_lines.anybits?(CLK)
89
+
90
+ def data_low? = low_lines.anybits?(DATA)
91
+
92
+ # CIA 2's port A pins as the bus leaves them: CLK IN and DATA IN low
93
+ # while their lines are.
94
+ def read_a(_port_a, _port_b)
95
+ low = low_lines
96
+ value = 0xff
97
+ value &= ~HOST_CLK_IN if low.anybits?(CLK)
98
+ value &= ~HOST_DATA_IN if low.anybits?(DATA)
99
+ value
100
+ end
101
+
102
+ # Port B goes to the user port, not the serial bus.
103
+ def read_b(_port_a, _port_b) = 0xff
104
+
105
+ # What the C64 pulls, as port A bits: its output bits, with input bits
106
+ # floating high as the 7406 inputs see them.
107
+ def host_lines = @host ? @host.port_a_lines : 0
108
+ end
109
+ end
@@ -53,8 +53,12 @@ module Badline
53
53
  # a LOAD (secondary address 0) opens at $7E and $026F of its memory,
54
54
  # and a LOAD of a name starting with "*" reopens that file rather than
55
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.
56
+ # to load a file the directory doesn't list. A LOAD of "$" reads the
57
+ # directory as a BASIC program, which a pattern after a colon narrows
58
+ # to the files it names.
57
59
  def self.for_name(storage, memory, secondary, name, type)
60
+ return new(storage.directory.bytes(name)) if listing?(storage, secondary, name)
61
+
58
62
  file = Storage.parse_name(name).first
59
63
  return for_file(storage, file, type) unless secondary.zero? && storage.respond_to?(:first_block)
60
64
 
@@ -66,6 +70,10 @@ module Badline
66
70
  for_file(storage, file, type)
67
71
  end
68
72
 
73
+ def self.listing?(storage, secondary, name)
74
+ secondary.zero? && name.start_with?("$") && storage.respond_to?(:directory)
75
+ end
76
+
69
77
  # The file whose chain starts at the block.
70
78
  def self.at_block(storage, track, sector)
71
79
  data = storage.read_file_at(track, sector)