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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Badline
6
+ # The part of Spinel's FFI DSL the SDL binding uses, for CRuby, over
7
+ # Fiddle, so the one binding in badline/sdl serves both builds. Spinel
8
+ # compiles the declarations itself and never loads this file: require it
9
+ # before a file that declares a binding.
10
+ #
11
+ # The declarations behave as they do in Spinel. ffi_func defines
12
+ # Module.name(...); a pointer argument takes nil for NULL, a Fiddle
13
+ # pointer, a String (its bytes) or an IO::Buffer, whose contents C sees
14
+ # for the call's duration; a NULL pointer comes back as nil. ffi_const
15
+ # sets Module::NAME, ffi_buffer defines Module.name returning a zeroed
16
+ # static buffer, and ffi_read_<width> and ffi_write_<width> define field
17
+ # accessors at a fixed offset into a pointer.
18
+ module FFI
19
+ class Error < StandardError; end
20
+
21
+ TYPES = {
22
+ void: Fiddle::TYPE_VOID,
23
+ int: Fiddle::TYPE_INT, bool: Fiddle::TYPE_INT, long: Fiddle::TYPE_LONG, size_t: Fiddle::TYPE_SIZE_T,
24
+ int8: Fiddle::TYPE_INT8_T, uint8: Fiddle::TYPE_UINT8_T, int16: Fiddle::TYPE_INT16_T,
25
+ uint16: Fiddle::TYPE_UINT16_T, int32: Fiddle::TYPE_INT32_T, uint32: Fiddle::TYPE_UINT32_T,
26
+ float: Fiddle::TYPE_FLOAT, double: Fiddle::TYPE_DOUBLE,
27
+ str: Fiddle::TYPE_CONST_STRING,
28
+ ptr: Fiddle::TYPE_VOIDP, buffer_in: Fiddle::TYPE_VOIDP, buffer_out: Fiddle::TYPE_VOIDP,
29
+ buffer_inout: Fiddle::TYPE_VOIDP, int_array: Fiddle::TYPE_VOIDP, float_array: Fiddle::TYPE_VOIDP
30
+ }.freeze
31
+
32
+ # The argument specs C may write through, so an IO::Buffer passed in one
33
+ # gets back what C left in it.
34
+ WRITABLE = %i[ptr buffer_out buffer_inout].freeze
35
+
36
+ # pack directives for the accessor widths, in the host's byte order.
37
+ WIDTHS = {
38
+ u8: "C", i8: "c", u16: "S", i16: "s", u32: "L", i32: "l", u64: "Q", i64: "q", ptr: "J"
39
+ }.freeze
40
+
41
+ # Where dlopen looks for a library besides its search path, which leaves
42
+ # out Homebrew's, and the versioned names a runtime package installs
43
+ # without the development package's unversioned link.
44
+ DIRECTORIES = [nil, "/opt/homebrew/lib", "/usr/local/lib"].freeze
45
+ VERSIONED = { "SDL2" => %w[libSDL2-2.0.so.0 libSDL2-2.0.0.dylib] }.freeze
46
+
47
+ module_function
48
+
49
+ def open_library(name)
50
+ candidates = library_candidates(name)
51
+ candidates.each do |candidate|
52
+ return Fiddle.dlopen(candidate)
53
+ rescue Fiddle::DLError
54
+ next
55
+ end
56
+ raise Error, "can't find lib#{name} (tried #{candidates.join(', ')})"
57
+ end
58
+
59
+ def library_candidates(name)
60
+ names = ["lib#{name}.dylib", "lib#{name}.so", *VERSIONED.fetch(name, []), "#{name}.dll"]
61
+ DIRECTORIES.flat_map { |dir| names.map { |file| dir ? File.join(dir, file) : file } }
62
+ end
63
+
64
+ # The value Fiddle passes for one argument, and a block that copies what
65
+ # C wrote back into an IO::Buffer once the call returns.
66
+ def argument(spec, value)
67
+ case value
68
+ when IO::Buffer then buffer_argument(spec, value)
69
+ when Array then [value.pack(spec == :float_array ? "d*" : "q*")]
70
+ when true, false then [value ? 1 : 0]
71
+ else [value]
72
+ end
73
+ end
74
+
75
+ def buffer_argument(spec, buffer)
76
+ return [nil] if buffer.null? || buffer.empty?
77
+
78
+ bytes = buffer.get_string
79
+ return [bytes] unless WRITABLE.include?(spec) && !buffer.readonly?
80
+
81
+ [bytes, -> { buffer.set_string(bytes) }]
82
+ end
83
+
84
+ def result(spec, value)
85
+ case spec
86
+ when :ptr then value.null? ? nil : value
87
+ when :bool then value != 0
88
+ else value
89
+ end
90
+ end
91
+
92
+ def read(pointer, offset, width)
93
+ value = Fiddle::Pointer[pointer][offset, width_size(width)].unpack1(WIDTHS.fetch(width))
94
+ width == :ptr ? result(:ptr, Fiddle::Pointer.new(value)) : value
95
+ end
96
+
97
+ def write(pointer, offset, width, value)
98
+ value = value.to_i if width == :ptr
99
+ Fiddle::Pointer[pointer][offset, width_size(width)] = [value || 0].pack(WIDTHS.fetch(width))
100
+ value
101
+ end
102
+
103
+ def width_size(width) = [0].pack(WIDTHS.fetch(width)).bytesize
104
+
105
+ # The declarations, private methods of every module once this file is
106
+ # loaded, as they are keywords of a module body in Spinel.
107
+ module DSL
108
+ private
109
+
110
+ def ffi_lib(name)
111
+ (@ffi_libraries ||= []) << FFI.open_library(name)
112
+ end
113
+
114
+ def ffi_func(name, arguments, returns, blocking: false)
115
+ function = Fiddle::Function.new(ffi_symbol(name), arguments.map { |spec| TYPES.fetch(spec) },
116
+ TYPES.fetch(returns), name: name.to_s, need_gvl: !blocking)
117
+ define_singleton_method(name) do |*values|
118
+ converted = arguments.zip(values).map { |spec, value| FFI.argument(spec, value) }
119
+ returned = function.call(*converted.map(&:first))
120
+ converted.each { |argument| argument[1]&.call }
121
+ FFI.result(returns, returned)
122
+ end
123
+ end
124
+
125
+ def ffi_const(name, value)
126
+ const_set(name, value)
127
+ end
128
+
129
+ def ffi_buffer(name, size)
130
+ buffer = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
131
+ buffer[0, size] = "\0" * size
132
+ define_singleton_method(name) { buffer }
133
+ end
134
+
135
+ WIDTHS.each_key do |width|
136
+ define_method(:"ffi_read_#{width}") do |name, offset|
137
+ define_singleton_method(name) { |pointer| FFI.read(pointer, offset, width) }
138
+ end
139
+
140
+ define_method(:"ffi_write_#{width}") do |name, offset|
141
+ define_singleton_method(name) { |pointer, value| FFI.write(pointer, offset, width, value) }
142
+ end
143
+ end
144
+
145
+ def ffi_symbol(name)
146
+ (@ffi_libraries || []).each do |library|
147
+ return library[name.to_s]
148
+ rescue Fiddle::DLError
149
+ next
150
+ end
151
+ Fiddle::Handle::DEFAULT[name.to_s]
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ Module.include(Badline::FFI::DSL)
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Opens the window, then runs the machine a frame at a time: poll
6
+ # events, clock the frame's cycles, queue the SID's samples, upload the
7
+ # changed lines, present and wait. Pacer decides the cycles and the wait.
8
+ class App
9
+ SCALE = 2
10
+ TITLE = "Badline"
11
+ STAGES = %w[events emulate audio blit present wait].freeze
12
+
13
+ # Takes the frame limit, the pacing, the snapshot path, the sound and
14
+ # the verbosity from Options, and runs the timeline's events.
15
+ def initialize(computer, options, timeline)
16
+ @computer = computer
17
+ @frame_limit = options.frames
18
+ @verbose = options.verbose?
19
+ @pacer = Pacer.new(paced: options.paced?, vsync: options.vsync?, verbose: @verbose, region: computer.region)
20
+ @timeline = timeline
21
+ @snapshots = Snapshots.new(computer, options)
22
+ @screen = Screen.new(computer.vic)
23
+ @led = DriveLed.for(computer)
24
+ @controls = Controls.new(computer)
25
+ @spent = Array.new(STAGES.size, 0.0)
26
+ @slowest = 0.0
27
+ open_window
28
+ @sound = Sound.new(computer.sid, options.sound?, @verbose)
29
+ @gamepads = Gamepads.new(computer, @verbose)
30
+ end
31
+
32
+ def run
33
+ @frames = 0
34
+ @running = true
35
+ @started = @reported = now
36
+ @pacer.start(@started)
37
+ @reported_samples = 0
38
+ frame while @running
39
+ @snapshots.finish
40
+ @computer.drive1541&.flush
41
+ @gamepads.close
42
+ @sound.close
43
+ close_window
44
+ end
45
+
46
+ private
47
+
48
+ def frame
49
+ stamps = [now]
50
+ handle_events
51
+ @gamepads.poll
52
+ stamps << now
53
+ emulate
54
+ stamps << now
55
+ @sound.feed
56
+ stamps << now
57
+ upload
58
+ stamps << now
59
+ draw
60
+ stamps << now
61
+ @pacer.wait(@sound)
62
+ stamps << now
63
+ finish_frame(stamps)
64
+ end
65
+
66
+ def finish_frame(stamps)
67
+ STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
68
+ took = stamps[-2] - stamps.first
69
+ @slowest = took if took > @slowest
70
+ @frames += 1
71
+ @timeline.run(@computer, @frames)
72
+ @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
73
+ @pacer.check(Pacer::EARLY_CHECK, stamps.last - @started, stamps.last) if @frames == Pacer::EARLY_CHECK
74
+ report(stamps.last) if (@frames % 50).zero?
75
+ end
76
+
77
+ def open_window
78
+ abort "SDL_Init: #{SDL.SDL_GetError}" unless SDL.SDL_Init(SDL::INIT_VIDEO | SDL::INIT_EVENTS).zero?
79
+
80
+ SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
81
+ SDL.SDL_SetHint("SDL_MOUSE_RELATIVE_SCALING", "0")
82
+ @window = SDL.SDL_CreateWindow(
83
+ TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
84
+ Screen::WIDTH * SCALE, Screen::HEIGHT * SCALE, SDL::WINDOW_RESIZABLE
85
+ )
86
+ flags = SDL::RENDERER_ACCELERATED
87
+ flags |= SDL::RENDERER_PRESENTVSYNC if @pacer.vsync?
88
+ @renderer = SDL.SDL_CreateRenderer(@window, -1, flags)
89
+ fit_display if @pacer.vsync?
90
+ SDL.SDL_RenderSetLogicalSize(@renderer, Screen::WIDTH, Screen::HEIGHT)
91
+ create_texture
92
+ end
93
+
94
+ def create_texture
95
+ @texture = SDL.SDL_CreateTexture(
96
+ @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
97
+ )
98
+ SDL.rect_w(SDL.rect, Screen::WIDTH)
99
+ SDL.rect_h(SDL.rect, Screen::HEIGHT)
100
+ end
101
+
102
+ def close_window
103
+ SDL.SDL_DestroyTexture(@texture)
104
+ SDL.SDL_DestroyRenderer(@renderer)
105
+ SDL.SDL_DestroyWindow(@window)
106
+ SDL.SDL_Quit
107
+ end
108
+
109
+ def handle_events
110
+ handle_event(SDL.event_type(SDL.event)) while SDL.SDL_PollEvent(SDL.event) != 0
111
+ end
112
+
113
+ def handle_event(type)
114
+ case type
115
+ when SDL::QUIT
116
+ @running = false
117
+ when SDL::KEYDOWN, SDL::KEYUP
118
+ handle_key(SDL.event_scancode(SDL.event), type == SDL::KEYDOWN) if SDL.event_repeat(SDL.event).zero?
119
+ when SDL::MOUSEMOTION
120
+ @controls.mouse_motion(SDL.event_xrel(SDL.event), SDL.event_yrel(SDL.event))
121
+ when SDL::MOUSEBUTTONDOWN, SDL::MOUSEBUTTONUP
122
+ @controls.mouse_button(SDL.event_button(SDL.event), type == SDL::MOUSEBUTTONDOWN)
123
+ when SDL::CONTROLLERDEVICEADDED, SDL::CONTROLLERDEVICEREMOVED
124
+ @gamepads.rescan
125
+ end
126
+ end
127
+
128
+ def handle_key(scancode, down)
129
+ if [Keys::TAB, Keys::F9, Keys::F10].include?(scancode)
130
+ handle_toggle(scancode) if down
131
+ elsif down && @snapshots.key(scancode)
132
+ swap(@snapshots.computer)
133
+ elsif !Snapshots::KEYS.include?(scancode)
134
+ @controls.key(scancode, down)
135
+ end
136
+ end
137
+
138
+ # Runs the restored machine in place of the one before: the screen,
139
+ # the drive LED, the controls, the gamepads and the sound go over to
140
+ # it.
141
+ def swap(computer)
142
+ @computer = computer
143
+ @screen = Screen.new(computer.vic)
144
+ @led = DriveLed.for(computer)
145
+ @controls.computer = computer
146
+ @gamepads.computer = computer
147
+ @sound.sid = computer.sid
148
+ end
149
+
150
+ def handle_toggle(scancode)
151
+ if scancode == Keys::TAB
152
+ @controls.cycle_mode(SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT) ? -1 : 1)
153
+ SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
154
+ elsif scancode == Keys::F9
155
+ @controls.swap_ports
156
+ else
157
+ @sound.toggle_mute
158
+ end
159
+ update_title
160
+ end
161
+
162
+ def update_title
163
+ title = TITLE
164
+ title += " [#{@controls.tag}]" unless @controls.tag.empty?
165
+ title += " [MUTED]" if @sound.muted?
166
+ SDL.SDL_SetWindowTitle(@window, title)
167
+ end
168
+
169
+ def fit_display
170
+ SDL.SDL_GetWindowDisplayMode(@window, SDL.display_mode)
171
+ @pacer.fit(SDL.mode_refresh(SDL.display_mode))
172
+ end
173
+
174
+ def emulate
175
+ computer = @computer
176
+ cycles = @pacer.cycles(@sound)
177
+ i = 0
178
+ while i < cycles
179
+ computer.cycle!
180
+ i += 1
181
+ end
182
+ end
183
+
184
+ def upload
185
+ @screen.update
186
+ SDL.SDL_UpdateTexture(@texture, SDL.rect, @screen.pixels, Screen::ROW_BYTES)
187
+ end
188
+
189
+ def draw
190
+ SDL.SDL_RenderClear(@renderer)
191
+ SDL.SDL_RenderCopy(@renderer, @texture, SDL.rect, SDL.rect)
192
+ @led&.draw(@renderer)
193
+ @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
194
+ SDL.SDL_RenderPresent(@renderer)
195
+ end
196
+
197
+ def report(at)
198
+ @pacer.check(50, at - @reported, at)
199
+ @pacer.measure(50, at - @reported)
200
+ report_frames(at) if @verbose
201
+ @spent = Array.new(STAGES.size, 0.0)
202
+ @slowest = 0.0
203
+ @reported = at
204
+ end
205
+
206
+ def report_frames(at)
207
+ fps = 50 / (at - @reported)
208
+ stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
209
+ puts "#{fps.round(1)} fps, per frame ms: #{stages.join(' ')}, slowest work #{(@slowest * 1000).round(2)}"
210
+ report_sound(at) if @sound.on?
211
+ end
212
+
213
+ def report_sound(at)
214
+ sound = @sound
215
+ rate = (sound.queued - @reported_samples) / (at - @reported)
216
+ queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
217
+ puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
218
+ sound.reset_levels
219
+ @reported_samples = sound.queued
220
+ end
221
+
222
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The host's audio device for --headless and the SID player, fed through
6
+ # SDL's queue: stereo signed 16-bit samples, the left and right
7
+ # interleaved, go in at the device's rate and SDL plays them out behind
8
+ # us. It answers what Audio::Playback asks of a sink.
9
+ class AudioSink
10
+ class Error < Audio::Playback::DeviceError; end
11
+
12
+ attr_reader :rate
13
+
14
+ # With `exact_rate` false SDL may pick the device's own rate instead,
15
+ # which #rate then reports.
16
+ def initialize(rate:, exact_rate: false, buffer: 1024)
17
+ @device = 0
18
+ @rate = rate
19
+ @buffer = IO::Buffer.new(4096)
20
+ check(SDL.SDL_InitSubSystem(SDL::INIT_AUDIO))
21
+ open_device(rate, exact_rate ? 0 : SDL::ALLOW_FREQUENCY_CHANGE, buffer)
22
+ end
23
+
24
+ def queue(samples)
25
+ count = samples.size
26
+ bytes = count * 2
27
+ @buffer.resize(bytes) if bytes > @buffer.size
28
+ buffer = @buffer
29
+ i = 0
30
+ while i < count
31
+ buffer.set_value(:s16, i * 2, samples[i])
32
+ i += 1
33
+ end
34
+ check(SDL.SDL_QueueAudio(@device, buffer, bytes))
35
+ end
36
+
37
+ def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (4.0 * @rate)
38
+
39
+ def start = SDL.SDL_PauseAudioDevice(@device, 0)
40
+
41
+ def pause = SDL.SDL_PauseAudioDevice(@device, 1)
42
+
43
+ def clear = SDL.SDL_ClearQueuedAudio(@device)
44
+
45
+ def close
46
+ return if @device.zero?
47
+
48
+ SDL.SDL_CloseAudioDevice(@device)
49
+ SDL.SDL_QuitSubSystem(SDL::INIT_AUDIO)
50
+ @device = 0
51
+ end
52
+
53
+ private
54
+
55
+ def open_device(rate, allowed_changes, buffer)
56
+ SDL.spec_freq(SDL.wanted, rate)
57
+ SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
58
+ SDL.spec_channels(SDL.wanted, 2)
59
+ SDL.spec_samples(SDL.wanted, buffer)
60
+ @device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, allowed_changes)
61
+ raise Error, SDL.SDL_GetError if @device.zero?
62
+
63
+ @rate = SDL.read_i32(SDL.obtained)
64
+ end
65
+
66
+ def check(result)
67
+ raise Error, SDL.SDL_GetError if result.negative?
68
+
69
+ result
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ # The window both builds play the machine in, badline-ruby on CRuby and
5
+ # the native badline compiled with Spinel, so it stays inside the subset
6
+ # of Ruby Spinel compiles. On CRuby, require badline/ffi before it.
7
+ module Frontend
8
+ # Builds the machine the options ask for and plays it in the window
9
+ # until it closes, and returns the exit status: 1 when the media won't
10
+ # attach, an --at key names nothing or an event fails, which it warns
11
+ # about.
12
+ def self.run(options)
13
+ error = Timeline.error(options.timeline)
14
+ unless error.empty?
15
+ warn "#{options.program}: #{error}"
16
+ return 1
17
+ end
18
+
19
+ begin
20
+ computer = boot(options)
21
+ rescue Media::TrueDrive::Error, Storage::SIDFile::FormatError, Storage::T64::FormatError,
22
+ Storage::TAP::FormatError, Storage::CRTFile::FormatError, Storage::G64Image::FormatError,
23
+ Cartridge::UnsupportedTypeError, Snapshot::FormatError => e
24
+ warn "#{options.program}: #{options.media_path}: #{e.message}"
25
+ return 1
26
+ end
27
+ timeline = Timeline.new(options)
28
+ App.new(computer, options, timeline).run
29
+ timeline.failed? ? 1 : 0
30
+ end
31
+
32
+ # The machine a .vsf snapshot holds, built as the saved one was, or a
33
+ # new one built from the options with the media attached.
34
+ def self.boot(options)
35
+ media = options.media_path
36
+ if options.snapshot?
37
+ computer = Snapshot.load(media) { |line| puts line }
38
+ puts "Restored #{media}"
39
+ return computer
40
+ end
41
+
42
+ computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
43
+ region: options.ntsc? ? Region::NTSC : Region::PAL)
44
+ Media::TrueDrive.plug(computer) if options.true_drive?
45
+ unless media.nil?
46
+ puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
47
+ disk: { read_only: options.read_only? })
48
+ end
49
+ computer
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's buttons: each is drawn where it goes on every frame,
6
+ # and remembers its place, so a click finds the action under it. The
7
+ # one under the pointer lights up, and one that's on is drawn reversed.
8
+ class Buttons
9
+ PAD = 2
10
+ HEIGHT = Painter::GLYPH + (PAD * 2)
11
+
12
+ def initialize(painter)
13
+ @painter = painter
14
+ @lefts = []
15
+ @tops = []
16
+ @widths = []
17
+ @heights = []
18
+ @actions = []
19
+ @pointer_x = -1
20
+ @pointer_y = -1
21
+ end
22
+
23
+ def forget
24
+ @lefts.clear
25
+ @tops.clear
26
+ @widths.clear
27
+ @heights.clear
28
+ @actions.clear
29
+ end
30
+
31
+ def point(left, top)
32
+ @pointer_x = left
33
+ @pointer_y = top
34
+ end
35
+
36
+ # Draws a button labelled `label` with its top left at `left`, `top`,
37
+ # and returns its width.
38
+ def text(left, top, label, action, on: false)
39
+ width = Painter.width(label) + (PAD * 2)
40
+ place(left, top, width, HEIGHT, action)
41
+ @painter.box(left, top, width, HEIGHT, SIDView::TEXT) if on
42
+ @painter.text(left + PAD, top + PAD, label, on ? PlayerScreen::BACKGROUND : color(@actions.size - 1))
43
+ width
44
+ end
45
+
46
+ # Draws an icon button `scale` times the size of a glyph, and returns
47
+ # its width.
48
+ def icon(left, top, name, action, scale: 1)
49
+ size = (Painter::GLYPH * scale) + (PAD * 2)
50
+ place(left, top, size, size, action)
51
+ @painter.icon(name, left + PAD, top + PAD, color(@actions.size - 1), scale:)
52
+ size
53
+ end
54
+
55
+ # Makes a box drawn elsewhere clickable, such as the bar showing the
56
+ # time played.
57
+ def area(box, action)
58
+ place(box[0], box[1], box[2], box[3], action)
59
+ end
60
+
61
+ # The action of the button at `left`, `top`, or nil.
62
+ def action_at(left, top)
63
+ index = 0
64
+ while index < @actions.size
65
+ return @actions[index] if inside?(index, left, top)
66
+
67
+ index += 1
68
+ end
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ def place(left, top, width, height, action)
75
+ @lefts << left
76
+ @tops << top
77
+ @widths << width
78
+ @heights << height
79
+ @actions << action
80
+ end
81
+
82
+ def color(index) = inside?(index, @pointer_x, @pointer_y) ? SIDView::BRIGHT : SIDView::TEXT
83
+
84
+ def inside?(index, left, top)
85
+ left >= @lefts[index] && left < @lefts[index] + @widths[index] &&
86
+ top >= @tops[index] && top < @tops[index] + @heights[index]
87
+ end
88
+ end
89
+ end
90
+ end