badline 0.8.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +22 -16
  4. data/doc/library.md +10 -3
  5. data/doc/media.md +11 -2
  6. data/doc/pinned-behaviour.md +53 -9
  7. data/doc/sid-player.md +8 -4
  8. data/doc/snapshots.md +66 -33
  9. data/lib/badline/address_bus/roms.rb +32 -0
  10. data/lib/badline/address_bus.rb +4 -4
  11. data/lib/badline/audio/bare_player.rb +24 -0
  12. data/lib/badline/audio/checkpoints.rb +42 -0
  13. data/lib/badline/audio/jukebox.rb +11 -4
  14. data/lib/badline/audio/machine_player.rb +16 -0
  15. data/lib/badline/audio/renderer.rb +80 -9
  16. data/lib/badline/audio/stereo.rb +3 -5
  17. data/lib/badline/audio.rb +1 -0
  18. data/lib/badline/computer/attachments.rb +5 -5
  19. data/lib/badline/computer.rb +12 -10
  20. data/lib/badline/core.rb +48 -0
  21. data/lib/badline/data_path.rb +37 -0
  22. data/lib/badline/datasette.rb +24 -9
  23. data/lib/badline/drive1541/disk.rb +3 -3
  24. data/lib/badline/drive1541/disk_state.rb +14 -5
  25. data/lib/badline/frontend/app.rb +4 -3
  26. data/lib/badline/frontend/boot.rb +9 -4
  27. data/lib/badline/frontend/confirmation.rb +17 -11
  28. data/lib/badline/frontend/menu_dialogs.rb +52 -14
  29. data/lib/badline/frontend/menu_media.rb +36 -7
  30. data/lib/badline/frontend/menu_pages.rb +24 -2
  31. data/lib/badline/frontend/name_field.rb +88 -0
  32. data/lib/badline/frontend/pause_menu.rb +25 -7
  33. data/lib/badline/frontend/player_window.rb +3 -3
  34. data/lib/badline/frontend/snapshots.rb +145 -17
  35. data/lib/badline/frontend/sound.rb +11 -5
  36. data/lib/badline/frontend.rb +1 -0
  37. data/lib/badline/kernal_trap/load.rb +10 -0
  38. data/lib/badline/kernal_trap/routine.rb +28 -0
  39. data/lib/badline/kernal_trap/serial.rb +19 -7
  40. data/lib/badline/media/disk_list.rb +75 -0
  41. data/lib/badline/media/disk_set.rb +13 -3
  42. data/lib/badline/media.rb +8 -3
  43. data/lib/badline/model.rb +62 -0
  44. data/lib/badline/options/event.rb +5 -3
  45. data/lib/badline/options/help.rb +16 -12
  46. data/lib/badline/options/table.rb +5 -1
  47. data/lib/badline/options/validation.rb +9 -0
  48. data/lib/badline/options.rb +7 -4
  49. data/lib/badline/region.rb +16 -4
  50. data/lib/badline/reu/ram.rb +27 -0
  51. data/lib/badline/reu/saved_state.rb +27 -0
  52. data/lib/badline/roms/README +10 -0
  53. data/lib/badline/roms/kernal-sx64.rom +0 -0
  54. data/lib/badline/sid/decimator.rb +13 -0
  55. data/lib/badline/sid/filter.rb +6 -3
  56. data/lib/badline/sid.rb +19 -0
  57. data/lib/badline/snapshot/fields.rb +1 -0
  58. data/lib/badline/snapshot/setup.rb +20 -8
  59. data/lib/badline/snapshot/state.rb +14 -7
  60. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  61. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  62. data/lib/badline/snapshot/vice/restore.rb +3 -1
  63. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  64. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  65. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  66. data/lib/badline/snapshot/vice.rb +25 -23
  67. data/lib/badline/storage/t64.rb +7 -4
  68. data/lib/badline/storage/tap.rb +7 -5
  69. data/lib/badline/storage/unavailable.rb +1 -1
  70. data/lib/badline/storage.rb +5 -5
  71. data/lib/badline/version.rb +1 -1
  72. data/lib/badline/vic/sprite/timing.rb +5 -9
  73. data/lib/badline/vic/sprite.rb +4 -3
  74. data/lib/badline.rb +2 -38
  75. metadata +10 -1
@@ -3,43 +3,57 @@
3
3
  module Badline
4
4
  module Frontend
5
5
  # The pause menu's screens that take over its panel: the file browser
6
- # INSERT and Quick open bring up, and the question before a cartridge
7
- # or Quick open power cycles the machine. A disk or tape picked goes
8
- # straight in.
6
+ # INSERT, Quick open and LOAD bring up, the question before a cartridge
7
+ # or Quick open power cycles the machine, and the prompt for a save's
8
+ # name. A disk or tape picked goes straight in, and a save loads.
9
9
  class MenuDialogs
10
10
  EXTENSIONS = {
11
- disk: PauseMenu::DISKS, tape: %w[.tap], cartridge: %w[.crt],
12
- program: %w[.d64 .d71 .d81 .g64 .t64 .tap .crt .prg .p00 .sid]
11
+ disk: %w[.d64 .d71 .d81 .g64 .t64 .m3u .vfl], tape: %w[.tap], cartridge: %w[.crt],
12
+ program: %w[.d64 .d71 .d81 .g64 .t64 .m3u .vfl .tap .crt .prg .p00 .sid], snapshot: %w[.vsf]
13
13
  }.freeze
14
14
  TITLES = { disk: "INSERT A DISK", tape: "INSERT A TAPE", cartridge: "INSERT A CARTRIDGE",
15
- program: "QUICK OPEN" }.freeze
15
+ program: "QUICK OPEN", snapshot: "LOAD A SAVE" }.freeze
16
16
 
17
17
  ESCAPE = 41
18
18
  RETURN = 40
19
19
  SPACE = 44
20
20
 
21
- # The machine Quick open started.
21
+ # The machine Quick open started or a save loaded.
22
22
  attr_reader :computer
23
23
 
24
- def initialize(painter, buttons, media, options)
24
+ def initialize(painter, buttons, media, options, snapshots)
25
25
  media_path = options.media_path.to_s
26
26
  @buttons = buttons
27
27
  @media = media
28
28
  @options = options
29
29
  @browser = FileBrowser.new(painter, buttons)
30
30
  @confirmation = Confirmation.new(painter, buttons)
31
+ @name_field = NameField.new(painter)
32
+ @snapshots = snapshots
31
33
  @browsing = nil
32
34
  @browse_from = media_path.empty? ? Dir.pwd : File.dirname(File.expand_path(media_path))
33
35
  @computer = nil
34
36
  end
35
37
 
36
- def open? = !@browsing.nil? || @confirmation.open?
38
+ def open? = !@browsing.nil? || @confirmation.open? || @name_field.open?
37
39
 
38
- # Opens the browser on the files for `kind`: :disk, :tape, :cartridge
39
- # or :program.
40
+ # Opens the browser on the files for `kind`: :disk, :tape, :cartridge,
41
+ # :program, or :snapshot, which starts in the saves folder.
40
42
  def browse(kind)
41
43
  @browsing = kind
42
- @browser.open(@browse_from, EXTENSIONS.fetch(kind))
44
+ @browser.open(kind == :snapshot ? Badline.data_folder("saves") : @browse_from, EXTENSIONS.fetch(kind))
45
+ end
46
+
47
+ # Asks for a save's name, starting from `name`.
48
+ def ask_name(name) = @name_field.open("SAVE AS", name)
49
+
50
+ # Loads the save at `path` into a new machine, and returns :swap, or
51
+ # nil when it won't load.
52
+ def load(path)
53
+ return nil unless @snapshots.load(path)
54
+
55
+ @computer = @snapshots.computer
56
+ :swap
43
57
  end
44
58
 
45
59
  def ask_remove = @confirmation.ask(:remove, @media.cartridge_name)
@@ -61,6 +75,9 @@ module Badline
61
75
  # answered yes, or nil.
62
76
  def key(scancode)
63
77
  return browsed(@browser.key(scancode)) unless @browsing.nil?
78
+ if @name_field.open? && !@confirmation.open?
79
+ return named(@name_field.key(scancode, SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT)))
80
+ end
64
81
  return answer(:cancel) if scancode == ESCAPE
65
82
  return answer(@buttons.focus) if [RETURN, SPACE].include?(scancode)
66
83
 
@@ -77,8 +94,9 @@ module Badline
77
94
 
78
95
  def draw(left, top, width)
79
96
  return @browser.draw(left, top, width, TITLES.fetch(@browsing)) unless @browsing.nil?
97
+ return @confirmation.draw(left, top) if @confirmation.open?
80
98
 
81
- @confirmation.draw(left, top)
99
+ @name_field.draw(left, top, width)
82
100
  end
83
101
 
84
102
  private
@@ -90,9 +108,10 @@ module Badline
90
108
  return nil if picked.nil?
91
109
 
92
110
  kind = @browsing
93
- @browse_from = @browser.directory
111
+ @browse_from = @browser.directory unless kind == :snapshot
94
112
  @browsing = nil
95
113
  return nil if picked == :cancel
114
+ return load(picked) if kind == :snapshot
96
115
 
97
116
  if %i[disk tape].include?(kind)
98
117
  @media.insert(kind, picked)
@@ -102,6 +121,24 @@ module Badline
102
121
  nil
103
122
  end
104
123
 
124
+ # Saves the machine under the name typed once Return takes it, and
125
+ # asks first when a save has that name already.
126
+ def named(result)
127
+ name = @name_field.text.strip
128
+ if result == :cancel
129
+ @name_field.close
130
+ elsif result == :done && name.empty?
131
+ @name_field.refuse("TYPE A NAME")
132
+ elsif result == :done
133
+ @snapshots.named?(name) ? @confirmation.ask(:overwrite, name) : save_as(name)
134
+ end
135
+ nil
136
+ end
137
+
138
+ def save_as(name, replace: false)
139
+ @name_field.close if @snapshots.save_named(name, replace:)
140
+ end
141
+
105
142
  # Does what the question asked about if `action` is :confirm, and
106
143
  # returns :resume, or :swap for a new machine.
107
144
  def answer(action)
@@ -112,6 +149,7 @@ module Badline
112
149
  case question[0]
113
150
  when :cartridge then @media.attach_cartridge(path) ? :resume : nil
114
151
  when :remove then @media.remove_cartridge && :resume
152
+ when :overwrite then save_as(path, replace: true) && nil
115
153
  else
116
154
  @computer = @media.start(@options, path)
117
155
  @computer.nil? ? nil : :swap
@@ -5,13 +5,17 @@ module Badline
5
5
  # What the pause menu puts in the machine and takes out: disks, tapes
6
6
  # and cartridges, whether disks are writable, which carries over from
7
7
  # one disk to the next, and the disk's set, which PREVIOUS and NEXT step
8
- # through. It keeps what last went wrong for the page to show.
8
+ # through: the .m3u or .vfl list it came from, while that lists it, or
9
+ # the set DiskSet finds. It keeps what last went wrong for the page to
10
+ # show.
9
11
  class MenuMedia
10
12
  attr_reader :disk_path, :problem
11
13
  attr_writer :computer
12
14
 
13
- def initialize(disk_path, writable)
14
- @disk_path = disk_path
15
+ # Starts on the disk the command line's `media_path` put in.
16
+ def initialize(media_path, writable)
17
+ @disk_path = PauseMenu.disk_path(media_path)
18
+ @list = Media::DiskList.list?(media_path) ? media_path : ""
15
19
  @writable = writable
16
20
  @set_path = ""
17
21
  @set = []
@@ -27,12 +31,14 @@ module Badline
27
31
  end
28
32
 
29
33
  # Puts a disk in device 8 or a tape in the datasette, `kind` :disk or
30
- # :tape.
34
+ # :tape. A list puts its first disk in.
31
35
  def insert(kind, path)
32
36
  @problem = ""
33
37
  if kind == :disk
34
- Media.insert_disk(@computer, path, read_only: !@writable)
35
- @disk_path = path
38
+ disk = Media::DiskList.disk(path)
39
+ Media.insert_disk(@computer, disk, read_only: !@writable)
40
+ list(path)
41
+ @disk_path = disk
36
42
  else
37
43
  @computer.datasette.insert(Storage::TAP.new(path))
38
44
  end
@@ -70,7 +76,8 @@ module Badline
70
76
  return [] if @disk_path.empty?
71
77
 
72
78
  unless @set_path == @disk_path
73
- @set = Media::DiskSet.around(@disk_path)
79
+ listed = @list.empty? ? [] : Media::DiskList.disks(@list)
80
+ @set = listed.include?(@disk_path) ? listed : Media::DiskSet.around(@disk_path)
74
81
  @set_path = @disk_path
75
82
  end
76
83
  @set
@@ -97,6 +104,17 @@ module Badline
97
104
  false
98
105
  end
99
106
 
107
+ # What a save of the machine is named after: the cartridge, the disk,
108
+ # the tape, or BASIC, without the bracketed parts of a file's name.
109
+ def game_name
110
+ name = cartridge_name
111
+ name = File.basename(@disk_path, File.extname(@disk_path)) if name.empty? && !@disk_path.empty?
112
+ tape = @computer.datasette.tape
113
+ name = File.basename(tape.path, File.extname(tape.path)) if name.empty? && !tape.nil?
114
+ name = name.sub(/\s*[(\[].*\z/, "").strip
115
+ name.empty? ? "BASIC" : name
116
+ end
117
+
100
118
  # The name the cartridge in the expansion port gives itself, or an
101
119
  # empty one.
102
120
  def cartridge_name
@@ -116,6 +134,7 @@ module Badline
116
134
  @problem = ""
117
135
  computer = Frontend.start(options, path, @writable)
118
136
  @disk_path = PauseMenu.disk_path(path)
137
+ list(path)
119
138
  computer
120
139
  rescue ArgumentError, SystemCallError, Media::TrueDrive::Error, Storage::SIDFile::FormatError,
121
140
  Storage::T64::FormatError, Storage::TAP::FormatError, Storage::CRTFile::FormatError,
@@ -123,6 +142,16 @@ module Badline
123
142
  @problem = e.message
124
143
  nil
125
144
  end
145
+
146
+ private
147
+
148
+ # Keeps `path` as the list the set comes from, if it's a list.
149
+ def list(path)
150
+ return unless Media::DiskList.list?(path)
151
+
152
+ @list = path
153
+ @set_path = ""
154
+ end
126
155
  end
127
156
  end
128
157
  end
@@ -14,6 +14,7 @@ module Badline
14
14
  ROW_WIDTH = 200
15
15
 
16
16
  WARNING = 0xff7a6b
17
+ RECENT = %i[recent0 recent1 recent2 recent3 recent4 recent5 recent6 recent7].freeze
17
18
 
18
19
  PORT_DEVICES = %i[joystick mouse paddles].freeze
19
20
  PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
@@ -23,10 +24,12 @@ module Badline
23
24
  # The disks, tapes and cartridges the pages put in and take out.
24
25
  attr_reader :media
25
26
 
26
- def initialize(painter, buttons, disk_path, options)
27
+ def initialize(painter, buttons, media_path, options, snapshots)
28
+ @snapshots = snapshots
29
+ @recent = []
27
30
  @painter = painter
28
31
  @buttons = buttons
29
- @media = MenuMedia.new(disk_path, options.writable?)
32
+ @media = MenuMedia.new(media_path, options.writable?)
30
33
  @reu_kb = options.reu
31
34
  @computer = nil
32
35
  @controls = nil
@@ -54,10 +57,14 @@ module Badline
54
57
  when :expansion then draw_expansion
55
58
  when :ports then draw_ports
56
59
  when :sound then draw_sound
60
+ when :snapshots then draw_snapshots
57
61
  else draw_power
58
62
  end
59
63
  end
60
64
 
65
+ # The path of the quicksave or autosave a RECENT action stands for.
66
+ def recent(action) = @recent[RECENT.index(action)].to_s
67
+
61
68
  def perform(action)
62
69
  case action
63
70
  when :eject_disk, :previous_disk, :next_disk, :protect, :unprotect then @media.drive(action)
@@ -141,6 +148,21 @@ module Badline
141
148
  toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], @computer.sid.model == :mos8580 ? 1 : 0])
142
149
  end
143
150
 
151
+ # Saving now and by name, loading a named save, and the quicksaves
152
+ # and autosaves, newest first, which a press loads.
153
+ def draw_snapshots
154
+ row("QUICKSAVE", :quicksave_now)
155
+ row("SAVE...", :save_as)
156
+ row("LOAD...", :load_save)
157
+ skip
158
+ saves = (@snapshots.list("quicksaves") + @snapshots.list("autosaves")).sort_by { |path| -File.mtime(path).to_f }
159
+ @recent = saves.first(RECENT.size)
160
+ @recent.each_with_index do |path, index|
161
+ kind = File.basename(path).start_with?("autosave") ? "AUTOSAVE" : "QUICKSAVE"
162
+ row(kind, RECENT[index], @snapshots.time_of(path))
163
+ end
164
+ end
165
+
144
166
  def draw_power
145
167
  row("RESET", :reset)
146
168
  row("POWER CYCLE", :power_cycle)
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's prompt for a name, typed with the keys by their
6
+ # place on a US keyboard: letters, with Shift for capitals, digits,
7
+ # space, - and _ and the full stop. Backspace deletes, Return takes the
8
+ # name and Esc leaves it.
9
+ class NameField
10
+ COLUMNS = 30
11
+ LETTERS = "abcdefghijklmnopqrstuvwxyz"
12
+ DIGITS = "1234567890"
13
+ OTHERS = { 44 => [" ", " "], 45 => ["-", "_"], 55 => [".", "."] }.freeze
14
+
15
+ RETURN = 40
16
+ ESCAPE = 41
17
+ BACKSPACE = 42
18
+
19
+ attr_reader :text
20
+
21
+ def initialize(painter)
22
+ @painter = painter
23
+ @open = false
24
+ @title = ""
25
+ @text = ""
26
+ @problem = ""
27
+ end
28
+
29
+ def open? = @open
30
+
31
+ def open(title, text)
32
+ @open = true
33
+ @title = title
34
+ @text = text
35
+ @problem = ""
36
+ end
37
+
38
+ def close
39
+ @open = false
40
+ end
41
+
42
+ # Says why the name won't do, under the field.
43
+ def refuse(problem)
44
+ @problem = problem
45
+ end
46
+
47
+ # Handles a key, and returns :done for Return, :cancel for Esc, or
48
+ # nil.
49
+ def key(scancode, shift)
50
+ return :done if scancode == RETURN
51
+ return :cancel if scancode == ESCAPE
52
+
53
+ @problem = ""
54
+ if scancode == BACKSPACE
55
+ @text = @text[0, @text.length - 1].to_s
56
+ else
57
+ char = character(scancode, shift)
58
+ @text += char unless char.empty? || @text.length >= COLUMNS
59
+ end
60
+ nil
61
+ end
62
+
63
+ def draw(left, top, width)
64
+ painter = @painter
65
+ painter.text(left, top, @title, PauseMenu::BRIGHT)
66
+ painter.box(left, top + 18, width, Buttons::HEIGHT + 4, PauseMenu::FILL)
67
+ painter.text(left + 4, top + 22, "#{@text}_", PauseMenu::TEXT)
68
+ painter.text(left, top + 42, @problem, MenuPages::WARNING) unless @problem.empty?
69
+ painter.text(left, top + 60, "RETURN: SAVE ESC: CANCEL", PauseMenu::DIM)
70
+ end
71
+
72
+ private
73
+
74
+ def character(scancode, shift)
75
+ if scancode.between?(4, 29)
76
+ letter = LETTERS[scancode - 4]
77
+ shift ? letter.upcase : letter
78
+ elsif scancode.between?(30, 39)
79
+ DIGITS[scancode - 30]
80
+ elsif OTHERS.key?(scancode)
81
+ OTHERS[scancode][shift ? 1 : 0]
82
+ else
83
+ ""
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -14,8 +14,8 @@ module Badline
14
14
  # a cartridge going in or out ask first, as each power cycles the
15
15
  # machine.
16
16
  class PauseMenu
17
- SECTIONS = ["DRIVE 8", "DATASETTE", "EXPANSION PORT", "PORTS", "SOUND", "POWER"].freeze
18
- PAGES = %i[drive datasette expansion ports sound power].freeze
17
+ SECTIONS = ["SNAPSHOTS", "DRIVE 8", "DATASETTE", "EXPANSION PORT", "PORTS", "SOUND", "POWER"].freeze
18
+ PAGES = %i[snapshots drive datasette expansion ports sound power].freeze
19
19
  DISKS = %w[.d64 .d71 .d81 .g64 .t64].freeze
20
20
 
21
21
  PANEL = 0x1d2230
@@ -34,25 +34,28 @@ module Badline
34
34
  ESCAPE = 41
35
35
  RETURN = 40
36
36
  SPACE = 44
37
+ SNAPSHOT_ACTIONS = %i[quicksave_now save_as load_save].freeze
37
38
  ARROWS = { 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
38
39
  MOUSEWHEEL = 0x403
39
40
 
40
41
  # Takes the media, the REU and whether disks are writable from Options.
41
- def initialize(painter, options)
42
+ def initialize(painter, options, snapshots)
42
43
  media_path = options.media_path.to_s
43
44
  @painter = painter
44
45
  @buttons = Buttons.new(painter, [TEXT, BRIGHT, PANEL, FILL])
45
- @pages = MenuPages.new(painter, @buttons, PauseMenu.disk_path(media_path), options)
46
- @dialogs = MenuDialogs.new(painter, @buttons, @pages.media, options)
46
+ @pages = MenuPages.new(painter, @buttons, media_path, options, snapshots)
47
+ @snapshots = snapshots
48
+ @dialogs = MenuDialogs.new(painter, @buttons, @pages.media, options, snapshots)
47
49
  @open = false
48
50
  @section = 0
49
51
  end
50
52
 
51
- # The path of the disk the command line puts in device 8, or an empty
52
- # one for other media.
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.
53
55
  def self.disk_path(path)
54
56
  return "" if path.empty?
55
57
  return path if File.directory?(path)
58
+ return Media::DiskList.disks(path).first.to_s if Media::DiskList.list?(path)
56
59
 
57
60
  DISKS.include?(File.extname(path).downcase) ? path : ""
58
61
  end
@@ -165,6 +168,8 @@ module Badline
165
168
  end
166
169
 
167
170
  def menu_action(action)
171
+ return snapshot_action(action) if SNAPSHOT_ACTIONS.include?(action) || MenuPages::RECENT.include?(action)
172
+
168
173
  case action
169
174
  when :insert_disk then @dialogs.browse(:disk)
170
175
  when :insert_tape then @dialogs.browse(:tape)
@@ -176,6 +181,19 @@ module Badline
176
181
  nil
177
182
  end
178
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
+
179
197
  # Moves the focus: down from RESUME to the sections, through them,
180
198
  # where each opens as it's reached, on to Quick open, right into a
181
199
  # page, up and down it, and left out of it again.
@@ -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
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