badline 0.1.0 → 0.2.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +185 -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 +96 -25
  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 +132 -16
  51. data/lib/badline/cia/interrupt_register.rb +102 -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 +138 -88
  55. data/lib/badline/color_memory.rb +14 -5
  56. data/lib/badline/computer.rb +97 -12
  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 +169 -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 +109 -168
  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 +51 -0
  85. data/lib/badline/kernal_trap/drive/status.rb +49 -0
  86. data/lib/badline/kernal_trap/drive.rb +277 -0
  87. data/lib/badline/kernal_trap/file.rb +5 -25
  88. data/lib/badline/kernal_trap/load.rb +86 -24
  89. data/lib/badline/kernal_trap/routine.rb +51 -0
  90. data/lib/badline/kernal_trap/save.rb +46 -14
  91. data/lib/badline/kernal_trap/serial.rb +171 -0
  92. data/lib/badline/kernal_trap.rb +4 -0
  93. data/lib/badline/keyboard.rb +40 -16
  94. data/lib/badline/media.rb +48 -10
  95. data/lib/badline/memory.rb +5 -0
  96. data/lib/badline/roms/eapi/LICENSE.md +15 -0
  97. data/lib/badline/roms/eapi/README +41 -0
  98. data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
  99. data/lib/badline/sid/decimator.rb +46 -0
  100. data/lib/badline/sid/envelope.rb +232 -0
  101. data/lib/badline/sid/filter.rb +213 -0
  102. data/lib/badline/sid/voice.rb +61 -0
  103. data/lib/badline/sid/waveform/combined.rb +126 -0
  104. data/lib/badline/sid/waveform/fast_forward.rb +105 -0
  105. data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
  106. data/lib/badline/sid/waveform.rb +289 -0
  107. data/lib/badline/sid.rb +274 -7
  108. data/lib/badline/status.rb +251 -23
  109. data/lib/badline/storage/crt_file.rb +18 -2
  110. data/lib/badline/storage/d64_image.rb +6 -0
  111. data/lib/badline/storage/d71_image.rb +4 -0
  112. data/lib/badline/storage/d81_image.rb +4 -0
  113. data/lib/badline/storage/disk_image.rb +99 -13
  114. data/lib/badline/storage/host_directory.rb +30 -4
  115. data/lib/badline/storage/sid_file.rb +274 -0
  116. data/lib/badline/storage/song_lengths.rb +65 -0
  117. data/lib/badline/storage/t64.rb +67 -0
  118. data/lib/badline/storage/tap.rb +68 -0
  119. data/lib/badline/storage.rb +19 -0
  120. data/lib/badline/time_of_day.rb +105 -52
  121. data/lib/badline/version.rb +1 -1
  122. data/lib/badline/vic/bank.rb +14 -6
  123. data/lib/badline/vic/border_mask.rb +76 -0
  124. data/lib/badline/vic/collisions.rb +104 -0
  125. data/lib/badline/vic/color_patches.rb +33 -0
  126. data/lib/badline/vic/display_state.rb +92 -29
  127. data/lib/badline/vic/graphics_mode.rb +78 -53
  128. data/lib/badline/vic/graphics_shifter.rb +140 -0
  129. data/lib/badline/vic/register_log.rb +63 -0
  130. data/lib/badline/vic/registers.rb +3 -0
  131. data/lib/badline/vic/sequencer.rb +163 -137
  132. data/lib/badline/vic/sequencer_output.rb +116 -0
  133. data/lib/badline/vic/sprite/internal_bus.rb +49 -0
  134. data/lib/badline/vic/sprite/shifter.rb +129 -0
  135. data/lib/badline/vic/sprite.rb +275 -65
  136. data/lib/badline/vic/sprites.rb +220 -58
  137. data/lib/badline/vic.rb +389 -55
  138. data/lib/badline.rb +5 -0
  139. data/vendor/.gitkeep +0 -0
  140. metadata +104 -4
@@ -6,32 +6,114 @@ module Badline
6
6
  SECTOR_SIZE = 256
7
7
  ENTRY_SIZE = 32
8
8
  ENTRIES_PER_SECTOR = 8
9
- FILETYPE_PRG = 0x02
9
+ FILETYPES = { seq: 0x01, prg: 0x02, usr: 0x03 }.freeze
10
10
  NAME_PADDING = 0xa0
11
11
 
12
+ # Error table codes and the DOS errors they stand for: 20 to 29 are
13
+ # the read, write and ID errors, 74 is DRIVE NOT READY.
14
+ DOS_ERRORS = (2..11).to_h { |code| [code, code + 18] }.merge(15 => 74).freeze
15
+
12
16
  def initialize(path)
13
17
  @bytes = File.binread(path).bytes
18
+ @errors = split_error_table
14
19
  end
15
20
 
16
- def read_file(name)
17
- pattern = Storage.matcher(name)
18
- entry = entries.find { |e| pattern.match?(e[:name]) }
21
+ # A LOAD reads only PRG files. An OPEN names the type it wants, or
22
+ # takes the first file of any type with a nil `type`.
23
+ def read_file(name, type: :prg)
24
+ entry = find_entry(name, type)
19
25
  read_chain(entry[:track], entry[:sector]) if entry
20
26
  end
21
27
 
28
+ # The first block in a file's chain that the error table marks bad:
29
+ # its DOS error, its track and sector, and how many of the file's
30
+ # bytes come before it. Nil when the whole chain reads cleanly.
31
+ def read_error(name, type: :prg)
32
+ entry = find_entry(name, type)
33
+ return unless @errors && entry
34
+
35
+ offset = 0
36
+ each_sector(entry[:track], entry[:sector]) do |data, track, sector|
37
+ error = block_error(track, sector)
38
+ return { error:, track:, sector:, offset: } if error
39
+
40
+ offset += data[0].zero? ? data[1] - 1 : SECTOR_SIZE - 2
41
+ end
42
+ nil
43
+ end
44
+
45
+ # Raw block access for the DOS `U1` command. Returns nil for blocks
46
+ # outside the image's geometry.
47
+ def read_block(track, sector)
48
+ return unless block?(track, sector)
49
+
50
+ sector_at(track, sector)
51
+ end
52
+
53
+ # The DOS error a read of the block raises, from the error table an
54
+ # image can carry after its last block, or nil when it reads cleanly.
55
+ def block_error(track, sector)
56
+ return unless @errors && block?(track, sector)
57
+
58
+ DOS_ERRORS[@errors[track_offset(track) + sector]]
59
+ end
60
+
61
+ # The block holding the disk name and ID, at the start of the
62
+ # directory track.
63
+ def header_block = [directory_track, 0]
64
+
65
+ # The directory block a new file's entry goes into: the first with a
66
+ # free slot, or the last one when every slot is taken.
67
+ def new_entry_block
68
+ last = nil
69
+ each_sector(directory_track, directory_sector) do |data, track, sector|
70
+ return [track, sector] if (0...ENTRIES_PER_SECTOR).any? { |i| data[(i * ENTRY_SIZE) + 2].zero? }
71
+
72
+ last = [track, sector]
73
+ end
74
+ last
75
+ end
76
+
22
77
  private
23
78
 
79
+ # An error table holds one byte per block after the last one. The image
80
+ # sizes that carry one are listed exactly, since a 40-track D64 with a
81
+ # table is also a whole number of 256-byte blocks.
82
+ def split_error_table
83
+ blocks = error_tables[@bytes.length]
84
+ @bytes.pop(blocks) if blocks
85
+ end
86
+
87
+ def block?(track, sector)
88
+ return false unless track.between?(1, 255) &&
89
+ sector.between?(0, sectors_in(track) - 1)
90
+
91
+ sector_offset(track, sector) + SECTOR_SIZE <= @bytes.length
92
+ end
93
+
94
+ def find_entry(name, type)
95
+ pattern = Storage.matcher(name)
96
+ entries.find do |e|
97
+ (type.nil? || e[:type] == type) && pattern.match?(e[:name])
98
+ end
99
+ end
100
+
24
101
  def entries
25
- @entries ||= each_sector(directory_track, directory_sector)
26
- .flat_map { |data| parse_entries(data) }
102
+ @entries ||= begin
103
+ list = []
104
+ each_sector(directory_track, directory_sector) { |data| list.concat(parse_entries(data)) }
105
+ list
106
+ end
27
107
  end
28
108
 
29
109
  def parse_entries(data)
30
110
  (0...ENTRIES_PER_SECTOR).filter_map do |i|
31
111
  entry = data[i * ENTRY_SIZE, ENTRY_SIZE]
32
- next unless (entry[2] & 0x07) == FILETYPE_PRG
112
+ type = FILETYPES.key(entry[2] & 0x07)
113
+ next unless type
33
114
 
34
115
  { name: decode_name(entry[5, 16]),
116
+ type:,
35
117
  track: entry[3],
36
118
  sector: entry[4] }
37
119
  end
@@ -42,19 +124,19 @@ module Badline
42
124
  end
43
125
 
44
126
  def read_chain(track, sector)
45
- each_sector(track, sector).flat_map do |data|
46
- data[0].zero? ? data[2..data[1]] : data[2..]
127
+ bytes = []
128
+ each_sector(track, sector) do |data|
129
+ bytes.concat(data[0].zero? ? data[2..data[1]] : data[2..])
47
130
  end
131
+ bytes
48
132
  end
49
133
 
50
134
  def each_sector(track, sector)
51
- return to_enum(:each_sector, track, sector) unless block_given?
52
-
53
135
  visited = {}
54
136
  while track != 0 && !visited[[track, sector]]
55
137
  visited[[track, sector]] = true
56
138
  data = sector_at(track, sector)
57
- yield data
139
+ yield data, track, sector
58
140
  track, sector = data[0, 2]
59
141
  end
60
142
  end
@@ -64,7 +146,11 @@ module Badline
64
146
  end
65
147
 
66
148
  def sector_offset(track, sector)
67
- ((1...track).sum { |t| sectors_in(t) } + sector) * SECTOR_SIZE
149
+ (track_offset(track) + sector) * SECTOR_SIZE
150
+ end
151
+
152
+ def track_offset(track)
153
+ (1...track).sum { |t| sectors_in(t) }
68
154
  end
69
155
  end
70
156
  end
@@ -2,22 +2,28 @@
2
2
 
3
3
  module Badline
4
4
  module Storage
5
+ # A host directory served as a drive: .prg files by their host name,
6
+ # .p00 and .t64 entries by their embedded names. A host file that can't
7
+ # be read is left out, and a failed write reports false.
5
8
  class HostDirectory
6
9
  def initialize(path)
7
10
  @path = path
8
11
  end
9
12
 
10
- def read_file(name)
13
+ def read_file(name, **)
11
14
  entry = find(name)
12
15
  return unless entry
13
16
 
14
- bytes = File.binread(File.join(@path, entry[:file])).bytes
15
- P00.wraps?(bytes) ? P00.data(bytes) : bytes
17
+ archive = entry[:archive]
18
+ archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file])
16
19
  end
17
20
 
18
21
  def write_file(name, bytes)
19
22
  host_name = "#{name.downcase.tr('/', '_')}.prg"
20
23
  File.binwrite(File.join(@path, host_name), bytes.pack("C*"))
24
+ true
25
+ rescue SystemCallError
26
+ false
21
27
  end
22
28
 
23
29
  private
@@ -28,7 +34,9 @@ module Badline
28
34
  end
29
35
 
30
36
  def entries
31
- Dir.children(@path).sort.filter_map { |f| entry_for(f) }
37
+ Dir.children(@path).sort.flat_map { |f| entry_for(f) }.compact
38
+ rescue SystemCallError
39
+ []
32
40
  end
33
41
 
34
42
  def entry_for(file)
@@ -37,12 +45,30 @@ module Badline
37
45
  { name: File.basename(file, ".*").downcase, file: }
38
46
  when ".p00"
39
47
  p00_entry(file)
48
+ when ".t64"
49
+ t64_entries(file)
40
50
  end
41
51
  end
42
52
 
43
53
  def p00_entry(file)
44
54
  header = File.binread(File.join(@path, file), P00::HEADER_SIZE).bytes
45
55
  { name: P00.name(header), file: } if P00.wraps?(header)
56
+ rescue SystemCallError
57
+ nil
58
+ end
59
+
60
+ def t64_entries(file)
61
+ archive = T64.new(File.join(@path, file))
62
+ archive.names.map { |name| { name:, archive: } }
63
+ rescue T64::FormatError, SystemCallError
64
+ nil
65
+ end
66
+
67
+ def file_bytes(file)
68
+ bytes = File.binread(File.join(@path, file)).bytes
69
+ P00.wraps?(bytes) ? P00.data(bytes) : bytes
70
+ rescue SystemCallError
71
+ nil
46
72
  end
47
73
  end
48
74
  end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
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