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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +135 -42
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +28 -24
  7. data/lib/badline/address_bus/extra_sids.rb +30 -0
  8. data/lib/badline/address_bus/sid_slots.rb +42 -0
  9. data/lib/badline/address_bus.rb +15 -1
  10. data/lib/badline/audio/aiff.rb +2 -2
  11. data/lib/badline/audio/bare_player.rb +23 -12
  12. data/lib/badline/audio/cli.rb +167 -59
  13. data/lib/badline/audio/console.rb +2 -1
  14. data/lib/badline/audio/jukebox.rb +121 -36
  15. data/lib/badline/audio/machine_player.rb +25 -10
  16. data/lib/badline/audio/pcm_writer.rb +10 -6
  17. data/lib/badline/audio/playback.rb +6 -5
  18. data/lib/badline/audio/queued_tune.rb +89 -0
  19. data/lib/badline/audio/renderer.rb +57 -10
  20. data/lib/badline/audio/stereo.rb +97 -0
  21. data/lib/badline/audio/terminal.rb +39 -6
  22. data/lib/badline/audio/wav.rb +1 -1
  23. data/lib/badline/audio.rb +2 -3
  24. data/lib/badline/computer.rb +6 -3
  25. data/lib/badline/cpu/boundary.rb +54 -0
  26. data/lib/badline/cpu/core.rb +165 -0
  27. data/lib/badline/cpu.rb +3 -214
  28. data/lib/badline/drive1541/cpu.rb +29 -0
  29. data/lib/badline/drive1541.rb +1 -0
  30. data/lib/badline/ffi.rb +157 -0
  31. data/lib/badline/frontend/app.rb +225 -0
  32. data/lib/badline/frontend/audio_sink.rb +73 -0
  33. data/lib/badline/frontend/boot.rb +52 -0
  34. data/lib/badline/frontend/buttons.rb +90 -0
  35. data/lib/badline/frontend/controls.rb +163 -0
  36. data/lib/badline/frontend/drive_led.rb +51 -0
  37. data/lib/badline/frontend/filter_view.rb +96 -0
  38. data/lib/badline/frontend/frame_rate.rb +83 -0
  39. data/lib/badline/frontend/gamepads.rb +76 -0
  40. data/lib/badline/frontend/info_view.rb +120 -0
  41. data/lib/badline/frontend/keys.rb +48 -0
  42. data/lib/badline/frontend/pacer.rb +100 -0
  43. data/lib/badline/frontend/pad_port.rb +61 -0
  44. data/lib/badline/frontend/painter.rb +175 -0
  45. data/lib/badline/frontend/player_commands.rb +33 -0
  46. data/lib/badline/frontend/player_footer.rb +86 -0
  47. data/lib/badline/frontend/player_header.rb +60 -0
  48. data/lib/badline/frontend/player_screen.rb +124 -0
  49. data/lib/badline/frontend/player_state.rb +67 -0
  50. data/lib/badline/frontend/player_window.rb +228 -0
  51. data/lib/badline/frontend/scope.rb +42 -0
  52. data/lib/badline/frontend/screen.rb +65 -0
  53. data/lib/badline/frontend/screenshot.rb +22 -0
  54. data/lib/badline/frontend/sid_history.rb +175 -0
  55. data/lib/badline/frontend/sid_view.rb +140 -0
  56. data/lib/badline/frontend/snapshots.rb +60 -0
  57. data/lib/badline/frontend/sound.rb +153 -0
  58. data/lib/badline/frontend/timeline.rb +171 -0
  59. data/lib/badline/frontend/visualizer_view.rb +101 -0
  60. data/lib/badline/frontend.rb +32 -0
  61. data/lib/badline/media/queue.rb +158 -0
  62. data/lib/badline/media.rb +13 -8
  63. data/lib/badline/options/event.rb +94 -0
  64. data/lib/badline/options/help.rb +107 -0
  65. data/lib/badline/options/option.rb +29 -0
  66. data/lib/badline/options/table.rb +64 -0
  67. data/lib/badline/options/validation.rb +54 -0
  68. data/lib/badline/options.rb +204 -149
  69. data/lib/badline/reu/dma/saved_state.rb +50 -0
  70. data/lib/badline/reu/dma.rb +56 -47
  71. data/lib/badline/reu/saved_state.rb +2 -1
  72. data/lib/badline/reu.rb +19 -10
  73. data/lib/badline/sdl.rb +133 -51
  74. data/lib/badline/sid/catch_up.rb +11 -1
  75. data/lib/badline/sid/filter.rb +9 -0
  76. data/lib/badline/sid/voice.rb +6 -0
  77. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  78. data/lib/badline/sid/waveform.rb +22 -8
  79. data/lib/badline/sid.rb +35 -3
  80. data/lib/badline/snapshot/state.rb +1 -1
  81. data/lib/badline/storage/hvsc.rb +41 -0
  82. data/lib/badline/storage/sid_file.rb +45 -25
  83. data/lib/badline/storage/song_lengths.rb +6 -29
  84. data/lib/badline/storage/stil/credit.rb +50 -0
  85. data/lib/badline/storage/stil.rb +126 -0
  86. data/lib/badline/storage.rb +8 -0
  87. data/lib/badline/version.rb +1 -1
  88. data/lib/badline/vic/bank.rb +21 -0
  89. data/lib/badline/vic.rb +15 -6
  90. metadata +49 -19
  91. data/.claude/hooks/session-start.sh +0 -57
  92. data/.claude/settings.json +0 -14
  93. data/lib/badline/audio/sdl_sink.rb +0 -70
  94. data/lib/badline/audio/stream.rb +0 -123
  95. data/lib/badline/gui/application.rb +0 -243
  96. data/lib/badline/gui/drive_led_pane.rb +0 -29
  97. data/lib/badline/gui/gamepads.rb +0 -104
  98. data/lib/badline/gui/joy_map.rb +0 -27
  99. data/lib/badline/gui/key_map.rb +0 -39
  100. data/lib/badline/gui/palette.rb +0 -35
  101. data/lib/badline/gui/pane.rb +0 -36
  102. data/lib/badline/gui/screen_pane.rb +0 -46
  103. data/lib/badline/gui/snapshots.rb +0 -67
  104. data/lib/badline/gui/window.rb +0 -61
  105. data/lib/badline/gui.rb +0 -14
  106. data/lib/badline/sdl/events.rb +0 -49
  107. data/lib/badline/sdl/functions.rb +0 -74
  108. 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