badline 0.15.0 → 0.15.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/CLAUDE.md +7 -0
  4. data/README.md +40 -10
  5. data/doc/media.md +19 -0
  6. data/doc/snapshot-format.md +61 -13
  7. data/exe/badline-ruby +1 -1
  8. data/lib/badline/audio/machine_player.rb +2 -14
  9. data/lib/badline/c64/true_drives.rb +13 -1
  10. data/lib/badline/c64.rb +6 -0
  11. data/lib/badline/chips/tia.rb +16 -0
  12. data/lib/badline/chips/vic/register_log.rb +4 -1
  13. data/lib/badline/drives/datasette/keyed_state.rb +8 -3
  14. data/lib/badline/drives/engine/core.rb +6 -14
  15. data/lib/badline/drives/engine/idle.rb +42 -4
  16. data/lib/badline/drives/engine/idle_cpu.rb +18 -0
  17. data/lib/badline/drives/engine/serial_connection.rb +25 -0
  18. data/lib/badline/drives/engine/sleep.rb +1 -0
  19. data/lib/badline/drives/engine.rb +1 -0
  20. data/lib/badline/drives/model1541/byte_ready.rb +19 -3
  21. data/lib/badline/drives/model1541/mechanism.rb +10 -1
  22. data/lib/badline/drives/model1541.rb +1 -0
  23. data/lib/badline/drives/model1551/bus.rb +138 -0
  24. data/lib/badline/drives/model1551/cable.rb +104 -0
  25. data/lib/badline/drives/model1551/cpu.rb +14 -0
  26. data/lib/badline/drives/model1551/keyed_state.rb +65 -0
  27. data/lib/badline/drives/model1551/port.rb +74 -0
  28. data/lib/badline/drives/model1551/tia.rb +92 -0
  29. data/lib/badline/drives/model1551.rb +170 -0
  30. data/lib/badline/drives/model1571.rb +1 -0
  31. data/lib/badline/drives/model1581.rb +1 -0
  32. data/lib/badline/frontend/boot.rb +20 -1
  33. data/lib/badline/frontend/menu/menu_dialogs.rb +13 -5
  34. data/lib/badline/frontend/menu/menu_media.rb +92 -14
  35. data/lib/badline/frontend/menu/menu_pages.rb +11 -6
  36. data/lib/badline/frontend/player/player_controller.rb +11 -4
  37. data/lib/badline/machine.rb +9 -5
  38. data/lib/badline/media/true_drive.rb +7 -5
  39. data/lib/badline/options/help.rb +4 -2
  40. data/lib/badline/options/option.rb +2 -1
  41. data/lib/badline/options/table.rb +14 -2
  42. data/lib/badline/options/validation.rb +49 -9
  43. data/lib/badline/options.rb +34 -2
  44. data/lib/badline/plus4/acia/frames.rb +55 -0
  45. data/lib/badline/plus4/acia.rb +276 -16
  46. data/lib/badline/plus4/bus.rb +181 -45
  47. data/lib/badline/plus4/keyed_modules.rb +22 -5
  48. data/lib/badline/plus4/keyed_parts.rb +33 -4
  49. data/lib/badline/plus4/keyed_state.rb +5 -2
  50. data/lib/badline/plus4/ram_expansion.rb +145 -0
  51. data/lib/badline/plus4/sid_card.rb +95 -0
  52. data/lib/badline/plus4/sound_mix.rb +51 -0
  53. data/lib/badline/plus4/tcbm_drive.rb +120 -0
  54. data/lib/badline/plus4/user_port.rb +11 -2
  55. data/lib/badline/plus4.rb +103 -21
  56. data/lib/badline/roms/README +9 -0
  57. data/lib/badline/roms/dos1551.rom +0 -0
  58. data/lib/badline/snapshot/field_doc.rb +7 -5
  59. data/lib/badline/snapshot/plus4_setup.rb +52 -10
  60. data/lib/badline/storage/g64_image.rb +1 -1
  61. data/lib/badline/version.rb +1 -1
  62. metadata +15 -1
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Drives
5
+ class Model1551
6
+ # The drive's 6523 TIA at $4000-$7FFF, its eight registers mirrored
7
+ # (zimmers.net's 1551 memory map, schematic 251860), a Chips::TIA
8
+ # with what the drive's side wires to it:
9
+ #
10
+ # - Port A carries the cable's eight data lines, pulled up.
11
+ # - Port B reads the GCR byte the gate array latched at BYTE READY
12
+ # and drives the byte the head writes.
13
+ # - Port C drives STATUS0 and STATUS1 (PC0-1), DEV (PC2), which picks
14
+ # where the computer's TIA answers, ACK (PC3) and the head's MODE
15
+ # (PC4, low to write), and reads the device number jumper (PC5,
16
+ # high for device 9), SYNC (PC6, low in a SYNC mark) and DAV (PC7).
17
+ #
18
+ # The DOS sets port C's directions to $1F. The cable's lines are
19
+ # pulled up, so an input nothing drives reads 1.
20
+ #
21
+ # Any access to the TIA clears BYTE LATCHED.
22
+ class TIA
23
+ DAV = 0x80
24
+ SYNC = 0x40
25
+ JUMPER = 0x20
26
+ MODE = 0x10
27
+
28
+ # The chip, whose inputs the drive's side sets before each read.
29
+ attr_reader :chip
30
+
31
+ def initialize(mechanism, device:)
32
+ @mechanism = mechanism
33
+ @jumper = device == 9 ? JUMPER : 0
34
+ @chip = Chips::TIA.new
35
+ inputs = @chip.inputs
36
+ inputs[0] = 0xff
37
+ inputs[1] = 0xff
38
+ inputs[2] = 0xff
39
+ mechanism.tia = self
40
+ mode_written
41
+ end
42
+
43
+ def ports = @chip.ports
44
+
45
+ def directions = @chip.directions
46
+
47
+ def inputs = @chip.inputs
48
+
49
+ def reset!
50
+ @chip.reset!
51
+ mode_written
52
+ end
53
+
54
+ def peek(addr)
55
+ mechanism = @mechanism
56
+ mechanism.release_byte_latch
57
+ chip = @chip
58
+ inputs = chip.inputs
59
+ case addr & 0x07
60
+ when 1 then inputs[1] = mechanism.latched_byte
61
+ when 2 then inputs[2] = (inputs[2] & DAV) | (mechanism.sync? ? 0 : SYNC) | @jumper | 0x1f
62
+ end
63
+ chip.peek(addr)
64
+ end
65
+
66
+ def poke(addr, value)
67
+ @mechanism.release_byte_latch
68
+ @chip.poke(addr, value)
69
+ register = addr & 0x07
70
+ mode_written if [2, 5].include?(register)
71
+ end
72
+
73
+ # What port B drives onto the head's write shift register.
74
+ def port_b_output = @chip.output(1)
75
+
76
+ # The chip's registers, in a keyed snapshot module (BL-D8-TIA).
77
+ def save_fields(out) = @chip.save_fields(out)
78
+
79
+ def load_fields(input)
80
+ @chip.load_fields(input)
81
+ end
82
+
83
+ private
84
+
85
+ # MODE selects reading while high, as the 1541's CB2 does.
86
+ def mode_written
87
+ @mechanism.cb2_written(@chip.output(2).anybits?(MODE))
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/chips/tia"
4
+ require "badline/drives/model1541"
5
+ require "badline/drives/model1551/cpu"
6
+ require "badline/drives/model1551/port"
7
+ require "badline/drives/model1551/tia"
8
+ require "badline/drives/model1551/bus"
9
+ require "badline/drives/model1551/cable"
10
+ require "badline/drives/model1551/keyed_state"
11
+
12
+ module Badline
13
+ module Drives
14
+ # The 1551 disk drive of the 264 series: a 6510T at 2 MHz running DOS
15
+ # 2.6 from 16 KB of ROM, with 2 KB of RAM, the 1541's GCR mechanism
16
+ # behind a gate array, and a 6523 TIA facing the computer over TCBM, a
17
+ # parallel cable to a second 6523 in the computer's expansion port
18
+ # (Commodore schematic 251860; zimmers.net's 1551 memory map).
19
+ #
20
+ # The 6510T's port drives the mechanism as the 1541's VIA 2 port B does
21
+ # and reads the write-protect sensor and the gate array's BYTE LATCHED
22
+ # (Port). The TIA's port B reads the GCR byte the gate array latched and
23
+ # drives the byte to write, and its port C selects reading or writing
24
+ # and reads SYNC (TIA).
25
+ #
26
+ # The drive comes with its interface for the computer's expansion port,
27
+ # a second 6523 (#host_tia), which the cable joins to the drive's
28
+ # (Cable). The computer puts it at $FEE0 for device 8 and at $FEC0 for
29
+ # device 9.
30
+ #
31
+ # A 555 timer pulls IRQ low for about 7 µs every 8.3 ms, its R1 120K,
32
+ # R2 100R and C1 0.1 µF, which runs the DOS's job loop. Nothing
33
+ # acknowledges it: the line lets go on its own.
34
+ #
35
+ # Drives::Engine::Core clocks it against the host and sleeps through
36
+ # the DOS's idle loop, which counts its passes in $61 (Engine::Idle's
37
+ # pass counter), until the 555 is due to pull IRQ, the count wraps or
38
+ # the computer moves the cable's lines.
39
+ class Model1551
40
+ include Engine::Core
41
+
42
+ CLOCK_HZ = 2_000_000
43
+
44
+ # The 555's cycle in drive cycles: high for 0.693 (R1 + R2) C and low,
45
+ # pulling IRQ, for 0.693 R2 C.
46
+ IRQ_PERIOD = 16_660
47
+ IRQ_LOW = 14
48
+
49
+ # The host cycles, at about 1 MHz, by which a 1551 switched on with the
50
+ # machine has run its DOS's reset, the RAM test and the ROM checksum,
51
+ # and answers TCBM: half a second, with a margin.
52
+ BOOT_CYCLES = 600_000
53
+
54
+ # Where the DOS's idle loop starts over, polling the TIA's port A for
55
+ # a command from the computer.
56
+ IDLE_LOOP = 0xeabd
57
+
58
+ # The byte the idle loop increments on each pass.
59
+ PASS_COUNTER = 0x61
60
+
61
+ def port
62
+ settle!
63
+ @port
64
+ end
65
+
66
+ def tia
67
+ settle!
68
+ @tia
69
+ end
70
+
71
+ # The TIA in the computer's expansion port, which the computer reads
72
+ # and writes without waking the drive.
73
+ attr_reader :host_tia
74
+
75
+ # The cable between the two TIAs.
76
+ def cable
77
+ settle!
78
+ @cable
79
+ end
80
+
81
+ def model_name = "1551"
82
+
83
+ # +rom+ covers $C000-$FFFF, and defaults to the DOS image in the ROM
84
+ # path. +device+ is 8 or 9, as the jumper on the TIA's PC5 sets it.
85
+ def initialize(rom: nil, host_clock_hz: CLOCK_HZ, device: 8, debug: false)
86
+ raise ArgumentError, "a 1551 is device 8 or 9, not #{device}" unless [8, 9].include?(device)
87
+
88
+ @device = device
89
+ @clock_hz = CLOCK_HZ
90
+ @mechanism = Model1541::Mechanism.new
91
+ @mechanism.clock_hz = CLOCK_HZ
92
+ @port = Port.new(@mechanism)
93
+ @tia = TIA.new(@mechanism, device:)
94
+ @host_tia = Chips::TIA.new
95
+ @bus = Bus.new(rom: rom || ROM.load("dos1551.rom", 0xc000), port: @port, tia: @tia)
96
+ @cpu = CPU.new(@bus, debug:)
97
+ @idle_loop = IDLE_LOOP
98
+ @host_clock_hz = host_clock_hz
99
+ @phase = 0
100
+ @cycles = 0
101
+ @irq_timer = IRQ_PERIOD
102
+ init_idle(debug)
103
+ @pass_counter = PASS_COUNTER
104
+ @cable = Cable.new(@host_tia, @tia.chip, self)
105
+ @serial_port = @cable
106
+ end
107
+
108
+ # Puts in a Disk from the .d64 or .g64 image at +path+, write-protected
109
+ # with `read_only`.
110
+ def insert_image(path, read_only: false)
111
+ insert(Model1541::Disk.open(path, read_only:))
112
+ end
113
+
114
+ # The computer's RESET line reaches the interface's TIA and, through
115
+ # the cable, the CPU, its port and the drive's TIA. RAM keeps its
116
+ # contents, and the 555 runs on.
117
+ def reset!
118
+ settle!
119
+ @host_tia.reset!
120
+ @port.reset!
121
+ @tia.reset!
122
+ @cpu.reset!
123
+ end
124
+
125
+ private
126
+
127
+ # One drive cycle: the mechanism turns the disk ahead of the CPU, and
128
+ # the 555 pulls IRQ while its output is low.
129
+ def step
130
+ @mechanism.cycle!
131
+ timer = @irq_timer - 1
132
+ timer = IRQ_PERIOD if timer.zero?
133
+ @irq_timer = timer
134
+ @cpu.irq = timer <= IRQ_LOW
135
+ @cpu.cycle!
136
+ @cycles += 1
137
+ end
138
+
139
+ # A pass of the idle loop reads the TIA's port A, which holds still
140
+ # until the computer moves the cable's lines, and RAM. It repeats
141
+ # while the motor is off and the 555 leaves IRQ alone, for as long as
142
+ # the 555 leaves room (see Engine::Idle).
143
+ def busy_for_pass? = @mechanism.motor_on? || @irq_timer <= IRQ_LOW
144
+
145
+ def quiet_cycles
146
+ quiet = @irq_timer - IRQ_LOW - 1
147
+ quiet.negative? ? 0 : quiet
148
+ end
149
+
150
+ def idle_state
151
+ chip = @tia.chip
152
+ [*@cpu.counting_state, @bus.data, *@mechanism.idle_state, *chip.ports, *chip.directions, *chip.inputs,
153
+ @port.ddr, @port.data]
154
+ end
155
+
156
+ def fast_forward_chips(cycles)
157
+ @irq_timer -= cycles
158
+ end
159
+
160
+ # Every 555 pulse runs the job loop, which reads the port and the TIA,
161
+ # so no orbit (Engine::Orbit) comes round, and the drive keeps no
162
+ # anchors.
163
+ def anchor = nil
164
+
165
+ def counter_state(_touched) = []
166
+
167
+ def skip_chip_orbits(_cycles, _touched) = nil
168
+ end
169
+ end
170
+ end
@@ -26,6 +26,7 @@ module Badline
26
26
  # (CIA#quiet?).
27
27
  class Model1571
28
28
  include Engine::Core
29
+ include Engine::SerialConnection
29
30
  include Engine::VIAs
30
31
  include Engine::FastSerial
31
32
 
@@ -39,6 +39,7 @@ module Badline
39
39
  # on either chip: the drive's CIA gets no TOD pulses (CIA's tod_hz).
40
40
  class Model1581
41
41
  include Engine::Core
42
+ include Engine::SerialConnection
42
43
  include Engine::FastSerial
43
44
 
44
45
  CLOCK_HZ = 2_000_000
@@ -17,7 +17,11 @@ module Badline
17
17
  end
18
18
 
19
19
  computer = nil
20
- problem = media_problem { computer = boot(options) }
20
+ problem = if cartridge?(options)
21
+ cartridge_problem { computer = boot(options) }
22
+ else
23
+ media_problem { computer = boot(options) }
24
+ end
21
25
  unless problem.empty?
22
26
  warn "#{options.program}: #{options.media_path}: #{problem}"
23
27
  return 1
@@ -40,6 +44,21 @@ module Badline
40
44
  e.message
41
45
  end
42
46
 
47
+ # Runs the block that puts a cartridge in, as media_problem does, and
48
+ # also returns the message of the ArgumentError a machine raises over a
49
+ # cartridge with a chip in a bank or slot it doesn't map.
50
+ def self.cartridge_problem(&)
51
+ media_problem(&)
52
+ rescue ArgumentError => e
53
+ e.message
54
+ end
55
+
56
+ # Whether the media the options start is a .crt.
57
+ def self.cartridge?(options)
58
+ media = options.media_path
59
+ !media.nil? && Media::Extensions.kind(media) == :cartridge
60
+ end
61
+
43
62
  # The machine a .vsf snapshot holds, built as the saved one was, or a
44
63
  # new one built from the options with the media attached.
45
64
  def self.boot(options)
@@ -37,7 +37,11 @@ module Badline
37
37
  # :program, or :snapshot, which starts in the saves folder.
38
38
  def browse(kind)
39
39
  @browsing = kind
40
- extensions = kind == :cartridge ? @media.cartridge_extensions : EXTENSIONS.fetch(kind)
40
+ extensions = case kind
41
+ when :cartridge then @media.cartridge_extensions
42
+ when :program then programs
43
+ else EXTENSIONS.fetch(kind)
44
+ end
41
45
  @browser.open(kind == :snapshot ? Badline.data_folder("saves") : @browse_from, extensions)
42
46
  end
43
47
 
@@ -56,15 +60,15 @@ module Badline
56
60
  def ask_remove = @confirmation.ask(:remove, @media.cartridge_name)
57
61
 
58
62
  # Puts a dropped disk or tape in its device, or asks before a
59
- # cartridge or a program starts, and says whether it asks. Other
60
- # files are left alone.
63
+ # cartridge, a Plus/4's raw ROM image among them, or a program
64
+ # starts, and says whether it asks. Other files are left alone.
61
65
  def drop(path)
62
66
  extension = File.extname(path).downcase
63
67
  return @media.insert(:disk, path) && false if EXTENSIONS[:disk].include?(extension)
64
68
  return @media.insert(:tape, path) && false if EXTENSIONS[:tape].include?(extension)
65
- return false unless EXTENSIONS[:program].include?(extension)
69
+ return false unless programs.include?(extension)
66
70
 
67
- @confirmation.ask(extension == ".crt" ? :cartridge : :start, path)
71
+ @confirmation.ask(@media.cartridge_extensions.include?(extension) ? :cartridge : :start, path)
68
72
  true
69
73
  end
70
74
 
@@ -98,6 +102,10 @@ module Badline
98
102
 
99
103
  private
100
104
 
105
+ # What Quick open and a drop start: every medium, and the cartridges
106
+ # the machine takes.
107
+ def programs = (EXTENSIONS[:program] + @media.cartridge_extensions).uniq
108
+
101
109
  # Puts the disk or tape picked in, or asks before a cartridge or
102
110
  # Quick open power cycles the machine, and leaves the browser, as Esc
103
111
  # does.
@@ -10,17 +10,29 @@ module Badline
10
10
  # wrong for the page to show.
11
11
  class MenuMedia
12
12
  attr_reader :problem
13
- attr_writer :computer
14
13
 
15
14
  # Takes the disk set from the command line's `media_path` when it's a
16
- # list.
17
- def initialize(media_path, writable)
15
+ # list, and the names of the cartridges it and `cartridges`, the
16
+ # Plus/4's ROM images by slot (Options#cartridges), put in the first
17
+ # machine.
18
+ def initialize(media_path, writable, cartridges)
18
19
  @list = Media::DiskList.list?(media_path) ? media_path : ""
19
20
  @writable = writable
20
21
  @set_path = ""
21
22
  @set = []
22
23
  @problem = ""
23
24
  @computer = nil
25
+ @command_line = cartridges.map { |slot, path| [slot, path] } + [["", media_path]]
26
+ @names = {}
27
+ @named = nil
28
+ end
29
+
30
+ def computer=(computer)
31
+ @computer = computer
32
+ return unless @named.nil?
33
+
34
+ @command_line.each { |slot, path| note_cartridge(computer, path.dup, slot) }
35
+ @named = computer
24
36
  end
25
37
 
26
38
  def writable? = @writable
@@ -97,7 +109,9 @@ module Badline
97
109
  # Swaps the cartridge for the one at `path`, which power cycles the
98
110
  # machine, and says whether it went in.
99
111
  def attach_cartridge(path)
100
- succeeds? { Media.attach(@computer, path) }
112
+ attached = cartridge_succeeds? { Media.attach(@computer, path) }
113
+ note_cartridge(@computer, path, "") if attached
114
+ attached
101
115
  end
102
116
 
103
117
  # What a save of the machine is named after: the cartridge, the disk,
@@ -122,26 +136,36 @@ module Badline
122
136
  end
123
137
 
124
138
  # The name the cartridge in the expansion port gives itself, or an
125
- # empty one. A Plus/4's names the slots with a ROM in them.
139
+ # empty one. A VIC-20's or a Plus/4's is the name of the file it came
140
+ # from: a .crt's own, or else the file's. A Plus/4 names each of its
141
+ # cartridges with a ROM in, and a slot filled from elsewhere, such as
142
+ # a snapshot, by the slot.
126
143
  def cartridge_name
127
- return @computer.cartridge_slots.map(&:upcase).join(", ") if @computer.family == :plus4
128
-
129
- cartridge = @computer.address_bus.cartridge
130
- cartridge.nil? ? "" : cartridge.name
144
+ case @computer.family
145
+ when :plus4 then @computer.cartridge_slots.map { |slot| slot_name(slot) }.uniq.join(", ")
146
+ when :vic20 then named? ? @names.fetch("", "") : ""
147
+ else
148
+ cartridge = @computer.address_bus.cartridge
149
+ cartridge.nil? ? "" : cartridge.name
150
+ end
131
151
  end
132
152
 
133
153
  # Takes the cartridge out with the power off.
134
- def remove_cartridge = MediaSlots.remove_cartridge(@computer)
154
+ def remove_cartridge
155
+ @names = {}
156
+ MediaSlots.remove_cartridge(@computer)
157
+ end
135
158
 
136
159
  # A new machine started on `path`, as the command line would, or nil
137
160
  # when it won't start.
138
161
  def start(options, path)
139
162
  computer = nil
140
- started = succeeds? do
141
- computer = Frontend.start(options, path, @writable)
142
- list(path)
163
+ if Media.kind(@computer, path) == :cartridge
164
+ cartridge_succeeds? { computer = started(options, path) }
165
+ else
166
+ succeeds? { computer = started(options, path) }
143
167
  end
144
- started ? computer : nil
168
+ @problem.empty? ? computer : nil
145
169
  end
146
170
 
147
171
  private
@@ -153,6 +177,60 @@ module Badline
153
177
  @problem.empty?
154
178
  end
155
179
 
180
+ # Runs the block as #succeeds? does, keeping a cartridge's chip in a
181
+ # bank or slot the machine doesn't map as the problem too.
182
+ def cartridge_succeeds?(&)
183
+ @problem = Frontend.cartridge_problem(&)
184
+ @problem.empty?
185
+ end
186
+
187
+ # The machine Frontend.start started on `path`, with the names of
188
+ # the cartridges in it noted.
189
+ def started(options, path)
190
+ computer = Frontend.start(options, path, @writable)
191
+ list(path)
192
+ @names = {}
193
+ options.cartridges.each { |slot, image| note_cartridge(computer, image.dup, slot) }
194
+ note_cartridge(computer, path, "")
195
+ @named = computer
196
+ computer
197
+ end
198
+
199
+ def named? = @computer.equal?(@named)
200
+
201
+ def slot_name(slot) = named? ? @names.fetch(slot, slot.upcase) : slot.upcase
202
+
203
+ # Keeps the name of the cartridge at `path` in `computer` by the
204
+ # slots it fills: a Plus/4's by xplus4's names, a raw image's in
205
+ # `slot` if it names one, and a VIC-20's in the one slot "". Other
206
+ # media, and other machines' cartridges, leave the names alone.
207
+ def note_cartridge(computer, path, slot)
208
+ case computer.family
209
+ when :plus4
210
+ return unless Media.kind(computer, path) == :cartridge
211
+
212
+ name = file_name(path)
213
+ Media::Plus4Media.cartridge_chips(path, slot: slot.empty? ? nil : slot).each do |chip|
214
+ @names["c#{chip.bank + 1}#{chip.address < 0xc000 ? 'lo' : 'hi'}"] = name
215
+ end
216
+ when :vic20 then @names[""] = file_name(path) if vic20_cartridge?(path)
217
+ end
218
+ end
219
+
220
+ def vic20_cartridge?(path)
221
+ case Media::Extensions.kind(path)
222
+ when :cartridge then true
223
+ when :program then File.file?(path) && !Media::Vic20Cartridge.chips(path, Media.program_bytes(path)).nil?
224
+ else false
225
+ end
226
+ end
227
+
228
+ # A .crt's own name, or else the file's, without its extension.
229
+ def file_name(path)
230
+ name = Media::Extensions.kind(path) == :cartridge ? Storage::CRTFile.new(path, machine: nil).name : ""
231
+ name.empty? ? File.basename(path, File.extname(path)) : name
232
+ end
233
+
156
234
  # Keeps `path` as the list the set comes from, if it's a list.
157
235
  def list(path)
158
236
  return unless Media::DiskList.list?(path)
@@ -29,7 +29,7 @@ module Badline
29
29
  @recent = []
30
30
  @painter = painter
31
31
  @buttons = buttons
32
- @media = MenuMedia.new(media_path, options.writable?)
32
+ @media = MenuMedia.new(media_path, options.writable?, options.cartridges)
33
33
  @reu_kb = options.reu
34
34
  @computer = nil
35
35
  @controls = nil
@@ -122,9 +122,9 @@ module Badline
122
122
  end
123
123
 
124
124
  def draw_expansion
125
+ info("CARTRIDGE", @media.cartridge_name)
125
126
  return info("RAM EXPANSION", RAM_NAMES.fetch(@computer.ram_configuration)) if vic20?
126
127
 
127
- info("CARTRIDGE", @media.cartridge_name)
128
128
  row("INSERT...", :insert_cartridge)
129
129
  row("REMOVE", :remove_cartridge)
130
130
  return if plus4?
@@ -137,7 +137,8 @@ module Badline
137
137
  info("RAM EXPANSION", "REU, #{@reu_kb} KB")
138
138
  end
139
139
 
140
- # The SID's model is a setting, and other sound chips have none.
140
+ # The SID's model is a setting, the C64's SID's or a Plus/4's SID
141
+ # card's, and other sound chips have none.
141
142
  def draw_sound
142
143
  if @sound.on?
143
144
  toggle("OUTPUT", :output, [%w[ON MUTED], %i[sound_on mute], @sound.muted? ? 1 : 0])
@@ -147,7 +148,7 @@ module Badline
147
148
  model = @computer.sound_source.model
148
149
  return unless SID_MODELS.include?(model)
149
150
 
150
- toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], model == :mos8580 ? 1 : 0])
151
+ toggle(plus4? ? "SID CARD" : "SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], model == :mos8580 ? 1 : 0])
151
152
  end
152
153
 
153
154
  # Saving now and by name, loading a named save, and the quicksaves
@@ -166,10 +167,14 @@ module Badline
166
167
  end
167
168
 
168
169
  # The C128 names its model and the mode it runs in first, and the
169
- # Plus/4 its model and RAM.
170
+ # Plus/4 its model and RAM, and its RAM expansion if it has one.
170
171
  def draw_power
171
172
  info("MACHINE", "#{@computer.model.name.upcase}, #{@computer.mode.to_s.upcase} MODE") if c128?
172
- info("MACHINE", @computer.model_label) if plus4?
173
+ if plus4?
174
+ info("MACHINE", @computer.model_label)
175
+ expansion = @computer.ram_expansion_label
176
+ info("RAM EXPANSION", expansion) if expansion
177
+ end
173
178
  row("RESET", :reset)
174
179
  row("POWER CYCLE", :power_cycle)
175
180
  skip
@@ -15,7 +15,8 @@ module Badline
15
15
  # way: #wait handles the window's clicks, keys and drops between frames
16
16
  # and redraws it at the display's pace. The SID model buttons switch
17
17
  # the chips playing on the spot, and each tune after it starts on the
18
- # same choice. AUTO gives each of a tune's SIDs its own model.
18
+ # same choice. AUTO gives each of a tune's SIDs its own model. It
19
+ # quits once it has drawn `frames` frames (--frames), unless that's 0.
19
20
  class PlayerController < Audio::Terminal
20
21
  FRAME = 1.0 / 50
21
22
 
@@ -30,8 +31,10 @@ module Badline
30
31
  Keys::TAB => :view, 6 => :chip
31
32
  }.freeze
32
33
 
33
- def initialize(input:, output:)
34
- super
34
+ def initialize(input:, output:, frames: 0)
35
+ super(input:, output:)
36
+ @frame_limit = frames
37
+ @frames = 0
35
38
  @screen = PlayerScreen.new
36
39
  @player = nil
37
40
  @tune_file = nil
@@ -73,7 +76,10 @@ module Badline
73
76
  actions = poll
74
77
  deadline = now + seconds
75
78
  loop do
76
- draw if now >= @next_draw
79
+ if now >= @next_draw
80
+ draw
81
+ actions << :quit if @frames == @frame_limit
82
+ end
77
83
  left = deadline - now
78
84
  break if left <= 0 || !actions.empty?
79
85
 
@@ -211,6 +217,7 @@ module Badline
211
217
 
212
218
  def draw
213
219
  @next_draw = now + FRAME
220
+ @frames += 1
214
221
  @screen.draw(@state, @player, @history, @tune_file, SIDTune::STIL::Credit.at(@credits, @state.played))
215
222
  end
216
223
 
@@ -93,7 +93,7 @@ module Badline
93
93
  def self.for_options(options, media, &)
94
94
  family = options.family
95
95
  return vic20_for(media, options.model, options.ram, &) if family == :vic20
96
- return plus4_for(media, options.model, options.ram_kb) if family == :plus4
96
+ return plus4_for(media, options) if family == :plus4
97
97
 
98
98
  sid_model = options.sid_model || Media.sid_model(media, otherwise: nil)
99
99
  return build(:c64, model: options.model, sid_model:, reu: options.reu) unless family == :c128
@@ -127,13 +127,17 @@ module Badline
127
127
  end
128
128
  private_class_method :vic20_for
129
129
 
130
- # A Plus/4 with `ram_kb` K of RAM (Plus4::Model::RAM_SIZES), or with
131
- # the model's own when nil.
132
- def self.plus4_for(media, model, ram_kb)
130
+ # A Plus/4 with the RAM --ram names (Plus4::Model::RAM_SIZES), or with
131
+ # the model's own without it, the true drive --drive-type names, the
132
+ # SID card --sid-card names, where --sid-card-address puts it, and the
133
+ # RAM expansion --ram-expansion names.
134
+ def self.plus4_for(media, options)
133
135
  unsupported = !media.nil? && Media::Vic20Media::UNSUPPORTED.include?(File.extname(media).downcase)
134
136
  raise Unsupported, "doesn't go in a Plus/4" if unsupported
135
137
 
136
- Plus4.new(model:, ram: ram_kb)
138
+ Plus4.new(model: options.model, ram: options.ram_kb, true_drive_model: options.drive_type,
139
+ sid_card: options.sid_card, sid_card_address: options.sid_card_address || 0xfd40,
140
+ ram_expansion: options.ram_expansion)
137
141
  end
138
142
  private_class_method :plus4_for
139
143
 
@@ -3,9 +3,10 @@
3
3
  module Badline
4
4
  module Media
5
5
  # Device 8 as a true drive instead of the KERNAL traps: a 1541, or on
6
- # the C128 its 1571, or a 1581. Once a true drive is plugged in as
7
- # device 8, disk media go into it rather than into the traps, so a LOAD
8
- # runs the DOS and every byte crosses the serial bus. A .g64 holds the
6
+ # the C128 its 1571, or on the Plus/4 a 1551 if it asks for one, or a
7
+ # 1581. Once a true drive is plugged in as device 8, disk media go into
8
+ # it rather than into the traps, so a LOAD runs the DOS and every byte
9
+ # crosses the serial bus or TCBM. A .g64 holds the
9
10
  # disk's raw GCR, which only a true drive reads, so it plugs one in
10
11
  # when there's none yet. The 1541 reads only .d64 and .g64 images, the
11
12
  # 1571 .d71 and .g71 as well, and the 1581 only .d81, so a .d81 swaps
@@ -52,9 +53,10 @@ module Badline
52
53
  raise Error, "#{path} is not a #{types.join(', ')} or .d81 image, which the #{name} and the 1581 read"
53
54
  end
54
55
 
55
- (drive || computer.plug_true_drive).insert_image(path, read_only:)
56
+ drive ||= computer.plug_true_drive
57
+ drive.insert_image(path, read_only:)
56
58
  computer.unmount
57
- "Inserted #{path} in the #{name} as device 8"
59
+ "Inserted #{path} in the #{drive.model_name} as device 8"
58
60
  end
59
61
 
60
62
  private