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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ class Disk
6
+ # Saving and restoring a Disk for a snapshot: the image by its path,
7
+ # and every half track as the head sees it, with those written since
8
+ # the last flush. The image itself is read again from its host file.
9
+ module State
10
+ # The image's path, expanded, and whether it was opened read-only,
11
+ # for a disk from Disk.open. Nil for a disk made another way.
12
+ attr_reader :path, :read_only
13
+
14
+ # A .g64 image by its name, and anything else a .d64.
15
+ def self.g64?(path) = File.extname(path).casecmp?(".g64")
16
+
17
+ # A disk for the image at the path, its tracks left for the state.
18
+ def self.reopen(path, read_only)
19
+ if g64?(path)
20
+ Disk.new(Storage::G64Image.new(path, read_only:)).opened(path, read_only)
21
+ else
22
+ Disk.new(Storage::D64Image.new(path, read_only:)).opened(path, read_only)
23
+ end
24
+ end
25
+
26
+ # The disk a state from save_state describes: +current+ when it was
27
+ # opened from the same image the same way, and otherwise a disk for
28
+ # the image at the path the state names, or one without an image,
29
+ # as a detached reader always gets. Either way its tracks come from
30
+ # the state.
31
+ def self.load(input, current)
32
+ path = input.optional_string
33
+ read_only = input.boolean?
34
+ disk = current if current && !path.nil? && current.path == path && current.read_only == read_only
35
+ disk ||= path && !input.detached? ? reopen(path, read_only) : Disk.new
36
+ disk.load_tracks(input)
37
+ disk
38
+ end
39
+
40
+ # Notes the image's path and how it was opened.
41
+ def opened(path, read_only)
42
+ @path = File.expand_path(path)
43
+ @read_only = read_only
44
+ self
45
+ end
46
+
47
+ def save_state(out)
48
+ out.optional_string(@path).boolean(@read_only)
49
+ @tracks.each do |track|
50
+ out.boolean(!track.nil?)
51
+ save_track(track, out) if track
52
+ end
53
+ out.ints(@written.keys)
54
+ end
55
+
56
+ def load_tracks(input)
57
+ @tracks.each_index do |half_track|
58
+ @tracks[half_track] = input.boolean? ? load_track(input) : nil
59
+ end
60
+ @written.clear
61
+ input.ints.each { |half_track| @written[half_track] = true }
62
+ end
63
+
64
+ private
65
+
66
+ def save_track(track, out)
67
+ out.int(track.zone).boolean(!track.speeds.nil?)
68
+ out.ints(track.speeds) if track.speeds
69
+ out.blob(track.bytes)
70
+ end
71
+
72
+ def load_track(input)
73
+ zone = input.int
74
+ speeds = input.boolean? ? input.ints : nil
75
+ Track.new(input.blob, zone, speeds)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/drive1541/rotation"
4
+ require "badline/drive1541/mechanism_state"
4
5
 
5
6
  module Badline
6
7
  class Drive1541
@@ -65,6 +66,7 @@ module Badline
65
66
  # half track after it.
66
67
  class Mechanism
67
68
  include Rotation
69
+ include MechanismState
68
70
 
69
71
  MOTOR = 0x04
70
72
  LED = 0x08
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # Saving and restoring the Mechanism for a snapshot.
6
+ module MechanismState
7
+ # The disk (Disk#save_state) and everything the head, the motor, the
8
+ # stepper and the read and write electronics hold, down to the cell
9
+ # under the head and the read clock's phase.
10
+ def save_state(out)
11
+ out.boolean(!@disk.nil?)
12
+ @disk&.save_state(out)
13
+ [@half_track, @slip, @zone, @clock, @time, @clock_at, @clocks, @shift, @ones, @bits, @write_shift,
14
+ @index, @mask, @cell_end, @width, @next].each { |value| out.int(value) }
15
+ [@motor, @led, @sync, @byte_ready, @writing, @write_gate, @protected, @track_written].each do |flag|
16
+ out.boolean(flag)
17
+ end
18
+ out.optional_int(@write_index)
19
+ end
20
+
21
+ # Puts the state back, reusing the disk in when it's the one the state
22
+ # names (Disk::State.load). A disk taken out is flushed, as insert
23
+ # does.
24
+ def load_state(input)
25
+ disk = input.boolean? ? Disk::State.load(input, @disk) : nil
26
+ flush unless disk.equal?(@disk)
27
+ @disk = disk
28
+ load_head(input)
29
+ load_flags(input)
30
+ @write_index = input.optional_int
31
+ head_on_track
32
+ end
33
+
34
+ private
35
+
36
+ def load_head(input)
37
+ @half_track = input.int
38
+ @slip = input.int
39
+ @zone = input.int
40
+ @clock = input.int
41
+ @time = input.int
42
+ @clock_at = input.int
43
+ @clocks = input.int
44
+ @shift = input.int
45
+ @ones = input.int
46
+ @bits = input.int
47
+ @write_shift = input.int
48
+ @index = input.int
49
+ @mask = input.int
50
+ @cell_end = input.int
51
+ @width = input.int
52
+ @next = input.int
53
+ end
54
+
55
+ def load_flags(input)
56
+ @motor = input.boolean?
57
+ @led = input.boolean?
58
+ @sync = input.boolean?
59
+ @byte_ready = input.boolean?
60
+ @writing = input.boolean?
61
+ @write_gate = input.boolean?
62
+ @protected = input.boolean?
63
+ @track_written = input.boolean?
64
+ end
65
+ end
66
+ end
67
+ end
@@ -92,6 +92,16 @@ module Badline
92
92
  @writing ? write_bit(at) : read_bit(one)
93
93
  end
94
94
 
95
+ # The track under the head and its layout, leaving the head's place
96
+ # on it as it stands.
97
+ def head_on_track
98
+ @track = @disk&.track(@half_track) || BLANK
99
+ @bytes = @track.bytes
100
+ @widths = @track.widths
101
+ @length = @track.length
102
+ @last = @length - 1
103
+ end
104
+
95
105
  # The track under the head, from the cell that's under it +time+
96
106
  # into the turn.
97
107
  def load_track(time = @time)
@@ -44,6 +44,16 @@ module Badline
44
44
  @host = @bus ? @bus.host_lines : 0
45
45
  end
46
46
 
47
+ # The C64's lines as last latched. The jumpers and the bus are the
48
+ # drive's wiring.
49
+ def save_state(out)
50
+ out.int(@host)
51
+ end
52
+
53
+ def load_state(input)
54
+ @host = input.int
55
+ end
56
+
47
57
  # Whether the C64 has moved ATN since the last latch_host.
48
58
  def atn_moved?
49
59
  lines = @bus ? @bus.host_lines : 0
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/drive1541/bus"
4
+ require "badline/drive1541/cpu"
4
5
  require "badline/drive1541/serial_port"
5
6
  require "badline/drive1541/gcr"
6
7
  require "badline/drive1541/track"
@@ -180,6 +181,40 @@ module Badline
180
181
  step
181
182
  end
182
183
 
184
+ # The drive's whole state: its clock phase, the CPU, RAM, both VIAs,
185
+ # the serial port's latch of the C64's lines and the mechanism with its
186
+ # disk. The drive settles first, so it is awake and not recording a
187
+ # pass, and a restored drive starts the same way, its orbits forgotten
188
+ # from the state it takes: Idle's and Orbit's bookkeeping
189
+ # isn't stored. The device number, the host's clock rate and whether
190
+ # the drive may sleep are the machine's wiring.
191
+ def save_state(out)
192
+ settle!
193
+ out.marker("DRIVE1541")
194
+ out.int(@phase).int(@cycles).boolean(@so_pending)
195
+ @serial_port.save_state(out)
196
+ @via1.save_state(out)
197
+ @via2.save_state(out)
198
+ @bus.save_state(out)
199
+ @cpu.save_state(out)
200
+ @mechanism.save_state(out)
201
+ end
202
+
203
+ def load_state(input)
204
+ settle!
205
+ input.marker("DRIVE1541")
206
+ @phase = input.int
207
+ @cycles = input.int
208
+ @so_pending = input.boolean?
209
+ @serial_port.load_state(input)
210
+ @via1.load_state(input)
211
+ @via2.load_state(input)
212
+ @bus.load_state(input)
213
+ @cpu.load_state(input)
214
+ @mechanism.load_state(input)
215
+ forget_orbits
216
+ end
217
+
183
218
  def inspect
184
219
  "#<#{self.class.name} cycles=#{cycles} cpu=(#{cpu.inspect})>"
185
220
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Badline
6
+ # The part of Spinel's FFI DSL the SDL binding uses, for CRuby, over
7
+ # Fiddle, so the one binding in badline/sdl serves both builds. Spinel
8
+ # compiles the declarations itself and never loads this file: require it
9
+ # before a file that declares a binding.
10
+ #
11
+ # The declarations behave as they do in Spinel. ffi_func defines
12
+ # Module.name(...); a pointer argument takes nil for NULL, a Fiddle
13
+ # pointer, a String (its bytes) or an IO::Buffer, whose contents C sees
14
+ # for the call's duration; a NULL pointer comes back as nil. ffi_const
15
+ # sets Module::NAME, ffi_buffer defines Module.name returning a zeroed
16
+ # static buffer, and ffi_read_<width> and ffi_write_<width> define field
17
+ # accessors at a fixed offset into a pointer.
18
+ module FFI
19
+ class Error < StandardError; end
20
+
21
+ TYPES = {
22
+ void: Fiddle::TYPE_VOID,
23
+ int: Fiddle::TYPE_INT, bool: Fiddle::TYPE_INT, long: Fiddle::TYPE_LONG, size_t: Fiddle::TYPE_SIZE_T,
24
+ int8: Fiddle::TYPE_INT8_T, uint8: Fiddle::TYPE_UINT8_T, int16: Fiddle::TYPE_INT16_T,
25
+ uint16: Fiddle::TYPE_UINT16_T, int32: Fiddle::TYPE_INT32_T, uint32: Fiddle::TYPE_UINT32_T,
26
+ float: Fiddle::TYPE_FLOAT, double: Fiddle::TYPE_DOUBLE,
27
+ str: Fiddle::TYPE_CONST_STRING,
28
+ ptr: Fiddle::TYPE_VOIDP, buffer_in: Fiddle::TYPE_VOIDP, buffer_out: Fiddle::TYPE_VOIDP,
29
+ buffer_inout: Fiddle::TYPE_VOIDP, int_array: Fiddle::TYPE_VOIDP, float_array: Fiddle::TYPE_VOIDP
30
+ }.freeze
31
+
32
+ # The argument specs C may write through, so an IO::Buffer passed in one
33
+ # gets back what C left in it.
34
+ WRITABLE = %i[ptr buffer_out buffer_inout].freeze
35
+
36
+ # pack directives for the accessor widths, in the host's byte order.
37
+ WIDTHS = {
38
+ u8: "C", i8: "c", u16: "S", i16: "s", u32: "L", i32: "l", u64: "Q", i64: "q", ptr: "J"
39
+ }.freeze
40
+
41
+ # Where dlopen looks for a library besides its search path, which leaves
42
+ # out Homebrew's, and the versioned names a runtime package installs
43
+ # without the development package's unversioned link.
44
+ DIRECTORIES = [nil, "/opt/homebrew/lib", "/usr/local/lib"].freeze
45
+ VERSIONED = { "SDL2" => %w[libSDL2-2.0.so.0 libSDL2-2.0.0.dylib] }.freeze
46
+
47
+ module_function
48
+
49
+ def open_library(name)
50
+ candidates = library_candidates(name)
51
+ candidates.each do |candidate|
52
+ return Fiddle.dlopen(candidate)
53
+ rescue Fiddle::DLError
54
+ next
55
+ end
56
+ raise Error, "can't find lib#{name} (tried #{candidates.join(', ')})"
57
+ end
58
+
59
+ def library_candidates(name)
60
+ names = ["lib#{name}.dylib", "lib#{name}.so", *VERSIONED.fetch(name, []), "#{name}.dll"]
61
+ DIRECTORIES.flat_map { |dir| names.map { |file| dir ? File.join(dir, file) : file } }
62
+ end
63
+
64
+ # The value Fiddle passes for one argument, and a block that copies what
65
+ # C wrote back into an IO::Buffer once the call returns.
66
+ def argument(spec, value)
67
+ case value
68
+ when IO::Buffer then buffer_argument(spec, value)
69
+ when Array then [value.pack(spec == :float_array ? "d*" : "q*")]
70
+ when true, false then [value ? 1 : 0]
71
+ else [value]
72
+ end
73
+ end
74
+
75
+ def buffer_argument(spec, buffer)
76
+ return [nil] if buffer.null? || buffer.empty?
77
+
78
+ bytes = buffer.get_string
79
+ return [bytes] unless WRITABLE.include?(spec) && !buffer.readonly?
80
+
81
+ [bytes, -> { buffer.set_string(bytes) }]
82
+ end
83
+
84
+ def result(spec, value)
85
+ case spec
86
+ when :ptr then value.null? ? nil : value
87
+ when :bool then value != 0
88
+ else value
89
+ end
90
+ end
91
+
92
+ def read(pointer, offset, width)
93
+ value = Fiddle::Pointer[pointer][offset, width_size(width)].unpack1(WIDTHS.fetch(width))
94
+ width == :ptr ? result(:ptr, Fiddle::Pointer.new(value)) : value
95
+ end
96
+
97
+ def write(pointer, offset, width, value)
98
+ value = value.to_i if width == :ptr
99
+ Fiddle::Pointer[pointer][offset, width_size(width)] = [value || 0].pack(WIDTHS.fetch(width))
100
+ value
101
+ end
102
+
103
+ def width_size(width) = [0].pack(WIDTHS.fetch(width)).bytesize
104
+
105
+ # The declarations, private methods of every module once this file is
106
+ # loaded, as they are keywords of a module body in Spinel.
107
+ module DSL
108
+ private
109
+
110
+ def ffi_lib(name)
111
+ (@ffi_libraries ||= []) << FFI.open_library(name)
112
+ end
113
+
114
+ def ffi_func(name, arguments, returns, blocking: false)
115
+ function = Fiddle::Function.new(ffi_symbol(name), arguments.map { |spec| TYPES.fetch(spec) },
116
+ TYPES.fetch(returns), name: name.to_s, need_gvl: !blocking)
117
+ define_singleton_method(name) do |*values|
118
+ converted = arguments.zip(values).map { |spec, value| FFI.argument(spec, value) }
119
+ returned = function.call(*converted.map(&:first))
120
+ converted.each { |argument| argument[1]&.call }
121
+ FFI.result(returns, returned)
122
+ end
123
+ end
124
+
125
+ def ffi_const(name, value)
126
+ const_set(name, value)
127
+ end
128
+
129
+ def ffi_buffer(name, size)
130
+ buffer = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
131
+ buffer[0, size] = "\0" * size
132
+ define_singleton_method(name) { buffer }
133
+ end
134
+
135
+ WIDTHS.each_key do |width|
136
+ define_method(:"ffi_read_#{width}") do |name, offset|
137
+ define_singleton_method(name) { |pointer| FFI.read(pointer, offset, width) }
138
+ end
139
+
140
+ define_method(:"ffi_write_#{width}") do |name, offset|
141
+ define_singleton_method(name) { |pointer, value| FFI.write(pointer, offset, width, value) }
142
+ end
143
+ end
144
+
145
+ def ffi_symbol(name)
146
+ (@ffi_libraries || []).each do |library|
147
+ return library[name.to_s]
148
+ rescue Fiddle::DLError
149
+ next
150
+ end
151
+ Fiddle::Handle::DEFAULT[name.to_s]
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ Module.include(Badline::FFI::DSL)
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Opens the window, then runs the machine a frame at a time: poll
6
+ # events, clock the frame's cycles, queue the SID's samples, upload the
7
+ # changed lines, present and wait. Pacer decides the cycles and the wait.
8
+ class App
9
+ SCALE = 2
10
+ TITLE = "Badline"
11
+ STAGES = %w[events emulate audio blit present wait].freeze
12
+
13
+ # Takes the frame limit, the pacing, the snapshot path, the sound and
14
+ # the verbosity from Options, and runs the timeline's events.
15
+ def initialize(computer, options, timeline)
16
+ @computer = computer
17
+ @frame_limit = options.frames
18
+ @verbose = options.verbose?
19
+ @pacer = Pacer.new(paced: options.paced?, vsync: options.vsync?, verbose: @verbose, region: computer.region)
20
+ @timeline = timeline
21
+ @snapshots = Snapshots.new(computer, options)
22
+ @screen = Screen.new(computer.vic)
23
+ @led = DriveLed.for(computer)
24
+ @controls = Controls.new(computer)
25
+ @spent = Array.new(STAGES.size, 0.0)
26
+ @slowest = 0.0
27
+ open_window
28
+ @sound = Sound.new(computer.sid, options.sound?, @verbose)
29
+ @gamepads = Gamepads.new(computer, @verbose)
30
+ end
31
+
32
+ def run
33
+ @frames = 0
34
+ @running = true
35
+ @started = @reported = now
36
+ @pacer.start(@started)
37
+ @reported_samples = 0
38
+ frame while @running
39
+ @snapshots.finish
40
+ @computer.drive1541&.flush
41
+ @gamepads.close
42
+ @sound.close
43
+ close_window
44
+ end
45
+
46
+ private
47
+
48
+ def frame
49
+ stamps = [now]
50
+ handle_events
51
+ @gamepads.poll
52
+ stamps << now
53
+ emulate
54
+ stamps << now
55
+ @sound.feed
56
+ stamps << now
57
+ upload
58
+ stamps << now
59
+ draw
60
+ stamps << now
61
+ @pacer.wait(@sound)
62
+ stamps << now
63
+ finish_frame(stamps)
64
+ end
65
+
66
+ def finish_frame(stamps)
67
+ STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
68
+ took = stamps[-2] - stamps.first
69
+ @slowest = took if took > @slowest
70
+ @frames += 1
71
+ @timeline.run(@computer, @frames)
72
+ @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
73
+ @pacer.check(Pacer::EARLY_CHECK, stamps.last - @started, stamps.last) if @frames == Pacer::EARLY_CHECK
74
+ report(stamps.last) if (@frames % 50).zero?
75
+ end
76
+
77
+ def open_window
78
+ abort "SDL_Init: #{SDL.SDL_GetError}" unless SDL.SDL_Init(SDL::INIT_VIDEO | SDL::INIT_EVENTS).zero?
79
+
80
+ SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
81
+ SDL.SDL_SetHint("SDL_MOUSE_RELATIVE_SCALING", "0")
82
+ @window = SDL.SDL_CreateWindow(
83
+ TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
84
+ Screen::WIDTH * SCALE, Screen::HEIGHT * SCALE, SDL::WINDOW_RESIZABLE
85
+ )
86
+ flags = SDL::RENDERER_ACCELERATED
87
+ flags |= SDL::RENDERER_PRESENTVSYNC if @pacer.vsync?
88
+ @renderer = SDL.SDL_CreateRenderer(@window, -1, flags)
89
+ fit_display if @pacer.vsync?
90
+ SDL.SDL_RenderSetLogicalSize(@renderer, Screen::WIDTH, Screen::HEIGHT)
91
+ create_texture
92
+ end
93
+
94
+ def create_texture
95
+ @texture = SDL.SDL_CreateTexture(
96
+ @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
97
+ )
98
+ SDL.rect_w(SDL.rect, Screen::WIDTH)
99
+ SDL.rect_h(SDL.rect, Screen::HEIGHT)
100
+ end
101
+
102
+ def close_window
103
+ SDL.SDL_DestroyTexture(@texture)
104
+ SDL.SDL_DestroyRenderer(@renderer)
105
+ SDL.SDL_DestroyWindow(@window)
106
+ SDL.SDL_Quit
107
+ end
108
+
109
+ def handle_events
110
+ handle_event(SDL.event_type(SDL.event)) while SDL.SDL_PollEvent(SDL.event) != 0
111
+ end
112
+
113
+ def handle_event(type)
114
+ case type
115
+ when SDL::QUIT
116
+ @running = false
117
+ when SDL::KEYDOWN, SDL::KEYUP
118
+ handle_key(SDL.event_scancode(SDL.event), type == SDL::KEYDOWN) if SDL.event_repeat(SDL.event).zero?
119
+ when SDL::MOUSEMOTION
120
+ @controls.mouse_motion(SDL.event_xrel(SDL.event), SDL.event_yrel(SDL.event))
121
+ when SDL::MOUSEBUTTONDOWN, SDL::MOUSEBUTTONUP
122
+ @controls.mouse_button(SDL.event_button(SDL.event), type == SDL::MOUSEBUTTONDOWN)
123
+ when SDL::CONTROLLERDEVICEADDED, SDL::CONTROLLERDEVICEREMOVED
124
+ @gamepads.rescan
125
+ end
126
+ end
127
+
128
+ def handle_key(scancode, down)
129
+ if [Keys::TAB, Keys::F9, Keys::F10].include?(scancode)
130
+ handle_toggle(scancode) if down
131
+ elsif down && @snapshots.key(scancode)
132
+ swap(@snapshots.computer)
133
+ elsif !Snapshots::KEYS.include?(scancode)
134
+ @controls.key(scancode, down)
135
+ end
136
+ end
137
+
138
+ # Runs the restored machine in place of the one before: the screen,
139
+ # the drive LED, the controls, the gamepads and the sound go over to
140
+ # it.
141
+ def swap(computer)
142
+ @computer = computer
143
+ @screen = Screen.new(computer.vic)
144
+ @led = DriveLed.for(computer)
145
+ @controls.computer = computer
146
+ @gamepads.computer = computer
147
+ @sound.sid = computer.sid
148
+ end
149
+
150
+ def handle_toggle(scancode)
151
+ if scancode == Keys::TAB
152
+ @controls.cycle_mode(SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT) ? -1 : 1)
153
+ SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
154
+ elsif scancode == Keys::F9
155
+ @controls.swap_ports
156
+ else
157
+ @sound.toggle_mute
158
+ end
159
+ update_title
160
+ end
161
+
162
+ def update_title
163
+ title = TITLE
164
+ title += " [#{@controls.tag}]" unless @controls.tag.empty?
165
+ title += " [MUTED]" if @sound.muted?
166
+ SDL.SDL_SetWindowTitle(@window, title)
167
+ end
168
+
169
+ def fit_display
170
+ SDL.SDL_GetWindowDisplayMode(@window, SDL.display_mode)
171
+ @pacer.fit(SDL.mode_refresh(SDL.display_mode))
172
+ end
173
+
174
+ def emulate
175
+ computer = @computer
176
+ cycles = @pacer.cycles(@sound)
177
+ i = 0
178
+ while i < cycles
179
+ computer.cycle!
180
+ i += 1
181
+ end
182
+ end
183
+
184
+ def upload
185
+ @screen.update
186
+ SDL.SDL_UpdateTexture(@texture, SDL.rect, @screen.pixels, Screen::ROW_BYTES)
187
+ end
188
+
189
+ def draw
190
+ SDL.SDL_RenderClear(@renderer)
191
+ SDL.SDL_RenderCopy(@renderer, @texture, SDL.rect, SDL.rect)
192
+ @led&.draw(@renderer)
193
+ @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
194
+ SDL.SDL_RenderPresent(@renderer)
195
+ end
196
+
197
+ def report(at)
198
+ @pacer.check(50, at - @reported, at)
199
+ @pacer.measure(50, at - @reported)
200
+ report_frames(at) if @verbose
201
+ @spent = Array.new(STAGES.size, 0.0)
202
+ @slowest = 0.0
203
+ @reported = at
204
+ end
205
+
206
+ def report_frames(at)
207
+ fps = 50 / (at - @reported)
208
+ stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
209
+ puts "#{fps.round(1)} fps, per frame ms: #{stages.join(' ')}, slowest work #{(@slowest * 1000).round(2)}"
210
+ report_sound(at) if @sound.on?
211
+ end
212
+
213
+ def report_sound(at)
214
+ sound = @sound
215
+ rate = (sound.queued - @reported_samples) / (at - @reported)
216
+ queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
217
+ puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
218
+ sound.reset_levels
219
+ @reported_samples = sound.queued
220
+ end
221
+
222
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The host's audio device for --headless, fed through SDL's queue: mono
6
+ # signed 16-bit samples go in at the device's rate and SDL plays them
7
+ # out behind us. It answers what Audio::Playback asks of a sink.
8
+ class AudioSink
9
+ class Error < Audio::Playback::DeviceError; end
10
+
11
+ attr_reader :rate
12
+
13
+ # With `exact_rate` false SDL may pick the device's own rate instead,
14
+ # which #rate then reports.
15
+ def initialize(rate:, exact_rate: false, buffer: 1024)
16
+ @device = 0
17
+ @rate = rate
18
+ @buffer = IO::Buffer.new(4096)
19
+ check(SDL.SDL_InitSubSystem(SDL::INIT_AUDIO))
20
+ open_device(rate, exact_rate ? 0 : SDL::ALLOW_FREQUENCY_CHANGE, buffer)
21
+ end
22
+
23
+ def queue(samples)
24
+ count = samples.size
25
+ bytes = count * 2
26
+ @buffer.resize(bytes) if bytes > @buffer.size
27
+ buffer = @buffer
28
+ i = 0
29
+ while i < count
30
+ buffer.set_value(:s16, i * 2, samples[i])
31
+ i += 1
32
+ end
33
+ check(SDL.SDL_QueueAudio(@device, buffer, bytes))
34
+ end
35
+
36
+ def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
37
+
38
+ def start = SDL.SDL_PauseAudioDevice(@device, 0)
39
+
40
+ def pause = SDL.SDL_PauseAudioDevice(@device, 1)
41
+
42
+ def clear = SDL.SDL_ClearQueuedAudio(@device)
43
+
44
+ def close
45
+ return if @device.zero?
46
+
47
+ SDL.SDL_CloseAudioDevice(@device)
48
+ SDL.SDL_QuitSubSystem(SDL::INIT_AUDIO)
49
+ @device = 0
50
+ end
51
+
52
+ private
53
+
54
+ def open_device(rate, allowed_changes, buffer)
55
+ SDL.spec_freq(SDL.wanted, rate)
56
+ SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
57
+ SDL.spec_channels(SDL.wanted, 1)
58
+ SDL.spec_samples(SDL.wanted, buffer)
59
+ @device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, allowed_changes)
60
+ raise Error, SDL.SDL_GetError if @device.zero?
61
+
62
+ @rate = SDL.read_i32(SDL.obtained)
63
+ end
64
+
65
+ def check(result)
66
+ raise Error, SDL.SDL_GetError if result.negative?
67
+
68
+ result
69
+ end
70
+ end
71
+ end
72
+ end