badline 0.5.1 → 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 +52 -0
- data/CLAUDE.md +10 -9
- data/README.md +82 -10
- data/doc/pinned-behaviour.md +131 -30
- data/exe/badline-ruby +20 -23
- data/lib/badline/address_bus/saved_state.rb +72 -0
- data/lib/badline/address_bus.rb +11 -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/cartridge/action_replay.rb +15 -0
- data/lib/badline/cartridge/comal80.rb +8 -0
- data/lib/badline/cartridge/dinamic.rb +8 -0
- data/lib/badline/cartridge/easy_flash.rb +20 -1
- data/lib/badline/cartridge/epyx_fastload.rb +8 -0
- data/lib/badline/cartridge/final_cartridge3.rb +12 -0
- data/lib/badline/cartridge/flash.rb +41 -0
- data/lib/badline/cartridge/fun_play.rb +8 -0
- data/lib/badline/cartridge/g_mod2.rb +12 -0
- data/lib/badline/cartridge/game_system.rb +8 -0
- data/lib/badline/cartridge/geo_ram.rb +16 -0
- data/lib/badline/cartridge/magic_desk.rb +8 -0
- data/lib/badline/cartridge/ocean.rb +8 -0
- data/lib/badline/cartridge/pagefox.rb +12 -0
- data/lib/badline/cartridge/retro_replay.rb +44 -1
- data/lib/badline/cartridge/rgcd.rb +10 -0
- data/lib/badline/cartridge/super_games.rb +10 -0
- data/lib/badline/cartridge/zaxxon.rb +16 -0
- data/lib/badline/cartridge.rb +72 -0
- data/lib/badline/cia/interrupt_register.rb +17 -0
- data/lib/badline/cia/serial.rb +44 -0
- data/lib/badline/cia/state.rb +47 -0
- data/lib/badline/cia/timer.rb +33 -0
- data/lib/badline/cia.rb +8 -0
- data/lib/badline/computer/attachments.rb +132 -0
- data/lib/badline/computer/saved_state.rb +116 -0
- data/lib/badline/computer.rb +13 -33
- data/lib/badline/cpu/boundary.rb +54 -0
- data/lib/badline/cpu/core.rb +165 -0
- data/lib/badline/cpu/microcode.rb +8 -0
- data/lib/badline/cpu/saved_state.rb +115 -0
- data/lib/badline/cpu.rb +3 -212
- data/lib/badline/datasette.rb +27 -0
- data/lib/badline/drive1541/bus.rb +13 -0
- data/lib/badline/drive1541/cpu.rb +29 -0
- data/lib/badline/drive1541/disk.rb +7 -2
- data/lib/badline/drive1541/disk_state.rb +80 -0
- data/lib/badline/drive1541/mechanism.rb +2 -0
- data/lib/badline/drive1541/mechanism_state.rb +67 -0
- data/lib/badline/drive1541/rotation.rb +10 -0
- data/lib/badline/drive1541/serial_port.rb +10 -0
- data/lib/badline/drive1541.rb +35 -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/kernal_trap/channel.rb +27 -0
- data/lib/badline/kernal_trap/drive/channels.rb +25 -0
- data/lib/badline/kernal_trap/drive/memory.rb +9 -0
- data/lib/badline/kernal_trap/drive/status.rb +8 -0
- data/lib/badline/kernal_trap/drive/write_file.rb +21 -0
- data/lib/badline/kernal_trap/drive.rb +19 -0
- data/lib/badline/kernal_trap/save.rb +8 -0
- data/lib/badline/kernal_trap/serial.rb +16 -0
- data/lib/badline/memory.rb +8 -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 -144
- data/lib/badline/ram_expansion/plus256k.rb +16 -0
- data/lib/badline/ram_expansion/plus60k.rb +12 -0
- data/lib/badline/ram_expansion/unexpanded.rb +6 -0
- data/lib/badline/reu/dma/saved_state.rb +50 -0
- data/lib/badline/reu/dma.rb +56 -11
- data/lib/badline/reu/ram.rb +15 -0
- data/lib/badline/reu/saved_state.rb +53 -0
- data/lib/badline/reu.rb +23 -10
- data/lib/badline/sdl.rb +123 -51
- data/lib/badline/sid/envelope/state.rb +68 -0
- data/lib/badline/sid/envelope.rb +7 -0
- data/lib/badline/sid/filter.rb +38 -0
- data/lib/badline/sid/voice.rb +10 -0
- data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
- data/lib/badline/sid/waveform/state.rb +66 -0
- data/lib/badline/sid/waveform.rb +4 -3
- data/lib/badline/sid.rb +34 -0
- data/lib/badline/snapshot/container.rb +117 -0
- data/lib/badline/snapshot/fields.rb +54 -0
- data/lib/badline/snapshot/image.rb +70 -0
- data/lib/badline/snapshot/machine_state.rb +77 -0
- data/lib/badline/snapshot/setup.rb +58 -0
- data/lib/badline/snapshot/state.rb +178 -0
- data/lib/badline/snapshot/vice/c64_mem.rb +53 -0
- data/lib/badline/snapshot/vice/cias.rb +89 -0
- data/lib/badline/snapshot/vice/cias_import.rb +70 -0
- data/lib/badline/snapshot/vice/main_cpu.rb +76 -0
- data/lib/badline/snapshot/vice/peripherals.rb +47 -0
- data/lib/badline/snapshot/vice/restore.rb +125 -0
- data/lib/badline/snapshot/vice/sid_extended.rb +96 -0
- data/lib/badline/snapshot/vice/sid_registers.rb +46 -0
- data/lib/badline/snapshot/vice/vicii.rb +119 -0
- data/lib/badline/snapshot/vice/vicii_import.rb +131 -0
- data/lib/badline/snapshot/vice.rb +74 -0
- data/lib/badline/snapshot.rb +52 -0
- data/lib/badline/storage/crt_file.rb +9 -3
- data/lib/badline/storage/d64_image.rb +2 -0
- data/lib/badline/storage/d71_image.rb +2 -0
- data/lib/badline/storage/d81_image.rb +2 -0
- data/lib/badline/storage/disk_image.rb +11 -2
- data/lib/badline/storage/host_directory.rb +5 -0
- data/lib/badline/storage/t64.rb +6 -0
- data/lib/badline/storage/tap.rb +10 -1
- data/lib/badline/storage/unavailable.rb +20 -0
- data/lib/badline/storage.rb +34 -0
- data/lib/badline/time_of_day.rb +56 -0
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +15 -0
- data/lib/badline/via/interrupt_register.rb +9 -0
- data/lib/badline/via/shift_register.rb +13 -0
- data/lib/badline/via/state.rb +42 -0
- data/lib/badline/via/timer1.rb +13 -0
- data/lib/badline/via/timer2.rb +18 -0
- data/lib/badline/via.rb +2 -0
- data/lib/badline/vic/bank.rb +21 -0
- data/lib/badline/vic/border_mask.rb +10 -0
- data/lib/badline/vic/collisions.rb +11 -0
- data/lib/badline/vic/color_patches.rb +11 -0
- data/lib/badline/vic/display_state.rb +29 -1
- data/lib/badline/vic/graphics_shifter.rb +26 -0
- data/lib/badline/vic/register_log.rb +18 -0
- data/lib/badline/vic/registers.rb +13 -2
- data/lib/badline/vic/sequencer.rb +12 -0
- data/lib/badline/vic/sequencer_state.rb +89 -0
- data/lib/badline/vic/sprite/internal_bus.rb +11 -0
- data/lib/badline/vic/sprite/saved_state.rb +64 -0
- data/lib/badline/vic/sprite.rb +2 -0
- data/lib/badline/vic/sprites.rb +3 -0
- data/lib/badline/vic/sprites_state.rb +57 -0
- data/lib/badline/vic.rb +109 -16
- data/lib/badline.rb +1 -0
- metadata +59 -18
- 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 -117
- data/lib/badline/gui/application.rb +0 -216
- data/lib/badline/gui/drive_led_pane.rb +0 -29
- data/lib/badline/gui/gamepads.rb +0 -97
- 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/window.rb +0 -61
- data/lib/badline/gui.rb +0 -13
- data/lib/badline/sdl/events.rb +0 -47
- data/lib/badline/sdl/functions.rb +0 -74
- data/vendor/.gitkeep +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
# The window both builds play the machine in, badline-ruby on CRuby and
|
|
5
|
+
# the native badline compiled with Spinel, so it stays inside the subset
|
|
6
|
+
# of Ruby Spinel compiles. On CRuby, require badline/ffi before it.
|
|
7
|
+
module Frontend
|
|
8
|
+
# Builds the machine the options ask for and plays it in the window
|
|
9
|
+
# until it closes, and returns the exit status: 1 when the media won't
|
|
10
|
+
# attach, an --at key names nothing or an event fails, which it warns
|
|
11
|
+
# about.
|
|
12
|
+
def self.run(options)
|
|
13
|
+
error = Timeline.error(options.timeline)
|
|
14
|
+
unless error.empty?
|
|
15
|
+
warn "#{options.program}: #{error}"
|
|
16
|
+
return 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
computer = boot(options)
|
|
21
|
+
rescue Media::TrueDrive::Error, Storage::SIDFile::FormatError, Storage::T64::FormatError,
|
|
22
|
+
Storage::TAP::FormatError, Storage::CRTFile::FormatError, Storage::G64Image::FormatError,
|
|
23
|
+
Cartridge::UnsupportedTypeError, Snapshot::FormatError => e
|
|
24
|
+
warn "#{options.program}: #{options.media_path}: #{e.message}"
|
|
25
|
+
return 1
|
|
26
|
+
end
|
|
27
|
+
timeline = Timeline.new(options)
|
|
28
|
+
App.new(computer, options, timeline).run
|
|
29
|
+
timeline.failed? ? 1 : 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The machine a .vsf snapshot holds, built as the saved one was, or a
|
|
33
|
+
# new one built from the options with the media attached.
|
|
34
|
+
def self.boot(options)
|
|
35
|
+
media = options.media_path
|
|
36
|
+
if options.snapshot?
|
|
37
|
+
computer = Snapshot.load(media) { |line| puts line }
|
|
38
|
+
puts "Restored #{media}"
|
|
39
|
+
return computer
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
|
|
43
|
+
region: options.ntsc? ? Region::NTSC : Region::PAL)
|
|
44
|
+
Media::TrueDrive.plug(computer) if options.true_drive?
|
|
45
|
+
unless media.nil?
|
|
46
|
+
puts Media.attach(computer, media, autostart: options.autostart?, song: options.song,
|
|
47
|
+
disk: { read_only: options.read_only? })
|
|
48
|
+
end
|
|
49
|
+
computer
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -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,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,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
|