badline 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +185 -0
  3. data/CLAUDE.md +217 -0
  4. data/CODE_OF_CONDUCT.md +92 -0
  5. data/CONTRIBUTING.md +51 -0
  6. data/README.md +224 -31
  7. data/doc/pinned-behaviour.md +1117 -0
  8. data/exe/badline +22 -2
  9. data/exe/badline-sid +34 -0
  10. data/lib/badline/address_bus.rb +96 -25
  11. data/lib/badline/audio/aiff.rb +36 -0
  12. data/lib/badline/audio/bare_player.rb +129 -0
  13. data/lib/badline/audio/cli.rb +133 -0
  14. data/lib/badline/audio/console.rb +75 -0
  15. data/lib/badline/audio/jukebox.rb +82 -0
  16. data/lib/badline/audio/machine_player.rb +57 -0
  17. data/lib/badline/audio/options.rb +137 -0
  18. data/lib/badline/audio/pcm_writer.rb +57 -0
  19. data/lib/badline/audio/playback.rb +108 -0
  20. data/lib/badline/audio/renderer.rb +85 -0
  21. data/lib/badline/audio/sdl_sink.rb +102 -0
  22. data/lib/badline/audio/wav.rb +23 -0
  23. data/lib/badline/audio.rb +14 -0
  24. data/lib/badline/cartridge/action_replay.rb +111 -0
  25. data/lib/badline/cartridge/atomic_power.rb +24 -0
  26. data/lib/badline/cartridge/bank.rb +81 -0
  27. data/lib/badline/cartridge/comal80.rb +48 -0
  28. data/lib/badline/cartridge/dinamic.rb +34 -0
  29. data/lib/badline/cartridge/easy_flash.rb +122 -0
  30. data/lib/badline/cartridge/epyx_fastload.rb +71 -0
  31. data/lib/badline/cartridge/final_cartridge3.rb +77 -0
  32. data/lib/badline/cartridge/flash.rb +265 -0
  33. data/lib/badline/cartridge/freezer.rb +31 -0
  34. data/lib/badline/cartridge/fun_play.rb +38 -0
  35. data/lib/badline/cartridge/g_mod2.rb +62 -0
  36. data/lib/badline/cartridge/game_system.rb +40 -0
  37. data/lib/badline/cartridge/kcs_power.rb +72 -0
  38. data/lib/badline/cartridge/mach5.rb +34 -0
  39. data/lib/badline/cartridge/magic_desk.rb +6 -3
  40. data/lib/badline/cartridge/ocean.rb +11 -12
  41. data/lib/badline/cartridge/pagefox.rb +56 -0
  42. data/lib/badline/cartridge/retro_replay.rb +276 -0
  43. data/lib/badline/cartridge/rex_utility.rb +33 -0
  44. data/lib/badline/cartridge/rgcd.rb +48 -0
  45. data/lib/badline/cartridge/simons_basic.rb +40 -0
  46. data/lib/badline/cartridge/standard.rb +3 -19
  47. data/lib/badline/cartridge/super_games.rb +36 -0
  48. data/lib/badline/cartridge/westermann.rb +34 -0
  49. data/lib/badline/cartridge/zaxxon.rb +50 -0
  50. data/lib/badline/cartridge.rb +132 -16
  51. data/lib/badline/cia/interrupt_register.rb +102 -0
  52. data/lib/badline/cia/serial.rb +219 -0
  53. data/lib/badline/cia/timer.rb +43 -31
  54. data/lib/badline/cia.rb +138 -88
  55. data/lib/badline/color_memory.rb +14 -5
  56. data/lib/badline/computer.rb +97 -12
  57. data/lib/badline/control_ports.rb +54 -9
  58. data/lib/badline/cpu/addressing.rb +131 -0
  59. data/lib/badline/cpu/microcode.rb +169 -0
  60. data/lib/badline/cpu/operations.rb +88 -0
  61. data/lib/badline/cpu/stack_operations.rb +82 -0
  62. data/lib/badline/cpu.rb +109 -168
  63. data/lib/badline/cycleable.rb +3 -0
  64. data/lib/badline/datasette.rb +81 -0
  65. data/lib/badline/debug_register.rb +28 -0
  66. data/lib/badline/gui/application.rb +79 -18
  67. data/lib/badline/gui/gamepads.rb +97 -0
  68. data/lib/badline/gui/joy_map.rb +15 -7
  69. data/lib/badline/gui.rb +1 -0
  70. data/lib/badline/input/mouse1351.rb +41 -0
  71. data/lib/badline/input/paddles.rb +36 -0
  72. data/lib/badline/input.rb +4 -0
  73. data/lib/badline/instruction_set/arithmetic.rb +16 -21
  74. data/lib/badline/instruction_set/bitwise.rb +17 -22
  75. data/lib/badline/instruction_set/branch.rb +20 -19
  76. data/lib/badline/instruction_set/flag.rb +7 -7
  77. data/lib/badline/instruction_set/illegal.rb +14 -22
  78. data/lib/badline/instruction_set/inc_dec.rb +8 -10
  79. data/lib/badline/instruction_set/stack.rb +5 -57
  80. data/lib/badline/instruction_set/transfer.rb +9 -9
  81. data/lib/badline/instruction_set.rb +9 -31
  82. data/lib/badline/interrupts.rb +104 -0
  83. data/lib/badline/joystick.rb +9 -9
  84. data/lib/badline/kernal_trap/channel.rb +51 -0
  85. data/lib/badline/kernal_trap/drive/status.rb +49 -0
  86. data/lib/badline/kernal_trap/drive.rb +277 -0
  87. data/lib/badline/kernal_trap/file.rb +5 -25
  88. data/lib/badline/kernal_trap/load.rb +86 -24
  89. data/lib/badline/kernal_trap/routine.rb +51 -0
  90. data/lib/badline/kernal_trap/save.rb +46 -14
  91. data/lib/badline/kernal_trap/serial.rb +171 -0
  92. data/lib/badline/kernal_trap.rb +4 -0
  93. data/lib/badline/keyboard.rb +40 -16
  94. data/lib/badline/media.rb +48 -10
  95. data/lib/badline/memory.rb +5 -0
  96. data/lib/badline/roms/eapi/LICENSE.md +15 -0
  97. data/lib/badline/roms/eapi/README +41 -0
  98. data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
  99. data/lib/badline/sid/decimator.rb +46 -0
  100. data/lib/badline/sid/envelope.rb +232 -0
  101. data/lib/badline/sid/filter.rb +213 -0
  102. data/lib/badline/sid/voice.rb +61 -0
  103. data/lib/badline/sid/waveform/combined.rb +126 -0
  104. data/lib/badline/sid/waveform/fast_forward.rb +105 -0
  105. data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
  106. data/lib/badline/sid/waveform.rb +289 -0
  107. data/lib/badline/sid.rb +274 -7
  108. data/lib/badline/status.rb +251 -23
  109. data/lib/badline/storage/crt_file.rb +18 -2
  110. data/lib/badline/storage/d64_image.rb +6 -0
  111. data/lib/badline/storage/d71_image.rb +4 -0
  112. data/lib/badline/storage/d81_image.rb +4 -0
  113. data/lib/badline/storage/disk_image.rb +99 -13
  114. data/lib/badline/storage/host_directory.rb +30 -4
  115. data/lib/badline/storage/sid_file.rb +274 -0
  116. data/lib/badline/storage/song_lengths.rb +65 -0
  117. data/lib/badline/storage/t64.rb +67 -0
  118. data/lib/badline/storage/tap.rb +68 -0
  119. data/lib/badline/storage.rb +19 -0
  120. data/lib/badline/time_of_day.rb +105 -52
  121. data/lib/badline/version.rb +1 -1
  122. data/lib/badline/vic/bank.rb +14 -6
  123. data/lib/badline/vic/border_mask.rb +76 -0
  124. data/lib/badline/vic/collisions.rb +104 -0
  125. data/lib/badline/vic/color_patches.rb +33 -0
  126. data/lib/badline/vic/display_state.rb +92 -29
  127. data/lib/badline/vic/graphics_mode.rb +78 -53
  128. data/lib/badline/vic/graphics_shifter.rb +140 -0
  129. data/lib/badline/vic/register_log.rb +63 -0
  130. data/lib/badline/vic/registers.rb +3 -0
  131. data/lib/badline/vic/sequencer.rb +163 -137
  132. data/lib/badline/vic/sequencer_output.rb +116 -0
  133. data/lib/badline/vic/sprite/internal_bus.rb +49 -0
  134. data/lib/badline/vic/sprite/shifter.rb +129 -0
  135. data/lib/badline/vic/sprite.rb +275 -65
  136. data/lib/badline/vic/sprites.rb +220 -58
  137. data/lib/badline/vic.rb +389 -55
  138. data/lib/badline.rb +5 -0
  139. data/vendor/.gitkeep +0 -0
  140. metadata +104 -4
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/kernal_trap/drive/status"
4
+
5
+ module Badline
6
+ module KernalTrap
7
+ # The CBM DOS side of the serial bus. Keeps the open channels, the
8
+ # command channel and its status message. `U1` reads a raw block into a
9
+ # buffer channel, which is how block-access loaders bypass the KERNAL's
10
+ # LOAD.
11
+ class Drive
12
+ COMMAND_CHANNEL = 15
13
+ BLOCK_SIZE = 256
14
+
15
+ OK = 0
16
+ WRITE_PROTECT_ON = 26
17
+ SYNTAX_ERROR = 30
18
+ FILE_NOT_FOUND = 62
19
+ FILE_EXISTS = 63
20
+ ILLEGAL_TRACK_OR_SECTOR = 66
21
+ NO_CHANNEL = 70
22
+ DOS_VERSION = 73
23
+ DRIVE_NOT_READY = 74
24
+
25
+ MEMORY_COMMANDS = { "M-W" => :memory_write, "M-R" => :memory_read }.freeze
26
+
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
+ # A U command jumps through the user table at $FFEA, indexed by the
39
+ # low nibble of its second character less one, so UA and UQ are U1.
40
+ # U1 and U2 read and write a block. U3 to U8 run code in the buffer
41
+ # at $0500, and the five entries past the table's end take vectors
42
+ # from the job queue. Neither runs here, so they report OK as a
43
+ # routine that returns does. U9 (UI) takes the NMI vector and U; the
44
+ # IRQ one, which restart the DOS with its RAM intact, except that a
45
+ # + or - after U9 only switches the bus speed. U: takes the reset
46
+ # vector, which runs the RAM test. U0 restores the table.
47
+ USER_TABLE = [:block_read, :write_protected, *[:initialized] * 6,
48
+ :warm_reset, :cold_reset, :warm_reset, *[:initialized] * 5].freeze
49
+
50
+ COMMANDS = {
51
+ /\AU[9IY)][+-]/i => :initialized,
52
+ /\AU.\s*:?\s*(.*)/i => :user,
53
+ /\AB-R\s*:?\s*(.*)/i => :counted_block_read,
54
+ /\AB-P\s*:?\s*(.*)/i => :buffer_pointer,
55
+ /\A[IV]/i => :initialized,
56
+ /\AB-[WAF]/i => :write_protected
57
+ }.freeze
58
+
59
+ # A drive powers on reporting its DOS version, as a reset does.
60
+ def initialize(storage)
61
+ @storage = storage
62
+ @channels = {}
63
+ @status = Status.new
64
+ @ram = Array.new(RAM_SIZE, 0)
65
+ report(DOS_VERSION)
66
+ end
67
+
68
+ # A name on the command channel is a command, "#" opens a block
69
+ # buffer, anything else opens a file for reading. The name comes as
70
+ # the bytes sent, because a memory command's arguments are binary.
71
+ def open(secondary, name)
72
+ if secondary == COMMAND_CHANNEL
73
+ command(name.bytes) unless name.empty?
74
+ elsif name.start_with?("#")
75
+ @channels[secondary] = Channel.new(Array.new(BLOCK_SIZE, 0))
76
+ report(OK)
77
+ else
78
+ open_file(secondary, Storage.ascii(name.bytes))
79
+ end
80
+ end
81
+
82
+ # Closing the command channel closes every other channel with it.
83
+ def close(secondary)
84
+ secondary == COMMAND_CHANNEL ? @channels.clear : @channels.delete(secondary)
85
+ end
86
+
87
+ def write(secondary, bytes)
88
+ return command(bytes) if secondary == COMMAND_CHANNEL
89
+
90
+ report(WRITE_PROTECT_ON) if listening?(secondary)
91
+ end
92
+
93
+ # Whether data sent on the channel reaches the drive. The command
94
+ # channel always listens, other channels once they are open.
95
+ def listening?(secondary)
96
+ secondary == COMMAND_CHANNEL || @channels.key?(secondary)
97
+ end
98
+
99
+ # Returns the next byte and whether it is the channel's last, or nil
100
+ # when there is nothing left to send.
101
+ def read(secondary)
102
+ return @status.read if secondary == COMMAND_CHANNEL
103
+
104
+ channel = @channels[secondary]
105
+ result = channel&.read
106
+ report(*channel.error) if result && channel.error && channel.exhausted?
107
+ result
108
+ end
109
+
110
+ private
111
+
112
+ # Secondary addresses 0 and 1 are LOAD and SAVE, which look for a PRG
113
+ # file unless the name asks for another type. Other channels take any
114
+ # type the name doesn't pin down.
115
+ def open_file(secondary, name)
116
+ file, type = Storage.parse_name(name)
117
+ return refuse_write(secondary, name, file) if writing?(secondary, name)
118
+
119
+ 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
123
+
124
+ # SAVE's secondary address 1 and a W mode open a file for writing.
125
+ def writing?(secondary, name)
126
+ secondary == 1 || name.split(",").drop(1).any? { |field| field.strip.match?(/\AW/i) }
127
+ end
128
+
129
+ # The disk is write-protected, so an open for writing fails and leaves
130
+ # the channel closed. A name already on the disk fails as FILE EXISTS
131
+ # unless @ asks to replace it, anything else as WRITE PROTECT ON.
132
+ def refuse_write(secondary, name, file)
133
+ @channels.delete(secondary)
134
+ return report(FILE_EXISTS) if !name.start_with?("@") && @storage.read_file(file, type: nil)
135
+
136
+ report(WRITE_PROTECT_ON, *protected_block(secondary))
137
+ end
138
+
139
+ # SAVE's channel fails at the directory block its entry would go to,
140
+ # a W mode open at the disk's header block.
141
+ def protected_block(secondary)
142
+ return [] unless @storage.respond_to?(:header_block)
143
+
144
+ secondary == 1 ? @storage.new_entry_block : @storage.header_block
145
+ end
146
+
147
+ def execute(text)
148
+ command = text.strip
149
+ pattern, action = COMMANDS.find { |candidate, _| candidate.match?(command) }
150
+ return report(SYNTAX_ERROR) unless action
151
+
152
+ 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)
158
+ end
159
+
160
+ def command(bytes)
161
+ action = MEMORY_COMMANDS[bytes[0, 3].pack("C*")]
162
+ action ? run_command(action, bytes[3..]) : execute(Storage.ascii(bytes))
163
+ end
164
+
165
+ def run_command(action, arguments)
166
+ case action
167
+ when :block_read then block_read(arguments)
168
+ when :counted_block_read then counted_block_read(arguments)
169
+ when :buffer_pointer then buffer_pointer(arguments)
170
+ when :initialized then report(OK)
171
+ when :write_protected then write_protected(arguments)
172
+ when :cold_reset then reset(cold: true)
173
+ when :warm_reset then reset(cold: false)
174
+ when :memory_write then memory_write(arguments)
175
+ when :memory_read then memory_read(arguments)
176
+ end
177
+ end
178
+
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.
217
+ 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
223
+ report(OK)
224
+ end
225
+
226
+ # M-R takes an address and a count, and answers on the command
227
+ # 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.
229
+ def memory_read(arguments)
230
+ address = word(arguments)
231
+ count = arguments[2..] == ["\r".ord] ? 1 : arguments.fetch(2, 1)
232
+ count = BLOCK_SIZE if count.zero?
233
+ @status.replace(Array.new(count) { |i| @ram.fetch(address + i, 0) })
234
+ nil
235
+ end
236
+
237
+ def word(arguments)
238
+ arguments[0].to_i | (arguments[1].to_i << 8)
239
+ end
240
+
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
+ def reset(cold:)
269
+ @ram.fill(0) if cold
270
+ @channels.clear
271
+ report(DOS_VERSION)
272
+ end
273
+
274
+ def report(...) = @status.report(...)
275
+ end
276
+ end
277
+ end
@@ -2,23 +2,18 @@
2
2
 
3
3
  module Badline
4
4
  module KernalTrap
5
- class File
6
- include IntegerHelper
7
-
8
- DEVICE = 8
9
-
5
+ class File < Routine
10
6
  MISSING_FILENAME = 0x08
11
7
 
12
- def initialize(cpu:, bus:, storage:)
13
- @cpu = cpu
14
- @bus = bus
8
+ def initialize(cpu:, bus:, storage: nil)
9
+ super(cpu:, bus:)
15
10
  @storage = storage
16
11
  end
17
12
 
18
13
  private
19
14
 
20
15
  def active?
21
- @bus.io_port.kernal? && @bus.peek(0xba) == DEVICE
16
+ kernal? && @bus.peek(0xba) == DEVICE
22
17
  end
23
18
 
24
19
  # Filename pointer at $BB/$BC, length at $B7
@@ -27,28 +22,13 @@ module Badline
27
22
  bytes = Array.new(@bus.peek(0xb7)) do |i|
28
23
  @bus.peek((pointer + i) & 0xffff)
29
24
  end
30
- strip_drive_prefix(Storage.ascii(bytes))
31
- end
32
-
33
- # CBM DOS drive prefix — "0:NAME" selects a drive, "@0:NAME" is
34
- # save-with-replace
35
- def strip_drive_prefix(name)
36
- name.sub(/\A@?\d*:/, "")
25
+ Storage.strip_drive_prefix(Storage.ascii(bytes))
37
26
  end
38
27
 
39
28
  def error(code)
40
29
  @cpu.a = code
41
30
  @cpu.status.carry = true
42
31
  end
43
-
44
- def return_to_caller
45
- @cpu.program_counter = (uint16(pull_byte, pull_byte) + 1) & 0xffff
46
- end
47
-
48
- def pull_byte
49
- @cpu.stack_pointer = (@cpu.stack_pointer + 1) & 0xff
50
- @bus.peek(0x0100 + @cpu.stack_pointer)
51
- end
52
32
  end
53
33
  end
54
34
  end
@@ -3,41 +3,112 @@
3
3
  module Badline
4
4
  module KernalTrap
5
5
  # PC trap on the KERNAL serial LOAD routine ($F4A5, the default ILOAD
6
- # vector target). Serves device 8 requests from a storage backend and
7
- # returns to the caller with the routine's register/zeropage contract;
8
- # other devices fall through to the ROM.
6
+ # vector target). Reads device 8 requests from the virtual drive's
7
+ # channel 0, then hands over to the ROM's own tail so it prints
8
+ # SEARCHING FOR and LOADING (or VERIFYING) in direct mode, reports
9
+ # errors and returns with the routine's register/zeropage contract;
10
+ # other devices fall through to the ROM. So does a load that reaches
11
+ # below $0334, into the zero page, the stack or the KERNAL vectors: the
12
+ # ROM's byte loop then loads it through the serial traps, and a loader
13
+ # that overwrites ISTOP takes over mid-load as it does on a real drive.
9
14
  class Load < File
10
15
  ADDRESS = 0xf4a5
11
16
 
12
- FILE_NOT_FOUND = 0x04
17
+ # ROM entry points: the SEARCHING FOR and LOADING/VERIFYING messages,
18
+ # the byte loop that retries a timed-out byte until RUN/STOP, the
19
+ # successful return (CLC, LDX $AE, LDY $AF, RTS) and the
20
+ # FILE NOT FOUND and MISSING FILE NAME error exits
21
+ SEARCHING_MESSAGE = 0xf5af
22
+ LOADING_MESSAGE = 0xf5d2
23
+ BYTE_LOOP = 0xf4f3
24
+ LOAD_DONE = 0xf5a9
25
+ FILE_NOT_FOUND_EXIT = 0xf704
26
+ MISSING_FILE_NAME_EXIT = 0xf710
27
+
28
+ # ST bits at $90
29
+ EOI = 0x40
30
+ VERIFY_MISMATCH = 0x10
31
+ READ_TIMEOUT = 0x02
32
+
33
+ def initialize(cpu:, bus:, drive:)
34
+ super(cpu:, bus:)
35
+ @drive = drive
36
+ end
13
37
 
14
38
  def call
15
39
  return unless active?
16
40
 
41
+ @bus.poke(0x93, @cpu.a)
42
+ @bus.poke(0x90, 0x00)
17
43
  name = filename
18
- if name.empty?
19
- error(MISSING_FILENAME)
20
- elsif (data = @storage.read_file(name))
21
- deliver(data)
44
+ return @cpu.program_counter = MISSING_FILE_NAME_EXIT if Storage.parse_name(name).first.empty?
45
+
46
+ data, complete = receive(name)
47
+ if low_memory?(data)
48
+ @drive.close(0)
22
49
  else
23
- error(FILE_NOT_FOUND)
50
+ finish(data, complete)
24
51
  end
25
- return_to_caller
26
52
  end
27
53
 
28
54
  private
29
55
 
30
- # A=0 is LOAD, A=1 is VERIFY
56
+ # Reads the file the way the ROM's byte loop would, up to the byte
57
+ # flagged EOI. A read error in the file's chain stops the bytes early,
58
+ # with no EOI.
59
+ def receive(name)
60
+ @drive.open(0, name)
61
+ data = []
62
+ loop do
63
+ byte, eoi = @drive.read(0)
64
+ return [data, false] unless byte
65
+
66
+ data << byte
67
+ return [data, true] if eoi
68
+ end
69
+ end
70
+
71
+ # No first byte means the file isn't there, or its first block didn't
72
+ # read. A later read error leaves the ROM retrying the next byte
73
+ # until RUN/STOP breaks the load, as on a real drive.
74
+ def finish(data, complete)
75
+ if data.empty?
76
+ @bus.poke(0x90, EOI | READ_TIMEOUT)
77
+ continue_with(SEARCHING_MESSAGE, FILE_NOT_FOUND_EXIT)
78
+ else
79
+ deliver(data)
80
+ @bus.poke(0x90, @bus.peek(0x90) & ~EOI) unless complete
81
+ continue_with(SEARCHING_MESSAGE, LOADING_MESSAGE, complete ? LOAD_DONE : BYTE_LOOP)
82
+ end
83
+ end
84
+
85
+ def low_memory?(data)
86
+ load? && data.length > 2 && load_address(data) < 0x0334
87
+ end
88
+
89
+ # A=0 is LOAD, A=1 is VERIFY, kept at $93 (VERCK)
31
90
  def load?
32
- @cpu.a.zero?
91
+ @bus.peek(0x93).zero?
33
92
  end
34
93
 
35
94
  def deliver(data)
36
95
  addr = load_address(data)
37
96
  payload = data[2..] || []
38
97
  payload = payload[0, 0x10000 - addr] if addr + payload.length > 0x10000
39
- @bus.ram.write(addr, payload) if load?
40
- finish((addr + payload.length) & 0xffff)
98
+ if load?
99
+ @bus.ram.write(addr, payload)
100
+ @bus.poke(0x90, EOI)
101
+ else
102
+ verify(addr, payload)
103
+ end
104
+ end_addr = (addr + payload.length) & 0xffff
105
+ @bus.poke(0xae, low_byte(end_addr))
106
+ @bus.poke(0xaf, high_byte(end_addr))
107
+ end
108
+
109
+ def verify(addr, payload)
110
+ match = payload.each_with_index.all? { |byte, i| @bus.peek(addr + i) == byte }
111
+ @bus.poke(0x90, match ? EOI : EOI | VERIFY_MISMATCH)
41
112
  end
42
113
 
43
114
  # Secondary address $B9 zero relocates to the caller-supplied address
@@ -46,18 +117,9 @@ module Badline
46
117
  if @bus.peek(0xb9).zero?
47
118
  uint16(@bus.peek(0xc3), @bus.peek(0xc4))
48
119
  else
49
- uint16(data[0], data[1])
120
+ uint16(data[0], data[1].to_i)
50
121
  end
51
122
  end
52
-
53
- def finish(end_addr)
54
- @bus.poke(0x90, 0x40)
55
- @bus.poke(0xae, low_byte(end_addr))
56
- @bus.poke(0xaf, high_byte(end_addr))
57
- @cpu.x = low_byte(end_addr)
58
- @cpu.y = high_byte(end_addr)
59
- @cpu.status.carry = false
60
- end
61
123
  end
62
124
  end
63
125
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ # Base class for the PC traps on KERNAL routines. Holds the device
6
+ # number they answer for and the return that stands in for the trapped
7
+ # routine's RTS.
8
+ class Routine
9
+ include IntegerHelper
10
+
11
+ DEVICE = 8
12
+
13
+ def initialize(cpu:, bus:)
14
+ @cpu = cpu
15
+ @bus = bus
16
+ end
17
+
18
+ private
19
+
20
+ def kernal?
21
+ @bus.io_port.kernal?
22
+ end
23
+
24
+ def return_to_caller
25
+ @cpu.program_counter = (uint16(pull_byte, pull_byte) + 1) & 0xffff
26
+ end
27
+
28
+ # Runs the ROM routines in order, each returning into the next, and
29
+ # the last one to the trapped routine's caller
30
+ def continue_with(first, *rest)
31
+ rest.reverse_each { |address| push_address((address - 1) & 0xffff) }
32
+ @cpu.program_counter = first
33
+ end
34
+
35
+ def push_address(address)
36
+ push_byte(high_byte(address))
37
+ push_byte(low_byte(address))
38
+ end
39
+
40
+ def push_byte(value)
41
+ @bus.poke(0x0100 + @cpu.stack_pointer, value)
42
+ @cpu.stack_pointer = (@cpu.stack_pointer - 1) & 0xff
43
+ end
44
+
45
+ def pull_byte
46
+ @cpu.stack_pointer = (@cpu.stack_pointer + 1) & 0xff
47
+ @bus.peek(0x0100 + @cpu.stack_pointer)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -5,25 +5,62 @@ module Badline
5
5
  # PC trap on the KERNAL serial SAVE routine ($F5ED, the default ISAVE
6
6
  # vector target). Writes device 8 saves to a storage backend as a PRG
7
7
  # (load address followed by the memory range); other devices fall
8
- # through to the ROM.
8
+ # through to the ROM. The ROM prints SAVING in direct mode and returns
9
+ # into the trap, which then writes the file and leaves through the
10
+ # ROM's own tail with the registers its UNLISTEN and return leave. A
11
+ # host write that fails ends the way a 1541 ends a SAVE it can't
12
+ # write: the drive stops listening, so ST reads DEVICE NOT PRESENT,
13
+ # and the ROM returns without an error.
9
14
  class Save < File
10
15
  ADDRESS = 0xf5ed
11
16
 
17
+ # ROM entry points: the SAVING message, the two line releases that
18
+ # end UNLISTEN (clock, then data, leaving A as read from $DD00), the
19
+ # successful return (CLC, RTS) and the MISSING FILE NAME error exit
20
+ SAVING_MESSAGE = 0xf68f
21
+ CLOCK_RELEASE = 0xee85
22
+ DATA_RELEASE = 0xee97
23
+ SAVE_DONE = 0xf657
24
+ MISSING_FILE_NAME_EXIT = 0xf710
25
+
26
+ SECONDARY = 0x61
27
+
28
+ # ST bit at $90
29
+ DEVICE_NOT_PRESENT = 0x80
30
+
31
+ def initialize(cpu:, bus:, storage:)
32
+ super
33
+ @saving = false
34
+ end
35
+
12
36
  def call
13
37
  return unless active?
38
+ return finish if @saving
39
+
40
+ @bus.poke(0xb9, SECONDARY)
41
+ return @cpu.program_counter = MISSING_FILE_NAME_EXIT if name.empty?
14
42
 
15
- name = filename
16
- if name.empty?
17
- error(MISSING_FILENAME)
18
- else
19
- @storage.write_file(name, payload)
20
- finish
21
- end
22
- return_to_caller
43
+ @bus.poke(0x90, 0x00)
44
+ @saving = true
45
+ continue_with(SAVING_MESSAGE, ADDRESS)
23
46
  end
24
47
 
25
48
  private
26
49
 
50
+ def name
51
+ Storage.parse_name(filename).first
52
+ end
53
+
54
+ def finish
55
+ @saving = false
56
+ @bus.poke(0x90, DEVICE_NOT_PRESENT) unless @storage.write_file(name, payload)
57
+ @bus.poke(0xac, @bus.peek(0xae))
58
+ @bus.poke(0xad, @bus.peek(0xaf))
59
+ @cpu.y = 0
60
+ @cpu.status.overflow = true
61
+ continue_with(CLOCK_RELEASE, DATA_RELEASE, SAVE_DONE)
62
+ end
63
+
27
64
  # Start address at $C1/$C2 (STAL), end address (exclusive) at
28
65
  # $AE/$AF (EAL)
29
66
  def payload
@@ -32,11 +69,6 @@ module Badline
32
69
  [low_byte(start), high_byte(start)] +
33
70
  Array.new(length) { |i| @bus.peek((start + i) & 0xffff) }
34
71
  end
35
-
36
- def finish
37
- @bus.poke(0x90, 0x00)
38
- @cpu.status.carry = false
39
- end
40
72
  end
41
73
  end
42
74
  end