badline 0.2.0 → 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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -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 +14 -6
  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 +16 -8
  24. data/lib/badline/checkpoint.rb +98 -0
  25. data/lib/badline/cia/interrupt_register.rb +20 -6
  26. data/lib/badline/cia.rb +36 -19
  27. data/lib/badline/color_memory.rb +2 -2
  28. data/lib/badline/computer.rb +41 -11
  29. data/lib/badline/cpu/microcode.rb +41 -43
  30. data/lib/badline/cpu.rb +3 -3
  31. data/lib/badline/gui/application.rb +41 -9
  32. data/lib/badline/kernal_trap/channel.rb +118 -8
  33. data/lib/badline/kernal_trap/drive/block_commands.rb +79 -0
  34. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  35. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  36. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  37. data/lib/badline/kernal_trap/drive/status.rb +49 -0
  38. data/lib/badline/kernal_trap/drive.rb +148 -132
  39. data/lib/badline/kernal_trap/file.rb +0 -5
  40. data/lib/badline/kernal_trap/load.rb +93 -25
  41. data/lib/badline/kernal_trap/routine.rb +17 -0
  42. data/lib/badline/kernal_trap/save.rb +48 -15
  43. data/lib/badline/kernal_trap/serial.rb +34 -2
  44. data/lib/badline/media.rb +2 -2
  45. data/lib/badline/memory.rb +7 -2
  46. data/lib/badline/rom.rb +14 -5
  47. data/lib/badline/sid/catch_up.rb +110 -0
  48. data/lib/badline/sid/decimator.rb +6 -1
  49. data/lib/badline/sid/filter.rb +5 -5
  50. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  51. data/lib/badline/sid/waveform.rb +7 -7
  52. data/lib/badline/sid.rb +16 -92
  53. data/lib/badline/status.rb +255 -32
  54. data/lib/badline/storage/d64_image.rb +6 -0
  55. data/lib/badline/storage/d71_image.rb +4 -0
  56. data/lib/badline/storage/d81_image.rb +4 -0
  57. data/lib/badline/storage/disk_image.rb +78 -17
  58. data/lib/badline/storage/host_directory.rb +26 -2
  59. data/lib/badline/storage/sid_file.rb +37 -3
  60. data/lib/badline/storage.rb +3 -1
  61. data/lib/badline/time_of_day.rb +7 -0
  62. data/lib/badline/version.rb +1 -1
  63. data/lib/badline/vic/graphics_shifter.rb +8 -2
  64. data/lib/badline/vic/registers.rb +16 -27
  65. data/lib/badline/vic.rb +23 -10
  66. data/lib/badline.rb +1 -0
  67. metadata +9 -1
@@ -1,5 +1,11 @@
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"
7
+ require "badline/kernal_trap/drive/status"
8
+
3
9
  module Badline
4
10
  module KernalTrap
5
11
  # The CBM DOS side of the serial bus. Keeps the open channels, the
@@ -7,6 +13,8 @@ module Badline
7
13
  # buffer channel, which is how block-access loaders bypass the KERNAL's
8
14
  # LOAD.
9
15
  class Drive
16
+ include BlockCommands
17
+
10
18
  COMMAND_CHANNEL = 15
11
19
  BLOCK_SIZE = 256
12
20
 
@@ -14,96 +22,119 @@ module Badline
14
22
  WRITE_PROTECT_ON = 26
15
23
  SYNTAX_ERROR = 30
16
24
  FILE_NOT_FOUND = 62
25
+ FILE_EXISTS = 63
17
26
  ILLEGAL_TRACK_OR_SECTOR = 66
18
27
  NO_CHANNEL = 70
28
+ DOS_VERSION = 73
19
29
  DRIVE_NOT_READY = 74
20
30
 
21
- MESSAGES = {
22
- OK => " OK",
23
- 20 => "READ ERROR",
24
- 21 => "READ ERROR",
25
- 22 => "READ ERROR",
26
- 23 => "READ ERROR",
27
- 24 => "READ ERROR",
28
- 25 => "WRITE ERROR",
29
- WRITE_PROTECT_ON => "WRITE PROTECT ON",
30
- 27 => "READ ERROR",
31
- 28 => "WRITE ERROR",
32
- 29 => "DISK ID MISMATCH",
33
- SYNTAX_ERROR => "SYNTAX ERROR",
34
- FILE_NOT_FOUND => "FILE NOT FOUND",
35
- ILLEGAL_TRACK_OR_SECTOR => "ILLEGAL TRACK OR SECTOR",
36
- NO_CHANNEL => "NO CHANNEL",
37
- DRIVE_NOT_READY => "DRIVE NOT READY"
38
- }.freeze
39
-
40
31
  MEMORY_COMMANDS = { "M-W" => :memory_write, "M-R" => :memory_read }.freeze
41
32
 
42
- RAM_SIZE = 0x800
43
-
44
- # The job queue: a job code for each of five buffers at $00, their
45
- # track and sector pairs from $06, and the buffers from $0300. A job
46
- # code is replaced by its result, 1 for success or an error table
47
- # code: the DOS error less 18, or 15 for DRIVE NOT READY.
48
- JOBS = 5
49
- READ_JOB = 0x80
50
- JOB_OK = 1
51
- HEADER_NOT_FOUND = 20
33
+ # A U command jumps through the user table at $FFEA, indexed by the
34
+ # low nibble of its second character less one, so UA and UQ are U1.
35
+ # U1 and U2 read and write a block. U3 to U8 run code in the buffer
36
+ # at $0500, and the five entries past the table's end take vectors
37
+ # from the job queue. Neither runs here, so they report OK as a
38
+ # routine that returns does. U9 (UI) takes the NMI vector and U; the
39
+ # IRQ one, which restart the DOS with its RAM intact, except that a
40
+ # + or - after U9 only switches the bus speed. U: takes the reset
41
+ # vector, which runs the RAM test. U0 restores the table.
42
+ USER_TABLE = [:block_read, :block_write, *[:initialized] * 6,
43
+ :warm_reset, :cold_reset, :warm_reset, *[:initialized] * 5].freeze
52
44
 
53
45
  COMMANDS = {
54
- /\AU[1A]\s*:?\s*(.*)/i => :block_read,
46
+ /\AU[9IY)][+-]/i => :initialized,
47
+ /\AU.\s*:?\s*(.*)/i => :user,
55
48
  /\AB-R\s*:?\s*(.*)/i => :counted_block_read,
56
49
  /\AB-P\s*:?\s*(.*)/i => :buffer_pointer,
57
- /\A[IV]/i => :initialized,
58
- /\A(?:U[2B]|B-[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
59
54
  }.freeze
60
55
 
61
- 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)
62
60
  @storage = storage
63
- @channels = {}
64
- @status = Channel.new
65
- @ram = Array.new(RAM_SIZE, 0)
66
- report(OK)
61
+ @channels = Channels.new
62
+ @status = Status.new
63
+ @memory = memory
64
+ memory.storage = storage
65
+ memory.read_bam
66
+ report(DOS_VERSION)
67
67
  end
68
68
 
69
69
  # A name on the command channel is a command, "#" opens a block
70
70
  # buffer, anything else opens a file for reading. The name comes as
71
71
  # the bytes sent, because a memory command's arguments are binary.
72
+ # Opening a data channel initializes a disk a reset left uninitialized.
72
73
  def open(secondary, name)
74
+ @memory.read_bam unless @memory.bam? || secondary == COMMAND_CHANNEL
73
75
  if secondary == COMMAND_CHANNEL
74
76
  command(name.bytes) unless name.empty?
75
77
  elsif name.start_with?("#")
76
- @channels[secondary] = Channel.new(Array.new(BLOCK_SIZE, 0))
77
- report(OK)
78
+ opened = @channels.open_buffer(secondary, Parameters.parse(name[1..]).first, @memory)
79
+ report(opened ? OK : NO_CHANNEL)
78
80
  else
79
- open_file(secondary, Storage.ascii(name.bytes))
81
+ open_file(secondary, Storage.ascii(without_return(name.bytes)))
80
82
  end
81
83
  end
82
84
 
83
85
  # Closing the command channel closes every other channel with it.
84
86
  def close(secondary)
85
- secondary == COMMAND_CHANNEL ? @channels.clear : @channels.delete(secondary)
87
+ secondary == COMMAND_CHANNEL ? @channels.clear : @channels.close(secondary)
86
88
  end
87
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.
88
92
  def write(secondary, bytes)
89
- return report(WRITE_PROTECT_ON) unless secondary == COMMAND_CHANNEL
93
+ return command(bytes) if secondary == COMMAND_CHANNEL
90
94
 
91
- command(bytes)
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
92
108
  end
93
109
 
110
+ # Whether data sent on the channel reaches the drive. The command
111
+ # channel always listens, other channels once they are open.
112
+ def listening?(secondary)
113
+ secondary == COMMAND_CHANNEL || @channels.open?(secondary)
114
+ end
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
+
94
120
  # Returns the next byte and whether it is the channel's last, or nil
95
121
  # when there is nothing left to send.
96
122
  def read(secondary)
97
- secondary == COMMAND_CHANNEL ? read_status : @channels[secondary]&.read
123
+ return @status.read if secondary == COMMAND_CHANNEL
124
+
125
+ channel = @channels[secondary]
126
+ result = channel&.read
127
+ report(*channel.error) if result && channel.error && channel.exhausted?
128
+ result
98
129
  end
99
130
 
100
131
  private
101
132
 
102
- def read_status
103
- result = @status.read
104
- report(OK) if result&.last
105
-
106
- result
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
107
138
  end
108
139
 
109
140
  # Secondary addresses 0 and 1 are LOAD and SAVE, which look for a PRG
@@ -112,83 +143,90 @@ module Badline
112
143
  def open_file(secondary, name)
113
144
  file, type = Storage.parse_name(name)
114
145
  type ||= :prg if secondary < 2
115
- data = @storage.read_file(file, type:)
116
- @channels[secondary] = Channel.new(data || [])
117
- report(data ? OK : FILE_NOT_FOUND)
118
- end
146
+ return refuse_write(secondary, name, file) if secondary == 1 || mode?(name, "W")
147
+ return refuse_append(secondary, file, type) if mode?(name, "A")
119
148
 
120
- def execute(text)
121
- command = text.strip
122
- pattern, action = COMMANDS.find { |candidate, _| candidate.match?(command) }
123
- return report(SYNTAX_ERROR) unless action
149
+ channel = @channels.open_file(secondary, Channel.for_name(@storage, @memory, secondary, name, type))
150
+ return report(NO_CHANNEL) unless channel
124
151
 
125
- send(action, arguments(pattern.match(command)[1]))
152
+ channel.failed? ? report(*channel.error) : report(OK)
126
153
  end
127
154
 
128
- def arguments(text)
129
- text.to_s.scan(/\d+/).map(&:to_i)
155
+ def mode?(name, mode)
156
+ name.split(",").drop(1).any? { |field| field.strip[0]&.upcase == mode }
130
157
  end
131
158
 
132
- def command(bytes)
133
- action = MEMORY_COMMANDS[bytes[0, 3].pack("C*")]
134
- action ? send(action, bytes[3..]) : execute(Storage.ascii(bytes))
135
- end
159
+ # The disk is write-protected, so an open for writing fails and leaves
160
+ # the channel closed. A name already on the disk fails as FILE EXISTS
161
+ # unless @ asks to replace it, anything else as WRITE PROTECT ON.
162
+ def refuse_write(secondary, name, file)
163
+ @channels.close(secondary)
164
+ return report(FILE_EXISTS) if !name.start_with?("@") && @storage.read_file(file, type: nil)
136
165
 
137
- def block_read(arguments, counted: false)
138
- channel, _drive, track, sector = arguments
139
- buffer = @channels[channel]
140
- return report(NO_CHANNEL) unless buffer
141
- return report(DRIVE_NOT_READY) unless @storage.respond_to?(:read_block)
166
+ report(WRITE_PROTECT_ON, *protected_block(secondary))
167
+ end
142
168
 
143
- data = sector && @storage.read_block(track, sector)
144
- return report(ILLEGAL_TRACK_OR_SECTOR, track, sector) unless data
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:)
145
174
 
146
- buffer.replace(counted ? data[0, data[0] + 1] : data)
147
- buffer.pointer = 1 if counted
148
- report_block_error(track, sector)
175
+ report(WRITE_PROTECT_ON, *(@storage.last_block(file, type:) if @storage.respond_to?(:last_block)))
149
176
  end
150
177
 
151
- # A block the image's error table marks bad still fills the buffer,
152
- # but the read reports its error, which copy protection checks for.
153
- def report_block_error(track, sector)
154
- error = @storage.block_error(track, sector)
155
- error ? report(error, track, sector) : report(OK)
156
- end
178
+ # SAVE's channel fails at the directory block its entry would go to,
179
+ # a W mode open at the disk's header block.
180
+ def protected_block(secondary)
181
+ return [] unless @storage.respond_to?(:header_block)
157
182
 
158
- # B-R takes the block's first byte as the index of its last one, and
159
- # hands out the bytes from the second up to there.
160
- def counted_block_read(arguments)
161
- block_read(arguments, counted: true)
183
+ secondary == 1 ? @storage.new_entry_block : @storage.header_block
162
184
  end
163
185
 
164
- def buffer_pointer(arguments)
165
- channel, position = arguments
166
- buffer = @channels[channel]
167
- return report(NO_CHANNEL) unless buffer
186
+ def execute(text)
187
+ command = text.strip
188
+ pattern, action = COMMANDS.find { |candidate, _| candidate.match?(command) }
189
+ return report(SYNTAX_ERROR) unless action
168
190
 
169
- buffer.pointer = position.to_i
170
- report(OK)
191
+ action = USER_TABLE[(command.getbyte(1) - 1) & 0x0f] if action == :user
192
+ run_command(action, Parameters.parse(pattern.match(command)[1]))
171
193
  end
172
194
 
173
- # M-W takes an address, a count and that many bytes. Writes past the
174
- # RAM are dropped, since the rest of the address space is I/O and ROM.
175
- def memory_write(arguments)
176
- address = word(arguments)
177
- arguments[3, arguments[2].to_i].to_a.each.with_index(address) do |byte, target|
178
- @ram[target] = byte if target < RAM_SIZE
195
+ def command(bytes)
196
+ action = MEMORY_COMMANDS[bytes[0, 3].pack("C*")]
197
+ action ? run_command(action, bytes[3..]) : execute(Storage.ascii(bytes))
198
+ end
199
+
200
+ def run_command(action, arguments)
201
+ case action
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)
205
+ when :counted_block_read then counted_block_read(arguments)
206
+ when :buffer_pointer then buffer_pointer(arguments)
207
+ when :initialized then report(OK)
208
+ when :initialize_disk then initialize_disk
209
+ when :write_protected then write_protected(arguments)
210
+ when :cold_reset then reset(cold: true)
211
+ when :warm_reset then reset(cold: false)
212
+ when :memory_write then memory_write(arguments)
213
+ when :memory_read then memory_read(arguments)
179
214
  end
180
- run_jobs
215
+ end
216
+
217
+ # M-W takes an address, a count and that many bytes.
218
+ def memory_write(arguments)
219
+ @memory.write(word(arguments), arguments[3, arguments[2].to_i].to_a)
181
220
  report(OK)
182
221
  end
183
222
 
184
223
  # M-R takes an address and a count, and answers on the command
185
224
  # channel. Without a count, or with only the carriage return PRINT#
186
- # ends a line with, it reads one byte. Only the RAM reads back.
225
+ # ends a line with, it reads one byte.
187
226
  def memory_read(arguments)
188
- address = word(arguments)
189
227
  count = arguments[2..] == ["\r".ord] ? 1 : arguments.fetch(2, 1)
190
228
  count = BLOCK_SIZE if count.zero?
191
- @status.replace(Array.new(count) { |i| @ram.fetch(address + i, 0) })
229
+ @status.replace(@memory.read(word(arguments), count))
192
230
  nil
193
231
  end
194
232
 
@@ -196,44 +234,22 @@ module Badline
196
234
  arguments[0].to_i | (arguments[1].to_i << 8)
197
235
  end
198
236
 
199
- def run_jobs
200
- JOBS.times do |job|
201
- next unless @ram[job] == READ_JOB
202
-
203
- track, sector = @ram[6 + (job * 2), 2]
204
- @ram[job] = read_job(0x300 + (job * BLOCK_SIZE), track, sector)
205
- end
206
- end
207
-
208
- def read_job(buffer, track, sector)
209
- data = @storage.respond_to?(:read_block) && @storage.read_block(track, sector)
210
- return job_result(HEADER_NOT_FOUND) unless data
211
-
212
- @ram[buffer, BLOCK_SIZE] = data
213
- job_result(@storage.block_error(track, sector))
214
- end
215
-
216
- def job_result(error)
217
- return JOB_OK unless error
237
+ def write_protected(_arguments) = report(WRITE_PROTECT_ON)
218
238
 
219
- error == DRIVE_NOT_READY ? 15 : error - 18
239
+ def reset(cold:)
240
+ @memory.clear if cold
241
+ @channels.clear
242
+ report(DOS_VERSION)
220
243
  end
221
244
 
222
- def initialized(_arguments)
245
+ # I closes the data channels and reads the BAM again.
246
+ def initialize_disk
247
+ @channels.clear
248
+ @memory.read_bam
223
249
  report(OK)
224
250
  end
225
251
 
226
- def write_protected(_arguments)
227
- report(WRITE_PROTECT_ON)
228
- end
229
-
230
- def report(code, track = 0, sector = 0)
231
- message = format("%<code>02d,%<message>s,%<track>02d,%<sector>02d\r",
232
- code:, message: MESSAGES.fetch(code),
233
- track: track.to_i, sector: sector.to_i)
234
- @status.replace(message.bytes)
235
- nil
236
- end
252
+ def report(...) = @status.report(...)
237
253
  end
238
254
  end
239
255
  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:)
9
- super(cpu:, bus:)
10
- @storage = storage
11
- end
12
-
13
8
  private
14
9
 
15
10
  def active?
@@ -3,41 +3,118 @@
3
3
  module Badline
4
4
  module KernalTrap
5
5
  # PC trap on the KERNAL serial LOAD routine ($F4A5, the default ILOAD
6
- # vector target). Serves device 8 requests from a storage backend and
7
- # returns to the caller with the routine's register/zeropage contract;
8
- # other devices fall through to the ROM.
6
+ # vector target). Reads device 8 requests from the virtual drive's
7
+ # channel 0, then hands over to the ROM's own tail so it prints
8
+ # SEARCHING FOR and LOADING (or VERIFYING) in direct mode, reports
9
+ # errors and returns with the routine's register/zeropage contract;
10
+ # other devices fall through to the ROM. So does a load that reaches
11
+ # below $0334, into the zero page, the stack or the KERNAL vectors: the
12
+ # ROM's byte loop then loads it through the serial traps, and a loader
13
+ # that overwrites ISTOP takes over mid-load as it does on a real drive.
9
14
  class Load < File
10
15
  ADDRESS = 0xf4a5
11
16
 
12
- FILE_NOT_FOUND = 0x04
17
+ # ROM entry points: the SEARCHING FOR and LOADING/VERIFYING messages,
18
+ # the byte loop that retries a timed-out byte until RUN/STOP, the
19
+ # successful return (CLC, LDX $AE, LDY $AF, RTS) and the
20
+ # FILE NOT FOUND and MISSING FILE NAME error exits
21
+ SEARCHING_MESSAGE = 0xf5af
22
+ LOADING_MESSAGE = 0xf5d2
23
+ BYTE_LOOP = 0xf4f3
24
+ LOAD_DONE = 0xf5a9
25
+ FILE_NOT_FOUND_EXIT = 0xf704
26
+ MISSING_FILE_NAME_EXIT = 0xf710
27
+
28
+ # ST bits at $90
29
+ EOI = 0x40
30
+ VERIFY_MISMATCH = 0x10
31
+ READ_TIMEOUT = 0x02
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
+
38
+ def initialize(cpu:, bus:, drive:)
39
+ super(cpu:, bus:)
40
+ @drive = drive
41
+ end
13
42
 
14
43
  def call
15
44
  return unless active?
16
45
 
17
- name, type = Storage.parse_name(filename)
18
- if name.empty?
19
- error(MISSING_FILENAME)
20
- elsif (data = @storage.read_file(name, type: type || :prg))
21
- deliver(data)
46
+ @bus.poke(0x93, @cpu.a)
47
+ @bus.poke(0x90, 0x00)
48
+ name = filename
49
+ return @cpu.program_counter = MISSING_FILE_NAME_EXIT if Storage.parse_name(name).first.empty?
50
+
51
+ data, complete = receive(name)
52
+ if low_memory?(data)
53
+ @drive.close(0)
22
54
  else
23
- error(FILE_NOT_FOUND)
55
+ finish(data, complete)
24
56
  end
25
- return_to_caller
26
57
  end
27
58
 
28
59
  private
29
60
 
30
- # A=0 is LOAD, A=1 is VERIFY
61
+ # Reads the file the way the ROM's byte loop would, up to the byte
62
+ # flagged EOI. A read error in the file's chain stops the bytes early,
63
+ # with no EOI.
64
+ def receive(name)
65
+ @drive.open(0, name)
66
+ data = []
67
+ loop do
68
+ byte, eoi = @drive.read(0)
69
+ return [data, false] unless byte
70
+
71
+ data << byte
72
+ return [data, true] if eoi
73
+ end
74
+ end
75
+
76
+ # No first byte means the file isn't there, or its first block didn't
77
+ # read. A later read error leaves the ROM retrying the next byte
78
+ # until RUN/STOP breaks the load, as on a real drive.
79
+ def finish(data, complete)
80
+ if data.empty?
81
+ @bus.poke(0x90, EOI | READ_TIMEOUT)
82
+ continue_with(SEARCHING_MESSAGE, FILE_NOT_FOUND_EXIT)
83
+ else
84
+ deliver(data)
85
+ @bus.poke(0x90, @bus.peek(0x90) & ~EOI) unless complete
86
+ continue_with(SEARCHING_MESSAGE, LOADING_MESSAGE, complete ? LOAD_DONE : BYTE_LOOP)
87
+ end
88
+ @bus.poke(0xb9, LOAD_SECONDARY)
89
+ end
90
+
91
+ def low_memory?(data)
92
+ load? && data.length > 2 && load_address(data) < 0x0334
93
+ end
94
+
95
+ # A=0 is LOAD, A=1 is VERIFY, kept at $93 (VERCK)
31
96
  def load?
32
- @cpu.a.zero?
97
+ @bus.peek(0x93).zero?
33
98
  end
34
99
 
35
100
  def deliver(data)
36
101
  addr = load_address(data)
37
102
  payload = data[2..] || []
38
103
  payload = payload[0, 0x10000 - addr] if addr + payload.length > 0x10000
39
- @bus.ram.write(addr, payload) if load?
40
- finish((addr + payload.length) & 0xffff)
104
+ if load?
105
+ @bus.ram.write(addr, payload)
106
+ @bus.poke(0x90, EOI)
107
+ else
108
+ verify(addr, payload)
109
+ end
110
+ end_addr = (addr + payload.length) & 0xffff
111
+ @bus.poke(0xae, low_byte(end_addr))
112
+ @bus.poke(0xaf, high_byte(end_addr))
113
+ end
114
+
115
+ def verify(addr, payload)
116
+ match = payload.each_with_index.all? { |byte, i| @bus.peek(addr + i) == byte }
117
+ @bus.poke(0x90, match ? EOI : EOI | VERIFY_MISMATCH)
41
118
  end
42
119
 
43
120
  # Secondary address $B9 zero relocates to the caller-supplied address
@@ -46,18 +123,9 @@ module Badline
46
123
  if @bus.peek(0xb9).zero?
47
124
  uint16(@bus.peek(0xc3), @bus.peek(0xc4))
48
125
  else
49
- uint16(data[0], data[1])
126
+ uint16(data[0], data[1].to_i)
50
127
  end
51
128
  end
52
-
53
- def finish(end_addr)
54
- @bus.poke(0x90, 0x40)
55
- @bus.poke(0xae, low_byte(end_addr))
56
- @bus.poke(0xaf, high_byte(end_addr))
57
- @cpu.x = low_byte(end_addr)
58
- @cpu.y = high_byte(end_addr)
59
- @cpu.status.carry = false
60
- end
61
129
  end
62
130
  end
63
131
  end
@@ -25,6 +25,23 @@ module Badline
25
25
  @cpu.program_counter = (uint16(pull_byte, pull_byte) + 1) & 0xffff
26
26
  end
27
27
 
28
+ # Runs the ROM routines in order, each returning into the next, and
29
+ # the last one to the trapped routine's caller
30
+ def continue_with(first, *rest)
31
+ rest.reverse_each { |address| push_address((address - 1) & 0xffff) }
32
+ @cpu.program_counter = first
33
+ end
34
+
35
+ def push_address(address)
36
+ push_byte(high_byte(address))
37
+ push_byte(low_byte(address))
38
+ end
39
+
40
+ def push_byte(value)
41
+ @bus.poke(0x0100 + @cpu.stack_pointer, value)
42
+ @cpu.stack_pointer = (@cpu.stack_pointer - 1) & 0xff
43
+ end
44
+
28
45
  def pull_byte
29
46
  @cpu.stack_pointer = (@cpu.stack_pointer + 1) & 0xff
30
47
  @bus.peek(0x0100 + @cpu.stack_pointer)
@@ -3,27 +3,65 @@
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
- # through to the ROM.
8
+ # through to the ROM. The ROM prints SAVING in direct mode and returns
9
+ # into the trap, which then writes the file and leaves through the
10
+ # ROM's own tail with the registers its UNLISTEN and return leave. A
11
+ # host write that fails ends the way a 1541 ends a SAVE it can't
12
+ # write: the drive stops listening, so ST reads DEVICE NOT PRESENT,
13
+ # and the ROM returns without an error.
9
14
  class Save < File
10
15
  ADDRESS = 0xf5ed
11
16
 
17
+ # ROM entry points: the SAVING message, the two line releases that
18
+ # end UNLISTEN (clock, then data, leaving A as read from $DD00), the
19
+ # successful return (CLC, RTS) and the MISSING FILE NAME error exit
20
+ SAVING_MESSAGE = 0xf68f
21
+ CLOCK_RELEASE = 0xee85
22
+ DATA_RELEASE = 0xee97
23
+ SAVE_DONE = 0xf657
24
+ MISSING_FILE_NAME_EXIT = 0xf710
25
+
26
+ SECONDARY = 0x61
27
+
28
+ # ST bit at $90
29
+ DEVICE_NOT_PRESENT = 0x80
30
+
31
+ def initialize(cpu:, bus:, drive:)
32
+ super(cpu:, bus:)
33
+ @drive = drive
34
+ @saving = false
35
+ end
36
+
12
37
  def call
13
38
  return unless active?
39
+ return finish if @saving
40
+
41
+ @bus.poke(0xb9, SECONDARY)
42
+ return @cpu.program_counter = MISSING_FILE_NAME_EXIT if name.empty?
14
43
 
15
- name = Storage.parse_name(filename).first
16
- if name.empty?
17
- error(MISSING_FILENAME)
18
- else
19
- @storage.write_file(name, payload)
20
- finish
21
- end
22
- return_to_caller
44
+ @bus.poke(0x90, 0x00)
45
+ @saving = true
46
+ continue_with(SAVING_MESSAGE, ADDRESS)
23
47
  end
24
48
 
25
49
  private
26
50
 
51
+ def name
52
+ Storage.parse_name(filename).first
53
+ end
54
+
55
+ def finish
56
+ @saving = false
57
+ @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @drive.save(name, payload)
58
+ @bus.poke(0xac, @bus.peek(0xae))
59
+ @bus.poke(0xad, @bus.peek(0xaf))
60
+ @cpu.y = 0
61
+ @cpu.status.overflow = true
62
+ continue_with(CLOCK_RELEASE, DATA_RELEASE, SAVE_DONE)
63
+ end
64
+
27
65
  # Start address at $C1/$C2 (STAL), end address (exclusive) at
28
66
  # $AE/$AF (EAL)
29
67
  def payload
@@ -32,11 +70,6 @@ module Badline
32
70
  [low_byte(start), high_byte(start)] +
33
71
  Array.new(length) { |i| @bus.peek((start + i) & 0xffff) }
34
72
  end
35
-
36
- def finish
37
- @bus.poke(0x90, 0x00)
38
- @cpu.status.carry = false
39
- end
40
73
  end
41
74
  end
42
75
  end