badline 0.4.1 → 0.5.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 +35 -0
- data/CLAUDE.md +10 -5
- data/README.md +59 -24
- data/doc/pinned-behaviour.md +221 -0
- data/exe/badline-ruby +8 -4
- data/lib/badline/address_bus/ultimax_pages.rb +38 -0
- data/lib/badline/address_bus.rb +19 -29
- data/lib/badline/audio/cli.rb +2 -1
- data/lib/badline/audio/jukebox.rb +6 -4
- data/lib/badline/audio/machine_player.rb +10 -6
- data/lib/badline/cartridge/kcs_power.rb +3 -3
- data/lib/badline/cia.rb +5 -1
- data/lib/badline/computer.rb +86 -7
- data/lib/badline/cpu.rb +39 -0
- data/lib/badline/drive1541/bus.rb +134 -0
- data/lib/badline/drive1541/disk.rb +256 -0
- data/lib/badline/drive1541/disk_via.rb +33 -0
- data/lib/badline/drive1541/gcr.rb +57 -0
- data/lib/badline/drive1541/idle.rb +184 -0
- data/lib/badline/drive1541/mechanism.rb +277 -0
- data/lib/badline/drive1541/rotation.rb +111 -0
- data/lib/badline/drive1541/sector_reader.rb +76 -0
- data/lib/badline/drive1541/serial_port.rb +67 -0
- data/lib/badline/drive1541/track.rb +98 -0
- data/lib/badline/drive1541.rb +218 -0
- data/lib/badline/gui/application.rb +24 -7
- data/lib/badline/gui/drive_led_pane.rb +29 -0
- data/lib/badline/gui.rb +1 -0
- data/lib/badline/iec_bus.rb +93 -0
- data/lib/badline/kernal_trap/channel.rb +9 -1
- data/lib/badline/kernal_trap/serial.rb +9 -3
- data/lib/badline/media/true_drive.rb +54 -0
- data/lib/badline/media.rb +22 -1
- data/lib/badline/options.rb +30 -4
- data/lib/badline/region.rb +35 -10
- data/lib/badline/reu/dma.rb +228 -0
- data/lib/badline/reu/ram.rb +65 -0
- data/lib/badline/reu/trigger.rb +32 -0
- data/lib/badline/reu.rb +276 -0
- data/lib/badline/roms/README +10 -0
- data/lib/badline/roms/dos1541.rom +0 -0
- data/lib/badline/sdl/functions.rb +1 -0
- data/lib/badline/storage/d64_image.rb +18 -9
- data/lib/badline/storage/d81_image.rb +1 -0
- data/lib/badline/storage/disk_image/catalog.rb +52 -0
- data/lib/badline/storage/disk_image/writing.rb +16 -0
- data/lib/badline/storage/disk_image.rb +2 -0
- data/lib/badline/storage/g64_image.rb +222 -0
- data/lib/badline/storage/host_directory.rb +21 -1
- data/lib/badline/storage/listing.rb +118 -0
- data/lib/badline/storage/t64.rb +14 -0
- data/lib/badline/storage.rb +2 -0
- data/lib/badline/traps.rb +4 -0
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +3 -0
- data/lib/badline/via/fast_forward.rb +38 -0
- data/lib/badline/via/shift_register.rb +8 -3
- data/lib/badline/via/timer1.rb +30 -0
- data/lib/badline/via/timer2.rb +25 -0
- data/lib/badline/via.rb +2 -0
- data/lib/badline/vic/display_state.rb +7 -7
- data/lib/badline/vic/sprite/internal_bus.rb +11 -7
- data/lib/badline/vic/sprite/shifter.rb +10 -15
- data/lib/badline/vic/sprite/timing.rb +60 -0
- data/lib/badline/vic/sprite.rb +14 -12
- data/lib/badline/vic/sprites.rb +2 -2
- data/lib/badline/vic.rb +86 -39
- data/lib/badline.rb +3 -0
- metadata +26 -1
|
@@ -7,10 +7,28 @@ module Badline
|
|
|
7
7
|
# (174848 bytes without one), 40 tracks (196608) and 42 tracks (205312)
|
|
8
8
|
ERROR_TABLES = { 175_531 => 683, 197_376 => 768, 206_114 => 802 }.freeze
|
|
9
9
|
|
|
10
|
+
# The tracks the image holds: 35, 40 or 42.
|
|
11
|
+
def track_count
|
|
12
|
+
blocks = @bytes.length / SECTOR_SIZE
|
|
13
|
+
count = 0
|
|
14
|
+
count += 1 while count < 42 && track_offset(count + 2) <= blocks
|
|
15
|
+
count
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sectors_in(track)
|
|
19
|
+
case track
|
|
20
|
+
when 1..17 then 21
|
|
21
|
+
when 18..24 then 19
|
|
22
|
+
when 25..30 then 18
|
|
23
|
+
else 17
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
10
27
|
private
|
|
11
28
|
|
|
12
29
|
def error_tables = ERROR_TABLES
|
|
13
30
|
|
|
31
|
+
def header_name = 0x90
|
|
14
32
|
def directory_track = 18
|
|
15
33
|
def directory_sector = 1
|
|
16
34
|
def directory_interleave = 3
|
|
@@ -23,15 +41,6 @@ module Badline
|
|
|
23
41
|
def bam_tracks = 1..35
|
|
24
42
|
def bam_count(track) = [18, 0, 4 * track]
|
|
25
43
|
def bam_bitmap(track) = [18, 0, (4 * track) + 1]
|
|
26
|
-
|
|
27
|
-
def sectors_in(track)
|
|
28
|
-
case track
|
|
29
|
-
when 1..17 then 21
|
|
30
|
-
when 18..24 then 19
|
|
31
|
-
when 25..30 then 18
|
|
32
|
-
else 17
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
44
|
end
|
|
36
45
|
end
|
|
37
46
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
class DiskImage
|
|
6
|
+
# The directory as LOAD"$" lists it: the disk name and ID from the
|
|
7
|
+
# header block, every entry with a type, and the free blocks the BAM
|
|
8
|
+
# counts outside the directory track.
|
|
9
|
+
module Catalog
|
|
10
|
+
HEADER_ID = 18
|
|
11
|
+
ID_LENGTH = 5
|
|
12
|
+
TYPE_MASK = 0x07
|
|
13
|
+
|
|
14
|
+
def directory
|
|
15
|
+
header = sector_at(*header_block)
|
|
16
|
+
Listing.new(name: header[header_name, DiskImage::Directory::NAME_LENGTH],
|
|
17
|
+
id: header[header_name + HEADER_ID, ID_LENGTH],
|
|
18
|
+
entries: catalog_entries,
|
|
19
|
+
blocks_free:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# A slot whose type byte is zero is empty or scratched.
|
|
25
|
+
def catalog_entries
|
|
26
|
+
list = []
|
|
27
|
+
each_sector(directory_track, directory_sector) do |data|
|
|
28
|
+
(0...ENTRIES_PER_SECTOR).each do |i|
|
|
29
|
+
entry = data[i * ENTRY_SIZE, ENTRY_SIZE]
|
|
30
|
+
list << catalog_entry(entry) unless entry[2].zero?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def catalog_entry(entry)
|
|
37
|
+
Listing::Entry.new(name: entry[5, DiskImage::Directory::NAME_LENGTH],
|
|
38
|
+
type: entry[2] & TYPE_MASK,
|
|
39
|
+
blocks: entry[30] | (entry[31] << 8),
|
|
40
|
+
closed: entry[2].anybits?(DiskImage::Directory::CLOSED),
|
|
41
|
+
locked: entry[2].anybits?(0x40))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def blocks_free
|
|
45
|
+
(bam_tracks.to_a - reserved_tracks).sum do |track|
|
|
46
|
+
block?(*bam_count(track).first(2)) ? @bytes[count_offset(track)] : 0
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -57,6 +57,22 @@ module Badline
|
|
|
57
57
|
update_image { put_sector(track, sector, data) } if block?(track, sector)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Stores blocks as a true drive read them back off its disk, in one
|
|
61
|
+
# write to the host file. Each is [track, sector, data, error]: nil
|
|
62
|
+
# data leaves the block's bytes as they were, and the error, the
|
|
63
|
+
# DOS error a read of it raises or nil, goes into the error table
|
|
64
|
+
# when the image has one.
|
|
65
|
+
def store_blocks(blocks)
|
|
66
|
+
update_image do
|
|
67
|
+
blocks.each do |track, sector, data, error|
|
|
68
|
+
next unless block?(track, sector)
|
|
69
|
+
|
|
70
|
+
put_sector(track, sector, data) if data
|
|
71
|
+
@errors[track_offset(track) + sector] = error ? DOS_ERRORS.key(error) : 1 if @errors
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
60
76
|
# Marks a block in use, as B-A does.
|
|
61
77
|
def allocate_block(track, sector) = update_image { mark(track, sector, false) }
|
|
62
78
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "badline/storage/disk_image/bam"
|
|
4
|
+
require "badline/storage/disk_image/catalog"
|
|
4
5
|
require "badline/storage/disk_image/directory"
|
|
5
6
|
require "badline/storage/disk_image/writing"
|
|
6
7
|
|
|
@@ -12,6 +13,7 @@ module Badline
|
|
|
12
13
|
# written.
|
|
13
14
|
class DiskImage
|
|
14
15
|
include Bam
|
|
16
|
+
include Catalog
|
|
15
17
|
include Directory
|
|
16
18
|
include Writing
|
|
17
19
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
# A .g64 image: a 1541 disk's flux as raw GCR, track by track, for the
|
|
6
|
+
# true drive to read the way it reads a real disk, copy protection and
|
|
7
|
+
# all. It has no file system of its own, so only the true drive
|
|
8
|
+
# (Drive1541::Disk.from_g64) can use it.
|
|
9
|
+
#
|
|
10
|
+
# The header is the signature "GCR-1541", a version byte ($00), the
|
|
11
|
+
# number of half tracks the tables cover (84, tracks 1 to 42.5) and
|
|
12
|
+
# the longest track a block may hold, a little-endian word. Two tables
|
|
13
|
+
# of little-endian longs follow, one entry per half track from track
|
|
14
|
+
# 1: the file offset of each half track's block, 0 for one without
|
|
15
|
+
# data, and its speed zone. A block is the track's length in bytes, a
|
|
16
|
+
# word, and then its bytes.
|
|
17
|
+
#
|
|
18
|
+
# A speed entry of 0 to 3 is the zone the whole track was written in.
|
|
19
|
+
# A larger one is the offset of a speed map, two bits a byte, four
|
|
20
|
+
# track bytes to a map byte, the first byte's in the top two bits, for
|
|
21
|
+
# a track whose zone changes along it. The drive passes each byte
|
|
22
|
+
# under the head at its own zone's rate (Drive1541::Track).
|
|
23
|
+
#
|
|
24
|
+
# The drive writes a track at one zone, so a track written back takes
|
|
25
|
+
# that zone as its speed entry. Writing it puts it in its own block,
|
|
26
|
+
# where it fits, and otherwise appends a block to the file for it, as
|
|
27
|
+
# for a half track that had no data. A track longer than the header's longest raises
|
|
28
|
+
# it, so the header stays true of every block. A half track beyond
|
|
29
|
+
# the tables stays on the disk alone. One opened with `read_only` is a
|
|
30
|
+
# write-protected disk, and the host file is never written.
|
|
31
|
+
#
|
|
32
|
+
# Opening an image checks that its header and tables are whole, and
|
|
33
|
+
# that every block and speed map lies past the tables and inside the
|
|
34
|
+
# file, and raises FormatError where one doesn't.
|
|
35
|
+
class G64Image
|
|
36
|
+
class FormatError < StandardError; end
|
|
37
|
+
|
|
38
|
+
SIGNATURE = "GCR-1541"
|
|
39
|
+
HEADER_SIZE = 12
|
|
40
|
+
HALF_TRACKS = 84
|
|
41
|
+
MAX_TRACK_SIZE = 7928
|
|
42
|
+
|
|
43
|
+
# A new image of +tracks+, {entry => [bytes, zone]} with entry 0 for
|
|
44
|
+
# track 1 and 1 for track 1.5, laid out as VICE lays one out: every
|
|
45
|
+
# block as long as the longest track.
|
|
46
|
+
def self.create(path, tracks, half_tracks: HALF_TRACKS)
|
|
47
|
+
max = [MAX_TRACK_SIZE, *tracks.values.map { |bytes, _| bytes.length }].max
|
|
48
|
+
offsets = Array.new(half_tracks, 0)
|
|
49
|
+
zones = Array.new(half_tracks, 0)
|
|
50
|
+
blocks = +"".b
|
|
51
|
+
position = HEADER_SIZE + (half_tracks * 8)
|
|
52
|
+
tracks.keys.sort.each do |entry|
|
|
53
|
+
bytes, zones[entry] = tracks[entry]
|
|
54
|
+
offsets[entry] = position + blocks.bytesize
|
|
55
|
+
blocks << block(bytes, max)
|
|
56
|
+
end
|
|
57
|
+
header = SIGNATURE.b + [0, half_tracks, max].pack("CCv")
|
|
58
|
+
File.binwrite(path, header + offsets.pack("V*") + zones.pack("V*") + blocks)
|
|
59
|
+
new(path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.block(bytes, size)
|
|
63
|
+
[bytes.length].pack("v") + bytes.pack("C*").ljust(size, "\x00".b)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :half_tracks, :max_track_size
|
|
67
|
+
|
|
68
|
+
def initialize(path, read_only: false)
|
|
69
|
+
@path = path
|
|
70
|
+
@read_only = read_only
|
|
71
|
+
@data = File.binread(path)
|
|
72
|
+
parse
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether the image was opened write-protected.
|
|
76
|
+
def read_only? = @read_only
|
|
77
|
+
|
|
78
|
+
# Whether the host file takes writes.
|
|
79
|
+
def writable? = !read_only? && File.writable?(@path)
|
|
80
|
+
|
|
81
|
+
# The track at table entry +entry+ (0 for track 1) as [bytes, zone],
|
|
82
|
+
# or nil where it has no data. A speed-mapped track's zone is the one
|
|
83
|
+
# most of its bytes were written in.
|
|
84
|
+
def track(entry)
|
|
85
|
+
offset = @offsets[entry].to_i
|
|
86
|
+
return if offset.zero?
|
|
87
|
+
|
|
88
|
+
length = block_length(offset)
|
|
89
|
+
return if length.zero?
|
|
90
|
+
|
|
91
|
+
speeds = mapped_speeds(entry, length)
|
|
92
|
+
[@data.byteslice(offset + 2, length).bytes, speeds ? majority(speeds) : @speeds[entry]]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Each byte's zone of the track at table entry +entry+, where its
|
|
96
|
+
# speed map changes along it, and nil otherwise.
|
|
97
|
+
def speeds(entry)
|
|
98
|
+
offset = @offsets[entry].to_i
|
|
99
|
+
return if offset.zero?
|
|
100
|
+
|
|
101
|
+
speeds = mapped_speeds(entry, block_length(offset))
|
|
102
|
+
speeds unless speeds.nil? || speeds.uniq.one?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Stores tracks the drive wrote, {entry => [bytes, zone]}, in one
|
|
106
|
+
# write to the host file. Each takes its zone as its speed entry.
|
|
107
|
+
def store_tracks(tracks)
|
|
108
|
+
raise WriteError, WriteError::WRITE_PROTECT_ON unless writable?
|
|
109
|
+
|
|
110
|
+
data = @data.dup
|
|
111
|
+
tracks.each do |entry, (bytes, zone)|
|
|
112
|
+
next unless entry < @half_tracks
|
|
113
|
+
|
|
114
|
+
store_track(data, entry, bytes, zone)
|
|
115
|
+
end
|
|
116
|
+
File.binwrite(@path, data)
|
|
117
|
+
@data = data
|
|
118
|
+
parse
|
|
119
|
+
rescue SystemCallError
|
|
120
|
+
parse
|
|
121
|
+
raise WriteError, WriteError::WRITE_PROTECT_ON
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def parse
|
|
127
|
+
raise FormatError, "Missing G64 signature" unless @data.start_with?(SIGNATURE)
|
|
128
|
+
raise FormatError, "G64 header is cut short" if @data.bytesize < HEADER_SIZE
|
|
129
|
+
|
|
130
|
+
version, @half_tracks, @max_track_size = @data.byteslice(8, 4).unpack("CCv")
|
|
131
|
+
raise FormatError, "Unsupported G64 version #{version}" unless version.zero?
|
|
132
|
+
|
|
133
|
+
tables = @data.byteslice(HEADER_SIZE, @half_tracks * 8).to_s
|
|
134
|
+
raise FormatError, "G64 track tables are cut short" if tables.bytesize < @half_tracks * 8
|
|
135
|
+
|
|
136
|
+
@offsets = tables.byteslice(0, @half_tracks * 4).unpack("V*")
|
|
137
|
+
@speeds = tables.byteslice(@half_tracks * 4, @half_tracks * 4).unpack("V*")
|
|
138
|
+
@half_tracks.times { |entry| check_block(entry) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def tables_end = HEADER_SIZE + (@half_tracks * 8)
|
|
142
|
+
|
|
143
|
+
def block_length(offset) = @data.byteslice(offset, 2).unpack1("v")
|
|
144
|
+
|
|
145
|
+
def check_block(entry)
|
|
146
|
+
offset = @offsets[entry]
|
|
147
|
+
return if offset.zero?
|
|
148
|
+
|
|
149
|
+
problem = misplaced(offset, 2) || misplaced(offset, 2 + block_length(offset))
|
|
150
|
+
raise FormatError, "G64 track entry #{entry} #{problem}" if problem
|
|
151
|
+
|
|
152
|
+
check_speed_map(entry, block_length(offset))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def check_speed_map(entry, length)
|
|
156
|
+
speed = @speeds[entry]
|
|
157
|
+
return if speed <= 3
|
|
158
|
+
|
|
159
|
+
problem = misplaced(speed, (length + 3) / 4)
|
|
160
|
+
raise FormatError, "G64 speed map of track entry #{entry} #{problem}" if problem
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# What's wrong with +size+ bytes at +offset+, or nil where they lie
|
|
164
|
+
# past the tables and inside the file.
|
|
165
|
+
def misplaced(offset, size)
|
|
166
|
+
if offset < tables_end then "points into the header or its tables"
|
|
167
|
+
elsif offset + size > @data.bytesize then "runs past the end of the image"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Each byte's zone from the track's speed map, or nil where its speed
|
|
172
|
+
# entry is a zone.
|
|
173
|
+
def mapped_speeds(entry, length)
|
|
174
|
+
speed = @speeds[entry]
|
|
175
|
+
return if speed <= 3
|
|
176
|
+
|
|
177
|
+
map = @data.byteslice(speed, (length + 3) / 4).to_s.bytes
|
|
178
|
+
Array.new(length) { |index| (map[index >> 2] >> (6 - ((index & 3) * 2))) & 3 }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The zone most of the bytes were written in.
|
|
182
|
+
def majority(speeds)
|
|
183
|
+
counts = [0, 0, 0, 0]
|
|
184
|
+
speeds.each { |zone| counts[zone] += 1 }
|
|
185
|
+
counts.index(counts.max)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def store_track(data, entry, bytes, zone)
|
|
189
|
+
offset = @offsets[entry]
|
|
190
|
+
if offset.zero? || bytes.length > capacity(offset)
|
|
191
|
+
offset = data.bytesize
|
|
192
|
+
grow(data, bytes.length)
|
|
193
|
+
data << self.class.block(bytes, @max_track_size)
|
|
194
|
+
else
|
|
195
|
+
data[offset, bytes.length + 2] = [bytes.length].pack("v") + bytes.pack("C*")
|
|
196
|
+
end
|
|
197
|
+
set_entry(data, entry, offset, zone)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The most bytes the block at +offset+ holds without running into
|
|
201
|
+
# the next block or speed map, or off the end of the file.
|
|
202
|
+
def capacity(offset)
|
|
203
|
+
ends = (@offsets + @speeds.select { |speed| speed > 3 }).select { |start| start > offset }
|
|
204
|
+
ends.push(@data.bytesize).min - offset - 2
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def grow(data, length)
|
|
208
|
+
return if length <= @max_track_size
|
|
209
|
+
|
|
210
|
+
@max_track_size = length
|
|
211
|
+
data[10, 2] = [length].pack("v")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def set_entry(data, entry, offset, zone)
|
|
215
|
+
@offsets[entry] = offset
|
|
216
|
+
@speeds[entry] = zone
|
|
217
|
+
data[HEADER_SIZE + (entry * 4), 4] = [offset].pack("V")
|
|
218
|
+
data[HEADER_SIZE + ((@half_tracks + entry) * 4), 4] = [zone].pack("V")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -8,6 +8,7 @@ module Badline
|
|
|
8
8
|
# can't be read is left out, and a failed write reports false.
|
|
9
9
|
class HostDirectory
|
|
10
10
|
NO_SYNC = 21
|
|
11
|
+
ID = "DIR".bytes.freeze
|
|
11
12
|
|
|
12
13
|
def initialize(path)
|
|
13
14
|
@path = path
|
|
@@ -39,8 +40,27 @@ module Badline
|
|
|
39
40
|
false
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# The directory as LOAD"$" lists it, under its host name: every file
|
|
44
|
+
# as a PRG, of the blocks it would take on a disk. A host directory
|
|
45
|
+
# counts no free blocks.
|
|
46
|
+
def directory
|
|
47
|
+
Listing.new(name: Listing.encode(File.basename(@path)), id: ID,
|
|
48
|
+
entries: entries.map { |entry| listing_entry(entry) }, blocks_free: 0)
|
|
49
|
+
end
|
|
50
|
+
|
|
42
51
|
private
|
|
43
52
|
|
|
53
|
+
def listing_entry(entry)
|
|
54
|
+
entry[:listed] || Listing.program(entry[:name], file_length(entry[:file]))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def file_length(file)
|
|
58
|
+
size = File.size(File.join(@path, file))
|
|
59
|
+
File.extname(file).casecmp?(".p00") ? size - P00::HEADER_SIZE : size
|
|
60
|
+
rescue SystemCallError
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
44
64
|
def find(name)
|
|
45
65
|
pattern = Storage.matcher(name)
|
|
46
66
|
entries.find { |e| pattern.match?(e[:name]) }
|
|
@@ -72,7 +92,7 @@ module Badline
|
|
|
72
92
|
|
|
73
93
|
def t64_entries(file)
|
|
74
94
|
archive = T64.new(File.join(@path, file))
|
|
75
|
-
archive.names.map { |name| { name:, archive: } }
|
|
95
|
+
archive.names.zip(archive.listing_entries).map { |name, listed| { name:, archive:, listed: } }
|
|
76
96
|
rescue T64::FormatError, SystemCallError
|
|
77
97
|
nil
|
|
78
98
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
# A directory as a 1541 sends it for LOAD"$",8: a BASIC program loaded
|
|
6
|
+
# at $0401. The header line is line 0, the disk's name and ID in
|
|
7
|
+
# reverse video, the name quoted. Each file is a line numbered by its
|
|
8
|
+
# length in blocks, with the name quoted and padded to 16 characters,
|
|
9
|
+
# then the type, marked `*` when the file was never closed and `<` when
|
|
10
|
+
# it is locked. The last line numbers the free blocks. Lines are
|
|
11
|
+
# padded with spaces to the drive's fixed widths, 27 characters for a
|
|
12
|
+
# file and 25 for the others, and shifted spaces print as plain ones.
|
|
13
|
+
#
|
|
14
|
+
# Names come as the bytes on the disk, padded with shifted spaces to 16.
|
|
15
|
+
# A file's name ends at its first shifted space, where the closing
|
|
16
|
+
# quote goes, and whatever follows it in the entry shows after the
|
|
17
|
+
# quote, as it does on a 1541.
|
|
18
|
+
class Listing
|
|
19
|
+
LOAD_ADDRESS = 0x0401
|
|
20
|
+
NAME_LENGTH = 16
|
|
21
|
+
NAME_PADDING = 0xa0
|
|
22
|
+
SPACE = 0x20
|
|
23
|
+
QUOTE = 0x22
|
|
24
|
+
REVERSE_ON = 0x12
|
|
25
|
+
FILE_WIDTH = 27
|
|
26
|
+
WIDTH = 25
|
|
27
|
+
TYPES = %w[DEL SEQ PRG USR REL CBM].freeze
|
|
28
|
+
PRG = 2
|
|
29
|
+
|
|
30
|
+
# One directory entry: its name's bytes, its type code (0 DEL to
|
|
31
|
+
# 4 REL, and the 1581's 5 CBM), its length in blocks, and whether it
|
|
32
|
+
# was closed and is locked.
|
|
33
|
+
Entry = Data.define(:name, :type, :blocks, :closed, :locked)
|
|
34
|
+
|
|
35
|
+
attr_reader :name, :id, :entries, :blocks_free
|
|
36
|
+
|
|
37
|
+
def initialize(name:, id:, entries:, blocks_free:)
|
|
38
|
+
@name = name
|
|
39
|
+
@id = id
|
|
40
|
+
@entries = entries
|
|
41
|
+
@blocks_free = blocks_free
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Name bytes padded with shifted spaces, from ASCII text in upper
|
|
45
|
+
# case, as a host file's name is listed.
|
|
46
|
+
def self.encode(text)
|
|
47
|
+
bytes = text.upcase.bytes.first(NAME_LENGTH)
|
|
48
|
+
bytes + Array.new(NAME_LENGTH - bytes.length, NAME_PADDING)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A host file listed as a closed PRG, named in ASCII, of the blocks
|
|
52
|
+
# its bytes would take on a disk, 254 to a block.
|
|
53
|
+
def self.program(name, length)
|
|
54
|
+
Entry.new(name: encode(name), type: PRG, blocks: (length + 253) / 254, closed: true, locked: false)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The file's bytes, load address first. A `$:NAME` or `$0:A*,B*`
|
|
58
|
+
# pattern lists only the files that match one of its names.
|
|
59
|
+
def bytes(request = "$")
|
|
60
|
+
lines = [[0, padded(header, WIDTH)]]
|
|
61
|
+
lines.concat(listed(request).map { |entry| [entry.blocks, file_line(entry)] })
|
|
62
|
+
lines << [@blocks_free, padded("BLOCKS FREE.".bytes, WIDTH)]
|
|
63
|
+
program(lines)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def listed(request)
|
|
69
|
+
patterns = request.sub(/\A\$\d*/, "")
|
|
70
|
+
return @entries unless patterns.start_with?(":")
|
|
71
|
+
|
|
72
|
+
matchers = patterns[1..].split(",").map { |pattern| Storage.matcher(pattern) }
|
|
73
|
+
@entries.select do |entry|
|
|
74
|
+
name = Storage.ascii(entry.name.take_while { |byte| byte != NAME_PADDING }).downcase
|
|
75
|
+
matchers.any? { |matcher| matcher.match?(name) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def header
|
|
80
|
+
[REVERSE_ON, QUOTE, *spaced(@name.first(NAME_LENGTH)), QUOTE, SPACE, *spaced(@id.first(5))]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A block count under 10 or 100 takes more spaces before the name, so
|
|
84
|
+
# the names line up.
|
|
85
|
+
def file_line(entry)
|
|
86
|
+
indent = 1 + (entry.blocks < 100 ? 1 : 0) + (entry.blocks < 10 ? 1 : 0)
|
|
87
|
+
line = Array.new(indent, SPACE) + quoted(entry.name) + [entry.closed ? SPACE : "*".ord]
|
|
88
|
+
line.concat(TYPES.fetch(entry.type, "???").bytes)
|
|
89
|
+
line << (entry.locked ? "<".ord : SPACE)
|
|
90
|
+
padded(line, FILE_WIDTH)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The closing quote takes the first shifted space's place, or follows
|
|
94
|
+
# a name that fills all 16.
|
|
95
|
+
def quoted(name)
|
|
96
|
+
name = name.first(NAME_LENGTH)
|
|
97
|
+
name += Array.new(NAME_LENGTH - name.length, NAME_PADDING)
|
|
98
|
+
cut = name.index(NAME_PADDING)
|
|
99
|
+
name = cut ? [*name[0, cut], QUOTE, *name[(cut + 1)..], SPACE] : [*name, QUOTE]
|
|
100
|
+
[QUOTE, *spaced(name)]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def padded(line, width) = line + Array.new([width - line.length, 0].max, SPACE)
|
|
104
|
+
|
|
105
|
+
def spaced(bytes) = bytes.map { |byte| byte == NAME_PADDING ? SPACE : byte }
|
|
106
|
+
|
|
107
|
+
def program(lines)
|
|
108
|
+
address = LOAD_ADDRESS
|
|
109
|
+
bytes = [address & 0xff, address >> 8]
|
|
110
|
+
lines.each do |number, text|
|
|
111
|
+
address += text.length + 5
|
|
112
|
+
bytes.push(address & 0xff, address >> 8, number & 0xff, (number >> 8) & 0xff, *text, 0)
|
|
113
|
+
end
|
|
114
|
+
bytes.push(0, 0)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/badline/storage/t64.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Badline
|
|
|
9
9
|
HEADER_SIZE = 0x40
|
|
10
10
|
ENTRY_SIZE = 32
|
|
11
11
|
NORMAL_FILE = 1
|
|
12
|
+
TAPE_NAME = 0x28
|
|
13
|
+
ID = "T64".bytes.freeze
|
|
12
14
|
|
|
13
15
|
def initialize(path)
|
|
14
16
|
@bytes = File.binread(path).bytes
|
|
@@ -29,6 +31,18 @@ module Badline
|
|
|
29
31
|
entries.map { |e| e[:name] }
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# The archive as LOAD"$" lists it: the tape's name, and each file as
|
|
35
|
+
# a PRG of the blocks it would take on a disk, load address and all.
|
|
36
|
+
# An archive has no free blocks.
|
|
37
|
+
def directory
|
|
38
|
+
Listing.new(name: Listing.encode(decode_name(@bytes[TAPE_NAME, Listing::NAME_LENGTH].to_a)),
|
|
39
|
+
id: ID, entries: listing_entries, blocks_free: 0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def listing_entries
|
|
43
|
+
entries.map { |e| Listing.program(e[:name], e[:length] + 2) }
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
private
|
|
33
47
|
|
|
34
48
|
def entries
|
data/lib/badline/storage.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "badline/storage/listing"
|
|
3
4
|
require "badline/storage/p00"
|
|
4
5
|
require "badline/storage/host_directory"
|
|
5
6
|
require "badline/storage/disk_image"
|
|
6
7
|
require "badline/storage/d64_image"
|
|
7
8
|
require "badline/storage/d71_image"
|
|
8
9
|
require "badline/storage/d81_image"
|
|
10
|
+
require "badline/storage/g64_image"
|
|
9
11
|
require "badline/storage/t64"
|
|
10
12
|
require "badline/storage/tap"
|
|
11
13
|
require "badline/storage/crt_file"
|
data/lib/badline/traps.rb
CHANGED
data/lib/badline/version.rb
CHANGED
|
@@ -55,6 +55,9 @@ module Badline
|
|
|
55
55
|
|
|
56
56
|
def active_c1_edge?(high) = high == @control.anybits?(0x01)
|
|
57
57
|
|
|
58
|
+
# Everything the lines hold, for comparing them at two points.
|
|
59
|
+
def state = [@control, @c1_high, @c2_high, @handshake_low, @pulse, @c2_output]
|
|
60
|
+
|
|
58
61
|
# A new C1 level. An active edge sets the flag and ends a handshake.
|
|
59
62
|
def c1=(high)
|
|
60
63
|
return if high == @c1_high
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
class VIA
|
|
5
|
+
# Running a VIA a stretch of cycles at once while nothing but its
|
|
6
|
+
# counters would change, for Drive1541::Idle.
|
|
7
|
+
module FastForward
|
|
8
|
+
# quiet_cycles for a VIA that will never set a flag of its own.
|
|
9
|
+
QUIET = 1 << 40
|
|
10
|
+
|
|
11
|
+
# How many cycles the VIA can run with nothing but its counters
|
|
12
|
+
# changing: no flag set, no pulse on a control line and no bit
|
|
13
|
+
# shifted. See Drive1541::Idle.
|
|
14
|
+
def quiet_cycles
|
|
15
|
+
return 0 if @sr_uses_t2 || @shift_register.clocking? || @ca.pulsing? || @cb.pulsing?
|
|
16
|
+
|
|
17
|
+
[@t1.quiet_cycles, @t2.quiet_cycles].min
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs +cycles+ cycles at once, as many as quiet_cycles allows, which
|
|
21
|
+
# moves only the counters.
|
|
22
|
+
def fast_forward(cycles)
|
|
23
|
+
return if cycles.zero?
|
|
24
|
+
|
|
25
|
+
@t1.fast_forward(cycles)
|
|
26
|
+
@t2.fast_forward(cycles)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Everything the VIA holds but its counters, for comparing it at two
|
|
30
|
+
# points. Timer 1's PB7 level counts only while ACR bit 7 puts it on
|
|
31
|
+
# the pin: nothing reads it otherwise.
|
|
32
|
+
def idle_state
|
|
33
|
+
[@ora, @orb, @ddra, @ddrb, @latch_a, @latch_b, @acr, @pcr, @pb6_high, @ifr.flags, @ifr.enable,
|
|
34
|
+
@acr.anybits?(0x80) && @t1.pb7, *@t1.state, *@t2.state, *@ca.state, *@cb.state, *@shift_register.state]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -35,8 +35,6 @@ module Badline
|
|
|
35
35
|
OUT_PHI2 = 6
|
|
36
36
|
OUT_EXTERNAL = 7
|
|
37
37
|
|
|
38
|
-
BITS = 8
|
|
39
|
-
|
|
40
38
|
attr_reader :mode, :data
|
|
41
39
|
attr_accessor :cb2_input
|
|
42
40
|
|
|
@@ -62,9 +60,16 @@ module Badline
|
|
|
62
60
|
# Every read or write of the data register (re)starts the count of
|
|
63
61
|
# eight bits.
|
|
64
62
|
def access!
|
|
65
|
-
@bits =
|
|
63
|
+
@bits = 8
|
|
66
64
|
end
|
|
67
65
|
|
|
66
|
+
# Whether the register is counting bits off its own clock, which
|
|
67
|
+
# changes it from cycle to cycle.
|
|
68
|
+
def clocking? = @bits.nonzero? && internal_clock?
|
|
69
|
+
|
|
70
|
+
# Everything the register holds, for comparing it at two points.
|
|
71
|
+
def state = [@mode, @data, @bits, @clock, @cb2, @cb2_input]
|
|
72
|
+
|
|
68
73
|
# The CB1 level the register drives, or nil when CB1 is an input.
|
|
69
74
|
def cb1_output
|
|
70
75
|
internal_clock? ? @clock : nil
|