badline 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +135 -42
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +28 -24
  7. data/lib/badline/address_bus/extra_sids.rb +30 -0
  8. data/lib/badline/address_bus/sid_slots.rb +42 -0
  9. data/lib/badline/address_bus.rb +15 -1
  10. data/lib/badline/audio/aiff.rb +2 -2
  11. data/lib/badline/audio/bare_player.rb +23 -12
  12. data/lib/badline/audio/cli.rb +167 -59
  13. data/lib/badline/audio/console.rb +2 -1
  14. data/lib/badline/audio/jukebox.rb +121 -36
  15. data/lib/badline/audio/machine_player.rb +25 -10
  16. data/lib/badline/audio/pcm_writer.rb +10 -6
  17. data/lib/badline/audio/playback.rb +6 -5
  18. data/lib/badline/audio/queued_tune.rb +89 -0
  19. data/lib/badline/audio/renderer.rb +57 -10
  20. data/lib/badline/audio/stereo.rb +97 -0
  21. data/lib/badline/audio/terminal.rb +39 -6
  22. data/lib/badline/audio/wav.rb +1 -1
  23. data/lib/badline/audio.rb +2 -3
  24. data/lib/badline/computer.rb +6 -3
  25. data/lib/badline/cpu/boundary.rb +54 -0
  26. data/lib/badline/cpu/core.rb +165 -0
  27. data/lib/badline/cpu.rb +3 -214
  28. data/lib/badline/drive1541/cpu.rb +29 -0
  29. data/lib/badline/drive1541.rb +1 -0
  30. data/lib/badline/ffi.rb +157 -0
  31. data/lib/badline/frontend/app.rb +225 -0
  32. data/lib/badline/frontend/audio_sink.rb +73 -0
  33. data/lib/badline/frontend/boot.rb +52 -0
  34. data/lib/badline/frontend/buttons.rb +90 -0
  35. data/lib/badline/frontend/controls.rb +163 -0
  36. data/lib/badline/frontend/drive_led.rb +51 -0
  37. data/lib/badline/frontend/filter_view.rb +96 -0
  38. data/lib/badline/frontend/frame_rate.rb +83 -0
  39. data/lib/badline/frontend/gamepads.rb +76 -0
  40. data/lib/badline/frontend/info_view.rb +120 -0
  41. data/lib/badline/frontend/keys.rb +48 -0
  42. data/lib/badline/frontend/pacer.rb +100 -0
  43. data/lib/badline/frontend/pad_port.rb +61 -0
  44. data/lib/badline/frontend/painter.rb +175 -0
  45. data/lib/badline/frontend/player_commands.rb +33 -0
  46. data/lib/badline/frontend/player_footer.rb +86 -0
  47. data/lib/badline/frontend/player_header.rb +60 -0
  48. data/lib/badline/frontend/player_screen.rb +124 -0
  49. data/lib/badline/frontend/player_state.rb +67 -0
  50. data/lib/badline/frontend/player_window.rb +228 -0
  51. data/lib/badline/frontend/scope.rb +42 -0
  52. data/lib/badline/frontend/screen.rb +65 -0
  53. data/lib/badline/frontend/screenshot.rb +22 -0
  54. data/lib/badline/frontend/sid_history.rb +175 -0
  55. data/lib/badline/frontend/sid_view.rb +140 -0
  56. data/lib/badline/frontend/snapshots.rb +60 -0
  57. data/lib/badline/frontend/sound.rb +153 -0
  58. data/lib/badline/frontend/timeline.rb +171 -0
  59. data/lib/badline/frontend/visualizer_view.rb +101 -0
  60. data/lib/badline/frontend.rb +32 -0
  61. data/lib/badline/media/queue.rb +158 -0
  62. data/lib/badline/media.rb +13 -8
  63. data/lib/badline/options/event.rb +94 -0
  64. data/lib/badline/options/help.rb +107 -0
  65. data/lib/badline/options/option.rb +29 -0
  66. data/lib/badline/options/table.rb +64 -0
  67. data/lib/badline/options/validation.rb +54 -0
  68. data/lib/badline/options.rb +204 -149
  69. data/lib/badline/reu/dma/saved_state.rb +50 -0
  70. data/lib/badline/reu/dma.rb +56 -47
  71. data/lib/badline/reu/saved_state.rb +2 -1
  72. data/lib/badline/reu.rb +19 -10
  73. data/lib/badline/sdl.rb +133 -51
  74. data/lib/badline/sid/catch_up.rb +11 -1
  75. data/lib/badline/sid/filter.rb +9 -0
  76. data/lib/badline/sid/voice.rb +6 -0
  77. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  78. data/lib/badline/sid/waveform.rb +22 -8
  79. data/lib/badline/sid.rb +35 -3
  80. data/lib/badline/snapshot/state.rb +1 -1
  81. data/lib/badline/storage/hvsc.rb +41 -0
  82. data/lib/badline/storage/sid_file.rb +45 -25
  83. data/lib/badline/storage/song_lengths.rb +6 -29
  84. data/lib/badline/storage/stil/credit.rb +50 -0
  85. data/lib/badline/storage/stil.rb +126 -0
  86. data/lib/badline/storage.rb +8 -0
  87. data/lib/badline/version.rb +1 -1
  88. data/lib/badline/vic/bank.rb +21 -0
  89. data/lib/badline/vic.rb +15 -6
  90. metadata +49 -19
  91. data/.claude/hooks/session-start.sh +0 -57
  92. data/.claude/settings.json +0 -14
  93. data/lib/badline/audio/sdl_sink.rb +0 -70
  94. data/lib/badline/audio/stream.rb +0 -123
  95. data/lib/badline/gui/application.rb +0 -243
  96. data/lib/badline/gui/drive_led_pane.rb +0 -29
  97. data/lib/badline/gui/gamepads.rb +0 -104
  98. data/lib/badline/gui/joy_map.rb +0 -27
  99. data/lib/badline/gui/key_map.rb +0 -39
  100. data/lib/badline/gui/palette.rb +0 -35
  101. data/lib/badline/gui/pane.rb +0 -36
  102. data/lib/badline/gui/screen_pane.rb +0 -46
  103. data/lib/badline/gui/snapshots.rb +0 -67
  104. data/lib/badline/gui/window.rb +0 -61
  105. data/lib/badline/gui.rb +0 -14
  106. data/lib/badline/sdl/events.rb +0 -49
  107. data/lib/badline/sdl/functions.rb +0 -74
  108. data/vendor/.gitkeep +0 -0
@@ -1,102 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
3
+ require "badline/options/option"
4
+ require "badline/options/event"
5
+ require "badline/options/table"
6
+ require "badline/options/help"
7
+ require "badline/options/validation"
4
8
 
5
9
  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
+ # The command line of both builds, badline-ruby and the native badline.
11
+ # Either opens the window for any media, or with --headless or
12
+ # --audio-out plays or renders a .sid tune without one. `sid`, given
13
+ # first, plays .sid tunes and directories of them in the terminal. The
14
+ # native build adds --no-sound and --version, and badline-ruby adds
15
+ # --disable-jit.
16
+ #
17
+ # TABLE lists the options, and both the parser and the help read it. It
18
+ # parses by hand, inside the subset of Ruby Spinel compiles. Values come
19
+ # as `--subtune 2` or `--subtune=2`, and `--` ends the options. It loads
20
+ # nothing else from badline, so badline-ruby can parse and reject its
21
+ # arguments before paying for the emulator.
10
22
  class Options
23
+ include Validation
24
+
11
25
  class Error < StandardError; end
12
26
 
13
27
  FALLBACK_SECONDS = 60.0
14
28
 
15
- DEFAULT_RATE = 44_100
29
+ # A tune on the fallback length ends sooner once it has been silent
30
+ # this long.
31
+ SILENCE_SECONDS = 5.0
16
32
 
17
33
  SID_MODELS = { "6581" => :mos6581, "8580" => :mos8580 }.freeze
18
34
 
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, a
25
- .g64 disk image for the true 1541, a .t64 tape archive, a .tap
26
- tape, a .crt cartridge, a .sid tune, a .vsf snapshot, or a directory
27
- to mount as device 8. It opens in the emulator window, where F11
28
- saves a snapshot and F12 restores it. Device 8 answers through
29
- traps on the KERNAL's disk routines, unless --true-drive puts an
30
- emulated 1541 there, which runs its own DOS and reads .d64 and .g64
31
- images only.
32
-
33
- --headless plays a .sid tune on the host's audio device without the
34
- window, and --audio-out renders it to 16-bit PCM instead. The
35
- container follows the file's extension, .wav or .aiff. Played on a
36
- terminal, n or → skips to the next song, p or ← to the previous one,
37
- space pauses and q quits.
38
-
39
- A .sid file carries no length of its own. Without --seconds the tune is
40
- looked up by MD5 in HVSC's Songlengths.md5, taken from --songlengths, from
41
- the DOCUMENTS directory of an HVSC collection above the tune, or from
42
- $HVSC_BASE. Failing all of those it runs for #{FALLBACK_SECONDS.to_i} seconds.
43
-
44
- Without the window, PSID tunes run on a bare CPU and SID, faster than
45
- real time. RSID tunes boot the whole machine and run at about half
46
- real time, so they stutter when played. --filter-chunk 1 runs the
47
- filter cycle by cycle, which is exact but takes about twice as long.
48
-
49
- BANNER
50
-
51
- attr_reader :media_path, :audio_out, :song, :seconds, :songlengths, :sid_model, :filter_chunk, :reu
52
-
53
- alias tune_path media_path
54
-
55
- def self.parse(argv) = new.parse(argv)
56
-
57
- def initialize
35
+ # What `sid` leaves out besides the window's options.
36
+ NOT_FOR_SID = %w[--audio-out].freeze
37
+
38
+ REU_SIZES = %w[128 256 512 1024 2048 4096 8192 16384].freeze
39
+
40
+ # A Float as OptionParser took one: digits with an optional sign,
41
+ # fraction and exponent.
42
+ DECIMAL = /\A[-+]?(\d+(\.\d+)?|\.\d+)([eE][-+]?\d+)?\z/
43
+
44
+ # tune_paths holds the media given, which `sid` takes more than one of:
45
+ # tunes and directories of tunes, in the order given.
46
+ attr_reader :program, :media_path, :tune_paths, :subtune, :sid_model, :reu, :frames, :screenshot, :save_snapshot,
47
+ :audio_out, :seconds, :songlengths, :filter_chunk, :timeline
48
+
49
+ def self.parse(argv, native: false) = new(native:).parse(argv)
50
+
51
+ # The native build plays the SID in the window unless --no-sound says
52
+ # otherwise, and badline-ruby, which runs below real time, only with
53
+ # --sound.
54
+ def initialize(native: false)
55
+ @native = native
56
+ @program = native ? "badline" : "badline-ruby"
57
+ @media_path = nil
58
+ @sid_command = false
59
+ @tune_paths = []
60
+ @subtune = nil
61
+ @sid_model = nil
62
+ @reu = nil
58
63
  @autostart = true
59
64
  @read_only = false
60
- @sound = false
65
+ @sound = native
66
+ @frames = 0
67
+ @paced = true
68
+ @vsync = true
61
69
  @verbose = false
62
70
  @true_drive = false
63
71
  @ntsc = false
64
- @headless = false
72
+ @screenshot = ""
73
+ @save_snapshot = ""
74
+ @timeline = []
65
75
  @jit = true
76
+ @help = false
77
+ @version = false
78
+ @headless = false
79
+ @audio_out = nil
80
+ @seconds = nil
81
+ @songlengths = nil
82
+ @rate = nil
83
+ @filter_chunk = nil
66
84
  @quiet = false
67
85
  @tui = true
68
- @help = false
86
+ @all_subtunes = false
69
87
  @window_only = []
70
88
  @headless_only = []
71
89
  end
72
90
 
73
91
  def parse(argv)
74
- @media_path, *extra = parser.parse(argv)
75
- raise Error, "unexpected argument: #{extra.first}" if extra.any?
76
-
77
- validate unless help?
92
+ args = argv.dup
93
+ sid_command(args) if args.first == "sid"
94
+ argument(args.shift, args) until args.empty?
95
+ play_lone_tune
96
+ validate unless help? || version?
78
97
  self
79
- rescue OptionParser::ParseError => e
80
- raise Error, e.message
81
98
  end
82
99
 
100
+ # Whether the media is a .vsf snapshot to restore.
101
+ def snapshot? = !@media_path.nil? && File.extname(@media_path).casecmp?(".vsf")
102
+
83
103
  # Plays or renders a .sid tune without the window.
84
- def headless? = @headless || render?
104
+ def headless? = @headless || render? || @sid_command
105
+
106
+ # Whether `sid` came first, to play the tunes and directories given.
107
+ def sid_command? = @sid_command
108
+
109
+ # Whether `sid` plays in the SID player's window rather than the
110
+ # terminal.
111
+ def player_window? = @sid_command && !@headless
85
112
 
86
113
  def window? = !headless?
87
114
 
88
- def render? = !audio_out.nil?
115
+ def render? = !@audio_out.nil?
89
116
 
90
- def rate = @rate || DEFAULT_RATE
117
+ # The tune --headless and --audio-out play, for Audio::CLI.
118
+ def tune_path = @media_path
91
119
 
92
120
  # How long to play a tune whose length nothing gives.
93
121
  def fallback_seconds = FALLBACK_SECONDS
94
122
 
123
+ # How long a tune playing for fallback_seconds may stay silent.
124
+ def silence_seconds = SILENCE_SECONDS
125
+
126
+ def rate = @rate.nil? ? DEFAULT_RATE : @rate
127
+
95
128
  def rate_given? = !@rate.nil?
96
129
 
97
- def help = parser.help
130
+ def quiet? = @quiet
98
131
 
99
- def help? = @help
132
+ def tui? = @tui
133
+
134
+ def all_subtunes? = @all_subtunes
100
135
 
101
136
  def autostart? = @autostart
102
137
 
@@ -104,133 +139,153 @@ module Badline
104
139
 
105
140
  def sound? = @sound
106
141
 
142
+ def paced? = @paced
143
+
144
+ def vsync? = @vsync
145
+
107
146
  def verbose? = @verbose
108
147
 
109
148
  def true_drive? = @true_drive
110
149
 
111
150
  def ntsc? = @ntsc
112
151
 
113
- def quiet? = @quiet
152
+ def jit? = @jit
114
153
 
115
- def tui? = @tui
154
+ def help? = @help
116
155
 
117
- def jit? = @jit
156
+ def version? = @version
118
157
 
119
158
  private
120
159
 
121
- def validate
122
- validate_mode
123
- validate_numbers
124
- validate_paths
160
+ def takes?(option)
161
+ return false if @sid_command && (option.needs == :window || NOT_FOR_SID.include?(option.name))
162
+
163
+ [:both, @native ? :native : :ruby].include?(option.build)
125
164
  end
126
165
 
127
- def validate_mode
128
- if headless?
129
- raise Error, "#{@window_only.first} needs the window" if @window_only.any?
130
- raise Error, "no tune given" unless media_path
131
- raise Error, "not a .sid tune: #{media_path}" unless File.extname(media_path).casecmp?(".sid")
132
- elsif @headless_only.any?
133
- raise Error, "#{@headless_only.first} needs --headless or --audio-out"
134
- end
166
+ def sid_command(args)
167
+ args.shift
168
+ @sid_command = true
135
169
  end
136
170
 
137
- def validate_numbers
138
- raise Error, "invalid argument: --song #{song}" if song&.< 1
139
- raise Error, "invalid argument: --seconds #{seconds}" unless seconds.nil? || seconds.positive?
140
- raise Error, "invalid argument: --rate #{rate}" unless rate.positive?
141
- raise Error, "invalid argument: --filter-chunk #{filter_chunk}" if filter_chunk&.< 1
171
+ # A .sid on its own plays in the SID player, as `sid` would, unless an
172
+ # option of the emulator's window asks for the machine.
173
+ def play_lone_tune
174
+ return if @sid_command || headless? || !@window_only.empty? || @media_path.nil?
175
+
176
+ @sid_command = File.extname(@media_path).casecmp?(".sid")
142
177
  end
143
178
 
144
- def validate_paths
145
- [media_path, songlengths].compact.each do |path|
146
- raise Error, "no such file or directory: #{path}" unless File.exist?(path)
179
+ def argument(arg, args)
180
+ if arg == "--"
181
+ media_argument(args.shift) until args.empty?
182
+ elsif arg.start_with?("-") && arg != "-"
183
+ option(arg, args)
184
+ else
185
+ media_argument(arg)
147
186
  end
148
187
  end
149
188
 
150
- def parser
151
- @parser ||= OptionParser.new do |opts|
152
- opts.banner = BANNER
153
- opts.separator "Options:"
154
- define_media_options(opts)
155
- opts.separator ""
156
- opts.separator "Window options:"
157
- define_window_options(opts)
158
- opts.separator ""
159
- opts.separator "Options without the window:"
160
- define_headless_options(opts)
189
+ def option(arg, args)
190
+ flag = arg.start_with?("--") && arg.include?("=") ? arg[0, arg.index("=")] : arg
191
+ option = find(flag)
192
+ restrict(option, flag)
193
+ if flag == arg
194
+ option.valued? ? valued_option(option.name, flag, take_value(flag, args)) : switch(option.name)
195
+ elsif option.valued?
196
+ valued_option(option.name, flag, arg[flag.size + 1, arg.size - flag.size - 1])
197
+ else
198
+ raise Error, "needless argument: #{arg}"
161
199
  end
162
200
  end
163
201
 
164
- def define_media_options(opts)
165
- opts.on("-s", "--song N", Integer, "Subtune of a .sid, from 1 (default: the tune's own)") { |n| @song = n }
166
- opts.on("--sid MODEL", SID_MODELS,
167
- "SID to fit: 6581 or 8580 (default: a .sid tune's own, else 6581)") { |model| @sid_model = model }
168
- opts.on("--disable-jit", "Run without enabling YJIT") { @jit = false }
169
- opts.on("-h", "--help", "Show this help") { @help = true }
202
+ def find(flag)
203
+ option = TABLE.find { |candidate| candidate.named?(flag) && takes?(candidate) }
204
+ raise Error, "invalid option: #{flag}" if option.nil?
205
+
206
+ option
170
207
  end
171
208
 
172
- def define_window_options(opts)
173
- opts.on("--no-autostart", "Boot to READY. instead of running the program") do
174
- window_only("--no-autostart") { @autostart = false }
175
- end
176
- opts.on("--read-only", "Mount a disk image write-protected, leaving its file unchanged") do
177
- window_only("--read-only") { @read_only = true }
178
- end
179
- opts.on("--sound", "Play the SID through the host's audio device (F10 mutes)") do
180
- window_only("--sound") { @sound = true }
181
- end
182
- opts.on("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps") do
183
- window_only("--true-drive") { @true_drive = true }
184
- end
185
- opts.on("--reu SIZE", %w[128 256 512 1024 2048 4096 8192 16384],
186
- "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384") do |size|
187
- window_only("--reu") { @reu = size.to_i }
188
- end
189
- opts.on("--ntsc", "Run an NTSC C64, with the 6567R8 VIC-II, instead of a PAL one") do
190
- window_only("--ntsc") { @ntsc = true }
191
- end
192
- opts.on("--verbose", "Print the display, sound and gamepad setup and the frame timing") do
193
- window_only("--verbose") { @verbose = true }
194
- end
209
+ # Notes an option only the window or only the headless player takes.
210
+ def restrict(option, flag)
211
+ @window_only << flag if option.needs == :window
212
+ @headless_only << flag if option.needs == :headless
195
213
  end
196
214
 
197
- def define_headless_options(opts)
198
- opts.on("--headless", "Play a .sid tune in the terminal instead of the window") { @headless = true }
199
- opts.on("--audio-out FILE", "Render a .sid tune to a .wav or .aiff file") { |path| @audio_out = path }
200
- define_length_options(opts)
201
- define_rendering_options(opts)
202
- opts.on("--quiet", "Don't report progress") { headless_only("--quiet") { @quiet = true } }
203
- opts.on("--no-tui", "Play without the interactive display, even on a terminal") do
204
- headless_only("--no-tui") { @tui = false }
205
- end
215
+ def take_value(flag, args)
216
+ raise Error, "missing argument: #{flag}" if args.empty?
217
+
218
+ args.shift
206
219
  end
207
220
 
208
- def define_length_options(opts)
209
- opts.on("--seconds N", Float, "Length to play (default: the tune's own)") do |n|
210
- headless_only("--seconds") { @seconds = n }
211
- end
212
- opts.on("--songlengths PATH", "HVSC Songlengths.md5 to take the length from") do |path|
213
- headless_only("--songlengths") { @songlengths = path }
221
+ def valued_option(name, flag, value)
222
+ case name
223
+ when "--subtune" then @subtune = number(flag, value)
224
+ when "--sid" then @sid_model = sid_model_for(value)
225
+ when "--reu" then @reu = reu_size(value)
226
+ when "--frames" then @frames = number(flag, value)
227
+ when "--screenshot" then @screenshot = value
228
+ when "--save-snapshot" then @save_snapshot = value
229
+ when "--at", "--script" then @timeline.concat(Event.given(name, value))
230
+ when "--audio-out" then @audio_out = value
231
+ when "--seconds" then @seconds = decimal(flag, value)
232
+ when "--songlengths" then @songlengths = value
233
+ when "--rate" then @rate = number(flag, value)
234
+ else @filter_chunk = number(flag, value)
214
235
  end
215
236
  end
216
237
 
217
- def define_rendering_options(opts)
218
- opts.on("--rate HZ", Integer, "Sample rate (default: #{DEFAULT_RATE}, or the audio device's own)") do |n|
219
- headless_only("--rate") { @rate = n }
220
- end
221
- opts.on("--filter-chunk N", Integer, "Filter step in cycles, 1 is exact (default: 4)") do |n|
222
- headless_only("--filter-chunk") { @filter_chunk = n }
238
+ def switch(name)
239
+ case name
240
+ when "--no-autostart" then @autostart = false
241
+ when "--read-only" then @read_only = true
242
+ when "--sound" then @sound = true
243
+ when "--no-sound" then @sound = false
244
+ when "--no-vsync" then @vsync = false
245
+ when "--verbose" then @verbose = true
246
+ when "--true-drive" then @true_drive = true
247
+ when "--ntsc" then @ntsc = true
248
+ when "--unpaced" then @paced = false
249
+ when "--disable-jit" then @jit = false
250
+ when "--help" then @help = true
251
+ when "--version" then @version = true
252
+ when "--headless" then @headless = true
253
+ when "--quiet" then @quiet = true
254
+ when "--all-subtunes" then @all_subtunes = true
255
+ else @tui = false
223
256
  end
224
257
  end
225
258
 
226
- def window_only(flag)
227
- @window_only << flag
228
- yield
259
+ def sid_model_for(value)
260
+ return if value == "auto"
261
+ raise Error, "invalid argument: --sid #{value}" unless SID_MODELS.key?(value)
262
+
263
+ SID_MODELS[value]
264
+ end
265
+
266
+ def reu_size(value)
267
+ raise Error, "invalid argument: --reu #{value}" unless REU_SIZES.include?(value)
268
+
269
+ value.to_i
270
+ end
271
+
272
+ def number(flag, value)
273
+ raise Error, "invalid argument: #{flag} #{value}" unless value.match?(/\A\d+\z/)
274
+
275
+ value.to_i
229
276
  end
230
277
 
231
- def headless_only(flag)
232
- @headless_only << flag
233
- yield
278
+ def decimal(flag, value)
279
+ raise Error, "invalid argument: #{flag} #{value}" unless value.match?(DECIMAL)
280
+
281
+ value.to_f
282
+ end
283
+
284
+ def media_argument(arg)
285
+ raise Error, "unexpected argument: #{arg}" unless @sid_command || @media_path.nil?
286
+
287
+ @media_path = arg if @media_path.nil?
288
+ @tune_paths << arg
234
289
  end
235
290
  end
236
291
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class REU
5
+ class DMA
6
+ # A transfer's state for a snapshot (REU::SavedState).
7
+ module SavedState
8
+ # Everything a transfer holds between cycles. Before the first
9
+ # transfer, the fields it sets on starting are empty.
10
+ def save_state(out)
11
+ out.int(@c64).int(@expansion).int(@length).int(@events)
12
+ [@type, @c64_step, @expansion_step, @last, @ba_low_after_writes, @swap_byte, @held_read].each do |value|
13
+ out.optional_int(value)
14
+ end
15
+ [@holding, @waiting, @stopped_after_write, @swap_write_due, @swap_read_again, @moved_all,
16
+ @extra_cycle].each do |flag|
17
+ out.boolean(flag)
18
+ end
19
+ end
20
+
21
+ def load_state(input)
22
+ @c64 = input.int
23
+ @expansion = input.int
24
+ @length = input.int
25
+ @events = input.int
26
+ load_steps(input)
27
+ @holding = input.boolean?
28
+ @waiting = input.boolean?
29
+ @stopped_after_write = input.boolean?
30
+ @swap_write_due = input.boolean?
31
+ @swap_read_again = input.boolean?
32
+ @moved_all = input.boolean?
33
+ @extra_cycle = input.boolean?
34
+ end
35
+
36
+ private
37
+
38
+ def load_steps(input)
39
+ @type = input.optional_int
40
+ @c64_step = input.optional_int
41
+ @expansion_step = input.optional_int
42
+ @last = input.optional_int
43
+ @ba_low_after_writes = input.optional_int
44
+ @swap_byte = input.optional_int
45
+ @held_read = input.optional_int
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/reu/dma/saved_state"
4
+
3
5
  module Badline
4
6
  class REU
5
7
  # One transfer between the C64 and the REU's RAM, clocked a cycle at a
@@ -8,6 +10,8 @@ module Badline
8
10
  # After a write it goes on through the first BA-low cycle and stops at
9
11
  # the second. See doc/pinned-behaviour.md, REU DMA.
10
12
  class DMA
13
+ include SavedState
14
+
11
15
  # Transfer types, as the command register's bottom two bits give
12
16
  # them.
13
17
  STASH = 0
@@ -52,16 +56,32 @@ module Badline
52
56
  @ba_low_after_writes = 0
53
57
  @stopped_after_write = false
54
58
  @swap_write_due = false
59
+ @swap_read_again = false
55
60
  @swap_byte = 0
56
61
  @held_read = 0
57
62
  @moved_all = false
58
63
  @extra_cycle = false
59
64
  end
60
65
 
66
+ # Writes the byte a fetch from these addresses starts with, without
67
+ # counting it. Other transfers start with a read.
68
+ def write_ahead(type, c64, expansion)
69
+ return unless type == FETCH
70
+
71
+ @c64 = c64
72
+ byte = @ram.peek(expansion)
73
+ @ram.latch = byte
74
+ write_c64(byte)
75
+ end
76
+
61
77
  # One cycle of the transfer. The cycle the REC lets go of the bus on
62
- # is the CPU's.
63
- def cycle!(ba_low)
64
- note_ba(ba_low)
78
+ # is the CPU's. late says BA fell on this cycle but a REC that moved a
79
+ # byte on the last one doesn't see it yet (VIC#reu_ba_late?), and
80
+ # handed_on that BA runs on from a bad line's DMA into a sprite's
81
+ # here (VIC#reu_ba_handed_on?).
82
+ def cycle!(ba_low, late, handed_on)
83
+ note_ba(ba_low, late)
84
+ @swap_read_again ||= handed_on && @swap_write_due
65
85
  settle_held_read(ba_low) if @held_read.positive?
66
86
  return if @waiting && ba_low
67
87
 
@@ -69,60 +89,38 @@ module Badline
69
89
  @moved_all ? wind_down : move(ba_low)
70
90
  end
71
91
 
72
- # Everything a transfer holds between cycles. Before the first
73
- # transfer, the fields it sets on starting are empty.
74
- def save_state(out)
75
- out.int(@c64).int(@expansion).int(@length).int(@events)
76
- [@type, @c64_step, @expansion_step, @last, @ba_low_after_writes, @swap_byte, @held_read].each do |value|
77
- out.optional_int(value)
78
- end
79
- [@holding, @waiting, @stopped_after_write, @swap_write_due, @moved_all, @extra_cycle].each do |flag|
80
- out.boolean(flag)
81
- end
82
- end
83
-
84
- def load_state(input)
85
- @c64 = input.int
86
- @expansion = input.int
87
- @length = input.int
88
- @events = input.int
89
- load_steps(input)
90
- @holding = input.boolean?
91
- @waiting = input.boolean?
92
- @stopped_after_write = input.boolean?
93
- @swap_write_due = input.boolean?
94
- @moved_all = input.boolean?
95
- @extra_cycle = input.boolean?
96
- end
97
-
98
92
  private
99
93
 
100
- def load_steps(input)
101
- @type = input.optional_int
102
- @c64_step = input.optional_int
103
- @expansion_step = input.optional_int
104
- @last = input.optional_int
105
- @ba_low_after_writes = input.optional_int
106
- @swap_byte = input.optional_int
107
- @held_read = input.optional_int
108
- end
109
-
110
94
  # The second BA-low cycle in a row after writes stops the REC, and so
111
95
  # does any BA-low cycle after a read. It then waits for BA to rise.
112
- def note_ba(ba_low)
96
+ def note_ba(ba_low, late)
97
+ seen = ba_low && !late
113
98
  if @last == READ
114
- @waiting = ba_low
99
+ note_ba_after_read(seen)
115
100
  elsif @last == WRITE
116
- @ba_low_after_writes = ba_low ? @ba_low_after_writes + 1 : 0
117
- @stopped_after_write = @ba_low_after_writes == 2
118
- if @stopped_after_write
119
- @waiting = true
120
- @ba_low_after_writes = 0
121
- end
101
+ note_ba_after_write(seen)
122
102
  end
123
103
  @last = IDLE
124
104
  end
125
105
 
106
+ # A swap's read that BA stops before its write is made again.
107
+ def note_ba_after_read(ba_low)
108
+ @waiting = ba_low
109
+ @ba_low_after_writes = 0
110
+ @swap_read_again = ba_low && @swap_write_due && @held_read.zero?
111
+ end
112
+
113
+ # A fetch's last write doesn't go through the first BA-low cycle.
114
+ # Pinned by REU/reutiming2/d and d2.
115
+ def note_ba_after_write(ba_low)
116
+ @ba_low_after_writes = ba_low ? @ba_low_after_writes + 1 : 0
117
+ @stopped_after_write = @ba_low_after_writes == 2 || (ba_low && @type == FETCH && @length == 1)
118
+ return unless @stopped_after_write
119
+
120
+ @waiting = true
121
+ @ba_low_after_writes = 0
122
+ end
123
+
126
124
  # A fetch or a swap whose last write ran into BA, or a verify that
127
125
  # found a difference before its last byte, takes one more cycle before
128
126
  # the REC lets go of the bus.
@@ -166,6 +164,8 @@ module Badline
166
164
  # Two cycles a byte: the C64's byte is read and stored while the
167
165
  # REU's is fetched, and the REU's is written to the C64 on the next.
168
166
  def swap(ba_low)
167
+ return read_swap_again if @swap_read_again
168
+
169
169
  if @swap_write_due
170
170
  @swap_write_due = false
171
171
  write_c64(@swap_byte)
@@ -196,6 +196,15 @@ module Badline
196
196
  @ba_low_after_writes = 0
197
197
  end
198
198
 
199
+ # A swap's read that BA cut off from its write is made again when BA
200
+ # rises, and the write follows it. Pinned by REU/reutiming2/e4-m2,
201
+ # e6-m2 and g3-m2.
202
+ def read_swap_again
203
+ @swap_read_again = false
204
+ @ram.poke(@expansion, read_c64)
205
+ @last = READ
206
+ end
207
+
199
208
  def settle_held_read(ba_low)
200
209
  @held_read -= 1
201
210
  return if @held_read.positive? && ba_low
@@ -13,7 +13,7 @@ module Badline
13
13
  out.marker("REU")
14
14
  [@status, @command, @c64, @c64_start, @expansion, @expansion_start, @bank, @bank_start, @length,
15
15
  @length_start, @interrupt_mask, @address_control].each { |value| out.int(value) }
16
- out.boolean(@armed).boolean(@requested).boolean(@running).boolean(@irq)
16
+ out.boolean(@armed).boolean(@requested).boolean(@running).boolean(@irq).boolean(@ba_was_low)
17
17
  @dma.save_state(out)
18
18
  @ram.save_state(out)
19
19
  end
@@ -27,6 +27,7 @@ module Badline
27
27
  @requested = input.boolean?
28
28
  @running = input.boolean?
29
29
  @irq = input.boolean?
30
+ @ba_was_low = input.boolean?
30
31
  @dma.load_state(input)
31
32
  @ram.load_state(input)
32
33
  end