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
@@ -8,8 +8,6 @@ module Badline
8
8
  class DriveLed
9
9
  WIDTH = 12
10
10
  HEIGHT = 4
11
- LEFT = Screen::WIDTH - WIDTH - 8
12
- TOP = Screen::HEIGHT - HEIGHT - 6
13
11
 
14
12
  LIT = [0xff, 0x20, 0x20].freeze
15
13
  DARK = [0x40, 0x00, 0x00].freeze
@@ -20,11 +18,12 @@ module Badline
20
18
  drive ? new(drive) : nil
21
19
  end
22
20
 
23
- # Sets SDL.led_rect to where the LED goes.
24
- def self.place
21
+ # Sets SDL.led_rect to where the LED goes on a screen `width` by
22
+ # `height`.
23
+ def self.place(width, height)
25
24
  rect = SDL.led_rect
26
- SDL.rect_x(rect, LEFT)
27
- SDL.rect_y(rect, TOP)
25
+ SDL.rect_x(rect, width - WIDTH - 8)
26
+ SDL.rect_y(rect, height - HEIGHT - 6)
28
27
  SDL.rect_w(rect, WIDTH)
29
28
  SDL.rect_h(rect, HEIGHT)
30
29
  end
@@ -37,10 +36,10 @@ module Badline
37
36
 
38
37
  def color = lit? ? LIT : DARK
39
38
 
40
- # Fills the LED's rectangle in its colour, then puts back the black
41
- # that RenderClear clears with.
42
- def draw(renderer)
43
- DriveLed.place
39
+ # Fills the LED's rectangle on a screen `width` by `height` in its
40
+ # colour, then puts back the black that RenderClear clears with.
41
+ def draw(renderer, width, height)
42
+ DriveLed.place(width, height)
44
43
  rgb = color
45
44
  SDL.SDL_SetRenderDrawColor(renderer, rgb[0], rgb[1], rgb[2], 255)
46
45
  SDL.SDL_RenderFillRect(renderer, SDL.led_rect)
@@ -31,10 +31,10 @@ module Badline
31
31
 
32
32
  attr_reader :base_cycles, :seconds, :refresh, :trim
33
33
 
34
- # One frame of the region's raster.
35
- def self.machine(region)
36
- cycles = region.cycles_per_line * region.lines_per_frame
37
- new(cycles, cycles.to_f / region.clock_hz, 0, region.clock_hz)
34
+ # One frame of the machine's raster, from its Timing.
35
+ def self.machine(timing)
36
+ cycles = timing.cycles_per_line * timing.lines_per_frame
37
+ new(cycles, cycles.to_f / timing.clock_hz, 0, timing.clock_hz)
38
38
  end
39
39
 
40
40
  def self.display(refresh, clock_hz)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The emulator's SDL window, its renderer and the streaming texture a
6
+ # Screen's pixels go into. The renderer's logical size is the screen's,
7
+ # with each of its pixels `pixel_width` square pixels wide, so the
8
+ # picture keeps the machine's aspect while the menu and the drive LED
9
+ # draw in square pixels over it. The window opens at SCALE times that.
10
+ class MachineWindow
11
+ SCALE = 2
12
+ TITLE = "Badline"
13
+
14
+ # The logical size, in square pixels.
15
+ attr_reader :width, :height
16
+
17
+ attr_reader :renderer, :texture
18
+
19
+ # Opens the window for `screen`, presenting in step with the display
20
+ # when `vsync`.
21
+ def initialize(screen, pixel_width, vsync:)
22
+ abort "SDL_Init: #{SDL.SDL_GetError}" unless SDL.SDL_Init(SDL::INIT_VIDEO | SDL::INIT_EVENTS).zero?
23
+
24
+ SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
25
+ SDL.SDL_SetHint("SDL_MOUSE_RELATIVE_SCALING", "0")
26
+ measure(screen, pixel_width)
27
+ @window = SDL.SDL_CreateWindow(
28
+ TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED, @width * SCALE, @height * SCALE,
29
+ SDL::WINDOW_RESIZABLE
30
+ )
31
+ flags = SDL::RENDERER_ACCELERATED
32
+ flags |= SDL::RENDERER_PRESENTVSYNC if vsync
33
+ @renderer = SDL.SDL_CreateRenderer(@window, -1, flags)
34
+ create_texture
35
+ end
36
+
37
+ # The refresh rate of the display the window is on, in Hz.
38
+ def refresh_rate
39
+ SDL.SDL_GetWindowDisplayMode(@window, SDL.display_mode)
40
+ SDL.mode_refresh(SDL.display_mode)
41
+ end
42
+
43
+ # Fits the texture, the logical size and the window to another
44
+ # machine's screen, unless it measures the same.
45
+ def fit(screen, pixel_width)
46
+ before = [@width, @height, @texture_width]
47
+ measure(screen, pixel_width)
48
+ return if before == [@width, @height, @texture_width]
49
+
50
+ SDL.SDL_DestroyTexture(@texture)
51
+ create_texture
52
+ SDL.SDL_SetWindowSize(@window, @width * SCALE, @height * SCALE)
53
+ end
54
+
55
+ def upload(screen)
56
+ SDL.SDL_UpdateTexture(@texture, nil, screen.pixels, screen.row_bytes)
57
+ end
58
+
59
+ # Titles the window, TITLE followed by `tags` in brackets.
60
+ def title(tags)
61
+ SDL.SDL_SetWindowTitle(@window, ([TITLE] + tags.map { |tag| "[#{tag}]" }).join(" "))
62
+ end
63
+
64
+ def close
65
+ SDL.SDL_DestroyTexture(@texture)
66
+ SDL.SDL_DestroyRenderer(@renderer)
67
+ SDL.SDL_DestroyWindow(@window)
68
+ SDL.SDL_Quit
69
+ end
70
+
71
+ private
72
+
73
+ def measure(screen, pixel_width)
74
+ @texture_width = screen.width
75
+ @width = screen.width * pixel_width
76
+ @height = screen.height
77
+ end
78
+
79
+ def create_texture
80
+ SDL.SDL_RenderSetLogicalSize(@renderer, @width, @height)
81
+ @texture = SDL.SDL_CreateTexture(
82
+ @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, @texture_width, @height
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
@@ -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
@@ -4,14 +4,18 @@ module Badline
4
4
  module Frontend
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
- # 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.
7
+ # one disk to the next, and the set of the disk in device 8, which
8
+ # PREVIOUS and NEXT step through: the .m3u or .vfl list it came from,
9
+ # while that lists it, or the set DiskSet finds. It keeps what last went
10
+ # wrong for the page to show.
9
11
  class MenuMedia
10
- attr_reader :disk_path, :problem
12
+ attr_reader :problem
11
13
  attr_writer :computer
12
14
 
13
- def initialize(disk_path, writable)
14
- @disk_path = disk_path
15
+ # Takes the disk set from the command line's `media_path` when it's a
16
+ # list.
17
+ def initialize(media_path, writable)
18
+ @list = Media::DiskList.list?(media_path) ? media_path : ""
15
19
  @writable = writable
16
20
  @set_path = ""
17
21
  @set = []
@@ -21,24 +25,40 @@ module Badline
21
25
 
22
26
  def writable? = @writable
23
27
 
28
+ # The expanded path of the disk in device 8, however it went in, or
29
+ # an empty one.
30
+ def disk_path
31
+ drive = @computer.drive1541
32
+ unless drive.nil?
33
+ disk = drive.disk
34
+ return disk.nil? ? "" : disk.path.to_s
35
+ end
36
+
37
+ path = @computer.mounted_path
38
+ path.empty? ? path : File.expand_path(path)
39
+ end
40
+
24
41
  def inserted?
25
42
  drive = @computer.drive1541
26
43
  drive.nil? ? @computer.mounted? : !drive.disk.nil?
27
44
  end
28
45
 
29
46
  # Puts a disk in device 8 or a tape in the datasette, `kind` :disk or
30
- # :tape.
47
+ # :tape, and says whether it went in. A list puts its first disk in.
31
48
  def insert(kind, path)
32
49
  @problem = ""
33
50
  if kind == :disk
34
- Media.insert_disk(@computer, path, read_only: !@writable)
35
- @disk_path = path
51
+ disk = Media::DiskList.disk(path)
52
+ Media.insert_disk(@computer, disk, read_only: !@writable)
53
+ list(path)
36
54
  else
37
55
  @computer.datasette.insert(Storage::TAP.new(path))
38
56
  end
57
+ true
39
58
  rescue ArgumentError, SystemCallError, Storage::TAP::FormatError, Storage::T64::FormatError,
40
59
  Storage::G64Image::FormatError => e
41
60
  @problem = e.message
61
+ false
42
62
  end
43
63
 
44
64
  # Does what the Drive page's :eject_disk, :previous_disk, :next_disk,
@@ -55,23 +75,25 @@ module Badline
55
75
  def eject_disk
56
76
  drive = @computer.drive1541
57
77
  drive.nil? ? @computer.unmount : drive.insert(nil)
58
- @disk_path = ""
59
78
  end
60
79
 
61
80
  # Sets whether disks are writable, for the disk in the drive too,
62
81
  # which goes in again as the notch now says.
63
82
  def writable(writable)
64
83
  @writable = writable
65
- insert(:disk, @disk_path) if inserted? && !@disk_path.empty?
84
+ path = disk_path
85
+ insert(:disk, path) if inserted? && !path.empty?
66
86
  end
67
87
 
68
88
  # The disks of the set the disk in the drive belongs to.
69
89
  def disk_set
70
- return [] if @disk_path.empty?
90
+ path = disk_path
91
+ return [] if path.empty?
71
92
 
72
- unless @set_path == @disk_path
73
- @set = Media::DiskSet.around(@disk_path)
74
- @set_path = @disk_path
93
+ unless @set_path == path
94
+ listed = @list.empty? ? [] : expanded(Media::DiskList.disks(@list))
95
+ @set = listed.include?(path) ? listed : expanded(Media::DiskSet.around(path))
96
+ @set_path = path
75
97
  end
76
98
  @set
77
99
  end
@@ -80,7 +102,7 @@ module Badline
80
102
  # drive, if there is one.
81
103
  def step_disk(step)
82
104
  set = disk_set
83
- index = set.index(@disk_path)
105
+ index = set.index(disk_path)
84
106
  return if index.nil? || !(index + step).between?(0, set.size - 1)
85
107
 
86
108
  insert(:disk, set[index + step])
@@ -97,6 +119,26 @@ module Badline
97
119
  false
98
120
  end
99
121
 
122
+ # What a save of the machine is named after: the cartridge, the disk,
123
+ # the tape, or BASIC, without the bracketed parts of a file's name. A
124
+ # VIC-20's are named after its disk, or else the machine.
125
+ def game_name
126
+ vic20 = @computer.family == :vic20
127
+ name = vic20 ? "" : cartridge_name
128
+ disk = disk_path
129
+ name = File.basename(disk, File.extname(disk)) if name.empty? && !disk.empty?
130
+ name = tape_name if name.empty? && !vic20
131
+ name = name.sub(/\s*[(\[].*\z/, "").strip
132
+ return name unless name.empty?
133
+
134
+ vic20 ? "VIC-20" : "BASIC"
135
+ end
136
+
137
+ def tape_name
138
+ tape = @computer.datasette.tape
139
+ tape.nil? ? "" : File.basename(tape.path, File.extname(tape.path))
140
+ end
141
+
100
142
  # The name the cartridge in the expansion port gives itself, or an
101
143
  # empty one.
102
144
  def cartridge_name
@@ -115,7 +157,7 @@ module Badline
115
157
  def start(options, path)
116
158
  @problem = ""
117
159
  computer = Frontend.start(options, path, @writable)
118
- @disk_path = PauseMenu.disk_path(path)
160
+ list(path)
119
161
  computer
120
162
  rescue ArgumentError, SystemCallError, Media::TrueDrive::Error, Storage::SIDFile::FormatError,
121
163
  Storage::T64::FormatError, Storage::TAP::FormatError, Storage::CRTFile::FormatError,
@@ -123,6 +165,18 @@ module Badline
123
165
  @problem = e.message
124
166
  nil
125
167
  end
168
+
169
+ private
170
+
171
+ # Keeps `path` as the list the set comes from, if it's a list.
172
+ def list(path)
173
+ return unless Media::DiskList.list?(path)
174
+
175
+ @list = path
176
+ @set_path = ""
177
+ end
178
+
179
+ def expanded(paths) = paths.map { |path| File.expand_path(path) }
126
180
  end
127
181
  end
128
182
  end
@@ -7,6 +7,8 @@ module Badline
7
7
  # row for each thing to do, a setting showing its value on the right,
8
8
  # which a press steps on.
9
9
  class MenuPages
10
+ include MenuPorts
11
+
10
12
  TEXT = PauseMenu::TEXT
11
13
  DIM = PauseMenu::DIM
12
14
  LINE = 12
@@ -14,19 +16,23 @@ module Badline
14
16
  ROW_WIDTH = 200
15
17
 
16
18
  WARNING = 0xff7a6b
19
+ RECENT = %i[recent0 recent1 recent2 recent3 recent4 recent5 recent6 recent7].freeze
17
20
 
18
- PORT_DEVICES = %i[joystick mouse paddles].freeze
19
- PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
20
- PORT_ACTIONS = [%i[port1_joystick port1_mouse port1_paddles], %i[port2_joystick port2_mouse port2_paddles]].freeze
21
- POTS = [%i[none mouse1 paddles1], %i[none mouse2 paddles2]].freeze
21
+ SID_MODELS = %i[mos6581 mos8580].freeze
22
+ RAM_NAMES = {
23
+ unexpanded: "", "3k": "3K", "8k": "8K", "16k": "16K", "24k": "24K", "32k": "32K",
24
+ all: "35K, ALL BLOCKS"
25
+ }.freeze
22
26
 
23
27
  # The disks, tapes and cartridges the pages put in and take out.
24
28
  attr_reader :media
25
29
 
26
- def initialize(painter, buttons, disk_path, options)
30
+ def initialize(painter, buttons, media_path, options, snapshots)
31
+ @snapshots = snapshots
32
+ @recent = []
27
33
  @painter = painter
28
34
  @buttons = buttons
29
- @media = MenuMedia.new(disk_path, options.writable?)
35
+ @media = MenuMedia.new(media_path, options.writable?)
30
36
  @reu_kb = options.reu
31
37
  @computer = nil
32
38
  @controls = nil
@@ -54,10 +60,14 @@ module Badline
54
60
  when :expansion then draw_expansion
55
61
  when :ports then draw_ports
56
62
  when :sound then draw_sound
63
+ when :snapshots then draw_snapshots
57
64
  else draw_power
58
65
  end
59
66
  end
60
67
 
68
+ # The path of the quicksave or autosave a RECENT action stands for.
69
+ def recent(action) = @recent[RECENT.index(action)].to_s
70
+
61
71
  def perform(action)
62
72
  case action
63
73
  when :eject_disk, :previous_disk, :next_disk, :protect, :unprotect then @media.drive(action)
@@ -68,7 +78,7 @@ module Badline
68
78
  when :keys_c64, :keys_joystick then keys(action == :keys_c64 ? :keyboard : :joystick)
69
79
  when :swap then @controls.swap_ports
70
80
  when :sound_on, :mute then @sound.toggle_mute unless @sound.muted? == (action == :mute)
71
- when :sid6581, :sid8580 then @computer.sid.model = action == :sid8580 ? :mos8580 : :mos6581
81
+ when :sid6581, :sid8580 then @computer.sound_source.model = action == :sid8580 ? :mos8580 : :mos6581
72
82
  when :reset then @computer.reset!
73
83
  when :power_cycle then @computer.power_cycle!
74
84
  else pick_port(action)
@@ -77,6 +87,14 @@ module Badline
77
87
 
78
88
  private
79
89
 
90
+ def vic20? = @computer.family == :vic20
91
+
92
+ # Says in the dim colour that the VIC-20 hasn't got what the page
93
+ # shows.
94
+ def note
95
+ @painter.text(@left, @top, "NOT ON THE VIC-20 YET", DIM)
96
+ end
97
+
80
98
  def draw_drive
81
99
  media = @media
82
100
  inserted = media.inserted?
@@ -107,6 +125,8 @@ module Badline
107
125
  end
108
126
 
109
127
  def draw_expansion
128
+ return info("RAM EXPANSION", RAM_NAMES.fetch(@computer.ram_configuration)) if vic20?
129
+
110
130
  cartridge = @computer.address_bus.cartridge
111
131
  info("CARTRIDGE", cartridge.nil? ? "" : cartridge.name)
112
132
  row("INSERT...", :insert_cartridge)
@@ -118,27 +138,32 @@ module Badline
118
138
  info("RAM EXPANSION", "REU, #{@reu_kb} KB")
119
139
  end
120
140
 
121
- def draw_ports
122
- toggle("PORT 1", :port1, [PORT_NAMES, PORT_ACTIONS[0], PORT_DEVICES.index(port_device(1))])
123
- toggle("PORT 2", :port2, [PORT_NAMES, PORT_ACTIONS[1], PORT_DEVICES.index(port_device(2))])
124
- joystick = @controls.joystick_mode?
125
- toggle("KEYS", :keys, [%w[C64 JOYSTICK], %i[keys_c64 keys_joystick], joystick ? 1 : 0])
126
- return unless joystick
127
-
128
- row("SWAP JOYSTICKS", :swap)
129
- skip
130
- arrows = @controls.arrows_port
131
- info("ARROWS, SPACE", "PORT #{arrows}")
132
- info("WASD, LEFT SHIFT", "PORT #{3 - arrows}")
133
- end
134
-
141
+ # The SID's model is a setting, and other sound chips have none.
135
142
  def draw_sound
136
143
  if @sound.on?
137
144
  toggle("OUTPUT", :output, [%w[ON MUTED], %i[sound_on mute], @sound.muted? ? 1 : 0])
138
145
  else
139
146
  info("OUTPUT", "OFF")
140
147
  end
141
- toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], @computer.sid.model == :mos8580 ? 1 : 0])
148
+ model = @computer.sound_source.model
149
+ return unless SID_MODELS.include?(model)
150
+
151
+ toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], model == :mos8580 ? 1 : 0])
152
+ end
153
+
154
+ # Saving now and by name, loading a named save, and the quicksaves
155
+ # and autosaves, newest first, which a press loads.
156
+ def draw_snapshots
157
+ row("QUICKSAVE", :quicksave_now)
158
+ row("SAVE...", :save_as)
159
+ row("LOAD...", :load_save)
160
+ skip
161
+ saves = (@snapshots.list("quicksaves") + @snapshots.list("autosaves")).sort_by { |path| -File.mtime(path).to_f }
162
+ @recent = saves.first(RECENT.size)
163
+ @recent.each_with_index do |path, index|
164
+ kind = File.basename(path).start_with?("autosave") ? "AUTOSAVE" : "QUICKSAVE"
165
+ row(kind, RECENT[index], @snapshots.time_of(path))
166
+ end
142
167
  end
143
168
 
144
169
  def draw_power
@@ -173,26 +198,6 @@ module Badline
173
198
  def skip
174
199
  @top += LINE
175
200
  end
176
-
177
- def keys(mode) = @controls.joystick_mode = mode == :joystick
178
-
179
- def port_device(port)
180
- index = POTS[port - 1].index(@controls.pot)
181
- index.nil? ? :joystick : PORT_DEVICES[index]
182
- end
183
-
184
- # Plugs the device a choice such as :port1_mouse names into its port.
185
- def pick_port(action)
186
- index = PORT_ACTIONS[0].include?(action) ? 0 : 1
187
- choice = PORT_ACTIONS[index].index(action)
188
- plug_device(index + 1, choice) unless choice.nil? || choice == PORT_DEVICES.index(port_device(index + 1))
189
- end
190
-
191
- # Plugs PORT_DEVICES[choice] into the port. A joystick there takes out
192
- # the pot device.
193
- def plug_device(port, choice)
194
- @controls.plug(POTS[port - 1][choice])
195
- end
196
201
  end
197
202
  end
198
203
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's PORTS page, which MenuPages draws: what each C64
6
+ # control port has plugged in, a joystick or a pot device, and whether
7
+ # the keys type or drive the joysticks. The VIC-20 has one port, a
8
+ # joystick's.
9
+ module MenuPorts
10
+ PORT_DEVICES = %i[joystick mouse paddles].freeze
11
+ PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
12
+ PORT_ACTIONS = [%i[port1_joystick port1_mouse port1_paddles], %i[port2_joystick port2_mouse port2_paddles]].freeze
13
+ POTS = [%i[none mouse1 paddles1], %i[none mouse2 paddles2]].freeze
14
+
15
+ private
16
+
17
+ def draw_ports
18
+ return draw_vic20_port if @computer.family == :vic20
19
+
20
+ toggle("PORT 1", :port1, [PORT_NAMES, PORT_ACTIONS[0], PORT_DEVICES.index(port_device(1))])
21
+ toggle("PORT 2", :port2, [PORT_NAMES, PORT_ACTIONS[1], PORT_DEVICES.index(port_device(2))])
22
+ return unless draw_keys("C64")
23
+
24
+ row("SWAP JOYSTICKS", :swap)
25
+ skip
26
+ arrows = @controls.arrows_port
27
+ info("ARROWS, SPACE", "PORT #{arrows}")
28
+ info("WASD, LEFT SHIFT", "PORT #{3 - arrows}")
29
+ end
30
+
31
+ # The VIC-20's one joystick, which both sets of keys drive.
32
+ def draw_vic20_port
33
+ return unless draw_keys("VIC-20")
34
+
35
+ skip
36
+ info("ARROWS, SPACE", "JOYSTICK")
37
+ info("WASD, LEFT SHIFT", "JOYSTICK")
38
+ end
39
+
40
+ # The toggle between the keyboard named and the joysticks, and
41
+ # whether the keys drive the joysticks.
42
+ def draw_keys(keyboard)
43
+ joystick = @controls.joystick_mode?
44
+ toggle("KEYS", :keys, [[keyboard, "JOYSTICK"], %i[keys_c64 keys_joystick], joystick ? 1 : 0])
45
+ joystick
46
+ end
47
+
48
+ def keys(mode) = @controls.joystick_mode = mode == :joystick
49
+
50
+ def port_device(port)
51
+ index = POTS[port - 1].index(@controls.pot)
52
+ index.nil? ? :joystick : PORT_DEVICES[index]
53
+ end
54
+
55
+ # Plugs the device a choice such as :port1_mouse names into its port.
56
+ def pick_port(action)
57
+ index = PORT_ACTIONS[0].include?(action) ? 0 : 1
58
+ choice = PORT_ACTIONS[index].index(action)
59
+ plug_device(index + 1, choice) unless choice.nil? || choice == PORT_DEVICES.index(port_device(index + 1))
60
+ end
61
+
62
+ # Plugs PORT_DEVICES[choice] into the port. A joystick there takes out
63
+ # the pot device.
64
+ def plug_device(port, choice)
65
+ @controls.plug(POTS[port - 1][choice])
66
+ end
67
+ end
68
+ end
69
+ end