badline 0.7.0 → 0.9.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +104 -292
  5. data/doc/library.md +69 -0
  6. data/doc/media.md +78 -0
  7. data/doc/pinned-behaviour.md +53 -9
  8. data/doc/sid-player.md +136 -0
  9. data/doc/snapshots.md +102 -0
  10. data/lib/badline/address_bus/roms.rb +32 -0
  11. data/lib/badline/address_bus.rb +4 -4
  12. data/lib/badline/audio/bare_player.rb +24 -0
  13. data/lib/badline/audio/checkpoints.rb +42 -0
  14. data/lib/badline/audio/jukebox.rb +11 -4
  15. data/lib/badline/audio/machine_player.rb +16 -0
  16. data/lib/badline/audio/renderer.rb +80 -9
  17. data/lib/badline/audio/stereo.rb +3 -5
  18. data/lib/badline/audio.rb +1 -0
  19. data/lib/badline/cartridge/freezer.rb +2 -0
  20. data/lib/badline/cartridge.rb +3 -0
  21. data/lib/badline/computer/attachments.rb +5 -5
  22. data/lib/badline/computer.rb +12 -10
  23. data/lib/badline/core.rb +48 -0
  24. data/lib/badline/data_path.rb +37 -0
  25. data/lib/badline/datasette.rb +24 -9
  26. data/lib/badline/drive1541/disk.rb +3 -3
  27. data/lib/badline/drive1541/disk_state.rb +14 -5
  28. data/lib/badline/frontend/app.rb +41 -37
  29. data/lib/badline/frontend/boot.rb +16 -4
  30. data/lib/badline/frontend/buttons.rb +101 -7
  31. data/lib/badline/frontend/confirmation.rb +56 -0
  32. data/lib/badline/frontend/controls.rb +44 -32
  33. data/lib/badline/frontend/file_browser.rb +225 -0
  34. data/lib/badline/frontend/frame_report.rb +52 -0
  35. data/lib/badline/frontend/menu_dialogs.rb +160 -0
  36. data/lib/badline/frontend/menu_media.rb +157 -0
  37. data/lib/badline/frontend/menu_pages.rb +220 -0
  38. data/lib/badline/frontend/name_field.rb +88 -0
  39. data/lib/badline/frontend/painter.rb +10 -0
  40. data/lib/badline/frontend/pause_menu.rb +264 -0
  41. data/lib/badline/frontend/player_window.rb +3 -3
  42. data/lib/badline/frontend/snapshots.rb +146 -18
  43. data/lib/badline/frontend/sound.rb +11 -6
  44. data/lib/badline/frontend/timeline.rb +5 -1
  45. data/lib/badline/frontend.rb +8 -0
  46. data/lib/badline/kernal_trap/load.rb +10 -0
  47. data/lib/badline/kernal_trap/routine.rb +28 -0
  48. data/lib/badline/kernal_trap/serial.rb +19 -7
  49. data/lib/badline/media/disk_list.rb +75 -0
  50. data/lib/badline/media/disk_set.rb +118 -0
  51. data/lib/badline/media.rb +9 -3
  52. data/lib/badline/model.rb +62 -0
  53. data/lib/badline/options/event.rb +5 -3
  54. data/lib/badline/options/help.rb +16 -12
  55. data/lib/badline/options/table.rb +6 -2
  56. data/lib/badline/options/validation.rb +9 -0
  57. data/lib/badline/options.rb +10 -7
  58. data/lib/badline/region.rb +16 -4
  59. data/lib/badline/reu/ram.rb +27 -0
  60. data/lib/badline/reu/saved_state.rb +27 -0
  61. data/lib/badline/roms/README +10 -0
  62. data/lib/badline/roms/kernal-sx64.rom +0 -0
  63. data/lib/badline/sid/decimator.rb +13 -0
  64. data/lib/badline/sid/filter.rb +6 -3
  65. data/lib/badline/sid.rb +19 -0
  66. data/lib/badline/snapshot/fields.rb +1 -0
  67. data/lib/badline/snapshot/setup.rb +20 -8
  68. data/lib/badline/snapshot/state.rb +14 -7
  69. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  70. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  71. data/lib/badline/snapshot/vice/restore.rb +3 -1
  72. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  73. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  74. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  75. data/lib/badline/snapshot/vice.rb +25 -23
  76. data/lib/badline/storage/t64.rb +7 -4
  77. data/lib/badline/storage/tap.rb +7 -5
  78. data/lib/badline/storage/unavailable.rb +1 -1
  79. data/lib/badline/storage.rb +5 -5
  80. data/lib/badline/version.rb +1 -1
  81. data/lib/badline/vic/sprite/timing.rb +5 -9
  82. data/lib/badline/vic/sprite.rb +4 -3
  83. data/lib/badline.rb +2 -38
  84. metadata +22 -1
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The emulator's pause menu, which F9 opens over the frozen picture.
6
+ # The machine stands still while it's open. Its sections follow the
7
+ # machine's hardware: the drive, the datasette, the expansion port, the
8
+ # control ports, the sound and the power. It's drawn in colours outside
9
+ # the C64's palette, so it reads as the emulator's and not the
10
+ # machine's, and F9 or Esc closes it again.
11
+ #
12
+ # INSERT on a page opens the file browser for that device. Quick open
13
+ # starts a file as the command line does, in a new machine, and it and
14
+ # a cartridge going in or out ask first, as each power cycles the
15
+ # machine.
16
+ class PauseMenu
17
+ SECTIONS = ["SNAPSHOTS", "DRIVE 8", "DATASETTE", "EXPANSION PORT", "PORTS", "SOUND", "POWER"].freeze
18
+ PAGES = %i[snapshots drive datasette expansion ports sound power].freeze
19
+ DISKS = %w[.d64 .d71 .d81 .g64 .t64].freeze
20
+
21
+ PANEL = 0x1d2230
22
+ EDGE = 0x3a4256
23
+ TEXT = 0xc9d1e0
24
+ BRIGHT = 0xffc66d
25
+ DIM = 0x6b7489
26
+ FILL = 0x343c50
27
+
28
+ LEFT = 16
29
+ TOP = 16
30
+ WIDTH = Screen::WIDTH - 32
31
+ HEIGHT = Screen::HEIGHT - 32
32
+ BODY = 154
33
+
34
+ ESCAPE = 41
35
+ RETURN = 40
36
+ SPACE = 44
37
+ SNAPSHOT_ACTIONS = %i[quicksave_now save_as load_save].freeze
38
+ ARROWS = { 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
39
+ MOUSEWHEEL = 0x403
40
+
41
+ # Takes the media, the REU and whether disks are writable from Options.
42
+ def initialize(painter, options, snapshots)
43
+ media_path = options.media_path.to_s
44
+ @painter = painter
45
+ @buttons = Buttons.new(painter, [TEXT, BRIGHT, PANEL, FILL])
46
+ @pages = MenuPages.new(painter, @buttons, media_path, options, snapshots)
47
+ @snapshots = snapshots
48
+ @dialogs = MenuDialogs.new(painter, @buttons, @pages.media, options, snapshots)
49
+ @open = false
50
+ @section = 0
51
+ end
52
+
53
+ # The path of the disk the command line puts in device 8, a list's
54
+ # first, or an empty one for other media.
55
+ def self.disk_path(path)
56
+ return "" if path.empty?
57
+ return path if File.directory?(path)
58
+ return Media::DiskList.disks(path).first.to_s if Media::DiskList.list?(path)
59
+
60
+ DISKS.include?(File.extname(path).downcase) ? path : ""
61
+ end
62
+
63
+ def open? = @open
64
+
65
+ # The machine Quick open started, which the app runs in place of the
66
+ # one before once #frame returns :swap.
67
+ def computer = @dialogs.computer
68
+
69
+ def show(computer, controls, sound)
70
+ @open = true
71
+ @buttons.focus = PAGES[@section]
72
+ @pages.machine(computer, controls, sound)
73
+ SDL.SDL_SetRelativeMouseMode(0)
74
+ draw
75
+ end
76
+
77
+ def close
78
+ @open = false
79
+ end
80
+
81
+ # Opens on the file the SDL event says was dropped on the window,
82
+ # and says whether the menu stays open to ask about it: a disk or tape
83
+ # goes straight in, and anything else asks first.
84
+ def drop(computer, controls, sound)
85
+ show(computer, controls, sound)
86
+ file = SDL.event_file(SDL.event)
87
+ asks = @dialogs.drop(LibC.strstr(file, ""))
88
+ LibC.free(file)
89
+ asks
90
+ end
91
+
92
+ # Handles a key pressed while the menu is open: the arrows move
93
+ # between the buttons, and a section opens as they reach it, Return
94
+ # or Space presses the button, and F9 or Esc closes the menu. While
95
+ # the file browser or a question is open, the keys go to it, and Esc
96
+ # goes back to the page. Returns the action the app has to take on,
97
+ # as #click does.
98
+ def key(scancode)
99
+ return :resume if scancode == Keys::F9
100
+ return @dialogs.key(scancode) if @dialogs.open?
101
+ return :resume if scancode == ESCAPE
102
+ return choose(@buttons.focus) if [RETURN, SPACE].include?(scancode)
103
+
104
+ direction = ARROWS[scancode]
105
+ move(direction) unless direction.nil?
106
+ nil
107
+ end
108
+
109
+ # Handles a click, and returns the action the app has to take on, if
110
+ # any: :resume, :quit, :freeze or :swap.
111
+ def click(left, top)
112
+ action = @buttons.action_at(left, top)
113
+ return @dialogs.click(action, top) if %i[browse confirm cancel].include?(action)
114
+
115
+ choose(action)
116
+ end
117
+
118
+ # Handles the events that came in, draws the menu over the frozen
119
+ # picture, dimmed, and returns the action the app has to take on, if
120
+ # any, leaving the events after one for the machine. Key releases
121
+ # still reach the machine, so no key stays held down while it stands
122
+ # still.
123
+ def frame(renderer, texture, controls)
124
+ action = nil
125
+ action = event(SDL.event_type(SDL.event), controls) while action.nil? && SDL.SDL_PollEvent(SDL.event) != 0
126
+ SDL.SDL_SetTextureColorMod(texture, 96, 96, 96)
127
+ SDL.SDL_RenderClear(renderer)
128
+ SDL.SDL_RenderCopy(renderer, texture, nil, nil)
129
+ SDL.SDL_SetTextureColorMod(texture, 255, 255, 255)
130
+ draw
131
+ SDL.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)
132
+ SDL.SDL_RenderPresent(renderer)
133
+ SDL.SDL_Delay(10)
134
+ action
135
+ end
136
+
137
+ def draw
138
+ painter = @painter
139
+ @buttons.forget
140
+ painter.box(LEFT, TOP, WIDTH, HEIGHT, PANEL)
141
+ painter.box(LEFT, TOP + 24, WIDTH, 1, EDGE)
142
+ painter.text(LEFT + 8, TOP + 9, "PAUSED", BRIGHT)
143
+ resume = "RESUME"
144
+ @buttons.plain(LEFT + WIDTH - 8 - Painter.width(resume) - (Buttons::PAD * 2), TOP + 7, resume, :resume)
145
+ return @dialogs.draw(LEFT + 8, TOP + 34, WIDTH - 16) if @dialogs.open?
146
+
147
+ painter.box(BODY - 8, TOP + 32, 1, HEIGHT - 56, EDGE)
148
+ draw_sections
149
+ @buttons.plain(LEFT + 6, TOP + HEIGHT - 28, "QUICK OPEN...", :quick_open)
150
+ painter.text(LEFT + 8, TOP + HEIGHT - 12, "F9/ESC: RESUME", DIM)
151
+ @pages.draw(PAGES[@section], BODY, TOP + 34)
152
+ end
153
+
154
+ private
155
+
156
+ def choose(pressed)
157
+ return nil if pressed.nil?
158
+
159
+ action = @buttons.resolve(pressed)
160
+ index = PAGES.index(action)
161
+ unless index.nil?
162
+ @section = index
163
+ return nil
164
+ end
165
+ return action if %i[resume quit freeze].include?(action)
166
+
167
+ menu_action(action)
168
+ end
169
+
170
+ def menu_action(action)
171
+ return snapshot_action(action) if SNAPSHOT_ACTIONS.include?(action) || MenuPages::RECENT.include?(action)
172
+
173
+ case action
174
+ when :insert_disk then @dialogs.browse(:disk)
175
+ when :insert_tape then @dialogs.browse(:tape)
176
+ when :insert_cartridge then @dialogs.browse(:cartridge)
177
+ when :quick_open then @dialogs.browse(:program)
178
+ when :remove_cartridge then @dialogs.ask_remove
179
+ else @pages.perform(action)
180
+ end
181
+ nil
182
+ end
183
+
184
+ # Saves now, asks for a name to save under, opens the saves folder,
185
+ # or loads one of the page's quicksaves and autosaves, returning
186
+ # :swap once a save has loaded.
187
+ def snapshot_action(action)
188
+ case action
189
+ when :quicksave_now then @snapshots.quicksave
190
+ when :save_as then @dialogs.ask_name(@snapshots.free_name(@pages.media.game_name))
191
+ when :load_save then @dialogs.browse(:snapshot)
192
+ else return @dialogs.load(@pages.recent(action))
193
+ end
194
+ nil
195
+ end
196
+
197
+ # Moves the focus: down from RESUME to the sections, through them,
198
+ # where each opens as it's reached, on to Quick open, right into a
199
+ # page, up and down it, and left out of it again.
200
+ def move(direction)
201
+ focus = @buttons.focus
202
+ section = focus == :quick_open ? PAGES.size : PAGES.index(focus)
203
+ if focus == :resume
204
+ @buttons.focus = PAGES[@section] if %i[down left].include?(direction)
205
+ elsif section.nil?
206
+ move_in_page(direction)
207
+ else
208
+ move_in_sections(section, direction)
209
+ end
210
+ end
211
+
212
+ # Up and down go through the page's rows, and left goes back to its
213
+ # section.
214
+ def move_in_page(direction)
215
+ case direction
216
+ when :up, :down then @buttons.shift(direction == :up ? -1 : 1, PAGES.size + 2)
217
+ when :left then @buttons.focus = PAGES[@section]
218
+ end
219
+ end
220
+
221
+ def move_in_sections(section, direction)
222
+ case direction
223
+ when :up then section.zero? ? @buttons.focus = :resume : open_section(section - 1)
224
+ when :down then section < PAGES.size - 1 ? open_section(section + 1) : @buttons.focus = :quick_open
225
+ when :right then @buttons.focus = @buttons.action(PAGES.size + 2) || @buttons.focus if section < PAGES.size
226
+ end
227
+ end
228
+
229
+ # Opens a section, laid out at once, so keys that come before the next
230
+ # frame find its rows.
231
+ def open_section(index)
232
+ @section = index
233
+ @buttons.focus = PAGES[index]
234
+ draw
235
+ end
236
+
237
+ def event(type, controls)
238
+ event = SDL.event
239
+ case type
240
+ when SDL::QUIT then :quit
241
+ when SDL::KEYDOWN then key_down(SDL.event_scancode(event), SDL.event_repeat(event))
242
+ when SDL::KEYUP then controls.key(SDL.event_scancode(event), false) && nil
243
+ when SDL::MOUSEMOTION then @buttons.point(SDL.event_x(event), SDL.event_y(event)) && nil
244
+ when SDL::MOUSEBUTTONDOWN
245
+ SDL.event_button(event) == 1 ? click(SDL.event_x(event), SDL.event_y(event)) : nil
246
+ when MOUSEWHEEL then @dialogs.scroll(-3 * SDL.event_x(event)) && nil
247
+ end
248
+ end
249
+
250
+ # A key held down repeats only the arrows.
251
+ def key_down(scancode, repeat)
252
+ repeat.zero? || ARROWS.key?(scancode) ? key(scancode) : nil
253
+ end
254
+
255
+ def draw_sections
256
+ top = TOP + 32
257
+ SECTIONS.each_with_index do |name, index|
258
+ @buttons.plain(LEFT + 6, top, name.ljust(14), PAGES[index], on: index == @section)
259
+ top += Buttons::HEIGHT + 2
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -45,7 +45,7 @@ module Badline
45
45
  @state = PlayerState.new
46
46
  @commands = PlayerCommands.new(@state)
47
47
  @next_draw = 0.0
48
- @seek_left = 0
48
+ @seek_to = 0.0
49
49
  @dropped = []
50
50
  @credits = []
51
51
  @stil = []
@@ -55,7 +55,7 @@ module Badline
55
55
  end
56
56
 
57
57
  # The seconds into the subtune the last click on the bar asked for.
58
- def seek_to = PlayerFooter.seek(@seek_left, @state.length)
58
+ attr_reader :seek_to
59
59
 
60
60
  # The files dropped on the window since it last said :drop.
61
61
  def dropped
@@ -175,7 +175,7 @@ module Badline
175
175
  action = @screen.buttons.action_at(left, SDL.event_y(SDL.event))
176
176
  return if action.nil?
177
177
 
178
- @seek_left = left if action == :seek
178
+ @seek_to = PlayerFooter.seek(left, @state.length) if action == :seek
179
179
  return scroll_along if action == :scroll
180
180
 
181
181
  command(action, actions)
@@ -2,20 +2,26 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # The window's snapshots: F11 saves the machine to a new
6
- # badline-<date>-<time>.vsf in the working directory, and F12 goes back
7
- # to the snapshot last saved or opened, in a new machine. --save-snapshot
8
- # saves one after the last frame.
5
+ # The window's snapshots: F11 saves the machine to the next of five
6
+ # quicksave slots in the data folder, replacing the oldest, and F12
7
+ # restores the newest quicksave or named save in a new machine. A run
8
+ # without a frame limit also autosaves every AUTOSAVE_FRAMES frames to
9
+ # the next of three autosave slots, which only the pause menu loads.
10
+ # --save-snapshot saves one after the last frame.
9
11
  class Snapshots
10
12
  KEYS = [Keys::F11, Keys::F12].freeze
13
+ SLOTS = 5
14
+ AUTOSAVES = 3
15
+ AUTOSAVE_FRAMES = 6000
11
16
 
12
17
  # The machine running, which F12 replaces with the restored one.
13
- attr_reader :computer
18
+ attr_accessor :computer
14
19
 
15
20
  def initialize(computer, options)
16
21
  @computer = computer
17
- @last = options.snapshot? ? options.media_path.to_s : ""
18
22
  @at_finish = options.save_snapshot
23
+ @autosave = options.frames.zero?
24
+ @autosave_at = AUTOSAVE_FRAMES
19
25
  end
20
26
 
21
27
  # Saves or restores for F11 or F12, and says whether a restored
@@ -23,38 +29,160 @@ module Badline
23
29
  def key(scancode)
24
30
  return restore if scancode == Keys::F12
25
31
 
26
- save if scancode == Keys::F11
32
+ quicksave if scancode == Keys::F11
27
33
  false
28
34
  end
29
35
 
30
- # Saves the machine, and warns when it can't.
31
- def save(path = "badline-#{Time.now.strftime('%Y%m%d-%H%M%S')}.vsf")
36
+ # Saves the machine to the first empty quicksave slot, or else to the
37
+ # one saved longest ago, and warns when it can't.
38
+ def quicksave
39
+ folder = Badline.data_folder("quicksaves")
40
+ slot = next_slot(folder, "quicksave", SLOTS)
41
+ save(slot_path(folder, "quicksave", slot), "quicksave #{slot}")
42
+ rescue SystemCallError => e
43
+ warn "badline: quicksave: #{e.message}"
44
+ end
45
+
46
+ # Saves the machine, and warns when it can't. Says whether it saved.
47
+ def save(path, name = "")
32
48
  @computer.save_snapshot(path)
33
- @last = path
34
- puts "Saved #{path}"
49
+ puts(name.empty? ? "Saved #{path}" : "Saved #{name} to #{path}")
50
+ true
35
51
  rescue Snapshot::FormatError, SystemCallError => e
36
52
  warn "badline: #{path}: #{e.message}"
53
+ false
37
54
  end
38
55
 
39
- # Restores the snapshot last saved or opened into a new machine, and
56
+ # Restores the newest quicksave or named save into a new machine, and
40
57
  # warns when it fails, leaving the machine running as it was.
41
58
  def restore
42
- if @last.empty?
43
- puts "No snapshot to restore, F11 saves one"
59
+ path = newest
60
+ if path.empty?
61
+ puts "No quicksave to restore. F11: Quicksave"
44
62
  return false
45
63
  end
46
64
 
47
- @computer = Snapshot.load(@last) { |line| puts line }
48
- puts "Restored #{@last}"
49
- true
65
+ restore_from(path)
66
+ rescue SystemCallError => e
67
+ warn "badline: quicksave: #{e.message}"
68
+ false
69
+ end
70
+
71
+ # Autosaves once the machine has run AUTOSAVE_FRAMES frames since the
72
+ # last, quietly. A machine that can't be saved stops autosaving after
73
+ # one warning.
74
+ def tick(frames)
75
+ return unless @autosave && frames >= @autosave_at
76
+
77
+ @autosave_at = frames + AUTOSAVE_FRAMES
78
+ folder = Badline.data_folder("autosaves")
79
+ @computer.save_snapshot(slot_path(folder, "autosave", next_slot(folder, "autosave", AUTOSAVES)))
50
80
  rescue Snapshot::FormatError, SystemCallError => e
51
- warn "badline: #{@last}: #{e.message}"
81
+ @autosave = false
82
+ warn "badline: autosave: #{e.message}"
83
+ end
84
+
85
+ # The snapshots in one of the data folders, `quicksaves`, `autosaves`
86
+ # or `saves`, newest first.
87
+ def list(name)
88
+ folder = Badline.data_folder(name)
89
+ paths = Dir.children(folder).select { |child| File.extname(child).casecmp?(".vsf") }
90
+ paths.map { |child| File.join(folder, child) }.sort_by { |path| -modified(path) }
91
+ rescue SystemCallError
92
+ []
93
+ end
94
+
95
+ # Whether a save in the saves folder has the name.
96
+ def named?(name) = File.exist?(File.join(Badline.data_folder("saves"), "#{name}.vsf"))
97
+
98
+ # Saves the machine as `name` in the saves folder, and says whether it
99
+ # did: a name already taken is overwritten only with `replace`.
100
+ def save_named(name, replace: false)
101
+ path = File.join(Badline.data_folder("saves"), "#{name}.vsf")
102
+ return false if File.exist?(path) && !replace
103
+
104
+ save(path, name)
105
+ rescue SystemCallError => e
106
+ warn "badline: #{name}: #{e.message}"
52
107
  false
53
108
  end
54
109
 
110
+ # `base` and the lowest number no named save has yet.
111
+ def free_name(base)
112
+ folder = Badline.data_folder("saves")
113
+ number = 1
114
+ number += 1 while File.exist?(File.join(folder, "#{base} #{number}.vsf"))
115
+ "#{base} #{number}"
116
+ rescue SystemCallError
117
+ "#{base} 1"
118
+ end
119
+
120
+ # Restores the snapshot at `path` into a new machine, and says whether
121
+ # it did.
122
+ def load(path) = restore_from(path)
123
+
124
+ # When the snapshot at `path` was saved, as HH:MM:SS.
125
+ def time_of(path)
126
+ time = File.mtime(path)
127
+ format("%<hour>02d:%<minute>02d:%<second>02d", hour: time.hour, minute: time.min, second: time.sec)
128
+ end
129
+
55
130
  def finish
56
131
  save(@at_finish) unless @at_finish.empty?
57
132
  end
133
+
134
+ private
135
+
136
+ def restore_from(path)
137
+ @computer = Snapshot.load(path) { |line| puts line }
138
+ puts "Restored #{name_of(path)} from #{path}"
139
+ true
140
+ rescue Snapshot::FormatError, SystemCallError => e
141
+ warn "badline: #{path}: #{e.message}"
142
+ false
143
+ end
144
+
145
+ def next_slot(folder, kind, count)
146
+ empty = (1..count).find { |slot| !File.exist?(slot_path(folder, kind, slot)) }
147
+ return empty unless empty.nil?
148
+
149
+ (1..count).min_by { |slot| modified(slot_path(folder, kind, slot)) }
150
+ end
151
+
152
+ def slot_path(folder, kind, slot) = File.join(folder, "#{kind}-#{slot}.vsf")
153
+
154
+ # The .vsf in quicksaves/ or saves/ written last, or "" when there's
155
+ # none.
156
+ def newest
157
+ found = ""
158
+ found_at = 0
159
+ %w[quicksaves saves].each do |name|
160
+ folder = Badline.data_folder(name)
161
+ Dir.children(folder).each do |child|
162
+ next unless File.extname(child).casecmp?(".vsf")
163
+
164
+ path = File.join(folder, child)
165
+ at = modified(path)
166
+ next unless found.empty? || at > found_at
167
+
168
+ found = path
169
+ found_at = at
170
+ end
171
+ end
172
+ found
173
+ end
174
+
175
+ def name_of(path)
176
+ folder = Badline.data_folder("quicksaves")
177
+ slot = (1..SLOTS).find { |n| slot_path(folder, "quicksave", n) == path }
178
+ slot.nil? ? File.basename(path, ".vsf") : "quicksave #{slot}"
179
+ end
180
+
181
+ # The modification time in nanoseconds.
182
+ def modified(path)
183
+ time = File.mtime(path)
184
+ (time.tv_sec * 1_000_000_000) + time.nsec
185
+ end
58
186
  end
59
187
  end
60
188
  end
@@ -13,6 +13,8 @@ module Badline
13
13
  # only happens unpaced.
14
14
  #
15
15
  # Each frame's samples go to SDL through an IO::Buffer of 16-bit values.
16
+ # The SID's cycles are converted at `clock_hz`, the machine's clock, so
17
+ # an NTSC or Drean machine plays at its own pitch.
16
18
  class Sound
17
19
  RATE = 44_100
18
20
  AHEAD = 0.08
@@ -20,8 +22,9 @@ module Badline
20
22
 
21
23
  attr_reader :rate, :underruns, :dropped, :queued, :low, :high
22
24
 
23
- def initialize(sid, wanted, verbose)
25
+ def initialize(sid, clock_hz, wanted, verbose)
24
26
  @sid = sid
27
+ @clock_hz = clock_hz
25
28
  @verbose = verbose
26
29
  @device = 0
27
30
  @rate = RATE
@@ -37,10 +40,11 @@ module Badline
37
40
 
38
41
  def on? = @device != 0
39
42
 
40
- # Plays another machine's SID from here on.
41
- def sid=(sid)
43
+ # Plays another machine's SID from here on, at that machine's clock.
44
+ def switch(sid, clock_hz)
42
45
  @sid = sid
43
- sid.record(rate: @rate) if on?
46
+ @clock_hz = clock_hz
47
+ record if on?
44
48
  end
45
49
 
46
50
  def muted? = @muted
@@ -102,10 +106,12 @@ module Badline
102
106
  return puts "No sound: #{SDL.SDL_GetError}" unless on?
103
107
 
104
108
  @rate = SDL.read_i32(SDL.obtained)
105
- @sid.record(rate: @rate)
109
+ record
106
110
  puts "Sound at #{@rate} Hz" if @verbose
107
111
  end
108
112
 
113
+ def record = @sid.record(rate: @rate, clock_hz: @clock_hz)
114
+
109
115
  def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
110
116
 
111
117
  def play(samples, level)
@@ -145,7 +151,6 @@ module Badline
145
151
 
146
152
  def underrun!
147
153
  @underruns += 1
148
- puts "Running below real time, so the sound will stutter." if @underruns == 1
149
154
  halt
150
155
  end
151
156
  end
@@ -15,7 +15,7 @@ module Badline
15
15
  unless options.screenshot.empty? || options.frames.zero?
16
16
  @events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
17
17
  end
18
- @read_only = options.read_only?
18
+ @read_only = !options.writable?
19
19
  @program = options.program
20
20
  @releases = []
21
21
  @failed = false
@@ -63,6 +63,10 @@ module Badline
63
63
  .map { |event| Timeline.path(event.argument, frame) }
64
64
  end
65
65
 
66
+ # Presses the freeze button after `frame`, and lets go of it as the
67
+ # events do.
68
+ def press_freeze(computer, frame) = freeze(computer, Options::Event.new(frame, "freeze", ""))
69
+
66
70
  # Whether the run ends at the frame: at a quit, or once an event failed.
67
71
  def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
68
72
 
@@ -11,6 +11,7 @@ require "badline/frontend/screenshot"
11
11
  require "badline/frontend/timeline"
12
12
  require "badline/frontend/sound"
13
13
  require "badline/frontend/pacer"
14
+ require "badline/frontend/frame_report"
14
15
  require "badline/frontend/controls"
15
16
  require "badline/frontend/pad_port"
16
17
  require "badline/frontend/gamepads"
@@ -30,3 +31,10 @@ require "badline/frontend/info_view"
30
31
  require "badline/frontend/player_commands"
31
32
  require "badline/frontend/player_screen"
32
33
  require "badline/frontend/player_window"
34
+ require "badline/frontend/pause_menu"
35
+ require "badline/frontend/file_browser"
36
+ require "badline/frontend/confirmation"
37
+ require "badline/frontend/name_field"
38
+ require "badline/frontend/menu_dialogs"
39
+ require "badline/frontend/menu_media"
40
+ require "badline/frontend/menu_pages"
@@ -79,15 +79,25 @@ module Badline
79
79
  def finish(data, complete)
80
80
  if data.empty?
81
81
  @bus.poke(0x90, EOI | READ_TIMEOUT)
82
+ leave_bus(ACPTR_TIMEOUT)
82
83
  continue_with(SEARCHING_MESSAGE, FILE_NOT_FOUND_EXIT)
83
84
  else
84
85
  deliver(data)
85
86
  @bus.poke(0x90, @bus.peek(0x90) & ~EOI) unless complete
87
+ leave_bus(ISOUR_TIMEOUT)
86
88
  continue_with(SEARCHING_MESSAGE, LOADING_MESSAGE, complete ? LOAD_DONE : BYTE_LOOP)
87
89
  end
88
90
  @bus.poke(0xb9, LOAD_SECONDARY)
89
91
  end
90
92
 
93
+ # A load ends with the UNLISTEN that closes the file, a byte sent,
94
+ # and a missing file with the ACPTR that timed out. Either way it
95
+ # releases ATN, the clock and the data line on CIA 2's port A.
96
+ def leave_bus(timer_high)
97
+ time_serial_byte(timer_high)
98
+ release_serial_lines
99
+ end
100
+
91
101
  def low_memory?(data)
92
102
  load? && data.length > 2 && load_address(data) < 0x0334
93
103
  end
@@ -10,6 +10,12 @@ module Badline
10
10
 
11
11
  DEVICE = 8
12
12
 
13
+ # Timer B's latch high byte as ISOUR and ACPTR set it, and the
14
+ # control value both start it with: force load, one-shot, start.
15
+ ISOUR_TIMEOUT = 0x04
16
+ ACPTR_TIMEOUT = 0x01
17
+ TIMER_ONE_SHOT_START = 0x19
18
+
13
19
  def initialize(cpu:, bus:)
14
20
  @cpu = cpu
15
21
  @bus = bus
@@ -21,6 +27,28 @@ module Badline
21
27
  @bus.io_port.kernal?
22
28
  end
23
29
 
30
+ # The ROM times each byte on the serial bus with CIA 1's timer B,
31
+ # started one-shot with the high byte of its latch set: ISOUR_TIMEOUT
32
+ # for a byte sent, ACPTR_TIMEOUT for one received.
33
+ def time_serial_byte(timer_high)
34
+ @bus.poke(0xdc07, timer_high)
35
+ @bus.poke(0xdc0f, TIMER_ONE_SHOT_START)
36
+ end
37
+
38
+ # The ROM's release of the serial bus ($EE03): ATN ($EDBE), then the
39
+ # clock ($EE85), then the data line ($EE97), each a read of CIA 2's
40
+ # port A, a mask and a write back, so the input bits written back
41
+ # follow the lines as each one is let go. Returns the last value
42
+ # written, which the ROM leaves in A.
43
+ def release_serial_lines
44
+ value = 0
45
+ [0xf7, 0xef, 0xdf].each do |mask|
46
+ value = @bus.peek(0xdd00) & mask
47
+ @bus.poke(0xdd00, value)
48
+ end
49
+ value
50
+ end
51
+
24
52
  def return_to_caller
25
53
  @cpu.program_counter = (uint16(pull_byte, pull_byte) + 1) & 0xffff
26
54
  end