81 KiB
Testing the SwiftLink opponent module in VICE
Note on addresses. Sections 0 to 9 record runs made before the probe-hardening pass, which relocated six internal helpers, and before the adopt-mode and re-entrancy-guard pass, which moved five more. Addresses quoted in those sections are the ones those routines had at the time of the run. Sections 10 and 11 quote the addresses of the builds they were run against - the pre-guard build in section 10, the current one in section 11. See
ADDRESS_CHANGES.mdfor both mappings.
What was tested: ../Modem_Wars_SwiftLink.d64, the stock game disk with the $E000 opponent module
replaced by the 6551/SwiftLink build (swiftlink/build/swiftlinkDriverE000.bin). Everything below
was observed in VICE 3.7.1 (x64sc) - sections 0 to 6 on 2026-08-21, the fix in section 5 and
sections 7 to 9 on 2026-08-22, against a disk rebuilt with ./build.sh (checkAbi.py: 452 of 4096
bytes changed in 24 regions, ABI CHECK PASSED). Nothing is inferred from the source alone unless
it says so.
Scripts used, all in swiftlink/:
| script | what it does |
|---|---|
viceHarness.py |
launches x64sc on a private Xvfb display with a private config file, drives the remote monitor, sends real key events |
testBoot.py |
boots a disk, photographs the options menu, dumps $E000 |
testModemSelect.py |
boots, picks COMPETE WITH MODEM OPPONENT, answers the modem prompts, records every access to $DE00-$DE03 |
testTwoMachines.py |
two instances with their serial lines cross-connected through a TCP relay |
testRealtime.py |
two instances at true C64 speed, windowed byte counting, cycle-counter check, error tracepoint, hot-key rate selection |
testHotkeys.py |
one instance at true C64 speed: which host key is the Commodore key, C= + C, the border paint, forced-DCD traces, baud hot keys |
testStrap.py, testStrapPair.py |
the $DF00 strap: one machine with watchpoints on both cartridge pages and both mirror ranges, then two machines with the mirror tripwire armed |
test1200AutoBaud.py |
one machine against a fake Hayes modem that answers CONNECT 1200, then $E7B2 driven directly |
testHighRates.py |
4800 to 38400 and a deliberate rate mismatch: per-machine rate selection, clean and instrumented windows, uartRxCount sampling, a wedge dump and a uartPendingCount repair probe |
testCost.py |
prices and classifies every pass of the NMI handler by pairing its entry with its exits, and prints the per-character-time budget |
testWedge.py |
pairs handler entries with exits, counts re-entries, measures the duty cycle, counts the $E00C frame service, and profiles PC/SP |
testStack.py |
the deep-nesting tracepoint trace exec e685 if sp < $b4 and the stack-page tripwires; traceonly mode records the staircase without ever stopping the machine |
testGuard.py |
counts entries to commNmiHandler against the ones the re-entrancy guard turned away |
testAdopt.py |
one machine: control-register presets written from the monitor and read back out of the driver's choices, plus the 38400 hot key and C= + U |
testAdoptPair.py |
the same with two machines: control (and command) register pre-set on both, before/after dumps and a measured window |
scanIoAccess.py, probeBankOut.py, probeOpenBus.py |
static scan of every instruction in the game that can reach $DE00-$DFFF, VICE's I/O map, and what an empty $DE00 reads back |
probeInput.py, probeFire.py, probeModemPrompt.py |
small experiments used to work out how to drive the game headlessly |
Logs kept: testLogs/aciaAccessLog.txt (800 monitor stops on $DE00-$DE03),
testLogs/twoMachineRun.txt (the whole two-machine session), testLogs/realtimeRun300first.txt and
testLogs/realtimeRun2400first.txt with their per-second testLogs/realtime.*.samples.txt,
testLogs/hotkeyRun.txt and testLogs/hotkeyKeymapSweep.txt, testLogs/fixRun1..3*.txt,
testLogs/strapDf00*.txt and strapSelectDf00*.txt, testLogs/rate1200Run1..3.txt and
autoBaud1200Run1..4.txt, testLogs/highRate*.txt, the wedge-diagnosis logs
testLogs/stack.st384*.txt, stack.st192a.txt, wedge.wg384a*.txt and cost.c192a*.txt, and the
current build's testLogs/portCompat*.txt with their stack.pc*.deep.txt traces.
Sections 1-4 all ran in warp. Sections 5 to 11 ran at true C64 speed, with the qualifications each
section states; how that was finally managed is the first entry under "Problems and loose ends".
Seven companion reports hold the full detail: realtimeReport.md, hotkeyReport.md,
strapReport.md, rate1200Report.md, highRateReport.md, wedgeDiagnosis.md (section 10) and
portCompatReport.md (section 11). hardenReport.md covers the probe-hardening pass between them.
Sections 10 and 11 supersede parts of section 9, and of highRateReport.md and hardenReport.md.
Section 9's "38400 wedges, four boots out of four" was a measurement of a build whose NMI handler could
be re-entered without bound; section 10 establishes that mechanism and section 11 measures the build
that fixes it, in which 38400 is selectable and did not wedge in seven boots. The older sections and
reports are left exactly as they were taken - they are records of runs made against the build of the
day - and carry pointer notes instead.
Because the machine is shared, every run picks its own X display number, its own TCP ports and its
own VICE config file (-config <scratch>/vicerc.<label>.<pid> +saveres), so the user's
~/.config/vice/vicerc is never read or written and no fixed port or display is claimed. Only
processes started by these scripts were ever killed.
0. The SwiftLink baud doubling - verified
The task said to check the claim that a SwiftLink's 3.6864 MHz crystal doubles every rate in the 6551's table, before relying on it. Two independent sources agree with it, so the claim holds:
- cc65's SwiftLink serial driver (
libsrc/c64/ser/c64-swlink.s) maps control-register values to rates as $02=150, $05=300, $06=600, $07=1200, $08=2400, $09=3600, $0A=4800, $0B=7200, $0C=9600, $0E=19200, $0F=38400 - exactly twice the data sheet's nominal rate for each code. - VICE's own ACIA emulation does the doubling explicitly.
vice/src/aciacore.choldsacia_bps_table[16] = {10, 50, 75, 109.92, 134.58, 150, 300, 600, 1200, 1800, 2400, 3600, 4800, 7200, 9600, 19200}with the comment "swiftlink and turbo232 modes use the same table except they double the values", andget_acia_bps()returnsacia_bps_table[ctrl & 0x0F] * 2forACIA_MODE_SWIFTLINK.
So the module's table at $E6FD - $15, $17, $18, $1A, $1C, $1E, $1F for 300, 1200, 2400, 4800, 9600, 19200 and 38400 - is right, and it is right in VICE too, which matters because VICE paces the emulated line at the rate the control register asks for.
1. The patched disk boots exactly like the stock disk
Command line (the ACIA arguments are the same in every run):
x64sc -config <private> +saveres -remotemonitor -remotemonitoraddress 127.0.0.1:<free port>
-drive8truedrive -drive8type 1541 -sounddev dummy +sound -warp -jamaction 2
-autostart-warp -model ntsc -joydev2 1
-acia1 -acia1mode 1 -acia1base 0xDE00 -acia1irq 1 -myaciadev 0
-rsdev1 127.0.0.1:<free port> -rsdev1baud 2400
-autostart <disk.d64>
Both disks were booted this way, breaking at $0800 to know when the loader had finished. (Sections 5
and 6 use the same line with +warp instead of -warp, and warp the loader through from the monitor
instead - see the first loose end.)
shots/stockMenu.png- the stockModem_Wars_1988_Electronic_Arts_b4.d64.shots/swiftlinkMenu.png- the patchedModem_Wars_SwiftLink.d64.
The two are the same screen: the STATS / REPAIR / MISC tab bar and the OPTIONS panel listing COMPETE WITH MODEM OPPONENT / PRACTICE WITH SOLO TRAINER / WATCH GAME FILM / SAVE GAME FILM / LOAD GAME FILM. No crash, no hang, no missing text. (The highlight bar blinks, so it is present in some screenshots and not in others; the selected row was read out of $91D5 instead of judged from the picture.)
At this point $E000 still holds the solo trainer module (4C 43 E0 4C 60 E0 ...) on both disks -
the game only fetches the modem build when the player asks for a modem game.
2. COMPETE WITH MODEM OPPONENT reaches the modem screens
Driving the menu needed two fixes that are worth recording for anyone repeating this:
- VICE's GTK front end ignores synthetic
XSendEventkey presses, which is whatxdotool key --window <id>sends. Keys only arrive through XTEST, i.e.xdotool keywith no--window, afterxdotool windowfocus --sync. (There is no window manager on the private display, sowindowactivatecannot work - it needs_NET_ACTIVE_WINDOW.) - VICE's numpad joystick uses KP_0 as the fire button (
kbd_initialize_numpad_joykeysinarch/gtk3/kbd.c), not KP_5. With KP_8 held, zp_5D went $0F -> $0E and the menu row $91D5 went 1 -> 0; with KP_0 held, joyFireUp (zp_5F) went 1 -> 0.
With that, selecting row 0 produces the sequence the disassembly predicts:
- The game reloads the $E000 module from track 18 sector 7 + track 34 and cold-initialises it.
Memory afterwards:
$E000: 4C 11 E1 4C 9A E2 4C A9 E0 4C F6 E0 4C CB E3 60- byte for byte the SwiftLink build, and nothing like the trainer that was there a moment earlier. $E540-$E56F heldAE 55 E0 20 53 E3 A9 09 20 51 E5 20 5F E5 4C 58 E5 | 8D BE E5 8D 02 DE 60 | AD 01 DE 8D BC E5 60 | AD 00 DE 60 | 8D 00 DE 60 | 8D 03 DE 60 | AD 03 DE 60, i.e.configureUserPortLinesfollowed by the six one-instruction ACIA accessors, all aimed at page $DE. The stock module has CIA2 writes (A9 00 8D 05 DD ...) at that address. $E012 read4C B7 0Dand $E0154C EC E2, so the game had patched the module's keyboard hook to its ownscanKeyboardand keys really do pass through the driver. - "GET OPPONENT ON PHONE AND / PRESS A OR O AND SET MODEM." -
shots/modemPromptAnswerOrOriginate.png. - Answering with A (or O) moves on to "PRESS SPACE, WAIT, HANGUP PHONE." -
shots/modemPromptPressSpace.png. This isopenCommLinkat $1B69 in the main program; the module's $E003 entry is only called after the space bar.
Nothing hung and nothing crashed at any of those steps. Note that no ACIA register is touched until both prompts have been answered - watchpoints on $DE00-$DE03 stayed silent through the whole menu, the module load and both prompts, which is exactly right.
3. The module really programs the 6551
With watch store $de00 $de03 and watch load $de00 $de03 armed from the options menu onwards, and
the two prompts answered with A and then space, the very first accesses to the cartridge were these
(the monitor prints the instruction and the accumulator at each stop; the full 800-stop log is in
swiftlink/testLogs/aciaAccessLog.txt):
1 $E567 STA $DE03 A=$1E aciaProbe: write the probe pattern to the control register
2 $E56B LDA $DE03 A=$1E aciaProbe: it reads back - a 6551 answered at $DE00
3 $E567 STA $DE03 A=$15 loadBaudParameters: control = 8N1, internal clock, rate bits $5
4 $E55F LDA $DE00 A=$00 aciaResumeReceiver: drain whatever the receiver had latched
5 $E558 LDA $DE01 A=$50 aciaGetStatus: seed the status shadow, clear the IRQ flag
6 $E554 STA $DE02 A=$09 setCiaNmiMask: command = DTR on, RTS low, RX IRQ on, TX IRQ off
7 $E567 STA $DE03 A=$1E configureUserPortLines -> loadBaudParameters -> aciaDetect again
8 $E56B LDA $DE03 A=$1E
9 $E567 STA $DE03 A=$15 control = $15 again
10 $E554 STA $DE02 A=$09 command = $09
11 $E55F LDA $DE00 A=$00 drain the receiver
12 $E558 LDA $DE01 A=$10 read the status (TDRE set, no IRQ pending)
The values written, and whether they are the intended ones:
- Control register $DE03 = $15. Bit 4 = 1 is the internal baud-rate generator; bits 6-5 = 00 is
8 data bits; bit 7 = 0 is one stop bit; bits 3-0 = $5. On a SwiftLink that rate code is 300 baud
(150 doubled), which is what
baudIndex($E055 = 0) selects, and 300 baud is what the stock bit-banged driver defaults to - so the two builds are still wire compatible at their default speed. This matches the intent exactly: 8N1, internal clock. - Command register $DE02 = $09. Bit 0 = 1 DTR asserted; bit 1 = 0 receiver interrupt enabled; bits 3-2 = 10 which on a 6551 means RTS low (asserted) with the transmit interrupt off; bit 4 = 0 no echo; bits 7-5 = 000 parity disabled. Again exactly the documented intent - "DTR on, RTS low, receiver IRQ on, transmit IRQ off" - and it agrees with the source comment at $E546.
- $DE02 = $05 appears as soon as there is something to send: the same byte with bits 3-2 = 01,
i.e. the transmit interrupt armed. It goes back to $09 the moment the transmit ring runs dry.
That is
startNextTxChar/ the NMI transmitter behaving as designed. - $DE03 = $1E is not a baud setting; it is
aciaProbe's test pattern, written and read back to find out whether a cartridge is there.loadBaudParametersoverwrites it with the real value two instructions later, so the ACIA is never left holding it. - Status reads returned $50 on the first read, $10 on the second and $90 on every read made from inside the NMI handler. $90 = IRQ flag + TDRE, the handler acknowledging its own interrupt. DCD (bit 5) read as 0 in all three, i.e. carrier present - a monitor peek at $DE01 before the link was opened had shown $30, with DCD set. So in VICE the emulated carrier comes up as soon as the ACIA is opened, and that is what lets the driver walk past the Hayes dialogue on its own.
And it transmits. The bytes handed to the data register at $E563 were, in order:
$0D $41 $54 $51 $30 $56 $31 $58 $31 $41 $0D = CR "ATQ0V1X1A" CR
which is modemAnswerInitString ($EBE3) - quiet off, verbose on, extended result codes, answer -
the Hayes string openCommLink queues for answer mode. After the modem answer timer expired the
driver moved into connection phase 2 and started the byte-sync handshake: the remaining 191 captured
data-register writes were all $00, which is beginByteSyncPhase sending $00 until the peer answers
$00. The far end of the TCP line in that run was a logging sink that never answers, so it kept
sending them - exactly what the protocol says it should do.
That is the whole answer to "does it really talk to the ACIA": it probes it, programs control and command, drives the transmitter from the NMI, and reaches the game's own link handshake.
Caveat about this particular capture: I stopped that run once it had collected 800 watchpoint stops, so its own serial-side byte log went with the process. The byte-level proof that the traffic really leaves the emulator is in section 4.
4. Two machines over TCP - they play each other
This one worked, and it went further than "they exchange bytes".
VICE's -rsdev1 "host:port" makes the emulator a TCP client, so two instances cannot be aimed at
each other directly. testTwoMachines.py therefore runs a small relay: it listens on one free port,
accepts both emulators' connections, forwards every byte from one to the other and logs both
directions. That is a null modem cable made out of sockets - it adds no protocol of its own, so
anything the two C64s agree on they agreed on themselves. Both instances got the same ACIA settings
as above (-acia1mode 1, base $DE00, NMI).
Sequence: boot both, pick COMPETE WITH MODEM OPPONENT on both, answer the A/O prompt with A on machine A and O on machine B, then space on both. The relay log then shows, in order:
[relay] connection 1when A's driver opened its ACIA,[relay] connection 2when B's did. Both scripts confirmed the open by readingaciaCommandShadow($E5BE) back as$05.- Traffic in both directions, beginning with the
$00 ... $00 $FFpattern ofbeginByteSyncPhase, then$55-led frames. Early sample, machine A to machine B:55 EE 55 | 55 0A 70 | 55 EE 55 | 00 00 ... 00 FF | 55 EE 55 | 55 0A 70 | 55 0E 74 | 55 EF 56 ...and B to A the same shape with different sequence bytes - this slice starts mid-frame -... ED 54 | 55 09 6F | 55 05 6B | 55 05 6B | 55 EE 55 | 55 0E 74 .... Later, once a game was being negotiated, longer frames appear:59 08 8C 05 F7repeated. - Totals kept climbing throughout: 2676/2040 bytes at the first checkpoint, 9085/10100 at the second, 12818/10442 at the third.
Both drivers reached connection phase 3 - runPacketPhase, the game's own packet protocol - and
stayed around phases 2 and 3, dropping back to a re-sync now and then and recovering:
[A] carrierOverrideFlags = 0, connectionPhase = 3
[A] carrierOverrideFlags = 0, connectionPhase = 2
[A] carrierOverrideFlags = 0, connectionPhase = 3
[B] carrierOverrideFlags = 0, connectionPhase = 3
And the game layer above it worked too. shots/two02aLinkOpen.png shows machine A displaying
"OPPONENT PICKING GAME/MAP." - message $1A, which startGameSession pins on side 1 while it waits
for side 0's map seed - and shots/two02bLinkOpen.png shows machine B on the GAME TYPE menu
(SCRIMAGE / QB SNEAK / THE BOMB / FACE-OFF / SLUGGERS / FULL WAR / DEFENDER). So the two machines
had already agreed over the SwiftLink link which of them plays side 0 and which side 1, and side 0
had moved into the setup overlay. That is the game's own handshake running end to end over a 6551.
Final register and state readback, taken through the monitor with both links up:
| machine A | machine B | |
|---|---|---|
$DE02 command |
$09 |
$09 (shadow $E5BE) |
$DE03 control |
$15 |
- |
$DE01 status |
$10 (TDRE, DCD low = carrier) |
$98 in aciaStatusSave |
$E03B isLinkActive |
$01 |
$01 |
$E03C/$E03D link status |
$F8/$F8 (carrier + data flowing) |
$F8/$F8 |
$E040 connectionPhase |
$02 |
$03 |
$E047 linkErrorCount |
$00 |
$00 |
$E033 ciaIcrShadow |
$7F |
$7F |
That last row is worth calling out: swiftlink/DESIGN_NOTES.md requires ciaIcrShadow to be pinned
at $7F at run time, because the main program writes that byte straight back into CIA2's interrupt
mask after every film-buffer access and this driver wants no CIA2 interrupt at all. Its stored value
in the module image is $90; both running machines read back $7F, so setCiaNmiMask really does
correct it.
What did not work: the C= + C carrier-override hot key. forceCarrier() sent ctrl+c four
times to each machine and carrierOverrideFlags ($E04A) read back 0 every time, so I cannot say
that hot key works - either VICE's default keymap does not put the Commodore key on left Ctrl, or the
key never reached the driver's hot-key layer. It turned out not to be needed: VICE reports DCD
asserted as soon as the ACIA is opened, so the drivers walked past the Hayes dialogue and into byte
sync on their own. Shortly after those keystrokes both machines put up "PICK UP PHONE THEN PRESS
SPACE." (message $17, the reconnect prompt) - shots/two04aLater.png and shots/two04bLater.png -
so something in that key sequence did disturb the session, but I did not chase down what.
That is now explained, and the hot key does work - see section 6. The first of the two guesses
above was the right one: VICE's default keymap puts the Commodore key on Tab, and Control_L is
the C64 CTRL key, which the game's own scanKeyboard reports as "no key at all", so ctrl+c could
never have reached the hot-key layer no matter how often it was sent.
The full run log is swiftlink/testLogs/twoMachineRun.txt.
5. Real time instead of warp: 300 baud is healthy; 2400 wedged, was fixed, and now holds
Warp can be switched off (see the first loose end below), so the two-machine test was repeated with
both emulators at 1 MHz for everything after the loader. Script testRealtime.py, two boots,
four 75-second measurement windows during which no monitor input was sent to either machine, byte
counts taken from the relay's own thread. Full detail, including the per-second samples, is in
realtimeReport.md.
Each window's emulated speed was checked independently with the monitor's stopwatch cycle counter
against wall clock: every window came out at 100.5% to 100.9% of real time, so neither emulator
was falling behind. (The systematic +0.5-0.9% is the rig - the counter is read while the machine is
stopped and the wall clock starts after it is resumed.)
At 300 baud the link is healthy and the throughput is exactly the line rate. While the two games
had something to say to each other - the side-0/side-1 negotiation of section 4 - almost every
one-second bucket held exactly 30 bytes in each direction, from t=2 s to t=55 s; over those 54.09
seconds the averages were 29.14 bytes/s A->B and 29.43 B->A. 8N1 costs ten bits per byte, so 30
bytes/s is 300 bit/s: 100% of what 300 baud can carry, in both directions at once. The
window as a whole was 1796 bytes A->B and 1813 B->A in 75.13 s; the tail is quieter because the link
dropped into a byte-sync round. 697 $55 frame lead bytes from A and 626 from B in that window, so
this is the real ARQ frame layer, not a sync flood. Errors: linkErrorCount $E047 read $00 at
both ends and a non-stopping tracepoint on countLinkError $E403 recorded zero hits for the
whole window. This also confirms VICE paces the emulated line from the ACIA control register and not
from -rsdev1baud (which said 2400 throughout).
At 2400 baud, before the fix, the link came up and then wedged. Both machines reach connection phase 3 at 2400,
whether the rate is set with C= + 2 on a live link (run A) or at the "PRESS A OR O" prompt before the
link is opened (run B, the order README.md tells the player to use), and both times it collapsed
within about twenty seconds and stayed collapsed for three consecutive 75-second windows. The
collapsed state is one-sided: one machine sends 0 bytes in 225 seconds while its peer pours out
$00 byte-sync characters at 59.8 bytes/s - exactly one per video frame, which is the game's
once-per-raster-IRQ link tick, not the line rate. No character-level error was counted at either end
while this happened.
The stalled machine is stuck, not idle: txCharActive $E5BB = $01 with aciaCommandShadow
$E5BE = $09 (transmit interrupt off) and uartPendingCount $E0A5 frozen at $03. That pair
is a state the driver cannot leave - startNextTxChar returns early while txCharActive is set, and
the NMI's transmit half tests the command shadow before it will clear it. Tested directly:
writing a single $00 over $E5BB from the monitor and touching nothing else made the machine that
had sent nothing for 225 seconds send 37 bytes in the next 15, and its peer answered with 98. How
the driver gets into that state was not determined; realtimeReport.md section 4 lists the two
candidate paths (both in the interrupt path, both consistent with the failure appearing at 2400 and
not at 300) and does not pick between them.
That was fixed on 2026-08-22, and the fix was re-measured on the same rig. txCharActive means
"a byte of ours is sitting in the ACIA's transmit holding register" and nothing else, so it must never
outlive the transmit interrupt that clears it. Three sites now hold that invariant:
aciaSetCommandIdle $E72A, called from the NMI's disarm at $E6E0, which writes the command
register and clears the flag with it and closes the one path that was traced; healStuckTransmitter
$E057, now the first thing startNextTxChar $E607 does, which clears the flag on sight once a
frame whenever the command shadow says the transmit interrupt is disarmed; and aciaSetControlIdle
$E04C, called from loadBaudParameters, so that a speed hot key landing on a character in flight
cannot strand it. The two helpers came out of the seventeen dead stock-UART constants at
$E04C-$E054 and $E057-$E05E; adding those two ranges, plus $E607 where startNextTxChar now
calls healStuckTransmitter instead of loading the flag directly, is why checkAbi.py reports 452
changed bytes in 24 regions where the pre-fix build reported 434 in 22. The other two changed call
sites, $E35C and $E6DE, are inside ranges the SwiftLink build had already replaced. Nothing in
the packet layer, the ring buffers, the modem state machine or the frame/ARQ layer moved.
The re-measurement, in realtimeReport.md section 8: nine 75-second windows at 2400 baud over
three boots, 675 seconds of measured link time, and both directions carried traffic in every single
second of every one of them. Both ways of reaching the rate were used, C= + 2 on a live link and
C= + 2 at the A/O prompt. Across 66 state readings at the window edges the pair that defined the
fault - txCharActive $E5BB set while aciaCommandShadow $E5BE bit 2 is clear - never
occurred, only the two self-consistent pairs $00/$09 and $01/$05 did, and the script's
deadlock probe (which would have written $00 over $E5BB and said so) never fired, so nothing in
those numbers is the rig repairing the driver. Errors: linkErrorCount $E047 read $00 at every
edge and the $E403 tracepoint recorded zero hits in each of the seven windows where it was tallied.
The 300-baud windows in the same runs came back at 29.81 / 29.82 bytes/s, and a run that dropped from
2400 back to 300 with C= + 3 on a live link survived the change and kept playing - which the pre-fix
run had not.
What the fix does not claim: the 2400-baud windows were quiet ones, about nine bytes a second, so
the transmitter's arm/disarm path was exercised a few thousand times but the line was never loaded.
The pre-fix fault appeared in exactly this game state and in windows measured the same way, which is
what makes the comparison fair, but a saturated 2400-baud link was never produced, because nothing in
this game state produces one. And the fix is proven by absence over 675 seconds at a rate that used
to fail within twenty, not by a proof that no path into the state remains -
healStuckTransmitter is what makes any remaining path self-healing within one video frame rather
than fatal.
One more measured number worth keeping: one byte per video frame, 59.8 bytes/s, is the ceiling this
driver's link layer can offer no matter how fast the line is. That is the measured form of what
README.md section 3 says in prose about latency rather than throughput.
6. The Commodore-key hot keys, C= + C included
Script testHotkeys.py, one machine at true C64 speed against a TCP sink that accepts the connection
and never answers. Full detail in hotkeyReport.md.
Which host key is the Commodore key was settled by experiment, by holding each candidate together
with C for 900 ms and reading carrierOverrideFlags $E04A (only C= + C touches it) and
lastHotkeyCode $E046 while the keys were still down:
held with C |
$E04A before -> after |
$E046 while held |
verdict |
|---|---|---|---|
Control_L |
$00 -> $00 |
$FF |
nothing reaches the module at all |
Tab |
$00 -> $40 |
$C3 |
C= + C fired: this is the Commodore key |
Super_L |
$40 -> $40 |
$C3 |
the letter arrives as a plain key; no hot key |
Reproduced from three separate boots, and it agrees with the emulator: the monitor reports
KeymapIndex=0 with KeymapSymFile="gtk3_sym.vkm", and that file has Tab 7 5 8200 (row 7 column 5,
"left cbm") and Control_L 7 2 16392 (the C64 CTRL key). Only the positional map puts C= on
Control_L. The Super_L row is the control case: it is in neither keymap, so the C arrives
alone, $E046 becomes $C3 - and $E04A does not move, because $E31F cpx #$80 fails. "The key
got in" and "the hot key fired" are two different observations.
C= + C toggles the override on and off - $E04A $00 -> $40 -> $00, $40 being
carrierOverrideToggleMask $E04B - and it works before the link is open as well as on a live link.
With the link up, the bytes the rest of the game reads change with it and change back:
| state | $E03C linkStatus |
$E03D linkStatusSample |
|---|---|---|
| override off | $78 (a real carrier sample) |
$78 |
| override on | $40 (an override sample) |
$40 |
| override off again | $78 |
$78 |
And it does what it exists for. VICE's emulated 6551 never sets status bit 6 - the bit a
SwiftLink puts DCD on - so "no carrier" was injected: break at $E5E9, poke aciaStatusSave $E5BC
to $40 (DCD deasserted), single-step the carrier sampler. With the override on the BIT aciaStatusSave at $E5F1 is never executed and $E03D is published as $40 = carrier; with the
override off and the same forced input, the DCD test runs, $E03D is published as $00 = no
carrier, and the game would tear the session down. Identical input, opposite results, so the hot key
is doing exactly the job section 3 of the README claims for it.
A baud hot key really reprograms the ACIA. On the live link: C= + 2 put baudIndex $E055 = 6,
bitPeriodLo $E056 = $18 and $DE03 = $18 (2400 baud on the doubled clock); C= + 3 put all
three back to 0 / $15 / $15. $18 is a control byte no earlier test in this project had ever
written - section 3 above recorded only $1E and $15.
The border indicator paints. A checkpoint on the module's own STA VIC_BORDER at $E30C, armed
first and Tab pressed afterwards, fired with A = $02 (red - $04 from linkStatusBorderTable
$E05F index 5, halved by the LSR at $E305) and X = $80, scanKeyboard's "Commodore key is
held" flag. Reading $D020 from the monitor is not how to see this: it reads the game's own $FC
almost always, because the game rewrites the border later in the same frame. What it looks like on
screen with the key held was not captured.
Still unpressed at the time of this section: C= + H, C= + P, C= + A, C= + O, C= + RETURN, and the C= + 1 / 4 / 9 / 0 / 8 baud keys. The five baud keys have since all been pressed - sections 8 and 9
- and every one of them programmed the chip on the first press. The other five hot keys have still never been pressed.
7. The $DF00 strap option
Full detail in strapReport.md; scripts testStrap.py, testStrapPair.py, scanIoAccess.py,
probeBankOut.py, probeOpenBus.py. A SwiftLink has a jumper that moves its 6551 from I/O1
($DE00) to I/O2 ($DF00), and a C64 Ultimate's emulation can be configured either way. The module
probes $DE00 and falls back to $DF00, but every run in sections 0 to 6 answered at $DE00 on the
first try, so the second half of aciaDetect had never executed.
The rig learned the strap rather than having it hard-coded: viceHarness.py gained
ACIA_BASE = int(os.environ.get("SWIFTLINK_ACIA_BASE", "0xDE00"), 16), and testBoot.py,
testModemSelect.py and testRealtime.py build their monitor commands from it. The default is
unchanged, so every command line in the earlier sections still means what it meant.
- The fallback is taken. With
-acia1base 0xDF00and watchpoints armed on$DE00-$DE03,$DF00-$DF03and both mirror ranges before the module could touch anything, the first stops areSTA $DE03A=$1E,LDA $DE03reading back something that is not$1E, thenSTA $DF03,LDA $DF00,LDA $DF01,STA $DF02- the probe writing its pattern to the first page, failing to read it back, re-aiming and carrying on at the second. Every one of 796 subsequent register accesses landed on$DF00-$DF03. All six accessor operands read$DFat run time and the module image on disk still says$DE. - The game behaves identically. Same options menu, same module load, same "PICK UP PHONE THEN
PRESS SPACE." prompt, same
$E000jump table, same Hayes string on the wire. - Two machines at
$DF00link and carry real ARQ frames. Both reached connection phase 3. At true C64 speed 300 baud gave 29.62 and 28.99 bytes/s with 599 and 682$55frame lead bytes and zero link errors, and 2400 baud gave 8.98 bytes/s each way with no transmitter deadlock. A warp run reached the side-0/side-1 negotiation with a setup-overlay disk load over the live link. - Nothing else in the game touches the page. Two static passes over every image the C64 loads
find exactly one instruction that can reach
$DF00-$DFFF, and it is the stock driver'ssta $DF59,y, which this build does not contain. The mirror tripwire on$DE04-$DEFFand$DF04-$DFFFnever fired in any run.
Two things about the probe are worth knowing and neither is a regression. Its read of an empty
$DE00 is open bus and not deterministic - probeOpenBus.py sampled $DE03 120 times across the
frame and got $F6 62 times, $2C 32, $2D 20 and $2E 6, never the $1E that would make the
probe believe a cartridge is there, but the value is screen data the driver does not control. And
the probe re-runs on every link open and every baud hot key, because aciaDetect is the first
thing loadBaudParameters does, so a $DF00 machine re-decides the page on a live link every time
the rate changes, and writes $1E into $DE03 - a foreign device's register - each time. Also,
when the probe does fall back it does not verify $DF00 at all. strapReport.md section 8 lists
three cheap hardenings. Caveat on the tripwire: VICE maps only the four bytes $DF00-$DF03 and
does not emulate the register mirrors a real cartridge has, so the tripwire proves that no instruction
addresses the mirror range, not what a real cartridge would do if one did.
8. 1200 baud, and the CONNECT 1200 auto-baud path
Full detail in rate1200Report.md; scripts testRealtime.py and the new test1200AutoBaud.py.
1200 is the rate most likely to matter in practice - the other speed the stock user-port module can
produce, and the one a real Hayes modem's CONNECT 1200 puts the driver on by itself - and until this
run it had never been driven at all.
It works. Both machines took C= + 1 at the A/O prompt, first press, and the ACIA control register
read $17 on both before the link was ever opened. 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. Per-second buckets ran from 6 to 119 bytes/s; 119 is 99% of
what 1200 baud can carry in a second, so the line does saturate in bursts even though the six-window
average is about a quarter of the ceiling. What sets the average is the game, not the wire.
The transmitter never parked in the old deadlock state. Thirty state readings at the window edges, only the two self-consistent pairs, and the deadlock probe never fired.
1200 is the first rate at which the 20-byte receive ring was seen to overflow. Thirty-two
overflows across the six windows. Telling an overflow from a bad character needed a second,
conditional tracepoint, because linkErrorCount counts both and restartByteSync clears it:
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 held uartRxCount three cycles earlier, so
X > $13 means the ring already held all twenty. In the four windows where both were live the two
counts were equal in every window - 8 and 8, 3 and 3, 7 and 7, 1 and 1 - so not one character was
rejected for a bad status; the other thirteen carry the same fingerprint. The link resynchronised
and carried on every time: the longest $00 flood in 452 seconds was 87 bytes, under a second of
line time, and connectionPhase was back at 2 or 3 at every edge with isLinkActive = $01. That
is the ceiling the task brief suspected, and it is real at 1200, not only at the top of the table -
but what the driver does about it is degrade and recover, not wedge.
The auto-baud path really does select 1200. A fake Hayes modem in the test script answered the
module's ATQ0V1X1A with \r\nCONNECT 1200\r; 2.58 s later the byte-sync flood started, and the
machine was left with $DE03 = $17 and bitPeriodLo = $17 while baudIndex $E055 was still
0 - which only switchTo1200Baud $E7B2 can produce, since openCommLink would have written
$15 and a hot key would have left $E055 = 3. Driving $E7B2 directly with X = 0, 3 and 6 gave
$15, $17 and $18, so the three-byte stride of baudEntryTable behaves and not just the one
index. Two side effects showed up there: txCharActive is cleared by the new
aciaSetControlIdle, so a CONNECT 1200 arriving mid-character cannot strand the flag; and
baudIndex is not updated, which is faithful to the stock driver but means a link torn down and
re-opened after a CONNECT 1200 comes back at 300 baud, silently (a re-open on a live link,
C= + RETURN, keeps 1200 because it skips loadBaudParameters entirely).
Every overflow in all three runs was on machine B and machine A recorded none in 452 seconds. That is not explained, and it is not the game screen or one emulator falling behind - both held 100.5% of real time or better throughout.
9. The high rates: 4800, 9600, 19200, 38400, and a rate mismatch
Superseded in part by sections 10 and 11. Everything below was measured on the build whose NMI handler could be re-entered without bound. Section 10 explains the 38400 wedges recorded here and section 11 re-measures every rate on the build with the re-entrancy guard, where 9600 and 19200 produce no receive errors at all and 38400 is selectable and does not wedge. The numbers below are left as they were taken.
Full detail in highRateReport.md; script testHighRates.py, which reuses testRealtime.py and adds
uartRxCount sampling, byte-sync entry/completion tracepoints, a wedge dump and a uartPendingCount
repair probe. The question is hardware compatibility, not speed: whether the driver survives a line
faster than the game was written for. Every window ran at 100.5% of real time or better on both
machines.
| Rate | Hot key | Control | Both reached phase 3 | Intervals carrying nothing | Ring overflows | Transmit chars lost | Outcome |
|---|---|---|---|---|---|---|---|
| 4800 | C= + 4 | $1A |
yes | none, 0 of 450 | 0 in 20 s of tracing | 0.8% / 1.5% | healthy |
| 9600 | C= + 9 | $1C |
yes | none, 0 of 442 | 8 and 41 in 20.6 s | 2.1% / 2.1% | degrades, recovers |
| 19200 | C= + 0 | $1E |
yes | none, 0 of 450 | 128 and 74 in 20.4 s | 2.2% / 2.3% | degrades, recovers |
| 38400 | C= + 8 | $1F |
3 of 4 boots, briefly | every interval, one or both ways, for the whole 170-246 s of every boot | 1259 in 21 s on the stuck machine | - | wedges, 4 of 4 boots |
| 38400 vs 300 | - | $1F / $15 |
yes | none, 0 of 474 | 0 | 0.0% / 0.1% | healthy |
What limits the driver, arithmetically. The raw receive ring holds 20 characters, only the
once-per-frame tick drains it, awaitWholePacket will not copy a packet body out until the whole
payload and check byte are resident - up to 17 bytes - so the headroom is about three bytes, and there
is no RTS throttling. A video frame is 0.50 character times at 300 baud, 2.01 at 1200, 4.01 at 2400,
8.02 at 4800, 16.05 at 9600, 32.09 at 19200 and 64.19 at 38400. Two figures in the existing
documents were wrong: the brief for this work said "roughly 640 character times" at 38400 and
spec_uart.md hazard 2 says "about 200"; it is 64.2. The ring is about three times too small at
38400, not thirty - which is why 19200, at 32 characters a frame, still works.
4800 is healthy. Four windows, 246.5 seconds, none of the 450 sampling intervals empty, and
zero hits on both the unconditional $E403 tracepoint and the conditional ring-full one. The
byte-sync rounds it does run are the packet layer's own unwindAndResync paths, not dropped
characters, and they complete: 78 entries / 76 completions on A and 80 / 78 on B in 76 seconds.
uartRxCount sampled 24 times per machine peaked at 7 and 5 of 20.
9600 and 19200 degrade and recover. No sampling interval at either rate carried nothing. Every
error was a ring overflow - the conditional and unconditional tracepoints returned identical counts at
both rates, so not one character was rejected for a bad status - and every degradation was followed by
a recovery, roughly 1.3 to 1.8 byte-sync rounds a second with two thirds to seven eighths reaching
sta ackPending inside the same window. uartRxCount never read the 20-byte limit when sampled.
And they buy nothing: the $55 frame-lead counts do not rise with the rate, so the extra line
time goes into $00 floods rather than into ARQ frames.
The driver also overwrites its own outgoing characters. VICE logs Acia1: ACIA: data register written although data has not been sent yet. each time, one transmitted character lost, on emulation
and on a real 6551 alike; the rates are in the table above. Every one is a checksum failure and a
retransmit at the far end, which is a large part of why the resync rate climbs. How the driver comes
to write a busy data register was not traced.
38400 wedges, four boots out of four, every one within about a minute of the link coming up and
none recovering in the 170 to 246 seconds each was then watched. The four shapes differ: both
machines frozen in phase 3 with both rings full at 20 of 20 and total silence for 245 s; one machine
flooding $00 at exactly one byte per video frame while the other froze; one machine's game back on
the plain OPTIONS menu; and one that ended *** Main CPU: JAM at $0007, the 6502 executing zero page
with $00 and $01 overwritten. It is not the 2400-baud deadlock - in every wedge
txCharActive and aciaCommandShadow were self-consistent, the deadlock probe reported False at
every edge, and healStuckTransmitter had nothing to heal. The byte out of range in the clearest
case is uartPendingCount $E0A5 reading $FF with the transmit ring empty, which is exactly
spec_uart.md hazard 1's warning; startNextTxChar resynchronises that count whenever it runs with
the ring dry, so on that machine the once-per-frame $E00C service had stopped being called, and
which of the two stopped first was not determined. The repair probe was run on the boot whose CPU had
jammed, where no write to any driver byte could have worked, so it has never been run against the
shape it was written for.
A 38400-against-300 mismatch was the healthiest run in the whole report. Both ends reached phase 3, none of 474 sampling intervals carried nothing, not one ring overflow or bad character at either end, neither machine wedged, and the lowest resync rate of any pair measured. The pair ran at the slow end's rate - the slow side pinned at 28.7-29.6 bytes/s, which is 300 baud's 8N1 ceiling to within a byte - because the packet layer is lock-step and the peer's rate paces the fast end for free. That must be read with VICE's byte-level ACIA in mind: two emulated ends set to different rates do not garble each other, so the error storm a real 300-baud receiver would see on a 38400 line cannot be produced here at all. What the run does establish is that neither state machine falls over on the mismatch itself.
10. Why 38400 wedged: NMI re-entrancy, measured
Full detail in wedgeDiagnosis.md; scripts testStack.py, testWedge.py and testCost.py, all run
on 2026-08-22 against the build that still had the fault. No driver source was changed for any
of it. Section 9 left the wedge characterised and not explained; this is the explanation, and it is
established rather than inferred.
The mechanism. Reading the 6551's status register is the interrupt acknowledge, and
commNmiHandler reaches that read eight instructions in. From there to the end of the pass - up to
about 220 more cycles - the chip is free to raise a fresh event, and a 6502 does not mask NMI. So the
handler can be re-entered at the very next instruction boundary, at a cost of 6 bytes of stack (or
8 if the edge lands inside one of the handler's own JSRs). At 38400 with traffic both ways there
are two interrupt-raising events per character time - one RDRF, one TDRE - so an edge arrives
roughly every 133 cycles against a pass that 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.
The cost of a pass, measured (testCost.py, 19200 baud, four windows, two machines, 34.8 emulated
seconds, 5694 complete passes paired entry-to-exit and classified by what each pass did):
| what the pass did | passes | whole interrupt |
|---|---|---|
| received a character | 2983 | 178 cycles |
| transmitted a character | 1835 | 178 |
| transmit interrupt found the ring dry, disarmed it | 677 | 175 |
| received and transmitted in the same pass | 188 | 256-266 |
| neither half had work (a DCD/DSR change) | 11 | 164 |
A hand count off the source agrees to within a cycle, which matters because it makes the figure a property of the code rather than of the emulator. Against an NTSC C64's 1,022,727 Hz and 8N1's ten bits per character, that is 1% of the CPU at 300 baud, 17% at 4800, 33% at 9600, 67% at 19200 and 134% at 38400 - and 134% is the whole failure in one number. A character time at 38400 is 266.3 cycles; a full-duplex pass costs 266.
The re-entrancy itself, caught three times (testStack.py with trace exec e685 if sp < $b4
armed before the link was ever opened - one frame below the deepest entry any healthy machine has
made, and a tracepoint that prints without stopping the machine):
| run | machine | entries | duration | SP fell |
frames |
|---|---|---|---|---|---|
st384d |
B | 44 | 8611 cycles = 8.42 ms | $B0 -> $02 |
29 |
st384d |
B | 91 | 12807 cycles = 12.52 ms | $B3 -> $08 |
28.5 |
st384c |
B | 84 | 11780 cycles = 11.52 ms | $B3 -> $00 |
30 |
The gaps between consecutive entries alternate 115 and about 150 cycles, and 115 + 150 = 265 - one
character time at 38400 with two interrupts inside it, measured rather than argued. 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. At 19200 the handler fits inside the character time, so it never nests.
The stack, decoded. st384b carried break exec e685 if sp < $20 as well, and it fired: machine
A stopped at $E685 with SP = $12, part-way down, before the pointer wrapped. Its page 1 walks
upward without a gap or an ambiguity into 34 complete nested handler frames plus 16 JSR return
addresses. Saved $01 was $35 in all 33 readable frames - the value the handler forces on
entry, so a frame that saved it was interrupted by a handler that had already run - saved X was
$14 = 20 in all 33 (the receive ring pinned at its limit because the game had stopped draining it),
and I was set in every saved P. The interrupted PCs are all in the transmit half, in the window
between the status read and the writes that would remove the interrupt source: $E6C4 (10), $E6C7
(9), $E551 (8), $E6C2 (7).
Two things fall out of that table that section 9 could not explain:
$E6C4and$E6C7straddledec uartPendingCount $E6C9. An outer pass interrupted there has already readtxCharActiveand decided to count a byte off; the inner pass does its owndec, pops the ring and writes the data register; the outer pass then decrements again for the same byte and writes the data register again. That is where section 9'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 - and it is howuartPendingCountreaches the$FFthe 38400 dump found. (Shown as a frame stopped one instruction short of theDEC; aDEC/INCimbalance was not counted during a storm.)$E551explains boot 4's impossible command register.aciaSetCommandwrites the shadow at$E551and the chip at$E554; a machine thrown off the rails between them ends up exactly as section 9 found it,$DE02=$05against a shadow saying$09.
Why it never recovers. wg384a's machine A was watched for 154 seconds after it died.
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, where its healthy peer ran
it 479 times in 8.51 seconds. The monitor read that machine's registers 124 times and found I set
in all 124, SP frozen at $BD. A 6502 with I set takes no IRQ, so there is no frame tick, so
startNextTxChar's once-a-frame repair of uartPendingCount (sta uartPendingCount $E611, also zero
executions) can never run, and serviceLock $E0A7 read $00 - held for ever, because the runaway
threw the CPU out of the middle of a service tick. That is why the uartPendingCount 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.
Ruled out, each on evidence: steady-state CPU starvation (after the wedge the NMI duty cycle is
0.3%); the 20-byte ring being too small (it overflows at 9600 and 19200 too, and those recover; in the
wedges it is full because the game stopped draining it); uartPendingCount reaching $FF (one
wedged machine had $00); the control byte $1F or the rate table ($DE03 read $1F everywhere and
characters crossed correctly; the 38400-against-300 pair of section 9 ran at $1F for 246 s with no
wedge); a spurious interrupt storm from the emulated chip (in normal operation the median gap between
entries is one character time); a protocol deadlock above the driver; and the 2400-baud transmitter
deadlock (txCharActive/aciaCommandShadow were the self-consistent $00/$09 on every wedged
machine). Framing is irrelevant to this failure: every run was 8N1 throughout.
38400 did 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. The failure needs both
directions bursting at once, so a quiet link can win the race for a while. Five 38400 boots in that
session, four machines dead.
What this section does not settle: VICE cannot say at what 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 differ -
356 cycles of work offered per 266 cycles of line time is arithmetic - and on a real line framing and
parity errors would add reportLinkError to the receive path, making the pass longer, not shorter.
Logs: testLogs/stack.st384a.txt (the boot that did not wedge), stack.st384b.txt (the SP = $12
stop and the decoded page 1), stack.st384c.txt / st384d.txt with their *.B.deep.txt staircases,
stack.st192a.txt (the 19200 control), wedge.wg384a.txt and its three traces, and
cost.c192a.txt with its four *.trace.txt files.
11. Whatever the port is set to: adopt mode, the eight-bit guard, and every rate including 38400
Full detail in portCompatReport.md; scripts testHighRates.py, testAdoptPair.py, testAdopt.py,
testGuard.py, testStack.py and testStrap.py, run on 2026-08-22 against a disk built from the
current source (checkAbi.py: 474 of 4096 bytes changed in 27 regions, ABI CHECK PASSED). This
section is the measurement of two decisions being reversed:
- 38400 had been made unselectable because it wedged, which hid a defect instead of fixing it. Section 10 established the defect; the driver now carries a re-entrancy guard, and 38400 is selectable again.
- The driver imposed its own line settings.
loadBaudParameterswrote$10 OR rateBitsat every link open - 8N1, internal generator - over whatever the user's hardware was configured for. It now comes up in adopt mode and uses the control register the hardware already holds.
The one thing that is not negotiable is eight data bits, and that constraint is a reading of the source, not a measurement; it is stated with its evidence below.
Every rate window and every adopt window ran with two machines at true C64 speed - warp read back
as off through the monitor with both emulators stopped, and every window bracketed by both emulators'
free-running cycle counters, the slowest of which reads 100.5%. A caution that cost one measurement:
the first testGuard.py run issued its warp off to a running emulator and did not read it back,
and produced a trace spanning 62.3 emulated seconds inside an eight-second window. That run is
reported anyway, in 11.3, for what it accidentally measured.
11.1 Every rate, both machines on the rate
testHighRates.py, one boot per rate, both machines given the rate with the driver's own hot key at
the "PRESS A OR O" prompt, three windows per boot: clean (75 s, no tracepoints, so the byte counts
are honest), sync (75 s, tracepoints on beginByteSyncPhase $E805 and sta ackPending $E855) and
errors (20 s, countLinkError $E73F and the conditional $E505 if x > $13, which separate "a
character was rejected" from "the 20-byte ring was full").
| Rate | Control | Both reached phase 3 | Intervals carrying nothing | Ring overflows $E505 |
Chars rejected $E73F |
Max uartRxCount A/B |
Tx chars lost | Outcome |
|---|---|---|---|---|---|---|---|---|
| 300 | $15 |
yes | 0 of 334 | 0 | 0 | 1 / 1 | 0 | healthy |
| 1200 | $17 |
yes | 0 of 334 | 2 | 2 | 1 / 14 | 0 | healthy, one overflow each, recovered |
| 2400 | $18 |
yes | 0 of 334 | 0 | 0 | 6 / 0 | 0 | healthy |
| 4800 | $1A |
yes | 0 of 334 | 0 | 0 | 0 / 6 | 0 | healthy |
| 9600 | $1C |
yes | 0 of 334 | 0 | 0 | 0 / 3 | 0 | healthy |
| 19200 | $1E |
yes | 0 of 334 | 0 | 0 | 4 / 6 | 0 | healthy |
| 38400 | $1F |
yes | 0 of 334 | 0 | 0 | 1 / 3 | 0 | healthy |
| 38400, boot 2 | $1F |
yes | 0 of 452 | 0 | 0 | 0 / 1 | 0 | healthy, 245.5 s |
1193.8 seconds of measured link time over the seven rate boots, 1439.3 s counting the second 38400
boot. The whole sweep produced two rejected characters and two ring overflows, and they are
the same two events - one per machine, both at 1200 baud, in the 20-second instrumented window, with
the conditional and unconditional tracepoints returning identical counts, which is what says the
character was thrown away because the ring was full and not because its status was bad. Both machines
carried on. That is the signature rate1200Report.md recorded at the same rate; 1200 is still the
first rate at which a burst can outrun the ring.
No rate above 1200 produced a receive error of any kind, where on the previous build 9600 and
19200 produced 8-41 and 74-128 ring overflows in the same 20-second window. And the transmit-side
loss is gone: every emulator log in this sweep contains zero of VICE's data register written although data has not been sent yet lines, at every rate including both 38400 boots, where the
previous build produced them for 0.8% of offered characters at 4800, 2.1% at 9600 and 2.2-2.3% at
19200. Section 10 predicted exactly that, having traced the overwrite to a pass interrupted between
lda txCharActive $E6C4 and dec uartPendingCount $E6C9. No log contains Main CPU: JAM either.
What the traffic actually is, and why it is the point. Every rate from 1200 up carried about
nine bytes a second in each direction and 250-odd $55 frame-lead bytes per 75-second window.
That is the game, not the line: two machines at the GAME TYPE menu exchanging the lock-step ARQ frames
the protocol keeps alive, and the wire's rate makes no difference to how many there are. 300 baud
already carries everything this game produces - and 300 is the one rate that works for it, moving
29.6 B/s (the 8N1 ceiling of a 300-baud line to within a byte) and three times as many $55 lead
bytes as any faster rate, 871 against 253. Why the frame count rises when the line slows was not
traced; what is honest is that it did, with zero receive errors and zero overflows while it happened.
11.2 Why 38400 no longer wedges, measured three ways
The mechanism is nmiHandlerState $E5BD and the drain loop at nmiIdle $E6E4: a second NMI that
finds a pass already running does not run the handler body, it adds one to that byte and returns - and
because a nested pass never reads the status register, /IRQ stays asserted and no further edge
can be raised until the running pass acknowledges. The running pass decrements the byte and goes
round again whenever the decrement leaves a note behind.
The stack. testStack.py, two machines at 38400, both in the packet phase, four checkpoints
armed before the link was ever opened and left armed for 200 seconds (run pcst384b, which measured
its own speed: 200.88 emulated seconds on A at 100.4% of real time and 200.58 on B at 100.2%):
| Checkpoint | Result |
|---|---|
trace exec e685 if sp < $b4 - any entry one frame deeper than the deepest healthy entry ever recorded |
0 hits on A, 0 hits on B |
trace store $0100 $0180 - the stack thirteen frames past anything healthy |
no hits |
break exec e685 if sp < $20 - the stack nearly gone |
never fired |
break store $0100 $0140 - the same from the other side |
never fired |
Both machines ended the watch running game code with SP = $E9 and the interrupt flag clear, so
both raster IRQs were still alive and still calling $E00C; page 1 held no handler frames below
$01D0 at all. On the previous build the same instrument caught three runaways in two boots (section
10) and one machine stopped with 34 nested frames on the stack, found with I set in 124 readings
out of 124.
The counter. testGuard.py traces the handler entry $E685 and nmiLeaveNote $E6F0, the
instruction a nested pass executes instead of running the body:
| Run | Rate | Window | Entries | Nested | nmiHandlerState after |
|---|---|---|---|---|---|
pcgd384c A |
38400 | 91.00 emulated s (101.1% of real time) | 1632 | 0 | $00 |
pcgd384c B |
38400 | 90.70 emulated s (100.7%) | 1620 | 0 | $00 |
pcgd384b A / B |
38400 | 10.80 s (132.7%) / 8.81 s (108.3%) | 175 / 144 | 0 | $00 |
pcgd192b A / B |
19200 | 9.08 s (111.2%) / 8.78 s (107.6%) | 156 / 144 | 0 | $00 |
Eighteen handler entries a second at both rates, which is what nine bytes a second each way should
produce - one RDRF and one TDRE per character. The 90-second window is the one to read closely,
because every trace line carries the cycle counter and so the gaps can be measured: 817 and 1027 of
the gaps were under 300 cycles, with a median of 266 - exactly one character time at 38400 - the
smallest gap seen was 222, and not one gap fell below 178 cycles, the cost of one cheap pass. The
stack pointer at entry stayed between $D7 and $F4. So the interrupts do arrive back to back at
the rate section 10 priced, and the margin against them is between zero and ninety cycles; in
3252 entries it was never lost.
Seven boots at 38400, no wedge: two testHighRates.py boots (170.4 s and 245.5 s of measured
windows, phase 3 in all 24 ring samples each), two testStack.py watches, and three testGuard.py
runs. The previous build wedged four boots out of four, every one inside the first minute, one ending
in *** Main CPU: JAM at $0007.
11.3 The accidental stress test
The lost run described above - the one that ran about eight times real time - is the only measurement in the session in which the guard was actually used, and it is kept for that reason. Why running unlimited was harder on the driver was not chased down; the obvious candidate is that two emulators running unlimited do not run at the same multiplier, so each one's bytes arrive in the other's emulated time frame in bursts true speed never produces.
| Machine | Entries $E685 |
Nested $E6F0 |
Emulated span | SP at entry | Smallest gap |
|---|---|---|---|---|---|
| A | 3050 | 260 (8.5%) | 62.30 s | min $D0, max $F0 |
58 cycles |
| B | 1685 | 145 (8.6%) | 38.95 s | min $D6, max $F4 |
58 cycles |
Two things matter in that table. Second NMIs really do arrive inside a running pass - a 58-cycle
gap against a handler costing 175 to 266 is not a near miss - and there were 405 of them. The stack
pointer at entry never went below $D0, one frame below the interrupted level and 128 bytes above
the $B4 line the previous build crossed on its way to zero. Both machines ended in the packet phase
with linkErrorCount = 0 and nmiHandlerState = $00. On the previous build every one of those 405
entries would have been a full re-entrant pass with a status read in it.
11.4 Adopt mode, two machines, before and after
testAdoptPair.py. Both machines are booted to the "PRESS A OR O" prompt with the module resident -
the last moment at which the chip has not been touched, confirmed by aciaPageLatch $E5BF = $00 and
the adopt entry $E726 = $00 on both - and then the 6551's control register is written from the
monitor, exactly as a configured cartridge would have left it. Then the link is opened and measured.
| Preset | After the link opened | The link |
|---|---|---|
control $98 (2400 baud, 8 data bits, two stop bits), command $69 (even parity) |
$98 on both machines, adopted whole; baudIndex still $15 = 21 |
75.20 s, 9.00 / 8.98 B/s, 0 of 148 one-second intervals empty, both in phase 3 |
control $B8 (2400 baud, seven data bits, two stop bits) |
$98 on both machines - bits 6-5 corrected and nothing else in the byte moved |
75.15 s, 8.98 B/s each way, 0 of 148 empty, both in phase 3 |
control $00 (a chip nobody has configured) |
$15 on both - 300 baud, 8N1, internal generator |
75.16 s, 10.23 / 10.22 B/s, 0 of 148 empty, linkErrorCount 0 in all 24 samples |
$98 is a byte the driver's own table does not contain and would never have chosen, and it came
through untouched: aciaProbeAdopted $E655 parked it in baudEntryTable's eighth entry at $E726,
loadBaudParameters wrote it to the chip, and the live copy sits in bitPeriodLo $E056. The old
driver would have written $15 into all three cases without looking.
The $00 row is the fallback: (control AND $1F) < $11 means bit 4 = 0 (an external receiver clock a
SwiftLink does not have) or rate bits %0000 (an external transmit clock) or both, and $00 is both.
It did not hang, and the rate really changed - the per-second range was 7.9-30.0 B/s one way and
6.9-30.0 the other, where every window at 1200 and above sat between 5.9 and 12.0, and 30.0 B/s is the
8N1 ceiling of a 300-baud line.
testAdopt.py covers the rest of the table on one machine, 46 of 46 checks passing: $18 ->
$18 and $9F (38400 with two stop bits) -> $9F copied through untouched; $5A (six data bits) ->
$1A and $3C (seven data bits) -> $1C, word length corrected and rate left alone; $00, $0C
(external receive clock) and $10 (internal clock, no rate) -> $15. Every C= + 3 put baudIndex =
0 and $15 in the register and every C= + U put it back to 21 and the adopted byte, so both
directions of the switch work. Two different $15s meet in these dumps: baudIndex $15 is 21
decimal, the adopt entry's byte offset; control-register $15 is 300 baud 8N1.
Adopt mode does not cover parity, and that is a limit rather than a defect. Parity lives in the
6551's command register, and the driver writes that register from constants at four sites - $09 at
$E546 and $E5A4, $0B at $E5A9 for the disk-load suspend, $05 at $E63F when the transmitter
is armed - every one of which has bits 7-5 = %000 = parity disabled. The register cannot be
part-written: DTR, RTS and both interrupt enables share the byte. So the $69 pre-set above read
$05 and then $09 after the link opened, and the link ran without parity. Both ends of a Modem
Wars link run this driver and clear the same bits, so they agree; the only peer that is not this
driver is the stock user-port one, which is 8N1 only. spec_uart.md hazard 8's "same 8N1 framing" is
therefore now true only of the data bits.
11.5 The eight-data-bit guard, and why it is not optional
$B8 -> $98 is the measurement: bits 6-5 went from %01 (seven data bits) to %00 (eight), and
bit 7 (the second stop bit), bit 4 (internal clock) and bits 3-0 (2400 baud) all survived. That is
the and #$9F at $E665 inside aciaProbeAdopted, the only place in the module where a control byte
is masked.
Why the mask has to be there is a statement about the source, and it cannot be measured on this rig - VICE's ACIA is byte level and will not truncate a character to seven bits however the control register is set. The argument needs no measurement, because one instruction decides it:
awaitPeerSyncFf:
jsr receiveByteTrackZeros ; E84C look for the peer's answer
tax ; E84F
beq linkTickReturn ; E850 nothing yet
inx ; E852 was it $FF?
bne restartByteSync ; E853 anything else: start the sync over
sta ackPending ; E855 $FF received, both ends are byte aligned
The byte-sync handshake is $00 one way and $FF the other, and $FF is tested for exactly, by
incrementing it to zero. On a seven-bit line the peer's $FF arrives as $7F, INX makes $80,
the branch is taken, and the sync restarts for ever: the link never leaves connectionPhase 2, the
border stays red and never turns green. Two more places in the frame layer need the eighth bit and
would corrupt data rather than stall it - computePacketChecksum $E937 is a full eight-bit
rotate-and-add, and the frame layer leads with a $55 length code followed by sequence bytes that take
any value at all. Parity and stop bits cost the frame layer nothing, which is exactly why the
mask touches two bits and no others.
11.6 No regression: both straps, and 1200 baud
testStrap.py, one machine, watchpoints on $DE00-$DE03, $DF00-$DF03 and both mirror ranges armed
before the module can touch anything. On the $DE00 strap the first cartridge access in the whole
session is now LDA $DE03 returning $00 - the adopt read - and only then the two probe patterns
$1E and $15, both of which read back, latching $DE. On the $DF00 strap the adopt read on
the default page returns $F6 (open bus), the first probe pattern fails there, and the fallback
reads $DF03 before it writes to it, so a cartridge on the other strap gets its own settings
adopted rather than the floating bus's. That is the part adopt mode changed.
On both straps: all six accessor operands and aciaPageLatch $E5BF agreed on the right page; the
other page read back F6 F6 F6 F6; the NMI vector $FFFA read $E685; zero accesses to the
mirror ranges $DE04-$DEFF / $DF04-$DFFF, and zero more in a further 45 seconds with only the
mirror tripwires armed; and the game reached the modem prompts and opened a link. One byte in those
dumps looks alarming and is not: nmiHandlerState $E5BD read $01 in both strap runs, because every
watchpoint in that test is on an ACIA register and almost every ACIA access this driver makes is made
from inside commNmiHandler - the emulator was stopped inside a pass, which is when that byte is
supposed to be 1. The reading that says it rests at zero is the one in 11.2.
1200 baud is the rate a stock user-port peer can also produce, and it is the 1200 row of 11.1: one
boot, both machines on C= + 1, 170.3 s of measured link time, 0 of 334 sampling intervals carrying
nothing, uartRxCount reaching 14 on one machine and never the 20-byte limit, and the two ring
overflows recovered from.
11.7 What section 11 does not show
- VICE, not hardware. No physical SwiftLink, no real cable, no C64 Ultimate.
- VICE's ACIA is byte level. It paces whole characters at the rate the control register asks for but does not shift them bit by bit, so no framing or parity error can be produced on this rig at all and a wrong word length cannot corrupt a character here. "Zero characters rejected at every rate above 1200" is a statement about this emulation, and 11.5 is a reading of the source rather than an experiment.
- Adopt mode is verified against VICE's control register, not against a device's idea of one.
Every preset was written there from the monitor. That is the same byte a configured cartridge would
leave, and the driver cannot tell the difference - but "an Ultimate set to 9600 in its own menu
presents
$1Cin the control register" is an assumption this rig cannot test. If a device presents$00while actually running at some other rate, this driver adopts 300 baud and is wrong. - The traffic is the menu, not a game. Both machines reach the GAME TYPE menu and stay there, as in every earlier report here. Nine bytes a second each way is what that produces; a running battle may produce more, and no rate was measured under one.
- 38400 is healthy at this traffic, not at any traffic. The margin measured in 11.2 is between zero and ninety cycles, and the arithmetic of section 10 still says a saturated 38400 line costs more CPU than a C64 has. What the guard changes is what happens then - ring overflow and an ARQ resync instead of the stack eating page 1 - and that degradation path was exercised at 1200 here and at 9600/19200 on the previous build, never at 38400 on this one, because nothing in this game saturates a 38400 line.
- One boot per rate, except 38400, which got seven. 170 seconds says a link keeps moving; it does not say it always will.
- Parity is not adopted (11.4), and nothing measures what a device that really is configured for parity would do with a driver that turns it off.
- The
uartPendingCount $E0A5repair probe never ran, becausetestHighRates.pyonly runs it when a direction carries zero bytes for a whole window, and no direction ever did. - The guard's nested path has only ever been exercised above real time. The 405 nested passes of 11.3 came from an unlimited-speed run; every speed-checked window counted zero.
Logs: testLogs/portCompat300.txt through portCompat38400.txt and portCompat38400run2.txt;
portCompatAdopt98.txt, ...AdoptB8.txt, ...Adopt00.txt, portCompatAdoptTable.txt;
portCompatStack38400.txt / ...b.txt; portCompatGuard38400.txt, ...b.txt, ...c.txt,
portCompatGuard19200.txt / ...b.txt; portCompatStrapDe00.txt / ...Df00.txt; the raw traces
stack.pcgd384*.[AB].deep.txt and stack.pcst384*.[AB].deep.txt; and the per-second samples in
highRate.pc*.samples.txt.
Problems and loose ends
Two defects in the module turned up in the real-time runs, and both are now fixed and
re-measured. The first, the 2400-baud transmitter deadlock, is in section 5. The second, the
38400-baud wedge, is in section 9; it was open when that section was written, was explained in section
10 as unbounded NMI re-entrancy in commNmiHandler, and section 11 measures the build that guards
against it - so "38400 must not be selected", which the earlier version of this paragraph said, is no
longer the position. Everything else here is a limit of the test rig or something worth knowing
before repeating it.
- Warp can be turned off after all - just not with a resource.
resourceset "WarpMode" "0",resourceset "Warp" "0"andresourceset "WarpModeEnabled" "0"really are all answered with "Unknown resource" in VICE 3.7.1, which is what the earlier version of this report concluded from. But the monitor has awarpcommand:warp on,warp off, andwarpon its own, which answersWarp mode is on./Warp mode is off..viceHarness.ViceSession(..., warp=False)startsx64scwith+warp; the test then warps the loader by hand, breaks at$0800as every other test does, and issueswarp off. Sections 5 and 6 were measured that way, with thestopwatchcycle counter confirming 100.5-100.9% of real time. Sections 1-4 still ran in warp and still say nothing about real-time behaviour. Two things fell out of this that anyone repeating the earlier runs needs.-warpon the command line does not survive the autostart on this disk: VICE logsAUTOSTART: aborting.a second or two in - the disk's own fast loader leaves the KERNAL routines VICE watches - and-autostart-warpdrops warp at that point, so a run that does not warp explicitly crawls; one such run sat over eleven minutes without reaching the menu, while the same script with an explicitwarp onreached the options menu in about 50 seconds, twice in a row. That is very probably the load-time variation recorded in the next entry. (The-autostart-warphalf of that is inference from VICE's log line, not something measured directly.) AndviceHarness.setWarp(), whichbootPastLoaderstill calls, only knows the three resource spellings, so it returnsNoneand changes nothing; the monitorwarpcommand is what does the work, and the tests issue it themselves. - Load time varies wildly, from about 90 seconds to over five and a half minutes of warp for the same disk on the same machine, depending on what else was running. Two runs were abandoned and restarted because of it. Anything scripted around this needs generous timeouts.
- The A/O prompt is easy to miss. A key pressed before the module has finished loading is
simply lost, and the prompt then waits for ever. My first attempt pressed A once, 25 seconds
after the fire button, and it landed too early - which is why an early run recorded zero ACIA
accesses and looked like a failure. The working version offers A and space repeatedly until the
ACIA is touched. (The tell that the key was taken:
lastTypedKey$9248 goes back to $FF, becausewaitForAnswerOriginateKeyexits throughresetMessageQueue.) - The options-menu highlight bar blinks, so a screenshot catches it in either phase; the selected row was verified by reading $91D5, not by looking at the picture.
- The Commodore key is Tab, not Ctrl - which is the whole reason C= + C looked broken in section
4. VICE's live keymap here is the symbolic one (
KeymapIndex=0,KeymapSymFile="gtk3_sym.vkm"), whereTabis row 7 column 5, the Commodore key, andControl_Lis row 7 column 2, the C64 CTRL key. Holding left Control is worse than merely not being C=: row 7 column 2 is$FFin the game's ownkeyMatrixCodeTable$0D67, soscanKeyboardanswers "no key at all" for as long as it is down, which is exactly thelastHotkeyCode=$FFthe sweep in section 6 measured. With Tab the hot key works. What is still untested about it: VICE's emulated 6551 never sets status bit 6, so the "no carrier" DCD it exists to override had to be poked intoaciaStatusSave$E5BCat a breakpoint rather than produced by the emulation, and the override was never tried with two machines actually playing. On real hardware with a real peer dropping DCD it is still unproven. - Do not put
Alt_Lin a key sweep, and do not hold a key across a monitor stop. Holding Alt opens the GTK menu bar, which takes a keyboard grab; everyxdotoolkey after that goes to the menu and the emulator sees nothing, and the rest of that run is junk. Separately, a key held withxdotool keydowndoes not survive a monitor stop: the$E30Cborder checkpoint never fired when Tab was pressed before the breakpoint was armed, and fired immediately when it was armed first and Tab pressed after the machine was running. Anything that needs a key down while the CPU runs must arm its breakpoints first and press afterwards. - A real-time measurement must not touch the monitor while it runs. Any input stops that
emulator, and
viceHarness.mon()returns as soon as it sees a prompt, which is often the previous command's - so a command can be sent into a monitor that has not finished the last one. An early version oftestRealtime.pyleft one emulator sitting at the prompt for a whole 75-second window (17099 emulated cycles against 75 seconds of wall clock). It now reads to quiescence, verifies every resume, and throws away any window in which either machine ran under 90% of real time. - The socket null modem can in principle drop data. Its forwarding sockets are non-blocking and a chunk that cannot be written is discarded rather than queued. At 30-240 bytes/s against a kernel buffer of tens of kilobytes this should never happen, and the clean 55-second saturated run at 300 baud is evidence that it did not, but it was never instrumented.
- Two emulators started at once: the second one can run past $0800 before its breakpoint is set,
in which case the breakpoint never fires and the boot wait burns its full timeout.
viceHarness.pynow checks the monitor prompt's PC first and skips the wait when the loader is already done. - The module-residency trap, which cost four runs across three sessions. While the 1541 is still
fetching track 34 at true C64 speed - the best part of a minute -
$E000-$EFFFstill holds the previous overlay, and both of the rig's "is it up yet" tests can read that wreckage as success.testTwoMachines.answerModemPromptsdecides the link is open as soon as$E5BEis non-zero, and the wreckage readE8 84 18 ACthere in three separate runs, so$ACwas taken for an open link while the screen still said "WORKING...";testRealtime.selectRateuses "$E013/$E014readsB7 0D" as its residency test, and the wreckage read exactly that in another. The fix, intest1200AutoBaud.waitForModuleandtestHighRates.moduleResident, is to wait for all sixteen bytes of the$E000jump table;testStrapPair.pyrequires the jump table and a programmed control register.testTwoMachines.pyandtestRealtime.pythemselves were left alone, so the trap is still there for anyone reusing them directly. linkErrorCount$E047cannot be used to count errors.restartByteSyncclears it at$E814, so it reads$00at a window edge while errors are plainly happening, and at high rates it simply wraps - across one 38400 boot's four edges it read 208, 21, 94, 206. Worse, it does not distinguish a bad character from a ring overflow. Both facts are why sections 8 and 9 use tracepoints instead:trace exec $e403for every rejected character andtrace exec $e505 if x > $13for the subset rejected because the ring was full. The monitor reads condition numbers as hex, which is why the constant is written$13and not19. Machines are 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.- A tracepoint-counting regexp in
testRealtime.pywas wrong, and two runs' own summaries are false because of it. 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 ofC:\$e403matches nothing. Runs 2 and 3 of the 1200-baud series print "0 tracepoint hits" in their logs and that is not true; the counts in section 8 come from the saved*.trace.txtfiles, re-counted afterwards. $DE03sometimes reads$00and$DE01$FFon a machine that is running perfectly well. That is the monitor reading through a CPU that happens to have the I/O area banked out -readFilmByte,writeFilmByteandcopyPageUnderIoset$01 = $34for up to 2600 cycles at a time. Two of the four 38400 boots caught a machine that way at every window edge, which briefly looked like a driver fault and is not one. Any test that reads the cartridge page through the monitor should read$01alongside it.- Instrumented windows are not free and must be kept separate from the ones the throughput numbers
come from. The
$E403/$E505windows in section 9 consistently read 102-103.5% of real time against their own cycle counters rather than the usual 100.5-101%, which is pause-and-resume overhead landing inside a shorter window. A clean window carries no tracepoints at all. - Do not rebuild the disk while a measurement is running.
./build.shrewrites the.d64the running emulators have attached; one 1200-baud run was killed and restarted for that reason, and its emulators and Xvfb displays cleaned up by hand. - One monitor socket closed on its own. Post-fix run 2 ended in a
BrokenPipeErroron machine B's monitor socket during a state readback, after all three of its 2400-baud windows had completed and been reported. Nothing in the emulator log explains it. The three windows are used;testRealtime.pynow writes each window's samples and trace as the window closes rather than in the closing summary, and a fourth boot was run to replace what run 2 could not report. - Emulator pairs were sometimes run two at a time on the same box - 4800 and 9600, and 38400 boot 2 with the mismatch run. That is exactly the sort of thing that invalidates a real-time measurement, and it is recorded for that reason, but it did not: every window in every report holds 100.5% of real time or better against its own cycle counter. What it did break was the fixed sleeps in the boot sequence, which is where the residency failures above came from.
spec_uart.mdhazard 2's arithmetic is wrong and has not been corrected in that file. It says a video frame is "about 200 character times" at 38400 baud; at 3840 characters a second and 59.826 frames a second it is 64.2 (highRateReport.mdsection 1). The conclusion the hazard draws is unaffected - the 20-byte ring is still outrun - but the size of the problem is three times smaller than that sentence implies, which is why 19200 survives.