badline 0.8.0 → 0.10.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/CLAUDE.md +6 -4
  4. data/README.md +60 -19
  5. data/doc/library.md +10 -3
  6. data/doc/media.md +11 -2
  7. data/doc/pinned-behaviour.md +199 -9
  8. data/doc/sid-player.md +8 -4
  9. data/doc/snapshots.md +79 -33
  10. data/lib/badline/address_bus/pla.rb +130 -0
  11. data/lib/badline/address_bus/roms.rb +32 -0
  12. data/lib/badline/address_bus.rb +13 -92
  13. data/lib/badline/audio/bare_player.rb +24 -0
  14. data/lib/badline/audio/checkpoints.rb +42 -0
  15. data/lib/badline/audio/jukebox.rb +11 -4
  16. data/lib/badline/audio/machine_player.rb +16 -0
  17. data/lib/badline/audio/renderer.rb +80 -9
  18. data/lib/badline/audio/stereo.rb +3 -5
  19. data/lib/badline/audio.rb +1 -0
  20. data/lib/badline/chrout_trap.rb +4 -3
  21. data/lib/badline/computer/attachments.rb +52 -12
  22. data/lib/badline/computer/saved_state.rb +1 -0
  23. data/lib/badline/computer.rb +50 -33
  24. data/lib/badline/control_ports.rb +10 -3
  25. data/lib/badline/core.rb +51 -0
  26. data/lib/badline/data_path.rb +37 -0
  27. data/lib/badline/datasette.rb +63 -10
  28. data/lib/badline/drive1541/disk.rb +3 -3
  29. data/lib/badline/drive1541/disk_state.rb +14 -5
  30. data/lib/badline/drive1541/serial_port.rb +5 -8
  31. data/lib/badline/drive1541/sleep.rb +5 -6
  32. data/lib/badline/drive1541.rb +3 -3
  33. data/lib/badline/frontend/app.rb +63 -64
  34. data/lib/badline/frontend/boot.rb +34 -4
  35. data/lib/badline/frontend/confirmation.rb +17 -11
  36. data/lib/badline/frontend/controls.rb +12 -6
  37. data/lib/badline/frontend/drive_led.rb +9 -10
  38. data/lib/badline/frontend/frame_rate.rb +4 -4
  39. data/lib/badline/frontend/machine_window.rb +87 -0
  40. data/lib/badline/frontend/menu_dialogs.rb +52 -14
  41. data/lib/badline/frontend/menu_media.rb +70 -16
  42. data/lib/badline/frontend/menu_pages.rb +47 -42
  43. data/lib/badline/frontend/menu_ports.rb +69 -0
  44. data/lib/badline/frontend/name_field.rb +88 -0
  45. data/lib/badline/frontend/pacer.rb +6 -5
  46. data/lib/badline/frontend/pause_menu.rb +52 -37
  47. data/lib/badline/frontend/player_screen.rb +1 -1
  48. data/lib/badline/frontend/player_window.rb +4 -4
  49. data/lib/badline/frontend/screen.rb +39 -37
  50. data/lib/badline/frontend/sid_view.rb +1 -1
  51. data/lib/badline/frontend/snapshots.rb +145 -17
  52. data/lib/badline/frontend/sound.rb +17 -9
  53. data/lib/badline/frontend/timeline.rb +36 -5
  54. data/lib/badline/frontend.rb +3 -0
  55. data/lib/badline/iec_bus.rb +16 -28
  56. data/lib/badline/kernal_trap/drive.rb +3 -0
  57. data/lib/badline/kernal_trap/file.rb +1 -1
  58. data/lib/badline/kernal_trap/layout.rb +112 -0
  59. data/lib/badline/kernal_trap/load.rb +24 -25
  60. data/lib/badline/kernal_trap/routine.rb +17 -5
  61. data/lib/badline/kernal_trap/save.rb +13 -23
  62. data/lib/badline/kernal_trap/serial.rb +38 -24
  63. data/lib/badline/kernal_trap.rb +1 -0
  64. data/lib/badline/keyboard.rb +17 -14
  65. data/lib/badline/machine.rb +60 -0
  66. data/lib/badline/media/disk_list.rb +75 -0
  67. data/lib/badline/media/disk_set.rb +13 -3
  68. data/lib/badline/media/vic20_basic.rb +54 -0
  69. data/lib/badline/media/vic20_cartridge.rb +74 -0
  70. data/lib/badline/media/vic20_media.rb +132 -0
  71. data/lib/badline/media.rb +39 -20
  72. data/lib/badline/model.rb +62 -0
  73. data/lib/badline/options/event.rb +10 -6
  74. data/lib/badline/options/help.rb +25 -13
  75. data/lib/badline/options/table.rb +15 -1
  76. data/lib/badline/options/validation.rb +63 -3
  77. data/lib/badline/options.rb +37 -34
  78. data/lib/badline/region.rb +16 -4
  79. data/lib/badline/reu/ram.rb +27 -0
  80. data/lib/badline/reu/saved_state.rb +27 -0
  81. data/lib/badline/roms/README +37 -0
  82. data/lib/badline/roms/kernal-sx64.rom +0 -0
  83. data/lib/badline/roms/vic20/basic.rom +0 -0
  84. data/lib/badline/roms/vic20/character.rom +0 -0
  85. data/lib/badline/roms/vic20/kernal-ntsc.rom +0 -0
  86. data/lib/badline/roms/vic20/kernal-pal.rom +0 -0
  87. data/lib/badline/sid/decimator.rb +13 -0
  88. data/lib/badline/sid/filter.rb +6 -3
  89. data/lib/badline/sid.rb +19 -0
  90. data/lib/badline/snapshot/container.rb +2 -0
  91. data/lib/badline/snapshot/fields.rb +1 -0
  92. data/lib/badline/snapshot/image.rb +15 -0
  93. data/lib/badline/snapshot/setup.rb +20 -8
  94. data/lib/badline/snapshot/state.rb +15 -7
  95. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  96. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  97. data/lib/badline/snapshot/vice/restore.rb +3 -1
  98. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  99. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  100. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  101. data/lib/badline/snapshot/vice.rb +25 -23
  102. data/lib/badline/snapshot.rb +10 -0
  103. data/lib/badline/storage/crt_file.rb +16 -5
  104. data/lib/badline/storage/disk_image.rb +3 -0
  105. data/lib/badline/storage/host_directory.rb +3 -0
  106. data/lib/badline/storage/t64.rb +10 -4
  107. data/lib/badline/storage/tap.rb +44 -8
  108. data/lib/badline/storage/unavailable.rb +3 -1
  109. data/lib/badline/storage.rb +5 -5
  110. data/lib/badline/timing.rb +12 -0
  111. data/lib/badline/version.rb +1 -1
  112. data/lib/badline/via/shift_register.rb +43 -16
  113. data/lib/badline/vic/bank.rb +53 -16
  114. data/lib/badline/vic/registers.rb +20 -4
  115. data/lib/badline/vic/sprite/timing.rb +5 -9
  116. data/lib/badline/vic/sprite.rb +4 -3
  117. data/lib/badline/vic.rb +34 -15
  118. data/lib/badline/vic20/attachments.rb +103 -0
  119. data/lib/badline/vic20/bus.rb +308 -0
  120. data/lib/badline/vic20/cpu.rb +10 -0
  121. data/lib/badline/vic20/keyboard_via_ports.rb +53 -0
  122. data/lib/badline/vic20/port_wiring.rb +50 -0
  123. data/lib/badline/vic20/saved_state.rb +152 -0
  124. data/lib/badline/vic20/sound/output.rb +83 -0
  125. data/lib/badline/vic20/sound.rb +233 -0
  126. data/lib/badline/vic20/user_via_ports.rb +47 -0
  127. data/lib/badline/vic20/vic/painter.rb +239 -0
  128. data/lib/badline/vic20/vic/saved_state.rb +55 -0
  129. data/lib/badline/vic20/vic/text_window.rb +71 -0
  130. data/lib/badline/vic20/vic.rb +267 -0
  131. data/lib/badline/vic20.rb +220 -0
  132. data/lib/badline.rb +2 -38
  133. metadata +37 -2
  134. data/lib/badline/address_bus/ultimax_pages.rb +0 -38
@@ -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
@@ -13,7 +13,8 @@ module Badline
13
13
  # SAFETY, so no samples are dropped. #measure fits the frames to the
14
14
  # rate the display presents at, measured over the whole run.
15
15
  #
16
- # Without vsync, a frame is one of the machine's frames, 20 ms on PAL.
16
+ # Without vsync, a frame is one of the machine's frames, as its Timing
17
+ # gives them, 20 ms on PAL.
17
18
  # With the sound playing, #wait lasts until the audio queue is down to
18
19
  # Sound::AHEAD; otherwise it waits out the rest of the frame.
19
20
  class Pacer
@@ -23,12 +24,12 @@ module Badline
23
24
 
24
25
  attr_reader :rate
25
26
 
26
- def initialize(paced:, vsync:, verbose:, region: Region::PAL)
27
+ def initialize(paced:, vsync:, verbose:, timing:)
27
28
  @paced = paced
28
29
  @verbose = verbose
29
30
  @vsync = paced && vsync
30
- @region = region
31
- @rate = FrameRate.machine(region)
31
+ @timing = timing
32
+ @rate = FrameRate.machine(timing)
32
33
  @deadline = 0.0
33
34
  @measured_frames = 0
34
35
  @measured_seconds = 0.0
@@ -39,7 +40,7 @@ module Badline
39
40
  # Fits the frames to the display's refresh rate, once the window has
40
41
  # been opened with vsync.
41
42
  def fit(refresh)
42
- @rate = FrameRate.display(refresh, @region.clock_hz)
43
+ @rate = FrameRate.display(refresh, @timing.clock_hz)
43
44
  puts "Display #{@rate.refresh} Hz, vsync: #{@rate.base_cycles} cycles a frame" if @verbose
44
45
  end
45
46
 
@@ -14,9 +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
19
- DISKS = %w[.d64 .d71 .d81 .g64 .t64].freeze
17
+ SECTIONS = ["SNAPSHOTS", "DRIVE 8", "DATASETTE", "EXPANSION PORT", "PORTS", "SOUND", "POWER"].freeze
18
+ PAGES = %i[snapshots drive datasette expansion ports sound power].freeze
20
19
 
21
20
  PANEL = 0x1d2230
22
21
  EDGE = 0x3a4256
@@ -25,40 +24,37 @@ module Badline
25
24
  DIM = 0x6b7489
26
25
  FILL = 0x343c50
27
26
 
28
- LEFT = 16
29
- TOP = 16
30
- WIDTH = Screen::WIDTH - 32
31
- HEIGHT = Screen::HEIGHT - 32
32
- BODY = 154
27
+ MARGIN = 16
28
+ WIDTH = 352
29
+ HEIGHT = 240
30
+ BODY = 138
33
31
 
34
32
  ESCAPE = 41
35
33
  RETURN = 40
36
34
  SPACE = 44
35
+ SNAPSHOT_ACTIONS = %i[quicksave_now save_as load_save].freeze
37
36
  ARROWS = { 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
38
37
  MOUSEWHEEL = 0x403
39
38
 
40
39
  # Takes the media, the REU and whether disks are writable from Options.
41
- def initialize(painter, options)
40
+ def initialize(painter, options, snapshots)
42
41
  media_path = options.media_path.to_s
43
42
  @painter = painter
44
43
  @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)
44
+ @pages = MenuPages.new(painter, @buttons, media_path, options, snapshots)
45
+ @snapshots = snapshots
46
+ @dialogs = MenuDialogs.new(painter, @buttons, @pages.media, options, snapshots)
47
47
  @open = false
48
48
  @section = 0
49
- end
50
-
51
- # The path of the disk the command line puts in device 8, or an empty
52
- # one for other media.
53
- def self.disk_path(path)
54
- return "" if path.empty?
55
- return path if File.directory?(path)
56
-
57
- DISKS.include?(File.extname(path).downcase) ? path : ""
49
+ @left = MARGIN
50
+ @top = MARGIN
58
51
  end
59
52
 
60
53
  def open? = @open
61
54
 
55
+ # The index in PAGES of the page #show opens on.
56
+ attr_writer :section
57
+
62
58
  # The machine Quick open started, which the app runs in place of the
63
59
  # one before once #frame returns :swap.
64
60
  def computer = @dialogs.computer
@@ -113,10 +109,10 @@ module Badline
113
109
  end
114
110
 
115
111
  # Handles the events that came in, draws the menu over the frozen
116
- # picture, dimmed, and returns the action the app has to take on, if
117
- # any, leaving the events after one for the machine. Key releases
118
- # still reach the machine, so no key stays held down while it stands
119
- # still.
112
+ # picture, dimmed, for the app to present, and returns the action the
113
+ # app has to take on, if any, leaving the events after one for the
114
+ # machine. Key releases still reach the machine, so no key stays held
115
+ # down while it stands still.
120
116
  def frame(renderer, texture, controls)
121
117
  action = nil
122
118
  action = event(SDL.event_type(SDL.event), controls) while action.nil? && SDL.SDL_PollEvent(SDL.event) != 0
@@ -126,26 +122,30 @@ module Badline
126
122
  SDL.SDL_SetTextureColorMod(texture, 255, 255, 255)
127
123
  draw
128
124
  SDL.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)
129
- SDL.SDL_RenderPresent(renderer)
130
- SDL.SDL_Delay(10)
131
125
  action
132
126
  end
133
127
 
128
+ # Centres the menu on a screen `width` by `height`.
129
+ def center(width, height)
130
+ @left = (width - WIDTH) / 2
131
+ @top = (height - HEIGHT) / 2
132
+ end
133
+
134
134
  def draw
135
135
  painter = @painter
136
136
  @buttons.forget
137
- painter.box(LEFT, TOP, WIDTH, HEIGHT, PANEL)
138
- painter.box(LEFT, TOP + 24, WIDTH, 1, EDGE)
139
- painter.text(LEFT + 8, TOP + 9, "PAUSED", BRIGHT)
137
+ painter.box(@left, @top, WIDTH, HEIGHT, PANEL)
138
+ painter.box(@left, @top + 24, WIDTH, 1, EDGE)
139
+ painter.text(@left + 8, @top + 9, "PAUSED", BRIGHT)
140
140
  resume = "RESUME"
141
- @buttons.plain(LEFT + WIDTH - 8 - Painter.width(resume) - (Buttons::PAD * 2), TOP + 7, resume, :resume)
142
- return @dialogs.draw(LEFT + 8, TOP + 34, WIDTH - 16) if @dialogs.open?
141
+ @buttons.plain(@left + WIDTH - 8 - Painter.width(resume) - (Buttons::PAD * 2), @top + 7, resume, :resume)
142
+ return @dialogs.draw(@left + 8, @top + 34, WIDTH - 16) if @dialogs.open?
143
143
 
144
- painter.box(BODY - 8, TOP + 32, 1, HEIGHT - 56, EDGE)
144
+ painter.box(@left + BODY - 8, @top + 32, 1, HEIGHT - 56, EDGE)
145
145
  draw_sections
146
- @buttons.plain(LEFT + 6, TOP + HEIGHT - 28, "QUICK OPEN...", :quick_open)
147
- painter.text(LEFT + 8, TOP + HEIGHT - 12, "F9/ESC: RESUME", DIM)
148
- @pages.draw(PAGES[@section], BODY, TOP + 34)
146
+ @buttons.plain(@left + 6, @top + HEIGHT - 28, "QUICK OPEN...", :quick_open)
147
+ painter.text(@left + 8, @top + HEIGHT - 12, "F9/ESC: RESUME", DIM)
148
+ @pages.draw(PAGES[@section], @left + BODY, @top + 34)
149
149
  end
150
150
 
151
151
  private
@@ -165,6 +165,8 @@ module Badline
165
165
  end
166
166
 
167
167
  def menu_action(action)
168
+ return snapshot_action(action) if SNAPSHOT_ACTIONS.include?(action) || MenuPages::RECENT.include?(action)
169
+
168
170
  case action
169
171
  when :insert_disk then @dialogs.browse(:disk)
170
172
  when :insert_tape then @dialogs.browse(:tape)
@@ -176,6 +178,19 @@ module Badline
176
178
  nil
177
179
  end
178
180
 
181
+ # Saves now, asks for a name to save under, opens the saves folder,
182
+ # or loads one of the page's quicksaves and autosaves, returning
183
+ # :swap once a save has loaded.
184
+ def snapshot_action(action)
185
+ case action
186
+ when :quicksave_now then @snapshots.quicksave
187
+ when :save_as then @dialogs.ask_name(@snapshots.free_name(@pages.media.game_name))
188
+ when :load_save then @dialogs.browse(:snapshot)
189
+ else return @dialogs.load(@pages.recent(action))
190
+ end
191
+ nil
192
+ end
193
+
179
194
  # Moves the focus: down from RESUME to the sections, through them,
180
195
  # where each opens as it's reached, on to Quick open, right into a
181
196
  # page, up and down it, and left out of it again.
@@ -235,9 +250,9 @@ module Badline
235
250
  end
236
251
 
237
252
  def draw_sections
238
- top = TOP + 32
253
+ top = @top + 32
239
254
  SECTIONS.each_with_index do |name, index|
240
- @buttons.plain(LEFT + 6, top, name.ljust(14), PAGES[index], on: index == @section)
255
+ @buttons.plain(@left + 6, top, name.ljust(14), PAGES[index], on: index == @section)
241
256
  top += Buttons::HEIGHT + 2
242
257
  end
243
258
  end
@@ -10,7 +10,7 @@ module Badline
10
10
  HEADER = 64
11
11
  SCALE = 2
12
12
  TITLE = "Badline"
13
- BACKGROUND = Screen::COLORS[6]
13
+ BACKGROUND = VIC::PALETTE[6]
14
14
 
15
15
  attr_reader :buttons, :info
16
16
 
@@ -23,7 +23,7 @@ module Badline
23
23
  UP = 82
24
24
  DOWN = 81
25
25
 
26
- WARNING = Screen::COLORS[10]
26
+ WARNING = VIC::PALETTE[10]
27
27
 
28
28
  CHIPS = PlayerHeader::CHIPS
29
29
 
@@ -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,60 +2,62 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # The visible part of the VIC's display, the crop of the machine's
6
- # region, repacked for an SDL texture in XRGB8888. Spinel hands an
7
- # Array of Integers to C as 64-bit words, as badline/ffi does on CRuby,
8
- # so each word carries two neighbouring pixels, the left one in the low
9
- # half. The texture is PAL's crop, 384x272, and a region with fewer
10
- # lines to show sits in the middle of it, between black bands.
5
+ # The visible part of the video chip's display, the crop of the
6
+ # machine's timing, repacked for an SDL texture in XRGB8888 in the
7
+ # chip's palette. Spinel hands an Array of Integers to C as 64-bit
8
+ # words, as badline/ffi does on CRuby, so each word carries two
9
+ # neighbouring pixels, the left one in the low half.
10
+ #
11
+ # The texture is as wide as the crop and at least as high as PAL's
12
+ # C64 crop, 272 lines, so a C64 shows in the same window whatever its
13
+ # region: a crop with fewer lines sits in the middle of the texture,
14
+ # between black bands. The VIC-20's 284 by 284 crop fills its own.
11
15
  class Screen
12
- WIDTH = Region::PAL.crop[2]
13
- HEIGHT = Region::PAL.crop[3]
14
- ROW_BYTES = WIDTH * 4
15
- ROW_WORDS = WIDTH / 2
16
+ MIN_HEIGHT = Region::PAL.crop[3]
16
17
 
17
- COLORS = [
18
- 0x000000, 0xffffff, 0x924a40, 0x84c5cc,
19
- 0x9351b6, 0x72b14b, 0x483aaa, 0xd5df7c,
20
- 0x675200, 0xc33d00, 0xc18178, 0x606060,
21
- 0x8a8a8a, 0xb3ec91, 0x867ade, 0xb3b3b3
22
- ].freeze
18
+ attr_reader :pixels, :width, :height
23
19
 
24
- PAIRS = Array.new(256) { |pair| COLORS[pair & 0x0f] | (COLORS[pair >> 4] << 32) }.freeze
25
-
26
- attr_reader :pixels
27
-
28
- def initialize(vic)
29
- @vic = vic
30
- crop = vic.region.crop
20
+ def initialize(video, crop)
21
+ @video = video
31
22
  @col_offset = crop[0]
32
23
  @row_offset = crop[1]
33
- @height = crop[3]
34
- @band = (HEIGHT - @height) / 2
35
- @pixels = Array.new(HEIGHT * ROW_WORDS, 0)
24
+ @lines = crop[3]
25
+ @width = crop[2]
26
+ @stride = video.width.to_i
27
+ @height = [@lines, MIN_HEIGHT].max
28
+ @row_words = @width / 2
29
+ @band = (@height - @lines) / 2
30
+ palette = video.palette
31
+ @pairs = Array.new(256) { |pair| palette[pair & 0x0f] | (palette[pair >> 4] << 32) }
32
+ @pixels = Array.new(@height * @row_words, 0)
36
33
  end
37
34
 
38
- # Repacks only the lines the VIC has changed since the last frame.
35
+ # The bytes of one row of the texture.
36
+ def row_bytes = @width * 4
37
+
38
+ # Repacks only the lines the chip has changed since the last frame.
39
39
  def update
40
- dirty = @vic.dirty_lines
40
+ video = @video
41
+ dirty = video.dirty_lines
42
+ display = video.display
41
43
  row = 0
42
- while row < @height
43
- pack_row(row) if dirty[row + @row_offset]
44
+ while row < @lines
45
+ pack_row(display, row) if dirty[row + @row_offset]
44
46
  row += 1
45
47
  end
46
- @vic.clear_dirty_lines!
48
+ video.clear_dirty_lines!
47
49
  end
48
50
 
49
51
  private
50
52
 
51
- def pack_row(row)
52
- display = @vic.display
53
+ def pack_row(display, row)
53
54
  pixels = @pixels
54
- from = ((row + @row_offset) * @vic.width) + @col_offset
55
- to = (row + @band) * ROW_WORDS
56
- last = to + ROW_WORDS
55
+ pairs = @pairs
56
+ from = ((row + @row_offset) * @stride) + @col_offset
57
+ to = (row + @band) * @row_words
58
+ last = to + @row_words
57
59
  while to < last
58
- pixels[to] = PAIRS[(display[from] & 0x0f) | ((display[from + 1] & 0x0f) << 4)]
60
+ pixels[to] = pairs[(display[from] & 0x0f) | ((display[from + 1] & 0x0f) << 4)]
59
61
  from += 2
60
62
  to += 1
61
63
  end
@@ -8,7 +8,7 @@ module Badline
8
8
  # a tune on more than one gets a row of buttons above the voices that
9
9
  # pick it, which makes the view PICKER taller.
10
10
  class SIDView
11
- COLORS = Screen::COLORS
11
+ COLORS = VIC::PALETTE
12
12
  TEXT = COLORS[14]
13
13
  BRIGHT = COLORS[1]
14
14
  DIM = COLORS[11]
@@ -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