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
data/lib/badline/sid.rb CHANGED
@@ -1,25 +1,292 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/sid/waveform"
4
+ require "badline/sid/envelope"
5
+ require "badline/sid/voice"
6
+ require "badline/sid/filter"
7
+ require "badline/sid/decimator"
8
+
3
9
  module Badline
10
+ # SID (Sound Interface Device) chip, in either the 6581 or the 8580
11
+ # revision.
12
+ #
13
+ # $D400-$D418 - Voices, filter and volume - write only
14
+ # $D419-$D41A - POTX/POTY paddle inputs - read only
15
+ # $D41B-$D41C - Voice 3 oscillator/envelope - read only
16
+ # $D41D-$D41F - Unconnected
17
+ #
18
+ # The register file mirrors every 32 bytes up to $D7FF.
19
+ #
20
+ # Reading a write-only or unconnected register returns the last byte the
21
+ # SID saw on the data bus. That value fades to $00 as the capacitance
22
+ # holding it drains. Paddles and the 1351 mouse attach as a pots source
23
+ # responding to #pot_x and #pot_y; with none attached the lines read $FF.
24
+ #
25
+ # Three voices feed the filter and the master volume; #output and #sample
26
+ # read out the mixed result, and #record collects it at an audio rate for
27
+ # #drain_samples. The DSP is clocked lazily and catches up when something
28
+ # asks for its state.
29
+ #
30
+ # The 6581 drives its voices well above ground, so the mix carries a large
31
+ # DC offset that the RC network on the C64 board filters out.
4
32
  class SID
5
33
  include Addressable
6
34
 
7
- def initialize
35
+ VOICES = 3
36
+
37
+ # Cycles the data bus holds a value, measured through SID/bitfade.
38
+ BUS_TTL = { mos6581: 0x1d00, mos8580: 0xa2000 }.freeze
39
+
40
+ # Scales the filter's 20-bit mix down to a signed 16-bit sample.
41
+ SAMPLE_DIVISOR = ((0xfff * 0xff) >> 7) * 3 * 15 * 2 / (2**16)
42
+
43
+ # Writes the DSP queues between catch-ups. A full queue catches up
44
+ # there and then, so no write loses its timing.
45
+ DEFERRED_WRITES = 0x400
46
+
47
+ # Cycles the filter integrates in one step while synthesizing, after
48
+ # reSID's delta_t_flt. 1 makes the audio exact, at a per-cycle cost.
49
+ FILTER_CHUNK = 4
50
+
51
+ attr_accessor :pots
52
+ attr_reader :model
53
+
54
+ def initialize(model: :mos6581, pots: nil, filter_chunk: FILTER_CHUNK)
8
55
  addressable_at(0xd400, length: 2**10)
56
+ @model = model
57
+ @pots = pots
58
+ @bus_ttl_reset = BUS_TTL.fetch(model)
59
+ @filter = Filter.new(model:)
60
+ @synthesizing = false
61
+ @pending_cycles = 0
62
+ @deferred_writes = []
63
+ @decimator = nil
64
+ @samples = []
65
+ @filter_chunk = filter_chunk
66
+ @voices = Voice.linked(VOICES, model:)
67
+ @voice1, @voice2, @voice3 = @voices
68
+ @waveform1, @waveform2, @waveform3 = @voices.map(&:waveform)
69
+ reset!
70
+ end
71
+
72
+ # The RES line clears the registers, the data bus, the filter and the
73
+ # voices, all but their accumulators. Recording carries on across it.
74
+ def reset!
75
+ catch_up
9
76
  @registers = Memory.new(length: 2**5)
77
+ @bus_value = 0x00
78
+ @bus_ttl = 0
79
+ @voices.each(&:reset!)
80
+ @filter.reset
81
+ end
82
+
83
+ # The DSP only counts cycles as they pass. Whatever asks for its state
84
+ # catches it up, a span at a time: each span fast-forwards the voices
85
+ # and ends on one whole cycle, so everything the CPU can read stays
86
+ # exact. The filter only runs once something wants audio.
87
+ def synthesizing? = @synthesizing
88
+
89
+ def synthesize!
90
+ return if @synthesizing
91
+
92
+ @synthesizing = true
93
+ catch_up
94
+ end
95
+
96
+ # Starts collecting the output, averaged down to `rate` samples a
97
+ # 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)
100
+ synthesize!
101
+ catch_up
102
+ @filter_chunk = filter_chunk
103
+ @decimator = Decimator.new(clock_hz: TimeOfDay::CLOCK_HZ, rate:)
104
+ @samples = []
105
+ end
106
+
107
+ # The samples recorded since the last drain.
108
+ def drain_samples
109
+ catch_up
110
+ samples = @samples
111
+ @samples = []
112
+ samples
113
+ end
114
+
115
+ def voices
116
+ catch_up
117
+ @voices
118
+ end
119
+
120
+ def filter
121
+ catch_up
122
+ @filter
123
+ end
124
+
125
+ # Voice 3's oscillator and envelope, the only synthesis state the CPU
126
+ # can see. Programs poll OSC3 with the noise waveform selected for
127
+ # random numbers, and ENV3 to time hard restarts.
128
+ def osc3
129
+ catch_up
130
+ @waveform3.osc3 >> 4
131
+ end
132
+
133
+ def env3
134
+ catch_up
135
+ @voice3.envelope.env3
136
+ end
137
+
138
+ def output
139
+ synthesize!
140
+ catch_up
141
+ @filter.output
142
+ end
143
+
144
+ def sample
145
+ output
146
+ current_sample
147
+ end
148
+
149
+ def cycle!
150
+ age_bus if @bus_ttl != 0
151
+ @pending_cycles += 1
10
152
  end
11
153
 
12
154
  def peek(addr)
13
- i = index(addr) % (2**5)
14
- case i
15
- when 0x1d..0x1f then 0xff # Unused memory
16
- else @registers.peek(i)
155
+ case index(addr) % (2**5)
156
+ when 0x19 then latch(pots ? pots.pot_x : 0xff)
157
+ when 0x1a then latch(pots ? pots.pot_y : 0xff)
158
+ when 0x1b then latch(osc3)
159
+ when 0x1c then latch(env3)
160
+ else @bus_value
17
161
  end
18
162
  end
19
163
 
20
164
  def poke(addr, value)
21
- i = index(addr) % (2**5)
22
- @registers.poke(i, value)
165
+ reg = index(addr) % (2**5)
166
+ write_register(reg, value) if reg <= 0x18
167
+ latch(value)
168
+ end
169
+
170
+ # Register contents as written, for save states and debugging.
171
+ def register(reg) = @registers.peek(reg)
172
+
173
+ private
174
+
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
+ def latch(value)
260
+ @bus_ttl = @bus_ttl_reset
261
+ @bus_value = value
262
+ end
263
+
264
+ def age_bus
265
+ @bus_ttl -= 1
266
+ @bus_value = 0x00 if @bus_ttl.zero?
267
+ end
268
+
269
+ def write_register(reg, value)
270
+ @registers.poke(reg, value)
271
+ catch_up if @deferred_writes.length == DEFERRED_WRITES
272
+ @deferred_writes << [@pending_cycles, reg, value]
273
+ end
274
+
275
+ def apply_write(reg, value)
276
+ reg < 0x15 ? write_voice(reg, value) : @filter.write(reg, value)
277
+ end
278
+
279
+ def write_voice(reg, value)
280
+ voice = @voices[reg / 7]
281
+ case reg % 7
282
+ when 0 then voice.waveform.frequency_low = value
283
+ when 1 then voice.waveform.frequency_high = value
284
+ when 2 then voice.waveform.pulse_width_low = value
285
+ when 3 then voice.waveform.pulse_width_high = value
286
+ when 4 then voice.control = value
287
+ when 5 then voice.envelope.attack_decay = value
288
+ when 6 then voice.envelope.sustain_release = value
289
+ end
23
290
  end
24
291
  end
25
292
  end
@@ -4,6 +4,38 @@ module Badline
4
4
  class Status
5
5
  attr_reader :value, :flags, :bitmask, :low_mask, :high_mask
6
6
 
7
+ class << self
8
+ # Each flag layout gets a subclass of its own, with the flag accessors
9
+ # compiled as ordinary methods rather than closures.
10
+ def new(flags = [], value: 0x0)
11
+ return super unless equal?(Status)
12
+
13
+ layout(flags).new(flags, value:)
14
+ end
15
+
16
+ private
17
+
18
+ def layout(flags)
19
+ @layouts ||= {}
20
+ @layouts[flags] ||= Class.new(Status) { define_accessors(flags) }
21
+ end
22
+
23
+ def define_accessors(flags)
24
+ flags.each_with_index do |name, i|
25
+ next unless name.is_a?(Symbol)
26
+
27
+ mask = 1 << i
28
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
29
+ def #{name}=(enabled) # def carry=(enabled)
30
+ @value = enabled && enabled != 0 ? @value | #{mask} : @value & #{~mask} # @value = ... | 0x01 : ... & ~0x01
31
+ end # end
32
+ def #{name}? = @value & #{mask} != 0 # def carry? = @value & 0x01 != 0
33
+ def #{name} = @value & #{mask} == 0 ? 0 : 1 # def carry = ... ? 0 : 1
34
+ RUBY
35
+ end
36
+ end
37
+ end
38
+
7
39
  def initialize(flags = [], value: 0x0)
8
40
  @flags = flags
9
41
 
@@ -12,8 +44,6 @@ module Badline
12
44
  @high_mask = create_mask { |f| f.is_a?(Integer) && f == 1 }
13
45
 
14
46
  self.value = value
15
-
16
- define_accessors!
17
47
  end
18
48
 
19
49
  def value=(new_value)
@@ -27,30 +57,5 @@ module Badline
27
57
  mask + (predicate.call(flag) ? 1 << i : 0)
28
58
  end
29
59
  end
30
-
31
- def define_accessors!
32
- flags.each_with_index do |name, i|
33
- next unless name.is_a?(Symbol)
34
-
35
- mask = 1 << i
36
- define_singleton_method("#{name}=") do |enabled|
37
- update(mask, enabled)
38
- end
39
- define_singleton_method("#{name}?") do
40
- !@value.nobits?(mask)
41
- end
42
- define_singleton_method(name) do
43
- @value.nobits?(mask) ? 0 : 1
44
- end
45
- end
46
- end
47
-
48
- def update(mask, enabled)
49
- self.value = if enabled && enabled != 0
50
- value | mask
51
- else
52
- value & ~mask
53
- end
54
- end
55
60
  end
56
61
  end
@@ -12,7 +12,21 @@ module Badline
12
12
 
13
13
  Chip = Data.define(:chip_type, :bank, :address, :data)
14
14
 
15
- attr_reader :hardware_type, :exrom, :game, :name, :chips
15
+ attr_reader :hardware_type, :subtype, :exrom, :game, :name, :chips
16
+
17
+ # What .write needs of an image: the fields a parsed CRTFile reads.
18
+ Image = Data.define(:hardware_type, :subtype, :exrom, :game, :name, :chips)
19
+
20
+ def self.write(path, image)
21
+ header = SIGNATURE +
22
+ [0x40, 0x0100, image.hardware_type, image.exrom, image.game, image.subtype].pack("NnnCCCx5") +
23
+ [image.name.to_s.b[0, 32]].pack("a32")
24
+ packets = image.chips.map do |chip|
25
+ ["CHIP", CHIP_HEADER_SIZE + chip.data.length, chip.chip_type, chip.bank, chip.address,
26
+ chip.data.length].pack("a4Nn4") + chip.data.pack("C*")
27
+ end
28
+ File.binwrite(path, header + packets.join)
29
+ end
16
30
 
17
31
  def initialize(path)
18
32
  parse(File.binread(path))
@@ -26,13 +40,15 @@ module Badline
26
40
  @hardware_type = bytes[0x16, 2].unpack1("n")
27
41
  @exrom = bytes.getbyte(0x18)
28
42
  @game = bytes.getbyte(0x19)
43
+ @subtype = bytes.getbyte(0x1a)
29
44
  @name = bytes[0x20, 32].unpack1("Z*")
30
45
  @chips = parse_chips(bytes, bytes[0x10, 4].unpack1("N"))
31
46
  end
32
47
 
48
+ # Trailing bytes too short to hold a CHIP header are ignored.
33
49
  def parse_chips(bytes, offset)
34
50
  chips = []
35
- while offset < bytes.length
51
+ while offset + CHIP_HEADER_SIZE <= bytes.length
36
52
  chip, offset = parse_chip(bytes, offset)
37
53
  chips << chip
38
54
  end
@@ -6,21 +6,62 @@ 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)
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)
17
24
  pattern = Storage.matcher(name)
18
- entry = entries.find { |e| pattern.match?(e[:name]) }
25
+ entry = entries.find do |e|
26
+ (type.nil? || e[:type] == type) && pattern.match?(e[:name])
27
+ end
19
28
  read_chain(entry[:track], entry[:sector]) if entry
20
29
  end
21
30
 
31
+ # Raw block access for the DOS `U1` command. Returns nil for blocks
32
+ # outside the image's geometry.
33
+ def read_block(track, sector)
34
+ return unless block?(track, sector)
35
+
36
+ sector_at(track, sector)
37
+ end
38
+
39
+ # The DOS error a read of the block raises, from the error table an
40
+ # image can carry after its last block, or nil when it reads cleanly.
41
+ def block_error(track, sector)
42
+ return unless @errors && block?(track, sector)
43
+
44
+ DOS_ERRORS[@errors[track_offset(track) + sector]]
45
+ end
46
+
22
47
  private
23
48
 
49
+ # An error table holds one byte per block, so the image is 257 bytes
50
+ # per block rather than 256.
51
+ def split_error_table
52
+ return unless (@bytes.length % (SECTOR_SIZE + 1)).zero? &&
53
+ !(@bytes.length % SECTOR_SIZE).zero?
54
+
55
+ @bytes.pop(@bytes.length / (SECTOR_SIZE + 1))
56
+ end
57
+
58
+ def block?(track, sector)
59
+ return false unless track.between?(1, 255) &&
60
+ sector.between?(0, sectors_in(track) - 1)
61
+
62
+ sector_offset(track, sector) + SECTOR_SIZE <= @bytes.length
63
+ end
64
+
24
65
  def entries
25
66
  @entries ||= each_sector(directory_track, directory_sector)
26
67
  .flat_map { |data| parse_entries(data) }
@@ -29,9 +70,11 @@ module Badline
29
70
  def parse_entries(data)
30
71
  (0...ENTRIES_PER_SECTOR).filter_map do |i|
31
72
  entry = data[i * ENTRY_SIZE, ENTRY_SIZE]
32
- next unless (entry[2] & 0x07) == FILETYPE_PRG
73
+ type = FILETYPES.key(entry[2] & 0x07)
74
+ next unless type
33
75
 
34
76
  { name: decode_name(entry[5, 16]),
77
+ type:,
35
78
  track: entry[3],
36
79
  sector: entry[4] }
37
80
  end
@@ -64,7 +107,11 @@ module Badline
64
107
  end
65
108
 
66
109
  def sector_offset(track, sector)
67
- ((1...track).sum { |t| sectors_in(t) } + sector) * SECTOR_SIZE
110
+ (track_offset(track) + sector) * SECTOR_SIZE
111
+ end
112
+
113
+ def track_offset(track)
114
+ (1...track).sum { |t| sectors_in(t) }
68
115
  end
69
116
  end
70
117
  end
@@ -7,12 +7,12 @@ module Badline
7
7
  @path = path
8
8
  end
9
9
 
10
- def read_file(name)
10
+ def read_file(name, **)
11
11
  entry = find(name)
12
12
  return unless entry
13
13
 
14
- bytes = File.binread(File.join(@path, entry[:file])).bytes
15
- P00.wraps?(bytes) ? P00.data(bytes) : bytes
14
+ archive = entry[:archive]
15
+ archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file])
16
16
  end
17
17
 
18
18
  def write_file(name, bytes)
@@ -28,7 +28,7 @@ module Badline
28
28
  end
29
29
 
30
30
  def entries
31
- Dir.children(@path).sort.filter_map { |f| entry_for(f) }
31
+ Dir.children(@path).sort.flat_map { |f| entry_for(f) }.compact
32
32
  end
33
33
 
34
34
  def entry_for(file)
@@ -37,6 +37,8 @@ module Badline
37
37
  { name: File.basename(file, ".*").downcase, file: }
38
38
  when ".p00"
39
39
  p00_entry(file)
40
+ when ".t64"
41
+ t64_entries(file)
40
42
  end
41
43
  end
42
44
 
@@ -44,6 +46,18 @@ module Badline
44
46
  header = File.binread(File.join(@path, file), P00::HEADER_SIZE).bytes
45
47
  { name: P00.name(header), file: } if P00.wraps?(header)
46
48
  end
49
+
50
+ def t64_entries(file)
51
+ archive = T64.new(File.join(@path, file))
52
+ archive.names.map { |name| { name:, archive: } }
53
+ rescue T64::FormatError
54
+ nil
55
+ end
56
+
57
+ def file_bytes(file)
58
+ bytes = File.binread(File.join(@path, file)).bytes
59
+ P00.wraps?(bytes) ? P00.data(bytes) : bytes
60
+ end
47
61
  end
48
62
  end
49
63
  end