badline 0.6.1 → 0.7.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +125 -46
  4. data/exe/badline-ruby +9 -1
  5. data/lib/badline/address_bus/extra_sids.rb +30 -0
  6. data/lib/badline/address_bus/sid_slots.rb +42 -0
  7. data/lib/badline/address_bus.rb +5 -0
  8. data/lib/badline/audio/aiff.rb +2 -2
  9. data/lib/badline/audio/bare_player.rb +23 -12
  10. data/lib/badline/audio/cli.rb +153 -58
  11. data/lib/badline/audio/jukebox.rb +121 -36
  12. data/lib/badline/audio/machine_player.rb +25 -10
  13. data/lib/badline/audio/pcm_writer.rb +10 -6
  14. data/lib/badline/audio/playback.rb +6 -5
  15. data/lib/badline/audio/queued_tune.rb +89 -0
  16. data/lib/badline/audio/renderer.rb +57 -10
  17. data/lib/badline/audio/stereo.rb +97 -0
  18. data/lib/badline/audio/terminal.rb +39 -6
  19. data/lib/badline/audio/wav.rb +1 -1
  20. data/lib/badline/audio.rb +2 -0
  21. data/lib/badline/frontend/audio_sink.rb +6 -5
  22. data/lib/badline/frontend/boot.rb +1 -1
  23. data/lib/badline/frontend/buttons.rb +90 -0
  24. data/lib/badline/frontend/filter_view.rb +96 -0
  25. data/lib/badline/frontend/info_view.rb +120 -0
  26. data/lib/badline/frontend/painter.rb +175 -0
  27. data/lib/badline/frontend/player_commands.rb +33 -0
  28. data/lib/badline/frontend/player_footer.rb +86 -0
  29. data/lib/badline/frontend/player_header.rb +60 -0
  30. data/lib/badline/frontend/player_screen.rb +124 -0
  31. data/lib/badline/frontend/player_state.rb +67 -0
  32. data/lib/badline/frontend/player_window.rb +228 -0
  33. data/lib/badline/frontend/scope.rb +42 -0
  34. data/lib/badline/frontend/sid_history.rb +175 -0
  35. data/lib/badline/frontend/sid_view.rb +140 -0
  36. data/lib/badline/frontend/visualizer_view.rb +101 -0
  37. data/lib/badline/frontend.rb +14 -0
  38. data/lib/badline/media/queue.rb +158 -0
  39. data/lib/badline/media.rb +13 -8
  40. data/lib/badline/options/help.rb +34 -5
  41. data/lib/badline/options/option.rb +1 -1
  42. data/lib/badline/options/table.rb +4 -2
  43. data/lib/badline/options/validation.rb +54 -0
  44. data/lib/badline/options.rb +62 -43
  45. data/lib/badline/sdl.rb +16 -6
  46. data/lib/badline/sid/catch_up.rb +11 -1
  47. data/lib/badline/sid/filter.rb +9 -0
  48. data/lib/badline/sid/voice.rb +6 -0
  49. data/lib/badline/sid/waveform.rb +20 -5
  50. data/lib/badline/sid.rb +35 -3
  51. data/lib/badline/storage/hvsc.rb +41 -0
  52. data/lib/badline/storage/sid_file.rb +45 -25
  53. data/lib/badline/storage/song_lengths.rb +6 -29
  54. data/lib/badline/storage/stil/credit.rb +50 -0
  55. data/lib/badline/storage/stil.rb +126 -0
  56. data/lib/badline/storage.rb +8 -0
  57. data/lib/badline/version.rb +1 -1
  58. metadata +24 -1
@@ -96,7 +96,17 @@ module Badline
96
96
 
97
97
  def record_sample(span)
98
98
  sample = @decimator.push(current_sample, span)
99
- @samples << sample if sample
99
+ return unless sample
100
+
101
+ @samples << sample
102
+ record_voices if @voice_samples
103
+ end
104
+
105
+ def record_voices
106
+ voice_samples = @voice_samples
107
+ voice_samples << (@voice1.output >> 7)
108
+ voice_samples << (@voice2.output >> 7)
109
+ voice_samples << (@voice3.output >> 7)
100
110
  end
101
111
 
102
112
  def current_sample
@@ -122,6 +122,15 @@ module Badline
122
122
  reset
123
123
  end
124
124
 
125
+ # The integrators and the registers carry over; the cutoff maps
126
+ # through the new model's curve.
127
+ def model=(model)
128
+ @model = model
129
+ @w0_table = W0.fetch(model)
130
+ @mixer_dc = MIXER_DC.fetch(model)
131
+ @w0 = @w0_table[@cutoff]
132
+ end
133
+
125
134
  def reset
126
135
  @cutoff = 0x000
127
136
  @routing = 0x0
@@ -33,6 +33,12 @@ module Badline
33
33
  @dc_offset = DC_OFFSET.fetch(model)
34
34
  end
35
35
 
36
+ def model=(model)
37
+ @waveform.model = model
38
+ @wave_zero = WAVE_ZERO.fetch(model)
39
+ @dc_offset = DC_OFFSET.fetch(model)
40
+ end
41
+
36
42
  def reset!
37
43
  @waveform.reset!
38
44
  @envelope = Envelope.new
@@ -54,17 +54,24 @@ module Badline
54
54
  attr_reader :accumulator, :shift_register, :frequency, :pulse_width, :pulse
55
55
 
56
56
  def initialize(model: :mos6581)
57
- @topbit_feedback = model != :mos8580
57
+ use_model(model)
58
58
  @tri_saw_delay = model == :mos8580
59
- @pulse_noise_read = PULSE_NOISE_READ.fetch(model)
60
- @pulse_noise_write = PULSE_NOISE_WRITE.fetch(model)
61
- @combined = Combined.tables(model)
62
- @shift_register_reset_delay = SHIFT_REGISTER_RESET_DELAY.fetch(model)
63
59
  @accumulator = POWER_ON_ACCUMULATOR
64
60
  @sync_source = @sync_dest = self
65
61
  reset!
66
62
  end
67
63
 
64
+ # The running state carries over. A 6581 never delays its shapers, so
65
+ # the 8580's delayed triangle and sawtooth start from the current phase.
66
+ def model=(model)
67
+ use_model(model)
68
+ if model == :mos8580 && !@tri_saw_delay
69
+ @delayed_sawtooth = sawtooth
70
+ @delayed_triangle = triangle
71
+ end
72
+ @tri_saw_delay = model == :mos8580
73
+ end
74
+
68
75
  # The RES line clears the registers and reseeds the LFSR, but leaves
69
76
  # the accumulator alone (SID/oscinit).
70
77
  def reset!
@@ -190,6 +197,14 @@ module Badline
190
197
 
191
198
  private
192
199
 
200
+ def use_model(model)
201
+ @topbit_feedback = model != :mos8580
202
+ @pulse_noise_read = PULSE_NOISE_READ.fetch(model)
203
+ @pulse_noise_write = PULSE_NOISE_WRITE.fetch(model)
204
+ @combined = Combined.tables(model)
205
+ @shift_register_reset_delay = SHIFT_REGISTER_RESET_DELAY.fetch(model)
206
+ end
207
+
193
208
  def shape(selected, saw = sawtooth, tri = triangle(selected))
194
209
  case selected
195
210
  when 0x1 then tri
data/lib/badline/sid.rb CHANGED
@@ -16,7 +16,9 @@ module Badline
16
16
  # $D41B-$D41C - Voice 3 oscillator/envelope - read only
17
17
  # $D41D-$D41F - Unconnected
18
18
  #
19
- # The register file mirrors every 32 bytes up to $D7FF.
19
+ # The register file mirrors every 32 bytes up to $D7FF. A second or
20
+ # third SID, which only the SID player fits, is built `at` another
21
+ # address and decodes just the 32 bytes there.
20
22
  #
21
23
  # Reading a write-only or unconnected register returns the last byte the
22
24
  # SID saw on the data bus. That value fades to $00 as the capacitance
@@ -55,8 +57,8 @@ module Badline
55
57
  attr_accessor :pots
56
58
  attr_reader :model
57
59
 
58
- def initialize(model: :mos6581, pots: nil, filter_chunk: FILTER_CHUNK)
59
- addressable_at(0xd400, length: 2**10)
60
+ def initialize(model: :mos6581, pots: nil, filter_chunk: FILTER_CHUNK, at: 0xd400)
61
+ addressable_at(at, length: at == 0xd400 ? 2**10 : 2**5)
60
62
  @model = model
61
63
  @pots = pots
62
64
  @bus_ttl_reset = BUS_TTL.fetch(model)
@@ -67,6 +69,7 @@ module Badline
67
69
  @deferred_writes = []
68
70
  @decimator = nil
69
71
  @samples = []
72
+ @voice_samples = nil
70
73
  @filter_chunk = filter_chunk
71
74
  @voices = Voice.linked(VOICES, model:)
72
75
  @voice1, @voice2, @voice3 = @voices
@@ -74,6 +77,18 @@ module Badline
74
77
  reset!
75
78
  end
76
79
 
80
+ # Switches the chip, its filter and its voices to the other model's
81
+ # values. The cycles already passed run on the old model first, and the
82
+ # running state carries over.
83
+ def model=(model)
84
+ catch_up
85
+ @model = model
86
+ @bus_ttl_reset = BUS_TTL.fetch(model)
87
+ @filter.model = model
88
+ @voices.each { |voice| voice.model = model }
89
+ update_span_rules
90
+ end
91
+
77
92
  # The RES line clears the registers, the data bus, the filter and the
78
93
  # voices, all but their accumulators. Recording carries on across it.
79
94
  def reset!
@@ -112,6 +127,23 @@ module Badline
112
127
  @samples = []
113
128
  end
114
129
 
130
+ # Also keeps each voice's own output, before the filter and the volume,
131
+ # as each recorded sample closes, for #drain_voice_samples.
132
+ def record_voices!
133
+ @voice_samples = [] if @voice_samples.nil?
134
+ end
135
+
136
+ # The voices' outputs since the last drain, three to a sample, in voice
137
+ # order, or nothing unless #record_voices! asked for them.
138
+ def drain_voice_samples
139
+ return [] if @voice_samples.nil?
140
+
141
+ catch_up
142
+ samples = @voice_samples
143
+ @voice_samples = []
144
+ samples
145
+ end
146
+
115
147
  # The samples recorded since the last drain.
116
148
  def drain_samples
117
149
  catch_up
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ # The layout of an HVSC collection: tunes in directories under its root,
6
+ # and databases about them in `DOCUMENTS`. A tune's HVSC path is its path
7
+ # from the root, as `/MUSICIANS/H/Hubbard_Rob/Commando.sid`.
8
+ module HVSC
9
+ class << self
10
+ # The named file in the DOCUMENTS directory of a collection above the
11
+ # tune, or else of the one `$HVSC_BASE` points at.
12
+ def document(tune_path, name)
13
+ candidates(tune_path).map { |dir| File.join(dir, "DOCUMENTS", name) }
14
+ .find { |path| File.file?(path) }
15
+ end
16
+
17
+ # The tune's HVSC path in the collection that holds the document, or
18
+ # nil when the tune lies outside it.
19
+ def path(tune_path, document)
20
+ root = File.dirname(File.expand_path(document), 2)
21
+ tune = File.expand_path(tune_path)
22
+ tune.start_with?("#{root}/") ? tune[root.length..] : nil
23
+ end
24
+
25
+ private
26
+
27
+ def candidates(tune_path)
28
+ dirs = []
29
+ dir = File.dirname(File.expand_path(tune_path))
30
+ until dirs.last == dir
31
+ dirs << dir
32
+ dir = File.dirname(dir)
33
+ end
34
+ base = ENV.fetch("HVSC_BASE", nil)
35
+ dirs << base if base
36
+ dirs
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -16,7 +16,7 @@ module Badline
16
16
  # BASIC's RUN handler, where a BASIC tune starts.
17
17
  BASIC_RUN = 0xa871
18
18
 
19
- # The 6502 stub that starts a tune: call init with the song number,
19
+ # The 6502 stub that starts a tune: call init with the subtune number,
20
20
  # point the KERNAL's IRQ vector at a raster handler that calls play,
21
21
  # and spin. Both calls are wrapped in the `$01` save, bank and restore
22
22
  # that puts the tune's own RAM under the CPU.
@@ -41,7 +41,7 @@ module Badline
41
41
  # pha
42
42
  # lda #iomap
43
43
  # sta $01
44
- # lda #song
44
+ # lda #subtune
45
45
  # jsr init
46
46
  # pla
47
47
  # sta $01
@@ -70,9 +70,9 @@ module Badline
70
70
  CIA1_ICR = 0xdc0d
71
71
  RASTER_IRQ_STATUS = 0xd019
72
72
 
73
- def initialize(tune, song:, base:)
73
+ def initialize(tune, subtune:, base:)
74
74
  @tune = tune
75
- @song = song
75
+ @subtune = subtune
76
76
  @base = base
77
77
  end
78
78
 
@@ -96,7 +96,7 @@ module Badline
96
96
 
97
97
  def boot
98
98
  start = [0x78] +
99
- banked(@tune.init_address, lda_imm(@song) + jsr(@tune.init_address)) +
99
+ banked(@tune.init_address, lda_imm(@subtune) + jsr(@tune.init_address)) +
100
100
  raster_irq + [0x58]
101
101
  start + jmp(boot_address + start.length)
102
102
  end
@@ -153,14 +153,13 @@ module Badline
153
153
  raise FormatError, "Truncated header" if truncated?
154
154
  raise FormatError, "No tune data" if data.empty?
155
155
  raise FormatError, "Sidplayer MUS data, which needs a MUS player" if mus?
156
- raise FormatError, "Written for #{sids} SIDs; only one is emulated" if sids > 1
157
156
  end
158
157
 
159
158
  def version = word(0x04)
160
159
  def data_offset = word(0x06)
161
160
  def play_address = word(0x0c)
162
- def songs = [word(0x0e), 1].max
163
- def start_song = word(0x10).clamp(1, songs)
161
+ def subtunes = [word(0x0e), 1].max
162
+ def start_subtune = word(0x10).clamp(1, subtunes)
164
163
  def speed = (word(0x12) << 16) + word(0x14)
165
164
  def name = text(0x16)
166
165
  def author = text(0x36)
@@ -181,22 +180,38 @@ module Badline
181
180
  # A v3 header can place a second SID at `$Dxx0` and a v4 header a
182
181
  # third, each given by its middle byte: even, and in `$d420-$d7e0` or
183
182
  # `$de00-$dfe0`. Anything else means the SID isn't there.
184
- def sids
185
- 1 + [0x7a, 0x7b].first([version - 2, 0].max).count do |offset|
183
+ def sids = 1 + sid_addresses.size
184
+
185
+ # Where the second and third SIDs sit, those the header places.
186
+ def sid_addresses
187
+ addresses = []
188
+ [0x7a, 0x7b].first([version - 2, 0].max).each do |offset|
186
189
  page = @bytes[offset].to_i
187
- page.even? && ((0x42..0x7e).cover?(page) || (0xe0..0xfe).cover?(page))
190
+ next unless page.even? && ((0x42..0x7e).cover?(page) || (0xe0..0xfe).cover?(page))
191
+
192
+ addresses << (0xd000 | (page << 4))
188
193
  end
194
+ addresses
189
195
  end
190
196
 
191
197
  # Flag bits 4-5 name the SID the tune was written for: 01 the 6581,
192
198
  # 10 the 8580, 11 either and 00 unknown. Only an 8580-only tune gets one.
193
199
  def sid_model = flags[4, 2] == 0b10 ? :mos8580 : :mos6581
194
200
 
201
+ # Each SID's model, SID 1's first. Bits 6-7 name the second SID's and
202
+ # bits 8-9 the third's the same way, and one the header leaves unknown
203
+ # or open takes SID 1's.
204
+ def sid_models
205
+ models = [sid_model]
206
+ sid_addresses.size.times { |extra| models << model_named(flags[6 + (extra * 2), 2]) }
207
+ models
208
+ end
209
+
195
210
  # Flag bits 2-3 name the video standard the same way: 01 PAL, 10 NTSC,
196
211
  # 11 either and 00 unknown. Only an NTSC-only tune gets NTSC.
197
212
  def ntsc? = flags[2, 2] == 0b10
198
213
 
199
- # HVSC keys its song length database on the whole file, header and all.
214
+ # HVSC keys its subtune length database on the whole file, header and all.
200
215
  def md5 = @md5 ||= Digest::MD5.hexdigest(@bytes.pack("C*"))
201
216
 
202
217
  # The `$01` value a routine at `address` has to run under, following
@@ -227,8 +242,8 @@ module Badline
227
242
  # A set speed bit asks for CIA timer pacing instead of a raster IRQ.
228
243
  # The bare player honours it; the machine driver paces everything off
229
244
  # the raster either way.
230
- def cia_timed?(song = start_song)
231
- speed[[song - 1, 31].min] == 1
245
+ def cia_timed?(subtune = start_subtune)
246
+ speed[[subtune - 1, 31].min] == 1
232
247
  end
233
248
 
234
249
  def driver_address
@@ -236,20 +251,20 @@ module Badline
236
251
  raise(FormatError, "No free RAM for the player")
237
252
  end
238
253
 
239
- def driver(song: start_song)
240
- Driver.new(self, song: song.clamp(1, songs) - 1, base: driver_address).bytes
254
+ def driver(subtune: start_subtune)
255
+ Driver.new(self, subtune: subtune.clamp(1, subtunes) - 1, base: driver_address).bytes
241
256
  end
242
257
 
243
258
  # What a booted machine needs in RAM besides the image before
244
259
  # `boot_command` starts the tune, as address => bytes. A BASIC tune
245
260
  # gets the end of its program in VARTAB, as LOAD leaves it, and the
246
- # song in the A, X and Y that SYS loads from `$030c-$030e`, where the
261
+ # subtune in the A, X and Y that SYS loads from `$030c-$030e`, where the
247
262
  # tune reads it with PEEK(780).
248
- def boot_memory(song: start_song)
249
- return { driver_address => driver(song:) } unless basic?
263
+ def boot_memory(subtune: start_subtune)
264
+ return { driver_address => driver(subtune:) } unless basic?
250
265
 
251
266
  { 0x2d => [low_byte(end_address), high_byte(end_address)],
252
- 0x030c => [song.clamp(1, songs) - 1] * 3 }
267
+ 0x030c => [subtune.clamp(1, subtunes) - 1] * 3 }
253
268
  end
254
269
 
255
270
  def boot_command = basic? ? "run\r" : "sys#{driver_address}\r"
@@ -289,14 +304,19 @@ module Badline
289
304
  end
290
305
 
291
306
  def driver_size
292
- @driver_size ||= Driver.new(self, song: 0, base: 0).bytes.length
307
+ @driver_size ||= Driver.new(self, subtune: 0, base: 0).bytes.length
308
+ end
309
+
310
+ def model_named(bits)
311
+ case bits
312
+ when 0b01 then :mos6581
313
+ when 0b10 then :mos8580
314
+ else sid_model
315
+ end
293
316
  end
294
317
 
295
318
  def text(offset)
296
- @bytes[offset, 32].take_while(&:positive?)
297
- .pack("C*")
298
- .force_encoding(Encoding::ISO_8859_1)
299
- .encode(Encoding::UTF_8)
319
+ Storage.latin1(@bytes[offset, 32].take_while(&:positive?))
300
320
  end
301
321
 
302
322
  # Header fields are big-endian, unlike everything else the 6502 sees.
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Badline
4
4
  module Storage
5
- # HVSC's song length database. Each tune gets a comment naming its path
5
+ # HVSC's subtune length database. Each tune gets a comment naming its path
6
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:
7
+ # `m:ss` time per subtune, optionally down to milliseconds:
8
8
  #
9
9
  # ; /DEMOS/0-9/12th_Sector_Music.sid
10
10
  # c7c299ce06ec5ccffb2261fb11b42a73=4:33.108
@@ -12,38 +12,15 @@ module Badline
12
12
  # The file runs to well over a hundred thousand entries, so a lookup
13
13
  # scans for its one key rather than building a table.
14
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
15
+ # The database lives in the DOCUMENTS directory of an HVSC
16
+ # collection.
17
+ def self.locate(tune_path) = HVSC.document(tune_path, "Songlengths.md5")
41
18
 
42
19
  def initialize(path)
43
20
  @path = path
44
21
  end
45
22
 
46
- # The tune's per-song lengths in seconds, or nil when it isn't listed.
23
+ # The tune's per-subtune lengths in seconds, or nil when it isn't listed.
47
24
  def lengths(md5)
48
25
  prefix = "#{md5.downcase}="
49
26
  File.foreach(@path) do |line|
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Storage
5
+ class STIL
6
+ # A TITLE in a STIL entry, the tune a subtune covers, with the ARTIST
7
+ # after it, and the stretch of the subtune it covers when the title
8
+ # ends in one, such as `(0:53)` or `(1:16-1:32)`. A credit without an
9
+ # end lasts until the next one takes over.
10
+ class Credit
11
+ TIMES = /\s*\((\d+):(\d\d)(?:\.\d+)?(?:-(\d+):(\d\d)(?:\.\d+)?)?\)\s*\z/
12
+
13
+ attr_reader :text, :start, :finish
14
+
15
+ def initialize(title, artist)
16
+ match = TIMES.match(title)
17
+ @start = match ? (match[1].to_i * 60) + match[2].to_i : 0
18
+ @finish = match && match[3] ? (match[3].to_i * 60) + match[4].to_i : -1
19
+ name = match ? title[0, match.begin(0)] : title
20
+ @text = artist.empty? ? name : "#{name} - #{artist}"
21
+ end
22
+
23
+ # The credits in the fields, in the order STIL lists them.
24
+ def self.list(fields)
25
+ credits = []
26
+ fields.each_with_index do |field, index|
27
+ next unless field.name == "TITLE"
28
+
29
+ following = fields[index + 1]
30
+ artist = following && following.name == "ARTIST" ? following.text : ""
31
+ credits << new(field.text.tr("\n", " "), artist.tr("\n", " "))
32
+ end
33
+ credits
34
+ end
35
+
36
+ # The text of the credit covering `seconds` into the subtune, or ""
37
+ # when none does: the last one started by then that hasn't ended.
38
+ def self.at(credits, seconds)
39
+ text = ""
40
+ credits.each do |credit|
41
+ text = credit.text if credit.covers?(seconds)
42
+ end
43
+ text
44
+ end
45
+
46
+ def covers?(seconds) = seconds >= @start && (@finish.negative? || seconds <= @finish)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/storage/stil/credit"
4
+
5
+ module Badline
6
+ module Storage
7
+ # HVSC's SID Tune Information List, `DOCUMENTS/STIL.txt`: covers,
8
+ # subtune names and composers' comments, keyed by each tune's HVSC path.
9
+ # An entry holds fields for the whole file, then a block per subtune
10
+ # that has any:
11
+ #
12
+ # /MUSICIANS/C/Crowther_Anthony/From_Ratt_To_You.sid
13
+ # COMMENT: The tune names are listed, if they were given a name, the
14
+ # remaining tunes were just called Tune 35, Tune 27 etc.
15
+ # (#16)
16
+ # NAME: G-Bust
17
+ # TITLE: Ghostbusters [from the movie]
18
+ # ARTIST: Ray Parker, Jr.
19
+ #
20
+ # A comment runs on over lines indented by nine spaces, and a blank line
21
+ # ends the entry. The file is ISO-8859-1.
22
+ class STIL
23
+ # One of NAME, AUTHOR, TITLE, ARTIST or COMMENT, with its lines of
24
+ # text joined by newlines.
25
+ class Field
26
+ attr_reader :name, :text
27
+
28
+ def initialize(name, text)
29
+ @name = name
30
+ @text = text
31
+ end
32
+
33
+ def continue(line)
34
+ @text = "#{@text}\n#{line}"
35
+ end
36
+
37
+ # The field laid out as STIL.txt does.
38
+ def lines
39
+ @text.split("\n").each_with_index.map do |line, index|
40
+ label = index.zero? ? "#{@name}:" : ""
41
+ "#{label.rjust(8)} #{line}"
42
+ end
43
+ end
44
+ end
45
+
46
+ # A tune's fields for the whole file, and for each subtune by number.
47
+ class Entry
48
+ attr_reader :fields
49
+
50
+ def initialize
51
+ @fields = []
52
+ @subtunes = {}
53
+ @subtune = 0
54
+ end
55
+
56
+ def subtune(number) = @subtunes.fetch(number, [])
57
+
58
+ # Starts the block for a subtune, which the fields read after it
59
+ # go to.
60
+ def open_subtune(subtune)
61
+ @subtune = subtune
62
+ @subtunes[subtune] = []
63
+ end
64
+
65
+ def add(field)
66
+ if @subtune.zero?
67
+ @fields << field
68
+ else
69
+ @subtunes[@subtune] << field
70
+ end
71
+ end
72
+
73
+ # Adds a line to the last field read.
74
+ def continue(line)
75
+ last = @subtune.zero? ? @fields.last : @subtunes[@subtune].last
76
+ last&.continue(line)
77
+ end
78
+ end
79
+
80
+ # The list lives in the DOCUMENTS directory of an HVSC collection.
81
+ def self.locate(tune_path) = HVSC.document(tune_path, "STIL.txt")
82
+
83
+ def initialize(path)
84
+ @path = path
85
+ end
86
+
87
+ # The entry for a tune's HVSC path, or nil when the list has none. The
88
+ # first lookup reads the whole list.
89
+ def entry(hvsc_path) = entries[hvsc_path]
90
+
91
+ def entries
92
+ @entries ||= parse(Storage.latin1(File.binread(@path).bytes))
93
+ end
94
+
95
+ private
96
+
97
+ def parse(text)
98
+ entries = {}
99
+ entry = nil
100
+ text.split("\n").each do |line|
101
+ line = line.chomp
102
+ if line.start_with?("/")
103
+ entry = Entry.new
104
+ entries[line] = entry
105
+ elsif entry.nil? || line.empty? || line.start_with?("#")
106
+ entry = nil
107
+ else
108
+ read(entry, line)
109
+ end
110
+ end
111
+ entries
112
+ end
113
+
114
+ def read(entry, line)
115
+ if line.start_with?("(#")
116
+ entry.open_subtune(line[2..].to_i)
117
+ elsif line.start_with?(" ")
118
+ entry.continue(line[9..])
119
+ else
120
+ name, text = line.lstrip.split(": ", 2)
121
+ entry.add(Field.new(name, text.to_s)) if %w[NAME AUTHOR TITLE ARTIST COMMENT].include?(name)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -12,7 +12,9 @@ require "badline/storage/t64"
12
12
  require "badline/storage/tap"
13
13
  require "badline/storage/crt_file"
14
14
  require "badline/storage/sid_file"
15
+ require "badline/storage/hvsc"
15
16
  require "badline/storage/song_lengths"
17
+ require "badline/storage/stil"
16
18
  require "badline/storage/unavailable"
17
19
 
18
20
  module Badline
@@ -62,6 +64,12 @@ module Badline
62
64
  bytes.map { |b| b.between?(0xc1, 0xda) ? b - 0x80 : b }.pack("C*")
63
65
  end
64
66
 
67
+ # Decodes ISO-8859-1 bytes, the text of .sid headers and HVSC's
68
+ # documents, to UTF-8.
69
+ def latin1(bytes)
70
+ bytes.pack("U*").force_encoding(Encoding::UTF_8)
71
+ end
72
+
65
73
  # CBM DOS drive prefix: "0:NAME" selects a drive, "@0:NAME" is
66
74
  # save-with-replace
67
75
  def strip_drive_prefix(name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end