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
@@ -3,14 +3,22 @@
3
3
  module Badline
4
4
  # The 1530 datasette. Plays a pulse stream into CIA 1's FLAG pin while the
5
5
  # motor runs with a key pressed, and reports the keys through the cassette
6
- # sense line on the $01 port.
6
+ # sense line on the $01 port. With RECORD down too, it records the
7
+ # machine's write line onto the tape instead.
7
8
  class Datasette
9
+ # What inserting a tape raises on a machine without a cassette port.
10
+ class Missing < ArgumentError; end
11
+
8
12
  attr_reader :tape
9
13
  attr_writer :motor
10
14
 
11
15
  def initialize(tape = nil)
12
16
  @tape = tape
17
+ @connected = true
13
18
  @playing = false
19
+ @recording = false
20
+ @write_high = true
21
+ @since = 0
14
22
  @motor = false
15
23
  @countdown = 0
16
24
  @flag_handler = nil
@@ -27,7 +35,18 @@ module Badline
27
35
  @sense_handler = handler
28
36
  end
29
37
 
38
+ # Whether the machine has a datasette at its cassette port. The SX-64
39
+ # has none: its keys stay up and it takes no tape.
40
+ def connected? = @connected
41
+
42
+ def disconnect!
43
+ eject
44
+ @connected = false
45
+ end
46
+
30
47
  def insert(tape)
48
+ raise Missing, "this machine has no datasette" unless @connected
49
+
31
50
  @tape = tape
32
51
  rewind
33
52
  end
@@ -45,6 +64,30 @@ module Badline
45
64
  def play! = press(true)
46
65
  def stop! = press(false)
47
66
 
67
+ # Presses RECORD and PLAY together.
68
+ def record!
69
+ return if !@connected || @tape.nil?
70
+
71
+ press(true)
72
+ @recording = true
73
+ @since = 0
74
+ end
75
+
76
+ def recording? = @recording
77
+
78
+ # The machine's tape write line. While the deck records with its motor
79
+ # running, each rising edge ends a pulse on the tape, as long as the
80
+ # time since the edge before, and plays back as a falling edge on the
81
+ # read line.
82
+ def write_line=(high)
83
+ rose = high && !@write_high
84
+ @write_high = high
85
+ return unless rose && @recording && running?
86
+
87
+ @tape.record_pulse(@since)
88
+ @since = 0
89
+ end
90
+
48
91
  def playing? = @playing
49
92
 
50
93
  # Sense reads low while a key is down on the deck.
@@ -54,18 +97,19 @@ module Badline
54
97
 
55
98
  def running? = @playing && @motor && !@tape.nil?
56
99
 
57
- # The keys, the motor, the countdown to the next pulse and the tape: its
58
- # path and how far it has played.
100
+ # The keys, the motor, the countdown to the next pulse and the tape:
101
+ # its path, its bytes and how far it has played.
59
102
  def save_state(out)
60
103
  out.marker("DATASETTE")
61
104
  out.boolean(@playing).boolean(@motor).int(@countdown).boolean(!@tape.nil?)
62
105
  return unless @tape
63
106
 
64
- out.string(@tape.path).int(@tape.position)
107
+ out.string(@tape.path).blob(@tape.bytes).int(@tape.position)
65
108
  end
66
109
 
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.
110
+ # The tape goes back in from the bytes the state holds, without its
111
+ # host file, unless the tape in is the same one. The sense and flag
112
+ # handlers don't fire.
69
113
  def load_state(input)
70
114
  input.marker("DATASETTE")
71
115
  @playing = input.boolean?
@@ -74,15 +118,15 @@ module Badline
74
118
  return @tape = nil unless input.boolean?
75
119
 
76
120
  path = input.string
121
+ bytes = input.blob
77
122
  position = input.int
78
- return @tape = nil if input.detached?
79
-
80
- @tape = Storage::TAP.new(path) unless @tape&.path&.b == path
123
+ @tape = Storage::TAP.new(path, bytes:) unless @tape&.path&.b == path && @tape.bytes == bytes
81
124
  @tape.position = position
82
125
  end
83
126
 
84
127
  def cycle!
85
128
  return unless running?
129
+ return @since += 1 if @recording
86
130
 
87
131
  @countdown = @tape.next_pulse.to_i if @countdown.zero?
88
132
  return if @countdown.zero?
@@ -99,10 +143,19 @@ module Badline
99
143
  private
100
144
 
101
145
  def press(down)
102
- return if down == @playing
146
+ return if down == @playing || !@connected
103
147
 
148
+ finish_recording unless down
104
149
  @playing = down
105
150
  @sense_handler&.call
106
151
  end
152
+
153
+ # Releasing the keys ends a recording, and writes the tape out.
154
+ def finish_recording
155
+ return unless @recording
156
+
157
+ @recording = false
158
+ @tape.save
159
+ end
107
160
  end
108
161
  end
@@ -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)
@@ -27,7 +27,7 @@ module Badline
27
27
 
28
28
  attr_reader :bus
29
29
 
30
- def initialize(device: 8, bus: nil)
30
+ def initialize(device: 8, bus: IECBus.new)
31
31
  @port_b = (0xff & ~(SERIAL_INPUTS | JUMPERS)) | (((device - 8) & 0x03) << 5)
32
32
  @host = 0
33
33
  self.bus = bus
@@ -38,10 +38,10 @@ module Badline
38
38
  latch_host
39
39
  end
40
40
 
41
- # Takes the C64's lines as they stand, for the drive cycles of the
42
- # next host cycle.
41
+ # Takes the C64's lines as the machine last pushed them into the bus,
42
+ # for the drive cycles of the next host cycle.
43
43
  def latch_host
44
- @host = @bus ? @bus.host_lines : 0
44
+ @host = @bus.host_lines
45
45
  end
46
46
 
47
47
  # The C64's lines as last latched. The jumpers and the bus are the
@@ -55,10 +55,7 @@ module Badline
55
55
  end
56
56
 
57
57
  # Whether the C64 has moved ATN since the last latch_host.
58
- def atn_moved?
59
- lines = @bus ? @bus.host_lines : 0
60
- (lines ^ @host).anybits?(IECBus::HOST_ATN_OUT)
61
- end
58
+ def atn_moved? = (@bus.host_lines ^ @host).anybits?(IECBus::HOST_ATN_OUT)
62
59
 
63
60
  def atn_low? = @bus.atn_low?(@host)
64
61
 
@@ -8,9 +8,8 @@ module Badline
8
8
  # passes go in bulk, and the rest cycle by cycle, which may find the
9
9
  # idle loop and put the drive back to sleep.
10
10
  #
11
- # The drive looks at ATN only when the C64 may have moved it, on a
12
- # bus that says when (IECBus#notifies?), and every host cycle on one
13
- # that doesn't.
11
+ # The drive looks at ATN only when the machine may have moved it, as
12
+ # it pushes its lines into the bus (IECBus#host_lines=).
14
13
  module Sleep
15
14
  # Whether the drive is skipping its idle loop right now.
16
15
  def asleep? = @asleep
@@ -40,10 +39,10 @@ module Badline
40
39
  @wake_at = 0
41
40
  end
42
41
 
43
- # A host cycle asleep that may wake the drive. The bus says whether
44
- # the next ones need a look at ATN.
42
+ # A host cycle asleep that may wake the drive. The next ones need no
43
+ # look at ATN until the machine pushes its lines again.
45
44
  def doze
46
- @host_still = @serial_bus.notifies?
45
+ @host_still = true
47
46
  if @serial_port.atn_moved?
48
47
  settle!
49
48
  elsif @slept == @wake_at
@@ -149,8 +149,8 @@ module Badline
149
149
  # The host has run this cycle already, and the drive sees what it did
150
150
  # from the next one on (see SerialPort). Asleep, the drive owes the
151
151
  # cycles instead, until ATN moves or its counters are due (see Idle).
152
- # It looks at ATN only after the C64 wrote CIA 2's port A, on a bus
153
- # that says so (see host_written!).
152
+ # It looks at ATN only after the machine pushed its lines into the bus
153
+ # (see host_written!).
154
154
  def host_cycle!
155
155
  if @asleep
156
156
  return if (@slept += 1) != @wake_at && @host_still
@@ -169,7 +169,7 @@ module Badline
169
169
  plan_wake if @asleep
170
170
  end
171
171
 
172
- # The C64 wrote CIA 2's port A, so ATN may have moved.
172
+ # The machine pushed its lines into the bus, so ATN may have moved.
173
173
  def host_written!
174
174
  @host_still = false
175
175
  end
@@ -3,12 +3,10 @@
3
3
  module Badline
4
4
  module Frontend
5
5
  # Opens the window, then runs the machine a frame at a time: poll
6
- # events, clock the frame's cycles, queue the SID's samples, upload the
6
+ # events, clock the frame's cycles, queue the sound's samples, upload the
7
7
  # changed lines, present and wait. Pacer decides the cycles and the wait.
8
8
  class App
9
- SCALE = 2
10
9
  DROPFILE = 0x1000
11
- TITLE = "Badline"
12
10
 
13
11
  # Takes the frame limit, the pacing, the snapshot path, the sound and
14
12
  # the verbosity from Options, and runs the timeline's events.
@@ -16,17 +14,21 @@ module Badline
16
14
  @computer = computer
17
15
  @frame_limit = options.frames
18
16
  @verbose = options.verbose?
19
- @pacer = Pacer.new(paced: options.paced?, vsync: options.vsync?, verbose: @verbose, region: computer.region)
17
+ timing = computer.timing
18
+ @pacer = Pacer.new(paced: options.paced?, vsync: options.vsync?, verbose: @verbose, timing:)
20
19
  @timeline = timeline
21
20
  @snapshots = Snapshots.new(computer, options)
22
- @screen = Screen.new(computer.vic)
21
+ @screen = Screen.new(computer.video, timing.crop)
23
22
  @led = DriveLed.for(computer)
24
23
  @controls = Controls.new(computer)
25
- open_window
26
- @sound = Sound.new(computer.sid, options.sound?, @verbose)
24
+ @window = MachineWindow.new(@screen, timing.pixel_width, vsync: @pacer.vsync?)
25
+ @pacer.fit(@window.refresh_rate) if @pacer.vsync?
26
+ @sound = Sound.new(computer.sound_source, timing.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(@window.renderer), options, @snapshots)
30
+ @menu.center(@window.width, @window.height)
31
+ @scripted = false
30
32
  end
31
33
 
32
34
  def run
@@ -39,7 +41,7 @@ module Badline
39
41
  @computer.drive1541&.flush
40
42
  @gamepads.close
41
43
  @sound.close
42
- close_window
44
+ @window.close
43
45
  end
44
46
 
45
47
  private
@@ -66,39 +68,24 @@ module Badline
66
68
  @frame_report.add(stamps) if @verbose
67
69
  @frames += 1
68
70
  @timeline.run(@computer, @frames)
71
+ @snapshots.tick(@frames)
69
72
  @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
70
73
  @pacer.check(Pacer::EARLY_CHECK, stamps.last - @started, stamps.last) if @frames == Pacer::EARLY_CHECK
71
74
  report(stamps.last) if (@frames % 50).zero?
72
- end
73
-
74
- def open_window
75
- abort "SDL_Init: #{SDL.SDL_GetError}" unless SDL.SDL_Init(SDL::INIT_VIDEO | SDL::INIT_EVENTS).zero?
76
-
77
- SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
78
- SDL.SDL_SetHint("SDL_MOUSE_RELATIVE_SCALING", "0")
79
- @window = SDL.SDL_CreateWindow(
80
- TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
81
- Screen::WIDTH * SCALE, Screen::HEIGHT * SCALE, SDL::WINDOW_RESIZABLE
82
- )
83
- flags = SDL::RENDERER_ACCELERATED
84
- flags |= SDL::RENDERER_PRESENTVSYNC if @pacer.vsync?
85
- @renderer = SDL.SDL_CreateRenderer(@window, -1, flags)
86
- fit_display if @pacer.vsync?
87
- SDL.SDL_RenderSetLogicalSize(@renderer, Screen::WIDTH, Screen::HEIGHT)
88
- create_texture
89
- end
90
-
91
- def create_texture
92
- @texture = SDL.SDL_CreateTexture(
93
- @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
94
- )
95
- end
96
-
97
- def close_window
98
- SDL.SDL_DestroyTexture(@texture)
99
- SDL.SDL_DestroyRenderer(@renderer)
100
- SDL.SDL_DestroyWindow(@window)
101
- SDL.SDL_Quit
75
+ menu_events
76
+ end
77
+
78
+ # Opens the menu at a menu event, on the page it names, and closes it
79
+ # at a resume. While the timeline holds it open, its frames count
80
+ # towards the events after it and --frames.
81
+ def menu_events
82
+ if @timeline.menu?(@frames)
83
+ @menu.section = @timeline.menu_section(@frames)
84
+ open_menu
85
+ @scripted = true
86
+ elsif @menu.open? && @timeline.resume?(@frames)
87
+ resume(false)
88
+ end
102
89
  end
103
90
 
104
91
  def handle_events
@@ -136,11 +123,14 @@ module Badline
136
123
  # it.
137
124
  def swap(computer)
138
125
  @computer = computer
139
- @screen = Screen.new(computer.vic)
126
+ timing = computer.timing
127
+ @screen = Screen.new(computer.video, timing.crop)
128
+ @window.fit(@screen, timing.pixel_width)
129
+ @menu.center(@window.width, @window.height)
140
130
  @led = DriveLed.for(computer)
141
131
  @controls.computer = computer
142
132
  @gamepads.computer = computer
143
- @sound.sid = computer.sid
133
+ @sound.switch(computer.sound_source, timing.clock_hz)
144
134
  @snapshots.computer = computer
145
135
  end
146
136
 
@@ -161,6 +151,7 @@ module Badline
161
151
  # pressing the cartridge's freeze button if asked.
162
152
  def resume(freeze)
163
153
  @menu.close
154
+ @scripted = false
164
155
  SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
165
156
  @timeline.press_freeze(@computer, @frames) if freeze
166
157
  @reported = now
@@ -169,50 +160,58 @@ module Badline
169
160
  end
170
161
 
171
162
  # Runs a frame of the menu, which has the keys and the mouse while the
172
- # machine stands still.
163
+ # machine stands still. A menu the timeline opened counts its frames
164
+ # and takes their screenshots.
173
165
  def paused_frame
174
- action = @menu.frame(@renderer, @texture, @controls)
166
+ scripted = @scripted
167
+ action = @menu.frame(@window.renderer, @window.texture, @controls)
168
+ present_menu(scripted)
175
169
  if action == :quit
176
170
  @running = false
177
171
  elsif !action.nil?
178
172
  swap(@menu.computer) if action == :swap
179
173
  resume(action == :freeze)
180
174
  end
175
+ finish_paused_frame if scripted && @running
181
176
  end
182
177
 
183
- def update_title
184
- title = TITLE
185
- title += " [#{@controls.tag}]" unless @controls.tag.empty?
186
- title += " [MUTED]" if @sound.muted?
187
- SDL.SDL_SetWindowTitle(@window, title)
178
+ def present_menu(scripted)
179
+ renderer = @window.renderer
180
+ @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(renderer, path) } if scripted
181
+ SDL.SDL_RenderPresent(renderer)
182
+ SDL.SDL_Delay(10)
183
+ end
184
+
185
+ def finish_paused_frame
186
+ @frames += 1
187
+ @timeline.run(@computer, @frames)
188
+ @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
189
+ menu_events
188
190
  end
189
191
 
190
- def fit_display
191
- SDL.SDL_GetWindowDisplayMode(@window, SDL.display_mode)
192
- @pacer.fit(SDL.mode_refresh(SDL.display_mode))
192
+ def update_title
193
+ tags = []
194
+ tags << @controls.tag unless @controls.tag.empty?
195
+ tags << "MUTED" if @sound.muted?
196
+ @window.title(tags)
193
197
  end
194
198
 
195
199
  def emulate
196
- computer = @computer
197
- cycles = @pacer.cycles(@sound)
198
- i = 0
199
- while i < cycles
200
- computer.cycle!
201
- i += 1
202
- end
200
+ @computer.run_cycles(@pacer.cycles(@sound))
203
201
  end
204
202
 
205
203
  def upload
206
204
  @screen.update
207
- SDL.SDL_UpdateTexture(@texture, nil, @screen.pixels, Screen::ROW_BYTES)
205
+ @window.upload(@screen)
208
206
  end
209
207
 
210
208
  def draw
211
- SDL.SDL_RenderClear(@renderer)
212
- SDL.SDL_RenderCopy(@renderer, @texture, nil, nil)
213
- @led&.draw(@renderer)
214
- @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
215
- SDL.SDL_RenderPresent(@renderer)
209
+ renderer = @window.renderer
210
+ SDL.SDL_RenderClear(renderer)
211
+ SDL.SDL_RenderCopy(renderer, @window.texture, nil, nil)
212
+ @led&.draw(renderer, @window.width, @window.height)
213
+ @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(renderer, path) }
214
+ SDL.SDL_RenderPresent(renderer)
216
215
  end
217
216
 
218
217
  def report(at)
@@ -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, Unsupported => e
24
25
  warn "#{options.program}: #{options.media_path}: #{e.message}"
25
26
  return 1
26
27
  end
@@ -44,10 +45,11 @@ 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 family and model give the machine, and a .sid tune
49
+ # or --sid the SID. The VIC-20 raises Unsupported for a .sid
50
+ # tune.
48
51
  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)
52
+ computer = options.family == :vic20 ? vic20(options, media) : c64(options, media)
51
53
  Media::TrueDrive.plug(computer) if options.true_drive?
52
54
  unless media.nil?
53
55
  puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
@@ -55,5 +57,33 @@ module Badline
55
57
  end
56
58
  computer
57
59
  end
60
+
61
+ def self.c64(options, media)
62
+ sid_model = options.sid_model || Media.sid_model(media, otherwise: nil)
63
+ Machine.build(:c64, model: options.model, sid_model:, reu: options.reu)
64
+ end
65
+
66
+ # A VIC-20 with the RAM --ram names, or else the RAM `media` needs
67
+ # (Media::Vic20Media.ram_for), which it says. Raises Unsupported for
68
+ # media a VIC-20 doesn't take.
69
+ def self.vic20(options, media)
70
+ ram = options.ram
71
+ unless media.nil?
72
+ unsupported = Media::Vic20Media::UNSUPPORTED.include?(File.extname(media).downcase)
73
+ raise Unsupported, "doesn't go in a VIC-20" if unsupported
74
+
75
+ ram ||= vic20_ram(media)
76
+ end
77
+ Machine.build(:vic20, model: options.model, ram:)
78
+ end
79
+
80
+ def self.vic20_ram(media)
81
+ ram = Media::Vic20Media.ram_for(media)
82
+ puts "RAM expansion: #{ram}"
83
+ ram
84
+ end
85
+
86
+ # Media the machine the options name doesn't take.
87
+ class Unsupported < ArgumentError; end
58
88
  end
59
89
  end
@@ -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
@@ -4,11 +4,11 @@ module Badline
4
4
  module Frontend
5
5
  # Routes host input to the machine. Two settings decide where it goes:
6
6
  #
7
- # - The keys: every host key goes to the C64 keyboard, or, as Tab
7
+ # - The keys: every host key goes to the machine's keyboard, or, as Tab
8
8
  # switches, the arrow cluster and space drive one joystick and WASD
9
9
  # and left shift the other. The arrows start on joystick 2, and
10
- # #swap_ports swaps them.
11
- # - A pot device the pause menu plugs into a control port: a 1351
10
+ # #swap_ports swaps them. The VIC-20's one joystick takes both.
11
+ # - A pot device the pause menu plugs into a C64 control port: a 1351
12
12
  # mouse or a pair of paddles, which the host mouse drives. Motion
13
13
  # turns the paddles or moves the mouse, and the host's left and right
14
14
  # buttons go to whichever lines the device puts them on.
@@ -65,7 +65,7 @@ module Badline
65
65
  # The title bar's tag for the settings, empty for the keyboard alone.
66
66
  def tag
67
67
  tags = []
68
- tags << "JOY #{@arrows_port}" if @joystick
68
+ tags << (@computer.family == :vic20 ? "JOY" : "JOY #{@arrows_port}") if @joystick
69
69
  tags << TAGS[POTS.index(@pot)] if pot_device?
70
70
  tags.join(", ")
71
71
  end
@@ -105,7 +105,7 @@ module Badline
105
105
  # RESTORE isn't in the key matrix, so it goes to the machine instead.
106
106
  def c64_key(key, down)
107
107
  if key == :restore
108
- @computer.press_restore if down
108
+ down ? @computer.press_restore : @computer.release_restore
109
109
  else
110
110
  down ? @computer.keyboard.press(key) : @computer.keyboard.release(key)
111
111
  end
@@ -113,9 +113,15 @@ module Badline
113
113
 
114
114
  def paddles? = POTS.index(@pot) >= 3
115
115
 
116
- # A fresh pot device on its port, and none on the other.
116
+ # A fresh pot device on its port, and none on the other. A machine
117
+ # without the ports takes none.
117
118
  def attach_pot_device
118
119
  ports = @computer.control_ports
120
+ if ports.nil?
121
+ @pot = :none
122
+ return
123
+ end
124
+
119
125
  ports.device1 = nil
120
126
  ports.device2 = nil
121
127
  return unless pot_device?