badline 0.14.0 → 0.15.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/CLAUDE.md +15 -5
  4. data/README.md +68 -8
  5. data/doc/pinned-behaviour.md +145 -0
  6. data/doc/snapshot-format.md +188 -38
  7. data/doc/snapshots.md +18 -1
  8. data/lib/badline/audio/bare_player.rb +4 -8
  9. data/lib/badline/audio/machine_player.rb +2 -12
  10. data/lib/badline/c128/banks.rb +1 -3
  11. data/lib/badline/c128/bus.rb +19 -1
  12. data/lib/badline/c128/mmu_pages.rb +7 -0
  13. data/lib/badline/c64/bus/extra_sids.rb +80 -0
  14. data/lib/badline/c64/bus.rb +1 -0
  15. data/lib/badline/c64/cartridge/action_replay.rb +6 -2
  16. data/lib/badline/c64/cartridge/comal80.rb +1 -0
  17. data/lib/badline/c64/cartridge/dinamic.rb +3 -1
  18. data/lib/badline/c64/cartridge/final_cartridge3.rb +1 -0
  19. data/lib/badline/c64/cartridge/fun_play.rb +2 -1
  20. data/lib/badline/c64/cartridge/game_system.rb +2 -1
  21. data/lib/badline/c64/cartridge/keyed_state.rb +155 -34
  22. data/lib/badline/c64/cartridge/magic_desk.rb +2 -1
  23. data/lib/badline/c64/cartridge/ocean.rb +1 -0
  24. data/lib/badline/c64/cartridge/pagefox.rb +1 -0
  25. data/lib/badline/c64/cartridge/rgcd.rb +2 -1
  26. data/lib/badline/c64/cartridge/super_games.rb +3 -2
  27. data/lib/badline/c64/cartridge/zaxxon.rb +1 -0
  28. data/lib/badline/c64/cartridge.rb +3 -3
  29. data/lib/badline/c64/keyed_modules.rb +6 -2
  30. data/lib/badline/chips/ted/beam.rb +13 -14
  31. data/lib/badline/chips/ted/fetches.rb +199 -0
  32. data/lib/badline/chips/ted/keyed_state.rb +156 -0
  33. data/lib/badline/chips/ted/modes.rb +134 -0
  34. data/lib/badline/chips/ted/palette.rb +43 -0
  35. data/lib/badline/chips/ted/picture.rb +182 -0
  36. data/lib/badline/chips/ted/sound/output.rb +86 -0
  37. data/lib/badline/chips/ted/sound.rb +284 -0
  38. data/lib/badline/chips/ted.rb +53 -6
  39. data/lib/badline/chips/tia.rb +12 -0
  40. data/lib/badline/drives/datasette/keyed_state.rb +7 -2
  41. data/lib/badline/drives/datasette.rb +22 -5
  42. data/lib/badline/frontend/boot.rb +3 -1
  43. data/lib/badline/frontend/controls.rb +7 -3
  44. data/lib/badline/frontend/keys.rb +14 -2
  45. data/lib/badline/frontend/media_slots.rb +11 -1
  46. data/lib/badline/frontend/menu/menu_dialogs.rb +2 -1
  47. data/lib/badline/frontend/menu/menu_media.rb +13 -3
  48. data/lib/badline/frontend/menu/menu_pages.rb +8 -8
  49. data/lib/badline/frontend/menu/menu_ports.rb +13 -1
  50. data/lib/badline/frontend/screen.rb +33 -12
  51. data/lib/badline/frontend/screens.rb +2 -1
  52. data/lib/badline/frontend/sound.rb +4 -4
  53. data/lib/badline/frontend/timeline.rb +19 -6
  54. data/lib/badline/machine/settling.rb +1 -1
  55. data/lib/badline/machine.rb +26 -6
  56. data/lib/badline/media/plus4_media.rb +68 -0
  57. data/lib/badline/media/program.rb +20 -6
  58. data/lib/badline/media.rb +33 -12
  59. data/lib/badline/options/family.rb +1 -0
  60. data/lib/badline/options/help.rb +30 -6
  61. data/lib/badline/options/table.rb +15 -2
  62. data/lib/badline/options/validation.rb +26 -4
  63. data/lib/badline/options.rb +37 -9
  64. data/lib/badline/plus4/bus.rb +77 -14
  65. data/lib/badline/plus4/cassette_sense.rb +21 -0
  66. data/lib/badline/plus4/keyboard_port.rb +35 -10
  67. data/lib/badline/plus4/keyed_modules.rb +99 -0
  68. data/lib/badline/plus4/keyed_parts.rb +143 -0
  69. data/lib/badline/plus4/keyed_state.rb +41 -0
  70. data/lib/badline/plus4.rb +92 -16
  71. data/lib/badline/snapshot/container.rb +2 -0
  72. data/lib/badline/snapshot/field_doc/markdown.rb +7 -6
  73. data/lib/badline/snapshot/field_doc.rb +46 -7
  74. data/lib/badline/snapshot/image.rb +12 -3
  75. data/lib/badline/snapshot/plus4_setup.rb +39 -0
  76. data/lib/badline/snapshot.rb +9 -4
  77. data/lib/badline/storage/g64_image.rb +1 -1
  78. data/lib/badline/storage/tap.rb +21 -12
  79. data/lib/badline/support/checkpoint.rb +3 -2
  80. data/lib/badline/support/plus4_checkpoint.rb +42 -0
  81. data/lib/badline/version.rb +1 -1
  82. metadata +15 -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
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,12 @@ 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. There Escape is ESC,
115
+ Page Down RUN/STOP and F4 HELP.
116
+
94
117
  --headless plays a .sid tune on the host's audio device without the
95
118
  window, and --audio-out renders it to 16-bit PCM instead. The
96
119
  container follows the file's extension, .wav or .aiff. Played on a
@@ -111,7 +134,8 @@ module Badline
111
134
  --at and --script run events once a number of frames has run, as
112
135
  in --at 3500:key=space or --at 250,500:screenshot=shot%05d.bmp.
113
136
  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
137
+ on the C128 esc, help, keypad1 and the rest, and on the Plus/4
138
+ esc, help and f1 to f3) or a joystick's
115
139
  direction or fire (joy1-up, joy2-fire) for #{Event::HOLD} frames, and
116
140
  pushes the C128's caps_lock or forty_eighty, which lock. type=TEXT
117
141
  types it, with \\n for RETURN. insert=FILE swaps in a disk, a list's
@@ -14,6 +14,10 @@ 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
+
17
21
  # The VIC-20 RAM expansions --ram takes, in the order the help lists
18
22
  # them.
19
23
  RAM_NAMES = %w[unexpanded 3k 8k 16k 24k 32k all].freeze
@@ -40,12 +44,21 @@ module Badline
40
44
  Option.new("--reu SIZE", "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384",
41
45
  section: "Window options", needs: :window),
42
46
  Option.new("--model NAME", "C64 to run: #{MODELS.join(', ')} (default: #{MODELS.first}); VIC-20: pal; " \
43
- "C128: #{C128_MODELS.join(', ')}",
47
+ "C128: #{C128_MODELS.join(', ')}; Plus/4: #{PLUS4_MODELS.join(', ')}",
44
48
  section: "Window options", needs: :window),
45
49
  Option.new("--ntsc", "Run an NTSC C64, as --model ntsc does", section: "Window options", needs: :window),
46
50
  Option.new("--c64", "Start the C128 in C64 mode, holding C= at power-on",
47
51
  section: "Window options", needs: :window),
48
- Option.new("--ram SIZE", "VIC-20 RAM expansion: #{RAM_NAMES.join(', ')} (default: unexpanded)",
52
+ Option.new("--ram SIZE", "VIC-20 RAM expansion: #{RAM_NAMES.join(', ')} (default: unexpanded); " \
53
+ "Plus/4 RAM: 16k, 32k or 64k (default: the model's own)",
54
+ section: "Window options", needs: :window),
55
+ Option.new("--c1lo FILE", "Put a .bin ROM image in the Plus/4's cartridge 1, low slot ($8000)",
56
+ section: "Window options", needs: :window),
57
+ Option.new("--c1hi FILE", "Put a .bin ROM image in cartridge 1's high slot ($C000)",
58
+ section: "Window options", needs: :window),
59
+ Option.new("--c2lo FILE", "Put a .bin ROM image in cartridge 2's low slot ($8000)",
60
+ section: "Window options", needs: :window),
61
+ Option.new("--c2hi FILE", "Put a .bin ROM image in cartridge 2's high slot ($C000)",
49
62
  section: "Window options", needs: :window),
50
63
  Option.new("--no-vsync", "Pace frames by the timer or the sound instead of the display",
51
64
  section: "Window options", needs: :window),
@@ -21,6 +21,12 @@ module Badline
21
21
  RAM_CONFIGURATIONS[value]
22
22
  end
23
23
 
24
+ def plus4_ram(value)
25
+ raise Error, "invalid argument: --ram #{value}" unless PLUS4_RAM.key?(value)
26
+
27
+ PLUS4_RAM[value]
28
+ end
29
+
24
30
  def reu_size(value)
25
31
  raise Error, "invalid argument: --reu #{value}" unless REU_SIZES.include?(value)
26
32
 
@@ -59,12 +65,15 @@ module Badline
59
65
  end
60
66
 
61
67
  def validate_family
62
- raise Error, "the Plus/4 isn't emulated yet" if @family == :plus4
63
-
64
68
  if @family == :vic20
65
69
  validate_vic20
66
70
  elsif !@ram.nil?
67
- raise Error, "--ram needs vic20"
71
+ raise Error, "--ram needs vic20 or plus4"
72
+ end
73
+ if @family == :plus4
74
+ validate_plus4
75
+ elsif !@cartridges.empty?
76
+ raise Error, "--#{@cartridges.keys.first} needs plus4"
68
77
  end
69
78
  if @family == :c128
70
79
  validate_c128
@@ -91,6 +100,19 @@ module Badline
91
100
  raise Error, "the VIC-20 needs the window" if headless?
92
101
  end
93
102
 
103
+ # The Plus/4 runs in the window, without the C64's SID and REU, its
104
+ # NTSC models have names of their own, and its cartridge slots take
105
+ # raw ROM images.
106
+ def validate_plus4
107
+ raise Error, "--ntsc needs the C64; the NTSC Plus/4 is --model plus4ntsc" if @models.include?("ntsc")
108
+ raise Error, "--sid needs the C64" unless @sid_model.nil?
109
+ raise Error, "--reu needs the C64" unless @reu.nil?
110
+ raise Error, "the Plus/4 needs the window" if headless?
111
+
112
+ image = @cartridges.values.find { |path| !File.extname(path).casecmp?(".bin") }
113
+ raise Error, "not a .bin ROM image: #{image}" unless image.nil?
114
+ end
115
+
94
116
  # `sid` takes directories as well as tunes.
95
117
  def validate_tune(path)
96
118
  return if !File.exist?(path) || File.extname(path).casecmp?(".sid")
@@ -120,7 +142,7 @@ module Badline
120
142
  end
121
143
 
122
144
  def validate_paths
123
- missing = tune_paths.find { |path| !File.exist?(path) }
145
+ missing = (tune_paths + @cartridges.values).find { |path| !File.exist?(path) }
124
146
  raise Error, "no such file or directory: #{missing}" unless missing.nil?
125
147
  raise Error, "no such file or directory: #{@songlengths}" unless @songlengths.nil? || File.exist?(@songlengths)
126
148
  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,13 @@ 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
+ # The Plus/4's cartridge slots, by the options that fill them and
55
+ # xplus4's names (Media::Plus4Media::SLOTS).
56
+ CARTRIDGE_SLOTS = { "--c1lo" => "c1lo", "--c1hi" => "c1hi", "--c2lo" => "c2lo", "--c2hi" => "c2hi" }.freeze
57
+
51
58
  # A Float as OptionParser took one: digits with an optional sign,
52
59
  # fraction and exponent.
53
60
  DECIMAL = /\A[-+]?(\d+(\.\d+)?|\.\d+)([eE][-+]?\d+)?\z/
@@ -57,14 +64,22 @@ module Badline
57
64
  attr_reader :program, :media_path, :tune_paths, :subtune, :sid_model, :reu, :frames, :screenshot, :save_snapshot,
58
65
  :audio_out, :seconds, :songlengths, :filter_chunk, :timeline
59
66
 
60
- # The machine family to build (Badline::Machine), :c64, :vic20 or
61
- # :c128.
67
+ # The machine family to build (Badline::Machine), :c64, :vic20, :c128
68
+ # or :plus4.
62
69
  attr_reader :family
63
70
 
64
71
  # The VIC-20's RAM expansion --ram names, a key of
65
72
  # Vic20::Bus::RAM_CONFIGURATIONS, or nil without --ram.
66
73
  attr_reader :ram
67
74
 
75
+ # The Plus/4's RAM in K --ram names, one of PLUS4_RAM's, or nil without
76
+ # --ram.
77
+ attr_reader :ram_kb
78
+
79
+ # The Plus/4's cartridge ROM images --c1lo, --c1hi, --c2lo and --c2hi
80
+ # name, by slot: { "c1lo" => path }.
81
+ attr_reader :cartridges
82
+
68
83
  def self.parse(argv, native: false) = new(native:).parse(argv)
69
84
 
70
85
  # The native build plays the SID in the window unless --no-sound says
@@ -84,6 +99,8 @@ module Badline
84
99
  @models = []
85
100
  @reu = nil
86
101
  @ram = nil
102
+ @ram_kb = nil
103
+ @cartridges = {}
87
104
  @autostart = true
88
105
  @writable = false
89
106
  @sound = native
@@ -178,8 +195,8 @@ module Badline
178
195
  def jit? = @jit
179
196
 
180
197
  # 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.
198
+ # of VIC20_MODELS, a C128 of C128_MODELS or a Plus/4 of PLUS4_MODELS.
199
+ # --ntsc is short for --model ntsc.
183
200
  def model = @models.first || family_models.first
184
201
 
185
202
  # Whether --c64 asks the C128 to start in C64 mode, holding C= at
@@ -201,7 +218,8 @@ module Badline
201
218
  # A .sid on its own plays in the SID player, as `sid` would, unless an
202
219
  # option of the emulator's window asks for the machine.
203
220
  def play_lone_tune
204
- return if @sid_command || @family == :vic20 || headless? || !@window_only.empty? || @media_path.nil?
221
+ return if @sid_command || %i[vic20 plus4].include?(@family) || headless? || !@window_only.empty?
222
+ return if @media_path.nil?
205
223
 
206
224
  @sid_command = File.extname(@media_path).casecmp?(".sid")
207
225
  end
@@ -254,7 +272,8 @@ module Badline
254
272
  when "--sid" then @sid_model = sid_model_for(value)
255
273
  when "--model" then @models << value
256
274
  when "--reu" then @reu = reu_size(value)
257
- when "--ram" then @ram = ram_configuration(value)
275
+ when "--ram" then ram_option(value)
276
+ when "--c1lo", "--c1hi", "--c2lo", "--c2hi" then @cartridges[CARTRIDGE_SLOTS.fetch(name)] = value
258
277
  when "--frames" then @frames = number(flag, value)
259
278
  when "--screenshot" then @screenshot = value
260
279
  when "--save-snapshot" then @save_snapshot = value
@@ -267,6 +286,15 @@ module Badline
267
286
  end
268
287
  end
269
288
 
289
+ # --ram names the VIC-20's RAM expansion, or the Plus/4's RAM.
290
+ def ram_option(value)
291
+ if @family == :plus4
292
+ @ram_kb = plus4_ram(value)
293
+ else
294
+ @ram = ram_configuration(value)
295
+ end
296
+ end
297
+
270
298
  def switch(name)
271
299
  case name
272
300
  when "--no-autostart" then @autostart = false
@@ -28,6 +28,13 @@ module Badline
28
28
  # RAM smaller than 64K repeats through the 64K, as the C16's 16K does
29
29
  # without A14 and A15. Where nothing answers, a read finds the last byte
30
30
  # on the bus.
31
+ #
32
+ # The TED fetches through the same banks: the video matrix and the
33
+ # attributes from ROM or RAM at $8000-$FFFF as the CPU sees it, with no
34
+ # I/O in the way (#ted_fetch), and character and bitmap data from RAM,
35
+ # or from ROM with $FF12 bit 2 set, where below $8000 and in an empty
36
+ # bank it finds the last byte on the bus (#ted_pattern_fetch, Plus/4
37
+ # openmem). Each byte it fetches is left on the bus.
31
38
  class Bus
32
39
  include Addressable
33
40
 
@@ -75,12 +82,14 @@ module Badline
75
82
 
76
83
  # Page $FD: the ACIA, the user port, the keyboard port and the ROM
77
84
  # bank latch, by the address's bits 4-7. +acia+ and +user_port+ are
78
- # nil where the machine has none.
85
+ # nil where the machine has none, and +cassette_sense+ answers in the
86
+ # user port's place on a machine without one (CassetteSense).
79
87
  class IOPage
80
- def initialize(keyboard_port:, acia: nil, user_port: nil)
88
+ def initialize(keyboard_port:, acia: nil, user_port: nil, cassette_sense: nil)
81
89
  @bus = nil
82
90
  @acia = acia
83
91
  @user_port = user_port
92
+ @cassette_sense = cassette_sense
84
93
  @keyboard_port = keyboard_port
85
94
  end
86
95
 
@@ -93,7 +102,7 @@ module Badline
93
102
  def peek(addr)
94
103
  case addr & 0xf0
95
104
  when 0x00 then @acia ? @acia.peek(addr) : @bus.data
96
- when 0x10 then @user_port ? @user_port.peek(addr) : @bus.data
105
+ when 0x10 then user_port_peek(addr)
97
106
  when 0x30 then @keyboard_port.peek(addr)
98
107
  else @bus.data
99
108
  end
@@ -107,6 +116,15 @@ module Badline
107
116
  when 0xd0 then @bus.select_banks(addr)
108
117
  end
109
118
  end
119
+
120
+ private
121
+
122
+ def user_port_peek(addr)
123
+ return @user_port.peek(addr) if @user_port
124
+ return @cassette_sense.read(@bus.data) if @cassette_sense
125
+
126
+ @bus.data
127
+ end
110
128
  end
111
129
 
112
130
  # VICE's debug cartridge register at $FDCF, a spot in the I/O page
@@ -211,6 +229,8 @@ module Badline
211
229
  @data = 0
212
230
  @read_pages = Array.new(256, @open_bus)
213
231
  @write_pages = Array.new(256, @ram_view)
232
+ @ted_pages = Array.new(256, @ram_view)
233
+ @ted_rom_pages = Array.new(256, @open_bus)
214
234
  @low_bank = @high_bank = 0
215
235
  @rom_selected = true
216
236
  @top_memory = @ram_view
@@ -259,21 +279,45 @@ module Badline
259
279
  map_reads
260
280
  end
261
281
 
262
- # Puts a cartridge's ROM chips in the slots of cartridge 1: a chip at
263
- # $8000 in low bank 2 and one at $C000 in high bank 2, each repeated
264
- # through the bank's 16K.
282
+ # Puts a cartridge's ROM chips in their slots, as a PLUS4 CARTRIDGE
283
+ # .crt numbers them: a chip's bank picks cartridge 1 (0) or 2 (1), in
284
+ # ROM banks 2 and 3, and its address the low ($8000) or the high
285
+ # ($C000) slot. A chip shorter than 16K repeats through the slot.
286
+ # Slots the chips don't name keep what they held.
265
287
  def attach_cartridge(chips)
266
288
  chips.each do |chip|
267
- data = chip.data * (0x4000 / chip.data.length)
289
+ raise ArgumentError, "no Plus/4 cartridge slot in bank #{chip.bank}" unless chip.bank.between?(0, 1)
290
+
291
+ rom = chip.data
292
+ length = rom.length
293
+ data = Array.new(0x4000) { |i| length.zero? ? 0xff : rom[i % length] }
268
294
  if chip.address < 0xc000
269
- @low_roms[2] = ROM.new(data, length: 0x4000, start: 0x8000)
295
+ @low_roms[2 + chip.bank] = ROM.new(data, length: 0x4000, start: 0x8000)
270
296
  else
271
- @high_roms[2] = ROM.new(data, length: 0x4000, start: 0xc000)
297
+ @high_roms[2 + chip.bank] = ROM.new(data, length: 0x4000, start: 0xc000)
272
298
  end
273
299
  end
274
300
  map_reads
275
301
  end
276
302
 
303
+ # Empties both cartridges' slots, ROM banks 2 and 3, which then read
304
+ # as the open bus.
305
+ def detach_cartridges
306
+ @low_roms[2] = @low_roms[3] = @high_roms[2] = @high_roms[3] = nil
307
+ map_reads
308
+ end
309
+
310
+ # The slots that hold a cartridge's ROM, by xplus4's names: c1lo,
311
+ # c1hi, c2lo and c2hi.
312
+ def cartridge_slots
313
+ slots = []
314
+ slots << "c1lo" unless @low_roms[2].nil?
315
+ slots << "c1hi" unless @high_roms[2].nil?
316
+ slots << "c2lo" unless @low_roms[3].nil?
317
+ slots << "c2hi" unless @high_roms[3].nil?
318
+ slots
319
+ end
320
+
277
321
  def peek(addr)
278
322
  @data = @read_pages[addr >> 8].peek(addr)
279
323
  end
@@ -283,6 +327,16 @@ module Badline
283
327
  @write_pages[addr >> 8].poke(addr, value)
284
328
  end
285
329
 
330
+ # A TED fetch of the video matrix or the attributes.
331
+ def ted_fetch(addr)
332
+ @data = @ted_pages[addr >> 8].peek(addr)
333
+ end
334
+
335
+ # A TED fetch of character or bitmap data, from ROM when +rom+.
336
+ def ted_pattern_fetch(addr, rom)
337
+ @data = rom ? @ted_rom_pages[addr >> 8].peek(addr) : @ram_view.peek(addr)
338
+ end
339
+
286
340
  private
287
341
 
288
342
  def map_writes
@@ -295,17 +349,26 @@ module Badline
295
349
  def map_reads
296
350
  @read_pages.fill(@ram_view, 0x00, 0xfd)
297
351
  @read_pages[0x00] = @zero_page
298
- map_roms if @rom_selected
352
+ map_roms(@read_pages) if @rom_selected
299
353
  @top_memory = @rom_selected ? @high_roms[@high_bank] || @open_bus : @ram_view
300
354
  @read_pages[0xfd] = @io_page
301
355
  @read_pages[0xfe] = @tcbm_page
302
356
  @read_pages[0xff] = @top_page
357
+ map_ted_reads
358
+ end
359
+
360
+ def map_ted_reads
361
+ @ted_pages.fill(@ram_view, 0x80, 0x80)
362
+ map_roms(@ted_pages) if @rom_selected
363
+ map_roms(@ted_rom_pages)
364
+ @ted_pages.fill(@high_roms[@high_bank] || @open_bus, 0xfd, 3) if @rom_selected
365
+ @ted_rom_pages.fill(@high_roms[@high_bank] || @open_bus, 0xfd, 3)
303
366
  end
304
367
 
305
- def map_roms
306
- @read_pages.fill(@low_roms[@low_bank] || @open_bus, 0x80, 0x40)
307
- @read_pages.fill(@high_roms[@high_bank] || @open_bus, 0xc0, 0x3c)
308
- @read_pages[0xfc] = @high_roms[0]
368
+ def map_roms(pages)
369
+ pages.fill(@low_roms[@low_bank] || @open_bus, 0x80, 0x40)
370
+ pages.fill(@high_roms[@high_bank] || @open_bus, 0xc0, 0x3c)
371
+ pages[0xfc] = @high_roms[0]
309
372
  end
310
373
  end
311
374
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Plus4
5
+ # The C16's and C116's cassette sense, which has no 6529 to go through:
6
+ # a read of $FD10-$FD1F enables a 74LS125 that drives the sense line
7
+ # onto data bit 2, low while a key is down on the deck (C16 schematic
8
+ # 251788, sheet 1, U11). Nothing drives the other bits, which keep the
9
+ # last byte on the bus, and a write goes nowhere.
10
+ class CassetteSense
11
+ TAPE_SENSE = 0x04
12
+
13
+ def initialize(datasette)
14
+ @datasette = datasette
15
+ end
16
+
17
+ # The byte a read finds, from +data+, the last byte on the bus.
18
+ def read(data) = @datasette.sense_low? ? data & ~TAPE_SENSE : data | TAPE_SENSE
19
+ end
20
+ end
21
+ end