badline 0.14.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/CLAUDE.md +22 -5
  4. data/README.md +101 -11
  5. data/doc/media.md +19 -0
  6. data/doc/pinned-behaviour.md +145 -0
  7. data/doc/snapshot-format.md +244 -46
  8. data/doc/snapshots.md +18 -1
  9. data/exe/badline-ruby +1 -1
  10. data/lib/badline/audio/bare_player.rb +4 -8
  11. data/lib/badline/audio/machine_player.rb +4 -26
  12. data/lib/badline/c128/banks.rb +1 -3
  13. data/lib/badline/c128/bus.rb +19 -1
  14. data/lib/badline/c128/mmu_pages.rb +7 -0
  15. data/lib/badline/c64/bus/extra_sids.rb +80 -0
  16. data/lib/badline/c64/bus.rb +1 -0
  17. data/lib/badline/c64/cartridge/action_replay.rb +6 -2
  18. data/lib/badline/c64/cartridge/comal80.rb +1 -0
  19. data/lib/badline/c64/cartridge/dinamic.rb +3 -1
  20. data/lib/badline/c64/cartridge/final_cartridge3.rb +1 -0
  21. data/lib/badline/c64/cartridge/fun_play.rb +2 -1
  22. data/lib/badline/c64/cartridge/game_system.rb +2 -1
  23. data/lib/badline/c64/cartridge/keyed_state.rb +155 -34
  24. data/lib/badline/c64/cartridge/magic_desk.rb +2 -1
  25. data/lib/badline/c64/cartridge/ocean.rb +1 -0
  26. data/lib/badline/c64/cartridge/pagefox.rb +1 -0
  27. data/lib/badline/c64/cartridge/rgcd.rb +2 -1
  28. data/lib/badline/c64/cartridge/super_games.rb +3 -2
  29. data/lib/badline/c64/cartridge/zaxxon.rb +1 -0
  30. data/lib/badline/c64/cartridge.rb +3 -3
  31. data/lib/badline/c64/keyed_modules.rb +6 -2
  32. data/lib/badline/c64/true_drives.rb +13 -1
  33. data/lib/badline/c64.rb +6 -0
  34. data/lib/badline/chips/ted/beam.rb +13 -14
  35. data/lib/badline/chips/ted/fetches.rb +199 -0
  36. data/lib/badline/chips/ted/keyed_state.rb +156 -0
  37. data/lib/badline/chips/ted/modes.rb +134 -0
  38. data/lib/badline/chips/ted/palette.rb +43 -0
  39. data/lib/badline/chips/ted/picture.rb +182 -0
  40. data/lib/badline/chips/ted/sound/output.rb +86 -0
  41. data/lib/badline/chips/ted/sound.rb +284 -0
  42. data/lib/badline/chips/ted.rb +53 -6
  43. data/lib/badline/chips/tia.rb +28 -0
  44. data/lib/badline/chips/vic/register_log.rb +4 -1
  45. data/lib/badline/drives/datasette/keyed_state.rb +14 -4
  46. data/lib/badline/drives/datasette.rb +22 -5
  47. data/lib/badline/drives/engine/core.rb +6 -14
  48. data/lib/badline/drives/engine/idle.rb +42 -4
  49. data/lib/badline/drives/engine/idle_cpu.rb +18 -0
  50. data/lib/badline/drives/engine/serial_connection.rb +25 -0
  51. data/lib/badline/drives/engine/sleep.rb +1 -0
  52. data/lib/badline/drives/engine.rb +1 -0
  53. data/lib/badline/drives/model1541/byte_ready.rb +19 -3
  54. data/lib/badline/drives/model1541/mechanism.rb +10 -1
  55. data/lib/badline/drives/model1541.rb +1 -0
  56. data/lib/badline/drives/model1551/bus.rb +138 -0
  57. data/lib/badline/drives/model1551/cable.rb +104 -0
  58. data/lib/badline/drives/model1551/cpu.rb +14 -0
  59. data/lib/badline/drives/model1551/keyed_state.rb +65 -0
  60. data/lib/badline/drives/model1551/port.rb +74 -0
  61. data/lib/badline/drives/model1551/tia.rb +92 -0
  62. data/lib/badline/drives/model1551.rb +170 -0
  63. data/lib/badline/drives/model1571.rb +1 -0
  64. data/lib/badline/drives/model1581.rb +1 -0
  65. data/lib/badline/frontend/boot.rb +23 -2
  66. data/lib/badline/frontend/controls.rb +7 -3
  67. data/lib/badline/frontend/keys.rb +14 -2
  68. data/lib/badline/frontend/media_slots.rb +11 -1
  69. data/lib/badline/frontend/menu/menu_dialogs.rb +14 -5
  70. data/lib/badline/frontend/menu/menu_media.rb +102 -14
  71. data/lib/badline/frontend/menu/menu_pages.rb +16 -11
  72. data/lib/badline/frontend/menu/menu_ports.rb +13 -1
  73. data/lib/badline/frontend/player/player_controller.rb +11 -4
  74. data/lib/badline/frontend/screen.rb +33 -12
  75. data/lib/badline/frontend/screens.rb +2 -1
  76. data/lib/badline/frontend/sound.rb +4 -4
  77. data/lib/badline/frontend/timeline.rb +19 -6
  78. data/lib/badline/machine/settling.rb +1 -1
  79. data/lib/badline/machine.rb +30 -6
  80. data/lib/badline/media/plus4_media.rb +68 -0
  81. data/lib/badline/media/program.rb +20 -6
  82. data/lib/badline/media/true_drive.rb +7 -5
  83. data/lib/badline/media.rb +33 -12
  84. data/lib/badline/options/family.rb +1 -0
  85. data/lib/badline/options/help.rb +32 -6
  86. data/lib/badline/options/option.rb +2 -1
  87. data/lib/badline/options/table.rb +29 -4
  88. data/lib/badline/options/validation.rb +66 -4
  89. data/lib/badline/options.rb +71 -11
  90. data/lib/badline/plus4/acia/frames.rb +55 -0
  91. data/lib/badline/plus4/acia.rb +276 -16
  92. data/lib/badline/plus4/bus.rb +245 -46
  93. data/lib/badline/plus4/cassette_sense.rb +21 -0
  94. data/lib/badline/plus4/keyboard_port.rb +35 -10
  95. data/lib/badline/plus4/keyed_modules.rb +116 -0
  96. data/lib/badline/plus4/keyed_parts.rb +172 -0
  97. data/lib/badline/plus4/keyed_state.rb +44 -0
  98. data/lib/badline/plus4/ram_expansion.rb +145 -0
  99. data/lib/badline/plus4/sid_card.rb +95 -0
  100. data/lib/badline/plus4/sound_mix.rb +51 -0
  101. data/lib/badline/plus4/tcbm_drive.rb +120 -0
  102. data/lib/badline/plus4/user_port.rb +11 -2
  103. data/lib/badline/plus4.rb +184 -26
  104. data/lib/badline/roms/README +9 -0
  105. data/lib/badline/roms/dos1551.rom +0 -0
  106. data/lib/badline/snapshot/container.rb +2 -0
  107. data/lib/badline/snapshot/field_doc/markdown.rb +7 -6
  108. data/lib/badline/snapshot/field_doc.rb +48 -7
  109. data/lib/badline/snapshot/image.rb +12 -3
  110. data/lib/badline/snapshot/plus4_setup.rb +81 -0
  111. data/lib/badline/snapshot.rb +9 -4
  112. data/lib/badline/storage/tap.rb +21 -12
  113. data/lib/badline/support/checkpoint.rb +3 -2
  114. data/lib/badline/support/plus4_checkpoint.rb +42 -0
  115. data/lib/badline/version.rb +1 -1
  116. metadata +29 -1
@@ -2,26 +2,40 @@
2
2
 
3
3
  module Badline
4
4
  module Media
5
- # A program loading into a C64 or a C128 once it has booted
6
- # (Media.attach). The VIC-20 has its own (Vic20Basic).
5
+ # A program loading into a C64, a C128 or a 264 series machine once it
6
+ # has booted (Media.attach). The VIC-20 has its own (Vic20Basic).
7
7
  module Program
8
+ # Where BASIC 3.5 starts on the 264 series.
9
+ PLUS4_BASIC_START = 0x1001
10
+
8
11
  class << self
9
12
  # Loads the program where it says, and RUNs one that loads at the
10
13
  # start of BASIC.
11
14
  def load(computer, data, autostart:)
12
15
  load_addr = computer.load_prg(data)
16
+ start, text_end, load_end = basic(computer)
13
17
  # Run only makes sense for programs at BASIC start, or a byte ahead
14
- # of it, where BASIC keeps the zero before its first line. BASIC 7.0
15
- # starts at $1C01 and keeps the end of its text at $1210.
16
- start, text_end = computer.family == :c128 && computer.mode == :c128 ? [0x1c01, 0x1210] : [BASIC_START, 0x2d]
18
+ # of it, where BASIC keeps the zero before its first line.
17
19
  return unless autostart && (load_addr == start || load_addr == start - 1)
18
20
 
19
21
  # The end of the program, where BASIC's variables start, and where
20
22
  # the KERNAL's LOAD leaves its end address.
21
23
  end_addr = load_addr + data.length - 2
22
- [text_end, 0xae].each { |pointer| computer.ram.write(pointer, [end_addr & 0xff, end_addr >> 8]) }
24
+ [text_end, load_end].each { |pointer| computer.ram.write(pointer, [end_addr & 0xff, end_addr >> 8]) }
23
25
  computer.type_text("run\r")
24
26
  end
27
+
28
+ private
29
+
30
+ # Where BASIC starts, the pointer to the end of its text and the
31
+ # KERNAL's end address (EAL). BASIC 7.0 starts at $1C01 and keeps
32
+ # the end of its text at $1210, and the 264 KERNAL keeps EAL at $9D.
33
+ def basic(computer)
34
+ return [PLUS4_BASIC_START, 0x2d, KernalTrap::PLUS4_ZERO_PAGE.end_address] if computer.family == :plus4
35
+ return [0x1c01, 0x1210, 0xae] if computer.family == :c128 && computer.mode == :c128
36
+
37
+ [BASIC_START, 0x2d, 0xae]
38
+ end
25
39
  end
26
40
  end
27
41
  end
@@ -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
data/lib/badline/media.rb CHANGED
@@ -10,6 +10,7 @@ require "badline/media/disk_list"
10
10
  require "badline/media/vic20_basic"
11
11
  require "badline/media/vic20_cartridge"
12
12
  require "badline/media/vic20_media"
13
+ require "badline/media/plus4_media"
13
14
  require "badline/media/tune"
14
15
  require "badline/media/program"
15
16
 
@@ -44,13 +45,14 @@ module Badline
44
45
  # An .m3u or .vfl list of disks attaches the first disk it lists
45
46
  # (DiskList). Every machine takes each kind of medium the same way,
46
47
  # except that the VIC-20 (Vic20Media) has cartridges, programs and a
47
- # disk autostart of its own, and takes no .sid tunes. The C128 in C64
48
- # mode takes a C64's.
48
+ # disk autostart of its own, and takes no .sid tunes, and the 264
49
+ # series loads programs at the start of its BASIC (Program), takes
50
+ # .crt cartridges and raw .bin ROM images in its two cartridges'
51
+ # slots (Plus4Media), and no .sid tunes either. The
52
+ # C128 in C64 mode takes a C64's.
49
53
  def attach(computer, path, autostart: true, subtune: nil, **)
50
54
  path = DiskList.disk(path)
51
- if computer.family == :vic20 && Vic20Media::UNSUPPORTED.include?(File.extname(path).downcase)
52
- raise ArgumentError, "#{path} doesn't go in a VIC-20"
53
- end
55
+ refuse_unsupported(computer, path)
54
56
 
55
57
  BootDisk.attach(computer, path, **) || attach_kind(computer, path, autostart:, subtune:, **)
56
58
  end
@@ -89,10 +91,12 @@ module Badline
89
91
  # What the medium at `path` is to `computer`, which attach goes by:
90
92
  # :true_drive for a disk that goes in a true drive (TrueDrive),
91
93
  # :directory, :storage for a disk image or a .t64 that the traps
92
- # mount, and otherwise its kind by its extension (Extensions).
94
+ # mount, :cartridge for a raw ROM image on the 264 series
95
+ # (Plus4Media), and otherwise its kind by its extension (Extensions).
93
96
  def kind(computer, path)
94
97
  return :true_drive if TrueDrive.takes?(computer, path)
95
98
  return :directory if File.directory?(path)
99
+ return :cartridge if computer.family == :plus4 && Plus4Media.rom_image?(path)
96
100
 
97
101
  kind = Extensions.kind(path)
98
102
  %i[disk archive].include?(kind) ? :storage : kind
@@ -116,6 +120,16 @@ module Badline
116
120
 
117
121
  private
118
122
 
123
+ # The VIC-20 and the 264 series have no SID to play a .sid tune on.
124
+ def refuse_unsupported(computer, path)
125
+ return unless Vic20Media::UNSUPPORTED.include?(File.extname(path).downcase)
126
+
127
+ case computer.family
128
+ when :vic20 then raise ArgumentError, "#{path} doesn't go in a VIC-20"
129
+ when :plus4 then raise ArgumentError, "#{path} doesn't go in a Plus/4"
130
+ end
131
+ end
132
+
119
133
  def attach_kind(computer, path, autostart:, subtune:, **options)
120
134
  disk = options.fetch(:disk, {})
121
135
  case kind(computer, path)
@@ -151,25 +165,32 @@ module Badline
151
165
  computer.family == :vic20 ? Vic20Media.disk_autostart(yield) : AUTOSTART
152
166
  end
153
167
 
154
- # A VIC-20 cartridge is its ROM chips.
168
+ # A VIC-20 or 264 series cartridge is its ROM chips. On the 264, the
169
+ # options' `slot` names the slot a raw ROM image goes in
170
+ # (Plus4Media.cartridge_chips).
155
171
  def attach_cartridge(computer, path, options)
156
- if computer.family == :vic20
157
- computer.attach_cartridge(Storage::CRTFile.new(path, machine: :vic20).chips)
172
+ case computer.family
173
+ when :vic20 then computer.attach_cartridge(Storage::CRTFile.new(path, machine: :vic20).chips)
174
+ when :plus4 then computer.attach_cartridge(Plus4Media.cartridge_chips(path, slot: options.fetch(:slot, nil)))
158
175
  else
159
176
  computer.attach_cartridge(C64::Cartridge.from_file(path, **options))
160
177
  end
161
178
  "Attached cartridge #{path}"
162
179
  end
163
180
 
164
- # A VIC-20 says when the tape was made for another machine
165
- # (Vic20Media.tape_message).
181
+ # A VIC-20 or a 264 says when the tape was made for another machine
182
+ # (Vic20Media.tape_message, Plus4Media.tape_message).
166
183
  def attach_tape(computer, path, autostart:)
167
184
  tape = Storage::TAP.new(path)
168
185
  computer.datasette.insert(tape)
169
186
  computer.datasette.play!
170
187
  computer.type_text(TAPE_AUTOSTART) if autostart
171
188
  message = "Inserted #{path} in the datasette"
172
- computer.family == :vic20 ? Vic20Media.tape_message(tape, message) : message
189
+ case computer.family
190
+ when :vic20 then Vic20Media.tape_message(tape, message)
191
+ when :plus4 then Plus4Media.tape_message(tape, message)
192
+ else message
193
+ end
173
194
  end
174
195
 
175
196
  # A VIC-20 has programs of its own (Vic20Media.attach_program).
@@ -30,6 +30,7 @@ module Badline
30
30
  case @family
31
31
  when :vic20 then VIC20_MODELS
32
32
  when :c128 then C128_MODELS
33
+ when :plus4 then PLUS4_MODELS
33
34
  else MODELS
34
35
  end
35
36
  end
@@ -16,6 +16,16 @@ module Badline
16
16
  filter cycle by cycle, which is exact but takes about twice as long.
17
17
  TEXT
18
18
 
19
+ # The article for +name+: "an" where it starts with a vowel sound. A
20
+ # name of letters with a vowel among them is read as a word, and any
21
+ # other letter by letter, so .sid and .tap take "a", and .m3u and .lnx
22
+ # "an".
23
+ def self.article(name)
24
+ word = name.delete_prefix(".").downcase
25
+ vowels = word.match?(/\A[a-z]*[aeiou][a-z]*\z/) ? "aeiou" : "aefhilmnorsx8"
26
+ vowels.include?(word[0]) ? "an" : "a"
27
+ end
28
+
19
29
  # What --help prints: the usage, then the options this build takes,
20
30
  # section by section, laid out as OptionParser did.
21
31
  def help
@@ -38,14 +48,19 @@ module Badline
38
48
  # The media the window opens, by their extensions (Media::Extensions),
39
49
  # wrapped as the banner is.
40
50
  def media_kinds
41
- text = "Media can be a #{Media::Extensions::PROGRAMS.join('/')} program, a #{listed(%i[disk])} disk image, " \
42
- "a #{listed(%i[gcr])} disk image for a true drive, an #{listed(%i[disk_list])} list of disk images, " \
43
- "a #{listed(%i[archive])} tape archive, a #{listed(%i[tape])} tape, a #{listed(%i[cartridge])} " \
44
- "cartridge, a #{listed(%i[tune])} tune, a .vsf snapshot, or a directory to mount as device 8."
51
+ text = "Media can be #{listed(Media::Extensions::PROGRAMS)} program, #{listed(%i[disk])} disk image, " \
52
+ "#{listed(%i[gcr])} disk image for a true drive, #{listed(%i[disk_list])} list of disk images, " \
53
+ "#{listed(%i[archive])} tape archive, #{listed(%i[tape])} tape, #{listed(%i[cartridge])} " \
54
+ "cartridge, #{listed(%i[tune])} tune, #{listed(%w[.vsf])} snapshot, or a directory to mount as device 8."
45
55
  wrap(text)
46
56
  end
47
57
 
48
- def listed(kinds) = Media::Extensions.of(kinds).join("/")
58
+ # The extensions of the kinds, or the extensions themselves, after the
59
+ # article the first takes.
60
+ def listed(kinds)
61
+ extensions = kinds.first.is_a?(Symbol) ? Media::Extensions.of(kinds) : kinds
62
+ "#{Options.article(extensions.first)} #{extensions.join('/')}"
63
+ end
49
64
 
50
65
  # The words of `text` in lines of at most 70 characters.
51
66
  def wrap(text)
@@ -68,6 +83,7 @@ module Badline
68
83
  Usage: #{@program} [c64] [options] [media]
69
84
  #{@program} vic20 [options] [media]
70
85
  #{@program} c128 [options] [media]
86
+ #{@program} plus4 [options] [media]
71
87
  #{@program} --headless [options] tune.sid
72
88
  #{@program} [options] tune.sid --audio-out FILE
73
89
  #{@program} sid [options] FILE|DIR...
@@ -76,6 +92,7 @@ module Badline
76
92
  c64 Run a C64, the one --model names (the default)
77
93
  vic20 Run a PAL VIC-20, with the RAM --ram names
78
94
  c128 Run a C128 in C128 mode, the one --model names
95
+ plus4 Run a Plus/4 or a C16, the one --model names
79
96
  sid Play .sid tunes and directories of them
80
97
 
81
98
  #{media_kinds}
@@ -91,6 +108,14 @@ module Badline
91
108
  tune opens in the SID player, as `sid` plays it, unless an option
92
109
  of the emulator's window asks for the machine.
93
110
 
111
+ The Plus/4 and the C16 take programs, disks, tapes, and cartridges as
112
+ .crt files or raw .bin ROM images. A .bin goes in cartridge 1, its
113
+ low slot for up to 16K and both slots for 32K, and --c1lo, --c1hi,
114
+ --c2lo and --c2hi put one in the slot they name. Their --true-drive
115
+ makes device 8 an emulated 1551, on the parallel TCBM cable, and
116
+ --drive-type 1541 a 1541 on the serial bus instead. There Escape is
117
+ ESC, Page Down RUN/STOP and F4 HELP.
118
+
94
119
  --headless plays a .sid tune on the host's audio device without the
95
120
  window, and --audio-out renders it to 16-bit PCM instead. The
96
121
  container follows the file's extension, .wav or .aiff. Played on a
@@ -111,7 +136,8 @@ module Badline
111
136
  --at and --script run events once a number of frames has run, as
112
137
  in --at 3500:key=space or --at 250,500:screenshot=shot%05d.bmp.
113
138
  key=NAME presses a key (space, return, a, f1, run_stop, restore, and
114
- on the C128 esc, help, keypad1 and the rest) or a joystick's
139
+ on the C128 esc, help, keypad1 and the rest, and on the Plus/4
140
+ esc, help and f1 to f3) or a joystick's
115
141
  direction or fire (joy1-up, joy2-fire) for #{Event::HOLD} frames, and
116
142
  pushes the C128's caps_lock or forty_eighty, which lock. type=TEXT
117
143
  types it, with \\n for RETURN. insert=FILE swaps in a disk, a list's
@@ -4,7 +4,8 @@ module Badline
4
4
  class Options
5
5
  # One option of the table: its flags and value as the help shows them
6
6
  # (`-s, --subtune N`), what it does, the help section it's listed in, the
7
- # mode it needs (:window, :headless or :either), and the build that
7
+ # mode it needs (:window, :headless, :either, or :any_window for the
8
+ # emulator's window or the SID player's), and the build that
8
9
  # takes it (:native, :ruby or :both).
9
10
  class Option
10
11
  attr_reader :usage, :name, :short, :value, :text, :section, :needs, :build
@@ -14,6 +14,13 @@ module Badline
14
14
  # The C128's models, as C128::Model names them.
15
15
  C128_MODELS = %w[c128 c128ntsc c128dcr c128dcrntsc].freeze
16
16
 
17
+ # The 264 series' models, as Plus4::Model names them, with the C116 as
18
+ # another name for the C16.
19
+ PLUS4_MODELS = %w[plus4 plus4ntsc c16 c16ntsc c116].freeze
20
+
21
+ # The true drives --drive-type names for the Plus/4.
22
+ PLUS4_DRIVE_TYPES = %w[1541 1551].freeze
23
+
17
24
  # The VIC-20 RAM expansions --ram takes, in the order the help lists
18
25
  # them.
19
26
  RAM_NAMES = %w[unexpanded 3k 8k 16k 24k 32k all].freeze
@@ -35,17 +42,35 @@ module Badline
35
42
  Option.new("--sound", "Play the SID through the host's audio device (F10 mutes)",
36
43
  section: "Window options", needs: :window),
37
44
  Option.new("--no-sound", "Don't play the SID", section: "Window options", needs: :window, build: :native),
38
- Option.new("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps, a 1571 on the C128",
45
+ Option.new("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps, a 1571 on the C128, " \
46
+ "a 1551 on the Plus/4",
47
+ section: "Window options", needs: :window),
48
+ Option.new("--drive-type MODEL", "The Plus/4's true drive: 1541 or 1551 (default: 1551); implies --true-drive",
39
49
  section: "Window options", needs: :window),
40
50
  Option.new("--reu SIZE", "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384",
41
51
  section: "Window options", needs: :window),
42
52
  Option.new("--model NAME", "C64 to run: #{MODELS.join(', ')} (default: #{MODELS.first}); VIC-20: pal; " \
43
- "C128: #{C128_MODELS.join(', ')}",
53
+ "C128: #{C128_MODELS.join(', ')}; Plus/4: #{PLUS4_MODELS.join(', ')}",
44
54
  section: "Window options", needs: :window),
45
55
  Option.new("--ntsc", "Run an NTSC C64, as --model ntsc does", section: "Window options", needs: :window),
46
56
  Option.new("--c64", "Start the C128 in C64 mode, holding C= at power-on",
47
57
  section: "Window options", needs: :window),
48
- Option.new("--ram SIZE", "VIC-20 RAM expansion: #{RAM_NAMES.join(', ')} (default: unexpanded)",
58
+ Option.new("--ram SIZE", "VIC-20 RAM expansion: #{RAM_NAMES.join(', ')} (default: unexpanded); " \
59
+ "Plus/4 RAM: 16k, 32k or 64k (default: the model's own)",
60
+ section: "Window options", needs: :window),
61
+ Option.new("--c1lo FILE", "Put a .bin ROM image in the Plus/4's cartridge 1, low slot ($8000)",
62
+ section: "Window options", needs: :window),
63
+ Option.new("--c1hi FILE", "Put a .bin ROM image in cartridge 1's high slot ($C000)",
64
+ section: "Window options", needs: :window),
65
+ Option.new("--c2lo FILE", "Put a .bin ROM image in cartridge 2's low slot ($8000)",
66
+ section: "Window options", needs: :window),
67
+ Option.new("--c2hi FILE", "Put a .bin ROM image in cartridge 2's high slot ($C000)",
68
+ section: "Window options", needs: :window),
69
+ Option.new("--sid-card MODEL", "Fit the Plus/4 or C16 with a SID card at $FD40: 6581 or 8580",
70
+ section: "Window options", needs: :window),
71
+ Option.new("--sid-card-address ADDRESS", "Put the SID card at fd40 (the default) or fe80",
72
+ section: "Window options", needs: :window),
73
+ Option.new("--ram-expansion NAME", "Fit the Plus/4 or C16 with a RAM expansion: hannes256, hannes1m or csory256",
49
74
  section: "Window options", needs: :window),
50
75
  Option.new("--no-vsync", "Pace frames by the timer or the sound instead of the display",
51
76
  section: "Window options", needs: :window),
@@ -68,7 +93,7 @@ module Badline
68
93
  section: "Options without the window", needs: :headless),
69
94
  Option.new("--no-tui", "Play without the interactive display, even on a terminal",
70
95
  section: "Options without the window", needs: :headless),
71
- Option.new("--frames N", "Quit after N frames", section: "Testing options", needs: :window),
96
+ Option.new("--frames N", "Quit after N frames", section: "Testing options", needs: :any_window),
72
97
  Option.new("--unpaced", "Run as fast as it can, without vsync or pacing",
73
98
  section: "Testing options", needs: :window),
74
99
  Option.new("--screenshot FILE", "Save the last frame as a .bmp",
@@ -8,6 +8,18 @@ module Badline
8
8
  module Validation
9
9
  private
10
10
 
11
+ def sid_card_for(value)
12
+ raise Error, "invalid argument: --sid-card #{value}" unless SID_MODELS.key?(value)
13
+
14
+ SID_MODELS[value]
15
+ end
16
+
17
+ def sid_card_address_for(value)
18
+ raise Error, "invalid argument: --sid-card-address #{value}" unless SID_CARD_ADDRESSES.key?(value.downcase)
19
+
20
+ SID_CARD_ADDRESSES[value.downcase]
21
+ end
22
+
11
23
  def sid_model_for(value)
12
24
  return if value == "auto"
13
25
  raise Error, "invalid argument: --sid #{value}" unless SID_MODELS.key?(value)
@@ -21,6 +33,24 @@ module Badline
21
33
  RAM_CONFIGURATIONS[value]
22
34
  end
23
35
 
36
+ def plus4_ram(value)
37
+ raise Error, "invalid argument: --ram #{value}" unless PLUS4_RAM.key?(value)
38
+
39
+ PLUS4_RAM[value]
40
+ end
41
+
42
+ def plus4_ram_expansion(value)
43
+ raise Error, "invalid argument: --ram-expansion #{value}" unless PLUS4_RAM_EXPANSIONS.key?(value)
44
+
45
+ PLUS4_RAM_EXPANSIONS[value]
46
+ end
47
+
48
+ def drive_type_for(value)
49
+ raise Error, "invalid argument: --drive-type #{value}" unless PLUS4_DRIVE_TYPES.include?(value)
50
+
51
+ value
52
+ end
53
+
24
54
  def reu_size(value)
25
55
  raise Error, "invalid argument: --reu #{value}" unless REU_SIZES.include?(value)
26
56
 
@@ -41,6 +71,8 @@ module Badline
41
71
 
42
72
  def validate
43
73
  validate_family
74
+ raise Error, "--drive-type needs plus4" unless @drive_type.nil? || @family == :plus4
75
+
44
76
  validate_mode
45
77
  validate_models
46
78
  validate_numbers
@@ -50,6 +82,7 @@ module Badline
50
82
  def validate_mode
51
83
  if headless?
52
84
  raise Error, "#{@window_only.first} needs the window" unless @window_only.empty?
85
+ raise Error, "#{@any_window_only.first} needs the window" unless player_window? || @any_window_only.empty?
53
86
  raise Error, "no tune given" if tune_paths.empty? && !player_window?
54
87
 
55
88
  tune_paths.each { |path| validate_tune(path) }
@@ -59,13 +92,12 @@ module Badline
59
92
  end
60
93
 
61
94
  def validate_family
62
- raise Error, "the Plus/4 isn't emulated yet" if @family == :plus4
63
-
64
95
  if @family == :vic20
65
96
  validate_vic20
66
97
  elsif !@ram.nil?
67
- raise Error, "--ram needs vic20"
98
+ raise Error, "--ram needs vic20 or plus4"
68
99
  end
100
+ @family == :plus4 ? validate_plus4 : refuse_plus4_options
69
101
  if @family == :c128
70
102
  validate_c128
71
103
  elsif @c64_mode
@@ -91,6 +123,36 @@ module Badline
91
123
  raise Error, "the VIC-20 needs the window" if headless?
92
124
  end
93
125
 
126
+ # Only the Plus/4 has cartridge slots for raw ROM images and takes a
127
+ # SID card and a RAM expansion.
128
+ def refuse_plus4_options
129
+ raise Error, "--#{@cartridges.keys.first} needs plus4" unless @cartridges.empty?
130
+ raise Error, "--sid-card needs plus4" unless @sid_card.nil?
131
+ raise Error, "--sid-card-address needs plus4" unless @sid_card_address.nil?
132
+ raise Error, "--ram-expansion needs plus4" unless @ram_expansion.nil?
133
+ end
134
+
135
+ # The Plus/4 runs in the window, without the C64's SID and REU but
136
+ # with a SID card if asked, its NTSC models have names of their own,
137
+ # and its cartridge slots take raw ROM images.
138
+ def validate_plus4
139
+ raise Error, "--ntsc needs the C64; the NTSC Plus/4 is --model plus4ntsc" if @models.include?("ntsc")
140
+ raise Error, "--sid needs the C64; the Plus/4's is --sid-card" unless @sid_model.nil?
141
+ raise Error, "--reu needs the C64" unless @reu.nil?
142
+ raise Error, "the Plus/4 needs the window" if headless?
143
+
144
+ validate_plus4_expansions
145
+ image = @cartridges.values.find { |path| !File.extname(path).casecmp?(".bin") }
146
+ raise Error, "not a .bin ROM image: #{image}" unless image.nil?
147
+ end
148
+
149
+ # A RAM expansion goes over 64K, and a SID card's address needs a
150
+ # card.
151
+ def validate_plus4_expansions
152
+ raise Error, "--ram-expansion needs 64k" unless @ram_expansion.nil? || [nil, 64].include?(@ram_kb)
153
+ raise Error, "--sid-card-address needs --sid-card" unless @sid_card_address.nil? || @sid_card
154
+ end
155
+
94
156
  # `sid` takes directories as well as tunes.
95
157
  def validate_tune(path)
96
158
  return if !File.exist?(path) || File.extname(path).casecmp?(".sid")
@@ -120,7 +182,7 @@ module Badline
120
182
  end
121
183
 
122
184
  def validate_paths
123
- missing = tune_paths.find { |path| !File.exist?(path) }
185
+ missing = (tune_paths + @cartridges.values).find { |path| !File.exist?(path) }
124
186
  raise Error, "no such file or directory: #{missing}" unless missing.nil?
125
187
  raise Error, "no such file or directory: #{@songlengths}" unless @songlengths.nil? || File.exist?(@songlengths)
126
188
  end
@@ -12,9 +12,9 @@ module Badline
12
12
  # Either opens the window for any media, or with --headless or
13
13
  # --audio-out plays or renders a .sid tune without one. `sid`, given
14
14
  # first, plays .sid tunes and directories of them in the terminal.
15
- # `c64`, `vic20` or `c128`, given first, names the machine family, and
16
- # without one the family is the C64, or the C128 for a program that loads
17
- # where C128 BASIC starts. `plus4` is refused until the Plus/4 runs. The native build adds --no-sound and
15
+ # `c64`, `vic20`, `c128` or `plus4`, given first, names the machine
16
+ # family, and without one the family is the C64, or the C128 for a program
17
+ # that loads where C128 BASIC starts. The native build adds --no-sound and
18
18
  # --version, and badline-ruby adds --disable-jit.
19
19
  #
20
20
  # TABLE lists the options, and both the parser and the help read it. It
@@ -48,6 +48,19 @@ module Badline
48
48
  "32k" => :"32k", "all" => :all
49
49
  }.freeze
50
50
 
51
+ # The Plus/4's RAM sizes as --ram names them, in K.
52
+ PLUS4_RAM = { "16k" => 16, "32k" => 32, "64k" => 64 }.freeze
53
+
54
+ # Where --sid-card-address puts the Plus/4's SID card.
55
+ SID_CARD_ADDRESSES = { "fd40" => 0xfd40, "fe80" => 0xfe80 }.freeze
56
+
57
+ # The Plus/4's RAM expansions as --ram-expansion names them.
58
+ PLUS4_RAM_EXPANSIONS = { "hannes256" => :hannes256, "hannes1m" => :hannes1m, "csory256" => :csory256 }.freeze
59
+
60
+ # The Plus/4's cartridge slots, by the options that fill them and
61
+ # xplus4's names (Media::Plus4Media::SLOTS).
62
+ CARTRIDGE_SLOTS = { "--c1lo" => "c1lo", "--c1hi" => "c1hi", "--c2lo" => "c2lo", "--c2hi" => "c2hi" }.freeze
63
+
51
64
  # A Float as OptionParser took one: digits with an optional sign,
52
65
  # fraction and exponent.
53
66
  DECIMAL = /\A[-+]?(\d+(\.\d+)?|\.\d+)([eE][-+]?\d+)?\z/
@@ -57,14 +70,34 @@ module Badline
57
70
  attr_reader :program, :media_path, :tune_paths, :subtune, :sid_model, :reu, :frames, :screenshot, :save_snapshot,
58
71
  :audio_out, :seconds, :songlengths, :filter_chunk, :timeline
59
72
 
60
- # The machine family to build (Badline::Machine), :c64, :vic20 or
61
- # :c128.
73
+ # The machine family to build (Badline::Machine), :c64, :vic20, :c128
74
+ # or :plus4.
62
75
  attr_reader :family
63
76
 
64
77
  # The VIC-20's RAM expansion --ram names, a key of
65
78
  # Vic20::Bus::RAM_CONFIGURATIONS, or nil without --ram.
66
79
  attr_reader :ram
67
80
 
81
+ # The Plus/4's RAM in K --ram names, one of PLUS4_RAM's, or nil without
82
+ # --ram.
83
+ attr_reader :ram_kb
84
+
85
+ # The Plus/4's cartridge ROM images --c1lo, --c1hi, --c2lo and --c2hi
86
+ # name, by slot: { "c1lo" => path }.
87
+ attr_reader :cartridges
88
+
89
+ # The SID model of the Plus/4's SID card --sid-card names, :mos6581 or
90
+ # :mos8580, or nil without one.
91
+ attr_reader :sid_card
92
+
93
+ # Where --sid-card-address puts the Plus/4's SID card, $FD40 without
94
+ # it.
95
+ attr_reader :sid_card_address
96
+
97
+ # The Plus/4's RAM expansion --ram-expansion names, a key of
98
+ # Plus4::RAMExpansion::KINDS, or nil without one.
99
+ attr_reader :ram_expansion
100
+
68
101
  def self.parse(argv, native: false) = new(native:).parse(argv)
69
102
 
70
103
  # The native build plays the SID in the window unless --no-sound says
@@ -81,9 +114,14 @@ module Badline
81
114
  @tune_paths = []
82
115
  @subtune = nil
83
116
  @sid_model = nil
117
+ @sid_card = nil
118
+ @sid_card_address = nil
119
+ @ram_expansion = nil
84
120
  @models = []
85
121
  @reu = nil
86
122
  @ram = nil
123
+ @ram_kb = nil
124
+ @cartridges = {}
87
125
  @autostart = true
88
126
  @writable = false
89
127
  @sound = native
@@ -92,6 +130,7 @@ module Badline
92
130
  @vsync = true
93
131
  @verbose = false
94
132
  @true_drive = false
133
+ @drive_type = nil
95
134
  @screenshot = ""
96
135
  @save_snapshot = ""
97
136
  @vice = false
@@ -109,6 +148,7 @@ module Badline
109
148
  @tui = true
110
149
  @all_subtunes = false
111
150
  @window_only = []
151
+ @any_window_only = []
112
152
  @headless_only = []
113
153
  end
114
154
 
@@ -170,7 +210,10 @@ module Badline
170
210
 
171
211
  def verbose? = @verbose
172
212
 
173
- def true_drive? = @true_drive
213
+ def true_drive? = @true_drive || !@drive_type.nil?
214
+
215
+ # The Plus/4's true drive --drive-type names, "1551" without it.
216
+ def drive_type = @drive_type || "1551"
174
217
 
175
218
  # Whether --save-snapshot adds VICE's modules, so x64sc opens it.
176
219
  def vice? = @vice
@@ -178,8 +221,8 @@ module Badline
178
221
  def jit? = @jit
179
222
 
180
223
  # The name of the model to build: a C64 Badline::Model names, a VIC-20
181
- # of VIC20_MODELS or a C128 of C128_MODELS. --ntsc is short for --model
182
- # ntsc.
224
+ # of VIC20_MODELS, a C128 of C128_MODELS or a Plus/4 of PLUS4_MODELS.
225
+ # --ntsc is short for --model ntsc.
183
226
  def model = @models.first || family_models.first
184
227
 
185
228
  # Whether --c64 asks the C128 to start in C64 mode, holding C= at
@@ -201,7 +244,8 @@ module Badline
201
244
  # A .sid on its own plays in the SID player, as `sid` would, unless an
202
245
  # option of the emulator's window asks for the machine.
203
246
  def play_lone_tune
204
- return if @sid_command || @family == :vic20 || headless? || !@window_only.empty? || @media_path.nil?
247
+ return if @sid_command || %i[vic20 plus4].include?(@family) || headless? || !@window_only.empty?
248
+ return if @media_path.nil?
205
249
 
206
250
  @sid_command = File.extname(@media_path).casecmp?(".sid")
207
251
  end
@@ -236,9 +280,11 @@ module Badline
236
280
  option
237
281
  end
238
282
 
239
- # Notes an option only the window or only the headless player takes.
283
+ # Notes an option only the window, only a window of either kind, or
284
+ # only the headless player takes.
240
285
  def restrict(option, flag)
241
286
  @window_only << flag if option.needs == :window
287
+ @any_window_only << flag if option.needs == :any_window
242
288
  @headless_only << flag if option.needs == :headless
243
289
  end
244
290
 
@@ -252,9 +298,14 @@ module Badline
252
298
  case name
253
299
  when "--subtune" then @subtune = number(flag, value)
254
300
  when "--sid" then @sid_model = sid_model_for(value)
301
+ when "--sid-card" then @sid_card = sid_card_for(value)
302
+ when "--sid-card-address" then @sid_card_address = sid_card_address_for(value)
303
+ when "--ram-expansion" then @ram_expansion = plus4_ram_expansion(value)
255
304
  when "--model" then @models << value
256
305
  when "--reu" then @reu = reu_size(value)
257
- when "--ram" then @ram = ram_configuration(value)
306
+ when "--ram" then ram_option(value)
307
+ when "--drive-type" then @drive_type = drive_type_for(value)
308
+ when "--c1lo", "--c1hi", "--c2lo", "--c2hi" then @cartridges[CARTRIDGE_SLOTS.fetch(name)] = value
258
309
  when "--frames" then @frames = number(flag, value)
259
310
  when "--screenshot" then @screenshot = value
260
311
  when "--save-snapshot" then @save_snapshot = value
@@ -267,6 +318,15 @@ module Badline
267
318
  end
268
319
  end
269
320
 
321
+ # --ram names the VIC-20's RAM expansion, or the Plus/4's RAM.
322
+ def ram_option(value)
323
+ if @family == :plus4
324
+ @ram_kb = plus4_ram(value)
325
+ else
326
+ @ram = ram_configuration(value)
327
+ end
328
+ end
329
+
270
330
  def switch(name)
271
331
  case name
272
332
  when "--no-autostart" then @autostart = false