badline 0.6.0 → 0.6.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +24 -10
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +20 -24
  7. data/lib/badline/address_bus.rb +10 -1
  8. data/lib/badline/audio/cli.rb +18 -5
  9. data/lib/badline/audio/console.rb +2 -1
  10. data/lib/badline/audio.rb +0 -3
  11. data/lib/badline/computer.rb +6 -3
  12. data/lib/badline/cpu/boundary.rb +54 -0
  13. data/lib/badline/cpu/core.rb +165 -0
  14. data/lib/badline/cpu.rb +3 -214
  15. data/lib/badline/drive1541/cpu.rb +29 -0
  16. data/lib/badline/drive1541.rb +1 -0
  17. data/lib/badline/ffi.rb +157 -0
  18. data/lib/badline/frontend/app.rb +225 -0
  19. data/lib/badline/frontend/audio_sink.rb +72 -0
  20. data/lib/badline/frontend/boot.rb +52 -0
  21. data/lib/badline/frontend/controls.rb +163 -0
  22. data/lib/badline/frontend/drive_led.rb +51 -0
  23. data/lib/badline/frontend/frame_rate.rb +83 -0
  24. data/lib/badline/frontend/gamepads.rb +76 -0
  25. data/lib/badline/frontend/keys.rb +48 -0
  26. data/lib/badline/frontend/pacer.rb +100 -0
  27. data/lib/badline/frontend/pad_port.rb +61 -0
  28. data/lib/badline/frontend/screen.rb +65 -0
  29. data/lib/badline/frontend/screenshot.rb +22 -0
  30. data/lib/badline/frontend/snapshots.rb +60 -0
  31. data/lib/badline/frontend/sound.rb +153 -0
  32. data/lib/badline/frontend/timeline.rb +171 -0
  33. data/lib/badline/frontend.rb +18 -0
  34. data/lib/badline/options/event.rb +94 -0
  35. data/lib/badline/options/help.rb +78 -0
  36. data/lib/badline/options/option.rb +29 -0
  37. data/lib/badline/options/table.rb +62 -0
  38. data/lib/badline/options.rb +181 -145
  39. data/lib/badline/reu/dma/saved_state.rb +50 -0
  40. data/lib/badline/reu/dma.rb +56 -47
  41. data/lib/badline/reu/saved_state.rb +2 -1
  42. data/lib/badline/reu.rb +19 -10
  43. data/lib/badline/sdl.rb +123 -51
  44. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  45. data/lib/badline/sid/waveform.rb +2 -3
  46. data/lib/badline/snapshot/state.rb +1 -1
  47. data/lib/badline/version.rb +1 -1
  48. data/lib/badline/vic/bank.rb +21 -0
  49. data/lib/badline/vic.rb +15 -6
  50. metadata +26 -19
  51. data/.claude/hooks/session-start.sh +0 -57
  52. data/.claude/settings.json +0 -14
  53. data/lib/badline/audio/sdl_sink.rb +0 -70
  54. data/lib/badline/audio/stream.rb +0 -123
  55. data/lib/badline/gui/application.rb +0 -243
  56. data/lib/badline/gui/drive_led_pane.rb +0 -29
  57. data/lib/badline/gui/gamepads.rb +0 -104
  58. data/lib/badline/gui/joy_map.rb +0 -27
  59. data/lib/badline/gui/key_map.rb +0 -39
  60. data/lib/badline/gui/palette.rb +0 -35
  61. data/lib/badline/gui/pane.rb +0 -36
  62. data/lib/badline/gui/screen_pane.rb +0 -46
  63. data/lib/badline/gui/snapshots.rb +0 -67
  64. data/lib/badline/gui/window.rb +0 -61
  65. data/lib/badline/gui.rb +0 -14
  66. data/lib/badline/sdl/events.rb +0 -49
  67. data/lib/badline/sdl/functions.rb +0 -74
  68. data/vendor/.gitkeep +0 -0
@@ -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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The host's audio device for --headless, fed through SDL's queue: mono
6
+ # signed 16-bit samples go in at the device's rate and SDL plays them
7
+ # out behind us. It answers what Audio::Playback asks of a sink.
8
+ class AudioSink
9
+ class Error < Audio::Playback::DeviceError; end
10
+
11
+ attr_reader :rate
12
+
13
+ # With `exact_rate` false SDL may pick the device's own rate instead,
14
+ # which #rate then reports.
15
+ def initialize(rate:, exact_rate: false, buffer: 1024)
16
+ @device = 0
17
+ @rate = rate
18
+ @buffer = IO::Buffer.new(4096)
19
+ check(SDL.SDL_InitSubSystem(SDL::INIT_AUDIO))
20
+ open_device(rate, exact_rate ? 0 : SDL::ALLOW_FREQUENCY_CHANGE, buffer)
21
+ end
22
+
23
+ def queue(samples)
24
+ count = samples.size
25
+ bytes = count * 2
26
+ @buffer.resize(bytes) if bytes > @buffer.size
27
+ buffer = @buffer
28
+ i = 0
29
+ while i < count
30
+ buffer.set_value(:s16, i * 2, samples[i])
31
+ i += 1
32
+ end
33
+ check(SDL.SDL_QueueAudio(@device, buffer, bytes))
34
+ end
35
+
36
+ def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
37
+
38
+ def start = SDL.SDL_PauseAudioDevice(@device, 0)
39
+
40
+ def pause = SDL.SDL_PauseAudioDevice(@device, 1)
41
+
42
+ def clear = SDL.SDL_ClearQueuedAudio(@device)
43
+
44
+ def close
45
+ return if @device.zero?
46
+
47
+ SDL.SDL_CloseAudioDevice(@device)
48
+ SDL.SDL_QuitSubSystem(SDL::INIT_AUDIO)
49
+ @device = 0
50
+ end
51
+
52
+ private
53
+
54
+ def open_device(rate, allowed_changes, buffer)
55
+ SDL.spec_freq(SDL.wanted, rate)
56
+ SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
57
+ SDL.spec_channels(SDL.wanted, 1)
58
+ SDL.spec_samples(SDL.wanted, buffer)
59
+ @device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, allowed_changes)
60
+ raise Error, SDL.SDL_GetError if @device.zero?
61
+
62
+ @rate = SDL.read_i32(SDL.obtained)
63
+ end
64
+
65
+ def check(result)
66
+ raise Error, SDL.SDL_GetError if result.negative?
67
+
68
+ result
69
+ end
70
+ end
71
+ end
72
+ 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?, song: options.song,
47
+ disk: { read_only: options.read_only? })
48
+ end
49
+ computer
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Routes host input to the machine in one of the input modes, which Tab
6
+ # steps through and shift-Tab steps back through:
7
+ #
8
+ # - keyboard: every host key goes to the C64 keyboard.
9
+ # - joystick: the arrow cluster and space drive one joystick and WASD
10
+ # and left shift the other. The arrows start on joystick 2, and F9
11
+ # swaps them.
12
+ # - mouse1, mouse2: the host mouse is a 1351 mouse on control port 1 or
13
+ # 2, and paddles1, paddles2 a pair of paddles there. Motion turns the
14
+ # paddles or moves the mouse, and the host's left and right buttons go
15
+ # to whichever lines the device puts them on.
16
+ class Controls
17
+ MODES = %i[keyboard joystick mouse1 mouse2 paddles1 paddles2].freeze
18
+ TAGS = ["", "JOY", "MOUSE 1", "MOUSE 2", "PADDLE 1", "PADDLE 2"].freeze
19
+
20
+ # SDL mouse button numbers.
21
+ MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
22
+
23
+ attr_reader :mode, :arrows_port
24
+
25
+ def initialize(computer)
26
+ @computer = computer
27
+ @mode = :keyboard
28
+ @arrows_port = 2
29
+ @mouse = Input::Mouse1351.new
30
+ @paddles = Input::Paddles.new
31
+ end
32
+
33
+ # Routes the input to another machine, the mode's mouse or paddles
34
+ # going into its port.
35
+ def computer=(computer)
36
+ release_all
37
+ @computer = computer
38
+ attach_pot_device
39
+ end
40
+
41
+ def joystick_mode? = @mode == :joystick
42
+
43
+ # Whether the host mouse drives a pot device, and so should be held
44
+ # in relative mode.
45
+ def pot_device? = MODES.index(@mode) >= 2
46
+
47
+ # The title bar's tag for the mode, empty for the keyboard.
48
+ def tag
49
+ return "JOY #{@arrows_port}" if joystick_mode?
50
+
51
+ TAGS[MODES.index(@mode)]
52
+ end
53
+
54
+ def key(scancode, down)
55
+ if joystick_mode? && Keys.joystick?(scancode)
56
+ joystick_key(scancode, down)
57
+ else
58
+ c64_key(Keys.c64_key(scancode), down)
59
+ end
60
+ end
61
+
62
+ # Steps to the next mode, or back with a negative step.
63
+ def cycle_mode(step)
64
+ @mode = MODES[(MODES.index(@mode) + step) % MODES.size]
65
+ release_all
66
+ attach_pot_device
67
+ end
68
+
69
+ def swap_ports
70
+ @arrows_port = 3 - @arrows_port
71
+ release_all
72
+ end
73
+
74
+ def mouse_motion(delta_x, delta_y)
75
+ return unless pot_device?
76
+
77
+ paddles? ? @paddles.move(delta_x, delta_y) : @mouse.move(delta_x, delta_y)
78
+ end
79
+
80
+ def mouse_button(button, down)
81
+ name = MOUSE_BUTTONS[button]
82
+ return unless name && pot_device?
83
+
84
+ if paddles?
85
+ down ? @paddles.press(name) : @paddles.release(name)
86
+ else
87
+ down ? @mouse.press(name) : @mouse.release(name)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # RESTORE isn't in the key matrix, so it goes to the machine instead.
94
+ def c64_key(key, down)
95
+ if key == :restore
96
+ @computer.press_restore if down
97
+ else
98
+ down ? @computer.keyboard.press(key) : @computer.keyboard.release(key)
99
+ end
100
+ end
101
+
102
+ def paddles? = MODES.index(@mode) >= 4
103
+
104
+ # A fresh device on the mode's port, and none on the other.
105
+ def attach_pot_device
106
+ ports = @computer.control_ports
107
+ ports.device1 = nil
108
+ ports.device2 = nil
109
+ return unless pot_device?
110
+
111
+ if paddles?
112
+ @paddles = Input::Paddles.new
113
+ attach_paddles(ports)
114
+ else
115
+ @mouse = Input::Mouse1351.new
116
+ attach_mouse(ports)
117
+ end
118
+ end
119
+
120
+ def attach_paddles(ports)
121
+ if port_one?
122
+ ports.device1 = @paddles
123
+ else
124
+ ports.device2 = @paddles
125
+ end
126
+ end
127
+
128
+ def attach_mouse(ports)
129
+ if port_one?
130
+ ports.device1 = @mouse
131
+ else
132
+ ports.device2 = @mouse
133
+ end
134
+ end
135
+
136
+ def port_one? = %i[mouse1 paddles1].include?(@mode)
137
+
138
+ def joystick_key(scancode, down)
139
+ direction = Keys.arrows(scancode)
140
+ if direction == :none
141
+ move(joystick(3 - @arrows_port), Keys.wasd(scancode), down)
142
+ else
143
+ move(joystick(@arrows_port), direction, down)
144
+ end
145
+ end
146
+
147
+ def joystick(port) = port == 1 ? @computer.joystick1 : @computer.joystick2
148
+
149
+ def move(joystick, direction, down)
150
+ down ? joystick.press(direction) : joystick.release(direction)
151
+ end
152
+
153
+ def release_all
154
+ Keys::ARROWS.each_key { |scancode| @computer.keyboard.release(Keys.c64_key(scancode)) }
155
+ Keys::WASD.each_key { |scancode| @computer.keyboard.release(Keys.c64_key(scancode)) }
156
+ Keys::ARROWS.each_value do |direction|
157
+ @computer.joystick1.release(direction)
158
+ @computer.joystick2.release(direction)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The true drive's red LED, drawn over the bottom right corner of the
6
+ # border: bright while the DOS lights it, as it does while a file is
7
+ # open or after an error, and dim otherwise.
8
+ class DriveLed
9
+ WIDTH = 12
10
+ HEIGHT = 4
11
+ LEFT = Screen::WIDTH - WIDTH - 8
12
+ TOP = Screen::HEIGHT - HEIGHT - 6
13
+
14
+ LIT = [0xff, 0x20, 0x20].freeze
15
+ DARK = [0x40, 0x00, 0x00].freeze
16
+
17
+ # The LED of the machine's true drive, or nil without one.
18
+ def self.for(computer)
19
+ drive = computer.drive1541
20
+ drive ? new(drive) : nil
21
+ end
22
+
23
+ # Sets SDL.led_rect to where the LED goes.
24
+ def self.place
25
+ rect = SDL.led_rect
26
+ SDL.rect_x(rect, LEFT)
27
+ SDL.rect_y(rect, TOP)
28
+ SDL.rect_w(rect, WIDTH)
29
+ SDL.rect_h(rect, HEIGHT)
30
+ end
31
+
32
+ def initialize(drive)
33
+ @drive = drive
34
+ end
35
+
36
+ def lit? = @drive.led_on?
37
+
38
+ def color = lit? ? LIT : DARK
39
+
40
+ # Fills the LED's rectangle in its colour, then puts back the black
41
+ # that RenderClear clears with.
42
+ def draw(renderer)
43
+ DriveLed.place
44
+ rgb = color
45
+ SDL.SDL_SetRenderDrawColor(renderer, rgb[0], rgb[1], rgb[2], 255)
46
+ SDL.SDL_RenderFillRect(renderer, SDL.led_rect)
47
+ SDL.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # How many cycles a frame clocks and how long it lasts. Without vsync a
6
+ # frame is one of the machine's frames: on PAL 312 lines of 63 cycles
7
+ # over 19.95 ms. With vsync a frame lasts one display refresh and clocks as
8
+ # many cycles as the machine runs in that time, so a 60 Hz display
9
+ # shows 60 PAL frames of 16,420 cycles a second.
10
+ #
11
+ # A display's reported refresh rate is a whole number, and can be some
12
+ # way off the rate it presents at. #refit sizes the frames to the rate
13
+ # measured instead, as long as that is within REFIT of the reported one.
14
+ #
15
+ # While the sound plays under vsync, the display paces the frames but
16
+ # the audio device consumes the samples, and the two clocks still drift
17
+ # apart. #cycles steers the audio queue towards its target: it trims
18
+ # each frame by up to PROPORTIONAL of the queue's error, and by a trim
19
+ # that builds up by INTEGRAL a frame while the error lasts, up to
20
+ # MAX_TRIM, so the queue settles at the target instead of beside it.
21
+ class FrameRate
22
+ # A display that reports no refresh rate is taken to run at this.
23
+ DEFAULT_REFRESH = 60
24
+ PROPORTIONAL = 0.02
25
+ INTEGRAL = 0.0001
26
+ MAX_TRIM = 0.05
27
+ # Presenting faster than this many times the refresh rate means vsync
28
+ # isn't waiting for the display.
29
+ VSYNC_SLACK = 1.5
30
+ REFIT = 0.1
31
+
32
+ attr_reader :base_cycles, :seconds, :refresh, :trim
33
+
34
+ # One frame of the region's raster.
35
+ def self.machine(region)
36
+ cycles = region.cycles_per_line * region.lines_per_frame
37
+ new(cycles, cycles.to_f / region.clock_hz, 0, region.clock_hz)
38
+ end
39
+
40
+ def self.display(refresh, clock_hz)
41
+ refresh = DEFAULT_REFRESH unless refresh.positive?
42
+ cycles = clock_hz / refresh
43
+ new(cycles, cycles.to_f / clock_hz, refresh, clock_hz)
44
+ end
45
+
46
+ def initialize(base_cycles, seconds, refresh, clock_hz)
47
+ @base_cycles = base_cycles
48
+ @seconds = seconds
49
+ @refresh = refresh
50
+ @clock_hz = clock_hz
51
+ @trim = 0.0
52
+ end
53
+
54
+ # The cycles for the next frame, steering the audio queue's `level`
55
+ # towards `target` (both in seconds).
56
+ def cycles(level, target)
57
+ error = (target - level) / target
58
+ error = 1.0 if error > 1.0
59
+ error = -1.0 if error < -1.0
60
+ @trim += INTEGRAL * error
61
+ @trim = MAX_TRIM if @trim > MAX_TRIM
62
+ @trim = -MAX_TRIM if @trim < -MAX_TRIM
63
+ (@base_cycles * (1.0 + @trim + (PROPORTIONAL * error))).round
64
+ end
65
+
66
+ # Sizes the frames to `frames` that lasted `elapsed` seconds.
67
+ def refit(frames, elapsed)
68
+ return unless frames.positive? && elapsed.positive?
69
+ return if ((frames / elapsed) - @refresh).abs > @refresh * REFIT
70
+
71
+ @base_cycles = (@clock_hz * elapsed / frames).round
72
+ @seconds = @base_cycles.to_f / @clock_hz
73
+ end
74
+
75
+ # Whether `frames` presented in `elapsed` seconds kept to the display.
76
+ def vsync_holds?(frames, elapsed)
77
+ return true unless elapsed.positive?
78
+
79
+ frames / elapsed <= @refresh * VSYNC_SLACK
80
+ end
81
+ end
82
+ end
83
+ end