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.
Files changed (173) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +82 -10
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +20 -23
  7. data/lib/badline/address_bus/saved_state.rb +72 -0
  8. data/lib/badline/address_bus.rb +11 -1
  9. data/lib/badline/audio/cli.rb +18 -5
  10. data/lib/badline/audio/console.rb +2 -1
  11. data/lib/badline/audio.rb +0 -3
  12. data/lib/badline/cartridge/action_replay.rb +15 -0
  13. data/lib/badline/cartridge/comal80.rb +8 -0
  14. data/lib/badline/cartridge/dinamic.rb +8 -0
  15. data/lib/badline/cartridge/easy_flash.rb +20 -1
  16. data/lib/badline/cartridge/epyx_fastload.rb +8 -0
  17. data/lib/badline/cartridge/final_cartridge3.rb +12 -0
  18. data/lib/badline/cartridge/flash.rb +41 -0
  19. data/lib/badline/cartridge/fun_play.rb +8 -0
  20. data/lib/badline/cartridge/g_mod2.rb +12 -0
  21. data/lib/badline/cartridge/game_system.rb +8 -0
  22. data/lib/badline/cartridge/geo_ram.rb +16 -0
  23. data/lib/badline/cartridge/magic_desk.rb +8 -0
  24. data/lib/badline/cartridge/ocean.rb +8 -0
  25. data/lib/badline/cartridge/pagefox.rb +12 -0
  26. data/lib/badline/cartridge/retro_replay.rb +44 -1
  27. data/lib/badline/cartridge/rgcd.rb +10 -0
  28. data/lib/badline/cartridge/super_games.rb +10 -0
  29. data/lib/badline/cartridge/zaxxon.rb +16 -0
  30. data/lib/badline/cartridge.rb +72 -0
  31. data/lib/badline/cia/interrupt_register.rb +17 -0
  32. data/lib/badline/cia/serial.rb +44 -0
  33. data/lib/badline/cia/state.rb +47 -0
  34. data/lib/badline/cia/timer.rb +33 -0
  35. data/lib/badline/cia.rb +8 -0
  36. data/lib/badline/computer/attachments.rb +132 -0
  37. data/lib/badline/computer/saved_state.rb +116 -0
  38. data/lib/badline/computer.rb +13 -33
  39. data/lib/badline/cpu/boundary.rb +54 -0
  40. data/lib/badline/cpu/core.rb +165 -0
  41. data/lib/badline/cpu/microcode.rb +8 -0
  42. data/lib/badline/cpu/saved_state.rb +115 -0
  43. data/lib/badline/cpu.rb +3 -212
  44. data/lib/badline/datasette.rb +27 -0
  45. data/lib/badline/drive1541/bus.rb +13 -0
  46. data/lib/badline/drive1541/cpu.rb +29 -0
  47. data/lib/badline/drive1541/disk.rb +7 -2
  48. data/lib/badline/drive1541/disk_state.rb +80 -0
  49. data/lib/badline/drive1541/mechanism.rb +2 -0
  50. data/lib/badline/drive1541/mechanism_state.rb +67 -0
  51. data/lib/badline/drive1541/rotation.rb +10 -0
  52. data/lib/badline/drive1541/serial_port.rb +10 -0
  53. data/lib/badline/drive1541.rb +35 -0
  54. data/lib/badline/ffi.rb +157 -0
  55. data/lib/badline/frontend/app.rb +225 -0
  56. data/lib/badline/frontend/audio_sink.rb +72 -0
  57. data/lib/badline/frontend/boot.rb +52 -0
  58. data/lib/badline/frontend/controls.rb +163 -0
  59. data/lib/badline/frontend/drive_led.rb +51 -0
  60. data/lib/badline/frontend/frame_rate.rb +83 -0
  61. data/lib/badline/frontend/gamepads.rb +76 -0
  62. data/lib/badline/frontend/keys.rb +48 -0
  63. data/lib/badline/frontend/pacer.rb +100 -0
  64. data/lib/badline/frontend/pad_port.rb +61 -0
  65. data/lib/badline/frontend/screen.rb +65 -0
  66. data/lib/badline/frontend/screenshot.rb +22 -0
  67. data/lib/badline/frontend/snapshots.rb +60 -0
  68. data/lib/badline/frontend/sound.rb +153 -0
  69. data/lib/badline/frontend/timeline.rb +171 -0
  70. data/lib/badline/frontend.rb +18 -0
  71. data/lib/badline/kernal_trap/channel.rb +27 -0
  72. data/lib/badline/kernal_trap/drive/channels.rb +25 -0
  73. data/lib/badline/kernal_trap/drive/memory.rb +9 -0
  74. data/lib/badline/kernal_trap/drive/status.rb +8 -0
  75. data/lib/badline/kernal_trap/drive/write_file.rb +21 -0
  76. data/lib/badline/kernal_trap/drive.rb +19 -0
  77. data/lib/badline/kernal_trap/save.rb +8 -0
  78. data/lib/badline/kernal_trap/serial.rb +16 -0
  79. data/lib/badline/memory.rb +8 -0
  80. data/lib/badline/options/event.rb +94 -0
  81. data/lib/badline/options/help.rb +78 -0
  82. data/lib/badline/options/option.rb +29 -0
  83. data/lib/badline/options/table.rb +62 -0
  84. data/lib/badline/options.rb +181 -144
  85. data/lib/badline/ram_expansion/plus256k.rb +16 -0
  86. data/lib/badline/ram_expansion/plus60k.rb +12 -0
  87. data/lib/badline/ram_expansion/unexpanded.rb +6 -0
  88. data/lib/badline/reu/dma/saved_state.rb +50 -0
  89. data/lib/badline/reu/dma.rb +56 -11
  90. data/lib/badline/reu/ram.rb +15 -0
  91. data/lib/badline/reu/saved_state.rb +53 -0
  92. data/lib/badline/reu.rb +23 -10
  93. data/lib/badline/sdl.rb +123 -51
  94. data/lib/badline/sid/envelope/state.rb +68 -0
  95. data/lib/badline/sid/envelope.rb +7 -0
  96. data/lib/badline/sid/filter.rb +38 -0
  97. data/lib/badline/sid/voice.rb +10 -0
  98. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  99. data/lib/badline/sid/waveform/state.rb +66 -0
  100. data/lib/badline/sid/waveform.rb +4 -3
  101. data/lib/badline/sid.rb +34 -0
  102. data/lib/badline/snapshot/container.rb +117 -0
  103. data/lib/badline/snapshot/fields.rb +54 -0
  104. data/lib/badline/snapshot/image.rb +70 -0
  105. data/lib/badline/snapshot/machine_state.rb +77 -0
  106. data/lib/badline/snapshot/setup.rb +58 -0
  107. data/lib/badline/snapshot/state.rb +178 -0
  108. data/lib/badline/snapshot/vice/c64_mem.rb +53 -0
  109. data/lib/badline/snapshot/vice/cias.rb +89 -0
  110. data/lib/badline/snapshot/vice/cias_import.rb +70 -0
  111. data/lib/badline/snapshot/vice/main_cpu.rb +76 -0
  112. data/lib/badline/snapshot/vice/peripherals.rb +47 -0
  113. data/lib/badline/snapshot/vice/restore.rb +125 -0
  114. data/lib/badline/snapshot/vice/sid_extended.rb +96 -0
  115. data/lib/badline/snapshot/vice/sid_registers.rb +46 -0
  116. data/lib/badline/snapshot/vice/vicii.rb +119 -0
  117. data/lib/badline/snapshot/vice/vicii_import.rb +131 -0
  118. data/lib/badline/snapshot/vice.rb +74 -0
  119. data/lib/badline/snapshot.rb +52 -0
  120. data/lib/badline/storage/crt_file.rb +9 -3
  121. data/lib/badline/storage/d64_image.rb +2 -0
  122. data/lib/badline/storage/d71_image.rb +2 -0
  123. data/lib/badline/storage/d81_image.rb +2 -0
  124. data/lib/badline/storage/disk_image.rb +11 -2
  125. data/lib/badline/storage/host_directory.rb +5 -0
  126. data/lib/badline/storage/t64.rb +6 -0
  127. data/lib/badline/storage/tap.rb +10 -1
  128. data/lib/badline/storage/unavailable.rb +20 -0
  129. data/lib/badline/storage.rb +34 -0
  130. data/lib/badline/time_of_day.rb +56 -0
  131. data/lib/badline/version.rb +1 -1
  132. data/lib/badline/via/control_lines.rb +15 -0
  133. data/lib/badline/via/interrupt_register.rb +9 -0
  134. data/lib/badline/via/shift_register.rb +13 -0
  135. data/lib/badline/via/state.rb +42 -0
  136. data/lib/badline/via/timer1.rb +13 -0
  137. data/lib/badline/via/timer2.rb +18 -0
  138. data/lib/badline/via.rb +2 -0
  139. data/lib/badline/vic/bank.rb +21 -0
  140. data/lib/badline/vic/border_mask.rb +10 -0
  141. data/lib/badline/vic/collisions.rb +11 -0
  142. data/lib/badline/vic/color_patches.rb +11 -0
  143. data/lib/badline/vic/display_state.rb +29 -1
  144. data/lib/badline/vic/graphics_shifter.rb +26 -0
  145. data/lib/badline/vic/register_log.rb +18 -0
  146. data/lib/badline/vic/registers.rb +13 -2
  147. data/lib/badline/vic/sequencer.rb +12 -0
  148. data/lib/badline/vic/sequencer_state.rb +89 -0
  149. data/lib/badline/vic/sprite/internal_bus.rb +11 -0
  150. data/lib/badline/vic/sprite/saved_state.rb +64 -0
  151. data/lib/badline/vic/sprite.rb +2 -0
  152. data/lib/badline/vic/sprites.rb +3 -0
  153. data/lib/badline/vic/sprites_state.rb +57 -0
  154. data/lib/badline/vic.rb +109 -16
  155. data/lib/badline.rb +1 -0
  156. metadata +59 -18
  157. data/.claude/hooks/session-start.sh +0 -57
  158. data/.claude/settings.json +0 -14
  159. data/lib/badline/audio/sdl_sink.rb +0 -70
  160. data/lib/badline/audio/stream.rb +0 -117
  161. data/lib/badline/gui/application.rb +0 -216
  162. data/lib/badline/gui/drive_led_pane.rb +0 -29
  163. data/lib/badline/gui/gamepads.rb +0 -97
  164. data/lib/badline/gui/joy_map.rb +0 -27
  165. data/lib/badline/gui/key_map.rb +0 -39
  166. data/lib/badline/gui/palette.rb +0 -35
  167. data/lib/badline/gui/pane.rb +0 -36
  168. data/lib/badline/gui/screen_pane.rb +0 -46
  169. data/lib/badline/gui/window.rb +0 -61
  170. data/lib/badline/gui.rb +0 -13
  171. data/lib/badline/sdl/events.rb +0 -47
  172. data/lib/badline/sdl/functions.rb +0 -74
  173. 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