badline 0.2.0 → 0.2.1

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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/kernal_trap/drive/status"
4
+
3
5
  module Badline
4
6
  module KernalTrap
5
7
  # The CBM DOS side of the serial bus. Keeps the open channels, the
@@ -14,29 +16,12 @@ module Badline
14
16
  WRITE_PROTECT_ON = 26
15
17
  SYNTAX_ERROR = 30
16
18
  FILE_NOT_FOUND = 62
19
+ FILE_EXISTS = 63
17
20
  ILLEGAL_TRACK_OR_SECTOR = 66
18
21
  NO_CHANNEL = 70
22
+ DOS_VERSION = 73
19
23
  DRIVE_NOT_READY = 74
20
24
 
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
25
  MEMORY_COMMANDS = { "M-W" => :memory_write, "M-R" => :memory_read }.freeze
41
26
 
42
27
  RAM_SIZE = 0x800
@@ -50,20 +35,34 @@ module Badline
50
35
  JOB_OK = 1
51
36
  HEADER_NOT_FOUND = 20
52
37
 
38
+ # A U command jumps through the user table at $FFEA, indexed by the
39
+ # low nibble of its second character less one, so UA and UQ are U1.
40
+ # U1 and U2 read and write a block. U3 to U8 run code in the buffer
41
+ # at $0500, and the five entries past the table's end take vectors
42
+ # from the job queue. Neither runs here, so they report OK as a
43
+ # routine that returns does. U9 (UI) takes the NMI vector and U; the
44
+ # IRQ one, which restart the DOS with its RAM intact, except that a
45
+ # + or - after U9 only switches the bus speed. U: takes the reset
46
+ # vector, which runs the RAM test. U0 restores the table.
47
+ USER_TABLE = [:block_read, :write_protected, *[:initialized] * 6,
48
+ :warm_reset, :cold_reset, :warm_reset, *[:initialized] * 5].freeze
49
+
53
50
  COMMANDS = {
54
- /\AU[1A]\s*:?\s*(.*)/i => :block_read,
51
+ /\AU[9IY)][+-]/i => :initialized,
52
+ /\AU.\s*:?\s*(.*)/i => :user,
55
53
  /\AB-R\s*:?\s*(.*)/i => :counted_block_read,
56
54
  /\AB-P\s*:?\s*(.*)/i => :buffer_pointer,
57
55
  /\A[IV]/i => :initialized,
58
- /\A(?:U[2B]|B-[WAF])/i => :write_protected
56
+ /\AB-[WAF]/i => :write_protected
59
57
  }.freeze
60
58
 
59
+ # A drive powers on reporting its DOS version, as a reset does.
61
60
  def initialize(storage)
62
61
  @storage = storage
63
62
  @channels = {}
64
- @status = Channel.new
63
+ @status = Status.new
65
64
  @ram = Array.new(RAM_SIZE, 0)
66
- report(OK)
65
+ report(DOS_VERSION)
67
66
  end
68
67
 
69
68
  # A name on the command channel is a command, "#" opens a block
@@ -86,35 +85,63 @@ module Badline
86
85
  end
87
86
 
88
87
  def write(secondary, bytes)
89
- return report(WRITE_PROTECT_ON) unless secondary == COMMAND_CHANNEL
88
+ return command(bytes) if secondary == COMMAND_CHANNEL
89
+
90
+ report(WRITE_PROTECT_ON) if listening?(secondary)
91
+ end
90
92
 
91
- command(bytes)
93
+ # Whether data sent on the channel reaches the drive. The command
94
+ # channel always listens, other channels once they are open.
95
+ def listening?(secondary)
96
+ secondary == COMMAND_CHANNEL || @channels.key?(secondary)
92
97
  end
93
98
 
94
99
  # Returns the next byte and whether it is the channel's last, or nil
95
100
  # when there is nothing left to send.
96
101
  def read(secondary)
97
- secondary == COMMAND_CHANNEL ? read_status : @channels[secondary]&.read
98
- end
99
-
100
- private
101
-
102
- def read_status
103
- result = @status.read
104
- report(OK) if result&.last
102
+ return @status.read if secondary == COMMAND_CHANNEL
105
103
 
104
+ channel = @channels[secondary]
105
+ result = channel&.read
106
+ report(*channel.error) if result && channel.error && channel.exhausted?
106
107
  result
107
108
  end
108
109
 
110
+ private
111
+
109
112
  # Secondary addresses 0 and 1 are LOAD and SAVE, which look for a PRG
110
113
  # file unless the name asks for another type. Other channels take any
111
114
  # type the name doesn't pin down.
112
115
  def open_file(secondary, name)
113
116
  file, type = Storage.parse_name(name)
117
+ return refuse_write(secondary, name, file) if writing?(secondary, name)
118
+
114
119
  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)
120
+ channel = @channels[secondary] = Channel.for_file(@storage, file, type)
121
+ channel.exhausted? && channel.error ? report(*channel.error) : report(OK)
122
+ end
123
+
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) }
127
+ end
128
+
129
+ # The disk is write-protected, so an open for writing fails and leaves
130
+ # the channel closed. A name already on the disk fails as FILE EXISTS
131
+ # unless @ asks to replace it, anything else as WRITE PROTECT ON.
132
+ def refuse_write(secondary, name, file)
133
+ @channels.delete(secondary)
134
+ return report(FILE_EXISTS) if !name.start_with?("@") && @storage.read_file(file, type: nil)
135
+
136
+ report(WRITE_PROTECT_ON, *protected_block(secondary))
137
+ end
138
+
139
+ # SAVE's channel fails at the directory block its entry would go to,
140
+ # a W mode open at the disk's header block.
141
+ def protected_block(secondary)
142
+ return [] unless @storage.respond_to?(:header_block)
143
+
144
+ secondary == 1 ? @storage.new_entry_block : @storage.header_block
118
145
  end
119
146
 
120
147
  def execute(text)
@@ -122,7 +149,8 @@ module Badline
122
149
  pattern, action = COMMANDS.find { |candidate, _| candidate.match?(command) }
123
150
  return report(SYNTAX_ERROR) unless action
124
151
 
125
- send(action, arguments(pattern.match(command)[1]))
152
+ action = USER_TABLE[(command.getbyte(1) - 1) & 0x0f] if action == :user
153
+ run_command(action, arguments(pattern.match(command)[1]))
126
154
  end
127
155
 
128
156
  def arguments(text)
@@ -131,7 +159,21 @@ module Badline
131
159
 
132
160
  def command(bytes)
133
161
  action = MEMORY_COMMANDS[bytes[0, 3].pack("C*")]
134
- action ? send(action, bytes[3..]) : execute(Storage.ascii(bytes))
162
+ action ? run_command(action, bytes[3..]) : execute(Storage.ascii(bytes))
163
+ end
164
+
165
+ def run_command(action, arguments)
166
+ case action
167
+ when :block_read then block_read(arguments)
168
+ when :counted_block_read then counted_block_read(arguments)
169
+ when :buffer_pointer then buffer_pointer(arguments)
170
+ when :initialized then report(OK)
171
+ when :write_protected then write_protected(arguments)
172
+ when :cold_reset then reset(cold: true)
173
+ when :warm_reset then reset(cold: false)
174
+ when :memory_write then memory_write(arguments)
175
+ when :memory_read then memory_read(arguments)
176
+ end
135
177
  end
136
178
 
137
179
  def block_read(arguments, counted: false)
@@ -219,21 +261,17 @@ module Badline
219
261
  error == DRIVE_NOT_READY ? 15 : error - 18
220
262
  end
221
263
 
222
- def initialized(_arguments)
223
- report(OK)
224
- end
225
-
226
264
  def write_protected(_arguments)
227
265
  report(WRITE_PROTECT_ON)
228
266
  end
229
267
 
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
268
+ def reset(cold:)
269
+ @ram.fill(0) if cold
270
+ @channels.clear
271
+ report(DOS_VERSION)
236
272
  end
273
+
274
+ def report(...) = @status.report(...)
237
275
  end
238
276
  end
239
277
  end
@@ -5,7 +5,7 @@ module Badline
5
5
  class File < Routine
6
6
  MISSING_FILENAME = 0x08
7
7
 
8
- def initialize(cpu:, bus:, storage:)
8
+ def initialize(cpu:, bus:, storage: nil)
9
9
  super(cpu:, bus:)
10
10
  @storage = storage
11
11
  end
@@ -3,41 +3,112 @@
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
+ def initialize(cpu:, bus:, drive:)
34
+ super(cpu:, bus:)
35
+ @drive = drive
36
+ end
13
37
 
14
38
  def call
15
39
  return unless active?
16
40
 
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)
41
+ @bus.poke(0x93, @cpu.a)
42
+ @bus.poke(0x90, 0x00)
43
+ name = filename
44
+ return @cpu.program_counter = MISSING_FILE_NAME_EXIT if Storage.parse_name(name).first.empty?
45
+
46
+ data, complete = receive(name)
47
+ if low_memory?(data)
48
+ @drive.close(0)
22
49
  else
23
- error(FILE_NOT_FOUND)
50
+ finish(data, complete)
24
51
  end
25
- return_to_caller
26
52
  end
27
53
 
28
54
  private
29
55
 
30
- # A=0 is LOAD, A=1 is VERIFY
56
+ # Reads the file the way the ROM's byte loop would, up to the byte
57
+ # flagged EOI. A read error in the file's chain stops the bytes early,
58
+ # with no EOI.
59
+ def receive(name)
60
+ @drive.open(0, name)
61
+ data = []
62
+ loop do
63
+ byte, eoi = @drive.read(0)
64
+ return [data, false] unless byte
65
+
66
+ data << byte
67
+ return [data, true] if eoi
68
+ end
69
+ end
70
+
71
+ # No first byte means the file isn't there, or its first block didn't
72
+ # read. A later read error leaves the ROM retrying the next byte
73
+ # until RUN/STOP breaks the load, as on a real drive.
74
+ def finish(data, complete)
75
+ if data.empty?
76
+ @bus.poke(0x90, EOI | READ_TIMEOUT)
77
+ continue_with(SEARCHING_MESSAGE, FILE_NOT_FOUND_EXIT)
78
+ else
79
+ deliver(data)
80
+ @bus.poke(0x90, @bus.peek(0x90) & ~EOI) unless complete
81
+ continue_with(SEARCHING_MESSAGE, LOADING_MESSAGE, complete ? LOAD_DONE : BYTE_LOOP)
82
+ end
83
+ end
84
+
85
+ def low_memory?(data)
86
+ load? && data.length > 2 && load_address(data) < 0x0334
87
+ end
88
+
89
+ # A=0 is LOAD, A=1 is VERIFY, kept at $93 (VERCK)
31
90
  def load?
32
- @cpu.a.zero?
91
+ @bus.peek(0x93).zero?
33
92
  end
34
93
 
35
94
  def deliver(data)
36
95
  addr = load_address(data)
37
96
  payload = data[2..] || []
38
97
  payload = payload[0, 0x10000 - addr] if addr + payload.length > 0x10000
39
- @bus.ram.write(addr, payload) if load?
40
- finish((addr + payload.length) & 0xffff)
98
+ if load?
99
+ @bus.ram.write(addr, payload)
100
+ @bus.poke(0x90, EOI)
101
+ else
102
+ verify(addr, payload)
103
+ end
104
+ end_addr = (addr + payload.length) & 0xffff
105
+ @bus.poke(0xae, low_byte(end_addr))
106
+ @bus.poke(0xaf, high_byte(end_addr))
107
+ end
108
+
109
+ def verify(addr, payload)
110
+ match = payload.each_with_index.all? { |byte, i| @bus.peek(addr + i) == byte }
111
+ @bus.poke(0x90, match ? EOI : EOI | VERIFY_MISMATCH)
41
112
  end
42
113
 
43
114
  # Secondary address $B9 zero relocates to the caller-supplied address
@@ -46,18 +117,9 @@ module Badline
46
117
  if @bus.peek(0xb9).zero?
47
118
  uint16(@bus.peek(0xc3), @bus.peek(0xc4))
48
119
  else
49
- uint16(data[0], data[1])
120
+ uint16(data[0], data[1].to_i)
50
121
  end
51
122
  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
123
  end
62
124
  end
63
125
  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)
@@ -5,25 +5,62 @@ module Badline
5
5
  # PC trap on the KERNAL serial SAVE routine ($F5ED, the default ISAVE
6
6
  # vector target). Writes device 8 saves to a storage backend 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:, storage:)
32
+ super
33
+ @saving = false
34
+ end
35
+
12
36
  def call
13
37
  return unless active?
38
+ return finish if @saving
39
+
40
+ @bus.poke(0xb9, SECONDARY)
41
+ return @cpu.program_counter = MISSING_FILE_NAME_EXIT if name.empty?
14
42
 
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
43
+ @bus.poke(0x90, 0x00)
44
+ @saving = true
45
+ continue_with(SAVING_MESSAGE, ADDRESS)
23
46
  end
24
47
 
25
48
  private
26
49
 
50
+ def name
51
+ Storage.parse_name(filename).first
52
+ end
53
+
54
+ def finish
55
+ @saving = false
56
+ @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @storage.write_file(name, payload)
57
+ @bus.poke(0xac, @bus.peek(0xae))
58
+ @bus.poke(0xad, @bus.peek(0xaf))
59
+ @cpu.y = 0
60
+ @cpu.status.overflow = true
61
+ continue_with(CLOCK_RELEASE, DATA_RELEASE, SAVE_DONE)
62
+ end
63
+
27
64
  # Start address at $C1/$C2 (STAL), end address (exclusive) at
28
65
  # $AE/$AF (EAL)
29
66
  def payload
@@ -32,11 +69,6 @@ module Badline
32
69
  [low_byte(start), high_byte(start)] +
33
70
  Array.new(length) { |i| @bus.peek((start + i) & 0xffff) }
34
71
  end
35
-
36
- def finish
37
- @bus.poke(0x90, 0x00)
38
- @cpu.status.carry = false
39
- end
40
72
  end
41
73
  end
42
74
  end
@@ -23,6 +23,7 @@ module Badline
23
23
  CLOSE = 0xe0
24
24
 
25
25
  # ST bits at $90
26
+ DEVICE_NOT_PRESENT = 0x80
26
27
  EOI = 0x40
27
28
  READ_TIMEOUT = 0x02
28
29
 
@@ -41,13 +42,26 @@ module Badline
41
42
 
42
43
  def install
43
44
  ROUTINES.each do |address, routine|
44
- @cpu.install_trap(address) { send(routine) if kernal? }
45
+ @cpu.install_trap(address) { call_routine(routine) if kernal? }
45
46
  end
46
47
  self
47
48
  end
48
49
 
49
50
  private
50
51
 
52
+ def call_routine(routine)
53
+ case routine
54
+ when :talk then talk
55
+ when :listen then listen
56
+ when :second then second
57
+ when :tksa then tksa
58
+ when :ciout then ciout
59
+ when :untalk then untalk
60
+ when :unlisten then unlisten
61
+ when :acptr then acptr
62
+ end
63
+ end
64
+
51
65
  # Addressing the bus starts a new frame. The channel number arrives
52
66
  # in the secondary address that follows.
53
67
  def talk
@@ -82,22 +96,40 @@ module Badline
82
96
  return unless @listening
83
97
 
84
98
  @buffer << @cpu.a
99
+ update_status(DEVICE_NOT_PRESENT) unless accepted?
85
100
  @cpu.status.carry = false
86
101
  return_to_caller
87
102
  end
88
103
 
104
+ # The drive acknowledges every byte of an open or close frame, but
105
+ # takes data only on a channel it has open. Otherwise nothing holds
106
+ # the data line when a byte starts, so the KERNAL finds no device.
107
+ def accepted?
108
+ [OPEN, CLOSE].include?(@frame) || @drive.listening?(@listen_channel)
109
+ end
110
+
89
111
  def unlisten
90
112
  return unless @listening
91
113
 
92
114
  @listening = false
93
115
  deliver_frame
94
- return_to_caller
116
+ release_bus
95
117
  end
96
118
 
97
119
  def untalk
98
120
  return unless @talking
99
121
 
100
122
  @talking = false
123
+ release_bus
124
+ end
125
+
126
+ # UNLSN and UNTLK end by releasing ATN, the clock and the data line on
127
+ # CIA 2's port A, and leave the port's last read in the accumulator.
128
+ def release_bus
129
+ @bus.poke(0xdd00, @bus.peek(0xdd00) & 0xc7)
130
+ @cpu.a = @bus.peek(0xdd00) & 0xdf
131
+ @cpu.status.negative = @cpu.a.anybits?(0x80)
132
+ @cpu.status.zero = @cpu.a.zero?
101
133
  return_to_caller
102
134
  end
103
135
 
@@ -26,6 +26,11 @@ module Badline
26
26
  Array(bytes).each_with_index { |b, i| poke(addr + i, b) }
27
27
  end
28
28
 
29
+ # Refills the whole memory as if newly built with `initial`
30
+ def clear!(initial = [])
31
+ @storage.replace(zero_fill(initial))
32
+ end
33
+
29
34
  private
30
35
 
31
36
  def blank_value