badline 0.8.0 → 0.10.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/CLAUDE.md +6 -4
  4. data/README.md +60 -19
  5. data/doc/library.md +10 -3
  6. data/doc/media.md +11 -2
  7. data/doc/pinned-behaviour.md +199 -9
  8. data/doc/sid-player.md +8 -4
  9. data/doc/snapshots.md +79 -33
  10. data/lib/badline/address_bus/pla.rb +130 -0
  11. data/lib/badline/address_bus/roms.rb +32 -0
  12. data/lib/badline/address_bus.rb +13 -92
  13. data/lib/badline/audio/bare_player.rb +24 -0
  14. data/lib/badline/audio/checkpoints.rb +42 -0
  15. data/lib/badline/audio/jukebox.rb +11 -4
  16. data/lib/badline/audio/machine_player.rb +16 -0
  17. data/lib/badline/audio/renderer.rb +80 -9
  18. data/lib/badline/audio/stereo.rb +3 -5
  19. data/lib/badline/audio.rb +1 -0
  20. data/lib/badline/chrout_trap.rb +4 -3
  21. data/lib/badline/computer/attachments.rb +52 -12
  22. data/lib/badline/computer/saved_state.rb +1 -0
  23. data/lib/badline/computer.rb +50 -33
  24. data/lib/badline/control_ports.rb +10 -3
  25. data/lib/badline/core.rb +51 -0
  26. data/lib/badline/data_path.rb +37 -0
  27. data/lib/badline/datasette.rb +63 -10
  28. data/lib/badline/drive1541/disk.rb +3 -3
  29. data/lib/badline/drive1541/disk_state.rb +14 -5
  30. data/lib/badline/drive1541/serial_port.rb +5 -8
  31. data/lib/badline/drive1541/sleep.rb +5 -6
  32. data/lib/badline/drive1541.rb +3 -3
  33. data/lib/badline/frontend/app.rb +63 -64
  34. data/lib/badline/frontend/boot.rb +34 -4
  35. data/lib/badline/frontend/confirmation.rb +17 -11
  36. data/lib/badline/frontend/controls.rb +12 -6
  37. data/lib/badline/frontend/drive_led.rb +9 -10
  38. data/lib/badline/frontend/frame_rate.rb +4 -4
  39. data/lib/badline/frontend/machine_window.rb +87 -0
  40. data/lib/badline/frontend/menu_dialogs.rb +52 -14
  41. data/lib/badline/frontend/menu_media.rb +70 -16
  42. data/lib/badline/frontend/menu_pages.rb +47 -42
  43. data/lib/badline/frontend/menu_ports.rb +69 -0
  44. data/lib/badline/frontend/name_field.rb +88 -0
  45. data/lib/badline/frontend/pacer.rb +6 -5
  46. data/lib/badline/frontend/pause_menu.rb +52 -37
  47. data/lib/badline/frontend/player_screen.rb +1 -1
  48. data/lib/badline/frontend/player_window.rb +4 -4
  49. data/lib/badline/frontend/screen.rb +39 -37
  50. data/lib/badline/frontend/sid_view.rb +1 -1
  51. data/lib/badline/frontend/snapshots.rb +145 -17
  52. data/lib/badline/frontend/sound.rb +17 -9
  53. data/lib/badline/frontend/timeline.rb +36 -5
  54. data/lib/badline/frontend.rb +3 -0
  55. data/lib/badline/iec_bus.rb +16 -28
  56. data/lib/badline/kernal_trap/drive.rb +3 -0
  57. data/lib/badline/kernal_trap/file.rb +1 -1
  58. data/lib/badline/kernal_trap/layout.rb +112 -0
  59. data/lib/badline/kernal_trap/load.rb +24 -25
  60. data/lib/badline/kernal_trap/routine.rb +17 -5
  61. data/lib/badline/kernal_trap/save.rb +13 -23
  62. data/lib/badline/kernal_trap/serial.rb +38 -24
  63. data/lib/badline/kernal_trap.rb +1 -0
  64. data/lib/badline/keyboard.rb +17 -14
  65. data/lib/badline/machine.rb +60 -0
  66. data/lib/badline/media/disk_list.rb +75 -0
  67. data/lib/badline/media/disk_set.rb +13 -3
  68. data/lib/badline/media/vic20_basic.rb +54 -0
  69. data/lib/badline/media/vic20_cartridge.rb +74 -0
  70. data/lib/badline/media/vic20_media.rb +132 -0
  71. data/lib/badline/media.rb +39 -20
  72. data/lib/badline/model.rb +62 -0
  73. data/lib/badline/options/event.rb +10 -6
  74. data/lib/badline/options/help.rb +25 -13
  75. data/lib/badline/options/table.rb +15 -1
  76. data/lib/badline/options/validation.rb +63 -3
  77. data/lib/badline/options.rb +37 -34
  78. data/lib/badline/region.rb +16 -4
  79. data/lib/badline/reu/ram.rb +27 -0
  80. data/lib/badline/reu/saved_state.rb +27 -0
  81. data/lib/badline/roms/README +37 -0
  82. data/lib/badline/roms/kernal-sx64.rom +0 -0
  83. data/lib/badline/roms/vic20/basic.rom +0 -0
  84. data/lib/badline/roms/vic20/character.rom +0 -0
  85. data/lib/badline/roms/vic20/kernal-ntsc.rom +0 -0
  86. data/lib/badline/roms/vic20/kernal-pal.rom +0 -0
  87. data/lib/badline/sid/decimator.rb +13 -0
  88. data/lib/badline/sid/filter.rb +6 -3
  89. data/lib/badline/sid.rb +19 -0
  90. data/lib/badline/snapshot/container.rb +2 -0
  91. data/lib/badline/snapshot/fields.rb +1 -0
  92. data/lib/badline/snapshot/image.rb +15 -0
  93. data/lib/badline/snapshot/setup.rb +20 -8
  94. data/lib/badline/snapshot/state.rb +15 -7
  95. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  96. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  97. data/lib/badline/snapshot/vice/restore.rb +3 -1
  98. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  99. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  100. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  101. data/lib/badline/snapshot/vice.rb +25 -23
  102. data/lib/badline/snapshot.rb +10 -0
  103. data/lib/badline/storage/crt_file.rb +16 -5
  104. data/lib/badline/storage/disk_image.rb +3 -0
  105. data/lib/badline/storage/host_directory.rb +3 -0
  106. data/lib/badline/storage/t64.rb +10 -4
  107. data/lib/badline/storage/tap.rb +44 -8
  108. data/lib/badline/storage/unavailable.rb +3 -1
  109. data/lib/badline/storage.rb +5 -5
  110. data/lib/badline/timing.rb +12 -0
  111. data/lib/badline/version.rb +1 -1
  112. data/lib/badline/via/shift_register.rb +43 -16
  113. data/lib/badline/vic/bank.rb +53 -16
  114. data/lib/badline/vic/registers.rb +20 -4
  115. data/lib/badline/vic/sprite/timing.rb +5 -9
  116. data/lib/badline/vic/sprite.rb +4 -3
  117. data/lib/badline/vic.rb +34 -15
  118. data/lib/badline/vic20/attachments.rb +103 -0
  119. data/lib/badline/vic20/bus.rb +308 -0
  120. data/lib/badline/vic20/cpu.rb +10 -0
  121. data/lib/badline/vic20/keyboard_via_ports.rb +53 -0
  122. data/lib/badline/vic20/port_wiring.rb +50 -0
  123. data/lib/badline/vic20/saved_state.rb +152 -0
  124. data/lib/badline/vic20/sound/output.rb +83 -0
  125. data/lib/badline/vic20/sound.rb +233 -0
  126. data/lib/badline/vic20/user_via_ports.rb +47 -0
  127. data/lib/badline/vic20/vic/painter.rb +239 -0
  128. data/lib/badline/vic20/vic/saved_state.rb +55 -0
  129. data/lib/badline/vic20/vic/text_window.rb +71 -0
  130. data/lib/badline/vic20/vic.rb +267 -0
  131. data/lib/badline/vic20.rb +220 -0
  132. data/lib/badline.rb +2 -38
  133. metadata +37 -2
  134. data/lib/badline/address_bus/ultimax_pages.rb +0 -38
@@ -12,17 +12,6 @@ module Badline
12
12
  class Serial < Routine
13
13
  attr_accessor :device
14
14
 
15
- ROUTINES = {
16
- 0xed09 => :talk,
17
- 0xed0c => :listen,
18
- 0xedb9 => :second,
19
- 0xedc7 => :tksa,
20
- 0xeddd => :ciout,
21
- 0xedef => :untalk,
22
- 0xedfe => :unlisten,
23
- 0xee13 => :acptr
24
- }.freeze
25
-
26
15
  # Frame type in the high nibble of the secondary address
27
16
  OPEN = 0xf0
28
17
  CLOSE = 0xe0
@@ -34,8 +23,8 @@ module Badline
34
23
 
35
24
  NO_DATA = [0x0d, EOI | READ_TIMEOUT].freeze
36
25
 
37
- def initialize(cpu:, bus:, drive:, device: DEVICE)
38
- super(cpu:, bus:)
26
+ def initialize(cpu:, bus:, layout:, drive:, device: DEVICE)
27
+ super(cpu:, bus:, layout:)
39
28
  @drive = drive
40
29
  @device = device
41
30
  @listening = false
@@ -63,14 +52,27 @@ module Badline
63
52
  end
64
53
 
65
54
  def install
66
- ROUTINES.each do |address, routine|
67
- @cpu.install_trap(address) { call_routine(routine) if kernal? }
55
+ routines.each do |address, routine|
56
+ @cpu.install_trap(address) { call_routine(routine) if kernal_mapped? }
68
57
  end
69
58
  self
70
59
  end
71
60
 
72
61
  private
73
62
 
63
+ def routines
64
+ {
65
+ @layout.talk => :talk,
66
+ @layout.listen => :listen,
67
+ @layout.second => :second,
68
+ @layout.tksa => :tksa,
69
+ @layout.ciout => :ciout,
70
+ @layout.untalk => :untalk,
71
+ @layout.unlisten => :unlisten,
72
+ @layout.acptr => :acptr
73
+ }
74
+ end
75
+
74
76
  def call_routine(routine)
75
77
  case routine
76
78
  when :talk then talk
@@ -85,17 +87,24 @@ module Badline
85
87
  end
86
88
 
87
89
  # Addressing the bus starts a new frame. The channel number arrives
88
- # in the secondary address that follows.
90
+ # in the secondary address that follows. Each byte sent under ATN,
91
+ # and each byte of a frame, times its handshake with the serial timer
92
+ # as ISOUR does.
89
93
  def talk
90
94
  @talk_channel = nil
91
95
  @talking = @cpu.a == @device
92
- return_to_caller if @talking
96
+ sent_under_atn if @talking
93
97
  end
94
98
 
95
99
  def listen
96
100
  @listen_channel = nil
97
101
  @listening = @cpu.a == @device
98
- return_to_caller if @listening
102
+ sent_under_atn if @listening
103
+ end
104
+
105
+ def sent_under_atn
106
+ time_serial_byte(ISOUR_TIMEOUT)
107
+ return_to_caller
99
108
  end
100
109
 
101
110
  def second
@@ -104,19 +113,22 @@ module Badline
104
113
  @frame = @cpu.a & 0xf0
105
114
  @listen_channel = @cpu.a & 0x0f
106
115
  @buffer = []
107
- return_to_caller
116
+ sent_under_atn
108
117
  end
109
118
 
110
119
  def tksa
111
120
  return unless @talking
112
121
 
113
122
  @talk_channel = @cpu.a & 0x0f
114
- return_to_caller
123
+ sent_under_atn
115
124
  end
116
125
 
126
+ # CIOUT holds each byte back until the next one, or the UNLISTEN,
127
+ # comes, so the first byte of a frame sends nothing.
117
128
  def ciout
118
129
  return unless @listening
119
130
 
131
+ time_serial_byte(ISOUR_TIMEOUT) if !@buffer.empty? && accepted?
120
132
  @buffer << @cpu.a
121
133
  update_status(DEVICE_NOT_PRESENT) unless accepted?
122
134
  @cpu.status.carry = false
@@ -135,6 +147,7 @@ module Badline
135
147
 
136
148
  @listening = false
137
149
  deliver_frame
150
+ time_serial_byte(ISOUR_TIMEOUT)
138
151
  release_bus
139
152
  end
140
153
 
@@ -142,14 +155,14 @@ module Badline
142
155
  return unless @talking
143
156
 
144
157
  @talking = false
158
+ time_serial_byte(ISOUR_TIMEOUT)
145
159
  release_bus
146
160
  end
147
161
 
148
- # UNLSN and UNTLK end by releasing ATN, the clock and the data line on
149
- # CIA 2's port A, and leave the port's last read in the accumulator.
162
+ # UNLSN and UNTLK end by releasing ATN, the clock and the data line,
163
+ # and leave the port's last read in the accumulator.
150
164
  def release_bus
151
- @bus.poke(0xdd00, @bus.peek(0xdd00) & 0xc7)
152
- @cpu.a = @bus.peek(0xdd00) & 0xdf
165
+ @cpu.a = @layout.release_serial_lines(@bus)
153
166
  @cpu.status.negative = @cpu.a.anybits?(0x80)
154
167
  @cpu.status.zero = @cpu.a.zero?
155
168
  return_to_caller
@@ -159,6 +172,7 @@ module Badline
159
172
  return unless @talking
160
173
 
161
174
  byte, status = received
175
+ time_serial_byte(ACPTR_TIMEOUT)
162
176
  @cpu.a = byte
163
177
  update_status(status)
164
178
  @cpu.status.carry = false
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "badline/kernal_trap/layout"
3
4
  require "badline/kernal_trap/routine"
4
5
  require "badline/kernal_trap/channel"
5
6
  require "badline/kernal_trap/drive"
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- # The 8x8 key matrix spanning CIA 1's two ports: rows on port A, columns on
5
- # port B.
4
+ # The key matrix spanning CIA 1's two ports: rows on port A, columns on
5
+ # port B. Row lines beyond the eighth sit above port A's bits in +rows+.
6
6
  #
7
7
  # A pressed key shorts its row line to its column line, so whichever side is
8
8
  # pulled low drags the other down with it. Scanning works in either
@@ -19,22 +19,25 @@ module Badline
19
19
  cursor_left: %i[lshift cursor_h]
20
20
  }.freeze
21
21
 
22
+ # The C64's wiring: one row of keys per port A line, in port B column
23
+ # order.
24
+ C64_MATRIX = [
25
+ %i[delete return cursor_h f7 f1 f3 f5 cursor_v],
26
+ %i[3 w a 4 z s e lshift],
27
+ %i[5 r d 6 c f t x],
28
+ %i[7 y g 8 b h u v],
29
+ %i[9 i j 0 m k o n],
30
+ %i[+ p l - . : @ ,],
31
+ %i[£ * ; clr_home rshift = up /],
32
+ %i[1 left control 2 space cbm q run_stop]
33
+ ].freeze
34
+
22
35
  attr_reader :keys, :matrix
23
36
 
24
- def initialize
37
+ def initialize(matrix: C64_MATRIX)
25
38
  @keys = []
26
39
  @row_masks = nil
27
-
28
- @matrix = [
29
- %i[delete return cursor_h f7 f1 f3 f5 cursor_v],
30
- %i[3 w a 4 z s e lshift],
31
- %i[5 r d 6 c f t x],
32
- %i[7 y g 8 b h u v],
33
- %i[9 i j 0 m k o n],
34
- %i[+ p l - . : @ ,],
35
- %i[£ * ; clr_home rshift = up /],
36
- %i[1 left control 2 space cbm q run_stop]
37
- ]
40
+ @matrix = matrix
38
41
  end
39
42
 
40
43
  def press(key)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ # = Machine
5
+ #
6
+ # The machines badline builds by family, as the command line's
7
+ # subcommand names them: the C64, Computer, and the VIC-20, Vic20.
8
+ #
9
+ # A machine answers what the front end, Media, the runners and the
10
+ # snapshots call on it:
11
+ #
12
+ # - Identity: +family+, the family it was built as.
13
+ # - Clocking: +run_cycles(n)+, +run_until(limit) { ... }+, +cycles+,
14
+ # +on_init+ and +init_threshold+, the cycle its handlers run at.
15
+ # - Lifecycle: +reset!+ and +power_cycle!+.
16
+ # - Video: +video+, the chip the screen reads (+display+, +width+,
17
+ # +dirty_lines+, +clear_dirty_lines!+ and +palette+), and +timing+, a
18
+ # Timing.
19
+ # - Sound: +sound_source+, which answers +record(rate:)+,
20
+ # +drain_samples+ and +model+.
21
+ # - Input: +keyboard+, +joystick1+, +joystick2+, +control_ports+,
22
+ # +press_restore+ and +release_restore+.
23
+ # - Media: +ram+, +load_prg+, +type_text+, +mount+, +unmount+,
24
+ # +mounted?+, +datasette+, +attach_cartridge+,
25
+ # +press_cartridge_button+, +release_cartridge_button+,
26
+ # +attach_drive1541+, +drive1541+, and +address_bus+ for its
27
+ # +cartridge+ and +detach_cartridge+.
28
+ # - Snapshots and tests: +snapshot+, +restore+, +save_snapshot+,
29
+ # +install_debug_register+ and +capture_output+.
30
+ #
31
+ # The VIC-20 answers the identity, clocking, lifecycle, video, sound and
32
+ # input parts, with its one joystick as both +joystick1+ and +joystick2+
33
+ # and nil for +control_ports+, and of the rest +ram+, +load_prg+,
34
+ # +type_text+, +mount+, +unmount+, +mounted?+, +datasette+, +attach_cartridge+,
35
+ # +attach_drive1541+, +drive1541+, +install_debug_register+ and
36
+ # +capture_output+.
37
+ module Machine
38
+ # A new machine of `family`, :c64 or :vic20, the model of it `model`
39
+ # names. A C64's model is one of Model::ALL, with its SID `sid_model`
40
+ # when not nil and an REU of `reu` K when not nil. A VIC-20's is "pal",
41
+ # with the RAM expansion `ram` names (Vic20::Bus::RAM_CONFIGURATIONS),
42
+ # unexpanded when nil.
43
+ def self.build(family, model:, sid_model: nil, reu: nil, ram: nil)
44
+ return vic20(model, ram) if family == :vic20
45
+ raise ArgumentError, "no machine family named #{family}" unless family == :c64
46
+
47
+ profile = Model.named(model)
48
+ Computer.new(vic_model: profile.vic_model, cia_model: profile.cia_model,
49
+ sid_model: sid_model || profile.sid_model, region: profile.region, reu:,
50
+ kernal: profile.kernal, datasette: profile.datasette)
51
+ end
52
+
53
+ def self.vic20(model, ram)
54
+ raise ArgumentError, "no VIC-20 model named #{model}" unless model == "pal"
55
+
56
+ Vic20.new(ram: ram || :unexpanded)
57
+ end
58
+ private_class_method :vic20
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # A list of the disks of a set: an .m3u playlist, one path a line, or
6
+ # a VICE .vfl flip list, whose paths follow a `UNIT 8` line. Paths are
7
+ # relative to the list or absolute, and blank lines and lines starting
8
+ # with # are skipped. Only the disk images the list names that are
9
+ # there count.
10
+ module DiskList
11
+ # A list naming no disk image that's there.
12
+ class Error < ArgumentError; end
13
+
14
+ EXTENSIONS = %w[.m3u .vfl].freeze
15
+
16
+ def self.list?(path) = EXTENSIONS.include?(File.extname(path).downcase)
17
+
18
+ # The disk `path` puts in the drive: a list's first disk, or the
19
+ # path itself.
20
+ def self.disk(path)
21
+ return path unless list?(path)
22
+
23
+ disks = disks(path)
24
+ raise Error, "#{path} lists no disk image that's there" if disks.empty?
25
+
26
+ disks.first
27
+ end
28
+
29
+ # The disk images the list names that are there, in its order.
30
+ def self.disks(list)
31
+ directory = File.dirname(list)
32
+ found = []
33
+ entries(list).each do |entry|
34
+ disk = entry.start_with?("/") ? entry : File.join(directory, entry)
35
+ found << disk if DiskSet::EXTENSIONS.include?(File.extname(disk).downcase) && File.file?(disk)
36
+ end
37
+ found
38
+ end
39
+
40
+ # The paths of the list's lines, only unit 8's for a .vfl, which
41
+ # starts on unit 8.
42
+ def self.entries(list)
43
+ flip = File.extname(list).casecmp?(".vfl")
44
+ unit = true
45
+ found = []
46
+ File.foreach(list) do |line|
47
+ text = line.strip
48
+ next if text.empty? || text.start_with?("#")
49
+
50
+ if flip && text.match?(/\AUNIT\s+\d+\z/i)
51
+ unit = text.split.last.to_i == 8
52
+ elsif unit
53
+ found << text
54
+ end
55
+ end
56
+ found
57
+ rescue SystemCallError
58
+ []
59
+ end
60
+
61
+ # The disks of the first list in the disk's folder that names it,
62
+ # with the disk as given, or none.
63
+ def self.beside(path)
64
+ directory = File.dirname(path)
65
+ own = File.expand_path(path)
66
+ lists = DiskSet.children(directory).sort.select { |name| list?(name) }
67
+ sets = lists.map { |name| disks(File.join(directory, name)) }
68
+ set = sets.find { |listed| listed.any? { |disk| File.expand_path(disk) == own } }
69
+ return [] if set.nil?
70
+
71
+ set.map { |disk| File.expand_path(disk) == own ? path : disk }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -8,16 +8,26 @@ module Badline
8
8
  # by those markers. A name with no marker can end in a number or a
9
9
  # letter, as in game_1.d64 and game_2.d64, which counts only when the
10
10
  # folder has the first of the set too.
11
+ #
12
+ # A list in the disk's folder that names it (DiskList) gives its set
13
+ # ahead of the names.
11
14
  module DiskSet
12
15
  KINDS = %w[disk disc side part].freeze
13
16
  EXTENSIONS = %w[.d64 .d71 .d81 .g64].freeze
14
17
 
15
18
  # The paths of the set's disks in order, or just `path` when it
16
- # belongs to none.
19
+ # belongs to none. A list's set is the disks it lists.
17
20
  def self.around(path)
18
- extension = File.extname(path).downcase
19
- return [path] unless EXTENSIONS.include?(extension)
21
+ return DiskList.disks(path) if DiskList.list?(path)
22
+ return [path] unless EXTENSIONS.include?(File.extname(path).downcase)
23
+
24
+ listed = DiskList.beside(path)
25
+ listed.empty? ? named(path) : listed
26
+ end
20
27
 
28
+ # The set of the disk's name.
29
+ def self.named(path)
30
+ extension = File.extname(path).downcase
21
31
  marked = !parse(stem(path))[1].negative?
22
32
  base = key(stem(path), marked)[0]
23
33
  found = siblings(File.dirname(path), extension, base, marked)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # A program loading into a VIC-20 once it has booted (Vic20Media).
6
+ module Vic20Basic
7
+ # Where BASIC starts with each RAM expansion it can start in, as
8
+ # TXTTAB holds it.
9
+ BASIC_STARTS = { 0x1001 => :unexpanded, 0x0401 => :"3k", 0x1201 => :"8k" }.freeze
10
+
11
+ class << self
12
+ # A BASIC program, one that loads at a start of BASIC or a byte
13
+ # ahead of it, goes to this machine's start of BASIC. Anything else
14
+ # goes where it says, and doesn't RUN.
15
+ def load_program(machine, data, autostart:)
16
+ load_addr = data[0] | (data[1] << 8)
17
+ basic_start = machine.basic_start
18
+ ahead = [0, 1].find { |offset| BASIC_STARTS.key?(load_addr + offset) || load_addr + offset == basic_start }
19
+ return machine.load_prg(data) unless ahead
20
+
21
+ load_basic(machine, data, basic_start - ahead, load_addr)
22
+ machine.type_text("run\r") if autostart
23
+ end
24
+
25
+ private
26
+
27
+ # Puts a BASIC program at `address`, relinking its lines when that
28
+ # isn't where it was saved from, as BASIC's LOAD does, and sets the
29
+ # end of the program, where BASIC's variables start, and the end
30
+ # address the KERNAL's LOAD leaves.
31
+ def load_basic(machine, data, address, saved_at)
32
+ ram = machine.ram
33
+ ram.write(address, data[2..])
34
+ end_addr = address + data.length - 2
35
+ relink(ram, machine.basic_start, end_addr) unless address == saved_at
36
+ [0x2d, 0xae].each { |pointer| ram.write(pointer, [end_addr & 0xff, end_addr >> 8]) }
37
+ end
38
+
39
+ # Points each line's link at the line after it, as BASIC's LINKPRG
40
+ # does: past the zero that ends the line's text. A link with a zero
41
+ # high byte ends the program.
42
+ def relink(ram, line, end_addr)
43
+ while line + 4 < end_addr && ram.peek(line + 1).positive?
44
+ following = line + 4
45
+ following += 1 while following < end_addr && ram.peek(following).positive?
46
+ following += 1
47
+ ram.write(line, [following & 0xff, following >> 8])
48
+ line = following
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # A VIC-20 cartridge kept as programs, one per ROM chip, each loading
6
+ # at the block the chip goes in. The chip in BLK5 at $A000 starts the
7
+ # cartridge, and a bigger cartridge has more in BLK1 to BLK3. The files
8
+ # of one cartridge share a name, with each block's address in its
9
+ # extension (game.a0, game.60) or its name (game-a000.prg,
10
+ # game-6000.prg).
11
+ module Vic20Cartridge
12
+ BLOCKS = [0xa000, 0x2000, 0x4000, 0x6000].freeze
13
+
14
+ class << self
15
+ # The ROM chips, as CRTFile::Chips, of the cartridge `data`, the
16
+ # program at `path`, is a part of, or nil when it isn't one: a
17
+ # program loading at $A000, or one loading at the start of BLK1 to
18
+ # BLK3 with an $A000 part beside it.
19
+ def chips(path, data)
20
+ own = load_address(data)
21
+ return unless BLOCKS.include?(own)
22
+
23
+ parts = BLOCKS.filter_map { |address| address == own ? data : part(path, own, address) }
24
+ return unless parts.any? { |part| load_address(part) == 0xa000 }
25
+
26
+ parts.map { |part| rom_chip(part) }
27
+ end
28
+
29
+ # A program's bytes, unwrapped from a .p00.
30
+ def program_bytes(path)
31
+ bytes = File.binread(path).bytes
32
+ Storage::P00.wraps?(bytes) ? Storage::P00.data(bytes) : bytes
33
+ end
34
+
35
+ private
36
+
37
+ def load_address(data)
38
+ data[0] | (data[1] << 8) if data.length > 2
39
+ end
40
+
41
+ def part(path, own, address)
42
+ name = part_names(path, own, address).find { |candidate| File.file?(candidate) }
43
+ return unless name
44
+
45
+ part = program_bytes(name)
46
+ part if load_address(part) == address
47
+ end
48
+
49
+ # `path` with the block at `own` in its extension or its name
50
+ # swapped for the one at `address`, its letters in either case.
51
+ def part_names(path, own, address)
52
+ extension = File.extname(path)
53
+ base = path[0, path.length - extension.length]
54
+ own_tag = own.to_s(16)
55
+ tags = [address.to_s(16), address.to_s(16).upcase].uniq
56
+ return tags.map { |tag| "#{base}.#{tag[0, 2]}" } if extension.downcase == ".#{own_tag[0, 2]}"
57
+
58
+ name = File.basename(base)
59
+ at = name.downcase.index(own_tag)
60
+ return [] unless at
61
+
62
+ tags.map { |tag| File.join(File.dirname(base), name[0, at] + tag + name[(at + 4)..]) + extension }
63
+ end
64
+
65
+ # A ROM chip of whole pages, the last padded with $FF.
66
+ def rom_chip(part)
67
+ data = part[2..]
68
+ data += [0xff] * (-data.length % 0x100)
69
+ Storage::CRTFile::Chip.new(chip_type: 0, bank: 0, address: load_address(part), data:)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Media
5
+ # Media on a VIC-20. A program loads once the KERNAL has booted: a
6
+ # BASIC program at the start of BASIC, wherever the RAM puts it, and
7
+ # RUNs. A program that loads into BLK5 at $A000 is a cartridge's ROM
8
+ # instead, as are the parts of a set that includes one
9
+ # (Vic20Cartridge), and goes in with a power cycle, as a .crt does.
10
+ # Disks mount on device 8 through the KERNAL traps, or go in a true
11
+ # 1541 (TrueDrive), and tapes play on the datasette.
12
+ module Vic20Media
13
+ # The expansions that fill BLK1, BLK2 and BLK3 one block after
14
+ # another, each with the last address it fills.
15
+ BLOCK_EXPANSIONS = [[0x3fff, :"8k"], [0x5fff, :"16k"], [0x7fff, :"24k"]].freeze
16
+
17
+ # What a VIC-20 doesn't take.
18
+ UNSUPPORTED = %w[.sid].freeze
19
+
20
+ # The platform byte of a VIC-20 tape (Storage::TAP#platform).
21
+ TAPE_PLATFORM = 1
22
+
23
+ class << self
24
+ def attach(machine, path, autostart:, **options)
25
+ extension = File.extname(path).downcase
26
+ raise ArgumentError, "#{path} doesn't go in a VIC-20" if UNSUPPORTED.include?(extension)
27
+
28
+ if TrueDrive.takes?(machine, path)
29
+ attach_true_drive(machine, path, options.fetch(:disk, {}), autostart:)
30
+ elsif File.directory?(path)
31
+ machine.mount(Storage::HostDirectory.new(path))
32
+ "Mounted #{path} as device 8"
33
+ elsif extension == ".crt"
34
+ attach_cartridge(machine, path, Storage::CRTFile.new(path, machine: :vic20).chips)
35
+ elsif extension == ".tap"
36
+ attach_tape(machine, path, autostart:)
37
+ elsif MOUNT_TYPES.key?(extension)
38
+ attach_storage(machine, path, options.fetch(:disk, {}), autostart:)
39
+ else
40
+ attach_prg(machine, path, autostart:)
41
+ end
42
+ end
43
+
44
+ # The RAM expansion (Vic20::Bus::RAM_CONFIGURATIONS) a VIC-20 needs
45
+ # for the program at `path`, or for the first program on the disk
46
+ # or in the directory there, by where it loads. A BASIC program
47
+ # needs the expansion its start of BASIC comes with, and more of
48
+ # BLK1 to BLK3 when it runs past $3FFF. So does a machine-language
49
+ # program loading into those blocks. Anything else, cartridges
50
+ # included, runs unexpanded.
51
+ def ram_for(path)
52
+ data = first_program(path)
53
+ return :unexpanded unless data && data.length > 2 && !Vic20Cartridge.chips(path, data)
54
+
55
+ load_addr = data[0] | (data[1] << 8)
56
+ if load_addr == 0x1201 || load_addr.between?(0x2000, 0x7fff)
57
+ expansion_reaching(load_addr + data.length - 3)
58
+ else
59
+ Vic20Basic::BASIC_STARTS.fetch(load_addr, :unexpanded)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def expansion_reaching(last)
66
+ BLOCK_EXPANSIONS.each { |reach, ram| return ram if last <= reach }
67
+ BLOCK_EXPANSIONS.last.last
68
+ end
69
+
70
+ def first_program(path)
71
+ extension = File.extname(path).downcase
72
+ if File.directory?(path) || MOUNT_TYPES.key?(extension)
73
+ open_storage(path).read_file("*")
74
+ elsif File.file?(path) && !%w[.sid .crt .tap .g64].include?(extension)
75
+ Vic20Cartridge.program_bytes(path)
76
+ end
77
+ end
78
+
79
+ def open_storage(path, disk = {})
80
+ return Storage::HostDirectory.new(path) if File.directory?(path)
81
+
82
+ storage = MOUNT_TYPES.fetch(File.extname(path).downcase)
83
+ storage < Storage::DiskImage ? storage.new(path, **disk) : storage.new(path)
84
+ end
85
+
86
+ def attach_cartridge(machine, path, chips)
87
+ machine.attach_cartridge(chips)
88
+ "Attached cartridge #{path}"
89
+ end
90
+
91
+ # A tape made for another machine plays all the same, and says so.
92
+ def attach_tape(machine, path, autostart:)
93
+ tape = Storage::TAP.new(path)
94
+ machine.datasette.insert(tape)
95
+ machine.datasette.play!
96
+ machine.type_text(TAPE_AUTOSTART) if autostart
97
+ message = "Inserted #{path} in the datasette"
98
+ tape.platform == TAPE_PLATFORM ? message : "#{message}, a tape for another machine than the VIC-20"
99
+ end
100
+
101
+ def attach_true_drive(machine, path, disk, autostart:)
102
+ message = TrueDrive.insert(machine, path, read_only: disk.fetch(:read_only, false))
103
+ machine.type_text(disk_autostart(first_program(path))) if autostart
104
+ message
105
+ end
106
+
107
+ def attach_storage(machine, path, disk, autostart:)
108
+ storage = open_storage(path, disk)
109
+ machine.mount(storage)
110
+ machine.type_text(disk_autostart(storage.read_file("*"))) if autostart
111
+ "Mounted #{path} as device 8"
112
+ end
113
+
114
+ # A BASIC program loads relocated to the start of BASIC, and anything
115
+ # else where it says.
116
+ def disk_autostart(data)
117
+ basic = data && data.length > 2 && Vic20Basic::BASIC_STARTS.key?(data[0] | (data[1] << 8))
118
+ basic ? %(lO"*",8\rrun\r) : AUTOSTART
119
+ end
120
+
121
+ def attach_prg(machine, path, autostart:)
122
+ bytes = Vic20Cartridge.program_bytes(path)
123
+ chips = Vic20Cartridge.chips(path, bytes)
124
+ return attach_cartridge(machine, path, chips) if chips
125
+
126
+ machine.on_init { Vic20Basic.load_program(machine, bytes, autostart:) }
127
+ "Loading #{path}"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end