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
|
@@ -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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Draws the SID's filter as the player shows it: its modes, cutoff,
|
|
6
|
+
# resonance and volume, and its response across the audible range on
|
|
7
|
+
# the chip playing, whose cutoff curve the two models don't share.
|
|
8
|
+
class FilterView
|
|
9
|
+
TEXT = SIDView::TEXT
|
|
10
|
+
BRIGHT = SIDView::BRIGHT
|
|
11
|
+
DIM = SIDView::DIM
|
|
12
|
+
BOX = SIDView::BOX
|
|
13
|
+
|
|
14
|
+
MODES = ["LP", "BP", "HP", "3 OFF"].freeze
|
|
15
|
+
|
|
16
|
+
LEFT = SIDView::LEFT
|
|
17
|
+
CURVE_LEFT = 224
|
|
18
|
+
WIDTH = SIDView::WAVE_WIDTH
|
|
19
|
+
HEIGHT = SIDView::PANEL_HEIGHT
|
|
20
|
+
|
|
21
|
+
# The lowest and highest frequencies on the curve's axis.
|
|
22
|
+
LOW_HZ = 30.0
|
|
23
|
+
HIGH_HZ = 16_000.0
|
|
24
|
+
|
|
25
|
+
def initialize(painter)
|
|
26
|
+
@painter = painter
|
|
27
|
+
@curve = Array.new(WIDTH + 1, 0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def draw(history, frame, top, model)
|
|
31
|
+
cutoff = (history.register(frame, 0x15) & 0x07) | (history.register(frame, 0x16) << 3)
|
|
32
|
+
resonance = history.register(frame, 0x17) >> 4
|
|
33
|
+
mode_volume = history.register(frame, 0x18)
|
|
34
|
+
hertz = SID::Filter::W0.fetch(model)[cutoff] / (2 * Math::PI * SID::Filter::SCALE)
|
|
35
|
+
paint = @painter
|
|
36
|
+
paint.text(LEFT, top, "FILTER", BRIGHT)
|
|
37
|
+
x = LEFT + 64
|
|
38
|
+
MODES.each_with_index do |name, bit|
|
|
39
|
+
x += paint.text(x, top, name, mode_volume[bit + 4] == 1 ? BRIGHT : DIM) + 8
|
|
40
|
+
end
|
|
41
|
+
paint.text(LEFT, top + 16, format("CUTOFF $%<cutoff>03X", cutoff:), TEXT)
|
|
42
|
+
paint.text(LEFT, top + 28, format(" %<hertz>d HZ", hertz: hertz.round), TEXT)
|
|
43
|
+
paint.text(LEFT, top + 40, format("RES %<res>X VOL %<vol>X", res: resonance, vol: mode_volume & 0x0f), TEXT)
|
|
44
|
+
draw_curve(hertz, resonance, mode_volume, top)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The gain for the modes selected on a logarithmic axis, with the
|
|
50
|
+
# cutoff marked.
|
|
51
|
+
def draw_curve(cutoff_hertz, resonance, mode_volume, top)
|
|
52
|
+
paint = @painter
|
|
53
|
+
paint.box(CURVE_LEFT, top, WIDTH, HEIGHT, BOX)
|
|
54
|
+
return paint.text(CURVE_LEFT + 8, top + 8, "NO MODE", DIM) if mode_volume.nobits?(0x70)
|
|
55
|
+
|
|
56
|
+
quality = 0.707 + (resonance / 15.0)
|
|
57
|
+
cutoff = [cutoff_hertz, 1.0].max
|
|
58
|
+
points = @curve
|
|
59
|
+
x = 0
|
|
60
|
+
while x <= WIDTH
|
|
61
|
+
hertz = LOW_HZ * ((HIGH_HZ / LOW_HZ)**(x.to_f / WIDTH))
|
|
62
|
+
decibels = gain(hertz / cutoff, quality, mode_volume).clamp(-36.0, 12.0)
|
|
63
|
+
points[x] = Painter.point(CURVE_LEFT + x, top + 8 + (((12.0 - decibels) * (HEIGHT - 12)) / 48.0).round)
|
|
64
|
+
x += 1
|
|
65
|
+
end
|
|
66
|
+
paint.polyline(points, BRIGHT)
|
|
67
|
+
marker = (WIDTH * Math.log(cutoff / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ)).round
|
|
68
|
+
paint.box(CURVE_LEFT + marker, top, 1, HEIGHT, DIM) if marker.between?(0, WIDTH)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The gain in decibels of a two-pole state-variable filter at `ratio`
|
|
72
|
+
# of its cutoff, summing the low-, band- and high-pass outputs chosen.
|
|
73
|
+
def gain(ratio, quality, mode_volume)
|
|
74
|
+
real = 1.0 - (ratio * ratio)
|
|
75
|
+
imaginary = ratio / quality
|
|
76
|
+
denominator = (real * real) + (imaginary * imaginary)
|
|
77
|
+
out_real = 0.0
|
|
78
|
+
out_imaginary = 0.0
|
|
79
|
+
if mode_volume.anybits?(0x10)
|
|
80
|
+
out_real += real / denominator
|
|
81
|
+
out_imaginary -= imaginary / denominator
|
|
82
|
+
end
|
|
83
|
+
if mode_volume.anybits?(0x20)
|
|
84
|
+
out_real += ratio * imaginary / denominator
|
|
85
|
+
out_imaginary += ratio * real / denominator
|
|
86
|
+
end
|
|
87
|
+
if mode_volume.anybits?(0x40)
|
|
88
|
+
out_real -= ratio * ratio * real / denominator
|
|
89
|
+
out_imaginary += ratio * ratio * imaginary / denominator
|
|
90
|
+
end
|
|
91
|
+
magnitude = Math.sqrt((out_real * out_real) + (out_imaginary * out_imaginary))
|
|
92
|
+
20.0 * Math.log([magnitude, 1.0e-6].max) / Math.log(10)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Host game controllers wired onto the control ports: the first
|
|
6
|
+
# controller found drives joystick 2, the port most games read, and a
|
|
7
|
+
# second one joystick 1. PadPort holds the mapping.
|
|
8
|
+
#
|
|
9
|
+
# The controllers are polled every frame, and rescanned whenever SDL
|
|
10
|
+
# reports one added or removed.
|
|
11
|
+
class Gamepads
|
|
12
|
+
PORTS = [2, 1].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(computer, verbose)
|
|
15
|
+
@verbose = verbose
|
|
16
|
+
@controllers = []
|
|
17
|
+
@ports = [PadPort.new(computer.joystick2), PadPort.new(computer.joystick1)]
|
|
18
|
+
@buttons = Array.new(PadPort::BUTTON_COUNT, 0)
|
|
19
|
+
@available = SDL.SDL_InitSubSystem(SDL::INIT_GAMECONTROLLER).zero?
|
|
20
|
+
puts "No game controllers: #{SDL.SDL_GetError}" if @verbose && !@available
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Wires the controllers to another machine's joysticks, which take
|
|
24
|
+
# what they hold on the next poll.
|
|
25
|
+
def computer=(computer)
|
|
26
|
+
@ports.each(&:release)
|
|
27
|
+
@ports = [PadPort.new(computer.joystick2), PadPort.new(computer.joystick1)]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rescan
|
|
31
|
+
return unless @available
|
|
32
|
+
|
|
33
|
+
close
|
|
34
|
+
index = 0
|
|
35
|
+
count = SDL.SDL_NumJoysticks
|
|
36
|
+
while index < count && @controllers.size < PORTS.size
|
|
37
|
+
open_controller(index) if SDL.SDL_IsGameController(index) != 0
|
|
38
|
+
index += 1
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def poll
|
|
43
|
+
@controllers.each_with_index { |controller, index| @ports[index].update(active(controller)) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@controllers.each { |controller| SDL.SDL_GameControllerClose(controller) }
|
|
48
|
+
@controllers = []
|
|
49
|
+
@ports.each(&:release)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def open_controller(index)
|
|
55
|
+
controller = SDL.SDL_GameControllerOpen(index)
|
|
56
|
+
# SDL returns NULL when the controller won't open, which Spinel's FFI
|
|
57
|
+
# reads as nil.
|
|
58
|
+
return if controller.nil?
|
|
59
|
+
|
|
60
|
+
puts "Gamepad on joystick #{PORTS[@controllers.size]}: #{SDL.SDL_GameControllerName(controller)}" if @verbose
|
|
61
|
+
@controllers << controller
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def active(controller)
|
|
65
|
+
buttons = @buttons
|
|
66
|
+
button = 0
|
|
67
|
+
while button < PadPort::BUTTON_COUNT
|
|
68
|
+
buttons[button] = SDL.SDL_GameControllerGetButton(controller, button)
|
|
69
|
+
button += 1
|
|
70
|
+
end
|
|
71
|
+
PadPort.active(buttons, SDL.SDL_GameControllerGetAxis(controller, PadPort::LEFT_X),
|
|
72
|
+
SDL.SDL_GameControllerGetAxis(controller, PadPort::LEFT_Y))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's INFO view: the tune's STIL entry, and the playing
|
|
6
|
+
# subtune's, laid out as STIL.txt lays them out but reflowed to the
|
|
7
|
+
# window's width. A line of STIL's that runs to its width goes on into
|
|
8
|
+
# the next, and a shorter one ends a paragraph. Text longer than the view
|
|
9
|
+
# scrolls, by the wheel, the up and down keys, or a click or drag along
|
|
10
|
+
# the scroll bar beside it.
|
|
11
|
+
class InfoView
|
|
12
|
+
HEIGHT = VisualizerView::HEIGHT
|
|
13
|
+
LINE = 10
|
|
14
|
+
ROWS = HEIGHT / LINE
|
|
15
|
+
LEFT = SIDView::LEFT
|
|
16
|
+
BAR = 6
|
|
17
|
+
BAR_LEFT = PlayerHeader::WIDTH - 16 - BAR
|
|
18
|
+
COLUMNS = (BAR_LEFT - LEFT - 8) / Painter::GLYPH
|
|
19
|
+
LABEL = 9
|
|
20
|
+
|
|
21
|
+
# How long a line of STIL's is when it runs on into the next.
|
|
22
|
+
FULL = 60
|
|
23
|
+
|
|
24
|
+
attr_reader :offset
|
|
25
|
+
|
|
26
|
+
def initialize(painter, buttons, top)
|
|
27
|
+
@painter = painter
|
|
28
|
+
@buttons = buttons
|
|
29
|
+
@top = top
|
|
30
|
+
@lines = []
|
|
31
|
+
@offset = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Shows the tune's fields, then the subtune's under its number, from
|
|
35
|
+
# the top.
|
|
36
|
+
def show(fields, subtune, subtune_fields)
|
|
37
|
+
lines = InfoView.layout(fields)
|
|
38
|
+
lines += ["", "(##{subtune})"] + InfoView.layout(subtune_fields) unless subtune_fields.empty?
|
|
39
|
+
@lines = lines.empty? ? ["No STIL entry for this tune."] : lines
|
|
40
|
+
@offset = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scroll(rows)
|
|
44
|
+
@offset = (@offset + rows).clamp(0, last_offset)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Scrolls to the place `top` points at along the scroll bar.
|
|
48
|
+
def scroll_along(top)
|
|
49
|
+
@offset = ((top - @top).to_f / HEIGHT * @lines.size).round.clamp(0, last_offset)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def draw
|
|
53
|
+
index = 0
|
|
54
|
+
while index < ROWS && @offset + index < @lines.size
|
|
55
|
+
@painter.text(LEFT, @top + (index * LINE), @lines[@offset + index], SIDView::TEXT)
|
|
56
|
+
index += 1
|
|
57
|
+
end
|
|
58
|
+
draw_bar if @lines.size > ROWS
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each field's label, right-aligned as in STIL.txt, before its text
|
|
62
|
+
# reflowed into the columns beside it.
|
|
63
|
+
def self.layout(fields)
|
|
64
|
+
lines = []
|
|
65
|
+
fields.each do |field|
|
|
66
|
+
label = "#{field.name}:".rjust(LABEL - 1)
|
|
67
|
+
paragraphs(field.text).each do |paragraph|
|
|
68
|
+
wrap(paragraph, COLUMNS - LABEL).each do |line|
|
|
69
|
+
lines << "#{label} #{line}"
|
|
70
|
+
label = " " * (LABEL - 1)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
lines
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The text's lines joined where one ran to STIL's width.
|
|
78
|
+
def self.paragraphs(text)
|
|
79
|
+
paragraphs = []
|
|
80
|
+
full = false
|
|
81
|
+
text.split("\n").each do |line|
|
|
82
|
+
if full
|
|
83
|
+
paragraphs[-1] = "#{paragraphs[-1]} #{line.strip}"
|
|
84
|
+
else
|
|
85
|
+
paragraphs << line.strip
|
|
86
|
+
end
|
|
87
|
+
full = line.length >= FULL
|
|
88
|
+
end
|
|
89
|
+
paragraphs
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The text broken at spaces into lines of at most `columns`, and a word
|
|
93
|
+
# longer than that broken where it has to be.
|
|
94
|
+
def self.wrap(text, columns)
|
|
95
|
+
lines = []
|
|
96
|
+
rest = text
|
|
97
|
+
while rest.length > columns
|
|
98
|
+
cut = rest.rindex(" ", columns)
|
|
99
|
+
cut = columns if cut.nil? || cut.zero?
|
|
100
|
+
lines << rest[0, cut].rstrip
|
|
101
|
+
rest = rest[cut, rest.length - cut].lstrip
|
|
102
|
+
end
|
|
103
|
+
lines << rest
|
|
104
|
+
lines
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def last_offset = [@lines.size - ROWS, 0].max
|
|
110
|
+
|
|
111
|
+
def draw_bar
|
|
112
|
+
@buttons.area([BAR_LEFT, @top, BAR, HEIGHT], :scroll)
|
|
113
|
+
@painter.box(BAR_LEFT, @top, BAR, HEIGHT, SIDView::BOX)
|
|
114
|
+
thumb = [HEIGHT * ROWS / @lines.size, 8].max
|
|
115
|
+
place = (HEIGHT - thumb) * @offset / [last_offset, 1].max
|
|
116
|
+
@painter.box(BAR_LEFT, @top + place, BAR, thumb, SIDView::TEXT)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# SDL scancodes follow key positions on a US layout. They map onto the C64
|
|
6
|
+
# keys and, in joystick mode, the joystick directions.
|
|
7
|
+
module Keys
|
|
8
|
+
TAB = 43
|
|
9
|
+
|
|
10
|
+
LETTERS = %i[a b c d e f g h i j k l m n o p q r s t u v w x y z].freeze
|
|
11
|
+
DIGITS = %i[1 2 3 4 5 6 7 8 9 0].freeze
|
|
12
|
+
|
|
13
|
+
OTHERS = {
|
|
14
|
+
40 => :return, 41 => :run_stop, 42 => :delete, 44 => :space,
|
|
15
|
+
45 => :-, 46 => :"=", 49 => :"@", 51 => :";", 52 => :":",
|
|
16
|
+
48 => :up, 53 => :left, 54 => :",", 55 => :".", 56 => :/,
|
|
17
|
+
58 => :f1, 60 => :f3, 62 => :f5, 64 => :f7,
|
|
18
|
+
74 => :clr_home, 75 => :restore, 77 => :£, 79 => :cursor_h, 80 => :cursor_left,
|
|
19
|
+
81 => :cursor_v, 82 => :cursor_up, 85 => :*, 87 => :+,
|
|
20
|
+
224 => :control, 225 => :lshift, 226 => :cbm, 229 => :rshift
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
F9 = 66
|
|
24
|
+
F10 = 67
|
|
25
|
+
F11 = 68
|
|
26
|
+
F12 = 69
|
|
27
|
+
|
|
28
|
+
ARROWS = { 44 => :fire, 228 => :fire, 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
|
|
29
|
+
WASD = { 225 => :fire, 7 => :right, 4 => :left, 22 => :down, 26 => :up }.freeze
|
|
30
|
+
|
|
31
|
+
def self.c64_key(scancode)
|
|
32
|
+
if scancode.between?(4, 29)
|
|
33
|
+
LETTERS[scancode - 4]
|
|
34
|
+
elsif scancode.between?(30, 39)
|
|
35
|
+
DIGITS[scancode - 30]
|
|
36
|
+
else
|
|
37
|
+
OTHERS[scancode] || :none
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.arrows(scancode) = ARROWS[scancode] || :none
|
|
42
|
+
|
|
43
|
+
def self.wasd(scancode) = WASD[scancode] || :none
|
|
44
|
+
|
|
45
|
+
def self.joystick?(scancode) = ARROWS.key?(scancode) || WASD.key?(scancode)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|