badline 0.4.1 → 0.5.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/CLAUDE.md +10 -5
  4. data/README.md +59 -24
  5. data/doc/pinned-behaviour.md +221 -0
  6. data/exe/badline-ruby +8 -4
  7. data/lib/badline/address_bus/ultimax_pages.rb +38 -0
  8. data/lib/badline/address_bus.rb +19 -29
  9. data/lib/badline/audio/cli.rb +2 -1
  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 +5 -1
  14. data/lib/badline/computer.rb +86 -7
  15. data/lib/badline/cpu.rb +39 -0
  16. data/lib/badline/drive1541/bus.rb +134 -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 +184 -0
  21. data/lib/badline/drive1541/mechanism.rb +277 -0
  22. data/lib/badline/drive1541/rotation.rb +111 -0
  23. data/lib/badline/drive1541/sector_reader.rb +76 -0
  24. data/lib/badline/drive1541/serial_port.rb +67 -0
  25. data/lib/badline/drive1541/track.rb +98 -0
  26. data/lib/badline/drive1541.rb +218 -0
  27. data/lib/badline/gui/application.rb +24 -7
  28. data/lib/badline/gui/drive_led_pane.rb +29 -0
  29. data/lib/badline/gui.rb +1 -0
  30. data/lib/badline/iec_bus.rb +93 -0
  31. data/lib/badline/kernal_trap/channel.rb +9 -1
  32. data/lib/badline/kernal_trap/serial.rb +9 -3
  33. data/lib/badline/media/true_drive.rb +54 -0
  34. data/lib/badline/media.rb +22 -1
  35. data/lib/badline/options.rb +30 -4
  36. data/lib/badline/region.rb +35 -10
  37. data/lib/badline/reu/dma.rb +228 -0
  38. data/lib/badline/reu/ram.rb +65 -0
  39. data/lib/badline/reu/trigger.rb +32 -0
  40. data/lib/badline/reu.rb +276 -0
  41. data/lib/badline/roms/README +10 -0
  42. data/lib/badline/roms/dos1541.rom +0 -0
  43. data/lib/badline/sdl/functions.rb +1 -0
  44. data/lib/badline/storage/d64_image.rb +18 -9
  45. data/lib/badline/storage/d81_image.rb +1 -0
  46. data/lib/badline/storage/disk_image/catalog.rb +52 -0
  47. data/lib/badline/storage/disk_image/writing.rb +16 -0
  48. data/lib/badline/storage/disk_image.rb +2 -0
  49. data/lib/badline/storage/g64_image.rb +222 -0
  50. data/lib/badline/storage/host_directory.rb +21 -1
  51. data/lib/badline/storage/listing.rb +118 -0
  52. data/lib/badline/storage/t64.rb +14 -0
  53. data/lib/badline/storage.rb +2 -0
  54. data/lib/badline/traps.rb +4 -0
  55. data/lib/badline/version.rb +1 -1
  56. data/lib/badline/via/control_lines.rb +3 -0
  57. data/lib/badline/via/fast_forward.rb +38 -0
  58. data/lib/badline/via/shift_register.rb +8 -3
  59. data/lib/badline/via/timer1.rb +30 -0
  60. data/lib/badline/via/timer2.rb +25 -0
  61. data/lib/badline/via.rb +2 -0
  62. data/lib/badline/vic/display_state.rb +7 -7
  63. data/lib/badline/vic/sprite/internal_bus.rb +11 -7
  64. data/lib/badline/vic/sprite/shifter.rb +10 -15
  65. data/lib/badline/vic/sprite/timing.rb +60 -0
  66. data/lib/badline/vic/sprite.rb +14 -12
  67. data/lib/badline/vic/sprites.rb +2 -2
  68. data/lib/badline/vic.rb +86 -39
  69. data/lib/badline.rb +3 -0
  70. metadata +26 -1
@@ -0,0 +1,218 @@
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/idle"
11
+
12
+ module Badline
13
+ # The 1541 disk drive as a machine of its own: a 6502 running the DOS
14
+ # ROM at 1 MHz, with 2 KB of RAM and two VIAs on its bus. VIA 1 faces
15
+ # the serial bus and VIA 2 the disk mechanism.
16
+ #
17
+ # The drive runs on its own crystal, so it clocks against the host's
18
+ # clock through a fractional accumulator: each host cycle adds 1 MHz
19
+ # worth of phase, and every whole host period in it runs a drive cycle.
20
+ # Against the PAL C64's 985,248 Hz that's one drive cycle per host cycle
21
+ # and a second one about every 67. The host sets its clock on attaching
22
+ # the drive, and until then the drive runs one cycle per host cycle.
23
+ #
24
+ # VIA 2's port B runs the Mechanism, which reads a Disk put in with
25
+ # insert.
26
+ #
27
+ # While the DOS idles, host_cycle! skips the drive's cycles and catches
28
+ # up on them later, exactly (see Idle). The readers of the drive's parts
29
+ # catch up first, so they find the drive as running every cycle would
30
+ # have left it.
31
+ class Drive1541
32
+ include Idle
33
+
34
+ CLOCK_HZ = 1_000_000
35
+
36
+ attr_reader :device, :serial_bus
37
+
38
+ def cpu
39
+ settle!
40
+ @cpu
41
+ end
42
+
43
+ def bus
44
+ settle!
45
+ @bus
46
+ end
47
+
48
+ def via1
49
+ settle!
50
+ @via1
51
+ end
52
+
53
+ def via2
54
+ settle!
55
+ @via2
56
+ end
57
+
58
+ def mechanism
59
+ settle!
60
+ @mechanism
61
+ end
62
+
63
+ def cycles
64
+ settle!
65
+ @cycles
66
+ end
67
+
68
+ def ram = bus.ram
69
+
70
+ def host_clock_hz=(clock_hz)
71
+ settle!
72
+ @host_clock_hz = clock_hz
73
+ end
74
+
75
+ # +rom+ covers $C000-$FFFF, and defaults to the DOS image in the ROM
76
+ # path. +device+ is the number the jumpers on VIA 1's PB5 and PB6 set,
77
+ # 8 to 11.
78
+ def initialize(rom: nil, host_clock_hz: CLOCK_HZ, device: 8, debug: false)
79
+ @device = device
80
+ @serial_port = SerialPort.new(device:)
81
+ @via1 = VIA.new(start: 0x1800, peripheral: @serial_port)
82
+ @mechanism = Mechanism.new(self)
83
+ @via2 = DiskVIA.new(start: 0x1c00, mechanism: @mechanism)
84
+ @bus = Bus.new(rom: rom || ROM.load("dos1541.rom", 0xc000), via1: @via1, via2: @via2)
85
+ @cpu = CPU.new(@bus, debug:)
86
+ @host_clock_hz = host_clock_hz
87
+ @phase = 0
88
+ @cycles = 0
89
+ @so_pending = false
90
+ @serial_bus = nil
91
+ init_idle(debug)
92
+ # CA1 powers up at the level of a released ATN, without an edge.
93
+ @via1.ca1 = false
94
+ @via1.reset!
95
+ connect(IECBus.new)
96
+ end
97
+
98
+ # Plugs the drive into a serial bus, leaving the one it was on. A drive
99
+ # starts out on a bus of its own, with nothing else on it.
100
+ def connect(serial_bus)
101
+ settle!
102
+ @serial_bus&.detach(self)
103
+ @serial_bus = serial_bus
104
+ @serial_port.bus = serial_bus
105
+ serial_bus.attach(self)
106
+ @via1.ca1 = @serial_port.atn_low?
107
+ end
108
+
109
+ # Puts a Disk in the drive (Disk.from_d64 makes one from an image).
110
+ # Nil takes the disk out.
111
+ def insert(disk)
112
+ settle!
113
+ @mechanism.insert(disk)
114
+ end
115
+
116
+ def disk = mechanism.disk
117
+
118
+ # Whether the LED is lit. Reading it leaves the drive asleep: the LED
119
+ # is the same at the end of every pass the drive sleeps through.
120
+ def led_on? = @mechanism.led_on?
121
+
122
+ # VIA 1's port B as it drives the serial bus. It holds still while the
123
+ # drive sleeps, so reading it leaves the drive asleep.
124
+ def serial_output = @via1.port_b_output
125
+
126
+ # Stores what the head wrote since the motor last stopped in the
127
+ # disk's image, as the motor stopping does.
128
+ def flush
129
+ settle!
130
+ @mechanism.flush
131
+ end
132
+
133
+ # The serial bus's RESET line reaches the CPU and both VIAs. RAM keeps
134
+ # its contents.
135
+ def reset!
136
+ settle!
137
+ @via1.reset!
138
+ @via2.reset!
139
+ @cpu.reset!
140
+ end
141
+
142
+ # Runs the drive cycles that fall in one host cycle: none, one or two.
143
+ # The host has run this cycle already, and the drive sees what it did
144
+ # from the next one on (see SerialPort). Asleep, the drive owes the
145
+ # cycles instead, until ATN moves or its counters are due (see Idle).
146
+ def host_cycle!
147
+ return doze if @asleep
148
+
149
+ phase = @phase + CLOCK_HZ
150
+ while phase >= @host_clock_hz
151
+ @asleep ? @owed += 1 : run_cycle
152
+ phase -= @host_clock_hz
153
+ end
154
+ @phase = phase
155
+ settle! if @serial_port.atn_moved?
156
+ @serial_port.latch_host
157
+ plan_wake if @asleep
158
+ end
159
+
160
+ # Runs one drive cycle, catching up first on any the drive owes (see
161
+ # step).
162
+ def cycle!
163
+ settle!
164
+ step
165
+ end
166
+
167
+ def inspect
168
+ "#<#{self.class.name} cycles=#{cycles} cpu=(#{cpu.inspect})>"
169
+ end
170
+
171
+ # The read electronics signal a whole GCR byte. BYTE READY pulls VIA 2's
172
+ # CA1 low, a falling edge that sets its flag and, with latching on,
173
+ # latches port A. It reaches the CPU's SO pin while VIA 2's CA2 (SOE)
174
+ # is high, which lets the DOS spin on BVC for each byte. The CPU
175
+ # samples SO on the next cycle (see step).
176
+ def byte_ready!
177
+ @via2.ca1 = false
178
+ @so_pending = true if @via2.ca2_output
179
+ end
180
+
181
+ # BYTE READY lets go of CA1 with the next bit.
182
+ def byte_ready_ended!
183
+ @via2.ca1 = true
184
+ end
185
+
186
+ private
187
+
188
+ # A drive cycle from host_cycle!, which may find the idle loop.
189
+ def run_cycle
190
+ step
191
+ idle_loop_reached if @cpu.program_counter == IDLE_LOOP && @idle_skip
192
+ end
193
+
194
+ # One drive cycle. The VIAs clock ahead of the CPU, as the C64's chips
195
+ # do, and either one pulls IRQ. Nothing drives NMI on the 1541. The CPU
196
+ # runs whether or not the motor turns, since it answers ATN.
197
+ #
198
+ # The disk mechanism runs first, so BYTE READY lands on the VIA ahead
199
+ # of the CPU's cycle. The CPU samples SO a cycle late: BYTE READY sets
200
+ # V for the next cycle's instruction step.
201
+ #
202
+ # ATN reaches VIA 1's CA1 through the same inverter as PB7, so CA1 goes
203
+ # high as the C64 asserts ATN, as the serial port sees it: from the
204
+ # host cycle after the one that asserts it.
205
+ def step
206
+ so = @so_pending
207
+ @so_pending = false
208
+ @mechanism.cycle!
209
+ @via1.ca1 = @serial_port.atn_low?
210
+ @via1.cycle!
211
+ @via2.cycle!
212
+ @cpu.irq = @via1.irq? || @via2.irq?
213
+ @cpu.so! if so
214
+ @cpu.cycle!
215
+ @cycles += 1
216
+ end
217
+ end
218
+ end
@@ -28,14 +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, **media)
33
- @computer = Computer.new(sid_model: sid_model || Media.sid_model(media_path))
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)
36
+ @verbose = verbose
37
+ @computer = Computer.new(**machine_options(machine, media_path))
38
+ Media::TrueDrive.plug(@computer) if machine[:true_drive]
34
39
  puts Media.attach(@computer, media_path, **media) if media_path
35
40
 
36
41
  @mode = :keyboard
37
42
  @pot_device = nil
38
43
  @panes = [ScreenPane.new(@computer)]
44
+ @panes << DriveLedPane.new(@computer.drive1541, @panes.first) if @computer.drive1541
39
45
  @stream = open_stream if sound
40
46
  @paced = ENV["NOVSYNC"].nil?
41
47
  @window = Window.new(
@@ -44,7 +50,7 @@ module Badline
44
50
  vsync: @paced && !@stream
45
51
  )
46
52
  @gamepads = Gamepads.new(@computer)
47
- @gamepads.names.each { |name| puts "Gamepad: #{name}" }
53
+ @gamepads.names.each { |name| report "Gamepad: #{name}" }
48
54
  fit_frame
49
55
  end
50
56
 
@@ -59,10 +65,11 @@ module Badline
59
65
  @stream.pace(@frame_seconds) if @stream && @paced
60
66
  end
61
67
  ensure
68
+ @computer.drive1541&.flush
62
69
  @stream&.close
63
70
  @gamepads.close
64
71
  @window.close
65
- puts @computer.cpu.inspect
72
+ report @computer.cpu.inspect
66
73
  end
67
74
 
68
75
  private
@@ -155,19 +162,29 @@ module Badline
155
162
 
156
163
  def open_stream
157
164
  sink = Audio::SDLSink.new(rate: Audio::Renderer::DEFAULT_RATE)
158
- puts "Sound at #{sink.rate} Hz, F10 mutes"
165
+ report "Sound at #{sink.rate} Hz, F10 mutes"
159
166
  Audio::Stream.new(sink, @computer.sid,
160
167
  on_underrun: -> { puts "Running below real time, so the sound will stutter." })
161
168
  rescue Audio::SDLSink::Error => e
162
169
  warn "badline: no sound, can't open the audio device: #{e.message}"
163
170
  end
164
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
+
165
178
  def fit_frame
166
179
  rate = @window.refresh_rate
167
180
  clock_hz = @computer.region.clock_hz
168
181
  @cycles_per_frame = clock_hz / rate
169
182
  @frame_seconds = @cycles_per_frame.fdiv(clock_hz)
170
- puts "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
183
+ report "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
184
+ end
185
+
186
+ def report(line)
187
+ puts line if @verbose
171
188
  end
172
189
 
173
190
  def attach_pot_device
@@ -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,93 @@
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
+ end
43
+
44
+ def attach(drive)
45
+ @drives << drive unless @drives.include?(drive)
46
+ end
47
+
48
+ def detach(drive)
49
+ @drives.delete(drive)
50
+ end
51
+
52
+ # One pass over everything on the bus, for a reader that wants more
53
+ # than one line. +host+ is the C64's port A lines as the reader sees
54
+ # them, which a drive takes a cycle late (see Drive1541::SerialPort).
55
+ def low_lines(host = host_lines)
56
+ atn = host.anybits?(HOST_ATN_OUT)
57
+ low = atn ? ATN : 0
58
+ low |= CLK if host.anybits?(HOST_CLK_OUT)
59
+ low |= DATA if host.anybits?(HOST_DATA_OUT)
60
+ i = 0
61
+ while i < @drives.length
62
+ drive = @drives[i].serial_output
63
+ low |= CLK if drive.anybits?(DRIVE_CLK_OUT)
64
+ low |= DATA if drive.anybits?(DRIVE_DATA_OUT) || atn != drive.anybits?(DRIVE_ATNA)
65
+ i += 1
66
+ end
67
+ low
68
+ end
69
+
70
+ def atn_low?(host = host_lines) = host.anybits?(HOST_ATN_OUT)
71
+
72
+ def clk_low? = low_lines.anybits?(CLK)
73
+
74
+ def data_low? = low_lines.anybits?(DATA)
75
+
76
+ # CIA 2's port A pins as the bus leaves them: CLK IN and DATA IN low
77
+ # while their lines are.
78
+ def read_a(_port_a, _port_b)
79
+ low = low_lines
80
+ value = 0xff
81
+ value &= ~HOST_CLK_IN if low.anybits?(CLK)
82
+ value &= ~HOST_DATA_IN if low.anybits?(DATA)
83
+ value
84
+ end
85
+
86
+ # Port B goes to the user port, not the serial bus.
87
+ def read_b(_port_a, _port_b) = 0xff
88
+
89
+ # What the C64 pulls, as port A bits: its output bits, with input bits
90
+ # floating high as the 7406 inputs see them.
91
+ def host_lines = @host ? @host.port_a_lines : 0
92
+ end
93
+ 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)
@@ -6,7 +6,12 @@ module Badline
6
6
  # device 8 go to a virtual drive instead of out over the IEC lines, so
7
7
  # OPEN/CHKIN/CHRIN reach a CBM DOS, and so do the block reads that
8
8
  # loaders make through them. Other devices fall through to the ROM.
9
+ #
10
+ # With +device+ nil the traps answer no device, and every frame goes
11
+ # out over the bus, to a true drive if one is plugged in.
9
12
  class Serial < Routine
13
+ attr_accessor :device
14
+
10
15
  ROUTINES = {
11
16
  0xed09 => :talk,
12
17
  0xed0c => :listen,
@@ -29,9 +34,10 @@ module Badline
29
34
 
30
35
  NO_DATA = [0x0d, EOI | READ_TIMEOUT].freeze
31
36
 
32
- def initialize(cpu:, bus:, drive:)
37
+ def initialize(cpu:, bus:, drive:, device: DEVICE)
33
38
  super(cpu:, bus:)
34
39
  @drive = drive
40
+ @device = device
35
41
  @listening = false
36
42
  @talking = false
37
43
  @listen_channel = nil
@@ -66,13 +72,13 @@ module Badline
66
72
  # in the secondary address that follows.
67
73
  def talk
68
74
  @talk_channel = nil
69
- @talking = @cpu.a == DEVICE
75
+ @talking = @cpu.a == @device
70
76
  return_to_caller if @talking
71
77
  end
72
78
 
73
79
  def listen
74
80
  @listen_channel = nil
75
- @listening = @cpu.a == DEVICE
81
+ @listening = @cpu.a == @device
76
82
  return_to_caller if @listening
77
83
  end
78
84
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # Device 8 as a true 1541 instead of the KERNAL traps. Once a Drive1541
6
+ # is plugged in as device 8, disk media go into it rather than into the
7
+ # traps, so a LOAD runs the DOS and every byte crosses the serial bus.
8
+ # A .g64 holds the disk's raw GCR, which only a true drive reads, so it
9
+ # plugs one in when there's none yet. The 1541 reads only .d64 and .g64
10
+ # images, so the traps' other disk media, a .d71, .d81 or .t64 or a host
11
+ # directory, raise Error.
12
+ module TrueDrive
13
+ class Error < ArgumentError; end
14
+
15
+ class << self
16
+ # Plugs a Drive1541 in as device 8, unless one is there already, and
17
+ # returns it.
18
+ def plug(computer)
19
+ drive(computer) || Drive1541.new.tap { |drive| computer.attach_drive1541(drive) }
20
+ end
21
+
22
+ # The Drive1541 on device 8, or nil.
23
+ def drive(computer)
24
+ drive = computer.drive1541
25
+ drive if drive && drive.device == KernalTrap::Routine::DEVICE
26
+ end
27
+
28
+ # Whether the path goes in a true drive: a .g64 always, and other
29
+ # disk media, which the traps would mount, when a true drive is
30
+ # device 8.
31
+ def takes?(computer, path)
32
+ return true if File.extname(path).casecmp?(".g64")
33
+
34
+ !drive(computer).nil? && (File.directory?(path) || MOUNT_TYPES.key?(File.extname(path).downcase))
35
+ end
36
+
37
+ # Puts the .d64 or .g64 in the drive on device 8, plugging one in
38
+ # when there's none, and takes out what was mounted through the
39
+ # traps, so LOAD and SAVE reach the drive too. `read_only` puts it
40
+ # in write-protected.
41
+ def insert(computer, path, read_only: false)
42
+ unless %w[.d64 .g64].include?(File.extname(path).downcase)
43
+ raise Error, "#{path} is not a .d64 or .g64 image, which the 1541 reads"
44
+ end
45
+
46
+ disk = Drive1541::Disk.open(path, read_only:)
47
+ computer.unmount
48
+ plug(computer).insert(disk)
49
+ "Inserted #{path} in the 1541 as device 8"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/badline/media.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/media/true_drive"
4
+
3
5
  module Badline
4
6
  module Media
5
7
  AUTOSTART = %(lO"*",8,1\rrun\r)
@@ -19,8 +21,14 @@ module Badline
19
21
  # the Retro Replay's flash mode, and `disk: { read_only: true }` mounts
20
22
  # a disk image write-protected, so nothing the program does writes to
21
23
  # its file. Media they don't apply to ignore them.
24
+ #
25
+ # A .g64 plugs in a true drive as device 8 (TrueDrive), and with one
26
+ # there, a .d64 goes into it instead of the KERNAL traps too. The
27
+ # autostart then loads through it.
22
28
  def attach(computer, path, autostart: true, song: nil, **options)
23
- if File.directory?(path)
29
+ if TrueDrive.takes?(computer, path)
30
+ attach_true_drive(computer, path, options.fetch(:disk, {}), autostart:)
31
+ elsif File.directory?(path)
24
32
  computer.mount(Storage::HostDirectory.new(path))
25
33
  "Mounted #{path} as device 8"
26
34
  elsif File.extname(path).downcase == ".crt"
@@ -40,7 +48,14 @@ module Badline
40
48
  # while the machine runs, without loading anything. The drive keeps
41
49
  # its RAM and its status. `read_only` inserts a disk image
42
50
  # write-protected.
51
+ #
52
+ # A .g64 goes in a true 1541, which is plugged in as device 8 when
53
+ # there's none yet (TrueDrive). Once a true 1541 is device 8, a .d64
54
+ # goes in its drive as well, and other disks, which a 1541 can't
55
+ # read, raise TrueDrive::Error, an ArgumentError.
43
56
  def insert_disk(computer, path, read_only: false)
57
+ return TrueDrive.insert(computer, path, read_only:) if TrueDrive.takes?(computer, path)
58
+
44
59
  raise ArgumentError, "#{path} is not a disk image or a directory" unless disk?(path)
45
60
 
46
61
  computer.mount(open_storage(path, { read_only: }))
@@ -98,6 +113,12 @@ module Badline
98
113
  "Inserted #{path} in the datasette"
99
114
  end
100
115
 
116
+ def attach_true_drive(computer, path, disk, autostart:)
117
+ message = TrueDrive.insert(computer, path, read_only: disk.fetch(:read_only, false))
118
+ computer.type_text(AUTOSTART) if autostart
119
+ message
120
+ end
121
+
101
122
  def attach_storage(computer, path, disk, autostart:)
102
123
  computer.mount(open_storage(path, disk))
103
124
  computer.type_text(AUTOSTART) if autostart
@@ -21,9 +21,13 @@ module Badline
21
21
  badline-ruby --headless [options] tune.sid
22
22
  badline-ruby [options] tune.sid --audio-out FILE
23
23
 
24
- Media can be a .prg/.p00 program, a .d64/.d71/.d81 disk image,
25
- a .t64 tape archive, a .tap tape, a .crt cartridge, a .sid tune, or
26
- a directory to mount as device 8. It opens in the emulator window.
24
+ Media can be a .prg/.p00 program, a .d64/.d71/.d81 disk image, a
25
+ .g64 disk image for the true 1541, a .t64 tape archive, a .tap
26
+ tape, a .crt cartridge, a .sid tune, or a directory to mount as
27
+ device 8. It opens in the emulator window. Device 8 answers through
28
+ traps on the KERNAL's disk routines, unless --true-drive puts an
29
+ emulated 1541 there, which runs its own DOS and reads .d64 and .g64
30
+ images only.
27
31
 
28
32
  --headless plays a .sid tune on the host's audio device without the
29
33
  window, and --audio-out renders it to 16-bit PCM instead. The
@@ -43,7 +47,7 @@ module Badline
43
47
 
44
48
  BANNER
45
49
 
46
- attr_reader :media_path, :audio_out, :song, :seconds, :songlengths, :sid_model, :filter_chunk
50
+ attr_reader :media_path, :audio_out, :song, :seconds, :songlengths, :sid_model, :filter_chunk, :reu
47
51
 
48
52
  alias tune_path media_path
49
53
 
@@ -53,6 +57,9 @@ module Badline
53
57
  @autostart = true
54
58
  @read_only = false
55
59
  @sound = false
60
+ @verbose = false
61
+ @true_drive = false
62
+ @ntsc = false
56
63
  @headless = false
57
64
  @jit = true
58
65
  @quiet = false
@@ -96,6 +103,12 @@ module Badline
96
103
 
97
104
  def sound? = @sound
98
105
 
106
+ def verbose? = @verbose
107
+
108
+ def true_drive? = @true_drive
109
+
110
+ def ntsc? = @ntsc
111
+
99
112
  def quiet? = @quiet
100
113
 
101
114
  def tui? = @tui
@@ -165,6 +178,19 @@ module Badline
165
178
  opts.on("--sound", "Play the SID through the host's audio device (F10 mutes)") do
166
179
  window_only("--sound") { @sound = true }
167
180
  end
181
+ opts.on("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps") do
182
+ window_only("--true-drive") { @true_drive = true }
183
+ end
184
+ opts.on("--reu SIZE", %w[128 256 512 1024 2048 4096 8192 16384],
185
+ "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384") do |size|
186
+ window_only("--reu") { @reu = size.to_i }
187
+ end
188
+ opts.on("--ntsc", "Run an NTSC C64, with the 6567R8 VIC-II, instead of a PAL one") do
189
+ window_only("--ntsc") { @ntsc = true }
190
+ end
191
+ opts.on("--verbose", "Print the display, sound and gamepad setup and the frame timing") do
192
+ window_only("--verbose") { @verbose = true }
193
+ end
168
194
  end
169
195
 
170
196
  def define_headless_options(opts)