badline 0.6.0 → 0.7.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +135 -42
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +28 -24
  7. data/lib/badline/address_bus/extra_sids.rb +30 -0
  8. data/lib/badline/address_bus/sid_slots.rb +42 -0
  9. data/lib/badline/address_bus.rb +15 -1
  10. data/lib/badline/audio/aiff.rb +2 -2
  11. data/lib/badline/audio/bare_player.rb +23 -12
  12. data/lib/badline/audio/cli.rb +167 -59
  13. data/lib/badline/audio/console.rb +2 -1
  14. data/lib/badline/audio/jukebox.rb +121 -36
  15. data/lib/badline/audio/machine_player.rb +25 -10
  16. data/lib/badline/audio/pcm_writer.rb +10 -6
  17. data/lib/badline/audio/playback.rb +6 -5
  18. data/lib/badline/audio/queued_tune.rb +89 -0
  19. data/lib/badline/audio/renderer.rb +57 -10
  20. data/lib/badline/audio/stereo.rb +97 -0
  21. data/lib/badline/audio/terminal.rb +39 -6
  22. data/lib/badline/audio/wav.rb +1 -1
  23. data/lib/badline/audio.rb +2 -3
  24. data/lib/badline/computer.rb +6 -3
  25. data/lib/badline/cpu/boundary.rb +54 -0
  26. data/lib/badline/cpu/core.rb +165 -0
  27. data/lib/badline/cpu.rb +3 -214
  28. data/lib/badline/drive1541/cpu.rb +29 -0
  29. data/lib/badline/drive1541.rb +1 -0
  30. data/lib/badline/ffi.rb +157 -0
  31. data/lib/badline/frontend/app.rb +225 -0
  32. data/lib/badline/frontend/audio_sink.rb +73 -0
  33. data/lib/badline/frontend/boot.rb +52 -0
  34. data/lib/badline/frontend/buttons.rb +90 -0
  35. data/lib/badline/frontend/controls.rb +163 -0
  36. data/lib/badline/frontend/drive_led.rb +51 -0
  37. data/lib/badline/frontend/filter_view.rb +96 -0
  38. data/lib/badline/frontend/frame_rate.rb +83 -0
  39. data/lib/badline/frontend/gamepads.rb +76 -0
  40. data/lib/badline/frontend/info_view.rb +120 -0
  41. data/lib/badline/frontend/keys.rb +48 -0
  42. data/lib/badline/frontend/pacer.rb +100 -0
  43. data/lib/badline/frontend/pad_port.rb +61 -0
  44. data/lib/badline/frontend/painter.rb +175 -0
  45. data/lib/badline/frontend/player_commands.rb +33 -0
  46. data/lib/badline/frontend/player_footer.rb +86 -0
  47. data/lib/badline/frontend/player_header.rb +60 -0
  48. data/lib/badline/frontend/player_screen.rb +124 -0
  49. data/lib/badline/frontend/player_state.rb +67 -0
  50. data/lib/badline/frontend/player_window.rb +228 -0
  51. data/lib/badline/frontend/scope.rb +42 -0
  52. data/lib/badline/frontend/screen.rb +65 -0
  53. data/lib/badline/frontend/screenshot.rb +22 -0
  54. data/lib/badline/frontend/sid_history.rb +175 -0
  55. data/lib/badline/frontend/sid_view.rb +140 -0
  56. data/lib/badline/frontend/snapshots.rb +60 -0
  57. data/lib/badline/frontend/sound.rb +153 -0
  58. data/lib/badline/frontend/timeline.rb +171 -0
  59. data/lib/badline/frontend/visualizer_view.rb +101 -0
  60. data/lib/badline/frontend.rb +32 -0
  61. data/lib/badline/media/queue.rb +158 -0
  62. data/lib/badline/media.rb +13 -8
  63. data/lib/badline/options/event.rb +94 -0
  64. data/lib/badline/options/help.rb +107 -0
  65. data/lib/badline/options/option.rb +29 -0
  66. data/lib/badline/options/table.rb +64 -0
  67. data/lib/badline/options/validation.rb +54 -0
  68. data/lib/badline/options.rb +204 -149
  69. data/lib/badline/reu/dma/saved_state.rb +50 -0
  70. data/lib/badline/reu/dma.rb +56 -47
  71. data/lib/badline/reu/saved_state.rb +2 -1
  72. data/lib/badline/reu.rb +19 -10
  73. data/lib/badline/sdl.rb +133 -51
  74. data/lib/badline/sid/catch_up.rb +11 -1
  75. data/lib/badline/sid/filter.rb +9 -0
  76. data/lib/badline/sid/voice.rb +6 -0
  77. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  78. data/lib/badline/sid/waveform.rb +22 -8
  79. data/lib/badline/sid.rb +35 -3
  80. data/lib/badline/snapshot/state.rb +1 -1
  81. data/lib/badline/storage/hvsc.rb +41 -0
  82. data/lib/badline/storage/sid_file.rb +45 -25
  83. data/lib/badline/storage/song_lengths.rb +6 -29
  84. data/lib/badline/storage/stil/credit.rb +50 -0
  85. data/lib/badline/storage/stil.rb +126 -0
  86. data/lib/badline/storage.rb +8 -0
  87. data/lib/badline/version.rb +1 -1
  88. data/lib/badline/vic/bank.rb +21 -0
  89. data/lib/badline/vic.rb +15 -6
  90. metadata +49 -19
  91. data/.claude/hooks/session-start.sh +0 -57
  92. data/.claude/settings.json +0 -14
  93. data/lib/badline/audio/sdl_sink.rb +0 -70
  94. data/lib/badline/audio/stream.rb +0 -123
  95. data/lib/badline/gui/application.rb +0 -243
  96. data/lib/badline/gui/drive_led_pane.rb +0 -29
  97. data/lib/badline/gui/gamepads.rb +0 -104
  98. data/lib/badline/gui/joy_map.rb +0 -27
  99. data/lib/badline/gui/key_map.rb +0 -39
  100. data/lib/badline/gui/palette.rb +0 -35
  101. data/lib/badline/gui/pane.rb +0 -36
  102. data/lib/badline/gui/screen_pane.rb +0 -46
  103. data/lib/badline/gui/snapshots.rb +0 -67
  104. data/lib/badline/gui/window.rb +0 -61
  105. data/lib/badline/gui.rb +0 -14
  106. data/lib/badline/sdl/events.rb +0 -49
  107. data/lib/badline/sdl/functions.rb +0 -74
  108. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # An ordered list of media files to go through one at a time, such as
6
+ # the tunes of a player. Each entry is a file and the part of it to
7
+ # start on, counting from 1, out of the parts the file holds: a .sid
8
+ # tune's subtunes, say, or 1 of 1 for media with no parts.
9
+ #
10
+ # #next_entry and #previous_entry step through the entries, and
11
+ # #next_part and #previous_part through the current entry's parts,
12
+ # stopping at its ends. Each returns the entry it moved to, or nil
13
+ # where it stays put. With #loop? the entry steps wrap around at
14
+ # either end, and with #shuffle? they follow a shuffled order that
15
+ # starts from the current entry. #all_parts? decides where #advance
16
+ # goes once a part has played out: on through the entry's parts, or
17
+ # to the next entry. An entry stepped to starts on its first part
18
+ # with #all_parts?, otherwise on its own.
19
+ #
20
+ # `shuffle`, if given, returns the entry indices it's given in a new
21
+ # order in place of Array#shuffle. `build`, if given, turns the paths
22
+ # #add is given into entries.
23
+ class Queue
24
+ # A file and the part of it to start on.
25
+ class Entry
26
+ attr_reader :path, :part, :parts
27
+
28
+ def initialize(path, part: 1, parts: 1)
29
+ @path = path
30
+ @part = part
31
+ @parts = parts
32
+ end
33
+ end
34
+
35
+ attr_reader :part
36
+
37
+ # The files `paths` name, in order: a file as given, and a directory
38
+ # as every file below it with the extension, in path order.
39
+ def self.files(paths, extension)
40
+ paths.flat_map { |path| File.directory?(path) ? below(path, extension).sort : [path] }
41
+ end
42
+
43
+ def self.below(directory, extension)
44
+ paths = []
45
+ Dir.children(directory).each do |name|
46
+ path = File.join(directory, name)
47
+ if File.directory?(path)
48
+ paths.concat(below(path, extension))
49
+ elsif File.extname(name).casecmp?(extension)
50
+ paths << path
51
+ end
52
+ end
53
+ paths
54
+ end
55
+ private_class_method :below
56
+
57
+ def initialize(entries, all_parts: false, shuffle: nil, build: nil)
58
+ @entries = entries
59
+ @shuffler = shuffle
60
+ @builder = build
61
+ @order = (0...entries.size).to_a
62
+ @position = 0
63
+ @part = entries.empty? ? 0 : entries.first.part
64
+ @all_parts = all_parts
65
+ @shuffle = false
66
+ @loop = false
67
+ end
68
+
69
+ def size = @entries.size
70
+
71
+ def empty? = @entries.empty?
72
+
73
+ # Where the current entry stands in the order played, from 1.
74
+ def position = empty? ? 0 : @position + 1
75
+
76
+ def entry = empty? ? nil : @entries[@order[@position]]
77
+
78
+ def all_parts? = @all_parts
79
+
80
+ def shuffle? = @shuffle
81
+
82
+ def loop? = @loop
83
+
84
+ # Puts the entries `build` makes of the paths at the end of the order,
85
+ # shuffled or not. An empty queue starts on the first of them.
86
+ def add(paths)
87
+ return if @builder.nil?
88
+
89
+ started = empty?
90
+ first = @entries.size
91
+ @entries.concat(@builder.call(paths))
92
+ @order.concat((first...@entries.size).to_a)
93
+ @part = entry.part if started && !empty?
94
+ end
95
+
96
+ def toggle_all_parts = @all_parts = !@all_parts
97
+
98
+ def toggle_loop = @loop = !@loop
99
+
100
+ def toggle_shuffle
101
+ return @shuffle = !@shuffle if empty?
102
+
103
+ current = @order[@position]
104
+ @shuffle = !@shuffle
105
+ @order = (0...size).to_a
106
+ @order = [current] + shuffled(@order - [current]) if @shuffle
107
+ @position = @order.index(current)
108
+ @shuffle
109
+ end
110
+
111
+ def next_entry
112
+ return if empty?
113
+
114
+ move(@position + 1)
115
+ end
116
+
117
+ def previous_entry
118
+ return if empty?
119
+
120
+ move(@position - 1)
121
+ end
122
+
123
+ def next_part
124
+ return if empty? || @part >= entry.parts
125
+
126
+ @part += 1
127
+ entry
128
+ end
129
+
130
+ def previous_part
131
+ return if empty? || @part <= 1
132
+
133
+ @part -= 1
134
+ entry
135
+ end
136
+
137
+ # Where to go once the current part has played to its end: the
138
+ # entry's next part with #all_parts?, otherwise the next entry.
139
+ def advance
140
+ return next_part if @all_parts && !empty? && @part < entry.parts
141
+
142
+ next_entry
143
+ end
144
+
145
+ private
146
+
147
+ def move(position)
148
+ return unless position.between?(0, size - 1) || @loop
149
+
150
+ @position = position % size
151
+ @part = @all_parts ? 1 : entry.part
152
+ entry
153
+ end
154
+
155
+ def shuffled(indices) = @shuffler ? @shuffler.call(indices) : indices.shuffle
156
+ end
157
+ end
158
+ end
data/lib/badline/media.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/media/true_drive"
4
+ require "badline/media/queue"
4
5
 
5
6
  module Badline
6
7
  module Media
@@ -25,7 +26,7 @@ module Badline
25
26
  # A .g64 plugs in a true drive as device 8 (TrueDrive), and with one
26
27
  # there, a .d64 goes into it instead of the KERNAL traps too. The
27
28
  # autostart then loads through it.
28
- def attach(computer, path, autostart: true, song: nil, **options)
29
+ def attach(computer, path, autostart: true, subtune: nil, **options)
29
30
  if TrueDrive.takes?(computer, path)
30
31
  attach_true_drive(computer, path, options.fetch(:disk, {}), autostart:)
31
32
  elsif File.directory?(path)
@@ -34,7 +35,7 @@ module Badline
34
35
  elsif File.extname(path).downcase == ".crt"
35
36
  attach_cartridge(computer, path, options.fetch(:cartridge, {}))
36
37
  elsif File.extname(path).downcase == ".sid"
37
- attach_sid(computer, path, autostart:, song:)
38
+ attach_sid(computer, path, autostart:, subtune:)
38
39
  elsif File.extname(path).downcase == ".tap"
39
40
  attach_tape(computer, path, autostart:)
40
41
  elsif MOUNT_TYPES.key?(File.extname(path).downcase)
@@ -91,18 +92,22 @@ module Badline
91
92
  "Attached cartridge #{path}"
92
93
  end
93
94
 
94
- def attach_sid(computer, path, autostart:, song:)
95
+ def attach_sid(computer, path, autostart:, subtune:)
95
96
  tune = Storage::SIDFile.new(path)
96
- song = (song || tune.start_song).clamp(1, tune.songs)
97
- computer.on_init { start_tune(computer, tune, autostart:, song:) }
97
+ if tune.sids > 1
98
+ raise Storage::SIDFile::FormatError, "Written for #{tune.sids} SIDs, which only the SID player plays"
99
+ end
100
+
101
+ subtune = (subtune || tune.start_subtune).clamp(1, tune.subtunes)
102
+ computer.on_init { start_tune(computer, tune, autostart:, subtune:) }
98
103
  title = tune.name.empty? ? path : tune.name
99
- title += " (song #{song})" if tune.songs > 1
104
+ title += " (subtune #{subtune})" if tune.subtunes > 1
100
105
  autostart ? "Playing #{title}" : "Loaded #{title}"
101
106
  end
102
107
 
103
- def start_tune(computer, tune, autostart:, song:)
108
+ def start_tune(computer, tune, autostart:, subtune:)
104
109
  computer.ram.write(tune.load_address, tune.data)
105
- tune.boot_memory(song:).each { |address, bytes| computer.ram.write(address, bytes) }
110
+ tune.boot_memory(subtune:).each { |address, bytes| computer.ram.write(address, bytes) }
106
111
  computer.type_text(tune.boot_command) if autostart
107
112
  end
108
113
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # One event of --at or --script: what to do, with its argument, once
6
+ # `frame` frames have run. An event given at several frames becomes
7
+ # one Event per frame.
8
+ class Event
9
+ # The actions, and whether each takes an argument.
10
+ ACTIONS = {
11
+ "key" => true, "type" => true, "insert" => true, "eject" => true, "screenshot" => true,
12
+ "reset" => false, "freeze" => false, "quit" => false
13
+ }.freeze
14
+
15
+ # The frames a key or the freeze button is held for.
16
+ HOLD = 5
17
+
18
+ EJECTABLE = %w[disk tape cartridge].freeze
19
+
20
+ # What insert takes: disk images, tapes and cartridges, or a
21
+ # directory to mount as device 8.
22
+ INSERTABLE = %w[.d64 .d71 .d81 .g64 .t64 .tap .crt].freeze
23
+
24
+ attr_reader :frame, :action, :argument
25
+
26
+ def initialize(frame, action, argument)
27
+ @frame = frame
28
+ @action = action
29
+ @argument = argument
30
+ end
31
+
32
+ # The events of --at's value, or of --script's file.
33
+ def self.given(flag, value)
34
+ return script(value) if flag == "--script"
35
+
36
+ events = parse(value)
37
+ raise Error, "invalid argument: #{flag} #{value}" if events.empty?
38
+
39
+ events
40
+ end
41
+
42
+ # The events of a --script file, one a line in --at's form. Blank
43
+ # lines and lines starting with # are skipped.
44
+ def self.script(path)
45
+ raise Error, "no such file: #{path}" unless File.file?(path)
46
+
47
+ events = []
48
+ File.foreach(path) do |line|
49
+ text = line.strip
50
+ events.concat(given("--script #{path}:", text)) unless text.empty? || text.start_with?("#")
51
+ end
52
+ events
53
+ end
54
+
55
+ # The events of `FRAME[,FRAME...]:ACTION[=ARGUMENT]`, or an empty list
56
+ # when the text isn't one.
57
+ def self.parse(text)
58
+ colon = text.index(":")
59
+ numbers = colon.nil? ? [] : frames(text[0, colon])
60
+ return [] if numbers.empty?
61
+
62
+ event = text[colon + 1, text.size - colon - 1]
63
+ equals = event.index("=")
64
+ action = equals.nil? ? event : event[0, equals]
65
+ argument = equals.nil? ? "" : event[equals + 1, event.size - equals - 1]
66
+ return [] unless valid?(action, argument)
67
+
68
+ numbers.map { |frame| new(frame, action, argument) }
69
+ end
70
+
71
+ # The frame numbers of `FRAME[,FRAME...]`, or none unless each is a
72
+ # positive number.
73
+ def self.frames(text)
74
+ numbers = text.split(",")
75
+ return [] unless !numbers.empty? && numbers.all? { |number| number.match?(/\A\d+\z/) && number.to_i.positive? }
76
+
77
+ numbers.map(&:to_i)
78
+ end
79
+
80
+ def self.valid?(action, argument)
81
+ return false unless ACTIONS.key?(action)
82
+ return argument.empty? unless ACTIONS[action]
83
+ return EJECTABLE.include?(argument) if action == "eject"
84
+ return insertable?(argument) if action == "insert"
85
+
86
+ !argument.empty?
87
+ end
88
+
89
+ def self.insertable?(path)
90
+ File.directory?(path) || (File.exist?(path) && INSERTABLE.include?(File.extname(path).downcase))
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # How fast the headless player runs, which the builds' help tells apart.
6
+ NATIVE_SPEED = <<~TEXT.chomp
7
+ Without the window, PSID tunes run on a bare CPU and SID, and RSID
8
+ tunes boot the whole machine. --filter-chunk 1 runs the filter
9
+ cycle by cycle, which is exact but slower.
10
+ TEXT
11
+
12
+ RUBY_SPEED = <<~TEXT.chomp
13
+ Without the window, PSID tunes run on a bare CPU and SID, faster than
14
+ real time. RSID tunes boot the whole machine and run at about half
15
+ real time, so they stutter when played. --filter-chunk 1 runs the
16
+ filter cycle by cycle, which is exact but takes about twice as long.
17
+ TEXT
18
+
19
+ # What --help prints: the usage, then the options this build takes,
20
+ # section by section, laid out as OptionParser did.
21
+ def help
22
+ lines = [@sid_command ? sid_banner : banner]
23
+ SECTIONS.each do |section|
24
+ options = TABLE.select { |option| option.section == section && takes?(option) }
25
+ next if options.empty?
26
+
27
+ lines << "" if lines.size > 1
28
+ lines << "#{section}:"
29
+ options.each { |option| lines << " #{option.usage.ljust(32)} #{describe(option)}" }
30
+ end
31
+ "#{lines.join("\n")}\n"
32
+ end
33
+
34
+ private
35
+
36
+ def describe(option) = @native && option.name == "--sound" ? "#{option.text} (default)" : option.text
37
+
38
+ def banner
39
+ <<~BANNER
40
+ Usage: #{@program} [options] [media]
41
+ #{@program} --headless [options] tune.sid
42
+ #{@program} [options] tune.sid --audio-out FILE
43
+ #{@program} sid [options] FILE|DIR...
44
+
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.
54
+
55
+ --headless plays a .sid tune on the host's audio device without the
56
+ window, and --audio-out renders it to 16-bit PCM instead. The
57
+ container follows the file's extension, .wav or .aiff. Played on a
58
+ terminal, → and ← step between the tune's subtunes, n and p between
59
+ tunes, , and . seek, space pauses and q quits. s shuffles the tunes,
60
+ l loops them, and a, or --all-subtunes, plays on through each tune's
61
+ subtunes instead of just the one. `#{@program} sid` plays many tunes, and whole
62
+ directories of them, the same way.
63
+
64
+ A .sid file carries no length of its own. Without --seconds the tune is
65
+ looked up by MD5 in HVSC's Songlengths.md5, taken from --songlengths, from
66
+ the DOCUMENTS directory of an HVSC collection above the tune, or from
67
+ $HVSC_BASE. Failing all of those it runs for #{FALLBACK_SECONDS.to_i} seconds, or
68
+ until it has been silent for #{SILENCE_SECONDS.to_i}.
69
+
70
+ #{@native ? NATIVE_SPEED : RUBY_SPEED}
71
+
72
+ --at and --script run events once a number of frames has run, as
73
+ in --at 3500:key=space or --at 250,500:screenshot=shot%05d.bmp.
74
+ key=NAME presses a C64 key (space, return, a, f1, run_stop, restore)
75
+ or a joystick's direction or fire (joy1-up, joy2-fire) for #{Event::HOLD}
76
+ 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
+ BANNER
82
+ end
83
+
84
+ def sid_banner
85
+ <<~BANNER
86
+ Usage: #{@program} sid [options] FILE|DIR...
87
+
88
+ Plays .sid tunes in the SID player's window, or in the terminal with
89
+ --headless, one after another in the order given. A directory adds
90
+ every .sid tune below it, in path order. → and ← step between a
91
+ tune's subtunes, n and p between tunes, , and . seek 10 seconds back
92
+ and forward, space pauses and q quits. s shuffles the tunes, l loops
93
+ them, and a, or --all-subtunes, plays on through each tune's subtunes
94
+ instead of just the one. The window has buttons for all of these,
95
+ and for switching the SID between the tune's own, the 6581 and the
96
+ 8580. --subtune picks the first tune's subtune, and each tune after
97
+ it starts on its own. Without tunes, the window opens with an empty
98
+ queue, and .sid files or folders dropped on the window join it.
99
+
100
+ Each tune gets the SID its header names unless --sid says otherwise,
101
+ and its length from HVSC's Songlengths.md5 as #{@program} --help
102
+ describes. A tune written for 2 or 3 SIDs plays on the one badline
103
+ emulates, with a notice.
104
+ BANNER
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # One option of the table: its flags and value as the help shows them
6
+ # (`-s, --subtune N`), what it does, the help section it's listed in, the
7
+ # mode it needs (:window, :headless or :either), and the build that
8
+ # takes it (:native, :ruby or :both).
9
+ class Option
10
+ attr_reader :usage, :name, :short, :value, :text, :section, :needs, :build
11
+
12
+ def initialize(usage, text, section:, needs: :either, build: :both)
13
+ @usage = usage.start_with?("--") ? " #{usage}" : usage
14
+ words = usage.split
15
+ @short = words.size > 1 && words[1].start_with?("--") ? words[0].delete_suffix(",") : ""
16
+ @name = @short.empty? ? words[0] : words[1]
17
+ @value = words.last.start_with?("-") ? "" : words.last
18
+ @text = text
19
+ @section = section
20
+ @needs = needs
21
+ @build = build
22
+ end
23
+
24
+ def valued? = !@value.empty?
25
+
26
+ def named?(flag) = flag == @name || (!@short.empty? && flag == @short)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ DEFAULT_RATE = 44_100
6
+
7
+ SECTIONS = ["Options", "Window options", "Options without the window", "Testing options"].freeze
8
+
9
+ # Every option of both builds, in the order the help lists them.
10
+ TABLE = [
11
+ Option.new("-s, --subtune N", "Subtune of a .sid, from 1 (default: the tune's own)", section: "Options"),
12
+ Option.new("--sid MODEL", "SID to fit: auto, 6581 or 8580 (default: auto, a .sid tune's own, else 6581)",
13
+ section: "Options"),
14
+ Option.new("--disable-jit", "Run without enabling YJIT", section: "Options", build: :ruby),
15
+ Option.new("-h, --help", "Show this help", section: "Options"),
16
+ Option.new("--version", "Show the version and what built it", section: "Options", build: :native),
17
+ Option.new("--no-autostart", "Boot to READY. instead of running the program",
18
+ section: "Window options", needs: :window),
19
+ Option.new("--read-only", "Mount a disk image write-protected, leaving its file unchanged",
20
+ section: "Window options", needs: :window),
21
+ Option.new("--sound", "Play the SID through the host's audio device (F10 mutes)",
22
+ section: "Window options", needs: :window),
23
+ Option.new("--no-sound", "Don't play the SID", section: "Window options", needs: :window, build: :native),
24
+ Option.new("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps",
25
+ section: "Window options", needs: :window),
26
+ Option.new("--reu SIZE", "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384",
27
+ section: "Window options", needs: :window),
28
+ Option.new("--ntsc", "Run an NTSC C64, with the 6567R8 VIC-II, instead of a PAL one",
29
+ section: "Window options", needs: :window),
30
+ Option.new("--no-vsync", "Pace frames by the timer or the sound instead of the display",
31
+ section: "Window options", needs: :window),
32
+ Option.new("--verbose", "Print the display, sound and gamepad setup and the frame timing",
33
+ section: "Window options", needs: :window),
34
+ Option.new("--headless", "Play a .sid tune in the terminal instead of the window",
35
+ section: "Options without the window"),
36
+ Option.new("--audio-out FILE", "Render a .sid tune to a .wav or .aiff file",
37
+ section: "Options without the window"),
38
+ Option.new("--seconds N", "Length to play (default: the tune's own)",
39
+ section: "Options without the window", needs: :headless),
40
+ Option.new("--songlengths PATH", "HVSC Songlengths.md5 to take the length from",
41
+ section: "Options without the window", needs: :headless),
42
+ Option.new("--rate HZ", "Sample rate (default: #{DEFAULT_RATE}, or the audio device's own)",
43
+ section: "Options without the window", needs: :headless),
44
+ Option.new("--filter-chunk N", "Filter step in cycles, 1 is exact (default: 4)",
45
+ section: "Options without the window", needs: :headless),
46
+ Option.new("--quiet", "Don't report progress", section: "Options without the window", needs: :headless),
47
+ Option.new("--all-subtunes", "Play on through each tune's subtunes, not just the one",
48
+ section: "Options without the window", needs: :headless),
49
+ Option.new("--no-tui", "Play without the interactive display, even on a terminal",
50
+ section: "Options without the window", needs: :headless),
51
+ Option.new("--frames N", "Quit after N frames", section: "Testing options", needs: :window),
52
+ Option.new("--unpaced", "Run as fast as it can, without vsync or pacing",
53
+ section: "Testing options", needs: :window),
54
+ Option.new("--screenshot FILE", "Save the last frame as a .bmp",
55
+ section: "Testing options", needs: :window),
56
+ Option.new("--save-snapshot FILE", "Save the machine as a .vsf snapshot after the last frame",
57
+ section: "Testing options", needs: :window),
58
+ Option.new("--at FRAME:EVENT", "Run EVENT once FRAME frames have run (see above)",
59
+ section: "Testing options", needs: :window),
60
+ Option.new("--script FILE", "Run the events in FILE, one FRAME:EVENT a line",
61
+ section: "Testing options", needs: :window)
62
+ ].freeze
63
+ end
64
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # The checks Options#parse makes once every argument has been read:
6
+ # the options fit the mode, the numbers are positive and the files
7
+ # exist.
8
+ module Validation
9
+ private
10
+
11
+ def validate
12
+ validate_mode
13
+ validate_numbers
14
+ validate_paths
15
+ end
16
+
17
+ def validate_mode
18
+ if headless?
19
+ raise Error, "#{@window_only.first} needs the window" unless @window_only.empty?
20
+ raise Error, "no tune given" if tune_paths.empty? && !player_window?
21
+
22
+ tune_paths.each { |path| validate_tune(path) }
23
+ elsif !@headless_only.empty?
24
+ raise Error, "#{@headless_only.first} needs --headless or --audio-out"
25
+ end
26
+ end
27
+
28
+ # `sid` takes directories as well as tunes.
29
+ def validate_tune(path)
30
+ return if !File.exist?(path) || File.extname(path).casecmp?(".sid")
31
+ return if @sid_command && File.directory?(path)
32
+
33
+ raise Error, "not a .sid tune: #{path}"
34
+ end
35
+
36
+ def validate_numbers
37
+ positive("--subtune", @subtune)
38
+ positive("--seconds", @seconds)
39
+ positive("--rate", @rate)
40
+ positive("--filter-chunk", @filter_chunk)
41
+ end
42
+
43
+ def positive(name, value)
44
+ raise Error, "invalid argument: #{name} #{value}" if !value.nil? && value <= 0
45
+ end
46
+
47
+ def validate_paths
48
+ missing = tune_paths.find { |path| !File.exist?(path) }
49
+ raise Error, "no such file or directory: #{missing}" unless missing.nil?
50
+ raise Error, "no such file or directory: #{@songlengths}" unless @songlengths.nil? || File.exist?(@songlengths)
51
+ end
52
+ end
53
+ end
54
+ end