badline 0.2.1 → 0.4.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/.claude/hooks/session-start.sh +57 -0
- data/.claude/settings.json +14 -0
- data/CHANGELOG.md +107 -0
- data/CLAUDE.md +51 -16
- data/CONTRIBUTING.md +95 -3
- data/README.md +119 -51
- data/SECURITY.md +9 -0
- data/doc/pinned-behaviour.md +293 -39
- data/exe/badline-ruby +59 -0
- data/lib/badline/address_bus.rb +28 -14
- data/lib/badline/addressable.rb +1 -1
- data/lib/badline/audio/aiff.rb +8 -3
- data/lib/badline/audio/bare_player.rb +20 -3
- data/lib/badline/audio/cli.rb +17 -12
- data/lib/badline/audio/console.rb +12 -58
- data/lib/badline/audio/machine_player.rb +9 -6
- data/lib/badline/audio/playback.rb +3 -0
- data/lib/badline/audio/renderer.rb +3 -3
- data/lib/badline/audio/sdl_sink.rb +20 -52
- data/lib/badline/audio/stream.rb +117 -0
- data/lib/badline/audio/terminal.rb +90 -0
- data/lib/badline/audio.rb +3 -2
- data/lib/badline/cartridge/easy_flash.rb +6 -2
- data/lib/badline/cartridge/epyx_fastload.rb +1 -1
- data/lib/badline/cartridge/geo_ram.rb +63 -0
- 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 +9 -0
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia/interrupt_register.rb +34 -10
- data/lib/badline/cia.rb +19 -10
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +34 -14
- data/lib/badline/cpu.rb +2 -2
- data/lib/badline/gui/application.rb +70 -26
- data/lib/badline/gui/gamepads.rb +22 -22
- data/lib/badline/gui/joy_map.rb +1 -1
- data/lib/badline/gui/key_map.rb +6 -1
- data/lib/badline/gui/pane.rb +7 -6
- data/lib/badline/gui/screen_pane.rb +9 -13
- data/lib/badline/gui/window.rb +35 -20
- data/lib/badline/gui.rb +1 -1
- data/lib/badline/input/mouse1351.rb +21 -6
- data/lib/badline/input/paddles.rb +11 -6
- data/lib/badline/kernal_trap/channel.rb +102 -7
- data/lib/badline/kernal_trap/drive/block_commands.rb +108 -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 +3 -0
- data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
- data/lib/badline/kernal_trap/drive/writes.rb +92 -0
- data/lib/badline/kernal_trap/drive.rb +109 -131
- data/lib/badline/kernal_trap/file.rb +6 -14
- data/lib/badline/kernal_trap/load.rb +6 -0
- data/lib/badline/kernal_trap/save.rb +13 -7
- data/lib/badline/keyboard.rb +19 -3
- data/lib/badline/media.rb +44 -13
- data/lib/badline/memory.rb +2 -2
- data/lib/badline/options.rb +209 -0
- data/lib/badline/ram_expansion/banking.rb +30 -0
- data/lib/badline/ram_expansion/plus256k.rb +38 -0
- data/lib/badline/ram_expansion/plus60k.rb +31 -0
- data/lib/badline/ram_expansion/unexpanded.rb +22 -0
- data/lib/badline/ram_expansion.rb +21 -0
- data/lib/badline/region.rb +51 -0
- data/lib/badline/rom.rb +14 -5
- data/lib/badline/sdl/events.rb +47 -0
- data/lib/badline/sdl/functions.rb +73 -0
- data/lib/badline/sdl.rb +67 -0
- 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/noise_writeback.rb +32 -14
- data/lib/badline/sid/waveform.rb +8 -8
- data/lib/badline/sid.rb +19 -93
- data/lib/badline/storage/d64_image.rb +10 -0
- data/lib/badline/storage/d71_image.rb +12 -0
- data/lib/badline/storage/d81_image.rb +9 -0
- data/lib/badline/storage/disk_image/bam.rb +111 -0
- data/lib/badline/storage/disk_image/directory.rb +79 -0
- data/lib/badline/storage/disk_image/writing.rb +110 -0
- data/lib/badline/storage/disk_image.rb +49 -4
- data/lib/badline/storage/host_directory.rb +17 -4
- data/lib/badline/storage/sid_file.rb +36 -2
- data/lib/badline/storage.rb +18 -1
- data/lib/badline/time_of_day.rb +11 -2
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +104 -0
- data/lib/badline/via/interrupt_register.rb +58 -0
- data/lib/badline/via/shift_register.rb +135 -0
- data/lib/badline/via/timer1.rb +82 -0
- data/lib/badline/via/timer2.rb +84 -0
- data/lib/badline/via.rb +262 -0
- data/lib/badline/vic/bank.rb +4 -3
- data/lib/badline/vic/color_patches.rb +6 -3
- data/lib/badline/vic/display_state.rb +4 -0
- data/lib/badline/vic/graphics_shifter.rb +49 -8
- data/lib/badline/vic/registers.rb +19 -27
- data/lib/badline/vic/sequencer.rb +18 -18
- data/lib/badline/vic/sequencer_output.rb +4 -4
- data/lib/badline/vic/sprite/internal_bus.rb +4 -0
- data/lib/badline/vic/sprite/shifter.rb +25 -3
- data/lib/badline/vic/sprite.rb +5 -4
- data/lib/badline/vic/sprites.rb +8 -3
- data/lib/badline/vic.rb +98 -46
- data/lib/badline.rb +5 -0
- data/packaging/homebrew/badline.rb +34 -0
- metadata +37 -20
- data/exe/badline +0 -75
- data/exe/badline-sid +0 -34
- data/lib/badline/audio/options.rb +0 -137
|
@@ -1,22 +1,35 @@
|
|
|
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"
|
|
8
|
+
require "badline/kernal_trap/drive/writes"
|
|
9
|
+
require "badline/kernal_trap/drive/write_file"
|
|
4
10
|
|
|
5
11
|
module Badline
|
|
6
12
|
module KernalTrap
|
|
7
13
|
# The CBM DOS side of the serial bus. Keeps the open channels, the
|
|
8
14
|
# command channel and its status message. `U1` reads a raw block into a
|
|
9
15
|
# buffer channel, which is how block-access loaders bypass the KERNAL's
|
|
10
|
-
# LOAD.
|
|
16
|
+
# LOAD. A disk image whose host file takes writes takes files, blocks
|
|
17
|
+
# and BAM changes too. Other disks are write-protected, though storage
|
|
18
|
+
# that can write files still takes a SAVE handed over whole.
|
|
11
19
|
class Drive
|
|
20
|
+
include BlockCommands
|
|
21
|
+
include Writes
|
|
22
|
+
|
|
12
23
|
COMMAND_CHANNEL = 15
|
|
13
24
|
BLOCK_SIZE = 256
|
|
14
25
|
|
|
15
26
|
OK = 0
|
|
27
|
+
FILES_SCRATCHED = 1
|
|
16
28
|
WRITE_PROTECT_ON = 26
|
|
17
29
|
SYNTAX_ERROR = 30
|
|
18
30
|
FILE_NOT_FOUND = 62
|
|
19
31
|
FILE_EXISTS = 63
|
|
32
|
+
NO_BLOCK = 65
|
|
20
33
|
ILLEGAL_TRACK_OR_SECTOR = 66
|
|
21
34
|
NO_CHANNEL = 70
|
|
22
35
|
DOS_VERSION = 73
|
|
@@ -24,17 +37,6 @@ module Badline
|
|
|
24
37
|
|
|
25
38
|
MEMORY_COMMANDS = { "M-W" => :memory_write, "M-R" => :memory_read }.freeze
|
|
26
39
|
|
|
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
40
|
# A U command jumps through the user table at $FFEA, indexed by the
|
|
39
41
|
# low nibble of its second character less one, so UA and UQ are U1.
|
|
40
42
|
# U1 and U2 read and write a block. U3 to U8 run code in the buffer
|
|
@@ -44,7 +46,7 @@ module Badline
|
|
|
44
46
|
# IRQ one, which restart the DOS with its RAM intact, except that a
|
|
45
47
|
# + or - after U9 only switches the bus speed. U: takes the reset
|
|
46
48
|
# vector, which runs the RAM test. U0 restores the table.
|
|
47
|
-
USER_TABLE = [:block_read, :
|
|
49
|
+
USER_TABLE = [:block_read, :block_write, *[:initialized] * 6,
|
|
48
50
|
:warm_reset, :cold_reset, :warm_reset, *[:initialized] * 5].freeze
|
|
49
51
|
|
|
50
52
|
COMMANDS = {
|
|
@@ -52,50 +54,95 @@ module Badline
|
|
|
52
54
|
/\AU.\s*:?\s*(.*)/i => :user,
|
|
53
55
|
/\AB-R\s*:?\s*(.*)/i => :counted_block_read,
|
|
54
56
|
/\AB-P\s*:?\s*(.*)/i => :buffer_pointer,
|
|
55
|
-
/\
|
|
56
|
-
/\
|
|
57
|
+
/\AB-W\s*:?\s*(.*)/i => :counted_block_write,
|
|
58
|
+
/\AI/i => :initialize_disk,
|
|
59
|
+
/\AV/i => :initialized,
|
|
60
|
+
/\AB-A\s*:?\s*(.*)/i => :block_allocate,
|
|
61
|
+
/\AB-F\s*:?\s*(.*)/i => :block_free,
|
|
62
|
+
/\AS[^:]*:(.*)/im => :scratch
|
|
57
63
|
}.freeze
|
|
58
64
|
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
# Commands that take their argument as text rather than numbers.
|
|
66
|
+
TEXT_COMMANDS = %i[scratch].freeze
|
|
67
|
+
|
|
68
|
+
# A drive powers on reporting its DOS version, as a reset does. It
|
|
69
|
+
# can take the RAM of a drive it replaces, and initializes its disk.
|
|
70
|
+
def initialize(storage, memory = Memory.new)
|
|
71
|
+
@channels = Channels.new
|
|
63
72
|
@status = Status.new
|
|
64
|
-
@
|
|
73
|
+
@memory = memory
|
|
74
|
+
insert(storage)
|
|
65
75
|
report(DOS_VERSION)
|
|
66
76
|
end
|
|
67
77
|
|
|
78
|
+
# Changes the disk. The drive keeps its RAM and its status, closes
|
|
79
|
+
# its channels, and initializes the new disk.
|
|
80
|
+
def insert(storage)
|
|
81
|
+
@storage = storage
|
|
82
|
+
@channels.clear
|
|
83
|
+
@memory.storage = storage
|
|
84
|
+
@memory.read_bam
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether the disk takes a SAVE handed over whole.
|
|
88
|
+
def saves? = @storage.respond_to?(:write_file)
|
|
89
|
+
|
|
68
90
|
# A name on the command channel is a command, "#" opens a block
|
|
69
91
|
# buffer, anything else opens a file for reading. The name comes as
|
|
70
92
|
# the bytes sent, because a memory command's arguments are binary.
|
|
93
|
+
# Opening a data channel initializes a disk a reset left uninitialized.
|
|
71
94
|
def open(secondary, name)
|
|
95
|
+
@memory.read_bam unless @memory.bam? || secondary == COMMAND_CHANNEL
|
|
72
96
|
if secondary == COMMAND_CHANNEL
|
|
73
97
|
command(name.bytes) unless name.empty?
|
|
74
98
|
elsif name.start_with?("#")
|
|
75
|
-
@channels
|
|
76
|
-
report(OK)
|
|
99
|
+
opened = @channels.open_buffer(secondary, Parameters.parse(name[1..]).first, @memory)
|
|
100
|
+
report(opened ? OK : NO_CHANNEL)
|
|
77
101
|
else
|
|
78
|
-
open_file(secondary, Storage.ascii(name.bytes))
|
|
102
|
+
open_file(secondary, Storage.ascii(without_return(name.bytes)))
|
|
79
103
|
end
|
|
80
104
|
end
|
|
81
105
|
|
|
82
|
-
# Closing the command channel closes every other channel with it
|
|
106
|
+
# Closing the command channel closes every other channel with it,
|
|
107
|
+
# and a file open for writing is lost. Closing a file open for
|
|
108
|
+
# writing writes it to the disk.
|
|
83
109
|
def close(secondary)
|
|
84
|
-
|
|
110
|
+
return @channels.clear if secondary == COMMAND_CHANNEL
|
|
111
|
+
|
|
112
|
+
channel = @channels.close(secondary)
|
|
113
|
+
writing { channel.store(@storage) } if channel.is_a?(WriteFile)
|
|
85
114
|
end
|
|
86
115
|
|
|
116
|
+
# A buffer channel takes the bytes into its block, and a file open for
|
|
117
|
+
# writing gathers them. A file open for reading fails as a
|
|
118
|
+
# write-protected disk does.
|
|
87
119
|
def write(secondary, bytes)
|
|
88
120
|
return command(bytes) if secondary == COMMAND_CHANNEL
|
|
89
121
|
|
|
90
|
-
|
|
122
|
+
channel = @channels[secondary]
|
|
123
|
+
return unless channel
|
|
124
|
+
|
|
125
|
+
channel.writable? ? channel.write(bytes) : report(WRITE_PROTECT_ON)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A SAVE the trap hands over whole, to storage that can write files.
|
|
129
|
+
# A disk image refuses a name it already has unless `replace` asks to
|
|
130
|
+
# write over it, and a write the host refuses fails as a
|
|
131
|
+
# write-protected disk does. Returns whether the file was written.
|
|
132
|
+
def save(name, bytes, replace: false)
|
|
133
|
+
writing { @storage.write_file(name, bytes, type: :prg, replace:) }
|
|
91
134
|
end
|
|
92
135
|
|
|
93
136
|
# Whether data sent on the channel reaches the drive. The command
|
|
94
137
|
# channel always listens, other channels once they are open.
|
|
95
138
|
def listening?(secondary)
|
|
96
|
-
secondary == COMMAND_CHANNEL || @channels.
|
|
139
|
+
secondary == COMMAND_CHANNEL || @channels.open?(secondary)
|
|
97
140
|
end
|
|
98
141
|
|
|
142
|
+
# The serial bus's RESET line restarts the DOS through its reset
|
|
143
|
+
# vector, as U: does.
|
|
144
|
+
def reset! = reset(cold: true)
|
|
145
|
+
|
|
99
146
|
# Returns the next byte and whether it is the channel's last, or nil
|
|
100
147
|
# when there is nothing left to send.
|
|
101
148
|
def read(secondary)
|
|
@@ -109,39 +156,30 @@ module Badline
|
|
|
109
156
|
|
|
110
157
|
private
|
|
111
158
|
|
|
159
|
+
# The DOS cuts a name of two or more bytes at a carriage return in
|
|
160
|
+
# its last or second-to-last byte, as PRINT# ends a line with one.
|
|
161
|
+
def without_return(bytes)
|
|
162
|
+
cut = bytes.rindex(0x0d)
|
|
163
|
+
cut && bytes.length > 1 && cut >= bytes.length - 2 ? bytes[0, cut] : bytes
|
|
164
|
+
end
|
|
165
|
+
|
|
112
166
|
# Secondary addresses 0 and 1 are LOAD and SAVE, which look for a PRG
|
|
113
167
|
# file unless the name asks for another type. Other channels take any
|
|
114
|
-
# type the name doesn't pin down.
|
|
168
|
+
# type the name doesn't pin down, and write a SEQ file.
|
|
115
169
|
def open_file(secondary, name)
|
|
116
170
|
file, type = Storage.parse_name(name)
|
|
117
|
-
return refuse_write(secondary, name, file) if writing?(secondary, name)
|
|
118
|
-
|
|
119
171
|
type ||= :prg if secondary < 2
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
end
|
|
172
|
+
return open_write(secondary, name, file, type) if secondary == 1 || mode?(name, "W")
|
|
173
|
+
return open_append(secondary, file, type) if mode?(name, "A")
|
|
123
174
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
secondary == 1 || name.split(",").drop(1).any? { |field| field.strip.match?(/\AW/i) }
|
|
127
|
-
end
|
|
175
|
+
channel = @channels.open_file(secondary, Channel.for_name(@storage, @memory, secondary, name, type))
|
|
176
|
+
return report(NO_CHANNEL) unless channel
|
|
128
177
|
|
|
129
|
-
|
|
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))
|
|
178
|
+
channel.failed? ? report(*channel.error) : report(OK)
|
|
137
179
|
end
|
|
138
180
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def protected_block(secondary)
|
|
142
|
-
return [] unless @storage.respond_to?(:header_block)
|
|
143
|
-
|
|
144
|
-
secondary == 1 ? @storage.new_entry_block : @storage.header_block
|
|
181
|
+
def mode?(name, mode)
|
|
182
|
+
name.split(",").drop(1).any? { |field| field.strip[0]&.upcase == mode }
|
|
145
183
|
end
|
|
146
184
|
|
|
147
185
|
def execute(text)
|
|
@@ -150,11 +188,8 @@ module Badline
|
|
|
150
188
|
return report(SYNTAX_ERROR) unless action
|
|
151
189
|
|
|
152
190
|
action = USER_TABLE[(command.getbyte(1) - 1) & 0x0f] if action == :user
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def arguments(text)
|
|
157
|
-
text.to_s.scan(/\d+/).map(&:to_i)
|
|
191
|
+
argument = pattern.match(command)[1]
|
|
192
|
+
run_command(action, TEXT_COMMANDS.include?(action) ? argument : Parameters.parse(argument))
|
|
158
193
|
end
|
|
159
194
|
|
|
160
195
|
def command(bytes)
|
|
@@ -165,10 +200,15 @@ 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)
|
|
171
|
-
when :
|
|
208
|
+
when :initialize_disk then initialize_disk
|
|
209
|
+
when :block_allocate then block_allocate(arguments)
|
|
210
|
+
when :block_free then block_free(arguments)
|
|
211
|
+
when :scratch then scratch(arguments)
|
|
172
212
|
when :cold_reset then reset(cold: true)
|
|
173
213
|
when :warm_reset then reset(cold: false)
|
|
174
214
|
when :memory_write then memory_write(arguments)
|
|
@@ -176,61 +216,19 @@ module Badline
|
|
|
176
216
|
end
|
|
177
217
|
end
|
|
178
218
|
|
|
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.
|
|
219
|
+
# M-W takes an address, a count and that many bytes.
|
|
217
220
|
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
|
|
221
|
+
@memory.write(word(arguments), arguments[3, arguments[2].to_i].to_a)
|
|
223
222
|
report(OK)
|
|
224
223
|
end
|
|
225
224
|
|
|
226
225
|
# M-R takes an address and a count, and answers on the command
|
|
227
226
|
# channel. Without a count, or with only the carriage return PRINT#
|
|
228
|
-
# ends a line with, it reads one byte.
|
|
227
|
+
# ends a line with, it reads one byte.
|
|
229
228
|
def memory_read(arguments)
|
|
230
|
-
address = word(arguments)
|
|
231
229
|
count = arguments[2..] == ["\r".ord] ? 1 : arguments.fetch(2, 1)
|
|
232
230
|
count = BLOCK_SIZE if count.zero?
|
|
233
|
-
@status.replace(
|
|
231
|
+
@status.replace(@memory.read(word(arguments), count))
|
|
234
232
|
nil
|
|
235
233
|
end
|
|
236
234
|
|
|
@@ -238,39 +236,19 @@ module Badline
|
|
|
238
236
|
arguments[0].to_i | (arguments[1].to_i << 8)
|
|
239
237
|
end
|
|
240
238
|
|
|
241
|
-
def run_jobs
|
|
242
|
-
JOBS.times do |job|
|
|
243
|
-
next unless @ram[job] == READ_JOB
|
|
244
|
-
|
|
245
|
-
track, sector = @ram[6 + (job * 2), 2]
|
|
246
|
-
@ram[job] = read_job(0x300 + (job * BLOCK_SIZE), track, sector)
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def read_job(buffer, track, sector)
|
|
251
|
-
data = @storage.respond_to?(:read_block) && @storage.read_block(track, sector)
|
|
252
|
-
return job_result(HEADER_NOT_FOUND) unless data
|
|
253
|
-
|
|
254
|
-
@ram[buffer, BLOCK_SIZE] = data
|
|
255
|
-
job_result(@storage.block_error(track, sector))
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def job_result(error)
|
|
259
|
-
return JOB_OK unless error
|
|
260
|
-
|
|
261
|
-
error == DRIVE_NOT_READY ? 15 : error - 18
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def write_protected(_arguments)
|
|
265
|
-
report(WRITE_PROTECT_ON)
|
|
266
|
-
end
|
|
267
|
-
|
|
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
|
|
@@ -3,31 +3,23 @@
|
|
|
3
3
|
module Badline
|
|
4
4
|
module KernalTrap
|
|
5
5
|
class File < Routine
|
|
6
|
-
MISSING_FILENAME = 0x08
|
|
7
|
-
|
|
8
|
-
def initialize(cpu:, bus:, storage: nil)
|
|
9
|
-
super(cpu:, bus:)
|
|
10
|
-
@storage = storage
|
|
11
|
-
end
|
|
12
|
-
|
|
13
6
|
private
|
|
14
7
|
|
|
15
8
|
def active?
|
|
16
9
|
kernal? && @bus.peek(0xba) == DEVICE
|
|
17
10
|
end
|
|
18
11
|
|
|
19
|
-
# Filename pointer at $BB/$BC, length at $B7
|
|
20
12
|
def filename
|
|
13
|
+
Storage.strip_drive_prefix(full_filename)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Filename pointer at $BB/$BC, length at $B7
|
|
17
|
+
def full_filename
|
|
21
18
|
pointer = uint16(@bus.peek(0xbb), @bus.peek(0xbc))
|
|
22
19
|
bytes = Array.new(@bus.peek(0xb7)) do |i|
|
|
23
20
|
@bus.peek((pointer + i) & 0xffff)
|
|
24
21
|
end
|
|
25
|
-
Storage.
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def error(code)
|
|
29
|
-
@cpu.a = code
|
|
30
|
-
@cpu.status.carry = true
|
|
22
|
+
Storage.ascii(bytes)
|
|
31
23
|
end
|
|
32
24
|
end
|
|
33
25
|
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,14 +3,17 @@
|
|
|
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, and so do saves to a disk that doesn't take them
|
|
9
|
+
# whole. The ROM prints SAVING in direct mode and returns
|
|
9
10
|
# into the trap, which then writes the file and leaves through the
|
|
10
11
|
# ROM's own tail with the registers its UNLISTEN and return leave. A
|
|
11
|
-
#
|
|
12
|
+
# write that fails, to a full disk, a name already on it or a host file
|
|
13
|
+
# that can't be written, ends the way a 1541 ends a SAVE it can't
|
|
12
14
|
# write: the drive stops listening, so ST reads DEVICE NOT PRESENT,
|
|
13
|
-
# and the ROM returns without an error.
|
|
15
|
+
# and the ROM returns without an error. A "@" before the drive prefix
|
|
16
|
+
# writes over a file of the same name.
|
|
14
17
|
class Save < File
|
|
15
18
|
ADDRESS = 0xf5ed
|
|
16
19
|
|
|
@@ -28,14 +31,16 @@ module Badline
|
|
|
28
31
|
# ST bit at $90
|
|
29
32
|
DEVICE_NOT_PRESENT = 0x80
|
|
30
33
|
|
|
31
|
-
def initialize(cpu:, bus:,
|
|
32
|
-
super
|
|
34
|
+
def initialize(cpu:, bus:, drive:)
|
|
35
|
+
super(cpu:, bus:)
|
|
36
|
+
@drive = drive
|
|
33
37
|
@saving = false
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def call
|
|
37
41
|
return unless active?
|
|
38
42
|
return finish if @saving
|
|
43
|
+
return unless @drive.saves?
|
|
39
44
|
|
|
40
45
|
@bus.poke(0xb9, SECONDARY)
|
|
41
46
|
return @cpu.program_counter = MISSING_FILE_NAME_EXIT if name.empty?
|
|
@@ -53,7 +58,8 @@ module Badline
|
|
|
53
58
|
|
|
54
59
|
def finish
|
|
55
60
|
@saving = false
|
|
56
|
-
@
|
|
61
|
+
saved = @drive.save(name, payload, replace: full_filename.start_with?("@"))
|
|
62
|
+
@bus.poke(0x90, DEVICE_NOT_PRESENT) unless saved
|
|
57
63
|
@bus.poke(0xac, @bus.peek(0xae))
|
|
58
64
|
@bus.poke(0xad, @bus.peek(0xaf))
|
|
59
65
|
@cpu.y = 0
|
data/lib/badline/keyboard.rb
CHANGED
|
@@ -11,6 +11,14 @@ module Badline
|
|
|
11
11
|
class Keyboard
|
|
12
12
|
include IntegerHelper
|
|
13
13
|
|
|
14
|
+
# Keys the C64 types as a combination: cursor up and cursor left are the
|
|
15
|
+
# shifted cursor keys. Pressing one holds both of its matrix keys, while
|
|
16
|
+
# a shift key held on its own stays down when it is released.
|
|
17
|
+
COMBINATIONS = {
|
|
18
|
+
cursor_up: %i[lshift cursor_v],
|
|
19
|
+
cursor_left: %i[lshift cursor_h]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
14
22
|
attr_reader :keys, :matrix
|
|
15
23
|
|
|
16
24
|
def initialize
|
|
@@ -70,13 +78,21 @@ module Badline
|
|
|
70
78
|
|
|
71
79
|
# Columns held down per row, as a bit mask of port B lines.
|
|
72
80
|
def row_masks
|
|
73
|
-
@row_masks ||=
|
|
74
|
-
|
|
81
|
+
@row_masks ||= begin
|
|
82
|
+
pressed = held
|
|
83
|
+
matrix.map do |row|
|
|
84
|
+
row.each_with_index.sum { |key, column| pressed.include?(key) ? 1 << column : 0 }
|
|
85
|
+
end
|
|
75
86
|
end
|
|
76
87
|
end
|
|
77
88
|
|
|
89
|
+
# The matrix keys held down, with each combination expanded.
|
|
90
|
+
def held
|
|
91
|
+
keys.flat_map { |key| COMBINATIONS[key] || [key] }
|
|
92
|
+
end
|
|
93
|
+
|
|
78
94
|
def valid_key?(key)
|
|
79
|
-
matrix.flatten.include?(key)
|
|
95
|
+
COMBINATIONS.key?(key) || matrix.flatten.include?(key)
|
|
80
96
|
end
|
|
81
97
|
end
|
|
82
98
|
end
|
data/lib/badline/media.rb
CHANGED
|
@@ -14,25 +14,39 @@ module Badline
|
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
16
|
class << self
|
|
17
|
-
# `cartridge
|
|
18
|
-
# as `{ flash_jumper: true }` for
|
|
19
|
-
|
|
17
|
+
# The options are the medium's own. `cartridge:` sets the jumpers of a
|
|
18
|
+
# .crt cartridge that has them, such as `{ flash_jumper: true }` for
|
|
19
|
+
# the Retro Replay's flash mode, and `disk: { read_only: true }` mounts
|
|
20
|
+
# a disk image write-protected, so nothing the program does writes to
|
|
21
|
+
# its file. Media they don't apply to ignore them.
|
|
22
|
+
def attach(computer, path, autostart: true, song: nil, **options)
|
|
20
23
|
if File.directory?(path)
|
|
21
24
|
computer.mount(Storage::HostDirectory.new(path))
|
|
22
25
|
"Mounted #{path} as device 8"
|
|
23
26
|
elsif File.extname(path).downcase == ".crt"
|
|
24
|
-
attach_cartridge(computer, path, cartridge)
|
|
27
|
+
attach_cartridge(computer, path, options.fetch(:cartridge, {}))
|
|
25
28
|
elsif File.extname(path).downcase == ".sid"
|
|
26
29
|
attach_sid(computer, path, autostart:, song:)
|
|
27
30
|
elsif File.extname(path).downcase == ".tap"
|
|
28
31
|
attach_tape(computer, path, autostart:)
|
|
29
|
-
elsif (
|
|
30
|
-
attach_storage(computer,
|
|
32
|
+
elsif MOUNT_TYPES.key?(File.extname(path).downcase)
|
|
33
|
+
attach_storage(computer, path, options.fetch(:disk, {}), autostart:)
|
|
31
34
|
else
|
|
32
35
|
attach_prg(computer, path, autostart:)
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
# Swaps the disk in device 8 for a disk image or a host directory,
|
|
40
|
+
# while the machine runs, without loading anything. The drive keeps
|
|
41
|
+
# its RAM and its status. `read_only` inserts a disk image
|
|
42
|
+
# write-protected.
|
|
43
|
+
def insert_disk(computer, path, read_only: false)
|
|
44
|
+
raise ArgumentError, "#{path} is not a disk image or a directory" unless disk?(path)
|
|
45
|
+
|
|
46
|
+
computer.mount(open_storage(path, { read_only: }))
|
|
47
|
+
"Inserted #{path} in device 8"
|
|
48
|
+
end
|
|
49
|
+
|
|
36
50
|
# The SID a machine for `path` should be built with. A .sid tune names
|
|
37
51
|
# its own; everything else gets the 6581.
|
|
38
52
|
def sid_model(path)
|
|
@@ -43,6 +57,20 @@ module Badline
|
|
|
43
57
|
|
|
44
58
|
private
|
|
45
59
|
|
|
60
|
+
def disk?(path)
|
|
61
|
+
storage = MOUNT_TYPES[File.extname(path).downcase]
|
|
62
|
+
File.directory?(path) || (!storage.nil? && storage < Storage::DiskImage)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Disk images take the `disk` options. A .t64 is read-only whatever
|
|
66
|
+
# it's given.
|
|
67
|
+
def open_storage(path, disk)
|
|
68
|
+
return Storage::HostDirectory.new(path) if File.directory?(path)
|
|
69
|
+
|
|
70
|
+
storage = MOUNT_TYPES[File.extname(path).downcase]
|
|
71
|
+
storage < Storage::DiskImage ? storage.new(path, **disk) : storage.new(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
46
74
|
def attach_cartridge(computer, path, options)
|
|
47
75
|
computer.attach_cartridge(Cartridge.from_file(path, **options))
|
|
48
76
|
"Attached cartridge #{path}"
|
|
@@ -59,8 +87,8 @@ module Badline
|
|
|
59
87
|
|
|
60
88
|
def start_tune(computer, tune, autostart:, song:)
|
|
61
89
|
computer.ram.write(tune.load_address, tune.data)
|
|
62
|
-
computer.ram.write(
|
|
63
|
-
computer.type_text(
|
|
90
|
+
tune.boot_memory(song:).each { |address, bytes| computer.ram.write(address, bytes) }
|
|
91
|
+
computer.type_text(tune.boot_command) if autostart
|
|
64
92
|
end
|
|
65
93
|
|
|
66
94
|
def attach_tape(computer, path, autostart:)
|
|
@@ -70,8 +98,8 @@ module Badline
|
|
|
70
98
|
"Inserted #{path} in the datasette"
|
|
71
99
|
end
|
|
72
100
|
|
|
73
|
-
def attach_storage(computer,
|
|
74
|
-
computer.mount(
|
|
101
|
+
def attach_storage(computer, path, disk, autostart:)
|
|
102
|
+
computer.mount(open_storage(path, disk))
|
|
75
103
|
computer.type_text(AUTOSTART) if autostart
|
|
76
104
|
"Mounted #{path} as device 8"
|
|
77
105
|
end
|
|
@@ -85,11 +113,14 @@ module Badline
|
|
|
85
113
|
|
|
86
114
|
def start_prg(computer, data, autostart:)
|
|
87
115
|
load_addr = computer.load_prg(data)
|
|
88
|
-
# Run only makes sense for programs at BASIC start
|
|
89
|
-
|
|
116
|
+
# Run only makes sense for programs at BASIC start, or a byte ahead
|
|
117
|
+
# of it, where BASIC keeps the zero before its first line.
|
|
118
|
+
return unless autostart && (load_addr == BASIC_START || load_addr == BASIC_START - 1)
|
|
90
119
|
|
|
120
|
+
# The end of the program, where BASIC's variables start, and where
|
|
121
|
+
# the KERNAL's LOAD leaves its end address.
|
|
91
122
|
end_addr = load_addr + data.length - 2
|
|
92
|
-
computer.ram.write(
|
|
123
|
+
[0x2d, 0xae].each { |pointer| computer.ram.write(pointer, [end_addr & 0xff, end_addr >> 8]) }
|
|
93
124
|
computer.type_text("run\r")
|
|
94
125
|
end
|
|
95
126
|
end
|
data/lib/badline/memory.rb
CHANGED