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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The drive's 2K of RAM, as M-W and M-R reach it, with the job queue
7
+ # that runs a read job written into it: a job code for each of five
8
+ # buffers at $00, their track and sector pairs from $06, and the
9
+ # buffers from $0300. A job code is replaced by its result, 1 for
10
+ # success or an error table code: the DOS error less 18, or 15 for
11
+ # DRIVE NOT READY.
12
+ class Memory
13
+ SIZE = 0x800
14
+ JOBS = 5
15
+ READ_JOB = 0x80
16
+ JOB_OK = 1
17
+ HEADER_NOT_FOUND = 20
18
+
19
+ # The RAM itself, which buffer channels read and write in place.
20
+ attr_reader :ram
21
+
22
+ # The disk that read jobs read from.
23
+ attr_writer :storage
24
+
25
+ # Buffer n takes the page $0300 + n * $100.
26
+ def self.buffer_address(number)
27
+ 0x300 + (number * BLOCK_SIZE)
28
+ end
29
+
30
+ def initialize
31
+ @storage = nil
32
+ @ram = Array.new(SIZE, 0)
33
+ @bam = false
34
+ end
35
+
36
+ # Writes past the RAM are dropped, since the rest of the address
37
+ # space is I/O and ROM.
38
+ def write(address, bytes)
39
+ bytes.each.with_index(address) do |byte, target|
40
+ @ram[target] = byte if target < SIZE
41
+ end
42
+ run_jobs
43
+ end
44
+
45
+ # Only the RAM reads back.
46
+ def read(address, count)
47
+ Array.new(count) { |i| @ram.fetch(address + i, 0) }
48
+ end
49
+
50
+ # A reset through the reset vector clears the RAM, and the BAM
51
+ # with it, until the next open initializes the disk again.
52
+ def clear
53
+ @ram.fill(0)
54
+ @bam = false
55
+ end
56
+
57
+ # Initializing the disk reads its BAM, the header block, into buffer
58
+ # 4, where M-R finds it. Storage without blocks has none.
59
+ def read_bam
60
+ @bam = true
61
+ block = @storage.respond_to?(:header_block) && @storage.read_block(*@storage.header_block)
62
+ load_buffer(Channels::BAM_BUFFER, block) if block
63
+ end
64
+
65
+ # Whether the BAM has been read since the last reset.
66
+ def bam? = @bam
67
+
68
+ def load_buffer(number, block)
69
+ @ram[Memory.buffer_address(number), block.length] = block
70
+ end
71
+
72
+ # The first block of the file the last LOAD read, which the DOS
73
+ # keeps at $7E and $026F, or nil while the track there is 0.
74
+ def last_program
75
+ [@ram[0x7e], @ram[0x26f]] unless @ram[0x7e].zero?
76
+ end
77
+
78
+ def last_program=(block)
79
+ @ram[0x7e], @ram[0x26f] = block
80
+ end
81
+
82
+ private
83
+
84
+ def run_jobs
85
+ JOBS.times do |job|
86
+ next unless @ram[job] == READ_JOB
87
+
88
+ track, sector = @ram[6 + (job * 2), 2]
89
+ @ram[job] = read_job(job, track, sector)
90
+ end
91
+ end
92
+
93
+ def read_job(number, track, sector)
94
+ data = @storage.respond_to?(:read_block) && @storage.read_block(track, sector)
95
+ return job_result(HEADER_NOT_FOUND) unless data
96
+
97
+ load_buffer(number, data)
98
+ job_result(@storage.block_error(track, sector))
99
+ end
100
+
101
+ def job_result(error)
102
+ return JOB_OK unless error
103
+
104
+ error == DRIVE_NOT_READY ? 15 : error - 18
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The numbers a DOS command takes. The DOS skips spaces, commas and
7
+ # cursor-rights before a parameter, reads it from the characters $30
8
+ # to $3F that follow, the low nibble of each, and skips whatever
9
+ # character ends it. So "18,\x02" reads as 18 and 0, ":" to "?" are
10
+ # digits 10 to 15, and a run of digits keeps its last three.
11
+ module Parameters
12
+ # Space, comma and cursor-right
13
+ GAPS = [0x20, 0x2c, 0x1d].freeze
14
+
15
+ def self.parse(text)
16
+ rest = text.to_s.bytes
17
+ values = []
18
+ until (rest = rest.drop_while { |byte| GAPS.include?(byte) }).empty?
19
+ digits = rest.take_while { |byte| byte.between?(0x30, 0x3f) }
20
+ values << digits.last(3).reduce(0) { |value, digit| (value * 10) + (digit & 0x0f) }
21
+ rest = rest.drop(digits.length + 1)
22
+ end
23
+ values
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/kernal_trap/drive/block_commands"
4
+ require "badline/kernal_trap/drive/channels"
5
+ require "badline/kernal_trap/drive/memory"
6
+ require "badline/kernal_trap/drive/parameters"
3
7
  require "badline/kernal_trap/drive/status"
4
8
 
5
9
  module Badline
@@ -9,6 +13,8 @@ module Badline
9
13
  # buffer channel, which is how block-access loaders bypass the KERNAL's
10
14
  # LOAD.
11
15
  class Drive
16
+ include BlockCommands
17
+
12
18
  COMMAND_CHANNEL = 15
13
19
  BLOCK_SIZE = 256
14
20
 
@@ -24,17 +30,6 @@ module Badline
24
30
 
25
31
  MEMORY_COMMANDS = { "M-W" => :memory_write, "M-R" => :memory_read }.freeze
26
32
 
27
- RAM_SIZE = 0x800
28
-
29
- # The job queue: a job code for each of five buffers at $00, their
30
- # track and sector pairs from $06, and the buffers from $0300. A job
31
- # code is replaced by its result, 1 for success or an error table
32
- # code: the DOS error less 18, or 15 for DRIVE NOT READY.
33
- JOBS = 5
34
- READ_JOB = 0x80
35
- JOB_OK = 1
36
- HEADER_NOT_FOUND = 20
37
-
38
33
  # A U command jumps through the user table at $FFEA, indexed by the
39
34
  # low nibble of its second character less one, so UA and UQ are U1.
40
35
  # U1 and U2 read and write a block. U3 to U8 run code in the buffer
@@ -44,7 +39,7 @@ module Badline
44
39
  # IRQ one, which restart the DOS with its RAM intact, except that a
45
40
  # + or - after U9 only switches the bus speed. U: takes the reset
46
41
  # vector, which runs the RAM test. U0 restores the table.
47
- USER_TABLE = [:block_read, :write_protected, *[:initialized] * 6,
42
+ USER_TABLE = [:block_read, :block_write, *[:initialized] * 6,
48
43
  :warm_reset, :cold_reset, :warm_reset, *[:initialized] * 5].freeze
49
44
 
50
45
  COMMANDS = {
@@ -52,50 +47,76 @@ module Badline
52
47
  /\AU.\s*:?\s*(.*)/i => :user,
53
48
  /\AB-R\s*:?\s*(.*)/i => :counted_block_read,
54
49
  /\AB-P\s*:?\s*(.*)/i => :buffer_pointer,
55
- /\A[IV]/i => :initialized,
56
- /\AB-[WAF]/i => :write_protected
50
+ /\AB-W\s*:?\s*(.*)/i => :counted_block_write,
51
+ /\AI/i => :initialize_disk,
52
+ /\AV/i => :initialized,
53
+ /\AB-[AF]/i => :write_protected
57
54
  }.freeze
58
55
 
59
- # A drive powers on reporting its DOS version, as a reset does.
60
- def initialize(storage)
56
+ # A drive powers on reporting its DOS version, as a reset does. It
57
+ # takes the RAM of the drive it replaces when the disk changes, and
58
+ # initializes the new disk.
59
+ def initialize(storage, memory = Memory.new)
61
60
  @storage = storage
62
- @channels = {}
61
+ @channels = Channels.new
63
62
  @status = Status.new
64
- @ram = Array.new(RAM_SIZE, 0)
63
+ @memory = memory
64
+ memory.storage = storage
65
+ memory.read_bam
65
66
  report(DOS_VERSION)
66
67
  end
67
68
 
68
69
  # A name on the command channel is a command, "#" opens a block
69
70
  # buffer, anything else opens a file for reading. The name comes as
70
71
  # the bytes sent, because a memory command's arguments are binary.
72
+ # Opening a data channel initializes a disk a reset left uninitialized.
71
73
  def open(secondary, name)
74
+ @memory.read_bam unless @memory.bam? || secondary == COMMAND_CHANNEL
72
75
  if secondary == COMMAND_CHANNEL
73
76
  command(name.bytes) unless name.empty?
74
77
  elsif name.start_with?("#")
75
- @channels[secondary] = Channel.new(Array.new(BLOCK_SIZE, 0))
76
- report(OK)
78
+ opened = @channels.open_buffer(secondary, Parameters.parse(name[1..]).first, @memory)
79
+ report(opened ? OK : NO_CHANNEL)
77
80
  else
78
- open_file(secondary, Storage.ascii(name.bytes))
81
+ open_file(secondary, Storage.ascii(without_return(name.bytes)))
79
82
  end
80
83
  end
81
84
 
82
85
  # Closing the command channel closes every other channel with it.
83
86
  def close(secondary)
84
- secondary == COMMAND_CHANNEL ? @channels.clear : @channels.delete(secondary)
87
+ secondary == COMMAND_CHANNEL ? @channels.clear : @channels.close(secondary)
85
88
  end
86
89
 
90
+ # A buffer channel takes the bytes into its block. A file channel is
91
+ # open for reading, so writing to it fails as the disk is protected.
87
92
  def write(secondary, bytes)
88
93
  return command(bytes) if secondary == COMMAND_CHANNEL
89
94
 
90
- report(WRITE_PROTECT_ON) if listening?(secondary)
95
+ channel = @channels[secondary]
96
+ return unless channel
97
+
98
+ channel.writable? ? channel.write(bytes) : report(WRITE_PROTECT_ON)
99
+ end
100
+
101
+ # A SAVE the trap hands over whole, to storage that can write files.
102
+ # A write the host refuses fails as a write-protected disk does.
103
+ # Returns whether the file was written.
104
+ def save(name, bytes)
105
+ saved = @storage.write_file(name, bytes)
106
+ saved ? report(OK) : report(WRITE_PROTECT_ON)
107
+ saved
91
108
  end
92
109
 
93
110
  # Whether data sent on the channel reaches the drive. The command
94
111
  # channel always listens, other channels once they are open.
95
112
  def listening?(secondary)
96
- secondary == COMMAND_CHANNEL || @channels.key?(secondary)
113
+ secondary == COMMAND_CHANNEL || @channels.open?(secondary)
97
114
  end
98
115
 
116
+ # The serial bus's RESET line restarts the DOS through its reset
117
+ # vector, as U: does.
118
+ def reset! = reset(cold: true)
119
+
99
120
  # Returns the next byte and whether it is the channel's last, or nil
100
121
  # when there is nothing left to send.
101
122
  def read(secondary)
@@ -109,33 +130,51 @@ module Badline
109
130
 
110
131
  private
111
132
 
133
+ # The DOS cuts a name of two or more bytes at a carriage return in
134
+ # its last or second-to-last byte, as PRINT# ends a line with one.
135
+ def without_return(bytes)
136
+ cut = bytes.rindex(0x0d)
137
+ cut && bytes.length > 1 && cut >= bytes.length - 2 ? bytes[0, cut] : bytes
138
+ end
139
+
112
140
  # Secondary addresses 0 and 1 are LOAD and SAVE, which look for a PRG
113
141
  # file unless the name asks for another type. Other channels take any
114
142
  # type the name doesn't pin down.
115
143
  def open_file(secondary, name)
116
144
  file, type = Storage.parse_name(name)
117
- return refuse_write(secondary, name, file) if writing?(secondary, name)
118
-
119
145
  type ||= :prg if secondary < 2
120
- channel = @channels[secondary] = Channel.for_file(@storage, file, type)
121
- channel.exhausted? && channel.error ? report(*channel.error) : report(OK)
146
+ return refuse_write(secondary, name, file) if secondary == 1 || mode?(name, "W")
147
+ return refuse_append(secondary, file, type) if mode?(name, "A")
148
+
149
+ channel = @channels.open_file(secondary, Channel.for_name(@storage, @memory, secondary, name, type))
150
+ return report(NO_CHANNEL) unless channel
151
+
152
+ channel.failed? ? report(*channel.error) : report(OK)
122
153
  end
123
154
 
124
- # SAVE's secondary address 1 and a W mode open a file for writing.
125
- def writing?(secondary, name)
126
- secondary == 1 || name.split(",").drop(1).any? { |field| field.strip.match?(/\AW/i) }
155
+ def mode?(name, mode)
156
+ name.split(",").drop(1).any? { |field| field.strip[0]&.upcase == mode }
127
157
  end
128
158
 
129
159
  # The disk is write-protected, so an open for writing fails and leaves
130
160
  # the channel closed. A name already on the disk fails as FILE EXISTS
131
161
  # unless @ asks to replace it, anything else as WRITE PROTECT ON.
132
162
  def refuse_write(secondary, name, file)
133
- @channels.delete(secondary)
163
+ @channels.close(secondary)
134
164
  return report(FILE_EXISTS) if !name.start_with?("@") && @storage.read_file(file, type: nil)
135
165
 
136
166
  report(WRITE_PROTECT_ON, *protected_block(secondary))
137
167
  end
138
168
 
169
+ # An A mode open needs the file on the disk. It fails at the file's
170
+ # last block, the first one an append writes back.
171
+ def refuse_append(secondary, file, type)
172
+ @channels.close(secondary)
173
+ return report(FILE_NOT_FOUND) unless @storage.read_file(file, type:)
174
+
175
+ report(WRITE_PROTECT_ON, *(@storage.last_block(file, type:) if @storage.respond_to?(:last_block)))
176
+ end
177
+
139
178
  # SAVE's channel fails at the directory block its entry would go to,
140
179
  # a W mode open at the disk's header block.
141
180
  def protected_block(secondary)
@@ -150,11 +189,7 @@ module Badline
150
189
  return report(SYNTAX_ERROR) unless action
151
190
 
152
191
  action = USER_TABLE[(command.getbyte(1) - 1) & 0x0f] if action == :user
153
- run_command(action, arguments(pattern.match(command)[1]))
154
- end
155
-
156
- def arguments(text)
157
- text.to_s.scan(/\d+/).map(&:to_i)
192
+ run_command(action, Parameters.parse(pattern.match(command)[1]))
158
193
  end
159
194
 
160
195
  def command(bytes)
@@ -165,9 +200,12 @@ module Badline
165
200
  def run_command(action, arguments)
166
201
  case action
167
202
  when :block_read then block_read(arguments)
203
+ when :block_write then block_write(arguments)
204
+ when :counted_block_write then counted_block_write(arguments)
168
205
  when :counted_block_read then counted_block_read(arguments)
169
206
  when :buffer_pointer then buffer_pointer(arguments)
170
207
  when :initialized then report(OK)
208
+ when :initialize_disk then initialize_disk
171
209
  when :write_protected then write_protected(arguments)
172
210
  when :cold_reset then reset(cold: true)
173
211
  when :warm_reset then reset(cold: false)
@@ -176,61 +214,19 @@ module Badline
176
214
  end
177
215
  end
178
216
 
179
- def block_read(arguments, counted: false)
180
- channel, _drive, track, sector = arguments
181
- buffer = @channels[channel]
182
- return report(NO_CHANNEL) unless buffer
183
- return report(DRIVE_NOT_READY) unless @storage.respond_to?(:read_block)
184
-
185
- data = sector && @storage.read_block(track, sector)
186
- return report(ILLEGAL_TRACK_OR_SECTOR, track, sector) unless data
187
-
188
- buffer.replace(counted ? data[0, data[0] + 1] : data)
189
- buffer.pointer = 1 if counted
190
- report_block_error(track, sector)
191
- end
192
-
193
- # A block the image's error table marks bad still fills the buffer,
194
- # but the read reports its error, which copy protection checks for.
195
- def report_block_error(track, sector)
196
- error = @storage.block_error(track, sector)
197
- error ? report(error, track, sector) : report(OK)
198
- end
199
-
200
- # B-R takes the block's first byte as the index of its last one, and
201
- # hands out the bytes from the second up to there.
202
- def counted_block_read(arguments)
203
- block_read(arguments, counted: true)
204
- end
205
-
206
- def buffer_pointer(arguments)
207
- channel, position = arguments
208
- buffer = @channels[channel]
209
- return report(NO_CHANNEL) unless buffer
210
-
211
- buffer.pointer = position.to_i
212
- report(OK)
213
- end
214
-
215
- # M-W takes an address, a count and that many bytes. Writes past the
216
- # RAM are dropped, since the rest of the address space is I/O and ROM.
217
+ # M-W takes an address, a count and that many bytes.
217
218
  def memory_write(arguments)
218
- address = word(arguments)
219
- arguments[3, arguments[2].to_i].to_a.each.with_index(address) do |byte, target|
220
- @ram[target] = byte if target < RAM_SIZE
221
- end
222
- run_jobs
219
+ @memory.write(word(arguments), arguments[3, arguments[2].to_i].to_a)
223
220
  report(OK)
224
221
  end
225
222
 
226
223
  # M-R takes an address and a count, and answers on the command
227
224
  # channel. Without a count, or with only the carriage return PRINT#
228
- # ends a line with, it reads one byte. Only the RAM reads back.
225
+ # ends a line with, it reads one byte.
229
226
  def memory_read(arguments)
230
- address = word(arguments)
231
227
  count = arguments[2..] == ["\r".ord] ? 1 : arguments.fetch(2, 1)
232
228
  count = BLOCK_SIZE if count.zero?
233
- @status.replace(Array.new(count) { |i| @ram.fetch(address + i, 0) })
229
+ @status.replace(@memory.read(word(arguments), count))
234
230
  nil
235
231
  end
236
232
 
@@ -238,39 +234,21 @@ module Badline
238
234
  arguments[0].to_i | (arguments[1].to_i << 8)
239
235
  end
240
236
 
241
- def run_jobs
242
- JOBS.times do |job|
243
- next unless @ram[job] == READ_JOB
244
-
245
- track, sector = @ram[6 + (job * 2), 2]
246
- @ram[job] = read_job(0x300 + (job * BLOCK_SIZE), track, sector)
247
- end
248
- end
249
-
250
- def read_job(buffer, track, sector)
251
- data = @storage.respond_to?(:read_block) && @storage.read_block(track, sector)
252
- return job_result(HEADER_NOT_FOUND) unless data
253
-
254
- @ram[buffer, BLOCK_SIZE] = data
255
- job_result(@storage.block_error(track, sector))
256
- end
257
-
258
- def job_result(error)
259
- return JOB_OK unless error
260
-
261
- error == DRIVE_NOT_READY ? 15 : error - 18
262
- end
263
-
264
- def write_protected(_arguments)
265
- report(WRITE_PROTECT_ON)
266
- end
237
+ def write_protected(_arguments) = report(WRITE_PROTECT_ON)
267
238
 
268
239
  def reset(cold:)
269
- @ram.fill(0) if cold
240
+ @memory.clear if cold
270
241
  @channels.clear
271
242
  report(DOS_VERSION)
272
243
  end
273
244
 
245
+ # I closes the data channels and reads the BAM again.
246
+ def initialize_disk
247
+ @channels.clear
248
+ @memory.read_bam
249
+ report(OK)
250
+ end
251
+
274
252
  def report(...) = @status.report(...)
275
253
  end
276
254
  end
@@ -5,11 +5,6 @@ module Badline
5
5
  class File < Routine
6
6
  MISSING_FILENAME = 0x08
7
7
 
8
- def initialize(cpu:, bus:, storage: nil)
9
- super(cpu:, bus:)
10
- @storage = storage
11
- end
12
-
13
8
  private
14
9
 
15
10
  def active?
@@ -30,6 +30,11 @@ module Badline
30
30
  VERIFY_MISMATCH = 0x10
31
31
  READ_TIMEOUT = 0x02
32
32
 
33
+ # The ROM opens the file with secondary address $60 and leaves it in
34
+ # $B9, so a second LOAD without a SETLFS loads to the file's own
35
+ # address even when the first one relocated.
36
+ LOAD_SECONDARY = 0x60
37
+
33
38
  def initialize(cpu:, bus:, drive:)
34
39
  super(cpu:, bus:)
35
40
  @drive = drive
@@ -80,6 +85,7 @@ module Badline
80
85
  @bus.poke(0x90, @bus.peek(0x90) & ~EOI) unless complete
81
86
  continue_with(SEARCHING_MESSAGE, LOADING_MESSAGE, complete ? LOAD_DONE : BYTE_LOOP)
82
87
  end
88
+ @bus.poke(0xb9, LOAD_SECONDARY)
83
89
  end
84
90
 
85
91
  def low_memory?(data)
@@ -3,7 +3,7 @@
3
3
  module Badline
4
4
  module KernalTrap
5
5
  # PC trap on the KERNAL serial SAVE routine ($F5ED, the default ISAVE
6
- # vector target). Writes device 8 saves to a storage backend as a PRG
6
+ # vector target). Hands device 8 saves to the virtual drive as a PRG
7
7
  # (load address followed by the memory range); other devices fall
8
8
  # through to the ROM. The ROM prints SAVING in direct mode and returns
9
9
  # into the trap, which then writes the file and leaves through the
@@ -28,8 +28,9 @@ module Badline
28
28
  # ST bit at $90
29
29
  DEVICE_NOT_PRESENT = 0x80
30
30
 
31
- def initialize(cpu:, bus:, storage:)
32
- super
31
+ def initialize(cpu:, bus:, drive:)
32
+ super(cpu:, bus:)
33
+ @drive = drive
33
34
  @saving = false
34
35
  end
35
36
 
@@ -53,7 +54,7 @@ module Badline
53
54
 
54
55
  def finish
55
56
  @saving = false
56
- @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @storage.write_file(name, payload)
57
+ @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @drive.save(name, payload)
57
58
  @bus.poke(0xac, @bus.peek(0xae))
58
59
  @bus.poke(0xad, @bus.peek(0xaf))
59
60
  @cpu.y = 0
data/lib/badline/media.rb CHANGED
@@ -59,8 +59,8 @@ module Badline
59
59
 
60
60
  def start_tune(computer, tune, autostart:, song:)
61
61
  computer.ram.write(tune.load_address, tune.data)
62
- computer.ram.write(tune.driver_address, tune.driver(song:))
63
- computer.type_text("sys#{tune.driver_address}\r") if autostart
62
+ tune.boot_memory(song:).each { |address, bytes| computer.ram.write(address, bytes) }
63
+ computer.type_text(tune.boot_command) if autostart
64
64
  end
65
65
 
66
66
  def attach_tape(computer, path, autostart:)
@@ -10,11 +10,11 @@ module Badline
10
10
  end
11
11
 
12
12
  def peek(addr)
13
- @storage[index(addr)]
13
+ @storage[offset_of(addr)]
14
14
  end
15
15
 
16
16
  def poke(addr, value)
17
- @storage[index(addr)] = value
17
+ @storage[offset_of(addr)] = value
18
18
  value
19
19
  end
20
20
 
data/lib/badline/rom.rb CHANGED
@@ -1,17 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
+ class << self
5
+ attr_writer :rom_path
6
+
7
+ # The directory ROM images and other bundled binaries load from.
8
+ # Defaults to $BADLINE_ROM_PATH, then to the roms directory next to this
9
+ # file. Assigning nil restores the default.
10
+ def rom_path
11
+ @rom_path ||= ENV.fetch("BADLINE_ROM_PATH", File.expand_path("roms", __dir__))
12
+ end
13
+ end
14
+
4
15
  class ROM < Memory
5
16
  class << self
6
17
  def load(filename, start = 0x0)
7
- data = File.read(file_path(filename)).bytes
18
+ data = read(filename)
8
19
  new(data, length: data.length, start: start)
9
20
  end
10
21
 
11
- private
12
-
13
- def file_path(filename)
14
- File.join(File.dirname(__FILE__), "roms", filename)
22
+ def read(filename)
23
+ File.binread(File.join(Badline.rom_path, filename)).bytes
15
24
  end
16
25
  end
17
26