badline 0.4.2 → 0.5.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/CLAUDE.md +10 -5
  4. data/README.md +54 -23
  5. data/doc/pinned-behaviour.md +221 -0
  6. data/exe/badline-ruby +7 -4
  7. data/lib/badline/address_bus/ultimax_pages.rb +38 -0
  8. data/lib/badline/address_bus.rb +19 -29
  9. data/lib/badline/audio/jukebox.rb +6 -4
  10. data/lib/badline/audio/machine_player.rb +10 -6
  11. data/lib/badline/cartridge/kcs_power.rb +3 -3
  12. data/lib/badline/cia.rb +5 -1
  13. data/lib/badline/computer.rb +86 -7
  14. data/lib/badline/cpu.rb +39 -0
  15. data/lib/badline/drive1541/bus.rb +134 -0
  16. data/lib/badline/drive1541/disk.rb +256 -0
  17. data/lib/badline/drive1541/disk_via.rb +33 -0
  18. data/lib/badline/drive1541/gcr.rb +57 -0
  19. data/lib/badline/drive1541/idle.rb +184 -0
  20. data/lib/badline/drive1541/mechanism.rb +277 -0
  21. data/lib/badline/drive1541/rotation.rb +111 -0
  22. data/lib/badline/drive1541/sector_reader.rb +76 -0
  23. data/lib/badline/drive1541/serial_port.rb +67 -0
  24. data/lib/badline/drive1541/track.rb +98 -0
  25. data/lib/badline/drive1541.rb +218 -0
  26. data/lib/badline/gui/application.rb +15 -3
  27. data/lib/badline/gui/drive_led_pane.rb +29 -0
  28. data/lib/badline/gui.rb +1 -0
  29. data/lib/badline/iec_bus.rb +93 -0
  30. data/lib/badline/kernal_trap/channel.rb +9 -1
  31. data/lib/badline/kernal_trap/serial.rb +9 -3
  32. data/lib/badline/media/true_drive.rb +54 -0
  33. data/lib/badline/media.rb +22 -1
  34. data/lib/badline/options.rb +24 -4
  35. data/lib/badline/region.rb +35 -10
  36. data/lib/badline/reu/dma.rb +228 -0
  37. data/lib/badline/reu/ram.rb +65 -0
  38. data/lib/badline/reu/trigger.rb +32 -0
  39. data/lib/badline/reu.rb +276 -0
  40. data/lib/badline/roms/README +10 -0
  41. data/lib/badline/roms/dos1541.rom +0 -0
  42. data/lib/badline/sdl/functions.rb +1 -0
  43. data/lib/badline/storage/d64_image.rb +18 -9
  44. data/lib/badline/storage/d81_image.rb +1 -0
  45. data/lib/badline/storage/disk_image/catalog.rb +52 -0
  46. data/lib/badline/storage/disk_image/writing.rb +16 -0
  47. data/lib/badline/storage/disk_image.rb +2 -0
  48. data/lib/badline/storage/g64_image.rb +222 -0
  49. data/lib/badline/storage/host_directory.rb +21 -1
  50. data/lib/badline/storage/listing.rb +118 -0
  51. data/lib/badline/storage/t64.rb +14 -0
  52. data/lib/badline/storage.rb +2 -0
  53. data/lib/badline/traps.rb +4 -0
  54. data/lib/badline/version.rb +1 -1
  55. data/lib/badline/via/control_lines.rb +3 -0
  56. data/lib/badline/via/fast_forward.rb +38 -0
  57. data/lib/badline/via/shift_register.rb +8 -3
  58. data/lib/badline/via/timer1.rb +30 -0
  59. data/lib/badline/via/timer2.rb +25 -0
  60. data/lib/badline/via.rb +2 -0
  61. data/lib/badline/vic/display_state.rb +7 -7
  62. data/lib/badline/vic/sprite/internal_bus.rb +11 -7
  63. data/lib/badline/vic/sprite/shifter.rb +10 -15
  64. data/lib/badline/vic/sprite/timing.rb +60 -0
  65. data/lib/badline/vic/sprite.rb +14 -12
  66. data/lib/badline/vic/sprites.rb +2 -2
  67. data/lib/badline/vic.rb +86 -39
  68. data/lib/badline.rb +3 -0
  69. metadata +26 -1
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/drive1541/sector_reader"
4
+
5
+ module Badline
6
+ class Drive1541
7
+ # A disk as the head sees it: a Track for each half track that holds
8
+ # data, numbered from 2 (track 1) to MAX_HALF_TRACK (track 42), and
9
+ # nothing between them. A .d64 image fills the whole tracks; a .g64
10
+ # can fill any of them, with tracks of any length.
11
+ #
12
+ # The head writes into the tracks' bytes, and a write to a half track
13
+ # without data gives it a blank Track first. A disk made from an image
14
+ # remembers it: flush reads the sectors back off the tracks written
15
+ # since the last flush and stores them in the image, which writes its
16
+ # host file. The disk is write-protected when the image won't take
17
+ # writes.
18
+ class Disk
19
+ MAX_HALF_TRACK = 84
20
+
21
+ # GCR bytes around a track in each speed zone: 200 ms of rotation at
22
+ # 300 rpm, in bytes of 32, 30, 28 and 26 µs.
23
+ TRACK_LENGTHS = [6250, 6666, 7142, 7692].freeze
24
+
25
+ # The speed zone each track is written in: 3 for the 21 sectors of
26
+ # tracks 1-17, 2 for 18-24, 1 for 25-30 and 0 from 31 on.
27
+ def self.zone(track)
28
+ if track <= 17 then 3
29
+ elsif track <= 24 then 2
30
+ elsif track <= 30 then 1
31
+ else 0
32
+ end
33
+ end
34
+
35
+ # How far round the DOS's N: starts each track from the one before,
36
+ # in each zone, in 1/10000 of a turn: it steps in and starts writing
37
+ # the next track that long after it started the last.
38
+ SKEWS = [2916, 897, 8915, 6869].freeze
39
+ SKEW_TURN = 10_000
40
+
41
+ SYNC_LENGTH = 5
42
+ HEADER_GAP = 9
43
+ GAP = 0x55
44
+ HEADER_ID = 0x08
45
+ DATA_ID = 0x07
46
+
47
+ # A sector: SYNC, the 10-byte GCR header, the header gap, SYNC and
48
+ # the 325-byte GCR data block. The gap after it takes up the rest.
49
+ SECTOR_LENGTH = SYNC_LENGTH + 10 + HEADER_GAP + SYNC_LENGTH + 325
50
+
51
+ # A disk from the image at +path+: a .g64 as its tracks are, and
52
+ # anything else as a .d64, formatted. `read_only` opens the image
53
+ # write-protected.
54
+ def self.open(path, read_only: false)
55
+ return from_g64(Storage::G64Image.new(path, read_only:)) if File.extname(path).casecmp?(".g64")
56
+
57
+ from_d64(Storage::D64Image.new(path, read_only:))
58
+ end
59
+
60
+ # A disk from a G64 image, each half track as the image stores it,
61
+ # with its speed map. Flushing it writes the tracks back as they are.
62
+ def self.from_g64(image)
63
+ disk = new(image)
64
+ (Mechanism::MIN_HALF_TRACK..MAX_HALF_TRACK).each do |half_track|
65
+ entry = half_track - Mechanism::MIN_HALF_TRACK
66
+ track = image.track(entry)
67
+ disk.write(half_track, Track.new(*track, image.speeds(entry))) if track
68
+ end
69
+ disk
70
+ end
71
+
72
+ # A disk formatted from a D64 image, each sector laid out as the DOS
73
+ # formats it, with the disk ID from the header block. A block the
74
+ # image's error table marks 20, 21, 22, 23, 27 or 29 is written the
75
+ # way that DOS error would read. The table's other codes, 24, 25, 26
76
+ # and 28, describe write or decoding faults the layout can't carry,
77
+ # so their blocks are written good and read without an error.
78
+ #
79
+ # Each track starts sector 0 at the angle the DOS's N: leaves it: track
80
+ # 1 at the index angle, and each track after it SKEWS[zone] of a turn
81
+ # round from the last.
82
+ def self.from_d64(image)
83
+ track, sector = image.header_block
84
+ header = image.read_block(track, sector)
85
+ id = [header[0xa2], header[0xa3]]
86
+ disk = new(image)
87
+ angle = 0
88
+ (1..image.track_count).each do |track|
89
+ angle = (angle + SKEWS[zone(track)]) % SKEW_TURN if track > 1
90
+ bytes = format_track(image, track, id)
91
+ disk.write(track * 2, Track.new(bytes.rotate(-(bytes.length * angle / SKEW_TURN)), zone(track)))
92
+ end
93
+ disk
94
+ end
95
+
96
+ def self.format_track(image, track, id)
97
+ sectors = image.sectors_in(track)
98
+ length = TRACK_LENGTHS[zone(track)]
99
+ gap = (length - (sectors * SECTOR_LENGTH)) / sectors
100
+ bytes = []
101
+ sectors.times do |sector|
102
+ format_sector(bytes, image, track, sector, id)
103
+ gap.times { bytes << GAP }
104
+ end
105
+ bytes << GAP while bytes.length < length
106
+ bytes
107
+ end
108
+
109
+ # Error 21 leaves the block without SYNC marks, and 29 writes its
110
+ # header with the wrong first ID byte.
111
+ def self.format_sector(bytes, image, track, sector, id)
112
+ error = image.block_error(track, sector)
113
+ sync = error == 21 ? GAP : 0xff
114
+ id = [id[0] ^ 0xff, id[1]] if error == 29
115
+ SYNC_LENGTH.times { bytes << sync }
116
+ bytes.concat(GCR.encode(header(track, sector, id, error)))
117
+ HEADER_GAP.times { bytes << GAP }
118
+ SYNC_LENGTH.times { bytes << sync }
119
+ bytes.concat(GCR.encode(data_block(image.read_block(track, sector), error)))
120
+ end
121
+
122
+ # ID, checksum, sector, track, the second ID byte, the first and two
123
+ # $0F fillers. Error 20 hides the block's ID and 27 spoils its
124
+ # checksum.
125
+ def self.header(track, sector, id, error)
126
+ checksum = sector ^ track ^ id[1] ^ id[0]
127
+ checksum ^= 0xff if error == 27
128
+ [error == 20 ? 0x00 : HEADER_ID, checksum, sector, track, id[1], id[0], 0x0f, 0x0f]
129
+ end
130
+
131
+ # ID, 256 data bytes, their checksum and two $00 fillers. Error 22
132
+ # hides the block's ID and 23 spoils its checksum.
133
+ def self.data_block(data, error)
134
+ checksum = 0
135
+ data.each { |byte| checksum ^= byte }
136
+ checksum ^= 0xff if error == 23
137
+ block = [error == 22 ? 0x00 : DATA_ID]
138
+ block.concat(data)
139
+ block << checksum << 0x00 << 0x00
140
+ end
141
+
142
+ attr_reader :image
143
+
144
+ def initialize(image = nil)
145
+ @image = image
146
+ @tracks = Array.new(MAX_HALF_TRACK + 1)
147
+ @written = {}
148
+ end
149
+
150
+ # The Track at the half track, or nil where there's no data.
151
+ def track(half_track) = @tracks[half_track]
152
+
153
+ def write(half_track, track)
154
+ @tracks[half_track] = track
155
+ end
156
+
157
+ # Whether the write-protect notch is covered: the disk's image won't
158
+ # take writes. A disk without an image takes them, and keeps them
159
+ # only in its tracks.
160
+ def write_protected? = !@image.nil? && !@image.writable?
161
+
162
+ # A half track the head writes to, blank to begin with: no flux, and
163
+ # as many bytes as a turn holds at the bit rate of the +zone+ it's
164
+ # written in.
165
+ def writable_track(half_track, zone)
166
+ @tracks[half_track] ||= Track.new(Array.new(TRACK_LENGTHS[zone], 0), zone)
167
+ end
168
+
169
+ # The head wrote to the half track.
170
+ def written(half_track)
171
+ @written[half_track] = true
172
+ end
173
+
174
+ # Whether any track was written since the last flush.
175
+ def written? = !@written.empty?
176
+
177
+ # Stores each track written since the last flush in the image, all
178
+ # in one write to its host file. A G64 image takes the tracks as they
179
+ # are. A D64 image takes the sectors read back off them, and keeps
180
+ # whole tracks only, so a half track, or a track past its last, stays
181
+ # on the disk alone. A sector that no longer reads back keeps its old
182
+ # data, and the flush warns once, naming the tracks that lost one.
183
+ def flush
184
+ return flush_tracks if @image.respond_to?(:store_tracks)
185
+
186
+ tracks = @written.keys.select { |half| half.even? && (1..@image&.track_count.to_i).cover?(half / 2) }
187
+ @written.clear
188
+ return if tracks.empty?
189
+
190
+ sectors = tracks.to_h { |half| [half / 2, SectorReader.read(@tracks[half].bytes, half / 2)] }
191
+ id = disk_id(sectors)
192
+ warn_lost(sectors)
193
+ @image.store_blocks(sectors.flat_map do |track, found|
194
+ Array.new(@image.sectors_in(track)) { |sector| stored_block(track, sector, found[sector], id) }
195
+ end)
196
+ end
197
+
198
+ private
199
+
200
+ def flush_tracks
201
+ tracks = @written.keys.sort.to_h do |half|
202
+ [half - Mechanism::MIN_HALF_TRACK, [@tracks[half].bytes, @tracks[half].zone]]
203
+ end
204
+ @written.clear
205
+ @image.store_tracks(tracks) unless tracks.empty?
206
+ end
207
+
208
+ def warn_lost(sectors)
209
+ lost = sectors.keys.select do |track|
210
+ Array.new(@image.sectors_in(track)) { |sector| lost?(track, sector, sectors[track][sector]) }.any?
211
+ end
212
+ return if lost.empty?
213
+
214
+ warn "1541: tracks written that no longer read back whole: #{lost.join(', ')}. The .d64 keeps the old " \
215
+ "data of the sectors lost, and a .g64 image keeps tracks as the drive writes them."
216
+ end
217
+
218
+ # A sector the head no longer finds, which the image doesn't already
219
+ # hold as unreadable.
220
+ def lost?(track, sector, found)
221
+ found.nil? && ![20, 21].include?(@image.block_error(track, sector))
222
+ end
223
+
224
+ # The disk ID the headers must carry, from the header block's $A2 and
225
+ # $A3 as Disk.from_d64 takes it: as just read back, or as the image
226
+ # holds it.
227
+ def disk_id(sectors)
228
+ track, sector = @image.header_block
229
+ header = sectors.dig(track, sector)&.data
230
+ data = header ? header[1, 256] : @image.read_block(track, sector)
231
+ [data[0xa2], data[0xa3]]
232
+ end
233
+
234
+ # [track, sector, data, error] for DiskImage#store_blocks: the data as
235
+ # read, nil where it can't be, and the DOS error a read of it would
236
+ # raise, the way Disk.from_d64 writes each one. A sector whose header
237
+ # isn't found keeps its data, and reads as error 20, or 21 where the
238
+ # image already has it so.
239
+ def stored_block(track, sector, found, id)
240
+ return [track, sector, nil, @image.block_error(track, sector) == 21 ? 21 : 20] unless found
241
+
242
+ header = found.header
243
+ data = found.data
244
+ [track, sector, data && data[1, 256], block_error(header, data, id)]
245
+ end
246
+
247
+ def block_error(header, data, id)
248
+ if header[1] != header[2] ^ header[3] ^ header[4] ^ header[5] then 27
249
+ elsif header[5] != id[0] || header[4] != id[1] then 29
250
+ elsif data.nil? || data[0] != DATA_ID then 22
251
+ elsif data[257] != data[1, 256].reduce(:^) then 23
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # VIA 2, the one facing the disk mechanism. Its port B drives the
6
+ # Mechanism, and CB2 selects its read or write mode. The mechanism
7
+ # hears both after every write and reset, since ORB, DDRB, the ACR and
8
+ # the PCR can all change them.
9
+ class DiskVIA < VIA
10
+ def initialize(start:, mechanism:)
11
+ @mechanism = mechanism
12
+ super(start:, peripheral: mechanism)
13
+ end
14
+
15
+ def reset!
16
+ super
17
+ tell_mechanism
18
+ end
19
+
20
+ def poke(addr, value)
21
+ super
22
+ tell_mechanism
23
+ end
24
+
25
+ private
26
+
27
+ def tell_mechanism
28
+ @mechanism.port_b_written(port_b_output)
29
+ @mechanism.cb2_written(cb2_output)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # Group code recording, the 1541's on-disk code. Each nibble becomes
6
+ # five bits, chosen so that no code has more than two 0 bits in a row
7
+ # and no run of codes has ten 1 bits in a row, which only a SYNC mark
8
+ # has. Four bytes become five.
9
+ module GCR
10
+ CODES = [0x0a, 0x0b, 0x12, 0x13, 0x0e, 0x0f, 0x16, 0x17,
11
+ 0x09, 0x19, 0x1a, 0x1b, 0x0d, 0x1d, 0x1e, 0x15].freeze
12
+
13
+ # Five-bit code => nibble, nil for the codes that aren't GCR.
14
+ NIBBLES = Array.new(32) { |code| CODES.index(code) }.freeze
15
+
16
+ module_function
17
+
18
+ # Encodes a whole number of four-byte groups.
19
+ def encode(bytes)
20
+ out = []
21
+ i = 0
22
+ while i < bytes.length
23
+ bits = 0
24
+ 4.times do |n|
25
+ byte = bytes[i + n]
26
+ bits = (bits << 10) | (CODES[byte >> 4] << 5) | CODES[byte & 0x0f]
27
+ end
28
+ 4.downto(0) { |n| out << ((bits >> (n * 8)) & 0xff) }
29
+ i += 4
30
+ end
31
+ out
32
+ end
33
+
34
+ # Decodes a whole number of five-byte groups, nil when any code
35
+ # isn't GCR.
36
+ def decode(bytes)
37
+ out = []
38
+ i = 0
39
+ while i < bytes.length
40
+ bits = 0
41
+ 5.times { |n| bits = (bits << 8) | bytes[i + n] }
42
+ n = 7
43
+ while n.positive?
44
+ high = NIBBLES[(bits >> (n * 5)) & 0x1f]
45
+ low = NIBBLES[(bits >> ((n - 1) * 5)) & 0x1f]
46
+ return nil if high.nil? || low.nil?
47
+
48
+ out << ((high << 4) | low)
49
+ n -= 2
50
+ end
51
+ i += 5
52
+ end
53
+ out
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # Skipping the drive's cycles while the DOS idles, exactly.
6
+ #
7
+ # With nothing to do, the DOS goes round its idle loop from $EBFF,
8
+ # 446 cycles a pass, until VIA 2's timer 1 interrupts it every 14,850
9
+ # cycles for the job loop, or ATN interrupts it through VIA 1's CA1. A
10
+ # pass writes RAM and VIA 2's port B, but puts back what it found, so
11
+ # the drive comes round to $EBFF as it left it. Only the timers'
12
+ # counters and the cycle counts move.
13
+ #
14
+ # Each time the CPU comes to $EBFF, the drive records the pass that
15
+ # follows. At the next $EBFF it checks that the pass
16
+ #
17
+ # - left the CPU, both VIAs but for their counters, the bus's last
18
+ # byte, the SO pin, the mechanism (Mechanism#idle_state) and what
19
+ # the ports read from it, and every byte of RAM it read or wrote as
20
+ # it found them (Bus#watch!)
21
+ # - read nothing that changes from pass to pass: no counter, no shift
22
+ # register, and not VIA 1's port B, which reads the serial bus
23
+ # - wrote nothing that reaches outside the drive: not VIA 1, whose port
24
+ # B drives the serial bus, and not the motor on
25
+ # - ran while no counter could set a flag (VIA#quiet_cycles), with the
26
+ # motor off, no interrupt pulled and no trap on the CPU.
27
+ #
28
+ # Such a pass runs the same way again from where it ended, for as
29
+ # long as ATN holds still and no counter sets a flag, so the drive
30
+ # sleeps: host_cycle! only counts the host cycles that go by. It wakes
31
+ # when ATN moves, when a counter is due to set a flag, or when something
32
+ # outside reads or changes the drive (the readers in Drive1541 call
33
+ # settle!). Waking counts off the whole passes it owes in bulk,
34
+ # moving the counters on, and runs the rest cycle by cycle.
35
+ #
36
+ # The loop reads neither CLK nor DATA, so the drive sleeps through them
37
+ # moving and takes the C64's lines as they stand once it wakes. Its
38
+ # own lines hold still while it sleeps, so the C64 reads them without
39
+ # waking it (serial_output).
40
+ #
41
+ # A pass starts at $EBFF, where the CPU always comes from the loop's
42
+ # last instruction, a JMP, so what that leaves in the CPU's working
43
+ # registers is the same each time round.
44
+ module Idle
45
+ # Where the DOS 2.6 idle loop starts over.
46
+ IDLE_LOOP = 0xebff
47
+
48
+ # Whether the drive may sleep through its idle loop. On by default,
49
+ # but for a drive whose CPU logs each instruction.
50
+ attr_reader :idle_skip
51
+
52
+ def idle_skip=(on)
53
+ settle!
54
+ @idle_skip = on
55
+ end
56
+
57
+ # Whether the drive is skipping its idle loop right now.
58
+ def asleep? = @asleep
59
+
60
+ # Brings the drive up to the present, cycle for cycle, and drops the
61
+ # pass it was recording, since whoever called may change it. Every
62
+ # reader of the drive's parts calls this.
63
+ def settle!
64
+ if @asleep
65
+ wake!
66
+ @serial_port.latch_host
67
+ end
68
+ stop_recording if @recording
69
+ end
70
+
71
+ private
72
+
73
+ # A host cycle asleep.
74
+ def doze
75
+ @slept += 1
76
+ settle! if @slept == @wake_at || @serial_port.atn_moved?
77
+ end
78
+
79
+ def init_idle(debug)
80
+ @idle_skip = !debug
81
+ @asleep = false
82
+ @recording = false
83
+ @owed = 0
84
+ @budget = 0
85
+ @slept = 0
86
+ @wake_at = 0
87
+ @pass_cycles = 0
88
+ @pass_instructions = 0
89
+ end
90
+
91
+ # The CPU is at $EBFF. At an instruction boundary, the drive sleeps if
92
+ # the pass it recorded can repeat, and otherwise records the next.
93
+ def idle_loop_reached
94
+ return unless @cpu.boundary?
95
+
96
+ if @recording
97
+ stop_recording
98
+ return fall_asleep if repeatable_pass? && room_for_pass?
99
+ end
100
+ start_recording
101
+ end
102
+
103
+ def start_recording
104
+ return if @mechanism.motor_on? || @via1.irq? || @via2.irq? || @cpu.trapped?
105
+
106
+ @recording = true
107
+ @record_state = idle_state
108
+ @record_cycles = @cycles
109
+ @record_instructions = @cpu.instructions
110
+ @record_quiet = quiet_cycles
111
+ @bus.watch!
112
+ end
113
+
114
+ def stop_recording
115
+ @recording = false
116
+ @bus.unwatch!
117
+ end
118
+
119
+ # Whether the pass just recorded can repeat.
120
+ def repeatable_pass?
121
+ !@bus.volatile && @cycles - @record_cycles <= @record_quiet && idle_state == @record_state &&
122
+ ram_as_found?
123
+ end
124
+
125
+ # Whether the counters leave room for a whole pass.
126
+ def room_for_pass? = quiet_cycles >= @cycles - @record_cycles
127
+
128
+ # Sleeps through the pass just recorded, over and over.
129
+ def fall_asleep
130
+ @pass_cycles = @cycles - @record_cycles
131
+ @pass_instructions = @cpu.instructions - @record_instructions
132
+ @asleep = true
133
+ @owed = 0
134
+ @budget = quiet_cycles
135
+ end
136
+
137
+ # Asleep, host_cycle! counts host cycles, and leaves it to wake! to
138
+ # turn them into drive cycles through the phase accumulator. This sets
139
+ # the host cycle that brings the drive cycles owed up to the budget.
140
+ def plan_wake
141
+ @slept = 0
142
+ needed = ((@budget - @owed) * @host_clock_hz) - @phase
143
+ @wake_at = needed.positive? ? (needed + CLOCK_HZ - 1) / CLOCK_HZ : 1
144
+ end
145
+
146
+ # Runs the cycles owed: whole passes in bulk, up to the budget, and
147
+ # the rest one by one.
148
+ def wake!
149
+ @asleep = false
150
+ phase = @phase + (@slept * CLOCK_HZ)
151
+ @phase = phase % @host_clock_hz
152
+ owed = @owed + (phase / @host_clock_hz)
153
+ @owed = 0
154
+ @slept = 0
155
+ span = [owed, @budget].min
156
+ passes = span / @pass_cycles
157
+ if passes.positive?
158
+ cycles = passes * @pass_cycles
159
+ @cpu.fast_forward(cycles, passes * @pass_instructions)
160
+ @via1.fast_forward(cycles)
161
+ @via2.fast_forward(cycles)
162
+ @cycles += cycles
163
+ owed -= cycles
164
+ end
165
+ owed.times { step }
166
+ end
167
+
168
+ def quiet_cycles
169
+ [@via1.quiet_cycles, @via2.quiet_cycles].min
170
+ end
171
+
172
+ def ram_as_found?
173
+ ram = @bus.ram
174
+ @bus.touched.all? { |addr, value| ram.peek(addr) == value }
175
+ end
176
+
177
+ def idle_state
178
+ mechanism = @mechanism
179
+ [*@cpu.idle_state, *@via1.idle_state, *@via2.idle_state, @bus.data, @so_pending,
180
+ *mechanism.idle_state, mechanism.read_a(0xff), mechanism.read_b(0xff)]
181
+ end
182
+ end
183
+ end
184
+ end