badline 0.6.0 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +24 -10
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +20 -24
  7. data/lib/badline/address_bus.rb +10 -1
  8. data/lib/badline/audio/cli.rb +18 -5
  9. data/lib/badline/audio/console.rb +2 -1
  10. data/lib/badline/audio.rb +0 -3
  11. data/lib/badline/computer.rb +6 -3
  12. data/lib/badline/cpu/boundary.rb +54 -0
  13. data/lib/badline/cpu/core.rb +165 -0
  14. data/lib/badline/cpu.rb +3 -214
  15. data/lib/badline/drive1541/cpu.rb +29 -0
  16. data/lib/badline/drive1541.rb +1 -0
  17. data/lib/badline/ffi.rb +157 -0
  18. data/lib/badline/frontend/app.rb +225 -0
  19. data/lib/badline/frontend/audio_sink.rb +72 -0
  20. data/lib/badline/frontend/boot.rb +52 -0
  21. data/lib/badline/frontend/controls.rb +163 -0
  22. data/lib/badline/frontend/drive_led.rb +51 -0
  23. data/lib/badline/frontend/frame_rate.rb +83 -0
  24. data/lib/badline/frontend/gamepads.rb +76 -0
  25. data/lib/badline/frontend/keys.rb +48 -0
  26. data/lib/badline/frontend/pacer.rb +100 -0
  27. data/lib/badline/frontend/pad_port.rb +61 -0
  28. data/lib/badline/frontend/screen.rb +65 -0
  29. data/lib/badline/frontend/screenshot.rb +22 -0
  30. data/lib/badline/frontend/snapshots.rb +60 -0
  31. data/lib/badline/frontend/sound.rb +153 -0
  32. data/lib/badline/frontend/timeline.rb +171 -0
  33. data/lib/badline/frontend.rb +18 -0
  34. data/lib/badline/options/event.rb +94 -0
  35. data/lib/badline/options/help.rb +78 -0
  36. data/lib/badline/options/option.rb +29 -0
  37. data/lib/badline/options/table.rb +62 -0
  38. data/lib/badline/options.rb +181 -145
  39. data/lib/badline/reu/dma/saved_state.rb +50 -0
  40. data/lib/badline/reu/dma.rb +56 -47
  41. data/lib/badline/reu/saved_state.rb +2 -1
  42. data/lib/badline/reu.rb +19 -10
  43. data/lib/badline/sdl.rb +123 -51
  44. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  45. data/lib/badline/sid/waveform.rb +2 -3
  46. data/lib/badline/snapshot/state.rb +1 -1
  47. data/lib/badline/version.rb +1 -1
  48. data/lib/badline/vic/bank.rb +21 -0
  49. data/lib/badline/vic.rb +15 -6
  50. metadata +26 -19
  51. data/.claude/hooks/session-start.sh +0 -57
  52. data/.claude/settings.json +0 -14
  53. data/lib/badline/audio/sdl_sink.rb +0 -70
  54. data/lib/badline/audio/stream.rb +0 -123
  55. data/lib/badline/gui/application.rb +0 -243
  56. data/lib/badline/gui/drive_led_pane.rb +0 -29
  57. data/lib/badline/gui/gamepads.rb +0 -104
  58. data/lib/badline/gui/joy_map.rb +0 -27
  59. data/lib/badline/gui/key_map.rb +0 -39
  60. data/lib/badline/gui/palette.rb +0 -35
  61. data/lib/badline/gui/pane.rb +0 -36
  62. data/lib/badline/gui/screen_pane.rb +0 -46
  63. data/lib/badline/gui/snapshots.rb +0 -67
  64. data/lib/badline/gui/window.rb +0 -61
  65. data/lib/badline/gui.rb +0 -14
  66. data/lib/badline/sdl/events.rb +0 -49
  67. data/lib/badline/sdl/functions.rb +0 -74
  68. data/vendor/.gitkeep +0 -0
@@ -1,102 +1,106 @@
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"
4
7
 
5
8
  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.
9
+ # The command line of both builds, badline-ruby and the native badline.
10
+ # Either opens the window for any media, or with --headless or
11
+ # --audio-out plays or renders a .sid tune without one. The native build
12
+ # adds --no-sound and --version, and badline-ruby adds --disable-jit.
13
+ #
14
+ # TABLE lists the options, and both the parser and the help read it. It
15
+ # parses by hand, inside the subset of Ruby Spinel compiles. Values come
16
+ # as `--song 2` or `--song=2`, and `--` ends the options. It loads
17
+ # nothing else from badline, so badline-ruby can parse and reject its
18
+ # arguments before paying for the emulator.
10
19
  class Options
11
20
  class Error < StandardError; end
12
21
 
13
22
  FALLBACK_SECONDS = 60.0
14
23
 
15
- DEFAULT_RATE = 44_100
16
-
17
24
  SID_MODELS = { "6581" => :mos6581, "8580" => :mos8580 }.freeze
18
25
 
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.
26
+ REU_SIZES = %w[128 256 512 1024 2048 4096 8192 16384].freeze
48
27
 
49
- BANNER
28
+ # A Float as OptionParser took one: digits with an optional sign,
29
+ # fraction and exponent.
30
+ DECIMAL = /\A[-+]?(\d+(\.\d+)?|\.\d+)([eE][-+]?\d+)?\z/
50
31
 
51
- attr_reader :media_path, :audio_out, :song, :seconds, :songlengths, :sid_model, :filter_chunk, :reu
32
+ attr_reader :program, :media_path, :song, :sid_model, :reu, :frames, :screenshot, :save_snapshot, :audio_out,
33
+ :seconds, :songlengths, :filter_chunk, :timeline
52
34
 
53
- alias tune_path media_path
35
+ def self.parse(argv, native: false) = new(native:).parse(argv)
54
36
 
55
- def self.parse(argv) = new.parse(argv)
56
-
57
- def initialize
37
+ # The native build plays the SID in the window unless --no-sound says
38
+ # otherwise, and badline-ruby, which runs below real time, only with
39
+ # --sound.
40
+ def initialize(native: false)
41
+ @native = native
42
+ @program = native ? "badline" : "badline-ruby"
43
+ @media_path = nil
44
+ @song = nil
45
+ @sid_model = nil
46
+ @reu = nil
58
47
  @autostart = true
59
48
  @read_only = false
60
- @sound = false
49
+ @sound = native
50
+ @frames = 0
51
+ @paced = true
52
+ @vsync = true
61
53
  @verbose = false
62
54
  @true_drive = false
63
55
  @ntsc = false
64
- @headless = false
56
+ @screenshot = ""
57
+ @save_snapshot = ""
58
+ @timeline = []
65
59
  @jit = true
60
+ @help = false
61
+ @version = false
62
+ @headless = false
63
+ @audio_out = nil
64
+ @seconds = nil
65
+ @songlengths = nil
66
+ @rate = nil
67
+ @filter_chunk = nil
66
68
  @quiet = false
67
69
  @tui = true
68
- @help = false
69
70
  @window_only = []
70
71
  @headless_only = []
71
72
  end
72
73
 
73
74
  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?
75
+ args = argv.dup
76
+ argument(args.shift, args) until args.empty?
77
+ validate unless help? || version?
78
78
  self
79
- rescue OptionParser::ParseError => e
80
- raise Error, e.message
81
79
  end
82
80
 
81
+ # Whether the media is a .vsf snapshot to restore.
82
+ def snapshot? = !@media_path.nil? && File.extname(@media_path).casecmp?(".vsf")
83
+
83
84
  # Plays or renders a .sid tune without the window.
84
85
  def headless? = @headless || render?
85
86
 
86
87
  def window? = !headless?
87
88
 
88
- def render? = !audio_out.nil?
89
+ def render? = !@audio_out.nil?
89
90
 
90
- def rate = @rate || DEFAULT_RATE
91
+ # The tune --headless and --audio-out play, for Audio::CLI.
92
+ def tune_path = @media_path
91
93
 
92
94
  # How long to play a tune whose length nothing gives.
93
95
  def fallback_seconds = FALLBACK_SECONDS
94
96
 
97
+ def rate = @rate.nil? ? DEFAULT_RATE : @rate
98
+
95
99
  def rate_given? = !@rate.nil?
96
100
 
97
- def help = parser.help
101
+ def quiet? = @quiet
98
102
 
99
- def help? = @help
103
+ def tui? = @tui
100
104
 
101
105
  def autostart? = @autostart
102
106
 
@@ -104,133 +108,165 @@ module Badline
104
108
 
105
109
  def sound? = @sound
106
110
 
111
+ def paced? = @paced
112
+
113
+ def vsync? = @vsync
114
+
107
115
  def verbose? = @verbose
108
116
 
109
117
  def true_drive? = @true_drive
110
118
 
111
119
  def ntsc? = @ntsc
112
120
 
113
- def quiet? = @quiet
121
+ def jit? = @jit
114
122
 
115
- def tui? = @tui
123
+ def help? = @help
116
124
 
117
- def jit? = @jit
125
+ def version? = @version
118
126
 
119
127
  private
120
128
 
121
- def validate
122
- validate_mode
123
- validate_numbers
124
- validate_paths
129
+ def takes?(option) = [:both, @native ? :native : :ruby].include?(option.build)
130
+
131
+ def argument(arg, args)
132
+ if arg == "--"
133
+ media_argument(args.shift) until args.empty?
134
+ elsif arg.start_with?("-") && arg != "-"
135
+ option(arg, args)
136
+ else
137
+ media_argument(arg)
138
+ end
125
139
  end
126
140
 
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"
141
+ def option(arg, args)
142
+ flag = arg.start_with?("--") && arg.include?("=") ? arg[0, arg.index("=")] : arg
143
+ option = find(flag)
144
+ restrict(option, flag)
145
+ if flag == arg
146
+ option.valued? ? valued_option(option.name, flag, take_value(flag, args)) : switch(option.name)
147
+ elsif option.valued?
148
+ valued_option(option.name, flag, arg[flag.size + 1, arg.size - flag.size - 1])
149
+ else
150
+ raise Error, "needless argument: #{arg}"
134
151
  end
135
152
  end
136
153
 
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
154
+ def find(flag)
155
+ option = TABLE.find { |candidate| candidate.named?(flag) && takes?(candidate) }
156
+ raise Error, "invalid option: #{flag}" if option.nil?
157
+
158
+ option
142
159
  end
143
160
 
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)
161
+ # Notes an option only the window or only the headless player takes.
162
+ def restrict(option, flag)
163
+ @window_only << flag if option.needs == :window
164
+ @headless_only << flag if option.needs == :headless
165
+ end
166
+
167
+ def take_value(flag, args)
168
+ raise Error, "missing argument: #{flag}" if args.empty?
169
+
170
+ args.shift
171
+ end
172
+
173
+ def valued_option(name, flag, value)
174
+ case name
175
+ when "--song" then @song = number(flag, value)
176
+ when "--sid" then @sid_model = sid_model_for(value)
177
+ when "--reu" then @reu = reu_size(value)
178
+ when "--frames" then @frames = number(flag, value)
179
+ when "--screenshot" then @screenshot = value
180
+ when "--save-snapshot" then @save_snapshot = value
181
+ when "--at", "--script" then @timeline.concat(Event.given(name, value))
182
+ when "--audio-out" then @audio_out = value
183
+ when "--seconds" then @seconds = decimal(flag, value)
184
+ when "--songlengths" then @songlengths = value
185
+ when "--rate" then @rate = number(flag, value)
186
+ else @filter_chunk = number(flag, value)
147
187
  end
148
188
  end
149
189
 
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)
190
+ def switch(name)
191
+ case name
192
+ when "--no-autostart" then @autostart = false
193
+ when "--read-only" then @read_only = true
194
+ when "--sound" then @sound = true
195
+ when "--no-sound" then @sound = false
196
+ when "--no-vsync" then @vsync = false
197
+ when "--verbose" then @verbose = true
198
+ when "--true-drive" then @true_drive = true
199
+ when "--ntsc" then @ntsc = true
200
+ when "--unpaced" then @paced = false
201
+ when "--disable-jit" then @jit = false
202
+ when "--help" then @help = true
203
+ when "--version" then @version = true
204
+ when "--headless" then @headless = true
205
+ when "--quiet" then @quiet = true
206
+ else @tui = false
161
207
  end
162
208
  end
163
209
 
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 }
210
+ def sid_model_for(value)
211
+ raise Error, "invalid argument: --sid #{value}" unless SID_MODELS.key?(value)
212
+
213
+ SID_MODELS[value]
170
214
  end
171
215
 
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
216
+ def reu_size(value)
217
+ raise Error, "invalid argument: --reu #{value}" unless REU_SIZES.include?(value)
218
+
219
+ value.to_i
195
220
  end
196
221
 
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
222
+ def number(flag, value)
223
+ raise Error, "invalid argument: #{flag} #{value}" unless value.match?(/\A\d+\z/)
224
+
225
+ value.to_i
206
226
  end
207
227
 
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 }
214
- end
228
+ def decimal(flag, value)
229
+ raise Error, "invalid argument: #{flag} #{value}" unless value.match?(DECIMAL)
230
+
231
+ value.to_f
215
232
  end
216
233
 
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 }
234
+ def media_argument(arg)
235
+ raise Error, "unexpected argument: #{arg}" unless @media_path.nil?
236
+
237
+ @media_path = arg
238
+ end
239
+
240
+ def validate
241
+ validate_mode
242
+ validate_numbers
243
+ validate_paths
244
+ end
245
+
246
+ def validate_mode
247
+ if headless?
248
+ raise Error, "#{@window_only.first} needs the window" unless @window_only.empty?
249
+ raise Error, "no tune given" if @media_path.nil?
250
+ raise Error, "not a .sid tune: #{@media_path}" unless File.extname(@media_path).casecmp?(".sid")
251
+ elsif !@headless_only.empty?
252
+ raise Error, "#{@headless_only.first} needs --headless or --audio-out"
223
253
  end
224
254
  end
225
255
 
226
- def window_only(flag)
227
- @window_only << flag
228
- yield
256
+ def validate_numbers
257
+ positive("--song", @song)
258
+ positive("--seconds", @seconds)
259
+ positive("--rate", @rate)
260
+ positive("--filter-chunk", @filter_chunk)
261
+ end
262
+
263
+ def positive(name, value)
264
+ raise Error, "invalid argument: #{name} #{value}" if !value.nil? && value <= 0
229
265
  end
230
266
 
231
- def headless_only(flag)
232
- @headless_only << flag
233
- yield
267
+ def validate_paths
268
+ raise Error, "no such file or directory: #{@media_path}" unless @media_path.nil? || File.exist?(@media_path)
269
+ raise Error, "no such file or directory: #{@songlengths}" unless @songlengths.nil? || File.exist?(@songlengths)
234
270
  end
235
271
  end
236
272
  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