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,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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The window's snapshots: F11 saves the machine to a new
6
+ # badline-<date>-<time>.vsf in the working directory, and F12 goes back
7
+ # to the snapshot last saved or opened, in a new machine. --save-snapshot
8
+ # saves one after the last frame.
9
+ class Snapshots
10
+ KEYS = [Keys::F11, Keys::F12].freeze
11
+
12
+ # The machine running, which F12 replaces with the restored one.
13
+ attr_reader :computer
14
+
15
+ def initialize(computer, options)
16
+ @computer = computer
17
+ @last = options.snapshot? ? options.media_path.to_s : ""
18
+ @at_finish = options.save_snapshot
19
+ end
20
+
21
+ # Saves or restores for F11 or F12, and says whether a restored
22
+ # machine now runs in place of the one before.
23
+ def key(scancode)
24
+ return restore if scancode == Keys::F12
25
+
26
+ save if scancode == Keys::F11
27
+ false
28
+ end
29
+
30
+ # Saves the machine, and warns when it can't.
31
+ def save(path = "badline-#{Time.now.strftime('%Y%m%d-%H%M%S')}.vsf")
32
+ @computer.save_snapshot(path)
33
+ @last = path
34
+ puts "Saved #{path}"
35
+ rescue Snapshot::FormatError, SystemCallError => e
36
+ warn "badline: #{path}: #{e.message}"
37
+ end
38
+
39
+ # Restores the snapshot last saved or opened into a new machine, and
40
+ # warns when it fails, leaving the machine running as it was.
41
+ def restore
42
+ if @last.empty?
43
+ puts "No snapshot to restore, F11 saves one"
44
+ return false
45
+ end
46
+
47
+ @computer = Snapshot.load(@last) { |line| puts line }
48
+ puts "Restored #{@last}"
49
+ true
50
+ rescue Snapshot::FormatError, SystemCallError => e
51
+ warn "badline: #{@last}: #{e.message}"
52
+ false
53
+ end
54
+
55
+ def finish
56
+ save(@at_finish) unless @at_finish.empty?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID's output on SDL's audio queue, as mono signed 16-bit samples.
6
+ # Once the device plays, it is the clock: #wait holds each frame until
7
+ # the queue is down to `AHEAD`, so the machine runs at the device's pace.
8
+ #
9
+ # The device starts once the queue holds AHEAD. Below real time the
10
+ # queue runs dry, and the device then stops until it holds AHEAD again,
11
+ # so the sound stutters with silent gaps rather than slowing down. A
12
+ # frame that would take the queue past LIMIT is dropped whole, which
13
+ # only happens unpaced.
14
+ #
15
+ # Each frame's samples go to SDL through an IO::Buffer of 16-bit values.
16
+ class Sound
17
+ RATE = 44_100
18
+ AHEAD = 0.08
19
+ LIMIT = 0.25
20
+
21
+ attr_reader :rate, :underruns, :dropped, :queued, :low, :high
22
+
23
+ def initialize(sid, wanted, verbose)
24
+ @sid = sid
25
+ @verbose = verbose
26
+ @device = 0
27
+ @rate = RATE
28
+ @started = false
29
+ @muted = false
30
+ @underruns = 0
31
+ @dropped = 0
32
+ @queued = 0
33
+ @buffer = IO::Buffer.new(4096)
34
+ reset_levels
35
+ open_device if wanted
36
+ end
37
+
38
+ def on? = @device != 0
39
+
40
+ # Plays another machine's SID from here on.
41
+ def sid=(sid)
42
+ @sid = sid
43
+ sid.record(rate: @rate) if on?
44
+ end
45
+
46
+ def muted? = @muted
47
+
48
+ def playing? = @started
49
+
50
+ def feed
51
+ return unless on?
52
+
53
+ samples = @sid.drain_samples
54
+ return if @muted || samples.empty?
55
+
56
+ level = queued_seconds
57
+ if level + (samples.size.to_f / @rate) > LIMIT
58
+ @dropped += samples.size
59
+ else
60
+ play(samples, level)
61
+ end
62
+ end
63
+
64
+ # How much the audio queue holds, in seconds.
65
+ def level = queued_seconds
66
+
67
+ # Waits until the queue is down to `level` seconds.
68
+ def wait(level = AHEAD)
69
+ SDL.SDL_Delay(1) while queued_seconds > level
70
+ end
71
+
72
+ def toggle_mute
73
+ @muted = !@muted
74
+ return unless @muted
75
+
76
+ halt
77
+ SDL.SDL_ClearQueuedAudio(@device)
78
+ end
79
+
80
+ def reset_levels
81
+ @low = LIMIT
82
+ @high = 0.0
83
+ end
84
+
85
+ def close
86
+ SDL.SDL_CloseAudioDevice(@device) if on?
87
+ end
88
+
89
+ private
90
+
91
+ def open_device
92
+ unless SDL.SDL_InitSubSystem(SDL::INIT_AUDIO).zero?
93
+ puts "No sound: #{SDL.SDL_GetError}"
94
+ return
95
+ end
96
+
97
+ SDL.spec_freq(SDL.wanted, RATE)
98
+ SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
99
+ SDL.spec_channels(SDL.wanted, 1)
100
+ SDL.spec_samples(SDL.wanted, 512)
101
+ @device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, SDL::ALLOW_FREQUENCY_CHANGE)
102
+ return puts "No sound: #{SDL.SDL_GetError}" unless on?
103
+
104
+ @rate = SDL.read_i32(SDL.obtained)
105
+ @sid.record(rate: @rate)
106
+ puts "Sound at #{@rate} Hz" if @verbose
107
+ end
108
+
109
+ def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
110
+
111
+ def play(samples, level)
112
+ @low = level if level < @low
113
+ underrun! if @started && level.zero?
114
+ queue(samples)
115
+ level = queued_seconds
116
+ @high = level if level > @high
117
+ start if level >= AHEAD
118
+ end
119
+
120
+ def queue(samples)
121
+ count = samples.size
122
+ bytes = count * 2
123
+ @buffer.resize(bytes) if bytes > @buffer.size
124
+ buffer = @buffer
125
+ i = 0
126
+ while i < count
127
+ buffer.set_value(:s16, i * 2, samples[i])
128
+ i += 1
129
+ end
130
+ SDL.SDL_QueueAudio(@device, buffer, bytes)
131
+ @queued += count
132
+ end
133
+
134
+ def start
135
+ return if @started
136
+
137
+ @started = true
138
+ SDL.SDL_PauseAudioDevice(@device, 0)
139
+ end
140
+
141
+ def halt
142
+ @started = false
143
+ SDL.SDL_PauseAudioDevice(@device, 1)
144
+ end
145
+
146
+ def underrun!
147
+ @underruns += 1
148
+ puts "Running below real time, so the sound will stutter." if @underruns == 1
149
+ halt
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The events of --at and --script. Each runs once its number of frames
6
+ # has run: a screenshot saves that frame, and the rest act on the
7
+ # machine before the next frame is clocked. A key or the freeze button
8
+ # is let go Options::Event::HOLD frames after it is pressed. An event
9
+ # that fails ends the run.
10
+ class Timeline
11
+ # Takes the events, --screenshot's at the last frame and whether to
12
+ # insert disks write-protected from Options.
13
+ def initialize(options)
14
+ @events = options.timeline
15
+ unless options.screenshot.empty? || options.frames.zero?
16
+ @events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
17
+ end
18
+ @read_only = options.read_only?
19
+ @program = options.program
20
+ @releases = []
21
+ @failed = false
22
+ end
23
+
24
+ # What's wrong with the events, the first key that names nothing,
25
+ # or an empty string.
26
+ def self.error(events)
27
+ event = events.find { |candidate| candidate.action == "key" && !key?(candidate.argument) }
28
+ event.nil? ? "" : "no such key: #{event.argument}"
29
+ end
30
+
31
+ def self.key?(name) = name == "restore" || !keyboard_key(name).nil? || !joystick_direction(name).nil?
32
+
33
+ # The Keyboard's key for a name such as space, return or a.
34
+ def self.keyboard_key(name)
35
+ key = Keyboard.new.matrix.flatten.find { |candidate| candidate.to_s == name }
36
+ key.nil? ? Keyboard::COMBINATIONS.keys.find { |candidate| candidate.to_s == name } : key
37
+ end
38
+
39
+ # The direction of joy1-NAME or joy2-NAME, such as up or fire.
40
+ def self.joystick_direction(name)
41
+ return nil unless name.start_with?("joy1-") || name.start_with?("joy2-")
42
+
43
+ Joystick::DIRECTIONS.keys.find { |direction| direction.to_s == name[5, name.size - 5] }
44
+ end
45
+
46
+ # The screenshot's file name, with the frame's number in place of %d,
47
+ # or of %Nd or %0Nd padded to N digits.
48
+ def self.path(pattern, frame)
49
+ start = pattern.index("%")
50
+ finish = start.nil? ? nil : pattern.index("d", start)
51
+ return pattern if finish.nil?
52
+
53
+ width = pattern[start + 1, finish - start - 1]
54
+ return pattern unless width.match?(/\A\d*\z/)
55
+
56
+ number = frame.to_s.rjust(width.to_i, width.start_with?("0") ? "0" : " ")
57
+ pattern[0, start] + number + pattern[finish + 1, pattern.size - finish - 1]
58
+ end
59
+
60
+ # The files the frame's screenshots go to.
61
+ def screenshots(frame)
62
+ @events.select { |event| event.frame == frame && event.action == "screenshot" }
63
+ .map { |event| Timeline.path(event.argument, frame) }
64
+ end
65
+
66
+ # Whether the run ends at the frame: at a quit, or once an event failed.
67
+ def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
68
+
69
+ # Whether an event failed, which ends the run.
70
+ def failed? = @failed
71
+
72
+ # Lets go of what was held until this frame, then runs its events, up
73
+ # to one that fails.
74
+ def run(computer, frame)
75
+ @releases.each { |event| release(computer, event) if event.frame == frame }
76
+ @releases.reject! { |event| event.frame == frame }
77
+ @events.each { |event| perform(computer, event) if event.frame == frame && !@failed }
78
+ end
79
+
80
+ private
81
+
82
+ def perform(computer, event)
83
+ case event.action
84
+ when "key" then press(computer, event)
85
+ when "type" then computer.type_text(event.argument.gsub("\\n", "\r").gsub("\\r", "\r"))
86
+ when "insert" then insert(computer, event.argument)
87
+ when "eject" then eject(computer, event.argument)
88
+ when "reset" then computer.reset!
89
+ when "freeze" then freeze(computer, event)
90
+ end
91
+ end
92
+
93
+ def press(computer, event)
94
+ name = event.argument
95
+ if name == "restore"
96
+ computer.press_restore
97
+ return
98
+ end
99
+
100
+ direction = Timeline.joystick_direction(name)
101
+ if direction.nil?
102
+ computer.keyboard.press(Timeline.keyboard_key(name))
103
+ else
104
+ joystick(computer, name).press(direction)
105
+ end
106
+ hold(event)
107
+ end
108
+
109
+ def freeze(computer, event)
110
+ computer.press_cartridge_button
111
+ hold(event)
112
+ end
113
+
114
+ def hold(event)
115
+ @releases << Options::Event.new(event.frame + Options::Event::HOLD, event.action, event.argument)
116
+ end
117
+
118
+ def release(computer, event)
119
+ return computer.release_cartridge_button if event.action == "freeze"
120
+
121
+ name = event.argument
122
+ direction = Timeline.joystick_direction(name)
123
+ if direction.nil?
124
+ computer.keyboard.release(Timeline.keyboard_key(name))
125
+ else
126
+ joystick(computer, name).release(direction)
127
+ end
128
+ end
129
+
130
+ def joystick(computer, name) = name.start_with?("joy1-") ? computer.joystick1 : computer.joystick2
131
+
132
+ def insert(computer, path)
133
+ puts Media.attach(computer, path, autostart: false, disk: { read_only: @read_only })
134
+ rescue StandardError => e
135
+ warn "#{@program}: #{path}: #{e.message}"
136
+ @failed = true
137
+ end
138
+
139
+ def eject(computer, what)
140
+ return puts "No #{what} to eject" unless inserted?(computer, what)
141
+
142
+ if what == "disk"
143
+ eject_disk(computer)
144
+ elsif what == "tape"
145
+ computer.datasette.eject
146
+ else
147
+ computer.address_bus.detach_cartridge
148
+ computer.power_cycle!
149
+ end
150
+ puts "Ejected the #{what}"
151
+ end
152
+
153
+ def inserted?(computer, what)
154
+ return !computer.datasette.tape.nil? if what == "tape"
155
+ return !computer.address_bus.cartridge.nil? if what == "cartridge"
156
+
157
+ drive = Media::TrueDrive.drive(computer)
158
+ drive.nil? ? computer.mounted? : !drive.disk.nil?
159
+ end
160
+
161
+ def eject_disk(computer)
162
+ drive = Media::TrueDrive.drive(computer)
163
+ if drive.nil?
164
+ computer.unmount
165
+ else
166
+ drive.insert(nil)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -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
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/sdl"
4
+ require "badline/frontend/boot"
5
+ require "badline/frontend/frame_rate"
6
+ require "badline/frontend/screen"
7
+ require "badline/frontend/drive_led"
8
+ require "badline/frontend/keys"
9
+ require "badline/frontend/snapshots"
10
+ require "badline/frontend/screenshot"
11
+ require "badline/frontend/timeline"
12
+ require "badline/frontend/sound"
13
+ require "badline/frontend/pacer"
14
+ require "badline/frontend/controls"
15
+ require "badline/frontend/pad_port"
16
+ require "badline/frontend/gamepads"
17
+ require "badline/frontend/app"
18
+ require "badline/frontend/audio_sink"
19
+ require "badline/frontend/painter"
20
+ require "badline/frontend/sid_history"
21
+ require "badline/frontend/sid_view"
22
+ require "badline/frontend/filter_view"
23
+ require "badline/frontend/scope"
24
+ require "badline/frontend/visualizer_view"
25
+ require "badline/frontend/buttons"
26
+ require "badline/frontend/player_state"
27
+ require "badline/frontend/player_header"
28
+ require "badline/frontend/player_footer"
29
+ require "badline/frontend/info_view"
30
+ require "badline/frontend/player_commands"
31
+ require "badline/frontend/player_screen"
32
+ require "badline/frontend/player_window"