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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -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 +6 -4
- 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 +8 -0
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia.rb +4 -2
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +11 -6
- data/lib/badline/cpu.rb +2 -2
- data/lib/badline/gui/application.rb +41 -9
- data/lib/badline/kernal_trap/channel.rb +99 -7
- 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.rb +88 -110
- data/lib/badline/kernal_trap/file.rb +0 -5
- data/lib/badline/kernal_trap/load.rb +6 -0
- data/lib/badline/kernal_trap/save.rb +5 -4
- data/lib/badline/media.rb +2 -2
- data/lib/badline/memory.rb +2 -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/storage/disk_image.rb +22 -0
- data/lib/badline/storage/host_directory.rb +15 -3
- data/lib/badline/storage/sid_file.rb +36 -2
- 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 +5 -2
- data/lib/badline.rb +1 -0
- 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, :
|
|
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
|
-
/\
|
|
56
|
-
/\
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
@
|
|
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
|
|
@@ -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).
|
|
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:,
|
|
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 @
|
|
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(
|
|
63
|
-
computer.type_text(
|
|
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:)
|
data/lib/badline/memory.rb
CHANGED
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 =
|
|
18
|
+
data = read(filename)
|
|
8
19
|
new(data, length: data.length, start: start)
|
|
9
20
|
end
|
|
10
21
|
|
|
11
|
-
|
|
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
|
|