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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/session-start.sh +57 -0
  3. data/.claude/settings.json +14 -0
  4. data/CHANGELOG.md +107 -0
  5. data/CLAUDE.md +51 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +119 -51
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +293 -39
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +28 -14
  12. data/lib/badline/addressable.rb +1 -1
  13. data/lib/badline/audio/aiff.rb +8 -3
  14. data/lib/badline/audio/bare_player.rb +20 -3
  15. data/lib/badline/audio/cli.rb +17 -12
  16. data/lib/badline/audio/console.rb +12 -58
  17. data/lib/badline/audio/machine_player.rb +9 -6
  18. data/lib/badline/audio/playback.rb +3 -0
  19. data/lib/badline/audio/renderer.rb +3 -3
  20. data/lib/badline/audio/sdl_sink.rb +20 -52
  21. data/lib/badline/audio/stream.rb +117 -0
  22. data/lib/badline/audio/terminal.rb +90 -0
  23. data/lib/badline/audio.rb +3 -2
  24. data/lib/badline/cartridge/easy_flash.rb +6 -2
  25. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  26. data/lib/badline/cartridge/geo_ram.rb +63 -0
  27. data/lib/badline/cartridge/mach5.rb +1 -1
  28. data/lib/badline/cartridge/magic_desk.rb +5 -1
  29. data/lib/badline/cartridge/ocean.rb +5 -2
  30. data/lib/badline/cartridge/rex_utility.rb +1 -1
  31. data/lib/badline/cartridge/rgcd.rb +1 -1
  32. data/lib/badline/cartridge/simons_basic.rb +2 -2
  33. data/lib/badline/cartridge/westermann.rb +2 -2
  34. data/lib/badline/cartridge.rb +9 -0
  35. data/lib/badline/checkpoint.rb +98 -0
  36. data/lib/badline/cia/interrupt_register.rb +34 -10
  37. data/lib/badline/cia.rb +19 -10
  38. data/lib/badline/color_memory.rb +2 -2
  39. data/lib/badline/computer.rb +34 -14
  40. data/lib/badline/cpu.rb +2 -2
  41. data/lib/badline/gui/application.rb +70 -26
  42. data/lib/badline/gui/gamepads.rb +22 -22
  43. data/lib/badline/gui/joy_map.rb +1 -1
  44. data/lib/badline/gui/key_map.rb +6 -1
  45. data/lib/badline/gui/pane.rb +7 -6
  46. data/lib/badline/gui/screen_pane.rb +9 -13
  47. data/lib/badline/gui/window.rb +35 -20
  48. data/lib/badline/gui.rb +1 -1
  49. data/lib/badline/input/mouse1351.rb +21 -6
  50. data/lib/badline/input/paddles.rb +11 -6
  51. data/lib/badline/kernal_trap/channel.rb +102 -7
  52. data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
  53. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  54. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  55. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  56. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  57. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  58. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  59. data/lib/badline/kernal_trap/drive.rb +109 -131
  60. data/lib/badline/kernal_trap/file.rb +6 -14
  61. data/lib/badline/kernal_trap/load.rb +6 -0
  62. data/lib/badline/kernal_trap/save.rb +13 -7
  63. data/lib/badline/keyboard.rb +19 -3
  64. data/lib/badline/media.rb +44 -13
  65. data/lib/badline/memory.rb +2 -2
  66. data/lib/badline/options.rb +209 -0
  67. data/lib/badline/ram_expansion/banking.rb +30 -0
  68. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  69. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  70. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  71. data/lib/badline/ram_expansion.rb +21 -0
  72. data/lib/badline/region.rb +51 -0
  73. data/lib/badline/rom.rb +14 -5
  74. data/lib/badline/sdl/events.rb +47 -0
  75. data/lib/badline/sdl/functions.rb +73 -0
  76. data/lib/badline/sdl.rb +67 -0
  77. data/lib/badline/sid/catch_up.rb +110 -0
  78. data/lib/badline/sid/decimator.rb +6 -1
  79. data/lib/badline/sid/filter.rb +5 -5
  80. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  81. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  82. data/lib/badline/sid/waveform.rb +8 -8
  83. data/lib/badline/sid.rb +19 -93
  84. data/lib/badline/storage/d64_image.rb +10 -0
  85. data/lib/badline/storage/d71_image.rb +12 -0
  86. data/lib/badline/storage/d81_image.rb +9 -0
  87. data/lib/badline/storage/disk_image/bam.rb +111 -0
  88. data/lib/badline/storage/disk_image/directory.rb +79 -0
  89. data/lib/badline/storage/disk_image/writing.rb +110 -0
  90. data/lib/badline/storage/disk_image.rb +49 -4
  91. data/lib/badline/storage/host_directory.rb +17 -4
  92. data/lib/badline/storage/sid_file.rb +36 -2
  93. data/lib/badline/storage.rb +18 -1
  94. data/lib/badline/time_of_day.rb +11 -2
  95. data/lib/badline/version.rb +1 -1
  96. data/lib/badline/via/control_lines.rb +104 -0
  97. data/lib/badline/via/interrupt_register.rb +58 -0
  98. data/lib/badline/via/shift_register.rb +135 -0
  99. data/lib/badline/via/timer1.rb +82 -0
  100. data/lib/badline/via/timer2.rb +84 -0
  101. data/lib/badline/via.rb +262 -0
  102. data/lib/badline/vic/bank.rb +4 -3
  103. data/lib/badline/vic/color_patches.rb +6 -3
  104. data/lib/badline/vic/display_state.rb +4 -0
  105. data/lib/badline/vic/graphics_shifter.rb +49 -8
  106. data/lib/badline/vic/registers.rb +19 -27
  107. data/lib/badline/vic/sequencer.rb +18 -18
  108. data/lib/badline/vic/sequencer_output.rb +4 -4
  109. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  110. data/lib/badline/vic/sprite/shifter.rb +25 -3
  111. data/lib/badline/vic/sprite.rb +5 -4
  112. data/lib/badline/vic/sprites.rb +8 -3
  113. data/lib/badline/vic.rb +98 -46
  114. data/lib/badline.rb +5 -0
  115. data/packaging/homebrew/badline.rb +34 -0
  116. metadata +37 -20
  117. data/exe/badline +0 -75
  118. data/exe/badline-sid +0 -34
  119. 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, :write_protected, *[:initialized] * 6,
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
- /\A[IV]/i => :initialized,
56
- /\AB-[WAF]/i => :write_protected
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
- # A drive powers on reporting its DOS version, as a reset does.
60
- def initialize(storage)
61
- @storage = storage
62
- @channels = {}
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
- @ram = Array.new(RAM_SIZE, 0)
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[secondary] = Channel.new(Array.new(BLOCK_SIZE, 0))
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
- secondary == COMMAND_CHANNEL ? @channels.clear : @channels.delete(secondary)
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
- report(WRITE_PROTECT_ON) if listening?(secondary)
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.key?(secondary)
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
- channel = @channels[secondary] = Channel.for_file(@storage, file, type)
121
- channel.exhausted? && channel.error ? report(*channel.error) : report(OK)
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
- # 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
175
+ channel = @channels.open_file(secondary, Channel.for_name(@storage, @memory, secondary, name, type))
176
+ return report(NO_CHANNEL) unless channel
128
177
 
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))
178
+ channel.failed? ? report(*channel.error) : report(OK)
137
179
  end
138
180
 
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
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
- run_command(action, arguments(pattern.match(command)[1]))
154
- end
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 :write_protected then write_protected(arguments)
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
- def block_read(arguments, counted: false)
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
- address = word(arguments)
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. Only the RAM reads back.
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(Array.new(count) { |i| @ram.fetch(address + i, 0) })
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
- @ram.fill(0) if cold
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.strip_drive_prefix(Storage.ascii(bytes))
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). Writes device 8 saves to a storage backend as a PRG
6
+ # vector target). Hands device 8 saves to the virtual drive as a PRG
7
7
  # (load address followed by the memory range); other devices fall
8
- # through to the ROM. The ROM prints SAVING in direct mode and returns
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
- # host write that fails ends the way a 1541 ends a SAVE it can't
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:, storage:)
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
- @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @storage.write_file(name, payload)
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
@@ -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 ||= matrix.map do |row|
74
- row.each_with_index.sum { |key, column| keys.include?(key) ? 1 << column : 0 }
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` sets the jumpers of a .crt cartridge that has them, such
18
- # as `{ flash_jumper: true }` for the Retro Replay's flash mode.
19
- def attach(computer, path, autostart: true, song: nil, cartridge: {})
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 (storage = MOUNT_TYPES[File.extname(path).downcase])
30
- attach_storage(computer, storage.new(path), path, autostart:)
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(tune.driver_address, tune.driver(song:))
63
- computer.type_text("sys#{tune.driver_address}\r") if autostart
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, storage, path, autostart:)
74
- computer.mount(storage)
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
- return unless autostart && load_addr == BASIC_START
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(0x2d, [end_addr & 0xff, end_addr >> 8])
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
@@ -10,11 +10,11 @@ module Badline
10
10
  end
11
11
 
12
12
  def peek(addr)
13
- @storage[index(addr)]
13
+ @storage[offset_of(addr)]
14
14
  end
15
15
 
16
16
  def poke(addr, value)
17
- @storage[index(addr)] = value
17
+ @storage[offset_of(addr)] = value
18
18
  value
19
19
  end
20
20