badline 0.4.2 → 0.5.1

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CLAUDE.md +32 -27
  4. data/CONTRIBUTING.md +5 -0
  5. data/README.md +55 -24
  6. data/doc/pinned-behaviour.md +223 -0
  7. data/exe/badline-ruby +7 -4
  8. data/lib/badline/address_bus/ultimax_pages.rb +38 -0
  9. data/lib/badline/address_bus.rb +19 -29
  10. data/lib/badline/audio/jukebox.rb +6 -4
  11. data/lib/badline/audio/machine_player.rb +10 -6
  12. data/lib/badline/cartridge/kcs_power.rb +3 -3
  13. data/lib/badline/cia.rb +21 -3
  14. data/lib/badline/computer.rb +86 -7
  15. data/lib/badline/cpu.rb +39 -0
  16. data/lib/badline/drive1541/bus.rb +197 -0
  17. data/lib/badline/drive1541/disk.rb +256 -0
  18. data/lib/badline/drive1541/disk_via.rb +33 -0
  19. data/lib/badline/drive1541/gcr.rb +57 -0
  20. data/lib/badline/drive1541/idle.rb +136 -0
  21. data/lib/badline/drive1541/mechanism.rb +277 -0
  22. data/lib/badline/drive1541/orbit.rb +81 -0
  23. data/lib/badline/drive1541/rotation.rb +111 -0
  24. data/lib/badline/drive1541/sector_reader.rb +76 -0
  25. data/lib/badline/drive1541/serial_port.rb +67 -0
  26. data/lib/badline/drive1541/sleep.rb +114 -0
  27. data/lib/badline/drive1541/track.rb +98 -0
  28. data/lib/badline/drive1541.rb +234 -0
  29. data/lib/badline/gui/application.rb +15 -3
  30. data/lib/badline/gui/drive_led_pane.rb +29 -0
  31. data/lib/badline/gui.rb +1 -0
  32. data/lib/badline/iec_bus.rb +109 -0
  33. data/lib/badline/kernal_trap/channel.rb +9 -1
  34. data/lib/badline/kernal_trap/serial.rb +9 -3
  35. data/lib/badline/media/true_drive.rb +54 -0
  36. data/lib/badline/media.rb +22 -1
  37. data/lib/badline/memory.rb +3 -0
  38. data/lib/badline/options.rb +24 -4
  39. data/lib/badline/region.rb +35 -10
  40. data/lib/badline/reu/dma.rb +228 -0
  41. data/lib/badline/reu/ram.rb +65 -0
  42. data/lib/badline/reu/trigger.rb +32 -0
  43. data/lib/badline/reu.rb +276 -0
  44. data/lib/badline/roms/README +10 -0
  45. data/lib/badline/roms/dos1541.rom +0 -0
  46. data/lib/badline/sdl/functions.rb +1 -0
  47. data/lib/badline/storage/d64_image.rb +18 -9
  48. data/lib/badline/storage/d81_image.rb +1 -0
  49. data/lib/badline/storage/disk_image/catalog.rb +52 -0
  50. data/lib/badline/storage/disk_image/writing.rb +16 -0
  51. data/lib/badline/storage/disk_image.rb +2 -0
  52. data/lib/badline/storage/g64_image.rb +222 -0
  53. data/lib/badline/storage/host_directory.rb +21 -1
  54. data/lib/badline/storage/listing.rb +118 -0
  55. data/lib/badline/storage/t64.rb +14 -0
  56. data/lib/badline/storage.rb +2 -0
  57. data/lib/badline/traps.rb +4 -0
  58. data/lib/badline/version.rb +1 -1
  59. data/lib/badline/via/control_lines.rb +3 -0
  60. data/lib/badline/via/fast_forward.rb +53 -0
  61. data/lib/badline/via/shift_register.rb +8 -3
  62. data/lib/badline/via/timer1.rb +36 -0
  63. data/lib/badline/via/timer2.rb +31 -0
  64. data/lib/badline/via.rb +2 -0
  65. data/lib/badline/vic/display_state.rb +7 -7
  66. data/lib/badline/vic/sprite/internal_bus.rb +11 -7
  67. data/lib/badline/vic/sprite/shifter.rb +10 -15
  68. data/lib/badline/vic/sprite/timing.rb +60 -0
  69. data/lib/badline/vic/sprite.rb +14 -12
  70. data/lib/badline/vic/sprites.rb +2 -2
  71. data/lib/badline/vic.rb +86 -39
  72. data/lib/badline.rb +3 -0
  73. metadata +28 -1
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/reu/ram"
4
+ require "badline/reu/dma"
5
+ require "badline/reu/trigger"
6
+
7
+ module Badline
8
+ # A RAM Expansion Unit: the Commodore 1700 (128K), 1764 (256K) and 1750
9
+ # (512K), and the bigger units built around the same 8726 RAM Expansion
10
+ # Controller (REC), such as the CMD 1750XL, up to 16M.
11
+ #
12
+ # The REC's eleven registers sit in I/O 2 from $DF00, repeated every 32
13
+ # bytes up to $DFFF, as the 1764 and 1750 manuals map them. It moves
14
+ # bytes between the C64 and its RAM by DMA, one bus cycle each, with the
15
+ # CPU held off the bus. A command with the execute bit set starts a
16
+ # transfer, or with bit 4 clear arms the REC to start one on the next
17
+ # write to $FF00, so a program can bank the ROMs out first. A transfer
18
+ # waits for the CPU's next read cycle with BA high, and Computer then
19
+ # clocks it through #dma_cycle! until it hands the bus back.
20
+ class REU
21
+ SIZES_KB = [128, 256, 512, 1024, 2048, 4096, 8192, 16_384].freeze
22
+
23
+ # The registers, by offset.
24
+ STATUS = 0x00
25
+ COMMAND = 0x01
26
+ C64_LOW = 0x02
27
+ C64_HIGH = 0x03
28
+ EXPANSION_LOW = 0x04
29
+ EXPANSION_HIGH = 0x05
30
+ EXPANSION_BANK = 0x06
31
+ LENGTH_LOW = 0x07
32
+ LENGTH_HIGH = 0x08
33
+ INTERRUPT_MASK = 0x09
34
+ ADDRESS_CONTROL = 0x0a
35
+
36
+ # Status: an interrupt is pending, the block ended, a verify found a
37
+ # difference (the manual's fault bit), and the unit is built from 256K
38
+ # chips, which only the 1700 isn't. Reading the status clears the top
39
+ # three.
40
+ INTERRUPT_PENDING = 0x80
41
+ END_OF_BLOCK = 0x40
42
+ FAULT = 0x20
43
+ CHIPS_256K = 0x10
44
+
45
+ # Command: execute, reload the registers after the transfer, don't wait
46
+ # for $FF00, and the transfer type in the bottom two bits.
47
+ EXECUTE = 0x80
48
+ AUTOLOAD = 0x20
49
+ NO_FF00_TRIGGER = 0x10
50
+ TRANSFER_TYPE = 0x03
51
+
52
+ # Interrupt mask: interrupts on at all, then on the end of a block and
53
+ # on a fault. The low five bits aren't there and read as 1.
54
+ INTERRUPTS_ON = 0x80
55
+ INTERRUPT_ON_END = 0x40
56
+ INTERRUPT_ON_FAULT = 0x20
57
+
58
+ # Address control: hold the C64 address or the expansion address where
59
+ # it is. The low six bits aren't there and read as 1.
60
+ FIX_C64 = 0x80
61
+ FIX_EXPANSION = 0x40
62
+
63
+ # The REC's bank register has three bits, and the other five read as
64
+ # 1. A unit past 512K latches all eight onto its own address lines.
65
+ BANK_STUCK = 0xf8
66
+
67
+ attr_reader :trigger
68
+
69
+ # bus is the bus the REC reaches C64 memory through, and vic the VIC,
70
+ # whose phi1 fetch is what a register read finds on the bus during a
71
+ # transfer.
72
+ def initialize(size_kb, bus:, vic:)
73
+ raise ArgumentError, "No #{size_kb}K REU. Pick one of #{SIZES_KB.join(', ')}." unless SIZES_KB.include?(size_kb)
74
+
75
+ # The 1700's REC counts 17 address bits, the others' 19.
76
+ span = size_kb == 128 ? 0x20000 : 0x80000
77
+ size = size_kb * 1024
78
+ @address_mask = [size, span].max - 1
79
+ @bank_bits = size > span ? 0xff : 0x07
80
+ @chips = size_kb == 128 ? 0 : CHIPS_256K
81
+ @ram = RAM.new(size, span)
82
+ @dma = DMA.new(@ram, span, bus)
83
+ @trigger = Trigger.new(self)
84
+ @vic = vic
85
+ @on_irq_change = nil
86
+ @on_dma = nil
87
+ @irq = false
88
+ reset!
89
+ end
90
+
91
+ def on_irq_change(&block)
92
+ @on_irq_change = block
93
+ end
94
+
95
+ # Called when the REC asks for the bus.
96
+ def on_dma(&block)
97
+ @on_dma = block
98
+ end
99
+
100
+ def irq? = @irq
101
+
102
+ # From the moment the REC asks for the bus until it hands it back.
103
+ def dma? = @requested || @running
104
+
105
+ # Whether the REC held the bus on the last cycle clocked. The CPU keeps
106
+ # it while the REC waits to start, and has it back from the cycle the
107
+ # REC lets go.
108
+ def holds_bus? = @running
109
+
110
+ def reset!
111
+ @status = @chips
112
+ @command = NO_FF00_TRIGGER
113
+ @c64 = @c64_start = 0
114
+ @expansion = @expansion_start = 0
115
+ @bank = @bank_start = 0
116
+ @length = @length_start = 0xffff
117
+ @interrupt_mask = 0x1f
118
+ @address_control = 0x3f
119
+ @armed = @requested = @running = false
120
+ interrupt(false)
121
+ end
122
+
123
+ def peek(addr)
124
+ return @vic.phi1_data if @running
125
+
126
+ register = addr & 0x1f
127
+ value = register_value(register)
128
+ read_status if register == STATUS
129
+ value
130
+ end
131
+
132
+ def poke(addr, value)
133
+ return if @running
134
+
135
+ register = addr & 0x1f
136
+ case register
137
+ when COMMAND then write_command(value)
138
+ when C64_LOW, C64_HIGH then @c64 = @c64_start = with_byte(@c64_start, value, register == C64_HIGH)
139
+ when EXPANSION_LOW, EXPANSION_HIGH
140
+ @expansion = @expansion_start = with_byte(@expansion_start, value, register == EXPANSION_HIGH)
141
+ when EXPANSION_BANK then @bank = @bank_start = value & @bank_bits
142
+ when LENGTH_LOW, LENGTH_HIGH
143
+ @length = @length_start = with_byte(@length_start, value, register == LENGTH_HIGH)
144
+ when INTERRUPT_MASK then write_interrupt_mask(value)
145
+ when ADDRESS_CONTROL then @address_control = value | 0x3f
146
+ end
147
+ end
148
+
149
+ # A write to $FF00 starts the transfer the command register armed.
150
+ def ff00_written
151
+ return unless @armed
152
+
153
+ @armed = false
154
+ request_bus
155
+ end
156
+
157
+ # Clocks the REC for a cycle, given the VIC's BA line and whether the
158
+ # CPU writes on it. A requested transfer starts on the first cycle the
159
+ # CPU reads with BA high.
160
+ def dma_cycle!(ba_low, cpu_writing)
161
+ if @requested
162
+ return if cpu_writing || ba_low
163
+
164
+ start_transfer
165
+ end
166
+ @dma.cycle!(ba_low)
167
+ end_transfer unless @dma.holds_bus?
168
+ end
169
+
170
+ # The REU's RAM, at an address as the REC's registers give it.
171
+ def ram_peek(addr) = @ram.peek(addr)
172
+
173
+ def ram_poke(addr, value) = @ram.poke(addr, value)
174
+
175
+ private
176
+
177
+ def register_value(register)
178
+ case register
179
+ when STATUS then @status
180
+ when COMMAND then @command
181
+ when C64_LOW then @c64 & 0xff
182
+ when C64_HIGH then @c64 >> 8
183
+ when EXPANSION_LOW then @expansion & 0xff
184
+ when EXPANSION_HIGH then @expansion >> 8
185
+ when EXPANSION_BANK then @bank | BANK_STUCK
186
+ when LENGTH_LOW then @length & 0xff
187
+ when LENGTH_HIGH then @length >> 8
188
+ when INTERRUPT_MASK then @interrupt_mask
189
+ when ADDRESS_CONTROL then @address_control
190
+ else 0xff
191
+ end
192
+ end
193
+
194
+ def read_status
195
+ @status &= CHIPS_256K
196
+ interrupt(false)
197
+ end
198
+
199
+ # Writing either byte of an address or the length sets the value the
200
+ # transfer starts from, and puts the counter back to it.
201
+ def with_byte(word, value, high)
202
+ high ? (word & 0x00ff) | (value << 8) : (word & 0xff00) | value
203
+ end
204
+
205
+ def write_command(value)
206
+ @command = value
207
+ return if value.nobits?(EXECUTE)
208
+
209
+ @armed = value.nobits?(NO_FF00_TRIGGER)
210
+ request_bus unless @armed
211
+ end
212
+
213
+ # An interrupt enabled over a block that already ended, or a fault
214
+ # already found, fires at once.
215
+ def write_interrupt_mask(value)
216
+ @interrupt_mask = value | 0x1f
217
+ interrupt_on(@status)
218
+ end
219
+
220
+ def interrupt_on(events)
221
+ return unless @interrupt_mask.anybits?(INTERRUPTS_ON)
222
+ return unless (events.anybits?(END_OF_BLOCK) && @interrupt_mask.anybits?(INTERRUPT_ON_END)) ||
223
+ (events.anybits?(FAULT) && @interrupt_mask.anybits?(INTERRUPT_ON_FAULT))
224
+
225
+ @status |= INTERRUPT_PENDING
226
+ interrupt(true)
227
+ end
228
+
229
+ def interrupt(level)
230
+ return if @irq == level
231
+
232
+ @irq = level
233
+ @on_irq_change&.call(level)
234
+ end
235
+
236
+ def request_bus
237
+ @requested = true
238
+ @on_dma&.call
239
+ end
240
+
241
+ def start_transfer
242
+ @requested = false
243
+ @running = true
244
+ @dma.start(@command & TRANSFER_TYPE, @c64, @expansion | (@bank << 16), @length, @address_control)
245
+ end
246
+
247
+ # The execute bit drops and the $FF00 trigger turns off. With autoload
248
+ # the addresses and the length go back to where the transfer started,
249
+ # and otherwise they stay where it stopped.
250
+ def end_transfer
251
+ @running = false
252
+ @command = (@command & ~EXECUTE & 0xff) | NO_FF00_TRIGGER
253
+ events = @dma.events
254
+ @status |= events
255
+ @command.anybits?(AUTOLOAD) ? reload : keep_counters
256
+ interrupt_on(events)
257
+ end
258
+
259
+ def reload
260
+ @c64 = @c64_start
261
+ @expansion = @expansion_start
262
+ @bank = @bank_start
263
+ @length = @length_start
264
+ end
265
+
266
+ def keep_counters
267
+ @c64 = @dma.c64 if @address_control.nobits?(FIX_C64)
268
+ @length = @dma.length & 0xffff
269
+ return if @address_control.anybits?(FIX_EXPANSION)
270
+
271
+ address = @dma.expansion & @address_mask
272
+ @expansion = address & 0xffff
273
+ @bank = (address >> 16) & 0xff
274
+ end
275
+ end
276
+ end
@@ -16,3 +16,13 @@ the C128's C64 mode.
16
16
  character.rom
17
17
  -------------
18
18
  The character generator ROM.
19
+
20
+ dos1541.rom
21
+ -----------
22
+ The 1541 disk drive's DOS 2.6, 16 KB for $C000-$FFFF: 325302-01
23
+ ($C000-$DFFF) followed by 901229-05 ($E000-$FFFF). Drive1541 loads it.
24
+
25
+ Both halves downloaded from
26
+ https://www.zimmers.net/anonftp/pub/cbm/firmware/drives/new/1541/
27
+ (1541-c000.325302-01.bin and 1541-e000.901229-05.bin) and concatenated.
28
+ SHA-1 ab16f56989b27d89babe5f89c5a8cb3da71a82f0.
Binary file
@@ -42,6 +42,7 @@ module Badline
42
42
  RenderSetLogicalSize = function("SDL_RenderSetLogicalSize", [POINTER, INT, INT], INT)
43
43
  SetRenderDrawColor = function("SDL_SetRenderDrawColor", [POINTER, UINT8, UINT8, UINT8, UINT8], INT)
44
44
  RenderClear = function("SDL_RenderClear", [POINTER], INT)
45
+ RenderFillRect = function("SDL_RenderFillRect", [POINTER, POINTER], INT)
45
46
  RenderCopy = function("SDL_RenderCopy", [POINTER, POINTER, POINTER, POINTER], INT)
46
47
  RenderPresent = function("SDL_RenderPresent", [POINTER], VOID)
47
48
  CreateRGBSurfaceFrom = function("SDL_CreateRGBSurfaceFrom",
@@ -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