modemwars/swiftlink/wedgeDiagnosis.md
2026-08-23 02:09:40 -05:00

350 lines
23 KiB
Markdown

# Why 38400 wedges
> Folded into `testReport.md` as **section 10**, which carries the headline numbers; this file is the
> full record. It was written against the build that still had the fault, so its addresses and its
> present tense are that build's; `portCompatReport.md` and `testReport.md` section 11 measure the
> build with the re-entrancy guard, in which the failure below does not occur.
`highRateReport.md` characterised the 38400-baud wedge and said plainly that it had not been
explained: four boots, four wedges, one ending in `*** Main CPU: JAM at $0007`, and no path traced
from any measured byte to any of it. This document explains it, from measurements taken for that
purpose.
**The cause is NMI re-entrancy, and it is established, not inferred.** A machine was stopped by a
checkpoint at the instant it entered `commNmiHandler` with `SP = $12`, and thirty-four complete
nested handler frames were decoded off page 1. The descent from a healthy stack to an exhausted one
was recorded three separate times, with the emulator's own cycle counter, and takes **8 to 13
milliseconds**. The arithmetic behind it was measured too: one pass of the handler costs 175 to 266
cycles, and at 38400 baud a character time is 266.3 cycles with **two** interrupts inside it. The
handler cannot finish before the next one arrives, so it never finishes at all.
Everything below is VICE 3.7.1 (`x64sc`), NTSC, `-acia1mode 1 -acia1base 0xDE00 -acia1irq 1`, on
**2026-08-22**, against a disk built from the current source with `./build.sh`
(`checkAbi.py` prints `ABI CHECK PASSED`). **No driver source was changed.** The only write either
script makes to a machine is `baudIndex $E055` before the link is opened - 38400's hot key was
removed from `baudEntryTable`, so the rate is selected by setting the index and letting the driver's
own `loadBaudParameters $E353` program the control register. `$DE03` read `$1F` on every machine in
every run.
---
## 1. The answer
1. The 6502 does not mask NMI. Entering an NMI sets `I`, which stops IRQs, and nothing at all stops
another NMI.
2. `commNmiHandler $E685` reaches `jsr aciaGetStatus` at `$E68F` eight instructions in, and that
routine's `lda $DE01` is the ninth. On a 6551 that read *is* the acknowledge: it clears the IRQ
flag and releases `/IRQ`.
3. From that read to the end of the pass - up to about 220 more cycles - the chip is free to raise
a fresh event. `/IRQ` falls again, the 6502 latches the edge, and the handler is re-entered
at the very next instruction boundary. Each re-entry costs **6 bytes of stack** (the CPU's
`PCH`/`PCL`/`P` plus the handler's `A`, `X` and saved `$01`), or **8** when the edge lands while
the interrupted pass was inside one of its own `JSR`s.
4. At 38400 baud with traffic in both directions there are **two** interrupt-raising events per
character time - one `RDRF`, one `TDRE` - so an edge arrives roughly every 133 cycles, while one
pass of the handler costs 175 to 266. **Service time exceeds inter-arrival time.** The queue is
not a queue: it is a stack, and it grows without bound.
5. About thirty frames is the whole of the 170-odd bytes of page 1 the game leaves free. Measured:
**8.4 ms, 12.5 ms and 11.5 ms** in three independent bursts.
6. Once `SP` wraps past `$00` the frames overwrite one another. Every `RTI` from then on pops a `P`
and a `PC` that belong to a different frame, and the `PLA` / `STA $01` at `$E6E3`/`$E6E4` writes a
foreign byte into the CPU port register. The machine leaves the handler with a corrupted program
counter, a corrupted flag byte, or a corrupted memory map - and that is the wedge.
The driver's own comment at `commNmiHandler` names this race and prices it at "a checksum failure and
one retransmit, which the ARQ layer above already deals with". That price is right for one re-entry.
What the comment does not say is that above about 19200 baud it stops being one re-entry.
---
## 2. What one pass of the handler costs - measured
`testCost.py` traces six addresses inside the handler - the entry `$E685`, `sta aciaRxByte $E699`,
`dec uartPendingCount $E6C9`, `jsr aciaPutData $E6D6`, and both exits (`rti $E6E9`, `jmp
(nmiChainVector) $E67F`) - and pairs every entry with its exit, so each pass can be priced *and*
classified by what it actually did. It was run at **19200**, where the link is healthy and busy, and
where nothing nests: the handler's cost is the same code at any rate.
Four windows, two machines, 34.8 emulated seconds, 5694 complete passes:
| what the pass did | passes | trace-to-trace, median | whole interrupt |
|---|---:|---:|---:|
| received a character | 2983 | 165 cycles | **178 cycles** |
| transmitted a character | 1835 | 165 | **178** |
| transmit interrupt found the ring dry, disarmed it | 677 | 162 | 175 |
| **received and transmitted in the same pass** | 188 | 243-253 | **256-266** |
| neither half had work (a DCD/DSR change) | 11 | 151 | 164 |
"Whole interrupt" adds the 7 cycles the CPU spends taking the NMI and the 6 the `RTI` costs, neither
of which lies between the two tracepoints. A hand count of the same paths off the source agrees to
within a cycle, which is worth saying because it means the figure is a property of the code and not
of the emulator.
Now put that against the line. An NTSC C64 runs at 1,022,727 Hz and 8N1 is ten bits per character:
| rate | characters/s | one character time | one pass | two passes | share of the CPU |
|---|---:|---:|---:|---:|---|
| 300 | 30 | 34090.9 cycles | 178 | 356 | 1% |
| 1200 | 120 | 8522.7 | 178 | 356 | 4% |
| 2400 | 240 | 4261.4 | 178 | 356 | 8% |
| 4800 | 480 | 2130.7 | 178 | 356 | 17% |
| 9600 | 960 | 1065.3 | 178 | 356 | 33% |
| 19200 | 1920 | 532.7 | 178 | 356 | **67%** |
| 38400 | 3840 | **266.3** | 178 | 356 | **134% - cannot keep up** |
The "one pass" column is a single-half pass; a pass that services both halves costs 256-266.
Two numbers in that table decide everything.
* **A full-duplex pass costs 266 cycles and a character time at 38400 is 266.3 cycles.** One
interrupt that services both halves consumes an entire character time on its own, leaving the game
nothing.
* When the receive and transmit events land in *separate* passes - which is the normal case, because
they are separate events at separate instants - the cost is **356 cycles of work offered every 266
cycles**. That is the whole failure in one line.
This is also the ladder every earlier report measured without knowing why: healthy at 4800 (17%),
degrading at 9600 and 19200 (33% and 67%), gone at 38400.
---
## 3. The re-entrancy itself - measured three times
`testStack.py` arms one tracepoint before the link is ever opened:
```
trace exec e685 if sp < $b4
```
A tracepoint prints and lets the machine run on, so this does not disturb anything, and it prints
**nothing at all** while nesting is shallow: the main line runs with `SP` between `$BD` and `$EB`, so
an entry below `$B4` is already one frame deeper than the deepest healthy entry seen on any
machine in any run. Every VICE trace
line carries `SP` and the free-running cycle counter, so the output is a timestamped staircase.
Three runaways were caught, on freely running machines, with no breakpoint involved:
| run | machine | entries | duration | `SP` fell | frames | rate of descent |
|---|---|---:|---:|---|---:|---:|
| `st384d` | B | 44 | 8611 cycles = **8.42 ms** | `$B0` -> `$02` | 174 bytes = 29 | 20666 stack bytes/s |
| `st384d` | B | 91 | 12807 cycles = **12.52 ms** | `$B3` -> `$08` | 171 bytes = 28.5 | 13656 bytes/s |
| `st384c` | B | 84 | 11780 cycles = **11.52 ms** | `$B3` -> `$00` | 179 bytes = 30 | 15541 bytes/s |
The first twenty-four entries `st384d`'s machine B recorded below `$B4`, as `(SP, cycle)`:
```
($B3,153671517) ($B0,+264) ($B2,+152) ($AA,+115) ($AF,+147) ($A7,+117) ($A9,+152) ($A1,+115)
($A9,+153) ($A1,+115) ($A1,+148) ($99,+115) ($A1,+153) ($99,+115) ($99,+147) ($91,+117)
($99,+151) ($91,+115) ($93,+153) ($8B,+115) ($90,+147) ($88,+163) ($8A,+107) ($84,+113)
```
Read the gaps: they alternate **115 and about 150 cycles, and 115 + 150 = 265** - one character time
at 38400, with two interrupts inside it. That is the "two events per character" claim measured
directly rather than argued. Read the stack pointer: it saws up and down as inner passes complete,
and every saw tooth is lower than the last. In `st384d` the in-burst gaps were min 55, 25th percentile 117,
median 158, 75th percentile 265 cycles; in `st384c`, min 55, 25th percentile 60, median 171, max 269
- against a handler that needs 175 to 266.
**Control.** The same script, the same instrumentation, 200 seconds at **19200** with two machines
carrying 16729 and 16856 bytes - six times the traffic the 38400 runs managed - produced **no entry
below `SP $B4` at all, on either machine**, and neither stack was ever pushed below `$01C9`. At
19200 the handler fits inside the character time, so it never nests.
---
## 4. The stack, decoded
`st384b` carried a second checkpoint, `break exec e685 if sp < $20`, which stops the emulator once
the stack is nearly gone. It fired. Machine A was stopped **at `$E685`, the first instruction of
`commNmiHandler`, with `SP = $12`, `X = $14` and `I` set** - about to push three more bytes onto a
stack with room for two more nested passes. This is the one machine whose stack can still be
*walked*: it was stopped part-way down, before the pointer wrapped and the frames began overwriting
one another.
Its page 1, walked upward from `$0113` (the newest pushed byte) to `$01FF`, decodes without a gap or
an ambiguity into **34 complete nested handler frames plus 16 `JSR` return addresses**:
| what | value | count |
|---|---|---:|
| interrupted PC | `$E6C4` | 10 |
| | `$E6C7` | 9 |
| | `$E551` | 8 |
| | `$E6C2` | 7 |
| saved `X` | `$14` | 33 of 33 |
| saved `$01` | `$35` | 33 of 33 |
| saved `P` | `$25` / `$27` | 25 / 9 - `I` set in every one |
| saved `A` | `$10` / `$09` / `$00` | 17 / 8 / 8 |
| `JSR` return addresses under the frames | `$E40A`, `$E6E2` | 8 each |
Every one of those addresses is inside the handler's **transmit half**, in the window between the
status read at `$E68F` and the two writes that would remove the interrupt source:
* `$E6C2` - `beq L_E6EA`, the `TDRE` test
* `$E6C4` - `lda txCharActive`
* `$E6C7` - `beq L_E6D1`
* `$E551` - `sta aciaCommandShadow` inside `aciaSetCommand`, reached from `aciaSetCommandIdle $E408`
at `$E6E0`; the two `JSR` return addresses `$E6E2` and `$E40A` sitting under those frames are that
call chain
The saved `$01` is `$35` in all 33 frames, which by itself proves nesting: `$35` is the value the
handler *forces* at `$E68D`, so a frame that saved `$35` was interrupted by a handler that had
already run. The saved `X` is `$14` = 20 in all 33: `pushUartRxRing` leaves `X` at `uartRxCount`,
and the receive ring was pinned at its 20-byte limit because the game had stopped draining it.
Three things fall out of that table that earlier reports could not explain.
* **`$E6C4` and `$E6C7` straddle `dec uartPendingCount $E6C9`.** A pass interrupted at `$E6C7` has
already read `txCharActive` and decided to count a byte off; the inner pass then does its own
`dec`, clears `txCharActive`, pops the ring and writes the data register; when the outer pass
resumes it decrements *again* for the same byte and writes the data register *again*. That is
where `highRateReport.md` section 4's lost transmit characters come from - VICE's "data register
written although data has not been sent yet", 0.8% at 4800 climbing to 2.2% at 19200, the same
curve as the duty cycle in section 2 - and it is how `uartPendingCount` gets below zero to the
`$FF` that section 6.1 found. *The double decrement is shown here as a frame stopped one
instruction short of the `DEC`; a `DEC`/`INC` imbalance was not separately counted during a storm.*
* **`$E551` explains boot 4's impossible command register.** `aciaSetCommand` writes the shadow at
`$E551` and the chip at `$E554`. A machine thrown off the rails between those two instructions
ends up exactly as `highRateReport.md` section 6.2 found it: `$DE02` = `$05`, transmit interrupt
still armed, while `aciaCommandShadow` says `$09`.
* **The two stacks that finished the job cannot be walked, and that is the point.** `st384c`'s
and `st384d`'s machine B both
finished with page 1 covered end to end in the same material - `$35`/`$14` pairs, `P` bytes of
`$65`/`$67` with `I` set, and the return addresses `$E6B5` (`jsr reportLinkError`), `$E6E2` (`jsr
aciaSetCommandIdle`), `$E40A` (`jsr aciaSetCommand`), `$E551`/`$E554`, `$E6B9`/`$E6BB` and
`$E6E7`/`$E6E8`, the last two being `tax` and `pla` in the handler's own exit sequence. Those two
stacks cannot be walked frame by frame the way `st384b`'s can, because the pointer had wrapped and
the frames had overwritten each other - which is itself the point.
---
## 5. The aftermath, and why the four boots looked different
Once `SP` has wrapped, an `RTI` pops three bytes that were written by three different interrupts.
What the machine does next depends on which bytes. Five 38400 boots were run in this session and
four machines died; with the one `highRateReport.md` caught, they are the *same* failure photographed
at different moments:
| machine | where it ended up |
|---|---|
| `st384b` A | caught mid-runaway: `PC $E685`, `SP $12`, 34 frames still on the stack |
| `wg384a` A | running game code at `$44EB-$4502` with `I` **set**, `SP` frozen at `$BD` |
| `st384d` B | `PC $C631`, `SP $67`, `I` set, page 1 covered in handler frames |
| `st384c` B | `PC $A34E`, `SP $B2`, `I` **and `B`** set, and **`$01 = $31`** - `CHAREN` clear, so `$D000-$DFFF` is the character ROM and the ACIA is not in the address space at all |
| `highRateReport.md` boot 4 | `JAM at $0007`, `$00`/`$01` = `$5E`/`$15` - the same corrupt `PC` and the same smashed CPU port, landing on an illegal opcode instead of on live code |
`st384c`'s `$01 = $31` is the mechanism caught in the open: `$E6E3` is `pla` and `$E6E4` is `sta
$01`, so a wrecked stack hands the memory-map register whatever byte happens to be there. Boot 4's
`$01 = $15` is the same instruction with different rubbish.
### Why it never recovers
`wg384a`'s machine A was measured for 154 seconds after it died, and three readings say why nothing
comes back:
* **`pollCarrierState $E3CB` - the `$E00C` entry the raster IRQ calls once a frame - executed ZERO
times** in a 12.15-second traced window and **zero** again in an 8.19-second one. Its healthy peer
executed it 479 times in 8.51 seconds, 56.3/s. Across the whole run the monitor read machine A's
registers **124 times, and the interrupt flag was set in all 124, with `SP` at `$BD` in all 124**;
the same 124 reads on its healthy peer found `I` set in only 15 - the ones that caught it inside
its raster IRQ - and `SP` ranging over `$DF`-`$E5`. A 6502 with `I` set takes no IRQ, so there is
no `$E000`/X=0 and no `$E00C`, and **nothing in the driver runs except the NMI**.
* **`sta uartPendingCount $E611`, `startNextTxChar`'s once-a-frame repair of exactly the byte
`highRateReport.md` section 6.1 found stuck at `$FF`, also executed zero times.** Its peer ran it
478 times in the same 8.51 seconds. The self-heal cannot heal anything, because the self-heal is
on the frame tick and the frame tick is gone.
* **`serviceLock $E0A7` read `$00`.** It rests at 1; `serviceCommTick` decrements it on entry and
restores it at `$E149`. The runaway threw the CPU out of the middle of a service tick, so the
lock is held for ever - a second latch that would refuse to run the driver even if the raster IRQ
came back.
Its remaining state, for the record: `connectionPhase $E040` = 2, `isLinkActive $E03B` = 1,
`uartRxCount $E42D` = `$14` with both indices frozen at `$02`, `uartTxCount $E414` = 0,
`uartPendingCount $E0A5` = `$00`, `txCharActive $E5BB` = `$00`, `aciaCommandShadow $E5BE` = `$09`,
`$DE01`/`$DE02`/`$DE03` = `$10`/`$09`/`$1F`. The NMI itself was still working perfectly - 18 to 21
interrupts a second, 0.3% duty cycle, every one of them 164 to 207 cycles - and it had nobody left to
deliver to. **That is why the 2400-baud repair probe could never have worked: by the time a wedged
machine can be examined, the driver is fine and the machine above it is dead.**
---
## 6. What was ruled out
| hypothesis | verdict | on what evidence |
|---|---|---|
| NMI re-entrancy | **ESTABLISHED** | 34 nested frames decoded off a live stack; three timed descents; the cost/character-time budget |
| Steady-state CPU starvation by the NMI | **ruled out as the wedge** | after the wedge the NMI duty cycle is 0.3% and the game gets essentially the whole CPU - it just has `I` set. Overload is the *trigger*, not the resting state |
| The 20-byte receive ring being too small | **not the cause** | it overflows at 9600 and 19200 too, and those recover. In the wedges the ring is full because the game stopped draining it - a consequence, and `X = $14` in every stack frame is that consequence in the act |
| `uartPendingCount` reaching `$FF` (`spec_uart.md` hazard 1) | **a consequence, not the cause** | `wg384a`'s machine A wedged with `uartPendingCount` = `$00`. The `$FF` shape needs both the double decrement (section 4) and a dead frame tick (section 5) |
| The control byte `$1F` itself, or the rate table | **ruled out** | `$DE03` read `$1F` on every machine in every run and characters crossed the wire correctly; `highRateReport.md` section 7 ran a machine at `$1F` against a 300-baud peer for 245 s with no wedge, no overflow and no bad character. The chip setting is innocent; the *traffic* is what kills |
| A spurious interrupt storm from the chip (a VICE artefact) | **ruled out** | in normal operation the median gap between handler entries is 267-268 cycles, exactly one character time, and the interrupt rate tracks the character rate. The chip raises one interrupt per event, as it should |
| A protocol deadlock above the driver | **ruled out** | the wedged machines' CPUs were thrown out of their own control flow with a corrupt `P`, `PC` or `$01`; a healthy peer's page 1 in the same run is untouched below `$01C9` |
| The 2400-baud transmitter deadlock | **not this** | `txCharActive`/`aciaCommandShadow` were the self-consistent pair `$00`/`$09` on every wedged machine, as `highRateReport.md` section 6 already found |
| Word length / parity / stop bits | **irrelevant here** | every run was 8N1 throughout. Nothing in this failure depends on the framing |
**38400 does not always kill inside a minute.** Run `st384a` reached phase 3 on both machines and
neither wedged in 200 seconds, on the same build, with the same instrumentation armed. The failure
needs both directions bursting at once - `sendPacket $E909` queues a whole 20-odd byte frame into the
22-byte ring in one loop and the NMI shifts it out back to back - so it is a race that a quiet link
can lose for a while. Five 38400 boots in this session, four machines dead.
---
## 7. What this does not show
* **VICE, not hardware.** No physical SwiftLink and no C64 Ultimate. Two things carry to hardware
regardless, because they are properties of the driver and of the 6502: the handler's cost in cycles
(measured here, and confirmed by a hand count off the source) and the fact that reading the status
register releases `/IRQ` while the handler still has 100 to 240 cycles of work left. What VICE
cannot settle is the precise instant a real 6551 re-asserts `/IRQ`, so the *speed* of the collapse
on a real cartridge may differ from 8-13 ms. The direction cannot: 356 cycles of work per 266
cycles of line time is arithmetic.
* **VICE's ACIA is byte level**, so no framing or parity error can be produced on this rig. On a
real line those errors add `reportLinkError $E3BC` -> `countLinkError $E73F` to the receive path,
which makes the pass *longer*, not shorter.
* **The double decrement of `uartPendingCount` is shown structurally, not counted.** Section 4 has a
stack frame stopped one instruction before the `DEC`; it does not have a `DEC`/`INC` imbalance
counted during a storm. Instrumenting `$E6C9` and `$E71E` unconditionally at 38400 prints
thousands of lines a second, and the storm lasts eight milliseconds.
* **Nothing was fixed and nothing was changed.** No driver source file was touched;
`checkAbi.py` passes on the image every run above was made against.
---
## 8. What any fix has to satisfy (not implemented)
Stated here only because the measurements pin the constraints, and because two of them rule out
repairs that look obvious:
1. **The handler must be safe to re-enter, or must not be re-enterable.** Re-entry is not rare at
high rates and it is not free: it costs 6 to 8 bytes of stack and it corrupts `uartPendingCount`,
`txCharActive` and the byte in the transmitter. A re-entrancy counter alone is not enough - the
source's own comment is right that naively refusing a nested pass can lose the interrupt that
would have restarted the transmitter, and a lost `TDRE` stalls the transmitter for ever.
2. **Total interrupt work per character time must stay below one character time at the rate the user
has configured.** At 38400 that means the two halves must be serviced by *one* interrupt of well
under 266 cycles - i.e. the handler has to get materially cheaper, or the transmitter has to stop
asking for an interrupt per character.
3. **Whatever is done must hold when the user's hardware is set to a rate the driver did not choose**,
which is the point of the current work: the rate is not the driver's to pick.
4. **Eight data bits are mandatory** - the frame layer sends `$55` length codes, sequence bytes and
rotate-and-add checksums, so it is not 7-bit clean. Parity and stop bits are free. Nothing in
this diagnosis depends on framing either way.
---
## 9. Files
| File | What it is |
|---|---|
| `testWedge.py` | pairs the handler's entry with its exits, counts re-entries, measures the duty cycle, counts the `$E00C` frame service, and profiles PC/SP with monitor pauses |
| `testStack.py` | the deep-nesting tracepoint and the stack-page checkpoints; `traceonly` mode records the staircase without ever stopping the machine |
| `testCost.py` | classifies and prices every pass of the handler, and prints the per-character-time budget |
| `testLogs/wedge.wg384a.txt` | the 38400 boot whose machine A died with `I` set and its raster IRQ gone |
| `testLogs/wedge.wg384a.A.nmi.trace.txt`, `.A.after.trace.txt`, `.B.after.trace.txt` | the traces behind the "`$E3CB` executed zero times" measurement |
| `testLogs/stack.st384a.txt` | a 38400 boot in which neither machine wedged in 200 s |
| `testLogs/stack.st384b.txt` | the boot stopped at `SP = $12`; the stack page decoded in section 4 |
| `testLogs/stack.st384c.txt`, `stack.st384d.txt` | the trace-only boots; the three timed descents |
| `testLogs/stack.st384c.B.deep.txt`, `stack.st384d.B.deep.txt` | the raw staircases, with `SP` and cycle counter per line |
| `testLogs/stack.st192a.txt` | the 19200 control: heavier traffic, no nesting, no wedge |
| `testLogs/cost.c192a.txt`, `testLogs/cost.c192a.[AB].[01].trace.txt` | the pass-cost measurement of section 2 |
| `shots/wgwg384a*.png`, `shots/stst384*.png`, `shots/stst192a*.png` | both machines at the end of each run |