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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +32 -27
- data/CONTRIBUTING.md +5 -0
- data/README.md +55 -24
- data/doc/pinned-behaviour.md +223 -0
- data/exe/badline-ruby +7 -4
- data/lib/badline/address_bus/ultimax_pages.rb +38 -0
- data/lib/badline/address_bus.rb +19 -29
- data/lib/badline/audio/jukebox.rb +6 -4
- data/lib/badline/audio/machine_player.rb +10 -6
- data/lib/badline/cartridge/kcs_power.rb +3 -3
- data/lib/badline/cia.rb +21 -3
- data/lib/badline/computer.rb +86 -7
- data/lib/badline/cpu.rb +39 -0
- data/lib/badline/drive1541/bus.rb +197 -0
- data/lib/badline/drive1541/disk.rb +256 -0
- data/lib/badline/drive1541/disk_via.rb +33 -0
- data/lib/badline/drive1541/gcr.rb +57 -0
- data/lib/badline/drive1541/idle.rb +136 -0
- data/lib/badline/drive1541/mechanism.rb +277 -0
- data/lib/badline/drive1541/orbit.rb +81 -0
- data/lib/badline/drive1541/rotation.rb +111 -0
- data/lib/badline/drive1541/sector_reader.rb +76 -0
- data/lib/badline/drive1541/serial_port.rb +67 -0
- data/lib/badline/drive1541/sleep.rb +114 -0
- data/lib/badline/drive1541/track.rb +98 -0
- data/lib/badline/drive1541.rb +234 -0
- data/lib/badline/gui/application.rb +15 -3
- data/lib/badline/gui/drive_led_pane.rb +29 -0
- data/lib/badline/gui.rb +1 -0
- data/lib/badline/iec_bus.rb +109 -0
- data/lib/badline/kernal_trap/channel.rb +9 -1
- data/lib/badline/kernal_trap/serial.rb +9 -3
- data/lib/badline/media/true_drive.rb +54 -0
- data/lib/badline/media.rb +22 -1
- data/lib/badline/memory.rb +3 -0
- data/lib/badline/options.rb +24 -4
- data/lib/badline/region.rb +35 -10
- data/lib/badline/reu/dma.rb +228 -0
- data/lib/badline/reu/ram.rb +65 -0
- data/lib/badline/reu/trigger.rb +32 -0
- data/lib/badline/reu.rb +276 -0
- data/lib/badline/roms/README +10 -0
- data/lib/badline/roms/dos1541.rom +0 -0
- data/lib/badline/sdl/functions.rb +1 -0
- data/lib/badline/storage/d64_image.rb +18 -9
- data/lib/badline/storage/d81_image.rb +1 -0
- data/lib/badline/storage/disk_image/catalog.rb +52 -0
- data/lib/badline/storage/disk_image/writing.rb +16 -0
- data/lib/badline/storage/disk_image.rb +2 -0
- data/lib/badline/storage/g64_image.rb +222 -0
- data/lib/badline/storage/host_directory.rb +21 -1
- data/lib/badline/storage/listing.rb +118 -0
- data/lib/badline/storage/t64.rb +14 -0
- data/lib/badline/storage.rb +2 -0
- data/lib/badline/traps.rb +4 -0
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +3 -0
- data/lib/badline/via/fast_forward.rb +53 -0
- data/lib/badline/via/shift_register.rb +8 -3
- data/lib/badline/via/timer1.rb +36 -0
- data/lib/badline/via/timer2.rb +31 -0
- data/lib/badline/via.rb +2 -0
- data/lib/badline/vic/display_state.rb +7 -7
- data/lib/badline/vic/sprite/internal_bus.rb +11 -7
- data/lib/badline/vic/sprite/shifter.rb +10 -15
- data/lib/badline/vic/sprite/timing.rb +60 -0
- data/lib/badline/vic/sprite.rb +14 -12
- data/lib/badline/vic/sprites.rb +2 -2
- data/lib/badline/vic.rb +86 -39
- data/lib/badline.rb +3 -0
- metadata +28 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
class Drive1541
|
|
5
|
+
# The drive asleep, as Idle and Orbit let it: host_cycle! counts the
|
|
6
|
+
# host cycles that go by, and waking turns them into drive cycles
|
|
7
|
+
# through the phase accumulator and runs them. Whole orbits and whole
|
|
8
|
+
# passes go in bulk, and the rest cycle by cycle, which may find the
|
|
9
|
+
# idle loop and put the drive back to sleep.
|
|
10
|
+
#
|
|
11
|
+
# The drive looks at ATN only when the C64 may have moved it, on a
|
|
12
|
+
# bus that says when (IECBus#notifies?), and every host cycle on one
|
|
13
|
+
# that doesn't.
|
|
14
|
+
module Sleep
|
|
15
|
+
# Whether the drive is skipping its idle loop right now.
|
|
16
|
+
def asleep? = @asleep
|
|
17
|
+
|
|
18
|
+
# Brings the drive up to the present, cycle for cycle, and drops the
|
|
19
|
+
# pass it was recording and the orbits it found, since whoever called
|
|
20
|
+
# may change it. Every reader of the drive's parts calls this.
|
|
21
|
+
def settle!
|
|
22
|
+
if @asleep
|
|
23
|
+
wake!
|
|
24
|
+
@serial_port.latch_host
|
|
25
|
+
@asleep = false
|
|
26
|
+
@orbit_cycles = nil
|
|
27
|
+
end
|
|
28
|
+
stop_recording if @recording
|
|
29
|
+
forget_orbits
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def init_sleep
|
|
35
|
+
@asleep = false
|
|
36
|
+
@host_still = false
|
|
37
|
+
@owed = 0
|
|
38
|
+
@budget = 0
|
|
39
|
+
@slept = 0
|
|
40
|
+
@wake_at = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A host cycle asleep that may wake the drive. The bus says whether
|
|
44
|
+
# the next ones need a look at ATN.
|
|
45
|
+
def doze
|
|
46
|
+
@host_still = @serial_bus.notifies?
|
|
47
|
+
if @serial_port.atn_moved?
|
|
48
|
+
settle!
|
|
49
|
+
elsif @slept == @wake_at
|
|
50
|
+
wake!
|
|
51
|
+
@serial_port.latch_host
|
|
52
|
+
plan_wake if @asleep
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Asleep, host_cycle! counts host cycles, and leaves it to wake! to
|
|
57
|
+
# turn them into drive cycles through the phase accumulator. This sets
|
|
58
|
+
# the host cycle that brings the drive cycles owed up to the budget.
|
|
59
|
+
# On an orbit the drive sleeps until something outside wakes it:
|
|
60
|
+
# @slept counts from 1, so it never comes to 0.
|
|
61
|
+
def plan_wake
|
|
62
|
+
@slept = 0
|
|
63
|
+
return @wake_at = 0 if @orbit_cycles
|
|
64
|
+
|
|
65
|
+
needed = ((@budget - @owed) * @host_clock_hz) - @phase
|
|
66
|
+
@wake_at = needed.positive? ? (needed + CLOCK_HZ - 1) / CLOCK_HZ : 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Runs the cycles owed.
|
|
70
|
+
def wake!
|
|
71
|
+
phase = @phase + (@slept * CLOCK_HZ)
|
|
72
|
+
@phase = phase % @host_clock_hz
|
|
73
|
+
owed = @owed + (phase / @host_clock_hz)
|
|
74
|
+
@owed = 0
|
|
75
|
+
@slept = 0
|
|
76
|
+
catch_up(owed)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Runs +owed+ drive cycles: whole orbits and whole passes in bulk,
|
|
80
|
+
# passes only up to the budget, and the rest cycle by cycle, which
|
|
81
|
+
# may put the drive back to sleep.
|
|
82
|
+
def catch_up(owed)
|
|
83
|
+
while owed.positive?
|
|
84
|
+
if @asleep
|
|
85
|
+
if @orbit_cycles && owed >= @orbit_cycles
|
|
86
|
+
owed -= skip_orbits(owed / @orbit_cycles)
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
@orbit_cycles = nil
|
|
90
|
+
passes = [owed, @budget].min / @pass_cycles
|
|
91
|
+
if passes.positive?
|
|
92
|
+
owed -= skip_passes(passes)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
@asleep = false
|
|
96
|
+
end
|
|
97
|
+
run_cycle
|
|
98
|
+
owed -= 1
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Runs whole passes, moving the counters on. Returns the cycles.
|
|
103
|
+
def skip_passes(passes)
|
|
104
|
+
cycles = passes * @pass_cycles
|
|
105
|
+
@cpu.fast_forward(cycles, passes * @pass_instructions)
|
|
106
|
+
@via1.fast_forward(cycles)
|
|
107
|
+
@via2.fast_forward(cycles)
|
|
108
|
+
@cycles += cycles
|
|
109
|
+
@budget -= cycles
|
|
110
|
+
cycles
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
class Drive1541
|
|
5
|
+
# One ring of flux on the disk, as the GCR bytes the head reads from it,
|
|
6
|
+
# most significant bit first, and the speed zone it was written in
|
|
7
|
+
# (0-3). The bytes wrap around: the last one's bits run into the
|
|
8
|
+
# first's. A track is as long as its bytes, which is what a .g64 image
|
|
9
|
+
# stores for each half track.
|
|
10
|
+
#
|
|
11
|
+
# The disk turns at 300 rpm, so every track passes under the head in
|
|
12
|
+
# the same 200 ms, and its bits pass at the rate they were written at.
|
|
13
|
+
# Each bit is a cell of time, starting at the track's index angle with
|
|
14
|
+
# the first byte's top bit, and a 1 bit is a flux transition at the
|
|
15
|
+
# start of its cell. The cells are spread evenly over the turn, so a
|
|
16
|
+
# track as long as a turn holds at its zone's rate
|
|
17
|
+
# (Disk::TRACK_LENGTHS) passes at that rate, within the part of a byte
|
|
18
|
+
# the length rounds off, and a longer or shorter one faster or slower.
|
|
19
|
+
# A .g64 speed map gives each byte the zone it was written in, and the
|
|
20
|
+
# cells of each byte are as wide as their zone's, scaled together to
|
|
21
|
+
# fill the turn.
|
|
22
|
+
#
|
|
23
|
+
# Times are in TICKs, 1/65536 of a tick of the drive's 16 MHz crystal,
|
|
24
|
+
# so the cells add up to a turn within a tick.
|
|
25
|
+
class Track
|
|
26
|
+
TICK = 1 << 16
|
|
27
|
+
TURN = 200_000 * 16 * TICK
|
|
28
|
+
|
|
29
|
+
# A bit cell at the zone's rate, in TICKs.
|
|
30
|
+
def self.cell(zone) = 4 * (16 - zone) * TICK
|
|
31
|
+
|
|
32
|
+
attr_reader :bytes, :zone, :speeds, :width, :widths
|
|
33
|
+
|
|
34
|
+
# +speeds+ holds each byte's zone, where the track's rate changes
|
|
35
|
+
# along it.
|
|
36
|
+
def initialize(bytes, zone, speeds = nil)
|
|
37
|
+
@bytes = bytes
|
|
38
|
+
@zone = zone
|
|
39
|
+
@speeds = speeds
|
|
40
|
+
@widths = speeds && scaled_widths(speeds)
|
|
41
|
+
@width = speeds ? @widths[0] : TURN / (bytes.length * 8)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def length = @bytes.length
|
|
45
|
+
|
|
46
|
+
# Whether the whole track was written at the +zone+'s rate.
|
|
47
|
+
def written_at?(zone) = @widths.nil? && @zone == zone
|
|
48
|
+
|
|
49
|
+
# The bit cell under the head +time+ TICKs into the turn, as
|
|
50
|
+
# [byte, mask, the time the cell ends].
|
|
51
|
+
def cell_at(time)
|
|
52
|
+
start = 0
|
|
53
|
+
index = 0
|
|
54
|
+
if @widths
|
|
55
|
+
while index < length - 1 && time >= start + (@widths[index] * 8)
|
|
56
|
+
start += @widths[index] * 8
|
|
57
|
+
index += 1
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
index = [time / (@width * 8), length - 1].min
|
|
61
|
+
start = index * @width * 8
|
|
62
|
+
end
|
|
63
|
+
width = @widths ? @widths[index] : @width
|
|
64
|
+
bit = [(time - start) / width, 7].min
|
|
65
|
+
cell_end = index == length - 1 && bit == 7 ? TURN : start + ((bit + 1) * width)
|
|
66
|
+
[index, 0x80 >> bit, cell_end]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The track laid out again as one turn at the +zone+'s rate, each
|
|
70
|
+
# flux transition in the cell under it.
|
|
71
|
+
def relaid(zone)
|
|
72
|
+
bits = Disk::TRACK_LENGTHS[zone] * 8
|
|
73
|
+
cell = TURN / bits
|
|
74
|
+
relaid = Array.new(bits / 8, 0)
|
|
75
|
+
time = 0
|
|
76
|
+
@bytes.each_with_index do |byte, index|
|
|
77
|
+
width = @widths ? @widths[index] : @width
|
|
78
|
+
8.times do |bit|
|
|
79
|
+
if byte.anybits?(0x80 >> bit)
|
|
80
|
+
cell_index = [time / cell, bits - 1].min
|
|
81
|
+
relaid[cell_index >> 3] |= 0x80 >> (cell_index & 7)
|
|
82
|
+
end
|
|
83
|
+
time += width
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
Track.new(relaid, zone)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def scaled_widths(speeds)
|
|
92
|
+
units = 0
|
|
93
|
+
speeds.each { |zone| units += 16 - zone }
|
|
94
|
+
speeds.map { |zone| TURN * (16 - zone) / (units * 8) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "badline/drive1541/bus"
|
|
4
|
+
require "badline/drive1541/serial_port"
|
|
5
|
+
require "badline/drive1541/gcr"
|
|
6
|
+
require "badline/drive1541/track"
|
|
7
|
+
require "badline/drive1541/disk"
|
|
8
|
+
require "badline/drive1541/mechanism"
|
|
9
|
+
require "badline/drive1541/disk_via"
|
|
10
|
+
require "badline/drive1541/sleep"
|
|
11
|
+
require "badline/drive1541/idle"
|
|
12
|
+
require "badline/drive1541/orbit"
|
|
13
|
+
|
|
14
|
+
module Badline
|
|
15
|
+
# The 1541 disk drive as a machine of its own: a 6502 running the DOS
|
|
16
|
+
# ROM at 1 MHz, with 2 KB of RAM and two VIAs on its bus. VIA 1 faces
|
|
17
|
+
# the serial bus and VIA 2 the disk mechanism.
|
|
18
|
+
#
|
|
19
|
+
# The drive runs on its own crystal, so it clocks against the host's
|
|
20
|
+
# clock through a fractional accumulator: each host cycle adds 1 MHz
|
|
21
|
+
# worth of phase, and every whole host period in it runs a drive cycle.
|
|
22
|
+
# Against the PAL C64's 985,248 Hz that's one drive cycle per host cycle
|
|
23
|
+
# and a second one about every 67. The host sets its clock on attaching
|
|
24
|
+
# the drive, and until then the drive runs one cycle per host cycle.
|
|
25
|
+
#
|
|
26
|
+
# VIA 2's port B runs the Mechanism, which reads a Disk put in with
|
|
27
|
+
# insert.
|
|
28
|
+
#
|
|
29
|
+
# While the DOS idles, host_cycle! skips the drive's cycles and catches
|
|
30
|
+
# up on them later, exactly (see Idle). The readers of the drive's parts
|
|
31
|
+
# catch up first, so they find the drive as running every cycle would
|
|
32
|
+
# have left it. Once the DOS's timer interrupts come round, the drive
|
|
33
|
+
# sleeps through them too (see Orbit).
|
|
34
|
+
class Drive1541
|
|
35
|
+
include Sleep
|
|
36
|
+
include Idle
|
|
37
|
+
include Orbit
|
|
38
|
+
|
|
39
|
+
CLOCK_HZ = 1_000_000
|
|
40
|
+
|
|
41
|
+
attr_reader :device, :serial_bus
|
|
42
|
+
|
|
43
|
+
def cpu
|
|
44
|
+
settle!
|
|
45
|
+
@cpu
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bus
|
|
49
|
+
settle!
|
|
50
|
+
@bus
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def via1
|
|
54
|
+
settle!
|
|
55
|
+
@via1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def via2
|
|
59
|
+
settle!
|
|
60
|
+
@via2
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def mechanism
|
|
64
|
+
settle!
|
|
65
|
+
@mechanism
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cycles
|
|
69
|
+
settle!
|
|
70
|
+
@cycles
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ram = bus.ram
|
|
74
|
+
|
|
75
|
+
def host_clock_hz=(clock_hz)
|
|
76
|
+
settle!
|
|
77
|
+
@host_clock_hz = clock_hz
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# +rom+ covers $C000-$FFFF, and defaults to the DOS image in the ROM
|
|
81
|
+
# path. +device+ is the number the jumpers on VIA 1's PB5 and PB6 set,
|
|
82
|
+
# 8 to 11.
|
|
83
|
+
def initialize(rom: nil, host_clock_hz: CLOCK_HZ, device: 8, debug: false)
|
|
84
|
+
@device = device
|
|
85
|
+
@serial_port = SerialPort.new(device:)
|
|
86
|
+
@via1 = VIA.new(start: 0x1800, peripheral: @serial_port)
|
|
87
|
+
@mechanism = Mechanism.new(self)
|
|
88
|
+
@via2 = DiskVIA.new(start: 0x1c00, mechanism: @mechanism)
|
|
89
|
+
@bus = Bus.new(rom: rom || ROM.load("dos1541.rom", 0xc000), via1: @via1, via2: @via2)
|
|
90
|
+
@cpu = CPU.new(@bus, debug:)
|
|
91
|
+
@host_clock_hz = host_clock_hz
|
|
92
|
+
@phase = 0
|
|
93
|
+
@cycles = 0
|
|
94
|
+
@so_pending = false
|
|
95
|
+
@serial_bus = nil
|
|
96
|
+
init_idle(debug)
|
|
97
|
+
# CA1 powers up at the level of a released ATN, without an edge.
|
|
98
|
+
@via1.ca1 = false
|
|
99
|
+
@via1.reset!
|
|
100
|
+
connect(IECBus.new)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Plugs the drive into a serial bus, leaving the one it was on. A drive
|
|
104
|
+
# starts out on a bus of its own, with nothing else on it.
|
|
105
|
+
def connect(serial_bus)
|
|
106
|
+
settle!
|
|
107
|
+
@serial_bus&.detach(self)
|
|
108
|
+
@serial_bus = serial_bus
|
|
109
|
+
@serial_port.bus = serial_bus
|
|
110
|
+
serial_bus.attach(self)
|
|
111
|
+
@via1.ca1 = @serial_port.atn_low?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Puts a Disk in the drive (Disk.from_d64 makes one from an image).
|
|
115
|
+
# Nil takes the disk out.
|
|
116
|
+
def insert(disk)
|
|
117
|
+
settle!
|
|
118
|
+
@mechanism.insert(disk)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def disk = mechanism.disk
|
|
122
|
+
|
|
123
|
+
# Whether the LED is lit. Reading it leaves the drive asleep: the LED
|
|
124
|
+
# is the same at the end of every pass the drive sleeps through.
|
|
125
|
+
def led_on? = @mechanism.led_on?
|
|
126
|
+
|
|
127
|
+
# VIA 1's port B as it drives the serial bus. It holds still while the
|
|
128
|
+
# drive sleeps, so reading it leaves the drive asleep.
|
|
129
|
+
def serial_output = @via1.port_b_output
|
|
130
|
+
|
|
131
|
+
# Stores what the head wrote since the motor last stopped in the
|
|
132
|
+
# disk's image, as the motor stopping does.
|
|
133
|
+
def flush
|
|
134
|
+
settle!
|
|
135
|
+
@mechanism.flush
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The serial bus's RESET line reaches the CPU and both VIAs. RAM keeps
|
|
139
|
+
# its contents.
|
|
140
|
+
def reset!
|
|
141
|
+
settle!
|
|
142
|
+
@via1.reset!
|
|
143
|
+
@via2.reset!
|
|
144
|
+
@cpu.reset!
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Runs the drive cycles that fall in one host cycle: none, one or two.
|
|
148
|
+
# The host has run this cycle already, and the drive sees what it did
|
|
149
|
+
# from the next one on (see SerialPort). Asleep, the drive owes the
|
|
150
|
+
# cycles instead, until ATN moves or its counters are due (see Idle).
|
|
151
|
+
# It looks at ATN only after the C64 wrote CIA 2's port A, on a bus
|
|
152
|
+
# that says so (see host_written!).
|
|
153
|
+
def host_cycle!
|
|
154
|
+
if @asleep
|
|
155
|
+
return if (@slept += 1) != @wake_at && @host_still
|
|
156
|
+
|
|
157
|
+
return doze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
phase = @phase + CLOCK_HZ
|
|
161
|
+
while phase >= @host_clock_hz
|
|
162
|
+
@asleep ? @owed += 1 : run_cycle
|
|
163
|
+
phase -= @host_clock_hz
|
|
164
|
+
end
|
|
165
|
+
@phase = phase
|
|
166
|
+
settle! if @serial_port.atn_moved?
|
|
167
|
+
@serial_port.latch_host
|
|
168
|
+
plan_wake if @asleep
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The C64 wrote CIA 2's port A, so ATN may have moved.
|
|
172
|
+
def host_written!
|
|
173
|
+
@host_still = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Runs one drive cycle, catching up first on any the drive owes (see
|
|
177
|
+
# step).
|
|
178
|
+
def cycle!
|
|
179
|
+
settle!
|
|
180
|
+
step
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def inspect
|
|
184
|
+
"#<#{self.class.name} cycles=#{cycles} cpu=(#{cpu.inspect})>"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The read electronics signal a whole GCR byte. BYTE READY pulls VIA 2's
|
|
188
|
+
# CA1 low, a falling edge that sets its flag and, with latching on,
|
|
189
|
+
# latches port A. It reaches the CPU's SO pin while VIA 2's CA2 (SOE)
|
|
190
|
+
# is high, which lets the DOS spin on BVC for each byte. The CPU
|
|
191
|
+
# samples SO on the next cycle (see step).
|
|
192
|
+
def byte_ready!
|
|
193
|
+
@via2.ca1 = false
|
|
194
|
+
@so_pending = true if @via2.ca2_output
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# BYTE READY lets go of CA1 with the next bit.
|
|
198
|
+
def byte_ready_ended!
|
|
199
|
+
@via2.ca1 = true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
# A drive cycle from host_cycle!, which may find the idle loop.
|
|
205
|
+
def run_cycle
|
|
206
|
+
step
|
|
207
|
+
idle_loop_reached if @cpu.program_counter == IDLE_LOOP && @idle_skip
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# One drive cycle. The VIAs clock ahead of the CPU, as the C64's chips
|
|
211
|
+
# do, and either one pulls IRQ. Nothing drives NMI on the 1541. The CPU
|
|
212
|
+
# runs whether or not the motor turns, since it answers ATN.
|
|
213
|
+
#
|
|
214
|
+
# The disk mechanism runs first, so BYTE READY lands on the VIA ahead
|
|
215
|
+
# of the CPU's cycle. The CPU samples SO a cycle late: BYTE READY sets
|
|
216
|
+
# V for the next cycle's instruction step.
|
|
217
|
+
#
|
|
218
|
+
# ATN reaches VIA 1's CA1 through the same inverter as PB7, so CA1 goes
|
|
219
|
+
# high as the C64 asserts ATN, as the serial port sees it: from the
|
|
220
|
+
# host cycle after the one that asserts it.
|
|
221
|
+
def step
|
|
222
|
+
so = @so_pending
|
|
223
|
+
@so_pending = false
|
|
224
|
+
@mechanism.cycle!
|
|
225
|
+
@via1.ca1 = @serial_port.atn_low?
|
|
226
|
+
@via1.cycle!
|
|
227
|
+
@via2.cycle!
|
|
228
|
+
@cpu.irq = @via1.irq? || @via2.irq?
|
|
229
|
+
@cpu.so! if so
|
|
230
|
+
@cpu.cycle!
|
|
231
|
+
@cycles += 1
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -28,15 +28,20 @@ module Badline
|
|
|
28
28
|
}.freeze
|
|
29
29
|
MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
|
|
30
30
|
|
|
31
|
-
# The
|
|
32
|
-
|
|
31
|
+
# The machine options (sid_model:, reu:, true_drive:, ntsc:) build
|
|
32
|
+
# the machine: reu plugs in an REU of that many K, true_drive puts a
|
|
33
|
+
# true 1541 on device 8, and ntsc makes it an NTSC C64. The media
|
|
34
|
+
# options (autostart:, song:, disk:) go to Media.attach.
|
|
35
|
+
def initialize(media_path: nil, machine: {}, sound: false, verbose: false, **media)
|
|
33
36
|
@verbose = verbose
|
|
34
|
-
@computer = Computer.new(
|
|
37
|
+
@computer = Computer.new(**machine_options(machine, media_path))
|
|
38
|
+
Media::TrueDrive.plug(@computer) if machine[:true_drive]
|
|
35
39
|
puts Media.attach(@computer, media_path, **media) if media_path
|
|
36
40
|
|
|
37
41
|
@mode = :keyboard
|
|
38
42
|
@pot_device = nil
|
|
39
43
|
@panes = [ScreenPane.new(@computer)]
|
|
44
|
+
@panes << DriveLedPane.new(@computer.drive1541, @panes.first) if @computer.drive1541
|
|
40
45
|
@stream = open_stream if sound
|
|
41
46
|
@paced = ENV["NOVSYNC"].nil?
|
|
42
47
|
@window = Window.new(
|
|
@@ -60,6 +65,7 @@ module Badline
|
|
|
60
65
|
@stream.pace(@frame_seconds) if @stream && @paced
|
|
61
66
|
end
|
|
62
67
|
ensure
|
|
68
|
+
@computer.drive1541&.flush
|
|
63
69
|
@stream&.close
|
|
64
70
|
@gamepads.close
|
|
65
71
|
@window.close
|
|
@@ -163,6 +169,12 @@ module Badline
|
|
|
163
169
|
warn "badline: no sound, can't open the audio device: #{e.message}"
|
|
164
170
|
end
|
|
165
171
|
|
|
172
|
+
def machine_options(machine, media_path)
|
|
173
|
+
options = { sid_model: machine[:sid_model] || Media.sid_model(media_path), **machine.slice(:reu) }
|
|
174
|
+
options[:region] = Region::NTSC if machine[:ntsc]
|
|
175
|
+
options
|
|
176
|
+
end
|
|
177
|
+
|
|
166
178
|
def fit_frame
|
|
167
179
|
rate = @window.refresh_rate
|
|
168
180
|
clock_hz = @computer.region.clock_hz
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module GUI
|
|
5
|
+
# The true drive's red LED, in the bottom right corner of the screen
|
|
6
|
+
# pane's border: bright while the DOS lights it, as it does while a file
|
|
7
|
+
# is open or after an error, and dim otherwise.
|
|
8
|
+
class DriveLedPane < Pane
|
|
9
|
+
WIDTH = 12
|
|
10
|
+
HEIGHT = 4
|
|
11
|
+
|
|
12
|
+
LIT = [0xff, 0x20, 0x20].freeze
|
|
13
|
+
DARK = [0x40, 0x00, 0x00].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(drive, screen)
|
|
16
|
+
super(width: WIDTH, height: HEIGHT,
|
|
17
|
+
left: screen.left + screen.width - WIDTH - 8, top: screen.top + screen.height - HEIGHT - 6)
|
|
18
|
+
@drive = drive
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def color = @drive.led_on? ? LIT : DARK
|
|
22
|
+
|
|
23
|
+
def render(renderer)
|
|
24
|
+
SDL::SetRenderDrawColor.call(renderer, *color, 0xff)
|
|
25
|
+
SDL::RenderFillRect.call(renderer, @rect)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/badline/gui.rb
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
# The serial bus between the C64 and its drives: ATN, CLK and DATA, each
|
|
5
|
+
# an open-collector line with a pull-up. A line is low while any device
|
|
6
|
+
# pulls it (wired-AND), and high otherwise. The bus holds no state of its
|
|
7
|
+
# own: every read works the levels out from what each side drives now.
|
|
8
|
+
#
|
|
9
|
+
# The C64 drives the lines from CIA 2's port A through 7406 inverters, so
|
|
10
|
+
# an output bit of 1 pulls its line low: PA3 ATN, PA4 CLK and PA5 DATA.
|
|
11
|
+
# PA6 and PA7 read CLK and DATA directly, 1 for a released line. The bus
|
|
12
|
+
# stands in as CIA 2's peripheral for those two inputs.
|
|
13
|
+
#
|
|
14
|
+
# A drive drives CLK and DATA from VIA 1's PB3 and PB1, the same way. It
|
|
15
|
+
# also pulls DATA while ATN IN differs from its ATN acknowledge on PB4
|
|
16
|
+
# (an XOR gate), which answers ATN in hardware before the DOS gets to it.
|
|
17
|
+
# SerialPort reads the lines back into VIA 1.
|
|
18
|
+
class IECBus
|
|
19
|
+
# CIA 2 port A
|
|
20
|
+
HOST_ATN_OUT = 0x08
|
|
21
|
+
HOST_CLK_OUT = 0x10
|
|
22
|
+
HOST_DATA_OUT = 0x20
|
|
23
|
+
HOST_CLK_IN = 0x40
|
|
24
|
+
HOST_DATA_IN = 0x80
|
|
25
|
+
|
|
26
|
+
# VIA 1 port B
|
|
27
|
+
DRIVE_DATA_OUT = 0x02
|
|
28
|
+
DRIVE_CLK_OUT = 0x08
|
|
29
|
+
DRIVE_ATNA = 0x10
|
|
30
|
+
|
|
31
|
+
# The lines pulled low, as ATN, CLK and DATA bits
|
|
32
|
+
ATN = 0x01
|
|
33
|
+
CLK = 0x02
|
|
34
|
+
DATA = 0x04
|
|
35
|
+
|
|
36
|
+
attr_reader :host, :drives
|
|
37
|
+
|
|
38
|
+
# +host+ is CIA 2, or nil for a bus with only drives on it.
|
|
39
|
+
def initialize(host: nil)
|
|
40
|
+
@host = host
|
|
41
|
+
@drives = []
|
|
42
|
+
@notifies = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stands in as the host's peripheral, CIA 2's, and has it say when it
|
|
46
|
+
# writes port A (see notifies?).
|
|
47
|
+
def plug_in!
|
|
48
|
+
@host.peripheral = self
|
|
49
|
+
@host.on_port_a_write { host_written }
|
|
50
|
+
@notifies = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether the host says when it may have moved a line (see plug_in!).
|
|
54
|
+
# Without, a drive asleep checks ATN every cycle.
|
|
55
|
+
def notifies? = @notifies
|
|
56
|
+
|
|
57
|
+
# CIA 2 wrote its port A, which may have moved a line.
|
|
58
|
+
def host_written = @drives.each(&:host_written!)
|
|
59
|
+
|
|
60
|
+
def attach(drive)
|
|
61
|
+
@drives << drive unless @drives.include?(drive)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def detach(drive)
|
|
65
|
+
@drives.delete(drive)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One pass over everything on the bus, for a reader that wants more
|
|
69
|
+
# than one line. +host+ is the C64's port A lines as the reader sees
|
|
70
|
+
# them, which a drive takes a cycle late (see Drive1541::SerialPort).
|
|
71
|
+
def low_lines(host = host_lines)
|
|
72
|
+
atn = host.anybits?(HOST_ATN_OUT)
|
|
73
|
+
low = atn ? ATN : 0
|
|
74
|
+
low |= CLK if host.anybits?(HOST_CLK_OUT)
|
|
75
|
+
low |= DATA if host.anybits?(HOST_DATA_OUT)
|
|
76
|
+
i = 0
|
|
77
|
+
while i < @drives.length
|
|
78
|
+
drive = @drives[i].serial_output
|
|
79
|
+
low |= CLK if drive.anybits?(DRIVE_CLK_OUT)
|
|
80
|
+
low |= DATA if drive.anybits?(DRIVE_DATA_OUT) || atn != drive.anybits?(DRIVE_ATNA)
|
|
81
|
+
i += 1
|
|
82
|
+
end
|
|
83
|
+
low
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def atn_low?(host = host_lines) = host.anybits?(HOST_ATN_OUT)
|
|
87
|
+
|
|
88
|
+
def clk_low? = low_lines.anybits?(CLK)
|
|
89
|
+
|
|
90
|
+
def data_low? = low_lines.anybits?(DATA)
|
|
91
|
+
|
|
92
|
+
# CIA 2's port A pins as the bus leaves them: CLK IN and DATA IN low
|
|
93
|
+
# while their lines are.
|
|
94
|
+
def read_a(_port_a, _port_b)
|
|
95
|
+
low = low_lines
|
|
96
|
+
value = 0xff
|
|
97
|
+
value &= ~HOST_CLK_IN if low.anybits?(CLK)
|
|
98
|
+
value &= ~HOST_DATA_IN if low.anybits?(DATA)
|
|
99
|
+
value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Port B goes to the user port, not the serial bus.
|
|
103
|
+
def read_b(_port_a, _port_b) = 0xff
|
|
104
|
+
|
|
105
|
+
# What the C64 pulls, as port A bits: its output bits, with input bits
|
|
106
|
+
# floating high as the 7406 inputs see them.
|
|
107
|
+
def host_lines = @host ? @host.port_a_lines : 0
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -53,8 +53,12 @@ module Badline
|
|
|
53
53
|
# a LOAD (secondary address 0) opens at $7E and $026F of its memory,
|
|
54
54
|
# and a LOAD of a name starting with "*" reopens that file rather than
|
|
55
55
|
# the first one on the disk. Loaders write the block there with M-W
|
|
56
|
-
# to load a file the directory doesn't list.
|
|
56
|
+
# to load a file the directory doesn't list. A LOAD of "$" reads the
|
|
57
|
+
# directory as a BASIC program, which a pattern after a colon narrows
|
|
58
|
+
# to the files it names.
|
|
57
59
|
def self.for_name(storage, memory, secondary, name, type)
|
|
60
|
+
return new(storage.directory.bytes(name)) if listing?(storage, secondary, name)
|
|
61
|
+
|
|
58
62
|
file = Storage.parse_name(name).first
|
|
59
63
|
return for_file(storage, file, type) unless secondary.zero? && storage.respond_to?(:first_block)
|
|
60
64
|
|
|
@@ -66,6 +70,10 @@ module Badline
|
|
|
66
70
|
for_file(storage, file, type)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
def self.listing?(storage, secondary, name)
|
|
74
|
+
secondary.zero? && name.start_with?("$") && storage.respond_to?(:directory)
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
# The file whose chain starts at the block.
|
|
70
78
|
def self.at_block(storage, track, sector)
|
|
71
79
|
data = storage.read_file_at(track, sector)
|