badline 0.2.1 → 0.3.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 +49 -0
  3. data/CLAUDE.md +6 -0
  4. data/README.md +24 -6
  5. data/doc/pinned-behaviour.md +18 -6
  6. data/exe/badline +5 -2
  7. data/lib/badline/address_bus.rb +6 -4
  8. data/lib/badline/addressable.rb +1 -1
  9. data/lib/badline/audio/bare_player.rb +20 -3
  10. data/lib/badline/audio/machine_player.rb +9 -6
  11. data/lib/badline/audio/renderer.rb +3 -3
  12. data/lib/badline/audio/stream.rb +117 -0
  13. data/lib/badline/audio.rb +1 -0
  14. data/lib/badline/cartridge/easy_flash.rb +6 -2
  15. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  16. data/lib/badline/cartridge/mach5.rb +1 -1
  17. data/lib/badline/cartridge/magic_desk.rb +5 -1
  18. data/lib/badline/cartridge/ocean.rb +5 -2
  19. data/lib/badline/cartridge/rex_utility.rb +1 -1
  20. data/lib/badline/cartridge/rgcd.rb +1 -1
  21. data/lib/badline/cartridge/simons_basic.rb +2 -2
  22. data/lib/badline/cartridge/westermann.rb +2 -2
  23. data/lib/badline/cartridge.rb +8 -0
  24. data/lib/badline/checkpoint.rb +98 -0
  25. data/lib/badline/cia.rb +4 -2
  26. data/lib/badline/color_memory.rb +2 -2
  27. data/lib/badline/computer.rb +11 -6
  28. data/lib/badline/cpu.rb +2 -2
  29. data/lib/badline/gui/application.rb +41 -9
  30. data/lib/badline/kernal_trap/channel.rb +99 -7
  31. data/lib/badline/kernal_trap/drive/block_commands.rb +79 -0
  32. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  33. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  34. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  35. data/lib/badline/kernal_trap/drive.rb +88 -110
  36. data/lib/badline/kernal_trap/file.rb +0 -5
  37. data/lib/badline/kernal_trap/load.rb +6 -0
  38. data/lib/badline/kernal_trap/save.rb +5 -4
  39. data/lib/badline/media.rb +2 -2
  40. data/lib/badline/memory.rb +2 -2
  41. data/lib/badline/rom.rb +14 -5
  42. data/lib/badline/sid/catch_up.rb +110 -0
  43. data/lib/badline/sid/decimator.rb +6 -1
  44. data/lib/badline/sid/filter.rb +5 -5
  45. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  46. data/lib/badline/sid/waveform.rb +7 -7
  47. data/lib/badline/sid.rb +16 -92
  48. data/lib/badline/storage/disk_image.rb +22 -0
  49. data/lib/badline/storage/host_directory.rb +15 -3
  50. data/lib/badline/storage/sid_file.rb +36 -2
  51. data/lib/badline/storage.rb +3 -1
  52. data/lib/badline/time_of_day.rb +7 -0
  53. data/lib/badline/version.rb +1 -1
  54. data/lib/badline/vic/graphics_shifter.rb +8 -2
  55. data/lib/badline/vic/registers.rb +16 -27
  56. data/lib/badline/vic.rb +5 -2
  57. data/lib/badline.rb +1 -0
  58. metadata +8 -1
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class SID
5
+ # Running the DSP over the cycles that have passed since it last caught
6
+ # up, in spans that fast-forward the voices and end on a whole cycle.
7
+ module CatchUp
8
+ private
9
+
10
+ # Runs the cycles that have passed, landing each queued write on the
11
+ # cycle the CPU wrote it on.
12
+ def catch_up
13
+ replayed = 0
14
+ i = 0
15
+ while i < @deferred_writes.length
16
+ cycle = @deferred_writes[i]
17
+ run(cycle - replayed)
18
+ replayed = cycle
19
+ apply_write(@deferred_writes[i + 1], @deferred_writes[i + 2])
20
+ i += 3
21
+ end
22
+ run(@pending_cycles - replayed)
23
+ @deferred_writes.clear
24
+ @pending_cycles = 0
25
+ end
26
+
27
+ def run(cycles)
28
+ while cycles != 0
29
+ span = span(cycles)
30
+ fast_forward(span - 1) if span > 1
31
+ clock!(span)
32
+ cycles -= span
33
+ end
34
+ end
35
+
36
+ # The longest stretch that can be fast-forwarded. A combined waveform
37
+ # feeding back into its own oscillator steps cycle by cycle, and a span
38
+ # never runs past an MSB rise that hard-syncs the next voice, so both
39
+ # land on a whole cycle.
40
+ def span(cycles)
41
+ return 1 if @stepped
42
+
43
+ span = @synthesizing ? synthesis_span(cycles) : cycles
44
+ return span unless @synced
45
+
46
+ span = sync_span(@waveform1, span)
47
+ span = sync_span(@waveform2, span)
48
+ sync_span(@waveform3, span)
49
+ end
50
+
51
+ # The filter steps at most @filter_chunk cycles at a time, and never
52
+ # across the end of a recorded sample.
53
+ def synthesis_span(cycles)
54
+ span = [cycles, @filter_chunk].min
55
+ return span unless @decimator
56
+
57
+ [@decimator.cycles_to_close, span].min
58
+ end
59
+
60
+ # Whether a span has to step, or watch for hard sync, only changes with
61
+ # a voice's control register.
62
+ def update_span_rules
63
+ @stepped = @voices.any? { |voice| voice.waveform.stepped?(@synthesizing) }
64
+ @synced = @voices.any? { |voice| voice.waveform.sync? }
65
+ end
66
+
67
+ def sync_span(waveform, span)
68
+ return span unless waveform.sync_dest.sync?
69
+
70
+ rise = waveform.cycles_to_msb_rise
71
+ rise && rise < span ? rise : span
72
+ end
73
+
74
+ # Unrolled over the three voices: the per-cycle block calls cost
75
+ # measurably on the synthesis path.
76
+ def fast_forward(cycles)
77
+ @voice1.fast_forward(cycles)
78
+ @voice2.fast_forward(cycles)
79
+ @voice3.fast_forward(cycles)
80
+ end
81
+
82
+ # The last cycle of a span, run whole. The filter integrates the span
83
+ # from the voices' output at its end.
84
+ def clock!(span)
85
+ @voice1.cycle!
86
+ @voice2.cycle!
87
+ @voice3.cycle!
88
+ @waveform1.synchronize!
89
+ @waveform2.synchronize!
90
+ @waveform3.synchronize!
91
+ return unless @synthesizing
92
+
93
+ @filter.cycle!(@voices, span)
94
+ record_sample(span) if @decimator
95
+ end
96
+
97
+ def record_sample(span)
98
+ sample = @decimator.push(current_sample, span)
99
+ @samples << sample if sample
100
+ end
101
+
102
+ def current_sample
103
+ sample = @filter.output / SAMPLE_DIVISOR
104
+ return sample unless sample < -0x8000 || sample > 0x7fff
105
+
106
+ sample.clamp(-0x8000, 0x7fff)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -16,10 +16,11 @@ module Badline
16
16
  @phase = 0
17
17
  @sum = 0
18
18
  @count = 0
19
+ @cycles_to_close = window_cycles
19
20
  end
20
21
 
21
22
  # Cycles until the current window closes, counting the next one as 1.
22
- def cycles_to_close = (@clock_hz - @phase + @rate - 1) / @rate
23
+ attr_reader :cycles_to_close
23
24
 
24
25
  # Returns the averaged sample when these cycles close a window, and
25
26
  # nil otherwise. They must not run past the window's end.
@@ -27,14 +28,18 @@ module Badline
27
28
  @sum += sample * cycles
28
29
  @count += cycles
29
30
  @phase += @rate * cycles
31
+ @cycles_to_close -= cycles
30
32
  return unless @phase >= @clock_hz
31
33
 
32
34
  @phase -= @clock_hz
35
+ @cycles_to_close = window_cycles
33
36
  average
34
37
  end
35
38
 
36
39
  private
37
40
 
41
+ def window_cycles = (@clock_hz - @phase + @rate - 1) / @rate
42
+
38
43
  def average
39
44
  value = @sum.fdiv(@count).round
40
45
  @sum = 0
@@ -182,7 +182,7 @@ module Badline
182
182
  @unfiltered = 0
183
183
  route_voice(voice1.output >> 7, 0x1)
184
184
  route_voice(voice2.output >> 7, 0x2)
185
- if @routing.anybits?(0x4)
185
+ if @routing & 0x4 != 0
186
186
  @input += voice3.output >> 7
187
187
  elsif !@voice3_off
188
188
  @unfiltered += voice3.output >> 7
@@ -190,7 +190,7 @@ module Badline
190
190
  end
191
191
 
192
192
  def route_voice(output, bit)
193
- if @routing.anybits?(bit)
193
+ if @routing & bit == bit
194
194
  @input += output
195
195
  else
196
196
  @unfiltered += output
@@ -203,9 +203,9 @@ module Badline
203
203
 
204
204
  def filtered
205
205
  value = 0
206
- value += @lowpass if @mode.anybits?(0x1)
207
- value += @bandpass if @mode.anybits?(0x2)
208
- value += @highpass if @mode.anybits?(0x4)
206
+ value += @lowpass if @mode & 0x1 != 0
207
+ value += @bandpass if @mode & 0x2 != 0
208
+ value += @highpass if @mode & 0x4 != 0
209
209
  value
210
210
  end
211
211
  end
@@ -14,11 +14,11 @@ module Badline
14
14
  if @test
15
15
  bleed_shift_register(cycles)
16
16
  else
17
- while @shift_pipeline.positive? && cycles.positive?
17
+ while @shift_pipeline != 0 && cycles != 0
18
18
  advance
19
19
  cycles -= 1
20
20
  end
21
- skip_ahead(cycles) if cycles.positive?
21
+ skip_ahead(cycles) if cycles != 0 && @frequency != 0
22
22
  compare_pulse(((@accumulator - @frequency) & 0xffffff) >> 12)
23
23
  end
24
24
  @stale = true
@@ -59,9 +59,9 @@ module Badline
59
59
  previous = @accumulator
60
60
  @accumulator += @frequency * cycles
61
61
  rises = bit19_rises(previous, @accumulator)
62
- if rises.positive?
62
+ if rises != 0
63
63
  @shift_pipeline = pipeline_after(cycles)
64
- rises -= 1 if @shift_pipeline.positive?
64
+ rises -= 1 if @shift_pipeline != 0
65
65
  rises.times { shift_noise }
66
66
  end
67
67
  @accumulator &= 0xffffff
@@ -74,27 +74,27 @@ module Badline
74
74
  # How far a rise on one of the last two cycles has come.
75
75
  def pipeline_after(cycles)
76
76
  last = @accumulator - @frequency
77
- return 2 if bit19_rises(last, @accumulator).positive?
78
- return 1 if cycles > 1 && bit19_rises(last - @frequency, last).positive?
77
+ return 2 if bit19_rises(last, @accumulator) != 0
78
+ return 1 if cycles > 1 && bit19_rises(last - @frequency, last) != 0
79
79
 
80
80
  0
81
81
  end
82
82
 
83
83
  def drain_floating(cycles)
84
- return unless @floating_ttl.positive?
84
+ return if @floating_ttl.zero?
85
85
 
86
86
  @floating_ttl -= cycles
87
- return if @floating_ttl.positive?
87
+ return unless @floating_ttl <= 0
88
88
 
89
89
  @floating_ttl = 0
90
90
  @floating = 0x000
91
91
  end
92
92
 
93
93
  def bleed_shift_register(cycles = 1)
94
- return unless @shift_register_reset.positive?
94
+ return if @shift_register_reset.zero?
95
95
 
96
96
  @shift_register_reset -= cycles
97
- return if @shift_register_reset.positive?
97
+ return unless @shift_register_reset <= 0
98
98
 
99
99
  @shift_register_reset = 0
100
100
  @shift_register = SHIFT_REGISTER_RESET
@@ -218,7 +218,7 @@ module Badline
218
218
  def compare_pulse(phase = @accumulator >> 12) = (@pulse = @test || phase >= @pulse_width ? 0xfff : 0x000)
219
219
 
220
220
  # More than one bit set in the selection.
221
- def combined?(selected) = selected.anybits?(selected - 1)
221
+ def combined?(selected) = selected & (selected - 1) != 0
222
222
 
223
223
  # The one point in the cycle where the waveform selector drives the DAC:
224
224
  # either it latches a fresh value, or nothing drives the line and the
@@ -259,10 +259,10 @@ module Badline
259
259
  def advance
260
260
  previous = @accumulator
261
261
  @accumulator = (previous + @frequency) & 0xffffff
262
- @msb_rising = previous.nobits?(MSB) && @accumulator.anybits?(MSB)
263
- if previous.nobits?(0x080000) && @accumulator.anybits?(0x080000)
262
+ @msb_rising = @accumulator & ~previous & MSB != 0
263
+ if @accumulator & ~previous & 0x080000 != 0
264
264
  @shift_pipeline = 2
265
- elsif @shift_pipeline.positive? && (@shift_pipeline -= 1).zero?
265
+ elsif @shift_pipeline != 0 && (@shift_pipeline -= 1).zero?
266
266
  write_shift_register(write_back(@selected, @output)) if release_writes_back?(@selected, @selected)
267
267
  shift_noise
268
268
  end
@@ -277,12 +277,12 @@ module Badline
277
277
  # TriXOR = !Saw & ((!V3 & Ring) ^ bit23), where V3 is the modulating
278
278
  # voice's MSB (SID/ringmod).
279
279
  def inverted?(selected)
280
- return false if selected.anybits?(0x2)
280
+ return false if selected & 0x2 != 0
281
281
 
282
- msb = @accumulator.anybits?(MSB)
282
+ msb = @accumulator & MSB != 0
283
283
  return msb unless @ring
284
284
 
285
- msb != @sync_source.accumulator.nobits?(MSB)
285
+ msb == (@sync_source.accumulator & MSB != 0)
286
286
  end
287
287
  end
288
288
  end
data/lib/badline/sid.rb CHANGED
@@ -5,6 +5,7 @@ require "badline/sid/envelope"
5
5
  require "badline/sid/voice"
6
6
  require "badline/sid/filter"
7
7
  require "badline/sid/decimator"
8
+ require "badline/sid/catch_up"
8
9
 
9
10
  module Badline
10
11
  # SID (Sound Interface Device) chip, in either the 6581 or the 8580
@@ -31,6 +32,7 @@ module Badline
31
32
  # DC offset that the RC network on the C64 board filters out.
32
33
  class SID
33
34
  include Addressable
35
+ include CatchUp
34
36
 
35
37
  VOICES = 3
36
38
 
@@ -58,6 +60,7 @@ module Badline
58
60
  @bus_ttl_reset = BUS_TTL.fetch(model)
59
61
  @filter = Filter.new(model:)
60
62
  @synthesizing = false
63
+ @stepped = @synced = false
61
64
  @pending_cycles = 0
62
65
  @deferred_writes = []
63
66
  @decimator = nil
@@ -78,6 +81,7 @@ module Badline
78
81
  @bus_ttl = 0
79
82
  @voices.each(&:reset!)
80
83
  @filter.reset
84
+ update_span_rules
81
85
  end
82
86
 
83
87
  # The DSP only counts cycles as they pass. Whatever asks for its state
@@ -90,17 +94,19 @@ module Badline
90
94
  return if @synthesizing
91
95
 
92
96
  @synthesizing = true
97
+ update_span_rules
93
98
  catch_up
94
99
  end
95
100
 
96
101
  # Starts collecting the output, averaged down to `rate` samples a
97
102
  # second, for #drain_samples. `filter_chunk` overrides the one the SID
98
- # was built with, for a machine that built its own.
99
- def record(rate:, filter_chunk: @filter_chunk)
103
+ # was built with, for a machine that built its own. `clock_hz` is the
104
+ # rate the SID's cycles are taken to run at.
105
+ def record(rate:, filter_chunk: @filter_chunk, clock_hz: TimeOfDay::CLOCK_HZ)
100
106
  synthesize!
101
107
  catch_up
102
108
  @filter_chunk = filter_chunk
103
- @decimator = Decimator.new(clock_hz: TimeOfDay::CLOCK_HZ, rate:)
109
+ @decimator = Decimator.new(clock_hz:, rate:)
104
110
  @samples = []
105
111
  end
106
112
 
@@ -152,7 +158,7 @@ module Badline
152
158
  end
153
159
 
154
160
  def peek(addr)
155
- case index(addr) % (2**5)
161
+ case offset_of(addr) % (2**5)
156
162
  when 0x19 then latch(pots ? pots.pot_x : 0xff)
157
163
  when 0x1a then latch(pots ? pots.pot_y : 0xff)
158
164
  when 0x1b then latch(osc3)
@@ -162,7 +168,7 @@ module Badline
162
168
  end
163
169
 
164
170
  def poke(addr, value)
165
- reg = index(addr) % (2**5)
171
+ reg = offset_of(addr) % (2**5)
166
172
  write_register(reg, value) if reg <= 0x18
167
173
  latch(value)
168
174
  end
@@ -172,90 +178,6 @@ module Badline
172
178
 
173
179
  private
174
180
 
175
- # Runs the cycles that have passed, landing each queued write on the
176
- # cycle the CPU wrote it on.
177
- def catch_up
178
- replayed = 0
179
- @deferred_writes.each do |cycle, reg, value|
180
- run(cycle - replayed)
181
- replayed = cycle
182
- apply_write(reg, value)
183
- end
184
- run(@pending_cycles - replayed)
185
- @deferred_writes.clear
186
- @pending_cycles = 0
187
- end
188
-
189
- def run(cycles)
190
- while cycles.positive?
191
- span = span(cycles)
192
- fast_forward(span - 1) if span > 1
193
- clock!(span)
194
- cycles -= span
195
- end
196
- end
197
-
198
- # The longest stretch that can be fast-forwarded. A combined waveform
199
- # feeding back into its own oscillator steps cycle by cycle, and a span
200
- # never runs past an MSB rise that hard-syncs the next voice, so both
201
- # land on a whole cycle.
202
- def span(cycles)
203
- synthesizing = @synthesizing
204
- return 1 if @waveform1.stepped?(synthesizing) || @waveform2.stepped?(synthesizing) ||
205
- @waveform3.stepped?(synthesizing)
206
-
207
- span = synthesizing ? synthesis_span(cycles) : cycles
208
- span = sync_span(@waveform1, span)
209
- span = sync_span(@waveform2, span)
210
- sync_span(@waveform3, span)
211
- end
212
-
213
- # The filter steps at most @filter_chunk cycles at a time, and never
214
- # across the end of a recorded sample.
215
- def synthesis_span(cycles)
216
- span = [cycles, @filter_chunk].min
217
- return span unless @decimator
218
-
219
- [@decimator.cycles_to_close, span].min
220
- end
221
-
222
- def sync_span(waveform, span)
223
- return span unless waveform.sync_dest.sync?
224
-
225
- rise = waveform.cycles_to_msb_rise
226
- rise && rise < span ? rise : span
227
- end
228
-
229
- # Unrolled over the three voices: the per-cycle block calls cost
230
- # measurably on the synthesis path.
231
- def fast_forward(cycles)
232
- @voice1.fast_forward(cycles)
233
- @voice2.fast_forward(cycles)
234
- @voice3.fast_forward(cycles)
235
- end
236
-
237
- # The last cycle of a span, run whole. The filter integrates the span
238
- # from the voices' output at its end.
239
- def clock!(span)
240
- @voice1.cycle!
241
- @voice2.cycle!
242
- @voice3.cycle!
243
- @waveform1.synchronize!
244
- @waveform2.synchronize!
245
- @waveform3.synchronize!
246
- return unless @synthesizing
247
-
248
- @filter.cycle!(@voices, span)
249
- record_sample(span) if @decimator
250
- end
251
-
252
- def record_sample(span)
253
- sample = @decimator.push(current_sample, span)
254
- @samples << sample if sample
255
- end
256
-
257
- def current_sample = (@filter.output / SAMPLE_DIVISOR).clamp(-0x8000, 0x7fff)
258
-
259
181
  def latch(value)
260
182
  @bus_ttl = @bus_ttl_reset
261
183
  @bus_value = value
@@ -268,8 +190,8 @@ module Badline
268
190
 
269
191
  def write_register(reg, value)
270
192
  @registers.poke(reg, value)
271
- catch_up if @deferred_writes.length == DEFERRED_WRITES
272
- @deferred_writes << [@pending_cycles, reg, value]
193
+ catch_up if @deferred_writes.length == DEFERRED_WRITES * 3
194
+ @deferred_writes.push(@pending_cycles, reg, value)
273
195
  end
274
196
 
275
197
  def apply_write(reg, value)
@@ -283,7 +205,9 @@ module Badline
283
205
  when 1 then voice.waveform.frequency_high = value
284
206
  when 2 then voice.waveform.pulse_width_low = value
285
207
  when 3 then voice.waveform.pulse_width_high = value
286
- when 4 then voice.control = value
208
+ when 4
209
+ voice.control = value
210
+ update_span_rules
287
211
  when 5 then voice.envelope.attack_decay = value
288
212
  when 6 then voice.envelope.sustain_release = value
289
213
  end
@@ -42,6 +42,28 @@ module Badline
42
42
  nil
43
43
  end
44
44
 
45
+ # The track and sector a file's chain starts at.
46
+ def first_block(name, type: :prg)
47
+ entry = find_entry(name, type)
48
+ [entry[:track], entry[:sector]] if entry
49
+ end
50
+
51
+ # The bytes of the chain that starts at the block, or nil when the
52
+ # block is outside the image's geometry.
53
+ def read_file_at(track, sector)
54
+ read_chain(track, sector) if block?(track, sector)
55
+ end
56
+
57
+ # The track and sector of the last block in a file's chain.
58
+ def last_block(name, type: :prg)
59
+ entry = find_entry(name, type)
60
+ return unless entry
61
+
62
+ last = nil
63
+ each_sector(entry[:track], entry[:sector]) { |_data, track, sector| last = [track, sector] }
64
+ last
65
+ end
66
+
45
67
  # Raw block access for the DOS `U1` command. Returns nil for blocks
46
68
  # outside the image's geometry.
47
69
  def read_block(track, sector)
@@ -3,9 +3,12 @@
3
3
  module Badline
4
4
  module Storage
5
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.
6
+ # .p00 and .t64 entries by their embedded names. A .prg the host can't
7
+ # read is still listed but fails to read, a .p00 or .t64 whose names
8
+ # can't be read is left out, and a failed write reports false.
8
9
  class HostDirectory
10
+ NO_SYNC = 21
11
+
9
12
  def initialize(path)
10
13
  @path = path
11
14
  end
@@ -15,7 +18,16 @@ module Badline
15
18
  return unless entry
16
19
 
17
20
  archive = entry[:archive]
18
- archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file])
21
+ archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file]) || []
22
+ end
23
+
24
+ # A listed file the host can't read fails before its first byte, as
25
+ # a 1541 does on a disk it finds no sync on.
26
+ def read_error(name, **)
27
+ entry = find(name)
28
+ return unless entry && entry[:file] && !file_bytes(entry[:file])
29
+
30
+ { error: NO_SYNC, track: 0, sector: 0, offset: 0 }
19
31
  end
20
32
 
21
33
  def write_file(name, bytes)
@@ -13,6 +13,9 @@ module Badline
13
13
 
14
14
  V1_HEADER_SIZE = 0x76
15
15
 
16
+ # BASIC's RUN handler, where a BASIC tune starts.
17
+ BASIC_RUN = 0xa871
18
+
16
19
  # The 6502 stub that starts a tune: call init with the song number,
17
20
  # point the KERNAL's IRQ vector at a raster handler that calls play,
18
21
  # and spin. Both calls are wrapped in the `$01` save, bank and restore
@@ -171,6 +174,10 @@ module Badline
171
174
  # code.
172
175
  def mus? = psid? && flags[0] == 1
173
176
 
177
+ # Flag bit 1 marks an RSID body as a BASIC program, which RUN starts
178
+ # instead of a call to init.
179
+ def basic? = !psid? && flags[1] == 1
180
+
174
181
  # A v3 header can place a second SID at `$Dxx0` and a v4 header a
175
182
  # third, each given by its middle byte: even, and in `$d420-$d7e0` or
176
183
  # `$de00-$dfe0`. Anything else means the SID isn't there.
@@ -185,16 +192,22 @@ module Badline
185
192
  # 10 the 8580, 11 either and 00 unknown. Only an 8580-only tune gets one.
186
193
  def sid_model = flags[4, 2] == 0b10 ? :mos8580 : :mos6581
187
194
 
195
+ # Flag bits 2-3 name the video standard the same way: 01 PAL, 10 NTSC,
196
+ # 11 either and 00 unknown. Only an NTSC-only tune gets NTSC.
197
+ def ntsc? = flags[2, 2] == 0b10
198
+
188
199
  # HVSC keys its song length database on the whole file, header and all.
189
200
  def md5 = @md5 ||= Digest::MD5.hexdigest(@bytes.pack("C*"))
190
201
 
191
202
  # The `$01` value a routine at `address` has to run under, following
192
203
  # libsidplayfp's iomap: RAM under BASIC from `$a000`, RAM under both
193
204
  # 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.
205
+ # KERNAL from `$e000`. A routine below `$a000` still gets BASIC banked
206
+ # out when the image reaches into it, as VSID does, since it may call
207
+ # into the part that lies there. An RSID tune banks itself and gets nil.
195
208
  def bank_for(address)
196
209
  return unless psid?
197
- return 0x37 if address < 0xa000
210
+ return (under_basic? ? 0x36 : 0x37) if address < 0xa000
198
211
  return 0x36 if address < 0xd000
199
212
  return 0x34 if address < 0xe000
200
213
 
@@ -227,6 +240,23 @@ module Badline
227
240
  Driver.new(self, song: song.clamp(1, songs) - 1, base: driver_address).bytes
228
241
  end
229
242
 
243
+ # What a booted machine needs in RAM besides the image before
244
+ # `boot_command` starts the tune, as address => bytes. A BASIC tune
245
+ # 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
247
+ # tune reads it with PEEK(780).
248
+ def boot_memory(song: start_song)
249
+ return { driver_address => driver(song:) } unless basic?
250
+
251
+ { 0x2d => [low_byte(end_address), high_byte(end_address)],
252
+ 0x030c => [song.clamp(1, songs) - 1] * 3 }
253
+ end
254
+
255
+ def boot_command = basic? ? "run\r" : "sys#{driver_address}\r"
256
+
257
+ # Where the CPU first lands once `boot_command` has started the tune.
258
+ def entry_address = basic? ? BASIC_RUN : driver_address
259
+
230
260
  private
231
261
 
232
262
  def truncated?
@@ -237,6 +267,10 @@ module Badline
237
267
  @body ||= @bytes[data_offset..] || []
238
268
  end
239
269
 
270
+ def under_basic?
271
+ load_address < 0xc000 && end_address > 0xa000
272
+ end
273
+
240
274
  # The tape buffer and the free block below the stack page, neither
241
275
  # of which BASIC touches.
242
276
  def candidates
@@ -36,8 +36,10 @@ module Badline
36
36
  end
37
37
 
38
38
  # CBM-style filename pattern: "*" and "?" wildcards, case-insensitive.
39
+ # The DOS stops comparing at the first "*", so whatever follows it
40
+ # is ignored.
39
41
  def matcher(name)
40
- escaped = Regexp.escape(name.downcase)
42
+ escaped = Regexp.escape(name.downcase.sub(/\*.*/m, "*"))
41
43
  .gsub('\*', ".*")
42
44
  .gsub('\?', ".")
43
45
  Regexp.new("\\A#{escaped}\\z")
@@ -49,6 +49,13 @@ module Badline
49
49
  @latch[:hours]
50
50
  end
51
51
 
52
+ # The clock and alarm as stored, read without latching the clock.
53
+ def registers
54
+ [@clock[:tenths], @clock[:seconds], @clock[:minutes], @clock[:hours],
55
+ @alarm[:tenths], @alarm[:seconds], @alarm[:minutes], @alarm[:hours],
56
+ @stopped ? 1 : 0, @latch ? 1 : 0]
57
+ end
58
+
52
59
  def write(field, value, alarm:)
53
60
  # The frequency counter is held clear while the clock is stopped, and
54
61
  # starts counting again from the write to tenths that restarts it.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -9,7 +9,8 @@ module Badline
9
9
  #
10
10
  # The byte a group draws loads at pixel XSCROLL. ECM and BMM take hold at
11
11
  # pixel 4 as they rise and at pixel 6 as they fall (the 6569's colour
12
- # latency). MCM takes hold at pixel 4 for the colour lookup but only at
12
+ # latency), except that BMM falls at pixel 5 out of any mode but hi-res
13
+ # bitmap. MCM takes hold at pixel 4 for the colour lookup but only at
13
14
  # pixel 7 for how the register is read, where a rising MCM also resets
14
15
  # the multicolour flip-flop. An MCM that falls out of the invalid
15
16
  # ECM+MCM mode is a pixel later on both counts: the lookup changes at
@@ -67,7 +68,7 @@ module Badline
67
68
  case pixel
68
69
  when 0 then end_late_read
69
70
  when 4 then step_lookup(mode)
70
- when 5 then @mode_lookup &= ~1 if @late_mcm
71
+ when 5 then step_early_fall(mode)
71
72
  when 6 then @mode_lookup &= mode | 1
72
73
  when 7 then step_read
73
74
  end
@@ -78,6 +79,11 @@ module Badline
78
79
  @mode_lookup = (@mode_lookup & ~1) | (mode & 1) | (@late_mcm ? 1 : 0) | (mode & ECM_BMM)
79
80
  end
80
81
 
82
+ def step_early_fall(mode)
83
+ @mode_lookup &= ~1 if @late_mcm
84
+ @mode_lookup &= mode | ~BMM unless @mode_lookup == BMM
85
+ end
86
+
81
87
  def step_read
82
88
  return @late_read = true if @late_mcm
83
89