badline 0.5.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c6abfa72fe3a5e5f8e88efe9d53e04b4e6eaeb355550ef5ac23c47c1a0916fc
4
- data.tar.gz: e15d996a7121eb244ba6ad7a8870694389f59ab8139977b2cf7b9d344f35bbe7
3
+ metadata.gz: 9175400f11b212812092f887bea13e0880417fb151011fdbc568c4789050dcf1
4
+ data.tar.gz: dbd07633e45bfcc06fd6b4ab1b680389276ff81340cc6c72d26bd4e7fdf6baad
5
5
  SHA512:
6
- metadata.gz: d6fdecfc6b65d6b67d296fafb91e1cbdad7920e0326f146c5bae4bfed08fdd3fad9b4b38007a7f3bdb6a859ff5f1428ff7d94f573e13b68dfdb2e31c5c15e9fe
7
- data.tar.gz: 6843561cca4725f4d6a93cff613ea9b0a9db78d1d45aebee9c4ba7573c49205b0162c4431d4cb78b81e12ded0e84388866017e88b94e065790934e2aaa0c4e8e
6
+ metadata.gz: d00a54fba0e530ff578a35b3b5953c0f011b6ea32a2b5ac3f8ccaf19dc8c8f3e26bb81dedfac4c53b026b0e2d0a8e3a35c9f212369624995765c0c8a99678542
7
+ data.tar.gz: '0918a31ea33114c1f46d76ec3c0e9978a5e922bd7b39f6abe9494ee03423fad48f4aecbf8c4a2e0af4414ee19b2ce2d33c6cb21774a473138db15a588c149f2b'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1](https://github.com/elektronaut/badline/compare/v0.5.0...v0.5.1) (2026-09-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * build the native badline with a Spinel that writes .d64 images through the true drive ([97f537a](https://github.com/elektronaut/badline/commit/97f537afe69b10ae044f83ccf4dd018402ebe1be))
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * sleep the idle true drive through its timer interrupts ([0482e43](https://github.com/elektronaut/badline/commit/0482e439cfb160ab69027b166b1059ed8fc9dfb3)), closes [#367](https://github.com/elektronaut/badline/issues/367)
14
+
3
15
  ## [0.5.0](https://github.com/elektronaut/badline/compare/v0.4.2...v0.5.0) (2026-09-29)
4
16
 
5
17
 
data/CLAUDE.md CHANGED
@@ -48,6 +48,7 @@ requires only the namespace file.
48
48
  | Wolfgang Lorenz suite | `bin/lorenz` | CPU, CIA, interrupts |
49
49
  | VICE testbench | `bin/testbench <subtree>` | `VICII/`, `CIA/`, `interrupts/`, `CPU/`, cartridges with `--carts`, and the true 1541 drive with `--drive` |
50
50
  | VICE SID testprogs | `bin/sidtests` | SID |
51
+ | Drive scenarios | `bin/drive_scenarios` | The true 1541 running the DOS ROM: save, format, the error channel, idle, write protect and autostart |
51
52
  | CIA offline grids | `bundle exec rspec --tag slow spec/badline/cia` | CIA timers and shift register, against a bare CIA in about 2 min |
52
53
 
53
54
  The CIA offline grids replay Lorenz's `cia1ta` and `cia1tb` sweeps (about
@@ -156,29 +157,29 @@ the rows your change can't reach tell you nothing about it.
156
157
  `ruby --yjit bin/lorenz [image] [start_test] [max_cycles]` (resumes the
157
158
  chain at a named test, and `--stop-after NAME` ends it once that test is
158
159
  done). These take seconds to minutes
159
- - When your change moves rows, re-record only those rows, in the same
160
- change: `rake "regression:record:<suite>[filter,...]"` (quote it, because
161
- zsh globs the brackets). It runs only the matching tests and splices
162
- their rows into the baseline, and every other row keeps its verdict.
160
+ - A pull request's CI is the verdict. It runs every suite but
161
+ `testbench-drive` on the Spinel build, compared row by row against
162
+ `test/baselines/`, and every job is a required check. A row that moved
163
+ fails it. Re-record only those rows, in the same change:
164
+ `rake "regression:record:<suite>[filter,...]"` (quote it, because zsh
165
+ globs the brackets). It runs only the matching tests on CRuby and
166
+ splices their rows into the baseline, and every other row keeps its
167
+ verdict. CI runs the same rows on Spinel, so the two have to agree.
163
168
  `lorenz` chains itself, so it takes a stretch of the chain instead:
164
169
  `rake "regression:record:lorenz[first,last]"` resumes just ahead of
165
170
  `first`, stops after `last` and leaves the `(suite)` row alone.
166
171
  `[first,(suite)]` runs on to the end of the chain and records the
167
- `(suite)` row too. Cover every test your change can reach, and explain
168
- every moved row. Never re-record just to make a diff go away
169
- - CI's nightly run owns the whole-suite verdict. The Regression workflow
170
- runs `testbench`, `lorenz` and `sid` against `main` each night, and skips
171
- the night when nothing relevant has changed since the last successful
172
- nightly run. It also runs from the Actions tab on demand. It never runs
173
- on push or on pull requests, so one verdict can cover a day's merges.
174
- The `testbench-*` and `sid-8580` suites run from the Actions tab on
175
- demand. The Spinel workflow runs the Spinel suites on pull requests
176
- that touch emulation, harness or build paths. Its jobs aren't required
177
- checks yet, and the CRuby Regression nightly runs as before
178
- - The one exception is a change whose reach you can't bound to a set of
179
- filters, such as reordering `Computer#cycle!` or changing the LOAD trap
180
- every suite loads through. Ask before running a full suite for it, and
181
- don't start one on your own judgement
172
+ `(suite)` row too. Explain every moved row in the pull request. Never
173
+ re-record just to make a diff go away
174
+ - For a change whose reach you can't bound to a set of filters, such as
175
+ reordering `Computer#cycle!` or changing the LOAD trap every suite loads
176
+ through, push and let CI run the suites
177
+ - `testbench-drive` needs the true drive, which the Spinel testbench
178
+ harness doesn't have, so CI leaves it out. Run the rows your change can
179
+ reach locally
180
+ - The Regression workflow runs the suites on CRuby, started by hand from
181
+ the Actions tab, for a person checking that CRuby and Spinel agree.
182
+ Agents don't start it
182
183
 
183
184
  Pick the filters from the suites your change can move: VIC → `testbench`,
184
185
  plus `testbench-vicii-new` (`bin/testbench --vicii-new`, the 8565) for
@@ -190,10 +191,9 @@ CPU, interrupts or timing → the matching `testbench-*` suite, plus
190
191
  the 6526A) for anything the interrupt register or the CIA model reaches; cartridge mappers, banking or power-on state →
191
192
  `testbench-carts`, plus `testbench-expansions` for banking; GEO-RAM, +60K,
192
193
  +256K, the REU or the VIC's BA line the REU follows → `testbench-expansions`; the 1541 drive, VIA or IEC bus →
193
- `testbench-drive` (`bin/testbench --drive`); SID → `sid`, plus `sid-8580` for anything the 8580
194
- model reaches (`bin/sidtests --sid 8580`). Lorenz isn't a per-change check:
195
- its full chain runs nightly, and the planner assigns any row it moves. The
196
- exception is code whose rule in `doc/pinned-behaviour.md` names Lorenz
194
+ `testbench-drive` (`bin/testbench --drive`) and `drive-scenarios` (`bin/drive_scenarios <filter>`); SID → `sid`, plus `sid-8580` for anything the 8580
195
+ model reaches (`bin/sidtests --sid 8580`). Leave the Lorenz chain to CI,
196
+ unless your code has a rule in `doc/pinned-behaviour.md` that names Lorenz
197
197
  tests: the interrupt polling, CPU port and CIA timer rules. Run just the
198
198
  tests that rule names, one at a time, with
199
199
  `ruby --yjit bin/lorenz --resume <test> --stop-after <test>`. A stretch
data/CONTRIBUTING.md CHANGED
@@ -85,6 +85,11 @@ bundle exec rubocop
85
85
  ## Tests and commits
86
86
 
87
87
  - Add tests for any behavior you change.
88
+ - CI runs rubocop, the specs and every regression suite on each pull
89
+ request, and fails on any suite row that differs from its baseline in
90
+ `test/baselines/`. When a change moves rows on purpose, re-record just
91
+ those rows as [test/baselines/README.md](test/baselines/README.md)
92
+ describes, and say why in the pull request.
88
93
  - Write commit messages using
89
94
  [Conventional Commits](https://www.conventionalcommits.org). The
90
95
  changelog and releases are generated from them, so the `feat:` and
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  [![Version](https://img.shields.io/gem/v/badline.svg?style=flat)](https://rubygems.org/gems/badline)
2
- [![Build](https://github.com/elektronaut/badline/actions/workflows/build.yml/badge.svg)](https://github.com/elektronaut/badline/actions/workflows/build.yml)
2
+ [![CI](https://github.com/elektronaut/badline/actions/workflows/ci.yml/badge.svg)](https://github.com/elektronaut/badline/actions/workflows/ci.yml)
3
3
 
4
4
  # Badline
5
5
 
@@ -1203,8 +1203,10 @@ and each was knocked out: removing it fails the rows named.
1203
1203
  - Spec guard: *steps in from the stop with the first phase after the
1204
1204
  bump* in
1205
1205
  [`mechanism_spec.rb`](../spec/badline/drive1541/mechanism_spec.rb),
1206
- and *lists it through the traps* (`:slow`) in
1207
- [`drive1541_spec.rb`](../spec/badline/drive1541_spec.rb).
1206
+ and the `format/name-and-id`, `format/bam-free` and
1207
+ `format/trap-readable` rows of the `drive-scenarios` suite
1208
+ ([`drive_scenarios.rb`](../test/drive_scenarios.rb)), which read the
1209
+ formatted disk back from its image.
1208
1210
  - The disk turns at 300 rpm whatever bit rate VIA 2's PB5-6 select: a
1209
1211
  turn is 200,000 drive cycles over every track and over a half track
1210
1212
  without data. Each track's bits pass under the head at the rate they
data/lib/badline/cia.rb CHANGED
@@ -62,6 +62,7 @@ module Badline
62
62
 
63
63
  @peripheral = peripheral
64
64
  @port_b4_handler = nil
65
+ @port_a_handler = nil
65
66
  @port_b4_high = true
66
67
  reset!
67
68
  end
@@ -87,6 +88,7 @@ module Badline
87
88
  @ta = Timer.new(@control_a)
88
89
  @tb = Timer.new(@control_b)
89
90
  @serial = Serial.new(@control_a)
91
+ @port_a_handler&.call
90
92
  end
91
93
 
92
94
  # Register a change handler on the PB4 line. On CIA 1, this feeds the
@@ -95,6 +97,13 @@ module Badline
95
97
  @port_b4_handler = handler
96
98
  end
97
99
 
100
+ # Register a handler for writes that can move port A's lines: to the
101
+ # data register or DDR, and the reset. On CIA 2, this tells the drives
102
+ # on the serial bus that the C64 may have moved a line.
103
+ def on_port_a_write(&handler)
104
+ @port_a_handler = handler
105
+ end
106
+
98
107
  # A falling edge on the FLAG pin. On CIA 1 the datasette's tape read
99
108
  # line drives it, on CIA 2 the serial bus SRQ.
100
109
  def flag! = raise_interrupt(:flag)
@@ -153,9 +162,9 @@ module Badline
153
162
 
154
163
  def poke(addr, value)
155
164
  case offset_of(addr) & 0x0f
156
- when 0x00 then @data_port_a = value
165
+ when 0x00 then update_port_a { @data_port_a = value }
157
166
  when 0x01 then update_port_b { @data_port_b = value }
158
- when 0x02 then @data_dir_a = value
167
+ when 0x02 then update_port_a { @data_dir_a = value }
159
168
  when 0x03 then update_port_b { @data_dir_b = value }
160
169
  when 0x04 then @ta.write_latch_low(value)
161
170
  when 0x05 then @ta.write_latch_high(value)
@@ -174,6 +183,11 @@ module Badline
174
183
 
175
184
  private
176
185
 
186
+ def update_port_a
187
+ yield
188
+ @port_a_handler&.call
189
+ end
190
+
177
191
  def update_port_b
178
192
  yield
179
193
  @port_b4_driven_high = driven_lines(@data_port_b, @data_dir_b).anybits?(0x10)
@@ -195,7 +195,7 @@ module Badline
195
195
  # for, which plugging in a drive does. Until then port A's serial
196
196
  # inputs float high.
197
197
  def iec_bus
198
- @iec_bus ||= IECBus.new(host: @cia2).tap { |bus| @cia2.peripheral = bus }
198
+ @iec_bus ||= IECBus.new(host: @cia2).tap(&:plug_in!)
199
199
  end
200
200
 
201
201
  def capture_output
@@ -19,7 +19,8 @@ module Badline
19
19
  # both on a real 1541.
20
20
  #
21
21
  # While Idle records a pass of the DOS's idle loop, the bus watches it
22
- # (see watch!).
22
+ # (see watch!). While Idle looks for an orbit, the bus guards the drive
23
+ # (see guard!).
23
24
  class Bus
24
25
  include Addressable
25
26
 
@@ -31,6 +32,10 @@ module Badline
31
32
  VOLATILE_VIA2_READS = 0x0730
32
33
  VOLATILE_VIA2_WRITES = 0x0ff0
33
34
 
35
+ # The timer each VIA register reaches, by offset, as bits: 1 for
36
+ # timer 1 and 2 for timer 2.
37
+ TIMER_REGISTERS = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 0, 0, 0, 0, 0, 0].freeze
38
+
34
39
  attr_reader :ram, :rom, :via1, :via2, :data
35
40
 
36
41
  # Whether a watched stretch did something that makes it unrepeatable.
@@ -40,6 +45,14 @@ module Badline
40
45
  # from before the first access.
41
46
  attr_reader :touched
42
47
 
48
+ # Whether the drive did something since guard! that reached outside
49
+ # it, or took something in from outside.
50
+ attr_reader :tainted
51
+
52
+ # The timers the drive read or wrote since guard!, as bits: VIA 1's
53
+ # timer 1 and timer 2 in bits 0 and 1, and VIA 2's in bits 2 and 3.
54
+ attr_reader :touched_timers
55
+
43
56
  def initialize(rom:, via1:, via2:)
44
57
  addressable_at(0, length: 2**16)
45
58
  @ram = Memory.new([], length: 0x0800, start: 0)
@@ -47,21 +60,38 @@ module Badline
47
60
  @via1 = via1
48
61
  @via2 = via2
49
62
  @data = 0
50
- @watching = false
51
- @volatile = false
63
+ @recording = @guarding = @watching = false
64
+ @volatile = @tainted = false
52
65
  @touched = {}
66
+ @touched_timers = 0
67
+ @led = 0
53
68
  end
54
69
 
55
70
  # Starts watching: see volatile and touched. The bus stops watching
56
71
  # once the stretch turns volatile.
57
72
  def watch!
58
- @watching = true
73
+ @recording = true
59
74
  @volatile = false
60
75
  @touched = {}
76
+ watching!
61
77
  end
62
78
 
63
79
  def unwatch!
64
- @watching = false
80
+ @recording = false
81
+ watching!
82
+ end
83
+
84
+ # Starts guarding: see tainted and touched_timers. Reading VIA 1's
85
+ # port B, which reads the serial bus, taints the drive, and so do
86
+ # any write to VIA 1, whose port B drives the serial bus, a write to
87
+ # VIA 2's shift register or ACR, and VIA 2 turning the motor on or
88
+ # changing the LED. The bus stops guarding once the drive is tainted.
89
+ def guard!
90
+ @guarding = true
91
+ @tainted = false
92
+ @touched_timers = 0
93
+ @led = @via2.port_b_output & Mechanism::LED
94
+ watching!
65
95
  end
66
96
 
67
97
  def peek(addr)
@@ -86,7 +116,7 @@ module Badline
86
116
 
87
117
  addr &= 0x1fff
88
118
  if addr < 0x0800
89
- touch(addr) if @watching
119
+ touch(addr) if @recording
90
120
  @ram.poke(addr, value)
91
121
  elsif addr >= 0x1c00
92
122
  @via2.poke(addr, value)
@@ -98,36 +128,69 @@ module Badline
98
128
 
99
129
  private
100
130
 
131
+ def watching!
132
+ @watching = @recording || @guarding
133
+ end
134
+
101
135
  def touch(addr)
102
136
  @touched[addr] = @ram.peek(addr) unless @touched.key?(addr)
103
137
  end
104
138
 
105
139
  def watch_read(addr)
106
140
  if addr < 0x0800
107
- touch(addr)
141
+ touch(addr) if @recording
108
142
  elsif addr >= 0x1800
109
- watch(addr < 0x1c00 ? VOLATILE_VIA1_READS : VOLATILE_VIA2_READS, addr)
143
+ via1 = addr < 0x1c00
144
+ watch(via1 ? VOLATILE_VIA1_READS : VOLATILE_VIA2_READS, addr) if @recording
145
+ guard_read(via1, addr & 0x0f) if @guarding
110
146
  end
111
147
  end
112
148
 
113
- # Any write to VIA 1 makes the stretch volatile, and so does a write
114
- # to VIA 2 that leaves the motor on.
115
149
  def watch_write(addr)
116
150
  if addr >= 0x1c00
117
- watch(VOLATILE_VIA2_WRITES, addr)
118
- volatile! if @watching && @via2.port_b_output.anybits?(Mechanism::MOTOR)
151
+ watch_via2_write(addr) if @recording
152
+ guard_via2_write(addr & 0x0f) if @guarding
119
153
  elsif addr >= 0x1800
120
- volatile!
154
+ volatile! if @recording
155
+ taint! if @guarding
121
156
  end
122
157
  end
123
158
 
159
+ # Any write to VIA 1 makes the stretch volatile (watch_write), and so
160
+ # does a write to VIA 2 that leaves the motor on.
161
+ def watch_via2_write(addr)
162
+ watch(VOLATILE_VIA2_WRITES, addr)
163
+ volatile! if @recording && @via2.port_b_output.anybits?(Mechanism::MOTOR)
164
+ end
165
+
124
166
  def watch(volatile, addr)
125
167
  volatile! if volatile.anybits?(1 << (addr & 0x0f))
126
168
  end
127
169
 
128
170
  def volatile!
129
171
  @volatile = true
130
- @watching = false
172
+ @recording = false
173
+ watching!
174
+ end
175
+
176
+ def guard_read(via1, offset)
177
+ return taint! if via1 && offset.zero?
178
+
179
+ @touched_timers |= via1 ? TIMER_REGISTERS[offset] : TIMER_REGISTERS[offset] << 2
180
+ end
181
+
182
+ def guard_via2_write(offset)
183
+ return taint! if offset.between?(0x0a, 0x0b)
184
+
185
+ @touched_timers |= TIMER_REGISTERS[offset] << 2
186
+ lines = @via2.port_b_output
187
+ taint! if lines.anybits?(Mechanism::MOTOR) || (lines & Mechanism::LED) != @led
188
+ end
189
+
190
+ def taint!
191
+ @tainted = true
192
+ @guarding = false
193
+ watching!
131
194
  end
132
195
  end
133
196
  end
@@ -27,7 +27,8 @@ module Badline
27
27
  #
28
28
  # Such a pass runs the same way again from where it ended, for as
29
29
  # long as ATN holds still and no counter sets a flag, so the drive
30
- # sleeps: host_cycle! only counts the host cycles that go by. It wakes
30
+ # sleeps: host_cycle! only counts the host cycles that go by (see
31
+ # Sleep). It wakes
31
32
  # when ATN moves, when a counter is due to set a flag, or when something
32
33
  # outside reads or changes the drive (the readers in Drive1541 call
33
34
  # settle!). Waking counts off the whole passes it owes in bulk,
@@ -41,6 +42,9 @@ module Badline
41
42
  # A pass starts at $EBFF, where the CPU always comes from the loop's
42
43
  # last instruction, a JMP, so what that leaves in the CPU's working
43
44
  # registers is the same each time round.
45
+ #
46
+ # The timer's interrupts come round too, and once they do, the drive
47
+ # sleeps on through them (see Orbit).
44
48
  module Idle
45
49
  # Where the DOS 2.6 idle loop starts over.
46
50
  IDLE_LOOP = 0xebff
@@ -54,38 +58,15 @@ module Badline
54
58
  @idle_skip = on
55
59
  end
56
60
 
57
- # Whether the drive is skipping its idle loop right now.
58
- def asleep? = @asleep
59
-
60
- # Brings the drive up to the present, cycle for cycle, and drops the
61
- # pass it was recording, since whoever called may change it. Every
62
- # reader of the drive's parts calls this.
63
- def settle!
64
- if @asleep
65
- wake!
66
- @serial_port.latch_host
67
- end
68
- stop_recording if @recording
69
- end
70
-
71
61
  private
72
62
 
73
- # A host cycle asleep.
74
- def doze
75
- @slept += 1
76
- settle! if @slept == @wake_at || @serial_port.atn_moved?
77
- end
78
-
79
63
  def init_idle(debug)
80
64
  @idle_skip = !debug
81
- @asleep = false
82
65
  @recording = false
83
- @owed = 0
84
- @budget = 0
85
- @slept = 0
86
- @wake_at = 0
87
66
  @pass_cycles = 0
88
67
  @pass_instructions = 0
68
+ init_sleep
69
+ init_orbits
89
70
  end
90
71
 
91
72
  # The CPU is at $EBFF. At an instruction boundary, the drive sleeps if
@@ -130,39 +111,10 @@ module Badline
130
111
  @pass_cycles = @cycles - @record_cycles
131
112
  @pass_instructions = @cpu.instructions - @record_instructions
132
113
  @asleep = true
114
+ @host_still = false
133
115
  @owed = 0
134
116
  @budget = quiet_cycles
135
- end
136
-
137
- # Asleep, host_cycle! counts host cycles, and leaves it to wake! to
138
- # turn them into drive cycles through the phase accumulator. This sets
139
- # the host cycle that brings the drive cycles owed up to the budget.
140
- def plan_wake
141
- @slept = 0
142
- needed = ((@budget - @owed) * @host_clock_hz) - @phase
143
- @wake_at = needed.positive? ? (needed + CLOCK_HZ - 1) / CLOCK_HZ : 1
144
- end
145
-
146
- # Runs the cycles owed: whole passes in bulk, up to the budget, and
147
- # the rest one by one.
148
- def wake!
149
- @asleep = false
150
- phase = @phase + (@slept * CLOCK_HZ)
151
- @phase = phase % @host_clock_hz
152
- owed = @owed + (phase / @host_clock_hz)
153
- @owed = 0
154
- @slept = 0
155
- span = [owed, @budget].min
156
- passes = span / @pass_cycles
157
- if passes.positive?
158
- cycles = passes * @pass_cycles
159
- @cpu.fast_forward(cycles, passes * @pass_instructions)
160
- @via1.fast_forward(cycles)
161
- @via2.fast_forward(cycles)
162
- @cycles += cycles
163
- owed -= cycles
164
- end
165
- owed.times { step }
117
+ anchor
166
118
  end
167
119
 
168
120
  def quiet_cycles
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Drive1541
5
+ # Sleeping through the DOS's timer interrupts, exactly, once they come
6
+ # round.
7
+ #
8
+ # VIA 2's timer 1 interrupts the idle loop for the job loop, which
9
+ # finds no job, restarts the timer and returns into the pass it cut
10
+ # into, 14,998 cycles and more apart. The interrupts land at a
11
+ # different point in the pass each time, until, six of them on, the
12
+ # whole drive is back where it was: an orbit of 90,044 cycles.
13
+ #
14
+ # Each place the drive falls asleep (see Idle) is an anchor, and the
15
+ # drive keeps everything it holds at each one: RAM, the counters of
16
+ # the timers that are armed or that it read or wrote since, and the
17
+ # rest as Idle compares a pass. It keeps them only while it keeps to
18
+ # itself, with Bus#guard! checking that it doesn't read the serial
19
+ # bus, write VIA 1, turn the motor on or change the LED. ATN moving,
20
+ # or a reader settling the drive, forgets them.
21
+ #
22
+ # Coming to an anchor it kept before closes an orbit, which runs the
23
+ # same way again from here for as long as ATN holds still, so the
24
+ # drive sleeps on without waking for its counters. Waking counts off
25
+ # the whole orbits it owes in bulk, moving on only the counters it
26
+ # never touched, and runs the rest as Idle does.
27
+ module Orbit
28
+ # How many anchors the drive keeps while it looks for an orbit.
29
+ ANCHORS = 32
30
+
31
+ private
32
+
33
+ def init_orbits
34
+ @anchors = {}
35
+ @orbit_cycles = nil
36
+ @orbit_instructions = 0
37
+ @bus.guard!
38
+ end
39
+
40
+ # The drive falls asleep at an anchor. One it came to before closes
41
+ # an orbit: everything from there to here runs the same way again.
42
+ def anchor
43
+ forget_orbits if @bus.tainted || @anchors.size >= ANCHORS
44
+ key = anchor_state
45
+ if (seen = @anchors[key])
46
+ @orbit_cycles = @cycles - seen[0]
47
+ @orbit_instructions = @cpu.instructions - seen[1]
48
+ else
49
+ @anchors[key] = [@cycles, @cpu.instructions]
50
+ end
51
+ end
52
+
53
+ def forget_orbits
54
+ @anchors.clear
55
+ @orbit_cycles = nil
56
+ @bus.guard!
57
+ end
58
+
59
+ # Runs whole orbits, which bring the drive back to the anchor it
60
+ # sleeps at but for the counters it never touched. Returns the
61
+ # cycles.
62
+ def skip_orbits(orbits)
63
+ cycles = orbits * @orbit_cycles
64
+ touched = @bus.touched_timers
65
+ @cpu.fast_forward(cycles, orbits * @orbit_instructions)
66
+ @via1.skip_orbits(cycles, touched & 0x03)
67
+ @via2.skip_orbits(cycles, touched >> 2)
68
+ @cycles += cycles
69
+ cycles
70
+ end
71
+
72
+ # Everything the drive holds at an anchor, but for the counters of
73
+ # the unarmed timers it hasn't touched since it was last tainted.
74
+ def anchor_state
75
+ touched = @bus.touched_timers
76
+ [*idle_state, *@via1.counter_state(touched & 0x03), *@via2.counter_state(touched >> 2),
77
+ @bus.ram.snapshot]
78
+ end
79
+ end
80
+ end
81
+ end
@@ -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
@@ -7,7 +7,9 @@ require "badline/drive1541/track"
7
7
  require "badline/drive1541/disk"
8
8
  require "badline/drive1541/mechanism"
9
9
  require "badline/drive1541/disk_via"
10
+ require "badline/drive1541/sleep"
10
11
  require "badline/drive1541/idle"
12
+ require "badline/drive1541/orbit"
11
13
 
12
14
  module Badline
13
15
  # The 1541 disk drive as a machine of its own: a 6502 running the DOS
@@ -27,9 +29,12 @@ module Badline
27
29
  # While the DOS idles, host_cycle! skips the drive's cycles and catches
28
30
  # up on them later, exactly (see Idle). The readers of the drive's parts
29
31
  # catch up first, so they find the drive as running every cycle would
30
- # have left it.
32
+ # have left it. Once the DOS's timer interrupts come round, the drive
33
+ # sleeps through them too (see Orbit).
31
34
  class Drive1541
35
+ include Sleep
32
36
  include Idle
37
+ include Orbit
33
38
 
34
39
  CLOCK_HZ = 1_000_000
35
40
 
@@ -143,8 +148,14 @@ module Badline
143
148
  # The host has run this cycle already, and the drive sees what it did
144
149
  # from the next one on (see SerialPort). Asleep, the drive owes the
145
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!).
146
153
  def host_cycle!
147
- return doze if @asleep
154
+ if @asleep
155
+ return if (@slept += 1) != @wake_at && @host_still
156
+
157
+ return doze
158
+ end
148
159
 
149
160
  phase = @phase + CLOCK_HZ
150
161
  while phase >= @host_clock_hz
@@ -157,6 +168,11 @@ module Badline
157
168
  plan_wake if @asleep
158
169
  end
159
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
+
160
176
  # Runs one drive cycle, catching up first on any the drive owes (see
161
177
  # step).
162
178
  def cycle!
@@ -39,8 +39,24 @@ module Badline
39
39
  def initialize(host: nil)
40
40
  @host = host
41
41
  @drives = []
42
+ @notifies = false
42
43
  end
43
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
+
44
60
  def attach(drive)
45
61
  @drives << drive unless @drives.include?(drive)
46
62
  end
@@ -22,6 +22,9 @@ module Badline
22
22
  (addr...(addr + length)).to_a.map { |a| peek(a) }
23
23
  end
24
24
 
25
+ # Every byte, as a copy.
26
+ def snapshot = @storage.dup
27
+
25
28
  def write(addr, bytes)
26
29
  Array(bytes).each_with_index { |b, i| poke(addr + i, b) }
27
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Badline
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -26,6 +26,21 @@ module Badline
26
26
  @t2.fast_forward(cycles)
27
27
  end
28
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
+
29
44
  # Everything the VIA holds but its counters, for comparing it at two
30
45
  # points. Timer 1's PB7 level counts only while ACR bit 7 puts it on
31
46
  # the pin: nothing reads it otherwise.
@@ -96,6 +96,12 @@ module Badline
96
96
  # points.
97
97
  def state = [@latch, @armed]
98
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
+
99
105
  private
100
106
 
101
107
  # The counter reloads either way. Armed, PB7 inverts and the flag
@@ -88,6 +88,12 @@ module Badline
88
88
  # comparing the timer at two points.
89
89
  def state = [@latch_low, @armed, @hold, @low_reload, @count_pulses, @counting_pulses]
90
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
+
91
97
  private
92
98
 
93
99
  # A cycle while counting pulses, or held after a load or a low byte
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: badline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inge Jørgensen
@@ -115,9 +115,11 @@ files:
115
115
  - lib/badline/drive1541/gcr.rb
116
116
  - lib/badline/drive1541/idle.rb
117
117
  - lib/badline/drive1541/mechanism.rb
118
+ - lib/badline/drive1541/orbit.rb
118
119
  - lib/badline/drive1541/rotation.rb
119
120
  - lib/badline/drive1541/sector_reader.rb
120
121
  - lib/badline/drive1541/serial_port.rb
122
+ - lib/badline/drive1541/sleep.rb
121
123
  - lib/badline/drive1541/track.rb
122
124
  - lib/badline/gui.rb
123
125
  - lib/badline/gui/application.rb