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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/CLAUDE.md +10 -9
- data/README.md +135 -42
- data/doc/pinned-behaviour.md +131 -30
- data/exe/badline-ruby +28 -24
- data/lib/badline/address_bus/extra_sids.rb +30 -0
- data/lib/badline/address_bus/sid_slots.rb +42 -0
- data/lib/badline/address_bus.rb +15 -1
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +167 -59
- data/lib/badline/audio/console.rb +2 -1
- data/lib/badline/audio/jukebox.rb +121 -36
- data/lib/badline/audio/machine_player.rb +25 -10
- data/lib/badline/audio/pcm_writer.rb +10 -6
- data/lib/badline/audio/playback.rb +6 -5
- data/lib/badline/audio/queued_tune.rb +89 -0
- data/lib/badline/audio/renderer.rb +57 -10
- data/lib/badline/audio/stereo.rb +97 -0
- data/lib/badline/audio/terminal.rb +39 -6
- data/lib/badline/audio/wav.rb +1 -1
- data/lib/badline/audio.rb +2 -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 +73 -0
- data/lib/badline/frontend/boot.rb +52 -0
- data/lib/badline/frontend/buttons.rb +90 -0
- data/lib/badline/frontend/controls.rb +163 -0
- data/lib/badline/frontend/drive_led.rb +51 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_rate.rb +83 -0
- data/lib/badline/frontend/gamepads.rb +76 -0
- data/lib/badline/frontend/info_view.rb +120 -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/painter.rb +175 -0
- data/lib/badline/frontend/player_commands.rb +33 -0
- data/lib/badline/frontend/player_footer.rb +86 -0
- data/lib/badline/frontend/player_header.rb +60 -0
- data/lib/badline/frontend/player_screen.rb +124 -0
- data/lib/badline/frontend/player_state.rb +67 -0
- data/lib/badline/frontend/player_window.rb +228 -0
- data/lib/badline/frontend/scope.rb +42 -0
- data/lib/badline/frontend/screen.rb +65 -0
- data/lib/badline/frontend/screenshot.rb +22 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -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/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +32 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +13 -8
- data/lib/badline/options/event.rb +94 -0
- data/lib/badline/options/help.rb +107 -0
- data/lib/badline/options/option.rb +29 -0
- data/lib/badline/options/table.rb +64 -0
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +204 -149
- 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 +133 -51
- data/lib/badline/sid/catch_up.rb +11 -1
- data/lib/badline/sid/filter.rb +9 -0
- data/lib/badline/sid/voice.rb +6 -0
- data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
- data/lib/badline/sid/waveform.rb +22 -8
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/snapshot/state.rb +1 -1
- data/lib/badline/storage/hvsc.rb +41 -0
- data/lib/badline/storage/sid_file.rb +45 -25
- data/lib/badline/storage/song_lengths.rb +6 -29
- data/lib/badline/storage/stil/credit.rb +50 -0
- data/lib/badline/storage/stil.rb +126 -0
- data/lib/badline/storage.rb +8 -0
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/bank.rb +21 -0
- data/lib/badline/vic.rb +15 -6
- metadata +49 -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
data/lib/badline/ffi.rb
ADDED
|
@@ -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
|