badline 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CLAUDE.md +32 -27
  4. data/CONTRIBUTING.md +5 -0
  5. data/README.md +55 -24
  6. data/doc/pinned-behaviour.md +223 -0
  7. data/exe/badline-ruby +7 -4
  8. data/lib/badline/address_bus/ultimax_pages.rb +38 -0
  9. data/lib/badline/address_bus.rb +19 -29
  10. data/lib/badline/audio/jukebox.rb +6 -4
  11. data/lib/badline/audio/machine_player.rb +10 -6
  12. data/lib/badline/cartridge/kcs_power.rb +3 -3
  13. data/lib/badline/cia.rb +21 -3
  14. data/lib/badline/computer.rb +86 -7
  15. data/lib/badline/cpu.rb +39 -0
  16. data/lib/badline/drive1541/bus.rb +197 -0
  17. data/lib/badline/drive1541/disk.rb +256 -0
  18. data/lib/badline/drive1541/disk_via.rb +33 -0
  19. data/lib/badline/drive1541/gcr.rb +57 -0
  20. data/lib/badline/drive1541/idle.rb +136 -0
  21. data/lib/badline/drive1541/mechanism.rb +277 -0
  22. data/lib/badline/drive1541/orbit.rb +81 -0
  23. data/lib/badline/drive1541/rotation.rb +111 -0
  24. data/lib/badline/drive1541/sector_reader.rb +76 -0
  25. data/lib/badline/drive1541/serial_port.rb +67 -0
  26. data/lib/badline/drive1541/sleep.rb +114 -0
  27. data/lib/badline/drive1541/track.rb +98 -0
  28. data/lib/badline/drive1541.rb +234 -0
  29. data/lib/badline/gui/application.rb +15 -3
  30. data/lib/badline/gui/drive_led_pane.rb +29 -0
  31. data/lib/badline/gui.rb +1 -0
  32. data/lib/badline/iec_bus.rb +109 -0
  33. data/lib/badline/kernal_trap/channel.rb +9 -1
  34. data/lib/badline/kernal_trap/serial.rb +9 -3
  35. data/lib/badline/media/true_drive.rb +54 -0
  36. data/lib/badline/media.rb +22 -1
  37. data/lib/badline/memory.rb +3 -0
  38. data/lib/badline/options.rb +24 -4
  39. data/lib/badline/region.rb +35 -10
  40. data/lib/badline/reu/dma.rb +228 -0
  41. data/lib/badline/reu/ram.rb +65 -0
  42. data/lib/badline/reu/trigger.rb +32 -0
  43. data/lib/badline/reu.rb +276 -0
  44. data/lib/badline/roms/README +10 -0
  45. data/lib/badline/roms/dos1541.rom +0 -0
  46. data/lib/badline/sdl/functions.rb +1 -0
  47. data/lib/badline/storage/d64_image.rb +18 -9
  48. data/lib/badline/storage/d81_image.rb +1 -0
  49. data/lib/badline/storage/disk_image/catalog.rb +52 -0
  50. data/lib/badline/storage/disk_image/writing.rb +16 -0
  51. data/lib/badline/storage/disk_image.rb +2 -0
  52. data/lib/badline/storage/g64_image.rb +222 -0
  53. data/lib/badline/storage/host_directory.rb +21 -1
  54. data/lib/badline/storage/listing.rb +118 -0
  55. data/lib/badline/storage/t64.rb +14 -0
  56. data/lib/badline/storage.rb +2 -0
  57. data/lib/badline/traps.rb +4 -0
  58. data/lib/badline/version.rb +1 -1
  59. data/lib/badline/via/control_lines.rb +3 -0
  60. data/lib/badline/via/fast_forward.rb +53 -0
  61. data/lib/badline/via/shift_register.rb +8 -3
  62. data/lib/badline/via/timer1.rb +36 -0
  63. data/lib/badline/via/timer2.rb +31 -0
  64. data/lib/badline/via.rb +2 -0
  65. data/lib/badline/vic/display_state.rb +7 -7
  66. data/lib/badline/vic/sprite/internal_bus.rb +11 -7
  67. data/lib/badline/vic/sprite/shifter.rb +10 -15
  68. data/lib/badline/vic/sprite/timing.rb +60 -0
  69. data/lib/badline/vic/sprite.rb +14 -12
  70. data/lib/badline/vic/sprites.rb +2 -2
  71. data/lib/badline/vic.rb +86 -39
  72. data/lib/badline.rb +3 -0
  73. metadata +28 -1
@@ -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
@@ -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
@@ -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
@@ -6,6 +6,10 @@ module Badline
6
6
  (@traps ||= {})[addr] = handler
7
7
  end
8
8
 
9
+ def remove_trap(addr)
10
+ @traps&.delete(addr)
11
+ end
12
+
9
13
  private
10
14
 
11
15
  def run_traps
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -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,53 @@
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
+ # The counters an orbit (see Drive1541::Idle) has to bring back: those
30
+ # of the armed timers, and of the timers +touched+ names (bit 0 for
31
+ # timer 1, bit 1 for timer 2). The others only count.
32
+ def counter_state(touched)
33
+ [(@t1.count_state if @t1.armed? || touched.anybits?(1)),
34
+ (@t2.count_state if @t2.armed? || touched.anybits?(2))]
35
+ end
36
+
37
+ # Runs +cycles+ cycles of whole orbits at once: the counters
38
+ # counter_state leaves out move on, and the rest come back round.
39
+ def skip_orbits(cycles, touched)
40
+ @t1.fast_forward(cycles) unless @t1.armed? || touched.anybits?(1)
41
+ @t2.fast_forward(cycles) unless @t2.armed? || touched.anybits?(2)
42
+ end
43
+
44
+ # Everything the VIA holds but its counters, for comparing it at two
45
+ # points. Timer 1's PB7 level counts only while ACR bit 7 puts it on
46
+ # the pin: nothing reads it otherwise.
47
+ def idle_state
48
+ [@ora, @orb, @ddra, @ddrb, @latch_a, @latch_b, @acr, @pcr, @pb6_high, @ifr.flags, @ifr.enable,
49
+ @acr.anybits?(0x80) && @t1.pb7, *@t1.state, *@t2.state, *@ca.state, *@cb.state, *@shift_register.state]
50
+ end
51
+ end
52
+ end
53
+ 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 = 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
@@ -66,6 +66,42 @@ module Badline
66
66
  @counter == 0xffff && timeout(free_run)
67
67
  end
68
68
 
69
+ # How many cycles the timer can run without setting its flag: until
70
+ # an armed timer's timeout, and forever for an unarmed one, whose
71
+ # timeouts only reload the counter. None while an armed timer loads
72
+ # or reloads.
73
+ def quiet_cycles
74
+ return FastForward::QUIET unless @armed
75
+ return 0 if @hold || @reload
76
+
77
+ @counter
78
+ end
79
+
80
+ # Runs +cycles+ cycles at once, as many as quiet_cycles allows. An
81
+ # unarmed timer goes round its period of latch + 2 cycles: the
82
+ # timeout at $FFFF, the reload, and latch down to 0.
83
+ def fast_forward(cycles)
84
+ if @hold || @reload
85
+ cycle!(false)
86
+ cycles -= 1
87
+ end
88
+ return @counter -= cycles if cycles <= @counter
89
+
90
+ phase = (cycles - @counter - 1) % (@latch + 2)
91
+ @reload = phase.zero?
92
+ @counter = @reload ? 0xffff : @latch - phase + 1
93
+ end
94
+
95
+ # What the counter and PB7 don't hold, for comparing the timer at two
96
+ # points.
97
+ def state = [@latch, @armed]
98
+
99
+ # Whether a timeout sets the flag.
100
+ def armed? = @armed
101
+
102
+ # The count, for comparing the timer at two points where it matters.
103
+ def count_state = [@counter, @hold, @reload]
104
+
69
105
  private
70
106
 
71
107
  # The counter reloads either way. Armed, PB7 inverts and the flag
@@ -63,6 +63,37 @@ module Badline
63
63
  # A falling edge on PB6. Returns true when it sets the flag.
64
64
  def pulse! = @counting_pulses && decrement
65
65
 
66
+ # How many cycles the timer can run without setting its flag, taken
67
+ # without the shift register clocking off it: until an armed timer
68
+ # underflows, and forever for one counting pulses or unarmed, which
69
+ # only rolls on. None while a load or a switch of mode is under way.
70
+ def quiet_cycles
71
+ return 0 if @hold || @counting_pulses != @count_pulses
72
+ return FastForward::QUIET if @counting_pulses || !@armed
73
+
74
+ @counter
75
+ end
76
+
77
+ # Runs +cycles+ cycles at once, as many as quiet_cycles allows. The
78
+ # low byte's underflow is set when the last cycle took it from $00.
79
+ def fast_forward(cycles)
80
+ @low_underflowed = false
81
+ return if @counting_pulses
82
+
83
+ @counter = (@counter - cycles) & 0xffff
84
+ @low_underflowed = @counter.allbits?(0xff)
85
+ end
86
+
87
+ # What the counter and the low byte's underflow don't hold, for
88
+ # comparing the timer at two points.
89
+ def state = [@latch_low, @armed, @hold, @low_reload, @count_pulses, @counting_pulses]
90
+
91
+ # Whether the next underflow sets the flag.
92
+ def armed? = @armed
93
+
94
+ # The count, for comparing the timer at two points where it matters.
95
+ def count_state = [@counter, @low_underflowed]
96
+
66
97
  private
67
98
 
68
99
  # A cycle while counting pulses, or held after a load or a low byte
data/lib/badline/via.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/via/control_lines"
4
+ require "badline/via/fast_forward"
4
5
  require "badline/via/interrupt_register"
5
6
  require "badline/via/shift_register"
6
7
  require "badline/via/timer1"
@@ -16,6 +17,7 @@ module Badline
16
17
  # feed the counter.
17
18
  class VIA
18
19
  include Addressable
20
+ include FastForward
19
21
 
20
22
  attr_reader :start, :peripheral, :shift_register, :acr, :pcr
21
23
 
@@ -12,10 +12,6 @@ module Badline
12
12
  LAST_LINE = 0xf7 # 247
13
13
  COLUMNS_PER_ROW = 40
14
14
 
15
- # The last column of a rasterline already compares against the line
16
- # about to start, the same wrap the raster IRQ latch uses.
17
- WRAP_COLUMN = 62
18
-
19
15
  # AEC follows BA three cycles later; the c-accesses before it read
20
16
  # the bus the CPU still drives.
21
17
  BA_DELAY = 3
@@ -32,12 +28,16 @@ module Badline
32
28
  LOAD_COLUMN = 12
33
29
  RC_COLUMN = 56
34
30
 
35
- GRAPHICS_COLUMNS = Array.new(63) { |column| column.between?(GRAPHICS_FIRST, GRAPHICS_LAST) }.freeze
31
+ GRAPHICS_COLUMNS = Array.new(65) { |column| column.between?(GRAPHICS_FIRST, GRAPHICS_LAST) }.freeze
36
32
 
37
33
  attr_reader :vc_base, :vc, :vmli, :rc
38
34
 
39
- def initialize(registers)
35
+ # The wrap column is the last column of a rasterline, 62 on the 6569,
36
+ # which already compares against the line about to start, the same
37
+ # wrap the raster IRQ latch uses.
38
+ def initialize(registers, wrap_column = 62)
40
39
  @registers = registers
40
+ @wrap_column = wrap_column
41
41
  @vc_base = 0
42
42
  @vc = 0
43
43
  @vmli = 0
@@ -77,7 +77,7 @@ module Badline
77
77
 
78
78
  # Returns true when this compare opened display state.
79
79
  def cycle(rasterline, column)
80
- match = column == WRAP_COLUMN ? wrap_match(rasterline + 1) : line_match
80
+ match = column == @wrap_column ? wrap_match(rasterline + 1) : line_match
81
81
  @matched = match
82
82
  opened = false
83
83
  if match
@@ -10,12 +10,15 @@ module Badline
10
10
  # the CPU reads or writes a VIC register in that cycle, and the middle
11
11
  # one is the idle fetch at $3fff (sbsprf24's readme).
12
12
  class InternalBus
13
- # The columns of a raster line.
14
- attr_reader :columns
13
+ # The columns of a raster line, and where the region puts the
14
+ # sprites in it.
15
+ attr_reader :columns, :timing
15
16
 
16
- def initialize(bank, columns)
17
+ def initialize(bank, timing)
17
18
  @bank = bank
18
- @columns = columns
19
+ @timing = timing
20
+ @columns = timing.columns
21
+ @sprite_cycle = timing.sprite_cycle
19
22
  @lines = 0
20
23
  @ghost = 0xff
21
24
  @value = Array.new(columns, 0xff)
@@ -37,10 +40,11 @@ module Badline
37
40
  @line[column] = column.zero? ? @lines + 1 : @lines
38
41
  end
39
42
 
40
- # The row sprite `index` (3-7) fetched this line, in Bauer cycles
41
- # 2n-5 and 2n-4.
43
+ # The row sprite `index` (3-7) fetched this line, in the phi2 halves
44
+ # of the two cycles from its p-access: Bauer cycles 2n-5 and 2n-4 on
45
+ # the 6569.
42
46
  def row(index)
43
- column = (2 * index) - 6
47
+ column = (@sprite_cycle + (2 * index) - 1) % @columns
44
48
  (phi2(column) << 16) | (@ghost << 8) | phi2(column + 1)
45
49
  end
46
50
 
@@ -6,18 +6,13 @@ module Badline
6
6
  # The X comparator and the 24-bit shift register behind it: where a
7
7
  # line fires the sprite, and the pixels each firing shifts out.
8
8
  module Shifter
9
- EMPTY = [].freeze
10
-
11
9
  private
12
10
 
13
11
  # The first pixel of every comparator match along the line. Writes to
14
12
  # $d000/$d010 move the compare value mid-line, so a sprite can miss
15
13
  # its match entirely and pick up a later one, a second one, or none.
16
14
  def comparator_hits(log)
17
- unless log
18
- match = compare_x(@registers)
19
- return match ? [(match + 1) % @width] : EMPTY
20
- end
15
+ return compare_x(@registers) unless log
21
16
 
22
17
  hits = []
23
18
  log.rewind
@@ -25,8 +20,10 @@ module Badline
25
20
  from = 0
26
21
  while from < @width
27
22
  upto = [log.next_x, @width].min
28
- match = compare_x(log)
29
- hits << ((match + 1) % @width) if match && match >= from && match < upto
23
+ compare_x(log).each do |start|
24
+ match = (start - 1) % @width
25
+ hits << start if match >= from && match < upto
26
+ end
30
27
  break if upto >= @width
31
28
 
32
29
  from = upto
@@ -35,15 +32,13 @@ module Badline
35
32
  hits
36
33
  end
37
34
 
38
- # The VIC's X counter only runs to 503, so the eight coordinates above
39
- # it never match and the sprite stays dark — the gap the spritegap
40
- # tests find at $1f8.
35
+ # The pixels the sprite starts at, one past each comparator match.
36
+ # The 6569's X counter only runs to 503, so the eight coordinates
37
+ # above it never match and the sprite stays dark — the gap the
38
+ # spritegap tests find at $1f8.
41
39
  def compare_x(view)
42
40
  msb = view[0x10].anybits?(@bit) ? 0x100 : 0
43
- xpos = msb | view[index * 2]
44
- return if xpos >= @width
45
-
46
- (xpos + COMPARE_OFFSET) % @width
41
+ @timing.x_pixels[msb | view[index * 2]]
47
42
  end
48
43
 
49
44
  # Pixels from the X match to the next reload.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class VIC < Cycleable
5
+ class Sprite
6
+ # = Timing
7
+ #
8
+ # Where a region puts the sprites in the line: the cycles of their
9
+ # fetches and of the display compare, the pixels those land on, and
10
+ # the raster pixels the X counter reads each coordinate at. Where the
11
+ # fetches or the compare run a cycle later than on the 6569, what
12
+ # hangs off them moves 8 pixels with them.
13
+ class Timing
14
+ # The X counter's value where it holds on a raster of more than 512
15
+ # pixels, and the four values it repeats from there.
16
+ HOLD_AT = 0x188
17
+ HOLD_BASE = 0x184
18
+
19
+ attr_reader :columns, :sprite_cycle, :display_off_x, :x_pixels
20
+
21
+ def initialize(region)
22
+ @columns = region.cycles_per_line
23
+ @sprite_cycle = region.sprite_cycle
24
+ @shift = region.sprite_cycle - Region::PAL.sprite_cycle
25
+ @display_off_x = DISPLAY_OFF_X + (8 * (region.sprite_display_cycle - Region::PAL.sprite_display_cycle))
26
+ @x_pixels = Timing.x_pixels(@columns * 8, region.x_hold)
27
+ end
28
+
29
+ # The column BA falls in for a sprite's own fetch: 55 for sprite 0 on
30
+ # the 6569, and two later for each sprite after it.
31
+ def ba_column(index) = 55 + @shift + (2 * index)
32
+
33
+ # The raster pixel of a sprite's reload, counted from the start of
34
+ # the line it is fetched on, so past the end of it for sprites 3-7
35
+ # on the 6569.
36
+ def reload_x(index) = RELOAD_X + (8 * @shift) + (RELOAD_STEP * index)
37
+
38
+ # The X counter at a raster pixel: X_OFFSET behind it, wrapping at
39
+ # the end of the line, less the pixels it held for past HOLD_AT,
40
+ # where it ran over $184-$187 again instead.
41
+ def self.xpos(pixel, width, hold)
42
+ count = (pixel - X_OFFSET) % width
43
+ return count if count < HOLD_AT
44
+ return count - hold if count >= HOLD_AT + hold
45
+
46
+ HOLD_BASE + ((count - HOLD_AT) % 4)
47
+ end
48
+
49
+ # The raster pixels at which the X counter reads each coordinate, 512
50
+ # lists. A sprite starts at one where its X matches. On the 6569 the
51
+ # coordinates past 503 have none.
52
+ def self.x_pixels(width, hold)
53
+ pixels = Array.new(512) { [] }
54
+ width.times { |pixel| pixels[xpos(pixel, width, hold)] << pixel }
55
+ pixels.each(&:freeze).freeze
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end