badline 0.6.1 → 0.8.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +132 -247
  5. data/doc/library.md +62 -0
  6. data/doc/media.md +69 -0
  7. data/doc/sid-player.md +132 -0
  8. data/doc/snapshots.md +69 -0
  9. data/exe/badline-ruby +9 -1
  10. data/lib/badline/address_bus/extra_sids.rb +30 -0
  11. data/lib/badline/address_bus/sid_slots.rb +42 -0
  12. data/lib/badline/address_bus.rb +5 -0
  13. data/lib/badline/audio/aiff.rb +2 -2
  14. data/lib/badline/audio/bare_player.rb +23 -12
  15. data/lib/badline/audio/cli.rb +153 -58
  16. data/lib/badline/audio/jukebox.rb +121 -36
  17. data/lib/badline/audio/machine_player.rb +25 -10
  18. data/lib/badline/audio/pcm_writer.rb +10 -6
  19. data/lib/badline/audio/playback.rb +6 -5
  20. data/lib/badline/audio/queued_tune.rb +89 -0
  21. data/lib/badline/audio/renderer.rb +57 -10
  22. data/lib/badline/audio/stereo.rb +97 -0
  23. data/lib/badline/audio/terminal.rb +39 -6
  24. data/lib/badline/audio/wav.rb +1 -1
  25. data/lib/badline/audio.rb +2 -0
  26. data/lib/badline/cartridge/freezer.rb +2 -0
  27. data/lib/badline/cartridge.rb +3 -0
  28. data/lib/badline/frontend/app.rb +38 -35
  29. data/lib/badline/frontend/audio_sink.rb +6 -5
  30. data/lib/badline/frontend/boot.rb +9 -2
  31. data/lib/badline/frontend/buttons.rb +184 -0
  32. data/lib/badline/frontend/confirmation.rb +50 -0
  33. data/lib/badline/frontend/controls.rb +44 -32
  34. data/lib/badline/frontend/file_browser.rb +225 -0
  35. data/lib/badline/frontend/filter_view.rb +96 -0
  36. data/lib/badline/frontend/frame_report.rb +52 -0
  37. data/lib/badline/frontend/info_view.rb +120 -0
  38. data/lib/badline/frontend/menu_dialogs.rb +122 -0
  39. data/lib/badline/frontend/menu_media.rb +128 -0
  40. data/lib/badline/frontend/menu_pages.rb +198 -0
  41. data/lib/badline/frontend/painter.rb +185 -0
  42. data/lib/badline/frontend/pause_menu.rb +246 -0
  43. data/lib/badline/frontend/player_commands.rb +33 -0
  44. data/lib/badline/frontend/player_footer.rb +86 -0
  45. data/lib/badline/frontend/player_header.rb +60 -0
  46. data/lib/badline/frontend/player_screen.rb +124 -0
  47. data/lib/badline/frontend/player_state.rb +67 -0
  48. data/lib/badline/frontend/player_window.rb +228 -0
  49. data/lib/badline/frontend/scope.rb +42 -0
  50. data/lib/badline/frontend/sid_history.rb +175 -0
  51. data/lib/badline/frontend/sid_view.rb +140 -0
  52. data/lib/badline/frontend/snapshots.rb +1 -1
  53. data/lib/badline/frontend/sound.rb +0 -1
  54. data/lib/badline/frontend/timeline.rb +5 -1
  55. data/lib/badline/frontend/visualizer_view.rb +101 -0
  56. data/lib/badline/frontend.rb +21 -0
  57. data/lib/badline/media/disk_set.rb +108 -0
  58. data/lib/badline/media/queue.rb +158 -0
  59. data/lib/badline/media.rb +14 -8
  60. data/lib/badline/options/help.rb +34 -5
  61. data/lib/badline/options/option.rb +1 -1
  62. data/lib/badline/options/table.rb +5 -3
  63. data/lib/badline/options/validation.rb +54 -0
  64. data/lib/badline/options.rb +65 -46
  65. data/lib/badline/sdl.rb +16 -6
  66. data/lib/badline/sid/catch_up.rb +11 -1
  67. data/lib/badline/sid/filter.rb +9 -0
  68. data/lib/badline/sid/voice.rb +6 -0
  69. data/lib/badline/sid/waveform.rb +20 -5
  70. data/lib/badline/sid.rb +35 -3
  71. data/lib/badline/storage/hvsc.rb +41 -0
  72. data/lib/badline/storage/sid_file.rb +45 -25
  73. data/lib/badline/storage/song_lengths.rb +6 -29
  74. data/lib/badline/storage/stil/credit.rb +50 -0
  75. data/lib/badline/storage/stil.rb +126 -0
  76. data/lib/badline/storage.rb +8 -0
  77. data/lib/badline/version.rb +1 -1
  78. metadata +36 -1
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's detailed view of one SID: each voice's note,
6
+ # registers and envelope over a scope of its output, then the filter (FilterView) and the scope of
7
+ # the SID's own output. `sid` picks which of a tune's SIDs it shows, and
8
+ # a tune on more than one gets a row of buttons above the voices that
9
+ # pick it, which makes the view PICKER taller.
10
+ class SIDView
11
+ COLORS = Screen::COLORS
12
+ TEXT = COLORS[14]
13
+ BRIGHT = COLORS[1]
14
+ DIM = COLORS[11]
15
+ BOX = COLORS[0]
16
+ VOICE_COLORS = [COLORS[7], COLORS[13], COLORS[3]].freeze
17
+
18
+ NOTES = %w[C C# D D# E F F# G G# A A# B].freeze
19
+ WAVES = %w[TRI SAW PUL NOI].freeze
20
+ FLAGS = %w[GATE SYNC RING TEST].freeze
21
+ PHASES = { attack: "ATTACK", decay_sustain: "DECAY/SUSTAIN", release: "RELEASE" }.freeze
22
+
23
+ LEFT = 16
24
+ WAVE_LEFT = 432
25
+ WAVE_WIDTH = 192
26
+ VOICE_HEIGHT = 64
27
+ PANEL_HEIGHT = 72
28
+ HEIGHT = (3 * VOICE_HEIGHT) + PANEL_HEIGHT + 12
29
+ PICKER = 20
30
+
31
+ SID_NAMES = ["SID 1", "SID 2", "SID 3"].freeze
32
+ SIDS = %i[sid1 sid2 sid3].freeze
33
+
34
+ # The nearest note to `hertz` and how many cents off it it is.
35
+ def self.note(hertz)
36
+ return "---" if hertz < 16.0
37
+
38
+ midi = 69 + (12 * Math.log(hertz / 440.0) / Math.log(2))
39
+ key = midi.round
40
+ cents = ((midi - key) * 100).round
41
+ name = NOTES[key % 12] + ((key / 12) - 1).to_s
42
+ name.ljust(3) + format("%<sign>s%<cents>02d", sign: cents.negative? ? "-" : "+", cents: cents.abs)
43
+ end
44
+
45
+ # The view's height for a tune on `sids` SIDs.
46
+ def self.height(sids) = sids > 1 ? HEIGHT + PICKER : HEIGHT
47
+
48
+ def initialize(painter, buttons, top)
49
+ @painter = painter
50
+ @buttons = buttons
51
+ @top = top
52
+ @filter = FilterView.new(painter)
53
+ @voices = Array.new(2) do |picker|
54
+ Array.new(3) do |voice|
55
+ Scope.new(painter, [WAVE_LEFT, top + (picker * PICKER) + (voice * VOICE_HEIGHT), WAVE_WIDTH,
56
+ VOICE_HEIGHT - 12], span: 512, range: 9216, centred: true)
57
+ end
58
+ end
59
+ @outputs = Array.new(2) do |picker|
60
+ Scope.new(painter, [WAVE_LEFT, top + (picker * PICKER) + (3 * VOICE_HEIGHT) + 4, WAVE_WIDTH, PANEL_HEIGHT],
61
+ span: 1024, range: 65_536)
62
+ end
63
+ end
64
+
65
+ def draw(history, played, clock_hz:, model:, sid: 0)
66
+ return if history.empty?
67
+
68
+ picker = history.sid_count > 1 ? 1 : 0
69
+ draw_picker(history.sid_count, sid) if picker == 1
70
+ top = @top + (picker * PICKER)
71
+ frame = history.frame(played, sid)
72
+ 3.times { |voice| draw_voice(history, frame, voice, top + (voice * VOICE_HEIGHT), clock_hz) }
73
+ 3.times { |voice| @voices[picker][voice].draw(history, played, (sid * 4) + voice, VOICE_COLORS[voice]) }
74
+ @filter.draw(history, frame, top + (3 * VOICE_HEIGHT) + 4, model)
75
+ @outputs[picker].draw(history, played, history.output(sid), BRIGHT)
76
+ end
77
+
78
+ private
79
+
80
+ def draw_picker(sids, shown)
81
+ x = LEFT
82
+ sids.times do |sid|
83
+ x += @buttons.text(x, @top, SID_NAMES[sid], SIDS[sid], on: sid == shown) + 4
84
+ end
85
+ end
86
+
87
+ def draw_voice(history, frame, voice, top, clock_hz)
88
+ base = voice * 7
89
+ color = VOICE_COLORS[voice]
90
+ control = history.register(frame, base + 4)
91
+ hertz = history.hertz(frame, voice, clock_hz)
92
+ paint = @painter
93
+ paint.text(LEFT, top, "VOICE #{voice + 1}", color)
94
+ paint.text(LEFT, top + 12, SIDView.note(hertz), history.level(frame, voice).zero? ? DIM : color, scale: 2)
95
+ paint.text(LEFT + 112, top + 12, format("%<hertz>.1f HZ", hertz:), TEXT)
96
+ draw_waves(control, top, color)
97
+ draw_flags(history, frame, voice, control, top + 24)
98
+ draw_envelope(history, frame, voice, top + 36, color)
99
+ end
100
+
101
+ def draw_waves(control, top, color)
102
+ x = LEFT + 224
103
+ WAVES.each_with_index do |wave, bit|
104
+ x += @painter.text(x, top, wave, control[bit + 4] == 1 ? color : DIM) + 8
105
+ end
106
+ end
107
+
108
+ def draw_flags(history, frame, voice, control, top)
109
+ x = LEFT + 112
110
+ FLAGS.each_with_index do |flag, bit|
111
+ x += @painter.text(x, top, flag, control[bit] == 1 ? BRIGHT : DIM) + 8
112
+ end
113
+ filtered = history.register(frame, 0x17)[voice] == 1
114
+ @painter.text(x, top, "FILT", filtered ? BRIGHT : DIM)
115
+ end
116
+
117
+ def draw_envelope(history, frame, voice, top, color)
118
+ base = voice * 7
119
+ attack_decay = history.register(frame, base + 5)
120
+ sustain_release = history.register(frame, base + 6)
121
+ paint = @painter
122
+ paint.text(LEFT + 112, top, format("PW %<pw>5.1f%%", pw: pulse_width(history, frame, voice) * 100.0 / 4096),
123
+ TEXT)
124
+ rates = "A#{hex(attack_decay >> 4)} D#{hex(attack_decay & 0x0f)} " \
125
+ "S#{hex(sustain_release >> 4)} R#{hex(sustain_release & 0x0f)}"
126
+ paint.text(LEFT + 224, top, rates, TEXT)
127
+ paint.box(LEFT, top + 12, 128, 6, BOX)
128
+ paint.box(LEFT, top + 12, history.level(frame, voice) / 2, 6, color)
129
+ paint.box(LEFT + ((sustain_release >> 4) * 17 / 2), top + 10, 1, 10, BRIGHT)
130
+ paint.text(LEFT + 136, top + 12, PHASES.fetch(history.state(frame, voice), ""), DIM)
131
+ end
132
+
133
+ def hex(nibble) = nibble.to_s(16).upcase
134
+
135
+ def pulse_width(history, frame, voice)
136
+ history.register(frame, (voice * 7) + 2) | ((history.register(frame, (voice * 7) + 3) & 0x0f) << 8)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -10,7 +10,7 @@ module Badline
10
10
  KEYS = [Keys::F11, Keys::F12].freeze
11
11
 
12
12
  # The machine running, which F12 replaces with the restored one.
13
- attr_reader :computer
13
+ attr_accessor :computer
14
14
 
15
15
  def initialize(computer, options)
16
16
  @computer = computer
@@ -145,7 +145,6 @@ module Badline
145
145
 
146
146
  def underrun!
147
147
  @underruns += 1
148
- puts "Running below real time, so the sound will stutter." if @underruns == 1
149
148
  halt
150
149
  end
151
150
  end
@@ -15,7 +15,7 @@ module Badline
15
15
  unless options.screenshot.empty? || options.frames.zero?
16
16
  @events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
17
17
  end
18
- @read_only = options.read_only?
18
+ @read_only = !options.writable?
19
19
  @program = options.program
20
20
  @releases = []
21
21
  @failed = false
@@ -63,6 +63,10 @@ module Badline
63
63
  .map { |event| Timeline.path(event.argument, frame) }
64
64
  end
65
65
 
66
+ # Presses the freeze button after `frame`, and lets go of it as the
67
+ # events do.
68
+ def press_freeze(computer, frame) = freeze(computer, Options::Event.new(frame, "freeze", ""))
69
+
66
70
  # Whether the run ends at the frame: at a quit, or once an event failed.
67
71
  def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
68
72
 
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's main view: each voice's note over a scope of its own
6
+ # output, and the mix below them. A tune on more than one SID gets a
7
+ # row of voices for each, shrunk to fit and labelled with the SID's
8
+ # number, and the mix split into its left and right.
9
+ class VisualizerView
10
+ HEIGHT = 176
11
+ VOICE_WIDTH = 192
12
+ VOICE_GAP = 16
13
+ MIX_WIDTH = 608
14
+ ROW_GAP = 4
15
+
16
+ # The height of each voice's scope, by how many SIDs there are.
17
+ VOICE_SCOPES = [64, 40, 28].freeze
18
+
19
+ def initialize(painter, top)
20
+ @painter = painter
21
+ @top = top
22
+ @voices = Array.new(VOICE_SCOPES.size) { |index| voice_scopes(index + 1) }
23
+ @mix = Scope.new(painter, [SIDView::LEFT, mix_top(1), MIX_WIDTH, mix_height(1)],
24
+ span: 1024, range: 32_768, centred: true)
25
+ @sides = Array.new(VOICE_SCOPES.size - 1) { |index| side_scopes(index + 2) }
26
+ end
27
+
28
+ def draw(history, played, clock_hz:)
29
+ return if history.empty?
30
+
31
+ count = history.sid_count
32
+ count.times { |sid| draw_row(history, played, sid, clock_hz) }
33
+ draw_mix(history, played, count)
34
+ end
35
+
36
+ private
37
+
38
+ def left(voice) = SIDView::LEFT + (voice * (VOICE_WIDTH + VOICE_GAP))
39
+
40
+ # The height of a row's notes, in glyphs twice the size for one SID.
41
+ def note_height(count) = count == 1 ? 20 : 10
42
+
43
+ def row_height(count) = note_height(count) + VOICE_SCOPES[count - 1] + ROW_GAP
44
+
45
+ def row_top(count, sid) = @top + (sid * row_height(count))
46
+
47
+ def mix_top(count) = row_top(count, count) + 4
48
+
49
+ def mix_height(count) = @top + HEIGHT - 8 - mix_top(count)
50
+
51
+ def voice_scopes(count)
52
+ Array.new(count * 3) do |index|
53
+ Scope.new(@painter, [left(index % 3), row_top(count, index / 3) + note_height(count), VOICE_WIDTH,
54
+ VOICE_SCOPES[count - 1]], span: 512, range: 9216, centred: true)
55
+ end
56
+ end
57
+
58
+ def side_scopes(count)
59
+ width = (MIX_WIDTH - VOICE_GAP) / 2
60
+ Array.new(2) do |side|
61
+ Scope.new(@painter, [SIDView::LEFT + (side * (width + VOICE_GAP)), mix_top(count), width, mix_height(count)],
62
+ span: 1024, range: 32_768, centred: true)
63
+ end
64
+ end
65
+
66
+ def draw_row(history, played, sid, clock_hz)
67
+ count = history.sid_count
68
+ frame = history.frame(played, sid)
69
+ top = row_top(count, sid)
70
+ scopes = @voices[count - 1]
71
+ 3.times do |voice|
72
+ color = SIDView::VOICE_COLORS[voice]
73
+ note = SIDView.note(history.hertz(frame, voice, clock_hz))
74
+ silent = history.level(frame, voice).zero?
75
+ @painter.text(left(voice), top, note, silent ? SIDView::DIM : color, scale: count == 1 ? 2 : 1)
76
+ scopes[(sid * 3) + voice].draw(history, played, (sid * 4) + voice, color)
77
+ end
78
+ draw_label(top, sid) if count > 1
79
+ end
80
+
81
+ def draw_label(top, sid)
82
+ label = "SID #{sid + 1}"
83
+ @painter.text(SIDView::LEFT + MIX_WIDTH - Painter.width(label), top, label, SIDView::TEXT)
84
+ end
85
+
86
+ def draw_mix(history, played, count)
87
+ if count == 1
88
+ @mix.draw(history, played, SIDHistory::MIX, SIDView::BRIGHT)
89
+ return
90
+ end
91
+
92
+ sides = @sides[count - 2]
93
+ sides[0].draw(history, played, history.left, SIDView::BRIGHT)
94
+ sides[1].draw(history, played, history.right, SIDView::BRIGHT)
95
+ top = mix_top(count) + 2
96
+ @painter.text(SIDView::LEFT + 2, top, "L", SIDView::DIM)
97
+ @painter.text(SIDView::LEFT + MIX_WIDTH - 10, top, "R", SIDView::DIM)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -11,8 +11,29 @@ require "badline/frontend/screenshot"
11
11
  require "badline/frontend/timeline"
12
12
  require "badline/frontend/sound"
13
13
  require "badline/frontend/pacer"
14
+ require "badline/frontend/frame_report"
14
15
  require "badline/frontend/controls"
15
16
  require "badline/frontend/pad_port"
16
17
  require "badline/frontend/gamepads"
17
18
  require "badline/frontend/app"
18
19
  require "badline/frontend/audio_sink"
20
+ require "badline/frontend/painter"
21
+ require "badline/frontend/sid_history"
22
+ require "badline/frontend/sid_view"
23
+ require "badline/frontend/filter_view"
24
+ require "badline/frontend/scope"
25
+ require "badline/frontend/visualizer_view"
26
+ require "badline/frontend/buttons"
27
+ require "badline/frontend/player_state"
28
+ require "badline/frontend/player_header"
29
+ require "badline/frontend/player_footer"
30
+ require "badline/frontend/info_view"
31
+ require "badline/frontend/player_commands"
32
+ require "badline/frontend/player_screen"
33
+ require "badline/frontend/player_window"
34
+ require "badline/frontend/pause_menu"
35
+ require "badline/frontend/file_browser"
36
+ require "badline/frontend/confirmation"
37
+ require "badline/frontend/menu_dialogs"
38
+ require "badline/frontend/menu_media"
39
+ require "badline/frontend/menu_pages"
@@ -0,0 +1,108 @@
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
+ module DiskSet
12
+ KINDS = %w[disk disc side part].freeze
13
+ EXTENSIONS = %w[.d64 .d71 .d81 .g64].freeze
14
+
15
+ # The paths of the set's disks in order, or just `path` when it
16
+ # belongs to none.
17
+ def self.around(path)
18
+ extension = File.extname(path).downcase
19
+ return [path] unless EXTENSIONS.include?(extension)
20
+
21
+ marked = !parse(stem(path))[1].negative?
22
+ base = key(stem(path), marked)[0]
23
+ found = siblings(File.dirname(path), extension, base, marked)
24
+ return [path] if found.size < 2 || (!marked && found.none? { |pair| pair[0] == 1 })
25
+
26
+ found.sort_by { |pair| pair[0] }.map { |pair| File.join(File.dirname(path), pair[1]) }
27
+ end
28
+
29
+ # The name's words without their markers, joined, and the order the
30
+ # markers give it, -1 for none.
31
+ def self.parse(stem)
32
+ words = words(stem)
33
+ base = []
34
+ values = []
35
+ index = 0
36
+ index = take(words, index, base, values) while index < words.size
37
+ [base.join(" "), order(values)]
38
+ end
39
+
40
+ # The words before a trailing number or letter, joined, and its
41
+ # order, -1 for a name that doesn't end in one.
42
+ def self.trailing(stem)
43
+ words = words(stem)
44
+ last = words.last.to_s
45
+ return ["", -1] if words.size < 2 || !value?(last) || last.length > 2
46
+
47
+ [words[0, words.size - 1].join(" "), value(last)]
48
+ end
49
+
50
+ def self.words(stem) = stem.downcase.split(/[^a-z0-9]+/).reject(&:empty?)
51
+
52
+ def self.stem(path) = File.basename(path, File.extname(path))
53
+
54
+ def self.key(stem, marked) = marked ? parse(stem) : trailing(stem)
55
+
56
+ # The order and name of each image in the folder in the set.
57
+ def self.siblings(directory, extension, base, marked)
58
+ found = []
59
+ children(directory).each do |name|
60
+ next unless File.extname(name).downcase == extension
61
+
62
+ name_key = key(stem(name), marked)
63
+ found << [name_key[1], name] if name_key[0] == base && !name_key[1].negative?
64
+ end
65
+ found
66
+ end
67
+
68
+ # Adds the word at `index` to the base, or its marker's value to the
69
+ # values, and returns the index of the next word.
70
+ def self.take(words, index, base, values)
71
+ word = words[index]
72
+ following = words[index + 1].to_s
73
+ if KINDS.include?(word) && value?(following)
74
+ values << value(following)
75
+ index += 2
76
+ index += 2 if words[index] == "of" && words[index + 1].to_s.match?(/\A\d+\z/)
77
+ index
78
+ elsif marker?(word)
79
+ values << value(word.sub(/\A(disk|disc|side|part|d|s)/, ""))
80
+ index + 1
81
+ else
82
+ base << word
83
+ index + 1
84
+ end
85
+ end
86
+
87
+ def self.marker?(word) = word.match?(/\A(disk|disc|side|part)(\d+|[a-z])\z/) || word.match?(/\A[ds]\d+\z/)
88
+
89
+ def self.value?(word) = word.match?(/\A(\d+|[a-z])\z/)
90
+
91
+ # A number counts as itself and a letter by its place in the
92
+ # alphabet, so side A is 1.
93
+ def self.value(word) = word.match?(/\A\d+\z/) ? word.to_i : word.ord - 96
94
+
95
+ def self.order(values)
96
+ return -1 if values.empty?
97
+
98
+ values.reduce(0) { |order, value| (order * 1000) + value }
99
+ end
100
+
101
+ def self.children(directory)
102
+ Dir.children(directory)
103
+ rescue SystemCallError
104
+ []
105
+ end
106
+ end
107
+ end
108
+ end
@@ -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,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/media/true_drive"
4
+ require "badline/media/queue"
5
+ require "badline/media/disk_set"
4
6
 
5
7
  module Badline
6
8
  module Media
@@ -25,7 +27,7 @@ module Badline
25
27
  # A .g64 plugs in a true drive as device 8 (TrueDrive), and with one
26
28
  # there, a .d64 goes into it instead of the KERNAL traps too. The
27
29
  # autostart then loads through it.
28
- def attach(computer, path, autostart: true, song: nil, **options)
30
+ def attach(computer, path, autostart: true, subtune: nil, **options)
29
31
  if TrueDrive.takes?(computer, path)
30
32
  attach_true_drive(computer, path, options.fetch(:disk, {}), autostart:)
31
33
  elsif File.directory?(path)
@@ -34,7 +36,7 @@ module Badline
34
36
  elsif File.extname(path).downcase == ".crt"
35
37
  attach_cartridge(computer, path, options.fetch(:cartridge, {}))
36
38
  elsif File.extname(path).downcase == ".sid"
37
- attach_sid(computer, path, autostart:, song:)
39
+ attach_sid(computer, path, autostart:, subtune:)
38
40
  elsif File.extname(path).downcase == ".tap"
39
41
  attach_tape(computer, path, autostart:)
40
42
  elsif MOUNT_TYPES.key?(File.extname(path).downcase)
@@ -91,18 +93,22 @@ module Badline
91
93
  "Attached cartridge #{path}"
92
94
  end
93
95
 
94
- def attach_sid(computer, path, autostart:, song:)
96
+ def attach_sid(computer, path, autostart:, subtune:)
95
97
  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:) }
98
+ if tune.sids > 1
99
+ raise Storage::SIDFile::FormatError, "Written for #{tune.sids} SIDs, which only the SID player plays"
100
+ end
101
+
102
+ subtune = (subtune || tune.start_subtune).clamp(1, tune.subtunes)
103
+ computer.on_init { start_tune(computer, tune, autostart:, subtune:) }
98
104
  title = tune.name.empty? ? path : tune.name
99
- title += " (song #{song})" if tune.songs > 1
105
+ title += " (subtune #{subtune})" if tune.subtunes > 1
100
106
  autostart ? "Playing #{title}" : "Loaded #{title}"
101
107
  end
102
108
 
103
- def start_tune(computer, tune, autostart:, song:)
109
+ def start_tune(computer, tune, autostart:, subtune:)
104
110
  computer.ram.write(tune.load_address, tune.data)
105
- tune.boot_memory(song:).each { |address, bytes| computer.ram.write(address, bytes) }
111
+ tune.boot_memory(subtune:).each { |address, bytes| computer.ram.write(address, bytes) }
106
112
  computer.type_text(tune.boot_command) if autostart
107
113
  end
108
114