badline 0.3.0 → 0.4.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.
Files changed (91) 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 +65 -0
  5. data/CLAUDE.md +45 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +111 -61
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +275 -33
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +22 -10
  12. data/lib/badline/audio/aiff.rb +8 -3
  13. data/lib/badline/audio/cli.rb +17 -12
  14. data/lib/badline/audio/console.rb +12 -58
  15. data/lib/badline/audio/playback.rb +3 -0
  16. data/lib/badline/audio/sdl_sink.rb +20 -52
  17. data/lib/badline/audio/terminal.rb +90 -0
  18. data/lib/badline/audio.rb +2 -2
  19. data/lib/badline/cartridge/geo_ram.rb +63 -0
  20. data/lib/badline/cartridge.rb +1 -0
  21. data/lib/badline/cia/interrupt_register.rb +34 -10
  22. data/lib/badline/cia.rb +15 -8
  23. data/lib/badline/computer.rb +28 -13
  24. data/lib/badline/gui/application.rb +33 -21
  25. data/lib/badline/gui/gamepads.rb +22 -22
  26. data/lib/badline/gui/joy_map.rb +1 -1
  27. data/lib/badline/gui/key_map.rb +6 -1
  28. data/lib/badline/gui/pane.rb +7 -6
  29. data/lib/badline/gui/screen_pane.rb +9 -13
  30. data/lib/badline/gui/window.rb +35 -20
  31. data/lib/badline/gui.rb +1 -1
  32. data/lib/badline/input/mouse1351.rb +21 -6
  33. data/lib/badline/input/paddles.rb +11 -6
  34. data/lib/badline/kernal_trap/channel.rb +3 -0
  35. data/lib/badline/kernal_trap/drive/block_commands.rb +33 -4
  36. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  37. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  38. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  39. data/lib/badline/kernal_trap/drive.rb +51 -51
  40. data/lib/badline/kernal_trap/file.rb +6 -9
  41. data/lib/badline/kernal_trap/save.rb +9 -4
  42. data/lib/badline/keyboard.rb +19 -3
  43. data/lib/badline/media.rb +42 -11
  44. data/lib/badline/options.rb +209 -0
  45. data/lib/badline/ram_expansion/banking.rb +30 -0
  46. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  47. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  48. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  49. data/lib/badline/ram_expansion.rb +21 -0
  50. data/lib/badline/region.rb +51 -0
  51. data/lib/badline/sdl/events.rb +47 -0
  52. data/lib/badline/sdl/functions.rb +73 -0
  53. data/lib/badline/sdl.rb +67 -0
  54. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  55. data/lib/badline/sid/waveform.rb +1 -1
  56. data/lib/badline/sid.rb +3 -1
  57. data/lib/badline/storage/d64_image.rb +10 -0
  58. data/lib/badline/storage/d71_image.rb +12 -0
  59. data/lib/badline/storage/d81_image.rb +9 -0
  60. data/lib/badline/storage/disk_image/bam.rb +111 -0
  61. data/lib/badline/storage/disk_image/directory.rb +79 -0
  62. data/lib/badline/storage/disk_image/writing.rb +110 -0
  63. data/lib/badline/storage/disk_image.rb +27 -4
  64. data/lib/badline/storage/host_directory.rb +2 -1
  65. data/lib/badline/storage.rb +15 -0
  66. data/lib/badline/time_of_day.rb +4 -2
  67. data/lib/badline/version.rb +1 -1
  68. data/lib/badline/via/control_lines.rb +104 -0
  69. data/lib/badline/via/interrupt_register.rb +58 -0
  70. data/lib/badline/via/shift_register.rb +135 -0
  71. data/lib/badline/via/timer1.rb +82 -0
  72. data/lib/badline/via/timer2.rb +84 -0
  73. data/lib/badline/via.rb +262 -0
  74. data/lib/badline/vic/bank.rb +4 -3
  75. data/lib/badline/vic/color_patches.rb +6 -3
  76. data/lib/badline/vic/display_state.rb +4 -0
  77. data/lib/badline/vic/graphics_shifter.rb +42 -7
  78. data/lib/badline/vic/registers.rb +3 -0
  79. data/lib/badline/vic/sequencer.rb +18 -18
  80. data/lib/badline/vic/sequencer_output.rb +4 -4
  81. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  82. data/lib/badline/vic/sprite/shifter.rb +25 -3
  83. data/lib/badline/vic/sprite.rb +5 -4
  84. data/lib/badline/vic/sprites.rb +8 -3
  85. data/lib/badline/vic.rb +93 -44
  86. data/lib/badline.rb +4 -0
  87. data/packaging/homebrew/badline.rb +34 -0
  88. metadata +30 -20
  89. data/exe/badline +0 -78
  90. data/exe/badline-sid +0 -34
  91. data/lib/badline/audio/options.rb +0 -137
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # Opens for writing, the S command and the reports a write makes. A
7
+ # file open for writing takes the type its name asks for, or SEQ on a
8
+ # data channel. On a write-protected disk, or storage that doesn't
9
+ # write files through channels, an open for writing fails.
10
+ module Writes
11
+ private
12
+
13
+ # A name already on the disk fails as FILE EXISTS unless @ asks to
14
+ # replace it. A write-protected disk fails anything else and leaves
15
+ # the channel closed.
16
+ def open_write(secondary, name, file, type)
17
+ replace = name.start_with?("@")
18
+ if !replace && @storage.read_file(file, type: nil)
19
+ @channels.close(secondary)
20
+ return report(FILE_EXISTS)
21
+ end
22
+ return refuse_write(secondary) unless writable_disk?
23
+
24
+ open_writer(secondary, WriteFile.new(file, type || :seq, mode: replace ? :replace : :write))
25
+ end
26
+
27
+ # An A mode open needs the file on the disk.
28
+ def open_append(secondary, file, type)
29
+ unless @storage.read_file(file, type:)
30
+ @channels.close(secondary)
31
+ return report(FILE_NOT_FOUND)
32
+ end
33
+ return refuse_append(secondary, file, type) unless writable_disk?
34
+
35
+ open_writer(secondary, WriteFile.new(file, type, mode: :append))
36
+ end
37
+
38
+ def open_writer(secondary, channel)
39
+ @channels.open_file(secondary, channel) ? report(OK) : report(NO_CHANNEL)
40
+ end
41
+
42
+ def writable_disk? = @storage.respond_to?(:writable?) && @storage.writable?
43
+
44
+ # Runs a write to the disk and reports how it went. Returns whether
45
+ # the disk took it.
46
+ def writing
47
+ written = yield != false
48
+ @memory.read_bam if @memory.bam?
49
+ written ? report(OK) : report(WRITE_PROTECT_ON)
50
+ written
51
+ rescue Storage::WriteError => e
52
+ report(e.code)
53
+ false
54
+ end
55
+
56
+ # An open for writing on a write-protected disk fails as WRITE
57
+ # PROTECT ON.
58
+ def refuse_write(secondary)
59
+ @channels.close(secondary)
60
+ report(WRITE_PROTECT_ON, *protected_block(secondary))
61
+ end
62
+
63
+ # An append on a write-protected disk fails at the file's last block,
64
+ # the first one an append writes back.
65
+ def refuse_append(secondary, file, type)
66
+ @channels.close(secondary)
67
+ report(WRITE_PROTECT_ON, *(@storage.last_block(file, type:) if @storage.respond_to?(:last_block)))
68
+ end
69
+
70
+ # SAVE's channel fails at the directory block its entry would go to,
71
+ # a W mode open at the disk's header block.
72
+ def protected_block(secondary)
73
+ return [] unless @storage.respond_to?(:header_block)
74
+
75
+ secondary == 1 ? @storage.new_entry_block : @storage.header_block
76
+ end
77
+
78
+ # S takes names separated by commas, each of which can be a pattern,
79
+ # and reports how many files it scratched.
80
+ def scratch(names)
81
+ return report(WRITE_PROTECT_ON) unless writable_disk?
82
+
83
+ count = 0
84
+ scratched = writing do
85
+ names.split(",").each { |name| count += @storage.scratch(Storage.strip_drive_prefix(name)) }
86
+ end
87
+ report(FILES_SCRATCHED, count) if scratched
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -5,24 +5,31 @@ require "badline/kernal_trap/drive/channels"
5
5
  require "badline/kernal_trap/drive/memory"
6
6
  require "badline/kernal_trap/drive/parameters"
7
7
  require "badline/kernal_trap/drive/status"
8
+ require "badline/kernal_trap/drive/writes"
9
+ require "badline/kernal_trap/drive/write_file"
8
10
 
9
11
  module Badline
10
12
  module KernalTrap
11
13
  # The CBM DOS side of the serial bus. Keeps the open channels, the
12
14
  # command channel and its status message. `U1` reads a raw block into a
13
15
  # buffer channel, which is how block-access loaders bypass the KERNAL's
14
- # 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.
15
19
  class Drive
16
20
  include BlockCommands
21
+ include Writes
17
22
 
18
23
  COMMAND_CHANNEL = 15
19
24
  BLOCK_SIZE = 256
20
25
 
21
26
  OK = 0
27
+ FILES_SCRATCHED = 1
22
28
  WRITE_PROTECT_ON = 26
23
29
  SYNTAX_ERROR = 30
24
30
  FILE_NOT_FOUND = 62
25
31
  FILE_EXISTS = 63
32
+ NO_BLOCK = 65
26
33
  ILLEGAL_TRACK_OR_SECTOR = 66
27
34
  NO_CHANNEL = 70
28
35
  DOS_VERSION = 73
@@ -50,22 +57,36 @@ module Badline
50
57
  /\AB-W\s*:?\s*(.*)/i => :counted_block_write,
51
58
  /\AI/i => :initialize_disk,
52
59
  /\AV/i => :initialized,
53
- /\AB-[AF]/i => :write_protected
60
+ /\AB-A\s*:?\s*(.*)/i => :block_allocate,
61
+ /\AB-F\s*:?\s*(.*)/i => :block_free,
62
+ /\AS[^:]*:(.*)/im => :scratch
54
63
  }.freeze
55
64
 
65
+ # Commands that take their argument as text rather than numbers.
66
+ TEXT_COMMANDS = %i[scratch].freeze
67
+
56
68
  # 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.
69
+ # can take the RAM of a drive it replaces, and initializes its disk.
59
70
  def initialize(storage, memory = Memory.new)
60
- @storage = storage
61
71
  @channels = Channels.new
62
72
  @status = Status.new
63
73
  @memory = memory
64
- memory.storage = storage
65
- memory.read_bam
74
+ insert(storage)
66
75
  report(DOS_VERSION)
67
76
  end
68
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
+
69
90
  # A name on the command channel is a command, "#" opens a block
70
91
  # buffer, anything else opens a file for reading. The name comes as
71
92
  # the bytes sent, because a memory command's arguments are binary.
@@ -82,13 +103,19 @@ module Badline
82
103
  end
83
104
  end
84
105
 
85
- # 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.
86
109
  def close(secondary)
87
- secondary == COMMAND_CHANNEL ? @channels.clear : @channels.close(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)
88
114
  end
89
115
 
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.
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.
92
119
  def write(secondary, bytes)
93
120
  return command(bytes) if secondary == COMMAND_CHANNEL
94
121
 
@@ -99,12 +126,11 @@ module Badline
99
126
  end
100
127
 
101
128
  # 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
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:) }
108
134
  end
109
135
 
110
136
  # Whether data sent on the channel reaches the drive. The command
@@ -139,12 +165,12 @@ module Badline
139
165
 
140
166
  # Secondary addresses 0 and 1 are LOAD and SAVE, which look for a PRG
141
167
  # file unless the name asks for another type. Other channels take any
142
- # type the name doesn't pin down.
168
+ # type the name doesn't pin down, and write a SEQ file.
143
169
  def open_file(secondary, name)
144
170
  file, type = Storage.parse_name(name)
145
171
  type ||= :prg if secondary < 2
146
- return refuse_write(secondary, name, file) if secondary == 1 || mode?(name, "W")
147
- return refuse_append(secondary, file, type) if mode?(name, "A")
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")
148
174
 
149
175
  channel = @channels.open_file(secondary, Channel.for_name(@storage, @memory, secondary, name, type))
150
176
  return report(NO_CHANNEL) unless channel
@@ -156,40 +182,14 @@ module Badline
156
182
  name.split(",").drop(1).any? { |field| field.strip[0]&.upcase == mode }
157
183
  end
158
184
 
159
- # The disk is write-protected, so an open for writing fails and leaves
160
- # the channel closed. A name already on the disk fails as FILE EXISTS
161
- # unless @ asks to replace it, anything else as WRITE PROTECT ON.
162
- def refuse_write(secondary, name, file)
163
- @channels.close(secondary)
164
- return report(FILE_EXISTS) if !name.start_with?("@") && @storage.read_file(file, type: nil)
165
-
166
- report(WRITE_PROTECT_ON, *protected_block(secondary))
167
- end
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
-
178
- # SAVE's channel fails at the directory block its entry would go to,
179
- # a W mode open at the disk's header block.
180
- def protected_block(secondary)
181
- return [] unless @storage.respond_to?(:header_block)
182
-
183
- secondary == 1 ? @storage.new_entry_block : @storage.header_block
184
- end
185
-
186
185
  def execute(text)
187
186
  command = text.strip
188
187
  pattern, action = COMMANDS.find { |candidate, _| candidate.match?(command) }
189
188
  return report(SYNTAX_ERROR) unless action
190
189
 
191
190
  action = USER_TABLE[(command.getbyte(1) - 1) & 0x0f] if action == :user
192
- run_command(action, Parameters.parse(pattern.match(command)[1]))
191
+ argument = pattern.match(command)[1]
192
+ run_command(action, TEXT_COMMANDS.include?(action) ? argument : Parameters.parse(argument))
193
193
  end
194
194
 
195
195
  def command(bytes)
@@ -206,7 +206,9 @@ module Badline
206
206
  when :buffer_pointer then buffer_pointer(arguments)
207
207
  when :initialized then report(OK)
208
208
  when :initialize_disk then initialize_disk
209
- when :write_protected then write_protected(arguments)
209
+ when :block_allocate then block_allocate(arguments)
210
+ when :block_free then block_free(arguments)
211
+ when :scratch then scratch(arguments)
210
212
  when :cold_reset then reset(cold: true)
211
213
  when :warm_reset then reset(cold: false)
212
214
  when :memory_write then memory_write(arguments)
@@ -234,8 +236,6 @@ module Badline
234
236
  arguments[0].to_i | (arguments[1].to_i << 8)
235
237
  end
236
238
 
237
- def write_protected(_arguments) = report(WRITE_PROTECT_ON)
238
-
239
239
  def reset(cold:)
240
240
  @memory.clear if cold
241
241
  @channels.clear
@@ -3,26 +3,23 @@
3
3
  module Badline
4
4
  module KernalTrap
5
5
  class File < Routine
6
- MISSING_FILENAME = 0x08
7
-
8
6
  private
9
7
 
10
8
  def active?
11
9
  kernal? && @bus.peek(0xba) == DEVICE
12
10
  end
13
11
 
14
- # Filename pointer at $BB/$BC, length at $B7
15
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
16
18
  pointer = uint16(@bus.peek(0xbb), @bus.peek(0xbc))
17
19
  bytes = Array.new(@bus.peek(0xb7)) do |i|
18
20
  @bus.peek((pointer + i) & 0xffff)
19
21
  end
20
- Storage.strip_drive_prefix(Storage.ascii(bytes))
21
- end
22
-
23
- def error(code)
24
- @cpu.a = code
25
- @cpu.status.carry = true
22
+ Storage.ascii(bytes)
26
23
  end
27
24
  end
28
25
  end
@@ -5,12 +5,15 @@ module Badline
5
5
  # PC trap on the KERNAL serial SAVE routine ($F5ED, the default ISAVE
6
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
 
@@ -37,6 +40,7 @@ module Badline
37
40
  def call
38
41
  return unless active?
39
42
  return finish if @saving
43
+ return unless @drive.saves?
40
44
 
41
45
  @bus.poke(0xb9, SECONDARY)
42
46
  return @cpu.program_counter = MISSING_FILE_NAME_EXIT if name.empty?
@@ -54,7 +58,8 @@ module Badline
54
58
 
55
59
  def finish
56
60
  @saving = false
57
- @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @drive.save(name, payload)
61
+ saved = @drive.save(name, payload, replace: full_filename.start_with?("@"))
62
+ @bus.poke(0x90, DEVICE_NOT_PRESENT) unless saved
58
63
  @bus.poke(0xac, @bus.peek(0xae))
59
64
  @bus.poke(0xad, @bus.peek(0xaf))
60
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}"
@@ -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