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
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Prepares a Claude Code on the web container to run `bundle exec rubocop`
|
|
3
|
-
# and `bundle exec rspec`. The regression suites need vendor/ and aren't set
|
|
4
|
-
# up here.
|
|
5
|
-
set -euo pipefail
|
|
6
|
-
|
|
7
|
-
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
|
8
|
-
exit 0
|
|
9
|
-
fi
|
|
10
|
-
|
|
11
|
-
cd "${CLAUDE_PROJECT_DIR:-$(dirname "$0")/../..}"
|
|
12
|
-
|
|
13
|
-
# The SDL specs open libSDL2 through Fiddle, so only the runtime library is
|
|
14
|
-
# needed, as in CI.
|
|
15
|
-
if ! ldconfig -p | grep 'libSDL2-2\.0\.so\.0' >/dev/null; then
|
|
16
|
-
export DEBIAN_FRONTEND=noninteractive
|
|
17
|
-
apt-get update -qq
|
|
18
|
-
apt-get install -y -qq libsdl2-2.0-0 >/dev/null
|
|
19
|
-
fi
|
|
20
|
-
|
|
21
|
-
# The image ships Ruby 3.3 and an rbenv. Install the newest Ruby 4.0.x, as
|
|
22
|
-
# CI's ruby-version "4.0" picks, from the prebuilt binaries setup-ruby uses,
|
|
23
|
-
# unless a 4.0 is installed already.
|
|
24
|
-
export RBENV_ROOT=/opt/rbenv
|
|
25
|
-
export PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
|
|
26
|
-
ruby_version=$(rbenv versions --bare | grep -E '^4\.0\.[0-9]+$' | sort -V | tail -1 || true)
|
|
27
|
-
if [ -z "$ruby_version" ]; then
|
|
28
|
-
ruby_version=$(curl -fsSL https://raw.githubusercontent.com/ruby/setup-ruby/master/ruby-builder-versions.json |
|
|
29
|
-
ruby -rjson -e 'puts JSON.parse($stdin.read)["ruby"].grep(/\A4\.0\.\d+\z/).max_by { Gem::Version.new(_1) }')
|
|
30
|
-
# The binaries only run from the prefix they were built with, so unpack
|
|
31
|
-
# there and link it into rbenv.
|
|
32
|
-
. /etc/os-release
|
|
33
|
-
prefix="/opt/hostedtoolcache/Ruby/$ruby_version/x64"
|
|
34
|
-
mkdir -p "$prefix"
|
|
35
|
-
if curl -fsSL "https://github.com/ruby/ruby-builder/releases/download/ruby-$ruby_version/ruby-$ruby_version-ubuntu-$VERSION_ID-x64.tar.gz" |
|
|
36
|
-
tar -xz -C "$prefix" --strip-components=1; then
|
|
37
|
-
ln -sfn "$prefix" "$RBENV_ROOT/versions/$ruby_version"
|
|
38
|
-
else
|
|
39
|
-
# No binary for this Ubuntu release: build from source, which takes minutes.
|
|
40
|
-
rm -rf "$prefix"
|
|
41
|
-
git -C "$RBENV_ROOT/plugins/ruby-build" pull --ff-only -q || true
|
|
42
|
-
MAKE_OPTS="-j$(nproc)" rbenv install --skip-existing "$ruby_version"
|
|
43
|
-
fi
|
|
44
|
-
fi
|
|
45
|
-
rbenv global "$ruby_version"
|
|
46
|
-
rbenv rehash
|
|
47
|
-
|
|
48
|
-
# Put rbenv's shims ahead of /usr/local/bin/ruby for the session.
|
|
49
|
-
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
|
50
|
-
{
|
|
51
|
-
echo "export RBENV_ROOT=$RBENV_ROOT"
|
|
52
|
-
echo "export PATH=\"$RBENV_ROOT/bin:$RBENV_ROOT/shims:\$PATH\""
|
|
53
|
-
} >> "$CLAUDE_ENV_FILE"
|
|
54
|
-
fi
|
|
55
|
-
|
|
56
|
-
# Bundler switches to the version Gemfile.lock was bundled with.
|
|
57
|
-
bundle install --jobs "$(nproc)"
|
data/.claude/settings.json
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Badline
|
|
4
|
-
module Audio
|
|
5
|
-
# The host's audio device, fed through SDL's queue: mono signed 16-bit
|
|
6
|
-
# samples go in at the device's rate and SDL plays them out behind us.
|
|
7
|
-
class SDLSink
|
|
8
|
-
class Error < Playback::DeviceError; end
|
|
9
|
-
|
|
10
|
-
# int freq; Uint16 format; Uint8 channels, silence; Uint16 samples,
|
|
11
|
-
# padding; Uint32 size; then the callback and userdata pointers.
|
|
12
|
-
SPEC_LAYOUT = "lSCCSSLQQ"
|
|
13
|
-
|
|
14
|
-
attr_reader :rate
|
|
15
|
-
|
|
16
|
-
# With `exact_rate` false SDL may pick the device's own rate instead,
|
|
17
|
-
# which #rate then reports.
|
|
18
|
-
def initialize(rate:, exact_rate: false, buffer: 1024)
|
|
19
|
-
load_sdl
|
|
20
|
-
check(SDL::InitSubSystem.call(SDL::INIT_AUDIO))
|
|
21
|
-
@device = open_device(rate, exact_rate ? 0 : SDL::AUDIO_ALLOW_FREQUENCY_CHANGE, buffer)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def queue(samples)
|
|
25
|
-
data = samples.pack("s*")
|
|
26
|
-
check(SDL::QueueAudio.call(@device, data, data.bytesize))
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def queued_seconds = SDL::GetQueuedAudioSize.call(@device).fdiv(2 * rate)
|
|
30
|
-
|
|
31
|
-
def start = SDL::PauseAudioDevice.call(@device, 0)
|
|
32
|
-
|
|
33
|
-
def pause = SDL::PauseAudioDevice.call(@device, 1)
|
|
34
|
-
|
|
35
|
-
def clear = SDL::ClearQueuedAudio.call(@device)
|
|
36
|
-
|
|
37
|
-
def close
|
|
38
|
-
return unless @device
|
|
39
|
-
|
|
40
|
-
SDL::CloseAudioDevice.call(@device)
|
|
41
|
-
SDL::QuitSubSystem.call(SDL::INIT_AUDIO)
|
|
42
|
-
@device = nil
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def open_device(rate, allowed_changes, buffer)
|
|
48
|
-
wanted = [rate, SDL::AUDIO_S16SYS, 1, 0, buffer, 0, 0, 0, 0].pack(SPEC_LAYOUT)
|
|
49
|
-
obtained = "\0".b * 32
|
|
50
|
-
device = SDL::OpenAudioDevice.call(nil, 0, wanted, obtained, allowed_changes)
|
|
51
|
-
raise Error, SDL.error if device.zero?
|
|
52
|
-
|
|
53
|
-
@rate = obtained.unpack1("l")
|
|
54
|
-
device
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def load_sdl
|
|
58
|
-
require "badline/sdl"
|
|
59
|
-
rescue SDL::Error => e
|
|
60
|
-
raise Error, e.message
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def check(result)
|
|
64
|
-
raise Error, SDL.error if result.negative?
|
|
65
|
-
|
|
66
|
-
result
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
data/lib/badline/audio/stream.rb
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Badline
|
|
4
|
-
module Audio
|
|
5
|
-
# Plays a running machine's SID through a sink, for the window: #feed
|
|
6
|
-
# queues whatever the SID recorded since the last call, once a frame,
|
|
7
|
-
# and #pace holds the machine to the device's clock.
|
|
8
|
-
#
|
|
9
|
-
# The device starts once the queue holds `ahead` seconds. Below real time
|
|
10
|
-
# the queue runs dry, and the device then stops until it holds `ahead`
|
|
11
|
-
# again, so the sound stutters with silent gaps rather than slowing
|
|
12
|
-
# down; `on_underrun` hears about the first time. A frame that would
|
|
13
|
-
# take the queue past `limit` seconds, as an unpaced machine above real
|
|
14
|
-
# time makes, is dropped whole.
|
|
15
|
-
class Stream
|
|
16
|
-
AHEAD = 0.08
|
|
17
|
-
LIMIT = 0.25
|
|
18
|
-
|
|
19
|
-
attr_reader :underruns, :dropped
|
|
20
|
-
|
|
21
|
-
# What #pace reads the time from and sleeps on: #now and #sleep.
|
|
22
|
-
attr_writer :clock
|
|
23
|
-
|
|
24
|
-
def initialize(sink, sid, ahead: AHEAD, limit: LIMIT, on_underrun: nil)
|
|
25
|
-
@sink = sink
|
|
26
|
-
@sid = sid
|
|
27
|
-
@ahead = ahead
|
|
28
|
-
@limit = limit
|
|
29
|
-
@on_underrun = on_underrun
|
|
30
|
-
@clock = Clock.new
|
|
31
|
-
@next_frame = nil
|
|
32
|
-
@started = false
|
|
33
|
-
@muted = false
|
|
34
|
-
@underruns = 0
|
|
35
|
-
@dropped = 0
|
|
36
|
-
sid.record(rate: sink.rate)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def rate = @sink.rate
|
|
40
|
-
|
|
41
|
-
# Plays another machine's SID from here on.
|
|
42
|
-
def sid=(sid)
|
|
43
|
-
@sid = sid
|
|
44
|
-
sid.record(rate: rate)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def muted? = @muted
|
|
48
|
-
|
|
49
|
-
def playing? = @started
|
|
50
|
-
|
|
51
|
-
# Muting drops the queue; unmuting fills it to `ahead` again before
|
|
52
|
-
# the device restarts.
|
|
53
|
-
def toggle_mute
|
|
54
|
-
@muted = !@muted
|
|
55
|
-
return unless @muted
|
|
56
|
-
|
|
57
|
-
halt
|
|
58
|
-
@sink.clear
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def feed
|
|
62
|
-
samples = @sid.drain_samples
|
|
63
|
-
return if @muted || samples.empty?
|
|
64
|
-
|
|
65
|
-
queued = @sink.queued_seconds
|
|
66
|
-
return @dropped += samples.size if queued + samples.size.fdiv(rate) > @limit
|
|
67
|
-
|
|
68
|
-
underrun! if @started && queued.zero?
|
|
69
|
-
@sink.queue(samples)
|
|
70
|
-
start if @sink.queued_seconds >= @ahead
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Waits out the frame just run. While the device plays, that is until
|
|
74
|
-
# it has played the queue down to `ahead`, so the machine runs at the
|
|
75
|
-
# device's rate and the queue neither drifts dry nor grows. Muted or
|
|
76
|
-
# filling up, the clock paces frames `seconds` apart instead.
|
|
77
|
-
def pace(seconds)
|
|
78
|
-
return wait_for_device if playing?
|
|
79
|
-
|
|
80
|
-
now = @clock.now
|
|
81
|
-
@next_frame = now if @next_frame.nil? || @next_frame < now
|
|
82
|
-
@clock.sleep(@next_frame - now)
|
|
83
|
-
@next_frame += seconds
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def close = @sink.close
|
|
87
|
-
|
|
88
|
-
private
|
|
89
|
-
|
|
90
|
-
def start
|
|
91
|
-
return if @started
|
|
92
|
-
|
|
93
|
-
@started = true
|
|
94
|
-
@sink.start
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def wait_for_device
|
|
98
|
-
@next_frame = nil
|
|
99
|
-
while (excess = @sink.queued_seconds - @ahead).positive?
|
|
100
|
-
@clock.sleep([excess, 0.001].max)
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def halt
|
|
105
|
-
@started = false
|
|
106
|
-
@sink.pause
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def underrun!
|
|
110
|
-
@underruns += 1
|
|
111
|
-
@on_underrun&.call if @underruns == 1
|
|
112
|
-
halt
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# The host's monotonic clock.
|
|
116
|
-
class Clock
|
|
117
|
-
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
-
|
|
119
|
-
def sleep(seconds) = Kernel.sleep(seconds)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Badline
|
|
4
|
-
module GUI
|
|
5
|
-
class Application
|
|
6
|
-
TITLE = "Badline"
|
|
7
|
-
TOGGLE_SYM = SDL::KEY_TAB
|
|
8
|
-
MUTE_SYM = SDL::KEY_F10
|
|
9
|
-
SAVE_SYM = SDL::KEY_F11
|
|
10
|
-
RESTORE_SYM = SDL::KEY_F12
|
|
11
|
-
HOST_SYMS = [TOGGLE_SYM, MUTE_SYM, SAVE_SYM, RESTORE_SYM].freeze
|
|
12
|
-
REVERSE_MOD = SDL::KMOD_SHIFT
|
|
13
|
-
|
|
14
|
-
SHARED_KEYS = %i[cursor_up cursor_left cursor_h cursor_v space w a s d lshift].freeze
|
|
15
|
-
|
|
16
|
-
# Tab steps through the input modes and shift-Tab back; the title bar
|
|
17
|
-
# names the live one. The pot devices appear once per control port, since
|
|
18
|
-
# games disagree on which one they read.
|
|
19
|
-
MODES = {
|
|
20
|
-
keyboard: nil, joystick: "JOY",
|
|
21
|
-
mouse1: "MOUSE 1", mouse2: "MOUSE 2",
|
|
22
|
-
paddles1: "PADDLE 1", paddles2: "PADDLE 2"
|
|
23
|
-
}.freeze
|
|
24
|
-
|
|
25
|
-
# Both pot devices take their input from the host mouse. Motion turns the
|
|
26
|
-
# paddle knobs or steps the 1351's counters, and the host buttons go to
|
|
27
|
-
# whichever lines the device puts them on.
|
|
28
|
-
POT_DEVICES = {
|
|
29
|
-
mouse1: [Input::Mouse1351, 1], mouse2: [Input::Mouse1351, 2],
|
|
30
|
-
paddles1: [Input::Paddles, 1], paddles2: [Input::Paddles, 2]
|
|
31
|
-
}.freeze
|
|
32
|
-
MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
|
|
33
|
-
|
|
34
|
-
# The machine options (sid_model:, reu:, true_drive:, ntsc:) build
|
|
35
|
-
# the machine: reu plugs in an REU of that many K, true_drive puts a
|
|
36
|
-
# true 1541 on device 8, and ntsc makes it an NTSC C64. The media
|
|
37
|
-
# options (autostart:, song:, disk:) go to Media.attach.
|
|
38
|
-
def initialize(media_path: nil, machine: {}, sound: false, verbose: false, **media)
|
|
39
|
-
@verbose = verbose
|
|
40
|
-
@snapshots = Snapshots.new
|
|
41
|
-
@computer = @snapshots.boot(media_path, machine, media)
|
|
42
|
-
|
|
43
|
-
@mode = :keyboard
|
|
44
|
-
@pot_device = nil
|
|
45
|
-
@panes = panes
|
|
46
|
-
@stream = open_stream if sound
|
|
47
|
-
@paced = ENV["NOVSYNC"].nil?
|
|
48
|
-
@window = Window.new(
|
|
49
|
-
title: TITLE,
|
|
50
|
-
width: canvas_width, height: canvas_height,
|
|
51
|
-
vsync: @paced && !@stream
|
|
52
|
-
)
|
|
53
|
-
@gamepads = Gamepads.new(@computer)
|
|
54
|
-
@gamepads.names.each { |name| report "Gamepad: #{name}" }
|
|
55
|
-
fit_frame
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def run
|
|
59
|
-
@running = true
|
|
60
|
-
while @running
|
|
61
|
-
handle_events
|
|
62
|
-
@gamepads.poll
|
|
63
|
-
@cycles_per_frame.times { @computer.cycle! }
|
|
64
|
-
@stream&.feed
|
|
65
|
-
@window.draw(@panes)
|
|
66
|
-
@stream.pace(@frame_seconds) if @stream && @paced
|
|
67
|
-
end
|
|
68
|
-
ensure
|
|
69
|
-
@computer.drive1541&.flush
|
|
70
|
-
@stream&.close
|
|
71
|
-
@gamepads.close
|
|
72
|
-
@window.close
|
|
73
|
-
report @computer.cpu.inspect
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
private
|
|
77
|
-
|
|
78
|
-
def handle_events
|
|
79
|
-
while (event = SDL.poll_event)
|
|
80
|
-
case event
|
|
81
|
-
when SDL::Quit
|
|
82
|
-
@running = false
|
|
83
|
-
when SDL::KeyDown
|
|
84
|
-
handle_key_down(event)
|
|
85
|
-
when SDL::KeyUp
|
|
86
|
-
handle_key_up(event)
|
|
87
|
-
when SDL::MouseMotion
|
|
88
|
-
@pot_device&.move(event.xrel, event.yrel)
|
|
89
|
-
when SDL::MouseButton
|
|
90
|
-
handle_mouse_button(event)
|
|
91
|
-
when SDL::ControllerDevice
|
|
92
|
-
@gamepads.rescan
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def handle_key_down(event)
|
|
98
|
-
return host_key(event) if HOST_SYMS.include?(event.sym)
|
|
99
|
-
|
|
100
|
-
port, dir = JoyMap.parse(event) if @mode == :joystick
|
|
101
|
-
if port
|
|
102
|
-
joystick(port).press(dir)
|
|
103
|
-
else
|
|
104
|
-
press_key(KeyMap.parse(event), event.repeat)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Tab, F10, F11 and F12 drive the front end, not the machine.
|
|
109
|
-
def host_key(event)
|
|
110
|
-
case event.sym
|
|
111
|
-
when TOGGLE_SYM then cycle_mode(event.mod.anybits?(REVERSE_MOD) ? -1 : 1)
|
|
112
|
-
when MUTE_SYM then toggle_mute
|
|
113
|
-
when SAVE_SYM then @snapshots.save(@computer) unless event.repeat
|
|
114
|
-
else restore_snapshot unless event.repeat
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# RESTORE isn't in the key matrix, so it goes to the machine instead,
|
|
119
|
-
# once per press as the real key's one-shot fires.
|
|
120
|
-
def press_key(key, repeat)
|
|
121
|
-
return @computer.press_restore if key == :restore && !repeat
|
|
122
|
-
return if key == :restore
|
|
123
|
-
|
|
124
|
-
@computer.keyboard.press(key)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def handle_key_up(event)
|
|
128
|
-
return if HOST_SYMS.include?(event.sym)
|
|
129
|
-
|
|
130
|
-
port, dir = JoyMap.parse(event) if @mode == :joystick
|
|
131
|
-
if port
|
|
132
|
-
joystick(port).release(dir)
|
|
133
|
-
else
|
|
134
|
-
@computer.keyboard.release(KeyMap.parse(event))
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def handle_mouse_button(event)
|
|
139
|
-
button = MOUSE_BUTTONS[event.button]
|
|
140
|
-
return unless button && @pot_device
|
|
141
|
-
|
|
142
|
-
if event.pressed
|
|
143
|
-
@pot_device.press(button)
|
|
144
|
-
else
|
|
145
|
-
@pot_device.release(button)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def joystick(port)
|
|
150
|
-
port == 1 ? @computer.joystick1 : @computer.joystick2
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def cycle_mode(step)
|
|
154
|
-
modes = MODES.keys
|
|
155
|
-
@mode = modes[(modes.index(@mode) + step) % modes.size]
|
|
156
|
-
release_inputs
|
|
157
|
-
attach_pot_device
|
|
158
|
-
update_title
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def toggle_mute
|
|
162
|
-
return unless @stream
|
|
163
|
-
|
|
164
|
-
@stream.toggle_mute
|
|
165
|
-
update_title
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Runs the restored machine in place of the one before: the screen,
|
|
169
|
-
# the drive LED, the gamepads and the sound go over to it. The input
|
|
170
|
-
# mode stays the host's, so a mouse or paddles go back in their port.
|
|
171
|
-
def restore_snapshot
|
|
172
|
-
computer = @snapshots.restore
|
|
173
|
-
return unless computer
|
|
174
|
-
|
|
175
|
-
release_inputs
|
|
176
|
-
@computer = computer
|
|
177
|
-
@panes = panes
|
|
178
|
-
@gamepads.computer = computer
|
|
179
|
-
@stream&.sid = computer.sid
|
|
180
|
-
attach_pot_device if POT_DEVICES.key?(@mode)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# The screen, and the drive LED in its border when a true drive is
|
|
184
|
-
# plugged in.
|
|
185
|
-
def panes
|
|
186
|
-
screen = ScreenPane.new(@computer)
|
|
187
|
-
drive = @computer.drive1541
|
|
188
|
-
drive ? [screen, DriveLedPane.new(drive, screen)] : [screen]
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def update_title
|
|
192
|
-
tags = [MODES[@mode], @stream&.muted? && "MUTED"].select(&:itself)
|
|
193
|
-
@window.title = [TITLE, *tags.map { |tag| "[#{tag}]" }].join(" ")
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def open_stream
|
|
197
|
-
sink = Audio::SDLSink.new(rate: Audio::Renderer::DEFAULT_RATE)
|
|
198
|
-
report "Sound at #{sink.rate} Hz, F10 mutes"
|
|
199
|
-
Audio::Stream.new(sink, @computer.sid,
|
|
200
|
-
on_underrun: -> { puts "Running below real time, so the sound will stutter." })
|
|
201
|
-
rescue Audio::SDLSink::Error => e
|
|
202
|
-
warn "badline: no sound, can't open the audio device: #{e.message}"
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def fit_frame
|
|
206
|
-
rate = @window.refresh_rate
|
|
207
|
-
clock_hz = @computer.region.clock_hz
|
|
208
|
-
@cycles_per_frame = clock_hz / rate
|
|
209
|
-
@frame_seconds = @cycles_per_frame.fdiv(clock_hz)
|
|
210
|
-
report "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def report(line)
|
|
214
|
-
puts line if @verbose
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def attach_pot_device
|
|
218
|
-
device_class, port = POT_DEVICES[@mode]
|
|
219
|
-
@pot_device = device_class&.new
|
|
220
|
-
ports = @computer.control_ports
|
|
221
|
-
ports.device1 = port == 1 ? @pot_device : nil
|
|
222
|
-
ports.device2 = port == 2 ? @pot_device : nil
|
|
223
|
-
SDL::SetRelativeMouseMode.call(@pot_device ? 1 : 0)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def release_inputs
|
|
227
|
-
SHARED_KEYS.each { |key| @computer.keyboard.release(key) }
|
|
228
|
-
Joystick::DIRECTIONS.each_key do |dir|
|
|
229
|
-
@computer.joystick1.release(dir)
|
|
230
|
-
@computer.joystick2.release(dir)
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def canvas_width
|
|
235
|
-
@panes.map { |pane| pane.left + pane.width }.max
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def canvas_height
|
|
239
|
-
@panes.map { |pane| pane.top + pane.height }.max
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Badline
|
|
4
|
-
module GUI
|
|
5
|
-
# The true drive's red LED, in the bottom right corner of the screen
|
|
6
|
-
# pane's border: bright while the DOS lights it, as it does while a file
|
|
7
|
-
# is open or after an error, and dim otherwise.
|
|
8
|
-
class DriveLedPane < Pane
|
|
9
|
-
WIDTH = 12
|
|
10
|
-
HEIGHT = 4
|
|
11
|
-
|
|
12
|
-
LIT = [0xff, 0x20, 0x20].freeze
|
|
13
|
-
DARK = [0x40, 0x00, 0x00].freeze
|
|
14
|
-
|
|
15
|
-
def initialize(drive, screen)
|
|
16
|
-
super(width: WIDTH, height: HEIGHT,
|
|
17
|
-
left: screen.left + screen.width - WIDTH - 8, top: screen.top + screen.height - HEIGHT - 6)
|
|
18
|
-
@drive = drive
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def color = @drive.led_on? ? LIT : DARK
|
|
22
|
-
|
|
23
|
-
def render(renderer)
|
|
24
|
-
SDL::SetRenderDrawColor.call(renderer, *color, 0xff)
|
|
25
|
-
SDL::RenderFillRect.call(renderer, @rect)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
data/lib/badline/gui/gamepads.rb
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Badline
|
|
4
|
-
module GUI
|
|
5
|
-
# Host game controllers wired onto the control ports.
|
|
6
|
-
#
|
|
7
|
-
# The first controller found drives joystick 2, the port most games read;
|
|
8
|
-
# a second one drives joystick 1. D-pad and left stick both steer, every
|
|
9
|
-
# face and shoulder button fires.
|
|
10
|
-
#
|
|
11
|
-
# SDL announces arrivals by device index but reports button presses by
|
|
12
|
-
# instance id, so rather than line the two up, this class polls the
|
|
13
|
-
# controllers every frame. It only releases directions it
|
|
14
|
-
# pressed itself, so the keyboard mapping keeps working with a pad
|
|
15
|
-
# connected.
|
|
16
|
-
class Gamepads
|
|
17
|
-
DEADZONE = 8_000
|
|
18
|
-
PORTS = [2, 1].freeze
|
|
19
|
-
|
|
20
|
-
BUTTONS = {
|
|
21
|
-
up: [SDL::CONTROLLER_BUTTON_DPAD_UP],
|
|
22
|
-
down: [SDL::CONTROLLER_BUTTON_DPAD_DOWN],
|
|
23
|
-
left: [SDL::CONTROLLER_BUTTON_DPAD_LEFT],
|
|
24
|
-
right: [SDL::CONTROLLER_BUTTON_DPAD_RIGHT],
|
|
25
|
-
fire: [SDL::CONTROLLER_BUTTON_A, SDL::CONTROLLER_BUTTON_B,
|
|
26
|
-
SDL::CONTROLLER_BUTTON_X, SDL::CONTROLLER_BUTTON_Y,
|
|
27
|
-
SDL::CONTROLLER_BUTTON_LEFTSHOULDER, SDL::CONTROLLER_BUTTON_RIGHTSHOULDER]
|
|
28
|
-
}.freeze
|
|
29
|
-
|
|
30
|
-
AXES = {
|
|
31
|
-
up: [SDL::CONTROLLER_AXIS_LEFTY, -1],
|
|
32
|
-
down: [SDL::CONTROLLER_AXIS_LEFTY, 1],
|
|
33
|
-
left: [SDL::CONTROLLER_AXIS_LEFTX, -1],
|
|
34
|
-
right: [SDL::CONTROLLER_AXIS_LEFTX, 1]
|
|
35
|
-
}.freeze
|
|
36
|
-
|
|
37
|
-
def initialize(computer)
|
|
38
|
-
SDL.check(SDL::InitSubSystem.call(SDL::INIT_GAMECONTROLLER))
|
|
39
|
-
@computer = computer
|
|
40
|
-
@controllers = []
|
|
41
|
-
@pressed = Array.new(PORTS.size) { [] }
|
|
42
|
-
rescan
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def names = @controllers.map { |controller| SDL::GameControllerName.call(controller) }
|
|
46
|
-
|
|
47
|
-
def rescan
|
|
48
|
-
close
|
|
49
|
-
@controllers = (0...SDL::NumJoysticks.call)
|
|
50
|
-
.select { |index| SDL::IsGameController.call(index) == 1 }
|
|
51
|
-
.first(PORTS.size)
|
|
52
|
-
.map { |index| SDL::GameControllerOpen.call(index) }
|
|
53
|
-
.reject(&:null?)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Wires the controllers to another machine's joysticks, which take
|
|
57
|
-
# what they hold on the next poll.
|
|
58
|
-
def computer=(computer)
|
|
59
|
-
@computer = computer
|
|
60
|
-
@pressed.map! { [] }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def poll
|
|
64
|
-
@controllers.each_with_index { |controller, index| apply(controller, index) }
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def close
|
|
68
|
-
@controllers.each { |controller| SDL::GameControllerClose.call(controller) }
|
|
69
|
-
@controllers = []
|
|
70
|
-
@pressed.size.times { |index| release(index) }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
def apply(controller, index)
|
|
76
|
-
active = Joystick::DIRECTIONS.keys.select { |dir| active?(controller, dir) }
|
|
77
|
-
(@pressed[index] - active).each { |dir| joystick(index).release(dir) }
|
|
78
|
-
active.each { |dir| joystick(index).press(dir) }
|
|
79
|
-
@pressed[index] = active
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def release(index)
|
|
83
|
-
@pressed[index].each { |dir| joystick(index).release(dir) }
|
|
84
|
-
@pressed[index] = []
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def active?(controller, direction)
|
|
88
|
-
BUTTONS.fetch(direction).any? { |button| SDL::GameControllerGetButton.call(controller, button) == 1 } ||
|
|
89
|
-
tilted?(controller, direction)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def tilted?(controller, direction)
|
|
93
|
-
axis, sign = AXES[direction]
|
|
94
|
-
return false unless axis
|
|
95
|
-
|
|
96
|
-
SDL::GameControllerGetAxis.call(controller, axis) * sign > DEADZONE
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def joystick(index)
|
|
100
|
-
PORTS[index] == 1 ? @computer.joystick1 : @computer.joystick2
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
data/lib/badline/gui/joy_map.rb
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Badline
|
|
4
|
-
module GUI
|
|
5
|
-
# Maps host keys onto the two control ports while joystick mode is on.
|
|
6
|
-
#
|
|
7
|
-
# The arrow cluster plus space drives joystick 2, WASD plus left shift
|
|
8
|
-
# joystick 1, so both ports can be played at once.
|
|
9
|
-
class JoyMap
|
|
10
|
-
PORTS = {
|
|
11
|
-
2 => { "Up" => :up, "Down" => :down, "Left" => :left, "Right" => :right, "Space" => :fire }.freeze,
|
|
12
|
-
1 => { "W" => :up, "S" => :down, "A" => :left, "D" => :right, "Left Shift" => :fire }.freeze
|
|
13
|
-
}.freeze
|
|
14
|
-
|
|
15
|
-
def self.parse(event)
|
|
16
|
-
name = SDL.key_name(event.sym)
|
|
17
|
-
|
|
18
|
-
PORTS.each do |port, map|
|
|
19
|
-
direction = map[name]
|
|
20
|
-
return [port, direction] if direction
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
nil
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|