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
@@ -1,101 +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, or a directory to mount as
27
- device 8. It opens in the emulator window. Device 8 answers through
28
- traps on the KERNAL's disk routines, unless --true-drive puts an
29
- emulated 1541 there, which runs its own DOS and reads .d64 and .g64
30
- images only.
31
-
32
- --headless plays a .sid tune on the host's audio device without the
33
- window, and --audio-out renders it to 16-bit PCM instead. The
34
- container follows the file's extension, .wav or .aiff. Played on a
35
- terminal, n or → skips to the next song, p or ← to the previous one,
36
- space pauses and q quits.
37
-
38
- A .sid file carries no length of its own. Without --seconds the tune is
39
- looked up by MD5 in HVSC's Songlengths.md5, taken from --songlengths, from
40
- the DOCUMENTS directory of an HVSC collection above the tune, or from
41
- $HVSC_BASE. Failing all of those it runs for #{FALLBACK_SECONDS.to_i} seconds.
42
-
43
- Without the window, PSID tunes run on a bare CPU and SID, faster than
44
- real time. RSID tunes boot the whole machine and run at about half
45
- real time, so they stutter when played. --filter-chunk 1 runs the
46
- 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
47
27
 
48
- 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/
49
31
 
50
- 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
51
34
 
52
- alias tune_path media_path
35
+ def self.parse(argv, native: false) = new(native:).parse(argv)
53
36
 
54
- def self.parse(argv) = new.parse(argv)
55
-
56
- 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
57
47
  @autostart = true
58
48
  @read_only = false
59
- @sound = false
49
+ @sound = native
50
+ @frames = 0
51
+ @paced = true
52
+ @vsync = true
60
53
  @verbose = false
61
54
  @true_drive = false
62
55
  @ntsc = false
63
- @headless = false
56
+ @screenshot = ""
57
+ @save_snapshot = ""
58
+ @timeline = []
64
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
65
68
  @quiet = false
66
69
  @tui = true
67
- @help = false
68
70
  @window_only = []
69
71
  @headless_only = []
70
72
  end
71
73
 
72
74
  def parse(argv)
73
- @media_path, *extra = parser.parse(argv)
74
- raise Error, "unexpected argument: #{extra.first}" if extra.any?
75
-
76
- validate unless help?
75
+ args = argv.dup
76
+ argument(args.shift, args) until args.empty?
77
+ validate unless help? || version?
77
78
  self
78
- rescue OptionParser::ParseError => e
79
- raise Error, e.message
80
79
  end
81
80
 
81
+ # Whether the media is a .vsf snapshot to restore.
82
+ def snapshot? = !@media_path.nil? && File.extname(@media_path).casecmp?(".vsf")
83
+
82
84
  # Plays or renders a .sid tune without the window.
83
85
  def headless? = @headless || render?
84
86
 
85
87
  def window? = !headless?
86
88
 
87
- def render? = !audio_out.nil?
89
+ def render? = !@audio_out.nil?
88
90
 
89
- def rate = @rate || DEFAULT_RATE
91
+ # The tune --headless and --audio-out play, for Audio::CLI.
92
+ def tune_path = @media_path
90
93
 
91
94
  # How long to play a tune whose length nothing gives.
92
95
  def fallback_seconds = FALLBACK_SECONDS
93
96
 
97
+ def rate = @rate.nil? ? DEFAULT_RATE : @rate
98
+
94
99
  def rate_given? = !@rate.nil?
95
100
 
96
- def help = parser.help
101
+ def quiet? = @quiet
97
102
 
98
- def help? = @help
103
+ def tui? = @tui
99
104
 
100
105
  def autostart? = @autostart
101
106
 
@@ -103,133 +108,165 @@ module Badline
103
108
 
104
109
  def sound? = @sound
105
110
 
111
+ def paced? = @paced
112
+
113
+ def vsync? = @vsync
114
+
106
115
  def verbose? = @verbose
107
116
 
108
117
  def true_drive? = @true_drive
109
118
 
110
119
  def ntsc? = @ntsc
111
120
 
112
- def quiet? = @quiet
121
+ def jit? = @jit
113
122
 
114
- def tui? = @tui
123
+ def help? = @help
115
124
 
116
- def jit? = @jit
125
+ def version? = @version
117
126
 
118
127
  private
119
128
 
120
- def validate
121
- validate_mode
122
- validate_numbers
123
- 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
124
139
  end
125
140
 
126
- def validate_mode
127
- if headless?
128
- raise Error, "#{@window_only.first} needs the window" if @window_only.any?
129
- raise Error, "no tune given" unless media_path
130
- raise Error, "not a .sid tune: #{media_path}" unless File.extname(media_path).casecmp?(".sid")
131
- elsif @headless_only.any?
132
- 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}"
133
151
  end
134
152
  end
135
153
 
136
- def validate_numbers
137
- raise Error, "invalid argument: --song #{song}" if song&.< 1
138
- raise Error, "invalid argument: --seconds #{seconds}" unless seconds.nil? || seconds.positive?
139
- raise Error, "invalid argument: --rate #{rate}" unless rate.positive?
140
- 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
141
159
  end
142
160
 
143
- def validate_paths
144
- [media_path, songlengths].compact.each do |path|
145
- 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)
146
187
  end
147
188
  end
148
189
 
149
- def parser
150
- @parser ||= OptionParser.new do |opts|
151
- opts.banner = BANNER
152
- opts.separator "Options:"
153
- define_media_options(opts)
154
- opts.separator ""
155
- opts.separator "Window options:"
156
- define_window_options(opts)
157
- opts.separator ""
158
- opts.separator "Options without the window:"
159
- 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
160
207
  end
161
208
  end
162
209
 
163
- def define_media_options(opts)
164
- opts.on("-s", "--song N", Integer, "Subtune of a .sid, from 1 (default: the tune's own)") { |n| @song = n }
165
- opts.on("--sid MODEL", SID_MODELS,
166
- "SID to fit: 6581 or 8580 (default: a .sid tune's own, else 6581)") { |model| @sid_model = model }
167
- opts.on("--disable-jit", "Run without enabling YJIT") { @jit = false }
168
- 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]
169
214
  end
170
215
 
171
- def define_window_options(opts)
172
- opts.on("--no-autostart", "Boot to READY. instead of running the program") do
173
- window_only("--no-autostart") { @autostart = false }
174
- end
175
- opts.on("--read-only", "Mount a disk image write-protected, leaving its file unchanged") do
176
- window_only("--read-only") { @read_only = true }
177
- end
178
- opts.on("--sound", "Play the SID through the host's audio device (F10 mutes)") do
179
- window_only("--sound") { @sound = true }
180
- end
181
- opts.on("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps") do
182
- window_only("--true-drive") { @true_drive = true }
183
- end
184
- opts.on("--reu SIZE", %w[128 256 512 1024 2048 4096 8192 16384],
185
- "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384") do |size|
186
- window_only("--reu") { @reu = size.to_i }
187
- end
188
- opts.on("--ntsc", "Run an NTSC C64, with the 6567R8 VIC-II, instead of a PAL one") do
189
- window_only("--ntsc") { @ntsc = true }
190
- end
191
- opts.on("--verbose", "Print the display, sound and gamepad setup and the frame timing") do
192
- window_only("--verbose") { @verbose = true }
193
- end
216
+ def reu_size(value)
217
+ raise Error, "invalid argument: --reu #{value}" unless REU_SIZES.include?(value)
218
+
219
+ value.to_i
194
220
  end
195
221
 
196
- def define_headless_options(opts)
197
- opts.on("--headless", "Play a .sid tune in the terminal instead of the window") { @headless = true }
198
- opts.on("--audio-out FILE", "Render a .sid tune to a .wav or .aiff file") { |path| @audio_out = path }
199
- define_length_options(opts)
200
- define_rendering_options(opts)
201
- opts.on("--quiet", "Don't report progress") { headless_only("--quiet") { @quiet = true } }
202
- opts.on("--no-tui", "Play without the interactive display, even on a terminal") do
203
- headless_only("--no-tui") { @tui = false }
204
- end
222
+ def number(flag, value)
223
+ raise Error, "invalid argument: #{flag} #{value}" unless value.match?(/\A\d+\z/)
224
+
225
+ value.to_i
205
226
  end
206
227
 
207
- def define_length_options(opts)
208
- opts.on("--seconds N", Float, "Length to play (default: the tune's own)") do |n|
209
- headless_only("--seconds") { @seconds = n }
210
- end
211
- opts.on("--songlengths PATH", "HVSC Songlengths.md5 to take the length from") do |path|
212
- headless_only("--songlengths") { @songlengths = path }
213
- end
228
+ def decimal(flag, value)
229
+ raise Error, "invalid argument: #{flag} #{value}" unless value.match?(DECIMAL)
230
+
231
+ value.to_f
214
232
  end
215
233
 
216
- def define_rendering_options(opts)
217
- opts.on("--rate HZ", Integer, "Sample rate (default: #{DEFAULT_RATE}, or the audio device's own)") do |n|
218
- headless_only("--rate") { @rate = n }
219
- end
220
- opts.on("--filter-chunk N", Integer, "Filter step in cycles, 1 is exact (default: 4)") do |n|
221
- 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"
222
253
  end
223
254
  end
224
255
 
225
- def window_only(flag)
226
- @window_only << flag
227
- 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
228
265
  end
229
266
 
230
- def headless_only(flag)
231
- @headless_only << flag
232
- 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)
233
270
  end
234
271
  end
235
272
  end
@@ -30,6 +30,22 @@ module Badline
30
30
  @locked = false
31
31
  end
32
32
 
33
+ def type = :plus256k
34
+
35
+ def save_state(out)
36
+ [@low_ram, @video_ram, @high_ram].each { |bank| out.int(@banks.index(bank)) }
37
+ out.boolean(@locked)
38
+ @banks.drop(1).each { |bank| bank.save_state(out) }
39
+ end
40
+
41
+ def load_state(input)
42
+ @low_ram = @banks.fetch(input.int)
43
+ @video_ram = @banks.fetch(input.int)
44
+ @high_ram = @banks.fetch(input.int)
45
+ @locked = input.boolean?
46
+ @banks.drop(1).each { |bank| bank.load_state(input) }
47
+ end
48
+
33
49
  def power_on!
34
50
  @banks.drop(1).each(&:clear!)
35
51
  end
@@ -23,6 +23,18 @@ module Badline
23
23
  @high_ram = @banks[0]
24
24
  end
25
25
 
26
+ def type = :plus60k
27
+
28
+ def save_state(out)
29
+ out.int(@banks.index(@high_ram))
30
+ @banks[1].save_state(out)
31
+ end
32
+
33
+ def load_state(input)
34
+ @high_ram = @banks.fetch(input.int)
35
+ @banks[1].load_state(input)
36
+ end
37
+
26
38
  def power_on!
27
39
  @banks[1].clear!
28
40
  end
@@ -12,8 +12,14 @@ module Badline
12
12
  @on_change = nil
13
13
  end
14
14
 
15
+ def type = nil
16
+
15
17
  def map_io(_read_pages, _write_pages); end
16
18
 
19
+ def save_state(_out); end
20
+
21
+ def load_state(_input); end
22
+
17
23
  def reset!; end
18
24
 
19
25
  def power_on!; 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
 
@@ -73,20 +93,34 @@ module Badline
73
93
 
74
94
  # The second BA-low cycle in a row after writes stops the REC, and so
75
95
  # does any BA-low cycle after a read. It then waits for BA to rise.
76
- def note_ba(ba_low)
96
+ def note_ba(ba_low, late)
97
+ seen = ba_low && !late
77
98
  if @last == READ
78
- @waiting = ba_low
99
+ note_ba_after_read(seen)
79
100
  elsif @last == WRITE
80
- @ba_low_after_writes = ba_low ? @ba_low_after_writes + 1 : 0
81
- @stopped_after_write = @ba_low_after_writes == 2
82
- if @stopped_after_write
83
- @waiting = true
84
- @ba_low_after_writes = 0
85
- end
101
+ note_ba_after_write(seen)
86
102
  end
87
103
  @last = IDLE
88
104
  end
89
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
+
90
124
  # A fetch or a swap whose last write ran into BA, or a verify that
91
125
  # found a difference before its last byte, takes one more cycle before
92
126
  # the REC lets go of the bus.
@@ -130,6 +164,8 @@ module Badline
130
164
  # Two cycles a byte: the C64's byte is read and stored while the
131
165
  # REU's is fetched, and the REU's is written to the C64 on the next.
132
166
  def swap(ba_low)
167
+ return read_swap_again if @swap_read_again
168
+
133
169
  if @swap_write_due
134
170
  @swap_write_due = false
135
171
  write_c64(@swap_byte)
@@ -160,6 +196,15 @@ module Badline
160
196
  @ba_low_after_writes = 0
161
197
  end
162
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
+
163
208
  def settle_held_read(ba_low)
164
209
  @held_read -= 1
165
210
  return if @held_read.positive? && ba_low
@@ -37,6 +37,21 @@ module Badline
37
37
  bank(addr >> 16)[addr & 0xffff] = value if addr < @size
38
38
  end
39
39
 
40
+ # The latch, and each 64K bank the REC has touched. A bank it hasn't
41
+ # keeps its power-on contents until it does.
42
+ def save_state(out)
43
+ out.int(@latch)
44
+ @banks.each do |bank|
45
+ out.boolean(!bank.empty?)
46
+ out.blob(bank) unless bank.empty?
47
+ end
48
+ end
49
+
50
+ def load_state(input)
51
+ @latch = input.int
52
+ @banks.each_index { |number| @banks[number] = input.boolean? ? input.blob : [] }
53
+ end
54
+
40
55
  private
41
56
 
42
57
  # A bank takes its power-on contents the first time it is touched,