badline 0.9.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/CLAUDE.md +6 -4
  4. data/README.md +39 -4
  5. data/doc/pinned-behaviour.md +146 -0
  6. data/doc/snapshots.md +13 -0
  7. data/lib/badline/address_bus/pla.rb +130 -0
  8. data/lib/badline/address_bus.rb +9 -88
  9. data/lib/badline/chrout_trap.rb +4 -3
  10. data/lib/badline/computer/attachments.rb +47 -7
  11. data/lib/badline/computer/saved_state.rb +1 -0
  12. data/lib/badline/computer.rb +44 -29
  13. data/lib/badline/control_ports.rb +10 -3
  14. data/lib/badline/core.rb +3 -0
  15. data/lib/badline/datasette.rb +39 -1
  16. data/lib/badline/drive1541/serial_port.rb +5 -8
  17. data/lib/badline/drive1541/sleep.rb +5 -6
  18. data/lib/badline/drive1541.rb +3 -3
  19. data/lib/badline/frontend/app.rb +62 -64
  20. data/lib/badline/frontend/boot.rb +33 -8
  21. data/lib/badline/frontend/controls.rb +12 -6
  22. data/lib/badline/frontend/drive_led.rb +9 -10
  23. data/lib/badline/frontend/frame_rate.rb +4 -4
  24. data/lib/badline/frontend/machine_window.rb +87 -0
  25. data/lib/badline/frontend/menu_media.rb +49 -24
  26. data/lib/badline/frontend/menu_pages.rb +23 -40
  27. data/lib/badline/frontend/menu_ports.rb +69 -0
  28. data/lib/badline/frontend/pacer.rb +6 -5
  29. data/lib/badline/frontend/pause_menu.rb +30 -33
  30. data/lib/badline/frontend/player_screen.rb +1 -1
  31. data/lib/badline/frontend/player_window.rb +1 -1
  32. data/lib/badline/frontend/screen.rb +39 -37
  33. data/lib/badline/frontend/sid_view.rb +1 -1
  34. data/lib/badline/frontend/sound.rb +11 -9
  35. data/lib/badline/frontend/timeline.rb +36 -5
  36. data/lib/badline/frontend.rb +2 -0
  37. data/lib/badline/iec_bus.rb +16 -28
  38. data/lib/badline/kernal_trap/drive.rb +3 -0
  39. data/lib/badline/kernal_trap/file.rb +1 -1
  40. data/lib/badline/kernal_trap/layout.rb +112 -0
  41. data/lib/badline/kernal_trap/load.rb +16 -27
  42. data/lib/badline/kernal_trap/routine.rb +11 -27
  43. data/lib/badline/kernal_trap/save.rb +13 -23
  44. data/lib/badline/kernal_trap/serial.rb +21 -19
  45. data/lib/badline/kernal_trap.rb +1 -0
  46. data/lib/badline/keyboard.rb +17 -14
  47. data/lib/badline/machine.rb +60 -0
  48. data/lib/badline/media/vic20_basic.rb +54 -0
  49. data/lib/badline/media/vic20_cartridge.rb +74 -0
  50. data/lib/badline/media/vic20_media.rb +132 -0
  51. data/lib/badline/media.rb +32 -18
  52. data/lib/badline/options/event.rb +5 -3
  53. data/lib/badline/options/help.rb +11 -3
  54. data/lib/badline/options/table.rb +11 -1
  55. data/lib/badline/options/validation.rb +55 -4
  56. data/lib/badline/options.rb +33 -33
  57. data/lib/badline/roms/README +27 -0
  58. data/lib/badline/roms/vic20/basic.rom +0 -0
  59. data/lib/badline/roms/vic20/character.rom +0 -0
  60. data/lib/badline/roms/vic20/kernal-ntsc.rom +0 -0
  61. data/lib/badline/roms/vic20/kernal-pal.rom +0 -0
  62. data/lib/badline/snapshot/container.rb +2 -0
  63. data/lib/badline/snapshot/image.rb +15 -0
  64. data/lib/badline/snapshot/state.rb +4 -3
  65. data/lib/badline/snapshot.rb +10 -0
  66. data/lib/badline/storage/crt_file.rb +16 -5
  67. data/lib/badline/storage/disk_image.rb +3 -0
  68. data/lib/badline/storage/host_directory.rb +3 -0
  69. data/lib/badline/storage/t64.rb +3 -0
  70. data/lib/badline/storage/tap.rb +38 -4
  71. data/lib/badline/storage/unavailable.rb +2 -0
  72. data/lib/badline/timing.rb +12 -0
  73. data/lib/badline/version.rb +1 -1
  74. data/lib/badline/via/shift_register.rb +43 -16
  75. data/lib/badline/vic/bank.rb +53 -16
  76. data/lib/badline/vic/registers.rb +20 -4
  77. data/lib/badline/vic.rb +34 -15
  78. data/lib/badline/vic20/attachments.rb +103 -0
  79. data/lib/badline/vic20/bus.rb +308 -0
  80. data/lib/badline/vic20/cpu.rb +10 -0
  81. data/lib/badline/vic20/keyboard_via_ports.rb +53 -0
  82. data/lib/badline/vic20/port_wiring.rb +50 -0
  83. data/lib/badline/vic20/saved_state.rb +152 -0
  84. data/lib/badline/vic20/sound/output.rb +83 -0
  85. data/lib/badline/vic20/sound.rb +233 -0
  86. data/lib/badline/vic20/user_via_ports.rb +47 -0
  87. data/lib/badline/vic20/vic/painter.rb +239 -0
  88. data/lib/badline/vic20/vic/saved_state.rb +55 -0
  89. data/lib/badline/vic20/vic/text_window.rb +71 -0
  90. data/lib/badline/vic20/vic.rb +267 -0
  91. data/lib/badline/vic20.rb +220 -0
  92. metadata +28 -2
  93. data/lib/badline/address_bus/ultimax_pages.rb +0 -38
@@ -22,11 +22,13 @@ module Badline
22
22
  return @drive.insert(storage) if @drive
23
23
 
24
24
  @drive = KernalTrap::Drive.new(storage)
25
- load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, drive: @drive)
26
- cpu.install_trap(KernalTrap::Load::ADDRESS) { load_trap.call }
27
- @serial_trap = KernalTrap::Serial.new(cpu:, bus: address_bus, drive: @drive, device: serial_trap_device).install
28
- save_trap = @save_trap = KernalTrap::Save.new(cpu:, bus: address_bus, drive: @drive)
29
- cpu.install_trap(KernalTrap::Save::ADDRESS) { save_trap.call }
25
+ layout = KernalTrap::C64_LAYOUT
26
+ load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, layout:, drive: @drive)
27
+ cpu.install_trap(layout.load) { load_trap.call }
28
+ @serial_trap = KernalTrap::Serial.new(cpu:, bus: address_bus, layout:, drive: @drive,
29
+ device: serial_trap_device).install
30
+ save_trap = @save_trap = KernalTrap::Save.new(cpu:, bus: address_bus, layout:, drive: @drive)
31
+ cpu.install_trap(layout.save) { save_trap.call }
30
32
  end
31
33
 
32
34
  # Takes device 8's mounted storage out, and with it the LOAD, SAVE and
@@ -35,8 +37,8 @@ module Badline
35
37
  def unmount
36
38
  return unless @drive
37
39
 
38
- cpu.remove_trap(KernalTrap::Load::ADDRESS)
39
- cpu.remove_trap(KernalTrap::Save::ADDRESS)
40
+ cpu.remove_trap(KernalTrap::C64_LAYOUT.load)
41
+ cpu.remove_trap(KernalTrap::C64_LAYOUT.save)
40
42
  @serial_trap.device = nil
41
43
  @serial_trap = nil
42
44
  @save_trap = nil
@@ -46,6 +48,24 @@ module Badline
46
48
  # Whether device 8 serves a disk or directory through the traps.
47
49
  def mounted? = !@drive.nil?
48
50
 
51
+ # The serial bus, which CIA 2's port A drives through the C64's own
52
+ # inverters and reads back on PA6 and PA7, with or without a drive on
53
+ # it.
54
+ attr_reader :iec_bus
55
+
56
+ # Plugs in a Drive1541, which then runs alongside the C64 on its own
57
+ # clock and talks to it over the serial bus. The serial traps stop
58
+ # answering the drive's device number, so the KERNAL's TALK, LISTEN and
59
+ # byte transfers reach the drive. The LOAD and SAVE traps stay, and
60
+ # still serve a mounted image.
61
+ def attach_drive1541(drive)
62
+ @iec_bus.detach(@drive1541) if @drive1541
63
+ drive.host_clock_hz = region.clock_hz
64
+ @drive1541 = drive
65
+ drive.connect(iec_bus)
66
+ @serial_trap&.device = serial_trap_device
67
+ end
68
+
49
69
  # Unplugs the Drive1541, leaving the serial bus with nothing on it.
50
70
  def detach_drive1541
51
71
  return unless @drive1541
@@ -57,6 +77,26 @@ module Badline
57
77
 
58
78
  private
59
79
 
80
+ # The device number the serial traps answer: device 8, unless a true
81
+ # drive is on the bus as device 8.
82
+ def serial_trap_device
83
+ @drive1541&.device == KernalTrap::Routine::DEVICE ? nil : KernalTrap::Routine::DEVICE
84
+ end
85
+
86
+ # CIA 2's port A drives the serial bus, which reads CLK and DATA back
87
+ # into it.
88
+ def plug_serial_bus
89
+ @iec_bus = IECBus.new
90
+ @cia2.peripheral = @iec_bus
91
+ @cia2.on_port_a_write { push_serial_lines }
92
+ push_serial_lines
93
+ end
94
+
95
+ # Pushes the lines CIA 2's port A pulls into the serial bus.
96
+ def push_serial_lines
97
+ @iec_bus.host_lines = @cia2.port_a_lines
98
+ end
99
+
60
100
  def save_cartridge(out)
61
101
  cartridge = address_bus.cartridge
62
102
  out.boolean(!cartridge.nil?)
@@ -97,6 +97,7 @@ module Badline
97
97
  @init_handlers_lost = [input.int - @init_handlers.length, 0].max
98
98
  load_cartridge(input)
99
99
  address_bus.load_state(input)
100
+ push_serial_lines
100
101
  @cpu.load_state(input)
101
102
  load_trap_drive(input)
102
103
  load_serial_bus(input)
@@ -12,6 +12,12 @@ module Badline
12
12
 
13
13
  attr_reader :address_bus, :cpu, :cycles, :drive1541
14
14
 
15
+ # The path of the disk or directory device 8 serves through the traps
16
+ # (Attachments), or an empty one.
17
+ def mounted_path = @drive.nil? ? "" : @drive.path
18
+
19
+ def family = :c64
20
+
15
21
  def region = address_bus.region
16
22
 
17
23
  def vic = address_bus.vic
@@ -70,7 +76,7 @@ module Badline
70
76
  @serial_trap = nil
71
77
  @save_trap = nil
72
78
  @drive1541 = nil
73
- @iec_bus = IECBus.new(host: @cia2).tap(&:plug_in!)
79
+ plug_serial_bus
74
80
  @reu = reu ? plug_reu(reu) : nil
75
81
  end
76
82
 
@@ -98,6 +104,40 @@ module Badline
98
104
  @cycles += 1
99
105
  end
100
106
 
107
+ # Runs `count` cycles, one #cycle! after another.
108
+ def run_cycles(count)
109
+ i = 0
110
+ while i < count
111
+ cycle!
112
+ i += 1
113
+ end
114
+ end
115
+
116
+ # Runs cycles until the block returns true or the cycle count passes
117
+ # `limit`, checking before each cycle.
118
+ def run_until(limit)
119
+ cycle! until yield || @cycles > limit
120
+ end
121
+
122
+ # The cycle at which #on_init's handlers run, once the KERNAL has booted.
123
+ def init_threshold
124
+ INIT_THRESHOLD
125
+ end
126
+
127
+ # The clock, the raster and the crop of the machine's region, a pixel
128
+ # to a window pixel.
129
+ def timing
130
+ region = address_bus.region
131
+ Timing.new(clock_hz: region.clock_hz, cycles_per_line: region.cycles_per_line,
132
+ lines_per_frame: region.lines_per_frame, crop: region.crop, pixel_width: 1)
133
+ end
134
+
135
+ # The chip whose #display a front end shows.
136
+ def video = @vic
137
+
138
+ # The chip a front end records the machine's sound from.
139
+ def sound_source = @sid
140
+
101
141
  def load_prg(data)
102
142
  uint16(data[0], data[1]).tap do |load_addr|
103
143
  ram.write(load_addr, data[2..])
@@ -159,26 +199,11 @@ module Badline
159
199
  @restore_pulse = true
160
200
  end
161
201
 
162
- # Plugs in a Drive1541, which then runs alongside the C64 on its own
163
- # clock and talks to it over the serial bus. The serial traps stop
164
- # answering the drive's device number, so the KERNAL's TALK, LISTEN and
165
- # byte transfers reach the drive. The LOAD and SAVE traps stay, and
166
- # still serve a mounted image.
167
- def attach_drive1541(drive)
168
- @iec_bus.detach(@drive1541) if @drive1541
169
- drive.host_clock_hz = region.clock_hz
170
- @drive1541 = drive
171
- drive.connect(iec_bus)
172
- @serial_trap&.device = serial_trap_device
173
- end
174
-
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
202
+ # The one-shot has pulsed already, so letting go does nothing.
203
+ def release_restore; end
179
204
 
180
205
  def capture_output
181
- @capture_output ||= ChroutTrap.new(cpu:, bus: address_bus).tap do |trap|
206
+ @capture_output ||= ChroutTrap.new(cpu:, bus: address_bus, layout: KernalTrap::C64_LAYOUT).tap do |trap|
182
207
  cpu.install_trap(ChroutTrap::ADDRESS) { trap.call }
183
208
  end
184
209
  end
@@ -197,12 +222,6 @@ module Badline
197
222
 
198
223
  private
199
224
 
200
- # The device number the serial traps answer: device 8, unless a true
201
- # drive is on the bus as device 8.
202
- def serial_trap_device
203
- @drive1541&.device == KernalTrap::Routine::DEVICE ? nil : KernalTrap::Routine::DEVICE
204
- end
205
-
206
225
  # The NMI line is wired-OR between CIA 2, the cartridge and the RESTORE
207
226
  # key, and the CPU takes an interrupt on its falling edge.
208
227
  def drive_nmi
@@ -258,9 +277,5 @@ module Badline
258
277
  def handle_init
259
278
  @init_handlers.each(&:call)
260
279
  end
261
-
262
- def init_threshold
263
- INIT_THRESHOLD
264
- end
265
280
  end
266
281
  end
@@ -16,13 +16,19 @@ module Badline
16
16
  # resistances; close neither and the lines float high. Reading a pot asks the
17
17
  # CIA for the current select lines, since a program sets them and then goes
18
18
  # straight to SID without touching the ports again.
19
+ #
20
+ # A matrix may have rows beyond port A's eight. Their select lines come from
21
+ # outside the CIA and are pushed into +extra_rows+, bit 0 for the ninth row
22
+ # upwards, active low like port A; idle, every line is high and none of
23
+ # those rows is selected. They settle with port A's lines as rows 8 and up,
24
+ # so their keys reach port B, but port A reads only its own eight.
19
25
  class ControlPorts
20
26
  PORT1_POTS = 0x40
21
27
  PORT2_POTS = 0x80
22
28
  FIRE = 0x10
23
29
 
24
30
  attr_reader :keyboard, :joystick1, :joystick2
25
- attr_accessor :device1, :device2, :port_a_source
31
+ attr_accessor :device1, :device2, :port_a_source, :extra_rows
26
32
 
27
33
  def initialize(keyboard:, joystick1:, joystick2:)
28
34
  @keyboard = keyboard
@@ -31,9 +37,10 @@ module Badline
31
37
  @device1 = nil
32
38
  @device2 = nil
33
39
  @port_a_source = nil
40
+ @extra_rows = 0xff
34
41
  end
35
42
 
36
- def read_a(port_a, port_b) = scan(port_a, port_b).first
43
+ def read_a(port_a, port_b) = scan(port_a, port_b).first & 0xff
37
44
 
38
45
  def read_b(port_a, port_b) = scan(port_a, port_b).last
39
46
 
@@ -58,7 +65,7 @@ module Badline
58
65
  end
59
66
 
60
67
  def scan(port_a, port_b)
61
- keyboard.scan(port_a & port_bits(@joystick2, @device2),
68
+ keyboard.scan((port_a & port_bits(@joystick2, @device2)) | (@extra_rows << 8),
62
69
  port_b & port_bits(@joystick1, @device1))
63
70
  end
64
71
 
data/lib/badline/core.rb CHANGED
@@ -8,6 +8,7 @@
8
8
  require "badline/version"
9
9
  require "badline/integer_helper"
10
10
  require "badline/region"
11
+ require "badline/timing"
11
12
  require "badline/model"
12
13
  require "badline/addressable"
13
14
  require "badline/memory"
@@ -40,9 +41,11 @@ require "badline/drive1541"
40
41
  require "badline/snapshot/state"
41
42
  require "badline/snapshot/setup"
42
43
  require "badline/computer"
44
+ require "badline/vic20"
43
45
  require "badline/checkpoint"
44
46
  require "badline/storage"
45
47
  require "badline/cartridge"
46
48
  require "badline/kernal_trap"
47
49
  require "badline/chrout_trap"
48
50
  require "badline/media"
51
+ require "badline/machine"
@@ -3,7 +3,8 @@
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
8
9
  # What inserting a tape raises on a machine without a cassette port.
9
10
  class Missing < ArgumentError; end
@@ -15,6 +16,9 @@ module Badline
15
16
  @tape = tape
16
17
  @connected = true
17
18
  @playing = false
19
+ @recording = false
20
+ @write_high = true
21
+ @since = 0
18
22
  @motor = false
19
23
  @countdown = 0
20
24
  @flag_handler = nil
@@ -60,6 +64,30 @@ module Badline
60
64
  def play! = press(true)
61
65
  def stop! = press(false)
62
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
+
63
91
  def playing? = @playing
64
92
 
65
93
  # Sense reads low while a key is down on the deck.
@@ -98,6 +126,7 @@ module Badline
98
126
 
99
127
  def cycle!
100
128
  return unless running?
129
+ return @since += 1 if @recording
101
130
 
102
131
  @countdown = @tape.next_pulse.to_i if @countdown.zero?
103
132
  return if @countdown.zero?
@@ -116,8 +145,17 @@ module Badline
116
145
  def press(down)
117
146
  return if down == @playing || !@connected
118
147
 
148
+ finish_recording unless down
119
149
  @playing = down
120
150
  @sense_handler&.call
121
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
122
160
  end
123
161
  end
@@ -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, computer.region.clock_hz, 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, @snapshots)
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
@@ -70,36 +72,20 @@ module Badline
70
72
  @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
71
73
  @pacer.check(Pacer::EARLY_CHECK, stamps.last - @started, stamps.last) if @frames == Pacer::EARLY_CHECK
72
74
  report(stamps.last) if (@frames % 50).zero?
73
- end
74
-
75
- def open_window
76
- abort "SDL_Init: #{SDL.SDL_GetError}" unless SDL.SDL_Init(SDL::INIT_VIDEO | SDL::INIT_EVENTS).zero?
77
-
78
- SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
79
- SDL.SDL_SetHint("SDL_MOUSE_RELATIVE_SCALING", "0")
80
- @window = SDL.SDL_CreateWindow(
81
- TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
82
- Screen::WIDTH * SCALE, Screen::HEIGHT * SCALE, SDL::WINDOW_RESIZABLE
83
- )
84
- flags = SDL::RENDERER_ACCELERATED
85
- flags |= SDL::RENDERER_PRESENTVSYNC if @pacer.vsync?
86
- @renderer = SDL.SDL_CreateRenderer(@window, -1, flags)
87
- fit_display if @pacer.vsync?
88
- SDL.SDL_RenderSetLogicalSize(@renderer, Screen::WIDTH, Screen::HEIGHT)
89
- create_texture
90
- end
91
-
92
- def create_texture
93
- @texture = SDL.SDL_CreateTexture(
94
- @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
95
- )
96
- end
97
-
98
- def close_window
99
- SDL.SDL_DestroyTexture(@texture)
100
- SDL.SDL_DestroyRenderer(@renderer)
101
- SDL.SDL_DestroyWindow(@window)
102
- 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
103
89
  end
104
90
 
105
91
  def handle_events
@@ -137,11 +123,14 @@ module Badline
137
123
  # it.
138
124
  def swap(computer)
139
125
  @computer = computer
140
- @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)
141
130
  @led = DriveLed.for(computer)
142
131
  @controls.computer = computer
143
132
  @gamepads.computer = computer
144
- @sound.switch(computer.sid, computer.region.clock_hz)
133
+ @sound.switch(computer.sound_source, timing.clock_hz)
145
134
  @snapshots.computer = computer
146
135
  end
147
136
 
@@ -162,6 +151,7 @@ module Badline
162
151
  # pressing the cartridge's freeze button if asked.
163
152
  def resume(freeze)
164
153
  @menu.close
154
+ @scripted = false
165
155
  SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
166
156
  @timeline.press_freeze(@computer, @frames) if freeze
167
157
  @reported = now
@@ -170,50 +160,58 @@ module Badline
170
160
  end
171
161
 
172
162
  # Runs a frame of the menu, which has the keys and the mouse while the
173
- # machine stands still.
163
+ # machine stands still. A menu the timeline opened counts its frames
164
+ # and takes their screenshots.
174
165
  def paused_frame
175
- action = @menu.frame(@renderer, @texture, @controls)
166
+ scripted = @scripted
167
+ action = @menu.frame(@window.renderer, @window.texture, @controls)
168
+ present_menu(scripted)
176
169
  if action == :quit
177
170
  @running = false
178
171
  elsif !action.nil?
179
172
  swap(@menu.computer) if action == :swap
180
173
  resume(action == :freeze)
181
174
  end
175
+ finish_paused_frame if scripted && @running
182
176
  end
183
177
 
184
- def update_title
185
- title = TITLE
186
- title += " [#{@controls.tag}]" unless @controls.tag.empty?
187
- title += " [MUTED]" if @sound.muted?
188
- 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
189
190
  end
190
191
 
191
- def fit_display
192
- SDL.SDL_GetWindowDisplayMode(@window, SDL.display_mode)
193
- @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)
194
197
  end
195
198
 
196
199
  def emulate
197
- computer = @computer
198
- cycles = @pacer.cycles(@sound)
199
- i = 0
200
- while i < cycles
201
- computer.cycle!
202
- i += 1
203
- end
200
+ @computer.run_cycles(@pacer.cycles(@sound))
204
201
  end
205
202
 
206
203
  def upload
207
204
  @screen.update
208
- SDL.SDL_UpdateTexture(@texture, nil, @screen.pixels, Screen::ROW_BYTES)
205
+ @window.upload(@screen)
209
206
  end
210
207
 
211
208
  def draw
212
- SDL.SDL_RenderClear(@renderer)
213
- SDL.SDL_RenderCopy(@renderer, @texture, nil, nil)
214
- @led&.draw(@renderer)
215
- @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
216
- 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)
217
215
  end
218
216
 
219
217
  def report(at)