badline 0.11.1 → 0.12.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/CLAUDE.md +8 -7
  4. data/CONTRIBUTING.md +7 -6
  5. data/README.md +12 -5
  6. data/doc/media.md +28 -1
  7. data/doc/pinned-behaviour.md +31 -1
  8. data/exe/badline-ruby +1 -1
  9. data/lib/badline/c128/bus.rb +4 -17
  10. data/lib/badline/c128/drives.rb +35 -22
  11. data/lib/badline/c128/fast_serial.rb +1 -0
  12. data/lib/badline/c128/io_pages.rb +35 -0
  13. data/lib/badline/c128/mmu.rb +9 -7
  14. data/lib/badline/c128/modes.rb +17 -4
  15. data/lib/badline/c128/saved_state.rb +22 -0
  16. data/lib/badline/c128/z80.rb +49 -0
  17. data/lib/badline/c128/z80_pages.rb +118 -0
  18. data/lib/badline/c128/z80_turns.rb +106 -0
  19. data/lib/badline/c128.rb +16 -19
  20. data/lib/badline/cartridge/action_replay.rb +2 -2
  21. data/lib/badline/cartridge/comal80.rb +1 -7
  22. data/lib/badline/cartridge/dinamic.rb +0 -8
  23. data/lib/badline/cartridge/final_cartridge3.rb +6 -6
  24. data/lib/badline/cartridge/fun_play.rb +0 -8
  25. data/lib/badline/cartridge/game_system.rb +0 -8
  26. data/lib/badline/cartridge/kcs_power.rb +10 -7
  27. data/lib/badline/cartridge/mach5.rb +2 -4
  28. data/lib/badline/cartridge/magic_desk.rb +0 -8
  29. data/lib/badline/cartridge/ocean.rb +0 -8
  30. data/lib/badline/cartridge/pagefox.rb +2 -2
  31. data/lib/badline/cartridge/retro_replay.rb +7 -6
  32. data/lib/badline/cartridge/rex_utility.rb +2 -6
  33. data/lib/badline/cartridge/rgcd.rb +2 -2
  34. data/lib/badline/cartridge/simons_basic.rb +0 -5
  35. data/lib/badline/cartridge/super_games.rb +4 -2
  36. data/lib/badline/cartridge/westermann.rb +2 -6
  37. data/lib/badline/cartridge.rb +37 -9
  38. data/lib/badline/cia/fast_forward.rb +21 -3
  39. data/lib/badline/cia.rb +8 -5
  40. data/lib/badline/computer/attachments.rb +4 -56
  41. data/lib/badline/computer/true_drives.rb +80 -0
  42. data/lib/badline/computer.rb +4 -0
  43. data/lib/badline/core.rb +2 -0
  44. data/lib/badline/drive/core.rb +8 -33
  45. data/lib/badline/drive/fast_serial.rb +73 -0
  46. data/lib/badline/drive/idle.rb +1 -11
  47. data/lib/badline/drive/orbit.rb +2 -5
  48. data/lib/badline/drive/sleep.rb +0 -2
  49. data/lib/badline/drive/vias.rb +84 -0
  50. data/lib/badline/drive.rb +2 -0
  51. data/lib/badline/drive1541.rb +1 -0
  52. data/lib/badline/drive1571/wd1770.rb +94 -8
  53. data/lib/badline/drive1571.rb +8 -58
  54. data/lib/badline/drive1581/bus.rb +137 -0
  55. data/lib/badline/drive1581/cpu.rb +11 -0
  56. data/lib/badline/drive1581/disk.rb +135 -0
  57. data/lib/badline/drive1581/disk_state.rb +87 -0
  58. data/lib/badline/drive1581/mechanism.rb +117 -0
  59. data/lib/badline/drive1581/serial_port.rb +52 -0
  60. data/lib/badline/drive1581/slot.rb +78 -0
  61. data/lib/badline/drive1581/track.rb +100 -0
  62. data/lib/badline/drive1581/track_bytes.rb +75 -0
  63. data/lib/badline/drive1581/wd1772/registers.rb +89 -0
  64. data/lib/badline/drive1581/wd1772/sector_writes.rb +73 -0
  65. data/lib/badline/drive1581/wd1772/sectors.rb +150 -0
  66. data/lib/badline/drive1581/wd1772/state.rb +66 -0
  67. data/lib/badline/drive1581/wd1772/stepping.rb +109 -0
  68. data/lib/badline/drive1581/wd1772/tracks.rb +125 -0
  69. data/lib/badline/drive1581/wd1772/turn.rb +73 -0
  70. data/lib/badline/drive1581/wd1772.rb +216 -0
  71. data/lib/badline/drive1581.rb +233 -0
  72. data/lib/badline/frontend/app.rb +1 -3
  73. data/lib/badline/frontend/audio_device.rb +67 -0
  74. data/lib/badline/frontend/audio_sink.rb +16 -50
  75. data/lib/badline/frontend/boot.rb +18 -10
  76. data/lib/badline/frontend/buttons.rb +7 -7
  77. data/lib/badline/frontend/keys.rb +33 -8
  78. data/lib/badline/frontend/media_slots.rb +42 -0
  79. data/lib/badline/frontend/{confirmation.rb → menu/confirmation.rb} +4 -4
  80. data/lib/badline/frontend/{file_browser.rb → menu/file_browser.rb} +18 -34
  81. data/lib/badline/frontend/{menu_dialogs.rb → menu/menu_dialogs.rb} +3 -7
  82. data/lib/badline/frontend/{menu_media.rb → menu/menu_media.rb} +26 -51
  83. data/lib/badline/frontend/{menu_pages.rb → menu/menu_pages.rb} +5 -8
  84. data/lib/badline/frontend/{name_field.rb → menu/name_field.rb} +11 -24
  85. data/lib/badline/frontend/{pause_menu.rb → menu/pause_menu.rb} +13 -28
  86. data/lib/badline/frontend/menu/theme.rb +18 -0
  87. data/lib/badline/frontend/{filter_view.rb → player/filter_view.rb} +4 -4
  88. data/lib/badline/frontend/{info_view.rb → player/info_view.rb} +3 -3
  89. data/lib/badline/frontend/{player_window.rb → player/player_controller.rb} +17 -25
  90. data/lib/badline/frontend/{player_footer.rb → player/player_footer.rb} +8 -8
  91. data/lib/badline/frontend/{player_header.rb → player/player_header.rb} +5 -5
  92. data/lib/badline/frontend/{player_screen.rb → player/player_screen.rb} +4 -5
  93. data/lib/badline/frontend/{scope.rb → player/scope.rb} +1 -1
  94. data/lib/badline/frontend/{sid_view.rb → player/sid_view.rb} +16 -20
  95. data/lib/badline/frontend/player/theme.rb +18 -0
  96. data/lib/badline/frontend/{visualizer_view.rb → player/visualizer_view.rb} +8 -8
  97. data/lib/badline/frontend/sound.rb +19 -38
  98. data/lib/badline/frontend/timeline.rb +3 -14
  99. data/lib/badline/frontend.rb +24 -20
  100. data/lib/badline/iec_bus.rb +14 -2
  101. data/lib/badline/machine.rb +5 -4
  102. data/lib/badline/media/boot_disk.rb +50 -0
  103. data/lib/badline/media/not_disk.rb +8 -0
  104. data/lib/badline/media/true_drive.rb +35 -15
  105. data/lib/badline/media.rb +9 -3
  106. data/lib/badline/options/event.rb +2 -2
  107. data/lib/badline/options/family.rb +3 -9
  108. data/lib/badline/options/help.rb +2 -1
  109. data/lib/badline/roms/README +9 -0
  110. data/lib/badline/roms/dos1581.rom +0 -0
  111. data/lib/badline/sdl.rb +37 -20
  112. data/lib/badline/snapshot/state.rb +5 -4
  113. data/lib/badline/storage/p00.rb +8 -0
  114. data/lib/badline/time_of_day.rb +3 -3
  115. data/lib/badline/version.rb +1 -1
  116. data/lib/badline/vic20/attachments.rb +21 -7
  117. data/lib/badline/vic20/saved_state.rb +2 -0
  118. data/lib/badline/vic20.rb +3 -0
  119. data/lib/badline/z80/core.rb +2 -0
  120. data/lib/badline/z80/saved_state.rb +69 -0
  121. metadata +54 -21
  122. /data/lib/badline/frontend/{menu_ports.rb → menu/menu_ports.rb} +0 -0
  123. /data/lib/badline/frontend/{player_commands.rb → player/player_commands.rb} +0 -0
  124. /data/lib/badline/frontend/{player_state.rb → player/player_state.rb} +0 -0
  125. /data/lib/badline/frontend/{sid_history.rb → player/sid_history.rb} +0 -0
@@ -16,6 +16,7 @@ module Badline
16
16
  # clocks it against the host and sleeps through the DOS's idle loop.
17
17
  class Drive1541
18
18
  include Drive::Core
19
+ include Drive::VIAs
19
20
 
20
21
  CLOCK_HZ = 1_000_000
21
22
 
@@ -2,32 +2,118 @@
2
2
 
3
3
  module Badline
4
4
  class Drive1571
5
- # The WD1770 floppy controller at $2000, which the DOS needs only for
6
- # MFM disks. Its four registers hold what's written to them and it
7
- # runs no commands: the status reads idle, with the motor off.
5
+ # The WD1770 floppy controller at $2000, which DOS 3.0 drives for MFM
6
+ # disks. The 1571 here only ever holds GCR disks, so the controller
7
+ # never finds an MFM sector: it runs each command for its time and ends
8
+ # it as the WD177x data sheet does on a disk without one.
8
9
  #
9
10
  # $2000 status (read), command (write)
10
11
  # $2001 track
11
12
  # $2002 sector
12
13
  # $2003 data
14
+ #
15
+ # BUSY sets as a command is written. A restore, seek or step (type I)
16
+ # moves the track register and ends a few milliseconds later, with
17
+ # TRACK 0 reported at track 0. A sector read or write, a read address
18
+ # or a read track ends with RECORD NOT FOUND after five turns of the
19
+ # disk, and a write track after one, with nothing written. A force
20
+ # interrupt ends the command at once. Time is the drive CPU's cycles.
13
21
  class WD1770
22
+ BUSY = 0x01
23
+ TRACK0 = 0x04
24
+ NOT_FOUND = 0x10
25
+ MOTOR_ON = 0x80
26
+
27
+ # Cycles at 2 MHz, the 1571's clock in 1571 mode: a turn of the disk
28
+ # at 300 rpm, and a step with the head settling.
29
+ TURN = 400_000
30
+ STEP = 12_000
31
+
32
+ # The CPU whose cycles time the commands.
33
+ attr_writer :cpu
34
+
14
35
  def initialize
15
36
  @registers = [0, 0, 0, 0]
37
+ @status = 0
38
+ @due = 0
39
+ @result = 0
40
+ @cpu = nil
16
41
  end
17
42
 
18
- # Register +offset+, 0 to 3.
19
- def peek(offset) = offset.zero? ? 0 : @registers[offset]
43
+ # Register +offset+, 0 to 3: the status, settled to the command's end
44
+ # once its time has run out.
45
+ def peek(offset)
46
+ return @registers[offset] unless offset.zero?
47
+
48
+ finish if @status.anybits?(BUSY) && @cpu.cycles >= @due
49
+ @status
50
+ end
20
51
 
21
52
  def poke(offset, value)
22
- @registers[offset] = value
53
+ offset.zero? ? command(value) : @registers[offset] = value
23
54
  end
24
55
 
25
56
  def save_state(out)
26
- out.ints(@registers)
57
+ out.ints([*@registers, @status, @due, @result])
27
58
  end
28
59
 
60
+ # A state from before the controller ran commands holds the four
61
+ # registers alone.
29
62
  def load_state(input)
30
- @registers = input.ints
63
+ values = input.ints
64
+ @registers = values[0, 4]
65
+ @status = values[4] || 0
66
+ @due = values[5] || 0
67
+ @result = values[6] || 0
68
+ end
69
+
70
+ private
71
+
72
+ def command(value)
73
+ return interrupt if (value & 0xf0) == 0xd0
74
+
75
+ @registers[0] = value
76
+ value < 0x80 ? step(value) : search(value)
77
+ end
78
+
79
+ # A restore ($0x), seek ($1x) or step ($2x to $7x), its track register
80
+ # moved at once.
81
+ def step(value)
82
+ track = @registers[1]
83
+ target = case value >> 4
84
+ when 0 then 0
85
+ when 1 then @registers[3]
86
+ else track + step_direction(value)
87
+ end
88
+ @registers[1] = target.clamp(0, 255) if value < 0x20 || value.anybits?(0x10)
89
+ start(STEP * [(target - track).abs, 1].max, @registers[1].zero? ? TRACK0 : 0)
90
+ end
91
+
92
+ # Step in ($4x) moves toward higher tracks, step out ($6x) toward
93
+ # track 0, and step ($2x) the way the last one went.
94
+ def step_direction(value)
95
+ @direction = 1 if (value >> 5) == 2
96
+ @direction = -1 if (value >> 5) == 3
97
+ @direction || 1
98
+ end
99
+
100
+ # A sector or track command that finds no MFM sector.
101
+ def search(value)
102
+ value >= 0xf0 ? start(TURN, 0) : start(TURN * 5, NOT_FOUND)
103
+ end
104
+
105
+ def start(cycles, result)
106
+ @status = BUSY | MOTOR_ON
107
+ @due = @cpu.cycles + cycles
108
+ @result = result
109
+ end
110
+
111
+ def finish
112
+ @status = @result
113
+ end
114
+
115
+ def interrupt
116
+ @status &= ~BUSY & 0xff
31
117
  end
32
118
  end
33
119
  end
@@ -19,15 +19,13 @@ module Badline
19
19
  # same speed either way (Drive1541::Mechanism#clock_hz=).
20
20
  #
21
21
  # The CIA sits at $4000, on the IRQ line with both VIAs. Only its serial
22
- # port is wired: through buffers PA1 turns round, its CNT drives SRQ and
23
- # its SP DATA, or it hears them. The drive pushes its pins into the bus
24
- # as they move, and hears the host's as the host pushes them, pin by
25
- # pin, so each byte goes through both shift registers bit by bit. The
26
- # CIA takes no part in a pass of the idle loop: the drive sleeps only
27
- # while the CIA is quiet (CIA#quiet?), and any access to it, or the
28
- # host moving its fast serial pins, wakes the drive.
22
+ # port is wired, for fast serial through buffers PA1 turns round
23
+ # (Drive::FastSerial). The drive sleeps only while the CIA is quiet
24
+ # (CIA#quiet?).
29
25
  class Drive1571
30
26
  include Drive::Core
27
+ include Drive::VIAs
28
+ include Drive::FastSerial
31
29
 
32
30
  CLOCK_HZ = 1_000_000
33
31
  FAST_CLOCK_HZ = 2_000_000
@@ -40,10 +38,6 @@ module Badline
40
38
  SIDE = 0x04
41
39
  FAST = 0x20
42
40
 
43
- # Whether PA1 turns the fast serial buffers outwards, the drive's CIA
44
- # driving DATA and SRQ.
45
- attr_reader :fast_serial_out
46
-
47
41
  def cia
48
42
  settle!
49
43
  @cia
@@ -62,8 +56,7 @@ module Badline
62
56
  def initialize(rom: nil, host_clock_hz: CLOCK_HZ, device: 8, debug: false)
63
57
  @device = device
64
58
  @clock_hz = CLOCK_HZ
65
- @fast_serial_out = false
66
- @fast_output = 0
59
+ init_fast_serial
67
60
  @mechanism = Drive1541::Mechanism.new(slip: 2)
68
61
  @serial_port = SerialPort.new(@mechanism, device:)
69
62
  @via1 = SerialVIA.new(start: 0x1800, peripheral: @serial_port, drive: self)
@@ -72,6 +65,7 @@ module Badline
72
65
  @fdc = WD1770.new
73
66
  @bus = Bus.new(rom: rom || ROM.load("dos1571.rom", 0x8000), via1: @via1, via2: @via2, cia: @cia, fdc: @fdc)
74
67
  @cpu = CPU.new(@bus, debug:)
68
+ @fdc.cpu = @cpu
75
69
  @idle_loop = IDLE_LOOP
76
70
  @host_clock_hz = host_clock_hz
77
71
  @phase = 0
@@ -114,13 +108,6 @@ module Badline
114
108
  # (IECBus::DRIVE_FAST_DATA and DRIVE_FAST_SRQ) above it.
115
109
  def serial_output = @via1.port_b_output | @fast_output
116
110
 
117
- # The host moved its fast serial pins: the CIA hears SRQ on CNT and
118
- # DATA on SP while PA1 turns the buffers inwards.
119
- def fast_lines_moved
120
- settle!
121
- hear_fast_serial unless @fast_serial_out
122
- end
123
-
124
111
  # The drive's whole state, as the 1541's (Drive1541#save_state), with
125
112
  # the CIA and the WD1770's registers. The clock rate, the side and the
126
113
  # fast serial direction follow from VIA 1's port A.
@@ -181,45 +168,8 @@ module Badline
181
168
  @cia.quiet? ? [@via1.quiet_cycles, @via2.quiet_cycles].min : 0
182
169
  end
183
170
 
184
- def fast_forward_chips(cycles)
171
+ def fast_forward_more(cycles)
185
172
  @cia.fast_forward(cycles)
186
173
  end
187
-
188
- # PA1 turned the buffers round: outwards the CIA's pins drive the
189
- # lines, and inwards they let go and hear them.
190
- def turn_fast_serial(out)
191
- @fast_serial_out = out
192
- return unless @serial_bus
193
-
194
- push_fast_output(out ? fast_pins : 0)
195
- hear_fast_serial unless out
196
- end
197
-
198
- def drive_fast_serial
199
- low = fast_pins
200
- push_fast_output(low) if low != @fast_output
201
- end
202
-
203
- # The lines the CIA's CNT and SP pull while its serial port drives
204
- # them.
205
- def fast_pins
206
- serial = @cia.serial
207
- return 0 unless serial.output?
208
-
209
- low = serial.cnt ? 0 : IECBus::DRIVE_FAST_SRQ
210
- serial.sp_out ? low : low | IECBus::DRIVE_FAST_DATA
211
- end
212
-
213
- def push_fast_output(low)
214
- @fast_output = low
215
- @serial_bus&.drives_fast_moved!
216
- end
217
-
218
- def hear_fast_serial
219
- low = @serial_bus.low_lines
220
- serial = @cia.serial
221
- serial.cnt_in = low.nobits?(IECBus::SRQ)
222
- serial.sp_in = low.nobits?(IECBus::DATA)
223
- end
224
174
  end
225
175
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1581
5
+ # The drive CPU's bus, as the 74LS139 decodes A13-A15 (1581 Service
6
+ # Manual, memory map and schematic sheet 1):
7
+ #
8
+ # $0000-$1FFF - 8 KB of RAM
9
+ # $2000-$3FFF - nothing: open bus
10
+ # $4000-$5FFF - the 8520, its sixteen registers mirrored
11
+ # $6000-$7FFF - the WD1772, its four registers mirrored
12
+ # $8000-$FFFF - the DOS ROM
13
+ #
14
+ # Writes to the ROM and to open bus go nowhere, and a read from open
15
+ # bus finds the last byte on the data bus, as on the 1541. A write to
16
+ # either of the 8520's ports or their direction registers moves the
17
+ # drive's lines (Drive1581#ports_written).
18
+ #
19
+ # While Idle records a pass of the DOS's idle loop, the bus watches it
20
+ # (watch!): the RAM it reads and writes, and whether it touched the
21
+ # 8520 or the WD1772, either of which makes the pass volatile. While
22
+ # Idle looks for an orbit, the bus guards the drive (guard!), and any
23
+ # access to either chip taints it.
24
+ class Bus
25
+ include Addressable
26
+
27
+ attr_reader :ram, :rom, :cia, :fdc, :data, :volatile, :touched, :tainted, :touched_timers
28
+
29
+ def initialize(rom:, cia:, fdc:, drive:)
30
+ addressable_at(0, length: 2**16)
31
+ @ram = Memory.new([], length: 0x2000, start: 0)
32
+ @rom = rom
33
+ @cia = cia
34
+ @fdc = fdc
35
+ @drive = drive
36
+ @data = 0
37
+ @recording = @guarding = @watching = false
38
+ @volatile = @tainted = false
39
+ @touched = {}
40
+ @touched_timers = 0
41
+ end
42
+
43
+ # The last byte on the data bus and the RAM. What a watched stretch
44
+ # touched is Idle's, and a restored bus isn't watching.
45
+ def save_state(out)
46
+ out.int(@data)
47
+ @ram.save_state(out)
48
+ end
49
+
50
+ def load_state(input)
51
+ @data = input.int
52
+ @ram.load_state(input)
53
+ @watching = false
54
+ end
55
+
56
+ def peek(addr)
57
+ return @data = @rom.peek(addr) if addr >= 0x8000
58
+ return @data = chip_peek(addr) if addr >= 0x2000
59
+
60
+ touch(addr) if @recording
61
+ @data = @ram.peek(addr)
62
+ end
63
+
64
+ def poke(addr, value)
65
+ @data = value
66
+ return if addr >= 0x8000
67
+ return chip_poke(addr, value) if addr >= 0x2000
68
+
69
+ touch(addr) if @recording
70
+ @ram.poke(addr, value)
71
+ end
72
+
73
+ # Starts watching: see volatile and touched.
74
+ def watch!
75
+ @recording = true
76
+ @volatile = false
77
+ @touched = {}
78
+ watching!
79
+ end
80
+
81
+ def unwatch!
82
+ @recording = false
83
+ watching!
84
+ end
85
+
86
+ # Starts guarding: see tainted.
87
+ def guard!
88
+ @guarding = true
89
+ @tainted = false
90
+ watching!
91
+ end
92
+
93
+ private
94
+
95
+ def chip_peek(addr)
96
+ watch_chip if @watching
97
+ if addr >= 0x6000
98
+ @fdc.peek(addr & 0x03)
99
+ elsif addr >= 0x4000
100
+ @cia.peek(0x4000 | (addr & 0x0f))
101
+ else
102
+ @data
103
+ end
104
+ end
105
+
106
+ def chip_poke(addr, value)
107
+ watch_chip if @watching
108
+ if addr >= 0x6000
109
+ @fdc.poke(addr & 0x03, value)
110
+ elsif addr >= 0x4000
111
+ @cia.poke(0x4000 | (addr & 0x0f), value)
112
+ @drive.ports_written if addr.nobits?(0x0c)
113
+ end
114
+ end
115
+
116
+ def touch(addr)
117
+ @touched[addr] = @ram.peek(addr) unless @touched.key?(addr)
118
+ end
119
+
120
+ def watching!
121
+ @watching = @recording || @guarding
122
+ end
123
+
124
+ def watch_chip
125
+ if @recording
126
+ @volatile = true
127
+ @recording = false
128
+ end
129
+ if @guarding
130
+ @tainted = true
131
+ @guarding = false
132
+ end
133
+ watching!
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1581
5
+ # The drive's 6502, on the drive's Bus.
6
+ class CPU
7
+ include Badline::CPU::Core
8
+ include Drive::IdleCPU
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/drive1581/disk_state"
4
+
5
+ module Badline
6
+ class Drive1581
7
+ # A 3.5" double-sided disk as the WD1772 sees it: a Track on each side
8
+ # of 80 cylinders, or none where it was never formatted.
9
+ #
10
+ # A .d81 image holds the 1581 DOS's logical blocks in order, 40 of 256
11
+ # bytes on each logical track 1 to 80 (1581 Service Manual, format
12
+ # organization). Logical track t is cylinder t - 1, and the DOS puts
13
+ # its sectors 0 to 19 on side 0 and 20 to 39 on side 1, two to each
14
+ # 512-byte physical sector, numbered from 1. So physical sector s on
15
+ # side h of cylinder c holds logical sectors 20h + 2(s - 1) and the one
16
+ # after it, and every cylinder of the image is a standard Track
17
+ # (Track.standard) on both sides. A block the image's error table
18
+ # marks 20 or 21 loses its sector's ID field, 27 spoils the ID's CRC,
19
+ # 22 loses the data field and 23 spoils its CRC.
20
+ #
21
+ # The WD1772 writes into the tracks, and flush stores the standard
22
+ # sectors of each one written back in the image, which writes its host
23
+ # file. A track formatted another way stays on the disk alone. The disk
24
+ # is write-protected when the image won't take writes.
25
+ class Disk
26
+ include State
27
+
28
+ CYLINDERS = 80
29
+ SIDES = 2
30
+
31
+ # Logical blocks a side of a cylinder holds.
32
+ SIDE_BLOCKS = 20
33
+
34
+ # A logical block's bytes, and how many a physical sector holds.
35
+ BLOCK_SIZE = 256
36
+ BLOCKS = Track::SECTOR_SIZE / BLOCK_SIZE
37
+
38
+ # A disk from the .d81 image at +path+. `read_only` opens the image
39
+ # write-protected.
40
+ def self.open(path, read_only: false)
41
+ new(Storage::D81Image.new(path, read_only:)).opened(path, read_only)
42
+ end
43
+
44
+ attr_reader :image
45
+
46
+ def initialize(image = nil)
47
+ @image = image
48
+ @tracks = Array.new(CYLINDERS * SIDES)
49
+ @written = {}
50
+ @changed = {}
51
+ @path = nil
52
+ @read_only = false
53
+ end
54
+
55
+ # The Track on +side+ of +cylinder+, or nil where there is none: past
56
+ # the image's cylinders, or on a disk without an image that the head
57
+ # never formatted.
58
+ def track(cylinder, side)
59
+ index = (cylinder * SIDES) + side
60
+ return @tracks[index] if @tracks[index] || !@image || cylinder >= CYLINDERS
61
+
62
+ @tracks[index] = image_track(cylinder, side)
63
+ end
64
+
65
+ # A write track laid +track+ down on +side+ of +cylinder+, or a write
66
+ # sector changed it.
67
+ def write(cylinder, side, track)
68
+ return if cylinder >= CYLINDERS
69
+
70
+ index = (cylinder * SIDES) + side
71
+ @tracks[index] = track
72
+ @written[index] = true
73
+ @changed[index] = true
74
+ end
75
+
76
+ # Whether the write-protect hole is open: the disk's image won't take
77
+ # writes, or a disk without an image was opened read-only.
78
+ def write_protected? = @image.nil? ? @read_only : !@image.writable?
79
+
80
+ # Whether any track was written since the last flush.
81
+ def written? = !@written.empty?
82
+
83
+ # Stores the standard sectors of each track written since the last
84
+ # flush in the image, all in one write to its host file.
85
+ def flush
86
+ written = @written.keys.sort
87
+ @written.clear
88
+ return if @image.nil? || written.empty?
89
+
90
+ @image.store_blocks(written.flat_map { |index| track_blocks(index / SIDES, index % SIDES) })
91
+ end
92
+
93
+ private
94
+
95
+ def image_track(cylinder, side)
96
+ track = Track.standard(cylinder, side) { |sector| physical_block(cylinder, side, sector) }
97
+ track.sectors.each { |sector| mark_errors(cylinder, side, sector) }
98
+ track.sectors.reject! { |sector| sector.id.nil? }
99
+ track
100
+ end
101
+
102
+ # The logical track and first sector physical sector +sector+ holds.
103
+ def logical(cylinder, side, sector) = [cylinder + 1, (side * SIDE_BLOCKS) + ((sector - 1) * BLOCKS)]
104
+
105
+ def physical_block(cylinder, side, sector)
106
+ track, first = logical(cylinder, side, sector)
107
+ Array.new(BLOCKS) { |i| @image.read_block(track, first + i) || [] }.flatten
108
+ end
109
+
110
+ def mark_errors(cylinder, side, sector)
111
+ track, first = logical(cylinder, side, sector.id.bytes[2])
112
+ errors = Array.new(BLOCKS) { |i| @image.block_error(track, first + i) }
113
+ sector.id.good = false if errors.include?(27)
114
+ sector.data.good = false if errors.include?(23)
115
+ sector.data = nil if errors.include?(22)
116
+ sector.id = nil if errors.include?(20) || errors.include?(21)
117
+ end
118
+
119
+ # [track, sector, data, nil] for DiskImage#store_blocks, for each
120
+ # logical block a standard sector of the track holds.
121
+ def track_blocks(cylinder, side)
122
+ track = @tracks[(cylinder * SIDES) + side]
123
+ Track::SECTORS.times.flat_map do |index|
124
+ data = track.sector([cylinder, side, index + 1, Track::SIZE_CODE])&.data
125
+ next [] unless data
126
+
127
+ logical_track, first = logical(cylinder, side, index + 1)
128
+ Array.new(BLOCKS) do |i|
129
+ [logical_track, first + i, data.bytes[i * BLOCK_SIZE, BLOCK_SIZE], nil]
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1581
5
+ class Disk
6
+ # Saving and restoring a Disk for a snapshot: the image by its path,
7
+ # and every track the head wrote since the disk went in, with those
8
+ # written since the last flush. The rest come from the image, read
9
+ # again from its host file where there still is one.
10
+ module State
11
+ # The image's path, expanded, and whether it was opened read-only,
12
+ # for a disk from Disk.open. Nil for a disk made another way.
13
+ attr_reader :path, :read_only
14
+
15
+ # The disk a state from save_state describes: +current+ when it was
16
+ # opened from the same image the same way, and otherwise a disk for
17
+ # the image at the path the state names. Where no file is at that
18
+ # path, the disk has no image and is write-protected, and a
19
+ # detached reader always gets one without an image.
20
+ def self.load(input, current)
21
+ path = input.optional_string
22
+ read_only = input.boolean?
23
+ disk = current if current && !path.nil? && current.path == path && current.read_only == read_only
24
+ disk ||= fresh(path, read_only, input.detached?)
25
+ disk.load_tracks(input)
26
+ disk
27
+ end
28
+
29
+ def self.fresh(path, read_only, detached)
30
+ return Disk.new if path.nil? || detached
31
+ return Disk.new.opened(path, true) unless File.file?(path)
32
+
33
+ Disk.open(path, read_only:)
34
+ end
35
+
36
+ # Notes the image's path and how it was opened.
37
+ def opened(path, read_only)
38
+ @path = File.expand_path(path)
39
+ @read_only = read_only
40
+ self
41
+ end
42
+
43
+ def save_state(out)
44
+ out.optional_string(@path).boolean(@read_only)
45
+ changed = @changed.keys.sort
46
+ out.ints(changed)
47
+ changed.each { |index| save_track(@tracks[index], out) }
48
+ out.ints(@written.keys)
49
+ end
50
+
51
+ def load_tracks(input)
52
+ @changed.clear
53
+ input.ints.each do |index|
54
+ @tracks[index] = load_track(input)
55
+ @changed[index] = true
56
+ end
57
+ @written.clear
58
+ input.ints.each { |index| @written[index] = true }
59
+ end
60
+
61
+ private
62
+
63
+ def save_track(track, out)
64
+ out.int(track.sectors.length)
65
+ track.sectors.each do |sector|
66
+ save_field(sector.id, out)
67
+ out.boolean(!sector.data.nil?)
68
+ save_field(sector.data, out) if sector.data
69
+ end
70
+ end
71
+
72
+ def save_field(field, out)
73
+ out.int(field.mark).ints(field.bytes).boolean(field.good).boolean(field.deleted)
74
+ end
75
+
76
+ def load_track(input)
77
+ Track.new(Array.new(input.int) do
78
+ id = load_field(input)
79
+ Track::Sector.new(id, input.boolean? ? load_field(input) : nil)
80
+ end)
81
+ end
82
+
83
+ def load_field(input) = Track::Field.new(input.int, input.ints, input.boolean?, input.boolean?)
84
+ end
85
+ end
86
+ end
87
+ end