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

23 KiB

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 JSRs.
  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