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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/session-start.sh +57 -0
  3. data/.claude/settings.json +14 -0
  4. data/CHANGELOG.md +107 -0
  5. data/CLAUDE.md +51 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +119 -51
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +293 -39
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +28 -14
  12. data/lib/badline/addressable.rb +1 -1
  13. data/lib/badline/audio/aiff.rb +8 -3
  14. data/lib/badline/audio/bare_player.rb +20 -3
  15. data/lib/badline/audio/cli.rb +17 -12
  16. data/lib/badline/audio/console.rb +12 -58
  17. data/lib/badline/audio/machine_player.rb +9 -6
  18. data/lib/badline/audio/playback.rb +3 -0
  19. data/lib/badline/audio/renderer.rb +3 -3
  20. data/lib/badline/audio/sdl_sink.rb +20 -52
  21. data/lib/badline/audio/stream.rb +117 -0
  22. data/lib/badline/audio/terminal.rb +90 -0
  23. data/lib/badline/audio.rb +3 -2
  24. data/lib/badline/cartridge/easy_flash.rb +6 -2
  25. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  26. data/lib/badline/cartridge/geo_ram.rb +63 -0
  27. data/lib/badline/cartridge/mach5.rb +1 -1
  28. data/lib/badline/cartridge/magic_desk.rb +5 -1
  29. data/lib/badline/cartridge/ocean.rb +5 -2
  30. data/lib/badline/cartridge/rex_utility.rb +1 -1
  31. data/lib/badline/cartridge/rgcd.rb +1 -1
  32. data/lib/badline/cartridge/simons_basic.rb +2 -2
  33. data/lib/badline/cartridge/westermann.rb +2 -2
  34. data/lib/badline/cartridge.rb +9 -0
  35. data/lib/badline/checkpoint.rb +98 -0
  36. data/lib/badline/cia/interrupt_register.rb +34 -10
  37. data/lib/badline/cia.rb +19 -10
  38. data/lib/badline/color_memory.rb +2 -2
  39. data/lib/badline/computer.rb +34 -14
  40. data/lib/badline/cpu.rb +2 -2
  41. data/lib/badline/gui/application.rb +70 -26
  42. data/lib/badline/gui/gamepads.rb +22 -22
  43. data/lib/badline/gui/joy_map.rb +1 -1
  44. data/lib/badline/gui/key_map.rb +6 -1
  45. data/lib/badline/gui/pane.rb +7 -6
  46. data/lib/badline/gui/screen_pane.rb +9 -13
  47. data/lib/badline/gui/window.rb +35 -20
  48. data/lib/badline/gui.rb +1 -1
  49. data/lib/badline/input/mouse1351.rb +21 -6
  50. data/lib/badline/input/paddles.rb +11 -6
  51. data/lib/badline/kernal_trap/channel.rb +102 -7
  52. data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
  53. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  54. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  55. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  56. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  57. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  58. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  59. data/lib/badline/kernal_trap/drive.rb +109 -131
  60. data/lib/badline/kernal_trap/file.rb +6 -14
  61. data/lib/badline/kernal_trap/load.rb +6 -0
  62. data/lib/badline/kernal_trap/save.rb +13 -7
  63. data/lib/badline/keyboard.rb +19 -3
  64. data/lib/badline/media.rb +44 -13
  65. data/lib/badline/memory.rb +2 -2
  66. data/lib/badline/options.rb +209 -0
  67. data/lib/badline/ram_expansion/banking.rb +30 -0
  68. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  69. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  70. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  71. data/lib/badline/ram_expansion.rb +21 -0
  72. data/lib/badline/region.rb +51 -0
  73. data/lib/badline/rom.rb +14 -5
  74. data/lib/badline/sdl/events.rb +47 -0
  75. data/lib/badline/sdl/functions.rb +73 -0
  76. data/lib/badline/sdl.rb +67 -0
  77. data/lib/badline/sid/catch_up.rb +110 -0
  78. data/lib/badline/sid/decimator.rb +6 -1
  79. data/lib/badline/sid/filter.rb +5 -5
  80. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  81. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  82. data/lib/badline/sid/waveform.rb +8 -8
  83. data/lib/badline/sid.rb +19 -93
  84. data/lib/badline/storage/d64_image.rb +10 -0
  85. data/lib/badline/storage/d71_image.rb +12 -0
  86. data/lib/badline/storage/d81_image.rb +9 -0
  87. data/lib/badline/storage/disk_image/bam.rb +111 -0
  88. data/lib/badline/storage/disk_image/directory.rb +79 -0
  89. data/lib/badline/storage/disk_image/writing.rb +110 -0
  90. data/lib/badline/storage/disk_image.rb +49 -4
  91. data/lib/badline/storage/host_directory.rb +17 -4
  92. data/lib/badline/storage/sid_file.rb +36 -2
  93. data/lib/badline/storage.rb +18 -1
  94. data/lib/badline/time_of_day.rb +11 -2
  95. data/lib/badline/version.rb +1 -1
  96. data/lib/badline/via/control_lines.rb +104 -0
  97. data/lib/badline/via/interrupt_register.rb +58 -0
  98. data/lib/badline/via/shift_register.rb +135 -0
  99. data/lib/badline/via/timer1.rb +82 -0
  100. data/lib/badline/via/timer2.rb +84 -0
  101. data/lib/badline/via.rb +262 -0
  102. data/lib/badline/vic/bank.rb +4 -3
  103. data/lib/badline/vic/color_patches.rb +6 -3
  104. data/lib/badline/vic/display_state.rb +4 -0
  105. data/lib/badline/vic/graphics_shifter.rb +49 -8
  106. data/lib/badline/vic/registers.rb +19 -27
  107. data/lib/badline/vic/sequencer.rb +18 -18
  108. data/lib/badline/vic/sequencer_output.rb +4 -4
  109. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  110. data/lib/badline/vic/sprite/shifter.rb +25 -3
  111. data/lib/badline/vic/sprite.rb +5 -4
  112. data/lib/badline/vic/sprites.rb +8 -3
  113. data/lib/badline/vic.rb +98 -46
  114. data/lib/badline.rb +5 -0
  115. data/packaging/homebrew/badline.rb +34 -0
  116. metadata +37 -20
  117. data/exe/badline +0 -75
  118. data/exe/badline-sid +0 -34
  119. 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) { |data| list.concat(parse_entries(data)) }
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
- def parse_entries(data)
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 host file that can't
7
- # be read is left out, and a failed write reports false.
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
- def write_file(name, bytes)
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`. An RSID tune banks itself and gets nil.
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
@@ -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")
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Badline
4
4
  class TimeOfDay
5
- CLOCK_HZ = 985_248 # PAL only for now.
6
- MAINS_HZ = 50 # The TOD pin is fed from the AC supply.
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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