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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +6 -0
- data/README.md +24 -6
- data/doc/pinned-behaviour.md +18 -6
- data/exe/badline +5 -2
- data/lib/badline/address_bus.rb +14 -6
- data/lib/badline/addressable.rb +1 -1
- data/lib/badline/audio/bare_player.rb +20 -3
- data/lib/badline/audio/machine_player.rb +9 -6
- data/lib/badline/audio/renderer.rb +3 -3
- data/lib/badline/audio/stream.rb +117 -0
- data/lib/badline/audio.rb +1 -0
- data/lib/badline/cartridge/easy_flash.rb +6 -2
- data/lib/badline/cartridge/epyx_fastload.rb +1 -1
- data/lib/badline/cartridge/mach5.rb +1 -1
- data/lib/badline/cartridge/magic_desk.rb +5 -1
- data/lib/badline/cartridge/ocean.rb +5 -2
- data/lib/badline/cartridge/rex_utility.rb +1 -1
- data/lib/badline/cartridge/rgcd.rb +1 -1
- data/lib/badline/cartridge/simons_basic.rb +2 -2
- data/lib/badline/cartridge/westermann.rb +2 -2
- data/lib/badline/cartridge.rb +16 -8
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia/interrupt_register.rb +20 -6
- data/lib/badline/cia.rb +36 -19
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +41 -11
- data/lib/badline/cpu/microcode.rb +41 -43
- data/lib/badline/cpu.rb +3 -3
- data/lib/badline/gui/application.rb +41 -9
- data/lib/badline/kernal_trap/channel.rb +118 -8
- data/lib/badline/kernal_trap/drive/block_commands.rb +79 -0
- data/lib/badline/kernal_trap/drive/channels.rb +53 -0
- data/lib/badline/kernal_trap/drive/memory.rb +109 -0
- data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
- data/lib/badline/kernal_trap/drive/status.rb +49 -0
- data/lib/badline/kernal_trap/drive.rb +148 -132
- data/lib/badline/kernal_trap/file.rb +0 -5
- data/lib/badline/kernal_trap/load.rb +93 -25
- data/lib/badline/kernal_trap/routine.rb +17 -0
- data/lib/badline/kernal_trap/save.rb +48 -15
- data/lib/badline/kernal_trap/serial.rb +34 -2
- data/lib/badline/media.rb +2 -2
- data/lib/badline/memory.rb +7 -2
- data/lib/badline/rom.rb +14 -5
- data/lib/badline/sid/catch_up.rb +110 -0
- data/lib/badline/sid/decimator.rb +6 -1
- data/lib/badline/sid/filter.rb +5 -5
- data/lib/badline/sid/waveform/fast_forward.rb +10 -10
- data/lib/badline/sid/waveform.rb +7 -7
- data/lib/badline/sid.rb +16 -92
- data/lib/badline/status.rb +255 -32
- data/lib/badline/storage/d64_image.rb +6 -0
- data/lib/badline/storage/d71_image.rb +4 -0
- data/lib/badline/storage/d81_image.rb +4 -0
- data/lib/badline/storage/disk_image.rb +78 -17
- data/lib/badline/storage/host_directory.rb +26 -2
- data/lib/badline/storage/sid_file.rb +37 -3
- data/lib/badline/storage.rb +3 -1
- data/lib/badline/time_of_day.rb +7 -0
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/graphics_shifter.rb +8 -2
- data/lib/badline/vic/registers.rb +16 -27
- data/lib/badline/vic.rb +23 -10
- data/lib/badline.rb +1 -0
- 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
|
-
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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[
|
|
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
|
-
/\
|
|
58
|
-
/\
|
|
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
|
-
|
|
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 =
|
|
65
|
-
@
|
|
66
|
-
|
|
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
|
|
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.
|
|
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
|
|
93
|
+
return command(bytes) if secondary == COMMAND_CHANNEL
|
|
90
94
|
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
152
|
+
channel.failed? ? report(*channel.error) : report(OK)
|
|
126
153
|
end
|
|
127
154
|
|
|
128
|
-
def
|
|
129
|
-
|
|
155
|
+
def mode?(name, mode)
|
|
156
|
+
name.split(",").drop(1).any? { |field| field.strip[0]&.upcase == mode }
|
|
130
157
|
end
|
|
131
158
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
152
|
-
#
|
|
153
|
-
def
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return report(
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
239
|
+
def reset(cold:)
|
|
240
|
+
@memory.clear if cold
|
|
241
|
+
@channels.clear
|
|
242
|
+
report(DOS_VERSION)
|
|
220
243
|
end
|
|
221
244
|
|
|
222
|
-
|
|
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
|
|
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
|
|
@@ -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).
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
55
|
+
finish(data, complete)
|
|
24
56
|
end
|
|
25
|
-
return_to_caller
|
|
26
57
|
end
|
|
27
58
|
|
|
28
59
|
private
|
|
29
60
|
|
|
30
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
40
|
-
|
|
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).
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|