badline 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +104 -292
  5. data/doc/library.md +69 -0
  6. data/doc/media.md +78 -0
  7. data/doc/pinned-behaviour.md +53 -9
  8. data/doc/sid-player.md +136 -0
  9. data/doc/snapshots.md +102 -0
  10. data/lib/badline/address_bus/roms.rb +32 -0
  11. data/lib/badline/address_bus.rb +4 -4
  12. data/lib/badline/audio/bare_player.rb +24 -0
  13. data/lib/badline/audio/checkpoints.rb +42 -0
  14. data/lib/badline/audio/jukebox.rb +11 -4
  15. data/lib/badline/audio/machine_player.rb +16 -0
  16. data/lib/badline/audio/renderer.rb +80 -9
  17. data/lib/badline/audio/stereo.rb +3 -5
  18. data/lib/badline/audio.rb +1 -0
  19. data/lib/badline/cartridge/freezer.rb +2 -0
  20. data/lib/badline/cartridge.rb +3 -0
  21. data/lib/badline/computer/attachments.rb +5 -5
  22. data/lib/badline/computer.rb +12 -10
  23. data/lib/badline/core.rb +48 -0
  24. data/lib/badline/data_path.rb +37 -0
  25. data/lib/badline/datasette.rb +24 -9
  26. data/lib/badline/drive1541/disk.rb +3 -3
  27. data/lib/badline/drive1541/disk_state.rb +14 -5
  28. data/lib/badline/frontend/app.rb +41 -37
  29. data/lib/badline/frontend/boot.rb +16 -4
  30. data/lib/badline/frontend/buttons.rb +101 -7
  31. data/lib/badline/frontend/confirmation.rb +56 -0
  32. data/lib/badline/frontend/controls.rb +44 -32
  33. data/lib/badline/frontend/file_browser.rb +225 -0
  34. data/lib/badline/frontend/frame_report.rb +52 -0
  35. data/lib/badline/frontend/menu_dialogs.rb +160 -0
  36. data/lib/badline/frontend/menu_media.rb +157 -0
  37. data/lib/badline/frontend/menu_pages.rb +220 -0
  38. data/lib/badline/frontend/name_field.rb +88 -0
  39. data/lib/badline/frontend/painter.rb +10 -0
  40. data/lib/badline/frontend/pause_menu.rb +264 -0
  41. data/lib/badline/frontend/player_window.rb +3 -3
  42. data/lib/badline/frontend/snapshots.rb +146 -18
  43. data/lib/badline/frontend/sound.rb +11 -6
  44. data/lib/badline/frontend/timeline.rb +5 -1
  45. data/lib/badline/frontend.rb +8 -0
  46. data/lib/badline/kernal_trap/load.rb +10 -0
  47. data/lib/badline/kernal_trap/routine.rb +28 -0
  48. data/lib/badline/kernal_trap/serial.rb +19 -7
  49. data/lib/badline/media/disk_list.rb +75 -0
  50. data/lib/badline/media/disk_set.rb +118 -0
  51. data/lib/badline/media.rb +9 -3
  52. data/lib/badline/model.rb +62 -0
  53. data/lib/badline/options/event.rb +5 -3
  54. data/lib/badline/options/help.rb +16 -12
  55. data/lib/badline/options/table.rb +6 -2
  56. data/lib/badline/options/validation.rb +9 -0
  57. data/lib/badline/options.rb +10 -7
  58. data/lib/badline/region.rb +16 -4
  59. data/lib/badline/reu/ram.rb +27 -0
  60. data/lib/badline/reu/saved_state.rb +27 -0
  61. data/lib/badline/roms/README +10 -0
  62. data/lib/badline/roms/kernal-sx64.rom +0 -0
  63. data/lib/badline/sid/decimator.rb +13 -0
  64. data/lib/badline/sid/filter.rb +6 -3
  65. data/lib/badline/sid.rb +19 -0
  66. data/lib/badline/snapshot/fields.rb +1 -0
  67. data/lib/badline/snapshot/setup.rb +20 -8
  68. data/lib/badline/snapshot/state.rb +14 -7
  69. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  70. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  71. data/lib/badline/snapshot/vice/restore.rb +3 -1
  72. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  73. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  74. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  75. data/lib/badline/snapshot/vice.rb +25 -23
  76. data/lib/badline/storage/t64.rb +7 -4
  77. data/lib/badline/storage/tap.rb +7 -5
  78. data/lib/badline/storage/unavailable.rb +1 -1
  79. data/lib/badline/storage.rb +5 -5
  80. data/lib/badline/version.rb +1 -1
  81. data/lib/badline/vic/sprite/timing.rb +5 -9
  82. data/lib/badline/vic/sprite.rb +4 -3
  83. data/lib/badline.rb +2 -38
  84. metadata +22 -1
@@ -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"
@@ -8,6 +8,8 @@ module Badline
8
8
  # cartridge ROM in Ultimax mode. The cartridge lets go of NMI when its
9
9
  # software acknowledges the freeze.
10
10
  module Freezer
11
+ def freezer? = true
12
+
11
13
  def press_button
12
14
  @button = true
13
15
  self.nmi = true if freeze_allowed?
@@ -179,6 +179,9 @@ module Badline
179
179
  # The RES line on the expansion port.
180
180
  def reset; end
181
181
 
182
+ # Whether the cartridge has a freeze button.
183
+ def freezer? = false
184
+
182
185
  # The freeze button, which cartridges without one ignore.
183
186
  def press_button; end
184
187
 
@@ -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)
@@ -7,8 +7,8 @@ module Badline
7
7
  # changed lines, present and wait. Pacer decides the cycles and the wait.
8
8
  class App
9
9
  SCALE = 2
10
+ DROPFILE = 0x1000
10
11
  TITLE = "Badline"
11
- STAGES = %w[events emulate audio blit present wait].freeze
12
12
 
13
13
  # Takes the frame limit, the pacing, the snapshot path, the sound and
14
14
  # the verbosity from Options, and runs the timeline's events.
@@ -22,11 +22,11 @@ module Badline
22
22
  @screen = Screen.new(computer.vic)
23
23
  @led = DriveLed.for(computer)
24
24
  @controls = Controls.new(computer)
25
- @spent = Array.new(STAGES.size, 0.0)
26
- @slowest = 0.0
27
25
  open_window
28
- @sound = Sound.new(computer.sid, options.sound?, @verbose)
26
+ @sound = Sound.new(computer.sid, computer.region.clock_hz, options.sound?, @verbose)
29
27
  @gamepads = Gamepads.new(computer, @verbose)
28
+ @frame_report = FrameReport.new(@sound)
29
+ @menu = PauseMenu.new(Painter.new(@renderer), options, @snapshots)
30
30
  end
31
31
 
32
32
  def run
@@ -34,8 +34,7 @@ module Badline
34
34
  @running = true
35
35
  @started = @reported = now
36
36
  @pacer.start(@started)
37
- @reported_samples = 0
38
- frame while @running
37
+ @menu.open? ? paused_frame : frame while @running
39
38
  @snapshots.finish
40
39
  @computer.drive1541&.flush
41
40
  @gamepads.close
@@ -64,11 +63,10 @@ module Badline
64
63
  end
65
64
 
66
65
  def finish_frame(stamps)
67
- STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
68
- took = stamps[-2] - stamps.first
69
- @slowest = took if took > @slowest
66
+ @frame_report.add(stamps) if @verbose
70
67
  @frames += 1
71
68
  @timeline.run(@computer, @frames)
69
+ @snapshots.tick(@frames)
72
70
  @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
73
71
  @pacer.check(Pacer::EARLY_CHECK, stamps.last - @started, stamps.last) if @frames == Pacer::EARLY_CHECK
74
72
  report(stamps.last) if (@frames % 50).zero?
@@ -95,8 +93,6 @@ module Badline
95
93
  @texture = SDL.SDL_CreateTexture(
96
94
  @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
97
95
  )
98
- SDL.rect_w(SDL.rect, Screen::WIDTH)
99
- SDL.rect_h(SDL.rect, Screen::HEIGHT)
100
96
  end
101
97
 
102
98
  def close_window
@@ -107,7 +103,7 @@ module Badline
107
103
  end
108
104
 
109
105
  def handle_events
110
- handle_event(SDL.event_type(SDL.event)) while SDL.SDL_PollEvent(SDL.event) != 0
106
+ handle_event(SDL.event_type(SDL.event)) while !@menu.open? && SDL.SDL_PollEvent(SDL.event) != 0
111
107
  end
112
108
 
113
109
  def handle_event(type)
@@ -122,6 +118,7 @@ module Badline
122
118
  @controls.mouse_button(SDL.event_button(SDL.event), type == SDL::MOUSEBUTTONDOWN)
123
119
  when SDL::CONTROLLERDEVICEADDED, SDL::CONTROLLERDEVICEREMOVED
124
120
  @gamepads.rescan
121
+ when DROPFILE then resume(false) unless @menu.drop(@computer, @controls, @sound)
125
122
  end
126
123
  end
127
124
 
@@ -144,21 +141,46 @@ module Badline
144
141
  @led = DriveLed.for(computer)
145
142
  @controls.computer = computer
146
143
  @gamepads.computer = computer
147
- @sound.sid = computer.sid
144
+ @sound.switch(computer.sid, computer.region.clock_hz)
145
+ @snapshots.computer = computer
148
146
  end
149
147
 
150
148
  def handle_toggle(scancode)
151
149
  if scancode == Keys::TAB
152
- @controls.cycle_mode(SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT) ? -1 : 1)
153
- SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
150
+ @controls.toggle_keys
154
151
  elsif scancode == Keys::F9
155
- @controls.swap_ports
152
+ return open_menu
156
153
  else
157
154
  @sound.toggle_mute
158
155
  end
159
156
  update_title
160
157
  end
161
158
 
159
+ def open_menu = @menu.show(@computer, @controls, @sound)
160
+
161
+ # Closes the menu and runs the machine on from where it stood,
162
+ # pressing the cartridge's freeze button if asked.
163
+ def resume(freeze)
164
+ @menu.close
165
+ SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
166
+ @timeline.press_freeze(@computer, @frames) if freeze
167
+ @reported = now
168
+ @pacer.start(@reported)
169
+ update_title
170
+ end
171
+
172
+ # Runs a frame of the menu, which has the keys and the mouse while the
173
+ # machine stands still.
174
+ def paused_frame
175
+ action = @menu.frame(@renderer, @texture, @controls)
176
+ if action == :quit
177
+ @running = false
178
+ elsif !action.nil?
179
+ swap(@menu.computer) if action == :swap
180
+ resume(action == :freeze)
181
+ end
182
+ end
183
+
162
184
  def update_title
163
185
  title = TITLE
164
186
  title += " [#{@controls.tag}]" unless @controls.tag.empty?
@@ -183,12 +205,12 @@ module Badline
183
205
 
184
206
  def upload
185
207
  @screen.update
186
- SDL.SDL_UpdateTexture(@texture, SDL.rect, @screen.pixels, Screen::ROW_BYTES)
208
+ SDL.SDL_UpdateTexture(@texture, nil, @screen.pixels, Screen::ROW_BYTES)
187
209
  end
188
210
 
189
211
  def draw
190
212
  SDL.SDL_RenderClear(@renderer)
191
- SDL.SDL_RenderCopy(@renderer, @texture, SDL.rect, SDL.rect)
213
+ SDL.SDL_RenderCopy(@renderer, @texture, nil, nil)
192
214
  @led&.draw(@renderer)
193
215
  @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
194
216
  SDL.SDL_RenderPresent(@renderer)
@@ -197,28 +219,10 @@ module Badline
197
219
  def report(at)
198
220
  @pacer.check(50, at - @reported, at)
199
221
  @pacer.measure(50, at - @reported)
200
- report_frames(at) if @verbose
201
- @spent = Array.new(STAGES.size, 0.0)
202
- @slowest = 0.0
222
+ @frame_report.show(at - @reported) if @verbose
203
223
  @reported = at
204
224
  end
205
225
 
206
- def report_frames(at)
207
- fps = 50 / (at - @reported)
208
- stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
209
- puts "#{fps.round(1)} fps, per frame ms: #{stages.join(' ')}, slowest work #{(@slowest * 1000).round(2)}"
210
- report_sound(at) if @sound.on?
211
- end
212
-
213
- def report_sound(at)
214
- sound = @sound
215
- rate = (sound.queued - @reported_samples) / (at - @reported)
216
- queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
217
- puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
218
- sound.reset_levels
219
- @reported_samples = sound.queued
220
- end
221
-
222
226
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
227
  end
224
228
  end