badline 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/session-start.sh +57 -0
  3. data/.claude/settings.json +14 -0
  4. data/CHANGELOG.md +107 -0
  5. data/CLAUDE.md +51 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +119 -51
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +293 -39
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +28 -14
  12. data/lib/badline/addressable.rb +1 -1
  13. data/lib/badline/audio/aiff.rb +8 -3
  14. data/lib/badline/audio/bare_player.rb +20 -3
  15. data/lib/badline/audio/cli.rb +17 -12
  16. data/lib/badline/audio/console.rb +12 -58
  17. data/lib/badline/audio/machine_player.rb +9 -6
  18. data/lib/badline/audio/playback.rb +3 -0
  19. data/lib/badline/audio/renderer.rb +3 -3
  20. data/lib/badline/audio/sdl_sink.rb +20 -52
  21. data/lib/badline/audio/stream.rb +117 -0
  22. data/lib/badline/audio/terminal.rb +90 -0
  23. data/lib/badline/audio.rb +3 -2
  24. data/lib/badline/cartridge/easy_flash.rb +6 -2
  25. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  26. data/lib/badline/cartridge/geo_ram.rb +63 -0
  27. data/lib/badline/cartridge/mach5.rb +1 -1
  28. data/lib/badline/cartridge/magic_desk.rb +5 -1
  29. data/lib/badline/cartridge/ocean.rb +5 -2
  30. data/lib/badline/cartridge/rex_utility.rb +1 -1
  31. data/lib/badline/cartridge/rgcd.rb +1 -1
  32. data/lib/badline/cartridge/simons_basic.rb +2 -2
  33. data/lib/badline/cartridge/westermann.rb +2 -2
  34. data/lib/badline/cartridge.rb +9 -0
  35. data/lib/badline/checkpoint.rb +98 -0
  36. data/lib/badline/cia/interrupt_register.rb +34 -10
  37. data/lib/badline/cia.rb +19 -10
  38. data/lib/badline/color_memory.rb +2 -2
  39. data/lib/badline/computer.rb +34 -14
  40. data/lib/badline/cpu.rb +2 -2
  41. data/lib/badline/gui/application.rb +70 -26
  42. data/lib/badline/gui/gamepads.rb +22 -22
  43. data/lib/badline/gui/joy_map.rb +1 -1
  44. data/lib/badline/gui/key_map.rb +6 -1
  45. data/lib/badline/gui/pane.rb +7 -6
  46. data/lib/badline/gui/screen_pane.rb +9 -13
  47. data/lib/badline/gui/window.rb +35 -20
  48. data/lib/badline/gui.rb +1 -1
  49. data/lib/badline/input/mouse1351.rb +21 -6
  50. data/lib/badline/input/paddles.rb +11 -6
  51. data/lib/badline/kernal_trap/channel.rb +102 -7
  52. data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
  53. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  54. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  55. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  56. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  57. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  58. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  59. data/lib/badline/kernal_trap/drive.rb +109 -131
  60. data/lib/badline/kernal_trap/file.rb +6 -14
  61. data/lib/badline/kernal_trap/load.rb +6 -0
  62. data/lib/badline/kernal_trap/save.rb +13 -7
  63. data/lib/badline/keyboard.rb +19 -3
  64. data/lib/badline/media.rb +44 -13
  65. data/lib/badline/memory.rb +2 -2
  66. data/lib/badline/options.rb +209 -0
  67. data/lib/badline/ram_expansion/banking.rb +30 -0
  68. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  69. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  70. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  71. data/lib/badline/ram_expansion.rb +21 -0
  72. data/lib/badline/region.rb +51 -0
  73. data/lib/badline/rom.rb +14 -5
  74. data/lib/badline/sdl/events.rb +47 -0
  75. data/lib/badline/sdl/functions.rb +73 -0
  76. data/lib/badline/sdl.rb +67 -0
  77. data/lib/badline/sid/catch_up.rb +110 -0
  78. data/lib/badline/sid/decimator.rb +6 -1
  79. data/lib/badline/sid/filter.rb +5 -5
  80. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  81. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  82. data/lib/badline/sid/waveform.rb +8 -8
  83. data/lib/badline/sid.rb +19 -93
  84. data/lib/badline/storage/d64_image.rb +10 -0
  85. data/lib/badline/storage/d71_image.rb +12 -0
  86. data/lib/badline/storage/d81_image.rb +9 -0
  87. data/lib/badline/storage/disk_image/bam.rb +111 -0
  88. data/lib/badline/storage/disk_image/directory.rb +79 -0
  89. data/lib/badline/storage/disk_image/writing.rb +110 -0
  90. data/lib/badline/storage/disk_image.rb +49 -4
  91. data/lib/badline/storage/host_directory.rb +17 -4
  92. data/lib/badline/storage/sid_file.rb +36 -2
  93. data/lib/badline/storage.rb +18 -1
  94. data/lib/badline/time_of_day.rb +11 -2
  95. data/lib/badline/version.rb +1 -1
  96. data/lib/badline/via/control_lines.rb +104 -0
  97. data/lib/badline/via/interrupt_register.rb +58 -0
  98. data/lib/badline/via/shift_register.rb +135 -0
  99. data/lib/badline/via/timer1.rb +82 -0
  100. data/lib/badline/via/timer2.rb +84 -0
  101. data/lib/badline/via.rb +262 -0
  102. data/lib/badline/vic/bank.rb +4 -3
  103. data/lib/badline/vic/color_patches.rb +6 -3
  104. data/lib/badline/vic/display_state.rb +4 -0
  105. data/lib/badline/vic/graphics_shifter.rb +49 -8
  106. data/lib/badline/vic/registers.rb +19 -27
  107. data/lib/badline/vic/sequencer.rb +18 -18
  108. data/lib/badline/vic/sequencer_output.rb +4 -4
  109. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  110. data/lib/badline/vic/sprite/shifter.rb +25 -3
  111. data/lib/badline/vic/sprite.rb +5 -4
  112. data/lib/badline/vic/sprites.rb +8 -3
  113. data/lib/badline/vic.rb +98 -46
  114. data/lib/badline.rb +5 -0
  115. data/packaging/homebrew/badline.rb +34 -0
  116. metadata +37 -20
  117. data/exe/badline +0 -75
  118. data/exe/badline-sid +0 -34
  119. data/lib/badline/audio/options.rb +0 -137
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Badline
6
+ # The command line of `badline-ruby`. It opens the window for any media,
7
+ # or with --headless or --audio-out plays or renders a .sid tune without
8
+ # one. It loads nothing else from badline, so the executable can parse
9
+ # and reject its arguments before paying for the emulator.
10
+ class Options
11
+ class Error < StandardError; end
12
+
13
+ FALLBACK_SECONDS = 60.0
14
+
15
+ DEFAULT_RATE = 44_100
16
+
17
+ SID_MODELS = { "6581" => :mos6581, "8580" => :mos8580 }.freeze
18
+
19
+ BANNER = <<~BANNER.freeze
20
+ Usage: badline-ruby [options] [media]
21
+ badline-ruby --headless [options] tune.sid
22
+ badline-ruby [options] tune.sid --audio-out FILE
23
+
24
+ Media can be a .prg/.p00 program, a .d64/.d71/.d81 disk image,
25
+ a .t64 tape archive, a .tap tape, a .crt cartridge, a .sid tune, or
26
+ a directory to mount as device 8. It opens in the emulator window.
27
+
28
+ --headless plays a .sid tune on the host's audio device without the
29
+ window, and --audio-out renders it to 16-bit PCM instead. The
30
+ container follows the file's extension, .wav or .aiff. Played on a
31
+ terminal, n or → skips to the next song, p or ← to the previous one,
32
+ space pauses and q quits.
33
+
34
+ A .sid file carries no length of its own. Without --seconds the tune is
35
+ looked up by MD5 in HVSC's Songlengths.md5, taken from --songlengths, from
36
+ the DOCUMENTS directory of an HVSC collection above the tune, or from
37
+ $HVSC_BASE. Failing all of those it runs for #{FALLBACK_SECONDS.to_i} seconds.
38
+
39
+ Without the window, PSID tunes run on a bare CPU and SID, faster than
40
+ real time. RSID tunes boot the whole machine and run at about half
41
+ real time, so they stutter when played. --filter-chunk 1 runs the
42
+ filter cycle by cycle, which is exact but takes about twice as long.
43
+
44
+ BANNER
45
+
46
+ attr_reader :media_path, :audio_out, :song, :seconds, :songlengths, :sid_model, :filter_chunk
47
+
48
+ alias tune_path media_path
49
+
50
+ def self.parse(argv) = new.parse(argv)
51
+
52
+ def initialize
53
+ @autostart = true
54
+ @read_only = false
55
+ @sound = false
56
+ @headless = false
57
+ @jit = true
58
+ @quiet = false
59
+ @tui = true
60
+ @help = false
61
+ @window_only = []
62
+ @headless_only = []
63
+ end
64
+
65
+ def parse(argv)
66
+ @media_path, *extra = parser.parse(argv)
67
+ raise Error, "unexpected argument: #{extra.first}" if extra.any?
68
+
69
+ validate unless help?
70
+ self
71
+ rescue OptionParser::ParseError => e
72
+ raise Error, e.message
73
+ end
74
+
75
+ # Plays or renders a .sid tune without the window.
76
+ def headless? = @headless || render?
77
+
78
+ def window? = !headless?
79
+
80
+ def render? = !audio_out.nil?
81
+
82
+ def rate = @rate || DEFAULT_RATE
83
+
84
+ # How long to play a tune whose length nothing gives.
85
+ def fallback_seconds = FALLBACK_SECONDS
86
+
87
+ def rate_given? = !@rate.nil?
88
+
89
+ def help = parser.help
90
+
91
+ def help? = @help
92
+
93
+ def autostart? = @autostart
94
+
95
+ def read_only? = @read_only
96
+
97
+ def sound? = @sound
98
+
99
+ def quiet? = @quiet
100
+
101
+ def tui? = @tui
102
+
103
+ def jit? = @jit
104
+
105
+ private
106
+
107
+ def validate
108
+ validate_mode
109
+ validate_numbers
110
+ validate_paths
111
+ end
112
+
113
+ def validate_mode
114
+ if headless?
115
+ raise Error, "#{@window_only.first} needs the window" if @window_only.any?
116
+ raise Error, "no tune given" unless media_path
117
+ raise Error, "not a .sid tune: #{media_path}" unless File.extname(media_path).casecmp?(".sid")
118
+ elsif @headless_only.any?
119
+ raise Error, "#{@headless_only.first} needs --headless or --audio-out"
120
+ end
121
+ end
122
+
123
+ def validate_numbers
124
+ raise Error, "invalid argument: --song #{song}" if song&.< 1
125
+ raise Error, "invalid argument: --seconds #{seconds}" unless seconds.nil? || seconds.positive?
126
+ raise Error, "invalid argument: --rate #{rate}" unless rate.positive?
127
+ raise Error, "invalid argument: --filter-chunk #{filter_chunk}" if filter_chunk&.< 1
128
+ end
129
+
130
+ def validate_paths
131
+ [media_path, songlengths].compact.each do |path|
132
+ raise Error, "no such file or directory: #{path}" unless File.exist?(path)
133
+ end
134
+ end
135
+
136
+ def parser
137
+ @parser ||= OptionParser.new do |opts|
138
+ opts.banner = BANNER
139
+ opts.separator "Options:"
140
+ define_media_options(opts)
141
+ opts.separator ""
142
+ opts.separator "Window options:"
143
+ define_window_options(opts)
144
+ opts.separator ""
145
+ opts.separator "Options without the window:"
146
+ define_headless_options(opts)
147
+ end
148
+ end
149
+
150
+ def define_media_options(opts)
151
+ opts.on("-s", "--song N", Integer, "Subtune of a .sid, from 1 (default: the tune's own)") { |n| @song = n }
152
+ opts.on("--sid MODEL", SID_MODELS,
153
+ "SID to fit: 6581 or 8580 (default: a .sid tune's own, else 6581)") { |model| @sid_model = model }
154
+ opts.on("--disable-jit", "Run without enabling YJIT") { @jit = false }
155
+ opts.on("-h", "--help", "Show this help") { @help = true }
156
+ end
157
+
158
+ def define_window_options(opts)
159
+ opts.on("--no-autostart", "Boot to READY. instead of running the program") do
160
+ window_only("--no-autostart") { @autostart = false }
161
+ end
162
+ opts.on("--read-only", "Mount a disk image write-protected, leaving its file unchanged") do
163
+ window_only("--read-only") { @read_only = true }
164
+ end
165
+ opts.on("--sound", "Play the SID through the host's audio device (F10 mutes)") do
166
+ window_only("--sound") { @sound = true }
167
+ end
168
+ end
169
+
170
+ def define_headless_options(opts)
171
+ opts.on("--headless", "Play a .sid tune in the terminal instead of the window") { @headless = true }
172
+ opts.on("--audio-out FILE", "Render a .sid tune to a .wav or .aiff file") { |path| @audio_out = path }
173
+ define_length_options(opts)
174
+ define_rendering_options(opts)
175
+ opts.on("--quiet", "Don't report progress") { headless_only("--quiet") { @quiet = true } }
176
+ opts.on("--no-tui", "Play without the interactive display, even on a terminal") do
177
+ headless_only("--no-tui") { @tui = false }
178
+ end
179
+ end
180
+
181
+ def define_length_options(opts)
182
+ opts.on("--seconds N", Float, "Length to play (default: the tune's own)") do |n|
183
+ headless_only("--seconds") { @seconds = n }
184
+ end
185
+ opts.on("--songlengths PATH", "HVSC Songlengths.md5 to take the length from") do |path|
186
+ headless_only("--songlengths") { @songlengths = path }
187
+ end
188
+ end
189
+
190
+ def define_rendering_options(opts)
191
+ opts.on("--rate HZ", Integer, "Sample rate (default: #{DEFAULT_RATE}, or the audio device's own)") do |n|
192
+ headless_only("--rate") { @rate = n }
193
+ end
194
+ opts.on("--filter-chunk N", Integer, "Filter step in cycles, 1 is exact (default: 4)") do |n|
195
+ headless_only("--filter-chunk") { @filter_chunk = n }
196
+ end
197
+ end
198
+
199
+ def window_only(flag)
200
+ @window_only << flag
201
+ yield
202
+ end
203
+
204
+ def headless_only(flag)
205
+ @headless_only << flag
206
+ yield
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module RAMExpansion
5
+ # The register and page mapping the expansions share. #map lays the
6
+ # selected banks out in the address bus's page tables, and #map_io puts
7
+ # the register over the VIC's I/O page at $D100.
8
+ module Banking
9
+ attr_reader :low_ram, :high_ram, :video_ram
10
+
11
+ # The block runs whenever the register moves a bank.
12
+ def on_change(&block)
13
+ @on_change = block
14
+ end
15
+
16
+ def peek(_addr) = 0xff
17
+
18
+ def map(read_pages, write_pages)
19
+ [read_pages, write_pages].each do |pages|
20
+ pages.fill(@low_ram, 0x00, 0x10)
21
+ pages.fill(@high_ram, 0x10, 0xf0)
22
+ end
23
+ end
24
+
25
+ def map_io(read_pages, write_pages)
26
+ read_pages[0xd1] = write_pages[0xd1] = self
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module RAMExpansion
5
+ # The +256K: four 64K banks, the first of them the machine's own RAM.
6
+ # $D100 picks one for $0000-$0FFF (bits 0-1), one for the VIC (bits 2-3)
7
+ # and one for $1000-$FFFF (bits 6-7). Setting bit 4 locks the register
8
+ # until the next reset.
9
+ class Plus256k
10
+ include Banking
11
+
12
+ def initialize(ram)
13
+ @banks = [ram, Memory.new, Memory.new, Memory.new]
14
+ @on_change = nil
15
+ reset!
16
+ end
17
+
18
+ def poke(_addr, value)
19
+ return if @locked
20
+
21
+ @low_ram = @banks[value & 0b11]
22
+ @video_ram = @banks[(value >> 2) & 0b11]
23
+ @high_ram = @banks[value >> 6]
24
+ @locked = value.anybits?(0x10)
25
+ @on_change&.call
26
+ end
27
+
28
+ def reset!
29
+ @low_ram = @video_ram = @high_ram = @banks[0]
30
+ @locked = false
31
+ end
32
+
33
+ def power_on!
34
+ @banks.drop(1).each(&:clear!)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module RAMExpansion
5
+ # The +60K: a second bank of RAM behind $1000-$FFFF, selected by bit 7
6
+ # of $D100. $0000-$0FFF stays the machine's own RAM, and so does all of
7
+ # what the VIC sees.
8
+ class Plus60k
9
+ include Banking
10
+
11
+ def initialize(ram)
12
+ @low_ram = @video_ram = @high_ram = ram
13
+ @banks = [ram, Memory.new]
14
+ @on_change = nil
15
+ end
16
+
17
+ def poke(_addr, value)
18
+ @high_ram = @banks[value >> 7]
19
+ @on_change&.call
20
+ end
21
+
22
+ def reset!
23
+ @high_ram = @banks[0]
24
+ end
25
+
26
+ def power_on!
27
+ @banks[1].clear!
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module RAMExpansion
5
+ # The machine with no expansion fitted: its own RAM everywhere, and
6
+ # $D100 left to the VIC.
7
+ class Unexpanded
8
+ include Banking
9
+
10
+ def initialize(ram)
11
+ @low_ram = @video_ram = @high_ram = ram
12
+ @on_change = nil
13
+ end
14
+
15
+ def map_io(_read_pages, _write_pages); end
16
+
17
+ def reset!; end
18
+
19
+ def power_on!; end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/ram_expansion/banking"
4
+ require "badline/ram_expansion/unexpanded"
5
+ require "badline/ram_expansion/plus60k"
6
+ require "badline/ram_expansion/plus256k"
7
+
8
+ module Badline
9
+ # RAM expansions that bank extra memory in under the CPU through a
10
+ # register at $D100, in the VIC's I/O page. Any write to $D100-$D1FF
11
+ # sets the register, which reads back as $FF.
12
+ module RAMExpansion
13
+ TYPES = { plus60k: Plus60k, plus256k: Plus256k }.freeze
14
+
15
+ def self.build(type, ram, &)
16
+ expansion = type ? TYPES.fetch(type).new(ram) : Unexpanded.new(ram)
17
+ expansion.on_change(&)
18
+ expansion
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ # = Region
5
+ #
6
+ # The timing and geometry a video standard gives the machine.
7
+ module Region
8
+ # The CPU clock, the VIC's raster, where the beam is blanked, the display
9
+ # window's edges, the part of the raster a front end shows, and the
10
+ # mains frequency that feeds the CIAs' TOD pins.
11
+ #
12
+ # Columns are the VIC's cycles within a line, counted from 0, and a
13
+ # column draws 8 pixels, so the raster is `cycles_per_line * 8` pixels
14
+ # wide. `hblank` and `vblank` are the [first, last] blanked column and
15
+ # line, inclusive, and wrap past the end of the line or frame when first
16
+ # is the larger. `display_x_bounds` holds the display window's
17
+ # [left, right] pixels per CSEL state, 38 columns then 40, and `crop`
18
+ # the [left, top, width, height] a front end shows.
19
+ Profile = Data.define(:name, :clock_hz, :cycles_per_line, :lines_per_frame, :hblank, :vblank,
20
+ :display_x_bounds, :crop, :mains_hz)
21
+
22
+ # The 6569 of a PAL C64: 63 cycles by 312 lines at 985,248 Hz, with
23
+ # 50 Hz mains.
24
+ PAL = Profile.new(
25
+ name: :pal, clock_hz: 985_248, cycles_per_line: 63, lines_per_frame: 312,
26
+ hblank: [61, 9].freeze, vblank: [300, 15].freeze,
27
+ display_x_bounds: [[135, 438].freeze, [128, 447].freeze].freeze,
28
+ crop: [96, 20, 384, 272].freeze, mains_hz: 50
29
+ )
30
+
31
+ # The 6567R8 of an NTSC C64: 65 cycles by 263 lines at 1,022,727 Hz,
32
+ # with 60 Hz mains, and lines 13-40 blanked (Bauer). The machine does
33
+ # not run it yet: the blanked columns and the display window are PAL's
34
+ # stand-ins, and the crop leaves out lines 0-12, which show below the
35
+ # last line.
36
+ NTSC = Profile.new(
37
+ name: :ntsc, clock_hz: 1_022_727, cycles_per_line: 65, lines_per_frame: 263,
38
+ hblank: [61, 9].freeze, vblank: [13, 40].freeze,
39
+ display_x_bounds: [[135, 438].freeze, [128, 447].freeze].freeze,
40
+ crop: [96, 41, 384, 222].freeze, mains_hz: 60
41
+ )
42
+
43
+ # Whether a column or line lies in a [first, last] blanking span.
44
+ def self.blanked?(position, span)
45
+ first, last = span
46
+ return position.between?(first, last) if first <= last
47
+
48
+ position >= first || position <= last
49
+ end
50
+ end
51
+ end
data/lib/badline/rom.rb CHANGED
@@ -1,17 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
+ class << self
5
+ attr_writer :rom_path
6
+
7
+ # The directory ROM images and other bundled binaries load from.
8
+ # Defaults to $BADLINE_ROM_PATH, then to the roms directory next to this
9
+ # file. Assigning nil restores the default.
10
+ def rom_path
11
+ @rom_path ||= ENV.fetch("BADLINE_ROM_PATH", File.expand_path("roms", __dir__))
12
+ end
13
+ end
14
+
4
15
  class ROM < Memory
5
16
  class << self
6
17
  def load(filename, start = 0x0)
7
- data = File.read(file_path(filename)).bytes
18
+ data = read(filename)
8
19
  new(data, length: data.length, start: start)
9
20
  end
10
21
 
11
- private
12
-
13
- def file_path(filename)
14
- File.join(File.dirname(__FILE__), "roms", filename)
22
+ def read(filename)
23
+ File.binread(File.join(Badline.rom_path, filename)).bytes
15
24
  end
16
25
  end
17
26
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module SDL
5
+ KEY_TAB = 0x09
6
+ KEY_F10 = 0x4000_0043
7
+ KMOD_SHIFT = 0x0003
8
+
9
+ # The events the front end handles. SDL_Event is a 56-byte union, read
10
+ # here at the offsets its keyboard, mouse and controller members use.
11
+ Quit = Data.define
12
+ # A held key sends repeats, flagged at byte 13 of the event.
13
+ KeyDown = Data.define(:sym, :mod, :repeat) do
14
+ def initialize(sym:, mod:, repeat: false) = super
15
+ end
16
+ KeyUp = Data.define(:sym, :mod)
17
+ MouseMotion = Data.define(:xrel, :yrel)
18
+ MouseButton = Data.define(:button, :pressed)
19
+ ControllerDevice = Data.define
20
+
21
+ EVENT_SIZE = 56
22
+
23
+ # The next event the front end handles, skipping the others, or nil once
24
+ # the queue is empty.
25
+ def self.poll_event
26
+ buffer = (@event ||= Fiddle::Pointer.malloc(EVENT_SIZE, Fiddle::RUBY_FREE))
27
+ while PollEvent.call(buffer) == 1
28
+ event = decode(buffer.to_str(EVENT_SIZE))
29
+ return event if event
30
+ end
31
+ nil
32
+ end
33
+
34
+ def self.decode(bytes)
35
+ case bytes.unpack1("L")
36
+ when 0x100 then Quit.new
37
+ when 0x300 then KeyDown.new(*bytes.unpack("lS", offset: 20), bytes.getbyte(13) != 0)
38
+ when 0x301 then KeyUp.new(*bytes.unpack("lS", offset: 20))
39
+ when 0x400 then MouseMotion.new(*bytes.unpack("l2", offset: 28))
40
+ when 0x401, 0x402 then MouseButton.new(bytes.getbyte(16), bytes.getbyte(17) == 1)
41
+ when 0x653..0x655 then ControllerDevice.new
42
+ end
43
+ end
44
+
45
+ def self.key_name(sym) = GetKeyName.call(sym)
46
+ end
47
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module SDL
5
+ INIT_AUDIO = 0x10
6
+ INIT_VIDEO = 0x20
7
+ INIT_GAMECONTROLLER = 0x2000
8
+ INIT_EVENTS = 0x4000
9
+
10
+ WINDOWPOS_CENTERED = 0x2fff_0000
11
+ WINDOW_RESIZABLE = 0x20
12
+ RENDERER_ACCELERATED = 0x02
13
+ RENDERER_PRESENTVSYNC = 0x04
14
+
15
+ AUDIO_S16SYS = [1].pack("s").getbyte(0) == 1 ? 0x8010 : 0x9010
16
+ AUDIO_ALLOW_FREQUENCY_CHANGE = 0x01
17
+
18
+ CONTROLLER_AXIS_LEFTX = 0
19
+ CONTROLLER_AXIS_LEFTY = 1
20
+ CONTROLLER_BUTTON_A = 0
21
+ CONTROLLER_BUTTON_B = 1
22
+ CONTROLLER_BUTTON_X = 2
23
+ CONTROLLER_BUTTON_Y = 3
24
+ CONTROLLER_BUTTON_LEFTSHOULDER = 9
25
+ CONTROLLER_BUTTON_RIGHTSHOULDER = 10
26
+ CONTROLLER_BUTTON_DPAD_UP = 11
27
+ CONTROLLER_BUTTON_DPAD_DOWN = 12
28
+ CONTROLLER_BUTTON_DPAD_LEFT = 13
29
+ CONTROLLER_BUTTON_DPAD_RIGHT = 14
30
+
31
+ InitSubSystem = function("SDL_InitSubSystem", [UINT32], INT)
32
+ QuitSubSystem = function("SDL_QuitSubSystem", [UINT32], VOID)
33
+ GetError = function("SDL_GetError", [], STRING)
34
+ SetHint = function("SDL_SetHint", [STRING, STRING], INT)
35
+
36
+ CreateWindow = function("SDL_CreateWindow", [STRING, INT, INT, INT, INT, UINT32], POINTER)
37
+ SetWindowTitle = function("SDL_SetWindowTitle", [POINTER, STRING], VOID)
38
+ DestroyWindow = function("SDL_DestroyWindow", [POINTER], VOID)
39
+ GetCurrentDisplayMode = function("SDL_GetCurrentDisplayMode", [INT, POINTER], INT)
40
+ CreateRenderer = function("SDL_CreateRenderer", [POINTER, INT, UINT32], POINTER)
41
+ DestroyRenderer = function("SDL_DestroyRenderer", [POINTER], VOID)
42
+ RenderSetLogicalSize = function("SDL_RenderSetLogicalSize", [POINTER, INT, INT], INT)
43
+ SetRenderDrawColor = function("SDL_SetRenderDrawColor", [POINTER, UINT8, UINT8, UINT8, UINT8], INT)
44
+ RenderClear = function("SDL_RenderClear", [POINTER], INT)
45
+ RenderCopy = function("SDL_RenderCopy", [POINTER, POINTER, POINTER, POINTER], INT)
46
+ RenderPresent = function("SDL_RenderPresent", [POINTER], VOID)
47
+ CreateRGBSurfaceFrom = function("SDL_CreateRGBSurfaceFrom",
48
+ [POINTER, INT, INT, INT, INT, UINT32, UINT32, UINT32, UINT32], POINTER)
49
+ FreeSurface = function("SDL_FreeSurface", [POINTER], VOID)
50
+ CreateTextureFromSurface = function("SDL_CreateTextureFromSurface", [POINTER, POINTER], POINTER)
51
+ DestroyTexture = function("SDL_DestroyTexture", [POINTER], VOID)
52
+
53
+ PollEvent = function("SDL_PollEvent", [POINTER], INT)
54
+ PushEvent = function("SDL_PushEvent", [POINTER], INT)
55
+ GetKeyName = function("SDL_GetKeyName", [INT], STRING)
56
+ SetRelativeMouseMode = function("SDL_SetRelativeMouseMode", [INT], INT)
57
+
58
+ NumJoysticks = function("SDL_NumJoysticks", [], INT)
59
+ IsGameController = function("SDL_IsGameController", [INT], INT)
60
+ GameControllerOpen = function("SDL_GameControllerOpen", [INT], POINTER)
61
+ GameControllerName = function("SDL_GameControllerName", [POINTER], STRING)
62
+ GameControllerGetButton = function("SDL_GameControllerGetButton", [POINTER, INT], UINT8)
63
+ GameControllerGetAxis = function("SDL_GameControllerGetAxis", [POINTER, INT], INT16)
64
+ GameControllerClose = function("SDL_GameControllerClose", [POINTER], VOID)
65
+
66
+ OpenAudioDevice = function("SDL_OpenAudioDevice", [STRING, INT, POINTER, POINTER, INT], UINT32)
67
+ CloseAudioDevice = function("SDL_CloseAudioDevice", [UINT32], VOID)
68
+ PauseAudioDevice = function("SDL_PauseAudioDevice", [UINT32, INT], VOID)
69
+ QueueAudio = function("SDL_QueueAudio", [UINT32, POINTER, UINT32], INT)
70
+ GetQueuedAudioSize = function("SDL_GetQueuedAudioSize", [UINT32], UINT32)
71
+ ClearQueuedAudio = function("SDL_ClearQueuedAudio", [UINT32], VOID)
72
+ end
73
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Badline
6
+ # The libSDL2 calls the window, the gamepads and the audio sink make,
7
+ # bound through Fiddle, so nothing has to compile against SDL at install
8
+ # time. Only the library itself has to be present.
9
+ #
10
+ # Each function is a constant named after its C name without the prefix:
11
+ # SDL::RenderClear.call(renderer) calls SDL_RenderClear. They're built
12
+ # with need_gvl, which skips releasing Ruby's lock around the call: none
13
+ # of these block for long, and releasing it costs more than most of them.
14
+ module SDL
15
+ class Error < StandardError; end
16
+
17
+ # Where package managers put the shared library. dlopen searches the
18
+ # system paths for the bare names, but not Homebrew's.
19
+ LIBRARIES = %w[
20
+ libSDL2-2.0.so.0 libSDL2.so
21
+ libSDL2-2.0.0.dylib /opt/homebrew/lib/libSDL2-2.0.0.dylib /usr/local/lib/libSDL2-2.0.0.dylib
22
+ SDL2.dll
23
+ ].freeze
24
+
25
+ def self.open_library
26
+ LIBRARIES.each do |name|
27
+ return Fiddle.dlopen(name)
28
+ rescue Fiddle::DLError
29
+ next
30
+ end
31
+ raise Error, "can't find libSDL2 (tried #{LIBRARIES.join(', ')})"
32
+ end
33
+
34
+ LIBRARY = open_library
35
+
36
+ VOID = Fiddle::TYPE_VOID
37
+ INT = Fiddle::TYPE_INT
38
+ UINT8 = Fiddle::TYPE_UINT8_T
39
+ INT16 = Fiddle::TYPE_INT16_T
40
+ UINT32 = Fiddle::TYPE_UINT32_T
41
+ POINTER = Fiddle::TYPE_VOIDP
42
+ STRING = Fiddle::TYPE_CONST_STRING
43
+
44
+ def self.function(name, arguments, result)
45
+ Fiddle::Function.new(LIBRARY[name], arguments, result, name:, need_gvl: true)
46
+ end
47
+
48
+ def self.error = GetError.call
49
+
50
+ # A negative result from SDL is an error, and SDL_GetError says which.
51
+ def self.check(result)
52
+ raise Error, error if result.negative?
53
+
54
+ result
55
+ end
56
+
57
+ # So is a null handle.
58
+ def self.check_pointer(pointer)
59
+ raise Error, error if pointer.null?
60
+
61
+ pointer
62
+ end
63
+ end
64
+ end
65
+
66
+ require "badline/sdl/functions"
67
+ require "badline/sdl/events"