badline 0.6.0 → 0.6.1
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 +40 -0
- data/CLAUDE.md +10 -9
- data/README.md +24 -10
- data/doc/pinned-behaviour.md +131 -30
- data/exe/badline-ruby +20 -24
- data/lib/badline/address_bus.rb +10 -1
- data/lib/badline/audio/cli.rb +18 -5
- data/lib/badline/audio/console.rb +2 -1
- data/lib/badline/audio.rb +0 -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 +72 -0
- data/lib/badline/frontend/boot.rb +52 -0
- data/lib/badline/frontend/controls.rb +163 -0
- data/lib/badline/frontend/drive_led.rb +51 -0
- data/lib/badline/frontend/frame_rate.rb +83 -0
- data/lib/badline/frontend/gamepads.rb +76 -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/screen.rb +65 -0
- data/lib/badline/frontend/screenshot.rb +22 -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.rb +18 -0
- data/lib/badline/options/event.rb +94 -0
- data/lib/badline/options/help.rb +78 -0
- data/lib/badline/options/option.rb +29 -0
- data/lib/badline/options/table.rb +62 -0
- data/lib/badline/options.rb +181 -145
- 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 +123 -51
- data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
- data/lib/badline/sid/waveform.rb +2 -3
- data/lib/badline/snapshot/state.rb +1 -1
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/bank.rb +21 -0
- data/lib/badline/vic.rb +15 -6
- metadata +26 -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,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,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
|
|
@@ -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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The visible part of the VIC's display, the crop of the machine's
|
|
6
|
+
# region, repacked for an SDL texture in XRGB8888. Spinel hands an
|
|
7
|
+
# Array of Integers to C as 64-bit words, as badline/ffi does on CRuby,
|
|
8
|
+
# so each word carries two neighbouring pixels, the left one in the low
|
|
9
|
+
# half. The texture is PAL's crop, 384x272, and a region with fewer
|
|
10
|
+
# lines to show sits in the middle of it, between black bands.
|
|
11
|
+
class Screen
|
|
12
|
+
WIDTH = Region::PAL.crop[2]
|
|
13
|
+
HEIGHT = Region::PAL.crop[3]
|
|
14
|
+
ROW_BYTES = WIDTH * 4
|
|
15
|
+
ROW_WORDS = WIDTH / 2
|
|
16
|
+
|
|
17
|
+
COLORS = [
|
|
18
|
+
0x000000, 0xffffff, 0x924a40, 0x84c5cc,
|
|
19
|
+
0x9351b6, 0x72b14b, 0x483aaa, 0xd5df7c,
|
|
20
|
+
0x675200, 0xc33d00, 0xc18178, 0x606060,
|
|
21
|
+
0x8a8a8a, 0xb3ec91, 0x867ade, 0xb3b3b3
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
PAIRS = Array.new(256) { |pair| COLORS[pair & 0x0f] | (COLORS[pair >> 4] << 32) }.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :pixels
|
|
27
|
+
|
|
28
|
+
def initialize(vic)
|
|
29
|
+
@vic = vic
|
|
30
|
+
crop = vic.region.crop
|
|
31
|
+
@col_offset = crop[0]
|
|
32
|
+
@row_offset = crop[1]
|
|
33
|
+
@height = crop[3]
|
|
34
|
+
@band = (HEIGHT - @height) / 2
|
|
35
|
+
@pixels = Array.new(HEIGHT * ROW_WORDS, 0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Repacks only the lines the VIC has changed since the last frame.
|
|
39
|
+
def update
|
|
40
|
+
dirty = @vic.dirty_lines
|
|
41
|
+
row = 0
|
|
42
|
+
while row < @height
|
|
43
|
+
pack_row(row) if dirty[row + @row_offset]
|
|
44
|
+
row += 1
|
|
45
|
+
end
|
|
46
|
+
@vic.clear_dirty_lines!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def pack_row(row)
|
|
52
|
+
display = @vic.display
|
|
53
|
+
pixels = @pixels
|
|
54
|
+
from = ((row + @row_offset) * @vic.width) + @col_offset
|
|
55
|
+
to = (row + @band) * ROW_WORDS
|
|
56
|
+
last = to + ROW_WORDS
|
|
57
|
+
while to < last
|
|
58
|
+
pixels[to] = PAIRS[(display[from] & 0x0f) | ((display[from + 1] & 0x0f) << 4)]
|
|
59
|
+
from += 2
|
|
60
|
+
to += 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# --screenshot's frame: what the renderer drew, read back before it is
|
|
6
|
+
# presented and saved as a BMP.
|
|
7
|
+
module Screenshot
|
|
8
|
+
def self.write(renderer, path)
|
|
9
|
+
SDL.SDL_GetRendererOutputSize(renderer, SDL.output_w, SDL.output_h)
|
|
10
|
+
width = SDL.read_i32(SDL.output_w)
|
|
11
|
+
height = SDL.read_i32(SDL.output_h)
|
|
12
|
+
data = LibC.malloc(width * height * 4)
|
|
13
|
+
SDL.SDL_RenderReadPixels(renderer, nil, SDL::PIXELFORMAT_RGB888, data, width * 4)
|
|
14
|
+
surface = SDL.SDL_CreateRGBSurfaceWithFormatFrom(data, width, height, 32, width * 4, SDL::PIXELFORMAT_RGB888)
|
|
15
|
+
SDL.SDL_SaveBMP_RW(surface, SDL.SDL_RWFromFile(path, "wb"), 1)
|
|
16
|
+
SDL.SDL_FreeSurface(surface)
|
|
17
|
+
LibC.free(data)
|
|
18
|
+
puts "wrote #{path}, #{width}x#{height}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The window's snapshots: F11 saves the machine to a new
|
|
6
|
+
# badline-<date>-<time>.vsf in the working directory, and F12 goes back
|
|
7
|
+
# to the snapshot last saved or opened, in a new machine. --save-snapshot
|
|
8
|
+
# saves one after the last frame.
|
|
9
|
+
class Snapshots
|
|
10
|
+
KEYS = [Keys::F11, Keys::F12].freeze
|
|
11
|
+
|
|
12
|
+
# The machine running, which F12 replaces with the restored one.
|
|
13
|
+
attr_reader :computer
|
|
14
|
+
|
|
15
|
+
def initialize(computer, options)
|
|
16
|
+
@computer = computer
|
|
17
|
+
@last = options.snapshot? ? options.media_path.to_s : ""
|
|
18
|
+
@at_finish = options.save_snapshot
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Saves or restores for F11 or F12, and says whether a restored
|
|
22
|
+
# machine now runs in place of the one before.
|
|
23
|
+
def key(scancode)
|
|
24
|
+
return restore if scancode == Keys::F12
|
|
25
|
+
|
|
26
|
+
save if scancode == Keys::F11
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Saves the machine, and warns when it can't.
|
|
31
|
+
def save(path = "badline-#{Time.now.strftime('%Y%m%d-%H%M%S')}.vsf")
|
|
32
|
+
@computer.save_snapshot(path)
|
|
33
|
+
@last = path
|
|
34
|
+
puts "Saved #{path}"
|
|
35
|
+
rescue Snapshot::FormatError, SystemCallError => e
|
|
36
|
+
warn "badline: #{path}: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Restores the snapshot last saved or opened into a new machine, and
|
|
40
|
+
# warns when it fails, leaving the machine running as it was.
|
|
41
|
+
def restore
|
|
42
|
+
if @last.empty?
|
|
43
|
+
puts "No snapshot to restore, F11 saves one"
|
|
44
|
+
return false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@computer = Snapshot.load(@last) { |line| puts line }
|
|
48
|
+
puts "Restored #{@last}"
|
|
49
|
+
true
|
|
50
|
+
rescue Snapshot::FormatError, SystemCallError => e
|
|
51
|
+
warn "badline: #{@last}: #{e.message}"
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def finish
|
|
56
|
+
save(@at_finish) unless @at_finish.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID's output on SDL's audio queue, as mono signed 16-bit samples.
|
|
6
|
+
# Once the device plays, it is the clock: #wait holds each frame until
|
|
7
|
+
# the queue is down to `AHEAD`, so the machine runs at the device's pace.
|
|
8
|
+
#
|
|
9
|
+
# The device starts once the queue holds AHEAD. Below real time the
|
|
10
|
+
# queue runs dry, and the device then stops until it holds AHEAD again,
|
|
11
|
+
# so the sound stutters with silent gaps rather than slowing down. A
|
|
12
|
+
# frame that would take the queue past LIMIT is dropped whole, which
|
|
13
|
+
# only happens unpaced.
|
|
14
|
+
#
|
|
15
|
+
# Each frame's samples go to SDL through an IO::Buffer of 16-bit values.
|
|
16
|
+
class Sound
|
|
17
|
+
RATE = 44_100
|
|
18
|
+
AHEAD = 0.08
|
|
19
|
+
LIMIT = 0.25
|
|
20
|
+
|
|
21
|
+
attr_reader :rate, :underruns, :dropped, :queued, :low, :high
|
|
22
|
+
|
|
23
|
+
def initialize(sid, wanted, verbose)
|
|
24
|
+
@sid = sid
|
|
25
|
+
@verbose = verbose
|
|
26
|
+
@device = 0
|
|
27
|
+
@rate = RATE
|
|
28
|
+
@started = false
|
|
29
|
+
@muted = false
|
|
30
|
+
@underruns = 0
|
|
31
|
+
@dropped = 0
|
|
32
|
+
@queued = 0
|
|
33
|
+
@buffer = IO::Buffer.new(4096)
|
|
34
|
+
reset_levels
|
|
35
|
+
open_device if wanted
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def on? = @device != 0
|
|
39
|
+
|
|
40
|
+
# Plays another machine's SID from here on.
|
|
41
|
+
def sid=(sid)
|
|
42
|
+
@sid = sid
|
|
43
|
+
sid.record(rate: @rate) if on?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def muted? = @muted
|
|
47
|
+
|
|
48
|
+
def playing? = @started
|
|
49
|
+
|
|
50
|
+
def feed
|
|
51
|
+
return unless on?
|
|
52
|
+
|
|
53
|
+
samples = @sid.drain_samples
|
|
54
|
+
return if @muted || samples.empty?
|
|
55
|
+
|
|
56
|
+
level = queued_seconds
|
|
57
|
+
if level + (samples.size.to_f / @rate) > LIMIT
|
|
58
|
+
@dropped += samples.size
|
|
59
|
+
else
|
|
60
|
+
play(samples, level)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# How much the audio queue holds, in seconds.
|
|
65
|
+
def level = queued_seconds
|
|
66
|
+
|
|
67
|
+
# Waits until the queue is down to `level` seconds.
|
|
68
|
+
def wait(level = AHEAD)
|
|
69
|
+
SDL.SDL_Delay(1) while queued_seconds > level
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def toggle_mute
|
|
73
|
+
@muted = !@muted
|
|
74
|
+
return unless @muted
|
|
75
|
+
|
|
76
|
+
halt
|
|
77
|
+
SDL.SDL_ClearQueuedAudio(@device)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reset_levels
|
|
81
|
+
@low = LIMIT
|
|
82
|
+
@high = 0.0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def close
|
|
86
|
+
SDL.SDL_CloseAudioDevice(@device) if on?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def open_device
|
|
92
|
+
unless SDL.SDL_InitSubSystem(SDL::INIT_AUDIO).zero?
|
|
93
|
+
puts "No sound: #{SDL.SDL_GetError}"
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
SDL.spec_freq(SDL.wanted, RATE)
|
|
98
|
+
SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
|
|
99
|
+
SDL.spec_channels(SDL.wanted, 1)
|
|
100
|
+
SDL.spec_samples(SDL.wanted, 512)
|
|
101
|
+
@device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, SDL::ALLOW_FREQUENCY_CHANGE)
|
|
102
|
+
return puts "No sound: #{SDL.SDL_GetError}" unless on?
|
|
103
|
+
|
|
104
|
+
@rate = SDL.read_i32(SDL.obtained)
|
|
105
|
+
@sid.record(rate: @rate)
|
|
106
|
+
puts "Sound at #{@rate} Hz" if @verbose
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
|
|
110
|
+
|
|
111
|
+
def play(samples, level)
|
|
112
|
+
@low = level if level < @low
|
|
113
|
+
underrun! if @started && level.zero?
|
|
114
|
+
queue(samples)
|
|
115
|
+
level = queued_seconds
|
|
116
|
+
@high = level if level > @high
|
|
117
|
+
start if level >= AHEAD
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def queue(samples)
|
|
121
|
+
count = samples.size
|
|
122
|
+
bytes = count * 2
|
|
123
|
+
@buffer.resize(bytes) if bytes > @buffer.size
|
|
124
|
+
buffer = @buffer
|
|
125
|
+
i = 0
|
|
126
|
+
while i < count
|
|
127
|
+
buffer.set_value(:s16, i * 2, samples[i])
|
|
128
|
+
i += 1
|
|
129
|
+
end
|
|
130
|
+
SDL.SDL_QueueAudio(@device, buffer, bytes)
|
|
131
|
+
@queued += count
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def start
|
|
135
|
+
return if @started
|
|
136
|
+
|
|
137
|
+
@started = true
|
|
138
|
+
SDL.SDL_PauseAudioDevice(@device, 0)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def halt
|
|
142
|
+
@started = false
|
|
143
|
+
SDL.SDL_PauseAudioDevice(@device, 1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def underrun!
|
|
147
|
+
@underruns += 1
|
|
148
|
+
puts "Running below real time, so the sound will stutter." if @underruns == 1
|
|
149
|
+
halt
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|