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,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