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

40 KiB

Whatever the port is set to: adopt mode, the eight-bit guard, and every rate including 38400

Folded into testReport.md as section 11, which carries the headline numbers; this file is the full record. It supersedes highRateReport.md's rate recommendation and hardenReport.md's 38400 retirement, both of which now carry pointer notes.

Two earlier decisions were wrong, and this report is the measurement of their replacements.

  1. 38400 was made unselectable because it wedged. That hid a defect instead of fixing it. wedgeDiagnosis.md then established the defect - unbounded NMI re-entrancy in commNmiHandler - and the driver now carries a re-entrancy guard. 38400 is selectable again, and in seven boots on this build it did not wedge once.
  2. The driver imposed its own line settings. loadBaudParameters $E353 wrote $10 OR rateBits at every link open: 8 data bits, one stop bit, internal baud generator, whatever the user's hardware had been 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 section 6 sets out why with the instruction that decides it. Parity and stop bits are free; word length is not.

Everything below is VICE 3.7.1 (x64sc), NTSC, -acia1 -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). Every rate window and every adopt window ran with two machines at true C64 speed: warp was read back as off through the monitor first, and each window is bracketed by both emulators' own free-running cycle counters, the slowest of which reads 100.5% of real time. The guard and stack runs carry their own speed readings in section 4, and one run that was not speed-limited at all is reported as exactly that, in section 4.3.


The answers, in two tables

Rate Control Both reached phase 3 Sampling intervals carrying nothing Receive-ring overflows $E505 Characters rejected $E73F Byte-sync rounds entered / completed Max uartRxCount A/B Transmit characters lost Outcome
300 $15 yes 0 of 334 0 0 6 / 6 1 / 1 0 healthy
1200 $17 yes 0 of 334 2 2 8 / 6 1 / 14 0 healthy (one overflow each, recovered)
2400 $18 yes 0 of 334 0 0 0 / 1 6 / 0 0 healthy
4800 $1A yes 0 of 334 0 0 0 / 0 0 / 6 0 healthy
9600 $1C yes 0 of 334 0 0 0 / 0 0 / 3 0 healthy
19200 $1E yes 0 of 334 0 0 0 / 0 4 / 6 0 healthy
38400 $1F yes 0 of 334 0 0 0 / 0 1 / 3 0 healthy
38400, second boot $1F yes 0 of 452 0 0 0 / 0 0 / 1 0 healthy, 245.5 s

Where the columns come from: the sampling intervals are the one-second buckets of the null modem's byte counts, both directions, across all three windows of the boot (74 + 74 + 19 per direction). The two error columns are tracepoint hits summed over both machines, and they only exist in the windows that carried those tracepoints - $E73F/$E505 in the 20-second window, $E805/$E855 in the 75-second sync window. uartRxCount is the maximum of 24 samples taken per machine with the emulator stopped. The transmit-loss column is VICE's own data register written although data has not been sent yet count over the whole boot, from each emulator's log.

38400 no longer wedges. Nothing in this report needs the word "degrades" either: at every one of the seven rates the link kept both directions moving in every sampling interval, the 20-byte receive ring never reached its limit, and no machine's CPU jammed.

What the port was set to before the game touched it What $DE03 held after the link opened Link
$98 - 2400 baud, 8 data bits, 2 stop bits (plus command $69, even parity) $98 on both machines - adopted whole healthy, 75.2 s, 9.00 / 8.98 B/s, 0 dead seconds
$B8 - 2400 baud, 7 data bits, 2 stop bits $98 on both machines - only the word length was corrected healthy, 75.2 s, 8.98 / 8.98 B/s, 0 dead seconds
$00 - a chip nobody has configured (external clocks) $15 on both machines - the 300-baud fallback works, 75.2 s, 10.23 / 10.22 B/s, 0 dead seconds

The old driver would have written $15 into all three of those cases and not looked at what was there: baudIndex $E055 came off the disk as 0 = 300 baud, and loadBaudParameters wrote $10 OR rateBits at every link open. A port configured for 2400 8N2 got 300 8N1; a port configured for anything at all got 300 8N1.

One thing adopt mode does not cover, and it is not a defect but it is a limit: parity. Parity lives in the 6551's command register, not its control register, and the driver rewrites that register from constants every time it arms or disarms the transmitter. Section 8 has the measurement.


1. The build under test

$ ./build.sh
wrote ../Modem_Wars_SwiftLink.d64: module installed in track 18 sector 7 and track 34 sectors 1-15
$ python3 checkAbi.py build/swiftlinkDriverE000.bin ../disassembly/build/game_modemDriverE000.orig.bin
bytes changed: 474 of 4096, in 27 region(s)
  $E04C-$E055  (10 bytes)
  $E057-$E05E  (8 bytes)
  $E327-$E331  (11 bytes)
  $E342-$E34F  (14 bytes)
  $E353-$E35E  (12 bytes)
  $E38E-$E38F  (2 bytes)
  $E3C7-$E3CA  (4 bytes)
  $E402-$E406  (5 bytes)
  $E408-$E40E  (7 bytes)
  $E540-$E578  (57 bytes)
  $E587-$E587  (1 bytes)
  $E593-$E5BA  (40 bytes)
  $E5E8-$E5E8  (1 bytes)
  $E5EC-$E5F7  (12 bytes)
  $E607-$E61E  (24 bytes)
  $E620-$E620  (1 bytes)
  $E622-$E636  (21 bytes)
  $E638-$E63A  (3 bytes)
  $E63C-$E63D  (2 bytes)
  $E640-$E642  (3 bytes)
  $E645-$E670  (44 bytes)
  $E672-$E684  (19 bytes)
  $E688-$E6CF  (72 bytes)
  $E6D1-$E714  (68 bytes)
  $E716-$E732  (29 bytes)
  $E73C-$E73E  (3 bytes)
  $E740-$E740  (1 bytes)

ABI CHECK PASSED: every address the rest of the game depends on is unchanged

Every changed byte is inside the UART layer or the link-control code that owns it: the ACIA accessors and their page latch, loadBaudParameters, the baud hot keys, the DTR toggle that used to poke CIA2, the NMI handler, baudEntryTable, and the two operands ADDRESS_CHANGES.md names - $E38E-$E38F, the JSR dropDtrLine inside hangUpModem, and $E3C7-$E3CA, the error-vector pair. The three frozen regions - the $E000 jump table, the shared variables at $E01D-$E047 and the whole of $EC00-$EFFF that the map-generator overlay calls - are byte-identical to the stock module.

2. The rig, and how "true C64 speed" is established

Two x64sc processes, each on its own private Xvfb display with its own private vicerc, their serial lines joined by a TCP null modem that counts bytes per direction (testTwoMachines.NullModemRelay). The host serial rate is set to 38400 on both ends so that it can never be the thing limiting the line; the ACIA control register is what these tests are about.

  • Warp is used only for the loader. After bootPastLoader both emulators are stopped, warp off is issued, and warp is read back - every run in this report printed Warp mode is off. on both machines before anything was measured.
  • Every measured window is bracketed by stopwatch readings taken with both emulators stopped, so the two counts cover the same interval. Emulated cycles divided by 1,022,727 Hz divided by wall seconds is the speed figure quoted throughout. 75-second windows read 100.5% to 101.6%; 20-second windows read 102.0% to 103.6%, which is pause-and-resume overhead landing inside a shorter window rather than the emulator running fast.
  • Nothing writes a driver byte. The only monitor writes anywhere in this report are to the ACIA's own registers (section 5), which is what a user's hardware configuration would have left there, and the baudIndex writes testStack.py uses to pick a rate without a keystroke.

A caution that cost a measurement. The first testGuard.py run in this session issued its warp off to a running emulator rather than a stopped one, and did not read the setting back; what came out of it was a trace whose cycle stamps span 62.3 emulated seconds inside an eight-second read window, i.e. the machine was running about eight times real time. The likely mechanism is that a line sent to a running emulator is consumed by the monitor's break-in, so the command never executed - that was not chased down, because the fix is the same either way: the script now does what testHighRates.py always did (stop both machines, set it, read it back) and brackets its window with the cycle counters, so the run says for itself. Both were verified on the re-runs. The lost run is reported anyway, in section 4.3, because of what it accidentally measured.

3. Every rate, including 38400

testHighRates.py, one boot per rate, both machines given the same rate with the driver's own hot key at the "PRESS A OR O" prompt, three windows per boot:

  • clean, 75 s, no tracepoints at all, so the byte counts are honest throughput;
  • sync, 75 s, with tracepoints on beginByteSyncPhase $E805 and on sta ackPending $E855, which count degradations and recoveries directly;
  • errors, 20 s, with countLinkError $E73F and the conditional $E505 if x > $13, which separate "a character was rejected" from "the 20-byte ring was full".

Then uartRxCount $E42D, connectionPhase $E040, linkErrorCount $E047, txCharActive $E5BB and aciaCommandShadow $E5BE are sampled 24 times per machine with the emulator stopped.

3.1 What the numbers actually say

Every rate carried the same traffic: about 9 bytes a second in each direction, 250-odd $55 frame-lead bytes per 75-second window, at 1200 baud and above. That is the game, not the line - the two machines sit at the GAME TYPE menu exchanging the lock-step ARQ frames the protocol keeps alive, and the rate the wire runs at makes no difference to how many of them there are. It is the premise of this whole exercise, measured: 300 baud already carries everything the game produces.

300 baud is the exception, and in the other direction: it moved 29.6 B/s, which is the 8N1 ceiling of a 300-baud line to within a byte, and counted three times as many $55 lead bytes per window as any faster rate (871 against 253). So the slowest rate is the one working hardest. Why the frame count goes up when the line slows down was not traced; the honest statement is that it did, with zero receive errors and zero ring overflows while it happened.

Rate clean window, A -> B / B -> A $55 lead bytes per-second range phases seen (A / B)
300 2228 B = 29.64 B/s / 2235 B = 29.74 B/s 871 / 781 26.0-31.0 / 26.7-31.0 [2,3] / [2,3]
1200 729 B = 9.70 B/s / 737 B = 9.81 B/s 268 / 254 5.9-26.0 / 5.9-57.0 [2,3] / [2,3]
2400 675 B = 8.98 B/s / 675 B = 8.98 B/s 253 / 253 5.9-12.0 / 8.9-9.0 [2,3] / [3]
4800 699 B = 9.30 B/s / 697 B = 9.27 B/s 258 / 261 5.9-24.0 / 5.9-23.0 [3] / [2,3]
9600 700 B = 9.31 B/s / 694 B = 9.23 B/s 258 / 261 5.9-15.0 / 5.9-17.0 [3] / [3]
19200 675 B = 8.98 B/s / 675 B = 8.98 B/s 253 / 253 8.9-9.0 / 8.9-9.0 [2,3] / [3]
38400 678 B = 9.02 B/s / 678 B = 9.02 B/s 254 / 254 5.9-12.0 / 5.9-12.0 [3] / [3]

connectionPhase 2 appearing on one machine is a byte-sync round in progress, and every one of them was followed by phase 3 in a later sample. At 300 and 1200 both machines were caught in one; at 9600 and 38400 neither ever was.

3.2 The errors, both of them

Across the seven rate boots - 1193.8 seconds of measured link time, 1439.3 s counting the second 38400 boot - there were two characters rejected by the receiver and two receive-ring overflows, and they are the same two events: one on each machine, both at 1200 baud, in the 20-second instrumented window. The unconditional $E73F tracepoint and the conditional $E505 if x > $13 returned identical counts, which is what says the character was thrown away because the 20-byte ring was full rather than because its status was bad. Both machines carried on; neither left the packet phase because of it. That is the same signature rate1200Report.md recorded, at the same rate, and 1200 is still the first rate at which a burst can outrun the ring.

No rate above 1200 produced a single receive error of any kind. On the previous build, 9600 and 19200 produced 8-41 and 74-128 ring overflows in the same 20-second window.

3.3 The transmit-side loss is gone as well

VICE writes Acia1: ACIA: data register written although data has not been sent yet. into its own log every time the driver writes the data register before the previous character has left - one transmitted character lost each time, on emulation and on a real 6551 alike. On the previous build that scaled with rate: 0.8% of offered characters at 4800, 2.1% at 9600, 2.2-2.3% at 19200.

Every emulator log in this report contains zero of them, at every rate, including both 38400 boots. wedgeDiagnosis.md section 4 predicted exactly that: it traced the overwrite to a pass of the NMI handler being interrupted between lda txCharActive $E6C4 and dec uartPendingCount $E6C9, so an inner pass fed the chip and the outer pass fed it again. With the guard, no pass is ever interrupted there.

No emulator log in this report contains Main CPU: JAM either.

4. 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 the byte and returns - and because it 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.

4.1 The stack: 200 seconds at 38400, nothing below $01B4

testStack.py, two machines at 38400, both in the packet phase, four checkpoints armed before the link was ever opened and left armed for a 200-second watch. 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 What it catches Result
trace exec e685 if sp < $b4 any entry to commNmiHandler one frame deeper than the deepest healthy entry ever recorded 0 hits on A, 0 hits on B
trace store $0100 $0180 the stack descending 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

At the end of the watch both machines were running game code (PC $12A4 and $12E5) with SP = $E9 and, in the processor status byte, the interrupt flag CLEAR (10100000 and 00100000) - so both raster IRQs were still alive and still calling the driver's $E00C tick. Page 1 from $0100 upwards holds nothing but the flat $00/$FF pattern that was in it before the link opened; there are no handler frames in it below $01D0 at all.

An earlier boot with the same instrument, pcst384, gave the same result - 0 deep entries, no break, SP $E9 on both - but it predates the speed check added to the script, so its 200 seconds are not independently proven to be real-time seconds. It is counted as a boot in section 4.4 and nothing else rests on it.

On the previous build the same instrument caught three runaways in two boots, each one taking the stack pointer from about $B3 to $00-$08 in 8.4, 11.5 and 12.5 milliseconds, and one machine was stopped mid-descent with 34 complete nested frames still decodable off page 1. The wedged machines in wedgeDiagnosis.md section 5 were found with the interrupt flag set in 124 readings out of 124, which is the state the two readings above are the opposite of.

4.2 The counter: it is entered, it never nests here, and it always comes back to zero

testGuard.py puts a tracepoint on the handler's entry $E685 and one on nmiLeaveNote $E6F0, the instruction a nested pass executes instead of running the body:

Run Rate Window Handler entries Nested nmiHandlerState after linkErrorCount
pcgd384c A 38400 91.00 emulated s (101.1% of real time) 1632 0 $00 0
pcgd384c B 38400 90.70 emulated s (100.7%) 1620 0 $00 0
pcgd384b A 38400 10.80 emulated s (132.7%) 175 0 $00 0
pcgd384b B 38400 8.81 emulated s (108.3%) 144 0 $00 0
pcgd192b A 19200 9.08 emulated s (111.2%) 156 0 $00 0
pcgd192b B 19200 8.78 emulated s (107.6%) 144 0 $00 0

Eighteen handler entries a second at both rates, which is what nine bytes a second in each direction should produce - one RDRF and one TDRE per character. Both machines were in connectionPhase 3 at both edges of every window, and nmiHandlerState $E5BD read $00 every time it was read.

The 90-second window is worth reading closely, because it says how close the margin actually is. Every trace line carries the emulator's cycle counter, so the gap between consecutive entries can be measured directly:

A B
entries 1632 1620
gaps below 300 cycles 817 1027
median of those gaps 266 cycles 266 cycles
smallest gap seen 224 222
gaps below 178 cycles (one cheap pass) 0 0
stack pointer at entry min $D9, max $F4 min $D7, max $F0

266 cycles is exactly one character time at 38400, and half of all the gaps are that value, so the interrupts really do arrive back to back at the rate wedgeDiagnosis.md priced - a burst of consecutive characters, each one asking for its own interrupt. Against them stands a handler that costs 175 cycles for a single half and 256-266 for a pass that services both. The margin is between zero and ninety cycles, and in these 3252 entries it was never lost: no gap fell below 178, nothing nested, and the stack pointer stayed within one frame of the main line.

That is the real shape of the fix. 38400 was never dangerous because it is fast; it was dangerous because losing that race once corrupted uartPendingCount and the byte in the transmitter, which produced a resync flood, which produced the saturation, which produced the runaway. The guard makes losing the race cost a six-byte note instead of a nested pass, so the first link in that chain is gone.

(The two eight-second windows read 108-133% of real time. They open immediately after a monitor pause and VICE runs fast for a moment afterwards; the 90-second window and every window in section 3 do not show it. Nothing in this table depends on wall-clock speed anyway - entries, nesting, gaps and the guard byte are all counted in the emulated domain.)

4.3 The accidental stress test, and what it is worth

The lost run described in section 2 - the one that ran eight times real time - is the only measurement in this session in which the guard was actually used, and it is worth keeping for that reason. Why it 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 that true speed never produces. Whatever the reason, the numbers below are the numbers, and they are the ones the guard exists for:

Machine Entries to $E685 Nested ($E6F0) Emulated span Entry rate SP at entry Gap between entries
A 3050 260 (8.5%) 62.30 s 49/s min $D0, max $F0 min 58 cycles, median 540 *
B 1685 145 (8.6%) 38.95 s 43/s min $D6, max $F4 min 58 cycles, median 271 *

* median taken over the gaps below 100000 cycles, i.e. excluding the quiet stretches between bursts.

Read the two things that matter. Second NMIs really do arrive inside a running pass - a 58-cycle gap against a handler that costs 175 to 266 cycles 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 that the previous build crossed on its way to zero. Both machines ended the window in the packet phase with linkErrorCount = 0 and nmiHandlerState = $00.

On the previous build every one of those 405 nested entries would have been a full re-entrant pass with a status read in it.

4.4 Seven boots at 38400, no wedge

Boot What it did Outcome
pc384 testHighRates.py, 170.4 s of measured windows healthy, phase 3 in all 24 ring samples
pc384b testHighRates.py, 245.5 s of measured windows healthy, phase 3 in all 24 ring samples
pcst384b testStack.py, 200.9 s at 100.4% of real time no deep entry, no break, I clear at the end
pcst384 testStack.py, the same watch before the speed check was added no deep entry, no break
pcgd384 testGuard.py, the unlimited run of section 4.3 405 nested passes absorbed, still linked
pcgd384b testGuard.py, 8 s at true speed 319 entries, none nested
pcgd384c testGuard.py, 90 s at true speed 3252 entries, none nested, both still in phase 3

The previous build wedged four boots out of four, every one inside the first minute, one of them ending with *** Main CPU: JAM at $0007.

5. Adopt mode, two machines, before and after

testAdoptPair.py. Both machines are booted to the "PRESS A OR O" prompt with the opponent module resident - which is the last moment at which the chip has not been touched, confirmed by aciaPageLatch $E5BF = $00 and the adopt entry $E726 = $00 on both machines - and then the 6551's control register is written from the monitor, exactly as a C64 Ultimate or a previously-configured cartridge would have left it. Then the link is opened and measured.

5.1 Two stop bits at 2400 baud, and even parity in the command register

Preset: control $98, command $69.

                  $DE03  $DE02  baudIndex  bitPeriodLo  adoptSlot  pageLatch
 A at the prompt   $00    $00      $15         $50         $00        $00
 A after preset    $98    $69      $15         $50         $00        $00
 A link opened     $98    $05      $15         $98         $98        $DE
 A in phase 3      $98    $09      $15         $98         $98        $DE
 A after 75 s      $98    $09      $15         $98         $98        $DE
 B at the prompt   $00    $00      $15         $50         $00        $00
 B after preset    $98    $69      $15         $50         $00        $00
 B link opened     $98    $09      $15         $98         $98        $DE
 B in phase 3      $98    $05      $15         $98         $98        $DE
 B after 75 s      $98    $09      $15         $98         $98        $DE

$98 is 2400 baud, 8 data bits, two stop bits, internal generator - a byte the driver's own table does not contain and would never have chosen. It came through untouched. baudIndex stayed at $15 = 21, the adopt entry (the disk default, so nothing had to be pressed); aciaProbeAdopted $E655 parked the adopted byte in baudEntryTable's eighth entry at $E726; loadBaudParameters wrote it to the chip and kept the live copy in bitPeriodLo $E056.

And the link works on it: 75.20 s, 677 and 675 bytes = 9.00 and 8.98 B/s, not one of the 148 one-second sampling intervals carrying nothing (5.9-12.0 B/s one way, 8.9-9.0 the other), both machines in connectionPhase 3, A and B running at 101.0% and 100.6% of real time.

The command register is the other half of that dump and section 8 is about it.

5.2 A note on baudIndex $E055 = $15

Two different $15s meet in these dumps and it is worth separating them once. baudIndex $15 is 21 decimal, the byte offset of the eighth entry of baudEntryTable - the adopt entry. Control register $15 is 300 baud, 8N1, internal generator. They are unrelated, and section 7 has both of them in the same dump at the same time.

6. The eight-data-bit guard

Preset: control $B8 = 2400 baud, 7 data bits, two stop bits, internal generator.

                  $DE03  baudIndex  bitPeriodLo  adoptSlot  pageLatch
 A after preset    $B8      $15         $50         $00        $00
 A link opened     $98      $15         $98         $98        $DE
 B after preset    $B8      $15         $50         $00        $00
 B link opened     $98      $15         $98         $98        $DE

$B8 -> $98: bits 6-5 went from %01 (7 data bits) to %00 (8 data bits), and nothing else in the byte moved. Bit 7 (the second stop bit) survived, bit 4 (the internal clock source) survived, and bits 3-0 (2400 baud) survived. That is the and #$9F at $E665 inside aciaProbeAdopted, and it is the only place in the module where a control byte is masked - the seven fixed entries are constants that already have those bits clear. Link: 75.15 s, 675 bytes each way = 8.98 B/s, 8.9-9.0 B/s in every one of the 148 one-second intervals, none of them empty, both machines in phase 3.

The single-machine table in testAdopt.py covers the other word lengths on the same build, 46 of 46 checks passing: $5A (six data bits at 4800) -> $1A, $3C (seven data bits at 9600) -> $1C, $9F (38400 with two stop bits) -> $9F unchanged, $18 -> $18 unchanged.

Why eight data bits are mandatory

This is a statement about the source, not a measurement, and it cannot be measured on this rig - VICE's ACIA is byte level, so it will not truncate a character to seven bits no matter what the control register says. The argument does not need a 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 would never leave connectionPhase 2: red border, never green, which is the symptom README.md section 4 describes.

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 (ASL A / ADC #$00 / ADC packetBuffer,x), and the length code the frame layer leads with is $55, with the sequence bytes that follow it taking any value at all. Parity and stop bits cost the frame layer nothing - they change how a character is delivered, not what is in it - which is exactly why the mask at $E665 touches two bits and no others.

7. The unusable-value fallback

Preset: control $00, on both machines - what a 6551 reads after reset, and what VICE's cold ACIA reads on this rig without any help (see the "at the prompt" rows in section 5.1, which read $00 before anything was written).

                  $DE03  baudIndex  bitPeriodLo  adoptSlot  pageLatch
 A after preset    $00      $15         $50         $00        $00
 A link opened     $15      $15         $15         $15        $DE
 B after preset    $00      $15         $50         $00        $00
 B link opened     $15      $15         $15         $15        $DE

(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. $00 is both. The driver replaced it with $15: 300 baud, 8N1, internal generator.

It did not hang. Both machines opened the link and carried traffic for the full window: 75.16 s, 769 and 768 bytes = 10.23 and 10.22 B/s, 0 of 148 one-second intervals empty, linkErrorCount = 0 in all 24 samples, uartRxCount never above 1.

The per-second range is the independent check that the rate really did change: 7.9 to 30.0 B/s one way and 6.9 to 30.0 the other, where every window at 1200 baud and above sat between 5.9 and 12.0. 30.0 B/s is the 8N1 ceiling of a 300-baud line, so the fallback is not just a byte in a register - the wire is running at it. connectionPhase oscillated between 2 and 3 on both machines during the sampling, which is what 300 baud does on this rig (the dedicated 300-baud rate run in section 3 shows the same pattern on both machines), and traffic never stopped while it did.

The single-machine table adds the two other unusable shapes on the same build: $0C (9600 baud but bit 4 clear) -> $15, and $10 (internal clock but no rate bits at all) -> $15.

8. What adopt mode does not cover: parity, and the command register

In section 5.1 the command register was pre-set to $69 - DTR on, receiver interrupt on, parity enabled, even - alongside the control register. After the link opened it read $05 and then $09. The parity bits were cleared.

That is not a bug in the adopt path; it is a consequence of where a 6551 keeps parity. Bits 7-5 of the command register select it, and the driver writes that register from constants at four sites: $E546 and $E5A4 write $09 (DTR on, RTS low, receiver IRQ on, transmit IRQ off), $E5A9 writes $0B (the same with the receiver IRQ off, for the disk-load suspend), and $E63F writes $05 (the transmit interrupt armed). Every one of those has bits 7-5 = %000 = parity disabled. The one write that is not a constant - the C= + H DTR toggle at $E327, which EORs bit 0 of aciaCommandShadow - only ever carries forward what those constants left there. And the register cannot be part-written: DTR, RTS and both interrupt enables live in the same byte, so arming the transmitter necessarily rewrites the parity bits too.

Consequences, stated plainly.

  • A port configured for parity will run without it. Both ends of a Modem Wars link run this same driver and both clear those bits identically, so the two agree and the link works - which is what section 5.1 measured with $69 pre-set on both machines.
  • What it would break is a link to something that is not this driver and does expect parity. There is no such peer: the only other implementation is the stock user-port driver, which is 8N1 only.
  • Stop bits are adopted and parity is not, so spec_uart.md hazard 8's "same 8N1 framing" is now only true of the data bits. Two SwiftLink ends will agree on whatever the control register said; a stock user-port peer can only ever be 8N1, so an adopted $98 will not talk to one.

Making parity adoptable would mean keeping the user's bits 7-5 in a shadow and OR-ing them into all four command writes. That was not done and was not measured; it is recorded here as the honest edge of what "adopt" currently means.

9. No regression

9.1 Both straps still detect and latch

testStrap.py, one machine, watchpoints on $DE00-$DE03, $DF00-$DF03 and both register-mirror ranges armed before the module can touch anything.

$DE00 strap - the cartridge answers on the default page:

load  $DE03  LDA $DE03 at $E56B  A=$00   aciaProbeAdopted reads the user's setting FIRST
store $DE03  STA $DE03 at $E567  A=$1E   probe pattern 1
load  $DE03  LDA $DE03 at $E56B  A=$1E   it matches
store $DE03  STA $DE03 at $E567  A=$15   probe pattern 2
load  $DE03  LDA $DE03 at $E56B  A=$15   it matches too - latch = $DE
store $DE03  STA $DE03 at $E567  A=$15   loadBaudParameters writes the adopted byte ($00 was
load  $DE03  LDA $DE03 at $E56B  A=$15     unusable, so the $15 fallback)
load  $DE00  LDA $DE00 at $E55F  A=$00   drain the receiver
load  $DE01  LDA $DE01 at $E558  A=$50   status
store $DE02  STA $DE02 at $E554  A=$09   command

$DF00 strap - SWIFTLINK_ACIA_BASE=0xDF00, nothing at $DE00:

load  $DE03  LDA $DE03 at $E56B  A=$F6   the adopt read on the default page: open bus
store $DE03  STA $DE03 at $E567  A=$1E   probe pattern 1
load  $DE03  LDA $DE03 at $E56B  A=$F6   not $1E - this page is not a 6551
load  $DF03  LDA $DF03 at $E56B  A=$00   the fallback reads the REAL chip's setting
store $DF03  STA $DF03 at $E567  A=$1E   probe pattern 1 at $DF00
load  $DF03  LDA $DF03 at $E56B  A=$1E   it matches
store $DF03  STA $DF03 at $E567  A=$15   probe pattern 2
load  $DF03  LDA $DF03 at $E56B  A=$15   it matches too - latch = $DF
store $DF03  STA $DF03 at $E567  A=$15   loadBaudParameters, adopted $15 again

That second block is the one adopt mode changed: the $DF00 fallback now reads the page before it writes to it, so a user whose cartridge is strapped to $DF00 gets their own settings adopted from their own chip and not from the open bus at $DE00. The $F6 the $DE00 read returned is a value probeOpenBus.py recorded in half its samples of an empty $DE03; it is briefly parked in the adopt slot and then overwritten by the $DF00 read, which is the whole reason the fallback calls the same routine instead of only aiming at the page.

On both straps:

$DE00 run $DF00 run
all six accessors' operand high bytes ($E556, $E55A, $E561, $E565, $E569, $E56D) $DE $DF
aciaPageLatch $E5BF $DE $DF
the cartridge page read back after the link opened $DE00-$DE03 = 00 10 05 15 $DF00-$DF03 = 00 10 09 15
the other page read back $DF00-$DF03 = F6 F6 F6 F6 (open bus) $DE00-$DE03 = F6 F6 F6 F6
the NMI vector $FFFA $E685 $E685
accesses to the mirror ranges $DE04-$DEFF / $DF04-$DFFF 0 0
stops in a further 45 s with only the mirror tripwires armed 0 0
the game reached the modem prompts and opened a link yes yes

One byte in those dumps needs a word of warning, because it looks alarming and is not: nmiHandlerState $E5BD read $01 in both strap runs. Every watchpoint in this test is on an ACIA register, and almost every ACIA register access this driver makes is made from inside commNmiHandler - so the emulator was stopped inside a pass, which is exactly when that byte is supposed to be 1. The reading that says the guard byte rests at zero is the one in section 4.2, taken with the machine stopped by nothing but a monitor pause.

9.2 1200 baud, two machines, still healthy

The 1200-baud row of section 3 is that run: testHighRates.py, one boot, both machines on C= + 1, 170.3 s of measured link time across three windows, both machines at 100.5-103.5% of real time. 729 and 737 bytes in the clean window (9.70 and 9.81 B/s), 0 of 334 sampling intervals carrying nothing, two byte-sync rounds entered on A and six on B with three completions each, and the two receive-ring overflows of section 3.2 - one per machine - recovered from. uartRxCount reached 14 on B and never the 20-byte limit. Both machines were found in connectionPhase 3 in most of the 24 samples and in phase 2 in the rest, which is a byte-sync round in progress; every one of them was followed by a phase-3 reading.

10. What this does not show

  • VICE, not hardware. No physical SwiftLink, no real cable, and no C64 Ultimate - the device this whole exercise is about has still never been touched by any of it.
  • 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. Two consequences: "zero characters rejected at every rate above 1200" is a statement about this emulation, and the eight-data-bit argument in section 6 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. Everything in sections 5 to 7 assumes the hardware presents its configuration in the 6551 control register the way VICE and a real SwiftLink do. If an Ultimate presents $00 there while actually running at some other rate, this driver will adopt 300 baud and be wrong, and nothing here can tell you whether it does.
  • The traffic is the menu, not a game. Both machines reach the GAME TYPE menu and stay there, as in every earlier report in this directory. Nine bytes a second in each direction is what that produces. A running battle may produce more, and the rates were not measured under it.
  • 38400 is healthy at this traffic, not at any traffic. Section 4.2 is explicit: at nine bytes a second the handler is entered eighteen times a second and, in 3252 entries, never nested - but half of those entries were one character time apart, so the margin measured there is between zero and ninety cycles. The arithmetic that killed the old build - two interrupts per 266-cycle character time against a 178-cycle handler - still says a saturated 38400 line costs more CPU than a C64 has. What the guard changes is what happens then: the receive ring overflows and the ARQ layer resynchronises, instead of the stack eating page
    1. That degradation path was exercised at 1200 in this report and at 9600/19200 on the previous build; it was never provoked at 38400 here, because nothing in this game saturates a 38400 line.
  • One boot per rate, except 38400, which got seven. 170 seconds is enough to say a link keeps moving; it is not enough to say it always will.
  • Parity is not adopted (section 8), and no measurement here says what a device that really is configured for parity would do with a driver that turns it off.
  • The uartPendingCount $E0A5 repair probe never ran, because testHighRates.py only runs it when a direction carries zero bytes for a whole window, and no direction ever did.

11. Files

File What it is
testHighRates.py the rate sweep: hot-key rate selection, clean and instrumented windows, ring sampling, the wedge dump
testAdoptPair.py new: two machines, a pre-programmed control (and optionally command) register, before/after dumps and a measured window
testAdopt.py the single-machine preset table, the 38400 hot key and C= + U
testGuard.py entries to commNmiHandler and how many of them were nested; now forces warp off with both machines stopped and brackets its window with the cycle counters
testStack.py the deep-nesting tracepoint and the stack-page checkpoints; same warp and speed fix
testStrap.py both straps, with watchpoints on both cartridge pages and their mirrors
testLogs/portCompat300.txt ... portCompat38400.txt the seven rate runs
testLogs/portCompat38400run2.txt the second 38400 boot, four windows, 245.5 s
testLogs/portCompatAdopt98.txt, ...AdoptB8.txt, ...Adopt00.txt the three two-machine adopt runs
testLogs/portCompatAdoptTable.txt the single-machine table, 46 of 46 checks
testLogs/portCompatStack38400b.txt the 200-second stack watch, with its own speed reading
testLogs/portCompatStack38400.txt the same watch, one boot earlier
testLogs/portCompatGuard38400b.txt, ...Guard19200b.txt, ...Guard38400c.txt the guard counts at true speed
testLogs/portCompatGuard38400.txt, testLogs/stack.pcgd384.[AB].deep.txt the unlimited-speed run of section 4.3 and its raw trace
testLogs/stack.pcgd384c.[AB].deep.txt the 90-second trace behind the inter-entry gap table in section 4.2
testLogs/portCompatStrapDe00.txt, ...StrapDf00.txt the two strap runs
testLogs/highRate.pc*.samples.txt / .trace.txt / .ring.txt per-second byte counts, raw tracepoint output and the ring samples behind section 3
shots/hrpc*.png, shots/apap*.png, shots/pcadopt*.png, shots/pcstrapD*.png, shots/stpcst384*.png both machines at every stage of every run