badline 0.7.0 → 0.9.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +104 -292
  5. data/doc/library.md +69 -0
  6. data/doc/media.md +78 -0
  7. data/doc/pinned-behaviour.md +53 -9
  8. data/doc/sid-player.md +136 -0
  9. data/doc/snapshots.md +102 -0
  10. data/lib/badline/address_bus/roms.rb +32 -0
  11. data/lib/badline/address_bus.rb +4 -4
  12. data/lib/badline/audio/bare_player.rb +24 -0
  13. data/lib/badline/audio/checkpoints.rb +42 -0
  14. data/lib/badline/audio/jukebox.rb +11 -4
  15. data/lib/badline/audio/machine_player.rb +16 -0
  16. data/lib/badline/audio/renderer.rb +80 -9
  17. data/lib/badline/audio/stereo.rb +3 -5
  18. data/lib/badline/audio.rb +1 -0
  19. data/lib/badline/cartridge/freezer.rb +2 -0
  20. data/lib/badline/cartridge.rb +3 -0
  21. data/lib/badline/computer/attachments.rb +5 -5
  22. data/lib/badline/computer.rb +12 -10
  23. data/lib/badline/core.rb +48 -0
  24. data/lib/badline/data_path.rb +37 -0
  25. data/lib/badline/datasette.rb +24 -9
  26. data/lib/badline/drive1541/disk.rb +3 -3
  27. data/lib/badline/drive1541/disk_state.rb +14 -5
  28. data/lib/badline/frontend/app.rb +41 -37
  29. data/lib/badline/frontend/boot.rb +16 -4
  30. data/lib/badline/frontend/buttons.rb +101 -7
  31. data/lib/badline/frontend/confirmation.rb +56 -0
  32. data/lib/badline/frontend/controls.rb +44 -32
  33. data/lib/badline/frontend/file_browser.rb +225 -0
  34. data/lib/badline/frontend/frame_report.rb +52 -0
  35. data/lib/badline/frontend/menu_dialogs.rb +160 -0
  36. data/lib/badline/frontend/menu_media.rb +157 -0
  37. data/lib/badline/frontend/menu_pages.rb +220 -0
  38. data/lib/badline/frontend/name_field.rb +88 -0
  39. data/lib/badline/frontend/painter.rb +10 -0
  40. data/lib/badline/frontend/pause_menu.rb +264 -0
  41. data/lib/badline/frontend/player_window.rb +3 -3
  42. data/lib/badline/frontend/snapshots.rb +146 -18
  43. data/lib/badline/frontend/sound.rb +11 -6
  44. data/lib/badline/frontend/timeline.rb +5 -1
  45. data/lib/badline/frontend.rb +8 -0
  46. data/lib/badline/kernal_trap/load.rb +10 -0
  47. data/lib/badline/kernal_trap/routine.rb +28 -0
  48. data/lib/badline/kernal_trap/serial.rb +19 -7
  49. data/lib/badline/media/disk_list.rb +75 -0
  50. data/lib/badline/media/disk_set.rb +118 -0
  51. data/lib/badline/media.rb +9 -3
  52. data/lib/badline/model.rb +62 -0
  53. data/lib/badline/options/event.rb +5 -3
  54. data/lib/badline/options/help.rb +16 -12
  55. data/lib/badline/options/table.rb +6 -2
  56. data/lib/badline/options/validation.rb +9 -0
  57. data/lib/badline/options.rb +10 -7
  58. data/lib/badline/region.rb +16 -4
  59. data/lib/badline/reu/ram.rb +27 -0
  60. data/lib/badline/reu/saved_state.rb +27 -0
  61. data/lib/badline/roms/README +10 -0
  62. data/lib/badline/roms/kernal-sx64.rom +0 -0
  63. data/lib/badline/sid/decimator.rb +13 -0
  64. data/lib/badline/sid/filter.rb +6 -3
  65. data/lib/badline/sid.rb +19 -0
  66. data/lib/badline/snapshot/fields.rb +1 -0
  67. data/lib/badline/snapshot/setup.rb +20 -8
  68. data/lib/badline/snapshot/state.rb +14 -7
  69. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  70. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  71. data/lib/badline/snapshot/vice/restore.rb +3 -1
  72. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  73. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  74. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  75. data/lib/badline/snapshot/vice.rb +25 -23
  76. data/lib/badline/storage/t64.rb +7 -4
  77. data/lib/badline/storage/tap.rb +7 -5
  78. data/lib/badline/storage/unavailable.rb +1 -1
  79. data/lib/badline/storage.rb +5 -5
  80. data/lib/badline/version.rb +1 -1
  81. data/lib/badline/vic/sprite/timing.rb +5 -9
  82. data/lib/badline/vic/sprite.rb +4 -3
  83. data/lib/badline.rb +2 -38
  84. metadata +22 -1
@@ -85,17 +85,24 @@ module Badline
85
85
  end
86
86
 
87
87
  # Addressing the bus starts a new frame. The channel number arrives
88
- # in the secondary address that follows.
88
+ # in the secondary address that follows. Each byte sent under ATN,
89
+ # and each byte of a frame, times its handshake with CIA 1's timer B
90
+ # as ISOUR does.
89
91
  def talk
90
92
  @talk_channel = nil
91
93
  @talking = @cpu.a == @device
92
- return_to_caller if @talking
94
+ sent_under_atn if @talking
93
95
  end
94
96
 
95
97
  def listen
96
98
  @listen_channel = nil
97
99
  @listening = @cpu.a == @device
98
- return_to_caller if @listening
100
+ sent_under_atn if @listening
101
+ end
102
+
103
+ def sent_under_atn
104
+ time_serial_byte(ISOUR_TIMEOUT)
105
+ return_to_caller
99
106
  end
100
107
 
101
108
  def second
@@ -104,19 +111,22 @@ module Badline
104
111
  @frame = @cpu.a & 0xf0
105
112
  @listen_channel = @cpu.a & 0x0f
106
113
  @buffer = []
107
- return_to_caller
114
+ sent_under_atn
108
115
  end
109
116
 
110
117
  def tksa
111
118
  return unless @talking
112
119
 
113
120
  @talk_channel = @cpu.a & 0x0f
114
- return_to_caller
121
+ sent_under_atn
115
122
  end
116
123
 
124
+ # CIOUT holds each byte back until the next one, or the UNLISTEN,
125
+ # comes, so the first byte of a frame sends nothing.
117
126
  def ciout
118
127
  return unless @listening
119
128
 
129
+ time_serial_byte(ISOUR_TIMEOUT) if !@buffer.empty? && accepted?
120
130
  @buffer << @cpu.a
121
131
  update_status(DEVICE_NOT_PRESENT) unless accepted?
122
132
  @cpu.status.carry = false
@@ -135,6 +145,7 @@ module Badline
135
145
 
136
146
  @listening = false
137
147
  deliver_frame
148
+ time_serial_byte(ISOUR_TIMEOUT)
138
149
  release_bus
139
150
  end
140
151
 
@@ -142,14 +153,14 @@ module Badline
142
153
  return unless @talking
143
154
 
144
155
  @talking = false
156
+ time_serial_byte(ISOUR_TIMEOUT)
145
157
  release_bus
146
158
  end
147
159
 
148
160
  # UNLSN and UNTLK end by releasing ATN, the clock and the data line on
149
161
  # CIA 2's port A, and leave the port's last read in the accumulator.
150
162
  def release_bus
151
- @bus.poke(0xdd00, @bus.peek(0xdd00) & 0xc7)
152
- @cpu.a = @bus.peek(0xdd00) & 0xdf
163
+ @cpu.a = release_serial_lines
153
164
  @cpu.status.negative = @cpu.a.anybits?(0x80)
154
165
  @cpu.status.zero = @cpu.a.zero?
155
166
  return_to_caller
@@ -159,6 +170,7 @@ module Badline
159
170
  return unless @talking
160
171
 
161
172
  byte, status = received
173
+ time_serial_byte(ACPTR_TIMEOUT)
162
174
  @cpu.a = byte
163
175
  update_status(status)
164
176
  @cpu.status.carry = false
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # A list of the disks of a set: an .m3u playlist, one path a line, or
6
+ # a VICE .vfl flip list, whose paths follow a `UNIT 8` line. Paths are
7
+ # relative to the list or absolute, and blank lines and lines starting
8
+ # with # are skipped. Only the disk images the list names that are
9
+ # there count.
10
+ module DiskList
11
+ # A list naming no disk image that's there.
12
+ class Error < ArgumentError; end
13
+
14
+ EXTENSIONS = %w[.m3u .vfl].freeze
15
+
16
+ def self.list?(path) = EXTENSIONS.include?(File.extname(path).downcase)
17
+
18
+ # The disk `path` puts in the drive: a list's first disk, or the
19
+ # path itself.
20
+ def self.disk(path)
21
+ return path unless list?(path)
22
+
23
+ disks = disks(path)
24
+ raise Error, "#{path} lists no disk image that's there" if disks.empty?
25
+
26
+ disks.first
27
+ end
28
+
29
+ # The disk images the list names that are there, in its order.
30
+ def self.disks(list)
31
+ directory = File.dirname(list)
32
+ found = []
33
+ entries(list).each do |entry|
34
+ disk = entry.start_with?("/") ? entry : File.join(directory, entry)
35
+ found << disk if DiskSet::EXTENSIONS.include?(File.extname(disk).downcase) && File.file?(disk)
36
+ end
37
+ found
38
+ end
39
+
40
+ # The paths of the list's lines, only unit 8's for a .vfl, which
41
+ # starts on unit 8.
42
+ def self.entries(list)
43
+ flip = File.extname(list).casecmp?(".vfl")
44
+ unit = true
45
+ found = []
46
+ File.foreach(list) do |line|
47
+ text = line.strip
48
+ next if text.empty? || text.start_with?("#")
49
+
50
+ if flip && text.match?(/\AUNIT\s+\d+\z/i)
51
+ unit = text.split.last.to_i == 8
52
+ elsif unit
53
+ found << text
54
+ end
55
+ end
56
+ found
57
+ rescue SystemCallError
58
+ []
59
+ end
60
+
61
+ # The disks of the first list in the disk's folder that names it,
62
+ # with the disk as given, or none.
63
+ def self.beside(path)
64
+ directory = File.dirname(path)
65
+ own = File.expand_path(path)
66
+ lists = DiskSet.children(directory).sort.select { |name| list?(name) }
67
+ sets = lists.map { |name| disks(File.join(directory, name)) }
68
+ set = sets.find { |listed| listed.any? { |disk| File.expand_path(disk) == own } }
69
+ return [] if set.nil?
70
+
71
+ set.map { |disk| File.expand_path(disk) == own ? path : disk }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # The disks of a set a disk image belongs to, found by name in its
6
+ # folder: images whose names differ only in their disk, side or part
7
+ # markers, such as "Disk 1", "Side B", "d2", "(Disk 1 of 3)", ordered
8
+ # by those markers. A name with no marker can end in a number or a
9
+ # letter, as in game_1.d64 and game_2.d64, which counts only when the
10
+ # folder has the first of the set too.
11
+ #
12
+ # A list in the disk's folder that names it (DiskList) gives its set
13
+ # ahead of the names.
14
+ module DiskSet
15
+ KINDS = %w[disk disc side part].freeze
16
+ EXTENSIONS = %w[.d64 .d71 .d81 .g64].freeze
17
+
18
+ # The paths of the set's disks in order, or just `path` when it
19
+ # belongs to none. A list's set is the disks it lists.
20
+ def self.around(path)
21
+ return DiskList.disks(path) if DiskList.list?(path)
22
+ return [path] unless EXTENSIONS.include?(File.extname(path).downcase)
23
+
24
+ listed = DiskList.beside(path)
25
+ listed.empty? ? named(path) : listed
26
+ end
27
+
28
+ # The set of the disk's name.
29
+ def self.named(path)
30
+ extension = File.extname(path).downcase
31
+ marked = !parse(stem(path))[1].negative?
32
+ base = key(stem(path), marked)[0]
33
+ found = siblings(File.dirname(path), extension, base, marked)
34
+ return [path] if found.size < 2 || (!marked && found.none? { |pair| pair[0] == 1 })
35
+
36
+ found.sort_by { |pair| pair[0] }.map { |pair| File.join(File.dirname(path), pair[1]) }
37
+ end
38
+
39
+ # The name's words without their markers, joined, and the order the
40
+ # markers give it, -1 for none.
41
+ def self.parse(stem)
42
+ words = words(stem)
43
+ base = []
44
+ values = []
45
+ index = 0
46
+ index = take(words, index, base, values) while index < words.size
47
+ [base.join(" "), order(values)]
48
+ end
49
+
50
+ # The words before a trailing number or letter, joined, and its
51
+ # order, -1 for a name that doesn't end in one.
52
+ def self.trailing(stem)
53
+ words = words(stem)
54
+ last = words.last.to_s
55
+ return ["", -1] if words.size < 2 || !value?(last) || last.length > 2
56
+
57
+ [words[0, words.size - 1].join(" "), value(last)]
58
+ end
59
+
60
+ def self.words(stem) = stem.downcase.split(/[^a-z0-9]+/).reject(&:empty?)
61
+
62
+ def self.stem(path) = File.basename(path, File.extname(path))
63
+
64
+ def self.key(stem, marked) = marked ? parse(stem) : trailing(stem)
65
+
66
+ # The order and name of each image in the folder in the set.
67
+ def self.siblings(directory, extension, base, marked)
68
+ found = []
69
+ children(directory).each do |name|
70
+ next unless File.extname(name).downcase == extension
71
+
72
+ name_key = key(stem(name), marked)
73
+ found << [name_key[1], name] if name_key[0] == base && !name_key[1].negative?
74
+ end
75
+ found
76
+ end
77
+
78
+ # Adds the word at `index` to the base, or its marker's value to the
79
+ # values, and returns the index of the next word.
80
+ def self.take(words, index, base, values)
81
+ word = words[index]
82
+ following = words[index + 1].to_s
83
+ if KINDS.include?(word) && value?(following)
84
+ values << value(following)
85
+ index += 2
86
+ index += 2 if words[index] == "of" && words[index + 1].to_s.match?(/\A\d+\z/)
87
+ index
88
+ elsif marker?(word)
89
+ values << value(word.sub(/\A(disk|disc|side|part|d|s)/, ""))
90
+ index + 1
91
+ else
92
+ base << word
93
+ index + 1
94
+ end
95
+ end
96
+
97
+ def self.marker?(word) = word.match?(/\A(disk|disc|side|part)(\d+|[a-z])\z/) || word.match?(/\A[ds]\d+\z/)
98
+
99
+ def self.value?(word) = word.match?(/\A(\d+|[a-z])\z/)
100
+
101
+ # A number counts as itself and a letter by its place in the
102
+ # alphabet, so side A is 1.
103
+ def self.value(word) = word.match?(/\A\d+\z/) ? word.to_i : word.ord - 96
104
+
105
+ def self.order(values)
106
+ return -1 if values.empty?
107
+
108
+ values.reduce(0) { |order, value| (order * 1000) + value }
109
+ end
110
+
111
+ def self.children(directory)
112
+ Dir.children(directory)
113
+ rescue SystemCallError
114
+ []
115
+ end
116
+ end
117
+ end
118
+ end
data/lib/badline/media.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "badline/media/true_drive"
4
4
  require "badline/media/queue"
5
+ require "badline/media/disk_set"
6
+ require "badline/media/disk_list"
5
7
 
6
8
  module Badline
7
9
  module Media
@@ -26,7 +28,11 @@ module Badline
26
28
  # A .g64 plugs in a true drive as device 8 (TrueDrive), and with one
27
29
  # there, a .d64 goes into it instead of the KERNAL traps too. The
28
30
  # autostart then loads through it.
31
+ #
32
+ # An .m3u or .vfl list of disks attaches the first disk it lists
33
+ # (DiskList).
29
34
  def attach(computer, path, autostart: true, subtune: nil, **options)
35
+ path = DiskList.disk(path)
30
36
  if TrueDrive.takes?(computer, path)
31
37
  attach_true_drive(computer, path, options.fetch(:disk, {}), autostart:)
32
38
  elsif File.directory?(path)
@@ -64,9 +70,9 @@ module Badline
64
70
  end
65
71
 
66
72
  # The SID a machine for `path` should be built with. A .sid tune names
67
- # its own; everything else gets the 6581.
68
- def sid_model(path)
69
- return :mos6581 unless path && File.extname(path).downcase == ".sid"
73
+ # its own; everything else gets `otherwise`.
74
+ def sid_model(path, otherwise: :mos6581)
75
+ return otherwise unless path && File.extname(path).downcase == ".sid"
70
76
 
71
77
  Storage::SIDFile.new(path).sid_model
72
78
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ # = Model
5
+ #
6
+ # The C64s badline builds by name, as `--model` selects them: the chips
7
+ # each was sold with, its video standard, its KERNAL and whether it has
8
+ # a datasette. The names are x64sc's.
9
+ module Model
10
+ # Each field but the name is a keyword Computer.new takes.
11
+ Profile = Data.define(:name, :vic_model, :cia_model, :sid_model, :region, :kernal, :datasette)
12
+
13
+ # The breadbin PAL C64: the 6569 VIC-II, 6526 CIAs and the 6581 SID.
14
+ C64 = Profile.new(name: "c64", vic_model: :mos6569, cia_model: :mos6526, sid_model: :mos6581,
15
+ region: Region::PAL, kernal: :c64, datasette: true)
16
+
17
+ # The PAL C64C: the HMOS 8565 VIC-II, 6526A CIAs and the 8580 SID.
18
+ C64C = Profile.new(name: "c64c", vic_model: :mos8565, cia_model: :mos6526a, sid_model: :mos8580,
19
+ region: Region::PAL, kernal: :c64, datasette: true)
20
+
21
+ # The NTSC C64 with the 6567R8 VIC-II.
22
+ NTSC = Profile.new(name: "ntsc", vic_model: :mos6569, cia_model: :mos6526, sid_model: :mos6581,
23
+ region: Region::NTSC, kernal: :c64, datasette: true)
24
+
25
+ # The NTSC C64C: the 8562, the 6567R8's HMOS successor, with the
26
+ # C64C's CIAs and SID.
27
+ NEW_NTSC = Profile.new(name: "newntsc", vic_model: :mos8565, cia_model: :mos6526a, sid_model: :mos8580,
28
+ region: Region::NTSC, kernal: :c64, datasette: true)
29
+
30
+ # The first NTSC C64s, with the 6567R56A VIC-II.
31
+ OLD_NTSC = Profile.new(name: "oldntsc", vic_model: :mos6569, cia_model: :mos6526, sid_model: :mos6581,
32
+ region: Region::NTSC_OLD, kernal: :c64, datasette: true)
33
+
34
+ # The Drean C64 of Argentina, on PAL-N with the 6572 VIC-II.
35
+ DREAN = Profile.new(name: "drean", vic_model: :mos6569, cia_model: :mos6526, sid_model: :mos6581,
36
+ region: Region::DREAN, kernal: :c64, datasette: true)
37
+
38
+ # The portable SX-64: a PAL C64's chips with its own KERNAL, a 1541
39
+ # built in as device 8 and no cassette port.
40
+ SX64 = Profile.new(name: "sx64", vic_model: :mos6569, cia_model: :mos6526, sid_model: :mos6581,
41
+ region: Region::PAL, kernal: :sx64, datasette: false)
42
+
43
+ ALL = [C64, C64C, NTSC, NEW_NTSC, OLD_NTSC, DREAN, SX64].freeze
44
+
45
+ def self.named(name)
46
+ model = ALL.find { |candidate| candidate.name == name }
47
+ raise ArgumentError, "no C64 model named #{name}" if model.nil?
48
+
49
+ model
50
+ end
51
+
52
+ # The model whose chips, region, KERNAL and datasette `machine` has, or
53
+ # nil for none.
54
+ def self.of(machine)
55
+ ALL.find do |model|
56
+ model.vic_model == machine.vic_model && model.cia_model == machine.cia_model &&
57
+ model.sid_model == machine.sid_model && model.region == machine.region &&
58
+ model.kernal == machine.kernal && model.datasette == machine.datasette
59
+ end
60
+ end
61
+ end
62
+ end
@@ -17,8 +17,8 @@ module Badline
17
17
 
18
18
  EJECTABLE = %w[disk tape cartridge].freeze
19
19
 
20
- # What insert takes: disk images, tapes and cartridges, or a
21
- # directory to mount as device 8.
20
+ # What insert takes: disk images, tapes and cartridges, or an .m3u
21
+ # or .vfl list of disks or a directory to mount as device 8.
22
22
  INSERTABLE = %w[.d64 .d71 .d81 .g64 .t64 .tap .crt].freeze
23
23
 
24
24
  attr_reader :frame, :action, :argument
@@ -87,7 +87,9 @@ module Badline
87
87
  end
88
88
 
89
89
  def self.insertable?(path)
90
- File.directory?(path) || (File.exist?(path) && INSERTABLE.include?(File.extname(path).downcase))
90
+ return true if File.directory?(path)
91
+
92
+ File.exist?(path) && (INSERTABLE.include?(File.extname(path).downcase) || Media::DiskList.list?(path))
91
93
  end
92
94
  end
93
95
  end
@@ -43,14 +43,18 @@ module Badline
43
43
  #{@program} sid [options] FILE|DIR...
44
44
 
45
45
  Media can be a .prg/.p00 program, a .d64/.d71/.d81 disk image, a
46
- .g64 disk image for the true 1541, a .t64 tape archive, a .tap
47
- tape, a .crt cartridge, a .sid tune, a .vsf snapshot, or a directory
48
- to mount as device 8. It opens in the emulator window, where F11
49
- saves a snapshot and F12 restores it. Device 8 answers through
50
- traps on the KERNAL's disk routines, unless --true-drive puts an
51
- emulated 1541 there, which runs its own DOS and reads .d64 and .g64
52
- images only. A .sid tune opens in the SID player, as `sid` plays it,
53
- unless an option of the emulator's window asks for the machine.
46
+ .g64 disk image for the true 1541, an .m3u or .vfl list of disk
47
+ images, a .t64 tape archive, a .tap tape, a .crt cartridge, a .sid
48
+ tune, a .vsf snapshot, or a directory to mount as device 8. It
49
+ opens in the emulator window. There F11 quicksaves the machine to
50
+ the next of five slots in badline's data folder, replacing the
51
+ oldest, and F12 restores the newest quicksave or named save. The
52
+ window also autosaves every two minutes of play, which only the
53
+ pause menu loads. Device 8 answers through traps on the KERNAL's
54
+ disk routines, unless --true-drive puts an emulated 1541 there,
55
+ which runs its own DOS and reads .d64 and .g64 images only. A .sid
56
+ tune opens in the SID player, as `sid` plays it, unless an option
57
+ of the emulator's window asks for the machine.
54
58
 
55
59
  --headless plays a .sid tune on the host's audio device without the
56
60
  window, and --audio-out renders it to 16-bit PCM instead. The
@@ -74,10 +78,10 @@ module Badline
74
78
  key=NAME presses a C64 key (space, return, a, f1, run_stop, restore)
75
79
  or a joystick's direction or fire (joy1-up, joy2-fire) for #{Event::HOLD}
76
80
  frames. type=TEXT types it, with \\n for RETURN. insert=FILE swaps
77
- in a disk, tape or cartridge, and eject=disk, tape or cartridge
78
- takes one out. screenshot=FILE saves the frame as a .bmp, with the
79
- frame's number in place of %d. reset, freeze and quit take no
80
- argument.
81
+ in a disk, a list's first disk, a tape or a cartridge, and
82
+ eject=disk, tape or cartridge takes one out. screenshot=FILE saves
83
+ the frame as a .bmp, with the frame's number in place of %d. reset,
84
+ freeze and quit take no argument.
81
85
  BANNER
82
86
  end
83
87
 
@@ -4,6 +4,9 @@ module Badline
4
4
  class Options
5
5
  DEFAULT_RATE = 44_100
6
6
 
7
+ # The names Badline::Model builds, in the order the help lists them.
8
+ MODELS = %w[c64 c64c ntsc newntsc oldntsc drean sx64].freeze
9
+
7
10
  SECTIONS = ["Options", "Window options", "Options without the window", "Testing options"].freeze
8
11
 
9
12
  # Every option of both builds, in the order the help lists them.
@@ -16,7 +19,7 @@ module Badline
16
19
  Option.new("--version", "Show the version and what built it", section: "Options", build: :native),
17
20
  Option.new("--no-autostart", "Boot to READY. instead of running the program",
18
21
  section: "Window options", needs: :window),
19
- Option.new("--read-only", "Mount a disk image write-protected, leaving its file unchanged",
22
+ Option.new("--writable", "Let the machine write to disk images, changing their files",
20
23
  section: "Window options", needs: :window),
21
24
  Option.new("--sound", "Play the SID through the host's audio device (F10 mutes)",
22
25
  section: "Window options", needs: :window),
@@ -25,8 +28,9 @@ module Badline
25
28
  section: "Window options", needs: :window),
26
29
  Option.new("--reu SIZE", "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384",
27
30
  section: "Window options", needs: :window),
28
- Option.new("--ntsc", "Run an NTSC C64, with the 6567R8 VIC-II, instead of a PAL one",
31
+ Option.new("--model NAME", "C64 to run: #{MODELS.join(', ')} (default: #{MODELS.first})",
29
32
  section: "Window options", needs: :window),
33
+ Option.new("--ntsc", "Run an NTSC C64, as --model ntsc does", section: "Window options", needs: :window),
30
34
  Option.new("--no-vsync", "Pace frames by the timer or the sound instead of the display",
31
35
  section: "Window options", needs: :window),
32
36
  Option.new("--verbose", "Print the display, sound and gamepad setup and the frame timing",
@@ -10,6 +10,7 @@ module Badline
10
10
 
11
11
  def validate
12
12
  validate_mode
13
+ validate_models
13
14
  validate_numbers
14
15
  validate_paths
15
16
  end
@@ -33,6 +34,14 @@ module Badline
33
34
  raise Error, "not a .sid tune: #{path}"
34
35
  end
35
36
 
37
+ # --model and --ntsc may be given more than once, but only for one
38
+ # model.
39
+ def validate_models
40
+ unknown = @models.find { |name| !MODELS.include?(name) }
41
+ raise Error, "invalid argument: --model #{unknown}" unless unknown.nil?
42
+ raise Error, "conflicting models: #{@models.uniq.join(' and ')}" unless @models.uniq.size <= 1
43
+ end
44
+
36
45
  def validate_numbers
37
46
  positive("--subtune", @subtune)
38
47
  positive("--seconds", @seconds)
@@ -59,16 +59,16 @@ module Badline
59
59
  @tune_paths = []
60
60
  @subtune = nil
61
61
  @sid_model = nil
62
+ @models = []
62
63
  @reu = nil
63
64
  @autostart = true
64
- @read_only = false
65
+ @writable = false
65
66
  @sound = native
66
67
  @frames = 0
67
68
  @paced = true
68
69
  @vsync = true
69
70
  @verbose = false
70
71
  @true_drive = false
71
- @ntsc = false
72
72
  @screenshot = ""
73
73
  @save_snapshot = ""
74
74
  @timeline = []
@@ -135,7 +135,7 @@ module Badline
135
135
 
136
136
  def autostart? = @autostart
137
137
 
138
- def read_only? = @read_only
138
+ def writable? = @writable
139
139
 
140
140
  def sound? = @sound
141
141
 
@@ -147,10 +147,12 @@ module Badline
147
147
 
148
148
  def true_drive? = @true_drive
149
149
 
150
- def ntsc? = @ntsc
151
-
152
150
  def jit? = @jit
153
151
 
152
+ # The name of the C64 to build (Badline::Model). --ntsc is short for
153
+ # --model ntsc.
154
+ def model = @models.empty? ? MODELS.first : @models.first
155
+
154
156
  def help? = @help
155
157
 
156
158
  def version? = @version
@@ -222,6 +224,7 @@ module Badline
222
224
  case name
223
225
  when "--subtune" then @subtune = number(flag, value)
224
226
  when "--sid" then @sid_model = sid_model_for(value)
227
+ when "--model" then @models << value
225
228
  when "--reu" then @reu = reu_size(value)
226
229
  when "--frames" then @frames = number(flag, value)
227
230
  when "--screenshot" then @screenshot = value
@@ -238,13 +241,13 @@ module Badline
238
241
  def switch(name)
239
242
  case name
240
243
  when "--no-autostart" then @autostart = false
241
- when "--read-only" then @read_only = true
244
+ when "--writable" then @writable = true
242
245
  when "--sound" then @sound = true
243
246
  when "--no-sound" then @sound = false
244
247
  when "--no-vsync" then @vsync = false
245
248
  when "--verbose" then @verbose = true
246
249
  when "--true-drive" then @true_drive = true
247
- when "--ntsc" then @ntsc = true
250
+ when "--ntsc" then @models << "ntsc"
248
251
  when "--unpaced" then @paced = false
249
252
  when "--disable-jit" then @jit = false
250
253
  when "--help" then @help = true
@@ -21,8 +21,8 @@ module Badline
21
21
  # `sprite_cycle` is Bauer's cycle for sprite 0's p-access, and each
22
22
  # sprite after it takes two more. `sprite_display_cycle` is the cycle
23
23
  # that turns a sprite's display on or off. `x_hold` is how many pixels
24
- # the X counter spends past its run, repeating $184-$187, and a raster
25
- # of more than 512 pixels needs them.
24
+ # the X counter spends past its run, going over $180-$187 a second
25
+ # time, and a raster of more than 512 pixels needs them.
26
26
  Profile = Data.define(:name, :clock_hz, :cycles_per_line, :lines_per_frame, :hblank, :vblank,
27
27
  :display_x_bounds, :crop, :mains_hz, :sprite_cycle, :sprite_display_cycle,
28
28
  :x_hold)
@@ -40,8 +40,8 @@ module Badline
40
40
  # The 6567R8 of an NTSC C64: 65 cycles by 263 lines at 1,022,727 Hz,
41
41
  # with 60 Hz mains. The sprite fetches and the compares ahead of them
42
42
  # run a cycle later than on the 6569 (the spritesteal, spritex and
43
- # phi1timing testprogs), and the X counter pauses near $184, as Bauer's
44
- # 6567R8 diagram shows, reading $184-$187 for 8 pixels more. The
43
+ # phi1timing testprogs), and the X counter runs over $180-$187 twice,
44
+ # 8 pixels more, as the 6567R8's lightpen dump shows. The
45
45
  # blanked lines are the ones VICE's NTSC view leaves out, 12-27. It shows lines 0-11 below
46
46
  # line 262, and the crop leaves those out.
47
47
  NTSC = Profile.new(
@@ -65,6 +65,18 @@ module Badline
65
65
  sprite_cycle: 59, sprite_display_cycle: 58, x_hold: 0
66
66
  )
67
67
 
68
+ # The 6572 of the Drean C64, sold in Argentina on PAL-N: 65 cycles by
69
+ # 312 lines at 1,023,440 Hz, with 50 Hz mains. It lays out the line
70
+ # as the 6567R8 does, sprite fetches and X counter hold included
71
+ # (spritescan_drean), and blanks and crops lines as PAL does.
72
+ DREAN = Profile.new(
73
+ name: :drean, clock_hz: 1_023_440, cycles_per_line: 65, lines_per_frame: 312,
74
+ hblank: [63, 9].freeze, vblank: [300, 15].freeze,
75
+ display_x_bounds: [[135, 438].freeze, [128, 447].freeze].freeze,
76
+ crop: [96, 20, 384, 272].freeze, mains_hz: 50,
77
+ sprite_cycle: 59, sprite_display_cycle: 59, x_hold: 8
78
+ )
79
+
68
80
  # Whether a column or line lies in a [first, last] blanking span.
69
81
  def self.blanked?(position, span)
70
82
  first, last = span
@@ -52,8 +52,35 @@ module Badline
52
52
  @banks.each_index { |number| @banks[number] = input.boolean? ? input.blob : [] }
53
53
  end
54
54
 
55
+ # Every byte, as one string, with a bank the REC hasn't touched at
56
+ # its power-on contents.
57
+ def contents
58
+ power_on = power_on_strings
59
+ Array.new(@banks.length) do |number|
60
+ bank = @banks[number]
61
+ bank.empty? ? power_on[(number >> 1) & 1] : bank.pack("C*")
62
+ end.join
63
+ end
64
+
65
+ # Takes every byte from a string as long as the RAM. A bank that
66
+ # holds its power-on contents stays untouched.
67
+ def restore(bytes)
68
+ power_on = power_on_strings
69
+ @banks.each_index do |number|
70
+ slice = bytes.byteslice(number * BANK_SIZE, BANK_SIZE).to_s
71
+ @banks[number] = slice == power_on[(number >> 1) & 1] ? [] : slice.bytes
72
+ end
73
+ end
74
+
55
75
  private
56
76
 
77
+ def power_on_strings
78
+ [0, 1].map do |half|
79
+ @power_on[half] = power_on_bank(half == 1) if @power_on[half].empty?
80
+ @power_on[half].pack("C*")
81
+ end
82
+ end
83
+
57
84
  # A bank takes its power-on contents the first time it is touched,
58
85
  # so an REU costs only the memory a program uses.
59
86
  def bank(number)
@@ -32,6 +32,33 @@ module Badline
32
32
  @ram.load_state(input)
33
33
  end
34
34
 
35
+ # The sixteen bytes the REC's registers read from $DF00, without
36
+ # what reading the status does, as VICE's REU1764 module holds them.
37
+ def register_file = Array.new(16) { |register| register_value(register) }
38
+
39
+ # Sets the registers from a register file, as writing them would but
40
+ # without starting a transfer: each address and the length set both
41
+ # the counter and the value it starts from. A command with the
42
+ # execute bit set and the $FF00 trigger on arms the REC.
43
+ def restore_registers(values)
44
+ @status = (values[STATUS] & (INTERRUPT_PENDING | END_OF_BLOCK | FAULT)) | @chips
45
+ @command = values[COMMAND]
46
+ @c64 = @c64_start = values[C64_LOW] | (values[C64_HIGH] << 8)
47
+ @expansion = @expansion_start = values[EXPANSION_LOW] | (values[EXPANSION_HIGH] << 8)
48
+ @bank = @bank_start = values[EXPANSION_BANK] & @bank_bits
49
+ @length = @length_start = values[LENGTH_LOW] | (values[LENGTH_HIGH] << 8)
50
+ @interrupt_mask = values[INTERRUPT_MASK] | 0x1f
51
+ @address_control = values[ADDRESS_CONTROL] | 0x3f
52
+ @armed = @command.anybits?(EXECUTE) && @command.nobits?(NO_FF00_TRIGGER)
53
+ @requested = @running = @ba_was_low = false
54
+ interrupt(@status.anybits?(INTERRUPT_PENDING))
55
+ end
56
+
57
+ # The RAM, every byte of it, as one string.
58
+ def ram_contents = @ram.contents
59
+
60
+ def restore_ram(bytes) = @ram.restore(bytes)
61
+
35
62
  private
36
63
 
37
64
  def load_registers(input)