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
@@ -44,6 +44,18 @@ module Badline
44
44
  @ports[register] = value
45
45
  end
46
46
  end
47
+
48
+ # The ports, their directions and what the other side drives, in a
49
+ # keyed snapshot module (Snapshot::Keyed, BL-TIA1 and BL-TIA2).
50
+ def save_fields(out)
51
+ out.ints("ports", @ports).ints("directions", @directions).ints("inputs", @inputs)
52
+ end
53
+
54
+ def load_fields(input)
55
+ input.ints_into("ports", @ports)
56
+ input.ints_into("directions", @directions)
57
+ input.ints_into("inputs", @inputs)
58
+ end
47
59
  end
48
60
  end
49
61
  end
@@ -6,11 +6,14 @@ module Badline
6
6
  # The datasette's fields in a keyed snapshot module (Snapshot::Keyed,
7
7
  # BL-TAPE): the keys, the motor, the countdown to the next pulse, and
8
8
  # a tape in it under tape.*: its path, its bytes and how far it has
9
- # played. A tape that isn't in leaves tape.* out. The sense and flag
10
- # handlers don't fire on load.
9
+ # played. A tape that isn't in leaves tape.* out. A datasette that
10
+ # times the read line's level adds when in the countdown the line
11
+ # rises, and whether a recording's low half wave is on the tape. The
12
+ # sense and flag handlers don't fire on load.
11
13
  module KeyedState
12
14
  def save_fields(out)
13
15
  out.bool("playing", @playing).bool("motor", @motor).int("countdown", @countdown)
16
+ out.int("rise_at", @rise_at).bool("low_recorded", @low_recorded) if @level
14
17
  tape = @tape
15
18
  return out unless tape
16
19
 
@@ -23,6 +26,8 @@ module Badline
23
26
  @playing = input.bool("playing", @playing)
24
27
  @motor = input.bool("motor", @motor)
25
28
  @countdown = input.int("countdown", @countdown)
29
+ @rise_at = input.int("rise_at", @rise_at)
30
+ @low_recorded = input.bool("low_recorded", @low_recorded)
26
31
  bytes = input.bytes("tape.bytes", "".b).bytes
27
32
  return @tape = nil if bytes.empty?
28
33
 
@@ -27,6 +27,7 @@ module Badline
27
27
  @motor = false
28
28
  @countdown = 0
29
29
  @rise_at = 0
30
+ @low_recorded = false
30
31
  @flag_handler = nil
31
32
  @sense_handler = nil
32
33
  end
@@ -78,6 +79,7 @@ module Badline
78
79
  press(true)
79
80
  @recording = true
80
81
  @since = 0
82
+ @low_recorded = false
81
83
  end
82
84
 
83
85
  def recording? = @recording
@@ -85,14 +87,21 @@ module Badline
85
87
  # The machine's tape write line. While the deck records with its motor
86
88
  # running, each rising edge ends a pulse on the tape, as long as the
87
89
  # time since the edge before, and plays back as a falling edge on the
88
- # read line.
90
+ # read line. A datasette built with +level: true+ records a version 2
91
+ # tape's half waves instead: a rising edge ends the low half and a
92
+ # falling edge the high half after it, so the tape plays back the
93
+ # levels it recorded (#read_high?).
89
94
  def write_line=(high)
90
- rose = high && !@write_high
95
+ edge = high != @write_high
91
96
  @write_high = high
92
- return unless rose && @recording && running?
97
+ return unless edge && @recording && running?
93
98
 
94
- @tape.record_pulse(@since)
95
- @since = 0
99
+ if @level && @tape.half_waves?
100
+ record_half_wave(high)
101
+ elsif high
102
+ @tape.record_pulse(@since)
103
+ @since = 0
104
+ end
96
105
  end
97
106
 
98
107
  # Sets the write line's level without recording an edge, as a
@@ -153,6 +162,14 @@ module Badline
153
162
  @countdown = first + @rise_at
154
163
  end
155
164
 
165
+ # A high half wave goes on the tape only after the low half before it,
166
+ # so the tape starts with a whole pulse.
167
+ def record_half_wave(rose)
168
+ @tape.record_half_wave(@since) if rose || @low_recorded
169
+ @low_recorded = rose
170
+ @since = 0
171
+ end
172
+
156
173
  def press(down)
157
174
  return if down == @playing || !@connected
158
175
 
@@ -55,11 +55,13 @@ module Badline
55
55
 
56
56
  # A new machine built from the options (Machine.for_options), with
57
57
  # `media`, if not nil, attached and started as the command line does,
58
- # its disk writable if `writable`. The VIC-20 raises
58
+ # its disk writable if `writable`, after the Plus/4's cartridges the
59
+ # options put in its slots. The VIC-20 and the Plus/4 raise
59
60
  # Machine::Unsupported for a .sid tune.
60
61
  def self.start(options, media, writable)
61
62
  computer = build(options, media)
62
63
  Media::TrueDrive.plug(computer) if options.true_drive?
64
+ options.cartridges.each { |slot, path| puts Media.attach(computer, path, cartridge: { slot: }) }
63
65
  unless media.nil?
64
66
  puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
65
67
  disk: { read_only: !writable })
@@ -117,7 +117,11 @@ module Badline
117
117
  end
118
118
 
119
119
  def host_key(scancode)
120
- @computer.family == :c128 ? Keys.c128_key(scancode, @computer.mode) : Keys.c64_key(scancode)
120
+ case @computer.family
121
+ when :c128 then Keys.c128_key(scancode, @computer.mode)
122
+ when :plus4 then Keys.plus4_key(scancode)
123
+ else Keys.c64_key(scancode)
124
+ end
121
125
  end
122
126
 
123
127
  # RESTORE isn't in the key matrix, so it goes to the machine instead.
@@ -196,8 +200,8 @@ module Badline
196
200
  end
197
201
 
198
202
  def release_all
199
- Keys::ARROWS.each_key { |scancode| @computer.keyboard.release(Keys.c64_key(scancode)) }
200
- Keys::WASD.each_key { |scancode| @computer.keyboard.release(Keys.c64_key(scancode)) }
203
+ Keys::ARROWS.each_key { |scancode| @computer.keyboard.release(host_key(scancode)) }
204
+ Keys::WASD.each_key { |scancode| @computer.keyboard.release(host_key(scancode)) }
201
205
  Keys::ARROWS.each_value do |direction|
202
206
  @computer.joystick1.release(direction)
203
207
  @computer.joystick2.release(direction)
@@ -3,8 +3,8 @@
3
3
  module Badline
4
4
  module Frontend
5
5
  # SDL scancodes follow key positions on a US layout. They map onto the C64
6
- # keys, the C128's own keys and, in joystick mode, the joystick
7
- # directions.
6
+ # keys, the C128's and the 264's own keys and, in joystick mode, the
7
+ # joystick directions.
8
8
  module Keys
9
9
  RETURN = 40
10
10
  ESCAPE = 41
@@ -56,6 +56,15 @@ module Badline
56
56
  63 => :forty_eighty, 72 => :forty_eighty, 78 => :run_stop
57
57
  }.freeze
58
58
 
59
+ # The 264's keys where they differ from the C64's: ESC on Esc, with
60
+ # RUN/STOP on Page Down as on the C128, F1-F3 on F1-F3, HELP on F4,
61
+ # Insert or Help, a cursor key on each arrow, and either SHIFT on the
62
+ # one SHIFT. Page Up, RESTORE on the C64, presses nothing.
63
+ PLUS4_OTHERS = {
64
+ 41 => :esc, 78 => :run_stop, 58 => :f1, 59 => :f2, 60 => :f3, 61 => :help, 73 => :help, 117 => :help,
65
+ 79 => :crsr_right, 80 => :crsr_left, 81 => :crsr_down, 82 => :crsr_up, 229 => :lshift, 75 => :none
66
+ }.freeze
67
+
59
68
  ARROWS = { 44 => :fire, 228 => :fire, 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
60
69
  WASD = { 225 => :fire, 7 => :right, 4 => :left, 22 => :down, 26 => :up }.freeze
61
70
 
@@ -77,6 +86,9 @@ module Badline
77
86
  C128_OTHERS[scancode] || c64_key(scancode)
78
87
  end
79
88
 
89
+ # The 264's key for the scancode: one of PLUS4_OTHERS or the C64's.
90
+ def self.plus4_key(scancode) = PLUS4_OTHERS[scancode] || c64_key(scancode)
91
+
80
92
  # The character the key types on a US layout: a letter, a capital
81
93
  # with `shift`, or a digit, and "" for any other key.
82
94
  def self.character(scancode, shift)
@@ -31,8 +31,18 @@ module Badline
31
31
  drive.nil? ? computer.unmount : drive.insert(nil)
32
32
  end
33
33
 
34
- # Takes the cartridge out with the power off.
34
+ # Whether the expansion port has a cartridge in, on the Plus/4 in
35
+ # either cartridge's slots.
36
+ def cartridge?(computer)
37
+ return !computer.cartridge_slots.empty? if computer.family == :plus4
38
+
39
+ !computer.address_bus.cartridge.nil?
40
+ end
41
+
42
+ # Takes the cartridge out with the power off, on the Plus/4 both.
35
43
  def remove_cartridge(computer)
44
+ return computer.detach_cartridges if computer.family == :plus4
45
+
36
46
  computer.address_bus.detach_cartridge
37
47
  computer.power_cycle!
38
48
  end
@@ -37,7 +37,8 @@ module Badline
37
37
  # :program, or :snapshot, which starts in the saves folder.
38
38
  def browse(kind)
39
39
  @browsing = kind
40
- @browser.open(kind == :snapshot ? Badline.data_folder("saves") : @browse_from, EXTENSIONS.fetch(kind))
40
+ extensions = kind == :cartridge ? @media.cartridge_extensions : EXTENSIONS.fetch(kind)
41
+ @browser.open(kind == :snapshot ? Badline.data_folder("saves") : @browse_from, extensions)
41
42
  end
42
43
 
43
44
  # Asks for a save's name, starting from `name`.
@@ -87,6 +87,13 @@ module Badline
87
87
  insert(:disk, set[index + step])
88
88
  end
89
89
 
90
+ # The extensions of the cartridges the machine takes: .crt, and on
91
+ # the Plus/4 raw .bin ROM images too.
92
+ def cartridge_extensions
93
+ extensions = Media::Extensions.of(%i[cartridge])
94
+ @computer.family == :plus4 ? extensions + [Media::Plus4Media::ROM_IMAGE] : extensions
95
+ end
96
+
90
97
  # Swaps the cartridge for the one at `path`, which power cycles the
91
98
  # machine, and says whether it went in.
92
99
  def attach_cartridge(path)
@@ -95,10 +102,11 @@ module Badline
95
102
 
96
103
  # What a save of the machine is named after: the cartridge, the disk,
97
104
  # the tape, or BASIC, without the bracketed parts of a file's name. A
98
- # VIC-20's are named after its disk, or else the machine.
105
+ # VIC-20's are named after its disk, or else the machine, and a
106
+ # Plus/4's after its disk or tape.
99
107
  def game_name
100
108
  vic20 = @computer.family == :vic20
101
- name = vic20 ? "" : cartridge_name
109
+ name = vic20 || @computer.family == :plus4 ? "" : cartridge_name
102
110
  disk = disk_path
103
111
  name = File.basename(disk, File.extname(disk)) if name.empty? && !disk.empty?
104
112
  name = tape_name if name.empty? && !vic20
@@ -114,8 +122,10 @@ module Badline
114
122
  end
115
123
 
116
124
  # The name the cartridge in the expansion port gives itself, or an
117
- # empty one.
125
+ # empty one. A Plus/4's names the slots with a ROM in them.
118
126
  def cartridge_name
127
+ return @computer.cartridge_slots.map(&:upcase).join(", ") if @computer.family == :plus4
128
+
119
129
  cartridge = @computer.address_bus.cartridge
120
130
  cartridge.nil? ? "" : cartridge.name
121
131
  end
@@ -89,11 +89,7 @@ module Badline
89
89
 
90
90
  def vic20? = @computer.family == :vic20
91
91
 
92
- # Says in the dim colour that the VIC-20 hasn't got what the page
93
- # shows.
94
- def note
95
- @painter.text(@left, @top, "NOT ON THE VIC-20 YET", MenuTheme::DIM)
96
- end
92
+ def plus4? = @computer.family == :plus4
97
93
 
98
94
  def draw_drive
99
95
  media = @media
@@ -128,10 +124,12 @@ module Badline
128
124
  def draw_expansion
129
125
  return info("RAM EXPANSION", RAM_NAMES.fetch(@computer.ram_configuration)) if vic20?
130
126
 
131
- cartridge = @computer.address_bus.cartridge
132
- info("CARTRIDGE", cartridge.nil? ? "" : cartridge.name)
127
+ info("CARTRIDGE", @media.cartridge_name)
133
128
  row("INSERT...", :insert_cartridge)
134
129
  row("REMOVE", :remove_cartridge)
130
+ return if plus4?
131
+
132
+ cartridge = @computer.address_bus.cartridge
135
133
  row("FREEZE", :freeze) if !cartridge.nil? && cartridge.freezer? && @computer.family == :c64
136
134
  return if @reu_kb.nil?
137
135
 
@@ -167,9 +165,11 @@ module Badline
167
165
  end
168
166
  end
169
167
 
170
- # The C128 names its model and the mode it runs in first.
168
+ # The C128 names its model and the mode it runs in first, and the
169
+ # Plus/4 its model and RAM.
171
170
  def draw_power
172
171
  info("MACHINE", "#{@computer.model.name.upcase}, #{@computer.mode.to_s.upcase} MODE") if c128?
172
+ info("MACHINE", @computer.model_label) if plus4?
173
173
  row("RESET", :reset)
174
174
  row("POWER CYCLE", :power_cycle)
175
175
  skip
@@ -5,7 +5,7 @@ module Badline
5
5
  # The pause menu's PORTS page, which MenuPages draws: what each C64
6
6
  # control port has plugged in, a joystick or a pot device, and whether
7
7
  # the keys type or drive the joysticks. The VIC-20 has one port, a
8
- # joystick's.
8
+ # joystick's, and the Plus/4 two joysticks and no pot devices.
9
9
  module MenuPorts
10
10
  PORT_DEVICES = %i[joystick mouse paddles].freeze
11
11
  PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
@@ -16,6 +16,7 @@ module Badline
16
16
 
17
17
  def draw_ports
18
18
  return draw_vic20_port if @computer.family == :vic20
19
+ return draw_plus4_ports if @computer.family == :plus4
19
20
 
20
21
  toggle("PORT 1", :port1, [PORT_NAMES, PORT_ACTIONS[0], PORT_DEVICES.index(port_device(1))])
21
22
  toggle("PORT 2", :port2, [PORT_NAMES, PORT_ACTIONS[1], PORT_DEVICES.index(port_device(2))])
@@ -37,6 +38,17 @@ module Badline
37
38
  info("WASD, LEFT SHIFT", "JOYSTICK")
38
39
  end
39
40
 
41
+ # The Plus/4's two joysticks, one for each set of keys.
42
+ def draw_plus4_ports
43
+ return unless draw_keys("PLUS/4")
44
+
45
+ row("SWAP JOYSTICKS", :swap)
46
+ skip
47
+ arrows = @controls.arrows_port
48
+ info("ARROWS, SPACE", "JOYSTICK #{arrows}")
49
+ info("WASD, LEFT SHIFT", "JOYSTICK #{3 - arrows}")
50
+ end
51
+
40
52
  # The toggle between the keyboard named and the joysticks, and
41
53
  # whether the keys drive the joysticks.
42
54
  def draw_keys(keyboard)
@@ -4,9 +4,10 @@ module Badline
4
4
  module Frontend
5
5
  # The visible part of the video chip's display, the crop of the
6
6
  # machine's timing, repacked for an SDL texture in XRGB8888 in the
7
- # chip's palette. Spinel hands an Array of Integers to C as 64-bit
8
- # words, as badline/ffi does on CRuby, so each word carries two
9
- # neighbouring pixels, the left one in the low half.
7
+ # chip's palette, of 16 colours or the TED's 128. Spinel hands an Array
8
+ # of Integers to C as 64-bit words, as badline/ffi does on CRuby, so
9
+ # each word carries two neighbouring pixels, the left one in the low
10
+ # half.
10
11
  #
11
12
  # Each pixel shows `pixel_width` of the window's square pixels wide,
12
13
  # or with `dots`, that many pixels share one, as the VDC's dots do. The
@@ -14,18 +15,21 @@ module Badline
14
15
  # least MIN_WIDTH window pixels wide, and at least as high as PAL's C64
15
16
  # crop, 272 lines, so a C64 shows in the same window whatever its
16
17
  # region. A crop with fewer lines or pixels sits in the middle of the
17
- # texture, between black bands. The VIC-20's 284 by 284 crop fills its
18
- # own.
18
+ # texture, between black bands. The VIC-20's 284 by 284 crop and the
19
+ # TED's 384 by 288 fill their own.
20
+ #
21
+ # With `frame_lines`, the crop's lines run on past the frame's last
22
+ # line to its first, as the TED's do: its top border is the end of
23
+ # the frame.
19
24
  class Screen
20
25
  MIN_WIDTH = Region::PAL.crop[2]
21
26
  MIN_HEIGHT = Region::PAL.crop[3]
22
27
 
23
28
  attr_reader :pixels, :width, :height
24
29
 
25
- def initialize(video, crop, pixel_width: 1, dots: 1)
30
+ def initialize(video, crop, pixel_width: 1, dots: 1, frame_lines: 0)
26
31
  @video = video
27
32
  @col_offset = crop[0]
28
- @row_offset = crop[1]
29
33
  @lines = crop[3]
30
34
  @columns = crop[2] & ~1
31
35
  @pixel_width = pixel_width
@@ -37,8 +41,10 @@ module Badline
37
41
  @pair_count = @columns / 2
38
42
  @margin = (@row_words - @pair_count) / 2
39
43
  @band = (@height - @lines) / 2
40
- palette = video.palette
41
- @pairs = Array.new(256) { |pair| palette[pair & 0x0f] | (palette[pair >> 4] << 32) }
44
+ top = crop[1]
45
+ wrap = frame_lines.zero? ? top + @lines : frame_lines
46
+ @sources = Array.new(@lines) { |row| (row + top) % wrap }
47
+ pair_up(video.palette)
42
48
  @pixels = Array.new(@height * @row_words, 0)
43
49
  pack_all
44
50
  end
@@ -54,9 +60,10 @@ module Badline
54
60
  video = @video
55
61
  dirty = video.dirty_lines
56
62
  display = video.display
63
+ sources = @sources
57
64
  row = 0
58
65
  while row < @lines
59
- pack_row(display, row) if dirty[row + @row_offset]
66
+ pack_row(display, row) if dirty[sources[row]]
60
67
  row += 1
61
68
  end
62
69
  video.clear_dirty_lines!
@@ -64,6 +71,18 @@ module Badline
64
71
 
65
72
  private
66
73
 
74
+ # The packed word for each pair of palette indices, the left one in
75
+ # the low bits.
76
+ def pair_up(palette)
77
+ colors = palette.size
78
+ shift = 4
79
+ shift += 1 while (1 << shift) < colors
80
+ mask = colors - 1
81
+ @shift = shift
82
+ @mask = mask
83
+ @pairs = Array.new(colors << shift) { |pair| palette[pair & mask] | (palette[(pair >> shift) & mask] << 32) }
84
+ end
85
+
67
86
  # Repacks every line, as a screen built for a display the chip has
68
87
  # already drawn starts with all of it.
69
88
  def pack_all
@@ -78,11 +97,13 @@ module Badline
78
97
  def pack_row(display, row)
79
98
  pixels = @pixels
80
99
  pairs = @pairs
81
- from = ((row + @row_offset) * @stride) + @col_offset
100
+ mask = @mask
101
+ shift = @shift
102
+ from = (@sources[row] * @stride) + @col_offset
82
103
  to = ((row + @band) * @row_words) + @margin
83
104
  last = to + @pair_count
84
105
  while to < last
85
- pixels[to] = pairs[(display[from] & 0x0f) | ((display[from + 1] & 0x0f) << 4)]
106
+ pixels[to] = pairs[(display[from] & mask) | ((display[from + 1] & mask) << shift)]
86
107
  from += 2
87
108
  to += 1
88
109
  end
@@ -70,7 +70,8 @@ module Badline
70
70
  @screen = Screen.new(vdc, vdc.crop, dots: 2)
71
71
  else
72
72
  timing = @computer.timing
73
- @screen = Screen.new(@computer.video, timing.crop, pixel_width: timing.pixel_width)
73
+ @screen = Screen.new(@computer.video, timing.crop,
74
+ pixel_width: timing.pixel_width, frame_lines: timing.lines_per_frame)
74
75
  end
75
76
  end
76
77
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # The machine's sound source, the C64's SID or the VIC-20's VIC, on
6
- # SDL's audio queue, as mono signed 16-bit samples. Both take
7
- # #record(rate:, clock_hz:) and #drain_samples.
5
+ # The machine's sound source, the C64's SID, the VIC-20's VIC or the
6
+ # Plus/4's TED, on SDL's audio queue, as mono signed 16-bit samples.
7
+ # Each takes #record(rate:, clock_hz:) and #drain_samples.
8
8
  # Once the device plays, it is the clock: #wait holds each frame until
9
9
  # the queue is down to `AHEAD`, so the machine runs at the device's pace.
10
10
  #
@@ -16,7 +16,7 @@ module Badline
16
16
  #
17
17
  # Each frame's samples go to a mono AudioDevice. The chip's cycles are
18
18
  # converted at `clock_hz`, the machine's clock, so an NTSC or Drean
19
- # machine plays at its own pitch.
19
+ # machine plays at its own pitch, and the TED's at its single clock.
20
20
  class Sound
21
21
  RATE = 44_100
22
22
  AHEAD = 0.08
@@ -38,17 +38,30 @@ module Badline
38
38
  # sound. An empty one is its first.
39
39
  def self.page?(name) = name.empty? || PauseMenu::PAGES.any? { |page| page.to_s == name }
40
40
 
41
+ # Whether the family's machine has a key of the name. The Plus/4 has
42
+ # no RESTORE.
41
43
  def self.key?(name, family)
42
- name == "restore" || (family == :c128 && LOCKS.include?(name)) ||
44
+ (name == "restore" && family != :plus4) || (family == :c128 && LOCKS.include?(name)) ||
43
45
  !keyboard_key(name, family).nil? || !joystick_direction(name).nil?
44
46
  end
45
47
 
46
48
  # The family's Keyboard key for a name such as space, return or a,
47
- # or on the C128 esc or keypad1.
49
+ # on the C128 esc or keypad1, or on the Plus/4 help or crsr_up. The
50
+ # shifted cursor keys, such as cursor_up, are the C64's and the
51
+ # C128's.
48
52
  def self.keyboard_key(name, family)
49
- matrix = family == :c128 ? C128::KEYBOARD_MATRIX : Input::Keyboard::C64_MATRIX
50
- key = matrix.flatten.find { |candidate| candidate.to_s == name }
51
- key.nil? ? Input::Keyboard::COMBINATIONS.keys.find { |candidate| candidate.to_s == name } : key
53
+ key = keyboard_matrix(family).flatten.find { |candidate| candidate.to_s == name }
54
+ return key unless key.nil? && family != :plus4
55
+
56
+ Input::Keyboard::COMBINATIONS.keys.find { |candidate| candidate.to_s == name }
57
+ end
58
+
59
+ def self.keyboard_matrix(family)
60
+ case family
61
+ when :c128 then C128::KEYBOARD_MATRIX
62
+ when :plus4 then Plus4::KeyboardPort::MATRIX
63
+ else Input::Keyboard::C64_MATRIX
64
+ end
52
65
  end
53
66
 
54
67
  # The direction of joy1-NAME or joy2-NAME, such as up or fire.
@@ -215,7 +228,7 @@ module Badline
215
228
 
216
229
  def inserted?(computer, what)
217
230
  return !computer.datasette.tape.nil? if what == "tape"
218
- return !computer.address_bus.cartridge.nil? if what == "cartridge"
231
+ return MediaSlots.cartridge?(computer) if what == "cartridge"
219
232
 
220
233
  MediaSlots.disk?(computer)
221
234
  end
@@ -4,7 +4,7 @@ module Badline
4
4
  module Machine
5
5
  # Brings the machine to the point a snapshot file is saved at: the main
6
6
  # CPU between instructions, or jammed, with no REU transfer holding the
7
- # bus. C64, Vic20 and C128 each include it, and answer
7
+ # bus. C64, Vic20, C128 and Plus4 each include it, and answer
8
8
  # #settled_for_snapshot? and #settle_chips_for_snapshot, which brings
9
9
  # what each chip defers up to the cycle without running one.
10
10
  module Settling
@@ -9,7 +9,7 @@ module Badline
9
9
  #
10
10
  # The machines badline builds by family, as the command line's
11
11
  # subcommand names them: the C64, Badline::C64, the VIC-20, Vic20, and the
12
- # C128 in C64 mode, C128.
12
+ # C128 in C64 mode, C128, and the 264 series, Plus4.
13
13
  #
14
14
  # A machine answers what the front end, Media, the runners and the
15
15
  # snapshots call on it:
@@ -49,22 +49,31 @@ module Badline
49
49
  # +release_display_key+, and +attach_drive1571+ and +drive1571+ for the
50
50
  # C128D's 1571, its true drive.
51
51
  #
52
+ # The Plus/4 answers the identity, clocking, lifecycle, video, sound and
53
+ # input parts, with nil for +control_ports+ and no RESTORE, and of the
54
+ # rest +ram+, +load_prg+, +type_text+, +mount+, +unmount+, +mounted?+,
55
+ # +datasette+, +attach_cartridge+, +attach_drive1541+, +drive1541+,
56
+ # +true_drive+, +plug_true_drive+, the 1581's, +install_debug_register+
57
+ # and +capture_output+, and besides +model_label+, +cartridge_slots+ and
58
+ # +detach_cartridges+ for its two cartridges.
59
+ #
52
60
  # Each machine includes Machine::Clocking for the clocking part.
53
61
  module Machine
54
62
  # Media the machine named doesn't take.
55
63
  class Unsupported < ArgumentError; end
56
64
 
57
- # A new machine of `family`, :c64, :vic20 or :c128, the model of it
58
- # `model` names. A C64's model is one of Model::ALL, with its SID
65
+ # A new machine of `family`, :c64, :vic20, :c128 or :plus4, the model
66
+ # of it `model` names. A C64's model is one of Model::ALL, with its SID
59
67
  # `sid_model` when not nil and an REU of `reu` K when not nil. A
60
68
  # VIC-20's is "pal", with the RAM expansion `ram` names
61
69
  # (Vic20::Bus::RAM_CONFIGURATIONS), unexpanded when nil. A C128's is
62
70
  # one of C128::Model::ALL, with its SID `sid_model` when not nil, built
63
- # for C64 mode. :plus4 is refused until the Plus/4 runs.
71
+ # for C64 mode. A Plus/4's is one of Plus4::Model::ALL or c116, with
72
+ # the model's own RAM.
64
73
  def self.build(family, model:, sid_model: nil, reu: nil, ram: nil)
65
74
  return vic20(model, ram) if family == :vic20
66
75
  return c128(model, sid_model, :c64) if family == :c128
67
- raise ArgumentError, "the Plus/4 isn't emulated yet" if family == :plus4
76
+ return Plus4.new(model:) if family == :plus4
68
77
  raise ArgumentError, "no machine family named #{family}" unless family == :c64
69
78
 
70
79
  profile = Model.named(model)
@@ -80,10 +89,11 @@ module Badline
80
89
  # only C64 mode runs: a .sid tune or a program that loads at the C64's
81
90
  # BASIC start. A VIC-20 without RAM named gets the expansion the media
82
91
  # needs (Media::Vic20Media.ram_for) and yields a line saying so. Raises
83
- # Unsupported for media a VIC-20 doesn't take.
92
+ # Unsupported for media a VIC-20 or a Plus/4 doesn't take.
84
93
  def self.for_options(options, media, &)
85
94
  family = options.family
86
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
87
97
 
88
98
  sid_model = options.sid_model || Media.sid_model(media, otherwise: nil)
89
99
  return build(:c64, model: options.model, sid_model:, reu: options.reu) unless family == :c128
@@ -117,6 +127,16 @@ module Badline
117
127
  end
118
128
  private_class_method :vic20_for
119
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)
133
+ unsupported = !media.nil? && Media::Vic20Media::UNSUPPORTED.include?(File.extname(media).downcase)
134
+ raise Unsupported, "doesn't go in a Plus/4" if unsupported
135
+
136
+ Plus4.new(model:, ram: ram_kb)
137
+ end
138
+ private_class_method :plus4_for
139
+
120
140
  def self.c64_media?(media)
121
141
  return false if media.nil? || !File.file?(media)
122
142
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # What the 264 series does with media of its own (Media.attach). A
6
+ # cartridge is a PLUS4 CARTRIDGE .crt, or a raw ROM image (.bin) that
7
+ # goes in the slot it's given, or by its size in cartridge 1's.
8
+ module Plus4Media
9
+ # The platform byte of a C16 or Plus/4 tape (Storage::TAP#platform).
10
+ TAPE_PLATFORM = 2
11
+
12
+ # The extension of a raw cartridge ROM image.
13
+ ROM_IMAGE = ".bin"
14
+
15
+ # The cartridge slots by xplus4's names, each as the bank and address
16
+ # a .crt's CHIP packet gives it (Plus4::Bus#attach_cartridge).
17
+ SLOTS = { "c1lo" => [0, 0x8000], "c1hi" => [0, 0xc000], "c2lo" => [1, 0x8000], "c2hi" => [1, 0xc000] }.freeze
18
+
19
+ SLOT_SIZE = 0x4000
20
+
21
+ class << self
22
+ # A tape made for another machine plays all the same, and says so.
23
+ def tape_message(tape, message)
24
+ tape.platform == TAPE_PLATFORM ? message : "#{message}, a tape for another machine than the Plus/4"
25
+ end
26
+
27
+ # Whether the file at `path` is a raw cartridge ROM image.
28
+ def rom_image?(path) = File.extname(path).downcase == ROM_IMAGE
29
+
30
+ # The ROM chips, as Storage::CRTFile::Chips, of the cartridge at
31
+ # `path`: a .crt's, or a raw image's (#rom_chips).
32
+ def cartridge_chips(path, slot: nil)
33
+ return rom_chips(File.binread(path).bytes, slot:) if rom_image?(path)
34
+
35
+ Storage::CRTFile.new(path, machine: :plus4).chips
36
+ end
37
+
38
+ # A raw ROM image's chips: one in `slot`, one of SLOTS, when it's
39
+ # named. Otherwise up to 16K goes in cartridge 1's low slot, and
40
+ # up to 32K fills its high slot too.
41
+ def rom_chips(bytes, slot: nil)
42
+ return [slot_chip(bytes, slot)] if slot
43
+
44
+ size = bytes.length
45
+ unless size.between?(1, 2 * SLOT_SIZE)
46
+ raise Machine::Unsupported, "a #{size}-byte image is no Plus/4 cartridge"
47
+ end
48
+
49
+ chips = [chip(0, 0x8000, bytes[0, SLOT_SIZE])]
50
+ chips << chip(0, 0xc000, bytes[SLOT_SIZE..]) if size > SLOT_SIZE
51
+ chips
52
+ end
53
+
54
+ private
55
+
56
+ def slot_chip(bytes, slot)
57
+ bank, address = SLOTS.fetch(slot) { raise ArgumentError, "no Plus/4 cartridge slot named #{slot}" }
58
+ size = bytes.length
59
+ raise Machine::Unsupported, "a #{size}-byte image doesn't fit a 16K slot" unless size.between?(1, SLOT_SIZE)
60
+
61
+ chip(bank, address, bytes)
62
+ end
63
+
64
+ def chip(bank, address, data) = Storage::CRTFile::Chip.new(chip_type: 0, bank:, address:, data:)
65
+ end
66
+ end
67
+ end
68
+ end