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
@@ -7,10 +7,28 @@ module Badline
7
7
  # (174848 bytes without one), 40 tracks (196608) and 42 tracks (205312)
8
8
  ERROR_TABLES = { 175_531 => 683, 197_376 => 768, 206_114 => 802 }.freeze
9
9
 
10
+ # The tracks the image holds: 35, 40 or 42.
11
+ def track_count
12
+ blocks = @bytes.length / SECTOR_SIZE
13
+ count = 0
14
+ count += 1 while count < 42 && track_offset(count + 2) <= blocks
15
+ count
16
+ end
17
+
18
+ def sectors_in(track)
19
+ case track
20
+ when 1..17 then 21
21
+ when 18..24 then 19
22
+ when 25..30 then 18
23
+ else 17
24
+ end
25
+ end
26
+
10
27
  private
11
28
 
12
29
  def error_tables = ERROR_TABLES
13
30
 
31
+ def header_name = 0x90
14
32
  def directory_track = 18
15
33
  def directory_sector = 1
16
34
  def directory_interleave = 3
@@ -23,15 +41,6 @@ module Badline
23
41
  def bam_tracks = 1..35
24
42
  def bam_count(track) = [18, 0, 4 * track]
25
43
  def bam_bitmap(track) = [18, 0, (4 * track) + 1]
26
-
27
- def sectors_in(track)
28
- case track
29
- when 1..17 then 21
30
- when 18..24 then 19
31
- when 25..30 then 18
32
- else 17
33
- end
34
- end
35
44
  end
36
45
  end
37
46
  end
@@ -9,6 +9,7 @@ module Badline
9
9
 
10
10
  def error_tables = ERROR_TABLES
11
11
 
12
+ def header_name = 0x04
12
13
  def directory_track = 40
13
14
  def directory_sector = 3
14
15
  def directory_interleave = 1
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ class DiskImage
6
+ # The directory as LOAD"$" lists it: the disk name and ID from the
7
+ # header block, every entry with a type, and the free blocks the BAM
8
+ # counts outside the directory track.
9
+ module Catalog
10
+ HEADER_ID = 18
11
+ ID_LENGTH = 5
12
+ TYPE_MASK = 0x07
13
+
14
+ def directory
15
+ header = sector_at(*header_block)
16
+ Listing.new(name: header[header_name, DiskImage::Directory::NAME_LENGTH],
17
+ id: header[header_name + HEADER_ID, ID_LENGTH],
18
+ entries: catalog_entries,
19
+ blocks_free:)
20
+ end
21
+
22
+ private
23
+
24
+ # A slot whose type byte is zero is empty or scratched.
25
+ def catalog_entries
26
+ list = []
27
+ each_sector(directory_track, directory_sector) do |data|
28
+ (0...ENTRIES_PER_SECTOR).each do |i|
29
+ entry = data[i * ENTRY_SIZE, ENTRY_SIZE]
30
+ list << catalog_entry(entry) unless entry[2].zero?
31
+ end
32
+ end
33
+ list
34
+ end
35
+
36
+ def catalog_entry(entry)
37
+ Listing::Entry.new(name: entry[5, DiskImage::Directory::NAME_LENGTH],
38
+ type: entry[2] & TYPE_MASK,
39
+ blocks: entry[30] | (entry[31] << 8),
40
+ closed: entry[2].anybits?(DiskImage::Directory::CLOSED),
41
+ locked: entry[2].anybits?(0x40))
42
+ end
43
+
44
+ def blocks_free
45
+ (bam_tracks.to_a - reserved_tracks).sum do |track|
46
+ block?(*bam_count(track).first(2)) ? @bytes[count_offset(track)] : 0
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -57,6 +57,22 @@ module Badline
57
57
  update_image { put_sector(track, sector, data) } if block?(track, sector)
58
58
  end
59
59
 
60
+ # Stores blocks as a true drive read them back off its disk, in one
61
+ # write to the host file. Each is [track, sector, data, error]: nil
62
+ # data leaves the block's bytes as they were, and the error, the
63
+ # DOS error a read of it raises or nil, goes into the error table
64
+ # when the image has one.
65
+ def store_blocks(blocks)
66
+ update_image do
67
+ blocks.each do |track, sector, data, error|
68
+ next unless block?(track, sector)
69
+
70
+ put_sector(track, sector, data) if data
71
+ @errors[track_offset(track) + sector] = error ? DOS_ERRORS.key(error) : 1 if @errors
72
+ end
73
+ end
74
+ end
75
+
60
76
  # Marks a block in use, as B-A does.
61
77
  def allocate_block(track, sector) = update_image { mark(track, sector, false) }
62
78
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/storage/disk_image/bam"
4
+ require "badline/storage/disk_image/catalog"
4
5
  require "badline/storage/disk_image/directory"
5
6
  require "badline/storage/disk_image/writing"
6
7
 
@@ -12,6 +13,7 @@ module Badline
12
13
  # written.
13
14
  class DiskImage
14
15
  include Bam
16
+ include Catalog
15
17
  include Directory
16
18
  include Writing
17
19
 
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ # A .g64 image: a 1541 disk's flux as raw GCR, track by track, for the
6
+ # true drive to read the way it reads a real disk, copy protection and
7
+ # all. It has no file system of its own, so only the true drive
8
+ # (Drive1541::Disk.from_g64) can use it.
9
+ #
10
+ # The header is the signature "GCR-1541", a version byte ($00), the
11
+ # number of half tracks the tables cover (84, tracks 1 to 42.5) and
12
+ # the longest track a block may hold, a little-endian word. Two tables
13
+ # of little-endian longs follow, one entry per half track from track
14
+ # 1: the file offset of each half track's block, 0 for one without
15
+ # data, and its speed zone. A block is the track's length in bytes, a
16
+ # word, and then its bytes.
17
+ #
18
+ # A speed entry of 0 to 3 is the zone the whole track was written in.
19
+ # A larger one is the offset of a speed map, two bits a byte, four
20
+ # track bytes to a map byte, the first byte's in the top two bits, for
21
+ # a track whose zone changes along it. The drive passes each byte
22
+ # under the head at its own zone's rate (Drive1541::Track).
23
+ #
24
+ # The drive writes a track at one zone, so a track written back takes
25
+ # that zone as its speed entry. Writing it puts it in its own block,
26
+ # where it fits, and otherwise appends a block to the file for it, as
27
+ # for a half track that had no data. A track longer than the header's longest raises
28
+ # it, so the header stays true of every block. A half track beyond
29
+ # the tables stays on the disk alone. One opened with `read_only` is a
30
+ # write-protected disk, and the host file is never written.
31
+ #
32
+ # Opening an image checks that its header and tables are whole, and
33
+ # that every block and speed map lies past the tables and inside the
34
+ # file, and raises FormatError where one doesn't.
35
+ class G64Image
36
+ class FormatError < StandardError; end
37
+
38
+ SIGNATURE = "GCR-1541"
39
+ HEADER_SIZE = 12
40
+ HALF_TRACKS = 84
41
+ MAX_TRACK_SIZE = 7928
42
+
43
+ # A new image of +tracks+, {entry => [bytes, zone]} with entry 0 for
44
+ # track 1 and 1 for track 1.5, laid out as VICE lays one out: every
45
+ # block as long as the longest track.
46
+ def self.create(path, tracks, half_tracks: HALF_TRACKS)
47
+ max = [MAX_TRACK_SIZE, *tracks.values.map { |bytes, _| bytes.length }].max
48
+ offsets = Array.new(half_tracks, 0)
49
+ zones = Array.new(half_tracks, 0)
50
+ blocks = +"".b
51
+ position = HEADER_SIZE + (half_tracks * 8)
52
+ tracks.keys.sort.each do |entry|
53
+ bytes, zones[entry] = tracks[entry]
54
+ offsets[entry] = position + blocks.bytesize
55
+ blocks << block(bytes, max)
56
+ end
57
+ header = SIGNATURE.b + [0, half_tracks, max].pack("CCv")
58
+ File.binwrite(path, header + offsets.pack("V*") + zones.pack("V*") + blocks)
59
+ new(path)
60
+ end
61
+
62
+ def self.block(bytes, size)
63
+ [bytes.length].pack("v") + bytes.pack("C*").ljust(size, "\x00".b)
64
+ end
65
+
66
+ attr_reader :half_tracks, :max_track_size
67
+
68
+ def initialize(path, read_only: false)
69
+ @path = path
70
+ @read_only = read_only
71
+ @data = File.binread(path)
72
+ parse
73
+ end
74
+
75
+ # Whether the image was opened write-protected.
76
+ def read_only? = @read_only
77
+
78
+ # Whether the host file takes writes.
79
+ def writable? = !read_only? && File.writable?(@path)
80
+
81
+ # The track at table entry +entry+ (0 for track 1) as [bytes, zone],
82
+ # or nil where it has no data. A speed-mapped track's zone is the one
83
+ # most of its bytes were written in.
84
+ def track(entry)
85
+ offset = @offsets[entry].to_i
86
+ return if offset.zero?
87
+
88
+ length = block_length(offset)
89
+ return if length.zero?
90
+
91
+ speeds = mapped_speeds(entry, length)
92
+ [@data.byteslice(offset + 2, length).bytes, speeds ? majority(speeds) : @speeds[entry]]
93
+ end
94
+
95
+ # Each byte's zone of the track at table entry +entry+, where its
96
+ # speed map changes along it, and nil otherwise.
97
+ def speeds(entry)
98
+ offset = @offsets[entry].to_i
99
+ return if offset.zero?
100
+
101
+ speeds = mapped_speeds(entry, block_length(offset))
102
+ speeds unless speeds.nil? || speeds.uniq.one?
103
+ end
104
+
105
+ # Stores tracks the drive wrote, {entry => [bytes, zone]}, in one
106
+ # write to the host file. Each takes its zone as its speed entry.
107
+ def store_tracks(tracks)
108
+ raise WriteError, WriteError::WRITE_PROTECT_ON unless writable?
109
+
110
+ data = @data.dup
111
+ tracks.each do |entry, (bytes, zone)|
112
+ next unless entry < @half_tracks
113
+
114
+ store_track(data, entry, bytes, zone)
115
+ end
116
+ File.binwrite(@path, data)
117
+ @data = data
118
+ parse
119
+ rescue SystemCallError
120
+ parse
121
+ raise WriteError, WriteError::WRITE_PROTECT_ON
122
+ end
123
+
124
+ private
125
+
126
+ def parse
127
+ raise FormatError, "Missing G64 signature" unless @data.start_with?(SIGNATURE)
128
+ raise FormatError, "G64 header is cut short" if @data.bytesize < HEADER_SIZE
129
+
130
+ version, @half_tracks, @max_track_size = @data.byteslice(8, 4).unpack("CCv")
131
+ raise FormatError, "Unsupported G64 version #{version}" unless version.zero?
132
+
133
+ tables = @data.byteslice(HEADER_SIZE, @half_tracks * 8).to_s
134
+ raise FormatError, "G64 track tables are cut short" if tables.bytesize < @half_tracks * 8
135
+
136
+ @offsets = tables.byteslice(0, @half_tracks * 4).unpack("V*")
137
+ @speeds = tables.byteslice(@half_tracks * 4, @half_tracks * 4).unpack("V*")
138
+ @half_tracks.times { |entry| check_block(entry) }
139
+ end
140
+
141
+ def tables_end = HEADER_SIZE + (@half_tracks * 8)
142
+
143
+ def block_length(offset) = @data.byteslice(offset, 2).unpack1("v")
144
+
145
+ def check_block(entry)
146
+ offset = @offsets[entry]
147
+ return if offset.zero?
148
+
149
+ problem = misplaced(offset, 2) || misplaced(offset, 2 + block_length(offset))
150
+ raise FormatError, "G64 track entry #{entry} #{problem}" if problem
151
+
152
+ check_speed_map(entry, block_length(offset))
153
+ end
154
+
155
+ def check_speed_map(entry, length)
156
+ speed = @speeds[entry]
157
+ return if speed <= 3
158
+
159
+ problem = misplaced(speed, (length + 3) / 4)
160
+ raise FormatError, "G64 speed map of track entry #{entry} #{problem}" if problem
161
+ end
162
+
163
+ # What's wrong with +size+ bytes at +offset+, or nil where they lie
164
+ # past the tables and inside the file.
165
+ def misplaced(offset, size)
166
+ if offset < tables_end then "points into the header or its tables"
167
+ elsif offset + size > @data.bytesize then "runs past the end of the image"
168
+ end
169
+ end
170
+
171
+ # Each byte's zone from the track's speed map, or nil where its speed
172
+ # entry is a zone.
173
+ def mapped_speeds(entry, length)
174
+ speed = @speeds[entry]
175
+ return if speed <= 3
176
+
177
+ map = @data.byteslice(speed, (length + 3) / 4).to_s.bytes
178
+ Array.new(length) { |index| (map[index >> 2] >> (6 - ((index & 3) * 2))) & 3 }
179
+ end
180
+
181
+ # The zone most of the bytes were written in.
182
+ def majority(speeds)
183
+ counts = [0, 0, 0, 0]
184
+ speeds.each { |zone| counts[zone] += 1 }
185
+ counts.index(counts.max)
186
+ end
187
+
188
+ def store_track(data, entry, bytes, zone)
189
+ offset = @offsets[entry]
190
+ if offset.zero? || bytes.length > capacity(offset)
191
+ offset = data.bytesize
192
+ grow(data, bytes.length)
193
+ data << self.class.block(bytes, @max_track_size)
194
+ else
195
+ data[offset, bytes.length + 2] = [bytes.length].pack("v") + bytes.pack("C*")
196
+ end
197
+ set_entry(data, entry, offset, zone)
198
+ end
199
+
200
+ # The most bytes the block at +offset+ holds without running into
201
+ # the next block or speed map, or off the end of the file.
202
+ def capacity(offset)
203
+ ends = (@offsets + @speeds.select { |speed| speed > 3 }).select { |start| start > offset }
204
+ ends.push(@data.bytesize).min - offset - 2
205
+ end
206
+
207
+ def grow(data, length)
208
+ return if length <= @max_track_size
209
+
210
+ @max_track_size = length
211
+ data[10, 2] = [length].pack("v")
212
+ end
213
+
214
+ def set_entry(data, entry, offset, zone)
215
+ @offsets[entry] = offset
216
+ @speeds[entry] = zone
217
+ data[HEADER_SIZE + (entry * 4), 4] = [offset].pack("V")
218
+ data[HEADER_SIZE + ((@half_tracks + entry) * 4), 4] = [zone].pack("V")
219
+ end
220
+ end
221
+ end
222
+ end
@@ -8,6 +8,7 @@ module Badline
8
8
  # can't be read is left out, and a failed write reports false.
9
9
  class HostDirectory
10
10
  NO_SYNC = 21
11
+ ID = "DIR".bytes.freeze
11
12
 
12
13
  def initialize(path)
13
14
  @path = path
@@ -39,8 +40,27 @@ module Badline
39
40
  false
40
41
  end
41
42
 
43
+ # The directory as LOAD"$" lists it, under its host name: every file
44
+ # as a PRG, of the blocks it would take on a disk. A host directory
45
+ # counts no free blocks.
46
+ def directory
47
+ Listing.new(name: Listing.encode(File.basename(@path)), id: ID,
48
+ entries: entries.map { |entry| listing_entry(entry) }, blocks_free: 0)
49
+ end
50
+
42
51
  private
43
52
 
53
+ def listing_entry(entry)
54
+ entry[:listed] || Listing.program(entry[:name], file_length(entry[:file]))
55
+ end
56
+
57
+ def file_length(file)
58
+ size = File.size(File.join(@path, file))
59
+ File.extname(file).casecmp?(".p00") ? size - P00::HEADER_SIZE : size
60
+ rescue SystemCallError
61
+ 0
62
+ end
63
+
44
64
  def find(name)
45
65
  pattern = Storage.matcher(name)
46
66
  entries.find { |e| pattern.match?(e[:name]) }
@@ -72,7 +92,7 @@ module Badline
72
92
 
73
93
  def t64_entries(file)
74
94
  archive = T64.new(File.join(@path, file))
75
- archive.names.map { |name| { name:, archive: } }
95
+ archive.names.zip(archive.listing_entries).map { |name, listed| { name:, archive:, listed: } }
76
96
  rescue T64::FormatError, SystemCallError
77
97
  nil
78
98
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ # A directory as a 1541 sends it for LOAD"$",8: a BASIC program loaded
6
+ # at $0401. The header line is line 0, the disk's name and ID in
7
+ # reverse video, the name quoted. Each file is a line numbered by its
8
+ # length in blocks, with the name quoted and padded to 16 characters,
9
+ # then the type, marked `*` when the file was never closed and `<` when
10
+ # it is locked. The last line numbers the free blocks. Lines are
11
+ # padded with spaces to the drive's fixed widths, 27 characters for a
12
+ # file and 25 for the others, and shifted spaces print as plain ones.
13
+ #
14
+ # Names come as the bytes on the disk, padded with shifted spaces to 16.
15
+ # A file's name ends at its first shifted space, where the closing
16
+ # quote goes, and whatever follows it in the entry shows after the
17
+ # quote, as it does on a 1541.
18
+ class Listing
19
+ LOAD_ADDRESS = 0x0401
20
+ NAME_LENGTH = 16
21
+ NAME_PADDING = 0xa0
22
+ SPACE = 0x20
23
+ QUOTE = 0x22
24
+ REVERSE_ON = 0x12
25
+ FILE_WIDTH = 27
26
+ WIDTH = 25
27
+ TYPES = %w[DEL SEQ PRG USR REL CBM].freeze
28
+ PRG = 2
29
+
30
+ # One directory entry: its name's bytes, its type code (0 DEL to
31
+ # 4 REL, and the 1581's 5 CBM), its length in blocks, and whether it
32
+ # was closed and is locked.
33
+ Entry = Data.define(:name, :type, :blocks, :closed, :locked)
34
+
35
+ attr_reader :name, :id, :entries, :blocks_free
36
+
37
+ def initialize(name:, id:, entries:, blocks_free:)
38
+ @name = name
39
+ @id = id
40
+ @entries = entries
41
+ @blocks_free = blocks_free
42
+ end
43
+
44
+ # Name bytes padded with shifted spaces, from ASCII text in upper
45
+ # case, as a host file's name is listed.
46
+ def self.encode(text)
47
+ bytes = text.upcase.bytes.first(NAME_LENGTH)
48
+ bytes + Array.new(NAME_LENGTH - bytes.length, NAME_PADDING)
49
+ end
50
+
51
+ # A host file listed as a closed PRG, named in ASCII, of the blocks
52
+ # its bytes would take on a disk, 254 to a block.
53
+ def self.program(name, length)
54
+ Entry.new(name: encode(name), type: PRG, blocks: (length + 253) / 254, closed: true, locked: false)
55
+ end
56
+
57
+ # The file's bytes, load address first. A `$:NAME` or `$0:A*,B*`
58
+ # pattern lists only the files that match one of its names.
59
+ def bytes(request = "$")
60
+ lines = [[0, padded(header, WIDTH)]]
61
+ lines.concat(listed(request).map { |entry| [entry.blocks, file_line(entry)] })
62
+ lines << [@blocks_free, padded("BLOCKS FREE.".bytes, WIDTH)]
63
+ program(lines)
64
+ end
65
+
66
+ private
67
+
68
+ def listed(request)
69
+ patterns = request.sub(/\A\$\d*/, "")
70
+ return @entries unless patterns.start_with?(":")
71
+
72
+ matchers = patterns[1..].split(",").map { |pattern| Storage.matcher(pattern) }
73
+ @entries.select do |entry|
74
+ name = Storage.ascii(entry.name.take_while { |byte| byte != NAME_PADDING }).downcase
75
+ matchers.any? { |matcher| matcher.match?(name) }
76
+ end
77
+ end
78
+
79
+ def header
80
+ [REVERSE_ON, QUOTE, *spaced(@name.first(NAME_LENGTH)), QUOTE, SPACE, *spaced(@id.first(5))]
81
+ end
82
+
83
+ # A block count under 10 or 100 takes more spaces before the name, so
84
+ # the names line up.
85
+ def file_line(entry)
86
+ indent = 1 + (entry.blocks < 100 ? 1 : 0) + (entry.blocks < 10 ? 1 : 0)
87
+ line = Array.new(indent, SPACE) + quoted(entry.name) + [entry.closed ? SPACE : "*".ord]
88
+ line.concat(TYPES.fetch(entry.type, "???").bytes)
89
+ line << (entry.locked ? "<".ord : SPACE)
90
+ padded(line, FILE_WIDTH)
91
+ end
92
+
93
+ # The closing quote takes the first shifted space's place, or follows
94
+ # a name that fills all 16.
95
+ def quoted(name)
96
+ name = name.first(NAME_LENGTH)
97
+ name += Array.new(NAME_LENGTH - name.length, NAME_PADDING)
98
+ cut = name.index(NAME_PADDING)
99
+ name = cut ? [*name[0, cut], QUOTE, *name[(cut + 1)..], SPACE] : [*name, QUOTE]
100
+ [QUOTE, *spaced(name)]
101
+ end
102
+
103
+ def padded(line, width) = line + Array.new([width - line.length, 0].max, SPACE)
104
+
105
+ def spaced(bytes) = bytes.map { |byte| byte == NAME_PADDING ? SPACE : byte }
106
+
107
+ def program(lines)
108
+ address = LOAD_ADDRESS
109
+ bytes = [address & 0xff, address >> 8]
110
+ lines.each do |number, text|
111
+ address += text.length + 5
112
+ bytes.push(address & 0xff, address >> 8, number & 0xff, (number >> 8) & 0xff, *text, 0)
113
+ end
114
+ bytes.push(0, 0)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -9,6 +9,8 @@ module Badline
9
9
  HEADER_SIZE = 0x40
10
10
  ENTRY_SIZE = 32
11
11
  NORMAL_FILE = 1
12
+ TAPE_NAME = 0x28
13
+ ID = "T64".bytes.freeze
12
14
 
13
15
  def initialize(path)
14
16
  @bytes = File.binread(path).bytes
@@ -29,6 +31,18 @@ module Badline
29
31
  entries.map { |e| e[:name] }
30
32
  end
31
33
 
34
+ # The archive as LOAD"$" lists it: the tape's name, and each file as
35
+ # a PRG of the blocks it would take on a disk, load address and all.
36
+ # An archive has no free blocks.
37
+ def directory
38
+ Listing.new(name: Listing.encode(decode_name(@bytes[TAPE_NAME, Listing::NAME_LENGTH].to_a)),
39
+ id: ID, entries: listing_entries, blocks_free: 0)
40
+ end
41
+
42
+ def listing_entries
43
+ entries.map { |e| Listing.program(e[:name], e[:length] + 2) }
44
+ end
45
+
32
46
  private
33
47
 
34
48
  def entries
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/storage/listing"
3
4
  require "badline/storage/p00"
4
5
  require "badline/storage/host_directory"
5
6
  require "badline/storage/disk_image"
6
7
  require "badline/storage/d64_image"
7
8
  require "badline/storage/d71_image"
8
9
  require "badline/storage/d81_image"
10
+ require "badline/storage/g64_image"
9
11
  require "badline/storage/t64"
10
12
  require "badline/storage/tap"
11
13
  require "badline/storage/crt_file"
data/lib/badline/traps.rb CHANGED
@@ -6,6 +6,10 @@ module Badline
6
6
  (@traps ||= {})[addr] = handler
7
7
  end
8
8
 
9
+ def remove_trap(addr)
10
+ @traps&.delete(addr)
11
+ end
12
+
9
13
  private
10
14
 
11
15
  def run_traps
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -55,6 +55,9 @@ module Badline
55
55
 
56
56
  def active_c1_edge?(high) = high == @control.anybits?(0x01)
57
57
 
58
+ # Everything the lines hold, for comparing them at two points.
59
+ def state = [@control, @c1_high, @c2_high, @handshake_low, @pulse, @c2_output]
60
+
58
61
  # A new C1 level. An active edge sets the flag and ends a handshake.
59
62
  def c1=(high)
60
63
  return if high == @c1_high
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class VIA
5
+ # Running a VIA a stretch of cycles at once while nothing but its
6
+ # counters would change, for Drive1541::Idle.
7
+ module FastForward
8
+ # quiet_cycles for a VIA that will never set a flag of its own.
9
+ QUIET = 1 << 40
10
+
11
+ # How many cycles the VIA can run with nothing but its counters
12
+ # changing: no flag set, no pulse on a control line and no bit
13
+ # shifted. See Drive1541::Idle.
14
+ def quiet_cycles
15
+ return 0 if @sr_uses_t2 || @shift_register.clocking? || @ca.pulsing? || @cb.pulsing?
16
+
17
+ [@t1.quiet_cycles, @t2.quiet_cycles].min
18
+ end
19
+
20
+ # Runs +cycles+ cycles at once, as many as quiet_cycles allows, which
21
+ # moves only the counters.
22
+ def fast_forward(cycles)
23
+ return if cycles.zero?
24
+
25
+ @t1.fast_forward(cycles)
26
+ @t2.fast_forward(cycles)
27
+ end
28
+
29
+ # Everything the VIA holds but its counters, for comparing it at two
30
+ # points. Timer 1's PB7 level counts only while ACR bit 7 puts it on
31
+ # the pin: nothing reads it otherwise.
32
+ def idle_state
33
+ [@ora, @orb, @ddra, @ddrb, @latch_a, @latch_b, @acr, @pcr, @pb6_high, @ifr.flags, @ifr.enable,
34
+ @acr.anybits?(0x80) && @t1.pb7, *@t1.state, *@t2.state, *@ca.state, *@cb.state, *@shift_register.state]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -35,8 +35,6 @@ module Badline
35
35
  OUT_PHI2 = 6
36
36
  OUT_EXTERNAL = 7
37
37
 
38
- BITS = 8
39
-
40
38
  attr_reader :mode, :data
41
39
  attr_accessor :cb2_input
42
40
 
@@ -62,9 +60,16 @@ module Badline
62
60
  # Every read or write of the data register (re)starts the count of
63
61
  # eight bits.
64
62
  def access!
65
- @bits = BITS
63
+ @bits = 8
66
64
  end
67
65
 
66
+ # Whether the register is counting bits off its own clock, which
67
+ # changes it from cycle to cycle.
68
+ def clocking? = @bits.nonzero? && internal_clock?
69
+
70
+ # Everything the register holds, for comparing it at two points.
71
+ def state = [@mode, @data, @bits, @clock, @cb2, @cb2_input]
72
+
68
73
  # The CB1 level the register drives, or nil when CB1 is an input.
69
74
  def cb1_output
70
75
  internal_clock? ? @clock : nil