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

27 KiB

1200 baud: two machines in real time, and the CONNECT 1200 auto-baud path

Note on addresses. This report records a run made before the probe-hardening pass, which relocated six internal helpers. Addresses quoted below are the ones those routines had at the time of the run. See ADDRESS_CHANGES.md for the mapping onto the current source.

1200 is the rate most likely to matter in practice. It is the other speed the stock user-port module can produce, so it is the only place besides 300 where a SwiftLink player and an unmodified user-port player can meet, and it is the rate a real Hayes modem's CONNECT 1200 result code puts the driver on by itself. Until now it had never been driven at all: testReport.md records that the only control-register values the module had ever been seen to write were $1E (the probe), $15 (300) and $18 (2400).

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). Both emulators run at true C64 speed - warp is used only for the loader and switched off through the monitor's warp command before the options menu - and their own cycle counters are read at every window edge to prove it.

Short answer:

  • 1200 baud works, in both directions, on both machines, for the whole of six 75-second windows over three boots. 452 seconds of measured link time, 13121 bytes one way and 12109 the other, and not one second in either direction carried zero bytes.
  • The transmitter never parked in the old deadlock state. Thirty state readings at the window edges: txCharActive $E5BB and aciaCommandShadow $E5BE were only ever seen as the two self-consistent pairs, $00/$09 (idle) and $01/$05 (a byte of ours in the chip). The pair that defined the fault - $E5BB set while $E5BE bit 2 is clear - did not occur once, and the script's deadlock probe never fired.
  • 1200 is the first rate at which the 20-byte receive ring has been seen to overflow. Thirty-two overflows across the six windows. Every one of them was a ring overflow, not a bad character - a conditional tracepoint proves that for the nineteen in the four windows it was live in, and the other thirteen carry the same fingerprint - and every one of them was on the same machine. The link resynchronised and carried on each time; it never wedged, and connectionPhase was back at 2 or 3 at every edge with isLinkActive $E03B = $01.
  • The auto-baud path really does select 1200. A fake Hayes modem answered the module's AT string with CONNECT 1200; the driver sniffed it and wrote ACIA control $17. Driving $E7B2 directly with X = 0, 3 and 6 gave $15, $17 and $18 - the three-byte stride of baudEntryTable $E6FD behaving, not just the one index.

Scripts: testRealtime.py (unchanged in method, three small additions noted in section 6) and the new test1200AutoBaud.py. Logs: testLogs/rate1200Run1.txt, testLogs/rate1200Run2.txt, testLogs/rate1200Run3.txt, testLogs/autoBaud1200Run4.txt (and the three failed attempts that preceded it, autoBaud1200Run1..3.txt). Per-second byte counts in testLogs/realtime.r12*.run1200.w*.samples.txt, tracepoint output in the matching *.trace.txt, screenshots shots/rtr12*.png and shots/abab4*.png.


1. How 1200 was selected, and what the chip was given

baudIndex $E055 comes off the disk as 0 every time the module is loaded, so 1200 has to be asked for. Both machines were given C= + 1 at the "PRESS A OR O" prompt, before the link was ever opened - the order README.md tells the player to use - with the Commodore key on Tab, which is where VICE's default symbolic keymap puts it.

The hot key took on the first press on both machines in all three runs, and it programmed the 6551 immediately rather than waiting for the link:

Reading, at the A/O prompt, before the link opened A B
baudIndex $E055 3 3
ACIA control register $DE03 $17 $17
bitPeriodLo $E056 (the live control byte) $17 $17
connectionPhase $E040 / isLinkActive $E03B 0 / $00 0 / $00
aciaCommandShadow $E5BE $00 $00

$17 is $10 (8 data bits, one stop bit, internal baud-rate generator) OR rate bits $7, which on a SwiftLink's doubled 3.6864 MHz crystal is 1200 baud. It matches baudEntryTable+1 at $E701 in the built image, where the seven entries read

$E6FD: B3 15 00   B1 17 00   B2 18 00   B4 1A 00   B9 1C 00   B0 1E 00   B8 1F 00
       C=+3 300   C=+1 1200  C=+2 2400  C=+4 4800  C=+9 9600  C=+0 19200 C=+8 38400

One thing worth writing down because it looks alarming in a log: at that same pre-link edge linkErrorCount $E047 reads 72 on both machines. 72 is $48, the H of the leftover junk bytes 44 54 48 ("DTH") the module ships at $E045-$E047; nothing has cleared it yet because the link has not been opened. It goes to $00 when the link comes up and stays there.

Both machines reached connectionPhase 3 - the game's own packet protocol - in every run, and the side-0/side-1 negotiation completed: one machine ends on "OPPONENT PICKING GAME/MAP." and the other on the GAME TYPE menu. That is the same game state the 300- and 2400-baud runs in realtimeReport.md were measured in, which is what makes the three rates comparable.


2. Bytes each way, per window

conn0 is machine A (answer, the first to open its ACIA); conn1 is machine B (originate). "speed" is each emulator's own free-running cycle counter over the window against the wall clock - the check that this was measured at true C64 speed and not on a machine that could not keep up.

Run Window Wall A speed B speed A -> B B -> A
1 1/2 75.67 s 100.9% 100.5% 1675 B = 22.14 B/s 1584 B = 20.93 B/s
1 2/2 75.58 s 101.3% 100.9% 3042 B = 40.25 B/s 2577 B = 34.10 B/s
2 1/2 75.09 s 101.0% 100.6% 773 B = 10.29 B/s 760 B = 10.12 B/s
2 2/2 75.36 s 101.2% 100.8% 792 B = 10.51 B/s 802 B = 10.64 B/s
3 1/2 75.67 s 101.0% 100.5% 3433 B = 45.37 B/s 3248 B = 42.92 B/s
3 2/2 75.14 s 100.9% 100.5% 3406 B = 45.33 B/s 3138 B = 41.76 B/s
all six 452.51 s 13121 B = 29.00 B/s 12109 B = 26.76 B/s

The 8N1 ceiling at 1200 baud is 120 bytes/s. The six-window average is 24% of it in one direction and 22% in the other, but the average is not the interesting number, because the line does get saturated in bursts: the per-second buckets run from 6 to 119 bytes/s, and 119 is 99% of what 1200 baud can carry in a second. What sets the average is the game, not the wire - the frame layer starts at most one thing per raster IRQ and the packet layer exchanges one packet per lock-step turn.

Per-second buckets, from testLogs/realtime.r12*.run1200.w*.samples.txt, one line per second per window:

Run Window conn0 (A -> B) conn1 (B -> A) seconds at zero
1 1/2 15 to 79 B/s 14 to 100 B/s none
1 2/2 15 to 88 B/s 13 to 88 B/s none
2 1/2 6 to 47 B/s 6 to 69 B/s none
2 2/2 6 to 61 B/s 6 to 84 B/s none
3 1/2 19 to 89 B/s 18 to 99 B/s none
3 2/2 15 to 74 B/s 13 to 119 B/s none

Not one second, in either direction, in any of the six windows, carried zero bytes. That is the single most important line in this report: the failure mode this driver has to avoid is a machine that stops talking, and at 1200 baud it never did, not even in the windows where the receive ring was overflowing.

$55 ARQ lead bytes

The frame layer's lead byte is $55. These are raw occurrences in the window's byte stream, so a $55 that happens to be payload is counted too - the number is a floor on the frame rate, not an exact frame count.

Run Window $55 A -> B per second $55 B -> A per second
1 1/2 553 7.3 580 7.7
1 2/2 713 9.4 733 9.7
2 1/2 279 3.7 254 3.4
2 2/2 297 3.9 291 3.9
3 1/2 786 10.4 751 9.9
3 2/2 758 10.1 688 9.2
all six 3386 7.5 3297 7.3

Every window carried real ARQ frames in both directions. For comparison, the 2400-baud windows in realtimeReport.md section 8 carried about 250 $55 per direction per window - roughly 3.4 frames a second - so 1200 baud in this same game state is doing between one and three times as much frame traffic as 2400 did, which is a statement about how much the two games had to say to each other in each run rather than about the rates.

Byte-sync floods

beginByteSyncPhase pours out $00 until the peer answers, so a run of eight or more $00 in the stream is the signature of a link that has dropped out of the packet phase and is resynchronising.

Run Window A -> B runs of >= 8 zeros / longest B -> A runs / longest
1 1/2 3 / 42 2 / 25
1 2/2 18 / 87 17 / 54
2 1/2 0 / 6 1 / 42
2 2/2 0 / 1 0 / 1
3 1/2 33 / 48 30 / 39
3 2/2 33 / 57 33 / 50

Runs 1 and 3 resynchronised repeatedly and run 2 barely at all. The resyncs track the error counts in section 3 exactly, and every one of them ended - the longest flood in 452 seconds was 87 bytes, under a second of line time at 1200 baud. Compare the pre-fix 2400-baud failure in realtimeReport.md section 4, where one direction carried 4495 bytes of $00 and nothing else for an entire 75-second window and never came back.


3. Errors: all thirty-two were receive-ring overflows, all on the same machine

The NMI receiver pushes two different things through reportLinkError $E3BC into countLinkError $E403: a character whose status shows overrun, framing or parity error, and a good character that had nowhere to go because the 20-byte receive ring was already full. linkErrorCount $E047 cannot tell them apart and cannot even be trusted as a total, because restartByteSync clears it at $E814

  • which is why it reads $00 at every window edge in this report while errors were plainly happening.

So a second tracepoint was added, conditional so that it fires only on the overflow case:

trace exec $e403                 # every character the receiver threw away
trace exec $e505 if x > $13      # ... and the subset thrown away because the ring was full

$E505 is the CPX #$14 inside pushUartRxRing, and X was loaded with uartRxCount three cycles earlier at $E502, so X > $13 means the ring already held all twenty characters. (The monitor reads condition numbers as hex - x > 19 would have meant X > $19 - which is why the constant is written $13.) A tracepoint prints and carries on; it never stops the emulator.

Machines were told apart afterwards by the free-running cycle counter each trace line carries: the two emulators start seconds apart and their stopwatch ranges never overlap.

Run Window $E403 total of which ring-full $E505 on machine A on machine B
1 1/2 7 not instrumented 0 7
1 2/2 6 not instrumented 0 6
2 1/2 8 8 0 8
2 2/2 3 3 0 3
3 1/2 7 7 0 7
3 2/2 1 1 0 1
all six 32 19 of 19, in the four windows it was live in 0 32

In runs 2 and 3, where both tracepoints were live, the two counts are equal in every window: 8 and 8, 3 and 3, 7 and 7, 1 and 1. Not one character was rejected for a bad status. Run 1 had only the $E403 tracepoint, but its trace lines carry the same fingerprint - X reads $14 at $E403 on all thirteen hits, which is uartRxCount = 20 left in X by the ring-full exit - so those thirteen were overflows too.

That is the ceiling the task brief suspected, and it is real at 1200 baud, not only at 38400. The raw receive ring is 20 bytes, only the once-per-frame tick drains it, and awaitWholePacket will not copy a packet out until the whole payload and its check byte are resident - up to 17 bytes - so the real headroom is about three bytes. At 1200 baud a video frame is about 2.0 character times, so a steady 1200-baud stream cannot overflow it; a burst that arrives while the tick is not draining can, and did. There is no RTS throttling anywhere in this driver.

What the driver does about it is the answer that matters. A non-zero linkErrorCount makes runLinkStateMachine restart the byte sync whatever phase it was in, and that is exactly what the $00 floods in section 2 are. Every one of them completed, the link came back to phase 3, and the per-second byte counts never dropped to zero in either direction. It degrades and recovers. It does not wedge.

Two honest caveats. First, every overflow in all three runs was on machine B, the originate side and the second emulator to be started, and machine A recorded not a single one in 452 seconds. That is not explained. It is not the game screen - B was on the GAME TYPE menu in runs 1 and 3 and on "OPPONENT PICKING GAME/MAP." in run 2, and it overflowed in all three. Both machines held 100.5% or better of real time throughout, so it is not one emulator falling behind. Second, VICE's ACIA is byte level: it paces whole bytes at the rate the control register asks for but does not shift them bit by bit, so the framing and parity errors a real noisy line would produce cannot appear on this rig at all. "Zero bad characters" is a statement about this emulation, not about a real cable.


4. The transmitter: the deadlock state never occurred

txCharActive $E5BB and aciaCommandShadow $E5BE were read on both machines at the open of each series and at the close of each window, with both emulators stopped.

txCharActive aciaCommandShadow Readings Meaning
$00 $00 6 at the A/O prompt, before the ACIA was ever programmed
$00 $09 10 idle: transmit interrupt disarmed, nothing believed to be in the chip
$01 $05 14 busy: transmit interrupt armed, a byte of ours in the chip
$01 $09 0 the deadlock - never seen

Thirty readings, only the two self-consistent pairs. testRealtime.isTxDeadlocked reported False at every edge and its repair probe - which would have written $00 over $E5BB from the monitor and said so in the log - never ran, so nothing in these numbers is the test rig patching the driver up.

The full edge table, all three runs, in order (open of the series, close of window 1, close of window 2). err is linkErrorCount $E047, pend is uartPendingCount $E0A5:

Run Edge A: phase / active / err / $E5BB / $E5BE / pend B: phase / active / err / $E5BB / $E5BE / pend
1 before the link opened 0 / $00 / $48 / $00 / $00 / $00 0 / $00 / $48 / $00 / $00 / $00
1 open of the series 3 / $01 / $00 / $00 / $09 / $00 3 / $01 / $00 / $00 / $09 / $00
1 close of w1 2 / $01 / $00 / $01 / $05 / $01 3 / $01 / $00 / $01 / $05 / $03
1 close of w2 3 / $01 / $00 / $00 / $09 / $00 2 / $01 / $00 / $01 / $05 / $01
2 before the link opened 0 / $00 / $48 / $00 / $00 / $00 0 / $00 / $48 / $00 / $00 / $00
2 open of the series 2 / $01 / $00 / $01 / $05 / $01 3 / $01 / $00 / $01 / $05 / $03
2 close of w1 3 / $01 / $00 / $00 / $09 / $00 3 / $01 / $00 / $01 / $05 / $03
2 close of w2 3 / $01 / $00 / $01 / $05 / $01 3 / $01 / $00 / $01 / $05 / $03
3 before the link opened 0 / $00 / $48 / $00 / $00 / $00 0 / $00 / $48 / $00 / $00 / $00
3 open of the series 2 / $01 / $00 / $01 / $05 / $01 3 / $01 / $00 / $01 / $05 / $02
3 close of w1 3 / $01 / $00 / $00 / $09 / $00 2 / $01 / $00 / $01 / $05 / $01
3 close of w2 3 / $01 / $00 / $01 / $05 / $03 2 / $01 / $00 / $00 / $09 / $00

isLinkActive $E03B was $01 at every edge after the link came up and $00 at every pre-link edge. linkErrorCount $E047 read $00 at every edge after the link came up - which, as section 3 explains, means "no error since the last resync" and not "no errors", and is the reason the tracepoints exist. connectionPhase $E040 moves between 3 and 2 as the link drops into and out of a byte-sync round, exactly as it did in the healthy 300-baud run of realtimeReport.md section 3. uartPendingCount never rose above $03, so neither transmitter ever had a backlog.

The ACIA control register $DE03 and bitPeriodLo $E056 read $17 and baudIndex $E055 read 3 at all thirty readings on both machines. Nothing reprogrammed the rate behind the driver's back and nothing drifted.


5. The auto-baud path: CONNECT 1200 really does select 1200

switchTo1200Baud $E7B2 is reached from $E7B0 (LDX #$03) when serviceModemInput sees a CR whose preceding character was '0' - the last digit of a verbose CONNECT 1200. That caller predates the SwiftLink table, so the question is whether X = 3 still lands where the table says it does. It was tested twice, once through the real path and once by driving the entry point directly. Script: test1200AutoBaud.py. Log: testLogs/autoBaud1200Run4.txt.

5.1 Through a fake Hayes modem

One machine, its serial line wired not to a peer but to a TCP listener in the test script that behaves like a modem far enough to answer the AT string. The emulator was left completely alone across the critical window - the answer timer at $E7D5 gives the reply about 121 frames, two emulated seconds, and every monitor command stops the emulated clock.

What the fake modem saw and said, from its own timestamped log:

+100.757  the emulator opened the serial line
+100.787  from the C64: '\r'
+101.870  from the C64: 'A' 'T' 'Q' '0' 'V' '1' 'X' '1' 'A'      (answer-mode Hayes string)
+102.201  from the C64: '\r'
+102.202  answered '\r\nCONNECT 1200\r'
+104.780  from the C64: '\x00' ... the byte-sync flood of connection phase 2

The $00 flood starting 2.58 s after the AT string's CR is the 121-frame answer timer expiring and the state machine falling out of terminal mode into byte sync, which is what should happen. What it left behind:

Reading Value
ACIA control register $DE03 $17 8N1, internal baud generator, rate bits $7 = 1200 baud
bitPeriodLo $E056 $17 the driver's own mirror of the live control byte
baudIndex $E055 0 unchanged - see below
lastModemChar $EB02 $20 the space $E7B5 substitutes for the CR after the sniff
connectionPhase $E040 / isLinkActive $E03B 2 / $01 in byte sync, link up
linkErrorCount $E047 $00

$17 with baudIndex still 0 can only have come from switchTo1200Baud. The only three callers of loadBaudParameters are openCommLink $E2C9, which loads X from baudIndex and would therefore have written $15; the speed hot keys, which do STX baudIndex at $E350 on the way in and would have left $E055 = 3; and $E7B2, which loads X immediately and touches $E055 never. No hot key was pressed in this run.

5.2 Driving $E7B2 directly

With the emulator stopped, the ACIA was forced back to 300 baud, $E056 cleared, txCharActive $E5BB set to $01 by hand, PC put on $E7B2 and X loaded through the monitor; n then steps over the JSR and its whole subroutine. Doing it for three indices exercises the three-byte stride rather than the single value the sniffer uses:

X $DE03 before $DE03 after $E056 after $E5BB after $E055 after Expected
0 $15 $15 $15 $00 0 (untouched) $15, 300 baud - PASS
3 $15 $17 $17 $00 0 (untouched) $17, 1200 baud - PASS
6 $15 $18 $18 $00 0 (untouched) $18, 2400 baud - PASS

So the behaviour matches the table, and matches the built image, where baudEntryTable+1 at $E701 is $17. Two side effects of the current loadBaudParameters show up in that table and are worth recording:

  • txCharActive is cleared. $E5BB was set to $01 before each entry and read $00 after, which is aciaSetControlIdle $E04C doing its job - the third of the three deadlock fix sites in realtimeReport.md section 8. A CONNECT 1200 that arrives while a character is half way out of the chip cannot strand the flag.
  • baudIndex $E055 is not updated. loadBaudParameters writes the control register and bitPeriodLo and nothing else. This is faithful to the stock driver, which behaved the same way, but it has a consequence: openCommLink $E2C6 reloads the rate from baudIndex whenever it finds isLinkActive = 0, so a link that is torn down and re-opened after a CONNECT 1200 comes back at 300 baud, silently. A re-open on a live link (C= + RETURN, which reaches openCommLink with isLinkActive already set) skips loadBaudParameters entirely and keeps 1200.

5.3 What the source said about it

The comment on $E7B0/$E7B2 was wrong in two ways and has been corrected - comments only, the assembled image is byte-for-byte identical before and after, and checkAbi.py still passes:

  • $E7B0 ended "so this caller still selects 120053 cycles = 1200 baud". The 53 cycles is wreckage from the stock driver's comment, where the number really was a CIA timer value.
  • $E7B2 said "copy that entry into the live bit period $E056/$E057 and the pacing divisor $E058", which is what the stock loadBaudParameters did. The SwiftLink one writes $E056 and the ACIA control register; $E057 and $E058 have been dead since the software UART was replaced. It now says what it does, and records the baudIndex trap above.

6. What was changed in the test rig, and what went wrong in it

Three additions to testRealtime.py, all additive:

  • each machine's tracepoint output is now kept separately (tracedBy), so a hit can be attributed to one end;
  • the conditional overflow tracepoint trace exec $e505 if x > $13 is set alongside $e403;
  • the hit counter's regular expression was wrong and is fixed. The monitor announces a hit as #1 (Trace exec e403) and disassembles the line as .C:e403 ... - no dollar sign on either - so a pattern of C:\$e403 matches nothing. The consequence is in the logs of runs 2 and 3: their own printed summaries say "0 tracepoint hits" and that is false. The counts in section 3 are from the saved *.trace.txt files, re-counted afterwards, and they are the real ones. Run 1's log predates the change and counted (Trace instead, which was right while $e403 was the only tracepoint.

test1200AutoBaud.py is new. Three attempts failed before the one reported above, all for rig reasons rather than driver reasons, and two of them are worth passing on:

  • answerModemPrompts can return before the link could possibly have opened. It uses "$E5BE is non-zero" as its test, and at true C64 speed the 1541 takes the best part of a minute to fetch the opponent module off track 34. Until it lands, $E000-$EFFF still holds the previous overlay - $E5BB-$E5BE read E8 84 18 AC in the first attempt, and $AC was taken for an open link while the screen still said "WORKING...". The module's stored bytes there are all $00, so the test is sound once the module is resident; the fix is to wait for residency first.
  • $E013/$E014 is not a safe residency test either, although testRealtime.selectRate uses it: the pre-load wreckage read B7 0D - exactly the patched value - in the second attempt. test1200AutoBaud.waitForModule waits on all sixteen bytes of the $E000 jump table instead, and re-offers the fire button every six polls, because in the third attempt the press that starts the load simply did not land.

One self-inflicted loss: ./build.sh was run to rebuild the disk while a measurement run was in flight, which rewrites the .d64 the running emulators have attached. That run was killed and restarted rather than trusted, and its emulators and Xvfb displays were cleaned up by hand. The three runs reported here all ran against a stable image.


7. What this does not show

  • VICE, not hardware. No physical SwiftLink, no real cable, no real modem. The CONNECT 1200 in section 5 came from fifteen bytes written into a TCP socket by a Python script.
  • VICE's ACIA is byte level. It cannot produce framing or parity errors, so "every error was a ring overflow" is a result about this rig. On a real line the same tracepoints would be expected to show bad characters as well.
  • The overflows are unexplained. Thirty-two of them, all on machine B, none on machine A, over three boots. What burst produces them, and why only one end sees them, was not chased - doing it needs the NMI instrumented, which stops the emulator and destroys the timing being measured.
  • No stock user-port peer. 1200 is where a SwiftLink player and an unmodified user-port player would meet, and that pairing still has not been run. Both machines here were SwiftLink builds.
  • No complete game. Same as every earlier report: the two machines reach the GAME TYPE menu and stay there.
  • Six windows is not a long soak. 452 seconds at 1200 baud is enough to say the deadlock did not recur and the link recovers from overflow, and not enough to say it never will.
  • The auto-baud path was exercised once end to end, in answer mode, on a link with no peer behind the fake modem. The originate string ATQ0V1X1D and a CONNECT 1200 arriving on a link that then goes on to play a real game were not tried.

8. Files

File What it is
test1200AutoBaud.py new: one machine, a fake Hayes modem that answers CONNECT 1200, then $E7B2 driven directly with X = 0, 3 and 6
testLogs/rate1200Run1.txt run 1 - C= + 1 at the A/O prompt, two 75 s windows
testLogs/rate1200Run2.txt run 2 - a second boot, same sequence
testLogs/rate1200Run3.txt run 3 - a third boot, same sequence, with both tracepoints live
testLogs/realtime.r12*.run1200.w*.samples.txt per-second byte counts, both directions, every window
testLogs/realtime.r12*.run1200.w*.trace.txt raw $E403 and $E505 tracepoint output, with the cycle counter that attributes each hit to a machine
testLogs/autoBaud1200Run4.txt the auto-baud run reported in section 5
testLogs/autoBaud1200Run1.txt, ...Run2.txt, ...Run3.txt the three rig failures described in section 6
testLogs/autoBaud1200.ab4.modem.txt the fake modem's own timestamped event log
shots/rtr120*.png, shots/rtr12b0*.png, shots/rtr12c0*.png the menu, the linked pair and the end of each window, runs 1 to 3
shots/abab401Menu.png, shots/abab402AfterConnect.png the auto-baud run