badline 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/CLAUDE.md +6 -0
- data/README.md +24 -6
- data/doc/pinned-behaviour.md +18 -6
- data/exe/badline +5 -2
- data/lib/badline/address_bus.rb +6 -4
- data/lib/badline/addressable.rb +1 -1
- data/lib/badline/audio/bare_player.rb +20 -3
- data/lib/badline/audio/machine_player.rb +9 -6
- data/lib/badline/audio/renderer.rb +3 -3
- data/lib/badline/audio/stream.rb +117 -0
- data/lib/badline/audio.rb +1 -0
- data/lib/badline/cartridge/easy_flash.rb +6 -2
- data/lib/badline/cartridge/epyx_fastload.rb +1 -1
- data/lib/badline/cartridge/mach5.rb +1 -1
- data/lib/badline/cartridge/magic_desk.rb +5 -1
- data/lib/badline/cartridge/ocean.rb +5 -2
- data/lib/badline/cartridge/rex_utility.rb +1 -1
- data/lib/badline/cartridge/rgcd.rb +1 -1
- data/lib/badline/cartridge/simons_basic.rb +2 -2
- data/lib/badline/cartridge/westermann.rb +2 -2
- data/lib/badline/cartridge.rb +8 -0
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia.rb +4 -2
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +11 -6
- data/lib/badline/cpu.rb +2 -2
- data/lib/badline/gui/application.rb +41 -9
- data/lib/badline/kernal_trap/channel.rb +99 -7
- data/lib/badline/kernal_trap/drive/block_commands.rb +79 -0
- data/lib/badline/kernal_trap/drive/channels.rb +53 -0
- data/lib/badline/kernal_trap/drive/memory.rb +109 -0
- data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
- data/lib/badline/kernal_trap/drive.rb +88 -110
- data/lib/badline/kernal_trap/file.rb +0 -5
- data/lib/badline/kernal_trap/load.rb +6 -0
- data/lib/badline/kernal_trap/save.rb +5 -4
- data/lib/badline/media.rb +2 -2
- data/lib/badline/memory.rb +2 -2
- data/lib/badline/rom.rb +14 -5
- data/lib/badline/sid/catch_up.rb +110 -0
- data/lib/badline/sid/decimator.rb +6 -1
- data/lib/badline/sid/filter.rb +5 -5
- data/lib/badline/sid/waveform/fast_forward.rb +10 -10
- data/lib/badline/sid/waveform.rb +7 -7
- data/lib/badline/sid.rb +16 -92
- data/lib/badline/storage/disk_image.rb +22 -0
- data/lib/badline/storage/host_directory.rb +15 -3
- data/lib/badline/storage/sid_file.rb +36 -2
- data/lib/badline/storage.rb +3 -1
- data/lib/badline/time_of_day.rb +7 -0
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/graphics_shifter.rb +8 -2
- data/lib/badline/vic/registers.rb +16 -27
- data/lib/badline/vic.rb +5 -2
- data/lib/badline.rb +1 -0
- metadata +8 -1
data/lib/badline/cartridge.rb
CHANGED
|
@@ -168,10 +168,18 @@ module Badline
|
|
|
168
168
|
Bank.new(data)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
# A bank the image leaves out reads as erased ROM.
|
|
171
172
|
def bank(banks, number)
|
|
172
173
|
banks[number] || EMPTY_BANK
|
|
173
174
|
end
|
|
174
175
|
|
|
176
|
+
# The bank register bits a ROM decodes. The ROM holds the highest bank
|
|
177
|
+
# in the image, and has address lines for the power of two that covers
|
|
178
|
+
# it; the register bits above those lines are ignored.
|
|
179
|
+
def bank_mask(banks)
|
|
180
|
+
(1 << (banks.length - 1).bit_length) - 1
|
|
181
|
+
end
|
|
182
|
+
|
|
175
183
|
# Sorts the CHIP packets into ROML and ROMH banks by load address. A 16K
|
|
176
184
|
# chip at $8000 spans both.
|
|
177
185
|
def banks_from(chips)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
# A digest of the machine's state at one cycle, for comparing two runs of
|
|
5
|
+
# the same scenario, on two builds or two revisions. Each component is
|
|
6
|
+
# hashed on its own with 32-bit FNV-1a, so a mismatch names the part of the
|
|
7
|
+
# machine that went its own way. Nothing is read through a chip's bus
|
|
8
|
+
# interface, so taking a checkpoint leaves the machine as it was.
|
|
9
|
+
#
|
|
10
|
+
# Colour RAM is digested as the four bits each cell stores: the upper
|
|
11
|
+
# nibble a CPU read sees comes off the open bus.
|
|
12
|
+
class Checkpoint
|
|
13
|
+
COMPONENTS = %w[cpu ram color_ram display vic cia1 cia2 sid].freeze
|
|
14
|
+
|
|
15
|
+
FNV_OFFSET = 0x811c9dc5
|
|
16
|
+
FNV_PRIME = 0x01000193
|
|
17
|
+
|
|
18
|
+
attr_reader :cycle, :digests
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def take(computer)
|
|
22
|
+
new(computer.cycles, [
|
|
23
|
+
fnv1a(cpu_state(computer.cpu)),
|
|
24
|
+
fnv1a(ram(computer.ram)),
|
|
25
|
+
fnv1a(color_ram(computer.address_bus.color_ram)),
|
|
26
|
+
fnv1a(computer.vic.display),
|
|
27
|
+
fnv1a(vic_state(computer.vic)),
|
|
28
|
+
fnv1a(cia_state(computer.cia1)),
|
|
29
|
+
fnv1a(cia_state(computer.cia2)),
|
|
30
|
+
fnv1a(Array.new(0x20) { |reg| computer.sid.register(reg) })
|
|
31
|
+
])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Reads a line written by #to_s.
|
|
35
|
+
def parse(line)
|
|
36
|
+
fields = line.split
|
|
37
|
+
digests = COMPONENTS.map do |name|
|
|
38
|
+
field = fields.find { |f| f.start_with?("#{name}=") }
|
|
39
|
+
raise ArgumentError, "no #{name} digest in #{line.inspect}" unless field
|
|
40
|
+
|
|
41
|
+
field.split("=").last.to_i(16)
|
|
42
|
+
end
|
|
43
|
+
new(fields.first.to_i, digests)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fnv1a(values)
|
|
47
|
+
hash = FNV_OFFSET
|
|
48
|
+
values.each { |value| hash = ((hash ^ (value & 0xffffffff)) * FNV_PRIME) & 0xffffffff }
|
|
49
|
+
hash
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def cpu_state(cpu)
|
|
55
|
+
[cpu.program_counter, cpu.a, cpu.x, cpu.y, cpu.stack_pointer, cpu.p, cpu.cycles]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ram(memory)
|
|
59
|
+
Array.new(0x10000) { |addr| memory.peek(addr) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def color_ram(memory)
|
|
63
|
+
Array.new(0x400) { |offset| memory.nibble(0xd800 + offset) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def vic_state(vic)
|
|
67
|
+
vic.register_file + [vic.rasterline, vic.column]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cia_state(cia)
|
|
71
|
+
start = cia.start
|
|
72
|
+
[cia.peek(start), cia.peek(start + 1), cia.peek(start + 2), cia.peek(start + 3),
|
|
73
|
+
cia.timer_a, cia.timer_a_latch, cia.timer_b, cia.timer_b_latch, cia.serial.data,
|
|
74
|
+
cia.control_a.value, cia.control_b.value, cia.interrupt_status.value,
|
|
75
|
+
cia.interrupt_control.value] + cia.time_of_day.registers
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def initialize(cycle, digests)
|
|
80
|
+
@cycle = cycle
|
|
81
|
+
@digests = digests
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The components whose digests differ from `other`'s.
|
|
85
|
+
def differences(other)
|
|
86
|
+
COMPONENTS.each_index.reject { |i| digests[i] == other.digests[i] }.map { |i| COMPONENTS[i] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ==(other)
|
|
90
|
+
other.is_a?(Checkpoint) && cycle == other.cycle && digests == other.digests
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_s
|
|
94
|
+
fields = COMPONENTS.each_index.map { |i| "#{COMPONENTS[i]}=#{digests[i].to_s(16).rjust(8, '0')}" }
|
|
95
|
+
"#{cycle} #{fields.join(' ')}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/badline/cia.rb
CHANGED
|
@@ -17,6 +17,8 @@ module Badline
|
|
|
17
17
|
|
|
18
18
|
def interrupt!(delay = 1) = @icr.assert!(delay)
|
|
19
19
|
|
|
20
|
+
def time_of_day = @tod
|
|
21
|
+
|
|
20
22
|
def timer_a = @ta.counter
|
|
21
23
|
|
|
22
24
|
def timer_a=(value)
|
|
@@ -115,7 +117,7 @@ module Badline
|
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
def peek(addr)
|
|
118
|
-
case
|
|
120
|
+
case offset_of(addr) & 0x0f
|
|
119
121
|
when 0x00 then read_port_a
|
|
120
122
|
when 0x01 then read_port_b
|
|
121
123
|
when 0x02 then @data_dir_a
|
|
@@ -136,7 +138,7 @@ module Badline
|
|
|
136
138
|
end
|
|
137
139
|
|
|
138
140
|
def poke(addr, value)
|
|
139
|
-
case
|
|
141
|
+
case offset_of(addr) & 0x0f
|
|
140
142
|
when 0x00 then @data_port_a = value
|
|
141
143
|
when 0x01 then update_port_b { @data_port_b = value }
|
|
142
144
|
when 0x02 then @data_dir_a = value
|
data/lib/badline/color_memory.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Badline
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def peek(addr)
|
|
13
|
-
(@vic.phi1_data & 0xf0) | @storage[
|
|
13
|
+
(@vic.phi1_data & 0xf0) | @storage[offset_of(addr)]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def poke(addr, value)
|
|
@@ -19,7 +19,7 @@ module Badline
|
|
|
19
19
|
|
|
20
20
|
# The four bits the cell stores, as the VIC's c-access sees them.
|
|
21
21
|
def nibble(addr)
|
|
22
|
-
@storage[
|
|
22
|
+
@storage[offset_of(addr)]
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
data/lib/badline/computer.rb
CHANGED
|
@@ -45,6 +45,7 @@ module Badline
|
|
|
45
45
|
@freeze_writes = 0
|
|
46
46
|
@init_handlers = []
|
|
47
47
|
@pending_keys = nil
|
|
48
|
+
@drive = nil
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
INIT_THRESHOLD = 2_500_000
|
|
@@ -92,14 +93,16 @@ module Badline
|
|
|
92
93
|
reset!
|
|
93
94
|
end
|
|
94
95
|
|
|
95
|
-
# The RES line reaches the CPU and its port, both CIAs, the SID
|
|
96
|
-
# cartridge port
|
|
96
|
+
# The RES line reaches the CPU and its port, both CIAs, the SID, the
|
|
97
|
+
# cartridge port and, through the serial bus's RESET line, the drive.
|
|
98
|
+
# The VIC has no reset pin.
|
|
97
99
|
def reset!
|
|
98
100
|
address_bus.reset_port!
|
|
99
101
|
@cia1.reset!
|
|
100
102
|
@cia2.reset!
|
|
101
103
|
@sid.reset!
|
|
102
104
|
address_bus.cartridge&.reset
|
|
105
|
+
@drive&.reset!
|
|
103
106
|
@freezing = false
|
|
104
107
|
@nmi_asserted = false
|
|
105
108
|
cpu.reset!
|
|
@@ -120,14 +123,16 @@ module Badline
|
|
|
120
123
|
address_bus.cartridge&.release_button
|
|
121
124
|
end
|
|
122
125
|
|
|
126
|
+
# A disk swap keeps the drive's RAM, which only a drive reset clears.
|
|
123
127
|
def mount(storage)
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
@drive_memory ||= KernalTrap::Drive::Memory.new
|
|
129
|
+
@drive = KernalTrap::Drive.new(storage, @drive_memory)
|
|
130
|
+
load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, drive: @drive)
|
|
126
131
|
cpu.install_trap(KernalTrap::Load::ADDRESS) { load_trap.call }
|
|
127
|
-
KernalTrap::Serial.new(cpu:, bus: address_bus, drive:).install
|
|
132
|
+
KernalTrap::Serial.new(cpu:, bus: address_bus, drive: @drive).install
|
|
128
133
|
return unless storage.respond_to?(:write_file)
|
|
129
134
|
|
|
130
|
-
save_trap = KernalTrap::Save.new(cpu:, bus: address_bus,
|
|
135
|
+
save_trap = KernalTrap::Save.new(cpu:, bus: address_bus, drive: @drive)
|
|
131
136
|
cpu.install_trap(KernalTrap::Save::ADDRESS) { save_trap.call }
|
|
132
137
|
end
|
|
133
138
|
|
data/lib/badline/cpu.rb
CHANGED
|
@@ -24,10 +24,10 @@ module Badline
|
|
|
24
24
|
|
|
25
25
|
# +ane_constant+ is ANE's magic constant, which varies from chip to
|
|
26
26
|
# chip. The default is the C64 6510's.
|
|
27
|
-
def initialize(memory
|
|
27
|
+
def initialize(memory, debug: false, ane_constant: 0xef)
|
|
28
28
|
@debug = debug
|
|
29
29
|
@ane_constant = ane_constant
|
|
30
|
-
@memory = memory
|
|
30
|
+
@memory = memory
|
|
31
31
|
@status = CPUStatus.new(STATUS_FLAGS, value: 0b00100000)
|
|
32
32
|
reset_registers
|
|
33
33
|
|
|
@@ -6,6 +6,7 @@ module Badline
|
|
|
6
6
|
PAL_CLOCK_HZ = 985_248
|
|
7
7
|
TITLE = "Badline"
|
|
8
8
|
TOGGLE_SYM = SDL2::Key::TAB
|
|
9
|
+
MUTE_SYM = SDL2::Key::F10
|
|
9
10
|
REVERSE_MOD = SDL2::Key::Mod::SHIFT
|
|
10
11
|
|
|
11
12
|
SHARED_KEYS = %i[up left cursor_h cursor_v space w a s d lshift].freeze
|
|
@@ -28,24 +29,23 @@ module Badline
|
|
|
28
29
|
}.freeze
|
|
29
30
|
MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
|
|
30
31
|
|
|
31
|
-
def initialize(media_path: nil, autostart: true, song: nil, sid_model: nil,
|
|
32
|
-
@computer = Computer.new(
|
|
32
|
+
def initialize(media_path: nil, autostart: true, song: nil, sid_model: nil, sound: false)
|
|
33
|
+
@computer = Computer.new(sid_model: sid_model || Media.sid_model(media_path))
|
|
33
34
|
puts Media.attach(@computer, media_path, autostart:, song:) if media_path
|
|
34
35
|
|
|
35
36
|
@mode = :keyboard
|
|
36
37
|
@pot_device = nil
|
|
37
38
|
@panes = [ScreenPane.new(@computer)]
|
|
39
|
+
@stream = open_stream if sound
|
|
40
|
+
@paced = ENV["NOVSYNC"].nil?
|
|
38
41
|
@window = Window.new(
|
|
39
42
|
title: TITLE,
|
|
40
43
|
width: canvas_width, height: canvas_height,
|
|
41
|
-
vsync:
|
|
44
|
+
vsync: @paced && !@stream
|
|
42
45
|
)
|
|
43
46
|
@gamepads = Gamepads.new(@computer)
|
|
44
47
|
@gamepads.names.each { |name| puts "Gamepad: #{name}" }
|
|
45
|
-
|
|
46
|
-
rate = @window.refresh_rate
|
|
47
|
-
@cycles_per_frame = PAL_CLOCK_HZ / rate
|
|
48
|
-
puts "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
|
|
48
|
+
fit_frame
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def run
|
|
@@ -54,9 +54,12 @@ module Badline
|
|
|
54
54
|
handle_events
|
|
55
55
|
@gamepads.poll
|
|
56
56
|
@cycles_per_frame.times { @computer.cycle! }
|
|
57
|
+
@stream&.feed
|
|
57
58
|
@window.draw(@panes)
|
|
59
|
+
@stream.pace(@frame_seconds) if @stream && @paced
|
|
58
60
|
end
|
|
59
61
|
ensure
|
|
62
|
+
@stream&.close
|
|
60
63
|
@gamepads.close
|
|
61
64
|
puts @computer.cpu.inspect
|
|
62
65
|
end
|
|
@@ -84,6 +87,7 @@ module Badline
|
|
|
84
87
|
|
|
85
88
|
def handle_key_down(event)
|
|
86
89
|
return cycle_mode(event.mod.anybits?(REVERSE_MOD) ? -1 : 1) if event.sym == TOGGLE_SYM
|
|
90
|
+
return toggle_mute if event.sym == MUTE_SYM
|
|
87
91
|
|
|
88
92
|
port, dir = JoyMap.parse(event) if @mode == :joystick
|
|
89
93
|
if port
|
|
@@ -94,7 +98,7 @@ module Badline
|
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
def handle_key_up(event)
|
|
97
|
-
return if event.sym
|
|
101
|
+
return if [TOGGLE_SYM, MUTE_SYM].include?(event.sym)
|
|
98
102
|
|
|
99
103
|
port, dir = JoyMap.parse(event) if @mode == :joystick
|
|
100
104
|
if port
|
|
@@ -123,7 +127,35 @@ module Badline
|
|
|
123
127
|
@mode = modes[(modes.index(@mode) + step) % modes.size]
|
|
124
128
|
release_inputs
|
|
125
129
|
attach_pot_device
|
|
126
|
-
|
|
130
|
+
update_title
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def toggle_mute
|
|
134
|
+
return unless @stream
|
|
135
|
+
|
|
136
|
+
@stream.toggle_mute
|
|
137
|
+
update_title
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def update_title
|
|
141
|
+
tags = [MODES[@mode], @stream&.muted? && "MUTED"].select(&:itself)
|
|
142
|
+
@window.title = [TITLE, *tags.map { |tag| "[#{tag}]" }].join(" ")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def open_stream
|
|
146
|
+
sink = Audio::SDLSink.new(rate: Audio::Renderer::DEFAULT_RATE)
|
|
147
|
+
puts "Sound at #{sink.rate} Hz, F10 mutes"
|
|
148
|
+
Audio::Stream.new(sink, @computer.sid,
|
|
149
|
+
on_underrun: -> { puts "Running below real time, so the sound will stutter." })
|
|
150
|
+
rescue Audio::SDLSink::Error => e
|
|
151
|
+
warn "badline: no sound, can't open the audio device: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def fit_frame
|
|
155
|
+
rate = @window.refresh_rate
|
|
156
|
+
@cycles_per_frame = PAL_CLOCK_HZ / rate
|
|
157
|
+
@frame_seconds = @cycles_per_frame.fdiv(PAL_CLOCK_HZ)
|
|
158
|
+
puts "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
|
|
127
159
|
end
|
|
128
160
|
|
|
129
161
|
def attach_pot_device
|
|
@@ -8,12 +8,31 @@ module Badline
|
|
|
8
8
|
# channel cut short by a read error never flags one, and holds the
|
|
9
9
|
# error for the drive to report once its bytes run out.
|
|
10
10
|
class Channel
|
|
11
|
-
attr_accessor :
|
|
12
|
-
attr_reader :error
|
|
11
|
+
attr_accessor :buffer
|
|
12
|
+
attr_reader :error, :pointer
|
|
13
|
+
attr_writer :lead
|
|
13
14
|
|
|
14
|
-
def initialize(bytes = [], error: nil)
|
|
15
|
+
def initialize(bytes = [], error: nil, writable: false, base: 0)
|
|
16
|
+
@base = base
|
|
15
17
|
replace(bytes)
|
|
16
18
|
@error = error
|
|
19
|
+
@writable = writable
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A block buffer opened with "#", which reads and writes one of the
|
|
23
|
+
# drive's buffers in its RAM, so M-R and M-W reach the same bytes. It
|
|
24
|
+
# hands out the whole block unless a B-R sets a shorter end, and
|
|
25
|
+
# flags that byte as the last. The DOS opens it with the pointer at 1
|
|
26
|
+
# and the buffer's number as the byte to send, so the first read
|
|
27
|
+
# answers with the number and the next one moves on to the third
|
|
28
|
+
# byte.
|
|
29
|
+
def self.buffer(memory, number)
|
|
30
|
+
new(memory.ram, writable: true, base: Drive::Memory.buffer_address(number)).tap do |channel|
|
|
31
|
+
channel.buffer = number
|
|
32
|
+
channel.rewind(Drive::BLOCK_SIZE)
|
|
33
|
+
channel.pointer = 1
|
|
34
|
+
channel.lead = number
|
|
35
|
+
end
|
|
17
36
|
end
|
|
18
37
|
|
|
19
38
|
# A file's bytes, up to the first block its chain can't read. The
|
|
@@ -30,22 +49,95 @@ module Badline
|
|
|
30
49
|
new(data[0, failure[:offset] - 1] || [], error: failure.values_at(:error, :track, :sector))
|
|
31
50
|
end
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
# The file an open names. The drive keeps the first block of the file
|
|
53
|
+
# a LOAD (secondary address 0) opens at $7E and $026F of its memory,
|
|
54
|
+
# and a LOAD of a name starting with "*" reopens that file rather than
|
|
55
|
+
# the first one on the disk. Loaders write the block there with M-W
|
|
56
|
+
# to load a file the directory doesn't list.
|
|
57
|
+
def self.for_name(storage, memory, secondary, name, type)
|
|
58
|
+
file = Storage.parse_name(name).first
|
|
59
|
+
return for_file(storage, file, type) unless secondary.zero? && storage.respond_to?(:first_block)
|
|
60
|
+
|
|
61
|
+
last = memory.last_program if name.start_with?("*")
|
|
62
|
+
return at_block(storage, *last) if last
|
|
63
|
+
|
|
64
|
+
block = storage.first_block(file, type:)
|
|
65
|
+
memory.last_program = block if block
|
|
66
|
+
for_file(storage, file, type)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The file whose chain starts at the block.
|
|
70
|
+
def self.at_block(storage, track, sector)
|
|
71
|
+
data = storage.read_file_at(track, sector)
|
|
72
|
+
data ? new(data) : new([], error: [Drive::ILLEGAL_TRACK_OR_SECTOR, track, sector])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def replace(bytes, length = bytes.length)
|
|
34
76
|
@bytes = bytes
|
|
35
|
-
|
|
77
|
+
rewind(length)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rewind(length)
|
|
81
|
+
@length = length
|
|
82
|
+
self.pointer = 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pointer=(position)
|
|
86
|
+
@pointer = position
|
|
87
|
+
@lead = nil
|
|
36
88
|
end
|
|
37
89
|
|
|
90
|
+
# A block read into the channel. A buffer channel takes it into its
|
|
91
|
+
# buffer, a file channel hands it out in place of the file.
|
|
92
|
+
def load(block, length)
|
|
93
|
+
return replace(block.dup, length) unless @writable
|
|
94
|
+
|
|
95
|
+
@bytes[@base, block.length] = block
|
|
96
|
+
rewind(length)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def writable? = @writable
|
|
100
|
+
|
|
38
101
|
def exhausted?
|
|
39
|
-
@pointer >= @
|
|
102
|
+
@pointer >= @length
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A file that holds nothing but its error never opened.
|
|
106
|
+
def failed?
|
|
107
|
+
exhausted? && !@error.nil?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Each byte lands at the pointer, which wraps within the block.
|
|
111
|
+
def write(bytes)
|
|
112
|
+
@lead = nil
|
|
113
|
+
bytes.each do |byte|
|
|
114
|
+
@bytes[@base + @pointer] = byte
|
|
115
|
+
@pointer = (@pointer + 1) & 0xff
|
|
116
|
+
end
|
|
40
117
|
end
|
|
41
118
|
|
|
42
119
|
def read
|
|
120
|
+
return read_buffer if @writable
|
|
43
121
|
return if exhausted?
|
|
44
122
|
|
|
45
|
-
byte = @bytes[@pointer]
|
|
123
|
+
byte = @lead || @bytes[@base + @pointer]
|
|
124
|
+
@lead = nil
|
|
46
125
|
@pointer += 1
|
|
47
126
|
[byte, exhausted? && !@error]
|
|
48
127
|
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# A buffer never runs out. Past its last byte, which goes out with
|
|
132
|
+
# EOI, the pointer wraps back to the second, and a pointer set past
|
|
133
|
+
# the end runs on around the block to it.
|
|
134
|
+
def read_buffer
|
|
135
|
+
byte = @lead || @bytes[@base + @pointer]
|
|
136
|
+
@lead = nil
|
|
137
|
+
last = @pointer == @length - 1
|
|
138
|
+
@pointer = last ? 1 : (@pointer + 1) & 0xff
|
|
139
|
+
[byte, last]
|
|
140
|
+
end
|
|
49
141
|
end
|
|
50
142
|
end
|
|
51
143
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module KernalTrap
|
|
5
|
+
class Drive
|
|
6
|
+
# The commands that read and write a block through a buffer channel,
|
|
7
|
+
# and B-P, which moves its pointer.
|
|
8
|
+
module BlockCommands
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def block_read(arguments, counted: false)
|
|
12
|
+
channel, _drive, track, sector = arguments
|
|
13
|
+
data = fetch_block(channel, track, sector)
|
|
14
|
+
return unless data
|
|
15
|
+
|
|
16
|
+
buffer = @channels[channel]
|
|
17
|
+
buffer.load(data, counted ? data[0] + 1 : data.length)
|
|
18
|
+
buffer.pointer = 1 if counted
|
|
19
|
+
report_block_error(track, sector)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The disk is write-protected, so a block write that gets as far as
|
|
23
|
+
# the disk fails at the block it names.
|
|
24
|
+
def block_write(arguments, counted: false)
|
|
25
|
+
channel, _drive, track, sector = arguments
|
|
26
|
+
return unless fetch_block(channel, track, sector)
|
|
27
|
+
|
|
28
|
+
store_count(@channels[channel]) if counted
|
|
29
|
+
report(WRITE_PROTECT_ON, track, sector)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# B-W first stores the index of the last byte written, the pointer
|
|
33
|
+
# less one but at least 1, as the block's first byte, which leaves
|
|
34
|
+
# the pointer at 1. B-R reads that count back.
|
|
35
|
+
def counted_block_write(arguments)
|
|
36
|
+
block_write(arguments, counted: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def store_count(buffer)
|
|
40
|
+
count = [buffer.pointer - 1, 1].max
|
|
41
|
+
buffer.pointer = 0
|
|
42
|
+
buffer.write([count])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Block access needs an open buffer channel, a disk and a block on it.
|
|
46
|
+
# Returns the block, or nil once it has reported why there is none.
|
|
47
|
+
def fetch_block(channel, track, sector)
|
|
48
|
+
return report(NO_CHANNEL) unless @channels[channel]
|
|
49
|
+
return report(DRIVE_NOT_READY) unless @storage.respond_to?(:read_block)
|
|
50
|
+
|
|
51
|
+
data = sector && @storage.read_block(track, sector)
|
|
52
|
+
data || report(ILLEGAL_TRACK_OR_SECTOR, track, sector)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A block the image's error table marks bad still fills the buffer,
|
|
56
|
+
# but the read reports its error, which copy protection checks for.
|
|
57
|
+
def report_block_error(track, sector)
|
|
58
|
+
error = @storage.block_error(track, sector)
|
|
59
|
+
error ? report(error, track, sector) : report(OK)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# B-R takes the block's first byte as the index of its last one, and
|
|
63
|
+
# hands out the bytes from the second up to there.
|
|
64
|
+
def counted_block_read(arguments)
|
|
65
|
+
block_read(arguments, counted: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def buffer_pointer(arguments)
|
|
69
|
+
channel, position = arguments
|
|
70
|
+
buffer = @channels[channel]
|
|
71
|
+
return report(NO_CHANNEL) unless buffer
|
|
72
|
+
|
|
73
|
+
buffer.pointer = position.to_i & 0xff
|
|
74
|
+
report(OK)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module KernalTrap
|
|
5
|
+
class Drive
|
|
6
|
+
# The drive's open data channels by secondary address, and the five
|
|
7
|
+
# buffers they hold. The DOS hands out the highest free buffer.
|
|
8
|
+
# Reading the BAM when it initializes the disk takes buffer 4, so
|
|
9
|
+
# channels get 3 down to 0, and the fifth data channel finds none.
|
|
10
|
+
class Channels
|
|
11
|
+
BAM_BUFFER = 4
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@channels = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def [](secondary) = @channels[secondary]
|
|
18
|
+
|
|
19
|
+
def open?(secondary) = @channels.key?(secondary)
|
|
20
|
+
|
|
21
|
+
def close(secondary) = @channels.delete(secondary)
|
|
22
|
+
|
|
23
|
+
def clear = @channels.clear
|
|
24
|
+
|
|
25
|
+
# Opens a buffer channel on the buffer a "#n" name asks for, or on
|
|
26
|
+
# the highest free one. Returns nil when that buffer isn't free,
|
|
27
|
+
# which leaves a channel asking again for its own buffer open.
|
|
28
|
+
def open_buffer(secondary, number, memory)
|
|
29
|
+
return if number && !free_buffers.include?(number)
|
|
30
|
+
|
|
31
|
+
close(secondary)
|
|
32
|
+
number ||= free_buffers.max
|
|
33
|
+
@channels[secondary] = Channel.buffer(memory, number) if number
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A file that opens takes a buffer, though its bytes don't pass
|
|
37
|
+
# through drive RAM here. One that fails to open takes none. Returns
|
|
38
|
+
# nil, leaving the channel closed, when no buffer is free.
|
|
39
|
+
def open_file(secondary, channel)
|
|
40
|
+
close(secondary)
|
|
41
|
+
channel.buffer = free_buffers.max unless channel.failed?
|
|
42
|
+
@channels[secondary] = channel if channel.failed? || channel.buffer
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def free_buffers
|
|
48
|
+
(0...BAM_BUFFER).to_a - @channels.values.map(&:buffer)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|