badline 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/hooks/session-start.sh +57 -0
- data/.claude/settings.json +14 -0
- data/CHANGELOG.md +107 -0
- data/CLAUDE.md +51 -16
- data/CONTRIBUTING.md +95 -3
- data/README.md +119 -51
- data/SECURITY.md +9 -0
- data/doc/pinned-behaviour.md +293 -39
- data/exe/badline-ruby +59 -0
- data/lib/badline/address_bus.rb +28 -14
- data/lib/badline/addressable.rb +1 -1
- data/lib/badline/audio/aiff.rb +8 -3
- data/lib/badline/audio/bare_player.rb +20 -3
- data/lib/badline/audio/cli.rb +17 -12
- data/lib/badline/audio/console.rb +12 -58
- data/lib/badline/audio/machine_player.rb +9 -6
- data/lib/badline/audio/playback.rb +3 -0
- data/lib/badline/audio/renderer.rb +3 -3
- data/lib/badline/audio/sdl_sink.rb +20 -52
- data/lib/badline/audio/stream.rb +117 -0
- data/lib/badline/audio/terminal.rb +90 -0
- data/lib/badline/audio.rb +3 -2
- data/lib/badline/cartridge/easy_flash.rb +6 -2
- data/lib/badline/cartridge/epyx_fastload.rb +1 -1
- data/lib/badline/cartridge/geo_ram.rb +63 -0
- 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 +9 -0
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia/interrupt_register.rb +34 -10
- data/lib/badline/cia.rb +19 -10
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +34 -14
- data/lib/badline/cpu.rb +2 -2
- data/lib/badline/gui/application.rb +70 -26
- data/lib/badline/gui/gamepads.rb +22 -22
- data/lib/badline/gui/joy_map.rb +1 -1
- data/lib/badline/gui/key_map.rb +6 -1
- data/lib/badline/gui/pane.rb +7 -6
- data/lib/badline/gui/screen_pane.rb +9 -13
- data/lib/badline/gui/window.rb +35 -20
- data/lib/badline/gui.rb +1 -1
- data/lib/badline/input/mouse1351.rb +21 -6
- data/lib/badline/input/paddles.rb +11 -6
- data/lib/badline/kernal_trap/channel.rb +102 -7
- data/lib/badline/kernal_trap/drive/block_commands.rb +108 -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/status.rb +3 -0
- data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
- data/lib/badline/kernal_trap/drive/writes.rb +92 -0
- data/lib/badline/kernal_trap/drive.rb +109 -131
- data/lib/badline/kernal_trap/file.rb +6 -14
- data/lib/badline/kernal_trap/load.rb +6 -0
- data/lib/badline/kernal_trap/save.rb +13 -7
- data/lib/badline/keyboard.rb +19 -3
- data/lib/badline/media.rb +44 -13
- data/lib/badline/memory.rb +2 -2
- data/lib/badline/options.rb +209 -0
- data/lib/badline/ram_expansion/banking.rb +30 -0
- data/lib/badline/ram_expansion/plus256k.rb +38 -0
- data/lib/badline/ram_expansion/plus60k.rb +31 -0
- data/lib/badline/ram_expansion/unexpanded.rb +22 -0
- data/lib/badline/ram_expansion.rb +21 -0
- data/lib/badline/region.rb +51 -0
- data/lib/badline/rom.rb +14 -5
- data/lib/badline/sdl/events.rb +47 -0
- data/lib/badline/sdl/functions.rb +73 -0
- data/lib/badline/sdl.rb +67 -0
- 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/noise_writeback.rb +32 -14
- data/lib/badline/sid/waveform.rb +8 -8
- data/lib/badline/sid.rb +19 -93
- data/lib/badline/storage/d64_image.rb +10 -0
- data/lib/badline/storage/d71_image.rb +12 -0
- data/lib/badline/storage/d81_image.rb +9 -0
- data/lib/badline/storage/disk_image/bam.rb +111 -0
- data/lib/badline/storage/disk_image/directory.rb +79 -0
- data/lib/badline/storage/disk_image/writing.rb +110 -0
- data/lib/badline/storage/disk_image.rb +49 -4
- data/lib/badline/storage/host_directory.rb +17 -4
- data/lib/badline/storage/sid_file.rb +36 -2
- data/lib/badline/storage.rb +18 -1
- data/lib/badline/time_of_day.rb +11 -2
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +104 -0
- data/lib/badline/via/interrupt_register.rb +58 -0
- data/lib/badline/via/shift_register.rb +135 -0
- data/lib/badline/via/timer1.rb +82 -0
- data/lib/badline/via/timer2.rb +84 -0
- data/lib/badline/via.rb +262 -0
- data/lib/badline/vic/bank.rb +4 -3
- data/lib/badline/vic/color_patches.rb +6 -3
- data/lib/badline/vic/display_state.rb +4 -0
- data/lib/badline/vic/graphics_shifter.rb +49 -8
- data/lib/badline/vic/registers.rb +19 -27
- data/lib/badline/vic/sequencer.rb +18 -18
- data/lib/badline/vic/sequencer_output.rb +4 -4
- data/lib/badline/vic/sprite/internal_bus.rb +4 -0
- data/lib/badline/vic/sprite/shifter.rb +25 -3
- data/lib/badline/vic/sprite.rb +5 -4
- data/lib/badline/vic/sprites.rb +8 -3
- data/lib/badline/vic.rb +98 -46
- data/lib/badline.rb +5 -0
- data/packaging/homebrew/badline.rb +34 -0
- metadata +37 -20
- data/exe/badline +0 -75
- data/exe/badline-sid +0 -34
- data/lib/badline/audio/options.rb +0 -137
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
class DiskImage
|
|
6
|
+
# The block availability map: a free count and a bitmap for each
|
|
7
|
+
# track, with a set bit for each free sector. Each format says where
|
|
8
|
+
# a track's count and bitmap sit, which tracks the map covers and
|
|
9
|
+
# which of them hold the directory. Files are laid out as the DOS
|
|
10
|
+
# does, on the tracks nearest the directory first, below it before
|
|
11
|
+
# above, with the format's sector interleave within a track.
|
|
12
|
+
module Bam
|
|
13
|
+
# Whether the BAM has an entry for the block.
|
|
14
|
+
def bam_block?(track, sector)
|
|
15
|
+
bam_tracks.cover?(track) && sector.between?(0, sectors_in(track) - 1) &&
|
|
16
|
+
block?(*bam_bitmap(track).first(2)) && block?(*bam_count(track).first(2))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Whether the BAM marks the block free. A block it has no entry for
|
|
20
|
+
# never is.
|
|
21
|
+
def block_free?(track, sector)
|
|
22
|
+
bam_block?(track, sector) && @bytes[bitmap_offset(track) + (sector / 8)][sector % 8] == 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The first free block past the one given, on its track and then
|
|
26
|
+
# the tracks above it, for B-A to report when the block is taken.
|
|
27
|
+
# Empty when there is none.
|
|
28
|
+
def next_free_block(track, sector)
|
|
29
|
+
bam_tracks.each do |candidate|
|
|
30
|
+
next if candidate < track || reserved_tracks.include?(candidate)
|
|
31
|
+
|
|
32
|
+
first = candidate == track ? sector + 1 : 0
|
|
33
|
+
free = (first...sectors_in(candidate)).find { |s| block_free?(candidate, s) }
|
|
34
|
+
return [candidate, free] if free
|
|
35
|
+
end
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def bitmap_offset(track)
|
|
42
|
+
block_track, block_sector, offset = bam_bitmap(track)
|
|
43
|
+
sector_offset(block_track, block_sector) + offset
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def count_offset(track)
|
|
47
|
+
block_track, block_sector, offset = bam_count(track)
|
|
48
|
+
sector_offset(block_track, block_sector) + offset
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Marks the block free or in use, and moves the track's free count
|
|
52
|
+
# with it. A block the BAM doesn't cover is left alone.
|
|
53
|
+
def mark(track, sector, free)
|
|
54
|
+
return if !bam_block?(track, sector) || block_free?(track, sector) == free
|
|
55
|
+
|
|
56
|
+
byte = bitmap_offset(track) + (sector / 8)
|
|
57
|
+
bit = 1 << (sector % 8)
|
|
58
|
+
@bytes[byte] = free ? @bytes[byte] | bit : @bytes[byte] & ~bit
|
|
59
|
+
@bytes[count_offset(track)] = (@bytes[count_offset(track)] + (free ? 1 : -1)) & 0xff
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Takes blocks for a chain, each placed after the one before it.
|
|
63
|
+
# Raises DISK FULL when the disk runs out.
|
|
64
|
+
def allocate_chain(count)
|
|
65
|
+
block = nil
|
|
66
|
+
Array.new(count) do
|
|
67
|
+
block = next_block(*block) || raise(WriteError, WriteError::DISK_FULL)
|
|
68
|
+
mark(*block, false)
|
|
69
|
+
block
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A chain's first block goes on the free track nearest the
|
|
74
|
+
# directory. Each block after it goes the interleave further round
|
|
75
|
+
# the same track, or on the next track out once that one is full.
|
|
76
|
+
def next_block(track = nil, sector = nil)
|
|
77
|
+
candidates = track ? [track, *tracks_after(track)] : allocation_order
|
|
78
|
+
candidates.each do |candidate|
|
|
79
|
+
free = free_sector(candidate, candidate == track ? sector + interleave : 0)
|
|
80
|
+
return [candidate, free] if free
|
|
81
|
+
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def free_sector(track, first)
|
|
86
|
+
count = sectors_in(track)
|
|
87
|
+
(0...count).map { |i| (first + i) % count }.find { |s| block_free?(track, s) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The tracks a file can use, nearest the directory first, the one
|
|
91
|
+
# below before the one above.
|
|
92
|
+
def allocation_order
|
|
93
|
+
(bam_tracks.to_a - reserved_tracks).sort_by do |track|
|
|
94
|
+
[(track - directory_track).abs, track > directory_track ? 1 : 0]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The tracks further from the directory on the same side, then the
|
|
99
|
+
# rest.
|
|
100
|
+
def tracks_after(track)
|
|
101
|
+
distance = (track - directory_track).abs
|
|
102
|
+
below = track < directory_track
|
|
103
|
+
further, rest = (allocation_order - [track]).partition do |candidate|
|
|
104
|
+
(candidate < directory_track) == below && (candidate - directory_track).abs > distance
|
|
105
|
+
end
|
|
106
|
+
further + rest
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
class DiskImage
|
|
6
|
+
# Directory entries as writes make them: a closed file's type, its
|
|
7
|
+
# first block and its length in blocks, under a name padded with
|
|
8
|
+
# shifted spaces. The directory grows a block at a time on its track.
|
|
9
|
+
module Directory
|
|
10
|
+
CLOSED = 0x80
|
|
11
|
+
NAME_LENGTH = 16
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def entry_named(name)
|
|
16
|
+
wanted = decode_name(encode_name(name))
|
|
17
|
+
entries.find { |entry| entry[:name] == wanted }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def encode_name(name)
|
|
21
|
+
bytes = name.upcase.bytes.first(NAME_LENGTH)
|
|
22
|
+
bytes + Array.new(NAME_LENGTH - bytes.length, NAME_PADDING)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The new chain is written before the old one is freed, so a
|
|
26
|
+
# replace that runs out of room leaves the old file.
|
|
27
|
+
def rewrite(entry, bytes, type)
|
|
28
|
+
blocks = write_chain(bytes)
|
|
29
|
+
free_chain(entry)
|
|
30
|
+
write_entry(entry[:offset], type, blocks)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_file(name, bytes, type)
|
|
34
|
+
blocks = write_chain(bytes)
|
|
35
|
+
offset = free_entry_offset
|
|
36
|
+
@bytes[offset + 5, NAME_LENGTH] = encode_name(name)
|
|
37
|
+
write_entry(offset, type, blocks)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The slot's first two bytes link the directory block, so they
|
|
41
|
+
# stay. The name stays too, and what follows it up to the block
|
|
42
|
+
# count is cleared.
|
|
43
|
+
def write_entry(offset, type, blocks)
|
|
44
|
+
@bytes[offset + 2] = CLOSED | FILETYPES.fetch(type)
|
|
45
|
+
@bytes[offset + 3, 2] = blocks.first
|
|
46
|
+
@bytes[offset + 21, 9] = Array.new(9, 0)
|
|
47
|
+
@bytes[offset + 30, 2] = [blocks.length & 0xff, blocks.length >> 8]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The first free slot in the directory, which grows by a block on
|
|
51
|
+
# the directory track when every slot is taken.
|
|
52
|
+
def free_entry_offset
|
|
53
|
+
last = nil
|
|
54
|
+
each_sector(directory_track, directory_sector) do |data, track, sector|
|
|
55
|
+
slot = (0...ENTRIES_PER_SECTOR).find { |i| data[(i * ENTRY_SIZE) + 2].zero? }
|
|
56
|
+
return sector_offset(track, sector) + (slot * ENTRY_SIZE) if slot
|
|
57
|
+
|
|
58
|
+
last = [track, sector]
|
|
59
|
+
end
|
|
60
|
+
sector_offset(*extend_directory(*last))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A new directory block goes the directory interleave on from the
|
|
64
|
+
# last one. A full directory track fails as DISK FULL.
|
|
65
|
+
def extend_directory(track, sector)
|
|
66
|
+
count = sectors_in(directory_track)
|
|
67
|
+
free = (0...count).map { |i| (sector + directory_interleave + i) % count }
|
|
68
|
+
.find { |s| block_free?(directory_track, s) }
|
|
69
|
+
raise WriteError, WriteError::DISK_FULL unless free
|
|
70
|
+
|
|
71
|
+
mark(directory_track, free, false)
|
|
72
|
+
@bytes[sector_offset(track, sector), 2] = [directory_track, free]
|
|
73
|
+
put_sector(directory_track, free, [0, 0xff])
|
|
74
|
+
[directory_track, free]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
class DiskImage
|
|
6
|
+
# Writes to the image: files with their chains, directory entries and
|
|
7
|
+
# BAM, raw blocks, and BAM changes. Each write goes back to the host
|
|
8
|
+
# file at once. A write the disk refuses raises WriteError and leaves
|
|
9
|
+
# the image as it was, and so does one to a read-only image or one
|
|
10
|
+
# the host can't store, which fail as a write-protected disk does.
|
|
11
|
+
module Writing
|
|
12
|
+
# Whether the disk takes writes: the image wasn't opened read-only
|
|
13
|
+
# and the host file takes them.
|
|
14
|
+
def writable? = !read_only? && ::File.writable?(@path)
|
|
15
|
+
|
|
16
|
+
# Writes a new file, or with `replace` writes over the one of the
|
|
17
|
+
# same name in its directory entry. A name already on the disk
|
|
18
|
+
# fails as FILE EXISTS otherwise.
|
|
19
|
+
def write_file(name, bytes, type: :prg, replace: false)
|
|
20
|
+
existing = entry_named(name)
|
|
21
|
+
raise WriteError, WriteError::FILE_EXISTS if existing && !replace
|
|
22
|
+
|
|
23
|
+
update_image do
|
|
24
|
+
existing ? rewrite(existing, bytes, type) : add_file(name, bytes, type)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Adds the bytes to the end of a file, which keeps its type and its
|
|
29
|
+
# directory entry.
|
|
30
|
+
def append_file(name, bytes, type: nil)
|
|
31
|
+
entry = find_entry(name, type)
|
|
32
|
+
raise WriteError, WriteError::FILE_NOT_FOUND unless entry
|
|
33
|
+
|
|
34
|
+
update_image { rewrite(entry, read_chain(entry[:track], entry[:sector]) + bytes, entry[:type]) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Scratches the files matching the pattern, of any type, except
|
|
38
|
+
# locked ones, and frees their blocks. Returns how many it
|
|
39
|
+
# scratched.
|
|
40
|
+
def scratch(name)
|
|
41
|
+
pattern = Storage.matcher(name)
|
|
42
|
+
files = entries.select { |entry| !entry[:locked] && pattern.match?(entry[:name]) }
|
|
43
|
+
return 0 if files.empty?
|
|
44
|
+
|
|
45
|
+
update_image do
|
|
46
|
+
files.each do |entry|
|
|
47
|
+
free_chain(entry)
|
|
48
|
+
@bytes[entry[:offset] + 2] = 0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
files.length
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raw block access for the DOS `U2` command. A block that the error
|
|
55
|
+
# table marks bad reads cleanly once written.
|
|
56
|
+
def write_block(track, sector, data)
|
|
57
|
+
update_image { put_sector(track, sector, data) } if block?(track, sector)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Marks a block in use, as B-A does.
|
|
61
|
+
def allocate_block(track, sector) = update_image { mark(track, sector, false) }
|
|
62
|
+
|
|
63
|
+
# Marks a block free, as B-F does.
|
|
64
|
+
def free_block(track, sector) = update_image { mark(track, sector, true) }
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def update_image
|
|
69
|
+
raise WriteError, WriteError::WRITE_PROTECT_ON unless writable?
|
|
70
|
+
|
|
71
|
+
saved = [@bytes.dup, @errors&.dup]
|
|
72
|
+
begin
|
|
73
|
+
yield
|
|
74
|
+
::File.binwrite(@path, (@bytes + @errors.to_a).pack("C*"))
|
|
75
|
+
rescue WriteError, SystemCallError => e
|
|
76
|
+
@bytes, @errors = saved
|
|
77
|
+
raise e.is_a?(WriteError) ? e : WriteError.new(WriteError::WRITE_PROTECT_ON)
|
|
78
|
+
ensure
|
|
79
|
+
@entries = nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Every block links the next, and the last one holds the index of
|
|
84
|
+
# its last byte in place of a link. An empty file still takes one.
|
|
85
|
+
def write_chain(bytes)
|
|
86
|
+
chunks = bytes.each_slice(SECTOR_SIZE - 2).to_a
|
|
87
|
+
chunks = [[]] if chunks.empty?
|
|
88
|
+
blocks = allocate_chain(chunks.length)
|
|
89
|
+
blocks.each_with_index do |block, i|
|
|
90
|
+
link = blocks[i + 1] || [0, chunks[i].length + 1]
|
|
91
|
+
put_sector(*block, link + chunks[i])
|
|
92
|
+
end
|
|
93
|
+
blocks
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def free_chain(entry)
|
|
97
|
+
each_sector(entry[:track], entry[:sector]) do |_data, track, sector|
|
|
98
|
+
mark(track, sector, true)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def put_sector(track, sector, data)
|
|
103
|
+
block = data.first(SECTOR_SIZE)
|
|
104
|
+
@bytes[sector_offset(track, sector), SECTOR_SIZE] = block + Array.new(SECTOR_SIZE - block.length, 0)
|
|
105
|
+
@errors[track_offset(track) + sector] = 1 if @errors
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "badline/storage/disk_image/bam"
|
|
4
|
+
require "badline/storage/disk_image/directory"
|
|
5
|
+
require "badline/storage/disk_image/writing"
|
|
6
|
+
|
|
3
7
|
module Badline
|
|
4
8
|
module Storage
|
|
9
|
+
# A disk image served as a drive. It reads files and blocks, and
|
|
10
|
+
# writes them back to the host file through Writing. One opened with
|
|
11
|
+
# `read_only` is a write-protected disk, and the host file is never
|
|
12
|
+
# written.
|
|
5
13
|
class DiskImage
|
|
14
|
+
include Bam
|
|
15
|
+
include Directory
|
|
16
|
+
include Writing
|
|
17
|
+
|
|
6
18
|
SECTOR_SIZE = 256
|
|
7
19
|
ENTRY_SIZE = 32
|
|
8
20
|
ENTRIES_PER_SECTOR = 8
|
|
@@ -13,11 +25,16 @@ module Badline
|
|
|
13
25
|
# the read, write and ID errors, 74 is DRIVE NOT READY.
|
|
14
26
|
DOS_ERRORS = (2..11).to_h { |code| [code, code + 18] }.merge(15 => 74).freeze
|
|
15
27
|
|
|
16
|
-
def initialize(path)
|
|
28
|
+
def initialize(path, read_only: false)
|
|
29
|
+
@path = path
|
|
30
|
+
@read_only = read_only
|
|
17
31
|
@bytes = File.binread(path).bytes
|
|
18
32
|
@errors = split_error_table
|
|
19
33
|
end
|
|
20
34
|
|
|
35
|
+
# Whether the image was opened write-protected.
|
|
36
|
+
def read_only? = @read_only
|
|
37
|
+
|
|
21
38
|
# A LOAD reads only PRG files. An OPEN names the type it wants, or
|
|
22
39
|
# takes the first file of any type with a nil `type`.
|
|
23
40
|
def read_file(name, type: :prg)
|
|
@@ -42,6 +59,28 @@ module Badline
|
|
|
42
59
|
nil
|
|
43
60
|
end
|
|
44
61
|
|
|
62
|
+
# The track and sector a file's chain starts at.
|
|
63
|
+
def first_block(name, type: :prg)
|
|
64
|
+
entry = find_entry(name, type)
|
|
65
|
+
[entry[:track], entry[:sector]] if entry
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The bytes of the chain that starts at the block, or nil when the
|
|
69
|
+
# block is outside the image's geometry.
|
|
70
|
+
def read_file_at(track, sector)
|
|
71
|
+
read_chain(track, sector) if block?(track, sector)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The track and sector of the last block in a file's chain.
|
|
75
|
+
def last_block(name, type: :prg)
|
|
76
|
+
entry = find_entry(name, type)
|
|
77
|
+
return unless entry
|
|
78
|
+
|
|
79
|
+
last = nil
|
|
80
|
+
each_sector(entry[:track], entry[:sector]) { |_data, track, sector| last = [track, sector] }
|
|
81
|
+
last
|
|
82
|
+
end
|
|
83
|
+
|
|
45
84
|
# Raw block access for the DOS `U1` command. Returns nil for blocks
|
|
46
85
|
# outside the image's geometry.
|
|
47
86
|
def read_block(track, sector)
|
|
@@ -101,12 +140,16 @@ module Badline
|
|
|
101
140
|
def entries
|
|
102
141
|
@entries ||= begin
|
|
103
142
|
list = []
|
|
104
|
-
each_sector(directory_track, directory_sector)
|
|
143
|
+
each_sector(directory_track, directory_sector) do |data, track, sector|
|
|
144
|
+
list.concat(parse_entries(data, sector_offset(track, sector)))
|
|
145
|
+
end
|
|
105
146
|
list
|
|
106
147
|
end
|
|
107
148
|
end
|
|
108
149
|
|
|
109
|
-
|
|
150
|
+
# Each entry keeps the offset of its slot in the image, where a write
|
|
151
|
+
# updates it.
|
|
152
|
+
def parse_entries(data, offset)
|
|
110
153
|
(0...ENTRIES_PER_SECTOR).filter_map do |i|
|
|
111
154
|
entry = data[i * ENTRY_SIZE, ENTRY_SIZE]
|
|
112
155
|
type = FILETYPES.key(entry[2] & 0x07)
|
|
@@ -115,7 +158,9 @@ module Badline
|
|
|
115
158
|
{ name: decode_name(entry[5, 16]),
|
|
116
159
|
type:,
|
|
117
160
|
track: entry[3],
|
|
118
|
-
sector: entry[4]
|
|
161
|
+
sector: entry[4],
|
|
162
|
+
locked: entry[2].anybits?(0x40),
|
|
163
|
+
offset: offset + (i * ENTRY_SIZE) }
|
|
119
164
|
end
|
|
120
165
|
end
|
|
121
166
|
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Storage
|
|
5
5
|
# A host directory served as a drive: .prg files by their host name,
|
|
6
|
-
# .p00 and .t64 entries by their embedded names. A
|
|
7
|
-
#
|
|
6
|
+
# .p00 and .t64 entries by their embedded names. A .prg the host can't
|
|
7
|
+
# read is still listed but fails to read, a .p00 or .t64 whose names
|
|
8
|
+
# can't be read is left out, and a failed write reports false.
|
|
8
9
|
class HostDirectory
|
|
10
|
+
NO_SYNC = 21
|
|
11
|
+
|
|
9
12
|
def initialize(path)
|
|
10
13
|
@path = path
|
|
11
14
|
end
|
|
@@ -15,10 +18,20 @@ module Badline
|
|
|
15
18
|
return unless entry
|
|
16
19
|
|
|
17
20
|
archive = entry[:archive]
|
|
18
|
-
archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file])
|
|
21
|
+
archive ? archive.read_file(entry[:name]) : file_bytes(entry[:file]) || []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A listed file the host can't read fails before its first byte, as
|
|
25
|
+
# a 1541 does on a disk it finds no sync on.
|
|
26
|
+
def read_error(name, **)
|
|
27
|
+
entry = find(name)
|
|
28
|
+
return unless entry && entry[:file] && !file_bytes(entry[:file])
|
|
29
|
+
|
|
30
|
+
{ error: NO_SYNC, track: 0, sector: 0, offset: 0 }
|
|
19
31
|
end
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
# Writes a .prg of any file type, over the one of the same name.
|
|
34
|
+
def write_file(name, bytes, **)
|
|
22
35
|
host_name = "#{name.downcase.tr('/', '_')}.prg"
|
|
23
36
|
File.binwrite(File.join(@path, host_name), bytes.pack("C*"))
|
|
24
37
|
true
|
|
@@ -13,6 +13,9 @@ module Badline
|
|
|
13
13
|
|
|
14
14
|
V1_HEADER_SIZE = 0x76
|
|
15
15
|
|
|
16
|
+
# BASIC's RUN handler, where a BASIC tune starts.
|
|
17
|
+
BASIC_RUN = 0xa871
|
|
18
|
+
|
|
16
19
|
# The 6502 stub that starts a tune: call init with the song number,
|
|
17
20
|
# point the KERNAL's IRQ vector at a raster handler that calls play,
|
|
18
21
|
# and spin. Both calls are wrapped in the `$01` save, bank and restore
|
|
@@ -171,6 +174,10 @@ module Badline
|
|
|
171
174
|
# code.
|
|
172
175
|
def mus? = psid? && flags[0] == 1
|
|
173
176
|
|
|
177
|
+
# Flag bit 1 marks an RSID body as a BASIC program, which RUN starts
|
|
178
|
+
# instead of a call to init.
|
|
179
|
+
def basic? = !psid? && flags[1] == 1
|
|
180
|
+
|
|
174
181
|
# A v3 header can place a second SID at `$Dxx0` and a v4 header a
|
|
175
182
|
# third, each given by its middle byte: even, and in `$d420-$d7e0` or
|
|
176
183
|
# `$de00-$dfe0`. Anything else means the SID isn't there.
|
|
@@ -185,16 +192,22 @@ module Badline
|
|
|
185
192
|
# 10 the 8580, 11 either and 00 unknown. Only an 8580-only tune gets one.
|
|
186
193
|
def sid_model = flags[4, 2] == 0b10 ? :mos8580 : :mos6581
|
|
187
194
|
|
|
195
|
+
# Flag bits 2-3 name the video standard the same way: 01 PAL, 10 NTSC,
|
|
196
|
+
# 11 either and 00 unknown. Only an NTSC-only tune gets NTSC.
|
|
197
|
+
def ntsc? = flags[2, 2] == 0b10
|
|
198
|
+
|
|
188
199
|
# HVSC keys its song length database on the whole file, header and all.
|
|
189
200
|
def md5 = @md5 ||= Digest::MD5.hexdigest(@bytes.pack("C*"))
|
|
190
201
|
|
|
191
202
|
# The `$01` value a routine at `address` has to run under, following
|
|
192
203
|
# libsidplayfp's iomap: RAM under BASIC from `$a000`, RAM under both
|
|
193
204
|
# ROMs and no I/O for a routine in the I/O window itself, RAM under the
|
|
194
|
-
# KERNAL from `$e000`.
|
|
205
|
+
# KERNAL from `$e000`. A routine below `$a000` still gets BASIC banked
|
|
206
|
+
# out when the image reaches into it, as VSID does, since it may call
|
|
207
|
+
# into the part that lies there. An RSID tune banks itself and gets nil.
|
|
195
208
|
def bank_for(address)
|
|
196
209
|
return unless psid?
|
|
197
|
-
return 0x37 if address < 0xa000
|
|
210
|
+
return (under_basic? ? 0x36 : 0x37) if address < 0xa000
|
|
198
211
|
return 0x36 if address < 0xd000
|
|
199
212
|
return 0x34 if address < 0xe000
|
|
200
213
|
|
|
@@ -227,6 +240,23 @@ module Badline
|
|
|
227
240
|
Driver.new(self, song: song.clamp(1, songs) - 1, base: driver_address).bytes
|
|
228
241
|
end
|
|
229
242
|
|
|
243
|
+
# What a booted machine needs in RAM besides the image before
|
|
244
|
+
# `boot_command` starts the tune, as address => bytes. A BASIC tune
|
|
245
|
+
# gets the end of its program in VARTAB, as LOAD leaves it, and the
|
|
246
|
+
# song in the A, X and Y that SYS loads from `$030c-$030e`, where the
|
|
247
|
+
# tune reads it with PEEK(780).
|
|
248
|
+
def boot_memory(song: start_song)
|
|
249
|
+
return { driver_address => driver(song:) } unless basic?
|
|
250
|
+
|
|
251
|
+
{ 0x2d => [low_byte(end_address), high_byte(end_address)],
|
|
252
|
+
0x030c => [song.clamp(1, songs) - 1] * 3 }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def boot_command = basic? ? "run\r" : "sys#{driver_address}\r"
|
|
256
|
+
|
|
257
|
+
# Where the CPU first lands once `boot_command` has started the tune.
|
|
258
|
+
def entry_address = basic? ? BASIC_RUN : driver_address
|
|
259
|
+
|
|
230
260
|
private
|
|
231
261
|
|
|
232
262
|
def truncated?
|
|
@@ -237,6 +267,10 @@ module Badline
|
|
|
237
267
|
@body ||= @bytes[data_offset..] || []
|
|
238
268
|
end
|
|
239
269
|
|
|
270
|
+
def under_basic?
|
|
271
|
+
load_address < 0xc000 && end_address > 0xa000
|
|
272
|
+
end
|
|
273
|
+
|
|
240
274
|
# The tape buffer and the free block below the stack page, neither
|
|
241
275
|
# of which BASIC touches.
|
|
242
276
|
def candidates
|
data/lib/badline/storage.rb
CHANGED
|
@@ -16,6 +16,21 @@ module Badline
|
|
|
16
16
|
module Storage
|
|
17
17
|
FILE_TYPES = { "S" => :seq, "P" => :prg, "U" => :usr }.freeze
|
|
18
18
|
|
|
19
|
+
# A write the disk refuses, with the DOS error it fails with.
|
|
20
|
+
class WriteError < StandardError
|
|
21
|
+
WRITE_PROTECT_ON = 26
|
|
22
|
+
FILE_NOT_FOUND = 62
|
|
23
|
+
FILE_EXISTS = 63
|
|
24
|
+
DISK_FULL = 72
|
|
25
|
+
|
|
26
|
+
attr_reader :code
|
|
27
|
+
|
|
28
|
+
def initialize(code)
|
|
29
|
+
@code = code
|
|
30
|
+
super("DOS error #{code}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
19
34
|
class << self
|
|
20
35
|
# Folds shifted PETSCII letters to their ASCII equivalents.
|
|
21
36
|
def ascii(bytes)
|
|
@@ -36,8 +51,10 @@ module Badline
|
|
|
36
51
|
end
|
|
37
52
|
|
|
38
53
|
# CBM-style filename pattern: "*" and "?" wildcards, case-insensitive.
|
|
54
|
+
# The DOS stops comparing at the first "*", so whatever follows it
|
|
55
|
+
# is ignored.
|
|
39
56
|
def matcher(name)
|
|
40
|
-
escaped = Regexp.escape(name.downcase)
|
|
57
|
+
escaped = Regexp.escape(name.downcase.sub(/\*.*/m, "*"))
|
|
41
58
|
.gsub('\*', ".*")
|
|
42
59
|
.gsub('\?', ".")
|
|
43
60
|
Regexp.new("\\A#{escaped}\\z")
|
data/lib/badline/time_of_day.rb
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
class TimeOfDay
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# The TOD pin is fed from the AC supply. A CIA passes its region's
|
|
6
|
+
# clock and mains frequency, and these are PAL's.
|
|
7
|
+
CLOCK_HZ = Region::PAL.clock_hz
|
|
8
|
+
MAINS_HZ = Region::PAL.mains_hz
|
|
7
9
|
|
|
8
10
|
def initialize(clock_hz: CLOCK_HZ, mains_hz: MAINS_HZ)
|
|
9
11
|
# The accumulator advances mains_hz per cycle, so a TOD pin pulse has
|
|
@@ -49,6 +51,13 @@ module Badline
|
|
|
49
51
|
@latch[:hours]
|
|
50
52
|
end
|
|
51
53
|
|
|
54
|
+
# The clock and alarm as stored, read without latching the clock.
|
|
55
|
+
def registers
|
|
56
|
+
[@clock[:tenths], @clock[:seconds], @clock[:minutes], @clock[:hours],
|
|
57
|
+
@alarm[:tenths], @alarm[:seconds], @alarm[:minutes], @alarm[:hours],
|
|
58
|
+
@stopped ? 1 : 0, @latch ? 1 : 0]
|
|
59
|
+
end
|
|
60
|
+
|
|
52
61
|
def write(field, value, alarm:)
|
|
53
62
|
# The frequency counter is held clear while the clock is stopped, and
|
|
54
63
|
# starts counting again from the write to tenths that restarts it.
|
data/lib/badline/version.rb
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
class VIA
|
|
5
|
+
# One port's pair of control lines, CA1 and CA2 or CB1 and CB2, set up
|
|
6
|
+
# by a nibble of the peripheral control register, and their two
|
|
7
|
+
# interrupt flags. Bit 0 picks the active edge of C1, rising when set.
|
|
8
|
+
# Bits 3-1 pick the C2 mode:
|
|
9
|
+
#
|
|
10
|
+
# 000 input, falling edge 100 handshake output
|
|
11
|
+
# 001 independent, falling 101 pulse output
|
|
12
|
+
# 010 input, rising edge 110 low output
|
|
13
|
+
# 011 independent, rising 111 high output
|
|
14
|
+
#
|
|
15
|
+
# A port access clears both flags, but an independent input keeps C2's.
|
|
16
|
+
# In handshake mode C2 goes low on a port access and back high on the
|
|
17
|
+
# next active C1 edge. In pulse mode it goes low for the cycle after the
|
|
18
|
+
# access. Port A handshakes on reads and writes, port B on writes only.
|
|
19
|
+
class ControlLines
|
|
20
|
+
attr_reader :control, :c1_high, :c2_high
|
|
21
|
+
|
|
22
|
+
# The level driven on C2, high while it is an input.
|
|
23
|
+
attr_reader :c2_output
|
|
24
|
+
|
|
25
|
+
def initialize(interrupts, c1_flag:, c2_flag:, handshake_on_read:)
|
|
26
|
+
@interrupts = interrupts
|
|
27
|
+
@c1_flag = c1_flag
|
|
28
|
+
@c2_flag = c2_flag
|
|
29
|
+
@handshake_on_read = handshake_on_read
|
|
30
|
+
@c1_high = @c2_high = true
|
|
31
|
+
reset!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Back to input mode with both handshake outputs released. The input
|
|
35
|
+
# levels carry over.
|
|
36
|
+
def reset!
|
|
37
|
+
@control = 0
|
|
38
|
+
@handshake_low = false
|
|
39
|
+
@pulse = 0
|
|
40
|
+
drive
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def control=(nibble)
|
|
44
|
+
@control = nibble & 0x0f
|
|
45
|
+
drive
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Only the pulse output needs the clock.
|
|
49
|
+
def pulsing? = @pulse.positive?
|
|
50
|
+
|
|
51
|
+
def cycle!
|
|
52
|
+
@pulse -= 1
|
|
53
|
+
drive if @pulse.zero?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def active_c1_edge?(high) = high == @control.anybits?(0x01)
|
|
57
|
+
|
|
58
|
+
# A new C1 level. An active edge sets the flag and ends a handshake.
|
|
59
|
+
def c1=(high)
|
|
60
|
+
return if high == @c1_high
|
|
61
|
+
|
|
62
|
+
@c1_high = high
|
|
63
|
+
return unless active_c1_edge?(high)
|
|
64
|
+
|
|
65
|
+
@handshake_low = false
|
|
66
|
+
drive
|
|
67
|
+
@interrupts.set(@c1_flag)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A new C2 level. An active edge sets the flag while C2 is an input.
|
|
71
|
+
def c2=(high)
|
|
72
|
+
return if high == @c2_high
|
|
73
|
+
|
|
74
|
+
@c2_high = high
|
|
75
|
+
@interrupts.set(@c2_flag) if @control.nobits?(0x08) && high == @control.anybits?(0x04)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A read or write of the handshaking port register.
|
|
79
|
+
def access!(write)
|
|
80
|
+
@interrupts.clear(independent? ? @c1_flag : @c1_flag | @c2_flag)
|
|
81
|
+
return unless write || @handshake_on_read
|
|
82
|
+
|
|
83
|
+
case @control >> 1
|
|
84
|
+
when 4 then @handshake_low = true
|
|
85
|
+
when 5 then @pulse = 2
|
|
86
|
+
end
|
|
87
|
+
drive
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def independent? = @control & 0x0a == 0x02
|
|
93
|
+
|
|
94
|
+
def drive
|
|
95
|
+
@c2_output = case @control >> 1
|
|
96
|
+
when 4 then !@handshake_low
|
|
97
|
+
when 5 then @pulse.zero?
|
|
98
|
+
when 6 then false
|
|
99
|
+
else true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|