badline 0.1.0 → 0.2.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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/CLAUDE.md +217 -0
  4. data/CODE_OF_CONDUCT.md +92 -0
  5. data/CONTRIBUTING.md +51 -0
  6. data/README.md +224 -31
  7. data/doc/pinned-behaviour.md +1117 -0
  8. data/exe/badline +22 -2
  9. data/exe/badline-sid +34 -0
  10. data/lib/badline/address_bus.rb +88 -23
  11. data/lib/badline/audio/aiff.rb +36 -0
  12. data/lib/badline/audio/bare_player.rb +129 -0
  13. data/lib/badline/audio/cli.rb +133 -0
  14. data/lib/badline/audio/console.rb +75 -0
  15. data/lib/badline/audio/jukebox.rb +82 -0
  16. data/lib/badline/audio/machine_player.rb +57 -0
  17. data/lib/badline/audio/options.rb +137 -0
  18. data/lib/badline/audio/pcm_writer.rb +57 -0
  19. data/lib/badline/audio/playback.rb +108 -0
  20. data/lib/badline/audio/renderer.rb +85 -0
  21. data/lib/badline/audio/sdl_sink.rb +102 -0
  22. data/lib/badline/audio/wav.rb +23 -0
  23. data/lib/badline/audio.rb +14 -0
  24. data/lib/badline/cartridge/action_replay.rb +111 -0
  25. data/lib/badline/cartridge/atomic_power.rb +24 -0
  26. data/lib/badline/cartridge/bank.rb +81 -0
  27. data/lib/badline/cartridge/comal80.rb +48 -0
  28. data/lib/badline/cartridge/dinamic.rb +34 -0
  29. data/lib/badline/cartridge/easy_flash.rb +122 -0
  30. data/lib/badline/cartridge/epyx_fastload.rb +71 -0
  31. data/lib/badline/cartridge/final_cartridge3.rb +77 -0
  32. data/lib/badline/cartridge/flash.rb +265 -0
  33. data/lib/badline/cartridge/freezer.rb +31 -0
  34. data/lib/badline/cartridge/fun_play.rb +38 -0
  35. data/lib/badline/cartridge/g_mod2.rb +62 -0
  36. data/lib/badline/cartridge/game_system.rb +40 -0
  37. data/lib/badline/cartridge/kcs_power.rb +72 -0
  38. data/lib/badline/cartridge/mach5.rb +34 -0
  39. data/lib/badline/cartridge/magic_desk.rb +6 -3
  40. data/lib/badline/cartridge/ocean.rb +11 -12
  41. data/lib/badline/cartridge/pagefox.rb +56 -0
  42. data/lib/badline/cartridge/retro_replay.rb +276 -0
  43. data/lib/badline/cartridge/rex_utility.rb +33 -0
  44. data/lib/badline/cartridge/rgcd.rb +48 -0
  45. data/lib/badline/cartridge/simons_basic.rb +40 -0
  46. data/lib/badline/cartridge/standard.rb +3 -19
  47. data/lib/badline/cartridge/super_games.rb +36 -0
  48. data/lib/badline/cartridge/westermann.rb +34 -0
  49. data/lib/badline/cartridge/zaxxon.rb +50 -0
  50. data/lib/badline/cartridge.rb +131 -15
  51. data/lib/badline/cia/interrupt_register.rb +88 -0
  52. data/lib/badline/cia/serial.rb +219 -0
  53. data/lib/badline/cia/timer.rb +43 -31
  54. data/lib/badline/cia.rb +109 -74
  55. data/lib/badline/color_memory.rb +14 -5
  56. data/lib/badline/computer.rb +68 -8
  57. data/lib/badline/control_ports.rb +54 -9
  58. data/lib/badline/cpu/addressing.rb +131 -0
  59. data/lib/badline/cpu/microcode.rb +171 -0
  60. data/lib/badline/cpu/operations.rb +88 -0
  61. data/lib/badline/cpu/stack_operations.rb +82 -0
  62. data/lib/badline/cpu.rb +108 -167
  63. data/lib/badline/cycleable.rb +3 -0
  64. data/lib/badline/datasette.rb +81 -0
  65. data/lib/badline/debug_register.rb +28 -0
  66. data/lib/badline/gui/application.rb +79 -18
  67. data/lib/badline/gui/gamepads.rb +97 -0
  68. data/lib/badline/gui/joy_map.rb +15 -7
  69. data/lib/badline/gui.rb +1 -0
  70. data/lib/badline/input/mouse1351.rb +41 -0
  71. data/lib/badline/input/paddles.rb +36 -0
  72. data/lib/badline/input.rb +4 -0
  73. data/lib/badline/instruction_set/arithmetic.rb +16 -21
  74. data/lib/badline/instruction_set/bitwise.rb +17 -22
  75. data/lib/badline/instruction_set/branch.rb +20 -19
  76. data/lib/badline/instruction_set/flag.rb +7 -7
  77. data/lib/badline/instruction_set/illegal.rb +14 -22
  78. data/lib/badline/instruction_set/inc_dec.rb +8 -10
  79. data/lib/badline/instruction_set/stack.rb +5 -57
  80. data/lib/badline/instruction_set/transfer.rb +9 -9
  81. data/lib/badline/instruction_set.rb +9 -31
  82. data/lib/badline/interrupts.rb +104 -0
  83. data/lib/badline/joystick.rb +9 -9
  84. data/lib/badline/kernal_trap/channel.rb +33 -0
  85. data/lib/badline/kernal_trap/drive.rb +239 -0
  86. data/lib/badline/kernal_trap/file.rb +4 -24
  87. data/lib/badline/kernal_trap/load.rb +2 -2
  88. data/lib/badline/kernal_trap/routine.rb +34 -0
  89. data/lib/badline/kernal_trap/save.rb +1 -1
  90. data/lib/badline/kernal_trap/serial.rb +139 -0
  91. data/lib/badline/kernal_trap.rb +4 -0
  92. data/lib/badline/keyboard.rb +40 -16
  93. data/lib/badline/media.rb +48 -10
  94. data/lib/badline/roms/eapi/LICENSE.md +15 -0
  95. data/lib/badline/roms/eapi/README +41 -0
  96. data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
  97. data/lib/badline/sid/decimator.rb +46 -0
  98. data/lib/badline/sid/envelope.rb +232 -0
  99. data/lib/badline/sid/filter.rb +213 -0
  100. data/lib/badline/sid/voice.rb +61 -0
  101. data/lib/badline/sid/waveform/combined.rb +126 -0
  102. data/lib/badline/sid/waveform/fast_forward.rb +105 -0
  103. data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
  104. data/lib/badline/sid/waveform.rb +289 -0
  105. data/lib/badline/sid.rb +274 -7
  106. data/lib/badline/status.rb +32 -27
  107. data/lib/badline/storage/crt_file.rb +18 -2
  108. data/lib/badline/storage/disk_image.rb +52 -5
  109. data/lib/badline/storage/host_directory.rb +18 -4
  110. data/lib/badline/storage/sid_file.rb +274 -0
  111. data/lib/badline/storage/song_lengths.rb +65 -0
  112. data/lib/badline/storage/t64.rb +67 -0
  113. data/lib/badline/storage/tap.rb +68 -0
  114. data/lib/badline/storage.rb +19 -0
  115. data/lib/badline/time_of_day.rb +105 -52
  116. data/lib/badline/version.rb +1 -1
  117. data/lib/badline/vic/bank.rb +14 -6
  118. data/lib/badline/vic/border_mask.rb +76 -0
  119. data/lib/badline/vic/collisions.rb +104 -0
  120. data/lib/badline/vic/color_patches.rb +33 -0
  121. data/lib/badline/vic/display_state.rb +92 -29
  122. data/lib/badline/vic/graphics_mode.rb +78 -53
  123. data/lib/badline/vic/graphics_shifter.rb +140 -0
  124. data/lib/badline/vic/register_log.rb +63 -0
  125. data/lib/badline/vic/registers.rb +3 -0
  126. data/lib/badline/vic/sequencer.rb +163 -137
  127. data/lib/badline/vic/sequencer_output.rb +116 -0
  128. data/lib/badline/vic/sprite/internal_bus.rb +49 -0
  129. data/lib/badline/vic/sprite/shifter.rb +129 -0
  130. data/lib/badline/vic/sprite.rb +275 -65
  131. data/lib/badline/vic/sprites.rb +220 -58
  132. data/lib/badline/vic.rb +373 -49
  133. data/lib/badline.rb +5 -0
  134. data/vendor/.gitkeep +0 -0
  135. metadata +103 -4
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Badline
6
+ module Storage
7
+ # PSID/RSID tunes: a memory image plus the addresses of the routines
8
+ # that initialise it and produce one frame of music.
9
+ class SIDFile
10
+ include IntegerHelper
11
+
12
+ class FormatError < StandardError; end
13
+
14
+ V1_HEADER_SIZE = 0x76
15
+
16
+ # The 6502 stub that starts a tune: call init with the song number,
17
+ # point the KERNAL's IRQ vector at a raster handler that calls play,
18
+ # and spin. Both calls are wrapped in the `$01` save, bank and restore
19
+ # that puts the tune's own RAM under the CPU.
20
+ #
21
+ # A tune owns all of zero page, so the stub never returns to whatever
22
+ # SYSed it: BASIC's READY loop reuses `$19-$21` and would overwrite the
23
+ # tune's variables between init and the first play.
24
+ #
25
+ # jmp boot
26
+ # irq: lda #$01
27
+ # sta $d019
28
+ # lda $01
29
+ # pha
30
+ # lda #iomap
31
+ # sta $01
32
+ # jsr play
33
+ # pla
34
+ # sta $01
35
+ # jmp $ea31
36
+ # boot: sei
37
+ # lda $01
38
+ # pha
39
+ # lda #iomap
40
+ # sta $01
41
+ # lda #song
42
+ # jsr init
43
+ # pla
44
+ # sta $01
45
+ # lda #$7f
46
+ # sta $dc0d
47
+ # lda $dc0d
48
+ # lda $d011
49
+ # and #$7f
50
+ # sta $d011
51
+ # lda #$00
52
+ # sta $d012
53
+ # lda #$01
54
+ # sta $d019
55
+ # sta $d01a
56
+ # lda #<irq
57
+ # sta $0314
58
+ # lda #>irq
59
+ # sta $0315
60
+ # cli
61
+ # idle: jmp idle
62
+ class Driver
63
+ include IntegerHelper
64
+
65
+ IRQ_VECTOR = 0x0314
66
+ KERNAL_IRQ = 0xea31
67
+ CIA1_ICR = 0xdc0d
68
+ RASTER_IRQ_STATUS = 0xd019
69
+
70
+ def initialize(tune, song:, base:)
71
+ @tune = tune
72
+ @song = song
73
+ @base = base
74
+ end
75
+
76
+ def bytes
77
+ @bytes ||= playing? ? jmp(boot_address) + handler + boot : boot
78
+ end
79
+
80
+ private
81
+
82
+ def playing? = @tune.play_address.positive?
83
+
84
+ def handler_address = @base + jmp(0).length
85
+
86
+ def boot_address = playing? ? handler_address + handler.length : @base
87
+
88
+ def handler
89
+ @handler ||= lda_imm(0x01) + sta_abs(RASTER_IRQ_STATUS) +
90
+ banked(@tune.play_address, jsr(@tune.play_address)) +
91
+ jmp(KERNAL_IRQ)
92
+ end
93
+
94
+ def boot
95
+ start = [0x78] +
96
+ banked(@tune.init_address, lda_imm(@song) + jsr(@tune.init_address)) +
97
+ raster_irq + [0x58]
98
+ start + jmp(boot_address + start.length)
99
+ end
100
+
101
+ # Tunes live under the ROMs as often as not, so a call has to bank out
102
+ # whatever covers the routine first. The 6510 port at `$01` is RAM to
103
+ # nobody, so saving it across the call leaves the caller's banking —
104
+ # and with it the I/O the rest of the stub writes to — as it was.
105
+ def banked(address, call)
106
+ bank = @tune.bank_for(address)
107
+ return call unless bank
108
+
109
+ lda_zp(0x01) + [0x48] + lda_imm(bank) + sta_zp(0x01) +
110
+ call + [0x68] + sta_zp(0x01)
111
+ end
112
+
113
+ def raster_irq
114
+ return [] unless playing?
115
+
116
+ mask_cia1 + arm_raster + install_vector
117
+ end
118
+
119
+ # Leaves the raster as the only interrupt source.
120
+ def mask_cia1
121
+ lda_imm(0x7f) + sta_abs(CIA1_ICR) + lda_abs(CIA1_ICR)
122
+ end
123
+
124
+ def arm_raster
125
+ lda_abs(0xd011) + [0x29, 0x7f] + sta_abs(0xd011) +
126
+ lda_imm(0x00) + sta_abs(0xd012) +
127
+ lda_imm(0x01) + sta_abs(RASTER_IRQ_STATUS) + sta_abs(0xd01a)
128
+ end
129
+
130
+ def install_vector
131
+ lda_imm(low_byte(handler_address)) + sta_abs(IRQ_VECTOR) +
132
+ lda_imm(high_byte(handler_address)) + sta_abs(IRQ_VECTOR + 1)
133
+ end
134
+
135
+ def lda_imm(value) = [0xa9, value]
136
+ def lda_zp(addr) = [0xa5, addr]
137
+ def sta_zp(addr) = [0x85, addr]
138
+ def lda_abs(addr) = [0xad, low_byte(addr), high_byte(addr)]
139
+ def sta_abs(addr) = [0x8d, low_byte(addr), high_byte(addr)]
140
+ def jsr(addr) = [0x20, low_byte(addr), high_byte(addr)]
141
+ def jmp(addr) = [0x4c, low_byte(addr), high_byte(addr)]
142
+ end
143
+
144
+ attr_reader :format
145
+
146
+ def initialize(path)
147
+ @bytes = File.binread(path).bytes
148
+ @format = @bytes[0, 4].pack("C*")
149
+ raise FormatError, "Missing PSID or RSID signature" unless %w[PSID RSID].include?(@format)
150
+ raise FormatError, "Truncated header" if truncated?
151
+ raise FormatError, "No tune data" if data.empty?
152
+ raise FormatError, "Sidplayer MUS data, which needs a MUS player" if mus?
153
+ raise FormatError, "Written for #{sids} SIDs; only one is emulated" if sids > 1
154
+ end
155
+
156
+ def version = word(0x04)
157
+ def data_offset = word(0x06)
158
+ def play_address = word(0x0c)
159
+ def songs = [word(0x0e), 1].max
160
+ def start_song = word(0x10).clamp(1, songs)
161
+ def speed = (word(0x12) << 16) + word(0x14)
162
+ def name = text(0x16)
163
+ def author = text(0x36)
164
+ def released = text(0x56)
165
+ def flags = version > 1 ? word(V1_HEADER_SIZE) : 0
166
+ def init_address = word(0x0a).nonzero? || load_address
167
+ def end_address = load_address + data.length
168
+ def psid? = @format == "PSID"
169
+
170
+ # Flag bit 0 marks a PSID body as Compute's Sidplayer data rather than
171
+ # code.
172
+ def mus? = psid? && flags[0] == 1
173
+
174
+ # A v3 header can place a second SID at `$Dxx0` and a v4 header a
175
+ # third, each given by its middle byte: even, and in `$d420-$d7e0` or
176
+ # `$de00-$dfe0`. Anything else means the SID isn't there.
177
+ def sids
178
+ 1 + [0x7a, 0x7b].first([version - 2, 0].max).count do |offset|
179
+ page = @bytes[offset].to_i
180
+ page.even? && ((0x42..0x7e).cover?(page) || (0xe0..0xfe).cover?(page))
181
+ end
182
+ end
183
+
184
+ # Flag bits 4-5 name the SID the tune was written for: 01 the 6581,
185
+ # 10 the 8580, 11 either and 00 unknown. Only an 8580-only tune gets one.
186
+ def sid_model = flags[4, 2] == 0b10 ? :mos8580 : :mos6581
187
+
188
+ # HVSC keys its song length database on the whole file, header and all.
189
+ def md5 = @md5 ||= Digest::MD5.hexdigest(@bytes.pack("C*"))
190
+
191
+ # The `$01` value a routine at `address` has to run under, following
192
+ # libsidplayfp's iomap: RAM under BASIC from `$a000`, RAM under both
193
+ # ROMs and no I/O for a routine in the I/O window itself, RAM under the
194
+ # KERNAL from `$e000`. An RSID tune banks itself and gets nil.
195
+ def bank_for(address)
196
+ return unless psid?
197
+ return 0x37 if address < 0xa000
198
+ return 0x36 if address < 0xd000
199
+ return 0x34 if address < 0xe000
200
+
201
+ 0x35
202
+ end
203
+
204
+ # A zero load address puts the real one in the first two bytes of
205
+ # the body, PRG style.
206
+ def load_address
207
+ @load_address ||= word(0x08).nonzero? || uint16(body[0].to_i, body[1].to_i)
208
+ end
209
+
210
+ def data
211
+ @data ||= body.drop(word(0x08).zero? ? 2 : 0).first(0x10000 - load_address)
212
+ end
213
+
214
+ # A set speed bit asks for CIA timer pacing instead of a raster IRQ.
215
+ # The bare player honours it; the machine driver paces everything off
216
+ # the raster either way.
217
+ def cia_timed?(song = start_song)
218
+ speed[[song - 1, 31].min] == 1
219
+ end
220
+
221
+ def driver_address
222
+ @driver_address ||= candidates.find { |addr| !overlaps?(addr) } ||
223
+ raise(FormatError, "No free RAM for the player")
224
+ end
225
+
226
+ def driver(song: start_song)
227
+ Driver.new(self, song: song.clamp(1, songs) - 1, base: driver_address).bytes
228
+ end
229
+
230
+ private
231
+
232
+ def truncated?
233
+ @bytes.length < V1_HEADER_SIZE || @bytes.length < data_offset
234
+ end
235
+
236
+ def body
237
+ @body ||= @bytes[data_offset..] || []
238
+ end
239
+
240
+ # The tape buffer and the free block below the stack page, neither
241
+ # of which BASIC touches.
242
+ def candidates
243
+ [relocation_address, 0x0334, 0x02a7].compact
244
+ end
245
+
246
+ def relocation_address
247
+ page = version > 1 ? @bytes[0x78].to_i : 0
248
+ return if page.zero? || page == 0xff
249
+
250
+ page << 8
251
+ end
252
+
253
+ def overlaps?(addr)
254
+ addr < end_address && load_address < addr + driver_size
255
+ end
256
+
257
+ def driver_size
258
+ @driver_size ||= Driver.new(self, song: 0, base: 0).bytes.length
259
+ end
260
+
261
+ def text(offset)
262
+ @bytes[offset, 32].take_while(&:positive?)
263
+ .pack("C*")
264
+ .force_encoding(Encoding::ISO_8859_1)
265
+ .encode(Encoding::UTF_8)
266
+ end
267
+
268
+ # Header fields are big-endian, unlike everything else the 6502 sees.
269
+ def word(offset)
270
+ uint16(@bytes[offset + 1], @bytes[offset])
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ # HVSC's song length database. Each tune gets a comment naming its path
6
+ # and a line keyed by the MD5 of the whole `.sid` file, holding one
7
+ # `m:ss` time per song, optionally down to milliseconds:
8
+ #
9
+ # ; /DEMOS/0-9/12th_Sector_Music.sid
10
+ # c7c299ce06ec5ccffb2261fb11b42a73=4:33.108
11
+ #
12
+ # The file runs to well over a hundred thousand entries, so a lookup
13
+ # scans for its one key rather than building a table.
14
+ class SongLengths
15
+ class << self
16
+ # The database lives in the DOCUMENTS directory of an HVSC
17
+ # collection, so look for one above the tune, then wherever
18
+ # `$HVSC_BASE` points.
19
+ def locate(tune_path)
20
+ candidates(tune_path).find { |path| File.file?(path) }
21
+ end
22
+
23
+ private
24
+
25
+ def candidates(tune_path)
26
+ ancestors(File.dirname(File.expand_path(tune_path)))
27
+ .push(ENV.fetch("HVSC_BASE", nil))
28
+ .compact
29
+ .map { |dir| File.join(dir, "DOCUMENTS", "Songlengths.md5") }
30
+ end
31
+
32
+ def ancestors(dir)
33
+ dirs = []
34
+ until dirs.last == dir
35
+ dirs << dir
36
+ dir = File.dirname(dir)
37
+ end
38
+ dirs
39
+ end
40
+ end
41
+
42
+ def initialize(path)
43
+ @path = path
44
+ end
45
+
46
+ # The tune's per-song lengths in seconds, or nil when it isn't listed.
47
+ def lengths(md5)
48
+ prefix = "#{md5.downcase}="
49
+ File.foreach(@path) do |line|
50
+ next unless line.start_with?(prefix)
51
+
52
+ return line[prefix.length..].split.map { |time| seconds(time) }
53
+ end
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ def seconds(time)
60
+ minutes, rest = time.split(":", 2)
61
+ rest ? (minutes.to_i * 60) + rest.to_f : minutes.to_f
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ class T64
6
+ class FormatError < StandardError; end
7
+
8
+ SIGNATURE = "C64".bytes.freeze
9
+ HEADER_SIZE = 0x40
10
+ ENTRY_SIZE = 32
11
+ NORMAL_FILE = 1
12
+
13
+ def initialize(path)
14
+ @bytes = File.binread(path).bytes
15
+ raise FormatError, "Missing T64 signature" unless @bytes[0, 3] == SIGNATURE
16
+ end
17
+
18
+ def read_file(name, **)
19
+ pattern = Storage.matcher(name)
20
+ entry = entries.find { |e| pattern.match?(e[:name]) }
21
+ return unless entry
22
+
23
+ # Stated end addresses are frequently wrong; the slice stops at
24
+ # the end of the archive either way.
25
+ entry[:load] + (@bytes[entry[:offset], entry[:length]] || [])
26
+ end
27
+
28
+ def names
29
+ entries.map { |e| e[:name] }
30
+ end
31
+
32
+ private
33
+
34
+ def entries
35
+ @entries ||= (0...entry_count).filter_map { |i| entry_at(i) }
36
+ end
37
+
38
+ # Directory size ($22) and used count ($24) disagree in the wild.
39
+ def entry_count
40
+ [word(0x22), word(0x24)].max
41
+ end
42
+
43
+ def entry_at(index)
44
+ entry = @bytes[HEADER_SIZE + (index * ENTRY_SIZE), ENTRY_SIZE]
45
+ return unless entry&.length == ENTRY_SIZE && entry[0] == NORMAL_FILE
46
+
47
+ { name: decode_name(entry[16, 16]),
48
+ load: entry[2, 2],
49
+ offset: little_endian(entry[8, 4]),
50
+ length: little_endian(entry[4, 2]) - little_endian(entry[2, 2]) }
51
+ end
52
+
53
+ # Names are padded with spaces, though NUL padding also occurs.
54
+ def decode_name(bytes)
55
+ Storage.ascii(bytes).downcase.sub(/[ \0]+\z/, "")
56
+ end
57
+
58
+ def word(offset)
59
+ little_endian(@bytes[offset, 2])
60
+ end
61
+
62
+ def little_endian(bytes)
63
+ bytes.each_with_index.sum { |byte, i| byte << (8 * i) }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ # Raw datasette pulse stream. The header holds the signature, the
6
+ # version at $0c and the data size at $10; the data runs from $14, every
7
+ # byte the time to the next falling edge on the tape read line, in units
8
+ # of 8 cycles.
9
+ class TAP
10
+ class FormatError < StandardError; end
11
+
12
+ SIGNATURE = "C64-TAPE-RAW".bytes.freeze
13
+ HEADER_SIZE = 0x14
14
+ OVERFLOW = 0x100 * 8
15
+
16
+ attr_reader :version
17
+
18
+ def initialize(path)
19
+ @bytes = File.binread(path).bytes
20
+ raise FormatError, "Missing TAP signature" unless @bytes[0, SIGNATURE.length] == SIGNATURE
21
+
22
+ @version = @bytes[0x0c].to_i
23
+ raise FormatError, "Unsupported TAP version #{@version}" if @version > 1
24
+
25
+ @end = tape_end
26
+ rewind
27
+ end
28
+
29
+ def rewind
30
+ @pos = HEADER_SIZE
31
+ end
32
+
33
+ def end? = @pos >= @end
34
+
35
+ # Cycles until the next falling edge, or nil once the tape has run out.
36
+ def next_pulse
37
+ return if end?
38
+
39
+ byte = @bytes[@pos]
40
+ @pos += 1
41
+ return byte * 8 unless byte.zero?
42
+
43
+ # $00 is an overflow in version 0 and a 24-bit cycle count in
44
+ # version 1.
45
+ version.zero? ? OVERFLOW : long_pulse
46
+ end
47
+
48
+ private
49
+
50
+ # The stated size disagrees with the file in the wild, and a few dumps
51
+ # leave it zeroed altogether.
52
+ def tape_end
53
+ size = little_endian(@bytes[0x10, 4] || [])
54
+ size.zero? ? @bytes.length : [HEADER_SIZE + size, @bytes.length].min
55
+ end
56
+
57
+ def long_pulse
58
+ bytes = @bytes[@pos, 3]
59
+ @pos += 3
60
+ bytes&.length == 3 ? little_endian(bytes) : OVERFLOW
61
+ end
62
+
63
+ def little_endian(bytes)
64
+ bytes.each_with_index.sum { |byte, i| byte << (8 * i) }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -6,16 +6,35 @@ require "badline/storage/disk_image"
6
6
  require "badline/storage/d64_image"
7
7
  require "badline/storage/d71_image"
8
8
  require "badline/storage/d81_image"
9
+ require "badline/storage/t64"
10
+ require "badline/storage/tap"
9
11
  require "badline/storage/crt_file"
12
+ require "badline/storage/sid_file"
13
+ require "badline/storage/song_lengths"
10
14
 
11
15
  module Badline
12
16
  module Storage
17
+ FILE_TYPES = { "S" => :seq, "P" => :prg, "U" => :usr }.freeze
18
+
13
19
  class << self
14
20
  # Folds shifted PETSCII letters to their ASCII equivalents.
15
21
  def ascii(bytes)
16
22
  bytes.map { |b| b.between?(0xc1, 0xda) ? b - 0x80 : b }.pack("C*")
17
23
  end
18
24
 
25
+ # CBM DOS drive prefix: "0:NAME" selects a drive, "@0:NAME" is
26
+ # save-with-replace
27
+ def strip_drive_prefix(name)
28
+ name.sub(/\A@?\d*:/, "")
29
+ end
30
+
31
+ # CBM DOS names read "NAME,TYPE,MODE". Returns the bare name and the
32
+ # file type it asks for, or nil when it names none.
33
+ def parse_name(name)
34
+ base, type = strip_drive_prefix(name).split(",", 3)
35
+ [base.to_s, FILE_TYPES[type.to_s.strip[0]&.upcase]]
36
+ end
37
+
19
38
  # CBM-style filename pattern: "*" and "?" wildcards, case-insensitive.
20
39
  def matcher(name)
21
40
  escaped = Regexp.escape(name.downcase)