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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Decides how many cycles each frame clocks and how long it waits.
|
|
6
|
+
#
|
|
7
|
+
# With vsync, a frame lasts one display refresh (see FrameRate), and
|
|
8
|
+
# presenting it waits for the display, so #wait has nothing left to do.
|
|
9
|
+
# While the sound plays, each frame's cycles are trimmed to keep the
|
|
10
|
+
# audio queue at Sound::AHEAD. If presenting doesn't wait, as when the
|
|
11
|
+
# display's driver has vsync off, #check falls back to the timer, and
|
|
12
|
+
# until then the sound holds any frame that would take its queue past
|
|
13
|
+
# SAFETY, so no samples are dropped. #measure fits the frames to the
|
|
14
|
+
# rate the display presents at, measured over the whole run.
|
|
15
|
+
#
|
|
16
|
+
# Without vsync, a frame is one of the machine's frames, 20 ms on PAL.
|
|
17
|
+
# With the sound playing, #wait lasts until the audio queue is down to
|
|
18
|
+
# Sound::AHEAD; otherwise it waits out the rest of the frame.
|
|
19
|
+
class Pacer
|
|
20
|
+
# The frame after which the app first checks that vsync holds.
|
|
21
|
+
EARLY_CHECK = 10
|
|
22
|
+
SAFETY = Sound::AHEAD * 2
|
|
23
|
+
|
|
24
|
+
attr_reader :rate
|
|
25
|
+
|
|
26
|
+
def initialize(paced:, vsync:, verbose:, region: Region::PAL)
|
|
27
|
+
@paced = paced
|
|
28
|
+
@verbose = verbose
|
|
29
|
+
@vsync = paced && vsync
|
|
30
|
+
@region = region
|
|
31
|
+
@rate = FrameRate.machine(region)
|
|
32
|
+
@deadline = 0.0
|
|
33
|
+
@measured_frames = 0
|
|
34
|
+
@measured_seconds = 0.0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def vsync? = @vsync
|
|
38
|
+
|
|
39
|
+
# Fits the frames to the display's refresh rate, once the window has
|
|
40
|
+
# been opened with vsync.
|
|
41
|
+
def fit(refresh)
|
|
42
|
+
@rate = FrameRate.display(refresh, @region.clock_hz)
|
|
43
|
+
puts "Display #{@rate.refresh} Hz, vsync: #{@rate.base_cycles} cycles a frame" if @verbose
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def start(at)
|
|
47
|
+
@deadline = at
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cycles(sound)
|
|
51
|
+
return @rate.base_cycles unless @vsync && sound.playing?
|
|
52
|
+
|
|
53
|
+
@rate.cycles(sound.level, Sound::AHEAD)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wait(sound)
|
|
57
|
+
return unless @paced
|
|
58
|
+
|
|
59
|
+
if sound.playing?
|
|
60
|
+
sound.wait(@vsync ? SAFETY : Sound::AHEAD)
|
|
61
|
+
elsif !@vsync
|
|
62
|
+
wait_for_timer
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Adds `frames` presented over `elapsed` seconds to the measure of the
|
|
67
|
+
# display's rate, and fits the frames to the rate measured so far.
|
|
68
|
+
def measure(frames, elapsed)
|
|
69
|
+
return unless @vsync
|
|
70
|
+
|
|
71
|
+
@measured_frames += frames
|
|
72
|
+
@measured_seconds += elapsed
|
|
73
|
+
@rate.refit(@measured_frames, @measured_seconds)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Falls back to the timer if the last `frames`, presented over
|
|
77
|
+
# `elapsed` seconds up to `at`, didn't wait for the display.
|
|
78
|
+
def check(frames, elapsed, at)
|
|
79
|
+
return if !@vsync || @rate.vsync_holds?(frames, elapsed)
|
|
80
|
+
|
|
81
|
+
puts "Vsync doesn't hold the display's #{@rate.refresh} Hz, so a timer paces the frames instead." if @verbose
|
|
82
|
+
@vsync = false
|
|
83
|
+
@deadline = at
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def wait_for_timer
|
|
89
|
+
@deadline += @rate.seconds
|
|
90
|
+
started = now
|
|
91
|
+
@deadline = started if @deadline < started - @rate.seconds
|
|
92
|
+
left = @deadline - started
|
|
93
|
+
SDL.SDL_Delay((left * 1000).to_i) if left > 0.001
|
|
94
|
+
nil while now < @deadline
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# One game controller's state wired onto a joystick: the D-pad and the
|
|
6
|
+
# left stick both steer, and every face and shoulder button fires. It
|
|
7
|
+
# only releases directions it pressed itself, so the keyboard's joystick
|
|
8
|
+
# keys keep working with a controller connected.
|
|
9
|
+
class PadPort
|
|
10
|
+
DEADZONE = 8_000
|
|
11
|
+
|
|
12
|
+
# SDL_GameControllerButton values.
|
|
13
|
+
BUTTON_COUNT = 15
|
|
14
|
+
BUTTONS = {
|
|
15
|
+
up: [11], down: [12], left: [13], right: [14],
|
|
16
|
+
fire: [0, 1, 2, 3, 9, 10] # A, B, X, Y and the shoulders
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# SDL_GameControllerAxis values, and the sign that steers each way.
|
|
20
|
+
LEFT_X = 0
|
|
21
|
+
LEFT_Y = 1
|
|
22
|
+
AXES = { up: [LEFT_Y, -1], down: [LEFT_Y, 1], left: [LEFT_X, -1], right: [LEFT_X, 1] }.freeze
|
|
23
|
+
|
|
24
|
+
DIRECTIONS = %i[up down left right fire].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :pressed
|
|
27
|
+
|
|
28
|
+
def initialize(joystick)
|
|
29
|
+
@joystick = joystick
|
|
30
|
+
@pressed = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The directions a controller holds, from its `buttons` (0 or 1 for
|
|
34
|
+
# each SDL button) and its left stick.
|
|
35
|
+
def self.active(buttons, left_x, left_y)
|
|
36
|
+
DIRECTIONS.select do |direction|
|
|
37
|
+
BUTTONS[direction].any? { |button| buttons[button] != 0 } ||
|
|
38
|
+
tilted?(direction, left_x, left_y)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.tilted?(direction, left_x, left_y)
|
|
43
|
+
return false if direction == :fire
|
|
44
|
+
|
|
45
|
+
axis, sign = AXES[direction]
|
|
46
|
+
(axis == LEFT_X ? left_x : left_y) * sign > DEADZONE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Presses what the controller holds and releases what it let go of.
|
|
50
|
+
def update(active)
|
|
51
|
+
@pressed.each { |direction| @joystick.release(direction) unless active.include?(direction) }
|
|
52
|
+
active.each { |direction| @joystick.press(direction) }
|
|
53
|
+
@pressed = active
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def release
|
|
57
|
+
update([])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Draws on an SDL renderer in the C64's colours and its character ROM's
|
|
6
|
+
# font: filled boxes, lines and text. The font is the ROM's second set,
|
|
7
|
+
# with both cases, turned into a texture once: 16 by 16 glyphs of 8 by 8
|
|
8
|
+
# pixels, white where the glyph is set and clear elsewhere, tinted to
|
|
9
|
+
# the colour asked for as each glyph is drawn.
|
|
10
|
+
class Painter
|
|
11
|
+
GLYPH = 8
|
|
12
|
+
ATLAS = GLYPH * 16
|
|
13
|
+
|
|
14
|
+
# An opaque white pixel, in the low half of a word and in the high
|
|
15
|
+
# half, where it only fits a signed 64-bit word as a negative number.
|
|
16
|
+
SET = 0xffffffff
|
|
17
|
+
SET_HIGH = -0x1_0000_0000
|
|
18
|
+
|
|
19
|
+
SPACE = 0x20
|
|
20
|
+
|
|
21
|
+
# Icons drawn in the C64's 8 by 8 cells, which take the places of the
|
|
22
|
+
# first reversed glyphs, from ICON on.
|
|
23
|
+
ICON = 0x80
|
|
24
|
+
ICONS = {
|
|
25
|
+
play: %w[.X...... .XX..... .XXX.... .XXXX... .XXXX... .XXX.... .XX..... .X......],
|
|
26
|
+
pause: %w[........ .XX..XX. .XX..XX. .XX..XX. .XX..XX. .XX..XX. .XX..XX. ........],
|
|
27
|
+
previous: %w[X.....X. X....XX. X...XXX. X..XXXX. X..XXXX. X...XXX. X....XX. X.....X.],
|
|
28
|
+
next: %w[.X.....X .XX....X .XXX...X .XXXX..X .XXXX..X .XXX...X .XX....X .X.....X],
|
|
29
|
+
left: %w[........ ....X... ...XX... ..XXX... ..XXX... ...XX... ....X... ........],
|
|
30
|
+
right: %w[........ ...X.... ...XX... ...XXX.. ...XXX.. ...XX... ...X.... ........]
|
|
31
|
+
}.freeze
|
|
32
|
+
ICON_NAMES = ICONS.keys.freeze
|
|
33
|
+
|
|
34
|
+
# The screen code of each printable ASCII character in the second set.
|
|
35
|
+
# The rest show as a question mark.
|
|
36
|
+
SCREEN_CODES = Array.new(128) do |ascii|
|
|
37
|
+
if ascii.between?(0x20, 0x3f) || ascii.between?(0x41, 0x5a) then ascii
|
|
38
|
+
elsif ascii == 0x40 then 0
|
|
39
|
+
elsif ascii.between?(0x61, 0x7a) then ascii - 0x60
|
|
40
|
+
elsif ascii == 0x5b then 27
|
|
41
|
+
elsif ascii == 0x5d then 29
|
|
42
|
+
elsif ascii == 0x5e then 30
|
|
43
|
+
elsif ascii == 0x5f then 100
|
|
44
|
+
else 63
|
|
45
|
+
end
|
|
46
|
+
end.freeze
|
|
47
|
+
|
|
48
|
+
def initialize(renderer)
|
|
49
|
+
@renderer = renderer
|
|
50
|
+
@font = SDL.SDL_CreateTexture(renderer, SDL::PIXELFORMAT_ARGB8888, SDL::TEXTUREACCESS_STREAMING, ATLAS, ATLAS)
|
|
51
|
+
SDL.SDL_SetTextureBlendMode(@font, SDL::BLENDMODE_BLEND)
|
|
52
|
+
load_font
|
|
53
|
+
SDL.rect_w(SDL.glyph_rect, GLYPH)
|
|
54
|
+
SDL.rect_h(SDL.glyph_rect, GLYPH)
|
|
55
|
+
@tint = -1
|
|
56
|
+
@size = 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def close = SDL.SDL_DestroyTexture(@font)
|
|
60
|
+
|
|
61
|
+
def box(left, top, width, height, rgb)
|
|
62
|
+
@size = 0
|
|
63
|
+
rect = SDL.rect
|
|
64
|
+
SDL.rect_x(rect, left)
|
|
65
|
+
SDL.rect_y(rect, top)
|
|
66
|
+
SDL.rect_w(rect, width)
|
|
67
|
+
SDL.rect_h(rect, height)
|
|
68
|
+
pen(rgb)
|
|
69
|
+
SDL.SDL_RenderFillRect(@renderer, rect)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Joins the points, each made by Painter.point, with lines.
|
|
73
|
+
def polyline(points, rgb)
|
|
74
|
+
pen(rgb)
|
|
75
|
+
SDL.SDL_RenderDrawLines(@renderer, points, points.size)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A point for #polyline: an SDL_Point, two 32-bit ints packed into one
|
|
79
|
+
# of the words an Integer Array hands to C, x in the low half.
|
|
80
|
+
def self.point(left, top) = left | (top << 32)
|
|
81
|
+
|
|
82
|
+
# Draws the text with its top left at `left` and `top`, each glyph
|
|
83
|
+
# `scale` times its size, and returns the width drawn.
|
|
84
|
+
def text(left, top, string, rgb, scale: 1)
|
|
85
|
+
tint(rgb)
|
|
86
|
+
place(top, GLYPH * scale)
|
|
87
|
+
x = left
|
|
88
|
+
string.each_char do |char|
|
|
89
|
+
code = screen_code(char)
|
|
90
|
+
glyph(code, x) unless code == SPACE
|
|
91
|
+
x += @size
|
|
92
|
+
end
|
|
93
|
+
x - left
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.width(string, scale: 1) = string.length * GLYPH * scale
|
|
97
|
+
|
|
98
|
+
def icon(name, left, top, rgb, scale: 1)
|
|
99
|
+
tint(rgb)
|
|
100
|
+
place(top, GLYPH * scale)
|
|
101
|
+
glyph(ICON + ICON_NAMES.index(name), left)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def screen_code(char)
|
|
107
|
+
ascii = char.ord
|
|
108
|
+
ascii < 128 ? SCREEN_CODES[ascii] : 63
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Sets the row and size glyphs go at, which a run of text shares.
|
|
112
|
+
def place(top, size)
|
|
113
|
+
to = SDL.rect
|
|
114
|
+
SDL.rect_y(to, top)
|
|
115
|
+
return if size == @size
|
|
116
|
+
|
|
117
|
+
@size = size
|
|
118
|
+
SDL.rect_w(to, size)
|
|
119
|
+
SDL.rect_h(to, size)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def glyph(code, left)
|
|
123
|
+
from = SDL.glyph_rect
|
|
124
|
+
SDL.rect_x(from, (code % 16) * GLYPH)
|
|
125
|
+
SDL.rect_y(from, (code / 16) * GLYPH)
|
|
126
|
+
to = SDL.rect
|
|
127
|
+
SDL.rect_x(to, left)
|
|
128
|
+
SDL.SDL_RenderCopy(@renderer, @font, from, to)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pen(rgb)
|
|
132
|
+
SDL.SDL_SetRenderDrawColor(@renderer, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def tint(rgb)
|
|
136
|
+
return if rgb == @tint
|
|
137
|
+
|
|
138
|
+
@tint = rgb
|
|
139
|
+
SDL.SDL_SetTextureColorMod(@font, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Two pixels to a word, the left one in the low half, as Screen packs
|
|
143
|
+
# them.
|
|
144
|
+
def load_font
|
|
145
|
+
rom = ROM.read("character.rom")
|
|
146
|
+
words = Array.new(ATLAS * ATLAS / 2, 0)
|
|
147
|
+
256.times { |code| unpack_glyph(rom[0x800 + (code * GLYPH), GLYPH], code, words) }
|
|
148
|
+
ICON_NAMES.each_with_index { |name, index| unpack_glyph(icon_rows(name), ICON + index, words) }
|
|
149
|
+
rect = SDL.glyph_rect
|
|
150
|
+
SDL.rect_x(rect, 0)
|
|
151
|
+
SDL.rect_y(rect, 0)
|
|
152
|
+
SDL.rect_w(rect, ATLAS)
|
|
153
|
+
SDL.rect_h(rect, ATLAS)
|
|
154
|
+
SDL.SDL_UpdateTexture(@font, rect, words, ATLAS * 4)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def icon_rows(name)
|
|
158
|
+
ICONS.fetch(name).map { |row| row.tr(".X", "01").to_i(2) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def unpack_glyph(rows, code, words)
|
|
162
|
+
GLYPH.times do |row|
|
|
163
|
+
bits = rows[row]
|
|
164
|
+
y = ((code / 16) * GLYPH) + row
|
|
165
|
+
x = (code % 16) * GLYPH
|
|
166
|
+
4.times do |pair|
|
|
167
|
+
left = bits[7 - (pair * 2)] == 1 ? SET : 0
|
|
168
|
+
right = bits[6 - (pair * 2)] == 1 ? SET_HIGH : 0
|
|
169
|
+
words[(((y * ATLAS) + x) / 2) + pair] = left | right
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# What the SID player's window does with an action a key or a click asks
|
|
6
|
+
# for: one that picks or steps through the views or the SID models, or
|
|
7
|
+
# picks the SID shown, changes the PlayerState, and any other goes on to
|
|
8
|
+
# the jukebox.
|
|
9
|
+
class PlayerCommands
|
|
10
|
+
VIEWS = PlayerHeader::VIEWS
|
|
11
|
+
CHIPS = PlayerHeader::CHIPS
|
|
12
|
+
SIDS = SIDView::SIDS
|
|
13
|
+
|
|
14
|
+
def initialize(state)
|
|
15
|
+
@state = state
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the action for the jukebox, or nil when the window has
|
|
19
|
+
# handled it.
|
|
20
|
+
def handle(action)
|
|
21
|
+
state = @state
|
|
22
|
+
if action == :view then state.view = (state.view + 1) % VIEWS.size
|
|
23
|
+
elsif action == :chip then state.chip = (state.chip + 1) % CHIPS.size
|
|
24
|
+
elsif VIEWS.include?(action) then state.view = VIEWS.index(action)
|
|
25
|
+
elsif CHIPS.include?(action) then state.chip = CHIPS.index(action)
|
|
26
|
+
elsif SIDS.include?(action) then state.sid = SIDS.index(action)
|
|
27
|
+
else return action
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The bottom of the SID player's window: how far the subtune has played,
|
|
6
|
+
# on a bar a click seeks along, marked with where a seek is headed, and the buttons that pause and step
|
|
7
|
+
# through the queue's tunes and the tune's subtunes and turn the queue's
|
|
8
|
+
# modes on and off.
|
|
9
|
+
class PlayerFooter
|
|
10
|
+
HEIGHT = 44
|
|
11
|
+
WIDTH = PlayerHeader::WIDTH
|
|
12
|
+
CLOCK = "00:00 / 00:00"
|
|
13
|
+
BAR_LEFT = 16
|
|
14
|
+
BAR_WIDTH = WIDTH - 32 - (CLOCK.length * Painter::GLYPH) - 8
|
|
15
|
+
TOGGLES = ["SHUFFLE", "LOOP", "ALL SUBTUNES"].freeze
|
|
16
|
+
TOGGLE_ACTIONS = %i[shuffle loop all_subtunes].freeze
|
|
17
|
+
TOGGLE_NOTES = ["shuffle", "loop", "all subtunes"].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(painter, buttons)
|
|
20
|
+
@painter = painter
|
|
21
|
+
@buttons = buttons
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def draw(state, top)
|
|
25
|
+
draw_progress(state, top + 4)
|
|
26
|
+
row = top + 18
|
|
27
|
+
x = 12
|
|
28
|
+
x += @buttons.icon(x, row, :previous, :previous, scale: 2) + 4
|
|
29
|
+
x += @buttons.icon(x, row, state.paused? ? :play : :pause, :pause, scale: 2) + 4
|
|
30
|
+
x += @buttons.icon(x, row, :next, :next, scale: 2) + 8
|
|
31
|
+
unless state.tunes.zero?
|
|
32
|
+
x += @painter.text(x, row + 6, "TUNE #{state.tune}/#{state.tunes}", SIDView::BRIGHT) + 24
|
|
33
|
+
draw_subtunes(state, x, row + 4)
|
|
34
|
+
end
|
|
35
|
+
draw_toggles(state, row + 4)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The seconds into the subtune a click `left` along the bar asks for.
|
|
39
|
+
def self.seek(left, length) = ((left - BAR_LEFT).to_f / BAR_WIDTH).clamp(0.0, 1.0) * length
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def draw_subtunes(state, left, row)
|
|
44
|
+
x = left + @painter.text(left, row + 2, "SUBTUNE", SIDView::TEXT) + 4
|
|
45
|
+
x += @buttons.icon(x, row, :left, :previous_subtune)
|
|
46
|
+
x += @painter.text(x + 2, row + 2, "#{state.subtune}/#{state.subtunes}", SIDView::BRIGHT) + 4
|
|
47
|
+
@buttons.icon(x, row, :right, :next_subtune)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def draw_progress(state, top)
|
|
51
|
+
played = state.played
|
|
52
|
+
width = BAR_WIDTH
|
|
53
|
+
@buttons.area([BAR_LEFT, top - 2, width, 12], :seek)
|
|
54
|
+
@painter.box(BAR_LEFT, top + 2, width, 4, SIDView::BOX)
|
|
55
|
+
draw_target(state, top) if state.seeking?
|
|
56
|
+
@painter.box(BAR_LEFT, top + 2, along(state, played), 4, SIDView::BRIGHT)
|
|
57
|
+
@painter.text(WIDTH - 16 - Painter.width(CLOCK), top, "#{clock(played)} / #{clock(state.length)}",
|
|
58
|
+
SIDView::TEXT)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def draw_target(state, top)
|
|
62
|
+
target = along(state, state.target)
|
|
63
|
+
@painter.box(BAR_LEFT, top + 2, target, 4, SIDView::DIM)
|
|
64
|
+
@painter.box(BAR_LEFT + [target, BAR_WIDTH - 1].min, top - 1, 1, 10, SIDView::BRIGHT)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# How far along the bar `seconds` into the subtune is.
|
|
68
|
+
def along(state, seconds)
|
|
69
|
+
length = state.length
|
|
70
|
+
length.positive? ? (BAR_WIDTH * seconds / length).round.clamp(0, BAR_WIDTH) : 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def draw_toggles(state, row)
|
|
74
|
+
x = PlayerHeader.right_aligned(TOGGLES)
|
|
75
|
+
TOGGLES.each_with_index do |label, toggle|
|
|
76
|
+
x += @buttons.text(x, row, label, TOGGLE_ACTIONS[toggle], on: state.on?(TOGGLE_NOTES[toggle])) + 4
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clock(seconds)
|
|
81
|
+
whole = seconds.floor
|
|
82
|
+
format("%<minutes>d:%<seconds>02d", minutes: whole / 60, seconds: whole % 60)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The top of the SID player's window: the tune's name, author and
|
|
6
|
+
# release, the STIL credit for what the subtune covers at the moment, a
|
|
7
|
+
# warning when there is one, and the buttons for the views and the SID
|
|
8
|
+
# model.
|
|
9
|
+
class PlayerHeader
|
|
10
|
+
WIDTH = 640
|
|
11
|
+
VIEW_NAMES = %w[VISUALIZER SID INFO].freeze
|
|
12
|
+
VIEWS = %i[visualizer sid info].freeze
|
|
13
|
+
CHIP_NAMES = %w[AUTO 6581 8580].freeze
|
|
14
|
+
CHIPS = %i[auto mos6581 mos8580].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(painter, buttons)
|
|
17
|
+
@painter = painter
|
|
18
|
+
@buttons = buttons
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Underlines the models playing while AUTO picks them.
|
|
22
|
+
def draw(state, tune, playing_models, credit)
|
|
23
|
+
unless tune.nil?
|
|
24
|
+
@painter.text(16, 8, fit(tune.name, 26), SIDView::BRIGHT, scale: 2)
|
|
25
|
+
@painter.text(16, 28, fit([tune.author, tune.released].reject(&:empty?).join(" - "), 50), SIDView::TEXT)
|
|
26
|
+
@painter.text(16, 40, fit(credit, 76), SIDView::BRIGHT)
|
|
27
|
+
@painter.text(16, 52, warning(state), PlayerWindow::WARNING)
|
|
28
|
+
end
|
|
29
|
+
draw_views(state)
|
|
30
|
+
draw_chips(state, playing_models)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.right_aligned(labels) = WIDTH - 12 - labels.sum { |label| Painter.width(label) + 8 }
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def warning(state) = state.on?("below real time") ? "Running below real time" : ""
|
|
38
|
+
|
|
39
|
+
def draw_views(state)
|
|
40
|
+
x = PlayerHeader.right_aligned(VIEW_NAMES)
|
|
41
|
+
VIEW_NAMES.each_with_index do |name, view|
|
|
42
|
+
x += @buttons.text(x, 6, name, VIEWS[view], on: view == state.view) + 4
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def draw_chips(state, playing_models)
|
|
47
|
+
x = PlayerHeader.right_aligned(CHIP_NAMES)
|
|
48
|
+
CHIP_NAMES.each_with_index do |name, chip|
|
|
49
|
+
width = @buttons.text(x, 24, name, CHIPS[chip], on: chip == state.chip)
|
|
50
|
+
if state.chip.zero? && playing_models.include?(CHIPS[chip])
|
|
51
|
+
@painter.box(x + 2, 37, width - 4, 1, SIDView::BRIGHT)
|
|
52
|
+
end
|
|
53
|
+
x += width + 4
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def fit(text, chars) = text.length > chars ? "#{text[0, chars - 2]}.." : text
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SDL window the SID player draws in, and its drawing: the header,
|
|
6
|
+
# the body of the view picked, and the footer, at the height the view
|
|
7
|
+
# needs, in logical pixels scaled up twice.
|
|
8
|
+
class PlayerScreen
|
|
9
|
+
WIDTH = PlayerHeader::WIDTH
|
|
10
|
+
HEADER = 64
|
|
11
|
+
SCALE = 2
|
|
12
|
+
TITLE = "Badline"
|
|
13
|
+
BACKGROUND = Screen::COLORS[6]
|
|
14
|
+
|
|
15
|
+
attr_reader :buttons, :info
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@window = nil
|
|
19
|
+
@renderer = nil
|
|
20
|
+
@painter = nil
|
|
21
|
+
@buttons = nil
|
|
22
|
+
@info = nil
|
|
23
|
+
@sids = 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def open?
|
|
27
|
+
!@window.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def open(view)
|
|
31
|
+
SDL.SDL_InitSubSystem(SDL::INIT_VIDEO | SDL::INIT_EVENTS)
|
|
32
|
+
SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
|
|
33
|
+
@window = SDL.SDL_CreateWindow(TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
|
|
34
|
+
WIDTH * SCALE, height(view) * SCALE, SDL::WINDOW_RESIZABLE)
|
|
35
|
+
@renderer = SDL.SDL_CreateRenderer(@window, -1, SDL::RENDERER_ACCELERATED)
|
|
36
|
+
SDL.SDL_RenderSetLogicalSize(@renderer, WIDTH, height(view))
|
|
37
|
+
build
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def close
|
|
41
|
+
return unless open?
|
|
42
|
+
|
|
43
|
+
@painter.close
|
|
44
|
+
SDL.SDL_DestroyRenderer(@renderer)
|
|
45
|
+
SDL.SDL_DestroyWindow(@window)
|
|
46
|
+
SDL.SDL_QuitSubSystem(SDL::INIT_VIDEO | SDL::INIT_EVENTS)
|
|
47
|
+
@window = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fits the window to the view's height.
|
|
51
|
+
def resize(view)
|
|
52
|
+
SDL.SDL_SetWindowSize(@window, WIDTH * SCALE, height(view) * SCALE)
|
|
53
|
+
SDL.SDL_RenderSetLogicalSize(@renderer, WIDTH, height(view))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fits the window to a tune on `sids` SIDs, whose SID view is taller
|
|
57
|
+
# than one on a single SID.
|
|
58
|
+
def fit_sids(view, sids)
|
|
59
|
+
return if sids == @sids
|
|
60
|
+
|
|
61
|
+
@sids = sids
|
|
62
|
+
resize(view) if open?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names the tune in the window's title.
|
|
66
|
+
def title(name)
|
|
67
|
+
SDL.SDL_SetWindowTitle(@window, name.empty? ? TITLE : "#{name} - #{TITLE}") if open?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Draws a frame of the player: `player` and `history` are the SIDs',
|
|
71
|
+
# nil before anything plays, and `credit` the header's STIL credit.
|
|
72
|
+
def draw(state, player, history, tune, credit)
|
|
73
|
+
return unless open?
|
|
74
|
+
|
|
75
|
+
SDL.SDL_SetRenderDrawColor(@renderer, (BACKGROUND >> 16) & 0xff, (BACKGROUND >> 8) & 0xff,
|
|
76
|
+
BACKGROUND & 0xff, 255)
|
|
77
|
+
SDL.SDL_RenderClear(@renderer)
|
|
78
|
+
@buttons.forget
|
|
79
|
+
@top.draw(state, tune, player.nil? ? [] : player.stereo.models, credit)
|
|
80
|
+
player.nil? ? draw_empty(state.view) : draw_body(state, player, history)
|
|
81
|
+
@bottom.draw(state, height(state.view) - PlayerFooter::HEIGHT)
|
|
82
|
+
SDL.SDL_RenderPresent(@renderer)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def height(view) = HEADER + body_height(view) + PlayerFooter::HEIGHT
|
|
88
|
+
|
|
89
|
+
def body_height(view) = view == 1 ? SIDView.height(@sids) : VisualizerView::HEIGHT
|
|
90
|
+
|
|
91
|
+
def build
|
|
92
|
+
@painter = Painter.new(@renderer)
|
|
93
|
+
@buttons = Buttons.new(@painter)
|
|
94
|
+
@top = PlayerHeader.new(@painter, @buttons)
|
|
95
|
+
@bottom = PlayerFooter.new(@painter, @buttons)
|
|
96
|
+
@visualizer = VisualizerView.new(@painter, HEADER + 4)
|
|
97
|
+
@sid_view = SIDView.new(@painter, @buttons, HEADER + 4)
|
|
98
|
+
@info = InfoView.new(@painter, @buttons, HEADER + 4)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def draw_body(state, player, history)
|
|
102
|
+
played = state.played
|
|
103
|
+
case state.view
|
|
104
|
+
when 0 then @visualizer.draw(history, played, clock_hz: player.clock_hz)
|
|
105
|
+
when 1 then draw_sid(state.sid, player, history, played)
|
|
106
|
+
else @info.draw
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The SID view of the SID picked, or of the last a tune has.
|
|
111
|
+
def draw_sid(sid, player, history, played)
|
|
112
|
+
models = player.stereo.models
|
|
113
|
+
shown = [sid, models.size - 1].min
|
|
114
|
+
@sid_view.draw(history, played, clock_hz: player.clock_hz, model: models[shown], sid: shown)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def draw_empty(view)
|
|
118
|
+
hint = "Drop .sid files or folders here"
|
|
119
|
+
@painter.text((WIDTH - Painter.width(hint, scale: 2)) / 2, HEADER + (body_height(view) / 2) - 8, hint,
|
|
120
|
+
SIDView::TEXT, scale: 2)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|