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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +10 -9
- data/README.md +24 -10
- data/doc/pinned-behaviour.md +131 -30
- data/exe/badline-ruby +20 -24
- data/lib/badline/address_bus.rb +10 -1
- data/lib/badline/audio/cli.rb +18 -5
- data/lib/badline/audio/console.rb +2 -1
- data/lib/badline/audio.rb +0 -3
- data/lib/badline/computer.rb +6 -3
- data/lib/badline/cpu/boundary.rb +54 -0
- data/lib/badline/cpu/core.rb +165 -0
- data/lib/badline/cpu.rb +3 -214
- data/lib/badline/drive1541/cpu.rb +29 -0
- data/lib/badline/drive1541.rb +1 -0
- data/lib/badline/ffi.rb +157 -0
- data/lib/badline/frontend/app.rb +225 -0
- data/lib/badline/frontend/audio_sink.rb +72 -0
- data/lib/badline/frontend/boot.rb +52 -0
- data/lib/badline/frontend/controls.rb +163 -0
- data/lib/badline/frontend/drive_led.rb +51 -0
- data/lib/badline/frontend/frame_rate.rb +83 -0
- data/lib/badline/frontend/gamepads.rb +76 -0
- data/lib/badline/frontend/keys.rb +48 -0
- data/lib/badline/frontend/pacer.rb +100 -0
- data/lib/badline/frontend/pad_port.rb +61 -0
- data/lib/badline/frontend/screen.rb +65 -0
- data/lib/badline/frontend/screenshot.rb +22 -0
- data/lib/badline/frontend/snapshots.rb +60 -0
- data/lib/badline/frontend/sound.rb +153 -0
- data/lib/badline/frontend/timeline.rb +171 -0
- data/lib/badline/frontend.rb +18 -0
- data/lib/badline/options/event.rb +94 -0
- data/lib/badline/options/help.rb +78 -0
- data/lib/badline/options/option.rb +29 -0
- data/lib/badline/options/table.rb +62 -0
- data/lib/badline/options.rb +181 -145
- data/lib/badline/reu/dma/saved_state.rb +50 -0
- data/lib/badline/reu/dma.rb +56 -47
- data/lib/badline/reu/saved_state.rb +2 -1
- data/lib/badline/reu.rb +19 -10
- data/lib/badline/sdl.rb +123 -51
- data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
- data/lib/badline/sid/waveform.rb +2 -3
- data/lib/badline/snapshot/state.rb +1 -1
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/bank.rb +21 -0
- data/lib/badline/vic.rb +15 -6
- metadata +26 -19
- data/.claude/hooks/session-start.sh +0 -57
- data/.claude/settings.json +0 -14
- data/lib/badline/audio/sdl_sink.rb +0 -70
- data/lib/badline/audio/stream.rb +0 -123
- data/lib/badline/gui/application.rb +0 -243
- data/lib/badline/gui/drive_led_pane.rb +0 -29
- data/lib/badline/gui/gamepads.rb +0 -104
- data/lib/badline/gui/joy_map.rb +0 -27
- data/lib/badline/gui/key_map.rb +0 -39
- data/lib/badline/gui/palette.rb +0 -35
- data/lib/badline/gui/pane.rb +0 -36
- data/lib/badline/gui/screen_pane.rb +0 -46
- data/lib/badline/gui/snapshots.rb +0 -67
- data/lib/badline/gui/window.rb +0 -61
- data/lib/badline/gui.rb +0 -14
- data/lib/badline/sdl/events.rb +0 -49
- data/lib/badline/sdl/functions.rb +0 -74
- 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
|