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
@@ -7,9 +7,11 @@ module Badline
7
7
  # turn the queue's shuffle, loop and all-subtunes modes on and off, and
8
8
  # quit, and the status line follows along. Seeking runs the subtune
9
9
  # silently up to where it was asked to go: on from where it is to go
10
- # forward, and from the start again to go back. A subtune that plays to
11
- # its end moves on as Media::Queue#advance says. It stops at the end of
12
- # the queue, on q, or on Ctrl-C.
10
+ # forward, and from the latest of its Checkpoints at or before there to
11
+ # go back, or to go forward when that checkpoint is further on than
12
+ # where the subtune is. A subtune that plays to its end moves on as
13
+ # Media::Queue#advance says. It stops at the end of the queue, on q, or
14
+ # on Ctrl-C.
13
15
  #
14
16
  # `renderer` builds the renderer for an entry's subtune at the sink's
15
17
  # rate, or returns nil for an entry that can't play, which is skipped
@@ -34,6 +36,9 @@ module Badline
34
36
  @backward = false
35
37
  @below_at = -BELOW_NOTE
36
38
  @from = 0.0
39
+ @entry = nil
40
+ @subtune = 0
41
+ @checkpoints = Checkpoints.new
37
42
  end
38
43
 
39
44
  # Returns the result of the last subtune's Playback#play, or :unplayable
@@ -77,6 +82,7 @@ module Badline
77
82
  end
78
83
 
79
84
  def play(entry, subtune)
85
+ @checkpoints = Checkpoints.new unless entry.equal?(@entry) && subtune == @subtune
80
86
  @entry = entry
81
87
  @subtune = subtune
82
88
  @elapsed = 0.0
@@ -84,6 +90,7 @@ module Badline
84
90
  return :unplayable if renderer.nil?
85
91
 
86
92
  renderer.from = @from
93
+ renderer.checkpoints = @checkpoints
87
94
  @streaming = renderer
88
95
  @from = 0.0
89
96
  @backward = false
@@ -127,7 +134,7 @@ module Badline
127
134
 
128
135
  def seek(seconds)
129
136
  target = seconds.clamp(0.0, [@length.call(@entry, @subtune) - 1.0, 0.0].max)
130
- return @streaming.from = target if target > @elapsed
137
+ return @streaming.from = target if target > @elapsed && !@streaming.checkpoint_ahead?(target)
131
138
 
132
139
  @from = target
133
140
  skip
@@ -39,6 +39,22 @@ module Badline
39
39
 
40
40
  def clock_hz = @computer.region.clock_hz
41
41
 
42
+ # The whole machine, as Computer#snapshot takes it, and the extra SIDs,
43
+ # once it plays.
44
+ def save_state(out)
45
+ @computer.save_state(out)
46
+ @extras.each { |sid| sid.save_state(out) }
47
+ end
48
+
49
+ # Takes a machine that hasn't started to where #save_state found one,
50
+ # in place of #start.
51
+ def load_state(input)
52
+ @computer.load_state(input)
53
+ @extras.each { |sid| sid.load_state(input) }
54
+ @stereo.synthesize!
55
+ @started = true
56
+ end
57
+
42
58
  # Advances one frame of the machine, or `budget` cycles if that is
43
59
  # shorter, then yields whatever the SIDs recorded over it, in stereo.
44
60
  # Returns the cycles advanced.
@@ -35,10 +35,15 @@ module Badline
35
35
 
36
36
  # Where #stream starts: the frames before it are rendered as fast as
37
37
  # they go and yielded without their samples, so the seconds rendered
38
- # still count up to it. Set while streaming, it skips ahead from the
39
- # frame rendered last.
38
+ # still count up to it. Rendering picks up from the latest of the
39
+ # checkpoints at or before it, without the frames before that. Set
40
+ # while streaming, it skips ahead from the frame rendered last.
40
41
  attr_writer :from
41
42
 
43
+ # The Checkpoints this subtune's renderers keep as they go and start
44
+ # from, for a player that seeks. Without them nothing is kept.
45
+ attr_writer :checkpoints
46
+
42
47
  def initialize(tune, seconds:, subtune: nil, rate: DEFAULT_RATE, sid_models: tune.sid_models)
43
48
  @tune = tune
44
49
  @seconds = seconds
@@ -52,6 +57,8 @@ module Badline
52
57
  @rendered = 0.0
53
58
  @observer = ->(_samples, _rendered) {}
54
59
  @from = 0.0
60
+ @played = 0
61
+ @checkpoints = nil
55
62
  end
56
63
 
57
64
  def silence=(seconds)
@@ -65,7 +72,6 @@ module Badline
65
72
  # Yields the seconds rendered so far after every frame.
66
73
  def render(path)
67
74
  container = container_for(path)
68
- player.start
69
75
  container.open(path, rate: @rate, channels: 2) do |writer|
70
76
  each_frame do |samples, seconds|
71
77
  samples.each { |sample| writer << sample }
@@ -76,9 +82,16 @@ module Badline
76
82
 
77
83
  # Starts the tune, then yields each frame's samples along with the
78
84
  # seconds rendered so far.
79
- def stream(&)
80
- player.start
81
- each_frame(&)
85
+ def stream(&) = each_frame(&)
86
+
87
+ # Whether the latest checkpoint at or before `seconds` lies past the
88
+ # frame rendered last, so that a seek there gets there sooner from it
89
+ # than on from here.
90
+ def checkpoint_ahead?(seconds)
91
+ checkpoints = @checkpoints
92
+ return false if checkpoints.nil?
93
+
94
+ checkpoints.reach((seconds * player.clock_hz).round) > @played
82
95
  end
83
96
 
84
97
  private
@@ -101,19 +114,77 @@ module Badline
101
114
  end
102
115
 
103
116
  def each_frame
104
- player.stereo.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
117
+ clock_hz = player.clock_hz
105
118
  total = total_cycles
106
- remaining = total
119
+ @played = start((@from * clock_hz).round)
120
+ remaining = total - @played
107
121
  while remaining.positive?
108
122
  samples = []
109
123
  remaining -= player.frame(remaining) { |sample| samples << sample }
110
- @rendered = (total - remaining).fdiv(player.clock_hz)
124
+ played = total - remaining
125
+ @played = played
126
+ @rendered = played.fdiv(clock_hz)
111
127
  @observer.call(samples, @rendered)
112
128
  yield(@rendered < @from ? [] : samples, @rendered)
113
129
  break if fallen_silent?(samples)
130
+
131
+ save_checkpoint(played, clock_hz)
114
132
  end
115
133
  end
116
134
 
135
+ # Starts the player and its recording, from the latest checkpoint at
136
+ # or before `cycles` if there is one, and returns the cycles into the
137
+ # subtune it starts at.
138
+ def start(cycles)
139
+ state = @checkpoints&.latest(cycles)
140
+ return start_afresh if state.nil?
141
+
142
+ input = Snapshot::StateReader.new(state)
143
+ played = input.int
144
+ resume(input)
145
+ record
146
+ player.stereo.sids.each { |sid| sid.load_recording(input) }
147
+ @level = input.int
148
+ @still = input.int
149
+ played
150
+ end
151
+
152
+ def start_afresh
153
+ player.start
154
+ record
155
+ 0
156
+ end
157
+
158
+ def record = player.stereo.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
159
+
160
+ # Restores the player as it was, then puts back the SID models chosen
161
+ # for this renderer.
162
+ def resume(input)
163
+ sids = player.stereo.sids
164
+ chosen = sids.map(&:model)
165
+ sids.each { |sid| refit(sid, Snapshot::Setup::SID_MODELS.fetch(input.int)) }
166
+ player.load_state(input)
167
+ sids.each_with_index { |sid, index| refit(sid, chosen[index]) }
168
+ end
169
+
170
+ def refit(sid, model)
171
+ sid.model = model unless sid.model == model
172
+ end
173
+
174
+ def save_checkpoint(played, clock_hz)
175
+ checkpoints = @checkpoints
176
+ return if checkpoints.nil? || !checkpoints.due?(played, clock_hz)
177
+
178
+ sids = player.stereo.sids
179
+ out = Snapshot::StateWriter.new
180
+ out.int(played)
181
+ sids.each { |sid| out.int(Snapshot::Setup::SID_MODELS.index(sid.model) || 0) }
182
+ player.save_state(out)
183
+ sids.each { |sid| sid.save_recording(out) }
184
+ out.int(@level).int(@still)
185
+ checkpoints.keep(played, out.state)
186
+ end
187
+
117
188
  # Counts the samples since the output last strayed more than QUIET
118
189
  # from where it settled.
119
190
  def fallen_silent?(samples)
@@ -65,11 +65,9 @@ module Badline
65
65
  private
66
66
 
67
67
  def both(samples)
68
- i = 0
69
- while i < samples.size
70
- yield samples[i]
71
- yield samples[i]
72
- i += 1
68
+ samples.each do |sample|
69
+ yield sample
70
+ yield sample
73
71
  end
74
72
  end
75
73
 
data/lib/badline/audio.rb CHANGED
@@ -6,6 +6,7 @@ require "badline/audio/aiff"
6
6
  require "badline/audio/stereo"
7
7
  require "badline/audio/bare_player"
8
8
  require "badline/audio/machine_player"
9
+ require "badline/audio/checkpoints"
9
10
  require "badline/audio/renderer"
10
11
  require "badline/audio/playback"
11
12
  require "badline/audio/terminal"
@@ -50,7 +50,7 @@ module Badline
50
50
  def detach_drive1541
51
51
  return unless @drive1541
52
52
 
53
- @iec_bus&.detach(@drive1541)
53
+ @iec_bus.detach(@drive1541)
54
54
  @drive1541 = nil
55
55
  @serial_trap&.device = serial_trap_device
56
56
  end
@@ -95,10 +95,10 @@ module Badline
95
95
  @save_trap.load_state(input)
96
96
  end
97
97
 
98
- # The serial bus, whose presence decides what CIA 2's port A reads,
99
- # and a true 1541 on it.
98
+ # A true 1541 on the serial bus. The leading flag is the bus itself,
99
+ # which every machine now has, kept so older snapshots still load.
100
100
  def save_serial_bus(out)
101
- out.boolean(!@iec_bus.nil?).boolean(!@drive1541.nil?)
101
+ out.boolean(true).boolean(!@drive1541.nil?)
102
102
  return unless @drive1541
103
103
 
104
104
  out.int(@drive1541.device)
@@ -106,7 +106,7 @@ module Badline
106
106
  end
107
107
 
108
108
  def load_serial_bus(input)
109
- iec_bus if input.boolean?
109
+ input.boolean?
110
110
  return detach_drive1541 unless input.boolean?
111
111
 
112
112
  device = input.int
@@ -42,9 +42,13 @@ module Badline
42
42
  # TOD clocks count. The KERNAL tells PAL from NTSC by the raster, so
43
43
  # every region boots the same ROMs. reu plugs an REU of that many K
44
44
  # into the expansion port, where it drives the IRQ line and takes the
45
- # bus for its transfers.
46
- def initialize(debug: false, reu: nil, **machine)
45
+ # bus for its transfers. kernal: names the KERNAL ROM
46
+ # (AddressBus::ROMs::KERNALS), and datasette: false leaves the cassette port
47
+ # empty, as on the SX-64.
48
+ def initialize(debug: false, reu: nil, kernal: :c64, datasette: true, **machine)
47
49
  @address_bus = AddressBus.new(**machine)
50
+ @address_bus.kernal = kernal unless kernal == :c64
51
+ @address_bus.datasette.disconnect! unless datasette
48
52
  @cpu = CPU.new(@address_bus, debug:)
49
53
  @vic = @address_bus.vic
50
54
  @vic.open_bus = -> { @address_bus.ram.peek(@cpu.program_counter) }
@@ -66,7 +70,7 @@ module Badline
66
70
  @serial_trap = nil
67
71
  @save_trap = nil
68
72
  @drive1541 = nil
69
- @iec_bus = nil
73
+ @iec_bus = IECBus.new(host: @cia2).tap(&:plug_in!)
70
74
  @reu = reu ? plug_reu(reu) : nil
71
75
  end
72
76
 
@@ -161,19 +165,17 @@ module Badline
161
165
  # byte transfers reach the drive. The LOAD and SAVE traps stay, and
162
166
  # still serve a mounted image.
163
167
  def attach_drive1541(drive)
164
- @iec_bus.detach(@drive1541) if @iec_bus && @drive1541
168
+ @iec_bus.detach(@drive1541) if @drive1541
165
169
  drive.host_clock_hz = region.clock_hz
166
170
  @drive1541 = drive
167
171
  drive.connect(iec_bus)
168
172
  @serial_trap&.device = serial_trap_device
169
173
  end
170
174
 
171
- # The serial bus. CIA 2's port A joins it the first time it's asked
172
- # for, which plugging in a drive does. Until then port A's serial
173
- # inputs float high.
174
- def iec_bus
175
- @iec_bus ||= IECBus.new(host: @cia2).tap(&:plug_in!)
176
- end
175
+ # The serial bus, which CIA 2's port A drives through the C64's own
176
+ # inverters and reads back on PA6 and PA7, with or without a drive on
177
+ # it.
178
+ attr_reader :iec_bus
177
179
 
178
180
  def capture_output
179
181
  @capture_output ||= ChroutTrap.new(cpu:, bus: address_bus).tap do |trap|
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The emulator core: the machine, its chips, media and traps, and the
4
+ # snapshot state Computer saves and restores with, without the options,
5
+ # the rest of the snapshot code, audio or front end. lib/badline.rb and
6
+ # the Spinel harnesses in spinel/ load it, so a file the core needs is
7
+ # added here.
8
+ require "badline/version"
9
+ require "badline/integer_helper"
10
+ require "badline/region"
11
+ require "badline/model"
12
+ require "badline/addressable"
13
+ require "badline/memory"
14
+ require "badline/color_memory"
15
+ require "badline/rom"
16
+ require "badline/ram_expansion"
17
+ require "badline/address_bus"
18
+ require "badline/instruction"
19
+ require "badline/instruction_set"
20
+ require "badline/status"
21
+ require "badline/keyboard"
22
+ require "badline/joystick"
23
+ require "badline/control_ports"
24
+ require "badline/input"
25
+ require "badline/cycleable"
26
+ require "badline/datasette"
27
+ require "badline/time_of_day"
28
+ require "badline/cia"
29
+ require "badline/via"
30
+ require "badline/sid"
31
+ require "badline/debug_register"
32
+ require "badline/reu"
33
+ require "badline/interrupts"
34
+ require "badline/traps"
35
+ require "badline/cpu"
36
+ require "badline/vic"
37
+ require "badline/keyboard_buffer"
38
+ require "badline/iec_bus"
39
+ require "badline/drive1541"
40
+ require "badline/snapshot/state"
41
+ require "badline/snapshot/setup"
42
+ require "badline/computer"
43
+ require "badline/checkpoint"
44
+ require "badline/storage"
45
+ require "badline/cartridge"
46
+ require "badline/kernal_trap"
47
+ require "badline/chrout_trap"
48
+ require "badline/media"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Badline
6
+ class << self
7
+ attr_writer :data_path
8
+
9
+ # The per-user folder badline keeps its own files in. Defaults to
10
+ # $BADLINE_DATA_PATH, then to the platform's folder for application
11
+ # data. Assigning nil restores the default. Nothing is created until
12
+ # data_folder is asked for a subfolder.
13
+ def data_path
14
+ @data_path ||= ENV.fetch("BADLINE_DATA_PATH", nil) || default_data_path(RUBY_PLATFORM)
15
+ end
16
+
17
+ # ~/Library/Application Support/badline on macOS, and
18
+ # $XDG_DATA_HOME/badline or ~/.local/share/badline elsewhere.
19
+ def default_data_path(platform)
20
+ return File.join(Dir.home, "Library", "Application Support", "badline") if platform.include?("darwin")
21
+
22
+ xdg = ENV.fetch("XDG_DATA_HOME", nil)
23
+ xdg = File.join(Dir.home, ".local", "share") if xdg.nil? || !xdg.start_with?("/")
24
+ File.join(xdg, "badline")
25
+ end
26
+
27
+ # The quicksaves, autosaves or saves subfolder of data_path, created
28
+ # along with data_path if it doesn't exist yet.
29
+ def data_folder(name)
30
+ raise ArgumentError, "unknown data folder: #{name}" unless %w[quicksaves autosaves saves].include?(name)
31
+
32
+ path = File.join(data_path, name)
33
+ FileUtils.mkdir_p(path)
34
+ path
35
+ end
36
+ end
37
+ end
@@ -5,11 +5,15 @@ module Badline
5
5
  # motor runs with a key pressed, and reports the keys through the cassette
6
6
  # sense line on the $01 port.
7
7
  class Datasette
8
+ # What inserting a tape raises on a machine without a cassette port.
9
+ class Missing < ArgumentError; end
10
+
8
11
  attr_reader :tape
9
12
  attr_writer :motor
10
13
 
11
14
  def initialize(tape = nil)
12
15
  @tape = tape
16
+ @connected = true
13
17
  @playing = false
14
18
  @motor = false
15
19
  @countdown = 0
@@ -27,7 +31,18 @@ module Badline
27
31
  @sense_handler = handler
28
32
  end
29
33
 
34
+ # Whether the machine has a datasette at its cassette port. The SX-64
35
+ # has none: its keys stay up and it takes no tape.
36
+ def connected? = @connected
37
+
38
+ def disconnect!
39
+ eject
40
+ @connected = false
41
+ end
42
+
30
43
  def insert(tape)
44
+ raise Missing, "this machine has no datasette" unless @connected
45
+
31
46
  @tape = tape
32
47
  rewind
33
48
  end
@@ -54,18 +69,19 @@ module Badline
54
69
 
55
70
  def running? = @playing && @motor && !@tape.nil?
56
71
 
57
- # The keys, the motor, the countdown to the next pulse and the tape: its
58
- # path and how far it has played.
72
+ # The keys, the motor, the countdown to the next pulse and the tape:
73
+ # its path, its bytes and how far it has played.
59
74
  def save_state(out)
60
75
  out.marker("DATASETTE")
61
76
  out.boolean(@playing).boolean(@motor).int(@countdown).boolean(!@tape.nil?)
62
77
  return unless @tape
63
78
 
64
- out.string(@tape.path).int(@tape.position)
79
+ out.string(@tape.path).blob(@tape.bytes).int(@tape.position)
65
80
  end
66
81
 
67
- # A tape from another file goes in from its path, and a detached
68
- # reader leaves the tape out. The sense and flag handlers don't fire.
82
+ # The tape goes back in from the bytes the state holds, without its
83
+ # host file, unless the tape in is the same one. The sense and flag
84
+ # handlers don't fire.
69
85
  def load_state(input)
70
86
  input.marker("DATASETTE")
71
87
  @playing = input.boolean?
@@ -74,10 +90,9 @@ module Badline
74
90
  return @tape = nil unless input.boolean?
75
91
 
76
92
  path = input.string
93
+ bytes = input.blob
77
94
  position = input.int
78
- return @tape = nil if input.detached?
79
-
80
- @tape = Storage::TAP.new(path) unless @tape&.path&.b == path
95
+ @tape = Storage::TAP.new(path, bytes:) unless @tape&.path&.b == path && @tape.bytes == bytes
81
96
  @tape.position = position
82
97
  end
83
98
 
@@ -99,7 +114,7 @@ module Badline
99
114
  private
100
115
 
101
116
  def press(down)
102
- return if down == @playing
117
+ return if down == @playing || !@connected
103
118
 
104
119
  @playing = down
105
120
  @sense_handler&.call
@@ -160,9 +160,9 @@ module Badline
160
160
  end
161
161
 
162
162
  # Whether the write-protect notch is covered: the disk's image won't
163
- # take writes. A disk without an image takes them, and keeps them
164
- # only in its tracks.
165
- def write_protected? = !@image.nil? && !@image.writable?
163
+ # take writes, or a disk without an image was opened read-only. A
164
+ # disk without an image keeps what it takes only in its tracks.
165
+ def write_protected? = @image.nil? ? @read_only : !@image.writable?
166
166
 
167
167
  # A half track the head writes to, blank to begin with: no flux, and
168
168
  # as many bytes as a turn holds at the bit rate of the +zone+ it's
@@ -5,7 +5,8 @@ module Badline
5
5
  class Disk
6
6
  # Saving and restoring a Disk for a snapshot: the image by its path,
7
7
  # and every half track as the head sees it, with those written since
8
- # the last flush. The image itself is read again from its host file.
8
+ # the last flush. The image itself is read again from its host file,
9
+ # where there still is one.
9
10
  module State
10
11
  # The image's path, expanded, and whether it was opened read-only,
11
12
  # for a disk from Disk.open. Nil for a disk made another way.
@@ -25,18 +26,26 @@ module Badline
25
26
 
26
27
  # The disk a state from save_state describes: +current+ when it was
27
28
  # opened from the same image the same way, and otherwise a disk for
28
- # the image at the path the state names, or one without an image,
29
- # as a detached reader always gets. Either way its tracks come from
30
- # the state.
29
+ # the image at the path the state names. Where no file is at that
30
+ # path, the disk has no image and is write-protected, and a
31
+ # detached reader always gets one without an image. Either way its
32
+ # tracks come from the state.
31
33
  def self.load(input, current)
32
34
  path = input.optional_string
33
35
  read_only = input.boolean?
34
36
  disk = current if current && !path.nil? && current.path == path && current.read_only == read_only
35
- disk ||= path && !input.detached? ? reopen(path, read_only) : Disk.new
37
+ disk ||= fresh(path, read_only, input.detached?)
36
38
  disk.load_tracks(input)
37
39
  disk
38
40
  end
39
41
 
42
+ def self.fresh(path, read_only, detached)
43
+ return Disk.new if path.nil? || detached
44
+ return Disk.new.opened(path, true) unless File.file?(path)
45
+
46
+ reopen(path, read_only)
47
+ end
48
+
40
49
  # Notes the image's path and how it was opened.
41
50
  def opened(path, read_only)
42
51
  @path = File.expand_path(path)
@@ -23,10 +23,10 @@ module Badline
23
23
  @led = DriveLed.for(computer)
24
24
  @controls = Controls.new(computer)
25
25
  open_window
26
- @sound = Sound.new(computer.sid, options.sound?, @verbose)
26
+ @sound = Sound.new(computer.sid, computer.region.clock_hz, options.sound?, @verbose)
27
27
  @gamepads = Gamepads.new(computer, @verbose)
28
28
  @frame_report = FrameReport.new(@sound)
29
- @menu = PauseMenu.new(Painter.new(@renderer), options)
29
+ @menu = PauseMenu.new(Painter.new(@renderer), options, @snapshots)
30
30
  end
31
31
 
32
32
  def run
@@ -66,6 +66,7 @@ module Badline
66
66
  @frame_report.add(stamps) if @verbose
67
67
  @frames += 1
68
68
  @timeline.run(@computer, @frames)
69
+ @snapshots.tick(@frames)
69
70
  @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
70
71
  @pacer.check(Pacer::EARLY_CHECK, stamps.last - @started, stamps.last) if @frames == Pacer::EARLY_CHECK
71
72
  report(stamps.last) if (@frames % 50).zero?
@@ -140,7 +141,7 @@ module Badline
140
141
  @led = DriveLed.for(computer)
141
142
  @controls.computer = computer
142
143
  @gamepads.computer = computer
143
- @sound.sid = computer.sid
144
+ @sound.switch(computer.sid, computer.region.clock_hz)
144
145
  @snapshots.computer = computer
145
146
  end
146
147
 
@@ -20,7 +20,8 @@ module Badline
20
20
  computer = boot(options)
21
21
  rescue Media::TrueDrive::Error, Storage::SIDFile::FormatError, Storage::T64::FormatError,
22
22
  Storage::TAP::FormatError, Storage::CRTFile::FormatError, Storage::G64Image::FormatError,
23
- Cartridge::UnsupportedTypeError, Snapshot::FormatError => e
23
+ Cartridge::UnsupportedTypeError, Snapshot::FormatError, Media::DiskList::Error,
24
+ Datasette::Missing => e
24
25
  warn "#{options.program}: #{options.media_path}: #{e.message}"
25
26
  return 1
26
27
  end
@@ -44,10 +45,14 @@ module Badline
44
45
 
45
46
  # A new machine built from the options, with `media`, if not nil,
46
47
  # attached and started as the command line does, its disk writable if
47
- # `writable`.
48
+ # `writable`. The model gives the chips, and a .sid tune or --sid the
49
+ # SID.
48
50
  def self.start(options, media, writable)
49
- computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
50
- region: options.ntsc? ? Region::NTSC : Region::PAL)
51
+ model = Model.named(options.model)
52
+ computer = Computer.new(vic_model: model.vic_model, cia_model: model.cia_model,
53
+ sid_model: options.sid_model || Media.sid_model(media, otherwise: model.sid_model),
54
+ region: model.region, reu: options.reu, kernal: model.kernal,
55
+ datasette: model.datasette)
51
56
  Media::TrueDrive.plug(computer) if options.true_drive?
52
57
  unless media.nil?
53
58
  puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
@@ -2,14 +2,19 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # The pause menu's question before something power cycles the machine:
6
- # a cartridge going in or out, or Quick open starting a file. CANCEL
7
- # has the focus, so Return alone changes nothing.
5
+ # The pause menu's question before something power cycles the machine
6
+ # or replaces a file: a cartridge going in or out, Quick open starting a
7
+ # file, or a save overwriting one of the same name. CANCEL has the
8
+ # focus, so Return alone changes nothing.
8
9
  class Confirmation
9
- TITLES = { cartridge: "INSERT A CARTRIDGE", remove: "REMOVE THE CARTRIDGE", start: "QUICK OPEN" }.freeze
10
- WARNINGS = { cartridge: "INSERTING IT POWER CYCLES THE MACHINE.", remove: "REMOVING IT POWER CYCLES THE MACHINE.",
11
- start: "THIS STARTS A NEW MACHINE." }.freeze
12
- ANSWERS = { cartridge: "INSERT", remove: "REMOVE", start: "START" }.freeze
10
+ TITLES = { cartridge: "INSERT A CARTRIDGE", remove: "REMOVE THE CARTRIDGE", start: "QUICK OPEN",
11
+ overwrite: "SAVE AS" }.freeze
12
+ LOST = "ANYTHING NOT SAVED IS LOST."
13
+ WARNINGS = { cartridge: ["INSERTING IT POWER CYCLES THE MACHINE.", LOST],
14
+ remove: ["REMOVING IT POWER CYCLES THE MACHINE.", LOST],
15
+ start: ["THIS STARTS A NEW MACHINE.", LOST],
16
+ overwrite: ["OVERWRITE EXISTING SAVE?", ""] }.freeze
17
+ ANSWERS = { cartridge: "INSERT", remove: "REMOVE", start: "START", overwrite: "OVERWRITE" }.freeze
13
18
 
14
19
  def initialize(painter, buttons)
15
20
  @painter = painter
@@ -21,8 +26,8 @@ module Badline
21
26
 
22
27
  def open? = @open
23
28
 
24
- # Asks about `kind`, :cartridge, :remove or :start, for the file at
25
- # `path`.
29
+ # Asks about `kind`, :cartridge, :remove, :start or :overwrite, for the
30
+ # file at `path`, or the save named so.
26
31
  def ask(kind, path)
27
32
  @kind = kind
28
33
  @path = path
@@ -40,8 +45,9 @@ module Badline
40
45
  painter = @painter
41
46
  painter.text(left, top, TITLES.fetch(@kind), PauseMenu::BRIGHT)
42
47
  painter.text(left, top + 20, Painter.fit(File.basename(@path), 40), PauseMenu::TEXT) unless @path.empty?
43
- painter.text(left, top + 44, WARNINGS.fetch(@kind), PauseMenu::TEXT)
44
- painter.text(left, top + 56, "ANYTHING NOT SAVED IS LOST.", PauseMenu::TEXT)
48
+ warning = WARNINGS.fetch(@kind)
49
+ painter.text(left, top + 44, warning[0], PauseMenu::TEXT)
50
+ painter.text(left, top + 56, warning[1], PauseMenu::TEXT) unless warning[1].empty?
45
51
  @buttons.row([left, top + 80, MenuPages::ROW_WIDTH], "CANCEL", :cancel)
46
52
  @buttons.row([left, top + 80 + Buttons::HEIGHT + 2, MenuPages::ROW_WIDTH], ANSWERS.fetch(@kind), :confirm)
47
53
  end